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
# 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
# prove the Docker images still build.
# from feature/* branches): lint + format checks, a demo settings-manifest
# drift guard, and a no-push image build to prove the Docker images still build.
on:
pull_request:
@@ -56,6 +56,30 @@ jobs:
run: npm run format:check
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:
name: Docker build
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;
- встроенные инструкции установки в 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 и внешняя ссылка на поддержку;
- раздел "Мои устройства" при включенном `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-логина.
- [Поддержка пользователей / тикеты](docs/features/support.md) - тикеты в Mini App, входящий список админки, уведомления, лимиты и внешняя ссылка поддержки.
- [Темы Web App](docs/features/webapp-themes.md) - кастомные темы, настройка внешнего вида, логотипы, CSS/ассеты и пайплайн создания новой темы.
- [Миграции](docs/migrations/index.md) - готовые сценарии переноса с других ботов; сейчас описан `remnawave-tg-shop`.
- [Миграция с remnawave-tg-shop](docs/migrations/remnawave-tg-shop.md) - готовый сценарий для legacy-стека.
- [Миграции](docs/migrations/index.md) - готовые сценарии переноса с `remnawave-tg-shop` и Remnashop.
- [Миграция с 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).
В 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
mkdir -p data/themes data/webapp-logo data/webapp-emoji
mkdir -p data/themes data/webapp-logo
touch data/locales-overrides.json
chown -R 10001:10001 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()
if not 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"
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)
elif target == "expired":
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:
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})
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))
premium_unlimited_override = bool(getattr(sub, "premium_unlimited_override", False))
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 {
"subscription_id": int(sub.subscription_id),
"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,
"premium_unlimited_override": premium_unlimited_override,
"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,
"display_label": display_label,
"is_trial": is_trial,
"auto_renew_enabled": bool(sub.auto_renew_enabled),
"provider": sub.provider,
"provider": provider,
"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",
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(
"/api/admin/users/{user_id:-?\\d+}/traffic-grant",
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_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/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
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:
keep_logos = {
filename
@@ -156,15 +152,6 @@ def prune_unused_appearance_assets(settings: Settings) -> None:
]
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-*"):
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:
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.*"):
if path.is_file() and not any(
path.name.startswith(prefix) for prefix in keep_emoji_prefixes
):
if path.is_file():
try:
path.unlink()
except OSError:
@@ -389,7 +375,6 @@ async def admin_appearance_logo_upload_route(request: web.Request) -> web.Respon
request,
{
"WEBAPP_LOGO_URL": logo_url,
"WEBAPP_LOGO_USE_EMOJI": False,
**(
{"WEBAPP_LOGO_FAVICON_URL": favicon_payload["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)
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:
_require_admin_user_id(request)
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)
)
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)
recent_payments_stmt = (
select(Payment)
@@ -830,12 +857,14 @@ async def admin_user_detail_route(request: web.Request) -> web.Response:
serialized_inviter = (
_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(
{
"user": serialized_user,
"active_subscription": _serialize_subscription(active_sub) if active_sub else None,
"subscriptions": [_serialize_subscription(s) for s in (latest_subs or [])],
"trial": trial_payload,
"total_paid": float(total_paid),
"recent_payments": [_serialize_payment(p) for p in recent_payments],
"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)})
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:
"""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")
if days <= 0:
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")
if subscription_service is None:
@@ -1426,6 +1529,7 @@ async def admin_user_extend_route(request: web.Request) -> web.Response:
target_id,
days,
"admin_extend_subscription_webapp",
extend_hwid_devices=extend_hwid_devices,
)
if not new_end:
await session.rollback()
@@ -1436,7 +1540,10 @@ async def admin_user_extend_route(request: web.Request) -> web.Response:
{
"user_id": actor_id,
"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,
"target_user_id": target_id,
},
@@ -13,9 +13,6 @@ WEBAPP_APPEARANCE_SETTING_KEYS = frozenset(
{
"WEBAPP_TITLE",
"WEBAPP_LOGO_URL",
"WEBAPP_LOGO_USE_EMOJI",
"WEBAPP_LOGO_EMOJI",
"WEBAPP_LOGO_EMOJI_FONT",
"WEBAPP_FAVICON_URL",
"WEBAPP_FAVICON_USE_CUSTOM",
"WEBAPP_LOGO_FAVICON_URL",
+131 -27
View File
@@ -65,7 +65,6 @@ SETTINGS_MANIFEST: List[SettingField] = [
"SUPPORT_LINK", "url", "general", "Ссылка поддержки", "Куда вести пользователей за помощью."
),
SettingField("SERVER_STATUS_URL", "url", "general", "Ссылка на статус серверов"),
SettingField("TERMS_OF_SERVICE_URL", "url", "general", "Условия использования"),
SettingField("PRIVACY_POLICY_URL", "url", "general", "Политика конфиденциальности"),
SettingField("USER_AGREEMENT_URL", "url", "general", "Пользовательское соглашение"),
SettingField("DISABLE_WELCOME_MESSAGE", "bool", "general", "Скрыть приветствие /start"),
@@ -77,14 +76,20 @@ SETTINGS_MANIFEST: List[SettingField] = [
"int",
"general",
"ID обязательного канала",
"Telegram ID канала, в котором нужно состоять.",
(
"Telegram ID канала для проверки подписки. Если бот видит канал, "
"ссылка кнопки будет получена автоматически."
),
),
SettingField(
"REQUIRED_CHANNEL_LINK",
"string",
"general",
"Ссылка на канал",
"Имя пользователя или invite-link.",
(
"Необязательно: публичный @username или invite-link, "
"если ссылку нельзя получить по ID канала."
),
),
SettingField(
"PANEL_API_URL",
@@ -101,6 +106,42 @@ SETTINGS_MANIFEST: List[SettingField] = [
"Секретный ключ API панели.",
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(
"PANEL_WEBHOOK_SECRET",
"string",
@@ -139,27 +180,7 @@ SETTINGS_MANIFEST: List[SettingField] = [
SettingField(
"WEBAPP_PRIMARY_COLOR", "color", "appearance", "Основной цвет", placeholder="#00fe7a"
),
SettingField("WEBAPP_LOGO_USE_EMOJI", "bool", "appearance", "Использовать эмоджи-логотип"),
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(
"WEBAPP_FAVICON_USE_CUSTOM",
"bool",
@@ -348,7 +369,7 @@ SETTINGS_MANIFEST: List[SettingField] = [
"string",
"payments",
"Порядок методов оплаты",
"Через запятую: severpay,freekassa,yookassa,platega,stars,cryptopay,heleket",
"Через запятую: severpay,freekassa,yookassa,platega,stars,cryptopay,heleket,paykilla",
subsection="common",
),
# ─── Trial ─────────────────────────────────────────────────────
@@ -386,6 +407,18 @@ SETTINGS_MANIFEST: List[SettingField] = [
optional=False,
subsection="trial",
),
SettingField(
"TRIAL_WITHOUT_TELEGRAM_ENABLED",
"bool",
"pricing",
"Триал без Telegram",
(
"Если выключено, email-only пользователю нужно привязать Telegram для "
"активации триала. Disposable email домены всегда требуют Telegram."
),
optional=False,
subsection="trial",
),
SettingField(
"TRIAL_SQUAD_UUIDS",
"string",
@@ -396,12 +429,82 @@ SETTINGS_MANIFEST: List[SettingField] = [
),
# ─── Referral program ──────────────────────────────────────────
SettingField(
"REFERRAL_ONE_BONUS_PER_REFEREE", "bool", "referral", "Один бонус на приглашённого"
"REFERRAL_ONE_BONUS_PER_REFEREE",
"bool",
"pricing",
"Один бонус на приглашённого",
subsection="referral",
),
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 ─────────────────────────────────────────────
SettingField(
"SUBSCRIPTION_NOTIFICATIONS_ENABLED",
@@ -734,6 +837,7 @@ def manifest_payload() -> List[dict]:
"devices": 10,
"subscription_guides": 10,
"system": 12,
"migrations": 13,
}
exclusive_map = {
key: opposite
+124
View File
@@ -318,6 +318,81 @@
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 ---------- */
.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-input, .admin-textarea, .admin-btn, .admin-chip,
.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-header, .admin-sidebar, .admin-sidebar-brand,
.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 {
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_in_admin": true,
"css_file": "style.css",
"assets_version": 4,
"assets_version": 6,
"tokens": {
"color_scheme": "dark",
"style_preset": "ascii"
@@ -204,3 +204,56 @@ body:has(.theme-key-light) .install-platform-item[data-selected] {
.theme-key-light .install-loading .ui-spinner {
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_in_admin": true,
"css_file": "style.css",
"assets_version": 3,
"assets_version": 5,
"tokens": {
"color_scheme": "light"
}
@@ -127,6 +127,7 @@
.theme-key-windows95 svg.lucide-file-text,
.theme-key-windows95 svg.lucide-gift,
.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-house,
.theme-key-windows95 svg.lucide-info,
@@ -232,6 +233,10 @@
--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 {
--win95-button-icon: var(--win95-icon-file-text);
}
@@ -375,6 +380,7 @@
svg.lucide-file-text,
svg.lucide-gift,
svg.lucide-globe-2,
svg.lucide-grip-vertical,
svg.lucide-home,
svg.lucide-house,
svg.lucide-info,
@@ -1171,6 +1177,96 @@ body:has(.theme-key-windows95) .install-platform-item[data-selected] {
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 textarea::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 {
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_in_admin": true,
"css_file": "style.css",
"assets_version": 11,
"assets_version": 13,
"tokens": {
"color_scheme": "light",
"style_preset": "win95"
+1 -3
View File
@@ -42,7 +42,7 @@ from bot.app.web.webapp_auth import (
verify_webapp_session_token,
)
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.promo_code_service import PromoCodeService
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_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_EMOJI_CACHE_DIR = APP_ROOT / "data" / "webapp-emoji"
WEBAPP_CONFIG_PLACEHOLDER = "<!-- WEBAPP_CONFIG_SCRIPT -->"
WEBAPP_I18N_PLACEHOLDER = "<!-- WEBAPP_I18N_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_MAX_REQUESTS = 30
WEBAPP_LOGO_MAX_BYTES = 2 * 1024 * 1024
WEBAPP_EMOJI_MAX_BYTES = 4 * 1024 * 1024
WEBAPP_THEME_CSS_MAX_BYTES = 512 * 1024
WEBAPP_THEME_ASSET_MAX_BYTES = 1024 * 1024
WEBAPP_THEME_ASSET_CONTENT_TYPES = {
@@ -33,7 +33,6 @@ def create_subscription_webapp_application(
async def _startup(app_obj: web.Application) -> None:
await _ensure_shared_http_session()
await _warm_webapp_logo_cache(app_obj)
await _warm_webapp_animated_emoji_cache(app_obj)
await warm_subscription_guides_config(app_obj)
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:
if getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False):
return ""
raw_logo_url = (getattr(settings, "WEBAPP_LOGO_URL", None) or "").strip()
if not raw_logo_url:
return WEBAPP_DEFAULT_LOGO_PATH
@@ -303,29 +300,8 @@ def _uploaded_webapp_logo_response(filename: str) -> web.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:
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()
if not raw_logo_url:
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)
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:
settings: Settings = app["settings"]
if getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False):
return
raw_logo_url = (settings.WEBAPP_LOGO_URL or "").strip()
if not raw_logo_url or not _is_proxyable_webapp_logo_url(raw_logo_url):
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]]:
disk_logo = await asyncio.to_thread(_read_webapp_logo_from_disk, logo_url)
if disk_logo:
@@ -923,7 +765,6 @@ def _get_cached_webapp_settings(request: web.Request) -> Dict[str, Any]:
"traffic_packages": settings.traffic_packages,
"stars_traffic_packages": settings.stars_traffic_packages,
"support_url": settings.SUPPORT_LINK or "",
"terms_url": settings.TERMS_OF_SERVICE_URL or "",
"privacy_policy_url": settings.PRIVACY_POLICY_URL or "",
"user_agreement_url": settings.USER_AGREEMENT_URL or "",
"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:
# Single source of truth shared with the telemetry worker so the admin
# 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(
@@ -1144,9 +1007,6 @@ def _build_webapp_bootstrap_payload(request: web.Request) -> Dict[str, Any]:
"themesDir": settings.WEBAPP_THEMES_DIR,
"themePreviewKey": preview_key,
"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"],
"faviconUseCustom": bool(settings.WEBAPP_FAVICON_USE_CUSTOM),
"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,
"telegramOAuthRequestAccess": _resolve_telegram_oauth_request_access(settings),
"supportUrl": cached["support_url"],
"termsUrl": cached["terms_url"],
"privacyPolicyUrl": cached["privacy_policy_url"],
"userAgreementUrl": cached["user_agreement_url"],
"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>',
)
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:
html = html.replace(
"</head>",
@@ -1683,6 +1536,9 @@ _INITIAL_THEME_TOKEN_CSS_MAP = {
"font_sans": "--font-sans",
"font_logo": "--font-logo",
"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_surface": "--admin-surface",
"admin_surface_2": "--admin-surface-2",
@@ -1694,6 +1550,12 @@ _INITIAL_THEME_TOKEN_CSS_MAP = {
"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:
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 {}
declarations = []
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()
if value:
declarations.append(f"{css_name}:{value}")
+190 -14
View File
@@ -713,6 +713,7 @@ async def email_auth_verify_route(request: web.Request) -> web.Response:
session,
referral_param,
current_user_id=None,
settings=settings,
)
db_user, _ = await user_dal.create_email_user(
session,
@@ -821,6 +822,7 @@ async def email_auth_magic_route(request: web.Request) -> web.Response:
session,
referral_param,
current_user_id=None,
settings=settings,
)
db_user, _ = await user_dal.create_email_user(
session,
@@ -1010,13 +1012,53 @@ async def _request_email_code(
def _telegram_id_for_user(user: User) -> Optional[int]:
if user.telegram_id:
return int(user.telegram_id)
if user.user_id and int(user.user_id) > 0:
return int(user.user_id)
telegram_id = getattr(user, "telegram_id", None)
if telegram_id:
return int(telegram_id)
user_id = getattr(user, "user_id", None)
if user_id and int(user_id) > 0:
return int(user_id)
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:
return panel_description_from_profile(
user.username,
@@ -1292,17 +1334,35 @@ async def _link_telegram_to_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()
if not value:
return None
return ""
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:]
elif value_lower.startswith("ref_"):
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:]
if not re.fullmatch(r"[A-Za-z0-9]{1,32}", value):
@@ -1310,27 +1370,65 @@ def _normalize_referral_param(raw: Optional[str]) -> Optional[str]:
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(
session: AsyncSession,
raw_referral_param: Optional[str],
*,
current_user_id: Optional[int],
settings: Optional[Settings] = None,
) -> Optional[int]:
normalized = _normalize_referral_param(raw_referral_param)
if not normalized:
remnashop_compat = _remnashop_referral_compat_enabled(settings)
candidates = _referral_param_lookup_candidates(
raw_referral_param,
remnashop_compat=remnashop_compat,
)
if not candidates:
return None
for normalized in candidates:
ref_user = None
if normalized.isdigit():
if normalized.isdigit() and not remnashop_compat:
ref_user = await user_dal.get_user_by_id(session, int(normalized))
if not ref_user:
ref_user = await user_dal.get_user_by_referral_code(session, normalized)
ref_user = await user_dal.get_user_by_referral_code(
session,
normalized,
include_legacy=remnashop_compat,
)
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:
return None
continue
if current_user_id is not None and int(ref_user.user_id) == int(current_user_id):
return None
continue
return int(ref_user.user_id)
return None
async def _apply_referral_to_existing_user(
request: web.Request,
@@ -1345,6 +1443,7 @@ async def _apply_referral_to_existing_user(
session,
raw_referral_param,
current_user_id=int(user.user_id),
settings=request.app["settings"],
)
if not referred_by_id:
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:
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"]
referral_welcome_days = max(
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(
session: AsyncSession,
telegram_user: Dict[str, Any],
@@ -1427,6 +1602,7 @@ async def _ensure_user_from_telegram(
session,
referral_param or telegram_user.get("start_param"),
current_user_id=user_id,
settings=settings,
)
db_user, created = await user_dal.create_user(
session,
+84 -23
View File
@@ -1,7 +1,18 @@
# ruff: noqa: F401,F403,F405,I001
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 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]:
@@ -68,7 +79,7 @@ async def apply_promo_route(request: web.Request) -> web.Response:
)
if not success:
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()
end_date = result if isinstance(result, datetime) else None
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
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 {
"hwid_device",
"hwid_devices",
"hwid_devices_renewal",
}:
tariff_key = str(payment_payload.tariff_key or "").strip()
if not tariff_key:
@@ -308,7 +320,7 @@ async def create_payment_route(request: web.Request) -> web.Response:
user_id=user_id,
device_count=int(payment_units),
tariff_key=sale_tariff_key,
renewal=_sale_mode_base(sale_mode) == "hwid_devices_renewal",
renewal=False,
currency=currency,
)
if not hwid_quote:
@@ -321,6 +333,25 @@ async def create_payment_route(request: web.Request) -> web.Response:
else:
price = float(hwid_quote["price"])
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 [])}
is_admin = bool(db_user.telegram_id and int(db_user.telegram_id) in admin_ids)
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)
if not db_user or db_user.is_banned:
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)
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:
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:
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")
lang = db_user.language_code or settings.DEFAULT_LANGUAGE
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_text = (
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,
device_count=count,
tariff_key=tariff.key,
renewal=renewal_available,
renewal=False,
currency=default_currency,
)
if count in currency_counts
@@ -693,7 +752,7 @@ async def device_topup_options_route(request: web.Request) -> web.Response:
user_id=user_id,
device_count=count,
tariff_key=tariff.key,
renewal=renewal_available,
renewal=False,
currency="stars",
)
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:
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 = {
"id": f"{tariff.key}:hwid:{count}{':renewal' if renewal_available else ''}",
"id": f"{tariff.key}:hwid:{count}",
"tariff_key": tariff.key,
"tariff_name": tariff.name(lang),
"billing_model": tariff.billing_model,
"sale_mode": sale_mode_for_plan,
"sale_mode": "hwid_devices",
"renewal": False,
"months": count,
"device_count": count,
"price": float(currency_quote.get("price") if currency_quote else 0),
"currency": default_currency_code,
"title": f"+{count}",
"subtitle": tariff.name(lang),
"valid_from": _billing_iso_datetime(
(currency_quote or stars_quote).get("valid_from")
),
"valid_until": _billing_iso_datetime(
(currency_quote or stars_quote).get("valid_until")
),
"proration_ratio": float(
(currency_quote or stars_quote).get("proration_ratio") or 0
),
"valid_from": _billing_iso_datetime(valid_from),
"valid_from_text": _billing_datetime_text(valid_from),
"valid_until": _billing_iso_datetime(valid_until),
"valid_until_text": _billing_datetime_text(valid_until),
"proration_ratio": float(quote.get("proration_ratio") or 0),
}
if stars_quote and int(stars_quote.get("price") or 0) > 0:
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),
"extra_hwid_devices_valid_until": _billing_iso_datetime(extra_hwid_valid_until),
"extra_hwid_devices_valid_until_text": extra_hwid_valid_until_text,
"renewal_available": renewal_available,
"renewal_recommended_count": int(active.get("extra_hwid_devices") or 0)
if active and renewal_available
else 0,
"renewal_available": False,
"renewal_recommended_count": 0,
"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_wata_payment_status(request, session, payment)
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(
{
"ok": True,
@@ -1005,6 +1065,7 @@ async def _create_subscription_payment(
description=description,
sale_mode=sale_mode,
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_until=hwid_quote.get("valid_until") if hwid_quote else None,
hwid_pricing_period_months=hwid_quote.get("pricing_period_months")
+1
View File
@@ -49,6 +49,7 @@ class WebAppPaymentCreatePayload(BaseModel):
device_count: Any = None
tariff_key: Optional[constr(max_length=128)] = None
sale_mode: Optional[constr(max_length=64)] = None
renew_hwid_devices: Optional[bool] = None
description: Optional[constr(max_length=4096)] = None
comment: 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_.-]+}}",
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.css", css_asset_route)
app.router.add_get(
@@ -89,6 +85,7 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
"/api/account/telegram/notifications/probe",
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/trial/activate", activate_trial_route)
app.router.add_get("/api/devices", devices_route)
+160 -17
View File
@@ -1,6 +1,11 @@
# ruff: noqa: F401,F403,F405,I001
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.webapp_themes_config import public_themes_catalog_payload
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
else None
)
trial_available = bool(
trial_base_available = bool(
settings.TRIAL_ENABLED
and settings.TRIAL_DURATION_DAYS > 0
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)
try:
await session.commit()
except Exception:
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 [])}
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(
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
),
"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_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,
"invited_count": referral_stats.get("invited_count", 0),
"purchased_count": referral_stats.get("purchased_count", 0),
"welcome_bonus_days": max(
0, int(getattr(settings, "REFERRAL_WELCOME_BONUS_DAYS", 0) or 0)
"welcome_bonus_days": referral_welcome_days,
"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(
getattr(settings, "REFERRAL_ONE_BONUS_PER_REFEREE", False)
),
"bonus_details": _serialize_referral_bonus_details(settings, lang),
},
"plans": _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"],
),
"plans": plans_payload,
"payment_methods": _serialize_payment_methods(
settings,
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_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_traffic_limit_gb": float(settings.TRIAL_TRAFFIC_LIMIT_GB or 0),
"trial_traffic_strategy": getattr(settings, "TRIAL_TRAFFIC_STRATEGY", "NO_RESET"),
@@ -342,11 +378,12 @@ def _serialize_subscription(
and tariff.premium_topup_packages.has_any()
)
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(
tariff.billing_model == "period"
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:
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(
request: Optional[web.Request],
settings: Settings,
@@ -495,7 +628,10 @@ def _serialize_plans(
else [],
}
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)
stars_price = tariff.period_price(int(months), "stars")
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 []
)
}
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)
stars_price = stars_packages.get(traffic_gb)
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"),
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
# (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)
if quick_links_count == 0:
builder.adjust(2, 2, 2, 1, 2, 1, 2)
builder.adjust(2, 2, 2, 1, 3, 1, 2)
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
@@ -403,6 +407,26 @@ async def format_user_card(
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_bonus_bytes = int(subscription_details.get("premium_bonus_bytes") or 0)
if premium_unlimited:
@@ -706,6 +730,32 @@ async def user_action_handler(
await handle_traffic_grant_prompt(callback, state, user, "regular", i18n, current_lang)
elif action == "traffic_grant_premium":
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:
await callback.answer(_("admin_unknown_action"), show_alert=True)
@@ -850,6 +900,162 @@ async def handle_premium_override_bonus_prompt(
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(
callback: types.CallbackQuery,
user: User,
@@ -1971,6 +2177,114 @@ async def process_premium_override_bonus_handler(
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)
async def process_traffic_grant_gb_handler(
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 (
is_required_channel_access_error,
normalize_required_channel_id,
resolve_required_channel_link,
)
from bot.utils.install_links import (
append_install_share_link_text,
@@ -40,6 +41,67 @@ from db.models import User
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(
settings: Settings,
subscription_service: SubscriptionService,
@@ -376,11 +438,12 @@ async def ensure_required_channel_subscription(
)
return True
keyboard = (
get_channel_subscription_keyboard(current_lang, i18n, settings.REQUIRED_CHANNEL_LINK)
if i18n
else None
channel_link = await resolve_required_channel_link(
bot_instance,
required_channel_id,
settings.REQUIRED_CHANNEL_LINK,
)
keyboard = get_channel_subscription_keyboard(current_lang, i18n, channel_link) if i18n else None
prompt_text = translate("channel_subscription_required")
@@ -410,14 +473,10 @@ async def ensure_required_channel_subscription(
@router.message(CommandStart())
@router.message(CommandStart(magic=F.args.regexp(r"^ref_([A-Za-z0-9_-]{1,64})$").as_("ref_match")))
@router.message(
CommandStart(
magic=F.args.regexp(r"^ref_((?:[uU][A-Za-z0-9]{9})|(?:[A-Za-z0-9]{9})|\d+)$").as_(
"ref_match"
)
)
CommandStart(magic=F.args.regexp(r"^promo_([A-Za-z0-9_-]{1,100})$").as_("promo_match"))
)
@router.message(CommandStart(magic=F.args.regexp(r"^promo_(\w+)$").as_("promo_match")))
@router.message(CommandStart(magic=F.args.regexp(r"^admin_user_(\d+)$").as_("admin_user_match")))
@router.message(CommandStart(magic=F.args.regexp(r"^ticket_(\d+)$").as_("ticket_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:
raw_ref_value = ref_match.group(1)
if raw_ref_value.isdigit():
if settings.LEGACY_REFS:
potential_referrer_id = int(raw_ref_value)
if potential_referrer_id != user_id and await user_dal.get_user_by_id(
session, potential_referrer_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
referred_by_user_id = await _resolve_referrer_from_start_ref(
session,
raw_ref_value,
settings=settings,
current_user_id=user_id,
)
elif promo_match:
promo_code_to_apply = promo_match.group(1)
logging.info(f"User {user_id} started with promo code: {promo_code_to_apply}")
@@ -1135,7 +1184,7 @@ async def main_action_callback_handler(
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
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:
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:"))
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)
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)
return
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)
months = int(months_raw)
default_currency = default_currency_key_for_settings(settings)
@@ -343,6 +349,22 @@ async def select_tariff_period_callback(
if price_rub is None:
await callback.answer(get_text("error_try_again"), show_alert=True)
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(
months,
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),
back_callback=f"tariff:select:{tariff.key}{callback_suffix_for_context(callback_context)}",
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.answer()
@@ -577,7 +602,6 @@ async def hwid_devices_list_callback(
if not packages:
await callback.answer(get_text("no_hwid_device_packages_available"), show_alert=True)
return
renewal_available = bool(active.get("device_topup_renewal_available"))
markup = get_hwid_device_packages_keyboard(
tariff,
packages,
@@ -585,14 +609,11 @@ async def hwid_devices_list_callback(
i18n,
settings,
back_callback="main_action:my_devices",
renewal=renewal_available,
)
text_key = (
"select_hwid_device_renewal_package" if renewal_available else "select_hwid_device_package"
renewal=False,
)
await callback.message.edit_text(
get_text(
text_key,
"select_hwid_device_package",
date=active.get("extra_hwid_devices_valid_until_text") or "",
),
reply_markup=markup,
@@ -640,6 +661,7 @@ async def hwid_devices_package_callback(
await callback.answer(get_text("error_try_again"), show_alert=True)
return
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)
currency_code = default_payment_currency_code_for_settings(settings)
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,
device_count=count,
tariff_key=tariff.key,
renewal=action == "renewal_package",
renewal=renewal,
currency=default_currency,
)
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,
device_count=count,
tariff_key=tariff.key,
renewal=action == "renewal_package",
renewal=renewal,
currency="stars",
)
if not currency_quote and not stars_quote:
+75 -18
View File
@@ -4,6 +4,7 @@ from aiogram.types import InlineKeyboardMarkup, WebAppInfo
from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton
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.mini_app_url import subscription_mini_app_trial_url
from config.settings import Settings
@@ -13,6 +14,13 @@ from config.tariffs_config import (
)
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]:
@@ -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:
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 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]:
if not sale_mode or "|" not in sale_mode:
return None
context = str(sale_mode).split("|", 1)[1].strip()
return context or None
tokens = sale_mode_tokens(sale_mode)
return BOT_MENU_CONTEXT if BOT_MENU_CONTEXT in tokens else None
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)
)
user_agreement_url = settings.USER_AGREEMENT_URL or settings.TERMS_OF_SERVICE_URL
if settings.PRIVACY_POLICY_URL or user_agreement_url:
if settings.PRIVACY_POLICY_URL or settings.USER_AGREEMENT_URL:
builder.row(
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)
)
user_agreement_url = settings.USER_AGREEMENT_URL or settings.TERMS_OF_SERVICE_URL
if settings.PRIVACY_POLICY_URL or user_agreement_url:
if settings.PRIVACY_POLICY_URL or settings.USER_AGREEMENT_URL:
builder.row(
InlineKeyboardButton(
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,
user_id: Optional[int] = 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:
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
@@ -491,12 +520,39 @@ def get_payment_method_keyboard(
return str(int(val)) if float(val).is_integer() else f"{val:g}"
value_str = _format_value(months)
import logging as _kbd_logging
_kbd_logging.info(
"payment_method_keyboard build: order=%s",
settings.payment_methods_order,
payment_sale_mode = sale_mode
selected_hwid_quote = hwid_renewal_quote or hwid_renewal_stars_quote
if selected_hwid_quote:
tariff_key = None
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
for method in settings.payment_methods_order:
@@ -517,7 +573,7 @@ def get_payment_method_keyboard(
value=value_str,
rub_price=price,
stars_price=stars_price,
sale_mode=sale_mode,
sale_mode=payment_sale_mode,
)
if not callback_data:
continue
@@ -576,7 +632,7 @@ def get_yk_autopay_choice_keyboard(
builder.row(
InlineKeyboardButton(
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(
@@ -718,10 +774,11 @@ def get_channel_subscription_keyboard(
has_buttons = False
if channel_link:
channel_url = normalize_required_channel_link(channel_link)
if channel_url:
builder.button(
text=_(key="channel_subscription_join_button"),
url=channel_link,
url=channel_url,
)
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.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 db.dal import user_dal
@@ -86,10 +89,14 @@ class ChannelSubscriptionMiddleware(BaseMiddleware):
return i18n_instance.gettext(current_lang, key)
return key
keyboard = (
get_channel_subscription_keyboard(
current_lang, i18n_instance, self.settings.REQUIRED_CHANNEL_LINK
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 = (
get_channel_subscription_keyboard(current_lang, i18n_instance, channel_link)
if i18n_instance
else None
)
+1
View File
@@ -114,6 +114,7 @@ class WebAppPaymentContext:
sale_mode: str
currency: str = "RUB"
traffic_gb: Optional[float] = None
hwid_device_count: Optional[int] = None
hwid_valid_from: Optional[Any] = None
hwid_valid_until: Optional[Any] = None
hwid_pricing_period_months: Optional[int] = None
+8 -1
View File
@@ -192,6 +192,7 @@ class CryptoPayService:
sale_mode: str = "subscription",
url_kind: str = "bot",
hwid_quote: Optional[dict] = None,
hwid_device_count: Optional[int] = None,
currency: Optional[str] = None,
) -> Optional[str]:
if not self.configured or not self.client:
@@ -210,7 +211,11 @@ class CryptoPayService:
return None
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:
payment_record = await payment_dal.create_payment_record(
session,
@@ -252,6 +257,7 @@ class CryptoPayService:
"payment_db_id": str(payment_record.payment_id),
"sale_mode": sale_mode,
"traffic_gb": str(months) if sale_mode_is_traffic(sale_mode) else None,
"hwid_devices": amounts.purchased_hwid_devices,
}
)
try:
@@ -513,6 +519,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
}
if ctx.hwid_valid_from and ctx.hwid_valid_until
else None,
hwid_device_count=ctx.hwid_device_count,
)
if not url:
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,
sale_mode=ctx.sale_mode,
traffic_gb=ctx.traffic_gb,
hwid_device_count=ctx.hwid_device_count,
)
payment = await create_webapp_payment_record(
ctx,
+2 -1
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
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 (
PaymentProviderPresentation,
PaymentProviderSpec,
@@ -21,6 +21,7 @@ PAYMENT_PROVIDER_SPECS: tuple[PaymentProviderSpec, ...] = (
stars.SPEC,
cryptopay.SPEC,
heleket.SPEC,
paykilla.SPEC,
)
@@ -8,8 +8,10 @@ from aiogram import types
from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.inline.user_keyboards import (
HWID_RENEWAL_TOKEN,
get_payment_url_keyboard,
payment_methods_back_callback,
sale_mode_has_token,
)
from bot.middlewares.i18n import JsonI18n
from db.dal import payment_dal
@@ -123,6 +125,27 @@ async def quote_hwid_callback_parts(
subscription_service,
currency: str = "rub",
) -> 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):
return parts, None
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)
is_traffic = sale_mode_is_traffic(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 = {
"user_id": user_id,
"amount": amount,
@@ -111,9 +116,9 @@ def build_payment_record_payload(
"sale_mode": sale_mode,
"tariff_key": sale_mode_tariff_key(sale_mode),
"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(
{
"hwid_valid_from": hwid_quote.get("valid_from"),
@@ -164,14 +169,20 @@ def payment_record_amounts(
months: Any,
sale_mode: str,
traffic_gb: Optional[float] = None,
hwid_device_count: Optional[int] = None,
) -> PaymentRecordAmounts:
traffic_sale = sale_mode_is_traffic(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
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(
months=int(float(units)) if traffic_sale else int(float(months)),
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),
traffic_sale=traffic_sale,
hwid_devices_sale=hwid_devices_sale,
@@ -281,6 +292,7 @@ async def create_webapp_payment_record(
months=ctx.months,
sale_mode=ctx.sale_mode,
traffic_gb=ctx.traffic_gb,
hwid_device_count=ctx.hwid_device_count,
)
return await create_base_payment_record(
ctx.session,
@@ -156,6 +156,28 @@ def append_hwid_renewal_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(
*,
bot: Bot,
@@ -320,8 +342,37 @@ async def finalize_successful_payment(
req.log_prefix,
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
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(
req.session,
user_id=req.user_id,
@@ -363,6 +414,14 @@ async def finalize_successful_payment(
)
)
if is_subscription and activation:
if activation.get("hwid_devices_renewed_count"):
success_text = append_hwid_renewed_note(
success_text,
translator,
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,
@@ -51,7 +51,7 @@ async def notify_user_payment_failed(
message_key: str = "payment_failed",
) -> None:
"""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 = (
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,
sale_mode=ctx.sale_mode,
traffic_gb=ctx.traffic_gb,
hwid_device_count=ctx.hwid_device_count,
)
payment = await create_webapp_payment_record(
ctx,
+1
View File
@@ -978,6 +978,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
months=ctx.months,
sale_mode=ctx.sale_mode,
traffic_gb=ctx.traffic_gb,
hwid_device_count=ctx.hwid_device_count,
)
months_for_lookup = (
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() != ""
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(
*,
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
payment_value = float(amount_data.get("value", 0.0))
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:
logging.error(
@@ -574,6 +609,19 @@ async def process_successful_payment(
yk_payment_id_from_hook,
)
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
# 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",
provider="yookassa",
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
except Exception as e_ensure:
@@ -1315,6 +1373,36 @@ def _parse_offer_payload(payload: str) -> Optional[Tuple[float, float, str]]:
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(
get_text, network: Optional[str], last4: Optional[str], is_default: bool
) -> str:
@@ -1363,6 +1451,9 @@ async def _initiate_yk_payment(
return False
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 = (
get_text("payment_description_traffic", traffic_gb=_format_value(months))
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
@@ -1379,12 +1470,14 @@ async def _initiate_yk_payment(
"status": "pending_yookassa",
"description": payment_description,
"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,
"purchased_gb": float(months)
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
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_until": hwid_quote.get("valid_until") if hwid_quote else None,
"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)
if sale_base in HWID_DEVICE_SALE_BASES:
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:
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
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
currency_code_for_yk = default_payment_currency_code_for_settings(settings)
autopay_enabled = bool(
@@ -1786,6 +1876,22 @@ async def pay_yk_callback_handler(
pass
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(
callback,
settings=settings,
@@ -1863,6 +1969,22 @@ async def pay_yk_new_card_handler(
return
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
currency_code_for_yk = default_payment_currency_code_for_settings(settings)
autopay_enabled = bool(
@@ -1889,6 +2011,7 @@ async def pay_yk_new_card_handler(
save_payment_method=autopay_enabled and autopay_require_binding,
back_callback=payment_methods_back_callback(_format_value(months), sale_mode, price_rub),
sale_mode=sale_mode,
hwid_quote=hwid_quote,
)
try:
await callback.answer()
@@ -1928,27 +2051,15 @@ async def pay_yk_saved_list_handler(
pass
return
parts = data_payload.split(":")
if len(parts) < 2:
parsed_saved_list = _parse_saved_list_payload(data_payload)
if not parsed_saved_list:
logging.error(f"pay_yk_saved_list payload missing components: {callback.data}")
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
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
months, price_rub, page, sale_mode = parsed_saved_list
autopay_enabled = bool(
settings.yookassa_autopayments_active
@@ -2138,6 +2249,24 @@ async def pay_yk_use_saved_handler(
method_identifier = parts[2]
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:
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,
currency_code_for_yk=currency_code_for_yk,
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,
selected_method_internal_id=selected_method.method_id,
sale_mode=sale_mode,
hwid_quote=hwid_quote,
)
try:
await callback.answer()
@@ -2754,6 +2886,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
months=ctx.months,
sale_mode=ctx.sale_mode,
traffic_gb=ctx.traffic_gb,
hwid_device_count=ctx.hwid_device_count,
)
payment = await create_webapp_payment_record(
ctx,
@@ -2775,8 +2908,8 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
}
if amounts.traffic_sale:
metadata["traffic_gb"] = format_number_for_payload(ctx.traffic_gb or ctx.months)
if amounts.hwid_devices_sale:
metadata["hwid_devices"] = str(int(float(ctx.months)))
if amounts.purchased_hwid_devices:
metadata["hwid_devices"] = str(int(amounts.purchased_hwid_devices))
if amounts.tariff_key:
metadata["tariff_key"] = amounts.tariff_key
response = await service.create_payment(
+104 -23
View File
@@ -10,13 +10,13 @@ from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from email.message import EmailMessage
from email.utils import formataddr
from typing import Optional
from typing import Optional, Sequence
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
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 config.settings import Settings
from db.dal import security_dal, user_dal
@@ -61,11 +61,39 @@ def normalize_email(value: str) -> str:
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:
email = normalize_email(value)
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:
target_part = "none" if target_user_id is None else str(target_user_id)
return f"{purpose}:{target_part}:{email}"
@@ -453,6 +481,7 @@ class EmailAuthService:
subject: str,
body: str,
html_body: Optional[str] = None,
inline_images: Sequence[EmailInlineImage] = (),
) -> None:
await asyncio.to_thread(
self._send_custom_email_sync,
@@ -460,6 +489,7 @@ class EmailAuthService:
subject=subject,
body=body,
html_body=html_body,
inline_images=inline_images,
)
async def send_rendered_email(
@@ -473,6 +503,7 @@ class EmailAuthService:
subject=content.subject,
body=content.text,
html_body=content.html,
inline_images=content.inline_images,
)
def _send_code_email_sync(
@@ -493,17 +524,13 @@ class EmailAuthService:
i18n=self.i18n,
)
message = EmailMessage()
message["Subject"] = content.subject
message["From"] = formataddr(
(
self.settings.SMTP_FROM_NAME or self.settings.WEBAPP_TITLE,
self.settings.SMTP_FROM_EMAIL or "",
message = self._build_email_message(
email=email,
subject=content.subject,
body=content.text,
html_body=content.html,
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()
smtp_host = self.settings.SMTP_HOST
@@ -554,19 +581,15 @@ class EmailAuthService:
subject: str,
body: str,
html_body: Optional[str] = None,
inline_images: Sequence[EmailInlineImage] = (),
) -> None:
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 = self._build_email_message(
email=email,
subject=subject,
body=body,
html_body=html_body,
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()
smtp_host = self.settings.SMTP_HOST
@@ -610,6 +633,64 @@ class EmailAuthService:
if 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(
self,
*,
+98 -12
View File
@@ -12,6 +12,7 @@ from __future__ import annotations
import html
import re
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Optional, Sequence, Tuple
from urllib.parse import urlsplit
@@ -27,6 +28,27 @@ _TEXT_MUTED = "#9aa3b2"
_TEXT_DIM = "#5d6573"
_DEFAULT_ACCENT = "#00fe7a"
_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)
@@ -34,6 +56,13 @@ class EmailContent:
subject: str
text: 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:
@@ -48,8 +77,6 @@ def _safe_color(value: Optional[str]) -> str:
def _public_logo_url(settings: Settings) -> Optional[str]:
"""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."""
if getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False):
return None
raw = (settings.WEBAPP_LOGO_URL or "").strip()
if not raw:
return None
@@ -59,6 +86,55 @@ def _public_logo_url(settings: Settings) -> Optional[str]:
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:
title = (settings.WEBAPP_TITLE or "").strip()
return title or "Subscription"
@@ -98,10 +174,10 @@ def _layout(
intro_html: str,
body_html: str,
footer_html: str,
) -> str:
) -> _EmailLayout:
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
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)
logo_block = ""
if logo_url:
@@ -111,7 +187,7 @@ def _layout(
f'border-radius:16px;">'
)
return f"""<!DOCTYPE html>
layout_html = f"""<!DOCTYPE html>
<html lang="{html_lang}" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
@@ -151,6 +227,16 @@ def _layout(
</body>
</html>
""" # 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:
@@ -319,7 +405,7 @@ def render_login_code(
body_html=body_html,
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(
@@ -372,7 +458,7 @@ def render_account_merged(
body_html=body_html,
footer_html=footer,
)
return EmailContent(subject=subject, text=text, html=rendered)
return _email_content(subject=subject, text=text, layout=rendered)
def render_payment_success(
@@ -506,7 +592,7 @@ def render_payment_success(
body_html="".join(body_parts),
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(
@@ -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(
@@ -631,7 +717,7 @@ def render_subscription_expiring(
body_html="".join(body_parts),
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(
@@ -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(
@@ -785,7 +871,7 @@ def _support_email(
]
if 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(
@@ -108,6 +108,7 @@ LOCALE_GROUPS = [
"prefixes": (
"admin_user_",
"admin_users_",
"admin_hwid_",
"admin_ban_",
"admin_unban_",
"admin_banned_",
@@ -115,6 +116,7 @@ LOCALE_GROUPS = [
"admin_traffic_grant_",
"admin_view_banned_",
"user_card_",
"user_hwid_",
"user_premium_",
"user_regular_",
"user_traffic_",
@@ -203,7 +205,6 @@ LOCALE_GROUPS = [
"admin_settings_field_subscription_mini_app_url",
"admin_settings_field_support_link",
"admin_settings_field_server_status_url",
"admin_settings_field_terms_",
"admin_settings_field_privacy_",
"admin_settings_field_user_agreement_",
"appearance_",
+94 -11
View File
@@ -22,6 +22,11 @@ class PanelApiService:
_TRANSIENT_STATUS_CODES = (-1, -3)
_SAFE_METHODS = frozenset({"GET", "HEAD"})
_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):
self.settings = settings
@@ -70,17 +75,46 @@ class PanelApiService:
async def _get_session(self) -> aiohttp.ClientSession:
if self._session is None or self._session.closed:
# Separate connect/read timeouts so a stuck panel does not hold a
# 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)
self._session = aiohttp.ClientSession(timeout=self._client_timeout())
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):
if self._session and not self._session.closed:
await self._session.close()
@@ -121,6 +155,15 @@ class PanelApiService:
for attempt in range(max_attempts):
result = await self._request_once(method, endpoint, log_full_response, **kwargs)
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)
continue
return result
@@ -158,8 +201,8 @@ class PanelApiService:
)
except Exception:
log_prefix += f" | Payload: {str(json_payload_for_log)[:300]}..."
try:
started = time.monotonic()
try:
async with aiohttp_session.request(
method.upper(), url_for_request, headers=headers, **kwargs
) as response:
@@ -228,15 +271,48 @@ class PanelApiService:
return {"error": True, "status_code": response_status, "details": error_details}
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}")
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:
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)
return {"error": True, "status_code": -2, "message": f"Client error: {str(e)}"}
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.")
return {"error": True, "status_code": -3, "message": "Request timed out"}
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(
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}")
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]]]:
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,
) -> Tuple[bool, datetime | str]:
_ = lambda k, **kw: self.i18n.gettext(user_lang, k, **kw)
code_input_upper = (code_input or "").strip().upper()[:100]
code_display = html_escape(code_input_upper[:100], quote=False)
preserve_case = bool(
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 = await security_dal.check_throttle(
@@ -54,7 +58,7 @@ class PromoCodeService:
)
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:
@@ -74,6 +78,8 @@ class PromoCodeService:
)
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(
session, promo_data.promo_code_id, user_id
)
@@ -86,7 +92,7 @@ class PromoCodeService:
session=session,
user_id=user_id,
bonus_days=bonus_days,
reason=f"promo code {code_input_upper}",
reason=f"promo code {applied_code}",
)
if new_end_date:
@@ -109,7 +115,7 @@ class PromoCodeService:
user = await user_dal.get_user_by_id(session, user_id)
await notification_service.notify_promo_activation(
user_id=user_id,
promo_code=code_input_upper,
promo_code=applied_code,
bonus_days=bonus_days,
username=user.username 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",
"traffic_limit_bytes": self.settings.user_traffic_limit_bytes,
"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:
await subscription_dal.deactivate_other_active_subscriptions(
@@ -28,10 +28,7 @@ from db.dal import app_settings_dal
logger = logging.getLogger(__name__)
APPEARANCE_OVERRIDE_KEYS = {
"WEBAPP_LOGO_USE_EMOJI",
"WEBAPP_LOGO_URL",
"WEBAPP_LOGO_EMOJI",
"WEBAPP_LOGO_EMOJI_FONT",
"WEBAPP_FAVICON_USE_CUSTOM",
"WEBAPP_FAVICON_URL",
"WEBAPP_LOGO_FAVICON_URL",
@@ -167,12 +164,6 @@ def _appearance_snapshot(settings: Settings) -> Dict[str, Any]:
snapshot["WEBAPP_FAVICON_URL"] = favicon_url
if getattr(settings, "WEBAPP_FAVICON_USE_CUSTOM", False):
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)
if primary_color and primary_color != "#00fe7a":
snapshot["WEBAPP_PRIMARY_COLOR"] = primary_color
@@ -153,6 +153,15 @@ class SubscriptionNotificationWorker:
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(
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)
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(
self,
session: AsyncSession,
@@ -73,6 +116,64 @@ class HwidDeviceMixin:
packages = package_set.for_currency(currency)
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(
self,
*,
@@ -85,16 +186,10 @@ class HwidDeviceMixin:
) -> Dict[str, Any]:
period_months = max(1, int(getattr(sub, "duration_months", None) or 1))
full_price = float(package.price_for_period(period_months))
period_start = self._as_aware_utc(getattr(sub, "start_date", None))
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())
basis_seconds = max(1.0, float(period_months * 30 * 24 * 60 * 60))
billable_start = max(now, valid_from)
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
price = self._round_hwid_price(raw_price, currency=currency)
min_price = getattr(package, "min_price", None)
@@ -150,7 +245,7 @@ class HwidDeviceMixin:
if sub.hwid_device_limit is not None
else self._base_hwid_limit_for_tariff(tariff)
)
if base_hwid_limit == 0:
if base_hwid_limit in (None, 0):
return None
package = self._find_hwid_package(tariff, purchased_devices, currency)
@@ -187,6 +282,80 @@ class HwidDeviceMixin:
)
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(
self,
session: AsyncSession,
@@ -248,7 +417,7 @@ class HwidDeviceMixin:
if sub.hwid_device_limit is not None
else self._base_hwid_limit_for_tariff(tariff)
)
if base_hwid_limit == 0:
if base_hwid_limit in (None, 0):
logging.info(
"Skipping HWID top-up for user %s because current limit is unlimited", user_id
)
@@ -178,6 +178,7 @@ class SubscriptionLifecycleMixin:
user_id: int,
target_tariff_key: str,
mode: str,
payment_id: Optional[int] = None,
) -> Optional[Dict[str, Any]]:
config = self._tariffs_config()
if not config:
@@ -336,7 +337,7 @@ class SubscriptionLifecycleMixin:
"from_tariff_key": before_tariff_key,
"to_tariff_key": target.key,
"mode": mode,
"payment_id": None,
"payment_id": payment_id,
"days_before": options.get("remaining_days"),
"days_after": (updated.end_date - now).days
if updated.end_date and target.billing_model == "period"
@@ -454,27 +455,11 @@ class SubscriptionLifecycleMixin:
user_id,
tariff_key,
"paid_diff",
payment_id=payment_db_id,
)
if result:
sub = await subscription_dal.get_active_subscription_by_user_id(session, user_id)
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["is_active"] = sub.is_active
db_user = await user_dal.get_user_by_id(session, user_id)
@@ -494,10 +479,29 @@ class SubscriptionLifecycleMixin:
await self._record_payment_context(
session,
payment_db_id,
sale_mode=sale_mode_base,
sale_mode=sale_mode,
tariff_key=tariff.key if tariff else tariff_key,
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)
if not db_user:
@@ -569,6 +573,26 @@ class SubscriptionLifecycleMixin:
promo_code_id_from_payment = None
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(
session, panel_user_uuid, panel_sub_link_id
)
@@ -614,7 +638,8 @@ class SubscriptionLifecycleMixin:
premium_topup_balance_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_unl_carry = bool(getattr(current_active_sub, "regular_unlimited_override", False))
traffic_limit_bytes = self._traffic_limit_for_period_tariff(
@@ -641,6 +666,9 @@ class SubscriptionLifecycleMixin:
"traffic_limit_bytes": traffic_limit_bytes,
"provider": provider,
"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,
"tariff_key": tariff.key if tariff else None,
"tier_baseline_bytes": tier_baseline_bytes,
@@ -695,6 +723,31 @@ class SubscriptionLifecycleMixin:
final_subscription_url = updated_panel_user.get("subscriptionUrl")
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(
db_user=db_user,
@@ -715,8 +768,12 @@ class SubscriptionLifecycleMixin:
"subscription_url": final_subscription_url,
"applied_promo_bonus_days": applied_promo_bonus_days,
"tariff_key": tariff.key if tariff else None,
"hwid_devices_renewal_recommended_count": extra_hwid_devices,
"hwid_devices_valid_until": hwid_devices_valid_until,
"hwid_devices_renewal_recommended_count": 0
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(
@@ -725,6 +782,7 @@ class SubscriptionLifecycleMixin:
user_id: int,
bonus_days: int,
reason: str = "bonus",
extend_hwid_devices: bool = True,
) -> Optional[datetime]:
reason_lower = (reason or "").lower()
apply_main_traffic_limit = any(
@@ -776,6 +834,9 @@ class SubscriptionLifecycleMixin:
"status_from_panel": "ACTIVE_BONUS",
"traffic_limit_bytes": traffic_limit,
"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(
session, panel_uuid, panel_sub_uuid
@@ -792,6 +853,21 @@ class SubscriptionLifecycleMixin:
updated_sub_model = await subscription_dal.update_subscription_end_date(
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 (
apply_main_traffic_limit
@@ -14,6 +14,7 @@ class PaymentContextMixin:
"severpay": "SeverPay",
"wata": "Wata",
"cryptopay": "Crypto Pay",
"paykilla": "PayKilla",
"telegram_stars": "Telegram Stars",
}
@@ -38,6 +39,7 @@ class PaymentContextMixin:
payment.sale_mode = sale_mode
payment.tariff_key = tariff_key
payment.purchased_gb = purchased_gb
if purchased_hwid_devices is not None:
payment.purchased_hwid_devices = purchased_hwid_devices
if hwid_valid_from is not None:
payment.hwid_valid_from = hwid_valid_from
@@ -42,6 +42,8 @@ class RenewalMixin:
months = sub.duration_months or 1
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
tariffs_config = (
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")
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 = {
"user_id": str(sub.user_id),
"auto_renew_for_subscription_id": str(sub.subscription_id),
"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(
amount=float(amount),
currency=currency,
@@ -314,7 +314,7 @@ class TariffMixin:
@staticmethod
def _effective_hwid_limit(base_limit: Optional[int], extra_devices: int = 0) -> Optional[int]:
if base_limit is None:
return None
return 0
base_int = max(0, int(base_limit))
if base_int == 0:
return 0
@@ -63,6 +63,8 @@ class TrialSubscriptionMixin:
"traffic_limit_bytes": self.settings.trial_traffic_limit_bytes,
"auto_renew_enabled": False,
"provider": "trial",
# Short trial: only warn a few hours before it ends, not days ahead.
"suppress_early_expiry_notifications": True,
}
try:
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_premium_override_bonus_gb = State()
waiting_for_traffic_grant_gb = State()
waiting_for_hwid_device_limit = State()
# Ads campaigns
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]:
@@ -28,6 +33,66 @@ def normalize_required_channel_id(value: object) -> Optional[int]:
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:
message = str(error).lower()
configuration_markers = (
+9
View File
@@ -29,6 +29,15 @@ class AsyncTTLCache:
return None
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
def _is_cacheable(value: Any) -> bool:
if value is None:
+167 -33
View File
@@ -1,5 +1,6 @@
import logging
import os
import re
import secrets
from typing import Any, Dict, List, Optional
@@ -25,7 +26,117 @@ DEFAULT_SUBSCRIPTION_PURCHASE_DESCRIPTION_EN = (
def _split_csv(value: Optional[str]) -> List[str]:
if not value:
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):
@@ -59,9 +170,6 @@ class WebAppSettings(BaseModel):
title: str
primary_color: str
logo_url: Optional[str]
logo_use_emoji: bool
logo_emoji: str
logo_emoji_font: str
favicon_use_custom: bool
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_ALL_USERS_CACHE_TTL_SECONDS: int = Field(default=5)
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_DB_STATS_CACHE_TTL_SECONDS: int = Field(default=5)
ADMIN_USERS_LIST_CACHE_TTL_SECONDS: int = Field(default=3)
@@ -147,7 +259,6 @@ class Settings(BaseSettings):
SUPPORT_LINK: 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)
USER_AGREEMENT_URL: Optional[str] = Field(default=None)
REQUIRED_CHANNEL_ID: Optional[int] = Field(
@@ -194,7 +305,7 @@ class Settings(BaseSettings):
STARS_ADMIN_ONLY_ENABLED: bool = Field(default=False)
PAYMENT_METHODS_ORDER: Optional[str] = Field(
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(
default=True,
@@ -282,10 +393,35 @@ class Settings(BaseSettings):
default=3,
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(
default=True,
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(
default="production",
@@ -328,6 +464,13 @@ class Settings(BaseSettings):
TRIAL_DURATION_DAYS: int = Field(default=3)
TRIAL_TRAFFIC_LIMIT_GB: Optional[float] = Field(default=5.0)
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(
default=None,
description=(
@@ -373,15 +516,6 @@ class Settings(BaseSettings):
),
)
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_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_FROM_EMAIL: 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_USE_SSL: bool = Field(default=False)
EMAIL_CODE_TTL_SECONDS: int = Field(default=10 * 60)
@@ -544,9 +685,6 @@ class Settings(BaseSettings):
title=self.WEBAPP_TITLE,
primary_color=self.WEBAPP_PRIMARY_COLOR,
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_url=self.WEBAPP_FAVICON_URL,
logo_favicon_url=self.WEBAPP_LOGO_FAVICON_URL,
@@ -627,6 +765,16 @@ class Settings(BaseSettings):
return trial_squads
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
@property
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):
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")
@classmethod
def ignore_deprecated_webapp_favicon_use_custom_env(cls, _value):
@@ -893,6 +1026,7 @@ class Settings(BaseSettings):
"stars",
"cryptopay",
"heleket",
"paykilla",
]
# Make sure default_order itself includes every registered spec.
for sid in spec_ids:
+7 -2
View File
@@ -54,6 +54,8 @@ class ThemeTokens(BaseModel):
font_logo: Optional[str] = None
font_mono: Optional[str] = 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_surface: 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)
return f"#{hex_value}"
@field_validator("home_logo_scale")
@field_validator("home_logo_scale", "home_logo_scale_desktop", "home_logo_scale_mobile")
@classmethod
def _normalize_home_logo_scale(cls, value: Optional[int]) -> Optional[int]:
if value is None:
return None
scale = int(value)
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
@@ -369,6 +371,7 @@ def _builtin_theme_assets_need_refresh(key: str, target_dir: Path) -> bool:
"--success-text" not in style
or ".theme-key-light.app-shell" 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":
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 "New webapp surfaces: support, purchase info, password login" 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":
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 "New webapp surfaces: support, purchase info, password login" 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)
)
+23
View File
@@ -51,6 +51,15 @@ async def ensure_payment_with_provider_id(
description: str,
provider: 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:
"""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": 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)
+29 -7
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)
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)"""
stmt = select(PromoCode).where(PromoCode.code == code_str.upper())
for candidate in _promo_lookup_candidates(code_str, preserve_case=preserve_case):
stmt = select(PromoCode).where(PromoCode.code == candidate)
result = await session.execute(stmt)
return result.scalar_one_or_none()
promo = result.scalar_one_or_none()
if promo:
return promo
return None
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]:
now = datetime.now(timezone.utc)
for candidate in _promo_lookup_candidates(code_str, preserve_case=preserve_case):
stmt = select(PromoCode).where(
PromoCode.code == code_str.upper(),
PromoCode.code == candidate,
PromoCode.is_active == True,
PromoCode.current_activations < PromoCode.max_activations,
or_(PromoCode.valid_until == None, PromoCode.valid_until > datetime.now(timezone.utc)),
or_(PromoCode.valid_until == None, PromoCode.valid_until > now),
)
result = await session.execute(stmt)
return result.scalar_one_or_none()
promo = result.scalar_one_or_none()
if promo:
return promo
return None
async def get_all_active_promo_codes(
+58 -1
View File
@@ -1,5 +1,5 @@
import inspect
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional
from sqlalchemy import and_, delete, func, or_, select, update
@@ -189,6 +189,63 @@ async def expire_hwid_device_purchases(
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(
session: AsyncSession,
change_data: Dict[str, Any],
+116 -6
View File
@@ -4,7 +4,7 @@ import string
from datetime import datetime, timedelta, timezone
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.ext.asyncio import AsyncSession
from sqlalchemy.future import select
@@ -14,6 +14,8 @@ from ..models import (
AdAttribution,
EmailVerificationCode,
HwidDevicePurchase,
LegacyImportMapping,
LegacyReferralCode,
MessageLog,
Payment,
PromoCodeActivation,
@@ -76,7 +78,7 @@ async def ensure_referral_code(session: AsyncSession, user: User) -> str:
Returns the existing or newly generated code.
"""
if user.referral_code:
normalized = user.referral_code.strip().upper()
normalized = user.referral_code.strip()
if normalized != user.referral_code:
user.referral_code = normalized
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"):
user_data["referral_code"] = await generate_unique_referral_code(session)
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
stmt = (
@@ -567,6 +569,19 @@ async def merge_users(
await session.execute(
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(
update(MessageLog)
@@ -590,13 +605,60 @@ async def merge_users(
return target
async def get_user_by_referral_code(session: AsyncSession, referral_code: str) -> Optional[User]:
normalized = referral_code.strip().upper()
async def get_user_by_referral_code(
session: AsyncSession,
referral_code: str,
*,
include_legacy: bool = False,
) -> Optional[User]:
normalized = referral_code.strip()
if not normalized:
return None
stmt = select(User).where(User.referral_code == normalized)
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(
@@ -857,6 +919,29 @@ async def get_user_ids_without_active_subscription(session: AsyncSession) -> Lis
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):
expired_subs = aliased(Subscription)
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(AdAttribution).where(AdAttribution.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(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] = [
Migration(
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",
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())
is_banned = Column(Boolean, default=False)
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)
lifetime_used_traffic_bytes = Column(BigInteger, 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)
provider = Column(String, nullable=True)
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)
tariff_key = Column(String, nullable=True, index=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):
__tablename__ = "message_logs"
+2 -2
View File
@@ -1,6 +1,6 @@
aiogram==3.28.2
python-dotenv==1.2.2
aiohttp==3.13.5
aiohttp>=3.13.5,<3.14
pydantic==2.13.4
yookassa==3.10.1
httpx>=0.27.0
@@ -9,6 +9,6 @@ email-validator==2.3.0
sqlalchemy[asyncio]==2.0.49
asyncpg==0.31.0
aiocryptopay==0.4.8
PyJWT[crypto]==2.12.1
PyJWT[crypto]==2.13.0
Pillow==12.2.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;
}
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_http_version 1.1;
proxy_set_header Host $host;
+1
View File
@@ -96,6 +96,7 @@ export default defineConfig({
items: [
{ label: 'Обзор миграций', slug: 'migrations' },
{ 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 runtimeBase = "/demo/runtime";
const runtimeAppPath = `${runtimeBase}/app/index.html`;
const demoBase = "/demo";
const defaultMock = "tariffs";
const publicRouteAliases = new Map([["/app", "/home"]]);
@@ -8,6 +9,8 @@ const stateMocks = new Set([
"depleted",
"no-subscription",
"trial",
"trial-telegram",
"referral-telegram",
"devices",
"notifications",
"auth",
@@ -145,7 +148,7 @@ if (initialMock !== "emails") {
params.delete("screen");
params.delete("admin_section");
params.set("path", initialRoute);
frame.src = `${runtimeBase}/app/?${params.toString()}${window.location.hash || ""}`;
frame.src = `${runtimeAppPath}?${params.toString()}${window.location.hash || ""}`;
}
const routeFromRuntimeUrl = (url) => {
@@ -155,7 +158,11 @@ const routeFromRuntimeUrl = (url) => {
const runtimePath = normalizePath(
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 runtimePath;
@@ -171,7 +178,7 @@ const runtimeSrc = (route, searchParams = new URLSearchParams()) => {
nextParams.delete("screen");
nextParams.delete("admin_section");
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 toggle = document.querySelector(".demo-topbar__toggle");
@@ -226,7 +233,8 @@ const setCollapsed = (collapsed) => {
toggle?.addEventListener("click", () => setCollapsed(false));
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);
stateSelect?.addEventListener("change", () => {
const mock = normalizeStateMock(stateSelect.value);
+44 -3
View File
@@ -1,5 +1,8 @@
import base64
import hashlib
import json
import sys
import tempfile
from pathlib import Path
from types import SimpleNamespace
@@ -9,6 +12,7 @@ sys.path.insert(0, str(BACKEND_ROOT))
if hasattr(sys.stdout, "reconfigure"):
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
render_account_merged,
render_login_code,
@@ -23,6 +27,34 @@ from bot.services.email_templates import ( # noqa: E402
)
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:
@@ -63,8 +95,7 @@ def settings():
return SimpleNamespace(
DEFAULT_LANGUAGE=LANGUAGE,
EMAIL_CODE_TTL_SECONDS=600,
WEBAPP_LOGO_URL="",
WEBAPP_LOGO_USE_EMOJI=False,
WEBAPP_LOGO_URL=PREVIEW_LOGO_URL,
WEBAPP_PRIMARY_COLOR="#00fe7a",
WEBAPP_TITLE="remnawave-minishop",
)
@@ -95,10 +126,20 @@ def preview(item_id: str, category: str, title: str, content):
"category": category,
"title": title,
"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(
item_id: str,
title: str,
+1
View File
@@ -30,6 +30,7 @@ const descriptions = {
'features/support.md': 'Пользовательские тикеты, список обращений в админке, уведомления и лимиты поддержки.',
'migrations/index.md': 'Готовые сценарии миграции в Remnawave 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/logs.md': 'Какие логи смотреть при диагностике backend, worker, frontend, миграций и вебхуков.',
'troubleshooting/maintenance.md': 'Обновления, миграции, резервные копии и проверки продакшен-стека.',
+1
View File
@@ -31,6 +31,7 @@ for (const command of pythonCommands) {
const result = spawnSync(command, [generatorPath], {
cwd: repoRoot,
encoding: "utf8",
maxBuffer: 20 * 1024 * 1024,
env: {
...process.env,
PYTHONIOENCODING: "utf-8",
+4 -2
View File
@@ -1,7 +1,7 @@
---
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/';
---
@@ -467,6 +467,8 @@ const docsHref = '/getting-started/demo/';
<option value="depleted">Трафик закончился</option>
<option value="no-subscription">Нет подписки</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="notifications">Telegram-уведомления</option>
<option value="auth">Вход и регистрация</option>
@@ -529,7 +531,7 @@ const docsHref = '/getting-started/demo/';
title={`Email preview: ${preview.title}`}
srcdoc={preview.html}
loading="lazy"
sandbox=""
sandbox="allow-scripts"
></iframe>
</div>
</div>
+44 -7
View File
@@ -59,6 +59,10 @@
| `PANEL_DEVICES_CACHE_TTL_SECONDS` | TTL кеша устройств пользователя Remnawave. |
| `PANEL_ALL_USERS_CACHE_TTL_SECONDS` | TTL кеша полных сканов пользователей Remnawave. |
| `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_DB_STATS_CACHE_TTL_SECONDS` | TTL дорогих DB-агрегатов админки. |
| `ADMIN_USERS_LIST_CACHE_TTL_SECONDS` | TTL списка пользователей админки. |
@@ -101,11 +105,10 @@
| `DEFAULT_CURRENCY_SYMBOL` | Символ/код валюты в интерфейсе. |
| `SUPPORT_LINK` | Внешняя ссылка поддержки. |
| `SERVER_STATUS_URL` | Страница статуса сервиса. |
| `TERMS_OF_SERVICE_URL` | Условия использования. |
| `PRIVACY_POLICY_URL` | Политика конфиденциальности. |
| `USER_AGREEMENT_URL` | Пользовательское соглашение. |
| `REQUIRED_CHANNEL_ID` | ID обязательного Telegram-канала. |
| `REQUIRED_CHANNEL_LINK` | Ссылка на обязательный канал. |
| `REQUIRED_CHANNEL_ID` | ID обязательного Telegram-канала. Используется для проверки подписки и автоматического получения ссылки кнопки, если бот видит канал. |
| `REQUIRED_CHANNEL_LINK` | Необязательная запасная ссылка на обязательный канал (`@username` или invite-link), если ссылку нельзя получить по ID. |
| `START_COMMAND_DESCRIPTION` | Описание `/start` для меню Telegram. |
| `DISABLE_WELCOME_MESSAGE` | Отключить приветствие на `/start`. |
@@ -157,9 +160,6 @@
| `TELEGRAM_OAUTH_REQUEST_ACCESS` | `.env` | Дополнительные разрешения, например `write`. |
| `WEBAPP_PRIMARY_COLOR` | Админка | Устаревшее 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_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_RU` / `SUBSCRIPTION_PURCHASE_DESCRIPTION_EN` | Локализованное описание подписки. |
| `PAYMENT_<METHOD>_WEBAPP_LABEL_RU` / `PAYMENT_<METHOD>_WEBAPP_LABEL_EN` | Текст кнопки провайдера в Web App. |
@@ -213,6 +213,7 @@
| `WATA_ENABLED` | Включает Wata. |
| `CRYPTOPAY_ENABLED` | Включает CryptoPay. |
| `HELEKET_ENABLED` | Включает Heleket. |
| `PAYKILLA_ENABLED` | Включает PayKilla. |
Конкретные ключи отображения:
@@ -271,6 +272,12 @@ PAYMENT_HELEKET_WEBAPP_ICON
PAYMENT_HELEKET_TELEGRAM_LABEL_RU
PAYMENT_HELEKET_TELEGRAM_LABEL_EN
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
@@ -357,6 +364,33 @@ PAYMENT_HELEKET_TELEGRAM_EMOJI
| `HELEKET_VERIFY_WEBHOOK_SIGNATURE` | Проверять подпись 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-цены
Рекомендуемый способ настройки тарифов - раздел **Система -> Тарифы** в админке. Он сохраняет JSON в `TARIFFS_CONFIG_PATH`.
@@ -386,10 +420,13 @@ PAYMENT_HELEKET_TELEGRAM_EMOJI
| `TRIAL_DURATION_DAYS` | Длительность пробного периода. |
| `TRIAL_TRAFFIC_LIMIT_GB` | Лимит трафика пробного периода. |
| `TRIAL_TRAFFIC_STRATEGY` | Стратегия лимита пробного периода. |
| `TRIAL_WITHOUT_TELEGRAM_ENABLED` | Разрешает активацию trial пользователям без привязанного Telegram. Disposable email домены всё равно требуют Telegram. |
| `TRIAL_SQUAD_UUIDS` | Internal Squads для trial через запятую. Если пусто, используется `USER_SQUAD_UUIDS`. |
| `REFERRAL_ONE_BONUS_PER_REFEREE` | Ограничить бонусы одним успешным платежом приглашенного. |
| `REFERRAL_WELCOME_BONUS_DAYS` | Приветственный бонус пришедшему по реферальной ссылке. |
| `REFERRAL_WELCOME_BONUS_WITHOUT_TELEGRAM_ENABLED` | Разрешает начислять реферальный приветственный бонус пользователям без привязанного Telegram. Disposable email домены всё равно требуют Telegram. |
| `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`. |
| `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` | Включает напоминания о подписке. |
+8 -4
View File
@@ -9,7 +9,7 @@
- блокировка пользователей, входящий список тикетов поддержки, рассылки, промокоды и просмотр логов;
- ручная синхронизация с Remnawave;
- редактор разрешенных настроек приложения из manifest-файла;
- раздел **Внешний вид** для логотипа, emoji-логотипа, выбора темы, accent-цвета, масштаба логотипа и предпросмотра тем;
- раздел **Внешний вид** для логотипа, выбора темы, accent-цвета, масштаба логотипа и предпросмотра тем;
- раздел **Инструкции подключения** для встроенной страницы установки, поведения кнопок бота и Remnawave Subscription Page config;
- раздел **Бэкапы** для просмотра локальных ZIP-архивов, загрузки архива и восстановления БД/compose-папки;
- редактор JSON-каталога тарифов;
@@ -49,7 +49,7 @@
В manifest сейчас входят:
- общие параметры: язык, валюта, ссылки поддержки, документы, обязательный канал, Remnawave-доступы и поведение `/start`;
- внешний вид и доступность Web App: название, цвет, логотип, emoji-логотип и `WEBAPP_ENABLED`;
- внешний вид и доступность Web App: название, цвет, логотип и `WEBAPP_ENABLED`;
- инструкции подключения: `SUBSCRIPTION_GUIDES_ENABLED`, `SUBSCRIPTION_GUIDES_BOT_MENU_ENABLED`, чтение конфига из Remnawave Panel, JSON-переопределение и резервный путь к файлу;
- legacy-тарифы без JSON-каталога: периоды подписки, RUB/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).
@@ -122,6 +124,8 @@
- **Premium**: названия premium-раздела RU/EN, premium Internal Squads, месячный premium-лимит и RUB/Stars пакеты premium-докупки;
- **Устройства**: RUB/Stars пакеты докупки HWID-устройств.
Порядок периодов, traffic-пакетов, обычных докупок, premium-докупок и HWID-пакетов меняется перетаскиванием строк в редакторе. Этот порядок сохраняется в JSON и используется на витрине Web App и в Telegram-боте.
Базовые и 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
```
## Брендинг писем
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 автоматически.
`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 при наличии привязанной почты.
Все HTML-письма используют общий email-шаблон с брендом из Web App: заголовком, accent-цветом и логотипом. Логотип, загруженный через раздел **Внешний вид**, отправляется как inline image, а публичный HTTPS-логотип остается внешней картинкой.
## Сводная таблица
| Событие | Получатель | Telegram | Email | Условия и ограничения |
@@ -16,7 +18,7 @@ Minishop отправляет уведомления в Telegram и на email.
| Успешная покупка отдельного пакета трафика | Пользователь | ✓ | ✓ | Для `traffic` / `traffic_package`; email отправляется, если SMTP настроен и у пользователя есть email. |
| Успешная докупка обычного трафика к тарифу | Пользователь | ✓ | ✓ | Для `topup`; email отправляется, если SMTP настроен и у пользователя есть email. |
| Успешная покупка premium-трафика | Пользователь | ✓ | ✓ | Для `premium_topup`; email отправляется, если SMTP настроен и у пользователя есть email. |
| Успешная покупка HWID-устройств | Пользователь | ✓ | ✓ | Отправляется после оплаты `hwid_devices` или `hwid_devices_renewal`; email отправляется, если SMTP настроен и у пользователя есть email. |
| Успешная покупка HWID-устройств | Пользователь | ✓ | ✓ | Отправляется после отдельной оплаты `hwid_devices`; при продлении устройств вместе с подпиской добавляется примечание к уведомлению об успешной оплате подписки. Email отправляется, если SMTP настроен и у пользователя есть email. |
| Платное повышение тарифа | Пользователь | ✓ | ✓ | Для `tariff_upgrade`; email отправляется, если SMTP настроен и у пользователя есть email. |
| Способ оплаты YooKassa привязан | Пользователь | ✓ | ✓ | Отправляется после успешного сохранения платежного метода через webhook YooKassa; email отправляется, если SMTP настроен и у пользователя есть email. |
| Ошибка оплаты по webhook провайдера | Пользователь | ✓ | ✓ | Отправляется, когда платежный провайдер сообщает о неуспешном платеже; email отправляется, если SMTP настроен и у пользователя есть email. |
+110 -13
View File
@@ -22,6 +22,30 @@
- [Тарифы](tariffs.md) — цены, Telegram Stars и сценарии покупки.
- [Логи](../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 используется для рублевых оплат. Провайдер также может участвовать в сценариях автопродления period-подписок.
@@ -29,7 +53,7 @@ YooKassa используется для рублевых оплат. Прова
### Настройка
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.
### Справочник
@@ -43,10 +67,10 @@ FreeKassa подключается как отдельный платежный
### Настройка
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. Проверьте настройки подписи.
4. Скопируйте URL вебхука из админ-панели и укажите его в кабинете FreeKassa.
5. При необходимости заполните список доверенных IP.
5. При необходимости заполните `FREEKASSA_TRUSTED_IPS`.
### Справочник
@@ -59,11 +83,20 @@ Platega подключается как отдельный платежный п
### Настройка
1. Включите `PLATEGA_ENABLED`.
2. Укажите `PLATEGA_MERCHANT_ID` и `PLATEGA_SECRET`.
2. Укажите `PLATEGA_BASE_URL`, `PLATEGA_MERCHANT_ID` и `PLATEGA_SECRET`.
3. Скопируйте URL вебхука из админ-панели и укажите его в кабинете Platega.
4. Проверьте `PLATEGA_RETURN_URL` и `PLATEGA_FAILED_URL`.
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)
@@ -75,9 +108,11 @@ SeverPay подключается как отдельный платежный
### Настройка
1. Включите `SEVERPAY_ENABLED`.
2. Укажите `SEVERPAY_MID`, `SEVERPAY_TOKEN`, `SEVERPAY_BASE_URL`
3. Скопируйте URL вебхука из админ-панели и укажите его в кабинете SeverPay.
4. При необходимости задайте `SEVERPAY_LIFETIME_MINUTES`.
2. Укажите `SEVERPAY_BASE_URL`.
3. Заполните `SEVERPAY_MID` и `SEVERPAY_TOKEN`.
4. Проверьте `SEVERPAY_RETURN_URL`.
5. Скопируйте URL вебхука из админ-панели и укажите его в кабинете SeverPay.
6. При необходимости задайте `SEVERPAY_LIFETIME_MINUTES`.
### Справочник
@@ -116,8 +151,8 @@ CryptoPay используется для криптовалютных плат
2. Укажите `CRYPTOPAY_TOKEN`.
3. Выберите `CRYPTOPAY_NETWORK`: `mainnet` или `testnet`.
4. Задайте `CRYPTOPAY_CURRENCY_TYPE`: `fiat` или `crypto`.
5. Скопируйте URL вебхука из админ-панели и укажите его в CryptoPay.
6. Проверьте `CRYPTOPAY_ASSET`.
5. Проверьте `CRYPTOPAY_ASSET`, например `RUB`, `USDT` или `BTC`.
6. Скопируйте URL вебхука из админ-панели и укажите его в CryptoPay.
### Проверка
@@ -138,10 +173,12 @@ Heleket используется для крипто-инвойсов с merchan
1. Включите `HELEKET_ENABLED`.
2. Укажите `HELEKET_BASE_URL`, `HELEKET_MERCHANT_ID` и `HELEKET_API_KEY`.
3. Настройте `HELEKET_CURRENCY`.
4. Скопируйте URL вебхука из админ-панели и укажите его в кабинете Heleket.
5. При необходимости задайте `HELEKET_TO_CURRENCY` и `HELEKET_NETWORK`.
7. При необходимости включите `HELEKET_VERIFY_WEBHOOK_SIGNATURE`.
8. Для IP-фильтрации заполните `HELEKET_TRUSTED_IPS`.
4. При необходимости задайте `HELEKET_TO_CURRENCY` и `HELEKET_NETWORK`.
5. Проверьте `HELEKET_RETURN_URL` и `HELEKET_SUCCESS_URL`.
6. Настройте `HELEKET_LIFETIME_SECONDS`.
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)
## 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 используются напрямую и поддерживаются в legacy-ценах и JSON-каталоге тарифов.
@@ -171,6 +266,8 @@ Telegram Stars используются напрямую и поддержива
### Ограничения
- Отдельный платежный webhook не нужен.
- Stars-события приходят через webhook Telegram-бота: `WEBHOOK_BASE_URL` + `/tg/webhook`.
- XTR/Stars-докупки не конвертируются без явно заданного курса.
### Справочник
+20 -13
View File
@@ -28,6 +28,8 @@ JSON-каталог может содержать несколько тариф
- настройка premium-раздела: названия RU/EN, premium Internal Squads, месячный premium-лимит и пакеты докупки premium-трафика в платежной валюте/Stars;
- настройка базового HWID-лимита и пакетов докупки устройств.
Порядок продаваемых вариантов управляется в админке перетаскиванием строк: это работает для периодов покупки подписки, traffic-пакетов, обычных докупок трафика, premium-докупок и HWID-пакетов. Такой же порядок сохраняется в JSON и затем используется в Web App и Telegram-боте.
После сохранения изменения применяются к новым запросам Web App сразу, потому что конфиг тарифов загружается из JSON при обращении. Уже созданные подписки сохраняют свой `tariff_key`; при удалении или отключении тарифа проверьте, что активные подписки с этим ключом не требуют дальнейшего продления или смены.
Подробности по админ-панели, правам доступа, сохранению настроек и списку разделов есть в [админ-панели](admin-panel.md).
@@ -57,7 +59,7 @@ Legacy-поля остаются алиасами: `prices_rub`, `conversion_rat
Платежные провайдеры не принимают произвольный код валюты одинаково. Бот фильтрует способы оплаты и блокирует создание платежа, если текущая валюта каталога не поддерживается провайдером:
| Провайдер | Валюты по умолчанию |
| --- | --- |
| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| YooKassa | `RUB` |
| WATA | `RUB`, `USD`, `EUR` |
| FreeKassa | `RUB`, `USD`, `EUR`, `UAH`, `KZT` |
@@ -114,7 +116,7 @@ Legacy-поля остаются алиасами: `prices_rub`, `conversion_rat
Основные поля:
| Поле | Назначение |
| --- | --- |
| ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `default_tariff` | Тариф по умолчанию для первичного выбора и привязки активных подписок без `tariff_key`. |
| `default_currency` | Валюта цен по умолчанию для JSON-каталога. По умолчанию `rub`; `stars` запрещен, потому что Stars используют отдельные цены. |
| `tariffs[].key` | Стабильный ключ тарифа. Используется в платежах, подписках и смене тарифа. |
@@ -125,29 +127,29 @@ Legacy-поля остаются алиасами: `prices_rub`, `conversion_rat
| `tariffs[].premium_names` | Название premium-раздела по языкам. Используется в карточке лимита, модалке докупки premium-трафика и предупреждениях. Если поле не задано, используется `Premium-серверы` / `Premium servers`. |
| `tariffs[].premium_squad_uuids` | Internal Squads с отдельным premium-лимитом. Ноды для учета берутся автоматически из accessible nodes этих сквадов через API панели. |
| `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[].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`-тарифа также используются:
| Поле | Назначение |
| --- | --- |
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `monthly_gb` | Базовый месячный лимит трафика тарифа. `0` означает безлимит. |
| `prices` | Generic-цены периодов по валютам, например `{ "usd": { "1": 4.99 } }`. |
| `prices_rub` | Legacy-цены периодов в рублях, ключ - количество месяцев. Эквивалент `prices.rub`. |
| `prices_stars` | Цены периодов в Telegram Stars. |
| `referral_bonus_days_inviter` | Бонус пригласившему в днях для каждого периода. Ключ - количество месяцев, как в `enabled_periods`. |
| `referral_bonus_days_referee` | Бонус приглашенному в днях для каждого периода. Ключ - количество месяцев, как в `enabled_periods`. |
| `enabled_periods` | Периоды, доступные для покупки. |
| `topup_packages` | Пакеты докупки трафика именно для этого тарифа. Если поле не задано или списки пустые, докупка для тарифа не показывается в Web App и Telegram-боте. |
| `enabled_periods` | Периоды, доступные для покупки. Порядок элементов в массиве задаёт порядок периодов на витрине (в Telegram-боте и Web App) — отсортируйте их так, как нужно показывать. В веб-админке этот порядок меняется перетаскиванием строк периодов. |
| `topup_packages` | Пакеты докупки трафика именно для этого тарифа. Если поле не задано или списки пустые, докупка для тарифа не показывается в Web App и Telegram-боте. Порядок строк задает порядок докупок на витрине и меняется drag&drop в админке. |
Для `traffic`-тарифа используются:
| Поле | Назначение |
| --- | --- |
| `traffic_packages` | Пакеты трафика в GB по валютам каталога и Telegram Stars. |
| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `traffic_packages` | Пакеты трафика в GB по валютам каталога и Telegram Stars. Порядок пакетов в списке задаёт порядок на витрине (в Telegram-боте и Web App): сначала идут пакеты валюты каталога, затем пакеты, доступные только за Stars. В веб-админке порядок меняется перетаскиванием строк. |
| `conversion_rate_per_gb` | Курс для конвертации оставшихся дней period-тарифа в GB при смене на traffic-тариф в валюте каталога. |
| `conversion_rate_rub_per_gb` | Legacy-алиас для рублевых каталогов. |
@@ -278,7 +280,10 @@ limit_after = current_used + balance_after
- полная цена HWID-пакета берется из `prices[duration_months]`; если периода нет, используется fallback `price * duration_months`;
- фактическая цена докупки считается пропорционально оплачиваемому окну `valid_from -> valid_until` относительно периода подписки и фиксируется в платежe;
- для Telegram Stars цена округляется вверх до целого Stars, для платежной валюты — вверх до копеек; `min_price` защищает от микроплатежей в конце периода;
- при продлении подписки докупленные устройства не продлеваются автоматически: старая докупка действует до прежнего `end_date`, а для нового срока создается отдельная `hwid_devices_renewal`-покупка;
- кнопка докупки устройств всегда покупает устройства только для текущей активной подписки и только до текущего срока ее действия;
- при продлении подписки пользователь видит отдельный чекбокс продления действующих докупленных устройств; чекбокс включен по умолчанию, цена считается по текущему тарифу и добавляется в тот же платеж подписки;
- если пользователь продлил подписку без продления устройств, старая докупка продолжает действовать до своего `valid_until`, а Web App показывает предупреждение о возможном временном возврате к базовому лимиту;
- админские продления, промокоды и реферальные бонусы добавляют фиксированное количество дней отдельно к подписке и к действующим докупкам устройств, не склеивая даты окончания;
- `traffic`-тарифы не показывают и не принимают докупку HWID-устройств, потому что у них нет срока подписки;
- при смене тарифа базовый лимит берется из целевого тарифа, а неиспользованная стоимость HWID-докупок в платежной валюте конвертируется в дни нового period-тарифа или GB traffic-тарифа; XTR/Stars-докупки не конвертируются без явного курса и продолжают жить по своему `valid_until`;
- история докупок пишется в `hwid_device_purchases`;
@@ -293,7 +298,7 @@ limit_after = current_used + balance_after
Варианты расчета:
| Переход | Поведение |
| --- | --- |
| ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `period -> period` | Остаток оплаченных дней оценивается по legacy-полю `effective_monthly_price_rub`, где хранится месячная цена в платежной валюте каталога, затем пересчитывается в дни целевого тарифа через месячную цену целевого тарифа. Неиспользованная стоимость HWID-докупок в платежной валюте добавляется к этому расчету как дополнительные дни. Количество дней округляется вниз. |
| `period -> period` с доплатой | Если целевой тариф дороже, может быть создан платеж `tariff_upgrade`; неиспользованная стоимость HWID-докупок в платежной валюте уменьшает сумму доплаты. После оплаты применяется целевой тариф, а конвертированные HWID-окна закрываются. |
| `period -> traffic` | Остаток оплаченных дней и неиспользованная стоимость HWID-докупок в платежной валюте конвертируются в GB по `conversion_rate_per_gb` или минимальной цене GB из пакетов целевого тарифа. |
@@ -314,7 +319,7 @@ limit_after = current_used + balance_after
В платежах используются поля:
| Поле | Назначение |
| --- | --- |
| ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| `sale_mode` | Тип продажи: `subscription`, `traffic_package`, `topup`, `premium_topup`, `tariff_upgrade`, `hwid_devices`. |
| `tariff_key` | Ключ тарифа, к которому относится платеж. |
| `purchased_gb` | Купленный объем GB для traffic-пакетов и докупки трафика. |
@@ -345,12 +350,14 @@ Remnawave ограничивает доступ при достижении `tra
Автопродление через 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-подписки.
Реферальные бонусы за оплату в 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 и другие параметры его текущего тарифа не пересчитываются под тариф приглашенного.
В 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 контейнеры.
Внешний вид настраивается в админке: раздел **Внешний вид** управляет логотипом, 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).
## Авторизация
+13 -9
View File
@@ -13,7 +13,6 @@ Web App поддерживает файловые темы, предпросмо
- включить или выключить применение темы в админ-панели;
- настроить масштаб логотипа на главной и экране входа;
- загрузить логотип файлом или по HTTPS-ссылке;
- включить emoji-логотип и выбрать способ его отрисовки;
- открыть предпросмотр темы через `/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` попадают в прод только при сборке собственного образа; опубликованный образ их не видит.
Важно: `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`
@@ -114,7 +115,8 @@ WEBAPP_DEFAULT_THEME=
"font_sans": "Inter, system-ui, sans-serif",
"font_logo": "Inter, system-ui, sans-serif",
"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_surface": "#11101c",
"admin_surface_2": "#090815",
@@ -131,7 +133,7 @@ WEBAPP_DEFAULT_THEME=
Поля верхнего уровня:
| Поле | Назначение |
| --- | --- |
| -------------------- | ------------------------------------------------------------------------------------------------------- |
| `key` | Уникальный ключ темы, 1-64 символа: латиница, цифры, `_` и `-`. Если ключ не указан, берется имя папки. |
| `names` | Локализованные названия, например `ru` и `en`. |
| `enabled` | Показывать тему пользователям. Отключенная тема не попадает в публичный каталог. |
@@ -147,7 +149,7 @@ WEBAPP_DEFAULT_THEME=
Поддерживаемые токены:
| Токен | CSS-переменная | Что меняет |
| --- | --- | --- |
| ------------------------- | --------------------------- | ---------------------------------------------------------------------------------------------------------- |
| `color_scheme` | `color-scheme` | Нативная светлая/темная схема браузера: `dark` или `light`. |
| `style_preset` | CSS-класс пресета | Сейчас `win95`/`windows95` добавляет `theme-preset-win95`; остальные значения не дают специального класса. |
| `accent` | `--accent` | Главный акцент: активные элементы, кнопки, прогресс, фокус. Только hex `#RGB` или `#RRGGBB`. |
@@ -166,7 +168,9 @@ WEBAPP_DEFAULT_THEME=
| `font_sans` | `--font-sans` | Основной шрифт интерфейса. |
| `font_logo` | `--font-logo` | Шрифт бренда и заголовка. |
| `font_mono` | `--font-mono` | Моноширинный шрифт. |
| `home_logo_scale` | `--home-logo-scale` | Масштаб логотипа на главной и входе, от `50` до `300` процентов. |
| `home_logo_scale_desktop` | `--home-logo-scale-desktop` | Масштаб логотипа на desktop layout, от `50` до `300` процентов. |
| `home_logo_scale_mobile` | `--home-logo-scale-mobile` | Масштаб логотипа на mobile layout, от `50` до `300` процентов. |
| `home_logo_scale` | `--home-logo-scale` | Legacy fallback для старых тем; используется, если desktop/mobile token не задан. |
| `admin_bg` | `--admin-bg` | Фон админ-панели. |
| `admin_surface` | `--admin-surface` | Основные карточки админки. |
| `admin_surface_2` | `--admin-surface-2` | Вторичные поверхности админки. |
@@ -254,7 +258,8 @@ CSS можно писать для пользовательской части
content: "";
width: 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 и масштаб логотипа.
В админке можно менять 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`, если токенов мало.
@@ -329,7 +334,6 @@ CSS можно писать для пользовательской части
11. Сделайте тему дефолтной.
Есть два способа:
- в админке выбрать тему и сохранить;
- указать `WEBAPP_DEFAULT_THEME=neon` в `.env`, если нужен жесткий override на уровне окружения.
+1 -1
View File
@@ -87,7 +87,7 @@ openssl rand -hex 32
Перед первым запуском создайте каталоги и отдайте их пользователю контейнера:
```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
chown -R 10001:10001 data
chmod -R u+rwX data
+2
View File
@@ -11,6 +11,8 @@
- [Админка: пользователи](/demo/admin/users)
- [Админка: бэкапы](/demo/admin/backups)
- [Пробный период](/demo/home?mock=trial)
- [Email-only: нужен Telegram для триала](/demo/home?mock=trial-telegram)
- [Email-only: нужен Telegram для реферального бонуса](/demo/home?mock=referral-telegram)
- [Докупка устройств](/demo/devices?mock=devices)
- [Запуск бота для Telegram-уведомлений](/demo/home?mock=notifications)
- [Вход и регистрация](/demo/login?mock=auth)
+39 -1
View File
@@ -13,6 +13,44 @@ docker compose ps
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` поднимает:
- `postgres` и `redis` с проверками здоровья;
@@ -339,7 +377,7 @@ distributed lock; код подготовлен к нескольким репл
Перед первым запуском на сервере заранее дайте права пользователю контейнера `10001`:
```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
chown -R 10001:10001 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) |
| [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.
Автоматический скрипт ниже рассчитан именно на родственный стек `remnawave-tg-shop`, где структура БД и Docker volumes известны заранее. Для других ботов нужен отдельный адаптер экспорта/импорта.
## Короткий путь без смены ветки и сборки
Если вы используете только готовые Docker-образы и не собираете проект
локально, git-команды из ручного способа не нужны. Достаточно обновить
compose-файл до одного из готовых примеров в `deploy/examples` и
перенести/обновить БД. Самый прямой вариант без встроенного обратного прокси -
`deploy/examples/no-proxy/docker-compose.yml`; для Caddy, Nginx и Newt есть
такие же самостоятельные папки.
Минимальная последовательность:
Для переноса со старого родственного стека используйте общий install wizard:
```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
В меню выберите `Install new stack and run migration` для нового
сервера или `Run migration only`, если compose-папка уже готова. Затем
выберите источник `Old remnawave-tg-shop`.
# Подготовьте стек из готовых образов.
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
Wizard поддерживает два способа переноса:
# Нужно только при переходе со старого имени volume remnawave-tg-shop-db-data.
# Если у вас уже есть remnawave-minishop-db-data, этот шаг пропустите.
docker run --rm \
- `Copy old Docker volumes` - для старого compose-стека на том же Docker host.
Скрипт подготавливает новый stack, копирует
`remnawave-tg-shop-db-data` в `remnawave-minishop-db-data`, опционально
переносит Caddy volumes и запускает новый stack.
- `Dump from a source PostgreSQL DSN` - для старой БД, доступной по DSN.
Скрипт поднимает целевой `postgres`, сбрасывает целевую БД, делает
`pg_dump` из старой БД, восстанавливает дамп в compose-БД и запускает
сервис `migrate`.
В обоих режимах старые volumes и старая БД не удаляются автоматически.
## Как работает перенос
`remnawave-tg-shop` и `remnawave-minishop` имеют совместимую историю схемы.
После переноса старой PostgreSQL-БД сервис `migrate` накатывает недостающие
миграции из `backend/db/migrator.py`: сначала применяются `Base.metadata`,
затем последовательные записи `schema_migrations`. Это one-shot сервис: он
должен завершиться с кодом `0`, после чего стартуют `backend` и `worker`.
При volume-миграции wizard:
1. Останавливает известные контейнеры старого и переходного стеков, если вы
подтверждаете этот шаг.
2. Запускает `docker compose up --no-start`, чтобы Docker Compose создал новые
volumes.
3. Копирует старый volume БД:
```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"
```
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
```
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.
Сервис `migrate` сам применит недостающие схемные миграции к перенесённому
тому PostgreSQL. Новые тома `remnawave-minishop-redis-data` и
`remnawave-minishop-shop-data` переносить не нужно: они создаются пустыми.
Этот документ описывает обновление стека, поднятого по `remnawave-tg-shop`
(включая последний релиз `v2.7.0` форка `kavore/remnawave-tg-shop`), до
текущей версии `remnawave-minishop` (v3.4+). Между этими версиями произошли
две независимые перетряски, и скрипт пытается отработать обе одной командой:
1. **Переименование стека** (v3.1.0): контейнеры и тома `remnawave-tg-shop-*`
стали `remnawave-minishop-*`. Простой `docker compose up -d` после
`git pull` создаёт пустую БД — без переноса тома данные теряются.
2. **Разделение бота на сервисы** (v3.4.0): из одного контейнера выделены
`backend`, `worker`, `frontend`, `migrate` + новые `postgres`, `redis`.
Появились новые volumes `redis-data` и `shop-data`, новые обязательные
переменные окружения, а схема БД обновляется автоматически one-shot
сервисом `migrate`.
После миграции `docker compose ps` должен показать как минимум: `backend`,
`worker`, `frontend`, `postgres`, `redis` (running) и `migrate` (exited 0).
Логи: `docker compose logs -f backend worker frontend`.
Доступные пути:
- [Автоматический](#автоматический-способ-через-скрипт) — скрипт-обёртка
останавливает старый стек, накатывает свежий код, переносит том БД,
поднимает новые сервисы. Идемпотентный.
- [Ручной](#ручной-способ) — те же шаги командами, для тех, кому нужно
понимать каждое действие или выполнить выборочно.
В обоих случаях:
- старые тома **не удаляются** автоматически — это безопасный бэкап на случай
отката;
- сертификаты Caddy (если используется `deploy/examples/caddy/docker-compose.yml`)
тоже переносятся, чтобы Let's Encrypt не выписывал их заново и не упереться
в rate limit;
- схема БД обновляется автоматически: при первом `docker compose up -d` сервис
`migrate` накатывает на перенесённый том все недостающие миграции (от
alembic-схемы v2.7.0 до текущей).
Если целевой DB volume уже непустой, wizard не перетирает его молча: он
останавливается и просит отдельное подтверждение на продолжение без копирования
старой БД.
## Что меняется в архитектуре
@@ -93,256 +63,55 @@ docker compose \
| Версия | Сервисы |
| --- | --- |
| `v2.7.0` | `remnawave-tg-shop`, `remnawave-tg-shop-db` |
| `v3.1.xv3.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` |
Внутри Docker-сети сервисы доступны по коротким DNS-именам (`backend`, `worker`,
`frontend`, `postgres`, `redis`), а не по полному `container_name`. Это важно
для внешнего reverse-proxy — см. раздел [Внешний reverse-proxy](#внешний-reverse-proxy) ниже.
| `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` |
**Volumes**:
| Volume | v2.7.0 | v3.4+ | Что внутри |
| --- | --- | --- | --- |
| `remnawave-minishop-db-data` | переименовать из `remnawave-tg-shop-db-data` | переносится скриптом | PostgreSQL |
| `remnawave-minishop-redis-data` | — | создаётся пустым | Redis (FSM, rate-limit, cache, очередь вебхуков, distributed locks) |
| `remnawave-minishop-shop-data` | — | создаётся пустым | `/app/data`: `tariffs.json`, темы Web App, кэш логотипа/emoji |
| `remnawave-minishop-caddy-data` / `remnawave-minishop-caddy-config` | переименовать из `remnawave-tg-shop-caddy-*` | переносится скриптом | только при Caddy-варианте |
| Volume | Что происходит |
| --- | --- |
| `remnawave-minishop-db-data` | переносится из `remnawave-tg-shop-db-data` или восстанавливается из source DSN |
| `remnawave-minishop-redis-data` | создается пустым |
| `remnawave-minishop-shop-data` | создается пустым; runtime-файлы в `/app/data` дальше настраиваются через админку или вручную |
| `remnawave-minishop-caddy-data` / `remnawave-minishop-caddy-config` | переносятся из `remnawave-tg-shop-caddy-*`, если старый стек использовал Caddy |
`redis-data` и `shop-data` стартуют пустыми — это нормально. Redis ничего
долгоживущего не хранит (всё либо FSM, либо кеш с TTL), а `data/` инициализируется
из образа при первом старте (`tariffs.json` пуст пока вы не сконфигурируете
тарифы через админ-панель).
Доступные compose-профили: `docker-compose.yml`,
`deploy/examples/caddy/docker-compose.yml`,
`deploy/examples/nginx/docker-compose.yml`,
`deploy/examples/newt/docker-compose.yml`,
`deploy/examples/no-proxy/docker-compose.yml`.
## Переменные окружения, которые могли исчезнуть или переехать
## Переменные окружения
Перед запуском нового стека проверьте `.env`. Ниже — только то, что точно
менялось между v2.7.0 и v3.4+:
Перед запуском нового стека проверьте `.env`. Самые важные изменения:
| Было (v2.7.0) | Стало (v3.4+) | Действие |
| Было | Стало | Действие |
| --- | --- | --- |
| `TELEGRAM_WEBHOOK_SECRET` | `WEBHOOK_SECRET_TOKEN` | Переименовать. Если пусто — будет сгенерирован при старте, но тогда Telegram переустановит webhook (на это не реагирует существующий запрос). |
| `TELEGRAM_WEBHOOK_PATH` | удалена | Путь вебхука теперь генерируется из `BOT_TOKEN` автоматически. |
| `REQUIRED_CHANNEL_SUBSCRIBE_TO_USE` | удалена | Гейт включается автоматически, как только задан `REQUIRED_CHANNEL_ID`. |
| `STARS_PROVIDER_TOKEN` | удалена | Telegram Stars (XTR) используются напрямую. |
| `REFERRAL_ENABLED` | удалена | Реферальная программа активна по умолчанию. В legacy-режиме без JSON-каталога отключайте платежные бонусы через нули в `REFERRAL_BONUS_DAYS_*` и `REFEREE_BONUS_DAYS_*`; в JSON-тарифах обнуляйте или удаляйте `referral_bonus_days_inviter` и `referral_bonus_days_referee` у period-тарифов. |
| `POSTGRES_HOST=remnawave-tg-shop-db` | в `.env``remnawave-minishop-db` или пусто | Под compose значение всё равно переопределяется на сервисное имя `postgres` (см. `environment:` в compose-файлах), поэтому скрипт правит `.env` только для bare-metal сценариев. |
| `WEBHOOK_BASE_URL` | **обязательна** | Polling-режим удалён, без публичного URL бот не стартует. |
| | `REDIS_URL=redis://redis:6379/0` | Обязательна для воркера, очередей и rate-limit. По умолчанию в compose-файлах уже задана. |
| — | `WEBAPP_SESSION_SECRET`, `WEBAPP_ENABLED`, `WEBAPP_SERVER_PORT`, `WEBAPP_THEMES_DIR`, `TARIFFS_CONFIG_PATH` | Новые настройки Web App / тарифного каталога. Безопасные дефолты есть в `.env.example`. |
| `TELEGRAM_WEBHOOK_SECRET` | `WEBHOOK_SECRET_TOKEN` | Перенести значение или сгенерировать новый stable secret. |
| `TELEGRAM_WEBHOOK_PATH` | удалена | Путь вебхука теперь рассчитывается автоматически. |
| `REQUIRED_CHANNEL_SUBSCRIBE_TO_USE` | удалена | Гейт включается, когда задан `REQUIRED_CHANNEL_ID`. |
| `STARS_PROVIDER_TOKEN` | удалена | Telegram Stars используются напрямую. |
| `POSTGRES_HOST=remnawave-tg-shop-db` | `postgres` внутри Compose | В compose-файлах `POSTGRES_HOST` переопределяется service name `postgres`. |
| `WEBHOOK_BASE_URL` | обязательна | Без публичного URL backend не стартует корректно. |
| - | `REDIS_URL=redis://redis:6379/0` | В compose-профилях задано автоматически. |
| - | `WEBAPP_SESSION_SECRET`, `WEBAPP_ENABLED`, `TARIFFS_CONFIG_PATH` | Новые настройки Web App и каталога тарифов. |
Полный референс — [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
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-имя сервиса | Порт |
| Назначение | Service | Port |
| --- | --- | --- |
| Telegram / платежные / вебхуки панели | `backend` | `8080` |
| Health-чек | `backend` | `8080` (`/healthz`) |
| Web App API (`/api/*`, `/auth/*`, ассеты тем и логотипов) | `backend` | `8081` (доступен только из Docker-сети) |
| Статический фронт Web App | `frontend` | `80` (внутри `frontend` уже проксирует `/api/*` и `/auth/*` на `backend:8081`) |
| Telegram, платежные и panel webhooks | `backend` | `8080` |
| Health-check | `backend` | `8080` (`/healthz`) |
| Web App API и auth | `backend` | `8081` внутри Docker-сети |
| Статический Web App frontend | `frontend` | `80` |
Минимальная замена для внешнего Nginx, который раньше слал всё на один
upstream:
Минимальная схема для внешнего Nginx:
```nginx
upstream remnawave_backend_webhooks { server backend:8080; }
@@ -351,8 +120,6 @@ upstream remnawave_frontend { server frontend:80; }
server {
server_name app.domain.com;
listen 443 ssl;
http2 on;
# ssl_certificate / ssl_certificate_key — без изменений
location /webhook/ { proxy_pass http://remnawave_backend_webhooks; }
location /healthz { proxy_pass http://remnawave_backend_webhooks; }
@@ -360,24 +127,25 @@ server {
}
```
Полные примеры (Caddy, Nginx, Newt/Pangolin и запуск без обратного прокси) — в
[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
пример, который уже знает правильную маршрутизацию.
Готовые Caddy, Nginx, Pangolin/Newt и no-proxy профили уже содержат нужную
маршрутизацию.
## Если что-то пошло не так
## Проверка
`migrate` упал → читайте `docker compose logs migrate`. Том БД остался
не тронут, можно откатиться, переключив compose-файл обратно на старый
коммит и подняв старый стек на старом томе `remnawave-tg-shop-db-data`
(пока вы его не удалили).
После переноса:
`backend` не стартует → чаще всего `WEBHOOK_BASE_URL` пуст, либо
`WEBHOOK_SECRET_TOKEN` отличается от того, что Telegram ждёт. Поставьте
свежий секрет в `.env` и перезапустите — Telegram переустановит webhook
автоматически.
```bash
docker compose ps
docker compose logs migrate
docker compose logs -f backend worker frontend
```
Web App пуст / 502 → проверьте, что `frontend` живёт (`docker compose ps`),
а внешний прокси шлёт на `frontend:80`, а не на старый
`remnawave-tg-shop:8000`.
`migrate` должен завершиться успешно, а `backend`, `worker`, `frontend`,
`postgres` и `redis` должны быть running/healthy.
Когда убедитесь, что новый 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
```
+224 -11
View File
@@ -74,11 +74,15 @@
const ACTIVATION_PENDING_WATCH_MAX_ATTEMPTS = 45;
const ACTIVATION_RESUME_CHECK_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 {
activationPaymentFailed,
createActivationHandoff,
} 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 { invalidateWebappTariffOptionCaches } from "./lib/webapp/billingOptionCache.js";
import { runWebappBoot } from "./lib/webapp/webappBoot.js";
@@ -171,6 +175,7 @@
let telegramNotificationsBotOpenedAt = 0;
let telegramNotificationsResumeRefreshBusy = false;
let telegramNotificationsResumeLastCheckAt = 0;
let telegramLinkPendingActionBusy = false;
let promoCode = "";
let promoBusy = false;
let promoStatus = "";
@@ -358,13 +363,9 @@
} = $accountStore);
$: brandTitle = CFG.title || "/minishop";
$: brandEmoji = CFG.logoEmoji || "🫥";
$: brandEmojiFont = CFG.logoEmojiFont || "system";
$: brand = normalizeBrand({
title: brandTitle,
logoUrl: CFG.logoUseEmoji ? "" : CFG.logoUrl,
emoji: brandEmoji,
emojiFont: brandEmojiFont,
logoUrl: CFG.logoUrl,
});
$: faviconBrand = normalizeBrand({
...brand,
@@ -494,7 +495,7 @@
$: telegramProfileName = telegramName(user);
$: profileEmail = user?.email || t("wa_settings_email_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();
$: userAgreementUrl = String(CFG.userAgreementUrl || "").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() {
const link = telegramNotificationsStartLink;
telegramNotificationsBotOpenedAt = Date.now();
@@ -1453,11 +1609,14 @@
getCsrfToken: () => csrfToken,
});
if (mode === "app" && screen !== "admin") {
const telegramActionHandled = await continueTelegramLinkPendingAction();
if (!telegramActionHandled) {
if (hasPendingActivationHandoff()) await loadData({ fresh: true });
const shown = await maybeShowActivationSuccessDialog({ source: "boot" });
if (!shown) startPendingActivationWatch();
}
}
}
function stripTopupQueryFromUrl() {
if (typeof window === "undefined") return;
@@ -1524,6 +1683,7 @@
selectedPlan: null,
selectedTariffKey: "",
paymentStep: "tariff",
renewHwidDevices: true,
selectedMethod: payload.payment_methods?.[0]?.id || "",
}));
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() {
if (trialBusy) return;
trialBusy = true;
@@ -1920,7 +2129,7 @@
await loadData({ fresh: true });
await maybeShowActivationSuccessDialog({ source: "trial", force: true });
} catch (error) {
const message = error?.message || t("wa_trial_activation_failed");
const message = trialActivationFailureMessage(error);
trialActivationError = message;
showToast(message);
} finally {
@@ -2077,9 +2286,6 @@
]);
return [
"WEBAPP_LOGO_URL",
"WEBAPP_LOGO_USE_EMOJI",
"WEBAPP_LOGO_EMOJI",
"WEBAPP_LOGO_EMOJI_FONT",
"WEBAPP_FAVICON_URL",
"WEBAPP_FAVICON_USE_CUSTOM",
"WEBAPP_LOGO_FAVICON_URL",
@@ -2286,7 +2492,9 @@
{premiumTrafficTopupUnlocked}
{regularTrafficTopupBarClickable}
{regularTrafficTopupUnlocked}
{referral}
{subscription}
{linkTelegramBusy}
{telegramNotificationsNeedPrompt}
{telegramNotificationsStartLink}
{telegramNotificationsStatus}
@@ -2294,6 +2502,8 @@
{trafficMode}
{trialBusy}
{activateTrial}
{linkTelegramAndActivateTrial}
{linkTelegramAndClaimReferralWelcome}
{openTelegramNotificationsBot}
openConnectLink={openInstallOrConnect}
{openPaymentModal}
@@ -2323,9 +2533,11 @@
{brandTitle}
{subscription}
{trialBusy}
{linkTelegramBusy}
trialResult={trialActivationResult}
trialError={trialActivationError}
{activateTrial}
{linkTelegramAndActivateTrial}
openInstallOrConnect={openTrialInstallOrConnect}
{goHome}
{t}
@@ -2428,6 +2640,7 @@
bind:paymentStep={$billingStore.paymentStep}
bind:selectedMethod={$billingStore.selectedMethod}
bind:selectedPlan={$billingStore.selectedPlan}
bind:renewHwidDevices={$billingStore.renewHwidDevices}
bind:selectedTariffKey={$billingStore.selectedTariffKey}
bind:setPasswordCode={$accountStore.setPasswordCode}
bind:setPasswordConfirm={$accountStore.setPasswordConfirm}
+6 -6
View File
@@ -31,7 +31,7 @@
export let mockData = {};
const title = config.title || "/minishop";
const logoEmoji = config.logoEmoji || "🫥";
const logoUrl = config.logoUrl || "/webapp-default-logo.webp";
const plans = mockData.plans || [];
const sub = mockData.subscription || {};
const methods = mockData.payment_methods || [];
@@ -78,7 +78,7 @@
<PhoneFrame number="1" label="Главный экран">
<main class="home-layout">
<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>
</div>
<div class="home-bottom">
@@ -113,7 +113,7 @@
<PhoneFrame number="2" label="Выбор тарифа">
<div class="preview-header">
<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>
</div>
</div>
@@ -198,7 +198,7 @@
<PhoneFrame number="6" label="Пригласить друга">
<div class="preview-header">
<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>
</div>
</div>
@@ -221,7 +221,7 @@
<PhoneFrame number="7" label="Настройки">
<div class="preview-header">
<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>
</div>
</div>
@@ -252,7 +252,7 @@
<PhoneFrame number="8" label="Логин" wide>
<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>
<p>Войдите в свой аккаунт</p>
</div>
@@ -1,16 +1,10 @@
<script>
import { Check, ExternalLink, FileText, RefreshCw, Save } from "$components/ui/icons.js";
import {
AdminBadge,
AdminButton,
AdminEmptyState,
AdminSelect,
} from "$components/patterns/admin/index.js";
import { AdminBadge, AdminButton, AdminEmptyState } from "$components/patterns/admin/index.js";
import { Checkbox, ColorInput, FileInput, Input, RangeInput } from "$components/ui/index.js";
import { Switch } from "$components/ui/primitives.js";
import { getContext, onDestroy, onMount } from "svelte";
import BrandMark from "$lib/webapp/BrandMark.svelte";
import { localizedThemeName } from "$lib/webapp/themeStyle.js";
export let at;
@@ -26,9 +20,6 @@
"SUBSCRIPTION_MINI_APP_URL",
"WEBAPP_PRIMARY_COLOR",
"WEBAPP_LOGO_URL",
"WEBAPP_LOGO_USE_EMOJI",
"WEBAPP_LOGO_EMOJI",
"WEBAPP_LOGO_EMOJI_FONT",
"WEBAPP_FAVICON_URL",
"WEBAPP_FAVICON_USE_CUSTOM",
"WEBAPP_LOGO_FAVICON_URL",
@@ -42,8 +33,7 @@
$: fieldMap = new Map(appearanceFields.map((field) => [field.key, field]));
$: activeKey = themesCatalog.default_theme;
$: logoUrl = valueForKey("WEBAPP_LOGO_URL");
$: useEmojiLogo = boolValue(valueForKey("WEBAPP_LOGO_USE_EMOJI"));
$: currentLogoUrl = !useEmojiLogo ? pendingLogoPreviewUrl || logoUrl || brand?.logoUrl || "" : "";
$: currentLogoUrl = pendingLogoPreviewUrl || logoUrl || brand?.logoUrl || "";
$: previewLogoUrl =
logoPreviewNonce && currentLogoUrl ? withLogoCacheBust(currentLogoUrl) : currentLogoUrl;
$: persistedUseCustomFavicon = boolValue(
@@ -59,28 +49,12 @@
$: useCustomFavicon = faviconUseCustomDraft;
$: faviconUrl = valueForKey("WEBAPP_FAVICON_URL", appFaviconUrl);
$: logoFaviconUrl = valueForKey("WEBAPP_LOGO_FAVICON_URL");
$: generatedFaviconUrl = !useEmojiLogo
? logoFaviconUrl || appFaviconUrl || previewLogoUrl || ""
: "";
$: generatedFaviconUrl = logoFaviconUrl || appFaviconUrl || previewLogoUrl || "";
$: currentFaviconUrl = useCustomFavicon
? pendingFaviconPreviewUrl || faviconUrl || ""
: generatedFaviconUrl;
$: previewFaviconUrl =
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) =>
isAppearanceSettingKey(key)
).length;
@@ -138,15 +112,6 @@
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) {
return withCacheBust(url, logoPreviewNonce);
}
@@ -213,10 +178,8 @@
return `#${hex}`;
}
function homeLogoScale(theme) {
const scale = Number(theme.tokens?.home_logo_scale || 100);
if (!Number.isFinite(scale)) return 100;
return Math.min(300, Math.max(50, Math.round(scale)));
function homeLogoScale(theme, mode) {
return themesStore.resolveThemeHomeLogoScale(theme, mode);
}
function openThemeAccentPicker(theme) {
@@ -241,7 +204,6 @@
if (uploaded?.faviconUrl) {
settingsStore.setFieldValue("WEBAPP_LOGO_FAVICON_URL", uploaded.faviconUrl);
}
settingsStore.setFieldValue("WEBAPP_LOGO_USE_EMOJI", false);
if (logoFileInput) logoFileInput.value = "";
});
}
@@ -256,7 +218,6 @@
if (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() {
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) =>
[
"WEBAPP_LOGO_URL",
"WEBAPP_LOGO_USE_EMOJI",
"WEBAPP_LOGO_EMOJI",
"WEBAPP_LOGO_EMOJI_FONT",
"WEBAPP_FAVICON_URL",
"WEBAPP_FAVICON_USE_CUSTOM",
"WEBAPP_LOGO_FAVICON_URL",
@@ -354,17 +294,21 @@
themesStore.setThemeAccent(theme.key, value);
}
function setThemeHomeLogoScale(theme, value) {
themesStore.setThemeHomeLogoScale(theme.key, value);
function setThemeHomeLogoScale(theme, mode, 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) {
if (event?.target?.closest?.("button,input,label")) return;
if (isThemeControlTarget(event?.target)) return;
if (!themesSaving) themesStore.setCurrentTheme(theme.key);
}
function handleThemeKeydown(event, theme) {
if (event?.target?.closest?.("button,input,label")) return;
if (isThemeControlTarget(event?.target)) return;
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
selectTheme(theme);
@@ -395,13 +339,7 @@
<header class="admin-card-head">
<div>
<h3>{at("appearance_brand_title", {}, "Логотип")}</h3>
<small
>{at(
"appearance_brand_sub",
{},
"Файл, ссылка или подтвержденный emoji-логотип"
)}</small
>
<small>{at("appearance_brand_sub", {}, "Загрузите логотип файлом или по ссылке")}</small>
</div>
<div class="admin-editor-section-actions">
{#if dirtyCount}
@@ -424,7 +362,7 @@
</header>
<div class="admin-card-body appearance-logo-grid">
<div class="appearance-logo-preview">
{#if !useEmojiLogo && previewLogoUrl && !logoPreviewFailed}
{#if previewLogoUrl && !logoPreviewFailed}
<img
class="appearance-logo-image"
src={previewLogoUrl}
@@ -435,8 +373,6 @@
logoPreviewFailed = true;
}}
/>
{:else if useEmojiLogo}
<BrandMark brand={logoBrand} size="lg" />
{:else}
<span class="appearance-logo-empty" aria-hidden="true"></span>
{/if}
@@ -476,39 +412,6 @@
</AdminButton>
</div>
</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>
@@ -525,8 +428,6 @@
faviconPreviewFailed = true;
}}
/>
{:else if !useCustomFavicon && useEmojiLogo}
<BrandMark brand={logoBrand} size="lg" />
{:else}
<span class="appearance-logo-empty" aria-hidden="true"></span>
{/if}
@@ -682,12 +583,12 @@
/>
<span>{at("themes_use_in_admin", {}, "Использовать в админке")}</span>
</label>
<label class="admin-theme-card-option appearance-logo-scale-row">
<span
<div class="admin-theme-card-option appearance-logo-scale-row">
<span class="appearance-logo-scale-label"
>{at(
"appearance_theme_home_logo_scale",
"appearance_theme_home_logo_scale_desktop",
{},
"Логотип на главной и входе"
"Логотип на десктопе"
)}</span
>
<RangeInput
@@ -695,9 +596,13 @@
min="50"
max="300"
step="5"
ariaLabel={at("appearance_theme_home_logo_scale", {}, "Home logo scale")}
value={homeLogoScale(theme)}
onValueChange={(value) => setThemeHomeLogoScale(theme, value)}
ariaLabel={at(
"appearance_theme_home_logo_scale_desktop",
{},
"Desktop logo scale"
)}
value={homeLogoScale(theme, "desktop")}
onValueChange={(value) => setThemeHomeLogoScale(theme, "desktop", value)}
/>
<span class="appearance-logo-scale-value">
<Input
@@ -706,12 +611,48 @@
min="50"
max="300"
step="5"
value={homeLogoScale(theme)}
oninput={(event) => setThemeHomeLogoScale(theme, event.currentTarget.value)}
value={homeLogoScale(theme, "desktop")}
oninput={(event) =>
setThemeHomeLogoScale(theme, "desktop", event.currentTarget.value)}
/>
%
</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">
<AdminButton
size="sm"
@@ -815,22 +756,14 @@
display: none;
}
.appearance-url-row,
.appearance-emoji-grid {
.appearance-url-row {
display: grid;
gap: 8px;
max-width: 520px;
}
.appearance-url-row {
grid-template-columns: minmax(0, 1fr) max-content;
width: 100%;
}
.appearance-emoji-grid {
grid-template-columns: minmax(0, 360px) max-content;
}
:global(.appearance-control.input),
:global(.appearance-control.admin-btn),
:global(.appearance-control.admin-select-trigger) {
@@ -952,10 +885,15 @@
.appearance-logo-scale-row {
display: grid;
grid-template-columns: auto minmax(96px, 1fr) auto;
grid-template-columns: minmax(112px, 0.8fr) minmax(96px, 1fr) auto;
width: 100%;
}
.appearance-logo-scale-label {
min-width: 0;
line-height: 1.25;
}
:global(.appearance-logo-scale-range) {
width: 100%;
accent-color: var(--accent);
@@ -1012,9 +950,16 @@
height: auto;
}
.appearance-url-row,
.appearance-emoji-grid {
.appearance-url-row {
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>
@@ -1,16 +1,27 @@
<script>
import { Textarea } from "$components/ui/index.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 { AdminButton, AdminSelect } from "$components/patterns/admin/index.js";
export let at;
const broadcastStore = getContext("broadcastStore");
$: ({ broadcastTarget, broadcastText, broadcastBusy, broadcastResult } = $broadcastStore);
$: ({ broadcastTarget, broadcastText, broadcastBusy, broadcastResult, broadcastCounts } =
$broadcastStore);
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>
<div class="admin-card">
@@ -24,7 +35,7 @@
<span>{at("broadcast_label_audience", {}, "Аудитория")}</span>
<AdminSelect
value={broadcastTarget}
items={BROADCAST_TARGET_OPTIONS}
items={targetOptions}
ariaLabel={at("broadcast_label_audience", {}, "Аудитория")}
onValueChange={(value) => broadcastStore.updateField({ broadcastTarget: value })}
/>
@@ -348,6 +348,7 @@
devices: "Устройства",
subscription_guides: "Connection guides",
system: "Система",
migrations: "Миграции",
};
return adminText(`settings_section_${id}`, {}, map[id] || id);
}
@@ -1,5 +1,5 @@
<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 Dialog from "$components/ui/dialog.svelte";
import { Plus, Save, Trash2, X } from "$components/ui/icons.js";
@@ -32,16 +32,6 @@
}));
$: defaultCurrencyKey = normalizeCurrencyKey(tariffsCatalog?.default_currency || "rub");
$: defaultCurrencyCode = defaultCurrencyKey.toUpperCase();
$: currencyPackageLabel = at(
"tariff_btn_package_currency",
{ currency: defaultCurrencyCode },
`Пакет ${defaultCurrencyCode}`
);
$: currencyPaymentLabel = at(
"payment_default_currency",
{ currency: defaultCurrencyCode },
`Оплата ${defaultCurrencyCode}`
);
$: currencyPriceColumnLabel = at(
"tariff_col_price_currency",
{ currency: defaultCurrencyCode },
@@ -413,31 +403,31 @@
>
</div>
<div class="admin-editor-section-actions">
<AdminButton
size="sm"
onclick={() => tariffsStore.addDraftRow("premiumTopupRubRows", { gb: 10, price: "" })}
><Plus size={12} /> {currencyPackageLabel}</AdminButton
>
<AdminButton
size="sm"
onclick={() =>
tariffsStore.addDraftRow("premiumTopupStarsRows", { gb: 10, price: "" })}
><Plus size={12} /> {at("tariff_btn_package_stars", {}, "Пакет")}</AdminButton
tariffsStore.addDraftRow("premiumTopupRows", { gb: 10, price: "", stars: "" })}
><Plus size={12} /> {at("tariff_btn_package", {}, "Пакет")}</AdminButton
>
</div>
</header>
<div class="admin-package-columns">
{#if tariffDraft.premiumTopupRows.length}
<div class="admin-row-editor">
<span class="admin-row-editor-caption">{currencyPaymentLabel}</span>
{#if tariffDraft.premiumTopupRubRows.length}
<div class="admin-row-editor-line admin-row-editor-header">
<div class="admin-row-editor-line admin-row-editor-drag admin-row-editor-header">
<span></span>
<span>{at("tariff_col_volume_gb", {}, "Объём, GB")}</span>
<span>{currencyPriceColumnLabel}</span>
<span>{at("tariff_col_price_stars_full", {}, "Цена, ⭐ Stars")}</span>
<span></span>
</div>
{/if}
{#each tariffDraft.premiumTopupRubRows as row, index}
<div class="admin-row-editor-line">
<Sortable
items={tariffDraft.premiumTopupRows}
class="admin-row-editor-line admin-row-editor-drag"
handleLabel={at("tariff_package_reorder", {}, "Перетащите, чтобы изменить порядок")}
onReorder={(from, to) => tariffsStore.moveDraftRow("premiumTopupRows", from, to)}
let:item={row}
let:index
>
<Input
class="input"
type="number"
@@ -456,44 +446,13 @@
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}
<div class="admin-row-editor-line admin-row-editor-header">
<span>{at("tariff_col_volume_gb", {}, "Объём, GB")}</span>
<span>{at("tariff_col_price_stars", {}, "Цена, ⭐")}</span>
<span></span>
</div>
{/if}
{#each tariffDraft.premiumTopupStarsRows as row, index}
<div class="admin-row-editor-line">
<Input
class="input"
type="number"
min="0.1"
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="1"
placeholder="100"
bind:value={row.price}
bind:value={row.stars}
aria-label={at(
"tariff_label_price_stars",
{},
@@ -503,13 +462,12 @@
<AdminButton
size="sm"
variant="danger"
onclick={() => tariffsStore.removeDraftRow("premiumTopupStarsRows", index)}
onclick={() => tariffsStore.removeDraftRow("premiumTopupRows", index)}
aria-label={at("btn_delete", {}, "Удалить")}><Trash2 size={13} /></AdminButton
>
</Sortable>
</div>
{/each}
</div>
</div>
{/if}
</section>
</Tabs.Content>
@@ -552,7 +510,8 @@
</p>
{:else}
<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>{currencyPriceColumnLabel}</span>
<span>{at("tariff_col_price_stars_full", {}, "Цена, ⭐ Stars")}</span>
@@ -560,8 +519,14 @@
<span>{at("tariff_col_ref_referee", {}, "Бонус приглашённому")}</span>
<span></span>
</div>
{#each tariffDraft.periodRows as row, index}
<div class="admin-row-editor-line admin-row-editor-6">
<Sortable
items={tariffDraft.periodRows}
class="admin-row-editor-line admin-row-editor-period"
handleLabel={at("tariff_period_reorder", {}, "Перетащите, чтобы изменить порядок")}
onReorder={(from, to) => tariffsStore.moveDraftRow("periodRows", from, to)}
let:item={row}
let:index
>
<Input
class="input"
type="number"
@@ -614,8 +579,7 @@
>
<Trash2 size={13} />
</AdminButton>
</div>
{/each}
</Sortable>
</div>
{/if}
</section>
@@ -635,28 +599,29 @@
<div class="admin-editor-section-actions">
<AdminButton
size="sm"
onclick={() => tariffsStore.addDraftRow("trafficRubRows", { gb: 10, price: "" })}
><Plus size={12} /> {currencyPackageLabel}</AdminButton
>
<AdminButton
size="sm"
onclick={() => tariffsStore.addDraftRow("trafficStarsRows", { gb: 10, price: "" })}
><Plus size={12} /> {at("tariff_btn_package_stars", {}, "Пакет ⭐")}</AdminButton
onclick={() =>
tariffsStore.addDraftRow("trafficRows", { gb: 10, price: "", stars: "" })}
><Plus size={12} /> {at("tariff_btn_package", {}, "Пакет")}</AdminButton
>
</div>
</header>
<div class="admin-package-columns">
{#if tariffDraft.trafficRows.length}
<div class="admin-row-editor">
<span class="admin-row-editor-caption">{currencyPaymentLabel}</span>
{#if tariffDraft.trafficRubRows.length}
<div class="admin-row-editor-line admin-row-editor-header">
<div class="admin-row-editor-line admin-row-editor-drag admin-row-editor-header">
<span></span>
<span>{at("tariff_col_volume_gb", {}, "Объём, GB")}</span>
<span>{currencyPriceColumnLabel}</span>
<span>{at("tariff_col_price_stars_full", {}, "Цена, ⭐ Stars")}</span>
<span></span>
</div>
{/if}
{#each tariffDraft.trafficRubRows as row, index}
<div class="admin-row-editor-line">
<Sortable
items={tariffDraft.trafficRows}
class="admin-row-editor-line admin-row-editor-drag"
handleLabel={at("tariff_package_reorder", {}, "Перетащите, чтобы изменить порядок")}
onReorder={(from, to) => tariffsStore.moveDraftRow("trafficRows", from, to)}
let:item={row}
let:index
>
<Input
class="input"
type="number"
@@ -675,56 +640,24 @@
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}
<div class="admin-row-editor-line admin-row-editor-header">
<span>{at("tariff_col_volume_gb", {}, "Объём, GB")}</span>
<span>{at("tariff_col_price_stars", {}, "Цена, ⭐")}</span>
<span></span>
</div>
{/if}
{#each tariffDraft.trafficStarsRows as row, index}
<div class="admin-row-editor-line">
<Input
class="input"
type="number"
min="0.1"
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="1"
placeholder="150"
bind:value={row.price}
bind:value={row.stars}
aria-label={at("tariff_label_price_stars", {}, "Цена пакета в Telegram Stars")}
/>
<AdminButton
size="sm"
variant="danger"
onclick={() => tariffsStore.removeDraftRow("trafficStarsRows", index)}
onclick={() => tariffsStore.removeDraftRow("trafficRows", index)}
aria-label={at("btn_delete", {}, "Удалить")}><Trash2 size={13} /></AdminButton
>
</Sortable>
</div>
{/each}
</div>
</div>
{/if}
</section>
{/if}
</Tabs.Content>
@@ -748,28 +681,29 @@
<div class="admin-editor-section-actions">
<AdminButton
size="sm"
onclick={() => tariffsStore.addDraftRow("topupRubRows", { gb: 10, price: "" })}
><Plus size={12} /> {currencyPackageLabel}</AdminButton
>
<AdminButton
size="sm"
onclick={() => tariffsStore.addDraftRow("topupStarsRows", { gb: 10, price: "" })}
><Plus size={12} /> {at("tariff_btn_package_stars", {}, "Пакет ⭐")}</AdminButton
onclick={() =>
tariffsStore.addDraftRow("topupRows", { gb: 10, price: "", stars: "" })}
><Plus size={12} /> {at("tariff_btn_package", {}, "Пакет")}</AdminButton
>
</div>
</header>
<div class="admin-package-columns">
{#if tariffDraft.topupRows.length}
<div class="admin-row-editor">
<span class="admin-row-editor-caption">{currencyPaymentLabel}</span>
{#if tariffDraft.topupRubRows.length}
<div class="admin-row-editor-line admin-row-editor-header">
<div class="admin-row-editor-line admin-row-editor-drag admin-row-editor-header">
<span></span>
<span>{at("tariff_col_volume_gb", {}, "Объём, GB")}</span>
<span>{currencyPriceColumnLabel}</span>
<span>{at("tariff_col_price_stars_full", {}, "Цена, ⭐ Stars")}</span>
<span></span>
</div>
{/if}
{#each tariffDraft.topupRubRows as row, index}
<div class="admin-row-editor-line">
<Sortable
items={tariffDraft.topupRows}
class="admin-row-editor-line admin-row-editor-drag"
handleLabel={at("tariff_package_reorder", {}, "Перетащите, чтобы изменить порядок")}
onReorder={(from, to) => tariffsStore.moveDraftRow("topupRows", from, to)}
let:item={row}
let:index
>
<Input
class="input"
type="number"
@@ -788,56 +722,24 @@
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}
<div class="admin-row-editor-line admin-row-editor-header">
<span>{at("tariff_col_volume_gb", {}, "Объём, GB")}</span>
<span>{at("tariff_col_price_stars", {}, "Цена, ⭐")}</span>
<span></span>
</div>
{/if}
{#each tariffDraft.topupStarsRows as row, index}
<div class="admin-row-editor-line">
<Input
class="input"
type="number"
min="0.1"
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="1"
placeholder="75"
bind:value={row.price}
bind:value={row.stars}
aria-label={at("tariff_label_price_stars", {}, "Цена пакета в Telegram Stars")}
/>
<AdminButton
size="sm"
variant="danger"
onclick={() => tariffsStore.removeDraftRow("topupStarsRows", index)}
onclick={() => tariffsStore.removeDraftRow("topupRows", index)}
aria-label={at("btn_delete", {}, "Удалить")}><Trash2 size={13} /></AdminButton
>
</Sortable>
</div>
{/each}
</div>
</div>
{/if}
</section>
{:else}
<p class="admin-muted">
@@ -872,28 +774,29 @@
<div class="admin-editor-section-actions">
<AdminButton
size="sm"
onclick={() => tariffsStore.addDraftRow("hwidRubRows", { count: 1, price: "" })}
><Plus size={12} /> {currencyPackageLabel}</AdminButton
>
<AdminButton
size="sm"
onclick={() => tariffsStore.addDraftRow("hwidStarsRows", { count: 1, price: "" })}
><Plus size={12} /> {at("tariff_btn_package_stars", {}, "Пакет ⭐")}</AdminButton
onclick={() =>
tariffsStore.addDraftRow("hwidRows", { count: 1, price: "", stars: "" })}
><Plus size={12} /> {at("tariff_btn_package", {}, "Пакет")}</AdminButton
>
</div>
</header>
<div class="admin-package-columns">
{#if tariffDraft.hwidRows.length}
<div class="admin-row-editor">
<span class="admin-row-editor-caption">{currencyPaymentLabel}</span>
{#if tariffDraft.hwidRubRows.length}
<div class="admin-row-editor-line admin-row-editor-header">
<div class="admin-row-editor-line admin-row-editor-drag admin-row-editor-header">
<span></span>
<span>{at("tariff_col_hwid_count", {}, "+ устройств")}</span>
<span>{currencyPriceColumnLabel}</span>
<span>{at("tariff_col_price_stars_full", {}, "Цена, ⭐ Stars")}</span>
<span></span>
</div>
{/if}
{#each tariffDraft.hwidRubRows as row, index}
<div class="admin-row-editor-line">
<Sortable
items={tariffDraft.hwidRows}
class="admin-row-editor-line admin-row-editor-drag"
handleLabel={at("tariff_package_reorder", {}, "Перетащите, чтобы изменить порядок")}
onReorder={(from, to) => tariffsStore.moveDraftRow("hwidRows", from, to)}
let:item={row}
let:index
>
<Input
class="input"
type="number"
@@ -916,60 +819,24 @@
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}
<div class="admin-row-editor-line admin-row-editor-header">
<span>{at("tariff_col_hwid_count", {}, "+ устройств")}</span>
<span>{at("tariff_col_price_stars", {}, "Цена, ⭐")}</span>
<span></span>
</div>
{/if}
{#each tariffDraft.hwidStarsRows as row, index}
<div class="admin-row-editor-line">
<Input
class="input"
type="number"
min="1"
step="1"
placeholder="1"
bind:value={row.count}
aria-label={at(
"tariff_label_hwid_count_full",
{},
"Сколько устройств добавляет пакет"
)}
/>
<Input
class="input"
type="number"
min="0"
step="1"
placeholder="50"
bind:value={row.price}
bind:value={row.stars}
aria-label={at("tariff_label_price_stars", {}, "Цена пакета в Telegram Stars")}
/>
<AdminButton
size="sm"
variant="danger"
onclick={() => tariffsStore.removeDraftRow("hwidStarsRows", index)}
onclick={() => tariffsStore.removeDraftRow("hwidRows", index)}
aria-label={at("btn_delete", {}, "Удалить")}><Trash2 size={13} /></AdminButton
>
</Sortable>
</div>
{/each}
</div>
</div>
{/if}
</section>
</Tabs.Content>
</Tabs.Root>
@@ -1,5 +1,5 @@
<script>
import { Input } from "$components/ui/index.js";
import { Input, Textarea } from "$components/ui/index.js";
import {
ChevronRight,
RefreshCw,
@@ -31,12 +31,30 @@
"TRIAL_DURATION_DAYS",
"TRIAL_TRAFFIC_LIMIT_GB",
"TRIAL_TRAFFIC_STRATEGY",
"TRIAL_WITHOUT_TELEGRAM_ENABLED",
"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_RESET_KEYS = ["TRIAL_TRAFFIC_STRATEGY"];
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 = [
[
"1",
@@ -102,6 +120,7 @@
.map((field) => [field.key, field])
);
$: 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) =>
Boolean(settingsDirty[key])
).length;
@@ -129,7 +148,7 @@
? `${first.gb} GB ${at("at", {}, "за")} ${fmtMoney(first.price, currencyCode)}`
: at("tariff_traffic_packages", {}, "Пакеты трафика");
}
const months = [...(tariff.enabled_periods || [])].sort((a, b) => a - b);
const months = [...(tariff.enabled_periods || [])];
return months
.map((month) => {
const rub =
@@ -410,6 +429,53 @@
{/if}
</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>
</section>
@@ -677,6 +743,352 @@
</div>
</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-overview-grid">
<article class="admin-card admin-tariff-currency-card">
@@ -53,6 +53,43 @@
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");
$: ({
@@ -71,6 +108,7 @@
userReferralsPage,
userReferralsPageSize,
premiumUnlimitedDraft,
hwidUnlimitedDraft,
userDetailTab,
userLogs,
userLogsTotal,
@@ -404,7 +442,7 @@
</li>
<li>
<span>{at("user_label_tariff", {}, "Тариф")}</span><strong
>{openedUserDetail.active_subscription.tariff_key || "—"}</strong
>{subscriptionDisplayLabel(openedUserDetail.active_subscription)}</strong
>
</li>
<li>
@@ -417,6 +455,11 @@
>{openedUserDetail.active_subscription.provider || "—"}</strong
>
</li>
<li>
<span>{at("user_label_hwid_devices", {}, "HWID-устройства")}</span><strong
>{hwidLimitLabel(openedUserDetail.active_subscription)}</strong
>
</li>
</ul>
<div class="admin-traffic-summary">
<AdminTrafficCard
@@ -507,6 +550,37 @@
</p>
{/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}
<Separator.Root class="admin-separator" />
<div class="admin-subsection-title">
@@ -520,10 +594,7 @@
{#each openedUserDetail.subscriptions.slice(0, 8) as sub}
<div class="admin-mini-list-row">
<div>
<strong
>{sub.tariff_key ||
at("user_history_no_tariff", {}, "Без тарифа")}</strong
>
<strong>{subscriptionDisplayLabel(sub)}</strong>
<small
>{at(
"user_history_until",
@@ -688,6 +759,41 @@
{at("user_btn_extend", {}, "Продлить")}
</AdminButton>
</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>
</div>
@@ -849,6 +955,66 @@
</div>
</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">
<AdminSectionHeader
title={at("user_traffic_grant_title", {}, "Выдать трафик")}
@@ -595,7 +595,7 @@
min-width: 82px;
}
:global(.admin-users-filter-toggle) {
:global(.admin-btn.admin-users-filter-toggle) {
display: none;
position: relative;
align-items: center;
@@ -846,7 +846,7 @@
padding-inline: 10px;
}
:global(.admin-users-filter-toggle) {
:global(.admin-btn.admin-users-filter-toggle) {
display: inline-flex;
min-width: 38px;
padding-inline: 10px;
+6 -2
View File
@@ -29,9 +29,13 @@ function applyDemoThemeTokens(catalog) {
const themes = catalog?.themes || [];
for (const theme of themes) {
theme.tokens = theme.tokens || {};
const currentScale = Number(theme.tokens.home_logo_scale);
const logoScaleKeys = ["home_logo_scale_desktop", "home_logo_scale_mobile"];
for (const key of ["home_logo_scale", ...logoScaleKeys]) {
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.home_logo_scale = DEMO_HOME_LOGO_SCALE;
theme.tokens[key] = DEMO_HOME_LOGO_SCALE;
}
}
}
}
@@ -6,6 +6,7 @@ export function createBroadcastStore({ api, onToast, at }) {
broadcastText: "",
broadcastBusy: false,
broadcastResult: null,
broadcastCounts: null,
});
const BROADCAST_TARGET_OPTIONS = [
@@ -13,8 +14,23 @@ export function createBroadcastStore({ api, onToast, at }) {
{ value: "active", label: at("broadcast_target_active", {}, "С подпиской") },
{ value: "inactive", label: at("broadcast_target_inactive", {}, "Без подписки") },
{ 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() {
let text = "";
let target = "";
@@ -56,6 +72,7 @@ export function createBroadcastStore({ api, onToast, at }) {
update: state.update,
runBroadcast,
updateField,
loadCounts,
BROADCAST_TARGET_OPTIONS,
};
}

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