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=
# Shared secret for validating incoming Remnawave webhooks.
# Use the same value when configuring the webhook in Remnawave panel.
# Create or set this secret in Remnawave Panel, then paste the same value here
# or into Admin -> System -> Settings -> Remnawave Panel.
# In Remnawave Panel, set WEBHOOK_URL to WEBHOOK_BASE_URL + /webhook/panel,
# for example: https://app.example.com/webhook/panel
PANEL_WEBHOOK_SECRET=
# Host port that publishes the backend webhook server from Docker Compose.
+6
View File
@@ -1,2 +1,8 @@
.gitattributes text eol=lf
*.sh text eol=lf
deploy/docker/frontend/*.sh text eol=lf
frontend/src/*.js text eol=lf
frontend/src/**/*.js text eol=lf
frontend/src/**/*.svelte text eol=lf
frontend/scripts/*.mjs text eol=lf
frontend/scripts/**/*.mjs text eol=lf
+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;
- остальные настройки удобнее задать в Web App админке.
В Remnawave Panel укажите `WEBHOOK_URL` как публичный адрес Minishop с путем `/webhook/panel`, например `https://app.example.com/webhook/panel`. Секрет вебхука задается в самой Remnawave Panel; это же значение вставьте в `PANEL_WEBHOOK_SECRET` в `.env` или в **Система -> Настройки -> Remnawave Panel** в админке.
После первого входа в админку настройте тарифы, платежные провайдеры, внешний вид, поддержку, уведомления и инструкции подключения через UI. Инструкции установки включены по умолчанию, читают Subscription Page config из Remnawave Panel и при проблемах с конфигом откатываются к обычной ссылке подключения. Полный справочник env-переменных: [docs/configuration/env-vars.md](docs/configuration/env-vars.md).
Для каталога тарифов используется `TARIFFS_CONFIG_PATH` со значением по умолчанию `data/tariffs.json`. Пример формата лежит в [data/tariffs.example.json](data/tariffs.example.json), подробности - в [docs/features/tariffs.md](docs/features/tariffs.md).
Если в Docker Compose включаете bind mount `./data:/app/data`, заранее создайте каталог и отдайте его пользователю контейнера. Это нужно для сохранения `data/tariffs.json`, каталога тем `data/themes`, кеша логотипа Web App и animated emoji:
В compose-примерах `/app/data` монтируется из папки `./data` рядом с `docker-compose.yml`. Заранее создайте каталог и отдайте его пользователю контейнера. Это нужно для сохранения `data/tariffs.json`, каталога тем `data/themes`, кеша логотипа Web App и animated emoji:
```bash
mkdir -p data/themes data/webapp-logo data/webapp-emoji
+49 -12
View File
@@ -143,12 +143,18 @@ def _payment_traffic_gb_split(payment: Payment) -> Tuple[Optional[float], Option
return None, None
def _payment_user_display_label(loaded_user: Any, payment_user_id: int) -> str:
"""Human-facing name for payments tables: TG profile name, else email, else user id."""
if loaded_user is None:
return str(payment_user_id)
def _user_display_label(
loaded_user: Any,
fallback_user_id: Optional[int],
*,
first_name: Optional[str] = None,
last_name: Optional[str] = None,
username: Optional[str] = None,
email: Optional[str] = None,
) -> Optional[str]:
"""Human-facing name: TG profile name, else email, else user id."""
tid = getattr(loaded_user, "telegram_id", None)
if tid is not None:
if loaded_user is not None and tid is not None:
fn = (getattr(loaded_user, "first_name", None) or "").strip()
ln = (getattr(loaded_user, "last_name", None) or "").strip()
full = f"{fn} {ln}".strip()
@@ -157,10 +163,30 @@ def _payment_user_display_label(loaded_user: Any, payment_user_id: int) -> str:
un = (getattr(loaded_user, "username", None) or "").strip()
if un:
return un if un.startswith("@") else f"@{un}"
return str(payment_user_id)
email = (getattr(loaded_user, "email", None) or "").strip()
if email:
return email
elif loaded_user is not None:
email = (getattr(loaded_user, "email", None) or "").strip()
if email:
return email
fn = (first_name or "").strip()
ln = (last_name or "").strip()
full = f"{fn} {ln}".strip()
if full:
return full
un = (username or "").strip()
if un:
return un if un.startswith("@") else f"@{un}"
email_value = (email or "").strip()
if email_value:
return email_value
if fallback_user_id is None:
return None
return str(fallback_user_id)
def _payment_user_display_label(loaded_user: Any, payment_user_id: int) -> str:
label = _user_display_label(loaded_user, payment_user_id)
if label:
return label
return str(payment_user_id)
@@ -229,16 +255,27 @@ def _serialize_ad(campaign: AdCampaign, totals: Optional[Dict[str, Any]] = None)
def _serialize_log(entry: MessageLog) -> Dict[str, Any]:
author_user = entry.__dict__.get("author_user")
target_user = entry.__dict__.get("target_user")
user_id = int(entry.user_id) if entry.user_id is not None else None
target_user_id = int(entry.target_user_id) if entry.target_user_id is not None else None
return {
"log_id": int(entry.log_id),
"user_id": int(entry.user_id) if entry.user_id else None,
"user_id": user_id,
"user_label": _user_display_label(
author_user,
user_id,
first_name=entry.telegram_first_name,
username=entry.telegram_username,
),
"telegram_username": entry.telegram_username,
"telegram_first_name": entry.telegram_first_name,
"email": getattr(getattr(entry, "author_user", None), "email", None),
"email": getattr(author_user, "email", None),
"event_type": entry.event_type,
"content": entry.content,
"is_admin_event": bool(entry.is_admin_event),
"target_user_id": int(entry.target_user_id) if entry.target_user_id else None,
"target_user_id": target_user_id,
"target_user_label": _user_display_label(target_user, target_user_id),
"timestamp": entry.timestamp.isoformat() if entry.timestamp else None,
}
+96 -66
View File
@@ -30,6 +30,11 @@ class SettingField:
i18n_label_key: Optional[str] = None
i18n_description_key: Optional[str] = None
i18n_subsection_key: Optional[str] = None
webhook_path: Optional[str] = None
webhook_requires_base_url: bool = False
webhook_provider_id: Optional[str] = None
webhook_hint_i18n_key: Optional[str] = None
webhook_hint: str = ""
SETTINGS_MANIFEST: List[SettingField] = [
@@ -84,44 +89,44 @@ SETTINGS_MANIFEST: List[SettingField] = [
SettingField(
"PANEL_API_URL",
"url",
"general",
"remnawave",
"URL API Remnawave",
"Например, https://panel.example.com/api.",
subsection="Remnawave",
),
SettingField(
"PANEL_API_KEY",
"string",
"general",
"remnawave",
"API-ключ Remnawave",
"Секретный ключ API панели.",
secret=True,
subsection="Remnawave",
),
SettingField(
"PANEL_WEBHOOK_SECRET",
"string",
"general",
"remnawave",
"Секрет вебхуков Remnawave",
"Используется для проверки входящих вебхуков панели.",
secret=True,
subsection="Remnawave",
webhook_path="/webhook/panel",
webhook_requires_base_url=True,
webhook_provider_id="remnawave",
webhook_hint_i18n_key="admin_settings_panel_webhook_url_hint",
webhook_hint="Use this URL as WEBHOOK_URL in Remnawave Panel.",
),
SettingField(
"USER_SQUAD_UUIDS",
"string",
"general",
"remnawave",
"Internal Squads по умолчанию",
"UUID через запятую для legacy-режима без JSON-каталога тарифов.",
subsection="Remnawave",
),
SettingField(
"USER_EXTERNAL_SQUAD_UUID",
"string",
"general",
"remnawave",
"External Squad по умолчанию",
"Необязательный UUID External Squad для новых пользователей.",
subsection="Remnawave",
),
# ─── Web app appearance ────────────────────────────────────────
SettingField(
@@ -230,6 +235,70 @@ SETTINGS_MANIFEST: List[SettingField] = [
SettingField("STARS_PRICE_3_MONTHS", "int", "pricing", "Цена 3 мес. (Stars)"),
SettingField("STARS_PRICE_6_MONTHS", "int", "pricing", "Цена 6 мес. (Stars)"),
SettingField("STARS_PRICE_12_MONTHS", "int", "pricing", "Цена 12 мес. (Stars)"),
SettingField(
"REFERRAL_BONUS_DAYS_INVITER_1_MONTH",
"int",
"pricing",
"Бонус приглашающему: 1 мес.",
min=0,
subsection="legacy_tariffs",
),
SettingField(
"REFERRAL_BONUS_DAYS_INVITER_3_MONTHS",
"int",
"pricing",
"Бонус приглашающему: 3 мес.",
min=0,
subsection="legacy_tariffs",
),
SettingField(
"REFERRAL_BONUS_DAYS_INVITER_6_MONTHS",
"int",
"pricing",
"Бонус приглашающему: 6 мес.",
min=0,
subsection="legacy_tariffs",
),
SettingField(
"REFERRAL_BONUS_DAYS_INVITER_12_MONTHS",
"int",
"pricing",
"Бонус приглашающему: 12 мес.",
min=0,
subsection="legacy_tariffs",
),
SettingField(
"REFERRAL_BONUS_DAYS_REFEREE_1_MONTH",
"int",
"pricing",
"Бонус приглашённому: 1 мес.",
min=0,
subsection="legacy_tariffs",
),
SettingField(
"REFERRAL_BONUS_DAYS_REFEREE_3_MONTHS",
"int",
"pricing",
"Бонус приглашённому: 3 мес.",
min=0,
subsection="legacy_tariffs",
),
SettingField(
"REFERRAL_BONUS_DAYS_REFEREE_6_MONTHS",
"int",
"pricing",
"Бонус приглашённому: 6 мес.",
min=0,
subsection="legacy_tariffs",
),
SettingField(
"REFERRAL_BONUS_DAYS_REFEREE_12_MONTHS",
"int",
"pricing",
"Бонус приглашённому: 12 мес.",
min=0,
subsection="legacy_tariffs",
),
SettingField(
"TRAFFIC_PACKAGES", "string", "pricing", "Пакеты трафика", "Формат: 10:199,50:799 (ГБ:цена)"
),
@@ -333,62 +402,6 @@ SETTINGS_MANIFEST: List[SettingField] = [
"REFERRAL_WELCOME_BONUS_DAYS", "int", "referral", "Приветственный бонус (дней)", min=0
),
SettingField("LEGACY_REFS", "bool", "referral", "Поддержка старых ref-ссылок"),
SettingField(
"REFERRAL_BONUS_DAYS_INVITER_1_MONTH",
"int",
"referral",
"Бонус приглашающему: 1 мес.",
min=0,
),
SettingField(
"REFERRAL_BONUS_DAYS_INVITER_3_MONTHS",
"int",
"referral",
"Бонус приглашающему: 3 мес.",
min=0,
),
SettingField(
"REFERRAL_BONUS_DAYS_INVITER_6_MONTHS",
"int",
"referral",
"Бонус приглашающему: 6 мес.",
min=0,
),
SettingField(
"REFERRAL_BONUS_DAYS_INVITER_12_MONTHS",
"int",
"referral",
"Бонус приглашающему: 12 мес.",
min=0,
),
SettingField(
"REFERRAL_BONUS_DAYS_REFEREE_1_MONTH",
"int",
"referral",
"Бонус приглашённому: 1 мес.",
min=0,
),
SettingField(
"REFERRAL_BONUS_DAYS_REFEREE_3_MONTHS",
"int",
"referral",
"Бонус приглашённому: 3 мес.",
min=0,
),
SettingField(
"REFERRAL_BONUS_DAYS_REFEREE_6_MONTHS",
"int",
"referral",
"Бонус приглашённому: 6 мес.",
min=0,
),
SettingField(
"REFERRAL_BONUS_DAYS_REFEREE_12_MONTHS",
"int",
"referral",
"Бонус приглашённому: 12 мес.",
min=0,
),
# ─── Notifications ─────────────────────────────────────────────
SettingField(
"SUBSCRIPTION_NOTIFICATIONS_ENABLED",
@@ -396,6 +409,13 @@ SETTINGS_MANIFEST: List[SettingField] = [
"notifications",
"Включены уведомления о подписке",
),
SettingField(
"SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED",
"bool",
"notifications",
"Дублировать уведомления о подписке на email",
"Письма отправляются только пользователям с привязанным email и рабочим SMTP.",
),
SettingField(
"SUBSCRIPTION_NOTIFY_ON_EXPIRE", "bool", "notifications", "Уведомлять об истечении"
),
@@ -692,6 +712,7 @@ def manifest_payload() -> List[dict]:
sections_order = {
"general": 1,
"appearance": 2,
"remnawave": 3,
"pricing": 11,
"payments": 4,
"trial": 5,
@@ -758,6 +779,15 @@ def manifest_payload() -> List[dict]:
item["default"] = default_value
if webhook_metadata:
item.update(webhook_metadata)
if field.webhook_path:
item["webhook_path"] = field.webhook_path
item["webhook_requires_base_url"] = field.webhook_requires_base_url
if field.webhook_provider_id:
item["provider_id"] = field.webhook_provider_id
if field.webhook_hint_i18n_key:
item["webhook_hint_i18n_key"] = field.webhook_hint_i18n_key
if field.webhook_hint:
item["webhook_hint"] = field.webhook_hint
if field.choices:
item["choices"] = [
{
@@ -16,6 +16,7 @@ from bot.app.web.webapp import (
routes as _routes,
serializers as _serializers,
support as _support,
telegram_notifications as _telegram_notifications,
)
_MODULES = (
@@ -25,6 +26,7 @@ _MODULES = (
_assets,
_auth,
_account,
_telegram_notifications,
_serializers,
_billing,
_devices,
+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_CSRF_HEADER_NAME = "X-CSRF-Token"
WEBAPP_STATE_CHANGING_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
ROBOTS_TX = """User-agent: *
Disallow: /
User-agent: GPTBot
Disallow: /
User-agent: ChatGPT-User
Disallow: /
User-agent: OAI-SearchBot
Disallow: /
User-agent: Google-Extended
Disallow: /
User-agent: ClaudeBot
Disallow: /
User-agent: anthropic-ai
Disallow: /
User-agent: PerplexityBot
Disallow: /
User-agent: Applebot-Extended
Disallow: /
"""
_APP_VERSION_CACHE: Optional[str] = None
WEBAPP_CSRF_EXEMPT_PATHS = {
"/api/auth/telegram/nonce",
+3
View File
@@ -8,6 +8,7 @@ from .auth import (
_sync_merged_panel_identity_for_user,
)
from .common import _invalidate_webapp_user_caches
from .telegram_notifications import _probe_telegram_notifications_for_user_id
async def account_email_request_route(request: web.Request) -> web.Response:
@@ -416,6 +417,8 @@ async def account_telegram_link_route(request: web.Request) -> web.Response:
except Exception:
logger.exception("Failed to send account Telegram linked notification")
await _probe_telegram_notifications_for_user_id(request, int(final_user_id))
token = create_webapp_session_token(settings, int(final_user_id))
response_payload: Dict[str, Any] = {
"ok": True,
+7
View File
@@ -23,6 +23,12 @@ async def health_route(request: web.Request) -> web.Response:
return web.json_response({"ok": True})
async def robots_txt_route(request: web.Request) -> web.Response:
response = web.Response(text=ROBOTS_TX, content_type="text/plain")
response.headers["Cache-Control"] = "public, max-age=3600"
return response
async def css_asset_route(request: web.Request) -> web.Response:
return await _css_asset_route(request, base_name="subscription_webapp")
@@ -869,6 +875,7 @@ async def _security_headers_middleware(request: web.Request, handler):
)
response.headers.setdefault("Referrer-Policy", "no-referrer")
response.headers.setdefault("X-Content-Type-Options", "nosniff")
response.headers.setdefault("X-Robots-Tag", "noindex, nofollow, noarchive")
response.headers.setdefault(
"Permissions-Policy",
(
+5
View File
@@ -1,6 +1,7 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
from .common import _invalidate_webapp_user_caches
from .telegram_notifications import _probe_telegram_notifications_for_user_id
def _resolve_telegram_bot_id(bot_token: str) -> Optional[int]:
@@ -436,6 +437,9 @@ async def telegram_oauth_callback_route(request: web.Request) -> web.Response:
first_name=linked_user_for_panel.first_name,
)
if final_user_id:
await _probe_telegram_notifications_for_user_id(request, int(final_user_id))
token = create_webapp_session_token(settings, int(final_user_id))
response = web.HTTPFound(_telegram_oauth_redirect_url(redirect_path, status="success"))
_clear_telegram_oauth_state_cookie(response)
@@ -531,6 +535,7 @@ async def auth_token_route(request: web.Request) -> web.Response:
return _json_error(500, "auth_failed", "Auth failed")
await _invalidate_webapp_user_caches(settings, authenticated_user_id, include_devices=True)
await _probe_telegram_notifications_for_user_id(request, int(authenticated_user_id))
token = create_webapp_session_token(settings, int(authenticated_user_id))
return _build_webapp_auth_response(settings, {"ok": True}, token=token)
+36 -8
View File
@@ -22,7 +22,13 @@ async def devices_route(request: web.Request) -> web.Response:
"devices",
user_id,
int(getattr(settings, "WEBAPP_DEVICES_CACHE_TTL_SECONDS", 5) or 0),
lambda: _load_devices_payload(subscription_service, session, user_id),
lambda: _load_devices_payload(
subscription_service,
session,
user_id,
fallback_panel_user_uuid=str(getattr(db_user, "panel_user_uuid", "") or "").strip()
or None,
),
)
if isinstance(result, dict) and result.get("ok") is True:
return web.json_response({"ok": True, **(result.get("payload") or {})})
@@ -45,16 +51,12 @@ async def _load_devices_payload(
subscription_service: SubscriptionService,
session: AsyncSession,
user_id: int,
fallback_panel_user_uuid: Optional[str] = None,
) -> Dict[str, Any]:
active = await subscription_service.get_active_subscription_details(session, user_id)
panel_user_uuid = active.get("user_id") if active else None
panel_user_uuid = str((active or {}).get("user_id") or fallback_panel_user_uuid or "").strip()
if not panel_user_uuid:
return {
"ok": False,
"status": 400,
"error": "subscription_not_active",
"message": "Subscription is not active",
}
return _empty_inactive_devices_payload()
panel_service = getattr(subscription_service, "panel_service", None)
if not panel_service:
@@ -82,6 +84,7 @@ async def _load_devices_payload(
"ok": True,
"payload": {
"enabled": True,
"subscription_active": _devices_subscription_is_active(active),
"current_devices": len(devices),
"max_devices": max_devices,
"max_devices_label": _format_devices_limit(max_devices),
@@ -92,6 +95,31 @@ async def _load_devices_payload(
}
def _empty_inactive_devices_payload() -> Dict[str, Any]:
return {
"ok": True,
"payload": {
"enabled": True,
"subscription_active": False,
"current_devices": 0,
"max_devices": None,
"max_devices_label": _format_devices_limit(None),
"devices": [],
},
}
def _devices_subscription_is_active(active: Optional[Dict[str, Any]]) -> bool:
if not active:
return False
end_date = active.get("end_date")
if not isinstance(end_date, datetime):
return False
if end_date.tzinfo is None:
end_date = end_date.replace(tzinfo=timezone.utc)
return end_date > datetime.now(timezone.utc)
async def disconnect_device_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
rate_limit_response = await _enforce_webapp_rate_limit(
+5
View File
@@ -3,6 +3,7 @@ from ._runtime import * # noqa: F403,F405
def setup_subscription_webapp_routes(app: web.Application) -> None:
app.router.add_get("/robots.txt", robots_txt_route)
app.router.add_get("/", index_route)
app.router.add_get("/login/password", index_route)
app.router.add_get("/home", index_route)
@@ -84,6 +85,10 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
app.router.add_post("/api/account/password/request", account_password_request_route)
app.router.add_post("/api/account/password/confirm", account_password_confirm_route)
app.router.add_post("/api/account/telegram/link", account_telegram_link_route)
app.router.add_post(
"/api/account/telegram/notifications/probe",
account_telegram_notifications_probe_route,
)
app.router.add_post("/api/promo/apply", apply_promo_route)
app.router.add_post("/api/trial/activate", activate_trial_route)
app.router.add_get("/api/devices", devices_route)
+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.webapp_themes_config import public_themes_catalog_payload
from bot.services.telegram_notifications import (
TELEGRAM_NOTIFICATIONS_ENABLED,
normalize_telegram_notification_status,
telegram_notifications_need_prompt,
telegram_notifications_start_link,
)
async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, Any]:
@@ -72,6 +78,12 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
lang = _normalize_language(db_user.language_code or settings.DEFAULT_LANGUAGE)
admin_ids = {int(x) for x in (settings.ADMIN_IDS or [])}
is_admin = bool(db_user.telegram_id and int(db_user.telegram_id) in admin_ids)
telegram_notifications_status = normalize_telegram_notification_status(
getattr(db_user, "telegram_notifications_status", None)
)
telegram_notifications_link = telegram_notifications_start_link(
request.app.get("bot_username") or ""
)
return {
"user": {
"id": user_id,
@@ -83,6 +95,12 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
),
"telegram_id": db_user.telegram_id,
"telegram_linked": bool(_telegram_id_for_user(db_user)),
"telegram_notifications_status": telegram_notifications_status,
"telegram_notifications_enabled": (
telegram_notifications_status == TELEGRAM_NOTIFICATIONS_ENABLED
),
"telegram_notifications_need_prompt": telegram_notifications_need_prompt(db_user),
"telegram_notifications_start_link": telegram_notifications_link,
"telegram_photo_url": _telegram_avatar_url(avatar),
"first_name": db_user.first_name,
"language_code": lang,
@@ -156,12 +174,79 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
}
def _serialize_referral_bonus_details(settings: Settings, lang: str) -> List[Dict[str, Any]]:
def _legacy_referral_bonus_periods(settings: Settings) -> List[int]:
if getattr(settings, "traffic_sale_mode", False):
return []
return sorted(int(months) for months in settings.subscription_options)
def _serialize_tariff_period_referral_bonus_details(tariff: Any, lang: str) -> List[Dict[str, Any]]:
details: List[Dict[str, Any]] = []
for months, _price in sorted(settings.subscription_options.items()):
for months in sorted(int(month) for month in tariff.enabled_periods):
inviter_days = tariff.referral_inviter_bonus_days(months)
friend_days = tariff.referral_referee_bonus_days(months)
if inviter_days is None and friend_days is None:
continue
details.append(
{
"id": f"{tariff.key}:{months}",
"tariff_key": tariff.key,
"tariff_name": tariff.name(lang),
"months": int(months),
"title": _format_months_title(int(months), lang),
"inviter_days": int(inviter_days or 0),
"friend_days": int(friend_days or 0),
}
)
return details
def _serialize_tariff_referral_bonus_details(settings: Settings, lang: str) -> List[Dict[str, Any]]:
tariffs_config = settings.tariffs_config
if not tariffs_config:
return []
period_tariffs = [
tariff for tariff in tariffs_config.enabled_tariffs if tariff.billing_model == "period"
]
if len(period_tariffs) <= 1:
return (
_serialize_tariff_period_referral_bonus_details(period_tariffs[0], lang)
if period_tariffs
else []
)
summaries: List[Dict[str, Any]] = []
for tariff in period_tariffs:
details = _serialize_tariff_period_referral_bonus_details(tariff, lang)
if not details:
continue
inviter_values = [int(item["inviter_days"]) for item in details]
friend_values = [int(item["friend_days"]) for item in details]
summaries.append(
{
"id": f"tariff:{tariff.key}",
"type": "tariff_summary",
"tariff_key": tariff.key,
"tariff_name": tariff.name(lang),
"title": tariff.name(lang),
"inviter_min_days": min(inviter_values),
"inviter_max_days": max(inviter_values),
"friend_min_days": min(friend_values),
"friend_max_days": max(friend_values),
"details": details,
}
)
return summaries
def _serialize_referral_bonus_details(settings: Settings, lang: str) -> List[Dict[str, Any]]:
if settings.tariffs_config:
return _serialize_tariff_referral_bonus_details(settings, lang)
details: List[Dict[str, Any]] = []
for months in _legacy_referral_bonus_periods(settings):
inviter_days = settings.referral_bonus_inviter.get(months)
friend_days = settings.referral_bonus_referee.get(months)
if inviter_days is None and friend_days is None:
@@ -391,6 +476,7 @@ def _serialize_plans(
for tariff in tariffs_config.enabled_tariffs:
common = {
"tariff_key": tariff.key,
"is_default_tariff": tariff.key == tariffs_config.default_tariff,
"tariff_name": tariff.name(lang),
"billing_model": tariff.billing_model,
"description": tariff.description(lang),
@@ -0,0 +1,70 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
from bot.services.telegram_notifications import (
TELEGRAM_NOTIFICATIONS_ENABLED,
probe_telegram_notifications,
telegram_notifications_start_link,
)
from .common import _invalidate_webapp_user_caches
async def _probe_telegram_notifications_for_user_id(
request: web.Request,
user_id: int,
*,
force: bool = False,
) -> Dict[str, Any]:
settings: Settings = request.app["settings"]
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
try:
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user or db_user.is_banned:
await session.rollback()
return {
"ok": False,
"status": "access_denied",
"enabled": False,
"start_link": telegram_notifications_start_link(
request.app.get("bot_username") or ""
),
}
result = await probe_telegram_notifications(
session=session,
bot=request.app["bot"],
settings=settings,
i18n=request.app.get("i18n"),
user=db_user,
bot_username=request.app.get("bot_username") or "",
force=force,
)
await session.commit()
status = str(result.get("status") or "")
await _invalidate_webapp_user_caches(settings, int(db_user.user_id))
return {
"ok": bool(result.get("ok")),
"status": status,
"enabled": status == TELEGRAM_NOTIFICATIONS_ENABLED,
"start_link": result.get("start_link"),
}
except Exception:
await session.rollback()
logger.exception("Telegram notification probe failed")
return {
"ok": False,
"status": "unknown",
"enabled": False,
"start_link": telegram_notifications_start_link(
request.app.get("bot_username") or ""
),
}
async def account_telegram_notifications_probe_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
force = True
result = await _probe_telegram_notifications_for_user_id(request, user_id, force=force)
if result.get("status") == "access_denied":
return _json_error(403, "access_denied", "Access denied")
return web.json_response({"ok": True, "telegram_notifications": result})
+2
View File
@@ -14,6 +14,7 @@ from bot.middlewares.i18n import JsonI18n
from bot.services.panel_api_service import PanelApiService
from bot.utils.text_sanitizer import panel_description_from_profile
from config.settings import Settings
from db.advisory_locks import acquire_subscription_background_sync_lock
from db.dal import panel_sync_dal, subscription_dal, user_dal
from db.models import Subscription, User
@@ -928,6 +929,7 @@ async def _perform_sync_impl(
total_panel_users = len(panel_users_data)
logging.info(f"Starting sync for {total_panel_users} panel users.")
await acquire_subscription_background_sync_lock(session)
sync_indexes = await _prefetch_sync_indexes(session, panel_users_data)
users_by_telegram_id = sync_indexes["users_by_telegram_id"]
users_by_user_id = sync_indexes["users_by_user_id"]
+128 -23
View File
@@ -1,5 +1,5 @@
import logging
from typing import Optional, Union
from typing import Any, Callable, Optional, Union
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
from aiogram import Bot, F, Router, types
@@ -76,31 +76,10 @@ async def referral_command_handler(
await event.answer()
return
bonus_info_parts = []
if getattr(settings, "traffic_sale_mode", False):
bonus_details_str = _("referral_not_available_for_traffic")
else:
if settings.subscription_options:
for months_period_key, _price in sorted(settings.subscription_options.items()):
inv_bonus = settings.referral_bonus_inviter.get(months_period_key)
ref_bonus = settings.referral_bonus_referee.get(months_period_key)
if inv_bonus is not None or ref_bonus is not None:
bonus_info_parts.append(
_(
"referral_bonus_per_period",
months=months_period_key,
inviter_bonus_days=inv_bonus
if inv_bonus is not None
else _("no_bonus_placeholder"),
referee_bonus_days=ref_bonus
if ref_bonus is not None
else _("no_bonus_placeholder"),
)
)
bonus_details_str = (
"\n".join(bonus_info_parts) if bonus_info_parts else _("referral_no_bonuses_configured")
)
bonus_details_str = _build_referral_bonus_details_text(settings, _, current_lang)
referral_stats = await referral_service.get_referral_stats(session, inviter_user_id)
@@ -208,6 +187,132 @@ async def referral_action_handler(
await callback.answer()
Translator = Callable[..., str]
def _period_bonus_text(
translator: Translator,
*,
months: int,
inviter_days: Optional[int],
referee_days: Optional[int],
) -> str:
return translator(
"referral_bonus_per_period",
months=months,
inviter_bonus_days=(
inviter_days if inviter_days is not None else translator("no_bonus_placeholder")
),
referee_bonus_days=(
referee_days if referee_days is not None else translator("no_bonus_placeholder")
),
)
def _tariff_period_bonus_entries(tariff: Any) -> list[dict[str, Optional[int]]]:
entries: list[dict[str, Optional[int]]] = []
for months in sorted(int(month) for month in getattr(tariff, "enabled_periods", [])):
inviter_days = tariff.referral_inviter_bonus_days(months)
referee_days = tariff.referral_referee_bonus_days(months)
if inviter_days is None and referee_days is None:
continue
entries.append(
{
"months": months,
"inviter_days": inviter_days,
"referee_days": referee_days,
}
)
return entries
def _legacy_period_bonus_entries(settings: Settings) -> list[dict[str, Optional[int]]]:
entries: list[dict[str, Optional[int]]] = []
for months, _price in sorted(settings.subscription_options.items()):
inviter_days = settings.referral_bonus_inviter.get(months)
referee_days = settings.referral_bonus_referee.get(months)
if inviter_days is None and referee_days is None:
continue
entries.append(
{
"months": int(months),
"inviter_days": inviter_days,
"referee_days": referee_days,
}
)
return entries
def _bonus_days_range(translator: Translator, values: list[int]) -> str:
return translator(
"referral_bonus_days_range",
min_days=min(values),
max_days=max(values),
)
def _build_referral_bonus_details_text(
settings: Settings, translator: Translator, current_lang: str
) -> str:
tariffs_config = settings.tariffs_config
if not tariffs_config:
bonus_info_parts = [
_period_bonus_text(
translator,
months=int(entry["months"] or 0),
inviter_days=entry["inviter_days"],
referee_days=entry["referee_days"],
)
for entry in _legacy_period_bonus_entries(settings)
]
return (
"\n".join(bonus_info_parts)
if bonus_info_parts
else translator("referral_no_bonuses_configured")
)
period_tariffs = [
tariff for tariff in tariffs_config.enabled_tariffs if tariff.billing_model == "period"
]
if len(period_tariffs) <= 1:
entries = _tariff_period_bonus_entries(period_tariffs[0]) if period_tariffs else []
bonus_info_parts = [
_period_bonus_text(
translator,
months=int(entry["months"] or 0),
inviter_days=entry["inviter_days"],
referee_days=entry["referee_days"],
)
for entry in entries
]
return (
"\n".join(bonus_info_parts)
if bonus_info_parts
else translator("referral_no_bonuses_configured")
)
bonus_info_parts = []
for tariff in period_tariffs:
entries = _tariff_period_bonus_entries(tariff)
if not entries:
continue
inviter_values = [int(entry["inviter_days"] or 0) for entry in entries]
referee_values = [int(entry["referee_days"] or 0) for entry in entries]
bonus_info_parts.append(
translator(
"referral_bonus_tariff_range",
tariff_name=tariff.name(current_lang),
inviter_bonus_range=_bonus_days_range(translator, inviter_values),
referee_bonus_range=_bonus_days_range(translator, referee_values),
)
)
return (
"\n".join(bonus_info_parts)
if bonus_info_parts
else translator("referral_no_bonuses_configured")
)
def _build_webapp_referral_link(
base_url: Optional[str], referral_code: Optional[str]
) -> Optional[str]:
+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.referral_service import ReferralService
from bot.services.subscription_service import SubscriptionService
from bot.services.telegram_notifications import TELEGRAM_NOTIFICATIONS_ENABLED
from bot.utils.callback_answer import safe_answer_callback
from bot.utils.install_links import (
append_install_share_link_text,
@@ -392,11 +393,12 @@ async def ensure_required_channel_subscription(
@router.message(CommandStart(magic=F.args.regexp(r"^promo_(\w+)$").as_("promo_match")))
@router.message(CommandStart(magic=F.args.regexp(r"^admin_user_(\d+)$").as_("admin_user_match")))
@router.message(CommandStart(magic=F.args.regexp(r"^ticket_(\d+)$").as_("ticket_match")))
@router.message(CommandStart(magic=F.args.regexp(r"^notifications$").as_("notifications_match")))
@router.message(CommandStart(magic=F.args.regexp(r"^page_ref$").as_("page_ref_match")))
@router.message(
CommandStart(
magic=F.args.regexp(
r"^(?!ref_|promo_|admin_user_|ticket_|page_ref$|webapp_auth_)([A-Za-z0-9_\-]{2,64})$"
r"^(?!ref_|promo_|admin_user_|ticket_|notifications$|page_ref$|webapp_auth_)([A-Za-z0-9_\-]{2,64})$"
).as_("ad_param_match")
)
)
@@ -414,6 +416,7 @@ async def start_command_handler(
ad_param_match: Optional[re.Match] = None,
admin_user_match: Optional[re.Match] = None,
ticket_match: Optional[re.Match] = None,
notifications_match: Optional[re.Match] = None,
):
await state.clear()
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
@@ -500,6 +503,7 @@ async def start_command_handler(
promo_code_to_apply: Optional[str] = None
should_open_referral_from_start = False
ad_start_param: Optional[str] = None
notifications_start_requested = bool(notifications_match)
if ref_match:
raw_ref_value = ref_match.group(1)
@@ -522,6 +526,8 @@ async def start_command_handler(
elif promo_match:
promo_code_to_apply = promo_match.group(1)
logging.info(f"User {user_id} started with promo code: {promo_code_to_apply}")
elif notifications_start_requested:
logging.info("User %s started bot from notifications deep-link.", user_id)
elif page_ref_match:
should_open_referral_from_start = True
logging.info(f"User {user_id} started with page_ref deep-link.")
@@ -532,18 +538,24 @@ async def start_command_handler(
sanitized_username = sanitize_username(user.username)
sanitized_first_name = sanitize_display_name(user.first_name)
sanitized_last_name = sanitize_display_name(user.last_name)
notification_status_now = datetime.now(timezone.utc)
db_user = await user_dal.get_user_by_id(session, user_id)
is_existing_user = db_user is not None
if not db_user:
user_data_to_create = {
"user_id": user_id,
"telegram_id": user_id,
"username": sanitized_username,
"first_name": sanitized_first_name,
"last_name": sanitized_last_name,
"language_code": current_lang,
"referred_by_id": referred_by_user_id,
"registration_date": datetime.now(timezone.utc),
"telegram_notifications_status": TELEGRAM_NOTIFICATIONS_ENABLED,
"telegram_notifications_checked_at": notification_status_now,
"telegram_notifications_enabled_at": notification_status_now,
"telegram_notifications_blocked_at": None,
}
try:
db_user, created = await user_dal.create_user(session, user_data_to_create)
@@ -631,6 +643,13 @@ async def start_command_handler(
update_payload = {}
if db_user.language_code != current_lang:
update_payload["language_code"] = current_lang
if db_user.telegram_id != user_id:
update_payload["telegram_id"] = user_id
if db_user.telegram_notifications_status != TELEGRAM_NOTIFICATIONS_ENABLED:
update_payload["telegram_notifications_status"] = TELEGRAM_NOTIFICATIONS_ENABLED
update_payload["telegram_notifications_checked_at"] = notification_status_now
update_payload["telegram_notifications_enabled_at"] = notification_status_now
update_payload["telegram_notifications_blocked_at"] = None
# Set referral only if not already set AND user is not currently active.
# This allows previously subscribed but currently inactive users to be attributed.
if referred_by_user_id and db_user.referred_by_id is None:
@@ -684,9 +703,16 @@ async def start_command_handler(
open_referral_page_for_existing_user = should_open_referral_from_start and is_existing_user
# Send welcome message if not disabled
if not settings.DISABLE_WELCOME_MESSAGE and not open_referral_page_for_existing_user:
if (
not settings.DISABLE_WELCOME_MESSAGE
and not open_referral_page_for_existing_user
and not notifications_start_requested
):
await message.answer(_(key="welcome", user_name=hd.quote(user.full_name)))
if notifications_start_requested:
await message.answer(_("telegram_notifications_started"), parse_mode="HTML")
# Auto-apply promo code if provided via start parameter
if promo_code_to_apply:
try:
+16 -2
View File
@@ -114,6 +114,20 @@ def _with_subscription_purchase_description(
return f"{description}\n\n{text}"
def _format_premium_bytes(value: object) -> str:
try:
bytes_value = max(0, int(value or 0))
except (TypeError, ValueError):
bytes_value = 0
return f"{bytes_value / 2**30:.2f} GB"
def _format_premium_usage_limit(active: dict[str, object]) -> str:
used = _format_premium_bytes(active.get("premium_used_bytes"))
limit = _format_premium_bytes(active.get("premium_limit_bytes"))
return f"{used} из {limit}"
async def display_subscription_options(
event: Union[types.Message, types.CallbackQuery],
i18n_data: dict,
@@ -456,7 +470,7 @@ async def tariff_topup_list_callback(
if len(labels) > len(visible):
premium_lines.append(f"• ... еще {len(labels) - len(visible)}")
premium_lines.append(
f"Premium использовано: {active.get('premium_used')} из {active.get('premium_limit')}. Осталось: {premium_left / 2**30:.2f} GB." # noqa: E501
f"Premium использовано: {_format_premium_usage_limit(active)}. Осталось: {premium_left / 2**30:.2f} GB." # noqa: E501
)
text = get_text("choose_payment_method_traffic")
if carryover_lines:
@@ -1065,7 +1079,7 @@ async def my_subscription_command_handler(
text += (
"\n\n🚀 <b>Premium-серверы</b>\n"
f"Статус: <b>{premium_status}</b>\n"
f"Лимит: <b>{active.get('premium_used')} из {active.get('premium_limit')}</b>\n"
f"Лимит: <b>{_format_premium_usage_limit(active)}</b>\n"
f"Осталось: <b>{premium_left / 2**30:.2f} GB</b>\n"
f"Докупленный остаток: <b>{premium_balance / 2**30:.2f} GB</b>\n"
"Отдельный лимит действует на:\n"
+23 -12
View File
@@ -307,6 +307,7 @@ class JsonI18n:
self.locales_data: Dict[str, Dict[str, str]] = {}
self._overrides_path: Optional[Path] = None
self._overrides_file_mtime_ns: Optional[int] = None
self._overrides_file_content: Optional[str] = None
self._overrides_file_next_check = 0.0
self._overrides_file_check_interval_seconds = 1.0
self._load_locales()
@@ -419,6 +420,7 @@ class JsonI18n:
if self._overrides_file_mtime_ns is None:
return False
self._overrides_file_mtime_ns = None
self._overrides_file_content = None
logging.info(
"Locale overrides file removed; keeping current in-memory overrides until "
"the DB fallback is reloaded"
@@ -432,19 +434,8 @@ class JsonI18n:
)
return False
if not force and stat.st_mtime_ns == self._overrides_file_mtime_ns:
return False
try:
payload = json.loads(self._overrides_path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
logging.warning(
"Failed to parse locale overrides file %s: %s",
self._overrides_path,
exc,
)
self._overrides_file_mtime_ns = stat.st_mtime_ns
return False
content = self._overrides_path.read_text(encoding="utf-8")
except OSError as exc:
logging.warning(
"Failed to read locale overrides file %s: %s",
@@ -453,7 +444,27 @@ class JsonI18n:
)
return False
if (
not force
and stat.st_mtime_ns == self._overrides_file_mtime_ns
and content == self._overrides_file_content
):
return False
try:
payload = json.loads(content)
except json.JSONDecodeError as exc:
logging.warning(
"Failed to parse locale overrides file %s: %s",
self._overrides_path,
exc,
)
self._overrides_file_mtime_ns = stat.st_mtime_ns
self._overrides_file_content = content
return False
self._overrides_file_mtime_ns = stat.st_mtime_ns
self._overrides_file_content = content
self.set_locale_overrides(payload)
logging.info("Locale overrides reloaded from %s", self._overrides_path)
return True
@@ -16,7 +16,13 @@ from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
from db.dal import payment_dal, user_dal
from db.models import Payment, User
from .common import Translator, format_human_units, make_translator, sale_mode_base
from .common import (
Translator,
format_human_units,
make_translator,
sale_mode_base,
sale_mode_tariff_key,
)
_TRAFFIC_MODES = {"traffic", "traffic_package", "topup", "premium_topup"}
_HWID_DEVICE_MODES = {"hwid_device", "hwid_devices", "hwid_devices_renewal"}
@@ -304,6 +310,7 @@ async def finalize_successful_payment(
activation_months or 1,
current_payment_db_id=req.payment.payment_id,
skip_if_active_before_payment=False,
tariff_key=sale_mode_tariff_key(req.sale_mode),
)
await req.session.commit()
except Exception:
@@ -6,6 +6,7 @@ from typing import Any, Optional
from aiogram import Bot
from sqlalchemy.ext.asyncio import AsyncSession
from bot.services.user_email_notifications import send_user_notification_email
from db.dal import payment_dal, user_dal
from db.models import Payment
@@ -55,11 +56,21 @@ async def notify_user_payment_failed(
db_user.language_code if db_user and db_user.language_code else settings.DEFAULT_LANGUAGE
)
translator = make_translator(i18n, language)
message_text = translator(message_key)
try:
await bot.send_message(payment.user_id, translator(message_key))
await bot.send_message(payment.user_id, message_text)
except Exception:
logging.exception(
"Webhook helper: failed to notify user %s about %s.",
payment.user_id,
message_key,
)
if db_user:
await send_user_notification_email(
settings=settings,
i18n=i18n,
user=db_user,
subject_key="email_payment_failed_subject",
message_text=message_text,
dashboard_url=(getattr(settings, "SUBSCRIPTION_MINI_APP_URL", "") or None),
)
+45 -8
View File
@@ -35,6 +35,7 @@ from bot.services.lknpd_service import LknpdService
from bot.services.panel_api_service import PanelApiService
from bot.services.referral_service import ReferralService
from bot.services.subscription_service import SubscriptionService
from bot.services.user_email_notifications import send_user_notification_email
from bot.utils.config_link import prepare_config_links
from bot.utils.install_links import ensure_user_install_guide_links
from bot.utils.request_security import ip_in_allowlist, request_client_ip
@@ -75,6 +76,9 @@ from .shared import (
from .shared import (
sale_mode_base as _sale_mode_base,
)
from .shared import (
sale_mode_tariff_key as _sale_mode_tariff_key,
)
class YooKassaConfig(ProviderEnvConfig):
@@ -749,6 +753,7 @@ async def process_successful_payment(
months_for_activation or int(subscription_months) or 1,
current_payment_db_id=payment_db_id,
skip_if_active_before_payment=False,
tariff_key=_sale_mode_tariff_key(sale_mode),
)
applied_referee_bonus_days_from_referral: Optional[int] = None
if referral_bonus_info and referral_bonus_info.get("referee_new_end_date"):
@@ -949,7 +954,20 @@ async def process_cancelled_payment(
user_lang = db_user.language_code
_ = lambda key, **kwargs: i18n.gettext(user_lang, key, **kwargs)
await bot.send_message(user_id, _("payment_failed"))
message_text = _("payment_failed")
try:
await bot.send_message(user_id, message_text)
except Exception:
logging.exception("Failed to notify YooKassa user %s about cancelled payment.", user_id)
if db_user:
await send_user_notification_email(
settings=settings,
i18n=i18n,
user=db_user,
subject_key="email_payment_failed_subject",
message_text=message_text,
dashboard_url=(settings.SUBSCRIPTION_MINI_APP_URL or None),
)
except Exception as e_process_cancel:
logging.error(
@@ -1202,13 +1220,32 @@ async def yookassa_webhook_route(request: web.Request):
get_back_to_payment_methods_keyboard,
)
await bot.send_message(
chat_id=user_id,
text=_("payment_method_bound_success"),
reply_markup=get_back_to_payment_methods_keyboard(
i18n_lang, i18n_instance
),
)
message_text = _("payment_method_bound_success")
try:
await bot.send_message(
chat_id=user_id,
text=message_text,
reply_markup=get_back_to_payment_methods_keyboard(
i18n_lang, i18n_instance
),
)
except Exception:
logging.exception(
"Failed to notify user %s "
"about payment method binding.",
user_id,
)
if db_user:
await send_user_notification_email(
settings=settings,
i18n=i18n_instance,
user=db_user,
subject_key="email_payment_method_bound_subject",
message_text=message_text,
dashboard_url=(
settings.SUBSCRIPTION_MINI_APP_URL or None
),
)
except Exception:
pass
# Attempt to cancel the authorization to avoid charge hold
+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}"
_ALLOWED_INLINE_TAGS = {
"b": "strong",
"strong": "strong",
"i": "em",
"em": "em",
"u": "u",
"s": "s",
"code": "code",
}
_INLINE_TAG_RE = re.compile(r"</?(?:b|strong|i|em|u|s|code)>", re.IGNORECASE)
_ANY_TAG_RE = re.compile(r"<[^>]+>")
def _telegram_html_to_email_html(value: str) -> str:
"""Escape arbitrary text while preserving the tiny Telegram HTML subset we use."""
source = str(value or "")
chunks: list[str] = []
cursor = 0
for match in _INLINE_TAG_RE.finditer(source):
chunks.append(html.escape(source[cursor : match.start()]))
raw_tag = match.group(0)
closing = raw_tag.startswith("</")
tag_name = raw_tag.strip("</>").lower()
mapped = _ALLOWED_INLINE_TAGS.get(tag_name)
if mapped:
chunks.append(f"</{mapped}>" if closing else f"<{mapped}>")
cursor = match.end()
chunks.append(html.escape(source[cursor:]))
return "".join(chunks).replace("\n", "<br>")
def _telegram_html_to_text(value: str) -> str:
return html.unescape(_ANY_TAG_RE.sub("", str(value or "")))
def _format_minutes(seconds: int) -> int:
return max(1, int(seconds) // 60)
@@ -351,12 +386,15 @@ def render_payment_success(
lang = _normalize_lang(language_code, settings)
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
brand = _brand_title(settings)
is_traffic = (sale_mode or "").split("@", 1)[0].split("|", 1)[0] in {
sale_base = (sale_mode or "").split("@", 1)[0].split("|", 1)[0]
is_traffic = sale_base in {
"traffic",
"traffic_package",
"topup",
"premium_topup",
}
is_hwid = sale_base in {"hwid_device", "hwid_devices", "hwid_devices_renewal"}
is_tariff_upgrade = sale_base == "tariff_upgrade"
amount_text = _format_amount(amount, currency)
safe_dashboard_url = (dashboard_url or "").strip()
end_date = end_date_text or ""
@@ -370,7 +408,12 @@ def render_payment_success(
cta_label = _t_text(i18n, lang, "email_payment_success_cta")
if is_traffic:
intro = _t_text(i18n, lang, "email_payment_success_intro_traffic", traffic_gb=traffic_label)
intro_key = (
"email_payment_success_intro_premium_topup"
if sale_base == "premium_topup"
else "email_payment_success_intro_traffic"
)
intro = _t_text(i18n, lang, intro_key, traffic_gb=traffic_label)
period_label = _t_text(i18n, lang, "email_payment_success_row_traffic")
period_value = _t_text(
i18n, lang, "email_payment_success_traffic_value", traffic_gb=traffic_label
@@ -383,6 +426,30 @@ def render_payment_success(
traffic_gb=traffic_label,
end_date=end_date,
)
elif is_hwid:
devices_count = max(0, int(months or 0))
intro = _t_text(i18n, lang, "email_payment_success_intro_hwid", count=devices_count)
period_label = _t_text(i18n, lang, "email_payment_success_row_hwid")
period_value = _t_text(i18n, lang, "email_payment_success_hwid_value", count=devices_count)
text = _t_text(
i18n,
lang,
"email_payment_success_text_hwid",
amount=amount_text,
count=devices_count,
end_date=end_date,
)
elif is_tariff_upgrade:
intro = _t_text(i18n, lang, "email_payment_success_intro_tariff_upgrade")
period_label = _t_text(i18n, lang, "email_payment_success_row_operation")
period_value = _t_text(i18n, lang, "email_payment_success_tariff_upgrade_value")
text = _t_text(
i18n,
lang,
"email_payment_success_text_tariff_upgrade",
amount=amount_text,
end_date=end_date,
)
else:
months_int = int(months or 0)
intro = _t_text(i18n, lang, "email_payment_success_intro_subscription", months=months_int)
@@ -434,6 +501,66 @@ def render_payment_success(
return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered)
def render_user_notification(
settings: Settings,
*,
language_code: Optional[str],
subject: str,
message_text: str,
dashboard_url: Optional[str] = None,
cta_label: Optional[str] = None,
heading: Optional[str] = None,
intro: Optional[str] = None,
i18n: Optional[JsonI18n] = None,
) -> EmailContent:
i18n = _resolve_i18n(i18n)
lang = _normalize_lang(language_code, settings)
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
brand = _brand_title(settings)
safe_dashboard_url = (dashboard_url or "").strip()
final_subject = (subject or "").strip() or _t_text(
i18n, lang, "email_user_notification_subject"
)
final_heading = (heading or "").strip() or final_subject
final_intro = (intro or "").strip() or _t_text(i18n, lang, "email_user_notification_intro")
final_cta_label = (cta_label or "").strip() or _t_text(
i18n,
lang,
"email_user_notification_cta",
)
footer = _t_html(i18n, lang, "email_footer_auto", brand=brand)
message_html = (
f'<div style="margin:0 0 16px 0;background:{_BG};border:1px solid {_BORDER};'
f"border-radius:14px;padding:14px 16px;font-size:14px;line-height:1.55;color:{_TEXT};"
f'white-space:pre-wrap;">{_telegram_html_to_email_html(message_text)}</div>'
)
body_parts = [message_html]
if safe_dashboard_url:
body_parts.append(
_cta_button_html(label=final_cta_label, url=safe_dashboard_url, accent=accent)
)
rendered = _layout(
settings=settings,
preheader=final_subject,
heading=final_heading,
intro_html=html.escape(final_intro),
body_html="".join(body_parts),
footer_html=footer,
)
text_lines = [final_subject, "", _telegram_html_to_text(message_text)]
if safe_dashboard_url:
text_lines.extend(
[
"",
_t_text(
i18n, lang, "email_user_notification_text_dashboard", url=safe_dashboard_url
),
]
)
return EmailContent(subject=final_subject, text="\n".join(text_lines), html=rendered)
def render_subscription_expiring(
settings: Settings,
*,
@@ -497,6 +624,107 @@ def render_subscription_expiring(
return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered)
def _subscription_lifecycle_title(
i18n: JsonI18n,
lang: str,
notification_key: str,
*,
days_left: Optional[int],
hours_before: Optional[int],
) -> str:
if notification_key == "before_2d_autorenew":
return _t_text(i18n, lang, "email_subscription_lifecycle_subject_autorenew")
if notification_key == "expired":
return _t_text(i18n, lang, "email_subscription_lifecycle_subject_expired")
if notification_key == "expired_24h_after":
return _t_text(i18n, lang, "email_subscription_lifecycle_subject_expired_after")
if hours_before is not None:
return _t_text(
i18n,
lang,
"email_subscription_lifecycle_subject_before_hours",
hours=hours_before,
)
return _t_text(
i18n,
lang,
"email_subscription_lifecycle_subject_before_days",
days=max(0, int(days_left or 0)),
)
def render_subscription_lifecycle_notification(
settings: Settings,
*,
language_code: Optional[str],
notification_key: str,
message_text: str,
end_date_text: str,
dashboard_url: Optional[str],
mirrored_from_telegram: bool = False,
days_left: Optional[int] = None,
hours_before: Optional[int] = None,
i18n: Optional[JsonI18n] = None,
) -> EmailContent:
i18n = _resolve_i18n(i18n)
lang = _normalize_lang(language_code, settings)
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
brand = _brand_title(settings)
safe_dashboard_url = (dashboard_url or "").strip()
end_date = end_date_text or ""
subject = _subscription_lifecycle_title(
i18n,
lang,
notification_key,
days_left=days_left,
hours_before=hours_before,
)
intro_key = (
"email_subscription_lifecycle_intro_mirrored"
if mirrored_from_telegram
else "email_subscription_lifecycle_intro_direct"
)
intro = _t_text(i18n, lang, intro_key)
footer = _t_html(i18n, lang, "email_footer_auto", brand=brand)
cta_label = _t_text(i18n, lang, "email_subscription_lifecycle_cta")
rows = [
(_t_text(i18n, lang, "email_subscription_lifecycle_row_end_date"), end_date),
]
message_html = (
f'<div style="margin:0 0 16px 0;background:{_BG};border:1px solid {_BORDER};'
f"border-radius:14px;padding:14px 16px;font-size:14px;line-height:1.55;color:{_TEXT};"
f'white-space:pre-wrap;">{html.escape(message_text or "")}</div>'
)
body_parts = [_info_rows_html(rows), message_html]
if safe_dashboard_url:
body_parts.append(_cta_button_html(label=cta_label, url=safe_dashboard_url, accent=accent))
rendered = _layout(
settings=settings,
preheader=subject,
heading=subject,
intro_html=html.escape(intro),
body_html="".join(body_parts),
footer_html=footer,
)
text_lines = [subject, "", message_text]
if safe_dashboard_url:
text_lines.extend(
[
"",
_t_text(
i18n,
lang,
"email_subscription_lifecycle_text_renew",
url=safe_dashboard_url,
),
]
)
return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered)
def _support_email(
settings: Settings,
i18n: Optional[JsonI18n],
+102 -2
View File
@@ -328,12 +328,112 @@ class PanelApiService:
async def _get_user_by_uuid_uncached(
self, user_uuid: str, log_response: bool = False
) -> Optional[Dict[str, Any]]:
lookup = await self.get_user_by_uuid_lookup(user_uuid, log_response=log_response)
if lookup.get("ok") and isinstance(lookup.get("user"), dict):
return lookup["user"]
return None
@staticmethod
def _panel_response_details(response_data: Optional[Dict[str, Any]]) -> Dict[str, Any]:
if not isinstance(response_data, dict):
return {}
details = response_data.get("details")
return details if isinstance(details, dict) else {}
@classmethod
def _panel_response_error_code(cls, response_data: Optional[Dict[str, Any]]) -> Optional[str]:
if not isinstance(response_data, dict):
return None
details = cls._panel_response_details(response_data)
error_code = (
response_data.get("errorCode")
or response_data.get("code")
or details.get("errorCode")
or details.get("code")
)
return str(error_code) if error_code else None
@classmethod
def _panel_response_message(cls, response_data: Optional[Dict[str, Any]]) -> Optional[str]:
if not isinstance(response_data, dict):
return None
details = cls._panel_response_details(response_data)
message = (
response_data.get("message")
or details.get("message")
or details.get("error")
or details.get("raw_response_text")
)
if message is None:
return None
message = str(message).replace("\n", " ").strip()
return message[:500] if message else None
@classmethod
def _is_user_not_found_response(cls, response_data: Optional[Dict[str, Any]]) -> bool:
if not isinstance(response_data, dict):
return False
status_code = response_data.get("status_code")
error_code = cls._panel_response_error_code(response_data)
if error_code in {"A040", "A062", "USER_NOT_FOUND", "NOT_FOUND"}:
return True
return status_code == 404
@classmethod
def _describe_user_lookup_failure(
cls,
response_data: Optional[Dict[str, Any]],
*,
not_found: bool,
) -> str:
if not isinstance(response_data, dict):
return "classification=panel_lookup_failed response=empty"
classification = "confirmed_not_found" if not_found else "panel_lookup_failed"
parts = [f"classification={classification}"]
status_code = response_data.get("status_code")
if status_code is not None:
parts.append(f"status_code={status_code}")
error_code = cls._panel_response_error_code(response_data)
if error_code:
parts.append(f"error_code={error_code}")
message = cls._panel_response_message(response_data)
if message:
parts.append(f"message={message}")
return " ".join(parts)
async def get_user_by_uuid_lookup(
self, user_uuid: str, log_response: bool = False
) -> Dict[str, Any]:
"""Fetch a panel user and preserve whether a miss was confirmed.
``get_user_by_uuid`` historically returned ``None`` both for a real
404/not-found and for transient panel/API failures. Callers that may
mutate local state need this richer result to avoid treating an outage
as a deleted panel user.
"""
endpoint = f"/users/{user_uuid}"
full_response = await self._request("GET", endpoint, log_full_response=log_response)
if full_response and not full_response.get("error") and "response" in full_response:
return full_response.get("response")
return {
"ok": True,
"user": full_response.get("response"),
"not_found": False,
"failure_reason": None,
"response": full_response,
}
return None
not_found = self._is_user_not_found_response(full_response)
return {
"ok": False,
"user": None,
"not_found": not_found,
"failure_reason": self._describe_user_lookup_failure(
full_response,
not_found=not_found,
),
"response": full_response,
}
async def get_user(
self,
+338 -115
View File
@@ -3,12 +3,15 @@ import hashlib
import hmac
import json
import logging
from datetime import datetime, timedelta, timezone
from typing import Optional
from aiogram import Bot
from aiogram.types import InlineKeyboardMarkup
from aiohttp import web
from sqlalchemy.orm import sessionmaker
from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload, sessionmaker
from bot.infra.webhook_queue import enqueue_webhook_event
from bot.keyboards.inline.user_keyboards import (
@@ -16,18 +19,40 @@ from bot.keyboards.inline.user_keyboards import (
get_subscribe_only_markup,
)
from bot.middlewares.i18n import JsonI18n
from bot.services.subscription_lifecycle_notifications import (
SubscriptionLifecycleNotificationService,
SubscriptionNotificationStage,
)
from config.settings import Settings
from db.dal import tariff_dal, user_dal
from db.dal import subscription_dal, tariff_dal, user_dal
from db.models import Subscription, User
from .email_auth_service import EmailAuthService
from .email_templates import render_subscription_expiring
from .panel_api_service import PanelApiService
EVENT_MAP = {
"user.expires_in_72_hours": (3, "subscription_72h_notification"),
"user.expires_in_48_hours": (2, "subscription_48h_notification"),
"user.expires_in_24_hours": (1, "subscription_24h_notification"),
"user.expires_in_72_hours": SubscriptionNotificationStage(
key="before_3d",
message_key="subscription_72h_notification",
days_left=3,
),
"user.expires_in_48_hours": SubscriptionNotificationStage(
key="before_2d",
message_key="subscription_48h_notification",
days_left=2,
),
"user.expires_in_24_hours": SubscriptionNotificationStage(
key="before_1d",
message_key="subscription_24h_notification",
days_left=1,
),
}
ACTIONABLE_EVENTS = frozenset(
{
*EVENT_MAP.keys(),
"user.expired",
"user.expired_24_hours_ago",
}
)
class PanelWebhookService:
@@ -48,6 +73,11 @@ class PanelWebhookService:
self.i18n = i18n
self.async_session_factory = async_session_factory
self.panel_service = panel_service
self.lifecycle_notifications = SubscriptionLifecycleNotificationService(
settings,
bot,
i18n,
)
self._event_semaphore = asyncio.Semaphore(self._MAX_CONCURRENT_EVENTS)
if not self.settings.PANEL_WEBHOOK_SECRET:
logging.error(
@@ -102,113 +132,201 @@ class PanelWebhookService:
)
async def handle_event(self, event_name: str, user_payload: dict):
telegram_id = user_payload.get("telegramId")
if not telegram_id:
logging.warning("Panel webhook without telegramId received")
return
user_id = int(telegram_id)
if not self.settings.SUBSCRIPTION_NOTIFICATIONS_ENABLED:
return
if event_name not in ACTIONABLE_EVENTS:
logging.info(
"Panel webhook event %s ignored: event is not used for subscription "
"notifications; %s",
event_name,
self._payload_log_context(user_payload),
)
return
async with self.async_session_factory() as session:
db_user = await user_dal.get_user_by_telegram_id(session, user_id)
if not db_user:
db_user = await user_dal.get_user_by_id(session, user_id)
internal_user_id = db_user.user_id if db_user else user_id
db_user = await self._user_for_payload(session, user_payload)
sub = await self._subscription_for_payload(session, user_payload, db_user)
telegram_id = self._payload_telegram_id(user_payload)
internal_user_id = (
int(db_user.user_id)
if db_user
else int(getattr(sub, "user_id", 0) or telegram_id or 0)
)
lang = (
db_user.language_code
if db_user and db_user.language_code
else self.settings.DEFAULT_LANGUAGE
)
first_name = db_user.first_name or f"User {user_id}" if db_user else f"User {user_id}"
user_email = (db_user.email or "").strip() if db_user else ""
if not sub:
if not telegram_id:
local_user_id = getattr(db_user, "user_id", None) if db_user else None
logging.warning(
"Panel webhook event %s cannot be matched to a local subscription; "
"notification skipped. %s local_user_id=%s. Possible causes: "
"panel user was created outside the bot, subscription was deleted "
"or not synced, panel identifiers changed, or skip_notifications "
"is enabled for the local subscription.",
event_name,
self._payload_log_context(user_payload),
local_user_id or "N/A",
)
return
await self._send_legacy_without_dedupe(
event_name,
user_payload,
int(telegram_id),
lang,
db_user,
)
return
markup = get_subscribe_only_markup(lang, self.i18n)
markup = get_subscribe_only_markup(lang, self.i18n)
end_date_text = self._payload_expire_date(user_payload)
if event_name in EVENT_MAP:
days_left, msg_key = EVENT_MAP[event_name]
hwid_renewal_note = await self._hwid_renewal_note(internal_user_id, lang)
if days_left == 1:
# Trigger auto-renew via SubscriptionService (wired in at factory)
try:
subscription_service = getattr(self, "subscription_service", None)
if subscription_service:
async with self.async_session_factory() as session:
from db.dal import subscription_dal
sub = await subscription_dal.get_active_subscription_by_user_id(
session, internal_user_id
)
if sub and sub.auto_renew_enabled and sub.provider == "yookassa":
try:
ok = await subscription_service.charge_subscription_renewal(
session, sub
if event_name in EVENT_MAP:
stage = EVENT_MAP[event_name]
days_left = int(stage.days_left or 0)
hwid_renewal_note = await self._hwid_renewal_note(internal_user_id, lang)
if days_left == 1:
# Trigger auto-renew via SubscriptionService (wired in at factory)
try:
subscription_service = getattr(self, "subscription_service", None)
if subscription_service:
async with self.async_session_factory() as renewal_session:
active_sub = (
await subscription_dal.get_active_subscription_by_user_id(
renewal_session,
internal_user_id,
)
# If initiation succeeded, suppress the 24h reminder by returning early # noqa: E501
if ok:
await session.commit()
return
else:
await session.rollback()
except Exception:
await session.rollback()
logging.exception("Auto-renew attempt (24h) failed")
except Exception:
logging.exception("Auto-renew trigger (24h) failed pre-check")
if days_left <= self.settings.SUBSCRIPTION_NOTIFY_DAYS_BEFORE:
# For 48h event, if auto-renew is enabled, show special notice with cancel button
if days_left == 2:
async with self.async_session_factory() as session:
from db.dal import subscription_dal
sub = await subscription_dal.get_active_subscription_by_user_id(
session, internal_user_id
)
if (
active_sub
and active_sub.auto_renew_enabled
and active_sub.provider == "yookassa"
):
try:
ok = await subscription_service.charge_subscription_renewal(
renewal_session,
active_sub,
)
# If initiation succeeded, suppress the 24h reminder by returning early # noqa: E501
if ok:
await renewal_session.commit()
return
await renewal_session.rollback()
except Exception:
await renewal_session.rollback()
logging.exception("Auto-renew attempt (24h) failed")
except Exception:
logging.exception("Auto-renew trigger (24h) failed pre-check")
if days_left <= self.settings.SUBSCRIPTION_NOTIFY_DAYS_BEFORE:
# For 48h, auto-renew users get a cancel button instead.
if days_left == 2:
active_sub = await subscription_dal.get_active_subscription_by_user_id(
session,
internal_user_id,
)
logging.info(
"48h webhook check: user_id=%s sub_found=%s auto_renew=%s provider=%s",
user_id,
bool(sub),
getattr(sub, "auto_renew_enabled", None) if sub else None,
getattr(sub, "provider", None) if sub else None,
internal_user_id,
bool(active_sub),
getattr(active_sub, "auto_renew_enabled", None) if active_sub else None,
getattr(active_sub, "provider", None) if active_sub else None,
)
if sub and sub.auto_renew_enabled and sub.provider == "yookassa":
if (
active_sub
and active_sub.auto_renew_enabled
and active_sub.provider == "yookassa"
):
cancel_kb = get_autorenew_cancel_keyboard(lang, self.i18n)
await self._send_message(
user_id,
lang,
"autorenew_48h_charge_tomorrow_notice",
reply_markup=cancel_kb,
user_name=first_name,
await self.lifecycle_notifications.send_stage(
session,
sub,
SubscriptionNotificationStage(
key="before_2d_autorenew",
message_key="autorenew_48h_charge_tomorrow_notice",
days_left=2,
),
user=db_user,
telegram_markup=cancel_kb,
extra_text=hwid_renewal_note,
end_date_text=end_date_text,
)
await session.commit()
return
await self._send_message(
user_id,
lang,
msg_key,
reply_markup=markup,
user_name=first_name,
end_date=user_payload.get("expireAt", "")[:10],
extra_text=hwid_renewal_note,
)
if days_left == 3 and user_email:
await self._send_subscription_expiring_email(
recipient=user_email,
lang=lang,
days_left=days_left,
end_date_text=user_payload.get("expireAt", "")[:10],
await self.lifecycle_notifications.send_stage(
session,
sub,
stage,
user=db_user,
telegram_markup=markup,
extra_text=hwid_renewal_note,
end_date_text=end_date_text,
)
elif event_name == "user.expired":
if self.settings.SUBSCRIPTION_NOTIFY_ON_EXPIRE:
await self._send_message(
user_id,
lang,
"subscription_expired_notification",
reply_markup=markup,
user_name=first_name,
end_date=user_payload.get("expireAt", "")[:10],
await session.commit()
elif event_name == "user.expired":
if self.settings.SUBSCRIPTION_NOTIFY_ON_EXPIRE:
await self.lifecycle_notifications.send_stage(
session,
sub,
SubscriptionNotificationStage(
key="expired",
message_key="subscription_expired_notification",
days_left=0,
),
user=db_user,
telegram_markup=markup,
end_date_text=end_date_text,
)
await session.commit()
elif (
event_name == "user.expired_24_hours_ago"
and self.settings.SUBSCRIPTION_NOTIFY_AFTER_EXPIRE
):
await self.lifecycle_notifications.send_stage(
session,
sub,
SubscriptionNotificationStage(
key="expired_24h_after",
message_key="subscription_expired_yesterday_notification",
days_left=0,
),
user=db_user,
telegram_markup=markup,
end_date_text=end_date_text,
)
await session.commit()
async def _send_legacy_without_dedupe(
self,
event_name: str,
user_payload: dict,
user_id: int,
lang: str,
db_user: Optional[User],
) -> None:
first_name = getattr(db_user, "first_name", None) or f"User {user_id}"
markup = get_subscribe_only_markup(lang, self.i18n)
if event_name in EVENT_MAP:
stage = EVENT_MAP[event_name]
await self._send_message(
user_id,
lang,
stage.message_key,
reply_markup=markup,
user_name=first_name,
end_date=self._payload_expire_date(user_payload),
)
elif event_name == "user.expired" and self.settings.SUBSCRIPTION_NOTIFY_ON_EXPIRE:
await self._send_message(
user_id,
lang,
"subscription_expired_notification",
reply_markup=markup,
user_name=first_name,
end_date=self._payload_expire_date(user_payload),
)
elif (
event_name == "user.expired_24_hours_ago"
and self.settings.SUBSCRIPTION_NOTIFY_AFTER_EXPIRE
@@ -219,33 +337,138 @@ class PanelWebhookService:
"subscription_expired_yesterday_notification",
reply_markup=markup,
user_name=first_name,
end_date=user_payload.get("expireAt", "")[:10],
end_date=self._payload_expire_date(user_payload),
)
async def _send_subscription_expiring_email(
async def _user_for_payload(
self,
*,
recipient: str,
lang: str,
days_left: int,
end_date_text: str,
) -> None:
"""Best-effort branded reminder; silently no-ops without SMTP config."""
if not self.settings.email_auth_configured:
return
try:
content = render_subscription_expiring(
self.settings,
language_code=lang,
days_left=days_left,
end_date_text=end_date_text,
dashboard_url=(self.settings.SUBSCRIPTION_MINI_APP_URL or "").strip() or None,
i18n=self.i18n,
session: AsyncSession,
user_payload: dict,
) -> Optional[User]:
telegram_id = self._payload_telegram_id(user_payload)
if telegram_id:
user = await user_dal.get_user_by_telegram_id(session, telegram_id)
if user:
return user
user = await user_dal.get_user_by_id(session, telegram_id)
if user:
return user
panel_uuid = self._payload_panel_uuid(user_payload)
if panel_uuid:
user = await user_dal.get_user_by_panel_uuid(session, panel_uuid)
if user:
return user
email = str(user_payload.get("email") or "").strip()
if email:
return await user_dal.get_user_by_email(session, email)
return None
async def _subscription_for_payload(
self,
session: AsyncSession,
user_payload: dict,
db_user: Optional[User],
) -> Optional[Subscription]:
conditions = []
if db_user:
conditions.append(Subscription.user_id == db_user.user_id)
panel_uuid = self._payload_panel_uuid(user_payload)
if panel_uuid:
conditions.append(Subscription.panel_user_uuid == panel_uuid)
if not conditions:
return None
base_stmt = (
select(Subscription)
.where(
Subscription.skip_notifications == False,
or_(*conditions),
)
email_service = EmailAuthService(self.settings, self.i18n)
await email_service.send_rendered_email(email=recipient, content=content)
except Exception:
logging.exception("Failed to send subscription-expiring email to %s", recipient)
.options(selectinload(Subscription.user))
)
expire_at = self._payload_expire_datetime(user_payload)
if expire_at is not None:
window_stmt = (
base_stmt.where(
Subscription.end_date >= expire_at - timedelta(days=1),
Subscription.end_date <= expire_at + timedelta(days=1),
)
.order_by(Subscription.end_date.desc())
.limit(1)
)
result = await session.execute(window_stmt)
found = result.scalars().first()
if found:
return found
stmt = base_stmt.order_by(Subscription.end_date.desc()).limit(1)
result = await session.execute(stmt)
return result.scalars().first()
@staticmethod
def _payload_telegram_id(user_payload: dict) -> Optional[int]:
raw = user_payload.get("telegramId")
try:
value = int(raw or 0)
except (TypeError, ValueError):
return None
return value if value > 0 else None
@staticmethod
def _payload_panel_uuid(user_payload: dict) -> str:
return str(
user_payload.get("uuid")
or user_payload.get("userUuid")
or user_payload.get("shortUuid")
or ""
).strip()
@staticmethod
def _payload_expire_date(user_payload: dict) -> str:
return str(user_payload.get("expireAt") or "")[:10]
@staticmethod
def _payload_log_context(user_payload: dict) -> str:
telegram_id = PanelWebhookService._payload_telegram_id(user_payload)
panel_uuid = PanelWebhookService._payload_panel_uuid(user_payload)
email = PanelWebhookService._mask_email(str(user_payload.get("email") or "").strip())
expire_at = str(user_payload.get("expireAt") or "").strip()
payload_keys = ",".join(sorted(str(key) for key in user_payload.keys())) or "none"
return (
f"telegramId={telegram_id or 'N/A'} "
f"panel_uuid={panel_uuid or 'N/A'} "
f"email={email or 'N/A'} "
f"expireAt={expire_at or 'N/A'} "
f"payload_keys={payload_keys}"
)
@staticmethod
def _mask_email(email: str) -> str:
if not email:
return ""
local_part, separator, domain = email.partition("@")
if not separator or not domain:
return "present"
visible = local_part[:2] if len(local_part) > 2 else local_part[:1]
return f"{visible}***@{domain}"
@staticmethod
def _payload_expire_datetime(user_payload: dict) -> Optional[datetime]:
raw = str(user_payload.get("expireAt") or "").strip()
if not raw:
return None
try:
value = datetime.fromisoformat(raw.replace("Z", "+00:00"))
except ValueError:
try:
value = datetime.fromisoformat(raw[:10])
except ValueError:
return None
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
async def handle_webhook(
self, raw_body: bytes, signature_header: Optional[str]
+88 -20
View File
@@ -7,6 +7,7 @@ from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from bot.middlewares.i18n import JsonI18n
from bot.services.user_email_notifications import send_user_notification_email
from config.settings import Settings
from db.dal import payment_dal, subscription_dal, user_dal
@@ -33,6 +34,7 @@ class ReferralService:
purchased_subscription_months: int,
current_payment_db_id: Optional[int] = None,
skip_if_active_before_payment: bool = True,
tariff_key: Optional[str] = None,
) -> Dict[str, Any]:
referee_final_end_date: Optional[datetime] = None
@@ -94,11 +96,9 @@ class ReferralService:
else self.i18n.gettext(default_lang_for_placeholder, "friend_placeholder")
)
inviter_bonus_days = self.settings.referral_bonus_inviter.get(
purchased_subscription_months
)
referee_bonus_days = self.settings.referral_bonus_referee.get(
purchased_subscription_months
inviter_bonus_days, referee_bonus_days = self._referral_bonus_days_for_payment(
purchased_subscription_months,
tariff_key=tariff_key,
)
if inviter_bonus_days and inviter_bonus_days > 0:
@@ -142,18 +142,36 @@ class ReferralService:
inviter_user_model.language_code or default_lang_for_placeholder
)
_i = lambda k, **kw: self.i18n.gettext(inviter_lang, k, **kw)
await self.bot.send_message(
inviter_user_id,
_i(
"referral_bonus_inviter_notification_extended",
days=inviter_bonus_days,
referee_name=referee_name_for_msg,
new_end_date=new_end_date_inviter.strftime("%Y-%m-%d"),
message_text = _i(
"referral_bonus_inviter_notification_extended",
days=inviter_bonus_days,
referee_name=referee_name_for_msg,
new_end_date=new_end_date_inviter.strftime("%Y-%m-%d"),
)
try:
await self.bot.send_message(inviter_user_id, message_text)
except Exception as e_notify_inviter:
logging.error(
f"Failed to send bonus notification to inviter {inviter_user_id}: {e_notify_inviter}" # noqa: E501
)
await send_user_notification_email(
settings=self.settings,
i18n=self.i18n,
user=inviter_user_model,
subject_key="email_referral_bonus_subject",
message_text=message_text,
dashboard_url=(
getattr(
self.settings,
"SUBSCRIPTION_MINI_APP_URL",
"",
)
or None
),
)
except Exception as e_notify_inviter:
logging.error(
f"Failed to send bonus notification to inviter {inviter_user_id}: {e_notify_inviter}" # noqa: E501
f"Failed to prepare bonus notification for inviter {inviter_user_id}: {e_notify_inviter}" # noqa: E501
)
else:
logging.info(
@@ -210,13 +228,34 @@ class ReferralService:
_i = lambda k, **kw: self.i18n.gettext(
inviter_lang, k, **kw
)
await self.bot.send_message(
inviter_user_id,
_i(
"referral_bonus_inviter_notification_new_sub",
days=inviter_bonus_days,
referee_name=referee_name_for_msg,
new_end_date=bonus_end_date.strftime("%Y-%m-%d"),
message_text = _i(
"referral_bonus_inviter_notification_new_sub",
days=inviter_bonus_days,
referee_name=referee_name_for_msg,
new_end_date=bonus_end_date.strftime("%Y-%m-%d"),
)
try:
await self.bot.send_message(
inviter_user_id,
message_text,
)
except Exception as e_notify_inviter:
logging.error(
f"Failed to send bonus notification to inviter {inviter_user_id}: {e_notify_inviter}" # noqa: E501
)
await send_user_notification_email(
settings=self.settings,
i18n=self.i18n,
user=inviter_user_model,
subject_key="email_referral_bonus_subject",
message_text=message_text,
dashboard_url=(
getattr(
self.settings,
"SUBSCRIPTION_MINI_APP_URL",
"",
)
or None
),
)
else:
@@ -263,6 +302,35 @@ class ReferralService:
raise
def _referral_bonus_days_for_payment(
self,
purchased_subscription_months: int,
*,
tariff_key: Optional[str] = None,
) -> tuple[Optional[int], Optional[int]]:
months = int(purchased_subscription_months)
tariffs_config = getattr(self.settings, "tariffs_config", None)
if tariff_key and tariffs_config:
try:
tariff = tariffs_config.require(str(tariff_key))
except Exception:
logging.warning(
"Referral bonuses skipped: tariff %s was not found.",
tariff_key,
)
return None, None
if tariff.billing_model != "period":
return None, None
return (
tariff.referral_inviter_bonus_days(months),
tariff.referral_referee_bonus_days(months),
)
return (
self.settings.referral_bonus_inviter.get(months),
self.settings.referral_bonus_referee.get(months),
)
async def generate_referral_link(
self, session: AsyncSession, bot_username: str, inviter_user_id: int
) -> Optional[str]:
@@ -0,0 +1,361 @@
import logging
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Optional
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
from aiogram import Bot
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
from aiogram.types import InlineKeyboardMarkup
from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.inline.user_keyboards import get_subscribe_only_markup
from bot.middlewares.i18n import JsonI18n
from bot.services.email_auth_service import EmailAuthService
from bot.services.email_templates import render_subscription_lifecycle_notification
from bot.services.telegram_notifications import (
TELEGRAM_NOTIFICATIONS_BLOCKED,
TELEGRAM_NOTIFICATIONS_ENABLED,
TELEGRAM_NOTIFICATIONS_NEEDS_START,
mark_telegram_notifications_status,
normalize_telegram_notification_status,
telegram_notification_status_from_error,
)
from config.settings import Settings
from db.dal import subscription_dal
from db.models import Subscription, User
@dataclass(frozen=True)
class SubscriptionNotificationStage:
key: str
message_key: str
days_left: Optional[int] = None
hours_before: Optional[int] = None
@dataclass(frozen=True)
class SubscriptionNotificationDelivery:
telegram_sent: bool = False
email_sent: bool = False
@property
def any_sent(self) -> bool:
return self.telegram_sent or self.email_sent
class SubscriptionLifecycleNotificationService:
def __init__(
self,
settings: Settings,
bot: Bot,
i18n: JsonI18n,
*,
email_service: Optional[EmailAuthService] = None,
) -> None:
self.settings = settings
self.bot = bot
self.i18n = i18n
self.email_service = email_service
async def send_stage(
self,
session: AsyncSession,
sub: Subscription,
stage: SubscriptionNotificationStage,
*,
user: Optional[User] = None,
telegram_markup: Optional[InlineKeyboardMarkup] = None,
extra_text: str = "",
end_date_text: Optional[str] = None,
sent_at: Optional[datetime] = None,
) -> SubscriptionNotificationDelivery:
if sent_at is None:
sent_at = datetime.now(timezone.utc)
resolved_user = user or getattr(sub, "user", None)
lang = getattr(resolved_user, "language_code", None) or self.settings.DEFAULT_LANGUAGE
user_id = int(getattr(sub, "user_id", 0) or 0)
final_end_date_text = end_date_text
if final_end_date_text is None:
end_date = self._as_utc(getattr(sub, "end_date", None))
final_end_date_text = end_date.strftime("%Y-%m-%d") if end_date else ""
recipient_email = self._email_recipient(resolved_user)
telegram_user_name = self._telegram_display_name(resolved_user, user_id)
email_user_name = self._email_display_name(
resolved_user,
recipient_email=recipient_email,
fallback=telegram_user_name,
)
kwargs = {"user_name": telegram_user_name, "end_date": final_end_date_text}
if stage.hours_before is not None:
kwargs["hours"] = stage.hours_before
message_text = self.i18n.gettext(lang, stage.message_key, **kwargs)
email_kwargs = {**kwargs, "user_name": email_user_name}
email_message_text = self.i18n.gettext(lang, stage.message_key, **email_kwargs)
final_extra_text = str(extra_text or "").strip()
if final_extra_text:
message_text = f"{message_text}\n\n{final_extra_text}"
email_message_text = f"{email_message_text}\n\n{final_extra_text}"
telegram_sent = await self._send_telegram(
session,
sub,
stage,
resolved_user,
lang=lang,
message_text=message_text,
markup=telegram_markup or get_subscribe_only_markup(lang, self.i18n),
sent_at=sent_at,
)
email_sent = await self._send_email(
session,
sub,
stage,
resolved_user,
lang=lang,
message_text=email_message_text,
end_date_text=final_end_date_text,
recipient=recipient_email,
telegram_sent=telegram_sent,
sent_at=sent_at,
)
return SubscriptionNotificationDelivery(
telegram_sent=telegram_sent,
email_sent=email_sent,
)
async def _send_telegram(
self,
session: AsyncSession,
sub: Subscription,
stage: SubscriptionNotificationStage,
user: Optional[User],
*,
lang: str,
message_text: str,
markup: Optional[InlineKeyboardMarkup],
sent_at: datetime,
) -> bool:
chat_id = self._telegram_chat_id(user, getattr(sub, "user_id", None))
if chat_id is None:
return False
if user:
status = normalize_telegram_notification_status(
getattr(user, "telegram_notifications_status", None)
)
if status in {TELEGRAM_NOTIFICATIONS_NEEDS_START, TELEGRAM_NOTIFICATIONS_BLOCKED}:
return False
if await self._already_sent(session, sub.subscription_id, stage.key, "telegram"):
return False
try:
await self.bot.send_message(chat_id, message_text, reply_markup=markup)
except (TelegramBadRequest, TelegramForbiddenError) as exc:
delivery_status = telegram_notification_status_from_error(exc)
if user and delivery_status:
await mark_telegram_notifications_status(
session,
int(user.user_id),
delivery_status,
)
if delivery_status:
logging.warning(
"Skipping subscription notification %s for unreachable Telegram user %s: %s",
stage.key,
chat_id,
exc,
)
return False
logging.exception(
"Failed to send subscription notification %s to Telegram user %s",
stage.key,
chat_id,
)
return False
except Exception:
logging.exception(
"Failed to send subscription notification %s to Telegram user %s",
stage.key,
chat_id,
)
return False
await subscription_dal.record_subscription_notification(
session,
sub.subscription_id,
self._channel_key(stage.key, "telegram"),
sent_at=sent_at,
)
if user:
status = normalize_telegram_notification_status(
getattr(user, "telegram_notifications_status", None)
)
if status != TELEGRAM_NOTIFICATIONS_ENABLED:
await mark_telegram_notifications_status(
session,
int(user.user_id),
TELEGRAM_NOTIFICATIONS_ENABLED,
telegram_id=chat_id,
checked_at=sent_at,
)
return True
async def _send_email(
self,
session: AsyncSession,
sub: Subscription,
stage: SubscriptionNotificationStage,
user: Optional[User],
*,
lang: str,
message_text: str,
end_date_text: str,
recipient: str,
telegram_sent: bool,
sent_at: datetime,
) -> bool:
if not getattr(self.settings, "SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED", True):
return False
if not getattr(self.settings, "email_auth_configured", False):
return False
if not recipient:
return False
if await self._already_sent(session, sub.subscription_id, stage.key, "email"):
return False
try:
content = render_subscription_lifecycle_notification(
self.settings,
language_code=lang,
notification_key=stage.key,
message_text=message_text,
end_date_text=end_date_text,
dashboard_url=self._renewal_dashboard_url(recipient, sub),
mirrored_from_telegram=telegram_sent,
days_left=stage.days_left,
hours_before=stage.hours_before,
i18n=self.i18n,
)
email_service = self.email_service or EmailAuthService(self.settings, self.i18n)
await email_service.send_rendered_email(email=recipient, content=content)
except Exception:
logging.exception(
"Failed to send subscription notification %s to email %s",
stage.key,
recipient,
)
return False
await subscription_dal.record_subscription_notification(
session,
sub.subscription_id,
self._channel_key(stage.key, "email"),
sent_at=sent_at,
)
return True
async def _already_sent(
self,
session: AsyncSession,
subscription_id: int,
stage_key: str,
channel: str,
) -> bool:
channel_key = self._channel_key(stage_key, channel)
if await subscription_dal.has_subscription_notification(
session,
subscription_id,
channel_key,
):
return True
# Legacy rows were stored without a channel. Treat them as Telegram-only
# history so existing installs do not re-send old bot messages, while
# still allowing the newly introduced email channel to catch up.
return channel == "telegram" and await subscription_dal.has_subscription_notification(
session,
subscription_id,
stage_key,
)
@staticmethod
def _channel_key(stage_key: str, channel: str) -> str:
return f"{stage_key}:{channel}"
@staticmethod
def _email_recipient(user: Optional[User]) -> str:
return str(getattr(user, "email", "") or "").strip().lower() if user else ""
@staticmethod
def _telegram_display_name(user: Optional[User], fallback_user_id: int) -> str:
return str(getattr(user, "first_name", "") or "").strip() or f"User {fallback_user_id}"
@staticmethod
def _email_display_name(
user: Optional[User],
*,
recipient_email: str,
fallback: str,
) -> str:
return str(getattr(user, "first_name", "") or "").strip() or recipient_email or fallback
def _renewal_dashboard_url(self, recipient_email: str, sub: Subscription) -> Optional[str]:
base_url = (self.settings.SUBSCRIPTION_MINI_APP_URL or "").strip()
if not base_url:
return None
parsed = urlsplit(base_url)
if parsed.scheme not in ("http", "https") or not parsed.netloc:
return None
query = dict(parse_qsl(parsed.query, keep_blank_values=True))
query.update(
{
"login": "email_code",
"login_email": recipient_email,
"after_login": "renew",
"renew": "1",
}
)
tariff_key = self._renewal_tariff_key(sub)
if tariff_key:
query["renew_tariff"] = tariff_key
else:
query.pop("renew_tariff", None)
return urlunsplit(
(
parsed.scheme,
parsed.netloc,
parsed.path or "/",
urlencode(query),
parsed.fragment,
)
)
@staticmethod
def _renewal_tariff_key(sub: Subscription) -> str:
provider = str(getattr(sub, "provider", "") or "").strip().lower()
status = str(getattr(sub, "status_from_panel", "") or "").strip().upper()
if provider == "trial" or status == "TRIAL":
return ""
return str(getattr(sub, "tariff_key", "") or "").strip()
@staticmethod
def _telegram_chat_id(user: Optional[User], fallback_user_id: Optional[int]) -> Optional[int]:
for candidate in (getattr(user, "telegram_id", None), fallback_user_id):
try:
chat_id = int(candidate or 0)
except (TypeError, ValueError):
continue
if chat_id > 0:
return chat_id
return None
@staticmethod
def _as_utc(value: Optional[datetime]) -> Optional[datetime]:
if value is None:
return None
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
@@ -1,7 +1,6 @@
import asyncio
import logging
import time
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Optional
@@ -15,8 +14,22 @@ from bot.infra.redis import redis_lock
from bot.keyboards.inline.user_keyboards import get_subscribe_only_markup
from bot.middlewares.i18n import JsonI18n
from bot.services.panel_api_service import PanelApiService
from bot.services.subscription_lifecycle_notifications import (
SubscriptionLifecycleNotificationService,
SubscriptionNotificationStage,
)
from bot.services.subscription_service import SubscriptionService
from bot.services.telegram_notifications import (
TELEGRAM_NOTIFICATIONS_BLOCKED,
TELEGRAM_NOTIFICATIONS_ENABLED,
TELEGRAM_NOTIFICATIONS_NEEDS_START,
mark_telegram_notifications_status,
normalize_telegram_notification_status,
telegram_notification_status_from_error,
)
from bot.services.user_email_notifications import send_user_notification_email
from config.settings import Settings
from db.advisory_locks import acquire_subscription_background_sync_lock
from db.dal import subscription_dal
from db.models import Subscription
@@ -26,13 +39,6 @@ EXPIRED_NOTIFICATION_WINDOW = timedelta(hours=24)
EXPIRED_AFTER_NOTIFICATION_WINDOW = timedelta(hours=48)
@dataclass(frozen=True)
class SubscriptionNotificationStage:
key: str
message_key: str
hours_before: Optional[int] = None
class SubscriptionNotificationWorker:
def __init__(
self,
@@ -49,6 +55,11 @@ class SubscriptionNotificationWorker:
self.i18n = i18n
self.panel_service = panel_service
self.subscription_service = subscription_service
self.lifecycle_notifications = SubscriptionLifecycleNotificationService(
settings,
bot,
i18n,
)
self._stopped = asyncio.Event()
async def run(self) -> None:
@@ -66,6 +77,7 @@ class SubscriptionNotificationWorker:
else:
started = time.monotonic()
async with self.session_factory() as session:
await acquire_subscription_background_sync_lock(session)
await self.expiry_tick(session)
await self.trial_traffic_tick(session)
await session.commit()
@@ -114,18 +126,10 @@ class SubscriptionNotificationWorker:
stage = self.stage_for_subscription(sub, now)
if stage is None:
continue
if await subscription_dal.has_subscription_notification(
await self.lifecycle_notifications.send_stage(
session,
sub.subscription_id,
stage.key,
):
continue
if not await self._send_expiry_notification(sub, stage):
continue
await subscription_dal.record_subscription_notification(
session,
sub.subscription_id,
stage.key,
sub,
stage,
sent_at=now,
)
@@ -164,6 +168,7 @@ class SubscriptionNotificationWorker:
return SubscriptionNotificationStage(
key=f"before_{days_before}d",
message_key=message_key,
days_left=days_before,
)
return None
@@ -175,6 +180,7 @@ class SubscriptionNotificationWorker:
return SubscriptionNotificationStage(
key="expired",
message_key="subscription_expired_notification",
days_left=0,
)
if (
getattr(self.settings, "SUBSCRIPTION_NOTIFY_AFTER_EXPIRE", True)
@@ -183,6 +189,7 @@ class SubscriptionNotificationWorker:
return SubscriptionNotificationStage(
key="expired_24h_after",
message_key="subscription_expired_yesterday_notification",
days_left=0,
)
return None
@@ -208,11 +215,22 @@ class SubscriptionNotificationWorker:
.order_by(Subscription.end_date.asc())
)
for sub in result.scalars().all():
if await subscription_dal.has_subscription_notification(
legacy_sent = await subscription_dal.has_subscription_notification(
session,
sub.subscription_id,
"trial_traffic_depleted",
):
)
telegram_done = legacy_sent or await subscription_dal.has_subscription_notification(
session,
sub.subscription_id,
"trial_traffic_depleted:telegram",
)
email_done = await subscription_dal.has_subscription_notification(
session,
sub.subscription_id,
"trial_traffic_depleted:email",
)
if telegram_done and email_done:
continue
used = int(getattr(sub, "traffic_used_bytes", 0) or 0)
@@ -234,14 +252,30 @@ class SubscriptionNotificationWorker:
if limit <= 0 or used < limit:
continue
if not await self._send_trial_traffic_depleted(sub, used=used, limit=limit):
continue
await subscription_dal.record_subscription_notification(
delivery = await self._send_trial_traffic_depleted(
session,
sub.subscription_id,
"trial_traffic_depleted",
sent_at=now,
sub,
used=used,
limit=limit,
send_telegram=not telegram_done,
send_email=not email_done,
)
if not delivery["telegram"] and not delivery["email"]:
continue
if delivery["telegram"]:
await subscription_dal.record_subscription_notification(
session,
sub.subscription_id,
"trial_traffic_depleted:telegram",
sent_at=now,
)
if delivery["email"]:
await subscription_dal.record_subscription_notification(
session,
sub.subscription_id,
"trial_traffic_depleted:email",
sent_at=now,
)
async def _panel_user(self, sub: Subscription) -> Optional[dict]:
panel_uuid = str(getattr(sub, "panel_user_uuid", "") or "").strip()
@@ -257,68 +291,72 @@ class SubscriptionNotificationWorker:
return None
return data if isinstance(data, dict) else None
async def _send_expiry_notification(
self,
sub: Subscription,
stage: SubscriptionNotificationStage,
) -> bool:
user_id = int(getattr(sub, "user_id", 0) or 0)
if user_id <= 0:
return False
user = getattr(sub, "user", None)
lang = getattr(user, "language_code", None) or self.settings.DEFAULT_LANGUAGE
user_name = getattr(user, "first_name", None) or f"User {user_id}"
end_date = self._as_utc(getattr(sub, "end_date", None))
end_date_text = end_date.strftime("%Y-%m-%d") if end_date else ""
translate = lambda k, **kw: self.i18n.gettext(lang, k, **kw)
kwargs = {"user_name": user_name, "end_date": end_date_text}
if stage.hours_before is not None:
kwargs["hours"] = stage.hours_before
try:
await self.bot.send_message(
user_id,
translate(stage.message_key, **kwargs),
reply_markup=get_subscribe_only_markup(lang, self.i18n),
)
return True
except Exception:
logging.exception(
"Failed to send subscription notification %s to user %s",
stage.key,
user_id,
)
return False
async def _send_trial_traffic_depleted(
self,
session: AsyncSession,
sub: Subscription,
*,
used: int,
limit: int,
) -> bool:
send_telegram: bool = True,
send_email: bool = True,
) -> dict[str, bool]:
user_id = int(getattr(sub, "user_id", 0) or 0)
if user_id <= 0:
return False
user = getattr(sub, "user", None)
lang = getattr(user, "language_code", None) or self.settings.DEFAULT_LANGUAGE
translate = lambda k, **kw: self.i18n.gettext(lang, k, **kw)
remaining = max(0, limit - used)
try:
await self.bot.send_message(
user_id,
translate(
"trial_traffic_depleted_notification",
used=hd.quote(self._fmt_bytes(used)),
remaining=hd.quote(self._fmt_bytes(remaining)),
limit_total=hd.quote(self._fmt_bytes(limit)),
),
reply_markup=get_subscribe_only_markup(lang, self.i18n),
parse_mode="HTML",
message_text = translate(
"trial_traffic_depleted_notification",
used=hd.quote(self._fmt_bytes(used)),
remaining=hd.quote(self._fmt_bytes(remaining)),
limit_total=hd.quote(self._fmt_bytes(limit)),
)
telegram_sent = False
email_sent = False
telegram_chat_id = int(getattr(user, "telegram_id", 0) or user_id or 0)
telegram_status = normalize_telegram_notification_status(
getattr(user, "telegram_notifications_status", None)
)
can_try_telegram = telegram_status not in {
TELEGRAM_NOTIFICATIONS_NEEDS_START,
TELEGRAM_NOTIFICATIONS_BLOCKED,
}
if send_telegram and telegram_chat_id > 0 and can_try_telegram:
try:
await self.bot.send_message(
telegram_chat_id,
message_text,
reply_markup=get_subscribe_only_markup(lang, self.i18n),
parse_mode="HTML",
)
telegram_sent = True
except Exception as exc:
status = telegram_notification_status_from_error(exc)
if status and user and user_id:
await mark_telegram_notifications_status(session, user_id, status)
logging.exception(
"Failed to send trial traffic depleted warning to user %s",
telegram_chat_id,
)
else:
if user and telegram_status != TELEGRAM_NOTIFICATIONS_ENABLED and user_id:
await mark_telegram_notifications_status(
session,
user_id,
TELEGRAM_NOTIFICATIONS_ENABLED,
telegram_id=telegram_chat_id,
)
if send_email and user:
email_sent = await send_user_notification_email(
settings=self.settings,
i18n=self.i18n,
user=user,
subject_key="email_trial_traffic_depleted_subject",
message_text=message_text,
dashboard_url=(getattr(self.settings, "SUBSCRIPTION_MINI_APP_URL", "") or None),
)
return True
except Exception:
logging.exception("Failed to send trial traffic depleted warning to user %s", user_id)
return False
return {"telegram": telegram_sent, "email": email_sent}
def _max_before_window(self) -> timedelta:
days_before = max(0, int(getattr(self.settings, "SUBSCRIPTION_NOTIFY_DAYS_BEFORE", 0) or 0))
@@ -357,6 +357,15 @@ class HwidDeviceMixin:
valid_from=valid_from,
valid_until=valid_until,
)
await self._send_payment_success_email(
db_user=db_user,
sale_mode="hwid_devices_renewal" if renewal else "hwid_devices",
months=purchased_devices,
traffic_gb=None,
payment_amount=payment_amount,
end_date=valid_until,
provider=provider,
)
return {
"subscription_id": updated_sub.subscription_id,
"end_date": updated_sub.end_date,
@@ -3,6 +3,175 @@ from ._runtime import * # noqa: F403,F405
class SubscriptionLifecycleMixin:
async def _lookup_panel_user_for_subscription_details(
self,
panel_user_uuid: str,
) -> Tuple[Optional[Dict[str, Any]], bool, str]:
lookup_method = getattr(self.panel_service, "get_user_by_uuid_lookup", None)
if callable(lookup_method):
try:
lookup = await lookup_method(panel_user_uuid, log_response=False)
except TypeError:
try:
lookup = await lookup_method(panel_user_uuid)
except Exception as exc:
logging.exception(
"Failed to fetch panel user %s for subscription details",
panel_user_uuid,
)
return None, False, self._panel_lookup_exception_reason(exc)
except Exception as exc:
logging.exception(
"Failed to fetch panel user %s for subscription details",
panel_user_uuid,
)
return None, False, self._panel_lookup_exception_reason(exc)
if isinstance(lookup, dict) and ("ok" in lookup or "not_found" in lookup):
user = lookup.get("user")
if lookup.get("ok") and isinstance(user, dict):
return user, False, ""
reason = str(lookup.get("failure_reason") or "classification=panel_lookup_failed")
return None, bool(lookup.get("not_found")), reason
try:
panel_user = await self.panel_service.get_user_by_uuid(panel_user_uuid)
except Exception as exc:
logging.exception(
"Failed to fetch panel user %s for subscription details",
panel_user_uuid,
)
return None, False, self._panel_lookup_exception_reason(exc)
return (panel_user if isinstance(panel_user, dict) else None), False, ""
@staticmethod
def _panel_lookup_exception_reason(exc: Exception) -> str:
message = str(exc).replace("\n", " ").strip()
if len(message) > 300:
message = f"{message[:300]}..."
reason = f"classification=panel_lookup_failed exception={type(exc).__name__}"
if message:
reason = f"{reason} message={message}"
return reason
@staticmethod
def _display_datetime_text(value: Optional[Any]) -> Optional[str]:
if not value:
return None
if isinstance(value, datetime):
normalized = value if value.tzinfo else value.replace(tzinfo=timezone.utc)
return normalized.strftime("%d.%m.%Y %H:%M")
return str(value)
@staticmethod
def _device_topup_renewal_available(
extra_hwid_devices: int,
extra_hwid_valid_until: Optional[Any],
subscription_end_date: Optional[Any],
) -> bool:
if not isinstance(extra_hwid_valid_until, datetime) or not isinstance(
subscription_end_date, datetime
):
return False
valid_until = (
extra_hwid_valid_until
if extra_hwid_valid_until.tzinfo
else extra_hwid_valid_until.replace(tzinfo=timezone.utc)
)
end_date = (
subscription_end_date
if subscription_end_date.tzinfo
else subscription_end_date.replace(tzinfo=timezone.utc)
)
return bool(int(extra_hwid_devices or 0) > 0 and valid_until < end_date)
async def _local_active_subscription_details_fallback(
self,
db_user: User,
local_active_sub: Subscription,
) -> Dict[str, Any]:
panel_sub_id = str(local_active_sub.panel_subscription_uuid or "").strip()
config_link_raw = (
await self.panel_service.get_subscription_link(panel_sub_id) if panel_sub_id else None
)
display_link, connect_button_url = await prepare_config_links(
self.settings,
config_link_raw,
)
tariff = None
if local_active_sub.tariff_key and self._tariffs_config():
try:
tariff = self._resolve_tariff(local_active_sub.tariff_key)
except Exception:
tariff = None
language = db_user.language_code or self.settings.DEFAULT_LANGUAGE
premium_access = (
await self.premium_access_for_tariff(tariff)
if tariff
else {"squad_uuids": [], "squad_labels": [], "node_labels": []}
)
premium_baseline = int(local_active_sub.premium_baseline_bytes or 0)
premium_topup_balance = int(local_active_sub.premium_topup_balance_bytes or 0)
premium_topup_used = int(getattr(local_active_sub, "premium_topup_used_bytes", 0) or 0)
premium_bonus_bytes = int(getattr(local_active_sub, "premium_bonus_bytes", 0) or 0)
return {
"user_id": db_user.panel_user_uuid,
"panel_subscription_uuid": local_active_sub.panel_subscription_uuid,
"panel_short_uuid": local_active_sub.panel_subscription_uuid,
"end_date": local_active_sub.end_date,
"status_from_panel": local_active_sub.status_from_panel or "LOCAL_CACHE",
"config_link": display_link,
"connect_button_url": connect_button_url,
"traffic_limit_bytes": local_active_sub.traffic_limit_bytes,
"traffic_used_bytes": local_active_sub.traffic_used_bytes,
"traffic_limit_strategy": "",
"tariff_key": local_active_sub.tariff_key,
"tariff_name": tariff.name(language) if tariff else None,
"tariff_description": tariff.description(language) if tariff else None,
"premium_title": tariff.premium_name(language) if tariff else None,
"billing_model": tariff.billing_model
if tariff
else ("traffic" if getattr(self.settings, "traffic_sale_mode", False) else "period"),
"tier_baseline_bytes": local_active_sub.tier_baseline_bytes,
"topup_balance_bytes": local_active_sub.topup_balance_bytes,
"regular_bonus_bytes": int(getattr(local_active_sub, "regular_bonus_bytes", 0) or 0),
"regular_unlimited_override": bool(
getattr(local_active_sub, "regular_unlimited_override", False)
),
"premium_baseline_bytes": premium_baseline,
"premium_topup_balance_bytes": premium_topup_balance,
"premium_topup_used_bytes": premium_topup_used,
"premium_used_bytes": local_active_sub.premium_used_bytes,
"premium_bonus_bytes": premium_bonus_bytes,
"premium_unlimited_override": bool(
getattr(local_active_sub, "premium_unlimited_override", False)
),
"premium_limit_bytes": self._premium_effective_limit_bytes(
premium_baseline,
premium_topup_balance,
premium_topup_used,
premium_bonus_bytes,
),
"premium_is_limited": bool(local_active_sub.premium_is_limited),
"premium_period_start_at": getattr(local_active_sub, "premium_period_start_at", None),
"premium_squad_labels": premium_access.get("squad_labels") or [],
"premium_node_labels": premium_access.get("node_labels") or [],
"period_start_at": local_active_sub.period_start_at,
"is_throttled": bool(local_active_sub.is_throttled),
"base_hwid_device_limit": local_active_sub.hwid_device_limit,
"extra_hwid_devices": int(local_active_sub.extra_hwid_devices or 0),
"extra_hwid_devices_valid_until": None,
"extra_hwid_devices_valid_until_text": None,
"extra_hwid_devices_next_valid_from": None,
"device_topup_renewal_available": False,
"user_bot_username": db_user.username,
"is_panel_data": False,
"max_devices": self._effective_hwid_limit(
local_active_sub.hwid_device_limit,
int(local_active_sub.extra_hwid_devices or 0),
),
}
async def switch_tariff_without_payment(
self,
session: AsyncSession,
@@ -308,6 +477,17 @@ class SubscriptionLifecycleMixin:
)
result["end_date"] = sub.end_date
result["is_active"] = sub.is_active
db_user = await user_dal.get_user_by_id(session, user_id)
if db_user:
await self._send_payment_success_email(
db_user=db_user,
sale_mode="tariff_upgrade",
months=0,
traffic_gb=None,
payment_amount=payment_amount,
end_date=sub.end_date,
provider=provider,
)
return result
tariff = self._resolve_tariff(tariff_key, "period") if self._tariffs_config() else None
@@ -568,6 +748,9 @@ class SubscriptionLifecycleMixin:
active_sub = await subscription_dal.get_active_subscription_by_user_id(
session, user_id, panel_uuid
)
preserve_tariff_limits = bool(
active_sub and active_sub.tariff_key and self._tariffs_config()
)
if not active_sub or not active_sub.end_date:
logging.info(
f"No active subscription found for user {user_id}. Creating new one for {bonus_days} days." # noqa: E501
@@ -612,6 +795,7 @@ class SubscriptionLifecycleMixin:
if (
apply_main_traffic_limit
and not preserve_tariff_limits
and updated_sub_model
and updated_sub_model.traffic_limit_bytes != self.settings.user_traffic_limit_bytes
):
@@ -626,7 +810,9 @@ class SubscriptionLifecycleMixin:
panel_update_payload = self._build_panel_update_payload(
expire_at=new_end_date_obj,
traffic_limit_bytes=(
self.settings.user_traffic_limit_bytes if apply_main_traffic_limit else None
self.settings.user_traffic_limit_bytes
if apply_main_traffic_limit and not preserve_tariff_limits
else None
),
include_uuid=False,
include_default_squads=False,
@@ -663,14 +849,36 @@ class SubscriptionLifecycleMixin:
local_active_sub = await subscription_dal.get_active_subscription_by_user_id(
session, user_id, panel_user_uuid
)
panel_user_data = await self.panel_service.get_user_by_uuid(panel_user_uuid)
(
panel_user_data,
panel_user_confirmed_absent,
panel_lookup_failure_reason,
) = await self._lookup_panel_user_for_subscription_details(panel_user_uuid)
if not panel_user_data:
if panel_user_confirmed_absent:
logging.warning(
"Panel user %s confirmed absent on panel for user %s. "
"Clearing local linkage. reason=%s",
panel_user_uuid,
user_id,
panel_lookup_failure_reason,
)
await subscription_dal.deactivate_all_user_subscriptions(session, user_id)
await user_dal.update_user(session, user_id, {"panel_user_uuid": None})
return None
logging.warning(
f"Panel user {panel_user_uuid} not found on panel for user {user_id}. Clearing local linkage." # noqa: E501
"Panel user %s lookup failed for user %s; treating it as a panel access/API "
"problem and preserving local linkage/subscription. reason=%s",
panel_user_uuid,
user_id,
panel_lookup_failure_reason,
)
await subscription_dal.deactivate_all_user_subscriptions(session, user_id)
await user_dal.update_user(session, user_id, {"panel_user_uuid": None})
if local_active_sub:
return await self._local_active_subscription_details_fallback(
db_user,
local_active_sub,
)
return None
panel_lifetime_used = self._extract_lifetime_used_traffic(panel_user_data)
@@ -836,6 +1044,14 @@ class SubscriptionLifecycleMixin:
if expected_hwid_limit is not None:
hwid_limit = expected_hwid_limit
extra_hwid_valid_until = hwid_entitlement_summary.get("active_until")
extra_hwid_next_valid_from = hwid_entitlement_summary.get("next_valid_from")
device_topup_renewal_available = self._device_topup_renewal_available(
active_extra_hwid_devices,
extra_hwid_valid_until,
panel_end_date,
)
return {
"user_id": panel_user_data.get("uuid"),
"panel_subscription_uuid": panel_user_data.get("subscriptionUuid")
@@ -896,8 +1112,12 @@ class SubscriptionLifecycleMixin:
if local_active_sub
else None,
"extra_hwid_devices": active_extra_hwid_devices,
"extra_hwid_devices_valid_until": hwid_entitlement_summary.get("active_until"),
"extra_hwid_devices_next_valid_from": hwid_entitlement_summary.get("next_valid_from"),
"extra_hwid_devices_valid_until": extra_hwid_valid_until,
"extra_hwid_devices_valid_until_text": self._display_datetime_text(
extra_hwid_valid_until
),
"extra_hwid_devices_next_valid_from": extra_hwid_next_valid_from,
"device_topup_renewal_available": device_topup_renewal_available,
"user_bot_username": db_user.username,
"is_panel_data": True,
"max_devices": hwid_limit,
@@ -93,7 +93,7 @@ class PaymentContextMixin:
"""Best-effort branded email confirming the payment. No-op if SMTP or
the user's email aren't set. Failures are logged and swallowed so the
payment flow is never blocked by mail delivery."""
if not self.settings.email_auth_configured:
if not getattr(self.settings, "email_auth_configured", False):
return
recipient = (db_user.email or "").strip() if db_user else ""
if not recipient:
@@ -269,6 +269,15 @@ class TrafficMixin:
purchased_bytes=purchase_bytes,
kind="topup",
)
await self._send_payment_success_email(
db_user=db_user,
sale_mode="topup",
months=0,
traffic_gb=float(traffic_gb),
payment_amount=payment_amount,
end_date=getattr(updated_sub, "end_date", None),
provider=provider,
)
return {
"subscription_id": sub.subscription_id,
"traffic_limit_bytes": new_limit,
@@ -376,6 +385,15 @@ class TrafficMixin:
purchased_bytes=purchase_bytes,
kind="premium_topup",
)
await self._send_payment_success_email(
db_user=db_user,
sale_mode="premium_topup",
months=0,
traffic_gb=float(traffic_gb),
payment_amount=payment_amount,
end_date=getattr(sub, "end_date", None),
provider=provider,
)
return {
"subscription_id": sub.subscription_id,
"premium_limit_bytes": premium_limit,
+221 -91
View File
@@ -2,7 +2,7 @@ import asyncio
import logging
import time
from datetime import datetime, timezone
from typing import Any, Optional
from typing import Any, Awaitable, Callable, Optional
from aiogram import Bot
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, WebAppInfo
@@ -15,9 +15,11 @@ from bot.infra.redis import redis_lock
from bot.middlewares.i18n import JsonI18n
from bot.services.panel_api_service import PanelApiService
from bot.services.subscription_service import SubscriptionService
from bot.services.user_email_notifications import send_user_notification_email
from bot.utils.date_utils import month_start
from bot.utils.mini_app_url import subscription_mini_app_topup_url
from config.settings import Settings
from db.advisory_locks import acquire_subscription_background_sync_lock
from db.dal import subscription_dal, tariff_dal, user_dal
from db.models import Subscription
@@ -31,6 +33,10 @@ TARIFF_WORKER_BATCH_SIZE = 50
TARIFF_WORKER_PANEL_CONCURRENCY = 10
TARIFF_WORKER_BULK_PANEL_FETCH_THRESHOLD = 50
TARIFF_WORKER_SQUAD_CONFIRMATION_CACHE_TTL_SECONDS = 900
TARIFF_WORKER_DB_RETRY_ATTEMPTS = 3
TARIFF_WORKER_DB_RETRY_BASE_SLEEP_SECONDS = 0.5
POSTGRES_RETRYABLE_SQLSTATES = {"40001", "40P01"}
POSTGRES_RETRYABLE_ERROR_NAMES = {"DeadlockDetectedError", "SerializationError"}
class TariffTrafficWorker:
@@ -97,6 +103,36 @@ class TariffTrafficWorker:
button = InlineKeyboardButton(text=_(fallback_key), callback_data="tariff_topup:list")
return InlineKeyboardMarkup(inline_keyboard=[[button]])
async def _send_traffic_warning_email(
self,
session: AsyncSession,
*,
user_id: int,
subject_key: str,
message_text: str,
kind: str,
) -> None:
try:
user = await user_dal.get_user_by_id(session, user_id)
except Exception:
logging.exception("TariffTrafficWorker: failed to load user %s for email", user_id)
return
if not user:
return
await send_user_notification_email(
settings=self.settings,
i18n=self.i18n,
user=user,
subject_key=subject_key,
message_text=message_text,
dashboard_url=subscription_mini_app_topup_url(self.settings, kind),
cta_label_key=(
"email_traffic_warning_premium_cta"
if kind == "premium"
else "email_traffic_warning_regular_cta"
),
)
async def run(self) -> None:
if not self.settings.tariffs_config:
return
@@ -111,12 +147,14 @@ class TariffTrafficWorker:
logging.info("TariffTrafficWorker tick skipped: Redis lock is held")
else:
started = time.monotonic()
async with self.session_factory() as session:
await self.traffic_period_tick(session)
await session.commit()
async with self.session_factory() as session:
await self.legacy_throttle_recovery_tick(session)
await session.commit()
await self._run_db_tick_with_retry(
"traffic_period",
self.traffic_period_tick,
)
await self._run_db_tick_with_retry(
"legacy_throttle_recovery",
self.legacy_throttle_recovery_tick,
)
logging.info(
"metric worker_tick_duration_seconds=%.3f worker=tariff",
time.monotonic() - started,
@@ -134,16 +172,80 @@ class TariffTrafficWorker:
def stop(self) -> None:
self._stopped.set()
async def _run_db_tick_with_retry(
self,
tick_name: str,
tick: Callable[[AsyncSession], Awaitable[None]],
) -> None:
for attempt in range(1, TARIFF_WORKER_DB_RETRY_ATTEMPTS + 1):
async with self.session_factory() as session:
try:
await acquire_subscription_background_sync_lock(session)
await tick(session)
await session.commit()
return
except Exception as exc:
await session.rollback()
if (
attempt < TARIFF_WORKER_DB_RETRY_ATTEMPTS
and self._is_retryable_db_exception(exc)
):
delay = TARIFF_WORKER_DB_RETRY_BASE_SLEEP_SECONDS * attempt
logging.warning(
"TariffTrafficWorker %s retrying after database concurrency "
"error, attempt %s/%s: %s",
tick_name,
attempt + 1,
TARIFF_WORKER_DB_RETRY_ATTEMPTS,
exc,
)
await asyncio.sleep(delay)
continue
raise
@staticmethod
def _is_retryable_db_exception(exc: BaseException) -> bool:
pending: list[BaseException] = [exc]
seen: set[int] = set()
while pending:
current = pending.pop()
current_id = id(current)
if current_id in seen:
continue
seen.add(current_id)
sqlstate = getattr(current, "sqlstate", None) or getattr(current, "pgcode", None)
if sqlstate in POSTGRES_RETRYABLE_SQLSTATES:
return True
error_name = type(current).__name__
message = str(current).lower()
if (
error_name in POSTGRES_RETRYABLE_ERROR_NAMES
or "deadlock detected" in message
or "could not serialize access" in message
):
return True
for attr in ("orig", "__cause__", "__context__"):
nested = getattr(current, attr, None)
if isinstance(nested, BaseException):
pending.append(nested)
return False
async def traffic_period_tick(self, session: AsyncSession) -> None:
now = datetime.now(timezone.utc)
self._premium_node_usage_tick_cache = {}
warning_period_start = month_start(now)
result = await session.execute(
select(Subscription).where(
select(Subscription)
.where(
Subscription.is_active == True,
Subscription.end_date > now,
Subscription.tariff_key.is_not(None),
)
.order_by(Subscription.subscription_id.asc())
)
subs = list(result.scalars().all())
if not subs:
@@ -454,6 +556,8 @@ class TariffTrafficWorker:
return
ratio = used_val / limit_val
levels = list(getattr(self.settings, "tariff_traffic_warning_levels", [85, 90, 95]))
if 100 not in levels:
levels.append(100)
for level in levels:
threshold = level / 100
if ratio < threshold:
@@ -474,30 +578,32 @@ class TariffTrafficWorker:
level=level,
traffic_limit_bytes=limit_val if tariff.billing_model == "traffic" else None,
)
user_lang = await self._user_lang(session, sub.user_id)
_ = (
(lambda k, **kw: self.i18n.gettext(user_lang, k, **kw))
if self.i18n
else (lambda k, **kw: k)
)
left_pct = max(0, 100 - level)
tariff_name = hd.quote(str(tariff.name(user_lang)))
usage = self._usage_placeholders(used_val, limit_val)
if level < 100:
text = _(
"traffic_warning_regular_almost",
tariff_name=tariff_name,
left_pct=left_pct,
**usage,
)
subject_key = "email_traffic_warning_regular_almost_subject"
else:
text = _(
"traffic_warning_regular_depleted",
tariff_name=tariff_name,
**usage,
)
subject_key = "email_traffic_warning_regular_depleted_subject"
if self.bot:
try:
user_lang = await self._user_lang(session, sub.user_id)
_ = (
(lambda k, **kw: self.i18n.gettext(user_lang, k, **kw))
if self.i18n
else (lambda k, **kw: k)
)
left_pct = max(0, 100 - level)
tariff_name = hd.quote(str(tariff.name(user_lang)))
usage = self._usage_placeholders(used_val, limit_val)
if level < 100:
text = _(
"traffic_warning_regular_almost",
tariff_name=tariff_name,
left_pct=left_pct,
**usage,
)
else:
text = _(
"traffic_warning_regular_depleted",
tariff_name=tariff_name,
**usage,
)
markup = self._traffic_topup_markup(user_lang, "regular")
await self.bot.send_message(
sub.user_id,
@@ -507,6 +613,13 @@ class TariffTrafficWorker:
)
except Exception:
logging.exception("Failed to send traffic warning to user %s", sub.user_id)
await self._send_traffic_warning_email(
session,
user_id=sub.user_id,
subject_key=subject_key,
message_text=text,
kind="regular",
)
if ratio >= 1.0 and not sub.is_throttled:
logging.info(
"Tariff traffic limit reached for user %s subscription %s. "
@@ -930,31 +1043,31 @@ class TariffTrafficWorker:
level=PREMIUM_WARNING_DEPLETED_LEVEL,
traffic_limit_bytes=None,
)
user_lang = await self._user_lang(session, sub.user_id)
_ = (
(lambda k, **kw: self.i18n.gettext(user_lang, k, **kw))
if self.i18n
else (lambda k, **kw: k)
)
access = await self.subscription_service.premium_access_for_tariff(tariff)
labels = access.get("node_labels") or access.get("squad_labels") or []
if labels:
visible = [hd.quote(str(x)) for x in labels[:8]]
servers = "\n".join(f"{label}" for label in visible)
if len(labels) > len(visible):
more = len(labels) - len(visible)
servers += "\n" + _("traffic_warning_premium_servers_more", count=more)
else:
servers = _("traffic_warning_premium_generic_servers")
usage = self._usage_placeholders(used_val, limit_val)
text = _(
"traffic_warning_premium_depleted",
tariff_name=hd.quote(str(tariff.name(user_lang))),
servers=servers,
**usage,
)
if self.bot:
try:
user_lang = await self._user_lang(session, sub.user_id)
_ = (
(lambda k, **kw: self.i18n.gettext(user_lang, k, **kw))
if self.i18n
else (lambda k, **kw: k)
)
access = await self.subscription_service.premium_access_for_tariff(tariff)
labels = access.get("node_labels") or access.get("squad_labels") or []
if labels:
visible = [hd.quote(str(x)) for x in labels[:8]]
servers = "\n".join(f"{label}" for label in visible)
if len(labels) > len(visible):
more = len(labels) - len(visible)
servers += "\n" + _("traffic_warning_premium_servers_more", count=more)
else:
servers = _("traffic_warning_premium_generic_servers")
usage = self._usage_placeholders(used_val, limit_val)
text = _(
"traffic_warning_premium_depleted",
tariff_name=hd.quote(str(tariff.name(user_lang))),
servers=servers,
**usage,
)
markup = self._traffic_topup_markup(user_lang, "premium")
await self.bot.send_message(
sub.user_id,
@@ -966,6 +1079,13 @@ class TariffTrafficWorker:
logging.exception(
"Failed to send premium traffic depleted warning to user %s", sub.user_id
)
await self._send_traffic_warning_email(
session,
user_id=sub.user_id,
subject_key="email_traffic_warning_premium_depleted_subject",
message_text=text,
kind="premium",
)
return
for level in levels:
@@ -989,43 +1109,51 @@ class TariffTrafficWorker:
level=storage_level,
traffic_limit_bytes=None,
)
if not self.bot:
continue
try:
user_lang = await self._user_lang(session, sub.user_id)
_ = (
(lambda k, **kw: self.i18n.gettext(user_lang, k, **kw))
if self.i18n
else (lambda k, **kw: k)
)
access = await self.subscription_service.premium_access_for_tariff(tariff)
labels = access.get("node_labels") or access.get("squad_labels") or []
if labels:
visible = [hd.quote(str(x)) for x in labels[:8]]
servers = "\n".join(f"{label}" for label in visible)
if len(labels) > len(visible):
more = len(labels) - len(visible)
servers += "\n" + _("traffic_warning_premium_servers_more", count=more)
else:
servers = _("traffic_warning_premium_generic_servers")
left_pct = max(0, 100 - int(level))
usage = self._usage_placeholders(used_val, limit_val)
text = _(
"traffic_warning_premium_almost",
tariff_name=hd.quote(str(tariff.name(user_lang))),
left_pct=left_pct,
servers=servers,
**usage,
)
markup = self._traffic_topup_markup(user_lang, "premium")
await self.bot.send_message(
sub.user_id,
text,
reply_markup=markup,
parse_mode="HTML",
)
except Exception:
logging.exception("Failed to send premium traffic warning to user %s", sub.user_id)
user_lang = await self._user_lang(session, sub.user_id)
_ = (
(lambda k, **kw: self.i18n.gettext(user_lang, k, **kw))
if self.i18n
else (lambda k, **kw: k)
)
access = await self.subscription_service.premium_access_for_tariff(tariff)
labels = access.get("node_labels") or access.get("squad_labels") or []
if labels:
visible = [hd.quote(str(x)) for x in labels[:8]]
servers = "\n".join(f"{label}" for label in visible)
if len(labels) > len(visible):
more = len(labels) - len(visible)
servers += "\n" + _("traffic_warning_premium_servers_more", count=more)
else:
servers = _("traffic_warning_premium_generic_servers")
left_pct = max(0, 100 - int(level))
usage = self._usage_placeholders(used_val, limit_val)
text = _(
"traffic_warning_premium_almost",
tariff_name=hd.quote(str(tariff.name(user_lang))),
left_pct=left_pct,
servers=servers,
**usage,
)
if self.bot:
try:
markup = self._traffic_topup_markup(user_lang, "premium")
await self.bot.send_message(
sub.user_id,
text,
reply_markup=markup,
parse_mode="HTML",
)
except Exception:
logging.exception(
"Failed to send premium traffic warning to user %s", sub.user_id
)
await self._send_traffic_warning_email(
session,
user_id=sub.user_id,
subject_key="email_traffic_warning_premium_almost_subject",
message_text=text,
kind="premium",
)
async def _premium_node_uuids_for_tariff(self, tariff) -> list[str]:
cache_key = tuple(sorted(tariff.premium_squad_uuids or []))
@@ -1159,10 +1287,12 @@ class TariffTrafficWorker:
from Internal Squads.
"""
result = await session.execute(
select(Subscription).where(
select(Subscription)
.where(
Subscription.is_active == True,
Subscription.is_throttled == True,
)
.order_by(Subscription.subscription_id.asc())
)
for sub in result.scalars().all():
try:
@@ -0,0 +1,240 @@
import logging
from datetime import datetime, timezone
from typing import Any, Optional
from aiogram import Bot
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, WebAppInfo
from sqlalchemy.ext.asyncio import AsyncSession
from bot.middlewares.i18n import JsonI18n
from config.settings import Settings
from db.dal import user_dal
from db.models import User
logger = logging.getLogger(__name__)
TELEGRAM_NOTIFICATIONS_UNKNOWN = "unknown"
TELEGRAM_NOTIFICATIONS_ENABLED = "enabled"
TELEGRAM_NOTIFICATIONS_NEEDS_START = "needs_start"
TELEGRAM_NOTIFICATIONS_BLOCKED = "blocked"
TELEGRAM_NOTIFICATION_STATUSES = {
TELEGRAM_NOTIFICATIONS_UNKNOWN,
TELEGRAM_NOTIFICATIONS_ENABLED,
TELEGRAM_NOTIFICATIONS_NEEDS_START,
TELEGRAM_NOTIFICATIONS_BLOCKED,
}
def normalize_telegram_notification_status(value: Optional[str]) -> str:
status = str(value or "").strip().lower()
return status if status in TELEGRAM_NOTIFICATION_STATUSES else TELEGRAM_NOTIFICATIONS_UNKNOWN
def telegram_notifications_enabled(user: Optional[User]) -> bool:
return (
bool(getattr(user, "telegram_id", None))
and normalize_telegram_notification_status(
getattr(user, "telegram_notifications_status", None)
)
== TELEGRAM_NOTIFICATIONS_ENABLED
)
def telegram_notifications_need_prompt(user: Optional[User]) -> bool:
status = normalize_telegram_notification_status(
getattr(user, "telegram_notifications_status", None)
)
return bool(getattr(user, "telegram_id", None)) and status in {
TELEGRAM_NOTIFICATIONS_NEEDS_START,
TELEGRAM_NOTIFICATIONS_BLOCKED,
}
def telegram_notifications_start_link(bot_username: Optional[str]) -> Optional[str]:
username = str(bot_username or "").strip().lstrip("@")
if not username or username == "your_bot_username":
return None
return f"https://t.me/{username}?start=notifications"
def telegram_notification_status_from_error(exc: Exception) -> Optional[str]:
if isinstance(exc, TelegramForbiddenError):
return TELEGRAM_NOTIFICATIONS_BLOCKED
if not isinstance(exc, TelegramBadRequest):
return None
message = str(exc).lower()
if any(
token in message
for token in (
"bot was blocked",
"user is deactivated",
"forbidden",
)
):
return TELEGRAM_NOTIFICATIONS_BLOCKED
if any(
token in message
for token in (
"chat not found",
"bot can't initiate conversation",
"bot can't initiate",
"user not found",
)
):
return TELEGRAM_NOTIFICATIONS_NEEDS_START
return None
async def mark_telegram_notifications_status(
session: AsyncSession,
user_id: int,
status: str,
*,
telegram_id: Optional[int] = None,
checked_at: Optional[datetime] = None,
) -> Optional[User]:
normalized = normalize_telegram_notification_status(status)
now = checked_at or datetime.now(timezone.utc)
update_data: dict[str, Any] = {
"telegram_notifications_status": normalized,
"telegram_notifications_checked_at": now,
}
if telegram_id:
update_data["telegram_id"] = int(telegram_id)
if normalized == TELEGRAM_NOTIFICATIONS_ENABLED:
update_data["telegram_notifications_enabled_at"] = now
update_data["telegram_notifications_blocked_at"] = None
elif normalized == TELEGRAM_NOTIFICATIONS_BLOCKED:
update_data["telegram_notifications_blocked_at"] = now
return await user_dal.update_user(session, user_id, update_data)
async def mark_telegram_notifications_enabled_for_telegram_user(
session: AsyncSession,
telegram_id: int,
) -> Optional[User]:
db_user = await user_dal.get_user_by_telegram_id(session, telegram_id)
if not db_user:
db_user = await user_dal.get_user_by_id(session, telegram_id)
if not db_user:
return None
return await mark_telegram_notifications_status(
session,
int(db_user.user_id),
TELEGRAM_NOTIFICATIONS_ENABLED,
telegram_id=telegram_id,
)
def _translate(
i18n: Optional[JsonI18n],
language: str,
key: str,
fallback: str,
**kwargs: Any,
) -> str:
if not i18n:
return fallback.format(**kwargs) if kwargs else fallback
return i18n.gettext(language, key, **kwargs) or fallback
def _probe_keyboard(
settings: Settings,
i18n: Optional[JsonI18n],
language: str,
) -> Optional[InlineKeyboardMarkup]:
app_url = str(getattr(settings, "SUBSCRIPTION_MINI_APP_URL", "") or "").strip()
if not app_url:
return None
text = _translate(
i18n,
language,
"telegram_notifications_open_app_button",
"Open app",
)
return InlineKeyboardMarkup(
inline_keyboard=[[InlineKeyboardButton(text=text, web_app=WebAppInfo(url=app_url))]]
)
async def probe_telegram_notifications(
*,
session: AsyncSession,
bot: Bot,
settings: Settings,
i18n: Optional[JsonI18n],
user: User,
bot_username: Optional[str] = None,
force: bool = False,
) -> dict[str, Any]:
telegram_id = getattr(user, "telegram_id", None)
if not telegram_id:
return {
"ok": False,
"status": TELEGRAM_NOTIFICATIONS_UNKNOWN,
"start_link": telegram_notifications_start_link(bot_username),
}
current_status = normalize_telegram_notification_status(
getattr(user, "telegram_notifications_status", None)
)
if current_status == TELEGRAM_NOTIFICATIONS_ENABLED and not force:
return {
"ok": True,
"status": TELEGRAM_NOTIFICATIONS_ENABLED,
"start_link": telegram_notifications_start_link(bot_username),
}
language = str(getattr(user, "language_code", "") or settings.DEFAULT_LANGUAGE)
text = _translate(
i18n,
language,
"telegram_notifications_enabled_message",
"Telegram notifications are enabled.",
)
try:
await bot.send_message(
int(telegram_id),
text,
reply_markup=_probe_keyboard(settings, i18n, language),
disable_web_page_preview=True,
)
except Exception as exc:
status = telegram_notification_status_from_error(exc)
if status:
await mark_telegram_notifications_status(session, int(user.user_id), status)
return {
"ok": False,
"status": status,
"start_link": telegram_notifications_start_link(bot_username),
}
logger.warning(
"Telegram notification probe failed for user %s / telegram %s: %s",
user.user_id,
telegram_id,
exc,
)
await mark_telegram_notifications_status(
session,
int(user.user_id),
TELEGRAM_NOTIFICATIONS_UNKNOWN,
)
return {
"ok": False,
"status": TELEGRAM_NOTIFICATIONS_UNKNOWN,
"start_link": telegram_notifications_start_link(bot_username),
}
await mark_telegram_notifications_status(
session,
int(user.user_id),
TELEGRAM_NOTIFICATIONS_ENABLED,
telegram_id=int(telegram_id),
)
return {
"ok": True,
"status": TELEGRAM_NOTIFICATIONS_ENABLED,
"start_link": telegram_notifications_start_link(bot_username),
}
@@ -0,0 +1,84 @@
import logging
from typing import Any, Optional
from bot.middlewares.i18n import JsonI18n
from bot.services.email_auth_service import EmailAuthService
from bot.services.email_templates import render_user_notification
from config.settings import Settings
def _translate(
i18n: Optional[JsonI18n],
language: str,
key: Optional[str],
fallback: str = "",
**kwargs: Any,
) -> str:
if not key:
return fallback
if not i18n:
return fallback or key
text = i18n.gettext(language, key, **kwargs)
return fallback if text == key and fallback else text
async def send_user_notification_email(
*,
settings: Settings,
i18n: Optional[JsonI18n],
user: Any,
subject_key: str,
message_text: str,
dashboard_url: Optional[str] = None,
cta_label_key: str = "email_user_notification_cta",
subject_kwargs: Optional[dict[str, Any]] = None,
heading_key: Optional[str] = None,
intro_key: Optional[str] = None,
) -> bool:
if not getattr(settings, "email_auth_configured", False):
return False
recipient = str(getattr(user, "email", "") or "").strip()
if not recipient:
return False
language = (
str(getattr(user, "language_code", "") or "").strip()
or getattr(settings, "DEFAULT_LANGUAGE", "ru")
or "ru"
)
kwargs = subject_kwargs or {}
subject = _translate(i18n, language, subject_key, subject_key, **kwargs)
heading = _translate(i18n, language, heading_key, subject, **kwargs)
intro = _translate(
i18n,
language,
intro_key or "email_user_notification_intro",
"Notification from your account.",
)
cta_label = _translate(
i18n,
language,
cta_label_key or "email_user_notification_cta",
"Open dashboard",
)
try:
content = render_user_notification(
settings,
language_code=language,
subject=subject,
heading=heading,
intro=intro,
message_text=message_text,
dashboard_url=dashboard_url,
cta_label=cta_label,
i18n=i18n,
)
await EmailAuthService(settings, i18n).send_rendered_email(
email=recipient,
content=content,
)
return True
except Exception:
logging.exception("Failed to send user notification email to %s.", recipient)
return False
+5 -4
View File
@@ -214,10 +214,10 @@ class Settings(BaseSettings):
MONTH_6_ENABLED: bool = Field(default=True, alias="6_MONTHS_ENABLED")
MONTH_12_ENABLED: bool = Field(default=True, alias="12_MONTHS_ENABLED")
RUB_PRICE_1_MONTH: Optional[int] = Field(default=None)
RUB_PRICE_3_MONTHS: Optional[int] = Field(default=None)
RUB_PRICE_6_MONTHS: Optional[int] = Field(default=None)
RUB_PRICE_12_MONTHS: Optional[int] = Field(default=None)
RUB_PRICE_1_MONTH: Optional[int] = Field(default=200)
RUB_PRICE_3_MONTHS: Optional[int] = Field(default=600)
RUB_PRICE_6_MONTHS: Optional[int] = Field(default=1200)
RUB_PRICE_12_MONTHS: Optional[int] = Field(default=2400)
STARS_PRICE_1_MONTH: Optional[int] = Field(default=None)
STARS_PRICE_3_MONTHS: Optional[int] = Field(default=None)
@@ -240,6 +240,7 @@ class Settings(BaseSettings):
)
SUBSCRIPTION_NOTIFICATIONS_ENABLED: bool = Field(default=True)
SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED: bool = Field(default=True)
SUBSCRIPTION_NOTIFY_ON_EXPIRE: bool = Field(default=True)
SUBSCRIPTION_NOTIFY_AFTER_EXPIRE: bool = Field(default=True)
SUBSCRIPTION_NOTIFY_DAYS_BEFORE: int = Field(default=3)
+35
View File
@@ -95,6 +95,8 @@ class Tariff(BaseModel):
monthly_gb: Optional[float] = None
prices_rub: Dict[str, float] = Field(default_factory=dict)
prices_stars: Dict[str, float] = Field(default_factory=dict)
referral_bonus_days_inviter: Dict[str, int] = Field(default_factory=dict)
referral_bonus_days_referee: Dict[str, int] = Field(default_factory=dict)
enabled_periods: List[int] = Field(default_factory=list)
topup_packages: Optional[PackageSet] = None
@@ -129,6 +131,14 @@ class Tariff(BaseModel):
if self.billing_model == "period":
if self.monthly_gb is None or self.monthly_gb < 0:
raise ValueError(f"period tariff {self.key}: monthly_gb must be >= 0")
self.referral_bonus_days_inviter = self._normalize_referral_bonus_map(
self.referral_bonus_days_inviter,
"referral_bonus_days_inviter",
)
self.referral_bonus_days_referee = self._normalize_referral_bonus_map(
self.referral_bonus_days_referee,
"referral_bonus_days_referee",
)
if not self.enabled_periods:
raise ValueError(f"period tariff {self.key}: enabled_periods is required")
for months in self.enabled_periods:
@@ -152,6 +162,23 @@ class Tariff(BaseModel):
)
return self
def _normalize_referral_bonus_map(
self, values: Dict[str, int], field_name: str
) -> Dict[str, int]:
normalized: Dict[str, int] = {}
for period, days in (values or {}).items():
try:
months = int(float(str(period).strip()))
bonus_days = int(float(days))
except (TypeError, ValueError):
raise ValueError(f"tariff {self.key}: {field_name} contains invalid entry")
if months <= 0:
raise ValueError(f"tariff {self.key}: {field_name} periods must be positive")
if bonus_days < 0:
raise ValueError(f"tariff {self.key}: {field_name} days must be >= 0")
normalized[str(months)] = bonus_days
return normalized
def name(self, lang: str, fallback: str = "ru") -> str:
return self.names.get(lang) or self.names.get(fallback) or self.key
@@ -173,6 +200,14 @@ class Tariff(BaseModel):
value = source.get(str(months))
return float(value) if value is not None else None
def referral_inviter_bonus_days(self, months: int) -> Optional[int]:
value = self.referral_bonus_days_inviter.get(str(int(months)))
return int(value) if value is not None else None
def referral_referee_bonus_days(self, months: int) -> Optional[int]:
value = self.referral_bonus_days_referee.get(str(int(months)))
return int(value) if value is not None else None
def min_period_price_rub(self) -> Optional[float]:
prices = [
float(self.prices_rub[str(months)])
+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
if not target.channel_subscription_verified_for and source.channel_subscription_verified_for:
target.channel_subscription_verified_for = source.channel_subscription_verified_for
source_tg_status = str(getattr(source, "telegram_notifications_status", None) or "unknown")
target_tg_status = str(getattr(target, "telegram_notifications_status", None) or "unknown")
if source_tg_status == "enabled" and target_tg_status != "enabled":
target.telegram_notifications_status = source_tg_status
elif target_tg_status == "unknown" and source_tg_status != "unknown":
target.telegram_notifications_status = source_tg_status
if getattr(source, "telegram_notifications_checked_at", None) and (
not getattr(target, "telegram_notifications_checked_at", None)
or source.telegram_notifications_checked_at > target.telegram_notifications_checked_at
):
target.telegram_notifications_checked_at = source.telegram_notifications_checked_at
if getattr(source, "telegram_notifications_enabled_at", None) and (
not getattr(target, "telegram_notifications_enabled_at", None)
or source.telegram_notifications_enabled_at > target.telegram_notifications_enabled_at
):
target.telegram_notifications_enabled_at = source.telegram_notifications_enabled_at
if getattr(source, "telegram_notifications_blocked_at", None) and (
not getattr(target, "telegram_notifications_blocked_at", None)
or source.telegram_notifications_blocked_at > target.telegram_notifications_blocked_at
):
target.telegram_notifications_blocked_at = source.telegram_notifications_blocked_at
if source.lifetime_used_traffic_bytes is not None:
target.lifetime_used_traffic_bytes = (
target.lifetime_used_traffic_bytes or 0
+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] = [
Migration(
id="0001_add_channel_subscription_fields",
@@ -1214,6 +1228,11 @@ MIGRATIONS: List[Migration] = [
description="Track sent subscription notification stages",
upgrade=_migration_0031_add_subscription_notifications,
),
Migration(
id="0032_add_telegram_notification_status",
description="Track whether the bot can message Telegram-linked users",
upgrade=_migration_0032_add_telegram_notification_status,
),
]
+4
View File
@@ -33,6 +33,10 @@ class User(Base):
password_set_at = Column(DateTime(timezone=True), nullable=True)
telegram_id = Column(BigInteger, nullable=True, unique=True, index=True)
telegram_photo_url = Column(Text, nullable=True)
telegram_notifications_status = Column(String(32), nullable=False, default="unknown")
telegram_notifications_checked_at = Column(DateTime(timezone=True), nullable=True)
telegram_notifications_enabled_at = Column(DateTime(timezone=True), nullable=True)
telegram_notifications_blocked_at = Column(DateTime(timezone=True), nullable=True)
first_name = Column(String, nullable=True)
last_name = Column(String, nullable=True)
language_code = Column(String, default="ru")
+3 -1
View File
@@ -40,8 +40,10 @@
{ "count": 1, "price": 2500 }
]
},
"prices_rub": { "1": 150, "3": 400, "6": 750, "12": 1400 },
"prices_rub": { "1": 200, "3": 600, "6": 1200, "12": 2400 },
"prices_stars": { "1": 0, "3": 0, "6": 0, "12": 0 },
"referral_bonus_days_inviter": { "1": 3, "3": 7, "6": 15, "12": 30 },
"referral_bonus_days_referee": { "1": 1, "3": 3, "6": 7, "12": 15 },
"enabled_periods": [1, 3, 6, 12],
"enabled": true
},
+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/00-startup-banner.sh /docker-entrypoint.d/00-startup-banner.sh
COPY deploy/docker/frontend/robots.txt /usr/share/nginx/html/robots.txt
COPY backend/bot/app/web/templates/subscription_webapp.html /usr/share/nginx/html/index.html
COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp.css /usr/share/nginx/html/subscription_webapp.css
COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp.*.css /usr/share/nginx/html/
+11
View File
@@ -23,6 +23,13 @@ server {
return 200 "ok\n";
}
location = /robots.txt {
expires 1h;
add_header Cache-Control "public, max-age=3600";
add_header X-Robots-Tag "noindex, nofollow, noarchive";
try_files /robots.txt =404;
}
location /api/ {
proxy_pass http://backend:8081;
proxy_http_version 1.1;
@@ -89,23 +96,27 @@ server {
location ~* ^/subscription_webapp(_admin)?\.(min\.)?[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]\.(css|js)$ {
expires off;
add_header Cache-Control "public, max-age=31536000, immutable";
add_header X-Robots-Tag "noindex, nofollow, noarchive";
try_files $uri =404;
}
location ~* ^/subscription_webapp(_admin)?\.(css|js)$ {
expires off;
add_header Cache-Control "no-cache";
add_header X-Robots-Tag "noindex, nofollow, noarchive";
try_files $uri =404;
}
location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|webp)$ {
expires 30d;
add_header Cache-Control "public";
add_header X-Robots-Tag "noindex, nofollow, noarchive";
try_files $uri =404;
}
location / {
add_header Cache-Control "no-cache";
add_header X-Robots-Tag "noindex, nofollow, noarchive";
try_files $uri /index.html;
}
}
+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
**/data/
nginx/ssl/*
!nginx/ssl/README.md
+2
View File
@@ -4,6 +4,8 @@
Эта папка хранит только рабочие compose-примеры и конфиги. Подробное описание не дублируется здесь, чтобы сайт документации и навигация из README использовали один источник.
Файлы приложения (`/app/data`: тарифы, темы, логотипы) монтируются из папки `data` рядом с выбранным `docker-compose.yml`. Для кастомных тем создайте `data/themes`.
| Папка | Документация |
| --- | --- |
| `caddy` | [Развертывание с Caddy](../../docs/getting-started/deployment.md#caddy-рекомендуемый-вариант) |
+3 -5
View File
@@ -48,7 +48,7 @@ services:
environment:
<<: *app_environment
volumes:
- shop-data:/app/data
- ./data:/app/data
networks:
- remnawave-shop
depends_on:
@@ -66,7 +66,7 @@ services:
WEBAPP_ENABLED: ${WEBAPP_ENABLED:-true}
TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.1,::1,172.16.0.0/12}
volumes:
- shop-data:/app/data
- ./data:/app/data
- ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:${COMPOSE_RESTORE_MODE:-rw}
networks:
- remnawave-shop
@@ -91,7 +91,7 @@ services:
environment:
<<: *app_environment
volumes:
- shop-data:/app/data
- ./data:/app/data
- ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:ro
networks:
- remnawave-shop
@@ -149,8 +149,6 @@ volumes:
name: remnawave-minishop-db-data
redis-data:
name: remnawave-minishop-redis-data
shop-data:
name: remnawave-minishop-shop-data
caddy-data:
name: remnawave-minishop-caddy-data
caddy-config:
+3 -5
View File
@@ -48,7 +48,7 @@ services:
environment:
<<: *app_environment
volumes:
- shop-data:/app/data
- ./data:/app/data
networks:
- remnawave-shop
depends_on:
@@ -66,7 +66,7 @@ services:
WEBAPP_ENABLED: ${WEBAPP_ENABLED:-true}
TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.1,::1,172.16.0.0/12}
volumes:
- shop-data:/app/data
- ./data:/app/data
- ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:${COMPOSE_RESTORE_MODE:-rw}
networks:
- remnawave-shop
@@ -91,7 +91,7 @@ services:
environment:
<<: *app_environment
volumes:
- shop-data:/app/data
- ./data:/app/data
- ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:ro
networks:
- remnawave-shop
@@ -144,5 +144,3 @@ volumes:
name: remnawave-minishop-db-data
redis-data:
name: remnawave-minishop-redis-data
shop-data:
name: remnawave-minishop-shop-data
+3 -5
View File
@@ -48,7 +48,7 @@ services:
environment:
<<: *app_environment
volumes:
- shop-data:/app/data
- ./data:/app/data
networks:
- remnawave-shop
depends_on:
@@ -66,7 +66,7 @@ services:
WEBAPP_ENABLED: ${WEBAPP_ENABLED:-true}
TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.1,::1,172.16.0.0/12}
volumes:
- shop-data:/app/data
- ./data:/app/data
- ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:${COMPOSE_RESTORE_MODE:-rw}
networks:
- remnawave-shop
@@ -91,7 +91,7 @@ services:
environment:
<<: *app_environment
volumes:
- shop-data:/app/data
- ./data:/app/data
- ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:ro
networks:
- remnawave-shop
@@ -148,5 +148,3 @@ volumes:
name: remnawave-minishop-db-data
redis-data:
name: remnawave-minishop-redis-data
shop-data:
name: remnawave-minishop-shop-data
+3 -5
View File
@@ -48,7 +48,7 @@ services:
environment:
<<: *app_environment
volumes:
- shop-data:/app/data
- ./data:/app/data
networks:
- remnawave-shop
depends_on:
@@ -68,7 +68,7 @@ services:
ports:
- "${WEB_SERVER_BIND:-0.0.0.0:8080}:8080"
volumes:
- shop-data:/app/data
- ./data:/app/data
- ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:${COMPOSE_RESTORE_MODE:-rw}
networks:
- remnawave-shop
@@ -93,7 +93,7 @@ services:
environment:
<<: *app_environment
volumes:
- shop-data:/app/data
- ./data:/app/data
- ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:ro
networks:
- remnawave-shop
@@ -132,5 +132,3 @@ volumes:
name: remnawave-minishop-db-data
redis-data:
name: remnawave-minishop-redis-data
shop-data:
name: remnawave-minishop-shop-data
+1
View File
@@ -79,6 +79,7 @@ export default defineConfig({
{ label: 'Основные', slug: 'features/core' },
{ label: 'Платежи', slug: 'features/payments' },
{ label: 'Подписки', slug: 'features/subscriptions' },
{ label: 'Уведомления', slug: 'features/notifications' },
{ label: 'Тарифы', slug: 'features/tariffs' },
{ label: 'Веб-приложение / Mini App', slug: 'features/web-app' },
{ label: 'Telegram-авторизация', slug: 'features/telegram-auth' },
+11 -22
View File
@@ -1,22 +1,11 @@
# Cloudflare Pages rewrites for the static docs demo SPA.
# Keep these scoped to app routes so runtime JS/CSS/assets are served directly.
/demo/runtime /demo/runtime/app.html 200
/demo/runtime/ /demo/runtime/app.html 200
/demo/runtime/home /demo/runtime/app.html 200
/demo/runtime/home/* /demo/runtime/app.html 200
/demo/runtime/install /demo/runtime/app.html 200
/demo/runtime/install/* /demo/runtime/app.html 200
/demo/runtime/trial /demo/runtime/app.html 200
/demo/runtime/trial/* /demo/runtime/app.html 200
/demo/runtime/invite /demo/runtime/app.html 200
/demo/runtime/invite/* /demo/runtime/app.html 200
/demo/runtime/devices /demo/runtime/app.html 200
/demo/runtime/devices/* /demo/runtime/app.html 200
/demo/runtime/support /demo/runtime/app.html 200
/demo/runtime/support/* /demo/runtime/app.html 200
/demo/runtime/settings /demo/runtime/app.html 200
/demo/runtime/settings/* /demo/runtime/app.html 200
/demo/runtime/login /demo/runtime/app.html 200
/demo/runtime/login/* /demo/runtime/app.html 200
/demo/runtime/admin /demo/runtime/app.html 200
/demo/runtime/admin/* /demo/runtime/app.html 200
# Cloudflare Pages rewrites for non-materialized static docs demo runtime routes.
# Use the directory entrypoint so Pages clean URLs do not loop between app.html and app.
/demo/runtime/home/* /demo/runtime/app/ 200
/demo/runtime/install/* /demo/runtime/app/ 200
/demo/runtime/trial/* /demo/runtime/app/ 200
/demo/runtime/invite/* /demo/runtime/app/ 200
/demo/runtime/devices/* /demo/runtime/app/ 200
/demo/runtime/support/* /demo/runtime/app/ 200
/demo/runtime/settings/* /demo/runtime/app/ 200
/demo/runtime/login/* /demo/runtime/app/ 200
/demo/runtime/admin/* /demo/runtime/app/ 200
+54 -32
View File
@@ -2,12 +2,14 @@ const frame = document.getElementById("demo-frame");
const runtimeBase = "/demo/runtime";
const demoBase = "/demo";
const defaultMock = "tariffs";
const publicRouteAliases = new Map([["/app", "/home"]]);
const stateMocks = new Set([
"tariffs",
"depleted",
"no-subscription",
"trial",
"devices",
"notifications",
"auth",
]);
const routeMocks = new Set([...stateMocks, "guides", "install"]);
@@ -41,7 +43,8 @@ const routeFromPublicPath = () => {
const publicRoute = pathname.slice(demoBase.length);
if (!publicRoute || publicRoute.toLowerCase().startsWith("/runtime"))
return "";
return normalizePath(publicRoute);
const normalized = normalizePath(publicRoute);
return publicRouteAliases.get(normalized.toLowerCase()) || normalized;
};
const routeFromParams = () => {
@@ -88,36 +91,6 @@ const mockForRoute = (route) => {
const initialMock = params.has("mock")
? normalizeRouteMock(params.get("mock"))
: normalizeRouteMock(mockForRoute(initialRoute) || defaultMock);
if (initialMock === "trial" && initialRoute === "/trial") {
initialRoute = "/home";
const normalizedUrl = new URL(window.location.href);
normalizedUrl.pathname = `${demoBase}/home`;
window.history.replaceState(
null,
"",
`${normalizedUrl.pathname}${normalizedUrl.search}${normalizedUrl.hash}`,
);
}
params.set("mock", initialMock);
params.delete("path");
params.delete("screen");
params.delete("admin_section");
params.set("path", initialRoute);
frame.src = `${runtimeBase}/app.html?${params.toString()}${window.location.hash || ""}`;
const routeFromRuntimeUrl = (url) => {
if (url.origin !== window.location.origin) return "";
if (!url.pathname.toLowerCase().startsWith(runtimeBase.toLowerCase()))
return "";
const runtimePath = normalizePath(
url.pathname.slice(runtimeBase.length) || "/home",
);
if (runtimePath === "/app.html") {
return normalizePath(url.searchParams.get("path") || "/home");
}
return runtimePath;
};
const materializedRouteFromRuntime = (route) => {
const normalized = normalizePath(route);
if (/^\/admin\/users\/-?\d+$/i.test(normalized)) return "/admin/users";
@@ -131,6 +104,55 @@ const materializedRouteFromRuntime = (route) => {
const publicPathFromRoute = (route) =>
`${demoBase}${materializedRouteFromRuntime(route)}`;
const canonicalizeInitialPublicUrl = (route) => {
const pathname = window.location.pathname.replace(/\/+$/, "") || "/";
const lowerPathname = pathname.toLowerCase();
if (
lowerPathname !== demoBase &&
!publicRouteAliases.has(pathname.slice(demoBase.length).toLowerCase())
) {
return;
}
const normalizedUrl = new URL(window.location.href);
normalizedUrl.pathname = publicPathFromRoute(route);
window.history.replaceState(
null,
"",
`${normalizedUrl.pathname}${normalizedUrl.search}${normalizedUrl.hash}`,
);
};
if (initialMock === "trial" && initialRoute === "/trial") {
initialRoute = "/home";
const normalizedUrl = new URL(window.location.href);
normalizedUrl.pathname = publicPathFromRoute(initialRoute);
window.history.replaceState(
null,
"",
`${normalizedUrl.pathname}${normalizedUrl.search}${normalizedUrl.hash}`,
);
} else {
canonicalizeInitialPublicUrl(initialRoute);
}
params.set("mock", initialMock);
params.delete("path");
params.delete("screen");
params.delete("admin_section");
params.set("path", initialRoute);
frame.src = `${runtimeBase}/app/?${params.toString()}${window.location.hash || ""}`;
const routeFromRuntimeUrl = (url) => {
if (url.origin !== window.location.origin) return "";
if (!url.pathname.toLowerCase().startsWith(runtimeBase.toLowerCase()))
return "";
const runtimePath = normalizePath(
url.pathname.slice(runtimeBase.length) || "/home",
);
if (runtimePath === "/app" || runtimePath === "/app.html") {
return normalizePath(url.searchParams.get("path") || "/home");
}
return runtimePath;
};
const routeForStateMock = (mock) => {
if (mock === "devices") return "/devices";
if (mock === "auth") return "/login";
@@ -141,7 +163,7 @@ const runtimeSrc = (route, searchParams = new URLSearchParams()) => {
nextParams.delete("screen");
nextParams.delete("admin_section");
nextParams.set("path", normalizePath(route));
return `${runtimeBase}/app.html?${nextParams.toString()}${window.location.hash || ""}`;
return `${runtimeBase}/app/?${nextParams.toString()}${window.location.hash || ""}`;
};
const topbar = document.querySelector(".demo-topbar");
const toggle = document.querySelector(".demo-topbar__toggle");
+6 -39
View File
@@ -1,49 +1,16 @@
import { copyFile, mkdir } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
demoPublicRoutes,
demoRuntimeRoutes,
} from "../src/lib/demoRoutes.mjs";
const siteRoot = path.resolve(fileURLToPath(new URL("..", import.meta.url)));
const distRoot = path.join(siteRoot, "dist");
const userRoutes = [
"home",
"install",
"trial",
"invite",
"devices",
"support",
"settings",
"login",
"login/password",
];
const adminRoutes = [
"stats",
"users",
"payments",
"promos",
"ads",
"broadcast",
"logs",
"support",
"tariffs",
"appearance",
"translations",
"backups",
"settings",
];
const demoRoutes = [
...userRoutes.map((route) => `demo/${route}`),
"demo/admin",
...adminRoutes.map((route) => `demo/admin/${route}`),
];
const runtimeRoutes = [
...userRoutes.map((route) => `demo/runtime/${route}`),
"demo/runtime/admin",
...adminRoutes.map((route) => `demo/runtime/admin/${route}`),
];
const demoRoutes = demoPublicRoutes.map((route) => `demo/${route}`);
const runtimeRoutes = demoRuntimeRoutes.map((route) => `demo/runtime/${route}`);
async function copyHtml(source, route) {
const targetDir = path.join(distRoot, route);
+1
View File
@@ -19,6 +19,7 @@ const descriptions = {
'features/core.md': 'Пользовательские и админские сценарии Remnawave Minishop.',
'features/payments.md': 'Платежные провайдеры, кнопки оплаты и webhook-обработка.',
'features/subscriptions.md': 'Тарифы на срок и по трафику, premium-сквады, HWID-устройства и жизненный цикл подписки.',
'features/notifications.md': 'Каналы Telegram и email для пользовательских, админских и сервисных уведомлений Remnawave Minishop.',
'features/tariffs.md': 'Каталог тарифов, модели на срок/по трафику, premium-сквады и HWID-устройства.',
'features/web-app.md': 'Telegram Mini App, публичные инструкции, проксирование и реферальные ссылки.',
'features/telegram-auth.md': 'Telegram Mini Apps initData, Telegram OAuth, BotFather и настройка входа через Telegram.',
+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/';
---
@@ -294,6 +294,7 @@ const docsHref = '/getting-started/demo/';
<option value="no-subscription">Нет подписки</option>
<option value="trial">Доступна пробная подписка</option>
<option value="devices">Лимит и докупка устройств</option>
<option value="notifications">Telegram-уведомления</option>
<option value="auth">Вход и регистрация</option>
</select>
</label>
+2 -32
View File
@@ -1,39 +1,9 @@
---
import DemoShell from '../demo.astro';
import { demoPublicRoutes } from '../../lib/demoRoutes.mjs';
export function getStaticPaths() {
const userRoutes = [
'home',
'install',
'trial',
'invite',
'devices',
'support',
'settings',
'login',
'login/password',
];
const adminRoutes = [
'stats',
'users',
'payments',
'promos',
'ads',
'broadcast',
'logs',
'support',
'tariffs',
'appearance',
'translations',
'backups',
'settings',
];
return [
...userRoutes.map((path) => ({ params: { path } })),
{ params: { path: 'admin' } },
...adminRoutes.map((section) => ({ params: { path: `admin/${section}` } })),
];
return demoPublicRoutes.map((path) => ({ params: { path } }));
}
---
@@ -0,0 +1,14 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
const runtimeAppHtml = path.join(process.cwd(), "public", "demo", "runtime", "app.html");
export const prerender = true;
export async function GET() {
return new Response(await readFile(runtimeAppHtml, "utf8"), {
headers: {
"Content-Type": "text/html; charset=utf-8",
},
});
}
+9 -4
View File
@@ -117,13 +117,15 @@
| --- | --- |
| `PANEL_API_URL` | URL API панели, например `https://panel.example.com/api`. |
| `PANEL_API_KEY` | API-ключ панели. |
| `PANEL_WEBHOOK_SECRET` | Секрет проверки Remnawave webhook. |
| `PANEL_WEBHOOK_SECRET` | Секрет проверки Remnawave webhook. Задайте его в Remnawave Panel и вставьте то же значение сюда или в админку. |
| `USER_SQUAD_UUIDS` | Internal Squads по умолчанию для legacy-режима без JSON-каталога. |
| `USER_EXTERNAL_SQUAD_UUID` | Необязательный External Squad. |
| `USER_TRAFFIC_LIMIT_GB` | Legacy-лимит трафика пользователя. |
| `USER_TRAFFIC_STRATEGY` | Legacy-стратегия лимита трафика. |
| `USER_HWID_DEVICE_LIMIT` | Legacy-лимит HWID-устройств по умолчанию. |
В Remnawave Panel поле `WEBHOOK_URL` должно указывать на публичный Minishop webhook: `WEBHOOK_BASE_URL` + `/webhook/panel`. Если публичный домен приложения `https://app.example.com`, итоговый адрес будет `https://app.example.com/webhook/panel`.
## Веб-приложение, внешний вид и Telegram Login
Часть внешнего вида (`WEBAPP_PRIMARY_COLOR`, `WEBAPP_LOGO_*`, `WEBAPP_FAVICON_*`) сохранена для совместимости, но env-значения этих полей игнорируются при загрузке. Настраивайте их в **Админка -> Внешний вид**.
@@ -355,6 +357,8 @@ PAYMENT_HELEKET_TELEGRAM_EMOJI
Рекомендуемый способ настройки тарифов - раздел **Система -> Тарифы** в админке. Он сохраняет JSON в `TARIFFS_CONFIG_PATH`.
Если JSON-каталог существует и проходит валидацию, цены, периоды и реферальные бонусы period-тарифов берутся из JSON. Legacy-переменные ниже используются только без JSON-каталога.
| Переменная | Назначение |
| --- | --- |
| `TARIFFS_CONFIG_PATH` | Путь к JSON-каталогу тарифов. |
@@ -363,7 +367,7 @@ PAYMENT_HELEKET_TELEGRAM_EMOJI
| `3_MONTHS_ENABLED` | Legacy-доступность периода 3 месяца без JSON-каталога. |
| `6_MONTHS_ENABLED` | Legacy-доступность периода 6 месяцев без JSON-каталога. |
| `12_MONTHS_ENABLED` | Legacy-доступность периода 12 месяцев без JSON-каталога. |
| `RUB_PRICE_1_MONTH`, `RUB_PRICE_3_MONTHS`, `RUB_PRICE_6_MONTHS`, `RUB_PRICE_12_MONTHS` | Legacy-цены RUB. |
| `RUB_PRICE_1_MONTH`, `RUB_PRICE_3_MONTHS`, `RUB_PRICE_6_MONTHS`, `RUB_PRICE_12_MONTHS` | Legacy-цены RUB. Дефолты: `200`, `600`, `1200`, `2400`. |
| `STARS_PRICE_1_MONTH`, `STARS_PRICE_3_MONTHS`, `STARS_PRICE_6_MONTHS`, `STARS_PRICE_12_MONTHS` | Legacy-цены Stars. |
| `TRAFFIC_PACKAGES` | Legacy-пакеты трафика RUB, формат `10:199,50:799`. |
| `STARS_TRAFFIC_PACKAGES` | Legacy-пакеты трафика Stars. |
@@ -382,9 +386,10 @@ PAYMENT_HELEKET_TELEGRAM_EMOJI
| `REFERRAL_ONE_BONUS_PER_REFEREE` | Ограничить бонусы одним успешным платежом приглашенного. |
| `REFERRAL_WELCOME_BONUS_DAYS` | Приветственный бонус пришедшему по реферальной ссылке. |
| `LEGACY_REFS` | Разрешить ссылки `ref_<telegram_id>`. |
| `REFERRAL_BONUS_DAYS_1_MONTH`, `REFERRAL_BONUS_DAYS_3_MONTHS`, `REFERRAL_BONUS_DAYS_6_MONTHS`, `REFERRAL_BONUS_DAYS_12_MONTHS` | Legacy-бонусы пригласившему. |
| `REFEREE_BONUS_DAYS_1_MONTH`, `REFEREE_BONUS_DAYS_3_MONTHS`, `REFEREE_BONUS_DAYS_6_MONTHS`, `REFEREE_BONUS_DAYS_12_MONTHS` | Legacy-бонусы приглашенному. |
| `REFERRAL_BONUS_DAYS_1_MONTH`, `REFERRAL_BONUS_DAYS_3_MONTHS`, `REFERRAL_BONUS_DAYS_6_MONTHS`, `REFERRAL_BONUS_DAYS_12_MONTHS` | Legacy-бонусы пригласившему без JSON-каталога. В JSON-тарифах используйте `referral_bonus_days_inviter`. |
| `REFEREE_BONUS_DAYS_1_MONTH`, `REFEREE_BONUS_DAYS_3_MONTHS`, `REFEREE_BONUS_DAYS_6_MONTHS`, `REFEREE_BONUS_DAYS_12_MONTHS` | Legacy-бонусы приглашенному без JSON-каталога. В JSON-тарифах используйте `referral_bonus_days_referee`. |
| `SUBSCRIPTION_NOTIFICATIONS_ENABLED` | Включает напоминания о подписке. |
| `SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED` | Дублирует пользовательские уведомления жизненного цикла подписки на email, если SMTP настроен и у пользователя есть email. |
| `SUBSCRIPTION_NOTIFY_ON_EXPIRE` | Уведомлять в день окончания. |
| `SUBSCRIPTION_NOTIFY_AFTER_EXPIRE` | Уведомлять после окончания. |
| `SUBSCRIPTION_NOTIFY_DAYS_BEFORE` | За сколько дней предупреждать. |
+2 -1
View File
@@ -6,7 +6,7 @@
- `WEBAPP_SESSION_SECRET` должен быть постоянным между рестартами, иначе Web App-сессии станут невалидными.
- `WEBHOOK_SECRET_TOKEN` защищает вебхук Telegram.
- `PANEL_WEBHOOK_SECRET` проверяет входящие события Remnawave Panel.
- `PANEL_WEBHOOK_SECRET` проверяет входящие события Remnawave Panel. Секрет задается в Remnawave Panel и вставляется в настройки бота.
- Платежные токены и webhook-секреты храните в `.env` или настройках админки с учетом доступа к серверу.
Сгенерировать секрет можно так:
@@ -24,6 +24,7 @@ openssl rand -hex 32
## Публичные URL
- `WEBHOOK_BASE_URL` должен вести на backend-сервер вебхуков.
- В Remnawave Panel `WEBHOOK_URL` должен быть `WEBHOOK_BASE_URL` + `/webhook/panel`, например `https://app.example.com/webhook/panel`.
- `SUBSCRIPTION_MINI_APP_URL` должен вести на frontend/Mini App-домен.
- Не добавляйте `/api`, `/auth` или webhook-пути в `SUBSCRIPTION_MINI_APP_URL`.
+3 -3
View File
@@ -51,9 +51,9 @@
- общие параметры: язык, валюта, ссылки поддержки, документы, обязательный канал, Remnawave-доступы и поведение `/start`;
- внешний вид и доступность Web App: название, цвет, логотип, emoji-логотип и `WEBAPP_ENABLED`;
- инструкции подключения: `SUBSCRIPTION_GUIDES_ENABLED`, `SUBSCRIPTION_GUIDES_BOT_MENU_ENABLED`, чтение конфига из Remnawave Panel, JSON-переопределение и резервный путь к файлу;
- legacy-цены без JSON-каталога: периоды подписки, RUB/Stars цены и пакеты трафика;
- legacy-тарифы без JSON-каталога: периоды подписки, RUB/Stars цены, реферальные бонусы и пакеты трафика;
- платежные провайдеры: включение методов, порядок кнопок, публичные параметры и секреты YooKassa, FreeKassa, Platega, SeverPay, Wata, CryptoPay, Heleket и Stars, а также текст и иконки кнопок оплаты;
- пробный период, реферальные бонусы, уведомления, логирование, поддержка, раздел устройств, лимит устройств и legacy-лимиты трафика.
- пробный период, приветственный реферальный бонус, уведомления, логирование, поддержка, раздел устройств, лимит устройств и legacy-лимиты трафика.
Секретные поля помечены как secret и не должны использоваться для произвольного просмотра старых значений. Настройки, которых нет в manifest, остаются только в `.env` или коде.
@@ -117,7 +117,7 @@
Редактор тарифа разделен на вкладки:
- **Основное**: ключ, модель `period`/`traffic`, видимость, названия и описания RU/EN, базовые Internal Squads, HWID-лимит, месячный лимит или курс конвертации;
- **Цены**: периоды и цены для `period`, пакеты GB и цены для `traffic`;
- **Цены**: периоды, цены и реферальные бонусы для `period`, пакеты GB и цены для `traffic`;
- **Докупки**: обычные пакеты докупки трафика для `period`; для `traffic` отдельные докупки не нужны, пользователь повторно покупает пакеты из `traffic_packages`;
- **Premium**: названия premium-раздела RU/EN, premium Internal Squads, месячный premium-лимит и RUB/Stars пакеты premium-докупки;
- **Устройства**: RUB/Stars пакеты докупки HWID-устройств.
+1 -1
View File
@@ -105,4 +105,4 @@ docker compose logs -f backend
- Код сразу устаревает: проверьте `EMAIL_CODE_TTL_SECONDS` и время на сервере.
- Пользователь получает `rate_limited`: подождите `EMAIL_CODE_RESEND_SECONDS` или проверьте brute-force настройки.
Email-уведомления поддержки и платежей используют тот же SMTP-контур. Сценарий поддержки описан в [разделе тикетов](support.md).
Email-уведомления поддержки, платежей и жизненного цикла подписки используют тот же SMTP-контур. Сценарий поддержки описан в [разделе тикетов](support.md), сводка по каналам - в разделе [уведомления](notifications.md).
+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 цены, курс конвертации;
- настройка базовых Internal Squads из списка Remnawave;
- настройка premium-раздела: названия RU/EN, premium Internal Squads, месячный premium-лимит и RUB/Stars пакеты докупки premium-трафика;
@@ -60,7 +60,9 @@ JSON-каталог может содержать несколько тариф
"squad_uuids": ["uuid-1"],
"billing_model": "period",
"monthly_gb": 500,
"prices_rub": { "1": 150, "3": 400 },
"prices_rub": { "1": 200, "3": 600 },
"referral_bonus_days_inviter": { "1": 3, "3": 7 },
"referral_bonus_days_referee": { "1": 1, "3": 3 },
"enabled_periods": [1, 3],
"topup_packages": {
"rub": [{ "gb": 10, "price": 99 }],
@@ -108,6 +110,8 @@ JSON-каталог может содержать несколько тариф
| `monthly_gb` | Базовый месячный лимит трафика тарифа. `0` означает безлимит. |
| `prices_rub` | Цены периодов в рублях, ключ - количество месяцев. |
| `prices_stars` | Цены периодов в Telegram Stars. |
| `referral_bonus_days_inviter` | Бонус пригласившему в днях для каждого периода. Ключ - количество месяцев, как в `enabled_periods`. |
| `referral_bonus_days_referee` | Бонус приглашенному в днях для каждого периода. Ключ - количество месяцев, как в `enabled_periods`. |
| `enabled_periods` | Периоды, доступные для покупки. |
| `topup_packages` | Пакеты докупки трафика именно для этого тарифа. Если поле не задано или списки пустые, докупка для тарифа не показывается в Web App и Telegram-боте. |
@@ -314,7 +318,15 @@ Remnawave ограничивает доступ при достижении `tra
Пробный период использует настройки `TRIAL_DURATION_DAYS`, `TRIAL_TRAFFIC_LIMIT_GB`, `TRIAL_TRAFFIC_STRATEGY` и `TRIAL_SQUAD_UUIDS`. Он не выбирает тариф из JSON-каталога, но его можно настроить на странице **Система → Тарифы** рядом с каталогом продаж. Если `TRIAL_SQUAD_UUIDS` пустой, для trial применяются squads из `USER_SQUAD_UUIDS`.
Промокоды с бонусными днями применяются к покупке period-подписки. Реферальные бонусы по периодам также относятся к подпискам на срок; в режиме продажи трафика без JSON-каталога Web App не показывает детализацию бонусов по месяцам.
Промокоды с бонусными днями применяются к покупке period-подписки.
Реферальные бонусы за оплату в JSON-каталоге задаются прямо в period-тарифе рядом с ценами периода: `referral_bonus_days_inviter` для пригласившего и `referral_bonus_days_referee` для приглашенного. Ключи этих словарей - месяцы периода (`"1"`, `"3"`, `"6"`, `"12"` или любые другие периоды тарифа, например `"2"`, `"4"`, `"8"`, `"16"`). Для `traffic`-тарифов такие бонусы не применяются.
Если приглашенный покупает один тариф, а пригласивший находится на другом, размер бонуса берется из тарифа и периода, который купил приглашенный. При этом подписка пригласившего только продлевается на бонусные дни: лимиты, Internal Squads и другие параметры его текущего тарифа не пересчитываются под тариф приглашенного.
В Web App и Telegram-меню подробные строки по периодам показываются только для legacy-режима или когда активен один period-тариф. Если включено несколько period-тарифов, Web App показывает сообщение, что бонус зависит от тарифа и периода оплаты друга, затем список тарифов с диапазонами "от N до N дней" и раскрытием подробностей по иконке вопроса. Telegram-меню в этом случае показывает только диапазоны по каждому тарифу.
В legacy-режиме без JSON-каталога используются переменные `REFERRAL_BONUS_DAYS_*` и `REFEREE_BONUS_DAYS_*`; при продаже трафика без JSON-каталога Web App не показывает детализацию бонусов по месяцам.
## Привязка существующих подписок
+2
View File
@@ -118,6 +118,8 @@ services:
- Telegram deep-link: `https://t.me/<bot>?start=ref_u<code>`;
- Web App ссылка: `https://app.domain.com/?ref=u<code>`.
В разделе бонусов Web App показывает приветственный бонус за регистрацию и бонусы за оплату подписки. В legacy-режиме или при одном period-тарифе выводятся подробные строки по периодам. Если в JSON-каталоге включено несколько period-тарифов, Web App показывает, что бонус зависит от тарифа и периода оплаты друга, затем список тарифов с диапазоном "от N до N дней"; подробности по периодам раскрываются по иконке вопроса.
Веб-приложение учитывает `ref`, `start`, `start_param` и Telegram Mini Apps `start_param`, сохраняет найденный параметр до авторизации и передает его в Telegram OAuth или вход по email.
Для email-регистраций пользователь в Remnawave создается с username вида `em_<referral_code>`. Email добавляется в описание пользователя панели и, если API панели принимает поле `email`, передается отдельным полем. Для Telegram-регистраций используется username `tg_<telegram_id>`.
+6 -1
View File
@@ -49,6 +49,8 @@ WEBAPP_DEFAULT_THEME=
`WEBAPP_DEFAULT_THEME` опционален. Если он задан и совпадает с ключом темы, он переопределяет `default: true` в `theme.json`. Если переменная пустая, дефолт выбирается из дескрипторов тем.
В compose-примерах `data/themes` - это локальная папка рядом с выбранным `docker-compose.yml`; она монтируется в контейнер как `/app/data/themes`. Правки в `backend/bot/app/web/themes` попадают в прод только при сборке собственного образа; опубликованный образ их не видит.
Важно: `WEBAPP_PRIMARY_COLOR`, `WEBAPP_LOGO_URL`, `WEBAPP_LOGO_USE_EMOJI`, `WEBAPP_LOGO_EMOJI` и `WEBAPP_LOGO_EMOJI_FONT` больше не являются рабочим способом первичной настройки через `.env`. Эти значения редактируются в админке и сохраняются как overrides в базе. Тема при этом может использовать сохраненный primary color как fallback accent.
## Контракт `theme.json`
@@ -266,7 +268,7 @@ CSS можно писать для пользовательской части
2. Создайте папку в `WEBAPP_THEMES_DIR`.
В Docker по умолчанию это `data/themes`. Если включен bind mount `./data:/app/data`, убедитесь, что контейнер может писать в `data`.
В Docker это `data/themes` рядом с выбранным `docker-compose.yml`, внутри контейнера путь будет `/app/data/themes`. Убедитесь, что контейнер может писать в `data`.
```bash
mkdir -p data/themes/neon
@@ -314,6 +316,8 @@ CSS можно писать для пользовательской части
Начинайте с переопределения CSS-переменных на `.theme-key-neon.app-shell`, затем переходите к конкретным компонентам. Проверяйте минимум: главная, `/install`, публичная `/s/<token>`, оплата, настройки, модалки, админский дашборд, таблица пользователей, редактор тарифов.
После ручного изменения CSS поднимите `assets_version` в `theme.json` или сделайте жесткую перезагрузку страницы: тема подключается с `?v=<assets_version>`, и браузер может держать старую версию.
9. Добавьте ассеты при необходимости.
Положите картинки в подпапку темы и ссылайтесь на них через `/webapp-theme-assets/<key>/...`. Не используйте относительные пути вроде `url("icons/x.png")`, если CSS может быть подключен с другого URL-уровня; явный `/webapp-theme-assets/neon/icons/x.png` надежнее.
@@ -357,6 +361,7 @@ CSS можно писать для пользовательской части
Если CSS не применился:
- проверьте `css_file` и URL `/webapp-theme-css/<key>/<css_file>`;
- если CSS уже был открыт в браузере, увеличьте `assets_version` в `theme.json` или очистите кеш;
- убедитесь, что файл меньше 512 KiB;
- начинайте селекторы с `.theme-key-<key>`;
- откройте `/home?theme_preview=<key>` в новом окне, чтобы исключить сохраненный старый выбор.
+5 -5
View File
@@ -24,14 +24,14 @@ nano .env
| --- | --- |
| `BOT_TOKEN` | Токен Telegram-бота. |
| `ADMIN_IDS` | Telegram ID администраторов через запятую; без этого не попасть в Web App админку. |
| `WEBHOOK_BASE_URL` | Публичный URL webhook-домена backend. |
| `WEBHOOK_BASE_URL` | Публичный URL webhook-домена backend. Для Remnawave Panel `WEBHOOK_URL` будет `WEBHOOK_BASE_URL` + `/webhook/panel`, например `https://app.example.com/webhook/panel`. |
| `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB` | Доступы PostgreSQL для Compose и backend. |
| `WEBAPP_ENABLED` | Включает Web App и админку. Для первого запуска держите `True`. |
| `WEBAPP_SESSION_SECRET` | Стабильный секрет сессий Web App. |
| `WEBHOOK_SECRET_TOKEN` | Стабильный секретный токен вебхука Telegram. |
| `SUBSCRIPTION_MINI_APP_URL` | Публичный HTTPS URL Mini App/frontend, например `https://app.domain.com/`. Это URL, который открывают кнопки Telegram и который указывается в BotFather; не добавляйте сюда `/api` или webhook-пути. |
| `SUBSCRIPTION_GUIDES_ENABLED`, `SUBSCRIPTION_GUIDES_BOT_MENU_ENABLED` | Встроенные инструкции установки в Web App и кнопках бота. По умолчанию включены; обычно их достаточно менять в админке. |
| `PANEL_API_URL`, `PANEL_API_KEY`, `PANEL_WEBHOOK_SECRET` | Базовая интеграция с Remnawave. Эти значения стоит хранить в `.env`, но при необходимости их можно переопределить из админки. |
| `PANEL_API_URL`, `PANEL_API_KEY`, `PANEL_WEBHOOK_SECRET` | Базовая интеграция с Remnawave. Секрет вебхука задайте в Remnawave Panel и вставьте то же значение в настройки бота; эти значения стоит хранить в `.env`, но при необходимости их можно переопределить из админки. |
`WEBAPP_SESSION_SECRET` и `WEBHOOK_SECRET_TOKEN` можно сгенерировать так:
@@ -57,7 +57,7 @@ openssl rand -hex 32
Рекомендуемый порядок первичной настройки:
1. **Система -> Настройки -> Remnawave**: проверьте `PANEL_API_URL`, `PANEL_API_KEY`, `PANEL_WEBHOOK_SECRET`, базовые squads.
1. **Система -> Настройки -> Remnawave Panel**: проверьте `PANEL_API_URL`, `PANEL_API_KEY`, `PANEL_WEBHOOK_SECRET`, базовые squads. Рядом с настройками Remnawave админка показывает вычисленный адрес для `WEBHOOK_URL` в панели, например `https://app.example.com/webhook/panel`.
2. **Система -> Тарифы**: создайте JSON-каталог тарифов, выберите Internal Squads, настройте модели на срок/по трафику, premium-сквады и HWID-пакеты.
3. **Система -> Настройки -> Инструкции подключения**: проверьте, что Remnawave Panel отдает нужный конфиг Subscription Page. JSON-переопределение включайте только если нужно временно заменить конфиг панели.
4. **Система -> Настройки -> Платежи**: включите нужные провайдеры и заполните их ключи.
@@ -82,9 +82,9 @@ openssl rand -hex 32
## Файловые данные
В штатном `docker-compose.yml` данные хранятся в named volume `shop-data`. Внутри него лежат тарифы, темы, логотипы и прочие файловые данные приложения.
В compose-примерах данные монтируются из локальной папки `./data` рядом с выбранным `docker-compose.yml`. Внутри нее лежат тарифы, темы, логотипы и прочие файловые данные приложения.
Если для локальной разработки включаете bind mount `./data:/app/data`, заранее создайте каталоги и отдайте их пользователю контейнера:
Перед первым запуском создайте каталоги и отдайте их пользователю контейнера:
```bash
mkdir -p data/themes data/webapp-logo data/webapp-emoji data/tariffs
+1
View File
@@ -12,6 +12,7 @@
- [Админка: бэкапы](/demo/admin/backups)
- [Пробный период](/demo/home?mock=trial)
- [Докупка устройств](/demo/devices?mock=devices)
- [Запуск бота для Telegram-уведомлений](/demo/home?mock=notifications)
- [Вход и регистрация](/demo/login?mock=auth)
## Как собирается
+3 -5
View File
@@ -331,14 +331,12 @@ distributed lock; код подготовлен к нескольким репл
- `postgres-data`;
- `redis-data`;
- `shop-data`;
В Caddy-варианте также используются `caddy-data` и `caddy-config`.
`shop-data` монтируется целиком в `/app/data`; внутри него лежат тарифы, темы, логотипы и прочие
файловые данные приложения.
Файлы приложения монтируются из локальной папки `./data` рядом с выбранным `docker-compose.yml` в
`/app/data`; внутри нее лежат тарифы, темы, логотипы и прочие файловые данные приложения.
Если вместо именованного volume включаете bind mount `./data:/app/data`, на сервере заранее дайте права
пользователю контейнера `10001`:
Перед первым запуском на сервере заранее дайте права пользователю контейнера `10001`:
```bash
mkdir -p data/themes data/webapp-logo data/webapp-emoji data/tariffs
+2
View File
@@ -20,6 +20,8 @@ docker compose logs -f backend worker frontend
- `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB`.
- `WEBAPP_SESSION_SECRET`, `WEBHOOK_SECRET_TOKEN`, `PANEL_API_URL`, `PANEL_API_KEY`, `PANEL_WEBHOOK_SECRET`.
Для вебхуков Remnawave в панели укажите `WEBHOOK_URL` как `WEBHOOK_BASE_URL` + `/webhook/panel`, например `https://app.example.com/webhook/panel`. Секрет создается или задается в Remnawave Panel; тот же секрет вставьте в `PANEL_WEBHOOK_SECRET` в `.env` или позже в **Система -> Настройки -> Remnawave Panel**.
## Как выбрать Compose-вариант
Для продакшена по умолчанию берите [Caddy](deployment.md#caddy-рекомендуемый-вариант): это самый короткий путь к публичному HTTPS без ручной раскладки сертификатов.
+1 -1
View File
@@ -125,7 +125,7 @@ docker compose \
| `TELEGRAM_WEBHOOK_PATH` | удалена | Путь вебхука теперь генерируется из `BOT_TOKEN` автоматически. |
| `REQUIRED_CHANNEL_SUBSCRIBE_TO_USE` | удалена | Гейт включается автоматически, как только задан `REQUIRED_CHANNEL_ID`. |
| `STARS_PROVIDER_TOKEN` | удалена | Telegram Stars (XTR) используются напрямую. |
| `REFERRAL_ENABLED` | удалена | Реферальная программа активна по умолчанию; чтобы выключить — обнулите `REFERRAL_BONUS_DAYS_*` и `REFEREE_BONUS_DAYS_*`. |
| `REFERRAL_ENABLED` | удалена | Реферальная программа активна по умолчанию. В legacy-режиме без JSON-каталога отключайте платежные бонусы через нули в `REFERRAL_BONUS_DAYS_*` и `REFEREE_BONUS_DAYS_*`; в JSON-тарифах обнуляйте или удаляйте `referral_bonus_days_inviter` и `referral_bonus_days_referee` у period-тарифов. |
| `POSTGRES_HOST=remnawave-tg-shop-db` | в `.env``remnawave-minishop-db` или пусто | Под compose значение всё равно переопределяется на сервисное имя `postgres` (см. `environment:` в compose-файлах), поэтому скрипт правит `.env` только для bare-metal сценариев. |
| `WEBHOOK_BASE_URL` | **обязательна** | Polling-режим удалён, без публичного URL бот не стартует. |
| — | `REDIS_URL=redis://redis:6379/0` | Обязательна для воркера, очередей и rate-limit. По умолчанию в compose-файлах уже задана. |
+152 -1
View File
@@ -73,6 +73,7 @@
const ACTIVATION_PENDING_WATCH_INTERVAL_MS = 2000;
const ACTIVATION_PENDING_WATCH_MAX_ATTEMPTS = 45;
const ACTIVATION_RESUME_CHECK_COOLDOWN_MS = 1500;
const TELEGRAM_NOTIFICATIONS_RESUME_REFRESH_COOLDOWN_MS = 1500;
import {
activationPaymentFailed,
createActivationHandoff,
@@ -152,6 +153,7 @@
let mode = isAppLaunchRoute ? "appLaunch" : isPreviewBoard ? "preview" : "loading";
let activeTab = "home";
let screen = "home";
let emailLoginDeeplinkConsumed = false;
let data = isPreviewBoard ? structuredCloneSafe(MOCK_SOURCE.data) : null;
let appLaunchTarget = isAppLaunchRoute ? readExternalAppLaunchTarget() : "";
let publicInstallSubscription = null;
@@ -166,6 +168,9 @@
let activationPendingWatchBusy = false;
let activationResumeRefreshBusy = false;
let activationResumeLastCheckAt = 0;
let telegramNotificationsBotOpenedAt = 0;
let telegramNotificationsResumeRefreshBusy = false;
let telegramNotificationsResumeLastCheckAt = 0;
let promoCode = "";
let promoBusy = false;
let promoStatus = "";
@@ -323,6 +328,7 @@
devicesBusy,
devicesStatus,
devicesIsError,
devicesErrorCode,
deviceConfirmOpen,
deviceToDisconnect,
deviceDisconnectBusy,
@@ -473,7 +479,13 @@
languageOptions.find((option) => option.value === currentLang) || languageOptions[0];
$: userLanguage = languageName(currentLang);
$: emailLinkStatus = user?.email ? t("wa_settings_linked") : t("wa_settings_email_not_linked");
$: hasUnlinkedIdentity = !user?.telegram_linked || !user?.email;
$: telegramNotificationsStatus = String(user?.telegram_notifications_status || "unknown");
$: telegramNotificationsNeedPrompt = Boolean(
user?.telegram_linked && user?.telegram_notifications_need_prompt
);
$: telegramNotificationsStartLink = String(user?.telegram_notifications_start_link || "");
$: hasUnlinkedIdentity =
!user?.telegram_linked || !user?.email || telegramNotificationsNeedPrompt;
$: referralBonusDetails = Array.isArray(referral?.bonus_details) ? referral.bonus_details : [];
$: referralWelcomeBonusDays = Math.max(0, Number(referral?.welcome_bonus_days || 0));
$: referralOneBonusPerReferee = Boolean(referral?.one_bonus_per_referee);
@@ -747,6 +759,34 @@
}
}
async function refreshTelegramNotificationsOnResume() {
if (
mode !== "app" ||
!telegramNotificationsNeedPrompt ||
!telegramNotificationsBotOpenedAt ||
telegramNotificationsResumeRefreshBusy
) {
return;
}
const now = Date.now();
if (
now - telegramNotificationsResumeLastCheckAt <
TELEGRAM_NOTIFICATIONS_RESUME_REFRESH_COOLDOWN_MS
) {
return;
}
telegramNotificationsResumeLastCheckAt = now;
telegramNotificationsResumeRefreshBusy = true;
try {
await loadData({ fresh: true, preserveView: true });
if (!telegramNotificationsNeedPrompt) telegramNotificationsBotOpenedAt = 0;
} catch (_error) {
void _error;
} finally {
telegramNotificationsResumeRefreshBusy = false;
}
}
function refreshAppLaunchTarget() {
appLaunchTarget = readExternalAppLaunchTarget();
return appLaunchTarget;
@@ -769,6 +809,7 @@
const onActivationResume = () => {
if (typeof document !== "undefined" && document.visibilityState === "hidden") return;
void refreshPendingActivationOnResume();
void refreshTelegramNotificationsOnResume();
};
const onVisibilityChange = () => {
if (document.visibilityState !== "hidden") onActivationResume();
@@ -1137,10 +1178,85 @@
}
}
function openTelegramNotificationsBot() {
const link = telegramNotificationsStartLink;
telegramNotificationsBotOpenedAt = Date.now();
if (!link) {
showToast(t("wa_telegram_notifications_link_unavailable"));
return;
}
const currentTg = tg || telegramSdk.refresh();
if (currentTg?.openTelegramLink && /^https:\/\/t\.me\//i.test(link)) {
try {
tg = currentTg;
currentTg.openTelegramLink(link);
return;
} catch {
// Fall back to generic external opening below.
}
}
openExternalLink(link);
}
function currentSearchParams() {
return new URLSearchParams(window.location.search);
}
function readEmailCodeLoginDeeplink() {
const params = currentSearchParams();
if (params.get("login") !== "email_code") return null;
const emailHint = normalizedEmail(params.get("login_email") || "");
if (!emailHint || !emailHint.includes("@")) return null;
return emailHint;
}
function hasEmailCodeLoginDeeplink() {
return Boolean(readEmailCodeLoginDeeplink());
}
async function startEmailCodeLoginFromDeeplink() {
if (emailLoginDeeplinkConsumed) return;
const emailHint = readEmailCodeLoginDeeplink();
if (!emailHint) return;
emailLoginDeeplinkConsumed = true;
authStore.update((s) => ({
...s,
email: emailHint,
emailCode: "",
pendingEmail: "",
passwordLoginMode: false,
passwordLoginFallback: false,
}));
await tick();
await authStore.requestEmailCode((nextScreen) => {
screen = nextScreen;
});
}
function readRenewalDeeplink() {
const params = currentSearchParams();
const shouldRenew = params.get("after_login") === "renew" || params.get("renew") === "1";
if (!shouldRenew) return null;
return {
tariffKey: String(params.get("renew_tariff") || "").trim(),
};
}
function stripRenewalLoginQueryFromUrl() {
if (typeof window === "undefined") return;
const url = new URL(window.location.href);
const keys = ["login", "login_email", "after_login", "renew", "renew_tariff"];
const changed = keys.some((key) => url.searchParams.has(key));
if (!changed) return;
for (const key of keys) url.searchParams.delete(key);
const search = url.searchParams.toString();
window.history.replaceState(
null,
"",
`${url.pathname}${search ? `?${search}` : ""}${url.hash}`
);
}
function docsDemoParentSearchParams() {
if (!isDocsDemo) return null;
try {
@@ -1316,6 +1432,7 @@
clearToken,
clearManualLogoutFlag,
isManuallyLoggedOut,
hasEmailCodeLoginDeeplink,
finalizeMagicLogin: (loginToken) => authStore.finalizeMagicLogin(loginToken),
finalizeTelegramAuth: (authData, source) => authStore.finalizeTelegramAuth(authData, source),
setAuthStatus: (message, isError) => authStore.setAuthStatus(message, isError),
@@ -1508,6 +1625,30 @@
stripTopupQueryFromUrl();
}
}
const renewalDeep = readRenewalDeeplink();
if (renewalDeep) {
const plansList = payload.plans?.length ? payload.plans : [];
const tariffCatalogLocal = buildTariffCatalog(plansList);
const tariffModeLocal = plansList.some((plan) => plan?.tariff_key);
activeTab = "home";
screen = "home";
syncAppSectionPath("home", true);
billingStore.openPaymentModal(
tariffModeLocal,
tariffModeLocal && tariffCatalogLocal.length === 1,
tariffCatalogLocal,
payload.subscription || {},
plansList,
payload.payment_methods?.[0]?.id || "",
{
preferredTariffKey: renewalDeep.tariffKey,
selectDefaultTariff: true,
preferCheckout: true,
}
);
stripRenewalLoginQueryFromUrl();
}
return payload;
}
@@ -1529,6 +1670,7 @@
screen = "login";
activeTab = "home";
setPasswordLoginMode(isPasswordLoginPath(), true);
void startEmailCodeLoginFromDeeplink();
}
async function api(path, options = {}) {
@@ -2124,10 +2266,14 @@
{regularTrafficTopupBarClickable}
{regularTrafficTopupUnlocked}
{subscription}
{telegramNotificationsNeedPrompt}
{telegramNotificationsStartLink}
{telegramNotificationsStatus}
{termUnitLabel}
{trafficMode}
{trialBusy}
{activateTrial}
{openTelegramNotificationsBot}
openConnectLink={openInstallOrConnect}
{openPaymentModal}
{openRegularTopupModal}
@@ -2185,6 +2331,7 @@
{devicesData}
{devicesIsError}
{devicesLoaded}
{devicesErrorCode}
{devicesStatus}
{subscription}
{loadDevices}
@@ -2230,12 +2377,16 @@
{profileEmail}
{profileTelegramId}
{supportUrl}
{telegramNotificationsNeedPrompt}
{telegramNotificationsStartLink}
{telegramNotificationsStatus}
{telegramProfileName}
{user}
{userAgreementUrl}
{userLanguage}
showLogout={!telegramMiniAppContext}
linkTelegramAccount={linkTelegramFromSettings}
{openTelegramNotificationsBot}
logout={accountStore.logout}
{openAdminPanel}
{openExternalLink}
+16 -1
View File
@@ -389,6 +389,21 @@
usersStore.openUser(uid, { pathContext: "payments" });
}
function openLogsUserCard(userId) {
const uid = Number(userId);
if (!Number.isFinite(uid) || uid === 0) return;
const next = normalizeSection("logs");
sidebarOpen = false;
if (active !== next) {
active = next;
paymentsStore.closePayment({ skipPush: true });
supportStore.closeTicketView({ skipPush: true });
onSectionChange(next);
}
usersStore.setActive(next);
usersStore.openUser(uid, { skipPush: true, pathContext: "logs" });
}
function openUserCard(userId) {
const uid = Number(userId);
if (!Number.isFinite(uid) || uid === 0) return;
@@ -801,7 +816,7 @@
{/if}
{#if active === "logs"}
<LogsSection {at} {fmtDate} />
<LogsSection {at} {fmtDate} onOpenUserCard={openLogsUserCard} />
{/if}
{#if active === "support"}
@@ -1,4 +1,5 @@
<script>
import { Input } from "$components/ui/index.js";
import { Trash2 } from "$components/ui/icons.js";
import { getContext, onMount } from "svelte";
import Dialog from "$components/ui/dialog.svelte";
@@ -106,7 +107,7 @@
<div class="admin-form" data-dialog-content>
<div class="admin-dialog-form-section">
<AdminField label={at("ad_label_source", {}, "Источник")}>
<input
<Input
class="input"
type="text"
placeholder="telegram_ads"
@@ -118,7 +119,7 @@
label={at("ad_label_param", {}, "start-параметр")}
hint={at("ad_hint_param", {}, "Передаётся в /start, должен быть уникален")}
>
<input
<Input
class="input"
type="text"
placeholder="ads_summer25"
@@ -129,7 +130,7 @@
</div>
<div class="admin-dialog-form-section">
<AdminField label={at("ad_label_cost", {}, "Стоимость, RUB")}>
<input
<Input
class="input"
type="number"
step="0.01"
@@ -6,7 +6,7 @@
AdminEmptyState,
AdminSelect,
} from "$components/patterns/admin/index.js";
import { Checkbox } from "$components/ui/index.js";
import { Checkbox, ColorInput, FileInput, Input, RangeInput } from "$components/ui/index.js";
import { Switch } from "$components/ui/primitives.js";
import { getContext, onDestroy, onMount } from "svelte";
@@ -444,10 +444,9 @@
<div class="appearance-controls">
<section class="appearance-control-card">
<input
bind:this={logoFileInput}
<FileInput
bind:element={logoFileInput}
class="appearance-file-input"
type="file"
accept="image/png,image/jpeg,image/gif,image/webp,image/svg+xml,image/x-icon"
onchange={handleLogoFileChange}
/>
@@ -461,7 +460,7 @@
{at("appearance_logo_upload_file", {}, "Загрузить файл")}
</AdminButton>
<div class="appearance-url-row">
<input
<Input
class="input appearance-control"
type="url"
placeholder="https://example.com/logo.png"
@@ -490,7 +489,7 @@
<span>{at("appearance_use_emoji_logo", {}, "Использовать emoji-логотип")}</span>
</label>
<div class="appearance-emoji-grid">
<input
<Input
class="input appearance-control"
type="text"
maxlength="8"
@@ -547,10 +546,9 @@
>{at("appearance_use_custom_favicon", {}, "Использовать отдельную favicon")}</span
>
</label>
<input
bind:this={faviconFileInput}
<FileInput
bind:element={faviconFileInput}
class="appearance-file-input"
type="file"
accept="image/png,image/jpeg,image/gif,image/webp,image/svg+xml,image/x-icon,.ico"
onchange={handleFaviconFileChange}
/>
@@ -564,7 +562,7 @@
{at("appearance_favicon_upload_file", {}, "Загрузить favicon")}
</AdminButton>
<div class="appearance-url-row">
<input
<Input
class="input appearance-control"
type="url"
placeholder="https://example.com/icon.png"
@@ -657,18 +655,17 @@
</span>
<label class="admin-theme-card-option appearance-color-row">
<span>{at("appearance_theme_accent", {}, "Accent")}</span>
<input
class="admin-color"
class:is-empty={!isThemeAccentSet(theme)}
type="color"
<ColorInput
class={`admin-color${!isThemeAccentSet(theme) ? " is-empty" : ""}`}
value={pickerHex(theme.tokens?.accent)}
ariaLabel={at("appearance_theme_accent", {}, "Accent")}
title={isThemeAccentSet(theme)
? theme.tokens?.accent
: at("appearance_theme_accent_empty", {}, "Не задан")}
onclick={() => openThemeAccentPicker(theme)}
oninput={(event) => setThemeAccent(theme, event.currentTarget.value)}
/>
<input
<Input
class="input appearance-color-text"
type="text"
placeholder={at("appearance_theme_accent_placeholder", {}, "Не задан")}
@@ -693,17 +690,17 @@
"Логотип на главной и входе"
)}</span
>
<input
<RangeInput
class="appearance-logo-scale-range"
type="range"
min="50"
max="300"
step="5"
ariaLabel={at("appearance_theme_home_logo_scale", {}, "Home logo scale")}
value={homeLogoScale(theme)}
oninput={(event) => setThemeHomeLogoScale(theme, event.currentTarget.value)}
onValueChange={(value) => setThemeHomeLogoScale(theme, value)}
/>
<span class="appearance-logo-scale-value">
<input
<Input
class="input"
type="number"
min="50"
@@ -814,7 +811,7 @@
background: color-mix(in srgb, var(--admin-surface-2) 40%, transparent);
}
.appearance-file-input {
:global(.appearance-file-input) {
display: none;
}
@@ -949,7 +946,7 @@
width: 100%;
}
.appearance-color-text {
:global(.appearance-color-text) {
min-width: 0;
}
@@ -959,7 +956,7 @@
width: 100%;
}
.appearance-logo-scale-range {
:global(.appearance-logo-scale-range) {
width: 100%;
accent-color: var(--accent);
}
@@ -971,14 +968,14 @@
color: var(--admin-text);
}
.appearance-logo-scale-value .input {
:global(.appearance-logo-scale-value .input) {
width: 70px;
min-height: 32px;
padding: 4px 8px;
font-size: 12px;
}
.admin-color.is-empty {
:global(.admin-color.is-empty) {
opacity: 0.42;
filter: grayscale(1);
}
@@ -8,7 +8,7 @@
AdminTable,
AdminTableSkeleton,
} from "$components/patterns/admin/index.js";
import { Checkbox, RadioGroup, RadioGroupItem } from "$components/ui/index.js";
import { Checkbox, FileInput, RadioGroup, RadioGroupItem } from "$components/ui/index.js";
import {
CheckCircle2,
Database,
@@ -178,10 +178,9 @@
? at("backups_uploading", {}, "Загрузка...")
: at("backups_upload", {}, "Загрузить архив")}
</AdminButton>
<input
bind:this={fileInput}
<FileInput
bind:element={fileInput}
class="backups-file-input"
type="file"
accept=".zip,application/zip"
on:change={uploadSelectedFile}
/>
@@ -362,7 +361,7 @@
align-items: center;
}
.backups-file-input {
:global(.backups-file-input) {
position: absolute;
width: 1px;
height: 1px;
@@ -1,4 +1,5 @@
<script>
import { Textarea } from "$components/ui/index.js";
import { Send } from "$components/ui/icons.js";
import { getContext } from "svelte";
import { Label } from "$components/ui/primitives.js";
@@ -31,12 +32,12 @@
<Label.Root class="admin-field-label">
<span>{at("broadcast_label_text", {}, "Текст сообщения")}</span>
<small>{at("broadcast_hint_text", {}, "Поддерживается HTML-разметка Telegram")}</small>
<textarea
<Textarea
class="admin-textarea"
rows="6"
value={broadcastText}
on:input={(e) => broadcastStore.updateField({ broadcastText: e.target.value })}
></textarea>
/>
</Label.Root>
<div style="display:flex; gap:8px; align-items:center;">
<AdminButton
+118 -8
View File
@@ -1,4 +1,5 @@
<script>
import { Input } from "$components/ui/index.js";
import { getContext, onMount } from "svelte";
import {
AdminButton,
@@ -7,9 +8,11 @@
AdminTable,
AdminTableSkeleton,
} from "$components/patterns/admin/index.js";
import { User } from "$components/ui/icons.js";
export let at;
export let fmtDate;
export let onOpenUserCard = () => {};
const logsStore = getContext("logsStore");
@@ -24,6 +27,25 @@
at("content", {}, "Контент"),
];
function userDisplay(entry, kind) {
const id = kind === "target" ? entry.target_user_id : entry.user_id;
const label = kind === "target" ? entry.target_user_label : entry.user_label;
if (label) return label;
if (kind !== "target") {
if (entry.telegram_first_name) return entry.telegram_first_name;
if (entry.telegram_username) {
const username = String(entry.telegram_username);
return username.startsWith("@") ? username : `@${username}`;
}
if (entry.email) return entry.email;
}
return id || "—";
}
function userId(entry, kind) {
return kind === "target" ? entry.target_user_id : entry.user_id;
}
onMount(() => {
logsStore.loadLogs();
});
@@ -31,7 +53,7 @@
<div class="admin-toolbar admin-toolbar-card">
<div class="admin-toolbar-search admin-toolbar-search-actions">
<input
<Input
type="search"
class="input"
placeholder={at("logs_user_filter_placeholder", {}, "Фильтр по ID пользователя")}
@@ -64,7 +86,7 @@
<AdminTableSkeleton
headers={logHeaders}
rows={10}
widths={["120px", "120px", "58px", "58px", "220px"]}
widths={["120px", "120px", "160px", "160px", "220px"]}
/>
{:else if !logs.length}
<AdminEmptyState tone="card"
@@ -88,12 +110,50 @@
<td class="admin-cell-mono" data-label={at("event", {}, "Событие")}
>{entry.event_type}</td
>
<td class="admin-cell-mono" data-label={at("user_short", {}, "User")}
>{entry.user_id || "—"}</td
>
<td class="admin-cell-mono" data-label={at("target_short", {}, "Target")}
>{entry.target_user_id || "—"}</td
>
<td class="admin-logs-user-cell" data-label={at("user_short", {}, "User")}>
{#if userId(entry, "user")}
<span class="admin-logs-user">
<AdminButton
class="admin-logs-user-btn"
variant="ghost"
size="icon"
title={at("payments_open_user", {}, "Open user card")}
aria-label={at("payments_open_user", {}, "Open user card")}
onclick={() => onOpenUserCard(userId(entry, "user"))}
>
<User size={14} />
</AdminButton>
<span class="admin-logs-user-meta">
<span class="admin-logs-user-name">{userDisplay(entry, "user")}</span>
<span class="admin-logs-user-id">ID {userId(entry, "user")}</span>
</span>
</span>
{:else}
<span class="admin-muted"></span>
{/if}
</td>
<td class="admin-logs-user-cell" data-label={at("target_short", {}, "Target")}>
{#if userId(entry, "target")}
<span class="admin-logs-user">
<AdminButton
class="admin-logs-user-btn"
variant="ghost"
size="icon"
title={at("payments_open_user", {}, "Open user card")}
aria-label={at("payments_open_user", {}, "Open user card")}
onclick={() => onOpenUserCard(userId(entry, "target"))}
>
<User size={14} />
</AdminButton>
<span class="admin-logs-user-meta">
<span class="admin-logs-user-name">{userDisplay(entry, "target")}</span>
<span class="admin-logs-user-id">ID {userId(entry, "target")}</span>
</span>
</span>
{:else}
<span class="admin-muted"></span>
{/if}
</td>
<td class="admin-cell-wrap" data-label={at("content", {}, "Контент")}
>{entry.content || ""}</td
>
@@ -117,3 +177,53 @@
logsStore.setPage(logsPage + 1);
}}
/>
<style>
.admin-logs-user-cell {
min-width: 150px;
}
.admin-logs-user {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.admin-logs-user-meta {
display: grid;
gap: 2px;
min-width: 0;
}
.admin-logs-user-name {
min-width: 0;
overflow: hidden;
color: var(--admin-text);
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-logs-user-id {
color: var(--admin-dim);
font-family: var(--font-mono);
font-size: 11px;
line-height: 1.2;
white-space: nowrap;
}
.admin-logs-user-cell :global(.admin-logs-user-btn.admin-btn) {
width: 30px;
height: 30px;
min-width: 30px;
min-height: 30px;
flex-shrink: 0;
padding: 0;
border-radius: 7px;
}
.admin-logs-user-cell :global(.admin-logs-user-btn svg) {
width: 14px;
height: 14px;
}
</style>
@@ -1,4 +1,5 @@
<script>
import { Input } from "$components/ui/index.js";
import { Trash2 } from "$components/ui/icons.js";
import { getContext, onMount } from "svelte";
import Dialog from "$components/ui/dialog.svelte";
@@ -110,7 +111,7 @@
<div class="admin-form" data-dialog-content>
<div class="admin-dialog-form-section">
<AdminField label={at("promo_label_code", {}, "Код")}>
<input
<Input
type="text"
class="input"
value={promoDraft.code}
@@ -122,7 +123,7 @@
<div class="admin-dialog-form-section">
<div class="admin-form-row-2">
<AdminField label={at("promo_label_bonus_days", {}, "Бонус (дней)")}>
<input
<Input
type="number"
class="input"
min="1"
@@ -131,7 +132,7 @@
/>
</AdminField>
<AdminField label={at("promo_label_max_activations", {}, "Макс. активаций")}>
<input
<Input
type="number"
class="input"
min="1"
@@ -141,7 +142,7 @@
</AdminField>
</div>
<AdminField label={at("promo_label_valid_days", {}, "Срок действия (дней от текущего)")}>
<input
<Input
type="number"
class="input"
min="1"
@@ -1,4 +1,5 @@
<script>
import { ColorInput, FileInput, Input, ScrollArea, Textarea } from "$components/ui/index.js";
import {
Check,
ChevronRight,
@@ -198,6 +199,8 @@
url,
requiresBaseUrl: Boolean(field.webhook_requires_base_url),
baseConfigured: field.webhook_base_url_configured !== false,
hintI18nKey: field.webhook_hint_i18n_key || "",
hintFallback: field.webhook_hint || "",
};
}
@@ -333,6 +336,7 @@
function sectionTitle(id) {
const map = {
general: "Общие",
remnawave: "Remnawave Panel",
appearance: "Внешний вид",
pricing: "Тарифы и цены",
payments: "Платёжные системы",
@@ -415,9 +419,9 @@
<small>
{webhook.url
? at(
"settings_provider_webhook_url_hint",
adminLocaleKey(webhook.hintI18nKey || "settings_provider_webhook_url_hint"),
{},
"Use this URL in the provider webhook settings."
webhook.hintFallback || "Use this URL in the provider webhook settings."
)
: at(
"settings_provider_webhook_base_missing",
@@ -512,13 +516,13 @@
>
</div>
{:else if field.type === "color"}
<input
<ColorInput
class="admin-color"
type="color"
value={valueFor(field) || "#00fe7a"}
ariaLabel={fieldLabelText(field)}
oninput={(e) => settingsStore.markDirty(field.key, e.currentTarget.value)}
/>
<input
<Input
class="input"
type="text"
value={valueFor(field) || ""}
@@ -557,7 +561,7 @@
onValueChange={(value) => settingsStore.markDirty(field.key, value)}
/>
{:else if field.type === "int" || field.type === "float"}
<input
<Input
class="input"
type="number"
step={field.type === "float" ? "0.1" : "1"}
@@ -568,19 +572,18 @@
oninput={(e) => settingsStore.markDirty(field.key, e.currentTarget.value)}
/>
{:else if field.type === "text"}
<textarea
<Textarea
class="admin-setting-textarea"
rows="4"
placeholder={fieldPlaceholderText(field)}
value={valueFor(field) ?? ""}
oninput={(e) => settingsStore.markDirty(field.key, e.currentTarget.value)}
></textarea>
/>
{:else if field.type === "json"}
<div class="admin-json-toolbar">
<input
<FileInput
id={"json-file-" + field.key}
class="admin-json-file-input"
type="file"
accept="application/json,.json"
onchange={(event) => handleJsonFile(field, event)}
/>
@@ -602,16 +605,16 @@
</AdminButton>
{/if}
</div>
<textarea
<Textarea
class="admin-setting-textarea admin-setting-json-textarea"
rows="10"
spellcheck="false"
placeholder={fieldPlaceholderText(field)}
value={valueFor(field) ?? ""}
oninput={(e) => settingsStore.markDirty(field.key, e.currentTarget.value)}
></textarea>
/>
{:else if field.secret}
<input
<Input
class="input"
type={revealed ? "text" : "password"}
placeholder={secretPlaceholder(field)}
@@ -628,7 +631,7 @@
{#if revealed}<EyeOff size={13} />{:else}<Eye size={13} />{/if}
</AdminButton>
{:else}
<input
<Input
class="input"
type="text"
placeholder={fieldPlaceholderText(field)}
@@ -813,7 +816,7 @@
<div class="admin-icon-picker-toolbar">
<label class="admin-icon-picker-search">
<Search size={15} />
<input
<Input
bind:value={iconPickerSearch}
class="input"
type="text"
@@ -821,21 +824,23 @@
/>
</label>
</div>
<div class="admin-icon-picker-grid">
{#each filteredIconOptions as iconName}
{@const Icon = iconComponent(iconName)}
<button
class:active={iconPickerField && iconValue(iconPickerField) === iconName}
class="admin-icon-picker-option"
type="button"
onclick={() => selectIcon(iconName)}
>
{#if Icon}
<svelte:component this={Icon} size={18} />
{/if}
<span>{iconName}</span>
</button>
{/each}
</div>
<ScrollArea class="admin-icon-picker-scroll" maxHeight="min(52vh, 460px)">
<div class="admin-icon-picker-grid">
{#each filteredIconOptions as iconName}
{@const Icon = iconComponent(iconName)}
<button
class:active={iconPickerField && iconValue(iconPickerField) === iconName}
class="admin-icon-picker-option"
type="button"
onclick={() => selectIcon(iconName)}
>
{#if Icon}
<svelte:component this={Icon} size={18} />
{/if}
<span>{iconName}</span>
</button>
{/each}
</div>
</ScrollArea>
</div>
</Dialog>
+33 -28
View File
@@ -4,6 +4,7 @@
import { fmtTrafficBytes } from "../../lib/admin/format.js";
import Badge from "$components/ui/badge.svelte";
import { ScrollArea } from "$components/ui/index.js";
import * as Card from "$components/ui/card/index.js";
import {
AdminDashboardGrid,
@@ -606,21 +607,23 @@
style="width:78%;height:9px;margin-top:6px"
></span>
</div>
<div class="admin-panel-dash-nodes-grid">
{#each Array(4) as _, m (m)}
<div class="admin-panel-dash-node">
<span class="admin-skeleton admin-skeleton-line" style="width:82%"></span>
<span
class="admin-skeleton admin-skeleton-line admin-skeleton-line-strong"
style="width:52%;height:16px;margin-top:6px"
></span>
<span
class="admin-skeleton admin-skeleton-line admin-skeleton-line-tiny"
style="width:44%;margin-top:6px"
></span>
</div>
{/each}
</div>
<ScrollArea class="admin-panel-dash-nodes-scroll" maxHeight="240px">
<div class="admin-panel-dash-nodes-grid">
{#each Array(4) as _, m (m)}
<div class="admin-panel-dash-node">
<span class="admin-skeleton admin-skeleton-line" style="width:82%"></span>
<span
class="admin-skeleton admin-skeleton-line admin-skeleton-line-strong"
style="width:52%;height:16px;margin-top:6px"
></span>
<span
class="admin-skeleton admin-skeleton-line admin-skeleton-line-tiny"
style="width:44%;margin-top:6px"
></span>
</div>
{/each}
</div>
</ScrollArea>
</div>
</div>
</Card.Content>
@@ -1018,19 +1021,21 @@
{at("stats_panel_inner_nodes_hint", {}, "")}
</p>
</div>
<div class="admin-panel-dash-nodes-grid">
{#each panelNodeTraffic.seven.slice(0, PANEL_NODE_TILE_LIMIT) as node}
<div class="admin-panel-dash-node">
<div class="admin-panel-dash-node-name">{node.label}</div>
<div class="admin-panel-dash-node-value">{node.value}</div>
{#if node.online != null}
<div class="admin-panel-dash-node-meta">
{at("stats_panel_node_users_online", { count: node.online }, "")}
</div>
{/if}
</div>
{/each}
</div>
<ScrollArea class="admin-panel-dash-nodes-scroll" maxHeight="240px">
<div class="admin-panel-dash-nodes-grid">
{#each panelNodeTraffic.seven.slice(0, PANEL_NODE_TILE_LIMIT) as node}
<div class="admin-panel-dash-node">
<div class="admin-panel-dash-node-name">{node.label}</div>
<div class="admin-panel-dash-node-value">{node.value}</div>
{#if node.online != null}
<div class="admin-panel-dash-node-meta">
{at("stats_panel_node_users_online", { count: node.online }, "")}
</div>
{/if}
</div>
{/each}
</div>
</ScrollArea>
{#if panelNodeTraffic.seven.length > PANEL_NODE_TILE_LIMIT}
<p class="admin-panel-dash-nodes-more">
{at(
@@ -11,7 +11,7 @@
import { TicketMessageBubble } from "$components/patterns/webapp/index.js";
import Dialog from "$components/ui/dialog.svelte";
import { Search } from "$components/ui/icons.js";
import { ScrollArea, Skeleton } from "$components/ui/index.js";
import { Input, ScrollArea, Skeleton } from "$components/ui/index.js";
export let at = (key) => key;
export let initialTicketId = null;
@@ -215,7 +215,7 @@
<div class="support-admin-toolbar admin-toolbar-card">
<label class="support-admin-search">
<Search size={16} />
<input
<Input
class="input"
type="search"
placeholder={at("support_search", {}, "Поиск")}
@@ -269,16 +269,18 @@
{:else if !tickets.length}
<div class="admin-empty-state">{at("support_empty", {}, "Тикетов пока нет")}</div>
{:else}
<div class="support-inbox-list">
{#each tickets as ticket}
<SupportInboxRow
{ticket}
active={openedTicketId === ticket.ticket_id}
{at}
onOpen={(item) => supportStore.openTicket(item.ticket_id)}
/>
{/each}
</div>
<ScrollArea class="support-inbox-list" maxHeight="none">
<div class="support-inbox-list-inner">
{#each tickets as ticket}
<SupportInboxRow
{ticket}
active={openedTicketId === ticket.ticket_id}
{at}
onOpen={(item) => supportStore.openTicket(item.ticket_id)}
/>
{/each}
</div>
</ScrollArea>
{/if}
</section>
</div>
@@ -1,4 +1,5 @@
<script>
import { Input } from "$components/ui/index.js";
import { Tabs, Switch, Label } from "$components/ui/primitives.js";
import Dialog from "$components/ui/dialog.svelte";
import { Plus, Save, Trash2, X } from "$components/ui/icons.js";
@@ -71,7 +72,7 @@
"Латиницей, без пробелов. Используется в платежах и подписках, менять после публикации не рекомендуется"
)}</small
>
<input
<Input
class="input"
type="text"
placeholder="standard"
@@ -127,7 +128,7 @@
<div class="admin-form-row admin-form-row-2">
<Label.Root class="admin-field-label">
<span>{at("tariff_label_name_ru", {}, "Название · RU")}</span>
<input
<Input
class="input"
type="text"
placeholder={at("tariff_placeholder_name_ru", {}, "Стандарт")}
@@ -136,7 +137,7 @@
</Label.Root>
<Label.Root class="admin-field-label">
<span>{at("tariff_label_name_en", {}, "Название · EN")}</span>
<input
<Input
class="input"
type="text"
placeholder={at("tariff_placeholder_name_en", {}, "Standard")}
@@ -148,7 +149,7 @@
<div class="admin-form-row admin-form-row-2">
<Label.Root class="admin-field-label">
<span>{at("tariff_label_desc_ru", {}, "Описание · RU")}</span>
<input
<Input
class="input"
type="text"
placeholder={at("tariff_placeholder_desc_ru", {}, "Базовый набор серверов")}
@@ -157,7 +158,7 @@
</Label.Root>
<Label.Root class="admin-field-label">
<span>{at("tariff_label_desc_en", {}, "Описание · EN")}</span>
<input
<Input
class="input"
type="text"
placeholder={at("tariff_placeholder_desc_en", {}, "Base server pool")}
@@ -211,7 +212,7 @@
"Сколько устройств может одновременно использовать подписку. Пусто — взять значение из .env, 0 — без ограничений"
)}</small
>
<input
<Input
class="input"
type="number"
min="0"
@@ -229,7 +230,7 @@
"Сколько GB включено в тариф на каждый месяц. 0 — безлимитный трафика. Сверху можно докупать пакеты на вкладке «Докупки»"
)}</small
>
<input
<Input
class="input"
type="number"
min="0"
@@ -248,7 +249,7 @@
"По этому курсу остаток подписки пересчитывается в гигабайты при переходе пользователя с тарифа «Период» на «Трафик»"
)}</small
>
<input
<Input
class="input"
type="number"
min="0"
@@ -287,7 +288,7 @@
"Эта строка заменит «Premium-серверы» в кабинете, докупках и карточках лимитов."
)}</small
>
<input
<Input
class="input"
type="text"
placeholder={at("tariff_placeholder_premium_name_ru", {}, "Premium-серверы")}
@@ -303,7 +304,7 @@
"Опционально для английского интерфейса."
)}</small
>
<input
<Input
class="input"
type="text"
placeholder={at("tariff_placeholder_premium_name_en", {}, "Premium servers")}
@@ -359,7 +360,7 @@
"Сколько GB через premium-сквады включено в тариф каждый месяц. 0 или пусто — отдельного premium-лимита нет (premium-нодами можно пользоваться без ограничения)"
)}</small
>
<input
<Input
class="input"
type="number"
min="0"
@@ -409,7 +410,7 @@
{/if}
{#each tariffDraft.premiumTopupRubRows as row, index}
<div class="admin-row-editor-line">
<input
<Input
class="input"
type="number"
min="0.1"
@@ -418,7 +419,7 @@
bind:value={row.gb}
aria-label={at("tariff_col_volume_gb", {}, "Объём premium-пакета в GB")}
/>
<input
<Input
class="input"
type="number"
min="0"
@@ -449,7 +450,7 @@
{/if}
{#each tariffDraft.premiumTopupStarsRows as row, index}
<div class="admin-row-editor-line">
<input
<Input
class="input"
type="number"
min="0.1"
@@ -458,7 +459,7 @@
bind:value={row.gb}
aria-label={at("tariff_col_volume_gb", {}, "Объём premium-пакета в GB")}
/>
<input
<Input
class="input"
type="number"
min="0"
@@ -501,7 +502,13 @@
<AdminButton
size="sm"
onclick={() =>
tariffsStore.addDraftRow("periodRows", { months: 1, rub: "", stars: "" })}
tariffsStore.addDraftRow("periodRows", {
months: 1,
rub: "",
stars: "",
referral_inviter: "",
referral_referee: "",
})}
>
<Plus size={13} />
{at("tariff_btn_period", {}, "Период")}
@@ -517,15 +524,17 @@
</p>
{:else}
<div class="admin-row-editor">
<div class="admin-row-editor-line admin-row-editor-4 admin-row-editor-header">
<div class="admin-row-editor-line admin-row-editor-6 admin-row-editor-header">
<span>{at("tariff_col_period_months", {}, "Срок, мес.")}</span>
<span>{at("tariff_col_price_rub", {}, "Цена, ₽")}</span>
<span>{at("tariff_col_price_stars_full", {}, "Цена, ⭐ Stars")}</span>
<span>{at("tariff_col_ref_inviter", {}, "Бонус приглашающему")}</span>
<span>{at("tariff_col_ref_referee", {}, "Бонус приглашённому")}</span>
<span></span>
</div>
{#each tariffDraft.periodRows as row, index}
<div class="admin-row-editor-line admin-row-editor-4">
<input
<div class="admin-row-editor-line admin-row-editor-6">
<Input
class="input"
type="number"
min="1"
@@ -533,7 +542,7 @@
bind:value={row.months}
aria-label={at("tariff_col_period_months", {}, "Срок (месяцы)")}
/>
<input
<Input
class="input"
type="number"
min="0"
@@ -542,7 +551,7 @@
bind:value={row.rub}
aria-label={at("tariff_label_price_rub", {}, "Цена в рублях")}
/>
<input
<Input
class="input"
type="number"
min="0"
@@ -551,6 +560,24 @@
bind:value={row.stars}
aria-label={at("tariff_label_price_stars", {}, "Цена в Telegram Stars")}
/>
<Input
class="input"
type="number"
min="0"
step="1"
placeholder="3"
bind:value={row.referral_inviter}
aria-label={at("tariff_label_ref_inviter", {}, "Бонус приглашающему")}
/>
<Input
class="input"
type="number"
min="0"
step="1"
placeholder="1"
bind:value={row.referral_referee}
aria-label={at("tariff_label_ref_referee", {}, "Бонус приглашённому")}
/>
<AdminButton
size="sm"
variant="danger"
@@ -603,7 +630,7 @@
{/if}
{#each tariffDraft.trafficRubRows as row, index}
<div class="admin-row-editor-line">
<input
<Input
class="input"
type="number"
min="0.1"
@@ -612,7 +639,7 @@
bind:value={row.gb}
aria-label={at("tariff_col_volume_gb", {}, "Объём пакета в GB")}
/>
<input
<Input
class="input"
type="number"
min="0"
@@ -643,7 +670,7 @@
{/if}
{#each tariffDraft.trafficStarsRows as row, index}
<div class="admin-row-editor-line">
<input
<Input
class="input"
type="number"
min="0.1"
@@ -652,7 +679,7 @@
bind:value={row.gb}
aria-label={at("tariff_col_volume_gb", {}, "Объём пакета в GB")}
/>
<input
<Input
class="input"
type="number"
min="0"
@@ -717,7 +744,7 @@
{/if}
{#each tariffDraft.topupRubRows as row, index}
<div class="admin-row-editor-line">
<input
<Input
class="input"
type="number"
min="0.1"
@@ -726,7 +753,7 @@
bind:value={row.gb}
aria-label={at("tariff_col_volume_gb", {}, "Объём пакета в GB")}
/>
<input
<Input
class="input"
type="number"
min="0"
@@ -757,7 +784,7 @@
{/if}
{#each tariffDraft.topupStarsRows as row, index}
<div class="admin-row-editor-line">
<input
<Input
class="input"
type="number"
min="0.1"
@@ -766,7 +793,7 @@
bind:value={row.gb}
aria-label={at("tariff_col_volume_gb", {}, "Объём пакета в GB")}
/>
<input
<Input
class="input"
type="number"
min="0"
@@ -841,7 +868,7 @@
{/if}
{#each tariffDraft.hwidRubRows as row, index}
<div class="admin-row-editor-line">
<input
<Input
class="input"
type="number"
min="1"
@@ -854,7 +881,7 @@
"Сколько устройств добавляет пакет"
)}
/>
<input
<Input
class="input"
type="number"
min="0"
@@ -885,7 +912,7 @@
{/if}
{#each tariffDraft.hwidStarsRows as row, index}
<div class="admin-row-editor-line">
<input
<Input
class="input"
type="number"
min="1"
@@ -898,7 +925,7 @@
"Сколько устройств добавляет пакет"
)}
/>
<input
<Input
class="input"
type="number"
min="0"
@@ -1,4 +1,5 @@
<script>
import { Input } from "$components/ui/index.js";
import {
ChevronRight,
RefreshCw,
@@ -36,10 +37,38 @@
const TRIAL_RESET_KEYS = ["TRIAL_TRAFFIC_STRATEGY"];
const TRIAL_SQUAD_KEYS = ["TRIAL_SQUAD_UUIDS"];
const LEGACY_PERIODS = [
["1", "MONTH_1_ENABLED", "RUB_PRICE_1_MONTH", "STARS_PRICE_1_MONTH"],
["3", "MONTH_3_ENABLED", "RUB_PRICE_3_MONTHS", "STARS_PRICE_3_MONTHS"],
["6", "MONTH_6_ENABLED", "RUB_PRICE_6_MONTHS", "STARS_PRICE_6_MONTHS"],
["12", "MONTH_12_ENABLED", "RUB_PRICE_12_MONTHS", "STARS_PRICE_12_MONTHS"],
[
"1",
"MONTH_1_ENABLED",
"RUB_PRICE_1_MONTH",
"STARS_PRICE_1_MONTH",
"REFERRAL_BONUS_DAYS_INVITER_1_MONTH",
"REFERRAL_BONUS_DAYS_REFEREE_1_MONTH",
],
[
"3",
"MONTH_3_ENABLED",
"RUB_PRICE_3_MONTHS",
"STARS_PRICE_3_MONTHS",
"REFERRAL_BONUS_DAYS_INVITER_3_MONTHS",
"REFERRAL_BONUS_DAYS_REFEREE_3_MONTHS",
],
[
"6",
"MONTH_6_ENABLED",
"RUB_PRICE_6_MONTHS",
"STARS_PRICE_6_MONTHS",
"REFERRAL_BONUS_DAYS_INVITER_6_MONTHS",
"REFERRAL_BONUS_DAYS_REFEREE_6_MONTHS",
],
[
"12",
"MONTH_12_ENABLED",
"RUB_PRICE_12_MONTHS",
"STARS_PRICE_12_MONTHS",
"REFERRAL_BONUS_DAYS_INVITER_12_MONTHS",
"REFERRAL_BONUS_DAYS_REFEREE_12_MONTHS",
],
];
const LEGACY_TARIFF_SETTING_KEYS = [
...LEGACY_PERIODS.flatMap((row) => row.slice(1)),
@@ -375,7 +404,7 @@
<code>TRIAL_DURATION_DAYS</code>
</div>
<div class="admin-setting-control">
<input
<Input
class="input"
type="number"
min="0"
@@ -411,7 +440,7 @@
<code>TRIAL_TRAFFIC_LIMIT_GB</code>
</div>
<div class="admin-setting-control">
<input
<Input
class="input"
type="number"
min="0"
@@ -563,7 +592,7 @@
onValueChange={handleTrialSquadSelect}
/>
{/key}
<input
<Input
class="input"
type="text"
placeholder={valueForKey("USER_SQUAD_UUIDS", settingsDirty, settingsFieldMap) ||
@@ -794,8 +823,10 @@
<span>{at("tariffs_legacy_enabled", {}, "Enabled")}</span>
<span>{at("payment_rub", {}, "RUB")}</span>
<span>{at("payment_stars", {}, "Stars")}</span>
<span>{at("tariffs_legacy_ref_inviter", {}, "Inviter")}</span>
<span>{at("tariffs_legacy_ref_referee", {}, "Friend")}</span>
</div>
{#each LEGACY_PERIODS as [months, enabledKey, rubKey, starsKey]}
{#each LEGACY_PERIODS as [months, enabledKey, rubKey, starsKey, inviterKey, refereeKey]}
<div class="admin-legacy-tariff-row">
<strong>{months} {at("months_short", {}, "mo")}</strong>
<div class="admin-setting-switch">
@@ -807,7 +838,7 @@
<Switch.Thumb class="admin-switch-thumb" />
</Switch.Root>
</div>
<input
<Input
class="input"
type="number"
min="0"
@@ -815,7 +846,7 @@
value={valueForKey(rubKey, settingsDirty, settingsFieldMap)}
oninput={(event) => setSetting(rubKey, event.currentTarget.value)}
/>
<input
<Input
class="input"
type="number"
min="0"
@@ -823,6 +854,22 @@
value={valueForKey(starsKey, settingsDirty, settingsFieldMap)}
oninput={(event) => setSetting(starsKey, event.currentTarget.value)}
/>
<Input
class="input"
type="number"
min="0"
step="1"
value={valueForKey(inviterKey, settingsDirty, settingsFieldMap)}
oninput={(event) => setSetting(inviterKey, event.currentTarget.value)}
/>
<Input
class="input"
type="number"
min="0"
step="1"
value={valueForKey(refereeKey, settingsDirty, settingsFieldMap)}
oninput={(event) => setSetting(refereeKey, event.currentTarget.value)}
/>
</div>
{/each}
</div>
@@ -831,7 +878,7 @@
<label class="admin-field-label admin-field-label-compact">
<span>{at("tariffs_legacy_traffic_packages", {}, "Traffic packages")}</span>
<small>{at("tariffs_legacy_traffic_hint", {}, "Format: 10:199,50:799")}</small>
<input
<Input
class="input"
type="text"
value={valueForKey("TRAFFIC_PACKAGES", settingsDirty, settingsFieldMap)}
@@ -843,7 +890,7 @@
>{at("tariffs_legacy_stars_traffic_packages", {}, "Traffic packages, Stars")}</span
>
<small>{at("tariffs_legacy_traffic_hint", {}, "Format: 10:199,50:799")}</small>
<input
<Input
class="input"
type="text"
value={valueForKey("STARS_TRAFFIC_PACKAGES", settingsDirty, settingsFieldMap)}
@@ -1,4 +1,5 @@
<script>
import { Input, Textarea } from "$components/ui/index.js";
import { ChevronRight, Languages, Plus, Search, X } from "$components/ui/icons.js";
import { AdminBadge, AdminButton, AdminEmptyState } from "$components/patterns/admin/index.js";
import { getContext, onDestroy, onMount } from "svelte";
@@ -396,7 +397,7 @@
{#if expanded}
<div class="admin-translation-locale-body" transition:slide={{ duration: 130 }}>
<textarea
<Textarea
class="admin-setting-textarea admin-translation-textarea"
rows="3"
spellcheck="false"
@@ -404,7 +405,7 @@
value={localeValue(item, lang, dirtyEntry)}
oninput={(event) =>
translationsStore.markDirty(lang, item.key, event.currentTarget.value)}
></textarea>
/>
<div class="admin-translation-base">
<small>{baseKind(item, lang)}</small>
<span title={baseValue(item, lang)}>{baseValue(item, lang) || "-"}</span>
@@ -457,7 +458,7 @@
<div class="admin-translations-toolbar">
<label class="admin-translations-search">
<Search size={15} />
<input
<Input
bind:value={search}
class="input"
type="text"
@@ -502,7 +503,7 @@
addLanguage();
}}
>
<input
<Input
bind:value={newLanguageCode}
class="input"
type="text"
@@ -1,6 +1,6 @@
<script>
import { Label, Separator, Tabs } from "$components/ui/primitives.js";
import { Checkbox } from "$components/ui/index.js";
import { Checkbox, Input, ScrollArea, Textarea } from "$components/ui/index.js";
import Dialog from "$components/ui/dialog.svelte";
import {
AdminBadge,
@@ -81,11 +81,7 @@
$: openedUserTelegramProfileLinkKind = openedUser ? userTelegramProfileLinkKind(openedUser) : "";
$: openedUserTelegramProfileHint =
openedUserTelegramProfileLinkKind === "id"
? at(
"user_open_tg_profile_id_hint",
{},
"Профиль будет открыт по Telegram ID. Telegram может заблокировать переход из-за настроек приватности пользователя или ограничений клиента."
)
? at("user_open_tg_profile_id_hint", {}, "Бот отправит кнопку профиля в Telegram")
: at("user_open_tg_profile_hint", {}, "Открыть профиль Telegram");
$: if (openedUser && userDetailTab === "logs" && !userLogsLoading && !userLogsLoaded) {
@@ -189,11 +185,6 @@
{at("user_open_tg_profile", {}, "Открыть Telegram")}
</AdminButton>
</div>
{#if openedUserTelegramProfileLinkKind === "id"}
<small class="admin-user-telegram-profile-note"
>{openedUserTelegramProfileHint}</small
>
{/if}
</div>
</div>
@@ -552,7 +543,7 @@
</div>
</div>
<div class="admin-user-logs-wrap">
<ScrollArea class="admin-user-logs-wrap" maxHeight="min(52vh, 460px)">
{#if userLogsLoading}
<AdminTableSkeleton
headers={[
@@ -604,7 +595,7 @@
</tbody>
</AdminTable>
{/if}
</div>
</ScrollArea>
{#if userLogsLoaded && userLogsTotal > userLogsPageSize}
<AdminPagination
@@ -632,7 +623,7 @@
<Label.Root class="admin-field-label admin-extend-field">
<span>{at("user_label_extend", {}, "Продлить подписку")}</span>
<div class="admin-extend-control">
<input
<Input
class="input"
type="number"
min="1"
@@ -662,7 +653,7 @@
<span>{at("user_premium_override_bonus", {}, "Доп. премиум-трафик, GB")}</span
>
<small>{at("user_premium_override_bonus_hint", {}, "")}</small>
<input
<Input
class="input"
type="number"
min="0"
@@ -741,7 +732,7 @@
>{at("user_regular_override_bonus", {}, "Доп. основной трафик, GB")}</span
>
<small>{at("user_regular_override_bonus_hint", {}, "")}</small>
<input
<Input
class="input"
type="number"
min="0"
@@ -837,7 +828,7 @@
<Label.Root class="admin-field-label admin-extend-field">
<span>{at("user_traffic_grant_gb", {}, "ГБ к выдаче")}</span>
<div class="admin-extend-control">
<input
<Input
class="input"
type="number"
min="0"
@@ -869,12 +860,12 @@
"Поддерживается HTML-разметка Telegram"
)}</small
>
<textarea
<Textarea
class="admin-textarea"
rows="3"
placeholder={at("user_placeholder_msg", {}, "Текст сообщения")}
bind:value={$usersStore.userMessageDraft}
></textarea>
/>
</Label.Root>
<div class="admin-message-actions">
<AdminButton
@@ -976,7 +967,9 @@
onclose={() => usersStore.updateState({ userMessageConfirmOpen: false })}
class="admin-dialog"
>
<div class="admin-confirm-message-preview">{userMessageDraft}</div>
<ScrollArea class="admin-confirm-message-preview" maxHeight="min(280px, 45vh)">
{userMessageDraft}
</ScrollArea>
<div class="admin-dialog-actions">
<AdminButton onclick={() => usersStore.updateState({ userMessageConfirmOpen: false })}
>{at("btn_cancel", {}, "Отмена")}</AdminButton
@@ -1166,12 +1159,6 @@
gap: 8px;
margin-top: 6px;
}
.admin-user-telegram-profile-note {
display: block;
margin-top: 2px;
color: var(--admin-dim);
line-height: 1.35;
}
:global(.admin-avatar-dialog) {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
@@ -1230,7 +1217,7 @@
gap: 8px;
font-size: 13px;
}
.admin-user-logs-wrap {
:global(.admin-user-logs-wrap) {
min-height: 120px;
}
.admin-user-log-event {
@@ -1,4 +1,5 @@
<script>
import { Input } from "$components/ui/index.js";
import { Label } from "$components/ui/primitives.js";
import {
AdminBadge,
@@ -107,7 +108,7 @@
<div class="admin-toolbar admin-toolbar-users">
<div class="admin-toolbar-search">
<input
<Input
type="search"
class="input"
placeholder={at("users_search_placeholder", {}, "ID, @username или email")}
+18 -4
View File
@@ -18,10 +18,10 @@ export function emptyTariffDraft() {
hwid_device_limit: "",
conversion_rate_rub_per_gb: "",
periodRows: [
{ months: 1, rub: 150, stars: "" },
{ months: 3, rub: 400, stars: "" },
{ months: 6, rub: 750, stars: "" },
{ months: 12, rub: 1400, stars: "" },
{ months: 1, rub: 200, stars: "", referral_inviter: 3, referral_referee: 1 },
{ months: 3, rub: 600, stars: "", referral_inviter: 7, referral_referee: 3 },
{ months: 6, rub: 1200, stars: "", referral_inviter: 15, referral_referee: 7 },
{ months: 12, rub: 2400, stars: "", referral_inviter: 30, referral_referee: 15 },
],
topupRubRows: [],
topupStarsRows: [],
@@ -67,6 +67,8 @@ export function draftFromTariff(tariff) {
months: month,
rub: tariff.prices_rub?.[String(month)] ?? "",
stars: tariff.prices_stars?.[String(month)] ?? "",
referral_inviter: tariff.referral_bonus_days_inviter?.[String(month)] ?? "",
referral_referee: tariff.referral_bonus_days_referee?.[String(month)] ?? "",
}));
return {
@@ -190,6 +192,8 @@ export function tariffFromDraft(draft) {
months: parseIntNumber(row.months),
rub: parseNumber(row.rub, 0),
stars: parseNumber(row.stars, 0),
referral_inviter: parseIntNumber(row.referral_inviter),
referral_referee: parseIntNumber(row.referral_referee),
}))
.filter((row) => row.months > 0)
.filter((row) => {
@@ -204,6 +208,16 @@ export function tariffFromDraft(draft) {
tariff.prices_stars = Object.fromEntries(
rows.map((row) => [String(row.months), row.stars || 0])
);
tariff.referral_bonus_days_inviter = Object.fromEntries(
rows
.filter((row) => row.referral_inviter !== null)
.map((row) => [String(row.months), row.referral_inviter])
);
tariff.referral_bonus_days_referee = Object.fromEntries(
rows
.filter((row) => row.referral_referee !== null)
.map((row) => [String(row.months), row.referral_referee])
);
const topupPackages = packageSetFromRows(draft.topupRubRows, draft.topupStarsRows, "gb");
if (topupPackages) tariff.topup_packages = topupPackages;
} else {
@@ -30,12 +30,14 @@
</Select.Trigger>
<Select.Portal>
<Select.Content class="admin-select-content" {sideOffset}>
{#each items as item (item.value)}
<Select.Item value={item.value} label={item.label} class="admin-select-item">
<span>{item.label}</span>
<Check size={14} class="admin-select-item-check" />
</Select.Item>
{/each}
<Select.Viewport class="admin-select-viewport">
{#each items as item (item.value)}
<Select.Item value={item.value} label={item.label} class="admin-select-item">
<span>{item.label}</span>
<Check size={14} class="admin-select-item-check" />
</Select.Item>
{/each}
</Select.Viewport>
</Select.Content>
</Select.Portal>
</Select.Root>
@@ -0,0 +1,20 @@
<script>
import { cn } from "$lib/utils.js";
export let value = "#000000";
export let disabled = false;
export let ariaLabel = "";
let className = "";
export { className as class };
</script>
<input
bind:value
class={cn("ui-color-input", className)}
type="color"
{disabled}
aria-label={ariaLabel}
on:input
on:change
{...$$restProps}
/>
+4 -1
View File
@@ -5,6 +5,7 @@
import { onMount } from "svelte";
import { fade, fly } from "svelte/transition";
import Button from "./button.svelte";
import ScrollArea from "./scroll-area.svelte";
export let open = false;
export let title = "";
@@ -65,7 +66,9 @@
<X size={18} />
</Button>
</div>
<slot />
<ScrollArea class="dialog-body-scroll" maxHeight="none">
<slot />
</ScrollArea>
</section>
</div>
{/if}
@@ -0,0 +1,19 @@
<script>
import { cn } from "$lib/utils.js";
export let element = null;
export let accept = undefined;
export let disabled = false;
let className = "";
export { className as class };
</script>
<input
bind:this={element}
class={cn("ui-file-input", className)}
type="file"
{accept}
{disabled}
on:change
{...$$restProps}
/>
+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 Button } from "./button.svelte";
export { default as Checkbox } from "./checkbox.svelte";
export { default as ColorInput } from "./color-input.svelte";
export { default as Dialog } from "./dialog.svelte";
export { default as FileInput } from "./file-input.svelte";
export { default as Input } from "./input.svelte";
export { default as LegacyCard } from "./card.svelte";
export { default as RadioGroup } from "./radio-group.svelte";
export { default as RadioGroupItem } from "./radio-group-item.svelte";
export { default as RangeInput } from "./range-input.svelte";
export { default as Skeleton } from "./skeleton.svelte";
export { default as Spinner } from "./spinner.svelte";
export { default as ScrollArea } from "./scroll-area.svelte";
+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>
import { cn } from "$lib/utils.js";
import { ScrollArea as ScrollAreaPrimitive } from "./primitives.js";
export let maxHeight = "100%";
export let element = null;
export let type = "auto";
let className = "";
export { className as class };
</script>
<div
bind:this={element}
class={`scroll-area ${className}`.trim()}
<ScrollAreaPrimitive.Root
class={cn("scroll-area scroll-area--mono", className)}
style={`max-height:${maxHeight};`}
{type}
{...$$restProps}
>
<slot />
</div>
<ScrollAreaPrimitive.Viewport bind:ref={element} class="scroll-area__viewport">
<slot />
</ScrollAreaPrimitive.Viewport>
<ScrollAreaPrimitive.Scrollbar class="scroll-area__scrollbar" orientation="vertical">
<ScrollAreaPrimitive.Thumb class="scroll-area__thumb" />
</ScrollAreaPrimitive.Scrollbar>
<ScrollAreaPrimitive.Scrollbar
class="scroll-area__scrollbar scroll-area__scrollbar--horizontal"
orientation="horizontal"
>
<ScrollAreaPrimitive.Thumb class="scroll-area__thumb" />
</ScrollAreaPrimitive.Scrollbar>
<ScrollAreaPrimitive.Corner class="scroll-area__corner" />
</ScrollAreaPrimitive.Root>
<style>
:global(.scroll-area) {
position: relative;
overflow: hidden;
}
:global(.scroll-area__viewport) {
width: 100%;
height: 100%;
max-height: inherit;
border-radius: inherit;
}
:global(.scroll-area__scrollbar) {
z-index: 2;
display: flex;
touch-action: none;
user-select: none;
padding: 2px;
transition: background 0.14s ease;
}
:global(.scroll-area__scrollbar[data-orientation="vertical"]) {
width: 10px;
}
:global(.scroll-area__scrollbar[data-orientation="horizontal"]) {
flex-direction: column;
height: 10px;
}
:global(.scroll-area__thumb) {
position: relative;
flex: 1;
border-radius: 999px;
background: color-mix(
in srgb,
var(--admin-muted, var(--muted)) 34%,
var(--admin-border, var(--border))
);
}
:global(.scroll-area__thumb:hover) {
background: color-mix(
in srgb,
var(--admin-muted, var(--muted)) 48%,
var(--admin-border, var(--border))
);
}
:global(.scroll-area__corner) {
background: transparent;
}
</style>
+6 -10
View File
@@ -101636,22 +101636,18 @@ export const DEMO_DATASET = {
audience: "internal",
values: {
ru: {
base: "Пользователь без username: бот отправит вам в Telegram сообщение с кнопкой открытия профиля.",
fallback:
"Пользователь без username: бот отправит вам в Telegram сообщение с кнопкой открытия профиля.",
effective:
"Пользователь без username: бот отправит вам в Telegram сообщение с кнопкой открытия профиля.",
base: "Бот отправит кнопку профиля в Telegram.",
fallback: "Бот отправит кнопку профиля в Telegram.",
effective: "Бот отправит кнопку профиля в Telegram.",
override: "",
overridden: false,
updated_at: null,
updated_by: null,
},
en: {
base: "This user has no username: the bot will send you a Telegram message with a profile button.",
fallback:
"Пользователь без username: бот отправит вам в Telegram сообщение с кнопкой открытия профиля.",
effective:
"This user has no username: the bot will send you a Telegram message with a profile button.",
base: "The bot will send a profile button in Telegram.",
fallback: "Бот отправит кнопку профиля в Telegram.",
effective: "The bot will send a profile button in Telegram.",
override: "",
overridden: false,
updated_at: null,
+45 -4
View File
@@ -103,6 +103,10 @@ function applyDemoEmailAuthUser() {
user_id: DEMO_DATASET.currentUser?.user_id || DEMO_DATASET.currentUser?.id || 910001,
telegram_id: null,
telegram_linked: false,
telegram_notifications_status: "unknown",
telegram_notifications_enabled: false,
telegram_notifications_need_prompt: false,
telegram_notifications_start_link: "https://t.me/preview_bot?start=notifications",
telegram_photo_url: "",
avatar_url: "",
username: DEMO_DATASET.currentUser?.username || "u3252a8",
@@ -181,6 +185,10 @@ function applyDemoTelegramAuthUser(authData = {}) {
user_id: adminUser.user_id || adminUser.id || 910001,
telegram_id: telegramId,
telegram_linked: true,
telegram_notifications_status: "needs_start",
telegram_notifications_enabled: false,
telegram_notifications_need_prompt: true,
telegram_notifications_start_link: "https://t.me/preview_bot?start=notifications",
username,
first_name: firstName,
last_name: lastName,
@@ -258,6 +266,10 @@ function applyDemoTelegramLink(authData = {}) {
user_id: DEV_MOCK.data.user?.user_id || DEV_MOCK.data.user?.id || 910001,
telegram_id: telegramId,
telegram_linked: true,
telegram_notifications_status: "needs_start",
telegram_notifications_enabled: false,
telegram_notifications_need_prompt: true,
telegram_notifications_start_link: "https://t.me/preview_bot?start=notifications",
username:
authData.username ||
authDemo.telegram_username ||
@@ -1689,17 +1701,25 @@ export async function mockApi(path, options = {}, context = {}) {
},
...[
["MONTH_1_ENABLED", "bool", true],
["RUB_PRICE_1_MONTH", "float", 150],
["RUB_PRICE_1_MONTH", "float", 200],
["STARS_PRICE_1_MONTH", "int", 0],
["REFERRAL_BONUS_DAYS_INVITER_1_MONTH", "int", 3],
["REFERRAL_BONUS_DAYS_REFEREE_1_MONTH", "int", 1],
["MONTH_3_ENABLED", "bool", true],
["RUB_PRICE_3_MONTHS", "float", 400],
["RUB_PRICE_3_MONTHS", "float", 600],
["STARS_PRICE_3_MONTHS", "int", 0],
["REFERRAL_BONUS_DAYS_INVITER_3_MONTHS", "int", 7],
["REFERRAL_BONUS_DAYS_REFEREE_3_MONTHS", "int", 3],
["MONTH_6_ENABLED", "bool", false],
["RUB_PRICE_6_MONTHS", "float", 750],
["RUB_PRICE_6_MONTHS", "float", 1200],
["STARS_PRICE_6_MONTHS", "int", 0],
["REFERRAL_BONUS_DAYS_INVITER_6_MONTHS", "int", 15],
["REFERRAL_BONUS_DAYS_REFEREE_6_MONTHS", "int", 7],
["MONTH_12_ENABLED", "bool", false],
["RUB_PRICE_12_MONTHS", "float", 1200],
["RUB_PRICE_12_MONTHS", "float", 2400],
["STARS_PRICE_12_MONTHS", "int", 0],
["REFERRAL_BONUS_DAYS_INVITER_12_MONTHS", "int", 30],
["REFERRAL_BONUS_DAYS_REFEREE_12_MONTHS", "int", 15],
["TRAFFIC_PACKAGES", "string", "10:99,50:399"],
["STARS_TRAFFIC_PACKAGES", "string", ""],
].map(([key, type, value]) => ({
@@ -1983,6 +2003,27 @@ export async function mockApi(path, options = {}, context = {}) {
applyDemoTelegramLink(body.auth_data || {});
return { ok: true, csrf_token: "local-preview-csrf" };
}
if (
path === "/account/telegram/notifications/probe" &&
String(options.method || "").toUpperCase() === "POST"
) {
DEV_MOCK.data.user = {
...(DEV_MOCK.data.user || {}),
telegram_notifications_status: "enabled",
telegram_notifications_enabled: true,
telegram_notifications_need_prompt: false,
telegram_notifications_start_link: "https://t.me/preview_bot?start=notifications",
};
return {
ok: true,
telegram_notifications: {
ok: true,
status: "enabled",
enabled: true,
start_link: "https://t.me/preview_bot?start=notifications",
},
};
}
if (path === "/payments" && String(options.method || "").toUpperCase() === "POST") {
const body = jsonBody(options);
if (isDeviceTopupSaleMode(body.sale_mode)) {

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