Compare commits

...
27 Commits
Author SHA1 Message Date
3252a8andGitHub 1a66d67e44 Merge pull request #12 from 3252a8/dev
Telegram delivery tracking, subscription lifecycle mirroring and per-tariff referral bonuses
2026-05-30 22:28:57 +03:00
3252a8 c1e2fe2c95 chore: improve admin logs user cards 2026-05-30 22:15:33 +03:00
3252a8 ea4ee4c4a7 fix: restore admin content scrolling 2026-05-30 21:49:40 +03:00
3252a8 fbc3e193bf chore: improve custom scroll areas 2026-05-30 21:41:32 +03:00
3252a8 3541f2f78b feat: block crawlers from production webapp 2026-05-30 21:25:03 +03:00
3252a8 acc222da41 feat: prompt users to start Telegram bot for notifications 2026-05-30 21:15:17 +03:00
3252a8 8c0e778388 chore: standardize web controls 2026-05-30 12:16:02 +03:00
3252a8 c6c5352813 fix: format premium traffic limits from bytes 2026-05-30 12:00:01 +03:00
3252a8 067d6fb59f chore: clarify panel webhook subscription logs 2026-05-30 11:52:07 +03:00
3252a8 c3e55bc853 feature: sync user email notifications 2026-05-30 11:46:07 +03:00
3252a8 09be82aad5 fix: improve subscription email renewal flow 2026-05-30 00:23:53 +03:00
3252a8 49781af921 feat: surface remnawave panel webhook settings 2026-05-29 23:55:12 +03:00
3252a8 604ba873dc docs: clarify remnawave panel webhook setup 2026-05-29 23:54:35 +03:00
3252a8 923ff9b208 fix: hide inactive devices usage summary 2026-05-29 23:23:55 +03:00
3252a8 7e7a2e58a2 fix: show telegram login without email auth 2026-05-29 23:09:49 +03:00
3252a8 e27886e15d docs: update referral bonus tariff docs 2026-05-29 23:00:18 +03:00
3252a8 7fe8e676cd feat: group referral bonus display by tariff 2026-05-29 22:55:01 +03:00
3252a8 6803c7801f fix: configure referral bonuses per tariff period 2026-05-29 22:30:31 +03:00
3252a8 cab963dcdc fix: prevent subscription worker deadlocks 2026-05-29 22:07:16 +03:00
3252a8 001e54cfe2 fix: preserve subscriptions on panel lookup failures 2026-05-29 21:46:42 +03:00
3252a8 5e257c0d3a chore: enforce frontend formatting line endings 2026-05-29 18:14:35 +03:00
3252a8 19f0f27a3b feat: mirror subscription lifecycle notifications 2026-05-29 18:08:28 +03:00
3252a8 6fbb8eebec fix: serve docs demo runtime in dev 2026-05-29 18:04:00 +03:00
3252a8 32616c80ab docs: add notifications table 2026-05-29 17:50:30 +03:00
3252a8 3186be1e3e docs: update deployment examples 2026-05-29 15:33:09 +03:00
3252a8 1329eb4fe2 fix: avoid docs demo redirect loop 2026-05-28 23:44:52 +03:00
3252a8 913b2d428e fix: handle docs demo app route 2026-05-28 23:33:40 +03:00
136 changed files with 6196 additions and 1034 deletions
+4 -1
View File
@@ -56,7 +56,10 @@ PANEL_API_URL=https://panel.yourdomain.tld/api
PANEL_API_KEY= PANEL_API_KEY=
# Shared secret for validating incoming Remnawave webhooks. # 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= PANEL_WEBHOOK_SECRET=
# Host port that publishes the backend webhook server from Docker Compose. # Host port that publishes the backend webhook server from Docker Compose.
+6
View File
@@ -1,2 +1,8 @@
.gitattributes text eol=lf
*.sh text eol=lf *.sh text eol=lf
deploy/docker/frontend/*.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
+3 -1
View File
@@ -92,11 +92,13 @@ docker compose logs -f backend worker frontend
- `PANEL_API_URL`, `PANEL_API_KEY`, `PANEL_WEBHOOK_SECRET` - доступ к Remnawave; - `PANEL_API_URL`, `PANEL_API_KEY`, `PANEL_WEBHOOK_SECRET` - доступ к Remnawave;
- остальные настройки удобнее задать в Web App админке. - остальные настройки удобнее задать в Web App админке.
В 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). После первого входа в админку настройте тарифы, платежные провайдеры, внешний вид, поддержку, уведомления и инструкции подключения через 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). Для каталога тарифов используется `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 ```bash
mkdir -p data/themes data/webapp-logo data/webapp-emoji mkdir -p data/themes data/webapp-logo data/webapp-emoji
+46 -9
View File
@@ -143,12 +143,18 @@ def _payment_traffic_gb_split(payment: Payment) -> Tuple[Optional[float], Option
return None, None return None, None
def _payment_user_display_label(loaded_user: Any, payment_user_id: int) -> str: def _user_display_label(
"""Human-facing name for payments tables: TG profile name, else email, else user id.""" loaded_user: Any,
if loaded_user is None: fallback_user_id: Optional[int],
return str(payment_user_id) *,
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) 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() fn = (getattr(loaded_user, "first_name", None) or "").strip()
ln = (getattr(loaded_user, "last_name", None) or "").strip() ln = (getattr(loaded_user, "last_name", None) or "").strip()
full = f"{fn} {ln}".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() un = (getattr(loaded_user, "username", None) or "").strip()
if un: if un:
return un if un.startswith("@") else f"@{un}" return un if un.startswith("@") else f"@{un}"
return str(payment_user_id) elif loaded_user is not None:
email = (getattr(loaded_user, "email", None) or "").strip() email = (getattr(loaded_user, "email", None) or "").strip()
if email: if email:
return 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) 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]: 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 { return {
"log_id": int(entry.log_id), "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_username": entry.telegram_username,
"telegram_first_name": entry.telegram_first_name, "telegram_first_name": entry.telegram_first_name,
"email": getattr(getattr(entry, "author_user", None), "email", None), "email": getattr(author_user, "email", None),
"event_type": entry.event_type, "event_type": entry.event_type,
"content": entry.content, "content": entry.content,
"is_admin_event": bool(entry.is_admin_event), "is_admin_event": bool(entry.is_admin_event),
"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, "timestamp": entry.timestamp.isoformat() if entry.timestamp else None,
} }
+96 -66
View File
@@ -30,6 +30,11 @@ class SettingField:
i18n_label_key: Optional[str] = None i18n_label_key: Optional[str] = None
i18n_description_key: Optional[str] = None i18n_description_key: Optional[str] = None
i18n_subsection_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] = [ SETTINGS_MANIFEST: List[SettingField] = [
@@ -84,44 +89,44 @@ SETTINGS_MANIFEST: List[SettingField] = [
SettingField( SettingField(
"PANEL_API_URL", "PANEL_API_URL",
"url", "url",
"general", "remnawave",
"URL API Remnawave", "URL API Remnawave",
"Например, https://panel.example.com/api.", "Например, https://panel.example.com/api.",
subsection="Remnawave",
), ),
SettingField( SettingField(
"PANEL_API_KEY", "PANEL_API_KEY",
"string", "string",
"general", "remnawave",
"API-ключ Remnawave", "API-ключ Remnawave",
"Секретный ключ API панели.", "Секретный ключ API панели.",
secret=True, secret=True,
subsection="Remnawave",
), ),
SettingField( SettingField(
"PANEL_WEBHOOK_SECRET", "PANEL_WEBHOOK_SECRET",
"string", "string",
"general", "remnawave",
"Секрет вебхуков Remnawave", "Секрет вебхуков Remnawave",
"Используется для проверки входящих вебхуков панели.", "Используется для проверки входящих вебхуков панели.",
secret=True, 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( SettingField(
"USER_SQUAD_UUIDS", "USER_SQUAD_UUIDS",
"string", "string",
"general", "remnawave",
"Internal Squads по умолчанию", "Internal Squads по умолчанию",
"UUID через запятую для legacy-режима без JSON-каталога тарифов.", "UUID через запятую для legacy-режима без JSON-каталога тарифов.",
subsection="Remnawave",
), ),
SettingField( SettingField(
"USER_EXTERNAL_SQUAD_UUID", "USER_EXTERNAL_SQUAD_UUID",
"string", "string",
"general", "remnawave",
"External Squad по умолчанию", "External Squad по умолчанию",
"Необязательный UUID External Squad для новых пользователей.", "Необязательный UUID External Squad для новых пользователей.",
subsection="Remnawave",
), ),
# ─── Web app appearance ──────────────────────────────────────── # ─── Web app appearance ────────────────────────────────────────
SettingField( SettingField(
@@ -230,6 +235,70 @@ SETTINGS_MANIFEST: List[SettingField] = [
SettingField("STARS_PRICE_3_MONTHS", "int", "pricing", "Цена 3 мес. (Stars)"), SettingField("STARS_PRICE_3_MONTHS", "int", "pricing", "Цена 3 мес. (Stars)"),
SettingField("STARS_PRICE_6_MONTHS", "int", "pricing", "Цена 6 мес. (Stars)"), SettingField("STARS_PRICE_6_MONTHS", "int", "pricing", "Цена 6 мес. (Stars)"),
SettingField("STARS_PRICE_12_MONTHS", "int", "pricing", "Цена 12 мес. (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( SettingField(
"TRAFFIC_PACKAGES", "string", "pricing", "Пакеты трафика", "Формат: 10:199,50:799 (ГБ:цена)" "TRAFFIC_PACKAGES", "string", "pricing", "Пакеты трафика", "Формат: 10:199,50:799 (ГБ:цена)"
), ),
@@ -333,62 +402,6 @@ SETTINGS_MANIFEST: List[SettingField] = [
"REFERRAL_WELCOME_BONUS_DAYS", "int", "referral", "Приветственный бонус (дней)", min=0 "REFERRAL_WELCOME_BONUS_DAYS", "int", "referral", "Приветственный бонус (дней)", min=0
), ),
SettingField("LEGACY_REFS", "bool", "referral", "Поддержка старых ref-ссылок"), 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 ───────────────────────────────────────────── # ─── Notifications ─────────────────────────────────────────────
SettingField( SettingField(
"SUBSCRIPTION_NOTIFICATIONS_ENABLED", "SUBSCRIPTION_NOTIFICATIONS_ENABLED",
@@ -396,6 +409,13 @@ SETTINGS_MANIFEST: List[SettingField] = [
"notifications", "notifications",
"Включены уведомления о подписке", "Включены уведомления о подписке",
), ),
SettingField(
"SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED",
"bool",
"notifications",
"Дублировать уведомления о подписке на email",
"Письма отправляются только пользователям с привязанным email и рабочим SMTP.",
),
SettingField( SettingField(
"SUBSCRIPTION_NOTIFY_ON_EXPIRE", "bool", "notifications", "Уведомлять об истечении" "SUBSCRIPTION_NOTIFY_ON_EXPIRE", "bool", "notifications", "Уведомлять об истечении"
), ),
@@ -692,6 +712,7 @@ def manifest_payload() -> List[dict]:
sections_order = { sections_order = {
"general": 1, "general": 1,
"appearance": 2, "appearance": 2,
"remnawave": 3,
"pricing": 11, "pricing": 11,
"payments": 4, "payments": 4,
"trial": 5, "trial": 5,
@@ -758,6 +779,15 @@ def manifest_payload() -> List[dict]:
item["default"] = default_value item["default"] = default_value
if webhook_metadata: if webhook_metadata:
item.update(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: if field.choices:
item["choices"] = [ item["choices"] = [
{ {
@@ -16,6 +16,7 @@ from bot.app.web.webapp import (
routes as _routes, routes as _routes,
serializers as _serializers, serializers as _serializers,
support as _support, support as _support,
telegram_notifications as _telegram_notifications,
) )
_MODULES = ( _MODULES = (
@@ -25,6 +26,7 @@ _MODULES = (
_assets, _assets,
_auth, _auth,
_account, _account,
_telegram_notifications,
_serializers, _serializers,
_billing, _billing,
_devices, _devices,
+27
View File
@@ -107,6 +107,33 @@ WEBAPP_CSRF_COOKIE_NAME = "rw_webapp_csrf"
WEBAPP_TELEGRAM_OAUTH_STATE_COOKIE_NAME = "rw_tg_oauth_state" WEBAPP_TELEGRAM_OAUTH_STATE_COOKIE_NAME = "rw_tg_oauth_state"
WEBAPP_CSRF_HEADER_NAME = "X-CSRF-Token" WEBAPP_CSRF_HEADER_NAME = "X-CSRF-Token"
WEBAPP_STATE_CHANGING_METHODS = {"POST", "PUT", "PATCH", "DELETE"} 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 _APP_VERSION_CACHE: Optional[str] = None
WEBAPP_CSRF_EXEMPT_PATHS = { WEBAPP_CSRF_EXEMPT_PATHS = {
"/api/auth/telegram/nonce", "/api/auth/telegram/nonce",
+3
View File
@@ -8,6 +8,7 @@ from .auth import (
_sync_merged_panel_identity_for_user, _sync_merged_panel_identity_for_user,
) )
from .common import _invalidate_webapp_user_caches 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: 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: except Exception:
logger.exception("Failed to send account Telegram linked notification") 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)) token = create_webapp_session_token(settings, int(final_user_id))
response_payload: Dict[str, Any] = { response_payload: Dict[str, Any] = {
"ok": True, "ok": True,
+7
View File
@@ -23,6 +23,12 @@ async def health_route(request: web.Request) -> web.Response:
return web.json_response({"ok": True}) 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: async def css_asset_route(request: web.Request) -> web.Response:
return await _css_asset_route(request, base_name="subscription_webapp") 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("Referrer-Policy", "no-referrer")
response.headers.setdefault("X-Content-Type-Options", "nosniff") response.headers.setdefault("X-Content-Type-Options", "nosniff")
response.headers.setdefault("X-Robots-Tag", "noindex, nofollow, noarchive")
response.headers.setdefault( response.headers.setdefault(
"Permissions-Policy", "Permissions-Policy",
( (
+5
View File
@@ -1,6 +1,7 @@
# ruff: noqa: F401,F403,F405,I001 # ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405 from ._runtime import * # noqa: F403,F405
from .common import _invalidate_webapp_user_caches 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]: 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, 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)) token = create_webapp_session_token(settings, int(final_user_id))
response = web.HTTPFound(_telegram_oauth_redirect_url(redirect_path, status="success")) response = web.HTTPFound(_telegram_oauth_redirect_url(redirect_path, status="success"))
_clear_telegram_oauth_state_cookie(response) _clear_telegram_oauth_state_cookie(response)
@@ -531,6 +535,7 @@ async def auth_token_route(request: web.Request) -> web.Response:
return _json_error(500, "auth_failed", "Auth failed") return _json_error(500, "auth_failed", "Auth failed")
await _invalidate_webapp_user_caches(settings, authenticated_user_id, include_devices=True) 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)) token = create_webapp_session_token(settings, int(authenticated_user_id))
return _build_webapp_auth_response(settings, {"ok": True}, token=token) return _build_webapp_auth_response(settings, {"ok": True}, token=token)
+36 -8
View File
@@ -22,7 +22,13 @@ async def devices_route(request: web.Request) -> web.Response:
"devices", "devices",
user_id, user_id,
int(getattr(settings, "WEBAPP_DEVICES_CACHE_TTL_SECONDS", 5) or 0), 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: if isinstance(result, dict) and result.get("ok") is True:
return web.json_response({"ok": True, **(result.get("payload") or {})}) return web.json_response({"ok": True, **(result.get("payload") or {})})
@@ -45,16 +51,12 @@ async def _load_devices_payload(
subscription_service: SubscriptionService, subscription_service: SubscriptionService,
session: AsyncSession, session: AsyncSession,
user_id: int, user_id: int,
fallback_panel_user_uuid: Optional[str] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
active = await subscription_service.get_active_subscription_details(session, user_id) 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: if not panel_user_uuid:
return { return _empty_inactive_devices_payload()
"ok": False,
"status": 400,
"error": "subscription_not_active",
"message": "Subscription is not active",
}
panel_service = getattr(subscription_service, "panel_service", None) panel_service = getattr(subscription_service, "panel_service", None)
if not panel_service: if not panel_service:
@@ -82,6 +84,7 @@ async def _load_devices_payload(
"ok": True, "ok": True,
"payload": { "payload": {
"enabled": True, "enabled": True,
"subscription_active": _devices_subscription_is_active(active),
"current_devices": len(devices), "current_devices": len(devices),
"max_devices": max_devices, "max_devices": max_devices,
"max_devices_label": _format_devices_limit(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: async def disconnect_device_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request) user_id = _require_user_id(request)
rate_limit_response = await _enforce_webapp_rate_limit( rate_limit_response = await _enforce_webapp_rate_limit(
+5
View File
@@ -3,6 +3,7 @@ from ._runtime import * # noqa: F403,F405
def setup_subscription_webapp_routes(app: web.Application) -> None: 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("/", index_route)
app.router.add_get("/login/password", index_route) app.router.add_get("/login/password", index_route)
app.router.add_get("/home", index_route) app.router.add_get("/home", index_route)
@@ -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/request", account_password_request_route)
app.router.add_post("/api/account/password/confirm", account_password_confirm_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/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/promo/apply", apply_promo_route)
app.router.add_post("/api/trial/activate", activate_trial_route) app.router.add_post("/api/trial/activate", activate_trial_route)
app.router.add_get("/api/devices", devices_route) app.router.add_get("/api/devices", devices_route)
+88 -2
View File
@@ -3,6 +3,12 @@ from ._runtime import * # noqa: F403,F405
from config.subscription_guides_config import subscription_guides_available from config.subscription_guides_config import subscription_guides_available
from config.webapp_themes_config import public_themes_catalog_payload from config.webapp_themes_config import public_themes_catalog_payload
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]: 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) lang = _normalize_language(db_user.language_code or settings.DEFAULT_LANGUAGE)
admin_ids = {int(x) for x in (settings.ADMIN_IDS or [])} 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) 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 { return {
"user": { "user": {
"id": user_id, "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_id": db_user.telegram_id,
"telegram_linked": bool(_telegram_id_for_user(db_user)), "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), "telegram_photo_url": _telegram_avatar_url(avatar),
"first_name": db_user.first_name, "first_name": db_user.first_name,
"language_code": lang, "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): if getattr(settings, "traffic_sale_mode", False):
return [] 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]] = [] 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) inviter_days = settings.referral_bonus_inviter.get(months)
friend_days = settings.referral_bonus_referee.get(months) friend_days = settings.referral_bonus_referee.get(months)
if inviter_days is None and friend_days is None: if inviter_days is None and friend_days is None:
@@ -391,6 +476,7 @@ def _serialize_plans(
for tariff in tariffs_config.enabled_tariffs: for tariff in tariffs_config.enabled_tariffs:
common = { common = {
"tariff_key": tariff.key, "tariff_key": tariff.key,
"is_default_tariff": tariff.key == tariffs_config.default_tariff,
"tariff_name": tariff.name(lang), "tariff_name": tariff.name(lang),
"billing_model": tariff.billing_model, "billing_model": tariff.billing_model,
"description": tariff.description(lang), "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})
+2
View File
@@ -14,6 +14,7 @@ from bot.middlewares.i18n import JsonI18n
from bot.services.panel_api_service import PanelApiService from bot.services.panel_api_service import PanelApiService
from bot.utils.text_sanitizer import panel_description_from_profile from bot.utils.text_sanitizer import panel_description_from_profile
from config.settings import Settings 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.dal import panel_sync_dal, subscription_dal, user_dal
from db.models import Subscription, User from db.models import Subscription, User
@@ -928,6 +929,7 @@ async def _perform_sync_impl(
total_panel_users = len(panel_users_data) total_panel_users = len(panel_users_data)
logging.info(f"Starting sync for {total_panel_users} panel users.") 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) sync_indexes = await _prefetch_sync_indexes(session, panel_users_data)
users_by_telegram_id = sync_indexes["users_by_telegram_id"] users_by_telegram_id = sync_indexes["users_by_telegram_id"]
users_by_user_id = sync_indexes["users_by_user_id"] users_by_user_id = sync_indexes["users_by_user_id"]
+128 -23
View File
@@ -1,5 +1,5 @@
import logging import logging
from typing import Optional, Union from typing import Any, Callable, Optional, Union
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
from aiogram import Bot, F, Router, types from aiogram import Bot, F, Router, types
@@ -76,31 +76,10 @@ async def referral_command_handler(
await event.answer() await event.answer()
return return
bonus_info_parts = []
if getattr(settings, "traffic_sale_mode", False): if getattr(settings, "traffic_sale_mode", False):
bonus_details_str = _("referral_not_available_for_traffic") bonus_details_str = _("referral_not_available_for_traffic")
else: else:
if settings.subscription_options: bonus_details_str = _build_referral_bonus_details_text(settings, _, current_lang)
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")
)
referral_stats = await referral_service.get_referral_stats(session, inviter_user_id) referral_stats = await referral_service.get_referral_stats(session, inviter_user_id)
@@ -208,6 +187,132 @@ async def referral_action_handler(
await callback.answer() 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( def _build_webapp_referral_link(
base_url: Optional[str], referral_code: Optional[str] base_url: Optional[str], referral_code: Optional[str]
) -> Optional[str]: ) -> Optional[str]:
+28 -2
View File
@@ -22,6 +22,7 @@ from bot.services.panel_api_service import PanelApiService
from bot.services.promo_code_service import PromoCodeService from bot.services.promo_code_service import PromoCodeService
from bot.services.referral_service import ReferralService from bot.services.referral_service import ReferralService
from bot.services.subscription_service import SubscriptionService from bot.services.subscription_service import SubscriptionService
from bot.services.telegram_notifications import TELEGRAM_NOTIFICATIONS_ENABLED
from bot.utils.callback_answer import safe_answer_callback from bot.utils.callback_answer import safe_answer_callback
from bot.utils.install_links import ( from bot.utils.install_links import (
append_install_share_link_text, 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"^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"^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"^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"^page_ref$").as_("page_ref_match")))
@router.message( @router.message(
CommandStart( CommandStart(
magic=F.args.regexp( 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") ).as_("ad_param_match")
) )
) )
@@ -414,6 +416,7 @@ async def start_command_handler(
ad_param_match: Optional[re.Match] = None, ad_param_match: Optional[re.Match] = None,
admin_user_match: Optional[re.Match] = None, admin_user_match: Optional[re.Match] = None,
ticket_match: Optional[re.Match] = None, ticket_match: Optional[re.Match] = None,
notifications_match: Optional[re.Match] = None,
): ):
await state.clear() await state.clear()
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) 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 promo_code_to_apply: Optional[str] = None
should_open_referral_from_start = False should_open_referral_from_start = False
ad_start_param: Optional[str] = None ad_start_param: Optional[str] = None
notifications_start_requested = bool(notifications_match)
if ref_match: if ref_match:
raw_ref_value = ref_match.group(1) raw_ref_value = ref_match.group(1)
@@ -522,6 +526,8 @@ async def start_command_handler(
elif promo_match: elif promo_match:
promo_code_to_apply = promo_match.group(1) promo_code_to_apply = promo_match.group(1)
logging.info(f"User {user_id} started with promo code: {promo_code_to_apply}") 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: elif page_ref_match:
should_open_referral_from_start = True should_open_referral_from_start = True
logging.info(f"User {user_id} started with page_ref deep-link.") 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_username = sanitize_username(user.username)
sanitized_first_name = sanitize_display_name(user.first_name) sanitized_first_name = sanitize_display_name(user.first_name)
sanitized_last_name = sanitize_display_name(user.last_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) db_user = await user_dal.get_user_by_id(session, user_id)
is_existing_user = db_user is not None is_existing_user = db_user is not None
if not db_user: if not db_user:
user_data_to_create = { user_data_to_create = {
"user_id": user_id, "user_id": user_id,
"telegram_id": user_id,
"username": sanitized_username, "username": sanitized_username,
"first_name": sanitized_first_name, "first_name": sanitized_first_name,
"last_name": sanitized_last_name, "last_name": sanitized_last_name,
"language_code": current_lang, "language_code": current_lang,
"referred_by_id": referred_by_user_id, "referred_by_id": referred_by_user_id,
"registration_date": datetime.now(timezone.utc), "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: try:
db_user, created = await user_dal.create_user(session, user_data_to_create) db_user, created = await user_dal.create_user(session, user_data_to_create)
@@ -631,6 +643,13 @@ async def start_command_handler(
update_payload = {} update_payload = {}
if db_user.language_code != current_lang: if db_user.language_code != current_lang:
update_payload["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. # Set referral only if not already set AND user is not currently active.
# This allows previously subscribed but currently inactive users to be attributed. # This allows previously subscribed but currently inactive users to be attributed.
if referred_by_user_id and db_user.referred_by_id is None: 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 open_referral_page_for_existing_user = should_open_referral_from_start and is_existing_user
# Send welcome message if not disabled # 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))) 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 # Auto-apply promo code if provided via start parameter
if promo_code_to_apply: if promo_code_to_apply:
try: try:
+16 -2
View File
@@ -114,6 +114,20 @@ def _with_subscription_purchase_description(
return f"{description}\n\n{text}" 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( async def display_subscription_options(
event: Union[types.Message, types.CallbackQuery], event: Union[types.Message, types.CallbackQuery],
i18n_data: dict, i18n_data: dict,
@@ -456,7 +470,7 @@ async def tariff_topup_list_callback(
if len(labels) > len(visible): if len(labels) > len(visible):
premium_lines.append(f"• ... еще {len(labels) - len(visible)}") premium_lines.append(f"• ... еще {len(labels) - len(visible)}")
premium_lines.append( 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") text = get_text("choose_payment_method_traffic")
if carryover_lines: if carryover_lines:
@@ -1065,7 +1079,7 @@ async def my_subscription_command_handler(
text += ( text += (
"\n\n🚀 <b>Premium-серверы</b>\n" "\n\n🚀 <b>Premium-серверы</b>\n"
f"Статус: <b>{premium_status}</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_left / 2**30:.2f} GB</b>\n"
f"Докупленный остаток: <b>{premium_balance / 2**30:.2f} GB</b>\n" f"Докупленный остаток: <b>{premium_balance / 2**30:.2f} GB</b>\n"
"Отдельный лимит действует на:\n" "Отдельный лимит действует на:\n"
+23 -12
View File
@@ -307,6 +307,7 @@ class JsonI18n:
self.locales_data: Dict[str, Dict[str, str]] = {} self.locales_data: Dict[str, Dict[str, str]] = {}
self._overrides_path: Optional[Path] = None self._overrides_path: Optional[Path] = None
self._overrides_file_mtime_ns: Optional[int] = 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_next_check = 0.0
self._overrides_file_check_interval_seconds = 1.0 self._overrides_file_check_interval_seconds = 1.0
self._load_locales() self._load_locales()
@@ -419,6 +420,7 @@ class JsonI18n:
if self._overrides_file_mtime_ns is None: if self._overrides_file_mtime_ns is None:
return False return False
self._overrides_file_mtime_ns = None self._overrides_file_mtime_ns = None
self._overrides_file_content = None
logging.info( logging.info(
"Locale overrides file removed; keeping current in-memory overrides until " "Locale overrides file removed; keeping current in-memory overrides until "
"the DB fallback is reloaded" "the DB fallback is reloaded"
@@ -432,19 +434,8 @@ class JsonI18n:
) )
return False return False
if not force and stat.st_mtime_ns == self._overrides_file_mtime_ns:
return False
try: try:
payload = json.loads(self._overrides_path.read_text(encoding="utf-8")) content = self._overrides_path.read_text(encoding="utf-8")
except json.JSONDecodeError as exc:
logging.warning(
"Failed to parse locale overrides file %s: %s",
self._overrides_path,
exc,
)
self._overrides_file_mtime_ns = stat.st_mtime_ns
return False
except OSError as exc: except OSError as exc:
logging.warning( logging.warning(
"Failed to read locale overrides file %s: %s", "Failed to read locale overrides file %s: %s",
@@ -453,7 +444,27 @@ class JsonI18n:
) )
return False 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_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) self.set_locale_overrides(payload)
logging.info("Locale overrides reloaded from %s", self._overrides_path) logging.info("Locale overrides reloaded from %s", self._overrides_path)
return True 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.dal import payment_dal, user_dal
from db.models import Payment, User 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"} _TRAFFIC_MODES = {"traffic", "traffic_package", "topup", "premium_topup"}
_HWID_DEVICE_MODES = {"hwid_device", "hwid_devices", "hwid_devices_renewal"} _HWID_DEVICE_MODES = {"hwid_device", "hwid_devices", "hwid_devices_renewal"}
@@ -304,6 +310,7 @@ async def finalize_successful_payment(
activation_months or 1, activation_months or 1,
current_payment_db_id=req.payment.payment_id, current_payment_db_id=req.payment.payment_id,
skip_if_active_before_payment=False, skip_if_active_before_payment=False,
tariff_key=sale_mode_tariff_key(req.sale_mode),
) )
await req.session.commit() await req.session.commit()
except Exception: except Exception:
@@ -6,6 +6,7 @@ from typing import Any, Optional
from aiogram import Bot from aiogram import Bot
from sqlalchemy.ext.asyncio import AsyncSession 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.dal import payment_dal, user_dal
from db.models import Payment 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 db_user.language_code if db_user and db_user.language_code else settings.DEFAULT_LANGUAGE
) )
translator = make_translator(i18n, language) translator = make_translator(i18n, language)
message_text = translator(message_key)
try: try:
await bot.send_message(payment.user_id, translator(message_key)) await bot.send_message(payment.user_id, message_text)
except Exception: except Exception:
logging.exception( logging.exception(
"Webhook helper: failed to notify user %s about %s.", "Webhook helper: failed to notify user %s about %s.",
payment.user_id, payment.user_id,
message_key, 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),
)
+39 -2
View File
@@ -35,6 +35,7 @@ from bot.services.lknpd_service import LknpdService
from bot.services.panel_api_service import PanelApiService from bot.services.panel_api_service import PanelApiService
from bot.services.referral_service import ReferralService from bot.services.referral_service import ReferralService
from bot.services.subscription_service import SubscriptionService from bot.services.subscription_service import SubscriptionService
from bot.services.user_email_notifications import send_user_notification_email
from bot.utils.config_link import prepare_config_links from bot.utils.config_link import prepare_config_links
from bot.utils.install_links import ensure_user_install_guide_links from bot.utils.install_links import ensure_user_install_guide_links
from bot.utils.request_security import ip_in_allowlist, request_client_ip from bot.utils.request_security import ip_in_allowlist, request_client_ip
@@ -75,6 +76,9 @@ from .shared import (
from .shared import ( from .shared import (
sale_mode_base as _sale_mode_base, sale_mode_base as _sale_mode_base,
) )
from .shared import (
sale_mode_tariff_key as _sale_mode_tariff_key,
)
class YooKassaConfig(ProviderEnvConfig): class YooKassaConfig(ProviderEnvConfig):
@@ -749,6 +753,7 @@ async def process_successful_payment(
months_for_activation or int(subscription_months) or 1, months_for_activation or int(subscription_months) or 1,
current_payment_db_id=payment_db_id, current_payment_db_id=payment_db_id,
skip_if_active_before_payment=False, skip_if_active_before_payment=False,
tariff_key=_sale_mode_tariff_key(sale_mode),
) )
applied_referee_bonus_days_from_referral: Optional[int] = None applied_referee_bonus_days_from_referral: Optional[int] = None
if referral_bonus_info and referral_bonus_info.get("referee_new_end_date"): 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 user_lang = db_user.language_code
_ = lambda key, **kwargs: i18n.gettext(user_lang, key, **kwargs) _ = 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: except Exception as e_process_cancel:
logging.error( logging.error(
@@ -1202,13 +1220,32 @@ async def yookassa_webhook_route(request: web.Request):
get_back_to_payment_methods_keyboard, get_back_to_payment_methods_keyboard,
) )
message_text = _("payment_method_bound_success")
try:
await bot.send_message( await bot.send_message(
chat_id=user_id, chat_id=user_id,
text=_("payment_method_bound_success"), text=message_text,
reply_markup=get_back_to_payment_methods_keyboard( reply_markup=get_back_to_payment_methods_keyboard(
i18n_lang, i18n_instance 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: except Exception:
pass pass
# Attempt to cancel the authorization to avoid charge hold # Attempt to cancel the authorization to avoid charge hold
+230 -2
View File
@@ -202,6 +202,41 @@ def _format_traffic(traffic_gb: Optional[float]) -> str:
return str(int(value)) if value.is_integer() else f"{value:g}" 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: def _format_minutes(seconds: int) -> int:
return max(1, int(seconds) // 60) return max(1, int(seconds) // 60)
@@ -351,12 +386,15 @@ def render_payment_success(
lang = _normalize_lang(language_code, settings) lang = _normalize_lang(language_code, settings)
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR) accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
brand = _brand_title(settings) 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",
"traffic_package", "traffic_package",
"topup", "topup",
"premium_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) amount_text = _format_amount(amount, currency)
safe_dashboard_url = (dashboard_url or "").strip() safe_dashboard_url = (dashboard_url or "").strip()
end_date = end_date_text or "" end_date = end_date_text or ""
@@ -370,7 +408,12 @@ def render_payment_success(
cta_label = _t_text(i18n, lang, "email_payment_success_cta") cta_label = _t_text(i18n, lang, "email_payment_success_cta")
if is_traffic: 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_label = _t_text(i18n, lang, "email_payment_success_row_traffic")
period_value = _t_text( period_value = _t_text(
i18n, lang, "email_payment_success_traffic_value", traffic_gb=traffic_label i18n, lang, "email_payment_success_traffic_value", traffic_gb=traffic_label
@@ -383,6 +426,30 @@ def render_payment_success(
traffic_gb=traffic_label, traffic_gb=traffic_label,
end_date=end_date, 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: else:
months_int = int(months or 0) months_int = int(months or 0)
intro = _t_text(i18n, lang, "email_payment_success_intro_subscription", months=months_int) 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) 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( def render_subscription_expiring(
settings: Settings, settings: Settings,
*, *,
@@ -497,6 +624,107 @@ def render_subscription_expiring(
return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered) 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( def _support_email(
settings: Settings, settings: Settings,
i18n: Optional[JsonI18n], i18n: Optional[JsonI18n],
+102 -2
View File
@@ -328,12 +328,112 @@ class PanelApiService:
async def _get_user_by_uuid_uncached( async def _get_user_by_uuid_uncached(
self, user_uuid: str, log_response: bool = False self, user_uuid: str, log_response: bool = False
) -> Optional[Dict[str, Any]]: ) -> 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}" endpoint = f"/users/{user_uuid}"
full_response = await self._request("GET", endpoint, log_full_response=log_response) 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: 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( async def get_user(
self, self,
+309 -86
View File
@@ -3,12 +3,15 @@ import hashlib
import hmac import hmac
import json import json
import logging import logging
from datetime import datetime, timedelta, timezone
from typing import Optional from typing import Optional
from aiogram import Bot from aiogram import Bot
from aiogram.types import InlineKeyboardMarkup from aiogram.types import InlineKeyboardMarkup
from aiohttp import web 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.infra.webhook_queue import enqueue_webhook_event
from bot.keyboards.inline.user_keyboards import ( from bot.keyboards.inline.user_keyboards import (
@@ -16,18 +19,40 @@ from bot.keyboards.inline.user_keyboards import (
get_subscribe_only_markup, get_subscribe_only_markup,
) )
from bot.middlewares.i18n import JsonI18n from bot.middlewares.i18n import JsonI18n
from bot.services.subscription_lifecycle_notifications import (
SubscriptionLifecycleNotificationService,
SubscriptionNotificationStage,
)
from config.settings import Settings 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 from .panel_api_service import PanelApiService
EVENT_MAP = { EVENT_MAP = {
"user.expires_in_72_hours": (3, "subscription_72h_notification"), "user.expires_in_72_hours": SubscriptionNotificationStage(
"user.expires_in_48_hours": (2, "subscription_48h_notification"), key="before_3d",
"user.expires_in_24_hours": (1, "subscription_24h_notification"), 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: class PanelWebhookService:
@@ -48,6 +73,11 @@ class PanelWebhookService:
self.i18n = i18n self.i18n = i18n
self.async_session_factory = async_session_factory self.async_session_factory = async_session_factory
self.panel_service = panel_service self.panel_service = panel_service
self.lifecycle_notifications = SubscriptionLifecycleNotificationService(
settings,
bot,
i18n,
)
self._event_semaphore = asyncio.Semaphore(self._MAX_CONCURRENT_EVENTS) self._event_semaphore = asyncio.Semaphore(self._MAX_CONCURRENT_EVENTS)
if not self.settings.PANEL_WEBHOOK_SECRET: if not self.settings.PANEL_WEBHOOK_SECRET:
logging.error( logging.error(
@@ -102,112 +132,200 @@ class PanelWebhookService:
) )
async def handle_event(self, event_name: str, user_payload: dict): async def handle_event(self, event_name: str, user_payload: dict):
telegram_id = user_payload.get("telegramId")
if not telegram_id:
logging.warning("Panel webhook without telegramId received")
return
user_id = int(telegram_id)
if not self.settings.SUBSCRIPTION_NOTIFICATIONS_ENABLED: if not self.settings.SUBSCRIPTION_NOTIFICATIONS_ENABLED:
return 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: async with self.async_session_factory() as session:
db_user = await user_dal.get_user_by_telegram_id(session, user_id) db_user = await self._user_for_payload(session, user_payload)
if not db_user: sub = await self._subscription_for_payload(session, user_payload, db_user)
db_user = await user_dal.get_user_by_id(session, user_id) telegram_id = self._payload_telegram_id(user_payload)
internal_user_id = db_user.user_id if db_user else user_id internal_user_id = (
int(db_user.user_id)
if db_user
else int(getattr(sub, "user_id", 0) or telegram_id or 0)
)
lang = ( lang = (
db_user.language_code db_user.language_code
if db_user and db_user.language_code if db_user and db_user.language_code
else self.settings.DEFAULT_LANGUAGE else self.settings.DEFAULT_LANGUAGE
) )
first_name = db_user.first_name or f"User {user_id}" if db_user else f"User {user_id}" if not sub:
user_email = (db_user.email or "").strip() if db_user else "" 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: if event_name in EVENT_MAP:
days_left, msg_key = EVENT_MAP[event_name] 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) hwid_renewal_note = await self._hwid_renewal_note(internal_user_id, lang)
if days_left == 1: if days_left == 1:
# Trigger auto-renew via SubscriptionService (wired in at factory) # Trigger auto-renew via SubscriptionService (wired in at factory)
try: try:
subscription_service = getattr(self, "subscription_service", None) subscription_service = getattr(self, "subscription_service", None)
if subscription_service: if subscription_service:
async with self.async_session_factory() as session: async with self.async_session_factory() as renewal_session:
from db.dal import subscription_dal active_sub = (
await subscription_dal.get_active_subscription_by_user_id(
sub = await subscription_dal.get_active_subscription_by_user_id( renewal_session,
session, internal_user_id internal_user_id,
) )
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"
):
try: try:
ok = await subscription_service.charge_subscription_renewal( ok = await subscription_service.charge_subscription_renewal(
session, sub renewal_session,
active_sub,
) )
# If initiation succeeded, suppress the 24h reminder by returning early # noqa: E501 # If initiation succeeded, suppress the 24h reminder by returning early # noqa: E501
if ok: if ok:
await session.commit() await renewal_session.commit()
return return
else: await renewal_session.rollback()
await session.rollback()
except Exception: except Exception:
await session.rollback() await renewal_session.rollback()
logging.exception("Auto-renew attempt (24h) failed") logging.exception("Auto-renew attempt (24h) failed")
except Exception: except Exception:
logging.exception("Auto-renew trigger (24h) failed pre-check") logging.exception("Auto-renew trigger (24h) failed pre-check")
if days_left <= self.settings.SUBSCRIPTION_NOTIFY_DAYS_BEFORE: if days_left <= self.settings.SUBSCRIPTION_NOTIFY_DAYS_BEFORE:
# For 48h event, if auto-renew is enabled, show special notice with cancel button # For 48h, auto-renew users get a cancel button instead.
if days_left == 2: if days_left == 2:
async with self.async_session_factory() as session: active_sub = await subscription_dal.get_active_subscription_by_user_id(
from db.dal import subscription_dal session,
internal_user_id,
sub = await subscription_dal.get_active_subscription_by_user_id(
session, internal_user_id
) )
logging.info( logging.info(
"48h webhook check: user_id=%s sub_found=%s auto_renew=%s provider=%s", "48h webhook check: user_id=%s sub_found=%s auto_renew=%s provider=%s",
user_id, internal_user_id,
bool(sub), bool(active_sub),
getattr(sub, "auto_renew_enabled", None) if sub else None, getattr(active_sub, "auto_renew_enabled", None) if active_sub else None,
getattr(sub, "provider", None) if 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) cancel_kb = get_autorenew_cancel_keyboard(lang, self.i18n)
await self._send_message( await self.lifecycle_notifications.send_stage(
user_id, session,
lang, sub,
"autorenew_48h_charge_tomorrow_notice", SubscriptionNotificationStage(
reply_markup=cancel_kb, key="before_2d_autorenew",
user_name=first_name, message_key="autorenew_48h_charge_tomorrow_notice",
days_left=2,
),
user=db_user,
telegram_markup=cancel_kb,
extra_text=hwid_renewal_note, extra_text=hwid_renewal_note,
end_date_text=end_date_text,
) )
await session.commit()
return return
await self._send_message( await self.lifecycle_notifications.send_stage(
user_id, session,
lang, sub,
msg_key, stage,
reply_markup=markup, user=db_user,
user_name=first_name, telegram_markup=markup,
end_date=user_payload.get("expireAt", "")[:10],
extra_text=hwid_renewal_note, extra_text=hwid_renewal_note,
end_date_text=end_date_text,
) )
if days_left == 3 and user_email: await session.commit()
await self._send_subscription_expiring_email(
recipient=user_email,
lang=lang,
days_left=days_left,
end_date_text=user_payload.get("expireAt", "")[:10],
)
elif event_name == "user.expired": elif event_name == "user.expired":
if self.settings.SUBSCRIPTION_NOTIFY_ON_EXPIRE: 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( await self._send_message(
user_id, user_id,
lang, lang,
"subscription_expired_notification", "subscription_expired_notification",
reply_markup=markup, reply_markup=markup,
user_name=first_name, user_name=first_name,
end_date=user_payload.get("expireAt", "")[:10], end_date=self._payload_expire_date(user_payload),
) )
elif ( elif (
event_name == "user.expired_24_hours_ago" event_name == "user.expired_24_hours_ago"
@@ -219,33 +337,138 @@ class PanelWebhookService:
"subscription_expired_yesterday_notification", "subscription_expired_yesterday_notification",
reply_markup=markup, reply_markup=markup,
user_name=first_name, 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, self,
*, session: AsyncSession,
recipient: str, user_payload: dict,
lang: str, ) -> Optional[User]:
days_left: int, telegram_id = self._payload_telegram_id(user_payload)
end_date_text: str, if telegram_id:
) -> None: user = await user_dal.get_user_by_telegram_id(session, telegram_id)
"""Best-effort branded reminder; silently no-ops without SMTP config.""" if user:
if not self.settings.email_auth_configured: return user
return user = await user_dal.get_user_by_id(session, telegram_id)
try: if user:
content = render_subscription_expiring( return user
self.settings,
language_code=lang, panel_uuid = self._payload_panel_uuid(user_payload)
days_left=days_left, if panel_uuid:
end_date_text=end_date_text, user = await user_dal.get_user_by_panel_uuid(session, panel_uuid)
dashboard_url=(self.settings.SUBSCRIPTION_MINI_APP_URL or "").strip() or None, if user:
i18n=self.i18n, 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) .options(selectinload(Subscription.user))
await email_service.send_rendered_email(email=recipient, content=content) )
except Exception:
logging.exception("Failed to send subscription-expiring email to %s", recipient) 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( async def handle_webhook(
self, raw_body: bytes, signature_header: Optional[str] self, raw_body: bytes, signature_header: Optional[str]
+80 -12
View File
@@ -7,6 +7,7 @@ from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from bot.middlewares.i18n import JsonI18n from bot.middlewares.i18n import JsonI18n
from bot.services.user_email_notifications import send_user_notification_email
from config.settings import Settings from config.settings import Settings
from db.dal import payment_dal, subscription_dal, user_dal from db.dal import payment_dal, subscription_dal, user_dal
@@ -33,6 +34,7 @@ class ReferralService:
purchased_subscription_months: int, purchased_subscription_months: int,
current_payment_db_id: Optional[int] = None, current_payment_db_id: Optional[int] = None,
skip_if_active_before_payment: bool = True, skip_if_active_before_payment: bool = True,
tariff_key: Optional[str] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
referee_final_end_date: Optional[datetime] = None referee_final_end_date: Optional[datetime] = None
@@ -94,11 +96,9 @@ class ReferralService:
else self.i18n.gettext(default_lang_for_placeholder, "friend_placeholder") else self.i18n.gettext(default_lang_for_placeholder, "friend_placeholder")
) )
inviter_bonus_days = self.settings.referral_bonus_inviter.get( inviter_bonus_days, referee_bonus_days = self._referral_bonus_days_for_payment(
purchased_subscription_months purchased_subscription_months,
) tariff_key=tariff_key,
referee_bonus_days = self.settings.referral_bonus_referee.get(
purchased_subscription_months
) )
if inviter_bonus_days and inviter_bonus_days > 0: 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 inviter_user_model.language_code or default_lang_for_placeholder
) )
_i = lambda k, **kw: self.i18n.gettext(inviter_lang, k, **kw) _i = lambda k, **kw: self.i18n.gettext(inviter_lang, k, **kw)
await self.bot.send_message( message_text = _i(
inviter_user_id,
_i(
"referral_bonus_inviter_notification_extended", "referral_bonus_inviter_notification_extended",
days=inviter_bonus_days, days=inviter_bonus_days,
referee_name=referee_name_for_msg, referee_name=referee_name_for_msg,
new_end_date=new_end_date_inviter.strftime("%Y-%m-%d"), 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: except Exception as e_notify_inviter:
logging.error( 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: else:
logging.info( logging.info(
@@ -210,13 +228,34 @@ class ReferralService:
_i = lambda k, **kw: self.i18n.gettext( _i = lambda k, **kw: self.i18n.gettext(
inviter_lang, k, **kw inviter_lang, k, **kw
) )
await self.bot.send_message( message_text = _i(
inviter_user_id,
_i(
"referral_bonus_inviter_notification_new_sub", "referral_bonus_inviter_notification_new_sub",
days=inviter_bonus_days, days=inviter_bonus_days,
referee_name=referee_name_for_msg, referee_name=referee_name_for_msg,
new_end_date=bonus_end_date.strftime("%Y-%m-%d"), 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: else:
@@ -263,6 +302,35 @@ class ReferralService:
raise 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( async def generate_referral_link(
self, session: AsyncSession, bot_username: str, inviter_user_id: int self, session: AsyncSession, bot_username: str, inviter_user_id: int
) -> Optional[str]: ) -> 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 asyncio
import logging import logging
import time import time
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Optional 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.keyboards.inline.user_keyboards import get_subscribe_only_markup
from bot.middlewares.i18n import JsonI18n from bot.middlewares.i18n import JsonI18n
from bot.services.panel_api_service import PanelApiService from bot.services.panel_api_service import PanelApiService
from bot.services.subscription_lifecycle_notifications import (
SubscriptionLifecycleNotificationService,
SubscriptionNotificationStage,
)
from bot.services.subscription_service import SubscriptionService 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 config.settings import Settings
from db.advisory_locks import acquire_subscription_background_sync_lock
from db.dal import subscription_dal from db.dal import subscription_dal
from db.models import Subscription from db.models import Subscription
@@ -26,13 +39,6 @@ EXPIRED_NOTIFICATION_WINDOW = timedelta(hours=24)
EXPIRED_AFTER_NOTIFICATION_WINDOW = timedelta(hours=48) EXPIRED_AFTER_NOTIFICATION_WINDOW = timedelta(hours=48)
@dataclass(frozen=True)
class SubscriptionNotificationStage:
key: str
message_key: str
hours_before: Optional[int] = None
class SubscriptionNotificationWorker: class SubscriptionNotificationWorker:
def __init__( def __init__(
self, self,
@@ -49,6 +55,11 @@ class SubscriptionNotificationWorker:
self.i18n = i18n self.i18n = i18n
self.panel_service = panel_service self.panel_service = panel_service
self.subscription_service = subscription_service self.subscription_service = subscription_service
self.lifecycle_notifications = SubscriptionLifecycleNotificationService(
settings,
bot,
i18n,
)
self._stopped = asyncio.Event() self._stopped = asyncio.Event()
async def run(self) -> None: async def run(self) -> None:
@@ -66,6 +77,7 @@ class SubscriptionNotificationWorker:
else: else:
started = time.monotonic() started = time.monotonic()
async with self.session_factory() as session: async with self.session_factory() as session:
await acquire_subscription_background_sync_lock(session)
await self.expiry_tick(session) await self.expiry_tick(session)
await self.trial_traffic_tick(session) await self.trial_traffic_tick(session)
await session.commit() await session.commit()
@@ -114,18 +126,10 @@ class SubscriptionNotificationWorker:
stage = self.stage_for_subscription(sub, now) stage = self.stage_for_subscription(sub, now)
if stage is None: if stage is None:
continue continue
if await subscription_dal.has_subscription_notification( await self.lifecycle_notifications.send_stage(
session, session,
sub.subscription_id, sub,
stage.key, stage,
):
continue
if not await self._send_expiry_notification(sub, stage):
continue
await subscription_dal.record_subscription_notification(
session,
sub.subscription_id,
stage.key,
sent_at=now, sent_at=now,
) )
@@ -164,6 +168,7 @@ class SubscriptionNotificationWorker:
return SubscriptionNotificationStage( return SubscriptionNotificationStage(
key=f"before_{days_before}d", key=f"before_{days_before}d",
message_key=message_key, message_key=message_key,
days_left=days_before,
) )
return None return None
@@ -175,6 +180,7 @@ class SubscriptionNotificationWorker:
return SubscriptionNotificationStage( return SubscriptionNotificationStage(
key="expired", key="expired",
message_key="subscription_expired_notification", message_key="subscription_expired_notification",
days_left=0,
) )
if ( if (
getattr(self.settings, "SUBSCRIPTION_NOTIFY_AFTER_EXPIRE", True) getattr(self.settings, "SUBSCRIPTION_NOTIFY_AFTER_EXPIRE", True)
@@ -183,6 +189,7 @@ class SubscriptionNotificationWorker:
return SubscriptionNotificationStage( return SubscriptionNotificationStage(
key="expired_24h_after", key="expired_24h_after",
message_key="subscription_expired_yesterday_notification", message_key="subscription_expired_yesterday_notification",
days_left=0,
) )
return None return None
@@ -208,11 +215,22 @@ class SubscriptionNotificationWorker:
.order_by(Subscription.end_date.asc()) .order_by(Subscription.end_date.asc())
) )
for sub in result.scalars().all(): for sub in result.scalars().all():
if await subscription_dal.has_subscription_notification( legacy_sent = await subscription_dal.has_subscription_notification(
session, session,
sub.subscription_id, sub.subscription_id,
"trial_traffic_depleted", "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 continue
used = int(getattr(sub, "traffic_used_bytes", 0) or 0) used = int(getattr(sub, "traffic_used_bytes", 0) or 0)
@@ -234,12 +252,28 @@ class SubscriptionNotificationWorker:
if limit <= 0 or used < limit: if limit <= 0 or used < limit:
continue continue
if not await self._send_trial_traffic_depleted(sub, used=used, limit=limit): delivery = await self._send_trial_traffic_depleted(
session,
sub,
used=used,
limit=limit,
send_telegram=not telegram_done,
send_email=not email_done,
)
if not delivery["telegram"] and not delivery["email"]:
continue continue
if delivery["telegram"]:
await subscription_dal.record_subscription_notification( await subscription_dal.record_subscription_notification(
session, session,
sub.subscription_id, sub.subscription_id,
"trial_traffic_depleted", "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, sent_at=now,
) )
@@ -257,68 +291,72 @@ class SubscriptionNotificationWorker:
return None return None
return data if isinstance(data, dict) else 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( async def _send_trial_traffic_depleted(
self, self,
session: AsyncSession,
sub: Subscription, sub: Subscription,
*, *,
used: int, used: int,
limit: 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) user_id = int(getattr(sub, "user_id", 0) or 0)
if user_id <= 0:
return False
user = getattr(sub, "user", None) user = getattr(sub, "user", None)
lang = getattr(user, "language_code", None) or self.settings.DEFAULT_LANGUAGE lang = getattr(user, "language_code", None) or self.settings.DEFAULT_LANGUAGE
translate = lambda k, **kw: self.i18n.gettext(lang, k, **kw) translate = lambda k, **kw: self.i18n.gettext(lang, k, **kw)
remaining = max(0, limit - used) remaining = max(0, limit - used)
try: message_text = translate(
await self.bot.send_message(
user_id,
translate(
"trial_traffic_depleted_notification", "trial_traffic_depleted_notification",
used=hd.quote(self._fmt_bytes(used)), used=hd.quote(self._fmt_bytes(used)),
remaining=hd.quote(self._fmt_bytes(remaining)), remaining=hd.quote(self._fmt_bytes(remaining)),
limit_total=hd.quote(self._fmt_bytes(limit)), 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), reply_markup=get_subscribe_only_markup(lang, self.i18n),
parse_mode="HTML", parse_mode="HTML",
) )
return True telegram_sent = True
except Exception: except Exception as exc:
logging.exception("Failed to send trial traffic depleted warning to user %s", user_id) status = telegram_notification_status_from_error(exc)
return False 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 {"telegram": telegram_sent, "email": email_sent}
def _max_before_window(self) -> timedelta: def _max_before_window(self) -> timedelta:
days_before = max(0, int(getattr(self.settings, "SUBSCRIPTION_NOTIFY_DAYS_BEFORE", 0) or 0)) 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_from=valid_from,
valid_until=valid_until, 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 { return {
"subscription_id": updated_sub.subscription_id, "subscription_id": updated_sub.subscription_id,
"end_date": updated_sub.end_date, "end_date": updated_sub.end_date,
@@ -3,6 +3,175 @@ from ._runtime import * # noqa: F403,F405
class SubscriptionLifecycleMixin: 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( async def switch_tariff_without_payment(
self, self,
session: AsyncSession, session: AsyncSession,
@@ -308,6 +477,17 @@ class SubscriptionLifecycleMixin:
) )
result["end_date"] = sub.end_date result["end_date"] = sub.end_date
result["is_active"] = sub.is_active 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 return result
tariff = self._resolve_tariff(tariff_key, "period") if self._tariffs_config() else None 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( active_sub = await subscription_dal.get_active_subscription_by_user_id(
session, user_id, panel_uuid 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: if not active_sub or not active_sub.end_date:
logging.info( logging.info(
f"No active subscription found for user {user_id}. Creating new one for {bonus_days} days." # noqa: E501 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 ( if (
apply_main_traffic_limit apply_main_traffic_limit
and not preserve_tariff_limits
and updated_sub_model and updated_sub_model
and updated_sub_model.traffic_limit_bytes != self.settings.user_traffic_limit_bytes 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( panel_update_payload = self._build_panel_update_payload(
expire_at=new_end_date_obj, expire_at=new_end_date_obj,
traffic_limit_bytes=( 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_uuid=False,
include_default_squads=False, include_default_squads=False,
@@ -663,15 +849,37 @@ class SubscriptionLifecycleMixin:
local_active_sub = await subscription_dal.get_active_subscription_by_user_id( local_active_sub = await subscription_dal.get_active_subscription_by_user_id(
session, user_id, panel_user_uuid 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 not panel_user_data:
if panel_user_confirmed_absent:
logging.warning( logging.warning(
f"Panel user {panel_user_uuid} not found on panel for user {user_id}. Clearing local linkage." # noqa: E501 "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 subscription_dal.deactivate_all_user_subscriptions(session, user_id)
await user_dal.update_user(session, user_id, {"panel_user_uuid": None}) await user_dal.update_user(session, user_id, {"panel_user_uuid": None})
return None return None
logging.warning(
"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,
)
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) panel_lifetime_used = self._extract_lifetime_used_traffic(panel_user_data)
if ( if (
@@ -836,6 +1044,14 @@ class SubscriptionLifecycleMixin:
if expected_hwid_limit is not None: if expected_hwid_limit is not None:
hwid_limit = expected_hwid_limit 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 { return {
"user_id": panel_user_data.get("uuid"), "user_id": panel_user_data.get("uuid"),
"panel_subscription_uuid": panel_user_data.get("subscriptionUuid") "panel_subscription_uuid": panel_user_data.get("subscriptionUuid")
@@ -896,8 +1112,12 @@ class SubscriptionLifecycleMixin:
if local_active_sub if local_active_sub
else None, else None,
"extra_hwid_devices": active_extra_hwid_devices, "extra_hwid_devices": active_extra_hwid_devices,
"extra_hwid_devices_valid_until": hwid_entitlement_summary.get("active_until"), "extra_hwid_devices_valid_until": extra_hwid_valid_until,
"extra_hwid_devices_next_valid_from": hwid_entitlement_summary.get("next_valid_from"), "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, "user_bot_username": db_user.username,
"is_panel_data": True, "is_panel_data": True,
"max_devices": hwid_limit, "max_devices": hwid_limit,
@@ -93,7 +93,7 @@ class PaymentContextMixin:
"""Best-effort branded email confirming the payment. No-op if SMTP or """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 the user's email aren't set. Failures are logged and swallowed so the
payment flow is never blocked by mail delivery.""" 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 return
recipient = (db_user.email or "").strip() if db_user else "" recipient = (db_user.email or "").strip() if db_user else ""
if not recipient: if not recipient:
@@ -269,6 +269,15 @@ class TrafficMixin:
purchased_bytes=purchase_bytes, purchased_bytes=purchase_bytes,
kind="topup", 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 { return {
"subscription_id": sub.subscription_id, "subscription_id": sub.subscription_id,
"traffic_limit_bytes": new_limit, "traffic_limit_bytes": new_limit,
@@ -376,6 +385,15 @@ class TrafficMixin:
purchased_bytes=purchase_bytes, purchased_bytes=purchase_bytes,
kind="premium_topup", 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 { return {
"subscription_id": sub.subscription_id, "subscription_id": sub.subscription_id,
"premium_limit_bytes": premium_limit, "premium_limit_bytes": premium_limit,
+147 -17
View File
@@ -2,7 +2,7 @@ import asyncio
import logging import logging
import time import time
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any, Optional from typing import Any, Awaitable, Callable, Optional
from aiogram import Bot from aiogram import Bot
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, WebAppInfo 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.middlewares.i18n import JsonI18n
from bot.services.panel_api_service import PanelApiService from bot.services.panel_api_service import PanelApiService
from bot.services.subscription_service import SubscriptionService from bot.services.subscription_service import SubscriptionService
from bot.services.user_email_notifications import send_user_notification_email
from bot.utils.date_utils import month_start from bot.utils.date_utils import month_start
from bot.utils.mini_app_url import subscription_mini_app_topup_url from bot.utils.mini_app_url import subscription_mini_app_topup_url
from config.settings import Settings 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.dal import subscription_dal, tariff_dal, user_dal
from db.models import Subscription from db.models import Subscription
@@ -31,6 +33,10 @@ TARIFF_WORKER_BATCH_SIZE = 50
TARIFF_WORKER_PANEL_CONCURRENCY = 10 TARIFF_WORKER_PANEL_CONCURRENCY = 10
TARIFF_WORKER_BULK_PANEL_FETCH_THRESHOLD = 50 TARIFF_WORKER_BULK_PANEL_FETCH_THRESHOLD = 50
TARIFF_WORKER_SQUAD_CONFIRMATION_CACHE_TTL_SECONDS = 900 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: class TariffTrafficWorker:
@@ -97,6 +103,36 @@ class TariffTrafficWorker:
button = InlineKeyboardButton(text=_(fallback_key), callback_data="tariff_topup:list") button = InlineKeyboardButton(text=_(fallback_key), callback_data="tariff_topup:list")
return InlineKeyboardMarkup(inline_keyboard=[[button]]) 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: async def run(self) -> None:
if not self.settings.tariffs_config: if not self.settings.tariffs_config:
return return
@@ -111,12 +147,14 @@ class TariffTrafficWorker:
logging.info("TariffTrafficWorker tick skipped: Redis lock is held") logging.info("TariffTrafficWorker tick skipped: Redis lock is held")
else: else:
started = time.monotonic() started = time.monotonic()
async with self.session_factory() as session: await self._run_db_tick_with_retry(
await self.traffic_period_tick(session) "traffic_period",
await session.commit() self.traffic_period_tick,
async with self.session_factory() as session: )
await self.legacy_throttle_recovery_tick(session) await self._run_db_tick_with_retry(
await session.commit() "legacy_throttle_recovery",
self.legacy_throttle_recovery_tick,
)
logging.info( logging.info(
"metric worker_tick_duration_seconds=%.3f worker=tariff", "metric worker_tick_duration_seconds=%.3f worker=tariff",
time.monotonic() - started, time.monotonic() - started,
@@ -134,16 +172,80 @@ class TariffTrafficWorker:
def stop(self) -> None: def stop(self) -> None:
self._stopped.set() 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: async def traffic_period_tick(self, session: AsyncSession) -> None:
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
self._premium_node_usage_tick_cache = {} self._premium_node_usage_tick_cache = {}
warning_period_start = month_start(now) warning_period_start = month_start(now)
result = await session.execute( result = await session.execute(
select(Subscription).where( select(Subscription)
.where(
Subscription.is_active == True, Subscription.is_active == True,
Subscription.end_date > now, Subscription.end_date > now,
Subscription.tariff_key.is_not(None), Subscription.tariff_key.is_not(None),
) )
.order_by(Subscription.subscription_id.asc())
) )
subs = list(result.scalars().all()) subs = list(result.scalars().all())
if not subs: if not subs:
@@ -454,6 +556,8 @@ class TariffTrafficWorker:
return return
ratio = used_val / limit_val ratio = used_val / limit_val
levels = list(getattr(self.settings, "tariff_traffic_warning_levels", [85, 90, 95])) levels = list(getattr(self.settings, "tariff_traffic_warning_levels", [85, 90, 95]))
if 100 not in levels:
levels.append(100)
for level in levels: for level in levels:
threshold = level / 100 threshold = level / 100
if ratio < threshold: if ratio < threshold:
@@ -474,8 +578,6 @@ class TariffTrafficWorker:
level=level, level=level,
traffic_limit_bytes=limit_val if tariff.billing_model == "traffic" else None, traffic_limit_bytes=limit_val if tariff.billing_model == "traffic" else None,
) )
if self.bot:
try:
user_lang = await self._user_lang(session, sub.user_id) user_lang = await self._user_lang(session, sub.user_id)
_ = ( _ = (
(lambda k, **kw: self.i18n.gettext(user_lang, k, **kw)) (lambda k, **kw: self.i18n.gettext(user_lang, k, **kw))
@@ -492,12 +594,16 @@ class TariffTrafficWorker:
left_pct=left_pct, left_pct=left_pct,
**usage, **usage,
) )
subject_key = "email_traffic_warning_regular_almost_subject"
else: else:
text = _( text = _(
"traffic_warning_regular_depleted", "traffic_warning_regular_depleted",
tariff_name=tariff_name, tariff_name=tariff_name,
**usage, **usage,
) )
subject_key = "email_traffic_warning_regular_depleted_subject"
if self.bot:
try:
markup = self._traffic_topup_markup(user_lang, "regular") markup = self._traffic_topup_markup(user_lang, "regular")
await self.bot.send_message( await self.bot.send_message(
sub.user_id, sub.user_id,
@@ -507,6 +613,13 @@ class TariffTrafficWorker:
) )
except Exception: except Exception:
logging.exception("Failed to send traffic warning to user %s", sub.user_id) 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: if ratio >= 1.0 and not sub.is_throttled:
logging.info( logging.info(
"Tariff traffic limit reached for user %s subscription %s. " "Tariff traffic limit reached for user %s subscription %s. "
@@ -930,8 +1043,6 @@ class TariffTrafficWorker:
level=PREMIUM_WARNING_DEPLETED_LEVEL, level=PREMIUM_WARNING_DEPLETED_LEVEL,
traffic_limit_bytes=None, traffic_limit_bytes=None,
) )
if self.bot:
try:
user_lang = await self._user_lang(session, sub.user_id) user_lang = await self._user_lang(session, sub.user_id)
_ = ( _ = (
(lambda k, **kw: self.i18n.gettext(user_lang, k, **kw)) (lambda k, **kw: self.i18n.gettext(user_lang, k, **kw))
@@ -955,6 +1066,8 @@ class TariffTrafficWorker:
servers=servers, servers=servers,
**usage, **usage,
) )
if self.bot:
try:
markup = self._traffic_topup_markup(user_lang, "premium") markup = self._traffic_topup_markup(user_lang, "premium")
await self.bot.send_message( await self.bot.send_message(
sub.user_id, sub.user_id,
@@ -966,6 +1079,13 @@ class TariffTrafficWorker:
logging.exception( logging.exception(
"Failed to send premium traffic depleted warning to user %s", sub.user_id "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 return
for level in levels: for level in levels:
@@ -989,9 +1109,6 @@ class TariffTrafficWorker:
level=storage_level, level=storage_level,
traffic_limit_bytes=None, traffic_limit_bytes=None,
) )
if not self.bot:
continue
try:
user_lang = await self._user_lang(session, sub.user_id) user_lang = await self._user_lang(session, sub.user_id)
_ = ( _ = (
(lambda k, **kw: self.i18n.gettext(user_lang, k, **kw)) (lambda k, **kw: self.i18n.gettext(user_lang, k, **kw))
@@ -1017,6 +1134,8 @@ class TariffTrafficWorker:
servers=servers, servers=servers,
**usage, **usage,
) )
if self.bot:
try:
markup = self._traffic_topup_markup(user_lang, "premium") markup = self._traffic_topup_markup(user_lang, "premium")
await self.bot.send_message( await self.bot.send_message(
sub.user_id, sub.user_id,
@@ -1025,7 +1144,16 @@ class TariffTrafficWorker:
parse_mode="HTML", parse_mode="HTML",
) )
except Exception: except Exception:
logging.exception("Failed to send premium traffic warning to user %s", sub.user_id) 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]: async def _premium_node_uuids_for_tariff(self, tariff) -> list[str]:
cache_key = tuple(sorted(tariff.premium_squad_uuids or [])) cache_key = tuple(sorted(tariff.premium_squad_uuids or []))
@@ -1159,10 +1287,12 @@ class TariffTrafficWorker:
from Internal Squads. from Internal Squads.
""" """
result = await session.execute( result = await session.execute(
select(Subscription).where( select(Subscription)
.where(
Subscription.is_active == True, Subscription.is_active == True,
Subscription.is_throttled == True, Subscription.is_throttled == True,
) )
.order_by(Subscription.subscription_id.asc())
) )
for sub in result.scalars().all(): for sub in result.scalars().all():
try: 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
+5 -4
View File
@@ -214,10 +214,10 @@ class Settings(BaseSettings):
MONTH_6_ENABLED: bool = Field(default=True, alias="6_MONTHS_ENABLED") MONTH_6_ENABLED: bool = Field(default=True, alias="6_MONTHS_ENABLED")
MONTH_12_ENABLED: bool = Field(default=True, alias="12_MONTHS_ENABLED") MONTH_12_ENABLED: bool = Field(default=True, alias="12_MONTHS_ENABLED")
RUB_PRICE_1_MONTH: Optional[int] = Field(default=None) RUB_PRICE_1_MONTH: Optional[int] = Field(default=200)
RUB_PRICE_3_MONTHS: Optional[int] = Field(default=None) RUB_PRICE_3_MONTHS: Optional[int] = Field(default=600)
RUB_PRICE_6_MONTHS: Optional[int] = Field(default=None) RUB_PRICE_6_MONTHS: Optional[int] = Field(default=1200)
RUB_PRICE_12_MONTHS: Optional[int] = Field(default=None) RUB_PRICE_12_MONTHS: Optional[int] = Field(default=2400)
STARS_PRICE_1_MONTH: Optional[int] = Field(default=None) STARS_PRICE_1_MONTH: Optional[int] = Field(default=None)
STARS_PRICE_3_MONTHS: 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_NOTIFICATIONS_ENABLED: bool = Field(default=True)
SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED: bool = Field(default=True)
SUBSCRIPTION_NOTIFY_ON_EXPIRE: bool = Field(default=True) SUBSCRIPTION_NOTIFY_ON_EXPIRE: bool = Field(default=True)
SUBSCRIPTION_NOTIFY_AFTER_EXPIRE: bool = Field(default=True) SUBSCRIPTION_NOTIFY_AFTER_EXPIRE: bool = Field(default=True)
SUBSCRIPTION_NOTIFY_DAYS_BEFORE: int = Field(default=3) SUBSCRIPTION_NOTIFY_DAYS_BEFORE: int = Field(default=3)
+35
View File
@@ -95,6 +95,8 @@ class Tariff(BaseModel):
monthly_gb: Optional[float] = None monthly_gb: Optional[float] = None
prices_rub: Dict[str, float] = Field(default_factory=dict) prices_rub: Dict[str, float] = Field(default_factory=dict)
prices_stars: 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) enabled_periods: List[int] = Field(default_factory=list)
topup_packages: Optional[PackageSet] = None topup_packages: Optional[PackageSet] = None
@@ -129,6 +131,14 @@ class Tariff(BaseModel):
if self.billing_model == "period": if self.billing_model == "period":
if self.monthly_gb is None or self.monthly_gb < 0: if self.monthly_gb is None or self.monthly_gb < 0:
raise ValueError(f"period tariff {self.key}: monthly_gb must be >= 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: if not self.enabled_periods:
raise ValueError(f"period tariff {self.key}: enabled_periods is required") raise ValueError(f"period tariff {self.key}: enabled_periods is required")
for months in self.enabled_periods: for months in self.enabled_periods:
@@ -152,6 +162,23 @@ class Tariff(BaseModel):
) )
return self 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: def name(self, lang: str, fallback: str = "ru") -> str:
return self.names.get(lang) or self.names.get(fallback) or self.key 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)) value = source.get(str(months))
return float(value) if value is not None else None 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]: def min_period_price_rub(self) -> Optional[float]:
prices = [ prices = [
float(self.prices_rub[str(months)]) float(self.prices_rub[str(months)])
+12
View File
@@ -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},
)
+21
View File
@@ -408,6 +408,27 @@ async def merge_users(
target.channel_subscription_checked_at = source.channel_subscription_checked_at target.channel_subscription_checked_at = source.channel_subscription_checked_at
if not target.channel_subscription_verified_for and source.channel_subscription_verified_for: if not target.channel_subscription_verified_for and source.channel_subscription_verified_for:
target.channel_subscription_verified_for = 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: if source.lifetime_used_traffic_bytes is not None:
target.lifetime_used_traffic_bytes = ( target.lifetime_used_traffic_bytes = (
target.lifetime_used_traffic_bytes or 0 target.lifetime_used_traffic_bytes or 0
+19
View File
@@ -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] = [ MIGRATIONS: List[Migration] = [
Migration( Migration(
id="0001_add_channel_subscription_fields", id="0001_add_channel_subscription_fields",
@@ -1214,6 +1228,11 @@ MIGRATIONS: List[Migration] = [
description="Track sent subscription notification stages", description="Track sent subscription notification stages",
upgrade=_migration_0031_add_subscription_notifications, 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,
),
] ]
+4
View File
@@ -33,6 +33,10 @@ class User(Base):
password_set_at = Column(DateTime(timezone=True), nullable=True) password_set_at = Column(DateTime(timezone=True), nullable=True)
telegram_id = Column(BigInteger, nullable=True, unique=True, index=True) telegram_id = Column(BigInteger, nullable=True, unique=True, index=True)
telegram_photo_url = Column(Text, nullable=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) first_name = Column(String, nullable=True)
last_name = Column(String, nullable=True) last_name = Column(String, nullable=True)
language_code = Column(String, default="ru") language_code = Column(String, default="ru")
+3 -1
View File
@@ -40,8 +40,10 @@
{ "count": 1, "price": 2500 } { "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 }, "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_periods": [1, 3, 6, 12],
"enabled": true "enabled": true
}, },
+1
View File
@@ -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/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/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 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/subscription_webapp.css
COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp.*.css /usr/share/nginx/html/ COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp.*.css /usr/share/nginx/html/
+11
View File
@@ -23,6 +23,13 @@ server {
return 200 "ok\n"; 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/ { location /api/ {
proxy_pass http://backend:8081; proxy_pass http://backend:8081;
proxy_http_version 1.1; 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)$ { 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; expires off;
add_header Cache-Control "public, max-age=31536000, immutable"; add_header Cache-Control "public, max-age=31536000, immutable";
add_header X-Robots-Tag "noindex, nofollow, noarchive";
try_files $uri =404; try_files $uri =404;
} }
location ~* ^/subscription_webapp(_admin)?\.(css|js)$ { location ~* ^/subscription_webapp(_admin)?\.(css|js)$ {
expires off; expires off;
add_header Cache-Control "no-cache"; add_header Cache-Control "no-cache";
add_header X-Robots-Tag "noindex, nofollow, noarchive";
try_files $uri =404; try_files $uri =404;
} }
location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|webp)$ { location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|webp)$ {
expires 30d; expires 30d;
add_header Cache-Control "public"; add_header Cache-Control "public";
add_header X-Robots-Tag "noindex, nofollow, noarchive";
try_files $uri =404; try_files $uri =404;
} }
location / { location / {
add_header Cache-Control "no-cache"; add_header Cache-Control "no-cache";
add_header X-Robots-Tag "noindex, nofollow, noarchive";
try_files $uri /index.html; try_files $uri /index.html;
} }
} }
+26
View File
@@ -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
View File
@@ -1,4 +1,5 @@
**/.env **/.env
**/data/
nginx/ssl/* nginx/ssl/*
!nginx/ssl/README.md !nginx/ssl/README.md
+2
View File
@@ -4,6 +4,8 @@
Эта папка хранит только рабочие compose-примеры и конфиги. Подробное описание не дублируется здесь, чтобы сайт документации и навигация из README использовали один источник. Эта папка хранит только рабочие compose-примеры и конфиги. Подробное описание не дублируется здесь, чтобы сайт документации и навигация из README использовали один источник.
Файлы приложения (`/app/data`: тарифы, темы, логотипы) монтируются из папки `data` рядом с выбранным `docker-compose.yml`. Для кастомных тем создайте `data/themes`.
| Папка | Документация | | Папка | Документация |
| --- | --- | | --- | --- |
| `caddy` | [Развертывание с Caddy](../../docs/getting-started/deployment.md#caddy-рекомендуемый-вариант) | | `caddy` | [Развертывание с Caddy](../../docs/getting-started/deployment.md#caddy-рекомендуемый-вариант) |
+3 -5
View File
@@ -48,7 +48,7 @@ services:
environment: environment:
<<: *app_environment <<: *app_environment
volumes: volumes:
- shop-data:/app/data - ./data:/app/data
networks: networks:
- remnawave-shop - remnawave-shop
depends_on: depends_on:
@@ -66,7 +66,7 @@ services:
WEBAPP_ENABLED: ${WEBAPP_ENABLED:-true} WEBAPP_ENABLED: ${WEBAPP_ENABLED:-true}
TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.1,::1,172.16.0.0/12} TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.1,::1,172.16.0.0/12}
volumes: volumes:
- shop-data:/app/data - ./data:/app/data
- ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:${COMPOSE_RESTORE_MODE:-rw} - ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:${COMPOSE_RESTORE_MODE:-rw}
networks: networks:
- remnawave-shop - remnawave-shop
@@ -91,7 +91,7 @@ services:
environment: environment:
<<: *app_environment <<: *app_environment
volumes: volumes:
- shop-data:/app/data - ./data:/app/data
- ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:ro - ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:ro
networks: networks:
- remnawave-shop - remnawave-shop
@@ -149,8 +149,6 @@ volumes:
name: remnawave-minishop-db-data name: remnawave-minishop-db-data
redis-data: redis-data:
name: remnawave-minishop-redis-data name: remnawave-minishop-redis-data
shop-data:
name: remnawave-minishop-shop-data
caddy-data: caddy-data:
name: remnawave-minishop-caddy-data name: remnawave-minishop-caddy-data
caddy-config: caddy-config:
+3 -5
View File
@@ -48,7 +48,7 @@ services:
environment: environment:
<<: *app_environment <<: *app_environment
volumes: volumes:
- shop-data:/app/data - ./data:/app/data
networks: networks:
- remnawave-shop - remnawave-shop
depends_on: depends_on:
@@ -66,7 +66,7 @@ services:
WEBAPP_ENABLED: ${WEBAPP_ENABLED:-true} WEBAPP_ENABLED: ${WEBAPP_ENABLED:-true}
TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.1,::1,172.16.0.0/12} TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.1,::1,172.16.0.0/12}
volumes: volumes:
- shop-data:/app/data - ./data:/app/data
- ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:${COMPOSE_RESTORE_MODE:-rw} - ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:${COMPOSE_RESTORE_MODE:-rw}
networks: networks:
- remnawave-shop - remnawave-shop
@@ -91,7 +91,7 @@ services:
environment: environment:
<<: *app_environment <<: *app_environment
volumes: volumes:
- shop-data:/app/data - ./data:/app/data
- ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:ro - ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:ro
networks: networks:
- remnawave-shop - remnawave-shop
@@ -144,5 +144,3 @@ volumes:
name: remnawave-minishop-db-data name: remnawave-minishop-db-data
redis-data: redis-data:
name: remnawave-minishop-redis-data name: remnawave-minishop-redis-data
shop-data:
name: remnawave-minishop-shop-data
+3 -5
View File
@@ -48,7 +48,7 @@ services:
environment: environment:
<<: *app_environment <<: *app_environment
volumes: volumes:
- shop-data:/app/data - ./data:/app/data
networks: networks:
- remnawave-shop - remnawave-shop
depends_on: depends_on:
@@ -66,7 +66,7 @@ services:
WEBAPP_ENABLED: ${WEBAPP_ENABLED:-true} WEBAPP_ENABLED: ${WEBAPP_ENABLED:-true}
TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.1,::1,172.16.0.0/12} TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.1,::1,172.16.0.0/12}
volumes: volumes:
- shop-data:/app/data - ./data:/app/data
- ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:${COMPOSE_RESTORE_MODE:-rw} - ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:${COMPOSE_RESTORE_MODE:-rw}
networks: networks:
- remnawave-shop - remnawave-shop
@@ -91,7 +91,7 @@ services:
environment: environment:
<<: *app_environment <<: *app_environment
volumes: volumes:
- shop-data:/app/data - ./data:/app/data
- ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:ro - ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:ro
networks: networks:
- remnawave-shop - remnawave-shop
@@ -148,5 +148,3 @@ volumes:
name: remnawave-minishop-db-data name: remnawave-minishop-db-data
redis-data: redis-data:
name: remnawave-minishop-redis-data name: remnawave-minishop-redis-data
shop-data:
name: remnawave-minishop-shop-data
+3 -5
View File
@@ -48,7 +48,7 @@ services:
environment: environment:
<<: *app_environment <<: *app_environment
volumes: volumes:
- shop-data:/app/data - ./data:/app/data
networks: networks:
- remnawave-shop - remnawave-shop
depends_on: depends_on:
@@ -68,7 +68,7 @@ services:
ports: ports:
- "${WEB_SERVER_BIND:-0.0.0.0:8080}:8080" - "${WEB_SERVER_BIND:-0.0.0.0:8080}:8080"
volumes: volumes:
- shop-data:/app/data - ./data:/app/data
- ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:${COMPOSE_RESTORE_MODE:-rw} - ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:${COMPOSE_RESTORE_MODE:-rw}
networks: networks:
- remnawave-shop - remnawave-shop
@@ -93,7 +93,7 @@ services:
environment: environment:
<<: *app_environment <<: *app_environment
volumes: volumes:
- shop-data:/app/data - ./data:/app/data
- ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:ro - ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:ro
networks: networks:
- remnawave-shop - remnawave-shop
@@ -132,5 +132,3 @@ volumes:
name: remnawave-minishop-db-data name: remnawave-minishop-db-data
redis-data: redis-data:
name: remnawave-minishop-redis-data name: remnawave-minishop-redis-data
shop-data:
name: remnawave-minishop-shop-data
+1
View File
@@ -79,6 +79,7 @@ export default defineConfig({
{ label: 'Основные', slug: 'features/core' }, { label: 'Основные', slug: 'features/core' },
{ label: 'Платежи', slug: 'features/payments' }, { label: 'Платежи', slug: 'features/payments' },
{ label: 'Подписки', slug: 'features/subscriptions' }, { label: 'Подписки', slug: 'features/subscriptions' },
{ label: 'Уведомления', slug: 'features/notifications' },
{ label: 'Тарифы', slug: 'features/tariffs' }, { label: 'Тарифы', slug: 'features/tariffs' },
{ label: 'Веб-приложение / Mini App', slug: 'features/web-app' }, { label: 'Веб-приложение / Mini App', slug: 'features/web-app' },
{ label: 'Telegram-авторизация', slug: 'features/telegram-auth' }, { label: 'Telegram-авторизация', slug: 'features/telegram-auth' },
+11 -22
View File
@@ -1,22 +1,11 @@
# Cloudflare Pages rewrites for the static docs demo SPA. # Cloudflare Pages rewrites for non-materialized static docs demo runtime routes.
# Keep these scoped to app routes so runtime JS/CSS/assets are served directly. # Use the directory entrypoint so Pages clean URLs do not loop between app.html and app.
/demo/runtime /demo/runtime/app.html 200 /demo/runtime/home/* /demo/runtime/app/ 200
/demo/runtime/ /demo/runtime/app.html 200 /demo/runtime/install/* /demo/runtime/app/ 200
/demo/runtime/home /demo/runtime/app.html 200 /demo/runtime/trial/* /demo/runtime/app/ 200
/demo/runtime/home/* /demo/runtime/app.html 200 /demo/runtime/invite/* /demo/runtime/app/ 200
/demo/runtime/install /demo/runtime/app.html 200 /demo/runtime/devices/* /demo/runtime/app/ 200
/demo/runtime/install/* /demo/runtime/app.html 200 /demo/runtime/support/* /demo/runtime/app/ 200
/demo/runtime/trial /demo/runtime/app.html 200 /demo/runtime/settings/* /demo/runtime/app/ 200
/demo/runtime/trial/* /demo/runtime/app.html 200 /demo/runtime/login/* /demo/runtime/app/ 200
/demo/runtime/invite /demo/runtime/app.html 200 /demo/runtime/admin/* /demo/runtime/app/ 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
+54 -32
View File
@@ -2,12 +2,14 @@ const frame = document.getElementById("demo-frame");
const runtimeBase = "/demo/runtime"; const runtimeBase = "/demo/runtime";
const demoBase = "/demo"; const demoBase = "/demo";
const defaultMock = "tariffs"; const defaultMock = "tariffs";
const publicRouteAliases = new Map([["/app", "/home"]]);
const stateMocks = new Set([ const stateMocks = new Set([
"tariffs", "tariffs",
"depleted", "depleted",
"no-subscription", "no-subscription",
"trial", "trial",
"devices", "devices",
"notifications",
"auth", "auth",
]); ]);
const routeMocks = new Set([...stateMocks, "guides", "install"]); const routeMocks = new Set([...stateMocks, "guides", "install"]);
@@ -41,7 +43,8 @@ const routeFromPublicPath = () => {
const publicRoute = pathname.slice(demoBase.length); const publicRoute = pathname.slice(demoBase.length);
if (!publicRoute || publicRoute.toLowerCase().startsWith("/runtime")) if (!publicRoute || publicRoute.toLowerCase().startsWith("/runtime"))
return ""; return "";
return normalizePath(publicRoute); const normalized = normalizePath(publicRoute);
return publicRouteAliases.get(normalized.toLowerCase()) || normalized;
}; };
const routeFromParams = () => { const routeFromParams = () => {
@@ -88,36 +91,6 @@ const mockForRoute = (route) => {
const initialMock = params.has("mock") const initialMock = params.has("mock")
? normalizeRouteMock(params.get("mock")) ? normalizeRouteMock(params.get("mock"))
: normalizeRouteMock(mockForRoute(initialRoute) || defaultMock); : 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 materializedRouteFromRuntime = (route) => {
const normalized = normalizePath(route); const normalized = normalizePath(route);
if (/^\/admin\/users\/-?\d+$/i.test(normalized)) return "/admin/users"; if (/^\/admin\/users\/-?\d+$/i.test(normalized)) return "/admin/users";
@@ -131,6 +104,55 @@ const materializedRouteFromRuntime = (route) => {
const publicPathFromRoute = (route) => const publicPathFromRoute = (route) =>
`${demoBase}${materializedRouteFromRuntime(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) => { const routeForStateMock = (mock) => {
if (mock === "devices") return "/devices"; if (mock === "devices") return "/devices";
if (mock === "auth") return "/login"; if (mock === "auth") return "/login";
@@ -141,7 +163,7 @@ const runtimeSrc = (route, searchParams = new URLSearchParams()) => {
nextParams.delete("screen"); nextParams.delete("screen");
nextParams.delete("admin_section"); nextParams.delete("admin_section");
nextParams.set("path", normalizePath(route)); 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 topbar = document.querySelector(".demo-topbar");
const toggle = document.querySelector(".demo-topbar__toggle"); const toggle = document.querySelector(".demo-topbar__toggle");
+6 -39
View File
@@ -1,49 +1,16 @@
import { copyFile, mkdir } from "node:fs/promises"; import { copyFile, mkdir } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import {
demoPublicRoutes,
demoRuntimeRoutes,
} from "../src/lib/demoRoutes.mjs";
const siteRoot = path.resolve(fileURLToPath(new URL("..", import.meta.url))); const siteRoot = path.resolve(fileURLToPath(new URL("..", import.meta.url)));
const distRoot = path.join(siteRoot, "dist"); const distRoot = path.join(siteRoot, "dist");
const userRoutes = [ const demoRoutes = demoPublicRoutes.map((route) => `demo/${route}`);
"home", const runtimeRoutes = demoRuntimeRoutes.map((route) => `demo/runtime/${route}`);
"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}`),
];
async function copyHtml(source, route) { async function copyHtml(source, route) {
const targetDir = path.join(distRoot, route); const targetDir = path.join(distRoot, route);
+1
View File
@@ -19,6 +19,7 @@ const descriptions = {
'features/core.md': 'Пользовательские и админские сценарии Remnawave Minishop.', 'features/core.md': 'Пользовательские и админские сценарии Remnawave Minishop.',
'features/payments.md': 'Платежные провайдеры, кнопки оплаты и webhook-обработка.', 'features/payments.md': 'Платежные провайдеры, кнопки оплаты и webhook-обработка.',
'features/subscriptions.md': 'Тарифы на срок и по трафику, premium-сквады, HWID-устройства и жизненный цикл подписки.', 'features/subscriptions.md': 'Тарифы на срок и по трафику, premium-сквады, HWID-устройства и жизненный цикл подписки.',
'features/notifications.md': 'Каналы Telegram и email для пользовательских, админских и сервисных уведомлений Remnawave Minishop.',
'features/tariffs.md': 'Каталог тарифов, модели на срок/по трафику, premium-сквады и HWID-устройства.', 'features/tariffs.md': 'Каталог тарифов, модели на срок/по трафику, premium-сквады и HWID-устройства.',
'features/web-app.md': 'Telegram Mini App, публичные инструкции, проксирование и реферальные ссылки.', 'features/web-app.md': 'Telegram Mini App, публичные инструкции, проксирование и реферальные ссылки.',
'features/telegram-auth.md': 'Telegram Mini Apps initData, Telegram OAuth, BotFather и настройка входа через Telegram.', 'features/telegram-auth.md': 'Telegram Mini Apps initData, Telegram OAuth, BotFather и настройка входа через Telegram.',
+42
View File
@@ -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}`),
];
+2 -1
View File
@@ -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/'; const docsHref = '/getting-started/demo/';
--- ---
@@ -294,6 +294,7 @@ const docsHref = '/getting-started/demo/';
<option value="no-subscription">Нет подписки</option> <option value="no-subscription">Нет подписки</option>
<option value="trial">Доступна пробная подписка</option> <option value="trial">Доступна пробная подписка</option>
<option value="devices">Лимит и докупка устройств</option> <option value="devices">Лимит и докупка устройств</option>
<option value="notifications">Telegram-уведомления</option>
<option value="auth">Вход и регистрация</option> <option value="auth">Вход и регистрация</option>
</select> </select>
</label> </label>
+2 -32
View File
@@ -1,39 +1,9 @@
--- ---
import DemoShell from '../demo.astro'; import DemoShell from '../demo.astro';
import { demoPublicRoutes } from '../../lib/demoRoutes.mjs';
export function getStaticPaths() { export function getStaticPaths() {
const userRoutes = [ return demoPublicRoutes.map((path) => ({ params: { path } }));
'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}` } })),
];
} }
--- ---
@@ -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",
},
});
}
+9 -4
View File
@@ -117,13 +117,15 @@
| --- | --- | | --- | --- |
| `PANEL_API_URL` | URL API панели, например `https://panel.example.com/api`. | | `PANEL_API_URL` | URL API панели, например `https://panel.example.com/api`. |
| `PANEL_API_KEY` | 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_SQUAD_UUIDS` | Internal Squads по умолчанию для legacy-режима без JSON-каталога. |
| `USER_EXTERNAL_SQUAD_UUID` | Необязательный External Squad. | | `USER_EXTERNAL_SQUAD_UUID` | Необязательный External Squad. |
| `USER_TRAFFIC_LIMIT_GB` | Legacy-лимит трафика пользователя. | | `USER_TRAFFIC_LIMIT_GB` | Legacy-лимит трафика пользователя. |
| `USER_TRAFFIC_STRATEGY` | Legacy-стратегия лимита трафика. | | `USER_TRAFFIC_STRATEGY` | Legacy-стратегия лимита трафика. |
| `USER_HWID_DEVICE_LIMIT` | Legacy-лимит HWID-устройств по умолчанию. | | `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 ## Веб-приложение, внешний вид и Telegram Login
Часть внешнего вида (`WEBAPP_PRIMARY_COLOR`, `WEBAPP_LOGO_*`, `WEBAPP_FAVICON_*`) сохранена для совместимости, но env-значения этих полей игнорируются при загрузке. Настраивайте их в **Админка -> Внешний вид**. Часть внешнего вида (`WEBAPP_PRIMARY_COLOR`, `WEBAPP_LOGO_*`, `WEBAPP_FAVICON_*`) сохранена для совместимости, но env-значения этих полей игнорируются при загрузке. Настраивайте их в **Админка -> Внешний вид**.
@@ -355,6 +357,8 @@ PAYMENT_HELEKET_TELEGRAM_EMOJI
Рекомендуемый способ настройки тарифов - раздел **Система -> Тарифы** в админке. Он сохраняет JSON в `TARIFFS_CONFIG_PATH`. Рекомендуемый способ настройки тарифов - раздел **Система -> Тарифы** в админке. Он сохраняет JSON в `TARIFFS_CONFIG_PATH`.
Если JSON-каталог существует и проходит валидацию, цены, периоды и реферальные бонусы period-тарифов берутся из JSON. Legacy-переменные ниже используются только без JSON-каталога.
| Переменная | Назначение | | Переменная | Назначение |
| --- | --- | | --- | --- |
| `TARIFFS_CONFIG_PATH` | Путь к JSON-каталогу тарифов. | | `TARIFFS_CONFIG_PATH` | Путь к JSON-каталогу тарифов. |
@@ -363,7 +367,7 @@ PAYMENT_HELEKET_TELEGRAM_EMOJI
| `3_MONTHS_ENABLED` | Legacy-доступность периода 3 месяца без JSON-каталога. | | `3_MONTHS_ENABLED` | Legacy-доступность периода 3 месяца без JSON-каталога. |
| `6_MONTHS_ENABLED` | Legacy-доступность периода 6 месяцев без JSON-каталога. | | `6_MONTHS_ENABLED` | Legacy-доступность периода 6 месяцев без JSON-каталога. |
| `12_MONTHS_ENABLED` | Legacy-доступность периода 12 месяцев без 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. | | `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`. | | `TRAFFIC_PACKAGES` | Legacy-пакеты трафика RUB, формат `10:199,50:799`. |
| `STARS_TRAFFIC_PACKAGES` | Legacy-пакеты трафика Stars. | | `STARS_TRAFFIC_PACKAGES` | Legacy-пакеты трафика Stars. |
@@ -382,9 +386,10 @@ PAYMENT_HELEKET_TELEGRAM_EMOJI
| `REFERRAL_ONE_BONUS_PER_REFEREE` | Ограничить бонусы одним успешным платежом приглашенного. | | `REFERRAL_ONE_BONUS_PER_REFEREE` | Ограничить бонусы одним успешным платежом приглашенного. |
| `REFERRAL_WELCOME_BONUS_DAYS` | Приветственный бонус пришедшему по реферальной ссылке. | | `REFERRAL_WELCOME_BONUS_DAYS` | Приветственный бонус пришедшему по реферальной ссылке. |
| `LEGACY_REFS` | Разрешить ссылки `ref_<telegram_id>`. | | `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-бонусы пригласившему. | | `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-бонусы приглашенному. | | `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_NOTIFICATIONS_ENABLED` | Включает напоминания о подписке. |
| `SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED` | Дублирует пользовательские уведомления жизненного цикла подписки на email, если SMTP настроен и у пользователя есть email. |
| `SUBSCRIPTION_NOTIFY_ON_EXPIRE` | Уведомлять в день окончания. | | `SUBSCRIPTION_NOTIFY_ON_EXPIRE` | Уведомлять в день окончания. |
| `SUBSCRIPTION_NOTIFY_AFTER_EXPIRE` | Уведомлять после окончания. | | `SUBSCRIPTION_NOTIFY_AFTER_EXPIRE` | Уведомлять после окончания. |
| `SUBSCRIPTION_NOTIFY_DAYS_BEFORE` | За сколько дней предупреждать. | | `SUBSCRIPTION_NOTIFY_DAYS_BEFORE` | За сколько дней предупреждать. |
+2 -1
View File
@@ -6,7 +6,7 @@
- `WEBAPP_SESSION_SECRET` должен быть постоянным между рестартами, иначе Web App-сессии станут невалидными. - `WEBAPP_SESSION_SECRET` должен быть постоянным между рестартами, иначе Web App-сессии станут невалидными.
- `WEBHOOK_SECRET_TOKEN` защищает вебхук Telegram. - `WEBHOOK_SECRET_TOKEN` защищает вебхук Telegram.
- `PANEL_WEBHOOK_SECRET` проверяет входящие события Remnawave Panel. - `PANEL_WEBHOOK_SECRET` проверяет входящие события Remnawave Panel. Секрет задается в Remnawave Panel и вставляется в настройки бота.
- Платежные токены и webhook-секреты храните в `.env` или настройках админки с учетом доступа к серверу. - Платежные токены и webhook-секреты храните в `.env` или настройках админки с учетом доступа к серверу.
Сгенерировать секрет можно так: Сгенерировать секрет можно так:
@@ -24,6 +24,7 @@ openssl rand -hex 32
## Публичные URL ## Публичные URL
- `WEBHOOK_BASE_URL` должен вести на backend-сервер вебхуков. - `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-домен. - `SUBSCRIPTION_MINI_APP_URL` должен вести на frontend/Mini App-домен.
- Не добавляйте `/api`, `/auth` или webhook-пути в `SUBSCRIPTION_MINI_APP_URL`. - Не добавляйте `/api`, `/auth` или webhook-пути в `SUBSCRIPTION_MINI_APP_URL`.
+3 -3
View File
@@ -51,9 +51,9 @@
- общие параметры: язык, валюта, ссылки поддержки, документы, обязательный канал, Remnawave-доступы и поведение `/start`; - общие параметры: язык, валюта, ссылки поддержки, документы, обязательный канал, Remnawave-доступы и поведение `/start`;
- внешний вид и доступность Web App: название, цвет, логотип, emoji-логотип и `WEBAPP_ENABLED`; - внешний вид и доступность Web App: название, цвет, логотип, emoji-логотип и `WEBAPP_ENABLED`;
- инструкции подключения: `SUBSCRIPTION_GUIDES_ENABLED`, `SUBSCRIPTION_GUIDES_BOT_MENU_ENABLED`, чтение конфига из Remnawave Panel, JSON-переопределение и резервный путь к файлу; - инструкции подключения: `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, а также текст и иконки кнопок оплаты; - платежные провайдеры: включение методов, порядок кнопок, публичные параметры и секреты YooKassa, FreeKassa, Platega, SeverPay, Wata, CryptoPay, Heleket и Stars, а также текст и иконки кнопок оплаты;
- пробный период, реферальные бонусы, уведомления, логирование, поддержка, раздел устройств, лимит устройств и legacy-лимиты трафика. - пробный период, приветственный реферальный бонус, уведомления, логирование, поддержка, раздел устройств, лимит устройств и legacy-лимиты трафика.
Секретные поля помечены как secret и не должны использоваться для произвольного просмотра старых значений. Настройки, которых нет в manifest, остаются только в `.env` или коде. Секретные поля помечены как secret и не должны использоваться для произвольного просмотра старых значений. Настройки, которых нет в manifest, остаются только в `.env` или коде.
@@ -117,7 +117,7 @@
Редактор тарифа разделен на вкладки: Редактор тарифа разделен на вкладки:
- **Основное**: ключ, модель `period`/`traffic`, видимость, названия и описания RU/EN, базовые Internal Squads, HWID-лимит, месячный лимит или курс конвертации; - **Основное**: ключ, модель `period`/`traffic`, видимость, названия и описания RU/EN, базовые Internal Squads, HWID-лимит, месячный лимит или курс конвертации;
- **Цены**: периоды и цены для `period`, пакеты GB и цены для `traffic`; - **Цены**: периоды, цены и реферальные бонусы для `period`, пакеты GB и цены для `traffic`;
- **Докупки**: обычные пакеты докупки трафика для `period`; для `traffic` отдельные докупки не нужны, пользователь повторно покупает пакеты из `traffic_packages`; - **Докупки**: обычные пакеты докупки трафика для `period`; для `traffic` отдельные докупки не нужны, пользователь повторно покупает пакеты из `traffic_packages`;
- **Premium**: названия premium-раздела RU/EN, premium Internal Squads, месячный premium-лимит и RUB/Stars пакеты premium-докупки; - **Premium**: названия premium-раздела RU/EN, premium Internal Squads, месячный premium-лимит и RUB/Stars пакеты premium-докупки;
- **Устройства**: RUB/Stars пакеты докупки HWID-устройств. - **Устройства**: RUB/Stars пакеты докупки HWID-устройств.
+1 -1
View File
@@ -105,4 +105,4 @@ docker compose logs -f backend
- Код сразу устаревает: проверьте `EMAIL_CODE_TTL_SECONDS` и время на сервере. - Код сразу устаревает: проверьте `EMAIL_CODE_TTL_SECONDS` и время на сервере.
- Пользователь получает `rate_limited`: подождите `EMAIL_CODE_RESEND_SECONDS` или проверьте brute-force настройки. - Пользователь получает `rate_limited`: подождите `EMAIL_CODE_RESEND_SECONDS` или проверьте brute-force настройки.
Email-уведомления поддержки и платежей используют тот же SMTP-контур. Сценарий поддержки описан в [разделе тикетов](support.md). Email-уведомления поддержки, платежей и жизненного цикла подписки используют тот же SMTP-контур. Сценарий поддержки описан в [разделе тикетов](support.md), сводка по каналам - в разделе [уведомления](notifications.md).
+76
View File
@@ -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 и без канальной дедупликации.
+15 -3
View File
@@ -22,7 +22,7 @@ JSON-каталог может содержать несколько тариф
- добавление, редактирование и удаление тарифов; - добавление, редактирование и удаление тарифов;
- включение и выключение тарифа на витрине; - включение и выключение тарифа на витрине;
- выбор тарифа по умолчанию; - выбор тарифа по умолчанию;
- настройка тарифов на срок (`period`): месячный лимит, периоды, RUB/Stars цены, пакеты докупки трафика; - настройка тарифов на срок (`period`): месячный лимит, периоды, RUB/Stars цены, реферальные бонусы и пакеты докупки трафика;
- настройка тарифов по трафику (`traffic`): пакеты GB, RUB/Stars цены, курс конвертации; - настройка тарифов по трафику (`traffic`): пакеты GB, RUB/Stars цены, курс конвертации;
- настройка базовых Internal Squads из списка Remnawave; - настройка базовых Internal Squads из списка Remnawave;
- настройка premium-раздела: названия RU/EN, premium Internal Squads, месячный premium-лимит и RUB/Stars пакеты докупки premium-трафика; - настройка premium-раздела: названия RU/EN, premium Internal Squads, месячный premium-лимит и RUB/Stars пакеты докупки premium-трафика;
@@ -60,7 +60,9 @@ JSON-каталог может содержать несколько тариф
"squad_uuids": ["uuid-1"], "squad_uuids": ["uuid-1"],
"billing_model": "period", "billing_model": "period",
"monthly_gb": 500, "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], "enabled_periods": [1, 3],
"topup_packages": { "topup_packages": {
"rub": [{ "gb": 10, "price": 99 }], "rub": [{ "gb": 10, "price": 99 }],
@@ -108,6 +110,8 @@ JSON-каталог может содержать несколько тариф
| `monthly_gb` | Базовый месячный лимит трафика тарифа. `0` означает безлимит. | | `monthly_gb` | Базовый месячный лимит трафика тарифа. `0` означает безлимит. |
| `prices_rub` | Цены периодов в рублях, ключ - количество месяцев. | | `prices_rub` | Цены периодов в рублях, ключ - количество месяцев. |
| `prices_stars` | Цены периодов в Telegram Stars. | | `prices_stars` | Цены периодов в Telegram Stars. |
| `referral_bonus_days_inviter` | Бонус пригласившему в днях для каждого периода. Ключ - количество месяцев, как в `enabled_periods`. |
| `referral_bonus_days_referee` | Бонус приглашенному в днях для каждого периода. Ключ - количество месяцев, как в `enabled_periods`. |
| `enabled_periods` | Периоды, доступные для покупки. | | `enabled_periods` | Периоды, доступные для покупки. |
| `topup_packages` | Пакеты докупки трафика именно для этого тарифа. Если поле не задано или списки пустые, докупка для тарифа не показывается в Web App и Telegram-боте. | | `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`. Пробный период использует настройки `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 не показывает детализацию бонусов по месяцам.
## Привязка существующих подписок ## Привязка существующих подписок
+2
View File
@@ -118,6 +118,8 @@ services:
- Telegram deep-link: `https://t.me/<bot>?start=ref_u<code>`; - Telegram deep-link: `https://t.me/<bot>?start=ref_u<code>`;
- Web App ссылка: `https://app.domain.com/?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. Веб-приложение учитывает `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>`. Для email-регистраций пользователь в Remnawave создается с username вида `em_<referral_code>`. Email добавляется в описание пользователя панели и, если API панели принимает поле `email`, передается отдельным полем. Для Telegram-регистраций используется username `tg_<telegram_id>`.
+6 -1
View File
@@ -49,6 +49,8 @@ WEBAPP_DEFAULT_THEME=
`WEBAPP_DEFAULT_THEME` опционален. Если он задан и совпадает с ключом темы, он переопределяет `default: true` в `theme.json`. Если переменная пустая, дефолт выбирается из дескрипторов тем. `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. Важно: `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` ## Контракт `theme.json`
@@ -266,7 +268,7 @@ CSS можно писать для пользовательской части
2. Создайте папку в `WEBAPP_THEMES_DIR`. 2. Создайте папку в `WEBAPP_THEMES_DIR`.
В Docker по умолчанию это `data/themes`. Если включен bind mount `./data:/app/data`, убедитесь, что контейнер может писать в `data`. В Docker это `data/themes` рядом с выбранным `docker-compose.yml`, внутри контейнера путь будет `/app/data/themes`. Убедитесь, что контейнер может писать в `data`.
```bash ```bash
mkdir -p data/themes/neon mkdir -p data/themes/neon
@@ -314,6 +316,8 @@ CSS можно писать для пользовательской части
Начинайте с переопределения CSS-переменных на `.theme-key-neon.app-shell`, затем переходите к конкретным компонентам. Проверяйте минимум: главная, `/install`, публичная `/s/<token>`, оплата, настройки, модалки, админский дашборд, таблица пользователей, редактор тарифов. Начинайте с переопределения CSS-переменных на `.theme-key-neon.app-shell`, затем переходите к конкретным компонентам. Проверяйте минимум: главная, `/install`, публичная `/s/<token>`, оплата, настройки, модалки, админский дашборд, таблица пользователей, редактор тарифов.
После ручного изменения CSS поднимите `assets_version` в `theme.json` или сделайте жесткую перезагрузку страницы: тема подключается с `?v=<assets_version>`, и браузер может держать старую версию.
9. Добавьте ассеты при необходимости. 9. Добавьте ассеты при необходимости.
Положите картинки в подпапку темы и ссылайтесь на них через `/webapp-theme-assets/<key>/...`. Не используйте относительные пути вроде `url("icons/x.png")`, если CSS может быть подключен с другого URL-уровня; явный `/webapp-theme-assets/neon/icons/x.png` надежнее. Положите картинки в подпапку темы и ссылайтесь на них через `/webapp-theme-assets/<key>/...`. Не используйте относительные пути вроде `url("icons/x.png")`, если CSS может быть подключен с другого URL-уровня; явный `/webapp-theme-assets/neon/icons/x.png` надежнее.
@@ -357,6 +361,7 @@ CSS можно писать для пользовательской части
Если CSS не применился: Если CSS не применился:
- проверьте `css_file` и URL `/webapp-theme-css/<key>/<css_file>`; - проверьте `css_file` и URL `/webapp-theme-css/<key>/<css_file>`;
- если CSS уже был открыт в браузере, увеличьте `assets_version` в `theme.json` или очистите кеш;
- убедитесь, что файл меньше 512 KiB; - убедитесь, что файл меньше 512 KiB;
- начинайте селекторы с `.theme-key-<key>`; - начинайте селекторы с `.theme-key-<key>`;
- откройте `/home?theme_preview=<key>` в новом окне, чтобы исключить сохраненный старый выбор. - откройте `/home?theme_preview=<key>` в новом окне, чтобы исключить сохраненный старый выбор.
+5 -5
View File
@@ -24,14 +24,14 @@ nano .env
| --- | --- | | --- | --- |
| `BOT_TOKEN` | Токен Telegram-бота. | | `BOT_TOKEN` | Токен Telegram-бота. |
| `ADMIN_IDS` | Telegram ID администраторов через запятую; без этого не попасть в Web App админку. | | `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. | | `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB` | Доступы PostgreSQL для Compose и backend. |
| `WEBAPP_ENABLED` | Включает Web App и админку. Для первого запуска держите `True`. | | `WEBAPP_ENABLED` | Включает Web App и админку. Для первого запуска держите `True`. |
| `WEBAPP_SESSION_SECRET` | Стабильный секрет сессий Web App. | | `WEBAPP_SESSION_SECRET` | Стабильный секрет сессий Web App. |
| `WEBHOOK_SECRET_TOKEN` | Стабильный секретный токен вебхука Telegram. | | `WEBHOOK_SECRET_TOKEN` | Стабильный секретный токен вебхука Telegram. |
| `SUBSCRIPTION_MINI_APP_URL` | Публичный HTTPS URL Mini App/frontend, например `https://app.domain.com/`. Это URL, который открывают кнопки Telegram и который указывается в BotFather; не добавляйте сюда `/api` или webhook-пути. | | `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 и кнопках бота. По умолчанию включены; обычно их достаточно менять в админке. | | `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` можно сгенерировать так: `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-пакеты. 2. **Система -> Тарифы**: создайте JSON-каталог тарифов, выберите Internal Squads, настройте модели на срок/по трафику, premium-сквады и HWID-пакеты.
3. **Система -> Настройки -> Инструкции подключения**: проверьте, что Remnawave Panel отдает нужный конфиг Subscription Page. JSON-переопределение включайте только если нужно временно заменить конфиг панели. 3. **Система -> Настройки -> Инструкции подключения**: проверьте, что Remnawave Panel отдает нужный конфиг Subscription Page. JSON-переопределение включайте только если нужно временно заменить конфиг панели.
4. **Система -> Настройки -> Платежи**: включите нужные провайдеры и заполните их ключи. 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 ```bash
mkdir -p data/themes data/webapp-logo data/webapp-emoji data/tariffs mkdir -p data/themes data/webapp-logo data/webapp-emoji data/tariffs
+1
View File
@@ -12,6 +12,7 @@
- [Админка: бэкапы](/demo/admin/backups) - [Админка: бэкапы](/demo/admin/backups)
- [Пробный период](/demo/home?mock=trial) - [Пробный период](/demo/home?mock=trial)
- [Докупка устройств](/demo/devices?mock=devices) - [Докупка устройств](/demo/devices?mock=devices)
- [Запуск бота для Telegram-уведомлений](/demo/home?mock=notifications)
- [Вход и регистрация](/demo/login?mock=auth) - [Вход и регистрация](/demo/login?mock=auth)
## Как собирается ## Как собирается
+3 -5
View File
@@ -331,14 +331,12 @@ distributed lock; код подготовлен к нескольким репл
- `postgres-data`; - `postgres-data`;
- `redis-data`; - `redis-data`;
- `shop-data`;
В Caddy-варианте также используются `caddy-data` и `caddy-config`. В Caddy-варианте также используются `caddy-data` и `caddy-config`.
`shop-data` монтируется целиком в `/app/data`; внутри него лежат тарифы, темы, логотипы и прочие Файлы приложения монтируются из локальной папки `./data` рядом с выбранным `docker-compose.yml` в
файловые данные приложения. `/app/data`; внутри нее лежат тарифы, темы, логотипы и прочие файловые данные приложения.
Если вместо именованного volume включаете bind mount `./data:/app/data`, на сервере заранее дайте права Перед первым запуском на сервере заранее дайте права пользователю контейнера `10001`:
пользователю контейнера `10001`:
```bash ```bash
mkdir -p data/themes data/webapp-logo data/webapp-emoji data/tariffs mkdir -p data/themes data/webapp-logo data/webapp-emoji data/tariffs
+2
View File
@@ -20,6 +20,8 @@ docker compose logs -f backend worker frontend
- `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB`. - `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB`.
- `WEBAPP_SESSION_SECRET`, `WEBHOOK_SECRET_TOKEN`, `PANEL_API_URL`, `PANEL_API_KEY`, `PANEL_WEBHOOK_SECRET`. - `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-вариант ## Как выбрать Compose-вариант
Для продакшена по умолчанию берите [Caddy](deployment.md#caddy-рекомендуемый-вариант): это самый короткий путь к публичному HTTPS без ручной раскладки сертификатов. Для продакшена по умолчанию берите [Caddy](deployment.md#caddy-рекомендуемый-вариант): это самый короткий путь к публичному HTTPS без ручной раскладки сертификатов.
+1 -1
View File
@@ -125,7 +125,7 @@ docker compose \
| `TELEGRAM_WEBHOOK_PATH` | удалена | Путь вебхука теперь генерируется из `BOT_TOKEN` автоматически. | | `TELEGRAM_WEBHOOK_PATH` | удалена | Путь вебхука теперь генерируется из `BOT_TOKEN` автоматически. |
| `REQUIRED_CHANNEL_SUBSCRIBE_TO_USE` | удалена | Гейт включается автоматически, как только задан `REQUIRED_CHANNEL_ID`. | | `REQUIRED_CHANNEL_SUBSCRIBE_TO_USE` | удалена | Гейт включается автоматически, как только задан `REQUIRED_CHANNEL_ID`. |
| `STARS_PROVIDER_TOKEN` | удалена | Telegram Stars (XTR) используются напрямую. | | `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 сценариев. | | `POSTGRES_HOST=remnawave-tg-shop-db` | в `.env``remnawave-minishop-db` или пусто | Под compose значение всё равно переопределяется на сервисное имя `postgres` (см. `environment:` в compose-файлах), поэтому скрипт правит `.env` только для bare-metal сценариев. |
| `WEBHOOK_BASE_URL` | **обязательна** | Polling-режим удалён, без публичного URL бот не стартует. | | `WEBHOOK_BASE_URL` | **обязательна** | Polling-режим удалён, без публичного URL бот не стартует. |
| — | `REDIS_URL=redis://redis:6379/0` | Обязательна для воркера, очередей и rate-limit. По умолчанию в compose-файлах уже задана. | | — | `REDIS_URL=redis://redis:6379/0` | Обязательна для воркера, очередей и rate-limit. По умолчанию в compose-файлах уже задана. |
+152 -1
View File
@@ -73,6 +73,7 @@
const ACTIVATION_PENDING_WATCH_INTERVAL_MS = 2000; const ACTIVATION_PENDING_WATCH_INTERVAL_MS = 2000;
const ACTIVATION_PENDING_WATCH_MAX_ATTEMPTS = 45; const ACTIVATION_PENDING_WATCH_MAX_ATTEMPTS = 45;
const ACTIVATION_RESUME_CHECK_COOLDOWN_MS = 1500; const ACTIVATION_RESUME_CHECK_COOLDOWN_MS = 1500;
const TELEGRAM_NOTIFICATIONS_RESUME_REFRESH_COOLDOWN_MS = 1500;
import { import {
activationPaymentFailed, activationPaymentFailed,
createActivationHandoff, createActivationHandoff,
@@ -152,6 +153,7 @@
let mode = isAppLaunchRoute ? "appLaunch" : isPreviewBoard ? "preview" : "loading"; let mode = isAppLaunchRoute ? "appLaunch" : isPreviewBoard ? "preview" : "loading";
let activeTab = "home"; let activeTab = "home";
let screen = "home"; let screen = "home";
let emailLoginDeeplinkConsumed = false;
let data = isPreviewBoard ? structuredCloneSafe(MOCK_SOURCE.data) : null; let data = isPreviewBoard ? structuredCloneSafe(MOCK_SOURCE.data) : null;
let appLaunchTarget = isAppLaunchRoute ? readExternalAppLaunchTarget() : ""; let appLaunchTarget = isAppLaunchRoute ? readExternalAppLaunchTarget() : "";
let publicInstallSubscription = null; let publicInstallSubscription = null;
@@ -166,6 +168,9 @@
let activationPendingWatchBusy = false; let activationPendingWatchBusy = false;
let activationResumeRefreshBusy = false; let activationResumeRefreshBusy = false;
let activationResumeLastCheckAt = 0; let activationResumeLastCheckAt = 0;
let telegramNotificationsBotOpenedAt = 0;
let telegramNotificationsResumeRefreshBusy = false;
let telegramNotificationsResumeLastCheckAt = 0;
let promoCode = ""; let promoCode = "";
let promoBusy = false; let promoBusy = false;
let promoStatus = ""; let promoStatus = "";
@@ -323,6 +328,7 @@
devicesBusy, devicesBusy,
devicesStatus, devicesStatus,
devicesIsError, devicesIsError,
devicesErrorCode,
deviceConfirmOpen, deviceConfirmOpen,
deviceToDisconnect, deviceToDisconnect,
deviceDisconnectBusy, deviceDisconnectBusy,
@@ -473,7 +479,13 @@
languageOptions.find((option) => option.value === currentLang) || languageOptions[0]; languageOptions.find((option) => option.value === currentLang) || languageOptions[0];
$: userLanguage = languageName(currentLang); $: userLanguage = languageName(currentLang);
$: emailLinkStatus = user?.email ? t("wa_settings_linked") : t("wa_settings_email_not_linked"); $: 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 : []; $: referralBonusDetails = Array.isArray(referral?.bonus_details) ? referral.bonus_details : [];
$: referralWelcomeBonusDays = Math.max(0, Number(referral?.welcome_bonus_days || 0)); $: referralWelcomeBonusDays = Math.max(0, Number(referral?.welcome_bonus_days || 0));
$: referralOneBonusPerReferee = Boolean(referral?.one_bonus_per_referee); $: 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() { function refreshAppLaunchTarget() {
appLaunchTarget = readExternalAppLaunchTarget(); appLaunchTarget = readExternalAppLaunchTarget();
return appLaunchTarget; return appLaunchTarget;
@@ -769,6 +809,7 @@
const onActivationResume = () => { const onActivationResume = () => {
if (typeof document !== "undefined" && document.visibilityState === "hidden") return; if (typeof document !== "undefined" && document.visibilityState === "hidden") return;
void refreshPendingActivationOnResume(); void refreshPendingActivationOnResume();
void refreshTelegramNotificationsOnResume();
}; };
const onVisibilityChange = () => { const onVisibilityChange = () => {
if (document.visibilityState !== "hidden") onActivationResume(); 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() { function currentSearchParams() {
return new URLSearchParams(window.location.search); 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() { function docsDemoParentSearchParams() {
if (!isDocsDemo) return null; if (!isDocsDemo) return null;
try { try {
@@ -1316,6 +1432,7 @@
clearToken, clearToken,
clearManualLogoutFlag, clearManualLogoutFlag,
isManuallyLoggedOut, isManuallyLoggedOut,
hasEmailCodeLoginDeeplink,
finalizeMagicLogin: (loginToken) => authStore.finalizeMagicLogin(loginToken), finalizeMagicLogin: (loginToken) => authStore.finalizeMagicLogin(loginToken),
finalizeTelegramAuth: (authData, source) => authStore.finalizeTelegramAuth(authData, source), finalizeTelegramAuth: (authData, source) => authStore.finalizeTelegramAuth(authData, source),
setAuthStatus: (message, isError) => authStore.setAuthStatus(message, isError), setAuthStatus: (message, isError) => authStore.setAuthStatus(message, isError),
@@ -1508,6 +1625,30 @@
stripTopupQueryFromUrl(); 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; return payload;
} }
@@ -1529,6 +1670,7 @@
screen = "login"; screen = "login";
activeTab = "home"; activeTab = "home";
setPasswordLoginMode(isPasswordLoginPath(), true); setPasswordLoginMode(isPasswordLoginPath(), true);
void startEmailCodeLoginFromDeeplink();
} }
async function api(path, options = {}) { async function api(path, options = {}) {
@@ -2124,10 +2266,14 @@
{regularTrafficTopupBarClickable} {regularTrafficTopupBarClickable}
{regularTrafficTopupUnlocked} {regularTrafficTopupUnlocked}
{subscription} {subscription}
{telegramNotificationsNeedPrompt}
{telegramNotificationsStartLink}
{telegramNotificationsStatus}
{termUnitLabel} {termUnitLabel}
{trafficMode} {trafficMode}
{trialBusy} {trialBusy}
{activateTrial} {activateTrial}
{openTelegramNotificationsBot}
openConnectLink={openInstallOrConnect} openConnectLink={openInstallOrConnect}
{openPaymentModal} {openPaymentModal}
{openRegularTopupModal} {openRegularTopupModal}
@@ -2185,6 +2331,7 @@
{devicesData} {devicesData}
{devicesIsError} {devicesIsError}
{devicesLoaded} {devicesLoaded}
{devicesErrorCode}
{devicesStatus} {devicesStatus}
{subscription} {subscription}
{loadDevices} {loadDevices}
@@ -2230,12 +2377,16 @@
{profileEmail} {profileEmail}
{profileTelegramId} {profileTelegramId}
{supportUrl} {supportUrl}
{telegramNotificationsNeedPrompt}
{telegramNotificationsStartLink}
{telegramNotificationsStatus}
{telegramProfileName} {telegramProfileName}
{user} {user}
{userAgreementUrl} {userAgreementUrl}
{userLanguage} {userLanguage}
showLogout={!telegramMiniAppContext} showLogout={!telegramMiniAppContext}
linkTelegramAccount={linkTelegramFromSettings} linkTelegramAccount={linkTelegramFromSettings}
{openTelegramNotificationsBot}
logout={accountStore.logout} logout={accountStore.logout}
{openAdminPanel} {openAdminPanel}
{openExternalLink} {openExternalLink}
+16 -1
View File
@@ -389,6 +389,21 @@
usersStore.openUser(uid, { pathContext: "payments" }); 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) { function openUserCard(userId) {
const uid = Number(userId); const uid = Number(userId);
if (!Number.isFinite(uid) || uid === 0) return; if (!Number.isFinite(uid) || uid === 0) return;
@@ -801,7 +816,7 @@
{/if} {/if}
{#if active === "logs"} {#if active === "logs"}
<LogsSection {at} {fmtDate} /> <LogsSection {at} {fmtDate} onOpenUserCard={openLogsUserCard} />
{/if} {/if}
{#if active === "support"} {#if active === "support"}
@@ -1,4 +1,5 @@
<script> <script>
import { Input } from "$components/ui/index.js";
import { Trash2 } from "$components/ui/icons.js"; import { Trash2 } from "$components/ui/icons.js";
import { getContext, onMount } from "svelte"; import { getContext, onMount } from "svelte";
import Dialog from "$components/ui/dialog.svelte"; import Dialog from "$components/ui/dialog.svelte";
@@ -106,7 +107,7 @@
<div class="admin-form" data-dialog-content> <div class="admin-form" data-dialog-content>
<div class="admin-dialog-form-section"> <div class="admin-dialog-form-section">
<AdminField label={at("ad_label_source", {}, "Источник")}> <AdminField label={at("ad_label_source", {}, "Источник")}>
<input <Input
class="input" class="input"
type="text" type="text"
placeholder="telegram_ads" placeholder="telegram_ads"
@@ -118,7 +119,7 @@
label={at("ad_label_param", {}, "start-параметр")} label={at("ad_label_param", {}, "start-параметр")}
hint={at("ad_hint_param", {}, "Передаётся в /start, должен быть уникален")} hint={at("ad_hint_param", {}, "Передаётся в /start, должен быть уникален")}
> >
<input <Input
class="input" class="input"
type="text" type="text"
placeholder="ads_summer25" placeholder="ads_summer25"
@@ -129,7 +130,7 @@
</div> </div>
<div class="admin-dialog-form-section"> <div class="admin-dialog-form-section">
<AdminField label={at("ad_label_cost", {}, "Стоимость, RUB")}> <AdminField label={at("ad_label_cost", {}, "Стоимость, RUB")}>
<input <Input
class="input" class="input"
type="number" type="number"
step="0.01" step="0.01"
@@ -6,7 +6,7 @@
AdminEmptyState, AdminEmptyState,
AdminSelect, AdminSelect,
} from "$components/patterns/admin/index.js"; } 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 { Switch } from "$components/ui/primitives.js";
import { getContext, onDestroy, onMount } from "svelte"; import { getContext, onDestroy, onMount } from "svelte";
@@ -444,10 +444,9 @@
<div class="appearance-controls"> <div class="appearance-controls">
<section class="appearance-control-card"> <section class="appearance-control-card">
<input <FileInput
bind:this={logoFileInput} bind:element={logoFileInput}
class="appearance-file-input" class="appearance-file-input"
type="file"
accept="image/png,image/jpeg,image/gif,image/webp,image/svg+xml,image/x-icon" accept="image/png,image/jpeg,image/gif,image/webp,image/svg+xml,image/x-icon"
onchange={handleLogoFileChange} onchange={handleLogoFileChange}
/> />
@@ -461,7 +460,7 @@
{at("appearance_logo_upload_file", {}, "Загрузить файл")} {at("appearance_logo_upload_file", {}, "Загрузить файл")}
</AdminButton> </AdminButton>
<div class="appearance-url-row"> <div class="appearance-url-row">
<input <Input
class="input appearance-control" class="input appearance-control"
type="url" type="url"
placeholder="https://example.com/logo.png" placeholder="https://example.com/logo.png"
@@ -490,7 +489,7 @@
<span>{at("appearance_use_emoji_logo", {}, "Использовать emoji-логотип")}</span> <span>{at("appearance_use_emoji_logo", {}, "Использовать emoji-логотип")}</span>
</label> </label>
<div class="appearance-emoji-grid"> <div class="appearance-emoji-grid">
<input <Input
class="input appearance-control" class="input appearance-control"
type="text" type="text"
maxlength="8" maxlength="8"
@@ -547,10 +546,9 @@
>{at("appearance_use_custom_favicon", {}, "Использовать отдельную favicon")}</span >{at("appearance_use_custom_favicon", {}, "Использовать отдельную favicon")}</span
> >
</label> </label>
<input <FileInput
bind:this={faviconFileInput} bind:element={faviconFileInput}
class="appearance-file-input" class="appearance-file-input"
type="file"
accept="image/png,image/jpeg,image/gif,image/webp,image/svg+xml,image/x-icon,.ico" accept="image/png,image/jpeg,image/gif,image/webp,image/svg+xml,image/x-icon,.ico"
onchange={handleFaviconFileChange} onchange={handleFaviconFileChange}
/> />
@@ -564,7 +562,7 @@
{at("appearance_favicon_upload_file", {}, "Загрузить favicon")} {at("appearance_favicon_upload_file", {}, "Загрузить favicon")}
</AdminButton> </AdminButton>
<div class="appearance-url-row"> <div class="appearance-url-row">
<input <Input
class="input appearance-control" class="input appearance-control"
type="url" type="url"
placeholder="https://example.com/icon.png" placeholder="https://example.com/icon.png"
@@ -657,18 +655,17 @@
</span> </span>
<label class="admin-theme-card-option appearance-color-row"> <label class="admin-theme-card-option appearance-color-row">
<span>{at("appearance_theme_accent", {}, "Accent")}</span> <span>{at("appearance_theme_accent", {}, "Accent")}</span>
<input <ColorInput
class="admin-color" class={`admin-color${!isThemeAccentSet(theme) ? " is-empty" : ""}`}
class:is-empty={!isThemeAccentSet(theme)}
type="color"
value={pickerHex(theme.tokens?.accent)} value={pickerHex(theme.tokens?.accent)}
ariaLabel={at("appearance_theme_accent", {}, "Accent")}
title={isThemeAccentSet(theme) title={isThemeAccentSet(theme)
? theme.tokens?.accent ? theme.tokens?.accent
: at("appearance_theme_accent_empty", {}, "Не задан")} : at("appearance_theme_accent_empty", {}, "Не задан")}
onclick={() => openThemeAccentPicker(theme)} onclick={() => openThemeAccentPicker(theme)}
oninput={(event) => setThemeAccent(theme, event.currentTarget.value)} oninput={(event) => setThemeAccent(theme, event.currentTarget.value)}
/> />
<input <Input
class="input appearance-color-text" class="input appearance-color-text"
type="text" type="text"
placeholder={at("appearance_theme_accent_placeholder", {}, "Не задан")} placeholder={at("appearance_theme_accent_placeholder", {}, "Не задан")}
@@ -693,17 +690,17 @@
"Логотип на главной и входе" "Логотип на главной и входе"
)}</span )}</span
> >
<input <RangeInput
class="appearance-logo-scale-range" class="appearance-logo-scale-range"
type="range"
min="50" min="50"
max="300" max="300"
step="5" step="5"
ariaLabel={at("appearance_theme_home_logo_scale", {}, "Home logo scale")}
value={homeLogoScale(theme)} value={homeLogoScale(theme)}
oninput={(event) => setThemeHomeLogoScale(theme, event.currentTarget.value)} onValueChange={(value) => setThemeHomeLogoScale(theme, value)}
/> />
<span class="appearance-logo-scale-value"> <span class="appearance-logo-scale-value">
<input <Input
class="input" class="input"
type="number" type="number"
min="50" min="50"
@@ -814,7 +811,7 @@
background: color-mix(in srgb, var(--admin-surface-2) 40%, transparent); background: color-mix(in srgb, var(--admin-surface-2) 40%, transparent);
} }
.appearance-file-input { :global(.appearance-file-input) {
display: none; display: none;
} }
@@ -949,7 +946,7 @@
width: 100%; width: 100%;
} }
.appearance-color-text { :global(.appearance-color-text) {
min-width: 0; min-width: 0;
} }
@@ -959,7 +956,7 @@
width: 100%; width: 100%;
} }
.appearance-logo-scale-range { :global(.appearance-logo-scale-range) {
width: 100%; width: 100%;
accent-color: var(--accent); accent-color: var(--accent);
} }
@@ -971,14 +968,14 @@
color: var(--admin-text); color: var(--admin-text);
} }
.appearance-logo-scale-value .input { :global(.appearance-logo-scale-value .input) {
width: 70px; width: 70px;
min-height: 32px; min-height: 32px;
padding: 4px 8px; padding: 4px 8px;
font-size: 12px; font-size: 12px;
} }
.admin-color.is-empty { :global(.admin-color.is-empty) {
opacity: 0.42; opacity: 0.42;
filter: grayscale(1); filter: grayscale(1);
} }
@@ -8,7 +8,7 @@
AdminTable, AdminTable,
AdminTableSkeleton, AdminTableSkeleton,
} from "$components/patterns/admin/index.js"; } 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 { import {
CheckCircle2, CheckCircle2,
Database, Database,
@@ -178,10 +178,9 @@
? at("backups_uploading", {}, "Загрузка...") ? at("backups_uploading", {}, "Загрузка...")
: at("backups_upload", {}, "Загрузить архив")} : at("backups_upload", {}, "Загрузить архив")}
</AdminButton> </AdminButton>
<input <FileInput
bind:this={fileInput} bind:element={fileInput}
class="backups-file-input" class="backups-file-input"
type="file"
accept=".zip,application/zip" accept=".zip,application/zip"
on:change={uploadSelectedFile} on:change={uploadSelectedFile}
/> />
@@ -362,7 +361,7 @@
align-items: center; align-items: center;
} }
.backups-file-input { :global(.backups-file-input) {
position: absolute; position: absolute;
width: 1px; width: 1px;
height: 1px; height: 1px;
@@ -1,4 +1,5 @@
<script> <script>
import { Textarea } from "$components/ui/index.js";
import { Send } from "$components/ui/icons.js"; import { Send } from "$components/ui/icons.js";
import { getContext } from "svelte"; import { getContext } from "svelte";
import { Label } from "$components/ui/primitives.js"; import { Label } from "$components/ui/primitives.js";
@@ -31,12 +32,12 @@
<Label.Root class="admin-field-label"> <Label.Root class="admin-field-label">
<span>{at("broadcast_label_text", {}, "Текст сообщения")}</span> <span>{at("broadcast_label_text", {}, "Текст сообщения")}</span>
<small>{at("broadcast_hint_text", {}, "Поддерживается HTML-разметка Telegram")}</small> <small>{at("broadcast_hint_text", {}, "Поддерживается HTML-разметка Telegram")}</small>
<textarea <Textarea
class="admin-textarea" class="admin-textarea"
rows="6" rows="6"
value={broadcastText} value={broadcastText}
on:input={(e) => broadcastStore.updateField({ broadcastText: e.target.value })} on:input={(e) => broadcastStore.updateField({ broadcastText: e.target.value })}
></textarea> />
</Label.Root> </Label.Root>
<div style="display:flex; gap:8px; align-items:center;"> <div style="display:flex; gap:8px; align-items:center;">
<AdminButton <AdminButton
+116 -6
View File
@@ -1,4 +1,5 @@
<script> <script>
import { Input } from "$components/ui/index.js";
import { getContext, onMount } from "svelte"; import { getContext, onMount } from "svelte";
import { import {
AdminButton, AdminButton,
@@ -7,9 +8,11 @@
AdminTable, AdminTable,
AdminTableSkeleton, AdminTableSkeleton,
} from "$components/patterns/admin/index.js"; } from "$components/patterns/admin/index.js";
import { User } from "$components/ui/icons.js";
export let at; export let at;
export let fmtDate; export let fmtDate;
export let onOpenUserCard = () => {};
const logsStore = getContext("logsStore"); const logsStore = getContext("logsStore");
@@ -24,6 +27,25 @@
at("content", {}, "Контент"), 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(() => { onMount(() => {
logsStore.loadLogs(); logsStore.loadLogs();
}); });
@@ -31,7 +53,7 @@
<div class="admin-toolbar admin-toolbar-card"> <div class="admin-toolbar admin-toolbar-card">
<div class="admin-toolbar-search admin-toolbar-search-actions"> <div class="admin-toolbar-search admin-toolbar-search-actions">
<input <Input
type="search" type="search"
class="input" class="input"
placeholder={at("logs_user_filter_placeholder", {}, "Фильтр по ID пользователя")} placeholder={at("logs_user_filter_placeholder", {}, "Фильтр по ID пользователя")}
@@ -64,7 +86,7 @@
<AdminTableSkeleton <AdminTableSkeleton
headers={logHeaders} headers={logHeaders}
rows={10} rows={10}
widths={["120px", "120px", "58px", "58px", "220px"]} widths={["120px", "120px", "160px", "160px", "220px"]}
/> />
{:else if !logs.length} {:else if !logs.length}
<AdminEmptyState tone="card" <AdminEmptyState tone="card"
@@ -88,12 +110,50 @@
<td class="admin-cell-mono" data-label={at("event", {}, "Событие")} <td class="admin-cell-mono" data-label={at("event", {}, "Событие")}
>{entry.event_type}</td >{entry.event_type}</td
> >
<td class="admin-cell-mono" data-label={at("user_short", {}, "User")} <td class="admin-logs-user-cell" data-label={at("user_short", {}, "User")}>
>{entry.user_id || "—"}</td {#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"))}
> >
<td class="admin-cell-mono" data-label={at("target_short", {}, "Target")} <User size={14} />
>{entry.target_user_id || "—"}</td </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", {}, "Контент")} <td class="admin-cell-wrap" data-label={at("content", {}, "Контент")}
>{entry.content || ""}</td >{entry.content || ""}</td
> >
@@ -117,3 +177,53 @@
logsStore.setPage(logsPage + 1); 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> <script>
import { Input } from "$components/ui/index.js";
import { Trash2 } from "$components/ui/icons.js"; import { Trash2 } from "$components/ui/icons.js";
import { getContext, onMount } from "svelte"; import { getContext, onMount } from "svelte";
import Dialog from "$components/ui/dialog.svelte"; import Dialog from "$components/ui/dialog.svelte";
@@ -110,7 +111,7 @@
<div class="admin-form" data-dialog-content> <div class="admin-form" data-dialog-content>
<div class="admin-dialog-form-section"> <div class="admin-dialog-form-section">
<AdminField label={at("promo_label_code", {}, "Код")}> <AdminField label={at("promo_label_code", {}, "Код")}>
<input <Input
type="text" type="text"
class="input" class="input"
value={promoDraft.code} value={promoDraft.code}
@@ -122,7 +123,7 @@
<div class="admin-dialog-form-section"> <div class="admin-dialog-form-section">
<div class="admin-form-row-2"> <div class="admin-form-row-2">
<AdminField label={at("promo_label_bonus_days", {}, "Бонус (дней)")}> <AdminField label={at("promo_label_bonus_days", {}, "Бонус (дней)")}>
<input <Input
type="number" type="number"
class="input" class="input"
min="1" min="1"
@@ -131,7 +132,7 @@
/> />
</AdminField> </AdminField>
<AdminField label={at("promo_label_max_activations", {}, "Макс. активаций")}> <AdminField label={at("promo_label_max_activations", {}, "Макс. активаций")}>
<input <Input
type="number" type="number"
class="input" class="input"
min="1" min="1"
@@ -141,7 +142,7 @@
</AdminField> </AdminField>
</div> </div>
<AdminField label={at("promo_label_valid_days", {}, "Срок действия (дней от текущего)")}> <AdminField label={at("promo_label_valid_days", {}, "Срок действия (дней от текущего)")}>
<input <Input
type="number" type="number"
class="input" class="input"
min="1" min="1"
@@ -1,4 +1,5 @@
<script> <script>
import { ColorInput, FileInput, Input, ScrollArea, Textarea } from "$components/ui/index.js";
import { import {
Check, Check,
ChevronRight, ChevronRight,
@@ -198,6 +199,8 @@
url, url,
requiresBaseUrl: Boolean(field.webhook_requires_base_url), requiresBaseUrl: Boolean(field.webhook_requires_base_url),
baseConfigured: field.webhook_base_url_configured !== false, baseConfigured: field.webhook_base_url_configured !== false,
hintI18nKey: field.webhook_hint_i18n_key || "",
hintFallback: field.webhook_hint || "",
}; };
} }
@@ -333,6 +336,7 @@
function sectionTitle(id) { function sectionTitle(id) {
const map = { const map = {
general: "Общие", general: "Общие",
remnawave: "Remnawave Panel",
appearance: "Внешний вид", appearance: "Внешний вид",
pricing: "Тарифы и цены", pricing: "Тарифы и цены",
payments: "Платёжные системы", payments: "Платёжные системы",
@@ -415,9 +419,9 @@
<small> <small>
{webhook.url {webhook.url
? at( ? 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( : at(
"settings_provider_webhook_base_missing", "settings_provider_webhook_base_missing",
@@ -512,13 +516,13 @@
> >
</div> </div>
{:else if field.type === "color"} {:else if field.type === "color"}
<input <ColorInput
class="admin-color" class="admin-color"
type="color"
value={valueFor(field) || "#00fe7a"} value={valueFor(field) || "#00fe7a"}
ariaLabel={fieldLabelText(field)}
oninput={(e) => settingsStore.markDirty(field.key, e.currentTarget.value)} oninput={(e) => settingsStore.markDirty(field.key, e.currentTarget.value)}
/> />
<input <Input
class="input" class="input"
type="text" type="text"
value={valueFor(field) || ""} value={valueFor(field) || ""}
@@ -557,7 +561,7 @@
onValueChange={(value) => settingsStore.markDirty(field.key, value)} onValueChange={(value) => settingsStore.markDirty(field.key, value)}
/> />
{:else if field.type === "int" || field.type === "float"} {:else if field.type === "int" || field.type === "float"}
<input <Input
class="input" class="input"
type="number" type="number"
step={field.type === "float" ? "0.1" : "1"} step={field.type === "float" ? "0.1" : "1"}
@@ -568,19 +572,18 @@
oninput={(e) => settingsStore.markDirty(field.key, e.currentTarget.value)} oninput={(e) => settingsStore.markDirty(field.key, e.currentTarget.value)}
/> />
{:else if field.type === "text"} {:else if field.type === "text"}
<textarea <Textarea
class="admin-setting-textarea" class="admin-setting-textarea"
rows="4" rows="4"
placeholder={fieldPlaceholderText(field)} placeholder={fieldPlaceholderText(field)}
value={valueFor(field) ?? ""} value={valueFor(field) ?? ""}
oninput={(e) => settingsStore.markDirty(field.key, e.currentTarget.value)} oninput={(e) => settingsStore.markDirty(field.key, e.currentTarget.value)}
></textarea> />
{:else if field.type === "json"} {:else if field.type === "json"}
<div class="admin-json-toolbar"> <div class="admin-json-toolbar">
<input <FileInput
id={"json-file-" + field.key} id={"json-file-" + field.key}
class="admin-json-file-input" class="admin-json-file-input"
type="file"
accept="application/json,.json" accept="application/json,.json"
onchange={(event) => handleJsonFile(field, event)} onchange={(event) => handleJsonFile(field, event)}
/> />
@@ -602,16 +605,16 @@
</AdminButton> </AdminButton>
{/if} {/if}
</div> </div>
<textarea <Textarea
class="admin-setting-textarea admin-setting-json-textarea" class="admin-setting-textarea admin-setting-json-textarea"
rows="10" rows="10"
spellcheck="false" spellcheck="false"
placeholder={fieldPlaceholderText(field)} placeholder={fieldPlaceholderText(field)}
value={valueFor(field) ?? ""} value={valueFor(field) ?? ""}
oninput={(e) => settingsStore.markDirty(field.key, e.currentTarget.value)} oninput={(e) => settingsStore.markDirty(field.key, e.currentTarget.value)}
></textarea> />
{:else if field.secret} {:else if field.secret}
<input <Input
class="input" class="input"
type={revealed ? "text" : "password"} type={revealed ? "text" : "password"}
placeholder={secretPlaceholder(field)} placeholder={secretPlaceholder(field)}
@@ -628,7 +631,7 @@
{#if revealed}<EyeOff size={13} />{:else}<Eye size={13} />{/if} {#if revealed}<EyeOff size={13} />{:else}<Eye size={13} />{/if}
</AdminButton> </AdminButton>
{:else} {:else}
<input <Input
class="input" class="input"
type="text" type="text"
placeholder={fieldPlaceholderText(field)} placeholder={fieldPlaceholderText(field)}
@@ -813,7 +816,7 @@
<div class="admin-icon-picker-toolbar"> <div class="admin-icon-picker-toolbar">
<label class="admin-icon-picker-search"> <label class="admin-icon-picker-search">
<Search size={15} /> <Search size={15} />
<input <Input
bind:value={iconPickerSearch} bind:value={iconPickerSearch}
class="input" class="input"
type="text" type="text"
@@ -821,6 +824,7 @@
/> />
</label> </label>
</div> </div>
<ScrollArea class="admin-icon-picker-scroll" maxHeight="min(52vh, 460px)">
<div class="admin-icon-picker-grid"> <div class="admin-icon-picker-grid">
{#each filteredIconOptions as iconName} {#each filteredIconOptions as iconName}
{@const Icon = iconComponent(iconName)} {@const Icon = iconComponent(iconName)}
@@ -837,5 +841,6 @@
</button> </button>
{/each} {/each}
</div> </div>
</ScrollArea>
</div> </div>
</Dialog> </Dialog>
@@ -4,6 +4,7 @@
import { fmtTrafficBytes } from "../../lib/admin/format.js"; import { fmtTrafficBytes } from "../../lib/admin/format.js";
import Badge from "$components/ui/badge.svelte"; import Badge from "$components/ui/badge.svelte";
import { ScrollArea } from "$components/ui/index.js";
import * as Card from "$components/ui/card/index.js"; import * as Card from "$components/ui/card/index.js";
import { import {
AdminDashboardGrid, AdminDashboardGrid,
@@ -606,6 +607,7 @@
style="width:78%;height:9px;margin-top:6px" style="width:78%;height:9px;margin-top:6px"
></span> ></span>
</div> </div>
<ScrollArea class="admin-panel-dash-nodes-scroll" maxHeight="240px">
<div class="admin-panel-dash-nodes-grid"> <div class="admin-panel-dash-nodes-grid">
{#each Array(4) as _, m (m)} {#each Array(4) as _, m (m)}
<div class="admin-panel-dash-node"> <div class="admin-panel-dash-node">
@@ -621,6 +623,7 @@
</div> </div>
{/each} {/each}
</div> </div>
</ScrollArea>
</div> </div>
</div> </div>
</Card.Content> </Card.Content>
@@ -1018,6 +1021,7 @@
{at("stats_panel_inner_nodes_hint", {}, "")} {at("stats_panel_inner_nodes_hint", {}, "")}
</p> </p>
</div> </div>
<ScrollArea class="admin-panel-dash-nodes-scroll" maxHeight="240px">
<div class="admin-panel-dash-nodes-grid"> <div class="admin-panel-dash-nodes-grid">
{#each panelNodeTraffic.seven.slice(0, PANEL_NODE_TILE_LIMIT) as node} {#each panelNodeTraffic.seven.slice(0, PANEL_NODE_TILE_LIMIT) as node}
<div class="admin-panel-dash-node"> <div class="admin-panel-dash-node">
@@ -1031,6 +1035,7 @@
</div> </div>
{/each} {/each}
</div> </div>
</ScrollArea>
{#if panelNodeTraffic.seven.length > PANEL_NODE_TILE_LIMIT} {#if panelNodeTraffic.seven.length > PANEL_NODE_TILE_LIMIT}
<p class="admin-panel-dash-nodes-more"> <p class="admin-panel-dash-nodes-more">
{at( {at(
@@ -11,7 +11,7 @@
import { TicketMessageBubble } from "$components/patterns/webapp/index.js"; import { TicketMessageBubble } from "$components/patterns/webapp/index.js";
import Dialog from "$components/ui/dialog.svelte"; import Dialog from "$components/ui/dialog.svelte";
import { Search } from "$components/ui/icons.js"; 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 at = (key) => key;
export let initialTicketId = null; export let initialTicketId = null;
@@ -215,7 +215,7 @@
<div class="support-admin-toolbar admin-toolbar-card"> <div class="support-admin-toolbar admin-toolbar-card">
<label class="support-admin-search"> <label class="support-admin-search">
<Search size={16} /> <Search size={16} />
<input <Input
class="input" class="input"
type="search" type="search"
placeholder={at("support_search", {}, "Поиск")} placeholder={at("support_search", {}, "Поиск")}
@@ -269,7 +269,8 @@
{:else if !tickets.length} {:else if !tickets.length}
<div class="admin-empty-state">{at("support_empty", {}, "Тикетов пока нет")}</div> <div class="admin-empty-state">{at("support_empty", {}, "Тикетов пока нет")}</div>
{:else} {:else}
<div class="support-inbox-list"> <ScrollArea class="support-inbox-list" maxHeight="none">
<div class="support-inbox-list-inner">
{#each tickets as ticket} {#each tickets as ticket}
<SupportInboxRow <SupportInboxRow
{ticket} {ticket}
@@ -279,6 +280,7 @@
/> />
{/each} {/each}
</div> </div>
</ScrollArea>
{/if} {/if}
</section> </section>
</div> </div>
@@ -1,4 +1,5 @@
<script> <script>
import { Input } from "$components/ui/index.js";
import { Tabs, Switch, Label } from "$components/ui/primitives.js"; import { Tabs, Switch, Label } from "$components/ui/primitives.js";
import Dialog from "$components/ui/dialog.svelte"; import Dialog from "$components/ui/dialog.svelte";
import { Plus, Save, Trash2, X } from "$components/ui/icons.js"; import { Plus, Save, Trash2, X } from "$components/ui/icons.js";
@@ -71,7 +72,7 @@
"Латиницей, без пробелов. Используется в платежах и подписках, менять после публикации не рекомендуется" "Латиницей, без пробелов. Используется в платежах и подписках, менять после публикации не рекомендуется"
)}</small )}</small
> >
<input <Input
class="input" class="input"
type="text" type="text"
placeholder="standard" placeholder="standard"
@@ -127,7 +128,7 @@
<div class="admin-form-row admin-form-row-2"> <div class="admin-form-row admin-form-row-2">
<Label.Root class="admin-field-label"> <Label.Root class="admin-field-label">
<span>{at("tariff_label_name_ru", {}, "Название · RU")}</span> <span>{at("tariff_label_name_ru", {}, "Название · RU")}</span>
<input <Input
class="input" class="input"
type="text" type="text"
placeholder={at("tariff_placeholder_name_ru", {}, "Стандарт")} placeholder={at("tariff_placeholder_name_ru", {}, "Стандарт")}
@@ -136,7 +137,7 @@
</Label.Root> </Label.Root>
<Label.Root class="admin-field-label"> <Label.Root class="admin-field-label">
<span>{at("tariff_label_name_en", {}, "Название · EN")}</span> <span>{at("tariff_label_name_en", {}, "Название · EN")}</span>
<input <Input
class="input" class="input"
type="text" type="text"
placeholder={at("tariff_placeholder_name_en", {}, "Standard")} placeholder={at("tariff_placeholder_name_en", {}, "Standard")}
@@ -148,7 +149,7 @@
<div class="admin-form-row admin-form-row-2"> <div class="admin-form-row admin-form-row-2">
<Label.Root class="admin-field-label"> <Label.Root class="admin-field-label">
<span>{at("tariff_label_desc_ru", {}, "Описание · RU")}</span> <span>{at("tariff_label_desc_ru", {}, "Описание · RU")}</span>
<input <Input
class="input" class="input"
type="text" type="text"
placeholder={at("tariff_placeholder_desc_ru", {}, "Базовый набор серверов")} placeholder={at("tariff_placeholder_desc_ru", {}, "Базовый набор серверов")}
@@ -157,7 +158,7 @@
</Label.Root> </Label.Root>
<Label.Root class="admin-field-label"> <Label.Root class="admin-field-label">
<span>{at("tariff_label_desc_en", {}, "Описание · EN")}</span> <span>{at("tariff_label_desc_en", {}, "Описание · EN")}</span>
<input <Input
class="input" class="input"
type="text" type="text"
placeholder={at("tariff_placeholder_desc_en", {}, "Base server pool")} placeholder={at("tariff_placeholder_desc_en", {}, "Base server pool")}
@@ -211,7 +212,7 @@
"Сколько устройств может одновременно использовать подписку. Пусто — взять значение из .env, 0 — без ограничений" "Сколько устройств может одновременно использовать подписку. Пусто — взять значение из .env, 0 — без ограничений"
)}</small )}</small
> >
<input <Input
class="input" class="input"
type="number" type="number"
min="0" min="0"
@@ -229,7 +230,7 @@
"Сколько GB включено в тариф на каждый месяц. 0 — безлимитный трафика. Сверху можно докупать пакеты на вкладке «Докупки»" "Сколько GB включено в тариф на каждый месяц. 0 — безлимитный трафика. Сверху можно докупать пакеты на вкладке «Докупки»"
)}</small )}</small
> >
<input <Input
class="input" class="input"
type="number" type="number"
min="0" min="0"
@@ -248,7 +249,7 @@
"По этому курсу остаток подписки пересчитывается в гигабайты при переходе пользователя с тарифа «Период» на «Трафик»" "По этому курсу остаток подписки пересчитывается в гигабайты при переходе пользователя с тарифа «Период» на «Трафик»"
)}</small )}</small
> >
<input <Input
class="input" class="input"
type="number" type="number"
min="0" min="0"
@@ -287,7 +288,7 @@
"Эта строка заменит «Premium-серверы» в кабинете, докупках и карточках лимитов." "Эта строка заменит «Premium-серверы» в кабинете, докупках и карточках лимитов."
)}</small )}</small
> >
<input <Input
class="input" class="input"
type="text" type="text"
placeholder={at("tariff_placeholder_premium_name_ru", {}, "Premium-серверы")} placeholder={at("tariff_placeholder_premium_name_ru", {}, "Premium-серверы")}
@@ -303,7 +304,7 @@
"Опционально для английского интерфейса." "Опционально для английского интерфейса."
)}</small )}</small
> >
<input <Input
class="input" class="input"
type="text" type="text"
placeholder={at("tariff_placeholder_premium_name_en", {}, "Premium servers")} placeholder={at("tariff_placeholder_premium_name_en", {}, "Premium servers")}
@@ -359,7 +360,7 @@
"Сколько GB через premium-сквады включено в тариф каждый месяц. 0 или пусто — отдельного premium-лимита нет (premium-нодами можно пользоваться без ограничения)" "Сколько GB через premium-сквады включено в тариф каждый месяц. 0 или пусто — отдельного premium-лимита нет (premium-нодами можно пользоваться без ограничения)"
)}</small )}</small
> >
<input <Input
class="input" class="input"
type="number" type="number"
min="0" min="0"
@@ -409,7 +410,7 @@
{/if} {/if}
{#each tariffDraft.premiumTopupRubRows as row, index} {#each tariffDraft.premiumTopupRubRows as row, index}
<div class="admin-row-editor-line"> <div class="admin-row-editor-line">
<input <Input
class="input" class="input"
type="number" type="number"
min="0.1" min="0.1"
@@ -418,7 +419,7 @@
bind:value={row.gb} bind:value={row.gb}
aria-label={at("tariff_col_volume_gb", {}, "Объём premium-пакета в GB")} aria-label={at("tariff_col_volume_gb", {}, "Объём premium-пакета в GB")}
/> />
<input <Input
class="input" class="input"
type="number" type="number"
min="0" min="0"
@@ -449,7 +450,7 @@
{/if} {/if}
{#each tariffDraft.premiumTopupStarsRows as row, index} {#each tariffDraft.premiumTopupStarsRows as row, index}
<div class="admin-row-editor-line"> <div class="admin-row-editor-line">
<input <Input
class="input" class="input"
type="number" type="number"
min="0.1" min="0.1"
@@ -458,7 +459,7 @@
bind:value={row.gb} bind:value={row.gb}
aria-label={at("tariff_col_volume_gb", {}, "Объём premium-пакета в GB")} aria-label={at("tariff_col_volume_gb", {}, "Объём premium-пакета в GB")}
/> />
<input <Input
class="input" class="input"
type="number" type="number"
min="0" min="0"
@@ -501,7 +502,13 @@
<AdminButton <AdminButton
size="sm" size="sm"
onclick={() => onclick={() =>
tariffsStore.addDraftRow("periodRows", { months: 1, rub: "", stars: "" })} tariffsStore.addDraftRow("periodRows", {
months: 1,
rub: "",
stars: "",
referral_inviter: "",
referral_referee: "",
})}
> >
<Plus size={13} /> <Plus size={13} />
{at("tariff_btn_period", {}, "Период")} {at("tariff_btn_period", {}, "Период")}
@@ -517,15 +524,17 @@
</p> </p>
{:else} {:else}
<div class="admin-row-editor"> <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_period_months", {}, "Срок, мес.")}</span>
<span>{at("tariff_col_price_rub", {}, "Цена, ₽")}</span> <span>{at("tariff_col_price_rub", {}, "Цена, ₽")}</span>
<span>{at("tariff_col_price_stars_full", {}, "Цена, ⭐ Stars")}</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> <span></span>
</div> </div>
{#each tariffDraft.periodRows as row, index} {#each tariffDraft.periodRows as row, index}
<div class="admin-row-editor-line admin-row-editor-4"> <div class="admin-row-editor-line admin-row-editor-6">
<input <Input
class="input" class="input"
type="number" type="number"
min="1" min="1"
@@ -533,7 +542,7 @@
bind:value={row.months} bind:value={row.months}
aria-label={at("tariff_col_period_months", {}, "Срок (месяцы)")} aria-label={at("tariff_col_period_months", {}, "Срок (месяцы)")}
/> />
<input <Input
class="input" class="input"
type="number" type="number"
min="0" min="0"
@@ -542,7 +551,7 @@
bind:value={row.rub} bind:value={row.rub}
aria-label={at("tariff_label_price_rub", {}, "Цена в рублях")} aria-label={at("tariff_label_price_rub", {}, "Цена в рублях")}
/> />
<input <Input
class="input" class="input"
type="number" type="number"
min="0" min="0"
@@ -551,6 +560,24 @@
bind:value={row.stars} bind:value={row.stars}
aria-label={at("tariff_label_price_stars", {}, "Цена в Telegram 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 <AdminButton
size="sm" size="sm"
variant="danger" variant="danger"
@@ -603,7 +630,7 @@
{/if} {/if}
{#each tariffDraft.trafficRubRows as row, index} {#each tariffDraft.trafficRubRows as row, index}
<div class="admin-row-editor-line"> <div class="admin-row-editor-line">
<input <Input
class="input" class="input"
type="number" type="number"
min="0.1" min="0.1"
@@ -612,7 +639,7 @@
bind:value={row.gb} bind:value={row.gb}
aria-label={at("tariff_col_volume_gb", {}, "Объём пакета в GB")} aria-label={at("tariff_col_volume_gb", {}, "Объём пакета в GB")}
/> />
<input <Input
class="input" class="input"
type="number" type="number"
min="0" min="0"
@@ -643,7 +670,7 @@
{/if} {/if}
{#each tariffDraft.trafficStarsRows as row, index} {#each tariffDraft.trafficStarsRows as row, index}
<div class="admin-row-editor-line"> <div class="admin-row-editor-line">
<input <Input
class="input" class="input"
type="number" type="number"
min="0.1" min="0.1"
@@ -652,7 +679,7 @@
bind:value={row.gb} bind:value={row.gb}
aria-label={at("tariff_col_volume_gb", {}, "Объём пакета в GB")} aria-label={at("tariff_col_volume_gb", {}, "Объём пакета в GB")}
/> />
<input <Input
class="input" class="input"
type="number" type="number"
min="0" min="0"
@@ -717,7 +744,7 @@
{/if} {/if}
{#each tariffDraft.topupRubRows as row, index} {#each tariffDraft.topupRubRows as row, index}
<div class="admin-row-editor-line"> <div class="admin-row-editor-line">
<input <Input
class="input" class="input"
type="number" type="number"
min="0.1" min="0.1"
@@ -726,7 +753,7 @@
bind:value={row.gb} bind:value={row.gb}
aria-label={at("tariff_col_volume_gb", {}, "Объём пакета в GB")} aria-label={at("tariff_col_volume_gb", {}, "Объём пакета в GB")}
/> />
<input <Input
class="input" class="input"
type="number" type="number"
min="0" min="0"
@@ -757,7 +784,7 @@
{/if} {/if}
{#each tariffDraft.topupStarsRows as row, index} {#each tariffDraft.topupStarsRows as row, index}
<div class="admin-row-editor-line"> <div class="admin-row-editor-line">
<input <Input
class="input" class="input"
type="number" type="number"
min="0.1" min="0.1"
@@ -766,7 +793,7 @@
bind:value={row.gb} bind:value={row.gb}
aria-label={at("tariff_col_volume_gb", {}, "Объём пакета в GB")} aria-label={at("tariff_col_volume_gb", {}, "Объём пакета в GB")}
/> />
<input <Input
class="input" class="input"
type="number" type="number"
min="0" min="0"
@@ -841,7 +868,7 @@
{/if} {/if}
{#each tariffDraft.hwidRubRows as row, index} {#each tariffDraft.hwidRubRows as row, index}
<div class="admin-row-editor-line"> <div class="admin-row-editor-line">
<input <Input
class="input" class="input"
type="number" type="number"
min="1" min="1"
@@ -854,7 +881,7 @@
"Сколько устройств добавляет пакет" "Сколько устройств добавляет пакет"
)} )}
/> />
<input <Input
class="input" class="input"
type="number" type="number"
min="0" min="0"
@@ -885,7 +912,7 @@
{/if} {/if}
{#each tariffDraft.hwidStarsRows as row, index} {#each tariffDraft.hwidStarsRows as row, index}
<div class="admin-row-editor-line"> <div class="admin-row-editor-line">
<input <Input
class="input" class="input"
type="number" type="number"
min="1" min="1"
@@ -898,7 +925,7 @@
"Сколько устройств добавляет пакет" "Сколько устройств добавляет пакет"
)} )}
/> />
<input <Input
class="input" class="input"
type="number" type="number"
min="0" min="0"
@@ -1,4 +1,5 @@
<script> <script>
import { Input } from "$components/ui/index.js";
import { import {
ChevronRight, ChevronRight,
RefreshCw, RefreshCw,
@@ -36,10 +37,38 @@
const TRIAL_RESET_KEYS = ["TRIAL_TRAFFIC_STRATEGY"]; const TRIAL_RESET_KEYS = ["TRIAL_TRAFFIC_STRATEGY"];
const TRIAL_SQUAD_KEYS = ["TRIAL_SQUAD_UUIDS"]; const TRIAL_SQUAD_KEYS = ["TRIAL_SQUAD_UUIDS"];
const LEGACY_PERIODS = [ 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"], "1",
["6", "MONTH_6_ENABLED", "RUB_PRICE_6_MONTHS", "STARS_PRICE_6_MONTHS"], "MONTH_1_ENABLED",
["12", "MONTH_12_ENABLED", "RUB_PRICE_12_MONTHS", "STARS_PRICE_12_MONTHS"], "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 = [ const LEGACY_TARIFF_SETTING_KEYS = [
...LEGACY_PERIODS.flatMap((row) => row.slice(1)), ...LEGACY_PERIODS.flatMap((row) => row.slice(1)),
@@ -375,7 +404,7 @@
<code>TRIAL_DURATION_DAYS</code> <code>TRIAL_DURATION_DAYS</code>
</div> </div>
<div class="admin-setting-control"> <div class="admin-setting-control">
<input <Input
class="input" class="input"
type="number" type="number"
min="0" min="0"
@@ -411,7 +440,7 @@
<code>TRIAL_TRAFFIC_LIMIT_GB</code> <code>TRIAL_TRAFFIC_LIMIT_GB</code>
</div> </div>
<div class="admin-setting-control"> <div class="admin-setting-control">
<input <Input
class="input" class="input"
type="number" type="number"
min="0" min="0"
@@ -563,7 +592,7 @@
onValueChange={handleTrialSquadSelect} onValueChange={handleTrialSquadSelect}
/> />
{/key} {/key}
<input <Input
class="input" class="input"
type="text" type="text"
placeholder={valueForKey("USER_SQUAD_UUIDS", settingsDirty, settingsFieldMap) || placeholder={valueForKey("USER_SQUAD_UUIDS", settingsDirty, settingsFieldMap) ||
@@ -794,8 +823,10 @@
<span>{at("tariffs_legacy_enabled", {}, "Enabled")}</span> <span>{at("tariffs_legacy_enabled", {}, "Enabled")}</span>
<span>{at("payment_rub", {}, "RUB")}</span> <span>{at("payment_rub", {}, "RUB")}</span>
<span>{at("payment_stars", {}, "Stars")}</span> <span>{at("payment_stars", {}, "Stars")}</span>
<span>{at("tariffs_legacy_ref_inviter", {}, "Inviter")}</span>
<span>{at("tariffs_legacy_ref_referee", {}, "Friend")}</span>
</div> </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"> <div class="admin-legacy-tariff-row">
<strong>{months} {at("months_short", {}, "mo")}</strong> <strong>{months} {at("months_short", {}, "mo")}</strong>
<div class="admin-setting-switch"> <div class="admin-setting-switch">
@@ -807,7 +838,7 @@
<Switch.Thumb class="admin-switch-thumb" /> <Switch.Thumb class="admin-switch-thumb" />
</Switch.Root> </Switch.Root>
</div> </div>
<input <Input
class="input" class="input"
type="number" type="number"
min="0" min="0"
@@ -815,7 +846,7 @@
value={valueForKey(rubKey, settingsDirty, settingsFieldMap)} value={valueForKey(rubKey, settingsDirty, settingsFieldMap)}
oninput={(event) => setSetting(rubKey, event.currentTarget.value)} oninput={(event) => setSetting(rubKey, event.currentTarget.value)}
/> />
<input <Input
class="input" class="input"
type="number" type="number"
min="0" min="0"
@@ -823,6 +854,22 @@
value={valueForKey(starsKey, settingsDirty, settingsFieldMap)} value={valueForKey(starsKey, settingsDirty, settingsFieldMap)}
oninput={(event) => setSetting(starsKey, event.currentTarget.value)} 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> </div>
{/each} {/each}
</div> </div>
@@ -831,7 +878,7 @@
<label class="admin-field-label admin-field-label-compact"> <label class="admin-field-label admin-field-label-compact">
<span>{at("tariffs_legacy_traffic_packages", {}, "Traffic packages")}</span> <span>{at("tariffs_legacy_traffic_packages", {}, "Traffic packages")}</span>
<small>{at("tariffs_legacy_traffic_hint", {}, "Format: 10:199,50:799")}</small> <small>{at("tariffs_legacy_traffic_hint", {}, "Format: 10:199,50:799")}</small>
<input <Input
class="input" class="input"
type="text" type="text"
value={valueForKey("TRAFFIC_PACKAGES", settingsDirty, settingsFieldMap)} value={valueForKey("TRAFFIC_PACKAGES", settingsDirty, settingsFieldMap)}
@@ -843,7 +890,7 @@
>{at("tariffs_legacy_stars_traffic_packages", {}, "Traffic packages, Stars")}</span >{at("tariffs_legacy_stars_traffic_packages", {}, "Traffic packages, Stars")}</span
> >
<small>{at("tariffs_legacy_traffic_hint", {}, "Format: 10:199,50:799")}</small> <small>{at("tariffs_legacy_traffic_hint", {}, "Format: 10:199,50:799")}</small>
<input <Input
class="input" class="input"
type="text" type="text"
value={valueForKey("STARS_TRAFFIC_PACKAGES", settingsDirty, settingsFieldMap)} value={valueForKey("STARS_TRAFFIC_PACKAGES", settingsDirty, settingsFieldMap)}
@@ -1,4 +1,5 @@
<script> <script>
import { Input, Textarea } from "$components/ui/index.js";
import { ChevronRight, Languages, Plus, Search, X } from "$components/ui/icons.js"; import { ChevronRight, Languages, Plus, Search, X } from "$components/ui/icons.js";
import { AdminBadge, AdminButton, AdminEmptyState } from "$components/patterns/admin/index.js"; import { AdminBadge, AdminButton, AdminEmptyState } from "$components/patterns/admin/index.js";
import { getContext, onDestroy, onMount } from "svelte"; import { getContext, onDestroy, onMount } from "svelte";
@@ -396,7 +397,7 @@
{#if expanded} {#if expanded}
<div class="admin-translation-locale-body" transition:slide={{ duration: 130 }}> <div class="admin-translation-locale-body" transition:slide={{ duration: 130 }}>
<textarea <Textarea
class="admin-setting-textarea admin-translation-textarea" class="admin-setting-textarea admin-translation-textarea"
rows="3" rows="3"
spellcheck="false" spellcheck="false"
@@ -404,7 +405,7 @@
value={localeValue(item, lang, dirtyEntry)} value={localeValue(item, lang, dirtyEntry)}
oninput={(event) => oninput={(event) =>
translationsStore.markDirty(lang, item.key, event.currentTarget.value)} translationsStore.markDirty(lang, item.key, event.currentTarget.value)}
></textarea> />
<div class="admin-translation-base"> <div class="admin-translation-base">
<small>{baseKind(item, lang)}</small> <small>{baseKind(item, lang)}</small>
<span title={baseValue(item, lang)}>{baseValue(item, lang) || "-"}</span> <span title={baseValue(item, lang)}>{baseValue(item, lang) || "-"}</span>
@@ -457,7 +458,7 @@
<div class="admin-translations-toolbar"> <div class="admin-translations-toolbar">
<label class="admin-translations-search"> <label class="admin-translations-search">
<Search size={15} /> <Search size={15} />
<input <Input
bind:value={search} bind:value={search}
class="input" class="input"
type="text" type="text"
@@ -502,7 +503,7 @@
addLanguage(); addLanguage();
}} }}
> >
<input <Input
bind:value={newLanguageCode} bind:value={newLanguageCode}
class="input" class="input"
type="text" type="text"
@@ -1,6 +1,6 @@
<script> <script>
import { Label, Separator, Tabs } from "$components/ui/primitives.js"; 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 Dialog from "$components/ui/dialog.svelte";
import { import {
AdminBadge, AdminBadge,
@@ -81,11 +81,7 @@
$: openedUserTelegramProfileLinkKind = openedUser ? userTelegramProfileLinkKind(openedUser) : ""; $: openedUserTelegramProfileLinkKind = openedUser ? userTelegramProfileLinkKind(openedUser) : "";
$: openedUserTelegramProfileHint = $: openedUserTelegramProfileHint =
openedUserTelegramProfileLinkKind === "id" openedUserTelegramProfileLinkKind === "id"
? at( ? at("user_open_tg_profile_id_hint", {}, "Бот отправит кнопку профиля в Telegram")
"user_open_tg_profile_id_hint",
{},
"Профиль будет открыт по Telegram ID. Telegram может заблокировать переход из-за настроек приватности пользователя или ограничений клиента."
)
: at("user_open_tg_profile_hint", {}, "Открыть профиль Telegram"); : at("user_open_tg_profile_hint", {}, "Открыть профиль Telegram");
$: if (openedUser && userDetailTab === "logs" && !userLogsLoading && !userLogsLoaded) { $: if (openedUser && userDetailTab === "logs" && !userLogsLoading && !userLogsLoaded) {
@@ -189,11 +185,6 @@
{at("user_open_tg_profile", {}, "Открыть Telegram")} {at("user_open_tg_profile", {}, "Открыть Telegram")}
</AdminButton> </AdminButton>
</div> </div>
{#if openedUserTelegramProfileLinkKind === "id"}
<small class="admin-user-telegram-profile-note"
>{openedUserTelegramProfileHint}</small
>
{/if}
</div> </div>
</div> </div>
@@ -552,7 +543,7 @@
</div> </div>
</div> </div>
<div class="admin-user-logs-wrap"> <ScrollArea class="admin-user-logs-wrap" maxHeight="min(52vh, 460px)">
{#if userLogsLoading} {#if userLogsLoading}
<AdminTableSkeleton <AdminTableSkeleton
headers={[ headers={[
@@ -604,7 +595,7 @@
</tbody> </tbody>
</AdminTable> </AdminTable>
{/if} {/if}
</div> </ScrollArea>
{#if userLogsLoaded && userLogsTotal > userLogsPageSize} {#if userLogsLoaded && userLogsTotal > userLogsPageSize}
<AdminPagination <AdminPagination
@@ -632,7 +623,7 @@
<Label.Root class="admin-field-label admin-extend-field"> <Label.Root class="admin-field-label admin-extend-field">
<span>{at("user_label_extend", {}, "Продлить подписку")}</span> <span>{at("user_label_extend", {}, "Продлить подписку")}</span>
<div class="admin-extend-control"> <div class="admin-extend-control">
<input <Input
class="input" class="input"
type="number" type="number"
min="1" min="1"
@@ -662,7 +653,7 @@
<span>{at("user_premium_override_bonus", {}, "Доп. премиум-трафик, GB")}</span <span>{at("user_premium_override_bonus", {}, "Доп. премиум-трафик, GB")}</span
> >
<small>{at("user_premium_override_bonus_hint", {}, "")}</small> <small>{at("user_premium_override_bonus_hint", {}, "")}</small>
<input <Input
class="input" class="input"
type="number" type="number"
min="0" min="0"
@@ -741,7 +732,7 @@
>{at("user_regular_override_bonus", {}, "Доп. основной трафик, GB")}</span >{at("user_regular_override_bonus", {}, "Доп. основной трафик, GB")}</span
> >
<small>{at("user_regular_override_bonus_hint", {}, "")}</small> <small>{at("user_regular_override_bonus_hint", {}, "")}</small>
<input <Input
class="input" class="input"
type="number" type="number"
min="0" min="0"
@@ -837,7 +828,7 @@
<Label.Root class="admin-field-label admin-extend-field"> <Label.Root class="admin-field-label admin-extend-field">
<span>{at("user_traffic_grant_gb", {}, "ГБ к выдаче")}</span> <span>{at("user_traffic_grant_gb", {}, "ГБ к выдаче")}</span>
<div class="admin-extend-control"> <div class="admin-extend-control">
<input <Input
class="input" class="input"
type="number" type="number"
min="0" min="0"
@@ -869,12 +860,12 @@
"Поддерживается HTML-разметка Telegram" "Поддерживается HTML-разметка Telegram"
)}</small )}</small
> >
<textarea <Textarea
class="admin-textarea" class="admin-textarea"
rows="3" rows="3"
placeholder={at("user_placeholder_msg", {}, "Текст сообщения")} placeholder={at("user_placeholder_msg", {}, "Текст сообщения")}
bind:value={$usersStore.userMessageDraft} bind:value={$usersStore.userMessageDraft}
></textarea> />
</Label.Root> </Label.Root>
<div class="admin-message-actions"> <div class="admin-message-actions">
<AdminButton <AdminButton
@@ -976,7 +967,9 @@
onclose={() => usersStore.updateState({ userMessageConfirmOpen: false })} onclose={() => usersStore.updateState({ userMessageConfirmOpen: false })}
class="admin-dialog" 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"> <div class="admin-dialog-actions">
<AdminButton onclick={() => usersStore.updateState({ userMessageConfirmOpen: false })} <AdminButton onclick={() => usersStore.updateState({ userMessageConfirmOpen: false })}
>{at("btn_cancel", {}, "Отмена")}</AdminButton >{at("btn_cancel", {}, "Отмена")}</AdminButton
@@ -1166,12 +1159,6 @@
gap: 8px; gap: 8px;
margin-top: 6px; 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) { :global(.admin-avatar-dialog) {
display: grid; display: grid;
grid-template-rows: auto minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr);
@@ -1230,7 +1217,7 @@
gap: 8px; gap: 8px;
font-size: 13px; font-size: 13px;
} }
.admin-user-logs-wrap { :global(.admin-user-logs-wrap) {
min-height: 120px; min-height: 120px;
} }
.admin-user-log-event { .admin-user-log-event {
@@ -1,4 +1,5 @@
<script> <script>
import { Input } from "$components/ui/index.js";
import { Label } from "$components/ui/primitives.js"; import { Label } from "$components/ui/primitives.js";
import { import {
AdminBadge, AdminBadge,
@@ -107,7 +108,7 @@
<div class="admin-toolbar admin-toolbar-users"> <div class="admin-toolbar admin-toolbar-users">
<div class="admin-toolbar-search"> <div class="admin-toolbar-search">
<input <Input
type="search" type="search"
class="input" class="input"
placeholder={at("users_search_placeholder", {}, "ID, @username или email")} placeholder={at("users_search_placeholder", {}, "ID, @username или email")}
+18 -4
View File
@@ -18,10 +18,10 @@ export function emptyTariffDraft() {
hwid_device_limit: "", hwid_device_limit: "",
conversion_rate_rub_per_gb: "", conversion_rate_rub_per_gb: "",
periodRows: [ periodRows: [
{ months: 1, rub: 150, stars: "" }, { months: 1, rub: 200, stars: "", referral_inviter: 3, referral_referee: 1 },
{ months: 3, rub: 400, stars: "" }, { months: 3, rub: 600, stars: "", referral_inviter: 7, referral_referee: 3 },
{ months: 6, rub: 750, stars: "" }, { months: 6, rub: 1200, stars: "", referral_inviter: 15, referral_referee: 7 },
{ months: 12, rub: 1400, stars: "" }, { months: 12, rub: 2400, stars: "", referral_inviter: 30, referral_referee: 15 },
], ],
topupRubRows: [], topupRubRows: [],
topupStarsRows: [], topupStarsRows: [],
@@ -67,6 +67,8 @@ export function draftFromTariff(tariff) {
months: month, months: month,
rub: tariff.prices_rub?.[String(month)] ?? "", rub: tariff.prices_rub?.[String(month)] ?? "",
stars: tariff.prices_stars?.[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 { return {
@@ -190,6 +192,8 @@ export function tariffFromDraft(draft) {
months: parseIntNumber(row.months), months: parseIntNumber(row.months),
rub: parseNumber(row.rub, 0), rub: parseNumber(row.rub, 0),
stars: parseNumber(row.stars, 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) => row.months > 0)
.filter((row) => { .filter((row) => {
@@ -204,6 +208,16 @@ export function tariffFromDraft(draft) {
tariff.prices_stars = Object.fromEntries( tariff.prices_stars = Object.fromEntries(
rows.map((row) => [String(row.months), row.stars || 0]) 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"); const topupPackages = packageSetFromRows(draft.topupRubRows, draft.topupStarsRows, "gb");
if (topupPackages) tariff.topup_packages = topupPackages; if (topupPackages) tariff.topup_packages = topupPackages;
} else { } else {
@@ -30,12 +30,14 @@
</Select.Trigger> </Select.Trigger>
<Select.Portal> <Select.Portal>
<Select.Content class="admin-select-content" {sideOffset}> <Select.Content class="admin-select-content" {sideOffset}>
<Select.Viewport class="admin-select-viewport">
{#each items as item (item.value)} {#each items as item (item.value)}
<Select.Item value={item.value} label={item.label} class="admin-select-item"> <Select.Item value={item.value} label={item.label} class="admin-select-item">
<span>{item.label}</span> <span>{item.label}</span>
<Check size={14} class="admin-select-item-check" /> <Check size={14} class="admin-select-item-check" />
</Select.Item> </Select.Item>
{/each} {/each}
</Select.Viewport>
</Select.Content> </Select.Content>
</Select.Portal> </Select.Portal>
</Select.Root> </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 { onMount } from "svelte";
import { fade, fly } from "svelte/transition"; import { fade, fly } from "svelte/transition";
import Button from "./button.svelte"; import Button from "./button.svelte";
import ScrollArea from "./scroll-area.svelte";
export let open = false; export let open = false;
export let title = ""; export let title = "";
@@ -65,7 +66,9 @@
<X size={18} /> <X size={18} />
</Button> </Button>
</div> </div>
<ScrollArea class="dialog-body-scroll" maxHeight="none">
<slot /> <slot />
</ScrollArea>
</section> </section>
</div> </div>
{/if} {/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}
/>
+3
View File
@@ -2,11 +2,14 @@ export { default as AttentionDot } from "./attention-dot.svelte";
export { default as Badge } from "./badge.svelte"; export { default as Badge } from "./badge.svelte";
export { default as Button } from "./button.svelte"; export { default as Button } from "./button.svelte";
export { default as Checkbox } from "./checkbox.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 Dialog } from "./dialog.svelte";
export { default as FileInput } from "./file-input.svelte";
export { default as Input } from "./input.svelte"; export { default as Input } from "./input.svelte";
export { default as LegacyCard } from "./card.svelte"; export { default as LegacyCard } from "./card.svelte";
export { default as RadioGroup } from "./radio-group.svelte"; export { default as RadioGroup } from "./radio-group.svelte";
export { default as RadioGroupItem } from "./radio-group-item.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 Skeleton } from "./skeleton.svelte";
export { default as Spinner } from "./spinner.svelte"; export { default as Spinner } from "./spinner.svelte";
export { default as ScrollArea } from "./scroll-area.svelte"; export { default as ScrollArea } from "./scroll-area.svelte";
+11 -1
View File
@@ -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> <script>
import { cn } from "$lib/utils.js";
import { ScrollArea as ScrollAreaPrimitive } from "./primitives.js";
export let maxHeight = "100%"; export let maxHeight = "100%";
export let element = null; export let element = null;
export let type = "auto";
let className = ""; let className = "";
export { className as class }; export { className as class };
</script> </script>
<div <ScrollAreaPrimitive.Root
bind:this={element} class={cn("scroll-area scroll-area--mono", className)}
class={`scroll-area ${className}`.trim()}
style={`max-height:${maxHeight};`} style={`max-height:${maxHeight};`}
{type}
{...$$restProps} {...$$restProps}
> >
<ScrollAreaPrimitive.Viewport bind:ref={element} class="scroll-area__viewport">
<slot /> <slot />
</div> </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>
+6 -10
View File
@@ -101636,22 +101636,18 @@ export const DEMO_DATASET = {
audience: "internal", audience: "internal",
values: { values: {
ru: { ru: {
base: "Пользователь без username: бот отправит вам в Telegram сообщение с кнопкой открытия профиля.", base: "Бот отправит кнопку профиля в Telegram.",
fallback: fallback: "Бот отправит кнопку профиля в Telegram.",
"Пользователь без username: бот отправит вам в Telegram сообщение с кнопкой открытия профиля.", effective: "Бот отправит кнопку профиля в Telegram.",
effective:
"Пользователь без username: бот отправит вам в Telegram сообщение с кнопкой открытия профиля.",
override: "", override: "",
overridden: false, overridden: false,
updated_at: null, updated_at: null,
updated_by: null, updated_by: null,
}, },
en: { en: {
base: "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: fallback: "Бот отправит кнопку профиля в Telegram.",
"Пользователь без username: бот отправит вам в Telegram сообщение с кнопкой открытия профиля.", effective: "The bot will send a profile button in Telegram.",
effective:
"This user has no username: the bot will send you a Telegram message with a profile button.",
override: "", override: "",
overridden: false, overridden: false,
updated_at: null, updated_at: null,
+45 -4
View File
@@ -103,6 +103,10 @@ function applyDemoEmailAuthUser() {
user_id: DEMO_DATASET.currentUser?.user_id || DEMO_DATASET.currentUser?.id || 910001, user_id: DEMO_DATASET.currentUser?.user_id || DEMO_DATASET.currentUser?.id || 910001,
telegram_id: null, telegram_id: null,
telegram_linked: false, 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: "", telegram_photo_url: "",
avatar_url: "", avatar_url: "",
username: DEMO_DATASET.currentUser?.username || "u3252a8", username: DEMO_DATASET.currentUser?.username || "u3252a8",
@@ -181,6 +185,10 @@ function applyDemoTelegramAuthUser(authData = {}) {
user_id: adminUser.user_id || adminUser.id || 910001, user_id: adminUser.user_id || adminUser.id || 910001,
telegram_id: telegramId, telegram_id: telegramId,
telegram_linked: true, 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, username,
first_name: firstName, first_name: firstName,
last_name: lastName, last_name: lastName,
@@ -258,6 +266,10 @@ function applyDemoTelegramLink(authData = {}) {
user_id: DEV_MOCK.data.user?.user_id || DEV_MOCK.data.user?.id || 910001, user_id: DEV_MOCK.data.user?.user_id || DEV_MOCK.data.user?.id || 910001,
telegram_id: telegramId, telegram_id: telegramId,
telegram_linked: true, 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: username:
authData.username || authData.username ||
authDemo.telegram_username || authDemo.telegram_username ||
@@ -1689,17 +1701,25 @@ export async function mockApi(path, options = {}, context = {}) {
}, },
...[ ...[
["MONTH_1_ENABLED", "bool", true], ["MONTH_1_ENABLED", "bool", true],
["RUB_PRICE_1_MONTH", "float", 150], ["RUB_PRICE_1_MONTH", "float", 200],
["STARS_PRICE_1_MONTH", "int", 0], ["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], ["MONTH_3_ENABLED", "bool", true],
["RUB_PRICE_3_MONTHS", "float", 400], ["RUB_PRICE_3_MONTHS", "float", 600],
["STARS_PRICE_3_MONTHS", "int", 0], ["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], ["MONTH_6_ENABLED", "bool", false],
["RUB_PRICE_6_MONTHS", "float", 750], ["RUB_PRICE_6_MONTHS", "float", 1200],
["STARS_PRICE_6_MONTHS", "int", 0], ["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], ["MONTH_12_ENABLED", "bool", false],
["RUB_PRICE_12_MONTHS", "float", 1200], ["RUB_PRICE_12_MONTHS", "float", 2400],
["STARS_PRICE_12_MONTHS", "int", 0], ["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"], ["TRAFFIC_PACKAGES", "string", "10:99,50:399"],
["STARS_TRAFFIC_PACKAGES", "string", ""], ["STARS_TRAFFIC_PACKAGES", "string", ""],
].map(([key, type, value]) => ({ ].map(([key, type, value]) => ({
@@ -1983,6 +2003,27 @@ export async function mockApi(path, options = {}, context = {}) {
applyDemoTelegramLink(body.auth_data || {}); applyDemoTelegramLink(body.auth_data || {});
return { ok: true, csrf_token: "local-preview-csrf" }; 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") { if (path === "/payments" && String(options.method || "").toUpperCase() === "POST") {
const body = jsonBody(options); const body = jsonBody(options);
if (isDeviceTopupSaleMode(body.sale_mode)) { if (isDeviceTopupSaleMode(body.sale_mode)) {

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