Merge branch 'dev' into patch-1

This commit is contained in:
BADtochka
2026-06-04 16:11:34 +03:00
committed by GitHub
172 changed files with 21250 additions and 3254 deletions
+26 -2
View File
@@ -1,8 +1,8 @@
name: PR checks name: PR checks
# Runs on pull requests into main (typically from dev) and into dev (typically # Runs on pull requests into main (typically from dev) and into dev (typically
# from feature/* branches): lint + format checks and a no-push image build to # from feature/* branches): lint + format checks, a demo settings-manifest
# prove the Docker images still build. # drift guard, and a no-push image build to prove the Docker images still build.
on: on:
pull_request: pull_request:
@@ -56,6 +56,30 @@ jobs:
run: npm run format:check run: npm run format:check
working-directory: frontend working-directory: frontend
demo-manifest:
name: Demo settings manifest in sync
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
cache-dependency-path: backend/requirements.txt
- name: Install backend deps + pytest
run: pip install -r backend/requirements.txt pytest
# Fails if admin_settings_manifest.py changed without regenerating the
# demo snapshot. Fix: `python scripts/export_settings_manifest.py` then
# `npx --prefix frontend prettier --write \
# src/lib/webapp/settingsManifest.generated.json`, and commit the result.
- name: Check demo settings manifest is in sync
run: python -m pytest tests/test_settings_manifest_demo_sync.py -q
build: build:
name: Docker build name: Docker build
uses: ./.github/workflows/_docker-build-push.yml uses: ./.github/workflows/_docker-build-push.yml
+5 -5
View File
@@ -16,7 +16,7 @@ Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи
- Web App / Mini App с входом через Telegram или email; - Web App / Mini App с входом через Telegram или email;
- встроенные инструкции установки в Mini App: личный экран `/install` и публичная ссылка `/s/<token>` для передачи инструкции; - встроенные инструкции установки в Mini App: личный экран `/install` и публичная ссылка `/s/<token>` для передачи инструкции;
- пробный период, промокоды и реферальная программа; - пробный период, промокоды и реферальная программа;
- оплата через YooKassa, FreeKassa, Platega, SeverPay, Wata, CryptoPay, Heleket и Telegram Stars; - оплата через YooKassa, FreeKassa, Platega, SeverPay, Wata, CryptoPay, Heleket, PayKilla и Telegram Stars;
- тикеты поддержки в Web App и внешняя ссылка на поддержку; - тикеты поддержки в Web App и внешняя ссылка на поддержку;
- раздел "Мои устройства" при включенном `MY_DEVICES_SECTION_ENABLED`. - раздел "Мои устройства" при включенном `MY_DEVICES_SECTION_ENABLED`.
@@ -43,8 +43,8 @@ Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи
- [Telegram-авторизация](docs/features/telegram-auth.md) и [вход по email](docs/features/email-login.md) - настройка BotFather/OAuth и SMTP-логина. - [Telegram-авторизация](docs/features/telegram-auth.md) и [вход по email](docs/features/email-login.md) - настройка BotFather/OAuth и SMTP-логина.
- [Поддержка пользователей / тикеты](docs/features/support.md) - тикеты в Mini App, входящий список админки, уведомления, лимиты и внешняя ссылка поддержки. - [Поддержка пользователей / тикеты](docs/features/support.md) - тикеты в Mini App, входящий список админки, уведомления, лимиты и внешняя ссылка поддержки.
- [Темы Web App](docs/features/webapp-themes.md) - кастомные темы, настройка внешнего вида, логотипы, CSS/ассеты и пайплайн создания новой темы. - [Темы Web App](docs/features/webapp-themes.md) - кастомные темы, настройка внешнего вида, логотипы, CSS/ассеты и пайплайн создания новой темы.
- [Миграции](docs/migrations/index.md) - готовые сценарии переноса с других ботов; сейчас описан `remnawave-tg-shop`. - [Миграции](docs/migrations/index.md) - готовые сценарии переноса с `remnawave-tg-shop` и Remnashop.
- [Миграция с remnawave-tg-shop](docs/migrations/remnawave-tg-shop.md) - готовый сценарий для legacy-стека. - [Миграция с remnawave-tg-shop](docs/migrations/remnawave-tg-shop.md) и [Remnashop](docs/migrations/remnashop.md) - сценарии через общий install wizard.
## Совместимость ## Совместимость
@@ -98,10 +98,10 @@ docker compose logs -f backend worker frontend
Для каталога тарифов используется `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).
В compose-примерах `/app/data` монтируется из папки `./data` рядом с `docker-compose.yml`. Заранее создайте каталог и отдайте его пользователю контейнера. Это нужно для сохранения `data/tariffs.json`, каталога тем `data/themes`, кеша логотипа Web App и animated emoji: В compose-примерах `/app/data` монтируется из папки `./data` рядом с `docker-compose.yml`. Заранее создайте каталог и отдайте его пользователю контейнера. Это нужно для сохранения `data/tariffs.json`, каталога тем `data/themes` и кеша логотипа Web App:
```bash ```bash
mkdir -p data/themes data/webapp-logo data/webapp-emoji mkdir -p data/themes data/webapp-logo
touch data/locales-overrides.json touch data/locales-overrides.json
chown -R 10001:10001 data chown -R 10001:10001 data
chmod -R u+rwX data chmod -R u+rwX data
@@ -9,7 +9,7 @@ async def admin_broadcast_route(request: web.Request) -> web.Response:
target = str(payload.get("target") or "all").strip().lower() target = str(payload.get("target") or "all").strip().lower()
if not text: if not text:
return _error(400, "empty_text") return _error(400, "empty_text")
if target not in {"all", "active", "inactive", "expired"}: if target not in {"all", "active", "inactive", "expired", "never"}:
target = "all" target = "all"
queue_manager = get_queue_manager() queue_manager = get_queue_manager()
@@ -24,6 +24,8 @@ async def admin_broadcast_route(request: web.Request) -> web.Response:
user_ids = await user_dal.get_user_ids_without_active_subscription(session) user_ids = await user_dal.get_user_ids_without_active_subscription(session)
elif target == "expired": elif target == "expired":
user_ids = await user_dal.get_user_ids_with_expired_subscription(session) user_ids = await user_dal.get_user_ids_with_expired_subscription(session)
elif target == "never":
user_ids = await user_dal.get_user_ids_without_any_subscription(session)
else: else:
user_ids = await user_dal.get_all_active_user_ids_for_broadcast(session) user_ids = await user_dal.get_all_active_user_ids_for_broadcast(session)
@@ -54,3 +56,20 @@ async def admin_broadcast_route(request: web.Request) -> web.Response:
) )
return _ok({"queued": sent, "failed": failed, "target": target}) return _ok({"queued": sent, "failed": failed, "target": target})
async def admin_broadcast_audience_counts_route(request: web.Request) -> web.Response:
"""Return how many users each broadcast audience currently resolves to."""
_require_admin_user_id(request)
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
counts = {
"all": len(await user_dal.get_all_active_user_ids_for_broadcast(session)),
"active": len(await user_dal.get_user_ids_with_active_subscription(session)),
"inactive": len(await user_dal.get_user_ids_without_active_subscription(session)),
"expired": len(await user_dal.get_user_ids_with_expired_subscription(session)),
"never": len(await user_dal.get_user_ids_without_any_subscription(session)),
}
return _ok({"counts": counts})
+8 -1
View File
@@ -94,6 +94,9 @@ def _serialize_subscription(sub: Subscription) -> Dict[str, Any]:
regular_unlimited_override = bool(getattr(sub, "regular_unlimited_override", False)) regular_unlimited_override = bool(getattr(sub, "regular_unlimited_override", False))
premium_unlimited_override = bool(getattr(sub, "premium_unlimited_override", False)) premium_unlimited_override = bool(getattr(sub, "premium_unlimited_override", False))
premium_limit_bytes = _premium_limit_bytes_from_subscription(sub) premium_limit_bytes = _premium_limit_bytes_from_subscription(sub)
provider = sub.provider
is_trial = str(provider or "").strip().lower() == "trial"
display_label = "Trial" if is_trial else sub.tariff_key
return { return {
"subscription_id": int(sub.subscription_id), "subscription_id": int(sub.subscription_id),
"panel_user_uuid": sub.panel_user_uuid, "panel_user_uuid": sub.panel_user_uuid,
@@ -117,9 +120,13 @@ def _serialize_subscription(sub: Subscription) -> Dict[str, Any]:
"regular_unlimited_override": regular_unlimited_override, "regular_unlimited_override": regular_unlimited_override,
"premium_unlimited_override": premium_unlimited_override, "premium_unlimited_override": premium_unlimited_override,
"premium_is_limited": bool(sub.premium_is_limited), "premium_is_limited": bool(sub.premium_is_limited),
"hwid_device_limit": getattr(sub, "hwid_device_limit", None),
"extra_hwid_devices": int(getattr(sub, "extra_hwid_devices", 0) or 0),
"tariff_key": sub.tariff_key, "tariff_key": sub.tariff_key,
"display_label": display_label,
"is_trial": is_trial,
"auto_renew_enabled": bool(sub.auto_renew_enabled), "auto_renew_enabled": bool(sub.auto_renew_enabled),
"provider": sub.provider, "provider": provider,
"is_throttled": bool(sub.is_throttled), "is_throttled": bool(sub.is_throttled),
} }
@@ -30,6 +30,10 @@ def setup_admin_routes(app: web.Application) -> None:
"/api/admin/users/{user_id:-?\\d+}/regular-traffic-override", "/api/admin/users/{user_id:-?\\d+}/regular-traffic-override",
admin_user_regular_traffic_override_route, admin_user_regular_traffic_override_route,
) )
router.add_post(
"/api/admin/users/{user_id:-?\\d+}/hwid-device-limit",
admin_user_hwid_device_limit_route,
)
router.add_post( router.add_post(
"/api/admin/users/{user_id:-?\\d+}/traffic-grant", "/api/admin/users/{user_id:-?\\d+}/traffic-grant",
admin_user_traffic_grant_route, admin_user_traffic_grant_route,
@@ -57,6 +61,7 @@ def setup_admin_routes(app: web.Application) -> None:
router.add_post("/api/admin/support/tickets/{id:\\d+}/read", admin_support_ticket_read_route) router.add_post("/api/admin/support/tickets/{id:\\d+}/read", admin_support_ticket_read_route)
router.add_get("/api/admin/support/stats", admin_support_stats_route) router.add_get("/api/admin/support/stats", admin_support_stats_route)
router.add_get("/api/admin/broadcast/audience-counts", admin_broadcast_audience_counts_route)
router.add_post("/api/admin/broadcast", admin_broadcast_route) router.add_post("/api/admin/broadcast", admin_broadcast_route)
router.add_post("/api/admin/sync", admin_sync_route) router.add_post("/api/admin/sync", admin_sync_route)
+2 -17
View File
@@ -136,10 +136,6 @@ def _favicon_digest(url: str) -> Optional[str]:
return match.group(1) if match else None return match.group(1) if match else None
def _emoji_to_codepoints(value: str) -> str:
return "_".join(f"{ord(char):x}" for char in str(value or "").strip())
def prune_unused_appearance_assets(settings: Settings) -> None: def prune_unused_appearance_assets(settings: Settings) -> None:
keep_logos = { keep_logos = {
filename filename
@@ -156,15 +152,6 @@ def prune_unused_appearance_assets(settings: Settings) -> None:
] ]
if digest if digest
} }
keep_emoji_prefixes = set()
if (
getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False)
and str(getattr(settings, "WEBAPP_LOGO_EMOJI_FONT", "") or "").strip()
== "noto-color-animated"
):
codepoints = _emoji_to_codepoints(getattr(settings, "WEBAPP_LOGO_EMOJI", ""))
if codepoints:
keep_emoji_prefixes.add(f"{codepoints}.512.")
for path in WEBAPP_UPLOADED_LOGO_DIR.glob("logo-*"): for path in WEBAPP_UPLOADED_LOGO_DIR.glob("logo-*"):
if path.is_file() and path.name not in keep_logos: if path.is_file() and path.name not in keep_logos:
@@ -184,10 +171,9 @@ def prune_unused_appearance_assets(settings: Settings) -> None:
except OSError: except OSError:
logger.warning("Failed to remove unused webapp favicon set %s", path, exc_info=True) logger.warning("Failed to remove unused webapp favicon set %s", path, exc_info=True)
# Emoji logos were removed; purge any leftover animated-emoji cache files.
for path in WEBAPP_EMOJI_CACHE_DIR.glob("*.512.*"): for path in WEBAPP_EMOJI_CACHE_DIR.glob("*.512.*"):
if path.is_file() and not any( if path.is_file():
path.name.startswith(prefix) for prefix in keep_emoji_prefixes
):
try: try:
path.unlink() path.unlink()
except OSError: except OSError:
@@ -389,7 +375,6 @@ async def admin_appearance_logo_upload_route(request: web.Request) -> web.Respon
request, request,
{ {
"WEBAPP_LOGO_URL": logo_url, "WEBAPP_LOGO_URL": logo_url,
"WEBAPP_LOGO_USE_EMOJI": False,
**( **(
{"WEBAPP_LOGO_FAVICON_URL": favicon_payload["favicon_url"]} {"WEBAPP_LOGO_FAVICON_URL": favicon_payload["favicon_url"]}
if favicon_payload.get("favicon_url") if favicon_payload.get("favicon_url")
+108 -1
View File
@@ -745,6 +745,24 @@ def _user_search_condition(query: str):
return or_(*conditions) return or_(*conditions)
def _serialize_trial_summary(user: User, trial_subs: List[Subscription]) -> Dict[str, Any]:
first_trial_sub = trial_subs[0] if trial_subs else None
latest_trial_sub = trial_subs[-1] if trial_subs else None
first_start = getattr(first_trial_sub, "start_date", None)
latest_start = getattr(latest_trial_sub, "start_date", None)
latest_end = getattr(latest_trial_sub, "end_date", None)
reset_at = getattr(user, "trial_eligibility_reset_at", None)
return {
"used": bool(trial_subs),
"count": len(trial_subs),
"first_activated_at": first_start.isoformat() if first_start else None,
"latest_activated_at": latest_start.isoformat() if latest_start else None,
"latest_end_date": latest_end.isoformat() if latest_end else None,
"active": bool(latest_trial_sub and getattr(latest_trial_sub, "is_active", False)),
"last_reset_at": reset_at.isoformat() if reset_at else None,
}
async def admin_user_detail_route(request: web.Request) -> web.Response: async def admin_user_detail_route(request: web.Request) -> web.Response:
_require_admin_user_id(request) _require_admin_user_id(request)
target_id = int(request.match_info["user_id"]) target_id = int(request.match_info["user_id"])
@@ -764,6 +782,15 @@ async def admin_user_detail_route(request: web.Request) -> web.Response:
.limit(20) .limit(20)
) )
latest_subs = (await session.execute(latest_subs_stmt)).scalars().all() latest_subs = (await session.execute(latest_subs_stmt)).scalars().all()
trial_subs_stmt = (
select(Subscription)
.where(
Subscription.user_id == target_id,
sa_func.lower(sa_func.coalesce(Subscription.provider, "")) == "trial",
)
.order_by(Subscription.start_date.asc().nullslast(), Subscription.end_date.asc())
)
trial_subs = (await session.execute(trial_subs_stmt)).scalars().all()
total_paid = await payment_dal.get_user_total_paid(session, target_id) total_paid = await payment_dal.get_user_total_paid(session, target_id)
recent_payments_stmt = ( recent_payments_stmt = (
select(Payment) select(Payment)
@@ -830,12 +857,14 @@ async def admin_user_detail_route(request: web.Request) -> web.Response:
serialized_inviter = ( serialized_inviter = (
_serialize_admin_user_with_avatar(inviter, avatar_keys) if inviter is not None else None _serialize_admin_user_with_avatar(inviter, avatar_keys) if inviter is not None else None
) )
trial_payload = _serialize_trial_summary(user, trial_subs)
return _ok( return _ok(
{ {
"user": serialized_user, "user": serialized_user,
"active_subscription": _serialize_subscription(active_sub) if active_sub else None, "active_subscription": _serialize_subscription(active_sub) if active_sub else None,
"subscriptions": [_serialize_subscription(s) for s in (latest_subs or [])], "subscriptions": [_serialize_subscription(s) for s in (latest_subs or [])],
"trial": trial_payload,
"total_paid": float(total_paid), "total_paid": float(total_paid),
"recent_payments": [_serialize_payment(p) for p in recent_payments], "recent_payments": [_serialize_payment(p) for p in recent_payments],
"log_count": int(log_count or 0), "log_count": int(log_count or 0),
@@ -1318,6 +1347,78 @@ async def admin_user_regular_traffic_override_route(request: web.Request) -> web
return _ok({"subscription": _serialize_subscription(active)}) return _ok({"subscription": _serialize_subscription(active)})
async def admin_user_hwid_device_limit_route(request: web.Request) -> web.Response:
"""Override the user's base HWID device limit.
``hwid_device_limit == 0`` means unlimited; ``NULL`` means the tariff/.env
default is used. Purchased extra devices remain tracked separately and are
added when syncing the effective panel limit.
"""
actor_id = _require_admin_user_id(request)
target_id = int(request.match_info["user_id"])
settings: Settings = request.app["settings"]
payload = await _read_json(request)
unlimited = bool(payload.get("unlimited"))
use_default = bool(payload.get("use_default") or payload.get("reset_to_default"))
limit_raw = payload.get("hwid_device_limit", payload.get("limit"))
if unlimited:
hwid_device_limit: Optional[int] = 0
elif use_default or limit_raw is None or limit_raw == "":
hwid_device_limit = None
else:
try:
hwid_device_limit = int(limit_raw)
except (TypeError, ValueError):
return _error(
400,
"invalid_hwid_device_limit",
"hwid_device_limit must be a non-negative integer",
)
if hwid_device_limit < 0 or hwid_device_limit > 1_000_000:
return _error(
400,
"invalid_hwid_device_limit",
"hwid_device_limit must be an integer from 0 to 1000000",
)
subscription_service = request.app.get("subscription_service")
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
active = await subscription_dal.get_active_subscription_by_user_id(session, target_id)
if not active:
return _error(404, "no_active_subscription")
active.hwid_device_limit = hwid_device_limit
effective_limit = None
if subscription_service is not None:
effective_limit = await subscription_service.sync_hwid_device_limit_to_panel(
session, target_id
)
await message_log_dal.create_message_log(
session,
{
"user_id": actor_id,
"event_type": "admin_hwid_device_limit_webapp",
"content": (
f"hwid_device_limit={hwid_device_limit!r} "
f"effective_hwid_device_limit={effective_limit!r}"
),
"is_admin_event": True,
"target_user_id": target_id,
},
)
await session.commit()
await session.refresh(active)
await _invalidate_after_admin_user_mutation(settings, target_id)
return _ok({"subscription": _serialize_subscription(active)})
async def admin_user_traffic_grant_route(request: web.Request) -> web.Response: async def admin_user_traffic_grant_route(request: web.Request) -> web.Response:
"""Credit regular or premium traffic to a user without a payment. """Credit regular or premium traffic to a user without a payment.
@@ -1414,6 +1515,8 @@ async def admin_user_extend_route(request: web.Request) -> web.Response:
return _error(400, "invalid_days") return _error(400, "invalid_days")
if days <= 0: if days <= 0:
return _error(400, "invalid_days") return _error(400, "invalid_days")
extend_hwid_devices = payload.get("extend_hwid_devices")
extend_hwid_devices = True if extend_hwid_devices is None else bool(extend_hwid_devices)
subscription_service = request.app.get("subscription_service") subscription_service = request.app.get("subscription_service")
if subscription_service is None: if subscription_service is None:
@@ -1426,6 +1529,7 @@ async def admin_user_extend_route(request: web.Request) -> web.Response:
target_id, target_id,
days, days,
"admin_extend_subscription_webapp", "admin_extend_subscription_webapp",
extend_hwid_devices=extend_hwid_devices,
) )
if not new_end: if not new_end:
await session.rollback() await session.rollback()
@@ -1436,7 +1540,10 @@ async def admin_user_extend_route(request: web.Request) -> web.Response:
{ {
"user_id": actor_id, "user_id": actor_id,
"event_type": "admin_extend_subscription_webapp", "event_type": "admin_extend_subscription_webapp",
"content": f"+{days}d -> {new_end.isoformat()}", "content": (
f"+{days}d -> {new_end.isoformat()} "
f"(hwid={'yes' if extend_hwid_devices else 'no'})"
),
"is_admin_event": True, "is_admin_event": True,
"target_user_id": target_id, "target_user_id": target_id,
}, },
@@ -13,9 +13,6 @@ WEBAPP_APPEARANCE_SETTING_KEYS = frozenset(
{ {
"WEBAPP_TITLE", "WEBAPP_TITLE",
"WEBAPP_LOGO_URL", "WEBAPP_LOGO_URL",
"WEBAPP_LOGO_USE_EMOJI",
"WEBAPP_LOGO_EMOJI",
"WEBAPP_LOGO_EMOJI_FONT",
"WEBAPP_FAVICON_URL", "WEBAPP_FAVICON_URL",
"WEBAPP_FAVICON_USE_CUSTOM", "WEBAPP_FAVICON_USE_CUSTOM",
"WEBAPP_LOGO_FAVICON_URL", "WEBAPP_LOGO_FAVICON_URL",
+131 -27
View File
@@ -65,7 +65,6 @@ SETTINGS_MANIFEST: List[SettingField] = [
"SUPPORT_LINK", "url", "general", "Ссылка поддержки", "Куда вести пользователей за помощью." "SUPPORT_LINK", "url", "general", "Ссылка поддержки", "Куда вести пользователей за помощью."
), ),
SettingField("SERVER_STATUS_URL", "url", "general", "Ссылка на статус серверов"), SettingField("SERVER_STATUS_URL", "url", "general", "Ссылка на статус серверов"),
SettingField("TERMS_OF_SERVICE_URL", "url", "general", "Условия использования"),
SettingField("PRIVACY_POLICY_URL", "url", "general", "Политика конфиденциальности"), SettingField("PRIVACY_POLICY_URL", "url", "general", "Политика конфиденциальности"),
SettingField("USER_AGREEMENT_URL", "url", "general", "Пользовательское соглашение"), SettingField("USER_AGREEMENT_URL", "url", "general", "Пользовательское соглашение"),
SettingField("DISABLE_WELCOME_MESSAGE", "bool", "general", "Скрыть приветствие /start"), SettingField("DISABLE_WELCOME_MESSAGE", "bool", "general", "Скрыть приветствие /start"),
@@ -77,14 +76,20 @@ SETTINGS_MANIFEST: List[SettingField] = [
"int", "int",
"general", "general",
"ID обязательного канала", "ID обязательного канала",
"Telegram ID канала, в котором нужно состоять.", (
"Telegram ID канала для проверки подписки. Если бот видит канал, "
"ссылка кнопки будет получена автоматически."
),
), ),
SettingField( SettingField(
"REQUIRED_CHANNEL_LINK", "REQUIRED_CHANNEL_LINK",
"string", "string",
"general", "general",
"Ссылка на канал", "Ссылка на канал",
"Имя пользователя или invite-link.", (
"Необязательно: публичный @username или invite-link, "
"если ссылку нельзя получить по ID канала."
),
), ),
SettingField( SettingField(
"PANEL_API_URL", "PANEL_API_URL",
@@ -101,6 +106,42 @@ SETTINGS_MANIFEST: List[SettingField] = [
"Секретный ключ API панели.", "Секретный ключ API панели.",
secret=True, secret=True,
), ),
SettingField(
"PANEL_API_TOTAL_TIMEOUT_SECONDS",
"float",
"remnawave",
"Panel API total timeout",
"Maximum total time for one Remnawave API request, in seconds.",
optional=False,
min=1,
),
SettingField(
"PANEL_API_CONNECT_TIMEOUT_SECONDS",
"float",
"remnawave",
"Panel API connect timeout",
"Maximum time to get or open a Remnawave API connection, in seconds.",
optional=False,
min=1,
),
SettingField(
"PANEL_API_SOCK_CONNECT_TIMEOUT_SECONDS",
"float",
"remnawave",
"Panel API socket connect timeout",
"Maximum TCP/TLS connection time for Remnawave API, in seconds.",
optional=False,
min=1,
),
SettingField(
"PANEL_API_SOCK_READ_TIMEOUT_SECONDS",
"float",
"remnawave",
"Panel API socket read timeout",
"Maximum time to wait for response data from Remnawave API, in seconds.",
optional=False,
min=1,
),
SettingField( SettingField(
"PANEL_WEBHOOK_SECRET", "PANEL_WEBHOOK_SECRET",
"string", "string",
@@ -139,27 +180,7 @@ SETTINGS_MANIFEST: List[SettingField] = [
SettingField( SettingField(
"WEBAPP_PRIMARY_COLOR", "color", "appearance", "Основной цвет", placeholder="#00fe7a" "WEBAPP_PRIMARY_COLOR", "color", "appearance", "Основной цвет", placeholder="#00fe7a"
), ),
SettingField("WEBAPP_LOGO_USE_EMOJI", "bool", "appearance", "Использовать эмоджи-логотип"),
SettingField("WEBAPP_LOGO_URL", "url", "appearance", "URL логотипа"), SettingField("WEBAPP_LOGO_URL", "url", "appearance", "URL логотипа"),
SettingField("WEBAPP_LOGO_EMOJI", "string", "appearance", "Эмоджи-логотип", placeholder="🫥"),
SettingField(
"WEBAPP_LOGO_EMOJI_FONT",
"string",
"appearance",
"Шрифт эмоджи-логотипа",
"Выберите шрифт для отображения эмодзи-логотипа",
choices=(
("system", "Системный (по умолчанию)"),
("noto-color", "Noto Color Emoji"),
("noto-color-animated", "Noto Color Emoji Animated"),
("noto-emoji", "Noto Emoji"),
("twemoji", "Twitter Emoji"),
("openmoji", "OpenMoji"),
("apple", "Apple Color Emoji (local)"),
("segoe", "Segoe UI Emoji (local)"),
("noto-local", "Noto Emoji (local)"),
),
),
SettingField( SettingField(
"WEBAPP_FAVICON_USE_CUSTOM", "WEBAPP_FAVICON_USE_CUSTOM",
"bool", "bool",
@@ -348,7 +369,7 @@ SETTINGS_MANIFEST: List[SettingField] = [
"string", "string",
"payments", "payments",
"Порядок методов оплаты", "Порядок методов оплаты",
"Через запятую: severpay,freekassa,yookassa,platega,stars,cryptopay,heleket", "Через запятую: severpay,freekassa,yookassa,platega,stars,cryptopay,heleket,paykilla",
subsection="common", subsection="common",
), ),
# ─── Trial ───────────────────────────────────────────────────── # ─── Trial ─────────────────────────────────────────────────────
@@ -386,6 +407,18 @@ SETTINGS_MANIFEST: List[SettingField] = [
optional=False, optional=False,
subsection="trial", subsection="trial",
), ),
SettingField(
"TRIAL_WITHOUT_TELEGRAM_ENABLED",
"bool",
"pricing",
"Триал без Telegram",
(
"Если выключено, email-only пользователю нужно привязать Telegram для "
"активации триала. Disposable email домены всегда требуют Telegram."
),
optional=False,
subsection="trial",
),
SettingField( SettingField(
"TRIAL_SQUAD_UUIDS", "TRIAL_SQUAD_UUIDS",
"string", "string",
@@ -396,12 +429,82 @@ SETTINGS_MANIFEST: List[SettingField] = [
), ),
# ─── Referral program ────────────────────────────────────────── # ─── Referral program ──────────────────────────────────────────
SettingField( SettingField(
"REFERRAL_ONE_BONUS_PER_REFEREE", "bool", "referral", "Один бонус на приглашённого" "REFERRAL_ONE_BONUS_PER_REFEREE",
"bool",
"pricing",
"Один бонус на приглашённого",
subsection="referral",
), ),
SettingField( SettingField(
"REFERRAL_WELCOME_BONUS_DAYS", "int", "referral", "Приветственный бонус (дней)", min=0 "REFERRAL_WELCOME_BONUS_DAYS",
"int",
"pricing",
"Приветственный бонус (дней)",
min=0,
subsection="referral",
),
SettingField(
"REFERRAL_WELCOME_BONUS_WITHOUT_TELEGRAM_ENABLED",
"bool",
"pricing",
"Приветственный бонус без Telegram",
(
"Если выключено, email-only пользователю нужно привязать Telegram для получения "
"реферального приветственного бонуса. Disposable email домены всегда требуют Telegram."
),
subsection="referral",
),
SettingField(
"LEGACY_REFS",
"bool",
"pricing",
"Поддержка старых ref-ссылок",
subsection="referral",
),
SettingField(
"DISPOSABLE_EMAIL_DOMAINS",
"text",
"pricing",
"Disposable email домены",
(
"Домены по одному на строку или через запятую. Пользователи без Telegram с такими "
"email не смогут получить trial или реферальный приветственный бонус."
),
placeholder="mailinator.com\ntemp-mail.org\nyopmail.com",
subsection="referral",
),
SettingField(
"MIGRATION_REMNASHOP_REFERRAL_CODE_COMPAT_ENABLED",
"bool",
"migrations",
"Старые ref-ссылки Remnashop",
"Принимать импортированные ref-коды Remnashop вместе с текущими кодами пользователей.",
subsection="Remnashop",
),
SettingField(
"MIGRATION_REMNASHOP_PROMO_CODE_COMPAT_ENABLED",
"bool",
"migrations",
"Старые промокоды Remnashop",
"Пробовать точное совпадение промокода перед обычной uppercase-нормализацией.",
subsection="Remnashop",
),
SettingField(
"MIGRATION_REMNASHOP_IMPORTED_AT",
"string",
"migrations",
"Последний импорт Remnashop",
"Заполняется скриптом импорта. Можно очистить, если отметка больше не нужна.",
subsection="Remnashop",
),
SettingField(
"MIGRATION_REMNASHOP_NOTES",
"text",
"migrations",
"Заметки по миграции Remnashop",
"Внутренние заметки оператора по перенесенному инстансу.",
subsection="Remnashop",
), ),
SettingField("LEGACY_REFS", "bool", "referral", "Поддержка старых ref-ссылок"),
# ─── Notifications ───────────────────────────────────────────── # ─── Notifications ─────────────────────────────────────────────
SettingField( SettingField(
"SUBSCRIPTION_NOTIFICATIONS_ENABLED", "SUBSCRIPTION_NOTIFICATIONS_ENABLED",
@@ -734,6 +837,7 @@ def manifest_payload() -> List[dict]:
"devices": 10, "devices": 10,
"subscription_guides": 10, "subscription_guides": 10,
"system": 12, "system": 12,
"migrations": 13,
} }
exclusive_map = { exclusive_map = {
key: opposite key: opposite
+124
View File
@@ -318,6 +318,81 @@
box-shadow: inset 0 0 0 1px #ffffff; box-shadow: inset 0 0 0 1px #ffffff;
} }
/* ---------- Admin controls: range sliders and sortable rows ---------- */
.theme-key-ascii .ui-range-input {
height: 20px;
}
.theme-key-ascii .ui-range-input::before {
height: 8px;
border: 1px solid #ffffff;
background: #000000;
}
.theme-key-ascii .ui-range-input__range {
height: 8px;
background: #ffffff;
}
.theme-key-ascii .ui-range-input__thumb {
width: 16px;
height: 18px;
border: 1px solid #ffffff;
border-radius: 0;
background: #000000;
box-shadow: none;
transition: none;
}
.theme-key-ascii .ui-range-input__thumb:hover,
.theme-key-ascii .ui-range-input__thumb:focus-visible {
background: #ffffff;
color: #000000;
box-shadow: 0 0 0 1px #000000;
}
.theme-key-ascii .ui-sortable {
--sortable-drop-line: #ffffff;
--sortable-drop-soft: rgba(255, 255, 255, 0.08);
gap: 6px;
}
.theme-key-ascii .ui-sortable-item.is-dragging {
opacity: 0.72;
}
.theme-key-ascii .ui-sortable-item.is-drop-target {
outline: 1px dashed #ffffff;
outline-offset: 2px;
background: rgba(255, 255, 255, 0.08);
box-shadow: none;
}
.theme-key-ascii .ui-sortable-item.is-drop-target::before {
top: -5px;
height: 1px;
border-radius: 0;
background: #ffffff;
box-shadow: none;
}
.theme-key-ascii .ui-sortable-handle {
align-self: center;
height: 28px;
border: 1px solid #ffffff;
background: #000000;
color: #ffffff;
box-shadow: none;
}
.theme-key-ascii .ui-sortable-handle:hover,
.theme-key-ascii .ui-sortable-handle:focus-visible,
.theme-key-ascii .ui-sortable-handle:active {
background: #ffffff;
color: #000000;
}
/* ---------- New webapp surfaces: support, purchase info, password login ---------- */ /* ---------- New webapp surfaces: support, purchase info, password login ---------- */
.theme-key-ascii .trial-offer-card, .theme-key-ascii .trial-offer-card,
@@ -1104,6 +1179,7 @@ body:has(.theme-key-ascii) .install-platform-item[data-selected] svg {
.admin-cn-card-skeleton--tall, .admin-cn-card-skeleton--tall,
.admin-input, .admin-textarea, .admin-btn, .admin-chip, .admin-input, .admin-textarea, .admin-btn, .admin-chip,
.admin-tabs-trigger, .admin-tabs-list, .admin-tabs-trigger, .admin-tabs-list,
.ui-range-input__thumb, .ui-sortable-item, .ui-sortable-handle,
.admin-nav-item, .admin-revenue-period-btn, .admin-mobile-toggle, .admin-nav-item, .admin-revenue-period-btn, .admin-mobile-toggle,
.admin-header, .admin-sidebar, .admin-sidebar-brand, .admin-header, .admin-sidebar, .admin-sidebar-brand,
.admin-dialog, .admin-dialog,
@@ -1243,3 +1319,51 @@ body:has(.theme-key-ascii) .install-platform-item[data-selected] svg {
.theme-key-ascii table tbody tr:hover td { .theme-key-ascii table tbody tr:hover td {
color: #ffffff; color: #ffffff;
} }
/* ============================================================
* Newer webapp surfaces: telegram banner, traffic/referral
* dropdowns, login language picker. Flatten the accent pills,
* rounded badges and colored gradients these ship with so they
* read as plain console boxes.
* ============================================================ */
/* Telegram notifications banner: the .card chrome is already
* flattened above; only the rounded, color-tinted icon badge needs
* squaring off (the Send glyph itself is whitened by the global rule). */
.theme-key-ascii .telegram-notifications-icon {
border: 1px solid #ffffff;
border-radius: 0;
background: #000000;
color: #ffffff;
}
/* Premium-server / referral-tariff dropdown help glyph: drop the
* pill background in every state (the accent maps to white here,
* which would otherwise paint a white blob behind the icon). */
.theme-key-ascii .premium-server-help-icon,
.theme-key-ascii .premium-server-dropdown summary:hover .premium-server-help-icon,
.theme-key-ascii .premium-server-dropdown[open] .premium-server-help-icon,
.theme-key-ascii .referral-tariff-dropdown summary:hover .premium-server-help-icon,
.theme-key-ascii .referral-tariff-dropdown[open] .premium-server-help-icon {
padding: 0;
border-radius: 0;
background: transparent;
color: #ffffff;
}
/* The check on the selected language sits on a solid white row, so a
* white glyph would vanish — invert it to black to keep it readable. */
.theme-key-ascii .language-select-item[data-selected] .language-select-item-check {
color: #000000 !important;
stroke: #000000 !important;
}
/* Login-screen language trigger: square the rounded chip. */
.theme-key-ascii .auth-language-trigger {
border-radius: 0;
}
/* Render flag emoji as monochrome glyphs to stay in the console palette. */
.theme-key-ascii .emoji-flag {
filter: grayscale(1) contrast(1.05);
}
+1 -1
View File
@@ -9,7 +9,7 @@
"use_primary_accent": false, "use_primary_accent": false,
"use_in_admin": true, "use_in_admin": true,
"css_file": "style.css", "css_file": "style.css",
"assets_version": 4, "assets_version": 6,
"tokens": { "tokens": {
"color_scheme": "dark", "color_scheme": "dark",
"style_preset": "ascii" "style_preset": "ascii"
@@ -204,3 +204,56 @@ body:has(.theme-key-light) .install-platform-item[data-selected] {
.theme-key-light .install-loading .ui-spinner { .theme-key-light .install-loading .ui-spinner {
color: color-mix(in srgb, var(--accent) 55%, #000000); color: color-mix(in srgb, var(--accent) 55%, #000000);
} }
/* Admin controls: range sliders and sortable rows */
.theme-key-light .ui-range-input::before {
background: rgba(15, 23, 42, 0.12);
}
.theme-key-light .ui-range-input__range {
background: color-mix(in srgb, var(--accent) 70%, #0f172a);
}
.theme-key-light .ui-range-input__thumb {
border-color: color-mix(in srgb, var(--accent) 68%, #0f172a);
background: #ffffff;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.18);
}
.theme-key-light .ui-range-input__thumb:focus-visible {
box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 18%, transparent);
}
.theme-key-light .ui-sortable-handle {
border-radius: 6px;
color: color-mix(in srgb, var(--admin-muted) 82%, var(--admin-text));
}
.theme-key-light .ui-sortable-handle:hover,
.theme-key-light .ui-sortable-handle:focus-visible {
background: rgba(15, 23, 42, 0.055);
color: color-mix(in srgb, var(--accent) 58%, #0f172a);
}
.theme-key-light .ui-sortable {
--sortable-drop-line: color-mix(in srgb, var(--accent) 64%, #0f172a);
}
.theme-key-light .ui-sortable-item.is-drop-target {
background: color-mix(in srgb, var(--accent) 8%, #ffffff);
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--accent) 26%, transparent),
0 10px 22px color-mix(in srgb, var(--accent) 7%, transparent);
}
.theme-key-light .ui-sortable-item.is-drop-target::before {
background: var(--sortable-drop-line);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 14%, transparent);
}
/* Telegram notifications banner: keep the warm warning tint but swap the
* dark-theme inset bevel for the soft drop shadow the other light cards use. */
.theme-key-light .telegram-notifications-card {
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.07);
}
+1 -1
View File
@@ -9,7 +9,7 @@
"use_primary_accent": true, "use_primary_accent": true,
"use_in_admin": true, "use_in_admin": true,
"css_file": "style.css", "css_file": "style.css",
"assets_version": 3, "assets_version": 5,
"tokens": { "tokens": {
"color_scheme": "light" "color_scheme": "light"
} }
@@ -127,6 +127,7 @@
.theme-key-windows95 svg.lucide-file-text, .theme-key-windows95 svg.lucide-file-text,
.theme-key-windows95 svg.lucide-gift, .theme-key-windows95 svg.lucide-gift,
.theme-key-windows95 svg.lucide-globe-2, .theme-key-windows95 svg.lucide-globe-2,
.theme-key-windows95 svg.lucide-grip-vertical,
.theme-key-windows95 svg.lucide-home, .theme-key-windows95 svg.lucide-home,
.theme-key-windows95 svg.lucide-house, .theme-key-windows95 svg.lucide-house,
.theme-key-windows95 svg.lucide-info, .theme-key-windows95 svg.lucide-info,
@@ -232,6 +233,10 @@
--win95-button-icon: var(--win95-icon-globe); --win95-button-icon: var(--win95-icon-globe);
} }
.theme-key-windows95 svg.lucide-grip-vertical {
--win95-button-icon: var(--win95-icon-sliders);
}
.theme-key-windows95 svg.lucide-file-text { .theme-key-windows95 svg.lucide-file-text {
--win95-button-icon: var(--win95-icon-file-text); --win95-button-icon: var(--win95-icon-file-text);
} }
@@ -375,6 +380,7 @@
svg.lucide-file-text, svg.lucide-file-text,
svg.lucide-gift, svg.lucide-gift,
svg.lucide-globe-2, svg.lucide-globe-2,
svg.lucide-grip-vertical,
svg.lucide-home, svg.lucide-home,
svg.lucide-house, svg.lucide-house,
svg.lucide-info, svg.lucide-info,
@@ -1171,6 +1177,96 @@ body:has(.theme-key-windows95) .install-platform-item[data-selected] {
opacity: 0.52; opacity: 0.52;
} }
/* Admin controls: range sliders and sortable rows */
.theme-key-windows95 .ui-range-input {
height: 22px;
}
.theme-key-windows95 .ui-range-input::before {
height: 8px;
border: 2px solid;
border-color: #404040 #ffffff #ffffff #404040;
border-radius: 0 !important;
background: #ffffff;
box-shadow:
inset 1px 1px 0 #808080,
inset -1px -1px 0 #dfdfdf;
}
.theme-key-windows95 .ui-range-input__range {
height: 8px;
border-radius: 0 !important;
background: var(--accent);
}
.theme-key-windows95 .ui-range-input__thumb {
width: 14px;
height: 20px;
border: 2px solid;
border-color: #ffffff #404040 #404040 #ffffff;
border-radius: 0 !important;
background: #c0c0c0;
box-shadow:
inset 1px 1px 0 #dfdfdf,
inset -1px -1px 0 #808080;
transition: none;
}
.theme-key-windows95 .ui-range-input__thumb:hover,
.theme-key-windows95 .ui-range-input__thumb:focus-visible {
background: #dfdfdf;
}
.theme-key-windows95 .ui-range-input__thumb[data-active] {
border-color: #404040 #ffffff #ffffff #404040;
box-shadow:
inset 1px 1px 0 #808080,
inset -1px -1px 0 #dfdfdf;
}
.theme-key-windows95 .ui-sortable-item.is-drop-target {
outline: 1px dotted #000000;
outline-offset: 3px;
background: color-mix(in srgb, var(--accent) 12%, var(--admin-surface));
}
.theme-key-windows95 .ui-sortable-item.is-drop-target::before {
top: -7px;
height: 2px;
border-radius: 0;
background: #000080;
box-shadow:
0 1px 0 #ffffff,
0 -1px 0 #000000;
}
.theme-key-windows95 .ui-sortable-handle {
align-self: center;
width: 24px;
height: 28px;
border: 2px solid;
border-color: #ffffff #404040 #404040 #ffffff;
background: #c0c0c0;
color: #000000;
box-shadow:
inset 1px 1px 0 #dfdfdf,
inset -1px -1px 0 #808080;
transition: none;
}
.theme-key-windows95 .ui-sortable-handle:hover,
.theme-key-windows95 .ui-sortable-handle:focus-visible {
background: #dfdfdf;
}
.theme-key-windows95 .ui-sortable-handle:active {
border-color: #404040 #ffffff #ffffff #404040;
box-shadow:
inset 1px 1px 0 #808080,
inset -1px -1px 0 #dfdfdf;
}
.theme-key-windows95 input::placeholder, .theme-key-windows95 input::placeholder,
.theme-key-windows95 textarea::placeholder, .theme-key-windows95 textarea::placeholder,
.theme-key-windows95 .input::placeholder, .theme-key-windows95 .input::placeholder,
@@ -1352,3 +1448,64 @@ body:has(.theme-key-windows95) .install-platform-item[data-selected] {
.theme-key-windows95 a:not(.btn):not(.bottom-nav button):not([class*="-trigger"]):visited { .theme-key-windows95 a:not(.btn):not(.bottom-nav button):not([class*="-trigger"]):visited {
color: #800080; color: #800080;
} }
/* ---------- Newer webapp surfaces: telegram banner, traffic /
* referral dropdowns, login language picker ---------- */
/* Telegram notifications banner: the Card chrome is already beveled by
* the shared .card rule; give the icon badge a raised chip look instead
* of the rounded, color-tinted default (the Send glyph maps to send.png). */
.theme-key-windows95 .telegram-notifications-icon {
border-width: 2px;
border-style: solid;
border-color: #ffffff #404040 #404040 #ffffff;
border-radius: 0;
background: var(--panel);
color: var(--text);
box-shadow:
inset 1px 1px 0 #dfdfdf,
inset -1px -1px 0 #808080;
}
/* Standalone referral-tariff dropdown and bonus rows: bevel them like the
* rest of the surfaces so they don't read as flat 1px boxes. */
.theme-key-windows95 .referral-tariff-dropdown,
.theme-key-windows95 .referral-bonus-row {
border-width: 2px;
border-style: solid;
border-color: #ffffff #404040 #404040 #ffffff;
border-radius: 0;
background: var(--panel);
box-shadow:
inset 1px 1px 0 #dfdfdf,
inset -1px -1px 0 #808080;
}
.theme-key-windows95 .referral-bonus-row-nested {
background: #dfdfdf;
}
/* Premium-server / referral help glyph: drop the rounded accent pill so it
* sits inline as a plain stroked question mark. */
.theme-key-windows95 .premium-server-help-icon,
.theme-key-windows95 .premium-server-dropdown summary:hover .premium-server-help-icon,
.theme-key-windows95 .premium-server-dropdown[open] .premium-server-help-icon,
.theme-key-windows95 .referral-tariff-dropdown summary:hover .premium-server-help-icon,
.theme-key-windows95 .referral-tariff-dropdown[open] .premium-server-help-icon {
padding: 0;
border-radius: 0;
background: transparent;
color: var(--text);
}
/* The selected language row turns navy; its check maps to a dark bitmap,
* so invert it to white to keep it visible. */
.theme-key-windows95 .language-select-item[data-highlighted] .language-select-item-check,
.theme-key-windows95 .language-select-item[data-selected] .language-select-item-check {
filter: brightness(0) invert(1);
}
/* Login-screen language trigger: square the rounded chip. */
.theme-key-windows95 .auth-language-trigger {
border-radius: 0;
}
@@ -9,7 +9,7 @@
"use_primary_accent": false, "use_primary_accent": false,
"use_in_admin": true, "use_in_admin": true,
"css_file": "style.css", "css_file": "style.css",
"assets_version": 11, "assets_version": 13,
"tokens": { "tokens": {
"color_scheme": "light", "color_scheme": "light",
"style_preset": "win95" "style_preset": "win95"
+1 -3
View File
@@ -42,7 +42,7 @@ from bot.app.web.webapp_auth import (
verify_webapp_session_token, verify_webapp_session_token,
) )
from bot.infra.redis import cache_delete, cache_get_json, cache_set_json, get_redis, redis_key from bot.infra.redis import cache_delete, cache_get_json, cache_set_json, get_redis, redis_key
from bot.services.email_auth_service import EmailAuthService, normalize_email from bot.services.email_auth_service import EmailAuthService, is_disposable_email, normalize_email
from bot.services.email_templates import render_account_merged from bot.services.email_templates import render_account_merged
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
@@ -82,7 +82,6 @@ WEBAPP_DEFAULT_LOGO_PATH = "/webapp-default-logo.webp"
WEBAPP_DEFAULT_FAVICON_DIGEST = "19b2a242e5b7bc2d" WEBAPP_DEFAULT_FAVICON_DIGEST = "19b2a242e5b7bc2d"
WEBAPP_DEFAULT_FAVICON_DIR = WEBAPP_DEFAULT_BRAND_DIR / "favicons" / WEBAPP_DEFAULT_FAVICON_DIGEST WEBAPP_DEFAULT_FAVICON_DIR = WEBAPP_DEFAULT_BRAND_DIR / "favicons" / WEBAPP_DEFAULT_FAVICON_DIGEST
WEBAPP_DEFAULT_FAVICON_URL = f"{WEBAPP_FAVICON_PATH}/{WEBAPP_DEFAULT_FAVICON_DIGEST}/icon-180.png" WEBAPP_DEFAULT_FAVICON_URL = f"{WEBAPP_FAVICON_PATH}/{WEBAPP_DEFAULT_FAVICON_DIGEST}/icon-180.png"
WEBAPP_EMOJI_CACHE_DIR = APP_ROOT / "data" / "webapp-emoji"
WEBAPP_CONFIG_PLACEHOLDER = "<!-- WEBAPP_CONFIG_SCRIPT -->" WEBAPP_CONFIG_PLACEHOLDER = "<!-- WEBAPP_CONFIG_SCRIPT -->"
WEBAPP_I18N_PLACEHOLDER = "<!-- WEBAPP_I18N_SCRIPT -->" WEBAPP_I18N_PLACEHOLDER = "<!-- WEBAPP_I18N_SCRIPT -->"
WEBAPP_JS_PLACEHOLDER = "<!-- WEBAPP_JS_SCRIPT -->" WEBAPP_JS_PLACEHOLDER = "<!-- WEBAPP_JS_SCRIPT -->"
@@ -92,7 +91,6 @@ DEV_MOCK_END_MARKER = "<!-- WEBAPP_DEV_MOCK_END -->"
WEBAPP_RATE_LIMIT_WINDOW_SECONDS = 60 WEBAPP_RATE_LIMIT_WINDOW_SECONDS = 60
WEBAPP_RATE_LIMIT_MAX_REQUESTS = 30 WEBAPP_RATE_LIMIT_MAX_REQUESTS = 30
WEBAPP_LOGO_MAX_BYTES = 2 * 1024 * 1024 WEBAPP_LOGO_MAX_BYTES = 2 * 1024 * 1024
WEBAPP_EMOJI_MAX_BYTES = 4 * 1024 * 1024
WEBAPP_THEME_CSS_MAX_BYTES = 512 * 1024 WEBAPP_THEME_CSS_MAX_BYTES = 512 * 1024
WEBAPP_THEME_ASSET_MAX_BYTES = 1024 * 1024 WEBAPP_THEME_ASSET_MAX_BYTES = 1024 * 1024
WEBAPP_THEME_ASSET_CONTENT_TYPES = { WEBAPP_THEME_ASSET_CONTENT_TYPES = {
@@ -33,7 +33,6 @@ def create_subscription_webapp_application(
async def _startup(app_obj: web.Application) -> None: async def _startup(app_obj: web.Application) -> None:
await _ensure_shared_http_session() await _ensure_shared_http_session()
await _warm_webapp_logo_cache(app_obj) await _warm_webapp_logo_cache(app_obj)
await _warm_webapp_animated_emoji_cache(app_obj)
await warm_subscription_guides_config(app_obj) await warm_subscription_guides_config(app_obj)
async def _shutdown(app_obj: web.Application) -> None: async def _shutdown(app_obj: web.Application) -> None:
+41 -171
View File
@@ -206,9 +206,6 @@ async def theme_asset_route(request: web.Request) -> web.Response:
def _resolve_webapp_logo_url(settings: Settings) -> str: def _resolve_webapp_logo_url(settings: Settings) -> str:
if getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False):
return ""
raw_logo_url = (getattr(settings, "WEBAPP_LOGO_URL", None) or "").strip() raw_logo_url = (getattr(settings, "WEBAPP_LOGO_URL", None) or "").strip()
if not raw_logo_url: if not raw_logo_url:
return WEBAPP_DEFAULT_LOGO_PATH return WEBAPP_DEFAULT_LOGO_PATH
@@ -303,29 +300,8 @@ def _uploaded_webapp_logo_response(filename: str) -> web.Response:
return response return response
def _emoji_to_codepoints(value: str) -> str:
return "_".join(f"{ord(char):x}" for char in str(value or "").strip())
def _webapp_emoji_disk_path(codepoints: str, ext: str) -> Path:
return WEBAPP_EMOJI_CACHE_DIR / f"{codepoints}.512.{ext}"
def _webapp_animated_emoji_source_url(codepoints: str, ext: str) -> str:
return f"https://fonts.gstatic.com/s/e/notoemoji/latest/{codepoints}/512.{ext}"
def _webapp_animated_emoji_asset_path(emoji: str, ext: str = "gif") -> str:
codepoints = _emoji_to_codepoints(emoji)
if not codepoints or ext not in {"gif", "webp"}:
return ""
return f"/webapp-emoji/{codepoints}/512.{ext}"
async def webapp_logo_route(request: web.Request) -> web.Response: async def webapp_logo_route(request: web.Request) -> web.Response:
settings: Settings = request.app["settings"] settings: Settings = request.app["settings"]
if getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False):
raise web.HTTPNotFound(text="webapp_logo_disabled")
raw_logo_url = (settings.WEBAPP_LOGO_URL or "").strip() raw_logo_url = (settings.WEBAPP_LOGO_URL or "").strip()
if not raw_logo_url: if not raw_logo_url:
raise web.HTTPNotFound(text="webapp_logo_not_configured") raise web.HTTPNotFound(text="webapp_logo_not_configured")
@@ -521,37 +497,8 @@ def _webapp_default_brand_file_response(path: Path, content_type: str) -> web.Re
return web.Response(body=body, content_type=content_type) return web.Response(body=body, content_type=content_type)
async def webapp_animated_emoji_route(request: web.Request) -> web.Response:
codepoints = str(request.match_info.get("codepoints") or "").strip().lower()
ext = str(request.match_info.get("ext") or "").strip().lower()
if not re.fullmatch(r"[0-9a-f]+(?:_[0-9a-f]+)*", codepoints) or ext not in {"gif", "webp"}:
raise web.HTTPNotFound(text="webapp_emoji_not_found")
emoji_cache_key = f"{codepoints}:{ext}"
emoji_caches: Dict[str, Tuple[bytes, str]] = request.app.setdefault("webapp_emoji_cache", {})
emoji_cache = emoji_caches.get(emoji_cache_key)
if emoji_cache is None:
cache_lock: asyncio.Lock = request.app.setdefault("webapp_emoji_cache_lock", asyncio.Lock())
async with cache_lock:
emoji_cache = emoji_caches.get(emoji_cache_key)
if emoji_cache is None:
emoji_cache = await _load_or_fetch_webapp_animated_emoji(codepoints, ext)
if emoji_cache:
emoji_caches[emoji_cache_key] = emoji_cache
if not emoji_cache:
raise web.HTTPNotFound(text="webapp_emoji_unavailable")
body, content_type = emoji_cache
response = web.Response(body=body, content_type=content_type)
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
return response
async def _warm_webapp_logo_cache(app: web.Application) -> None: async def _warm_webapp_logo_cache(app: web.Application) -> None:
settings: Settings = app["settings"] settings: Settings = app["settings"]
if getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False):
return
raw_logo_url = (settings.WEBAPP_LOGO_URL or "").strip() raw_logo_url = (settings.WEBAPP_LOGO_URL or "").strip()
if not raw_logo_url or not _is_proxyable_webapp_logo_url(raw_logo_url): if not raw_logo_url or not _is_proxyable_webapp_logo_url(raw_logo_url):
return return
@@ -573,111 +520,6 @@ async def _warm_webapp_logo_cache(app: web.Application) -> None:
) )
async def _warm_webapp_animated_emoji_cache(app: web.Application) -> None:
settings: Settings = app["settings"]
if not getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False):
return
if str(settings.WEBAPP_LOGO_EMOJI_FONT or "").strip() != "noto-color-animated":
return
codepoints = _emoji_to_codepoints(settings.WEBAPP_LOGO_EMOJI)
if not codepoints:
return
app.setdefault("webapp_emoji_cache", {})
app.setdefault("webapp_emoji_cache_lock", asyncio.Lock())
emoji_caches: Dict[str, Tuple[bytes, str]] = app["webapp_emoji_cache"]
for ext in ("gif", "webp"):
emoji_cache_key = f"{codepoints}:{ext}"
if emoji_cache_key in emoji_caches:
continue
loaded_emoji = await _load_or_fetch_webapp_animated_emoji(codepoints, ext)
if loaded_emoji:
emoji_caches[emoji_cache_key] = loaded_emoji
if ext == "gif":
return
async def _load_or_fetch_webapp_animated_emoji(
codepoints: str, ext: str
) -> Optional[Tuple[bytes, str]]:
disk_emoji = await asyncio.to_thread(_read_webapp_animated_emoji_from_disk, codepoints, ext)
if disk_emoji:
return disk_emoji
fetched_emoji = await _fetch_webapp_animated_emoji(codepoints, ext)
if fetched_emoji:
await asyncio.to_thread(
_write_webapp_animated_emoji_to_disk, codepoints, ext, fetched_emoji
)
return fetched_emoji
def _read_webapp_animated_emoji_from_disk(codepoints: str, ext: str) -> Optional[Tuple[bytes, str]]:
path = _webapp_emoji_disk_path(codepoints, ext)
try:
body = path.read_bytes()
except OSError:
return None
if not body or len(body) > WEBAPP_EMOJI_MAX_BYTES:
return None
return body, "image/gif" if ext == "gif" else "image/webp"
def _write_webapp_animated_emoji_to_disk(
codepoints: str, ext: str, emoji: Tuple[bytes, str]
) -> None:
body, _content_type = emoji
if not body or len(body) > WEBAPP_EMOJI_MAX_BYTES:
return
path = _webapp_emoji_disk_path(codepoints, ext)
try:
WEBAPP_EMOJI_CACHE_DIR.mkdir(parents=True, exist_ok=True)
path.write_bytes(body)
except OSError as exc:
logger.warning("Failed to write WEBAPP animated emoji cache: %s", exc)
async def _fetch_webapp_animated_emoji(codepoints: str, ext: str) -> Optional[Tuple[bytes, str]]:
try:
session = await _get_shared_http_session()
timeout = ClientTimeout(total=4)
source_url = _webapp_animated_emoji_source_url(codepoints, ext)
async with session.get(
source_url,
allow_redirects=False,
headers={"Accept": "image/gif,image/webp,image/*,*/*;q=0.8"},
timeout=timeout,
) as response:
if response.status != 200:
return None
content_type = (
(response.headers.get("Content-Type") or "").split(";", 1)[0].strip().lower()
)
expected_content_type = "image/gif" if ext == "gif" else "image/webp"
if content_type and content_type != expected_content_type:
return None
body = bytearray()
async for chunk in response.content.iter_chunked(64 * 1024):
body.extend(chunk)
if len(body) > WEBAPP_EMOJI_MAX_BYTES:
logger.warning("WEBAPP animated emoji exceeded the 4 MiB limit.")
return None
if not body:
return None
return bytes(body), expected_content_type
except Exception as exc:
logger.warning("Failed to fetch WEBAPP animated emoji: %s", exc)
return None
async def _load_or_fetch_webapp_logo(logo_url: str) -> Optional[Tuple[bytes, str]]: async def _load_or_fetch_webapp_logo(logo_url: str) -> Optional[Tuple[bytes, str]]:
disk_logo = await asyncio.to_thread(_read_webapp_logo_from_disk, logo_url) disk_logo = await asyncio.to_thread(_read_webapp_logo_from_disk, logo_url)
if disk_logo: if disk_logo:
@@ -923,7 +765,6 @@ def _get_cached_webapp_settings(request: web.Request) -> Dict[str, Any]:
"traffic_packages": settings.traffic_packages, "traffic_packages": settings.traffic_packages,
"stars_traffic_packages": settings.stars_traffic_packages, "stars_traffic_packages": settings.stars_traffic_packages,
"support_url": settings.SUPPORT_LINK or "", "support_url": settings.SUPPORT_LINK or "",
"terms_url": settings.TERMS_OF_SERVICE_URL or "",
"privacy_policy_url": settings.PRIVACY_POLICY_URL or "", "privacy_policy_url": settings.PRIVACY_POLICY_URL or "",
"user_agreement_url": settings.USER_AGREEMENT_URL or "", "user_agreement_url": settings.USER_AGREEMENT_URL or "",
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB", "currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
@@ -937,9 +778,31 @@ def _get_cached_webapp_settings(request: web.Request) -> Dict[str, Any]:
def _resolve_app_version() -> str: def _resolve_app_version() -> str:
# Single source of truth shared with the telemetry worker so the admin # Single source of truth shared with the telemetry worker so the admin
# sidebar and the install beacon always report the same version. # sidebar and the install beacon always report the same version.
from bot.utils.app_version import resolve_app_version from bot.utils import app_version as app_version_module
return resolve_app_version() global _APP_VERSION_CACHE
app_version_module.APP_ROOT = APP_ROOT
app_version_module._run_git_command = _run_git_command
app_version_module._APP_VERSION_CACHE = _APP_VERSION_CACHE
version = app_version_module.resolve_app_version()
_APP_VERSION_CACHE = app_version_module._APP_VERSION_CACHE
return version
def _run_git_command(*args: str) -> str:
try:
result = subprocess.run(
["git", *args],
cwd=APP_ROOT,
check=True,
capture_output=True,
text=True,
timeout=1.5,
)
except (OSError, subprocess.SubprocessError):
return ""
return result.stdout.strip()
async def _enforce_webapp_rate_limit( async def _enforce_webapp_rate_limit(
@@ -1144,9 +1007,6 @@ def _build_webapp_bootstrap_payload(request: web.Request) -> Dict[str, Any]:
"themesDir": settings.WEBAPP_THEMES_DIR, "themesDir": settings.WEBAPP_THEMES_DIR,
"themePreviewKey": preview_key, "themePreviewKey": preview_key,
"logoUrl": cached["logo_url"], "logoUrl": cached["logo_url"],
"logoUseEmoji": bool(settings.WEBAPP_LOGO_USE_EMOJI),
"logoEmoji": settings.WEBAPP_LOGO_EMOJI,
"logoEmojiFont": settings.WEBAPP_LOGO_EMOJI_FONT,
"faviconUrl": cached["favicon_url"], "faviconUrl": cached["favicon_url"],
"faviconUseCustom": bool(settings.WEBAPP_FAVICON_USE_CUSTOM), "faviconUseCustom": bool(settings.WEBAPP_FAVICON_USE_CUSTOM),
"apiBase": "/api", "apiBase": "/api",
@@ -1157,7 +1017,6 @@ def _build_webapp_bootstrap_payload(request: web.Request) -> Dict[str, Any]:
"telegramOAuthClientId": _resolve_telegram_oauth_client_id(settings) or 0, "telegramOAuthClientId": _resolve_telegram_oauth_client_id(settings) or 0,
"telegramOAuthRequestAccess": _resolve_telegram_oauth_request_access(settings), "telegramOAuthRequestAccess": _resolve_telegram_oauth_request_access(settings),
"supportUrl": cached["support_url"], "supportUrl": cached["support_url"],
"termsUrl": cached["terms_url"],
"privacyPolicyUrl": cached["privacy_policy_url"], "privacyPolicyUrl": cached["privacy_policy_url"],
"userAgreementUrl": cached["user_agreement_url"], "userAgreementUrl": cached["user_agreement_url"],
"currency": cached["currency"], "currency": cached["currency"],
@@ -1308,12 +1167,6 @@ async def index_route(request: web.Request) -> web.Response:
f'<script src="/{_resolve_webapp_js_asset_name()}" defer></script>', f'<script src="/{_resolve_webapp_js_asset_name()}" defer></script>',
) )
brand_asset_url = cached["logo_url"] brand_asset_url = cached["logo_url"]
if (
not brand_asset_url
and settings.WEBAPP_LOGO_USE_EMOJI
and settings.WEBAPP_LOGO_EMOJI_FONT == "noto-color-animated"
):
brand_asset_url = _webapp_animated_emoji_asset_path(settings.WEBAPP_LOGO_EMOJI)
if brand_asset_url: if brand_asset_url:
html = html.replace( html = html.replace(
"</head>", "</head>",
@@ -1683,6 +1536,9 @@ _INITIAL_THEME_TOKEN_CSS_MAP = {
"font_sans": "--font-sans", "font_sans": "--font-sans",
"font_logo": "--font-logo", "font_logo": "--font-logo",
"font_mono": "--font-mono", "font_mono": "--font-mono",
"home_logo_scale": "--home-logo-scale",
"home_logo_scale_desktop": "--home-logo-scale-desktop",
"home_logo_scale_mobile": "--home-logo-scale-mobile",
"admin_bg": "--admin-bg", "admin_bg": "--admin-bg",
"admin_surface": "--admin-surface", "admin_surface": "--admin-surface",
"admin_surface_2": "--admin-surface-2", "admin_surface_2": "--admin-surface-2",
@@ -1694,6 +1550,12 @@ _INITIAL_THEME_TOKEN_CSS_MAP = {
"admin_dim": "--admin-dim", "admin_dim": "--admin-dim",
} }
_INITIAL_THEME_LOGO_SCALE_TOKENS = {
"home_logo_scale",
"home_logo_scale_desktop",
"home_logo_scale_mobile",
}
def _theme_css_href_for_html(theme: Any) -> str: def _theme_css_href_for_html(theme: Any) -> str:
css_file = str(getattr(theme, "css_file", "") or "").strip() css_file = str(getattr(theme, "css_file", "") or "").strip()
@@ -1737,6 +1599,14 @@ def _initial_theme_head_markup(request: web.Request, theme: Any, primary_color:
tokens = tokens if isinstance(tokens, dict) else {} tokens = tokens if isinstance(tokens, dict) else {}
declarations = [] declarations = []
for token_key, css_name in _INITIAL_THEME_TOKEN_CSS_MAP.items(): for token_key, css_name in _INITIAL_THEME_TOKEN_CSS_MAP.items():
if token_key in _INITIAL_THEME_LOGO_SCALE_TOKENS:
try:
scale = float(tokens.get(token_key) or 0)
except (TypeError, ValueError):
continue
if scale > 0:
declarations.append(f"{css_name}:{scale / 100:g}")
continue
value = str(tokens.get(token_key) or "").strip() value = str(tokens.get(token_key) or "").strip()
if value: if value:
declarations.append(f"{css_name}:{value}") declarations.append(f"{css_name}:{value}")
+196 -20
View File
@@ -713,6 +713,7 @@ async def email_auth_verify_route(request: web.Request) -> web.Response:
session, session,
referral_param, referral_param,
current_user_id=None, current_user_id=None,
settings=settings,
) )
db_user, _ = await user_dal.create_email_user( db_user, _ = await user_dal.create_email_user(
session, session,
@@ -821,6 +822,7 @@ async def email_auth_magic_route(request: web.Request) -> web.Response:
session, session,
referral_param, referral_param,
current_user_id=None, current_user_id=None,
settings=settings,
) )
db_user, _ = await user_dal.create_email_user( db_user, _ = await user_dal.create_email_user(
session, session,
@@ -1010,13 +1012,53 @@ async def _request_email_code(
def _telegram_id_for_user(user: User) -> Optional[int]: def _telegram_id_for_user(user: User) -> Optional[int]:
if user.telegram_id: telegram_id = getattr(user, "telegram_id", None)
return int(user.telegram_id) if telegram_id:
if user.user_id and int(user.user_id) > 0: return int(telegram_id)
return int(user.user_id) user_id = getattr(user, "user_id", None)
if user_id and int(user_id) > 0:
return int(user_id)
return None return None
def _user_has_linked_telegram(user: User) -> bool:
return bool(getattr(user, "telegram_id", None))
def _email_only_telegram_required_reason(
settings: Settings,
user: User,
*,
without_telegram_enabled_attr: str,
) -> Optional[str]:
if _user_has_linked_telegram(user):
return None
if is_disposable_email(getattr(user, "email", None), settings):
return "disposable_email"
if not bool(getattr(settings, without_telegram_enabled_attr, True)):
return "telegram_required"
return None
def _trial_telegram_required_reason(settings: Settings, user: User) -> Optional[str]:
return _email_only_telegram_required_reason(
settings,
user,
without_telegram_enabled_attr="TRIAL_WITHOUT_TELEGRAM_ENABLED",
)
def _referral_welcome_telegram_required_reason(
settings: Settings,
user: User,
) -> Optional[str]:
return _email_only_telegram_required_reason(
settings,
user,
without_telegram_enabled_attr="REFERRAL_WELCOME_BONUS_WITHOUT_TELEGRAM_ENABLED",
)
def _panel_description_for_user(user: User) -> str: def _panel_description_for_user(user: User) -> str:
return panel_description_from_profile( return panel_description_from_profile(
user.username, user.username,
@@ -1292,17 +1334,35 @@ async def _link_telegram_to_user(
return current_user return current_user
def _normalize_referral_param(raw: Optional[str]) -> Optional[str]: def _remnashop_referral_compat_enabled(settings: Optional[Settings]) -> bool:
if settings is None:
return False
return bool(getattr(settings, "MIGRATION_REMNASHOP_REFERRAL_CODE_COMPAT_ENABLED", False))
def _strip_referral_param_prefix(
raw: Optional[str],
*,
preserve_current_u_prefix: bool,
) -> str:
value = (raw or "").strip() value = (raw or "").strip()
if not value: if not value:
return None return ""
value_lower = value.lower() value_lower = value.lower()
if value_lower.startswith("ref_u"): if value_lower.startswith("ref_u") and not preserve_current_u_prefix:
value = value[5:] value = value[5:]
elif value_lower.startswith("ref_"): elif value_lower.startswith("ref_"):
value = value[4:] value = value[4:]
elif value and value[0].lower() == "u" and len(value) == 10: return value
def _normalize_referral_param(raw: Optional[str]) -> Optional[str]:
value = _strip_referral_param_prefix(raw, preserve_current_u_prefix=False)
if not value:
return None
if value and value[0].lower() == "u" and len(value) == 10:
value = value[1:] value = value[1:]
if not re.fullmatch(r"[A-Za-z0-9]{1,32}", value): if not re.fullmatch(r"[A-Za-z0-9]{1,32}", value):
@@ -1310,26 +1370,64 @@ def _normalize_referral_param(raw: Optional[str]) -> Optional[str]:
return value.upper() return value.upper()
def _referral_param_lookup_candidates(
raw: Optional[str],
*,
remnashop_compat: bool,
) -> List[str]:
if not remnashop_compat:
normalized = _normalize_referral_param(raw)
return [normalized] if normalized else []
value = _strip_referral_param_prefix(raw, preserve_current_u_prefix=True)
if not value or not re.fullmatch(r"[A-Za-z0-9._:-]{1,128}", value):
return []
candidates = [value]
if value and value[0].lower() == "u":
candidates.append(value[1:])
unique: List[str] = []
for candidate in candidates:
if candidate and candidate not in unique:
unique.append(candidate)
return unique
async def _resolve_referrer_id( async def _resolve_referrer_id(
session: AsyncSession, session: AsyncSession,
raw_referral_param: Optional[str], raw_referral_param: Optional[str],
*, *,
current_user_id: Optional[int], current_user_id: Optional[int],
settings: Optional[Settings] = None,
) -> Optional[int]: ) -> Optional[int]:
normalized = _normalize_referral_param(raw_referral_param) remnashop_compat = _remnashop_referral_compat_enabled(settings)
if not normalized: candidates = _referral_param_lookup_candidates(
raw_referral_param,
remnashop_compat=remnashop_compat,
)
if not candidates:
return None return None
ref_user = None for normalized in candidates:
if normalized.isdigit(): ref_user = None
ref_user = await user_dal.get_user_by_id(session, int(normalized)) if normalized.isdigit() and not remnashop_compat:
if not ref_user: ref_user = await user_dal.get_user_by_id(session, int(normalized))
ref_user = await user_dal.get_user_by_referral_code(session, normalized) if not ref_user:
if not ref_user: ref_user = await user_dal.get_user_by_referral_code(
return None session,
if current_user_id is not None and int(ref_user.user_id) == int(current_user_id): normalized,
return None include_legacy=remnashop_compat,
return int(ref_user.user_id) )
if not ref_user and normalized.isdigit() and remnashop_compat:
ref_user = await user_dal.get_user_by_id(session, int(normalized))
if not ref_user:
continue
if current_user_id is not None and int(ref_user.user_id) == int(current_user_id):
continue
return int(ref_user.user_id)
return None
async def _apply_referral_to_existing_user( async def _apply_referral_to_existing_user(
@@ -1345,6 +1443,7 @@ async def _apply_referral_to_existing_user(
session, session,
raw_referral_param, raw_referral_param,
current_user_id=int(user.user_id), current_user_id=int(user.user_id),
settings=request.app["settings"],
) )
if not referred_by_id: if not referred_by_id:
return False return False
@@ -1374,6 +1473,21 @@ async def _apply_referral_welcome_bonus_if_needed(
if not raw_referral_param or not user.referred_by_id: if not raw_referral_param or not user.referred_by_id:
return None return None
settings: Settings = request.app["settings"]
if _referral_welcome_telegram_required_reason(settings, user):
return None
return await _grant_referral_welcome_bonus_if_eligible(request, session, user)
async def _grant_referral_welcome_bonus_if_eligible(
request: web.Request,
session: AsyncSession,
user: User,
) -> Optional[datetime]:
if not user.referred_by_id:
return None
settings: Settings = request.app["settings"] settings: Settings = request.app["settings"]
referral_welcome_days = max( referral_welcome_days = max(
0, 0,
@@ -1397,6 +1511,67 @@ async def _apply_referral_welcome_bonus_if_needed(
) )
def _webapp_datetime_text(value: Optional[datetime]) -> Optional[str]:
if not value:
return None
normalized = value if value.tzinfo else value.replace(tzinfo=timezone.utc)
return normalized.strftime("%d.%m.%Y %H:%M")
async def referral_welcome_bonus_claim_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
rate_limit_response = await _enforce_webapp_rate_limit(
request,
user_id=user_id,
action="referral_welcome_claim",
)
if rate_limit_response:
return rate_limit_response
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 _json_error(403, "access_denied", "Access denied")
reason = _referral_welcome_telegram_required_reason(settings, db_user)
if reason:
await session.rollback()
return _json_error(400, "referral_welcome_telegram_required", reason)
end_date = await _grant_referral_welcome_bonus_if_eligible(
request,
session,
db_user,
)
if not end_date:
await session.rollback()
return _json_error(
400,
"referral_welcome_unavailable",
"Referral welcome bonus is not available",
)
await session.commit()
except Exception:
await session.rollback()
logger.exception("Referral welcome bonus claim failed")
return _json_error(500, "referral_welcome_failed", "Referral welcome bonus failed")
await _invalidate_webapp_user_caches(settings, user_id, include_devices=True)
return web.json_response(
{
"ok": True,
"claimed": True,
"end_date": end_date.isoformat() if isinstance(end_date, datetime) else None,
"end_date_text": _webapp_datetime_text(end_date),
}
)
async def _ensure_user_from_telegram( async def _ensure_user_from_telegram(
session: AsyncSession, session: AsyncSession,
telegram_user: Dict[str, Any], telegram_user: Dict[str, Any],
@@ -1427,6 +1602,7 @@ async def _ensure_user_from_telegram(
session, session,
referral_param or telegram_user.get("start_param"), referral_param or telegram_user.get("start_param"),
current_user_id=user_id, current_user_id=user_id,
settings=settings,
) )
db_user, created = await user_dal.create_user( db_user, created = await user_dal.create_user(
session, session,
+84 -23
View File
@@ -1,7 +1,18 @@
# 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 bot.app.web.webapp.auth import _trial_telegram_required_reason
from bot.app.web.webapp.cache_helpers import invalidate_webapp_user_caches from bot.app.web.webapp.cache_helpers import invalidate_webapp_user_caches
from db.dal import message_log_dal
_HTML_TAG_RE = re.compile(r"<[^>]+>")
def _plain_text_message(value: Any) -> str:
"""Strip Telegram-style HTML markup from a localized message for the web app."""
text = _HTML_TAG_RE.sub("", str(value))
return html.unescape(text).strip()
def _billing_iso_datetime(value: Optional[Any]) -> Optional[str]: def _billing_iso_datetime(value: Optional[Any]) -> Optional[str]:
@@ -68,7 +79,7 @@ async def apply_promo_route(request: web.Request) -> web.Response:
) )
if not success: if not success:
await session.commit() await session.commit()
return _json_error(400, "promo_apply_failed", str(result)) return _json_error(400, "promo_apply_failed", _plain_text_message(result))
await session.commit() await session.commit()
end_date = result if isinstance(result, datetime) else None end_date = result if isinstance(result, datetime) else None
return web.json_response( return web.json_response(
@@ -111,10 +122,11 @@ async def create_payment_route(request: web.Request) -> web.Response:
hwid_quote: Optional[Dict[str, Any]] = None hwid_quote: Optional[Dict[str, Any]] = None
requested_sale_mode = _sale_mode_base(str(payment_payload.sale_mode or "")) requested_sale_mode = _sale_mode_base(str(payment_payload.sale_mode or ""))
if tariffs_config and requested_sale_mode == "hwid_devices_renewal":
return _json_error(400, "invalid_plan", "Device renewal is part of subscription renewal")
if tariffs_config and requested_sale_mode in { if tariffs_config and requested_sale_mode in {
"hwid_device", "hwid_device",
"hwid_devices", "hwid_devices",
"hwid_devices_renewal",
}: }:
tariff_key = str(payment_payload.tariff_key or "").strip() tariff_key = str(payment_payload.tariff_key or "").strip()
if not tariff_key: if not tariff_key:
@@ -308,7 +320,7 @@ async def create_payment_route(request: web.Request) -> web.Response:
user_id=user_id, user_id=user_id,
device_count=int(payment_units), device_count=int(payment_units),
tariff_key=sale_tariff_key, tariff_key=sale_tariff_key,
renewal=_sale_mode_base(sale_mode) == "hwid_devices_renewal", renewal=False,
currency=currency, currency=currency,
) )
if not hwid_quote: if not hwid_quote:
@@ -321,6 +333,25 @@ async def create_payment_route(request: web.Request) -> web.Response:
else: else:
price = float(hwid_quote["price"]) price = float(hwid_quote["price"])
stars_price = None stars_price = None
elif _sale_mode_base(sale_mode) == "subscription" and bool(
payment_payload.renew_hwid_devices
):
currency = "stars" if method == "stars" else default_currency
sale_tariff_key = _sale_mode_tariff_key(sale_mode)
if sale_tariff_key:
hwid_quote = await subscription_service.quote_hwid_device_renewal_for_subscription(
session,
user_id=user_id,
target_tariff_key=sale_tariff_key,
months=int(payment_units),
currency=currency,
)
if hwid_quote:
if method == "stars":
stars_price = int(stars_price or 0) + int(hwid_quote["price"])
else:
price = float(price or 0) + float(hwid_quote["price"])
stars_price = None
admin_ids = {int(item) for item in (settings.ADMIN_IDS or [])} admin_ids = {int(item) for item in (settings.ADMIN_IDS or [])}
is_admin = bool(db_user.telegram_id and int(db_user.telegram_id) in admin_ids) is_admin = bool(db_user.telegram_id and int(db_user.telegram_id) in admin_ids)
return await _create_subscription_payment( return await _create_subscription_payment(
@@ -360,6 +391,13 @@ async def activate_trial_route(request: web.Request) -> web.Response:
db_user = await user_dal.get_user_by_id(session, user_id) db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user or db_user.is_banned: if not db_user or db_user.is_banned:
return _json_error(403, "access_denied", "Access denied") return _json_error(403, "access_denied", "Access denied")
telegram_required_reason = _trial_telegram_required_reason(settings, db_user)
if telegram_required_reason:
return _json_error(
400,
"trial_telegram_required",
telegram_required_reason,
)
activation_result = await subscription_service.activate_trial_subscription(session, user_id) activation_result = await subscription_service.activate_trial_subscription(session, user_id)
if not activation_result or not activation_result.get("activated"): if not activation_result or not activation_result.get("activated"):
@@ -395,6 +433,28 @@ async def activate_trial_route(request: web.Request) -> web.Response:
except Exception: except Exception:
logger.exception("Failed to send WebApp trial activation notification") logger.exception("Failed to send WebApp trial activation notification")
try:
await message_log_dal.create_message_log_no_commit(
session,
{
"user_id": user_id,
"telegram_username": getattr(db_user, "username", None),
"telegram_first_name": getattr(db_user, "first_name", None),
"event_type": "webapp_trial_activate",
"content": (
f"Trial activated via WebApp for user_id={user_id}; "
f"email={getattr(db_user, 'email', None) or 'N/A'}"
),
"is_admin_event": False,
"target_user_id": user_id,
"timestamp": datetime.now(timezone.utc),
},
)
except Exception:
logger.exception("Failed to add WebApp trial activation audit log")
await session.commit()
try: try:
from db.dal import ad_dal as _ad_dal from db.dal import ad_dal as _ad_dal
@@ -659,7 +719,6 @@ async def device_topup_options_route(request: web.Request) -> web.Response:
return _json_error(400, "device_topup_unavailable", "Device top-up is not available") return _json_error(400, "device_topup_unavailable", "Device top-up is not available")
lang = db_user.language_code or settings.DEFAULT_LANGUAGE lang = db_user.language_code or settings.DEFAULT_LANGUAGE
active = await subscription_service.get_active_subscription_details(session, user_id) active = await subscription_service.get_active_subscription_details(session, user_id)
renewal_available = bool(active and active.get("device_topup_renewal_available"))
extra_hwid_valid_until = active.get("extra_hwid_devices_valid_until") if active else None extra_hwid_valid_until = active.get("extra_hwid_devices_valid_until") if active else None
extra_hwid_valid_until_text = ( extra_hwid_valid_until_text = (
active.get("extra_hwid_devices_valid_until_text") if active else None active.get("extra_hwid_devices_valid_until_text") if active else None
@@ -681,7 +740,7 @@ async def device_topup_options_route(request: web.Request) -> web.Response:
user_id=user_id, user_id=user_id,
device_count=count, device_count=count,
tariff_key=tariff.key, tariff_key=tariff.key,
renewal=renewal_available, renewal=False,
currency=default_currency, currency=default_currency,
) )
if count in currency_counts if count in currency_counts
@@ -693,7 +752,7 @@ async def device_topup_options_route(request: web.Request) -> web.Response:
user_id=user_id, user_id=user_id,
device_count=count, device_count=count,
tariff_key=tariff.key, tariff_key=tariff.key,
renewal=renewal_available, renewal=False,
currency="stars", currency="stars",
) )
if count in stars_counts if count in stars_counts
@@ -701,28 +760,27 @@ async def device_topup_options_route(request: web.Request) -> web.Response:
) )
if not currency_quote and not stars_quote: if not currency_quote and not stars_quote:
continue continue
sale_mode_for_plan = "hwid_devices_renewal" if renewal_available else "hwid_devices" quote = currency_quote or stars_quote
valid_from = quote.get("valid_from")
valid_until = quote.get("valid_until")
plan = { plan = {
"id": f"{tariff.key}:hwid:{count}{':renewal' if renewal_available else ''}", "id": f"{tariff.key}:hwid:{count}",
"tariff_key": tariff.key, "tariff_key": tariff.key,
"tariff_name": tariff.name(lang), "tariff_name": tariff.name(lang),
"billing_model": tariff.billing_model, "billing_model": tariff.billing_model,
"sale_mode": sale_mode_for_plan, "sale_mode": "hwid_devices",
"renewal": False,
"months": count, "months": count,
"device_count": count, "device_count": count,
"price": float(currency_quote.get("price") if currency_quote else 0), "price": float(currency_quote.get("price") if currency_quote else 0),
"currency": default_currency_code, "currency": default_currency_code,
"title": f"+{count}", "title": f"+{count}",
"subtitle": tariff.name(lang), "subtitle": tariff.name(lang),
"valid_from": _billing_iso_datetime( "valid_from": _billing_iso_datetime(valid_from),
(currency_quote or stars_quote).get("valid_from") "valid_from_text": _billing_datetime_text(valid_from),
), "valid_until": _billing_iso_datetime(valid_until),
"valid_until": _billing_iso_datetime( "valid_until_text": _billing_datetime_text(valid_until),
(currency_quote or stars_quote).get("valid_until") "proration_ratio": float(quote.get("proration_ratio") or 0),
),
"proration_ratio": float(
(currency_quote or stars_quote).get("proration_ratio") or 0
),
} }
if stars_quote and int(stars_quote.get("price") or 0) > 0: if stars_quote and int(stars_quote.get("price") or 0) > 0:
plan["stars_price"] = int(stars_quote["price"]) plan["stars_price"] = int(stars_quote["price"])
@@ -738,10 +796,8 @@ async def device_topup_options_route(request: web.Request) -> web.Response:
else int(sub.extra_hwid_devices or 0), else int(sub.extra_hwid_devices or 0),
"extra_hwid_devices_valid_until": _billing_iso_datetime(extra_hwid_valid_until), "extra_hwid_devices_valid_until": _billing_iso_datetime(extra_hwid_valid_until),
"extra_hwid_devices_valid_until_text": extra_hwid_valid_until_text, "extra_hwid_devices_valid_until_text": extra_hwid_valid_until_text,
"renewal_available": renewal_available, "renewal_available": False,
"renewal_recommended_count": int(active.get("extra_hwid_devices") or 0) "renewal_recommended_count": 0,
if active and renewal_available
else 0,
"plans": plans, "plans": plans,
} }
) )
@@ -907,7 +963,11 @@ async def payment_status_route(request: web.Request) -> web.Response:
payment = await _refresh_yookassa_payment_status(request, session, payment) payment = await _refresh_yookassa_payment_status(request, session, payment)
payment = await _refresh_wata_payment_status(request, session, payment) payment = await _refresh_wata_payment_status(request, session, payment)
if payment.status == "succeeded": if payment.status == "succeeded":
await invalidate_webapp_user_caches(request.app["settings"], user_id) await invalidate_webapp_user_caches(
request.app["settings"],
user_id,
include_devices=True,
)
return web.json_response( return web.json_response(
{ {
"ok": True, "ok": True,
@@ -1005,6 +1065,7 @@ async def _create_subscription_payment(
description=description, description=description,
sale_mode=sale_mode, sale_mode=sale_mode,
traffic_gb=traffic_gb, traffic_gb=traffic_gb,
hwid_device_count=hwid_quote.get("device_count") if hwid_quote else None,
hwid_valid_from=hwid_quote.get("valid_from") if hwid_quote else None, hwid_valid_from=hwid_quote.get("valid_from") if hwid_quote else None,
hwid_valid_until=hwid_quote.get("valid_until") if hwid_quote else None, hwid_valid_until=hwid_quote.get("valid_until") if hwid_quote else None,
hwid_pricing_period_months=hwid_quote.get("pricing_period_months") hwid_pricing_period_months=hwid_quote.get("pricing_period_months")
+1
View File
@@ -49,6 +49,7 @@ class WebAppPaymentCreatePayload(BaseModel):
device_count: Any = None device_count: Any = None
tariff_key: Optional[constr(max_length=128)] = None tariff_key: Optional[constr(max_length=128)] = None
sale_mode: Optional[constr(max_length=64)] = None sale_mode: Optional[constr(max_length=64)] = None
renew_hwid_devices: Optional[bool] = None
description: Optional[constr(max_length=4096)] = None description: Optional[constr(max_length=4096)] = None
comment: Optional[constr(max_length=4096)] = None comment: Optional[constr(max_length=4096)] = None
note: Optional[constr(max_length=4096)] = None note: Optional[constr(max_length=4096)] = None
+1 -4
View File
@@ -46,10 +46,6 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
rf"{WEBAPP_FAVICON_PATH}/{{digest:[0-9a-f]{{16}}}}/{{filename:[A-Za-z0-9_.-]+}}", rf"{WEBAPP_FAVICON_PATH}/{{digest:[0-9a-f]{{16}}}}/{{filename:[A-Za-z0-9_.-]+}}",
webapp_favicon_route, webapp_favicon_route,
) )
app.router.add_get(
r"/webapp-emoji/{codepoints:[0-9a-f_]+}/512.{ext:gif|webp}",
webapp_animated_emoji_route,
)
app.router.add_get("/subscription_webapp.{asset_hash:[0-9a-f]{8}}.css", css_asset_route) app.router.add_get("/subscription_webapp.{asset_hash:[0-9a-f]{8}}.css", css_asset_route)
app.router.add_get("/subscription_webapp.css", css_asset_route) app.router.add_get("/subscription_webapp.css", css_asset_route)
app.router.add_get( app.router.add_get(
@@ -89,6 +85,7 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
"/api/account/telegram/notifications/probe", "/api/account/telegram/notifications/probe",
account_telegram_notifications_probe_route, account_telegram_notifications_probe_route,
) )
app.router.add_post("/api/referral/welcome-bonus/claim", referral_welcome_bonus_claim_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)
+160 -17
View File
@@ -1,6 +1,11 @@
# 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 bot.app.web.webapp.auth import (
_referral_welcome_telegram_required_reason,
_trial_telegram_required_reason,
_user_has_linked_telegram,
)
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 ( from bot.services.telegram_notifications import (
@@ -64,20 +69,48 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
if active and local_sub if active and local_sub
else None else None
) )
trial_available = bool( trial_base_available = bool(
settings.TRIAL_ENABLED settings.TRIAL_ENABLED
and settings.TRIAL_DURATION_DAYS > 0 and settings.TRIAL_DURATION_DAYS > 0
and not await subscription_service.has_trial_blocking_subscription(session, user_id) and not await subscription_service.has_trial_blocking_subscription(session, user_id)
) )
trial_telegram_required_reason = (
_trial_telegram_required_reason(settings, db_user) if trial_base_available else None
)
trial_available = bool(trial_base_available and not trial_telegram_required_reason)
lang = _normalize_language(db_user.language_code or settings.DEFAULT_LANGUAGE)
plans_payload = _serialize_plans(
settings,
lang,
subscription_options=cached["subscription_options"],
stars_subscription_options=cached["stars_subscription_options"],
traffic_packages=cached["traffic_packages"],
stars_traffic_packages=cached["stars_traffic_packages"],
)
await _attach_hwid_renewal_quotes_to_plans(
session,
subscription_service,
user_id=user_id,
settings=settings,
active=active,
local_sub=local_sub,
plans=plans_payload,
)
avatar = await _ensure_cached_telegram_avatar(request, session, db_user) avatar = await _ensure_cached_telegram_avatar(request, session, db_user)
try: try:
await session.commit() await session.commit()
except Exception: except Exception:
await session.rollback() await session.rollback()
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_linked = _user_has_linked_telegram(db_user)
referral_welcome_days = max(0, int(getattr(settings, "REFERRAL_WELCOME_BONUS_DAYS", 0) or 0))
referral_welcome_telegram_required_reason = (
_referral_welcome_telegram_required_reason(settings, db_user)
if db_user.referred_by_id and not active and referral_welcome_days > 0
else None
)
telegram_notifications_status = normalize_telegram_notification_status( telegram_notifications_status = normalize_telegram_notification_status(
getattr(db_user, "telegram_notifications_status", None) getattr(db_user, "telegram_notifications_status", None)
) )
@@ -94,7 +127,7 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
db_user.email and db_user.email_verified_at and db_user.password_hash db_user.email and db_user.email_verified_at and db_user.password_hash
), ),
"telegram_id": db_user.telegram_id, "telegram_id": db_user.telegram_id,
"telegram_linked": bool(_telegram_id_for_user(db_user)), "telegram_linked": telegram_linked,
"telegram_notifications_status": telegram_notifications_status, "telegram_notifications_status": telegram_notifications_status,
"telegram_notifications_enabled": ( "telegram_notifications_enabled": (
telegram_notifications_status == TELEGRAM_NOTIFICATIONS_ENABLED telegram_notifications_status == TELEGRAM_NOTIFICATIONS_ENABLED
@@ -120,22 +153,20 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
"webapp_link": webapp_referral_link, "webapp_link": webapp_referral_link,
"invited_count": referral_stats.get("invited_count", 0), "invited_count": referral_stats.get("invited_count", 0),
"purchased_count": referral_stats.get("purchased_count", 0), "purchased_count": referral_stats.get("purchased_count", 0),
"welcome_bonus_days": max( "welcome_bonus_days": referral_welcome_days,
0, int(getattr(settings, "REFERRAL_WELCOME_BONUS_DAYS", 0) or 0) "welcome_bonus_without_telegram_enabled": bool(
getattr(settings, "REFERRAL_WELCOME_BONUS_WITHOUT_TELEGRAM_ENABLED", True)
), ),
"welcome_bonus_requires_telegram": bool(
referral_welcome_telegram_required_reason and not telegram_linked
),
"welcome_bonus_block_reason": referral_welcome_telegram_required_reason,
"one_bonus_per_referee": bool( "one_bonus_per_referee": bool(
getattr(settings, "REFERRAL_ONE_BONUS_PER_REFEREE", False) getattr(settings, "REFERRAL_ONE_BONUS_PER_REFEREE", False)
), ),
"bonus_details": _serialize_referral_bonus_details(settings, lang), "bonus_details": _serialize_referral_bonus_details(settings, lang),
}, },
"plans": _serialize_plans( "plans": plans_payload,
settings,
lang,
subscription_options=cached["subscription_options"],
stars_subscription_options=cached["stars_subscription_options"],
traffic_packages=cached["traffic_packages"],
stars_traffic_packages=cached["stars_traffic_packages"],
),
"payment_methods": _serialize_payment_methods( "payment_methods": _serialize_payment_methods(
settings, settings,
request.app, request.app,
@@ -164,6 +195,11 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
), ),
"trial_enabled": bool(settings.TRIAL_ENABLED), "trial_enabled": bool(settings.TRIAL_ENABLED),
"trial_available": trial_available, "trial_available": trial_available,
"trial_without_telegram_enabled": bool(
getattr(settings, "TRIAL_WITHOUT_TELEGRAM_ENABLED", True)
),
"trial_requires_telegram": bool(trial_telegram_required_reason and not telegram_linked),
"trial_block_reason": trial_telegram_required_reason,
"trial_duration_days": int(settings.TRIAL_DURATION_DAYS or 0), "trial_duration_days": int(settings.TRIAL_DURATION_DAYS or 0),
"trial_traffic_limit_gb": float(settings.TRIAL_TRAFFIC_LIMIT_GB or 0), "trial_traffic_limit_gb": float(settings.TRIAL_TRAFFIC_LIMIT_GB or 0),
"trial_traffic_strategy": getattr(settings, "TRIAL_TRAFFIC_STRATEGY", "NO_RESET"), "trial_traffic_strategy": getattr(settings, "TRIAL_TRAFFIC_STRATEGY", "NO_RESET"),
@@ -342,11 +378,12 @@ def _serialize_subscription(
and tariff.premium_topup_packages.has_any() and tariff.premium_topup_packages.has_any()
) )
can_topup_traffic = bool(can_topup_regular_traffic or can_topup_premium_traffic) can_topup_traffic = bool(can_topup_regular_traffic or can_topup_premium_traffic)
# max_devices == 0 means unlimited — top-up is pointless in that case. max_devices = _coerce_int_or_none(active.get("max_devices"))
# max_devices == 0 or None means unlimited — top-up is pointless in that case.
can_topup_devices = bool( can_topup_devices = bool(
tariff.billing_model == "period" tariff.billing_model == "period"
and tariff.has_hwid_device_packages() and tariff.has_hwid_device_packages()
and _coerce_int_or_none(active.get("max_devices")) != 0 and max_devices not in (None, 0)
) )
except Exception: except Exception:
can_topup_regular_traffic = False can_topup_regular_traffic = False
@@ -437,6 +474,102 @@ def _serialize_subscription(
} }
def _webapp_iso_datetime(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.isoformat()
return str(value)
def _webapp_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)
async def _attach_hwid_renewal_quotes_to_plans(
session: AsyncSession,
subscription_service: SubscriptionService,
*,
user_id: int,
settings: Settings,
active: Optional[Dict[str, Any]],
local_sub: Optional[Any],
plans: List[Dict[str, Any]],
) -> None:
quote_method = getattr(subscription_service, "quote_hwid_device_renewal_for_subscription", None)
if not callable(quote_method):
return
if not active or not local_sub or not settings.tariffs_config:
return
if not active.get("end_date") or int(active.get("extra_hwid_devices") or 0) <= 0:
return
default_currency = default_currency_key_for_settings(settings)
default_currency_code = payment_currency_code(default_currency)
for plan in plans:
if str(plan.get("sale_mode") or "subscription") != "subscription":
continue
target_tariff_key = str(plan.get("tariff_key") or "").strip()
if not target_tariff_key:
continue
try:
months = int(plan.get("months") or 0)
except (TypeError, ValueError):
continue
if months <= 0:
continue
try:
currency_quote = await quote_method(
session,
user_id=user_id,
target_tariff_key=target_tariff_key,
months=months,
currency=default_currency,
)
stars_quote = await quote_method(
session,
user_id=user_id,
target_tariff_key=target_tariff_key,
months=months,
currency="stars",
)
except Exception:
logger.exception(
"Failed to quote HWID renewal for plan %s/%s",
target_tariff_key,
months,
)
continue
quote = currency_quote or stars_quote
if not quote:
continue
valid_from = quote.get("valid_from")
valid_until = quote.get("valid_until")
active_until = quote.get("active_until")
renewal = {
"available": True,
"device_count": int(quote.get("device_count") or 0),
"price": float(currency_quote.get("price") if currency_quote else 0),
"currency": default_currency_code,
"valid_from": _webapp_iso_datetime(valid_from),
"valid_from_text": _webapp_datetime_text(valid_from),
"valid_until": _webapp_iso_datetime(valid_until),
"valid_until_text": _webapp_datetime_text(valid_until),
"active_until": _webapp_iso_datetime(active_until),
"active_until_text": _webapp_datetime_text(active_until),
"pricing_period_months": int(quote.get("pricing_period_months") or months),
}
if stars_quote and int(stars_quote.get("price") or 0) > 0:
renewal["stars_price"] = int(stars_quote["price"])
plan["hwid_renewal"] = renewal
def _build_install_share_link( def _build_install_share_link(
request: Optional[web.Request], request: Optional[web.Request],
settings: Settings, settings: Settings,
@@ -495,7 +628,10 @@ def _serialize_plans(
else [], else [],
} }
if tariff.billing_model == "period": if tariff.billing_model == "period":
for months in sorted(tariff.enabled_periods): # Render periods in the configured order (enabled_periods is the
# source of truth for purchase-period ordering, matching the bot
# keyboards). Do not sort so admins can reorder via drag & drop.
for months in tariff.enabled_periods:
price = tariff.period_price(int(months), default_currency) price = tariff.period_price(int(months), default_currency)
stars_price = tariff.period_price(int(months), "stars") stars_price = tariff.period_price(int(months), "stars")
if price is None and (stars_price is None or int(stars_price) <= 0): if price is None and (stars_price is None or int(stars_price) <= 0):
@@ -528,7 +664,14 @@ def _serialize_plans(
tariff.traffic_packages.stars if tariff.traffic_packages else [] tariff.traffic_packages.stars if tariff.traffic_packages else []
) )
} }
for traffic_gb in sorted(set(currency_packages) | set(stars_packages)): # Preserve the configured package order (default-currency list first,
# then any Stars-only volumes) so admins can reorder via drag & drop.
# Matches the bot keyboard, which iterates the package list as-is.
ordered_gb: List[float] = []
for traffic_gb in list(currency_packages) + list(stars_packages):
if traffic_gb not in ordered_gb:
ordered_gb.append(traffic_gb)
for traffic_gb in ordered_gb:
price = currency_packages.get(traffic_gb) price = currency_packages.get(traffic_gb)
stars_price = stars_packages.get(traffic_gb) stars_price = stars_packages.get(traffic_gb)
if price is None and (stars_price is None or int(stars_price) <= 0): if price is None and (stars_price is None or int(stars_price) <= 0):
+316 -2
View File
@@ -236,6 +236,10 @@ def get_user_card_keyboard(
text=_(key="admin_user_traffic_grant_button"), text=_(key="admin_user_traffic_grant_button"),
callback_data=f"user_action:traffic_grant:{user_id}", callback_data=f"user_action:traffic_grant:{user_id}",
) )
builder.button(
text=_(key="admin_user_hwid_limit_button"),
callback_data=f"user_action:hwid_limit:{user_id}",
)
# Row 4: Quick links — only for users with a real Telegram profile # Row 4: Quick links — only for users with a real Telegram profile
# (synthetic email-only users have a negative user_id with no tg profile). # (synthetic email-only users have a negative user_id with no tg profile).
@@ -261,9 +265,9 @@ def get_user_card_keyboard(
quick_links_count = (1 if has_self_link else 0) + (1 if has_referrer_link else 0) quick_links_count = (1 if has_self_link else 0) + (1 if has_referrer_link else 0)
if quick_links_count == 0: if quick_links_count == 0:
builder.adjust(2, 2, 2, 1, 2, 1, 2) builder.adjust(2, 2, 2, 1, 3, 1, 2)
else: else:
builder.adjust(2, 2, 2, 1, 2, quick_links_count, 1, 2) builder.adjust(2, 2, 2, 1, 3, quick_links_count, 1, 2)
return builder return builder
@@ -403,6 +407,26 @@ async def format_user_card(
f"{_('admin_user_traffic_label')} {hcode(f'{used_display} / {limit_display}')}" f"{_('admin_user_traffic_label')} {hcode(f'{used_display} / {limit_display}')}"
) )
max_devices = subscription_details.get("max_devices")
extra_hwid_devices = int(subscription_details.get("extra_hwid_devices") or 0)
if max_devices is not None:
if int(max_devices) == 0:
devices_display = _("admin_hwid_limit_state_unlimited")
elif extra_hwid_devices > 0:
base_hwid_limit = subscription_details.get("base_hwid_device_limit")
if base_hwid_limit is None:
devices_display = _("admin_hwid_limit_state_count", count=int(max_devices))
else:
devices_display = _(
"admin_hwid_limit_state_with_extra",
total=int(max_devices),
base=int(base_hwid_limit),
extra=extra_hwid_devices,
)
else:
devices_display = _("admin_hwid_limit_state_count", count=int(max_devices))
card_parts.append(f"{_('admin_user_hwid_limit_label')} {hcode(devices_display)}")
premium_unlimited = bool(subscription_details.get("premium_unlimited_override")) premium_unlimited = bool(subscription_details.get("premium_unlimited_override"))
premium_bonus_bytes = int(subscription_details.get("premium_bonus_bytes") or 0) premium_bonus_bytes = int(subscription_details.get("premium_bonus_bytes") or 0)
if premium_unlimited: if premium_unlimited:
@@ -706,6 +730,32 @@ async def user_action_handler(
await handle_traffic_grant_prompt(callback, state, user, "regular", i18n, current_lang) await handle_traffic_grant_prompt(callback, state, user, "regular", i18n, current_lang)
elif action == "traffic_grant_premium": elif action == "traffic_grant_premium":
await handle_traffic_grant_prompt(callback, state, user, "premium", i18n, current_lang) await handle_traffic_grant_prompt(callback, state, user, "premium", i18n, current_lang)
elif action == "hwid_limit":
await handle_hwid_limit_menu(callback, state, user, session, i18n, current_lang)
elif action == "hwid_limit_set_unlimited":
await handle_hwid_limit_apply(
callback,
user,
subscription_service,
session,
settings,
i18n,
current_lang,
hwid_device_limit=0,
)
elif action == "hwid_limit_reset":
await handle_hwid_limit_apply(
callback,
user,
subscription_service,
session,
settings,
i18n,
current_lang,
hwid_device_limit=None,
)
elif action == "hwid_limit_set_number":
await handle_hwid_limit_prompt(callback, state, user, i18n, current_lang)
else: else:
await callback.answer(_("admin_unknown_action"), show_alert=True) await callback.answer(_("admin_unknown_action"), show_alert=True)
@@ -850,6 +900,162 @@ async def handle_premium_override_bonus_prompt(
await callback.answer() await callback.answer()
def _admin_hwid_limit_state_text(
get_text: Callable[..., str],
hwid_device_limit: Optional[int],
extra_hwid_devices: int = 0,
) -> str:
if hwid_device_limit is None:
return get_text("admin_hwid_limit_state_default")
base_limit = int(hwid_device_limit)
if base_limit == 0:
return get_text("admin_hwid_limit_state_unlimited")
extra = max(0, int(extra_hwid_devices or 0))
if extra > 0:
return get_text(
"admin_hwid_limit_state_with_extra",
total=base_limit + extra,
base=base_limit,
extra=extra,
)
return get_text("admin_hwid_limit_state_count", count=base_limit)
async def handle_hwid_limit_menu(
callback: types.CallbackQuery,
state: FSMContext,
user: User,
session: AsyncSession,
i18n_instance,
lang: str,
) -> None:
"""Show HWID device limit override controls."""
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
active_sub = await subscription_dal.get_active_subscription_by_user_id(session, user.user_id)
if not active_sub:
await callback.answer(_("admin_hwid_limit_no_subscription"), show_alert=True)
return
current_text = _admin_hwid_limit_state_text(
_,
getattr(active_sub, "hwid_device_limit", None),
int(getattr(active_sub, "extra_hwid_devices", 0) or 0),
)
text = "\n".join(
[
f"<b>{_('admin_hwid_limit_title')}</b>",
"",
_("admin_hwid_limit_hint"),
"",
_("admin_hwid_limit_current", current=current_text),
]
)
builder = InlineKeyboardBuilder()
builder.button(
text=_("admin_hwid_limit_btn_set_number"),
callback_data=f"user_action:hwid_limit_set_number:{user.user_id}",
)
builder.button(
text=_("admin_hwid_limit_btn_unlimited"),
callback_data=f"user_action:hwid_limit_set_unlimited:{user.user_id}",
)
builder.button(
text=_("admin_hwid_limit_btn_reset"),
callback_data=f"user_action:hwid_limit_reset:{user.user_id}",
)
builder.button(
text=_("admin_user_back_to_card_button"),
callback_data=f"user_action:refresh:{user.user_id}",
)
builder.adjust(1, 1, 1, 1)
try:
await callback.message.edit_text(text, reply_markup=builder.as_markup(), parse_mode="HTML")
except Exception:
await callback.message.answer(text, reply_markup=builder.as_markup(), parse_mode="HTML")
await state.update_data(target_user_id=user.user_id)
await callback.answer()
async def handle_hwid_limit_apply(
callback: types.CallbackQuery,
user: User,
subscription_service: SubscriptionService,
session: AsyncSession,
settings: Settings,
i18n_instance,
lang: str,
*,
hwid_device_limit: Optional[int],
) -> None:
"""Persist a HWID device base limit override and push it to the panel."""
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
try:
active_sub = await subscription_dal.get_active_subscription_by_user_id(
session, user.user_id
)
if not active_sub:
await callback.answer(_("admin_hwid_limit_no_subscription"), show_alert=True)
return
active_sub.hwid_device_limit = hwid_device_limit
effective_limit = await subscription_service.sync_hwid_device_limit_to_panel(
session, user.user_id
)
await message_log_dal.create_message_log_no_commit(
session,
{
"user_id": callback.from_user.id if callback.from_user else user.user_id,
"event_type": "admin:hwid_device_limit",
"content": (
f"hwid_device_limit={hwid_device_limit!r} "
f"effective_hwid_device_limit={effective_limit!r}"
),
"is_admin_event": True,
"target_user_id": user.user_id,
"timestamp": datetime.now(timezone.utc),
},
)
await session.commit()
await callback.answer(_("admin_hwid_limit_saved"), show_alert=False)
await handle_refresh_user_card(
callback, user, subscription_service, session, settings, i18n_instance, lang
)
except Exception as exc:
logging.error(
"Failed to apply HWID device limit for user %s: %s",
user.user_id,
exc,
exc_info=True,
)
await session.rollback()
await callback.answer(_("admin_hwid_limit_save_error"), show_alert=True)
async def handle_hwid_limit_prompt(
callback: types.CallbackQuery,
state: FSMContext,
user: User,
i18n_instance,
lang: str,
) -> None:
"""Ask admin for an explicit HWID device limit."""
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
await state.update_data(target_user_id=user.user_id)
await state.set_state(AdminStates.waiting_for_hwid_device_limit)
prompt = _("admin_hwid_limit_prompt", user_id=user.user_id)
try:
await callback.message.edit_text(prompt)
except Exception:
await callback.message.answer(prompt)
await callback.answer()
async def handle_traffic_grant_menu( async def handle_traffic_grant_menu(
callback: types.CallbackQuery, callback: types.CallbackQuery,
user: User, user: User,
@@ -1971,6 +2177,114 @@ async def process_premium_override_bonus_handler(
await state.clear() await state.clear()
@router.message(AdminStates.waiting_for_hwid_device_limit, F.text)
async def process_hwid_device_limit_handler(
message: types.Message,
state: FSMContext,
settings: Settings,
i18n_data: dict,
subscription_service: SubscriptionService,
session: AsyncSession,
):
"""Read explicit HWID device limit and apply it."""
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n:
await message.reply("Language service error.")
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
data = await state.get_data()
target_user_id = data.get("target_user_id")
if not target_user_id:
await message.answer(_("admin_hwid_limit_state_missing"))
await state.clear()
return
raw = (message.text or "").strip()
try:
hwid_device_limit = int(raw)
if hwid_device_limit < 0 or hwid_device_limit > 1_000_000:
raise ValueError("out_of_range")
except (TypeError, ValueError):
await message.answer(_("admin_hwid_limit_invalid"))
return
target_user = await user_dal.get_user_by_id(session, target_user_id)
if not target_user:
await message.answer(_("admin_user_not_found_action"))
await state.clear()
return
try:
active_sub = await subscription_dal.get_active_subscription_by_user_id(
session, target_user_id
)
if not active_sub:
await message.answer(_("admin_hwid_limit_no_subscription"))
await state.clear()
return
active_sub.hwid_device_limit = hwid_device_limit
effective_limit = await subscription_service.sync_hwid_device_limit_to_panel(
session, target_user_id
)
await message_log_dal.create_message_log_no_commit(
session,
{
"user_id": message.from_user.id if message.from_user else target_user_id,
"event_type": "admin:hwid_device_limit",
"content": (
f"hwid_device_limit={hwid_device_limit!r} "
f"effective_hwid_device_limit={effective_limit!r}"
),
"is_admin_event": True,
"target_user_id": target_user_id,
"timestamp": datetime.now(timezone.utc),
},
)
await session.commit()
current_text = _admin_hwid_limit_state_text(_, hwid_device_limit)
await message.answer(
_("admin_hwid_limit_set", current=current_text, user_id=target_user_id)
)
referral_service = ReferralService(settings, subscription_service, message.bot, i18n)
bot_username = await _resolve_bot_username(message.bot)
user_card_text = await format_user_card(
target_user,
session,
subscription_service,
i18n,
current_lang,
referral_service,
settings=settings,
bot_username=bot_username,
)
keyboard = get_user_card_keyboard(
target_user.user_id, i18n, current_lang, target_user.referred_by_id
)
await _send_with_profile_link_fallback(
message.answer,
text=user_card_text,
markup=keyboard.as_markup(),
user_id=target_user.user_id,
parse_mode="HTML",
)
except Exception as exc:
logging.error(
"Error setting HWID device limit for user %s: %s",
target_user_id,
exc,
exc_info=True,
)
await session.rollback()
await message.answer(_("admin_hwid_limit_save_error"))
finally:
await state.clear()
@router.message(AdminStates.waiting_for_traffic_grant_gb, F.text) @router.message(AdminStates.waiting_for_traffic_grant_gb, F.text)
async def process_traffic_grant_gb_handler( async def process_traffic_grant_gb_handler(
message: types.Message, message: types.Message,
+76 -27
View File
@@ -27,6 +27,7 @@ from bot.utils.callback_answer import safe_answer_callback
from bot.utils.channel_subscription import ( from bot.utils.channel_subscription import (
is_required_channel_access_error, is_required_channel_access_error,
normalize_required_channel_id, normalize_required_channel_id,
resolve_required_channel_link,
) )
from bot.utils.install_links import ( from bot.utils.install_links import (
append_install_share_link_text, append_install_share_link_text,
@@ -40,6 +41,67 @@ from db.models import User
router = Router(name="user_start_router") router = Router(name="user_start_router")
def _remnashop_referral_compat_enabled(settings: Settings) -> bool:
return bool(getattr(settings, "MIGRATION_REMNASHOP_REFERRAL_CODE_COMPAT_ENABLED", False))
def _referral_code_lookup_candidates(
raw_ref_value: str,
*,
remnashop_compat: bool,
) -> list[str]:
value = str(raw_ref_value or "").strip()
if not value:
return []
candidates = [value]
if value and value[0].lower() == "u":
stripped_current_prefix = value[1:]
if remnashop_compat:
candidates.append(stripped_current_prefix)
else:
candidates = [stripped_current_prefix]
unique: list[str] = []
for candidate in candidates:
candidate = candidate.strip()
if candidate and candidate not in unique:
unique.append(candidate)
return unique
async def _resolve_referrer_from_start_ref(
session: AsyncSession,
raw_ref_value: str,
*,
settings: Settings,
current_user_id: int,
) -> Optional[int]:
ref_user: Optional[User] = None
if raw_ref_value.isdigit() and settings.LEGACY_REFS:
potential_referrer_id = int(raw_ref_value)
if potential_referrer_id != current_user_id:
ref_user = await user_dal.get_user_by_id(session, potential_referrer_id)
include_legacy = _remnashop_referral_compat_enabled(settings)
if not ref_user:
for code in _referral_code_lookup_candidates(
raw_ref_value,
remnashop_compat=include_legacy,
):
ref_user = await user_dal.get_user_by_referral_code(
session,
code,
include_legacy=include_legacy,
)
if ref_user:
break
if ref_user and ref_user.user_id != current_user_id:
return int(ref_user.user_id)
return None
async def should_show_trial_button( async def should_show_trial_button(
settings: Settings, settings: Settings,
subscription_service: SubscriptionService, subscription_service: SubscriptionService,
@@ -376,11 +438,12 @@ async def ensure_required_channel_subscription(
) )
return True return True
keyboard = ( channel_link = await resolve_required_channel_link(
get_channel_subscription_keyboard(current_lang, i18n, settings.REQUIRED_CHANNEL_LINK) bot_instance,
if i18n required_channel_id,
else None settings.REQUIRED_CHANNEL_LINK,
) )
keyboard = get_channel_subscription_keyboard(current_lang, i18n, channel_link) if i18n else None
prompt_text = translate("channel_subscription_required") prompt_text = translate("channel_subscription_required")
@@ -410,14 +473,10 @@ async def ensure_required_channel_subscription(
@router.message(CommandStart()) @router.message(CommandStart())
@router.message(CommandStart(magic=F.args.regexp(r"^ref_([A-Za-z0-9_-]{1,64})$").as_("ref_match")))
@router.message( @router.message(
CommandStart( CommandStart(magic=F.args.regexp(r"^promo_([A-Za-z0-9_-]{1,100})$").as_("promo_match"))
magic=F.args.regexp(r"^ref_((?:[uU][A-Za-z0-9]{9})|(?:[A-Za-z0-9]{9})|\d+)$").as_(
"ref_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"^notifications$").as_("notifications_match")))
@@ -534,22 +593,12 @@ async def start_command_handler(
if ref_match: if ref_match:
raw_ref_value = ref_match.group(1) raw_ref_value = ref_match.group(1)
if raw_ref_value.isdigit(): referred_by_user_id = await _resolve_referrer_from_start_ref(
if settings.LEGACY_REFS: session,
potential_referrer_id = int(raw_ref_value) raw_ref_value,
if potential_referrer_id != user_id and await user_dal.get_user_by_id( settings=settings,
session, potential_referrer_id current_user_id=user_id,
): )
referred_by_user_id = potential_referrer_id
else:
normalized_code = raw_ref_value.strip()
if normalized_code and normalized_code[0].lower() == "u":
normalized_code = normalized_code[1:]
ref_user = None
if normalized_code:
ref_user = await user_dal.get_user_by_referral_code(session, normalized_code)
if ref_user and ref_user.user_id != user_id:
referred_by_user_id = ref_user.user_id
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}")
@@ -1135,7 +1184,7 @@ async def main_action_callback_handler(
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
privacy_url = settings.PRIVACY_POLICY_URL privacy_url = settings.PRIVACY_POLICY_URL
user_agreement_url = settings.USER_AGREEMENT_URL or settings.TERMS_OF_SERVICE_URL user_agreement_url = settings.USER_AGREEMENT_URL
if not privacy_url and not user_agreement_url: if not privacy_url and not user_agreement_url:
await safe_answer_callback( await safe_answer_callback(
+32 -10
View File
@@ -319,7 +319,11 @@ async def select_tariff_callback(
@router.callback_query(F.data.startswith("tariff:period:")) @router.callback_query(F.data.startswith("tariff:period:"))
async def select_tariff_period_callback( async def select_tariff_period_callback(
callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession callback: types.CallbackQuery,
i18n_data: dict,
settings: Settings,
session: AsyncSession,
subscription_service: SubscriptionService,
): ):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: JsonI18n = i18n_data.get("i18n_instance") i18n: JsonI18n = i18n_data.get("i18n_instance")
@@ -333,7 +337,9 @@ async def select_tariff_period_callback(
await callback.answer(get_text("error_try_again"), show_alert=True) await callback.answer(get_text("error_try_again"), show_alert=True)
return return
tariff_key, months_raw = parts[2], parts[3] tariff_key, months_raw = parts[2], parts[3]
callback_context = parts[4] if len(parts) > 4 else None callback_tokens = [part for part in parts[4:] if part]
callback_context = "bot" if "bot" in callback_tokens else None
renew_hwid_devices = "no_hwid" not in callback_tokens
tariff = config.require(tariff_key) tariff = config.require(tariff_key)
months = int(months_raw) months = int(months_raw)
default_currency = default_currency_key_for_settings(settings) default_currency = default_currency_key_for_settings(settings)
@@ -343,6 +349,22 @@ async def select_tariff_period_callback(
if price_rub is None: if price_rub is None:
await callback.answer(get_text("error_try_again"), show_alert=True) await callback.answer(get_text("error_try_again"), show_alert=True)
return return
hwid_renewal_quote = await subscription_service.quote_hwid_device_renewal_for_subscription(
session,
user_id=callback.from_user.id,
target_tariff_key=tariff.key,
months=months,
currency=default_currency,
)
hwid_renewal_stars_quote = (
await subscription_service.quote_hwid_device_renewal_for_subscription(
session,
user_id=callback.from_user.id,
target_tariff_key=tariff.key,
months=months,
currency="stars",
)
)
markup = get_payment_method_keyboard( markup = get_payment_method_keyboard(
months, months,
price_rub, price_rub,
@@ -354,6 +376,9 @@ async def select_tariff_period_callback(
sale_mode=sale_mode_with_callback_context(f"subscription@{tariff.key}", callback_context), sale_mode=sale_mode_with_callback_context(f"subscription@{tariff.key}", callback_context),
back_callback=f"tariff:select:{tariff.key}{callback_suffix_for_context(callback_context)}", back_callback=f"tariff:select:{tariff.key}{callback_suffix_for_context(callback_context)}",
user_id=callback.from_user.id, user_id=callback.from_user.id,
hwid_renewal_quote=hwid_renewal_quote,
hwid_renewal_stars_quote=hwid_renewal_stars_quote,
hwid_renewal_selected=bool(renew_hwid_devices),
) )
await callback.message.edit_text(get_text("choose_payment_method"), reply_markup=markup) await callback.message.edit_text(get_text("choose_payment_method"), reply_markup=markup)
await callback.answer() await callback.answer()
@@ -577,7 +602,6 @@ async def hwid_devices_list_callback(
if not packages: if not packages:
await callback.answer(get_text("no_hwid_device_packages_available"), show_alert=True) await callback.answer(get_text("no_hwid_device_packages_available"), show_alert=True)
return return
renewal_available = bool(active.get("device_topup_renewal_available"))
markup = get_hwid_device_packages_keyboard( markup = get_hwid_device_packages_keyboard(
tariff, tariff,
packages, packages,
@@ -585,14 +609,11 @@ async def hwid_devices_list_callback(
i18n, i18n,
settings, settings,
back_callback="main_action:my_devices", back_callback="main_action:my_devices",
renewal=renewal_available, renewal=False,
)
text_key = (
"select_hwid_device_renewal_package" if renewal_available else "select_hwid_device_package"
) )
await callback.message.edit_text( await callback.message.edit_text(
get_text( get_text(
text_key, "select_hwid_device_package",
date=active.get("extra_hwid_devices_valid_until_text") or "", date=active.get("extra_hwid_devices_valid_until_text") or "",
), ),
reply_markup=markup, reply_markup=markup,
@@ -640,6 +661,7 @@ async def hwid_devices_package_callback(
await callback.answer(get_text("error_try_again"), show_alert=True) await callback.answer(get_text("error_try_again"), show_alert=True)
return return
sale_mode_base = "hwid_devices_renewal" if action == "renewal_package" else "hwid_devices" sale_mode_base = "hwid_devices_renewal" if action == "renewal_package" else "hwid_devices"
renewal = action == "renewal_package"
default_currency = default_currency_key_for_settings(settings) default_currency = default_currency_key_for_settings(settings)
currency_code = default_payment_currency_code_for_settings(settings) currency_code = default_payment_currency_code_for_settings(settings)
currency_quote = await subscription_service.quote_hwid_device_topup( currency_quote = await subscription_service.quote_hwid_device_topup(
@@ -647,7 +669,7 @@ async def hwid_devices_package_callback(
user_id=callback.from_user.id, user_id=callback.from_user.id,
device_count=count, device_count=count,
tariff_key=tariff.key, tariff_key=tariff.key,
renewal=action == "renewal_package", renewal=renewal,
currency=default_currency, currency=default_currency,
) )
stars_quote = await subscription_service.quote_hwid_device_topup( stars_quote = await subscription_service.quote_hwid_device_topup(
@@ -655,7 +677,7 @@ async def hwid_devices_package_callback(
user_id=callback.from_user.id, user_id=callback.from_user.id,
device_count=count, device_count=count,
tariff_key=tariff.key, tariff_key=tariff.key,
renewal=action == "renewal_package", renewal=renewal,
currency="stars", currency="stars",
) )
if not currency_quote and not stars_quote: if not currency_quote and not stars_quote:
+76 -19
View File
@@ -4,6 +4,7 @@ from aiogram.types import InlineKeyboardMarkup, WebAppInfo
from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton
from bot.middlewares.i18n import locale_language_options from bot.middlewares.i18n import locale_language_options
from bot.utils.channel_subscription import normalize_required_channel_link
from bot.utils.install_links import bot_install_guide_url from bot.utils.install_links import bot_install_guide_url
from bot.utils.mini_app_url import subscription_mini_app_trial_url from bot.utils.mini_app_url import subscription_mini_app_trial_url
from config.settings import Settings from config.settings import Settings
@@ -13,6 +14,13 @@ from config.tariffs_config import (
) )
BOT_MENU_CONTEXT = "bot" BOT_MENU_CONTEXT = "bot"
HWID_RENEWAL_TOKEN = "hwid_renewal"
def sale_mode_tokens(sale_mode: Optional[str]) -> Tuple[str, ...]:
if not sale_mode or "|" not in sale_mode:
return ()
return tuple(token.strip() for token in str(sale_mode).split("|")[1:] if token.strip())
def callback_context_from_back_callback(back_callback: Optional[str]) -> Optional[str]: def callback_context_from_back_callback(back_callback: Optional[str]) -> Optional[str]:
@@ -23,16 +31,36 @@ def callback_context_from_back_callback(back_callback: Optional[str]) -> Optiona
def sale_mode_with_callback_context(sale_mode: str, context: Optional[str]) -> str: def sale_mode_with_callback_context(sale_mode: str, context: Optional[str]) -> str:
sale_mode = sale_mode or "subscription" sale_mode = sale_mode or "subscription"
if not context or "|" in sale_mode: if not context or context in sale_mode_tokens(sale_mode):
return sale_mode return sale_mode
return f"{sale_mode}|{context}" return f"{sale_mode}|{context}"
def sale_mode_with_token(sale_mode: str, token: str) -> str:
sale_mode = sale_mode or "subscription"
token = str(token or "").strip()
if not token or token in sale_mode_tokens(sale_mode):
return sale_mode
return f"{sale_mode}|{token}"
def sale_mode_without_token(sale_mode: str, token: str) -> str:
sale_mode = sale_mode or "subscription"
token = str(token or "").strip()
if not token or "|" not in sale_mode:
return sale_mode
base, *tokens = sale_mode.split("|")
kept = [item for item in tokens if item.strip() and item.strip() != token]
return "|".join([base, *kept])
def sale_mode_has_token(sale_mode: Optional[str], token: str) -> bool:
return str(token or "").strip() in sale_mode_tokens(sale_mode)
def callback_context_from_sale_mode(sale_mode: Optional[str]) -> Optional[str]: def callback_context_from_sale_mode(sale_mode: Optional[str]) -> Optional[str]:
if not sale_mode or "|" not in sale_mode: tokens = sale_mode_tokens(sale_mode)
return None return BOT_MENU_CONTEXT if BOT_MENU_CONTEXT in tokens else None
context = str(sale_mode).split("|", 1)[1].strip()
return context or None
def callback_suffix_for_context(context: Optional[str]) -> str: def callback_suffix_for_context(context: Optional[str]) -> str:
@@ -138,8 +166,7 @@ def get_main_menu_inline_keyboard(
InlineKeyboardButton(text=_(key="menu_support_button"), url=settings.SUPPORT_LINK) InlineKeyboardButton(text=_(key="menu_support_button"), url=settings.SUPPORT_LINK)
) )
user_agreement_url = settings.USER_AGREEMENT_URL or settings.TERMS_OF_SERVICE_URL if settings.PRIVACY_POLICY_URL or settings.USER_AGREEMENT_URL:
if settings.PRIVACY_POLICY_URL or user_agreement_url:
builder.row( builder.row(
InlineKeyboardButton(text=_(key="menu_info_button"), callback_data="main_action:info") InlineKeyboardButton(text=_(key="menu_info_button"), callback_data="main_action:info")
) )
@@ -206,8 +233,7 @@ def get_bot_interface_inline_keyboard(
InlineKeyboardButton(text=_(key="menu_support_button"), url=settings.SUPPORT_LINK) InlineKeyboardButton(text=_(key="menu_support_button"), url=settings.SUPPORT_LINK)
) )
user_agreement_url = settings.USER_AGREEMENT_URL or settings.TERMS_OF_SERVICE_URL if settings.PRIVACY_POLICY_URL or settings.USER_AGREEMENT_URL:
if settings.PRIVACY_POLICY_URL or user_agreement_url:
builder.row( builder.row(
InlineKeyboardButton( InlineKeyboardButton(
text=_(key="menu_info_button"), callback_data="main_action:bot_info" text=_(key="menu_info_button"), callback_data="main_action:bot_info"
@@ -483,6 +509,9 @@ def get_payment_method_keyboard(
back_callback: Optional[str] = None, back_callback: Optional[str] = None,
user_id: Optional[int] = None, user_id: Optional[int] = None,
is_admin: Optional[bool] = None, is_admin: Optional[bool] = None,
hwid_renewal_quote: Optional[Dict[str, Any]] = None,
hwid_renewal_stars_quote: Optional[Dict[str, Any]] = None,
hwid_renewal_selected: bool = True,
) -> InlineKeyboardMarkup: ) -> InlineKeyboardMarkup:
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder() builder = InlineKeyboardBuilder()
@@ -491,12 +520,39 @@ def get_payment_method_keyboard(
return str(int(val)) if float(val).is_integer() else f"{val:g}" return str(int(val)) if float(val).is_integer() else f"{val:g}"
value_str = _format_value(months) value_str = _format_value(months)
import logging as _kbd_logging payment_sale_mode = sale_mode
selected_hwid_quote = hwid_renewal_quote or hwid_renewal_stars_quote
_kbd_logging.info( if selected_hwid_quote:
"payment_method_keyboard build: order=%s", tariff_key = None
settings.payment_methods_order, sale_mode_main = str(sale_mode or "").split("|", 1)[0]
) if "@" in sale_mode_main:
tariff_key = sale_mode_main.split("@", 1)[1]
context = callback_context_from_sale_mode(sale_mode)
toggle_tokens = [f"tariff:period:{tariff_key}:{value_str}"]
if context:
toggle_tokens.append(context)
toggle_tokens.append("no_hwid" if hwid_renewal_selected else "hwid")
builder.row(
InlineKeyboardButton(
text=_(
"payment_hwid_renewal_toggle_on"
if hwid_renewal_selected
else "payment_hwid_renewal_toggle_off",
count=int(selected_hwid_quote.get("device_count") or 0),
price=(
hwid_renewal_quote.get("price")
if hwid_renewal_quote
else hwid_renewal_stars_quote.get("price")
),
currency_symbol=currency_symbol_val,
),
callback_data=":".join(toggle_tokens),
)
)
if hwid_renewal_selected:
payment_sale_mode = sale_mode_with_token(sale_mode, HWID_RENEWAL_TOKEN)
else:
payment_sale_mode = sale_mode_without_token(sale_mode, HWID_RENEWAL_TOKEN)
from bot.payment_providers import get_provider_spec, provider_telegram_button_text from bot.payment_providers import get_provider_spec, provider_telegram_button_text
for method in settings.payment_methods_order: for method in settings.payment_methods_order:
@@ -517,7 +573,7 @@ def get_payment_method_keyboard(
value=value_str, value=value_str,
rub_price=price, rub_price=price,
stars_price=stars_price, stars_price=stars_price,
sale_mode=sale_mode, sale_mode=payment_sale_mode,
) )
if not callback_data: if not callback_data:
continue continue
@@ -576,7 +632,7 @@ def get_yk_autopay_choice_keyboard(
builder.row( builder.row(
InlineKeyboardButton( InlineKeyboardButton(
text=_(key="yookassa_autopay_pay_saved_card_button"), text=_(key="yookassa_autopay_pay_saved_card_button"),
callback_data=f"pay_yk_saved_list:{value_str}:{price_str}{suffix}", callback_data=f"pay_yk_saved_list:{value_str}:{price_str}:0{suffix}",
) )
) )
builder.row( builder.row(
@@ -718,10 +774,11 @@ def get_channel_subscription_keyboard(
has_buttons = False has_buttons = False
if channel_link: channel_url = normalize_required_channel_link(channel_link)
if channel_url:
builder.button( builder.button(
text=_(key="channel_subscription_join_button"), text=_(key="channel_subscription_join_button"),
url=channel_link, url=channel_url,
) )
has_buttons = True has_buttons = True
@@ -11,7 +11,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.inline.user_keyboards import get_channel_subscription_keyboard from bot.keyboards.inline.user_keyboards import get_channel_subscription_keyboard
from bot.middlewares.i18n import JsonI18n from bot.middlewares.i18n import JsonI18n
from bot.utils.channel_subscription import normalize_required_channel_id from bot.utils.channel_subscription import (
normalize_required_channel_id,
resolve_required_channel_link,
)
from config.settings import Settings from config.settings import Settings
from db.dal import user_dal from db.dal import user_dal
@@ -86,10 +89,14 @@ class ChannelSubscriptionMiddleware(BaseMiddleware):
return i18n_instance.gettext(current_lang, key) return i18n_instance.gettext(current_lang, key)
return key return key
bot_instance = data.get("bot") or data.get("bot_instance")
channel_link = await resolve_required_channel_link(
bot_instance,
required_channel_id,
self.settings.REQUIRED_CHANNEL_LINK,
)
keyboard = ( keyboard = (
get_channel_subscription_keyboard( get_channel_subscription_keyboard(current_lang, i18n_instance, channel_link)
current_lang, i18n_instance, self.settings.REQUIRED_CHANNEL_LINK
)
if i18n_instance if i18n_instance
else None else None
) )
+1
View File
@@ -114,6 +114,7 @@ class WebAppPaymentContext:
sale_mode: str sale_mode: str
currency: str = "RUB" currency: str = "RUB"
traffic_gb: Optional[float] = None traffic_gb: Optional[float] = None
hwid_device_count: Optional[int] = None
hwid_valid_from: Optional[Any] = None hwid_valid_from: Optional[Any] = None
hwid_valid_until: Optional[Any] = None hwid_valid_until: Optional[Any] = None
hwid_pricing_period_months: Optional[int] = None hwid_pricing_period_months: Optional[int] = None
+8 -1
View File
@@ -192,6 +192,7 @@ class CryptoPayService:
sale_mode: str = "subscription", sale_mode: str = "subscription",
url_kind: str = "bot", url_kind: str = "bot",
hwid_quote: Optional[dict] = None, hwid_quote: Optional[dict] = None,
hwid_device_count: Optional[int] = None,
currency: Optional[str] = None, currency: Optional[str] = None,
) -> Optional[str]: ) -> Optional[str]:
if not self.configured or not self.client: if not self.configured or not self.client:
@@ -210,7 +211,11 @@ class CryptoPayService:
return None return None
sale_base = sale_mode_base(sale_mode) sale_base = sale_mode_base(sale_mode)
amounts = payment_record_amounts(months=months, sale_mode=sale_mode) amounts = payment_record_amounts(
months=months,
sale_mode=sale_mode,
hwid_device_count=hwid_device_count,
)
try: try:
payment_record = await payment_dal.create_payment_record( payment_record = await payment_dal.create_payment_record(
session, session,
@@ -252,6 +257,7 @@ class CryptoPayService:
"payment_db_id": str(payment_record.payment_id), "payment_db_id": str(payment_record.payment_id),
"sale_mode": sale_mode, "sale_mode": sale_mode,
"traffic_gb": str(months) if sale_mode_is_traffic(sale_mode) else None, "traffic_gb": str(months) if sale_mode_is_traffic(sale_mode) else None,
"hwid_devices": amounts.purchased_hwid_devices,
} }
) )
try: try:
@@ -513,6 +519,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
} }
if ctx.hwid_valid_from and ctx.hwid_valid_until if ctx.hwid_valid_from and ctx.hwid_valid_until
else None, else None,
hwid_device_count=ctx.hwid_device_count,
) )
if not url: if not url:
return payment_failed() return payment_failed()
File diff suppressed because it is too large Load Diff
+1
View File
@@ -609,6 +609,7 @@ async def _create_webapp_payment(ctx: WebAppPaymentContext, variant: str) -> web
months=ctx.months, months=ctx.months,
sale_mode=ctx.sale_mode, sale_mode=ctx.sale_mode,
traffic_gb=ctx.traffic_gb, traffic_gb=ctx.traffic_gb,
hwid_device_count=ctx.hwid_device_count,
) )
payment = await create_webapp_payment_record( payment = await create_webapp_payment_record(
ctx, ctx,
+2 -1
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from typing import Any, Dict, Iterable, List, Mapping, Optional from typing import Any, Dict, Iterable, List, Mapping, Optional
from . import cryptopay, freekassa, heleket, platega, severpay, stars, wata, yookassa from . import cryptopay, freekassa, heleket, paykilla, platega, severpay, stars, wata, yookassa
from .base import ( from .base import (
PaymentProviderPresentation, PaymentProviderPresentation,
PaymentProviderSpec, PaymentProviderSpec,
@@ -21,6 +21,7 @@ PAYMENT_PROVIDER_SPECS: tuple[PaymentProviderSpec, ...] = (
stars.SPEC, stars.SPEC,
cryptopay.SPEC, cryptopay.SPEC,
heleket.SPEC, heleket.SPEC,
paykilla.SPEC,
) )
@@ -8,8 +8,10 @@ from aiogram import types
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.inline.user_keyboards import ( from bot.keyboards.inline.user_keyboards import (
HWID_RENEWAL_TOKEN,
get_payment_url_keyboard, get_payment_url_keyboard,
payment_methods_back_callback, payment_methods_back_callback,
sale_mode_has_token,
) )
from bot.middlewares.i18n import JsonI18n from bot.middlewares.i18n import JsonI18n
from db.dal import payment_dal from db.dal import payment_dal
@@ -123,6 +125,27 @@ async def quote_hwid_callback_parts(
subscription_service, subscription_service,
currency: str = "rub", currency: str = "rub",
) -> tuple[Optional[PaymentCallbackParts], Optional[dict]]: ) -> tuple[Optional[PaymentCallbackParts], Optional[dict]]:
base = sale_mode_base(parts.sale_mode)
if base == "subscription" and sale_mode_has_token(parts.sale_mode, HWID_RENEWAL_TOKEN):
try:
months = int(parts.months)
except (TypeError, ValueError):
return None, None
quote = await subscription_service.quote_hwid_device_renewal_for_subscription(
session,
user_id=user_id,
target_tariff_key=sale_mode_tariff_key(parts.sale_mode),
months=months,
currency=currency,
)
if not quote:
return parts, None
quoted_parts = PaymentCallbackParts(
months=months,
price=float(parts.price or 0) + float(quote.get("price") or 0),
sale_mode=parts.sale_mode,
)
return quoted_parts, quote
if not sale_mode_is_hwid_devices(parts.sale_mode): if not sale_mode_is_hwid_devices(parts.sale_mode):
return parts, None return parts, None
device_count = parse_positive_int_units(parts.months) device_count = parse_positive_int_units(parts.months)
+15 -3
View File
@@ -100,6 +100,11 @@ def build_payment_record_payload(
base = sale_mode_base(sale_mode) base = sale_mode_base(sale_mode)
is_traffic = sale_mode_is_traffic(sale_mode) is_traffic = sale_mode_is_traffic(sale_mode)
is_hwid = sale_mode_is_hwid_devices(sale_mode) is_hwid = sale_mode_is_hwid_devices(sale_mode)
hwid_devices = int(float(months)) if is_hwid else None
if hwid_quote:
quote_devices = parse_positive_int_units(hwid_quote.get("device_count"))
if quote_devices is not None:
hwid_devices = quote_devices
payload = { payload = {
"user_id": user_id, "user_id": user_id,
"amount": amount, "amount": amount,
@@ -111,9 +116,9 @@ def build_payment_record_payload(
"sale_mode": sale_mode, "sale_mode": sale_mode,
"tariff_key": sale_mode_tariff_key(sale_mode), "tariff_key": sale_mode_tariff_key(sale_mode),
"purchased_gb": float(months) if is_traffic else None, "purchased_gb": float(months) if is_traffic else None,
"purchased_hwid_devices": int(float(months)) if is_hwid else None, "purchased_hwid_devices": hwid_devices,
} }
if hwid_quote and is_hwid: if hwid_quote and hwid_devices is not None:
payload.update( payload.update(
{ {
"hwid_valid_from": hwid_quote.get("valid_from"), "hwid_valid_from": hwid_quote.get("valid_from"),
@@ -164,14 +169,20 @@ def payment_record_amounts(
months: Any, months: Any,
sale_mode: str, sale_mode: str,
traffic_gb: Optional[float] = None, traffic_gb: Optional[float] = None,
hwid_device_count: Optional[int] = None,
) -> PaymentRecordAmounts: ) -> PaymentRecordAmounts:
traffic_sale = sale_mode_is_traffic(sale_mode) traffic_sale = sale_mode_is_traffic(sale_mode)
hwid_devices_sale = sale_mode_is_hwid_devices(sale_mode) hwid_devices_sale = sale_mode_is_hwid_devices(sale_mode)
units = traffic_gb if traffic_sale and traffic_gb is not None else months units = traffic_gb if traffic_sale and traffic_gb is not None else months
purchased_hwid_devices = int(float(months)) if hwid_devices_sale else None
if not hwid_devices_sale and hwid_device_count is not None:
parsed_hwid_devices = parse_positive_int_units(hwid_device_count)
if parsed_hwid_devices is not None:
purchased_hwid_devices = parsed_hwid_devices
return PaymentRecordAmounts( return PaymentRecordAmounts(
months=int(float(units)) if traffic_sale else int(float(months)), months=int(float(units)) if traffic_sale else int(float(months)),
purchased_gb=float(units) if traffic_sale else None, purchased_gb=float(units) if traffic_sale else None,
purchased_hwid_devices=int(float(months)) if hwid_devices_sale else None, purchased_hwid_devices=purchased_hwid_devices,
tariff_key=sale_mode_tariff_key(sale_mode), tariff_key=sale_mode_tariff_key(sale_mode),
traffic_sale=traffic_sale, traffic_sale=traffic_sale,
hwid_devices_sale=hwid_devices_sale, hwid_devices_sale=hwid_devices_sale,
@@ -281,6 +292,7 @@ async def create_webapp_payment_record(
months=ctx.months, months=ctx.months,
sale_mode=ctx.sale_mode, sale_mode=ctx.sale_mode,
traffic_gb=ctx.traffic_gb, traffic_gb=ctx.traffic_gb,
hwid_device_count=ctx.hwid_device_count,
) )
return await create_base_payment_record( return await create_base_payment_record(
ctx.session, ctx.session,
@@ -156,6 +156,28 @@ def append_hwid_renewal_note(
return f"{text}\n\n{note}" return f"{text}\n\n{note}"
def append_hwid_renewed_note(
text: str,
translator: Translator,
*,
count: Any,
valid_until: Optional[datetime],
) -> str:
try:
count_int = int(count or 0)
except (TypeError, ValueError):
count_int = 0
if count_int <= 0:
return text
date_text = valid_until.strftime("%Y-%m-%d") if valid_until else ""
note = translator(
"payment_successful_hwid_devices_renewed_note",
count=format_human_units(count_int),
date=date_text,
)
return f"{text}\n\n{note}"
async def send_success_message_to_user( async def send_success_message_to_user(
*, *,
bot: Bot, bot: Bot,
@@ -320,8 +342,37 @@ async def finalize_successful_payment(
req.log_prefix, req.log_prefix,
req.payment.payment_id, req.payment.payment_id,
) )
try:
await payment_dal.update_payment_status_by_db_id(
req.session,
req.payment.payment_id,
"activation_failed",
)
await req.session.commit()
except Exception:
await req.session.rollback()
logging.exception(
"%s: failed to mark payment %s activation_failed.",
req.log_prefix,
req.payment.payment_id,
)
return None return None
try:
from bot.app.web.webapp.cache_helpers import invalidate_webapp_user_caches
await invalidate_webapp_user_caches(
req.settings,
req.user_id,
include_devices=True,
)
except Exception:
logging.exception(
"%s: failed to invalidate webapp caches for user %s.",
req.log_prefix,
req.user_id,
)
db_user, language = await resolve_user_language( db_user, language = await resolve_user_language(
req.session, req.session,
user_id=req.user_id, user_id=req.user_id,
@@ -363,12 +414,20 @@ async def finalize_successful_payment(
) )
) )
if is_subscription and activation: if is_subscription and activation:
success_text = append_hwid_renewal_note( if activation.get("hwid_devices_renewed_count"):
success_text, success_text = append_hwid_renewed_note(
translator, success_text,
count=activation.get("hwid_devices_renewal_recommended_count"), translator,
valid_until=activation.get("hwid_devices_valid_until"), count=activation.get("hwid_devices_renewed_count"),
) valid_until=final_end_date or activation.get("hwid_devices_renewed_until"),
)
else:
success_text = append_hwid_renewal_note(
success_text,
translator,
count=activation.get("hwid_devices_renewal_recommended_count"),
valid_until=activation.get("hwid_devices_valid_until"),
)
if req.text_prefix: if req.text_prefix:
success_text = f"{req.text_prefix}\n{success_text}" success_text = f"{req.text_prefix}\n{success_text}"
@@ -51,7 +51,7 @@ async def notify_user_payment_failed(
message_key: str = "payment_failed", message_key: str = "payment_failed",
) -> None: ) -> None:
"""Send the localized ``payment_failed`` text to the user; never raises.""" """Send the localized ``payment_failed`` text to the user; never raises."""
db_user = payment.user or await user_dal.get_user_by_id(session, payment.user_id) db_user = await user_dal.get_user_by_id(session, payment.user_id)
language = ( language = (
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
) )
+1
View File
@@ -344,6 +344,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
months=ctx.months, months=ctx.months,
sale_mode=ctx.sale_mode, sale_mode=ctx.sale_mode,
traffic_gb=ctx.traffic_gb, traffic_gb=ctx.traffic_gb,
hwid_device_count=ctx.hwid_device_count,
) )
payment = await create_webapp_payment_record( payment = await create_webapp_payment_record(
ctx, ctx,
+1
View File
@@ -978,6 +978,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
months=ctx.months, months=ctx.months,
sale_mode=ctx.sale_mode, sale_mode=ctx.sale_mode,
traffic_gb=ctx.traffic_gb, traffic_gb=ctx.traffic_gb,
hwid_device_count=ctx.hwid_device_count,
) )
months_for_lookup = ( months_for_lookup = (
reuse_amounts.months if sale_mode_base(ctx.sale_mode) == "subscription" else None reuse_amounts.months if sale_mode_base(ctx.sale_mode) == "subscription" else None
+169 -36
View File
@@ -448,6 +448,36 @@ def _metadata_value_present(value: Optional[Any]) -> bool:
return value is not None and str(value).strip() != "" return value is not None and str(value).strip() != ""
def _metadata_int(value: Optional[Any]) -> Optional[int]:
if not _metadata_value_present(value):
return None
try:
return int(float(str(value).strip()))
except (TypeError, ValueError):
return None
def _metadata_float(value: Optional[Any]) -> Optional[float]:
if not _metadata_value_present(value):
return None
try:
return float(str(value).strip())
except (TypeError, ValueError):
return None
def _metadata_datetime(value: Optional[Any]) -> Optional[datetime]:
if not _metadata_value_present(value):
return None
try:
parsed = datetime.fromisoformat(str(value).strip().replace("Z", "+00:00"))
except (TypeError, ValueError):
return None
if parsed.tzinfo is None:
return parsed.replace(tzinfo=timezone.utc)
return parsed
def _resolve_yookassa_activation_amounts( def _resolve_yookassa_activation_amounts(
*, *,
sale_mode_base: str, sale_mode_base: str,
@@ -559,6 +589,11 @@ async def process_successful_payment(
months_for_record = int(subscription_months) if sale_mode_base == "subscription" else 0 months_for_record = int(subscription_months) if sale_mode_base == "subscription" else 0
payment_value = float(amount_data.get("value", 0.0)) payment_value = float(amount_data.get("value", 0.0))
yk_payment_id_from_hook = payment_info_from_webhook.get("id") yk_payment_id_from_hook = payment_info_from_webhook.get("id")
hwid_valid_from = _metadata_datetime(metadata.get("hwid_valid_from"))
hwid_valid_until = _metadata_datetime(metadata.get("hwid_valid_until"))
hwid_pricing_period_months = _metadata_int(metadata.get("hwid_pricing_period_months"))
hwid_proration_ratio = _metadata_float(metadata.get("hwid_proration_ratio"))
hwid_full_price = _metadata_float(metadata.get("hwid_full_price"))
if _is_hwid_device_sale_base(sale_mode_base) and hwid_devices_count <= 0: if _is_hwid_device_sale_base(sale_mode_base) and hwid_devices_count <= 0:
logging.error( logging.error(
@@ -574,6 +609,19 @@ async def process_successful_payment(
yk_payment_id_from_hook, yk_payment_id_from_hook,
) )
return return
if sale_mode_base == "subscription" and hwid_devices_count > 0:
if (
not hwid_valid_from
or not hwid_valid_until
or hwid_valid_from >= hwid_valid_until
or hwid_full_price is None
):
logging.error(
"YooKassa subscription+HWID payment %s has invalid HWID metadata: %s",
yk_payment_id_from_hook,
metadata,
)
return
payment_record = None payment_record = None
# If this is an auto-renewal (no payment_db_id in metadata), ensure a payment record exists # If this is an auto-renewal (no payment_db_id in metadata), ensure a payment record exists
@@ -600,6 +648,16 @@ async def process_successful_payment(
or f"Auto-renewal for {months_for_record or subscription_months} months", or f"Auto-renewal for {months_for_record or subscription_months} months",
provider="yookassa", provider="yookassa",
provider_payment_id=yk_payment_id_from_hook, provider_payment_id=yk_payment_id_from_hook,
sale_mode=sale_mode,
tariff_key=_sale_mode_tariff_key(sale_mode),
purchased_hwid_devices=(
hwid_devices_count if hwid_devices_count > 0 else None
),
hwid_valid_from=hwid_valid_from,
hwid_valid_until=hwid_valid_until,
hwid_pricing_period_months=hwid_pricing_period_months,
hwid_proration_ratio=hwid_proration_ratio,
hwid_full_price=hwid_full_price,
) )
payment_db_id = payment_record.payment_id payment_db_id = payment_record.payment_id
except Exception as e_ensure: except Exception as e_ensure:
@@ -1315,6 +1373,36 @@ def _parse_offer_payload(payload: str) -> Optional[Tuple[float, float, str]]:
return None return None
def _parse_saved_list_payload(payload: str) -> Optional[Tuple[float, float, int, str]]:
parts = payload.split(":")
if len(parts) < 2:
return None
try:
months = float(parts[0])
price = float(parts[1])
except (ValueError, IndexError):
return None
page = 0
sale_mode = "subscription"
if len(parts) > 2:
try:
page = int(parts[2])
sale_mode = parts[3] if len(parts) > 3 else "subscription"
except ValueError:
sale_mode = parts[2]
return months, price, page, sale_mode
def _metadata_iso(value: Any) -> Optional[str]:
if value is None:
return None
if hasattr(value, "isoformat"):
return value.isoformat()
text = str(value).strip()
return text or None
def _format_saved_payment_method_title( def _format_saved_payment_method_title(
get_text, network: Optional[str], last4: Optional[str], is_default: bool get_text, network: Optional[str], last4: Optional[str], is_default: bool
) -> str: ) -> str:
@@ -1363,6 +1451,9 @@ async def _initiate_yk_payment(
return False return False
sale_base = _sale_mode_base(sale_mode) sale_base = _sale_mode_base(sale_mode)
hwid_device_count = None
if hwid_quote:
hwid_device_count = parse_positive_int_units(hwid_quote.get("device_count"))
payment_description = ( payment_description = (
get_text("payment_description_traffic", traffic_gb=_format_value(months)) get_text("payment_description_traffic", traffic_gb=_format_value(months))
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
@@ -1379,12 +1470,14 @@ async def _initiate_yk_payment(
"status": "pending_yookassa", "status": "pending_yookassa",
"description": payment_description, "description": payment_description,
"subscription_duration_months": int(months) if sale_base == "subscription" else None, "subscription_duration_months": int(months) if sale_base == "subscription" else None,
"sale_mode": sale_base, "sale_mode": sale_mode,
"tariff_key": sale_mode.split("@", 1)[1].split("|", 1)[0] if "@" in sale_mode else None, "tariff_key": sale_mode.split("@", 1)[1].split("|", 1)[0] if "@" in sale_mode else None,
"purchased_gb": float(months) "purchased_gb": float(months)
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else None, else None,
"purchased_hwid_devices": int(months) if sale_base in HWID_DEVICE_SALE_BASES else None, "purchased_hwid_devices": (
int(months) if sale_base in HWID_DEVICE_SALE_BASES else hwid_device_count
),
"hwid_valid_from": hwid_quote.get("valid_from") if hwid_quote else None, "hwid_valid_from": hwid_quote.get("valid_from") if hwid_quote else None,
"hwid_valid_until": hwid_quote.get("valid_until") if hwid_quote else None, "hwid_valid_until": hwid_quote.get("valid_until") if hwid_quote else None,
"hwid_pricing_period_months": hwid_quote.get("pricing_period_months") "hwid_pricing_period_months": hwid_quote.get("pricing_period_months")
@@ -1430,6 +1523,19 @@ async def _initiate_yk_payment(
yookassa_metadata["traffic_gb"] = str(months) yookassa_metadata["traffic_gb"] = str(months)
if sale_base in HWID_DEVICE_SALE_BASES: if sale_base in HWID_DEVICE_SALE_BASES:
yookassa_metadata["hwid_devices"] = str(months) yookassa_metadata["hwid_devices"] = str(months)
elif hwid_device_count:
yookassa_metadata["hwid_devices"] = str(hwid_device_count)
if hwid_quote and hwid_device_count:
hwid_metadata = {
"hwid_valid_from": _metadata_iso(hwid_quote.get("valid_from")),
"hwid_valid_until": _metadata_iso(hwid_quote.get("valid_until")),
"hwid_pricing_period_months": hwid_quote.get("pricing_period_months"),
"hwid_proration_ratio": hwid_quote.get("proration_ratio"),
"hwid_full_price": hwid_quote.get("full_price"),
}
yookassa_metadata.update(
{key: str(value) for key, value in hwid_metadata.items() if value is not None}
)
if payment_method_id: if payment_method_id:
yookassa_metadata["used_saved_payment_method_id"] = payment_method_id yookassa_metadata["used_saved_payment_method_id"] = payment_method_id
@@ -1709,22 +1815,6 @@ async def pay_yk_callback_handler(
months, price_rub, sale_mode = parsed months, price_rub, sale_mode = parsed
hwid_quote = None hwid_quote = None
if _sale_mode_base(sale_mode) in HWID_DEVICE_SALE_BASES:
quoted_parts, hwid_quote = await quote_hwid_callback_parts(
session=session,
user_id=callback.from_user.id,
parts=PaymentCallbackParts(months=months, price=price_rub, sale_mode=sale_mode),
subscription_service=yookassa_service.subscription_service,
currency=default_currency_key_for_settings(settings),
)
if not quoted_parts:
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
months = quoted_parts.months
price_rub = quoted_parts.price
user_id = callback.from_user.id user_id = callback.from_user.id
currency_code_for_yk = default_payment_currency_code_for_settings(settings) currency_code_for_yk = default_payment_currency_code_for_settings(settings)
autopay_enabled = bool( autopay_enabled = bool(
@@ -1786,6 +1876,22 @@ async def pay_yk_callback_handler(
pass pass
return return
quoted_parts, hwid_quote = await quote_hwid_callback_parts(
session=session,
user_id=callback.from_user.id,
parts=PaymentCallbackParts(months=months, price=price_rub, sale_mode=sale_mode),
subscription_service=yookassa_service.subscription_service,
currency=default_currency_key_for_settings(settings),
)
if not quoted_parts:
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
months = quoted_parts.months
price_rub = quoted_parts.price
await _initiate_yk_payment( await _initiate_yk_payment(
callback, callback,
settings=settings, settings=settings,
@@ -1863,6 +1969,22 @@ async def pay_yk_new_card_handler(
return return
months, price_rub, sale_mode = parsed months, price_rub, sale_mode = parsed
hwid_quote = None
quoted_parts, hwid_quote = await quote_hwid_callback_parts(
session=session,
user_id=callback.from_user.id,
parts=PaymentCallbackParts(months=months, price=price_rub, sale_mode=sale_mode),
subscription_service=yookassa_service.subscription_service,
currency=default_currency_key_for_settings(settings),
)
if not quoted_parts:
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
months = quoted_parts.months
price_rub = quoted_parts.price
user_id = callback.from_user.id user_id = callback.from_user.id
currency_code_for_yk = default_payment_currency_code_for_settings(settings) currency_code_for_yk = default_payment_currency_code_for_settings(settings)
autopay_enabled = bool( autopay_enabled = bool(
@@ -1889,6 +2011,7 @@ async def pay_yk_new_card_handler(
save_payment_method=autopay_enabled and autopay_require_binding, save_payment_method=autopay_enabled and autopay_require_binding,
back_callback=payment_methods_back_callback(_format_value(months), sale_mode, price_rub), back_callback=payment_methods_back_callback(_format_value(months), sale_mode, price_rub),
sale_mode=sale_mode, sale_mode=sale_mode,
hwid_quote=hwid_quote,
) )
try: try:
await callback.answer() await callback.answer()
@@ -1928,27 +2051,15 @@ async def pay_yk_saved_list_handler(
pass pass
return return
parts = data_payload.split(":") parsed_saved_list = _parse_saved_list_payload(data_payload)
if len(parts) < 2: if not parsed_saved_list:
logging.error(f"pay_yk_saved_list payload missing components: {callback.data}") logging.error(f"pay_yk_saved_list payload missing components: {callback.data}")
try: try:
await callback.answer(get_text("error_try_again"), show_alert=True) await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception: except Exception:
pass pass
return return
months, price_rub, page, sale_mode = parsed_saved_list
try:
months = float(parts[0])
price_rub = float(parts[1])
page = int(parts[2]) if len(parts) > 2 else 0
sale_mode = parts[3] if len(parts) > 3 else "subscription"
except (ValueError, IndexError):
logging.error(f"pay_yk_saved_list payload parsing error: {callback.data}")
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
autopay_enabled = bool( autopay_enabled = bool(
settings.yookassa_autopayments_active settings.yookassa_autopayments_active
@@ -2138,6 +2249,24 @@ async def pay_yk_use_saved_handler(
method_identifier = parts[2] method_identifier = parts[2]
user_id = callback.from_user.id user_id = callback.from_user.id
base_months = months
base_price_rub = price_rub
hwid_quote = None
quoted_parts, hwid_quote = await quote_hwid_callback_parts(
session=session,
user_id=user_id,
parts=PaymentCallbackParts(months=months, price=price_rub, sale_mode=sale_mode),
subscription_service=yookassa_service.subscription_service,
currency=default_currency_key_for_settings(settings),
)
if not quoted_parts:
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
months = quoted_parts.months
price_rub = quoted_parts.price
try: try:
saved_methods = await user_billing_dal.list_user_payment_methods( saved_methods = await user_billing_dal.list_user_payment_methods(
@@ -2182,10 +2311,13 @@ async def pay_yk_use_saved_handler(
price_rub=price_rub, price_rub=price_rub,
currency_code_for_yk=currency_code_for_yk, currency_code_for_yk=currency_code_for_yk,
save_payment_method=False, save_payment_method=False,
back_callback=f"pay_yk_saved_list:{_format_value(months)}:{price_rub}:{sale_mode}", back_callback=(
f"pay_yk_saved_list:{_format_value(base_months)}:{base_price_rub}:0:{sale_mode}"
),
payment_method_id=selected_method.provider_payment_method_id, payment_method_id=selected_method.provider_payment_method_id,
selected_method_internal_id=selected_method.method_id, selected_method_internal_id=selected_method.method_id,
sale_mode=sale_mode, sale_mode=sale_mode,
hwid_quote=hwid_quote,
) )
try: try:
await callback.answer() await callback.answer()
@@ -2754,6 +2886,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
months=ctx.months, months=ctx.months,
sale_mode=ctx.sale_mode, sale_mode=ctx.sale_mode,
traffic_gb=ctx.traffic_gb, traffic_gb=ctx.traffic_gb,
hwid_device_count=ctx.hwid_device_count,
) )
payment = await create_webapp_payment_record( payment = await create_webapp_payment_record(
ctx, ctx,
@@ -2775,8 +2908,8 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
} }
if amounts.traffic_sale: if amounts.traffic_sale:
metadata["traffic_gb"] = format_number_for_payload(ctx.traffic_gb or ctx.months) metadata["traffic_gb"] = format_number_for_payload(ctx.traffic_gb or ctx.months)
if amounts.hwid_devices_sale: if amounts.purchased_hwid_devices:
metadata["hwid_devices"] = str(int(float(ctx.months))) metadata["hwid_devices"] = str(int(amounts.purchased_hwid_devices))
if amounts.tariff_key: if amounts.tariff_key:
metadata["tariff_key"] = amounts.tariff_key metadata["tariff_key"] = amounts.tariff_key
response = await service.create_payment( response = await service.create_payment(
+104 -23
View File
@@ -10,13 +10,13 @@ from dataclasses import dataclass
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from email.message import EmailMessage from email.message import EmailMessage
from email.utils import formataddr from email.utils import formataddr
from typing import Optional from typing import Optional, Sequence
from sqlalchemy import select, update from sqlalchemy import select, update
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.email_templates import EmailContent, render_login_code from bot.services.email_templates import EmailContent, EmailInlineImage, render_login_code
from bot.services.message_audit import log_user_message_delivery from bot.services.message_audit import log_user_message_delivery
from config.settings import Settings from config.settings import Settings
from db.dal import security_dal, user_dal from db.dal import security_dal, user_dal
@@ -61,11 +61,39 @@ def normalize_email(value: str) -> str:
return (value or "").strip().lower() return (value or "").strip().lower()
def email_domain(value: Optional[str]) -> str:
email = normalize_email(value or "")
if "@" not in email:
return ""
return email.rsplit("@", 1)[1].strip().lower().rstrip(".")
def is_valid_email(value: str) -> bool: def is_valid_email(value: str) -> bool:
email = normalize_email(value) email = normalize_email(value)
return bool(email and len(email) <= 254 and EMAIL_RE.match(email)) return bool(email and len(email) <= 254 and EMAIL_RE.match(email))
def _split_disposable_domain_values(value: str) -> list[str]:
return [item.strip() for item in re.split(r"[,;\s]+", value or "") if item.strip()]
def is_disposable_email(value: Optional[str], settings: Settings) -> bool:
domain = email_domain(value)
if not domain:
return False
blocked_domains = getattr(settings, "disposable_email_domains", None)
if blocked_domains is None:
blocked_domains = _split_disposable_domain_values(
str(getattr(settings, "DISPOSABLE_EMAIL_DOMAINS", "") or "")
)
blocked_domains = blocked_domains or []
for blocked in blocked_domains:
normalized = str(blocked or "").strip().lower().lstrip("@.")
if normalized and (domain == normalized or domain.endswith(f".{normalized}")):
return True
return False
def _email_throttle_identifier(email: str, purpose: str, target_user_id: Optional[int]) -> str: def _email_throttle_identifier(email: str, purpose: str, target_user_id: Optional[int]) -> str:
target_part = "none" if target_user_id is None else str(target_user_id) target_part = "none" if target_user_id is None else str(target_user_id)
return f"{purpose}:{target_part}:{email}" return f"{purpose}:{target_part}:{email}"
@@ -453,6 +481,7 @@ class EmailAuthService:
subject: str, subject: str,
body: str, body: str,
html_body: Optional[str] = None, html_body: Optional[str] = None,
inline_images: Sequence[EmailInlineImage] = (),
) -> None: ) -> None:
await asyncio.to_thread( await asyncio.to_thread(
self._send_custom_email_sync, self._send_custom_email_sync,
@@ -460,6 +489,7 @@ class EmailAuthService:
subject=subject, subject=subject,
body=body, body=body,
html_body=html_body, html_body=html_body,
inline_images=inline_images,
) )
async def send_rendered_email( async def send_rendered_email(
@@ -473,6 +503,7 @@ class EmailAuthService:
subject=content.subject, subject=content.subject,
body=content.text, body=content.text,
html_body=content.html, html_body=content.html,
inline_images=content.inline_images,
) )
def _send_code_email_sync( def _send_code_email_sync(
@@ -493,17 +524,13 @@ class EmailAuthService:
i18n=self.i18n, i18n=self.i18n,
) )
message = EmailMessage() message = self._build_email_message(
message["Subject"] = content.subject email=email,
message["From"] = formataddr( subject=content.subject,
( body=content.text,
self.settings.SMTP_FROM_NAME or self.settings.WEBAPP_TITLE, html_body=content.html,
self.settings.SMTP_FROM_EMAIL or "", inline_images=content.inline_images,
)
) )
message["To"] = email
message.set_content(content.text)
message.add_alternative(content.html, subtype="html")
context = ssl.create_default_context() context = ssl.create_default_context()
smtp_host = self.settings.SMTP_HOST smtp_host = self.settings.SMTP_HOST
@@ -554,19 +581,15 @@ class EmailAuthService:
subject: str, subject: str,
body: str, body: str,
html_body: Optional[str] = None, html_body: Optional[str] = None,
inline_images: Sequence[EmailInlineImage] = (),
) -> None: ) -> None:
message = EmailMessage() message = self._build_email_message(
message["Subject"] = subject email=email,
message["From"] = formataddr( subject=subject,
( body=body,
self.settings.SMTP_FROM_NAME or self.settings.WEBAPP_TITLE, html_body=html_body,
self.settings.SMTP_FROM_EMAIL or "", inline_images=inline_images,
)
) )
message["To"] = email
message.set_content(body)
if html_body:
message.add_alternative(html_body, subtype="html")
context = ssl.create_default_context() context = ssl.create_default_context()
smtp_host = self.settings.SMTP_HOST smtp_host = self.settings.SMTP_HOST
@@ -610,6 +633,64 @@ class EmailAuthService:
if last_error: if last_error:
raise last_error raise last_error
def _build_email_message(
self,
*,
email: str,
subject: str,
body: str,
html_body: Optional[str] = None,
inline_images: Sequence[EmailInlineImage] = (),
) -> EmailMessage:
message = EmailMessage()
message["Subject"] = subject
message["From"] = formataddr(
(
self.settings.SMTP_FROM_NAME or self.settings.WEBAPP_TITLE,
self.settings.SMTP_FROM_EMAIL or "",
)
)
message["To"] = email
message.set_content(body)
if html_body:
message.add_alternative(html_body, subtype="html")
self._attach_inline_images(message, inline_images)
return message
@staticmethod
def _attach_inline_images(
message: EmailMessage,
inline_images: Sequence[EmailInlineImage],
) -> None:
if not inline_images:
return
html_part = message.get_body(("html",))
if html_part is None:
return
for image in inline_images:
content_type = (image.content_type or "").split(";", 1)[0].strip().lower()
if "/" not in content_type:
continue
maintype, subtype = content_type.split("/", 1)
if maintype != "image" or not subtype:
continue
body = bytes(image.data or b"")
content_id = (image.content_id or "").strip()
if not body or not content_id:
continue
cid_header = content_id
if not (cid_header.startswith("<") and cid_header.endswith(">")):
cid_header = f"<{cid_header}>"
html_part.add_related(
body,
maintype=maintype,
subtype=subtype,
cid=cid_header,
)
def _send_message_via_smtp( def _send_message_via_smtp(
self, self,
*, *,
+98 -12
View File
@@ -12,6 +12,7 @@ from __future__ import annotations
import html import html
import re import re
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Optional, Sequence, Tuple from typing import TYPE_CHECKING, Optional, Sequence, Tuple
from urllib.parse import urlsplit from urllib.parse import urlsplit
@@ -27,6 +28,27 @@ _TEXT_MUTED = "#9aa3b2"
_TEXT_DIM = "#5d6573" _TEXT_DIM = "#5d6573"
_DEFAULT_ACCENT = "#00fe7a" _DEFAULT_ACCENT = "#00fe7a"
_HEX_RE = re.compile(r"^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$") _HEX_RE = re.compile(r"^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$")
_EMAIL_LOGO_CONTENT_ID = "webapp-logo"
_WEBAPP_UPLOADED_LOGO_PATH = "/webapp-uploaded-logo"
_WEBAPP_UPLOADED_LOGO_DIR = Path(__file__).resolve().parents[3] / "data" / "webapp-logo" / "uploads"
_WEBAPP_LOGO_MAX_BYTES = 2 * 1024 * 1024
_UPLOADED_LOGO_RE = re.compile(r"logo-[0-9a-f]{16}\.(?:gif|ico|jpe?g|png|svg|webp)")
_LOGO_CONTENT_TYPES = {
".gif": "image/gif",
".ico": "image/x-icon",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".svg": "image/svg+xml",
".webp": "image/webp",
}
@dataclass(frozen=True)
class EmailInlineImage:
content_id: str
content_type: str
data: bytes
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -34,6 +56,13 @@ class EmailContent:
subject: str subject: str
text: str text: str
html: str html: str
inline_images: Tuple[EmailInlineImage, ...] = ()
@dataclass(frozen=True)
class _EmailLayout:
html: str
inline_images: Tuple[EmailInlineImage, ...] = ()
def _safe_color(value: Optional[str]) -> str: def _safe_color(value: Optional[str]) -> str:
@@ -48,8 +77,6 @@ def _safe_color(value: Optional[str]) -> str:
def _public_logo_url(settings: Settings) -> Optional[str]: def _public_logo_url(settings: Settings) -> Optional[str]:
"""Email recipients can't reach the in-app /webapp-logo proxy, so only a """Email recipients can't reach the in-app /webapp-logo proxy, so only a
stored public https URL can be used directly. Anything else is dropped.""" stored public https URL can be used directly. Anything else is dropped."""
if getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False):
return None
raw = (settings.WEBAPP_LOGO_URL or "").strip() raw = (settings.WEBAPP_LOGO_URL or "").strip()
if not raw: if not raw:
return None return None
@@ -59,6 +86,55 @@ def _public_logo_url(settings: Settings) -> Optional[str]:
return raw return raw
def _uploaded_logo_filename(url: str) -> Optional[str]:
parsed = urlsplit(str(url or ""))
path = parsed.path if parsed.scheme or parsed.netloc else str(url or "")
prefix = f"{_WEBAPP_UPLOADED_LOGO_PATH}/"
if not path.startswith(prefix):
return None
filename = path.removeprefix(prefix)
return filename if _UPLOADED_LOGO_RE.fullmatch(filename) else None
def _inline_uploaded_logo(settings: Settings) -> Optional[EmailInlineImage]:
filename = _uploaded_logo_filename((settings.WEBAPP_LOGO_URL or "").strip())
if not filename:
return None
content_type = _LOGO_CONTENT_TYPES.get(Path(filename).suffix.lower())
if not content_type:
return None
try:
uploads_dir = _WEBAPP_UPLOADED_LOGO_DIR.resolve()
logo_path = (uploads_dir / filename).resolve()
logo_path.relative_to(uploads_dir)
body = logo_path.read_bytes()
except (OSError, ValueError):
return None
if not body or len(body) > _WEBAPP_LOGO_MAX_BYTES:
return None
return EmailInlineImage(
content_id=_EMAIL_LOGO_CONTENT_ID,
content_type=content_type,
data=body,
)
def _email_logo(settings: Settings) -> Tuple[Optional[str], Tuple[EmailInlineImage, ...]]:
inline_logo = _inline_uploaded_logo(settings)
if inline_logo:
return f"cid:{inline_logo.content_id}", (inline_logo,)
public_url = _public_logo_url(settings)
if public_url:
return public_url, ()
return None, ()
def _brand_title(settings: Settings) -> str: def _brand_title(settings: Settings) -> str:
title = (settings.WEBAPP_TITLE or "").strip() title = (settings.WEBAPP_TITLE or "").strip()
return title or "Subscription" return title or "Subscription"
@@ -98,10 +174,10 @@ def _layout(
intro_html: str, intro_html: str,
body_html: str, body_html: str,
footer_html: str, footer_html: str,
) -> str: ) -> _EmailLayout:
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR) accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
brand_title = html.escape(_brand_title(settings)) brand_title = html.escape(_brand_title(settings))
logo_url = _public_logo_url(settings) logo_url, inline_images = _email_logo(settings)
html_lang = html.escape((language_code or "en").replace("_", "-"), quote=True) html_lang = html.escape((language_code or "en").replace("_", "-"), quote=True)
logo_block = "" logo_block = ""
if logo_url: if logo_url:
@@ -111,7 +187,7 @@ def _layout(
f'border-radius:16px;">' f'border-radius:16px;">'
) )
return f"""<!DOCTYPE html> layout_html = f"""<!DOCTYPE html>
<html lang="{html_lang}" xmlns="http://www.w3.org/1999/xhtml"> <html lang="{html_lang}" xmlns="http://www.w3.org/1999/xhtml">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
@@ -151,6 +227,16 @@ def _layout(
</body> </body>
</html> </html>
""" # noqa: E501 """ # noqa: E501
return _EmailLayout(html=layout_html, inline_images=inline_images)
def _email_content(*, subject: str, text: str, layout: _EmailLayout) -> EmailContent:
return EmailContent(
subject=subject,
text=text,
html=layout.html,
inline_images=layout.inline_images,
)
def _info_rows_html(rows: Sequence[Tuple[str, str]]) -> str: def _info_rows_html(rows: Sequence[Tuple[str, str]]) -> str:
@@ -319,7 +405,7 @@ def render_login_code(
body_html=body_html, body_html=body_html,
footer_html=footer, footer_html=footer,
) )
return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered) return _email_content(subject=subject, text="\n".join(text_lines), layout=rendered)
def render_account_merged( def render_account_merged(
@@ -372,7 +458,7 @@ def render_account_merged(
body_html=body_html, body_html=body_html,
footer_html=footer, footer_html=footer,
) )
return EmailContent(subject=subject, text=text, html=rendered) return _email_content(subject=subject, text=text, layout=rendered)
def render_payment_success( def render_payment_success(
@@ -506,7 +592,7 @@ def render_payment_success(
body_html="".join(body_parts), body_html="".join(body_parts),
footer_html=footer, footer_html=footer,
) )
return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered) return _email_content(subject=subject, text="\n".join(text_lines), layout=rendered)
def render_user_notification( def render_user_notification(
@@ -567,7 +653,7 @@ def render_user_notification(
), ),
] ]
) )
return EmailContent(subject=final_subject, text="\n".join(text_lines), html=rendered) return _email_content(subject=final_subject, text="\n".join(text_lines), layout=rendered)
def render_subscription_expiring( def render_subscription_expiring(
@@ -631,7 +717,7 @@ def render_subscription_expiring(
body_html="".join(body_parts), body_html="".join(body_parts),
footer_html=footer, footer_html=footer,
) )
return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered) return _email_content(subject=subject, text="\n".join(text_lines), layout=rendered)
def _subscription_lifecycle_title( def _subscription_lifecycle_title(
@@ -733,7 +819,7 @@ def render_subscription_lifecycle_notification(
), ),
] ]
) )
return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered) return _email_content(subject=subject, text="\n".join(text_lines), layout=rendered)
def _support_email( def _support_email(
@@ -785,7 +871,7 @@ def _support_email(
] ]
if safe_url: if safe_url:
text_lines.extend(["", safe_url]) text_lines.extend(["", safe_url])
return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered) return _email_content(subject=subject, text="\n".join(text_lines), layout=rendered)
def render_support_new_ticket_admin( def render_support_new_ticket_admin(
@@ -108,6 +108,7 @@ LOCALE_GROUPS = [
"prefixes": ( "prefixes": (
"admin_user_", "admin_user_",
"admin_users_", "admin_users_",
"admin_hwid_",
"admin_ban_", "admin_ban_",
"admin_unban_", "admin_unban_",
"admin_banned_", "admin_banned_",
@@ -115,6 +116,7 @@ LOCALE_GROUPS = [
"admin_traffic_grant_", "admin_traffic_grant_",
"admin_view_banned_", "admin_view_banned_",
"user_card_", "user_card_",
"user_hwid_",
"user_premium_", "user_premium_",
"user_regular_", "user_regular_",
"user_traffic_", "user_traffic_",
@@ -203,7 +205,6 @@ LOCALE_GROUPS = [
"admin_settings_field_subscription_mini_app_url", "admin_settings_field_subscription_mini_app_url",
"admin_settings_field_support_link", "admin_settings_field_support_link",
"admin_settings_field_server_status_url", "admin_settings_field_server_status_url",
"admin_settings_field_terms_",
"admin_settings_field_privacy_", "admin_settings_field_privacy_",
"admin_settings_field_user_agreement_", "admin_settings_field_user_agreement_",
"appearance_", "appearance_",
+94 -11
View File
@@ -22,6 +22,11 @@ class PanelApiService:
_TRANSIENT_STATUS_CODES = (-1, -3) _TRANSIENT_STATUS_CODES = (-1, -3)
_SAFE_METHODS = frozenset({"GET", "HEAD"}) _SAFE_METHODS = frozenset({"GET", "HEAD"})
_RETRY_BACKOFF_SECONDS = 0.5 _RETRY_BACKOFF_SECONDS = 0.5
_MIN_TIMEOUT_SECONDS = 0.1
_DEFAULT_TOTAL_TIMEOUT_SECONDS = 25.0
_DEFAULT_CONNECT_TIMEOUT_SECONDS = 8.0
_DEFAULT_SOCK_CONNECT_TIMEOUT_SECONDS = 8.0
_DEFAULT_SOCK_READ_TIMEOUT_SECONDS = 15.0
def __init__(self, settings: Settings): def __init__(self, settings: Settings):
self.settings = settings self.settings = settings
@@ -70,17 +75,46 @@ class PanelApiService:
async def _get_session(self) -> aiohttp.ClientSession: async def _get_session(self) -> aiohttp.ClientSession:
if self._session is None or self._session.closed: if self._session is None or self._session.closed:
# Separate connect/read timeouts so a stuck panel does not hold a self._session = aiohttp.ClientSession(timeout=self._client_timeout())
# bot worker for the full window; total caps worst-case latency.
timeout = aiohttp.ClientTimeout(
total=15,
connect=3,
sock_connect=3,
sock_read=10,
)
self._session = aiohttp.ClientSession(timeout=timeout)
return self._session return self._session
@classmethod
def _timeout_setting(cls, settings: Settings, name: str, default: float) -> float:
raw_value = getattr(settings, name, default)
try:
value = float(raw_value)
except (TypeError, ValueError):
return default
if value <= 0:
return default
return max(cls._MIN_TIMEOUT_SECONDS, value)
def _client_timeout(self) -> aiohttp.ClientTimeout:
# Separate connect/read timeouts so a slow panel route has more room,
# while genuinely stuck requests still cannot pin a worker forever.
return aiohttp.ClientTimeout(
total=self._timeout_setting(
self.settings,
"PANEL_API_TOTAL_TIMEOUT_SECONDS",
self._DEFAULT_TOTAL_TIMEOUT_SECONDS,
),
connect=self._timeout_setting(
self.settings,
"PANEL_API_CONNECT_TIMEOUT_SECONDS",
self._DEFAULT_CONNECT_TIMEOUT_SECONDS,
),
sock_connect=self._timeout_setting(
self.settings,
"PANEL_API_SOCK_CONNECT_TIMEOUT_SECONDS",
self._DEFAULT_SOCK_CONNECT_TIMEOUT_SECONDS,
),
sock_read=self._timeout_setting(
self.settings,
"PANEL_API_SOCK_READ_TIMEOUT_SECONDS",
self._DEFAULT_SOCK_READ_TIMEOUT_SECONDS,
),
)
async def close_session(self): async def close_session(self):
if self._session and not self._session.closed: if self._session and not self._session.closed:
await self._session.close() await self._session.close()
@@ -121,6 +155,15 @@ class PanelApiService:
for attempt in range(max_attempts): for attempt in range(max_attempts):
result = await self._request_once(method, endpoint, log_full_response, **kwargs) result = await self._request_once(method, endpoint, log_full_response, **kwargs)
if attempt + 1 < max_attempts and self._is_transient_error(result): if attempt + 1 < max_attempts and self._is_transient_error(result):
logging.warning(
"Retrying transient Panel API request method=%s endpoint=%s "
"attempt=%s/%s status_code=%s",
method.upper(),
endpoint,
attempt + 1,
max_attempts,
result.get("status_code") if isinstance(result, dict) else None,
)
await asyncio.sleep(self._RETRY_BACKOFF_SECONDS) await asyncio.sleep(self._RETRY_BACKOFF_SECONDS)
continue continue
return result return result
@@ -158,8 +201,8 @@ class PanelApiService:
) )
except Exception: except Exception:
log_prefix += f" | Payload: {str(json_payload_for_log)[:300]}..." log_prefix += f" | Payload: {str(json_payload_for_log)[:300]}..."
started = time.monotonic()
try: try:
started = time.monotonic()
async with aiohttp_session.request( async with aiohttp_session.request(
method.upper(), url_for_request, headers=headers, **kwargs method.upper(), url_for_request, headers=headers, **kwargs
) as response: ) as response:
@@ -228,15 +271,48 @@ class PanelApiService:
return {"error": True, "status_code": response_status, "details": error_details} return {"error": True, "status_code": response_status, "details": error_details}
except aiohttp.ClientConnectorError as e: except aiohttp.ClientConnectorError as e:
logging.info(
"metric panel_latency_seconds=%.3f method=%s endpoint=%s status=connect_error",
time.monotonic() - started,
method.upper(),
endpoint,
)
logging.error(f"Panel API ClientConnectorError to {url_for_request}: {e}") logging.error(f"Panel API ClientConnectorError to {url_for_request}: {e}")
return {"error": True, "status_code": -1, "message": f"Connection error: {str(e)}"} return {"error": True, "status_code": -1, "message": f"Connection error: {str(e)}"}
except aiohttp.ServerTimeoutError as e:
logging.info(
"metric panel_latency_seconds=%.3f method=%s endpoint=%s status=timeout",
time.monotonic() - started,
method.upper(),
endpoint,
)
logging.warning("Panel API timeout to %s: %s", url_for_request, e)
return {"error": True, "status_code": -3, "message": f"Request timed out: {str(e)}"}
except aiohttp.ClientError as e: except aiohttp.ClientError as e:
logging.info(
"metric panel_latency_seconds=%.3f method=%s endpoint=%s status=client_error",
time.monotonic() - started,
method.upper(),
endpoint,
)
logging.exception("Panel API ClientError to %s.", url_for_request) logging.exception("Panel API ClientError to %s.", url_for_request)
return {"error": True, "status_code": -2, "message": f"Client error: {str(e)}"} return {"error": True, "status_code": -2, "message": f"Client error: {str(e)}"}
except asyncio.TimeoutError: except asyncio.TimeoutError:
logging.info(
"metric panel_latency_seconds=%.3f method=%s endpoint=%s status=timeout",
time.monotonic() - started,
method.upper(),
endpoint,
)
logging.error(f"Panel API request to {url_for_request} timed out.") logging.error(f"Panel API request to {url_for_request} timed out.")
return {"error": True, "status_code": -3, "message": "Request timed out"} return {"error": True, "status_code": -3, "message": "Request timed out"}
except Exception as e: except Exception as e:
logging.info(
"metric panel_latency_seconds=%.3f method=%s endpoint=%s status=unexpected_error",
time.monotonic() - started,
method.upper(),
endpoint,
)
logging.error( logging.error(
f"Unexpected Panel API request error to {url_for_request}: {e}", exc_info=True f"Unexpected Panel API request error to {url_for_request}: {e}", exc_info=True
) )
@@ -885,7 +961,14 @@ class PanelApiService:
await self._devices_cache.invalidate_remote(f"user:{user_uuid}") await self._devices_cache.invalidate_remote(f"user:{user_uuid}")
async def get_internal_squads(self) -> Optional[List[Dict[str, Any]]]: async def get_internal_squads(self) -> Optional[List[Dict[str, Any]]]:
return await self._squads_cache.get_or_load("list", self._get_internal_squads_uncached) squads = await self._squads_cache.get_or_load("list", self._get_internal_squads_uncached)
if squads is not None:
return squads
stale_squads = self._squads_cache.get_stale("list")
if stale_squads is not None:
logging.warning("Using stale internal squads cache after panel fetch failed.")
return stale_squads
return None
async def _get_internal_squads_uncached(self) -> Optional[List[Dict[str, Any]]]: async def _get_internal_squads_uncached(self) -> Optional[List[Dict[str, Any]]]:
response_data = await self._request("GET", "/internal-squads", log_full_response=False) response_data = await self._request("GET", "/internal-squads", log_full_response=False)
+11 -5
View File
@@ -38,8 +38,12 @@ class PromoCodeService:
user_lang: str, user_lang: str,
) -> Tuple[bool, datetime | str]: ) -> Tuple[bool, datetime | str]:
_ = lambda k, **kw: self.i18n.gettext(user_lang, k, **kw) _ = lambda k, **kw: self.i18n.gettext(user_lang, k, **kw)
code_input_upper = (code_input or "").strip().upper()[:100] preserve_case = bool(
code_display = html_escape(code_input_upper[:100], quote=False) getattr(self.settings, "MIGRATION_REMNASHOP_PROMO_CODE_COMPAT_ENABLED", False)
)
code_input_clean = (code_input or "").strip()[:100]
lookup_code = code_input_clean if preserve_case else code_input_clean.upper()
code_display = html_escape(lookup_code[:100], quote=False)
throttle_identifier = self._throttle_identifier(user_id) throttle_identifier = self._throttle_identifier(user_id)
throttle = await security_dal.check_throttle( throttle = await security_dal.check_throttle(
@@ -54,7 +58,7 @@ class PromoCodeService:
) )
promo_data = await promo_code_dal.get_active_promo_code_by_code_str( promo_data = await promo_code_dal.get_active_promo_code_by_code_str(
session, code_input_upper session, lookup_code, preserve_case=preserve_case
) )
if not promo_data: if not promo_data:
@@ -74,6 +78,8 @@ class PromoCodeService:
) )
return False, _("promo_code_not_found", code=code_display) return False, _("promo_code_not_found", code=code_display)
applied_code = str(promo_data.code or lookup_code)
code_display = html_escape(applied_code[:100], quote=False)
existing_activation = await promo_code_dal.get_user_activation_for_promo( existing_activation = await promo_code_dal.get_user_activation_for_promo(
session, promo_data.promo_code_id, user_id session, promo_data.promo_code_id, user_id
) )
@@ -86,7 +92,7 @@ class PromoCodeService:
session=session, session=session,
user_id=user_id, user_id=user_id,
bonus_days=bonus_days, bonus_days=bonus_days,
reason=f"promo code {code_input_upper}", reason=f"promo code {applied_code}",
) )
if new_end_date: if new_end_date:
@@ -109,7 +115,7 @@ class PromoCodeService:
user = await user_dal.get_user_by_id(session, user_id) user = await user_dal.get_user_by_id(session, user_id)
await notification_service.notify_promo_activation( await notification_service.notify_promo_activation(
user_id=user_id, user_id=user_id,
promo_code=code_input_upper, promo_code=applied_code,
bonus_days=bonus_days, bonus_days=bonus_days,
username=user.username if user else None, username=user.username if user else None,
email=getattr(user, "email", None) if user else None, email=getattr(user, "email", None) if user else None,
+3
View File
@@ -197,6 +197,9 @@ class ReferralService:
"status_from_panel": "ACTIVE_BONUS", "status_from_panel": "ACTIVE_BONUS",
"traffic_limit_bytes": self.settings.user_traffic_limit_bytes, "traffic_limit_bytes": self.settings.user_traffic_limit_bytes,
"auto_renew_enabled": False, "auto_renew_enabled": False,
# Short bonus grant: warn only hours before it
# ends, not days ahead. A real payment clears this.
"suppress_early_expiry_notifications": True,
} }
try: try:
await subscription_dal.deactivate_other_active_subscriptions( await subscription_dal.deactivate_other_active_subscriptions(
@@ -28,10 +28,7 @@ from db.dal import app_settings_dal
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
APPEARANCE_OVERRIDE_KEYS = { APPEARANCE_OVERRIDE_KEYS = {
"WEBAPP_LOGO_USE_EMOJI",
"WEBAPP_LOGO_URL", "WEBAPP_LOGO_URL",
"WEBAPP_LOGO_EMOJI",
"WEBAPP_LOGO_EMOJI_FONT",
"WEBAPP_FAVICON_USE_CUSTOM", "WEBAPP_FAVICON_USE_CUSTOM",
"WEBAPP_FAVICON_URL", "WEBAPP_FAVICON_URL",
"WEBAPP_LOGO_FAVICON_URL", "WEBAPP_LOGO_FAVICON_URL",
@@ -167,12 +164,6 @@ def _appearance_snapshot(settings: Settings) -> Dict[str, Any]:
snapshot["WEBAPP_FAVICON_URL"] = favicon_url snapshot["WEBAPP_FAVICON_URL"] = favicon_url
if getattr(settings, "WEBAPP_FAVICON_USE_CUSTOM", False): if getattr(settings, "WEBAPP_FAVICON_USE_CUSTOM", False):
snapshot["WEBAPP_FAVICON_USE_CUSTOM"] = True snapshot["WEBAPP_FAVICON_USE_CUSTOM"] = True
if getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False):
snapshot["WEBAPP_LOGO_USE_EMOJI"] = True
snapshot["WEBAPP_LOGO_EMOJI"] = getattr(settings, "WEBAPP_LOGO_EMOJI", "")
emoji_font = getattr(settings, "WEBAPP_LOGO_EMOJI_FONT", "")
if emoji_font and emoji_font != "system":
snapshot["WEBAPP_LOGO_EMOJI_FONT"] = emoji_font
primary_color = getattr(settings, "WEBAPP_PRIMARY_COLOR", None) primary_color = getattr(settings, "WEBAPP_PRIMARY_COLOR", None)
if primary_color and primary_color != "#00fe7a": if primary_color and primary_color != "#00fe7a":
snapshot["WEBAPP_PRIMARY_COLOR"] = primary_color snapshot["WEBAPP_PRIMARY_COLOR"] = primary_color
@@ -153,6 +153,15 @@ class SubscriptionNotificationWorker:
hours_before=hours_before, hours_before=hours_before,
) )
# Trial and registration/referral-bonus subscriptions last only a
# few days, so a multi-day "ending soon" reminder would fire almost
# the moment they are granted and needlessly alarm newcomers. Skip
# the day-before stages for them — they still get the hours-before
# reminder above and the expiry/after-expiry notices below. Paying
# for a real subscription clears the flag and restores all stages.
if bool(getattr(sub, "suppress_early_expiry_notifications", False)):
return None
days_before_limit = max( days_before_limit = max(
0, 0,
int(getattr(self.settings, "SUBSCRIPTION_NOTIFY_DAYS_BEFORE", 0) or 0), int(getattr(self.settings, "SUBSCRIPTION_NOTIFY_DAYS_BEFORE", 0) or 0),
@@ -31,6 +31,49 @@ class HwidDeviceMixin:
) )
return int(getattr(sub, "extra_hwid_devices", 0) or 0) return int(getattr(sub, "extra_hwid_devices", 0) or 0)
async def sync_hwid_device_limit_to_panel(
self,
session: AsyncSession,
user_id: int,
) -> Optional[int]:
"""Push the current local HWID device limit override to the panel."""
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user or not db_user.panel_user_uuid:
return None
sub = await subscription_dal.get_active_subscription_by_user_id(
session, user_id, db_user.panel_user_uuid
)
if not sub:
return None
tariff = self._resolve_tariff(sub.tariff_key) if sub.tariff_key else None
base_hwid_limit = (
int(sub.hwid_device_limit)
if sub.hwid_device_limit is not None
else self._base_hwid_limit_for_tariff(tariff)
)
extra_hwid_devices = await self._active_hwid_extra_devices_for_sub(session, sub)
sub.extra_hwid_devices = extra_hwid_devices
effective_hwid_limit = self._effective_hwid_limit(base_hwid_limit, extra_hwid_devices)
if effective_hwid_limit is None:
return None
panel_payload = self._build_panel_update_payload(
panel_user_uuid=db_user.panel_user_uuid,
expire_at=sub.end_date,
status="ACTIVE",
hwid_device_limit=effective_hwid_limit,
include_default_squads=False,
)
panel_payload.update(self._panel_identity_payload_for_user(db_user))
try:
await self.panel_service.update_user_details_on_panel(
db_user.panel_user_uuid, panel_payload
)
except Exception:
logging.exception("sync_hwid_device_limit_to_panel failed for user %s", user_id)
return effective_hwid_limit
async def _hwid_topup_validity_window( async def _hwid_topup_validity_window(
self, self,
session: AsyncSession, session: AsyncSession,
@@ -73,6 +116,64 @@ class HwidDeviceMixin:
packages = package_set.for_currency(currency) packages = package_set.for_currency(currency)
return next((pkg for pkg in packages if int(pkg.count) == int(device_count)), None) return next((pkg for pkg in packages if int(pkg.count) == int(device_count)), None)
@staticmethod
def _quote_hwid_full_period_package_price(
tariff: Tariff,
*,
device_count: int,
period_months: int,
currency: str,
) -> Optional[Dict[str, Any]]:
package_set = tariff.hwid_device_packages
if not package_set:
return None
try:
target_count = int(device_count)
months = max(1, int(period_months))
except (TypeError, ValueError):
return None
if target_count <= 0:
return None
packages = [
package
for package in package_set.for_currency(currency)
if int(getattr(package, "count", 0) or 0) > 0
]
if not packages:
return None
best: Dict[int, tuple[float, List[Any]]] = {0: (0.0, [])}
for count in range(1, target_count + 1):
best_for_count: Optional[tuple[float, List[Any]]] = None
for package in packages:
package_count = int(package.count)
previous = best.get(count - package_count)
if previous is None:
continue
price = previous[0] + float(package.price_for_period(months))
selected = [*previous[1], package]
if best_for_count is None or price < best_for_count[0]:
best_for_count = (price, selected)
if best_for_count is not None:
best[count] = best_for_count
resolved = best.get(target_count)
if resolved is None:
return None
full_price, selected_packages = resolved
rounded_price = HwidDeviceMixin._round_hwid_price(full_price, currency=currency)
if currency == "stars":
rounded_price = float(int(math.ceil(rounded_price)))
return {
"price": rounded_price,
"full_price": float(full_price),
"pricing_period_months": months,
"proration_ratio": 1.0,
"currency": currency,
"package_counts": [int(package.count) for package in selected_packages],
}
def _quote_hwid_package_price( def _quote_hwid_package_price(
self, self,
*, *,
@@ -85,16 +186,10 @@ class HwidDeviceMixin:
) -> Dict[str, Any]: ) -> Dict[str, Any]:
period_months = max(1, int(getattr(sub, "duration_months", None) or 1)) period_months = max(1, int(getattr(sub, "duration_months", None) or 1))
full_price = float(package.price_for_period(period_months)) full_price = float(package.price_for_period(period_months))
period_start = self._as_aware_utc(getattr(sub, "start_date", None)) basis_seconds = max(1.0, float(period_months * 30 * 24 * 60 * 60))
period_end = self._as_aware_utc(getattr(sub, "end_date", None)) or valid_until
inferred_period_start = add_months(period_end, -period_months)
if not period_start or period_start >= period_end or period_start < inferred_period_start:
period_start = inferred_period_start
basis_seconds = max(1.0, (period_end - period_start).total_seconds())
billable_start = max(now, valid_from) billable_start = max(now, valid_from)
billable_seconds = max(0.0, (valid_until - billable_start).total_seconds()) billable_seconds = max(0.0, (valid_until - billable_start).total_seconds())
ratio = billable_seconds / basis_seconds ratio = min(1.0, billable_seconds / basis_seconds)
raw_price = full_price * ratio raw_price = full_price * ratio
price = self._round_hwid_price(raw_price, currency=currency) price = self._round_hwid_price(raw_price, currency=currency)
min_price = getattr(package, "min_price", None) min_price = getattr(package, "min_price", None)
@@ -150,7 +245,7 @@ class HwidDeviceMixin:
if sub.hwid_device_limit is not None if sub.hwid_device_limit is not None
else self._base_hwid_limit_for_tariff(tariff) else self._base_hwid_limit_for_tariff(tariff)
) )
if base_hwid_limit == 0: if base_hwid_limit in (None, 0):
return None return None
package = self._find_hwid_package(tariff, purchased_devices, currency) package = self._find_hwid_package(tariff, purchased_devices, currency)
@@ -187,6 +282,80 @@ class HwidDeviceMixin:
) )
return quote return quote
async def quote_hwid_device_renewal_for_subscription(
self,
session: AsyncSession,
*,
user_id: int,
target_tariff_key: str,
months: int,
currency: str = "rub",
now: Optional[datetime] = None,
) -> Optional[Dict[str, Any]]:
try:
period_months = int(months)
except (TypeError, ValueError):
return None
if period_months <= 0:
return None
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user or not db_user.panel_user_uuid:
return None
sub = await subscription_dal.get_active_subscription_by_user_id(
session, user_id, db_user.panel_user_uuid
)
if not sub or not sub.end_date:
return None
now = now or datetime.now(timezone.utc)
subscription_end = self._as_aware_utc(sub.end_date)
if not subscription_end or subscription_end <= now:
return None
try:
tariff = self._resolve_tariff(target_tariff_key)
except Exception:
return None
if not tariff or tariff.billing_model != "period":
return None
base_hwid_limit = self._base_hwid_limit_for_tariff(tariff)
if base_hwid_limit in (None, 0):
return None
entitlement_summary = await tariff_dal.get_hwid_device_entitlement_summary(
session,
subscription_id=sub.subscription_id,
at=now,
)
active_devices = int(entitlement_summary.get("active_devices") or 0)
if active_devices <= 0:
return None
price_quote = self._quote_hwid_full_period_package_price(
tariff,
device_count=active_devices,
period_months=period_months,
currency=currency,
)
if not price_quote:
return None
valid_from = subscription_end
valid_until = add_months(valid_from, period_months)
price_quote.update(
{
"subscription_id": sub.subscription_id,
"tariff_key": tariff.key,
"device_count": active_devices,
"renewal": True,
"valid_from": valid_from,
"valid_until": valid_until,
"active_until": entitlement_summary.get("active_until"),
}
)
return price_quote
async def activate_hwid_device_topup( async def activate_hwid_device_topup(
self, self,
session: AsyncSession, session: AsyncSession,
@@ -248,7 +417,7 @@ class HwidDeviceMixin:
if sub.hwid_device_limit is not None if sub.hwid_device_limit is not None
else self._base_hwid_limit_for_tariff(tariff) else self._base_hwid_limit_for_tariff(tariff)
) )
if base_hwid_limit == 0: if base_hwid_limit in (None, 0):
logging.info( logging.info(
"Skipping HWID top-up for user %s because current limit is unlimited", user_id "Skipping HWID top-up for user %s because current limit is unlimited", user_id
) )
@@ -178,6 +178,7 @@ class SubscriptionLifecycleMixin:
user_id: int, user_id: int,
target_tariff_key: str, target_tariff_key: str,
mode: str, mode: str,
payment_id: Optional[int] = None,
) -> Optional[Dict[str, Any]]: ) -> Optional[Dict[str, Any]]:
config = self._tariffs_config() config = self._tariffs_config()
if not config: if not config:
@@ -336,7 +337,7 @@ class SubscriptionLifecycleMixin:
"from_tariff_key": before_tariff_key, "from_tariff_key": before_tariff_key,
"to_tariff_key": target.key, "to_tariff_key": target.key,
"mode": mode, "mode": mode,
"payment_id": None, "payment_id": payment_id,
"days_before": options.get("remaining_days"), "days_before": options.get("remaining_days"),
"days_after": (updated.end_date - now).days "days_after": (updated.end_date - now).days
if updated.end_date and target.billing_model == "period" if updated.end_date and target.billing_model == "period"
@@ -454,27 +455,11 @@ class SubscriptionLifecycleMixin:
user_id, user_id,
tariff_key, tariff_key,
"paid_diff", "paid_diff",
payment_id=payment_db_id,
) )
if result: if result:
sub = await subscription_dal.get_active_subscription_by_user_id(session, user_id) sub = await subscription_dal.get_active_subscription_by_user_id(session, user_id)
if sub: if sub:
await tariff_dal.create_tariff_change(
session,
{
"subscription_id": sub.subscription_id,
"from_tariff_key": None,
"to_tariff_key": tariff_key,
"mode": "paid_diff",
"payment_id": payment_db_id,
"days_before": None,
"days_after": (sub.end_date - datetime.now(timezone.utc)).days
if sub.end_date
else None,
"converted_bytes": None,
"eff_price_before": None,
"eff_price_after": sub.effective_monthly_price_rub,
},
)
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) db_user = await user_dal.get_user_by_id(session, user_id)
@@ -494,10 +479,29 @@ class SubscriptionLifecycleMixin:
await self._record_payment_context( await self._record_payment_context(
session, session,
payment_db_id, payment_db_id,
sale_mode=sale_mode_base, sale_mode=sale_mode,
tariff_key=tariff.key if tariff else tariff_key, tariff_key=tariff.key if tariff else tariff_key,
purchased_gb=None, purchased_gb=None,
) )
payment = await payment_dal.get_payment_by_db_id(session, payment_db_id)
try:
hwid_renewal_devices = int(getattr(payment, "purchased_hwid_devices", 0) or 0)
except (TypeError, ValueError):
hwid_renewal_devices = 0
try:
hwid_renewal_price = (
float(getattr(payment, "hwid_full_price", 0) or 0)
if hwid_renewal_devices > 0
else 0.0
)
except (TypeError, ValueError):
hwid_renewal_price = 0.0
hwid_renewal_valid_from = self._as_aware_utc(
getattr(payment, "hwid_valid_from", None) if payment else None
)
hwid_renewal_valid_until = self._as_aware_utc(
getattr(payment, "hwid_valid_until", None) if payment else None
)
db_user = await user_dal.get_user_by_id(session, user_id) db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user: if not db_user:
@@ -569,6 +573,26 @@ class SubscriptionLifecycleMixin:
promo_code_id_from_payment = None promo_code_id_from_payment = None
final_end_date = start_date + timedelta(days=duration_days_total) final_end_date = start_date + timedelta(days=duration_days_total)
if hwid_renewal_devices > 0 and hwid_renewal_valid_until and applied_promo_bonus_days:
hwid_renewal_valid_until = hwid_renewal_valid_until + timedelta(
days=applied_promo_bonus_days
)
if payment:
payment.hwid_valid_until = hwid_renewal_valid_until
elif applied_promo_bonus_days > 0 and current_active_sub:
try:
await tariff_dal.extend_hwid_device_purchases_for_subscription_bonus(
session,
subscription_id=current_active_sub.subscription_id,
at=datetime.now(timezone.utc),
subscription_end_before=start_date,
delta=timedelta(days=applied_promo_bonus_days),
)
except Exception:
logging.exception(
"Failed to extend HWID device purchases for promo payment bonus of user %s",
user_id,
)
await subscription_dal.deactivate_other_active_subscriptions( await subscription_dal.deactivate_other_active_subscriptions(
session, panel_user_uuid, panel_sub_link_id session, panel_user_uuid, panel_sub_link_id
) )
@@ -614,7 +638,8 @@ class SubscriptionLifecycleMixin:
premium_topup_balance_bytes, premium_topup_balance_bytes,
premium_topup_used_bytes, premium_topup_used_bytes,
) )
effective_monthly_price = float(payment_amount) / max(1, months_int) subscription_amount_for_pricing = max(0.0, float(payment_amount) - hwid_renewal_price)
effective_monthly_price = subscription_amount_for_pricing / max(1, months_int)
regular_bonus_carry = int(getattr(current_active_sub, "regular_bonus_bytes", 0) or 0) regular_bonus_carry = int(getattr(current_active_sub, "regular_bonus_bytes", 0) or 0)
regular_unl_carry = bool(getattr(current_active_sub, "regular_unlimited_override", False)) regular_unl_carry = bool(getattr(current_active_sub, "regular_unlimited_override", False))
traffic_limit_bytes = self._traffic_limit_for_period_tariff( traffic_limit_bytes = self._traffic_limit_for_period_tariff(
@@ -641,6 +666,9 @@ class SubscriptionLifecycleMixin:
"traffic_limit_bytes": traffic_limit_bytes, "traffic_limit_bytes": traffic_limit_bytes,
"provider": provider, "provider": provider,
"skip_notifications": False, "skip_notifications": False,
# A real payment restores the full reminder spectrum, clearing any
# trial/bonus suppression carried over on this panel subscription.
"suppress_early_expiry_notifications": False,
"auto_renew_enabled": auto_renew_should_enable, "auto_renew_enabled": auto_renew_should_enable,
"tariff_key": tariff.key if tariff else None, "tariff_key": tariff.key if tariff else None,
"tier_baseline_bytes": tier_baseline_bytes, "tier_baseline_bytes": tier_baseline_bytes,
@@ -695,6 +723,31 @@ class SubscriptionLifecycleMixin:
final_subscription_url = updated_panel_user.get("subscriptionUrl") final_subscription_url = updated_panel_user.get("subscriptionUrl")
final_panel_short_uuid = updated_panel_user.get("shortUuid", panel_short_uuid) final_panel_short_uuid = updated_panel_user.get("shortUuid", panel_short_uuid)
hwid_devices_renewed_count = 0
hwid_devices_renewed_until = None
if hwid_renewal_devices > 0:
if (
hwid_renewal_valid_from
and hwid_renewal_valid_until
and hwid_renewal_valid_from < hwid_renewal_valid_until
):
await tariff_dal.create_hwid_device_purchase(
session,
subscription_id=new_or_updated_sub.subscription_id,
payment_id=payment_db_id,
purchased_devices=hwid_renewal_devices,
valid_from=hwid_renewal_valid_from,
valid_until=hwid_renewal_valid_until,
)
hwid_devices_renewed_count = hwid_renewal_devices
hwid_devices_renewed_until = hwid_renewal_valid_until
else:
logging.warning(
"Skipping HWID renewal purchase for payment %s: invalid window %s -> %s",
payment_db_id,
hwid_renewal_valid_from,
hwid_renewal_valid_until,
)
await self._send_payment_success_email( await self._send_payment_success_email(
db_user=db_user, db_user=db_user,
@@ -715,8 +768,12 @@ class SubscriptionLifecycleMixin:
"subscription_url": final_subscription_url, "subscription_url": final_subscription_url,
"applied_promo_bonus_days": applied_promo_bonus_days, "applied_promo_bonus_days": applied_promo_bonus_days,
"tariff_key": tariff.key if tariff else None, "tariff_key": tariff.key if tariff else None,
"hwid_devices_renewal_recommended_count": extra_hwid_devices, "hwid_devices_renewal_recommended_count": 0
"hwid_devices_valid_until": hwid_devices_valid_until, if hwid_devices_renewed_count
else extra_hwid_devices,
"hwid_devices_valid_until": hwid_devices_renewed_until or hwid_devices_valid_until,
"hwid_devices_renewed_count": hwid_devices_renewed_count,
"hwid_devices_renewed_until": hwid_devices_renewed_until,
} }
async def extend_active_subscription_days( async def extend_active_subscription_days(
@@ -725,6 +782,7 @@ class SubscriptionLifecycleMixin:
user_id: int, user_id: int,
bonus_days: int, bonus_days: int,
reason: str = "bonus", reason: str = "bonus",
extend_hwid_devices: bool = True,
) -> Optional[datetime]: ) -> Optional[datetime]:
reason_lower = (reason or "").lower() reason_lower = (reason or "").lower()
apply_main_traffic_limit = any( apply_main_traffic_limit = any(
@@ -776,6 +834,9 @@ class SubscriptionLifecycleMixin:
"status_from_panel": "ACTIVE_BONUS", "status_from_panel": "ACTIVE_BONUS",
"traffic_limit_bytes": traffic_limit, "traffic_limit_bytes": traffic_limit,
"auto_renew_enabled": False, "auto_renew_enabled": False,
# Registration/referral bonus grants are short-lived, like a
# trial: only warn a few hours before they end, not days ahead.
"suppress_early_expiry_notifications": True,
} }
await subscription_dal.deactivate_other_active_subscriptions( await subscription_dal.deactivate_other_active_subscriptions(
session, panel_uuid, panel_sub_uuid session, panel_uuid, panel_sub_uuid
@@ -792,6 +853,21 @@ class SubscriptionLifecycleMixin:
updated_sub_model = await subscription_dal.update_subscription_end_date( updated_sub_model = await subscription_dal.update_subscription_end_date(
session, active_sub.subscription_id, new_end_date_obj session, active_sub.subscription_id, new_end_date_obj
) )
if updated_sub_model and extend_hwid_devices:
try:
await tariff_dal.extend_hwid_device_purchases_for_subscription_bonus(
session,
subscription_id=active_sub.subscription_id,
at=now_utc,
subscription_end_before=current_end_date,
delta=timedelta(days=bonus_days),
)
except Exception:
logging.exception(
"Failed to extend HWID device purchases for %s bonus of user %s",
reason,
user_id,
)
if ( if (
apply_main_traffic_limit apply_main_traffic_limit
@@ -14,6 +14,7 @@ class PaymentContextMixin:
"severpay": "SeverPay", "severpay": "SeverPay",
"wata": "Wata", "wata": "Wata",
"cryptopay": "Crypto Pay", "cryptopay": "Crypto Pay",
"paykilla": "PayKilla",
"telegram_stars": "Telegram Stars", "telegram_stars": "Telegram Stars",
} }
@@ -38,7 +39,8 @@ class PaymentContextMixin:
payment.sale_mode = sale_mode payment.sale_mode = sale_mode
payment.tariff_key = tariff_key payment.tariff_key = tariff_key
payment.purchased_gb = purchased_gb payment.purchased_gb = purchased_gb
payment.purchased_hwid_devices = purchased_hwid_devices if purchased_hwid_devices is not None:
payment.purchased_hwid_devices = purchased_hwid_devices
if hwid_valid_from is not None: if hwid_valid_from is not None:
payment.hwid_valid_from = hwid_valid_from payment.hwid_valid_from = hwid_valid_from
if hwid_valid_until is not None: if hwid_valid_until is not None:
@@ -42,6 +42,8 @@ class RenewalMixin:
months = sub.duration_months or 1 months = sub.duration_months or 1
currency = default_payment_currency_code_for_settings(self.settings) currency = default_payment_currency_code_for_settings(self.settings)
tariff_key = str(getattr(sub, "tariff_key", "") or "").strip() or None
sale_mode = f"subscription@{tariff_key}" if tariff_key else "subscription"
amount = None amount = None
tariffs_config = ( tariffs_config = (
self._tariffs_config() if callable(getattr(self, "_tariffs_config", None)) else None self._tariffs_config() if callable(getattr(self, "_tariffs_config", None)) else None
@@ -62,11 +64,55 @@ class RenewalMixin:
logging.error(f"Auto-renew price missing for {months} months") logging.error(f"Auto-renew price missing for {months} months")
return False return False
hwid_quote = None
quote_hwid_renewal = getattr(
self,
"quote_hwid_device_renewal_for_subscription",
None,
)
if tariff_key and callable(quote_hwid_renewal):
try:
hwid_quote = await quote_hwid_renewal(
session,
user_id=sub.user_id,
target_tariff_key=tariff_key,
months=int(months),
currency=default_currency_key_for_settings(self.settings),
)
except Exception:
logging.exception(
"Failed to quote HWID devices for auto-renew user %s",
sub.user_id,
)
hwid_quote = None
if hwid_quote:
amount = float(amount) + float(hwid_quote.get("price") or 0)
metadata = { metadata = {
"user_id": str(sub.user_id), "user_id": str(sub.user_id),
"auto_renew_for_subscription_id": str(sub.subscription_id), "auto_renew_for_subscription_id": str(sub.subscription_id),
"subscription_months": str(months), "subscription_months": str(months),
"sale_mode": sale_mode,
} }
if hwid_quote:
metadata["hwid_devices"] = str(int(hwid_quote.get("device_count") or 0))
for source_key, metadata_key in (
("valid_from", "hwid_valid_from"),
("valid_until", "hwid_valid_until"),
):
value = hwid_quote.get(source_key)
if value:
metadata[metadata_key] = (
value.isoformat() if hasattr(value, "isoformat") else str(value)
)
for key in (
"pricing_period_months",
"proration_ratio",
"full_price",
):
value = hwid_quote.get(key)
if value is not None:
metadata[f"hwid_{key}"] = str(value)
resp = await yk.create_payment( resp = await yk.create_payment(
amount=float(amount), amount=float(amount),
currency=currency, currency=currency,
@@ -314,7 +314,7 @@ class TariffMixin:
@staticmethod @staticmethod
def _effective_hwid_limit(base_limit: Optional[int], extra_devices: int = 0) -> Optional[int]: def _effective_hwid_limit(base_limit: Optional[int], extra_devices: int = 0) -> Optional[int]:
if base_limit is None: if base_limit is None:
return None return 0
base_int = max(0, int(base_limit)) base_int = max(0, int(base_limit))
if base_int == 0: if base_int == 0:
return 0 return 0
@@ -63,6 +63,8 @@ class TrialSubscriptionMixin:
"traffic_limit_bytes": self.settings.trial_traffic_limit_bytes, "traffic_limit_bytes": self.settings.trial_traffic_limit_bytes,
"auto_renew_enabled": False, "auto_renew_enabled": False,
"provider": "trial", "provider": "trial",
# Short trial: only warn a few hours before it ends, not days ahead.
"suppress_early_expiry_notifications": True,
} }
try: try:
await subscription_dal.upsert_subscription(session, trial_sub_data) await subscription_dal.upsert_subscription(session, trial_sub_data)
+1
View File
@@ -30,6 +30,7 @@ class AdminStates(StatesGroup):
waiting_for_user_delete_confirmation = State() waiting_for_user_delete_confirmation = State()
waiting_for_premium_override_bonus_gb = State() waiting_for_premium_override_bonus_gb = State()
waiting_for_traffic_grant_gb = State() waiting_for_traffic_grant_gb = State()
waiting_for_hwid_device_limit = State()
# Ads campaigns # Ads campaigns
waiting_for_ad_source = State() waiting_for_ad_source = State()
+66 -1
View File
@@ -1,4 +1,9 @@
from typing import Optional import logging
import re
from typing import Any, Optional
_TELEGRAM_LINK_RE = re.compile(r"^(?:https?://|tg://)", re.IGNORECASE)
_TELEGRAM_USERNAME_RE = re.compile(r"^[A-Za-z0-9_]{5,64}$")
def normalize_required_channel_id(value: object) -> Optional[int]: def normalize_required_channel_id(value: object) -> Optional[int]:
@@ -28,6 +33,66 @@ def normalize_required_channel_id(value: object) -> Optional[int]:
return -int(f"100{raw_abs}") return -int(f"100{raw_abs}")
def normalize_required_channel_link(value: object) -> Optional[str]:
if value is None:
return None
raw = str(value).strip()
if not raw:
return None
if _TELEGRAM_LINK_RE.match(raw):
return raw
raw = raw.lstrip("@").strip()
if not raw or re.search(r"\s", raw):
return None
if raw.startswith(("t.me/", "telegram.me/")):
return f"https://{raw}"
if raw.startswith(("+", "joinchat/", "c/")):
return f"https://t.me/{raw}"
if _TELEGRAM_USERNAME_RE.fullmatch(raw):
return f"https://t.me/{raw}"
return None
def _required_channel_link_from_chat(chat: Any) -> Optional[str]:
username = str(getattr(chat, "username", "") or "").strip().lstrip("@")
if username:
return f"https://t.me/{username}"
invite_link = normalize_required_channel_link(getattr(chat, "invite_link", None))
if invite_link:
return invite_link
return None
async def resolve_required_channel_link(
bot: Any,
required_channel_id: Optional[int],
configured_link: object,
) -> Optional[str]:
if bot is not None and required_channel_id:
try:
chat = await bot.get_chat(required_channel_id)
resolved_link = _required_channel_link_from_chat(chat)
if resolved_link:
return resolved_link
except Exception as error:
logging.warning(
"Failed to resolve required channel link from chat %s: %s",
required_channel_id,
error,
)
return normalize_required_channel_link(configured_link)
def is_required_channel_access_error(error: BaseException) -> bool: def is_required_channel_access_error(error: BaseException) -> bool:
message = str(error).lower() message = str(error).lower()
configuration_markers = ( configuration_markers = (
+9
View File
@@ -29,6 +29,15 @@ class AsyncTTLCache:
return None return None
return value return value
def get_stale(self, key: str) -> Optional[Any]:
entry = self._data.get(key)
if entry is None:
return None
_, value = entry
if not self._is_cacheable(value):
return None
return value
@staticmethod @staticmethod
def _is_cacheable(value: Any) -> bool: def _is_cacheable(value: Any) -> bool:
if value is None: if value is None:
+167 -33
View File
@@ -1,5 +1,6 @@
import logging import logging
import os import os
import re
import secrets import secrets
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
@@ -25,7 +26,117 @@ DEFAULT_SUBSCRIPTION_PURCHASE_DESCRIPTION_EN = (
def _split_csv(value: Optional[str]) -> List[str]: def _split_csv(value: Optional[str]) -> List[str]:
if not value: if not value:
return [] return []
return [item.strip() for item in value.split(",") if item.strip()] return [item.strip() for item in re.split(r"[,;\r\n]+", value) if item.strip()]
DEFAULT_DISPOSABLE_EMAIL_DOMAINS = "\n".join(
[
"10minutemail.com",
"10minutemail.net",
"10minutemail.org",
"20minutemail.com",
"33mail.com",
"anonbox.net",
"anonymbox.com",
"armyspy.com",
"byom.de",
"crazymailing.com",
"cuvox.de",
"dayrep.com",
"deadaddress.com",
"dispostable.com",
"dodgeit.com",
"dodgit.com",
"dropmail.me",
"easytrashmail.com",
"emailfake.com",
"emailondeck.com",
"emailtemporanea.com",
"emailtemporanea.net",
"einrot.com",
"fakeinbox.com",
"filzmail.com",
"fleckens.hu",
"generator.email",
"getairmail.com",
"getnada.com",
"grr.la",
"guerrillamail.biz",
"guerrillamail.com",
"guerrillamail.de",
"guerrillamail.info",
"guerrillamail.net",
"guerrillamail.org",
"guerrillamailblock.com",
"gustr.com",
"hmamail.com",
"incognitomail.org",
"inboxbear.com",
"jetable.org",
"jourrapide.com",
"kasmail.com",
"mail-temp.com",
"mailcatch.com",
"maildrop.cc",
"mailexpire.com",
"mailinator.com",
"mailinator.net",
"mailinator.org",
"mailmetrash.com",
"mailnesia.com",
"mailnull.com",
"mailpoof.com",
"mailtothis.com",
"mail.tm",
"mintemail.com",
"mohmal.com",
"moakt.com",
"mytemp.email",
"mytrashmail.com",
"nada.email",
"no-spam.ws",
"pookmail.com",
"rhyta.com",
"sharklasers.com",
"sofort-mail.de",
"spam4.me",
"spambog.com",
"spamdecoy.net",
"spamfree24.org",
"spamgourmet.com",
"spamhole.com",
"spam.la",
"spammotel.com",
"superrito.com",
"teleworm.us",
"tempail.com",
"temp-mail.io",
"temp-mail.org",
"tempmail.com",
"tempmail.dev",
"tempmail.net",
"tempmailo.com",
"temporaryemail.net",
"temporary-mail.net",
"tempr.email",
"throwawaymail.com",
"trash-mail.com",
"trash-mail.de",
"trashmail.com",
"trashmail.me",
"trashmail.net",
"trashmailer.com",
"trashymail.com",
"weg-werf-email.de",
"wegwerfmail.de",
"wegwerfmail.net",
"wegwerfmail.org",
"yomail.info",
"yopmail.com",
"yopmail.fr",
"yopmail.net",
]
)
class DBSettings(BaseModel): class DBSettings(BaseModel):
@@ -59,9 +170,6 @@ class WebAppSettings(BaseModel):
title: str title: str
primary_color: str primary_color: str
logo_url: Optional[str] logo_url: Optional[str]
logo_use_emoji: bool
logo_emoji: str
logo_emoji_font: str
favicon_use_custom: bool favicon_use_custom: bool
favicon_url: Optional[str] favicon_url: Optional[str]
logo_favicon_url: Optional[str] logo_favicon_url: Optional[str]
@@ -100,6 +208,10 @@ class Settings(BaseSettings):
PANEL_DEVICES_CACHE_TTL_SECONDS: int = Field(default=5) PANEL_DEVICES_CACHE_TTL_SECONDS: int = Field(default=5)
PANEL_ALL_USERS_CACHE_TTL_SECONDS: int = Field(default=5) PANEL_ALL_USERS_CACHE_TTL_SECONDS: int = Field(default=5)
PANEL_ALL_USERS_PAGE_SIZE: int = Field(default=1000) PANEL_ALL_USERS_PAGE_SIZE: int = Field(default=1000)
PANEL_API_TOTAL_TIMEOUT_SECONDS: float = Field(default=25)
PANEL_API_CONNECT_TIMEOUT_SECONDS: float = Field(default=8)
PANEL_API_SOCK_CONNECT_TIMEOUT_SECONDS: float = Field(default=8)
PANEL_API_SOCK_READ_TIMEOUT_SECONDS: float = Field(default=15)
ADMIN_PANEL_STATS_CACHE_TTL_SECONDS: int = Field(default=15) ADMIN_PANEL_STATS_CACHE_TTL_SECONDS: int = Field(default=15)
ADMIN_DB_STATS_CACHE_TTL_SECONDS: int = Field(default=5) ADMIN_DB_STATS_CACHE_TTL_SECONDS: int = Field(default=5)
ADMIN_USERS_LIST_CACHE_TTL_SECONDS: int = Field(default=3) ADMIN_USERS_LIST_CACHE_TTL_SECONDS: int = Field(default=3)
@@ -147,7 +259,6 @@ class Settings(BaseSettings):
SUPPORT_LINK: Optional[str] = Field(default=None) SUPPORT_LINK: Optional[str] = Field(default=None)
SERVER_STATUS_URL: Optional[str] = Field(default=None) SERVER_STATUS_URL: Optional[str] = Field(default=None)
TERMS_OF_SERVICE_URL: Optional[str] = Field(default=None)
PRIVACY_POLICY_URL: Optional[str] = Field(default=None) PRIVACY_POLICY_URL: Optional[str] = Field(default=None)
USER_AGREEMENT_URL: Optional[str] = Field(default=None) USER_AGREEMENT_URL: Optional[str] = Field(default=None)
REQUIRED_CHANNEL_ID: Optional[int] = Field( REQUIRED_CHANNEL_ID: Optional[int] = Field(
@@ -194,7 +305,7 @@ class Settings(BaseSettings):
STARS_ADMIN_ONLY_ENABLED: bool = Field(default=False) STARS_ADMIN_ONLY_ENABLED: bool = Field(default=False)
PAYMENT_METHODS_ORDER: Optional[str] = Field( PAYMENT_METHODS_ORDER: Optional[str] = Field(
default=None, default=None,
description="Comma-separated list of payment methods to show (e.g., severpay,wata,freekassa,yookassa,platega,stars,cryptopay)", # noqa: E501 description="Comma-separated list of payment methods to show (e.g., severpay,wata,freekassa,yookassa,platega,stars,cryptopay,heleket,paykilla)", # noqa: E501
) )
SUBSCRIPTION_PURCHASE_DESCRIPTION_ENABLED: bool = Field( SUBSCRIPTION_PURCHASE_DESCRIPTION_ENABLED: bool = Field(
default=True, default=True,
@@ -282,10 +393,35 @@ class Settings(BaseSettings):
default=3, default=3,
description="Welcome bonus days granted to a newly registered user who joined via referral link.", # noqa: E501 description="Welcome bonus days granted to a newly registered user who joined via referral link.", # noqa: E501
) )
REFERRAL_WELCOME_BONUS_WITHOUT_TELEGRAM_ENABLED: bool = Field(
default=True,
description=(
"Allow referral welcome bonus grants for users who have not linked Telegram. "
"Disposable email domains are still blocked until Telegram is linked."
),
)
LEGACY_REFS: bool = Field( LEGACY_REFS: bool = Field(
default=True, default=True,
description="Allow legacy referral links like ref_<telegram_id> to continue working. Defaults to True when unset.", # noqa: E501 description="Allow legacy referral links like ref_<telegram_id> to continue working. Defaults to True when unset.", # noqa: E501
) )
MIGRATION_REMNASHOP_REFERRAL_CODE_COMPAT_ENABLED: bool = Field(
default=False,
description=(
"Accept referral links imported from snoups/remnashop via legacy_referral_codes."
),
)
MIGRATION_REMNASHOP_PROMO_CODE_COMPAT_ENABLED: bool = Field(
default=False,
description="Try exact legacy Remnashop promo codes before uppercase normalization.",
)
MIGRATION_REMNASHOP_IMPORTED_AT: Optional[str] = Field(
default=None,
description="Timestamp of the latest Remnashop import run, managed by the import script.",
)
MIGRATION_REMNASHOP_NOTES: Optional[str] = Field(
default=None,
description="Operator notes for instances migrated from Remnashop.",
)
APP_RUNTIME_MODE: str = Field( APP_RUNTIME_MODE: str = Field(
default="production", default="production",
@@ -328,6 +464,13 @@ class Settings(BaseSettings):
TRIAL_DURATION_DAYS: int = Field(default=3) TRIAL_DURATION_DAYS: int = Field(default=3)
TRIAL_TRAFFIC_LIMIT_GB: Optional[float] = Field(default=5.0) TRIAL_TRAFFIC_LIMIT_GB: Optional[float] = Field(default=5.0)
TRIAL_TRAFFIC_STRATEGY: str = Field(default="NO_RESET") TRIAL_TRAFFIC_STRATEGY: str = Field(default="NO_RESET")
TRIAL_WITHOUT_TELEGRAM_ENABLED: bool = Field(
default=True,
description=(
"Allow trial activation for users who have not linked Telegram. "
"Disposable email domains are still blocked until Telegram is linked."
),
)
TRIAL_SQUAD_UUIDS: Optional[str] = Field( TRIAL_SQUAD_UUIDS: Optional[str] = Field(
default=None, default=None,
description=( description=(
@@ -373,15 +516,6 @@ class Settings(BaseSettings):
), ),
) )
WEBAPP_LOGO_URL: Optional[str] = Field(default=None) WEBAPP_LOGO_URL: Optional[str] = Field(default=None)
WEBAPP_LOGO_USE_EMOJI: bool = Field(default=False)
WEBAPP_LOGO_EMOJI: str = Field(default="🫥")
WEBAPP_LOGO_EMOJI_FONT: str = Field(
default="system",
description=(
"Emoji font for logo fallback: system, noto-color, noto-color-animated, "
"noto-emoji, twemoji, openmoji, apple, segoe, noto-local"
),
)
WEBAPP_FAVICON_USE_CUSTOM: bool = Field(default=False) WEBAPP_FAVICON_USE_CUSTOM: bool = Field(default=False)
WEBAPP_FAVICON_URL: Optional[str] = Field(default=None) WEBAPP_FAVICON_URL: Optional[str] = Field(default=None)
WEBAPP_LOGO_FAVICON_URL: Optional[str] = Field(default=None) WEBAPP_LOGO_FAVICON_URL: Optional[str] = Field(default=None)
@@ -440,6 +574,13 @@ class Settings(BaseSettings):
SMTP_PASSWORD: Optional[str] = Field(default=None) SMTP_PASSWORD: Optional[str] = Field(default=None)
SMTP_FROM_EMAIL: Optional[str] = Field(default=None) SMTP_FROM_EMAIL: Optional[str] = Field(default=None)
SMTP_FROM_NAME: Optional[str] = Field(default=None) SMTP_FROM_NAME: Optional[str] = Field(default=None)
DISPOSABLE_EMAIL_DOMAINS: str = Field(
default=DEFAULT_DISPOSABLE_EMAIL_DOMAINS,
description=(
"Disposable email domains treated as requiring Telegram for trial and "
"referral welcome bonus abuse protection. Accepts commas or one domain per line."
),
)
SMTP_STARTTLS: bool = Field(default=True) SMTP_STARTTLS: bool = Field(default=True)
SMTP_USE_SSL: bool = Field(default=False) SMTP_USE_SSL: bool = Field(default=False)
EMAIL_CODE_TTL_SECONDS: int = Field(default=10 * 60) EMAIL_CODE_TTL_SECONDS: int = Field(default=10 * 60)
@@ -544,9 +685,6 @@ class Settings(BaseSettings):
title=self.WEBAPP_TITLE, title=self.WEBAPP_TITLE,
primary_color=self.WEBAPP_PRIMARY_COLOR, primary_color=self.WEBAPP_PRIMARY_COLOR,
logo_url=self.WEBAPP_LOGO_URL, logo_url=self.WEBAPP_LOGO_URL,
logo_use_emoji=self.WEBAPP_LOGO_USE_EMOJI,
logo_emoji=self.WEBAPP_LOGO_EMOJI,
logo_emoji_font=self.WEBAPP_LOGO_EMOJI_FONT,
favicon_use_custom=self.WEBAPP_FAVICON_USE_CUSTOM, favicon_use_custom=self.WEBAPP_FAVICON_USE_CUSTOM,
favicon_url=self.WEBAPP_FAVICON_URL, favicon_url=self.WEBAPP_FAVICON_URL,
logo_favicon_url=self.WEBAPP_LOGO_FAVICON_URL, logo_favicon_url=self.WEBAPP_LOGO_FAVICON_URL,
@@ -627,6 +765,16 @@ class Settings(BaseSettings):
return trial_squads return trial_squads
return self.parsed_user_squad_uuids return self.parsed_user_squad_uuids
@computed_field
@property
def disposable_email_domains(self) -> List[str]:
domains: List[str] = []
for domain in _split_csv(self.DISPOSABLE_EMAIL_DOMAINS):
normalized = domain.strip().lower().lstrip("@.")
if normalized and normalized not in domains:
domains.append(normalized)
return domains
@computed_field @computed_field
@property @property
def parsed_user_external_squad_uuid(self) -> Optional[str]: def parsed_user_external_squad_uuid(self) -> Optional[str]:
@@ -788,21 +936,6 @@ class Settings(BaseSettings):
def ignore_deprecated_webapp_logo_url_env(cls, _value): def ignore_deprecated_webapp_logo_url_env(cls, _value):
return None return None
@field_validator("WEBAPP_LOGO_USE_EMOJI", mode="before")
@classmethod
def ignore_deprecated_webapp_logo_use_emoji_env(cls, _value):
return False
@field_validator("WEBAPP_LOGO_EMOJI", mode="before")
@classmethod
def ignore_deprecated_webapp_logo_emoji_env(cls, _value):
return "🫥"
@field_validator("WEBAPP_LOGO_EMOJI_FONT", mode="before")
@classmethod
def ignore_deprecated_webapp_logo_emoji_font_env(cls, _value):
return "system"
@field_validator("WEBAPP_FAVICON_USE_CUSTOM", mode="before") @field_validator("WEBAPP_FAVICON_USE_CUSTOM", mode="before")
@classmethod @classmethod
def ignore_deprecated_webapp_favicon_use_custom_env(cls, _value): def ignore_deprecated_webapp_favicon_use_custom_env(cls, _value):
@@ -893,6 +1026,7 @@ class Settings(BaseSettings):
"stars", "stars",
"cryptopay", "cryptopay",
"heleket", "heleket",
"paykilla",
] ]
# Make sure default_order itself includes every registered spec. # Make sure default_order itself includes every registered spec.
for sid in spec_ids: for sid in spec_ids:
+7 -2
View File
@@ -54,6 +54,8 @@ class ThemeTokens(BaseModel):
font_logo: Optional[str] = None font_logo: Optional[str] = None
font_mono: Optional[str] = None font_mono: Optional[str] = None
home_logo_scale: Optional[int] = None home_logo_scale: Optional[int] = None
home_logo_scale_desktop: Optional[int] = None
home_logo_scale_mobile: Optional[int] = None
admin_bg: Optional[str] = None admin_bg: Optional[str] = None
admin_surface: Optional[str] = None admin_surface: Optional[str] = None
admin_surface_2: Optional[str] = None admin_surface_2: Optional[str] = None
@@ -80,14 +82,14 @@ class ThemeTokens(BaseModel):
hex_value = "".join(char * 2 for char in hex_value) hex_value = "".join(char * 2 for char in hex_value)
return f"#{hex_value}" return f"#{hex_value}"
@field_validator("home_logo_scale") @field_validator("home_logo_scale", "home_logo_scale_desktop", "home_logo_scale_mobile")
@classmethod @classmethod
def _normalize_home_logo_scale(cls, value: Optional[int]) -> Optional[int]: def _normalize_home_logo_scale(cls, value: Optional[int]) -> Optional[int]:
if value is None: if value is None:
return None return None
scale = int(value) scale = int(value)
if scale < 50 or scale > 300: if scale < 50 or scale > 300:
raise ValueError("home_logo_scale must be between 50 and 300 percent") raise ValueError("home logo scale must be between 50 and 300 percent")
return scale return scale
@@ -369,6 +371,7 @@ def _builtin_theme_assets_need_refresh(key: str, target_dir: Path) -> bool:
"--success-text" not in style "--success-text" not in style
or ".theme-key-light.app-shell" not in style or ".theme-key-light.app-shell" not in style
or "Install guide theme surfaces" not in style or "Install guide theme surfaces" not in style
or "Admin controls: range sliders and sortable rows" not in style
) )
if key == "ascii": if key == "ascii":
return ( return (
@@ -379,6 +382,7 @@ def _builtin_theme_assets_need_refresh(key: str, target_dir: Path) -> bool:
or "Console-style tables" not in style or "Console-style tables" not in style
or "New webapp surfaces: support, purchase info, password login" not in style or "New webapp surfaces: support, purchase info, password login" not in style
or "Install guide theme surfaces" not in style or "Install guide theme surfaces" not in style
or "Admin controls: range sliders and sortable rows" not in style
) )
if key != "windows95": if key != "windows95":
return False return False
@@ -402,6 +406,7 @@ def _builtin_theme_assets_need_refresh(key: str, target_dir: Path) -> bool:
or "lucide-qr-code" not in style or "lucide-qr-code" not in style
or "New webapp surfaces: support, purchase info, password login" not in style or "New webapp surfaces: support, purchase info, password login" not in style
or "Install guide theme surfaces" not in style or "Install guide theme surfaces" not in style
or "Admin controls: range sliders and sortable rows" not in style
or any(not (target_dir / "icons" / icon).exists() for icon in required_icons) or any(not (target_dir / "icons" / icon).exists() for icon in required_icons)
) )
+23
View File
@@ -51,6 +51,15 @@ async def ensure_payment_with_provider_id(
description: str, description: str,
provider: str, provider: str,
provider_payment_id: str, provider_payment_id: str,
sale_mode: Optional[str] = None,
tariff_key: Optional[str] = None,
purchased_gb: Optional[float] = None,
purchased_hwid_devices: Optional[int] = None,
hwid_valid_from: Optional[Any] = None,
hwid_valid_until: Optional[Any] = None,
hwid_pricing_period_months: Optional[int] = None,
hwid_proration_ratio: Optional[float] = None,
hwid_full_price: Optional[float] = None,
) -> Payment: ) -> Payment:
"""Idempotently create a payment record for a provider event. """Idempotently create a payment record for a provider event.
@@ -72,6 +81,20 @@ async def ensure_payment_with_provider_id(
"provider_payment_id": provider_payment_id, "provider_payment_id": provider_payment_id,
"provider": provider, "provider": provider,
} }
optional_fields = {
"sale_mode": sale_mode,
"tariff_key": tariff_key,
"purchased_gb": purchased_gb,
"purchased_hwid_devices": purchased_hwid_devices,
"hwid_valid_from": hwid_valid_from,
"hwid_valid_until": hwid_valid_until,
"hwid_pricing_period_months": hwid_pricing_period_months,
"hwid_proration_ratio": hwid_proration_ratio,
"hwid_full_price": hwid_full_price,
}
payment_payload.update(
{field: value for field, value in optional_fields.items() if value is not None}
)
return await create_payment_record(session, payment_payload) return await create_payment_record(session, payment_payload)
+35 -13
View File
@@ -22,24 +22,46 @@ async def get_promo_code_by_id(session: AsyncSession, promo_code_id: int) -> Opt
return await session.get(PromoCode, promo_code_id) return await session.get(PromoCode, promo_code_id)
async def get_promo_code_by_code(session: AsyncSession, code_str: str) -> Optional[PromoCode]: def _promo_lookup_candidates(code_str: str, *, preserve_case: bool) -> List[str]:
code = str(code_str or "").strip()
if not code:
return []
candidates = [code] if preserve_case else []
upper_code = code.upper()
if upper_code not in candidates:
candidates.append(upper_code)
return candidates
async def get_promo_code_by_code(
session: AsyncSession, code_str: str, *, preserve_case: bool = False
) -> Optional[PromoCode]:
"""Get promo code by code string (regardless of active status)""" """Get promo code by code string (regardless of active status)"""
stmt = select(PromoCode).where(PromoCode.code == code_str.upper()) for candidate in _promo_lookup_candidates(code_str, preserve_case=preserve_case):
result = await session.execute(stmt) stmt = select(PromoCode).where(PromoCode.code == candidate)
return result.scalar_one_or_none() result = await session.execute(stmt)
promo = result.scalar_one_or_none()
if promo:
return promo
return None
async def get_active_promo_code_by_code_str( async def get_active_promo_code_by_code_str(
session: AsyncSession, code_str: str session: AsyncSession, code_str: str, *, preserve_case: bool = False
) -> Optional[PromoCode]: ) -> Optional[PromoCode]:
stmt = select(PromoCode).where( now = datetime.now(timezone.utc)
PromoCode.code == code_str.upper(), for candidate in _promo_lookup_candidates(code_str, preserve_case=preserve_case):
PromoCode.is_active == True, stmt = select(PromoCode).where(
PromoCode.current_activations < PromoCode.max_activations, PromoCode.code == candidate,
or_(PromoCode.valid_until == None, PromoCode.valid_until > datetime.now(timezone.utc)), PromoCode.is_active == True,
) PromoCode.current_activations < PromoCode.max_activations,
result = await session.execute(stmt) or_(PromoCode.valid_until == None, PromoCode.valid_until > now),
return result.scalar_one_or_none() )
result = await session.execute(stmt)
promo = result.scalar_one_or_none()
if promo:
return promo
return None
async def get_all_active_promo_codes( async def get_all_active_promo_codes(
+58 -1
View File
@@ -1,5 +1,5 @@
import inspect import inspect
from datetime import datetime, timezone from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from sqlalchemy import and_, delete, func, or_, select, update from sqlalchemy import and_, delete, func, or_, select, update
@@ -189,6 +189,63 @@ async def expire_hwid_device_purchases(
return result.rowcount or 0 return result.rowcount or 0
def _normalize_aware_utc(value: datetime) -> datetime:
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value
async def extend_hwid_device_purchases_for_subscription_bonus(
session: AsyncSession,
*,
subscription_id: int,
at: Optional[datetime] = None,
subscription_end_before: Optional[datetime] = None,
delta: timedelta,
) -> int:
if delta.total_seconds() <= 0:
return 0
at = _normalize_aware_utc(at or datetime.now(timezone.utc))
end_before = _normalize_aware_utc(subscription_end_before) if subscription_end_before else None
target_records: List[HwidDevicePurchase] = []
if end_before:
tail_result = await session.execute(
select(HwidDevicePurchase).where(
and_(
HwidDevicePurchase.subscription_id == subscription_id,
HwidDevicePurchase.purchased_devices > 0,
HwidDevicePurchase.valid_until.is_not(None),
HwidDevicePurchase.valid_until >= end_before,
HwidDevicePurchase.valid_until > at,
or_(
HwidDevicePurchase.valid_from.is_(None),
HwidDevicePurchase.valid_from < end_before,
),
)
)
)
target_records = list(tail_result.scalars().all())
if not target_records:
active_result = await session.execute(
select(HwidDevicePurchase).where(
and_(
*_hwid_active_conditions(subscription_id, at),
HwidDevicePurchase.valid_until.is_not(None),
)
)
)
target_records = list(active_result.scalars().all())
for record in target_records:
if record.valid_until is not None:
record.valid_until = _normalize_aware_utc(record.valid_until) + delta
if target_records:
await session.flush()
return len(target_records)
async def create_tariff_change( async def create_tariff_change(
session: AsyncSession, session: AsyncSession,
change_data: Dict[str, Any], change_data: Dict[str, Any],
+116 -6
View File
@@ -4,7 +4,7 @@ import string
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
from sqlalchemy import and_, case, delete, desc, func, or_, update from sqlalchemy import String, and_, case, cast, delete, desc, func, or_, update
from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select from sqlalchemy.future import select
@@ -14,6 +14,8 @@ from ..models import (
AdAttribution, AdAttribution,
EmailVerificationCode, EmailVerificationCode,
HwidDevicePurchase, HwidDevicePurchase,
LegacyImportMapping,
LegacyReferralCode,
MessageLog, MessageLog,
Payment, Payment,
PromoCodeActivation, PromoCodeActivation,
@@ -76,7 +78,7 @@ async def ensure_referral_code(session: AsyncSession, user: User) -> str:
Returns the existing or newly generated code. Returns the existing or newly generated code.
""" """
if user.referral_code: if user.referral_code:
normalized = user.referral_code.strip().upper() normalized = user.referral_code.strip()
if normalized != user.referral_code: if normalized != user.referral_code:
user.referral_code = normalized user.referral_code = normalized
await session.flush() await session.flush()
@@ -210,7 +212,7 @@ async def create_user(session: AsyncSession, user_data: Dict[str, Any]) -> Tuple
if not user_data.get("referral_code"): if not user_data.get("referral_code"):
user_data["referral_code"] = await generate_unique_referral_code(session) user_data["referral_code"] = await generate_unique_referral_code(session)
else: else:
user_data["referral_code"] = user_data["referral_code"].strip().upper() user_data["referral_code"] = user_data["referral_code"].strip()
# Use PostgreSQL upsert to avoid IntegrityError on concurrent inserts # Use PostgreSQL upsert to avoid IntegrityError on concurrent inserts
stmt = ( stmt = (
@@ -567,6 +569,19 @@ async def merge_users(
await session.execute( await session.execute(
update(model).where(model.user_id == source_user_id).values(user_id=target_user_id) update(model).where(model.user_id == source_user_id).values(user_id=target_user_id)
) )
await session.execute(
update(LegacyReferralCode)
.where(LegacyReferralCode.user_id == source_user_id)
.values(user_id=target_user_id)
)
await session.execute(
update(LegacyImportMapping)
.where(
LegacyImportMapping.target_table == "users",
LegacyImportMapping.target_id == str(source_user_id),
)
.values(target_id=str(target_user_id))
)
await session.execute( await session.execute(
update(MessageLog) update(MessageLog)
@@ -590,13 +605,60 @@ async def merge_users(
return target return target
async def get_user_by_referral_code(session: AsyncSession, referral_code: str) -> Optional[User]: async def get_user_by_referral_code(
normalized = referral_code.strip().upper() session: AsyncSession,
referral_code: str,
*,
include_legacy: bool = False,
) -> Optional[User]:
normalized = referral_code.strip()
if not normalized: if not normalized:
return None return None
stmt = select(User).where(User.referral_code == normalized) stmt = select(User).where(User.referral_code == normalized)
result = await session.execute(stmt) result = await session.execute(stmt)
return result.scalar_one_or_none() user = result.scalar_one_or_none()
if user:
return user
upper_normalized = normalized.upper()
if upper_normalized != normalized:
stmt = select(User).where(User.referral_code == upper_normalized)
result = await session.execute(stmt)
user = result.scalar_one_or_none()
if user:
return user
if not include_legacy:
return None
stmt = (
select(User)
.join(LegacyReferralCode, LegacyReferralCode.user_id == User.user_id)
.where(LegacyReferralCode.code == normalized, LegacyReferralCode.is_active == True)
.limit(1)
)
result = await session.execute(stmt)
user = result.scalar_one_or_none()
if user:
return user
if upper_normalized != normalized:
stmt = (
select(User)
.join(LegacyReferralCode, LegacyReferralCode.user_id == User.user_id)
.where(
LegacyReferralCode.code == upper_normalized,
LegacyReferralCode.is_active == True,
)
.limit(1)
)
result = await session.execute(stmt)
user = result.scalar_one_or_none()
if user:
return user
return None
async def update_user( async def update_user(
@@ -857,6 +919,29 @@ async def get_user_ids_without_active_subscription(session: AsyncSession) -> Lis
return result.scalars().all() return result.scalars().all()
async def get_user_ids_without_any_subscription(session: AsyncSession) -> List[int]:
"""Return non-banned user IDs who never had any subscription or trial.
These are users who registered but have no ``Subscription`` rows at all
no active, no expired and no trial history. In other words, accounts that
signed up and never did anything.
"""
any_sub = aliased(Subscription)
stmt = (
select(User.user_id)
.outerjoin(any_sub, any_sub.user_id == User.user_id)
.where(
and_(
User.is_banned == False,
any_sub.user_id.is_(None),
)
)
)
result = await session.execute(stmt)
return result.scalars().all()
def _expired_subscription_exists_for_user(now: datetime): def _expired_subscription_exists_for_user(now: datetime):
expired_subs = aliased(Subscription) expired_subs = aliased(Subscription)
normalized_status = func.lower(func.coalesce(expired_subs.status_from_panel, "")) normalized_status = func.lower(func.coalesce(expired_subs.status_from_panel, ""))
@@ -997,6 +1082,31 @@ async def delete_user_and_relations(session: AsyncSession, user_id: int) -> bool
await session.execute(delete(UserBilling).where(UserBilling.user_id == user_id)) await session.execute(delete(UserBilling).where(UserBilling.user_id == user_id))
await session.execute(delete(AdAttribution).where(AdAttribution.user_id == user_id)) await session.execute(delete(AdAttribution).where(AdAttribution.user_id == user_id))
await session.execute(delete(UserTelegramAvatar).where(UserTelegramAvatar.user_id == user_id)) await session.execute(delete(UserTelegramAvatar).where(UserTelegramAvatar.user_id == user_id))
await session.execute(delete(LegacyReferralCode).where(LegacyReferralCode.user_id == user_id))
await session.execute(
delete(LegacyImportMapping).where(
or_(
and_(
LegacyImportMapping.target_table == "users",
LegacyImportMapping.target_id == str(user_id),
),
and_(
LegacyImportMapping.target_table == "subscriptions",
LegacyImportMapping.target_id.in_(
select(cast(Subscription.subscription_id, String)).where(
Subscription.user_id == user_id
)
),
),
and_(
LegacyImportMapping.target_table == "payments",
LegacyImportMapping.target_id.in_(
select(cast(Payment.payment_id, String)).where(Payment.user_id == user_id)
),
),
)
)
)
await session.execute(delete(Payment).where(Payment.user_id == user_id)) await session.execute(delete(Payment).where(Payment.user_id == user_id))
await session.execute(delete(Subscription).where(Subscription.user_id == user_id)) await session.execute(delete(Subscription).where(Subscription.user_id == user_id))
+95
View File
@@ -1070,6 +1070,91 @@ def _migration_0033_add_trial_eligibility_reset_marker(connection: Connection) -
) )
def _migration_0034_add_legacy_import_compatibility(connection: Connection) -> None:
inspector = inspect(connection)
table_names = set(inspector.get_table_names())
if "users" in table_names:
columns = {col["name"]: col for col in inspector.get_columns("users")}
referral_column = columns.get("referral_code")
length = getattr(referral_column.get("type"), "length", None) if referral_column else None
if referral_column and (length is None or int(length) < 64):
connection.execute(
text("ALTER TABLE users ALTER COLUMN referral_code TYPE VARCHAR(64)")
)
connection.execute(
text(
"""
CREATE TABLE IF NOT EXISTS legacy_referral_codes (
legacy_code_id SERIAL PRIMARY KEY,
source VARCHAR(64) NOT NULL DEFAULT 'remnashop',
code VARCHAR(128) NOT NULL,
user_id BIGINT NOT NULL REFERENCES users(user_id),
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NULL,
CONSTRAINT uq_legacy_referral_source_code UNIQUE (source, code)
)
"""
)
)
for stmt in [
(
"CREATE INDEX IF NOT EXISTS ix_legacy_referral_codes_source "
"ON legacy_referral_codes (source)"
),
"CREATE INDEX IF NOT EXISTS ix_legacy_referral_codes_code ON legacy_referral_codes (code)",
(
"CREATE INDEX IF NOT EXISTS ix_legacy_referral_codes_user_id "
"ON legacy_referral_codes (user_id)"
),
(
"CREATE INDEX IF NOT EXISTS ix_legacy_referral_codes_is_active "
"ON legacy_referral_codes (is_active)"
),
]:
connection.execute(text(stmt))
connection.execute(
text(
"""
CREATE TABLE IF NOT EXISTS legacy_import_mappings (
source VARCHAR(64) NOT NULL,
entity_type VARCHAR(64) NOT NULL,
source_id VARCHAR(128) NOT NULL,
target_table VARCHAR(128) NOT NULL,
target_id VARCHAR(128) NOT NULL,
metadata_json TEXT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NULL,
PRIMARY KEY (source, entity_type, source_id)
)
"""
)
)
connection.execute(
text(
"""
CREATE INDEX IF NOT EXISTS ix_legacy_import_mappings_target
ON legacy_import_mappings (target_table, target_id)
"""
)
)
def _migration_0035_add_subscription_promo_expiry_flag(connection: Connection) -> None:
inspector = inspect(connection)
columns: Set[str] = {col["name"] for col in inspector.get_columns("subscriptions")}
if "suppress_early_expiry_notifications" not in columns:
connection.execute(
text(
"ALTER TABLE subscriptions ADD COLUMN suppress_early_expiry_notifications "
"BOOLEAN NOT NULL DEFAULT FALSE"
)
)
MIGRATIONS: List[Migration] = [ MIGRATIONS: List[Migration] = [
Migration( Migration(
id="0001_add_channel_subscription_fields", id="0001_add_channel_subscription_fields",
@@ -1247,6 +1332,16 @@ MIGRATIONS: List[Migration] = [
description="Track admin resets of per-user trial eligibility without deleting history", description="Track admin resets of per-user trial eligibility without deleting history",
upgrade=_migration_0033_add_trial_eligibility_reset_marker, upgrade=_migration_0033_add_trial_eligibility_reset_marker,
), ),
Migration(
id="0034_add_legacy_import_compatibility",
description="Store legacy import mappings and referral codes for source-bot migrations",
upgrade=_migration_0034_add_legacy_import_compatibility,
),
Migration(
id="0035_add_subscription_promo_expiry_flag",
description="Suppress multi-day expiry reminders for trial and bonus subscriptions",
upgrade=_migration_0035_add_subscription_promo_expiry_flag,
),
] ]
+36 -1
View File
@@ -43,7 +43,7 @@ class User(Base):
registration_date = Column(DateTime(timezone=True), server_default=func.now()) registration_date = Column(DateTime(timezone=True), server_default=func.now())
is_banned = Column(Boolean, default=False) is_banned = Column(Boolean, default=False)
panel_user_uuid = Column(String, nullable=True, unique=True, index=True) panel_user_uuid = Column(String, nullable=True, unique=True, index=True)
referral_code = Column(String(16), nullable=True, unique=True, index=True) referral_code = Column(String(64), nullable=True, unique=True, index=True)
referred_by_id = Column(BigInteger, ForeignKey("users.user_id"), nullable=True) referred_by_id = Column(BigInteger, ForeignKey("users.user_id"), nullable=True)
lifetime_used_traffic_bytes = Column(BigInteger, nullable=True) lifetime_used_traffic_bytes = Column(BigInteger, nullable=True)
lifetime_used_traffic_synced_at = Column(DateTime(timezone=True), nullable=True) lifetime_used_traffic_synced_at = Column(DateTime(timezone=True), nullable=True)
@@ -122,6 +122,12 @@ class Subscription(Base):
last_notification_sent = Column(DateTime(timezone=True), nullable=True) last_notification_sent = Column(DateTime(timezone=True), nullable=True)
provider = Column(String, nullable=True) provider = Column(String, nullable=True)
skip_notifications = Column(Boolean, default=False) skip_notifications = Column(Boolean, default=False)
# Trial and registration/referral-bonus subscriptions are only a few days
# long, so the multi-day "ending soon" reminders would fire almost as soon
# as they are granted. While this is set the worker keeps only the
# hours-before reminder plus the expiry/after-expiry notices; a real payment
# clears it so the full reminder spectrum resumes.
suppress_early_expiry_notifications = Column(Boolean, nullable=False, default=False)
auto_renew_enabled = Column(Boolean, default=True, index=True) auto_renew_enabled = Column(Boolean, default=True, index=True)
tariff_key = Column(String, nullable=True, index=True) tariff_key = Column(String, nullable=True, index=True)
tier_baseline_bytes = Column(BigInteger, nullable=True) tier_baseline_bytes = Column(BigInteger, nullable=True)
@@ -396,6 +402,35 @@ class PromoCodeActivation(Base):
) )
class LegacyReferralCode(Base):
__tablename__ = "legacy_referral_codes"
legacy_code_id = Column(Integer, primary_key=True, autoincrement=True)
source = Column(String(64), nullable=False, default="remnashop", index=True)
code = Column(String(128), nullable=False, index=True)
user_id = Column(BigInteger, ForeignKey("users.user_id"), nullable=False, index=True)
is_active = Column(Boolean, nullable=False, default=True, index=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now(), nullable=True)
user = relationship("User")
__table_args__ = (UniqueConstraint("source", "code", name="uq_legacy_referral_source_code"),)
class LegacyImportMapping(Base):
__tablename__ = "legacy_import_mappings"
source = Column(String(64), primary_key=True)
entity_type = Column(String(64), primary_key=True)
source_id = Column(String(128), primary_key=True)
target_table = Column(String(128), nullable=False)
target_id = Column(String(128), nullable=False)
metadata_json = Column(Text, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now(), nullable=True)
class MessageLog(Base): class MessageLog(Base):
__tablename__ = "message_logs" __tablename__ = "message_logs"
+2 -2
View File
@@ -1,6 +1,6 @@
aiogram==3.28.2 aiogram==3.28.2
python-dotenv==1.2.2 python-dotenv==1.2.2
aiohttp==3.13.5 aiohttp>=3.13.5,<3.14
pydantic==2.13.4 pydantic==2.13.4
yookassa==3.10.1 yookassa==3.10.1
httpx>=0.27.0 httpx>=0.27.0
@@ -9,6 +9,6 @@ email-validator==2.3.0
sqlalchemy[asyncio]==2.0.49 sqlalchemy[asyncio]==2.0.49
asyncpg==0.31.0 asyncpg==0.31.0
aiocryptopay==0.4.8 aiocryptopay==0.4.8
PyJWT[crypto]==2.12.1 PyJWT[crypto]==2.13.0
Pillow==12.2.0 Pillow==12.2.0
redis==6.4.0 redis==6.4.0
+1
View File
@@ -0,0 +1 @@
"""Operational one-shot scripts shipped with the backend image."""
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -84,7 +84,7 @@ server {
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
location ~ ^/(webapp-logo|webapp-uploaded-logo|webapp-favicon|webapp-emoji|webapp-theme-css|webapp-theme-assets)/ { location ~ ^/(webapp-logo|webapp-uploaded-logo|webapp-favicon|webapp-theme-css|webapp-theme-assets)/ {
proxy_pass http://backend:8081; proxy_pass http://backend:8081;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $host;
+1
View File
@@ -96,6 +96,7 @@ export default defineConfig({
items: [ items: [
{ label: 'Обзор миграций', slug: 'migrations' }, { label: 'Обзор миграций', slug: 'migrations' },
{ label: 'remnawave-tg-shop', slug: 'migrations/remnawave-tg-shop' }, { label: 'remnawave-tg-shop', slug: 'migrations/remnawave-tg-shop' },
{ label: 'remnashop', slug: 'migrations/remnashop' },
], ],
}, },
{ {
+12 -4
View File
@@ -1,5 +1,6 @@
const frame = document.getElementById("demo-frame"); const frame = document.getElementById("demo-frame");
const runtimeBase = "/demo/runtime"; const runtimeBase = "/demo/runtime";
const runtimeAppPath = `${runtimeBase}/app/index.html`;
const demoBase = "/demo"; const demoBase = "/demo";
const defaultMock = "tariffs"; const defaultMock = "tariffs";
const publicRouteAliases = new Map([["/app", "/home"]]); const publicRouteAliases = new Map([["/app", "/home"]]);
@@ -8,6 +9,8 @@ const stateMocks = new Set([
"depleted", "depleted",
"no-subscription", "no-subscription",
"trial", "trial",
"trial-telegram",
"referral-telegram",
"devices", "devices",
"notifications", "notifications",
"auth", "auth",
@@ -145,7 +148,7 @@ if (initialMock !== "emails") {
params.delete("screen"); params.delete("screen");
params.delete("admin_section"); params.delete("admin_section");
params.set("path", initialRoute); params.set("path", initialRoute);
frame.src = `${runtimeBase}/app/?${params.toString()}${window.location.hash || ""}`; frame.src = `${runtimeAppPath}?${params.toString()}${window.location.hash || ""}`;
} }
const routeFromRuntimeUrl = (url) => { const routeFromRuntimeUrl = (url) => {
@@ -155,7 +158,11 @@ const routeFromRuntimeUrl = (url) => {
const runtimePath = normalizePath( const runtimePath = normalizePath(
url.pathname.slice(runtimeBase.length) || "/home", url.pathname.slice(runtimeBase.length) || "/home",
); );
if (runtimePath === "/app" || runtimePath === "/app.html") { if (
runtimePath === "/app" ||
runtimePath === "/app/index.html" ||
runtimePath === "/app.html"
) {
return normalizePath(url.searchParams.get("path") || "/home"); return normalizePath(url.searchParams.get("path") || "/home");
} }
return runtimePath; return runtimePath;
@@ -171,7 +178,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/?${nextParams.toString()}${window.location.hash || ""}`; return `${runtimeAppPath}?${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");
@@ -226,7 +233,8 @@ const setCollapsed = (collapsed) => {
toggle?.addEventListener("click", () => setCollapsed(false)); toggle?.addEventListener("click", () => setCollapsed(false));
hide?.addEventListener("click", () => setCollapsed(true)); hide?.addEventListener("click", () => setCollapsed(true));
if (stateSelect) stateSelect.value = normalizeStateMock(params.get("mock") || initialMock); if (stateSelect)
stateSelect.value = normalizeStateMock(params.get("mock") || initialMock);
setDemoMode(initialMock); setDemoMode(initialMock);
stateSelect?.addEventListener("change", () => { stateSelect?.addEventListener("change", () => {
const mock = normalizeStateMock(stateSelect.value); const mock = normalizeStateMock(stateSelect.value);
+44 -3
View File
@@ -1,5 +1,8 @@
import base64
import hashlib
import json import json
import sys import sys
import tempfile
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
@@ -9,6 +12,7 @@ sys.path.insert(0, str(BACKEND_ROOT))
if hasattr(sys.stdout, "reconfigure"): if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8") sys.stdout.reconfigure(encoding="utf-8")
from bot.services import email_templates as email_templates_module # noqa: E402
from bot.services.email_templates import ( # noqa: E402 from bot.services.email_templates import ( # noqa: E402
render_account_merged, render_account_merged,
render_login_code, render_login_code,
@@ -23,6 +27,34 @@ from bot.services.email_templates import ( # noqa: E402
) )
LANGUAGE = "ru" LANGUAGE = "ru"
PREVIEW_LOGO_FILE = (
REPO_ROOT
/ "backend"
/ "bot"
/ "app"
/ "web"
/ "templates"
/ "default-brand"
/ "favicons"
/ "19b2a242e5b7bc2d"
/ "icon-180.png"
)
PREVIEW_LOGO_TEMP_DIR = tempfile.TemporaryDirectory()
def prepare_preview_logo_url() -> str:
if not PREVIEW_LOGO_FILE.exists():
return ""
body = PREVIEW_LOGO_FILE.read_bytes()
digest = hashlib.sha256(body).hexdigest()[:16]
filename = f"logo-{digest}.png"
logo_dir = Path(PREVIEW_LOGO_TEMP_DIR.name)
(logo_dir / filename).write_bytes(body)
email_templates_module._WEBAPP_UPLOADED_LOGO_DIR = logo_dir
return f"/webapp-uploaded-logo/{filename}"
PREVIEW_LOGO_URL = prepare_preview_logo_url()
class PreviewI18n: class PreviewI18n:
@@ -63,8 +95,7 @@ def settings():
return SimpleNamespace( return SimpleNamespace(
DEFAULT_LANGUAGE=LANGUAGE, DEFAULT_LANGUAGE=LANGUAGE,
EMAIL_CODE_TTL_SECONDS=600, EMAIL_CODE_TTL_SECONDS=600,
WEBAPP_LOGO_URL="", WEBAPP_LOGO_URL=PREVIEW_LOGO_URL,
WEBAPP_LOGO_USE_EMOJI=False,
WEBAPP_PRIMARY_COLOR="#00fe7a", WEBAPP_PRIMARY_COLOR="#00fe7a",
WEBAPP_TITLE="remnawave-minishop", WEBAPP_TITLE="remnawave-minishop",
) )
@@ -95,10 +126,20 @@ def preview(item_id: str, category: str, title: str, content):
"category": category, "category": category,
"title": title, "title": title,
"subject": content.subject, "subject": content.subject,
"html": content.html, "html": preview_html(content),
} }
def preview_html(content):
rendered = content.html
for image in content.inline_images:
data_url = (
f"data:{image.content_type};base64,{base64.b64encode(image.data).decode('ascii')}"
)
rendered = rendered.replace(f"cid:{image.content_id}", data_url)
return rendered
def payment_preview( def payment_preview(
item_id: str, item_id: str,
title: str, title: str,
+1
View File
@@ -30,6 +30,7 @@ const descriptions = {
'features/support.md': 'Пользовательские тикеты, список обращений в админке, уведомления и лимиты поддержки.', 'features/support.md': 'Пользовательские тикеты, список обращений в админке, уведомления и лимиты поддержки.',
'migrations/index.md': 'Готовые сценарии миграции в Remnawave Minishop с других ботов.', 'migrations/index.md': 'Готовые сценарии миграции в Remnawave Minishop с других ботов.',
'migrations/remnawave-tg-shop.md': 'Перенос данных со старого remnawave-tg-shop на split-архитектуру Minishop.', 'migrations/remnawave-tg-shop.md': 'Перенос данных со старого remnawave-tg-shop на split-архитектуру Minishop.',
'migrations/remnashop.md': 'Импорт данных из Remnashop через install wizard или скрипт import_legacy.py.',
'troubleshooting/issues.md': 'Короткие чеклисты для частых проблем запуска, вебхуков, Mini App и платежей.', 'troubleshooting/issues.md': 'Короткие чеклисты для частых проблем запуска, вебхуков, Mini App и платежей.',
'troubleshooting/logs.md': 'Какие логи смотреть при диагностике backend, worker, frontend, миграций и вебхуков.', 'troubleshooting/logs.md': 'Какие логи смотреть при диагностике backend, worker, frontend, миграций и вебхуков.',
'troubleshooting/maintenance.md': 'Обновления, миграции, резервные копии и проверки продакшен-стека.', 'troubleshooting/maintenance.md': 'Обновления, миграции, резервные копии и проверки продакшен-стека.',
+1
View File
@@ -31,6 +31,7 @@ for (const command of pythonCommands) {
const result = spawnSync(command, [generatorPath], { const result = spawnSync(command, [generatorPath], {
cwd: repoRoot, cwd: repoRoot,
encoding: "utf8", encoding: "utf8",
maxBuffer: 20 * 1024 * 1024,
env: { env: {
...process.env, ...process.env,
PYTHONIOENCODING: "utf-8", PYTHONIOENCODING: "utf-8",
+4 -2
View File
@@ -1,7 +1,7 @@
--- ---
import { emailPreviews } from '../lib/emailPreviews.mjs'; import { emailPreviews } from '../lib/emailPreviews.mjs';
const defaultDemoSrc = '/demo/runtime/app/?path=/home&mock=tariffs'; const defaultDemoSrc = '/demo/runtime/app/index.html?path=/home&mock=tariffs';
const docsHref = '/getting-started/demo/'; const docsHref = '/getting-started/demo/';
--- ---
@@ -467,6 +467,8 @@ const docsHref = '/getting-started/demo/';
<option value="depleted">Трафик закончился</option> <option value="depleted">Трафик закончился</option>
<option value="no-subscription">Нет подписки</option> <option value="no-subscription">Нет подписки</option>
<option value="trial">Доступна пробная подписка</option> <option value="trial">Доступна пробная подписка</option>
<option value="trial-telegram">Email-only: Telegram для триала</option>
<option value="referral-telegram">Email-only: Telegram для рефбонуса</option>
<option value="devices">Лимит и докупка устройств</option> <option value="devices">Лимит и докупка устройств</option>
<option value="notifications">Telegram-уведомления</option> <option value="notifications">Telegram-уведомления</option>
<option value="auth">Вход и регистрация</option> <option value="auth">Вход и регистрация</option>
@@ -529,7 +531,7 @@ const docsHref = '/getting-started/demo/';
title={`Email preview: ${preview.title}`} title={`Email preview: ${preview.title}`}
srcdoc={preview.html} srcdoc={preview.html}
loading="lazy" loading="lazy"
sandbox="" sandbox="allow-scripts"
></iframe> ></iframe>
</div> </div>
</div> </div>
+44 -7
View File
@@ -59,6 +59,10 @@
| `PANEL_DEVICES_CACHE_TTL_SECONDS` | TTL кеша устройств пользователя Remnawave. | | `PANEL_DEVICES_CACHE_TTL_SECONDS` | TTL кеша устройств пользователя Remnawave. |
| `PANEL_ALL_USERS_CACHE_TTL_SECONDS` | TTL кеша полных сканов пользователей Remnawave. | | `PANEL_ALL_USERS_CACHE_TTL_SECONDS` | TTL кеша полных сканов пользователей Remnawave. |
| `PANEL_ALL_USERS_PAGE_SIZE` | Размер страницы Remnawave `/users`. | | `PANEL_ALL_USERS_PAGE_SIZE` | Размер страницы Remnawave `/users`. |
| `PANEL_API_TOTAL_TIMEOUT_SECONDS` | Общий timeout запроса к Remnawave API. |
| `PANEL_API_CONNECT_TIMEOUT_SECONDS` | Timeout получения соединения с Remnawave API. |
| `PANEL_API_SOCK_CONNECT_TIMEOUT_SECONDS` | Timeout TCP/TLS-подключения к Remnawave API. |
| `PANEL_API_SOCK_READ_TIMEOUT_SECONDS` | Timeout ожидания данных ответа Remnawave API. |
| `ADMIN_PANEL_STATS_CACHE_TTL_SECONDS` | TTL статистики Remnawave в админке. | | `ADMIN_PANEL_STATS_CACHE_TTL_SECONDS` | TTL статистики Remnawave в админке. |
| `ADMIN_DB_STATS_CACHE_TTL_SECONDS` | TTL дорогих DB-агрегатов админки. | | `ADMIN_DB_STATS_CACHE_TTL_SECONDS` | TTL дорогих DB-агрегатов админки. |
| `ADMIN_USERS_LIST_CACHE_TTL_SECONDS` | TTL списка пользователей админки. | | `ADMIN_USERS_LIST_CACHE_TTL_SECONDS` | TTL списка пользователей админки. |
@@ -101,11 +105,10 @@
| `DEFAULT_CURRENCY_SYMBOL` | Символ/код валюты в интерфейсе. | | `DEFAULT_CURRENCY_SYMBOL` | Символ/код валюты в интерфейсе. |
| `SUPPORT_LINK` | Внешняя ссылка поддержки. | | `SUPPORT_LINK` | Внешняя ссылка поддержки. |
| `SERVER_STATUS_URL` | Страница статуса сервиса. | | `SERVER_STATUS_URL` | Страница статуса сервиса. |
| `TERMS_OF_SERVICE_URL` | Условия использования. |
| `PRIVACY_POLICY_URL` | Политика конфиденциальности. | | `PRIVACY_POLICY_URL` | Политика конфиденциальности. |
| `USER_AGREEMENT_URL` | Пользовательское соглашение. | | `USER_AGREEMENT_URL` | Пользовательское соглашение. |
| `REQUIRED_CHANNEL_ID` | ID обязательного Telegram-канала. | | `REQUIRED_CHANNEL_ID` | ID обязательного Telegram-канала. Используется для проверки подписки и автоматического получения ссылки кнопки, если бот видит канал. |
| `REQUIRED_CHANNEL_LINK` | Ссылка на обязательный канал. | | `REQUIRED_CHANNEL_LINK` | Необязательная запасная ссылка на обязательный канал (`@username` или invite-link), если ссылку нельзя получить по ID. |
| `START_COMMAND_DESCRIPTION` | Описание `/start` для меню Telegram. | | `START_COMMAND_DESCRIPTION` | Описание `/start` для меню Telegram. |
| `DISABLE_WELCOME_MESSAGE` | Отключить приветствие на `/start`. | | `DISABLE_WELCOME_MESSAGE` | Отключить приветствие на `/start`. |
@@ -157,9 +160,6 @@
| `TELEGRAM_OAUTH_REQUEST_ACCESS` | `.env` | Дополнительные разрешения, например `write`. | | `TELEGRAM_OAUTH_REQUEST_ACCESS` | `.env` | Дополнительные разрешения, например `write`. |
| `WEBAPP_PRIMARY_COLOR` | Админка | Устаревшее env-поле, игнорируется. | | `WEBAPP_PRIMARY_COLOR` | Админка | Устаревшее env-поле, игнорируется. |
| `WEBAPP_LOGO_URL` | Админка | Устаревшее env-поле, игнорируется. | | `WEBAPP_LOGO_URL` | Админка | Устаревшее env-поле, игнорируется. |
| `WEBAPP_LOGO_USE_EMOJI` | Админка | Устаревшее env-поле, игнорируется. |
| `WEBAPP_LOGO_EMOJI` | Админка | Устаревшее env-поле, игнорируется. |
| `WEBAPP_LOGO_EMOJI_FONT` | Админка | Устаревшее env-поле, игнорируется. |
| `WEBAPP_FAVICON_USE_CUSTOM` | Админка | Устаревшее env-поле, игнорируется. | | `WEBAPP_FAVICON_USE_CUSTOM` | Админка | Устаревшее env-поле, игнорируется. |
| `WEBAPP_FAVICON_URL` | Админка | Устаревшее env-поле, игнорируется. | | `WEBAPP_FAVICON_URL` | Админка | Устаревшее env-поле, игнорируется. |
| `WEBAPP_LOGO_FAVICON_URL` | Админка | Устаревшее env-поле, игнорируется. | | `WEBAPP_LOGO_FAVICON_URL` | Админка | Устаревшее env-поле, игнорируется. |
@@ -197,7 +197,7 @@
| Переменная | Назначение | | Переменная | Назначение |
| --- | --- | | --- | --- |
| `PAYMENT_METHODS_ORDER` | Порядок кнопок оплаты: `severpay,wata,freekassa,platega,yookassa,stars,cryptopay,heleket`. | | `PAYMENT_METHODS_ORDER` | Порядок кнопок оплаты: `severpay,wata,freekassa,platega,yookassa,stars,cryptopay,heleket,paykilla`. |
| `SUBSCRIPTION_PURCHASE_DESCRIPTION_ENABLED` | Показывать описание подписки перед выбором срока. | | `SUBSCRIPTION_PURCHASE_DESCRIPTION_ENABLED` | Показывать описание подписки перед выбором срока. |
| `SUBSCRIPTION_PURCHASE_DESCRIPTION_RU` / `SUBSCRIPTION_PURCHASE_DESCRIPTION_EN` | Локализованное описание подписки. | | `SUBSCRIPTION_PURCHASE_DESCRIPTION_RU` / `SUBSCRIPTION_PURCHASE_DESCRIPTION_EN` | Локализованное описание подписки. |
| `PAYMENT_<METHOD>_WEBAPP_LABEL_RU` / `PAYMENT_<METHOD>_WEBAPP_LABEL_EN` | Текст кнопки провайдера в Web App. | | `PAYMENT_<METHOD>_WEBAPP_LABEL_RU` / `PAYMENT_<METHOD>_WEBAPP_LABEL_EN` | Текст кнопки провайдера в Web App. |
@@ -213,6 +213,7 @@
| `WATA_ENABLED` | Включает Wata. | | `WATA_ENABLED` | Включает Wata. |
| `CRYPTOPAY_ENABLED` | Включает CryptoPay. | | `CRYPTOPAY_ENABLED` | Включает CryptoPay. |
| `HELEKET_ENABLED` | Включает Heleket. | | `HELEKET_ENABLED` | Включает Heleket. |
| `PAYKILLA_ENABLED` | Включает PayKilla. |
Конкретные ключи отображения: Конкретные ключи отображения:
@@ -271,6 +272,12 @@ PAYMENT_HELEKET_WEBAPP_ICON
PAYMENT_HELEKET_TELEGRAM_LABEL_RU PAYMENT_HELEKET_TELEGRAM_LABEL_RU
PAYMENT_HELEKET_TELEGRAM_LABEL_EN PAYMENT_HELEKET_TELEGRAM_LABEL_EN
PAYMENT_HELEKET_TELEGRAM_EMOJI PAYMENT_HELEKET_TELEGRAM_EMOJI
PAYMENT_PAYKILLA_WEBAPP_LABEL_RU
PAYMENT_PAYKILLA_WEBAPP_LABEL_EN
PAYMENT_PAYKILLA_WEBAPP_ICON
PAYMENT_PAYKILLA_TELEGRAM_LABEL_RU
PAYMENT_PAYKILLA_TELEGRAM_LABEL_EN
PAYMENT_PAYKILLA_TELEGRAM_EMOJI
``` ```
### YooKassa ### YooKassa
@@ -357,6 +364,33 @@ PAYMENT_HELEKET_TELEGRAM_EMOJI
| `HELEKET_VERIFY_WEBHOOK_SIGNATURE` | Проверять подпись webhook. | | `HELEKET_VERIFY_WEBHOOK_SIGNATURE` | Проверять подпись webhook. |
| `HELEKET_TRUSTED_IPS` | Список доверенных IP webhook-источников. | | `HELEKET_TRUSTED_IPS` | Список доверенных IP webhook-источников. |
### PayKilla
Для приема оплат нужен API key типа **HMAC** с правом **INVOICE**. Право **WITHDRAWAL** для оплаты подписок не требуется; включайте его только для отдельной интеграции выплат.
Webhook настраивается в PayKilla Dashboard: **Settings -> Webhooks**. Укажите `WEBHOOK_BASE_URL` + `/webhook/paykilla`, например `https://bot.example.com/webhook/paykilla`. Включите события `INVOICE_PAID` и `INVOICE_EXPIRED` как минимум. Рекомендуемый набор галочек: `INVOICE_PAID`, `PAYMENT_COMPLETED`, `PAYMENT_FAILED`, `PAYMENT_OVERPAID`, `PAYMENT_UNDERPAID`, `PAYMENT_PARTIAL`, `INVOICE_EXPIRED`, `COMPLIANCE_FAILED`. Если хотите видеть промежуточные статусы в логах PayKilla, дополнительно включите `INVOICE_CREATED`, `PAYMENT_PENDING`, `TRANSACTION_CONFIRMED` и `TRANSACTION_FINAL`.
| Переменная | Назначение |
| --- | --- |
| `PAYKILLA_BASE_URL` | Базовый URL API, по умолчанию `https://account-api.paykilla.com`. |
| `PAYKILLA_WIDGET_URL` | URL hosted checkout, по умолчанию `https://gopay.paykilla.com`. |
| `PAYKILLA_API_KEY` / `PAYKILLA_V2_API_KEY` | Public HMAC key с правом `INVOICE`. |
| `PAYKILLA_SECRET_KEY` / `PAYKILLA_V2_SECRET_KEY` | Secret HMAC key для подписи API-запросов и проверки webhook. |
| `PAYKILLA_CURRENCY` | Резервная валюта инвойса PayKilla для платежей, чья валюта тарифа не входит в `PAYKILLA_INVOICE_CURRENCIES`. По умолчанию `USD`. |
| `PAYKILLA_INVOICE_CURRENCIES` | Валюты, которые PayKilla принимает в поле `currency` при создании invoice. По умолчанию `USD,EUR`. Если тариф в `RUB`, Minishop конвертирует сумму в `PAYKILLA_CURRENCY`. |
| `PAYKILLA_PAYMENT_CURRENCIES` | Crypto tickers для оплаты. Рекомендуемый стартовый вариант: `USDTTRC`; добавляйте `BTC`, `ETH` и другие тикеры только если они доступны в PayKilla Dashboard для merchant account. |
| `PAYKILLA_SUPPORTED_CURRENCIES` | Валюты тарифов/платежей, которым разрешено использовать PayKilla в этом магазине. |
| `PAYKILLA_INVOICE_TYPE` | Необязательный override: `FIAT_BASED`, `FIXED_AMOUNT` или `OPEN_AMOUNT`. |
| `PAYKILLA_LIFETIME_SECONDS` | TTL инвойса, отправляется как `expiredAt`. |
| `PAYKILLA_RECV_WINDOW_MS` | `recvWindow` для подписанных API-запросов. |
| `PAYKILLA_USER_PAYS_SERVICE_FEE` | `true`, если пользователь оплачивает service fee. |
| `PAYKILLA_USER_PAYS_NETWORK_FEE` | `true`, если пользователь оплачивает network fee. |
| `PAYKILLA_EXCHANGE_RATE_URL` | Бесплатный no-key endpoint курса для конвертации валюты тарифа в валюту инвойса. По умолчанию `https://open.er-api.com/v6/latest/{source}`. Поддерживает placeholders `{source}` и `{target}`. |
| `PAYKILLA_EXCHANGE_RATE_CACHE_SECONDS` | Кэш курса и PayKilla currency limits в секундах. По умолчанию `3600`. |
| `PAYKILLA_VERIFY_WEBHOOK_SIGNATURE` | Проверять `X-API-SIGN` по raw body webhook. |
| `PAYKILLA_WEBHOOK_URL` | Точный публичный webhook URL для проверки подписи, если он отличается от `WEBHOOK_BASE_URL` + `/webhook/paykilla`. |
| `PAYKILLA_TRUSTED_IPS` | Необязательный список доверенных IP webhook-источников. |
## Тарифы и legacy-цены ## Тарифы и legacy-цены
Рекомендуемый способ настройки тарифов - раздел **Система -> Тарифы** в админке. Он сохраняет JSON в `TARIFFS_CONFIG_PATH`. Рекомендуемый способ настройки тарифов - раздел **Система -> Тарифы** в админке. Он сохраняет JSON в `TARIFFS_CONFIG_PATH`.
@@ -386,10 +420,13 @@ PAYMENT_HELEKET_TELEGRAM_EMOJI
| `TRIAL_DURATION_DAYS` | Длительность пробного периода. | | `TRIAL_DURATION_DAYS` | Длительность пробного периода. |
| `TRIAL_TRAFFIC_LIMIT_GB` | Лимит трафика пробного периода. | | `TRIAL_TRAFFIC_LIMIT_GB` | Лимит трафика пробного периода. |
| `TRIAL_TRAFFIC_STRATEGY` | Стратегия лимита пробного периода. | | `TRIAL_TRAFFIC_STRATEGY` | Стратегия лимита пробного периода. |
| `TRIAL_WITHOUT_TELEGRAM_ENABLED` | Разрешает активацию trial пользователям без привязанного Telegram. Disposable email домены всё равно требуют Telegram. |
| `TRIAL_SQUAD_UUIDS` | Internal Squads для trial через запятую. Если пусто, используется `USER_SQUAD_UUIDS`. | | `TRIAL_SQUAD_UUIDS` | Internal Squads для trial через запятую. Если пусто, используется `USER_SQUAD_UUIDS`. |
| `REFERRAL_ONE_BONUS_PER_REFEREE` | Ограничить бонусы одним успешным платежом приглашенного. | | `REFERRAL_ONE_BONUS_PER_REFEREE` | Ограничить бонусы одним успешным платежом приглашенного. |
| `REFERRAL_WELCOME_BONUS_DAYS` | Приветственный бонус пришедшему по реферальной ссылке. | | `REFERRAL_WELCOME_BONUS_DAYS` | Приветственный бонус пришедшему по реферальной ссылке. |
| `REFERRAL_WELCOME_BONUS_WITHOUT_TELEGRAM_ENABLED` | Разрешает начислять реферальный приветственный бонус пользователям без привязанного Telegram. Disposable email домены всё равно требуют Telegram. |
| `LEGACY_REFS` | Разрешить ссылки `ref_<telegram_id>`. | | `LEGACY_REFS` | Разрешить ссылки `ref_<telegram_id>`. |
| `DISPOSABLE_EMAIL_DOMAINS` | Домены одноразовой почты через запятую. Для таких email trial и реферальный welcome bonus доступны только после привязки Telegram. |
| `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`. | | `REFERRAL_BONUS_DAYS_1_MONTH`, `REFERRAL_BONUS_DAYS_3_MONTHS`, `REFERRAL_BONUS_DAYS_6_MONTHS`, `REFERRAL_BONUS_DAYS_12_MONTHS` | Legacy-бонусы пригласившему без JSON-каталога. В JSON-тарифах используйте `referral_bonus_days_inviter`. |
| `REFEREE_BONUS_DAYS_1_MONTH`, `REFEREE_BONUS_DAYS_3_MONTHS`, `REFEREE_BONUS_DAYS_6_MONTHS`, `REFEREE_BONUS_DAYS_12_MONTHS` | Legacy-бонусы приглашенному без JSON-каталога. В JSON-тарифах используйте `referral_bonus_days_referee`. | | `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` | Включает напоминания о подписке. |
+8 -4
View File
@@ -9,7 +9,7 @@
- блокировка пользователей, входящий список тикетов поддержки, рассылки, промокоды и просмотр логов; - блокировка пользователей, входящий список тикетов поддержки, рассылки, промокоды и просмотр логов;
- ручная синхронизация с Remnawave; - ручная синхронизация с Remnawave;
- редактор разрешенных настроек приложения из manifest-файла; - редактор разрешенных настроек приложения из manifest-файла;
- раздел **Внешний вид** для логотипа, emoji-логотипа, выбора темы, accent-цвета, масштаба логотипа и предпросмотра тем; - раздел **Внешний вид** для логотипа, выбора темы, accent-цвета, масштаба логотипа и предпросмотра тем;
- раздел **Инструкции подключения** для встроенной страницы установки, поведения кнопок бота и Remnawave Subscription Page config; - раздел **Инструкции подключения** для встроенной страницы установки, поведения кнопок бота и Remnawave Subscription Page config;
- раздел **Бэкапы** для просмотра локальных ZIP-архивов, загрузки архива и восстановления БД/compose-папки; - раздел **Бэкапы** для просмотра локальных ZIP-архивов, загрузки архива и восстановления БД/compose-папки;
- редактор JSON-каталога тарифов; - редактор JSON-каталога тарифов;
@@ -49,7 +49,7 @@
В manifest сейчас входят: В manifest сейчас входят:
- общие параметры: язык, валюта, ссылки поддержки, документы, обязательный канал, Remnawave-доступы и поведение `/start`; - общие параметры: язык, валюта, ссылки поддержки, документы, обязательный канал, Remnawave-доступы и поведение `/start`;
- внешний вид и доступность Web App: название, цвет, логотип, emoji-логотип и `WEBAPP_ENABLED`; - внешний вид и доступность Web App: название, цвет, логотип и `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, а также текст и иконки кнопок оплаты;
@@ -102,9 +102,11 @@
## Внешний вид ## Внешний вид
Раздел **Внешний вид** объединяет настройки бренда и темы Web App. Логотип можно загрузить файлом или по HTTPS-ссылке; backend сохраняет файл в `data/webapp-logo/uploads` и подставляет локальный URL. Если включен emoji-логотип, картинка скрывается, а для emoji можно выбрать системный, Twemoji, Noto Color, animated Noto и другие варианты отрисовки. Раздел **Внешний вид** объединяет настройки бренда и темы Web App. Логотип можно загрузить файлом или по HTTPS-ссылке; backend сохраняет файл в `data/webapp-logo/uploads` и подставляет локальный URL. Если логотип не задан, показывается логотип проекта по умолчанию. Favicon генерируется из логотипа или загружается отдельно.
В блоке тем админка читает каталог из `WEBAPP_THEMES_DIR`, показывает встроенные и кастомные темы, позволяет выбрать текущую тему, изменить accent, включить или выключить тему для админки и настроить масштаб логотипа на главной и экране входа. Кнопка предпросмотра открывает `/home?theme_preview=<key>` и не меняет глобальную тему до сохранения. Этот же бренд используется в HTML-письмах. Загруженный локальный логотип встраивается в письмо как inline image (`cid:webapp-logo`), поэтому email-клиенту не нужен прямой доступ к `/webapp-uploaded-logo/...`. Логотип по публичной HTTPS-ссылке остается внешней картинкой в письме.
В блоке тем админка читает каталог из `WEBAPP_THEMES_DIR`, показывает встроенные и кастомные темы, позволяет выбрать текущую тему, изменить accent, включить или выключить тему для админки и настроить отдельный масштаб логотипа для desktop и mobile layout. Кнопка предпросмотра открывает `/home?theme_preview=<key>` и не меняет глобальную тему до сохранения.
Подробный формат `theme.json`, CSS/asset-роуты и пошаговый пайплайн создания новой темы описаны в [webapp-themes.md](webapp-themes.md). Подробный формат `theme.json`, CSS/asset-роуты и пошаговый пайплайн создания новой темы описаны в [webapp-themes.md](webapp-themes.md).
@@ -122,6 +124,8 @@
- **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-устройств.
Порядок периодов, traffic-пакетов, обычных докупок, premium-докупок и HWID-пакетов меняется перетаскиванием строк в редакторе. Этот порядок сохраняется в JSON и используется на витрине Web App и в Telegram-боте.
Базовые и premium Internal Squads выбираются из Remnawave через `/api/admin/panel/internal-squads`. Если панель недоступна, можно сохранить уже существующие UUID в JSON, но выпадающий список не загрузится. Базовые и premium Internal Squads выбираются из Remnawave через `/api/admin/panel/internal-squads`. Если панель недоступна, можно сохранить уже существующие UUID в JSON, но выпадающий список не загрузится.
## Практические замечания ## Практические замечания
+6
View File
@@ -44,6 +44,12 @@ BRUTE_FORCE_WINDOW_SECONDS=900
BRUTE_FORCE_LOCK_SECONDS=900 BRUTE_FORCE_LOCK_SECONDS=900
``` ```
## Брендинг писем
HTML-письма используют тот же бренд, что и Mini App: название из `WEBAPP_TITLE`, accent из внешнего вида и логотип из раздела **Внешний вид**. Если логотип загружен через админку файлом, backend прикладывает его к письму как inline image (`cid:webapp-logo`), поэтому получателю не нужен доступ к внутреннему `/webapp-uploaded-logo/...`.
Если в качестве логотипа задан публичный `https://` URL, письмо использует его как обычный внешний `<img>`. В этом режиме некоторые почтовые клиенты могут скрыть картинку, пока получатель не разрешит загрузку внешних изображений.
Для Brevo обычно подходит порт `587` с STARTTLS. Если основной порт недоступен, приложение пробует порты из `SMTP_FALLBACK_PORTS`; порт `465` используется через SSL wrapper автоматически. Для Brevo обычно подходит порт `587` с STARTTLS. Если основной порт недоступен, приложение пробует порты из `SMTP_FALLBACK_PORTS`; порт `465` используется через SSL wrapper автоматически.
`SMTP_FROM_EMAIL` должен быть подтвержден у SMTP-провайдера, иначе письмо часто отклоняется или попадает в спам. `SMTP_FROM_NAME` можно оставить пустым, тогда используется название Web App. `SMTP_FROM_EMAIL` должен быть подтвержден у SMTP-провайдера, иначе письмо часто отклоняется или попадает в спам. `SMTP_FROM_NAME` можно оставить пустым, тогда используется название Web App.
+3 -1
View File
@@ -6,6 +6,8 @@ Minishop отправляет уведомления в Telegram и на email.
Для уведомлений жизненного цикла подписки есть отдельный флаг `SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED`. Если он включен, пользовательские уведомления об окончании подписки отправляются в Telegram при наличии привязанного Telegram-аккаунта и на email при наличии привязанной почты. Для уведомлений жизненного цикла подписки есть отдельный флаг `SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED`. Если он включен, пользовательские уведомления об окончании подписки отправляются в Telegram при наличии привязанного Telegram-аккаунта и на email при наличии привязанной почты.
Все HTML-письма используют общий email-шаблон с брендом из Web App: заголовком, accent-цветом и логотипом. Логотип, загруженный через раздел **Внешний вид**, отправляется как inline image, а публичный HTTPS-логотип остается внешней картинкой.
## Сводная таблица ## Сводная таблица
| Событие | Получатель | Telegram | Email | Условия и ограничения | | Событие | Получатель | Telegram | Email | Условия и ограничения |
@@ -16,7 +18,7 @@ Minishop отправляет уведомления в Telegram и на email.
| Успешная покупка отдельного пакета трафика | Пользователь | ✓ | ✓ | Для `traffic` / `traffic_package`; email отправляется, если SMTP настроен и у пользователя есть email. | | Успешная покупка отдельного пакета трафика | Пользователь | ✓ | ✓ | Для `traffic` / `traffic_package`; email отправляется, если SMTP настроен и у пользователя есть email. |
| Успешная докупка обычного трафика к тарифу | Пользователь | ✓ | ✓ | Для `topup`; email отправляется, если SMTP настроен и у пользователя есть email. | | Успешная докупка обычного трафика к тарифу | Пользователь | ✓ | ✓ | Для `topup`; email отправляется, если SMTP настроен и у пользователя есть email. |
| Успешная покупка premium-трафика | Пользователь | ✓ | ✓ | Для `premium_topup`; email отправляется, если SMTP настроен и у пользователя есть email. | | Успешная покупка premium-трафика | Пользователь | ✓ | ✓ | Для `premium_topup`; email отправляется, если SMTP настроен и у пользователя есть email. |
| Успешная покупка HWID-устройств | Пользователь | ✓ | ✓ | Отправляется после оплаты `hwid_devices` или `hwid_devices_renewal`; email отправляется, если SMTP настроен и у пользователя есть email. | | Успешная покупка HWID-устройств | Пользователь | ✓ | ✓ | Отправляется после отдельной оплаты `hwid_devices`; при продлении устройств вместе с подпиской добавляется примечание к уведомлению об успешной оплате подписки. Email отправляется, если SMTP настроен и у пользователя есть email. |
| Платное повышение тарифа | Пользователь | ✓ | ✓ | Для `tariff_upgrade`; email отправляется, если SMTP настроен и у пользователя есть email. | | Платное повышение тарифа | Пользователь | ✓ | ✓ | Для `tariff_upgrade`; email отправляется, если SMTP настроен и у пользователя есть email. |
| Способ оплаты YooKassa привязан | Пользователь | ✓ | ✓ | Отправляется после успешного сохранения платежного метода через webhook YooKassa; email отправляется, если SMTP настроен и у пользователя есть email. | | Способ оплаты YooKassa привязан | Пользователь | ✓ | ✓ | Отправляется после успешного сохранения платежного метода через webhook YooKassa; email отправляется, если SMTP настроен и у пользователя есть email. |
| Ошибка оплаты по webhook провайдера | Пользователь | ✓ | ✓ | Отправляется, когда платежный провайдер сообщает о неуспешном платеже; email отправляется, если SMTP настроен и у пользователя есть email. | | Ошибка оплаты по webhook провайдера | Пользователь | ✓ | ✓ | Отправляется, когда платежный провайдер сообщает о неуспешном платеже; email отправляется, если SMTP настроен и у пользователя есть email. |
+110 -13
View File
@@ -22,6 +22,30 @@
- [Тарифы](tariffs.md) — цены, Telegram Stars и сценарии покупки. - [Тарифы](tariffs.md) — цены, Telegram Stars и сценарии покупки.
- [Логи](../troubleshooting/logs.md) — проверка webhook и создания платежных ссылок. - [Логи](../troubleshooting/logs.md) — проверка webhook и создания платежных ссылок.
## Webhook URL провайдеров
Все платежные webhook URL строятся от `WEBHOOK_BASE_URL` — публичного HTTPS-адреса backend/webhook-домена.
Это должен быть домен, который проксируется на backend-сервер вебхуков (`backend:8080`), а не frontend/Mini App домен из `SUBSCRIPTION_MINI_APP_URL`.
Если `WEBHOOK_BASE_URL=https://bot.example.com`, полный webhook URL получается как `https://bot.example.com` + путь из таблицы.
| Провайдер | URL |
| --- | --- |
| YooKassa | `WEBHOOK_BASE_URL` + `/webhook/yookassa` |
| FreeKassa | `WEBHOOK_BASE_URL` + `/webhook/freekassa` |
| Platega | `WEBHOOK_BASE_URL` + `/webhook/platega` |
| SeverPay | `WEBHOOK_BASE_URL` + `/webhook/severpay` |
| Wata | `WEBHOOK_BASE_URL` + `/webhook/wata` |
| CryptoPay | `WEBHOOK_BASE_URL` + `/webhook/cryptopay` |
| Heleket | `WEBHOOK_BASE_URL` + `/webhook/heleket` |
| PayKilla | `WEBHOOK_BASE_URL` + `/webhook/paykilla` |
| Telegram Stars | `WEBHOOK_BASE_URL` + `/tg/webhook` |
После настройки сделайте тестовый платеж и проверьте, что в логах `backend` виден входящий `POST` на нужный путь.
Если провайдер сообщает, что адрес недоступен, проверьте DNS, HTTPS и reverse proxy для `WEBHOOK_BASE_URL`. Путь должен начинаться с `/webhook/...` без `/api`, `/auth` и frontend-домена.
## YooKassa ## YooKassa
YooKassa используется для рублевых оплат. Провайдер также может участвовать в сценариях автопродления period-подписок. YooKassa используется для рублевых оплат. Провайдер также может участвовать в сценариях автопродления period-подписок.
@@ -29,7 +53,7 @@ YooKassa используется для рублевых оплат. Прова
### Настройка ### Настройка
1. Включите `YOOKASSA_ENABLED`. 1. Включите `YOOKASSA_ENABLED`.
2. Заполните `YOOKASSA_SHOP_ID`, `YOOKASSA_SECRET_KEY`, `YOOKASSA_RETURN_URL`. 2. Заполните `YOOKASSA_SHOP_ID`, `YOOKASSA_SECRET_KEY` и `YOOKASSA_RETURN_URL`.
3. Скопируйте URL вебхука из админ-панели и укажите его в кабинете YooKassa. 3. Скопируйте URL вебхука из админ-панели и укажите его в кабинете YooKassa.
### Справочник ### Справочник
@@ -43,10 +67,10 @@ FreeKassa подключается как отдельный платежный
### Настройка ### Настройка
1. Включите `FREEKASSA_ENABLED`. 1. Включите `FREEKASSA_ENABLED`.
2. Заполните `FREEKASSA_MERCHANT_ID`, `FREEKASSA_FIRST_SECRET`, `FREEKASSA_SECOND_SECRET`, `FREEKASSA_API_KEY`. 2. Заполните `FREEKASSA_MERCHANT_ID`, `FREEKASSA_FIRST_SECRET`, `FREEKASSA_SECOND_SECRET` и `FREEKASSA_API_KEY`.
3. Проверьте настройки подписи. 3. Проверьте настройки подписи.
4. Скопируйте URL вебхука из админ-панели и укажите его в кабинете FreeKassa. 4. Скопируйте URL вебхука из админ-панели и укажите его в кабинете FreeKassa.
5. При необходимости заполните список доверенных IP. 5. При необходимости заполните `FREEKASSA_TRUSTED_IPS`.
### Справочник ### Справочник
@@ -59,11 +83,20 @@ Platega подключается как отдельный платежный п
### Настройка ### Настройка
1. Включите `PLATEGA_ENABLED`. 1. Включите `PLATEGA_ENABLED`.
2. Укажите `PLATEGA_MERCHANT_ID` и `PLATEGA_SECRET`. 2. Укажите `PLATEGA_BASE_URL`, `PLATEGA_MERCHANT_ID` и `PLATEGA_SECRET`.
3. Скопируйте URL вебхука из админ-панели и укажите его в кабинете Platega. 3. Скопируйте URL вебхука из админ-панели и укажите его в кабинете Platega.
4. Проверьте `PLATEGA_RETURN_URL` и `PLATEGA_FAILED_URL`. 4. Проверьте `PLATEGA_RETURN_URL` и `PLATEGA_FAILED_URL`.
5. При необходимости укажите `PLATEGA_PAYMENT_METHOD`. 5. При необходимости укажите `PLATEGA_PAYMENT_METHOD`.
### Дополнительные кнопки
- `PLATEGA_SBP_ENABLED` — отдельная кнопка СБП/карта.
- `PLATEGA_SBP_METHOD` — ID метода для СБП/карты.
- `PLATEGA_CRYPTO_ENABLED` — отдельная crypto-кнопка Platega.
- `PLATEGA_CRYPTO_METHOD` — ID метода для crypto-кнопки.
- `PAYMENT_PLATEGA_SBP_*` — текст и иконка кнопки СБП/карта.
- `PAYMENT_PLATEGA_CRYPTO_*` — текст и иконка crypto-кнопки.
### Справочник ### Справочник
- [Platega](../configuration/env-vars.md#platega) - [Platega](../configuration/env-vars.md#platega)
@@ -75,9 +108,11 @@ SeverPay подключается как отдельный платежный
### Настройка ### Настройка
1. Включите `SEVERPAY_ENABLED`. 1. Включите `SEVERPAY_ENABLED`.
2. Укажите `SEVERPAY_MID`, `SEVERPAY_TOKEN`, `SEVERPAY_BASE_URL` 2. Укажите `SEVERPAY_BASE_URL`.
3. Скопируйте URL вебхука из админ-панели и укажите его в кабинете SeverPay. 3. Заполните `SEVERPAY_MID` и `SEVERPAY_TOKEN`.
4. При необходимости задайте `SEVERPAY_LIFETIME_MINUTES`. 4. Проверьте `SEVERPAY_RETURN_URL`.
5. Скопируйте URL вебхука из админ-панели и укажите его в кабинете SeverPay.
6. При необходимости задайте `SEVERPAY_LIFETIME_MINUTES`.
### Справочник ### Справочник
@@ -116,8 +151,8 @@ CryptoPay используется для криптовалютных плат
2. Укажите `CRYPTOPAY_TOKEN`. 2. Укажите `CRYPTOPAY_TOKEN`.
3. Выберите `CRYPTOPAY_NETWORK`: `mainnet` или `testnet`. 3. Выберите `CRYPTOPAY_NETWORK`: `mainnet` или `testnet`.
4. Задайте `CRYPTOPAY_CURRENCY_TYPE`: `fiat` или `crypto`. 4. Задайте `CRYPTOPAY_CURRENCY_TYPE`: `fiat` или `crypto`.
5. Скопируйте URL вебхука из админ-панели и укажите его в CryptoPay. 5. Проверьте `CRYPTOPAY_ASSET`, например `RUB`, `USDT` или `BTC`.
6. Проверьте `CRYPTOPAY_ASSET`. 6. Скопируйте URL вебхука из админ-панели и укажите его в CryptoPay.
### Проверка ### Проверка
@@ -138,10 +173,12 @@ Heleket используется для крипто-инвойсов с merchan
1. Включите `HELEKET_ENABLED`. 1. Включите `HELEKET_ENABLED`.
2. Укажите `HELEKET_BASE_URL`, `HELEKET_MERCHANT_ID` и `HELEKET_API_KEY`. 2. Укажите `HELEKET_BASE_URL`, `HELEKET_MERCHANT_ID` и `HELEKET_API_KEY`.
3. Настройте `HELEKET_CURRENCY`. 3. Настройте `HELEKET_CURRENCY`.
4. Скопируйте URL вебхука из админ-панели и укажите его в кабинете Heleket. 4. При необходимости задайте `HELEKET_TO_CURRENCY` и `HELEKET_NETWORK`.
5. При необходимости задайте `HELEKET_TO_CURRENCY` и `HELEKET_NETWORK`. 5. Проверьте `HELEKET_RETURN_URL` и `HELEKET_SUCCESS_URL`.
7. При необходимости включите `HELEKET_VERIFY_WEBHOOK_SIGNATURE`. 6. Настройте `HELEKET_LIFETIME_SECONDS`.
8. Для IP-фильтрации заполните `HELEKET_TRUSTED_IPS`. 7. Скопируйте URL вебхука из админ-панели и укажите его в кабинете Heleket.
8. При необходимости включите `HELEKET_VERIFY_WEBHOOK_SIGNATURE`.
9. Для IP-фильтрации заполните `HELEKET_TRUSTED_IPS`.
### Ограничения ### Ограничения
@@ -151,6 +188,64 @@ Heleket используется для крипто-инвойсов с merchan
- [Heleket](../configuration/env-vars.md#heleket) - [Heleket](../configuration/env-vars.md#heleket)
## PayKilla
PayKilla используется для крипто-инвойсов V2 через hosted checkout `https://gopay.paykilla.com/{invoice_id}`.
API-запросы подписываются HMAC-SHA256. Webhook проверяется по заголовку `X-API-SIGN` и raw body.
### Особенности
- PayKilla строго валидирует текстовые поля invoice.
- В `purpose` и `description` Minishop отправляет простой английский текст `<WEBAPP_TITLE> payment <id>`.
- Локализованное описание платежа остается только внутри Minishop.
- ASCII-safe sanitizer допускает ASCII-буквы, цифры, пробелы, `_`, `.`, `,`.
### Валюта invoice
Minishop создает invoice в валюте, которую PayKilla принимает в поле `currency`.
Если валюта тарифа входит в `PAYKILLA_INVOICE_CURRENCIES`, сумма отправляется как есть.
Если валюта тарифа не входит в список, сумма конвертируется в `PAYKILLA_CURRENCY`. По умолчанию рублевые тарифы конвертируются в `USD` через ExchangeRate-API с кэшем `PAYKILLA_EXCHANGE_RATE_CACHE_SECONDS`.
### Payload invoice
Payload создания invoice содержит обязательные поля `type`, `purpose`, `currency`, `totalPrice` и `paymentCurrencies`.
Дополнительно отправляются `clientOrderId`, `description`, `expiredAt`, `userPaysServiceFee` и `userPaysNetworkFee`.
Redirect URLs в PayKilla не отправляются. Завершение платежа обрабатывается через webhook.
### API key
1. В PayKilla Dashboard откройте **Settings -> API keys**.
2. Создайте ключ типа **HMAC**.
3. Для приема оплат включите permission **INVOICE**.
4. Permission **WITHDRAWAL** не нужен для Minishop-платежей.
5. Сохраните `publicKey` в `PAYKILLA_API_KEY`.
6. Сохраните `secretKey` в `PAYKILLA_SECRET_KEY`.
### Webhook
1. В PayKilla Dashboard откройте **Settings -> Webhooks**.
2. Скопируйте URL вебхука из админ-панели и укажите его в PayKilla.
3. Включите минимальные события: `INVOICE_PAID`, `INVOICE_EXPIRED`.
4. Для production также включите `PAYMENT_COMPLETED`, `PAYMENT_FAILED`, `PAYMENT_OVERPAID`, `PAYMENT_UNDERPAID`, `PAYMENT_PARTIAL`, `COMPLIANCE_FAILED`.
5. Оставьте `PAYKILLA_VERIFY_WEBHOOK_SIGNATURE=True`.
### Настройка
1. Включите `PAYKILLA_ENABLED`.
2. Укажите `PAYKILLA_API_KEY` и `PAYKILLA_SECRET_KEY`.
3. Оставьте `PAYKILLA_CURRENCY=USD`, если PayKilla не принимает валюту тарифов как invoice currency.
4. В `PAYKILLA_INVOICE_CURRENCIES` укажите валюты invoice, например `USD,EUR`.
5. В `PAYKILLA_PAYMENT_CURRENCIES` начните с `USDTTRC`.
### Справочник
- [PayKilla](../configuration/env-vars.md#paykilla)
## Telegram Stars ## Telegram Stars
Telegram Stars используются напрямую и поддерживаются в legacy-ценах и JSON-каталоге тарифов. Telegram Stars используются напрямую и поддерживаются в legacy-ценах и JSON-каталоге тарифов.
@@ -171,6 +266,8 @@ Telegram Stars используются напрямую и поддержива
### Ограничения ### Ограничения
- Отдельный платежный webhook не нужен.
- Stars-события приходят через webhook Telegram-бота: `WEBHOOK_BASE_URL` + `/tg/webhook`.
- XTR/Stars-докупки не конвертируются без явно заданного курса. - XTR/Stars-докупки не конвертируются без явно заданного курса.
### Справочник ### Справочник
+65 -58
View File
@@ -28,6 +28,8 @@ JSON-каталог может содержать несколько тариф
- настройка premium-раздела: названия RU/EN, premium Internal Squads, месячный premium-лимит и пакеты докупки premium-трафика в платежной валюте/Stars; - настройка premium-раздела: названия RU/EN, premium Internal Squads, месячный premium-лимит и пакеты докупки premium-трафика в платежной валюте/Stars;
- настройка базового HWID-лимита и пакетов докупки устройств. - настройка базового HWID-лимита и пакетов докупки устройств.
Порядок продаваемых вариантов управляется в админке перетаскиванием строк: это работает для периодов покупки подписки, traffic-пакетов, обычных докупок трафика, premium-докупок и HWID-пакетов. Такой же порядок сохраняется в JSON и затем используется в Web App и Telegram-боте.
После сохранения изменения применяются к новым запросам Web App сразу, потому что конфиг тарифов загружается из JSON при обращении. Уже созданные подписки сохраняют свой `tariff_key`; при удалении или отключении тарифа проверьте, что активные подписки с этим ключом не требуют дальнейшего продления или смены. После сохранения изменения применяются к новым запросам Web App сразу, потому что конфиг тарифов загружается из JSON при обращении. Уже созданные подписки сохраняют свой `tariff_key`; при удалении или отключении тарифа проверьте, что активные подписки с этим ключом не требуют дальнейшего продления или смены.
Подробности по админ-панели, правам доступа, сохранению настроек и списку разделов есть в [админ-панели](admin-panel.md). Подробности по админ-панели, правам доступа, сохранению настроек и списку разделов есть в [админ-панели](admin-panel.md).
@@ -56,16 +58,16 @@ Legacy-поля остаются алиасами: `prices_rub`, `conversion_rat
Платежные провайдеры не принимают произвольный код валюты одинаково. Бот фильтрует способы оплаты и блокирует создание платежа, если текущая валюта каталога не поддерживается провайдером: Платежные провайдеры не принимают произвольный код валюты одинаково. Бот фильтрует способы оплаты и блокирует создание платежа, если текущая валюта каталога не поддерживается провайдером:
| Провайдер | Валюты по умолчанию | | Провайдер | Валюты по умолчанию |
| --- | --- | | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| YooKassa | `RUB` | | YooKassa | `RUB` |
| WATA | `RUB`, `USD`, `EUR` | | WATA | `RUB`, `USD`, `EUR` |
| FreeKassa | `RUB`, `USD`, `EUR`, `UAH`, `KZT` | | FreeKassa | `RUB`, `USD`, `EUR`, `UAH`, `KZT` |
| CryptoPay | fiat: `USD`, `EUR`, `RUB`, `BYN`, `UAH`, `GBP`, `CNY`, `KZT`, `UZS`, `GEL`, `TRY`, `AMD`, `THB`, `INR`, `BRL`, `IDR`, `AZN`, `AED`, `PLN`, `ILS`; crypto: `USDT`, `TON`, `BTC`, `ETH`, `LTC`, `BNB`, `TRX`, `USDC` | | CryptoPay | fiat: `USD`, `EUR`, `RUB`, `BYN`, `UAH`, `GBP`, `CNY`, `KZT`, `UZS`, `GEL`, `TRY`, `AMD`, `THB`, `INR`, `BRL`, `IDR`, `AZN`, `AED`, `PLN`, `ILS`; crypto: `USDT`, `TON`, `BTC`, `ETH`, `LTC`, `BNB`, `TRX`, `USDC` |
| Heleket | настраиваемый список `HELEKET_SUPPORTED_CURRENCIES` | | Heleket | настраиваемый список `HELEKET_SUPPORTED_CURRENCIES` |
| Platega | настраиваемый список `PLATEGA_SUPPORTED_CURRENCIES` | | Platega | настраиваемый список `PLATEGA_SUPPORTED_CURRENCIES` |
| SeverPay | настраиваемый список `SEVERPAY_SUPPORTED_CURRENCIES` | | SeverPay | настраиваемый список `SEVERPAY_SUPPORTED_CURRENCIES` |
| Telegram Stars | `XTR`, отдельные Stars-цены | | Telegram Stars | `XTR`, отдельные Stars-цены |
В админке раздел **Система → Тарифы** показывает текущую платежную валюту и матрицу провайдеров: включен ли метод, настроен ли сервис и будет ли он доступен при выбранной валюте. Для Platega, SeverPay и Heleket список валют нужно держать в соответствии с условиями вашего мерчанта. В админке раздел **Система → Тарифы** показывает текущую платежную валюту и матрицу провайдеров: включен ли метод, настроен ли сервис и будет ли он доступен при выбранной валюте. Для Platega, SeverPay и Heleket список валют нужно держать в соответствии с условиями вашего мерчанта.
@@ -113,43 +115,43 @@ Legacy-поля остаются алиасами: `prices_rub`, `conversion_rat
Основные поля: Основные поля:
| Поле | Назначение | | Поле | Назначение |
| --- | --- | | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `default_tariff` | Тариф по умолчанию для первичного выбора и привязки активных подписок без `tariff_key`. | | `default_tariff` | Тариф по умолчанию для первичного выбора и привязки активных подписок без `tariff_key`. |
| `default_currency` | Валюта цен по умолчанию для JSON-каталога. По умолчанию `rub`; `stars` запрещен, потому что Stars используют отдельные цены. | | `default_currency` | Валюта цен по умолчанию для JSON-каталога. По умолчанию `rub`; `stars` запрещен, потому что Stars используют отдельные цены. |
| `tariffs[].key` | Стабильный ключ тарифа. Используется в платежах, подписках и смене тарифа. | | `tariffs[].key` | Стабильный ключ тарифа. Используется в платежах, подписках и смене тарифа. |
| `tariffs[].names` | Названия тарифа по языкам. | | `tariffs[].names` | Названия тарифа по языкам. |
| `tariffs[].descriptions` | Описания тарифа по языкам. | | `tariffs[].descriptions` | Описания тарифа по языкам. |
| `tariffs[].enabled` | Доступность тарифа на витрине. | | `tariffs[].enabled` | Доступность тарифа на витрине. |
| `tariffs[].squad_uuids` | Internal Squads Remnawave для пользователей тарифа. | | `tariffs[].squad_uuids` | Internal Squads Remnawave для пользователей тарифа. |
| `tariffs[].premium_names` | Название premium-раздела по языкам. Используется в карточке лимита, модалке докупки premium-трафика и предупреждениях. Если поле не задано, используется `Premium-серверы` / `Premium servers`. | | `tariffs[].premium_names` | Название premium-раздела по языкам. Используется в карточке лимита, модалке докупки premium-трафика и предупреждениях. Если поле не задано, используется `Premium-серверы` / `Premium servers`. |
| `tariffs[].premium_squad_uuids` | Internal Squads с отдельным premium-лимитом. Ноды для учета берутся автоматически из accessible nodes этих сквадов через API панели. | | `tariffs[].premium_squad_uuids` | Internal Squads с отдельным premium-лимитом. Ноды для учета берутся автоматически из accessible nodes этих сквадов через API панели. |
| `tariffs[].premium_monthly_gb` | Отдельный месячный лимит трафика по premium-сквадам. `0` или отсутствие поля отключает отдельное ограничение. | | `tariffs[].premium_monthly_gb` | Отдельный месячный лимит трафика по premium-сквадам. `0` или отсутствие поля отключает отдельное ограничение. |
| `tariffs[].premium_topup_packages` | Пакеты докупки premium-трафика в формате `{ "rub": [{ "gb": 10, "price": 99 }], "stars": [...] }`. Требуют `premium_squad_uuids`. | | `tariffs[].premium_topup_packages` | Пакеты докупки premium-трафика в формате `{ "rub": [{ "gb": 10, "price": 99 }], "stars": [...] }`. Требуют `premium_squad_uuids`. Порядок строк задает порядок premium-докупок на витрине и меняется drag&drop в админке. |
| `tariffs[].billing_model` | Модель тарифа: `period` или `traffic`. | | `tariffs[].billing_model` | Модель тарифа: `period` или `traffic`. |
| `tariffs[].hwid_device_limit` | Базовый лимит HWID-устройств. `0` означает безлимит, отсутствие поля использует `USER_HWID_DEVICE_LIMIT`. | | `tariffs[].hwid_device_limit` | Базовый лимит HWID-устройств. `0` означает безлимит, отсутствие поля использует `USER_HWID_DEVICE_LIMIT`. |
| `tariffs[].hwid_device_packages` | Пакеты докупки устройств. `price` — legacy/monthly fallback, `prices` задаёт полную цену пакета для периодов тарифа (`"1"`, `"3"`, `"6"`, `"12"`), `min_price` задаёт минимальную цену prorate-докупки. | | `tariffs[].hwid_device_packages` | Пакеты докупки устройств. `price` — legacy/monthly fallback, `prices` задаёт полную цену пакета для периодов тарифа (`"1"`, `"3"`, `"6"`, `"12"`), `min_price` задаёт минимальную цену prorate-докупки. Порядок строк задает порядок HWID-докупок и меняется drag&drop в админке. |
Для `period`-тарифа также используются: Для `period`-тарифа также используются:
| Поле | Назначение | | Поле | Назначение |
| --- | --- | | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `monthly_gb` | Базовый месячный лимит трафика тарифа. `0` означает безлимит. | | `monthly_gb` | Базовый месячный лимит трафика тарифа. `0` означает безлимит. |
| `prices` | Generic-цены периодов по валютам, например `{ "usd": { "1": 4.99 } }`. | | `prices` | Generic-цены периодов по валютам, например `{ "usd": { "1": 4.99 } }`. |
| `prices_rub` | Legacy-цены периодов в рублях, ключ - количество месяцев. Эквивалент `prices.rub`. | | `prices_rub` | Legacy-цены периодов в рублях, ключ - количество месяцев. Эквивалент `prices.rub`. |
| `prices_stars` | Цены периодов в Telegram Stars. | | `prices_stars` | Цены периодов в Telegram Stars. |
| `referral_bonus_days_inviter` | Бонус пригласившему в днях для каждого периода. Ключ - количество месяцев, как в `enabled_periods`. | | `referral_bonus_days_inviter` | Бонус пригласившему в днях для каждого периода. Ключ - количество месяцев, как в `enabled_periods`. |
| `referral_bonus_days_referee` | Бонус приглашенному в днях для каждого периода. Ключ - количество месяцев, как в `enabled_periods`. | | `referral_bonus_days_referee` | Бонус приглашенному в днях для каждого периода. Ключ - количество месяцев, как в `enabled_periods`. |
| `enabled_periods` | Периоды, доступные для покупки. | | `enabled_periods` | Периоды, доступные для покупки. Порядок элементов в массиве задаёт порядок периодов на витрине (в Telegram-боте и Web App) — отсортируйте их так, как нужно показывать. В веб-админке этот порядок меняется перетаскиванием строк периодов. |
| `topup_packages` | Пакеты докупки трафика именно для этого тарифа. Если поле не задано или списки пустые, докупка для тарифа не показывается в Web App и Telegram-боте. | | `topup_packages` | Пакеты докупки трафика именно для этого тарифа. Если поле не задано или списки пустые, докупка для тарифа не показывается в Web App и Telegram-боте. Порядок строк задает порядок докупок на витрине и меняется drag&drop в админке. |
Для `traffic`-тарифа используются: Для `traffic`-тарифа используются:
| Поле | Назначение | | Поле | Назначение |
| --- | --- | | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `traffic_packages` | Пакеты трафика в GB по валютам каталога и Telegram Stars. | | `traffic_packages` | Пакеты трафика в GB по валютам каталога и Telegram Stars. Порядок пакетов в списке задаёт порядок на витрине (в Telegram-боте и Web App): сначала идут пакеты валюты каталога, затем пакеты, доступные только за Stars. В веб-админке порядок меняется перетаскиванием строк. |
| `conversion_rate_per_gb` | Курс для конвертации оставшихся дней period-тарифа в GB при смене на traffic-тариф в валюте каталога. | | `conversion_rate_per_gb` | Курс для конвертации оставшихся дней period-тарифа в GB при смене на traffic-тариф в валюте каталога. |
| `conversion_rate_rub_per_gb` | Legacy-алиас для рублевых каталогов. | | `conversion_rate_rub_per_gb` | Legacy-алиас для рублевых каталогов. |
Если у traffic-тарифа нет пакетов в `default_currency`, `conversion_rate_per_gb` обязателен. Если у traffic-тарифа нет пакетов в `default_currency`, `conversion_rate_per_gb` обязателен.
@@ -278,7 +280,10 @@ limit_after = current_used + balance_after
- полная цена HWID-пакета берется из `prices[duration_months]`; если периода нет, используется fallback `price * duration_months`; - полная цена HWID-пакета берется из `prices[duration_months]`; если периода нет, используется fallback `price * duration_months`;
- фактическая цена докупки считается пропорционально оплачиваемому окну `valid_from -> valid_until` относительно периода подписки и фиксируется в платежe; - фактическая цена докупки считается пропорционально оплачиваемому окну `valid_from -> valid_until` относительно периода подписки и фиксируется в платежe;
- для Telegram Stars цена округляется вверх до целого Stars, для платежной валюты — вверх до копеек; `min_price` защищает от микроплатежей в конце периода; - для Telegram Stars цена округляется вверх до целого Stars, для платежной валюты — вверх до копеек; `min_price` защищает от микроплатежей в конце периода;
- при продлении подписки докупленные устройства не продлеваются автоматически: старая докупка действует до прежнего `end_date`, а для нового срока создается отдельная `hwid_devices_renewal`-покупка; - кнопка докупки устройств всегда покупает устройства только для текущей активной подписки и только до текущего срока ее действия;
- при продлении подписки пользователь видит отдельный чекбокс продления действующих докупленных устройств; чекбокс включен по умолчанию, цена считается по текущему тарифу и добавляется в тот же платеж подписки;
- если пользователь продлил подписку без продления устройств, старая докупка продолжает действовать до своего `valid_until`, а Web App показывает предупреждение о возможном временном возврате к базовому лимиту;
- админские продления, промокоды и реферальные бонусы добавляют фиксированное количество дней отдельно к подписке и к действующим докупкам устройств, не склеивая даты окончания;
- `traffic`-тарифы не показывают и не принимают докупку HWID-устройств, потому что у них нет срока подписки; - `traffic`-тарифы не показывают и не принимают докупку HWID-устройств, потому что у них нет срока подписки;
- при смене тарифа базовый лимит берется из целевого тарифа, а неиспользованная стоимость HWID-докупок в платежной валюте конвертируется в дни нового period-тарифа или GB traffic-тарифа; XTR/Stars-докупки не конвертируются без явного курса и продолжают жить по своему `valid_until`; - при смене тарифа базовый лимит берется из целевого тарифа, а неиспользованная стоимость HWID-докупок в платежной валюте конвертируется в дни нового period-тарифа или GB traffic-тарифа; XTR/Stars-докупки не конвертируются без явного курса и продолжают жить по своему `valid_until`;
- история докупок пишется в `hwid_device_purchases`; - история докупок пишется в `hwid_device_purchases`;
@@ -292,12 +297,12 @@ limit_after = current_used + balance_after
Варианты расчета: Варианты расчета:
| Переход | Поведение | | Переход | Поведение |
| --- | --- | | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `period -> period` | Остаток оплаченных дней оценивается по legacy-полю `effective_monthly_price_rub`, где хранится месячная цена в платежной валюте каталога, затем пересчитывается в дни целевого тарифа через месячную цену целевого тарифа. Неиспользованная стоимость HWID-докупок в платежной валюте добавляется к этому расчету как дополнительные дни. Количество дней округляется вниз. | | `period -> period` | Остаток оплаченных дней оценивается по legacy-полю `effective_monthly_price_rub`, где хранится месячная цена в платежной валюте каталога, затем пересчитывается в дни целевого тарифа через месячную цену целевого тарифа. Неиспользованная стоимость HWID-докупок в платежной валюте добавляется к этому расчету как дополнительные дни. Количество дней округляется вниз. |
| `period -> period` с доплатой | Если целевой тариф дороже, может быть создан платеж `tariff_upgrade`; неиспользованная стоимость HWID-докупок в платежной валюте уменьшает сумму доплаты. После оплаты применяется целевой тариф, а конвертированные HWID-окна закрываются. | | `period -> period` с доплатой | Если целевой тариф дороже, может быть создан платеж `tariff_upgrade`; неиспользованная стоимость HWID-докупок в платежной валюте уменьшает сумму доплаты. После оплаты применяется целевой тариф, а конвертированные HWID-окна закрываются. |
| `period -> traffic` | Остаток оплаченных дней и неиспользованная стоимость HWID-докупок в платежной валюте конвертируются в GB по `conversion_rate_per_gb` или минимальной цене GB из пакетов целевого тарифа. | | `period -> traffic` | Остаток оплаченных дней и неиспользованная стоимость HWID-докупок в платежной валюте конвертируются в GB по `conversion_rate_per_gb` или минимальной цене GB из пакетов целевого тарифа. |
| `traffic -> period` | Пользователь выбирает и оплачивает период целевого тарифа; остаток GB сохраняется как `topup_balance_bytes` поверх лимита period-тарифа. | | `traffic -> period` | Пользователь выбирает и оплачивает период целевого тарифа; остаток GB сохраняется как `topup_balance_bytes` поверх лимита period-тарифа. |
При смене тарифа бот меняет: При смене тарифа бот меняет:
@@ -313,15 +318,15 @@ limit_after = current_used + balance_after
В платежах используются поля: В платежах используются поля:
| Поле | Назначение | | Поле | Назначение |
| --- | --- | | ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| `sale_mode` | Тип продажи: `subscription`, `traffic_package`, `topup`, `premium_topup`, `tariff_upgrade`, `hwid_devices`. | | `sale_mode` | Тип продажи: `subscription`, `traffic_package`, `topup`, `premium_topup`, `tariff_upgrade`, `hwid_devices`. |
| `tariff_key` | Ключ тарифа, к которому относится платеж. | | `tariff_key` | Ключ тарифа, к которому относится платеж. |
| `purchased_gb` | Купленный объем GB для traffic-пакетов и докупки трафика. | | `purchased_gb` | Купленный объем GB для traffic-пакетов и докупки трафика. |
| `purchased_hwid_devices` | Количество устройств при докупке HWID. | | `purchased_hwid_devices` | Количество устройств при докупке HWID. |
| `hwid_valid_from`, `hwid_valid_until` | Зафиксированное окно действия HWID-докупки на момент создания платежа. | | `hwid_valid_from`, `hwid_valid_until` | Зафиксированное окно действия HWID-докупки на момент создания платежа. |
| `hwid_pricing_period_months`, `hwid_proration_ratio`, `hwid_full_price` | Метаданные расчета цены HWID-докупки: период тарифа, коэффициент prorate и полная цена пакета для периода. | | `hwid_pricing_period_months`, `hwid_proration_ratio`, `hwid_full_price` | Метаданные расчета цены HWID-докупки: период тарифа, коэффициент prorate и полная цена пакета для периода. |
| `subscription_duration_months` | Количество месяцев для подписки на срок; также используется платежными обработчиками как числовое поле покупки. | | `subscription_duration_months` | Количество месяцев для подписки на срок; также используется платежными обработчиками как числовое поле покупки. |
В callback и metadata платежных провайдеров `sale_mode` может передаваться с суффиксом тарифа, например `subscription@standard` или `topup@standard`. При активации платежа тариф сохраняется отдельно в `tariff_key`. В callback и metadata платежных провайдеров `sale_mode` может передаваться с суффиксом тарифа, например `subscription@standard` или `topup@standard`. При активации платежа тариф сохраняется отдельно в `tariff_key`.
@@ -345,12 +350,14 @@ Remnawave ограничивает доступ при достижении `tra
Автопродление через YooKassa применяется к подпискам на срок. Для режима продажи трафика без JSON-каталога автопродление пропускается. Для traffic-тарифов JSON-каталога покупка является пакетом трафика, а не периодической подпиской. Автопродление через YooKassa применяется к подпискам на срок. Для режима продажи трафика без JSON-каталога автопродление пропускается. Для traffic-тарифов JSON-каталога покупка является пакетом трафика, а не периодической подпиской.
Пробный период использует настройки `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`. Переключатель `TRIAL_WITHOUT_TELEGRAM_ENABLED` управляет активацией trial для аккаунтов без Telegram, а домены из `DISPOSABLE_EMAIL_DOMAINS` требуют привязки Telegram независимо от этого переключателя.
Промокоды с бонусными днями применяются к покупке period-подписки. Промокоды с бонусными днями применяются к покупке period-подписки.
Реферальные бонусы за оплату в JSON-каталоге задаются прямо в period-тарифе рядом с ценами периода: `referral_bonus_days_inviter` для пригласившего и `referral_bonus_days_referee` для приглашенного. Ключи этих словарей - месяцы периода (`"1"`, `"3"`, `"6"`, `"12"` или любые другие периоды тарифа, например `"2"`, `"4"`, `"8"`, `"16"`). Для `traffic`-тарифов такие бонусы не применяются. Реферальные бонусы за оплату в JSON-каталоге задаются прямо в period-тарифе рядом с ценами периода: `referral_bonus_days_inviter` для пригласившего и `referral_bonus_days_referee` для приглашенного. Ключи этих словарей - месяцы периода (`"1"`, `"3"`, `"6"`, `"12"` или любые другие периоды тарифа, например `"2"`, `"4"`, `"8"`, `"16"`). Для `traffic`-тарифов такие бонусы не применяются.
Приветственный бонус приглашённому (`REFERRAL_WELCOME_BONUS_DAYS`) настраивается в отдельном блоке **Реферальная программа** на странице тарифов. `REFERRAL_WELCOME_BONUS_WITHOUT_TELEGRAM_ENABLED` разрешает или запрещает выдачу этого бонуса аккаунтам без Telegram; disposable email домены из `DISPOSABLE_EMAIL_DOMAINS` всегда требуют Telegram перед начислением.
Если приглашенный покупает один тариф, а пригласивший находится на другом, размер бонуса берется из тарифа и периода, который купил приглашенный. При этом подписка пригласившего только продлевается на бонусные дни: лимиты, Internal Squads и другие параметры его текущего тарифа не пересчитываются под тариф приглашенного. Если приглашенный покупает один тариф, а пригласивший находится на другом, размер бонуса берется из тарифа и периода, который купил приглашенный. При этом подписка пригласившего только продлевается на бонусные дни: лимиты, Internal Squads и другие параметры его текущего тарифа не пересчитываются под тариф приглашенного.
В Web App и Telegram-меню подробные строки по периодам показываются только для legacy-режима или когда активен один period-тариф. Если включено несколько period-тарифов, Web App показывает сообщение, что бонус зависит от тарифа и периода оплаты друга, затем список тарифов с диапазонами "от N до N дней" и раскрытием подробностей по иконке вопроса. Telegram-меню в этом случае показывает только диапазоны по каждому тарифу. В Web App и Telegram-меню подробные строки по периодам показываются только для legacy-режима или когда активен один period-тариф. Если включено несколько period-тарифов, Web App показывает сообщение, что бонус зависит от тарифа и периода оплаты друга, затем список тарифов с диапазонами "от N до N дней" и раскрытием подробностей по иконке вопроса. Telegram-меню в этом случае показывает только диапазоны по каждому тарифу.
+1 -1
View File
@@ -67,7 +67,7 @@ SUPPORT_TICKET_RATE_LIMIT_PER_HOUR=5
Если `WEBAPP_ENABLED=False`, пользовательское веб-приложение и админ-панель не регистрируются. Чтобы снова попасть в админку, включите `WEBAPP_ENABLED=True` в `.env` и перезапустите backend/frontend контейнеры. Если `WEBAPP_ENABLED=False`, пользовательское веб-приложение и админ-панель не регистрируются. Чтобы снова попасть в админку, включите `WEBAPP_ENABLED=True` в `.env` и перезапустите backend/frontend контейнеры.
Внешний вид настраивается в админке: раздел **Внешний вид** управляет логотипом, emoji-логотипом, accent-цветом, выбранной темой и масштабом логотипа. Кастомные темы читаются из `WEBAPP_THEMES_DIR`, а `WEBAPP_DEFAULT_THEME` может принудительно выбрать тему по ключу. Подробный контракт `theme.json`, CSS/asset-роуты и пайплайн создания темы описаны в [webapp-themes.md](webapp-themes.md). Внешний вид настраивается в админке: раздел **Внешний вид** управляет логотипом, favicon, accent-цветом, выбранной темой и отдельным масштабом логотипа для desktop/mobile layout. Кастомные темы читаются из `WEBAPP_THEMES_DIR`, а `WEBAPP_DEFAULT_THEME` может принудительно выбрать тему по ключу. Подробный контракт `theme.json`, CSS/asset-роуты и пайплайн создания темы описаны в [webapp-themes.md](webapp-themes.md).
## Авторизация ## Авторизация
+50 -46
View File
@@ -13,7 +13,6 @@ Web App поддерживает файловые темы, предпросмо
- включить или выключить применение темы в админ-панели; - включить или выключить применение темы в админ-панели;
- настроить масштаб логотипа на главной и экране входа; - настроить масштаб логотипа на главной и экране входа;
- загрузить логотип файлом или по HTTPS-ссылке; - загрузить логотип файлом или по HTTPS-ссылке;
- включить emoji-логотип и выбрать способ его отрисовки;
- открыть предпросмотр темы через `/home?theme_preview=<key>`. - открыть предпросмотр темы через `/home?theme_preview=<key>`.
Через файлы темы можно менять намного больше: Через файлы темы можно менять намного больше:
@@ -51,7 +50,9 @@ WEBAPP_DEFAULT_THEME=
В compose-примерах `data/themes` - это локальная папка рядом с выбранным `docker-compose.yml`; она монтируется в контейнер как `/app/data/themes`. Правки в `backend/bot/app/web/themes` попадают в прод только при сборке собственного образа; опубликованный образ их не видит. В 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` больше не являются рабочим способом первичной настройки через `.env`. Эти значения редактируются в админке и сохраняются как overrides в базе. Тема при этом может использовать сохраненный primary color как fallback accent.
Email-шаблоны берут тот же бренд из настроек внешнего вида. Загруженный логотип добавляется в письма как inline image (`cid:webapp-logo`), а публичный HTTPS-логотип остается внешней картинкой, которую почтовый клиент может скрыть до разрешения загрузки изображений.
## Контракт `theme.json` ## Контракт `theme.json`
@@ -114,7 +115,8 @@ WEBAPP_DEFAULT_THEME=
"font_sans": "Inter, system-ui, sans-serif", "font_sans": "Inter, system-ui, sans-serif",
"font_logo": "Inter, system-ui, sans-serif", "font_logo": "Inter, system-ui, sans-serif",
"font_mono": "\"JetBrains Mono\", \"Fira Code\", monospace", "font_mono": "\"JetBrains Mono\", \"Fira Code\", monospace",
"home_logo_scale": 120, "home_logo_scale_desktop": 120,
"home_logo_scale_mobile": 95,
"admin_bg": "#05040a", "admin_bg": "#05040a",
"admin_surface": "#11101c", "admin_surface": "#11101c",
"admin_surface_2": "#090815", "admin_surface_2": "#090815",
@@ -130,52 +132,54 @@ WEBAPP_DEFAULT_THEME=
Поля верхнего уровня: Поля верхнего уровня:
| Поле | Назначение | | Поле | Назначение |
| --- | --- | | -------------------- | ------------------------------------------------------------------------------------------------------- |
| `key` | Уникальный ключ темы, 1-64 символа: латиница, цифры, `_` и `-`. Если ключ не указан, берется имя папки. | | `key` | Уникальный ключ темы, 1-64 символа: латиница, цифры, `_` и `-`. Если ключ не указан, берется имя папки. |
| `names` | Локализованные названия, например `ru` и `en`. | | `names` | Локализованные названия, например `ru` и `en`. |
| `enabled` | Показывать тему пользователям. Отключенная тема не попадает в публичный каталог. | | `enabled` | Показывать тему пользователям. Отключенная тема не попадает в публичный каталог. |
| `default` | Делает тему выбранной по умолчанию, если `WEBAPP_DEFAULT_THEME` не задан. | | `default` | Делает тему выбранной по умолчанию, если `WEBAPP_DEFAULT_THEME` не задан. |
| `use_primary_accent` | Если `true`, тема может получить accent из настройки внешнего вида, когда в `tokens.accent` ничего нет. | | `use_primary_accent` | Если `true`, тема может получить accent из настройки внешнего вида, когда в `tokens.accent` ничего нет. |
| `use_in_admin` | Если `false`, пользовательская часть использует тему, но админка откатывается на `dark`. | | `use_in_admin` | Если `false`, пользовательская часть использует тему, но админка откатывается на `dark`. |
| `css_file` | CSS-файл внутри папки темы. Может быть `style.css` или вложенный путь вроде `css/theme.css`. | | `css_file` | CSS-файл внутри папки темы. Может быть `style.css` или вложенный путь вроде `css/theme.css`. |
| `assets_version` | Версия ассетов. Для встроенных тем используется для обновления старых файлов в `data/themes`. | | `assets_version` | Версия ассетов. Для встроенных тем используется для обновления старых файлов в `data/themes`. |
| `tokens` | Дизайн-токены, которые превращаются в CSS-переменные на `.app-shell`. | | `tokens` | Дизайн-токены, которые превращаются в CSS-переменные на `.app-shell`. |
## Токены ## Токены
Поддерживаемые токены: Поддерживаемые токены:
| Токен | CSS-переменная | Что меняет | | Токен | CSS-переменная | Что меняет |
| --- | --- | --- | | ------------------------- | --------------------------- | ---------------------------------------------------------------------------------------------------------- |
| `color_scheme` | `color-scheme` | Нативная светлая/темная схема браузера: `dark` или `light`. | | `color_scheme` | `color-scheme` | Нативная светлая/темная схема браузера: `dark` или `light`. |
| `style_preset` | CSS-класс пресета | Сейчас `win95`/`windows95` добавляет `theme-preset-win95`; остальные значения не дают специального класса. | | `style_preset` | CSS-класс пресета | Сейчас `win95`/`windows95` добавляет `theme-preset-win95`; остальные значения не дают специального класса. |
| `accent` | `--accent` | Главный акцент: активные элементы, кнопки, прогресс, фокус. Только hex `#RGB` или `#RRGGBB`. | | `accent` | `--accent` | Главный акцент: активные элементы, кнопки, прогресс, фокус. Только hex `#RGB` или `#RRGGBB`. |
| `bg` | `--bg` | Основной фон приложения. | | `bg` | `--bg` | Основной фон приложения. |
| `panel` | `--panel` | Основные карточки и поверхности. | | `panel` | `--panel` | Основные карточки и поверхности. |
| `panel_2` | `--panel-2` | Вторичные поверхности. | | `panel_2` | `--panel-2` | Вторичные поверхности. |
| `panel_3` | `--panel-3` | Поверхности повышенной вложенности, dropdown/popover. | | `panel_3` | `--panel-3` | Поверхности повышенной вложенности, dropdown/popover. |
| `border` | `--border` | Обычные границы. | | `border` | `--border` | Обычные границы. |
| `border_strong` | `--border-strong` | Усиленные границы и hover-состояния. | | `border_strong` | `--border-strong` | Усиленные границы и hover-состояния. |
| `text` | `--text` | Основной текст. | | `text` | `--text` | Основной текст. |
| `muted` | `--muted` | Вторичный текст. | | `muted` | `--muted` | Вторичный текст. |
| `dim` | `--dim` | Еще более тихий текст и служебные подписи. | | `dim` | `--dim` | Еще более тихий текст и служебные подписи. |
| `danger` | `--danger` | Ошибки и опасные действия. | | `danger` | `--danger` | Ошибки и опасные действия. |
| `blue` | `--blue` | Синий вспомогательный цвет. | | `blue` | `--blue` | Синий вспомогательный цвет. |
| `radius` | `--radius` | Базовый радиус карточек, кнопок и контролов. | | `radius` | `--radius` | Базовый радиус карточек, кнопок и контролов. |
| `font_sans` | `--font-sans` | Основной шрифт интерфейса. | | `font_sans` | `--font-sans` | Основной шрифт интерфейса. |
| `font_logo` | `--font-logo` | Шрифт бренда и заголовка. | | `font_logo` | `--font-logo` | Шрифт бренда и заголовка. |
| `font_mono` | `--font-mono` | Моноширинный шрифт. | | `font_mono` | `--font-mono` | Моноширинный шрифт. |
| `home_logo_scale` | `--home-logo-scale` | Масштаб логотипа на главной и входе, от `50` до `300` процентов. | | `home_logo_scale_desktop` | `--home-logo-scale-desktop` | Масштаб логотипа на desktop layout, от `50` до `300` процентов. |
| `admin_bg` | `--admin-bg` | Фон админ-панели. | | `home_logo_scale_mobile` | `--home-logo-scale-mobile` | Масштаб логотипа на mobile layout, от `50` до `300` процентов. |
| `admin_surface` | `--admin-surface` | Основные карточки админки. | | `home_logo_scale` | `--home-logo-scale` | Legacy fallback для старых тем; используется, если desktop/mobile token не задан. |
| `admin_surface_2` | `--admin-surface-2` | Вторичные поверхности админки. | | `admin_bg` | `--admin-bg` | Фон админ-панели. |
| `admin_elev` | `--admin-elev` | Elevated-поверхности админки. | | `admin_surface` | `--admin-surface` | Основные карточки админки. |
| `admin_border` | `--admin-border` | Границы админки. | | `admin_surface_2` | `--admin-surface-2` | Вторичные поверхности админки. |
| `admin_border_strong` | `--admin-border-strong` | Усиленные границы админки. | | `admin_elev` | `--admin-elev` | Elevated-поверхности админки. |
| `admin_text` | `--admin-text` | Основной текст админки. | | `admin_border` | `--admin-border` | Границы админки. |
| `admin_muted` | `--admin-muted` | Вторичный текст админки. | | `admin_border_strong` | `--admin-border-strong` | Усиленные границы админки. |
| `admin_dim` | `--admin-dim` | Тихие подписи админки. | | `admin_text` | `--admin-text` | Основной текст админки. |
| `admin_muted` | `--admin-muted` | Вторичный текст админки. |
| `admin_dim` | `--admin-dim` | Тихие подписи админки. |
Если `css_file` не задан, интерфейс полностью строится на токенах и общих стилях. Если `css_file` задан, токены все равно применяются первыми, а CSS темы может уточнить или полностью переопределить внешний вид. Если `css_file` не задан, интерфейс полностью строится на токенах и общих стилях. Если `css_file` задан, токены все равно применяются первыми, а CSS темы может уточнить или полностью переопределить внешний вид.
@@ -254,7 +258,8 @@ CSS можно писать для пользовательской части
content: ""; content: "";
width: 16px; width: 16px;
height: 16px; height: 16px;
background: url("/webapp-theme-assets/neon/icons/spark.png") center / contain no-repeat; background: url("/webapp-theme-assets/neon/icons/spark.png") center / contain
no-repeat;
} }
``` ```
@@ -302,7 +307,7 @@ CSS можно писать для пользовательской части
7. Подберите accent и масштаб логотипа. 7. Подберите accent и масштаб логотипа.
В админке можно менять accent и `home_logo_scale` без ручного редактирования JSON. При сохранении backend перепишет `theme.json` в `WEBAPP_THEMES_DIR`, выставит ровно один `default` и сбросит кеш публичных настроек. В админке можно менять accent, `home_logo_scale_desktop` и `home_logo_scale_mobile` без ручного редактирования JSON. При сохранении backend перепишет `theme.json` в `WEBAPP_THEMES_DIR`, выставит ровно один `default` и сбросит кеш публичных настроек. Старый `home_logo_scale` сохраняется как fallback для уже существующих тем.
8. Добавьте `style.css`, если токенов мало. 8. Добавьте `style.css`, если токенов мало.
@@ -329,7 +334,6 @@ CSS можно писать для пользовательской части
11. Сделайте тему дефолтной. 11. Сделайте тему дефолтной.
Есть два способа: Есть два способа:
- в админке выбрать тему и сохранить; - в админке выбрать тему и сохранить;
- указать `WEBAPP_DEFAULT_THEME=neon` в `.env`, если нужен жесткий override на уровне окружения. - указать `WEBAPP_DEFAULT_THEME=neon` в `.env`, если нужен жесткий override на уровне окружения.
+1 -1
View File
@@ -87,7 +87,7 @@ openssl rand -hex 32
Перед первым запуском создайте каталоги и отдайте их пользователю контейнера: Перед первым запуском создайте каталоги и отдайте их пользователю контейнера:
```bash ```bash
mkdir -p data/themes data/webapp-logo data/webapp-emoji data/tariffs mkdir -p data/themes data/webapp-logo data/tariffs
touch data/locales-overrides.json touch data/locales-overrides.json
chown -R 10001:10001 data chown -R 10001:10001 data
chmod -R u+rwX data chmod -R u+rwX data
+2
View File
@@ -11,6 +11,8 @@
- [Админка: пользователи](/demo/admin/users) - [Админка: пользователи](/demo/admin/users)
- [Админка: бэкапы](/demo/admin/backups) - [Админка: бэкапы](/demo/admin/backups)
- [Пробный период](/demo/home?mock=trial) - [Пробный период](/demo/home?mock=trial)
- [Email-only: нужен Telegram для триала](/demo/home?mock=trial-telegram)
- [Email-only: нужен Telegram для реферального бонуса](/demo/home?mock=referral-telegram)
- [Докупка устройств](/demo/devices?mock=devices) - [Докупка устройств](/demo/devices?mock=devices)
- [Запуск бота для Telegram-уведомлений](/demo/home?mock=notifications) - [Запуск бота для Telegram-уведомлений](/demo/home?mock=notifications)
- [Вход и регистрация](/demo/login?mock=auth) - [Вход и регистрация](/demo/login?mock=auth)
+39 -1
View File
@@ -13,6 +13,44 @@ docker compose ps
docker compose logs -f backend worker frontend docker compose logs -f backend worker frontend
``` ```
## Интерактивный install wizard
Для нового сервера скачайте install-скрипт и запустите его:
```bash
curl -fsSL https://raw.githubusercontent.com/3252a8/remnawave-minishop/main/scripts/install.sh -o install.sh
sh install.sh
```
Wizard работает через меню с цифрами и подтверждениями `y/n`. Он умеет:
- скачать выбранный compose-профиль (`Caddy`, `Nginx`, `Pangolin/Newt` или `no-proxy`);
- сгенерировать минимальный `.env`, включая пароли и стабильные secrets;
- сохранить backup существующих файлов перед перезаписью;
- подготовить writable `data/` для файлов приложения;
- запустить `docker compose pull && docker compose up -d`;
- проверить текущий стек через `docker compose ps` и логи `migrate`;
- запустить миграцию из поддерживаемых ботов: Remnashop и старый
`remnawave-tg-shop`;
Для тестирования другой ветки или форка задайте источник перед запуском:
```bash
MINISHOP_INSTALL_REPO=3252a8/remnawave-minishop \
MINISHOP_INSTALL_REF=main \
sh install.sh
```
Миграция Remnashop в wizard сначала запускает `dry-run`, показывает JSON-сводку
и только после отдельного подтверждения применяет изменения в целевую БД. Если
указать старый Remnashop `.env`, wizard передаст importer-у `APP_CRYPT_KEY`,
Remnawave API settings и поддерживаемые payment provider settings из таблицы
`payment_gateways`. После применения wizard печатает новые webhook URL для
Remnawave Panel и платежных провайдеров.
Миграция со старого `remnawave-tg-shop` работает как upgrade совместимой БД:
либо копирует старый Docker volume, либо делает `pg_dump` по source DSN,
восстанавливает дамп в целевую compose-БД и запускает сервис `migrate`.
Обычный `docker compose up -d --build` поднимает: Обычный `docker compose up -d --build` поднимает:
- `postgres` и `redis` с проверками здоровья; - `postgres` и `redis` с проверками здоровья;
@@ -339,7 +377,7 @@ distributed lock; код подготовлен к нескольким репл
Перед первым запуском на сервере заранее дайте права пользователю контейнера `10001`: Перед первым запуском на сервере заранее дайте права пользователю контейнера `10001`:
```bash ```bash
mkdir -p data/themes data/webapp-logo data/webapp-emoji data/tariffs mkdir -p data/themes data/webapp-logo data/tariffs
touch data/locales-overrides.json touch data/locales-overrides.json
chown -R 10001:10001 data chown -R 10001:10001 data
chmod -R u+rwX data chmod -R u+rwX data
+1
View File
@@ -5,3 +5,4 @@
| Источник | Поддерживаемый случай | Документы | | Источник | Поддерживаемый случай | Документы |
| --- | --- | --- | | --- | --- | --- |
| [remnawave-tg-shop](https://github.com/kavore/remnawave-tg-shop/) | Полный перенос всех данных | [Инструкция](remnawave-tg-shop.md) | | [remnawave-tg-shop](https://github.com/kavore/remnawave-tg-shop/) | Полный перенос всех данных | [Инструкция](remnawave-tg-shop.md) |
| [Remnashop](https://github.com/snoups/remnashop/) | Автоматический импорт пользователей, подписок, платежей, рефералов, промокодов и поддерживаемых платежных настроек | [Инструкция](remnashop.md) |
+124
View File
@@ -0,0 +1,124 @@
# Миграция из Remnashop
Remnashop импортируется через общий скрипт импорта `backend/scripts/import_legacy.py`.
Самый удобный путь - интерактивный install wizard:
```bash
curl -fsSL https://raw.githubusercontent.com/3252a8/remnawave-minishop/main/scripts/install.sh -o install.sh
sh install.sh
```
В меню выберите `Install new stack and run migration` для нового сервера
или `Run migration only`, если compose-папка и `.env` уже готовы.
## Что переносится
- пользователи Telegram, username, email, Remnawave UUID и метаданные профиля;
- старые referral codes и связи рефералов;
- подписки, сроки, лимиты трафика, HWID/device limit и UUID подписок панели;
- платежи и статусы платежей;
- промокоды на дни подписки и их активации, если таблицы есть в source DB;
- служебные mappings, чтобы повторный запуск мог работать в режиме `merge`;
- настройки совместимости Remnashop в админке: старые ref-ссылки и promo codes.
Данные, которые не имеют прямого аналога, сохраняются в служебных таблицах миграции или
message logs как заметки, чтобы администратор мог проверить их после переноса.
## Настройки и платежные провайдеры
Если указать старый Remnashop `.env`, importer дополнительно переносит часть
настроек в админские overrides:
- `REMNAWAVE_HOST` -> `PANEL_API_URL`;
- `REMNAWAVE_TOKEN` -> `PANEL_API_KEY`;
- `REMNAWAVE_WEBHOOK_SECRET` -> `PANEL_WEBHOOK_SECRET`;
- `BOT_SUPPORT_USERNAME` -> `SUPPORT_LINK`;
- `APP_DEFAULT_LOCALE` -> `DEFAULT_LANGUAGE`.
`BOT_MINI_APP` из Remnashop не переносится автоматически. В Remnashop эта
переменная управляет кнопкой подключения к subscription page или внешнему Mini
App, а не веб-кабинетом Remnashop. В Minishop `SUBSCRIPTION_MINI_APP_URL`
должен указывать на текущий frontend/Mini App этого стека; wizard настраивает
его из `WEBHOOK_HOST`/`MINIAPP_HOST` или `MINIAPP_PUBLIC_URL`.
Значения-заглушки вроде `change_me` importer пропускает, чтобы случайно не
записать шаблонные секреты в рабочую конфигурацию.
Платежные провайдеры берутся из таблицы Remnashop `payment_gateways`.
Поддерживаются и автоматически маппятся: Telegram Stars, YooKassa, WATA,
CryptoPay, Heleket, PayKilla, FreeKassa и Platega. Для них importer переносит флаги
включения, API-ключи/merchant IDs и прямые технические параметры, без которых
провайдер не сможет работать: YooKassa receipt email/VAT, FreeKassa second
secret/payment method/server IP и Platega payment method.
Provider currency и supported-currency ограничения не переносятся автоматически:
в Minishop валюта платежа управляется тарифами и `DEFAULT_CURRENCY_SYMBOL`.
Если старый gateway Remnashop был настроен на нестандартную валюту, importer
оставит предупреждение в JSON-сводке; проверьте `CRYPTOPAY_ASSET`,
`HELEKET_CURRENCY`, `HELEKET_SUPPORTED_CURRENCIES`, `PAYKILLA_CURRENCY`,
`PAYKILLA_PAYMENT_CURRENCIES` или
`PLATEGA_SUPPORTED_CURRENCIES` вручную.
Провайдеры YooMoney, Cryptomus, MulenPay, PayMaster, RoboKassa и UrlPay сейчас
не имеют прямого аналога в Minishop. Если они были в Remnashop, importer
оставит предупреждение в JSON-сводке и notes миграции, а настроить их нужно
вручную или через будущий отдельный provider.
Remnashop может хранить секреты в формате `enc_...`. Для расшифровки нужен
старый `APP_CRYPT_KEY`; проще всего указать путь к старому `.env` в wizard или
передать `--source-env-file`. Если ключ не передан или неверный, зашифрованные
значения будут пропущены с предупреждением, остальные данные продолжат
импортироваться.
После успешного применения wizard печатает список новых адресов webhook. Их
нужно указать во внешних сервисах вместо старых Remnashop URL:
- Remnawave Panel -> `WEBHOOK_URL`: `WEBHOOK_BASE_URL` + `/webhook/panel`;
- YooKassa HTTP notifications URL: `WEBHOOK_BASE_URL` + `/webhook/yookassa`;
- WATA webhook/callback URL: `WEBHOOK_BASE_URL` + `/webhook/wata`;
- CryptoBot/Crypto Pay webhook URL: `WEBHOOK_BASE_URL` + `/webhook/cryptopay`;
- Heleket payment webhook/callback URL: `WEBHOOK_BASE_URL` + `/webhook/heleket`;
- PayKilla webhook URL: `WEBHOOK_BASE_URL` + `/webhook/paykilla`;
- FreeKassa notification/result URL: `WEBHOOK_BASE_URL` + `/webhook/freekassa`;
- Platega webhook URL: `WEBHOOK_BASE_URL` + `/webhook/platega`;
- Telegram webhook `WEBHOOK_BASE_URL` + `/tg/webhook` выставляется ботом
автоматически при старте.
## Flow wizard
1. Wizard скачивает compose-профиль и `backend/scripts/import_legacy.py` через
`raw.githubusercontent.com`, без клонирования репозитория.
2. Вы указываете source PostgreSQL DSN Remnashop и schema, обычно `public`.
3. Опционально указываете путь к старому Remnashop `.env` для `APP_CRYPT_KEY`,
Remnawave API settings и переносимых settings.
4. Вы выбираете целевую БД: текущую compose-БД или ручной target DSN.
5. При необходимости указываете JSON map тарифов Remnashop в локальные
`tariff_key`, например `{"basic": "standard_month"}`.
6. Wizard запускает `dry-run` и показывает JSON-сводку.
7. После подтверждения `y` importer применяет изменения, печатает список новых
webhook URL для Remnawave Panel и платежных провайдеров, затем перезапускает
`backend`/`worker`, чтобы настройки совместимости перечитались.
Если source DB находится на том же Docker host, помните, что DSN выполняется
из backend-контейнера. Для подключения к сервису вне compose-сети может
понадобиться host name вроде `host.docker.internal`, внешний адрес сервера или
ручное подключение контейнеров к общей Docker network.
## Ручной запуск
Если нужно запустить importer без wizard:
```bash
docker compose run --rm backend \
python backend/scripts/import_legacy.py \
--source-type remnashop \
--source-dsn 'postgresql://old_user:old_password@old_host:5432/remnashop' \
--source-schema public \
--source-env-file /path/to/remnashop/.env \
--dry-run
```
После успешного `dry-run` повторите команду без `--dry-run`. По умолчанию
режим конфликтов `merge`: существующие пользователи и платежи сопоставляются,
а новые записи добавляются. Для узкого импорта используйте `--only`, например
`--only users,referrals,promocodes`.
+99 -331
View File
@@ -1,90 +1,60 @@
# Миграция с `remnawave-tg-shop` (≤ v2.7.0) на `remnawave-minishop` (v3.4+) # Миграция с `remnawave-tg-shop` на `remnawave-minishop`
Эта страница - готовый сценарий для legacy-стека `remnawave-tg-shop`. Это единственная миграция с другого бота, которая сейчас описана в документации. Для других Telegram-ботов, самописных панелей и ручных таблиц готового сценария пока нет: их нельзя переносить по этой инструкции без отдельного анализа схемы БД, тарифов, платежей и связи с Remnawave Panel. Для переноса со старого родственного стека используйте общий install wizard:
Автоматический скрипт ниже рассчитан именно на родственный стек `remnawave-tg-shop`, где структура БД и Docker volumes известны заранее. Для других ботов нужен отдельный адаптер экспорта/импорта.
## Короткий путь без смены ветки и сборки
Если вы используете только готовые Docker-образы и не собираете проект
локально, git-команды из ручного способа не нужны. Достаточно обновить
compose-файл до одного из готовых примеров в `deploy/examples` и
перенести/обновить БД. Самый прямой вариант без встроенного обратного прокси -
`deploy/examples/no-proxy/docker-compose.yml`; для Caddy, Nginx и Newt есть
такие же самостоятельные папки.
Минимальная последовательность:
```bash ```bash
docker compose down curl -fsSL https://raw.githubusercontent.com/3252a8/remnawave-minishop/main/scripts/install.sh -o install.sh
sh install.sh
# Скопируйте старый .env в выбранную папку примера и обновите значения там.
cp .env deploy/examples/no-proxy/.env
nano deploy/examples/no-proxy/.env
# Подготовьте стек из готовых образов.
IMAGE_TAG=3.4.0 docker compose \
--env-file deploy/examples/no-proxy/.env \
-f deploy/examples/no-proxy/docker-compose.yml \
up --no-start
# Нужно только при переходе со старого имени volume remnawave-tg-shop-db-data.
# Если у вас уже есть remnawave-minishop-db-data, этот шаг пропустите.
docker run --rm \
-v remnawave-tg-shop-db-data:/from:ro \
-v remnawave-minishop-db-data:/to \
alpine sh -c "cd /from && cp -a . /to"
IMAGE_TAG=3.4.0 docker compose \
--env-file deploy/examples/no-proxy/.env \
-f deploy/examples/no-proxy/docker-compose.yml \
up -d
docker compose \
--env-file deploy/examples/no-proxy/.env \
-f deploy/examples/no-proxy/docker-compose.yml \
logs migrate
``` ```
Сервис `migrate` сам применит недостающие схемные миграции к перенесённому В меню выберите `Install new stack and run migration` для нового
тому PostgreSQL. Новые тома `remnawave-minishop-redis-data` и сервера или `Run migration only`, если compose-папка уже готова. Затем
`remnawave-minishop-shop-data` переносить не нужно: они создаются пустыми. выберите источник `Old remnawave-tg-shop`.
Этот документ описывает обновление стека, поднятого по `remnawave-tg-shop` Wizard поддерживает два способа переноса:
(включая последний релиз `v2.7.0` форка `kavore/remnawave-tg-shop`), до
текущей версии `remnawave-minishop` (v3.4+). Между этими версиями произошли
две независимые перетряски, и скрипт пытается отработать обе одной командой:
1. **Переименование стека** (v3.1.0): контейнеры и тома `remnawave-tg-shop-*` - `Copy old Docker volumes` - для старого compose-стека на том же Docker host.
стали `remnawave-minishop-*`. Простой `docker compose up -d` после Скрипт подготавливает новый stack, копирует
`git pull` создаёт пустую БД — без переноса тома данные теряются. `remnawave-tg-shop-db-data` в `remnawave-minishop-db-data`, опционально
2. **Разделение бота на сервисы** (v3.4.0): из одного контейнера выделены переносит Caddy volumes и запускает новый stack.
`backend`, `worker`, `frontend`, `migrate` + новые `postgres`, `redis`. - `Dump from a source PostgreSQL DSN` - для старой БД, доступной по DSN.
Появились новые volumes `redis-data` и `shop-data`, новые обязательные Скрипт поднимает целевой `postgres`, сбрасывает целевую БД, делает
переменные окружения, а схема БД обновляется автоматически one-shot `pg_dump` из старой БД, восстанавливает дамп в compose-БД и запускает
сервисом `migrate`. сервис `migrate`.
После миграции `docker compose ps` должен показать как минимум: `backend`, В обоих режимах старые volumes и старая БД не удаляются автоматически.
`worker`, `frontend`, `postgres`, `redis` (running) и `migrate` (exited 0).
Логи: `docker compose logs -f backend worker frontend`.
Доступные пути: ## Как работает перенос
- [Автоматический](#автоматический-способ-через-скрипт) — скрипт-обёртка `remnawave-tg-shop` и `remnawave-minishop` имеют совместимую историю схемы.
останавливает старый стек, накатывает свежий код, переносит том БД, После переноса старой PostgreSQL-БД сервис `migrate` накатывает недостающие
поднимает новые сервисы. Идемпотентный. миграции из `backend/db/migrator.py`: сначала применяются `Base.metadata`,
- [Ручной](#ручной-способ) — те же шаги командами, для тех, кому нужно затем последовательные записи `schema_migrations`. Это one-shot сервис: он
понимать каждое действие или выполнить выборочно. должен завершиться с кодом `0`, после чего стартуют `backend` и `worker`.
В обоих случаях: При volume-миграции wizard:
- старые тома **не удаляются** автоматически — это безопасный бэкап на случай 1. Останавливает известные контейнеры старого и переходного стеков, если вы
отката; подтверждаете этот шаг.
- сертификаты Caddy (если используется `deploy/examples/caddy/docker-compose.yml`) 2. Запускает `docker compose up --no-start`, чтобы Docker Compose создал новые
тоже переносятся, чтобы Let's Encrypt не выписывал их заново и не упереться volumes.
в rate limit; 3. Копирует старый volume БД:
- схема БД обновляется автоматически: при первом `docker compose up -d` сервис
`migrate` накатывает на перенесённый том все недостающие миграции (от ```bash
alembic-схемы v2.7.0 до текущей). docker run --rm \
-v remnawave-tg-shop-db-data:/from:ro \
-v remnawave-minishop-db-data:/to \
alpine sh -c "cd /from && cp -a . /to"
```
4. Если старые Caddy volumes существуют, переносит
`remnawave-tg-shop-caddy-data` -> `remnawave-minishop-caddy-data` и
`remnawave-tg-shop-caddy-config` -> `remnawave-minishop-caddy-config`.
5. Запускает новый stack через Docker Compose.
Если целевой DB volume уже непустой, wizard не перетирает его молча: он
останавливается и просит отдельное подтверждение на продолжение без копирования
старой БД.
## Что меняется в архитектуре ## Что меняется в архитектуре
@@ -93,256 +63,55 @@ docker compose \
| Версия | Сервисы | | Версия | Сервисы |
| --- | --- | | --- | --- |
| `v2.7.0` | `remnawave-tg-shop`, `remnawave-tg-shop-db` | | `v2.7.0` | `remnawave-tg-shop`, `remnawave-tg-shop-db` |
| `v3.1.xv3.3.x` | `remnawave-minishop`, `remnawave-minishop-db` | | `v3.1.x-v3.3.x` | `remnawave-minishop`, `remnawave-minishop-db` |
| `v3.4+` (текущая) | `remnawave-minishop-backend`, `remnawave-minishop-worker`, `remnawave-minishop-frontend`, `remnawave-minishop-migrate`, `remnawave-minishop-postgres`, `remnawave-minishop-redis` | | `v3.4+` | `remnawave-minishop-backend`, `remnawave-minishop-worker`, `remnawave-minishop-frontend`, `remnawave-minishop-migrate`, `remnawave-minishop-postgres`, `remnawave-minishop-redis` |
Внутри Docker-сети сервисы доступны по коротким DNS-именам (`backend`, `worker`,
`frontend`, `postgres`, `redis`), а не по полному `container_name`. Это важно
для внешнего reverse-proxy — см. раздел [Внешний reverse-proxy](#внешний-reverse-proxy) ниже.
**Volumes**: **Volumes**:
| Volume | v2.7.0 | v3.4+ | Что внутри | | Volume | Что происходит |
| --- | --- | --- | --- | | --- | --- |
| `remnawave-minishop-db-data` | переименовать из `remnawave-tg-shop-db-data` | переносится скриптом | PostgreSQL | | `remnawave-minishop-db-data` | переносится из `remnawave-tg-shop-db-data` или восстанавливается из source DSN |
| `remnawave-minishop-redis-data` | — | создаётся пустым | Redis (FSM, rate-limit, cache, очередь вебхуков, distributed locks) | | `remnawave-minishop-redis-data` | создается пустым |
| `remnawave-minishop-shop-data` | — | создаётся пустым | `/app/data`: `tariffs.json`, темы Web App, кэш логотипа/emoji | | `remnawave-minishop-shop-data` | создается пустым; runtime-файлы в `/app/data` дальше настраиваются через админку или вручную |
| `remnawave-minishop-caddy-data` / `remnawave-minishop-caddy-config` | переименовать из `remnawave-tg-shop-caddy-*` | переносится скриптом | только при Caddy-варианте | | `remnawave-minishop-caddy-data` / `remnawave-minishop-caddy-config` | переносятся из `remnawave-tg-shop-caddy-*`, если старый стек использовал Caddy |
`redis-data` и `shop-data` стартуют пустыми — это нормально. Redis ничего Доступные compose-профили: `docker-compose.yml`,
долгоживущего не хранит (всё либо FSM, либо кеш с TTL), а `data/` инициализируется `deploy/examples/caddy/docker-compose.yml`,
из образа при первом старте (`tariffs.json` пуст пока вы не сконфигурируете `deploy/examples/nginx/docker-compose.yml`,
тарифы через админ-панель). `deploy/examples/newt/docker-compose.yml`,
`deploy/examples/no-proxy/docker-compose.yml`.
## Переменные окружения, которые могли исчезнуть или переехать ## Переменные окружения
Перед запуском нового стека проверьте `.env`. Ниже — только то, что точно Перед запуском нового стека проверьте `.env`. Самые важные изменения:
менялось между v2.7.0 и v3.4+:
| Было (v2.7.0) | Стало (v3.4+) | Действие | | Было | Стало | Действие |
| --- | --- | --- | | --- | --- | --- |
| `TELEGRAM_WEBHOOK_SECRET` | `WEBHOOK_SECRET_TOKEN` | Переименовать. Если пусто — будет сгенерирован при старте, но тогда Telegram переустановит webhook (на это не реагирует существующий запрос). | | `TELEGRAM_WEBHOOK_SECRET` | `WEBHOOK_SECRET_TOKEN` | Перенести значение или сгенерировать новый stable secret. |
| `TELEGRAM_WEBHOOK_PATH` | удалена | Путь вебхука теперь генерируется из `BOT_TOKEN` автоматически. | | `TELEGRAM_WEBHOOK_PATH` | удалена | Путь вебхука теперь рассчитывается автоматически. |
| `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 используются напрямую. |
| `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` | `postgres` внутри Compose | В compose-файлах `POSTGRES_HOST` переопределяется service name `postgres`. |
| `POSTGRES_HOST=remnawave-tg-shop-db` | в `.env``remnawave-minishop-db` или пусто | Под compose значение всё равно переопределяется на сервисное имя `postgres` (см. `environment:` в compose-файлах), поэтому скрипт правит `.env` только для bare-metal сценариев. | | `WEBHOOK_BASE_URL` | обязательна | Без публичного URL backend не стартует корректно. |
| `WEBHOOK_BASE_URL` | **обязательна** | Polling-режим удалён, без публичного URL бот не стартует. | | - | `REDIS_URL=redis://redis:6379/0` | В compose-профилях задано автоматически. |
| | `REDIS_URL=redis://redis:6379/0` | Обязательна для воркера, очередей и rate-limit. По умолчанию в compose-файлах уже задана. | | - | `WEBAPP_SESSION_SECRET`, `WEBAPP_ENABLED`, `TARIFFS_CONFIG_PATH` | Новые настройки Web App и каталога тарифов. |
| — | `WEBAPP_SESSION_SECRET`, `WEBAPP_ENABLED`, `WEBAPP_SERVER_PORT`, `WEBAPP_THEMES_DIR`, `TARIFFS_CONFIG_PATH` | Новые настройки Web App / тарифного каталога. Безопасные дефолты есть в `.env.example`. |
Полный референс — [docs/getting-started/configuration.md](../getting-started/configuration.md). Скрипт миграции Остальные продуктовые настройки удобнее проверить после первого входа в
эти переменные **не правит** автоматически (только `POSTGRES_HOST`), потому админку.
что у каждой инсталляции свой шаблон `.env` с кастомными значениями. Лучше
сравнить свой `.env` с `.env.example` глазами один раз, чем получить
несовместимый шаблон автоматом.
## Автоматический способ (через скрипт) ## Reverse Proxy
Если helper ещё не лежит у вас локально, запускайте его прямо из `raw` из В старом стеке часто был один upstream `remnawave-tg-shop:8000`. В текущем
корня старого репозитория: split-arch stack маршруты разделены:
```bash | Назначение | Service | Port |
bash <(curl -fsSL https://raw.githubusercontent.com/3252a8/remnawave-minishop/main/scripts/migrate_to_minishop.sh)
```
> Команда выше рассчитана на `bash` / Git Bash / WSL. Если вы запускаете из
> PowerShell, удобнее сначала открыть Git Bash.
Если вы уже подтянули новую версию и файл есть локально, можно запускать так:
```bash
bash scripts/migrate_to_minishop.sh
```
По умолчанию скрипт работает с `docker-compose.yml` и переключается на ветку
`main`. Можно переопределить через переменные окружения:
| Переменная | Назначение | По умолчанию |
| ----------------- | ----------------------------------------------------------------------- | ---------------------- |
| `PROJECT_ROOT` | Явный путь к корню старого репозитория, если запуск не из него | текущая директория |
| `COMPOSE_FILE` | Какой compose-файл стартовать в конце | `docker-compose.yml` |
| `TARGET_BRANCH` | На какую ветку переключаться и подтягивать обновления | `main` |
| `GIT_REMOTE` | Какой remote использовать для `fetch`/`pull` | `origin` |
| `NEW_ORIGIN_URL` | Если задано и не совпадает с URL выбранного remote — он будет обновлён | (не меняется) |
| `ASSUME_YES` | `1` — не задавать интерактивных вопросов | `0` |
Примеры:
```bash
# Caddy-вариант из raw-файла.
# Перед запуском скопируйте старый .env в deploy/examples/caddy/.env
# и заполните WEBHOOK_HOST / MINIAPP_HOST.
COMPOSE_FILE=deploy/examples/caddy/docker-compose.yml \
bash <(curl -fsSL https://raw.githubusercontent.com/3252a8/remnawave-minishop/main/scripts/migrate_to_minishop.sh)
# С переключением origin на форк 3252a8
NEW_ORIGIN_URL=https://github.com/3252a8/remnawave-minishop.git \
bash <(curl -fsSL https://raw.githubusercontent.com/3252a8/remnawave-minishop/main/scripts/migrate_to_minishop.sh)
# Без интерактива
ASSUME_YES=1 \
bash <(curl -fsSL https://raw.githubusercontent.com/3252a8/remnawave-minishop/main/scripts/migrate_to_minishop.sh)
```
Что делает скрипт:
1. **Останавливает текущий стек**: ищет известные контейнеры старой схемы
(`remnawave-tg-shop`, `…-db`, `…-caddy`), переходного периода
(`remnawave-minishop`, `…-db`, `…-caddy`) и новой схемы
(`…-backend`, `…-worker`, `…-frontend`, `…-migrate`, `…-postgres`, `…-redis`)
и останавливает их, если запущены. Безопасно при повторном запуске.
2. **Переключает `origin`**, если задана переменная `NEW_ORIGIN_URL`, иначе
оставляет как есть.
3. **Подтягивает целевую ветку** (`git fetch` + `git switch` + `git pull --ff-only`).
Прерывается, если в рабочем дереве есть незакоммиченные изменения.
4. **Правит `POSTGRES_HOST` в `.env`** (только для bare-metal сценариев — в
compose это значение перебивает `environment:` блок).
5. **Подготавливает новый стек в режиме `--no-start`**, чтобы Compose сам
создал тома `db-data`, `redis-data`, `shop-data` и не ругался на уже
существующий volume.
6. **Переносит том БД** `remnawave-tg-shop-db-data``remnawave-minishop-db-data`
(и Caddy-тома, если применимо) через одноразовый `alpine`-контейнер. Если
новый том уже непустой — копирование пропускается. Новые volumes
`redis-data` и `shop-data` остаются пустыми (их и не должно быть в старом
стеке).
7. **Стартует новый стек** (`docker compose up -d --remove-orphans` плюс
`--build` для локальной сборки). `migrate` отработает первым, накатит
на перенесённый том все недостающие миграции (от alembic-схемы v2.7.0 до
текущей) и завершится. Затем стартуют `backend`, `worker`, `frontend`.
Скрипт идемпотентен: повторный запуск ничего не сломает, просто пропустит уже
выполненные шаги.
После того как убедитесь, что бот работает и данные на месте, удалите старые
тома:
```bash
docker volume rm remnawave-tg-shop-db-data
docker volume rm remnawave-tg-shop-caddy-data remnawave-tg-shop-caddy-config 2>/dev/null || true
```
## Ручной способ
1. **Остановите старый стек и обновите код:**
```bash
docker compose down
git fetch origin
git checkout main
git pull --ff-only origin main
```
2. **(Только для bare-metal без compose)** обновите `.env`, если в нём ещё
жёстко прописан старый контейнер БД:
```bash
sed -i.bak 's/^POSTGRES_HOST=remnawave-tg-shop-db$/POSTGRES_HOST=remnawave-minishop-db/' .env
```
Под `docker compose up` это не нужно: compose сам выставляет
`POSTGRES_HOST: postgres` (имя сервиса) в `environment:` и `.env`-значение
не используется.
3. **Проверьте `.env`** на наличие переменных, которые исчезли или
переименовались — см. раздел
[Переменные окружения](#переменные-окружения-которые-могли-исчезнуть-или-переехать)
выше. Главное: `WEBHOOK_SECRET_TOKEN` (бывший `TELEGRAM_WEBHOOK_SECRET`),
обязательный `WEBHOOK_BASE_URL` и наличие `REDIS_URL` (по умолчанию задано
в compose).
4. **Подготовьте новый стек без запуска**, чтобы Compose создал новые volumes
(`db-data`, `redis-data`, `shop-data`) и контейнеры:
```bash
# Локальная сборка
docker compose up --no-start --build
# Или готовый Caddy-вариант из GHCR-образов
cp .env deploy/examples/caddy/.env
nano deploy/examples/caddy/.env
docker compose \
--env-file deploy/examples/caddy/.env \
-f deploy/examples/caddy/docker-compose.yml \
up --no-start
# Другие готовые варианты:
# deploy/examples/nginx/docker-compose.yml
# deploy/examples/newt/docker-compose.yml
# deploy/examples/no-proxy/docker-compose.yml
```
5. **Перенесите том БД в новое имя:**
```bash
docker run --rm \
-v remnawave-tg-shop-db-data:/from:ro \
-v remnawave-minishop-db-data:/to \
alpine sh -c "cd /from && cp -a . /to"
```
`remnawave-minishop-redis-data` и `remnawave-minishop-shop-data` — новые,
переносить нечего. Они инициализируются на лету: Redis пуст, а `data/`
наполняется при первом обращении к настройкам Web App / каталогу тарифов.
6. **(Только для Caddy)** перенесите тома Caddy с TLS-сертификатами и
состоянием ACME:
```bash
for v in caddy-data caddy-config; do
docker run --rm \
-v "remnawave-tg-shop-$v":/from:ro \
-v "remnawave-minishop-$v":/to \
alpine sh -c "cd /from && cp -a . /to"
done
```
7. **Запустите новый стек:**
```bash
docker compose up -d
# или
docker compose \
--env-file deploy/examples/caddy/.env \
-f deploy/examples/caddy/docker-compose.yml \
up -d
```
Сервис `migrate` запустится первым, обнаружит перенесённый том,
применит недостающие схемные миграции (`Base.metadata.create_all` +
последовательные миграции `0001..00NN` из `backend/db/migrator.py`) и
выйдет с кодом 0. Только после этого стартуют `backend` и `worker`.
8. **Проверьте состояние:**
```bash
docker compose ps
docker compose logs -f backend worker frontend
docker compose logs migrate # должен закончиться "Migrator: migration 00NN applied successfully"
```
9. **(Опционально) удалите старые тома**, когда убедитесь, что новый стек
стабилен:
```bash
docker volume rm remnawave-tg-shop-db-data
docker volume rm remnawave-tg-shop-caddy-data remnawave-tg-shop-caddy-config 2>/dev/null || true
```
## Внешний reverse-proxy
В v2.7.0 был один upstream — `remnawave-tg-shop:8000`. В v3.4+ функциональность
разнесена по портам и сервисам:
| Назначение | DNS-имя сервиса | Порт |
| --- | --- | --- | | --- | --- | --- |
| Telegram / платежные / вебхуки панели | `backend` | `8080` | | Telegram, платежные и panel webhooks | `backend` | `8080` |
| Health-чек | `backend` | `8080` (`/healthz`) | | Health-check | `backend` | `8080` (`/healthz`) |
| Web App API (`/api/*`, `/auth/*`, ассеты тем и логотипов) | `backend` | `8081` (доступен только из Docker-сети) | | Web App API и auth | `backend` | `8081` внутри Docker-сети |
| Статический фронт Web App | `frontend` | `80` (внутри `frontend` уже проксирует `/api/*` и `/auth/*` на `backend:8081`) | | Статический Web App frontend | `frontend` | `80` |
Минимальная замена для внешнего Nginx, который раньше слал всё на один Минимальная схема для внешнего Nginx:
upstream:
```nginx ```nginx
upstream remnawave_backend_webhooks { server backend:8080; } upstream remnawave_backend_webhooks { server backend:8080; }
@@ -351,33 +120,32 @@ upstream remnawave_frontend { server frontend:80; }
server { server {
server_name app.domain.com; server_name app.domain.com;
listen 443 ssl; listen 443 ssl;
http2 on;
# ssl_certificate / ssl_certificate_key — без изменений
location /webhook/ { proxy_pass http://remnawave_backend_webhooks; } location /webhook/ { proxy_pass http://remnawave_backend_webhooks; }
location /healthz { proxy_pass http://remnawave_backend_webhooks; } location /healthz { proxy_pass http://remnawave_backend_webhooks; }
location / { proxy_pass http://remnawave_frontend; } location / { proxy_pass http://remnawave_frontend; }
} }
``` ```
Полные примеры (Caddy, Nginx, Newt/Pangolin и запуск без обратного прокси) — в Готовые Caddy, Nginx, Pangolin/Newt и no-proxy профили уже содержат нужную
[docs/getting-started/deployment.md](../getting-started/deployment.md) и [docs/features/web-app.md](../features/web-app.md). Если раньше прокси указывал на маршрутизацию.
`remnawave-tg-shop:8000` напрямую, после миграции нужно либо переключиться на
`backend:8080` / `frontend:80`, либо использовать готовый Caddy/Nginx/Newt
пример, который уже знает правильную маршрутизацию.
## Если что-то пошло не так ## Проверка
`migrate` упал → читайте `docker compose logs migrate`. Том БД остался После переноса:
не тронут, можно откатиться, переключив compose-файл обратно на старый
коммит и подняв старый стек на старом томе `remnawave-tg-shop-db-data`
(пока вы его не удалили).
`backend` не стартует → чаще всего `WEBHOOK_BASE_URL` пуст, либо ```bash
`WEBHOOK_SECRET_TOKEN` отличается от того, что Telegram ждёт. Поставьте docker compose ps
свежий секрет в `.env` и перезапустите — Telegram переустановит webhook docker compose logs migrate
автоматически. docker compose logs -f backend worker frontend
```
Web App пуст / 502 → проверьте, что `frontend` живёт (`docker compose ps`), `migrate` должен завершиться успешно, а `backend`, `worker`, `frontend`,
а внешний прокси шлёт на `frontend:80`, а не на старый `postgres` и `redis` должны быть running/healthy.
`remnawave-tg-shop:8000`.
Когда убедитесь, что новый stack работает, старые volumes можно удалить вручную:
```bash
docker volume rm remnawave-tg-shop-db-data
docker volume rm remnawave-tg-shop-caddy-data remnawave-tg-shop-caddy-config 2>/dev/null || true
```
+227 -14
View File
@@ -74,11 +74,15 @@
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; const TELEGRAM_NOTIFICATIONS_RESUME_REFRESH_COOLDOWN_MS = 1500;
const TELEGRAM_LINK_PENDING_ACTION_STORAGE_KEY = "rw_webapp_telegram_link_pending_action_v1";
const TELEGRAM_LINK_PENDING_TTL_MS = 10 * 60 * 1000;
const TELEGRAM_LINK_ACTION_TRIAL = "trial";
const TELEGRAM_LINK_ACTION_REFERRAL_WELCOME = "referral_welcome";
import { import {
activationPaymentFailed, activationPaymentFailed,
createActivationHandoff, createActivationHandoff,
} from "./lib/webapp/activationHandoff.js"; } from "./lib/webapp/activationHandoff.js";
import { buildGravatarUrl } from "./lib/webapp/gravatar.js"; import { buildGravatarUrl, resolveProfileAvatarUrl } from "./lib/webapp/gravatar.js";
import { createBillingActions } from "./lib/webapp/billingActions.js"; import { createBillingActions } from "./lib/webapp/billingActions.js";
import { invalidateWebappTariffOptionCaches } from "./lib/webapp/billingOptionCache.js"; import { invalidateWebappTariffOptionCaches } from "./lib/webapp/billingOptionCache.js";
import { runWebappBoot } from "./lib/webapp/webappBoot.js"; import { runWebappBoot } from "./lib/webapp/webappBoot.js";
@@ -171,6 +175,7 @@
let telegramNotificationsBotOpenedAt = 0; let telegramNotificationsBotOpenedAt = 0;
let telegramNotificationsResumeRefreshBusy = false; let telegramNotificationsResumeRefreshBusy = false;
let telegramNotificationsResumeLastCheckAt = 0; let telegramNotificationsResumeLastCheckAt = 0;
let telegramLinkPendingActionBusy = false;
let promoCode = ""; let promoCode = "";
let promoBusy = false; let promoBusy = false;
let promoStatus = ""; let promoStatus = "";
@@ -358,13 +363,9 @@
} = $accountStore); } = $accountStore);
$: brandTitle = CFG.title || "/minishop"; $: brandTitle = CFG.title || "/minishop";
$: brandEmoji = CFG.logoEmoji || "🫥";
$: brandEmojiFont = CFG.logoEmojiFont || "system";
$: brand = normalizeBrand({ $: brand = normalizeBrand({
title: brandTitle, title: brandTitle,
logoUrl: CFG.logoUseEmoji ? "" : CFG.logoUrl, logoUrl: CFG.logoUrl,
emoji: brandEmoji,
emojiFont: brandEmojiFont,
}); });
$: faviconBrand = normalizeBrand({ $: faviconBrand = normalizeBrand({
...brand, ...brand,
@@ -494,7 +495,7 @@
$: telegramProfileName = telegramName(user); $: telegramProfileName = telegramName(user);
$: profileEmail = user?.email || t("wa_settings_email_not_linked"); $: profileEmail = user?.email || t("wa_settings_email_not_linked");
$: profileTelegramId = user?.telegram_id ? `TG ID ${user.telegram_id}` : t("wa_tg_id_not_linked"); $: profileTelegramId = user?.telegram_id ? `TG ID ${user.telegram_id}` : t("wa_tg_id_not_linked");
$: profileAvatarUrl = user?.telegram_photo_url || emailAvatarUrl || ""; $: profileAvatarUrl = resolveProfileAvatarUrl(user, emailAvatarUrl);
$: privacyPolicyUrl = String(CFG.privacyPolicyUrl || "").trim(); $: privacyPolicyUrl = String(CFG.privacyPolicyUrl || "").trim();
$: userAgreementUrl = String(CFG.userAgreementUrl || "").trim(); $: userAgreementUrl = String(CFG.userAgreementUrl || "").trim();
$: supportUrl = String(appSettings?.support_url || CFG.supportUrl || "").trim(); $: supportUrl = String(appSettings?.support_url || CFG.supportUrl || "").trim();
@@ -1187,6 +1188,161 @@
} }
} }
function currentTelegramLinkPendingUserId() {
const currentUser = data?.user || user || {};
const id = currentUser.user_id ?? currentUser.id;
return id == null ? "" : String(id);
}
function isTelegramLinkPendingAction(action) {
return [TELEGRAM_LINK_ACTION_TRIAL, TELEGRAM_LINK_ACTION_REFERRAL_WELCOME].includes(action);
}
function rememberTelegramLinkPendingAction(action) {
if (typeof window === "undefined" || !isTelegramLinkPendingAction(action)) return;
try {
window.sessionStorage.setItem(
TELEGRAM_LINK_PENDING_ACTION_STORAGE_KEY,
JSON.stringify({
action,
userId: currentTelegramLinkPendingUserId(),
createdAt: Date.now(),
})
);
} catch (_error) {
void _error;
}
}
function clearTelegramLinkPendingAction() {
if (typeof window === "undefined") return;
try {
window.sessionStorage.removeItem(TELEGRAM_LINK_PENDING_ACTION_STORAGE_KEY);
} catch (_error) {
void _error;
}
}
function readTelegramLinkPendingAction() {
if (typeof window === "undefined") return null;
try {
const raw = window.sessionStorage.getItem(TELEGRAM_LINK_PENDING_ACTION_STORAGE_KEY);
if (!raw) return null;
const payload = JSON.parse(raw);
const action = String(payload?.action || "");
const createdAt = Number(payload?.createdAt || 0);
const pendingUserId = String(payload?.userId || "");
const currentUserId = currentTelegramLinkPendingUserId();
if (
!isTelegramLinkPendingAction(action) ||
!createdAt ||
Date.now() - createdAt > TELEGRAM_LINK_PENDING_TTL_MS ||
(pendingUserId && currentUserId && pendingUserId !== currentUserId)
) {
clearTelegramLinkPendingAction();
return null;
}
return action;
} catch (_error) {
clearTelegramLinkPendingAction();
return null;
}
}
async function runTelegramLinkedAction(action) {
if (action === TELEGRAM_LINK_ACTION_TRIAL) {
await activateTrial();
return true;
}
if (action === TELEGRAM_LINK_ACTION_REFERRAL_WELCOME) {
await claimReferralWelcomeBonus();
return true;
}
return false;
}
async function continueTelegramLinkPendingAction() {
if (telegramLinkPendingActionBusy) return false;
const currentUser = data?.user || user || {};
if (!currentUser?.telegram_linked) return false;
const action = readTelegramLinkPendingAction();
if (!action) return false;
telegramLinkPendingActionBusy = true;
clearTelegramLinkPendingAction();
try {
return await runTelegramLinkedAction(action);
} finally {
telegramLinkPendingActionBusy = false;
}
}
async function linkTelegramWithPayloadForPendingAction(payload) {
accountStore.update((s) => ({ ...s, linkTelegramBusy: true }));
try {
const response = await api("/account/telegram/link", {
method: "POST",
body: JSON.stringify(payload),
});
if (!response?.ok) throw response;
if (response?.csrf_token) setToken("", response.csrf_token);
await loadData({ fresh: true, preserveView: true });
const handled = await continueTelegramLinkPendingAction();
if (!handled) {
clearTelegramLinkPendingAction();
showToast(t("wa_settings_linked"));
}
} catch (error) {
clearTelegramLinkPendingAction();
showToast(error?.message || t("wa_auth_telegram_not_confirmed"));
} finally {
accountStore.update((s) => ({ ...s, linkTelegramBusy: false }));
}
}
async function linkTelegramForPendingAction(action) {
if (!isTelegramLinkPendingAction(action) || linkTelegramBusy || telegramLinkPendingActionBusy) {
return;
}
const currentUser = data?.user || user || {};
if (currentUser?.telegram_linked) {
await runTelegramLinkedAction(action);
return;
}
rememberTelegramLinkPendingAction(action);
if (demoAuthLogin) {
await linkTelegramWithPayloadForPendingAction({ auth_data: demoTelegramAuthPayload() });
return;
}
const isTelegramMiniAppAttempt = hasTelegramLaunchParams();
if (isTelegramMiniAppAttempt) {
await telegramSdk.ensureForAction();
}
const initData =
telegramMiniAppInitData || tg?.initData || readTelegramMiniAppInitDataFromLocation();
if (initData) {
await linkTelegramWithPayloadForPendingAction({ init_data: initData });
return;
}
if (!telegramOAuthClientId) {
clearTelegramLinkPendingAction();
showToast(t("wa_auth_telegram_not_configured"));
return;
}
await accountStore.linkTelegramAccount(
() => telegramMiniAppInitData || tg?.initData || readTelegramMiniAppInitDataFromLocation()
);
}
function linkTelegramAndActivateTrial() {
return linkTelegramForPendingAction(TELEGRAM_LINK_ACTION_TRIAL);
}
function linkTelegramAndClaimReferralWelcome() {
return linkTelegramForPendingAction(TELEGRAM_LINK_ACTION_REFERRAL_WELCOME);
}
function openTelegramNotificationsBot() { function openTelegramNotificationsBot() {
const link = telegramNotificationsStartLink; const link = telegramNotificationsStartLink;
telegramNotificationsBotOpenedAt = Date.now(); telegramNotificationsBotOpenedAt = Date.now();
@@ -1453,9 +1609,12 @@
getCsrfToken: () => csrfToken, getCsrfToken: () => csrfToken,
}); });
if (mode === "app" && screen !== "admin") { if (mode === "app" && screen !== "admin") {
if (hasPendingActivationHandoff()) await loadData({ fresh: true }); const telegramActionHandled = await continueTelegramLinkPendingAction();
const shown = await maybeShowActivationSuccessDialog({ source: "boot" }); if (!telegramActionHandled) {
if (!shown) startPendingActivationWatch(); if (hasPendingActivationHandoff()) await loadData({ fresh: true });
const shown = await maybeShowActivationSuccessDialog({ source: "boot" });
if (!shown) startPendingActivationWatch();
}
} }
} }
@@ -1524,6 +1683,7 @@
selectedPlan: null, selectedPlan: null,
selectedTariffKey: "", selectedTariffKey: "",
paymentStep: "tariff", paymentStep: "tariff",
renewHwidDevices: true,
selectedMethod: payload.payment_methods?.[0]?.id || "", selectedMethod: payload.payment_methods?.[0]?.id || "",
})); }));
const currentQuery = currentSearchParams(); const currentQuery = currentSearchParams();
@@ -1904,6 +2064,55 @@
} }
} }
function trialActivationFailureMessage(error) {
if (
error?.error === "trial_telegram_required" ||
error?.message === "telegram_required" ||
error?.message === "disposable_email"
) {
return t(
"wa_trial_telegram_required_error",
{},
"Для активации пробного периода привяжите Telegram."
);
}
return error?.message || t("wa_trial_activation_failed");
}
function referralWelcomeFailureMessage(error) {
if (
error?.error === "referral_welcome_telegram_required" ||
error?.message === "telegram_required" ||
error?.message === "disposable_email"
) {
return t(
"wa_referral_welcome_telegram_required_error",
{},
"Для получения реферального бонуса привяжите Telegram."
);
}
return error?.message || t("wa_referral_welcome_claim_failed");
}
async function claimReferralWelcomeBonus() {
try {
const response = await api("/referral/welcome-bonus/claim", {
method: "POST",
body: JSON.stringify({}),
});
if (!response.ok) throw response;
showToast(
response.end_date_text
? t("wa_referral_welcome_claimed_until", { date: response.end_date_text })
: t("wa_referral_welcome_claimed")
);
await loadData({ fresh: true });
await maybeShowActivationSuccessDialog({ source: "referral_welcome", force: true });
} catch (error) {
showToast(referralWelcomeFailureMessage(error));
}
}
async function activateTrial() { async function activateTrial() {
if (trialBusy) return; if (trialBusy) return;
trialBusy = true; trialBusy = true;
@@ -1920,7 +2129,7 @@
await loadData({ fresh: true }); await loadData({ fresh: true });
await maybeShowActivationSuccessDialog({ source: "trial", force: true }); await maybeShowActivationSuccessDialog({ source: "trial", force: true });
} catch (error) { } catch (error) {
const message = error?.message || t("wa_trial_activation_failed"); const message = trialActivationFailureMessage(error);
trialActivationError = message; trialActivationError = message;
showToast(message); showToast(message);
} finally { } finally {
@@ -2077,9 +2286,6 @@
]); ]);
return [ return [
"WEBAPP_LOGO_URL", "WEBAPP_LOGO_URL",
"WEBAPP_LOGO_USE_EMOJI",
"WEBAPP_LOGO_EMOJI",
"WEBAPP_LOGO_EMOJI_FONT",
"WEBAPP_FAVICON_URL", "WEBAPP_FAVICON_URL",
"WEBAPP_FAVICON_USE_CUSTOM", "WEBAPP_FAVICON_USE_CUSTOM",
"WEBAPP_LOGO_FAVICON_URL", "WEBAPP_LOGO_FAVICON_URL",
@@ -2286,7 +2492,9 @@
{premiumTrafficTopupUnlocked} {premiumTrafficTopupUnlocked}
{regularTrafficTopupBarClickable} {regularTrafficTopupBarClickable}
{regularTrafficTopupUnlocked} {regularTrafficTopupUnlocked}
{referral}
{subscription} {subscription}
{linkTelegramBusy}
{telegramNotificationsNeedPrompt} {telegramNotificationsNeedPrompt}
{telegramNotificationsStartLink} {telegramNotificationsStartLink}
{telegramNotificationsStatus} {telegramNotificationsStatus}
@@ -2294,6 +2502,8 @@
{trafficMode} {trafficMode}
{trialBusy} {trialBusy}
{activateTrial} {activateTrial}
{linkTelegramAndActivateTrial}
{linkTelegramAndClaimReferralWelcome}
{openTelegramNotificationsBot} {openTelegramNotificationsBot}
openConnectLink={openInstallOrConnect} openConnectLink={openInstallOrConnect}
{openPaymentModal} {openPaymentModal}
@@ -2323,9 +2533,11 @@
{brandTitle} {brandTitle}
{subscription} {subscription}
{trialBusy} {trialBusy}
{linkTelegramBusy}
trialResult={trialActivationResult} trialResult={trialActivationResult}
trialError={trialActivationError} trialError={trialActivationError}
{activateTrial} {activateTrial}
{linkTelegramAndActivateTrial}
openInstallOrConnect={openTrialInstallOrConnect} openInstallOrConnect={openTrialInstallOrConnect}
{goHome} {goHome}
{t} {t}
@@ -2428,6 +2640,7 @@
bind:paymentStep={$billingStore.paymentStep} bind:paymentStep={$billingStore.paymentStep}
bind:selectedMethod={$billingStore.selectedMethod} bind:selectedMethod={$billingStore.selectedMethod}
bind:selectedPlan={$billingStore.selectedPlan} bind:selectedPlan={$billingStore.selectedPlan}
bind:renewHwidDevices={$billingStore.renewHwidDevices}
bind:selectedTariffKey={$billingStore.selectedTariffKey} bind:selectedTariffKey={$billingStore.selectedTariffKey}
bind:setPasswordCode={$accountStore.setPasswordCode} bind:setPasswordCode={$accountStore.setPasswordCode}
bind:setPasswordConfirm={$accountStore.setPasswordConfirm} bind:setPasswordConfirm={$accountStore.setPasswordConfirm}
+6 -6
View File
@@ -31,7 +31,7 @@
export let mockData = {}; export let mockData = {};
const title = config.title || "/minishop"; const title = config.title || "/minishop";
const logoEmoji = config.logoEmoji || "🫥"; const logoUrl = config.logoUrl || "/webapp-default-logo.webp";
const plans = mockData.plans || []; const plans = mockData.plans || [];
const sub = mockData.subscription || {}; const sub = mockData.subscription || {};
const methods = mockData.payment_methods || []; const methods = mockData.payment_methods || [];
@@ -78,7 +78,7 @@
<PhoneFrame number="1" label="Главный экран"> <PhoneFrame number="1" label="Главный экран">
<main class="home-layout"> <main class="home-layout">
<div class="login-brand home-brand"> <div class="login-brand home-brand">
<div class="brand-mark brand-mark-xl"><span>{logoEmoji}</span></div> <div class="brand-mark brand-mark-xl"><img class="loaded" src={logoUrl} alt="" /></div>
<h1>{title}</h1> <h1>{title}</h1>
</div> </div>
<div class="home-bottom"> <div class="home-bottom">
@@ -113,7 +113,7 @@
<PhoneFrame number="2" label="Выбор тарифа"> <PhoneFrame number="2" label="Выбор тарифа">
<div class="preview-header"> <div class="preview-header">
<div class="brand-row"> <div class="brand-row">
<div class="brand-mark"><span>{logoEmoji}</span></div> <div class="brand-mark"><img class="loaded" src={logoUrl} alt="" /></div>
<strong>{title}</strong> <strong>{title}</strong>
</div> </div>
</div> </div>
@@ -198,7 +198,7 @@
<PhoneFrame number="6" label="Пригласить друга"> <PhoneFrame number="6" label="Пригласить друга">
<div class="preview-header"> <div class="preview-header">
<div class="brand-row"> <div class="brand-row">
<div class="brand-mark"><span>{logoEmoji}</span></div> <div class="brand-mark"><img class="loaded" src={logoUrl} alt="" /></div>
<strong>{title}</strong> <strong>{title}</strong>
</div> </div>
</div> </div>
@@ -221,7 +221,7 @@
<PhoneFrame number="7" label="Настройки"> <PhoneFrame number="7" label="Настройки">
<div class="preview-header"> <div class="preview-header">
<div class="brand-row"> <div class="brand-row">
<div class="brand-mark"><span>{logoEmoji}</span></div> <div class="brand-mark"><img class="loaded" src={logoUrl} alt="" /></div>
<strong>{title}</strong> <strong>{title}</strong>
</div> </div>
</div> </div>
@@ -252,7 +252,7 @@
<PhoneFrame number="8" label="Логин" wide> <PhoneFrame number="8" label="Логин" wide>
<div class="login-brand small"> <div class="login-brand small">
<div class="brand-mark brand-mark-xl"><span>{logoEmoji}</span></div> <div class="brand-mark brand-mark-xl"><img class="loaded" src={logoUrl} alt="" /></div>
<h1>{title}</h1> <h1>{title}</h1>
<p>Войдите в свой аккаунт</p> <p>Войдите в свой аккаунт</p>
</div> </div>
@@ -1,16 +1,10 @@
<script> <script>
import { Check, ExternalLink, FileText, RefreshCw, Save } from "$components/ui/icons.js"; import { Check, ExternalLink, FileText, RefreshCw, Save } from "$components/ui/icons.js";
import { import { AdminBadge, AdminButton, AdminEmptyState } from "$components/patterns/admin/index.js";
AdminBadge,
AdminButton,
AdminEmptyState,
AdminSelect,
} from "$components/patterns/admin/index.js";
import { Checkbox, ColorInput, FileInput, Input, RangeInput } 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";
import BrandMark from "$lib/webapp/BrandMark.svelte";
import { localizedThemeName } from "$lib/webapp/themeStyle.js"; import { localizedThemeName } from "$lib/webapp/themeStyle.js";
export let at; export let at;
@@ -26,9 +20,6 @@
"SUBSCRIPTION_MINI_APP_URL", "SUBSCRIPTION_MINI_APP_URL",
"WEBAPP_PRIMARY_COLOR", "WEBAPP_PRIMARY_COLOR",
"WEBAPP_LOGO_URL", "WEBAPP_LOGO_URL",
"WEBAPP_LOGO_USE_EMOJI",
"WEBAPP_LOGO_EMOJI",
"WEBAPP_LOGO_EMOJI_FONT",
"WEBAPP_FAVICON_URL", "WEBAPP_FAVICON_URL",
"WEBAPP_FAVICON_USE_CUSTOM", "WEBAPP_FAVICON_USE_CUSTOM",
"WEBAPP_LOGO_FAVICON_URL", "WEBAPP_LOGO_FAVICON_URL",
@@ -42,8 +33,7 @@
$: fieldMap = new Map(appearanceFields.map((field) => [field.key, field])); $: fieldMap = new Map(appearanceFields.map((field) => [field.key, field]));
$: activeKey = themesCatalog.default_theme; $: activeKey = themesCatalog.default_theme;
$: logoUrl = valueForKey("WEBAPP_LOGO_URL"); $: logoUrl = valueForKey("WEBAPP_LOGO_URL");
$: useEmojiLogo = boolValue(valueForKey("WEBAPP_LOGO_USE_EMOJI")); $: currentLogoUrl = pendingLogoPreviewUrl || logoUrl || brand?.logoUrl || "";
$: currentLogoUrl = !useEmojiLogo ? pendingLogoPreviewUrl || logoUrl || brand?.logoUrl || "" : "";
$: previewLogoUrl = $: previewLogoUrl =
logoPreviewNonce && currentLogoUrl ? withLogoCacheBust(currentLogoUrl) : currentLogoUrl; logoPreviewNonce && currentLogoUrl ? withLogoCacheBust(currentLogoUrl) : currentLogoUrl;
$: persistedUseCustomFavicon = boolValue( $: persistedUseCustomFavicon = boolValue(
@@ -59,28 +49,12 @@
$: useCustomFavicon = faviconUseCustomDraft; $: useCustomFavicon = faviconUseCustomDraft;
$: faviconUrl = valueForKey("WEBAPP_FAVICON_URL", appFaviconUrl); $: faviconUrl = valueForKey("WEBAPP_FAVICON_URL", appFaviconUrl);
$: logoFaviconUrl = valueForKey("WEBAPP_LOGO_FAVICON_URL"); $: logoFaviconUrl = valueForKey("WEBAPP_LOGO_FAVICON_URL");
$: generatedFaviconUrl = !useEmojiLogo $: generatedFaviconUrl = logoFaviconUrl || appFaviconUrl || previewLogoUrl || "";
? logoFaviconUrl || appFaviconUrl || previewLogoUrl || ""
: "";
$: currentFaviconUrl = useCustomFavicon $: currentFaviconUrl = useCustomFavicon
? pendingFaviconPreviewUrl || faviconUrl || "" ? pendingFaviconPreviewUrl || faviconUrl || ""
: generatedFaviconUrl; : generatedFaviconUrl;
$: previewFaviconUrl = $: previewFaviconUrl =
faviconPreviewNonce && currentFaviconUrl ? withCacheBust(currentFaviconUrl) : currentFaviconUrl; faviconPreviewNonce && currentFaviconUrl ? withCacheBust(currentFaviconUrl) : currentFaviconUrl;
$: logoEmoji = valueForKey("WEBAPP_LOGO_EMOJI");
$: logoEmojiInput = useEmojiLogo ? logoEmoji : "";
$: logoEmojiPreview = logoEmoji || "🫥";
$: logoEmojiFont = valueForKey("WEBAPP_LOGO_EMOJI_FONT") || "system";
$: logoBrand = {
title: "",
logoUrl: useEmojiLogo ? "" : previewLogoUrl,
emoji: logoEmojiPreview,
emojiFont: logoEmojiFont,
};
$: emojiFontItems = (fieldMap.get("WEBAPP_LOGO_EMOJI_FONT")?.choices || []).map((item) => ({
value: item.value,
label: item.i18n_label_key ? adminText(item.i18n_label_key, {}, item.label) : item.label,
}));
$: dirtyCount = Object.keys(settingsDirty || {}).filter((key) => $: dirtyCount = Object.keys(settingsDirty || {}).filter((key) =>
isAppearanceSettingKey(key) isAppearanceSettingKey(key)
).length; ).length;
@@ -138,15 +112,6 @@
return Boolean(value); return Boolean(value);
} }
function adminLocaleKey(key) {
const raw = String(key || "");
return raw.startsWith("admin_") ? raw.slice("admin_".length) : raw;
}
function adminText(key, params = {}, fallback = "") {
return key ? at(adminLocaleKey(key), params, fallback) : fallback;
}
function withLogoCacheBust(url) { function withLogoCacheBust(url) {
return withCacheBust(url, logoPreviewNonce); return withCacheBust(url, logoPreviewNonce);
} }
@@ -213,10 +178,8 @@
return `#${hex}`; return `#${hex}`;
} }
function homeLogoScale(theme) { function homeLogoScale(theme, mode) {
const scale = Number(theme.tokens?.home_logo_scale || 100); return themesStore.resolveThemeHomeLogoScale(theme, mode);
if (!Number.isFinite(scale)) return 100;
return Math.min(300, Math.max(50, Math.round(scale)));
} }
function openThemeAccentPicker(theme) { function openThemeAccentPicker(theme) {
@@ -241,7 +204,6 @@
if (uploaded?.faviconUrl) { if (uploaded?.faviconUrl) {
settingsStore.setFieldValue("WEBAPP_LOGO_FAVICON_URL", uploaded.faviconUrl); settingsStore.setFieldValue("WEBAPP_LOGO_FAVICON_URL", uploaded.faviconUrl);
} }
settingsStore.setFieldValue("WEBAPP_LOGO_USE_EMOJI", false);
if (logoFileInput) logoFileInput.value = ""; if (logoFileInput) logoFileInput.value = "";
}); });
} }
@@ -256,7 +218,6 @@
if (uploaded?.faviconUrl) { if (uploaded?.faviconUrl) {
settingsStore.setFieldValue("WEBAPP_LOGO_FAVICON_URL", uploaded.faviconUrl); settingsStore.setFieldValue("WEBAPP_LOGO_FAVICON_URL", uploaded.faviconUrl);
} }
settingsStore.setFieldValue("WEBAPP_LOGO_USE_EMOJI", false);
}); });
} }
@@ -303,32 +264,11 @@
} }
} }
function setEmojiLogo(enabled) {
settingsStore.markDirty("WEBAPP_LOGO_USE_EMOJI", Boolean(enabled));
if (!enabled) {
settingsStore.markDirty("WEBAPP_LOGO_EMOJI", "");
} else {
pendingLogoPreviewUrl = "";
clearPendingObjectUrl();
}
}
function setAppearanceValue(key, value) {
settingsStore.markDirty(key, value);
}
async function saveAppearance() { async function saveAppearance() {
const keysToSave = new Set(appearanceDirtyKeys); const keysToSave = new Set(appearanceDirtyKeys);
if (!useEmojiLogo && logoEmoji) {
settingsStore.markDirty("WEBAPP_LOGO_EMOJI", "");
keysToSave.add("WEBAPP_LOGO_EMOJI");
}
const shouldReloadFrontend = Array.from(keysToSave).some((key) => const shouldReloadFrontend = Array.from(keysToSave).some((key) =>
[ [
"WEBAPP_LOGO_URL", "WEBAPP_LOGO_URL",
"WEBAPP_LOGO_USE_EMOJI",
"WEBAPP_LOGO_EMOJI",
"WEBAPP_LOGO_EMOJI_FONT",
"WEBAPP_FAVICON_URL", "WEBAPP_FAVICON_URL",
"WEBAPP_FAVICON_USE_CUSTOM", "WEBAPP_FAVICON_USE_CUSTOM",
"WEBAPP_LOGO_FAVICON_URL", "WEBAPP_LOGO_FAVICON_URL",
@@ -354,17 +294,21 @@
themesStore.setThemeAccent(theme.key, value); themesStore.setThemeAccent(theme.key, value);
} }
function setThemeHomeLogoScale(theme, value) { function setThemeHomeLogoScale(theme, mode, value) {
themesStore.setThemeHomeLogoScale(theme.key, value); themesStore.setThemeHomeLogoScale(theme.key, mode, value);
}
function isThemeControlTarget(target) {
return target?.closest?.("button,input,label,.admin-theme-card-option,.ui-range-input");
} }
function selectTheme(theme, event = null) { function selectTheme(theme, event = null) {
if (event?.target?.closest?.("button,input,label")) return; if (isThemeControlTarget(event?.target)) return;
if (!themesSaving) themesStore.setCurrentTheme(theme.key); if (!themesSaving) themesStore.setCurrentTheme(theme.key);
} }
function handleThemeKeydown(event, theme) { function handleThemeKeydown(event, theme) {
if (event?.target?.closest?.("button,input,label")) return; if (isThemeControlTarget(event?.target)) return;
if (event.key !== "Enter" && event.key !== " ") return; if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault(); event.preventDefault();
selectTheme(theme); selectTheme(theme);
@@ -395,13 +339,7 @@
<header class="admin-card-head"> <header class="admin-card-head">
<div> <div>
<h3>{at("appearance_brand_title", {}, "Логотип")}</h3> <h3>{at("appearance_brand_title", {}, "Логотип")}</h3>
<small <small>{at("appearance_brand_sub", {}, "Загрузите логотип файлом или по ссылке")}</small>
>{at(
"appearance_brand_sub",
{},
"Файл, ссылка или подтвержденный emoji-логотип"
)}</small
>
</div> </div>
<div class="admin-editor-section-actions"> <div class="admin-editor-section-actions">
{#if dirtyCount} {#if dirtyCount}
@@ -424,7 +362,7 @@
</header> </header>
<div class="admin-card-body appearance-logo-grid"> <div class="admin-card-body appearance-logo-grid">
<div class="appearance-logo-preview"> <div class="appearance-logo-preview">
{#if !useEmojiLogo && previewLogoUrl && !logoPreviewFailed} {#if previewLogoUrl && !logoPreviewFailed}
<img <img
class="appearance-logo-image" class="appearance-logo-image"
src={previewLogoUrl} src={previewLogoUrl}
@@ -435,8 +373,6 @@
logoPreviewFailed = true; logoPreviewFailed = true;
}} }}
/> />
{:else if useEmojiLogo}
<BrandMark brand={logoBrand} size="lg" />
{:else} {:else}
<span class="appearance-logo-empty" aria-hidden="true"></span> <span class="appearance-logo-empty" aria-hidden="true"></span>
{/if} {/if}
@@ -476,39 +412,6 @@
</AdminButton> </AdminButton>
</div> </div>
</section> </section>
<section class="appearance-control-card">
<label class="appearance-switch">
<Switch.Root
checked={useEmojiLogo}
onCheckedChange={setEmojiLogo}
class="admin-switch-root"
>
<Switch.Thumb class="admin-switch-thumb" />
</Switch.Root>
<span>{at("appearance_use_emoji_logo", {}, "Использовать emoji-логотип")}</span>
</label>
<div class="appearance-emoji-grid">
<Input
class="input appearance-control"
type="text"
maxlength="8"
value={logoEmojiInput}
disabled={!useEmojiLogo}
oninput={(event) =>
setAppearanceValue("WEBAPP_LOGO_EMOJI", event.currentTarget.value)}
/>
<AdminSelect
class="appearance-control"
value={logoEmojiFont}
items={emojiFontItems}
disabled={!useEmojiLogo}
ariaLabel={at("appearance_emoji_font", {}, "Шрифт emoji")}
placeholder={at("appearance_emoji_font", {}, "Шрифт emoji")}
onValueChange={(value) => setAppearanceValue("WEBAPP_LOGO_EMOJI_FONT", value)}
/>
</div>
</section>
</div> </div>
</div> </div>
@@ -525,8 +428,6 @@
faviconPreviewFailed = true; faviconPreviewFailed = true;
}} }}
/> />
{:else if !useCustomFavicon && useEmojiLogo}
<BrandMark brand={logoBrand} size="lg" />
{:else} {:else}
<span class="appearance-logo-empty" aria-hidden="true"></span> <span class="appearance-logo-empty" aria-hidden="true"></span>
{/if} {/if}
@@ -682,12 +583,12 @@
/> />
<span>{at("themes_use_in_admin", {}, "Использовать в админке")}</span> <span>{at("themes_use_in_admin", {}, "Использовать в админке")}</span>
</label> </label>
<label class="admin-theme-card-option appearance-logo-scale-row"> <div class="admin-theme-card-option appearance-logo-scale-row">
<span <span class="appearance-logo-scale-label"
>{at( >{at(
"appearance_theme_home_logo_scale", "appearance_theme_home_logo_scale_desktop",
{}, {},
"Логотип на главной и входе" "Логотип на десктопе"
)}</span )}</span
> >
<RangeInput <RangeInput
@@ -695,9 +596,13 @@
min="50" min="50"
max="300" max="300"
step="5" step="5"
ariaLabel={at("appearance_theme_home_logo_scale", {}, "Home logo scale")} ariaLabel={at(
value={homeLogoScale(theme)} "appearance_theme_home_logo_scale_desktop",
onValueChange={(value) => setThemeHomeLogoScale(theme, value)} {},
"Desktop logo scale"
)}
value={homeLogoScale(theme, "desktop")}
onValueChange={(value) => setThemeHomeLogoScale(theme, "desktop", value)}
/> />
<span class="appearance-logo-scale-value"> <span class="appearance-logo-scale-value">
<Input <Input
@@ -706,12 +611,48 @@
min="50" min="50"
max="300" max="300"
step="5" step="5"
value={homeLogoScale(theme)} value={homeLogoScale(theme, "desktop")}
oninput={(event) => setThemeHomeLogoScale(theme, event.currentTarget.value)} oninput={(event) =>
setThemeHomeLogoScale(theme, "desktop", event.currentTarget.value)}
/> />
% %
</span> </span>
</label> </div>
<div class="admin-theme-card-option appearance-logo-scale-row">
<span class="appearance-logo-scale-label"
>{at(
"appearance_theme_home_logo_scale_mobile",
{},
"Логотип на мобильных"
)}</span
>
<RangeInput
class="appearance-logo-scale-range"
min="50"
max="300"
step="5"
ariaLabel={at(
"appearance_theme_home_logo_scale_mobile",
{},
"Mobile logo scale"
)}
value={homeLogoScale(theme, "mobile")}
onValueChange={(value) => setThemeHomeLogoScale(theme, "mobile", value)}
/>
<span class="appearance-logo-scale-value">
<Input
class="input"
type="number"
min="50"
max="300"
step="5"
value={homeLogoScale(theme, "mobile")}
oninput={(event) =>
setThemeHomeLogoScale(theme, "mobile", event.currentTarget.value)}
/>
%
</span>
</div>
<div class="appearance-theme-actions"> <div class="appearance-theme-actions">
<AdminButton <AdminButton
size="sm" size="sm"
@@ -815,22 +756,14 @@
display: none; display: none;
} }
.appearance-url-row, .appearance-url-row {
.appearance-emoji-grid {
display: grid; display: grid;
gap: 8px; gap: 8px;
max-width: 520px; max-width: 520px;
}
.appearance-url-row {
grid-template-columns: minmax(0, 1fr) max-content; grid-template-columns: minmax(0, 1fr) max-content;
width: 100%; width: 100%;
} }
.appearance-emoji-grid {
grid-template-columns: minmax(0, 360px) max-content;
}
:global(.appearance-control.input), :global(.appearance-control.input),
:global(.appearance-control.admin-btn), :global(.appearance-control.admin-btn),
:global(.appearance-control.admin-select-trigger) { :global(.appearance-control.admin-select-trigger) {
@@ -952,10 +885,15 @@
.appearance-logo-scale-row { .appearance-logo-scale-row {
display: grid; display: grid;
grid-template-columns: auto minmax(96px, 1fr) auto; grid-template-columns: minmax(112px, 0.8fr) minmax(96px, 1fr) auto;
width: 100%; width: 100%;
} }
.appearance-logo-scale-label {
min-width: 0;
line-height: 1.25;
}
:global(.appearance-logo-scale-range) { :global(.appearance-logo-scale-range) {
width: 100%; width: 100%;
accent-color: var(--accent); accent-color: var(--accent);
@@ -1012,9 +950,16 @@
height: auto; height: auto;
} }
.appearance-url-row, .appearance-url-row {
.appearance-emoji-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.appearance-logo-scale-row {
grid-template-columns: minmax(0, 1fr) auto;
}
:global(.appearance-logo-scale-range) {
grid-column: 1 / -1;
}
} }
</style> </style>
@@ -1,16 +1,27 @@
<script> <script>
import { Textarea } from "$components/ui/index.js"; 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, onMount } from "svelte";
import { Label } from "$components/ui/primitives.js"; import { Label } from "$components/ui/primitives.js";
import { AdminButton, AdminSelect } from "$components/patterns/admin/index.js"; import { AdminButton, AdminSelect } from "$components/patterns/admin/index.js";
export let at; export let at;
const broadcastStore = getContext("broadcastStore"); const broadcastStore = getContext("broadcastStore");
$: ({ broadcastTarget, broadcastText, broadcastBusy, broadcastResult } = $broadcastStore); $: ({ broadcastTarget, broadcastText, broadcastBusy, broadcastResult, broadcastCounts } =
$broadcastStore);
const BROADCAST_TARGET_OPTIONS = broadcastStore.BROADCAST_TARGET_OPTIONS; const BROADCAST_TARGET_OPTIONS = broadcastStore.BROADCAST_TARGET_OPTIONS;
// Append the resolved audience size to each option once counts are loaded.
$: targetOptions = BROADCAST_TARGET_OPTIONS.map((option) => {
const count = broadcastCounts?.[option.value];
return count == null ? option : { ...option, label: `${option.label} (${count})` };
});
onMount(() => {
broadcastStore.loadCounts();
});
</script> </script>
<div class="admin-card"> <div class="admin-card">
@@ -24,7 +35,7 @@
<span>{at("broadcast_label_audience", {}, "Аудитория")}</span> <span>{at("broadcast_label_audience", {}, "Аудитория")}</span>
<AdminSelect <AdminSelect
value={broadcastTarget} value={broadcastTarget}
items={BROADCAST_TARGET_OPTIONS} items={targetOptions}
ariaLabel={at("broadcast_label_audience", {}, "Аудитория")} ariaLabel={at("broadcast_label_audience", {}, "Аудитория")}
onValueChange={(value) => broadcastStore.updateField({ broadcastTarget: value })} onValueChange={(value) => broadcastStore.updateField({ broadcastTarget: value })}
/> />
@@ -348,6 +348,7 @@
devices: "Устройства", devices: "Устройства",
subscription_guides: "Connection guides", subscription_guides: "Connection guides",
system: "Система", system: "Система",
migrations: "Миграции",
}; };
return adminText(`settings_section_${id}`, {}, map[id] || id); return adminText(`settings_section_${id}`, {}, map[id] || id);
} }
@@ -1,5 +1,5 @@
<script> <script>
import { Input } from "$components/ui/index.js"; import { Input, Sortable } 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";
@@ -32,16 +32,6 @@
})); }));
$: defaultCurrencyKey = normalizeCurrencyKey(tariffsCatalog?.default_currency || "rub"); $: defaultCurrencyKey = normalizeCurrencyKey(tariffsCatalog?.default_currency || "rub");
$: defaultCurrencyCode = defaultCurrencyKey.toUpperCase(); $: defaultCurrencyCode = defaultCurrencyKey.toUpperCase();
$: currencyPackageLabel = at(
"tariff_btn_package_currency",
{ currency: defaultCurrencyCode },
`Пакет ${defaultCurrencyCode}`
);
$: currencyPaymentLabel = at(
"payment_default_currency",
{ currency: defaultCurrencyCode },
`Оплата ${defaultCurrencyCode}`
);
$: currencyPriceColumnLabel = at( $: currencyPriceColumnLabel = at(
"tariff_col_price_currency", "tariff_col_price_currency",
{ currency: defaultCurrencyCode }, { currency: defaultCurrencyCode },
@@ -413,103 +403,71 @@
> >
</div> </div>
<div class="admin-editor-section-actions"> <div class="admin-editor-section-actions">
<AdminButton
size="sm"
onclick={() => tariffsStore.addDraftRow("premiumTopupRubRows", { gb: 10, price: "" })}
><Plus size={12} /> {currencyPackageLabel}</AdminButton
>
<AdminButton <AdminButton
size="sm" size="sm"
onclick={() => onclick={() =>
tariffsStore.addDraftRow("premiumTopupStarsRows", { gb: 10, price: "" })} tariffsStore.addDraftRow("premiumTopupRows", { gb: 10, price: "", stars: "" })}
><Plus size={12} /> {at("tariff_btn_package_stars", {}, "Пакет")}</AdminButton ><Plus size={12} /> {at("tariff_btn_package", {}, "Пакет")}</AdminButton
> >
</div> </div>
</header> </header>
<div class="admin-package-columns"> {#if tariffDraft.premiumTopupRows.length}
<div class="admin-row-editor"> <div class="admin-row-editor">
<span class="admin-row-editor-caption">{currencyPaymentLabel}</span> <div class="admin-row-editor-line admin-row-editor-drag admin-row-editor-header">
{#if tariffDraft.premiumTopupRubRows.length} <span></span>
<div class="admin-row-editor-line admin-row-editor-header"> <span>{at("tariff_col_volume_gb", {}, "Объём, GB")}</span>
<span>{at("tariff_col_volume_gb", {}, "Объём, GB")}</span> <span>{currencyPriceColumnLabel}</span>
<span>{currencyPriceColumnLabel}</span> <span>{at("tariff_col_price_stars_full", {}, "Цена, ⭐ Stars")}</span>
<span></span> <span></span>
</div> </div>
{/if} <Sortable
{#each tariffDraft.premiumTopupRubRows as row, index} items={tariffDraft.premiumTopupRows}
<div class="admin-row-editor-line"> class="admin-row-editor-line admin-row-editor-drag"
<Input handleLabel={at("tariff_package_reorder", {}, "Перетащите, чтобы изменить порядок")}
class="input" onReorder={(from, to) => tariffsStore.moveDraftRow("premiumTopupRows", from, to)}
type="number" let:item={row}
min="0.1" let:index
step="0.1"
placeholder="10"
bind:value={row.gb}
aria-label={at("tariff_col_volume_gb", {}, "Объём premium-пакета в GB")}
/>
<Input
class="input"
type="number"
min="0"
step="0.01"
placeholder="199"
bind:value={row.price}
aria-label={currencyPriceAriaLabel}
/>
<AdminButton
size="sm"
variant="danger"
onclick={() => tariffsStore.removeDraftRow("premiumTopupRubRows", index)}
aria-label={at("btn_delete", {}, "Удалить")}><Trash2 size={13} /></AdminButton
>
</div>
{/each}
</div>
<div class="admin-row-editor">
<span class="admin-row-editor-caption"
>{at("payment_stars", {}, "Оплата Telegram Stars")}</span
> >
{#if tariffDraft.premiumTopupStarsRows.length} <Input
<div class="admin-row-editor-line admin-row-editor-header"> class="input"
<span>{at("tariff_col_volume_gb", {}, "Объём, GB")}</span> type="number"
<span>{at("tariff_col_price_stars", {}, "Цена, ⭐")}</span> min="0.1"
<span></span> step="0.1"
</div> placeholder="10"
{/if} bind:value={row.gb}
{#each tariffDraft.premiumTopupStarsRows as row, index} aria-label={at("tariff_col_volume_gb", {}, "Объём premium-пакета в GB")}
<div class="admin-row-editor-line"> />
<Input <Input
class="input" class="input"
type="number" type="number"
min="0.1" min="0"
step="0.1" step="0.01"
placeholder="10" placeholder="199"
bind:value={row.gb} bind:value={row.price}
aria-label={at("tariff_col_volume_gb", {}, "Объём premium-пакета в GB")} aria-label={currencyPriceAriaLabel}
/> />
<Input <Input
class="input" class="input"
type="number" type="number"
min="0" min="0"
step="1" step="1"
placeholder="100" placeholder="100"
bind:value={row.price} bind:value={row.stars}
aria-label={at( aria-label={at(
"tariff_label_price_stars", "tariff_label_price_stars",
{}, {},
"Цена premium-пакета в Telegram Stars" "Цена premium-пакета в Telegram Stars"
)} )}
/> />
<AdminButton <AdminButton
size="sm" size="sm"
variant="danger" variant="danger"
onclick={() => tariffsStore.removeDraftRow("premiumTopupStarsRows", index)} onclick={() => tariffsStore.removeDraftRow("premiumTopupRows", index)}
aria-label={at("btn_delete", {}, "Удалить")}><Trash2 size={13} /></AdminButton aria-label={at("btn_delete", {}, "Удалить")}><Trash2 size={13} /></AdminButton
> >
</div> </Sortable>
{/each}
</div> </div>
</div> {/if}
</section> </section>
</Tabs.Content> </Tabs.Content>
@@ -552,7 +510,8 @@
</p> </p>
{:else} {:else}
<div class="admin-row-editor"> <div class="admin-row-editor">
<div class="admin-row-editor-line admin-row-editor-6 admin-row-editor-header"> <div class="admin-row-editor-line admin-row-editor-period admin-row-editor-header">
<span></span>
<span>{at("tariff_col_period_months", {}, "Срок, мес.")}</span> <span>{at("tariff_col_period_months", {}, "Срок, мес.")}</span>
<span>{currencyPriceColumnLabel}</span> <span>{currencyPriceColumnLabel}</span>
<span>{at("tariff_col_price_stars_full", {}, "Цена, ⭐ Stars")}</span> <span>{at("tariff_col_price_stars_full", {}, "Цена, ⭐ Stars")}</span>
@@ -560,62 +519,67 @@
<span>{at("tariff_col_ref_referee", {}, "Бонус приглашённому")}</span> <span>{at("tariff_col_ref_referee", {}, "Бонус приглашённому")}</span>
<span></span> <span></span>
</div> </div>
{#each tariffDraft.periodRows as row, index} <Sortable
<div class="admin-row-editor-line admin-row-editor-6"> items={tariffDraft.periodRows}
<Input class="admin-row-editor-line admin-row-editor-period"
class="input" handleLabel={at("tariff_period_reorder", {}, "Перетащите, чтобы изменить порядок")}
type="number" onReorder={(from, to) => tariffsStore.moveDraftRow("periodRows", from, to)}
min="1" let:item={row}
placeholder="1" let:index
bind:value={row.months} >
aria-label={at("tariff_col_period_months", {}, "Срок (месяцы)")} <Input
/> class="input"
<Input type="number"
class="input" min="1"
type="number" placeholder="1"
min="0" bind:value={row.months}
step="0.01" aria-label={at("tariff_col_period_months", {}, "Срок (месяцы)")}
placeholder="299" />
bind:value={row.rub} <Input
aria-label={currencyPriceAriaLabel} class="input"
/> type="number"
<Input min="0"
class="input" step="0.01"
type="number" placeholder="299"
min="0" bind:value={row.rub}
step="1" aria-label={currencyPriceAriaLabel}
placeholder="150" />
bind:value={row.stars} <Input
aria-label={at("tariff_label_price_stars", {}, "Цена в Telegram Stars")} class="input"
/> type="number"
<Input min="0"
class="input" step="1"
type="number" placeholder="150"
min="0" bind:value={row.stars}
step="1" aria-label={at("tariff_label_price_stars", {}, "Цена в Telegram Stars")}
placeholder="3" />
bind:value={row.referral_inviter} <Input
aria-label={at("tariff_label_ref_inviter", {}, "Бонус приглашающему")} class="input"
/> type="number"
<Input min="0"
class="input" step="1"
type="number" placeholder="3"
min="0" bind:value={row.referral_inviter}
step="1" aria-label={at("tariff_label_ref_inviter", {}, "Бонус приглашающему")}
placeholder="1" />
bind:value={row.referral_referee} <Input
aria-label={at("tariff_label_ref_referee", {}, "Бонус приглашённому")} class="input"
/> type="number"
<AdminButton min="0"
size="sm" step="1"
variant="danger" placeholder="1"
onclick={() => tariffsStore.removeDraftRow("periodRows", index)} bind:value={row.referral_referee}
aria-label={at("btn_delete", {}, "Удалить")} aria-label={at("tariff_label_ref_referee", {}, "Бонус приглашённому")}
> />
<Trash2 size={13} /> <AdminButton
</AdminButton> size="sm"
</div> variant="danger"
{/each} onclick={() => tariffsStore.removeDraftRow("periodRows", index)}
aria-label={at("btn_delete", {}, "Удалить")}
>
<Trash2 size={13} />
</AdminButton>
</Sortable>
</div> </div>
{/if} {/if}
</section> </section>
@@ -635,96 +599,65 @@
<div class="admin-editor-section-actions"> <div class="admin-editor-section-actions">
<AdminButton <AdminButton
size="sm" size="sm"
onclick={() => tariffsStore.addDraftRow("trafficRubRows", { gb: 10, price: "" })} onclick={() =>
><Plus size={12} /> {currencyPackageLabel}</AdminButton tariffsStore.addDraftRow("trafficRows", { gb: 10, price: "", stars: "" })}
> ><Plus size={12} /> {at("tariff_btn_package", {}, "Пакет")}</AdminButton
<AdminButton
size="sm"
onclick={() => tariffsStore.addDraftRow("trafficStarsRows", { gb: 10, price: "" })}
><Plus size={12} /> {at("tariff_btn_package_stars", {}, "Пакет ⭐")}</AdminButton
> >
</div> </div>
</header> </header>
<div class="admin-package-columns"> {#if tariffDraft.trafficRows.length}
<div class="admin-row-editor"> <div class="admin-row-editor">
<span class="admin-row-editor-caption">{currencyPaymentLabel}</span> <div class="admin-row-editor-line admin-row-editor-drag admin-row-editor-header">
{#if tariffDraft.trafficRubRows.length} <span></span>
<div class="admin-row-editor-line admin-row-editor-header"> <span>{at("tariff_col_volume_gb", {}, "Объём, GB")}</span>
<span>{at("tariff_col_volume_gb", {}, "Объём, GB")}</span> <span>{currencyPriceColumnLabel}</span>
<span>{currencyPriceColumnLabel}</span> <span>{at("tariff_col_price_stars_full", {}, "Цена, ⭐ Stars")}</span>
<span></span> <span></span>
</div> </div>
{/if} <Sortable
{#each tariffDraft.trafficRubRows as row, index} items={tariffDraft.trafficRows}
<div class="admin-row-editor-line"> class="admin-row-editor-line admin-row-editor-drag"
<Input handleLabel={at("tariff_package_reorder", {}, "Перетащите, чтобы изменить порядок")}
class="input" onReorder={(from, to) => tariffsStore.moveDraftRow("trafficRows", from, to)}
type="number" let:item={row}
min="0.1" let:index
step="0.1"
placeholder="50"
bind:value={row.gb}
aria-label={at("tariff_col_volume_gb", {}, "Объём пакета в GB")}
/>
<Input
class="input"
type="number"
min="0"
step="0.01"
placeholder="299"
bind:value={row.price}
aria-label={currencyPriceAriaLabel}
/>
<AdminButton
size="sm"
variant="danger"
onclick={() => tariffsStore.removeDraftRow("trafficRubRows", index)}
aria-label={at("btn_delete", {}, "Удалить")}><Trash2 size={13} /></AdminButton
>
</div>
{/each}
</div>
<div class="admin-row-editor">
<span class="admin-row-editor-caption"
>{at("payment_stars", {}, "Оплата Telegram Stars")}</span
> >
{#if tariffDraft.trafficStarsRows.length} <Input
<div class="admin-row-editor-line admin-row-editor-header"> class="input"
<span>{at("tariff_col_volume_gb", {}, "Объём, GB")}</span> type="number"
<span>{at("tariff_col_price_stars", {}, "Цена, ⭐")}</span> min="0.1"
<span></span> step="0.1"
</div> placeholder="50"
{/if} bind:value={row.gb}
{#each tariffDraft.trafficStarsRows as row, index} aria-label={at("tariff_col_volume_gb", {}, "Объём пакета в GB")}
<div class="admin-row-editor-line"> />
<Input <Input
class="input" class="input"
type="number" type="number"
min="0.1" min="0"
step="0.1" step="0.01"
placeholder="50" placeholder="299"
bind:value={row.gb} bind:value={row.price}
aria-label={at("tariff_col_volume_gb", {}, "Объём пакета в GB")} aria-label={currencyPriceAriaLabel}
/> />
<Input <Input
class="input" class="input"
type="number" type="number"
min="0" min="0"
step="1" step="1"
placeholder="150" placeholder="150"
bind:value={row.price} bind:value={row.stars}
aria-label={at("tariff_label_price_stars", {}, "Цена пакета в Telegram Stars")} aria-label={at("tariff_label_price_stars", {}, "Цена пакета в Telegram Stars")}
/> />
<AdminButton <AdminButton
size="sm" size="sm"
variant="danger" variant="danger"
onclick={() => tariffsStore.removeDraftRow("trafficStarsRows", index)} onclick={() => tariffsStore.removeDraftRow("trafficRows", index)}
aria-label={at("btn_delete", {}, "Удалить")}><Trash2 size={13} /></AdminButton aria-label={at("btn_delete", {}, "Удалить")}><Trash2 size={13} /></AdminButton
> >
</div> </Sortable>
{/each}
</div> </div>
</div> {/if}
</section> </section>
{/if} {/if}
</Tabs.Content> </Tabs.Content>
@@ -748,96 +681,65 @@
<div class="admin-editor-section-actions"> <div class="admin-editor-section-actions">
<AdminButton <AdminButton
size="sm" size="sm"
onclick={() => tariffsStore.addDraftRow("topupRubRows", { gb: 10, price: "" })} onclick={() =>
><Plus size={12} /> {currencyPackageLabel}</AdminButton tariffsStore.addDraftRow("topupRows", { gb: 10, price: "", stars: "" })}
> ><Plus size={12} /> {at("tariff_btn_package", {}, "Пакет")}</AdminButton
<AdminButton
size="sm"
onclick={() => tariffsStore.addDraftRow("topupStarsRows", { gb: 10, price: "" })}
><Plus size={12} /> {at("tariff_btn_package_stars", {}, "Пакет ⭐")}</AdminButton
> >
</div> </div>
</header> </header>
<div class="admin-package-columns"> {#if tariffDraft.topupRows.length}
<div class="admin-row-editor"> <div class="admin-row-editor">
<span class="admin-row-editor-caption">{currencyPaymentLabel}</span> <div class="admin-row-editor-line admin-row-editor-drag admin-row-editor-header">
{#if tariffDraft.topupRubRows.length} <span></span>
<div class="admin-row-editor-line admin-row-editor-header"> <span>{at("tariff_col_volume_gb", {}, "Объём, GB")}</span>
<span>{at("tariff_col_volume_gb", {}, "Объём, GB")}</span> <span>{currencyPriceColumnLabel}</span>
<span>{currencyPriceColumnLabel}</span> <span>{at("tariff_col_price_stars_full", {}, "Цена, ⭐ Stars")}</span>
<span></span> <span></span>
</div> </div>
{/if} <Sortable
{#each tariffDraft.topupRubRows as row, index} items={tariffDraft.topupRows}
<div class="admin-row-editor-line"> class="admin-row-editor-line admin-row-editor-drag"
<Input handleLabel={at("tariff_package_reorder", {}, "Перетащите, чтобы изменить порядок")}
class="input" onReorder={(from, to) => tariffsStore.moveDraftRow("topupRows", from, to)}
type="number" let:item={row}
min="0.1" let:index
step="0.1"
placeholder="20"
bind:value={row.gb}
aria-label={at("tariff_col_volume_gb", {}, "Объём пакета в GB")}
/>
<Input
class="input"
type="number"
min="0"
step="0.01"
placeholder="149"
bind:value={row.price}
aria-label={currencyPriceAriaLabel}
/>
<AdminButton
size="sm"
variant="danger"
onclick={() => tariffsStore.removeDraftRow("topupRubRows", index)}
aria-label={at("btn_delete", {}, "Удалить")}><Trash2 size={13} /></AdminButton
>
</div>
{/each}
</div>
<div class="admin-row-editor">
<span class="admin-row-editor-caption"
>{at("payment_stars", {}, "Оплата Telegram Stars")}</span
> >
{#if tariffDraft.topupStarsRows.length} <Input
<div class="admin-row-editor-line admin-row-editor-header"> class="input"
<span>{at("tariff_col_volume_gb", {}, "Объём, GB")}</span> type="number"
<span>{at("tariff_col_price_stars", {}, "Цена, ⭐")}</span> min="0.1"
<span></span> step="0.1"
</div> placeholder="20"
{/if} bind:value={row.gb}
{#each tariffDraft.topupStarsRows as row, index} aria-label={at("tariff_col_volume_gb", {}, "Объём пакета в GB")}
<div class="admin-row-editor-line"> />
<Input <Input
class="input" class="input"
type="number" type="number"
min="0.1" min="0"
step="0.1" step="0.01"
placeholder="20" placeholder="149"
bind:value={row.gb} bind:value={row.price}
aria-label={at("tariff_col_volume_gb", {}, "Объём пакета в GB")} aria-label={currencyPriceAriaLabel}
/> />
<Input <Input
class="input" class="input"
type="number" type="number"
min="0" min="0"
step="1" step="1"
placeholder="75" placeholder="75"
bind:value={row.price} bind:value={row.stars}
aria-label={at("tariff_label_price_stars", {}, "Цена пакета в Telegram Stars")} aria-label={at("tariff_label_price_stars", {}, "Цена пакета в Telegram Stars")}
/> />
<AdminButton <AdminButton
size="sm" size="sm"
variant="danger" variant="danger"
onclick={() => tariffsStore.removeDraftRow("topupStarsRows", index)} onclick={() => tariffsStore.removeDraftRow("topupRows", index)}
aria-label={at("btn_delete", {}, "Удалить")}><Trash2 size={13} /></AdminButton aria-label={at("btn_delete", {}, "Удалить")}><Trash2 size={13} /></AdminButton
> >
</div> </Sortable>
{/each}
</div> </div>
</div> {/if}
</section> </section>
{:else} {:else}
<p class="admin-muted"> <p class="admin-muted">
@@ -872,104 +774,69 @@
<div class="admin-editor-section-actions"> <div class="admin-editor-section-actions">
<AdminButton <AdminButton
size="sm" size="sm"
onclick={() => tariffsStore.addDraftRow("hwidRubRows", { count: 1, price: "" })} onclick={() =>
><Plus size={12} /> {currencyPackageLabel}</AdminButton tariffsStore.addDraftRow("hwidRows", { count: 1, price: "", stars: "" })}
> ><Plus size={12} /> {at("tariff_btn_package", {}, "Пакет")}</AdminButton
<AdminButton
size="sm"
onclick={() => tariffsStore.addDraftRow("hwidStarsRows", { count: 1, price: "" })}
><Plus size={12} /> {at("tariff_btn_package_stars", {}, "Пакет ⭐")}</AdminButton
> >
</div> </div>
</header> </header>
<div class="admin-package-columns"> {#if tariffDraft.hwidRows.length}
<div class="admin-row-editor"> <div class="admin-row-editor">
<span class="admin-row-editor-caption">{currencyPaymentLabel}</span> <div class="admin-row-editor-line admin-row-editor-drag admin-row-editor-header">
{#if tariffDraft.hwidRubRows.length} <span></span>
<div class="admin-row-editor-line admin-row-editor-header"> <span>{at("tariff_col_hwid_count", {}, "+ устройств")}</span>
<span>{at("tariff_col_hwid_count", {}, "+ устройств")}</span> <span>{currencyPriceColumnLabel}</span>
<span>{currencyPriceColumnLabel}</span> <span>{at("tariff_col_price_stars_full", {}, "Цена, ⭐ Stars")}</span>
<span></span> <span></span>
</div> </div>
{/if} <Sortable
{#each tariffDraft.hwidRubRows as row, index} items={tariffDraft.hwidRows}
<div class="admin-row-editor-line"> class="admin-row-editor-line admin-row-editor-drag"
<Input handleLabel={at("tariff_package_reorder", {}, "Перетащите, чтобы изменить порядок")}
class="input" onReorder={(from, to) => tariffsStore.moveDraftRow("hwidRows", from, to)}
type="number" let:item={row}
min="1" let:index
step="1"
placeholder="1"
bind:value={row.count}
aria-label={at(
"tariff_label_hwid_count_full",
{},
"Сколько устройств добавляет пакет"
)}
/>
<Input
class="input"
type="number"
min="0"
step="0.01"
placeholder="99"
bind:value={row.price}
aria-label={currencyPriceAriaLabel}
/>
<AdminButton
size="sm"
variant="danger"
onclick={() => tariffsStore.removeDraftRow("hwidRubRows", index)}
aria-label={at("btn_delete", {}, "Удалить")}><Trash2 size={13} /></AdminButton
>
</div>
{/each}
</div>
<div class="admin-row-editor">
<span class="admin-row-editor-caption"
>{at("payment_stars", {}, "Оплата Telegram Stars")}</span
> >
{#if tariffDraft.hwidStarsRows.length} <Input
<div class="admin-row-editor-line admin-row-editor-header"> class="input"
<span>{at("tariff_col_hwid_count", {}, "+ устройств")}</span> type="number"
<span>{at("tariff_col_price_stars", {}, "Цена, ⭐")}</span> min="1"
<span></span> step="1"
</div> placeholder="1"
{/if} bind:value={row.count}
{#each tariffDraft.hwidStarsRows as row, index} aria-label={at(
<div class="admin-row-editor-line"> "tariff_label_hwid_count_full",
<Input {},
class="input" "Сколько устройств добавляет пакет"
type="number" )}
min="1" />
step="1" <Input
placeholder="1" class="input"
bind:value={row.count} type="number"
aria-label={at( min="0"
"tariff_label_hwid_count_full", step="0.01"
{}, placeholder="99"
"Сколько устройств добавляет пакет" bind:value={row.price}
)} aria-label={currencyPriceAriaLabel}
/> />
<Input <Input
class="input" class="input"
type="number" type="number"
min="0" min="0"
step="1" step="1"
placeholder="50" placeholder="50"
bind:value={row.price} bind:value={row.stars}
aria-label={at("tariff_label_price_stars", {}, "Цена пакета в Telegram Stars")} aria-label={at("tariff_label_price_stars", {}, "Цена пакета в Telegram Stars")}
/> />
<AdminButton <AdminButton
size="sm" size="sm"
variant="danger" variant="danger"
onclick={() => tariffsStore.removeDraftRow("hwidStarsRows", index)} onclick={() => tariffsStore.removeDraftRow("hwidRows", index)}
aria-label={at("btn_delete", {}, "Удалить")}><Trash2 size={13} /></AdminButton aria-label={at("btn_delete", {}, "Удалить")}><Trash2 size={13} /></AdminButton
> >
</div> </Sortable>
{/each}
</div> </div>
</div> {/if}
</section> </section>
</Tabs.Content> </Tabs.Content>
</Tabs.Root> </Tabs.Root>
@@ -1,5 +1,5 @@
<script> <script>
import { Input } from "$components/ui/index.js"; import { Input, Textarea } from "$components/ui/index.js";
import { import {
ChevronRight, ChevronRight,
RefreshCw, RefreshCw,
@@ -31,12 +31,30 @@
"TRIAL_DURATION_DAYS", "TRIAL_DURATION_DAYS",
"TRIAL_TRAFFIC_LIMIT_GB", "TRIAL_TRAFFIC_LIMIT_GB",
"TRIAL_TRAFFIC_STRATEGY", "TRIAL_TRAFFIC_STRATEGY",
"TRIAL_WITHOUT_TELEGRAM_ENABLED",
"TRIAL_SQUAD_UUIDS", "TRIAL_SQUAD_UUIDS",
]; ];
const TRIAL_SWITCH_KEYS = ["TRIAL_ENABLED"]; const TRIAL_SWITCH_KEYS = ["TRIAL_ENABLED", "TRIAL_WITHOUT_TELEGRAM_ENABLED"];
const TRIAL_GENERAL_KEYS = ["TRIAL_DURATION_DAYS", "TRIAL_TRAFFIC_LIMIT_GB"]; const TRIAL_GENERAL_KEYS = ["TRIAL_DURATION_DAYS", "TRIAL_TRAFFIC_LIMIT_GB"];
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 REFERRAL_SETTING_KEYS = [
"REFERRAL_WELCOME_BONUS_DAYS",
"REFERRAL_WELCOME_BONUS_WITHOUT_TELEGRAM_ENABLED",
"REFERRAL_ONE_BONUS_PER_REFEREE",
"LEGACY_REFS",
"DISPOSABLE_EMAIL_DOMAINS",
];
const REFERRAL_WELCOME_KEYS = [
"REFERRAL_WELCOME_BONUS_DAYS",
"REFERRAL_WELCOME_BONUS_WITHOUT_TELEGRAM_ENABLED",
];
const REFERRAL_RULE_KEYS = [
"REFERRAL_ONE_BONUS_PER_REFEREE",
"LEGACY_REFS",
"DISPOSABLE_EMAIL_DOMAINS",
];
const DISPOSABLE_EMAIL_DOMAINS_PLACEHOLDER = "mailinator.com\ntemp-mail.org\nyopmail.com";
const LEGACY_PERIODS = [ const LEGACY_PERIODS = [
[ [
"1", "1",
@@ -102,6 +120,7 @@
.map((field) => [field.key, field]) .map((field) => [field.key, field])
); );
$: trialDirtyCount = TRIAL_SETTING_KEYS.filter((key) => Boolean(settingsDirty[key])).length; $: trialDirtyCount = TRIAL_SETTING_KEYS.filter((key) => Boolean(settingsDirty[key])).length;
$: referralDirtyCount = REFERRAL_SETTING_KEYS.filter((key) => Boolean(settingsDirty[key])).length;
$: legacyDirtyCount = LEGACY_TARIFF_SETTING_KEYS.filter((key) => $: legacyDirtyCount = LEGACY_TARIFF_SETTING_KEYS.filter((key) =>
Boolean(settingsDirty[key]) Boolean(settingsDirty[key])
).length; ).length;
@@ -129,7 +148,7 @@
? `${first.gb} GB ${at("at", {}, "за")} ${fmtMoney(first.price, currencyCode)}` ? `${first.gb} GB ${at("at", {}, "за")} ${fmtMoney(first.price, currencyCode)}`
: at("tariff_traffic_packages", {}, "Пакеты трафика"); : at("tariff_traffic_packages", {}, "Пакеты трафика");
} }
const months = [...(tariff.enabled_periods || [])].sort((a, b) => a - b); const months = [...(tariff.enabled_periods || [])];
return months return months
.map((month) => { .map((month) => {
const rub = const rub =
@@ -410,6 +429,53 @@
{/if} {/if}
</div> </div>
</div> </div>
<div
class="admin-setting admin-trial-setting-row"
class:is-dirty={isSettingDirty("TRIAL_WITHOUT_TELEGRAM_ENABLED", settingsDirty)}
>
<div class="admin-setting-meta">
<strong>
{at("tariffs_trial_without_telegram_enabled", {}, "Триал без Telegram")}
{#if isSettingDirty("TRIAL_WITHOUT_TELEGRAM_ENABLED", settingsDirty)}
<AdminBadge variant="warning"
>{at("settings_badge_dirty", {}, "Изменено")}</AdminBadge
>
{/if}
</strong>
<code>TRIAL_WITHOUT_TELEGRAM_ENABLED</code>
</div>
<div class="admin-setting-control">
<div class="admin-setting-switch">
<Switch.Root
checked={boolValue(
"TRIAL_WITHOUT_TELEGRAM_ENABLED",
settingsDirty,
settingsFieldMap
)}
onCheckedChange={(checked) =>
setSetting("TRIAL_WITHOUT_TELEGRAM_ENABLED", checked)}
class="admin-switch-root"
>
<Switch.Thumb class="admin-switch-thumb" />
</Switch.Root>
<span
>{boolValue("TRIAL_WITHOUT_TELEGRAM_ENABLED", settingsDirty, settingsFieldMap)
? at("enabled", {}, "Включено")
: at("disabled", {}, "Выключено")}</span
>
</div>
{#if isSettingDirty("TRIAL_WITHOUT_TELEGRAM_ENABLED", settingsDirty)}
<AdminButton
size="sm"
variant="ghost"
onclick={() => resetSetting("TRIAL_WITHOUT_TELEGRAM_ENABLED")}
>
<X size={12} />
{at("reset", {}, "Сбросить")}
</AdminButton>
{/if}
</div>
</div>
</div> </div>
</section> </section>
@@ -677,6 +743,352 @@
</div> </div>
</article> </article>
<article class="admin-card admin-tariff-settings-card">
<header class="admin-card-head">
<div>
<h3>{at("tariffs_referral_title", {}, "Реферальная программа")}</h3>
<small>
{at(
"tariffs_referral_subtitle",
{},
"Настройки приветственного бонуса, правил начисления и защиты от одноразовых email."
)}
</small>
</div>
<div class="admin-editor-section-actions">
<AdminBadge
variant={Number(
valueForKey("REFERRAL_WELCOME_BONUS_DAYS", settingsDirty, settingsFieldMap) || 0
) > 0
? "success"
: "muted"}
>
{Number(
valueForKey("REFERRAL_WELCOME_BONUS_DAYS", settingsDirty, settingsFieldMap) || 0
) > 0
? at("enabled", {}, "Включено")
: at("disabled", {}, "Выключено")}
</AdminBadge>
{#if referralDirtyCount}
<AdminBadge variant="warning">
{at(
"settings_dirty_count",
{ count: referralDirtyCount },
`Изменений: ${referralDirtyCount}`
)}
</AdminBadge>
<AdminButton
size="sm"
variant="primary"
onclick={saveTariffSettings}
disabled={settingsSaving}
>
<Save size={13} />
{settingsSaving
? at("btn_saving", {}, "Сохранение...")
: at("btn_save", {}, "Сохранить")}
</AdminButton>
{/if}
</div>
</header>
<div class="admin-card-body admin-trial-settings-body">
<div class="admin-settings-field-groups admin-trial-settings-groups">
<section
class="admin-settings-field-group"
class:is-dirty={dirtyCount(REFERRAL_WELCOME_KEYS, settingsDirty)}
>
<header class="admin-settings-field-group-head">
<div class="admin-settings-field-group-head-copy">
<strong>{at("tariffs_referral_group_welcome", {}, "Приветственный бонус")}</strong>
<small>
{at(
"tariffs_referral_group_welcome_hint",
{},
"Дни, которые получает приглашённый пользователь после регистрации по ссылке."
)}
</small>
</div>
{#if dirtyCount(REFERRAL_WELCOME_KEYS, settingsDirty)}
<AdminBadge variant="warning">
{at(
"settings_dirty_count",
{ count: dirtyCount(REFERRAL_WELCOME_KEYS, settingsDirty) },
`Изменений: ${dirtyCount(REFERRAL_WELCOME_KEYS, settingsDirty)}`
)}
</AdminBadge>
{/if}
</header>
<div class="admin-settings-field-group-body">
<div
class="admin-setting admin-trial-setting-row"
class:is-dirty={isSettingDirty("REFERRAL_WELCOME_BONUS_DAYS", settingsDirty)}
>
<div class="admin-setting-meta">
<strong>
{at("tariffs_referral_welcome_bonus_days", {}, "Приветственный бонус, дней")}
{#if isSettingDirty("REFERRAL_WELCOME_BONUS_DAYS", settingsDirty)}
<AdminBadge variant="warning"
>{at("settings_badge_dirty", {}, "Изменено")}</AdminBadge
>
{/if}
</strong>
<code>REFERRAL_WELCOME_BONUS_DAYS</code>
</div>
<div class="admin-setting-control">
<Input
class="input"
type="number"
min="0"
step="1"
value={valueForKey(
"REFERRAL_WELCOME_BONUS_DAYS",
settingsDirty,
settingsFieldMap
)}
oninput={(event) =>
setSetting("REFERRAL_WELCOME_BONUS_DAYS", event.currentTarget.value)}
/>
{#if isSettingDirty("REFERRAL_WELCOME_BONUS_DAYS", settingsDirty)}
<AdminButton
size="sm"
variant="ghost"
onclick={() => resetSetting("REFERRAL_WELCOME_BONUS_DAYS")}
>
<X size={12} />
{at("reset", {}, "Сбросить")}
</AdminButton>
{/if}
</div>
</div>
<div
class="admin-setting admin-trial-setting-row"
class:is-dirty={isSettingDirty(
"REFERRAL_WELCOME_BONUS_WITHOUT_TELEGRAM_ENABLED",
settingsDirty
)}
>
<div class="admin-setting-meta">
<strong>
{at(
"tariffs_referral_without_telegram",
{},
"Начислять welcome bonus без Telegram"
)}
{#if isSettingDirty("REFERRAL_WELCOME_BONUS_WITHOUT_TELEGRAM_ENABLED", settingsDirty)}
<AdminBadge variant="warning"
>{at("settings_badge_dirty", {}, "Изменено")}</AdminBadge
>
{/if}
</strong>
<code>REFERRAL_WELCOME_BONUS_WITHOUT_TELEGRAM_ENABLED</code>
</div>
<div class="admin-setting-control">
<div class="admin-setting-switch">
<Switch.Root
checked={boolValue(
"REFERRAL_WELCOME_BONUS_WITHOUT_TELEGRAM_ENABLED",
settingsDirty,
settingsFieldMap
)}
onCheckedChange={(checked) =>
setSetting("REFERRAL_WELCOME_BONUS_WITHOUT_TELEGRAM_ENABLED", checked)}
class="admin-switch-root"
>
<Switch.Thumb class="admin-switch-thumb" />
</Switch.Root>
<span
>{boolValue(
"REFERRAL_WELCOME_BONUS_WITHOUT_TELEGRAM_ENABLED",
settingsDirty,
settingsFieldMap
)
? at("enabled", {}, "Включено")
: at("disabled", {}, "Выключено")}</span
>
</div>
{#if isSettingDirty("REFERRAL_WELCOME_BONUS_WITHOUT_TELEGRAM_ENABLED", settingsDirty)}
<AdminButton
size="sm"
variant="ghost"
onclick={() => resetSetting("REFERRAL_WELCOME_BONUS_WITHOUT_TELEGRAM_ENABLED")}
>
<X size={12} />
{at("reset", {}, "Сбросить")}
</AdminButton>
{/if}
</div>
</div>
</div>
</section>
<section
class="admin-settings-field-group"
class:is-dirty={dirtyCount(REFERRAL_RULE_KEYS, settingsDirty)}
>
<header class="admin-settings-field-group-head">
<div class="admin-settings-field-group-head-copy">
<strong>{at("tariffs_referral_group_rules", {}, "Правила и антиабьюз")}</strong>
<small>
{at(
"tariffs_referral_group_rules_hint",
{},
"Ограничения повторных бонусов и домены одноразовой почты для no-Telegram аккаунтов."
)}
</small>
</div>
{#if dirtyCount(REFERRAL_RULE_KEYS, settingsDirty)}
<AdminBadge variant="warning">
{at(
"settings_dirty_count",
{ count: dirtyCount(REFERRAL_RULE_KEYS, settingsDirty) },
`Изменений: ${dirtyCount(REFERRAL_RULE_KEYS, settingsDirty)}`
)}
</AdminBadge>
{/if}
</header>
<div class="admin-settings-field-group-body">
<div
class="admin-setting admin-trial-setting-row"
class:is-dirty={isSettingDirty("REFERRAL_ONE_BONUS_PER_REFEREE", settingsDirty)}
>
<div class="admin-setting-meta">
<strong>
{at("tariffs_referral_one_bonus_per_referee", {}, "Один бонус на приглашённого")}
{#if isSettingDirty("REFERRAL_ONE_BONUS_PER_REFEREE", settingsDirty)}
<AdminBadge variant="warning"
>{at("settings_badge_dirty", {}, "Изменено")}</AdminBadge
>
{/if}
</strong>
<code>REFERRAL_ONE_BONUS_PER_REFEREE</code>
</div>
<div class="admin-setting-control">
<div class="admin-setting-switch">
<Switch.Root
checked={boolValue(
"REFERRAL_ONE_BONUS_PER_REFEREE",
settingsDirty,
settingsFieldMap
)}
onCheckedChange={(checked) =>
setSetting("REFERRAL_ONE_BONUS_PER_REFEREE", checked)}
class="admin-switch-root"
>
<Switch.Thumb class="admin-switch-thumb" />
</Switch.Root>
<span
>{boolValue("REFERRAL_ONE_BONUS_PER_REFEREE", settingsDirty, settingsFieldMap)
? at("enabled", {}, "Включено")
: at("disabled", {}, "Выключено")}</span
>
</div>
{#if isSettingDirty("REFERRAL_ONE_BONUS_PER_REFEREE", settingsDirty)}
<AdminButton
size="sm"
variant="ghost"
onclick={() => resetSetting("REFERRAL_ONE_BONUS_PER_REFEREE")}
>
<X size={12} />
{at("reset", {}, "Сбросить")}
</AdminButton>
{/if}
</div>
</div>
<div
class="admin-setting admin-trial-setting-row"
class:is-dirty={isSettingDirty("LEGACY_REFS", settingsDirty)}
>
<div class="admin-setting-meta">
<strong>
{at("tariffs_referral_legacy_refs", {}, "Старые ref-ссылки")}
{#if isSettingDirty("LEGACY_REFS", settingsDirty)}
<AdminBadge variant="warning"
>{at("settings_badge_dirty", {}, "Изменено")}</AdminBadge
>
{/if}
</strong>
<code>LEGACY_REFS</code>
</div>
<div class="admin-setting-control">
<div class="admin-setting-switch">
<Switch.Root
checked={boolValue("LEGACY_REFS", settingsDirty, settingsFieldMap)}
onCheckedChange={(checked) => setSetting("LEGACY_REFS", checked)}
class="admin-switch-root"
>
<Switch.Thumb class="admin-switch-thumb" />
</Switch.Root>
<span
>{boolValue("LEGACY_REFS", settingsDirty, settingsFieldMap)
? at("enabled", {}, "Включено")
: at("disabled", {}, "Выключено")}</span
>
</div>
{#if isSettingDirty("LEGACY_REFS", settingsDirty)}
<AdminButton
size="sm"
variant="ghost"
onclick={() => resetSetting("LEGACY_REFS")}
>
<X size={12} />
{at("reset", {}, "Сбросить")}
</AdminButton>
{/if}
</div>
</div>
<div
class="admin-setting admin-trial-setting-row"
class:is-dirty={isSettingDirty("DISPOSABLE_EMAIL_DOMAINS", settingsDirty)}
>
<div class="admin-setting-meta">
<strong>
{at("tariffs_referral_disposable_domains", {}, "Disposable email домены")}
{#if isSettingDirty("DISPOSABLE_EMAIL_DOMAINS", settingsDirty)}
<AdminBadge variant="warning"
>{at("settings_badge_dirty", {}, "Изменено")}</AdminBadge
>
{/if}
</strong>
<code>DISPOSABLE_EMAIL_DOMAINS</code>
<small>
{at(
"tariffs_referral_disposable_domains_hint",
{},
"По одному домену на строку или через запятую. Поддомены тоже считаются совпадением."
)}
</small>
</div>
<div class="admin-setting-control">
<Textarea
class="admin-setting-textarea"
rows="8"
placeholder={DISPOSABLE_EMAIL_DOMAINS_PLACEHOLDER}
value={valueForKey("DISPOSABLE_EMAIL_DOMAINS", settingsDirty, settingsFieldMap)}
oninput={(event) =>
setSetting("DISPOSABLE_EMAIL_DOMAINS", event.currentTarget.value)}
/>
{#if isSettingDirty("DISPOSABLE_EMAIL_DOMAINS", settingsDirty)}
<AdminButton
size="sm"
variant="ghost"
onclick={() => resetSetting("DISPOSABLE_EMAIL_DOMAINS")}
>
<X size={12} />
{at("reset", {}, "Сбросить")}
</AdminButton>
{/if}
</div>
</div>
</div>
</section>
</div>
</div>
</article>
<div class="admin-tariff-management"> <div class="admin-tariff-management">
<div class="admin-tariff-overview-grid"> <div class="admin-tariff-overview-grid">
<article class="admin-card admin-tariff-currency-card"> <article class="admin-card admin-tariff-currency-card">
@@ -53,6 +53,43 @@
return String(val ?? "—"); return String(val ?? "—");
} }
function isTrialSubscription(sub) {
return Boolean(sub?.is_trial || String(sub?.provider || "").toLowerCase() === "trial");
}
function subscriptionDisplayLabel(sub) {
if (!sub) return "—";
if (isTrialSubscription(sub)) return at("user_subscription_trial", {}, "Триал");
if (sub.display_label) return sub.display_label;
return sub.tariff_name || sub.tariff_key || at("user_history_no_tariff", {}, "Без тарифа");
}
function trialSummaryText(trial) {
if (!trial?.used) return at("user_trial_not_used", {}, "Не брал");
const date = trial.latest_activated_at || trial.first_activated_at;
const base = date
? at("user_trial_used_at", { date: fmtDate(date) }, `Брал ${fmtDate(date)}`)
: at("user_trial_used", {}, "Брал");
return trial.active ? `${base} · ${at("user_trial_active", {}, "активен")}` : base;
}
function hwidLimitLabel(sub) {
const rawBase = sub?.hwid_device_limit;
const hasBase = rawBase !== null && rawBase !== undefined;
const extra = Math.max(0, Number(sub?.extra_hwid_devices || 0));
if (!hasBase) return at("user_hwid_limit_default", {}, "Тарифный / default");
const base = Number(rawBase);
if (base === 0) return at("user_hwid_limit_unlimited", {}, "Безлимит");
if (extra > 0) {
return at(
"user_hwid_limit_with_extra",
{ base, extra, total: base + extra },
`${base + extra} (${base} + ${extra})`
);
}
return at("user_hwid_limit_count", { count: base }, `${base}`);
}
const usersStore = getContext("usersStore"); const usersStore = getContext("usersStore");
$: ({ $: ({
@@ -71,6 +108,7 @@
userReferralsPage, userReferralsPage,
userReferralsPageSize, userReferralsPageSize,
premiumUnlimitedDraft, premiumUnlimitedDraft,
hwidUnlimitedDraft,
userDetailTab, userDetailTab,
userLogs, userLogs,
userLogsTotal, userLogsTotal,
@@ -404,7 +442,7 @@
</li> </li>
<li> <li>
<span>{at("user_label_tariff", {}, "Тариф")}</span><strong <span>{at("user_label_tariff", {}, "Тариф")}</span><strong
>{openedUserDetail.active_subscription.tariff_key || "—"}</strong >{subscriptionDisplayLabel(openedUserDetail.active_subscription)}</strong
> >
</li> </li>
<li> <li>
@@ -417,6 +455,11 @@
>{openedUserDetail.active_subscription.provider || "—"}</strong >{openedUserDetail.active_subscription.provider || "—"}</strong
> >
</li> </li>
<li>
<span>{at("user_label_hwid_devices", {}, "HWID-устройства")}</span><strong
>{hwidLimitLabel(openedUserDetail.active_subscription)}</strong
>
</li>
</ul> </ul>
<div class="admin-traffic-summary"> <div class="admin-traffic-summary">
<AdminTrafficCard <AdminTrafficCard
@@ -507,6 +550,37 @@
</p> </p>
{/if} {/if}
{#if openedUserDetail?.trial}
<ul class="admin-meta-list">
<li>
<span>{at("user_label_trial", {}, "Пробник / триал")}</span><strong
>{trialSummaryText(openedUserDetail.trial)}</strong
>
</li>
{#if openedUserDetail.trial.used && openedUserDetail.trial.latest_end_date}
<li>
<span>{at("user_label_trial_until", {}, "Триал до")}</span><strong
>{fmtDate(openedUserDetail.trial.latest_end_date)}</strong
>
</li>
{/if}
{#if Number(openedUserDetail.trial.count || 0) > 1}
<li>
<span>{at("user_label_trial_count", {}, "Триалов")}</span><strong
>{openedUserDetail.trial.count}</strong
>
</li>
{/if}
{#if openedUserDetail.trial.last_reset_at}
<li>
<span>{at("user_label_trial_reset_at", {}, "Сброс триала")}</span><strong
>{fmtDate(openedUserDetail.trial.last_reset_at)}</strong
>
</li>
{/if}
</ul>
{/if}
{#if (openedUserDetail.subscriptions || []).length} {#if (openedUserDetail.subscriptions || []).length}
<Separator.Root class="admin-separator" /> <Separator.Root class="admin-separator" />
<div class="admin-subsection-title"> <div class="admin-subsection-title">
@@ -520,10 +594,7 @@
{#each openedUserDetail.subscriptions.slice(0, 8) as sub} {#each openedUserDetail.subscriptions.slice(0, 8) as sub}
<div class="admin-mini-list-row"> <div class="admin-mini-list-row">
<div> <div>
<strong <strong>{subscriptionDisplayLabel(sub)}</strong>
>{sub.tariff_key ||
at("user_history_no_tariff", {}, "Без тарифа")}</strong
>
<small <small
>{at( >{at(
"user_history_until", "user_history_until",
@@ -688,6 +759,41 @@
{at("user_btn_extend", {}, "Продлить")} {at("user_btn_extend", {}, "Продлить")}
</AdminButton> </AdminButton>
</div> </div>
{#if Number(openedUserDetail?.active_subscription?.extra_hwid_devices || 0) > 0}
<label class="admin-extend-hwid-option">
<Checkbox
bind:checked={$usersStore.userExtendHwidDevices}
disabled={userActionBusy}
ariaLabel={at(
"user_extend_hwid_devices_aria",
{},
"Продлить докупленные HWID-устройства"
)}
/>
<span>
<strong>
{at(
"user_extend_hwid_devices",
{
count: Number(
openedUserDetail.active_subscription.extra_hwid_devices || 0
),
},
`Продлить также +${Number(
openedUserDetail.active_subscription.extra_hwid_devices || 0
)} HWID-устройств`
)}
</strong>
<small>
{at(
"user_extend_hwid_devices_hint",
{},
"Срок действующих докупок увеличится на те же дни."
)}
</small>
</span>
</label>
{/if}
</Label.Root> </Label.Root>
</div> </div>
@@ -849,6 +955,66 @@
</div> </div>
</section> </section>
<section class="admin-user-action-sheet admin-user-action-sheet--hwid-limit">
<AdminSectionHeader
title={at("user_hwid_limit_card_title", {}, "HWID-устройства")}
description={at(
"user_hwid_limit_card_hint",
{},
"Ручной лимит устройств для пользователя. Пустое поле вернёт тарифный или default-лимит."
)}
/>
<div class="admin-user-action-sheet-body admin-user-override-stack">
<Label.Root class="admin-field-label admin-extend-field">
<span>{at("user_hwid_limit_input", {}, "Лимит устройств")}</span>
<small
>{at(
"user_hwid_limit_input_hint",
{},
"Пусто — тариф/default; 0 или галочка — безлимит."
)}</small
>
<Input
class="input"
type="number"
min="0"
step="1"
placeholder={at("user_hwid_limit_default_placeholder", {}, "Тариф")}
disabled={hwidUnlimitedDraft}
aria-label={at("user_hwid_limit_input", {}, "Лимит устройств")}
bind:value={$usersStore.hwidDeviceLimitDraft}
/>
</Label.Root>
</div>
<div class="admin-user-action-sheet-footer admin-override-card-footer">
<div class="admin-override-card-toolbar">
<label class="admin-override-unlimited-label">
<Checkbox
bind:checked={$usersStore.hwidUnlimitedDraft}
aria-label={at("user_override_unlimited_short", {}, "Безлимит")}
/>
<span>{at("user_override_unlimited_short", {}, "Безлимит")}</span>
</label>
<AdminButton
variant="primary"
onclick={usersStore.saveHwidDeviceLimit}
disabled={userActionBusy}
>
{at("user_hwid_limit_save", {}, "Сохранить")}
</AdminButton>
</div>
<div class="admin-override-status-lines">
<span class="admin-meta-truncate">
{at(
"user_hwid_limit_status",
{ current: hwidLimitLabel(openedUserDetail.active_subscription) },
`Сейчас: ${hwidLimitLabel(openedUserDetail.active_subscription)}`
)}
</span>
</div>
</div>
</section>
<section class="admin-user-action-sheet admin-user-action-sheet--traffic-grant"> <section class="admin-user-action-sheet admin-user-action-sheet--traffic-grant">
<AdminSectionHeader <AdminSectionHeader
title={at("user_traffic_grant_title", {}, "Выдать трафик")} title={at("user_traffic_grant_title", {}, "Выдать трафик")}
@@ -595,7 +595,7 @@
min-width: 82px; min-width: 82px;
} }
:global(.admin-users-filter-toggle) { :global(.admin-btn.admin-users-filter-toggle) {
display: none; display: none;
position: relative; position: relative;
align-items: center; align-items: center;
@@ -846,7 +846,7 @@
padding-inline: 10px; padding-inline: 10px;
} }
:global(.admin-users-filter-toggle) { :global(.admin-btn.admin-users-filter-toggle) {
display: inline-flex; display: inline-flex;
min-width: 38px; min-width: 38px;
padding-inline: 10px; padding-inline: 10px;
+7 -3
View File
@@ -29,9 +29,13 @@ function applyDemoThemeTokens(catalog) {
const themes = catalog?.themes || []; const themes = catalog?.themes || [];
for (const theme of themes) { for (const theme of themes) {
theme.tokens = theme.tokens || {}; theme.tokens = theme.tokens || {};
const currentScale = Number(theme.tokens.home_logo_scale); const logoScaleKeys = ["home_logo_scale_desktop", "home_logo_scale_mobile"];
if (!Number.isFinite(currentScale) || currentScale > DEMO_HOME_LOGO_SCALE) { for (const key of ["home_logo_scale", ...logoScaleKeys]) {
theme.tokens.home_logo_scale = DEMO_HOME_LOGO_SCALE; if (key !== "home_logo_scale" && !(key in theme.tokens)) continue;
const currentScale = Number(theme.tokens[key]);
if (!Number.isFinite(currentScale) || currentScale > DEMO_HOME_LOGO_SCALE) {
theme.tokens[key] = DEMO_HOME_LOGO_SCALE;
}
} }
} }
} }
@@ -6,6 +6,7 @@ export function createBroadcastStore({ api, onToast, at }) {
broadcastText: "", broadcastText: "",
broadcastBusy: false, broadcastBusy: false,
broadcastResult: null, broadcastResult: null,
broadcastCounts: null,
}); });
const BROADCAST_TARGET_OPTIONS = [ const BROADCAST_TARGET_OPTIONS = [
@@ -13,8 +14,23 @@ export function createBroadcastStore({ api, onToast, at }) {
{ value: "active", label: at("broadcast_target_active", {}, "С подпиской") }, { value: "active", label: at("broadcast_target_active", {}, "С подпиской") },
{ value: "inactive", label: at("broadcast_target_inactive", {}, "Без подписки") }, { value: "inactive", label: at("broadcast_target_inactive", {}, "Без подписки") },
{ value: "expired", label: at("broadcast_target_expired", {}, "Expired subscription") }, { value: "expired", label: at("broadcast_target_expired", {}, "Expired subscription") },
{
value: "never",
label: at("broadcast_target_never", {}, "Без подписки и без истории"),
},
]; ];
async function loadCounts() {
try {
const res = await api("/admin/broadcast/audience-counts");
if (res?.ok && res.counts) {
state.update((s) => ({ ...s, broadcastCounts: res.counts }));
}
} catch {
// Counts are advisory; ignore failures and keep plain labels.
}
}
async function runBroadcast() { async function runBroadcast() {
let text = ""; let text = "";
let target = ""; let target = "";
@@ -56,6 +72,7 @@ export function createBroadcastStore({ api, onToast, at }) {
update: state.update, update: state.update,
runBroadcast, runBroadcast,
updateField, updateField,
loadCounts,
BROADCAST_TARGET_OPTIONS, BROADCAST_TARGET_OPTIONS,
}; };
} }

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