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 | ||
|
|
5892a72575 | ||
|
|
d299a3c524 | ||
|
|
1c1ef06e29 | ||
|
|
bea62d75a9 | ||
|
|
e998b9ddd1 | ||
|
|
2f192447ce | ||
|
|
e89ed870dc | ||
|
|
6a722bfa17 | ||
|
|
a96aaa763f | ||
|
|
bbe05d7f57 | ||
|
|
da28b69461 | ||
|
|
cd469ae2bb | ||
|
|
9af49453f8 | ||
|
|
e543665704 | ||
|
|
39c0696e53 | ||
|
|
c6cc4ab963 | ||
|
|
4dfe71f392 | ||
|
|
cf05c6580d | ||
|
|
d544ebd879 | ||
|
|
7e0e8ff319 | ||
|
|
613a9860a0 | ||
|
|
92f0277dad | ||
|
|
749693078b | ||
|
|
b566a34725 | ||
|
|
3ab6c18a76 | ||
|
|
52458eb223 | ||
|
|
790946d89e | ||
|
|
30c5d9ae11 | ||
|
|
12e60629fe | ||
|
|
6c0d2932c3 | ||
|
|
ce19c7e2d1 | ||
|
|
93c9dde572 | ||
|
|
d46324cb6a | ||
|
|
f153ca5bf4 | ||
|
|
62e950f5c4 | ||
|
|
2b8bcd10b8 | ||
|
|
4c2ee19957 | ||
|
|
8217d13cd6 | ||
|
|
a2a887b898 | ||
|
|
1620de9a01 | ||
|
|
b6c6887842 | ||
|
|
ef4b493e65 | ||
|
|
50dd1951c4 | ||
|
|
b3894e53e4 | ||
|
|
ded044b4c0 | ||
|
|
bce78c4f28 | ||
|
|
75586d8883 | ||
|
|
f2fc335221 | ||
|
|
1c9e55d797 | ||
|
|
25056602d8 | ||
|
|
bd2e67059f | ||
|
|
fe34edfe73 | ||
|
|
4706be53ab | ||
|
|
4bd547f06a | ||
|
|
0250264fa0 | ||
|
|
3aede8fe95 | ||
|
|
e90988ea5c | ||
|
|
69d3400310 | ||
|
|
6a44da1f31 | ||
|
|
77c8785ec0 | ||
|
|
f2c0a9f6d5 | ||
|
|
f77a6ea46d | ||
|
|
a92ad32b23 | ||
|
|
a6af8f8415 | ||
|
|
da9db7b7f8 | ||
|
|
11048a6ed8 | ||
|
|
c3381bdd31 | ||
|
|
53f1cec401 | ||
|
|
bf46a15446 | ||
|
|
0df52d0235 | ||
|
|
0c167c8f09 | ||
|
|
804ccdabec | ||
|
|
5833b18052 |
@@ -13,6 +13,10 @@ scratch/
|
||||
*.local.*
|
||||
node_modules/
|
||||
frontend/node_modules/
|
||||
docs-site/node_modules/
|
||||
docs-site/.astro/
|
||||
docs-site/dist/
|
||||
docs-site/src/content/docs/
|
||||
deploy/compose/docker-compose-dev.yml
|
||||
data/*
|
||||
!data/tariffs.example.json
|
||||
@@ -31,10 +35,38 @@ deploy/compose/*.yml
|
||||
tmp/
|
||||
|
||||
# WebApp build artifacts (regenerated inside Docker)
|
||||
bot/app/web/templates/subscription_webapp.css
|
||||
bot/app/web/templates/subscription_webapp.js
|
||||
bot/app/web/templates/subscription_webapp.min.*.js
|
||||
bot/app/web/templates/subscription_webapp.*.css
|
||||
bot/app/web/templates/subscription_webapp.min.*.js.br
|
||||
bot/app/web/templates/subscription_webapp.min.*.js.gz
|
||||
bot/app/web/templates/subscription_webapp.*.css.br
|
||||
bot/app/web/templates/subscription_webapp.*.css.gz
|
||||
bot/app/web/templates/subscription_webapp_admin.css
|
||||
bot/app/web/templates/subscription_webapp_admin.js
|
||||
bot/app/web/templates/subscription_webapp_admin.min.*.js
|
||||
bot/app/web/templates/subscription_webapp_admin.*.css
|
||||
bot/app/web/templates/subscription_webapp_admin.min.*.js.br
|
||||
bot/app/web/templates/subscription_webapp_admin.min.*.js.gz
|
||||
bot/app/web/templates/subscription_webapp_admin.*.css.br
|
||||
bot/app/web/templates/subscription_webapp_admin.*.css.gz
|
||||
backend/bot/app/web/templates/subscription_webapp.css
|
||||
backend/bot/app/web/templates/subscription_webapp.js
|
||||
backend/bot/app/web/templates/subscription_webapp.min.*.js
|
||||
backend/bot/app/web/templates/subscription_webapp.*.css
|
||||
backend/bot/app/web/templates/subscription_webapp.min.*.js.br
|
||||
backend/bot/app/web/templates/subscription_webapp.min.*.js.gz
|
||||
backend/bot/app/web/templates/subscription_webapp.*.css.br
|
||||
backend/bot/app/web/templates/subscription_webapp.*.css.gz
|
||||
backend/bot/app/web/templates/subscription_webapp_admin.css
|
||||
backend/bot/app/web/templates/subscription_webapp_admin.js
|
||||
backend/bot/app/web/templates/subscription_webapp_admin.min.*.js
|
||||
backend/bot/app/web/templates/subscription_webapp_admin.*.css
|
||||
backend/bot/app/web/templates/subscription_webapp_admin.min.*.js.br
|
||||
backend/bot/app/web/templates/subscription_webapp_admin.min.*.js.gz
|
||||
backend/bot/app/web/templates/subscription_webapp_admin.*.css.br
|
||||
backend/bot/app/web/templates/subscription_webapp_admin.*.css.gz
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
**/__pycache__/
|
||||
|
||||
@@ -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
|
||||
@@ -12,6 +12,12 @@ scratch/
|
||||
node_modules/
|
||||
.git/
|
||||
|
||||
# Documentation site build artifacts
|
||||
docs-site/.astro/
|
||||
docs-site/dist/
|
||||
docs-site/public/demo/runtime/
|
||||
docs-site/src/content/docs/
|
||||
|
||||
# WebApp build artifacts (regenerated by `npm run build:webapp` / Docker build)
|
||||
bot/app/web/templates/subscription_webapp.css
|
||||
bot/app/web/templates/subscription_webapp.js
|
||||
@@ -29,6 +35,11 @@ bot/app/web/templates/subscription_webapp_admin.min.*.js.br
|
||||
bot/app/web/templates/subscription_webapp_admin.min.*.js.gz
|
||||
bot/app/web/templates/subscription_webapp_admin.*.css.br
|
||||
bot/app/web/templates/subscription_webapp_admin.*.css.gz
|
||||
bot/app/web/templates/subscription_webapp_docs_demo.css
|
||||
bot/app/web/templates/subscription_webapp_docs_demo.js
|
||||
bot/app/web/templates/subscription_webapp_docs_demo.*.css
|
||||
bot/app/web/templates/subscription_webapp_docs_demo.*.css.br
|
||||
bot/app/web/templates/subscription_webapp_docs_demo.*.css.gz
|
||||
backend/bot/app/web/templates/subscription_webapp.css
|
||||
backend/bot/app/web/templates/subscription_webapp.js
|
||||
backend/bot/app/web/templates/subscription_webapp.min.*.js
|
||||
@@ -45,6 +56,11 @@ backend/bot/app/web/templates/subscription_webapp_admin.min.*.js.br
|
||||
backend/bot/app/web/templates/subscription_webapp_admin.min.*.js.gz
|
||||
backend/bot/app/web/templates/subscription_webapp_admin.*.css.br
|
||||
backend/bot/app/web/templates/subscription_webapp_admin.*.css.gz
|
||||
backend/bot/app/web/templates/subscription_webapp_docs_demo.css
|
||||
backend/bot/app/web/templates/subscription_webapp_docs_demo.js
|
||||
backend/bot/app/web/templates/subscription_webapp_docs_demo.*.css
|
||||
backend/bot/app/web/templates/subscription_webapp_docs_demo.*.css.br
|
||||
backend/bot/app/web/templates/subscription_webapp_docs_demo.*.css.gz
|
||||
tmp
|
||||
.claude
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи и управления подписками панели [Remnawave](https://docs.rw/). Бот обрабатывает регистрацию, оплату, продление, пробный период, промокоды, рефералов и поддержку в чате. Web App показывает ссылку подключения, срок действия, трафик, оплату, устройства и вход по Telegram Mini Apps `initData`, Telegram OAuth / OpenID Connect и одноразовому email-коду.
|
||||
|
||||
Проект является переработанным форком [kavore/remnawave-tg-shop](https://github.com/kavore/remnawave-tg-shop). Для переноса данных из прежнего стека используйте [инструкцию по миграции](docs/migration-to-minishop.md).
|
||||
Проект является переработанным форком [kavore/remnawave-tg-shop](https://github.com/kavore/remnawave-tg-shop). Для переноса данных из прежнего стека и других ботов используйте [раздел миграций](docs/migrations/index.md).
|
||||
|
||||
## Возможности
|
||||
|
||||
@@ -26,21 +26,25 @@ Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи
|
||||
- статистика пользователей, подписок, платежей и синхронизации с Remnawave;
|
||||
- список пользователей с поиском, фильтрами и колонкой premium-трафика;
|
||||
- блокировка пользователей, поддержка через тикеты, рассылки, промокоды, логи действий и настройка разрешенных параметров приложения поверх `.env`;
|
||||
- редактор JSON-каталога тарифов с period/traffic-моделями, Internal Squads, premium-сквадами и HWID-пакетами;
|
||||
- настройки инструкций подключения: чтение конфига Subscription Page из Remnawave Panel, опциональный JSON-override и переключатель поведения кнопок бота;
|
||||
- редактор JSON-каталога тарифов с моделями на срок/по трафику, Internal Squads, premium-сквадами и HWID-пакетами;
|
||||
- настройки инструкций подключения: чтение конфига Subscription Page из Remnawave Panel, опциональное JSON-переопределение и переключатель поведения кнопок бота;
|
||||
- ручная синхронизация пользователей и подписок с панелью.
|
||||
|
||||
## Документация
|
||||
|
||||
- [Настройка окружения](docs/configuration.md) - bootstrap `.env` и рекомендуемая настройка через Web App админку.
|
||||
- [Переменные `.env`](docs/env-vars.md) - полный справочник всех env-ключей по разделам.
|
||||
- [Тарифы](docs/tariffs.md) - каталог тарифов, period- и traffic-модели, обычные и premium-докупки, premium-сквады, смена тарифа, HWID-лимиты и обработка трафика.
|
||||
- [Админ-панель](docs/admin.md) - права доступа, настройки, редактор тарифов, premium-сквады и сохранение JSON-каталога.
|
||||
- [Web App / Mini App](docs/webapp.md) - отдельный порт, домен, Telegram OAuth, email-вход, инструкции установки и реферальные ссылки.
|
||||
- [Поддержка](docs/support.md) - тикеты в Mini App, входящий список админки, уведомления, лимиты и внешняя ссылка поддержки.
|
||||
- [Темы Web App](docs/webapp-themes.md) - кастомные темы, настройка внешнего вида, логотипы, CSS/ассеты и пайплайн создания новой темы.
|
||||
- [Развертывание](docs/deployment.md) - Docker Compose, reverse proxy, Nginx, Caddy, вебхуки, запуск из образа и обновление версии (`IMAGE_TAG`).
|
||||
- [Миграция с remnawave-tg-shop](docs/migration-to-minishop.md) - перенос данных из прежнего стека.
|
||||
- [Входная страница документации](docs/index.md) - маршрут по установке, настройке, платежам, админке и диагностике.
|
||||
- [Развертывание](docs/getting-started/deployment.md) - Docker Compose, Caddy, Nginx, Pangolin/Newt и запуск без обратного прокси.
|
||||
- [Настройка окружения](docs/getting-started/configuration.md) - bootstrap `.env` и рекомендуемая настройка через Web App админку.
|
||||
- [Переменные `.env`](docs/configuration/env-vars.md) - полный справочник всех env-ключей по разделам.
|
||||
- [Бэкапы и восстановление](docs/features/backups.md) - автоматические архивы, Telegram-отправка и restore через админку.
|
||||
- [Тарифы](docs/features/tariffs.md) - каталог тарифов, модели на срок и по трафику, обычные и premium-докупки, premium-сквады, смена тарифа, HWID-лимиты и обработка трафика.
|
||||
- [Админ-панель](docs/features/admin-panel.md) - права доступа, настройки, редактор тарифов, premium-сквады и сохранение JSON-каталога.
|
||||
- [Веб-приложение / Mini App](docs/features/web-app.md) - отдельный порт, домен, инструкции установки и реферальные ссылки.
|
||||
- [Telegram-авторизация](docs/features/telegram-auth.md) и [вход по email](docs/features/email-login.md) - настройка BotFather/OAuth и SMTP-логина.
|
||||
- [Поддержка пользователей / тикеты](docs/features/support.md) - тикеты в Mini App, входящий список админки, уведомления, лимиты и внешняя ссылка поддержки.
|
||||
- [Темы Web App](docs/features/webapp-themes.md) - кастомные темы, настройка внешнего вида, логотипы, CSS/ассеты и пайплайн создания новой темы.
|
||||
- [Миграции](docs/migrations/index.md) - готовые сценарии переноса с других ботов; сейчас описан `remnawave-tg-shop`.
|
||||
- [Миграция с remnawave-tg-shop](docs/migrations/remnawave-tg-shop.md) - готовый сценарий для legacy-стека.
|
||||
|
||||
## Совместимость
|
||||
|
||||
@@ -88,11 +92,13 @@ docker compose logs -f backend worker frontend
|
||||
- `PANEL_API_URL`, `PANEL_API_KEY`, `PANEL_WEBHOOK_SECRET` - доступ к Remnawave;
|
||||
- остальные настройки удобнее задать в Web App админке.
|
||||
|
||||
После первого входа в админку настройте тарифы, платежные провайдеры, внешний вид, поддержку, уведомления и инструкции подключения через UI. Инструкции установки включены по умолчанию, читают Subscription Page config из Remnawave Panel и при проблемах с конфигом откатываются к обычной ссылке подключения. Полный справочник env-переменных: [docs/env-vars.md](docs/env-vars.md).
|
||||
В Remnawave Panel укажите `WEBHOOK_URL` как публичный адрес Minishop с путем `/webhook/panel`, например `https://app.example.com/webhook/panel`. Секрет вебхука задается в самой Remnawave Panel; это же значение вставьте в `PANEL_WEBHOOK_SECRET` в `.env` или в **Система -> Настройки -> Remnawave Panel** в админке.
|
||||
|
||||
Для каталога тарифов используется `TARIFFS_CONFIG_PATH` со значением по умолчанию `data/tariffs.json`. Пример формата лежит в [data/tariffs.example.json](data/tariffs.example.json), подробности - в [docs/tariffs.md](docs/tariffs.md).
|
||||
После первого входа в админку настройте тарифы, платежные провайдеры, внешний вид, поддержку, уведомления и инструкции подключения через UI. Инструкции установки включены по умолчанию, читают Subscription Page config из Remnawave Panel и при проблемах с конфигом откатываются к обычной ссылке подключения. Полный справочник env-переменных: [docs/configuration/env-vars.md](docs/configuration/env-vars.md).
|
||||
|
||||
Если в Docker Compose включаете bind mount `./data:/app/data`, заранее создайте каталог и отдайте его пользователю контейнера. Это нужно для сохранения `data/tariffs.json`, каталога тем `data/themes`, кеша логотипа Web App и animated emoji:
|
||||
Для каталога тарифов используется `TARIFFS_CONFIG_PATH` со значением по умолчанию `data/tariffs.json`. Пример формата лежит в [data/tariffs.example.json](data/tariffs.example.json), подробности - в [docs/features/tariffs.md](docs/features/tariffs.md).
|
||||
|
||||
В compose-примерах `/app/data` монтируется из папки `./data` рядом с `docker-compose.yml`. Заранее создайте каталог и отдайте его пользователю контейнера. Это нужно для сохранения `data/tariffs.json`, каталога тем `data/themes`, кеша логотипа Web App и animated emoji:
|
||||
|
||||
```bash
|
||||
mkdir -p data/themes data/webapp-logo data/webapp-emoji
|
||||
@@ -110,7 +116,7 @@ docker compose up -d --build
|
||||
# Логи приложения
|
||||
docker compose logs -f backend worker frontend
|
||||
|
||||
# Готовые production-примеры
|
||||
# Рекомендуемый продакшен-вариант с Caddy
|
||||
cd deploy/examples/caddy # или nginx, newt, no-proxy
|
||||
cp .env.example .env
|
||||
nano .env
|
||||
@@ -120,13 +126,24 @@ docker compose up -d
|
||||
IMAGE_TAG=3.1.0 docker compose up -d
|
||||
```
|
||||
|
||||
Для production-запуска удобнее брать готовые папки из [`deploy/examples`](deploy/examples): там отдельно собраны варианты для Caddy, Nginx, Newt/Pangolin и прямой публикации портов без reverse proxy. В каждой папке рядом лежат `docker-compose.yml`, `.env.example`, README и нужный proxy-конфиг.
|
||||
Для продакшен-запуска удобнее брать готовые папки из [`deploy/examples`](deploy/examples), а читать каноничные инструкции в [docs/getting-started/deployment.md](docs/getting-started/deployment.md). Предпочтительный вариант для обычного публичного сервера - Caddy: он сам выпускает и продлевает HTTPS-сертификаты. В папках рядом с compose лежат только конфиги и короткие ссылки на документацию.
|
||||
|
||||
GHCR image names for releases:
|
||||
Имена образов для релизов:
|
||||
|
||||
- `ghcr.io/3252a8/remnawave-minishop-backend`
|
||||
- `ghcr.io/3252a8/remnawave-minishop-worker`
|
||||
- `ghcr.io/3252a8/remnawave-minishop-frontend`
|
||||
- `docker.io/3252a8/remnawave-minishop-backend`
|
||||
- `docker.io/3252a8/remnawave-minishop-worker`
|
||||
- `docker.io/3252a8/remnawave-minishop-frontend`
|
||||
|
||||
Сборка и публикация сразу в GHCR и Docker Hub:
|
||||
|
||||
```bash
|
||||
docker login ghcr.io
|
||||
docker login docker.io
|
||||
IMAGE_TAG=v3.4.3 bash scripts/docker-build-push-images.sh
|
||||
```
|
||||
|
||||
## Поддержать проект
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -6,6 +6,7 @@ from bot.app.web.admin_api_impl import (
|
||||
_runtime as _runtime,
|
||||
ads as _ads,
|
||||
auth as _auth,
|
||||
backups as _backups,
|
||||
broadcast as _broadcast,
|
||||
common as _common,
|
||||
logs as _logs,
|
||||
@@ -36,6 +37,7 @@ _MODULES = (
|
||||
_broadcast,
|
||||
_sync,
|
||||
_ads,
|
||||
_backups,
|
||||
_settings,
|
||||
_tariffs,
|
||||
_themes,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
import secrets
|
||||
import subprocess
|
||||
|
||||
from bot.infra.redis import redis_lock
|
||||
from bot.services.backup_restore_service import (
|
||||
BACKUP_UPLOAD_MAX_BYTES,
|
||||
BackupArchiveError,
|
||||
BackupArchiveInfo,
|
||||
BackupRestoreError,
|
||||
BackupRestoreService,
|
||||
)
|
||||
from bot.services.backup_worker import BackupWorker
|
||||
|
||||
|
||||
def _backup_archive_payload(archive) -> Dict[str, Any]:
|
||||
return archive.to_payload()
|
||||
|
||||
|
||||
async def _read_uploaded_backup_file(request: web.Request) -> BackupArchiveInfo:
|
||||
settings: Settings = request.app["settings"]
|
||||
service = BackupRestoreService(settings)
|
||||
backup_dir = service.backup_dir()
|
||||
temp_path: Optional[Path] = None
|
||||
|
||||
reader = await request.multipart()
|
||||
try:
|
||||
async for part in reader:
|
||||
if part.name != "file":
|
||||
continue
|
||||
|
||||
original_filename = part.filename or "backup.zip"
|
||||
temp_path = backup_dir / f".upload-{secrets.token_urlsafe(12)}.zip.tmp"
|
||||
size = 0
|
||||
with temp_path.open("wb") as handle:
|
||||
while True:
|
||||
chunk = await part.read_chunk(size=1024 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
size += len(chunk)
|
||||
if size > BACKUP_UPLOAD_MAX_BYTES:
|
||||
raise BackupArchiveError("Backup archive is too large")
|
||||
handle.write(chunk)
|
||||
if size <= 0:
|
||||
raise BackupArchiveError("Uploaded archive is empty")
|
||||
archive = service.import_uploaded_archive(temp_path, original_filename)
|
||||
temp_path = None
|
||||
return archive
|
||||
finally:
|
||||
if temp_path is not None and temp_path.exists():
|
||||
try:
|
||||
temp_path.unlink()
|
||||
except OSError:
|
||||
logger.warning("Failed to remove temporary backup upload %s", temp_path)
|
||||
|
||||
raise BackupArchiveError("file field is required")
|
||||
|
||||
|
||||
async def admin_backups_list_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
settings: Settings = request.app["settings"]
|
||||
try:
|
||||
service = BackupRestoreService(settings)
|
||||
archives = service.list_archives()
|
||||
except OSError as exc:
|
||||
logger.exception("Failed to list backup archives")
|
||||
return _error(500, "backup_list_failed", str(exc))
|
||||
return _ok(
|
||||
{
|
||||
"backup_dir": str(service.backup_dir()),
|
||||
"archives": [_backup_archive_payload(archive) for archive in archives],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def admin_backups_upload_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
content_type = (request.headers.get("Content-Type") or "").lower()
|
||||
if not content_type.startswith("multipart/form-data"):
|
||||
return _error(400, "invalid_backup_archive", "multipart file upload is required")
|
||||
try:
|
||||
archive = await _read_uploaded_backup_file(request)
|
||||
except BackupArchiveError as exc:
|
||||
return _error(400, "invalid_backup_archive", str(exc))
|
||||
except OSError as exc:
|
||||
logger.exception("Failed to save uploaded backup archive")
|
||||
return _error(500, "backup_upload_failed", str(exc))
|
||||
return _ok({"archive": _backup_archive_payload(archive)})
|
||||
|
||||
|
||||
async def admin_backups_create_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
settings: Settings = request.app["settings"]
|
||||
bot = request.app["bot"]
|
||||
session_factory = request.app.get("async_session_factory")
|
||||
worker = BackupWorker(settings, bot, session_factory=session_factory)
|
||||
|
||||
ttl_seconds = max(
|
||||
60,
|
||||
int(
|
||||
max(
|
||||
getattr(settings, "BACKUP_LOCK_TTL_SECONDS", 7200) or 7200,
|
||||
getattr(settings, "BACKUP_PG_DUMP_TIMEOUT_SECONDS", 1800) or 1800,
|
||||
)
|
||||
),
|
||||
)
|
||||
try:
|
||||
async with redis_lock(settings, "backup-worker", ttl_seconds=ttl_seconds) as acquired:
|
||||
if not acquired:
|
||||
return _error(409, "backup_create_busy", "Backup or restore is already running")
|
||||
await worker.refresh_settings()
|
||||
result = await worker.create_and_send_backup(backup_type="manual")
|
||||
archive = BackupRestoreService(settings).inspect_archive(result.archive_path)
|
||||
except BackupArchiveError as exc:
|
||||
return _error(400, "invalid_backup_archive", str(exc))
|
||||
except (OSError, RuntimeError, subprocess.SubprocessError, TimeoutError) as exc:
|
||||
logger.exception("Manual backup creation failed")
|
||||
return _error(500, "backup_create_failed", str(exc))
|
||||
except Exception as exc:
|
||||
logger.exception("Manual backup creation failed")
|
||||
return _error(500, "backup_create_failed", str(exc))
|
||||
|
||||
return _ok(
|
||||
{
|
||||
"result": result.to_payload(),
|
||||
"archive": _backup_archive_payload(archive),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def admin_backups_restore_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
settings: Settings = request.app["settings"]
|
||||
payload = await _read_json(request)
|
||||
|
||||
archive_name = str(payload.get("archive_name") or "").strip()
|
||||
restore_database = bool(payload.get("restore_database"))
|
||||
restore_compose = bool(payload.get("restore_compose"))
|
||||
confirm = bool(payload.get("confirm"))
|
||||
if not confirm:
|
||||
return _error(400, "restore_confirmation_required")
|
||||
|
||||
service = BackupRestoreService(settings)
|
||||
ttl_seconds = max(
|
||||
60,
|
||||
int(
|
||||
max(
|
||||
getattr(settings, "BACKUP_LOCK_TTL_SECONDS", 7200) or 7200,
|
||||
getattr(settings, "BACKUP_PG_RESTORE_TIMEOUT_SECONDS", 1800) or 1800,
|
||||
)
|
||||
),
|
||||
)
|
||||
try:
|
||||
async with redis_lock(settings, "backup-worker", ttl_seconds=ttl_seconds) as acquired:
|
||||
if not acquired:
|
||||
return _error(409, "backup_restore_busy", "Backup or restore is already running")
|
||||
result = await service.restore_archive(
|
||||
archive_name,
|
||||
restore_database=restore_database,
|
||||
restore_compose=restore_compose,
|
||||
)
|
||||
except BackupArchiveError as exc:
|
||||
return _error(400, "invalid_backup_archive", str(exc))
|
||||
except BackupRestoreError as exc:
|
||||
logger.exception("Backup restore failed")
|
||||
return _error(500, "backup_restore_failed", str(exc))
|
||||
except (OSError, subprocess.SubprocessError, TimeoutError) as exc:
|
||||
logger.exception("Backup restore failed")
|
||||
return _error(500, "backup_restore_failed", str(exc))
|
||||
finally:
|
||||
try:
|
||||
from db import database_setup
|
||||
|
||||
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")
|
||||
|
||||
return _ok({"result": result.to_payload()})
|
||||
@@ -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)
|
||||
@@ -75,4 +76,8 @@ def setup_admin_routes(app: web.Application) -> None:
|
||||
router.add_put("/api/admin/themes", admin_themes_save_route)
|
||||
router.add_post("/api/admin/appearance/logo", admin_appearance_logo_upload_route)
|
||||
router.add_post("/api/admin/appearance/favicon", admin_appearance_favicon_upload_route)
|
||||
router.add_get("/api/admin/backups", admin_backups_list_route)
|
||||
router.add_post("/api/admin/backups/create", admin_backups_create_route)
|
||||
router.add_post("/api/admin/backups/upload", admin_backups_upload_route)
|
||||
router.add_post("/api/admin/backups/restore", admin_backups_restore_route)
|
||||
router.add_get("/api/admin/panel/internal-squads", admin_panel_internal_squads_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
|
||||
|
||||
@@ -11,6 +11,7 @@ from bot.services.locale_override_service import (
|
||||
update_locale_overrides,
|
||||
)
|
||||
|
||||
|
||||
def _locale_languages(
|
||||
i18n: JsonI18n,
|
||||
overrides: Optional[List[Dict[str, Any]]] = None,
|
||||
|
||||
@@ -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 (ГБ:цена)"
|
||||
),
|
||||
@@ -283,12 +352,20 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
||||
subsection="common",
|
||||
),
|
||||
# ─── Trial ─────────────────────────────────────────────────────
|
||||
SettingField("TRIAL_ENABLED", "bool", "pricing", "Триал включён", subsection="trial"),
|
||||
SettingField(
|
||||
"TRIAL_ENABLED",
|
||||
"bool",
|
||||
"pricing",
|
||||
"Триал включён",
|
||||
optional=False,
|
||||
subsection="trial",
|
||||
),
|
||||
SettingField(
|
||||
"TRIAL_DURATION_DAYS",
|
||||
"int",
|
||||
"pricing",
|
||||
"Длительность триала (дней)",
|
||||
optional=False,
|
||||
min=0,
|
||||
subsection="trial",
|
||||
),
|
||||
@@ -297,6 +374,7 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
||||
"float",
|
||||
"pricing",
|
||||
"Лимит трафика триала (ГБ)",
|
||||
optional=False,
|
||||
min=0,
|
||||
subsection="trial",
|
||||
),
|
||||
@@ -305,6 +383,7 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
||||
"string",
|
||||
"pricing",
|
||||
"Стратегия сброса трафика триала",
|
||||
optional=False,
|
||||
subsection="trial",
|
||||
),
|
||||
SettingField(
|
||||
@@ -323,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",
|
||||
@@ -386,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", "Уведомлять об истечении"
|
||||
),
|
||||
@@ -399,6 +429,14 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
||||
"За сколько дней предупреждать",
|
||||
min=0,
|
||||
),
|
||||
SettingField(
|
||||
"SUBSCRIPTION_NOTIFY_HOURS_BEFORE",
|
||||
"int",
|
||||
"notifications",
|
||||
"За сколько часов предупреждать",
|
||||
min=0,
|
||||
max=23,
|
||||
),
|
||||
SettingField("LOG_NEW_USERS", "bool", "notifications", "Логировать новых пользователей"),
|
||||
SettingField("LOG_PAYMENTS", "bool", "notifications", "Логировать платежи"),
|
||||
SettingField("LOG_SUPPORT", "bool", "notifications", "Логировать тикеты поддержки"),
|
||||
@@ -434,6 +472,55 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
||||
"ID треда поддержки",
|
||||
"Тред лог-чата для уведомлений о тикетах поддержки.",
|
||||
),
|
||||
SettingField(
|
||||
"BACKUP_ENABLED",
|
||||
"bool",
|
||||
"backups",
|
||||
"Бэкапы включены",
|
||||
"Worker будет периодически собирать ZIP-архив и отправлять его в Telegram.",
|
||||
),
|
||||
SettingField(
|
||||
"BACKUP_CHAT_ID",
|
||||
"int",
|
||||
"backups",
|
||||
"ID чата для бэкапов",
|
||||
"Куда отправлять ZIP-архивы. Если пусто, используется LOG_CHAT_ID.",
|
||||
),
|
||||
SettingField(
|
||||
"BACKUP_THREAD_ID",
|
||||
"int",
|
||||
"backups",
|
||||
"ID треда для бэкапов",
|
||||
"Необязательный topic/thread ID. Если пусто, используется LOG_THREAD_ID.",
|
||||
),
|
||||
SettingField(
|
||||
"BACKUP_INTERVAL_SECONDS",
|
||||
"int",
|
||||
"backups",
|
||||
"Период бэкапов (сек.)",
|
||||
"По умолчанию 3600: запуск на границе часа (12:00, 13:00 и т.д.).",
|
||||
optional=False,
|
||||
min=60,
|
||||
),
|
||||
SettingField(
|
||||
"BACKUP_LOCAL_RETENTION",
|
||||
"int",
|
||||
"backups",
|
||||
"Сколько архивов хранить",
|
||||
"Сколько последних ZIP-архивов оставлять в data/backups на сервере.",
|
||||
optional=False,
|
||||
min=1,
|
||||
),
|
||||
SettingField(
|
||||
"BACKUP_COMPOSE_ENABLED",
|
||||
"bool",
|
||||
"backups",
|
||||
"Добавлять compose-папку",
|
||||
(
|
||||
"Добавляет snapshot /app/compose-source. Если папка не смонтирована, "
|
||||
"бэкап БД все равно будет создан."
|
||||
),
|
||||
),
|
||||
SettingField(
|
||||
"SUPPORT_TICKETS_ENABLED",
|
||||
"bool",
|
||||
@@ -501,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",
|
||||
"Анонимная статистика установки",
|
||||
"Раз в сутки отправляет обезличенный сигнал: версия, ОС, локаль и число "
|
||||
"пользователей в виде диапазона. Без персональных данных, токенов и "
|
||||
"доменов. Помогает понять число активных установок и какие версии "
|
||||
"используются. Можно отключить здесь без перезапуска.",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@@ -561,6 +659,8 @@ def coerce_value(field: SettingField, raw: Any) -> Any:
|
||||
return text
|
||||
|
||||
if raw is None or (isinstance(raw, str) and raw.strip() == ""):
|
||||
if not field.optional:
|
||||
raise ValueError(f"{field.key}: value required")
|
||||
return None
|
||||
|
||||
if field.type == "bool":
|
||||
@@ -623,14 +723,17 @@ def manifest_payload() -> List[dict]:
|
||||
sections_order = {
|
||||
"general": 1,
|
||||
"appearance": 2,
|
||||
"remnawave": 3,
|
||||
"pricing": 11,
|
||||
"payments": 4,
|
||||
"trial": 5,
|
||||
"referral": 6,
|
||||
"notifications": 7,
|
||||
"support": 8,
|
||||
"devices": 9,
|
||||
"backups": 9,
|
||||
"devices": 10,
|
||||
"subscription_guides": 10,
|
||||
"system": 12,
|
||||
}
|
||||
exclusive_map = {
|
||||
key: opposite
|
||||
@@ -678,12 +781,25 @@ def manifest_payload() -> List[dict]:
|
||||
"optional": field.optional,
|
||||
"secret": field.secret,
|
||||
}
|
||||
if field.min is not None:
|
||||
item["min"] = field.min
|
||||
if field.max is not None:
|
||||
item["max"] = field.max
|
||||
if field.key in exclusive_map:
|
||||
item["mutually_exclusive_key"] = exclusive_map[field.key]
|
||||
if default_value is not None:
|
||||
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,
|
||||
|
||||
|
After Width: | Height: | Size: 436 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 738 B |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 27 KiB |
@@ -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
|
||||
@@ -71,6 +76,12 @@ WEBAPP_UPLOADED_LOGO_DIR = WEBAPP_LOGO_CACHE_DIR / "uploads"
|
||||
WEBAPP_UPLOADED_LOGO_PATH = "/webapp-uploaded-logo"
|
||||
WEBAPP_FAVICON_DIR = WEBAPP_LOGO_CACHE_DIR / "favicons"
|
||||
WEBAPP_FAVICON_PATH = "/webapp-favicon"
|
||||
WEBAPP_DEFAULT_BRAND_DIR = ASSET_DIR / "default-brand"
|
||||
WEBAPP_DEFAULT_LOGO_FILE = WEBAPP_DEFAULT_BRAND_DIR / "default-logo.webp"
|
||||
WEBAPP_DEFAULT_LOGO_PATH = "/webapp-default-logo.webp"
|
||||
WEBAPP_DEFAULT_FAVICON_DIGEST = "19b2a242e5b7bc2d"
|
||||
WEBAPP_DEFAULT_FAVICON_DIR = WEBAPP_DEFAULT_BRAND_DIR / "favicons" / WEBAPP_DEFAULT_FAVICON_DIGEST
|
||||
WEBAPP_DEFAULT_FAVICON_URL = f"{WEBAPP_FAVICON_PATH}/{WEBAPP_DEFAULT_FAVICON_DIGEST}/icon-180.png"
|
||||
WEBAPP_EMOJI_CACHE_DIR = APP_ROOT / "data" / "webapp-emoji"
|
||||
WEBAPP_CONFIG_PLACEHOLDER = "<!-- WEBAPP_CONFIG_SCRIPT -->"
|
||||
WEBAPP_I18N_PLACEHOLDER = "<!-- WEBAPP_I18N_SCRIPT -->"
|
||||
@@ -101,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,
|
||||
@@ -482,6 +485,7 @@ async def account_avatar_route(request: web.Request) -> web.Response:
|
||||
|
||||
async def account_language_route(request: web.Request) -> web.Response:
|
||||
user_id = _require_user_id(request)
|
||||
settings: Settings = request.app["settings"]
|
||||
payload = await _read_json(request)
|
||||
language_payload, validation_error = _validate_model_payload(WebAppLanguagePayload, payload)
|
||||
if validation_error:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -205,7 +211,7 @@ def _resolve_webapp_logo_url(settings: Settings) -> str:
|
||||
|
||||
raw_logo_url = (getattr(settings, "WEBAPP_LOGO_URL", None) or "").strip()
|
||||
if not raw_logo_url:
|
||||
return ""
|
||||
return WEBAPP_DEFAULT_LOGO_PATH
|
||||
|
||||
parsed_logo_url = urlsplit(raw_logo_url)
|
||||
if parsed_logo_url.scheme == "https":
|
||||
@@ -215,7 +221,7 @@ def _resolve_webapp_logo_url(settings: Settings) -> str:
|
||||
return raw_logo_url
|
||||
if raw_logo_url.startswith("/"):
|
||||
return raw_logo_url
|
||||
return ""
|
||||
return WEBAPP_DEFAULT_LOGO_PATH
|
||||
|
||||
|
||||
def _resolve_webapp_favicon_url(settings: Settings, logo_url: str = "") -> str:
|
||||
@@ -227,7 +233,9 @@ def _resolve_webapp_favicon_url(settings: Settings, logo_url: str = "") -> str:
|
||||
resolved = _resolve_webapp_asset_url(raw_logo_favicon_url)
|
||||
if resolved:
|
||||
return resolved
|
||||
return logo_url or ""
|
||||
if logo_url and logo_url != WEBAPP_DEFAULT_LOGO_PATH:
|
||||
return logo_url
|
||||
return WEBAPP_DEFAULT_FAVICON_URL
|
||||
|
||||
|
||||
def _resolve_webapp_asset_url(raw_url: str) -> str:
|
||||
@@ -364,6 +372,16 @@ async def webapp_uploaded_logo_route(request: web.Request) -> web.Response:
|
||||
return _uploaded_webapp_logo_response(filename)
|
||||
|
||||
|
||||
async def webapp_default_logo_route(request: web.Request) -> web.Response:
|
||||
settings: Settings = request.app["settings"]
|
||||
if not settings.WEBAPP_ENABLED:
|
||||
raise web.HTTPNotFound(text="webapp_disabled")
|
||||
|
||||
response = _webapp_default_brand_file_response(WEBAPP_DEFAULT_LOGO_FILE, "image/webp")
|
||||
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
|
||||
return response
|
||||
|
||||
|
||||
async def webapp_favicon_route(request: web.Request) -> web.Response:
|
||||
settings: Settings = request.app["settings"]
|
||||
if not settings.WEBAPP_ENABLED:
|
||||
@@ -451,6 +469,9 @@ def _webapp_favicon_file_response(digest: str, filename: str) -> web.Response:
|
||||
):
|
||||
raise web.HTTPNotFound(text="webapp_favicon_not_found")
|
||||
|
||||
if digest == WEBAPP_DEFAULT_FAVICON_DIGEST:
|
||||
return _webapp_default_favicon_file_response(filename)
|
||||
|
||||
root = WEBAPP_FAVICON_DIR.expanduser().resolve()
|
||||
path = (root / digest / filename).resolve()
|
||||
try:
|
||||
@@ -477,6 +498,29 @@ def _webapp_favicon_file_response(digest: str, filename: str) -> web.Response:
|
||||
return response
|
||||
|
||||
|
||||
def _webapp_default_favicon_file_response(filename: str) -> web.Response:
|
||||
path = WEBAPP_DEFAULT_FAVICON_DIR / filename
|
||||
content_type = WEBAPP_THEME_ASSET_CONTENT_TYPES.get(path.suffix.lower())
|
||||
if not content_type:
|
||||
raise web.HTTPNotFound(text="webapp_favicon_not_found")
|
||||
|
||||
response = _webapp_default_brand_file_response(path, content_type)
|
||||
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
|
||||
return response
|
||||
|
||||
|
||||
def _webapp_default_brand_file_response(path: Path, content_type: str) -> web.Response:
|
||||
try:
|
||||
body = _read_template_binary_cached(path)
|
||||
except OSError:
|
||||
raise web.HTTPNotFound(text="webapp_default_brand_not_found") from None
|
||||
|
||||
if not body or len(body) > WEBAPP_LOGO_MAX_BYTES:
|
||||
raise web.HTTPNotFound(text="webapp_default_brand_not_found")
|
||||
|
||||
return web.Response(body=body, content_type=content_type)
|
||||
|
||||
|
||||
async def webapp_animated_emoji_route(request: web.Request) -> web.Response:
|
||||
codepoints = str(request.match_info.get("codepoints") or "").strip().lower()
|
||||
ext = str(request.match_info.get("ext") or "").strip().lower()
|
||||
@@ -831,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",
|
||||
(
|
||||
@@ -889,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)
|
||||
|
||||
@@ -1203,7 +1208,7 @@ def _apply_telegram_profile_to_user(
|
||||
settings: Settings,
|
||||
) -> None:
|
||||
language_code = _normalize_language(
|
||||
telegram_user.get("language_code") or user.language_code or settings.DEFAULT_LANGUAGE
|
||||
user.language_code or telegram_user.get("language_code") or settings.DEFAULT_LANGUAGE
|
||||
)
|
||||
|
||||
user.telegram_id = int(telegram_user["id"])
|
||||
@@ -1251,8 +1256,8 @@ async def _link_telegram_to_user(
|
||||
|
||||
if not existing_telegram_user and int(current_user.user_id) < 0:
|
||||
language_code = _normalize_language(
|
||||
telegram_user.get("language_code")
|
||||
or current_user.language_code
|
||||
current_user.language_code
|
||||
or telegram_user.get("language_code")
|
||||
or settings.DEFAULT_LANGUAGE
|
||||
)
|
||||
target_user, _ = await user_dal.create_user(
|
||||
@@ -1400,20 +1405,19 @@ async def _ensure_user_from_telegram(
|
||||
referral_param: Optional[str] = None,
|
||||
) -> User:
|
||||
user_id = int(telegram_user["id"])
|
||||
language_code = _normalize_language(
|
||||
telegram_language_code = _normalize_language(
|
||||
telegram_user.get("language_code") or settings.DEFAULT_LANGUAGE
|
||||
)
|
||||
|
||||
update_data = {
|
||||
profile_data = {
|
||||
"telegram_id": user_id,
|
||||
"username": sanitize_username(telegram_user.get("username")),
|
||||
"first_name": sanitize_display_name(telegram_user.get("first_name")),
|
||||
"last_name": sanitize_display_name(telegram_user.get("last_name")),
|
||||
"language_code": language_code,
|
||||
}
|
||||
telegram_photo_url = _telegram_photo_url_value(telegram_user)
|
||||
if telegram_photo_url:
|
||||
update_data["telegram_photo_url"] = telegram_photo_url
|
||||
profile_data["telegram_photo_url"] = telegram_photo_url
|
||||
|
||||
db_user = await user_dal.get_user_by_telegram_id(session, user_id)
|
||||
if not db_user:
|
||||
@@ -1428,7 +1432,8 @@ async def _ensure_user_from_telegram(
|
||||
session,
|
||||
{
|
||||
"user_id": user_id,
|
||||
**update_data,
|
||||
**profile_data,
|
||||
"language_code": telegram_language_code,
|
||||
"referred_by_id": referred_by_id,
|
||||
"registration_date": datetime.now(timezone.utc),
|
||||
},
|
||||
@@ -1436,6 +1441,10 @@ async def _ensure_user_from_telegram(
|
||||
setattr(db_user, "_webapp_created", bool(created))
|
||||
return db_user
|
||||
|
||||
update_data = {
|
||||
**profile_data,
|
||||
"language_code": _normalize_language(db_user.language_code or telegram_language_code),
|
||||
}
|
||||
changed = {key: value for key, value in update_data.items() if getattr(db_user, key) != value}
|
||||
if changed:
|
||||
db_user = await user_dal.update_user(session, db_user.user_id, changed) or db_user
|
||||
|
||||
@@ -4,6 +4,42 @@ from ._runtime import * # noqa: F403,F405
|
||||
from bot.app.web.webapp.cache_helpers import invalidate_webapp_user_caches
|
||||
|
||||
|
||||
def _billing_iso_datetime(value: Optional[Any]) -> Optional[str]:
|
||||
if not value:
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
normalized = value if value.tzinfo else value.replace(tzinfo=timezone.utc)
|
||||
return normalized.isoformat()
|
||||
return str(value)
|
||||
|
||||
|
||||
def _billing_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")
|
||||
text = str(value)
|
||||
try:
|
||||
normalized = datetime.fromisoformat(text.replace("Z", "+00:00"))
|
||||
return normalized.strftime("%d.%m.%Y %H:%M")
|
||||
except Exception:
|
||||
return text
|
||||
|
||||
|
||||
def _parse_positive_int_units(value: Any) -> Optional[int]:
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
try:
|
||||
number = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if not number.is_integer():
|
||||
return None
|
||||
integer = int(number)
|
||||
return integer if integer > 0 else None
|
||||
|
||||
|
||||
async def apply_promo_route(request: web.Request) -> web.Response:
|
||||
user_id = _require_user_id(request)
|
||||
payload = await _read_json(request)
|
||||
@@ -64,8 +100,11 @@ async def create_payment_route(request: web.Request) -> web.Response:
|
||||
return validation_error
|
||||
method = str(payment_payload.method or "").strip().lower()
|
||||
settings: Settings = request.app["settings"]
|
||||
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
|
||||
@@ -86,15 +125,12 @@ async def create_payment_route(request: web.Request) -> web.Response:
|
||||
return _json_error(400, "invalid_plan", "Tariff is not available")
|
||||
if tariff.billing_model != "period":
|
||||
return _json_error(400, "invalid_plan", "Device top-up is not available")
|
||||
try:
|
||||
device_count = int(
|
||||
float(
|
||||
payment_payload.device_count
|
||||
if payment_payload.device_count is not None
|
||||
else payment_payload.months
|
||||
)
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
device_count = _parse_positive_int_units(
|
||||
payment_payload.device_count
|
||||
if payment_payload.device_count is not None
|
||||
else payment_payload.months
|
||||
)
|
||||
if device_count is None:
|
||||
return _json_error(400, "invalid_plan", "Invalid device package")
|
||||
if not tariff.hwid_device_packages:
|
||||
return _json_error(400, "invalid_plan", "Device package is not available")
|
||||
@@ -121,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
|
||||
)
|
||||
@@ -162,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
|
||||
)
|
||||
@@ -190,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":
|
||||
@@ -262,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,
|
||||
@@ -291,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,
|
||||
@@ -549,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:
|
||||
@@ -584,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}",
|
||||
)
|
||||
@@ -613,27 +656,35 @@ async def device_topup_options_route(request: web.Request) -> web.Response:
|
||||
)
|
||||
tariff = config.require(sub.tariff_key)
|
||||
if tariff.billing_model != "period":
|
||||
return _json_error(
|
||||
400, "device_topup_unavailable", "Device top-up is not available"
|
||||
)
|
||||
return _json_error(400, "device_topup_unavailable", "Device top-up is not available")
|
||||
lang = db_user.language_code or settings.DEFAULT_LANGUAGE
|
||||
active = await subscription_service.get_active_subscription_details(session, user_id)
|
||||
renewal_available = bool(active and active.get("device_topup_renewal_available"))
|
||||
extra_hwid_valid_until = active.get("extra_hwid_devices_valid_until") if active else None
|
||||
extra_hwid_valid_until_text = (
|
||||
active.get("extra_hwid_devices_valid_until_text") if active else None
|
||||
) 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 = (
|
||||
@@ -648,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 = {
|
||||
@@ -659,21 +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": (
|
||||
(rub_quote or stars_quote)["valid_from"].isoformat()
|
||||
if (rub_quote or stars_quote).get("valid_from")
|
||||
else None
|
||||
"valid_from": _billing_iso_datetime(
|
||||
(currency_quote or stars_quote).get("valid_from")
|
||||
),
|
||||
"valid_until": (
|
||||
(rub_quote or stars_quote)["valid_until"].isoformat()
|
||||
if (rub_quote or stars_quote).get("valid_until")
|
||||
else None
|
||||
"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
|
||||
),
|
||||
"proration_ratio": float((rub_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"])
|
||||
@@ -687,14 +736,8 @@ async def device_topup_options_route(request: web.Request) -> web.Response:
|
||||
"extra_hwid_devices": int(active.get("extra_hwid_devices") or 0)
|
||||
if active
|
||||
else int(sub.extra_hwid_devices or 0),
|
||||
"extra_hwid_devices_valid_until": active.get("extra_hwid_devices_valid_until")
|
||||
if active
|
||||
else None,
|
||||
"extra_hwid_devices_valid_until_text": active.get(
|
||||
"extra_hwid_devices_valid_until_text"
|
||||
)
|
||||
if active
|
||||
else None,
|
||||
"extra_hwid_devices_valid_until": _billing_iso_datetime(extra_hwid_valid_until),
|
||||
"extra_hwid_devices_valid_until_text": extra_hwid_valid_until_text,
|
||||
"renewal_available": renewal_available,
|
||||
"renewal_recommended_count": int(active.get("extra_hwid_devices") or 0)
|
||||
if active and renewal_available
|
||||
@@ -907,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)
|
||||
@@ -936,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,
|
||||
@@ -945,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,
|
||||
@@ -953,9 +1010,7 @@ async def _create_subscription_payment(
|
||||
hwid_pricing_period_months=hwid_quote.get("pricing_period_months")
|
||||
if hwid_quote
|
||||
else None,
|
||||
hwid_proration_ratio=hwid_quote.get("proration_ratio")
|
||||
if hwid_quote
|
||||
else None,
|
||||
hwid_proration_ratio=hwid_quote.get("proration_ratio") if hwid_quote else None,
|
||||
hwid_full_price=hwid_quote.get("full_price") if hwid_quote else None,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
@@ -193,6 +221,15 @@ def _format_device_datetime(value: Any) -> str:
|
||||
return text
|
||||
|
||||
|
||||
def _serialize_device_datetime(value: Any) -> Optional[str]:
|
||||
if not value:
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
normalized = value if value.tzinfo else value.replace(tzinfo=timezone.utc)
|
||||
return normalized.isoformat()
|
||||
return str(value)
|
||||
|
||||
|
||||
def _serialize_device(device: Dict[str, Any], index: int) -> Dict[str, Any]:
|
||||
hwid = str(device.get("hwid") or "").strip()
|
||||
model = str(device.get("deviceModel") or "").strip()
|
||||
@@ -208,7 +245,7 @@ def _serialize_device(device: Dict[str, Any], index: int) -> Dict[str, Any]:
|
||||
"os_version": os_version,
|
||||
"platform_label": platform_label,
|
||||
"user_agent": user_agent,
|
||||
"created_at": device.get("createdAt"),
|
||||
"created_at": _serialize_device_datetime(device.get("createdAt")),
|
||||
"created_at_text": _format_device_datetime(device.get("createdAt")),
|
||||
"hwid_short": _shorten_hwid_for_display(hwid),
|
||||
"token": _device_hwid_token(hwid) if hwid else "",
|
||||
|
||||
@@ -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)
|
||||
@@ -19,7 +20,7 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
|
||||
app.router.add_get(
|
||||
(
|
||||
"/admin/{section:stats|users|payments|promos|ads|broadcast|logs|tariffs|"
|
||||
"appearance|settings|translations|support}"
|
||||
"appearance|settings|translations|support|backups}"
|
||||
),
|
||||
index_route,
|
||||
)
|
||||
@@ -35,6 +36,7 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
|
||||
app.router.add_get("/apple-touch-icon-precomposed.png", webapp_current_favicon_route)
|
||||
app.router.add_get("/icon-192.png", webapp_current_favicon_route)
|
||||
app.router.add_get("/icon-512.png", webapp_current_favicon_route)
|
||||
app.router.add_get(WEBAPP_DEFAULT_LOGO_PATH, webapp_default_logo_route)
|
||||
app.router.add_get(WEBAPP_LOGO_PROXY_PATH, webapp_logo_route)
|
||||
app.router.add_get(
|
||||
rf"{WEBAPP_UPLOADED_LOGO_PATH}/{{filename:[A-Za-z0-9_.-]+}}",
|
||||
@@ -83,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":
|
||||
@@ -611,14 +711,12 @@ def _serialize_tariff_change_target(
|
||||
"mode": "recalc_days",
|
||||
"kind": "free",
|
||||
"title": "recalc_days",
|
||||
"days_after": int(options.get("recalc_days") or 0),
|
||||
"remaining_days": int(options.get("remaining_days") or 0),
|
||||
"converted_hwid_value_rub": float(
|
||||
options.get("converted_hwid_value_rub") or 0
|
||||
),
|
||||
"converted_hwid_days": int(options.get("converted_hwid_days") or 0),
|
||||
}
|
||||
)
|
||||
"days_after": int(options.get("recalc_days") or 0),
|
||||
"remaining_days": int(options.get("remaining_days") or 0),
|
||||
"converted_hwid_value_rub": float(options.get("converted_hwid_value_rub") or 0),
|
||||
"converted_hwid_days": int(options.get("converted_hwid_days") or 0),
|
||||
}
|
||||
)
|
||||
paid_diff = float(options.get("paid_diff_rub") or 0)
|
||||
if paid_diff > 0:
|
||||
actions.append(
|
||||
@@ -627,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":
|
||||
@@ -638,9 +736,7 @@ def _serialize_tariff_change_target(
|
||||
"title": "convert_days_to_gb",
|
||||
"converted_gb": float(options.get("converted_gb") or 0),
|
||||
"remaining_days": int(options.get("remaining_days") or 0),
|
||||
"converted_hwid_value_rub": float(
|
||||
options.get("converted_hwid_value_rub") or 0
|
||||
),
|
||||
"converted_hwid_value_rub": float(options.get("converted_hwid_value_rub") or 0),
|
||||
"converted_hwid_gb": float(options.get("converted_hwid_gb") or 0),
|
||||
}
|
||||
)
|
||||
@@ -651,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(
|
||||
{
|
||||
@@ -666,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 {
|
||||
@@ -690,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"]
|
||||
@@ -1203,15 +1205,13 @@ async def _perform_sync_impl(
|
||||
merge_panel_patches = int(merge_result.get("panel_patches", 0))
|
||||
if merge_panel_patches:
|
||||
panel_patch_count += merge_panel_patches
|
||||
panel_patch_reason_counts[
|
||||
"duplicate_panel_merge_extend"
|
||||
] += merge_panel_patches
|
||||
panel_patch_reason_counts["duplicate_panel_merge_extend"] += (
|
||||
merge_panel_patches
|
||||
)
|
||||
if merge_result["resolved"]:
|
||||
users_updated += 1
|
||||
users_uuid_updated += 1
|
||||
local_update_reason_counts.update(
|
||||
["duplicate_panel_identity_resolved"]
|
||||
)
|
||||
local_update_reason_counts.update(["duplicate_panel_identity_resolved"])
|
||||
panel_uuids_by_telegram_id.get(telegram_id_from_panel, set()).discard(
|
||||
str(panel_uuid)
|
||||
)
|
||||
@@ -1322,9 +1322,7 @@ async def _perform_sync_impl(
|
||||
current_panel_user=panel_user_for_identity,
|
||||
reasons=panel_reasons,
|
||||
panel_view=(
|
||||
"list"
|
||||
if missing_identity_fields_match
|
||||
else "full_fetch"
|
||||
"list" if missing_identity_fields_match else "full_fetch"
|
||||
),
|
||||
)
|
||||
panel_patch_count += 1
|
||||
|
||||
@@ -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
|
||||
@@ -543,9 +588,7 @@ async def hwid_devices_list_callback(
|
||||
renewal=renewal_available,
|
||||
)
|
||||
text_key = (
|
||||
"select_hwid_device_renewal_package"
|
||||
if renewal_available
|
||||
else "select_hwid_device_package"
|
||||
"select_hwid_device_renewal_package" if renewal_available else "select_hwid_device_package"
|
||||
)
|
||||
await callback.message.edit_text(
|
||||
get_text(
|
||||
@@ -582,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,
|
||||
@@ -591,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,
|
||||
@@ -607,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,
|
||||
@@ -703,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(
|
||||
@@ -717,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']}",
|
||||
)
|
||||
]
|
||||
@@ -731,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}",
|
||||
)
|
||||
]
|
||||
@@ -830,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(
|
||||
@@ -845,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()
|
||||
@@ -887,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,
|
||||
@@ -1067,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"
|
||||
@@ -1214,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(
|
||||
[
|
||||
@@ -1431,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)
|
||||
|
||||
|
||||
@@ -160,10 +160,7 @@ def locale_language_flag(code: object) -> str:
|
||||
|
||||
|
||||
def sort_locale_language_codes(codes: Iterable[object]) -> List[str]:
|
||||
normalized = {
|
||||
normalize_locale_language_code(code, prefer_known_base=False)
|
||||
for code in codes
|
||||
}
|
||||
normalized = {normalize_locale_language_code(code, prefer_known_base=False) for code in codes}
|
||||
normalized = {code for code in normalized if code and is_valid_locale_language_code(code)}
|
||||
preferred = [code for code in DEFAULT_LANGUAGE_ORDER if code in normalized]
|
||||
rest = sorted(code for code in normalized if code not in DEFAULT_LANGUAGE_ORDER)
|
||||
@@ -310,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()
|
||||
@@ -422,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"
|
||||
@@ -435,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",
|
||||
@@ -456,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,
|
||||
)
|
||||
@@ -270,6 +315,14 @@ class CryptoPayService:
|
||||
referral_service: ReferralService = app["referral_service"]
|
||||
|
||||
async with async_session_factory() as session:
|
||||
payment = await payment_dal.get_payment_by_db_id(session, payment_db_id)
|
||||
if not payment:
|
||||
logging.error("CryptoPay webhook: payment %s not found.", payment_db_id)
|
||||
return
|
||||
if payment.status == "succeeded":
|
||||
logging.info("CryptoPay webhook: payment %s already succeeded.", payment_db_id)
|
||||
return
|
||||
|
||||
try:
|
||||
await payment_dal.update_provider_payment_and_status(
|
||||
session,
|
||||
@@ -385,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)
|
||||
@@ -400,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:
|
||||
@@ -449,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,
|
||||
@@ -584,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,
|
||||
)
|
||||
@@ -49,12 +54,14 @@ from .shared import (
|
||||
parse_payment_callback,
|
||||
payment_failed,
|
||||
payment_unavailable,
|
||||
payment_units_for_activation,
|
||||
post_json_request,
|
||||
quote_hwid_callback_parts,
|
||||
render_link_or_fail,
|
||||
)
|
||||
|
||||
_LOG = "freekassa"
|
||||
FREEKASSA_SUPPORTED_CURRENCIES = ("RUB", "USD", "EUR", "UAH", "KZT")
|
||||
|
||||
|
||||
class FreeKassaConfig(ProviderEnvConfig):
|
||||
@@ -143,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)
|
||||
@@ -206,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),
|
||||
@@ -382,10 +395,10 @@ class FreeKassaService(HttpClientMixin):
|
||||
)
|
||||
return web.Response(status=500, text="processing_error")
|
||||
|
||||
months = payment.purchased_gb or payment.subscription_duration_months or 1
|
||||
sale_mode = payment.sale_mode or (
|
||||
"traffic" if self.settings.traffic_sale_mode else "subscription"
|
||||
)
|
||||
months = payment_units_for_activation(payment, sale_mode)
|
||||
|
||||
success_prefix: Optional[str] = None
|
||||
if provider_payment_id:
|
||||
@@ -476,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)
|
||||
@@ -484,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)
|
||||
@@ -577,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",
|
||||
)
|
||||
@@ -591,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},
|
||||
@@ -761,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,
|
||||
)
|
||||
@@ -49,6 +55,7 @@ from .shared import (
|
||||
parse_payment_callback,
|
||||
payment_failed,
|
||||
payment_unavailable,
|
||||
payment_units_for_activation,
|
||||
quote_hwid_callback_parts,
|
||||
render_link_or_fail,
|
||||
)
|
||||
@@ -58,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):
|
||||
@@ -82,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
|
||||
@@ -298,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,
|
||||
@@ -469,10 +490,10 @@ class HeleketService(HttpClientMixin):
|
||||
)
|
||||
return web.Response(status=500, text="processing_error")
|
||||
|
||||
payment_units = payment.purchased_gb or payment.subscription_duration_months or 1
|
||||
sale_mode = payment.sale_mode or (
|
||||
"traffic" if self.settings.traffic_sale_mode else "subscription"
|
||||
)
|
||||
payment_units = payment_units_for_activation(payment, sale_mode)
|
||||
|
||||
outcome = await finalize_successful_payment(
|
||||
PaymentSuccessRequest(
|
||||
@@ -571,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,
|
||||
@@ -631,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,
|
||||
@@ -786,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",
|
||||
@@ -858,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,
|
||||
)
|
||||
@@ -45,6 +51,7 @@ from .shared import (
|
||||
payment_failed,
|
||||
payment_record_amounts,
|
||||
payment_unavailable,
|
||||
payment_units_for_activation,
|
||||
post_json_request,
|
||||
quote_hwid_callback_parts,
|
||||
render_link_or_fail,
|
||||
@@ -75,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
|
||||
@@ -228,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] = {
|
||||
@@ -303,10 +321,10 @@ class PlategaService(HttpClientMixin):
|
||||
if payment.status == "succeeded" and status == "CONFIRMED":
|
||||
return web.Response(text="ok")
|
||||
|
||||
payment_months = payment.purchased_gb or payment.subscription_duration_months or 1
|
||||
sale_mode = payment.sale_mode or (
|
||||
"traffic" if self.settings.traffic_sale_mode else "subscription"
|
||||
)
|
||||
payment_months = payment_units_for_activation(payment, sale_mode)
|
||||
|
||||
if status == "CONFIRMED":
|
||||
if amount_raw is not None:
|
||||
@@ -481,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,
|
||||
@@ -595,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",
|
||||
)
|
||||
@@ -615,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,
|
||||
@@ -756,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"
|
||||
),
|
||||
@@ -792,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(
|
||||
@@ -817,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,
|
||||
)
|
||||
@@ -46,6 +52,7 @@ from .shared import (
|
||||
parse_payment_callback,
|
||||
payment_failed,
|
||||
payment_unavailable,
|
||||
payment_units_for_activation,
|
||||
post_json_request,
|
||||
quote_hwid_callback_parts,
|
||||
render_link_or_fail,
|
||||
@@ -68,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
|
||||
@@ -200,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),
|
||||
@@ -270,12 +288,19 @@ class SeverPayService(HttpClientMixin):
|
||||
return web.json_response({"status": False, "msg": "payment_not_found"}, status=404)
|
||||
|
||||
resolved_provider_id = provider_payment_id or str(payment.payment_id)
|
||||
payment_months = payment.purchased_gb or payment.subscription_duration_months or 1
|
||||
sale_mode = payment.sale_mode or (
|
||||
"traffic" if self.settings.traffic_sale_mode else "subscription"
|
||||
)
|
||||
payment_months = payment_units_for_activation(payment, sale_mode)
|
||||
|
||||
if status == "success":
|
||||
if payment.status == "succeeded":
|
||||
logging.info(
|
||||
"SeverPay webhook: payment %s already succeeded.",
|
||||
payment.payment_id,
|
||||
)
|
||||
return web.json_response({"status": True})
|
||||
|
||||
try:
|
||||
await payment_dal.update_provider_payment_and_status(
|
||||
session,
|
||||
@@ -420,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,
|
||||
@@ -495,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,
|
||||
@@ -619,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",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -643,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",
|
||||
)
|
||||
|
||||
@@ -36,10 +36,12 @@ from .common import (
|
||||
json_error,
|
||||
make_translator,
|
||||
mark_payment_failed_creation,
|
||||
parse_positive_int_units,
|
||||
payment_failed,
|
||||
payment_link_response,
|
||||
payment_record_amounts,
|
||||
payment_unavailable,
|
||||
payment_units_for_activation,
|
||||
sale_mode_base,
|
||||
sale_mode_is_hwid_devices,
|
||||
sale_mode_is_traffic,
|
||||
@@ -103,6 +105,7 @@ __all__ = [
|
||||
"lookup_payment_by_order_or_provider_id",
|
||||
"make_translator",
|
||||
"mark_payment_failed_creation",
|
||||
"parse_positive_int_units",
|
||||
"notify_admins_payment_received",
|
||||
"notify_callback_parse_error",
|
||||
"notify_payment_gateway_failure",
|
||||
@@ -114,6 +117,7 @@ __all__ = [
|
||||
"payment_link_message_text",
|
||||
"payment_link_response",
|
||||
"payment_record_amounts",
|
||||
"payment_units_for_activation",
|
||||
"payment_unavailable",
|
||||
"post_json_request",
|
||||
"quote_hwid_callback_parts",
|
||||
|
||||
@@ -20,6 +20,7 @@ from .common import (
|
||||
build_payment_description,
|
||||
format_human_units,
|
||||
mark_payment_failed_creation,
|
||||
parse_positive_int_units,
|
||||
sale_mode_base,
|
||||
sale_mode_is_hwid_devices,
|
||||
sale_mode_tariff_key,
|
||||
@@ -124,10 +125,13 @@ async def quote_hwid_callback_parts(
|
||||
) -> tuple[Optional[PaymentCallbackParts], Optional[dict]]:
|
||||
if not sale_mode_is_hwid_devices(parts.sale_mode):
|
||||
return parts, None
|
||||
device_count = parse_positive_int_units(parts.months)
|
||||
if device_count is None:
|
||||
return None, None
|
||||
quote = await subscription_service.quote_hwid_device_topup(
|
||||
session,
|
||||
user_id=user_id,
|
||||
device_count=int(parts.months),
|
||||
device_count=device_count,
|
||||
tariff_key=sale_mode_tariff_key(parts.sale_mode),
|
||||
renewal=sale_mode_base(parts.sale_mode) == "hwid_devices_renewal",
|
||||
currency=currency,
|
||||
@@ -135,7 +139,7 @@ async def quote_hwid_callback_parts(
|
||||
if not quote:
|
||||
return None, None
|
||||
quoted_parts = PaymentCallbackParts(
|
||||
months=parts.months,
|
||||
months=device_count,
|
||||
price=float(quote.get("price") or 0),
|
||||
sale_mode=parts.sale_mode,
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from aiohttp import web
|
||||
@@ -36,6 +36,20 @@ def decimal_amounts_equal(left: Any, right: Any, places: int = 2) -> bool:
|
||||
return format_decimal_amount(left, places) == format_decimal_amount(right, places)
|
||||
|
||||
|
||||
def parse_positive_int_units(value: Any) -> Optional[int]:
|
||||
"""Return a positive integer only when the input represents whole units exactly."""
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
try:
|
||||
decimal_value = Decimal(str(value).strip())
|
||||
except (InvalidOperation, ValueError):
|
||||
return None
|
||||
if not decimal_value.is_finite() or decimal_value != decimal_value.to_integral_value():
|
||||
return None
|
||||
integer_value = int(decimal_value)
|
||||
return integer_value if integer_value > 0 else None
|
||||
|
||||
|
||||
def format_human_units(value: Any) -> str:
|
||||
"""Render numeric units the way the UI expects: integers w/o decimals, floats with %g."""
|
||||
numeric = float(value)
|
||||
@@ -164,6 +178,24 @@ def payment_record_amounts(
|
||||
)
|
||||
|
||||
|
||||
def payment_units_for_activation(payment: Any, sale_mode: str) -> Any:
|
||||
"""Resolve purchased units from a payment record for webhook activation."""
|
||||
base = sale_mode_base(sale_mode)
|
||||
if sale_mode_is_traffic(base):
|
||||
return (
|
||||
getattr(payment, "purchased_gb", None)
|
||||
or getattr(payment, "subscription_duration_months", None)
|
||||
or 1
|
||||
)
|
||||
if sale_mode_is_hwid_devices(base):
|
||||
return (
|
||||
getattr(payment, "purchased_hwid_devices", None)
|
||||
or getattr(payment, "subscription_duration_months", None)
|
||||
or 1
|
||||
)
|
||||
return getattr(payment, "subscription_duration_months", None) or 1
|
||||
|
||||
|
||||
def json_error(status: int, code: str, message: str) -> web.Response:
|
||||
return web.json_response({"ok": False, "error": code, "message": message}, status=status)
|
||||
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -143,6 +143,14 @@ class StarsService:
|
||||
i18n_data: dict,
|
||||
sale_mode: str = "subscription",
|
||||
) -> None:
|
||||
payment = await payment_dal.get_payment_by_db_id(session, payment_db_id)
|
||||
if not payment:
|
||||
logging.error("Stars: payment %s not found.", payment_db_id)
|
||||
return
|
||||
if payment.status == "succeeded":
|
||||
logging.info("Stars: payment %s already succeeded.", payment_db_id)
|
||||
return
|
||||
|
||||
try:
|
||||
payment_record = await payment_dal.update_provider_payment_and_status(
|
||||
session,
|
||||
@@ -162,9 +170,6 @@ class StarsService:
|
||||
else int(message.from_user.id)
|
||||
)
|
||||
payment = await payment_dal.get_payment_by_db_id(session, payment_db_id)
|
||||
if not payment:
|
||||
logging.error("Stars: payment %s vanished after status update.", payment_db_id)
|
||||
return
|
||||
|
||||
await finalize_successful_payment(
|
||||
PaymentSuccessRequest(
|
||||
@@ -479,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,
|
||||
)
|
||||
@@ -52,6 +57,7 @@ from .shared import (
|
||||
payment_link_response,
|
||||
payment_record_amounts,
|
||||
payment_unavailable,
|
||||
payment_units_for_activation,
|
||||
post_json_request,
|
||||
quote_hwid_callback_parts,
|
||||
render_link_or_fail,
|
||||
@@ -62,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
|
||||
@@ -257,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,
|
||||
@@ -531,10 +548,10 @@ class WataService(HttpClientMixin):
|
||||
)
|
||||
return None
|
||||
|
||||
payment_units = payment.purchased_gb or payment.subscription_duration_months or 1
|
||||
sale_mode = payment.sale_mode or (
|
||||
"traffic" if self.settings.traffic_sale_mode else "subscription"
|
||||
)
|
||||
payment_units = payment_units_for_activation(payment, sale_mode)
|
||||
outcome = await finalize_successful_payment(
|
||||
PaymentSuccessRequest(
|
||||
bot=self.bot,
|
||||
@@ -862,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)
|
||||
@@ -955,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,
|
||||
@@ -1189,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,
|
||||
)
|
||||
@@ -63,6 +69,7 @@ from .shared import (
|
||||
make_translator,
|
||||
mark_payment_failed_creation,
|
||||
notify_admins_payment_received,
|
||||
parse_positive_int_units,
|
||||
payment_failed,
|
||||
payment_link_response,
|
||||
payment_record_amounts,
|
||||
@@ -74,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):
|
||||
@@ -144,10 +154,12 @@ class YooKassaService:
|
||||
bot_username_for_default_return: Optional[str] = None,
|
||||
settings_obj: Optional[Settings] = None,
|
||||
config: Optional[YooKassaConfig] = None,
|
||||
subscription_service: Optional[SubscriptionService] = None,
|
||||
):
|
||||
|
||||
self.settings = settings_obj
|
||||
self.config = config or YooKassaConfig()
|
||||
self.subscription_service = subscription_service
|
||||
self._bot_username_for_default_return = bot_username_for_default_return
|
||||
self._configured_return_url_override = configured_return_url
|
||||
self._sdk_configured_for = (
|
||||
@@ -169,9 +181,7 @@ class YooKassaService:
|
||||
@property
|
||||
def configured(self) -> bool:
|
||||
if not (
|
||||
provider_runtime_enabled(self.config)
|
||||
and self.config.SHOP_ID
|
||||
and self.config.SECRET_KEY
|
||||
provider_runtime_enabled(self.config) and self.config.SHOP_ID and self.config.SECRET_KEY
|
||||
):
|
||||
return False
|
||||
self._ensure_sdk_configured()
|
||||
@@ -229,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
|
||||
@@ -444,11 +459,17 @@ def _resolve_yookassa_activation_amounts(
|
||||
traffic_amount_gb = (
|
||||
float(traffic_gb_raw) if _metadata_value_present(traffic_gb_raw) else subscription_months
|
||||
)
|
||||
hwid_devices_count = (
|
||||
int(float(hwid_devices_raw))
|
||||
if _metadata_value_present(hwid_devices_raw)
|
||||
else (int(subscription_months) if _is_hwid_device_sale_base(sale_mode_base) else 0)
|
||||
)
|
||||
hwid_devices_count = 0
|
||||
if _metadata_value_present(hwid_devices_raw):
|
||||
parsed_hwid_devices = parse_positive_int_units(hwid_devices_raw)
|
||||
if parsed_hwid_devices is None:
|
||||
raise ValueError("Invalid HWID device count")
|
||||
hwid_devices_count = parsed_hwid_devices
|
||||
elif _is_hwid_device_sale_base(sale_mode_base):
|
||||
parsed_hwid_devices = parse_positive_int_units(subscription_months_raw)
|
||||
if parsed_hwid_devices is None:
|
||||
raise ValueError("Invalid HWID device count")
|
||||
hwid_devices_count = parsed_hwid_devices
|
||||
|
||||
if sale_mode_base == "subscription":
|
||||
months_for_activation = int(subscription_months)
|
||||
@@ -742,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"):
|
||||
@@ -873,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
|
||||
@@ -942,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(
|
||||
@@ -1195,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
|
||||
@@ -1327,9 +1384,7 @@ async def _initiate_yk_payment(
|
||||
"purchased_gb": float(months)
|
||||
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
|
||||
else None,
|
||||
"purchased_hwid_devices": int(months)
|
||||
if sale_base in HWID_DEVICE_SALE_BASES
|
||||
else None,
|
||||
"purchased_hwid_devices": int(months) if sale_base in HWID_DEVICE_SALE_BASES else None,
|
||||
"hwid_valid_from": hwid_quote.get("valid_from") if hwid_quote else None,
|
||||
"hwid_valid_until": hwid_quote.get("valid_until") if hwid_quote else None,
|
||||
"hwid_pricing_period_months": hwid_quote.get("pricing_period_months")
|
||||
@@ -1660,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:
|
||||
@@ -1671,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"
|
||||
@@ -1809,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"
|
||||
@@ -2112,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,
|
||||
@@ -2218,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,
|
||||
@@ -2684,6 +2739,7 @@ def create_service(ctx: ServiceFactoryContext) -> YooKassaService:
|
||||
bot_username_for_default_return=ctx.bot_username_for_default_return,
|
||||
settings_obj=ctx.settings,
|
||||
config=config,
|
||||
subscription_service=ctx.subscription_service,
|
||||
)
|
||||
|
||||
|
||||
@@ -2691,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(
|
||||
@@ -2701,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",
|
||||
)
|
||||
@@ -2724,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,
|
||||
@@ -2886,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",
|
||||
)
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import hashlib
|
||||
import json
|
||||
import zipfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
BACKUP_APP_ID = "remnawave-minishop"
|
||||
BACKUP_FILENAME_PREFIX = "minishop-"
|
||||
BACKUP_FORMAT_VERSION = 1
|
||||
BACKUP_MANIFEST_NAME = "manifest.json"
|
||||
|
||||
|
||||
def backup_filename_timestamp() -> str:
|
||||
return datetime.now().astimezone().strftime("%Y%m%d-%H-%M")
|
||||
|
||||
|
||||
def file_sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def build_file_records(source_dir: Path) -> list[dict[str, Any]]:
|
||||
records: list[dict[str, Any]] = []
|
||||
for path in sorted(source_dir.rglob("*")):
|
||||
if not path.is_file():
|
||||
continue
|
||||
relative = path.relative_to(source_dir).as_posix()
|
||||
if relative == BACKUP_MANIFEST_NAME:
|
||||
continue
|
||||
stat = path.stat()
|
||||
records.append(
|
||||
{
|
||||
"path": relative,
|
||||
"size_bytes": int(stat.st_size),
|
||||
"sha256": file_sha256(path),
|
||||
}
|
||||
)
|
||||
return records
|
||||
|
||||
|
||||
def attach_archive_integrity(
|
||||
manifest: dict[str, Any],
|
||||
*,
|
||||
file_records: list[dict[str, Any]],
|
||||
) -> None:
|
||||
manifest["app"] = BACKUP_APP_ID
|
||||
manifest["format_version"] = BACKUP_FORMAT_VERSION
|
||||
manifest["archive"] = {
|
||||
"files": file_records,
|
||||
}
|
||||
|
||||
|
||||
def write_manifest(source_dir: Path, manifest: dict[str, Any]) -> None:
|
||||
(source_dir / BACKUP_MANIFEST_NAME).write_text(
|
||||
json.dumps(manifest, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def write_zip_from_directory(source_dir: Path, archive_path: Path) -> None:
|
||||
with zipfile.ZipFile(
|
||||
archive_path,
|
||||
mode="w",
|
||||
compression=zipfile.ZIP_DEFLATED,
|
||||
compresslevel=6,
|
||||
) as archive:
|
||||
for path in sorted(source_dir.rglob("*")):
|
||||
if path.is_file():
|
||||
archive.write(path, path.relative_to(source_dir).as_posix())
|
||||
@@ -0,0 +1,711 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
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,
|
||||
BACKUP_FORMAT_VERSION,
|
||||
BACKUP_MANIFEST_NAME,
|
||||
attach_archive_integrity,
|
||||
backup_filename_timestamp,
|
||||
build_file_records,
|
||||
write_manifest,
|
||||
write_zip_from_directory,
|
||||
)
|
||||
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__)
|
||||
|
||||
BACKUP_UPLOAD_MAX_BYTES = 2 * 1024 * 1024 * 1024
|
||||
BACKUP_MAX_MEMBERS = 20_000
|
||||
BACKUP_MAX_MEMBER_BYTES = 4 * 1024 * 1024 * 1024
|
||||
BACKUP_MAX_UNCOMPRESSED_BYTES = 16 * 1024 * 1024 * 1024
|
||||
BACKUP_MAX_COMPOSE_BYTES = 1024 * 1024 * 1024
|
||||
BACKUP_MAX_COMPOSE_MEMBER_BYTES = 256 * 1024 * 1024
|
||||
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):
|
||||
"""The selected archive cannot be used for restore."""
|
||||
|
||||
|
||||
class BackupRestoreError(RuntimeError):
|
||||
"""Restore command failed after archive validation."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class BackupArchiveInfo:
|
||||
name: str
|
||||
path: Path
|
||||
size_bytes: int
|
||||
modified_at: datetime
|
||||
created_at: Optional[str] = None
|
||||
created_at_local: Optional[str] = None
|
||||
has_database: bool = False
|
||||
has_compose: bool = False
|
||||
database_name: Optional[str] = None
|
||||
compose_files_count: int = 0
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
manifest: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_payload(self) -> dict[str, Any]:
|
||||
return {
|
||||
"name": self.name,
|
||||
"size_bytes": self.size_bytes,
|
||||
"modified_at": self.modified_at.isoformat(),
|
||||
"created_at": self.created_at,
|
||||
"created_at_local": self.created_at_local,
|
||||
"has_database": self.has_database,
|
||||
"has_compose": self.has_compose,
|
||||
"database_name": self.database_name,
|
||||
"compose_files_count": self.compose_files_count,
|
||||
"warnings": self.warnings,
|
||||
"manifest": self.manifest,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class BackupRestoreResult:
|
||||
archive_name: str
|
||||
started_at: datetime
|
||||
completed_at: datetime
|
||||
database_restored: bool = False
|
||||
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]:
|
||||
return {
|
||||
"archive_name": self.archive_name,
|
||||
"started_at": self.started_at.isoformat(),
|
||||
"completed_at": self.completed_at.isoformat(),
|
||||
"database_restored": self.database_restored,
|
||||
"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,
|
||||
}
|
||||
|
||||
|
||||
class BackupRestoreService:
|
||||
def __init__(self, settings: Settings):
|
||||
self.settings = settings
|
||||
|
||||
def backup_dir(self) -> Path:
|
||||
path = Path(self.settings.BACKUP_DIR).expanduser()
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
def list_archives(self) -> list[BackupArchiveInfo]:
|
||||
backup_dir = self.backup_dir()
|
||||
archives = []
|
||||
for path in backup_dir.glob("*.zip"):
|
||||
if not path.is_file():
|
||||
continue
|
||||
try:
|
||||
archives.append(self.inspect_archive(path))
|
||||
except BackupArchiveError as exc:
|
||||
logger.warning("Skipping invalid backup archive %s: %s", path, exc)
|
||||
return sorted(archives, key=lambda item: item.modified_at, reverse=True)
|
||||
|
||||
def archive_path_for_name(self, archive_name: str) -> Path:
|
||||
raw_name = str(archive_name or "").strip()
|
||||
safe_name = Path(raw_name).name
|
||||
if not raw_name or safe_name != raw_name or not SAFE_ARCHIVE_NAME_RE.fullmatch(safe_name):
|
||||
raise BackupArchiveError("Invalid archive name")
|
||||
|
||||
backup_dir = self.backup_dir().resolve()
|
||||
archive_path = (backup_dir / safe_name).resolve()
|
||||
try:
|
||||
archive_path.relative_to(backup_dir)
|
||||
except ValueError as exc:
|
||||
raise BackupArchiveError("Archive path escapes backup directory") from exc
|
||||
if not archive_path.is_file():
|
||||
raise BackupArchiveError("Archive does not exist")
|
||||
return archive_path
|
||||
|
||||
def inspect_archive(self, archive_path: Path) -> BackupArchiveInfo:
|
||||
if not zipfile.is_zipfile(archive_path):
|
||||
raise BackupArchiveError("Archive is not a valid ZIP file")
|
||||
|
||||
stat = archive_path.stat()
|
||||
warnings: list[str] = []
|
||||
with zipfile.ZipFile(archive_path) as archive:
|
||||
self._validate_zip_members(archive.infolist())
|
||||
manifest = self._read_manifest(archive)
|
||||
has_database = self._find_database_dump_member(archive) is not None
|
||||
compose_members = self._compose_file_members(archive)
|
||||
|
||||
manifest_warnings = manifest.get("warnings")
|
||||
if isinstance(manifest_warnings, list):
|
||||
warnings.extend(str(item) for item in manifest_warnings if item)
|
||||
|
||||
postgres = manifest.get("postgres") if isinstance(manifest.get("postgres"), dict) else {}
|
||||
compose = manifest.get("compose") if isinstance(manifest.get("compose"), dict) else {}
|
||||
return BackupArchiveInfo(
|
||||
name=archive_path.name,
|
||||
path=archive_path,
|
||||
size_bytes=int(stat.st_size),
|
||||
modified_at=datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc),
|
||||
created_at=str(manifest.get("created_at") or "") or None,
|
||||
created_at_local=str(manifest.get("created_at_local") or "") or None,
|
||||
has_database=has_database,
|
||||
has_compose=bool(compose_members),
|
||||
database_name=str(postgres.get("database") or "") or None,
|
||||
compose_files_count=int(compose.get("files_count") or len(compose_members)),
|
||||
warnings=warnings,
|
||||
manifest=manifest,
|
||||
)
|
||||
|
||||
def import_uploaded_archive(
|
||||
self,
|
||||
temp_path: Path,
|
||||
original_filename: str = "",
|
||||
) -> BackupArchiveInfo:
|
||||
self._validate_archive_for_restore(temp_path)
|
||||
digest = self._file_digest(temp_path)
|
||||
stamp = backup_filename_timestamp()
|
||||
archive_name = f"{BACKUP_FILENAME_PREFIX}uploaded-{stamp}-{digest}.zip"
|
||||
target_path = self._unique_archive_path(archive_name)
|
||||
temp_path.replace(target_path)
|
||||
return self.inspect_archive(target_path)
|
||||
|
||||
async def restore_archive(
|
||||
self,
|
||||
archive_name: str,
|
||||
*,
|
||||
restore_database: bool,
|
||||
restore_compose: bool,
|
||||
) -> BackupRestoreResult:
|
||||
return await asyncio.to_thread(
|
||||
self.restore_archive_sync,
|
||||
archive_name,
|
||||
restore_database=restore_database,
|
||||
restore_compose=restore_compose,
|
||||
)
|
||||
|
||||
def restore_archive_sync(
|
||||
self,
|
||||
archive_name: str,
|
||||
*,
|
||||
restore_database: bool,
|
||||
restore_compose: bool,
|
||||
) -> BackupRestoreResult:
|
||||
if not restore_database and not restore_compose:
|
||||
raise BackupArchiveError("Select at least one restore target")
|
||||
|
||||
archive_path = self.archive_path_for_name(archive_name)
|
||||
self._validate_archive_for_restore(archive_path)
|
||||
started_at = datetime.now(timezone.utc)
|
||||
warnings: list[str] = []
|
||||
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix=f"restore-{archive_path.stem}-",
|
||||
dir=self.backup_dir(),
|
||||
) as tmp:
|
||||
temp_dir = Path(tmp)
|
||||
with zipfile.ZipFile(archive_path) as archive:
|
||||
self._validate_zip_members(archive.infolist())
|
||||
db_member = self._find_database_dump_member(archive) if restore_database else None
|
||||
compose_members = self._compose_file_members(archive) if restore_compose else []
|
||||
|
||||
if restore_database and db_member is None:
|
||||
raise BackupArchiveError("Archive does not contain a database dump")
|
||||
if restore_compose and not compose_members:
|
||||
raise BackupArchiveError("Archive does not contain compose files")
|
||||
|
||||
compose_target_dir: Optional[Path] = None
|
||||
compose_pre_restore_archive: Optional[Path] = None
|
||||
if restore_compose:
|
||||
compose_target_dir = self._compose_restore_target_dir()
|
||||
self._assert_compose_target_writable(compose_target_dir)
|
||||
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
|
||||
if compose_target_dir is not None:
|
||||
compose_files_restored = self._restore_compose_members(
|
||||
archive,
|
||||
compose_members,
|
||||
compose_target_dir,
|
||||
)
|
||||
|
||||
return BackupRestoreResult(
|
||||
archive_name=archive_path.name,
|
||||
started_at=started_at,
|
||||
completed_at=datetime.now(timezone.utc),
|
||||
database_restored=database_restored,
|
||||
compose_files_restored=compose_files_restored,
|
||||
compose_target_dir=str(compose_target_dir) if compose_target_dir else None,
|
||||
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"
|
||||
if shutil.which(pg_restore_path) is None and Path(pg_restore_path).name == pg_restore_path:
|
||||
raise BackupRestoreError(
|
||||
"pg_restore executable was not found. Rebuild the backend image with "
|
||||
"PostgreSQL client tools."
|
||||
)
|
||||
|
||||
env = os.environ.copy()
|
||||
env["PGPASSWORD"] = self.settings.POSTGRES_PASSWORD
|
||||
command = [
|
||||
pg_restore_path,
|
||||
"-h",
|
||||
self.settings.POSTGRES_HOST,
|
||||
"-p",
|
||||
str(self.settings.POSTGRES_PORT),
|
||||
"-U",
|
||||
self.settings.POSTGRES_USER,
|
||||
"-d",
|
||||
self.settings.POSTGRES_DB,
|
||||
"--clean",
|
||||
"--if-exists",
|
||||
"--no-owner",
|
||||
"--no-privileges",
|
||||
str(dump_path),
|
||||
]
|
||||
timeout = max(
|
||||
30,
|
||||
int(
|
||||
getattr(
|
||||
self.settings,
|
||||
"BACKUP_PG_RESTORE_TIMEOUT_SECONDS",
|
||||
self.settings.BACKUP_PG_DUMP_TIMEOUT_SECONDS,
|
||||
)
|
||||
or 1800
|
||||
),
|
||||
)
|
||||
result = subprocess.run(
|
||||
command,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
env=env,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
stderr = (result.stderr or result.stdout or "").strip()
|
||||
raise BackupRestoreError(
|
||||
f"pg_restore failed with exit code {result.returncode}: {stderr[:500]}"
|
||||
)
|
||||
|
||||
def _compose_restore_target_dir(self) -> Path:
|
||||
target_raw = (
|
||||
getattr(self.settings, "BACKUP_COMPOSE_RESTORE_DIR", None)
|
||||
or self.settings.BACKUP_COMPOSE_SOURCE_DIR
|
||||
or ""
|
||||
)
|
||||
if not str(target_raw).strip():
|
||||
raise BackupArchiveError("Compose restore directory is not configured")
|
||||
return Path(str(target_raw)).expanduser()
|
||||
|
||||
def _assert_compose_target_writable(self, target_dir: Path) -> None:
|
||||
if not target_dir.exists() or not target_dir.is_dir():
|
||||
raise BackupArchiveError(
|
||||
f"Compose restore directory is unavailable: {target_dir}. "
|
||||
"Mount the compose folder into the backend container."
|
||||
)
|
||||
probe = target_dir / f".restore-write-test-{os.getpid()}"
|
||||
try:
|
||||
probe.write_text("", encoding="utf-8")
|
||||
probe.unlink()
|
||||
except OSError as exc:
|
||||
raise BackupArchiveError(
|
||||
f"Compose restore directory is not writable: {target_dir}"
|
||||
) from exc
|
||||
|
||||
def _snapshot_current_compose(self, target_dir: Path) -> Optional[Path]:
|
||||
stamp = backup_filename_timestamp()
|
||||
archive_path = self._unique_archive_path(f"{COMPOSE_PRE_RESTORE_PREFIX}{stamp}.zip")
|
||||
excluded_dirs = self._compose_excluded_dirs()
|
||||
files_count = 0
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix=f"{archive_path.stem}-",
|
||||
dir=self.backup_dir(),
|
||||
) as tmp:
|
||||
staging_dir = Path(tmp)
|
||||
compose_dir = staging_dir / "compose"
|
||||
for path in sorted(target_dir.rglob("*")):
|
||||
relative = path.relative_to(target_dir)
|
||||
if any(part in excluded_dirs for part in relative.parts):
|
||||
continue
|
||||
if path.is_dir() or path.is_symlink():
|
||||
continue
|
||||
destination = compose_dir / relative
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(path, destination)
|
||||
files_count += 1
|
||||
if files_count <= 0:
|
||||
return None
|
||||
|
||||
completed_at = datetime.now(timezone.utc)
|
||||
manifest = {
|
||||
"app": BACKUP_APP_ID,
|
||||
"format_version": BACKUP_FORMAT_VERSION,
|
||||
"type": "compose-pre-restore",
|
||||
"created_at": completed_at.isoformat(),
|
||||
"created_at_local": completed_at.astimezone().isoformat(),
|
||||
"postgres": {
|
||||
"database": self.settings.POSTGRES_DB,
|
||||
"included": False,
|
||||
},
|
||||
"compose": {
|
||||
"source_dir": str(target_dir),
|
||||
"included": True,
|
||||
"files_count": files_count,
|
||||
},
|
||||
"warnings": [],
|
||||
}
|
||||
attach_archive_integrity(
|
||||
manifest,
|
||||
file_records=build_file_records(staging_dir),
|
||||
)
|
||||
write_manifest(staging_dir, manifest)
|
||||
tmp_archive = archive_path.with_name(f"{archive_path.name}.tmp")
|
||||
try:
|
||||
write_zip_from_directory(staging_dir, tmp_archive)
|
||||
tmp_archive.replace(archive_path)
|
||||
finally:
|
||||
if tmp_archive.exists():
|
||||
try:
|
||||
tmp_archive.unlink()
|
||||
except OSError:
|
||||
logger.warning("Failed to remove temporary snapshot %s", tmp_archive)
|
||||
return archive_path
|
||||
|
||||
def _restore_compose_members(
|
||||
self,
|
||||
archive: zipfile.ZipFile,
|
||||
members: list[zipfile.ZipInfo],
|
||||
target_dir: Path,
|
||||
) -> int:
|
||||
target_root = target_dir.resolve()
|
||||
restored = 0
|
||||
for member in members:
|
||||
relative = PurePosixPath(member.filename).relative_to("compose")
|
||||
destination = target_root.joinpath(*relative.parts).resolve()
|
||||
try:
|
||||
destination.relative_to(target_root)
|
||||
except ValueError as exc:
|
||||
raise BackupArchiveError(
|
||||
f"Unsafe compose archive member: {member.filename}"
|
||||
) from exc
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
temp_destination = destination.with_name(
|
||||
f".{destination.name}.restore-{os.getpid()}.tmp"
|
||||
)
|
||||
try:
|
||||
with archive.open(member) as source, temp_destination.open("wb") as target:
|
||||
shutil.copyfileobj(source, target)
|
||||
temp_destination.replace(destination)
|
||||
finally:
|
||||
if temp_destination.exists():
|
||||
try:
|
||||
temp_destination.unlink()
|
||||
except OSError:
|
||||
logger.warning(
|
||||
"Failed to remove temporary restore file %s",
|
||||
temp_destination,
|
||||
)
|
||||
restored += 1
|
||||
return restored
|
||||
|
||||
def _extract_database_dump(
|
||||
self,
|
||||
archive: zipfile.ZipFile,
|
||||
member: zipfile.ZipInfo,
|
||||
temp_dir: Path,
|
||||
) -> Path:
|
||||
dump_dir = temp_dir / "database"
|
||||
dump_dir.mkdir(parents=True, exist_ok=True)
|
||||
dump_path = dump_dir / Path(member.filename).name
|
||||
with archive.open(member) as source, dump_path.open("wb") as target:
|
||||
shutil.copyfileobj(source, target)
|
||||
return dump_path
|
||||
|
||||
def _find_database_dump_member(self, archive: zipfile.ZipFile) -> Optional[zipfile.ZipInfo]:
|
||||
candidates = [
|
||||
item
|
||||
for item in archive.infolist()
|
||||
if not item.is_dir()
|
||||
and item.filename.startswith("database/")
|
||||
and PurePosixPath(item.filename).suffix.lower() in {".dump", ".backup"}
|
||||
]
|
||||
return sorted(candidates, key=lambda item: item.filename)[0] if candidates else None
|
||||
|
||||
def _compose_file_members(self, archive: zipfile.ZipFile) -> list[zipfile.ZipInfo]:
|
||||
members = [
|
||||
item
|
||||
for item in archive.infolist()
|
||||
if not item.is_dir() and item.filename.startswith("compose/")
|
||||
]
|
||||
self._validate_compose_members(members)
|
||||
return members
|
||||
|
||||
def _validate_zip_members(self, members: list[zipfile.ZipInfo]) -> None:
|
||||
if len(members) > BACKUP_MAX_MEMBERS:
|
||||
raise BackupArchiveError("Archive contains too many files")
|
||||
|
||||
seen: set[str] = set()
|
||||
total_size = 0
|
||||
for member in members:
|
||||
filename = member.filename
|
||||
if "\\" in filename or "\x00" in filename:
|
||||
raise BackupArchiveError(f"Unsafe archive member path: {filename}")
|
||||
path = PurePosixPath(member.filename)
|
||||
if (
|
||||
not path.parts
|
||||
or path.is_absolute()
|
||||
or ".." in path.parts
|
||||
or any(part in {"", "."} for part in path.parts)
|
||||
):
|
||||
raise BackupArchiveError(f"Unsafe archive member path: {member.filename}")
|
||||
if member.is_dir():
|
||||
continue
|
||||
if filename in seen:
|
||||
raise BackupArchiveError(f"Duplicate archive member path: {filename}")
|
||||
seen.add(filename)
|
||||
if member.file_size > BACKUP_MAX_MEMBER_BYTES:
|
||||
raise BackupArchiveError(f"Archive member is too large: {filename}")
|
||||
total_size += int(member.file_size)
|
||||
if total_size > BACKUP_MAX_UNCOMPRESSED_BYTES:
|
||||
raise BackupArchiveError("Archive uncompressed size is too large")
|
||||
compressed = max(1, int(member.compress_size or 1))
|
||||
ratio = int(member.file_size) / compressed
|
||||
if (
|
||||
member.file_size >= BACKUP_ZIP_BOMB_MIN_BYTES
|
||||
and ratio > BACKUP_MAX_COMPRESSION_RATIO
|
||||
):
|
||||
raise BackupArchiveError(
|
||||
f"Archive member compression ratio is too high: {filename}"
|
||||
)
|
||||
|
||||
def _validate_compose_members(self, members: list[zipfile.ZipInfo]) -> None:
|
||||
total_size = 0
|
||||
for member in members:
|
||||
if member.file_size > BACKUP_MAX_COMPOSE_MEMBER_BYTES:
|
||||
raise BackupArchiveError(f"Compose archive member is too large: {member.filename}")
|
||||
total_size += int(member.file_size)
|
||||
if total_size > BACKUP_MAX_COMPOSE_BYTES:
|
||||
raise BackupArchiveError("Compose archive contents are too large")
|
||||
|
||||
def _read_manifest(self, archive: zipfile.ZipFile) -> dict[str, Any]:
|
||||
if BACKUP_MANIFEST_NAME not in archive.namelist():
|
||||
raise BackupArchiveError("Archive does not contain manifest.json")
|
||||
try:
|
||||
manifest = json.loads(archive.read(BACKUP_MANIFEST_NAME).decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise BackupArchiveError("manifest.json is not valid JSON") from exc
|
||||
if not isinstance(manifest, dict):
|
||||
raise BackupArchiveError("manifest.json must contain an object")
|
||||
if manifest.get("app") != BACKUP_APP_ID:
|
||||
raise BackupArchiveError("Archive manifest belongs to another application")
|
||||
try:
|
||||
format_version = int(manifest.get("format_version") or 0)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise BackupArchiveError("Archive manifest format is not supported") from exc
|
||||
if format_version != BACKUP_FORMAT_VERSION:
|
||||
raise BackupArchiveError("Archive manifest format is not supported")
|
||||
return manifest
|
||||
|
||||
def _validate_archive_for_restore(self, archive_path: Path) -> None:
|
||||
if not zipfile.is_zipfile(archive_path):
|
||||
raise BackupArchiveError("Archive is not a valid ZIP file")
|
||||
with zipfile.ZipFile(archive_path) as archive:
|
||||
self._validate_zip_members(archive.infolist())
|
||||
manifest = self._read_manifest(archive)
|
||||
self._validate_archive_integrity(archive, manifest)
|
||||
|
||||
def _validate_archive_integrity(
|
||||
self,
|
||||
archive: zipfile.ZipFile,
|
||||
manifest: dict[str, Any],
|
||||
) -> None:
|
||||
archive_manifest = (
|
||||
manifest.get("archive") if isinstance(manifest.get("archive"), dict) else {}
|
||||
)
|
||||
file_records = archive_manifest.get("files")
|
||||
if not isinstance(file_records, list):
|
||||
raise BackupArchiveError("Archive manifest does not contain file checksums")
|
||||
|
||||
expected: dict[str, dict[str, Any]] = {}
|
||||
for record in file_records:
|
||||
if not isinstance(record, dict):
|
||||
raise BackupArchiveError("Archive manifest contains invalid file record")
|
||||
filename = str(record.get("path") or "")
|
||||
if not filename:
|
||||
raise BackupArchiveError("Archive manifest contains empty file path")
|
||||
if filename in expected:
|
||||
raise BackupArchiveError(
|
||||
f"Archive manifest contains duplicate file path: {filename}"
|
||||
)
|
||||
expected[filename] = record
|
||||
|
||||
actual = {
|
||||
item.filename
|
||||
for item in archive.infolist()
|
||||
if not item.is_dir() and item.filename != BACKUP_MANIFEST_NAME
|
||||
}
|
||||
if actual != set(expected):
|
||||
raise BackupArchiveError("Archive contents do not match manifest")
|
||||
|
||||
for info in archive.infolist():
|
||||
if info.is_dir() or info.filename == BACKUP_MANIFEST_NAME:
|
||||
continue
|
||||
record = expected[info.filename]
|
||||
try:
|
||||
expected_size = int(record.get("size_bytes") or -1)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise BackupArchiveError(
|
||||
f"Archive manifest size is invalid: {info.filename}"
|
||||
) from exc
|
||||
expected_hash = str(record.get("sha256") or "")
|
||||
if expected_size != int(info.file_size):
|
||||
raise BackupArchiveError(
|
||||
f"Archive member size does not match manifest: {info.filename}"
|
||||
)
|
||||
if not re.fullmatch(r"[0-9a-f]{64}", expected_hash):
|
||||
raise BackupArchiveError(f"Archive manifest checksum is invalid: {info.filename}")
|
||||
digest = hashlib.sha256()
|
||||
with archive.open(info) as source:
|
||||
for chunk in iter(lambda: source.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
if not hmac.compare_digest(digest.hexdigest(), expected_hash):
|
||||
raise BackupArchiveError(
|
||||
f"Archive member checksum does not match manifest: {info.filename}"
|
||||
)
|
||||
|
||||
def _compose_excluded_dirs(self) -> set[str]:
|
||||
configured = self._split_csv(self.settings.BACKUP_COMPOSE_EXCLUDE_DIRS)
|
||||
return DEFAULT_COMPOSE_EXCLUDED_DIRS | set(configured)
|
||||
|
||||
@staticmethod
|
||||
def _split_csv(value: Optional[str]) -> list[str]:
|
||||
if not value:
|
||||
return []
|
||||
return [item.strip() for item in value.split(",") if item.strip()]
|
||||
|
||||
@staticmethod
|
||||
def _file_digest(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()[:16]
|
||||
|
||||
@staticmethod
|
||||
def _safe_original_stem(filename: str) -> str:
|
||||
stem = Path(str(filename or "backup")).stem
|
||||
safe = re.sub(r"[^A-Za-z0-9_.+-]+", "-", stem).strip(".-")
|
||||
return (safe or "backup")[:72]
|
||||
|
||||
def _unique_archive_path(self, archive_name: str) -> Path:
|
||||
backup_dir = self.backup_dir()
|
||||
stem = Path(archive_name).stem
|
||||
suffix = Path(archive_name).suffix
|
||||
candidate = backup_dir / archive_name
|
||||
counter = 2
|
||||
while candidate.exists():
|
||||
candidate = backup_dir / f"{stem}-{counter}{suffix}"
|
||||
counter += 1
|
||||
return candidate
|
||||
@@ -0,0 +1,479 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Iterable, Optional
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.types import FSInputFile
|
||||
|
||||
from bot.infra.redis import redis_lock
|
||||
from bot.services.backup_archive import (
|
||||
BACKUP_APP_ID,
|
||||
BACKUP_FILENAME_PREFIX,
|
||||
BACKUP_FORMAT_VERSION,
|
||||
attach_archive_integrity,
|
||||
backup_filename_timestamp,
|
||||
build_file_records,
|
||||
write_manifest,
|
||||
write_zip_from_directory,
|
||||
)
|
||||
from config.settings import Settings
|
||||
|
||||
COMPOSE_MARKER_FILES = {
|
||||
"compose.yaml",
|
||||
"compose.yml",
|
||||
"docker-compose.yaml",
|
||||
"docker-compose.yml",
|
||||
}
|
||||
DEFAULT_COMPOSE_EXCLUDED_DIRS = {
|
||||
".git",
|
||||
".pytest_cache",
|
||||
".ruff_cache",
|
||||
"__pycache__",
|
||||
"backups",
|
||||
"node_modules",
|
||||
"postgres-data",
|
||||
"redis-data",
|
||||
"shop-data",
|
||||
}
|
||||
BACKUP_RUNTIME_SETTING_KEYS = {
|
||||
"BACKUP_ENABLED",
|
||||
"BACKUP_CHAT_ID",
|
||||
"BACKUP_THREAD_ID",
|
||||
"BACKUP_INTERVAL_SECONDS",
|
||||
"BACKUP_LOCAL_RETENTION",
|
||||
"BACKUP_POSTGRES_DUMP_ENABLED",
|
||||
"BACKUP_PG_DUMP_PATH",
|
||||
"BACKUP_PG_DUMP_TIMEOUT_SECONDS",
|
||||
"BACKUP_COMPOSE_ENABLED",
|
||||
"BACKUP_COMPOSE_SOURCE_DIR",
|
||||
"BACKUP_COMPOSE_EXCLUDE_DIRS",
|
||||
}
|
||||
TELEGRAM_DOCUMENT_CAPTION_LIMIT = 1024
|
||||
TELEGRAM_WARNING_DETAIL_LIMIT = 6
|
||||
TELEGRAM_WARNING_LINE_LIMIT = 220
|
||||
|
||||
|
||||
@dataclass
|
||||
class BackupResult:
|
||||
archive_path: Path
|
||||
started_at: datetime
|
||||
completed_at: datetime
|
||||
db_dump_included: bool
|
||||
compose_files_count: int
|
||||
size_bytes: int
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
|
||||
def to_payload(self) -> dict[str, object]:
|
||||
return {
|
||||
"archive_name": self.archive_path.name,
|
||||
"archive_path": str(self.archive_path),
|
||||
"started_at": self.started_at.isoformat(),
|
||||
"completed_at": self.completed_at.isoformat(),
|
||||
"db_dump_included": self.db_dump_included,
|
||||
"compose_files_count": self.compose_files_count,
|
||||
"size_bytes": self.size_bytes,
|
||||
"warnings": self.warnings,
|
||||
}
|
||||
|
||||
|
||||
class BackupWorker:
|
||||
SETTINGS_REFRESH_SECONDS = 60
|
||||
|
||||
def __init__(self, settings: Settings, bot: Bot, session_factory=None):
|
||||
self.settings = settings
|
||||
self.bot = bot
|
||||
self.session_factory = session_factory
|
||||
|
||||
async def run(self) -> None:
|
||||
while True:
|
||||
await self._refresh_settings()
|
||||
if not self.settings.BACKUP_ENABLED:
|
||||
await asyncio.sleep(self.SETTINGS_REFRESH_SECONDS)
|
||||
continue
|
||||
|
||||
interval = self._interval_seconds()
|
||||
delay_seconds = self._seconds_until_next_slot(interval)
|
||||
if delay_seconds > 0:
|
||||
should_run = await self._sleep_until_next_slot(delay_seconds, interval)
|
||||
if not should_run:
|
||||
continue
|
||||
|
||||
await self._refresh_settings()
|
||||
if not self.settings.BACKUP_ENABLED:
|
||||
continue
|
||||
|
||||
try:
|
||||
ttl_seconds = max(
|
||||
60,
|
||||
int(getattr(self.settings, "BACKUP_LOCK_TTL_SECONDS", 7200) or 7200),
|
||||
)
|
||||
async with redis_lock(
|
||||
self.settings,
|
||||
"backup-worker",
|
||||
ttl_seconds=ttl_seconds,
|
||||
) as acquired:
|
||||
if acquired:
|
||||
started = time.monotonic()
|
||||
result = await self.create_and_send_backup()
|
||||
logging.info(
|
||||
"metric worker_tick_duration_seconds=%.3f worker=backup size_bytes=%s",
|
||||
time.monotonic() - started,
|
||||
result.size_bytes,
|
||||
)
|
||||
else:
|
||||
logging.info(
|
||||
"Backup worker tick skipped because another worker holds the lock"
|
||||
)
|
||||
except Exception as exc:
|
||||
logging.exception("Backup worker tick failed")
|
||||
await self._notify_failure(exc)
|
||||
|
||||
async def create_and_send_backup(self, *, backup_type: str = "scheduled") -> BackupResult:
|
||||
result = await self.create_backup(backup_type=backup_type)
|
||||
try:
|
||||
await self.send_backup(result)
|
||||
finally:
|
||||
self.prune_old_backups()
|
||||
return result
|
||||
|
||||
async def create_backup(self, *, backup_type: str = "scheduled") -> BackupResult:
|
||||
started_at = datetime.now(timezone.utc)
|
||||
stamp = backup_filename_timestamp()
|
||||
archive_name = f"{BACKUP_FILENAME_PREFIX}{stamp}.zip"
|
||||
backup_dir = Path(self.settings.BACKUP_DIR).expanduser()
|
||||
backup_dir.mkdir(parents=True, exist_ok=True)
|
||||
archive_path = self._unique_archive_path(backup_dir / archive_name)
|
||||
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix=f"{BACKUP_FILENAME_PREFIX}{stamp}-",
|
||||
dir=backup_dir,
|
||||
) as tmp:
|
||||
staging_dir = Path(tmp)
|
||||
warnings: list[str] = []
|
||||
db_dump_included = False
|
||||
compose_files_count = 0
|
||||
|
||||
if self.settings.BACKUP_POSTGRES_DUMP_ENABLED:
|
||||
dump_dir = staging_dir / "database"
|
||||
dump_dir.mkdir(parents=True, exist_ok=True)
|
||||
dump_path = dump_dir / f"{self.settings.POSTGRES_DB}.dump"
|
||||
await self._dump_database(dump_path)
|
||||
db_dump_included = True
|
||||
|
||||
if self.settings.BACKUP_COMPOSE_ENABLED:
|
||||
compose_files_count = self._stage_compose_source(staging_dir / "compose", warnings)
|
||||
|
||||
completed_at = datetime.now(timezone.utc)
|
||||
manifest = {
|
||||
"app": BACKUP_APP_ID,
|
||||
"format_version": BACKUP_FORMAT_VERSION,
|
||||
"type": str(backup_type or "scheduled"),
|
||||
"created_at": completed_at.isoformat(),
|
||||
"created_at_local": completed_at.astimezone().isoformat(),
|
||||
"postgres": {
|
||||
"host": self.settings.POSTGRES_HOST,
|
||||
"port": self.settings.POSTGRES_PORT,
|
||||
"database": self.settings.POSTGRES_DB,
|
||||
"user": self.settings.POSTGRES_USER,
|
||||
"dump_format": "pg_dump custom",
|
||||
"included": db_dump_included,
|
||||
},
|
||||
"compose": {
|
||||
"source_dir": self.settings.BACKUP_COMPOSE_SOURCE_DIR,
|
||||
"included": compose_files_count > 0,
|
||||
"files_count": compose_files_count,
|
||||
},
|
||||
"warnings": warnings,
|
||||
}
|
||||
attach_archive_integrity(
|
||||
manifest,
|
||||
file_records=build_file_records(staging_dir),
|
||||
)
|
||||
write_manifest(staging_dir, manifest)
|
||||
|
||||
tmp_archive = archive_path.with_name(f"{archive_path.name}.tmp")
|
||||
write_zip_from_directory(staging_dir, tmp_archive)
|
||||
tmp_archive.replace(archive_path)
|
||||
|
||||
return BackupResult(
|
||||
archive_path=archive_path,
|
||||
started_at=started_at,
|
||||
completed_at=completed_at,
|
||||
db_dump_included=db_dump_included,
|
||||
compose_files_count=compose_files_count,
|
||||
size_bytes=archive_path.stat().st_size,
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
def _unique_archive_path(self, archive_path: Path) -> Path:
|
||||
if not archive_path.exists():
|
||||
return archive_path
|
||||
for index in range(2, 1000):
|
||||
candidate = archive_path.with_name(f"{archive_path.stem}-{index}{archive_path.suffix}")
|
||||
if not candidate.exists():
|
||||
return candidate
|
||||
raise RuntimeError("Could not allocate a unique backup archive filename")
|
||||
|
||||
async def _dump_database(self, dump_path: Path) -> None:
|
||||
await asyncio.to_thread(self._run_pg_dump, dump_path)
|
||||
|
||||
def _run_pg_dump(self, dump_path: Path) -> None:
|
||||
pg_dump_path = str(self.settings.BACKUP_PG_DUMP_PATH or "pg_dump")
|
||||
if shutil.which(pg_dump_path) is None and Path(pg_dump_path).name == pg_dump_path:
|
||||
raise RuntimeError(
|
||||
"pg_dump executable was not found. Rebuild the worker image with "
|
||||
"PostgreSQL client tools."
|
||||
)
|
||||
|
||||
env = os.environ.copy()
|
||||
env["PGPASSWORD"] = self.settings.POSTGRES_PASSWORD
|
||||
command = [
|
||||
pg_dump_path,
|
||||
"-h",
|
||||
self.settings.POSTGRES_HOST,
|
||||
"-p",
|
||||
str(self.settings.POSTGRES_PORT),
|
||||
"-U",
|
||||
self.settings.POSTGRES_USER,
|
||||
"-d",
|
||||
self.settings.POSTGRES_DB,
|
||||
"--format=custom",
|
||||
"--no-owner",
|
||||
"--no-privileges",
|
||||
"--file",
|
||||
str(dump_path),
|
||||
]
|
||||
timeout = max(30, int(self.settings.BACKUP_PG_DUMP_TIMEOUT_SECONDS or 1800))
|
||||
result = subprocess.run(
|
||||
command,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
env=env,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
stderr = (result.stderr or result.stdout or "").strip()
|
||||
raise RuntimeError(f"pg_dump failed with exit code {result.returncode}: {stderr[:500]}")
|
||||
|
||||
def _stage_compose_source(self, target_dir: Path, warnings: list[str]) -> int:
|
||||
source_raw = (self.settings.BACKUP_COMPOSE_SOURCE_DIR or "").strip()
|
||||
if not source_raw:
|
||||
warnings.append(
|
||||
"Compose source directory is not configured. Set "
|
||||
"BACKUP_COMPOSE_SOURCE_DIR or mount the compose folder into the backup container."
|
||||
)
|
||||
return 0
|
||||
|
||||
source_dir = Path(source_raw).expanduser()
|
||||
if not source_dir.exists() or not source_dir.is_dir():
|
||||
warnings.append(
|
||||
"If manual backup includes compose but scheduled backup does not, recreate "
|
||||
"the worker service with the compose-source mount. Compose source directory "
|
||||
f"is unavailable in this container: {source_dir}"
|
||||
)
|
||||
return 0
|
||||
|
||||
if not any((source_dir / marker).is_file() for marker in COMPOSE_MARKER_FILES):
|
||||
warnings.append(
|
||||
"Check that COMPOSE_BACKUP_SOURCE points to the folder with docker-compose.yml. "
|
||||
f"Compose source directory has no compose file marker: {source_dir}"
|
||||
)
|
||||
|
||||
excluded_dirs = self._compose_excluded_dirs()
|
||||
files_count = 0
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for path in source_dir.rglob("*"):
|
||||
relative = path.relative_to(source_dir)
|
||||
if any(part in excluded_dirs for part in relative.parts):
|
||||
continue
|
||||
if path.is_dir() or path.is_symlink():
|
||||
continue
|
||||
if path.name.startswith(f"{BACKUP_FILENAME_PREFIX}") and path.suffix == ".zip":
|
||||
continue
|
||||
destination = target_dir / relative
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
shutil.copy2(path, destination)
|
||||
files_count += 1
|
||||
except OSError as exc:
|
||||
warnings.append(f"Skipped compose file {relative.as_posix()}: {exc}")
|
||||
|
||||
return files_count
|
||||
|
||||
def _compose_excluded_dirs(self) -> set[str]:
|
||||
configured = self._split_csv(self.settings.BACKUP_COMPOSE_EXCLUDE_DIRS)
|
||||
return DEFAULT_COMPOSE_EXCLUDED_DIRS | set(configured)
|
||||
|
||||
@staticmethod
|
||||
def _split_csv(value: Optional[str]) -> list[str]:
|
||||
if not value:
|
||||
return []
|
||||
return [item.strip() for item in value.split(",") if item.strip()]
|
||||
|
||||
async def send_backup(self, result: BackupResult) -> None:
|
||||
chat_id = self._target_chat_id()
|
||||
if chat_id is None:
|
||||
logging.warning(
|
||||
"Backup archive created at %s but BACKUP_CHAT_ID/LOG_CHAT_ID is not configured",
|
||||
result.archive_path,
|
||||
)
|
||||
return
|
||||
|
||||
kwargs = {
|
||||
"chat_id": chat_id,
|
||||
"document": FSInputFile(result.archive_path),
|
||||
"caption": self._caption(result),
|
||||
}
|
||||
thread_id = self._target_thread_id()
|
||||
if thread_id is not None:
|
||||
kwargs["message_thread_id"] = thread_id
|
||||
await self.bot.send_document(**kwargs)
|
||||
|
||||
def prune_old_backups(self) -> None:
|
||||
retention = int(getattr(self.settings, "BACKUP_LOCAL_RETENTION", 3) or 0)
|
||||
if retention <= 0:
|
||||
return
|
||||
|
||||
backup_dir = Path(self.settings.BACKUP_DIR).expanduser()
|
||||
archives = sorted(
|
||||
backup_dir.glob(f"{BACKUP_FILENAME_PREFIX}*.zip"),
|
||||
key=lambda path: path.stat().st_mtime,
|
||||
reverse=True,
|
||||
)
|
||||
for archive in archives[retention:]:
|
||||
try:
|
||||
archive.unlink()
|
||||
except OSError:
|
||||
logging.exception("Failed to delete old backup archive %s", archive)
|
||||
|
||||
def _target_chat_id(self) -> Optional[int]:
|
||||
return self.settings.BACKUP_CHAT_ID or self.settings.LOG_CHAT_ID
|
||||
|
||||
def _target_thread_id(self) -> Optional[int]:
|
||||
return self.settings.BACKUP_THREAD_ID or self.settings.LOG_THREAD_ID
|
||||
|
||||
def _caption(self, result: BackupResult) -> str:
|
||||
completed_at = result.completed_at.astimezone()
|
||||
lines = [
|
||||
"Remnawave Minishop backup",
|
||||
f"Created: {completed_at.strftime('%Y-%m-%d %H:%M:%S %Z')}",
|
||||
f"Database dump: {'yes' if result.db_dump_included else 'no'}",
|
||||
f"Compose files: {result.compose_files_count}",
|
||||
f"Archive size: {self._human_size(result.size_bytes)}",
|
||||
]
|
||||
if result.warnings:
|
||||
lines.append(f"Warnings ({len(result.warnings)}):")
|
||||
for index, warning in enumerate(
|
||||
result.warnings[:TELEGRAM_WARNING_DETAIL_LIMIT],
|
||||
start=1,
|
||||
):
|
||||
lines.append(f"{index}. {self._caption_warning(warning)}")
|
||||
hidden_count = len(result.warnings) - TELEGRAM_WARNING_DETAIL_LIMIT
|
||||
if hidden_count > 0:
|
||||
lines.append(f"... and {hidden_count} more warning(s)")
|
||||
return self._fit_caption(lines)
|
||||
|
||||
@staticmethod
|
||||
def _caption_warning(warning: str) -> str:
|
||||
text = " ".join(str(warning or "").split())
|
||||
if len(text) <= TELEGRAM_WARNING_LINE_LIMIT:
|
||||
return text
|
||||
return f"{text[: TELEGRAM_WARNING_LINE_LIMIT - 1].rstrip()}..."
|
||||
|
||||
@staticmethod
|
||||
def _fit_caption(lines: list[str]) -> str:
|
||||
caption = "\n".join(lines)
|
||||
if len(caption) <= TELEGRAM_DOCUMENT_CAPTION_LIMIT:
|
||||
return caption
|
||||
suffix = "\n... caption truncated"
|
||||
return f"{caption[: TELEGRAM_DOCUMENT_CAPTION_LIMIT - len(suffix)].rstrip()}{suffix}"
|
||||
|
||||
@staticmethod
|
||||
def _human_size(size_bytes: int) -> str:
|
||||
units: Iterable[str] = ("B", "KB", "MB", "GB")
|
||||
size = float(size_bytes)
|
||||
unit = "B"
|
||||
for unit in units:
|
||||
if size < 1024 or unit == "GB":
|
||||
break
|
||||
size /= 1024
|
||||
if unit == "B":
|
||||
return f"{int(size)} {unit}"
|
||||
return f"{size:.1f} {unit}"
|
||||
|
||||
async def refresh_settings(self) -> None:
|
||||
await self._refresh_settings()
|
||||
|
||||
async def _refresh_settings(self) -> None:
|
||||
if self.session_factory is None:
|
||||
return
|
||||
try:
|
||||
from bot.services.settings_override_service import refresh_overrides_from_db
|
||||
|
||||
await refresh_overrides_from_db(
|
||||
self.settings,
|
||||
self.session_factory,
|
||||
keys=BACKUP_RUNTIME_SETTING_KEYS,
|
||||
)
|
||||
except Exception:
|
||||
logging.exception("Failed to refresh backup settings from DB")
|
||||
|
||||
def _interval_seconds(self) -> int:
|
||||
try:
|
||||
interval = int(self.settings.BACKUP_INTERVAL_SECONDS or 0)
|
||||
except (TypeError, ValueError):
|
||||
interval = 0
|
||||
return max(60, interval)
|
||||
|
||||
def _seconds_until_next_slot(self, interval_seconds: int) -> float:
|
||||
now = datetime.now().astimezone()
|
||||
if interval_seconds <= 0:
|
||||
return 0.0
|
||||
if interval_seconds <= 24 * 60 * 60:
|
||||
midnight = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
elapsed = (now - midnight).total_seconds()
|
||||
remainder = elapsed % interval_seconds
|
||||
else:
|
||||
remainder = time.time() % interval_seconds
|
||||
if remainder < 0.5:
|
||||
return 0.0
|
||||
return max(0.0, interval_seconds - remainder)
|
||||
|
||||
async def _sleep_until_next_slot(self, delay_seconds: float, interval_seconds: int) -> bool:
|
||||
deadline = datetime.now(timezone.utc) + timedelta(seconds=delay_seconds)
|
||||
while True:
|
||||
remaining = (deadline - datetime.now(timezone.utc)).total_seconds()
|
||||
if remaining <= 0:
|
||||
return True
|
||||
await asyncio.sleep(min(remaining, self.SETTINGS_REFRESH_SECONDS))
|
||||
await self._refresh_settings()
|
||||
if not self.settings.BACKUP_ENABLED:
|
||||
return False
|
||||
if self._interval_seconds() != interval_seconds:
|
||||
return False
|
||||
|
||||
async def _notify_failure(self, exc: Exception) -> None:
|
||||
chat_id = self._target_chat_id()
|
||||
if chat_id is None:
|
||||
return
|
||||
kwargs = {
|
||||
"chat_id": chat_id,
|
||||
"text": f"Remnawave Minishop backup failed: {type(exc).__name__}. Check worker logs.",
|
||||
}
|
||||
thread_id = self._target_thread_id()
|
||||
if thread_id is not None:
|
||||
kwargs["message_thread_id"] = thread_id
|
||||
try:
|
||||
await self.bot.send_message(**kwargs)
|
||||
except Exception:
|
||||
logging.exception("Failed to send backup failure notification")
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
@@ -53,6 +53,7 @@ LOCALE_GROUPS = [
|
||||
"admin_sort_",
|
||||
"admin_status_",
|
||||
"admin_badge_",
|
||||
"admin_backups_",
|
||||
"admin_aria_",
|
||||
"admin_search",
|
||||
"admin_clear",
|
||||
@@ -257,6 +258,7 @@ LOCALE_GROUPS = [
|
||||
"audience": "internal",
|
||||
"prefixes": (
|
||||
"admin_settings_field_log_",
|
||||
"admin_settings_field_backup_",
|
||||
"admin_settings_field_support_",
|
||||
"admin_settings_field_subscription_notifications_",
|
||||
"admin_settings_field_subscription_notify_",
|
||||
|
||||
@@ -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(
|
||||
@@ -68,9 +98,7 @@ class PanelWebhookService:
|
||||
text = _(message_key, **kwargs)
|
||||
if extra_text:
|
||||
text = f"{text}\n\n{extra_text}"
|
||||
await self.bot.send_message(
|
||||
user_id, text, reply_markup=reply_markup
|
||||
)
|
||||
await self.bot.send_message(user_id, text, reply_markup=reply_markup)
|
||||
except Exception:
|
||||
logging.exception("Failed to send notification to %s", user_id)
|
||||
|
||||
@@ -104,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
|
||||
@@ -221,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]:
|
||||
|
||||
@@ -263,6 +263,35 @@ async def load_overrides_from_db(settings: Settings, async_session_factory: sess
|
||||
return applied
|
||||
|
||||
|
||||
async def refresh_overrides_from_db(
|
||||
settings: Settings,
|
||||
async_session_factory: sessionmaker,
|
||||
*,
|
||||
keys: Optional[set[str]] = None,
|
||||
) -> int:
|
||||
"""Refresh already-known runtime overrides without startup restore side effects."""
|
||||
|
||||
try:
|
||||
async with async_session_factory() as session:
|
||||
overrides = await app_settings_dal.get_all_overrides(session)
|
||||
except Exception as exc:
|
||||
logger.warning("Could not refresh setting overrides from DB: %s", exc)
|
||||
return 0
|
||||
if keys is not None:
|
||||
try:
|
||||
env_only = Settings()
|
||||
for key in keys:
|
||||
if key in overrides:
|
||||
continue
|
||||
attr_name = _resolve_attribute_name(env_only, key)
|
||||
if attr_name and hasattr(env_only, attr_name):
|
||||
setattr(settings, attr_name, getattr(env_only, attr_name))
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to restore env defaults while refreshing overrides: %s", exc)
|
||||
overrides = {key: value for key, value in overrides.items() if key in keys}
|
||||
return apply_overrides(settings, overrides)
|
||||
|
||||
|
||||
async def update_overrides(
|
||||
settings: Settings,
|
||||
async_session_factory: sessionmaker,
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1,402 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.utils.text_decorations import html_decoration as hd
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
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
|
||||
|
||||
SUBSCRIPTION_NOTIFICATION_LOCK = "subscription-notification-worker"
|
||||
DEFAULT_SUBSCRIPTION_NOTIFICATION_TICK_SECONDS = 300
|
||||
EXPIRED_NOTIFICATION_WINDOW = timedelta(hours=24)
|
||||
EXPIRED_AFTER_NOTIFICATION_WINDOW = timedelta(hours=48)
|
||||
|
||||
|
||||
class SubscriptionNotificationWorker:
|
||||
def __init__(
|
||||
self,
|
||||
settings: Settings,
|
||||
session_factory: sessionmaker,
|
||||
bot: Bot,
|
||||
i18n: JsonI18n,
|
||||
panel_service: PanelApiService,
|
||||
subscription_service: SubscriptionService,
|
||||
) -> None:
|
||||
self.settings = settings
|
||||
self.session_factory = session_factory
|
||||
self.bot = bot
|
||||
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:
|
||||
while not self._stopped.is_set():
|
||||
try:
|
||||
async with redis_lock(
|
||||
self.settings,
|
||||
SUBSCRIPTION_NOTIFICATION_LOCK,
|
||||
ttl_seconds=max(60, self._tick_seconds() - 10),
|
||||
) as acquired:
|
||||
if not acquired:
|
||||
logging.info(
|
||||
"SubscriptionNotificationWorker tick skipped: Redis lock is held"
|
||||
)
|
||||
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()
|
||||
logging.info(
|
||||
"metric worker_tick_duration_seconds=%.3f "
|
||||
"worker=subscription_notification",
|
||||
time.monotonic() - started,
|
||||
)
|
||||
except Exception:
|
||||
logging.exception("SubscriptionNotificationWorker tick failed")
|
||||
try:
|
||||
await asyncio.wait_for(self._stopped.wait(), timeout=self._tick_seconds())
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stopped.set()
|
||||
|
||||
def _tick_seconds(self) -> int:
|
||||
return int(
|
||||
getattr(
|
||||
self.settings,
|
||||
"SUBSCRIPTION_NOTIFICATION_WORKER_TICK_SECONDS",
|
||||
DEFAULT_SUBSCRIPTION_NOTIFICATION_TICK_SECONDS,
|
||||
)
|
||||
or DEFAULT_SUBSCRIPTION_NOTIFICATION_TICK_SECONDS
|
||||
)
|
||||
|
||||
async def expiry_tick(self, session: AsyncSession) -> None:
|
||||
if not getattr(self.settings, "SUBSCRIPTION_NOTIFICATIONS_ENABLED", True):
|
||||
return
|
||||
now = datetime.now(timezone.utc)
|
||||
lower = now - EXPIRED_AFTER_NOTIFICATION_WINDOW
|
||||
upper = now + self._max_before_window()
|
||||
result = await session.execute(
|
||||
select(Subscription)
|
||||
.where(
|
||||
Subscription.skip_notifications == False,
|
||||
Subscription.end_date >= lower,
|
||||
Subscription.end_date <= upper,
|
||||
)
|
||||
.options(selectinload(Subscription.user))
|
||||
.order_by(Subscription.end_date.asc())
|
||||
)
|
||||
for sub in result.scalars().all():
|
||||
stage = self.stage_for_subscription(sub, now)
|
||||
if stage is None:
|
||||
continue
|
||||
await self.lifecycle_notifications.send_stage(
|
||||
session,
|
||||
sub,
|
||||
stage,
|
||||
sent_at=now,
|
||||
)
|
||||
|
||||
def stage_for_subscription(
|
||||
self,
|
||||
sub: Subscription,
|
||||
now: datetime,
|
||||
) -> Optional[SubscriptionNotificationStage]:
|
||||
end_date = self._as_utc(getattr(sub, "end_date", None))
|
||||
if end_date is None:
|
||||
return None
|
||||
|
||||
seconds_left = (end_date - now).total_seconds()
|
||||
if seconds_left > 0:
|
||||
hours_before = int(getattr(self.settings, "SUBSCRIPTION_NOTIFY_HOURS_BEFORE", 0) or 0)
|
||||
if 0 < hours_before <= 23 and seconds_left <= hours_before * 3600:
|
||||
return SubscriptionNotificationStage(
|
||||
key=f"before_{hours_before}h",
|
||||
message_key="subscription_hours_notification",
|
||||
hours_before=hours_before,
|
||||
)
|
||||
|
||||
days_before_limit = max(
|
||||
0,
|
||||
int(getattr(self.settings, "SUBSCRIPTION_NOTIFY_DAYS_BEFORE", 0) or 0),
|
||||
)
|
||||
day_stages = (
|
||||
(1, "subscription_24h_notification"),
|
||||
(2, "subscription_48h_notification"),
|
||||
(3, "subscription_72h_notification"),
|
||||
)
|
||||
for days_before, message_key in day_stages:
|
||||
if days_before > days_before_limit:
|
||||
continue
|
||||
if seconds_left <= days_before * 24 * 3600:
|
||||
return SubscriptionNotificationStage(
|
||||
key=f"before_{days_before}d",
|
||||
message_key=message_key,
|
||||
days_left=days_before,
|
||||
)
|
||||
return None
|
||||
|
||||
expired_for = now - end_date
|
||||
if (
|
||||
getattr(self.settings, "SUBSCRIPTION_NOTIFY_ON_EXPIRE", True)
|
||||
and expired_for <= EXPIRED_NOTIFICATION_WINDOW
|
||||
):
|
||||
return SubscriptionNotificationStage(
|
||||
key="expired",
|
||||
message_key="subscription_expired_notification",
|
||||
days_left=0,
|
||||
)
|
||||
if (
|
||||
getattr(self.settings, "SUBSCRIPTION_NOTIFY_AFTER_EXPIRE", True)
|
||||
and EXPIRED_NOTIFICATION_WINDOW < expired_for <= EXPIRED_AFTER_NOTIFICATION_WINDOW
|
||||
):
|
||||
return SubscriptionNotificationStage(
|
||||
key="expired_24h_after",
|
||||
message_key="subscription_expired_yesterday_notification",
|
||||
days_left=0,
|
||||
)
|
||||
return None
|
||||
|
||||
async def trial_traffic_tick(self, session: AsyncSession) -> None:
|
||||
if not getattr(self.settings, "SUBSCRIPTION_NOTIFICATIONS_ENABLED", True):
|
||||
return
|
||||
now = datetime.now(timezone.utc)
|
||||
result = await session.execute(
|
||||
select(Subscription)
|
||||
.where(
|
||||
Subscription.skip_notifications == False,
|
||||
Subscription.is_active == True,
|
||||
Subscription.end_date > now,
|
||||
Subscription.traffic_limit_bytes.is_not(None),
|
||||
Subscription.traffic_limit_bytes > 0,
|
||||
or_(
|
||||
Subscription.provider == "trial",
|
||||
Subscription.status_from_panel == "TRIAL",
|
||||
Subscription.duration_months == 0,
|
||||
),
|
||||
)
|
||||
.options(selectinload(Subscription.user))
|
||||
.order_by(Subscription.end_date.asc())
|
||||
)
|
||||
for sub in result.scalars().all():
|
||||
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)
|
||||
limit = int(getattr(sub, "traffic_limit_bytes", 0) or 0)
|
||||
panel_data = await self._panel_user(sub)
|
||||
if panel_data:
|
||||
panel_used, panel_limit, _ = (
|
||||
self.subscription_service._extract_panel_traffic_details(panel_data)
|
||||
)
|
||||
if panel_used is not None:
|
||||
used = int(panel_used)
|
||||
sub.traffic_used_bytes = used
|
||||
if panel_limit is not None:
|
||||
limit = int(panel_limit)
|
||||
sub.traffic_limit_bytes = limit
|
||||
panel_status = str(panel_data.get("status") or "").upper()
|
||||
if panel_status:
|
||||
sub.status_from_panel = panel_status
|
||||
|
||||
if limit <= 0 or used < limit:
|
||||
continue
|
||||
delivery = await self._send_trial_traffic_depleted(
|
||||
session,
|
||||
sub,
|
||||
used=used,
|
||||
limit=limit,
|
||||
send_telegram=not telegram_done,
|
||||
send_email=not email_done,
|
||||
)
|
||||
if not delivery["telegram"] and not delivery["email"]:
|
||||
continue
|
||||
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()
|
||||
if not panel_uuid:
|
||||
return None
|
||||
try:
|
||||
data = await self.panel_service.get_user_by_uuid(panel_uuid, log_response=False)
|
||||
except Exception:
|
||||
logging.exception(
|
||||
"SubscriptionNotificationWorker: failed to fetch panel user %s",
|
||||
panel_uuid,
|
||||
)
|
||||
return None
|
||||
return data if isinstance(data, dict) else None
|
||||
|
||||
async def _send_trial_traffic_depleted(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
sub: Subscription,
|
||||
*,
|
||||
used: int,
|
||||
limit: int,
|
||||
send_telegram: bool = True,
|
||||
send_email: bool = True,
|
||||
) -> dict[str, bool]:
|
||||
user_id = int(getattr(sub, "user_id", 0) or 0)
|
||||
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)
|
||||
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}"
|
||||
),
|
||||
)
|
||||
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))
|
||||
hours_before = max(
|
||||
0,
|
||||
int(getattr(self.settings, "SUBSCRIPTION_NOTIFY_HOURS_BEFORE", 0) or 0),
|
||||
)
|
||||
return max(timedelta(days=min(days_before, 3)), timedelta(hours=hours_before))
|
||||
|
||||
@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)
|
||||
|
||||
@staticmethod
|
||||
def _fmt_bytes(value: int) -> str:
|
||||
size = float(max(0, int(value or 0)))
|
||||
for unit in ("B", "KB", "MB", "GB", "TB"):
|
||||
if size < 1024 or unit == "TB":
|
||||
return f"{size:.1f} {unit}" if unit != "B" else f"{int(size)} B"
|
||||
size /= 1024
|
||||
return f"{size:.1f} TB"
|
||||
@@ -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(
|
||||
@@ -87,9 +87,9 @@ class HwidDeviceMixin:
|
||||
full_price = float(package.price_for_period(period_months))
|
||||
period_start = self._as_aware_utc(getattr(sub, "start_date", None))
|
||||
period_end = self._as_aware_utc(getattr(sub, "end_date", None)) or valid_until
|
||||
if not period_start or period_start >= period_end:
|
||||
period_start = valid_from
|
||||
period_end = valid_until
|
||||
inferred_period_start = add_months(period_end, -period_months)
|
||||
if not period_start or period_start >= period_end or period_start < inferred_period_start:
|
||||
period_start = inferred_period_start
|
||||
|
||||
basis_seconds = max(1.0, (period_end - period_start).total_seconds())
|
||||
billable_start = max(now, valid_from)
|
||||
@@ -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
|
||||
@@ -416,9 +596,7 @@ class SubscriptionLifecycleMixin:
|
||||
"Failed to recalculate active HWID devices for renewal of user %s",
|
||||
user_id,
|
||||
)
|
||||
extra_hwid_devices = int(
|
||||
getattr(current_active_sub, "extra_hwid_devices", 0) or 0
|
||||
)
|
||||
extra_hwid_devices = int(getattr(current_active_sub, "extra_hwid_devices", 0) or 0)
|
||||
premium_topup_balance_bytes = int(
|
||||
getattr(current_active_sub, "premium_topup_balance_bytes", 0) or 0
|
||||
)
|
||||
@@ -570,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
|
||||
@@ -614,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
|
||||
):
|
||||
@@ -628,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,
|
||||
@@ -665,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)
|
||||
@@ -813,9 +1019,7 @@ class SubscriptionLifecycleMixin:
|
||||
subscription_id=local_active_sub.subscription_id,
|
||||
at=datetime.now(timezone.utc),
|
||||
)
|
||||
active_extra_hwid_devices = int(
|
||||
hwid_entitlement_summary.get("active_devices") or 0
|
||||
)
|
||||
active_extra_hwid_devices = int(hwid_entitlement_summary.get("active_devices") or 0)
|
||||
if active_extra_hwid_devices != int(local_active_sub.extra_hwid_devices or 0):
|
||||
await subscription_dal.update_subscription(
|
||||
session,
|
||||
@@ -840,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")
|
||||
@@ -900,10 +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:
|
||||
@@ -441,9 +451,7 @@ class TariffMixin:
|
||||
|
||||
if options.get("mode") == "period_to_period":
|
||||
target_monthly = float(options.get("target_monthly_rub") or 0)
|
||||
hwid_days = (
|
||||
math.floor((value_rub / target_monthly) * 30) if target_monthly > 0 else 0
|
||||
)
|
||||
hwid_days = math.floor((value_rub / target_monthly) * 30) if target_monthly > 0 else 0
|
||||
options["converted_hwid_days"] = max(0, hwid_days)
|
||||
options["recalc_days"] = int(options.get("recalc_days") or 0) + max(0, hwid_days)
|
||||
options["paid_diff_rub"] = max(
|
||||
|
||||
@@ -54,9 +54,7 @@ class TrafficMixin:
|
||||
|
||||
purchase_bytes = self.gb_to_bytes(traffic_gb)
|
||||
extra_hwid_devices = (
|
||||
await self._active_hwid_extra_devices_for_sub(session, active_sub)
|
||||
if active_sub
|
||||
else 0
|
||||
await self._active_hwid_extra_devices_for_sub(session, active_sub) if active_sub else 0
|
||||
)
|
||||
base_hwid_limit = self._base_hwid_limit_for_tariff(tariff)
|
||||
effective_hwid_limit = self._effective_hwid_limit(base_hwid_limit, extra_hwid_devices)
|
||||
@@ -271,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,
|
||||
@@ -378,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,
|
||||
@@ -62,6 +62,7 @@ class TrialSubscriptionMixin:
|
||||
"status_from_panel": "TRIAL",
|
||||
"traffic_limit_bytes": self.settings.trial_traffic_limit_bytes,
|
||||
"auto_renew_enabled": False,
|
||||
"provider": "trial",
|
||||
}
|
||||
try:
|
||||
await subscription_dal.upsert_subscription(session, trial_sub_data)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
@@ -134,6 +135,16 @@ class SupportService:
|
||||
email_auth_service=self.email_auth_service,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _schedule_notification(coro, error_message: str, *error_args: Any) -> None:
|
||||
async def _runner():
|
||||
try:
|
||||
await coro
|
||||
except Exception:
|
||||
logger.exception(error_message, *error_args)
|
||||
|
||||
asyncio.create_task(_runner(), name="support-notification")
|
||||
|
||||
async def _ensure_user_allowed(self, session, user_id: int) -> User:
|
||||
user = await user_dal.get_user_by_id(session, user_id)
|
||||
if not user or user.is_banned or not self.settings.SUPPORT_TICKETS_ENABLED:
|
||||
@@ -218,8 +229,8 @@ class SupportService:
|
||||
await session.commit()
|
||||
|
||||
if notification_decision.send_telegram or notification_decision.send_email:
|
||||
try:
|
||||
await self.notification_service.notify_support_user_reply(
|
||||
self._schedule_notification(
|
||||
self.notification_service.notify_support_user_reply(
|
||||
ticket,
|
||||
message,
|
||||
user,
|
||||
@@ -227,9 +238,10 @@ class SupportService:
|
||||
unread_count=int(ticket.unread_admin_count or 0),
|
||||
send_telegram=notification_decision.send_telegram,
|
||||
send_email=notification_decision.send_email,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to notify about support user reply %s", ticket_id)
|
||||
),
|
||||
"Failed to notify about support user reply %s",
|
||||
ticket_id,
|
||||
)
|
||||
return ticket, message
|
||||
|
||||
async def reply_as_admin(
|
||||
@@ -275,10 +287,11 @@ class SupportService:
|
||||
await session.commit()
|
||||
|
||||
if user and not is_internal_note:
|
||||
try:
|
||||
await self.notification_service.notify_support_admin_reply(ticket, message, user)
|
||||
except Exception:
|
||||
logger.exception("Failed to notify user about support admin reply %s", ticket_id)
|
||||
self._schedule_notification(
|
||||
self.notification_service.notify_support_admin_reply(ticket, message, user),
|
||||
"Failed to notify user about support admin reply %s",
|
||||
ticket_id,
|
||||
)
|
||||
return ticket, message
|
||||
|
||||
async def change_status(self, admin_id: int, ticket_id: int, status: str) -> SupportTicket:
|
||||
|
||||
@@ -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. "
|
||||
@@ -795,8 +933,7 @@ class TariffTrafficWorker:
|
||||
if not cached_at:
|
||||
return False
|
||||
return (
|
||||
time.monotonic() - float(cached_at)
|
||||
< TARIFF_WORKER_SQUAD_CONFIRMATION_CACHE_TTL_SECONDS
|
||||
time.monotonic() - float(cached_at) < TARIFF_WORKER_SQUAD_CONFIRMATION_CACHE_TTL_SECONDS
|
||||
)
|
||||
|
||||
def _remember_premium_squad_match(self, cache_key: tuple[str, tuple[str, ...]]) -> None:
|
||||
@@ -931,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,
|
||||
@@ -963,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:
|
||||
@@ -990,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 []))
|
||||
@@ -1160,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)
|
||||