Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9393962510 | ||
|
|
2c32e81638 | ||
|
|
0032c1804b | ||
|
|
a98ea65ef4 | ||
|
|
5218ede0f1 | ||
|
|
5b19ba2c2f | ||
|
|
687fc03e8c | ||
|
|
74272039c5 | ||
|
|
eadb86faf2 | ||
|
|
4fa18a1262 | ||
|
|
7d3178bd48 | ||
|
|
88d99fb578 | ||
|
|
8366a73575 | ||
|
|
dcdddeb1c5 | ||
|
|
d7840d3a86 | ||
|
|
6a40d0c9ce | ||
|
|
3d579b12d4 | ||
|
|
0d932b0915 | ||
|
|
2b763efef9 | ||
|
|
a0ea2261f4 | ||
|
|
5bb1400917 | ||
|
|
63ec3a6152 | ||
|
|
3896c455b8 | ||
|
|
d97afbec18 | ||
|
|
21079f78dc | ||
|
|
3e922d8edb | ||
|
|
391487811b | ||
|
|
939bc37995 | ||
|
|
c2344824dc | ||
|
|
7cf1d577f3 | ||
|
|
ecf779763c | ||
|
|
1578a9da36 | ||
|
|
80e5f0c80d | ||
|
|
df8f2636d2 | ||
|
|
45543983c2 | ||
|
|
b531d4de3b | ||
|
|
eda5d3e633 | ||
|
|
e4ef52df8b | ||
|
|
3e1f1cb787 | ||
|
|
74da8ab98e | ||
|
|
19f7daff6c | ||
|
|
cf3af17243 | ||
|
|
93353db511 | ||
|
|
e7f93a5f47 | ||
|
|
c2ab881be2 | ||
|
|
4a84bba697 | ||
|
|
cda3b741a1 | ||
|
|
2e82febdcc | ||
|
|
bd7710d03c | ||
|
|
1a66d67e44 | ||
|
|
c1e2fe2c95 | ||
|
|
ea4ee4c4a7 | ||
|
|
fbc3e193bf | ||
|
|
3541f2f78b | ||
|
|
acc222da41 | ||
|
|
8c0e778388 | ||
|
|
c6c5352813 | ||
|
|
067d6fb59f | ||
|
|
c3e55bc853 | ||
|
|
09be82aad5 | ||
|
|
49781af921 | ||
|
|
604ba873dc | ||
|
|
923ff9b208 | ||
|
|
7e7a2e58a2 | ||
|
|
e27886e15d | ||
|
|
7fe8e676cd | ||
|
|
6803c7801f | ||
|
|
cab963dcdc | ||
|
|
001e54cfe2 | ||
|
|
5e257c0d3a | ||
|
|
19f0f27a3b | ||
|
|
6fbb8eebec | ||
|
|
32616c80ab | ||
|
|
3186be1e3e | ||
|
|
1329eb4fe2 | ||
|
|
913b2d428e |
+16
-1
@@ -56,7 +56,10 @@ PANEL_API_URL=https://panel.yourdomain.tld/api
|
||||
PANEL_API_KEY=
|
||||
|
||||
# Shared secret for validating incoming Remnawave webhooks.
|
||||
# Use the same value when configuring the webhook in Remnawave panel.
|
||||
# Create or set this secret in Remnawave Panel, then paste the same value here
|
||||
# or into Admin -> System -> Settings -> Remnawave Panel.
|
||||
# In Remnawave Panel, set WEBHOOK_URL to WEBHOOK_BASE_URL + /webhook/panel,
|
||||
# for example: https://app.example.com/webhook/panel
|
||||
PANEL_WEBHOOK_SECRET=
|
||||
|
||||
# Host port that publishes the backend webhook server from Docker Compose.
|
||||
@@ -70,3 +73,15 @@ FRONTEND_PORT=8082
|
||||
# Reverse proxy IPs/CIDRs trusted for X-Forwarded-For.
|
||||
# Keep loopback for local proxy; add your proxy network if needed.
|
||||
TRUSTED_PROXIES=127.0.0.1,::1
|
||||
|
||||
# ─── Anonymous install telemetry (opt-out) ──────────────────────────────
|
||||
# Once a day the worker sends a single anonymous "heartbeat" so the project
|
||||
# maintainer can see how many installs are active and which versions/OSes are
|
||||
# used. It contains an opaque random install id and coarse facts only:
|
||||
# version, OS/arch, Python version, language, enabled payment providers and a
|
||||
# user-count RANGE (e.g. "51-200"). No bot token, domain, user data or any
|
||||
# personal information is ever sent. Full details: docs/configuration/telemetry.md
|
||||
#
|
||||
# Set to False to disable, or toggle it any time in Admin -> System ->
|
||||
# "Anonymous install analytics" (applies without a restart).
|
||||
TELEMETRY_ENABLED=True
|
||||
|
||||
@@ -1,2 +1,9 @@
|
||||
.gitattributes text eol=lf
|
||||
*.sh text eol=lf
|
||||
.github/workflows/*.yml text eol=lf
|
||||
deploy/docker/frontend/*.sh text eol=lf
|
||||
frontend/src/*.js text eol=lf
|
||||
frontend/src/**/*.js text eol=lf
|
||||
frontend/src/**/*.svelte text eol=lf
|
||||
frontend/scripts/*.mjs text eol=lf
|
||||
frontend/scripts/**/*.mjs text eol=lf
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
name: Docker build & push (reusable)
|
||||
|
||||
# Reusable workflow that builds the three image targets defined in
|
||||
# deploy/docker/Dockerfile (backend, worker, frontend) and optionally pushes
|
||||
# them to both ghcr.io and Docker Hub under the 3252a8/ namespace.
|
||||
#
|
||||
# Called by:
|
||||
# - docker-dev.yml (tag_mode: dev, push: true) on pushes to dev
|
||||
# - docker-release.yml (tag_mode: release, push: true) on pushes to main
|
||||
# - ci.yml (tag_mode: dev, push: false) on pull requests
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
push:
|
||||
description: "Push the built images to the registries"
|
||||
type: boolean
|
||||
default: true
|
||||
tag_mode:
|
||||
description: "Tagging strategy: 'dev' or 'release'"
|
||||
type: string
|
||||
required: true
|
||||
|
||||
# No permissions block here on purpose: a reusable workflow cannot request more
|
||||
# than its caller grants, so the token scope is set by each caller
|
||||
# (docker-dev.yml / docker-release.yml grant packages: write to push; ci.yml
|
||||
# only needs contents: read for a no-push build).
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- target: backend
|
||||
image: remnawave-minishop-backend
|
||||
- target: worker
|
||||
image: remnawave-minishop-worker
|
||||
- target: frontend
|
||||
image: remnawave-minishop-frontend
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# Full history + tags: the Dockerfile's version-builder stage runs
|
||||
# `git describe --tags` against the copied .git tree.
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Resolve release version
|
||||
id: version
|
||||
if: inputs.tag_mode == 'release'
|
||||
run: |
|
||||
# On a tag push github.ref_name is the tag (e.g. v3.4.5); for a
|
||||
# manual workflow_dispatch on a branch, fall back to the latest tag.
|
||||
if [ "${{ github.ref_type }}" = "tag" ]; then
|
||||
raw="${{ github.ref_name }}"
|
||||
else
|
||||
raw="$(git describe --tags --abbrev=0 2>/dev/null)"
|
||||
fi
|
||||
version="${raw#v}"
|
||||
if [ -z "$version" ]; then
|
||||
echo "::error::No git tag found to derive the release version from"
|
||||
exit 1
|
||||
fi
|
||||
echo "version=${version}" >> "$GITHUB_OUTPUT"
|
||||
echo "Release version: ${version}"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
if: inputs.push
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: inputs.push
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
3252a8/${{ matrix.image }}
|
||||
ghcr.io/3252a8/${{ matrix.image }}
|
||||
tags: |
|
||||
type=raw,value=dev,enable=${{ inputs.tag_mode == 'dev' }}
|
||||
type=raw,value=latest,enable=${{ inputs.tag_mode == 'release' }}
|
||||
type=raw,value=${{ steps.version.outputs.version }},enable=${{ inputs.tag_mode == 'release' }}
|
||||
|
||||
- name: Build${{ inputs.push && ' & push' || '' }} ${{ matrix.image }}
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: deploy/docker/Dockerfile
|
||||
target: ${{ matrix.target }}
|
||||
platforms: linux/amd64
|
||||
push: ${{ inputs.push }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
# The Dockerfile's version-builder appends a "-<branch>" suffix to the
|
||||
# internal version string for non-main builds. Force "main" on release
|
||||
# (the ref is the tag, not a branch) so release images stay un-suffixed.
|
||||
build-args: |
|
||||
GITHUB_REF_NAME=${{ inputs.tag_mode == 'release' && 'main' || github.ref_name }}
|
||||
cache-from: type=gha,scope=${{ matrix.target }}
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.target }}
|
||||
provenance: false
|
||||
@@ -0,0 +1,64 @@
|
||||
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.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Lint & format
|
||||
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"
|
||||
|
||||
- name: Install ruff
|
||||
run: pip install "ruff>=0.8.0"
|
||||
|
||||
- name: Ruff lint (Python)
|
||||
run: ruff check .
|
||||
|
||||
- name: Ruff format check (Python)
|
||||
run: ruff format --check .
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install frontend deps
|
||||
run: npm ci
|
||||
working-directory: frontend
|
||||
|
||||
- name: ESLint (frontend)
|
||||
run: npm run lint
|
||||
working-directory: frontend
|
||||
|
||||
- name: Prettier check (frontend)
|
||||
run: npm run format:check
|
||||
working-directory: frontend
|
||||
|
||||
build:
|
||||
name: Docker build
|
||||
uses: ./.github/workflows/_docker-build-push.yml
|
||||
with:
|
||||
push: false
|
||||
tag_mode: dev
|
||||
@@ -0,0 +1,27 @@
|
||||
name: Dependency review
|
||||
|
||||
# On PRs into main/dev, flag any newly added dependency that has a known
|
||||
# vulnerability or an incompatible license before it gets merged.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
dependency-review:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Dependency review
|
||||
uses: actions/dependency-review-action@v4
|
||||
with:
|
||||
fail-on-severity: high
|
||||
comment-summary-in-pr: on-failure
|
||||
@@ -0,0 +1,25 @@
|
||||
name: Dev images
|
||||
|
||||
# On every push to the dev branch, build all three images and push them to
|
||||
# ghcr.io and Docker Hub tagged `dev` and `dev-<short-sha>`.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: docker-dev-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
build-push:
|
||||
uses: ./.github/workflows/_docker-build-push.yml
|
||||
with:
|
||||
push: true
|
||||
tag_mode: dev
|
||||
secrets: inherit
|
||||
@@ -0,0 +1,28 @@
|
||||
name: Release images
|
||||
|
||||
# Build all three images and push them to ghcr.io and Docker Hub tagged
|
||||
# `latest` and the release version (the pushed tag with its leading `v`
|
||||
# stripped, e.g. v3.4.5 -> 3.4.5). Triggered only when a new v* tag is pushed,
|
||||
# so images are built once per release rather than on every commit to main.
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: docker-release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
build-push:
|
||||
uses: ./.github/workflows/_docker-build-push.yml
|
||||
with:
|
||||
push: true
|
||||
tag_mode: release
|
||||
secrets: inherit
|
||||
@@ -0,0 +1,96 @@
|
||||
name: Security
|
||||
|
||||
# Audits the full dependency set (pip-audit, npm audit) and runs a Trivy
|
||||
# filesystem scan (dependencies + Dockerfile/IaC misconfig). Trivy results are
|
||||
# uploaded to the Security -> Code scanning tab.
|
||||
#
|
||||
# pip-audit / npm audit are informational (continue-on-error): they surface
|
||||
# upstream/transitive advisories that aren't necessarily fixable in a given PR,
|
||||
# so they report in the logs without blocking merges. The PR gate for newly
|
||||
# introduced vulnerable deps is dependency-review.yml.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
push:
|
||||
branches: [main, dev]
|
||||
schedule:
|
||||
- cron: "27 4 * * 1" # weekly, Monday 04:27 UTC
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: security-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
python-audit:
|
||||
name: pip-audit
|
||||
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"
|
||||
|
||||
- name: Install pip-audit
|
||||
run: pip install pip-audit
|
||||
|
||||
- name: Audit Python dependencies
|
||||
continue-on-error: true
|
||||
run: pip-audit -r backend/requirements.txt
|
||||
|
||||
npm-audit:
|
||||
name: npm audit
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install frontend deps
|
||||
run: npm ci
|
||||
working-directory: frontend
|
||||
|
||||
- name: Audit npm dependencies
|
||||
continue-on-error: true
|
||||
run: npm audit --audit-level=high
|
||||
working-directory: frontend
|
||||
|
||||
trivy:
|
||||
name: Trivy filesystem scan
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Run Trivy
|
||||
uses: aquasecurity/trivy-action@v0.36.0
|
||||
with:
|
||||
scan-type: fs
|
||||
scan-ref: .
|
||||
format: sarif
|
||||
output: trivy-results.sarif
|
||||
severity: CRITICAL,HIGH
|
||||
ignore-unfixed: true
|
||||
|
||||
- name: Upload Trivy results
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
if: always()
|
||||
with:
|
||||
sarif_file: trivy-results.sarif
|
||||
category: trivy-fs
|
||||
@@ -92,11 +92,13 @@ docker compose logs -f backend worker frontend
|
||||
- `PANEL_API_URL`, `PANEL_API_KEY`, `PANEL_WEBHOOK_SECRET` - доступ к Remnawave;
|
||||
- остальные настройки удобнее задать в Web App админке.
|
||||
|
||||
В Remnawave Panel укажите `WEBHOOK_URL` как публичный адрес Minishop с путем `/webhook/panel`, например `https://app.example.com/webhook/panel`. Секрет вебхука задается в самой Remnawave Panel; это же значение вставьте в `PANEL_WEBHOOK_SECRET` в `.env` или в **Система -> Настройки -> Remnawave Panel** в админке.
|
||||
|
||||
После первого входа в админку настройте тарифы, платежные провайдеры, внешний вид, поддержку, уведомления и инструкции подключения через UI. Инструкции установки включены по умолчанию, читают Subscription Page config из Remnawave Panel и при проблемах с конфигом откатываются к обычной ссылке подключения. Полный справочник env-переменных: [docs/configuration/env-vars.md](docs/configuration/env-vars.md).
|
||||
|
||||
Для каталога тарифов используется `TARIFFS_CONFIG_PATH` со значением по умолчанию `data/tariffs.json`. Пример формата лежит в [data/tariffs.example.json](data/tariffs.example.json), подробности - в [docs/features/tariffs.md](docs/features/tariffs.md).
|
||||
|
||||
Если в Docker Compose включаете bind mount `./data:/app/data`, заранее создайте каталог и отдайте его пользователю контейнера. Это нужно для сохранения `data/tariffs.json`, каталога тем `data/themes`, кеша логотипа Web App и animated emoji:
|
||||
В compose-примерах `/app/data` монтируется из папки `./data` рядом с `docker-compose.yml`. Заранее создайте каталог и отдайте его пользователю контейнера. Это нужно для сохранения `data/tariffs.json`, каталога тем `data/themes`, кеша логотипа Web App и animated emoji:
|
||||
|
||||
```bash
|
||||
mkdir -p data/themes data/webapp-logo data/webapp-emoji
|
||||
|
||||
@@ -11,6 +11,7 @@ from bot.services.email_auth_service import EmailAuthService
|
||||
from bot.services.lknpd_service import LknpdService
|
||||
from bot.services.notification_service import NotificationService
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
from bot.services.panel_dry_run_api_service import PanelDryRunApiService
|
||||
from bot.services.panel_webhook_service import PanelWebhookService
|
||||
from bot.services.promo_code_service import PromoCodeService
|
||||
from bot.services.referral_service import ReferralService
|
||||
@@ -26,7 +27,11 @@ def build_core_services(
|
||||
i18n: JsonI18n,
|
||||
bot_username_for_default_return: str,
|
||||
):
|
||||
panel_service = PanelApiService(settings)
|
||||
panel_service = (
|
||||
PanelDryRunApiService(settings)
|
||||
if bool(getattr(settings, "panel_dry_run_enabled", False))
|
||||
else PanelApiService(settings)
|
||||
)
|
||||
subscription_service = SubscriptionService(settings, panel_service, bot, i18n)
|
||||
referral_service = ReferralService(settings, subscription_service, bot, i18n)
|
||||
promo_code_service = PromoCodeService(settings, subscription_service, bot, i18n)
|
||||
|
||||
@@ -37,7 +37,7 @@ from bot.services.settings_override_service import (
|
||||
from bot.utils import MessageContent, send_message_via_queue
|
||||
from bot.utils.message_queue import get_queue_manager
|
||||
from config.settings import Settings
|
||||
from config.tariffs_config import TariffsConfig
|
||||
from config.tariffs_config import TariffsConfig, default_payment_currency_code_for_settings
|
||||
from db.dal import (
|
||||
ad_dal,
|
||||
app_settings_dal,
|
||||
|
||||
@@ -169,12 +169,11 @@ async def admin_backups_restore_route(request: web.Request) -> web.Response:
|
||||
except (OSError, subprocess.SubprocessError, TimeoutError) as exc:
|
||||
logger.exception("Backup restore failed")
|
||||
return _error(500, "backup_restore_failed", str(exc))
|
||||
|
||||
if result.database_restored:
|
||||
finally:
|
||||
try:
|
||||
from db import database_setup
|
||||
|
||||
if database_setup.async_engine is not None:
|
||||
if restore_database and database_setup.async_engine is not None:
|
||||
await database_setup.async_engine.dispose()
|
||||
except Exception:
|
||||
logger.exception("Failed to dispose DB engine after backup restore")
|
||||
|
||||
@@ -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"}:
|
||||
if target not in {"all", "active", "inactive", "expired"}:
|
||||
target = "all"
|
||||
|
||||
queue_manager = get_queue_manager()
|
||||
@@ -22,6 +22,8 @@ async def admin_broadcast_route(request: web.Request) -> web.Response:
|
||||
user_ids = await user_dal.get_user_ids_with_active_subscription(session)
|
||||
elif target == "inactive":
|
||||
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)
|
||||
else:
|
||||
user_ids = await user_dal.get_all_active_user_ids_for_broadcast(session)
|
||||
|
||||
|
||||
@@ -143,12 +143,18 @@ def _payment_traffic_gb_split(payment: Payment) -> Tuple[Optional[float], Option
|
||||
return None, None
|
||||
|
||||
|
||||
def _payment_user_display_label(loaded_user: Any, payment_user_id: int) -> str:
|
||||
"""Human-facing name for payments tables: TG profile name, else email, else user id."""
|
||||
if loaded_user is None:
|
||||
return str(payment_user_id)
|
||||
def _user_display_label(
|
||||
loaded_user: Any,
|
||||
fallback_user_id: Optional[int],
|
||||
*,
|
||||
first_name: Optional[str] = None,
|
||||
last_name: Optional[str] = None,
|
||||
username: Optional[str] = None,
|
||||
email: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
"""Human-facing name: TG profile name, else email, else user id."""
|
||||
tid = getattr(loaded_user, "telegram_id", None)
|
||||
if tid is not None:
|
||||
if loaded_user is not None and tid is not None:
|
||||
fn = (getattr(loaded_user, "first_name", None) or "").strip()
|
||||
ln = (getattr(loaded_user, "last_name", None) or "").strip()
|
||||
full = f"{fn} {ln}".strip()
|
||||
@@ -157,10 +163,30 @@ def _payment_user_display_label(loaded_user: Any, payment_user_id: int) -> str:
|
||||
un = (getattr(loaded_user, "username", None) or "").strip()
|
||||
if un:
|
||||
return un if un.startswith("@") else f"@{un}"
|
||||
return str(payment_user_id)
|
||||
email = (getattr(loaded_user, "email", None) or "").strip()
|
||||
if email:
|
||||
return email
|
||||
elif loaded_user is not None:
|
||||
email = (getattr(loaded_user, "email", None) or "").strip()
|
||||
if email:
|
||||
return email
|
||||
fn = (first_name or "").strip()
|
||||
ln = (last_name or "").strip()
|
||||
full = f"{fn} {ln}".strip()
|
||||
if full:
|
||||
return full
|
||||
un = (username or "").strip()
|
||||
if un:
|
||||
return un if un.startswith("@") else f"@{un}"
|
||||
email_value = (email or "").strip()
|
||||
if email_value:
|
||||
return email_value
|
||||
if fallback_user_id is None:
|
||||
return None
|
||||
return str(fallback_user_id)
|
||||
|
||||
|
||||
def _payment_user_display_label(loaded_user: Any, payment_user_id: int) -> str:
|
||||
label = _user_display_label(loaded_user, payment_user_id)
|
||||
if label:
|
||||
return label
|
||||
return str(payment_user_id)
|
||||
|
||||
|
||||
@@ -229,16 +255,27 @@ def _serialize_ad(campaign: AdCampaign, totals: Optional[Dict[str, Any]] = None)
|
||||
|
||||
|
||||
def _serialize_log(entry: MessageLog) -> Dict[str, Any]:
|
||||
author_user = entry.__dict__.get("author_user")
|
||||
target_user = entry.__dict__.get("target_user")
|
||||
user_id = int(entry.user_id) if entry.user_id is not None else None
|
||||
target_user_id = int(entry.target_user_id) if entry.target_user_id is not None else None
|
||||
return {
|
||||
"log_id": int(entry.log_id),
|
||||
"user_id": int(entry.user_id) if entry.user_id else None,
|
||||
"user_id": user_id,
|
||||
"user_label": _user_display_label(
|
||||
author_user,
|
||||
user_id,
|
||||
first_name=entry.telegram_first_name,
|
||||
username=entry.telegram_username,
|
||||
),
|
||||
"telegram_username": entry.telegram_username,
|
||||
"telegram_first_name": entry.telegram_first_name,
|
||||
"email": getattr(getattr(entry, "author_user", None), "email", None),
|
||||
"email": getattr(author_user, "email", None),
|
||||
"event_type": entry.event_type,
|
||||
"content": entry.content,
|
||||
"is_admin_event": bool(entry.is_admin_event),
|
||||
"target_user_id": int(entry.target_user_id) if entry.target_user_id else None,
|
||||
"target_user_id": target_user_id,
|
||||
"target_user_label": _user_display_label(target_user, target_user_id),
|
||||
"timestamp": entry.timestamp.isoformat() if entry.timestamp else None,
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ def setup_admin_routes(app: web.Application) -> None:
|
||||
|
||||
router.add_get("/api/admin/users", admin_users_list_route)
|
||||
router.add_get("/api/admin/users/{user_id:-?\\d+}", admin_user_detail_route)
|
||||
router.add_get("/api/admin/users/{user_id:-?\\d+}/referrals", admin_user_referrals_route)
|
||||
router.add_get("/api/admin/users/{user_id:-?\\d+}/avatar", admin_user_avatar_route)
|
||||
router.add_post("/api/admin/users/{user_id:-?\\d+}/ban", admin_user_ban_route)
|
||||
router.add_post("/api/admin/users/{user_id:-?\\d+}/message", admin_user_message_route)
|
||||
|
||||
@@ -34,7 +34,7 @@ async def admin_stats_route(request: web.Request) -> web.Response:
|
||||
except Exception: # pragma: no cover - defensive
|
||||
payload["queue"] = None
|
||||
|
||||
payload["currency_symbol"] = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
|
||||
payload["currency_symbol"] = default_payment_currency_code_for_settings(settings)
|
||||
return _ok(payload)
|
||||
|
||||
|
||||
|
||||
@@ -21,9 +21,14 @@ async def admin_tariffs_get_route(request: web.Request) -> web.Response:
|
||||
"path": str(path),
|
||||
"catalog": {
|
||||
"default_tariff": "",
|
||||
"default_currency": "rub",
|
||||
"topup_packages_default": {"rub": [], "stars": []},
|
||||
"tariffs": [],
|
||||
},
|
||||
"provider_currency_support": _provider_currency_support_payload(
|
||||
settings,
|
||||
request.app,
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -32,6 +37,7 @@ async def admin_tariffs_get_route(request: web.Request) -> web.Response:
|
||||
"exists": True,
|
||||
"path": str(path),
|
||||
"catalog": _tariffs_config_payload(config),
|
||||
"provider_currency_support": _provider_currency_support_payload(settings, request.app),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -58,4 +64,51 @@ async def admin_tariffs_save_route(request: web.Request) -> web.Response:
|
||||
|
||||
await refresh_webapp_runtime_after_settings_change(request, updates={}, deletes=[])
|
||||
|
||||
return _ok({"exists": True, "path": str(path), "catalog": _tariffs_config_payload(config)})
|
||||
return _ok(
|
||||
{
|
||||
"exists": True,
|
||||
"path": str(path),
|
||||
"catalog": _tariffs_config_payload(config),
|
||||
"provider_currency_support": _provider_currency_support_payload(settings, request.app),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _provider_currency_support_payload(
|
||||
settings: Settings,
|
||||
app: web.Application,
|
||||
) -> List[Dict[str, Any]]:
|
||||
from bot.payment_providers import iter_provider_specs, resolve_provider_presentation
|
||||
|
||||
default_currency = default_payment_currency_code_for_settings(settings)
|
||||
providers: List[Dict[str, Any]] = []
|
||||
for spec in iter_provider_specs():
|
||||
presentation = resolve_provider_presentation(spec, settings)
|
||||
supported = spec.supported_currency_codes(settings)
|
||||
providers.append(
|
||||
{
|
||||
"id": spec.id,
|
||||
"provider_key": spec.provider_key,
|
||||
"label": presentation.webapp_label or spec.label,
|
||||
"telegram_label": presentation.telegram_label,
|
||||
"icon": presentation.webapp_icon,
|
||||
"enabled": spec.is_effectively_enabled(settings),
|
||||
"configured": spec.is_service_configured(app),
|
||||
"admin_only": spec.is_admin_only_enabled(settings),
|
||||
"price_source": spec.price_source,
|
||||
"currencies": list(supported) if supported is not None else None,
|
||||
"accepts_any_currency": supported is None,
|
||||
"supports_default_currency": spec.is_usable_for_payment_currency(
|
||||
settings,
|
||||
default_currency,
|
||||
),
|
||||
"directly_supports_default_currency": spec.supports_currency(
|
||||
settings,
|
||||
default_currency,
|
||||
),
|
||||
"default_currency": default_currency,
|
||||
"note": spec.currency_support_note,
|
||||
"docs_url": spec.currency_support_url,
|
||||
}
|
||||
)
|
||||
return providers
|
||||
|
||||
@@ -16,6 +16,7 @@ import hashlib
|
||||
from html import escape as html_escape
|
||||
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from sqlalchemy.orm import aliased
|
||||
|
||||
from bot.app.web.webapp.cache_helpers import invalidate_webapp_user_caches
|
||||
from bot.infra.redis import cache_delete_pattern, redis_key
|
||||
@@ -127,12 +128,15 @@ async def _load_admin_users_list_payload_uncached(
|
||||
active_subs = await _bulk_active_subscriptions_for_users(
|
||||
session, [u.user_id for u in users]
|
||||
)
|
||||
payment_summaries = await _bulk_user_payment_summaries(session, [u.user_id for u in users])
|
||||
referral_counts = await _bulk_user_referral_counts(session, [u.user_id for u in users])
|
||||
|
||||
serialized = []
|
||||
for user in users:
|
||||
payload = _serialize_user(user)
|
||||
status_payload = statuses.get(user.user_id) or {"status": "bot_only", "end_date": None}
|
||||
payload["panel_status"] = status_payload.get("status")
|
||||
payload["subscription_expires_at"] = status_payload.get("end_date")
|
||||
if status_payload.get("status") == "expired" and status_payload.get("end_date"):
|
||||
payload["panel_status_expired_at"] = status_payload["end_date"]
|
||||
payload["avatar_url"] = (
|
||||
@@ -141,6 +145,11 @@ async def _load_admin_users_list_payload_uncached(
|
||||
else None
|
||||
)
|
||||
payload["premium_traffic"] = _premium_traffic_list_payload(active_subs.get(user.user_id))
|
||||
payment_summary = payment_summaries.get(user.user_id) or {}
|
||||
payload["payments_total_amount"] = float(payment_summary.get("total_amount") or 0)
|
||||
payload["payments_count"] = int(payment_summary.get("count") or 0)
|
||||
payload["payments_currency"] = payment_summary.get("currency")
|
||||
payload["invited_users_count"] = int(referral_counts.get(user.user_id) or 0)
|
||||
serialized.append(payload)
|
||||
|
||||
return {
|
||||
@@ -255,6 +264,17 @@ async def _bulk_user_avatar_keys(session: AsyncSession, user_ids: List[int]) ->
|
||||
return {int(uid): (updated_at.isoformat() if updated_at else "") for uid, updated_at in rows}
|
||||
|
||||
|
||||
def _serialize_admin_user_with_avatar(user: User, avatar_keys: Dict[int, str]) -> Dict[str, Any]:
|
||||
payload = _serialize_user(user)
|
||||
user_id = int(user.user_id)
|
||||
payload["avatar_url"] = (
|
||||
f"/api/admin/users/{user_id}/avatar?v={avatar_keys[user_id]}"
|
||||
if user_id in avatar_keys
|
||||
else None
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
async def admin_user_avatar_route(request: web.Request) -> web.Response:
|
||||
"""Serve the cached Telegram avatar for any user (admin-only).
|
||||
|
||||
@@ -353,6 +373,88 @@ async def _bulk_active_subscriptions_for_users(
|
||||
return out
|
||||
|
||||
|
||||
def _user_payment_summary_sq():
|
||||
return (
|
||||
select(
|
||||
Payment.user_id.label("user_id"),
|
||||
sa_func.coalesce(sa_func.sum(Payment.amount), 0.0).label("payments_total_amount"),
|
||||
sa_func.count(Payment.payment_id).label("payments_count"),
|
||||
)
|
||||
.where(Payment.status == "succeeded")
|
||||
.group_by(Payment.user_id)
|
||||
.subquery(name="user_payment_summary")
|
||||
)
|
||||
|
||||
|
||||
def _user_referral_count_sq():
|
||||
referred_user = aliased(User)
|
||||
return (
|
||||
select(
|
||||
referred_user.referred_by_id.label("user_id"),
|
||||
sa_func.count(referred_user.user_id).label("invited_users_count"),
|
||||
)
|
||||
.where(referred_user.referred_by_id.is_not(None))
|
||||
.group_by(referred_user.referred_by_id)
|
||||
.subquery(name="user_referral_count")
|
||||
)
|
||||
|
||||
|
||||
def _user_subscription_expiry_sq():
|
||||
return (
|
||||
select(
|
||||
Subscription.user_id.label("user_id"),
|
||||
sa_func.max(Subscription.end_date).label("subscription_expires_at"),
|
||||
)
|
||||
.group_by(Subscription.user_id)
|
||||
.subquery(name="user_subscription_expiry")
|
||||
)
|
||||
|
||||
|
||||
async def _bulk_user_payment_summaries(
|
||||
session: AsyncSession,
|
||||
user_ids: List[int],
|
||||
) -> Dict[int, Dict[str, Any]]:
|
||||
if not user_ids:
|
||||
return {}
|
||||
|
||||
stmt = (
|
||||
select(
|
||||
Payment.user_id,
|
||||
sa_func.coalesce(sa_func.sum(Payment.amount), 0.0),
|
||||
sa_func.count(Payment.payment_id),
|
||||
sa_func.max(Payment.currency),
|
||||
)
|
||||
.where(Payment.user_id.in_(user_ids), Payment.status == "succeeded")
|
||||
.group_by(Payment.user_id)
|
||||
)
|
||||
rows = (await session.execute(stmt)).all()
|
||||
return {
|
||||
int(user_id): {
|
||||
"total_amount": float(total_amount or 0),
|
||||
"count": int(payments_count or 0),
|
||||
"currency": currency,
|
||||
}
|
||||
for user_id, total_amount, payments_count, currency in rows
|
||||
}
|
||||
|
||||
|
||||
async def _bulk_user_referral_counts(
|
||||
session: AsyncSession,
|
||||
user_ids: List[int],
|
||||
) -> Dict[int, int]:
|
||||
if not user_ids:
|
||||
return {}
|
||||
|
||||
referred_user = aliased(User)
|
||||
stmt = (
|
||||
select(referred_user.referred_by_id, sa_func.count(referred_user.user_id))
|
||||
.where(referred_user.referred_by_id.in_(user_ids))
|
||||
.group_by(referred_user.referred_by_id)
|
||||
)
|
||||
rows = (await session.execute(stmt)).all()
|
||||
return {int(user_id): int(count or 0) for user_id, count in rows}
|
||||
|
||||
|
||||
async def _filter_and_sort_users(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
@@ -381,6 +483,13 @@ async def _filter_and_sort_users(
|
||||
ratio_expr = None
|
||||
plim_expr = None
|
||||
pu_expr = None
|
||||
payment_summary_sq = None
|
||||
payment_total_expr = None
|
||||
payment_count_expr = None
|
||||
referral_count_sq = None
|
||||
referral_count_expr = None
|
||||
subscription_expiry_sq = None
|
||||
subscription_expires_expr = None
|
||||
|
||||
if needs_premium_sq:
|
||||
sq = _ranked_active_subscriptions_sq(now)
|
||||
@@ -401,6 +510,42 @@ async def _filter_and_sort_users(
|
||||
else_=cast(pu_expr, Float) / cast(plim_expr, Float),
|
||||
)
|
||||
|
||||
if sort_key in {
|
||||
"payments_total_asc",
|
||||
"payments_total_desc",
|
||||
"payments_count_asc",
|
||||
"payments_count_desc",
|
||||
}:
|
||||
payment_summary_sq = _user_payment_summary_sq()
|
||||
stmt = stmt.outerjoin(payment_summary_sq, User.user_id == payment_summary_sq.c.user_id)
|
||||
count_stmt = count_stmt.outerjoin(
|
||||
payment_summary_sq,
|
||||
User.user_id == payment_summary_sq.c.user_id,
|
||||
)
|
||||
payment_total_expr = sa_func.coalesce(payment_summary_sq.c.payments_total_amount, 0.0)
|
||||
payment_count_expr = sa_func.coalesce(payment_summary_sq.c.payments_count, 0)
|
||||
|
||||
if sort_key in {"invited_users_count_asc", "invited_users_count_desc"}:
|
||||
referral_count_sq = _user_referral_count_sq()
|
||||
stmt = stmt.outerjoin(referral_count_sq, User.user_id == referral_count_sq.c.user_id)
|
||||
count_stmt = count_stmt.outerjoin(
|
||||
referral_count_sq,
|
||||
User.user_id == referral_count_sq.c.user_id,
|
||||
)
|
||||
referral_count_expr = sa_func.coalesce(referral_count_sq.c.invited_users_count, 0)
|
||||
|
||||
if sort_key in {"subscription_expires_at_asc", "subscription_expires_at_desc"}:
|
||||
subscription_expiry_sq = _user_subscription_expiry_sq()
|
||||
stmt = stmt.outerjoin(
|
||||
subscription_expiry_sq,
|
||||
User.user_id == subscription_expiry_sq.c.user_id,
|
||||
)
|
||||
count_stmt = count_stmt.outerjoin(
|
||||
subscription_expiry_sq,
|
||||
User.user_id == subscription_expiry_sq.c.user_id,
|
||||
)
|
||||
subscription_expires_expr = subscription_expiry_sq.c.subscription_expires_at
|
||||
|
||||
search_cond = _user_search_condition(query)
|
||||
if search_cond is not None:
|
||||
stmt = stmt.where(search_cond)
|
||||
@@ -499,6 +644,22 @@ async def _filter_and_sort_users(
|
||||
stmt = stmt.order_by(ratio_expr.asc().nullslast(), User.user_id.asc())
|
||||
elif needs_premium_sq and ratio_expr is not None and sort_key == "premium_ratio_desc":
|
||||
stmt = stmt.order_by(ratio_expr.desc().nullslast(), User.user_id.desc())
|
||||
elif payment_total_expr is not None and sort_key == "payments_total_asc":
|
||||
stmt = stmt.order_by(payment_total_expr.asc(), User.user_id.asc())
|
||||
elif payment_total_expr is not None and sort_key == "payments_total_desc":
|
||||
stmt = stmt.order_by(payment_total_expr.desc(), User.user_id.desc())
|
||||
elif payment_count_expr is not None and sort_key == "payments_count_asc":
|
||||
stmt = stmt.order_by(payment_count_expr.asc(), User.user_id.asc())
|
||||
elif payment_count_expr is not None and sort_key == "payments_count_desc":
|
||||
stmt = stmt.order_by(payment_count_expr.desc(), User.user_id.desc())
|
||||
elif referral_count_expr is not None and sort_key == "invited_users_count_asc":
|
||||
stmt = stmt.order_by(referral_count_expr.asc(), User.user_id.asc())
|
||||
elif referral_count_expr is not None and sort_key == "invited_users_count_desc":
|
||||
stmt = stmt.order_by(referral_count_expr.desc(), User.user_id.desc())
|
||||
elif subscription_expires_expr is not None and sort_key == "subscription_expires_at_asc":
|
||||
stmt = stmt.order_by(subscription_expires_expr.asc().nullslast(), User.user_id.asc())
|
||||
elif subscription_expires_expr is not None and sort_key == "subscription_expires_at_desc":
|
||||
stmt = stmt.order_by(subscription_expires_expr.desc().nullslast(), User.user_id.desc())
|
||||
else:
|
||||
order = sort_map.get(sort_key, sort_map["registered_desc"])
|
||||
if isinstance(order, tuple):
|
||||
@@ -527,9 +688,34 @@ def _user_panel_status_condition(panel_status: str):
|
||||
normalized_status == "active", blank_status & Subscription.is_active.is_(True)
|
||||
)
|
||||
elif status == "expired":
|
||||
status_cond = or_(
|
||||
normalized_status == "expired", blank_status & Subscription.is_active.is_(False)
|
||||
now = datetime.now(timezone.utc)
|
||||
expired_subs = aliased(Subscription)
|
||||
active_subs = aliased(Subscription)
|
||||
expired_status = sa_func.lower(sa_func.coalesce(expired_subs.status_from_panel, ""))
|
||||
expired_blank_status = or_(
|
||||
expired_subs.status_from_panel.is_(None),
|
||||
expired_subs.status_from_panel == "",
|
||||
)
|
||||
expired_condition = or_(
|
||||
expired_status == "expired",
|
||||
expired_blank_status & expired_subs.is_active.is_(False),
|
||||
expired_subs.end_date <= now,
|
||||
)
|
||||
expired_exists = (
|
||||
select(expired_subs.subscription_id)
|
||||
.where(expired_subs.user_id == User.user_id, expired_condition)
|
||||
.exists()
|
||||
)
|
||||
active_exists = (
|
||||
select(active_subs.subscription_id)
|
||||
.where(
|
||||
active_subs.user_id == User.user_id,
|
||||
active_subs.is_active.is_(True),
|
||||
active_subs.end_date > now,
|
||||
)
|
||||
.exists()
|
||||
)
|
||||
return and_(expired_exists, ~active_exists)
|
||||
else:
|
||||
status_cond = normalized_status == "limited"
|
||||
|
||||
@@ -587,7 +773,12 @@ async def admin_user_detail_route(request: web.Request) -> web.Response:
|
||||
)
|
||||
recent_payments = (await session.execute(recent_payments_stmt)).scalars().all()
|
||||
log_count = await message_log_dal.count_user_message_logs(session, target_id)
|
||||
avatar_keys = await _bulk_user_avatar_keys(session, [target_id])
|
||||
inviter = await user_dal.get_referrer_for_user(session, user)
|
||||
invitees_total = await user_dal.count_users_referred_by(session, target_id)
|
||||
avatar_user_ids = [target_id]
|
||||
if inviter is not None:
|
||||
avatar_user_ids.append(int(inviter.user_id))
|
||||
avatar_keys = await _bulk_user_avatar_keys(session, avatar_user_ids)
|
||||
|
||||
# Referral links — both the bot deep-link and the webapp deep-link.
|
||||
referral_code: Optional[str] = None
|
||||
@@ -635,11 +826,9 @@ async def admin_user_detail_route(request: web.Request) -> web.Response:
|
||||
exc_panel,
|
||||
)
|
||||
|
||||
serialized_user = _serialize_user(user)
|
||||
serialized_user["avatar_url"] = (
|
||||
f"/api/admin/users/{target_id}/avatar?v={avatar_keys[target_id]}"
|
||||
if target_id in avatar_keys
|
||||
else None
|
||||
serialized_user = _serialize_admin_user_with_avatar(user, avatar_keys)
|
||||
serialized_inviter = (
|
||||
_serialize_admin_user_with_avatar(inviter, avatar_keys) if inviter is not None else None
|
||||
)
|
||||
|
||||
return _ok(
|
||||
@@ -655,11 +844,54 @@ async def admin_user_detail_route(request: web.Request) -> web.Response:
|
||||
"code": referral_code,
|
||||
"bot_link": referral_bot_link,
|
||||
"webapp_link": referral_webapp_link,
|
||||
"inviter": serialized_inviter,
|
||||
"invitees_total": int(invitees_total or 0),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def admin_user_referrals_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
target_id = int(request.match_info["user_id"])
|
||||
page = max(0, int(request.query.get("page", 0) or 0))
|
||||
page_size = min(100, max(1, int(request.query.get("page_size", 25) or 25)))
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
|
||||
async with async_session_factory() as session:
|
||||
user = await user_dal.get_user_by_id(session, target_id)
|
||||
if not user:
|
||||
return _error(404, "not_found", "User not found")
|
||||
|
||||
inviter = await user_dal.get_referrer_for_user(session, user)
|
||||
invitees_total = await user_dal.count_users_referred_by(session, target_id)
|
||||
invitees = await user_dal.get_users_referred_by(
|
||||
session,
|
||||
target_id,
|
||||
limit=page_size,
|
||||
offset=page * page_size,
|
||||
)
|
||||
avatar_user_ids = [target_id, *(int(u.user_id) for u in invitees)]
|
||||
if inviter is not None:
|
||||
avatar_user_ids.append(int(inviter.user_id))
|
||||
avatar_keys = await _bulk_user_avatar_keys(session, avatar_user_ids)
|
||||
|
||||
return _ok(
|
||||
{
|
||||
"user": _serialize_admin_user_with_avatar(user, avatar_keys),
|
||||
"inviter": _serialize_admin_user_with_avatar(inviter, avatar_keys)
|
||||
if inviter is not None
|
||||
else None,
|
||||
"invitees": [
|
||||
_serialize_admin_user_with_avatar(invitee, avatar_keys) for invitee in invitees
|
||||
],
|
||||
"total": int(invitees_total or 0),
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def admin_user_ban_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
target_id = int(request.match_info["user_id"])
|
||||
@@ -940,10 +1172,6 @@ async def admin_user_reset_trial_route(request: web.Request) -> web.Response:
|
||||
actor_id = _require_admin_user_id(request)
|
||||
target_id = int(request.match_info["user_id"])
|
||||
settings: Settings = request.app["settings"]
|
||||
panel_service = request.app.get("panel_service")
|
||||
subscription_service = request.app.get("subscription_service")
|
||||
if panel_service is None or subscription_service is None:
|
||||
return _error(503, "service_unavailable")
|
||||
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
@@ -951,16 +1179,17 @@ async def admin_user_reset_trial_route(request: web.Request) -> web.Response:
|
||||
if not user:
|
||||
return _error(404, "not_found")
|
||||
|
||||
active = await subscription_dal.get_active_subscription_by_user_id(session, target_id)
|
||||
if active:
|
||||
await session.delete(active)
|
||||
reset_at = await user_dal.mark_trial_eligibility_reset(session, target_id)
|
||||
if reset_at is None:
|
||||
await session.rollback()
|
||||
return _error(404, "not_found")
|
||||
|
||||
await message_log_dal.create_message_log(
|
||||
await message_log_dal.create_message_log_no_commit(
|
||||
session,
|
||||
{
|
||||
"user_id": actor_id,
|
||||
"event_type": "admin_reset_trial_webapp",
|
||||
"content": f"Reset trial for user_id={target_id}",
|
||||
"content": f"Reset trial eligibility for user_id={target_id}",
|
||||
"is_admin_event": True,
|
||||
"target_user_id": target_id,
|
||||
},
|
||||
@@ -1031,7 +1260,7 @@ async def admin_user_premium_override_route(request: web.Request) -> web.Respons
|
||||
|
||||
|
||||
async def admin_user_regular_traffic_override_route(request: web.Request) -> web.Response:
|
||||
"""Main (regular) traffic: unlimited-style ceiling + admin bonus GB."""
|
||||
"""Main (regular) traffic: native unlimited panel limit + admin bonus GB."""
|
||||
actor_id = _require_admin_user_id(request)
|
||||
target_id = int(request.match_info["user_id"])
|
||||
settings: Settings = request.app["settings"]
|
||||
|
||||
@@ -30,6 +30,11 @@ class SettingField:
|
||||
i18n_label_key: Optional[str] = None
|
||||
i18n_description_key: Optional[str] = None
|
||||
i18n_subsection_key: Optional[str] = None
|
||||
webhook_path: Optional[str] = None
|
||||
webhook_requires_base_url: bool = False
|
||||
webhook_provider_id: Optional[str] = None
|
||||
webhook_hint_i18n_key: Optional[str] = None
|
||||
webhook_hint: str = ""
|
||||
|
||||
|
||||
SETTINGS_MANIFEST: List[SettingField] = [
|
||||
@@ -84,44 +89,44 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
||||
SettingField(
|
||||
"PANEL_API_URL",
|
||||
"url",
|
||||
"general",
|
||||
"remnawave",
|
||||
"URL API Remnawave",
|
||||
"Например, https://panel.example.com/api.",
|
||||
subsection="Remnawave",
|
||||
),
|
||||
SettingField(
|
||||
"PANEL_API_KEY",
|
||||
"string",
|
||||
"general",
|
||||
"remnawave",
|
||||
"API-ключ Remnawave",
|
||||
"Секретный ключ API панели.",
|
||||
secret=True,
|
||||
subsection="Remnawave",
|
||||
),
|
||||
SettingField(
|
||||
"PANEL_WEBHOOK_SECRET",
|
||||
"string",
|
||||
"general",
|
||||
"remnawave",
|
||||
"Секрет вебхуков Remnawave",
|
||||
"Используется для проверки входящих вебхуков панели.",
|
||||
secret=True,
|
||||
subsection="Remnawave",
|
||||
webhook_path="/webhook/panel",
|
||||
webhook_requires_base_url=True,
|
||||
webhook_provider_id="remnawave",
|
||||
webhook_hint_i18n_key="admin_settings_panel_webhook_url_hint",
|
||||
webhook_hint="Use this URL as WEBHOOK_URL in Remnawave Panel.",
|
||||
),
|
||||
SettingField(
|
||||
"USER_SQUAD_UUIDS",
|
||||
"string",
|
||||
"general",
|
||||
"remnawave",
|
||||
"Internal Squads по умолчанию",
|
||||
"UUID через запятую для legacy-режима без JSON-каталога тарифов.",
|
||||
subsection="Remnawave",
|
||||
),
|
||||
SettingField(
|
||||
"USER_EXTERNAL_SQUAD_UUID",
|
||||
"string",
|
||||
"general",
|
||||
"remnawave",
|
||||
"External Squad по умолчанию",
|
||||
"Необязательный UUID External Squad для новых пользователей.",
|
||||
subsection="Remnawave",
|
||||
),
|
||||
# ─── Web app appearance ────────────────────────────────────────
|
||||
SettingField(
|
||||
@@ -230,6 +235,70 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
||||
SettingField("STARS_PRICE_3_MONTHS", "int", "pricing", "Цена 3 мес. (Stars)"),
|
||||
SettingField("STARS_PRICE_6_MONTHS", "int", "pricing", "Цена 6 мес. (Stars)"),
|
||||
SettingField("STARS_PRICE_12_MONTHS", "int", "pricing", "Цена 12 мес. (Stars)"),
|
||||
SettingField(
|
||||
"REFERRAL_BONUS_DAYS_INVITER_1_MONTH",
|
||||
"int",
|
||||
"pricing",
|
||||
"Бонус приглашающему: 1 мес.",
|
||||
min=0,
|
||||
subsection="legacy_tariffs",
|
||||
),
|
||||
SettingField(
|
||||
"REFERRAL_BONUS_DAYS_INVITER_3_MONTHS",
|
||||
"int",
|
||||
"pricing",
|
||||
"Бонус приглашающему: 3 мес.",
|
||||
min=0,
|
||||
subsection="legacy_tariffs",
|
||||
),
|
||||
SettingField(
|
||||
"REFERRAL_BONUS_DAYS_INVITER_6_MONTHS",
|
||||
"int",
|
||||
"pricing",
|
||||
"Бонус приглашающему: 6 мес.",
|
||||
min=0,
|
||||
subsection="legacy_tariffs",
|
||||
),
|
||||
SettingField(
|
||||
"REFERRAL_BONUS_DAYS_INVITER_12_MONTHS",
|
||||
"int",
|
||||
"pricing",
|
||||
"Бонус приглашающему: 12 мес.",
|
||||
min=0,
|
||||
subsection="legacy_tariffs",
|
||||
),
|
||||
SettingField(
|
||||
"REFERRAL_BONUS_DAYS_REFEREE_1_MONTH",
|
||||
"int",
|
||||
"pricing",
|
||||
"Бонус приглашённому: 1 мес.",
|
||||
min=0,
|
||||
subsection="legacy_tariffs",
|
||||
),
|
||||
SettingField(
|
||||
"REFERRAL_BONUS_DAYS_REFEREE_3_MONTHS",
|
||||
"int",
|
||||
"pricing",
|
||||
"Бонус приглашённому: 3 мес.",
|
||||
min=0,
|
||||
subsection="legacy_tariffs",
|
||||
),
|
||||
SettingField(
|
||||
"REFERRAL_BONUS_DAYS_REFEREE_6_MONTHS",
|
||||
"int",
|
||||
"pricing",
|
||||
"Бонус приглашённому: 6 мес.",
|
||||
min=0,
|
||||
subsection="legacy_tariffs",
|
||||
),
|
||||
SettingField(
|
||||
"REFERRAL_BONUS_DAYS_REFEREE_12_MONTHS",
|
||||
"int",
|
||||
"pricing",
|
||||
"Бонус приглашённому: 12 мес.",
|
||||
min=0,
|
||||
subsection="legacy_tariffs",
|
||||
),
|
||||
SettingField(
|
||||
"TRAFFIC_PACKAGES", "string", "pricing", "Пакеты трафика", "Формат: 10:199,50:799 (ГБ:цена)"
|
||||
),
|
||||
@@ -333,62 +402,6 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
||||
"REFERRAL_WELCOME_BONUS_DAYS", "int", "referral", "Приветственный бонус (дней)", min=0
|
||||
),
|
||||
SettingField("LEGACY_REFS", "bool", "referral", "Поддержка старых ref-ссылок"),
|
||||
SettingField(
|
||||
"REFERRAL_BONUS_DAYS_INVITER_1_MONTH",
|
||||
"int",
|
||||
"referral",
|
||||
"Бонус приглашающему: 1 мес.",
|
||||
min=0,
|
||||
),
|
||||
SettingField(
|
||||
"REFERRAL_BONUS_DAYS_INVITER_3_MONTHS",
|
||||
"int",
|
||||
"referral",
|
||||
"Бонус приглашающему: 3 мес.",
|
||||
min=0,
|
||||
),
|
||||
SettingField(
|
||||
"REFERRAL_BONUS_DAYS_INVITER_6_MONTHS",
|
||||
"int",
|
||||
"referral",
|
||||
"Бонус приглашающему: 6 мес.",
|
||||
min=0,
|
||||
),
|
||||
SettingField(
|
||||
"REFERRAL_BONUS_DAYS_INVITER_12_MONTHS",
|
||||
"int",
|
||||
"referral",
|
||||
"Бонус приглашающему: 12 мес.",
|
||||
min=0,
|
||||
),
|
||||
SettingField(
|
||||
"REFERRAL_BONUS_DAYS_REFEREE_1_MONTH",
|
||||
"int",
|
||||
"referral",
|
||||
"Бонус приглашённому: 1 мес.",
|
||||
min=0,
|
||||
),
|
||||
SettingField(
|
||||
"REFERRAL_BONUS_DAYS_REFEREE_3_MONTHS",
|
||||
"int",
|
||||
"referral",
|
||||
"Бонус приглашённому: 3 мес.",
|
||||
min=0,
|
||||
),
|
||||
SettingField(
|
||||
"REFERRAL_BONUS_DAYS_REFEREE_6_MONTHS",
|
||||
"int",
|
||||
"referral",
|
||||
"Бонус приглашённому: 6 мес.",
|
||||
min=0,
|
||||
),
|
||||
SettingField(
|
||||
"REFERRAL_BONUS_DAYS_REFEREE_12_MONTHS",
|
||||
"int",
|
||||
"referral",
|
||||
"Бонус приглашённому: 12 мес.",
|
||||
min=0,
|
||||
),
|
||||
# ─── Notifications ─────────────────────────────────────────────
|
||||
SettingField(
|
||||
"SUBSCRIPTION_NOTIFICATIONS_ENABLED",
|
||||
@@ -396,6 +409,13 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
||||
"notifications",
|
||||
"Включены уведомления о подписке",
|
||||
),
|
||||
SettingField(
|
||||
"SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED",
|
||||
"bool",
|
||||
"notifications",
|
||||
"Дублировать уведомления о подписке на email",
|
||||
"Письма отправляются только пользователям с привязанным email и рабочим SMTP.",
|
||||
),
|
||||
SettingField(
|
||||
"SUBSCRIPTION_NOTIFY_ON_EXPIRE", "bool", "notifications", "Уведомлять об истечении"
|
||||
),
|
||||
@@ -568,6 +588,17 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
||||
),
|
||||
SettingField("USER_TRAFFIC_LIMIT_GB", "float", "devices", "Лимит трафика пользователя (ГБ)"),
|
||||
SettingField("USER_TRAFFIC_STRATEGY", "string", "devices", "Стратегия сброса трафика"),
|
||||
# ─── System ────────────────────────────────────────────────────
|
||||
SettingField(
|
||||
"TELEMETRY_ENABLED",
|
||||
"bool",
|
||||
"system",
|
||||
"Анонимная статистика установки",
|
||||
"Раз в сутки отправляет обезличенный сигнал: версия, ОС, локаль и число "
|
||||
"пользователей в виде диапазона. Без персональных данных, токенов и "
|
||||
"доменов. Помогает понять число активных установок и какие версии "
|
||||
"используются. Можно отключить здесь без перезапуска.",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@@ -692,6 +723,7 @@ def manifest_payload() -> List[dict]:
|
||||
sections_order = {
|
||||
"general": 1,
|
||||
"appearance": 2,
|
||||
"remnawave": 3,
|
||||
"pricing": 11,
|
||||
"payments": 4,
|
||||
"trial": 5,
|
||||
@@ -701,6 +733,7 @@ def manifest_payload() -> List[dict]:
|
||||
"backups": 9,
|
||||
"devices": 10,
|
||||
"subscription_guides": 10,
|
||||
"system": 12,
|
||||
}
|
||||
exclusive_map = {
|
||||
key: opposite
|
||||
@@ -758,6 +791,15 @@ def manifest_payload() -> List[dict]:
|
||||
item["default"] = default_value
|
||||
if webhook_metadata:
|
||||
item.update(webhook_metadata)
|
||||
if field.webhook_path:
|
||||
item["webhook_path"] = field.webhook_path
|
||||
item["webhook_requires_base_url"] = field.webhook_requires_base_url
|
||||
if field.webhook_provider_id:
|
||||
item["provider_id"] = field.webhook_provider_id
|
||||
if field.webhook_hint_i18n_key:
|
||||
item["webhook_hint_i18n_key"] = field.webhook_hint_i18n_key
|
||||
if field.webhook_hint:
|
||||
item["webhook_hint"] = field.webhook_hint
|
||||
if field.choices:
|
||||
item["choices"] = [
|
||||
{
|
||||
|
||||
@@ -16,6 +16,7 @@ from bot.app.web.webapp import (
|
||||
routes as _routes,
|
||||
serializers as _serializers,
|
||||
support as _support,
|
||||
telegram_notifications as _telegram_notifications,
|
||||
)
|
||||
|
||||
_MODULES = (
|
||||
@@ -25,6 +26,7 @@ _MODULES = (
|
||||
_assets,
|
||||
_auth,
|
||||
_account,
|
||||
_telegram_notifications,
|
||||
_serializers,
|
||||
_billing,
|
||||
_devices,
|
||||
|
||||
@@ -109,6 +109,8 @@
|
||||
let attempted = false;
|
||||
let pageLeft = false;
|
||||
let state = "opening";
|
||||
let closeAttemptTimer = null;
|
||||
const CLOSE_ATTEMPT_DELAY_MS = 2500;
|
||||
|
||||
function hasControlChars(value) {
|
||||
return Array.from(String(value || "")).some((char) => {
|
||||
@@ -167,7 +169,10 @@
|
||||
function markDone() {
|
||||
if (state === "done" || isUnsafe) return;
|
||||
render("done");
|
||||
window.setTimeout(tryCloseWindow, 120);
|
||||
if (closeAttemptTimer) window.clearTimeout(closeAttemptTimer);
|
||||
closeAttemptTimer = window.setTimeout(() => {
|
||||
if (pageLeft || document.hidden) tryCloseWindow();
|
||||
}, CLOSE_ATTEMPT_DELAY_MS);
|
||||
}
|
||||
|
||||
function notePageLeft() {
|
||||
@@ -201,7 +206,6 @@
|
||||
render("done");
|
||||
});
|
||||
window.addEventListener("pagehide", notePageLeft);
|
||||
window.addEventListener("blur", notePageLeft);
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (!attempted) return;
|
||||
if (document.hidden) {
|
||||
|
||||
@@ -55,6 +55,11 @@ from bot.utils.text_sanitizer import (
|
||||
sanitize_username,
|
||||
)
|
||||
from config.settings import Settings
|
||||
from config.tariffs_config import (
|
||||
default_currency_key_for_settings,
|
||||
default_payment_currency_code_for_settings,
|
||||
payment_currency_code,
|
||||
)
|
||||
from db.dal import payment_dal, security_dal, subscription_dal, support_dal, user_dal
|
||||
from db.dal.user_dal import UserMergeConflictError
|
||||
from db.models import Payment, User, UserTelegramAvatar
|
||||
@@ -107,6 +112,33 @@ WEBAPP_CSRF_COOKIE_NAME = "rw_webapp_csrf"
|
||||
WEBAPP_TELEGRAM_OAUTH_STATE_COOKIE_NAME = "rw_tg_oauth_state"
|
||||
WEBAPP_CSRF_HEADER_NAME = "X-CSRF-Token"
|
||||
WEBAPP_STATE_CHANGING_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
|
||||
ROBOTS_TX = """User-agent: *
|
||||
Disallow: /
|
||||
|
||||
User-agent: GPTBot
|
||||
Disallow: /
|
||||
|
||||
User-agent: ChatGPT-User
|
||||
Disallow: /
|
||||
|
||||
User-agent: OAI-SearchBot
|
||||
Disallow: /
|
||||
|
||||
User-agent: Google-Extended
|
||||
Disallow: /
|
||||
|
||||
User-agent: ClaudeBot
|
||||
Disallow: /
|
||||
|
||||
User-agent: anthropic-ai
|
||||
Disallow: /
|
||||
|
||||
User-agent: PerplexityBot
|
||||
Disallow: /
|
||||
|
||||
User-agent: Applebot-Extended
|
||||
Disallow: /
|
||||
"""
|
||||
_APP_VERSION_CACHE: Optional[str] = None
|
||||
WEBAPP_CSRF_EXEMPT_PATHS = {
|
||||
"/api/auth/telegram/nonce",
|
||||
|
||||
@@ -8,6 +8,7 @@ from .auth import (
|
||||
_sync_merged_panel_identity_for_user,
|
||||
)
|
||||
from .common import _invalidate_webapp_user_caches
|
||||
from .telegram_notifications import _probe_telegram_notifications_for_user_id
|
||||
|
||||
|
||||
async def account_email_request_route(request: web.Request) -> web.Response:
|
||||
@@ -416,6 +417,8 @@ async def account_telegram_link_route(request: web.Request) -> web.Response:
|
||||
except Exception:
|
||||
logger.exception("Failed to send account Telegram linked notification")
|
||||
|
||||
await _probe_telegram_notifications_for_user_id(request, int(final_user_id))
|
||||
|
||||
token = create_webapp_session_token(settings, int(final_user_id))
|
||||
response_payload: Dict[str, Any] = {
|
||||
"ok": True,
|
||||
|
||||
@@ -23,6 +23,12 @@ async def health_route(request: web.Request) -> web.Response:
|
||||
return web.json_response({"ok": True})
|
||||
|
||||
|
||||
async def robots_txt_route(request: web.Request) -> web.Response:
|
||||
response = web.Response(text=ROBOTS_TX, content_type="text/plain")
|
||||
response.headers["Cache-Control"] = "public, max-age=3600"
|
||||
return response
|
||||
|
||||
|
||||
async def css_asset_route(request: web.Request) -> web.Response:
|
||||
return await _css_asset_route(request, base_name="subscription_webapp")
|
||||
|
||||
@@ -869,6 +875,7 @@ async def _security_headers_middleware(request: web.Request, handler):
|
||||
)
|
||||
response.headers.setdefault("Referrer-Policy", "no-referrer")
|
||||
response.headers.setdefault("X-Content-Type-Options", "nosniff")
|
||||
response.headers.setdefault("X-Robots-Tag", "noindex, nofollow, noarchive")
|
||||
response.headers.setdefault(
|
||||
"Permissions-Policy",
|
||||
(
|
||||
@@ -927,87 +934,12 @@ def _get_cached_webapp_settings(request: web.Request) -> Dict[str, Any]:
|
||||
return cache["data"]
|
||||
|
||||
|
||||
def _run_git_command(*args: str) -> str:
|
||||
repo_root = APP_ROOT
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", *args],
|
||||
cwd=repo_root,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=1.5,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return ""
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def _normalize_version_branch(raw_branch: str) -> str:
|
||||
branch = str(raw_branch or "").strip()
|
||||
for prefix in ("refs/heads/", "refs/remotes/origin/", "origin/"):
|
||||
if branch.startswith(prefix):
|
||||
branch = branch[len(prefix) :]
|
||||
break
|
||||
if branch == "HEAD":
|
||||
return ""
|
||||
return re.sub(r"[^A-Za-z0-9._-]+", "-", branch).strip("-")[:48]
|
||||
|
||||
|
||||
def _resolve_version_branch() -> str:
|
||||
for env_name in (
|
||||
"REMNAWAVE_MINISHOP_BRANCH",
|
||||
"GIT_BRANCH",
|
||||
"BRANCH_NAME",
|
||||
"GITHUB_REF_NAME",
|
||||
"CI_COMMIT_REF_NAME",
|
||||
):
|
||||
branch = _normalize_version_branch(os.getenv(env_name, ""))
|
||||
if branch:
|
||||
return branch
|
||||
return _normalize_version_branch(
|
||||
_run_git_command("branch", "--show-current")
|
||||
or _run_git_command("symbolic-ref", "--quiet", "--short", "HEAD")
|
||||
)
|
||||
|
||||
|
||||
def _format_app_version(tag: str, sha: str, branch: str) -> str:
|
||||
branch_suffix = "" if not branch or branch == "main" else f"-{branch}"
|
||||
if tag and sha:
|
||||
return f"{tag}{branch_suffix}+g{sha}"
|
||||
if sha:
|
||||
return f"dev{branch_suffix}+g{sha}"
|
||||
if tag:
|
||||
return f"{tag}{branch_suffix}"
|
||||
return f"dev{branch_suffix}+unknown"
|
||||
|
||||
|
||||
def _resolve_app_version() -> str:
|
||||
global _APP_VERSION_CACHE
|
||||
if _APP_VERSION_CACHE:
|
||||
return _APP_VERSION_CACHE
|
||||
# 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
|
||||
|
||||
env_version = os.getenv("REMNAWAVE_MINISHOP_VERSION", "").strip()
|
||||
if env_version:
|
||||
_APP_VERSION_CACHE = env_version
|
||||
return env_version
|
||||
|
||||
build_version_path = APP_ROOT / ".build-version"
|
||||
try:
|
||||
build_version = build_version_path.read_text(encoding="utf-8").strip()
|
||||
except OSError:
|
||||
build_version = ""
|
||||
if build_version:
|
||||
_APP_VERSION_CACHE = build_version
|
||||
return build_version
|
||||
|
||||
tag = _run_git_command("describe", "--tags", "--abbrev=0")
|
||||
sha = _run_git_command("rev-parse", "--short", "HEAD")
|
||||
branch = _resolve_version_branch()
|
||||
version = _format_app_version(tag, sha, branch)
|
||||
|
||||
_APP_VERSION_CACHE = version
|
||||
return version
|
||||
return resolve_app_version()
|
||||
|
||||
|
||||
async def _enforce_webapp_rate_limit(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
from .common import _invalidate_webapp_user_caches
|
||||
from .telegram_notifications import _probe_telegram_notifications_for_user_id
|
||||
|
||||
|
||||
def _resolve_telegram_bot_id(bot_token: str) -> Optional[int]:
|
||||
@@ -436,6 +437,9 @@ async def telegram_oauth_callback_route(request: web.Request) -> web.Response:
|
||||
first_name=linked_user_for_panel.first_name,
|
||||
)
|
||||
|
||||
if final_user_id:
|
||||
await _probe_telegram_notifications_for_user_id(request, int(final_user_id))
|
||||
|
||||
token = create_webapp_session_token(settings, int(final_user_id))
|
||||
response = web.HTTPFound(_telegram_oauth_redirect_url(redirect_path, status="success"))
|
||||
_clear_telegram_oauth_state_cookie(response)
|
||||
@@ -531,6 +535,7 @@ async def auth_token_route(request: web.Request) -> web.Response:
|
||||
return _json_error(500, "auth_failed", "Auth failed")
|
||||
|
||||
await _invalidate_webapp_user_caches(settings, authenticated_user_id, include_devices=True)
|
||||
await _probe_telegram_notifications_for_user_id(request, int(authenticated_user_id))
|
||||
token = create_webapp_session_token(settings, int(authenticated_user_id))
|
||||
return _build_webapp_auth_response(settings, {"ok": True}, token=token)
|
||||
|
||||
|
||||
@@ -103,6 +103,8 @@ async def create_payment_route(request: web.Request) -> web.Response:
|
||||
subscription_service: SubscriptionService = request.app["subscription_service"]
|
||||
cached = _get_cached_webapp_settings(request)
|
||||
tariffs_config = settings.tariffs_config
|
||||
default_currency = default_currency_key_for_settings(settings)
|
||||
default_currency_code = payment_currency_code(default_currency)
|
||||
traffic_mode = bool(settings.traffic_sale_mode)
|
||||
sale_mode = "subscription"
|
||||
traffic_gb_for_payment: Optional[float] = None
|
||||
@@ -155,17 +157,17 @@ async def create_payment_route(request: web.Request) -> web.Response:
|
||||
if requested_sale_mode == "premium_topup"
|
||||
else tariffs_config.topup_packages_for(tariff)
|
||||
)
|
||||
rub_packages = {
|
||||
currency_packages = {
|
||||
float(package.gb): float(package.price)
|
||||
for package in (packages.rub if packages else [])
|
||||
for package in (packages.for_currency(default_currency) if packages else [])
|
||||
}
|
||||
stars_packages = {
|
||||
float(package.gb): int(float(package.price))
|
||||
for package in (packages.stars if packages else [])
|
||||
}
|
||||
package_key = _resolve_numeric_option_key(rub_packages, traffic_gb)
|
||||
package_key = _resolve_numeric_option_key(currency_packages, traffic_gb)
|
||||
stars_package_key = _resolve_numeric_option_key(stars_packages, traffic_gb)
|
||||
price = rub_packages.get(package_key) if package_key is not None else None
|
||||
price = currency_packages.get(package_key) if package_key is not None else None
|
||||
stars_price = (
|
||||
stars_packages.get(stars_package_key) if stars_package_key is not None else None
|
||||
)
|
||||
@@ -196,17 +198,21 @@ async def create_payment_route(request: web.Request) -> web.Response:
|
||||
return _json_error(400, "invalid_plan", "Invalid traffic package")
|
||||
if traffic_gb <= 0:
|
||||
return _json_error(400, "invalid_plan", "Invalid traffic package")
|
||||
rub_packages = {
|
||||
currency_packages = {
|
||||
float(package.gb): float(package.price)
|
||||
for package in (tariff.traffic_packages.rub if tariff.traffic_packages else [])
|
||||
for package in (
|
||||
tariff.traffic_packages.for_currency(default_currency)
|
||||
if tariff.traffic_packages
|
||||
else []
|
||||
)
|
||||
}
|
||||
stars_packages = {
|
||||
float(package.gb): int(float(package.price))
|
||||
for package in (tariff.traffic_packages.stars if tariff.traffic_packages else [])
|
||||
}
|
||||
package_key = _resolve_numeric_option_key(rub_packages, traffic_gb)
|
||||
package_key = _resolve_numeric_option_key(currency_packages, traffic_gb)
|
||||
stars_package_key = _resolve_numeric_option_key(stars_packages, traffic_gb)
|
||||
price = rub_packages.get(package_key) if package_key is not None else None
|
||||
price = currency_packages.get(package_key) if package_key is not None else None
|
||||
stars_price = (
|
||||
stars_packages.get(stars_package_key) if stars_package_key is not None else None
|
||||
)
|
||||
@@ -224,7 +230,7 @@ async def create_payment_route(request: web.Request) -> web.Response:
|
||||
return _json_error(400, "invalid_plan", "Invalid subscription period")
|
||||
if months not in tariff.enabled_periods:
|
||||
return _json_error(400, "invalid_plan", "Subscription period is not available")
|
||||
price = tariff.period_price(months, "rub")
|
||||
price = tariff.period_price(months, default_currency)
|
||||
stars_price_raw = tariff.period_price(months, "stars")
|
||||
stars_price = int(stars_price_raw) if stars_price_raw and stars_price_raw > 0 else None
|
||||
if price is None and method != "stars":
|
||||
@@ -296,7 +302,7 @@ async def create_payment_route(request: web.Request) -> web.Response:
|
||||
active_tariff = None
|
||||
if not active_tariff or active_tariff.billing_model != "period":
|
||||
return _json_error(400, "invalid_plan", "Device top-up is not available")
|
||||
currency = "stars" if method == "stars" else "rub"
|
||||
currency = "stars" if method == "stars" else default_currency
|
||||
hwid_quote = await subscription_service.quote_hwid_device_topup(
|
||||
session,
|
||||
user_id=user_id,
|
||||
@@ -325,6 +331,7 @@ async def create_payment_route(request: web.Request) -> web.Response:
|
||||
months=payment_units,
|
||||
price=float(price or 0),
|
||||
stars_price=stars_price,
|
||||
currency=default_currency_code,
|
||||
lang=lang,
|
||||
sale_mode=sale_mode,
|
||||
traffic_gb=traffic_gb_for_payment,
|
||||
@@ -583,6 +590,7 @@ async def tariff_change_payment_route(request: web.Request) -> web.Response:
|
||||
tariff_key = str(payment_payload.tariff_key or "").strip()
|
||||
settings: Settings = request.app["settings"]
|
||||
config = settings.tariffs_config
|
||||
default_currency_code = default_payment_currency_code_for_settings(settings)
|
||||
if not config:
|
||||
return _json_error(404, "tariffs_unavailable", "Tariffs are not configured")
|
||||
if not tariff_key:
|
||||
@@ -618,6 +626,7 @@ async def tariff_change_payment_route(request: web.Request) -> web.Response:
|
||||
months=1,
|
||||
price=price,
|
||||
stars_price=None,
|
||||
currency=default_currency_code,
|
||||
lang=db_user.language_code or settings.DEFAULT_LANGUAGE,
|
||||
sale_mode=f"tariff_upgrade@{target.key}",
|
||||
)
|
||||
@@ -656,20 +665,26 @@ async def device_topup_options_route(request: web.Request) -> web.Response:
|
||||
active.get("extra_hwid_devices_valid_until_text") if active else None
|
||||
) or _billing_datetime_text(extra_hwid_valid_until)
|
||||
packages = tariff.hwid_device_packages
|
||||
rub_counts = {int(package.count) for package in (packages.rub if packages else [])}
|
||||
default_currency = default_currency_key_for_settings(settings)
|
||||
default_currency_code = payment_currency_code(default_currency)
|
||||
if packages and hasattr(packages, "for_currency"):
|
||||
default_packages = packages.for_currency(default_currency)
|
||||
else:
|
||||
default_packages = getattr(packages, default_currency, []) if packages else []
|
||||
currency_counts = {int(package.count) for package in default_packages}
|
||||
stars_counts = {int(package.count) for package in (packages.stars if packages else [])}
|
||||
plans = []
|
||||
for count in sorted(rub_counts | stars_counts):
|
||||
rub_quote = (
|
||||
for count in sorted(currency_counts | stars_counts):
|
||||
currency_quote = (
|
||||
await subscription_service.quote_hwid_device_topup(
|
||||
session,
|
||||
user_id=user_id,
|
||||
device_count=count,
|
||||
tariff_key=tariff.key,
|
||||
renewal=renewal_available,
|
||||
currency="rub",
|
||||
currency=default_currency,
|
||||
)
|
||||
if count in rub_counts
|
||||
if count in currency_counts
|
||||
else None
|
||||
)
|
||||
stars_quote = (
|
||||
@@ -684,7 +699,7 @@ async def device_topup_options_route(request: web.Request) -> web.Response:
|
||||
if count in stars_counts
|
||||
else None
|
||||
)
|
||||
if not rub_quote and not stars_quote:
|
||||
if not currency_quote and not stars_quote:
|
||||
continue
|
||||
sale_mode_for_plan = "hwid_devices_renewal" if renewal_available else "hwid_devices"
|
||||
plan = {
|
||||
@@ -695,13 +710,19 @@ async def device_topup_options_route(request: web.Request) -> web.Response:
|
||||
"sale_mode": sale_mode_for_plan,
|
||||
"months": count,
|
||||
"device_count": count,
|
||||
"price": float(rub_quote.get("price") if rub_quote else 0),
|
||||
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
|
||||
"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((rub_quote or stars_quote).get("valid_from")),
|
||||
"valid_until": _billing_iso_datetime((rub_quote or stars_quote).get("valid_until")),
|
||||
"proration_ratio": float((rub_quote or stars_quote).get("proration_ratio") or 0),
|
||||
"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
|
||||
),
|
||||
}
|
||||
if stars_quote and int(stars_quote.get("price") or 0) > 0:
|
||||
plan["stars_price"] = int(stars_quote["price"])
|
||||
@@ -929,12 +950,14 @@ async def _create_subscription_payment(
|
||||
price: float,
|
||||
stars_price: Optional[int],
|
||||
lang: str,
|
||||
currency: Optional[str] = None,
|
||||
sale_mode: str = "subscription",
|
||||
traffic_gb: Optional[float] = None,
|
||||
is_admin: bool = False,
|
||||
hwid_quote: Optional[Dict[str, Any]] = None,
|
||||
) -> web.Response:
|
||||
settings: Settings = request.app["settings"]
|
||||
payment_currency = (currency or default_payment_currency_code_for_settings(settings)).upper()
|
||||
sale_mode = str(sale_mode or "subscription")
|
||||
traffic_sale = _sale_mode_is_traffic(sale_mode)
|
||||
hwid_devices_sale = _sale_mode_is_hwid_devices(sale_mode)
|
||||
@@ -958,6 +981,17 @@ async def _create_subscription_payment(
|
||||
provider_spec.is_service_configured(request.app),
|
||||
)
|
||||
return _json_error(400, "payment_unavailable", "Payment method unavailable")
|
||||
if not provider_spec.is_usable_for_payment_currency(settings, payment_currency):
|
||||
logger.warning(
|
||||
"WebApp payment method does not support currency: method=%s currency=%s",
|
||||
method,
|
||||
payment_currency,
|
||||
)
|
||||
return _json_error(
|
||||
400,
|
||||
"unsupported_currency",
|
||||
"Payment method does not support this currency",
|
||||
)
|
||||
return await provider_spec.create_webapp_payment(
|
||||
WebAppPaymentContext(
|
||||
request=request,
|
||||
@@ -967,6 +1001,7 @@ async def _create_subscription_payment(
|
||||
months=months,
|
||||
price=price,
|
||||
stars_price=stars_price,
|
||||
currency=payment_currency,
|
||||
description=description,
|
||||
sale_mode=sale_mode,
|
||||
traffic_gb=traffic_gb,
|
||||
|
||||
@@ -22,7 +22,13 @@ async def devices_route(request: web.Request) -> web.Response:
|
||||
"devices",
|
||||
user_id,
|
||||
int(getattr(settings, "WEBAPP_DEVICES_CACHE_TTL_SECONDS", 5) or 0),
|
||||
lambda: _load_devices_payload(subscription_service, session, user_id),
|
||||
lambda: _load_devices_payload(
|
||||
subscription_service,
|
||||
session,
|
||||
user_id,
|
||||
fallback_panel_user_uuid=str(getattr(db_user, "panel_user_uuid", "") or "").strip()
|
||||
or None,
|
||||
),
|
||||
)
|
||||
if isinstance(result, dict) and result.get("ok") is True:
|
||||
return web.json_response({"ok": True, **(result.get("payload") or {})})
|
||||
@@ -45,16 +51,12 @@ async def _load_devices_payload(
|
||||
subscription_service: SubscriptionService,
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
fallback_panel_user_uuid: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
active = await subscription_service.get_active_subscription_details(session, user_id)
|
||||
panel_user_uuid = active.get("user_id") if active else None
|
||||
panel_user_uuid = str((active or {}).get("user_id") or fallback_panel_user_uuid or "").strip()
|
||||
if not panel_user_uuid:
|
||||
return {
|
||||
"ok": False,
|
||||
"status": 400,
|
||||
"error": "subscription_not_active",
|
||||
"message": "Subscription is not active",
|
||||
}
|
||||
return _empty_inactive_devices_payload()
|
||||
|
||||
panel_service = getattr(subscription_service, "panel_service", None)
|
||||
if not panel_service:
|
||||
@@ -82,6 +84,7 @@ async def _load_devices_payload(
|
||||
"ok": True,
|
||||
"payload": {
|
||||
"enabled": True,
|
||||
"subscription_active": _devices_subscription_is_active(active),
|
||||
"current_devices": len(devices),
|
||||
"max_devices": max_devices,
|
||||
"max_devices_label": _format_devices_limit(max_devices),
|
||||
@@ -92,6 +95,31 @@ async def _load_devices_payload(
|
||||
}
|
||||
|
||||
|
||||
def _empty_inactive_devices_payload() -> Dict[str, Any]:
|
||||
return {
|
||||
"ok": True,
|
||||
"payload": {
|
||||
"enabled": True,
|
||||
"subscription_active": False,
|
||||
"current_devices": 0,
|
||||
"max_devices": None,
|
||||
"max_devices_label": _format_devices_limit(None),
|
||||
"devices": [],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _devices_subscription_is_active(active: Optional[Dict[str, Any]]) -> bool:
|
||||
if not active:
|
||||
return False
|
||||
end_date = active.get("end_date")
|
||||
if not isinstance(end_date, datetime):
|
||||
return False
|
||||
if end_date.tzinfo is None:
|
||||
end_date = end_date.replace(tzinfo=timezone.utc)
|
||||
return end_date > datetime.now(timezone.utc)
|
||||
|
||||
|
||||
async def disconnect_device_route(request: web.Request) -> web.Response:
|
||||
user_id = _require_user_id(request)
|
||||
rate_limit_response = await _enforce_webapp_rate_limit(
|
||||
|
||||
@@ -3,6 +3,7 @@ from ._runtime import * # noqa: F403,F405
|
||||
|
||||
|
||||
def setup_subscription_webapp_routes(app: web.Application) -> None:
|
||||
app.router.add_get("/robots.txt", robots_txt_route)
|
||||
app.router.add_get("/", index_route)
|
||||
app.router.add_get("/login/password", index_route)
|
||||
app.router.add_get("/home", index_route)
|
||||
@@ -84,6 +85,10 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
|
||||
app.router.add_post("/api/account/password/request", account_password_request_route)
|
||||
app.router.add_post("/api/account/password/confirm", account_password_confirm_route)
|
||||
app.router.add_post("/api/account/telegram/link", account_telegram_link_route)
|
||||
app.router.add_post(
|
||||
"/api/account/telegram/notifications/probe",
|
||||
account_telegram_notifications_probe_route,
|
||||
)
|
||||
app.router.add_post("/api/promo/apply", apply_promo_route)
|
||||
app.router.add_post("/api/trial/activate", activate_trial_route)
|
||||
app.router.add_get("/api/devices", devices_route)
|
||||
|
||||
@@ -3,6 +3,12 @@ from ._runtime import * # noqa: F403,F405
|
||||
|
||||
from config.subscription_guides_config import subscription_guides_available
|
||||
from config.webapp_themes_config import public_themes_catalog_payload
|
||||
from bot.services.telegram_notifications import (
|
||||
TELEGRAM_NOTIFICATIONS_ENABLED,
|
||||
normalize_telegram_notification_status,
|
||||
telegram_notifications_need_prompt,
|
||||
telegram_notifications_start_link,
|
||||
)
|
||||
|
||||
|
||||
async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, Any]:
|
||||
@@ -61,7 +67,7 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
|
||||
trial_available = bool(
|
||||
settings.TRIAL_ENABLED
|
||||
and settings.TRIAL_DURATION_DAYS > 0
|
||||
and not await subscription_service.has_had_any_subscription(session, user_id)
|
||||
and not await subscription_service.has_trial_blocking_subscription(session, user_id)
|
||||
)
|
||||
avatar = await _ensure_cached_telegram_avatar(request, session, db_user)
|
||||
try:
|
||||
@@ -72,6 +78,12 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
|
||||
lang = _normalize_language(db_user.language_code or settings.DEFAULT_LANGUAGE)
|
||||
admin_ids = {int(x) for x in (settings.ADMIN_IDS or [])}
|
||||
is_admin = bool(db_user.telegram_id and int(db_user.telegram_id) in admin_ids)
|
||||
telegram_notifications_status = normalize_telegram_notification_status(
|
||||
getattr(db_user, "telegram_notifications_status", None)
|
||||
)
|
||||
telegram_notifications_link = telegram_notifications_start_link(
|
||||
request.app.get("bot_username") or ""
|
||||
)
|
||||
return {
|
||||
"user": {
|
||||
"id": user_id,
|
||||
@@ -83,6 +95,12 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
|
||||
),
|
||||
"telegram_id": db_user.telegram_id,
|
||||
"telegram_linked": bool(_telegram_id_for_user(db_user)),
|
||||
"telegram_notifications_status": telegram_notifications_status,
|
||||
"telegram_notifications_enabled": (
|
||||
telegram_notifications_status == TELEGRAM_NOTIFICATIONS_ENABLED
|
||||
),
|
||||
"telegram_notifications_need_prompt": telegram_notifications_need_prompt(db_user),
|
||||
"telegram_notifications_start_link": telegram_notifications_link,
|
||||
"telegram_photo_url": _telegram_avatar_url(avatar),
|
||||
"first_name": db_user.first_name,
|
||||
"language_code": lang,
|
||||
@@ -156,12 +174,79 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
|
||||
}
|
||||
|
||||
|
||||
def _serialize_referral_bonus_details(settings: Settings, lang: str) -> List[Dict[str, Any]]:
|
||||
def _legacy_referral_bonus_periods(settings: Settings) -> List[int]:
|
||||
if getattr(settings, "traffic_sale_mode", False):
|
||||
return []
|
||||
|
||||
return sorted(int(months) for months in settings.subscription_options)
|
||||
|
||||
|
||||
def _serialize_tariff_period_referral_bonus_details(tariff: Any, lang: str) -> List[Dict[str, Any]]:
|
||||
details: List[Dict[str, Any]] = []
|
||||
for months, _price in sorted(settings.subscription_options.items()):
|
||||
for months in sorted(int(month) for month in tariff.enabled_periods):
|
||||
inviter_days = tariff.referral_inviter_bonus_days(months)
|
||||
friend_days = tariff.referral_referee_bonus_days(months)
|
||||
if inviter_days is None and friend_days is None:
|
||||
continue
|
||||
details.append(
|
||||
{
|
||||
"id": f"{tariff.key}:{months}",
|
||||
"tariff_key": tariff.key,
|
||||
"tariff_name": tariff.name(lang),
|
||||
"months": int(months),
|
||||
"title": _format_months_title(int(months), lang),
|
||||
"inviter_days": int(inviter_days or 0),
|
||||
"friend_days": int(friend_days or 0),
|
||||
}
|
||||
)
|
||||
return details
|
||||
|
||||
|
||||
def _serialize_tariff_referral_bonus_details(settings: Settings, lang: str) -> List[Dict[str, Any]]:
|
||||
tariffs_config = settings.tariffs_config
|
||||
if not tariffs_config:
|
||||
return []
|
||||
|
||||
period_tariffs = [
|
||||
tariff for tariff in tariffs_config.enabled_tariffs if tariff.billing_model == "period"
|
||||
]
|
||||
if len(period_tariffs) <= 1:
|
||||
return (
|
||||
_serialize_tariff_period_referral_bonus_details(period_tariffs[0], lang)
|
||||
if period_tariffs
|
||||
else []
|
||||
)
|
||||
|
||||
summaries: List[Dict[str, Any]] = []
|
||||
for tariff in period_tariffs:
|
||||
details = _serialize_tariff_period_referral_bonus_details(tariff, lang)
|
||||
if not details:
|
||||
continue
|
||||
inviter_values = [int(item["inviter_days"]) for item in details]
|
||||
friend_values = [int(item["friend_days"]) for item in details]
|
||||
summaries.append(
|
||||
{
|
||||
"id": f"tariff:{tariff.key}",
|
||||
"type": "tariff_summary",
|
||||
"tariff_key": tariff.key,
|
||||
"tariff_name": tariff.name(lang),
|
||||
"title": tariff.name(lang),
|
||||
"inviter_min_days": min(inviter_values),
|
||||
"inviter_max_days": max(inviter_values),
|
||||
"friend_min_days": min(friend_values),
|
||||
"friend_max_days": max(friend_values),
|
||||
"details": details,
|
||||
}
|
||||
)
|
||||
return summaries
|
||||
|
||||
|
||||
def _serialize_referral_bonus_details(settings: Settings, lang: str) -> List[Dict[str, Any]]:
|
||||
if settings.tariffs_config:
|
||||
return _serialize_tariff_referral_bonus_details(settings, lang)
|
||||
|
||||
details: List[Dict[str, Any]] = []
|
||||
for months in _legacy_referral_bonus_periods(settings):
|
||||
inviter_days = settings.referral_bonus_inviter.get(months)
|
||||
friend_days = settings.referral_bonus_referee.get(months)
|
||||
if inviter_days is None and friend_days is None:
|
||||
@@ -387,15 +472,18 @@ def _serialize_plans(
|
||||
) -> List[Dict[str, Any]]:
|
||||
tariffs_config = settings.tariffs_config
|
||||
if tariffs_config:
|
||||
default_currency = default_currency_key_for_settings(settings)
|
||||
default_currency_code = payment_currency_code(default_currency)
|
||||
plans: List[Dict[str, Any]] = []
|
||||
for tariff in tariffs_config.enabled_tariffs:
|
||||
common = {
|
||||
"tariff_key": tariff.key,
|
||||
"is_default_tariff": tariff.key == tariffs_config.default_tariff,
|
||||
"tariff_name": tariff.name(lang),
|
||||
"billing_model": tariff.billing_model,
|
||||
"description": tariff.description(lang),
|
||||
"squad_uuids": tariff.squad_uuids,
|
||||
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
|
||||
"currency": default_currency_code,
|
||||
"hwid_device_limit": tariff.hwid_device_limit,
|
||||
"hwid_device_packages": _serialize_hwid_device_packages(
|
||||
settings,
|
||||
@@ -408,7 +496,7 @@ def _serialize_plans(
|
||||
}
|
||||
if tariff.billing_model == "period":
|
||||
for months in sorted(tariff.enabled_periods):
|
||||
price = tariff.period_price(int(months), "rub")
|
||||
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):
|
||||
continue
|
||||
@@ -426,9 +514,13 @@ def _serialize_plans(
|
||||
plan["stars_price"] = int(stars_price)
|
||||
plans.append(plan)
|
||||
else:
|
||||
rub_packages = {
|
||||
currency_packages = {
|
||||
float(package.gb): float(package.price)
|
||||
for package in (tariff.traffic_packages.rub if tariff.traffic_packages else [])
|
||||
for package in (
|
||||
tariff.traffic_packages.for_currency(default_currency)
|
||||
if tariff.traffic_packages
|
||||
else []
|
||||
)
|
||||
}
|
||||
stars_packages = {
|
||||
float(package.gb): int(float(package.price))
|
||||
@@ -436,8 +528,8 @@ def _serialize_plans(
|
||||
tariff.traffic_packages.stars if tariff.traffic_packages else []
|
||||
)
|
||||
}
|
||||
for traffic_gb in sorted(set(rub_packages) | set(stars_packages)):
|
||||
price = rub_packages.get(traffic_gb)
|
||||
for traffic_gb in sorted(set(currency_packages) | set(stars_packages)):
|
||||
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):
|
||||
continue
|
||||
@@ -523,16 +615,19 @@ def _serialize_topup_packages(
|
||||
sale_mode: str = "topup",
|
||||
title_prefix: str = "",
|
||||
) -> List[Dict[str, Any]]:
|
||||
rub_packages = {
|
||||
float(package.gb): float(package.price) for package in (packages.rub if packages else [])
|
||||
default_currency = default_currency_key_for_settings(settings)
|
||||
default_currency_code = payment_currency_code(default_currency)
|
||||
currency_packages = {
|
||||
float(package.gb): float(package.price)
|
||||
for package in (packages.for_currency(default_currency) if packages else [])
|
||||
}
|
||||
stars_packages = {
|
||||
float(package.gb): int(float(package.price))
|
||||
for package in (packages.stars if packages else [])
|
||||
}
|
||||
plans: List[Dict[str, Any]] = []
|
||||
for traffic_gb in sorted(set(rub_packages) | set(stars_packages)):
|
||||
price = rub_packages.get(traffic_gb)
|
||||
for traffic_gb in sorted(set(currency_packages) | set(stars_packages)):
|
||||
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):
|
||||
continue
|
||||
@@ -546,7 +641,7 @@ def _serialize_topup_packages(
|
||||
"months": int(traffic_value) if traffic_value.is_integer() else traffic_value,
|
||||
"traffic_gb": traffic_value,
|
||||
"price": float(price or 0),
|
||||
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
|
||||
"currency": default_currency_code,
|
||||
"title": f"{title_prefix}{_format_traffic_title(traffic_value, lang)}",
|
||||
"subtitle": tariff.premium_name(lang)
|
||||
if sale_mode == "premium_topup"
|
||||
@@ -564,16 +659,19 @@ def _serialize_hwid_device_packages(
|
||||
packages: Optional[Any],
|
||||
lang: str,
|
||||
) -> List[Dict[str, Any]]:
|
||||
rub_packages = {
|
||||
int(package.count): float(package.price) for package in (packages.rub if packages else [])
|
||||
default_currency = default_currency_key_for_settings(settings)
|
||||
default_currency_code = payment_currency_code(default_currency)
|
||||
currency_packages = {
|
||||
int(package.count): float(package.price)
|
||||
for package in (packages.for_currency(default_currency) if packages else [])
|
||||
}
|
||||
stars_packages = {
|
||||
int(package.count): int(float(package.price))
|
||||
for package in (packages.stars if packages else [])
|
||||
}
|
||||
plans: List[Dict[str, Any]] = []
|
||||
for count in sorted(set(rub_packages) | set(stars_packages)):
|
||||
price = rub_packages.get(count)
|
||||
for count in sorted(set(currency_packages) | set(stars_packages)):
|
||||
price = currency_packages.get(count)
|
||||
stars_price = stars_packages.get(count)
|
||||
if price is None and (stars_price is None or int(stars_price) <= 0):
|
||||
continue
|
||||
@@ -586,7 +684,7 @@ def _serialize_hwid_device_packages(
|
||||
"months": int(count),
|
||||
"device_count": int(count),
|
||||
"price": float(price or 0),
|
||||
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
|
||||
"currency": default_currency_code,
|
||||
"title": f"+{count}",
|
||||
"subtitle": tariff.name(lang),
|
||||
}
|
||||
@@ -603,6 +701,8 @@ def _serialize_tariff_change_target(
|
||||
options: Dict[str, Any],
|
||||
lang: str,
|
||||
) -> Dict[str, Any]:
|
||||
default_currency = default_currency_key_for_settings(settings)
|
||||
default_currency_code = payment_currency_code(default_currency)
|
||||
actions: List[Dict[str, Any]] = []
|
||||
mode = str(options.get("mode") or "")
|
||||
if mode == "period_to_period":
|
||||
@@ -625,7 +725,7 @@ def _serialize_tariff_change_target(
|
||||
"kind": "payment",
|
||||
"title": "paid_diff",
|
||||
"price": paid_diff,
|
||||
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
|
||||
"currency": default_currency_code,
|
||||
}
|
||||
)
|
||||
elif mode == "period_to_traffic":
|
||||
@@ -647,13 +747,17 @@ def _serialize_tariff_change_target(
|
||||
"title": f"+{package.gb:g} GB",
|
||||
"traffic_gb": float(package.gb),
|
||||
"price": float(package.price),
|
||||
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
|
||||
"currency": default_currency_code,
|
||||
}
|
||||
for package in (tariff.traffic_packages.rub if tariff.traffic_packages else [])
|
||||
for package in (
|
||||
tariff.traffic_packages.for_currency(default_currency)
|
||||
if tariff.traffic_packages
|
||||
else []
|
||||
)
|
||||
)
|
||||
else:
|
||||
for months in tariff.enabled_periods:
|
||||
price = tariff.period_price(int(months), "rub")
|
||||
price = tariff.period_price(int(months), default_currency)
|
||||
if price:
|
||||
actions.append(
|
||||
{
|
||||
@@ -662,7 +766,7 @@ def _serialize_tariff_change_target(
|
||||
"months": int(months),
|
||||
"title": _format_months_title(int(months), lang),
|
||||
"price": float(price),
|
||||
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
|
||||
"currency": default_currency_code,
|
||||
}
|
||||
)
|
||||
return {
|
||||
@@ -686,10 +790,15 @@ def _serialize_payment_methods(
|
||||
from bot.payment_providers import get_provider_spec, resolve_provider_presentation
|
||||
|
||||
methods: List[Dict[str, Any]] = []
|
||||
payment_currency = default_payment_currency_code_for_settings(settings)
|
||||
for method in settings.payment_methods_order:
|
||||
method = method.lower()
|
||||
spec = get_provider_spec(method)
|
||||
if spec and spec.is_visible_for_user(settings, app, is_admin=is_admin):
|
||||
if (
|
||||
spec
|
||||
and spec.is_visible_for_user(settings, app, is_admin=is_admin)
|
||||
and spec.is_usable_for_payment_currency(settings, payment_currency)
|
||||
):
|
||||
presentation = resolve_provider_presentation(spec, settings, language=lang)
|
||||
methods.append(
|
||||
{
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
from bot.services.telegram_notifications import (
|
||||
TELEGRAM_NOTIFICATIONS_ENABLED,
|
||||
probe_telegram_notifications,
|
||||
telegram_notifications_start_link,
|
||||
)
|
||||
from .common import _invalidate_webapp_user_caches
|
||||
|
||||
|
||||
async def _probe_telegram_notifications_for_user_id(
|
||||
request: web.Request,
|
||||
user_id: int,
|
||||
*,
|
||||
force: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
settings: Settings = request.app["settings"]
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
if not db_user or db_user.is_banned:
|
||||
await session.rollback()
|
||||
return {
|
||||
"ok": False,
|
||||
"status": "access_denied",
|
||||
"enabled": False,
|
||||
"start_link": telegram_notifications_start_link(
|
||||
request.app.get("bot_username") or ""
|
||||
),
|
||||
}
|
||||
result = await probe_telegram_notifications(
|
||||
session=session,
|
||||
bot=request.app["bot"],
|
||||
settings=settings,
|
||||
i18n=request.app.get("i18n"),
|
||||
user=db_user,
|
||||
bot_username=request.app.get("bot_username") or "",
|
||||
force=force,
|
||||
)
|
||||
await session.commit()
|
||||
status = str(result.get("status") or "")
|
||||
await _invalidate_webapp_user_caches(settings, int(db_user.user_id))
|
||||
return {
|
||||
"ok": bool(result.get("ok")),
|
||||
"status": status,
|
||||
"enabled": status == TELEGRAM_NOTIFICATIONS_ENABLED,
|
||||
"start_link": result.get("start_link"),
|
||||
}
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logger.exception("Telegram notification probe failed")
|
||||
return {
|
||||
"ok": False,
|
||||
"status": "unknown",
|
||||
"enabled": False,
|
||||
"start_link": telegram_notifications_start_link(
|
||||
request.app.get("bot_username") or ""
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def account_telegram_notifications_probe_route(request: web.Request) -> web.Response:
|
||||
user_id = _require_user_id(request)
|
||||
force = True
|
||||
result = await _probe_telegram_notifications_for_user_id(request, user_id, force=force)
|
||||
if result.get("status") == "access_denied":
|
||||
return _json_error(403, "access_denied", "Access denied")
|
||||
return web.json_response({"ok": True, "telegram_notifications": result})
|
||||
@@ -155,7 +155,7 @@ async def change_broadcast_target_handler(
|
||||
return
|
||||
|
||||
new_target = callback.data.split(":")[1]
|
||||
if new_target not in {"all", "active", "inactive"}:
|
||||
if new_target not in {"all", "active", "inactive", "expired"}:
|
||||
await callback.answer("Unknown target.", show_alert=True)
|
||||
return
|
||||
|
||||
@@ -247,6 +247,8 @@ async def confirm_broadcast_callback_handler(
|
||||
user_ids = await user_dal.get_user_ids_with_active_subscription(session)
|
||||
elif target == "inactive":
|
||||
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)
|
||||
else:
|
||||
user_ids = await user_dal.get_all_active_user_ids_for_broadcast(session)
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ from bot.middlewares.i18n import JsonI18n
|
||||
from bot.payment_providers import pending_statuses
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
from config.settings import Settings
|
||||
from config.tariffs_config import default_payment_currency_code_for_settings
|
||||
from db.dal import panel_sync_dal, payment_dal, user_dal
|
||||
from db.models import PanelSyncStatus, Payment
|
||||
|
||||
@@ -71,11 +72,17 @@ async def show_statistics_handler(
|
||||
f"📊 {_('admin_user_stats_total_label')}: <b>{user_stats['total_users']}</b>"
|
||||
)
|
||||
# Removed: Active today moved to panel stats
|
||||
stats_text_parts.append(
|
||||
f"📡 {_('admin_user_stats_active_subscription_label')}: <b>{user_stats['active_subscriptions']}</b>" # noqa: E501
|
||||
)
|
||||
stats_text_parts.append(
|
||||
f"💳 {_('admin_user_stats_paid_subs_label')}: <b>{user_stats['paid_subscriptions']}</b>"
|
||||
)
|
||||
stats_text_parts.append(
|
||||
f"🆓 {_('admin_user_stats_trial_label')}: <b>{user_stats['trial_users']}</b>"
|
||||
f"🧪 {_('admin_user_stats_trial_label')}: <b>{user_stats['trial_users']}</b>"
|
||||
)
|
||||
stats_text_parts.append(
|
||||
f"🎁 {_('admin_user_stats_free_subscription_label')}: <b>{user_stats['free_subscription_users']}</b>" # noqa: E501
|
||||
)
|
||||
stats_text_parts.append(
|
||||
f"😴 {_('admin_user_stats_inactive_label')}: <b>{user_stats['inactive_users']}</b>"
|
||||
@@ -189,19 +196,20 @@ async def show_statistics_handler(
|
||||
|
||||
# Financial statistics
|
||||
financial_stats = await payment_dal.get_financial_statistics(session)
|
||||
currency = default_payment_currency_code_for_settings(settings)
|
||||
|
||||
stats_text_parts.append(f"\n<b>💰 {_('admin_financial_stats_header')}</b>")
|
||||
stats_text_parts.append(
|
||||
f"📅 {_('admin_financial_today_label')}: <b>{financial_stats['today_revenue']:.2f} RUB</b> ({financial_stats['today_payments_count']} {_('admin_financial_payments_label')})" # noqa: E501
|
||||
f"📅 {_('admin_financial_today_label')}: <b>{financial_stats['today_revenue']:.2f} {currency}</b> ({financial_stats['today_payments_count']} {_('admin_financial_payments_label')})" # noqa: E501
|
||||
)
|
||||
stats_text_parts.append(
|
||||
f"📅 {_('admin_financial_week_label')}: <b>{financial_stats['week_revenue']:.2f} RUB</b>"
|
||||
f"📅 {_('admin_financial_week_label')}: <b>{financial_stats['week_revenue']:.2f} {currency}</b>" # noqa: E501
|
||||
)
|
||||
stats_text_parts.append(
|
||||
f"📅 {_('admin_financial_month_label')}: <b>{financial_stats['month_revenue']:.2f} RUB</b>"
|
||||
f"📅 {_('admin_financial_month_label')}: <b>{financial_stats['month_revenue']:.2f} {currency}</b>" # noqa: E501
|
||||
)
|
||||
stats_text_parts.append(
|
||||
f"🏆 {_('admin_financial_all_time_label')}: <b>{financial_stats['all_time_revenue']:.2f} RUB</b>" # noqa: E501
|
||||
f"🏆 {_('admin_financial_all_time_label')}: <b>{financial_stats['all_time_revenue']:.2f} {currency}</b>" # noqa: E501
|
||||
)
|
||||
|
||||
last_payments_models: List[Payment] = await payment_dal.get_recent_payment_logs_with_user(
|
||||
|
||||
@@ -14,6 +14,7 @@ from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
from bot.utils.text_sanitizer import panel_description_from_profile
|
||||
from config.settings import Settings
|
||||
from db.advisory_locks import acquire_subscription_background_sync_lock
|
||||
from db.dal import panel_sync_dal, subscription_dal, user_dal
|
||||
from db.models import Subscription, User
|
||||
|
||||
@@ -928,6 +929,7 @@ async def _perform_sync_impl(
|
||||
|
||||
total_panel_users = len(panel_users_data)
|
||||
logging.info(f"Starting sync for {total_panel_users} panel users.")
|
||||
await acquire_subscription_background_sync_lock(session)
|
||||
sync_indexes = await _prefetch_sync_indexes(session, panel_users_data)
|
||||
users_by_telegram_id = sync_indexes["users_by_telegram_id"]
|
||||
users_by_user_id = sync_indexes["users_by_user_id"]
|
||||
|
||||
@@ -27,6 +27,7 @@ from bot.utils.text_sanitizer import (
|
||||
username_for_display,
|
||||
)
|
||||
from config.settings import Settings
|
||||
from config.tariffs_config import default_payment_currency_code_for_settings
|
||||
from db.dal import message_log_dal, subscription_dal, user_dal
|
||||
from db.models import User
|
||||
|
||||
@@ -88,6 +89,31 @@ async def _find_user_by_admin_input(
|
||||
return None
|
||||
|
||||
|
||||
def _admin_user_reference_label(
|
||||
user: Optional[User], fallback_user_id: Optional[int] = None
|
||||
) -> str:
|
||||
if user is None:
|
||||
return f"ID {fallback_user_id}" if fallback_user_id is not None else "N/A"
|
||||
|
||||
first_name = sanitize_display_name(user.first_name) if user.first_name else ""
|
||||
last_name = sanitize_display_name(user.last_name) if user.last_name else ""
|
||||
full_name = f"{first_name} {last_name}".strip()
|
||||
if full_name:
|
||||
label = full_name
|
||||
elif user.username:
|
||||
label = username_for_display(user.username, with_at=True)
|
||||
elif user.email:
|
||||
label = user.email
|
||||
else:
|
||||
label = f"ID {user.user_id}"
|
||||
return f"{label} · ID {user.user_id}"
|
||||
|
||||
|
||||
def _admin_user_button_label(user: User) -> str:
|
||||
label = _admin_user_reference_label(user)
|
||||
return label[:64]
|
||||
|
||||
|
||||
async def users_list_handler(
|
||||
callback: types.CallbackQuery,
|
||||
i18n_data: dict,
|
||||
@@ -195,7 +221,13 @@ def get_user_card_keyboard(
|
||||
text=_(key="admin_user_refresh_button"), callback_data=f"user_action:refresh:{user_id}"
|
||||
)
|
||||
|
||||
# Row 3b: Premium override + traffic grant
|
||||
# Row 3b: Referral details
|
||||
builder.button(
|
||||
text=_(key="admin_user_invitees_button"),
|
||||
callback_data=f"user_action:invitees:{user_id}:0",
|
||||
)
|
||||
|
||||
# Row 4: Premium override + traffic grant
|
||||
builder.button(
|
||||
text=_(key="admin_user_premium_override_button"),
|
||||
callback_data=f"user_action:premium_override:{user_id}",
|
||||
@@ -229,9 +261,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, 2, 1, 2)
|
||||
builder.adjust(2, 2, 2, 1, 2, 1, 2)
|
||||
else:
|
||||
builder.adjust(2, 2, 2, 2, quick_links_count, 1, 2)
|
||||
builder.adjust(2, 2, 2, 1, 2, quick_links_count, 1, 2)
|
||||
return builder
|
||||
|
||||
|
||||
@@ -314,7 +346,11 @@ async def format_user_card(
|
||||
|
||||
# Referral info
|
||||
if user.referred_by_id:
|
||||
card_parts.append(f"{_('admin_user_referral_label')} {hcode(str(user.referred_by_id))}")
|
||||
referrer = await user_dal.get_referrer_for_user(session, user)
|
||||
card_parts.append(
|
||||
f"{_('admin_user_invited_by_label')} "
|
||||
f"{hcode(_admin_user_reference_label(referrer, user.referred_by_id))}"
|
||||
)
|
||||
|
||||
# Panel info
|
||||
if user.panel_user_uuid:
|
||||
@@ -407,17 +443,18 @@ async def format_user_card(
|
||||
try:
|
||||
from db.dal import payment_dal
|
||||
|
||||
currency = default_payment_currency_code_for_settings(settings)
|
||||
|
||||
# Total amount paid by this user
|
||||
total_paid = await payment_dal.get_user_total_paid(session, user.user_id)
|
||||
card_parts.append(
|
||||
f"{_('admin_user_total_paid_label')} {hcode(f'{total_paid:.2f} RUB')}"
|
||||
f"{_('admin_user_total_paid_label')} {hcode(f'{total_paid:.2f} {currency}')}"
|
||||
)
|
||||
|
||||
# Total revenue from referrals
|
||||
referral_revenue = await payment_dal.get_referral_revenue(session, user.user_id)
|
||||
card_parts.append(
|
||||
f"{_('admin_user_referral_revenue_label')} {hcode(f'{referral_revenue:.2f} RUB')}"
|
||||
)
|
||||
referral_revenue_text = hcode(f"{referral_revenue:.2f} {currency}")
|
||||
card_parts.append(f"{_('admin_user_referral_revenue_label')} {referral_revenue_text}")
|
||||
except Exception as e_fin:
|
||||
logging.error(
|
||||
f"Failed to build financial analytics for admin card {user.user_id}: {e_fin}"
|
||||
@@ -619,6 +656,12 @@ async def user_action_handler(
|
||||
await handle_send_message_prompt(callback, state, user, i18n, current_lang)
|
||||
elif action == "view_logs":
|
||||
await handle_view_user_logs(callback, user, session, settings, i18n, current_lang)
|
||||
elif action == "invitees":
|
||||
try:
|
||||
page = max(0, int(parts[3])) if len(parts) > 3 else 0
|
||||
except (TypeError, ValueError):
|
||||
page = 0
|
||||
await handle_view_user_invitees(callback, user, session, i18n, current_lang, page=page)
|
||||
elif action == "refresh":
|
||||
await handle_refresh_user_card(
|
||||
callback, user, subscription_service, session, settings, i18n, current_lang
|
||||
@@ -889,8 +932,7 @@ async def handle_reset_trial(
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
|
||||
try:
|
||||
# Delete all user subscriptions to reset trial eligibility
|
||||
await subscription_dal.delete_all_user_subscriptions(session, user.user_id)
|
||||
await user_dal.mark_trial_eligibility_reset(session, user.user_id)
|
||||
await session.commit()
|
||||
|
||||
await callback.answer(_("admin_user_trial_reset_success"), show_alert=True)
|
||||
@@ -1056,6 +1098,120 @@ async def handle_view_user_logs(
|
||||
await callback.answer(_("admin_user_logs_error"), show_alert=True)
|
||||
|
||||
|
||||
async def handle_view_user_invitees(
|
||||
callback: types.CallbackQuery,
|
||||
user: User,
|
||||
session: AsyncSession,
|
||||
i18n_instance,
|
||||
lang: str,
|
||||
*,
|
||||
page: int = 0,
|
||||
):
|
||||
"""Show users invited by the selected account."""
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
page_size = 10
|
||||
safe_page = max(0, int(page or 0))
|
||||
|
||||
try:
|
||||
total = await user_dal.count_users_referred_by(session, user.user_id)
|
||||
total_pages = max(1, (total + page_size - 1) // page_size)
|
||||
if safe_page >= total_pages:
|
||||
safe_page = total_pages - 1
|
||||
invitees = await user_dal.get_users_referred_by(
|
||||
session,
|
||||
user.user_id,
|
||||
limit=page_size,
|
||||
offset=safe_page * page_size,
|
||||
)
|
||||
|
||||
header = _(
|
||||
"admin_user_invitees_message_title",
|
||||
user=hcode(_admin_user_reference_label(user)),
|
||||
total=total,
|
||||
current=safe_page + 1,
|
||||
total_pages=total_pages,
|
||||
)
|
||||
if total <= 0:
|
||||
invitees_text = f"{header}\n\n{_('admin_user_invitees_empty')}"
|
||||
else:
|
||||
lines = []
|
||||
for index, invitee in enumerate(invitees, start=safe_page * page_size + 1):
|
||||
registered = (
|
||||
invitee.registration_date.strftime("%Y-%m-%d")
|
||||
if invitee.registration_date
|
||||
else ""
|
||||
)
|
||||
suffix = (
|
||||
_("admin_user_invitee_registered_suffix", date=registered) if registered else ""
|
||||
)
|
||||
lines.append(
|
||||
_(
|
||||
"admin_user_invitee_item",
|
||||
index=index,
|
||||
user=hcode(_admin_user_reference_label(invitee)),
|
||||
suffix=suffix,
|
||||
)
|
||||
)
|
||||
invitees_text = "\n".join([header, "", *lines])
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
for invitee in invitees:
|
||||
builder.row(
|
||||
types.InlineKeyboardButton(
|
||||
text=_admin_user_button_label(invitee),
|
||||
callback_data=f"user_action:refresh:{invitee.user_id}",
|
||||
)
|
||||
)
|
||||
|
||||
pagination_buttons = []
|
||||
if safe_page > 0:
|
||||
pagination_buttons.append(
|
||||
types.InlineKeyboardButton(
|
||||
text=_("prev_page_button"),
|
||||
callback_data=f"user_action:invitees:{user.user_id}:{safe_page - 1}",
|
||||
)
|
||||
)
|
||||
if safe_page < total_pages - 1:
|
||||
pagination_buttons.append(
|
||||
types.InlineKeyboardButton(
|
||||
text=_("next_page_button"),
|
||||
callback_data=f"user_action:invitees:{user.user_id}:{safe_page + 1}",
|
||||
)
|
||||
)
|
||||
if pagination_buttons:
|
||||
builder.row(*pagination_buttons)
|
||||
builder.row(
|
||||
types.InlineKeyboardButton(
|
||||
text=_("admin_user_back_to_card_button"),
|
||||
callback_data=f"user_action:refresh:{user.user_id}",
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
types.InlineKeyboardButton(
|
||||
text=_("back_to_admin_panel_button"), callback_data="admin_action:main"
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
invitees_text, reply_markup=builder.as_markup(), parse_mode="HTML"
|
||||
)
|
||||
except Exception:
|
||||
await callback.message.answer(
|
||||
invitees_text, reply_markup=builder.as_markup(), parse_mode="HTML"
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
except Exception as exc:
|
||||
logging.error(
|
||||
"Error viewing invitees for user %s: %s",
|
||||
user.user_id,
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
await callback.answer(_("admin_user_invitees_error"), show_alert=True)
|
||||
|
||||
|
||||
async def handle_refresh_user_card(
|
||||
callback: types.CallbackQuery,
|
||||
user: User,
|
||||
@@ -1964,7 +2120,7 @@ async def user_card_from_list_handler(
|
||||
text=_("admin_user_back_to_list_button"), callback_data=f"admin_action:users_list:{page}"
|
||||
)
|
||||
quick_links_width = 2 if user.referred_by_id else 1
|
||||
keyboard.adjust(2, 2, 2, 2, quick_links_width, 1, 2, 1)
|
||||
keyboard.adjust(2, 2, 2, 1, 2, quick_links_width, 1, 2, 1)
|
||||
|
||||
# Format user card
|
||||
try:
|
||||
|
||||
@@ -166,8 +166,10 @@ async def create_user_stats_result(
|
||||
"inline_user_stats_message",
|
||||
total=user_stats["total_users"],
|
||||
active_today=user_stats["active_today"],
|
||||
active=user_stats["active_subscriptions"],
|
||||
paid=user_stats["paid_subscriptions"],
|
||||
trial=user_stats["trial_users"],
|
||||
free=user_stats["free_subscription_users"],
|
||||
inactive=user_stats["inactive_users"],
|
||||
banned=user_stats["banned_users"],
|
||||
referral=user_stats["referral_users"],
|
||||
@@ -179,7 +181,7 @@ async def create_user_stats_result(
|
||||
description=_(
|
||||
"inline_user_stats_description",
|
||||
total=user_stats["total_users"],
|
||||
active=user_stats["paid_subscriptions"],
|
||||
active=user_stats["active_subscriptions"],
|
||||
),
|
||||
input_message_content=InputTextMessageContent(
|
||||
message_text=stats_text, parse_mode="HTML"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import logging
|
||||
from typing import Optional, Union
|
||||
from typing import Any, Callable, Optional, Union
|
||||
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
||||
|
||||
from aiogram import Bot, F, Router, types
|
||||
@@ -76,31 +76,10 @@ async def referral_command_handler(
|
||||
await event.answer()
|
||||
return
|
||||
|
||||
bonus_info_parts = []
|
||||
if getattr(settings, "traffic_sale_mode", False):
|
||||
bonus_details_str = _("referral_not_available_for_traffic")
|
||||
else:
|
||||
if settings.subscription_options:
|
||||
for months_period_key, _price in sorted(settings.subscription_options.items()):
|
||||
inv_bonus = settings.referral_bonus_inviter.get(months_period_key)
|
||||
ref_bonus = settings.referral_bonus_referee.get(months_period_key)
|
||||
if inv_bonus is not None or ref_bonus is not None:
|
||||
bonus_info_parts.append(
|
||||
_(
|
||||
"referral_bonus_per_period",
|
||||
months=months_period_key,
|
||||
inviter_bonus_days=inv_bonus
|
||||
if inv_bonus is not None
|
||||
else _("no_bonus_placeholder"),
|
||||
referee_bonus_days=ref_bonus
|
||||
if ref_bonus is not None
|
||||
else _("no_bonus_placeholder"),
|
||||
)
|
||||
)
|
||||
|
||||
bonus_details_str = (
|
||||
"\n".join(bonus_info_parts) if bonus_info_parts else _("referral_no_bonuses_configured")
|
||||
)
|
||||
bonus_details_str = _build_referral_bonus_details_text(settings, _, current_lang)
|
||||
|
||||
referral_stats = await referral_service.get_referral_stats(session, inviter_user_id)
|
||||
|
||||
@@ -208,6 +187,132 @@ async def referral_action_handler(
|
||||
await callback.answer()
|
||||
|
||||
|
||||
Translator = Callable[..., str]
|
||||
|
||||
|
||||
def _period_bonus_text(
|
||||
translator: Translator,
|
||||
*,
|
||||
months: int,
|
||||
inviter_days: Optional[int],
|
||||
referee_days: Optional[int],
|
||||
) -> str:
|
||||
return translator(
|
||||
"referral_bonus_per_period",
|
||||
months=months,
|
||||
inviter_bonus_days=(
|
||||
inviter_days if inviter_days is not None else translator("no_bonus_placeholder")
|
||||
),
|
||||
referee_bonus_days=(
|
||||
referee_days if referee_days is not None else translator("no_bonus_placeholder")
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _tariff_period_bonus_entries(tariff: Any) -> list[dict[str, Optional[int]]]:
|
||||
entries: list[dict[str, Optional[int]]] = []
|
||||
for months in sorted(int(month) for month in getattr(tariff, "enabled_periods", [])):
|
||||
inviter_days = tariff.referral_inviter_bonus_days(months)
|
||||
referee_days = tariff.referral_referee_bonus_days(months)
|
||||
if inviter_days is None and referee_days is None:
|
||||
continue
|
||||
entries.append(
|
||||
{
|
||||
"months": months,
|
||||
"inviter_days": inviter_days,
|
||||
"referee_days": referee_days,
|
||||
}
|
||||
)
|
||||
return entries
|
||||
|
||||
|
||||
def _legacy_period_bonus_entries(settings: Settings) -> list[dict[str, Optional[int]]]:
|
||||
entries: list[dict[str, Optional[int]]] = []
|
||||
for months, _price in sorted(settings.subscription_options.items()):
|
||||
inviter_days = settings.referral_bonus_inviter.get(months)
|
||||
referee_days = settings.referral_bonus_referee.get(months)
|
||||
if inviter_days is None and referee_days is None:
|
||||
continue
|
||||
entries.append(
|
||||
{
|
||||
"months": int(months),
|
||||
"inviter_days": inviter_days,
|
||||
"referee_days": referee_days,
|
||||
}
|
||||
)
|
||||
return entries
|
||||
|
||||
|
||||
def _bonus_days_range(translator: Translator, values: list[int]) -> str:
|
||||
return translator(
|
||||
"referral_bonus_days_range",
|
||||
min_days=min(values),
|
||||
max_days=max(values),
|
||||
)
|
||||
|
||||
|
||||
def _build_referral_bonus_details_text(
|
||||
settings: Settings, translator: Translator, current_lang: str
|
||||
) -> str:
|
||||
tariffs_config = settings.tariffs_config
|
||||
if not tariffs_config:
|
||||
bonus_info_parts = [
|
||||
_period_bonus_text(
|
||||
translator,
|
||||
months=int(entry["months"] or 0),
|
||||
inviter_days=entry["inviter_days"],
|
||||
referee_days=entry["referee_days"],
|
||||
)
|
||||
for entry in _legacy_period_bonus_entries(settings)
|
||||
]
|
||||
return (
|
||||
"\n".join(bonus_info_parts)
|
||||
if bonus_info_parts
|
||||
else translator("referral_no_bonuses_configured")
|
||||
)
|
||||
|
||||
period_tariffs = [
|
||||
tariff for tariff in tariffs_config.enabled_tariffs if tariff.billing_model == "period"
|
||||
]
|
||||
if len(period_tariffs) <= 1:
|
||||
entries = _tariff_period_bonus_entries(period_tariffs[0]) if period_tariffs else []
|
||||
bonus_info_parts = [
|
||||
_period_bonus_text(
|
||||
translator,
|
||||
months=int(entry["months"] or 0),
|
||||
inviter_days=entry["inviter_days"],
|
||||
referee_days=entry["referee_days"],
|
||||
)
|
||||
for entry in entries
|
||||
]
|
||||
return (
|
||||
"\n".join(bonus_info_parts)
|
||||
if bonus_info_parts
|
||||
else translator("referral_no_bonuses_configured")
|
||||
)
|
||||
|
||||
bonus_info_parts = []
|
||||
for tariff in period_tariffs:
|
||||
entries = _tariff_period_bonus_entries(tariff)
|
||||
if not entries:
|
||||
continue
|
||||
inviter_values = [int(entry["inviter_days"] or 0) for entry in entries]
|
||||
referee_values = [int(entry["referee_days"] or 0) for entry in entries]
|
||||
bonus_info_parts.append(
|
||||
translator(
|
||||
"referral_bonus_tariff_range",
|
||||
tariff_name=tariff.name(current_lang),
|
||||
inviter_bonus_range=_bonus_days_range(translator, inviter_values),
|
||||
referee_bonus_range=_bonus_days_range(translator, referee_values),
|
||||
)
|
||||
)
|
||||
return (
|
||||
"\n".join(bonus_info_parts)
|
||||
if bonus_info_parts
|
||||
else translator("referral_no_bonuses_configured")
|
||||
)
|
||||
|
||||
|
||||
def _build_webapp_referral_link(
|
||||
base_url: Optional[str], referral_code: Optional[str]
|
||||
) -> Optional[str]:
|
||||
|
||||
@@ -22,7 +22,12 @@ from bot.services.panel_api_service import PanelApiService
|
||||
from bot.services.promo_code_service import PromoCodeService
|
||||
from bot.services.referral_service import ReferralService
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.services.telegram_notifications import TELEGRAM_NOTIFICATIONS_ENABLED
|
||||
from bot.utils.callback_answer import safe_answer_callback
|
||||
from bot.utils.channel_subscription import (
|
||||
is_required_channel_access_error,
|
||||
normalize_required_channel_id,
|
||||
)
|
||||
from bot.utils.install_links import (
|
||||
append_install_share_link_text,
|
||||
ensure_user_install_guide_links,
|
||||
@@ -44,12 +49,12 @@ async def should_show_trial_button(
|
||||
if not settings.TRIAL_ENABLED:
|
||||
return False
|
||||
|
||||
if hasattr(subscription_service, "has_had_any_subscription") and callable(
|
||||
getattr(subscription_service, "has_had_any_subscription")
|
||||
if hasattr(subscription_service, "has_trial_blocking_subscription") and callable(
|
||||
getattr(subscription_service, "has_trial_blocking_subscription")
|
||||
):
|
||||
return not await subscription_service.has_had_any_subscription(session, user_id)
|
||||
return not await subscription_service.has_trial_blocking_subscription(session, user_id)
|
||||
|
||||
logging.error("Method has_had_any_subscription is missing in SubscriptionService!")
|
||||
logging.error("Method has_trial_blocking_subscription is missing in SubscriptionService!")
|
||||
return False
|
||||
|
||||
|
||||
@@ -214,7 +219,7 @@ async def ensure_required_channel_subscription(
|
||||
Verify that the user is a member of the required channel (if configured).
|
||||
Returns True when access can proceed, False when user must subscribe first.
|
||||
"""
|
||||
required_channel_id = settings.REQUIRED_CHANNEL_ID
|
||||
required_channel_id = normalize_required_channel_id(settings.REQUIRED_CHANNEL_ID)
|
||||
if not required_channel_id:
|
||||
return True
|
||||
|
||||
@@ -278,6 +283,29 @@ async def ensure_required_channel_subscription(
|
||||
if status_value in allowed_statuses:
|
||||
is_member = True
|
||||
except TelegramBadRequest as bad_request:
|
||||
if is_required_channel_access_error(bad_request):
|
||||
logging.error(
|
||||
"Required channel check failed due to channel access/configuration error "
|
||||
"(configured=%s, normalized=%s): %s",
|
||||
settings.REQUIRED_CHANNEL_ID,
|
||||
required_channel_id,
|
||||
bad_request,
|
||||
)
|
||||
error_text = translate("channel_subscription_check_failed")
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
try:
|
||||
await event.answer(error_text, show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
if message_obj:
|
||||
try:
|
||||
await message_obj.answer(error_text)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
await event.answer(error_text)
|
||||
return False
|
||||
|
||||
logging.info(
|
||||
"Required channel check: user %s not subscribed (details: %s)",
|
||||
user_id,
|
||||
@@ -392,11 +420,12 @@ async def ensure_required_channel_subscription(
|
||||
@router.message(CommandStart(magic=F.args.regexp(r"^promo_(\w+)$").as_("promo_match")))
|
||||
@router.message(CommandStart(magic=F.args.regexp(r"^admin_user_(\d+)$").as_("admin_user_match")))
|
||||
@router.message(CommandStart(magic=F.args.regexp(r"^ticket_(\d+)$").as_("ticket_match")))
|
||||
@router.message(CommandStart(magic=F.args.regexp(r"^notifications$").as_("notifications_match")))
|
||||
@router.message(CommandStart(magic=F.args.regexp(r"^page_ref$").as_("page_ref_match")))
|
||||
@router.message(
|
||||
CommandStart(
|
||||
magic=F.args.regexp(
|
||||
r"^(?!ref_|promo_|admin_user_|ticket_|page_ref$|webapp_auth_)([A-Za-z0-9_\-]{2,64})$"
|
||||
r"^(?!ref_|promo_|admin_user_|ticket_|notifications$|page_ref$|webapp_auth_)([A-Za-z0-9_\-]{2,64})$"
|
||||
).as_("ad_param_match")
|
||||
)
|
||||
)
|
||||
@@ -414,6 +443,7 @@ async def start_command_handler(
|
||||
ad_param_match: Optional[re.Match] = None,
|
||||
admin_user_match: Optional[re.Match] = None,
|
||||
ticket_match: Optional[re.Match] = None,
|
||||
notifications_match: Optional[re.Match] = None,
|
||||
):
|
||||
await state.clear()
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
@@ -500,6 +530,7 @@ async def start_command_handler(
|
||||
promo_code_to_apply: Optional[str] = None
|
||||
should_open_referral_from_start = False
|
||||
ad_start_param: Optional[str] = None
|
||||
notifications_start_requested = bool(notifications_match)
|
||||
|
||||
if ref_match:
|
||||
raw_ref_value = ref_match.group(1)
|
||||
@@ -522,6 +553,8 @@ async def start_command_handler(
|
||||
elif promo_match:
|
||||
promo_code_to_apply = promo_match.group(1)
|
||||
logging.info(f"User {user_id} started with promo code: {promo_code_to_apply}")
|
||||
elif notifications_start_requested:
|
||||
logging.info("User %s started bot from notifications deep-link.", user_id)
|
||||
elif page_ref_match:
|
||||
should_open_referral_from_start = True
|
||||
logging.info(f"User {user_id} started with page_ref deep-link.")
|
||||
@@ -532,18 +565,24 @@ async def start_command_handler(
|
||||
sanitized_username = sanitize_username(user.username)
|
||||
sanitized_first_name = sanitize_display_name(user.first_name)
|
||||
sanitized_last_name = sanitize_display_name(user.last_name)
|
||||
notification_status_now = datetime.now(timezone.utc)
|
||||
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
is_existing_user = db_user is not None
|
||||
if not db_user:
|
||||
user_data_to_create = {
|
||||
"user_id": user_id,
|
||||
"telegram_id": user_id,
|
||||
"username": sanitized_username,
|
||||
"first_name": sanitized_first_name,
|
||||
"last_name": sanitized_last_name,
|
||||
"language_code": current_lang,
|
||||
"referred_by_id": referred_by_user_id,
|
||||
"registration_date": datetime.now(timezone.utc),
|
||||
"telegram_notifications_status": TELEGRAM_NOTIFICATIONS_ENABLED,
|
||||
"telegram_notifications_checked_at": notification_status_now,
|
||||
"telegram_notifications_enabled_at": notification_status_now,
|
||||
"telegram_notifications_blocked_at": None,
|
||||
}
|
||||
try:
|
||||
db_user, created = await user_dal.create_user(session, user_data_to_create)
|
||||
@@ -631,6 +670,13 @@ async def start_command_handler(
|
||||
update_payload = {}
|
||||
if db_user.language_code != current_lang:
|
||||
update_payload["language_code"] = current_lang
|
||||
if db_user.telegram_id != user_id:
|
||||
update_payload["telegram_id"] = user_id
|
||||
if db_user.telegram_notifications_status != TELEGRAM_NOTIFICATIONS_ENABLED:
|
||||
update_payload["telegram_notifications_status"] = TELEGRAM_NOTIFICATIONS_ENABLED
|
||||
update_payload["telegram_notifications_checked_at"] = notification_status_now
|
||||
update_payload["telegram_notifications_enabled_at"] = notification_status_now
|
||||
update_payload["telegram_notifications_blocked_at"] = None
|
||||
# Set referral only if not already set AND user is not currently active.
|
||||
# This allows previously subscribed but currently inactive users to be attributed.
|
||||
if referred_by_user_id and db_user.referred_by_id is None:
|
||||
@@ -684,9 +730,16 @@ async def start_command_handler(
|
||||
open_referral_page_for_existing_user = should_open_referral_from_start and is_existing_user
|
||||
|
||||
# Send welcome message if not disabled
|
||||
if not settings.DISABLE_WELCOME_MESSAGE and not open_referral_page_for_existing_user:
|
||||
if (
|
||||
not settings.DISABLE_WELCOME_MESSAGE
|
||||
and not open_referral_page_for_existing_user
|
||||
and not notifications_start_requested
|
||||
):
|
||||
await message.answer(_(key="welcome", user_name=hd.quote(user.full_name)))
|
||||
|
||||
if notifications_start_requested:
|
||||
await message.answer(_("telegram_notifications_started"), parse_mode="HTML")
|
||||
|
||||
# Auto-apply promo code if provided via start parameter
|
||||
if promo_code_to_apply:
|
||||
try:
|
||||
|
||||
@@ -32,6 +32,10 @@ from bot.utils.install_links import (
|
||||
ensure_user_install_guide_links,
|
||||
)
|
||||
from config.settings import Settings
|
||||
from config.tariffs_config import (
|
||||
default_currency_key_for_settings,
|
||||
default_payment_currency_code_for_settings,
|
||||
)
|
||||
from db.dal import subscription_dal, user_billing_dal
|
||||
from db.models import Subscription
|
||||
|
||||
@@ -80,11 +84,13 @@ def _tariff_purchase_markup(
|
||||
back_callback=back_callback,
|
||||
callback_context=callback_context,
|
||||
)
|
||||
default_currency = default_currency_key_for_settings(settings)
|
||||
return get_tariff_packages_keyboard(
|
||||
tariff,
|
||||
tariff.traffic_packages.rub,
|
||||
tariff.traffic_packages.for_currency(default_currency),
|
||||
current_lang,
|
||||
i18n,
|
||||
currency_symbol=default_payment_currency_code_for_settings(settings),
|
||||
back_callback=back_callback,
|
||||
callback_context=callback_context,
|
||||
)
|
||||
@@ -114,6 +120,20 @@ def _with_subscription_purchase_description(
|
||||
return f"{description}\n\n{text}"
|
||||
|
||||
|
||||
def _format_premium_bytes(value: object) -> str:
|
||||
try:
|
||||
bytes_value = max(0, int(value or 0))
|
||||
except (TypeError, ValueError):
|
||||
bytes_value = 0
|
||||
return f"{bytes_value / 2**30:.2f} GB"
|
||||
|
||||
|
||||
def _format_premium_usage_limit(active: dict[str, object]) -> str:
|
||||
used = _format_premium_bytes(active.get("premium_used_bytes"))
|
||||
limit = _format_premium_bytes(active.get("premium_limit_bytes"))
|
||||
return f"{used} из {limit}"
|
||||
|
||||
|
||||
async def display_subscription_options(
|
||||
event: Union[types.Message, types.CallbackQuery],
|
||||
i18n_data: dict,
|
||||
@@ -171,6 +191,7 @@ async def display_subscription_options(
|
||||
enabled_tariffs,
|
||||
current_lang,
|
||||
i18n,
|
||||
settings=settings,
|
||||
back_callback=back_callback,
|
||||
callback_context=callback_context,
|
||||
)
|
||||
@@ -315,7 +336,9 @@ async def select_tariff_period_callback(
|
||||
callback_context = parts[4] if len(parts) > 4 else None
|
||||
tariff = config.require(tariff_key)
|
||||
months = int(months_raw)
|
||||
price_rub = tariff.period_price(months, "rub")
|
||||
default_currency = default_currency_key_for_settings(settings)
|
||||
currency_code = default_payment_currency_code_for_settings(settings)
|
||||
price_rub = tariff.period_price(months, default_currency)
|
||||
stars_price = tariff.period_price(months, "stars")
|
||||
if price_rub is None:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
@@ -324,7 +347,7 @@ async def select_tariff_period_callback(
|
||||
months,
|
||||
price_rub,
|
||||
int(stars_price) if stars_price else None,
|
||||
settings.DEFAULT_CURRENCY_SYMBOL,
|
||||
currency_code,
|
||||
current_lang,
|
||||
i18n,
|
||||
settings,
|
||||
@@ -355,10 +378,16 @@ async def select_tariff_package_callback(
|
||||
callback_context = parts[4] if len(parts) > 4 else None
|
||||
tariff = config.require(tariff_key)
|
||||
gb = float(gb_raw)
|
||||
default_currency = default_currency_key_for_settings(settings)
|
||||
currency_code = default_payment_currency_code_for_settings(settings)
|
||||
packages = (
|
||||
tariff.traffic_packages.rub
|
||||
tariff.traffic_packages.for_currency(default_currency)
|
||||
if tariff.billing_model == "traffic"
|
||||
else (config.topup_packages_for(tariff).rub if config.topup_packages_for(tariff) else [])
|
||||
else (
|
||||
config.topup_packages_for(tariff).for_currency(default_currency)
|
||||
if config.topup_packages_for(tariff)
|
||||
else []
|
||||
)
|
||||
)
|
||||
package = next((pkg for pkg in packages if float(pkg.gb) == gb), None)
|
||||
if not package:
|
||||
@@ -377,7 +406,7 @@ async def select_tariff_package_callback(
|
||||
gb,
|
||||
package.price,
|
||||
None,
|
||||
settings.DEFAULT_CURRENCY_SYMBOL,
|
||||
currency_code,
|
||||
current_lang,
|
||||
i18n,
|
||||
settings,
|
||||
@@ -409,14 +438,19 @@ async def tariff_topup_list_callback(
|
||||
return
|
||||
tariff = config.require(active["tariff_key"])
|
||||
packages = config.topup_packages_for(tariff)
|
||||
rub_packages = packages.rub if packages else []
|
||||
premium_packages = tariff.premium_topup_packages.rub if tariff.premium_topup_packages else []
|
||||
if not rub_packages and not premium_packages:
|
||||
default_currency = default_currency_key_for_settings(settings)
|
||||
currency = default_payment_currency_code_for_settings(settings)
|
||||
currency_packages = packages.for_currency(default_currency) if packages else []
|
||||
premium_packages = (
|
||||
tariff.premium_topup_packages.for_currency(default_currency)
|
||||
if tariff.premium_topup_packages
|
||||
else []
|
||||
)
|
||||
if not currency_packages and not premium_packages:
|
||||
await callback.answer(get_text("no_subscription_options_available"), show_alert=True)
|
||||
return
|
||||
builder = InlineKeyboardBuilder()
|
||||
currency = settings.DEFAULT_CURRENCY_SYMBOL
|
||||
for package in rub_packages:
|
||||
for package in currency_packages:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=f"Обычный трафик +{package.gb:g} GB — {package.price:g} {currency}",
|
||||
@@ -438,7 +472,7 @@ async def tariff_topup_list_callback(
|
||||
|
||||
premium_lines = []
|
||||
carryover_lines = []
|
||||
if rub_packages or premium_packages:
|
||||
if currency_packages or premium_packages:
|
||||
carryover_lines.append(
|
||||
"Докупленный трафик не сгорает: сначала расходуется месячный лимит, затем докупленный остаток." # noqa: E501
|
||||
)
|
||||
@@ -456,7 +490,7 @@ async def tariff_topup_list_callback(
|
||||
if len(labels) > len(visible):
|
||||
premium_lines.append(f"• ... еще {len(labels) - len(visible)}")
|
||||
premium_lines.append(
|
||||
f"Premium использовано: {active.get('premium_used')} из {active.get('premium_limit')}. Осталось: {premium_left / 2**30:.2f} GB." # noqa: E501
|
||||
f"Premium использовано: {_format_premium_usage_limit(active)}. Осталось: {premium_left / 2**30:.2f} GB." # noqa: E501
|
||||
)
|
||||
text = get_text("choose_payment_method_traffic")
|
||||
if carryover_lines:
|
||||
@@ -481,7 +515,13 @@ async def select_tariff_premium_package_callback(
|
||||
_, _, tariff_key, gb_raw = callback.data.split(":", 3)
|
||||
tariff = config.require(tariff_key)
|
||||
gb = float(gb_raw)
|
||||
packages = tariff.premium_topup_packages.rub if tariff.premium_topup_packages else []
|
||||
default_currency = default_currency_key_for_settings(settings)
|
||||
currency_code = default_payment_currency_code_for_settings(settings)
|
||||
packages = (
|
||||
tariff.premium_topup_packages.for_currency(default_currency)
|
||||
if tariff.premium_topup_packages
|
||||
else []
|
||||
)
|
||||
package = next((pkg for pkg in packages if float(pkg.gb) == gb), None)
|
||||
if not package:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
@@ -490,7 +530,7 @@ async def select_tariff_premium_package_callback(
|
||||
gb,
|
||||
package.price,
|
||||
None,
|
||||
settings.DEFAULT_CURRENCY_SYMBOL,
|
||||
currency_code,
|
||||
current_lang,
|
||||
i18n,
|
||||
settings,
|
||||
@@ -528,7 +568,12 @@ async def hwid_devices_list_callback(
|
||||
if tariff.billing_model != "period":
|
||||
await callback.answer(get_text("no_hwid_device_packages_available"), show_alert=True)
|
||||
return
|
||||
packages = tariff.hwid_device_packages.rub if tariff.hwid_device_packages else []
|
||||
default_currency = default_currency_key_for_settings(settings)
|
||||
packages = (
|
||||
tariff.hwid_device_packages.for_currency(default_currency)
|
||||
if tariff.hwid_device_packages
|
||||
else []
|
||||
)
|
||||
if not packages:
|
||||
await callback.answer(get_text("no_hwid_device_packages_available"), show_alert=True)
|
||||
return
|
||||
@@ -580,7 +625,13 @@ async def hwid_devices_package_callback(
|
||||
package = next(
|
||||
(
|
||||
pkg
|
||||
for pkg in (tariff.hwid_device_packages.rub if tariff.hwid_device_packages else [])
|
||||
for pkg in (
|
||||
tariff.hwid_device_packages.for_currency(
|
||||
default_currency_key_for_settings(settings)
|
||||
)
|
||||
if tariff.hwid_device_packages
|
||||
else []
|
||||
)
|
||||
if int(pkg.count) == count
|
||||
),
|
||||
None,
|
||||
@@ -589,13 +640,15 @@ 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"
|
||||
rub_quote = await subscription_service.quote_hwid_device_topup(
|
||||
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(
|
||||
session,
|
||||
user_id=callback.from_user.id,
|
||||
device_count=count,
|
||||
tariff_key=tariff.key,
|
||||
renewal=action == "renewal_package",
|
||||
currency="rub",
|
||||
currency=default_currency,
|
||||
)
|
||||
stars_quote = await subscription_service.quote_hwid_device_topup(
|
||||
session,
|
||||
@@ -605,16 +658,16 @@ async def hwid_devices_package_callback(
|
||||
renewal=action == "renewal_package",
|
||||
currency="stars",
|
||||
)
|
||||
if not rub_quote and not stars_quote:
|
||||
if not currency_quote and not stars_quote:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
return
|
||||
markup = get_payment_method_keyboard(
|
||||
count,
|
||||
float(rub_quote.get("price") if rub_quote else 0),
|
||||
float(currency_quote.get("price") if currency_quote else 0),
|
||||
int(stars_quote["price"])
|
||||
if stars_quote and int(stars_quote.get("price") or 0) > 0
|
||||
else None,
|
||||
settings.DEFAULT_CURRENCY_SYMBOL,
|
||||
currency_code,
|
||||
current_lang,
|
||||
i18n,
|
||||
settings,
|
||||
@@ -701,6 +754,8 @@ async def tariff_change_select_callback(
|
||||
options = await subscription_service.calculate_tariff_switch_options_with_hwid(
|
||||
session, db_sub, target
|
||||
)
|
||||
default_currency = default_currency_key_for_settings(settings)
|
||||
currency_code = default_payment_currency_code_for_settings(settings)
|
||||
rows = []
|
||||
if options["mode"] == "period_to_period":
|
||||
rows.append(
|
||||
@@ -715,7 +770,7 @@ async def tariff_change_select_callback(
|
||||
rows.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=f"Доплатить {options['paid_diff_rub']} RUB",
|
||||
text=f"Доплатить {options['paid_diff_rub']} {currency_code}",
|
||||
callback_data=f"tariff_change:confirm_pay:{target.key}:{options['paid_diff_rub']}",
|
||||
)
|
||||
]
|
||||
@@ -729,23 +784,23 @@ async def tariff_change_select_callback(
|
||||
)
|
||||
]
|
||||
)
|
||||
for package in target.traffic_packages.rub:
|
||||
for package in target.traffic_packages.for_currency(default_currency):
|
||||
rows.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=f"+ {package.gb:g} GB за {package.price:g} RUB",
|
||||
text=f"+ {package.gb:g} GB за {package.price:g} {currency_code}",
|
||||
callback_data=f"tariff:package:{target.key}:{package.gb:g}",
|
||||
)
|
||||
]
|
||||
)
|
||||
else:
|
||||
for months in target.enabled_periods:
|
||||
price = target.period_price(months, "rub")
|
||||
price = target.period_price(months, default_currency)
|
||||
if price:
|
||||
rows.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=f"{months} мес. за {price:g} RUB",
|
||||
text=f"{months} мес. за {price:g} {currency_code}",
|
||||
callback_data=f"tariff:period:{target.key}:{months}",
|
||||
)
|
||||
]
|
||||
@@ -828,6 +883,7 @@ async def tariff_change_confirm_pay_callback(
|
||||
return
|
||||
_, _, tariff_key, amount_raw = callback.data.split(":", 3)
|
||||
target = config.require(tariff_key)
|
||||
currency_code = default_payment_currency_code_for_settings(settings)
|
||||
rows = [
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
@@ -843,7 +899,7 @@ async def tariff_change_confirm_pay_callback(
|
||||
],
|
||||
]
|
||||
await callback.message.edit_text(
|
||||
f"Подтвердите смену тарифа\n\nНовый тариф: {target.name(current_lang)}\nБудет создана оплата на {amount_raw} RUB.", # noqa: E501
|
||||
f"Подтвердите смену тарифа\n\nНовый тариф: {target.name(current_lang)}\nБудет создана оплата на {amount_raw} {currency_code}.", # noqa: E501
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=rows),
|
||||
)
|
||||
await callback.answer()
|
||||
@@ -885,11 +941,12 @@ async def tariff_change_pay_callback(
|
||||
i18n: JsonI18n = i18n_data.get("i18n_instance")
|
||||
_, _, tariff_key, amount_raw = callback.data.split(":", 3)
|
||||
amount = float(amount_raw)
|
||||
currency_code = default_payment_currency_code_for_settings(settings)
|
||||
markup = get_payment_method_keyboard(
|
||||
1,
|
||||
amount,
|
||||
None,
|
||||
settings.DEFAULT_CURRENCY_SYMBOL,
|
||||
currency_code,
|
||||
current_lang,
|
||||
i18n,
|
||||
settings,
|
||||
@@ -1065,7 +1122,7 @@ async def my_subscription_command_handler(
|
||||
text += (
|
||||
"\n\n🚀 <b>Premium-серверы</b>\n"
|
||||
f"Статус: <b>{premium_status}</b>\n"
|
||||
f"Лимит: <b>{active.get('premium_used')} из {active.get('premium_limit')}</b>\n"
|
||||
f"Лимит: <b>{_format_premium_usage_limit(active)}</b>\n"
|
||||
f"Осталось: <b>{premium_left / 2**30:.2f} GB</b>\n"
|
||||
f"Докупленный остаток: <b>{premium_balance / 2**30:.2f} GB</b>\n"
|
||||
"Отдельный лимит действует на:\n"
|
||||
@@ -1212,7 +1269,9 @@ async def my_subscription_command_handler(
|
||||
if (
|
||||
tariff_for_devices.billing_model == "period"
|
||||
and tariff_for_devices.hwid_device_packages
|
||||
and tariff_for_devices.hwid_device_packages.rub
|
||||
and tariff_for_devices.hwid_device_packages.for_currency(
|
||||
default_currency_key_for_settings(settings)
|
||||
)
|
||||
):
|
||||
prepend_rows.append(
|
||||
[
|
||||
@@ -1429,7 +1488,9 @@ async def my_devices_command_handler(
|
||||
if (
|
||||
tariff_for_devices.billing_model == "period"
|
||||
and tariff_for_devices.hwid_device_packages
|
||||
and tariff_for_devices.hwid_device_packages.rub
|
||||
and tariff_for_devices.hwid_device_packages.for_currency(
|
||||
default_currency_key_for_settings(settings)
|
||||
)
|
||||
):
|
||||
devices_kb.append(
|
||||
[
|
||||
|
||||
@@ -46,7 +46,7 @@ async def request_trial_confirmation_handler(
|
||||
return
|
||||
|
||||
if settings.TRIAL_ENABLED:
|
||||
if not await subscription_service.has_had_any_subscription(session, user_id):
|
||||
if not await subscription_service.has_trial_blocking_subscription(session, user_id):
|
||||
pass
|
||||
|
||||
if not settings.TRIAL_ENABLED:
|
||||
@@ -60,7 +60,7 @@ async def request_trial_confirmation_handler(
|
||||
pass
|
||||
return
|
||||
|
||||
if await subscription_service.has_had_any_subscription(session, user_id):
|
||||
if await subscription_service.has_trial_blocking_subscription(session, user_id):
|
||||
await callback.message.edit_text(
|
||||
_("trial_already_had_subscription_or_trial"),
|
||||
reply_markup=get_main_menu_inline_keyboard(current_lang, i18n, settings, False),
|
||||
@@ -147,8 +147,9 @@ async def request_trial_confirmation_handler(
|
||||
await callback.answer(final_message_text_in_chat, show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
if settings.TRIAL_ENABLED and not await subscription_service.has_had_any_subscription(
|
||||
session, user_id
|
||||
if (
|
||||
settings.TRIAL_ENABLED
|
||||
and not await subscription_service.has_trial_blocking_subscription(session, user_id)
|
||||
):
|
||||
show_trial_button_after_action = True
|
||||
|
||||
@@ -218,7 +219,7 @@ async def confirm_activate_trial_handler(
|
||||
callback, settings, i18n_data, subscription_service, session, is_edit=True
|
||||
)
|
||||
return
|
||||
if await subscription_service.has_had_any_subscription(session, user_id):
|
||||
if await subscription_service.has_trial_blocking_subscription(session, user_id):
|
||||
try:
|
||||
await callback.answer(_("trial_already_had_subscription_or_trial"), show_alert=True)
|
||||
except Exception:
|
||||
@@ -283,8 +284,9 @@ async def confirm_activate_trial_handler(
|
||||
await callback.answer(final_message_text_in_chat, show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
if settings.TRIAL_ENABLED and not await subscription_service.has_had_any_subscription(
|
||||
session, user_id
|
||||
if (
|
||||
settings.TRIAL_ENABLED
|
||||
and not await subscription_service.has_trial_blocking_subscription(session, user_id)
|
||||
):
|
||||
show_trial_button_after_action = True
|
||||
|
||||
|
||||
@@ -452,10 +452,11 @@ def get_broadcast_confirmation_keyboard(
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
# Row: target selection (all / active / inactive)
|
||||
# Row: target selection (all / active / inactive / expired)
|
||||
target_all_label = _(key="broadcast_target_all_button")
|
||||
target_active_label = _(key="broadcast_target_active_button")
|
||||
target_inactive_label = _(key="broadcast_target_inactive_button")
|
||||
target_expired_label = _(key="broadcast_target_expired_button")
|
||||
|
||||
# Highlight current selection with a prefix
|
||||
def mark_selected(label: str, is_selected: bool) -> str:
|
||||
@@ -473,7 +474,10 @@ def get_broadcast_confirmation_keyboard(
|
||||
text=mark_selected(target_inactive_label, target == "inactive"),
|
||||
callback_data="broadcast_target:inactive",
|
||||
)
|
||||
builder.adjust(3)
|
||||
builder.button(
|
||||
text=mark_selected(target_expired_label, target == "expired"),
|
||||
callback_data="broadcast_target:expired",
|
||||
)
|
||||
|
||||
# Row: confirmation
|
||||
builder.button(
|
||||
@@ -482,7 +486,7 @@ def get_broadcast_confirmation_keyboard(
|
||||
builder.button(
|
||||
text=_(key="cancel_broadcast_button"), callback_data="broadcast_final_action:cancel"
|
||||
)
|
||||
builder.adjust(2)
|
||||
builder.adjust(2, 2, 2)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
|
||||
@@ -7,6 +7,10 @@ from bot.middlewares.i18n import locale_language_options
|
||||
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
|
||||
from config.tariffs_config import (
|
||||
default_currency_key_for_settings,
|
||||
default_payment_currency_code_for_settings,
|
||||
)
|
||||
|
||||
BOT_MENU_CONTEXT = "bot"
|
||||
|
||||
@@ -328,19 +332,31 @@ def get_tariff_catalog_keyboard(
|
||||
tariffs: List[Any],
|
||||
lang: str,
|
||||
i18n_instance,
|
||||
settings: Optional[Settings] = None,
|
||||
back_callback: str = "main_action:back_to_main",
|
||||
callback_context: Optional[str] = None,
|
||||
) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
callback_context = callback_context or callback_context_from_back_callback(back_callback)
|
||||
default_currency = default_currency_key_for_settings(settings) if settings else "rub"
|
||||
for tariff in tariffs:
|
||||
label = tariff.name(lang)
|
||||
if tariff.billing_model == "period":
|
||||
min_price = tariff.min_period_price_rub()
|
||||
if hasattr(tariff, "min_period_price"):
|
||||
min_price = tariff.min_period_price(default_currency)
|
||||
elif default_currency == "rub" and hasattr(tariff, "min_period_price_rub"):
|
||||
min_price = tariff.min_period_price_rub()
|
||||
else:
|
||||
min_price = None
|
||||
if min_price is not None:
|
||||
label = f"{label} от {min_price:g}"
|
||||
else:
|
||||
package = tariff.min_traffic_package_rub()
|
||||
if hasattr(tariff, "min_traffic_package"):
|
||||
package = tariff.min_traffic_package(default_currency)
|
||||
elif default_currency == "rub" and hasattr(tariff, "min_traffic_package_rub"):
|
||||
package = tariff.min_traffic_package_rub()
|
||||
else:
|
||||
package = None
|
||||
if package:
|
||||
label = f"{label} от {package.price:g} / {package.gb:g} GB"
|
||||
builder.row(
|
||||
@@ -368,8 +384,10 @@ def get_tariff_periods_keyboard(
|
||||
builder = InlineKeyboardBuilder()
|
||||
callback_context = callback_context or callback_context_from_back_callback(back_callback)
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
default_currency = default_currency_key_for_settings(settings)
|
||||
currency_code = default_payment_currency_code_for_settings(settings)
|
||||
for months in tariff.enabled_periods:
|
||||
rub_price = tariff.period_price(months, "rub")
|
||||
rub_price = tariff.period_price(months, default_currency)
|
||||
if rub_price and rub_price > 0:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
@@ -377,7 +395,7 @@ def get_tariff_periods_keyboard(
|
||||
"subscribe_for_months_button",
|
||||
months=months,
|
||||
price=rub_price,
|
||||
currency_symbol=settings.DEFAULT_CURRENCY_SYMBOL,
|
||||
currency_symbol=currency_code,
|
||||
),
|
||||
callback_data=f"tariff:period:{tariff.key}:{months}"
|
||||
f"{callback_suffix_for_context(callback_context)}",
|
||||
@@ -394,6 +412,7 @@ def get_tariff_packages_keyboard(
|
||||
packages: List[Any],
|
||||
lang: str,
|
||||
i18n_instance,
|
||||
currency_symbol: str = "RUB",
|
||||
back_callback: str = "main_action:subscribe",
|
||||
callback_context: Optional[str] = None,
|
||||
) -> InlineKeyboardMarkup:
|
||||
@@ -407,7 +426,7 @@ def get_tariff_packages_keyboard(
|
||||
"buy_traffic_package_button",
|
||||
traffic_gb=f"{package.gb:g}",
|
||||
price=package.price,
|
||||
currency_symbol="RUB",
|
||||
currency_symbol=currency_symbol,
|
||||
),
|
||||
callback_data=f"tariff:package:{tariff.key}:{package.gb:g}"
|
||||
f"{callback_suffix_for_context(callback_context)}",
|
||||
@@ -430,6 +449,7 @@ def get_hwid_device_packages_keyboard(
|
||||
) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
currency_code = default_payment_currency_code_for_settings(settings)
|
||||
for package in packages:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
@@ -437,7 +457,7 @@ def get_hwid_device_packages_keyboard(
|
||||
"buy_hwid_devices_button",
|
||||
count=package.count,
|
||||
price=package.price,
|
||||
currency_symbol=settings.DEFAULT_CURRENCY_SYMBOL,
|
||||
currency_symbol=currency_code,
|
||||
),
|
||||
callback_data=(
|
||||
f"hwid_devices:{'renewal_package' if renewal else 'package'}:"
|
||||
@@ -484,6 +504,7 @@ def get_payment_method_keyboard(
|
||||
if (
|
||||
not spec
|
||||
or not spec.callback_prefix
|
||||
or not spec.is_usable_for_payment_currency(settings, currency_symbol_val)
|
||||
or not spec.is_available_to_user(
|
||||
settings,
|
||||
user_id=user_id,
|
||||
|
||||
@@ -11,6 +11,7 @@ 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 config.settings import Settings
|
||||
from db.dal import user_dal
|
||||
|
||||
@@ -32,7 +33,7 @@ class ChannelSubscriptionMiddleware(BaseMiddleware):
|
||||
event: Update,
|
||||
data: Dict[str, Any],
|
||||
) -> Any:
|
||||
required_channel_id = self.settings.REQUIRED_CHANNEL_ID
|
||||
required_channel_id = normalize_required_channel_id(self.settings.REQUIRED_CHANNEL_ID)
|
||||
if not required_channel_id:
|
||||
return await handler(event, data)
|
||||
|
||||
|
||||
@@ -307,6 +307,7 @@ class JsonI18n:
|
||||
self.locales_data: Dict[str, Dict[str, str]] = {}
|
||||
self._overrides_path: Optional[Path] = None
|
||||
self._overrides_file_mtime_ns: Optional[int] = None
|
||||
self._overrides_file_content: Optional[str] = None
|
||||
self._overrides_file_next_check = 0.0
|
||||
self._overrides_file_check_interval_seconds = 1.0
|
||||
self._load_locales()
|
||||
@@ -419,6 +420,7 @@ class JsonI18n:
|
||||
if self._overrides_file_mtime_ns is None:
|
||||
return False
|
||||
self._overrides_file_mtime_ns = None
|
||||
self._overrides_file_content = None
|
||||
logging.info(
|
||||
"Locale overrides file removed; keeping current in-memory overrides until "
|
||||
"the DB fallback is reloaded"
|
||||
@@ -432,19 +434,8 @@ class JsonI18n:
|
||||
)
|
||||
return False
|
||||
|
||||
if not force and stat.st_mtime_ns == self._overrides_file_mtime_ns:
|
||||
return False
|
||||
|
||||
try:
|
||||
payload = json.loads(self._overrides_path.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError as exc:
|
||||
logging.warning(
|
||||
"Failed to parse locale overrides file %s: %s",
|
||||
self._overrides_path,
|
||||
exc,
|
||||
)
|
||||
self._overrides_file_mtime_ns = stat.st_mtime_ns
|
||||
return False
|
||||
content = self._overrides_path.read_text(encoding="utf-8")
|
||||
except OSError as exc:
|
||||
logging.warning(
|
||||
"Failed to read locale overrides file %s: %s",
|
||||
@@ -453,7 +444,27 @@ class JsonI18n:
|
||||
)
|
||||
return False
|
||||
|
||||
if (
|
||||
not force
|
||||
and stat.st_mtime_ns == self._overrides_file_mtime_ns
|
||||
and content == self._overrides_file_content
|
||||
):
|
||||
return False
|
||||
|
||||
try:
|
||||
payload = json.loads(content)
|
||||
except json.JSONDecodeError as exc:
|
||||
logging.warning(
|
||||
"Failed to parse locale overrides file %s: %s",
|
||||
self._overrides_path,
|
||||
exc,
|
||||
)
|
||||
self._overrides_file_mtime_ns = stat.st_mtime_ns
|
||||
self._overrides_file_content = content
|
||||
return False
|
||||
|
||||
self._overrides_file_mtime_ns = stat.st_mtime_ns
|
||||
self._overrides_file_content = content
|
||||
self.set_locale_overrides(payload)
|
||||
logging.info("Locale overrides reloaded from %s", self._overrides_path)
|
||||
return True
|
||||
|
||||
@@ -112,6 +112,7 @@ class WebAppPaymentContext:
|
||||
stars_price: Optional[int]
|
||||
description: str
|
||||
sale_mode: str
|
||||
currency: str = "RUB"
|
||||
traffic_gb: Optional[float] = None
|
||||
hwid_valid_from: Optional[Any] = None
|
||||
hwid_valid_until: Optional[Any] = None
|
||||
@@ -125,6 +126,36 @@ ServiceFactory = Callable[[ServiceFactoryContext], Any]
|
||||
WebhookPathGetter = Callable[[Any], str]
|
||||
WebhookRoute = Callable[[Any], Awaitable[Any]]
|
||||
WebAppPaymentFactory = Callable[[WebAppPaymentContext], Awaitable[Any]]
|
||||
CurrencySupportResolver = Callable[[Any], Optional[Sequence[str]]]
|
||||
|
||||
|
||||
def normalize_payment_currency_code(value: Any, default: str = "RUB") -> str:
|
||||
text = str(value or "").strip().upper()
|
||||
if not text:
|
||||
text = str(default).strip().upper() if default is not None else ""
|
||||
if not text:
|
||||
return ""
|
||||
aliases = {"RUR": "RUB", "STARS": "XTR", "STAR": "XTR"}
|
||||
normalized = aliases.get(text, text)
|
||||
return "".join(ch for ch in normalized if ch.isalnum() or ch in {"_", "-"}).strip("_-")
|
||||
|
||||
|
||||
def parse_supported_currency_codes(value: Any) -> tuple[str, ...]:
|
||||
if value is None:
|
||||
return ()
|
||||
if isinstance(value, str):
|
||||
raw_items = value.replace(";", ",").split(",")
|
||||
else:
|
||||
raw_items = list(value)
|
||||
currencies: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for item in raw_items:
|
||||
code = normalize_payment_currency_code(item, default="")
|
||||
if not code or code in seen:
|
||||
continue
|
||||
seen.add(code)
|
||||
currencies.append(code)
|
||||
return tuple(currencies)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -158,6 +189,10 @@ class PaymentProviderSpec:
|
||||
admin_only_manifest_key: Optional[str] = None
|
||||
admin_only_config_attr: str = "ADMIN_ONLY_ENABLED"
|
||||
admin_only_enabled: Optional[EnabledPredicate] = None
|
||||
supported_currencies: Optional[Sequence[str]] = ("RUB",)
|
||||
supported_currencies_resolver: Optional[CurrencySupportResolver] = None
|
||||
currency_support_note: str = ""
|
||||
currency_support_url: Optional[str] = None
|
||||
|
||||
@property
|
||||
def settings_key(self) -> str:
|
||||
@@ -237,6 +272,39 @@ class PaymentProviderSpec:
|
||||
service = app.get(self.service_key) if hasattr(app, "get") else None
|
||||
return bool(service and getattr(service, "configured", False))
|
||||
|
||||
def _currency_source(self, source: Any) -> Any:
|
||||
if self.config_class is not None and self.service_key:
|
||||
from .registry import get_provider_bundle
|
||||
|
||||
bundle = get_provider_bundle(self.service_key)
|
||||
if bundle and bundle.config is not None:
|
||||
return bundle.config
|
||||
return source
|
||||
|
||||
def supported_currency_codes(self, source: Any = None) -> Optional[tuple[str, ...]]:
|
||||
if self.price_source == "stars":
|
||||
return ("XTR",)
|
||||
source_for_currency = self._currency_source(source)
|
||||
if self.supported_currencies_resolver is not None:
|
||||
resolved = self.supported_currencies_resolver(source_for_currency)
|
||||
if resolved is None:
|
||||
return None
|
||||
return parse_supported_currency_codes(resolved)
|
||||
if self.supported_currencies is None:
|
||||
return None
|
||||
return parse_supported_currency_codes(self.supported_currencies)
|
||||
|
||||
def supports_currency(self, source: Any, currency: Any) -> bool:
|
||||
supported = self.supported_currency_codes(source)
|
||||
if supported is None:
|
||||
return True
|
||||
return normalize_payment_currency_code(currency) in supported
|
||||
|
||||
def is_usable_for_payment_currency(self, source: Any, currency: Any) -> bool:
|
||||
if self.price_source == "stars":
|
||||
return True
|
||||
return self.supports_currency(source, currency)
|
||||
|
||||
def is_visible(self, source: Any, app: Any) -> bool:
|
||||
return self.is_enabled(source) and self.is_service_configured(app)
|
||||
|
||||
|
||||
@@ -17,6 +17,10 @@ from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.referral_service import ReferralService
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from config.settings import Settings
|
||||
from config.tariffs_config import (
|
||||
default_currency_key_for_settings,
|
||||
default_payment_currency_code_for_settings,
|
||||
)
|
||||
from db.dal import payment_dal
|
||||
|
||||
from .base import (
|
||||
@@ -25,6 +29,7 @@ from .base import (
|
||||
ProviderManifestField,
|
||||
ServiceFactoryContext,
|
||||
WebAppPaymentContext,
|
||||
normalize_payment_currency_code,
|
||||
provider_env_file,
|
||||
provider_runtime_enabled,
|
||||
)
|
||||
@@ -49,6 +54,34 @@ from .shared import (
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_LOG = "cryptopay"
|
||||
CRYPTOPAY_FIAT_CURRENCIES = (
|
||||
"USD",
|
||||
"EUR",
|
||||
"RUB",
|
||||
"BYN",
|
||||
"UAH",
|
||||
"GBP",
|
||||
"CNY",
|
||||
"KZT",
|
||||
"UZS",
|
||||
"GEL",
|
||||
"TRY",
|
||||
"AMD",
|
||||
"THB",
|
||||
"INR",
|
||||
"BRL",
|
||||
"IDR",
|
||||
"AZN",
|
||||
"AED",
|
||||
"PLN",
|
||||
"ILS",
|
||||
)
|
||||
CRYPTOPAY_CRYPTO_ASSETS = ("USDT", "TON", "BTC", "ETH", "LTC", "BNB", "TRX", "USDC")
|
||||
|
||||
|
||||
def _cryptopay_supported_currencies(config) -> tuple[str, ...]:
|
||||
currency_type = str(getattr(config, "CURRENCY_TYPE", "fiat") or "fiat").strip().lower()
|
||||
return CRYPTOPAY_CRYPTO_ASSETS if currency_type == "crypto" else CRYPTOPAY_FIAT_CURRENCIES
|
||||
|
||||
|
||||
class CryptoPayConfig(ProviderEnvConfig):
|
||||
@@ -159,11 +192,23 @@ class CryptoPayService:
|
||||
sale_mode: str = "subscription",
|
||||
url_kind: str = "bot",
|
||||
hwid_quote: Optional[dict] = None,
|
||||
currency: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
if not self.configured or not self.client:
|
||||
logging.error("CryptoPayService not configured")
|
||||
return None
|
||||
|
||||
currency_code = normalize_payment_currency_code(currency or self.config.ASSET)
|
||||
currency_type = str(self.config.CURRENCY_TYPE or "fiat").strip().lower()
|
||||
supported = _cryptopay_supported_currencies(self.config)
|
||||
if currency_code not in supported:
|
||||
logging.error(
|
||||
"CryptoPay currency %s is not supported for currency_type=%s",
|
||||
currency_code,
|
||||
currency_type,
|
||||
)
|
||||
return None
|
||||
|
||||
sale_base = sale_mode_base(sale_mode)
|
||||
amounts = payment_record_amounts(months=months, sale_mode=sale_mode)
|
||||
try:
|
||||
@@ -172,7 +217,7 @@ class CryptoPayService:
|
||||
{
|
||||
"user_id": user_id,
|
||||
"amount": float(amount),
|
||||
"currency": self.config.ASSET,
|
||||
"currency": currency_code,
|
||||
"status": "pending_cryptopay",
|
||||
"description": description,
|
||||
"subscription_duration_months": (
|
||||
@@ -212,9 +257,9 @@ class CryptoPayService:
|
||||
try:
|
||||
invoice = await self.client.create_invoice(
|
||||
amount=amount,
|
||||
currency_type=self.config.CURRENCY_TYPE,
|
||||
fiat=self.config.ASSET if self.config.CURRENCY_TYPE == "fiat" else None,
|
||||
asset=self.config.ASSET if self.config.CURRENCY_TYPE == "crypto" else None,
|
||||
currency_type=currency_type,
|
||||
fiat=currency_code if currency_type == "fiat" else None,
|
||||
asset=currency_code if currency_type == "crypto" else None,
|
||||
description=description,
|
||||
payload=payload,
|
||||
)
|
||||
@@ -393,7 +438,7 @@ async def pay_crypto_callback_handler(
|
||||
user_id=callback.from_user.id,
|
||||
parts=parts,
|
||||
subscription_service=cryptopay_service.subscription_service,
|
||||
currency="rub",
|
||||
currency=default_currency_key_for_settings(settings),
|
||||
)
|
||||
if not parts:
|
||||
await notify_callback_parse_error(callback, translator)
|
||||
@@ -408,6 +453,7 @@ async def pay_crypto_callback_handler(
|
||||
description=payment_description,
|
||||
sale_mode=parts.sale_mode,
|
||||
hwid_quote=hwid_quote,
|
||||
currency=default_payment_currency_code_for_settings(settings),
|
||||
)
|
||||
|
||||
if invoice_url:
|
||||
@@ -457,6 +503,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
|
||||
description=ctx.description,
|
||||
sale_mode=ctx.sale_mode,
|
||||
url_kind="web",
|
||||
currency=ctx.currency,
|
||||
hwid_quote={
|
||||
"valid_from": ctx.hwid_valid_from,
|
||||
"valid_until": ctx.hwid_valid_until,
|
||||
@@ -592,4 +639,10 @@ SPEC = PaymentProviderSpec(
|
||||
config_class=CryptoPayConfig,
|
||||
presentation_class=CryptoPayPresentation,
|
||||
manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST,
|
||||
supported_currencies_resolver=_cryptopay_supported_currencies,
|
||||
currency_support_note=(
|
||||
"Crypto Pay supports different sets for fiat invoices and crypto invoices; "
|
||||
"CURRENCY_TYPE selects which set is active."
|
||||
),
|
||||
currency_support_url="https://help.crypt.bot/crypto-pay-api/",
|
||||
)
|
||||
|
||||
@@ -20,6 +20,10 @@ from bot.services.referral_service import ReferralService
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.utils.request_security import ip_in_allowlist, request_client_ip
|
||||
from config.settings import Settings
|
||||
from config.tariffs_config import (
|
||||
default_currency_key_for_settings,
|
||||
default_payment_currency_code_for_settings,
|
||||
)
|
||||
from db.dal import payment_dal
|
||||
|
||||
from .base import (
|
||||
@@ -28,6 +32,7 @@ from .base import (
|
||||
ProviderManifestField,
|
||||
ServiceFactoryContext,
|
||||
WebAppPaymentContext,
|
||||
normalize_payment_currency_code,
|
||||
provider_env_file,
|
||||
provider_runtime_enabled,
|
||||
)
|
||||
@@ -56,6 +61,7 @@ from .shared import (
|
||||
)
|
||||
|
||||
_LOG = "freekassa"
|
||||
FREEKASSA_SUPPORTED_CURRENCIES = ("RUB", "USD", "EUR", "UAH", "KZT")
|
||||
|
||||
|
||||
class FreeKassaConfig(ProviderEnvConfig):
|
||||
@@ -144,7 +150,7 @@ class FreeKassaService(HttpClientMixin):
|
||||
self.subscription_service = subscription_service
|
||||
self.referral_service = referral_service
|
||||
|
||||
self.default_currency: str = (settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
|
||||
self.default_currency: str = default_payment_currency_code_for_settings(settings).upper()
|
||||
|
||||
self.api_base_url: str = "https://api.fk.life/v1"
|
||||
self._init_http_client(total_timeout=15)
|
||||
@@ -207,7 +213,13 @@ class FreeKassaService(HttpClientMixin):
|
||||
return False, {"message": "missing_ip"}
|
||||
|
||||
email = email or f"{user_id}@telegram.org"
|
||||
currency_code = (currency or self.default_currency or "RUB").upper()
|
||||
currency_code = normalize_payment_currency_code(currency or self.default_currency or "RUB")
|
||||
if currency_code not in FREEKASSA_SUPPORTED_CURRENCIES:
|
||||
return False, {
|
||||
"message": "unsupported_currency",
|
||||
"currency": currency_code,
|
||||
"supported_currencies": list(FREEKASSA_SUPPORTED_CURRENCIES),
|
||||
}
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
"shopId": int(self.shop_id),
|
||||
@@ -477,7 +489,7 @@ async def pay_fk_callback_handler(
|
||||
user_id=callback.from_user.id,
|
||||
parts=parts,
|
||||
subscription_service=freekassa_service.subscription_service,
|
||||
currency="rub",
|
||||
currency=default_currency_key_for_settings(settings),
|
||||
)
|
||||
if not parts:
|
||||
await notify_callback_parse_error(callback, translator)
|
||||
@@ -485,7 +497,7 @@ async def pay_fk_callback_handler(
|
||||
|
||||
currency_code = (
|
||||
getattr(freekassa_service, "default_currency", None)
|
||||
or settings.DEFAULT_CURRENCY_SYMBOL
|
||||
or default_payment_currency_code_for_settings(settings)
|
||||
or "RUB"
|
||||
)
|
||||
payment_description = describe_payment(translator, parts)
|
||||
@@ -578,12 +590,13 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
|
||||
service: FreeKassaService = ctx.request.app["freekassa_service"]
|
||||
if not service or not service.configured or not service.payment_method_id:
|
||||
return payment_unavailable()
|
||||
currency = ctx.currency or service.default_currency
|
||||
|
||||
try:
|
||||
payment = await create_webapp_payment_record(
|
||||
ctx,
|
||||
amount=ctx.price,
|
||||
currency=service.default_currency,
|
||||
currency=currency,
|
||||
status="pending_freekassa",
|
||||
provider="freekassa",
|
||||
)
|
||||
@@ -592,7 +605,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
|
||||
user_id=ctx.user_id,
|
||||
months=ctx.months,
|
||||
amount=ctx.price,
|
||||
currency=service.default_currency,
|
||||
currency=currency,
|
||||
payment_method_id=service.payment_method_id,
|
||||
ip_address=service.server_ip,
|
||||
extra_params={"us_method": service.payment_method_id},
|
||||
@@ -762,4 +775,9 @@ SPEC = PaymentProviderSpec(
|
||||
config_class=FreeKassaConfig,
|
||||
presentation_class=FreeKassaPresentation,
|
||||
manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST,
|
||||
supported_currencies=FREEKASSA_SUPPORTED_CURRENCIES,
|
||||
currency_support_note=(
|
||||
"FreeKassa SCI documents the payment currency parameter as RUB, USD, EUR, UAH or KZT."
|
||||
),
|
||||
currency_support_url="https://docs.freekassa.net/",
|
||||
)
|
||||
|
||||
@@ -18,6 +18,10 @@ from bot.services.referral_service import ReferralService
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.utils.request_security import ip_in_allowlist, request_client_ip
|
||||
from config.settings import Settings
|
||||
from config.tariffs_config import (
|
||||
default_currency_key_for_settings,
|
||||
default_payment_currency_code_for_settings,
|
||||
)
|
||||
from db.dal import payment_dal
|
||||
|
||||
from .base import (
|
||||
@@ -26,6 +30,8 @@ from .base import (
|
||||
ProviderManifestField,
|
||||
ServiceFactoryContext,
|
||||
WebAppPaymentContext,
|
||||
normalize_payment_currency_code,
|
||||
parse_supported_currency_codes,
|
||||
provider_env_file,
|
||||
provider_runtime_enabled,
|
||||
)
|
||||
@@ -59,6 +65,10 @@ _LOG = "heleket"
|
||||
|
||||
_SUCCESS_STATUSES = {"paid", "paid_over"}
|
||||
_FAILED_STATUSES = {"fail", "wrong_amount", "cancel", "system_fail"}
|
||||
HELEKET_DEFAULT_SUPPORTED_CURRENCIES = (
|
||||
"RUB,USD,EUR,USDT,USDC,BTC,ETH,LTC,TON,TRX,BNB,BCH,DASH,DAI,DOGE,"
|
||||
"MATIC,SHIB,SOL,XMR,AVAX,BUSD,VERSE"
|
||||
)
|
||||
|
||||
|
||||
class HeleketConfig(ProviderEnvConfig):
|
||||
@@ -83,6 +93,7 @@ class HeleketConfig(ProviderEnvConfig):
|
||||
LIFETIME_SECONDS: int = Field(default=3600)
|
||||
VERIFY_WEBHOOK_SIGNATURE: bool = Field(default=True)
|
||||
TRUSTED_IPS: str = Field(default="31.133.220.8")
|
||||
SUPPORTED_CURRENCIES: str = Field(default=HELEKET_DEFAULT_SUPPORTED_CURRENCIES)
|
||||
|
||||
@field_validator("LIFETIME_SECONDS", mode="before")
|
||||
@classmethod
|
||||
@@ -299,9 +310,18 @@ class HeleketService(HttpClientMixin):
|
||||
logging.error("HeleketService is not configured. Cannot create payment link.")
|
||||
return False, {"message": "service_not_configured"}
|
||||
|
||||
currency_code = normalize_payment_currency_code(currency or self.currency)
|
||||
supported = parse_supported_currency_codes(self.config.SUPPORTED_CURRENCIES)
|
||||
if supported and currency_code not in supported:
|
||||
return False, {
|
||||
"message": "unsupported_currency",
|
||||
"currency": currency_code,
|
||||
"supported_currencies": list(supported),
|
||||
}
|
||||
|
||||
body: Dict[str, Any] = {
|
||||
"amount": str(format_decimal_amount(amount)),
|
||||
"currency": (currency or self.currency).upper(),
|
||||
"currency": currency_code,
|
||||
"order_id": str(payment_db_id),
|
||||
"url_return": self.return_url,
|
||||
"url_success": self.success_url,
|
||||
@@ -572,13 +592,13 @@ async def pay_heleket_callback_handler(
|
||||
user_id=callback.from_user.id,
|
||||
parts=parts,
|
||||
subscription_service=heleket_service.subscription_service,
|
||||
currency="rub",
|
||||
currency=default_currency_key_for_settings(settings),
|
||||
)
|
||||
if not parts:
|
||||
await notify_callback_parse_error(callback, translator)
|
||||
return
|
||||
|
||||
currency_code = (heleket_service.currency or settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
|
||||
currency_code = default_payment_currency_code_for_settings(settings)
|
||||
payment_description = describe_payment(translator, parts)
|
||||
record_payload = build_payment_record_payload(
|
||||
user_id=callback.from_user.id,
|
||||
@@ -632,7 +652,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
|
||||
if not service or not service.configured:
|
||||
return payment_unavailable()
|
||||
|
||||
currency = (service.currency or settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
|
||||
currency = ctx.currency or default_payment_currency_code_for_settings(settings)
|
||||
try:
|
||||
payment = await create_webapp_payment_record(
|
||||
ctx,
|
||||
@@ -787,6 +807,18 @@ _CONFIG_MANIFEST = (
|
||||
subsection="Heleket",
|
||||
attr="CURRENCY",
|
||||
),
|
||||
ProviderManifestField(
|
||||
"HELEKET_SUPPORTED_CURRENCIES",
|
||||
"string",
|
||||
"Supported currencies",
|
||||
description=(
|
||||
"Comma-separated invoice currencies allowed for Heleket in this shop. "
|
||||
"Heleket can reject unsupported codes per account/service."
|
||||
),
|
||||
placeholder=HELEKET_DEFAULT_SUPPORTED_CURRENCIES,
|
||||
subsection="Heleket",
|
||||
attr="SUPPORTED_CURRENCIES",
|
||||
),
|
||||
ProviderManifestField(
|
||||
"HELEKET_TO_CURRENCY",
|
||||
"string",
|
||||
@@ -859,4 +891,12 @@ SPEC = PaymentProviderSpec(
|
||||
config_class=HeleketConfig,
|
||||
presentation_class=HeleketPresentation,
|
||||
manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST,
|
||||
supported_currencies_resolver=lambda config: getattr(
|
||||
config, "SUPPORTED_CURRENCIES", HELEKET_DEFAULT_SUPPORTED_CURRENCIES
|
||||
),
|
||||
currency_support_note=(
|
||||
"Heleket supports crypto and fiat invoice currencies, but exact availability "
|
||||
"can depend on service/account settings."
|
||||
),
|
||||
currency_support_url="https://doc.heleket.com/methods/payments/creating-invoice",
|
||||
)
|
||||
|
||||
@@ -14,6 +14,10 @@ from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.referral_service import ReferralService
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from config.settings import Settings
|
||||
from config.tariffs_config import (
|
||||
default_currency_key_for_settings,
|
||||
default_payment_currency_code_for_settings,
|
||||
)
|
||||
from db.dal import payment_dal
|
||||
|
||||
from .base import (
|
||||
@@ -22,6 +26,8 @@ from .base import (
|
||||
ProviderManifestField,
|
||||
ServiceFactoryContext,
|
||||
WebAppPaymentContext,
|
||||
normalize_payment_currency_code,
|
||||
parse_supported_currency_codes,
|
||||
provider_env_file,
|
||||
provider_runtime_enabled,
|
||||
)
|
||||
@@ -76,6 +82,7 @@ class PlategaConfig(ProviderEnvConfig):
|
||||
CRYPTO_METHOD: int = Field(default=13)
|
||||
RETURN_URL: Optional[str] = None
|
||||
FAILED_URL: Optional[str] = None
|
||||
SUPPORTED_CURRENCIES: str = Field(default="RUB")
|
||||
|
||||
@field_validator("MERCHANT_ID", "SECRET", "RETURN_URL", "FAILED_URL", mode="before")
|
||||
@classmethod
|
||||
@@ -229,9 +236,19 @@ class PlategaService(HttpClientMixin):
|
||||
logging.error("PlategaService is not configured. Cannot create transaction.")
|
||||
return False, {"message": "service_not_configured"}
|
||||
|
||||
currency_code = normalize_payment_currency_code(
|
||||
currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
|
||||
)
|
||||
supported = parse_supported_currency_codes(self.config.SUPPORTED_CURRENCIES)
|
||||
if supported and currency_code not in supported:
|
||||
return False, {
|
||||
"message": "unsupported_currency",
|
||||
"currency": currency_code,
|
||||
"supported_currencies": list(supported),
|
||||
}
|
||||
|
||||
session = await self._get_session()
|
||||
url = f"{self.base_url}/transaction/process"
|
||||
currency_code = (currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
|
||||
method_id = int(payment_method if payment_method is not None else self.payment_method)
|
||||
|
||||
body: Dict[str, Any] = {
|
||||
@@ -482,13 +499,13 @@ async def pay_platega_callback_handler(
|
||||
user_id=callback.from_user.id,
|
||||
parts=parts,
|
||||
subscription_service=platega_service.subscription_service,
|
||||
currency="rub",
|
||||
currency=default_currency_key_for_settings(settings),
|
||||
)
|
||||
if not parts:
|
||||
await notify_callback_parse_error(callback, translator)
|
||||
return
|
||||
|
||||
currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
|
||||
currency_code = default_payment_currency_code_for_settings(settings)
|
||||
payment_description = describe_payment(translator, parts)
|
||||
record_payload = build_payment_record_payload(
|
||||
user_id=callback.from_user.id,
|
||||
@@ -596,7 +613,7 @@ async def _create_webapp_payment(ctx: WebAppPaymentContext, variant: str) -> web
|
||||
payment = await create_webapp_payment_record(
|
||||
ctx,
|
||||
amount=ctx.price,
|
||||
currency=settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
|
||||
currency=ctx.currency or settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
|
||||
status="pending_platega",
|
||||
provider="platega",
|
||||
)
|
||||
@@ -616,7 +633,7 @@ async def _create_webapp_payment(ctx: WebAppPaymentContext, variant: str) -> web
|
||||
)
|
||||
success, response_data = await service.create_transaction(
|
||||
amount=ctx.price,
|
||||
currency=settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
|
||||
currency=ctx.currency or settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
|
||||
description=ctx.description,
|
||||
payload=payload,
|
||||
payment_method=platega_method_id,
|
||||
@@ -757,6 +774,18 @@ _CONFIG_MANIFEST = (
|
||||
subsection="Platega",
|
||||
attr="CRYPTO_METHOD",
|
||||
),
|
||||
ProviderManifestField(
|
||||
"PLATEGA_SUPPORTED_CURRENCIES",
|
||||
"string",
|
||||
"Supported currencies",
|
||||
description=(
|
||||
"Comma-separated payment currencies enabled for your Platega merchant. "
|
||||
"Public docs expose currency per method/limits but do not publish a fixed global list."
|
||||
),
|
||||
placeholder="RUB",
|
||||
subsection="Platega",
|
||||
attr="SUPPORTED_CURRENCIES",
|
||||
),
|
||||
ProviderManifestField(
|
||||
"PLATEGA_RETURN_URL", "url", "Return URL", subsection="Platega", attr="RETURN_URL"
|
||||
),
|
||||
@@ -793,6 +822,12 @@ SBP_SPEC = PaymentProviderSpec(
|
||||
presentation_class=PlategaSbpPresentation,
|
||||
manifest_fields=_CONFIG_MANIFEST
|
||||
+ _platega_presentation_manifest("Platega", "CreditCard", "PLATEGA_SBP"),
|
||||
supported_currencies_resolver=lambda config: getattr(config, "SUPPORTED_CURRENCIES", "RUB"),
|
||||
currency_support_note=(
|
||||
"Platega currencies are merchant/method-specific; configure the codes "
|
||||
"enabled for your account."
|
||||
),
|
||||
currency_support_url="https://docs.platega.io/",
|
||||
)
|
||||
|
||||
CRYPTO_SPEC = PaymentProviderSpec(
|
||||
@@ -818,6 +853,12 @@ CRYPTO_SPEC = PaymentProviderSpec(
|
||||
config_class=PlategaConfig,
|
||||
presentation_class=PlategaCryptoPresentation,
|
||||
manifest_fields=_platega_presentation_manifest("Platega", "Bitcoin", "PLATEGA_CRYPTO"),
|
||||
supported_currencies_resolver=lambda config: getattr(config, "SUPPORTED_CURRENCIES", "RUB"),
|
||||
currency_support_note=(
|
||||
"Platega currencies are merchant/method-specific; configure the codes "
|
||||
"enabled for your account."
|
||||
),
|
||||
currency_support_url="https://docs.platega.io/",
|
||||
)
|
||||
|
||||
SPECS = (SBP_SPEC, CRYPTO_SPEC)
|
||||
|
||||
@@ -16,6 +16,10 @@ from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.referral_service import ReferralService
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from config.settings import Settings
|
||||
from config.tariffs_config import (
|
||||
default_currency_key_for_settings,
|
||||
default_payment_currency_code_for_settings,
|
||||
)
|
||||
from db.dal import payment_dal
|
||||
|
||||
from .base import (
|
||||
@@ -24,6 +28,8 @@ from .base import (
|
||||
ProviderManifestField,
|
||||
ServiceFactoryContext,
|
||||
WebAppPaymentContext,
|
||||
normalize_payment_currency_code,
|
||||
parse_supported_currency_codes,
|
||||
provider_env_file,
|
||||
provider_runtime_enabled,
|
||||
)
|
||||
@@ -69,6 +75,7 @@ class SeverPayConfig(ProviderEnvConfig):
|
||||
RETURN_URL: Optional[str] = None
|
||||
BASE_URL: str = Field(default="https://severpay.io/api/merchant")
|
||||
LIFETIME_MINUTES: Optional[int] = None
|
||||
SUPPORTED_CURRENCIES: str = Field(default="RUB,USD")
|
||||
|
||||
@field_validator("MID", "LIFETIME_MINUTES", mode="before")
|
||||
@classmethod
|
||||
@@ -201,9 +208,19 @@ class SeverPayService(HttpClientMixin):
|
||||
logging.error("SeverPayService is not configured. Cannot create payment.")
|
||||
return False, {"message": "service_not_configured"}
|
||||
|
||||
currency_code = normalize_payment_currency_code(
|
||||
currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
|
||||
)
|
||||
supported = parse_supported_currency_codes(self.config.SUPPORTED_CURRENCIES)
|
||||
if supported and currency_code not in supported:
|
||||
return False, {
|
||||
"message": "unsupported_currency",
|
||||
"currency": currency_code,
|
||||
"supported_currencies": list(supported),
|
||||
}
|
||||
|
||||
session = await self._get_session()
|
||||
url = f"{self.base_url}/payin/create"
|
||||
currency_code = (currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
|
||||
|
||||
body = {
|
||||
"order_id": str(payment_db_id),
|
||||
@@ -428,13 +445,13 @@ async def pay_severpay_callback_handler(
|
||||
user_id=callback.from_user.id,
|
||||
parts=parts,
|
||||
subscription_service=severpay_service.subscription_service,
|
||||
currency="rub",
|
||||
currency=default_currency_key_for_settings(settings),
|
||||
)
|
||||
if not parts:
|
||||
await notify_callback_parse_error(callback, translator)
|
||||
return
|
||||
|
||||
currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
|
||||
currency_code = default_payment_currency_code_for_settings(settings)
|
||||
payment_description = describe_payment(translator, parts)
|
||||
record_payload = build_payment_record_payload(
|
||||
user_id=callback.from_user.id,
|
||||
@@ -503,7 +520,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
|
||||
if not service or not service.configured:
|
||||
return payment_unavailable()
|
||||
|
||||
currency = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
|
||||
currency = ctx.currency or settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
|
||||
try:
|
||||
payment = await create_webapp_payment_record(
|
||||
ctx,
|
||||
@@ -627,6 +644,18 @@ _CONFIG_MANIFEST = (
|
||||
max=4320,
|
||||
attr="LIFETIME_MINUTES",
|
||||
),
|
||||
ProviderManifestField(
|
||||
"SEVERPAY_SUPPORTED_CURRENCIES",
|
||||
"string",
|
||||
"Supported currencies",
|
||||
description=(
|
||||
"Comma-separated currencies enabled for your SeverPay merchant. "
|
||||
"The public PayIn docs show USD examples but do not publish a fixed global list."
|
||||
),
|
||||
placeholder="RUB,USD",
|
||||
subsection="SeverPay",
|
||||
attr="SUPPORTED_CURRENCIES",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -651,4 +680,9 @@ SPEC = PaymentProviderSpec(
|
||||
config_class=SeverPayConfig,
|
||||
presentation_class=SeverPayPresentation,
|
||||
manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST,
|
||||
supported_currencies_resolver=lambda config: getattr(config, "SUPPORTED_CURRENCIES", "RUB,USD"),
|
||||
currency_support_note=(
|
||||
"SeverPay PayIn requires a currency; keep this list aligned with your merchant account."
|
||||
),
|
||||
currency_support_url="https://docs.severpay.io/ru/payin/create",
|
||||
)
|
||||
|
||||
@@ -16,7 +16,13 @@ from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
|
||||
from db.dal import payment_dal, user_dal
|
||||
from db.models import Payment, User
|
||||
|
||||
from .common import Translator, format_human_units, make_translator, sale_mode_base
|
||||
from .common import (
|
||||
Translator,
|
||||
format_human_units,
|
||||
make_translator,
|
||||
sale_mode_base,
|
||||
sale_mode_tariff_key,
|
||||
)
|
||||
|
||||
_TRAFFIC_MODES = {"traffic", "traffic_package", "topup", "premium_topup"}
|
||||
_HWID_DEVICE_MODES = {"hwid_device", "hwid_devices", "hwid_devices_renewal"}
|
||||
@@ -304,6 +310,7 @@ async def finalize_successful_payment(
|
||||
activation_months or 1,
|
||||
current_payment_db_id=req.payment.payment_id,
|
||||
skip_if_active_before_payment=False,
|
||||
tariff_key=sale_mode_tariff_key(req.sale_mode),
|
||||
)
|
||||
await req.session.commit()
|
||||
except Exception:
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Any, Optional
|
||||
from aiogram import Bot
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot.services.user_email_notifications import send_user_notification_email
|
||||
from db.dal import payment_dal, user_dal
|
||||
from db.models import Payment
|
||||
|
||||
@@ -55,11 +56,21 @@ async def notify_user_payment_failed(
|
||||
db_user.language_code if db_user and db_user.language_code else settings.DEFAULT_LANGUAGE
|
||||
)
|
||||
translator = make_translator(i18n, language)
|
||||
message_text = translator(message_key)
|
||||
try:
|
||||
await bot.send_message(payment.user_id, translator(message_key))
|
||||
await bot.send_message(payment.user_id, message_text)
|
||||
except Exception:
|
||||
logging.exception(
|
||||
"Webhook helper: failed to notify user %s about %s.",
|
||||
payment.user_id,
|
||||
message_key,
|
||||
)
|
||||
if db_user:
|
||||
await send_user_notification_email(
|
||||
settings=settings,
|
||||
i18n=i18n,
|
||||
user=db_user,
|
||||
subject_key="email_payment_failed_subject",
|
||||
message_text=message_text,
|
||||
dashboard_url=(getattr(settings, "SUBSCRIPTION_MINI_APP_URL", "") or None),
|
||||
)
|
||||
|
||||
@@ -484,4 +484,6 @@ SPEC = PaymentProviderSpec(
|
||||
telegram_emoji="⭐",
|
||||
presentation_class=StarsPresentation,
|
||||
manifest_fields=_PRESENTATION_MANIFEST,
|
||||
supported_currencies=("XTR",),
|
||||
currency_support_note="Telegram Stars use Telegram's XTR currency and separate Stars prices.",
|
||||
)
|
||||
|
||||
@@ -19,6 +19,10 @@ from bot.services.referral_service import ReferralService
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.utils.request_security import ip_in_allowlist, request_client_ip
|
||||
from config.settings import Settings
|
||||
from config.tariffs_config import (
|
||||
default_currency_key_for_settings,
|
||||
default_payment_currency_code_for_settings,
|
||||
)
|
||||
from db.dal import payment_dal
|
||||
|
||||
from .base import (
|
||||
@@ -27,6 +31,7 @@ from .base import (
|
||||
ProviderManifestField,
|
||||
ServiceFactoryContext,
|
||||
WebAppPaymentContext,
|
||||
normalize_payment_currency_code,
|
||||
provider_env_file,
|
||||
provider_runtime_enabled,
|
||||
)
|
||||
@@ -63,6 +68,7 @@ from .shared import (
|
||||
|
||||
router = Router(name="user_subscription_payments_wata_router")
|
||||
_LOG = "wata"
|
||||
WATA_SUPPORTED_CURRENCIES = ("RUB", "USD", "EUR")
|
||||
_WATA_IN_PROGRESS_STATUSES = {"created", "pending"}
|
||||
_WATA_LINK_OPENED_STATUSES = {"opened", "open"}
|
||||
_WATA_LINK_DEFAULT_TTL_MINUTES = 15
|
||||
@@ -258,13 +264,23 @@ class WataService(HttpClientMixin):
|
||||
logging.error("WataService is not configured. Cannot create payment link.")
|
||||
return False, {"message": "service_not_configured"}
|
||||
|
||||
currency_code = normalize_payment_currency_code(
|
||||
currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
|
||||
)
|
||||
if currency_code not in WATA_SUPPORTED_CURRENCIES:
|
||||
return False, {
|
||||
"message": "unsupported_currency",
|
||||
"currency": currency_code,
|
||||
"supported_currencies": list(WATA_SUPPORTED_CURRENCIES),
|
||||
}
|
||||
|
||||
session = await self._get_session()
|
||||
expires_at = (
|
||||
datetime.now(timezone.utc) + timedelta(minutes=self.payment_link_ttl_minutes)
|
||||
).replace(microsecond=0)
|
||||
body: Dict[str, Any] = {
|
||||
"amount": float(format_decimal_amount(amount)),
|
||||
"currency": (currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper(),
|
||||
"currency": currency_code,
|
||||
"description": description,
|
||||
"orderId": str(payment_db_id),
|
||||
"successRedirectUrl": self.return_url,
|
||||
@@ -863,13 +879,13 @@ async def pay_wata_callback_handler(
|
||||
user_id=callback.from_user.id,
|
||||
parts=parts,
|
||||
subscription_service=wata_service.subscription_service,
|
||||
currency="rub",
|
||||
currency=default_currency_key_for_settings(settings),
|
||||
)
|
||||
if not parts:
|
||||
await notify_callback_parse_error(callback, translator)
|
||||
return
|
||||
|
||||
currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
|
||||
currency_code = default_payment_currency_code_for_settings(settings)
|
||||
payment_description = describe_payment(translator, parts)
|
||||
|
||||
reuse_amounts = payment_record_amounts(months=parts.months, sale_mode=parts.sale_mode)
|
||||
@@ -956,7 +972,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
|
||||
if not service or not service.configured:
|
||||
return payment_unavailable()
|
||||
|
||||
currency = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
|
||||
currency = ctx.currency or settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
|
||||
|
||||
reuse_amounts = payment_record_amounts(
|
||||
months=ctx.months,
|
||||
@@ -1190,4 +1206,9 @@ SPEC = PaymentProviderSpec(
|
||||
config_class=WataConfig,
|
||||
presentation_class=WataPresentation,
|
||||
manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST,
|
||||
supported_currencies=WATA_SUPPORTED_CURRENCIES,
|
||||
currency_support_note=(
|
||||
"WATA H2H payment links and widget document RUB, USD and EUR as payment currencies."
|
||||
),
|
||||
currency_support_url="https://wata.pro/api",
|
||||
)
|
||||
|
||||
@@ -35,10 +35,15 @@ from bot.services.lknpd_service import LknpdService
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
from bot.services.referral_service import ReferralService
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.services.user_email_notifications import send_user_notification_email
|
||||
from bot.utils.config_link import prepare_config_links
|
||||
from bot.utils.install_links import ensure_user_install_guide_links
|
||||
from bot.utils.request_security import ip_in_allowlist, request_client_ip
|
||||
from config.settings import Settings
|
||||
from config.tariffs_config import (
|
||||
default_currency_key_for_settings,
|
||||
default_payment_currency_code_for_settings,
|
||||
)
|
||||
from db.dal import payment_dal, user_billing_dal, user_dal
|
||||
from db.models import Payment
|
||||
|
||||
@@ -48,6 +53,7 @@ from .base import (
|
||||
ProviderManifestField,
|
||||
ServiceFactoryContext,
|
||||
WebAppPaymentContext,
|
||||
normalize_payment_currency_code,
|
||||
provider_env_file,
|
||||
provider_runtime_enabled,
|
||||
)
|
||||
@@ -75,6 +81,9 @@ from .shared import (
|
||||
from .shared import (
|
||||
sale_mode_base as _sale_mode_base,
|
||||
)
|
||||
from .shared import (
|
||||
sale_mode_tariff_key as _sale_mode_tariff_key,
|
||||
)
|
||||
|
||||
|
||||
class YooKassaConfig(ProviderEnvConfig):
|
||||
@@ -230,6 +239,11 @@ class YooKassaService:
|
||||
"internal_message": "Service settings (Settings object) not initialized.",
|
||||
}
|
||||
|
||||
currency = normalize_payment_currency_code(currency)
|
||||
if currency != "RUB":
|
||||
logging.error("YooKassa currency %s is not supported by this integration", currency)
|
||||
return None
|
||||
|
||||
customer_contact_for_receipt = {}
|
||||
if receipt_email:
|
||||
customer_contact_for_receipt["email"] = receipt_email
|
||||
@@ -749,6 +763,7 @@ async def process_successful_payment(
|
||||
months_for_activation or int(subscription_months) or 1,
|
||||
current_payment_db_id=payment_db_id,
|
||||
skip_if_active_before_payment=False,
|
||||
tariff_key=_sale_mode_tariff_key(sale_mode),
|
||||
)
|
||||
applied_referee_bonus_days_from_referral: Optional[int] = None
|
||||
if referral_bonus_info and referral_bonus_info.get("referee_new_end_date"):
|
||||
@@ -880,7 +895,10 @@ async def process_successful_payment(
|
||||
i18n=i18n,
|
||||
user_id=user_id,
|
||||
amount=payment_value,
|
||||
currency=settings.DEFAULT_CURRENCY_SYMBOL,
|
||||
currency=amount_data.get(
|
||||
"currency",
|
||||
default_payment_currency_code_for_settings(settings),
|
||||
),
|
||||
months_for_admin=int(subscription_months) if sale_mode_base == "subscription" else 0,
|
||||
traffic_gb_for_admin=(
|
||||
traffic_amount_gb if is_traffic_sale_base(sale_mode_base) else None
|
||||
@@ -949,7 +967,20 @@ async def process_cancelled_payment(
|
||||
user_lang = db_user.language_code
|
||||
|
||||
_ = lambda key, **kwargs: i18n.gettext(user_lang, key, **kwargs)
|
||||
await bot.send_message(user_id, _("payment_failed"))
|
||||
message_text = _("payment_failed")
|
||||
try:
|
||||
await bot.send_message(user_id, message_text)
|
||||
except Exception:
|
||||
logging.exception("Failed to notify YooKassa user %s about cancelled payment.", user_id)
|
||||
if db_user:
|
||||
await send_user_notification_email(
|
||||
settings=settings,
|
||||
i18n=i18n,
|
||||
user=db_user,
|
||||
subject_key="email_payment_failed_subject",
|
||||
message_text=message_text,
|
||||
dashboard_url=(settings.SUBSCRIPTION_MINI_APP_URL or None),
|
||||
)
|
||||
|
||||
except Exception as e_process_cancel:
|
||||
logging.error(
|
||||
@@ -1202,13 +1233,32 @@ async def yookassa_webhook_route(request: web.Request):
|
||||
get_back_to_payment_methods_keyboard,
|
||||
)
|
||||
|
||||
await bot.send_message(
|
||||
chat_id=user_id,
|
||||
text=_("payment_method_bound_success"),
|
||||
reply_markup=get_back_to_payment_methods_keyboard(
|
||||
i18n_lang, i18n_instance
|
||||
),
|
||||
)
|
||||
message_text = _("payment_method_bound_success")
|
||||
try:
|
||||
await bot.send_message(
|
||||
chat_id=user_id,
|
||||
text=message_text,
|
||||
reply_markup=get_back_to_payment_methods_keyboard(
|
||||
i18n_lang, i18n_instance
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
logging.exception(
|
||||
"Failed to notify user %s "
|
||||
"about payment method binding.",
|
||||
user_id,
|
||||
)
|
||||
if db_user:
|
||||
await send_user_notification_email(
|
||||
settings=settings,
|
||||
i18n=i18n_instance,
|
||||
user=db_user,
|
||||
subject_key="email_payment_method_bound_subject",
|
||||
message_text=message_text,
|
||||
dashboard_url=(
|
||||
settings.SUBSCRIPTION_MINI_APP_URL or None
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
# Attempt to cancel the authorization to avoid charge hold
|
||||
@@ -1665,7 +1715,7 @@ async def pay_yk_callback_handler(
|
||||
user_id=callback.from_user.id,
|
||||
parts=PaymentCallbackParts(months=months, price=price_rub, sale_mode=sale_mode),
|
||||
subscription_service=yookassa_service.subscription_service,
|
||||
currency="rub",
|
||||
currency=default_currency_key_for_settings(settings),
|
||||
)
|
||||
if not quoted_parts:
|
||||
try:
|
||||
@@ -1676,7 +1726,7 @@ async def pay_yk_callback_handler(
|
||||
months = quoted_parts.months
|
||||
price_rub = quoted_parts.price
|
||||
user_id = callback.from_user.id
|
||||
currency_code_for_yk = "RUB"
|
||||
currency_code_for_yk = default_payment_currency_code_for_settings(settings)
|
||||
autopay_enabled = bool(
|
||||
settings.yookassa_autopayments_active
|
||||
and _sale_mode_base(sale_mode) == "subscription"
|
||||
@@ -1814,7 +1864,7 @@ async def pay_yk_new_card_handler(
|
||||
|
||||
months, price_rub, sale_mode = parsed
|
||||
user_id = callback.from_user.id
|
||||
currency_code_for_yk = "RUB"
|
||||
currency_code_for_yk = default_payment_currency_code_for_settings(settings)
|
||||
autopay_enabled = bool(
|
||||
settings.yookassa_autopayments_active
|
||||
and _sale_mode_base(sale_mode) == "subscription"
|
||||
@@ -2117,7 +2167,7 @@ async def pay_yk_use_saved_handler(
|
||||
pass
|
||||
return
|
||||
|
||||
currency_code_for_yk = "RUB"
|
||||
currency_code_for_yk = default_payment_currency_code_for_settings(settings)
|
||||
|
||||
await _initiate_yk_payment(
|
||||
callback,
|
||||
@@ -2223,7 +2273,7 @@ async def payment_method_bind(
|
||||
metadata = {"user_id": str(callback.from_user.id), "bind_only": "1"}
|
||||
resp = await yookassa_service.create_payment(
|
||||
amount=1.00,
|
||||
currency="RUB",
|
||||
currency=default_payment_currency_code_for_settings(settings),
|
||||
description="Bind card",
|
||||
metadata=metadata,
|
||||
receipt_email=yookassa_service.config.DEFAULT_RECEIPT_EMAIL,
|
||||
@@ -2697,6 +2747,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
|
||||
service: YooKassaService = ctx.request.app["yookassa_service"]
|
||||
if not service or not service.configured:
|
||||
return payment_unavailable()
|
||||
currency = (ctx.currency or "RUB").upper()
|
||||
|
||||
try:
|
||||
amounts = payment_record_amounts(
|
||||
@@ -2707,7 +2758,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
|
||||
payment = await create_webapp_payment_record(
|
||||
ctx,
|
||||
amount=ctx.price,
|
||||
currency="RUB",
|
||||
currency=currency,
|
||||
status="pending_yookassa",
|
||||
provider="yookassa",
|
||||
)
|
||||
@@ -2730,7 +2781,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
|
||||
metadata["tariff_key"] = amounts.tariff_key
|
||||
response = await service.create_payment(
|
||||
amount=ctx.price,
|
||||
currency="RUB",
|
||||
currency=currency,
|
||||
description=ctx.description,
|
||||
metadata=metadata,
|
||||
receipt_email=service.config.DEFAULT_RECEIPT_EMAIL,
|
||||
@@ -2892,4 +2943,10 @@ SPEC = PaymentProviderSpec(
|
||||
config_class=YooKassaConfig,
|
||||
presentation_class=YooKassaPresentation,
|
||||
manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST,
|
||||
supported_currencies=("RUB",),
|
||||
currency_support_note=(
|
||||
"YooKassa public payment API examples and limits are RUB-based; "
|
||||
"treat non-RUB as unsupported unless your YooKassa contract confirms otherwise."
|
||||
),
|
||||
currency_support_url="https://yookassa.ru/developers/payment-acceptance/integration-scenarios/smart-payment",
|
||||
)
|
||||
|
||||
@@ -9,11 +9,16 @@ import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import zipfile
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any, Optional
|
||||
|
||||
from sqlalchemy import inspect, text
|
||||
from sqlalchemy.engine import Connection
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from bot.services.backup_archive import (
|
||||
BACKUP_APP_ID,
|
||||
BACKUP_FILENAME_PREFIX,
|
||||
@@ -29,6 +34,8 @@ from bot.services.backup_worker import (
|
||||
DEFAULT_COMPOSE_EXCLUDED_DIRS,
|
||||
)
|
||||
from config.settings import Settings
|
||||
from db.migrator import MIGRATIONS, run_database_migrations
|
||||
from db.models import Base
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -42,6 +49,23 @@ BACKUP_MAX_COMPRESSION_RATIO = 200
|
||||
BACKUP_ZIP_BOMB_MIN_BYTES = 100 * 1024 * 1024
|
||||
COMPOSE_PRE_RESTORE_PREFIX = "minishop-pre-restore-"
|
||||
SAFE_ARCHIVE_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.@+-]{0,220}\.zip$")
|
||||
DB_RESTORE_MIGRATION_ADVISORY_LOCK_ID = 817512404897421337
|
||||
|
||||
|
||||
def _applied_migration_ids(connection: Connection) -> set[str]:
|
||||
inspector = inspect(connection)
|
||||
if "schema_migrations" not in inspector.get_table_names():
|
||||
return set()
|
||||
return {row[0] for row in connection.execute(text("SELECT id FROM schema_migrations"))}
|
||||
|
||||
|
||||
def _create_missing_tables_and_migrate(connection: Connection) -> list[str]:
|
||||
before = _applied_migration_ids(connection)
|
||||
Base.metadata.create_all(connection)
|
||||
run_database_migrations(connection)
|
||||
after = _applied_migration_ids(connection)
|
||||
newly_applied = after - before
|
||||
return [migration.id for migration in MIGRATIONS if migration.id in newly_applied]
|
||||
|
||||
|
||||
class BackupArchiveError(ValueError):
|
||||
@@ -92,6 +116,7 @@ class BackupRestoreResult:
|
||||
compose_files_restored: int = 0
|
||||
compose_target_dir: Optional[str] = None
|
||||
compose_pre_restore_archive: Optional[str] = None
|
||||
database_migrations_applied: list[str] = field(default_factory=list)
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
|
||||
def to_payload(self) -> dict[str, Any]:
|
||||
@@ -103,6 +128,7 @@ class BackupRestoreResult:
|
||||
"compose_files_restored": self.compose_files_restored,
|
||||
"compose_target_dir": self.compose_target_dir,
|
||||
"compose_pre_restore_archive": self.compose_pre_restore_archive,
|
||||
"database_migrations_applied": self.database_migrations_applied,
|
||||
"warnings": self.warnings,
|
||||
}
|
||||
|
||||
@@ -242,9 +268,11 @@ class BackupRestoreService:
|
||||
compose_pre_restore_archive = self._snapshot_current_compose(compose_target_dir)
|
||||
|
||||
database_restored = False
|
||||
database_migrations_applied: list[str] = []
|
||||
if db_member is not None:
|
||||
dump_path = self._extract_database_dump(archive, db_member, temp_dir)
|
||||
self._run_pg_restore(dump_path)
|
||||
database_migrations_applied = self._run_post_restore_migrations()
|
||||
database_restored = True
|
||||
|
||||
compose_files_restored = 0
|
||||
@@ -265,9 +293,50 @@ class BackupRestoreService:
|
||||
compose_pre_restore_archive=str(compose_pre_restore_archive)
|
||||
if compose_pre_restore_archive
|
||||
else None,
|
||||
database_migrations_applied=database_migrations_applied,
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
def _run_post_restore_migrations(self) -> list[str]:
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
run_migrations = lambda: asyncio.run(self._run_post_restore_migrations_async())
|
||||
else:
|
||||
run_migrations = self._run_post_restore_migrations_in_thread
|
||||
|
||||
try:
|
||||
return run_migrations()
|
||||
except BackupRestoreError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise BackupRestoreError(
|
||||
f"Database restore completed, but post-restore migrations failed: {str(exc)[:500]}"
|
||||
) from exc
|
||||
|
||||
def _run_post_restore_migrations_in_thread(self) -> list[str]:
|
||||
with ThreadPoolExecutor(max_workers=1, thread_name_prefix="backup-restore-migrate") as pool:
|
||||
return pool.submit(
|
||||
lambda: asyncio.run(self._run_post_restore_migrations_async())
|
||||
).result()
|
||||
|
||||
async def _run_post_restore_migrations_async(self) -> list[str]:
|
||||
engine = create_async_engine(
|
||||
self.settings.DATABASE_URL,
|
||||
echo=False,
|
||||
pool_pre_ping=True,
|
||||
pool_size=1,
|
||||
max_overflow=0,
|
||||
)
|
||||
try:
|
||||
async with engine.begin() as connection:
|
||||
await connection.execute(
|
||||
text(f"SELECT pg_advisory_xact_lock({DB_RESTORE_MIGRATION_ADVISORY_LOCK_ID})")
|
||||
)
|
||||
return await connection.run_sync(_create_missing_tables_and_migrate)
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
def _run_pg_restore(self, dump_path: Path) -> None:
|
||||
pg_restore_path = str(getattr(self.settings, "BACKUP_PG_RESTORE_PATH", "pg_restore") or "")
|
||||
pg_restore_path = pg_restore_path or "pg_restore"
|
||||
|
||||
@@ -17,8 +17,9 @@ 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.message_audit import log_user_message_delivery
|
||||
from config.settings import Settings
|
||||
from db.dal import security_dal
|
||||
from db.dal import security_dal, user_dal
|
||||
from db.models import EmailVerificationCode
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -222,6 +223,28 @@ class EmailAuthService:
|
||||
magic_link=magic_link,
|
||||
purpose=purpose,
|
||||
)
|
||||
resolved_target_user_id = target_user_id
|
||||
if resolved_target_user_id is None:
|
||||
try:
|
||||
existing_user = await user_dal.get_user_by_email(session, normalized_email)
|
||||
resolved_target_user_id = (
|
||||
int(existing_user.user_id) if existing_user is not None else None
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to resolve email auth target user for audit log: %s",
|
||||
normalized_email,
|
||||
)
|
||||
await log_user_message_delivery(
|
||||
session,
|
||||
target_user_id=resolved_target_user_id,
|
||||
event_type="email_login_code_sent"
|
||||
if purpose == "login"
|
||||
else "email_verification_code_sent",
|
||||
channel="email",
|
||||
recipient=normalized_email,
|
||||
content=f"purpose={purpose} magic_link={bool(magic_link)}",
|
||||
)
|
||||
return EmailCodeRequestResult(ok=True)
|
||||
|
||||
async def verify_code(
|
||||
|
||||
@@ -12,11 +12,12 @@ from __future__ import annotations
|
||||
import html
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Sequence, Tuple
|
||||
from typing import TYPE_CHECKING, Optional, Sequence, Tuple
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from bot.middlewares.i18n import JsonI18n, get_i18n_instance, normalize_locale_language_code
|
||||
from config.settings import Settings
|
||||
if TYPE_CHECKING:
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from config.settings import Settings
|
||||
|
||||
_BG = "#05070a"
|
||||
_CARD_BG = "#0e1116"
|
||||
@@ -64,14 +65,16 @@ def _brand_title(settings: Settings) -> str:
|
||||
|
||||
|
||||
def _normalize_lang(language_code: Optional[str], settings: Settings) -> str:
|
||||
return normalize_locale_language_code(
|
||||
language_code or settings.DEFAULT_LANGUAGE or "ru",
|
||||
prefer_known_base=False,
|
||||
)
|
||||
value = str(language_code or settings.DEFAULT_LANGUAGE or "ru").strip().lower()
|
||||
return value.replace("_", "-") or "ru"
|
||||
|
||||
|
||||
def _resolve_i18n(i18n: Optional[JsonI18n]) -> JsonI18n:
|
||||
return i18n or get_i18n_instance()
|
||||
if i18n is not None:
|
||||
return i18n
|
||||
from bot.middlewares.i18n import get_i18n_instance
|
||||
|
||||
return get_i18n_instance()
|
||||
|
||||
|
||||
def _t_html(i18n: JsonI18n, lang: str, key: str, **kwargs) -> str:
|
||||
@@ -89,6 +92,7 @@ def _t_text(i18n: JsonI18n, lang: str, key: str, **kwargs) -> str:
|
||||
def _layout(
|
||||
*,
|
||||
settings: Settings,
|
||||
language_code: str,
|
||||
preheader: str,
|
||||
heading: str,
|
||||
intro_html: str,
|
||||
@@ -98,6 +102,7 @@ def _layout(
|
||||
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
|
||||
brand_title = html.escape(_brand_title(settings))
|
||||
logo_url = _public_logo_url(settings)
|
||||
html_lang = html.escape((language_code or "en").replace("_", "-"), quote=True)
|
||||
logo_block = ""
|
||||
if logo_url:
|
||||
logo_block = (
|
||||
@@ -107,7 +112,7 @@ def _layout(
|
||||
)
|
||||
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
|
||||
<html lang="{html_lang}" xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
@@ -202,6 +207,41 @@ def _format_traffic(traffic_gb: Optional[float]) -> str:
|
||||
return str(int(value)) if value.is_integer() else f"{value:g}"
|
||||
|
||||
|
||||
_ALLOWED_INLINE_TAGS = {
|
||||
"b": "strong",
|
||||
"strong": "strong",
|
||||
"i": "em",
|
||||
"em": "em",
|
||||
"u": "u",
|
||||
"s": "s",
|
||||
"code": "code",
|
||||
}
|
||||
_INLINE_TAG_RE = re.compile(r"</?(?:b|strong|i|em|u|s|code)>", re.IGNORECASE)
|
||||
_ANY_TAG_RE = re.compile(r"<[^>]+>")
|
||||
|
||||
|
||||
def _telegram_html_to_email_html(value: str) -> str:
|
||||
"""Escape arbitrary text while preserving the tiny Telegram HTML subset we use."""
|
||||
source = str(value or "")
|
||||
chunks: list[str] = []
|
||||
cursor = 0
|
||||
for match in _INLINE_TAG_RE.finditer(source):
|
||||
chunks.append(html.escape(source[cursor : match.start()]))
|
||||
raw_tag = match.group(0)
|
||||
closing = raw_tag.startswith("</")
|
||||
tag_name = raw_tag.strip("</>").lower()
|
||||
mapped = _ALLOWED_INLINE_TAGS.get(tag_name)
|
||||
if mapped:
|
||||
chunks.append(f"</{mapped}>" if closing else f"<{mapped}>")
|
||||
cursor = match.end()
|
||||
chunks.append(html.escape(source[cursor:]))
|
||||
return "".join(chunks).replace("\n", "<br>")
|
||||
|
||||
|
||||
def _telegram_html_to_text(value: str) -> str:
|
||||
return html.unescape(_ANY_TAG_RE.sub("", str(value or "")))
|
||||
|
||||
|
||||
def _format_minutes(seconds: int) -> int:
|
||||
return max(1, int(seconds) // 60)
|
||||
|
||||
@@ -272,6 +312,7 @@ def render_login_code(
|
||||
|
||||
rendered = _layout(
|
||||
settings=settings,
|
||||
language_code=lang,
|
||||
preheader=preheader,
|
||||
heading=heading,
|
||||
intro_html=html.escape(intro),
|
||||
@@ -324,6 +365,7 @@ def render_account_merged(
|
||||
|
||||
rendered = _layout(
|
||||
settings=settings,
|
||||
language_code=lang,
|
||||
preheader=preheader,
|
||||
heading=heading,
|
||||
intro_html=html.escape(intro),
|
||||
@@ -351,12 +393,15 @@ def render_payment_success(
|
||||
lang = _normalize_lang(language_code, settings)
|
||||
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
|
||||
brand = _brand_title(settings)
|
||||
is_traffic = (sale_mode or "").split("@", 1)[0].split("|", 1)[0] in {
|
||||
sale_base = (sale_mode or "").split("@", 1)[0].split("|", 1)[0]
|
||||
is_traffic = sale_base in {
|
||||
"traffic",
|
||||
"traffic_package",
|
||||
"topup",
|
||||
"premium_topup",
|
||||
}
|
||||
is_hwid = sale_base in {"hwid_device", "hwid_devices", "hwid_devices_renewal"}
|
||||
is_tariff_upgrade = sale_base == "tariff_upgrade"
|
||||
amount_text = _format_amount(amount, currency)
|
||||
safe_dashboard_url = (dashboard_url or "").strip()
|
||||
end_date = end_date_text or "—"
|
||||
@@ -370,7 +415,12 @@ def render_payment_success(
|
||||
cta_label = _t_text(i18n, lang, "email_payment_success_cta")
|
||||
|
||||
if is_traffic:
|
||||
intro = _t_text(i18n, lang, "email_payment_success_intro_traffic", traffic_gb=traffic_label)
|
||||
intro_key = (
|
||||
"email_payment_success_intro_premium_topup"
|
||||
if sale_base == "premium_topup"
|
||||
else "email_payment_success_intro_traffic"
|
||||
)
|
||||
intro = _t_text(i18n, lang, intro_key, traffic_gb=traffic_label)
|
||||
period_label = _t_text(i18n, lang, "email_payment_success_row_traffic")
|
||||
period_value = _t_text(
|
||||
i18n, lang, "email_payment_success_traffic_value", traffic_gb=traffic_label
|
||||
@@ -383,6 +433,30 @@ def render_payment_success(
|
||||
traffic_gb=traffic_label,
|
||||
end_date=end_date,
|
||||
)
|
||||
elif is_hwid:
|
||||
devices_count = max(0, int(months or 0))
|
||||
intro = _t_text(i18n, lang, "email_payment_success_intro_hwid", count=devices_count)
|
||||
period_label = _t_text(i18n, lang, "email_payment_success_row_hwid")
|
||||
period_value = _t_text(i18n, lang, "email_payment_success_hwid_value", count=devices_count)
|
||||
text = _t_text(
|
||||
i18n,
|
||||
lang,
|
||||
"email_payment_success_text_hwid",
|
||||
amount=amount_text,
|
||||
count=devices_count,
|
||||
end_date=end_date,
|
||||
)
|
||||
elif is_tariff_upgrade:
|
||||
intro = _t_text(i18n, lang, "email_payment_success_intro_tariff_upgrade")
|
||||
period_label = _t_text(i18n, lang, "email_payment_success_row_operation")
|
||||
period_value = _t_text(i18n, lang, "email_payment_success_tariff_upgrade_value")
|
||||
text = _t_text(
|
||||
i18n,
|
||||
lang,
|
||||
"email_payment_success_text_tariff_upgrade",
|
||||
amount=amount_text,
|
||||
end_date=end_date,
|
||||
)
|
||||
else:
|
||||
months_int = int(months or 0)
|
||||
intro = _t_text(i18n, lang, "email_payment_success_intro_subscription", months=months_int)
|
||||
@@ -425,6 +499,7 @@ def render_payment_success(
|
||||
|
||||
rendered = _layout(
|
||||
settings=settings,
|
||||
language_code=lang,
|
||||
preheader=preheader,
|
||||
heading=heading,
|
||||
intro_html=html.escape(intro),
|
||||
@@ -434,6 +509,67 @@ def render_payment_success(
|
||||
return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered)
|
||||
|
||||
|
||||
def render_user_notification(
|
||||
settings: Settings,
|
||||
*,
|
||||
language_code: Optional[str],
|
||||
subject: str,
|
||||
message_text: str,
|
||||
dashboard_url: Optional[str] = None,
|
||||
cta_label: Optional[str] = None,
|
||||
heading: Optional[str] = None,
|
||||
intro: Optional[str] = None,
|
||||
i18n: Optional[JsonI18n] = None,
|
||||
) -> EmailContent:
|
||||
i18n = _resolve_i18n(i18n)
|
||||
lang = _normalize_lang(language_code, settings)
|
||||
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
|
||||
brand = _brand_title(settings)
|
||||
safe_dashboard_url = (dashboard_url or "").strip()
|
||||
final_subject = (subject or "").strip() or _t_text(
|
||||
i18n, lang, "email_user_notification_subject"
|
||||
)
|
||||
final_heading = (heading or "").strip() or final_subject
|
||||
final_intro = (intro or "").strip() or _t_text(i18n, lang, "email_user_notification_intro")
|
||||
final_cta_label = (cta_label or "").strip() or _t_text(
|
||||
i18n,
|
||||
lang,
|
||||
"email_user_notification_cta",
|
||||
)
|
||||
footer = _t_html(i18n, lang, "email_footer_auto", brand=brand)
|
||||
message_html = (
|
||||
f'<div style="margin:0 0 16px 0;background:{_BG};border:1px solid {_BORDER};'
|
||||
f"border-radius:14px;padding:14px 16px;font-size:14px;line-height:1.55;color:{_TEXT};"
|
||||
f'white-space:pre-wrap;">{_telegram_html_to_email_html(message_text)}</div>'
|
||||
)
|
||||
body_parts = [message_html]
|
||||
if safe_dashboard_url:
|
||||
body_parts.append(
|
||||
_cta_button_html(label=final_cta_label, url=safe_dashboard_url, accent=accent)
|
||||
)
|
||||
|
||||
rendered = _layout(
|
||||
settings=settings,
|
||||
language_code=lang,
|
||||
preheader=final_subject,
|
||||
heading=final_heading,
|
||||
intro_html=html.escape(final_intro),
|
||||
body_html="".join(body_parts),
|
||||
footer_html=footer,
|
||||
)
|
||||
text_lines = [final_subject, "", _telegram_html_to_text(message_text)]
|
||||
if safe_dashboard_url:
|
||||
text_lines.extend(
|
||||
[
|
||||
"",
|
||||
_t_text(
|
||||
i18n, lang, "email_user_notification_text_dashboard", url=safe_dashboard_url
|
||||
),
|
||||
]
|
||||
)
|
||||
return EmailContent(subject=final_subject, text="\n".join(text_lines), html=rendered)
|
||||
|
||||
|
||||
def render_subscription_expiring(
|
||||
settings: Settings,
|
||||
*,
|
||||
@@ -488,6 +624,7 @@ def render_subscription_expiring(
|
||||
|
||||
rendered = _layout(
|
||||
settings=settings,
|
||||
language_code=lang,
|
||||
preheader=preheader,
|
||||
heading=heading,
|
||||
intro_html=html.escape(intro),
|
||||
@@ -497,6 +634,108 @@ def render_subscription_expiring(
|
||||
return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered)
|
||||
|
||||
|
||||
def _subscription_lifecycle_title(
|
||||
i18n: JsonI18n,
|
||||
lang: str,
|
||||
notification_key: str,
|
||||
*,
|
||||
days_left: Optional[int],
|
||||
hours_before: Optional[int],
|
||||
) -> str:
|
||||
if notification_key == "before_2d_autorenew":
|
||||
return _t_text(i18n, lang, "email_subscription_lifecycle_subject_autorenew")
|
||||
if notification_key == "expired":
|
||||
return _t_text(i18n, lang, "email_subscription_lifecycle_subject_expired")
|
||||
if notification_key == "expired_24h_after":
|
||||
return _t_text(i18n, lang, "email_subscription_lifecycle_subject_expired_after")
|
||||
if hours_before is not None:
|
||||
return _t_text(
|
||||
i18n,
|
||||
lang,
|
||||
"email_subscription_lifecycle_subject_before_hours",
|
||||
hours=hours_before,
|
||||
)
|
||||
return _t_text(
|
||||
i18n,
|
||||
lang,
|
||||
"email_subscription_lifecycle_subject_before_days",
|
||||
days=max(0, int(days_left or 0)),
|
||||
)
|
||||
|
||||
|
||||
def render_subscription_lifecycle_notification(
|
||||
settings: Settings,
|
||||
*,
|
||||
language_code: Optional[str],
|
||||
notification_key: str,
|
||||
message_text: str,
|
||||
end_date_text: str,
|
||||
dashboard_url: Optional[str],
|
||||
mirrored_from_telegram: bool = False,
|
||||
days_left: Optional[int] = None,
|
||||
hours_before: Optional[int] = None,
|
||||
i18n: Optional[JsonI18n] = None,
|
||||
) -> EmailContent:
|
||||
i18n = _resolve_i18n(i18n)
|
||||
lang = _normalize_lang(language_code, settings)
|
||||
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
|
||||
brand = _brand_title(settings)
|
||||
safe_dashboard_url = (dashboard_url or "").strip()
|
||||
end_date = end_date_text or "—"
|
||||
subject = _subscription_lifecycle_title(
|
||||
i18n,
|
||||
lang,
|
||||
notification_key,
|
||||
days_left=days_left,
|
||||
hours_before=hours_before,
|
||||
)
|
||||
intro_key = (
|
||||
"email_subscription_lifecycle_intro_mirrored"
|
||||
if mirrored_from_telegram
|
||||
else "email_subscription_lifecycle_intro_direct"
|
||||
)
|
||||
intro = _t_text(i18n, lang, intro_key)
|
||||
footer = _t_html(i18n, lang, "email_footer_auto", brand=brand)
|
||||
cta_label = _t_text(i18n, lang, "email_subscription_lifecycle_cta")
|
||||
|
||||
rows = [
|
||||
(_t_text(i18n, lang, "email_subscription_lifecycle_row_end_date"), end_date),
|
||||
]
|
||||
message_html = (
|
||||
f'<div style="margin:0 0 16px 0;background:{_BG};border:1px solid {_BORDER};'
|
||||
f"border-radius:14px;padding:14px 16px;font-size:14px;line-height:1.55;color:{_TEXT};"
|
||||
f'white-space:pre-wrap;">{html.escape(message_text or "")}</div>'
|
||||
)
|
||||
body_parts = [_info_rows_html(rows), message_html]
|
||||
if safe_dashboard_url:
|
||||
body_parts.append(_cta_button_html(label=cta_label, url=safe_dashboard_url, accent=accent))
|
||||
|
||||
rendered = _layout(
|
||||
settings=settings,
|
||||
language_code=lang,
|
||||
preheader=subject,
|
||||
heading=subject,
|
||||
intro_html=html.escape(intro),
|
||||
body_html="".join(body_parts),
|
||||
footer_html=footer,
|
||||
)
|
||||
|
||||
text_lines = [subject, "", message_text]
|
||||
if safe_dashboard_url:
|
||||
text_lines.extend(
|
||||
[
|
||||
"",
|
||||
_t_text(
|
||||
i18n,
|
||||
lang,
|
||||
"email_subscription_lifecycle_text_renew",
|
||||
url=safe_dashboard_url,
|
||||
),
|
||||
]
|
||||
)
|
||||
return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered)
|
||||
|
||||
|
||||
def _support_email(
|
||||
settings: Settings,
|
||||
i18n: Optional[JsonI18n],
|
||||
@@ -510,28 +749,40 @@ def _support_email(
|
||||
ticket_url: Optional[str],
|
||||
cta_label: str,
|
||||
) -> EmailContent:
|
||||
i18n = _resolve_i18n(i18n)
|
||||
lang = _normalize_lang(language, settings)
|
||||
brand = _brand_title(settings)
|
||||
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
|
||||
safe_url = (ticket_url or "").strip()
|
||||
footer = _t_html(_resolve_i18n(i18n), lang, "email_footer_auto", brand=brand)
|
||||
footer = _t_html(i18n, lang, "email_footer_auto", brand=brand)
|
||||
localized_rows = [
|
||||
(_t_text(i18n, lang, label) if str(label).startswith("email_") else str(label), value)
|
||||
for label, value in rows
|
||||
]
|
||||
preview_block = (
|
||||
f'<div style="margin:0 0 16px 0;background:{_BG};border:1px solid {_BORDER};'
|
||||
f"border-radius:14px;padding:14px 16px;font-size:14px;line-height:1.55;color:{_TEXT};"
|
||||
f'white-space:pre-wrap;">{html.escape(body_preview or "")}</div>'
|
||||
)
|
||||
body_parts = [_info_rows_html(rows), preview_block]
|
||||
body_parts = [_info_rows_html(localized_rows), preview_block]
|
||||
if safe_url:
|
||||
body_parts.append(_cta_button_html(label=cta_label, url=safe_url, accent=accent))
|
||||
rendered = _layout(
|
||||
settings=settings,
|
||||
language_code=lang,
|
||||
preheader=intro,
|
||||
heading=heading,
|
||||
intro_html=html.escape(intro),
|
||||
body_html="".join(body_parts),
|
||||
footer_html=footer,
|
||||
)
|
||||
text_lines = [intro, "", *[f"{label}: {value}" for label, value in rows], "", body_preview]
|
||||
text_lines = [
|
||||
intro,
|
||||
"",
|
||||
*[f"{label}: {value}" for label, value in localized_rows],
|
||||
"",
|
||||
body_preview,
|
||||
]
|
||||
if safe_url:
|
||||
text_lines.extend(["", safe_url])
|
||||
return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered)
|
||||
@@ -549,23 +800,25 @@ def render_support_new_ticket_admin(
|
||||
snapshot_rows: Sequence[Tuple[str, str]],
|
||||
ticket_url: Optional[str],
|
||||
) -> EmailContent:
|
||||
i18n = _resolve_i18n(i18n)
|
||||
lang = _normalize_lang(language, settings)
|
||||
rows = [
|
||||
("Ticket", f"#{ticket_id}"),
|
||||
("User", user_display),
|
||||
("Subject", subject),
|
||||
("email_support_row_ticket", f"#{ticket_id}"),
|
||||
("email_support_row_user", user_display),
|
||||
("email_support_row_subject", subject),
|
||||
*snapshot_rows,
|
||||
]
|
||||
return _support_email(
|
||||
settings,
|
||||
i18n,
|
||||
language,
|
||||
subject=f"New support ticket #{ticket_id}",
|
||||
heading=f"New support ticket #{ticket_id}",
|
||||
intro="A user opened a new support ticket.",
|
||||
lang,
|
||||
subject=_t_text(i18n, lang, "email_support_new_ticket_admin_subject", ticket_id=ticket_id),
|
||||
heading=_t_text(i18n, lang, "email_support_new_ticket_admin_heading", ticket_id=ticket_id),
|
||||
intro=_t_text(i18n, lang, "email_support_new_ticket_admin_intro"),
|
||||
rows=rows,
|
||||
body_preview=body_preview,
|
||||
ticket_url=ticket_url,
|
||||
cta_label="Open ticket",
|
||||
cta_label=_t_text(i18n, lang, "email_support_cta_open_ticket"),
|
||||
)
|
||||
|
||||
|
||||
@@ -581,23 +834,25 @@ def render_support_user_reply_admin(
|
||||
snapshot_rows: Sequence[Tuple[str, str]],
|
||||
ticket_url: Optional[str],
|
||||
) -> EmailContent:
|
||||
i18n = _resolve_i18n(i18n)
|
||||
lang = _normalize_lang(language, settings)
|
||||
rows = [
|
||||
("Ticket", f"#{ticket_id}"),
|
||||
("User", user_display),
|
||||
("Subject", subject),
|
||||
("email_support_row_ticket", f"#{ticket_id}"),
|
||||
("email_support_row_user", user_display),
|
||||
("email_support_row_subject", subject),
|
||||
*snapshot_rows,
|
||||
]
|
||||
return _support_email(
|
||||
settings,
|
||||
i18n,
|
||||
language,
|
||||
subject=f"New user reply in ticket #{ticket_id}",
|
||||
heading=f"User replied in ticket #{ticket_id}",
|
||||
intro="A user sent a new support message.",
|
||||
lang,
|
||||
subject=_t_text(i18n, lang, "email_support_user_reply_admin_subject", ticket_id=ticket_id),
|
||||
heading=_t_text(i18n, lang, "email_support_user_reply_admin_heading", ticket_id=ticket_id),
|
||||
intro=_t_text(i18n, lang, "email_support_user_reply_admin_intro"),
|
||||
rows=rows,
|
||||
body_preview=body_preview,
|
||||
ticket_url=ticket_url,
|
||||
cta_label="Open ticket",
|
||||
cta_label=_t_text(i18n, lang, "email_support_cta_open_ticket"),
|
||||
)
|
||||
|
||||
|
||||
@@ -611,17 +866,22 @@ def render_support_admin_reply_user(
|
||||
body_preview: str,
|
||||
ticket_url: Optional[str],
|
||||
) -> EmailContent:
|
||||
i18n = _resolve_i18n(i18n)
|
||||
lang = _normalize_lang(language, settings)
|
||||
return _support_email(
|
||||
settings,
|
||||
i18n,
|
||||
language,
|
||||
subject=f"New reply for ticket #{ticket_id}",
|
||||
heading=f"New reply for ticket #{ticket_id}",
|
||||
intro="Support has replied to your ticket.",
|
||||
rows=[("Ticket", f"#{ticket_id}"), ("Subject", subject)],
|
||||
lang,
|
||||
subject=_t_text(i18n, lang, "email_support_admin_reply_user_subject", ticket_id=ticket_id),
|
||||
heading=_t_text(i18n, lang, "email_support_admin_reply_user_heading", ticket_id=ticket_id),
|
||||
intro=_t_text(i18n, lang, "email_support_admin_reply_user_intro"),
|
||||
rows=[
|
||||
("email_support_row_ticket", f"#{ticket_id}"),
|
||||
("email_support_row_subject", subject),
|
||||
],
|
||||
body_preview=body_preview,
|
||||
ticket_url=ticket_url,
|
||||
cta_label="Open in Mini App",
|
||||
cta_label=_t_text(i18n, lang, "email_support_cta_open_mini_app"),
|
||||
)
|
||||
|
||||
|
||||
@@ -635,15 +895,24 @@ def render_support_ticket_closed_user(
|
||||
body_preview: str = "",
|
||||
ticket_url: Optional[str],
|
||||
) -> EmailContent:
|
||||
i18n = _resolve_i18n(i18n)
|
||||
lang = _normalize_lang(language, settings)
|
||||
return _support_email(
|
||||
settings,
|
||||
i18n,
|
||||
language,
|
||||
subject=f"Ticket #{ticket_id} was closed",
|
||||
heading=f"Ticket #{ticket_id} was closed",
|
||||
intro="Your support ticket has been closed.",
|
||||
rows=[("Ticket", f"#{ticket_id}"), ("Subject", subject)],
|
||||
body_preview=body_preview or "The ticket is closed.",
|
||||
lang,
|
||||
subject=_t_text(
|
||||
i18n, lang, "email_support_ticket_closed_user_subject", ticket_id=ticket_id
|
||||
),
|
||||
heading=_t_text(
|
||||
i18n, lang, "email_support_ticket_closed_user_heading", ticket_id=ticket_id
|
||||
),
|
||||
intro=_t_text(i18n, lang, "email_support_ticket_closed_user_intro"),
|
||||
rows=[
|
||||
("email_support_row_ticket", f"#{ticket_id}"),
|
||||
("email_support_row_subject", subject),
|
||||
],
|
||||
body_preview=body_preview or _t_text(i18n, lang, "email_support_ticket_closed_user_body"),
|
||||
ticket_url=ticket_url,
|
||||
cta_label="Open in Mini App",
|
||||
cta_label=_t_text(i18n, lang, "email_support_cta_open_mini_app"),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from db.dal import message_log_dal
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _clean_piece(value: Optional[object]) -> str:
|
||||
return str(value or "").strip()
|
||||
|
||||
|
||||
async def log_user_message_delivery(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
target_user_id: Optional[int],
|
||||
event_type: str,
|
||||
channel: str,
|
||||
content: str,
|
||||
recipient: Optional[str] = None,
|
||||
timestamp: Optional[datetime] = None,
|
||||
) -> None:
|
||||
"""Add a best-effort user log entry for important outbound messages."""
|
||||
clean_event = _clean_piece(event_type)
|
||||
clean_channel = _clean_piece(channel)
|
||||
if not clean_event or not clean_channel:
|
||||
return
|
||||
|
||||
parts = [f"channel={clean_channel}"]
|
||||
clean_recipient = _clean_piece(recipient)
|
||||
if clean_recipient:
|
||||
parts.append(f"recipient={clean_recipient}")
|
||||
clean_content = _clean_piece(content)
|
||||
if clean_content:
|
||||
parts.append(clean_content)
|
||||
|
||||
try:
|
||||
await message_log_dal.create_message_log_no_commit(
|
||||
session,
|
||||
{
|
||||
"user_id": None,
|
||||
"event_type": clean_event,
|
||||
"content": " | ".join(parts)[:4000],
|
||||
"is_admin_event": False,
|
||||
"target_user_id": int(target_user_id) if target_user_id is not None else None,
|
||||
"timestamp": timestamp or datetime.now(timezone.utc),
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to add outbound message audit log for user %s event %s",
|
||||
target_user_id,
|
||||
clean_event,
|
||||
)
|
||||
@@ -273,10 +273,10 @@ class NotificationService:
|
||||
return []
|
||||
rows = []
|
||||
for key, label in (
|
||||
("tariff", "Tariff"),
|
||||
("end_date", "End date"),
|
||||
("remaining", "Remaining"),
|
||||
("panel_status", "Panel status"),
|
||||
("tariff", "email_support_row_tariff"),
|
||||
("end_date", "email_support_row_end_date"),
|
||||
("remaining", "email_support_row_remaining"),
|
||||
("panel_status", "email_support_row_panel_status"),
|
||||
):
|
||||
value = snapshot.get(key)
|
||||
if value:
|
||||
|
||||
@@ -328,12 +328,112 @@ class PanelApiService:
|
||||
async def _get_user_by_uuid_uncached(
|
||||
self, user_uuid: str, log_response: bool = False
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
lookup = await self.get_user_by_uuid_lookup(user_uuid, log_response=log_response)
|
||||
if lookup.get("ok") and isinstance(lookup.get("user"), dict):
|
||||
return lookup["user"]
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _panel_response_details(response_data: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
if not isinstance(response_data, dict):
|
||||
return {}
|
||||
details = response_data.get("details")
|
||||
return details if isinstance(details, dict) else {}
|
||||
|
||||
@classmethod
|
||||
def _panel_response_error_code(cls, response_data: Optional[Dict[str, Any]]) -> Optional[str]:
|
||||
if not isinstance(response_data, dict):
|
||||
return None
|
||||
details = cls._panel_response_details(response_data)
|
||||
error_code = (
|
||||
response_data.get("errorCode")
|
||||
or response_data.get("code")
|
||||
or details.get("errorCode")
|
||||
or details.get("code")
|
||||
)
|
||||
return str(error_code) if error_code else None
|
||||
|
||||
@classmethod
|
||||
def _panel_response_message(cls, response_data: Optional[Dict[str, Any]]) -> Optional[str]:
|
||||
if not isinstance(response_data, dict):
|
||||
return None
|
||||
details = cls._panel_response_details(response_data)
|
||||
message = (
|
||||
response_data.get("message")
|
||||
or details.get("message")
|
||||
or details.get("error")
|
||||
or details.get("raw_response_text")
|
||||
)
|
||||
if message is None:
|
||||
return None
|
||||
message = str(message).replace("\n", " ").strip()
|
||||
return message[:500] if message else None
|
||||
|
||||
@classmethod
|
||||
def _is_user_not_found_response(cls, response_data: Optional[Dict[str, Any]]) -> bool:
|
||||
if not isinstance(response_data, dict):
|
||||
return False
|
||||
status_code = response_data.get("status_code")
|
||||
error_code = cls._panel_response_error_code(response_data)
|
||||
if error_code in {"A040", "A062", "USER_NOT_FOUND", "NOT_FOUND"}:
|
||||
return True
|
||||
return status_code == 404
|
||||
|
||||
@classmethod
|
||||
def _describe_user_lookup_failure(
|
||||
cls,
|
||||
response_data: Optional[Dict[str, Any]],
|
||||
*,
|
||||
not_found: bool,
|
||||
) -> str:
|
||||
if not isinstance(response_data, dict):
|
||||
return "classification=panel_lookup_failed response=empty"
|
||||
|
||||
classification = "confirmed_not_found" if not_found else "panel_lookup_failed"
|
||||
parts = [f"classification={classification}"]
|
||||
status_code = response_data.get("status_code")
|
||||
if status_code is not None:
|
||||
parts.append(f"status_code={status_code}")
|
||||
error_code = cls._panel_response_error_code(response_data)
|
||||
if error_code:
|
||||
parts.append(f"error_code={error_code}")
|
||||
message = cls._panel_response_message(response_data)
|
||||
if message:
|
||||
parts.append(f"message={message}")
|
||||
return " ".join(parts)
|
||||
|
||||
async def get_user_by_uuid_lookup(
|
||||
self, user_uuid: str, log_response: bool = False
|
||||
) -> Dict[str, Any]:
|
||||
"""Fetch a panel user and preserve whether a miss was confirmed.
|
||||
|
||||
``get_user_by_uuid`` historically returned ``None`` both for a real
|
||||
404/not-found and for transient panel/API failures. Callers that may
|
||||
mutate local state need this richer result to avoid treating an outage
|
||||
as a deleted panel user.
|
||||
"""
|
||||
endpoint = f"/users/{user_uuid}"
|
||||
full_response = await self._request("GET", endpoint, log_full_response=log_response)
|
||||
if full_response and not full_response.get("error") and "response" in full_response:
|
||||
return full_response.get("response")
|
||||
return {
|
||||
"ok": True,
|
||||
"user": full_response.get("response"),
|
||||
"not_found": False,
|
||||
"failure_reason": None,
|
||||
"response": full_response,
|
||||
}
|
||||
|
||||
return None
|
||||
not_found = self._is_user_not_found_response(full_response)
|
||||
return {
|
||||
"ok": False,
|
||||
"user": None,
|
||||
"not_found": not_found,
|
||||
"failure_reason": self._describe_user_lookup_failure(
|
||||
full_response,
|
||||
not_found=not_found,
|
||||
),
|
||||
"response": full_response,
|
||||
}
|
||||
|
||||
async def get_user(
|
||||
self,
|
||||
|
||||
@@ -0,0 +1,622 @@
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from config.settings import Settings
|
||||
|
||||
from .panel_api_service import PanelApiService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_USER_ACTION_RE = re.compile(
|
||||
r"^/users/(?P<user_uuid>[^/]+)/actions/(?P<action>enable|disable|reset-traffic)$"
|
||||
)
|
||||
_INTERNAL_SQUAD_BULK_RE = re.compile(
|
||||
r"^/internal-squads/(?P<squad_uuid>[^/]+)/bulk-actions/"
|
||||
r"(?P<action>add-users|remove-users)$"
|
||||
)
|
||||
_LIVE_POST_ENDPOINTS = frozenset({"/system/tools/happ/encrypt"})
|
||||
_KNOWN_TRAFFIC_STRATEGIES = frozenset({"NO_RESET", "DAY", "WEEK", "MONTH"})
|
||||
|
||||
# Constant path templates for intercepted endpoints. The logged path is rebuilt
|
||||
# from these literals (never from the raw endpoint) so user/squad UUIDs and any
|
||||
# other id-like segment can never reach the log as clear text.
|
||||
_USER_ACTION_TEMPLATES = {
|
||||
"enable": "/users/<id>/actions/enable",
|
||||
"disable": "/users/<id>/actions/disable",
|
||||
"reset-traffic": "/users/<id>/actions/reset-traffic",
|
||||
}
|
||||
_SQUAD_BULK_TEMPLATES = {
|
||||
"add-users": "/internal-squads/<id>/bulk-actions/add-users",
|
||||
"remove-users": "/internal-squads/<id>/bulk-actions/remove-users",
|
||||
}
|
||||
# Exact intercepted endpoints that carry no id and are safe to log verbatim.
|
||||
# Mapped to themselves so the logged value comes from this literal table, not
|
||||
# from the (tainted) request endpoint.
|
||||
_SAFE_LITERAL_ENDPOINTS = {
|
||||
"/users": "/users",
|
||||
"/hwid/devices/delete": "/hwid/devices/delete",
|
||||
}
|
||||
|
||||
# Panel payloads can carry proxy credentials (e.g. trojanPassword, ssPassword,
|
||||
# vless/vmess uuids) and PII (email, telegramId). Redact such values before they
|
||||
# reach the dry-run log so secrets are never written in clear text.
|
||||
_SENSITIVE_KEY_RE = re.compile(
|
||||
r"pass|pwd|secret|token|key|credential|auth|cookie|session|"
|
||||
r"email|mail|phone|telegram|mnemonic",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
# Field names the dry-run validator understands. Keys are echoed into the log
|
||||
# only via this table (value == name), so the logged key is always a literal and
|
||||
# never the raw, source-derived dict key. Unknown keys collapse to "<field>".
|
||||
_FIELD_LABELS = {
|
||||
name: name
|
||||
for name in (
|
||||
"uuid",
|
||||
"username",
|
||||
"status",
|
||||
"expireAt",
|
||||
"trafficLimitBytes",
|
||||
"trafficLimitStrategy",
|
||||
"hwidDeviceLimit",
|
||||
"telegramId",
|
||||
"email",
|
||||
"description",
|
||||
"tag",
|
||||
"activeInternalSquads",
|
||||
"activeUserInbounds",
|
||||
"externalSquadUuid",
|
||||
"userUuid",
|
||||
"userUuids",
|
||||
"users",
|
||||
"hwid",
|
||||
)
|
||||
}
|
||||
_REDACTED = "***"
|
||||
_UNKNOWN_FIELD = "<field>"
|
||||
_UNKNOWN_ENDPOINT = "<other>"
|
||||
|
||||
|
||||
@dataclass
|
||||
class _DryRunValidation:
|
||||
errors: List[str] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return not self.errors
|
||||
|
||||
def add(self, message: str) -> None:
|
||||
self.errors.append(message)
|
||||
|
||||
|
||||
class PanelDryRunApiService(PanelApiService):
|
||||
"""Panel API client that reads live data but never mutates Remnawave users."""
|
||||
|
||||
def __init__(self, settings: Settings):
|
||||
super().__init__(settings)
|
||||
self._synthetic_users: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
async def _request(
|
||||
self, method: str, endpoint: str, log_full_response: bool = False, **kwargs
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
method_upper = method.upper()
|
||||
normalized_endpoint = self._normalize_endpoint(endpoint)
|
||||
if not self._should_intercept(method_upper, normalized_endpoint):
|
||||
return await super()._request(
|
||||
method_upper,
|
||||
endpoint,
|
||||
log_full_response=log_full_response,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
validation = await self._validate_dry_run_request(
|
||||
method_upper,
|
||||
normalized_endpoint,
|
||||
kwargs.get("json"),
|
||||
)
|
||||
if not validation.ok:
|
||||
self._log_dry_run(
|
||||
"BLOCKED",
|
||||
method_upper,
|
||||
normalized_endpoint,
|
||||
kwargs.get("json"),
|
||||
errors=validation.errors,
|
||||
)
|
||||
return {
|
||||
"error": True,
|
||||
"status_code": 400,
|
||||
"errorCode": "DRY_RUN_VALIDATION_FAILED",
|
||||
"message": "Panel dry-run validation failed.",
|
||||
"details": {"errors": validation.errors},
|
||||
}
|
||||
|
||||
response = await self._dry_run_response(
|
||||
method_upper,
|
||||
normalized_endpoint,
|
||||
kwargs.get("json"),
|
||||
)
|
||||
self._log_dry_run("OK", method_upper, normalized_endpoint, kwargs.get("json"))
|
||||
return {"response": response, "dryRun": True}
|
||||
|
||||
@staticmethod
|
||||
def _normalize_endpoint(endpoint: str) -> str:
|
||||
return f"/{str(endpoint or '').lstrip('/')}"
|
||||
|
||||
@staticmethod
|
||||
def _safe_endpoint(endpoint: str) -> str:
|
||||
"""Map the request path to a constant log label.
|
||||
|
||||
Every return value comes from a literal template/table, never from the
|
||||
(tainted) endpoint itself, so user/squad UUIDs and any other id-like
|
||||
segment can never reach the log as clear text. The raw path is only
|
||||
matched against, not echoed.
|
||||
"""
|
||||
raw = str(endpoint or "")
|
||||
if match := _USER_ACTION_RE.match(raw):
|
||||
return _USER_ACTION_TEMPLATES.get(match.group("action"), "/users/<id>/actions/<action>")
|
||||
if match := _INTERNAL_SQUAD_BULK_RE.match(raw):
|
||||
return _SQUAD_BULK_TEMPLATES.get(
|
||||
match.group("action"), "/internal-squads/<id>/bulk-actions/<action>"
|
||||
)
|
||||
literal = _SAFE_LITERAL_ENDPOINTS.get(raw)
|
||||
if literal is not None:
|
||||
return literal
|
||||
if raw.startswith("/users/"):
|
||||
return "/users/<id>"
|
||||
if raw.startswith("/internal-squads/"):
|
||||
return "/internal-squads/<id>"
|
||||
return _UNKNOWN_ENDPOINT
|
||||
|
||||
@staticmethod
|
||||
def _summarize_leaf(value: Any) -> Any:
|
||||
"""Reduce a scalar to a non-sensitive type token.
|
||||
|
||||
Leaf values can carry PII or proxy credentials, so the log never echoes
|
||||
them — only their JSON type. ``None`` is kept so absent fields stay
|
||||
distinguishable from present ones.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, bool):
|
||||
return "<bool>"
|
||||
if isinstance(value, int):
|
||||
return "<int>"
|
||||
if isinstance(value, float):
|
||||
return "<float>"
|
||||
if isinstance(value, str):
|
||||
return "<str>"
|
||||
return f"<{type(value).__name__}>"
|
||||
|
||||
@staticmethod
|
||||
def _safe_key(key: Any) -> str:
|
||||
"""Return a constant label for a payload key.
|
||||
|
||||
Known field names are echoed from the ``_FIELD_LABELS`` table (the value,
|
||||
not the source-derived key); anything else collapses to ``<field>``. This
|
||||
keeps the raw dict key out of the log entirely.
|
||||
"""
|
||||
if isinstance(key, str):
|
||||
return _FIELD_LABELS.get(key, _UNKNOWN_FIELD)
|
||||
return _UNKNOWN_FIELD
|
||||
|
||||
@classmethod
|
||||
def _redact(cls, value: Any, _depth: int = 0) -> Any:
|
||||
"""Recursively summarize values, keeping only the JSON shape.
|
||||
|
||||
Keys are replaced by constant labels, sensitive keys collapse to a
|
||||
placeholder, and every scalar leaf becomes a type token. The result shows
|
||||
which fields a mutation would touch without logging any source-derived
|
||||
string (key or value).
|
||||
"""
|
||||
if _depth > 6:
|
||||
return "..."
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
cls._safe_key(k): (
|
||||
_REDACTED
|
||||
if isinstance(k, str) and _SENSITIVE_KEY_RE.search(k)
|
||||
else cls._redact(v, _depth + 1)
|
||||
)
|
||||
for k, v in value.items()
|
||||
}
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [cls._redact(item, _depth + 1) for item in value]
|
||||
return cls._summarize_leaf(value)
|
||||
|
||||
@classmethod
|
||||
def _payload_preview(cls, payload: Any) -> str:
|
||||
redacted = cls._redact(payload)
|
||||
try:
|
||||
text = json.dumps(redacted, ensure_ascii=False, default=str, sort_keys=True)
|
||||
except Exception:
|
||||
text = str(redacted)
|
||||
if len(text) > 1200:
|
||||
return f"{text[:1200]}..."
|
||||
return text
|
||||
|
||||
def _log_dry_run(
|
||||
self,
|
||||
status: str,
|
||||
method: str,
|
||||
endpoint: str,
|
||||
payload: Any,
|
||||
*,
|
||||
errors: Optional[List[str]] = None,
|
||||
) -> None:
|
||||
logger.info(
|
||||
"[PANEL DRY-RUN %s] would %s %s payload=%s%s",
|
||||
status,
|
||||
method,
|
||||
self._safe_endpoint(endpoint),
|
||||
self._payload_preview(payload),
|
||||
f" errors={errors}" if errors else "",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _should_intercept(method: str, endpoint: str) -> bool:
|
||||
if method in PanelApiService._SAFE_METHODS:
|
||||
return False
|
||||
if method == "POST" and endpoint in _LIVE_POST_ENDPOINTS:
|
||||
return False
|
||||
return True
|
||||
|
||||
async def _validate_dry_run_request(
|
||||
self,
|
||||
method: str,
|
||||
endpoint: str,
|
||||
payload: Any,
|
||||
) -> _DryRunValidation:
|
||||
validation = _DryRunValidation()
|
||||
data = payload if isinstance(payload, dict) else {}
|
||||
if payload is not None and not isinstance(payload, dict):
|
||||
validation.add("JSON payload must be an object.")
|
||||
return validation
|
||||
|
||||
if method == "POST" and endpoint == "/users":
|
||||
await self._validate_create_user_payload(data, validation)
|
||||
return validation
|
||||
if method == "PATCH" and endpoint == "/users":
|
||||
await self._validate_update_user_payload(data, validation)
|
||||
return validation
|
||||
if method == "POST" and (match := _USER_ACTION_RE.match(endpoint)):
|
||||
user_uuid = match.group("user_uuid")
|
||||
self._validate_non_empty_string(user_uuid, "user uuid", validation)
|
||||
await self._validate_remote_user(user_uuid, validation)
|
||||
return validation
|
||||
if method == "DELETE" and endpoint.startswith("/users/"):
|
||||
user_uuid = endpoint.removeprefix("/users/").strip()
|
||||
self._validate_non_empty_string(user_uuid, "user uuid", validation)
|
||||
await self._validate_remote_user(user_uuid, validation)
|
||||
return validation
|
||||
if method == "POST" and endpoint == "/hwid/devices/delete":
|
||||
user_uuid = self._validate_non_empty_string(
|
||||
data.get("userUuid"),
|
||||
"userUuid",
|
||||
validation,
|
||||
)
|
||||
self._validate_non_empty_string(data.get("hwid"), "hwid", validation)
|
||||
await self._validate_remote_user(user_uuid, validation)
|
||||
return validation
|
||||
if match := _INTERNAL_SQUAD_BULK_RE.match(endpoint):
|
||||
squad_uuid = match.group("squad_uuid")
|
||||
self._validate_non_empty_string(squad_uuid, "squad uuid", validation)
|
||||
user_uuids = self._validate_string_list(data.get("userUuids"), "userUuids", validation)
|
||||
if not user_uuids:
|
||||
user_uuids = self._validate_string_list(data.get("users"), "users", validation)
|
||||
await self._validate_remote_squads([squad_uuid], validation)
|
||||
for user_uuid in user_uuids:
|
||||
await self._validate_remote_user(user_uuid, validation)
|
||||
return validation
|
||||
|
||||
if payload is None:
|
||||
return validation
|
||||
self._validate_json_serializable(payload, validation)
|
||||
return validation
|
||||
|
||||
async def _validate_create_user_payload(
|
||||
self,
|
||||
payload: Dict[str, Any],
|
||||
validation: _DryRunValidation,
|
||||
) -> None:
|
||||
username = self._validate_non_empty_string(payload.get("username"), "username", validation)
|
||||
if username and (
|
||||
not (3 <= len(username) <= 36) or not re.match(r"^[A-Za-z0-9_-]+$", username)
|
||||
):
|
||||
validation.add("username must be 3-36 chars and contain only A-Z, 0-9, _ or -.")
|
||||
self._validate_user_mutation_payload(payload, validation, require_uuid=False)
|
||||
await self._validate_remote_squads(
|
||||
self._validate_string_list(
|
||||
payload.get("activeInternalSquads"),
|
||||
"activeInternalSquads",
|
||||
validation,
|
||||
required=False,
|
||||
),
|
||||
validation,
|
||||
)
|
||||
if not bool(getattr(self.settings, "PANEL_DRY_RUN_SYNTHETIC_CREATE", True)):
|
||||
validation.add("PANEL_DRY_RUN_SYNTHETIC_CREATE is disabled.")
|
||||
if self._remote_validation_enabled and username:
|
||||
await self._validate_create_uniqueness(payload, validation)
|
||||
|
||||
async def _validate_update_user_payload(
|
||||
self,
|
||||
payload: Dict[str, Any],
|
||||
validation: _DryRunValidation,
|
||||
) -> None:
|
||||
user_uuid = self._validate_non_empty_string(payload.get("uuid"), "uuid", validation)
|
||||
self._validate_user_mutation_payload(payload, validation, require_uuid=True)
|
||||
await self._validate_remote_user(user_uuid, validation)
|
||||
await self._validate_remote_squads(
|
||||
self._validate_string_list(
|
||||
payload.get("activeInternalSquads"),
|
||||
"activeInternalSquads",
|
||||
validation,
|
||||
required=False,
|
||||
),
|
||||
validation,
|
||||
)
|
||||
|
||||
def _validate_user_mutation_payload(
|
||||
self,
|
||||
payload: Dict[str, Any],
|
||||
validation: _DryRunValidation,
|
||||
*,
|
||||
require_uuid: bool,
|
||||
) -> None:
|
||||
if require_uuid:
|
||||
self._validate_non_empty_string(payload.get("uuid"), "uuid", validation)
|
||||
if "expireAt" in payload:
|
||||
self._validate_datetime(payload.get("expireAt"), "expireAt", validation)
|
||||
if "trafficLimitBytes" in payload:
|
||||
self._validate_non_negative_int(
|
||||
payload.get("trafficLimitBytes"),
|
||||
"trafficLimitBytes",
|
||||
validation,
|
||||
)
|
||||
if "trafficLimitStrategy" in payload:
|
||||
strategy = self._validate_non_empty_string(
|
||||
payload.get("trafficLimitStrategy"),
|
||||
"trafficLimitStrategy",
|
||||
validation,
|
||||
)
|
||||
if strategy and strategy.upper() not in _KNOWN_TRAFFIC_STRATEGIES:
|
||||
validation.add(f"trafficLimitStrategy {strategy!r} is not supported.")
|
||||
if "hwidDeviceLimit" in payload:
|
||||
self._validate_non_negative_int(
|
||||
payload.get("hwidDeviceLimit"),
|
||||
"hwidDeviceLimit",
|
||||
validation,
|
||||
)
|
||||
if "telegramId" in payload:
|
||||
self._validate_positive_int(payload.get("telegramId"), "telegramId", validation)
|
||||
if "email" in payload and payload.get("email") is not None:
|
||||
self._validate_non_empty_string(payload.get("email"), "email", validation)
|
||||
if "externalSquadUuid" in payload and payload.get("externalSquadUuid") is not None:
|
||||
self._validate_non_empty_string(
|
||||
payload.get("externalSquadUuid"),
|
||||
"externalSquadUuid",
|
||||
validation,
|
||||
)
|
||||
self._validate_json_serializable(payload, validation)
|
||||
|
||||
@property
|
||||
def _remote_validation_enabled(self) -> bool:
|
||||
return bool(getattr(self.settings, "PANEL_DRY_RUN_VALIDATE_REMOTE", True))
|
||||
|
||||
async def _validate_remote_user(
|
||||
self,
|
||||
user_uuid: Optional[str],
|
||||
validation: _DryRunValidation,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
if not user_uuid or not self._remote_validation_enabled:
|
||||
return self._synthetic_users.get(str(user_uuid or ""))
|
||||
user = self._synthetic_users.get(str(user_uuid))
|
||||
if user:
|
||||
return user
|
||||
try:
|
||||
user = await super().get_user_by_uuid(str(user_uuid), log_response=False)
|
||||
except Exception as exc:
|
||||
validation.add(f"failed to validate panel user {user_uuid}: {type(exc).__name__}")
|
||||
return None
|
||||
if not user:
|
||||
validation.add(f"panel user {user_uuid} was not found.")
|
||||
return user
|
||||
|
||||
async def _validate_remote_squads(
|
||||
self,
|
||||
squad_uuids: List[str],
|
||||
validation: _DryRunValidation,
|
||||
) -> None:
|
||||
if not squad_uuids or not self._remote_validation_enabled:
|
||||
return
|
||||
try:
|
||||
squads = await super().get_internal_squads()
|
||||
except Exception as exc:
|
||||
validation.add(f"failed to validate panel squads: {type(exc).__name__}")
|
||||
return
|
||||
if squads is None:
|
||||
validation.add("failed to validate panel squads: empty panel response.")
|
||||
return
|
||||
known = {
|
||||
str(squad.get("uuid") or squad.get("id") or "").strip()
|
||||
for squad in squads
|
||||
if isinstance(squad, dict)
|
||||
}
|
||||
missing = sorted({squad_uuid for squad_uuid in squad_uuids if squad_uuid not in known})
|
||||
if missing:
|
||||
validation.add(f"panel squads were not found: {', '.join(missing)}.")
|
||||
|
||||
async def _validate_create_uniqueness(
|
||||
self,
|
||||
payload: Dict[str, Any],
|
||||
validation: _DryRunValidation,
|
||||
) -> None:
|
||||
checks = (
|
||||
("username", "username", payload.get("username")),
|
||||
("telegramId", "telegram_id", payload.get("telegramId")),
|
||||
("email", "email", payload.get("email")),
|
||||
)
|
||||
for label, argument_name, value in checks:
|
||||
if value in (None, ""):
|
||||
continue
|
||||
try:
|
||||
users = await super().get_users_by_filter(**{argument_name: value})
|
||||
except Exception as exc:
|
||||
validation.add(f"failed to validate unique {label}: {type(exc).__name__}")
|
||||
continue
|
||||
if users:
|
||||
validation.add(f"panel user with {label} {value!r} already exists.")
|
||||
|
||||
@staticmethod
|
||||
def _validate_non_empty_string(
|
||||
value: Any,
|
||||
name: str,
|
||||
validation: _DryRunValidation,
|
||||
) -> Optional[str]:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
validation.add(f"{name} must be a non-empty string.")
|
||||
return None
|
||||
return value.strip()
|
||||
|
||||
@staticmethod
|
||||
def _validate_string_list(
|
||||
value: Any,
|
||||
name: str,
|
||||
validation: _DryRunValidation,
|
||||
*,
|
||||
required: bool = True,
|
||||
) -> List[str]:
|
||||
if value is None:
|
||||
if required:
|
||||
validation.add(f"{name} must be a list of strings.")
|
||||
return []
|
||||
if not isinstance(value, list):
|
||||
validation.add(f"{name} must be a list of strings.")
|
||||
return []
|
||||
result = []
|
||||
for item in value:
|
||||
if not isinstance(item, str) or not item.strip():
|
||||
validation.add(f"{name} contains an empty or non-string value.")
|
||||
continue
|
||||
result.append(item.strip())
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _validate_non_negative_int(
|
||||
value: Any,
|
||||
name: str,
|
||||
validation: _DryRunValidation,
|
||||
) -> None:
|
||||
try:
|
||||
parsed = int(value)
|
||||
except (TypeError, ValueError):
|
||||
validation.add(f"{name} must be an integer.")
|
||||
return
|
||||
if parsed < 0:
|
||||
validation.add(f"{name} must be >= 0.")
|
||||
|
||||
@staticmethod
|
||||
def _validate_positive_int(value: Any, name: str, validation: _DryRunValidation) -> None:
|
||||
try:
|
||||
parsed = int(value)
|
||||
except (TypeError, ValueError):
|
||||
validation.add(f"{name} must be an integer.")
|
||||
return
|
||||
if parsed <= 0:
|
||||
validation.add(f"{name} must be > 0.")
|
||||
|
||||
@staticmethod
|
||||
def _validate_datetime(value: Any, name: str, validation: _DryRunValidation) -> None:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
validation.add(f"{name} must be an ISO datetime string.")
|
||||
return
|
||||
try:
|
||||
datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
validation.add(f"{name} must be a valid ISO datetime string.")
|
||||
|
||||
@staticmethod
|
||||
def _validate_json_serializable(value: Any, validation: _DryRunValidation) -> None:
|
||||
try:
|
||||
json.dumps(value, default=str)
|
||||
except (TypeError, ValueError):
|
||||
validation.add("payload must be JSON serializable.")
|
||||
|
||||
async def _dry_run_response(
|
||||
self,
|
||||
method: str,
|
||||
endpoint: str,
|
||||
payload: Any,
|
||||
) -> Dict[str, Any]:
|
||||
data = payload if isinstance(payload, dict) else {}
|
||||
if method == "POST" and endpoint == "/users":
|
||||
return self._dry_run_create_user_response(data)
|
||||
if method == "PATCH" and endpoint == "/users":
|
||||
return await self._dry_run_patch_user_response(data)
|
||||
if method == "POST" and (match := _USER_ACTION_RE.match(endpoint)):
|
||||
return self._dry_run_user_action_response(
|
||||
match.group("user_uuid"),
|
||||
match.group("action"),
|
||||
)
|
||||
if method == "DELETE" and endpoint.startswith("/users/"):
|
||||
return {"uuid": endpoint.removeprefix("/users/"), "deleted": True, "dryRun": True}
|
||||
if method == "POST" and endpoint == "/hwid/devices/delete":
|
||||
return {"userUuid": data.get("userUuid"), "hwid": data.get("hwid"), "dryRun": True}
|
||||
if match := _INTERNAL_SQUAD_BULK_RE.match(endpoint):
|
||||
return {
|
||||
"squadUuid": match.group("squad_uuid"),
|
||||
"action": match.group("action"),
|
||||
"users": data.get("userUuids") or data.get("users") or [],
|
||||
"dryRun": True,
|
||||
}
|
||||
return {"dryRun": True}
|
||||
|
||||
def _dry_run_create_user_response(self, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
identity = ":".join(
|
||||
str(payload.get(key) or "") for key in ("username", "telegramId", "email")
|
||||
)
|
||||
user_uuid = str(uuid.uuid5(uuid.NAMESPACE_URL, f"remnawave-minishop:dry-run:{identity}"))
|
||||
short_uuid = user_uuid.split("-")[0]
|
||||
response = {
|
||||
**payload,
|
||||
"uuid": user_uuid,
|
||||
"shortUuid": short_uuid,
|
||||
"subscriptionUuid": short_uuid,
|
||||
"subscriptionUrl": self._subscription_url(short_uuid),
|
||||
"dryRun": True,
|
||||
}
|
||||
self._synthetic_users[user_uuid] = response
|
||||
return response
|
||||
|
||||
async def _dry_run_patch_user_response(self, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
user_uuid = str(payload.get("uuid") or "")
|
||||
existing = self._synthetic_users.get(user_uuid)
|
||||
if not existing and self._remote_validation_enabled:
|
||||
try:
|
||||
existing = await super().get_user_by_uuid(user_uuid, log_response=False)
|
||||
except Exception:
|
||||
existing = None
|
||||
response = {**(existing or {"uuid": user_uuid}), **payload, "dryRun": True}
|
||||
if user_uuid in self._synthetic_users:
|
||||
self._synthetic_users[user_uuid] = response
|
||||
return response
|
||||
|
||||
@staticmethod
|
||||
def _dry_run_user_action_response(user_uuid: str, action: str) -> Dict[str, Any]:
|
||||
response: Dict[str, Any] = {"uuid": user_uuid, "action": action, "dryRun": True}
|
||||
if action == "enable":
|
||||
response["status"] = "ACTIVE"
|
||||
elif action == "disable":
|
||||
response["status"] = "DISABLED"
|
||||
elif action == "reset-traffic":
|
||||
response["userTraffic"] = {"usedTrafficBytes": 0}
|
||||
return response
|
||||
|
||||
def _subscription_url(self, short_uuid: str) -> Optional[str]:
|
||||
if not self.settings.PANEL_API_URL:
|
||||
return None
|
||||
return f"{self.settings.PANEL_API_URL.rstrip('/')}/sub/{short_uuid}"
|
||||
@@ -3,12 +3,15 @@ import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.types import InlineKeyboardMarkup
|
||||
from aiohttp import web
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload, sessionmaker
|
||||
|
||||
from bot.infra.webhook_queue import enqueue_webhook_event
|
||||
from bot.keyboards.inline.user_keyboards import (
|
||||
@@ -16,18 +19,40 @@ from bot.keyboards.inline.user_keyboards import (
|
||||
get_subscribe_only_markup,
|
||||
)
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.subscription_lifecycle_notifications import (
|
||||
SubscriptionLifecycleNotificationService,
|
||||
SubscriptionNotificationStage,
|
||||
)
|
||||
from config.settings import Settings
|
||||
from db.dal import tariff_dal, user_dal
|
||||
from db.dal import subscription_dal, tariff_dal, user_dal
|
||||
from db.models import Subscription, User
|
||||
|
||||
from .email_auth_service import EmailAuthService
|
||||
from .email_templates import render_subscription_expiring
|
||||
from .panel_api_service import PanelApiService
|
||||
|
||||
EVENT_MAP = {
|
||||
"user.expires_in_72_hours": (3, "subscription_72h_notification"),
|
||||
"user.expires_in_48_hours": (2, "subscription_48h_notification"),
|
||||
"user.expires_in_24_hours": (1, "subscription_24h_notification"),
|
||||
"user.expires_in_72_hours": SubscriptionNotificationStage(
|
||||
key="before_3d",
|
||||
message_key="subscription_72h_notification",
|
||||
days_left=3,
|
||||
),
|
||||
"user.expires_in_48_hours": SubscriptionNotificationStage(
|
||||
key="before_2d",
|
||||
message_key="subscription_48h_notification",
|
||||
days_left=2,
|
||||
),
|
||||
"user.expires_in_24_hours": SubscriptionNotificationStage(
|
||||
key="before_1d",
|
||||
message_key="subscription_24h_notification",
|
||||
days_left=1,
|
||||
),
|
||||
}
|
||||
ACTIONABLE_EVENTS = frozenset(
|
||||
{
|
||||
*EVENT_MAP.keys(),
|
||||
"user.expired",
|
||||
"user.expired_24_hours_ago",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class PanelWebhookService:
|
||||
@@ -48,6 +73,11 @@ class PanelWebhookService:
|
||||
self.i18n = i18n
|
||||
self.async_session_factory = async_session_factory
|
||||
self.panel_service = panel_service
|
||||
self.lifecycle_notifications = SubscriptionLifecycleNotificationService(
|
||||
settings,
|
||||
bot,
|
||||
i18n,
|
||||
)
|
||||
self._event_semaphore = asyncio.Semaphore(self._MAX_CONCURRENT_EVENTS)
|
||||
if not self.settings.PANEL_WEBHOOK_SECRET:
|
||||
logging.error(
|
||||
@@ -102,113 +132,201 @@ class PanelWebhookService:
|
||||
)
|
||||
|
||||
async def handle_event(self, event_name: str, user_payload: dict):
|
||||
telegram_id = user_payload.get("telegramId")
|
||||
if not telegram_id:
|
||||
logging.warning("Panel webhook without telegramId received")
|
||||
return
|
||||
user_id = int(telegram_id)
|
||||
|
||||
if not self.settings.SUBSCRIPTION_NOTIFICATIONS_ENABLED:
|
||||
return
|
||||
|
||||
if event_name not in ACTIONABLE_EVENTS:
|
||||
logging.info(
|
||||
"Panel webhook event %s ignored: event is not used for subscription "
|
||||
"notifications; %s",
|
||||
event_name,
|
||||
self._payload_log_context(user_payload),
|
||||
)
|
||||
return
|
||||
|
||||
async with self.async_session_factory() as session:
|
||||
db_user = await user_dal.get_user_by_telegram_id(session, user_id)
|
||||
if not db_user:
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
internal_user_id = db_user.user_id if db_user else user_id
|
||||
db_user = await self._user_for_payload(session, user_payload)
|
||||
sub = await self._subscription_for_payload(session, user_payload, db_user)
|
||||
telegram_id = self._payload_telegram_id(user_payload)
|
||||
internal_user_id = (
|
||||
int(db_user.user_id)
|
||||
if db_user
|
||||
else int(getattr(sub, "user_id", 0) or telegram_id or 0)
|
||||
)
|
||||
lang = (
|
||||
db_user.language_code
|
||||
if db_user and db_user.language_code
|
||||
else self.settings.DEFAULT_LANGUAGE
|
||||
)
|
||||
first_name = db_user.first_name or f"User {user_id}" if db_user else f"User {user_id}"
|
||||
user_email = (db_user.email or "").strip() if db_user else ""
|
||||
if not sub:
|
||||
if not telegram_id:
|
||||
local_user_id = getattr(db_user, "user_id", None) if db_user else None
|
||||
logging.warning(
|
||||
"Panel webhook event %s cannot be matched to a local subscription; "
|
||||
"notification skipped. %s local_user_id=%s. Possible causes: "
|
||||
"panel user was created outside the bot, subscription was deleted "
|
||||
"or not synced, panel identifiers changed, or skip_notifications "
|
||||
"is enabled for the local subscription.",
|
||||
event_name,
|
||||
self._payload_log_context(user_payload),
|
||||
local_user_id or "N/A",
|
||||
)
|
||||
return
|
||||
await self._send_legacy_without_dedupe(
|
||||
event_name,
|
||||
user_payload,
|
||||
int(telegram_id),
|
||||
lang,
|
||||
db_user,
|
||||
)
|
||||
return
|
||||
|
||||
markup = get_subscribe_only_markup(lang, self.i18n)
|
||||
markup = get_subscribe_only_markup(lang, self.i18n)
|
||||
end_date_text = self._payload_expire_date(user_payload)
|
||||
|
||||
if event_name in EVENT_MAP:
|
||||
days_left, msg_key = EVENT_MAP[event_name]
|
||||
hwid_renewal_note = await self._hwid_renewal_note(internal_user_id, lang)
|
||||
if days_left == 1:
|
||||
# Trigger auto-renew via SubscriptionService (wired in at factory)
|
||||
try:
|
||||
subscription_service = getattr(self, "subscription_service", None)
|
||||
if subscription_service:
|
||||
async with self.async_session_factory() as session:
|
||||
from db.dal import subscription_dal
|
||||
|
||||
sub = await subscription_dal.get_active_subscription_by_user_id(
|
||||
session, internal_user_id
|
||||
)
|
||||
if sub and sub.auto_renew_enabled and sub.provider == "yookassa":
|
||||
try:
|
||||
ok = await subscription_service.charge_subscription_renewal(
|
||||
session, sub
|
||||
if event_name in EVENT_MAP:
|
||||
stage = EVENT_MAP[event_name]
|
||||
days_left = int(stage.days_left or 0)
|
||||
hwid_renewal_note = await self._hwid_renewal_note(internal_user_id, lang)
|
||||
if days_left == 1:
|
||||
# Trigger auto-renew via SubscriptionService (wired in at factory)
|
||||
try:
|
||||
subscription_service = getattr(self, "subscription_service", None)
|
||||
if subscription_service:
|
||||
async with self.async_session_factory() as renewal_session:
|
||||
active_sub = (
|
||||
await subscription_dal.get_active_subscription_by_user_id(
|
||||
renewal_session,
|
||||
internal_user_id,
|
||||
)
|
||||
# If initiation succeeded, suppress the 24h reminder by returning early # noqa: E501
|
||||
if ok:
|
||||
await session.commit()
|
||||
return
|
||||
else:
|
||||
await session.rollback()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logging.exception("Auto-renew attempt (24h) failed")
|
||||
except Exception:
|
||||
logging.exception("Auto-renew trigger (24h) failed pre-check")
|
||||
if days_left <= self.settings.SUBSCRIPTION_NOTIFY_DAYS_BEFORE:
|
||||
# For 48h event, if auto-renew is enabled, show special notice with cancel button
|
||||
if days_left == 2:
|
||||
async with self.async_session_factory() as session:
|
||||
from db.dal import subscription_dal
|
||||
|
||||
sub = await subscription_dal.get_active_subscription_by_user_id(
|
||||
session, internal_user_id
|
||||
)
|
||||
if (
|
||||
active_sub
|
||||
and active_sub.auto_renew_enabled
|
||||
and active_sub.provider == "yookassa"
|
||||
):
|
||||
try:
|
||||
ok = await subscription_service.charge_subscription_renewal(
|
||||
renewal_session,
|
||||
active_sub,
|
||||
)
|
||||
# If initiation succeeded, suppress the 24h reminder by returning early # noqa: E501
|
||||
if ok:
|
||||
await renewal_session.commit()
|
||||
return
|
||||
await renewal_session.rollback()
|
||||
except Exception:
|
||||
await renewal_session.rollback()
|
||||
logging.exception("Auto-renew attempt (24h) failed")
|
||||
except Exception:
|
||||
logging.exception("Auto-renew trigger (24h) failed pre-check")
|
||||
if days_left <= self.settings.SUBSCRIPTION_NOTIFY_DAYS_BEFORE:
|
||||
# For 48h, auto-renew users get a cancel button instead.
|
||||
if days_left == 2:
|
||||
active_sub = await subscription_dal.get_active_subscription_by_user_id(
|
||||
session,
|
||||
internal_user_id,
|
||||
)
|
||||
logging.info(
|
||||
"48h webhook check: user_id=%s sub_found=%s auto_renew=%s provider=%s",
|
||||
user_id,
|
||||
bool(sub),
|
||||
getattr(sub, "auto_renew_enabled", None) if sub else None,
|
||||
getattr(sub, "provider", None) if sub else None,
|
||||
internal_user_id,
|
||||
bool(active_sub),
|
||||
getattr(active_sub, "auto_renew_enabled", None) if active_sub else None,
|
||||
getattr(active_sub, "provider", None) if active_sub else None,
|
||||
)
|
||||
if sub and sub.auto_renew_enabled and sub.provider == "yookassa":
|
||||
if (
|
||||
active_sub
|
||||
and active_sub.auto_renew_enabled
|
||||
and active_sub.provider == "yookassa"
|
||||
):
|
||||
cancel_kb = get_autorenew_cancel_keyboard(lang, self.i18n)
|
||||
await self._send_message(
|
||||
user_id,
|
||||
lang,
|
||||
"autorenew_48h_charge_tomorrow_notice",
|
||||
reply_markup=cancel_kb,
|
||||
user_name=first_name,
|
||||
await self.lifecycle_notifications.send_stage(
|
||||
session,
|
||||
sub,
|
||||
SubscriptionNotificationStage(
|
||||
key="before_2d_autorenew",
|
||||
message_key="autorenew_48h_charge_tomorrow_notice",
|
||||
days_left=2,
|
||||
),
|
||||
user=db_user,
|
||||
telegram_markup=cancel_kb,
|
||||
extra_text=hwid_renewal_note,
|
||||
end_date_text=end_date_text,
|
||||
)
|
||||
await session.commit()
|
||||
return
|
||||
await self._send_message(
|
||||
user_id,
|
||||
lang,
|
||||
msg_key,
|
||||
reply_markup=markup,
|
||||
user_name=first_name,
|
||||
end_date=user_payload.get("expireAt", "")[:10],
|
||||
extra_text=hwid_renewal_note,
|
||||
)
|
||||
if days_left == 3 and user_email:
|
||||
await self._send_subscription_expiring_email(
|
||||
recipient=user_email,
|
||||
lang=lang,
|
||||
days_left=days_left,
|
||||
end_date_text=user_payload.get("expireAt", "")[:10],
|
||||
await self.lifecycle_notifications.send_stage(
|
||||
session,
|
||||
sub,
|
||||
stage,
|
||||
user=db_user,
|
||||
telegram_markup=markup,
|
||||
extra_text=hwid_renewal_note,
|
||||
end_date_text=end_date_text,
|
||||
)
|
||||
elif event_name == "user.expired":
|
||||
if self.settings.SUBSCRIPTION_NOTIFY_ON_EXPIRE:
|
||||
await self._send_message(
|
||||
user_id,
|
||||
lang,
|
||||
"subscription_expired_notification",
|
||||
reply_markup=markup,
|
||||
user_name=first_name,
|
||||
end_date=user_payload.get("expireAt", "")[:10],
|
||||
await session.commit()
|
||||
elif event_name == "user.expired":
|
||||
if self.settings.SUBSCRIPTION_NOTIFY_ON_EXPIRE:
|
||||
await self.lifecycle_notifications.send_stage(
|
||||
session,
|
||||
sub,
|
||||
SubscriptionNotificationStage(
|
||||
key="expired",
|
||||
message_key="subscription_expired_notification",
|
||||
days_left=0,
|
||||
),
|
||||
user=db_user,
|
||||
telegram_markup=markup,
|
||||
end_date_text=end_date_text,
|
||||
)
|
||||
await session.commit()
|
||||
elif (
|
||||
event_name == "user.expired_24_hours_ago"
|
||||
and self.settings.SUBSCRIPTION_NOTIFY_AFTER_EXPIRE
|
||||
):
|
||||
await self.lifecycle_notifications.send_stage(
|
||||
session,
|
||||
sub,
|
||||
SubscriptionNotificationStage(
|
||||
key="expired_24h_after",
|
||||
message_key="subscription_expired_yesterday_notification",
|
||||
days_left=0,
|
||||
),
|
||||
user=db_user,
|
||||
telegram_markup=markup,
|
||||
end_date_text=end_date_text,
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
async def _send_legacy_without_dedupe(
|
||||
self,
|
||||
event_name: str,
|
||||
user_payload: dict,
|
||||
user_id: int,
|
||||
lang: str,
|
||||
db_user: Optional[User],
|
||||
) -> None:
|
||||
first_name = getattr(db_user, "first_name", None) or f"User {user_id}"
|
||||
markup = get_subscribe_only_markup(lang, self.i18n)
|
||||
if event_name in EVENT_MAP:
|
||||
stage = EVENT_MAP[event_name]
|
||||
await self._send_message(
|
||||
user_id,
|
||||
lang,
|
||||
stage.message_key,
|
||||
reply_markup=markup,
|
||||
user_name=first_name,
|
||||
end_date=self._payload_expire_date(user_payload),
|
||||
)
|
||||
elif event_name == "user.expired" and self.settings.SUBSCRIPTION_NOTIFY_ON_EXPIRE:
|
||||
await self._send_message(
|
||||
user_id,
|
||||
lang,
|
||||
"subscription_expired_notification",
|
||||
reply_markup=markup,
|
||||
user_name=first_name,
|
||||
end_date=self._payload_expire_date(user_payload),
|
||||
)
|
||||
elif (
|
||||
event_name == "user.expired_24_hours_ago"
|
||||
and self.settings.SUBSCRIPTION_NOTIFY_AFTER_EXPIRE
|
||||
@@ -219,33 +337,138 @@ class PanelWebhookService:
|
||||
"subscription_expired_yesterday_notification",
|
||||
reply_markup=markup,
|
||||
user_name=first_name,
|
||||
end_date=user_payload.get("expireAt", "")[:10],
|
||||
end_date=self._payload_expire_date(user_payload),
|
||||
)
|
||||
|
||||
async def _send_subscription_expiring_email(
|
||||
async def _user_for_payload(
|
||||
self,
|
||||
*,
|
||||
recipient: str,
|
||||
lang: str,
|
||||
days_left: int,
|
||||
end_date_text: str,
|
||||
) -> None:
|
||||
"""Best-effort branded reminder; silently no-ops without SMTP config."""
|
||||
if not self.settings.email_auth_configured:
|
||||
return
|
||||
try:
|
||||
content = render_subscription_expiring(
|
||||
self.settings,
|
||||
language_code=lang,
|
||||
days_left=days_left,
|
||||
end_date_text=end_date_text,
|
||||
dashboard_url=(self.settings.SUBSCRIPTION_MINI_APP_URL or "").strip() or None,
|
||||
i18n=self.i18n,
|
||||
session: AsyncSession,
|
||||
user_payload: dict,
|
||||
) -> Optional[User]:
|
||||
telegram_id = self._payload_telegram_id(user_payload)
|
||||
if telegram_id:
|
||||
user = await user_dal.get_user_by_telegram_id(session, telegram_id)
|
||||
if user:
|
||||
return user
|
||||
user = await user_dal.get_user_by_id(session, telegram_id)
|
||||
if user:
|
||||
return user
|
||||
|
||||
panel_uuid = self._payload_panel_uuid(user_payload)
|
||||
if panel_uuid:
|
||||
user = await user_dal.get_user_by_panel_uuid(session, panel_uuid)
|
||||
if user:
|
||||
return user
|
||||
|
||||
email = str(user_payload.get("email") or "").strip()
|
||||
if email:
|
||||
return await user_dal.get_user_by_email(session, email)
|
||||
return None
|
||||
|
||||
async def _subscription_for_payload(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
user_payload: dict,
|
||||
db_user: Optional[User],
|
||||
) -> Optional[Subscription]:
|
||||
conditions = []
|
||||
if db_user:
|
||||
conditions.append(Subscription.user_id == db_user.user_id)
|
||||
panel_uuid = self._payload_panel_uuid(user_payload)
|
||||
if panel_uuid:
|
||||
conditions.append(Subscription.panel_user_uuid == panel_uuid)
|
||||
if not conditions:
|
||||
return None
|
||||
base_stmt = (
|
||||
select(Subscription)
|
||||
.where(
|
||||
Subscription.skip_notifications == False,
|
||||
or_(*conditions),
|
||||
)
|
||||
email_service = EmailAuthService(self.settings, self.i18n)
|
||||
await email_service.send_rendered_email(email=recipient, content=content)
|
||||
except Exception:
|
||||
logging.exception("Failed to send subscription-expiring email to %s", recipient)
|
||||
.options(selectinload(Subscription.user))
|
||||
)
|
||||
|
||||
expire_at = self._payload_expire_datetime(user_payload)
|
||||
if expire_at is not None:
|
||||
window_stmt = (
|
||||
base_stmt.where(
|
||||
Subscription.end_date >= expire_at - timedelta(days=1),
|
||||
Subscription.end_date <= expire_at + timedelta(days=1),
|
||||
)
|
||||
.order_by(Subscription.end_date.desc())
|
||||
.limit(1)
|
||||
)
|
||||
result = await session.execute(window_stmt)
|
||||
found = result.scalars().first()
|
||||
if found:
|
||||
return found
|
||||
|
||||
stmt = base_stmt.order_by(Subscription.end_date.desc()).limit(1)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalars().first()
|
||||
|
||||
@staticmethod
|
||||
def _payload_telegram_id(user_payload: dict) -> Optional[int]:
|
||||
raw = user_payload.get("telegramId")
|
||||
try:
|
||||
value = int(raw or 0)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return value if value > 0 else None
|
||||
|
||||
@staticmethod
|
||||
def _payload_panel_uuid(user_payload: dict) -> str:
|
||||
return str(
|
||||
user_payload.get("uuid")
|
||||
or user_payload.get("userUuid")
|
||||
or user_payload.get("shortUuid")
|
||||
or ""
|
||||
).strip()
|
||||
|
||||
@staticmethod
|
||||
def _payload_expire_date(user_payload: dict) -> str:
|
||||
return str(user_payload.get("expireAt") or "")[:10]
|
||||
|
||||
@staticmethod
|
||||
def _payload_log_context(user_payload: dict) -> str:
|
||||
telegram_id = PanelWebhookService._payload_telegram_id(user_payload)
|
||||
panel_uuid = PanelWebhookService._payload_panel_uuid(user_payload)
|
||||
email = PanelWebhookService._mask_email(str(user_payload.get("email") or "").strip())
|
||||
expire_at = str(user_payload.get("expireAt") or "").strip()
|
||||
payload_keys = ",".join(sorted(str(key) for key in user_payload.keys())) or "none"
|
||||
return (
|
||||
f"telegramId={telegram_id or 'N/A'} "
|
||||
f"panel_uuid={panel_uuid or 'N/A'} "
|
||||
f"email={email or 'N/A'} "
|
||||
f"expireAt={expire_at or 'N/A'} "
|
||||
f"payload_keys={payload_keys}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _mask_email(email: str) -> str:
|
||||
if not email:
|
||||
return ""
|
||||
local_part, separator, domain = email.partition("@")
|
||||
if not separator or not domain:
|
||||
return "present"
|
||||
visible = local_part[:2] if len(local_part) > 2 else local_part[:1]
|
||||
return f"{visible}***@{domain}"
|
||||
|
||||
@staticmethod
|
||||
def _payload_expire_datetime(user_payload: dict) -> Optional[datetime]:
|
||||
raw = str(user_payload.get("expireAt") or "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
value = datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
try:
|
||||
value = datetime.fromisoformat(raw[:10])
|
||||
except ValueError:
|
||||
return None
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=timezone.utc)
|
||||
return value.astimezone(timezone.utc)
|
||||
|
||||
async def handle_webhook(
|
||||
self, raw_body: bytes, signature_header: Optional[str]
|
||||
|
||||
@@ -7,6 +7,7 @@ from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.user_email_notifications import send_user_notification_email
|
||||
from config.settings import Settings
|
||||
from db.dal import payment_dal, subscription_dal, user_dal
|
||||
|
||||
@@ -33,6 +34,7 @@ class ReferralService:
|
||||
purchased_subscription_months: int,
|
||||
current_payment_db_id: Optional[int] = None,
|
||||
skip_if_active_before_payment: bool = True,
|
||||
tariff_key: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
|
||||
referee_final_end_date: Optional[datetime] = None
|
||||
@@ -94,11 +96,9 @@ class ReferralService:
|
||||
else self.i18n.gettext(default_lang_for_placeholder, "friend_placeholder")
|
||||
)
|
||||
|
||||
inviter_bonus_days = self.settings.referral_bonus_inviter.get(
|
||||
purchased_subscription_months
|
||||
)
|
||||
referee_bonus_days = self.settings.referral_bonus_referee.get(
|
||||
purchased_subscription_months
|
||||
inviter_bonus_days, referee_bonus_days = self._referral_bonus_days_for_payment(
|
||||
purchased_subscription_months,
|
||||
tariff_key=tariff_key,
|
||||
)
|
||||
|
||||
if inviter_bonus_days and inviter_bonus_days > 0:
|
||||
@@ -142,18 +142,36 @@ class ReferralService:
|
||||
inviter_user_model.language_code or default_lang_for_placeholder
|
||||
)
|
||||
_i = lambda k, **kw: self.i18n.gettext(inviter_lang, k, **kw)
|
||||
await self.bot.send_message(
|
||||
inviter_user_id,
|
||||
_i(
|
||||
"referral_bonus_inviter_notification_extended",
|
||||
days=inviter_bonus_days,
|
||||
referee_name=referee_name_for_msg,
|
||||
new_end_date=new_end_date_inviter.strftime("%Y-%m-%d"),
|
||||
message_text = _i(
|
||||
"referral_bonus_inviter_notification_extended",
|
||||
days=inviter_bonus_days,
|
||||
referee_name=referee_name_for_msg,
|
||||
new_end_date=new_end_date_inviter.strftime("%Y-%m-%d"),
|
||||
)
|
||||
try:
|
||||
await self.bot.send_message(inviter_user_id, message_text)
|
||||
except Exception as e_notify_inviter:
|
||||
logging.error(
|
||||
f"Failed to send bonus notification to inviter {inviter_user_id}: {e_notify_inviter}" # noqa: E501
|
||||
)
|
||||
await send_user_notification_email(
|
||||
settings=self.settings,
|
||||
i18n=self.i18n,
|
||||
user=inviter_user_model,
|
||||
subject_key="email_referral_bonus_subject",
|
||||
message_text=message_text,
|
||||
dashboard_url=(
|
||||
getattr(
|
||||
self.settings,
|
||||
"SUBSCRIPTION_MINI_APP_URL",
|
||||
"",
|
||||
)
|
||||
or None
|
||||
),
|
||||
)
|
||||
except Exception as e_notify_inviter:
|
||||
logging.error(
|
||||
f"Failed to send bonus notification to inviter {inviter_user_id}: {e_notify_inviter}" # noqa: E501
|
||||
f"Failed to prepare bonus notification for inviter {inviter_user_id}: {e_notify_inviter}" # noqa: E501
|
||||
)
|
||||
else:
|
||||
logging.info(
|
||||
@@ -210,13 +228,34 @@ class ReferralService:
|
||||
_i = lambda k, **kw: self.i18n.gettext(
|
||||
inviter_lang, k, **kw
|
||||
)
|
||||
await self.bot.send_message(
|
||||
inviter_user_id,
|
||||
_i(
|
||||
"referral_bonus_inviter_notification_new_sub",
|
||||
days=inviter_bonus_days,
|
||||
referee_name=referee_name_for_msg,
|
||||
new_end_date=bonus_end_date.strftime("%Y-%m-%d"),
|
||||
message_text = _i(
|
||||
"referral_bonus_inviter_notification_new_sub",
|
||||
days=inviter_bonus_days,
|
||||
referee_name=referee_name_for_msg,
|
||||
new_end_date=bonus_end_date.strftime("%Y-%m-%d"),
|
||||
)
|
||||
try:
|
||||
await self.bot.send_message(
|
||||
inviter_user_id,
|
||||
message_text,
|
||||
)
|
||||
except Exception as e_notify_inviter:
|
||||
logging.error(
|
||||
f"Failed to send bonus notification to inviter {inviter_user_id}: {e_notify_inviter}" # noqa: E501
|
||||
)
|
||||
await send_user_notification_email(
|
||||
settings=self.settings,
|
||||
i18n=self.i18n,
|
||||
user=inviter_user_model,
|
||||
subject_key="email_referral_bonus_subject",
|
||||
message_text=message_text,
|
||||
dashboard_url=(
|
||||
getattr(
|
||||
self.settings,
|
||||
"SUBSCRIPTION_MINI_APP_URL",
|
||||
"",
|
||||
)
|
||||
or None
|
||||
),
|
||||
)
|
||||
else:
|
||||
@@ -263,6 +302,35 @@ class ReferralService:
|
||||
|
||||
raise
|
||||
|
||||
def _referral_bonus_days_for_payment(
|
||||
self,
|
||||
purchased_subscription_months: int,
|
||||
*,
|
||||
tariff_key: Optional[str] = None,
|
||||
) -> tuple[Optional[int], Optional[int]]:
|
||||
months = int(purchased_subscription_months)
|
||||
tariffs_config = getattr(self.settings, "tariffs_config", None)
|
||||
if tariff_key and tariffs_config:
|
||||
try:
|
||||
tariff = tariffs_config.require(str(tariff_key))
|
||||
except Exception:
|
||||
logging.warning(
|
||||
"Referral bonuses skipped: tariff %s was not found.",
|
||||
tariff_key,
|
||||
)
|
||||
return None, None
|
||||
if tariff.billing_model != "period":
|
||||
return None, None
|
||||
return (
|
||||
tariff.referral_inviter_bonus_days(months),
|
||||
tariff.referral_referee_bonus_days(months),
|
||||
)
|
||||
|
||||
return (
|
||||
self.settings.referral_bonus_inviter.get(months),
|
||||
self.settings.referral_bonus_referee.get(months),
|
||||
)
|
||||
|
||||
async def generate_referral_link(
|
||||
self, session: AsyncSession, bot_username: str, inviter_user_id: int
|
||||
) -> Optional[str]:
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||
from aiogram.types import InlineKeyboardMarkup
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot.keyboards.inline.user_keyboards import get_subscribe_only_markup
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.email_auth_service import EmailAuthService
|
||||
from bot.services.email_templates import render_subscription_lifecycle_notification
|
||||
from bot.services.message_audit import log_user_message_delivery
|
||||
from bot.services.telegram_notifications import (
|
||||
TELEGRAM_NOTIFICATIONS_BLOCKED,
|
||||
TELEGRAM_NOTIFICATIONS_ENABLED,
|
||||
TELEGRAM_NOTIFICATIONS_NEEDS_START,
|
||||
mark_telegram_notifications_status,
|
||||
normalize_telegram_notification_status,
|
||||
telegram_notification_status_from_error,
|
||||
)
|
||||
from config.settings import Settings
|
||||
from db.dal import subscription_dal
|
||||
from db.models import Subscription, User
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SubscriptionNotificationStage:
|
||||
key: str
|
||||
message_key: str
|
||||
days_left: Optional[int] = None
|
||||
hours_before: Optional[int] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SubscriptionNotificationDelivery:
|
||||
telegram_sent: bool = False
|
||||
email_sent: bool = False
|
||||
|
||||
@property
|
||||
def any_sent(self) -> bool:
|
||||
return self.telegram_sent or self.email_sent
|
||||
|
||||
|
||||
class SubscriptionLifecycleNotificationService:
|
||||
def __init__(
|
||||
self,
|
||||
settings: Settings,
|
||||
bot: Bot,
|
||||
i18n: JsonI18n,
|
||||
*,
|
||||
email_service: Optional[EmailAuthService] = None,
|
||||
) -> None:
|
||||
self.settings = settings
|
||||
self.bot = bot
|
||||
self.i18n = i18n
|
||||
self.email_service = email_service
|
||||
|
||||
async def send_stage(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
sub: Subscription,
|
||||
stage: SubscriptionNotificationStage,
|
||||
*,
|
||||
user: Optional[User] = None,
|
||||
telegram_markup: Optional[InlineKeyboardMarkup] = None,
|
||||
extra_text: str = "",
|
||||
end_date_text: Optional[str] = None,
|
||||
sent_at: Optional[datetime] = None,
|
||||
) -> SubscriptionNotificationDelivery:
|
||||
if sent_at is None:
|
||||
sent_at = datetime.now(timezone.utc)
|
||||
|
||||
resolved_user = user or getattr(sub, "user", None)
|
||||
lang = getattr(resolved_user, "language_code", None) or self.settings.DEFAULT_LANGUAGE
|
||||
user_id = int(getattr(sub, "user_id", 0) or 0)
|
||||
final_end_date_text = end_date_text
|
||||
if final_end_date_text is None:
|
||||
end_date = self._as_utc(getattr(sub, "end_date", None))
|
||||
final_end_date_text = end_date.strftime("%Y-%m-%d") if end_date else ""
|
||||
|
||||
recipient_email = self._email_recipient(resolved_user)
|
||||
telegram_user_name = self._telegram_display_name(resolved_user, user_id)
|
||||
email_user_name = self._email_display_name(
|
||||
resolved_user,
|
||||
recipient_email=recipient_email,
|
||||
fallback=telegram_user_name,
|
||||
)
|
||||
|
||||
kwargs = {"user_name": telegram_user_name, "end_date": final_end_date_text}
|
||||
if stage.hours_before is not None:
|
||||
kwargs["hours"] = stage.hours_before
|
||||
|
||||
message_text = self.i18n.gettext(lang, stage.message_key, **kwargs)
|
||||
email_kwargs = {**kwargs, "user_name": email_user_name}
|
||||
email_message_text = self.i18n.gettext(lang, stage.message_key, **email_kwargs)
|
||||
final_extra_text = str(extra_text or "").strip()
|
||||
if final_extra_text:
|
||||
message_text = f"{message_text}\n\n{final_extra_text}"
|
||||
email_message_text = f"{email_message_text}\n\n{final_extra_text}"
|
||||
|
||||
telegram_sent = await self._send_telegram(
|
||||
session,
|
||||
sub,
|
||||
stage,
|
||||
resolved_user,
|
||||
lang=lang,
|
||||
message_text=message_text,
|
||||
markup=telegram_markup or get_subscribe_only_markup(lang, self.i18n),
|
||||
sent_at=sent_at,
|
||||
)
|
||||
email_sent = await self._send_email(
|
||||
session,
|
||||
sub,
|
||||
stage,
|
||||
resolved_user,
|
||||
lang=lang,
|
||||
message_text=email_message_text,
|
||||
end_date_text=final_end_date_text,
|
||||
recipient=recipient_email,
|
||||
telegram_sent=telegram_sent,
|
||||
sent_at=sent_at,
|
||||
)
|
||||
return SubscriptionNotificationDelivery(
|
||||
telegram_sent=telegram_sent,
|
||||
email_sent=email_sent,
|
||||
)
|
||||
|
||||
async def _send_telegram(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
sub: Subscription,
|
||||
stage: SubscriptionNotificationStage,
|
||||
user: Optional[User],
|
||||
*,
|
||||
lang: str,
|
||||
message_text: str,
|
||||
markup: Optional[InlineKeyboardMarkup],
|
||||
sent_at: datetime,
|
||||
) -> bool:
|
||||
chat_id = self._telegram_chat_id(user, getattr(sub, "user_id", None))
|
||||
if chat_id is None:
|
||||
return False
|
||||
if user:
|
||||
status = normalize_telegram_notification_status(
|
||||
getattr(user, "telegram_notifications_status", None)
|
||||
)
|
||||
if status in {TELEGRAM_NOTIFICATIONS_NEEDS_START, TELEGRAM_NOTIFICATIONS_BLOCKED}:
|
||||
return False
|
||||
if await self._already_sent(session, sub.subscription_id, stage.key, "telegram"):
|
||||
return False
|
||||
try:
|
||||
await self.bot.send_message(chat_id, message_text, reply_markup=markup)
|
||||
except (TelegramBadRequest, TelegramForbiddenError) as exc:
|
||||
delivery_status = telegram_notification_status_from_error(exc)
|
||||
if user and delivery_status:
|
||||
await mark_telegram_notifications_status(
|
||||
session,
|
||||
int(user.user_id),
|
||||
delivery_status,
|
||||
)
|
||||
if delivery_status:
|
||||
logging.warning(
|
||||
"Skipping subscription notification %s for unreachable Telegram user %s: %s",
|
||||
stage.key,
|
||||
chat_id,
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
logging.exception(
|
||||
"Failed to send subscription notification %s to Telegram user %s",
|
||||
stage.key,
|
||||
chat_id,
|
||||
)
|
||||
return False
|
||||
except Exception:
|
||||
logging.exception(
|
||||
"Failed to send subscription notification %s to Telegram user %s",
|
||||
stage.key,
|
||||
chat_id,
|
||||
)
|
||||
return False
|
||||
await subscription_dal.record_subscription_notification(
|
||||
session,
|
||||
sub.subscription_id,
|
||||
self._channel_key(stage.key, "telegram"),
|
||||
sent_at=sent_at,
|
||||
)
|
||||
await log_user_message_delivery(
|
||||
session,
|
||||
target_user_id=getattr(sub, "user_id", None),
|
||||
event_type="telegram_subscription_notification_sent",
|
||||
channel="telegram",
|
||||
recipient=str(chat_id),
|
||||
content=(
|
||||
f"stage={stage.key} message_key={stage.message_key} "
|
||||
f"subscription_id={getattr(sub, 'subscription_id', '')}"
|
||||
),
|
||||
timestamp=sent_at,
|
||||
)
|
||||
if user:
|
||||
status = normalize_telegram_notification_status(
|
||||
getattr(user, "telegram_notifications_status", None)
|
||||
)
|
||||
if status != TELEGRAM_NOTIFICATIONS_ENABLED:
|
||||
await mark_telegram_notifications_status(
|
||||
session,
|
||||
int(user.user_id),
|
||||
TELEGRAM_NOTIFICATIONS_ENABLED,
|
||||
telegram_id=chat_id,
|
||||
checked_at=sent_at,
|
||||
)
|
||||
return True
|
||||
|
||||
async def _send_email(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
sub: Subscription,
|
||||
stage: SubscriptionNotificationStage,
|
||||
user: Optional[User],
|
||||
*,
|
||||
lang: str,
|
||||
message_text: str,
|
||||
end_date_text: str,
|
||||
recipient: str,
|
||||
telegram_sent: bool,
|
||||
sent_at: datetime,
|
||||
) -> bool:
|
||||
if not getattr(self.settings, "SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED", True):
|
||||
return False
|
||||
if not getattr(self.settings, "email_auth_configured", False):
|
||||
return False
|
||||
if not recipient:
|
||||
return False
|
||||
if await self._already_sent(session, sub.subscription_id, stage.key, "email"):
|
||||
return False
|
||||
|
||||
try:
|
||||
content = render_subscription_lifecycle_notification(
|
||||
self.settings,
|
||||
language_code=lang,
|
||||
notification_key=stage.key,
|
||||
message_text=message_text,
|
||||
end_date_text=end_date_text,
|
||||
dashboard_url=self._renewal_dashboard_url(recipient, sub),
|
||||
mirrored_from_telegram=telegram_sent,
|
||||
days_left=stage.days_left,
|
||||
hours_before=stage.hours_before,
|
||||
i18n=self.i18n,
|
||||
)
|
||||
email_service = self.email_service or EmailAuthService(self.settings, self.i18n)
|
||||
await email_service.send_rendered_email(email=recipient, content=content)
|
||||
except Exception:
|
||||
logging.exception(
|
||||
"Failed to send subscription notification %s to email %s",
|
||||
stage.key,
|
||||
recipient,
|
||||
)
|
||||
return False
|
||||
await subscription_dal.record_subscription_notification(
|
||||
session,
|
||||
sub.subscription_id,
|
||||
self._channel_key(stage.key, "email"),
|
||||
sent_at=sent_at,
|
||||
)
|
||||
await log_user_message_delivery(
|
||||
session,
|
||||
target_user_id=getattr(sub, "user_id", None),
|
||||
event_type="email_subscription_notification_sent",
|
||||
channel="email",
|
||||
recipient=recipient,
|
||||
content=(
|
||||
f"stage={stage.key} message_key={stage.message_key} "
|
||||
f"subscription_id={getattr(sub, 'subscription_id', '')}"
|
||||
),
|
||||
timestamp=sent_at,
|
||||
)
|
||||
return True
|
||||
|
||||
async def _already_sent(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
subscription_id: int,
|
||||
stage_key: str,
|
||||
channel: str,
|
||||
) -> bool:
|
||||
channel_key = self._channel_key(stage_key, channel)
|
||||
if await subscription_dal.has_subscription_notification(
|
||||
session,
|
||||
subscription_id,
|
||||
channel_key,
|
||||
):
|
||||
return True
|
||||
|
||||
# Legacy rows were stored without a channel. Treat them as Telegram-only
|
||||
# history so existing installs do not re-send old bot messages, while
|
||||
# still allowing the newly introduced email channel to catch up.
|
||||
return channel == "telegram" and await subscription_dal.has_subscription_notification(
|
||||
session,
|
||||
subscription_id,
|
||||
stage_key,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _channel_key(stage_key: str, channel: str) -> str:
|
||||
return f"{stage_key}:{channel}"
|
||||
|
||||
@staticmethod
|
||||
def _email_recipient(user: Optional[User]) -> str:
|
||||
return str(getattr(user, "email", "") or "").strip().lower() if user else ""
|
||||
|
||||
@staticmethod
|
||||
def _telegram_display_name(user: Optional[User], fallback_user_id: int) -> str:
|
||||
return str(getattr(user, "first_name", "") or "").strip() or f"User {fallback_user_id}"
|
||||
|
||||
@staticmethod
|
||||
def _email_display_name(
|
||||
user: Optional[User],
|
||||
*,
|
||||
recipient_email: str,
|
||||
fallback: str,
|
||||
) -> str:
|
||||
return str(getattr(user, "first_name", "") or "").strip() or recipient_email or fallback
|
||||
|
||||
def _renewal_dashboard_url(self, recipient_email: str, sub: Subscription) -> Optional[str]:
|
||||
base_url = (self.settings.SUBSCRIPTION_MINI_APP_URL or "").strip()
|
||||
if not base_url:
|
||||
return None
|
||||
parsed = urlsplit(base_url)
|
||||
if parsed.scheme not in ("http", "https") or not parsed.netloc:
|
||||
return None
|
||||
|
||||
query = dict(parse_qsl(parsed.query, keep_blank_values=True))
|
||||
query.update(
|
||||
{
|
||||
"login": "email_code",
|
||||
"login_email": recipient_email,
|
||||
"after_login": "renew",
|
||||
"renew": "1",
|
||||
}
|
||||
)
|
||||
tariff_key = self._renewal_tariff_key(sub)
|
||||
if tariff_key:
|
||||
query["renew_tariff"] = tariff_key
|
||||
else:
|
||||
query.pop("renew_tariff", None)
|
||||
|
||||
return urlunsplit(
|
||||
(
|
||||
parsed.scheme,
|
||||
parsed.netloc,
|
||||
parsed.path or "/",
|
||||
urlencode(query),
|
||||
parsed.fragment,
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _renewal_tariff_key(sub: Subscription) -> str:
|
||||
provider = str(getattr(sub, "provider", "") or "").strip().lower()
|
||||
status = str(getattr(sub, "status_from_panel", "") or "").strip().upper()
|
||||
if provider == "trial" or status == "TRIAL":
|
||||
return ""
|
||||
return str(getattr(sub, "tariff_key", "") or "").strip()
|
||||
|
||||
@staticmethod
|
||||
def _telegram_chat_id(user: Optional[User], fallback_user_id: Optional[int]) -> Optional[int]:
|
||||
for candidate in (getattr(user, "telegram_id", None), fallback_user_id):
|
||||
try:
|
||||
chat_id = int(candidate or 0)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if chat_id > 0:
|
||||
return chat_id
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _as_utc(value: Optional[datetime]) -> Optional[datetime]:
|
||||
if value is None:
|
||||
return None
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=timezone.utc)
|
||||
return value.astimezone(timezone.utc)
|
||||
@@ -1,7 +1,6 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
@@ -14,9 +13,24 @@ from sqlalchemy.orm import selectinload, sessionmaker
|
||||
from bot.infra.redis import redis_lock
|
||||
from bot.keyboards.inline.user_keyboards import get_subscribe_only_markup
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.message_audit import log_user_message_delivery
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
from bot.services.subscription_lifecycle_notifications import (
|
||||
SubscriptionLifecycleNotificationService,
|
||||
SubscriptionNotificationStage,
|
||||
)
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.services.telegram_notifications import (
|
||||
TELEGRAM_NOTIFICATIONS_BLOCKED,
|
||||
TELEGRAM_NOTIFICATIONS_ENABLED,
|
||||
TELEGRAM_NOTIFICATIONS_NEEDS_START,
|
||||
mark_telegram_notifications_status,
|
||||
normalize_telegram_notification_status,
|
||||
telegram_notification_status_from_error,
|
||||
)
|
||||
from bot.services.user_email_notifications import send_user_notification_email
|
||||
from config.settings import Settings
|
||||
from db.advisory_locks import acquire_subscription_background_sync_lock
|
||||
from db.dal import subscription_dal
|
||||
from db.models import Subscription
|
||||
|
||||
@@ -26,13 +40,6 @@ EXPIRED_NOTIFICATION_WINDOW = timedelta(hours=24)
|
||||
EXPIRED_AFTER_NOTIFICATION_WINDOW = timedelta(hours=48)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SubscriptionNotificationStage:
|
||||
key: str
|
||||
message_key: str
|
||||
hours_before: Optional[int] = None
|
||||
|
||||
|
||||
class SubscriptionNotificationWorker:
|
||||
def __init__(
|
||||
self,
|
||||
@@ -49,6 +56,11 @@ class SubscriptionNotificationWorker:
|
||||
self.i18n = i18n
|
||||
self.panel_service = panel_service
|
||||
self.subscription_service = subscription_service
|
||||
self.lifecycle_notifications = SubscriptionLifecycleNotificationService(
|
||||
settings,
|
||||
bot,
|
||||
i18n,
|
||||
)
|
||||
self._stopped = asyncio.Event()
|
||||
|
||||
async def run(self) -> None:
|
||||
@@ -66,6 +78,7 @@ class SubscriptionNotificationWorker:
|
||||
else:
|
||||
started = time.monotonic()
|
||||
async with self.session_factory() as session:
|
||||
await acquire_subscription_background_sync_lock(session)
|
||||
await self.expiry_tick(session)
|
||||
await self.trial_traffic_tick(session)
|
||||
await session.commit()
|
||||
@@ -114,18 +127,10 @@ class SubscriptionNotificationWorker:
|
||||
stage = self.stage_for_subscription(sub, now)
|
||||
if stage is None:
|
||||
continue
|
||||
if await subscription_dal.has_subscription_notification(
|
||||
await self.lifecycle_notifications.send_stage(
|
||||
session,
|
||||
sub.subscription_id,
|
||||
stage.key,
|
||||
):
|
||||
continue
|
||||
if not await self._send_expiry_notification(sub, stage):
|
||||
continue
|
||||
await subscription_dal.record_subscription_notification(
|
||||
session,
|
||||
sub.subscription_id,
|
||||
stage.key,
|
||||
sub,
|
||||
stage,
|
||||
sent_at=now,
|
||||
)
|
||||
|
||||
@@ -164,6 +169,7 @@ class SubscriptionNotificationWorker:
|
||||
return SubscriptionNotificationStage(
|
||||
key=f"before_{days_before}d",
|
||||
message_key=message_key,
|
||||
days_left=days_before,
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -175,6 +181,7 @@ class SubscriptionNotificationWorker:
|
||||
return SubscriptionNotificationStage(
|
||||
key="expired",
|
||||
message_key="subscription_expired_notification",
|
||||
days_left=0,
|
||||
)
|
||||
if (
|
||||
getattr(self.settings, "SUBSCRIPTION_NOTIFY_AFTER_EXPIRE", True)
|
||||
@@ -183,6 +190,7 @@ class SubscriptionNotificationWorker:
|
||||
return SubscriptionNotificationStage(
|
||||
key="expired_24h_after",
|
||||
message_key="subscription_expired_yesterday_notification",
|
||||
days_left=0,
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -208,11 +216,22 @@ class SubscriptionNotificationWorker:
|
||||
.order_by(Subscription.end_date.asc())
|
||||
)
|
||||
for sub in result.scalars().all():
|
||||
if await subscription_dal.has_subscription_notification(
|
||||
legacy_sent = await subscription_dal.has_subscription_notification(
|
||||
session,
|
||||
sub.subscription_id,
|
||||
"trial_traffic_depleted",
|
||||
):
|
||||
)
|
||||
telegram_done = legacy_sent or await subscription_dal.has_subscription_notification(
|
||||
session,
|
||||
sub.subscription_id,
|
||||
"trial_traffic_depleted:telegram",
|
||||
)
|
||||
email_done = await subscription_dal.has_subscription_notification(
|
||||
session,
|
||||
sub.subscription_id,
|
||||
"trial_traffic_depleted:email",
|
||||
)
|
||||
if telegram_done and email_done:
|
||||
continue
|
||||
|
||||
used = int(getattr(sub, "traffic_used_bytes", 0) or 0)
|
||||
@@ -234,14 +253,30 @@ class SubscriptionNotificationWorker:
|
||||
|
||||
if limit <= 0 or used < limit:
|
||||
continue
|
||||
if not await self._send_trial_traffic_depleted(sub, used=used, limit=limit):
|
||||
continue
|
||||
await subscription_dal.record_subscription_notification(
|
||||
delivery = await self._send_trial_traffic_depleted(
|
||||
session,
|
||||
sub.subscription_id,
|
||||
"trial_traffic_depleted",
|
||||
sent_at=now,
|
||||
sub,
|
||||
used=used,
|
||||
limit=limit,
|
||||
send_telegram=not telegram_done,
|
||||
send_email=not email_done,
|
||||
)
|
||||
if not delivery["telegram"] and not delivery["email"]:
|
||||
continue
|
||||
if delivery["telegram"]:
|
||||
await subscription_dal.record_subscription_notification(
|
||||
session,
|
||||
sub.subscription_id,
|
||||
"trial_traffic_depleted:telegram",
|
||||
sent_at=now,
|
||||
)
|
||||
if delivery["email"]:
|
||||
await subscription_dal.record_subscription_notification(
|
||||
session,
|
||||
sub.subscription_id,
|
||||
"trial_traffic_depleted:email",
|
||||
sent_at=now,
|
||||
)
|
||||
|
||||
async def _panel_user(self, sub: Subscription) -> Optional[dict]:
|
||||
panel_uuid = str(getattr(sub, "panel_user_uuid", "") or "").strip()
|
||||
@@ -257,68 +292,89 @@ class SubscriptionNotificationWorker:
|
||||
return None
|
||||
return data if isinstance(data, dict) else None
|
||||
|
||||
async def _send_expiry_notification(
|
||||
self,
|
||||
sub: Subscription,
|
||||
stage: SubscriptionNotificationStage,
|
||||
) -> bool:
|
||||
user_id = int(getattr(sub, "user_id", 0) or 0)
|
||||
if user_id <= 0:
|
||||
return False
|
||||
user = getattr(sub, "user", None)
|
||||
lang = getattr(user, "language_code", None) or self.settings.DEFAULT_LANGUAGE
|
||||
user_name = getattr(user, "first_name", None) or f"User {user_id}"
|
||||
end_date = self._as_utc(getattr(sub, "end_date", None))
|
||||
end_date_text = end_date.strftime("%Y-%m-%d") if end_date else ""
|
||||
translate = lambda k, **kw: self.i18n.gettext(lang, k, **kw)
|
||||
kwargs = {"user_name": user_name, "end_date": end_date_text}
|
||||
if stage.hours_before is not None:
|
||||
kwargs["hours"] = stage.hours_before
|
||||
try:
|
||||
await self.bot.send_message(
|
||||
user_id,
|
||||
translate(stage.message_key, **kwargs),
|
||||
reply_markup=get_subscribe_only_markup(lang, self.i18n),
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
logging.exception(
|
||||
"Failed to send subscription notification %s to user %s",
|
||||
stage.key,
|
||||
user_id,
|
||||
)
|
||||
return False
|
||||
|
||||
async def _send_trial_traffic_depleted(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
sub: Subscription,
|
||||
*,
|
||||
used: int,
|
||||
limit: int,
|
||||
) -> bool:
|
||||
send_telegram: bool = True,
|
||||
send_email: bool = True,
|
||||
) -> dict[str, bool]:
|
||||
user_id = int(getattr(sub, "user_id", 0) or 0)
|
||||
if user_id <= 0:
|
||||
return False
|
||||
user = getattr(sub, "user", None)
|
||||
lang = getattr(user, "language_code", None) or self.settings.DEFAULT_LANGUAGE
|
||||
translate = lambda k, **kw: self.i18n.gettext(lang, k, **kw)
|
||||
remaining = max(0, limit - used)
|
||||
try:
|
||||
await self.bot.send_message(
|
||||
user_id,
|
||||
translate(
|
||||
"trial_traffic_depleted_notification",
|
||||
used=hd.quote(self._fmt_bytes(used)),
|
||||
remaining=hd.quote(self._fmt_bytes(remaining)),
|
||||
limit_total=hd.quote(self._fmt_bytes(limit)),
|
||||
message_text = translate(
|
||||
"trial_traffic_depleted_notification",
|
||||
used=hd.quote(self._fmt_bytes(used)),
|
||||
remaining=hd.quote(self._fmt_bytes(remaining)),
|
||||
limit_total=hd.quote(self._fmt_bytes(limit)),
|
||||
)
|
||||
telegram_sent = False
|
||||
email_sent = False
|
||||
telegram_chat_id = int(getattr(user, "telegram_id", 0) or user_id or 0)
|
||||
telegram_status = normalize_telegram_notification_status(
|
||||
getattr(user, "telegram_notifications_status", None)
|
||||
)
|
||||
can_try_telegram = telegram_status not in {
|
||||
TELEGRAM_NOTIFICATIONS_NEEDS_START,
|
||||
TELEGRAM_NOTIFICATIONS_BLOCKED,
|
||||
}
|
||||
if send_telegram and telegram_chat_id > 0 and can_try_telegram:
|
||||
try:
|
||||
await self.bot.send_message(
|
||||
telegram_chat_id,
|
||||
message_text,
|
||||
reply_markup=get_subscribe_only_markup(lang, self.i18n),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
telegram_sent = True
|
||||
await log_user_message_delivery(
|
||||
session,
|
||||
target_user_id=user_id,
|
||||
event_type="telegram_traffic_warning_sent",
|
||||
channel="telegram",
|
||||
recipient=str(telegram_chat_id),
|
||||
content=(
|
||||
"kind=trial warning_key=trial_traffic_depleted "
|
||||
f"used_bytes={used} limit_bytes={limit}"
|
||||
),
|
||||
)
|
||||
except Exception as exc:
|
||||
status = telegram_notification_status_from_error(exc)
|
||||
if status and user and user_id:
|
||||
await mark_telegram_notifications_status(session, user_id, status)
|
||||
logging.exception(
|
||||
"Failed to send trial traffic depleted warning to user %s",
|
||||
telegram_chat_id,
|
||||
)
|
||||
else:
|
||||
if user and telegram_status != TELEGRAM_NOTIFICATIONS_ENABLED and user_id:
|
||||
await mark_telegram_notifications_status(
|
||||
session,
|
||||
user_id,
|
||||
TELEGRAM_NOTIFICATIONS_ENABLED,
|
||||
telegram_id=telegram_chat_id,
|
||||
)
|
||||
if send_email and user:
|
||||
email_sent = await send_user_notification_email(
|
||||
settings=self.settings,
|
||||
i18n=self.i18n,
|
||||
user=user,
|
||||
subject_key="email_trial_traffic_depleted_subject",
|
||||
message_text=message_text,
|
||||
dashboard_url=(getattr(self.settings, "SUBSCRIPTION_MINI_APP_URL", "") or None),
|
||||
session=session,
|
||||
audit_event_type="email_traffic_warning_sent",
|
||||
audit_content=(
|
||||
"kind=trial warning_key=trial_traffic_depleted "
|
||||
f"used_bytes={used} limit_bytes={limit}"
|
||||
),
|
||||
reply_markup=get_subscribe_only_markup(lang, self.i18n),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
logging.exception("Failed to send trial traffic depleted warning to user %s", user_id)
|
||||
return False
|
||||
return {"telegram": telegram_sent, "email": email_sent}
|
||||
|
||||
def _max_before_window(self) -> timedelta:
|
||||
days_before = max(0, int(getattr(self.settings, "SUBSCRIPTION_NOTIFY_DAYS_BEFORE", 0) or 0))
|
||||
|
||||
@@ -11,7 +11,11 @@ from bot.middlewares.i18n import JsonI18n
|
||||
from bot.utils.config_link import prepare_config_links
|
||||
from bot.utils.date_utils import add_months, month_start
|
||||
from config.settings import Settings
|
||||
from config.tariffs_config import Tariff
|
||||
from config.tariffs_config import (
|
||||
Tariff,
|
||||
default_currency_key_for_settings,
|
||||
default_payment_currency_code_for_settings,
|
||||
)
|
||||
from db.dal import (
|
||||
payment_dal,
|
||||
promo_code_dal,
|
||||
|
||||
@@ -70,7 +70,7 @@ class HwidDeviceMixin:
|
||||
package_set = tariff.hwid_device_packages
|
||||
if not package_set:
|
||||
return None
|
||||
packages = package_set.for_currency("stars" if currency == "stars" else "rub")
|
||||
packages = package_set.for_currency(currency)
|
||||
return next((pkg for pkg in packages if int(pkg.count) == int(device_count)), None)
|
||||
|
||||
def _quote_hwid_package_price(
|
||||
@@ -173,7 +173,7 @@ class HwidDeviceMixin:
|
||||
valid_from=valid_from,
|
||||
valid_until=valid_until,
|
||||
now=now,
|
||||
currency="stars" if currency == "stars" else "rub",
|
||||
currency=currency,
|
||||
)
|
||||
quote.update(
|
||||
{
|
||||
@@ -227,7 +227,11 @@ class HwidDeviceMixin:
|
||||
)
|
||||
return None
|
||||
packages = (
|
||||
[*tariff.hwid_device_packages.rub, *tariff.hwid_device_packages.stars]
|
||||
[
|
||||
package
|
||||
for currency_packages in tariff.hwid_device_packages.root.values()
|
||||
for package in currency_packages
|
||||
]
|
||||
if tariff.hwid_device_packages
|
||||
else []
|
||||
)
|
||||
@@ -357,6 +361,15 @@ class HwidDeviceMixin:
|
||||
valid_from=valid_from,
|
||||
valid_until=valid_until,
|
||||
)
|
||||
await self._send_payment_success_email(
|
||||
db_user=db_user,
|
||||
sale_mode="hwid_devices_renewal" if renewal else "hwid_devices",
|
||||
months=purchased_devices,
|
||||
traffic_gb=None,
|
||||
payment_amount=payment_amount,
|
||||
end_date=valid_until,
|
||||
provider=provider,
|
||||
)
|
||||
return {
|
||||
"subscription_id": updated_sub.subscription_id,
|
||||
"end_date": updated_sub.end_date,
|
||||
|
||||
@@ -3,6 +3,175 @@ from ._runtime import * # noqa: F403,F405
|
||||
|
||||
|
||||
class SubscriptionLifecycleMixin:
|
||||
async def _lookup_panel_user_for_subscription_details(
|
||||
self,
|
||||
panel_user_uuid: str,
|
||||
) -> Tuple[Optional[Dict[str, Any]], bool, str]:
|
||||
lookup_method = getattr(self.panel_service, "get_user_by_uuid_lookup", None)
|
||||
if callable(lookup_method):
|
||||
try:
|
||||
lookup = await lookup_method(panel_user_uuid, log_response=False)
|
||||
except TypeError:
|
||||
try:
|
||||
lookup = await lookup_method(panel_user_uuid)
|
||||
except Exception as exc:
|
||||
logging.exception(
|
||||
"Failed to fetch panel user %s for subscription details",
|
||||
panel_user_uuid,
|
||||
)
|
||||
return None, False, self._panel_lookup_exception_reason(exc)
|
||||
except Exception as exc:
|
||||
logging.exception(
|
||||
"Failed to fetch panel user %s for subscription details",
|
||||
panel_user_uuid,
|
||||
)
|
||||
return None, False, self._panel_lookup_exception_reason(exc)
|
||||
|
||||
if isinstance(lookup, dict) and ("ok" in lookup or "not_found" in lookup):
|
||||
user = lookup.get("user")
|
||||
if lookup.get("ok") and isinstance(user, dict):
|
||||
return user, False, ""
|
||||
reason = str(lookup.get("failure_reason") or "classification=panel_lookup_failed")
|
||||
return None, bool(lookup.get("not_found")), reason
|
||||
|
||||
try:
|
||||
panel_user = await self.panel_service.get_user_by_uuid(panel_user_uuid)
|
||||
except Exception as exc:
|
||||
logging.exception(
|
||||
"Failed to fetch panel user %s for subscription details",
|
||||
panel_user_uuid,
|
||||
)
|
||||
return None, False, self._panel_lookup_exception_reason(exc)
|
||||
return (panel_user if isinstance(panel_user, dict) else None), False, ""
|
||||
|
||||
@staticmethod
|
||||
def _panel_lookup_exception_reason(exc: Exception) -> str:
|
||||
message = str(exc).replace("\n", " ").strip()
|
||||
if len(message) > 300:
|
||||
message = f"{message[:300]}..."
|
||||
reason = f"classification=panel_lookup_failed exception={type(exc).__name__}"
|
||||
if message:
|
||||
reason = f"{reason} message={message}"
|
||||
return reason
|
||||
|
||||
@staticmethod
|
||||
def _display_datetime_text(value: Optional[Any]) -> Optional[str]:
|
||||
if not value:
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
normalized = value if value.tzinfo else value.replace(tzinfo=timezone.utc)
|
||||
return normalized.strftime("%d.%m.%Y %H:%M")
|
||||
return str(value)
|
||||
|
||||
@staticmethod
|
||||
def _device_topup_renewal_available(
|
||||
extra_hwid_devices: int,
|
||||
extra_hwid_valid_until: Optional[Any],
|
||||
subscription_end_date: Optional[Any],
|
||||
) -> bool:
|
||||
if not isinstance(extra_hwid_valid_until, datetime) or not isinstance(
|
||||
subscription_end_date, datetime
|
||||
):
|
||||
return False
|
||||
valid_until = (
|
||||
extra_hwid_valid_until
|
||||
if extra_hwid_valid_until.tzinfo
|
||||
else extra_hwid_valid_until.replace(tzinfo=timezone.utc)
|
||||
)
|
||||
end_date = (
|
||||
subscription_end_date
|
||||
if subscription_end_date.tzinfo
|
||||
else subscription_end_date.replace(tzinfo=timezone.utc)
|
||||
)
|
||||
return bool(int(extra_hwid_devices or 0) > 0 and valid_until < end_date)
|
||||
|
||||
async def _local_active_subscription_details_fallback(
|
||||
self,
|
||||
db_user: User,
|
||||
local_active_sub: Subscription,
|
||||
) -> Dict[str, Any]:
|
||||
panel_sub_id = str(local_active_sub.panel_subscription_uuid or "").strip()
|
||||
config_link_raw = (
|
||||
await self.panel_service.get_subscription_link(panel_sub_id) if panel_sub_id else None
|
||||
)
|
||||
display_link, connect_button_url = await prepare_config_links(
|
||||
self.settings,
|
||||
config_link_raw,
|
||||
)
|
||||
tariff = None
|
||||
if local_active_sub.tariff_key and self._tariffs_config():
|
||||
try:
|
||||
tariff = self._resolve_tariff(local_active_sub.tariff_key)
|
||||
except Exception:
|
||||
tariff = None
|
||||
language = db_user.language_code or self.settings.DEFAULT_LANGUAGE
|
||||
premium_access = (
|
||||
await self.premium_access_for_tariff(tariff)
|
||||
if tariff
|
||||
else {"squad_uuids": [], "squad_labels": [], "node_labels": []}
|
||||
)
|
||||
premium_baseline = int(local_active_sub.premium_baseline_bytes or 0)
|
||||
premium_topup_balance = int(local_active_sub.premium_topup_balance_bytes or 0)
|
||||
premium_topup_used = int(getattr(local_active_sub, "premium_topup_used_bytes", 0) or 0)
|
||||
premium_bonus_bytes = int(getattr(local_active_sub, "premium_bonus_bytes", 0) or 0)
|
||||
return {
|
||||
"user_id": db_user.panel_user_uuid,
|
||||
"panel_subscription_uuid": local_active_sub.panel_subscription_uuid,
|
||||
"panel_short_uuid": local_active_sub.panel_subscription_uuid,
|
||||
"end_date": local_active_sub.end_date,
|
||||
"status_from_panel": local_active_sub.status_from_panel or "LOCAL_CACHE",
|
||||
"config_link": display_link,
|
||||
"connect_button_url": connect_button_url,
|
||||
"traffic_limit_bytes": local_active_sub.traffic_limit_bytes,
|
||||
"traffic_used_bytes": local_active_sub.traffic_used_bytes,
|
||||
"traffic_limit_strategy": "",
|
||||
"tariff_key": local_active_sub.tariff_key,
|
||||
"tariff_name": tariff.name(language) if tariff else None,
|
||||
"tariff_description": tariff.description(language) if tariff else None,
|
||||
"premium_title": tariff.premium_name(language) if tariff else None,
|
||||
"billing_model": tariff.billing_model
|
||||
if tariff
|
||||
else ("traffic" if getattr(self.settings, "traffic_sale_mode", False) else "period"),
|
||||
"tier_baseline_bytes": local_active_sub.tier_baseline_bytes,
|
||||
"topup_balance_bytes": local_active_sub.topup_balance_bytes,
|
||||
"regular_bonus_bytes": int(getattr(local_active_sub, "regular_bonus_bytes", 0) or 0),
|
||||
"regular_unlimited_override": bool(
|
||||
getattr(local_active_sub, "regular_unlimited_override", False)
|
||||
),
|
||||
"premium_baseline_bytes": premium_baseline,
|
||||
"premium_topup_balance_bytes": premium_topup_balance,
|
||||
"premium_topup_used_bytes": premium_topup_used,
|
||||
"premium_used_bytes": local_active_sub.premium_used_bytes,
|
||||
"premium_bonus_bytes": premium_bonus_bytes,
|
||||
"premium_unlimited_override": bool(
|
||||
getattr(local_active_sub, "premium_unlimited_override", False)
|
||||
),
|
||||
"premium_limit_bytes": self._premium_effective_limit_bytes(
|
||||
premium_baseline,
|
||||
premium_topup_balance,
|
||||
premium_topup_used,
|
||||
premium_bonus_bytes,
|
||||
),
|
||||
"premium_is_limited": bool(local_active_sub.premium_is_limited),
|
||||
"premium_period_start_at": getattr(local_active_sub, "premium_period_start_at", None),
|
||||
"premium_squad_labels": premium_access.get("squad_labels") or [],
|
||||
"premium_node_labels": premium_access.get("node_labels") or [],
|
||||
"period_start_at": local_active_sub.period_start_at,
|
||||
"is_throttled": bool(local_active_sub.is_throttled),
|
||||
"base_hwid_device_limit": local_active_sub.hwid_device_limit,
|
||||
"extra_hwid_devices": int(local_active_sub.extra_hwid_devices or 0),
|
||||
"extra_hwid_devices_valid_until": None,
|
||||
"extra_hwid_devices_valid_until_text": None,
|
||||
"extra_hwid_devices_next_valid_from": None,
|
||||
"device_topup_renewal_available": False,
|
||||
"user_bot_username": db_user.username,
|
||||
"is_panel_data": False,
|
||||
"max_devices": self._effective_hwid_limit(
|
||||
local_active_sub.hwid_device_limit,
|
||||
int(local_active_sub.extra_hwid_devices or 0),
|
||||
),
|
||||
}
|
||||
|
||||
async def switch_tariff_without_payment(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
@@ -79,9 +248,9 @@ class SubscriptionLifecycleMixin:
|
||||
traffic_used_bytes=used_sub,
|
||||
)
|
||||
update_data["period_start_at"] = None
|
||||
update_data["effective_monthly_price_rub"] = (
|
||||
target.period_price(1, "rub") or target.min_period_price_rub()
|
||||
)
|
||||
update_data["effective_monthly_price_rub"] = target.period_price(
|
||||
1, default_currency_key_for_settings(self.settings)
|
||||
) or target.min_period_price(default_currency_key_for_settings(self.settings))
|
||||
if mode == "recalc_days" and options.get("recalc_days") is not None:
|
||||
update_data["end_date"] = now + timedelta(days=int(options["recalc_days"]))
|
||||
else:
|
||||
@@ -308,6 +477,17 @@ class SubscriptionLifecycleMixin:
|
||||
)
|
||||
result["end_date"] = sub.end_date
|
||||
result["is_active"] = sub.is_active
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
if db_user:
|
||||
await self._send_payment_success_email(
|
||||
db_user=db_user,
|
||||
sale_mode="tariff_upgrade",
|
||||
months=0,
|
||||
traffic_gb=None,
|
||||
payment_amount=payment_amount,
|
||||
end_date=sub.end_date,
|
||||
provider=provider,
|
||||
)
|
||||
return result
|
||||
|
||||
tariff = self._resolve_tariff(tariff_key, "period") if self._tariffs_config() else None
|
||||
@@ -568,6 +748,9 @@ class SubscriptionLifecycleMixin:
|
||||
active_sub = await subscription_dal.get_active_subscription_by_user_id(
|
||||
session, user_id, panel_uuid
|
||||
)
|
||||
preserve_tariff_limits = bool(
|
||||
active_sub and active_sub.tariff_key and self._tariffs_config()
|
||||
)
|
||||
if not active_sub or not active_sub.end_date:
|
||||
logging.info(
|
||||
f"No active subscription found for user {user_id}. Creating new one for {bonus_days} days." # noqa: E501
|
||||
@@ -612,6 +795,7 @@ class SubscriptionLifecycleMixin:
|
||||
|
||||
if (
|
||||
apply_main_traffic_limit
|
||||
and not preserve_tariff_limits
|
||||
and updated_sub_model
|
||||
and updated_sub_model.traffic_limit_bytes != self.settings.user_traffic_limit_bytes
|
||||
):
|
||||
@@ -626,7 +810,9 @@ class SubscriptionLifecycleMixin:
|
||||
panel_update_payload = self._build_panel_update_payload(
|
||||
expire_at=new_end_date_obj,
|
||||
traffic_limit_bytes=(
|
||||
self.settings.user_traffic_limit_bytes if apply_main_traffic_limit else None
|
||||
self.settings.user_traffic_limit_bytes
|
||||
if apply_main_traffic_limit and not preserve_tariff_limits
|
||||
else None
|
||||
),
|
||||
include_uuid=False,
|
||||
include_default_squads=False,
|
||||
@@ -663,14 +849,36 @@ class SubscriptionLifecycleMixin:
|
||||
local_active_sub = await subscription_dal.get_active_subscription_by_user_id(
|
||||
session, user_id, panel_user_uuid
|
||||
)
|
||||
panel_user_data = await self.panel_service.get_user_by_uuid(panel_user_uuid)
|
||||
(
|
||||
panel_user_data,
|
||||
panel_user_confirmed_absent,
|
||||
panel_lookup_failure_reason,
|
||||
) = await self._lookup_panel_user_for_subscription_details(panel_user_uuid)
|
||||
|
||||
if not panel_user_data:
|
||||
if panel_user_confirmed_absent:
|
||||
logging.warning(
|
||||
"Panel user %s confirmed absent on panel for user %s. "
|
||||
"Clearing local linkage. reason=%s",
|
||||
panel_user_uuid,
|
||||
user_id,
|
||||
panel_lookup_failure_reason,
|
||||
)
|
||||
await subscription_dal.deactivate_all_user_subscriptions(session, user_id)
|
||||
await user_dal.update_user(session, user_id, {"panel_user_uuid": None})
|
||||
return None
|
||||
logging.warning(
|
||||
f"Panel user {panel_user_uuid} not found on panel for user {user_id}. Clearing local linkage." # noqa: E501
|
||||
"Panel user %s lookup failed for user %s; treating it as a panel access/API "
|
||||
"problem and preserving local linkage/subscription. reason=%s",
|
||||
panel_user_uuid,
|
||||
user_id,
|
||||
panel_lookup_failure_reason,
|
||||
)
|
||||
await subscription_dal.deactivate_all_user_subscriptions(session, user_id)
|
||||
await user_dal.update_user(session, user_id, {"panel_user_uuid": None})
|
||||
if local_active_sub:
|
||||
return await self._local_active_subscription_details_fallback(
|
||||
db_user,
|
||||
local_active_sub,
|
||||
)
|
||||
return None
|
||||
|
||||
panel_lifetime_used = self._extract_lifetime_used_traffic(panel_user_data)
|
||||
@@ -836,6 +1044,14 @@ class SubscriptionLifecycleMixin:
|
||||
if expected_hwid_limit is not None:
|
||||
hwid_limit = expected_hwid_limit
|
||||
|
||||
extra_hwid_valid_until = hwid_entitlement_summary.get("active_until")
|
||||
extra_hwid_next_valid_from = hwid_entitlement_summary.get("next_valid_from")
|
||||
device_topup_renewal_available = self._device_topup_renewal_available(
|
||||
active_extra_hwid_devices,
|
||||
extra_hwid_valid_until,
|
||||
panel_end_date,
|
||||
)
|
||||
|
||||
return {
|
||||
"user_id": panel_user_data.get("uuid"),
|
||||
"panel_subscription_uuid": panel_user_data.get("subscriptionUuid")
|
||||
@@ -896,8 +1112,12 @@ class SubscriptionLifecycleMixin:
|
||||
if local_active_sub
|
||||
else None,
|
||||
"extra_hwid_devices": active_extra_hwid_devices,
|
||||
"extra_hwid_devices_valid_until": hwid_entitlement_summary.get("active_until"),
|
||||
"extra_hwid_devices_next_valid_from": hwid_entitlement_summary.get("next_valid_from"),
|
||||
"extra_hwid_devices_valid_until": extra_hwid_valid_until,
|
||||
"extra_hwid_devices_valid_until_text": self._display_datetime_text(
|
||||
extra_hwid_valid_until
|
||||
),
|
||||
"extra_hwid_devices_next_valid_from": extra_hwid_next_valid_from,
|
||||
"device_topup_renewal_available": device_topup_renewal_available,
|
||||
"user_bot_username": db_user.username,
|
||||
"is_panel_data": True,
|
||||
"max_devices": hwid_limit,
|
||||
|
||||
@@ -62,6 +62,9 @@ class PaymentContextMixin:
|
||||
async def has_had_any_subscription(self, session: AsyncSession, user_id: int) -> bool:
|
||||
return await subscription_dal.has_any_subscription_for_user(session, user_id)
|
||||
|
||||
async def has_trial_blocking_subscription(self, session: AsyncSession, user_id: int) -> bool:
|
||||
return await subscription_dal.has_trial_blocking_subscription_for_user(session, user_id)
|
||||
|
||||
async def has_active_subscription(self, session: AsyncSession, user_id: int) -> bool:
|
||||
"""Return True if user currently has an active subscription (end_date in future)."""
|
||||
try:
|
||||
@@ -93,7 +96,7 @@ class PaymentContextMixin:
|
||||
"""Best-effort branded email confirming the payment. No-op if SMTP or
|
||||
the user's email aren't set. Failures are logged and swallowed so the
|
||||
payment flow is never blocked by mail delivery."""
|
||||
if not self.settings.email_auth_configured:
|
||||
if not getattr(self.settings, "email_auth_configured", False):
|
||||
return
|
||||
recipient = (db_user.email or "").strip() if db_user else ""
|
||||
if not recipient:
|
||||
@@ -120,7 +123,7 @@ class PaymentContextMixin:
|
||||
months=int(months or 0),
|
||||
traffic_gb=traffic_gb,
|
||||
amount=float(payment_amount or 0),
|
||||
currency=self.settings.DEFAULT_CURRENCY_SYMBOL,
|
||||
currency=default_payment_currency_code_for_settings(self.settings),
|
||||
end_date_text=end_date_text,
|
||||
dashboard_url=dashboard_url,
|
||||
provider_label=provider_label,
|
||||
|
||||
@@ -41,7 +41,23 @@ class RenewalMixin:
|
||||
return False
|
||||
|
||||
months = sub.duration_months or 1
|
||||
amount = self.settings.subscription_options.get(months)
|
||||
currency = default_payment_currency_code_for_settings(self.settings)
|
||||
amount = None
|
||||
tariffs_config = (
|
||||
self._tariffs_config() if callable(getattr(self, "_tariffs_config", None)) else None
|
||||
)
|
||||
if tariffs_config and callable(getattr(self, "_resolve_tariff", None)):
|
||||
try:
|
||||
tariff = self._resolve_tariff(getattr(sub, "tariff_key", None))
|
||||
except Exception:
|
||||
tariff = None
|
||||
if tariff and tariff.billing_model == "period":
|
||||
amount = tariff.period_price(
|
||||
months,
|
||||
default_currency_key_for_settings(self.settings),
|
||||
)
|
||||
if amount is None:
|
||||
amount = self.settings.subscription_options.get(months)
|
||||
if not amount:
|
||||
logging.error(f"Auto-renew price missing for {months} months")
|
||||
return False
|
||||
@@ -53,7 +69,7 @@ class RenewalMixin:
|
||||
}
|
||||
resp = await yk.create_payment(
|
||||
amount=float(amount),
|
||||
currency="RUB",
|
||||
currency=currency,
|
||||
description=f"Auto-renewal for {months} months",
|
||||
metadata=metadata,
|
||||
payment_method_id=default_pm.provider_payment_method_id,
|
||||
|
||||
@@ -112,15 +112,14 @@ class TariffMixin:
|
||||
regular_unlimited_override: bool,
|
||||
traffic_used_bytes: int,
|
||||
) -> int:
|
||||
"""Numeric cap sent to the panel; ``regular_unlimited_override`` uses a large practical ceiling.""" # noqa: E501
|
||||
"""Numeric cap sent to the panel; Remnawave treats ``0`` as unlimited."""
|
||||
floor = (
|
||||
int(tier_baseline_bytes or 0)
|
||||
+ max(0, int(topup_balance_bytes or 0))
|
||||
+ max(0, int(regular_bonus_bytes or 0))
|
||||
)
|
||||
if regular_unlimited_override:
|
||||
used = max(0, int(traffic_used_bytes or 0))
|
||||
return max(floor, used + 512 * (1024**3), 1024**5)
|
||||
return 0
|
||||
return floor
|
||||
|
||||
async def premium_access_for_tariff(self, tariff: Optional[Tariff]) -> Dict[str, Any]:
|
||||
@@ -331,11 +330,12 @@ class TariffMixin:
|
||||
remaining_days = max(0, (sub.end_date - now).days) if sub.end_date else 0
|
||||
effective = float(sub.effective_monthly_price_rub or 0)
|
||||
current_model = current_tariff.billing_model if current_tariff else "period"
|
||||
default_currency = default_currency_key_for_settings(self.settings)
|
||||
|
||||
if current_model == "period" and target_tariff.billing_model == "period":
|
||||
target_monthly = (
|
||||
target_tariff.period_price(1, "rub")
|
||||
or target_tariff.min_period_price_rub()
|
||||
target_tariff.period_price(1, default_currency)
|
||||
or target_tariff.min_period_price(default_currency)
|
||||
or effective
|
||||
or 1
|
||||
)
|
||||
@@ -355,11 +355,14 @@ class TariffMixin:
|
||||
"remaining_days": remaining_days,
|
||||
"recalc_days": max(0, days_after),
|
||||
"paid_diff_rub": paid_diff,
|
||||
"paid_diff": paid_diff,
|
||||
"target_monthly_rub": float(target_monthly),
|
||||
"target_monthly_price": float(target_monthly),
|
||||
"currency": default_currency,
|
||||
}
|
||||
|
||||
if current_model == "period" and target_tariff.billing_model == "traffic":
|
||||
rub_per_gb = target_tariff.rub_per_gb_for_conversion()
|
||||
rub_per_gb = target_tariff.currency_per_gb_for_conversion(default_currency)
|
||||
remaining_value = remaining_days * (effective / 30) if effective else 0
|
||||
converted_gb = math.floor(remaining_value / rub_per_gb) if rub_per_gb else 0
|
||||
return {
|
||||
@@ -367,9 +370,15 @@ class TariffMixin:
|
||||
"remaining_days": remaining_days,
|
||||
"converted_gb": max(0, converted_gb),
|
||||
"rub_per_gb": rub_per_gb,
|
||||
"currency_per_gb": rub_per_gb,
|
||||
"currency": default_currency,
|
||||
}
|
||||
|
||||
return {"mode": "traffic_to_period", "remaining_days": remaining_days}
|
||||
return {
|
||||
"mode": "traffic_to_period",
|
||||
"remaining_days": remaining_days,
|
||||
"currency": default_currency,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _aware_utc(value: Optional[datetime]) -> Optional[datetime]:
|
||||
@@ -434,6 +443,7 @@ class TariffMixin:
|
||||
credit = await self._hwid_conversion_credit(session, sub, at=now)
|
||||
value_rub = float(credit.get("value_rub") or 0)
|
||||
options["converted_hwid_value_rub"] = round(value_rub, 2)
|
||||
options["converted_hwid_value"] = round(value_rub, 2)
|
||||
options["convertible_hwid_purchase_ids"] = list(credit.get("purchase_ids") or [])
|
||||
options["nonconverted_hwid_devices"] = int(credit.get("skipped_devices") or 0)
|
||||
if value_rub <= 0:
|
||||
|
||||
@@ -269,6 +269,15 @@ class TrafficMixin:
|
||||
purchased_bytes=purchase_bytes,
|
||||
kind="topup",
|
||||
)
|
||||
await self._send_payment_success_email(
|
||||
db_user=db_user,
|
||||
sale_mode="topup",
|
||||
months=0,
|
||||
traffic_gb=float(traffic_gb),
|
||||
payment_amount=payment_amount,
|
||||
end_date=getattr(updated_sub, "end_date", None),
|
||||
provider=provider,
|
||||
)
|
||||
return {
|
||||
"subscription_id": sub.subscription_id,
|
||||
"traffic_limit_bytes": new_limit,
|
||||
@@ -376,6 +385,15 @@ class TrafficMixin:
|
||||
purchased_bytes=purchase_bytes,
|
||||
kind="premium_topup",
|
||||
)
|
||||
await self._send_payment_success_email(
|
||||
db_user=db_user,
|
||||
sale_mode="premium_topup",
|
||||
months=0,
|
||||
traffic_gb=float(traffic_gb),
|
||||
payment_amount=payment_amount,
|
||||
end_date=getattr(sub, "end_date", None),
|
||||
provider=provider,
|
||||
)
|
||||
return {
|
||||
"subscription_id": sub.subscription_id,
|
||||
"premium_limit_bytes": premium_limit,
|
||||
|
||||
@@ -22,7 +22,7 @@ class TrialSubscriptionMixin:
|
||||
"message_key": "user_not_found_for_trial",
|
||||
}
|
||||
|
||||
if await self.has_had_any_subscription(session, user_id):
|
||||
if await self.has_trial_blocking_subscription(session, user_id):
|
||||
return {
|
||||
"eligible": False,
|
||||
"activated": False,
|
||||
|
||||
@@ -2,7 +2,7 @@ import asyncio
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional
|
||||
from typing import Any, Awaitable, Callable, Optional
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, WebAppInfo
|
||||
@@ -13,11 +13,14 @@ from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from bot.infra.redis import redis_lock
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.message_audit import log_user_message_delivery
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.services.user_email_notifications import send_user_notification_email
|
||||
from bot.utils.date_utils import month_start
|
||||
from bot.utils.mini_app_url import subscription_mini_app_topup_url
|
||||
from config.settings import Settings
|
||||
from db.advisory_locks import acquire_subscription_background_sync_lock
|
||||
from db.dal import subscription_dal, tariff_dal, user_dal
|
||||
from db.models import Subscription
|
||||
|
||||
@@ -31,6 +34,10 @@ TARIFF_WORKER_BATCH_SIZE = 50
|
||||
TARIFF_WORKER_PANEL_CONCURRENCY = 10
|
||||
TARIFF_WORKER_BULK_PANEL_FETCH_THRESHOLD = 50
|
||||
TARIFF_WORKER_SQUAD_CONFIRMATION_CACHE_TTL_SECONDS = 900
|
||||
TARIFF_WORKER_DB_RETRY_ATTEMPTS = 3
|
||||
TARIFF_WORKER_DB_RETRY_BASE_SLEEP_SECONDS = 0.5
|
||||
POSTGRES_RETRYABLE_SQLSTATES = {"40001", "40P01"}
|
||||
POSTGRES_RETRYABLE_ERROR_NAMES = {"DeadlockDetectedError", "SerializationError"}
|
||||
|
||||
|
||||
class TariffTrafficWorker:
|
||||
@@ -97,6 +104,41 @@ class TariffTrafficWorker:
|
||||
button = InlineKeyboardButton(text=_(fallback_key), callback_data="tariff_topup:list")
|
||||
return InlineKeyboardMarkup(inline_keyboard=[[button]])
|
||||
|
||||
async def _send_traffic_warning_email(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
subject_key: str,
|
||||
message_text: str,
|
||||
kind: str,
|
||||
warning_key: str,
|
||||
audit_content: str,
|
||||
) -> None:
|
||||
try:
|
||||
user = await user_dal.get_user_by_id(session, user_id)
|
||||
except Exception:
|
||||
logging.exception("TariffTrafficWorker: failed to load user %s for email", user_id)
|
||||
return
|
||||
if not user:
|
||||
return
|
||||
await send_user_notification_email(
|
||||
settings=self.settings,
|
||||
i18n=self.i18n,
|
||||
user=user,
|
||||
subject_key=subject_key,
|
||||
message_text=message_text,
|
||||
dashboard_url=subscription_mini_app_topup_url(self.settings, kind),
|
||||
cta_label_key=(
|
||||
"email_traffic_warning_premium_cta"
|
||||
if kind == "premium"
|
||||
else "email_traffic_warning_regular_cta"
|
||||
),
|
||||
session=session,
|
||||
audit_event_type="email_traffic_warning_sent",
|
||||
audit_content=f"{audit_content} subject_key={subject_key} warning_key={warning_key}",
|
||||
)
|
||||
|
||||
async def run(self) -> None:
|
||||
if not self.settings.tariffs_config:
|
||||
return
|
||||
@@ -111,12 +153,14 @@ class TariffTrafficWorker:
|
||||
logging.info("TariffTrafficWorker tick skipped: Redis lock is held")
|
||||
else:
|
||||
started = time.monotonic()
|
||||
async with self.session_factory() as session:
|
||||
await self.traffic_period_tick(session)
|
||||
await session.commit()
|
||||
async with self.session_factory() as session:
|
||||
await self.legacy_throttle_recovery_tick(session)
|
||||
await session.commit()
|
||||
await self._run_db_tick_with_retry(
|
||||
"traffic_period",
|
||||
self.traffic_period_tick,
|
||||
)
|
||||
await self._run_db_tick_with_retry(
|
||||
"legacy_throttle_recovery",
|
||||
self.legacy_throttle_recovery_tick,
|
||||
)
|
||||
logging.info(
|
||||
"metric worker_tick_duration_seconds=%.3f worker=tariff",
|
||||
time.monotonic() - started,
|
||||
@@ -134,16 +178,80 @@ class TariffTrafficWorker:
|
||||
def stop(self) -> None:
|
||||
self._stopped.set()
|
||||
|
||||
async def _run_db_tick_with_retry(
|
||||
self,
|
||||
tick_name: str,
|
||||
tick: Callable[[AsyncSession], Awaitable[None]],
|
||||
) -> None:
|
||||
for attempt in range(1, TARIFF_WORKER_DB_RETRY_ATTEMPTS + 1):
|
||||
async with self.session_factory() as session:
|
||||
try:
|
||||
await acquire_subscription_background_sync_lock(session)
|
||||
await tick(session)
|
||||
await session.commit()
|
||||
return
|
||||
except Exception as exc:
|
||||
await session.rollback()
|
||||
if (
|
||||
attempt < TARIFF_WORKER_DB_RETRY_ATTEMPTS
|
||||
and self._is_retryable_db_exception(exc)
|
||||
):
|
||||
delay = TARIFF_WORKER_DB_RETRY_BASE_SLEEP_SECONDS * attempt
|
||||
logging.warning(
|
||||
"TariffTrafficWorker %s retrying after database concurrency "
|
||||
"error, attempt %s/%s: %s",
|
||||
tick_name,
|
||||
attempt + 1,
|
||||
TARIFF_WORKER_DB_RETRY_ATTEMPTS,
|
||||
exc,
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
continue
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def _is_retryable_db_exception(exc: BaseException) -> bool:
|
||||
pending: list[BaseException] = [exc]
|
||||
seen: set[int] = set()
|
||||
while pending:
|
||||
current = pending.pop()
|
||||
current_id = id(current)
|
||||
if current_id in seen:
|
||||
continue
|
||||
seen.add(current_id)
|
||||
|
||||
sqlstate = getattr(current, "sqlstate", None) or getattr(current, "pgcode", None)
|
||||
if sqlstate in POSTGRES_RETRYABLE_SQLSTATES:
|
||||
return True
|
||||
|
||||
error_name = type(current).__name__
|
||||
message = str(current).lower()
|
||||
if (
|
||||
error_name in POSTGRES_RETRYABLE_ERROR_NAMES
|
||||
or "deadlock detected" in message
|
||||
or "could not serialize access" in message
|
||||
):
|
||||
return True
|
||||
|
||||
for attr in ("orig", "__cause__", "__context__"):
|
||||
nested = getattr(current, attr, None)
|
||||
if isinstance(nested, BaseException):
|
||||
pending.append(nested)
|
||||
|
||||
return False
|
||||
|
||||
async def traffic_period_tick(self, session: AsyncSession) -> None:
|
||||
now = datetime.now(timezone.utc)
|
||||
self._premium_node_usage_tick_cache = {}
|
||||
warning_period_start = month_start(now)
|
||||
result = await session.execute(
|
||||
select(Subscription).where(
|
||||
select(Subscription)
|
||||
.where(
|
||||
Subscription.is_active == True,
|
||||
Subscription.end_date > now,
|
||||
Subscription.tariff_key.is_not(None),
|
||||
)
|
||||
.order_by(Subscription.subscription_id.asc())
|
||||
)
|
||||
subs = list(result.scalars().all())
|
||||
if not subs:
|
||||
@@ -454,6 +562,8 @@ class TariffTrafficWorker:
|
||||
return
|
||||
ratio = used_val / limit_val
|
||||
levels = list(getattr(self.settings, "tariff_traffic_warning_levels", [85, 90, 95]))
|
||||
if 100 not in levels:
|
||||
levels.append(100)
|
||||
for level in levels:
|
||||
threshold = level / 100
|
||||
if ratio < threshold:
|
||||
@@ -474,30 +584,41 @@ class TariffTrafficWorker:
|
||||
level=level,
|
||||
traffic_limit_bytes=limit_val if tariff.billing_model == "traffic" else None,
|
||||
)
|
||||
user_lang = await self._user_lang(session, sub.user_id)
|
||||
_ = (
|
||||
(lambda k, **kw: self.i18n.gettext(user_lang, k, **kw))
|
||||
if self.i18n
|
||||
else (lambda k, **kw: k)
|
||||
)
|
||||
left_pct = max(0, 100 - level)
|
||||
tariff_name = hd.quote(str(tariff.name(user_lang)))
|
||||
usage = self._usage_placeholders(used_val, limit_val)
|
||||
if level < 100:
|
||||
text = _(
|
||||
"traffic_warning_regular_almost",
|
||||
tariff_name=tariff_name,
|
||||
left_pct=left_pct,
|
||||
**usage,
|
||||
)
|
||||
subject_key = "email_traffic_warning_regular_almost_subject"
|
||||
else:
|
||||
text = _(
|
||||
"traffic_warning_regular_depleted",
|
||||
tariff_name=tariff_name,
|
||||
**usage,
|
||||
)
|
||||
subject_key = "email_traffic_warning_regular_depleted_subject"
|
||||
warning_key = (
|
||||
"traffic_warning_regular_almost"
|
||||
if level < 100
|
||||
else "traffic_warning_regular_depleted"
|
||||
)
|
||||
audit_content = (
|
||||
f"kind=regular warning_key={warning_key} level={level} "
|
||||
f"used_bytes={used_val} limit_bytes={limit_val}"
|
||||
)
|
||||
if self.bot:
|
||||
try:
|
||||
user_lang = await self._user_lang(session, sub.user_id)
|
||||
_ = (
|
||||
(lambda k, **kw: self.i18n.gettext(user_lang, k, **kw))
|
||||
if self.i18n
|
||||
else (lambda k, **kw: k)
|
||||
)
|
||||
left_pct = max(0, 100 - level)
|
||||
tariff_name = hd.quote(str(tariff.name(user_lang)))
|
||||
usage = self._usage_placeholders(used_val, limit_val)
|
||||
if level < 100:
|
||||
text = _(
|
||||
"traffic_warning_regular_almost",
|
||||
tariff_name=tariff_name,
|
||||
left_pct=left_pct,
|
||||
**usage,
|
||||
)
|
||||
else:
|
||||
text = _(
|
||||
"traffic_warning_regular_depleted",
|
||||
tariff_name=tariff_name,
|
||||
**usage,
|
||||
)
|
||||
markup = self._traffic_topup_markup(user_lang, "regular")
|
||||
await self.bot.send_message(
|
||||
sub.user_id,
|
||||
@@ -505,8 +626,25 @@ class TariffTrafficWorker:
|
||||
reply_markup=markup,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await log_user_message_delivery(
|
||||
session,
|
||||
target_user_id=sub.user_id,
|
||||
event_type="telegram_traffic_warning_sent",
|
||||
channel="telegram",
|
||||
recipient=str(sub.user_id),
|
||||
content=audit_content,
|
||||
)
|
||||
except Exception:
|
||||
logging.exception("Failed to send traffic warning to user %s", sub.user_id)
|
||||
await self._send_traffic_warning_email(
|
||||
session,
|
||||
user_id=sub.user_id,
|
||||
subject_key=subject_key,
|
||||
message_text=text,
|
||||
kind="regular",
|
||||
warning_key=warning_key,
|
||||
audit_content=audit_content,
|
||||
)
|
||||
if ratio >= 1.0 and not sub.is_throttled:
|
||||
logging.info(
|
||||
"Tariff traffic limit reached for user %s subscription %s. "
|
||||
@@ -930,31 +1068,36 @@ class TariffTrafficWorker:
|
||||
level=PREMIUM_WARNING_DEPLETED_LEVEL,
|
||||
traffic_limit_bytes=None,
|
||||
)
|
||||
user_lang = await self._user_lang(session, sub.user_id)
|
||||
_ = (
|
||||
(lambda k, **kw: self.i18n.gettext(user_lang, k, **kw))
|
||||
if self.i18n
|
||||
else (lambda k, **kw: k)
|
||||
)
|
||||
access = await self.subscription_service.premium_access_for_tariff(tariff)
|
||||
labels = access.get("node_labels") or access.get("squad_labels") or []
|
||||
if labels:
|
||||
visible = [hd.quote(str(x)) for x in labels[:8]]
|
||||
servers = "\n".join(f"• {label}" for label in visible)
|
||||
if len(labels) > len(visible):
|
||||
more = len(labels) - len(visible)
|
||||
servers += "\n" + _("traffic_warning_premium_servers_more", count=more)
|
||||
else:
|
||||
servers = _("traffic_warning_premium_generic_servers")
|
||||
usage = self._usage_placeholders(used_val, limit_val)
|
||||
text = _(
|
||||
"traffic_warning_premium_depleted",
|
||||
tariff_name=hd.quote(str(tariff.name(user_lang))),
|
||||
servers=servers,
|
||||
**usage,
|
||||
)
|
||||
warning_key = "traffic_warning_premium_depleted"
|
||||
audit_content = (
|
||||
f"kind=premium warning_key={warning_key} "
|
||||
f"used_bytes={used_val} limit_bytes={limit_val}"
|
||||
)
|
||||
if self.bot:
|
||||
try:
|
||||
user_lang = await self._user_lang(session, sub.user_id)
|
||||
_ = (
|
||||
(lambda k, **kw: self.i18n.gettext(user_lang, k, **kw))
|
||||
if self.i18n
|
||||
else (lambda k, **kw: k)
|
||||
)
|
||||
access = await self.subscription_service.premium_access_for_tariff(tariff)
|
||||
labels = access.get("node_labels") or access.get("squad_labels") or []
|
||||
if labels:
|
||||
visible = [hd.quote(str(x)) for x in labels[:8]]
|
||||
servers = "\n".join(f"• {label}" for label in visible)
|
||||
if len(labels) > len(visible):
|
||||
more = len(labels) - len(visible)
|
||||
servers += "\n" + _("traffic_warning_premium_servers_more", count=more)
|
||||
else:
|
||||
servers = _("traffic_warning_premium_generic_servers")
|
||||
usage = self._usage_placeholders(used_val, limit_val)
|
||||
text = _(
|
||||
"traffic_warning_premium_depleted",
|
||||
tariff_name=hd.quote(str(tariff.name(user_lang))),
|
||||
servers=servers,
|
||||
**usage,
|
||||
)
|
||||
markup = self._traffic_topup_markup(user_lang, "premium")
|
||||
await self.bot.send_message(
|
||||
sub.user_id,
|
||||
@@ -962,10 +1105,27 @@ class TariffTrafficWorker:
|
||||
reply_markup=markup,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await log_user_message_delivery(
|
||||
session,
|
||||
target_user_id=sub.user_id,
|
||||
event_type="telegram_traffic_warning_sent",
|
||||
channel="telegram",
|
||||
recipient=str(sub.user_id),
|
||||
content=audit_content,
|
||||
)
|
||||
except Exception:
|
||||
logging.exception(
|
||||
"Failed to send premium traffic depleted warning to user %s", sub.user_id
|
||||
)
|
||||
await self._send_traffic_warning_email(
|
||||
session,
|
||||
user_id=sub.user_id,
|
||||
subject_key="email_traffic_warning_premium_depleted_subject",
|
||||
message_text=text,
|
||||
kind="premium",
|
||||
warning_key=warning_key,
|
||||
audit_content=audit_content,
|
||||
)
|
||||
return
|
||||
|
||||
for level in levels:
|
||||
@@ -989,43 +1149,66 @@ class TariffTrafficWorker:
|
||||
level=storage_level,
|
||||
traffic_limit_bytes=None,
|
||||
)
|
||||
if not self.bot:
|
||||
continue
|
||||
try:
|
||||
user_lang = await self._user_lang(session, sub.user_id)
|
||||
_ = (
|
||||
(lambda k, **kw: self.i18n.gettext(user_lang, k, **kw))
|
||||
if self.i18n
|
||||
else (lambda k, **kw: k)
|
||||
)
|
||||
access = await self.subscription_service.premium_access_for_tariff(tariff)
|
||||
labels = access.get("node_labels") or access.get("squad_labels") or []
|
||||
if labels:
|
||||
visible = [hd.quote(str(x)) for x in labels[:8]]
|
||||
servers = "\n".join(f"• {label}" for label in visible)
|
||||
if len(labels) > len(visible):
|
||||
more = len(labels) - len(visible)
|
||||
servers += "\n" + _("traffic_warning_premium_servers_more", count=more)
|
||||
else:
|
||||
servers = _("traffic_warning_premium_generic_servers")
|
||||
left_pct = max(0, 100 - int(level))
|
||||
usage = self._usage_placeholders(used_val, limit_val)
|
||||
text = _(
|
||||
"traffic_warning_premium_almost",
|
||||
tariff_name=hd.quote(str(tariff.name(user_lang))),
|
||||
left_pct=left_pct,
|
||||
servers=servers,
|
||||
**usage,
|
||||
)
|
||||
markup = self._traffic_topup_markup(user_lang, "premium")
|
||||
await self.bot.send_message(
|
||||
sub.user_id,
|
||||
text,
|
||||
reply_markup=markup,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
except Exception:
|
||||
logging.exception("Failed to send premium traffic warning to user %s", sub.user_id)
|
||||
user_lang = await self._user_lang(session, sub.user_id)
|
||||
_ = (
|
||||
(lambda k, **kw: self.i18n.gettext(user_lang, k, **kw))
|
||||
if self.i18n
|
||||
else (lambda k, **kw: k)
|
||||
)
|
||||
access = await self.subscription_service.premium_access_for_tariff(tariff)
|
||||
labels = access.get("node_labels") or access.get("squad_labels") or []
|
||||
if labels:
|
||||
visible = [hd.quote(str(x)) for x in labels[:8]]
|
||||
servers = "\n".join(f"• {label}" for label in visible)
|
||||
if len(labels) > len(visible):
|
||||
more = len(labels) - len(visible)
|
||||
servers += "\n" + _("traffic_warning_premium_servers_more", count=more)
|
||||
else:
|
||||
servers = _("traffic_warning_premium_generic_servers")
|
||||
left_pct = max(0, 100 - int(level))
|
||||
usage = self._usage_placeholders(used_val, limit_val)
|
||||
text = _(
|
||||
"traffic_warning_premium_almost",
|
||||
tariff_name=hd.quote(str(tariff.name(user_lang))),
|
||||
left_pct=left_pct,
|
||||
servers=servers,
|
||||
**usage,
|
||||
)
|
||||
warning_key = "traffic_warning_premium_almost"
|
||||
audit_content = (
|
||||
f"kind=premium warning_key={warning_key} level={int(level)} "
|
||||
f"used_bytes={used_val} limit_bytes={limit_val}"
|
||||
)
|
||||
if self.bot:
|
||||
try:
|
||||
markup = self._traffic_topup_markup(user_lang, "premium")
|
||||
await self.bot.send_message(
|
||||
sub.user_id,
|
||||
text,
|
||||
reply_markup=markup,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await log_user_message_delivery(
|
||||
session,
|
||||
target_user_id=sub.user_id,
|
||||
event_type="telegram_traffic_warning_sent",
|
||||
channel="telegram",
|
||||
recipient=str(sub.user_id),
|
||||
content=audit_content,
|
||||
)
|
||||
except Exception:
|
||||
logging.exception(
|
||||
"Failed to send premium traffic warning to user %s", sub.user_id
|
||||
)
|
||||
await self._send_traffic_warning_email(
|
||||
session,
|
||||
user_id=sub.user_id,
|
||||
subject_key="email_traffic_warning_premium_almost_subject",
|
||||
message_text=text,
|
||||
kind="premium",
|
||||
warning_key=warning_key,
|
||||
audit_content=audit_content,
|
||||
)
|
||||
|
||||
async def _premium_node_uuids_for_tariff(self, tariff) -> list[str]:
|
||||
cache_key = tuple(sorted(tariff.premium_squad_uuids or []))
|
||||
@@ -1159,10 +1342,12 @@ class TariffTrafficWorker:
|
||||
from Internal Squads.
|
||||
"""
|
||||
result = await session.execute(
|
||||
select(Subscription).where(
|
||||
select(Subscription)
|
||||
.where(
|
||||
Subscription.is_active == True,
|
||||
Subscription.is_throttled == True,
|
||||
)
|
||||
.order_by(Subscription.subscription_id.asc())
|
||||
)
|
||||
for sub in result.scalars().all():
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from config.settings import Settings
|
||||
from db.dal import user_dal
|
||||
from db.models import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TELEGRAM_NOTIFICATIONS_UNKNOWN = "unknown"
|
||||
TELEGRAM_NOTIFICATIONS_ENABLED = "enabled"
|
||||
TELEGRAM_NOTIFICATIONS_NEEDS_START = "needs_start"
|
||||
TELEGRAM_NOTIFICATIONS_BLOCKED = "blocked"
|
||||
TELEGRAM_NOTIFICATION_STATUSES = {
|
||||
TELEGRAM_NOTIFICATIONS_UNKNOWN,
|
||||
TELEGRAM_NOTIFICATIONS_ENABLED,
|
||||
TELEGRAM_NOTIFICATIONS_NEEDS_START,
|
||||
TELEGRAM_NOTIFICATIONS_BLOCKED,
|
||||
}
|
||||
|
||||
|
||||
def normalize_telegram_notification_status(value: Optional[str]) -> str:
|
||||
status = str(value or "").strip().lower()
|
||||
return status if status in TELEGRAM_NOTIFICATION_STATUSES else TELEGRAM_NOTIFICATIONS_UNKNOWN
|
||||
|
||||
|
||||
def telegram_notifications_enabled(user: Optional[User]) -> bool:
|
||||
return (
|
||||
bool(getattr(user, "telegram_id", None))
|
||||
and normalize_telegram_notification_status(
|
||||
getattr(user, "telegram_notifications_status", None)
|
||||
)
|
||||
== TELEGRAM_NOTIFICATIONS_ENABLED
|
||||
)
|
||||
|
||||
|
||||
def telegram_notifications_need_prompt(user: Optional[User]) -> bool:
|
||||
status = normalize_telegram_notification_status(
|
||||
getattr(user, "telegram_notifications_status", None)
|
||||
)
|
||||
return bool(getattr(user, "telegram_id", None)) and status in {
|
||||
TELEGRAM_NOTIFICATIONS_NEEDS_START,
|
||||
TELEGRAM_NOTIFICATIONS_BLOCKED,
|
||||
}
|
||||
|
||||
|
||||
def telegram_notifications_start_link(bot_username: Optional[str]) -> Optional[str]:
|
||||
username = str(bot_username or "").strip().lstrip("@")
|
||||
if not username or username == "your_bot_username":
|
||||
return None
|
||||
return f"https://t.me/{username}?start=notifications"
|
||||
|
||||
|
||||
def telegram_notification_status_from_error(exc: Exception) -> Optional[str]:
|
||||
if isinstance(exc, TelegramForbiddenError):
|
||||
return TELEGRAM_NOTIFICATIONS_BLOCKED
|
||||
if not isinstance(exc, TelegramBadRequest):
|
||||
return None
|
||||
|
||||
message = str(exc).lower()
|
||||
if any(
|
||||
token in message
|
||||
for token in (
|
||||
"bot was blocked",
|
||||
"user is deactivated",
|
||||
"forbidden",
|
||||
)
|
||||
):
|
||||
return TELEGRAM_NOTIFICATIONS_BLOCKED
|
||||
if any(
|
||||
token in message
|
||||
for token in (
|
||||
"chat not found",
|
||||
"bot can't initiate conversation",
|
||||
"bot can't initiate",
|
||||
"user not found",
|
||||
)
|
||||
):
|
||||
return TELEGRAM_NOTIFICATIONS_NEEDS_START
|
||||
return None
|
||||
|
||||
|
||||
async def mark_telegram_notifications_status(
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
status: str,
|
||||
*,
|
||||
telegram_id: Optional[int] = None,
|
||||
checked_at: Optional[datetime] = None,
|
||||
) -> Optional[User]:
|
||||
normalized = normalize_telegram_notification_status(status)
|
||||
now = checked_at or datetime.now(timezone.utc)
|
||||
update_data: dict[str, Any] = {
|
||||
"telegram_notifications_status": normalized,
|
||||
"telegram_notifications_checked_at": now,
|
||||
}
|
||||
if telegram_id:
|
||||
update_data["telegram_id"] = int(telegram_id)
|
||||
if normalized == TELEGRAM_NOTIFICATIONS_ENABLED:
|
||||
update_data["telegram_notifications_enabled_at"] = now
|
||||
update_data["telegram_notifications_blocked_at"] = None
|
||||
elif normalized == TELEGRAM_NOTIFICATIONS_BLOCKED:
|
||||
update_data["telegram_notifications_blocked_at"] = now
|
||||
return await user_dal.update_user(session, user_id, update_data)
|
||||
|
||||
|
||||
async def mark_telegram_notifications_enabled_for_telegram_user(
|
||||
session: AsyncSession,
|
||||
telegram_id: int,
|
||||
) -> Optional[User]:
|
||||
db_user = await user_dal.get_user_by_telegram_id(session, telegram_id)
|
||||
if not db_user:
|
||||
db_user = await user_dal.get_user_by_id(session, telegram_id)
|
||||
if not db_user:
|
||||
return None
|
||||
return await mark_telegram_notifications_status(
|
||||
session,
|
||||
int(db_user.user_id),
|
||||
TELEGRAM_NOTIFICATIONS_ENABLED,
|
||||
telegram_id=telegram_id,
|
||||
)
|
||||
|
||||
|
||||
async def probe_telegram_notifications(
|
||||
*,
|
||||
session: AsyncSession,
|
||||
bot: Bot,
|
||||
settings: Settings,
|
||||
i18n: Optional[JsonI18n],
|
||||
user: User,
|
||||
bot_username: Optional[str] = None,
|
||||
force: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
telegram_id = getattr(user, "telegram_id", None)
|
||||
if not telegram_id:
|
||||
return {
|
||||
"ok": False,
|
||||
"status": TELEGRAM_NOTIFICATIONS_UNKNOWN,
|
||||
"start_link": telegram_notifications_start_link(bot_username),
|
||||
}
|
||||
|
||||
current_status = normalize_telegram_notification_status(
|
||||
getattr(user, "telegram_notifications_status", None)
|
||||
)
|
||||
if current_status == TELEGRAM_NOTIFICATIONS_ENABLED and not force:
|
||||
return {
|
||||
"ok": True,
|
||||
"status": TELEGRAM_NOTIFICATIONS_ENABLED,
|
||||
"start_link": telegram_notifications_start_link(bot_username),
|
||||
}
|
||||
|
||||
try:
|
||||
await bot.get_chat(int(telegram_id))
|
||||
except Exception as exc:
|
||||
status = telegram_notification_status_from_error(exc)
|
||||
if status:
|
||||
await mark_telegram_notifications_status(session, int(user.user_id), status)
|
||||
return {
|
||||
"ok": False,
|
||||
"status": status,
|
||||
"start_link": telegram_notifications_start_link(bot_username),
|
||||
}
|
||||
logger.warning(
|
||||
"Telegram notification chat probe failed for user %s / telegram %s: %s",
|
||||
user.user_id,
|
||||
telegram_id,
|
||||
exc,
|
||||
)
|
||||
await mark_telegram_notifications_status(
|
||||
session,
|
||||
int(user.user_id),
|
||||
TELEGRAM_NOTIFICATIONS_UNKNOWN,
|
||||
)
|
||||
return {
|
||||
"ok": False,
|
||||
"status": TELEGRAM_NOTIFICATIONS_UNKNOWN,
|
||||
"start_link": telegram_notifications_start_link(bot_username),
|
||||
}
|
||||
|
||||
await mark_telegram_notifications_status(
|
||||
session,
|
||||
int(user.user_id),
|
||||
TELEGRAM_NOTIFICATIONS_ENABLED,
|
||||
telegram_id=int(telegram_id),
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"status": TELEGRAM_NOTIFICATIONS_ENABLED,
|
||||
"start_link": telegram_notifications_start_link(bot_username),
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
"""Anonymous install telemetry beacon (self-hosted friendly, opt-out).
|
||||
|
||||
Once per ``TELEMETRY_INTERVAL_HOURS`` the worker sends a single obfuscation-free
|
||||
but fully anonymous "heartbeat" to a PostHog ingestion endpoint so the project
|
||||
maintainer can see how many installs are active and which versions/OSes are in
|
||||
use. No personal data, bot tokens, domains or user identities are sent — only
|
||||
an opaque per-install UUID plus coarse environment facts.
|
||||
|
||||
Operators can opt out in three independent ways, any of which stops the beacon:
|
||||
* ``TELEMETRY_ENABLED=false`` in ``.env``
|
||||
* the *System → Anonymous install analytics* toggle in the web admin (stored
|
||||
as a DB override and re-read every tick, so no restart is required)
|
||||
* leaving ``TELEMETRY_ENDPOINT`` / ``TELEMETRY_API_KEY`` empty in the image
|
||||
|
||||
Delivery is strictly fire-and-forget: every failure is swallowed so telemetry
|
||||
can never delay, block or crash the worker.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import platform
|
||||
import uuid
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import aiohttp
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from bot.infra.redis import redis_lock
|
||||
from bot.utils.app_version import resolve_app_version, resolve_app_version_tag
|
||||
from config.settings import Settings
|
||||
from db.dal import app_settings_dal, user_dal
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
INSTALLATION_ID_KEY = "TELEMETRY_INSTALLATION_ID"
|
||||
TELEMETRY_ENABLED_KEY = "TELEMETRY_ENABLED"
|
||||
HEARTBEAT_EVENT = "installation_heartbeat"
|
||||
INITIAL_DELAY_SECONDS = 300
|
||||
HTTP_TIMEOUT_SECONDS = 10
|
||||
|
||||
# Report the user count as a coarse range so individual installs stay anonymous
|
||||
# and the property keeps a low cardinality for breakdowns.
|
||||
_USER_BUCKETS = (
|
||||
(0, "0"),
|
||||
(10, "1-10"),
|
||||
(50, "11-50"),
|
||||
(200, "51-200"),
|
||||
(1000, "201-1000"),
|
||||
(5000, "1001-5000"),
|
||||
)
|
||||
|
||||
|
||||
def _bucket_users(count: int) -> str:
|
||||
for upper, label in _USER_BUCKETS:
|
||||
if count <= upper:
|
||||
return label
|
||||
return "5000+"
|
||||
|
||||
|
||||
class TelemetryWorker:
|
||||
def __init__(self, settings: Settings, session_factory: sessionmaker):
|
||||
self.settings = settings
|
||||
self.session_factory = session_factory
|
||||
self._stopped = asyncio.Event()
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stopped.set()
|
||||
|
||||
def _delivery_configured(self) -> bool:
|
||||
return bool(
|
||||
str(self.settings.TELEMETRY_ENDPOINT or "").strip()
|
||||
and str(self.settings.TELEMETRY_API_KEY or "").strip()
|
||||
)
|
||||
|
||||
async def run(self) -> None:
|
||||
if not self._delivery_configured():
|
||||
logger.info("Telemetry endpoint/key not configured; anonymous beacon disabled")
|
||||
return
|
||||
logger.info(
|
||||
"Anonymous install telemetry is ON (endpoint=%s, every %sh). "
|
||||
"It sends an opaque install id, version, OS and a user-count range — "
|
||||
"no personal data. Opt out via TELEMETRY_ENABLED=false or "
|
||||
"Admin -> System -> Anonymous install analytics. "
|
||||
"See docs/configuration/telemetry.md.",
|
||||
self.settings.TELEMETRY_ENDPOINT,
|
||||
self.settings.TELEMETRY_INTERVAL_HOURS,
|
||||
)
|
||||
await self._sleep(INITIAL_DELAY_SECONDS)
|
||||
while not self._stopped.is_set():
|
||||
try:
|
||||
await self._beacon_tick()
|
||||
except Exception:
|
||||
logger.exception("Telemetry beacon tick failed")
|
||||
await self._sleep(self._interval_seconds())
|
||||
|
||||
def _interval_seconds(self) -> int:
|
||||
return max(1, int(self.settings.TELEMETRY_INTERVAL_HOURS or 24)) * 3600
|
||||
|
||||
async def _sleep(self, seconds: float) -> None:
|
||||
try:
|
||||
await asyncio.wait_for(self._stopped.wait(), timeout=seconds)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
|
||||
async def _beacon_tick(self) -> None:
|
||||
# A short-lived lock keeps a single beacon per interval even when the
|
||||
# worker is scaled to several replicas. Without Redis the lock yields
|
||||
# True, which is correct for the common single-worker deployment.
|
||||
async with redis_lock(
|
||||
self.settings,
|
||||
"telemetry-beacon",
|
||||
ttl_seconds=max(60, self._interval_seconds() // 2),
|
||||
) as acquired:
|
||||
if not acquired:
|
||||
return
|
||||
async with self.session_factory() as session:
|
||||
if not await self._is_enabled(session):
|
||||
return
|
||||
installation_id = await self._get_or_create_installation_id(session)
|
||||
payload = await self._build_payload(session, installation_id)
|
||||
await session.commit()
|
||||
await self._send(payload)
|
||||
|
||||
async def _is_enabled(self, session: AsyncSession) -> bool:
|
||||
# The web admin writes the toggle as a DB override. The worker process
|
||||
# does not apply overrides onto its in-memory Settings, so read it
|
||||
# straight from the table; the env default applies when unset.
|
||||
present, value = await app_settings_dal.get_override_value(session, TELEMETRY_ENABLED_KEY)
|
||||
if present:
|
||||
return bool(value)
|
||||
return bool(self.settings.TELEMETRY_ENABLED)
|
||||
|
||||
async def _get_or_create_installation_id(self, session: AsyncSession) -> str:
|
||||
present, value = await app_settings_dal.get_override_value(session, INSTALLATION_ID_KEY)
|
||||
if present and value:
|
||||
return str(value)
|
||||
installation_id = str(uuid.uuid4())
|
||||
await app_settings_dal.upsert_override(
|
||||
session,
|
||||
key=INSTALLATION_ID_KEY,
|
||||
value=installation_id,
|
||||
updated_by=None,
|
||||
)
|
||||
return installation_id
|
||||
|
||||
def _enabled_payment_providers(self) -> List[str]:
|
||||
try:
|
||||
from bot.payment_providers import iter_provider_specs
|
||||
|
||||
providers = [
|
||||
str(spec.id)
|
||||
for spec in iter_provider_specs()
|
||||
if spec.is_effectively_enabled(self.settings)
|
||||
]
|
||||
return sorted(set(providers))
|
||||
except Exception:
|
||||
logger.debug("Telemetry: failed to enumerate payment providers", exc_info=True)
|
||||
return []
|
||||
|
||||
async def _build_payload(self, session: AsyncSession, installation_id: str) -> Dict[str, Any]:
|
||||
try:
|
||||
user_count = await user_dal.count_all_users(session)
|
||||
except Exception:
|
||||
logger.debug("Telemetry: failed to count users", exc_info=True)
|
||||
user_count = 0
|
||||
|
||||
version = resolve_app_version()
|
||||
version_tag = resolve_app_version_tag()
|
||||
# Person properties (``$set``) snapshot the latest state per install, so
|
||||
# "version breakdown" in PostHog is a person-property breakdown.
|
||||
person_props = {
|
||||
"app_version": version,
|
||||
"app_version_tag": version_tag,
|
||||
"os": platform.system().lower() or "unknown",
|
||||
"arch": platform.machine().lower() or "unknown",
|
||||
"python_version": platform.python_version(),
|
||||
"locale": str(self.settings.DEFAULT_LANGUAGE or ""),
|
||||
"users_bucket": _bucket_users(int(user_count or 0)),
|
||||
"webapp_enabled": bool(self.settings.WEBAPP_ENABLED),
|
||||
"panel_configured": bool(str(self.settings.PANEL_API_URL or "").strip()),
|
||||
"payment_providers": self._enabled_payment_providers(),
|
||||
}
|
||||
properties = {
|
||||
**person_props,
|
||||
"$lib": "remnawave-minishop",
|
||||
"$lib_version": version,
|
||||
"$set": person_props,
|
||||
}
|
||||
return {
|
||||
"api_key": str(self.settings.TELEMETRY_API_KEY or "").strip(),
|
||||
"event": HEARTBEAT_EVENT,
|
||||
"distinct_id": installation_id,
|
||||
"properties": properties,
|
||||
}
|
||||
|
||||
async def _send(self, payload: Dict[str, Any]) -> None:
|
||||
url = str(self.settings.TELEMETRY_ENDPOINT or "").strip().rstrip("/") + "/capture/"
|
||||
timeout = aiohttp.ClientTimeout(total=HTTP_TIMEOUT_SECONDS)
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=timeout) as http:
|
||||
async with http.post(url, json=payload) as resp:
|
||||
if resp.status >= 400:
|
||||
body = (await resp.text())[:200]
|
||||
logger.warning("Telemetry beacon rejected: HTTP %s %s", resp.status, body)
|
||||
else:
|
||||
logger.debug("Telemetry beacon delivered (HTTP %s)", resp.status)
|
||||
except Exception:
|
||||
# Never let telemetry surface as an error to operators.
|
||||
logger.debug("Telemetry beacon delivery failed", exc_info=True)
|
||||
@@ -0,0 +1,99 @@
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.email_auth_service import EmailAuthService
|
||||
from bot.services.email_templates import render_user_notification
|
||||
from bot.services.message_audit import log_user_message_delivery
|
||||
from config.settings import Settings
|
||||
|
||||
|
||||
def _translate(
|
||||
i18n: Optional[JsonI18n],
|
||||
language: str,
|
||||
key: Optional[str],
|
||||
fallback: str = "",
|
||||
**kwargs: Any,
|
||||
) -> str:
|
||||
if not key:
|
||||
return fallback
|
||||
if not i18n:
|
||||
return fallback or key
|
||||
text = i18n.gettext(language, key, **kwargs)
|
||||
return fallback if text == key and fallback else text
|
||||
|
||||
|
||||
async def send_user_notification_email(
|
||||
*,
|
||||
settings: Settings,
|
||||
i18n: Optional[JsonI18n],
|
||||
user: Any,
|
||||
subject_key: str,
|
||||
message_text: str,
|
||||
dashboard_url: Optional[str] = None,
|
||||
cta_label_key: str = "email_user_notification_cta",
|
||||
subject_kwargs: Optional[dict[str, Any]] = None,
|
||||
heading_key: Optional[str] = None,
|
||||
intro_key: Optional[str] = None,
|
||||
session: Optional[AsyncSession] = None,
|
||||
audit_event_type: Optional[str] = None,
|
||||
audit_content: Optional[str] = None,
|
||||
) -> bool:
|
||||
if not getattr(settings, "email_auth_configured", False):
|
||||
return False
|
||||
recipient = str(getattr(user, "email", "") or "").strip()
|
||||
if not recipient:
|
||||
return False
|
||||
|
||||
language = (
|
||||
str(getattr(user, "language_code", "") or "").strip()
|
||||
or getattr(settings, "DEFAULT_LANGUAGE", "ru")
|
||||
or "ru"
|
||||
)
|
||||
kwargs = subject_kwargs or {}
|
||||
subject = _translate(i18n, language, subject_key, subject_key, **kwargs)
|
||||
heading = _translate(i18n, language, heading_key, subject, **kwargs)
|
||||
intro = _translate(
|
||||
i18n,
|
||||
language,
|
||||
intro_key or "email_user_notification_intro",
|
||||
"Notification from your account.",
|
||||
)
|
||||
cta_label = _translate(
|
||||
i18n,
|
||||
language,
|
||||
cta_label_key or "email_user_notification_cta",
|
||||
"Open dashboard",
|
||||
)
|
||||
|
||||
try:
|
||||
content = render_user_notification(
|
||||
settings,
|
||||
language_code=language,
|
||||
subject=subject,
|
||||
heading=heading,
|
||||
intro=intro,
|
||||
message_text=message_text,
|
||||
dashboard_url=dashboard_url,
|
||||
cta_label=cta_label,
|
||||
i18n=i18n,
|
||||
)
|
||||
await EmailAuthService(settings, i18n).send_rendered_email(
|
||||
email=recipient,
|
||||
content=content,
|
||||
)
|
||||
if session is not None and audit_event_type:
|
||||
await log_user_message_delivery(
|
||||
session,
|
||||
target_user_id=getattr(user, "user_id", None),
|
||||
event_type=audit_event_type,
|
||||
channel="email",
|
||||
recipient=recipient,
|
||||
content=audit_content or f"subject_key={subject_key}",
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
logging.exception("Failed to send user notification email to %s.", recipient)
|
||||
return False
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Single source of truth for the application version string.
|
||||
|
||||
Resolution order mirrors the Dockerfile build chain so the dev checkout and
|
||||
the runtime container agree on the value:
|
||||
|
||||
REMNAWAVE_MINISHOP_VERSION env > .build-version file > live ``git describe``
|
||||
> ``dev+unknown``
|
||||
|
||||
The same value powers the admin sidebar (web process) and the anonymous
|
||||
telemetry beacon (worker process), so "active installs" and version
|
||||
breakdowns line up across both.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
# ``/app`` in the container (parent of ``/app/backend``); repo root in dev.
|
||||
# Matches where the Dockerfile drops .build-version / .build-tag / .build-commit.
|
||||
APP_ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
_APP_VERSION_CACHE: Optional[str] = None
|
||||
|
||||
|
||||
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()
|
||||
|
||||
|
||||
def _normalize_version_branch(raw_branch: str) -> str:
|
||||
branch = str(raw_branch or "").strip()
|
||||
for prefix in ("refs/heads/", "refs/remotes/origin/", "origin/"):
|
||||
if branch.startswith(prefix):
|
||||
branch = branch[len(prefix) :]
|
||||
break
|
||||
if branch in ("", "HEAD"):
|
||||
return ""
|
||||
return re.sub(r"[^A-Za-z0-9._-]+", "-", branch).strip("-")[:48]
|
||||
|
||||
|
||||
def _resolve_version_branch() -> str:
|
||||
for env_name in (
|
||||
"REMNAWAVE_MINISHOP_BRANCH",
|
||||
"GIT_BRANCH",
|
||||
"BRANCH_NAME",
|
||||
"GITHUB_REF_NAME",
|
||||
"CI_COMMIT_REF_NAME",
|
||||
):
|
||||
branch = _normalize_version_branch(os.getenv(env_name, ""))
|
||||
if branch:
|
||||
return branch
|
||||
return _normalize_version_branch(
|
||||
_run_git_command("branch", "--show-current")
|
||||
or _run_git_command("symbolic-ref", "--quiet", "--short", "HEAD")
|
||||
)
|
||||
|
||||
|
||||
def _format_app_version(tag: str, sha: str, branch: str) -> str:
|
||||
branch_suffix = "" if not branch or branch == "main" else f"-{branch}"
|
||||
if tag and sha:
|
||||
return f"{tag}{branch_suffix}+g{sha}"
|
||||
if sha:
|
||||
return f"dev{branch_suffix}+g{sha}"
|
||||
if tag:
|
||||
return f"{tag}{branch_suffix}"
|
||||
return f"dev{branch_suffix}+unknown"
|
||||
|
||||
|
||||
def _read_build_file(name: str) -> str:
|
||||
try:
|
||||
return (APP_ROOT / name).read_text(encoding="utf-8").strip()
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
|
||||
def resolve_app_version() -> str:
|
||||
"""Full version string (cached), e.g. ``v3.4.6+gabc1234``."""
|
||||
global _APP_VERSION_CACHE
|
||||
if _APP_VERSION_CACHE:
|
||||
return _APP_VERSION_CACHE
|
||||
|
||||
env_version = os.getenv("REMNAWAVE_MINISHOP_VERSION", "").strip()
|
||||
if env_version:
|
||||
_APP_VERSION_CACHE = env_version
|
||||
return env_version
|
||||
|
||||
build_version = _read_build_file(".build-version")
|
||||
if build_version:
|
||||
_APP_VERSION_CACHE = build_version
|
||||
return build_version
|
||||
|
||||
tag = _run_git_command("describe", "--tags", "--abbrev=0")
|
||||
sha = _run_git_command("rev-parse", "--short", "HEAD")
|
||||
branch = _resolve_version_branch()
|
||||
version = _format_app_version(tag, sha, branch)
|
||||
_APP_VERSION_CACHE = version
|
||||
return version
|
||||
|
||||
|
||||
def resolve_app_version_tag() -> str:
|
||||
"""Clean release tag for low-cardinality breakdowns, e.g. ``v3.4.6``.
|
||||
|
||||
Prefers the build-time ``.build-tag`` artifact, then a live ``git
|
||||
describe``; falls back to the full version string when no tag is known.
|
||||
"""
|
||||
build_tag = _read_build_file(".build-tag")
|
||||
if build_tag and build_tag != "unknown":
|
||||
return build_tag
|
||||
tag = _run_git_command("describe", "--tags", "--abbrev=0")
|
||||
if tag:
|
||||
return tag
|
||||
return resolve_app_version()
|
||||
@@ -0,0 +1,40 @@
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def normalize_required_channel_id(value: object) -> Optional[int]:
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
raw = str(value).strip()
|
||||
if not raw:
|
||||
return None
|
||||
|
||||
try:
|
||||
channel_id = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
if channel_id == 0:
|
||||
return None
|
||||
|
||||
if channel_id > 0:
|
||||
return int(f"-100{channel_id}")
|
||||
|
||||
raw_abs = str(abs(channel_id))
|
||||
if raw.startswith("-100"):
|
||||
return channel_id
|
||||
if abs(channel_id) < 1_000_000_000:
|
||||
return channel_id
|
||||
return -int(f"100{raw_abs}")
|
||||
|
||||
|
||||
def is_required_channel_access_error(error: BaseException) -> bool:
|
||||
message = str(error).lower()
|
||||
configuration_markers = (
|
||||
"chat not found",
|
||||
"bot is not a member",
|
||||
"not enough rights",
|
||||
"have no rights",
|
||||
"kicked",
|
||||
)
|
||||
return any(marker in message for marker in configuration_markers)
|
||||
@@ -214,10 +214,10 @@ class Settings(BaseSettings):
|
||||
MONTH_6_ENABLED: bool = Field(default=True, alias="6_MONTHS_ENABLED")
|
||||
MONTH_12_ENABLED: bool = Field(default=True, alias="12_MONTHS_ENABLED")
|
||||
|
||||
RUB_PRICE_1_MONTH: Optional[int] = Field(default=None)
|
||||
RUB_PRICE_3_MONTHS: Optional[int] = Field(default=None)
|
||||
RUB_PRICE_6_MONTHS: Optional[int] = Field(default=None)
|
||||
RUB_PRICE_12_MONTHS: Optional[int] = Field(default=None)
|
||||
RUB_PRICE_1_MONTH: Optional[int] = Field(default=200)
|
||||
RUB_PRICE_3_MONTHS: Optional[int] = Field(default=600)
|
||||
RUB_PRICE_6_MONTHS: Optional[int] = Field(default=1200)
|
||||
RUB_PRICE_12_MONTHS: Optional[int] = Field(default=2400)
|
||||
|
||||
STARS_PRICE_1_MONTH: Optional[int] = Field(default=None)
|
||||
STARS_PRICE_3_MONTHS: Optional[int] = Field(default=None)
|
||||
@@ -240,6 +240,7 @@ class Settings(BaseSettings):
|
||||
)
|
||||
|
||||
SUBSCRIPTION_NOTIFICATIONS_ENABLED: bool = Field(default=True)
|
||||
SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED: bool = Field(default=True)
|
||||
SUBSCRIPTION_NOTIFY_ON_EXPIRE: bool = Field(default=True)
|
||||
SUBSCRIPTION_NOTIFY_AFTER_EXPIRE: bool = Field(default=True)
|
||||
SUBSCRIPTION_NOTIFY_DAYS_BEFORE: int = Field(default=3)
|
||||
@@ -286,6 +287,30 @@ class Settings(BaseSettings):
|
||||
description="Allow legacy referral links like ref_<telegram_id> to continue working. Defaults to True when unset.", # noqa: E501
|
||||
)
|
||||
|
||||
APP_RUNTIME_MODE: str = Field(
|
||||
default="production",
|
||||
description="Runtime profile: production, development, staging or test.",
|
||||
)
|
||||
PANEL_WRITE_MODE: str = Field(
|
||||
default="auto",
|
||||
description=(
|
||||
"Panel write behavior: auto uses dry-run in development/test runtimes, "
|
||||
"live always writes to Remnawave, dry_run validates and logs mutations only."
|
||||
),
|
||||
)
|
||||
PANEL_DRY_RUN_VALIDATE_REMOTE: bool = Field(
|
||||
default=True,
|
||||
description=(
|
||||
"When panel dry-run is enabled, validate referenced users and squads "
|
||||
"via live GET requests."
|
||||
),
|
||||
)
|
||||
PANEL_DRY_RUN_SYNTHETIC_CREATE: bool = Field(
|
||||
default=True,
|
||||
description=(
|
||||
"When panel dry-run is enabled, return synthetic users for create-user attempts."
|
||||
),
|
||||
)
|
||||
PANEL_API_URL: Optional[str] = None
|
||||
PANEL_API_KEY: Optional[str] = None
|
||||
USER_TRAFFIC_LIMIT_GB: Optional[float] = Field(default=0.0)
|
||||
@@ -559,6 +584,17 @@ class Settings(BaseSettings):
|
||||
ids = self.ADMIN_IDS
|
||||
return ids[0] if ids else None
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def panel_dry_run_enabled(self) -> bool:
|
||||
mode = str(self.PANEL_WRITE_MODE or "auto").strip().lower().replace("-", "_")
|
||||
if mode == "dry_run":
|
||||
return True
|
||||
if mode == "live":
|
||||
return False
|
||||
runtime = str(self.APP_RUNTIME_MODE or "production").strip().lower()
|
||||
return runtime in {"dev", "development", "local", "test", "testing"}
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def trial_traffic_limit_bytes(self) -> int:
|
||||
@@ -982,6 +1018,7 @@ class Settings(BaseSettings):
|
||||
"LOG_SUPPORT_THREAD_ID",
|
||||
"BACKUP_CHAT_ID",
|
||||
"BACKUP_THREAD_ID",
|
||||
"REQUIRED_CHANNEL_ID",
|
||||
mode="before",
|
||||
)
|
||||
@classmethod
|
||||
@@ -1024,6 +1061,28 @@ class Settings(BaseSettings):
|
||||
return None
|
||||
return v
|
||||
|
||||
@field_validator("APP_RUNTIME_MODE", mode="before")
|
||||
@classmethod
|
||||
def normalize_app_runtime_mode(cls, v):
|
||||
value = str(v or "production").strip().lower().replace("-", "_")
|
||||
if not value:
|
||||
return "production"
|
||||
aliases = {
|
||||
"prod": "production",
|
||||
"dev": "development",
|
||||
"local_dev": "development",
|
||||
"testing": "test",
|
||||
}
|
||||
return aliases.get(value, value)
|
||||
|
||||
@field_validator("PANEL_WRITE_MODE", mode="before")
|
||||
@classmethod
|
||||
def validate_panel_write_mode(cls, v):
|
||||
value = str(v or "auto").strip().lower().replace("-", "_")
|
||||
if value not in {"auto", "live", "dry_run"}:
|
||||
raise ValueError("PANEL_WRITE_MODE must be one of: auto, live, dry_run")
|
||||
return value
|
||||
|
||||
# Notification types
|
||||
LOG_NEW_USERS: bool = Field(
|
||||
default=True, description="Send notifications for new user registrations"
|
||||
@@ -1042,6 +1101,37 @@ class Settings(BaseSettings):
|
||||
)
|
||||
LOG_SUPPORT: bool = Field(default=True, description="Send support ticket notifications")
|
||||
|
||||
# Anonymous install telemetry (self-hosted friendly, opt-out).
|
||||
TELEMETRY_ENABLED: bool = Field(
|
||||
default=True,
|
||||
description=(
|
||||
"Send an anonymous daily install heartbeat (version, OS, locale, "
|
||||
"user-count range). No personal data. Opt out here, via the web "
|
||||
"admin, or by clearing TELEMETRY_ENDPOINT/TELEMETRY_API_KEY."
|
||||
),
|
||||
)
|
||||
TELEMETRY_ENDPOINT: str = Field(
|
||||
default="https://eu.i.posthog.com",
|
||||
description="PostHog ingestion host. Empty disables telemetry.",
|
||||
)
|
||||
TELEMETRY_API_KEY: str = Field(
|
||||
default="phc_sRiAbbrjhyYPfsgBwSZyLvujDXBLaDpmWKt6paGmCCMm",
|
||||
description=(
|
||||
"PostHog project API key (phc_...). Safe to ship in the image: it is "
|
||||
"a write-only ingest key. Empty disables telemetry."
|
||||
),
|
||||
)
|
||||
TELEMETRY_INTERVAL_HOURS: int = Field(default=24)
|
||||
|
||||
@property
|
||||
def telemetry_configured(self) -> bool:
|
||||
"""True when telemetry is enabled and has a delivery target."""
|
||||
return bool(
|
||||
self.TELEMETRY_ENABLED
|
||||
and str(self.TELEMETRY_ENDPOINT or "").strip()
|
||||
and str(self.TELEMETRY_API_KEY or "").strip()
|
||||
)
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env", env_file_encoding="utf-8", extra="ignore", populate_by_name=True
|
||||
)
|
||||
@@ -1065,6 +1155,11 @@ def get_settings() -> Settings:
|
||||
logging.warning(
|
||||
"CRITICAL: PANEL_API_URL is not set. Panel integration will not work."
|
||||
)
|
||||
if _settings_instance.panel_dry_run_enabled:
|
||||
logging.warning(
|
||||
"PANEL_WRITE_MODE dry-run is enabled: Remnawave write requests will be "
|
||||
"validated and logged without changing panel users."
|
||||
)
|
||||
if not os.getenv("WEBAPP_SESSION_SECRET"):
|
||||
logging.warning(
|
||||
"WEBAPP_SESSION_SECRET is not set. A generated secret will be used for this process only." # noqa: E501
|
||||
|
||||
@@ -1,14 +1,53 @@
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Literal, Optional
|
||||
from typing import Any, Dict, List, Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, Field, ValidationError, model_validator
|
||||
from pydantic import BaseModel, Field, RootModel, ValidationError, model_validator
|
||||
|
||||
Currency = Literal["rub", "stars"]
|
||||
DEFAULT_TARIFF_CURRENCY = "rub"
|
||||
STARS_TARIFF_CURRENCY = "stars"
|
||||
|
||||
Currency = str
|
||||
BillingModel = Literal["period", "traffic"]
|
||||
|
||||
|
||||
def normalize_currency_key(value: Any, default: str = DEFAULT_TARIFF_CURRENCY) -> str:
|
||||
text = str(value or "").strip().lower()
|
||||
if not text:
|
||||
return default
|
||||
aliases = {
|
||||
"rur": "rub",
|
||||
"xtr": STARS_TARIFF_CURRENCY,
|
||||
"star": STARS_TARIFF_CURRENCY,
|
||||
"stars": STARS_TARIFF_CURRENCY,
|
||||
}
|
||||
normalized = aliases.get(text, text)
|
||||
cleaned = "".join(ch for ch in normalized if ch.isalnum() or ch in {"_", "-"}).strip("_-")
|
||||
return cleaned or default
|
||||
|
||||
|
||||
def payment_currency_code(currency: Any, default: str = "RUB") -> str:
|
||||
key = normalize_currency_key(currency, default=normalize_currency_key(default))
|
||||
if key == STARS_TARIFF_CURRENCY:
|
||||
return "XTR"
|
||||
return key.upper()
|
||||
|
||||
|
||||
def default_currency_key_for_settings(settings: Any) -> str:
|
||||
try:
|
||||
config = getattr(settings, "tariffs_config", None)
|
||||
except Exception:
|
||||
config = None
|
||||
if config is not None and getattr(config, "default_currency", None):
|
||||
return normalize_currency_key(config.default_currency)
|
||||
return normalize_currency_key(getattr(settings, "DEFAULT_CURRENCY_SYMBOL", None))
|
||||
|
||||
|
||||
def default_payment_currency_code_for_settings(settings: Any) -> str:
|
||||
return payment_currency_code(default_currency_key_for_settings(settings))
|
||||
|
||||
|
||||
class TrafficPackage(BaseModel):
|
||||
gb: float
|
||||
price: float
|
||||
@@ -61,26 +100,76 @@ class HwidDevicePackage(BaseModel):
|
||||
return float(self.price) * months_int
|
||||
|
||||
|
||||
class PackageSet(BaseModel):
|
||||
rub: List[TrafficPackage] = Field(default_factory=list)
|
||||
stars: List[TrafficPackage] = Field(default_factory=list)
|
||||
class PackageSet(RootModel[Dict[str, List[TrafficPackage]]]):
|
||||
root: Dict[str, List[TrafficPackage]] = Field(default_factory=dict)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def normalize_input(cls, data: Any) -> Any:
|
||||
if data is None:
|
||||
return {}
|
||||
if not isinstance(data, dict):
|
||||
return data
|
||||
normalized: Dict[str, Any] = {}
|
||||
for currency, packages in data.items():
|
||||
key = normalize_currency_key(currency, default="")
|
||||
if not key:
|
||||
raise ValueError("package currency must not be empty")
|
||||
normalized[key] = packages or []
|
||||
return normalized
|
||||
|
||||
def for_currency(self, currency: Currency) -> List[TrafficPackage]:
|
||||
return list(getattr(self, currency) or [])
|
||||
return list(self.root.get(normalize_currency_key(currency), []) or [])
|
||||
|
||||
@property
|
||||
def rub(self) -> List[TrafficPackage]:
|
||||
return self.for_currency("rub")
|
||||
|
||||
@property
|
||||
def stars(self) -> List[TrafficPackage]:
|
||||
return self.for_currency("stars")
|
||||
|
||||
@property
|
||||
def non_stars_currencies(self) -> List[str]:
|
||||
return [
|
||||
currency for currency, packages in self.root.items() if currency != "stars" and packages
|
||||
]
|
||||
|
||||
def has_any(self) -> bool:
|
||||
return bool(self.rub or self.stars)
|
||||
return any(bool(packages) for packages in self.root.values())
|
||||
|
||||
|
||||
class HwidDevicePackageSet(BaseModel):
|
||||
rub: List[HwidDevicePackage] = Field(default_factory=list)
|
||||
stars: List[HwidDevicePackage] = Field(default_factory=list)
|
||||
class HwidDevicePackageSet(RootModel[Dict[str, List[HwidDevicePackage]]]):
|
||||
root: Dict[str, List[HwidDevicePackage]] = Field(default_factory=dict)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def normalize_input(cls, data: Any) -> Any:
|
||||
if data is None:
|
||||
return {}
|
||||
if not isinstance(data, dict):
|
||||
return data
|
||||
normalized: Dict[str, Any] = {}
|
||||
for currency, packages in data.items():
|
||||
key = normalize_currency_key(currency, default="")
|
||||
if not key:
|
||||
raise ValueError("device package currency must not be empty")
|
||||
normalized[key] = packages or []
|
||||
return normalized
|
||||
|
||||
def for_currency(self, currency: Currency) -> List[HwidDevicePackage]:
|
||||
return list(getattr(self, currency) or [])
|
||||
return list(self.root.get(normalize_currency_key(currency), []) or [])
|
||||
|
||||
@property
|
||||
def rub(self) -> List[HwidDevicePackage]:
|
||||
return self.for_currency("rub")
|
||||
|
||||
@property
|
||||
def stars(self) -> List[HwidDevicePackage]:
|
||||
return self.for_currency("stars")
|
||||
|
||||
def has_any(self) -> bool:
|
||||
return bool(self.rub or self.stars)
|
||||
return any(bool(packages) for packages in self.root.values())
|
||||
|
||||
|
||||
class Tariff(BaseModel):
|
||||
@@ -93,12 +182,16 @@ class Tariff(BaseModel):
|
||||
enabled: bool = True
|
||||
|
||||
monthly_gb: Optional[float] = None
|
||||
prices: Dict[str, Dict[str, float]] = Field(default_factory=dict)
|
||||
prices_rub: Dict[str, float] = Field(default_factory=dict)
|
||||
prices_stars: Dict[str, float] = Field(default_factory=dict)
|
||||
referral_bonus_days_inviter: Dict[str, int] = Field(default_factory=dict)
|
||||
referral_bonus_days_referee: Dict[str, int] = Field(default_factory=dict)
|
||||
enabled_periods: List[int] = Field(default_factory=list)
|
||||
topup_packages: Optional[PackageSet] = None
|
||||
|
||||
traffic_packages: Optional[PackageSet] = None
|
||||
conversion_rate_per_gb: Optional[float] = None
|
||||
conversion_rate_rub_per_gb: Optional[float] = None
|
||||
hwid_device_limit: Optional[int] = None
|
||||
hwid_device_packages: Optional[HwidDevicePackageSet] = None
|
||||
@@ -126,32 +219,107 @@ class Tariff(BaseModel):
|
||||
if self.premium_monthly_gb and self.premium_monthly_gb > 0 and not self.premium_squad_uuids:
|
||||
raise ValueError(f"tariff {self.key}: premium_monthly_gb requires premium_squad_uuids")
|
||||
|
||||
self.prices = self._normalize_prices_by_currency(self.prices)
|
||||
self.prices_rub = self._normalize_period_price_map(self.prices_rub, "prices_rub")
|
||||
self.prices_stars = self._normalize_period_price_map(
|
||||
self.prices_stars,
|
||||
"prices_stars",
|
||||
)
|
||||
if self.prices_rub:
|
||||
self.prices["rub"] = dict(self.prices_rub)
|
||||
elif self.prices.get("rub"):
|
||||
self.prices_rub = dict(self.prices["rub"])
|
||||
if self.prices_stars:
|
||||
self.prices["stars"] = dict(self.prices_stars)
|
||||
elif self.prices.get("stars"):
|
||||
self.prices_stars = dict(self.prices["stars"])
|
||||
|
||||
if self.conversion_rate_per_gb is None and self.conversion_rate_rub_per_gb is not None:
|
||||
self.conversion_rate_per_gb = float(self.conversion_rate_rub_per_gb)
|
||||
if self.conversion_rate_per_gb is not None and self.conversion_rate_per_gb <= 0:
|
||||
raise ValueError(f"traffic tariff {self.key}: conversion_rate_per_gb must be > 0")
|
||||
|
||||
if self.billing_model == "period":
|
||||
if self.monthly_gb is None or self.monthly_gb < 0:
|
||||
raise ValueError(f"period tariff {self.key}: monthly_gb must be >= 0")
|
||||
self.referral_bonus_days_inviter = self._normalize_referral_bonus_map(
|
||||
self.referral_bonus_days_inviter,
|
||||
"referral_bonus_days_inviter",
|
||||
)
|
||||
self.referral_bonus_days_referee = self._normalize_referral_bonus_map(
|
||||
self.referral_bonus_days_referee,
|
||||
"referral_bonus_days_referee",
|
||||
)
|
||||
if not self.enabled_periods:
|
||||
raise ValueError(f"period tariff {self.key}: enabled_periods is required")
|
||||
for months in self.enabled_periods:
|
||||
if months <= 0:
|
||||
raise ValueError(f"period tariff {self.key}: enabled periods must be positive")
|
||||
rub_price = self.prices_rub.get(str(months), 0) or 0
|
||||
stars_price = self.prices_stars.get(str(months), 0) or 0
|
||||
if rub_price <= 0 and stars_price <= 0:
|
||||
period_prices = [
|
||||
float(prices.get(str(months), 0) or 0) for prices in self.prices.values()
|
||||
]
|
||||
if not any(price > 0 for price in period_prices):
|
||||
raise ValueError(
|
||||
f"period tariff {self.key}: period {months} needs a non-zero rub or stars price" # noqa: E501
|
||||
f"period tariff {self.key}: period {months} needs a non-zero price"
|
||||
)
|
||||
return self
|
||||
|
||||
if not self.traffic_packages or not self.traffic_packages.has_any():
|
||||
raise ValueError(f"traffic tariff {self.key}: traffic_packages is required")
|
||||
if self.conversion_rate_rub_per_gb is not None and self.conversion_rate_rub_per_gb <= 0:
|
||||
raise ValueError(f"traffic tariff {self.key}: conversion_rate_rub_per_gb must be > 0")
|
||||
if not self.traffic_packages.rub and self.conversion_rate_rub_per_gb is None:
|
||||
if not self.traffic_packages.non_stars_currencies and self.conversion_rate_per_gb is None:
|
||||
raise ValueError(
|
||||
f"traffic tariff {self.key}: conversion_rate_rub_per_gb is required without RUB packages" # noqa: E501
|
||||
f"traffic tariff {self.key}: conversion_rate_per_gb is required without fiat packages" # noqa: E501
|
||||
)
|
||||
return self
|
||||
|
||||
def _normalize_period_price_map(
|
||||
self,
|
||||
values: Dict[str, float],
|
||||
field_name: str,
|
||||
) -> Dict[str, float]:
|
||||
normalized: Dict[str, float] = {}
|
||||
for period, value in (values or {}).items():
|
||||
try:
|
||||
months = int(float(str(period).strip()))
|
||||
price = float(value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError(f"tariff {self.key}: {field_name} contains invalid entry") from exc
|
||||
if months <= 0:
|
||||
raise ValueError(f"tariff {self.key}: {field_name} periods must be positive")
|
||||
if price < 0:
|
||||
raise ValueError(f"tariff {self.key}: {field_name} prices must be >= 0")
|
||||
normalized[str(months)] = price
|
||||
return normalized
|
||||
|
||||
def _normalize_prices_by_currency(
|
||||
self,
|
||||
values: Dict[str, Dict[str, float]],
|
||||
) -> Dict[str, Dict[str, float]]:
|
||||
normalized: Dict[str, Dict[str, float]] = {}
|
||||
for currency, price_map in (values or {}).items():
|
||||
key = normalize_currency_key(currency, default="")
|
||||
if not key:
|
||||
raise ValueError(f"tariff {self.key}: price currency must not be empty")
|
||||
normalized[key] = self._normalize_period_price_map(price_map or {}, f"prices.{key}")
|
||||
return normalized
|
||||
|
||||
def _normalize_referral_bonus_map(
|
||||
self, values: Dict[str, int], field_name: str
|
||||
) -> Dict[str, int]:
|
||||
normalized: Dict[str, int] = {}
|
||||
for period, days in (values or {}).items():
|
||||
try:
|
||||
months = int(float(str(period).strip()))
|
||||
bonus_days = int(float(days))
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError(f"tariff {self.key}: {field_name} contains invalid entry")
|
||||
if months <= 0:
|
||||
raise ValueError(f"tariff {self.key}: {field_name} periods must be positive")
|
||||
if bonus_days < 0:
|
||||
raise ValueError(f"tariff {self.key}: {field_name} days must be >= 0")
|
||||
normalized[str(months)] = bonus_days
|
||||
return normalized
|
||||
|
||||
def name(self, lang: str, fallback: str = "ru") -> str:
|
||||
return self.names.get(lang) or self.names.get(fallback) or self.key
|
||||
|
||||
@@ -169,28 +337,52 @@ class Tariff(BaseModel):
|
||||
return int(float(self.monthly_gb) * (1024**3))
|
||||
|
||||
def period_price(self, months: int, currency: Currency = "rub") -> Optional[float]:
|
||||
source = self.prices_rub if currency == "rub" else self.prices_stars
|
||||
source = self.prices.get(normalize_currency_key(currency), {})
|
||||
value = source.get(str(months))
|
||||
return float(value) if value is not None else None
|
||||
|
||||
def min_period_price_rub(self) -> Optional[float]:
|
||||
def referral_inviter_bonus_days(self, months: int) -> Optional[int]:
|
||||
value = self.referral_bonus_days_inviter.get(str(int(months)))
|
||||
return int(value) if value is not None else None
|
||||
|
||||
def referral_referee_bonus_days(self, months: int) -> Optional[int]:
|
||||
value = self.referral_bonus_days_referee.get(str(int(months)))
|
||||
return int(value) if value is not None else None
|
||||
|
||||
def min_period_price(self, currency: Currency = "rub") -> Optional[float]:
|
||||
key = normalize_currency_key(currency)
|
||||
source = self.prices.get(key, {})
|
||||
prices = [
|
||||
float(self.prices_rub[str(months)])
|
||||
float(source[str(months)])
|
||||
for months in self.enabled_periods
|
||||
if self.prices_rub.get(str(months), 0) and self.prices_rub.get(str(months), 0) > 0
|
||||
if source.get(str(months), 0) and source.get(str(months), 0) > 0
|
||||
]
|
||||
return min(prices) if prices else None
|
||||
|
||||
def min_traffic_package_rub(self) -> Optional[TrafficPackage]:
|
||||
packages = self.traffic_packages.rub if self.traffic_packages else []
|
||||
def min_period_price_rub(self) -> Optional[float]:
|
||||
return self.min_period_price("rub")
|
||||
|
||||
def min_traffic_package(self, currency: Currency = "rub") -> Optional[TrafficPackage]:
|
||||
packages = self.traffic_packages.for_currency(currency) if self.traffic_packages else []
|
||||
return min(packages, key=lambda pkg: pkg.price) if packages else None
|
||||
|
||||
def rub_per_gb_for_conversion(self) -> float:
|
||||
if self.conversion_rate_rub_per_gb:
|
||||
return float(self.conversion_rate_rub_per_gb)
|
||||
packages = self.traffic_packages.rub if self.traffic_packages else []
|
||||
def min_traffic_package_rub(self) -> Optional[TrafficPackage]:
|
||||
return self.min_traffic_package("rub")
|
||||
|
||||
def currency_per_gb_for_conversion(self, currency: Currency = "rub") -> float:
|
||||
if self.conversion_rate_per_gb:
|
||||
return float(self.conversion_rate_per_gb)
|
||||
packages = self.traffic_packages.for_currency(currency) if self.traffic_packages else []
|
||||
if not packages and self.traffic_packages:
|
||||
for key in self.traffic_packages.non_stars_currencies:
|
||||
packages = self.traffic_packages.for_currency(key)
|
||||
if packages:
|
||||
break
|
||||
return min(float(pkg.price) / float(pkg.gb) for pkg in packages)
|
||||
|
||||
def rub_per_gb_for_conversion(self) -> float:
|
||||
return self.currency_per_gb_for_conversion("rub")
|
||||
|
||||
def has_hwid_device_packages(self) -> bool:
|
||||
return bool(self.hwid_device_packages and self.hwid_device_packages.has_any())
|
||||
|
||||
@@ -209,11 +401,15 @@ class Tariff(BaseModel):
|
||||
|
||||
class TariffsConfig(BaseModel):
|
||||
default_tariff: str
|
||||
default_currency: str = DEFAULT_TARIFF_CURRENCY
|
||||
topup_packages_default: Optional[PackageSet] = None
|
||||
tariffs: List[Tariff]
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_config(self) -> "TariffsConfig":
|
||||
self.default_currency = normalize_currency_key(self.default_currency)
|
||||
if self.default_currency == STARS_TARIFF_CURRENCY:
|
||||
raise ValueError("default_currency must be a non-Stars payment currency")
|
||||
keys = [tariff.key for tariff in self.tariffs]
|
||||
if len(keys) != len(set(keys)):
|
||||
raise ValueError("tariff keys must be unique")
|
||||
@@ -242,6 +438,10 @@ class TariffsConfig(BaseModel):
|
||||
def default(self) -> Tariff:
|
||||
return self.require(self.default_tariff)
|
||||
|
||||
@property
|
||||
def default_payment_currency_code(self) -> str:
|
||||
return payment_currency_code(self.default_currency)
|
||||
|
||||
def topup_packages_for(self, tariff: Tariff) -> Optional[PackageSet]:
|
||||
if tariff.billing_model == "traffic":
|
||||
return tariff.traffic_packages
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
# Serializes background jobs that rewrite subscription rows from panel state.
|
||||
SUBSCRIPTION_BACKGROUND_SYNC_LOCK_ID = 817512404897421338
|
||||
|
||||
|
||||
async def acquire_subscription_background_sync_lock(session: AsyncSession) -> None:
|
||||
await session.execute(
|
||||
text("SELECT pg_advisory_xact_lock(:lock_id)"),
|
||||
{"lock_id": SUBSCRIPTION_BACKGROUND_SYNC_LOCK_ID},
|
||||
)
|
||||
@@ -4,12 +4,12 @@ import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from sqlalchemy import delete, func, or_, update
|
||||
from sqlalchemy import and_, delete, func, or_, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.future import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from db.models import Subscription, SubscriptionNotification
|
||||
from db.models import Subscription, SubscriptionNotification, User
|
||||
|
||||
INSTALL_SHARE_TOKEN_BYTES = 16
|
||||
|
||||
@@ -252,7 +252,7 @@ async def deactivate_all_user_subscriptions(session: AsyncSession, user_id: int)
|
||||
|
||||
|
||||
async def delete_all_user_subscriptions(session: AsyncSession, user_id: int) -> int:
|
||||
"""Completely delete all user subscriptions (for trial reset)"""
|
||||
"""Completely delete all user subscriptions."""
|
||||
stmt = delete(Subscription).where(Subscription.user_id == user_id)
|
||||
result = await session.execute(stmt)
|
||||
if result.rowcount > 0:
|
||||
@@ -284,6 +284,28 @@ async def has_any_subscription_for_user(session: AsyncSession, user_id: int) ->
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
|
||||
async def has_trial_blocking_subscription_for_user(session: AsyncSession, user_id: int) -> bool:
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
reset_at = (
|
||||
select(User.trial_eligibility_reset_at).where(User.user_id == user_id).scalar_subquery()
|
||||
)
|
||||
subscription_anchor = func.coalesce(Subscription.start_date, Subscription.end_date)
|
||||
stmt = (
|
||||
select(Subscription.subscription_id)
|
||||
.where(
|
||||
Subscription.user_id == user_id,
|
||||
or_(
|
||||
reset_at.is_(None),
|
||||
and_(Subscription.is_active == True, Subscription.end_date > now_utc),
|
||||
subscription_anchor > reset_at,
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
|
||||
async def get_subscriptions_near_expiration(
|
||||
session: AsyncSession, days_threshold: int
|
||||
) -> List[Subscription]:
|
||||
|
||||
+197
-14
@@ -95,6 +95,39 @@ async def get_user_by_id(session: AsyncSession, user_id: int) -> Optional[User]:
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_referrer_for_user(session: AsyncSession, user: User) -> Optional[User]:
|
||||
referred_by_id = getattr(user, "referred_by_id", None)
|
||||
if referred_by_id is None:
|
||||
return None
|
||||
return await get_user_by_id(session, int(referred_by_id))
|
||||
|
||||
|
||||
async def get_users_referred_by(
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
*,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> List[User]:
|
||||
safe_limit = max(1, min(500, int(limit or 50)))
|
||||
safe_offset = max(0, int(offset or 0))
|
||||
stmt = (
|
||||
select(User)
|
||||
.where(User.referred_by_id == user_id)
|
||||
.order_by(User.registration_date.desc().nullslast(), User.user_id.desc())
|
||||
.offset(safe_offset)
|
||||
.limit(safe_limit)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def count_users_referred_by(session: AsyncSession, user_id: int) -> int:
|
||||
stmt = select(func.count(User.user_id)).where(User.referred_by_id == user_id)
|
||||
result = await session.execute(stmt)
|
||||
return int(result.scalar_one() or 0)
|
||||
|
||||
|
||||
async def get_user_by_username(session: AsyncSession, username: str) -> Optional[User]:
|
||||
clean_username = username.lstrip("@").lower()
|
||||
stmt = select(User).where(func.lower(User.username) == clean_username)
|
||||
@@ -228,6 +261,20 @@ async def create_email_user(
|
||||
)
|
||||
|
||||
|
||||
async def mark_trial_eligibility_reset(
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
*,
|
||||
reset_at: Optional[datetime] = None,
|
||||
) -> Optional[datetime]:
|
||||
reset_at = reset_at or datetime.now(timezone.utc)
|
||||
stmt = update(User).where(User.user_id == user_id).values(trial_eligibility_reset_at=reset_at)
|
||||
result = await session.execute(stmt)
|
||||
if result.rowcount <= 0:
|
||||
return None
|
||||
return reset_at
|
||||
|
||||
|
||||
async def _has_active_panel_subscription(
|
||||
session: AsyncSession, user_id: int, panel_user_uuid: str
|
||||
) -> bool:
|
||||
@@ -408,6 +455,27 @@ async def merge_users(
|
||||
target.channel_subscription_checked_at = source.channel_subscription_checked_at
|
||||
if not target.channel_subscription_verified_for and source.channel_subscription_verified_for:
|
||||
target.channel_subscription_verified_for = source.channel_subscription_verified_for
|
||||
source_tg_status = str(getattr(source, "telegram_notifications_status", None) or "unknown")
|
||||
target_tg_status = str(getattr(target, "telegram_notifications_status", None) or "unknown")
|
||||
if source_tg_status == "enabled" and target_tg_status != "enabled":
|
||||
target.telegram_notifications_status = source_tg_status
|
||||
elif target_tg_status == "unknown" and source_tg_status != "unknown":
|
||||
target.telegram_notifications_status = source_tg_status
|
||||
if getattr(source, "telegram_notifications_checked_at", None) and (
|
||||
not getattr(target, "telegram_notifications_checked_at", None)
|
||||
or source.telegram_notifications_checked_at > target.telegram_notifications_checked_at
|
||||
):
|
||||
target.telegram_notifications_checked_at = source.telegram_notifications_checked_at
|
||||
if getattr(source, "telegram_notifications_enabled_at", None) and (
|
||||
not getattr(target, "telegram_notifications_enabled_at", None)
|
||||
or source.telegram_notifications_enabled_at > target.telegram_notifications_enabled_at
|
||||
):
|
||||
target.telegram_notifications_enabled_at = source.telegram_notifications_enabled_at
|
||||
if getattr(source, "telegram_notifications_blocked_at", None) and (
|
||||
not getattr(target, "telegram_notifications_blocked_at", None)
|
||||
or source.telegram_notifications_blocked_at > target.telegram_notifications_blocked_at
|
||||
):
|
||||
target.telegram_notifications_blocked_at = source.telegram_notifications_blocked_at
|
||||
if source.lifetime_used_traffic_bytes is not None:
|
||||
target.lifetime_used_traffic_bytes = (
|
||||
target.lifetime_used_traffic_bytes or 0
|
||||
@@ -645,17 +713,33 @@ async def get_enhanced_user_statistics(session: AsyncSession) -> Dict[str, Any]:
|
||||
active_today = int(user_counts[2] or 0)
|
||||
referral_users = int(user_counts[3] or 0)
|
||||
|
||||
subscription_counts_stmt = (
|
||||
provider_value = func.lower(func.coalesce(Subscription.provider, ""))
|
||||
panel_status_value = func.upper(func.coalesce(Subscription.status_from_panel, ""))
|
||||
trial_subscription_condition = or_(
|
||||
provider_value == "trial",
|
||||
panel_status_value == "TRIAL",
|
||||
)
|
||||
paid_subscription_condition = and_(
|
||||
provider_value != "",
|
||||
provider_value != "trial",
|
||||
panel_status_value != "TRIAL",
|
||||
)
|
||||
free_subscription_condition = and_(
|
||||
provider_value == "",
|
||||
panel_status_value != "TRIAL",
|
||||
)
|
||||
|
||||
active_subscription_flags_sq = (
|
||||
select(
|
||||
func.count(
|
||||
func.distinct(
|
||||
case((Subscription.provider.is_not(None), Subscription.user_id), else_=None)
|
||||
)
|
||||
Subscription.user_id.label("user_id"),
|
||||
func.max(case((paid_subscription_condition, 1), else_=0)).label(
|
||||
"has_paid_subscription"
|
||||
),
|
||||
func.count(
|
||||
func.distinct(
|
||||
case((Subscription.provider.is_(None), Subscription.user_id), else_=None)
|
||||
)
|
||||
func.max(case((trial_subscription_condition, 1), else_=0)).label(
|
||||
"has_trial_subscription"
|
||||
),
|
||||
func.max(case((free_subscription_condition, 1), else_=0)).label(
|
||||
"has_free_subscription"
|
||||
),
|
||||
)
|
||||
.join(User, Subscription.user_id == User.user_id)
|
||||
@@ -665,27 +749,66 @@ async def get_enhanced_user_statistics(session: AsyncSession) -> Dict[str, Any]:
|
||||
Subscription.end_date > now,
|
||||
)
|
||||
)
|
||||
.group_by(Subscription.user_id)
|
||||
.subquery()
|
||||
)
|
||||
|
||||
subscription_counts_stmt = select(
|
||||
func.count(active_subscription_flags_sq.c.user_id),
|
||||
func.coalesce(func.sum(active_subscription_flags_sq.c.has_paid_subscription), 0),
|
||||
func.coalesce(
|
||||
func.sum(
|
||||
case(
|
||||
(
|
||||
active_subscription_flags_sq.c.has_paid_subscription == 0,
|
||||
active_subscription_flags_sq.c.has_trial_subscription,
|
||||
),
|
||||
else_=0,
|
||||
)
|
||||
),
|
||||
0,
|
||||
),
|
||||
func.coalesce(
|
||||
func.sum(
|
||||
case(
|
||||
(
|
||||
and_(
|
||||
active_subscription_flags_sq.c.has_paid_subscription == 0,
|
||||
active_subscription_flags_sq.c.has_trial_subscription == 0,
|
||||
),
|
||||
active_subscription_flags_sq.c.has_free_subscription,
|
||||
),
|
||||
else_=0,
|
||||
)
|
||||
),
|
||||
0,
|
||||
),
|
||||
)
|
||||
subscription_counts = (await session.execute(subscription_counts_stmt)).one()
|
||||
paid_subs_users = int(subscription_counts[0] or 0)
|
||||
trial_users = int(subscription_counts[1] or 0)
|
||||
active_subscription_users = int(subscription_counts[0] or 0)
|
||||
paid_subs_users = int(subscription_counts[1] or 0)
|
||||
trial_users = int(subscription_counts[2] or 0)
|
||||
free_subscription_users = int(subscription_counts[3] or 0)
|
||||
|
||||
# Inactive users (no active subscription)
|
||||
inactive_users = total_users - paid_subs_users - trial_users - banned_users
|
||||
inactive_users = total_users - active_subscription_users
|
||||
expired_subscription_users = await count_users_with_expired_subscription(session)
|
||||
|
||||
return {
|
||||
"total_users": total_users,
|
||||
"banned_users": banned_users,
|
||||
"active_today": active_today,
|
||||
"active_subscriptions": active_subscription_users,
|
||||
"paid_subscriptions": paid_subs_users,
|
||||
"trial_users": trial_users,
|
||||
"free_subscription_users": free_subscription_users,
|
||||
"inactive_users": max(0, inactive_users),
|
||||
"expired_subscription_users": expired_subscription_users,
|
||||
"referral_users": referral_users,
|
||||
}
|
||||
|
||||
|
||||
async def get_user_ids_with_active_subscription(session: AsyncSession) -> List[int]:
|
||||
"""Return non-banned user IDs who have an active subscription (paid or trial)."""
|
||||
"""Return non-banned user IDs who have any active subscription."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
@@ -734,6 +857,66 @@ async def get_user_ids_without_active_subscription(session: AsyncSession) -> Lis
|
||||
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, ""))
|
||||
blank_status = or_(
|
||||
expired_subs.status_from_panel.is_(None),
|
||||
expired_subs.status_from_panel == "",
|
||||
)
|
||||
expired_condition = or_(
|
||||
normalized_status == "expired",
|
||||
blank_status & expired_subs.is_active.is_(False),
|
||||
expired_subs.end_date <= now,
|
||||
)
|
||||
|
||||
return (
|
||||
select(expired_subs.subscription_id)
|
||||
.where(expired_subs.user_id == User.user_id, expired_condition)
|
||||
.exists()
|
||||
)
|
||||
|
||||
|
||||
def _active_subscription_exists_for_user(now: datetime):
|
||||
active_subs = aliased(Subscription)
|
||||
return (
|
||||
select(active_subs.subscription_id)
|
||||
.where(
|
||||
active_subs.user_id == User.user_id,
|
||||
active_subs.is_active == True,
|
||||
active_subs.end_date > now,
|
||||
)
|
||||
.exists()
|
||||
)
|
||||
|
||||
|
||||
async def count_users_with_expired_subscription(session: AsyncSession) -> int:
|
||||
"""Count users who have an expired subscription and no currently active subscription."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
stmt = select(func.count(User.user_id)).where(
|
||||
_expired_subscription_exists_for_user(now),
|
||||
~_active_subscription_exists_for_user(now),
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
return int(result.scalar_one() or 0)
|
||||
|
||||
|
||||
async def get_user_ids_with_expired_subscription(session: AsyncSession) -> List[int]:
|
||||
"""Return non-banned user IDs with an expired subscription and no active one."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
stmt = select(User.user_id).where(
|
||||
User.is_banned == False,
|
||||
_expired_subscription_exists_for_user(now),
|
||||
~_active_subscription_exists_for_user(now),
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def delete_user_and_relations(session: AsyncSession, user_id: int) -> bool:
|
||||
"""Completely remove a user and all dependent records from the database.
|
||||
|
||||
|
||||
@@ -1047,6 +1047,29 @@ def _migration_0031_add_subscription_notifications(connection: Connection) -> No
|
||||
)
|
||||
|
||||
|
||||
def _migration_0032_add_telegram_notification_status(connection: Connection) -> None:
|
||||
inspector = inspect(connection)
|
||||
columns: Set[str] = {col["name"] for col in inspector.get_columns("users")}
|
||||
additions = {
|
||||
"telegram_notifications_status": "VARCHAR(32) NOT NULL DEFAULT 'unknown'",
|
||||
"telegram_notifications_checked_at": "TIMESTAMPTZ",
|
||||
"telegram_notifications_enabled_at": "TIMESTAMPTZ",
|
||||
"telegram_notifications_blocked_at": "TIMESTAMPTZ",
|
||||
}
|
||||
for column, ddl_type in additions.items():
|
||||
if column not in columns:
|
||||
connection.execute(text(f"ALTER TABLE users ADD COLUMN {column} {ddl_type}"))
|
||||
|
||||
|
||||
def _migration_0033_add_trial_eligibility_reset_marker(connection: Connection) -> None:
|
||||
inspector = inspect(connection)
|
||||
columns: Set[str] = {col["name"] for col in inspector.get_columns("users")}
|
||||
if "trial_eligibility_reset_at" not in columns:
|
||||
connection.execute(
|
||||
text("ALTER TABLE users ADD COLUMN trial_eligibility_reset_at TIMESTAMPTZ")
|
||||
)
|
||||
|
||||
|
||||
MIGRATIONS: List[Migration] = [
|
||||
Migration(
|
||||
id="0001_add_channel_subscription_fields",
|
||||
@@ -1214,6 +1237,16 @@ MIGRATIONS: List[Migration] = [
|
||||
description="Track sent subscription notification stages",
|
||||
upgrade=_migration_0031_add_subscription_notifications,
|
||||
),
|
||||
Migration(
|
||||
id="0032_add_telegram_notification_status",
|
||||
description="Track whether the bot can message Telegram-linked users",
|
||||
upgrade=_migration_0032_add_telegram_notification_status,
|
||||
),
|
||||
Migration(
|
||||
id="0033_add_trial_eligibility_reset_marker",
|
||||
description="Track admin resets of per-user trial eligibility without deleting history",
|
||||
upgrade=_migration_0033_add_trial_eligibility_reset_marker,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -33,6 +33,10 @@ class User(Base):
|
||||
password_set_at = Column(DateTime(timezone=True), nullable=True)
|
||||
telegram_id = Column(BigInteger, nullable=True, unique=True, index=True)
|
||||
telegram_photo_url = Column(Text, nullable=True)
|
||||
telegram_notifications_status = Column(String(32), nullable=False, default="unknown")
|
||||
telegram_notifications_checked_at = Column(DateTime(timezone=True), nullable=True)
|
||||
telegram_notifications_enabled_at = Column(DateTime(timezone=True), nullable=True)
|
||||
telegram_notifications_blocked_at = Column(DateTime(timezone=True), nullable=True)
|
||||
first_name = Column(String, nullable=True)
|
||||
last_name = Column(String, nullable=True)
|
||||
language_code = Column(String, default="ru")
|
||||
@@ -43,6 +47,7 @@ class User(Base):
|
||||
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)
|
||||
trial_eligibility_reset_at = Column(DateTime(timezone=True), nullable=True)
|
||||
channel_subscription_verified = Column(Boolean, nullable=True)
|
||||
channel_subscription_checked_at = Column(DateTime(timezone=True), nullable=True)
|
||||
channel_subscription_verified_for = Column(BigInteger, nullable=True)
|
||||
|
||||
@@ -27,6 +27,7 @@ from bot.services.backup_worker import BackupWorker
|
||||
from bot.services.locale_override_service import load_locale_overrides
|
||||
from bot.services.subscription_notification_worker import SubscriptionNotificationWorker
|
||||
from bot.services.tariff_worker import TariffTrafficWorker
|
||||
from bot.services.telemetry_worker import TelemetryWorker
|
||||
from bot.utils.message_queue import init_queue_manager
|
||||
from config.settings import get_settings
|
||||
|
||||
@@ -209,6 +210,8 @@ async def main() -> None:
|
||||
)
|
||||
backup_worker = BackupWorker(settings, bot, session_factory=session_factory)
|
||||
tasks.append(asyncio.create_task(backup_worker.run(), name="BackupWorker"))
|
||||
telemetry_worker = TelemetryWorker(settings, session_factory)
|
||||
tasks.append(asyncio.create_task(telemetry_worker.run(), name="TelemetryWorker"))
|
||||
tasks.append(asyncio.create_task(_panel_sync_loop(settings, session_factory, i18n, services)))
|
||||
for idx in range(max(1, settings.WEBHOOK_QUEUE_CONCURRENCY)):
|
||||
tasks.append(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"default_tariff": "standard",
|
||||
"default_currency": "rub",
|
||||
"tariffs": [
|
||||
{
|
||||
"key": "standard",
|
||||
@@ -40,8 +41,10 @@
|
||||
{ "count": 1, "price": 2500 }
|
||||
]
|
||||
},
|
||||
"prices_rub": { "1": 150, "3": 400, "6": 750, "12": 1400 },
|
||||
"prices_rub": { "1": 200, "3": 600, "6": 1200, "12": 2400 },
|
||||
"prices_stars": { "1": 0, "3": 0, "6": 0, "12": 0 },
|
||||
"referral_bonus_days_inviter": { "1": 3, "3": 7, "6": 15, "12": 30 },
|
||||
"referral_bonus_days_referee": { "1": 1, "3": 3, "6": 7, "12": 15 },
|
||||
"enabled_periods": [1, 3, 6, 12],
|
||||
"enabled": true
|
||||
},
|
||||
|
||||
@@ -130,6 +130,7 @@ LABEL org.opencontainers.image.source="https://github.com/3252a8/remnawave-minis
|
||||
|
||||
COPY deploy/docker/frontend/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY deploy/docker/frontend/00-startup-banner.sh /docker-entrypoint.d/00-startup-banner.sh
|
||||
COPY deploy/docker/frontend/robots.txt /usr/share/nginx/html/robots.txt
|
||||
COPY backend/bot/app/web/templates/subscription_webapp.html /usr/share/nginx/html/index.html
|
||||
COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp.css /usr/share/nginx/html/subscription_webapp.css
|
||||
COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp.*.css /usr/share/nginx/html/
|
||||
|
||||
@@ -23,6 +23,13 @@ server {
|
||||
return 200 "ok\n";
|
||||
}
|
||||
|
||||
location = /robots.txt {
|
||||
expires 1h;
|
||||
add_header Cache-Control "public, max-age=3600";
|
||||
add_header X-Robots-Tag "noindex, nofollow, noarchive";
|
||||
try_files /robots.txt =404;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8081;
|
||||
proxy_http_version 1.1;
|
||||
@@ -89,23 +96,27 @@ server {
|
||||
location ~* ^/subscription_webapp(_admin)?\.(min\.)?[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]\.(css|js)$ {
|
||||
expires off;
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
add_header X-Robots-Tag "noindex, nofollow, noarchive";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location ~* ^/subscription_webapp(_admin)?\.(css|js)$ {
|
||||
expires off;
|
||||
add_header Cache-Control "no-cache";
|
||||
add_header X-Robots-Tag "noindex, nofollow, noarchive";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|webp)$ {
|
||||
expires 30d;
|
||||
add_header Cache-Control "public";
|
||||
add_header X-Robots-Tag "noindex, nofollow, noarchive";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location / {
|
||||
add_header Cache-Control "no-cache";
|
||||
add_header X-Robots-Tag "noindex, nofollow, noarchive";
|
||||
try_files $uri /index.html;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
User-agent: *
|
||||
Disallow: /
|
||||
|
||||
User-agent: GPTBot
|
||||
Disallow: /
|
||||
|
||||
User-agent: ChatGPT-User
|
||||
Disallow: /
|
||||
|
||||
User-agent: OAI-SearchBot
|
||||
Disallow: /
|
||||
|
||||
User-agent: Google-Extended
|
||||
Disallow: /
|
||||
|
||||
User-agent: ClaudeBot
|
||||
Disallow: /
|
||||
|
||||
User-agent: anthropic-ai
|
||||
Disallow: /
|
||||
|
||||
User-agent: PerplexityBot
|
||||
Disallow: /
|
||||
|
||||
User-agent: Applebot-Extended
|
||||
Disallow: /
|
||||
@@ -1,4 +1,5 @@
|
||||
**/.env
|
||||
**/data/
|
||||
nginx/ssl/*
|
||||
!nginx/ssl/README.md
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
Эта папка хранит только рабочие compose-примеры и конфиги. Подробное описание не дублируется здесь, чтобы сайт документации и навигация из README использовали один источник.
|
||||
|
||||
Файлы приложения (`/app/data`: тарифы, темы, логотипы) монтируются из папки `data` рядом с выбранным `docker-compose.yml`. Для кастомных тем создайте `data/themes`.
|
||||
|
||||
| Папка | Документация |
|
||||
| --- | --- |
|
||||
| `caddy` | [Развертывание с Caddy](../../docs/getting-started/deployment.md#caddy-рекомендуемый-вариант) |
|
||||
|
||||
@@ -48,7 +48,7 @@ services:
|
||||
environment:
|
||||
<<: *app_environment
|
||||
volumes:
|
||||
- shop-data:/app/data
|
||||
- ./data:/app/data
|
||||
networks:
|
||||
- remnawave-shop
|
||||
depends_on:
|
||||
@@ -66,7 +66,7 @@ services:
|
||||
WEBAPP_ENABLED: ${WEBAPP_ENABLED:-true}
|
||||
TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.1,::1,172.16.0.0/12}
|
||||
volumes:
|
||||
- shop-data:/app/data
|
||||
- ./data:/app/data
|
||||
- ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:${COMPOSE_RESTORE_MODE:-rw}
|
||||
networks:
|
||||
- remnawave-shop
|
||||
@@ -91,7 +91,7 @@ services:
|
||||
environment:
|
||||
<<: *app_environment
|
||||
volumes:
|
||||
- shop-data:/app/data
|
||||
- ./data:/app/data
|
||||
- ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:ro
|
||||
networks:
|
||||
- remnawave-shop
|
||||
@@ -149,8 +149,6 @@ volumes:
|
||||
name: remnawave-minishop-db-data
|
||||
redis-data:
|
||||
name: remnawave-minishop-redis-data
|
||||
shop-data:
|
||||
name: remnawave-minishop-shop-data
|
||||
caddy-data:
|
||||
name: remnawave-minishop-caddy-data
|
||||
caddy-config:
|
||||
|
||||
@@ -48,7 +48,7 @@ services:
|
||||
environment:
|
||||
<<: *app_environment
|
||||
volumes:
|
||||
- shop-data:/app/data
|
||||
- ./data:/app/data
|
||||
networks:
|
||||
- remnawave-shop
|
||||
depends_on:
|
||||
@@ -66,7 +66,7 @@ services:
|
||||
WEBAPP_ENABLED: ${WEBAPP_ENABLED:-true}
|
||||
TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.1,::1,172.16.0.0/12}
|
||||
volumes:
|
||||
- shop-data:/app/data
|
||||
- ./data:/app/data
|
||||
- ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:${COMPOSE_RESTORE_MODE:-rw}
|
||||
networks:
|
||||
- remnawave-shop
|
||||
@@ -91,7 +91,7 @@ services:
|
||||
environment:
|
||||
<<: *app_environment
|
||||
volumes:
|
||||
- shop-data:/app/data
|
||||
- ./data:/app/data
|
||||
- ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:ro
|
||||
networks:
|
||||
- remnawave-shop
|
||||
@@ -144,5 +144,3 @@ volumes:
|
||||
name: remnawave-minishop-db-data
|
||||
redis-data:
|
||||
name: remnawave-minishop-redis-data
|
||||
shop-data:
|
||||
name: remnawave-minishop-shop-data
|
||||
|
||||
@@ -48,7 +48,7 @@ services:
|
||||
environment:
|
||||
<<: *app_environment
|
||||
volumes:
|
||||
- shop-data:/app/data
|
||||
- ./data:/app/data
|
||||
networks:
|
||||
- remnawave-shop
|
||||
depends_on:
|
||||
@@ -66,7 +66,7 @@ services:
|
||||
WEBAPP_ENABLED: ${WEBAPP_ENABLED:-true}
|
||||
TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.1,::1,172.16.0.0/12}
|
||||
volumes:
|
||||
- shop-data:/app/data
|
||||
- ./data:/app/data
|
||||
- ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:${COMPOSE_RESTORE_MODE:-rw}
|
||||
networks:
|
||||
- remnawave-shop
|
||||
@@ -91,7 +91,7 @@ services:
|
||||
environment:
|
||||
<<: *app_environment
|
||||
volumes:
|
||||
- shop-data:/app/data
|
||||
- ./data:/app/data
|
||||
- ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:ro
|
||||
networks:
|
||||
- remnawave-shop
|
||||
@@ -148,5 +148,3 @@ volumes:
|
||||
name: remnawave-minishop-db-data
|
||||
redis-data:
|
||||
name: remnawave-minishop-redis-data
|
||||
shop-data:
|
||||
name: remnawave-minishop-shop-data
|
||||
|
||||
@@ -48,7 +48,7 @@ services:
|
||||
environment:
|
||||
<<: *app_environment
|
||||
volumes:
|
||||
- shop-data:/app/data
|
||||
- ./data:/app/data
|
||||
networks:
|
||||
- remnawave-shop
|
||||
depends_on:
|
||||
@@ -68,7 +68,7 @@ services:
|
||||
ports:
|
||||
- "${WEB_SERVER_BIND:-0.0.0.0:8080}:8080"
|
||||
volumes:
|
||||
- shop-data:/app/data
|
||||
- ./data:/app/data
|
||||
- ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:${COMPOSE_RESTORE_MODE:-rw}
|
||||
networks:
|
||||
- remnawave-shop
|
||||
@@ -93,7 +93,7 @@ services:
|
||||
environment:
|
||||
<<: *app_environment
|
||||
volumes:
|
||||
- shop-data:/app/data
|
||||
- ./data:/app/data
|
||||
- ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:ro
|
||||
networks:
|
||||
- remnawave-shop
|
||||
@@ -132,5 +132,3 @@ volumes:
|
||||
name: remnawave-minishop-db-data
|
||||
redis-data:
|
||||
name: remnawave-minishop-redis-data
|
||||
shop-data:
|
||||
name: remnawave-minishop-shop-data
|
||||
|
||||
@@ -71,6 +71,7 @@ export default defineConfig({
|
||||
items: [
|
||||
{ label: 'Переменные окружения', slug: 'configuration/env-vars' },
|
||||
{ label: 'Безопасность', slug: 'configuration/security' },
|
||||
{ label: 'Телеметрия', slug: 'configuration/telemetry' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -79,6 +80,7 @@ export default defineConfig({
|
||||
{ label: 'Основные', slug: 'features/core' },
|
||||
{ label: 'Платежи', slug: 'features/payments' },
|
||||
{ label: 'Подписки', slug: 'features/subscriptions' },
|
||||
{ label: 'Уведомления', slug: 'features/notifications' },
|
||||
{ label: 'Тарифы', slug: 'features/tariffs' },
|
||||
{ label: 'Веб-приложение / Mini App', slug: 'features/web-app' },
|
||||
{ label: 'Telegram-авторизация', slug: 'features/telegram-auth' },
|
||||
|
||||
+12
-22
@@ -1,22 +1,12 @@
|
||||
# Cloudflare Pages rewrites for the static docs demo SPA.
|
||||
# Keep these scoped to app routes so runtime JS/CSS/assets are served directly.
|
||||
/demo/runtime /demo/runtime/app.html 200
|
||||
/demo/runtime/ /demo/runtime/app.html 200
|
||||
/demo/runtime/home /demo/runtime/app.html 200
|
||||
/demo/runtime/home/* /demo/runtime/app.html 200
|
||||
/demo/runtime/install /demo/runtime/app.html 200
|
||||
/demo/runtime/install/* /demo/runtime/app.html 200
|
||||
/demo/runtime/trial /demo/runtime/app.html 200
|
||||
/demo/runtime/trial/* /demo/runtime/app.html 200
|
||||
/demo/runtime/invite /demo/runtime/app.html 200
|
||||
/demo/runtime/invite/* /demo/runtime/app.html 200
|
||||
/demo/runtime/devices /demo/runtime/app.html 200
|
||||
/demo/runtime/devices/* /demo/runtime/app.html 200
|
||||
/demo/runtime/support /demo/runtime/app.html 200
|
||||
/demo/runtime/support/* /demo/runtime/app.html 200
|
||||
/demo/runtime/settings /demo/runtime/app.html 200
|
||||
/demo/runtime/settings/* /demo/runtime/app.html 200
|
||||
/demo/runtime/login /demo/runtime/app.html 200
|
||||
/demo/runtime/login/* /demo/runtime/app.html 200
|
||||
/demo/runtime/admin /demo/runtime/app.html 200
|
||||
/demo/runtime/admin/* /demo/runtime/app.html 200
|
||||
# Cloudflare Pages rewrites for non-materialized static docs demo runtime routes.
|
||||
# Use the generated directory index instead of app.html so Pages clean URLs do not
|
||||
# loop between extensionless and .html variants.
|
||||
/demo/runtime/home/* /demo/runtime/app/index.html 200
|
||||
/demo/runtime/install/* /demo/runtime/app/index.html 200
|
||||
/demo/runtime/trial/* /demo/runtime/app/index.html 200
|
||||
/demo/runtime/invite/* /demo/runtime/app/index.html 200
|
||||
/demo/runtime/devices/* /demo/runtime/app/index.html 200
|
||||
/demo/runtime/support/* /demo/runtime/app/index.html 200
|
||||
/demo/runtime/settings/* /demo/runtime/app/index.html 200
|
||||
/demo/runtime/login/* /demo/runtime/app/index.html 200
|
||||
/demo/runtime/admin/* /demo/runtime/app/index.html 200
|
||||
|
||||
@@ -2,13 +2,16 @@ const frame = document.getElementById("demo-frame");
|
||||
const runtimeBase = "/demo/runtime";
|
||||
const demoBase = "/demo";
|
||||
const defaultMock = "tariffs";
|
||||
const publicRouteAliases = new Map([["/app", "/home"]]);
|
||||
const stateMocks = new Set([
|
||||
"tariffs",
|
||||
"depleted",
|
||||
"no-subscription",
|
||||
"trial",
|
||||
"devices",
|
||||
"notifications",
|
||||
"auth",
|
||||
"emails",
|
||||
]);
|
||||
const routeMocks = new Set([...stateMocks, "guides", "install"]);
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
@@ -41,7 +44,8 @@ const routeFromPublicPath = () => {
|
||||
const publicRoute = pathname.slice(demoBase.length);
|
||||
if (!publicRoute || publicRoute.toLowerCase().startsWith("/runtime"))
|
||||
return "";
|
||||
return normalizePath(publicRoute);
|
||||
const normalized = normalizePath(publicRoute);
|
||||
return publicRouteAliases.get(normalized.toLowerCase()) || normalized;
|
||||
};
|
||||
|
||||
const routeFromParams = () => {
|
||||
@@ -80,6 +84,7 @@ const routeFromParams = () => {
|
||||
let initialRoute = routeFromParams();
|
||||
const mockForRoute = (route) => {
|
||||
const normalized = normalizePath(route);
|
||||
if (normalized === "/emails") return "emails";
|
||||
if (normalized === "/devices") return "devices";
|
||||
if (normalized === "/login" || normalized.startsWith("/login/"))
|
||||
return "auth";
|
||||
@@ -88,36 +93,6 @@ const mockForRoute = (route) => {
|
||||
const initialMock = params.has("mock")
|
||||
? normalizeRouteMock(params.get("mock"))
|
||||
: normalizeRouteMock(mockForRoute(initialRoute) || defaultMock);
|
||||
if (initialMock === "trial" && initialRoute === "/trial") {
|
||||
initialRoute = "/home";
|
||||
const normalizedUrl = new URL(window.location.href);
|
||||
normalizedUrl.pathname = `${demoBase}/home`;
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
`${normalizedUrl.pathname}${normalizedUrl.search}${normalizedUrl.hash}`,
|
||||
);
|
||||
}
|
||||
params.set("mock", initialMock);
|
||||
params.delete("path");
|
||||
params.delete("screen");
|
||||
params.delete("admin_section");
|
||||
params.set("path", initialRoute);
|
||||
frame.src = `${runtimeBase}/app.html?${params.toString()}${window.location.hash || ""}`;
|
||||
|
||||
const routeFromRuntimeUrl = (url) => {
|
||||
if (url.origin !== window.location.origin) return "";
|
||||
if (!url.pathname.toLowerCase().startsWith(runtimeBase.toLowerCase()))
|
||||
return "";
|
||||
const runtimePath = normalizePath(
|
||||
url.pathname.slice(runtimeBase.length) || "/home",
|
||||
);
|
||||
if (runtimePath === "/app.html") {
|
||||
return normalizePath(url.searchParams.get("path") || "/home");
|
||||
}
|
||||
return runtimePath;
|
||||
};
|
||||
|
||||
const materializedRouteFromRuntime = (route) => {
|
||||
const normalized = normalizePath(route);
|
||||
if (/^\/admin\/users\/-?\d+$/i.test(normalized)) return "/admin/users";
|
||||
@@ -131,7 +106,62 @@ const materializedRouteFromRuntime = (route) => {
|
||||
|
||||
const publicPathFromRoute = (route) =>
|
||||
`${demoBase}${materializedRouteFromRuntime(route)}`;
|
||||
const canonicalizeInitialPublicUrl = (route) => {
|
||||
const pathname = window.location.pathname.replace(/\/+$/, "") || "/";
|
||||
const lowerPathname = pathname.toLowerCase();
|
||||
if (
|
||||
lowerPathname !== demoBase &&
|
||||
!publicRouteAliases.has(pathname.slice(demoBase.length).toLowerCase())
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const normalizedUrl = new URL(window.location.href);
|
||||
normalizedUrl.pathname = publicPathFromRoute(route);
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
`${normalizedUrl.pathname}${normalizedUrl.search}${normalizedUrl.hash}`,
|
||||
);
|
||||
};
|
||||
|
||||
if (initialMock === "emails") {
|
||||
initialRoute = "/emails";
|
||||
canonicalizeInitialPublicUrl(initialRoute);
|
||||
} else if (initialMock === "trial" && initialRoute === "/trial") {
|
||||
initialRoute = "/home";
|
||||
const normalizedUrl = new URL(window.location.href);
|
||||
normalizedUrl.pathname = publicPathFromRoute(initialRoute);
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
`${normalizedUrl.pathname}${normalizedUrl.search}${normalizedUrl.hash}`,
|
||||
);
|
||||
} else {
|
||||
canonicalizeInitialPublicUrl(initialRoute);
|
||||
}
|
||||
if (initialMock !== "emails") {
|
||||
params.set("mock", initialMock);
|
||||
params.delete("path");
|
||||
params.delete("screen");
|
||||
params.delete("admin_section");
|
||||
params.set("path", initialRoute);
|
||||
frame.src = `${runtimeBase}/app/?${params.toString()}${window.location.hash || ""}`;
|
||||
}
|
||||
|
||||
const routeFromRuntimeUrl = (url) => {
|
||||
if (url.origin !== window.location.origin) return "";
|
||||
if (!url.pathname.toLowerCase().startsWith(runtimeBase.toLowerCase()))
|
||||
return "";
|
||||
const runtimePath = normalizePath(
|
||||
url.pathname.slice(runtimeBase.length) || "/home",
|
||||
);
|
||||
if (runtimePath === "/app" || runtimePath === "/app.html") {
|
||||
return normalizePath(url.searchParams.get("path") || "/home");
|
||||
}
|
||||
return runtimePath;
|
||||
};
|
||||
const routeForStateMock = (mock) => {
|
||||
if (mock === "emails") return "/emails";
|
||||
if (mock === "devices") return "/devices";
|
||||
if (mock === "auth") return "/login";
|
||||
return "/home";
|
||||
@@ -141,14 +171,24 @@ const runtimeSrc = (route, searchParams = new URLSearchParams()) => {
|
||||
nextParams.delete("screen");
|
||||
nextParams.delete("admin_section");
|
||||
nextParams.set("path", normalizePath(route));
|
||||
return `${runtimeBase}/app.html?${nextParams.toString()}${window.location.hash || ""}`;
|
||||
return `${runtimeBase}/app/?${nextParams.toString()}${window.location.hash || ""}`;
|
||||
};
|
||||
const topbar = document.querySelector(".demo-topbar");
|
||||
const toggle = document.querySelector(".demo-topbar__toggle");
|
||||
const hide = document.querySelector(".demo-topbar__hide");
|
||||
const stateSelect = document.querySelector(".demo-topbar__state-select");
|
||||
const emailPreviews = document.getElementById("email-previews");
|
||||
|
||||
const setDemoMode = (mock) => {
|
||||
const emailMode = mock === "emails";
|
||||
frame.hidden = emailMode;
|
||||
if (emailPreviews) emailPreviews.hidden = !emailMode;
|
||||
if (emailMode) document.body.setAttribute("data-demo-mode", "emails");
|
||||
else document.body.removeAttribute("data-demo-mode");
|
||||
};
|
||||
|
||||
const syncParentUrlFromFrame = () => {
|
||||
if (frame.hidden) return;
|
||||
try {
|
||||
const frameUrl = new URL(frame.contentWindow.location.href);
|
||||
const route = routeFromRuntimeUrl(frameUrl);
|
||||
@@ -186,19 +226,21 @@ const setCollapsed = (collapsed) => {
|
||||
|
||||
toggle?.addEventListener("click", () => setCollapsed(false));
|
||||
hide?.addEventListener("click", () => setCollapsed(true));
|
||||
if (stateSelect) stateSelect.value = normalizeStateMock(params.get("mock"));
|
||||
if (stateSelect) stateSelect.value = normalizeStateMock(params.get("mock") || initialMock);
|
||||
setDemoMode(initialMock);
|
||||
stateSelect?.addEventListener("change", () => {
|
||||
const mock = normalizeStateMock(stateSelect.value);
|
||||
const nextParams = new URLSearchParams(window.location.search);
|
||||
nextParams.delete("path");
|
||||
nextParams.delete("screen");
|
||||
nextParams.delete("admin_section");
|
||||
if (mock === defaultMock) nextParams.delete("mock");
|
||||
if (mock === defaultMock || mock === "emails") nextParams.delete("mock");
|
||||
else nextParams.set("mock", mock);
|
||||
|
||||
const query = nextParams.toString();
|
||||
const stateRoute = routeForStateMock(mock);
|
||||
const publicUrl = `${demoBase}${stateRoute}${query ? `?${query}` : ""}`;
|
||||
window.history.replaceState(null, "", publicUrl);
|
||||
frame.src = runtimeSrc(stateRoute, nextParams);
|
||||
setDemoMode(mock);
|
||||
if (mock !== "emails") frame.src = runtimeSrc(stateRoute, nextParams);
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user