Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a66d67e44 | ||
|
|
c1e2fe2c95 | ||
|
|
ea4ee4c4a7 | ||
|
|
fbc3e193bf | ||
|
|
3541f2f78b | ||
|
|
acc222da41 | ||
|
|
8c0e778388 | ||
|
|
c6c5352813 | ||
|
|
067d6fb59f | ||
|
|
c3e55bc853 | ||
|
|
09be82aad5 | ||
|
|
49781af921 | ||
|
|
604ba873dc | ||
|
|
923ff9b208 | ||
|
|
7e7a2e58a2 | ||
|
|
e27886e15d | ||
|
|
7fe8e676cd | ||
|
|
6803c7801f | ||
|
|
cab963dcdc | ||
|
|
001e54cfe2 | ||
|
|
5e257c0d3a | ||
|
|
19f0f27a3b | ||
|
|
6fbb8eebec | ||
|
|
32616c80ab | ||
|
|
3186be1e3e | ||
|
|
1329eb4fe2 | ||
|
|
913b2d428e |
+4
-1
@@ -56,7 +56,10 @@ PANEL_API_URL=https://panel.yourdomain.tld/api
|
|||||||
PANEL_API_KEY=
|
PANEL_API_KEY=
|
||||||
|
|
||||||
# Shared secret for validating incoming Remnawave webhooks.
|
# Shared secret for validating incoming Remnawave webhooks.
|
||||||
# Use the same value when configuring the webhook in Remnawave panel.
|
# Create or set this secret in Remnawave Panel, then paste the same value here
|
||||||
|
# or into Admin -> System -> Settings -> Remnawave Panel.
|
||||||
|
# In Remnawave Panel, set WEBHOOK_URL to WEBHOOK_BASE_URL + /webhook/panel,
|
||||||
|
# for example: https://app.example.com/webhook/panel
|
||||||
PANEL_WEBHOOK_SECRET=
|
PANEL_WEBHOOK_SECRET=
|
||||||
|
|
||||||
# Host port that publishes the backend webhook server from Docker Compose.
|
# Host port that publishes the backend webhook server from Docker Compose.
|
||||||
|
|||||||
@@ -1,2 +1,8 @@
|
|||||||
|
.gitattributes text eol=lf
|
||||||
*.sh text eol=lf
|
*.sh text eol=lf
|
||||||
deploy/docker/frontend/*.sh text eol=lf
|
deploy/docker/frontend/*.sh text eol=lf
|
||||||
|
frontend/src/*.js text eol=lf
|
||||||
|
frontend/src/**/*.js text eol=lf
|
||||||
|
frontend/src/**/*.svelte text eol=lf
|
||||||
|
frontend/scripts/*.mjs text eol=lf
|
||||||
|
frontend/scripts/**/*.mjs text eol=lf
|
||||||
|
|||||||
@@ -92,11 +92,13 @@ docker compose logs -f backend worker frontend
|
|||||||
- `PANEL_API_URL`, `PANEL_API_KEY`, `PANEL_WEBHOOK_SECRET` - доступ к Remnawave;
|
- `PANEL_API_URL`, `PANEL_API_KEY`, `PANEL_WEBHOOK_SECRET` - доступ к Remnawave;
|
||||||
- остальные настройки удобнее задать в Web App админке.
|
- остальные настройки удобнее задать в Web App админке.
|
||||||
|
|
||||||
|
В Remnawave Panel укажите `WEBHOOK_URL` как публичный адрес Minishop с путем `/webhook/panel`, например `https://app.example.com/webhook/panel`. Секрет вебхука задается в самой Remnawave Panel; это же значение вставьте в `PANEL_WEBHOOK_SECRET` в `.env` или в **Система -> Настройки -> Remnawave Panel** в админке.
|
||||||
|
|
||||||
После первого входа в админку настройте тарифы, платежные провайдеры, внешний вид, поддержку, уведомления и инструкции подключения через UI. Инструкции установки включены по умолчанию, читают Subscription Page config из Remnawave Panel и при проблемах с конфигом откатываются к обычной ссылке подключения. Полный справочник env-переменных: [docs/configuration/env-vars.md](docs/configuration/env-vars.md).
|
После первого входа в админку настройте тарифы, платежные провайдеры, внешний вид, поддержку, уведомления и инструкции подключения через UI. Инструкции установки включены по умолчанию, читают Subscription Page config из Remnawave Panel и при проблемах с конфигом откатываются к обычной ссылке подключения. Полный справочник env-переменных: [docs/configuration/env-vars.md](docs/configuration/env-vars.md).
|
||||||
|
|
||||||
Для каталога тарифов используется `TARIFFS_CONFIG_PATH` со значением по умолчанию `data/tariffs.json`. Пример формата лежит в [data/tariffs.example.json](data/tariffs.example.json), подробности - в [docs/features/tariffs.md](docs/features/tariffs.md).
|
Для каталога тарифов используется `TARIFFS_CONFIG_PATH` со значением по умолчанию `data/tariffs.json`. Пример формата лежит в [data/tariffs.example.json](data/tariffs.example.json), подробности - в [docs/features/tariffs.md](docs/features/tariffs.md).
|
||||||
|
|
||||||
Если в Docker Compose включаете bind mount `./data:/app/data`, заранее создайте каталог и отдайте его пользователю контейнера. Это нужно для сохранения `data/tariffs.json`, каталога тем `data/themes`, кеша логотипа Web App и animated emoji:
|
В compose-примерах `/app/data` монтируется из папки `./data` рядом с `docker-compose.yml`. Заранее создайте каталог и отдайте его пользователю контейнера. Это нужно для сохранения `data/tariffs.json`, каталога тем `data/themes`, кеша логотипа Web App и animated emoji:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir -p data/themes data/webapp-logo data/webapp-emoji
|
mkdir -p data/themes data/webapp-logo data/webapp-emoji
|
||||||
|
|||||||
@@ -143,12 +143,18 @@ def _payment_traffic_gb_split(payment: Payment) -> Tuple[Optional[float], Option
|
|||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
def _payment_user_display_label(loaded_user: Any, payment_user_id: int) -> str:
|
def _user_display_label(
|
||||||
"""Human-facing name for payments tables: TG profile name, else email, else user id."""
|
loaded_user: Any,
|
||||||
if loaded_user is None:
|
fallback_user_id: Optional[int],
|
||||||
return str(payment_user_id)
|
*,
|
||||||
|
first_name: Optional[str] = None,
|
||||||
|
last_name: Optional[str] = None,
|
||||||
|
username: Optional[str] = None,
|
||||||
|
email: Optional[str] = None,
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""Human-facing name: TG profile name, else email, else user id."""
|
||||||
tid = getattr(loaded_user, "telegram_id", None)
|
tid = getattr(loaded_user, "telegram_id", None)
|
||||||
if tid is not None:
|
if loaded_user is not None and tid is not None:
|
||||||
fn = (getattr(loaded_user, "first_name", None) or "").strip()
|
fn = (getattr(loaded_user, "first_name", None) or "").strip()
|
||||||
ln = (getattr(loaded_user, "last_name", None) or "").strip()
|
ln = (getattr(loaded_user, "last_name", None) or "").strip()
|
||||||
full = f"{fn} {ln}".strip()
|
full = f"{fn} {ln}".strip()
|
||||||
@@ -157,10 +163,30 @@ def _payment_user_display_label(loaded_user: Any, payment_user_id: int) -> str:
|
|||||||
un = (getattr(loaded_user, "username", None) or "").strip()
|
un = (getattr(loaded_user, "username", None) or "").strip()
|
||||||
if un:
|
if un:
|
||||||
return un if un.startswith("@") else f"@{un}"
|
return un if un.startswith("@") else f"@{un}"
|
||||||
return str(payment_user_id)
|
elif loaded_user is not None:
|
||||||
email = (getattr(loaded_user, "email", None) or "").strip()
|
email = (getattr(loaded_user, "email", None) or "").strip()
|
||||||
if email:
|
if email:
|
||||||
return email
|
return email
|
||||||
|
fn = (first_name or "").strip()
|
||||||
|
ln = (last_name or "").strip()
|
||||||
|
full = f"{fn} {ln}".strip()
|
||||||
|
if full:
|
||||||
|
return full
|
||||||
|
un = (username or "").strip()
|
||||||
|
if un:
|
||||||
|
return un if un.startswith("@") else f"@{un}"
|
||||||
|
email_value = (email or "").strip()
|
||||||
|
if email_value:
|
||||||
|
return email_value
|
||||||
|
if fallback_user_id is None:
|
||||||
|
return None
|
||||||
|
return str(fallback_user_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _payment_user_display_label(loaded_user: Any, payment_user_id: int) -> str:
|
||||||
|
label = _user_display_label(loaded_user, payment_user_id)
|
||||||
|
if label:
|
||||||
|
return label
|
||||||
return str(payment_user_id)
|
return str(payment_user_id)
|
||||||
|
|
||||||
|
|
||||||
@@ -229,16 +255,27 @@ def _serialize_ad(campaign: AdCampaign, totals: Optional[Dict[str, Any]] = None)
|
|||||||
|
|
||||||
|
|
||||||
def _serialize_log(entry: MessageLog) -> Dict[str, Any]:
|
def _serialize_log(entry: MessageLog) -> Dict[str, Any]:
|
||||||
|
author_user = entry.__dict__.get("author_user")
|
||||||
|
target_user = entry.__dict__.get("target_user")
|
||||||
|
user_id = int(entry.user_id) if entry.user_id is not None else None
|
||||||
|
target_user_id = int(entry.target_user_id) if entry.target_user_id is not None else None
|
||||||
return {
|
return {
|
||||||
"log_id": int(entry.log_id),
|
"log_id": int(entry.log_id),
|
||||||
"user_id": int(entry.user_id) if entry.user_id else None,
|
"user_id": user_id,
|
||||||
|
"user_label": _user_display_label(
|
||||||
|
author_user,
|
||||||
|
user_id,
|
||||||
|
first_name=entry.telegram_first_name,
|
||||||
|
username=entry.telegram_username,
|
||||||
|
),
|
||||||
"telegram_username": entry.telegram_username,
|
"telegram_username": entry.telegram_username,
|
||||||
"telegram_first_name": entry.telegram_first_name,
|
"telegram_first_name": entry.telegram_first_name,
|
||||||
"email": getattr(getattr(entry, "author_user", None), "email", None),
|
"email": getattr(author_user, "email", None),
|
||||||
"event_type": entry.event_type,
|
"event_type": entry.event_type,
|
||||||
"content": entry.content,
|
"content": entry.content,
|
||||||
"is_admin_event": bool(entry.is_admin_event),
|
"is_admin_event": bool(entry.is_admin_event),
|
||||||
"target_user_id": int(entry.target_user_id) if entry.target_user_id else None,
|
"target_user_id": target_user_id,
|
||||||
|
"target_user_label": _user_display_label(target_user, target_user_id),
|
||||||
"timestamp": entry.timestamp.isoformat() if entry.timestamp else None,
|
"timestamp": entry.timestamp.isoformat() if entry.timestamp else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ class SettingField:
|
|||||||
i18n_label_key: Optional[str] = None
|
i18n_label_key: Optional[str] = None
|
||||||
i18n_description_key: Optional[str] = None
|
i18n_description_key: Optional[str] = None
|
||||||
i18n_subsection_key: Optional[str] = None
|
i18n_subsection_key: Optional[str] = None
|
||||||
|
webhook_path: Optional[str] = None
|
||||||
|
webhook_requires_base_url: bool = False
|
||||||
|
webhook_provider_id: Optional[str] = None
|
||||||
|
webhook_hint_i18n_key: Optional[str] = None
|
||||||
|
webhook_hint: str = ""
|
||||||
|
|
||||||
|
|
||||||
SETTINGS_MANIFEST: List[SettingField] = [
|
SETTINGS_MANIFEST: List[SettingField] = [
|
||||||
@@ -84,44 +89,44 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
|||||||
SettingField(
|
SettingField(
|
||||||
"PANEL_API_URL",
|
"PANEL_API_URL",
|
||||||
"url",
|
"url",
|
||||||
"general",
|
"remnawave",
|
||||||
"URL API Remnawave",
|
"URL API Remnawave",
|
||||||
"Например, https://panel.example.com/api.",
|
"Например, https://panel.example.com/api.",
|
||||||
subsection="Remnawave",
|
|
||||||
),
|
),
|
||||||
SettingField(
|
SettingField(
|
||||||
"PANEL_API_KEY",
|
"PANEL_API_KEY",
|
||||||
"string",
|
"string",
|
||||||
"general",
|
"remnawave",
|
||||||
"API-ключ Remnawave",
|
"API-ключ Remnawave",
|
||||||
"Секретный ключ API панели.",
|
"Секретный ключ API панели.",
|
||||||
secret=True,
|
secret=True,
|
||||||
subsection="Remnawave",
|
|
||||||
),
|
),
|
||||||
SettingField(
|
SettingField(
|
||||||
"PANEL_WEBHOOK_SECRET",
|
"PANEL_WEBHOOK_SECRET",
|
||||||
"string",
|
"string",
|
||||||
"general",
|
"remnawave",
|
||||||
"Секрет вебхуков Remnawave",
|
"Секрет вебхуков Remnawave",
|
||||||
"Используется для проверки входящих вебхуков панели.",
|
"Используется для проверки входящих вебхуков панели.",
|
||||||
secret=True,
|
secret=True,
|
||||||
subsection="Remnawave",
|
webhook_path="/webhook/panel",
|
||||||
|
webhook_requires_base_url=True,
|
||||||
|
webhook_provider_id="remnawave",
|
||||||
|
webhook_hint_i18n_key="admin_settings_panel_webhook_url_hint",
|
||||||
|
webhook_hint="Use this URL as WEBHOOK_URL in Remnawave Panel.",
|
||||||
),
|
),
|
||||||
SettingField(
|
SettingField(
|
||||||
"USER_SQUAD_UUIDS",
|
"USER_SQUAD_UUIDS",
|
||||||
"string",
|
"string",
|
||||||
"general",
|
"remnawave",
|
||||||
"Internal Squads по умолчанию",
|
"Internal Squads по умолчанию",
|
||||||
"UUID через запятую для legacy-режима без JSON-каталога тарифов.",
|
"UUID через запятую для legacy-режима без JSON-каталога тарифов.",
|
||||||
subsection="Remnawave",
|
|
||||||
),
|
),
|
||||||
SettingField(
|
SettingField(
|
||||||
"USER_EXTERNAL_SQUAD_UUID",
|
"USER_EXTERNAL_SQUAD_UUID",
|
||||||
"string",
|
"string",
|
||||||
"general",
|
"remnawave",
|
||||||
"External Squad по умолчанию",
|
"External Squad по умолчанию",
|
||||||
"Необязательный UUID External Squad для новых пользователей.",
|
"Необязательный UUID External Squad для новых пользователей.",
|
||||||
subsection="Remnawave",
|
|
||||||
),
|
),
|
||||||
# ─── Web app appearance ────────────────────────────────────────
|
# ─── Web app appearance ────────────────────────────────────────
|
||||||
SettingField(
|
SettingField(
|
||||||
@@ -230,6 +235,70 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
|||||||
SettingField("STARS_PRICE_3_MONTHS", "int", "pricing", "Цена 3 мес. (Stars)"),
|
SettingField("STARS_PRICE_3_MONTHS", "int", "pricing", "Цена 3 мес. (Stars)"),
|
||||||
SettingField("STARS_PRICE_6_MONTHS", "int", "pricing", "Цена 6 мес. (Stars)"),
|
SettingField("STARS_PRICE_6_MONTHS", "int", "pricing", "Цена 6 мес. (Stars)"),
|
||||||
SettingField("STARS_PRICE_12_MONTHS", "int", "pricing", "Цена 12 мес. (Stars)"),
|
SettingField("STARS_PRICE_12_MONTHS", "int", "pricing", "Цена 12 мес. (Stars)"),
|
||||||
|
SettingField(
|
||||||
|
"REFERRAL_BONUS_DAYS_INVITER_1_MONTH",
|
||||||
|
"int",
|
||||||
|
"pricing",
|
||||||
|
"Бонус приглашающему: 1 мес.",
|
||||||
|
min=0,
|
||||||
|
subsection="legacy_tariffs",
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"REFERRAL_BONUS_DAYS_INVITER_3_MONTHS",
|
||||||
|
"int",
|
||||||
|
"pricing",
|
||||||
|
"Бонус приглашающему: 3 мес.",
|
||||||
|
min=0,
|
||||||
|
subsection="legacy_tariffs",
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"REFERRAL_BONUS_DAYS_INVITER_6_MONTHS",
|
||||||
|
"int",
|
||||||
|
"pricing",
|
||||||
|
"Бонус приглашающему: 6 мес.",
|
||||||
|
min=0,
|
||||||
|
subsection="legacy_tariffs",
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"REFERRAL_BONUS_DAYS_INVITER_12_MONTHS",
|
||||||
|
"int",
|
||||||
|
"pricing",
|
||||||
|
"Бонус приглашающему: 12 мес.",
|
||||||
|
min=0,
|
||||||
|
subsection="legacy_tariffs",
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"REFERRAL_BONUS_DAYS_REFEREE_1_MONTH",
|
||||||
|
"int",
|
||||||
|
"pricing",
|
||||||
|
"Бонус приглашённому: 1 мес.",
|
||||||
|
min=0,
|
||||||
|
subsection="legacy_tariffs",
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"REFERRAL_BONUS_DAYS_REFEREE_3_MONTHS",
|
||||||
|
"int",
|
||||||
|
"pricing",
|
||||||
|
"Бонус приглашённому: 3 мес.",
|
||||||
|
min=0,
|
||||||
|
subsection="legacy_tariffs",
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"REFERRAL_BONUS_DAYS_REFEREE_6_MONTHS",
|
||||||
|
"int",
|
||||||
|
"pricing",
|
||||||
|
"Бонус приглашённому: 6 мес.",
|
||||||
|
min=0,
|
||||||
|
subsection="legacy_tariffs",
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"REFERRAL_BONUS_DAYS_REFEREE_12_MONTHS",
|
||||||
|
"int",
|
||||||
|
"pricing",
|
||||||
|
"Бонус приглашённому: 12 мес.",
|
||||||
|
min=0,
|
||||||
|
subsection="legacy_tariffs",
|
||||||
|
),
|
||||||
SettingField(
|
SettingField(
|
||||||
"TRAFFIC_PACKAGES", "string", "pricing", "Пакеты трафика", "Формат: 10:199,50:799 (ГБ:цена)"
|
"TRAFFIC_PACKAGES", "string", "pricing", "Пакеты трафика", "Формат: 10:199,50:799 (ГБ:цена)"
|
||||||
),
|
),
|
||||||
@@ -333,62 +402,6 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
|||||||
"REFERRAL_WELCOME_BONUS_DAYS", "int", "referral", "Приветственный бонус (дней)", min=0
|
"REFERRAL_WELCOME_BONUS_DAYS", "int", "referral", "Приветственный бонус (дней)", min=0
|
||||||
),
|
),
|
||||||
SettingField("LEGACY_REFS", "bool", "referral", "Поддержка старых ref-ссылок"),
|
SettingField("LEGACY_REFS", "bool", "referral", "Поддержка старых ref-ссылок"),
|
||||||
SettingField(
|
|
||||||
"REFERRAL_BONUS_DAYS_INVITER_1_MONTH",
|
|
||||||
"int",
|
|
||||||
"referral",
|
|
||||||
"Бонус приглашающему: 1 мес.",
|
|
||||||
min=0,
|
|
||||||
),
|
|
||||||
SettingField(
|
|
||||||
"REFERRAL_BONUS_DAYS_INVITER_3_MONTHS",
|
|
||||||
"int",
|
|
||||||
"referral",
|
|
||||||
"Бонус приглашающему: 3 мес.",
|
|
||||||
min=0,
|
|
||||||
),
|
|
||||||
SettingField(
|
|
||||||
"REFERRAL_BONUS_DAYS_INVITER_6_MONTHS",
|
|
||||||
"int",
|
|
||||||
"referral",
|
|
||||||
"Бонус приглашающему: 6 мес.",
|
|
||||||
min=0,
|
|
||||||
),
|
|
||||||
SettingField(
|
|
||||||
"REFERRAL_BONUS_DAYS_INVITER_12_MONTHS",
|
|
||||||
"int",
|
|
||||||
"referral",
|
|
||||||
"Бонус приглашающему: 12 мес.",
|
|
||||||
min=0,
|
|
||||||
),
|
|
||||||
SettingField(
|
|
||||||
"REFERRAL_BONUS_DAYS_REFEREE_1_MONTH",
|
|
||||||
"int",
|
|
||||||
"referral",
|
|
||||||
"Бонус приглашённому: 1 мес.",
|
|
||||||
min=0,
|
|
||||||
),
|
|
||||||
SettingField(
|
|
||||||
"REFERRAL_BONUS_DAYS_REFEREE_3_MONTHS",
|
|
||||||
"int",
|
|
||||||
"referral",
|
|
||||||
"Бонус приглашённому: 3 мес.",
|
|
||||||
min=0,
|
|
||||||
),
|
|
||||||
SettingField(
|
|
||||||
"REFERRAL_BONUS_DAYS_REFEREE_6_MONTHS",
|
|
||||||
"int",
|
|
||||||
"referral",
|
|
||||||
"Бонус приглашённому: 6 мес.",
|
|
||||||
min=0,
|
|
||||||
),
|
|
||||||
SettingField(
|
|
||||||
"REFERRAL_BONUS_DAYS_REFEREE_12_MONTHS",
|
|
||||||
"int",
|
|
||||||
"referral",
|
|
||||||
"Бонус приглашённому: 12 мес.",
|
|
||||||
min=0,
|
|
||||||
),
|
|
||||||
# ─── Notifications ─────────────────────────────────────────────
|
# ─── Notifications ─────────────────────────────────────────────
|
||||||
SettingField(
|
SettingField(
|
||||||
"SUBSCRIPTION_NOTIFICATIONS_ENABLED",
|
"SUBSCRIPTION_NOTIFICATIONS_ENABLED",
|
||||||
@@ -396,6 +409,13 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
|||||||
"notifications",
|
"notifications",
|
||||||
"Включены уведомления о подписке",
|
"Включены уведомления о подписке",
|
||||||
),
|
),
|
||||||
|
SettingField(
|
||||||
|
"SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED",
|
||||||
|
"bool",
|
||||||
|
"notifications",
|
||||||
|
"Дублировать уведомления о подписке на email",
|
||||||
|
"Письма отправляются только пользователям с привязанным email и рабочим SMTP.",
|
||||||
|
),
|
||||||
SettingField(
|
SettingField(
|
||||||
"SUBSCRIPTION_NOTIFY_ON_EXPIRE", "bool", "notifications", "Уведомлять об истечении"
|
"SUBSCRIPTION_NOTIFY_ON_EXPIRE", "bool", "notifications", "Уведомлять об истечении"
|
||||||
),
|
),
|
||||||
@@ -692,6 +712,7 @@ def manifest_payload() -> List[dict]:
|
|||||||
sections_order = {
|
sections_order = {
|
||||||
"general": 1,
|
"general": 1,
|
||||||
"appearance": 2,
|
"appearance": 2,
|
||||||
|
"remnawave": 3,
|
||||||
"pricing": 11,
|
"pricing": 11,
|
||||||
"payments": 4,
|
"payments": 4,
|
||||||
"trial": 5,
|
"trial": 5,
|
||||||
@@ -758,6 +779,15 @@ def manifest_payload() -> List[dict]:
|
|||||||
item["default"] = default_value
|
item["default"] = default_value
|
||||||
if webhook_metadata:
|
if webhook_metadata:
|
||||||
item.update(webhook_metadata)
|
item.update(webhook_metadata)
|
||||||
|
if field.webhook_path:
|
||||||
|
item["webhook_path"] = field.webhook_path
|
||||||
|
item["webhook_requires_base_url"] = field.webhook_requires_base_url
|
||||||
|
if field.webhook_provider_id:
|
||||||
|
item["provider_id"] = field.webhook_provider_id
|
||||||
|
if field.webhook_hint_i18n_key:
|
||||||
|
item["webhook_hint_i18n_key"] = field.webhook_hint_i18n_key
|
||||||
|
if field.webhook_hint:
|
||||||
|
item["webhook_hint"] = field.webhook_hint
|
||||||
if field.choices:
|
if field.choices:
|
||||||
item["choices"] = [
|
item["choices"] = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from bot.app.web.webapp import (
|
|||||||
routes as _routes,
|
routes as _routes,
|
||||||
serializers as _serializers,
|
serializers as _serializers,
|
||||||
support as _support,
|
support as _support,
|
||||||
|
telegram_notifications as _telegram_notifications,
|
||||||
)
|
)
|
||||||
|
|
||||||
_MODULES = (
|
_MODULES = (
|
||||||
@@ -25,6 +26,7 @@ _MODULES = (
|
|||||||
_assets,
|
_assets,
|
||||||
_auth,
|
_auth,
|
||||||
_account,
|
_account,
|
||||||
|
_telegram_notifications,
|
||||||
_serializers,
|
_serializers,
|
||||||
_billing,
|
_billing,
|
||||||
_devices,
|
_devices,
|
||||||
|
|||||||
@@ -107,6 +107,33 @@ WEBAPP_CSRF_COOKIE_NAME = "rw_webapp_csrf"
|
|||||||
WEBAPP_TELEGRAM_OAUTH_STATE_COOKIE_NAME = "rw_tg_oauth_state"
|
WEBAPP_TELEGRAM_OAUTH_STATE_COOKIE_NAME = "rw_tg_oauth_state"
|
||||||
WEBAPP_CSRF_HEADER_NAME = "X-CSRF-Token"
|
WEBAPP_CSRF_HEADER_NAME = "X-CSRF-Token"
|
||||||
WEBAPP_STATE_CHANGING_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
|
WEBAPP_STATE_CHANGING_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
|
||||||
|
ROBOTS_TX = """User-agent: *
|
||||||
|
Disallow: /
|
||||||
|
|
||||||
|
User-agent: GPTBot
|
||||||
|
Disallow: /
|
||||||
|
|
||||||
|
User-agent: ChatGPT-User
|
||||||
|
Disallow: /
|
||||||
|
|
||||||
|
User-agent: OAI-SearchBot
|
||||||
|
Disallow: /
|
||||||
|
|
||||||
|
User-agent: Google-Extended
|
||||||
|
Disallow: /
|
||||||
|
|
||||||
|
User-agent: ClaudeBot
|
||||||
|
Disallow: /
|
||||||
|
|
||||||
|
User-agent: anthropic-ai
|
||||||
|
Disallow: /
|
||||||
|
|
||||||
|
User-agent: PerplexityBot
|
||||||
|
Disallow: /
|
||||||
|
|
||||||
|
User-agent: Applebot-Extended
|
||||||
|
Disallow: /
|
||||||
|
"""
|
||||||
_APP_VERSION_CACHE: Optional[str] = None
|
_APP_VERSION_CACHE: Optional[str] = None
|
||||||
WEBAPP_CSRF_EXEMPT_PATHS = {
|
WEBAPP_CSRF_EXEMPT_PATHS = {
|
||||||
"/api/auth/telegram/nonce",
|
"/api/auth/telegram/nonce",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from .auth import (
|
|||||||
_sync_merged_panel_identity_for_user,
|
_sync_merged_panel_identity_for_user,
|
||||||
)
|
)
|
||||||
from .common import _invalidate_webapp_user_caches
|
from .common import _invalidate_webapp_user_caches
|
||||||
|
from .telegram_notifications import _probe_telegram_notifications_for_user_id
|
||||||
|
|
||||||
|
|
||||||
async def account_email_request_route(request: web.Request) -> web.Response:
|
async def account_email_request_route(request: web.Request) -> web.Response:
|
||||||
@@ -416,6 +417,8 @@ async def account_telegram_link_route(request: web.Request) -> web.Response:
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Failed to send account Telegram linked notification")
|
logger.exception("Failed to send account Telegram linked notification")
|
||||||
|
|
||||||
|
await _probe_telegram_notifications_for_user_id(request, int(final_user_id))
|
||||||
|
|
||||||
token = create_webapp_session_token(settings, int(final_user_id))
|
token = create_webapp_session_token(settings, int(final_user_id))
|
||||||
response_payload: Dict[str, Any] = {
|
response_payload: Dict[str, Any] = {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
|
|||||||
@@ -23,6 +23,12 @@ async def health_route(request: web.Request) -> web.Response:
|
|||||||
return web.json_response({"ok": True})
|
return web.json_response({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
|
async def robots_txt_route(request: web.Request) -> web.Response:
|
||||||
|
response = web.Response(text=ROBOTS_TX, content_type="text/plain")
|
||||||
|
response.headers["Cache-Control"] = "public, max-age=3600"
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
async def css_asset_route(request: web.Request) -> web.Response:
|
async def css_asset_route(request: web.Request) -> web.Response:
|
||||||
return await _css_asset_route(request, base_name="subscription_webapp")
|
return await _css_asset_route(request, base_name="subscription_webapp")
|
||||||
|
|
||||||
@@ -869,6 +875,7 @@ async def _security_headers_middleware(request: web.Request, handler):
|
|||||||
)
|
)
|
||||||
response.headers.setdefault("Referrer-Policy", "no-referrer")
|
response.headers.setdefault("Referrer-Policy", "no-referrer")
|
||||||
response.headers.setdefault("X-Content-Type-Options", "nosniff")
|
response.headers.setdefault("X-Content-Type-Options", "nosniff")
|
||||||
|
response.headers.setdefault("X-Robots-Tag", "noindex, nofollow, noarchive")
|
||||||
response.headers.setdefault(
|
response.headers.setdefault(
|
||||||
"Permissions-Policy",
|
"Permissions-Policy",
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
# ruff: noqa: F401,F403,F405,I001
|
# ruff: noqa: F401,F403,F405,I001
|
||||||
from ._runtime import * # noqa: F403,F405
|
from ._runtime import * # noqa: F403,F405
|
||||||
from .common import _invalidate_webapp_user_caches
|
from .common import _invalidate_webapp_user_caches
|
||||||
|
from .telegram_notifications import _probe_telegram_notifications_for_user_id
|
||||||
|
|
||||||
|
|
||||||
def _resolve_telegram_bot_id(bot_token: str) -> Optional[int]:
|
def _resolve_telegram_bot_id(bot_token: str) -> Optional[int]:
|
||||||
@@ -436,6 +437,9 @@ async def telegram_oauth_callback_route(request: web.Request) -> web.Response:
|
|||||||
first_name=linked_user_for_panel.first_name,
|
first_name=linked_user_for_panel.first_name,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if final_user_id:
|
||||||
|
await _probe_telegram_notifications_for_user_id(request, int(final_user_id))
|
||||||
|
|
||||||
token = create_webapp_session_token(settings, int(final_user_id))
|
token = create_webapp_session_token(settings, int(final_user_id))
|
||||||
response = web.HTTPFound(_telegram_oauth_redirect_url(redirect_path, status="success"))
|
response = web.HTTPFound(_telegram_oauth_redirect_url(redirect_path, status="success"))
|
||||||
_clear_telegram_oauth_state_cookie(response)
|
_clear_telegram_oauth_state_cookie(response)
|
||||||
@@ -531,6 +535,7 @@ async def auth_token_route(request: web.Request) -> web.Response:
|
|||||||
return _json_error(500, "auth_failed", "Auth failed")
|
return _json_error(500, "auth_failed", "Auth failed")
|
||||||
|
|
||||||
await _invalidate_webapp_user_caches(settings, authenticated_user_id, include_devices=True)
|
await _invalidate_webapp_user_caches(settings, authenticated_user_id, include_devices=True)
|
||||||
|
await _probe_telegram_notifications_for_user_id(request, int(authenticated_user_id))
|
||||||
token = create_webapp_session_token(settings, int(authenticated_user_id))
|
token = create_webapp_session_token(settings, int(authenticated_user_id))
|
||||||
return _build_webapp_auth_response(settings, {"ok": True}, token=token)
|
return _build_webapp_auth_response(settings, {"ok": True}, token=token)
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,13 @@ async def devices_route(request: web.Request) -> web.Response:
|
|||||||
"devices",
|
"devices",
|
||||||
user_id,
|
user_id,
|
||||||
int(getattr(settings, "WEBAPP_DEVICES_CACHE_TTL_SECONDS", 5) or 0),
|
int(getattr(settings, "WEBAPP_DEVICES_CACHE_TTL_SECONDS", 5) or 0),
|
||||||
lambda: _load_devices_payload(subscription_service, session, user_id),
|
lambda: _load_devices_payload(
|
||||||
|
subscription_service,
|
||||||
|
session,
|
||||||
|
user_id,
|
||||||
|
fallback_panel_user_uuid=str(getattr(db_user, "panel_user_uuid", "") or "").strip()
|
||||||
|
or None,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
if isinstance(result, dict) and result.get("ok") is True:
|
if isinstance(result, dict) and result.get("ok") is True:
|
||||||
return web.json_response({"ok": True, **(result.get("payload") or {})})
|
return web.json_response({"ok": True, **(result.get("payload") or {})})
|
||||||
@@ -45,16 +51,12 @@ async def _load_devices_payload(
|
|||||||
subscription_service: SubscriptionService,
|
subscription_service: SubscriptionService,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
user_id: int,
|
user_id: int,
|
||||||
|
fallback_panel_user_uuid: Optional[str] = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
active = await subscription_service.get_active_subscription_details(session, user_id)
|
active = await subscription_service.get_active_subscription_details(session, user_id)
|
||||||
panel_user_uuid = active.get("user_id") if active else None
|
panel_user_uuid = str((active or {}).get("user_id") or fallback_panel_user_uuid or "").strip()
|
||||||
if not panel_user_uuid:
|
if not panel_user_uuid:
|
||||||
return {
|
return _empty_inactive_devices_payload()
|
||||||
"ok": False,
|
|
||||||
"status": 400,
|
|
||||||
"error": "subscription_not_active",
|
|
||||||
"message": "Subscription is not active",
|
|
||||||
}
|
|
||||||
|
|
||||||
panel_service = getattr(subscription_service, "panel_service", None)
|
panel_service = getattr(subscription_service, "panel_service", None)
|
||||||
if not panel_service:
|
if not panel_service:
|
||||||
@@ -82,6 +84,7 @@ async def _load_devices_payload(
|
|||||||
"ok": True,
|
"ok": True,
|
||||||
"payload": {
|
"payload": {
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
|
"subscription_active": _devices_subscription_is_active(active),
|
||||||
"current_devices": len(devices),
|
"current_devices": len(devices),
|
||||||
"max_devices": max_devices,
|
"max_devices": max_devices,
|
||||||
"max_devices_label": _format_devices_limit(max_devices),
|
"max_devices_label": _format_devices_limit(max_devices),
|
||||||
@@ -92,6 +95,31 @@ async def _load_devices_payload(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _empty_inactive_devices_payload() -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"payload": {
|
||||||
|
"enabled": True,
|
||||||
|
"subscription_active": False,
|
||||||
|
"current_devices": 0,
|
||||||
|
"max_devices": None,
|
||||||
|
"max_devices_label": _format_devices_limit(None),
|
||||||
|
"devices": [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _devices_subscription_is_active(active: Optional[Dict[str, Any]]) -> bool:
|
||||||
|
if not active:
|
||||||
|
return False
|
||||||
|
end_date = active.get("end_date")
|
||||||
|
if not isinstance(end_date, datetime):
|
||||||
|
return False
|
||||||
|
if end_date.tzinfo is None:
|
||||||
|
end_date = end_date.replace(tzinfo=timezone.utc)
|
||||||
|
return end_date > datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
async def disconnect_device_route(request: web.Request) -> web.Response:
|
async def disconnect_device_route(request: web.Request) -> web.Response:
|
||||||
user_id = _require_user_id(request)
|
user_id = _require_user_id(request)
|
||||||
rate_limit_response = await _enforce_webapp_rate_limit(
|
rate_limit_response = await _enforce_webapp_rate_limit(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from ._runtime import * # noqa: F403,F405
|
|||||||
|
|
||||||
|
|
||||||
def setup_subscription_webapp_routes(app: web.Application) -> None:
|
def setup_subscription_webapp_routes(app: web.Application) -> None:
|
||||||
|
app.router.add_get("/robots.txt", robots_txt_route)
|
||||||
app.router.add_get("/", index_route)
|
app.router.add_get("/", index_route)
|
||||||
app.router.add_get("/login/password", index_route)
|
app.router.add_get("/login/password", index_route)
|
||||||
app.router.add_get("/home", index_route)
|
app.router.add_get("/home", index_route)
|
||||||
@@ -84,6 +85,10 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
|
|||||||
app.router.add_post("/api/account/password/request", account_password_request_route)
|
app.router.add_post("/api/account/password/request", account_password_request_route)
|
||||||
app.router.add_post("/api/account/password/confirm", account_password_confirm_route)
|
app.router.add_post("/api/account/password/confirm", account_password_confirm_route)
|
||||||
app.router.add_post("/api/account/telegram/link", account_telegram_link_route)
|
app.router.add_post("/api/account/telegram/link", account_telegram_link_route)
|
||||||
|
app.router.add_post(
|
||||||
|
"/api/account/telegram/notifications/probe",
|
||||||
|
account_telegram_notifications_probe_route,
|
||||||
|
)
|
||||||
app.router.add_post("/api/promo/apply", apply_promo_route)
|
app.router.add_post("/api/promo/apply", apply_promo_route)
|
||||||
app.router.add_post("/api/trial/activate", activate_trial_route)
|
app.router.add_post("/api/trial/activate", activate_trial_route)
|
||||||
app.router.add_get("/api/devices", devices_route)
|
app.router.add_get("/api/devices", devices_route)
|
||||||
|
|||||||
@@ -3,6 +3,12 @@ from ._runtime import * # noqa: F403,F405
|
|||||||
|
|
||||||
from config.subscription_guides_config import subscription_guides_available
|
from config.subscription_guides_config import subscription_guides_available
|
||||||
from config.webapp_themes_config import public_themes_catalog_payload
|
from config.webapp_themes_config import public_themes_catalog_payload
|
||||||
|
from bot.services.telegram_notifications import (
|
||||||
|
TELEGRAM_NOTIFICATIONS_ENABLED,
|
||||||
|
normalize_telegram_notification_status,
|
||||||
|
telegram_notifications_need_prompt,
|
||||||
|
telegram_notifications_start_link,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, Any]:
|
async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, Any]:
|
||||||
@@ -72,6 +78,12 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
|
|||||||
lang = _normalize_language(db_user.language_code or settings.DEFAULT_LANGUAGE)
|
lang = _normalize_language(db_user.language_code or settings.DEFAULT_LANGUAGE)
|
||||||
admin_ids = {int(x) for x in (settings.ADMIN_IDS or [])}
|
admin_ids = {int(x) for x in (settings.ADMIN_IDS or [])}
|
||||||
is_admin = bool(db_user.telegram_id and int(db_user.telegram_id) in admin_ids)
|
is_admin = bool(db_user.telegram_id and int(db_user.telegram_id) in admin_ids)
|
||||||
|
telegram_notifications_status = normalize_telegram_notification_status(
|
||||||
|
getattr(db_user, "telegram_notifications_status", None)
|
||||||
|
)
|
||||||
|
telegram_notifications_link = telegram_notifications_start_link(
|
||||||
|
request.app.get("bot_username") or ""
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"user": {
|
"user": {
|
||||||
"id": user_id,
|
"id": user_id,
|
||||||
@@ -83,6 +95,12 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
|
|||||||
),
|
),
|
||||||
"telegram_id": db_user.telegram_id,
|
"telegram_id": db_user.telegram_id,
|
||||||
"telegram_linked": bool(_telegram_id_for_user(db_user)),
|
"telegram_linked": bool(_telegram_id_for_user(db_user)),
|
||||||
|
"telegram_notifications_status": telegram_notifications_status,
|
||||||
|
"telegram_notifications_enabled": (
|
||||||
|
telegram_notifications_status == TELEGRAM_NOTIFICATIONS_ENABLED
|
||||||
|
),
|
||||||
|
"telegram_notifications_need_prompt": telegram_notifications_need_prompt(db_user),
|
||||||
|
"telegram_notifications_start_link": telegram_notifications_link,
|
||||||
"telegram_photo_url": _telegram_avatar_url(avatar),
|
"telegram_photo_url": _telegram_avatar_url(avatar),
|
||||||
"first_name": db_user.first_name,
|
"first_name": db_user.first_name,
|
||||||
"language_code": lang,
|
"language_code": lang,
|
||||||
@@ -156,12 +174,79 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _serialize_referral_bonus_details(settings: Settings, lang: str) -> List[Dict[str, Any]]:
|
def _legacy_referral_bonus_periods(settings: Settings) -> List[int]:
|
||||||
if getattr(settings, "traffic_sale_mode", False):
|
if getattr(settings, "traffic_sale_mode", False):
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
return sorted(int(months) for months in settings.subscription_options)
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_tariff_period_referral_bonus_details(tariff: Any, lang: str) -> List[Dict[str, Any]]:
|
||||||
details: List[Dict[str, Any]] = []
|
details: List[Dict[str, Any]] = []
|
||||||
for months, _price in sorted(settings.subscription_options.items()):
|
for months in sorted(int(month) for month in tariff.enabled_periods):
|
||||||
|
inviter_days = tariff.referral_inviter_bonus_days(months)
|
||||||
|
friend_days = tariff.referral_referee_bonus_days(months)
|
||||||
|
if inviter_days is None and friend_days is None:
|
||||||
|
continue
|
||||||
|
details.append(
|
||||||
|
{
|
||||||
|
"id": f"{tariff.key}:{months}",
|
||||||
|
"tariff_key": tariff.key,
|
||||||
|
"tariff_name": tariff.name(lang),
|
||||||
|
"months": int(months),
|
||||||
|
"title": _format_months_title(int(months), lang),
|
||||||
|
"inviter_days": int(inviter_days or 0),
|
||||||
|
"friend_days": int(friend_days or 0),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return details
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_tariff_referral_bonus_details(settings: Settings, lang: str) -> List[Dict[str, Any]]:
|
||||||
|
tariffs_config = settings.tariffs_config
|
||||||
|
if not tariffs_config:
|
||||||
|
return []
|
||||||
|
|
||||||
|
period_tariffs = [
|
||||||
|
tariff for tariff in tariffs_config.enabled_tariffs if tariff.billing_model == "period"
|
||||||
|
]
|
||||||
|
if len(period_tariffs) <= 1:
|
||||||
|
return (
|
||||||
|
_serialize_tariff_period_referral_bonus_details(period_tariffs[0], lang)
|
||||||
|
if period_tariffs
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
|
||||||
|
summaries: List[Dict[str, Any]] = []
|
||||||
|
for tariff in period_tariffs:
|
||||||
|
details = _serialize_tariff_period_referral_bonus_details(tariff, lang)
|
||||||
|
if not details:
|
||||||
|
continue
|
||||||
|
inviter_values = [int(item["inviter_days"]) for item in details]
|
||||||
|
friend_values = [int(item["friend_days"]) for item in details]
|
||||||
|
summaries.append(
|
||||||
|
{
|
||||||
|
"id": f"tariff:{tariff.key}",
|
||||||
|
"type": "tariff_summary",
|
||||||
|
"tariff_key": tariff.key,
|
||||||
|
"tariff_name": tariff.name(lang),
|
||||||
|
"title": tariff.name(lang),
|
||||||
|
"inviter_min_days": min(inviter_values),
|
||||||
|
"inviter_max_days": max(inviter_values),
|
||||||
|
"friend_min_days": min(friend_values),
|
||||||
|
"friend_max_days": max(friend_values),
|
||||||
|
"details": details,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return summaries
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_referral_bonus_details(settings: Settings, lang: str) -> List[Dict[str, Any]]:
|
||||||
|
if settings.tariffs_config:
|
||||||
|
return _serialize_tariff_referral_bonus_details(settings, lang)
|
||||||
|
|
||||||
|
details: List[Dict[str, Any]] = []
|
||||||
|
for months in _legacy_referral_bonus_periods(settings):
|
||||||
inviter_days = settings.referral_bonus_inviter.get(months)
|
inviter_days = settings.referral_bonus_inviter.get(months)
|
||||||
friend_days = settings.referral_bonus_referee.get(months)
|
friend_days = settings.referral_bonus_referee.get(months)
|
||||||
if inviter_days is None and friend_days is None:
|
if inviter_days is None and friend_days is None:
|
||||||
@@ -391,6 +476,7 @@ def _serialize_plans(
|
|||||||
for tariff in tariffs_config.enabled_tariffs:
|
for tariff in tariffs_config.enabled_tariffs:
|
||||||
common = {
|
common = {
|
||||||
"tariff_key": tariff.key,
|
"tariff_key": tariff.key,
|
||||||
|
"is_default_tariff": tariff.key == tariffs_config.default_tariff,
|
||||||
"tariff_name": tariff.name(lang),
|
"tariff_name": tariff.name(lang),
|
||||||
"billing_model": tariff.billing_model,
|
"billing_model": tariff.billing_model,
|
||||||
"description": tariff.description(lang),
|
"description": tariff.description(lang),
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# ruff: noqa: F401,F403,F405,I001
|
||||||
|
from ._runtime import * # noqa: F403,F405
|
||||||
|
|
||||||
|
from bot.services.telegram_notifications import (
|
||||||
|
TELEGRAM_NOTIFICATIONS_ENABLED,
|
||||||
|
probe_telegram_notifications,
|
||||||
|
telegram_notifications_start_link,
|
||||||
|
)
|
||||||
|
from .common import _invalidate_webapp_user_caches
|
||||||
|
|
||||||
|
|
||||||
|
async def _probe_telegram_notifications_for_user_id(
|
||||||
|
request: web.Request,
|
||||||
|
user_id: int,
|
||||||
|
*,
|
||||||
|
force: bool = False,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
settings: Settings = request.app["settings"]
|
||||||
|
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||||
|
async with async_session_factory() as session:
|
||||||
|
try:
|
||||||
|
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||||
|
if not db_user or db_user.is_banned:
|
||||||
|
await session.rollback()
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"status": "access_denied",
|
||||||
|
"enabled": False,
|
||||||
|
"start_link": telegram_notifications_start_link(
|
||||||
|
request.app.get("bot_username") or ""
|
||||||
|
),
|
||||||
|
}
|
||||||
|
result = await probe_telegram_notifications(
|
||||||
|
session=session,
|
||||||
|
bot=request.app["bot"],
|
||||||
|
settings=settings,
|
||||||
|
i18n=request.app.get("i18n"),
|
||||||
|
user=db_user,
|
||||||
|
bot_username=request.app.get("bot_username") or "",
|
||||||
|
force=force,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
status = str(result.get("status") or "")
|
||||||
|
await _invalidate_webapp_user_caches(settings, int(db_user.user_id))
|
||||||
|
return {
|
||||||
|
"ok": bool(result.get("ok")),
|
||||||
|
"status": status,
|
||||||
|
"enabled": status == TELEGRAM_NOTIFICATIONS_ENABLED,
|
||||||
|
"start_link": result.get("start_link"),
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
await session.rollback()
|
||||||
|
logger.exception("Telegram notification probe failed")
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"status": "unknown",
|
||||||
|
"enabled": False,
|
||||||
|
"start_link": telegram_notifications_start_link(
|
||||||
|
request.app.get("bot_username") or ""
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def account_telegram_notifications_probe_route(request: web.Request) -> web.Response:
|
||||||
|
user_id = _require_user_id(request)
|
||||||
|
force = True
|
||||||
|
result = await _probe_telegram_notifications_for_user_id(request, user_id, force=force)
|
||||||
|
if result.get("status") == "access_denied":
|
||||||
|
return _json_error(403, "access_denied", "Access denied")
|
||||||
|
return web.json_response({"ok": True, "telegram_notifications": result})
|
||||||
@@ -14,6 +14,7 @@ from bot.middlewares.i18n import JsonI18n
|
|||||||
from bot.services.panel_api_service import PanelApiService
|
from bot.services.panel_api_service import PanelApiService
|
||||||
from bot.utils.text_sanitizer import panel_description_from_profile
|
from bot.utils.text_sanitizer import panel_description_from_profile
|
||||||
from config.settings import Settings
|
from config.settings import Settings
|
||||||
|
from db.advisory_locks import acquire_subscription_background_sync_lock
|
||||||
from db.dal import panel_sync_dal, subscription_dal, user_dal
|
from db.dal import panel_sync_dal, subscription_dal, user_dal
|
||||||
from db.models import Subscription, User
|
from db.models import Subscription, User
|
||||||
|
|
||||||
@@ -928,6 +929,7 @@ async def _perform_sync_impl(
|
|||||||
|
|
||||||
total_panel_users = len(panel_users_data)
|
total_panel_users = len(panel_users_data)
|
||||||
logging.info(f"Starting sync for {total_panel_users} panel users.")
|
logging.info(f"Starting sync for {total_panel_users} panel users.")
|
||||||
|
await acquire_subscription_background_sync_lock(session)
|
||||||
sync_indexes = await _prefetch_sync_indexes(session, panel_users_data)
|
sync_indexes = await _prefetch_sync_indexes(session, panel_users_data)
|
||||||
users_by_telegram_id = sync_indexes["users_by_telegram_id"]
|
users_by_telegram_id = sync_indexes["users_by_telegram_id"]
|
||||||
users_by_user_id = sync_indexes["users_by_user_id"]
|
users_by_user_id = sync_indexes["users_by_user_id"]
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import logging
|
import logging
|
||||||
from typing import Optional, Union
|
from typing import Any, Callable, Optional, Union
|
||||||
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
||||||
|
|
||||||
from aiogram import Bot, F, Router, types
|
from aiogram import Bot, F, Router, types
|
||||||
@@ -76,31 +76,10 @@ async def referral_command_handler(
|
|||||||
await event.answer()
|
await event.answer()
|
||||||
return
|
return
|
||||||
|
|
||||||
bonus_info_parts = []
|
|
||||||
if getattr(settings, "traffic_sale_mode", False):
|
if getattr(settings, "traffic_sale_mode", False):
|
||||||
bonus_details_str = _("referral_not_available_for_traffic")
|
bonus_details_str = _("referral_not_available_for_traffic")
|
||||||
else:
|
else:
|
||||||
if settings.subscription_options:
|
bonus_details_str = _build_referral_bonus_details_text(settings, _, current_lang)
|
||||||
for months_period_key, _price in sorted(settings.subscription_options.items()):
|
|
||||||
inv_bonus = settings.referral_bonus_inviter.get(months_period_key)
|
|
||||||
ref_bonus = settings.referral_bonus_referee.get(months_period_key)
|
|
||||||
if inv_bonus is not None or ref_bonus is not None:
|
|
||||||
bonus_info_parts.append(
|
|
||||||
_(
|
|
||||||
"referral_bonus_per_period",
|
|
||||||
months=months_period_key,
|
|
||||||
inviter_bonus_days=inv_bonus
|
|
||||||
if inv_bonus is not None
|
|
||||||
else _("no_bonus_placeholder"),
|
|
||||||
referee_bonus_days=ref_bonus
|
|
||||||
if ref_bonus is not None
|
|
||||||
else _("no_bonus_placeholder"),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
bonus_details_str = (
|
|
||||||
"\n".join(bonus_info_parts) if bonus_info_parts else _("referral_no_bonuses_configured")
|
|
||||||
)
|
|
||||||
|
|
||||||
referral_stats = await referral_service.get_referral_stats(session, inviter_user_id)
|
referral_stats = await referral_service.get_referral_stats(session, inviter_user_id)
|
||||||
|
|
||||||
@@ -208,6 +187,132 @@ async def referral_action_handler(
|
|||||||
await callback.answer()
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
|
Translator = Callable[..., str]
|
||||||
|
|
||||||
|
|
||||||
|
def _period_bonus_text(
|
||||||
|
translator: Translator,
|
||||||
|
*,
|
||||||
|
months: int,
|
||||||
|
inviter_days: Optional[int],
|
||||||
|
referee_days: Optional[int],
|
||||||
|
) -> str:
|
||||||
|
return translator(
|
||||||
|
"referral_bonus_per_period",
|
||||||
|
months=months,
|
||||||
|
inviter_bonus_days=(
|
||||||
|
inviter_days if inviter_days is not None else translator("no_bonus_placeholder")
|
||||||
|
),
|
||||||
|
referee_bonus_days=(
|
||||||
|
referee_days if referee_days is not None else translator("no_bonus_placeholder")
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _tariff_period_bonus_entries(tariff: Any) -> list[dict[str, Optional[int]]]:
|
||||||
|
entries: list[dict[str, Optional[int]]] = []
|
||||||
|
for months in sorted(int(month) for month in getattr(tariff, "enabled_periods", [])):
|
||||||
|
inviter_days = tariff.referral_inviter_bonus_days(months)
|
||||||
|
referee_days = tariff.referral_referee_bonus_days(months)
|
||||||
|
if inviter_days is None and referee_days is None:
|
||||||
|
continue
|
||||||
|
entries.append(
|
||||||
|
{
|
||||||
|
"months": months,
|
||||||
|
"inviter_days": inviter_days,
|
||||||
|
"referee_days": referee_days,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
def _legacy_period_bonus_entries(settings: Settings) -> list[dict[str, Optional[int]]]:
|
||||||
|
entries: list[dict[str, Optional[int]]] = []
|
||||||
|
for months, _price in sorted(settings.subscription_options.items()):
|
||||||
|
inviter_days = settings.referral_bonus_inviter.get(months)
|
||||||
|
referee_days = settings.referral_bonus_referee.get(months)
|
||||||
|
if inviter_days is None and referee_days is None:
|
||||||
|
continue
|
||||||
|
entries.append(
|
||||||
|
{
|
||||||
|
"months": int(months),
|
||||||
|
"inviter_days": inviter_days,
|
||||||
|
"referee_days": referee_days,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
def _bonus_days_range(translator: Translator, values: list[int]) -> str:
|
||||||
|
return translator(
|
||||||
|
"referral_bonus_days_range",
|
||||||
|
min_days=min(values),
|
||||||
|
max_days=max(values),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_referral_bonus_details_text(
|
||||||
|
settings: Settings, translator: Translator, current_lang: str
|
||||||
|
) -> str:
|
||||||
|
tariffs_config = settings.tariffs_config
|
||||||
|
if not tariffs_config:
|
||||||
|
bonus_info_parts = [
|
||||||
|
_period_bonus_text(
|
||||||
|
translator,
|
||||||
|
months=int(entry["months"] or 0),
|
||||||
|
inviter_days=entry["inviter_days"],
|
||||||
|
referee_days=entry["referee_days"],
|
||||||
|
)
|
||||||
|
for entry in _legacy_period_bonus_entries(settings)
|
||||||
|
]
|
||||||
|
return (
|
||||||
|
"\n".join(bonus_info_parts)
|
||||||
|
if bonus_info_parts
|
||||||
|
else translator("referral_no_bonuses_configured")
|
||||||
|
)
|
||||||
|
|
||||||
|
period_tariffs = [
|
||||||
|
tariff for tariff in tariffs_config.enabled_tariffs if tariff.billing_model == "period"
|
||||||
|
]
|
||||||
|
if len(period_tariffs) <= 1:
|
||||||
|
entries = _tariff_period_bonus_entries(period_tariffs[0]) if period_tariffs else []
|
||||||
|
bonus_info_parts = [
|
||||||
|
_period_bonus_text(
|
||||||
|
translator,
|
||||||
|
months=int(entry["months"] or 0),
|
||||||
|
inviter_days=entry["inviter_days"],
|
||||||
|
referee_days=entry["referee_days"],
|
||||||
|
)
|
||||||
|
for entry in entries
|
||||||
|
]
|
||||||
|
return (
|
||||||
|
"\n".join(bonus_info_parts)
|
||||||
|
if bonus_info_parts
|
||||||
|
else translator("referral_no_bonuses_configured")
|
||||||
|
)
|
||||||
|
|
||||||
|
bonus_info_parts = []
|
||||||
|
for tariff in period_tariffs:
|
||||||
|
entries = _tariff_period_bonus_entries(tariff)
|
||||||
|
if not entries:
|
||||||
|
continue
|
||||||
|
inviter_values = [int(entry["inviter_days"] or 0) for entry in entries]
|
||||||
|
referee_values = [int(entry["referee_days"] or 0) for entry in entries]
|
||||||
|
bonus_info_parts.append(
|
||||||
|
translator(
|
||||||
|
"referral_bonus_tariff_range",
|
||||||
|
tariff_name=tariff.name(current_lang),
|
||||||
|
inviter_bonus_range=_bonus_days_range(translator, inviter_values),
|
||||||
|
referee_bonus_range=_bonus_days_range(translator, referee_values),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
"\n".join(bonus_info_parts)
|
||||||
|
if bonus_info_parts
|
||||||
|
else translator("referral_no_bonuses_configured")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _build_webapp_referral_link(
|
def _build_webapp_referral_link(
|
||||||
base_url: Optional[str], referral_code: Optional[str]
|
base_url: Optional[str], referral_code: Optional[str]
|
||||||
) -> Optional[str]:
|
) -> Optional[str]:
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from bot.services.panel_api_service import PanelApiService
|
|||||||
from bot.services.promo_code_service import PromoCodeService
|
from bot.services.promo_code_service import PromoCodeService
|
||||||
from bot.services.referral_service import ReferralService
|
from bot.services.referral_service import ReferralService
|
||||||
from bot.services.subscription_service import SubscriptionService
|
from bot.services.subscription_service import SubscriptionService
|
||||||
|
from bot.services.telegram_notifications import TELEGRAM_NOTIFICATIONS_ENABLED
|
||||||
from bot.utils.callback_answer import safe_answer_callback
|
from bot.utils.callback_answer import safe_answer_callback
|
||||||
from bot.utils.install_links import (
|
from bot.utils.install_links import (
|
||||||
append_install_share_link_text,
|
append_install_share_link_text,
|
||||||
@@ -392,11 +393,12 @@ async def ensure_required_channel_subscription(
|
|||||||
@router.message(CommandStart(magic=F.args.regexp(r"^promo_(\w+)$").as_("promo_match")))
|
@router.message(CommandStart(magic=F.args.regexp(r"^promo_(\w+)$").as_("promo_match")))
|
||||||
@router.message(CommandStart(magic=F.args.regexp(r"^admin_user_(\d+)$").as_("admin_user_match")))
|
@router.message(CommandStart(magic=F.args.regexp(r"^admin_user_(\d+)$").as_("admin_user_match")))
|
||||||
@router.message(CommandStart(magic=F.args.regexp(r"^ticket_(\d+)$").as_("ticket_match")))
|
@router.message(CommandStart(magic=F.args.regexp(r"^ticket_(\d+)$").as_("ticket_match")))
|
||||||
|
@router.message(CommandStart(magic=F.args.regexp(r"^notifications$").as_("notifications_match")))
|
||||||
@router.message(CommandStart(magic=F.args.regexp(r"^page_ref$").as_("page_ref_match")))
|
@router.message(CommandStart(magic=F.args.regexp(r"^page_ref$").as_("page_ref_match")))
|
||||||
@router.message(
|
@router.message(
|
||||||
CommandStart(
|
CommandStart(
|
||||||
magic=F.args.regexp(
|
magic=F.args.regexp(
|
||||||
r"^(?!ref_|promo_|admin_user_|ticket_|page_ref$|webapp_auth_)([A-Za-z0-9_\-]{2,64})$"
|
r"^(?!ref_|promo_|admin_user_|ticket_|notifications$|page_ref$|webapp_auth_)([A-Za-z0-9_\-]{2,64})$"
|
||||||
).as_("ad_param_match")
|
).as_("ad_param_match")
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -414,6 +416,7 @@ async def start_command_handler(
|
|||||||
ad_param_match: Optional[re.Match] = None,
|
ad_param_match: Optional[re.Match] = None,
|
||||||
admin_user_match: Optional[re.Match] = None,
|
admin_user_match: Optional[re.Match] = None,
|
||||||
ticket_match: Optional[re.Match] = None,
|
ticket_match: Optional[re.Match] = None,
|
||||||
|
notifications_match: Optional[re.Match] = None,
|
||||||
):
|
):
|
||||||
await state.clear()
|
await state.clear()
|
||||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||||
@@ -500,6 +503,7 @@ async def start_command_handler(
|
|||||||
promo_code_to_apply: Optional[str] = None
|
promo_code_to_apply: Optional[str] = None
|
||||||
should_open_referral_from_start = False
|
should_open_referral_from_start = False
|
||||||
ad_start_param: Optional[str] = None
|
ad_start_param: Optional[str] = None
|
||||||
|
notifications_start_requested = bool(notifications_match)
|
||||||
|
|
||||||
if ref_match:
|
if ref_match:
|
||||||
raw_ref_value = ref_match.group(1)
|
raw_ref_value = ref_match.group(1)
|
||||||
@@ -522,6 +526,8 @@ async def start_command_handler(
|
|||||||
elif promo_match:
|
elif promo_match:
|
||||||
promo_code_to_apply = promo_match.group(1)
|
promo_code_to_apply = promo_match.group(1)
|
||||||
logging.info(f"User {user_id} started with promo code: {promo_code_to_apply}")
|
logging.info(f"User {user_id} started with promo code: {promo_code_to_apply}")
|
||||||
|
elif notifications_start_requested:
|
||||||
|
logging.info("User %s started bot from notifications deep-link.", user_id)
|
||||||
elif page_ref_match:
|
elif page_ref_match:
|
||||||
should_open_referral_from_start = True
|
should_open_referral_from_start = True
|
||||||
logging.info(f"User {user_id} started with page_ref deep-link.")
|
logging.info(f"User {user_id} started with page_ref deep-link.")
|
||||||
@@ -532,18 +538,24 @@ async def start_command_handler(
|
|||||||
sanitized_username = sanitize_username(user.username)
|
sanitized_username = sanitize_username(user.username)
|
||||||
sanitized_first_name = sanitize_display_name(user.first_name)
|
sanitized_first_name = sanitize_display_name(user.first_name)
|
||||||
sanitized_last_name = sanitize_display_name(user.last_name)
|
sanitized_last_name = sanitize_display_name(user.last_name)
|
||||||
|
notification_status_now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||||
is_existing_user = db_user is not None
|
is_existing_user = db_user is not None
|
||||||
if not db_user:
|
if not db_user:
|
||||||
user_data_to_create = {
|
user_data_to_create = {
|
||||||
"user_id": user_id,
|
"user_id": user_id,
|
||||||
|
"telegram_id": user_id,
|
||||||
"username": sanitized_username,
|
"username": sanitized_username,
|
||||||
"first_name": sanitized_first_name,
|
"first_name": sanitized_first_name,
|
||||||
"last_name": sanitized_last_name,
|
"last_name": sanitized_last_name,
|
||||||
"language_code": current_lang,
|
"language_code": current_lang,
|
||||||
"referred_by_id": referred_by_user_id,
|
"referred_by_id": referred_by_user_id,
|
||||||
"registration_date": datetime.now(timezone.utc),
|
"registration_date": datetime.now(timezone.utc),
|
||||||
|
"telegram_notifications_status": TELEGRAM_NOTIFICATIONS_ENABLED,
|
||||||
|
"telegram_notifications_checked_at": notification_status_now,
|
||||||
|
"telegram_notifications_enabled_at": notification_status_now,
|
||||||
|
"telegram_notifications_blocked_at": None,
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
db_user, created = await user_dal.create_user(session, user_data_to_create)
|
db_user, created = await user_dal.create_user(session, user_data_to_create)
|
||||||
@@ -631,6 +643,13 @@ async def start_command_handler(
|
|||||||
update_payload = {}
|
update_payload = {}
|
||||||
if db_user.language_code != current_lang:
|
if db_user.language_code != current_lang:
|
||||||
update_payload["language_code"] = current_lang
|
update_payload["language_code"] = current_lang
|
||||||
|
if db_user.telegram_id != user_id:
|
||||||
|
update_payload["telegram_id"] = user_id
|
||||||
|
if db_user.telegram_notifications_status != TELEGRAM_NOTIFICATIONS_ENABLED:
|
||||||
|
update_payload["telegram_notifications_status"] = TELEGRAM_NOTIFICATIONS_ENABLED
|
||||||
|
update_payload["telegram_notifications_checked_at"] = notification_status_now
|
||||||
|
update_payload["telegram_notifications_enabled_at"] = notification_status_now
|
||||||
|
update_payload["telegram_notifications_blocked_at"] = None
|
||||||
# Set referral only if not already set AND user is not currently active.
|
# Set referral only if not already set AND user is not currently active.
|
||||||
# This allows previously subscribed but currently inactive users to be attributed.
|
# This allows previously subscribed but currently inactive users to be attributed.
|
||||||
if referred_by_user_id and db_user.referred_by_id is None:
|
if referred_by_user_id and db_user.referred_by_id is None:
|
||||||
@@ -684,9 +703,16 @@ async def start_command_handler(
|
|||||||
open_referral_page_for_existing_user = should_open_referral_from_start and is_existing_user
|
open_referral_page_for_existing_user = should_open_referral_from_start and is_existing_user
|
||||||
|
|
||||||
# Send welcome message if not disabled
|
# Send welcome message if not disabled
|
||||||
if not settings.DISABLE_WELCOME_MESSAGE and not open_referral_page_for_existing_user:
|
if (
|
||||||
|
not settings.DISABLE_WELCOME_MESSAGE
|
||||||
|
and not open_referral_page_for_existing_user
|
||||||
|
and not notifications_start_requested
|
||||||
|
):
|
||||||
await message.answer(_(key="welcome", user_name=hd.quote(user.full_name)))
|
await message.answer(_(key="welcome", user_name=hd.quote(user.full_name)))
|
||||||
|
|
||||||
|
if notifications_start_requested:
|
||||||
|
await message.answer(_("telegram_notifications_started"), parse_mode="HTML")
|
||||||
|
|
||||||
# Auto-apply promo code if provided via start parameter
|
# Auto-apply promo code if provided via start parameter
|
||||||
if promo_code_to_apply:
|
if promo_code_to_apply:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -114,6 +114,20 @@ def _with_subscription_purchase_description(
|
|||||||
return f"{description}\n\n{text}"
|
return f"{description}\n\n{text}"
|
||||||
|
|
||||||
|
|
||||||
|
def _format_premium_bytes(value: object) -> str:
|
||||||
|
try:
|
||||||
|
bytes_value = max(0, int(value or 0))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
bytes_value = 0
|
||||||
|
return f"{bytes_value / 2**30:.2f} GB"
|
||||||
|
|
||||||
|
|
||||||
|
def _format_premium_usage_limit(active: dict[str, object]) -> str:
|
||||||
|
used = _format_premium_bytes(active.get("premium_used_bytes"))
|
||||||
|
limit = _format_premium_bytes(active.get("premium_limit_bytes"))
|
||||||
|
return f"{used} из {limit}"
|
||||||
|
|
||||||
|
|
||||||
async def display_subscription_options(
|
async def display_subscription_options(
|
||||||
event: Union[types.Message, types.CallbackQuery],
|
event: Union[types.Message, types.CallbackQuery],
|
||||||
i18n_data: dict,
|
i18n_data: dict,
|
||||||
@@ -456,7 +470,7 @@ async def tariff_topup_list_callback(
|
|||||||
if len(labels) > len(visible):
|
if len(labels) > len(visible):
|
||||||
premium_lines.append(f"• ... еще {len(labels) - len(visible)}")
|
premium_lines.append(f"• ... еще {len(labels) - len(visible)}")
|
||||||
premium_lines.append(
|
premium_lines.append(
|
||||||
f"Premium использовано: {active.get('premium_used')} из {active.get('premium_limit')}. Осталось: {premium_left / 2**30:.2f} GB." # noqa: E501
|
f"Premium использовано: {_format_premium_usage_limit(active)}. Осталось: {premium_left / 2**30:.2f} GB." # noqa: E501
|
||||||
)
|
)
|
||||||
text = get_text("choose_payment_method_traffic")
|
text = get_text("choose_payment_method_traffic")
|
||||||
if carryover_lines:
|
if carryover_lines:
|
||||||
@@ -1065,7 +1079,7 @@ async def my_subscription_command_handler(
|
|||||||
text += (
|
text += (
|
||||||
"\n\n🚀 <b>Premium-серверы</b>\n"
|
"\n\n🚀 <b>Premium-серверы</b>\n"
|
||||||
f"Статус: <b>{premium_status}</b>\n"
|
f"Статус: <b>{premium_status}</b>\n"
|
||||||
f"Лимит: <b>{active.get('premium_used')} из {active.get('premium_limit')}</b>\n"
|
f"Лимит: <b>{_format_premium_usage_limit(active)}</b>\n"
|
||||||
f"Осталось: <b>{premium_left / 2**30:.2f} GB</b>\n"
|
f"Осталось: <b>{premium_left / 2**30:.2f} GB</b>\n"
|
||||||
f"Докупленный остаток: <b>{premium_balance / 2**30:.2f} GB</b>\n"
|
f"Докупленный остаток: <b>{premium_balance / 2**30:.2f} GB</b>\n"
|
||||||
"Отдельный лимит действует на:\n"
|
"Отдельный лимит действует на:\n"
|
||||||
|
|||||||
@@ -307,6 +307,7 @@ class JsonI18n:
|
|||||||
self.locales_data: Dict[str, Dict[str, str]] = {}
|
self.locales_data: Dict[str, Dict[str, str]] = {}
|
||||||
self._overrides_path: Optional[Path] = None
|
self._overrides_path: Optional[Path] = None
|
||||||
self._overrides_file_mtime_ns: Optional[int] = None
|
self._overrides_file_mtime_ns: Optional[int] = None
|
||||||
|
self._overrides_file_content: Optional[str] = None
|
||||||
self._overrides_file_next_check = 0.0
|
self._overrides_file_next_check = 0.0
|
||||||
self._overrides_file_check_interval_seconds = 1.0
|
self._overrides_file_check_interval_seconds = 1.0
|
||||||
self._load_locales()
|
self._load_locales()
|
||||||
@@ -419,6 +420,7 @@ class JsonI18n:
|
|||||||
if self._overrides_file_mtime_ns is None:
|
if self._overrides_file_mtime_ns is None:
|
||||||
return False
|
return False
|
||||||
self._overrides_file_mtime_ns = None
|
self._overrides_file_mtime_ns = None
|
||||||
|
self._overrides_file_content = None
|
||||||
logging.info(
|
logging.info(
|
||||||
"Locale overrides file removed; keeping current in-memory overrides until "
|
"Locale overrides file removed; keeping current in-memory overrides until "
|
||||||
"the DB fallback is reloaded"
|
"the DB fallback is reloaded"
|
||||||
@@ -432,19 +434,8 @@ class JsonI18n:
|
|||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if not force and stat.st_mtime_ns == self._overrides_file_mtime_ns:
|
|
||||||
return False
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
payload = json.loads(self._overrides_path.read_text(encoding="utf-8"))
|
content = self._overrides_path.read_text(encoding="utf-8")
|
||||||
except json.JSONDecodeError as exc:
|
|
||||||
logging.warning(
|
|
||||||
"Failed to parse locale overrides file %s: %s",
|
|
||||||
self._overrides_path,
|
|
||||||
exc,
|
|
||||||
)
|
|
||||||
self._overrides_file_mtime_ns = stat.st_mtime_ns
|
|
||||||
return False
|
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
logging.warning(
|
logging.warning(
|
||||||
"Failed to read locale overrides file %s: %s",
|
"Failed to read locale overrides file %s: %s",
|
||||||
@@ -453,7 +444,27 @@ class JsonI18n:
|
|||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
if (
|
||||||
|
not force
|
||||||
|
and stat.st_mtime_ns == self._overrides_file_mtime_ns
|
||||||
|
and content == self._overrides_file_content
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = json.loads(content)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
logging.warning(
|
||||||
|
"Failed to parse locale overrides file %s: %s",
|
||||||
|
self._overrides_path,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
self._overrides_file_mtime_ns = stat.st_mtime_ns
|
self._overrides_file_mtime_ns = stat.st_mtime_ns
|
||||||
|
self._overrides_file_content = content
|
||||||
|
return False
|
||||||
|
|
||||||
|
self._overrides_file_mtime_ns = stat.st_mtime_ns
|
||||||
|
self._overrides_file_content = content
|
||||||
self.set_locale_overrides(payload)
|
self.set_locale_overrides(payload)
|
||||||
logging.info("Locale overrides reloaded from %s", self._overrides_path)
|
logging.info("Locale overrides reloaded from %s", self._overrides_path)
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -16,7 +16,13 @@ from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
|
|||||||
from db.dal import payment_dal, user_dal
|
from db.dal import payment_dal, user_dal
|
||||||
from db.models import Payment, User
|
from db.models import Payment, User
|
||||||
|
|
||||||
from .common import Translator, format_human_units, make_translator, sale_mode_base
|
from .common import (
|
||||||
|
Translator,
|
||||||
|
format_human_units,
|
||||||
|
make_translator,
|
||||||
|
sale_mode_base,
|
||||||
|
sale_mode_tariff_key,
|
||||||
|
)
|
||||||
|
|
||||||
_TRAFFIC_MODES = {"traffic", "traffic_package", "topup", "premium_topup"}
|
_TRAFFIC_MODES = {"traffic", "traffic_package", "topup", "premium_topup"}
|
||||||
_HWID_DEVICE_MODES = {"hwid_device", "hwid_devices", "hwid_devices_renewal"}
|
_HWID_DEVICE_MODES = {"hwid_device", "hwid_devices", "hwid_devices_renewal"}
|
||||||
@@ -304,6 +310,7 @@ async def finalize_successful_payment(
|
|||||||
activation_months or 1,
|
activation_months or 1,
|
||||||
current_payment_db_id=req.payment.payment_id,
|
current_payment_db_id=req.payment.payment_id,
|
||||||
skip_if_active_before_payment=False,
|
skip_if_active_before_payment=False,
|
||||||
|
tariff_key=sale_mode_tariff_key(req.sale_mode),
|
||||||
)
|
)
|
||||||
await req.session.commit()
|
await req.session.commit()
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from typing import Any, Optional
|
|||||||
from aiogram import Bot
|
from aiogram import Bot
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from bot.services.user_email_notifications import send_user_notification_email
|
||||||
from db.dal import payment_dal, user_dal
|
from db.dal import payment_dal, user_dal
|
||||||
from db.models import Payment
|
from db.models import Payment
|
||||||
|
|
||||||
@@ -55,11 +56,21 @@ async def notify_user_payment_failed(
|
|||||||
db_user.language_code if db_user and db_user.language_code else settings.DEFAULT_LANGUAGE
|
db_user.language_code if db_user and db_user.language_code else settings.DEFAULT_LANGUAGE
|
||||||
)
|
)
|
||||||
translator = make_translator(i18n, language)
|
translator = make_translator(i18n, language)
|
||||||
|
message_text = translator(message_key)
|
||||||
try:
|
try:
|
||||||
await bot.send_message(payment.user_id, translator(message_key))
|
await bot.send_message(payment.user_id, message_text)
|
||||||
except Exception:
|
except Exception:
|
||||||
logging.exception(
|
logging.exception(
|
||||||
"Webhook helper: failed to notify user %s about %s.",
|
"Webhook helper: failed to notify user %s about %s.",
|
||||||
payment.user_id,
|
payment.user_id,
|
||||||
message_key,
|
message_key,
|
||||||
)
|
)
|
||||||
|
if db_user:
|
||||||
|
await send_user_notification_email(
|
||||||
|
settings=settings,
|
||||||
|
i18n=i18n,
|
||||||
|
user=db_user,
|
||||||
|
subject_key="email_payment_failed_subject",
|
||||||
|
message_text=message_text,
|
||||||
|
dashboard_url=(getattr(settings, "SUBSCRIPTION_MINI_APP_URL", "") or None),
|
||||||
|
)
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ from bot.services.lknpd_service import LknpdService
|
|||||||
from bot.services.panel_api_service import PanelApiService
|
from bot.services.panel_api_service import PanelApiService
|
||||||
from bot.services.referral_service import ReferralService
|
from bot.services.referral_service import ReferralService
|
||||||
from bot.services.subscription_service import SubscriptionService
|
from bot.services.subscription_service import SubscriptionService
|
||||||
|
from bot.services.user_email_notifications import send_user_notification_email
|
||||||
from bot.utils.config_link import prepare_config_links
|
from bot.utils.config_link import prepare_config_links
|
||||||
from bot.utils.install_links import ensure_user_install_guide_links
|
from bot.utils.install_links import ensure_user_install_guide_links
|
||||||
from bot.utils.request_security import ip_in_allowlist, request_client_ip
|
from bot.utils.request_security import ip_in_allowlist, request_client_ip
|
||||||
@@ -75,6 +76,9 @@ from .shared import (
|
|||||||
from .shared import (
|
from .shared import (
|
||||||
sale_mode_base as _sale_mode_base,
|
sale_mode_base as _sale_mode_base,
|
||||||
)
|
)
|
||||||
|
from .shared import (
|
||||||
|
sale_mode_tariff_key as _sale_mode_tariff_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class YooKassaConfig(ProviderEnvConfig):
|
class YooKassaConfig(ProviderEnvConfig):
|
||||||
@@ -749,6 +753,7 @@ async def process_successful_payment(
|
|||||||
months_for_activation or int(subscription_months) or 1,
|
months_for_activation or int(subscription_months) or 1,
|
||||||
current_payment_db_id=payment_db_id,
|
current_payment_db_id=payment_db_id,
|
||||||
skip_if_active_before_payment=False,
|
skip_if_active_before_payment=False,
|
||||||
|
tariff_key=_sale_mode_tariff_key(sale_mode),
|
||||||
)
|
)
|
||||||
applied_referee_bonus_days_from_referral: Optional[int] = None
|
applied_referee_bonus_days_from_referral: Optional[int] = None
|
||||||
if referral_bonus_info and referral_bonus_info.get("referee_new_end_date"):
|
if referral_bonus_info and referral_bonus_info.get("referee_new_end_date"):
|
||||||
@@ -949,7 +954,20 @@ async def process_cancelled_payment(
|
|||||||
user_lang = db_user.language_code
|
user_lang = db_user.language_code
|
||||||
|
|
||||||
_ = lambda key, **kwargs: i18n.gettext(user_lang, key, **kwargs)
|
_ = lambda key, **kwargs: i18n.gettext(user_lang, key, **kwargs)
|
||||||
await bot.send_message(user_id, _("payment_failed"))
|
message_text = _("payment_failed")
|
||||||
|
try:
|
||||||
|
await bot.send_message(user_id, message_text)
|
||||||
|
except Exception:
|
||||||
|
logging.exception("Failed to notify YooKassa user %s about cancelled payment.", user_id)
|
||||||
|
if db_user:
|
||||||
|
await send_user_notification_email(
|
||||||
|
settings=settings,
|
||||||
|
i18n=i18n,
|
||||||
|
user=db_user,
|
||||||
|
subject_key="email_payment_failed_subject",
|
||||||
|
message_text=message_text,
|
||||||
|
dashboard_url=(settings.SUBSCRIPTION_MINI_APP_URL or None),
|
||||||
|
)
|
||||||
|
|
||||||
except Exception as e_process_cancel:
|
except Exception as e_process_cancel:
|
||||||
logging.error(
|
logging.error(
|
||||||
@@ -1202,13 +1220,32 @@ async def yookassa_webhook_route(request: web.Request):
|
|||||||
get_back_to_payment_methods_keyboard,
|
get_back_to_payment_methods_keyboard,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
message_text = _("payment_method_bound_success")
|
||||||
|
try:
|
||||||
await bot.send_message(
|
await bot.send_message(
|
||||||
chat_id=user_id,
|
chat_id=user_id,
|
||||||
text=_("payment_method_bound_success"),
|
text=message_text,
|
||||||
reply_markup=get_back_to_payment_methods_keyboard(
|
reply_markup=get_back_to_payment_methods_keyboard(
|
||||||
i18n_lang, i18n_instance
|
i18n_lang, i18n_instance
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
except Exception:
|
||||||
|
logging.exception(
|
||||||
|
"Failed to notify user %s "
|
||||||
|
"about payment method binding.",
|
||||||
|
user_id,
|
||||||
|
)
|
||||||
|
if db_user:
|
||||||
|
await send_user_notification_email(
|
||||||
|
settings=settings,
|
||||||
|
i18n=i18n_instance,
|
||||||
|
user=db_user,
|
||||||
|
subject_key="email_payment_method_bound_subject",
|
||||||
|
message_text=message_text,
|
||||||
|
dashboard_url=(
|
||||||
|
settings.SUBSCRIPTION_MINI_APP_URL or None
|
||||||
|
),
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
# Attempt to cancel the authorization to avoid charge hold
|
# Attempt to cancel the authorization to avoid charge hold
|
||||||
|
|||||||
@@ -202,6 +202,41 @@ def _format_traffic(traffic_gb: Optional[float]) -> str:
|
|||||||
return str(int(value)) if value.is_integer() else f"{value:g}"
|
return str(int(value)) if value.is_integer() else f"{value:g}"
|
||||||
|
|
||||||
|
|
||||||
|
_ALLOWED_INLINE_TAGS = {
|
||||||
|
"b": "strong",
|
||||||
|
"strong": "strong",
|
||||||
|
"i": "em",
|
||||||
|
"em": "em",
|
||||||
|
"u": "u",
|
||||||
|
"s": "s",
|
||||||
|
"code": "code",
|
||||||
|
}
|
||||||
|
_INLINE_TAG_RE = re.compile(r"</?(?:b|strong|i|em|u|s|code)>", re.IGNORECASE)
|
||||||
|
_ANY_TAG_RE = re.compile(r"<[^>]+>")
|
||||||
|
|
||||||
|
|
||||||
|
def _telegram_html_to_email_html(value: str) -> str:
|
||||||
|
"""Escape arbitrary text while preserving the tiny Telegram HTML subset we use."""
|
||||||
|
source = str(value or "")
|
||||||
|
chunks: list[str] = []
|
||||||
|
cursor = 0
|
||||||
|
for match in _INLINE_TAG_RE.finditer(source):
|
||||||
|
chunks.append(html.escape(source[cursor : match.start()]))
|
||||||
|
raw_tag = match.group(0)
|
||||||
|
closing = raw_tag.startswith("</")
|
||||||
|
tag_name = raw_tag.strip("</>").lower()
|
||||||
|
mapped = _ALLOWED_INLINE_TAGS.get(tag_name)
|
||||||
|
if mapped:
|
||||||
|
chunks.append(f"</{mapped}>" if closing else f"<{mapped}>")
|
||||||
|
cursor = match.end()
|
||||||
|
chunks.append(html.escape(source[cursor:]))
|
||||||
|
return "".join(chunks).replace("\n", "<br>")
|
||||||
|
|
||||||
|
|
||||||
|
def _telegram_html_to_text(value: str) -> str:
|
||||||
|
return html.unescape(_ANY_TAG_RE.sub("", str(value or "")))
|
||||||
|
|
||||||
|
|
||||||
def _format_minutes(seconds: int) -> int:
|
def _format_minutes(seconds: int) -> int:
|
||||||
return max(1, int(seconds) // 60)
|
return max(1, int(seconds) // 60)
|
||||||
|
|
||||||
@@ -351,12 +386,15 @@ def render_payment_success(
|
|||||||
lang = _normalize_lang(language_code, settings)
|
lang = _normalize_lang(language_code, settings)
|
||||||
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
|
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
|
||||||
brand = _brand_title(settings)
|
brand = _brand_title(settings)
|
||||||
is_traffic = (sale_mode or "").split("@", 1)[0].split("|", 1)[0] in {
|
sale_base = (sale_mode or "").split("@", 1)[0].split("|", 1)[0]
|
||||||
|
is_traffic = sale_base in {
|
||||||
"traffic",
|
"traffic",
|
||||||
"traffic_package",
|
"traffic_package",
|
||||||
"topup",
|
"topup",
|
||||||
"premium_topup",
|
"premium_topup",
|
||||||
}
|
}
|
||||||
|
is_hwid = sale_base in {"hwid_device", "hwid_devices", "hwid_devices_renewal"}
|
||||||
|
is_tariff_upgrade = sale_base == "tariff_upgrade"
|
||||||
amount_text = _format_amount(amount, currency)
|
amount_text = _format_amount(amount, currency)
|
||||||
safe_dashboard_url = (dashboard_url or "").strip()
|
safe_dashboard_url = (dashboard_url or "").strip()
|
||||||
end_date = end_date_text or "—"
|
end_date = end_date_text or "—"
|
||||||
@@ -370,7 +408,12 @@ def render_payment_success(
|
|||||||
cta_label = _t_text(i18n, lang, "email_payment_success_cta")
|
cta_label = _t_text(i18n, lang, "email_payment_success_cta")
|
||||||
|
|
||||||
if is_traffic:
|
if is_traffic:
|
||||||
intro = _t_text(i18n, lang, "email_payment_success_intro_traffic", traffic_gb=traffic_label)
|
intro_key = (
|
||||||
|
"email_payment_success_intro_premium_topup"
|
||||||
|
if sale_base == "premium_topup"
|
||||||
|
else "email_payment_success_intro_traffic"
|
||||||
|
)
|
||||||
|
intro = _t_text(i18n, lang, intro_key, traffic_gb=traffic_label)
|
||||||
period_label = _t_text(i18n, lang, "email_payment_success_row_traffic")
|
period_label = _t_text(i18n, lang, "email_payment_success_row_traffic")
|
||||||
period_value = _t_text(
|
period_value = _t_text(
|
||||||
i18n, lang, "email_payment_success_traffic_value", traffic_gb=traffic_label
|
i18n, lang, "email_payment_success_traffic_value", traffic_gb=traffic_label
|
||||||
@@ -383,6 +426,30 @@ def render_payment_success(
|
|||||||
traffic_gb=traffic_label,
|
traffic_gb=traffic_label,
|
||||||
end_date=end_date,
|
end_date=end_date,
|
||||||
)
|
)
|
||||||
|
elif is_hwid:
|
||||||
|
devices_count = max(0, int(months or 0))
|
||||||
|
intro = _t_text(i18n, lang, "email_payment_success_intro_hwid", count=devices_count)
|
||||||
|
period_label = _t_text(i18n, lang, "email_payment_success_row_hwid")
|
||||||
|
period_value = _t_text(i18n, lang, "email_payment_success_hwid_value", count=devices_count)
|
||||||
|
text = _t_text(
|
||||||
|
i18n,
|
||||||
|
lang,
|
||||||
|
"email_payment_success_text_hwid",
|
||||||
|
amount=amount_text,
|
||||||
|
count=devices_count,
|
||||||
|
end_date=end_date,
|
||||||
|
)
|
||||||
|
elif is_tariff_upgrade:
|
||||||
|
intro = _t_text(i18n, lang, "email_payment_success_intro_tariff_upgrade")
|
||||||
|
period_label = _t_text(i18n, lang, "email_payment_success_row_operation")
|
||||||
|
period_value = _t_text(i18n, lang, "email_payment_success_tariff_upgrade_value")
|
||||||
|
text = _t_text(
|
||||||
|
i18n,
|
||||||
|
lang,
|
||||||
|
"email_payment_success_text_tariff_upgrade",
|
||||||
|
amount=amount_text,
|
||||||
|
end_date=end_date,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
months_int = int(months or 0)
|
months_int = int(months or 0)
|
||||||
intro = _t_text(i18n, lang, "email_payment_success_intro_subscription", months=months_int)
|
intro = _t_text(i18n, lang, "email_payment_success_intro_subscription", months=months_int)
|
||||||
@@ -434,6 +501,66 @@ def render_payment_success(
|
|||||||
return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered)
|
return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered)
|
||||||
|
|
||||||
|
|
||||||
|
def render_user_notification(
|
||||||
|
settings: Settings,
|
||||||
|
*,
|
||||||
|
language_code: Optional[str],
|
||||||
|
subject: str,
|
||||||
|
message_text: str,
|
||||||
|
dashboard_url: Optional[str] = None,
|
||||||
|
cta_label: Optional[str] = None,
|
||||||
|
heading: Optional[str] = None,
|
||||||
|
intro: Optional[str] = None,
|
||||||
|
i18n: Optional[JsonI18n] = None,
|
||||||
|
) -> EmailContent:
|
||||||
|
i18n = _resolve_i18n(i18n)
|
||||||
|
lang = _normalize_lang(language_code, settings)
|
||||||
|
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
|
||||||
|
brand = _brand_title(settings)
|
||||||
|
safe_dashboard_url = (dashboard_url or "").strip()
|
||||||
|
final_subject = (subject or "").strip() or _t_text(
|
||||||
|
i18n, lang, "email_user_notification_subject"
|
||||||
|
)
|
||||||
|
final_heading = (heading or "").strip() or final_subject
|
||||||
|
final_intro = (intro or "").strip() or _t_text(i18n, lang, "email_user_notification_intro")
|
||||||
|
final_cta_label = (cta_label or "").strip() or _t_text(
|
||||||
|
i18n,
|
||||||
|
lang,
|
||||||
|
"email_user_notification_cta",
|
||||||
|
)
|
||||||
|
footer = _t_html(i18n, lang, "email_footer_auto", brand=brand)
|
||||||
|
message_html = (
|
||||||
|
f'<div style="margin:0 0 16px 0;background:{_BG};border:1px solid {_BORDER};'
|
||||||
|
f"border-radius:14px;padding:14px 16px;font-size:14px;line-height:1.55;color:{_TEXT};"
|
||||||
|
f'white-space:pre-wrap;">{_telegram_html_to_email_html(message_text)}</div>'
|
||||||
|
)
|
||||||
|
body_parts = [message_html]
|
||||||
|
if safe_dashboard_url:
|
||||||
|
body_parts.append(
|
||||||
|
_cta_button_html(label=final_cta_label, url=safe_dashboard_url, accent=accent)
|
||||||
|
)
|
||||||
|
|
||||||
|
rendered = _layout(
|
||||||
|
settings=settings,
|
||||||
|
preheader=final_subject,
|
||||||
|
heading=final_heading,
|
||||||
|
intro_html=html.escape(final_intro),
|
||||||
|
body_html="".join(body_parts),
|
||||||
|
footer_html=footer,
|
||||||
|
)
|
||||||
|
text_lines = [final_subject, "", _telegram_html_to_text(message_text)]
|
||||||
|
if safe_dashboard_url:
|
||||||
|
text_lines.extend(
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
_t_text(
|
||||||
|
i18n, lang, "email_user_notification_text_dashboard", url=safe_dashboard_url
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return EmailContent(subject=final_subject, text="\n".join(text_lines), html=rendered)
|
||||||
|
|
||||||
|
|
||||||
def render_subscription_expiring(
|
def render_subscription_expiring(
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
*,
|
*,
|
||||||
@@ -497,6 +624,107 @@ def render_subscription_expiring(
|
|||||||
return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered)
|
return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered)
|
||||||
|
|
||||||
|
|
||||||
|
def _subscription_lifecycle_title(
|
||||||
|
i18n: JsonI18n,
|
||||||
|
lang: str,
|
||||||
|
notification_key: str,
|
||||||
|
*,
|
||||||
|
days_left: Optional[int],
|
||||||
|
hours_before: Optional[int],
|
||||||
|
) -> str:
|
||||||
|
if notification_key == "before_2d_autorenew":
|
||||||
|
return _t_text(i18n, lang, "email_subscription_lifecycle_subject_autorenew")
|
||||||
|
if notification_key == "expired":
|
||||||
|
return _t_text(i18n, lang, "email_subscription_lifecycle_subject_expired")
|
||||||
|
if notification_key == "expired_24h_after":
|
||||||
|
return _t_text(i18n, lang, "email_subscription_lifecycle_subject_expired_after")
|
||||||
|
if hours_before is not None:
|
||||||
|
return _t_text(
|
||||||
|
i18n,
|
||||||
|
lang,
|
||||||
|
"email_subscription_lifecycle_subject_before_hours",
|
||||||
|
hours=hours_before,
|
||||||
|
)
|
||||||
|
return _t_text(
|
||||||
|
i18n,
|
||||||
|
lang,
|
||||||
|
"email_subscription_lifecycle_subject_before_days",
|
||||||
|
days=max(0, int(days_left or 0)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def render_subscription_lifecycle_notification(
|
||||||
|
settings: Settings,
|
||||||
|
*,
|
||||||
|
language_code: Optional[str],
|
||||||
|
notification_key: str,
|
||||||
|
message_text: str,
|
||||||
|
end_date_text: str,
|
||||||
|
dashboard_url: Optional[str],
|
||||||
|
mirrored_from_telegram: bool = False,
|
||||||
|
days_left: Optional[int] = None,
|
||||||
|
hours_before: Optional[int] = None,
|
||||||
|
i18n: Optional[JsonI18n] = None,
|
||||||
|
) -> EmailContent:
|
||||||
|
i18n = _resolve_i18n(i18n)
|
||||||
|
lang = _normalize_lang(language_code, settings)
|
||||||
|
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
|
||||||
|
brand = _brand_title(settings)
|
||||||
|
safe_dashboard_url = (dashboard_url or "").strip()
|
||||||
|
end_date = end_date_text or "—"
|
||||||
|
subject = _subscription_lifecycle_title(
|
||||||
|
i18n,
|
||||||
|
lang,
|
||||||
|
notification_key,
|
||||||
|
days_left=days_left,
|
||||||
|
hours_before=hours_before,
|
||||||
|
)
|
||||||
|
intro_key = (
|
||||||
|
"email_subscription_lifecycle_intro_mirrored"
|
||||||
|
if mirrored_from_telegram
|
||||||
|
else "email_subscription_lifecycle_intro_direct"
|
||||||
|
)
|
||||||
|
intro = _t_text(i18n, lang, intro_key)
|
||||||
|
footer = _t_html(i18n, lang, "email_footer_auto", brand=brand)
|
||||||
|
cta_label = _t_text(i18n, lang, "email_subscription_lifecycle_cta")
|
||||||
|
|
||||||
|
rows = [
|
||||||
|
(_t_text(i18n, lang, "email_subscription_lifecycle_row_end_date"), end_date),
|
||||||
|
]
|
||||||
|
message_html = (
|
||||||
|
f'<div style="margin:0 0 16px 0;background:{_BG};border:1px solid {_BORDER};'
|
||||||
|
f"border-radius:14px;padding:14px 16px;font-size:14px;line-height:1.55;color:{_TEXT};"
|
||||||
|
f'white-space:pre-wrap;">{html.escape(message_text or "")}</div>'
|
||||||
|
)
|
||||||
|
body_parts = [_info_rows_html(rows), message_html]
|
||||||
|
if safe_dashboard_url:
|
||||||
|
body_parts.append(_cta_button_html(label=cta_label, url=safe_dashboard_url, accent=accent))
|
||||||
|
|
||||||
|
rendered = _layout(
|
||||||
|
settings=settings,
|
||||||
|
preheader=subject,
|
||||||
|
heading=subject,
|
||||||
|
intro_html=html.escape(intro),
|
||||||
|
body_html="".join(body_parts),
|
||||||
|
footer_html=footer,
|
||||||
|
)
|
||||||
|
|
||||||
|
text_lines = [subject, "", message_text]
|
||||||
|
if safe_dashboard_url:
|
||||||
|
text_lines.extend(
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
_t_text(
|
||||||
|
i18n,
|
||||||
|
lang,
|
||||||
|
"email_subscription_lifecycle_text_renew",
|
||||||
|
url=safe_dashboard_url,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered)
|
||||||
|
|
||||||
|
|
||||||
def _support_email(
|
def _support_email(
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
i18n: Optional[JsonI18n],
|
i18n: Optional[JsonI18n],
|
||||||
|
|||||||
@@ -328,12 +328,112 @@ class PanelApiService:
|
|||||||
async def _get_user_by_uuid_uncached(
|
async def _get_user_by_uuid_uncached(
|
||||||
self, user_uuid: str, log_response: bool = False
|
self, user_uuid: str, log_response: bool = False
|
||||||
) -> Optional[Dict[str, Any]]:
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
lookup = await self.get_user_by_uuid_lookup(user_uuid, log_response=log_response)
|
||||||
|
if lookup.get("ok") and isinstance(lookup.get("user"), dict):
|
||||||
|
return lookup["user"]
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _panel_response_details(response_data: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
||||||
|
if not isinstance(response_data, dict):
|
||||||
|
return {}
|
||||||
|
details = response_data.get("details")
|
||||||
|
return details if isinstance(details, dict) else {}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _panel_response_error_code(cls, response_data: Optional[Dict[str, Any]]) -> Optional[str]:
|
||||||
|
if not isinstance(response_data, dict):
|
||||||
|
return None
|
||||||
|
details = cls._panel_response_details(response_data)
|
||||||
|
error_code = (
|
||||||
|
response_data.get("errorCode")
|
||||||
|
or response_data.get("code")
|
||||||
|
or details.get("errorCode")
|
||||||
|
or details.get("code")
|
||||||
|
)
|
||||||
|
return str(error_code) if error_code else None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _panel_response_message(cls, response_data: Optional[Dict[str, Any]]) -> Optional[str]:
|
||||||
|
if not isinstance(response_data, dict):
|
||||||
|
return None
|
||||||
|
details = cls._panel_response_details(response_data)
|
||||||
|
message = (
|
||||||
|
response_data.get("message")
|
||||||
|
or details.get("message")
|
||||||
|
or details.get("error")
|
||||||
|
or details.get("raw_response_text")
|
||||||
|
)
|
||||||
|
if message is None:
|
||||||
|
return None
|
||||||
|
message = str(message).replace("\n", " ").strip()
|
||||||
|
return message[:500] if message else None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _is_user_not_found_response(cls, response_data: Optional[Dict[str, Any]]) -> bool:
|
||||||
|
if not isinstance(response_data, dict):
|
||||||
|
return False
|
||||||
|
status_code = response_data.get("status_code")
|
||||||
|
error_code = cls._panel_response_error_code(response_data)
|
||||||
|
if error_code in {"A040", "A062", "USER_NOT_FOUND", "NOT_FOUND"}:
|
||||||
|
return True
|
||||||
|
return status_code == 404
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _describe_user_lookup_failure(
|
||||||
|
cls,
|
||||||
|
response_data: Optional[Dict[str, Any]],
|
||||||
|
*,
|
||||||
|
not_found: bool,
|
||||||
|
) -> str:
|
||||||
|
if not isinstance(response_data, dict):
|
||||||
|
return "classification=panel_lookup_failed response=empty"
|
||||||
|
|
||||||
|
classification = "confirmed_not_found" if not_found else "panel_lookup_failed"
|
||||||
|
parts = [f"classification={classification}"]
|
||||||
|
status_code = response_data.get("status_code")
|
||||||
|
if status_code is not None:
|
||||||
|
parts.append(f"status_code={status_code}")
|
||||||
|
error_code = cls._panel_response_error_code(response_data)
|
||||||
|
if error_code:
|
||||||
|
parts.append(f"error_code={error_code}")
|
||||||
|
message = cls._panel_response_message(response_data)
|
||||||
|
if message:
|
||||||
|
parts.append(f"message={message}")
|
||||||
|
return " ".join(parts)
|
||||||
|
|
||||||
|
async def get_user_by_uuid_lookup(
|
||||||
|
self, user_uuid: str, log_response: bool = False
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Fetch a panel user and preserve whether a miss was confirmed.
|
||||||
|
|
||||||
|
``get_user_by_uuid`` historically returned ``None`` both for a real
|
||||||
|
404/not-found and for transient panel/API failures. Callers that may
|
||||||
|
mutate local state need this richer result to avoid treating an outage
|
||||||
|
as a deleted panel user.
|
||||||
|
"""
|
||||||
endpoint = f"/users/{user_uuid}"
|
endpoint = f"/users/{user_uuid}"
|
||||||
full_response = await self._request("GET", endpoint, log_full_response=log_response)
|
full_response = await self._request("GET", endpoint, log_full_response=log_response)
|
||||||
if full_response and not full_response.get("error") and "response" in full_response:
|
if full_response and not full_response.get("error") and "response" in full_response:
|
||||||
return full_response.get("response")
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"user": full_response.get("response"),
|
||||||
|
"not_found": False,
|
||||||
|
"failure_reason": None,
|
||||||
|
"response": full_response,
|
||||||
|
}
|
||||||
|
|
||||||
return None
|
not_found = self._is_user_not_found_response(full_response)
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"user": None,
|
||||||
|
"not_found": not_found,
|
||||||
|
"failure_reason": self._describe_user_lookup_failure(
|
||||||
|
full_response,
|
||||||
|
not_found=not_found,
|
||||||
|
),
|
||||||
|
"response": full_response,
|
||||||
|
}
|
||||||
|
|
||||||
async def get_user(
|
async def get_user(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -3,12 +3,15 @@ import hashlib
|
|||||||
import hmac
|
import hmac
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from aiogram import Bot
|
from aiogram import Bot
|
||||||
from aiogram.types import InlineKeyboardMarkup
|
from aiogram.types import InlineKeyboardMarkup
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy import or_, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload, sessionmaker
|
||||||
|
|
||||||
from bot.infra.webhook_queue import enqueue_webhook_event
|
from bot.infra.webhook_queue import enqueue_webhook_event
|
||||||
from bot.keyboards.inline.user_keyboards import (
|
from bot.keyboards.inline.user_keyboards import (
|
||||||
@@ -16,18 +19,40 @@ from bot.keyboards.inline.user_keyboards import (
|
|||||||
get_subscribe_only_markup,
|
get_subscribe_only_markup,
|
||||||
)
|
)
|
||||||
from bot.middlewares.i18n import JsonI18n
|
from bot.middlewares.i18n import JsonI18n
|
||||||
|
from bot.services.subscription_lifecycle_notifications import (
|
||||||
|
SubscriptionLifecycleNotificationService,
|
||||||
|
SubscriptionNotificationStage,
|
||||||
|
)
|
||||||
from config.settings import Settings
|
from config.settings import Settings
|
||||||
from db.dal import tariff_dal, user_dal
|
from db.dal import subscription_dal, tariff_dal, user_dal
|
||||||
|
from db.models import Subscription, User
|
||||||
|
|
||||||
from .email_auth_service import EmailAuthService
|
|
||||||
from .email_templates import render_subscription_expiring
|
|
||||||
from .panel_api_service import PanelApiService
|
from .panel_api_service import PanelApiService
|
||||||
|
|
||||||
EVENT_MAP = {
|
EVENT_MAP = {
|
||||||
"user.expires_in_72_hours": (3, "subscription_72h_notification"),
|
"user.expires_in_72_hours": SubscriptionNotificationStage(
|
||||||
"user.expires_in_48_hours": (2, "subscription_48h_notification"),
|
key="before_3d",
|
||||||
"user.expires_in_24_hours": (1, "subscription_24h_notification"),
|
message_key="subscription_72h_notification",
|
||||||
|
days_left=3,
|
||||||
|
),
|
||||||
|
"user.expires_in_48_hours": SubscriptionNotificationStage(
|
||||||
|
key="before_2d",
|
||||||
|
message_key="subscription_48h_notification",
|
||||||
|
days_left=2,
|
||||||
|
),
|
||||||
|
"user.expires_in_24_hours": SubscriptionNotificationStage(
|
||||||
|
key="before_1d",
|
||||||
|
message_key="subscription_24h_notification",
|
||||||
|
days_left=1,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
ACTIONABLE_EVENTS = frozenset(
|
||||||
|
{
|
||||||
|
*EVENT_MAP.keys(),
|
||||||
|
"user.expired",
|
||||||
|
"user.expired_24_hours_ago",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class PanelWebhookService:
|
class PanelWebhookService:
|
||||||
@@ -48,6 +73,11 @@ class PanelWebhookService:
|
|||||||
self.i18n = i18n
|
self.i18n = i18n
|
||||||
self.async_session_factory = async_session_factory
|
self.async_session_factory = async_session_factory
|
||||||
self.panel_service = panel_service
|
self.panel_service = panel_service
|
||||||
|
self.lifecycle_notifications = SubscriptionLifecycleNotificationService(
|
||||||
|
settings,
|
||||||
|
bot,
|
||||||
|
i18n,
|
||||||
|
)
|
||||||
self._event_semaphore = asyncio.Semaphore(self._MAX_CONCURRENT_EVENTS)
|
self._event_semaphore = asyncio.Semaphore(self._MAX_CONCURRENT_EVENTS)
|
||||||
if not self.settings.PANEL_WEBHOOK_SECRET:
|
if not self.settings.PANEL_WEBHOOK_SECRET:
|
||||||
logging.error(
|
logging.error(
|
||||||
@@ -102,112 +132,200 @@ class PanelWebhookService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def handle_event(self, event_name: str, user_payload: dict):
|
async def handle_event(self, event_name: str, user_payload: dict):
|
||||||
telegram_id = user_payload.get("telegramId")
|
|
||||||
if not telegram_id:
|
|
||||||
logging.warning("Panel webhook without telegramId received")
|
|
||||||
return
|
|
||||||
user_id = int(telegram_id)
|
|
||||||
|
|
||||||
if not self.settings.SUBSCRIPTION_NOTIFICATIONS_ENABLED:
|
if not self.settings.SUBSCRIPTION_NOTIFICATIONS_ENABLED:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if event_name not in ACTIONABLE_EVENTS:
|
||||||
|
logging.info(
|
||||||
|
"Panel webhook event %s ignored: event is not used for subscription "
|
||||||
|
"notifications; %s",
|
||||||
|
event_name,
|
||||||
|
self._payload_log_context(user_payload),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
async with self.async_session_factory() as session:
|
async with self.async_session_factory() as session:
|
||||||
db_user = await user_dal.get_user_by_telegram_id(session, user_id)
|
db_user = await self._user_for_payload(session, user_payload)
|
||||||
if not db_user:
|
sub = await self._subscription_for_payload(session, user_payload, db_user)
|
||||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
telegram_id = self._payload_telegram_id(user_payload)
|
||||||
internal_user_id = db_user.user_id if db_user else user_id
|
internal_user_id = (
|
||||||
|
int(db_user.user_id)
|
||||||
|
if db_user
|
||||||
|
else int(getattr(sub, "user_id", 0) or telegram_id or 0)
|
||||||
|
)
|
||||||
lang = (
|
lang = (
|
||||||
db_user.language_code
|
db_user.language_code
|
||||||
if db_user and db_user.language_code
|
if db_user and db_user.language_code
|
||||||
else self.settings.DEFAULT_LANGUAGE
|
else self.settings.DEFAULT_LANGUAGE
|
||||||
)
|
)
|
||||||
first_name = db_user.first_name or f"User {user_id}" if db_user else f"User {user_id}"
|
if not sub:
|
||||||
user_email = (db_user.email or "").strip() if db_user else ""
|
if not telegram_id:
|
||||||
|
local_user_id = getattr(db_user, "user_id", None) if db_user else None
|
||||||
|
logging.warning(
|
||||||
|
"Panel webhook event %s cannot be matched to a local subscription; "
|
||||||
|
"notification skipped. %s local_user_id=%s. Possible causes: "
|
||||||
|
"panel user was created outside the bot, subscription was deleted "
|
||||||
|
"or not synced, panel identifiers changed, or skip_notifications "
|
||||||
|
"is enabled for the local subscription.",
|
||||||
|
event_name,
|
||||||
|
self._payload_log_context(user_payload),
|
||||||
|
local_user_id or "N/A",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
await self._send_legacy_without_dedupe(
|
||||||
|
event_name,
|
||||||
|
user_payload,
|
||||||
|
int(telegram_id),
|
||||||
|
lang,
|
||||||
|
db_user,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
markup = get_subscribe_only_markup(lang, self.i18n)
|
markup = get_subscribe_only_markup(lang, self.i18n)
|
||||||
|
end_date_text = self._payload_expire_date(user_payload)
|
||||||
|
|
||||||
if event_name in EVENT_MAP:
|
if event_name in EVENT_MAP:
|
||||||
days_left, msg_key = EVENT_MAP[event_name]
|
stage = EVENT_MAP[event_name]
|
||||||
|
days_left = int(stage.days_left or 0)
|
||||||
hwid_renewal_note = await self._hwid_renewal_note(internal_user_id, lang)
|
hwid_renewal_note = await self._hwid_renewal_note(internal_user_id, lang)
|
||||||
if days_left == 1:
|
if days_left == 1:
|
||||||
# Trigger auto-renew via SubscriptionService (wired in at factory)
|
# Trigger auto-renew via SubscriptionService (wired in at factory)
|
||||||
try:
|
try:
|
||||||
subscription_service = getattr(self, "subscription_service", None)
|
subscription_service = getattr(self, "subscription_service", None)
|
||||||
if subscription_service:
|
if subscription_service:
|
||||||
async with self.async_session_factory() as session:
|
async with self.async_session_factory() as renewal_session:
|
||||||
from db.dal import subscription_dal
|
active_sub = (
|
||||||
|
await subscription_dal.get_active_subscription_by_user_id(
|
||||||
sub = await subscription_dal.get_active_subscription_by_user_id(
|
renewal_session,
|
||||||
session, internal_user_id
|
internal_user_id,
|
||||||
)
|
)
|
||||||
if sub and sub.auto_renew_enabled and sub.provider == "yookassa":
|
)
|
||||||
|
if (
|
||||||
|
active_sub
|
||||||
|
and active_sub.auto_renew_enabled
|
||||||
|
and active_sub.provider == "yookassa"
|
||||||
|
):
|
||||||
try:
|
try:
|
||||||
ok = await subscription_service.charge_subscription_renewal(
|
ok = await subscription_service.charge_subscription_renewal(
|
||||||
session, sub
|
renewal_session,
|
||||||
|
active_sub,
|
||||||
)
|
)
|
||||||
# If initiation succeeded, suppress the 24h reminder by returning early # noqa: E501
|
# If initiation succeeded, suppress the 24h reminder by returning early # noqa: E501
|
||||||
if ok:
|
if ok:
|
||||||
await session.commit()
|
await renewal_session.commit()
|
||||||
return
|
return
|
||||||
else:
|
await renewal_session.rollback()
|
||||||
await session.rollback()
|
|
||||||
except Exception:
|
except Exception:
|
||||||
await session.rollback()
|
await renewal_session.rollback()
|
||||||
logging.exception("Auto-renew attempt (24h) failed")
|
logging.exception("Auto-renew attempt (24h) failed")
|
||||||
except Exception:
|
except Exception:
|
||||||
logging.exception("Auto-renew trigger (24h) failed pre-check")
|
logging.exception("Auto-renew trigger (24h) failed pre-check")
|
||||||
if days_left <= self.settings.SUBSCRIPTION_NOTIFY_DAYS_BEFORE:
|
if days_left <= self.settings.SUBSCRIPTION_NOTIFY_DAYS_BEFORE:
|
||||||
# For 48h event, if auto-renew is enabled, show special notice with cancel button
|
# For 48h, auto-renew users get a cancel button instead.
|
||||||
if days_left == 2:
|
if days_left == 2:
|
||||||
async with self.async_session_factory() as session:
|
active_sub = await subscription_dal.get_active_subscription_by_user_id(
|
||||||
from db.dal import subscription_dal
|
session,
|
||||||
|
internal_user_id,
|
||||||
sub = await subscription_dal.get_active_subscription_by_user_id(
|
|
||||||
session, internal_user_id
|
|
||||||
)
|
)
|
||||||
logging.info(
|
logging.info(
|
||||||
"48h webhook check: user_id=%s sub_found=%s auto_renew=%s provider=%s",
|
"48h webhook check: user_id=%s sub_found=%s auto_renew=%s provider=%s",
|
||||||
user_id,
|
internal_user_id,
|
||||||
bool(sub),
|
bool(active_sub),
|
||||||
getattr(sub, "auto_renew_enabled", None) if sub else None,
|
getattr(active_sub, "auto_renew_enabled", None) if active_sub else None,
|
||||||
getattr(sub, "provider", None) if sub else None,
|
getattr(active_sub, "provider", None) if active_sub else None,
|
||||||
)
|
)
|
||||||
if sub and sub.auto_renew_enabled and sub.provider == "yookassa":
|
if (
|
||||||
|
active_sub
|
||||||
|
and active_sub.auto_renew_enabled
|
||||||
|
and active_sub.provider == "yookassa"
|
||||||
|
):
|
||||||
cancel_kb = get_autorenew_cancel_keyboard(lang, self.i18n)
|
cancel_kb = get_autorenew_cancel_keyboard(lang, self.i18n)
|
||||||
await self._send_message(
|
await self.lifecycle_notifications.send_stage(
|
||||||
user_id,
|
session,
|
||||||
lang,
|
sub,
|
||||||
"autorenew_48h_charge_tomorrow_notice",
|
SubscriptionNotificationStage(
|
||||||
reply_markup=cancel_kb,
|
key="before_2d_autorenew",
|
||||||
user_name=first_name,
|
message_key="autorenew_48h_charge_tomorrow_notice",
|
||||||
|
days_left=2,
|
||||||
|
),
|
||||||
|
user=db_user,
|
||||||
|
telegram_markup=cancel_kb,
|
||||||
extra_text=hwid_renewal_note,
|
extra_text=hwid_renewal_note,
|
||||||
|
end_date_text=end_date_text,
|
||||||
)
|
)
|
||||||
|
await session.commit()
|
||||||
return
|
return
|
||||||
await self._send_message(
|
await self.lifecycle_notifications.send_stage(
|
||||||
user_id,
|
session,
|
||||||
lang,
|
sub,
|
||||||
msg_key,
|
stage,
|
||||||
reply_markup=markup,
|
user=db_user,
|
||||||
user_name=first_name,
|
telegram_markup=markup,
|
||||||
end_date=user_payload.get("expireAt", "")[:10],
|
|
||||||
extra_text=hwid_renewal_note,
|
extra_text=hwid_renewal_note,
|
||||||
|
end_date_text=end_date_text,
|
||||||
)
|
)
|
||||||
if days_left == 3 and user_email:
|
await session.commit()
|
||||||
await self._send_subscription_expiring_email(
|
|
||||||
recipient=user_email,
|
|
||||||
lang=lang,
|
|
||||||
days_left=days_left,
|
|
||||||
end_date_text=user_payload.get("expireAt", "")[:10],
|
|
||||||
)
|
|
||||||
elif event_name == "user.expired":
|
elif event_name == "user.expired":
|
||||||
if self.settings.SUBSCRIPTION_NOTIFY_ON_EXPIRE:
|
if self.settings.SUBSCRIPTION_NOTIFY_ON_EXPIRE:
|
||||||
|
await self.lifecycle_notifications.send_stage(
|
||||||
|
session,
|
||||||
|
sub,
|
||||||
|
SubscriptionNotificationStage(
|
||||||
|
key="expired",
|
||||||
|
message_key="subscription_expired_notification",
|
||||||
|
days_left=0,
|
||||||
|
),
|
||||||
|
user=db_user,
|
||||||
|
telegram_markup=markup,
|
||||||
|
end_date_text=end_date_text,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
elif (
|
||||||
|
event_name == "user.expired_24_hours_ago"
|
||||||
|
and self.settings.SUBSCRIPTION_NOTIFY_AFTER_EXPIRE
|
||||||
|
):
|
||||||
|
await self.lifecycle_notifications.send_stage(
|
||||||
|
session,
|
||||||
|
sub,
|
||||||
|
SubscriptionNotificationStage(
|
||||||
|
key="expired_24h_after",
|
||||||
|
message_key="subscription_expired_yesterday_notification",
|
||||||
|
days_left=0,
|
||||||
|
),
|
||||||
|
user=db_user,
|
||||||
|
telegram_markup=markup,
|
||||||
|
end_date_text=end_date_text,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
async def _send_legacy_without_dedupe(
|
||||||
|
self,
|
||||||
|
event_name: str,
|
||||||
|
user_payload: dict,
|
||||||
|
user_id: int,
|
||||||
|
lang: str,
|
||||||
|
db_user: Optional[User],
|
||||||
|
) -> None:
|
||||||
|
first_name = getattr(db_user, "first_name", None) or f"User {user_id}"
|
||||||
|
markup = get_subscribe_only_markup(lang, self.i18n)
|
||||||
|
if event_name in EVENT_MAP:
|
||||||
|
stage = EVENT_MAP[event_name]
|
||||||
|
await self._send_message(
|
||||||
|
user_id,
|
||||||
|
lang,
|
||||||
|
stage.message_key,
|
||||||
|
reply_markup=markup,
|
||||||
|
user_name=first_name,
|
||||||
|
end_date=self._payload_expire_date(user_payload),
|
||||||
|
)
|
||||||
|
elif event_name == "user.expired" and self.settings.SUBSCRIPTION_NOTIFY_ON_EXPIRE:
|
||||||
await self._send_message(
|
await self._send_message(
|
||||||
user_id,
|
user_id,
|
||||||
lang,
|
lang,
|
||||||
"subscription_expired_notification",
|
"subscription_expired_notification",
|
||||||
reply_markup=markup,
|
reply_markup=markup,
|
||||||
user_name=first_name,
|
user_name=first_name,
|
||||||
end_date=user_payload.get("expireAt", "")[:10],
|
end_date=self._payload_expire_date(user_payload),
|
||||||
)
|
)
|
||||||
elif (
|
elif (
|
||||||
event_name == "user.expired_24_hours_ago"
|
event_name == "user.expired_24_hours_ago"
|
||||||
@@ -219,33 +337,138 @@ class PanelWebhookService:
|
|||||||
"subscription_expired_yesterday_notification",
|
"subscription_expired_yesterday_notification",
|
||||||
reply_markup=markup,
|
reply_markup=markup,
|
||||||
user_name=first_name,
|
user_name=first_name,
|
||||||
end_date=user_payload.get("expireAt", "")[:10],
|
end_date=self._payload_expire_date(user_payload),
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _send_subscription_expiring_email(
|
async def _user_for_payload(
|
||||||
self,
|
self,
|
||||||
*,
|
session: AsyncSession,
|
||||||
recipient: str,
|
user_payload: dict,
|
||||||
lang: str,
|
) -> Optional[User]:
|
||||||
days_left: int,
|
telegram_id = self._payload_telegram_id(user_payload)
|
||||||
end_date_text: str,
|
if telegram_id:
|
||||||
) -> None:
|
user = await user_dal.get_user_by_telegram_id(session, telegram_id)
|
||||||
"""Best-effort branded reminder; silently no-ops without SMTP config."""
|
if user:
|
||||||
if not self.settings.email_auth_configured:
|
return user
|
||||||
return
|
user = await user_dal.get_user_by_id(session, telegram_id)
|
||||||
try:
|
if user:
|
||||||
content = render_subscription_expiring(
|
return user
|
||||||
self.settings,
|
|
||||||
language_code=lang,
|
panel_uuid = self._payload_panel_uuid(user_payload)
|
||||||
days_left=days_left,
|
if panel_uuid:
|
||||||
end_date_text=end_date_text,
|
user = await user_dal.get_user_by_panel_uuid(session, panel_uuid)
|
||||||
dashboard_url=(self.settings.SUBSCRIPTION_MINI_APP_URL or "").strip() or None,
|
if user:
|
||||||
i18n=self.i18n,
|
return user
|
||||||
|
|
||||||
|
email = str(user_payload.get("email") or "").strip()
|
||||||
|
if email:
|
||||||
|
return await user_dal.get_user_by_email(session, email)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _subscription_for_payload(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
user_payload: dict,
|
||||||
|
db_user: Optional[User],
|
||||||
|
) -> Optional[Subscription]:
|
||||||
|
conditions = []
|
||||||
|
if db_user:
|
||||||
|
conditions.append(Subscription.user_id == db_user.user_id)
|
||||||
|
panel_uuid = self._payload_panel_uuid(user_payload)
|
||||||
|
if panel_uuid:
|
||||||
|
conditions.append(Subscription.panel_user_uuid == panel_uuid)
|
||||||
|
if not conditions:
|
||||||
|
return None
|
||||||
|
base_stmt = (
|
||||||
|
select(Subscription)
|
||||||
|
.where(
|
||||||
|
Subscription.skip_notifications == False,
|
||||||
|
or_(*conditions),
|
||||||
)
|
)
|
||||||
email_service = EmailAuthService(self.settings, self.i18n)
|
.options(selectinload(Subscription.user))
|
||||||
await email_service.send_rendered_email(email=recipient, content=content)
|
)
|
||||||
except Exception:
|
|
||||||
logging.exception("Failed to send subscription-expiring email to %s", recipient)
|
expire_at = self._payload_expire_datetime(user_payload)
|
||||||
|
if expire_at is not None:
|
||||||
|
window_stmt = (
|
||||||
|
base_stmt.where(
|
||||||
|
Subscription.end_date >= expire_at - timedelta(days=1),
|
||||||
|
Subscription.end_date <= expire_at + timedelta(days=1),
|
||||||
|
)
|
||||||
|
.order_by(Subscription.end_date.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
result = await session.execute(window_stmt)
|
||||||
|
found = result.scalars().first()
|
||||||
|
if found:
|
||||||
|
return found
|
||||||
|
|
||||||
|
stmt = base_stmt.order_by(Subscription.end_date.desc()).limit(1)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
return result.scalars().first()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _payload_telegram_id(user_payload: dict) -> Optional[int]:
|
||||||
|
raw = user_payload.get("telegramId")
|
||||||
|
try:
|
||||||
|
value = int(raw or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
return value if value > 0 else None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _payload_panel_uuid(user_payload: dict) -> str:
|
||||||
|
return str(
|
||||||
|
user_payload.get("uuid")
|
||||||
|
or user_payload.get("userUuid")
|
||||||
|
or user_payload.get("shortUuid")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _payload_expire_date(user_payload: dict) -> str:
|
||||||
|
return str(user_payload.get("expireAt") or "")[:10]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _payload_log_context(user_payload: dict) -> str:
|
||||||
|
telegram_id = PanelWebhookService._payload_telegram_id(user_payload)
|
||||||
|
panel_uuid = PanelWebhookService._payload_panel_uuid(user_payload)
|
||||||
|
email = PanelWebhookService._mask_email(str(user_payload.get("email") or "").strip())
|
||||||
|
expire_at = str(user_payload.get("expireAt") or "").strip()
|
||||||
|
payload_keys = ",".join(sorted(str(key) for key in user_payload.keys())) or "none"
|
||||||
|
return (
|
||||||
|
f"telegramId={telegram_id or 'N/A'} "
|
||||||
|
f"panel_uuid={panel_uuid or 'N/A'} "
|
||||||
|
f"email={email or 'N/A'} "
|
||||||
|
f"expireAt={expire_at or 'N/A'} "
|
||||||
|
f"payload_keys={payload_keys}"
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _mask_email(email: str) -> str:
|
||||||
|
if not email:
|
||||||
|
return ""
|
||||||
|
local_part, separator, domain = email.partition("@")
|
||||||
|
if not separator or not domain:
|
||||||
|
return "present"
|
||||||
|
visible = local_part[:2] if len(local_part) > 2 else local_part[:1]
|
||||||
|
return f"{visible}***@{domain}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _payload_expire_datetime(user_payload: dict) -> Optional[datetime]:
|
||||||
|
raw = str(user_payload.get("expireAt") or "").strip()
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
value = datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
||||||
|
except ValueError:
|
||||||
|
try:
|
||||||
|
value = datetime.fromisoformat(raw[:10])
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if value.tzinfo is None:
|
||||||
|
return value.replace(tzinfo=timezone.utc)
|
||||||
|
return value.astimezone(timezone.utc)
|
||||||
|
|
||||||
async def handle_webhook(
|
async def handle_webhook(
|
||||||
self, raw_body: bytes, signature_header: Optional[str]
|
self, raw_body: bytes, signature_header: Optional[str]
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from sqlalchemy import text
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from bot.middlewares.i18n import JsonI18n
|
from bot.middlewares.i18n import JsonI18n
|
||||||
|
from bot.services.user_email_notifications import send_user_notification_email
|
||||||
from config.settings import Settings
|
from config.settings import Settings
|
||||||
from db.dal import payment_dal, subscription_dal, user_dal
|
from db.dal import payment_dal, subscription_dal, user_dal
|
||||||
|
|
||||||
@@ -33,6 +34,7 @@ class ReferralService:
|
|||||||
purchased_subscription_months: int,
|
purchased_subscription_months: int,
|
||||||
current_payment_db_id: Optional[int] = None,
|
current_payment_db_id: Optional[int] = None,
|
||||||
skip_if_active_before_payment: bool = True,
|
skip_if_active_before_payment: bool = True,
|
||||||
|
tariff_key: Optional[str] = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
|
|
||||||
referee_final_end_date: Optional[datetime] = None
|
referee_final_end_date: Optional[datetime] = None
|
||||||
@@ -94,11 +96,9 @@ class ReferralService:
|
|||||||
else self.i18n.gettext(default_lang_for_placeholder, "friend_placeholder")
|
else self.i18n.gettext(default_lang_for_placeholder, "friend_placeholder")
|
||||||
)
|
)
|
||||||
|
|
||||||
inviter_bonus_days = self.settings.referral_bonus_inviter.get(
|
inviter_bonus_days, referee_bonus_days = self._referral_bonus_days_for_payment(
|
||||||
purchased_subscription_months
|
purchased_subscription_months,
|
||||||
)
|
tariff_key=tariff_key,
|
||||||
referee_bonus_days = self.settings.referral_bonus_referee.get(
|
|
||||||
purchased_subscription_months
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if inviter_bonus_days and inviter_bonus_days > 0:
|
if inviter_bonus_days and inviter_bonus_days > 0:
|
||||||
@@ -142,18 +142,36 @@ class ReferralService:
|
|||||||
inviter_user_model.language_code or default_lang_for_placeholder
|
inviter_user_model.language_code or default_lang_for_placeholder
|
||||||
)
|
)
|
||||||
_i = lambda k, **kw: self.i18n.gettext(inviter_lang, k, **kw)
|
_i = lambda k, **kw: self.i18n.gettext(inviter_lang, k, **kw)
|
||||||
await self.bot.send_message(
|
message_text = _i(
|
||||||
inviter_user_id,
|
|
||||||
_i(
|
|
||||||
"referral_bonus_inviter_notification_extended",
|
"referral_bonus_inviter_notification_extended",
|
||||||
days=inviter_bonus_days,
|
days=inviter_bonus_days,
|
||||||
referee_name=referee_name_for_msg,
|
referee_name=referee_name_for_msg,
|
||||||
new_end_date=new_end_date_inviter.strftime("%Y-%m-%d"),
|
new_end_date=new_end_date_inviter.strftime("%Y-%m-%d"),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await self.bot.send_message(inviter_user_id, message_text)
|
||||||
|
except Exception as e_notify_inviter:
|
||||||
|
logging.error(
|
||||||
|
f"Failed to send bonus notification to inviter {inviter_user_id}: {e_notify_inviter}" # noqa: E501
|
||||||
|
)
|
||||||
|
await send_user_notification_email(
|
||||||
|
settings=self.settings,
|
||||||
|
i18n=self.i18n,
|
||||||
|
user=inviter_user_model,
|
||||||
|
subject_key="email_referral_bonus_subject",
|
||||||
|
message_text=message_text,
|
||||||
|
dashboard_url=(
|
||||||
|
getattr(
|
||||||
|
self.settings,
|
||||||
|
"SUBSCRIPTION_MINI_APP_URL",
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
or None
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
except Exception as e_notify_inviter:
|
except Exception as e_notify_inviter:
|
||||||
logging.error(
|
logging.error(
|
||||||
f"Failed to send bonus notification to inviter {inviter_user_id}: {e_notify_inviter}" # noqa: E501
|
f"Failed to prepare bonus notification for inviter {inviter_user_id}: {e_notify_inviter}" # noqa: E501
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logging.info(
|
logging.info(
|
||||||
@@ -210,13 +228,34 @@ class ReferralService:
|
|||||||
_i = lambda k, **kw: self.i18n.gettext(
|
_i = lambda k, **kw: self.i18n.gettext(
|
||||||
inviter_lang, k, **kw
|
inviter_lang, k, **kw
|
||||||
)
|
)
|
||||||
await self.bot.send_message(
|
message_text = _i(
|
||||||
inviter_user_id,
|
|
||||||
_i(
|
|
||||||
"referral_bonus_inviter_notification_new_sub",
|
"referral_bonus_inviter_notification_new_sub",
|
||||||
days=inviter_bonus_days,
|
days=inviter_bonus_days,
|
||||||
referee_name=referee_name_for_msg,
|
referee_name=referee_name_for_msg,
|
||||||
new_end_date=bonus_end_date.strftime("%Y-%m-%d"),
|
new_end_date=bonus_end_date.strftime("%Y-%m-%d"),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await self.bot.send_message(
|
||||||
|
inviter_user_id,
|
||||||
|
message_text,
|
||||||
|
)
|
||||||
|
except Exception as e_notify_inviter:
|
||||||
|
logging.error(
|
||||||
|
f"Failed to send bonus notification to inviter {inviter_user_id}: {e_notify_inviter}" # noqa: E501
|
||||||
|
)
|
||||||
|
await send_user_notification_email(
|
||||||
|
settings=self.settings,
|
||||||
|
i18n=self.i18n,
|
||||||
|
user=inviter_user_model,
|
||||||
|
subject_key="email_referral_bonus_subject",
|
||||||
|
message_text=message_text,
|
||||||
|
dashboard_url=(
|
||||||
|
getattr(
|
||||||
|
self.settings,
|
||||||
|
"SUBSCRIPTION_MINI_APP_URL",
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
or None
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
@@ -263,6 +302,35 @@ class ReferralService:
|
|||||||
|
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
def _referral_bonus_days_for_payment(
|
||||||
|
self,
|
||||||
|
purchased_subscription_months: int,
|
||||||
|
*,
|
||||||
|
tariff_key: Optional[str] = None,
|
||||||
|
) -> tuple[Optional[int], Optional[int]]:
|
||||||
|
months = int(purchased_subscription_months)
|
||||||
|
tariffs_config = getattr(self.settings, "tariffs_config", None)
|
||||||
|
if tariff_key and tariffs_config:
|
||||||
|
try:
|
||||||
|
tariff = tariffs_config.require(str(tariff_key))
|
||||||
|
except Exception:
|
||||||
|
logging.warning(
|
||||||
|
"Referral bonuses skipped: tariff %s was not found.",
|
||||||
|
tariff_key,
|
||||||
|
)
|
||||||
|
return None, None
|
||||||
|
if tariff.billing_model != "period":
|
||||||
|
return None, None
|
||||||
|
return (
|
||||||
|
tariff.referral_inviter_bonus_days(months),
|
||||||
|
tariff.referral_referee_bonus_days(months),
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
self.settings.referral_bonus_inviter.get(months),
|
||||||
|
self.settings.referral_bonus_referee.get(months),
|
||||||
|
)
|
||||||
|
|
||||||
async def generate_referral_link(
|
async def generate_referral_link(
|
||||||
self, session: AsyncSession, bot_username: str, inviter_user_id: int
|
self, session: AsyncSession, bot_username: str, inviter_user_id: int
|
||||||
) -> Optional[str]:
|
) -> Optional[str]:
|
||||||
|
|||||||
@@ -0,0 +1,361 @@
|
|||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Optional
|
||||||
|
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
||||||
|
|
||||||
|
from aiogram import Bot
|
||||||
|
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||||
|
from aiogram.types import InlineKeyboardMarkup
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from bot.keyboards.inline.user_keyboards import get_subscribe_only_markup
|
||||||
|
from bot.middlewares.i18n import JsonI18n
|
||||||
|
from bot.services.email_auth_service import EmailAuthService
|
||||||
|
from bot.services.email_templates import render_subscription_lifecycle_notification
|
||||||
|
from bot.services.telegram_notifications import (
|
||||||
|
TELEGRAM_NOTIFICATIONS_BLOCKED,
|
||||||
|
TELEGRAM_NOTIFICATIONS_ENABLED,
|
||||||
|
TELEGRAM_NOTIFICATIONS_NEEDS_START,
|
||||||
|
mark_telegram_notifications_status,
|
||||||
|
normalize_telegram_notification_status,
|
||||||
|
telegram_notification_status_from_error,
|
||||||
|
)
|
||||||
|
from config.settings import Settings
|
||||||
|
from db.dal import subscription_dal
|
||||||
|
from db.models import Subscription, User
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SubscriptionNotificationStage:
|
||||||
|
key: str
|
||||||
|
message_key: str
|
||||||
|
days_left: Optional[int] = None
|
||||||
|
hours_before: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SubscriptionNotificationDelivery:
|
||||||
|
telegram_sent: bool = False
|
||||||
|
email_sent: bool = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def any_sent(self) -> bool:
|
||||||
|
return self.telegram_sent or self.email_sent
|
||||||
|
|
||||||
|
|
||||||
|
class SubscriptionLifecycleNotificationService:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
settings: Settings,
|
||||||
|
bot: Bot,
|
||||||
|
i18n: JsonI18n,
|
||||||
|
*,
|
||||||
|
email_service: Optional[EmailAuthService] = None,
|
||||||
|
) -> None:
|
||||||
|
self.settings = settings
|
||||||
|
self.bot = bot
|
||||||
|
self.i18n = i18n
|
||||||
|
self.email_service = email_service
|
||||||
|
|
||||||
|
async def send_stage(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
sub: Subscription,
|
||||||
|
stage: SubscriptionNotificationStage,
|
||||||
|
*,
|
||||||
|
user: Optional[User] = None,
|
||||||
|
telegram_markup: Optional[InlineKeyboardMarkup] = None,
|
||||||
|
extra_text: str = "",
|
||||||
|
end_date_text: Optional[str] = None,
|
||||||
|
sent_at: Optional[datetime] = None,
|
||||||
|
) -> SubscriptionNotificationDelivery:
|
||||||
|
if sent_at is None:
|
||||||
|
sent_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
resolved_user = user or getattr(sub, "user", None)
|
||||||
|
lang = getattr(resolved_user, "language_code", None) or self.settings.DEFAULT_LANGUAGE
|
||||||
|
user_id = int(getattr(sub, "user_id", 0) or 0)
|
||||||
|
final_end_date_text = end_date_text
|
||||||
|
if final_end_date_text is None:
|
||||||
|
end_date = self._as_utc(getattr(sub, "end_date", None))
|
||||||
|
final_end_date_text = end_date.strftime("%Y-%m-%d") if end_date else ""
|
||||||
|
|
||||||
|
recipient_email = self._email_recipient(resolved_user)
|
||||||
|
telegram_user_name = self._telegram_display_name(resolved_user, user_id)
|
||||||
|
email_user_name = self._email_display_name(
|
||||||
|
resolved_user,
|
||||||
|
recipient_email=recipient_email,
|
||||||
|
fallback=telegram_user_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
kwargs = {"user_name": telegram_user_name, "end_date": final_end_date_text}
|
||||||
|
if stage.hours_before is not None:
|
||||||
|
kwargs["hours"] = stage.hours_before
|
||||||
|
|
||||||
|
message_text = self.i18n.gettext(lang, stage.message_key, **kwargs)
|
||||||
|
email_kwargs = {**kwargs, "user_name": email_user_name}
|
||||||
|
email_message_text = self.i18n.gettext(lang, stage.message_key, **email_kwargs)
|
||||||
|
final_extra_text = str(extra_text or "").strip()
|
||||||
|
if final_extra_text:
|
||||||
|
message_text = f"{message_text}\n\n{final_extra_text}"
|
||||||
|
email_message_text = f"{email_message_text}\n\n{final_extra_text}"
|
||||||
|
|
||||||
|
telegram_sent = await self._send_telegram(
|
||||||
|
session,
|
||||||
|
sub,
|
||||||
|
stage,
|
||||||
|
resolved_user,
|
||||||
|
lang=lang,
|
||||||
|
message_text=message_text,
|
||||||
|
markup=telegram_markup or get_subscribe_only_markup(lang, self.i18n),
|
||||||
|
sent_at=sent_at,
|
||||||
|
)
|
||||||
|
email_sent = await self._send_email(
|
||||||
|
session,
|
||||||
|
sub,
|
||||||
|
stage,
|
||||||
|
resolved_user,
|
||||||
|
lang=lang,
|
||||||
|
message_text=email_message_text,
|
||||||
|
end_date_text=final_end_date_text,
|
||||||
|
recipient=recipient_email,
|
||||||
|
telegram_sent=telegram_sent,
|
||||||
|
sent_at=sent_at,
|
||||||
|
)
|
||||||
|
return SubscriptionNotificationDelivery(
|
||||||
|
telegram_sent=telegram_sent,
|
||||||
|
email_sent=email_sent,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _send_telegram(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
sub: Subscription,
|
||||||
|
stage: SubscriptionNotificationStage,
|
||||||
|
user: Optional[User],
|
||||||
|
*,
|
||||||
|
lang: str,
|
||||||
|
message_text: str,
|
||||||
|
markup: Optional[InlineKeyboardMarkup],
|
||||||
|
sent_at: datetime,
|
||||||
|
) -> bool:
|
||||||
|
chat_id = self._telegram_chat_id(user, getattr(sub, "user_id", None))
|
||||||
|
if chat_id is None:
|
||||||
|
return False
|
||||||
|
if user:
|
||||||
|
status = normalize_telegram_notification_status(
|
||||||
|
getattr(user, "telegram_notifications_status", None)
|
||||||
|
)
|
||||||
|
if status in {TELEGRAM_NOTIFICATIONS_NEEDS_START, TELEGRAM_NOTIFICATIONS_BLOCKED}:
|
||||||
|
return False
|
||||||
|
if await self._already_sent(session, sub.subscription_id, stage.key, "telegram"):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
await self.bot.send_message(chat_id, message_text, reply_markup=markup)
|
||||||
|
except (TelegramBadRequest, TelegramForbiddenError) as exc:
|
||||||
|
delivery_status = telegram_notification_status_from_error(exc)
|
||||||
|
if user and delivery_status:
|
||||||
|
await mark_telegram_notifications_status(
|
||||||
|
session,
|
||||||
|
int(user.user_id),
|
||||||
|
delivery_status,
|
||||||
|
)
|
||||||
|
if delivery_status:
|
||||||
|
logging.warning(
|
||||||
|
"Skipping subscription notification %s for unreachable Telegram user %s: %s",
|
||||||
|
stage.key,
|
||||||
|
chat_id,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
logging.exception(
|
||||||
|
"Failed to send subscription notification %s to Telegram user %s",
|
||||||
|
stage.key,
|
||||||
|
chat_id,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
except Exception:
|
||||||
|
logging.exception(
|
||||||
|
"Failed to send subscription notification %s to Telegram user %s",
|
||||||
|
stage.key,
|
||||||
|
chat_id,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
await subscription_dal.record_subscription_notification(
|
||||||
|
session,
|
||||||
|
sub.subscription_id,
|
||||||
|
self._channel_key(stage.key, "telegram"),
|
||||||
|
sent_at=sent_at,
|
||||||
|
)
|
||||||
|
if user:
|
||||||
|
status = normalize_telegram_notification_status(
|
||||||
|
getattr(user, "telegram_notifications_status", None)
|
||||||
|
)
|
||||||
|
if status != TELEGRAM_NOTIFICATIONS_ENABLED:
|
||||||
|
await mark_telegram_notifications_status(
|
||||||
|
session,
|
||||||
|
int(user.user_id),
|
||||||
|
TELEGRAM_NOTIFICATIONS_ENABLED,
|
||||||
|
telegram_id=chat_id,
|
||||||
|
checked_at=sent_at,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def _send_email(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
sub: Subscription,
|
||||||
|
stage: SubscriptionNotificationStage,
|
||||||
|
user: Optional[User],
|
||||||
|
*,
|
||||||
|
lang: str,
|
||||||
|
message_text: str,
|
||||||
|
end_date_text: str,
|
||||||
|
recipient: str,
|
||||||
|
telegram_sent: bool,
|
||||||
|
sent_at: datetime,
|
||||||
|
) -> bool:
|
||||||
|
if not getattr(self.settings, "SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED", True):
|
||||||
|
return False
|
||||||
|
if not getattr(self.settings, "email_auth_configured", False):
|
||||||
|
return False
|
||||||
|
if not recipient:
|
||||||
|
return False
|
||||||
|
if await self._already_sent(session, sub.subscription_id, stage.key, "email"):
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
content = render_subscription_lifecycle_notification(
|
||||||
|
self.settings,
|
||||||
|
language_code=lang,
|
||||||
|
notification_key=stage.key,
|
||||||
|
message_text=message_text,
|
||||||
|
end_date_text=end_date_text,
|
||||||
|
dashboard_url=self._renewal_dashboard_url(recipient, sub),
|
||||||
|
mirrored_from_telegram=telegram_sent,
|
||||||
|
days_left=stage.days_left,
|
||||||
|
hours_before=stage.hours_before,
|
||||||
|
i18n=self.i18n,
|
||||||
|
)
|
||||||
|
email_service = self.email_service or EmailAuthService(self.settings, self.i18n)
|
||||||
|
await email_service.send_rendered_email(email=recipient, content=content)
|
||||||
|
except Exception:
|
||||||
|
logging.exception(
|
||||||
|
"Failed to send subscription notification %s to email %s",
|
||||||
|
stage.key,
|
||||||
|
recipient,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
await subscription_dal.record_subscription_notification(
|
||||||
|
session,
|
||||||
|
sub.subscription_id,
|
||||||
|
self._channel_key(stage.key, "email"),
|
||||||
|
sent_at=sent_at,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def _already_sent(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
subscription_id: int,
|
||||||
|
stage_key: str,
|
||||||
|
channel: str,
|
||||||
|
) -> bool:
|
||||||
|
channel_key = self._channel_key(stage_key, channel)
|
||||||
|
if await subscription_dal.has_subscription_notification(
|
||||||
|
session,
|
||||||
|
subscription_id,
|
||||||
|
channel_key,
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Legacy rows were stored without a channel. Treat them as Telegram-only
|
||||||
|
# history so existing installs do not re-send old bot messages, while
|
||||||
|
# still allowing the newly introduced email channel to catch up.
|
||||||
|
return channel == "telegram" and await subscription_dal.has_subscription_notification(
|
||||||
|
session,
|
||||||
|
subscription_id,
|
||||||
|
stage_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _channel_key(stage_key: str, channel: str) -> str:
|
||||||
|
return f"{stage_key}:{channel}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _email_recipient(user: Optional[User]) -> str:
|
||||||
|
return str(getattr(user, "email", "") or "").strip().lower() if user else ""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _telegram_display_name(user: Optional[User], fallback_user_id: int) -> str:
|
||||||
|
return str(getattr(user, "first_name", "") or "").strip() or f"User {fallback_user_id}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _email_display_name(
|
||||||
|
user: Optional[User],
|
||||||
|
*,
|
||||||
|
recipient_email: str,
|
||||||
|
fallback: str,
|
||||||
|
) -> str:
|
||||||
|
return str(getattr(user, "first_name", "") or "").strip() or recipient_email or fallback
|
||||||
|
|
||||||
|
def _renewal_dashboard_url(self, recipient_email: str, sub: Subscription) -> Optional[str]:
|
||||||
|
base_url = (self.settings.SUBSCRIPTION_MINI_APP_URL or "").strip()
|
||||||
|
if not base_url:
|
||||||
|
return None
|
||||||
|
parsed = urlsplit(base_url)
|
||||||
|
if parsed.scheme not in ("http", "https") or not parsed.netloc:
|
||||||
|
return None
|
||||||
|
|
||||||
|
query = dict(parse_qsl(parsed.query, keep_blank_values=True))
|
||||||
|
query.update(
|
||||||
|
{
|
||||||
|
"login": "email_code",
|
||||||
|
"login_email": recipient_email,
|
||||||
|
"after_login": "renew",
|
||||||
|
"renew": "1",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
tariff_key = self._renewal_tariff_key(sub)
|
||||||
|
if tariff_key:
|
||||||
|
query["renew_tariff"] = tariff_key
|
||||||
|
else:
|
||||||
|
query.pop("renew_tariff", None)
|
||||||
|
|
||||||
|
return urlunsplit(
|
||||||
|
(
|
||||||
|
parsed.scheme,
|
||||||
|
parsed.netloc,
|
||||||
|
parsed.path or "/",
|
||||||
|
urlencode(query),
|
||||||
|
parsed.fragment,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _renewal_tariff_key(sub: Subscription) -> str:
|
||||||
|
provider = str(getattr(sub, "provider", "") or "").strip().lower()
|
||||||
|
status = str(getattr(sub, "status_from_panel", "") or "").strip().upper()
|
||||||
|
if provider == "trial" or status == "TRIAL":
|
||||||
|
return ""
|
||||||
|
return str(getattr(sub, "tariff_key", "") or "").strip()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _telegram_chat_id(user: Optional[User], fallback_user_id: Optional[int]) -> Optional[int]:
|
||||||
|
for candidate in (getattr(user, "telegram_id", None), fallback_user_id):
|
||||||
|
try:
|
||||||
|
chat_id = int(candidate or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if chat_id > 0:
|
||||||
|
return chat_id
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _as_utc(value: Optional[datetime]) -> Optional[datetime]:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if value.tzinfo is None:
|
||||||
|
return value.replace(tzinfo=timezone.utc)
|
||||||
|
return value.astimezone(timezone.utc)
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
@@ -15,8 +14,22 @@ from bot.infra.redis import redis_lock
|
|||||||
from bot.keyboards.inline.user_keyboards import get_subscribe_only_markup
|
from bot.keyboards.inline.user_keyboards import get_subscribe_only_markup
|
||||||
from bot.middlewares.i18n import JsonI18n
|
from bot.middlewares.i18n import JsonI18n
|
||||||
from bot.services.panel_api_service import PanelApiService
|
from bot.services.panel_api_service import PanelApiService
|
||||||
|
from bot.services.subscription_lifecycle_notifications import (
|
||||||
|
SubscriptionLifecycleNotificationService,
|
||||||
|
SubscriptionNotificationStage,
|
||||||
|
)
|
||||||
from bot.services.subscription_service import SubscriptionService
|
from bot.services.subscription_service import SubscriptionService
|
||||||
|
from bot.services.telegram_notifications import (
|
||||||
|
TELEGRAM_NOTIFICATIONS_BLOCKED,
|
||||||
|
TELEGRAM_NOTIFICATIONS_ENABLED,
|
||||||
|
TELEGRAM_NOTIFICATIONS_NEEDS_START,
|
||||||
|
mark_telegram_notifications_status,
|
||||||
|
normalize_telegram_notification_status,
|
||||||
|
telegram_notification_status_from_error,
|
||||||
|
)
|
||||||
|
from bot.services.user_email_notifications import send_user_notification_email
|
||||||
from config.settings import Settings
|
from config.settings import Settings
|
||||||
|
from db.advisory_locks import acquire_subscription_background_sync_lock
|
||||||
from db.dal import subscription_dal
|
from db.dal import subscription_dal
|
||||||
from db.models import Subscription
|
from db.models import Subscription
|
||||||
|
|
||||||
@@ -26,13 +39,6 @@ EXPIRED_NOTIFICATION_WINDOW = timedelta(hours=24)
|
|||||||
EXPIRED_AFTER_NOTIFICATION_WINDOW = timedelta(hours=48)
|
EXPIRED_AFTER_NOTIFICATION_WINDOW = timedelta(hours=48)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class SubscriptionNotificationStage:
|
|
||||||
key: str
|
|
||||||
message_key: str
|
|
||||||
hours_before: Optional[int] = None
|
|
||||||
|
|
||||||
|
|
||||||
class SubscriptionNotificationWorker:
|
class SubscriptionNotificationWorker:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -49,6 +55,11 @@ class SubscriptionNotificationWorker:
|
|||||||
self.i18n = i18n
|
self.i18n = i18n
|
||||||
self.panel_service = panel_service
|
self.panel_service = panel_service
|
||||||
self.subscription_service = subscription_service
|
self.subscription_service = subscription_service
|
||||||
|
self.lifecycle_notifications = SubscriptionLifecycleNotificationService(
|
||||||
|
settings,
|
||||||
|
bot,
|
||||||
|
i18n,
|
||||||
|
)
|
||||||
self._stopped = asyncio.Event()
|
self._stopped = asyncio.Event()
|
||||||
|
|
||||||
async def run(self) -> None:
|
async def run(self) -> None:
|
||||||
@@ -66,6 +77,7 @@ class SubscriptionNotificationWorker:
|
|||||||
else:
|
else:
|
||||||
started = time.monotonic()
|
started = time.monotonic()
|
||||||
async with self.session_factory() as session:
|
async with self.session_factory() as session:
|
||||||
|
await acquire_subscription_background_sync_lock(session)
|
||||||
await self.expiry_tick(session)
|
await self.expiry_tick(session)
|
||||||
await self.trial_traffic_tick(session)
|
await self.trial_traffic_tick(session)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@@ -114,18 +126,10 @@ class SubscriptionNotificationWorker:
|
|||||||
stage = self.stage_for_subscription(sub, now)
|
stage = self.stage_for_subscription(sub, now)
|
||||||
if stage is None:
|
if stage is None:
|
||||||
continue
|
continue
|
||||||
if await subscription_dal.has_subscription_notification(
|
await self.lifecycle_notifications.send_stage(
|
||||||
session,
|
session,
|
||||||
sub.subscription_id,
|
sub,
|
||||||
stage.key,
|
stage,
|
||||||
):
|
|
||||||
continue
|
|
||||||
if not await self._send_expiry_notification(sub, stage):
|
|
||||||
continue
|
|
||||||
await subscription_dal.record_subscription_notification(
|
|
||||||
session,
|
|
||||||
sub.subscription_id,
|
|
||||||
stage.key,
|
|
||||||
sent_at=now,
|
sent_at=now,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -164,6 +168,7 @@ class SubscriptionNotificationWorker:
|
|||||||
return SubscriptionNotificationStage(
|
return SubscriptionNotificationStage(
|
||||||
key=f"before_{days_before}d",
|
key=f"before_{days_before}d",
|
||||||
message_key=message_key,
|
message_key=message_key,
|
||||||
|
days_left=days_before,
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -175,6 +180,7 @@ class SubscriptionNotificationWorker:
|
|||||||
return SubscriptionNotificationStage(
|
return SubscriptionNotificationStage(
|
||||||
key="expired",
|
key="expired",
|
||||||
message_key="subscription_expired_notification",
|
message_key="subscription_expired_notification",
|
||||||
|
days_left=0,
|
||||||
)
|
)
|
||||||
if (
|
if (
|
||||||
getattr(self.settings, "SUBSCRIPTION_NOTIFY_AFTER_EXPIRE", True)
|
getattr(self.settings, "SUBSCRIPTION_NOTIFY_AFTER_EXPIRE", True)
|
||||||
@@ -183,6 +189,7 @@ class SubscriptionNotificationWorker:
|
|||||||
return SubscriptionNotificationStage(
|
return SubscriptionNotificationStage(
|
||||||
key="expired_24h_after",
|
key="expired_24h_after",
|
||||||
message_key="subscription_expired_yesterday_notification",
|
message_key="subscription_expired_yesterday_notification",
|
||||||
|
days_left=0,
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -208,11 +215,22 @@ class SubscriptionNotificationWorker:
|
|||||||
.order_by(Subscription.end_date.asc())
|
.order_by(Subscription.end_date.asc())
|
||||||
)
|
)
|
||||||
for sub in result.scalars().all():
|
for sub in result.scalars().all():
|
||||||
if await subscription_dal.has_subscription_notification(
|
legacy_sent = await subscription_dal.has_subscription_notification(
|
||||||
session,
|
session,
|
||||||
sub.subscription_id,
|
sub.subscription_id,
|
||||||
"trial_traffic_depleted",
|
"trial_traffic_depleted",
|
||||||
):
|
)
|
||||||
|
telegram_done = legacy_sent or await subscription_dal.has_subscription_notification(
|
||||||
|
session,
|
||||||
|
sub.subscription_id,
|
||||||
|
"trial_traffic_depleted:telegram",
|
||||||
|
)
|
||||||
|
email_done = await subscription_dal.has_subscription_notification(
|
||||||
|
session,
|
||||||
|
sub.subscription_id,
|
||||||
|
"trial_traffic_depleted:email",
|
||||||
|
)
|
||||||
|
if telegram_done and email_done:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
used = int(getattr(sub, "traffic_used_bytes", 0) or 0)
|
used = int(getattr(sub, "traffic_used_bytes", 0) or 0)
|
||||||
@@ -234,12 +252,28 @@ class SubscriptionNotificationWorker:
|
|||||||
|
|
||||||
if limit <= 0 or used < limit:
|
if limit <= 0 or used < limit:
|
||||||
continue
|
continue
|
||||||
if not await self._send_trial_traffic_depleted(sub, used=used, limit=limit):
|
delivery = await self._send_trial_traffic_depleted(
|
||||||
|
session,
|
||||||
|
sub,
|
||||||
|
used=used,
|
||||||
|
limit=limit,
|
||||||
|
send_telegram=not telegram_done,
|
||||||
|
send_email=not email_done,
|
||||||
|
)
|
||||||
|
if not delivery["telegram"] and not delivery["email"]:
|
||||||
continue
|
continue
|
||||||
|
if delivery["telegram"]:
|
||||||
await subscription_dal.record_subscription_notification(
|
await subscription_dal.record_subscription_notification(
|
||||||
session,
|
session,
|
||||||
sub.subscription_id,
|
sub.subscription_id,
|
||||||
"trial_traffic_depleted",
|
"trial_traffic_depleted:telegram",
|
||||||
|
sent_at=now,
|
||||||
|
)
|
||||||
|
if delivery["email"]:
|
||||||
|
await subscription_dal.record_subscription_notification(
|
||||||
|
session,
|
||||||
|
sub.subscription_id,
|
||||||
|
"trial_traffic_depleted:email",
|
||||||
sent_at=now,
|
sent_at=now,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -257,68 +291,72 @@ class SubscriptionNotificationWorker:
|
|||||||
return None
|
return None
|
||||||
return data if isinstance(data, dict) else None
|
return data if isinstance(data, dict) else None
|
||||||
|
|
||||||
async def _send_expiry_notification(
|
|
||||||
self,
|
|
||||||
sub: Subscription,
|
|
||||||
stage: SubscriptionNotificationStage,
|
|
||||||
) -> bool:
|
|
||||||
user_id = int(getattr(sub, "user_id", 0) or 0)
|
|
||||||
if user_id <= 0:
|
|
||||||
return False
|
|
||||||
user = getattr(sub, "user", None)
|
|
||||||
lang = getattr(user, "language_code", None) or self.settings.DEFAULT_LANGUAGE
|
|
||||||
user_name = getattr(user, "first_name", None) or f"User {user_id}"
|
|
||||||
end_date = self._as_utc(getattr(sub, "end_date", None))
|
|
||||||
end_date_text = end_date.strftime("%Y-%m-%d") if end_date else ""
|
|
||||||
translate = lambda k, **kw: self.i18n.gettext(lang, k, **kw)
|
|
||||||
kwargs = {"user_name": user_name, "end_date": end_date_text}
|
|
||||||
if stage.hours_before is not None:
|
|
||||||
kwargs["hours"] = stage.hours_before
|
|
||||||
try:
|
|
||||||
await self.bot.send_message(
|
|
||||||
user_id,
|
|
||||||
translate(stage.message_key, **kwargs),
|
|
||||||
reply_markup=get_subscribe_only_markup(lang, self.i18n),
|
|
||||||
)
|
|
||||||
return True
|
|
||||||
except Exception:
|
|
||||||
logging.exception(
|
|
||||||
"Failed to send subscription notification %s to user %s",
|
|
||||||
stage.key,
|
|
||||||
user_id,
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def _send_trial_traffic_depleted(
|
async def _send_trial_traffic_depleted(
|
||||||
self,
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
sub: Subscription,
|
sub: Subscription,
|
||||||
*,
|
*,
|
||||||
used: int,
|
used: int,
|
||||||
limit: int,
|
limit: int,
|
||||||
) -> bool:
|
send_telegram: bool = True,
|
||||||
|
send_email: bool = True,
|
||||||
|
) -> dict[str, bool]:
|
||||||
user_id = int(getattr(sub, "user_id", 0) or 0)
|
user_id = int(getattr(sub, "user_id", 0) or 0)
|
||||||
if user_id <= 0:
|
|
||||||
return False
|
|
||||||
user = getattr(sub, "user", None)
|
user = getattr(sub, "user", None)
|
||||||
lang = getattr(user, "language_code", None) or self.settings.DEFAULT_LANGUAGE
|
lang = getattr(user, "language_code", None) or self.settings.DEFAULT_LANGUAGE
|
||||||
translate = lambda k, **kw: self.i18n.gettext(lang, k, **kw)
|
translate = lambda k, **kw: self.i18n.gettext(lang, k, **kw)
|
||||||
remaining = max(0, limit - used)
|
remaining = max(0, limit - used)
|
||||||
try:
|
message_text = translate(
|
||||||
await self.bot.send_message(
|
|
||||||
user_id,
|
|
||||||
translate(
|
|
||||||
"trial_traffic_depleted_notification",
|
"trial_traffic_depleted_notification",
|
||||||
used=hd.quote(self._fmt_bytes(used)),
|
used=hd.quote(self._fmt_bytes(used)),
|
||||||
remaining=hd.quote(self._fmt_bytes(remaining)),
|
remaining=hd.quote(self._fmt_bytes(remaining)),
|
||||||
limit_total=hd.quote(self._fmt_bytes(limit)),
|
limit_total=hd.quote(self._fmt_bytes(limit)),
|
||||||
),
|
)
|
||||||
|
telegram_sent = False
|
||||||
|
email_sent = False
|
||||||
|
telegram_chat_id = int(getattr(user, "telegram_id", 0) or user_id or 0)
|
||||||
|
telegram_status = normalize_telegram_notification_status(
|
||||||
|
getattr(user, "telegram_notifications_status", None)
|
||||||
|
)
|
||||||
|
can_try_telegram = telegram_status not in {
|
||||||
|
TELEGRAM_NOTIFICATIONS_NEEDS_START,
|
||||||
|
TELEGRAM_NOTIFICATIONS_BLOCKED,
|
||||||
|
}
|
||||||
|
if send_telegram and telegram_chat_id > 0 and can_try_telegram:
|
||||||
|
try:
|
||||||
|
await self.bot.send_message(
|
||||||
|
telegram_chat_id,
|
||||||
|
message_text,
|
||||||
reply_markup=get_subscribe_only_markup(lang, self.i18n),
|
reply_markup=get_subscribe_only_markup(lang, self.i18n),
|
||||||
parse_mode="HTML",
|
parse_mode="HTML",
|
||||||
)
|
)
|
||||||
return True
|
telegram_sent = True
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
logging.exception("Failed to send trial traffic depleted warning to user %s", user_id)
|
status = telegram_notification_status_from_error(exc)
|
||||||
return False
|
if status and user and user_id:
|
||||||
|
await mark_telegram_notifications_status(session, user_id, status)
|
||||||
|
logging.exception(
|
||||||
|
"Failed to send trial traffic depleted warning to user %s",
|
||||||
|
telegram_chat_id,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
if user and telegram_status != TELEGRAM_NOTIFICATIONS_ENABLED and user_id:
|
||||||
|
await mark_telegram_notifications_status(
|
||||||
|
session,
|
||||||
|
user_id,
|
||||||
|
TELEGRAM_NOTIFICATIONS_ENABLED,
|
||||||
|
telegram_id=telegram_chat_id,
|
||||||
|
)
|
||||||
|
if send_email and user:
|
||||||
|
email_sent = await send_user_notification_email(
|
||||||
|
settings=self.settings,
|
||||||
|
i18n=self.i18n,
|
||||||
|
user=user,
|
||||||
|
subject_key="email_trial_traffic_depleted_subject",
|
||||||
|
message_text=message_text,
|
||||||
|
dashboard_url=(getattr(self.settings, "SUBSCRIPTION_MINI_APP_URL", "") or None),
|
||||||
|
)
|
||||||
|
return {"telegram": telegram_sent, "email": email_sent}
|
||||||
|
|
||||||
def _max_before_window(self) -> timedelta:
|
def _max_before_window(self) -> timedelta:
|
||||||
days_before = max(0, int(getattr(self.settings, "SUBSCRIPTION_NOTIFY_DAYS_BEFORE", 0) or 0))
|
days_before = max(0, int(getattr(self.settings, "SUBSCRIPTION_NOTIFY_DAYS_BEFORE", 0) or 0))
|
||||||
|
|||||||
@@ -357,6 +357,15 @@ class HwidDeviceMixin:
|
|||||||
valid_from=valid_from,
|
valid_from=valid_from,
|
||||||
valid_until=valid_until,
|
valid_until=valid_until,
|
||||||
)
|
)
|
||||||
|
await self._send_payment_success_email(
|
||||||
|
db_user=db_user,
|
||||||
|
sale_mode="hwid_devices_renewal" if renewal else "hwid_devices",
|
||||||
|
months=purchased_devices,
|
||||||
|
traffic_gb=None,
|
||||||
|
payment_amount=payment_amount,
|
||||||
|
end_date=valid_until,
|
||||||
|
provider=provider,
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"subscription_id": updated_sub.subscription_id,
|
"subscription_id": updated_sub.subscription_id,
|
||||||
"end_date": updated_sub.end_date,
|
"end_date": updated_sub.end_date,
|
||||||
|
|||||||
@@ -3,6 +3,175 @@ from ._runtime import * # noqa: F403,F405
|
|||||||
|
|
||||||
|
|
||||||
class SubscriptionLifecycleMixin:
|
class SubscriptionLifecycleMixin:
|
||||||
|
async def _lookup_panel_user_for_subscription_details(
|
||||||
|
self,
|
||||||
|
panel_user_uuid: str,
|
||||||
|
) -> Tuple[Optional[Dict[str, Any]], bool, str]:
|
||||||
|
lookup_method = getattr(self.panel_service, "get_user_by_uuid_lookup", None)
|
||||||
|
if callable(lookup_method):
|
||||||
|
try:
|
||||||
|
lookup = await lookup_method(panel_user_uuid, log_response=False)
|
||||||
|
except TypeError:
|
||||||
|
try:
|
||||||
|
lookup = await lookup_method(panel_user_uuid)
|
||||||
|
except Exception as exc:
|
||||||
|
logging.exception(
|
||||||
|
"Failed to fetch panel user %s for subscription details",
|
||||||
|
panel_user_uuid,
|
||||||
|
)
|
||||||
|
return None, False, self._panel_lookup_exception_reason(exc)
|
||||||
|
except Exception as exc:
|
||||||
|
logging.exception(
|
||||||
|
"Failed to fetch panel user %s for subscription details",
|
||||||
|
panel_user_uuid,
|
||||||
|
)
|
||||||
|
return None, False, self._panel_lookup_exception_reason(exc)
|
||||||
|
|
||||||
|
if isinstance(lookup, dict) and ("ok" in lookup or "not_found" in lookup):
|
||||||
|
user = lookup.get("user")
|
||||||
|
if lookup.get("ok") and isinstance(user, dict):
|
||||||
|
return user, False, ""
|
||||||
|
reason = str(lookup.get("failure_reason") or "classification=panel_lookup_failed")
|
||||||
|
return None, bool(lookup.get("not_found")), reason
|
||||||
|
|
||||||
|
try:
|
||||||
|
panel_user = await self.panel_service.get_user_by_uuid(panel_user_uuid)
|
||||||
|
except Exception as exc:
|
||||||
|
logging.exception(
|
||||||
|
"Failed to fetch panel user %s for subscription details",
|
||||||
|
panel_user_uuid,
|
||||||
|
)
|
||||||
|
return None, False, self._panel_lookup_exception_reason(exc)
|
||||||
|
return (panel_user if isinstance(panel_user, dict) else None), False, ""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _panel_lookup_exception_reason(exc: Exception) -> str:
|
||||||
|
message = str(exc).replace("\n", " ").strip()
|
||||||
|
if len(message) > 300:
|
||||||
|
message = f"{message[:300]}..."
|
||||||
|
reason = f"classification=panel_lookup_failed exception={type(exc).__name__}"
|
||||||
|
if message:
|
||||||
|
reason = f"{reason} message={message}"
|
||||||
|
return reason
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _display_datetime_text(value: Optional[Any]) -> Optional[str]:
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
normalized = value if value.tzinfo else value.replace(tzinfo=timezone.utc)
|
||||||
|
return normalized.strftime("%d.%m.%Y %H:%M")
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _device_topup_renewal_available(
|
||||||
|
extra_hwid_devices: int,
|
||||||
|
extra_hwid_valid_until: Optional[Any],
|
||||||
|
subscription_end_date: Optional[Any],
|
||||||
|
) -> bool:
|
||||||
|
if not isinstance(extra_hwid_valid_until, datetime) or not isinstance(
|
||||||
|
subscription_end_date, datetime
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
valid_until = (
|
||||||
|
extra_hwid_valid_until
|
||||||
|
if extra_hwid_valid_until.tzinfo
|
||||||
|
else extra_hwid_valid_until.replace(tzinfo=timezone.utc)
|
||||||
|
)
|
||||||
|
end_date = (
|
||||||
|
subscription_end_date
|
||||||
|
if subscription_end_date.tzinfo
|
||||||
|
else subscription_end_date.replace(tzinfo=timezone.utc)
|
||||||
|
)
|
||||||
|
return bool(int(extra_hwid_devices or 0) > 0 and valid_until < end_date)
|
||||||
|
|
||||||
|
async def _local_active_subscription_details_fallback(
|
||||||
|
self,
|
||||||
|
db_user: User,
|
||||||
|
local_active_sub: Subscription,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
panel_sub_id = str(local_active_sub.panel_subscription_uuid or "").strip()
|
||||||
|
config_link_raw = (
|
||||||
|
await self.panel_service.get_subscription_link(panel_sub_id) if panel_sub_id else None
|
||||||
|
)
|
||||||
|
display_link, connect_button_url = await prepare_config_links(
|
||||||
|
self.settings,
|
||||||
|
config_link_raw,
|
||||||
|
)
|
||||||
|
tariff = None
|
||||||
|
if local_active_sub.tariff_key and self._tariffs_config():
|
||||||
|
try:
|
||||||
|
tariff = self._resolve_tariff(local_active_sub.tariff_key)
|
||||||
|
except Exception:
|
||||||
|
tariff = None
|
||||||
|
language = db_user.language_code or self.settings.DEFAULT_LANGUAGE
|
||||||
|
premium_access = (
|
||||||
|
await self.premium_access_for_tariff(tariff)
|
||||||
|
if tariff
|
||||||
|
else {"squad_uuids": [], "squad_labels": [], "node_labels": []}
|
||||||
|
)
|
||||||
|
premium_baseline = int(local_active_sub.premium_baseline_bytes or 0)
|
||||||
|
premium_topup_balance = int(local_active_sub.premium_topup_balance_bytes or 0)
|
||||||
|
premium_topup_used = int(getattr(local_active_sub, "premium_topup_used_bytes", 0) or 0)
|
||||||
|
premium_bonus_bytes = int(getattr(local_active_sub, "premium_bonus_bytes", 0) or 0)
|
||||||
|
return {
|
||||||
|
"user_id": db_user.panel_user_uuid,
|
||||||
|
"panel_subscription_uuid": local_active_sub.panel_subscription_uuid,
|
||||||
|
"panel_short_uuid": local_active_sub.panel_subscription_uuid,
|
||||||
|
"end_date": local_active_sub.end_date,
|
||||||
|
"status_from_panel": local_active_sub.status_from_panel or "LOCAL_CACHE",
|
||||||
|
"config_link": display_link,
|
||||||
|
"connect_button_url": connect_button_url,
|
||||||
|
"traffic_limit_bytes": local_active_sub.traffic_limit_bytes,
|
||||||
|
"traffic_used_bytes": local_active_sub.traffic_used_bytes,
|
||||||
|
"traffic_limit_strategy": "",
|
||||||
|
"tariff_key": local_active_sub.tariff_key,
|
||||||
|
"tariff_name": tariff.name(language) if tariff else None,
|
||||||
|
"tariff_description": tariff.description(language) if tariff else None,
|
||||||
|
"premium_title": tariff.premium_name(language) if tariff else None,
|
||||||
|
"billing_model": tariff.billing_model
|
||||||
|
if tariff
|
||||||
|
else ("traffic" if getattr(self.settings, "traffic_sale_mode", False) else "period"),
|
||||||
|
"tier_baseline_bytes": local_active_sub.tier_baseline_bytes,
|
||||||
|
"topup_balance_bytes": local_active_sub.topup_balance_bytes,
|
||||||
|
"regular_bonus_bytes": int(getattr(local_active_sub, "regular_bonus_bytes", 0) or 0),
|
||||||
|
"regular_unlimited_override": bool(
|
||||||
|
getattr(local_active_sub, "regular_unlimited_override", False)
|
||||||
|
),
|
||||||
|
"premium_baseline_bytes": premium_baseline,
|
||||||
|
"premium_topup_balance_bytes": premium_topup_balance,
|
||||||
|
"premium_topup_used_bytes": premium_topup_used,
|
||||||
|
"premium_used_bytes": local_active_sub.premium_used_bytes,
|
||||||
|
"premium_bonus_bytes": premium_bonus_bytes,
|
||||||
|
"premium_unlimited_override": bool(
|
||||||
|
getattr(local_active_sub, "premium_unlimited_override", False)
|
||||||
|
),
|
||||||
|
"premium_limit_bytes": self._premium_effective_limit_bytes(
|
||||||
|
premium_baseline,
|
||||||
|
premium_topup_balance,
|
||||||
|
premium_topup_used,
|
||||||
|
premium_bonus_bytes,
|
||||||
|
),
|
||||||
|
"premium_is_limited": bool(local_active_sub.premium_is_limited),
|
||||||
|
"premium_period_start_at": getattr(local_active_sub, "premium_period_start_at", None),
|
||||||
|
"premium_squad_labels": premium_access.get("squad_labels") or [],
|
||||||
|
"premium_node_labels": premium_access.get("node_labels") or [],
|
||||||
|
"period_start_at": local_active_sub.period_start_at,
|
||||||
|
"is_throttled": bool(local_active_sub.is_throttled),
|
||||||
|
"base_hwid_device_limit": local_active_sub.hwid_device_limit,
|
||||||
|
"extra_hwid_devices": int(local_active_sub.extra_hwid_devices or 0),
|
||||||
|
"extra_hwid_devices_valid_until": None,
|
||||||
|
"extra_hwid_devices_valid_until_text": None,
|
||||||
|
"extra_hwid_devices_next_valid_from": None,
|
||||||
|
"device_topup_renewal_available": False,
|
||||||
|
"user_bot_username": db_user.username,
|
||||||
|
"is_panel_data": False,
|
||||||
|
"max_devices": self._effective_hwid_limit(
|
||||||
|
local_active_sub.hwid_device_limit,
|
||||||
|
int(local_active_sub.extra_hwid_devices or 0),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
async def switch_tariff_without_payment(
|
async def switch_tariff_without_payment(
|
||||||
self,
|
self,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
@@ -308,6 +477,17 @@ class SubscriptionLifecycleMixin:
|
|||||||
)
|
)
|
||||||
result["end_date"] = sub.end_date
|
result["end_date"] = sub.end_date
|
||||||
result["is_active"] = sub.is_active
|
result["is_active"] = sub.is_active
|
||||||
|
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||||
|
if db_user:
|
||||||
|
await self._send_payment_success_email(
|
||||||
|
db_user=db_user,
|
||||||
|
sale_mode="tariff_upgrade",
|
||||||
|
months=0,
|
||||||
|
traffic_gb=None,
|
||||||
|
payment_amount=payment_amount,
|
||||||
|
end_date=sub.end_date,
|
||||||
|
provider=provider,
|
||||||
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
tariff = self._resolve_tariff(tariff_key, "period") if self._tariffs_config() else None
|
tariff = self._resolve_tariff(tariff_key, "period") if self._tariffs_config() else None
|
||||||
@@ -568,6 +748,9 @@ class SubscriptionLifecycleMixin:
|
|||||||
active_sub = await subscription_dal.get_active_subscription_by_user_id(
|
active_sub = await subscription_dal.get_active_subscription_by_user_id(
|
||||||
session, user_id, panel_uuid
|
session, user_id, panel_uuid
|
||||||
)
|
)
|
||||||
|
preserve_tariff_limits = bool(
|
||||||
|
active_sub and active_sub.tariff_key and self._tariffs_config()
|
||||||
|
)
|
||||||
if not active_sub or not active_sub.end_date:
|
if not active_sub or not active_sub.end_date:
|
||||||
logging.info(
|
logging.info(
|
||||||
f"No active subscription found for user {user_id}. Creating new one for {bonus_days} days." # noqa: E501
|
f"No active subscription found for user {user_id}. Creating new one for {bonus_days} days." # noqa: E501
|
||||||
@@ -612,6 +795,7 @@ class SubscriptionLifecycleMixin:
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
apply_main_traffic_limit
|
apply_main_traffic_limit
|
||||||
|
and not preserve_tariff_limits
|
||||||
and updated_sub_model
|
and updated_sub_model
|
||||||
and updated_sub_model.traffic_limit_bytes != self.settings.user_traffic_limit_bytes
|
and updated_sub_model.traffic_limit_bytes != self.settings.user_traffic_limit_bytes
|
||||||
):
|
):
|
||||||
@@ -626,7 +810,9 @@ class SubscriptionLifecycleMixin:
|
|||||||
panel_update_payload = self._build_panel_update_payload(
|
panel_update_payload = self._build_panel_update_payload(
|
||||||
expire_at=new_end_date_obj,
|
expire_at=new_end_date_obj,
|
||||||
traffic_limit_bytes=(
|
traffic_limit_bytes=(
|
||||||
self.settings.user_traffic_limit_bytes if apply_main_traffic_limit else None
|
self.settings.user_traffic_limit_bytes
|
||||||
|
if apply_main_traffic_limit and not preserve_tariff_limits
|
||||||
|
else None
|
||||||
),
|
),
|
||||||
include_uuid=False,
|
include_uuid=False,
|
||||||
include_default_squads=False,
|
include_default_squads=False,
|
||||||
@@ -663,15 +849,37 @@ class SubscriptionLifecycleMixin:
|
|||||||
local_active_sub = await subscription_dal.get_active_subscription_by_user_id(
|
local_active_sub = await subscription_dal.get_active_subscription_by_user_id(
|
||||||
session, user_id, panel_user_uuid
|
session, user_id, panel_user_uuid
|
||||||
)
|
)
|
||||||
panel_user_data = await self.panel_service.get_user_by_uuid(panel_user_uuid)
|
(
|
||||||
|
panel_user_data,
|
||||||
|
panel_user_confirmed_absent,
|
||||||
|
panel_lookup_failure_reason,
|
||||||
|
) = await self._lookup_panel_user_for_subscription_details(panel_user_uuid)
|
||||||
|
|
||||||
if not panel_user_data:
|
if not panel_user_data:
|
||||||
|
if panel_user_confirmed_absent:
|
||||||
logging.warning(
|
logging.warning(
|
||||||
f"Panel user {panel_user_uuid} not found on panel for user {user_id}. Clearing local linkage." # noqa: E501
|
"Panel user %s confirmed absent on panel for user %s. "
|
||||||
|
"Clearing local linkage. reason=%s",
|
||||||
|
panel_user_uuid,
|
||||||
|
user_id,
|
||||||
|
panel_lookup_failure_reason,
|
||||||
)
|
)
|
||||||
await subscription_dal.deactivate_all_user_subscriptions(session, user_id)
|
await subscription_dal.deactivate_all_user_subscriptions(session, user_id)
|
||||||
await user_dal.update_user(session, user_id, {"panel_user_uuid": None})
|
await user_dal.update_user(session, user_id, {"panel_user_uuid": None})
|
||||||
return None
|
return None
|
||||||
|
logging.warning(
|
||||||
|
"Panel user %s lookup failed for user %s; treating it as a panel access/API "
|
||||||
|
"problem and preserving local linkage/subscription. reason=%s",
|
||||||
|
panel_user_uuid,
|
||||||
|
user_id,
|
||||||
|
panel_lookup_failure_reason,
|
||||||
|
)
|
||||||
|
if local_active_sub:
|
||||||
|
return await self._local_active_subscription_details_fallback(
|
||||||
|
db_user,
|
||||||
|
local_active_sub,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
panel_lifetime_used = self._extract_lifetime_used_traffic(panel_user_data)
|
panel_lifetime_used = self._extract_lifetime_used_traffic(panel_user_data)
|
||||||
if (
|
if (
|
||||||
@@ -836,6 +1044,14 @@ class SubscriptionLifecycleMixin:
|
|||||||
if expected_hwid_limit is not None:
|
if expected_hwid_limit is not None:
|
||||||
hwid_limit = expected_hwid_limit
|
hwid_limit = expected_hwid_limit
|
||||||
|
|
||||||
|
extra_hwid_valid_until = hwid_entitlement_summary.get("active_until")
|
||||||
|
extra_hwid_next_valid_from = hwid_entitlement_summary.get("next_valid_from")
|
||||||
|
device_topup_renewal_available = self._device_topup_renewal_available(
|
||||||
|
active_extra_hwid_devices,
|
||||||
|
extra_hwid_valid_until,
|
||||||
|
panel_end_date,
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"user_id": panel_user_data.get("uuid"),
|
"user_id": panel_user_data.get("uuid"),
|
||||||
"panel_subscription_uuid": panel_user_data.get("subscriptionUuid")
|
"panel_subscription_uuid": panel_user_data.get("subscriptionUuid")
|
||||||
@@ -896,8 +1112,12 @@ class SubscriptionLifecycleMixin:
|
|||||||
if local_active_sub
|
if local_active_sub
|
||||||
else None,
|
else None,
|
||||||
"extra_hwid_devices": active_extra_hwid_devices,
|
"extra_hwid_devices": active_extra_hwid_devices,
|
||||||
"extra_hwid_devices_valid_until": hwid_entitlement_summary.get("active_until"),
|
"extra_hwid_devices_valid_until": extra_hwid_valid_until,
|
||||||
"extra_hwid_devices_next_valid_from": hwid_entitlement_summary.get("next_valid_from"),
|
"extra_hwid_devices_valid_until_text": self._display_datetime_text(
|
||||||
|
extra_hwid_valid_until
|
||||||
|
),
|
||||||
|
"extra_hwid_devices_next_valid_from": extra_hwid_next_valid_from,
|
||||||
|
"device_topup_renewal_available": device_topup_renewal_available,
|
||||||
"user_bot_username": db_user.username,
|
"user_bot_username": db_user.username,
|
||||||
"is_panel_data": True,
|
"is_panel_data": True,
|
||||||
"max_devices": hwid_limit,
|
"max_devices": hwid_limit,
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ class PaymentContextMixin:
|
|||||||
"""Best-effort branded email confirming the payment. No-op if SMTP or
|
"""Best-effort branded email confirming the payment. No-op if SMTP or
|
||||||
the user's email aren't set. Failures are logged and swallowed so the
|
the user's email aren't set. Failures are logged and swallowed so the
|
||||||
payment flow is never blocked by mail delivery."""
|
payment flow is never blocked by mail delivery."""
|
||||||
if not self.settings.email_auth_configured:
|
if not getattr(self.settings, "email_auth_configured", False):
|
||||||
return
|
return
|
||||||
recipient = (db_user.email or "").strip() if db_user else ""
|
recipient = (db_user.email or "").strip() if db_user else ""
|
||||||
if not recipient:
|
if not recipient:
|
||||||
|
|||||||
@@ -269,6 +269,15 @@ class TrafficMixin:
|
|||||||
purchased_bytes=purchase_bytes,
|
purchased_bytes=purchase_bytes,
|
||||||
kind="topup",
|
kind="topup",
|
||||||
)
|
)
|
||||||
|
await self._send_payment_success_email(
|
||||||
|
db_user=db_user,
|
||||||
|
sale_mode="topup",
|
||||||
|
months=0,
|
||||||
|
traffic_gb=float(traffic_gb),
|
||||||
|
payment_amount=payment_amount,
|
||||||
|
end_date=getattr(updated_sub, "end_date", None),
|
||||||
|
provider=provider,
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"subscription_id": sub.subscription_id,
|
"subscription_id": sub.subscription_id,
|
||||||
"traffic_limit_bytes": new_limit,
|
"traffic_limit_bytes": new_limit,
|
||||||
@@ -376,6 +385,15 @@ class TrafficMixin:
|
|||||||
purchased_bytes=purchase_bytes,
|
purchased_bytes=purchase_bytes,
|
||||||
kind="premium_topup",
|
kind="premium_topup",
|
||||||
)
|
)
|
||||||
|
await self._send_payment_success_email(
|
||||||
|
db_user=db_user,
|
||||||
|
sale_mode="premium_topup",
|
||||||
|
months=0,
|
||||||
|
traffic_gb=float(traffic_gb),
|
||||||
|
payment_amount=payment_amount,
|
||||||
|
end_date=getattr(sub, "end_date", None),
|
||||||
|
provider=provider,
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"subscription_id": sub.subscription_id,
|
"subscription_id": sub.subscription_id,
|
||||||
"premium_limit_bytes": premium_limit,
|
"premium_limit_bytes": premium_limit,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any, Optional
|
from typing import Any, Awaitable, Callable, Optional
|
||||||
|
|
||||||
from aiogram import Bot
|
from aiogram import Bot
|
||||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, WebAppInfo
|
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, WebAppInfo
|
||||||
@@ -15,9 +15,11 @@ from bot.infra.redis import redis_lock
|
|||||||
from bot.middlewares.i18n import JsonI18n
|
from bot.middlewares.i18n import JsonI18n
|
||||||
from bot.services.panel_api_service import PanelApiService
|
from bot.services.panel_api_service import PanelApiService
|
||||||
from bot.services.subscription_service import SubscriptionService
|
from bot.services.subscription_service import SubscriptionService
|
||||||
|
from bot.services.user_email_notifications import send_user_notification_email
|
||||||
from bot.utils.date_utils import month_start
|
from bot.utils.date_utils import month_start
|
||||||
from bot.utils.mini_app_url import subscription_mini_app_topup_url
|
from bot.utils.mini_app_url import subscription_mini_app_topup_url
|
||||||
from config.settings import Settings
|
from config.settings import Settings
|
||||||
|
from db.advisory_locks import acquire_subscription_background_sync_lock
|
||||||
from db.dal import subscription_dal, tariff_dal, user_dal
|
from db.dal import subscription_dal, tariff_dal, user_dal
|
||||||
from db.models import Subscription
|
from db.models import Subscription
|
||||||
|
|
||||||
@@ -31,6 +33,10 @@ TARIFF_WORKER_BATCH_SIZE = 50
|
|||||||
TARIFF_WORKER_PANEL_CONCURRENCY = 10
|
TARIFF_WORKER_PANEL_CONCURRENCY = 10
|
||||||
TARIFF_WORKER_BULK_PANEL_FETCH_THRESHOLD = 50
|
TARIFF_WORKER_BULK_PANEL_FETCH_THRESHOLD = 50
|
||||||
TARIFF_WORKER_SQUAD_CONFIRMATION_CACHE_TTL_SECONDS = 900
|
TARIFF_WORKER_SQUAD_CONFIRMATION_CACHE_TTL_SECONDS = 900
|
||||||
|
TARIFF_WORKER_DB_RETRY_ATTEMPTS = 3
|
||||||
|
TARIFF_WORKER_DB_RETRY_BASE_SLEEP_SECONDS = 0.5
|
||||||
|
POSTGRES_RETRYABLE_SQLSTATES = {"40001", "40P01"}
|
||||||
|
POSTGRES_RETRYABLE_ERROR_NAMES = {"DeadlockDetectedError", "SerializationError"}
|
||||||
|
|
||||||
|
|
||||||
class TariffTrafficWorker:
|
class TariffTrafficWorker:
|
||||||
@@ -97,6 +103,36 @@ class TariffTrafficWorker:
|
|||||||
button = InlineKeyboardButton(text=_(fallback_key), callback_data="tariff_topup:list")
|
button = InlineKeyboardButton(text=_(fallback_key), callback_data="tariff_topup:list")
|
||||||
return InlineKeyboardMarkup(inline_keyboard=[[button]])
|
return InlineKeyboardMarkup(inline_keyboard=[[button]])
|
||||||
|
|
||||||
|
async def _send_traffic_warning_email(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
user_id: int,
|
||||||
|
subject_key: str,
|
||||||
|
message_text: str,
|
||||||
|
kind: str,
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
user = await user_dal.get_user_by_id(session, user_id)
|
||||||
|
except Exception:
|
||||||
|
logging.exception("TariffTrafficWorker: failed to load user %s for email", user_id)
|
||||||
|
return
|
||||||
|
if not user:
|
||||||
|
return
|
||||||
|
await send_user_notification_email(
|
||||||
|
settings=self.settings,
|
||||||
|
i18n=self.i18n,
|
||||||
|
user=user,
|
||||||
|
subject_key=subject_key,
|
||||||
|
message_text=message_text,
|
||||||
|
dashboard_url=subscription_mini_app_topup_url(self.settings, kind),
|
||||||
|
cta_label_key=(
|
||||||
|
"email_traffic_warning_premium_cta"
|
||||||
|
if kind == "premium"
|
||||||
|
else "email_traffic_warning_regular_cta"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
async def run(self) -> None:
|
async def run(self) -> None:
|
||||||
if not self.settings.tariffs_config:
|
if not self.settings.tariffs_config:
|
||||||
return
|
return
|
||||||
@@ -111,12 +147,14 @@ class TariffTrafficWorker:
|
|||||||
logging.info("TariffTrafficWorker tick skipped: Redis lock is held")
|
logging.info("TariffTrafficWorker tick skipped: Redis lock is held")
|
||||||
else:
|
else:
|
||||||
started = time.monotonic()
|
started = time.monotonic()
|
||||||
async with self.session_factory() as session:
|
await self._run_db_tick_with_retry(
|
||||||
await self.traffic_period_tick(session)
|
"traffic_period",
|
||||||
await session.commit()
|
self.traffic_period_tick,
|
||||||
async with self.session_factory() as session:
|
)
|
||||||
await self.legacy_throttle_recovery_tick(session)
|
await self._run_db_tick_with_retry(
|
||||||
await session.commit()
|
"legacy_throttle_recovery",
|
||||||
|
self.legacy_throttle_recovery_tick,
|
||||||
|
)
|
||||||
logging.info(
|
logging.info(
|
||||||
"metric worker_tick_duration_seconds=%.3f worker=tariff",
|
"metric worker_tick_duration_seconds=%.3f worker=tariff",
|
||||||
time.monotonic() - started,
|
time.monotonic() - started,
|
||||||
@@ -134,16 +172,80 @@ class TariffTrafficWorker:
|
|||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
self._stopped.set()
|
self._stopped.set()
|
||||||
|
|
||||||
|
async def _run_db_tick_with_retry(
|
||||||
|
self,
|
||||||
|
tick_name: str,
|
||||||
|
tick: Callable[[AsyncSession], Awaitable[None]],
|
||||||
|
) -> None:
|
||||||
|
for attempt in range(1, TARIFF_WORKER_DB_RETRY_ATTEMPTS + 1):
|
||||||
|
async with self.session_factory() as session:
|
||||||
|
try:
|
||||||
|
await acquire_subscription_background_sync_lock(session)
|
||||||
|
await tick(session)
|
||||||
|
await session.commit()
|
||||||
|
return
|
||||||
|
except Exception as exc:
|
||||||
|
await session.rollback()
|
||||||
|
if (
|
||||||
|
attempt < TARIFF_WORKER_DB_RETRY_ATTEMPTS
|
||||||
|
and self._is_retryable_db_exception(exc)
|
||||||
|
):
|
||||||
|
delay = TARIFF_WORKER_DB_RETRY_BASE_SLEEP_SECONDS * attempt
|
||||||
|
logging.warning(
|
||||||
|
"TariffTrafficWorker %s retrying after database concurrency "
|
||||||
|
"error, attempt %s/%s: %s",
|
||||||
|
tick_name,
|
||||||
|
attempt + 1,
|
||||||
|
TARIFF_WORKER_DB_RETRY_ATTEMPTS,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
continue
|
||||||
|
raise
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_retryable_db_exception(exc: BaseException) -> bool:
|
||||||
|
pending: list[BaseException] = [exc]
|
||||||
|
seen: set[int] = set()
|
||||||
|
while pending:
|
||||||
|
current = pending.pop()
|
||||||
|
current_id = id(current)
|
||||||
|
if current_id in seen:
|
||||||
|
continue
|
||||||
|
seen.add(current_id)
|
||||||
|
|
||||||
|
sqlstate = getattr(current, "sqlstate", None) or getattr(current, "pgcode", None)
|
||||||
|
if sqlstate in POSTGRES_RETRYABLE_SQLSTATES:
|
||||||
|
return True
|
||||||
|
|
||||||
|
error_name = type(current).__name__
|
||||||
|
message = str(current).lower()
|
||||||
|
if (
|
||||||
|
error_name in POSTGRES_RETRYABLE_ERROR_NAMES
|
||||||
|
or "deadlock detected" in message
|
||||||
|
or "could not serialize access" in message
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
|
||||||
|
for attr in ("orig", "__cause__", "__context__"):
|
||||||
|
nested = getattr(current, attr, None)
|
||||||
|
if isinstance(nested, BaseException):
|
||||||
|
pending.append(nested)
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
async def traffic_period_tick(self, session: AsyncSession) -> None:
|
async def traffic_period_tick(self, session: AsyncSession) -> None:
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
self._premium_node_usage_tick_cache = {}
|
self._premium_node_usage_tick_cache = {}
|
||||||
warning_period_start = month_start(now)
|
warning_period_start = month_start(now)
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(Subscription).where(
|
select(Subscription)
|
||||||
|
.where(
|
||||||
Subscription.is_active == True,
|
Subscription.is_active == True,
|
||||||
Subscription.end_date > now,
|
Subscription.end_date > now,
|
||||||
Subscription.tariff_key.is_not(None),
|
Subscription.tariff_key.is_not(None),
|
||||||
)
|
)
|
||||||
|
.order_by(Subscription.subscription_id.asc())
|
||||||
)
|
)
|
||||||
subs = list(result.scalars().all())
|
subs = list(result.scalars().all())
|
||||||
if not subs:
|
if not subs:
|
||||||
@@ -454,6 +556,8 @@ class TariffTrafficWorker:
|
|||||||
return
|
return
|
||||||
ratio = used_val / limit_val
|
ratio = used_val / limit_val
|
||||||
levels = list(getattr(self.settings, "tariff_traffic_warning_levels", [85, 90, 95]))
|
levels = list(getattr(self.settings, "tariff_traffic_warning_levels", [85, 90, 95]))
|
||||||
|
if 100 not in levels:
|
||||||
|
levels.append(100)
|
||||||
for level in levels:
|
for level in levels:
|
||||||
threshold = level / 100
|
threshold = level / 100
|
||||||
if ratio < threshold:
|
if ratio < threshold:
|
||||||
@@ -474,8 +578,6 @@ class TariffTrafficWorker:
|
|||||||
level=level,
|
level=level,
|
||||||
traffic_limit_bytes=limit_val if tariff.billing_model == "traffic" else None,
|
traffic_limit_bytes=limit_val if tariff.billing_model == "traffic" else None,
|
||||||
)
|
)
|
||||||
if self.bot:
|
|
||||||
try:
|
|
||||||
user_lang = await self._user_lang(session, sub.user_id)
|
user_lang = await self._user_lang(session, sub.user_id)
|
||||||
_ = (
|
_ = (
|
||||||
(lambda k, **kw: self.i18n.gettext(user_lang, k, **kw))
|
(lambda k, **kw: self.i18n.gettext(user_lang, k, **kw))
|
||||||
@@ -492,12 +594,16 @@ class TariffTrafficWorker:
|
|||||||
left_pct=left_pct,
|
left_pct=left_pct,
|
||||||
**usage,
|
**usage,
|
||||||
)
|
)
|
||||||
|
subject_key = "email_traffic_warning_regular_almost_subject"
|
||||||
else:
|
else:
|
||||||
text = _(
|
text = _(
|
||||||
"traffic_warning_regular_depleted",
|
"traffic_warning_regular_depleted",
|
||||||
tariff_name=tariff_name,
|
tariff_name=tariff_name,
|
||||||
**usage,
|
**usage,
|
||||||
)
|
)
|
||||||
|
subject_key = "email_traffic_warning_regular_depleted_subject"
|
||||||
|
if self.bot:
|
||||||
|
try:
|
||||||
markup = self._traffic_topup_markup(user_lang, "regular")
|
markup = self._traffic_topup_markup(user_lang, "regular")
|
||||||
await self.bot.send_message(
|
await self.bot.send_message(
|
||||||
sub.user_id,
|
sub.user_id,
|
||||||
@@ -507,6 +613,13 @@ class TariffTrafficWorker:
|
|||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logging.exception("Failed to send traffic warning to user %s", sub.user_id)
|
logging.exception("Failed to send traffic warning to user %s", sub.user_id)
|
||||||
|
await self._send_traffic_warning_email(
|
||||||
|
session,
|
||||||
|
user_id=sub.user_id,
|
||||||
|
subject_key=subject_key,
|
||||||
|
message_text=text,
|
||||||
|
kind="regular",
|
||||||
|
)
|
||||||
if ratio >= 1.0 and not sub.is_throttled:
|
if ratio >= 1.0 and not sub.is_throttled:
|
||||||
logging.info(
|
logging.info(
|
||||||
"Tariff traffic limit reached for user %s subscription %s. "
|
"Tariff traffic limit reached for user %s subscription %s. "
|
||||||
@@ -930,8 +1043,6 @@ class TariffTrafficWorker:
|
|||||||
level=PREMIUM_WARNING_DEPLETED_LEVEL,
|
level=PREMIUM_WARNING_DEPLETED_LEVEL,
|
||||||
traffic_limit_bytes=None,
|
traffic_limit_bytes=None,
|
||||||
)
|
)
|
||||||
if self.bot:
|
|
||||||
try:
|
|
||||||
user_lang = await self._user_lang(session, sub.user_id)
|
user_lang = await self._user_lang(session, sub.user_id)
|
||||||
_ = (
|
_ = (
|
||||||
(lambda k, **kw: self.i18n.gettext(user_lang, k, **kw))
|
(lambda k, **kw: self.i18n.gettext(user_lang, k, **kw))
|
||||||
@@ -955,6 +1066,8 @@ class TariffTrafficWorker:
|
|||||||
servers=servers,
|
servers=servers,
|
||||||
**usage,
|
**usage,
|
||||||
)
|
)
|
||||||
|
if self.bot:
|
||||||
|
try:
|
||||||
markup = self._traffic_topup_markup(user_lang, "premium")
|
markup = self._traffic_topup_markup(user_lang, "premium")
|
||||||
await self.bot.send_message(
|
await self.bot.send_message(
|
||||||
sub.user_id,
|
sub.user_id,
|
||||||
@@ -966,6 +1079,13 @@ class TariffTrafficWorker:
|
|||||||
logging.exception(
|
logging.exception(
|
||||||
"Failed to send premium traffic depleted warning to user %s", sub.user_id
|
"Failed to send premium traffic depleted warning to user %s", sub.user_id
|
||||||
)
|
)
|
||||||
|
await self._send_traffic_warning_email(
|
||||||
|
session,
|
||||||
|
user_id=sub.user_id,
|
||||||
|
subject_key="email_traffic_warning_premium_depleted_subject",
|
||||||
|
message_text=text,
|
||||||
|
kind="premium",
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
for level in levels:
|
for level in levels:
|
||||||
@@ -989,9 +1109,6 @@ class TariffTrafficWorker:
|
|||||||
level=storage_level,
|
level=storage_level,
|
||||||
traffic_limit_bytes=None,
|
traffic_limit_bytes=None,
|
||||||
)
|
)
|
||||||
if not self.bot:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
user_lang = await self._user_lang(session, sub.user_id)
|
user_lang = await self._user_lang(session, sub.user_id)
|
||||||
_ = (
|
_ = (
|
||||||
(lambda k, **kw: self.i18n.gettext(user_lang, k, **kw))
|
(lambda k, **kw: self.i18n.gettext(user_lang, k, **kw))
|
||||||
@@ -1017,6 +1134,8 @@ class TariffTrafficWorker:
|
|||||||
servers=servers,
|
servers=servers,
|
||||||
**usage,
|
**usage,
|
||||||
)
|
)
|
||||||
|
if self.bot:
|
||||||
|
try:
|
||||||
markup = self._traffic_topup_markup(user_lang, "premium")
|
markup = self._traffic_topup_markup(user_lang, "premium")
|
||||||
await self.bot.send_message(
|
await self.bot.send_message(
|
||||||
sub.user_id,
|
sub.user_id,
|
||||||
@@ -1025,7 +1144,16 @@ class TariffTrafficWorker:
|
|||||||
parse_mode="HTML",
|
parse_mode="HTML",
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logging.exception("Failed to send premium traffic warning to user %s", sub.user_id)
|
logging.exception(
|
||||||
|
"Failed to send premium traffic warning to user %s", sub.user_id
|
||||||
|
)
|
||||||
|
await self._send_traffic_warning_email(
|
||||||
|
session,
|
||||||
|
user_id=sub.user_id,
|
||||||
|
subject_key="email_traffic_warning_premium_almost_subject",
|
||||||
|
message_text=text,
|
||||||
|
kind="premium",
|
||||||
|
)
|
||||||
|
|
||||||
async def _premium_node_uuids_for_tariff(self, tariff) -> list[str]:
|
async def _premium_node_uuids_for_tariff(self, tariff) -> list[str]:
|
||||||
cache_key = tuple(sorted(tariff.premium_squad_uuids or []))
|
cache_key = tuple(sorted(tariff.premium_squad_uuids or []))
|
||||||
@@ -1159,10 +1287,12 @@ class TariffTrafficWorker:
|
|||||||
from Internal Squads.
|
from Internal Squads.
|
||||||
"""
|
"""
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(Subscription).where(
|
select(Subscription)
|
||||||
|
.where(
|
||||||
Subscription.is_active == True,
|
Subscription.is_active == True,
|
||||||
Subscription.is_throttled == True,
|
Subscription.is_throttled == True,
|
||||||
)
|
)
|
||||||
|
.order_by(Subscription.subscription_id.asc())
|
||||||
)
|
)
|
||||||
for sub in result.scalars().all():
|
for sub in result.scalars().all():
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from aiogram import Bot
|
||||||
|
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||||
|
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, WebAppInfo
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from bot.middlewares.i18n import JsonI18n
|
||||||
|
from config.settings import Settings
|
||||||
|
from db.dal import user_dal
|
||||||
|
from db.models import User
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
TELEGRAM_NOTIFICATIONS_UNKNOWN = "unknown"
|
||||||
|
TELEGRAM_NOTIFICATIONS_ENABLED = "enabled"
|
||||||
|
TELEGRAM_NOTIFICATIONS_NEEDS_START = "needs_start"
|
||||||
|
TELEGRAM_NOTIFICATIONS_BLOCKED = "blocked"
|
||||||
|
TELEGRAM_NOTIFICATION_STATUSES = {
|
||||||
|
TELEGRAM_NOTIFICATIONS_UNKNOWN,
|
||||||
|
TELEGRAM_NOTIFICATIONS_ENABLED,
|
||||||
|
TELEGRAM_NOTIFICATIONS_NEEDS_START,
|
||||||
|
TELEGRAM_NOTIFICATIONS_BLOCKED,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_telegram_notification_status(value: Optional[str]) -> str:
|
||||||
|
status = str(value or "").strip().lower()
|
||||||
|
return status if status in TELEGRAM_NOTIFICATION_STATUSES else TELEGRAM_NOTIFICATIONS_UNKNOWN
|
||||||
|
|
||||||
|
|
||||||
|
def telegram_notifications_enabled(user: Optional[User]) -> bool:
|
||||||
|
return (
|
||||||
|
bool(getattr(user, "telegram_id", None))
|
||||||
|
and normalize_telegram_notification_status(
|
||||||
|
getattr(user, "telegram_notifications_status", None)
|
||||||
|
)
|
||||||
|
== TELEGRAM_NOTIFICATIONS_ENABLED
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def telegram_notifications_need_prompt(user: Optional[User]) -> bool:
|
||||||
|
status = normalize_telegram_notification_status(
|
||||||
|
getattr(user, "telegram_notifications_status", None)
|
||||||
|
)
|
||||||
|
return bool(getattr(user, "telegram_id", None)) and status in {
|
||||||
|
TELEGRAM_NOTIFICATIONS_NEEDS_START,
|
||||||
|
TELEGRAM_NOTIFICATIONS_BLOCKED,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def telegram_notifications_start_link(bot_username: Optional[str]) -> Optional[str]:
|
||||||
|
username = str(bot_username or "").strip().lstrip("@")
|
||||||
|
if not username or username == "your_bot_username":
|
||||||
|
return None
|
||||||
|
return f"https://t.me/{username}?start=notifications"
|
||||||
|
|
||||||
|
|
||||||
|
def telegram_notification_status_from_error(exc: Exception) -> Optional[str]:
|
||||||
|
if isinstance(exc, TelegramForbiddenError):
|
||||||
|
return TELEGRAM_NOTIFICATIONS_BLOCKED
|
||||||
|
if not isinstance(exc, TelegramBadRequest):
|
||||||
|
return None
|
||||||
|
|
||||||
|
message = str(exc).lower()
|
||||||
|
if any(
|
||||||
|
token in message
|
||||||
|
for token in (
|
||||||
|
"bot was blocked",
|
||||||
|
"user is deactivated",
|
||||||
|
"forbidden",
|
||||||
|
)
|
||||||
|
):
|
||||||
|
return TELEGRAM_NOTIFICATIONS_BLOCKED
|
||||||
|
if any(
|
||||||
|
token in message
|
||||||
|
for token in (
|
||||||
|
"chat not found",
|
||||||
|
"bot can't initiate conversation",
|
||||||
|
"bot can't initiate",
|
||||||
|
"user not found",
|
||||||
|
)
|
||||||
|
):
|
||||||
|
return TELEGRAM_NOTIFICATIONS_NEEDS_START
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def mark_telegram_notifications_status(
|
||||||
|
session: AsyncSession,
|
||||||
|
user_id: int,
|
||||||
|
status: str,
|
||||||
|
*,
|
||||||
|
telegram_id: Optional[int] = None,
|
||||||
|
checked_at: Optional[datetime] = None,
|
||||||
|
) -> Optional[User]:
|
||||||
|
normalized = normalize_telegram_notification_status(status)
|
||||||
|
now = checked_at or datetime.now(timezone.utc)
|
||||||
|
update_data: dict[str, Any] = {
|
||||||
|
"telegram_notifications_status": normalized,
|
||||||
|
"telegram_notifications_checked_at": now,
|
||||||
|
}
|
||||||
|
if telegram_id:
|
||||||
|
update_data["telegram_id"] = int(telegram_id)
|
||||||
|
if normalized == TELEGRAM_NOTIFICATIONS_ENABLED:
|
||||||
|
update_data["telegram_notifications_enabled_at"] = now
|
||||||
|
update_data["telegram_notifications_blocked_at"] = None
|
||||||
|
elif normalized == TELEGRAM_NOTIFICATIONS_BLOCKED:
|
||||||
|
update_data["telegram_notifications_blocked_at"] = now
|
||||||
|
return await user_dal.update_user(session, user_id, update_data)
|
||||||
|
|
||||||
|
|
||||||
|
async def mark_telegram_notifications_enabled_for_telegram_user(
|
||||||
|
session: AsyncSession,
|
||||||
|
telegram_id: int,
|
||||||
|
) -> Optional[User]:
|
||||||
|
db_user = await user_dal.get_user_by_telegram_id(session, telegram_id)
|
||||||
|
if not db_user:
|
||||||
|
db_user = await user_dal.get_user_by_id(session, telegram_id)
|
||||||
|
if not db_user:
|
||||||
|
return None
|
||||||
|
return await mark_telegram_notifications_status(
|
||||||
|
session,
|
||||||
|
int(db_user.user_id),
|
||||||
|
TELEGRAM_NOTIFICATIONS_ENABLED,
|
||||||
|
telegram_id=telegram_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _translate(
|
||||||
|
i18n: Optional[JsonI18n],
|
||||||
|
language: str,
|
||||||
|
key: str,
|
||||||
|
fallback: str,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> str:
|
||||||
|
if not i18n:
|
||||||
|
return fallback.format(**kwargs) if kwargs else fallback
|
||||||
|
return i18n.gettext(language, key, **kwargs) or fallback
|
||||||
|
|
||||||
|
|
||||||
|
def _probe_keyboard(
|
||||||
|
settings: Settings,
|
||||||
|
i18n: Optional[JsonI18n],
|
||||||
|
language: str,
|
||||||
|
) -> Optional[InlineKeyboardMarkup]:
|
||||||
|
app_url = str(getattr(settings, "SUBSCRIPTION_MINI_APP_URL", "") or "").strip()
|
||||||
|
if not app_url:
|
||||||
|
return None
|
||||||
|
text = _translate(
|
||||||
|
i18n,
|
||||||
|
language,
|
||||||
|
"telegram_notifications_open_app_button",
|
||||||
|
"Open app",
|
||||||
|
)
|
||||||
|
return InlineKeyboardMarkup(
|
||||||
|
inline_keyboard=[[InlineKeyboardButton(text=text, web_app=WebAppInfo(url=app_url))]]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def probe_telegram_notifications(
|
||||||
|
*,
|
||||||
|
session: AsyncSession,
|
||||||
|
bot: Bot,
|
||||||
|
settings: Settings,
|
||||||
|
i18n: Optional[JsonI18n],
|
||||||
|
user: User,
|
||||||
|
bot_username: Optional[str] = None,
|
||||||
|
force: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
telegram_id = getattr(user, "telegram_id", None)
|
||||||
|
if not telegram_id:
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"status": TELEGRAM_NOTIFICATIONS_UNKNOWN,
|
||||||
|
"start_link": telegram_notifications_start_link(bot_username),
|
||||||
|
}
|
||||||
|
|
||||||
|
current_status = normalize_telegram_notification_status(
|
||||||
|
getattr(user, "telegram_notifications_status", None)
|
||||||
|
)
|
||||||
|
if current_status == TELEGRAM_NOTIFICATIONS_ENABLED and not force:
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"status": TELEGRAM_NOTIFICATIONS_ENABLED,
|
||||||
|
"start_link": telegram_notifications_start_link(bot_username),
|
||||||
|
}
|
||||||
|
|
||||||
|
language = str(getattr(user, "language_code", "") or settings.DEFAULT_LANGUAGE)
|
||||||
|
text = _translate(
|
||||||
|
i18n,
|
||||||
|
language,
|
||||||
|
"telegram_notifications_enabled_message",
|
||||||
|
"Telegram notifications are enabled.",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await bot.send_message(
|
||||||
|
int(telegram_id),
|
||||||
|
text,
|
||||||
|
reply_markup=_probe_keyboard(settings, i18n, language),
|
||||||
|
disable_web_page_preview=True,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
status = telegram_notification_status_from_error(exc)
|
||||||
|
if status:
|
||||||
|
await mark_telegram_notifications_status(session, int(user.user_id), status)
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"status": status,
|
||||||
|
"start_link": telegram_notifications_start_link(bot_username),
|
||||||
|
}
|
||||||
|
logger.warning(
|
||||||
|
"Telegram notification probe failed for user %s / telegram %s: %s",
|
||||||
|
user.user_id,
|
||||||
|
telegram_id,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
await mark_telegram_notifications_status(
|
||||||
|
session,
|
||||||
|
int(user.user_id),
|
||||||
|
TELEGRAM_NOTIFICATIONS_UNKNOWN,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"status": TELEGRAM_NOTIFICATIONS_UNKNOWN,
|
||||||
|
"start_link": telegram_notifications_start_link(bot_username),
|
||||||
|
}
|
||||||
|
|
||||||
|
await mark_telegram_notifications_status(
|
||||||
|
session,
|
||||||
|
int(user.user_id),
|
||||||
|
TELEGRAM_NOTIFICATIONS_ENABLED,
|
||||||
|
telegram_id=int(telegram_id),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"status": TELEGRAM_NOTIFICATIONS_ENABLED,
|
||||||
|
"start_link": telegram_notifications_start_link(bot_username),
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import logging
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from bot.middlewares.i18n import JsonI18n
|
||||||
|
from bot.services.email_auth_service import EmailAuthService
|
||||||
|
from bot.services.email_templates import render_user_notification
|
||||||
|
from config.settings import Settings
|
||||||
|
|
||||||
|
|
||||||
|
def _translate(
|
||||||
|
i18n: Optional[JsonI18n],
|
||||||
|
language: str,
|
||||||
|
key: Optional[str],
|
||||||
|
fallback: str = "",
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> str:
|
||||||
|
if not key:
|
||||||
|
return fallback
|
||||||
|
if not i18n:
|
||||||
|
return fallback or key
|
||||||
|
text = i18n.gettext(language, key, **kwargs)
|
||||||
|
return fallback if text == key and fallback else text
|
||||||
|
|
||||||
|
|
||||||
|
async def send_user_notification_email(
|
||||||
|
*,
|
||||||
|
settings: Settings,
|
||||||
|
i18n: Optional[JsonI18n],
|
||||||
|
user: Any,
|
||||||
|
subject_key: str,
|
||||||
|
message_text: str,
|
||||||
|
dashboard_url: Optional[str] = None,
|
||||||
|
cta_label_key: str = "email_user_notification_cta",
|
||||||
|
subject_kwargs: Optional[dict[str, Any]] = None,
|
||||||
|
heading_key: Optional[str] = None,
|
||||||
|
intro_key: Optional[str] = None,
|
||||||
|
) -> bool:
|
||||||
|
if not getattr(settings, "email_auth_configured", False):
|
||||||
|
return False
|
||||||
|
recipient = str(getattr(user, "email", "") or "").strip()
|
||||||
|
if not recipient:
|
||||||
|
return False
|
||||||
|
|
||||||
|
language = (
|
||||||
|
str(getattr(user, "language_code", "") or "").strip()
|
||||||
|
or getattr(settings, "DEFAULT_LANGUAGE", "ru")
|
||||||
|
or "ru"
|
||||||
|
)
|
||||||
|
kwargs = subject_kwargs or {}
|
||||||
|
subject = _translate(i18n, language, subject_key, subject_key, **kwargs)
|
||||||
|
heading = _translate(i18n, language, heading_key, subject, **kwargs)
|
||||||
|
intro = _translate(
|
||||||
|
i18n,
|
||||||
|
language,
|
||||||
|
intro_key or "email_user_notification_intro",
|
||||||
|
"Notification from your account.",
|
||||||
|
)
|
||||||
|
cta_label = _translate(
|
||||||
|
i18n,
|
||||||
|
language,
|
||||||
|
cta_label_key or "email_user_notification_cta",
|
||||||
|
"Open dashboard",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
content = render_user_notification(
|
||||||
|
settings,
|
||||||
|
language_code=language,
|
||||||
|
subject=subject,
|
||||||
|
heading=heading,
|
||||||
|
intro=intro,
|
||||||
|
message_text=message_text,
|
||||||
|
dashboard_url=dashboard_url,
|
||||||
|
cta_label=cta_label,
|
||||||
|
i18n=i18n,
|
||||||
|
)
|
||||||
|
await EmailAuthService(settings, i18n).send_rendered_email(
|
||||||
|
email=recipient,
|
||||||
|
content=content,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
logging.exception("Failed to send user notification email to %s.", recipient)
|
||||||
|
return False
|
||||||
@@ -214,10 +214,10 @@ class Settings(BaseSettings):
|
|||||||
MONTH_6_ENABLED: bool = Field(default=True, alias="6_MONTHS_ENABLED")
|
MONTH_6_ENABLED: bool = Field(default=True, alias="6_MONTHS_ENABLED")
|
||||||
MONTH_12_ENABLED: bool = Field(default=True, alias="12_MONTHS_ENABLED")
|
MONTH_12_ENABLED: bool = Field(default=True, alias="12_MONTHS_ENABLED")
|
||||||
|
|
||||||
RUB_PRICE_1_MONTH: Optional[int] = Field(default=None)
|
RUB_PRICE_1_MONTH: Optional[int] = Field(default=200)
|
||||||
RUB_PRICE_3_MONTHS: Optional[int] = Field(default=None)
|
RUB_PRICE_3_MONTHS: Optional[int] = Field(default=600)
|
||||||
RUB_PRICE_6_MONTHS: Optional[int] = Field(default=None)
|
RUB_PRICE_6_MONTHS: Optional[int] = Field(default=1200)
|
||||||
RUB_PRICE_12_MONTHS: Optional[int] = Field(default=None)
|
RUB_PRICE_12_MONTHS: Optional[int] = Field(default=2400)
|
||||||
|
|
||||||
STARS_PRICE_1_MONTH: Optional[int] = Field(default=None)
|
STARS_PRICE_1_MONTH: Optional[int] = Field(default=None)
|
||||||
STARS_PRICE_3_MONTHS: Optional[int] = Field(default=None)
|
STARS_PRICE_3_MONTHS: Optional[int] = Field(default=None)
|
||||||
@@ -240,6 +240,7 @@ class Settings(BaseSettings):
|
|||||||
)
|
)
|
||||||
|
|
||||||
SUBSCRIPTION_NOTIFICATIONS_ENABLED: bool = Field(default=True)
|
SUBSCRIPTION_NOTIFICATIONS_ENABLED: bool = Field(default=True)
|
||||||
|
SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED: bool = Field(default=True)
|
||||||
SUBSCRIPTION_NOTIFY_ON_EXPIRE: bool = Field(default=True)
|
SUBSCRIPTION_NOTIFY_ON_EXPIRE: bool = Field(default=True)
|
||||||
SUBSCRIPTION_NOTIFY_AFTER_EXPIRE: bool = Field(default=True)
|
SUBSCRIPTION_NOTIFY_AFTER_EXPIRE: bool = Field(default=True)
|
||||||
SUBSCRIPTION_NOTIFY_DAYS_BEFORE: int = Field(default=3)
|
SUBSCRIPTION_NOTIFY_DAYS_BEFORE: int = Field(default=3)
|
||||||
|
|||||||
@@ -95,6 +95,8 @@ class Tariff(BaseModel):
|
|||||||
monthly_gb: Optional[float] = None
|
monthly_gb: Optional[float] = None
|
||||||
prices_rub: Dict[str, float] = Field(default_factory=dict)
|
prices_rub: Dict[str, float] = Field(default_factory=dict)
|
||||||
prices_stars: Dict[str, float] = Field(default_factory=dict)
|
prices_stars: Dict[str, float] = Field(default_factory=dict)
|
||||||
|
referral_bonus_days_inviter: Dict[str, int] = Field(default_factory=dict)
|
||||||
|
referral_bonus_days_referee: Dict[str, int] = Field(default_factory=dict)
|
||||||
enabled_periods: List[int] = Field(default_factory=list)
|
enabled_periods: List[int] = Field(default_factory=list)
|
||||||
topup_packages: Optional[PackageSet] = None
|
topup_packages: Optional[PackageSet] = None
|
||||||
|
|
||||||
@@ -129,6 +131,14 @@ class Tariff(BaseModel):
|
|||||||
if self.billing_model == "period":
|
if self.billing_model == "period":
|
||||||
if self.monthly_gb is None or self.monthly_gb < 0:
|
if self.monthly_gb is None or self.monthly_gb < 0:
|
||||||
raise ValueError(f"period tariff {self.key}: monthly_gb must be >= 0")
|
raise ValueError(f"period tariff {self.key}: monthly_gb must be >= 0")
|
||||||
|
self.referral_bonus_days_inviter = self._normalize_referral_bonus_map(
|
||||||
|
self.referral_bonus_days_inviter,
|
||||||
|
"referral_bonus_days_inviter",
|
||||||
|
)
|
||||||
|
self.referral_bonus_days_referee = self._normalize_referral_bonus_map(
|
||||||
|
self.referral_bonus_days_referee,
|
||||||
|
"referral_bonus_days_referee",
|
||||||
|
)
|
||||||
if not self.enabled_periods:
|
if not self.enabled_periods:
|
||||||
raise ValueError(f"period tariff {self.key}: enabled_periods is required")
|
raise ValueError(f"period tariff {self.key}: enabled_periods is required")
|
||||||
for months in self.enabled_periods:
|
for months in self.enabled_periods:
|
||||||
@@ -152,6 +162,23 @@ class Tariff(BaseModel):
|
|||||||
)
|
)
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
def _normalize_referral_bonus_map(
|
||||||
|
self, values: Dict[str, int], field_name: str
|
||||||
|
) -> Dict[str, int]:
|
||||||
|
normalized: Dict[str, int] = {}
|
||||||
|
for period, days in (values or {}).items():
|
||||||
|
try:
|
||||||
|
months = int(float(str(period).strip()))
|
||||||
|
bonus_days = int(float(days))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise ValueError(f"tariff {self.key}: {field_name} contains invalid entry")
|
||||||
|
if months <= 0:
|
||||||
|
raise ValueError(f"tariff {self.key}: {field_name} periods must be positive")
|
||||||
|
if bonus_days < 0:
|
||||||
|
raise ValueError(f"tariff {self.key}: {field_name} days must be >= 0")
|
||||||
|
normalized[str(months)] = bonus_days
|
||||||
|
return normalized
|
||||||
|
|
||||||
def name(self, lang: str, fallback: str = "ru") -> str:
|
def name(self, lang: str, fallback: str = "ru") -> str:
|
||||||
return self.names.get(lang) or self.names.get(fallback) or self.key
|
return self.names.get(lang) or self.names.get(fallback) or self.key
|
||||||
|
|
||||||
@@ -173,6 +200,14 @@ class Tariff(BaseModel):
|
|||||||
value = source.get(str(months))
|
value = source.get(str(months))
|
||||||
return float(value) if value is not None else None
|
return float(value) if value is not None else None
|
||||||
|
|
||||||
|
def referral_inviter_bonus_days(self, months: int) -> Optional[int]:
|
||||||
|
value = self.referral_bonus_days_inviter.get(str(int(months)))
|
||||||
|
return int(value) if value is not None else None
|
||||||
|
|
||||||
|
def referral_referee_bonus_days(self, months: int) -> Optional[int]:
|
||||||
|
value = self.referral_bonus_days_referee.get(str(int(months)))
|
||||||
|
return int(value) if value is not None else None
|
||||||
|
|
||||||
def min_period_price_rub(self) -> Optional[float]:
|
def min_period_price_rub(self) -> Optional[float]:
|
||||||
prices = [
|
prices = [
|
||||||
float(self.prices_rub[str(months)])
|
float(self.prices_rub[str(months)])
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
# Serializes background jobs that rewrite subscription rows from panel state.
|
||||||
|
SUBSCRIPTION_BACKGROUND_SYNC_LOCK_ID = 817512404897421338
|
||||||
|
|
||||||
|
|
||||||
|
async def acquire_subscription_background_sync_lock(session: AsyncSession) -> None:
|
||||||
|
await session.execute(
|
||||||
|
text("SELECT pg_advisory_xact_lock(:lock_id)"),
|
||||||
|
{"lock_id": SUBSCRIPTION_BACKGROUND_SYNC_LOCK_ID},
|
||||||
|
)
|
||||||
@@ -408,6 +408,27 @@ async def merge_users(
|
|||||||
target.channel_subscription_checked_at = source.channel_subscription_checked_at
|
target.channel_subscription_checked_at = source.channel_subscription_checked_at
|
||||||
if not target.channel_subscription_verified_for and source.channel_subscription_verified_for:
|
if not target.channel_subscription_verified_for and source.channel_subscription_verified_for:
|
||||||
target.channel_subscription_verified_for = source.channel_subscription_verified_for
|
target.channel_subscription_verified_for = source.channel_subscription_verified_for
|
||||||
|
source_tg_status = str(getattr(source, "telegram_notifications_status", None) or "unknown")
|
||||||
|
target_tg_status = str(getattr(target, "telegram_notifications_status", None) or "unknown")
|
||||||
|
if source_tg_status == "enabled" and target_tg_status != "enabled":
|
||||||
|
target.telegram_notifications_status = source_tg_status
|
||||||
|
elif target_tg_status == "unknown" and source_tg_status != "unknown":
|
||||||
|
target.telegram_notifications_status = source_tg_status
|
||||||
|
if getattr(source, "telegram_notifications_checked_at", None) and (
|
||||||
|
not getattr(target, "telegram_notifications_checked_at", None)
|
||||||
|
or source.telegram_notifications_checked_at > target.telegram_notifications_checked_at
|
||||||
|
):
|
||||||
|
target.telegram_notifications_checked_at = source.telegram_notifications_checked_at
|
||||||
|
if getattr(source, "telegram_notifications_enabled_at", None) and (
|
||||||
|
not getattr(target, "telegram_notifications_enabled_at", None)
|
||||||
|
or source.telegram_notifications_enabled_at > target.telegram_notifications_enabled_at
|
||||||
|
):
|
||||||
|
target.telegram_notifications_enabled_at = source.telegram_notifications_enabled_at
|
||||||
|
if getattr(source, "telegram_notifications_blocked_at", None) and (
|
||||||
|
not getattr(target, "telegram_notifications_blocked_at", None)
|
||||||
|
or source.telegram_notifications_blocked_at > target.telegram_notifications_blocked_at
|
||||||
|
):
|
||||||
|
target.telegram_notifications_blocked_at = source.telegram_notifications_blocked_at
|
||||||
if source.lifetime_used_traffic_bytes is not None:
|
if source.lifetime_used_traffic_bytes is not None:
|
||||||
target.lifetime_used_traffic_bytes = (
|
target.lifetime_used_traffic_bytes = (
|
||||||
target.lifetime_used_traffic_bytes or 0
|
target.lifetime_used_traffic_bytes or 0
|
||||||
|
|||||||
@@ -1047,6 +1047,20 @@ def _migration_0031_add_subscription_notifications(connection: Connection) -> No
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _migration_0032_add_telegram_notification_status(connection: Connection) -> None:
|
||||||
|
inspector = inspect(connection)
|
||||||
|
columns: Set[str] = {col["name"] for col in inspector.get_columns("users")}
|
||||||
|
additions = {
|
||||||
|
"telegram_notifications_status": "VARCHAR(32) NOT NULL DEFAULT 'unknown'",
|
||||||
|
"telegram_notifications_checked_at": "TIMESTAMPTZ",
|
||||||
|
"telegram_notifications_enabled_at": "TIMESTAMPTZ",
|
||||||
|
"telegram_notifications_blocked_at": "TIMESTAMPTZ",
|
||||||
|
}
|
||||||
|
for column, ddl_type in additions.items():
|
||||||
|
if column not in columns:
|
||||||
|
connection.execute(text(f"ALTER TABLE users ADD COLUMN {column} {ddl_type}"))
|
||||||
|
|
||||||
|
|
||||||
MIGRATIONS: List[Migration] = [
|
MIGRATIONS: List[Migration] = [
|
||||||
Migration(
|
Migration(
|
||||||
id="0001_add_channel_subscription_fields",
|
id="0001_add_channel_subscription_fields",
|
||||||
@@ -1214,6 +1228,11 @@ MIGRATIONS: List[Migration] = [
|
|||||||
description="Track sent subscription notification stages",
|
description="Track sent subscription notification stages",
|
||||||
upgrade=_migration_0031_add_subscription_notifications,
|
upgrade=_migration_0031_add_subscription_notifications,
|
||||||
),
|
),
|
||||||
|
Migration(
|
||||||
|
id="0032_add_telegram_notification_status",
|
||||||
|
description="Track whether the bot can message Telegram-linked users",
|
||||||
|
upgrade=_migration_0032_add_telegram_notification_status,
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ class User(Base):
|
|||||||
password_set_at = Column(DateTime(timezone=True), nullable=True)
|
password_set_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
telegram_id = Column(BigInteger, nullable=True, unique=True, index=True)
|
telegram_id = Column(BigInteger, nullable=True, unique=True, index=True)
|
||||||
telegram_photo_url = Column(Text, nullable=True)
|
telegram_photo_url = Column(Text, nullable=True)
|
||||||
|
telegram_notifications_status = Column(String(32), nullable=False, default="unknown")
|
||||||
|
telegram_notifications_checked_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
telegram_notifications_enabled_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
telegram_notifications_blocked_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
first_name = Column(String, nullable=True)
|
first_name = Column(String, nullable=True)
|
||||||
last_name = Column(String, nullable=True)
|
last_name = Column(String, nullable=True)
|
||||||
language_code = Column(String, default="ru")
|
language_code = Column(String, default="ru")
|
||||||
|
|||||||
@@ -40,8 +40,10 @@
|
|||||||
{ "count": 1, "price": 2500 }
|
{ "count": 1, "price": 2500 }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"prices_rub": { "1": 150, "3": 400, "6": 750, "12": 1400 },
|
"prices_rub": { "1": 200, "3": 600, "6": 1200, "12": 2400 },
|
||||||
"prices_stars": { "1": 0, "3": 0, "6": 0, "12": 0 },
|
"prices_stars": { "1": 0, "3": 0, "6": 0, "12": 0 },
|
||||||
|
"referral_bonus_days_inviter": { "1": 3, "3": 7, "6": 15, "12": 30 },
|
||||||
|
"referral_bonus_days_referee": { "1": 1, "3": 3, "6": 7, "12": 15 },
|
||||||
"enabled_periods": [1, 3, 6, 12],
|
"enabled_periods": [1, 3, 6, 12],
|
||||||
"enabled": true
|
"enabled": true
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ LABEL org.opencontainers.image.source="https://github.com/3252a8/remnawave-minis
|
|||||||
|
|
||||||
COPY deploy/docker/frontend/nginx.conf /etc/nginx/conf.d/default.conf
|
COPY deploy/docker/frontend/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
COPY deploy/docker/frontend/00-startup-banner.sh /docker-entrypoint.d/00-startup-banner.sh
|
COPY deploy/docker/frontend/00-startup-banner.sh /docker-entrypoint.d/00-startup-banner.sh
|
||||||
|
COPY deploy/docker/frontend/robots.txt /usr/share/nginx/html/robots.txt
|
||||||
COPY backend/bot/app/web/templates/subscription_webapp.html /usr/share/nginx/html/index.html
|
COPY backend/bot/app/web/templates/subscription_webapp.html /usr/share/nginx/html/index.html
|
||||||
COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp.css /usr/share/nginx/html/subscription_webapp.css
|
COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp.css /usr/share/nginx/html/subscription_webapp.css
|
||||||
COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp.*.css /usr/share/nginx/html/
|
COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp.*.css /usr/share/nginx/html/
|
||||||
|
|||||||
@@ -23,6 +23,13 @@ server {
|
|||||||
return 200 "ok\n";
|
return 200 "ok\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location = /robots.txt {
|
||||||
|
expires 1h;
|
||||||
|
add_header Cache-Control "public, max-age=3600";
|
||||||
|
add_header X-Robots-Tag "noindex, nofollow, noarchive";
|
||||||
|
try_files /robots.txt =404;
|
||||||
|
}
|
||||||
|
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://backend:8081;
|
proxy_pass http://backend:8081;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
@@ -89,23 +96,27 @@ server {
|
|||||||
location ~* ^/subscription_webapp(_admin)?\.(min\.)?[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]\.(css|js)$ {
|
location ~* ^/subscription_webapp(_admin)?\.(min\.)?[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]\.(css|js)$ {
|
||||||
expires off;
|
expires off;
|
||||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||||
|
add_header X-Robots-Tag "noindex, nofollow, noarchive";
|
||||||
try_files $uri =404;
|
try_files $uri =404;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~* ^/subscription_webapp(_admin)?\.(css|js)$ {
|
location ~* ^/subscription_webapp(_admin)?\.(css|js)$ {
|
||||||
expires off;
|
expires off;
|
||||||
add_header Cache-Control "no-cache";
|
add_header Cache-Control "no-cache";
|
||||||
|
add_header X-Robots-Tag "noindex, nofollow, noarchive";
|
||||||
try_files $uri =404;
|
try_files $uri =404;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|webp)$ {
|
location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|webp)$ {
|
||||||
expires 30d;
|
expires 30d;
|
||||||
add_header Cache-Control "public";
|
add_header Cache-Control "public";
|
||||||
|
add_header X-Robots-Tag "noindex, nofollow, noarchive";
|
||||||
try_files $uri =404;
|
try_files $uri =404;
|
||||||
}
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
add_header Cache-Control "no-cache";
|
add_header Cache-Control "no-cache";
|
||||||
|
add_header X-Robots-Tag "noindex, nofollow, noarchive";
|
||||||
try_files $uri /index.html;
|
try_files $uri /index.html;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
User-agent: *
|
||||||
|
Disallow: /
|
||||||
|
|
||||||
|
User-agent: GPTBot
|
||||||
|
Disallow: /
|
||||||
|
|
||||||
|
User-agent: ChatGPT-User
|
||||||
|
Disallow: /
|
||||||
|
|
||||||
|
User-agent: OAI-SearchBot
|
||||||
|
Disallow: /
|
||||||
|
|
||||||
|
User-agent: Google-Extended
|
||||||
|
Disallow: /
|
||||||
|
|
||||||
|
User-agent: ClaudeBot
|
||||||
|
Disallow: /
|
||||||
|
|
||||||
|
User-agent: anthropic-ai
|
||||||
|
Disallow: /
|
||||||
|
|
||||||
|
User-agent: PerplexityBot
|
||||||
|
Disallow: /
|
||||||
|
|
||||||
|
User-agent: Applebot-Extended
|
||||||
|
Disallow: /
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
**/.env
|
**/.env
|
||||||
|
**/data/
|
||||||
nginx/ssl/*
|
nginx/ssl/*
|
||||||
!nginx/ssl/README.md
|
!nginx/ssl/README.md
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
|
|
||||||
Эта папка хранит только рабочие compose-примеры и конфиги. Подробное описание не дублируется здесь, чтобы сайт документации и навигация из README использовали один источник.
|
Эта папка хранит только рабочие compose-примеры и конфиги. Подробное описание не дублируется здесь, чтобы сайт документации и навигация из README использовали один источник.
|
||||||
|
|
||||||
|
Файлы приложения (`/app/data`: тарифы, темы, логотипы) монтируются из папки `data` рядом с выбранным `docker-compose.yml`. Для кастомных тем создайте `data/themes`.
|
||||||
|
|
||||||
| Папка | Документация |
|
| Папка | Документация |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `caddy` | [Развертывание с Caddy](../../docs/getting-started/deployment.md#caddy-рекомендуемый-вариант) |
|
| `caddy` | [Развертывание с Caddy](../../docs/getting-started/deployment.md#caddy-рекомендуемый-вариант) |
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
<<: *app_environment
|
<<: *app_environment
|
||||||
volumes:
|
volumes:
|
||||||
- shop-data:/app/data
|
- ./data:/app/data
|
||||||
networks:
|
networks:
|
||||||
- remnawave-shop
|
- remnawave-shop
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -66,7 +66,7 @@ services:
|
|||||||
WEBAPP_ENABLED: ${WEBAPP_ENABLED:-true}
|
WEBAPP_ENABLED: ${WEBAPP_ENABLED:-true}
|
||||||
TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.1,::1,172.16.0.0/12}
|
TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.1,::1,172.16.0.0/12}
|
||||||
volumes:
|
volumes:
|
||||||
- shop-data:/app/data
|
- ./data:/app/data
|
||||||
- ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:${COMPOSE_RESTORE_MODE:-rw}
|
- ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:${COMPOSE_RESTORE_MODE:-rw}
|
||||||
networks:
|
networks:
|
||||||
- remnawave-shop
|
- remnawave-shop
|
||||||
@@ -91,7 +91,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
<<: *app_environment
|
<<: *app_environment
|
||||||
volumes:
|
volumes:
|
||||||
- shop-data:/app/data
|
- ./data:/app/data
|
||||||
- ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:ro
|
- ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:ro
|
||||||
networks:
|
networks:
|
||||||
- remnawave-shop
|
- remnawave-shop
|
||||||
@@ -149,8 +149,6 @@ volumes:
|
|||||||
name: remnawave-minishop-db-data
|
name: remnawave-minishop-db-data
|
||||||
redis-data:
|
redis-data:
|
||||||
name: remnawave-minishop-redis-data
|
name: remnawave-minishop-redis-data
|
||||||
shop-data:
|
|
||||||
name: remnawave-minishop-shop-data
|
|
||||||
caddy-data:
|
caddy-data:
|
||||||
name: remnawave-minishop-caddy-data
|
name: remnawave-minishop-caddy-data
|
||||||
caddy-config:
|
caddy-config:
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
<<: *app_environment
|
<<: *app_environment
|
||||||
volumes:
|
volumes:
|
||||||
- shop-data:/app/data
|
- ./data:/app/data
|
||||||
networks:
|
networks:
|
||||||
- remnawave-shop
|
- remnawave-shop
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -66,7 +66,7 @@ services:
|
|||||||
WEBAPP_ENABLED: ${WEBAPP_ENABLED:-true}
|
WEBAPP_ENABLED: ${WEBAPP_ENABLED:-true}
|
||||||
TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.1,::1,172.16.0.0/12}
|
TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.1,::1,172.16.0.0/12}
|
||||||
volumes:
|
volumes:
|
||||||
- shop-data:/app/data
|
- ./data:/app/data
|
||||||
- ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:${COMPOSE_RESTORE_MODE:-rw}
|
- ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:${COMPOSE_RESTORE_MODE:-rw}
|
||||||
networks:
|
networks:
|
||||||
- remnawave-shop
|
- remnawave-shop
|
||||||
@@ -91,7 +91,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
<<: *app_environment
|
<<: *app_environment
|
||||||
volumes:
|
volumes:
|
||||||
- shop-data:/app/data
|
- ./data:/app/data
|
||||||
- ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:ro
|
- ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:ro
|
||||||
networks:
|
networks:
|
||||||
- remnawave-shop
|
- remnawave-shop
|
||||||
@@ -144,5 +144,3 @@ volumes:
|
|||||||
name: remnawave-minishop-db-data
|
name: remnawave-minishop-db-data
|
||||||
redis-data:
|
redis-data:
|
||||||
name: remnawave-minishop-redis-data
|
name: remnawave-minishop-redis-data
|
||||||
shop-data:
|
|
||||||
name: remnawave-minishop-shop-data
|
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
<<: *app_environment
|
<<: *app_environment
|
||||||
volumes:
|
volumes:
|
||||||
- shop-data:/app/data
|
- ./data:/app/data
|
||||||
networks:
|
networks:
|
||||||
- remnawave-shop
|
- remnawave-shop
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -66,7 +66,7 @@ services:
|
|||||||
WEBAPP_ENABLED: ${WEBAPP_ENABLED:-true}
|
WEBAPP_ENABLED: ${WEBAPP_ENABLED:-true}
|
||||||
TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.1,::1,172.16.0.0/12}
|
TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.1,::1,172.16.0.0/12}
|
||||||
volumes:
|
volumes:
|
||||||
- shop-data:/app/data
|
- ./data:/app/data
|
||||||
- ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:${COMPOSE_RESTORE_MODE:-rw}
|
- ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:${COMPOSE_RESTORE_MODE:-rw}
|
||||||
networks:
|
networks:
|
||||||
- remnawave-shop
|
- remnawave-shop
|
||||||
@@ -91,7 +91,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
<<: *app_environment
|
<<: *app_environment
|
||||||
volumes:
|
volumes:
|
||||||
- shop-data:/app/data
|
- ./data:/app/data
|
||||||
- ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:ro
|
- ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:ro
|
||||||
networks:
|
networks:
|
||||||
- remnawave-shop
|
- remnawave-shop
|
||||||
@@ -148,5 +148,3 @@ volumes:
|
|||||||
name: remnawave-minishop-db-data
|
name: remnawave-minishop-db-data
|
||||||
redis-data:
|
redis-data:
|
||||||
name: remnawave-minishop-redis-data
|
name: remnawave-minishop-redis-data
|
||||||
shop-data:
|
|
||||||
name: remnawave-minishop-shop-data
|
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
<<: *app_environment
|
<<: *app_environment
|
||||||
volumes:
|
volumes:
|
||||||
- shop-data:/app/data
|
- ./data:/app/data
|
||||||
networks:
|
networks:
|
||||||
- remnawave-shop
|
- remnawave-shop
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -68,7 +68,7 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "${WEB_SERVER_BIND:-0.0.0.0:8080}:8080"
|
- "${WEB_SERVER_BIND:-0.0.0.0:8080}:8080"
|
||||||
volumes:
|
volumes:
|
||||||
- shop-data:/app/data
|
- ./data:/app/data
|
||||||
- ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:${COMPOSE_RESTORE_MODE:-rw}
|
- ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:${COMPOSE_RESTORE_MODE:-rw}
|
||||||
networks:
|
networks:
|
||||||
- remnawave-shop
|
- remnawave-shop
|
||||||
@@ -93,7 +93,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
<<: *app_environment
|
<<: *app_environment
|
||||||
volumes:
|
volumes:
|
||||||
- shop-data:/app/data
|
- ./data:/app/data
|
||||||
- ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:ro
|
- ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:ro
|
||||||
networks:
|
networks:
|
||||||
- remnawave-shop
|
- remnawave-shop
|
||||||
@@ -132,5 +132,3 @@ volumes:
|
|||||||
name: remnawave-minishop-db-data
|
name: remnawave-minishop-db-data
|
||||||
redis-data:
|
redis-data:
|
||||||
name: remnawave-minishop-redis-data
|
name: remnawave-minishop-redis-data
|
||||||
shop-data:
|
|
||||||
name: remnawave-minishop-shop-data
|
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ export default defineConfig({
|
|||||||
{ label: 'Основные', slug: 'features/core' },
|
{ label: 'Основные', slug: 'features/core' },
|
||||||
{ label: 'Платежи', slug: 'features/payments' },
|
{ label: 'Платежи', slug: 'features/payments' },
|
||||||
{ label: 'Подписки', slug: 'features/subscriptions' },
|
{ label: 'Подписки', slug: 'features/subscriptions' },
|
||||||
|
{ label: 'Уведомления', slug: 'features/notifications' },
|
||||||
{ label: 'Тарифы', slug: 'features/tariffs' },
|
{ label: 'Тарифы', slug: 'features/tariffs' },
|
||||||
{ label: 'Веб-приложение / Mini App', slug: 'features/web-app' },
|
{ label: 'Веб-приложение / Mini App', slug: 'features/web-app' },
|
||||||
{ label: 'Telegram-авторизация', slug: 'features/telegram-auth' },
|
{ label: 'Telegram-авторизация', slug: 'features/telegram-auth' },
|
||||||
|
|||||||
+11
-22
@@ -1,22 +1,11 @@
|
|||||||
# Cloudflare Pages rewrites for the static docs demo SPA.
|
# Cloudflare Pages rewrites for non-materialized static docs demo runtime routes.
|
||||||
# Keep these scoped to app routes so runtime JS/CSS/assets are served directly.
|
# Use the directory entrypoint so Pages clean URLs do not loop between app.html and app.
|
||||||
/demo/runtime /demo/runtime/app.html 200
|
/demo/runtime/home/* /demo/runtime/app/ 200
|
||||||
/demo/runtime/ /demo/runtime/app.html 200
|
/demo/runtime/install/* /demo/runtime/app/ 200
|
||||||
/demo/runtime/home /demo/runtime/app.html 200
|
/demo/runtime/trial/* /demo/runtime/app/ 200
|
||||||
/demo/runtime/home/* /demo/runtime/app.html 200
|
/demo/runtime/invite/* /demo/runtime/app/ 200
|
||||||
/demo/runtime/install /demo/runtime/app.html 200
|
/demo/runtime/devices/* /demo/runtime/app/ 200
|
||||||
/demo/runtime/install/* /demo/runtime/app.html 200
|
/demo/runtime/support/* /demo/runtime/app/ 200
|
||||||
/demo/runtime/trial /demo/runtime/app.html 200
|
/demo/runtime/settings/* /demo/runtime/app/ 200
|
||||||
/demo/runtime/trial/* /demo/runtime/app.html 200
|
/demo/runtime/login/* /demo/runtime/app/ 200
|
||||||
/demo/runtime/invite /demo/runtime/app.html 200
|
/demo/runtime/admin/* /demo/runtime/app/ 200
|
||||||
/demo/runtime/invite/* /demo/runtime/app.html 200
|
|
||||||
/demo/runtime/devices /demo/runtime/app.html 200
|
|
||||||
/demo/runtime/devices/* /demo/runtime/app.html 200
|
|
||||||
/demo/runtime/support /demo/runtime/app.html 200
|
|
||||||
/demo/runtime/support/* /demo/runtime/app.html 200
|
|
||||||
/demo/runtime/settings /demo/runtime/app.html 200
|
|
||||||
/demo/runtime/settings/* /demo/runtime/app.html 200
|
|
||||||
/demo/runtime/login /demo/runtime/app.html 200
|
|
||||||
/demo/runtime/login/* /demo/runtime/app.html 200
|
|
||||||
/demo/runtime/admin /demo/runtime/app.html 200
|
|
||||||
/demo/runtime/admin/* /demo/runtime/app.html 200
|
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ const frame = document.getElementById("demo-frame");
|
|||||||
const runtimeBase = "/demo/runtime";
|
const runtimeBase = "/demo/runtime";
|
||||||
const demoBase = "/demo";
|
const demoBase = "/demo";
|
||||||
const defaultMock = "tariffs";
|
const defaultMock = "tariffs";
|
||||||
|
const publicRouteAliases = new Map([["/app", "/home"]]);
|
||||||
const stateMocks = new Set([
|
const stateMocks = new Set([
|
||||||
"tariffs",
|
"tariffs",
|
||||||
"depleted",
|
"depleted",
|
||||||
"no-subscription",
|
"no-subscription",
|
||||||
"trial",
|
"trial",
|
||||||
"devices",
|
"devices",
|
||||||
|
"notifications",
|
||||||
"auth",
|
"auth",
|
||||||
]);
|
]);
|
||||||
const routeMocks = new Set([...stateMocks, "guides", "install"]);
|
const routeMocks = new Set([...stateMocks, "guides", "install"]);
|
||||||
@@ -41,7 +43,8 @@ const routeFromPublicPath = () => {
|
|||||||
const publicRoute = pathname.slice(demoBase.length);
|
const publicRoute = pathname.slice(demoBase.length);
|
||||||
if (!publicRoute || publicRoute.toLowerCase().startsWith("/runtime"))
|
if (!publicRoute || publicRoute.toLowerCase().startsWith("/runtime"))
|
||||||
return "";
|
return "";
|
||||||
return normalizePath(publicRoute);
|
const normalized = normalizePath(publicRoute);
|
||||||
|
return publicRouteAliases.get(normalized.toLowerCase()) || normalized;
|
||||||
};
|
};
|
||||||
|
|
||||||
const routeFromParams = () => {
|
const routeFromParams = () => {
|
||||||
@@ -88,36 +91,6 @@ const mockForRoute = (route) => {
|
|||||||
const initialMock = params.has("mock")
|
const initialMock = params.has("mock")
|
||||||
? normalizeRouteMock(params.get("mock"))
|
? normalizeRouteMock(params.get("mock"))
|
||||||
: normalizeRouteMock(mockForRoute(initialRoute) || defaultMock);
|
: normalizeRouteMock(mockForRoute(initialRoute) || defaultMock);
|
||||||
if (initialMock === "trial" && initialRoute === "/trial") {
|
|
||||||
initialRoute = "/home";
|
|
||||||
const normalizedUrl = new URL(window.location.href);
|
|
||||||
normalizedUrl.pathname = `${demoBase}/home`;
|
|
||||||
window.history.replaceState(
|
|
||||||
null,
|
|
||||||
"",
|
|
||||||
`${normalizedUrl.pathname}${normalizedUrl.search}${normalizedUrl.hash}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
params.set("mock", initialMock);
|
|
||||||
params.delete("path");
|
|
||||||
params.delete("screen");
|
|
||||||
params.delete("admin_section");
|
|
||||||
params.set("path", initialRoute);
|
|
||||||
frame.src = `${runtimeBase}/app.html?${params.toString()}${window.location.hash || ""}`;
|
|
||||||
|
|
||||||
const routeFromRuntimeUrl = (url) => {
|
|
||||||
if (url.origin !== window.location.origin) return "";
|
|
||||||
if (!url.pathname.toLowerCase().startsWith(runtimeBase.toLowerCase()))
|
|
||||||
return "";
|
|
||||||
const runtimePath = normalizePath(
|
|
||||||
url.pathname.slice(runtimeBase.length) || "/home",
|
|
||||||
);
|
|
||||||
if (runtimePath === "/app.html") {
|
|
||||||
return normalizePath(url.searchParams.get("path") || "/home");
|
|
||||||
}
|
|
||||||
return runtimePath;
|
|
||||||
};
|
|
||||||
|
|
||||||
const materializedRouteFromRuntime = (route) => {
|
const materializedRouteFromRuntime = (route) => {
|
||||||
const normalized = normalizePath(route);
|
const normalized = normalizePath(route);
|
||||||
if (/^\/admin\/users\/-?\d+$/i.test(normalized)) return "/admin/users";
|
if (/^\/admin\/users\/-?\d+$/i.test(normalized)) return "/admin/users";
|
||||||
@@ -131,6 +104,55 @@ const materializedRouteFromRuntime = (route) => {
|
|||||||
|
|
||||||
const publicPathFromRoute = (route) =>
|
const publicPathFromRoute = (route) =>
|
||||||
`${demoBase}${materializedRouteFromRuntime(route)}`;
|
`${demoBase}${materializedRouteFromRuntime(route)}`;
|
||||||
|
const canonicalizeInitialPublicUrl = (route) => {
|
||||||
|
const pathname = window.location.pathname.replace(/\/+$/, "") || "/";
|
||||||
|
const lowerPathname = pathname.toLowerCase();
|
||||||
|
if (
|
||||||
|
lowerPathname !== demoBase &&
|
||||||
|
!publicRouteAliases.has(pathname.slice(demoBase.length).toLowerCase())
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const normalizedUrl = new URL(window.location.href);
|
||||||
|
normalizedUrl.pathname = publicPathFromRoute(route);
|
||||||
|
window.history.replaceState(
|
||||||
|
null,
|
||||||
|
"",
|
||||||
|
`${normalizedUrl.pathname}${normalizedUrl.search}${normalizedUrl.hash}`,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (initialMock === "trial" && initialRoute === "/trial") {
|
||||||
|
initialRoute = "/home";
|
||||||
|
const normalizedUrl = new URL(window.location.href);
|
||||||
|
normalizedUrl.pathname = publicPathFromRoute(initialRoute);
|
||||||
|
window.history.replaceState(
|
||||||
|
null,
|
||||||
|
"",
|
||||||
|
`${normalizedUrl.pathname}${normalizedUrl.search}${normalizedUrl.hash}`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
canonicalizeInitialPublicUrl(initialRoute);
|
||||||
|
}
|
||||||
|
params.set("mock", initialMock);
|
||||||
|
params.delete("path");
|
||||||
|
params.delete("screen");
|
||||||
|
params.delete("admin_section");
|
||||||
|
params.set("path", initialRoute);
|
||||||
|
frame.src = `${runtimeBase}/app/?${params.toString()}${window.location.hash || ""}`;
|
||||||
|
|
||||||
|
const routeFromRuntimeUrl = (url) => {
|
||||||
|
if (url.origin !== window.location.origin) return "";
|
||||||
|
if (!url.pathname.toLowerCase().startsWith(runtimeBase.toLowerCase()))
|
||||||
|
return "";
|
||||||
|
const runtimePath = normalizePath(
|
||||||
|
url.pathname.slice(runtimeBase.length) || "/home",
|
||||||
|
);
|
||||||
|
if (runtimePath === "/app" || runtimePath === "/app.html") {
|
||||||
|
return normalizePath(url.searchParams.get("path") || "/home");
|
||||||
|
}
|
||||||
|
return runtimePath;
|
||||||
|
};
|
||||||
const routeForStateMock = (mock) => {
|
const routeForStateMock = (mock) => {
|
||||||
if (mock === "devices") return "/devices";
|
if (mock === "devices") return "/devices";
|
||||||
if (mock === "auth") return "/login";
|
if (mock === "auth") return "/login";
|
||||||
@@ -141,7 +163,7 @@ const runtimeSrc = (route, searchParams = new URLSearchParams()) => {
|
|||||||
nextParams.delete("screen");
|
nextParams.delete("screen");
|
||||||
nextParams.delete("admin_section");
|
nextParams.delete("admin_section");
|
||||||
nextParams.set("path", normalizePath(route));
|
nextParams.set("path", normalizePath(route));
|
||||||
return `${runtimeBase}/app.html?${nextParams.toString()}${window.location.hash || ""}`;
|
return `${runtimeBase}/app/?${nextParams.toString()}${window.location.hash || ""}`;
|
||||||
};
|
};
|
||||||
const topbar = document.querySelector(".demo-topbar");
|
const topbar = document.querySelector(".demo-topbar");
|
||||||
const toggle = document.querySelector(".demo-topbar__toggle");
|
const toggle = document.querySelector(".demo-topbar__toggle");
|
||||||
|
|||||||
@@ -1,49 +1,16 @@
|
|||||||
import { copyFile, mkdir } from "node:fs/promises";
|
import { copyFile, mkdir } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
import {
|
||||||
|
demoPublicRoutes,
|
||||||
|
demoRuntimeRoutes,
|
||||||
|
} from "../src/lib/demoRoutes.mjs";
|
||||||
|
|
||||||
const siteRoot = path.resolve(fileURLToPath(new URL("..", import.meta.url)));
|
const siteRoot = path.resolve(fileURLToPath(new URL("..", import.meta.url)));
|
||||||
const distRoot = path.join(siteRoot, "dist");
|
const distRoot = path.join(siteRoot, "dist");
|
||||||
|
|
||||||
const userRoutes = [
|
const demoRoutes = demoPublicRoutes.map((route) => `demo/${route}`);
|
||||||
"home",
|
const runtimeRoutes = demoRuntimeRoutes.map((route) => `demo/runtime/${route}`);
|
||||||
"install",
|
|
||||||
"trial",
|
|
||||||
"invite",
|
|
||||||
"devices",
|
|
||||||
"support",
|
|
||||||
"settings",
|
|
||||||
"login",
|
|
||||||
"login/password",
|
|
||||||
];
|
|
||||||
|
|
||||||
const adminRoutes = [
|
|
||||||
"stats",
|
|
||||||
"users",
|
|
||||||
"payments",
|
|
||||||
"promos",
|
|
||||||
"ads",
|
|
||||||
"broadcast",
|
|
||||||
"logs",
|
|
||||||
"support",
|
|
||||||
"tariffs",
|
|
||||||
"appearance",
|
|
||||||
"translations",
|
|
||||||
"backups",
|
|
||||||
"settings",
|
|
||||||
];
|
|
||||||
|
|
||||||
const demoRoutes = [
|
|
||||||
...userRoutes.map((route) => `demo/${route}`),
|
|
||||||
"demo/admin",
|
|
||||||
...adminRoutes.map((route) => `demo/admin/${route}`),
|
|
||||||
];
|
|
||||||
|
|
||||||
const runtimeRoutes = [
|
|
||||||
...userRoutes.map((route) => `demo/runtime/${route}`),
|
|
||||||
"demo/runtime/admin",
|
|
||||||
...adminRoutes.map((route) => `demo/runtime/admin/${route}`),
|
|
||||||
];
|
|
||||||
|
|
||||||
async function copyHtml(source, route) {
|
async function copyHtml(source, route) {
|
||||||
const targetDir = path.join(distRoot, route);
|
const targetDir = path.join(distRoot, route);
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const descriptions = {
|
|||||||
'features/core.md': 'Пользовательские и админские сценарии Remnawave Minishop.',
|
'features/core.md': 'Пользовательские и админские сценарии Remnawave Minishop.',
|
||||||
'features/payments.md': 'Платежные провайдеры, кнопки оплаты и webhook-обработка.',
|
'features/payments.md': 'Платежные провайдеры, кнопки оплаты и webhook-обработка.',
|
||||||
'features/subscriptions.md': 'Тарифы на срок и по трафику, premium-сквады, HWID-устройства и жизненный цикл подписки.',
|
'features/subscriptions.md': 'Тарифы на срок и по трафику, premium-сквады, HWID-устройства и жизненный цикл подписки.',
|
||||||
|
'features/notifications.md': 'Каналы Telegram и email для пользовательских, админских и сервисных уведомлений Remnawave Minishop.',
|
||||||
'features/tariffs.md': 'Каталог тарифов, модели на срок/по трафику, premium-сквады и HWID-устройства.',
|
'features/tariffs.md': 'Каталог тарифов, модели на срок/по трафику, premium-сквады и HWID-устройства.',
|
||||||
'features/web-app.md': 'Telegram Mini App, публичные инструкции, проксирование и реферальные ссылки.',
|
'features/web-app.md': 'Telegram Mini App, публичные инструкции, проксирование и реферальные ссылки.',
|
||||||
'features/telegram-auth.md': 'Telegram Mini Apps initData, Telegram OAuth, BotFather и настройка входа через Telegram.',
|
'features/telegram-auth.md': 'Telegram Mini Apps initData, Telegram OAuth, BotFather и настройка входа через Telegram.',
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
export const demoUserRoutes = [
|
||||||
|
"home",
|
||||||
|
"install",
|
||||||
|
"trial",
|
||||||
|
"invite",
|
||||||
|
"devices",
|
||||||
|
"support",
|
||||||
|
"settings",
|
||||||
|
"login",
|
||||||
|
"login/password",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const demoAdminRoutes = [
|
||||||
|
"stats",
|
||||||
|
"users",
|
||||||
|
"payments",
|
||||||
|
"promos",
|
||||||
|
"ads",
|
||||||
|
"broadcast",
|
||||||
|
"logs",
|
||||||
|
"support",
|
||||||
|
"tariffs",
|
||||||
|
"appearance",
|
||||||
|
"translations",
|
||||||
|
"backups",
|
||||||
|
"settings",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const demoPublicRouteAliases = ["app"];
|
||||||
|
|
||||||
|
export const demoPublicRoutes = [
|
||||||
|
...demoPublicRouteAliases,
|
||||||
|
...demoUserRoutes,
|
||||||
|
"admin",
|
||||||
|
...demoAdminRoutes.map((route) => `admin/${route}`),
|
||||||
|
];
|
||||||
|
|
||||||
|
export const demoRuntimeRoutes = [
|
||||||
|
...demoUserRoutes,
|
||||||
|
"admin",
|
||||||
|
...demoAdminRoutes.map((route) => `admin/${route}`),
|
||||||
|
];
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
const defaultDemoSrc = '/demo/runtime/app.html?path=/home&mock=tariffs';
|
const defaultDemoSrc = '/demo/runtime/app/?path=/home&mock=tariffs';
|
||||||
const docsHref = '/getting-started/demo/';
|
const docsHref = '/getting-started/demo/';
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -294,6 +294,7 @@ const docsHref = '/getting-started/demo/';
|
|||||||
<option value="no-subscription">Нет подписки</option>
|
<option value="no-subscription">Нет подписки</option>
|
||||||
<option value="trial">Доступна пробная подписка</option>
|
<option value="trial">Доступна пробная подписка</option>
|
||||||
<option value="devices">Лимит и докупка устройств</option>
|
<option value="devices">Лимит и докупка устройств</option>
|
||||||
|
<option value="notifications">Telegram-уведомления</option>
|
||||||
<option value="auth">Вход и регистрация</option>
|
<option value="auth">Вход и регистрация</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -1,39 +1,9 @@
|
|||||||
---
|
---
|
||||||
import DemoShell from '../demo.astro';
|
import DemoShell from '../demo.astro';
|
||||||
|
import { demoPublicRoutes } from '../../lib/demoRoutes.mjs';
|
||||||
|
|
||||||
export function getStaticPaths() {
|
export function getStaticPaths() {
|
||||||
const userRoutes = [
|
return demoPublicRoutes.map((path) => ({ params: { path } }));
|
||||||
'home',
|
|
||||||
'install',
|
|
||||||
'trial',
|
|
||||||
'invite',
|
|
||||||
'devices',
|
|
||||||
'support',
|
|
||||||
'settings',
|
|
||||||
'login',
|
|
||||||
'login/password',
|
|
||||||
];
|
|
||||||
const adminRoutes = [
|
|
||||||
'stats',
|
|
||||||
'users',
|
|
||||||
'payments',
|
|
||||||
'promos',
|
|
||||||
'ads',
|
|
||||||
'broadcast',
|
|
||||||
'logs',
|
|
||||||
'support',
|
|
||||||
'tariffs',
|
|
||||||
'appearance',
|
|
||||||
'translations',
|
|
||||||
'backups',
|
|
||||||
'settings',
|
|
||||||
];
|
|
||||||
|
|
||||||
return [
|
|
||||||
...userRoutes.map((path) => ({ params: { path } })),
|
|
||||||
{ params: { path: 'admin' } },
|
|
||||||
...adminRoutes.map((section) => ({ params: { path: `admin/${section}` } })),
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const runtimeAppHtml = path.join(process.cwd(), "public", "demo", "runtime", "app.html");
|
||||||
|
|
||||||
|
export const prerender = true;
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return new Response(await readFile(runtimeAppHtml, "utf8"), {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/html; charset=utf-8",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -117,13 +117,15 @@
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `PANEL_API_URL` | URL API панели, например `https://panel.example.com/api`. |
|
| `PANEL_API_URL` | URL API панели, например `https://panel.example.com/api`. |
|
||||||
| `PANEL_API_KEY` | API-ключ панели. |
|
| `PANEL_API_KEY` | API-ключ панели. |
|
||||||
| `PANEL_WEBHOOK_SECRET` | Секрет проверки Remnawave webhook. |
|
| `PANEL_WEBHOOK_SECRET` | Секрет проверки Remnawave webhook. Задайте его в Remnawave Panel и вставьте то же значение сюда или в админку. |
|
||||||
| `USER_SQUAD_UUIDS` | Internal Squads по умолчанию для legacy-режима без JSON-каталога. |
|
| `USER_SQUAD_UUIDS` | Internal Squads по умолчанию для legacy-режима без JSON-каталога. |
|
||||||
| `USER_EXTERNAL_SQUAD_UUID` | Необязательный External Squad. |
|
| `USER_EXTERNAL_SQUAD_UUID` | Необязательный External Squad. |
|
||||||
| `USER_TRAFFIC_LIMIT_GB` | Legacy-лимит трафика пользователя. |
|
| `USER_TRAFFIC_LIMIT_GB` | Legacy-лимит трафика пользователя. |
|
||||||
| `USER_TRAFFIC_STRATEGY` | Legacy-стратегия лимита трафика. |
|
| `USER_TRAFFIC_STRATEGY` | Legacy-стратегия лимита трафика. |
|
||||||
| `USER_HWID_DEVICE_LIMIT` | Legacy-лимит HWID-устройств по умолчанию. |
|
| `USER_HWID_DEVICE_LIMIT` | Legacy-лимит HWID-устройств по умолчанию. |
|
||||||
|
|
||||||
|
В Remnawave Panel поле `WEBHOOK_URL` должно указывать на публичный Minishop webhook: `WEBHOOK_BASE_URL` + `/webhook/panel`. Если публичный домен приложения `https://app.example.com`, итоговый адрес будет `https://app.example.com/webhook/panel`.
|
||||||
|
|
||||||
## Веб-приложение, внешний вид и Telegram Login
|
## Веб-приложение, внешний вид и Telegram Login
|
||||||
|
|
||||||
Часть внешнего вида (`WEBAPP_PRIMARY_COLOR`, `WEBAPP_LOGO_*`, `WEBAPP_FAVICON_*`) сохранена для совместимости, но env-значения этих полей игнорируются при загрузке. Настраивайте их в **Админка -> Внешний вид**.
|
Часть внешнего вида (`WEBAPP_PRIMARY_COLOR`, `WEBAPP_LOGO_*`, `WEBAPP_FAVICON_*`) сохранена для совместимости, но env-значения этих полей игнорируются при загрузке. Настраивайте их в **Админка -> Внешний вид**.
|
||||||
@@ -355,6 +357,8 @@ PAYMENT_HELEKET_TELEGRAM_EMOJI
|
|||||||
|
|
||||||
Рекомендуемый способ настройки тарифов - раздел **Система -> Тарифы** в админке. Он сохраняет JSON в `TARIFFS_CONFIG_PATH`.
|
Рекомендуемый способ настройки тарифов - раздел **Система -> Тарифы** в админке. Он сохраняет JSON в `TARIFFS_CONFIG_PATH`.
|
||||||
|
|
||||||
|
Если JSON-каталог существует и проходит валидацию, цены, периоды и реферальные бонусы period-тарифов берутся из JSON. Legacy-переменные ниже используются только без JSON-каталога.
|
||||||
|
|
||||||
| Переменная | Назначение |
|
| Переменная | Назначение |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `TARIFFS_CONFIG_PATH` | Путь к JSON-каталогу тарифов. |
|
| `TARIFFS_CONFIG_PATH` | Путь к JSON-каталогу тарифов. |
|
||||||
@@ -363,7 +367,7 @@ PAYMENT_HELEKET_TELEGRAM_EMOJI
|
|||||||
| `3_MONTHS_ENABLED` | Legacy-доступность периода 3 месяца без JSON-каталога. |
|
| `3_MONTHS_ENABLED` | Legacy-доступность периода 3 месяца без JSON-каталога. |
|
||||||
| `6_MONTHS_ENABLED` | Legacy-доступность периода 6 месяцев без JSON-каталога. |
|
| `6_MONTHS_ENABLED` | Legacy-доступность периода 6 месяцев без JSON-каталога. |
|
||||||
| `12_MONTHS_ENABLED` | Legacy-доступность периода 12 месяцев без JSON-каталога. |
|
| `12_MONTHS_ENABLED` | Legacy-доступность периода 12 месяцев без JSON-каталога. |
|
||||||
| `RUB_PRICE_1_MONTH`, `RUB_PRICE_3_MONTHS`, `RUB_PRICE_6_MONTHS`, `RUB_PRICE_12_MONTHS` | Legacy-цены RUB. |
|
| `RUB_PRICE_1_MONTH`, `RUB_PRICE_3_MONTHS`, `RUB_PRICE_6_MONTHS`, `RUB_PRICE_12_MONTHS` | Legacy-цены RUB. Дефолты: `200`, `600`, `1200`, `2400`. |
|
||||||
| `STARS_PRICE_1_MONTH`, `STARS_PRICE_3_MONTHS`, `STARS_PRICE_6_MONTHS`, `STARS_PRICE_12_MONTHS` | Legacy-цены Stars. |
|
| `STARS_PRICE_1_MONTH`, `STARS_PRICE_3_MONTHS`, `STARS_PRICE_6_MONTHS`, `STARS_PRICE_12_MONTHS` | Legacy-цены Stars. |
|
||||||
| `TRAFFIC_PACKAGES` | Legacy-пакеты трафика RUB, формат `10:199,50:799`. |
|
| `TRAFFIC_PACKAGES` | Legacy-пакеты трафика RUB, формат `10:199,50:799`. |
|
||||||
| `STARS_TRAFFIC_PACKAGES` | Legacy-пакеты трафика Stars. |
|
| `STARS_TRAFFIC_PACKAGES` | Legacy-пакеты трафика Stars. |
|
||||||
@@ -382,9 +386,10 @@ PAYMENT_HELEKET_TELEGRAM_EMOJI
|
|||||||
| `REFERRAL_ONE_BONUS_PER_REFEREE` | Ограничить бонусы одним успешным платежом приглашенного. |
|
| `REFERRAL_ONE_BONUS_PER_REFEREE` | Ограничить бонусы одним успешным платежом приглашенного. |
|
||||||
| `REFERRAL_WELCOME_BONUS_DAYS` | Приветственный бонус пришедшему по реферальной ссылке. |
|
| `REFERRAL_WELCOME_BONUS_DAYS` | Приветственный бонус пришедшему по реферальной ссылке. |
|
||||||
| `LEGACY_REFS` | Разрешить ссылки `ref_<telegram_id>`. |
|
| `LEGACY_REFS` | Разрешить ссылки `ref_<telegram_id>`. |
|
||||||
| `REFERRAL_BONUS_DAYS_1_MONTH`, `REFERRAL_BONUS_DAYS_3_MONTHS`, `REFERRAL_BONUS_DAYS_6_MONTHS`, `REFERRAL_BONUS_DAYS_12_MONTHS` | Legacy-бонусы пригласившему. |
|
| `REFERRAL_BONUS_DAYS_1_MONTH`, `REFERRAL_BONUS_DAYS_3_MONTHS`, `REFERRAL_BONUS_DAYS_6_MONTHS`, `REFERRAL_BONUS_DAYS_12_MONTHS` | Legacy-бонусы пригласившему без JSON-каталога. В JSON-тарифах используйте `referral_bonus_days_inviter`. |
|
||||||
| `REFEREE_BONUS_DAYS_1_MONTH`, `REFEREE_BONUS_DAYS_3_MONTHS`, `REFEREE_BONUS_DAYS_6_MONTHS`, `REFEREE_BONUS_DAYS_12_MONTHS` | Legacy-бонусы приглашенному. |
|
| `REFEREE_BONUS_DAYS_1_MONTH`, `REFEREE_BONUS_DAYS_3_MONTHS`, `REFEREE_BONUS_DAYS_6_MONTHS`, `REFEREE_BONUS_DAYS_12_MONTHS` | Legacy-бонусы приглашенному без JSON-каталога. В JSON-тарифах используйте `referral_bonus_days_referee`. |
|
||||||
| `SUBSCRIPTION_NOTIFICATIONS_ENABLED` | Включает напоминания о подписке. |
|
| `SUBSCRIPTION_NOTIFICATIONS_ENABLED` | Включает напоминания о подписке. |
|
||||||
|
| `SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED` | Дублирует пользовательские уведомления жизненного цикла подписки на email, если SMTP настроен и у пользователя есть email. |
|
||||||
| `SUBSCRIPTION_NOTIFY_ON_EXPIRE` | Уведомлять в день окончания. |
|
| `SUBSCRIPTION_NOTIFY_ON_EXPIRE` | Уведомлять в день окончания. |
|
||||||
| `SUBSCRIPTION_NOTIFY_AFTER_EXPIRE` | Уведомлять после окончания. |
|
| `SUBSCRIPTION_NOTIFY_AFTER_EXPIRE` | Уведомлять после окончания. |
|
||||||
| `SUBSCRIPTION_NOTIFY_DAYS_BEFORE` | За сколько дней предупреждать. |
|
| `SUBSCRIPTION_NOTIFY_DAYS_BEFORE` | За сколько дней предупреждать. |
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
- `WEBAPP_SESSION_SECRET` должен быть постоянным между рестартами, иначе Web App-сессии станут невалидными.
|
- `WEBAPP_SESSION_SECRET` должен быть постоянным между рестартами, иначе Web App-сессии станут невалидными.
|
||||||
- `WEBHOOK_SECRET_TOKEN` защищает вебхук Telegram.
|
- `WEBHOOK_SECRET_TOKEN` защищает вебхук Telegram.
|
||||||
- `PANEL_WEBHOOK_SECRET` проверяет входящие события Remnawave Panel.
|
- `PANEL_WEBHOOK_SECRET` проверяет входящие события Remnawave Panel. Секрет задается в Remnawave Panel и вставляется в настройки бота.
|
||||||
- Платежные токены и webhook-секреты храните в `.env` или настройках админки с учетом доступа к серверу.
|
- Платежные токены и webhook-секреты храните в `.env` или настройках админки с учетом доступа к серверу.
|
||||||
|
|
||||||
Сгенерировать секрет можно так:
|
Сгенерировать секрет можно так:
|
||||||
@@ -24,6 +24,7 @@ openssl rand -hex 32
|
|||||||
## Публичные URL
|
## Публичные URL
|
||||||
|
|
||||||
- `WEBHOOK_BASE_URL` должен вести на backend-сервер вебхуков.
|
- `WEBHOOK_BASE_URL` должен вести на backend-сервер вебхуков.
|
||||||
|
- В Remnawave Panel `WEBHOOK_URL` должен быть `WEBHOOK_BASE_URL` + `/webhook/panel`, например `https://app.example.com/webhook/panel`.
|
||||||
- `SUBSCRIPTION_MINI_APP_URL` должен вести на frontend/Mini App-домен.
|
- `SUBSCRIPTION_MINI_APP_URL` должен вести на frontend/Mini App-домен.
|
||||||
- Не добавляйте `/api`, `/auth` или webhook-пути в `SUBSCRIPTION_MINI_APP_URL`.
|
- Не добавляйте `/api`, `/auth` или webhook-пути в `SUBSCRIPTION_MINI_APP_URL`.
|
||||||
|
|
||||||
|
|||||||
@@ -51,9 +51,9 @@
|
|||||||
- общие параметры: язык, валюта, ссылки поддержки, документы, обязательный канал, Remnawave-доступы и поведение `/start`;
|
- общие параметры: язык, валюта, ссылки поддержки, документы, обязательный канал, Remnawave-доступы и поведение `/start`;
|
||||||
- внешний вид и доступность Web App: название, цвет, логотип, emoji-логотип и `WEBAPP_ENABLED`;
|
- внешний вид и доступность Web App: название, цвет, логотип, emoji-логотип и `WEBAPP_ENABLED`;
|
||||||
- инструкции подключения: `SUBSCRIPTION_GUIDES_ENABLED`, `SUBSCRIPTION_GUIDES_BOT_MENU_ENABLED`, чтение конфига из Remnawave Panel, JSON-переопределение и резервный путь к файлу;
|
- инструкции подключения: `SUBSCRIPTION_GUIDES_ENABLED`, `SUBSCRIPTION_GUIDES_BOT_MENU_ENABLED`, чтение конфига из Remnawave Panel, JSON-переопределение и резервный путь к файлу;
|
||||||
- legacy-цены без JSON-каталога: периоды подписки, RUB/Stars цены и пакеты трафика;
|
- legacy-тарифы без JSON-каталога: периоды подписки, RUB/Stars цены, реферальные бонусы и пакеты трафика;
|
||||||
- платежные провайдеры: включение методов, порядок кнопок, публичные параметры и секреты YooKassa, FreeKassa, Platega, SeverPay, Wata, CryptoPay, Heleket и Stars, а также текст и иконки кнопок оплаты;
|
- платежные провайдеры: включение методов, порядок кнопок, публичные параметры и секреты YooKassa, FreeKassa, Platega, SeverPay, Wata, CryptoPay, Heleket и Stars, а также текст и иконки кнопок оплаты;
|
||||||
- пробный период, реферальные бонусы, уведомления, логирование, поддержка, раздел устройств, лимит устройств и legacy-лимиты трафика.
|
- пробный период, приветственный реферальный бонус, уведомления, логирование, поддержка, раздел устройств, лимит устройств и legacy-лимиты трафика.
|
||||||
|
|
||||||
Секретные поля помечены как secret и не должны использоваться для произвольного просмотра старых значений. Настройки, которых нет в manifest, остаются только в `.env` или коде.
|
Секретные поля помечены как secret и не должны использоваться для произвольного просмотра старых значений. Настройки, которых нет в manifest, остаются только в `.env` или коде.
|
||||||
|
|
||||||
@@ -117,7 +117,7 @@
|
|||||||
Редактор тарифа разделен на вкладки:
|
Редактор тарифа разделен на вкладки:
|
||||||
|
|
||||||
- **Основное**: ключ, модель `period`/`traffic`, видимость, названия и описания RU/EN, базовые Internal Squads, HWID-лимит, месячный лимит или курс конвертации;
|
- **Основное**: ключ, модель `period`/`traffic`, видимость, названия и описания RU/EN, базовые Internal Squads, HWID-лимит, месячный лимит или курс конвертации;
|
||||||
- **Цены**: периоды и цены для `period`, пакеты GB и цены для `traffic`;
|
- **Цены**: периоды, цены и реферальные бонусы для `period`, пакеты GB и цены для `traffic`;
|
||||||
- **Докупки**: обычные пакеты докупки трафика для `period`; для `traffic` отдельные докупки не нужны, пользователь повторно покупает пакеты из `traffic_packages`;
|
- **Докупки**: обычные пакеты докупки трафика для `period`; для `traffic` отдельные докупки не нужны, пользователь повторно покупает пакеты из `traffic_packages`;
|
||||||
- **Premium**: названия premium-раздела RU/EN, premium Internal Squads, месячный premium-лимит и RUB/Stars пакеты premium-докупки;
|
- **Premium**: названия premium-раздела RU/EN, premium Internal Squads, месячный premium-лимит и RUB/Stars пакеты premium-докупки;
|
||||||
- **Устройства**: RUB/Stars пакеты докупки HWID-устройств.
|
- **Устройства**: RUB/Stars пакеты докупки HWID-устройств.
|
||||||
|
|||||||
@@ -105,4 +105,4 @@ docker compose logs -f backend
|
|||||||
- Код сразу устаревает: проверьте `EMAIL_CODE_TTL_SECONDS` и время на сервере.
|
- Код сразу устаревает: проверьте `EMAIL_CODE_TTL_SECONDS` и время на сервере.
|
||||||
- Пользователь получает `rate_limited`: подождите `EMAIL_CODE_RESEND_SECONDS` или проверьте brute-force настройки.
|
- Пользователь получает `rate_limited`: подождите `EMAIL_CODE_RESEND_SECONDS` или проверьте brute-force настройки.
|
||||||
|
|
||||||
Email-уведомления поддержки и платежей используют тот же SMTP-контур. Сценарий поддержки описан в [разделе тикетов](support.md).
|
Email-уведомления поддержки, платежей и жизненного цикла подписки используют тот же SMTP-контур. Сценарий поддержки описан в [разделе тикетов](support.md), сводка по каналам - в разделе [уведомления](notifications.md).
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# Уведомления
|
||||||
|
|
||||||
|
Minishop отправляет уведомления в Telegram и на email. Telegram-канал означает личные сообщения пользователю, сообщения администраторам из `ADMIN_IDS` или сообщения в `LOG_CHAT_ID` - зависит от события. Email работает только при настроенном SMTP и наличии email у получателя.
|
||||||
|
|
||||||
|
Для пользовательских строк отметка `Telegram = ✓` означает, что событие умеет отправляться в Telegram пользователю с привязанным Telegram-аккаунтом. Email-only пользователь такое Telegram-сообщение не получит.
|
||||||
|
|
||||||
|
Для уведомлений жизненного цикла подписки есть отдельный флаг `SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED`. Если он включен, пользовательские уведомления об окончании подписки отправляются в Telegram при наличии привязанного Telegram-аккаунта и на email при наличии привязанной почты.
|
||||||
|
|
||||||
|
## Сводная таблица
|
||||||
|
|
||||||
|
| Событие | Получатель | Telegram | Email | Условия и ограничения |
|
||||||
|
| --- | --- | :---: | :---: | --- |
|
||||||
|
| Код входа по email и magic link | Пользователь | - | ✓ | Нужны `SMTP_*` и `EMAIL_*` настройки. Magic link также требует корректный `SUBSCRIPTION_MINI_APP_URL`. |
|
||||||
|
| Код установки или смены пароля | Пользователь | - | ✓ | Нужны `SMTP_*` и `EMAIL_*` настройки. |
|
||||||
|
| Успешная оплата подписки | Пользователь | ✓ | ✓ | Для `subscription`; email отправляется, если SMTP настроен и у пользователя есть email. |
|
||||||
|
| Успешная покупка отдельного пакета трафика | Пользователь | ✓ | ✓ | Для `traffic` / `traffic_package`; email отправляется, если SMTP настроен и у пользователя есть email. |
|
||||||
|
| Успешная докупка обычного трафика к тарифу | Пользователь | ✓ | ✓ | Для `topup`; email отправляется, если SMTP настроен и у пользователя есть email. |
|
||||||
|
| Успешная покупка premium-трафика | Пользователь | ✓ | ✓ | Для `premium_topup`; email отправляется, если SMTP настроен и у пользователя есть email. |
|
||||||
|
| Успешная покупка HWID-устройств | Пользователь | ✓ | ✓ | Отправляется после оплаты `hwid_devices` или `hwid_devices_renewal`; email отправляется, если SMTP настроен и у пользователя есть email. |
|
||||||
|
| Платное повышение тарифа | Пользователь | ✓ | ✓ | Для `tariff_upgrade`; email отправляется, если SMTP настроен и у пользователя есть email. |
|
||||||
|
| Способ оплаты YooKassa привязан | Пользователь | ✓ | ✓ | Отправляется после успешного сохранения платежного метода через webhook YooKassa; email отправляется, если SMTP настроен и у пользователя есть email. |
|
||||||
|
| Ошибка оплаты по webhook провайдера | Пользователь | ✓ | ✓ | Отправляется, когда платежный провайдер сообщает о неуспешном платеже; email отправляется, если SMTP настроен и у пользователя есть email. |
|
||||||
|
| Реферальный бонус приглашателю: активная подписка продлена | Пользователь-пригласивший | ✓ | ✓ | Отправляется после оплаты приглашенным пользователем, если бонус продлил активную подписку приглашателя. |
|
||||||
|
| Реферальный бонус приглашателю: создана бонусная подписка | Пользователь-пригласивший | ✓ | ✓ | Отправляется после оплаты приглашенным пользователем, если у приглашателя не было активной подписки для продления. |
|
||||||
|
| Напоминание за 3 дня до окончания подписки | Пользователь | ✓ | ✓ | Работает, если `SUBSCRIPTION_NOTIFY_DAYS_BEFORE >= 3`; email требует `SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED=True`. |
|
||||||
|
| Напоминание за 2 дня до окончания подписки | Пользователь | ✓ | ✓ | Работает, если `SUBSCRIPTION_NOTIFY_DAYS_BEFORE >= 2`; email требует `SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED=True`. |
|
||||||
|
| Предупреждение о списании автопродления за 48 часов | Пользователь | ✓ | ✓ | Отправляется вместо обычного 48-часового напоминания для YooKassa-подписок с включенным автопродлением; email требует `SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED=True`. |
|
||||||
|
| Напоминание за 1 день до окончания подписки | Пользователь | ✓ | ✓ | Работает, если `SUBSCRIPTION_NOTIFY_DAYS_BEFORE >= 1`; email требует `SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED=True`. Для YooKassa auto-renew 24-часовой webhook сначала пытается списать продление и может не отправить напоминание. |
|
||||||
|
| Напоминание за несколько часов до окончания подписки | Пользователь | ✓ | ✓ | Отправляется локальным worker, если `SUBSCRIPTION_NOTIFY_HOURS_BEFORE` задан от 1 до 23; email требует `SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED=True`. |
|
||||||
|
| Уведомление в день окончания подписки | Пользователь | ✓ | ✓ | Управляется `SUBSCRIPTION_NOTIFY_ON_EXPIRE`; email требует `SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED=True`. |
|
||||||
|
| Уведомление через сутки после окончания подписки | Пользователь | ✓ | ✓ | Управляется `SUBSCRIPTION_NOTIFY_AFTER_EXPIRE`; email требует `SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED=True`. |
|
||||||
|
| Исчерпан трафик пробного периода | Пользователь | ✓ | ✓ | Отправляется локальным worker для trial-подписок; email отправляется, если SMTP настроен и у пользователя есть email. |
|
||||||
|
| Обычный трафик тарифа почти исчерпан | Пользователь | ✓ | ✓ | Отправляется `TariffTrafficWorker` по уровням из `TARIFF_TRAFFIC_WARNING_LEVELS`; email отправляется, если SMTP настроен и у пользователя есть email. |
|
||||||
|
| Обычный трафик тарифа исчерпан | Пользователь | ✓ | ✓ | Отправляется `TariffTrafficWorker`, когда регулярный лимит трафика достигнут; email отправляется, если SMTP настроен и у пользователя есть email. |
|
||||||
|
| Premium-трафик тарифа почти исчерпан | Пользователь | ✓ | ✓ | Отправляется `TariffTrafficWorker` по уровням из `TARIFF_TRAFFIC_WARNING_LEVELS` для premium-квоты; email отправляется, если SMTP настроен и у пользователя есть email. |
|
||||||
|
| Premium-трафик тарифа исчерпан | Пользователь | ✓ | ✓ | Отправляется `TariffTrafficWorker`, когда premium-квота достигнута; email отправляется, если SMTP настроен и у пользователя есть email. |
|
||||||
|
| Ответ администратора в тикете поддержки | Пользователь | ✓ | ✓ | Telegram отправляется пользователям с Telegram-аккаунтом, email - пользователям с привязанным email. |
|
||||||
|
| Закрытие тикета поддержки | Пользователь | ✓ | ✓ | Telegram отправляется пользователям с Telegram-аккаунтом, email - пользователям с привязанным email. |
|
||||||
|
| Слияние email- и Telegram-аккаунтов | Пользователь | - | ✓ | Email отправляется на итоговый email при merge из Web App-формы привязки; Telegram OAuth-link merge логируется для админов без пользовательского email-письма. |
|
||||||
|
| Рассылка администратора пользователям | Пользователи с Telegram | ✓ | - | Отправляется через очередь из Telegram-админки или Web Admin выбранной аудитории: `all`, `active`, `inactive`. |
|
||||||
|
| Прямое сообщение администратора пользователю | Пользователь с Telegram | ✓ | - | Отправляется из карточки пользователя в Telegram-админке или Web Admin; email-канала нет. |
|
||||||
|
| Новый тикет поддержки | Администраторы | ✓ | ✓ | Telegram/log уведомления зависят от `LOG_SUPPORT`; email включается через `SUPPORT_ADMIN_EMAIL_NOTIFICATIONS_ENABLED`. |
|
||||||
|
| Ответ пользователя в тикете поддержки | Администраторы | ✓ | ✓ | Telegram/log уведомления зависят от `LOG_SUPPORT`; email включается через `SUPPORT_ADMIN_EMAIL_NOTIFICATIONS_ENABLED`. |
|
||||||
|
| Новая регистрация через Telegram | Администраторы / лог-чат | ✓ | - | Управляется `LOG_NEW_USERS`. |
|
||||||
|
| Новая регистрация по email | Администраторы / лог-чат | ✓ | - | Управляется `LOG_NEW_USERS`. |
|
||||||
|
| Email привязан к Telegram-аккаунту | Администраторы / лог-чат | ✓ | - | Управляется `LOG_NEW_USERS`. |
|
||||||
|
| Telegram привязан к email-аккаунту | Администраторы / лог-чат | ✓ | - | Управляется `LOG_NEW_USERS`. |
|
||||||
|
| Email- и Telegram-аккаунты объединены | Администраторы / лог-чат | ✓ | - | Управляется `LOG_NEW_USERS`. |
|
||||||
|
| Успешная оплата | Администраторы / лог-чат | ✓ | - | Управляется `LOG_PAYMENTS`. |
|
||||||
|
| Активация trial | Администраторы / лог-чат | ✓ | - | Управляется `LOG_TRIAL_ACTIVATIONS`. |
|
||||||
|
| Активация промокода | Администраторы / лог-чат | ✓ | - | Управляется `LOG_PROMO_ACTIVATIONS`. |
|
||||||
|
| Подозрительная попытка промокода | Администраторы / лог-чат | ✓ | - | Управляется `LOG_SUSPICIOUS_ACTIVITY`. |
|
||||||
|
| Результат ручной синхронизации с Remnawave Panel | Администратор, запустивший синхронизацию | ✓ | - | Worker отправляет короткий результат в `target_chat_id`, если синхронизация запускалась через очередь. |
|
||||||
|
| Ошибка создания пользователя в Remnawave Panel | Администраторы | ✓ | - | Отправляется администраторам из `ADMIN_IDS`. |
|
||||||
|
| Архив бэкапа создан | Администраторы / backup-чат | ✓ | - | Архив отправляется в `BACKUP_CHAT_ID`, а если он пустой - в `LOG_CHAT_ID`. |
|
||||||
|
| Ошибка бэкапа | Администраторы / backup-чат | ✓ | - | Ошибка отправляется в `BACKUP_CHAT_ID`, а если он пустой - в `LOG_CHAT_ID`. |
|
||||||
|
|
||||||
|
## Важно про окончание подписки
|
||||||
|
|
||||||
|
Если пользователь привязал email к Telegram-аккаунту, уведомления об окончании подписки дублируются в оба канала: Telegram и email. Если привязан только Telegram - уйдет только Telegram. Если есть только email - уйдет только email.
|
||||||
|
|
||||||
|
Кнопка в email-уведомлениях об окончании подписки открывает Web App через вход по email-коду: код отправляется сразу, после подтверждения пользователь попадает к продлению подписки. Для бывшей платной подписки выбирается тот же тариф, для trial - стандартный, тариф по умолчанию или первый доступный тариф.
|
||||||
|
|
||||||
|
Остальные полезные пользовательские события - успешные и неуспешные оплаты, докупки трафика/HWID, платное повышение тарифа, реферальные бонусы, предупреждения по трафику и привязка способа оплаты - тоже отправляются на email, если SMTP настроен и у пользователя есть привязанная почта. Ручные админские рассылки и прямые сообщения остаются Telegram-only, потому что их содержимое и аудитория управляются администратором отдельно и могут включать Telegram-специфичный формат.
|
||||||
|
|
||||||
|
Для пользовательских email-уведомлений нужен настроенный SMTP-контур и email у пользователя. Уведомления жизненного цикла подписки дополнительно требуют `SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED=True`; support-уведомления администраторам дополнительно управляются `SUPPORT_ADMIN_EMAIL_NOTIFICATIONS_ENABLED`.
|
||||||
|
|
||||||
|
## Синхронизация каналов
|
||||||
|
|
||||||
|
Remnawave webhook и локальный `SubscriptionNotificationWorker` используют общий сервис отправки и общую таблицу дедупликации `subscription_notifications`.
|
||||||
|
|
||||||
|
Дедупликация ведется отдельно по каналам: например, `before_3d:telegram` и `before_3d:email`. Благодаря этому сбой одного канала не блокирует второй, а повторное событие из webhook или worker не отправляет уже доставленное уведомление повторно.
|
||||||
|
|
||||||
|
Старые записи без канала, например `before_3d`, считаются Telegram-историей. Они блокируют повторное Telegram-сообщение, но не мешают отправить email после обновления.
|
||||||
|
|
||||||
|
Если webhook от Remnawave приходит по пользователю без локальной подписки, используется legacy fallback: отправляется только Telegram по `telegram_id` из payload, без email и без канальной дедупликации.
|
||||||
@@ -22,7 +22,7 @@ JSON-каталог может содержать несколько тариф
|
|||||||
- добавление, редактирование и удаление тарифов;
|
- добавление, редактирование и удаление тарифов;
|
||||||
- включение и выключение тарифа на витрине;
|
- включение и выключение тарифа на витрине;
|
||||||
- выбор тарифа по умолчанию;
|
- выбор тарифа по умолчанию;
|
||||||
- настройка тарифов на срок (`period`): месячный лимит, периоды, RUB/Stars цены, пакеты докупки трафика;
|
- настройка тарифов на срок (`period`): месячный лимит, периоды, RUB/Stars цены, реферальные бонусы и пакеты докупки трафика;
|
||||||
- настройка тарифов по трафику (`traffic`): пакеты GB, RUB/Stars цены, курс конвертации;
|
- настройка тарифов по трафику (`traffic`): пакеты GB, RUB/Stars цены, курс конвертации;
|
||||||
- настройка базовых Internal Squads из списка Remnawave;
|
- настройка базовых Internal Squads из списка Remnawave;
|
||||||
- настройка premium-раздела: названия RU/EN, premium Internal Squads, месячный premium-лимит и RUB/Stars пакеты докупки premium-трафика;
|
- настройка premium-раздела: названия RU/EN, premium Internal Squads, месячный premium-лимит и RUB/Stars пакеты докупки premium-трафика;
|
||||||
@@ -60,7 +60,9 @@ JSON-каталог может содержать несколько тариф
|
|||||||
"squad_uuids": ["uuid-1"],
|
"squad_uuids": ["uuid-1"],
|
||||||
"billing_model": "period",
|
"billing_model": "period",
|
||||||
"monthly_gb": 500,
|
"monthly_gb": 500,
|
||||||
"prices_rub": { "1": 150, "3": 400 },
|
"prices_rub": { "1": 200, "3": 600 },
|
||||||
|
"referral_bonus_days_inviter": { "1": 3, "3": 7 },
|
||||||
|
"referral_bonus_days_referee": { "1": 1, "3": 3 },
|
||||||
"enabled_periods": [1, 3],
|
"enabled_periods": [1, 3],
|
||||||
"topup_packages": {
|
"topup_packages": {
|
||||||
"rub": [{ "gb": 10, "price": 99 }],
|
"rub": [{ "gb": 10, "price": 99 }],
|
||||||
@@ -108,6 +110,8 @@ JSON-каталог может содержать несколько тариф
|
|||||||
| `monthly_gb` | Базовый месячный лимит трафика тарифа. `0` означает безлимит. |
|
| `monthly_gb` | Базовый месячный лимит трафика тарифа. `0` означает безлимит. |
|
||||||
| `prices_rub` | Цены периодов в рублях, ключ - количество месяцев. |
|
| `prices_rub` | Цены периодов в рублях, ключ - количество месяцев. |
|
||||||
| `prices_stars` | Цены периодов в Telegram Stars. |
|
| `prices_stars` | Цены периодов в Telegram Stars. |
|
||||||
|
| `referral_bonus_days_inviter` | Бонус пригласившему в днях для каждого периода. Ключ - количество месяцев, как в `enabled_periods`. |
|
||||||
|
| `referral_bonus_days_referee` | Бонус приглашенному в днях для каждого периода. Ключ - количество месяцев, как в `enabled_periods`. |
|
||||||
| `enabled_periods` | Периоды, доступные для покупки. |
|
| `enabled_periods` | Периоды, доступные для покупки. |
|
||||||
| `topup_packages` | Пакеты докупки трафика именно для этого тарифа. Если поле не задано или списки пустые, докупка для тарифа не показывается в Web App и Telegram-боте. |
|
| `topup_packages` | Пакеты докупки трафика именно для этого тарифа. Если поле не задано или списки пустые, докупка для тарифа не показывается в Web App и Telegram-боте. |
|
||||||
|
|
||||||
@@ -314,7 +318,15 @@ Remnawave ограничивает доступ при достижении `tra
|
|||||||
|
|
||||||
Пробный период использует настройки `TRIAL_DURATION_DAYS`, `TRIAL_TRAFFIC_LIMIT_GB`, `TRIAL_TRAFFIC_STRATEGY` и `TRIAL_SQUAD_UUIDS`. Он не выбирает тариф из JSON-каталога, но его можно настроить на странице **Система → Тарифы** рядом с каталогом продаж. Если `TRIAL_SQUAD_UUIDS` пустой, для trial применяются squads из `USER_SQUAD_UUIDS`.
|
Пробный период использует настройки `TRIAL_DURATION_DAYS`, `TRIAL_TRAFFIC_LIMIT_GB`, `TRIAL_TRAFFIC_STRATEGY` и `TRIAL_SQUAD_UUIDS`. Он не выбирает тариф из JSON-каталога, но его можно настроить на странице **Система → Тарифы** рядом с каталогом продаж. Если `TRIAL_SQUAD_UUIDS` пустой, для trial применяются squads из `USER_SQUAD_UUIDS`.
|
||||||
|
|
||||||
Промокоды с бонусными днями применяются к покупке period-подписки. Реферальные бонусы по периодам также относятся к подпискам на срок; в режиме продажи трафика без JSON-каталога Web App не показывает детализацию бонусов по месяцам.
|
Промокоды с бонусными днями применяются к покупке period-подписки.
|
||||||
|
|
||||||
|
Реферальные бонусы за оплату в JSON-каталоге задаются прямо в period-тарифе рядом с ценами периода: `referral_bonus_days_inviter` для пригласившего и `referral_bonus_days_referee` для приглашенного. Ключи этих словарей - месяцы периода (`"1"`, `"3"`, `"6"`, `"12"` или любые другие периоды тарифа, например `"2"`, `"4"`, `"8"`, `"16"`). Для `traffic`-тарифов такие бонусы не применяются.
|
||||||
|
|
||||||
|
Если приглашенный покупает один тариф, а пригласивший находится на другом, размер бонуса берется из тарифа и периода, который купил приглашенный. При этом подписка пригласившего только продлевается на бонусные дни: лимиты, Internal Squads и другие параметры его текущего тарифа не пересчитываются под тариф приглашенного.
|
||||||
|
|
||||||
|
В Web App и Telegram-меню подробные строки по периодам показываются только для legacy-режима или когда активен один period-тариф. Если включено несколько period-тарифов, Web App показывает сообщение, что бонус зависит от тарифа и периода оплаты друга, затем список тарифов с диапазонами "от N до N дней" и раскрытием подробностей по иконке вопроса. Telegram-меню в этом случае показывает только диапазоны по каждому тарифу.
|
||||||
|
|
||||||
|
В legacy-режиме без JSON-каталога используются переменные `REFERRAL_BONUS_DAYS_*` и `REFEREE_BONUS_DAYS_*`; при продаже трафика без JSON-каталога Web App не показывает детализацию бонусов по месяцам.
|
||||||
|
|
||||||
## Привязка существующих подписок
|
## Привязка существующих подписок
|
||||||
|
|
||||||
|
|||||||
@@ -118,6 +118,8 @@ services:
|
|||||||
- Telegram deep-link: `https://t.me/<bot>?start=ref_u<code>`;
|
- Telegram deep-link: `https://t.me/<bot>?start=ref_u<code>`;
|
||||||
- Web App ссылка: `https://app.domain.com/?ref=u<code>`.
|
- Web App ссылка: `https://app.domain.com/?ref=u<code>`.
|
||||||
|
|
||||||
|
В разделе бонусов Web App показывает приветственный бонус за регистрацию и бонусы за оплату подписки. В legacy-режиме или при одном period-тарифе выводятся подробные строки по периодам. Если в JSON-каталоге включено несколько period-тарифов, Web App показывает, что бонус зависит от тарифа и периода оплаты друга, затем список тарифов с диапазоном "от N до N дней"; подробности по периодам раскрываются по иконке вопроса.
|
||||||
|
|
||||||
Веб-приложение учитывает `ref`, `start`, `start_param` и Telegram Mini Apps `start_param`, сохраняет найденный параметр до авторизации и передает его в Telegram OAuth или вход по email.
|
Веб-приложение учитывает `ref`, `start`, `start_param` и Telegram Mini Apps `start_param`, сохраняет найденный параметр до авторизации и передает его в Telegram OAuth или вход по email.
|
||||||
|
|
||||||
Для email-регистраций пользователь в Remnawave создается с username вида `em_<referral_code>`. Email добавляется в описание пользователя панели и, если API панели принимает поле `email`, передается отдельным полем. Для Telegram-регистраций используется username `tg_<telegram_id>`.
|
Для email-регистраций пользователь в Remnawave создается с username вида `em_<referral_code>`. Email добавляется в описание пользователя панели и, если API панели принимает поле `email`, передается отдельным полем. Для Telegram-регистраций используется username `tg_<telegram_id>`.
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ WEBAPP_DEFAULT_THEME=
|
|||||||
|
|
||||||
`WEBAPP_DEFAULT_THEME` опционален. Если он задан и совпадает с ключом темы, он переопределяет `default: true` в `theme.json`. Если переменная пустая, дефолт выбирается из дескрипторов тем.
|
`WEBAPP_DEFAULT_THEME` опционален. Если он задан и совпадает с ключом темы, он переопределяет `default: true` в `theme.json`. Если переменная пустая, дефолт выбирается из дескрипторов тем.
|
||||||
|
|
||||||
|
В compose-примерах `data/themes` - это локальная папка рядом с выбранным `docker-compose.yml`; она монтируется в контейнер как `/app/data/themes`. Правки в `backend/bot/app/web/themes` попадают в прод только при сборке собственного образа; опубликованный образ их не видит.
|
||||||
|
|
||||||
Важно: `WEBAPP_PRIMARY_COLOR`, `WEBAPP_LOGO_URL`, `WEBAPP_LOGO_USE_EMOJI`, `WEBAPP_LOGO_EMOJI` и `WEBAPP_LOGO_EMOJI_FONT` больше не являются рабочим способом первичной настройки через `.env`. Эти значения редактируются в админке и сохраняются как overrides в базе. Тема при этом может использовать сохраненный primary color как fallback accent.
|
Важно: `WEBAPP_PRIMARY_COLOR`, `WEBAPP_LOGO_URL`, `WEBAPP_LOGO_USE_EMOJI`, `WEBAPP_LOGO_EMOJI` и `WEBAPP_LOGO_EMOJI_FONT` больше не являются рабочим способом первичной настройки через `.env`. Эти значения редактируются в админке и сохраняются как overrides в базе. Тема при этом может использовать сохраненный primary color как fallback accent.
|
||||||
|
|
||||||
## Контракт `theme.json`
|
## Контракт `theme.json`
|
||||||
@@ -266,7 +268,7 @@ CSS можно писать для пользовательской части
|
|||||||
|
|
||||||
2. Создайте папку в `WEBAPP_THEMES_DIR`.
|
2. Создайте папку в `WEBAPP_THEMES_DIR`.
|
||||||
|
|
||||||
В Docker по умолчанию это `data/themes`. Если включен bind mount `./data:/app/data`, убедитесь, что контейнер может писать в `data`.
|
В Docker это `data/themes` рядом с выбранным `docker-compose.yml`, внутри контейнера путь будет `/app/data/themes`. Убедитесь, что контейнер может писать в `data`.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir -p data/themes/neon
|
mkdir -p data/themes/neon
|
||||||
@@ -314,6 +316,8 @@ CSS можно писать для пользовательской части
|
|||||||
|
|
||||||
Начинайте с переопределения CSS-переменных на `.theme-key-neon.app-shell`, затем переходите к конкретным компонентам. Проверяйте минимум: главная, `/install`, публичная `/s/<token>`, оплата, настройки, модалки, админский дашборд, таблица пользователей, редактор тарифов.
|
Начинайте с переопределения CSS-переменных на `.theme-key-neon.app-shell`, затем переходите к конкретным компонентам. Проверяйте минимум: главная, `/install`, публичная `/s/<token>`, оплата, настройки, модалки, админский дашборд, таблица пользователей, редактор тарифов.
|
||||||
|
|
||||||
|
После ручного изменения CSS поднимите `assets_version` в `theme.json` или сделайте жесткую перезагрузку страницы: тема подключается с `?v=<assets_version>`, и браузер может держать старую версию.
|
||||||
|
|
||||||
9. Добавьте ассеты при необходимости.
|
9. Добавьте ассеты при необходимости.
|
||||||
|
|
||||||
Положите картинки в подпапку темы и ссылайтесь на них через `/webapp-theme-assets/<key>/...`. Не используйте относительные пути вроде `url("icons/x.png")`, если CSS может быть подключен с другого URL-уровня; явный `/webapp-theme-assets/neon/icons/x.png` надежнее.
|
Положите картинки в подпапку темы и ссылайтесь на них через `/webapp-theme-assets/<key>/...`. Не используйте относительные пути вроде `url("icons/x.png")`, если CSS может быть подключен с другого URL-уровня; явный `/webapp-theme-assets/neon/icons/x.png` надежнее.
|
||||||
@@ -357,6 +361,7 @@ CSS можно писать для пользовательской части
|
|||||||
Если CSS не применился:
|
Если CSS не применился:
|
||||||
|
|
||||||
- проверьте `css_file` и URL `/webapp-theme-css/<key>/<css_file>`;
|
- проверьте `css_file` и URL `/webapp-theme-css/<key>/<css_file>`;
|
||||||
|
- если CSS уже был открыт в браузере, увеличьте `assets_version` в `theme.json` или очистите кеш;
|
||||||
- убедитесь, что файл меньше 512 KiB;
|
- убедитесь, что файл меньше 512 KiB;
|
||||||
- начинайте селекторы с `.theme-key-<key>`;
|
- начинайте селекторы с `.theme-key-<key>`;
|
||||||
- откройте `/home?theme_preview=<key>` в новом окне, чтобы исключить сохраненный старый выбор.
|
- откройте `/home?theme_preview=<key>` в новом окне, чтобы исключить сохраненный старый выбор.
|
||||||
|
|||||||
@@ -24,14 +24,14 @@ nano .env
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `BOT_TOKEN` | Токен Telegram-бота. |
|
| `BOT_TOKEN` | Токен Telegram-бота. |
|
||||||
| `ADMIN_IDS` | Telegram ID администраторов через запятую; без этого не попасть в Web App админку. |
|
| `ADMIN_IDS` | Telegram ID администраторов через запятую; без этого не попасть в Web App админку. |
|
||||||
| `WEBHOOK_BASE_URL` | Публичный URL webhook-домена backend. |
|
| `WEBHOOK_BASE_URL` | Публичный URL webhook-домена backend. Для Remnawave Panel `WEBHOOK_URL` будет `WEBHOOK_BASE_URL` + `/webhook/panel`, например `https://app.example.com/webhook/panel`. |
|
||||||
| `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB` | Доступы PostgreSQL для Compose и backend. |
|
| `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB` | Доступы PostgreSQL для Compose и backend. |
|
||||||
| `WEBAPP_ENABLED` | Включает Web App и админку. Для первого запуска держите `True`. |
|
| `WEBAPP_ENABLED` | Включает Web App и админку. Для первого запуска держите `True`. |
|
||||||
| `WEBAPP_SESSION_SECRET` | Стабильный секрет сессий Web App. |
|
| `WEBAPP_SESSION_SECRET` | Стабильный секрет сессий Web App. |
|
||||||
| `WEBHOOK_SECRET_TOKEN` | Стабильный секретный токен вебхука Telegram. |
|
| `WEBHOOK_SECRET_TOKEN` | Стабильный секретный токен вебхука Telegram. |
|
||||||
| `SUBSCRIPTION_MINI_APP_URL` | Публичный HTTPS URL Mini App/frontend, например `https://app.domain.com/`. Это URL, который открывают кнопки Telegram и который указывается в BotFather; не добавляйте сюда `/api` или webhook-пути. |
|
| `SUBSCRIPTION_MINI_APP_URL` | Публичный HTTPS URL Mini App/frontend, например `https://app.domain.com/`. Это URL, который открывают кнопки Telegram и который указывается в BotFather; не добавляйте сюда `/api` или webhook-пути. |
|
||||||
| `SUBSCRIPTION_GUIDES_ENABLED`, `SUBSCRIPTION_GUIDES_BOT_MENU_ENABLED` | Встроенные инструкции установки в Web App и кнопках бота. По умолчанию включены; обычно их достаточно менять в админке. |
|
| `SUBSCRIPTION_GUIDES_ENABLED`, `SUBSCRIPTION_GUIDES_BOT_MENU_ENABLED` | Встроенные инструкции установки в Web App и кнопках бота. По умолчанию включены; обычно их достаточно менять в админке. |
|
||||||
| `PANEL_API_URL`, `PANEL_API_KEY`, `PANEL_WEBHOOK_SECRET` | Базовая интеграция с Remnawave. Эти значения стоит хранить в `.env`, но при необходимости их можно переопределить из админки. |
|
| `PANEL_API_URL`, `PANEL_API_KEY`, `PANEL_WEBHOOK_SECRET` | Базовая интеграция с Remnawave. Секрет вебхука задайте в Remnawave Panel и вставьте то же значение в настройки бота; эти значения стоит хранить в `.env`, но при необходимости их можно переопределить из админки. |
|
||||||
|
|
||||||
`WEBAPP_SESSION_SECRET` и `WEBHOOK_SECRET_TOKEN` можно сгенерировать так:
|
`WEBAPP_SESSION_SECRET` и `WEBHOOK_SECRET_TOKEN` можно сгенерировать так:
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@ openssl rand -hex 32
|
|||||||
|
|
||||||
Рекомендуемый порядок первичной настройки:
|
Рекомендуемый порядок первичной настройки:
|
||||||
|
|
||||||
1. **Система -> Настройки -> Remnawave**: проверьте `PANEL_API_URL`, `PANEL_API_KEY`, `PANEL_WEBHOOK_SECRET`, базовые squads.
|
1. **Система -> Настройки -> Remnawave Panel**: проверьте `PANEL_API_URL`, `PANEL_API_KEY`, `PANEL_WEBHOOK_SECRET`, базовые squads. Рядом с настройками Remnawave админка показывает вычисленный адрес для `WEBHOOK_URL` в панели, например `https://app.example.com/webhook/panel`.
|
||||||
2. **Система -> Тарифы**: создайте JSON-каталог тарифов, выберите Internal Squads, настройте модели на срок/по трафику, premium-сквады и HWID-пакеты.
|
2. **Система -> Тарифы**: создайте JSON-каталог тарифов, выберите Internal Squads, настройте модели на срок/по трафику, premium-сквады и HWID-пакеты.
|
||||||
3. **Система -> Настройки -> Инструкции подключения**: проверьте, что Remnawave Panel отдает нужный конфиг Subscription Page. JSON-переопределение включайте только если нужно временно заменить конфиг панели.
|
3. **Система -> Настройки -> Инструкции подключения**: проверьте, что Remnawave Panel отдает нужный конфиг Subscription Page. JSON-переопределение включайте только если нужно временно заменить конфиг панели.
|
||||||
4. **Система -> Настройки -> Платежи**: включите нужные провайдеры и заполните их ключи.
|
4. **Система -> Настройки -> Платежи**: включите нужные провайдеры и заполните их ключи.
|
||||||
@@ -82,9 +82,9 @@ openssl rand -hex 32
|
|||||||
|
|
||||||
## Файловые данные
|
## Файловые данные
|
||||||
|
|
||||||
В штатном `docker-compose.yml` данные хранятся в named volume `shop-data`. Внутри него лежат тарифы, темы, логотипы и прочие файловые данные приложения.
|
В compose-примерах данные монтируются из локальной папки `./data` рядом с выбранным `docker-compose.yml`. Внутри нее лежат тарифы, темы, логотипы и прочие файловые данные приложения.
|
||||||
|
|
||||||
Если для локальной разработки включаете bind mount `./data:/app/data`, заранее создайте каталоги и отдайте их пользователю контейнера:
|
Перед первым запуском создайте каталоги и отдайте их пользователю контейнера:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir -p data/themes data/webapp-logo data/webapp-emoji data/tariffs
|
mkdir -p data/themes data/webapp-logo data/webapp-emoji data/tariffs
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
- [Админка: бэкапы](/demo/admin/backups)
|
- [Админка: бэкапы](/demo/admin/backups)
|
||||||
- [Пробный период](/demo/home?mock=trial)
|
- [Пробный период](/demo/home?mock=trial)
|
||||||
- [Докупка устройств](/demo/devices?mock=devices)
|
- [Докупка устройств](/demo/devices?mock=devices)
|
||||||
|
- [Запуск бота для Telegram-уведомлений](/demo/home?mock=notifications)
|
||||||
- [Вход и регистрация](/demo/login?mock=auth)
|
- [Вход и регистрация](/demo/login?mock=auth)
|
||||||
|
|
||||||
## Как собирается
|
## Как собирается
|
||||||
|
|||||||
@@ -331,14 +331,12 @@ distributed lock; код подготовлен к нескольким репл
|
|||||||
|
|
||||||
- `postgres-data`;
|
- `postgres-data`;
|
||||||
- `redis-data`;
|
- `redis-data`;
|
||||||
- `shop-data`;
|
|
||||||
В Caddy-варианте также используются `caddy-data` и `caddy-config`.
|
В Caddy-варианте также используются `caddy-data` и `caddy-config`.
|
||||||
|
|
||||||
`shop-data` монтируется целиком в `/app/data`; внутри него лежат тарифы, темы, логотипы и прочие
|
Файлы приложения монтируются из локальной папки `./data` рядом с выбранным `docker-compose.yml` в
|
||||||
файловые данные приложения.
|
`/app/data`; внутри нее лежат тарифы, темы, логотипы и прочие файловые данные приложения.
|
||||||
|
|
||||||
Если вместо именованного volume включаете bind mount `./data:/app/data`, на сервере заранее дайте права
|
Перед первым запуском на сервере заранее дайте права пользователю контейнера `10001`:
|
||||||
пользователю контейнера `10001`:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir -p data/themes data/webapp-logo data/webapp-emoji data/tariffs
|
mkdir -p data/themes data/webapp-logo data/webapp-emoji data/tariffs
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ docker compose logs -f backend worker frontend
|
|||||||
- `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB`.
|
- `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB`.
|
||||||
- `WEBAPP_SESSION_SECRET`, `WEBHOOK_SECRET_TOKEN`, `PANEL_API_URL`, `PANEL_API_KEY`, `PANEL_WEBHOOK_SECRET`.
|
- `WEBAPP_SESSION_SECRET`, `WEBHOOK_SECRET_TOKEN`, `PANEL_API_URL`, `PANEL_API_KEY`, `PANEL_WEBHOOK_SECRET`.
|
||||||
|
|
||||||
|
Для вебхуков Remnawave в панели укажите `WEBHOOK_URL` как `WEBHOOK_BASE_URL` + `/webhook/panel`, например `https://app.example.com/webhook/panel`. Секрет создается или задается в Remnawave Panel; тот же секрет вставьте в `PANEL_WEBHOOK_SECRET` в `.env` или позже в **Система -> Настройки -> Remnawave Panel**.
|
||||||
|
|
||||||
## Как выбрать Compose-вариант
|
## Как выбрать Compose-вариант
|
||||||
|
|
||||||
Для продакшена по умолчанию берите [Caddy](deployment.md#caddy-рекомендуемый-вариант): это самый короткий путь к публичному HTTPS без ручной раскладки сертификатов.
|
Для продакшена по умолчанию берите [Caddy](deployment.md#caddy-рекомендуемый-вариант): это самый короткий путь к публичному HTTPS без ручной раскладки сертификатов.
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ docker compose \
|
|||||||
| `TELEGRAM_WEBHOOK_PATH` | удалена | Путь вебхука теперь генерируется из `BOT_TOKEN` автоматически. |
|
| `TELEGRAM_WEBHOOK_PATH` | удалена | Путь вебхука теперь генерируется из `BOT_TOKEN` автоматически. |
|
||||||
| `REQUIRED_CHANNEL_SUBSCRIBE_TO_USE` | удалена | Гейт включается автоматически, как только задан `REQUIRED_CHANNEL_ID`. |
|
| `REQUIRED_CHANNEL_SUBSCRIBE_TO_USE` | удалена | Гейт включается автоматически, как только задан `REQUIRED_CHANNEL_ID`. |
|
||||||
| `STARS_PROVIDER_TOKEN` | удалена | Telegram Stars (XTR) используются напрямую. |
|
| `STARS_PROVIDER_TOKEN` | удалена | Telegram Stars (XTR) используются напрямую. |
|
||||||
| `REFERRAL_ENABLED` | удалена | Реферальная программа активна по умолчанию; чтобы выключить — обнулите `REFERRAL_BONUS_DAYS_*` и `REFEREE_BONUS_DAYS_*`. |
|
| `REFERRAL_ENABLED` | удалена | Реферальная программа активна по умолчанию. В legacy-режиме без JSON-каталога отключайте платежные бонусы через нули в `REFERRAL_BONUS_DAYS_*` и `REFEREE_BONUS_DAYS_*`; в JSON-тарифах обнуляйте или удаляйте `referral_bonus_days_inviter` и `referral_bonus_days_referee` у period-тарифов. |
|
||||||
| `POSTGRES_HOST=remnawave-tg-shop-db` | в `.env` — `remnawave-minishop-db` или пусто | Под compose значение всё равно переопределяется на сервисное имя `postgres` (см. `environment:` в compose-файлах), поэтому скрипт правит `.env` только для bare-metal сценариев. |
|
| `POSTGRES_HOST=remnawave-tg-shop-db` | в `.env` — `remnawave-minishop-db` или пусто | Под compose значение всё равно переопределяется на сервисное имя `postgres` (см. `environment:` в compose-файлах), поэтому скрипт правит `.env` только для bare-metal сценариев. |
|
||||||
| `WEBHOOK_BASE_URL` | **обязательна** | Polling-режим удалён, без публичного URL бот не стартует. |
|
| `WEBHOOK_BASE_URL` | **обязательна** | Polling-режим удалён, без публичного URL бот не стартует. |
|
||||||
| — | `REDIS_URL=redis://redis:6379/0` | Обязательна для воркера, очередей и rate-limit. По умолчанию в compose-файлах уже задана. |
|
| — | `REDIS_URL=redis://redis:6379/0` | Обязательна для воркера, очередей и rate-limit. По умолчанию в compose-файлах уже задана. |
|
||||||
|
|||||||
+152
-1
@@ -73,6 +73,7 @@
|
|||||||
const ACTIVATION_PENDING_WATCH_INTERVAL_MS = 2000;
|
const ACTIVATION_PENDING_WATCH_INTERVAL_MS = 2000;
|
||||||
const ACTIVATION_PENDING_WATCH_MAX_ATTEMPTS = 45;
|
const ACTIVATION_PENDING_WATCH_MAX_ATTEMPTS = 45;
|
||||||
const ACTIVATION_RESUME_CHECK_COOLDOWN_MS = 1500;
|
const ACTIVATION_RESUME_CHECK_COOLDOWN_MS = 1500;
|
||||||
|
const TELEGRAM_NOTIFICATIONS_RESUME_REFRESH_COOLDOWN_MS = 1500;
|
||||||
import {
|
import {
|
||||||
activationPaymentFailed,
|
activationPaymentFailed,
|
||||||
createActivationHandoff,
|
createActivationHandoff,
|
||||||
@@ -152,6 +153,7 @@
|
|||||||
let mode = isAppLaunchRoute ? "appLaunch" : isPreviewBoard ? "preview" : "loading";
|
let mode = isAppLaunchRoute ? "appLaunch" : isPreviewBoard ? "preview" : "loading";
|
||||||
let activeTab = "home";
|
let activeTab = "home";
|
||||||
let screen = "home";
|
let screen = "home";
|
||||||
|
let emailLoginDeeplinkConsumed = false;
|
||||||
let data = isPreviewBoard ? structuredCloneSafe(MOCK_SOURCE.data) : null;
|
let data = isPreviewBoard ? structuredCloneSafe(MOCK_SOURCE.data) : null;
|
||||||
let appLaunchTarget = isAppLaunchRoute ? readExternalAppLaunchTarget() : "";
|
let appLaunchTarget = isAppLaunchRoute ? readExternalAppLaunchTarget() : "";
|
||||||
let publicInstallSubscription = null;
|
let publicInstallSubscription = null;
|
||||||
@@ -166,6 +168,9 @@
|
|||||||
let activationPendingWatchBusy = false;
|
let activationPendingWatchBusy = false;
|
||||||
let activationResumeRefreshBusy = false;
|
let activationResumeRefreshBusy = false;
|
||||||
let activationResumeLastCheckAt = 0;
|
let activationResumeLastCheckAt = 0;
|
||||||
|
let telegramNotificationsBotOpenedAt = 0;
|
||||||
|
let telegramNotificationsResumeRefreshBusy = false;
|
||||||
|
let telegramNotificationsResumeLastCheckAt = 0;
|
||||||
let promoCode = "";
|
let promoCode = "";
|
||||||
let promoBusy = false;
|
let promoBusy = false;
|
||||||
let promoStatus = "";
|
let promoStatus = "";
|
||||||
@@ -323,6 +328,7 @@
|
|||||||
devicesBusy,
|
devicesBusy,
|
||||||
devicesStatus,
|
devicesStatus,
|
||||||
devicesIsError,
|
devicesIsError,
|
||||||
|
devicesErrorCode,
|
||||||
deviceConfirmOpen,
|
deviceConfirmOpen,
|
||||||
deviceToDisconnect,
|
deviceToDisconnect,
|
||||||
deviceDisconnectBusy,
|
deviceDisconnectBusy,
|
||||||
@@ -473,7 +479,13 @@
|
|||||||
languageOptions.find((option) => option.value === currentLang) || languageOptions[0];
|
languageOptions.find((option) => option.value === currentLang) || languageOptions[0];
|
||||||
$: userLanguage = languageName(currentLang);
|
$: userLanguage = languageName(currentLang);
|
||||||
$: emailLinkStatus = user?.email ? t("wa_settings_linked") : t("wa_settings_email_not_linked");
|
$: emailLinkStatus = user?.email ? t("wa_settings_linked") : t("wa_settings_email_not_linked");
|
||||||
$: hasUnlinkedIdentity = !user?.telegram_linked || !user?.email;
|
$: telegramNotificationsStatus = String(user?.telegram_notifications_status || "unknown");
|
||||||
|
$: telegramNotificationsNeedPrompt = Boolean(
|
||||||
|
user?.telegram_linked && user?.telegram_notifications_need_prompt
|
||||||
|
);
|
||||||
|
$: telegramNotificationsStartLink = String(user?.telegram_notifications_start_link || "");
|
||||||
|
$: hasUnlinkedIdentity =
|
||||||
|
!user?.telegram_linked || !user?.email || telegramNotificationsNeedPrompt;
|
||||||
$: referralBonusDetails = Array.isArray(referral?.bonus_details) ? referral.bonus_details : [];
|
$: referralBonusDetails = Array.isArray(referral?.bonus_details) ? referral.bonus_details : [];
|
||||||
$: referralWelcomeBonusDays = Math.max(0, Number(referral?.welcome_bonus_days || 0));
|
$: referralWelcomeBonusDays = Math.max(0, Number(referral?.welcome_bonus_days || 0));
|
||||||
$: referralOneBonusPerReferee = Boolean(referral?.one_bonus_per_referee);
|
$: referralOneBonusPerReferee = Boolean(referral?.one_bonus_per_referee);
|
||||||
@@ -747,6 +759,34 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function refreshTelegramNotificationsOnResume() {
|
||||||
|
if (
|
||||||
|
mode !== "app" ||
|
||||||
|
!telegramNotificationsNeedPrompt ||
|
||||||
|
!telegramNotificationsBotOpenedAt ||
|
||||||
|
telegramNotificationsResumeRefreshBusy
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const now = Date.now();
|
||||||
|
if (
|
||||||
|
now - telegramNotificationsResumeLastCheckAt <
|
||||||
|
TELEGRAM_NOTIFICATIONS_RESUME_REFRESH_COOLDOWN_MS
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
telegramNotificationsResumeLastCheckAt = now;
|
||||||
|
telegramNotificationsResumeRefreshBusy = true;
|
||||||
|
try {
|
||||||
|
await loadData({ fresh: true, preserveView: true });
|
||||||
|
if (!telegramNotificationsNeedPrompt) telegramNotificationsBotOpenedAt = 0;
|
||||||
|
} catch (_error) {
|
||||||
|
void _error;
|
||||||
|
} finally {
|
||||||
|
telegramNotificationsResumeRefreshBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function refreshAppLaunchTarget() {
|
function refreshAppLaunchTarget() {
|
||||||
appLaunchTarget = readExternalAppLaunchTarget();
|
appLaunchTarget = readExternalAppLaunchTarget();
|
||||||
return appLaunchTarget;
|
return appLaunchTarget;
|
||||||
@@ -769,6 +809,7 @@
|
|||||||
const onActivationResume = () => {
|
const onActivationResume = () => {
|
||||||
if (typeof document !== "undefined" && document.visibilityState === "hidden") return;
|
if (typeof document !== "undefined" && document.visibilityState === "hidden") return;
|
||||||
void refreshPendingActivationOnResume();
|
void refreshPendingActivationOnResume();
|
||||||
|
void refreshTelegramNotificationsOnResume();
|
||||||
};
|
};
|
||||||
const onVisibilityChange = () => {
|
const onVisibilityChange = () => {
|
||||||
if (document.visibilityState !== "hidden") onActivationResume();
|
if (document.visibilityState !== "hidden") onActivationResume();
|
||||||
@@ -1137,10 +1178,85 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openTelegramNotificationsBot() {
|
||||||
|
const link = telegramNotificationsStartLink;
|
||||||
|
telegramNotificationsBotOpenedAt = Date.now();
|
||||||
|
if (!link) {
|
||||||
|
showToast(t("wa_telegram_notifications_link_unavailable"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const currentTg = tg || telegramSdk.refresh();
|
||||||
|
if (currentTg?.openTelegramLink && /^https:\/\/t\.me\//i.test(link)) {
|
||||||
|
try {
|
||||||
|
tg = currentTg;
|
||||||
|
currentTg.openTelegramLink(link);
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
// Fall back to generic external opening below.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
openExternalLink(link);
|
||||||
|
}
|
||||||
|
|
||||||
function currentSearchParams() {
|
function currentSearchParams() {
|
||||||
return new URLSearchParams(window.location.search);
|
return new URLSearchParams(window.location.search);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readEmailCodeLoginDeeplink() {
|
||||||
|
const params = currentSearchParams();
|
||||||
|
if (params.get("login") !== "email_code") return null;
|
||||||
|
const emailHint = normalizedEmail(params.get("login_email") || "");
|
||||||
|
if (!emailHint || !emailHint.includes("@")) return null;
|
||||||
|
return emailHint;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasEmailCodeLoginDeeplink() {
|
||||||
|
return Boolean(readEmailCodeLoginDeeplink());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startEmailCodeLoginFromDeeplink() {
|
||||||
|
if (emailLoginDeeplinkConsumed) return;
|
||||||
|
const emailHint = readEmailCodeLoginDeeplink();
|
||||||
|
if (!emailHint) return;
|
||||||
|
emailLoginDeeplinkConsumed = true;
|
||||||
|
authStore.update((s) => ({
|
||||||
|
...s,
|
||||||
|
email: emailHint,
|
||||||
|
emailCode: "",
|
||||||
|
pendingEmail: "",
|
||||||
|
passwordLoginMode: false,
|
||||||
|
passwordLoginFallback: false,
|
||||||
|
}));
|
||||||
|
await tick();
|
||||||
|
await authStore.requestEmailCode((nextScreen) => {
|
||||||
|
screen = nextScreen;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function readRenewalDeeplink() {
|
||||||
|
const params = currentSearchParams();
|
||||||
|
const shouldRenew = params.get("after_login") === "renew" || params.get("renew") === "1";
|
||||||
|
if (!shouldRenew) return null;
|
||||||
|
return {
|
||||||
|
tariffKey: String(params.get("renew_tariff") || "").trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripRenewalLoginQueryFromUrl() {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
const keys = ["login", "login_email", "after_login", "renew", "renew_tariff"];
|
||||||
|
const changed = keys.some((key) => url.searchParams.has(key));
|
||||||
|
if (!changed) return;
|
||||||
|
for (const key of keys) url.searchParams.delete(key);
|
||||||
|
const search = url.searchParams.toString();
|
||||||
|
window.history.replaceState(
|
||||||
|
null,
|
||||||
|
"",
|
||||||
|
`${url.pathname}${search ? `?${search}` : ""}${url.hash}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function docsDemoParentSearchParams() {
|
function docsDemoParentSearchParams() {
|
||||||
if (!isDocsDemo) return null;
|
if (!isDocsDemo) return null;
|
||||||
try {
|
try {
|
||||||
@@ -1316,6 +1432,7 @@
|
|||||||
clearToken,
|
clearToken,
|
||||||
clearManualLogoutFlag,
|
clearManualLogoutFlag,
|
||||||
isManuallyLoggedOut,
|
isManuallyLoggedOut,
|
||||||
|
hasEmailCodeLoginDeeplink,
|
||||||
finalizeMagicLogin: (loginToken) => authStore.finalizeMagicLogin(loginToken),
|
finalizeMagicLogin: (loginToken) => authStore.finalizeMagicLogin(loginToken),
|
||||||
finalizeTelegramAuth: (authData, source) => authStore.finalizeTelegramAuth(authData, source),
|
finalizeTelegramAuth: (authData, source) => authStore.finalizeTelegramAuth(authData, source),
|
||||||
setAuthStatus: (message, isError) => authStore.setAuthStatus(message, isError),
|
setAuthStatus: (message, isError) => authStore.setAuthStatus(message, isError),
|
||||||
@@ -1508,6 +1625,30 @@
|
|||||||
stripTopupQueryFromUrl();
|
stripTopupQueryFromUrl();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const renewalDeep = readRenewalDeeplink();
|
||||||
|
if (renewalDeep) {
|
||||||
|
const plansList = payload.plans?.length ? payload.plans : [];
|
||||||
|
const tariffCatalogLocal = buildTariffCatalog(plansList);
|
||||||
|
const tariffModeLocal = plansList.some((plan) => plan?.tariff_key);
|
||||||
|
activeTab = "home";
|
||||||
|
screen = "home";
|
||||||
|
syncAppSectionPath("home", true);
|
||||||
|
billingStore.openPaymentModal(
|
||||||
|
tariffModeLocal,
|
||||||
|
tariffModeLocal && tariffCatalogLocal.length === 1,
|
||||||
|
tariffCatalogLocal,
|
||||||
|
payload.subscription || {},
|
||||||
|
plansList,
|
||||||
|
payload.payment_methods?.[0]?.id || "",
|
||||||
|
{
|
||||||
|
preferredTariffKey: renewalDeep.tariffKey,
|
||||||
|
selectDefaultTariff: true,
|
||||||
|
preferCheckout: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
stripRenewalLoginQueryFromUrl();
|
||||||
|
}
|
||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1529,6 +1670,7 @@
|
|||||||
screen = "login";
|
screen = "login";
|
||||||
activeTab = "home";
|
activeTab = "home";
|
||||||
setPasswordLoginMode(isPasswordLoginPath(), true);
|
setPasswordLoginMode(isPasswordLoginPath(), true);
|
||||||
|
void startEmailCodeLoginFromDeeplink();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function api(path, options = {}) {
|
async function api(path, options = {}) {
|
||||||
@@ -2124,10 +2266,14 @@
|
|||||||
{regularTrafficTopupBarClickable}
|
{regularTrafficTopupBarClickable}
|
||||||
{regularTrafficTopupUnlocked}
|
{regularTrafficTopupUnlocked}
|
||||||
{subscription}
|
{subscription}
|
||||||
|
{telegramNotificationsNeedPrompt}
|
||||||
|
{telegramNotificationsStartLink}
|
||||||
|
{telegramNotificationsStatus}
|
||||||
{termUnitLabel}
|
{termUnitLabel}
|
||||||
{trafficMode}
|
{trafficMode}
|
||||||
{trialBusy}
|
{trialBusy}
|
||||||
{activateTrial}
|
{activateTrial}
|
||||||
|
{openTelegramNotificationsBot}
|
||||||
openConnectLink={openInstallOrConnect}
|
openConnectLink={openInstallOrConnect}
|
||||||
{openPaymentModal}
|
{openPaymentModal}
|
||||||
{openRegularTopupModal}
|
{openRegularTopupModal}
|
||||||
@@ -2185,6 +2331,7 @@
|
|||||||
{devicesData}
|
{devicesData}
|
||||||
{devicesIsError}
|
{devicesIsError}
|
||||||
{devicesLoaded}
|
{devicesLoaded}
|
||||||
|
{devicesErrorCode}
|
||||||
{devicesStatus}
|
{devicesStatus}
|
||||||
{subscription}
|
{subscription}
|
||||||
{loadDevices}
|
{loadDevices}
|
||||||
@@ -2230,12 +2377,16 @@
|
|||||||
{profileEmail}
|
{profileEmail}
|
||||||
{profileTelegramId}
|
{profileTelegramId}
|
||||||
{supportUrl}
|
{supportUrl}
|
||||||
|
{telegramNotificationsNeedPrompt}
|
||||||
|
{telegramNotificationsStartLink}
|
||||||
|
{telegramNotificationsStatus}
|
||||||
{telegramProfileName}
|
{telegramProfileName}
|
||||||
{user}
|
{user}
|
||||||
{userAgreementUrl}
|
{userAgreementUrl}
|
||||||
{userLanguage}
|
{userLanguage}
|
||||||
showLogout={!telegramMiniAppContext}
|
showLogout={!telegramMiniAppContext}
|
||||||
linkTelegramAccount={linkTelegramFromSettings}
|
linkTelegramAccount={linkTelegramFromSettings}
|
||||||
|
{openTelegramNotificationsBot}
|
||||||
logout={accountStore.logout}
|
logout={accountStore.logout}
|
||||||
{openAdminPanel}
|
{openAdminPanel}
|
||||||
{openExternalLink}
|
{openExternalLink}
|
||||||
|
|||||||
@@ -389,6 +389,21 @@
|
|||||||
usersStore.openUser(uid, { pathContext: "payments" });
|
usersStore.openUser(uid, { pathContext: "payments" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openLogsUserCard(userId) {
|
||||||
|
const uid = Number(userId);
|
||||||
|
if (!Number.isFinite(uid) || uid === 0) return;
|
||||||
|
const next = normalizeSection("logs");
|
||||||
|
sidebarOpen = false;
|
||||||
|
if (active !== next) {
|
||||||
|
active = next;
|
||||||
|
paymentsStore.closePayment({ skipPush: true });
|
||||||
|
supportStore.closeTicketView({ skipPush: true });
|
||||||
|
onSectionChange(next);
|
||||||
|
}
|
||||||
|
usersStore.setActive(next);
|
||||||
|
usersStore.openUser(uid, { skipPush: true, pathContext: "logs" });
|
||||||
|
}
|
||||||
|
|
||||||
function openUserCard(userId) {
|
function openUserCard(userId) {
|
||||||
const uid = Number(userId);
|
const uid = Number(userId);
|
||||||
if (!Number.isFinite(uid) || uid === 0) return;
|
if (!Number.isFinite(uid) || uid === 0) return;
|
||||||
@@ -801,7 +816,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if active === "logs"}
|
{#if active === "logs"}
|
||||||
<LogsSection {at} {fmtDate} />
|
<LogsSection {at} {fmtDate} onOpenUserCard={openLogsUserCard} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if active === "support"}
|
{#if active === "support"}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script>
|
<script>
|
||||||
|
import { Input } from "$components/ui/index.js";
|
||||||
import { Trash2 } from "$components/ui/icons.js";
|
import { Trash2 } from "$components/ui/icons.js";
|
||||||
import { getContext, onMount } from "svelte";
|
import { getContext, onMount } from "svelte";
|
||||||
import Dialog from "$components/ui/dialog.svelte";
|
import Dialog from "$components/ui/dialog.svelte";
|
||||||
@@ -106,7 +107,7 @@
|
|||||||
<div class="admin-form" data-dialog-content>
|
<div class="admin-form" data-dialog-content>
|
||||||
<div class="admin-dialog-form-section">
|
<div class="admin-dialog-form-section">
|
||||||
<AdminField label={at("ad_label_source", {}, "Источник")}>
|
<AdminField label={at("ad_label_source", {}, "Источник")}>
|
||||||
<input
|
<Input
|
||||||
class="input"
|
class="input"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="telegram_ads"
|
placeholder="telegram_ads"
|
||||||
@@ -118,7 +119,7 @@
|
|||||||
label={at("ad_label_param", {}, "start-параметр")}
|
label={at("ad_label_param", {}, "start-параметр")}
|
||||||
hint={at("ad_hint_param", {}, "Передаётся в /start, должен быть уникален")}
|
hint={at("ad_hint_param", {}, "Передаётся в /start, должен быть уникален")}
|
||||||
>
|
>
|
||||||
<input
|
<Input
|
||||||
class="input"
|
class="input"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="ads_summer25"
|
placeholder="ads_summer25"
|
||||||
@@ -129,7 +130,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="admin-dialog-form-section">
|
<div class="admin-dialog-form-section">
|
||||||
<AdminField label={at("ad_label_cost", {}, "Стоимость, RUB")}>
|
<AdminField label={at("ad_label_cost", {}, "Стоимость, RUB")}>
|
||||||
<input
|
<Input
|
||||||
class="input"
|
class="input"
|
||||||
type="number"
|
type="number"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
AdminEmptyState,
|
AdminEmptyState,
|
||||||
AdminSelect,
|
AdminSelect,
|
||||||
} from "$components/patterns/admin/index.js";
|
} from "$components/patterns/admin/index.js";
|
||||||
import { Checkbox } from "$components/ui/index.js";
|
import { Checkbox, ColorInput, FileInput, Input, RangeInput } from "$components/ui/index.js";
|
||||||
import { Switch } from "$components/ui/primitives.js";
|
import { Switch } from "$components/ui/primitives.js";
|
||||||
import { getContext, onDestroy, onMount } from "svelte";
|
import { getContext, onDestroy, onMount } from "svelte";
|
||||||
|
|
||||||
@@ -444,10 +444,9 @@
|
|||||||
|
|
||||||
<div class="appearance-controls">
|
<div class="appearance-controls">
|
||||||
<section class="appearance-control-card">
|
<section class="appearance-control-card">
|
||||||
<input
|
<FileInput
|
||||||
bind:this={logoFileInput}
|
bind:element={logoFileInput}
|
||||||
class="appearance-file-input"
|
class="appearance-file-input"
|
||||||
type="file"
|
|
||||||
accept="image/png,image/jpeg,image/gif,image/webp,image/svg+xml,image/x-icon"
|
accept="image/png,image/jpeg,image/gif,image/webp,image/svg+xml,image/x-icon"
|
||||||
onchange={handleLogoFileChange}
|
onchange={handleLogoFileChange}
|
||||||
/>
|
/>
|
||||||
@@ -461,7 +460,7 @@
|
|||||||
{at("appearance_logo_upload_file", {}, "Загрузить файл")}
|
{at("appearance_logo_upload_file", {}, "Загрузить файл")}
|
||||||
</AdminButton>
|
</AdminButton>
|
||||||
<div class="appearance-url-row">
|
<div class="appearance-url-row">
|
||||||
<input
|
<Input
|
||||||
class="input appearance-control"
|
class="input appearance-control"
|
||||||
type="url"
|
type="url"
|
||||||
placeholder="https://example.com/logo.png"
|
placeholder="https://example.com/logo.png"
|
||||||
@@ -490,7 +489,7 @@
|
|||||||
<span>{at("appearance_use_emoji_logo", {}, "Использовать emoji-логотип")}</span>
|
<span>{at("appearance_use_emoji_logo", {}, "Использовать emoji-логотип")}</span>
|
||||||
</label>
|
</label>
|
||||||
<div class="appearance-emoji-grid">
|
<div class="appearance-emoji-grid">
|
||||||
<input
|
<Input
|
||||||
class="input appearance-control"
|
class="input appearance-control"
|
||||||
type="text"
|
type="text"
|
||||||
maxlength="8"
|
maxlength="8"
|
||||||
@@ -547,10 +546,9 @@
|
|||||||
>{at("appearance_use_custom_favicon", {}, "Использовать отдельную favicon")}</span
|
>{at("appearance_use_custom_favicon", {}, "Использовать отдельную favicon")}</span
|
||||||
>
|
>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<FileInput
|
||||||
bind:this={faviconFileInput}
|
bind:element={faviconFileInput}
|
||||||
class="appearance-file-input"
|
class="appearance-file-input"
|
||||||
type="file"
|
|
||||||
accept="image/png,image/jpeg,image/gif,image/webp,image/svg+xml,image/x-icon,.ico"
|
accept="image/png,image/jpeg,image/gif,image/webp,image/svg+xml,image/x-icon,.ico"
|
||||||
onchange={handleFaviconFileChange}
|
onchange={handleFaviconFileChange}
|
||||||
/>
|
/>
|
||||||
@@ -564,7 +562,7 @@
|
|||||||
{at("appearance_favicon_upload_file", {}, "Загрузить favicon")}
|
{at("appearance_favicon_upload_file", {}, "Загрузить favicon")}
|
||||||
</AdminButton>
|
</AdminButton>
|
||||||
<div class="appearance-url-row">
|
<div class="appearance-url-row">
|
||||||
<input
|
<Input
|
||||||
class="input appearance-control"
|
class="input appearance-control"
|
||||||
type="url"
|
type="url"
|
||||||
placeholder="https://example.com/icon.png"
|
placeholder="https://example.com/icon.png"
|
||||||
@@ -657,18 +655,17 @@
|
|||||||
</span>
|
</span>
|
||||||
<label class="admin-theme-card-option appearance-color-row">
|
<label class="admin-theme-card-option appearance-color-row">
|
||||||
<span>{at("appearance_theme_accent", {}, "Accent")}</span>
|
<span>{at("appearance_theme_accent", {}, "Accent")}</span>
|
||||||
<input
|
<ColorInput
|
||||||
class="admin-color"
|
class={`admin-color${!isThemeAccentSet(theme) ? " is-empty" : ""}`}
|
||||||
class:is-empty={!isThemeAccentSet(theme)}
|
|
||||||
type="color"
|
|
||||||
value={pickerHex(theme.tokens?.accent)}
|
value={pickerHex(theme.tokens?.accent)}
|
||||||
|
ariaLabel={at("appearance_theme_accent", {}, "Accent")}
|
||||||
title={isThemeAccentSet(theme)
|
title={isThemeAccentSet(theme)
|
||||||
? theme.tokens?.accent
|
? theme.tokens?.accent
|
||||||
: at("appearance_theme_accent_empty", {}, "Не задан")}
|
: at("appearance_theme_accent_empty", {}, "Не задан")}
|
||||||
onclick={() => openThemeAccentPicker(theme)}
|
onclick={() => openThemeAccentPicker(theme)}
|
||||||
oninput={(event) => setThemeAccent(theme, event.currentTarget.value)}
|
oninput={(event) => setThemeAccent(theme, event.currentTarget.value)}
|
||||||
/>
|
/>
|
||||||
<input
|
<Input
|
||||||
class="input appearance-color-text"
|
class="input appearance-color-text"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={at("appearance_theme_accent_placeholder", {}, "Не задан")}
|
placeholder={at("appearance_theme_accent_placeholder", {}, "Не задан")}
|
||||||
@@ -693,17 +690,17 @@
|
|||||||
"Логотип на главной и входе"
|
"Логотип на главной и входе"
|
||||||
)}</span
|
)}</span
|
||||||
>
|
>
|
||||||
<input
|
<RangeInput
|
||||||
class="appearance-logo-scale-range"
|
class="appearance-logo-scale-range"
|
||||||
type="range"
|
|
||||||
min="50"
|
min="50"
|
||||||
max="300"
|
max="300"
|
||||||
step="5"
|
step="5"
|
||||||
|
ariaLabel={at("appearance_theme_home_logo_scale", {}, "Home logo scale")}
|
||||||
value={homeLogoScale(theme)}
|
value={homeLogoScale(theme)}
|
||||||
oninput={(event) => setThemeHomeLogoScale(theme, event.currentTarget.value)}
|
onValueChange={(value) => setThemeHomeLogoScale(theme, value)}
|
||||||
/>
|
/>
|
||||||
<span class="appearance-logo-scale-value">
|
<span class="appearance-logo-scale-value">
|
||||||
<input
|
<Input
|
||||||
class="input"
|
class="input"
|
||||||
type="number"
|
type="number"
|
||||||
min="50"
|
min="50"
|
||||||
@@ -814,7 +811,7 @@
|
|||||||
background: color-mix(in srgb, var(--admin-surface-2) 40%, transparent);
|
background: color-mix(in srgb, var(--admin-surface-2) 40%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.appearance-file-input {
|
:global(.appearance-file-input) {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -949,7 +946,7 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.appearance-color-text {
|
:global(.appearance-color-text) {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -959,7 +956,7 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.appearance-logo-scale-range {
|
:global(.appearance-logo-scale-range) {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
accent-color: var(--accent);
|
accent-color: var(--accent);
|
||||||
}
|
}
|
||||||
@@ -971,14 +968,14 @@
|
|||||||
color: var(--admin-text);
|
color: var(--admin-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.appearance-logo-scale-value .input {
|
:global(.appearance-logo-scale-value .input) {
|
||||||
width: 70px;
|
width: 70px;
|
||||||
min-height: 32px;
|
min-height: 32px;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-color.is-empty {
|
:global(.admin-color.is-empty) {
|
||||||
opacity: 0.42;
|
opacity: 0.42;
|
||||||
filter: grayscale(1);
|
filter: grayscale(1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
AdminTable,
|
AdminTable,
|
||||||
AdminTableSkeleton,
|
AdminTableSkeleton,
|
||||||
} from "$components/patterns/admin/index.js";
|
} from "$components/patterns/admin/index.js";
|
||||||
import { Checkbox, RadioGroup, RadioGroupItem } from "$components/ui/index.js";
|
import { Checkbox, FileInput, RadioGroup, RadioGroupItem } from "$components/ui/index.js";
|
||||||
import {
|
import {
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Database,
|
Database,
|
||||||
@@ -178,10 +178,9 @@
|
|||||||
? at("backups_uploading", {}, "Загрузка...")
|
? at("backups_uploading", {}, "Загрузка...")
|
||||||
: at("backups_upload", {}, "Загрузить архив")}
|
: at("backups_upload", {}, "Загрузить архив")}
|
||||||
</AdminButton>
|
</AdminButton>
|
||||||
<input
|
<FileInput
|
||||||
bind:this={fileInput}
|
bind:element={fileInput}
|
||||||
class="backups-file-input"
|
class="backups-file-input"
|
||||||
type="file"
|
|
||||||
accept=".zip,application/zip"
|
accept=".zip,application/zip"
|
||||||
on:change={uploadSelectedFile}
|
on:change={uploadSelectedFile}
|
||||||
/>
|
/>
|
||||||
@@ -362,7 +361,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.backups-file-input {
|
:global(.backups-file-input) {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 1px;
|
width: 1px;
|
||||||
height: 1px;
|
height: 1px;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script>
|
<script>
|
||||||
|
import { Textarea } from "$components/ui/index.js";
|
||||||
import { Send } from "$components/ui/icons.js";
|
import { Send } from "$components/ui/icons.js";
|
||||||
import { getContext } from "svelte";
|
import { getContext } from "svelte";
|
||||||
import { Label } from "$components/ui/primitives.js";
|
import { Label } from "$components/ui/primitives.js";
|
||||||
@@ -31,12 +32,12 @@
|
|||||||
<Label.Root class="admin-field-label">
|
<Label.Root class="admin-field-label">
|
||||||
<span>{at("broadcast_label_text", {}, "Текст сообщения")}</span>
|
<span>{at("broadcast_label_text", {}, "Текст сообщения")}</span>
|
||||||
<small>{at("broadcast_hint_text", {}, "Поддерживается HTML-разметка Telegram")}</small>
|
<small>{at("broadcast_hint_text", {}, "Поддерживается HTML-разметка Telegram")}</small>
|
||||||
<textarea
|
<Textarea
|
||||||
class="admin-textarea"
|
class="admin-textarea"
|
||||||
rows="6"
|
rows="6"
|
||||||
value={broadcastText}
|
value={broadcastText}
|
||||||
on:input={(e) => broadcastStore.updateField({ broadcastText: e.target.value })}
|
on:input={(e) => broadcastStore.updateField({ broadcastText: e.target.value })}
|
||||||
></textarea>
|
/>
|
||||||
</Label.Root>
|
</Label.Root>
|
||||||
<div style="display:flex; gap:8px; align-items:center;">
|
<div style="display:flex; gap:8px; align-items:center;">
|
||||||
<AdminButton
|
<AdminButton
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script>
|
<script>
|
||||||
|
import { Input } from "$components/ui/index.js";
|
||||||
import { getContext, onMount } from "svelte";
|
import { getContext, onMount } from "svelte";
|
||||||
import {
|
import {
|
||||||
AdminButton,
|
AdminButton,
|
||||||
@@ -7,9 +8,11 @@
|
|||||||
AdminTable,
|
AdminTable,
|
||||||
AdminTableSkeleton,
|
AdminTableSkeleton,
|
||||||
} from "$components/patterns/admin/index.js";
|
} from "$components/patterns/admin/index.js";
|
||||||
|
import { User } from "$components/ui/icons.js";
|
||||||
|
|
||||||
export let at;
|
export let at;
|
||||||
export let fmtDate;
|
export let fmtDate;
|
||||||
|
export let onOpenUserCard = () => {};
|
||||||
|
|
||||||
const logsStore = getContext("logsStore");
|
const logsStore = getContext("logsStore");
|
||||||
|
|
||||||
@@ -24,6 +27,25 @@
|
|||||||
at("content", {}, "Контент"),
|
at("content", {}, "Контент"),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function userDisplay(entry, kind) {
|
||||||
|
const id = kind === "target" ? entry.target_user_id : entry.user_id;
|
||||||
|
const label = kind === "target" ? entry.target_user_label : entry.user_label;
|
||||||
|
if (label) return label;
|
||||||
|
if (kind !== "target") {
|
||||||
|
if (entry.telegram_first_name) return entry.telegram_first_name;
|
||||||
|
if (entry.telegram_username) {
|
||||||
|
const username = String(entry.telegram_username);
|
||||||
|
return username.startsWith("@") ? username : `@${username}`;
|
||||||
|
}
|
||||||
|
if (entry.email) return entry.email;
|
||||||
|
}
|
||||||
|
return id || "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
function userId(entry, kind) {
|
||||||
|
return kind === "target" ? entry.target_user_id : entry.user_id;
|
||||||
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
logsStore.loadLogs();
|
logsStore.loadLogs();
|
||||||
});
|
});
|
||||||
@@ -31,7 +53,7 @@
|
|||||||
|
|
||||||
<div class="admin-toolbar admin-toolbar-card">
|
<div class="admin-toolbar admin-toolbar-card">
|
||||||
<div class="admin-toolbar-search admin-toolbar-search-actions">
|
<div class="admin-toolbar-search admin-toolbar-search-actions">
|
||||||
<input
|
<Input
|
||||||
type="search"
|
type="search"
|
||||||
class="input"
|
class="input"
|
||||||
placeholder={at("logs_user_filter_placeholder", {}, "Фильтр по ID пользователя")}
|
placeholder={at("logs_user_filter_placeholder", {}, "Фильтр по ID пользователя")}
|
||||||
@@ -64,7 +86,7 @@
|
|||||||
<AdminTableSkeleton
|
<AdminTableSkeleton
|
||||||
headers={logHeaders}
|
headers={logHeaders}
|
||||||
rows={10}
|
rows={10}
|
||||||
widths={["120px", "120px", "58px", "58px", "220px"]}
|
widths={["120px", "120px", "160px", "160px", "220px"]}
|
||||||
/>
|
/>
|
||||||
{:else if !logs.length}
|
{:else if !logs.length}
|
||||||
<AdminEmptyState tone="card"
|
<AdminEmptyState tone="card"
|
||||||
@@ -88,12 +110,50 @@
|
|||||||
<td class="admin-cell-mono" data-label={at("event", {}, "Событие")}
|
<td class="admin-cell-mono" data-label={at("event", {}, "Событие")}
|
||||||
>{entry.event_type}</td
|
>{entry.event_type}</td
|
||||||
>
|
>
|
||||||
<td class="admin-cell-mono" data-label={at("user_short", {}, "User")}
|
<td class="admin-logs-user-cell" data-label={at("user_short", {}, "User")}>
|
||||||
>{entry.user_id || "—"}</td
|
{#if userId(entry, "user")}
|
||||||
|
<span class="admin-logs-user">
|
||||||
|
<AdminButton
|
||||||
|
class="admin-logs-user-btn"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
title={at("payments_open_user", {}, "Open user card")}
|
||||||
|
aria-label={at("payments_open_user", {}, "Open user card")}
|
||||||
|
onclick={() => onOpenUserCard(userId(entry, "user"))}
|
||||||
>
|
>
|
||||||
<td class="admin-cell-mono" data-label={at("target_short", {}, "Target")}
|
<User size={14} />
|
||||||
>{entry.target_user_id || "—"}</td
|
</AdminButton>
|
||||||
|
<span class="admin-logs-user-meta">
|
||||||
|
<span class="admin-logs-user-name">{userDisplay(entry, "user")}</span>
|
||||||
|
<span class="admin-logs-user-id">ID {userId(entry, "user")}</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<span class="admin-muted">—</span>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="admin-logs-user-cell" data-label={at("target_short", {}, "Target")}>
|
||||||
|
{#if userId(entry, "target")}
|
||||||
|
<span class="admin-logs-user">
|
||||||
|
<AdminButton
|
||||||
|
class="admin-logs-user-btn"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
title={at("payments_open_user", {}, "Open user card")}
|
||||||
|
aria-label={at("payments_open_user", {}, "Open user card")}
|
||||||
|
onclick={() => onOpenUserCard(userId(entry, "target"))}
|
||||||
>
|
>
|
||||||
|
<User size={14} />
|
||||||
|
</AdminButton>
|
||||||
|
<span class="admin-logs-user-meta">
|
||||||
|
<span class="admin-logs-user-name">{userDisplay(entry, "target")}</span>
|
||||||
|
<span class="admin-logs-user-id">ID {userId(entry, "target")}</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<span class="admin-muted">—</span>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
<td class="admin-cell-wrap" data-label={at("content", {}, "Контент")}
|
<td class="admin-cell-wrap" data-label={at("content", {}, "Контент")}
|
||||||
>{entry.content || ""}</td
|
>{entry.content || ""}</td
|
||||||
>
|
>
|
||||||
@@ -117,3 +177,53 @@
|
|||||||
logsStore.setPage(logsPage + 1);
|
logsStore.setPage(logsPage + 1);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.admin-logs-user-cell {
|
||||||
|
min-width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-logs-user {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-logs-user-meta {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-logs-user-name {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--admin-text);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-logs-user-id {
|
||||||
|
color: var(--admin-dim);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.2;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-logs-user-cell :global(.admin-logs-user-btn.admin-btn) {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
min-width: 30px;
|
||||||
|
min-height: 30px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-logs-user-cell :global(.admin-logs-user-btn svg) {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script>
|
<script>
|
||||||
|
import { Input } from "$components/ui/index.js";
|
||||||
import { Trash2 } from "$components/ui/icons.js";
|
import { Trash2 } from "$components/ui/icons.js";
|
||||||
import { getContext, onMount } from "svelte";
|
import { getContext, onMount } from "svelte";
|
||||||
import Dialog from "$components/ui/dialog.svelte";
|
import Dialog from "$components/ui/dialog.svelte";
|
||||||
@@ -110,7 +111,7 @@
|
|||||||
<div class="admin-form" data-dialog-content>
|
<div class="admin-form" data-dialog-content>
|
||||||
<div class="admin-dialog-form-section">
|
<div class="admin-dialog-form-section">
|
||||||
<AdminField label={at("promo_label_code", {}, "Код")}>
|
<AdminField label={at("promo_label_code", {}, "Код")}>
|
||||||
<input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
class="input"
|
class="input"
|
||||||
value={promoDraft.code}
|
value={promoDraft.code}
|
||||||
@@ -122,7 +123,7 @@
|
|||||||
<div class="admin-dialog-form-section">
|
<div class="admin-dialog-form-section">
|
||||||
<div class="admin-form-row-2">
|
<div class="admin-form-row-2">
|
||||||
<AdminField label={at("promo_label_bonus_days", {}, "Бонус (дней)")}>
|
<AdminField label={at("promo_label_bonus_days", {}, "Бонус (дней)")}>
|
||||||
<input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
class="input"
|
class="input"
|
||||||
min="1"
|
min="1"
|
||||||
@@ -131,7 +132,7 @@
|
|||||||
/>
|
/>
|
||||||
</AdminField>
|
</AdminField>
|
||||||
<AdminField label={at("promo_label_max_activations", {}, "Макс. активаций")}>
|
<AdminField label={at("promo_label_max_activations", {}, "Макс. активаций")}>
|
||||||
<input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
class="input"
|
class="input"
|
||||||
min="1"
|
min="1"
|
||||||
@@ -141,7 +142,7 @@
|
|||||||
</AdminField>
|
</AdminField>
|
||||||
</div>
|
</div>
|
||||||
<AdminField label={at("promo_label_valid_days", {}, "Срок действия (дней от текущего)")}>
|
<AdminField label={at("promo_label_valid_days", {}, "Срок действия (дней от текущего)")}>
|
||||||
<input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
class="input"
|
class="input"
|
||||||
min="1"
|
min="1"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script>
|
<script>
|
||||||
|
import { ColorInput, FileInput, Input, ScrollArea, Textarea } from "$components/ui/index.js";
|
||||||
import {
|
import {
|
||||||
Check,
|
Check,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
@@ -198,6 +199,8 @@
|
|||||||
url,
|
url,
|
||||||
requiresBaseUrl: Boolean(field.webhook_requires_base_url),
|
requiresBaseUrl: Boolean(field.webhook_requires_base_url),
|
||||||
baseConfigured: field.webhook_base_url_configured !== false,
|
baseConfigured: field.webhook_base_url_configured !== false,
|
||||||
|
hintI18nKey: field.webhook_hint_i18n_key || "",
|
||||||
|
hintFallback: field.webhook_hint || "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,6 +336,7 @@
|
|||||||
function sectionTitle(id) {
|
function sectionTitle(id) {
|
||||||
const map = {
|
const map = {
|
||||||
general: "Общие",
|
general: "Общие",
|
||||||
|
remnawave: "Remnawave Panel",
|
||||||
appearance: "Внешний вид",
|
appearance: "Внешний вид",
|
||||||
pricing: "Тарифы и цены",
|
pricing: "Тарифы и цены",
|
||||||
payments: "Платёжные системы",
|
payments: "Платёжные системы",
|
||||||
@@ -415,9 +419,9 @@
|
|||||||
<small>
|
<small>
|
||||||
{webhook.url
|
{webhook.url
|
||||||
? at(
|
? at(
|
||||||
"settings_provider_webhook_url_hint",
|
adminLocaleKey(webhook.hintI18nKey || "settings_provider_webhook_url_hint"),
|
||||||
{},
|
{},
|
||||||
"Use this URL in the provider webhook settings."
|
webhook.hintFallback || "Use this URL in the provider webhook settings."
|
||||||
)
|
)
|
||||||
: at(
|
: at(
|
||||||
"settings_provider_webhook_base_missing",
|
"settings_provider_webhook_base_missing",
|
||||||
@@ -512,13 +516,13 @@
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
{:else if field.type === "color"}
|
{:else if field.type === "color"}
|
||||||
<input
|
<ColorInput
|
||||||
class="admin-color"
|
class="admin-color"
|
||||||
type="color"
|
|
||||||
value={valueFor(field) || "#00fe7a"}
|
value={valueFor(field) || "#00fe7a"}
|
||||||
|
ariaLabel={fieldLabelText(field)}
|
||||||
oninput={(e) => settingsStore.markDirty(field.key, e.currentTarget.value)}
|
oninput={(e) => settingsStore.markDirty(field.key, e.currentTarget.value)}
|
||||||
/>
|
/>
|
||||||
<input
|
<Input
|
||||||
class="input"
|
class="input"
|
||||||
type="text"
|
type="text"
|
||||||
value={valueFor(field) || ""}
|
value={valueFor(field) || ""}
|
||||||
@@ -557,7 +561,7 @@
|
|||||||
onValueChange={(value) => settingsStore.markDirty(field.key, value)}
|
onValueChange={(value) => settingsStore.markDirty(field.key, value)}
|
||||||
/>
|
/>
|
||||||
{:else if field.type === "int" || field.type === "float"}
|
{:else if field.type === "int" || field.type === "float"}
|
||||||
<input
|
<Input
|
||||||
class="input"
|
class="input"
|
||||||
type="number"
|
type="number"
|
||||||
step={field.type === "float" ? "0.1" : "1"}
|
step={field.type === "float" ? "0.1" : "1"}
|
||||||
@@ -568,19 +572,18 @@
|
|||||||
oninput={(e) => settingsStore.markDirty(field.key, e.currentTarget.value)}
|
oninput={(e) => settingsStore.markDirty(field.key, e.currentTarget.value)}
|
||||||
/>
|
/>
|
||||||
{:else if field.type === "text"}
|
{:else if field.type === "text"}
|
||||||
<textarea
|
<Textarea
|
||||||
class="admin-setting-textarea"
|
class="admin-setting-textarea"
|
||||||
rows="4"
|
rows="4"
|
||||||
placeholder={fieldPlaceholderText(field)}
|
placeholder={fieldPlaceholderText(field)}
|
||||||
value={valueFor(field) ?? ""}
|
value={valueFor(field) ?? ""}
|
||||||
oninput={(e) => settingsStore.markDirty(field.key, e.currentTarget.value)}
|
oninput={(e) => settingsStore.markDirty(field.key, e.currentTarget.value)}
|
||||||
></textarea>
|
/>
|
||||||
{:else if field.type === "json"}
|
{:else if field.type === "json"}
|
||||||
<div class="admin-json-toolbar">
|
<div class="admin-json-toolbar">
|
||||||
<input
|
<FileInput
|
||||||
id={"json-file-" + field.key}
|
id={"json-file-" + field.key}
|
||||||
class="admin-json-file-input"
|
class="admin-json-file-input"
|
||||||
type="file"
|
|
||||||
accept="application/json,.json"
|
accept="application/json,.json"
|
||||||
onchange={(event) => handleJsonFile(field, event)}
|
onchange={(event) => handleJsonFile(field, event)}
|
||||||
/>
|
/>
|
||||||
@@ -602,16 +605,16 @@
|
|||||||
</AdminButton>
|
</AdminButton>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<textarea
|
<Textarea
|
||||||
class="admin-setting-textarea admin-setting-json-textarea"
|
class="admin-setting-textarea admin-setting-json-textarea"
|
||||||
rows="10"
|
rows="10"
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
placeholder={fieldPlaceholderText(field)}
|
placeholder={fieldPlaceholderText(field)}
|
||||||
value={valueFor(field) ?? ""}
|
value={valueFor(field) ?? ""}
|
||||||
oninput={(e) => settingsStore.markDirty(field.key, e.currentTarget.value)}
|
oninput={(e) => settingsStore.markDirty(field.key, e.currentTarget.value)}
|
||||||
></textarea>
|
/>
|
||||||
{:else if field.secret}
|
{:else if field.secret}
|
||||||
<input
|
<Input
|
||||||
class="input"
|
class="input"
|
||||||
type={revealed ? "text" : "password"}
|
type={revealed ? "text" : "password"}
|
||||||
placeholder={secretPlaceholder(field)}
|
placeholder={secretPlaceholder(field)}
|
||||||
@@ -628,7 +631,7 @@
|
|||||||
{#if revealed}<EyeOff size={13} />{:else}<Eye size={13} />{/if}
|
{#if revealed}<EyeOff size={13} />{:else}<Eye size={13} />{/if}
|
||||||
</AdminButton>
|
</AdminButton>
|
||||||
{:else}
|
{:else}
|
||||||
<input
|
<Input
|
||||||
class="input"
|
class="input"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={fieldPlaceholderText(field)}
|
placeholder={fieldPlaceholderText(field)}
|
||||||
@@ -813,7 +816,7 @@
|
|||||||
<div class="admin-icon-picker-toolbar">
|
<div class="admin-icon-picker-toolbar">
|
||||||
<label class="admin-icon-picker-search">
|
<label class="admin-icon-picker-search">
|
||||||
<Search size={15} />
|
<Search size={15} />
|
||||||
<input
|
<Input
|
||||||
bind:value={iconPickerSearch}
|
bind:value={iconPickerSearch}
|
||||||
class="input"
|
class="input"
|
||||||
type="text"
|
type="text"
|
||||||
@@ -821,6 +824,7 @@
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<ScrollArea class="admin-icon-picker-scroll" maxHeight="min(52vh, 460px)">
|
||||||
<div class="admin-icon-picker-grid">
|
<div class="admin-icon-picker-grid">
|
||||||
{#each filteredIconOptions as iconName}
|
{#each filteredIconOptions as iconName}
|
||||||
{@const Icon = iconComponent(iconName)}
|
{@const Icon = iconComponent(iconName)}
|
||||||
@@ -837,5 +841,6 @@
|
|||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
import { fmtTrafficBytes } from "../../lib/admin/format.js";
|
import { fmtTrafficBytes } from "../../lib/admin/format.js";
|
||||||
import Badge from "$components/ui/badge.svelte";
|
import Badge from "$components/ui/badge.svelte";
|
||||||
|
import { ScrollArea } from "$components/ui/index.js";
|
||||||
import * as Card from "$components/ui/card/index.js";
|
import * as Card from "$components/ui/card/index.js";
|
||||||
import {
|
import {
|
||||||
AdminDashboardGrid,
|
AdminDashboardGrid,
|
||||||
@@ -606,6 +607,7 @@
|
|||||||
style="width:78%;height:9px;margin-top:6px"
|
style="width:78%;height:9px;margin-top:6px"
|
||||||
></span>
|
></span>
|
||||||
</div>
|
</div>
|
||||||
|
<ScrollArea class="admin-panel-dash-nodes-scroll" maxHeight="240px">
|
||||||
<div class="admin-panel-dash-nodes-grid">
|
<div class="admin-panel-dash-nodes-grid">
|
||||||
{#each Array(4) as _, m (m)}
|
{#each Array(4) as _, m (m)}
|
||||||
<div class="admin-panel-dash-node">
|
<div class="admin-panel-dash-node">
|
||||||
@@ -621,6 +623,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
@@ -1018,6 +1021,7 @@
|
|||||||
{at("stats_panel_inner_nodes_hint", {}, "")}
|
{at("stats_panel_inner_nodes_hint", {}, "")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<ScrollArea class="admin-panel-dash-nodes-scroll" maxHeight="240px">
|
||||||
<div class="admin-panel-dash-nodes-grid">
|
<div class="admin-panel-dash-nodes-grid">
|
||||||
{#each panelNodeTraffic.seven.slice(0, PANEL_NODE_TILE_LIMIT) as node}
|
{#each panelNodeTraffic.seven.slice(0, PANEL_NODE_TILE_LIMIT) as node}
|
||||||
<div class="admin-panel-dash-node">
|
<div class="admin-panel-dash-node">
|
||||||
@@ -1031,6 +1035,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
{#if panelNodeTraffic.seven.length > PANEL_NODE_TILE_LIMIT}
|
{#if panelNodeTraffic.seven.length > PANEL_NODE_TILE_LIMIT}
|
||||||
<p class="admin-panel-dash-nodes-more">
|
<p class="admin-panel-dash-nodes-more">
|
||||||
{at(
|
{at(
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
import { TicketMessageBubble } from "$components/patterns/webapp/index.js";
|
import { TicketMessageBubble } from "$components/patterns/webapp/index.js";
|
||||||
import Dialog from "$components/ui/dialog.svelte";
|
import Dialog from "$components/ui/dialog.svelte";
|
||||||
import { Search } from "$components/ui/icons.js";
|
import { Search } from "$components/ui/icons.js";
|
||||||
import { ScrollArea, Skeleton } from "$components/ui/index.js";
|
import { Input, ScrollArea, Skeleton } from "$components/ui/index.js";
|
||||||
|
|
||||||
export let at = (key) => key;
|
export let at = (key) => key;
|
||||||
export let initialTicketId = null;
|
export let initialTicketId = null;
|
||||||
@@ -215,7 +215,7 @@
|
|||||||
<div class="support-admin-toolbar admin-toolbar-card">
|
<div class="support-admin-toolbar admin-toolbar-card">
|
||||||
<label class="support-admin-search">
|
<label class="support-admin-search">
|
||||||
<Search size={16} />
|
<Search size={16} />
|
||||||
<input
|
<Input
|
||||||
class="input"
|
class="input"
|
||||||
type="search"
|
type="search"
|
||||||
placeholder={at("support_search", {}, "Поиск")}
|
placeholder={at("support_search", {}, "Поиск")}
|
||||||
@@ -269,7 +269,8 @@
|
|||||||
{:else if !tickets.length}
|
{:else if !tickets.length}
|
||||||
<div class="admin-empty-state">{at("support_empty", {}, "Тикетов пока нет")}</div>
|
<div class="admin-empty-state">{at("support_empty", {}, "Тикетов пока нет")}</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="support-inbox-list">
|
<ScrollArea class="support-inbox-list" maxHeight="none">
|
||||||
|
<div class="support-inbox-list-inner">
|
||||||
{#each tickets as ticket}
|
{#each tickets as ticket}
|
||||||
<SupportInboxRow
|
<SupportInboxRow
|
||||||
{ticket}
|
{ticket}
|
||||||
@@ -279,6 +280,7 @@
|
|||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script>
|
<script>
|
||||||
|
import { Input } from "$components/ui/index.js";
|
||||||
import { Tabs, Switch, Label } from "$components/ui/primitives.js";
|
import { Tabs, Switch, Label } from "$components/ui/primitives.js";
|
||||||
import Dialog from "$components/ui/dialog.svelte";
|
import Dialog from "$components/ui/dialog.svelte";
|
||||||
import { Plus, Save, Trash2, X } from "$components/ui/icons.js";
|
import { Plus, Save, Trash2, X } from "$components/ui/icons.js";
|
||||||
@@ -71,7 +72,7 @@
|
|||||||
"Латиницей, без пробелов. Используется в платежах и подписках, менять после публикации не рекомендуется"
|
"Латиницей, без пробелов. Используется в платежах и подписках, менять после публикации не рекомендуется"
|
||||||
)}</small
|
)}</small
|
||||||
>
|
>
|
||||||
<input
|
<Input
|
||||||
class="input"
|
class="input"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="standard"
|
placeholder="standard"
|
||||||
@@ -127,7 +128,7 @@
|
|||||||
<div class="admin-form-row admin-form-row-2">
|
<div class="admin-form-row admin-form-row-2">
|
||||||
<Label.Root class="admin-field-label">
|
<Label.Root class="admin-field-label">
|
||||||
<span>{at("tariff_label_name_ru", {}, "Название · RU")}</span>
|
<span>{at("tariff_label_name_ru", {}, "Название · RU")}</span>
|
||||||
<input
|
<Input
|
||||||
class="input"
|
class="input"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={at("tariff_placeholder_name_ru", {}, "Стандарт")}
|
placeholder={at("tariff_placeholder_name_ru", {}, "Стандарт")}
|
||||||
@@ -136,7 +137,7 @@
|
|||||||
</Label.Root>
|
</Label.Root>
|
||||||
<Label.Root class="admin-field-label">
|
<Label.Root class="admin-field-label">
|
||||||
<span>{at("tariff_label_name_en", {}, "Название · EN")}</span>
|
<span>{at("tariff_label_name_en", {}, "Название · EN")}</span>
|
||||||
<input
|
<Input
|
||||||
class="input"
|
class="input"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={at("tariff_placeholder_name_en", {}, "Standard")}
|
placeholder={at("tariff_placeholder_name_en", {}, "Standard")}
|
||||||
@@ -148,7 +149,7 @@
|
|||||||
<div class="admin-form-row admin-form-row-2">
|
<div class="admin-form-row admin-form-row-2">
|
||||||
<Label.Root class="admin-field-label">
|
<Label.Root class="admin-field-label">
|
||||||
<span>{at("tariff_label_desc_ru", {}, "Описание · RU")}</span>
|
<span>{at("tariff_label_desc_ru", {}, "Описание · RU")}</span>
|
||||||
<input
|
<Input
|
||||||
class="input"
|
class="input"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={at("tariff_placeholder_desc_ru", {}, "Базовый набор серверов")}
|
placeholder={at("tariff_placeholder_desc_ru", {}, "Базовый набор серверов")}
|
||||||
@@ -157,7 +158,7 @@
|
|||||||
</Label.Root>
|
</Label.Root>
|
||||||
<Label.Root class="admin-field-label">
|
<Label.Root class="admin-field-label">
|
||||||
<span>{at("tariff_label_desc_en", {}, "Описание · EN")}</span>
|
<span>{at("tariff_label_desc_en", {}, "Описание · EN")}</span>
|
||||||
<input
|
<Input
|
||||||
class="input"
|
class="input"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={at("tariff_placeholder_desc_en", {}, "Base server pool")}
|
placeholder={at("tariff_placeholder_desc_en", {}, "Base server pool")}
|
||||||
@@ -211,7 +212,7 @@
|
|||||||
"Сколько устройств может одновременно использовать подписку. Пусто — взять значение из .env, 0 — без ограничений"
|
"Сколько устройств может одновременно использовать подписку. Пусто — взять значение из .env, 0 — без ограничений"
|
||||||
)}</small
|
)}</small
|
||||||
>
|
>
|
||||||
<input
|
<Input
|
||||||
class="input"
|
class="input"
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
@@ -229,7 +230,7 @@
|
|||||||
"Сколько GB включено в тариф на каждый месяц. 0 — безлимитный трафика. Сверху можно докупать пакеты на вкладке «Докупки»"
|
"Сколько GB включено в тариф на каждый месяц. 0 — безлимитный трафика. Сверху можно докупать пакеты на вкладке «Докупки»"
|
||||||
)}</small
|
)}</small
|
||||||
>
|
>
|
||||||
<input
|
<Input
|
||||||
class="input"
|
class="input"
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
@@ -248,7 +249,7 @@
|
|||||||
"По этому курсу остаток подписки пересчитывается в гигабайты при переходе пользователя с тарифа «Период» на «Трафик»"
|
"По этому курсу остаток подписки пересчитывается в гигабайты при переходе пользователя с тарифа «Период» на «Трафик»"
|
||||||
)}</small
|
)}</small
|
||||||
>
|
>
|
||||||
<input
|
<Input
|
||||||
class="input"
|
class="input"
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
@@ -287,7 +288,7 @@
|
|||||||
"Эта строка заменит «Premium-серверы» в кабинете, докупках и карточках лимитов."
|
"Эта строка заменит «Premium-серверы» в кабинете, докупках и карточках лимитов."
|
||||||
)}</small
|
)}</small
|
||||||
>
|
>
|
||||||
<input
|
<Input
|
||||||
class="input"
|
class="input"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={at("tariff_placeholder_premium_name_ru", {}, "Premium-серверы")}
|
placeholder={at("tariff_placeholder_premium_name_ru", {}, "Premium-серверы")}
|
||||||
@@ -303,7 +304,7 @@
|
|||||||
"Опционально для английского интерфейса."
|
"Опционально для английского интерфейса."
|
||||||
)}</small
|
)}</small
|
||||||
>
|
>
|
||||||
<input
|
<Input
|
||||||
class="input"
|
class="input"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={at("tariff_placeholder_premium_name_en", {}, "Premium servers")}
|
placeholder={at("tariff_placeholder_premium_name_en", {}, "Premium servers")}
|
||||||
@@ -359,7 +360,7 @@
|
|||||||
"Сколько GB через premium-сквады включено в тариф каждый месяц. 0 или пусто — отдельного premium-лимита нет (premium-нодами можно пользоваться без ограничения)"
|
"Сколько GB через premium-сквады включено в тариф каждый месяц. 0 или пусто — отдельного premium-лимита нет (premium-нодами можно пользоваться без ограничения)"
|
||||||
)}</small
|
)}</small
|
||||||
>
|
>
|
||||||
<input
|
<Input
|
||||||
class="input"
|
class="input"
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
@@ -409,7 +410,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#each tariffDraft.premiumTopupRubRows as row, index}
|
{#each tariffDraft.premiumTopupRubRows as row, index}
|
||||||
<div class="admin-row-editor-line">
|
<div class="admin-row-editor-line">
|
||||||
<input
|
<Input
|
||||||
class="input"
|
class="input"
|
||||||
type="number"
|
type="number"
|
||||||
min="0.1"
|
min="0.1"
|
||||||
@@ -418,7 +419,7 @@
|
|||||||
bind:value={row.gb}
|
bind:value={row.gb}
|
||||||
aria-label={at("tariff_col_volume_gb", {}, "Объём premium-пакета в GB")}
|
aria-label={at("tariff_col_volume_gb", {}, "Объём premium-пакета в GB")}
|
||||||
/>
|
/>
|
||||||
<input
|
<Input
|
||||||
class="input"
|
class="input"
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
@@ -449,7 +450,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#each tariffDraft.premiumTopupStarsRows as row, index}
|
{#each tariffDraft.premiumTopupStarsRows as row, index}
|
||||||
<div class="admin-row-editor-line">
|
<div class="admin-row-editor-line">
|
||||||
<input
|
<Input
|
||||||
class="input"
|
class="input"
|
||||||
type="number"
|
type="number"
|
||||||
min="0.1"
|
min="0.1"
|
||||||
@@ -458,7 +459,7 @@
|
|||||||
bind:value={row.gb}
|
bind:value={row.gb}
|
||||||
aria-label={at("tariff_col_volume_gb", {}, "Объём premium-пакета в GB")}
|
aria-label={at("tariff_col_volume_gb", {}, "Объём premium-пакета в GB")}
|
||||||
/>
|
/>
|
||||||
<input
|
<Input
|
||||||
class="input"
|
class="input"
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
@@ -501,7 +502,13 @@
|
|||||||
<AdminButton
|
<AdminButton
|
||||||
size="sm"
|
size="sm"
|
||||||
onclick={() =>
|
onclick={() =>
|
||||||
tariffsStore.addDraftRow("periodRows", { months: 1, rub: "", stars: "" })}
|
tariffsStore.addDraftRow("periodRows", {
|
||||||
|
months: 1,
|
||||||
|
rub: "",
|
||||||
|
stars: "",
|
||||||
|
referral_inviter: "",
|
||||||
|
referral_referee: "",
|
||||||
|
})}
|
||||||
>
|
>
|
||||||
<Plus size={13} />
|
<Plus size={13} />
|
||||||
{at("tariff_btn_period", {}, "Период")}
|
{at("tariff_btn_period", {}, "Период")}
|
||||||
@@ -517,15 +524,17 @@
|
|||||||
</p>
|
</p>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="admin-row-editor">
|
<div class="admin-row-editor">
|
||||||
<div class="admin-row-editor-line admin-row-editor-4 admin-row-editor-header">
|
<div class="admin-row-editor-line admin-row-editor-6 admin-row-editor-header">
|
||||||
<span>{at("tariff_col_period_months", {}, "Срок, мес.")}</span>
|
<span>{at("tariff_col_period_months", {}, "Срок, мес.")}</span>
|
||||||
<span>{at("tariff_col_price_rub", {}, "Цена, ₽")}</span>
|
<span>{at("tariff_col_price_rub", {}, "Цена, ₽")}</span>
|
||||||
<span>{at("tariff_col_price_stars_full", {}, "Цена, ⭐ Stars")}</span>
|
<span>{at("tariff_col_price_stars_full", {}, "Цена, ⭐ Stars")}</span>
|
||||||
|
<span>{at("tariff_col_ref_inviter", {}, "Бонус приглашающему")}</span>
|
||||||
|
<span>{at("tariff_col_ref_referee", {}, "Бонус приглашённому")}</span>
|
||||||
<span></span>
|
<span></span>
|
||||||
</div>
|
</div>
|
||||||
{#each tariffDraft.periodRows as row, index}
|
{#each tariffDraft.periodRows as row, index}
|
||||||
<div class="admin-row-editor-line admin-row-editor-4">
|
<div class="admin-row-editor-line admin-row-editor-6">
|
||||||
<input
|
<Input
|
||||||
class="input"
|
class="input"
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
@@ -533,7 +542,7 @@
|
|||||||
bind:value={row.months}
|
bind:value={row.months}
|
||||||
aria-label={at("tariff_col_period_months", {}, "Срок (месяцы)")}
|
aria-label={at("tariff_col_period_months", {}, "Срок (месяцы)")}
|
||||||
/>
|
/>
|
||||||
<input
|
<Input
|
||||||
class="input"
|
class="input"
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
@@ -542,7 +551,7 @@
|
|||||||
bind:value={row.rub}
|
bind:value={row.rub}
|
||||||
aria-label={at("tariff_label_price_rub", {}, "Цена в рублях")}
|
aria-label={at("tariff_label_price_rub", {}, "Цена в рублях")}
|
||||||
/>
|
/>
|
||||||
<input
|
<Input
|
||||||
class="input"
|
class="input"
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
@@ -551,6 +560,24 @@
|
|||||||
bind:value={row.stars}
|
bind:value={row.stars}
|
||||||
aria-label={at("tariff_label_price_stars", {}, "Цена в Telegram Stars")}
|
aria-label={at("tariff_label_price_stars", {}, "Цена в Telegram Stars")}
|
||||||
/>
|
/>
|
||||||
|
<Input
|
||||||
|
class="input"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="1"
|
||||||
|
placeholder="3"
|
||||||
|
bind:value={row.referral_inviter}
|
||||||
|
aria-label={at("tariff_label_ref_inviter", {}, "Бонус приглашающему")}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
class="input"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="1"
|
||||||
|
placeholder="1"
|
||||||
|
bind:value={row.referral_referee}
|
||||||
|
aria-label={at("tariff_label_ref_referee", {}, "Бонус приглашённому")}
|
||||||
|
/>
|
||||||
<AdminButton
|
<AdminButton
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="danger"
|
variant="danger"
|
||||||
@@ -603,7 +630,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#each tariffDraft.trafficRubRows as row, index}
|
{#each tariffDraft.trafficRubRows as row, index}
|
||||||
<div class="admin-row-editor-line">
|
<div class="admin-row-editor-line">
|
||||||
<input
|
<Input
|
||||||
class="input"
|
class="input"
|
||||||
type="number"
|
type="number"
|
||||||
min="0.1"
|
min="0.1"
|
||||||
@@ -612,7 +639,7 @@
|
|||||||
bind:value={row.gb}
|
bind:value={row.gb}
|
||||||
aria-label={at("tariff_col_volume_gb", {}, "Объём пакета в GB")}
|
aria-label={at("tariff_col_volume_gb", {}, "Объём пакета в GB")}
|
||||||
/>
|
/>
|
||||||
<input
|
<Input
|
||||||
class="input"
|
class="input"
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
@@ -643,7 +670,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#each tariffDraft.trafficStarsRows as row, index}
|
{#each tariffDraft.trafficStarsRows as row, index}
|
||||||
<div class="admin-row-editor-line">
|
<div class="admin-row-editor-line">
|
||||||
<input
|
<Input
|
||||||
class="input"
|
class="input"
|
||||||
type="number"
|
type="number"
|
||||||
min="0.1"
|
min="0.1"
|
||||||
@@ -652,7 +679,7 @@
|
|||||||
bind:value={row.gb}
|
bind:value={row.gb}
|
||||||
aria-label={at("tariff_col_volume_gb", {}, "Объём пакета в GB")}
|
aria-label={at("tariff_col_volume_gb", {}, "Объём пакета в GB")}
|
||||||
/>
|
/>
|
||||||
<input
|
<Input
|
||||||
class="input"
|
class="input"
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
@@ -717,7 +744,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#each tariffDraft.topupRubRows as row, index}
|
{#each tariffDraft.topupRubRows as row, index}
|
||||||
<div class="admin-row-editor-line">
|
<div class="admin-row-editor-line">
|
||||||
<input
|
<Input
|
||||||
class="input"
|
class="input"
|
||||||
type="number"
|
type="number"
|
||||||
min="0.1"
|
min="0.1"
|
||||||
@@ -726,7 +753,7 @@
|
|||||||
bind:value={row.gb}
|
bind:value={row.gb}
|
||||||
aria-label={at("tariff_col_volume_gb", {}, "Объём пакета в GB")}
|
aria-label={at("tariff_col_volume_gb", {}, "Объём пакета в GB")}
|
||||||
/>
|
/>
|
||||||
<input
|
<Input
|
||||||
class="input"
|
class="input"
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
@@ -757,7 +784,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#each tariffDraft.topupStarsRows as row, index}
|
{#each tariffDraft.topupStarsRows as row, index}
|
||||||
<div class="admin-row-editor-line">
|
<div class="admin-row-editor-line">
|
||||||
<input
|
<Input
|
||||||
class="input"
|
class="input"
|
||||||
type="number"
|
type="number"
|
||||||
min="0.1"
|
min="0.1"
|
||||||
@@ -766,7 +793,7 @@
|
|||||||
bind:value={row.gb}
|
bind:value={row.gb}
|
||||||
aria-label={at("tariff_col_volume_gb", {}, "Объём пакета в GB")}
|
aria-label={at("tariff_col_volume_gb", {}, "Объём пакета в GB")}
|
||||||
/>
|
/>
|
||||||
<input
|
<Input
|
||||||
class="input"
|
class="input"
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
@@ -841,7 +868,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#each tariffDraft.hwidRubRows as row, index}
|
{#each tariffDraft.hwidRubRows as row, index}
|
||||||
<div class="admin-row-editor-line">
|
<div class="admin-row-editor-line">
|
||||||
<input
|
<Input
|
||||||
class="input"
|
class="input"
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
@@ -854,7 +881,7 @@
|
|||||||
"Сколько устройств добавляет пакет"
|
"Сколько устройств добавляет пакет"
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<input
|
<Input
|
||||||
class="input"
|
class="input"
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
@@ -885,7 +912,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#each tariffDraft.hwidStarsRows as row, index}
|
{#each tariffDraft.hwidStarsRows as row, index}
|
||||||
<div class="admin-row-editor-line">
|
<div class="admin-row-editor-line">
|
||||||
<input
|
<Input
|
||||||
class="input"
|
class="input"
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
@@ -898,7 +925,7 @@
|
|||||||
"Сколько устройств добавляет пакет"
|
"Сколько устройств добавляет пакет"
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<input
|
<Input
|
||||||
class="input"
|
class="input"
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script>
|
<script>
|
||||||
|
import { Input } from "$components/ui/index.js";
|
||||||
import {
|
import {
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
@@ -36,10 +37,38 @@
|
|||||||
const TRIAL_RESET_KEYS = ["TRIAL_TRAFFIC_STRATEGY"];
|
const TRIAL_RESET_KEYS = ["TRIAL_TRAFFIC_STRATEGY"];
|
||||||
const TRIAL_SQUAD_KEYS = ["TRIAL_SQUAD_UUIDS"];
|
const TRIAL_SQUAD_KEYS = ["TRIAL_SQUAD_UUIDS"];
|
||||||
const LEGACY_PERIODS = [
|
const LEGACY_PERIODS = [
|
||||||
["1", "MONTH_1_ENABLED", "RUB_PRICE_1_MONTH", "STARS_PRICE_1_MONTH"],
|
[
|
||||||
["3", "MONTH_3_ENABLED", "RUB_PRICE_3_MONTHS", "STARS_PRICE_3_MONTHS"],
|
"1",
|
||||||
["6", "MONTH_6_ENABLED", "RUB_PRICE_6_MONTHS", "STARS_PRICE_6_MONTHS"],
|
"MONTH_1_ENABLED",
|
||||||
["12", "MONTH_12_ENABLED", "RUB_PRICE_12_MONTHS", "STARS_PRICE_12_MONTHS"],
|
"RUB_PRICE_1_MONTH",
|
||||||
|
"STARS_PRICE_1_MONTH",
|
||||||
|
"REFERRAL_BONUS_DAYS_INVITER_1_MONTH",
|
||||||
|
"REFERRAL_BONUS_DAYS_REFEREE_1_MONTH",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"3",
|
||||||
|
"MONTH_3_ENABLED",
|
||||||
|
"RUB_PRICE_3_MONTHS",
|
||||||
|
"STARS_PRICE_3_MONTHS",
|
||||||
|
"REFERRAL_BONUS_DAYS_INVITER_3_MONTHS",
|
||||||
|
"REFERRAL_BONUS_DAYS_REFEREE_3_MONTHS",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"6",
|
||||||
|
"MONTH_6_ENABLED",
|
||||||
|
"RUB_PRICE_6_MONTHS",
|
||||||
|
"STARS_PRICE_6_MONTHS",
|
||||||
|
"REFERRAL_BONUS_DAYS_INVITER_6_MONTHS",
|
||||||
|
"REFERRAL_BONUS_DAYS_REFEREE_6_MONTHS",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"12",
|
||||||
|
"MONTH_12_ENABLED",
|
||||||
|
"RUB_PRICE_12_MONTHS",
|
||||||
|
"STARS_PRICE_12_MONTHS",
|
||||||
|
"REFERRAL_BONUS_DAYS_INVITER_12_MONTHS",
|
||||||
|
"REFERRAL_BONUS_DAYS_REFEREE_12_MONTHS",
|
||||||
|
],
|
||||||
];
|
];
|
||||||
const LEGACY_TARIFF_SETTING_KEYS = [
|
const LEGACY_TARIFF_SETTING_KEYS = [
|
||||||
...LEGACY_PERIODS.flatMap((row) => row.slice(1)),
|
...LEGACY_PERIODS.flatMap((row) => row.slice(1)),
|
||||||
@@ -375,7 +404,7 @@
|
|||||||
<code>TRIAL_DURATION_DAYS</code>
|
<code>TRIAL_DURATION_DAYS</code>
|
||||||
</div>
|
</div>
|
||||||
<div class="admin-setting-control">
|
<div class="admin-setting-control">
|
||||||
<input
|
<Input
|
||||||
class="input"
|
class="input"
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
@@ -411,7 +440,7 @@
|
|||||||
<code>TRIAL_TRAFFIC_LIMIT_GB</code>
|
<code>TRIAL_TRAFFIC_LIMIT_GB</code>
|
||||||
</div>
|
</div>
|
||||||
<div class="admin-setting-control">
|
<div class="admin-setting-control">
|
||||||
<input
|
<Input
|
||||||
class="input"
|
class="input"
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
@@ -563,7 +592,7 @@
|
|||||||
onValueChange={handleTrialSquadSelect}
|
onValueChange={handleTrialSquadSelect}
|
||||||
/>
|
/>
|
||||||
{/key}
|
{/key}
|
||||||
<input
|
<Input
|
||||||
class="input"
|
class="input"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={valueForKey("USER_SQUAD_UUIDS", settingsDirty, settingsFieldMap) ||
|
placeholder={valueForKey("USER_SQUAD_UUIDS", settingsDirty, settingsFieldMap) ||
|
||||||
@@ -794,8 +823,10 @@
|
|||||||
<span>{at("tariffs_legacy_enabled", {}, "Enabled")}</span>
|
<span>{at("tariffs_legacy_enabled", {}, "Enabled")}</span>
|
||||||
<span>{at("payment_rub", {}, "RUB")}</span>
|
<span>{at("payment_rub", {}, "RUB")}</span>
|
||||||
<span>{at("payment_stars", {}, "Stars")}</span>
|
<span>{at("payment_stars", {}, "Stars")}</span>
|
||||||
|
<span>{at("tariffs_legacy_ref_inviter", {}, "Inviter")}</span>
|
||||||
|
<span>{at("tariffs_legacy_ref_referee", {}, "Friend")}</span>
|
||||||
</div>
|
</div>
|
||||||
{#each LEGACY_PERIODS as [months, enabledKey, rubKey, starsKey]}
|
{#each LEGACY_PERIODS as [months, enabledKey, rubKey, starsKey, inviterKey, refereeKey]}
|
||||||
<div class="admin-legacy-tariff-row">
|
<div class="admin-legacy-tariff-row">
|
||||||
<strong>{months} {at("months_short", {}, "mo")}</strong>
|
<strong>{months} {at("months_short", {}, "mo")}</strong>
|
||||||
<div class="admin-setting-switch">
|
<div class="admin-setting-switch">
|
||||||
@@ -807,7 +838,7 @@
|
|||||||
<Switch.Thumb class="admin-switch-thumb" />
|
<Switch.Thumb class="admin-switch-thumb" />
|
||||||
</Switch.Root>
|
</Switch.Root>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<Input
|
||||||
class="input"
|
class="input"
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
@@ -815,7 +846,7 @@
|
|||||||
value={valueForKey(rubKey, settingsDirty, settingsFieldMap)}
|
value={valueForKey(rubKey, settingsDirty, settingsFieldMap)}
|
||||||
oninput={(event) => setSetting(rubKey, event.currentTarget.value)}
|
oninput={(event) => setSetting(rubKey, event.currentTarget.value)}
|
||||||
/>
|
/>
|
||||||
<input
|
<Input
|
||||||
class="input"
|
class="input"
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
@@ -823,6 +854,22 @@
|
|||||||
value={valueForKey(starsKey, settingsDirty, settingsFieldMap)}
|
value={valueForKey(starsKey, settingsDirty, settingsFieldMap)}
|
||||||
oninput={(event) => setSetting(starsKey, event.currentTarget.value)}
|
oninput={(event) => setSetting(starsKey, event.currentTarget.value)}
|
||||||
/>
|
/>
|
||||||
|
<Input
|
||||||
|
class="input"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="1"
|
||||||
|
value={valueForKey(inviterKey, settingsDirty, settingsFieldMap)}
|
||||||
|
oninput={(event) => setSetting(inviterKey, event.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
class="input"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="1"
|
||||||
|
value={valueForKey(refereeKey, settingsDirty, settingsFieldMap)}
|
||||||
|
oninput={(event) => setSetting(refereeKey, event.currentTarget.value)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -831,7 +878,7 @@
|
|||||||
<label class="admin-field-label admin-field-label-compact">
|
<label class="admin-field-label admin-field-label-compact">
|
||||||
<span>{at("tariffs_legacy_traffic_packages", {}, "Traffic packages")}</span>
|
<span>{at("tariffs_legacy_traffic_packages", {}, "Traffic packages")}</span>
|
||||||
<small>{at("tariffs_legacy_traffic_hint", {}, "Format: 10:199,50:799")}</small>
|
<small>{at("tariffs_legacy_traffic_hint", {}, "Format: 10:199,50:799")}</small>
|
||||||
<input
|
<Input
|
||||||
class="input"
|
class="input"
|
||||||
type="text"
|
type="text"
|
||||||
value={valueForKey("TRAFFIC_PACKAGES", settingsDirty, settingsFieldMap)}
|
value={valueForKey("TRAFFIC_PACKAGES", settingsDirty, settingsFieldMap)}
|
||||||
@@ -843,7 +890,7 @@
|
|||||||
>{at("tariffs_legacy_stars_traffic_packages", {}, "Traffic packages, Stars")}</span
|
>{at("tariffs_legacy_stars_traffic_packages", {}, "Traffic packages, Stars")}</span
|
||||||
>
|
>
|
||||||
<small>{at("tariffs_legacy_traffic_hint", {}, "Format: 10:199,50:799")}</small>
|
<small>{at("tariffs_legacy_traffic_hint", {}, "Format: 10:199,50:799")}</small>
|
||||||
<input
|
<Input
|
||||||
class="input"
|
class="input"
|
||||||
type="text"
|
type="text"
|
||||||
value={valueForKey("STARS_TRAFFIC_PACKAGES", settingsDirty, settingsFieldMap)}
|
value={valueForKey("STARS_TRAFFIC_PACKAGES", settingsDirty, settingsFieldMap)}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script>
|
<script>
|
||||||
|
import { Input, Textarea } from "$components/ui/index.js";
|
||||||
import { ChevronRight, Languages, Plus, Search, X } from "$components/ui/icons.js";
|
import { ChevronRight, Languages, Plus, Search, X } from "$components/ui/icons.js";
|
||||||
import { AdminBadge, AdminButton, AdminEmptyState } from "$components/patterns/admin/index.js";
|
import { AdminBadge, AdminButton, AdminEmptyState } from "$components/patterns/admin/index.js";
|
||||||
import { getContext, onDestroy, onMount } from "svelte";
|
import { getContext, onDestroy, onMount } from "svelte";
|
||||||
@@ -396,7 +397,7 @@
|
|||||||
|
|
||||||
{#if expanded}
|
{#if expanded}
|
||||||
<div class="admin-translation-locale-body" transition:slide={{ duration: 130 }}>
|
<div class="admin-translation-locale-body" transition:slide={{ duration: 130 }}>
|
||||||
<textarea
|
<Textarea
|
||||||
class="admin-setting-textarea admin-translation-textarea"
|
class="admin-setting-textarea admin-translation-textarea"
|
||||||
rows="3"
|
rows="3"
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
@@ -404,7 +405,7 @@
|
|||||||
value={localeValue(item, lang, dirtyEntry)}
|
value={localeValue(item, lang, dirtyEntry)}
|
||||||
oninput={(event) =>
|
oninput={(event) =>
|
||||||
translationsStore.markDirty(lang, item.key, event.currentTarget.value)}
|
translationsStore.markDirty(lang, item.key, event.currentTarget.value)}
|
||||||
></textarea>
|
/>
|
||||||
<div class="admin-translation-base">
|
<div class="admin-translation-base">
|
||||||
<small>{baseKind(item, lang)}</small>
|
<small>{baseKind(item, lang)}</small>
|
||||||
<span title={baseValue(item, lang)}>{baseValue(item, lang) || "-"}</span>
|
<span title={baseValue(item, lang)}>{baseValue(item, lang) || "-"}</span>
|
||||||
@@ -457,7 +458,7 @@
|
|||||||
<div class="admin-translations-toolbar">
|
<div class="admin-translations-toolbar">
|
||||||
<label class="admin-translations-search">
|
<label class="admin-translations-search">
|
||||||
<Search size={15} />
|
<Search size={15} />
|
||||||
<input
|
<Input
|
||||||
bind:value={search}
|
bind:value={search}
|
||||||
class="input"
|
class="input"
|
||||||
type="text"
|
type="text"
|
||||||
@@ -502,7 +503,7 @@
|
|||||||
addLanguage();
|
addLanguage();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<input
|
<Input
|
||||||
bind:value={newLanguageCode}
|
bind:value={newLanguageCode}
|
||||||
class="input"
|
class="input"
|
||||||
type="text"
|
type="text"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script>
|
<script>
|
||||||
import { Label, Separator, Tabs } from "$components/ui/primitives.js";
|
import { Label, Separator, Tabs } from "$components/ui/primitives.js";
|
||||||
import { Checkbox } from "$components/ui/index.js";
|
import { Checkbox, Input, ScrollArea, Textarea } from "$components/ui/index.js";
|
||||||
import Dialog from "$components/ui/dialog.svelte";
|
import Dialog from "$components/ui/dialog.svelte";
|
||||||
import {
|
import {
|
||||||
AdminBadge,
|
AdminBadge,
|
||||||
@@ -81,11 +81,7 @@
|
|||||||
$: openedUserTelegramProfileLinkKind = openedUser ? userTelegramProfileLinkKind(openedUser) : "";
|
$: openedUserTelegramProfileLinkKind = openedUser ? userTelegramProfileLinkKind(openedUser) : "";
|
||||||
$: openedUserTelegramProfileHint =
|
$: openedUserTelegramProfileHint =
|
||||||
openedUserTelegramProfileLinkKind === "id"
|
openedUserTelegramProfileLinkKind === "id"
|
||||||
? at(
|
? at("user_open_tg_profile_id_hint", {}, "Бот отправит кнопку профиля в Telegram")
|
||||||
"user_open_tg_profile_id_hint",
|
|
||||||
{},
|
|
||||||
"Профиль будет открыт по Telegram ID. Telegram может заблокировать переход из-за настроек приватности пользователя или ограничений клиента."
|
|
||||||
)
|
|
||||||
: at("user_open_tg_profile_hint", {}, "Открыть профиль Telegram");
|
: at("user_open_tg_profile_hint", {}, "Открыть профиль Telegram");
|
||||||
|
|
||||||
$: if (openedUser && userDetailTab === "logs" && !userLogsLoading && !userLogsLoaded) {
|
$: if (openedUser && userDetailTab === "logs" && !userLogsLoading && !userLogsLoaded) {
|
||||||
@@ -189,11 +185,6 @@
|
|||||||
{at("user_open_tg_profile", {}, "Открыть Telegram")}
|
{at("user_open_tg_profile", {}, "Открыть Telegram")}
|
||||||
</AdminButton>
|
</AdminButton>
|
||||||
</div>
|
</div>
|
||||||
{#if openedUserTelegramProfileLinkKind === "id"}
|
|
||||||
<small class="admin-user-telegram-profile-note"
|
|
||||||
>{openedUserTelegramProfileHint}</small
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -552,7 +543,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="admin-user-logs-wrap">
|
<ScrollArea class="admin-user-logs-wrap" maxHeight="min(52vh, 460px)">
|
||||||
{#if userLogsLoading}
|
{#if userLogsLoading}
|
||||||
<AdminTableSkeleton
|
<AdminTableSkeleton
|
||||||
headers={[
|
headers={[
|
||||||
@@ -604,7 +595,7 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</AdminTable>
|
</AdminTable>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</ScrollArea>
|
||||||
|
|
||||||
{#if userLogsLoaded && userLogsTotal > userLogsPageSize}
|
{#if userLogsLoaded && userLogsTotal > userLogsPageSize}
|
||||||
<AdminPagination
|
<AdminPagination
|
||||||
@@ -632,7 +623,7 @@
|
|||||||
<Label.Root class="admin-field-label admin-extend-field">
|
<Label.Root class="admin-field-label admin-extend-field">
|
||||||
<span>{at("user_label_extend", {}, "Продлить подписку")}</span>
|
<span>{at("user_label_extend", {}, "Продлить подписку")}</span>
|
||||||
<div class="admin-extend-control">
|
<div class="admin-extend-control">
|
||||||
<input
|
<Input
|
||||||
class="input"
|
class="input"
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
@@ -662,7 +653,7 @@
|
|||||||
<span>{at("user_premium_override_bonus", {}, "Доп. премиум-трафик, GB")}</span
|
<span>{at("user_premium_override_bonus", {}, "Доп. премиум-трафик, GB")}</span
|
||||||
>
|
>
|
||||||
<small>{at("user_premium_override_bonus_hint", {}, "")}</small>
|
<small>{at("user_premium_override_bonus_hint", {}, "")}</small>
|
||||||
<input
|
<Input
|
||||||
class="input"
|
class="input"
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
@@ -741,7 +732,7 @@
|
|||||||
>{at("user_regular_override_bonus", {}, "Доп. основной трафик, GB")}</span
|
>{at("user_regular_override_bonus", {}, "Доп. основной трафик, GB")}</span
|
||||||
>
|
>
|
||||||
<small>{at("user_regular_override_bonus_hint", {}, "")}</small>
|
<small>{at("user_regular_override_bonus_hint", {}, "")}</small>
|
||||||
<input
|
<Input
|
||||||
class="input"
|
class="input"
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
@@ -837,7 +828,7 @@
|
|||||||
<Label.Root class="admin-field-label admin-extend-field">
|
<Label.Root class="admin-field-label admin-extend-field">
|
||||||
<span>{at("user_traffic_grant_gb", {}, "ГБ к выдаче")}</span>
|
<span>{at("user_traffic_grant_gb", {}, "ГБ к выдаче")}</span>
|
||||||
<div class="admin-extend-control">
|
<div class="admin-extend-control">
|
||||||
<input
|
<Input
|
||||||
class="input"
|
class="input"
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
@@ -869,12 +860,12 @@
|
|||||||
"Поддерживается HTML-разметка Telegram"
|
"Поддерживается HTML-разметка Telegram"
|
||||||
)}</small
|
)}</small
|
||||||
>
|
>
|
||||||
<textarea
|
<Textarea
|
||||||
class="admin-textarea"
|
class="admin-textarea"
|
||||||
rows="3"
|
rows="3"
|
||||||
placeholder={at("user_placeholder_msg", {}, "Текст сообщения")}
|
placeholder={at("user_placeholder_msg", {}, "Текст сообщения")}
|
||||||
bind:value={$usersStore.userMessageDraft}
|
bind:value={$usersStore.userMessageDraft}
|
||||||
></textarea>
|
/>
|
||||||
</Label.Root>
|
</Label.Root>
|
||||||
<div class="admin-message-actions">
|
<div class="admin-message-actions">
|
||||||
<AdminButton
|
<AdminButton
|
||||||
@@ -976,7 +967,9 @@
|
|||||||
onclose={() => usersStore.updateState({ userMessageConfirmOpen: false })}
|
onclose={() => usersStore.updateState({ userMessageConfirmOpen: false })}
|
||||||
class="admin-dialog"
|
class="admin-dialog"
|
||||||
>
|
>
|
||||||
<div class="admin-confirm-message-preview">{userMessageDraft}</div>
|
<ScrollArea class="admin-confirm-message-preview" maxHeight="min(280px, 45vh)">
|
||||||
|
{userMessageDraft}
|
||||||
|
</ScrollArea>
|
||||||
<div class="admin-dialog-actions">
|
<div class="admin-dialog-actions">
|
||||||
<AdminButton onclick={() => usersStore.updateState({ userMessageConfirmOpen: false })}
|
<AdminButton onclick={() => usersStore.updateState({ userMessageConfirmOpen: false })}
|
||||||
>{at("btn_cancel", {}, "Отмена")}</AdminButton
|
>{at("btn_cancel", {}, "Отмена")}</AdminButton
|
||||||
@@ -1166,12 +1159,6 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
}
|
}
|
||||||
.admin-user-telegram-profile-note {
|
|
||||||
display: block;
|
|
||||||
margin-top: 2px;
|
|
||||||
color: var(--admin-dim);
|
|
||||||
line-height: 1.35;
|
|
||||||
}
|
|
||||||
:global(.admin-avatar-dialog) {
|
:global(.admin-avatar-dialog) {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: auto minmax(0, 1fr);
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
@@ -1230,7 +1217,7 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
.admin-user-logs-wrap {
|
:global(.admin-user-logs-wrap) {
|
||||||
min-height: 120px;
|
min-height: 120px;
|
||||||
}
|
}
|
||||||
.admin-user-log-event {
|
.admin-user-log-event {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script>
|
<script>
|
||||||
|
import { Input } from "$components/ui/index.js";
|
||||||
import { Label } from "$components/ui/primitives.js";
|
import { Label } from "$components/ui/primitives.js";
|
||||||
import {
|
import {
|
||||||
AdminBadge,
|
AdminBadge,
|
||||||
@@ -107,7 +108,7 @@
|
|||||||
|
|
||||||
<div class="admin-toolbar admin-toolbar-users">
|
<div class="admin-toolbar admin-toolbar-users">
|
||||||
<div class="admin-toolbar-search">
|
<div class="admin-toolbar-search">
|
||||||
<input
|
<Input
|
||||||
type="search"
|
type="search"
|
||||||
class="input"
|
class="input"
|
||||||
placeholder={at("users_search_placeholder", {}, "ID, @username или email")}
|
placeholder={at("users_search_placeholder", {}, "ID, @username или email")}
|
||||||
|
|||||||
@@ -18,10 +18,10 @@ export function emptyTariffDraft() {
|
|||||||
hwid_device_limit: "",
|
hwid_device_limit: "",
|
||||||
conversion_rate_rub_per_gb: "",
|
conversion_rate_rub_per_gb: "",
|
||||||
periodRows: [
|
periodRows: [
|
||||||
{ months: 1, rub: 150, stars: "" },
|
{ months: 1, rub: 200, stars: "", referral_inviter: 3, referral_referee: 1 },
|
||||||
{ months: 3, rub: 400, stars: "" },
|
{ months: 3, rub: 600, stars: "", referral_inviter: 7, referral_referee: 3 },
|
||||||
{ months: 6, rub: 750, stars: "" },
|
{ months: 6, rub: 1200, stars: "", referral_inviter: 15, referral_referee: 7 },
|
||||||
{ months: 12, rub: 1400, stars: "" },
|
{ months: 12, rub: 2400, stars: "", referral_inviter: 30, referral_referee: 15 },
|
||||||
],
|
],
|
||||||
topupRubRows: [],
|
topupRubRows: [],
|
||||||
topupStarsRows: [],
|
topupStarsRows: [],
|
||||||
@@ -67,6 +67,8 @@ export function draftFromTariff(tariff) {
|
|||||||
months: month,
|
months: month,
|
||||||
rub: tariff.prices_rub?.[String(month)] ?? "",
|
rub: tariff.prices_rub?.[String(month)] ?? "",
|
||||||
stars: tariff.prices_stars?.[String(month)] ?? "",
|
stars: tariff.prices_stars?.[String(month)] ?? "",
|
||||||
|
referral_inviter: tariff.referral_bonus_days_inviter?.[String(month)] ?? "",
|
||||||
|
referral_referee: tariff.referral_bonus_days_referee?.[String(month)] ?? "",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -190,6 +192,8 @@ export function tariffFromDraft(draft) {
|
|||||||
months: parseIntNumber(row.months),
|
months: parseIntNumber(row.months),
|
||||||
rub: parseNumber(row.rub, 0),
|
rub: parseNumber(row.rub, 0),
|
||||||
stars: parseNumber(row.stars, 0),
|
stars: parseNumber(row.stars, 0),
|
||||||
|
referral_inviter: parseIntNumber(row.referral_inviter),
|
||||||
|
referral_referee: parseIntNumber(row.referral_referee),
|
||||||
}))
|
}))
|
||||||
.filter((row) => row.months > 0)
|
.filter((row) => row.months > 0)
|
||||||
.filter((row) => {
|
.filter((row) => {
|
||||||
@@ -204,6 +208,16 @@ export function tariffFromDraft(draft) {
|
|||||||
tariff.prices_stars = Object.fromEntries(
|
tariff.prices_stars = Object.fromEntries(
|
||||||
rows.map((row) => [String(row.months), row.stars || 0])
|
rows.map((row) => [String(row.months), row.stars || 0])
|
||||||
);
|
);
|
||||||
|
tariff.referral_bonus_days_inviter = Object.fromEntries(
|
||||||
|
rows
|
||||||
|
.filter((row) => row.referral_inviter !== null)
|
||||||
|
.map((row) => [String(row.months), row.referral_inviter])
|
||||||
|
);
|
||||||
|
tariff.referral_bonus_days_referee = Object.fromEntries(
|
||||||
|
rows
|
||||||
|
.filter((row) => row.referral_referee !== null)
|
||||||
|
.map((row) => [String(row.months), row.referral_referee])
|
||||||
|
);
|
||||||
const topupPackages = packageSetFromRows(draft.topupRubRows, draft.topupStarsRows, "gb");
|
const topupPackages = packageSetFromRows(draft.topupRubRows, draft.topupStarsRows, "gb");
|
||||||
if (topupPackages) tariff.topup_packages = topupPackages;
|
if (topupPackages) tariff.topup_packages = topupPackages;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -30,12 +30,14 @@
|
|||||||
</Select.Trigger>
|
</Select.Trigger>
|
||||||
<Select.Portal>
|
<Select.Portal>
|
||||||
<Select.Content class="admin-select-content" {sideOffset}>
|
<Select.Content class="admin-select-content" {sideOffset}>
|
||||||
|
<Select.Viewport class="admin-select-viewport">
|
||||||
{#each items as item (item.value)}
|
{#each items as item (item.value)}
|
||||||
<Select.Item value={item.value} label={item.label} class="admin-select-item">
|
<Select.Item value={item.value} label={item.label} class="admin-select-item">
|
||||||
<span>{item.label}</span>
|
<span>{item.label}</span>
|
||||||
<Check size={14} class="admin-select-item-check" />
|
<Check size={14} class="admin-select-item-check" />
|
||||||
</Select.Item>
|
</Select.Item>
|
||||||
{/each}
|
{/each}
|
||||||
|
</Select.Viewport>
|
||||||
</Select.Content>
|
</Select.Content>
|
||||||
</Select.Portal>
|
</Select.Portal>
|
||||||
</Select.Root>
|
</Select.Root>
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<script>
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
export let value = "#000000";
|
||||||
|
export let disabled = false;
|
||||||
|
export let ariaLabel = "";
|
||||||
|
let className = "";
|
||||||
|
export { className as class };
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<input
|
||||||
|
bind:value
|
||||||
|
class={cn("ui-color-input", className)}
|
||||||
|
type="color"
|
||||||
|
{disabled}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
on:input
|
||||||
|
on:change
|
||||||
|
{...$$restProps}
|
||||||
|
/>
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { fade, fly } from "svelte/transition";
|
import { fade, fly } from "svelte/transition";
|
||||||
import Button from "./button.svelte";
|
import Button from "./button.svelte";
|
||||||
|
import ScrollArea from "./scroll-area.svelte";
|
||||||
|
|
||||||
export let open = false;
|
export let open = false;
|
||||||
export let title = "";
|
export let title = "";
|
||||||
@@ -65,7 +66,9 @@
|
|||||||
<X size={18} />
|
<X size={18} />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
<ScrollArea class="dialog-body-scroll" maxHeight="none">
|
||||||
<slot />
|
<slot />
|
||||||
|
</ScrollArea>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<script>
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
export let element = null;
|
||||||
|
export let accept = undefined;
|
||||||
|
export let disabled = false;
|
||||||
|
let className = "";
|
||||||
|
export { className as class };
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<input
|
||||||
|
bind:this={element}
|
||||||
|
class={cn("ui-file-input", className)}
|
||||||
|
type="file"
|
||||||
|
{accept}
|
||||||
|
{disabled}
|
||||||
|
on:change
|
||||||
|
{...$$restProps}
|
||||||
|
/>
|
||||||
@@ -2,11 +2,14 @@ export { default as AttentionDot } from "./attention-dot.svelte";
|
|||||||
export { default as Badge } from "./badge.svelte";
|
export { default as Badge } from "./badge.svelte";
|
||||||
export { default as Button } from "./button.svelte";
|
export { default as Button } from "./button.svelte";
|
||||||
export { default as Checkbox } from "./checkbox.svelte";
|
export { default as Checkbox } from "./checkbox.svelte";
|
||||||
|
export { default as ColorInput } from "./color-input.svelte";
|
||||||
export { default as Dialog } from "./dialog.svelte";
|
export { default as Dialog } from "./dialog.svelte";
|
||||||
|
export { default as FileInput } from "./file-input.svelte";
|
||||||
export { default as Input } from "./input.svelte";
|
export { default as Input } from "./input.svelte";
|
||||||
export { default as LegacyCard } from "./card.svelte";
|
export { default as LegacyCard } from "./card.svelte";
|
||||||
export { default as RadioGroup } from "./radio-group.svelte";
|
export { default as RadioGroup } from "./radio-group.svelte";
|
||||||
export { default as RadioGroupItem } from "./radio-group-item.svelte";
|
export { default as RadioGroupItem } from "./radio-group-item.svelte";
|
||||||
|
export { default as RangeInput } from "./range-input.svelte";
|
||||||
export { default as Skeleton } from "./skeleton.svelte";
|
export { default as Skeleton } from "./skeleton.svelte";
|
||||||
export { default as Spinner } from "./spinner.svelte";
|
export { default as Spinner } from "./spinner.svelte";
|
||||||
export { default as ScrollArea } from "./scroll-area.svelte";
|
export { default as ScrollArea } from "./scroll-area.svelte";
|
||||||
|
|||||||
@@ -1 +1,11 @@
|
|||||||
export { Accordion, Label, Select, Separator, Switch, Tabs, Tooltip } from "bits-ui";
|
export {
|
||||||
|
Accordion,
|
||||||
|
Label,
|
||||||
|
ScrollArea,
|
||||||
|
Select,
|
||||||
|
Separator,
|
||||||
|
Slider,
|
||||||
|
Switch,
|
||||||
|
Tabs,
|
||||||
|
Tooltip,
|
||||||
|
} from "bits-ui";
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
<script>
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
import { Slider } from "./primitives.js";
|
||||||
|
|
||||||
|
export let value = 0;
|
||||||
|
export let min = undefined;
|
||||||
|
export let max = undefined;
|
||||||
|
export let step = undefined;
|
||||||
|
export let disabled = false;
|
||||||
|
export let ariaLabel = "";
|
||||||
|
export let onValueChange = () => {};
|
||||||
|
export let onValueCommit = () => {};
|
||||||
|
let className = "";
|
||||||
|
export { className as class };
|
||||||
|
|
||||||
|
$: sliderValue = Number(value ?? min ?? 0);
|
||||||
|
$: sliderMin = min === undefined ? undefined : Number(min);
|
||||||
|
$: sliderMax = max === undefined ? undefined : Number(max);
|
||||||
|
$: sliderStep = step === undefined ? undefined : Number(step);
|
||||||
|
|
||||||
|
function handleValueChange(next) {
|
||||||
|
value = next;
|
||||||
|
onValueChange(next);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Slider.Root
|
||||||
|
class={cn("ui-range-input", className)}
|
||||||
|
type="single"
|
||||||
|
value={sliderValue}
|
||||||
|
min={sliderMin}
|
||||||
|
max={sliderMax}
|
||||||
|
step={sliderStep}
|
||||||
|
{disabled}
|
||||||
|
onValueChange={handleValueChange}
|
||||||
|
{onValueCommit}
|
||||||
|
{...$$restProps}
|
||||||
|
>
|
||||||
|
<Slider.Range class="ui-range-input__range" />
|
||||||
|
<Slider.Thumb class="ui-range-input__thumb" aria-label={ariaLabel} />
|
||||||
|
</Slider.Root>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:global(.ui-range-input) {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 18px;
|
||||||
|
touch-action: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.ui-range-input::before) {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
left: 0;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--admin-border-strong, var(--border)) 70%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.ui-range-input__range) {
|
||||||
|
position: absolute;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.ui-range-input__thumb) {
|
||||||
|
display: block;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border: 2px solid var(--accent);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--admin-surface, var(--panel));
|
||||||
|
box-shadow: 0 1px 6px rgba(0, 0, 0, 0.24);
|
||||||
|
outline: none;
|
||||||
|
transition:
|
||||||
|
box-shadow 0.14s ease,
|
||||||
|
transform 0.14s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.ui-range-input__thumb:hover) {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.ui-range-input__thumb:focus-visible) {
|
||||||
|
box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 24%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.ui-range-input[data-disabled]) {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,15 +1,86 @@
|
|||||||
<script>
|
<script>
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
import { ScrollArea as ScrollAreaPrimitive } from "./primitives.js";
|
||||||
|
|
||||||
export let maxHeight = "100%";
|
export let maxHeight = "100%";
|
||||||
export let element = null;
|
export let element = null;
|
||||||
|
export let type = "auto";
|
||||||
let className = "";
|
let className = "";
|
||||||
export { className as class };
|
export { className as class };
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<ScrollAreaPrimitive.Root
|
||||||
bind:this={element}
|
class={cn("scroll-area scroll-area--mono", className)}
|
||||||
class={`scroll-area ${className}`.trim()}
|
|
||||||
style={`max-height:${maxHeight};`}
|
style={`max-height:${maxHeight};`}
|
||||||
|
{type}
|
||||||
{...$$restProps}
|
{...$$restProps}
|
||||||
>
|
>
|
||||||
|
<ScrollAreaPrimitive.Viewport bind:ref={element} class="scroll-area__viewport">
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</ScrollAreaPrimitive.Viewport>
|
||||||
|
<ScrollAreaPrimitive.Scrollbar class="scroll-area__scrollbar" orientation="vertical">
|
||||||
|
<ScrollAreaPrimitive.Thumb class="scroll-area__thumb" />
|
||||||
|
</ScrollAreaPrimitive.Scrollbar>
|
||||||
|
<ScrollAreaPrimitive.Scrollbar
|
||||||
|
class="scroll-area__scrollbar scroll-area__scrollbar--horizontal"
|
||||||
|
orientation="horizontal"
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.Thumb class="scroll-area__thumb" />
|
||||||
|
</ScrollAreaPrimitive.Scrollbar>
|
||||||
|
<ScrollAreaPrimitive.Corner class="scroll-area__corner" />
|
||||||
|
</ScrollAreaPrimitive.Root>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:global(.scroll-area) {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.scroll-area__viewport) {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
max-height: inherit;
|
||||||
|
border-radius: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.scroll-area__scrollbar) {
|
||||||
|
z-index: 2;
|
||||||
|
display: flex;
|
||||||
|
touch-action: none;
|
||||||
|
user-select: none;
|
||||||
|
padding: 2px;
|
||||||
|
transition: background 0.14s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.scroll-area__scrollbar[data-orientation="vertical"]) {
|
||||||
|
width: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.scroll-area__scrollbar[data-orientation="horizontal"]) {
|
||||||
|
flex-direction: column;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.scroll-area__thumb) {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--admin-muted, var(--muted)) 34%,
|
||||||
|
var(--admin-border, var(--border))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.scroll-area__thumb:hover) {
|
||||||
|
background: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--admin-muted, var(--muted)) 48%,
|
||||||
|
var(--admin-border, var(--border))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.scroll-area__corner) {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -101636,22 +101636,18 @@ export const DEMO_DATASET = {
|
|||||||
audience: "internal",
|
audience: "internal",
|
||||||
values: {
|
values: {
|
||||||
ru: {
|
ru: {
|
||||||
base: "Пользователь без username: бот отправит вам в Telegram сообщение с кнопкой открытия профиля.",
|
base: "Бот отправит кнопку профиля в Telegram.",
|
||||||
fallback:
|
fallback: "Бот отправит кнопку профиля в Telegram.",
|
||||||
"Пользователь без username: бот отправит вам в Telegram сообщение с кнопкой открытия профиля.",
|
effective: "Бот отправит кнопку профиля в Telegram.",
|
||||||
effective:
|
|
||||||
"Пользователь без username: бот отправит вам в Telegram сообщение с кнопкой открытия профиля.",
|
|
||||||
override: "",
|
override: "",
|
||||||
overridden: false,
|
overridden: false,
|
||||||
updated_at: null,
|
updated_at: null,
|
||||||
updated_by: null,
|
updated_by: null,
|
||||||
},
|
},
|
||||||
en: {
|
en: {
|
||||||
base: "This user has no username: the bot will send you a Telegram message with a profile button.",
|
base: "The bot will send a profile button in Telegram.",
|
||||||
fallback:
|
fallback: "Бот отправит кнопку профиля в Telegram.",
|
||||||
"Пользователь без username: бот отправит вам в Telegram сообщение с кнопкой открытия профиля.",
|
effective: "The bot will send a profile button in Telegram.",
|
||||||
effective:
|
|
||||||
"This user has no username: the bot will send you a Telegram message with a profile button.",
|
|
||||||
override: "",
|
override: "",
|
||||||
overridden: false,
|
overridden: false,
|
||||||
updated_at: null,
|
updated_at: null,
|
||||||
|
|||||||
@@ -103,6 +103,10 @@ function applyDemoEmailAuthUser() {
|
|||||||
user_id: DEMO_DATASET.currentUser?.user_id || DEMO_DATASET.currentUser?.id || 910001,
|
user_id: DEMO_DATASET.currentUser?.user_id || DEMO_DATASET.currentUser?.id || 910001,
|
||||||
telegram_id: null,
|
telegram_id: null,
|
||||||
telegram_linked: false,
|
telegram_linked: false,
|
||||||
|
telegram_notifications_status: "unknown",
|
||||||
|
telegram_notifications_enabled: false,
|
||||||
|
telegram_notifications_need_prompt: false,
|
||||||
|
telegram_notifications_start_link: "https://t.me/preview_bot?start=notifications",
|
||||||
telegram_photo_url: "",
|
telegram_photo_url: "",
|
||||||
avatar_url: "",
|
avatar_url: "",
|
||||||
username: DEMO_DATASET.currentUser?.username || "u3252a8",
|
username: DEMO_DATASET.currentUser?.username || "u3252a8",
|
||||||
@@ -181,6 +185,10 @@ function applyDemoTelegramAuthUser(authData = {}) {
|
|||||||
user_id: adminUser.user_id || adminUser.id || 910001,
|
user_id: adminUser.user_id || adminUser.id || 910001,
|
||||||
telegram_id: telegramId,
|
telegram_id: telegramId,
|
||||||
telegram_linked: true,
|
telegram_linked: true,
|
||||||
|
telegram_notifications_status: "needs_start",
|
||||||
|
telegram_notifications_enabled: false,
|
||||||
|
telegram_notifications_need_prompt: true,
|
||||||
|
telegram_notifications_start_link: "https://t.me/preview_bot?start=notifications",
|
||||||
username,
|
username,
|
||||||
first_name: firstName,
|
first_name: firstName,
|
||||||
last_name: lastName,
|
last_name: lastName,
|
||||||
@@ -258,6 +266,10 @@ function applyDemoTelegramLink(authData = {}) {
|
|||||||
user_id: DEV_MOCK.data.user?.user_id || DEV_MOCK.data.user?.id || 910001,
|
user_id: DEV_MOCK.data.user?.user_id || DEV_MOCK.data.user?.id || 910001,
|
||||||
telegram_id: telegramId,
|
telegram_id: telegramId,
|
||||||
telegram_linked: true,
|
telegram_linked: true,
|
||||||
|
telegram_notifications_status: "needs_start",
|
||||||
|
telegram_notifications_enabled: false,
|
||||||
|
telegram_notifications_need_prompt: true,
|
||||||
|
telegram_notifications_start_link: "https://t.me/preview_bot?start=notifications",
|
||||||
username:
|
username:
|
||||||
authData.username ||
|
authData.username ||
|
||||||
authDemo.telegram_username ||
|
authDemo.telegram_username ||
|
||||||
@@ -1689,17 +1701,25 @@ export async function mockApi(path, options = {}, context = {}) {
|
|||||||
},
|
},
|
||||||
...[
|
...[
|
||||||
["MONTH_1_ENABLED", "bool", true],
|
["MONTH_1_ENABLED", "bool", true],
|
||||||
["RUB_PRICE_1_MONTH", "float", 150],
|
["RUB_PRICE_1_MONTH", "float", 200],
|
||||||
["STARS_PRICE_1_MONTH", "int", 0],
|
["STARS_PRICE_1_MONTH", "int", 0],
|
||||||
|
["REFERRAL_BONUS_DAYS_INVITER_1_MONTH", "int", 3],
|
||||||
|
["REFERRAL_BONUS_DAYS_REFEREE_1_MONTH", "int", 1],
|
||||||
["MONTH_3_ENABLED", "bool", true],
|
["MONTH_3_ENABLED", "bool", true],
|
||||||
["RUB_PRICE_3_MONTHS", "float", 400],
|
["RUB_PRICE_3_MONTHS", "float", 600],
|
||||||
["STARS_PRICE_3_MONTHS", "int", 0],
|
["STARS_PRICE_3_MONTHS", "int", 0],
|
||||||
|
["REFERRAL_BONUS_DAYS_INVITER_3_MONTHS", "int", 7],
|
||||||
|
["REFERRAL_BONUS_DAYS_REFEREE_3_MONTHS", "int", 3],
|
||||||
["MONTH_6_ENABLED", "bool", false],
|
["MONTH_6_ENABLED", "bool", false],
|
||||||
["RUB_PRICE_6_MONTHS", "float", 750],
|
["RUB_PRICE_6_MONTHS", "float", 1200],
|
||||||
["STARS_PRICE_6_MONTHS", "int", 0],
|
["STARS_PRICE_6_MONTHS", "int", 0],
|
||||||
|
["REFERRAL_BONUS_DAYS_INVITER_6_MONTHS", "int", 15],
|
||||||
|
["REFERRAL_BONUS_DAYS_REFEREE_6_MONTHS", "int", 7],
|
||||||
["MONTH_12_ENABLED", "bool", false],
|
["MONTH_12_ENABLED", "bool", false],
|
||||||
["RUB_PRICE_12_MONTHS", "float", 1200],
|
["RUB_PRICE_12_MONTHS", "float", 2400],
|
||||||
["STARS_PRICE_12_MONTHS", "int", 0],
|
["STARS_PRICE_12_MONTHS", "int", 0],
|
||||||
|
["REFERRAL_BONUS_DAYS_INVITER_12_MONTHS", "int", 30],
|
||||||
|
["REFERRAL_BONUS_DAYS_REFEREE_12_MONTHS", "int", 15],
|
||||||
["TRAFFIC_PACKAGES", "string", "10:99,50:399"],
|
["TRAFFIC_PACKAGES", "string", "10:99,50:399"],
|
||||||
["STARS_TRAFFIC_PACKAGES", "string", ""],
|
["STARS_TRAFFIC_PACKAGES", "string", ""],
|
||||||
].map(([key, type, value]) => ({
|
].map(([key, type, value]) => ({
|
||||||
@@ -1983,6 +2003,27 @@ export async function mockApi(path, options = {}, context = {}) {
|
|||||||
applyDemoTelegramLink(body.auth_data || {});
|
applyDemoTelegramLink(body.auth_data || {});
|
||||||
return { ok: true, csrf_token: "local-preview-csrf" };
|
return { ok: true, csrf_token: "local-preview-csrf" };
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
path === "/account/telegram/notifications/probe" &&
|
||||||
|
String(options.method || "").toUpperCase() === "POST"
|
||||||
|
) {
|
||||||
|
DEV_MOCK.data.user = {
|
||||||
|
...(DEV_MOCK.data.user || {}),
|
||||||
|
telegram_notifications_status: "enabled",
|
||||||
|
telegram_notifications_enabled: true,
|
||||||
|
telegram_notifications_need_prompt: false,
|
||||||
|
telegram_notifications_start_link: "https://t.me/preview_bot?start=notifications",
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
telegram_notifications: {
|
||||||
|
ok: true,
|
||||||
|
status: "enabled",
|
||||||
|
enabled: true,
|
||||||
|
start_link: "https://t.me/preview_bot?start=notifications",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
if (path === "/payments" && String(options.method || "").toUpperCase() === "POST") {
|
if (path === "/payments" && String(options.method || "").toUpperCase() === "POST") {
|
||||||
const body = jsonBody(options);
|
const body = jsonBody(options);
|
||||||
if (isDeviceTopupSaleMode(body.sale_mode)) {
|
if (isDeviceTopupSaleMode(body.sale_mode)) {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user