Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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.*
|
*.local.*
|
||||||
node_modules/
|
node_modules/
|
||||||
frontend/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
|
deploy/compose/docker-compose-dev.yml
|
||||||
data/*
|
data/*
|
||||||
!data/tariffs.example.json
|
!data/tariffs.example.json
|
||||||
@@ -31,10 +35,38 @@ deploy/compose/*.yml
|
|||||||
tmp/
|
tmp/
|
||||||
|
|
||||||
# WebApp build artifacts (regenerated inside Docker)
|
# 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.css
|
||||||
backend/bot/app/web/templates/subscription_webapp.js
|
backend/bot/app/web/templates/subscription_webapp.js
|
||||||
backend/bot/app/web/templates/subscription_webapp.min.*.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.*.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
|
# Byte-compiled / optimized / DLL files
|
||||||
**/__pycache__/
|
**/__pycache__/
|
||||||
|
|||||||
@@ -56,7 +56,10 @@ PANEL_API_URL=https://panel.yourdomain.tld/api
|
|||||||
PANEL_API_KEY=
|
PANEL_API_KEY=
|
||||||
|
|
||||||
# Shared secret for validating incoming Remnawave webhooks.
|
# Shared secret for validating incoming Remnawave webhooks.
|
||||||
# Use the same value when configuring the webhook in Remnawave panel.
|
# Create or set this secret in Remnawave Panel, then paste the same value here
|
||||||
|
# or into Admin -> System -> Settings -> Remnawave Panel.
|
||||||
|
# In Remnawave Panel, set WEBHOOK_URL to WEBHOOK_BASE_URL + /webhook/panel,
|
||||||
|
# for example: https://app.example.com/webhook/panel
|
||||||
PANEL_WEBHOOK_SECRET=
|
PANEL_WEBHOOK_SECRET=
|
||||||
|
|
||||||
# Host port that publishes the backend webhook server from Docker Compose.
|
# Host port that publishes the backend webhook server from Docker Compose.
|
||||||
|
|||||||
@@ -1,2 +1,9 @@
|
|||||||
|
.gitattributes text eol=lf
|
||||||
*.sh text eol=lf
|
*.sh text eol=lf
|
||||||
|
.github/workflows/*.yml text eol=lf
|
||||||
deploy/docker/frontend/*.sh text eol=lf
|
deploy/docker/frontend/*.sh text eol=lf
|
||||||
|
frontend/src/*.js text eol=lf
|
||||||
|
frontend/src/**/*.js text eol=lf
|
||||||
|
frontend/src/**/*.svelte text eol=lf
|
||||||
|
frontend/scripts/*.mjs text eol=lf
|
||||||
|
frontend/scripts/**/*.mjs text eol=lf
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
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=sha,prefix=dev-,format=short,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/
|
node_modules/
|
||||||
.git/
|
.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)
|
# WebApp build artifacts (regenerated by `npm run build:webapp` / Docker build)
|
||||||
bot/app/web/templates/subscription_webapp.css
|
bot/app/web/templates/subscription_webapp.css
|
||||||
bot/app/web/templates/subscription_webapp.js
|
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.min.*.js.gz
|
||||||
bot/app/web/templates/subscription_webapp_admin.*.css.br
|
bot/app/web/templates/subscription_webapp_admin.*.css.br
|
||||||
bot/app/web/templates/subscription_webapp_admin.*.css.gz
|
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.css
|
||||||
backend/bot/app/web/templates/subscription_webapp.js
|
backend/bot/app/web/templates/subscription_webapp.js
|
||||||
backend/bot/app/web/templates/subscription_webapp.min.*.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.min.*.js.gz
|
||||||
backend/bot/app/web/templates/subscription_webapp_admin.*.css.br
|
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_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
|
tmp
|
||||||
.claude
|
.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-коду.
|
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;
|
- статистика пользователей, подписок, платежей и синхронизации с Remnawave;
|
||||||
- список пользователей с поиском, фильтрами и колонкой premium-трафика;
|
- список пользователей с поиском, фильтрами и колонкой premium-трафика;
|
||||||
- блокировка пользователей, поддержка через тикеты, рассылки, промокоды, логи действий и настройка разрешенных параметров приложения поверх `.env`;
|
- блокировка пользователей, поддержка через тикеты, рассылки, промокоды, логи действий и настройка разрешенных параметров приложения поверх `.env`;
|
||||||
- редактор JSON-каталога тарифов с period/traffic-моделями, Internal Squads, premium-сквадами и HWID-пакетами;
|
- редактор JSON-каталога тарифов с моделями на срок/по трафику, Internal Squads, premium-сквадами и HWID-пакетами;
|
||||||
- настройки инструкций подключения: чтение конфига Subscription Page из Remnawave Panel, опциональный JSON-override и переключатель поведения кнопок бота;
|
- настройки инструкций подключения: чтение конфига Subscription Page из Remnawave Panel, опциональное JSON-переопределение и переключатель поведения кнопок бота;
|
||||||
- ручная синхронизация пользователей и подписок с панелью.
|
- ручная синхронизация пользователей и подписок с панелью.
|
||||||
|
|
||||||
## Документация
|
## Документация
|
||||||
|
|
||||||
- [Настройка окружения](docs/configuration.md) - bootstrap `.env` и рекомендуемая настройка через Web App админку.
|
- [Входная страница документации](docs/index.md) - маршрут по установке, настройке, платежам, админке и диагностике.
|
||||||
- [Переменные `.env`](docs/env-vars.md) - полный справочник всех env-ключей по разделам.
|
- [Развертывание](docs/getting-started/deployment.md) - Docker Compose, Caddy, Nginx, Pangolin/Newt и запуск без обратного прокси.
|
||||||
- [Тарифы](docs/tariffs.md) - каталог тарифов, period- и traffic-модели, обычные и premium-докупки, premium-сквады, смена тарифа, HWID-лимиты и обработка трафика.
|
- [Настройка окружения](docs/getting-started/configuration.md) - bootstrap `.env` и рекомендуемая настройка через Web App админку.
|
||||||
- [Админ-панель](docs/admin.md) - права доступа, настройки, редактор тарифов, premium-сквады и сохранение JSON-каталога.
|
- [Переменные `.env`](docs/configuration/env-vars.md) - полный справочник всех env-ключей по разделам.
|
||||||
- [Web App / Mini App](docs/webapp.md) - отдельный порт, домен, Telegram OAuth, email-вход, инструкции установки и реферальные ссылки.
|
- [Бэкапы и восстановление](docs/features/backups.md) - автоматические архивы, Telegram-отправка и restore через админку.
|
||||||
- [Поддержка](docs/support.md) - тикеты в Mini App, входящий список админки, уведомления, лимиты и внешняя ссылка поддержки.
|
- [Тарифы](docs/features/tariffs.md) - каталог тарифов, модели на срок и по трафику, обычные и premium-докупки, premium-сквады, смена тарифа, HWID-лимиты и обработка трафика.
|
||||||
- [Темы Web App](docs/webapp-themes.md) - кастомные темы, настройка внешнего вида, логотипы, CSS/ассеты и пайплайн создания новой темы.
|
- [Админ-панель](docs/features/admin-panel.md) - права доступа, настройки, редактор тарифов, premium-сквады и сохранение JSON-каталога.
|
||||||
- [Развертывание](docs/deployment.md) - Docker Compose, reverse proxy, Nginx, Caddy, вебхуки, запуск из образа и обновление версии (`IMAGE_TAG`).
|
- [Веб-приложение / Mini App](docs/features/web-app.md) - отдельный порт, домен, инструкции установки и реферальные ссылки.
|
||||||
- [Миграция с remnawave-tg-shop](docs/migration-to-minishop.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;
|
- `PANEL_API_URL`, `PANEL_API_KEY`, `PANEL_WEBHOOK_SECRET` - доступ к Remnawave;
|
||||||
- остальные настройки удобнее задать в Web App админке.
|
- остальные настройки удобнее задать в 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
|
```bash
|
||||||
mkdir -p data/themes data/webapp-logo data/webapp-emoji
|
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
|
docker compose logs -f backend worker frontend
|
||||||
|
|
||||||
# Готовые production-примеры
|
# Рекомендуемый продакшен-вариант с Caddy
|
||||||
cd deploy/examples/caddy # или nginx, newt, no-proxy
|
cd deploy/examples/caddy # или nginx, newt, no-proxy
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
nano .env
|
nano .env
|
||||||
@@ -120,13 +126,24 @@ docker compose up -d
|
|||||||
IMAGE_TAG=3.1.0 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-backend`
|
||||||
- `ghcr.io/3252a8/remnawave-minishop-worker`
|
- `ghcr.io/3252a8/remnawave-minishop-worker`
|
||||||
- `ghcr.io/3252a8/remnawave-minishop-frontend`
|
- `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
|
||||||
|
```
|
||||||
|
|
||||||
## Поддержать проект
|
## Поддержать проект
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from bot.app.web.admin_api_impl import (
|
|||||||
_runtime as _runtime,
|
_runtime as _runtime,
|
||||||
ads as _ads,
|
ads as _ads,
|
||||||
auth as _auth,
|
auth as _auth,
|
||||||
|
backups as _backups,
|
||||||
broadcast as _broadcast,
|
broadcast as _broadcast,
|
||||||
common as _common,
|
common as _common,
|
||||||
logs as _logs,
|
logs as _logs,
|
||||||
@@ -36,6 +37,7 @@ _MODULES = (
|
|||||||
_broadcast,
|
_broadcast,
|
||||||
_sync,
|
_sync,
|
||||||
_ads,
|
_ads,
|
||||||
|
_backups,
|
||||||
_settings,
|
_settings,
|
||||||
_tariffs,
|
_tariffs,
|
||||||
_themes,
|
_themes,
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
# 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))
|
||||||
|
|
||||||
|
if result.database_restored:
|
||||||
|
try:
|
||||||
|
from db import database_setup
|
||||||
|
|
||||||
|
if 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()})
|
||||||
@@ -143,12 +143,18 @@ def _payment_traffic_gb_split(payment: Payment) -> Tuple[Optional[float], Option
|
|||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
def _payment_user_display_label(loaded_user: Any, payment_user_id: int) -> str:
|
def _user_display_label(
|
||||||
"""Human-facing name for payments tables: TG profile name, else email, else user id."""
|
loaded_user: Any,
|
||||||
if loaded_user is None:
|
fallback_user_id: Optional[int],
|
||||||
return str(payment_user_id)
|
*,
|
||||||
|
first_name: Optional[str] = None,
|
||||||
|
last_name: Optional[str] = None,
|
||||||
|
username: Optional[str] = None,
|
||||||
|
email: Optional[str] = None,
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""Human-facing name: TG profile name, else email, else user id."""
|
||||||
tid = getattr(loaded_user, "telegram_id", None)
|
tid = getattr(loaded_user, "telegram_id", None)
|
||||||
if tid is not None:
|
if loaded_user is not None and tid is not None:
|
||||||
fn = (getattr(loaded_user, "first_name", None) or "").strip()
|
fn = (getattr(loaded_user, "first_name", None) or "").strip()
|
||||||
ln = (getattr(loaded_user, "last_name", None) or "").strip()
|
ln = (getattr(loaded_user, "last_name", None) or "").strip()
|
||||||
full = f"{fn} {ln}".strip()
|
full = f"{fn} {ln}".strip()
|
||||||
@@ -157,10 +163,30 @@ def _payment_user_display_label(loaded_user: Any, payment_user_id: int) -> str:
|
|||||||
un = (getattr(loaded_user, "username", None) or "").strip()
|
un = (getattr(loaded_user, "username", None) or "").strip()
|
||||||
if un:
|
if un:
|
||||||
return un if un.startswith("@") else f"@{un}"
|
return un if un.startswith("@") else f"@{un}"
|
||||||
return str(payment_user_id)
|
elif loaded_user is not None:
|
||||||
email = (getattr(loaded_user, "email", None) or "").strip()
|
email = (getattr(loaded_user, "email", None) or "").strip()
|
||||||
if email:
|
if email:
|
||||||
return email
|
return email
|
||||||
|
fn = (first_name or "").strip()
|
||||||
|
ln = (last_name or "").strip()
|
||||||
|
full = f"{fn} {ln}".strip()
|
||||||
|
if full:
|
||||||
|
return full
|
||||||
|
un = (username or "").strip()
|
||||||
|
if un:
|
||||||
|
return un if un.startswith("@") else f"@{un}"
|
||||||
|
email_value = (email or "").strip()
|
||||||
|
if email_value:
|
||||||
|
return email_value
|
||||||
|
if fallback_user_id is None:
|
||||||
|
return None
|
||||||
|
return str(fallback_user_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _payment_user_display_label(loaded_user: Any, payment_user_id: int) -> str:
|
||||||
|
label = _user_display_label(loaded_user, payment_user_id)
|
||||||
|
if label:
|
||||||
|
return label
|
||||||
return str(payment_user_id)
|
return str(payment_user_id)
|
||||||
|
|
||||||
|
|
||||||
@@ -229,16 +255,27 @@ def _serialize_ad(campaign: AdCampaign, totals: Optional[Dict[str, Any]] = None)
|
|||||||
|
|
||||||
|
|
||||||
def _serialize_log(entry: MessageLog) -> Dict[str, Any]:
|
def _serialize_log(entry: MessageLog) -> Dict[str, Any]:
|
||||||
|
author_user = entry.__dict__.get("author_user")
|
||||||
|
target_user = entry.__dict__.get("target_user")
|
||||||
|
user_id = int(entry.user_id) if entry.user_id is not None else None
|
||||||
|
target_user_id = int(entry.target_user_id) if entry.target_user_id is not None else None
|
||||||
return {
|
return {
|
||||||
"log_id": int(entry.log_id),
|
"log_id": int(entry.log_id),
|
||||||
"user_id": int(entry.user_id) if entry.user_id else None,
|
"user_id": user_id,
|
||||||
|
"user_label": _user_display_label(
|
||||||
|
author_user,
|
||||||
|
user_id,
|
||||||
|
first_name=entry.telegram_first_name,
|
||||||
|
username=entry.telegram_username,
|
||||||
|
),
|
||||||
"telegram_username": entry.telegram_username,
|
"telegram_username": entry.telegram_username,
|
||||||
"telegram_first_name": entry.telegram_first_name,
|
"telegram_first_name": entry.telegram_first_name,
|
||||||
"email": getattr(getattr(entry, "author_user", None), "email", None),
|
"email": getattr(author_user, "email", None),
|
||||||
"event_type": entry.event_type,
|
"event_type": entry.event_type,
|
||||||
"content": entry.content,
|
"content": entry.content,
|
||||||
"is_admin_event": bool(entry.is_admin_event),
|
"is_admin_event": bool(entry.is_admin_event),
|
||||||
"target_user_id": int(entry.target_user_id) if entry.target_user_id else None,
|
"target_user_id": target_user_id,
|
||||||
|
"target_user_label": _user_display_label(target_user, target_user_id),
|
||||||
"timestamp": entry.timestamp.isoformat() if entry.timestamp else None,
|
"timestamp": entry.timestamp.isoformat() if entry.timestamp else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -75,4 +75,8 @@ def setup_admin_routes(app: web.Application) -> None:
|
|||||||
router.add_put("/api/admin/themes", admin_themes_save_route)
|
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/logo", admin_appearance_logo_upload_route)
|
||||||
router.add_post("/api/admin/appearance/favicon", admin_appearance_favicon_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)
|
router.add_get("/api/admin/panel/internal-squads", admin_panel_internal_squads_route)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from bot.services.locale_override_service import (
|
|||||||
update_locale_overrides,
|
update_locale_overrides,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _locale_languages(
|
def _locale_languages(
|
||||||
i18n: JsonI18n,
|
i18n: JsonI18n,
|
||||||
overrides: Optional[List[Dict[str, Any]]] = None,
|
overrides: Optional[List[Dict[str, Any]]] = None,
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ class SettingField:
|
|||||||
i18n_label_key: Optional[str] = None
|
i18n_label_key: Optional[str] = None
|
||||||
i18n_description_key: Optional[str] = None
|
i18n_description_key: Optional[str] = None
|
||||||
i18n_subsection_key: Optional[str] = None
|
i18n_subsection_key: Optional[str] = None
|
||||||
|
webhook_path: Optional[str] = None
|
||||||
|
webhook_requires_base_url: bool = False
|
||||||
|
webhook_provider_id: Optional[str] = None
|
||||||
|
webhook_hint_i18n_key: Optional[str] = None
|
||||||
|
webhook_hint: str = ""
|
||||||
|
|
||||||
|
|
||||||
SETTINGS_MANIFEST: List[SettingField] = [
|
SETTINGS_MANIFEST: List[SettingField] = [
|
||||||
@@ -84,44 +89,44 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
|||||||
SettingField(
|
SettingField(
|
||||||
"PANEL_API_URL",
|
"PANEL_API_URL",
|
||||||
"url",
|
"url",
|
||||||
"general",
|
"remnawave",
|
||||||
"URL API Remnawave",
|
"URL API Remnawave",
|
||||||
"Например, https://panel.example.com/api.",
|
"Например, https://panel.example.com/api.",
|
||||||
subsection="Remnawave",
|
|
||||||
),
|
),
|
||||||
SettingField(
|
SettingField(
|
||||||
"PANEL_API_KEY",
|
"PANEL_API_KEY",
|
||||||
"string",
|
"string",
|
||||||
"general",
|
"remnawave",
|
||||||
"API-ключ Remnawave",
|
"API-ключ Remnawave",
|
||||||
"Секретный ключ API панели.",
|
"Секретный ключ API панели.",
|
||||||
secret=True,
|
secret=True,
|
||||||
subsection="Remnawave",
|
|
||||||
),
|
),
|
||||||
SettingField(
|
SettingField(
|
||||||
"PANEL_WEBHOOK_SECRET",
|
"PANEL_WEBHOOK_SECRET",
|
||||||
"string",
|
"string",
|
||||||
"general",
|
"remnawave",
|
||||||
"Секрет вебхуков Remnawave",
|
"Секрет вебхуков Remnawave",
|
||||||
"Используется для проверки входящих вебхуков панели.",
|
"Используется для проверки входящих вебхуков панели.",
|
||||||
secret=True,
|
secret=True,
|
||||||
subsection="Remnawave",
|
webhook_path="/webhook/panel",
|
||||||
|
webhook_requires_base_url=True,
|
||||||
|
webhook_provider_id="remnawave",
|
||||||
|
webhook_hint_i18n_key="admin_settings_panel_webhook_url_hint",
|
||||||
|
webhook_hint="Use this URL as WEBHOOK_URL in Remnawave Panel.",
|
||||||
),
|
),
|
||||||
SettingField(
|
SettingField(
|
||||||
"USER_SQUAD_UUIDS",
|
"USER_SQUAD_UUIDS",
|
||||||
"string",
|
"string",
|
||||||
"general",
|
"remnawave",
|
||||||
"Internal Squads по умолчанию",
|
"Internal Squads по умолчанию",
|
||||||
"UUID через запятую для legacy-режима без JSON-каталога тарифов.",
|
"UUID через запятую для legacy-режима без JSON-каталога тарифов.",
|
||||||
subsection="Remnawave",
|
|
||||||
),
|
),
|
||||||
SettingField(
|
SettingField(
|
||||||
"USER_EXTERNAL_SQUAD_UUID",
|
"USER_EXTERNAL_SQUAD_UUID",
|
||||||
"string",
|
"string",
|
||||||
"general",
|
"remnawave",
|
||||||
"External Squad по умолчанию",
|
"External Squad по умолчанию",
|
||||||
"Необязательный UUID External Squad для новых пользователей.",
|
"Необязательный UUID External Squad для новых пользователей.",
|
||||||
subsection="Remnawave",
|
|
||||||
),
|
),
|
||||||
# ─── Web app appearance ────────────────────────────────────────
|
# ─── Web app appearance ────────────────────────────────────────
|
||||||
SettingField(
|
SettingField(
|
||||||
@@ -230,6 +235,70 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
|||||||
SettingField("STARS_PRICE_3_MONTHS", "int", "pricing", "Цена 3 мес. (Stars)"),
|
SettingField("STARS_PRICE_3_MONTHS", "int", "pricing", "Цена 3 мес. (Stars)"),
|
||||||
SettingField("STARS_PRICE_6_MONTHS", "int", "pricing", "Цена 6 мес. (Stars)"),
|
SettingField("STARS_PRICE_6_MONTHS", "int", "pricing", "Цена 6 мес. (Stars)"),
|
||||||
SettingField("STARS_PRICE_12_MONTHS", "int", "pricing", "Цена 12 мес. (Stars)"),
|
SettingField("STARS_PRICE_12_MONTHS", "int", "pricing", "Цена 12 мес. (Stars)"),
|
||||||
|
SettingField(
|
||||||
|
"REFERRAL_BONUS_DAYS_INVITER_1_MONTH",
|
||||||
|
"int",
|
||||||
|
"pricing",
|
||||||
|
"Бонус приглашающему: 1 мес.",
|
||||||
|
min=0,
|
||||||
|
subsection="legacy_tariffs",
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"REFERRAL_BONUS_DAYS_INVITER_3_MONTHS",
|
||||||
|
"int",
|
||||||
|
"pricing",
|
||||||
|
"Бонус приглашающему: 3 мес.",
|
||||||
|
min=0,
|
||||||
|
subsection="legacy_tariffs",
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"REFERRAL_BONUS_DAYS_INVITER_6_MONTHS",
|
||||||
|
"int",
|
||||||
|
"pricing",
|
||||||
|
"Бонус приглашающему: 6 мес.",
|
||||||
|
min=0,
|
||||||
|
subsection="legacy_tariffs",
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"REFERRAL_BONUS_DAYS_INVITER_12_MONTHS",
|
||||||
|
"int",
|
||||||
|
"pricing",
|
||||||
|
"Бонус приглашающему: 12 мес.",
|
||||||
|
min=0,
|
||||||
|
subsection="legacy_tariffs",
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"REFERRAL_BONUS_DAYS_REFEREE_1_MONTH",
|
||||||
|
"int",
|
||||||
|
"pricing",
|
||||||
|
"Бонус приглашённому: 1 мес.",
|
||||||
|
min=0,
|
||||||
|
subsection="legacy_tariffs",
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"REFERRAL_BONUS_DAYS_REFEREE_3_MONTHS",
|
||||||
|
"int",
|
||||||
|
"pricing",
|
||||||
|
"Бонус приглашённому: 3 мес.",
|
||||||
|
min=0,
|
||||||
|
subsection="legacy_tariffs",
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"REFERRAL_BONUS_DAYS_REFEREE_6_MONTHS",
|
||||||
|
"int",
|
||||||
|
"pricing",
|
||||||
|
"Бонус приглашённому: 6 мес.",
|
||||||
|
min=0,
|
||||||
|
subsection="legacy_tariffs",
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"REFERRAL_BONUS_DAYS_REFEREE_12_MONTHS",
|
||||||
|
"int",
|
||||||
|
"pricing",
|
||||||
|
"Бонус приглашённому: 12 мес.",
|
||||||
|
min=0,
|
||||||
|
subsection="legacy_tariffs",
|
||||||
|
),
|
||||||
SettingField(
|
SettingField(
|
||||||
"TRAFFIC_PACKAGES", "string", "pricing", "Пакеты трафика", "Формат: 10:199,50:799 (ГБ:цена)"
|
"TRAFFIC_PACKAGES", "string", "pricing", "Пакеты трафика", "Формат: 10:199,50:799 (ГБ:цена)"
|
||||||
),
|
),
|
||||||
@@ -283,12 +352,20 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
|||||||
subsection="common",
|
subsection="common",
|
||||||
),
|
),
|
||||||
# ─── Trial ─────────────────────────────────────────────────────
|
# ─── Trial ─────────────────────────────────────────────────────
|
||||||
SettingField("TRIAL_ENABLED", "bool", "pricing", "Триал включён", subsection="trial"),
|
SettingField(
|
||||||
|
"TRIAL_ENABLED",
|
||||||
|
"bool",
|
||||||
|
"pricing",
|
||||||
|
"Триал включён",
|
||||||
|
optional=False,
|
||||||
|
subsection="trial",
|
||||||
|
),
|
||||||
SettingField(
|
SettingField(
|
||||||
"TRIAL_DURATION_DAYS",
|
"TRIAL_DURATION_DAYS",
|
||||||
"int",
|
"int",
|
||||||
"pricing",
|
"pricing",
|
||||||
"Длительность триала (дней)",
|
"Длительность триала (дней)",
|
||||||
|
optional=False,
|
||||||
min=0,
|
min=0,
|
||||||
subsection="trial",
|
subsection="trial",
|
||||||
),
|
),
|
||||||
@@ -297,6 +374,7 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
|||||||
"float",
|
"float",
|
||||||
"pricing",
|
"pricing",
|
||||||
"Лимит трафика триала (ГБ)",
|
"Лимит трафика триала (ГБ)",
|
||||||
|
optional=False,
|
||||||
min=0,
|
min=0,
|
||||||
subsection="trial",
|
subsection="trial",
|
||||||
),
|
),
|
||||||
@@ -305,6 +383,7 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
|||||||
"string",
|
"string",
|
||||||
"pricing",
|
"pricing",
|
||||||
"Стратегия сброса трафика триала",
|
"Стратегия сброса трафика триала",
|
||||||
|
optional=False,
|
||||||
subsection="trial",
|
subsection="trial",
|
||||||
),
|
),
|
||||||
SettingField(
|
SettingField(
|
||||||
@@ -323,62 +402,6 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
|||||||
"REFERRAL_WELCOME_BONUS_DAYS", "int", "referral", "Приветственный бонус (дней)", min=0
|
"REFERRAL_WELCOME_BONUS_DAYS", "int", "referral", "Приветственный бонус (дней)", min=0
|
||||||
),
|
),
|
||||||
SettingField("LEGACY_REFS", "bool", "referral", "Поддержка старых ref-ссылок"),
|
SettingField("LEGACY_REFS", "bool", "referral", "Поддержка старых ref-ссылок"),
|
||||||
SettingField(
|
|
||||||
"REFERRAL_BONUS_DAYS_INVITER_1_MONTH",
|
|
||||||
"int",
|
|
||||||
"referral",
|
|
||||||
"Бонус приглашающему: 1 мес.",
|
|
||||||
min=0,
|
|
||||||
),
|
|
||||||
SettingField(
|
|
||||||
"REFERRAL_BONUS_DAYS_INVITER_3_MONTHS",
|
|
||||||
"int",
|
|
||||||
"referral",
|
|
||||||
"Бонус приглашающему: 3 мес.",
|
|
||||||
min=0,
|
|
||||||
),
|
|
||||||
SettingField(
|
|
||||||
"REFERRAL_BONUS_DAYS_INVITER_6_MONTHS",
|
|
||||||
"int",
|
|
||||||
"referral",
|
|
||||||
"Бонус приглашающему: 6 мес.",
|
|
||||||
min=0,
|
|
||||||
),
|
|
||||||
SettingField(
|
|
||||||
"REFERRAL_BONUS_DAYS_INVITER_12_MONTHS",
|
|
||||||
"int",
|
|
||||||
"referral",
|
|
||||||
"Бонус приглашающему: 12 мес.",
|
|
||||||
min=0,
|
|
||||||
),
|
|
||||||
SettingField(
|
|
||||||
"REFERRAL_BONUS_DAYS_REFEREE_1_MONTH",
|
|
||||||
"int",
|
|
||||||
"referral",
|
|
||||||
"Бонус приглашённому: 1 мес.",
|
|
||||||
min=0,
|
|
||||||
),
|
|
||||||
SettingField(
|
|
||||||
"REFERRAL_BONUS_DAYS_REFEREE_3_MONTHS",
|
|
||||||
"int",
|
|
||||||
"referral",
|
|
||||||
"Бонус приглашённому: 3 мес.",
|
|
||||||
min=0,
|
|
||||||
),
|
|
||||||
SettingField(
|
|
||||||
"REFERRAL_BONUS_DAYS_REFEREE_6_MONTHS",
|
|
||||||
"int",
|
|
||||||
"referral",
|
|
||||||
"Бонус приглашённому: 6 мес.",
|
|
||||||
min=0,
|
|
||||||
),
|
|
||||||
SettingField(
|
|
||||||
"REFERRAL_BONUS_DAYS_REFEREE_12_MONTHS",
|
|
||||||
"int",
|
|
||||||
"referral",
|
|
||||||
"Бонус приглашённому: 12 мес.",
|
|
||||||
min=0,
|
|
||||||
),
|
|
||||||
# ─── Notifications ─────────────────────────────────────────────
|
# ─── Notifications ─────────────────────────────────────────────
|
||||||
SettingField(
|
SettingField(
|
||||||
"SUBSCRIPTION_NOTIFICATIONS_ENABLED",
|
"SUBSCRIPTION_NOTIFICATIONS_ENABLED",
|
||||||
@@ -386,6 +409,13 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
|||||||
"notifications",
|
"notifications",
|
||||||
"Включены уведомления о подписке",
|
"Включены уведомления о подписке",
|
||||||
),
|
),
|
||||||
|
SettingField(
|
||||||
|
"SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED",
|
||||||
|
"bool",
|
||||||
|
"notifications",
|
||||||
|
"Дублировать уведомления о подписке на email",
|
||||||
|
"Письма отправляются только пользователям с привязанным email и рабочим SMTP.",
|
||||||
|
),
|
||||||
SettingField(
|
SettingField(
|
||||||
"SUBSCRIPTION_NOTIFY_ON_EXPIRE", "bool", "notifications", "Уведомлять об истечении"
|
"SUBSCRIPTION_NOTIFY_ON_EXPIRE", "bool", "notifications", "Уведомлять об истечении"
|
||||||
),
|
),
|
||||||
@@ -399,6 +429,14 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
|||||||
"За сколько дней предупреждать",
|
"За сколько дней предупреждать",
|
||||||
min=0,
|
min=0,
|
||||||
),
|
),
|
||||||
|
SettingField(
|
||||||
|
"SUBSCRIPTION_NOTIFY_HOURS_BEFORE",
|
||||||
|
"int",
|
||||||
|
"notifications",
|
||||||
|
"За сколько часов предупреждать",
|
||||||
|
min=0,
|
||||||
|
max=23,
|
||||||
|
),
|
||||||
SettingField("LOG_NEW_USERS", "bool", "notifications", "Логировать новых пользователей"),
|
SettingField("LOG_NEW_USERS", "bool", "notifications", "Логировать новых пользователей"),
|
||||||
SettingField("LOG_PAYMENTS", "bool", "notifications", "Логировать платежи"),
|
SettingField("LOG_PAYMENTS", "bool", "notifications", "Логировать платежи"),
|
||||||
SettingField("LOG_SUPPORT", "bool", "notifications", "Логировать тикеты поддержки"),
|
SettingField("LOG_SUPPORT", "bool", "notifications", "Логировать тикеты поддержки"),
|
||||||
@@ -434,6 +472,55 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
|||||||
"ID треда поддержки",
|
"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(
|
SettingField(
|
||||||
"SUPPORT_TICKETS_ENABLED",
|
"SUPPORT_TICKETS_ENABLED",
|
||||||
"bool",
|
"bool",
|
||||||
@@ -561,6 +648,8 @@ def coerce_value(field: SettingField, raw: Any) -> Any:
|
|||||||
return text
|
return text
|
||||||
|
|
||||||
if raw is None or (isinstance(raw, str) and raw.strip() == ""):
|
if raw is None or (isinstance(raw, str) and raw.strip() == ""):
|
||||||
|
if not field.optional:
|
||||||
|
raise ValueError(f"{field.key}: value required")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if field.type == "bool":
|
if field.type == "bool":
|
||||||
@@ -623,13 +712,15 @@ def manifest_payload() -> List[dict]:
|
|||||||
sections_order = {
|
sections_order = {
|
||||||
"general": 1,
|
"general": 1,
|
||||||
"appearance": 2,
|
"appearance": 2,
|
||||||
|
"remnawave": 3,
|
||||||
"pricing": 11,
|
"pricing": 11,
|
||||||
"payments": 4,
|
"payments": 4,
|
||||||
"trial": 5,
|
"trial": 5,
|
||||||
"referral": 6,
|
"referral": 6,
|
||||||
"notifications": 7,
|
"notifications": 7,
|
||||||
"support": 8,
|
"support": 8,
|
||||||
"devices": 9,
|
"backups": 9,
|
||||||
|
"devices": 10,
|
||||||
"subscription_guides": 10,
|
"subscription_guides": 10,
|
||||||
}
|
}
|
||||||
exclusive_map = {
|
exclusive_map = {
|
||||||
@@ -678,12 +769,25 @@ def manifest_payload() -> List[dict]:
|
|||||||
"optional": field.optional,
|
"optional": field.optional,
|
||||||
"secret": field.secret,
|
"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:
|
if field.key in exclusive_map:
|
||||||
item["mutually_exclusive_key"] = exclusive_map[field.key]
|
item["mutually_exclusive_key"] = exclusive_map[field.key]
|
||||||
if default_value is not None:
|
if default_value is not None:
|
||||||
item["default"] = default_value
|
item["default"] = default_value
|
||||||
if webhook_metadata:
|
if webhook_metadata:
|
||||||
item.update(webhook_metadata)
|
item.update(webhook_metadata)
|
||||||
|
if field.webhook_path:
|
||||||
|
item["webhook_path"] = field.webhook_path
|
||||||
|
item["webhook_requires_base_url"] = field.webhook_requires_base_url
|
||||||
|
if field.webhook_provider_id:
|
||||||
|
item["provider_id"] = field.webhook_provider_id
|
||||||
|
if field.webhook_hint_i18n_key:
|
||||||
|
item["webhook_hint_i18n_key"] = field.webhook_hint_i18n_key
|
||||||
|
if field.webhook_hint:
|
||||||
|
item["webhook_hint"] = field.webhook_hint
|
||||||
if field.choices:
|
if field.choices:
|
||||||
item["choices"] = [
|
item["choices"] = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from bot.app.web.webapp import (
|
|||||||
routes as _routes,
|
routes as _routes,
|
||||||
serializers as _serializers,
|
serializers as _serializers,
|
||||||
support as _support,
|
support as _support,
|
||||||
|
telegram_notifications as _telegram_notifications,
|
||||||
)
|
)
|
||||||
|
|
||||||
_MODULES = (
|
_MODULES = (
|
||||||
@@ -25,6 +26,7 @@ _MODULES = (
|
|||||||
_assets,
|
_assets,
|
||||||
_auth,
|
_auth,
|
||||||
_account,
|
_account,
|
||||||
|
_telegram_notifications,
|
||||||
_serializers,
|
_serializers,
|
||||||
_billing,
|
_billing,
|
||||||
_devices,
|
_devices,
|
||||||
|
|||||||
|
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 |
@@ -71,6 +71,12 @@ WEBAPP_UPLOADED_LOGO_DIR = WEBAPP_LOGO_CACHE_DIR / "uploads"
|
|||||||
WEBAPP_UPLOADED_LOGO_PATH = "/webapp-uploaded-logo"
|
WEBAPP_UPLOADED_LOGO_PATH = "/webapp-uploaded-logo"
|
||||||
WEBAPP_FAVICON_DIR = WEBAPP_LOGO_CACHE_DIR / "favicons"
|
WEBAPP_FAVICON_DIR = WEBAPP_LOGO_CACHE_DIR / "favicons"
|
||||||
WEBAPP_FAVICON_PATH = "/webapp-favicon"
|
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_EMOJI_CACHE_DIR = APP_ROOT / "data" / "webapp-emoji"
|
||||||
WEBAPP_CONFIG_PLACEHOLDER = "<!-- WEBAPP_CONFIG_SCRIPT -->"
|
WEBAPP_CONFIG_PLACEHOLDER = "<!-- WEBAPP_CONFIG_SCRIPT -->"
|
||||||
WEBAPP_I18N_PLACEHOLDER = "<!-- WEBAPP_I18N_SCRIPT -->"
|
WEBAPP_I18N_PLACEHOLDER = "<!-- WEBAPP_I18N_SCRIPT -->"
|
||||||
@@ -101,6 +107,33 @@ WEBAPP_CSRF_COOKIE_NAME = "rw_webapp_csrf"
|
|||||||
WEBAPP_TELEGRAM_OAUTH_STATE_COOKIE_NAME = "rw_tg_oauth_state"
|
WEBAPP_TELEGRAM_OAUTH_STATE_COOKIE_NAME = "rw_tg_oauth_state"
|
||||||
WEBAPP_CSRF_HEADER_NAME = "X-CSRF-Token"
|
WEBAPP_CSRF_HEADER_NAME = "X-CSRF-Token"
|
||||||
WEBAPP_STATE_CHANGING_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
|
WEBAPP_STATE_CHANGING_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
|
||||||
|
ROBOTS_TX = """User-agent: *
|
||||||
|
Disallow: /
|
||||||
|
|
||||||
|
User-agent: GPTBot
|
||||||
|
Disallow: /
|
||||||
|
|
||||||
|
User-agent: ChatGPT-User
|
||||||
|
Disallow: /
|
||||||
|
|
||||||
|
User-agent: OAI-SearchBot
|
||||||
|
Disallow: /
|
||||||
|
|
||||||
|
User-agent: Google-Extended
|
||||||
|
Disallow: /
|
||||||
|
|
||||||
|
User-agent: ClaudeBot
|
||||||
|
Disallow: /
|
||||||
|
|
||||||
|
User-agent: anthropic-ai
|
||||||
|
Disallow: /
|
||||||
|
|
||||||
|
User-agent: PerplexityBot
|
||||||
|
Disallow: /
|
||||||
|
|
||||||
|
User-agent: Applebot-Extended
|
||||||
|
Disallow: /
|
||||||
|
"""
|
||||||
_APP_VERSION_CACHE: Optional[str] = None
|
_APP_VERSION_CACHE: Optional[str] = None
|
||||||
WEBAPP_CSRF_EXEMPT_PATHS = {
|
WEBAPP_CSRF_EXEMPT_PATHS = {
|
||||||
"/api/auth/telegram/nonce",
|
"/api/auth/telegram/nonce",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from .auth import (
|
|||||||
_sync_merged_panel_identity_for_user,
|
_sync_merged_panel_identity_for_user,
|
||||||
)
|
)
|
||||||
from .common import _invalidate_webapp_user_caches
|
from .common import _invalidate_webapp_user_caches
|
||||||
|
from .telegram_notifications import _probe_telegram_notifications_for_user_id
|
||||||
|
|
||||||
|
|
||||||
async def account_email_request_route(request: web.Request) -> web.Response:
|
async def account_email_request_route(request: web.Request) -> web.Response:
|
||||||
@@ -416,6 +417,8 @@ async def account_telegram_link_route(request: web.Request) -> web.Response:
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Failed to send account Telegram linked notification")
|
logger.exception("Failed to send account Telegram linked notification")
|
||||||
|
|
||||||
|
await _probe_telegram_notifications_for_user_id(request, int(final_user_id))
|
||||||
|
|
||||||
token = create_webapp_session_token(settings, int(final_user_id))
|
token = create_webapp_session_token(settings, int(final_user_id))
|
||||||
response_payload: Dict[str, Any] = {
|
response_payload: Dict[str, Any] = {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
@@ -482,6 +485,7 @@ async def account_avatar_route(request: web.Request) -> web.Response:
|
|||||||
|
|
||||||
async def account_language_route(request: web.Request) -> web.Response:
|
async def account_language_route(request: web.Request) -> web.Response:
|
||||||
user_id = _require_user_id(request)
|
user_id = _require_user_id(request)
|
||||||
|
settings: Settings = request.app["settings"]
|
||||||
payload = await _read_json(request)
|
payload = await _read_json(request)
|
||||||
language_payload, validation_error = _validate_model_payload(WebAppLanguagePayload, payload)
|
language_payload, validation_error = _validate_model_payload(WebAppLanguagePayload, payload)
|
||||||
if validation_error:
|
if validation_error:
|
||||||
|
|||||||
@@ -23,6 +23,12 @@ async def health_route(request: web.Request) -> web.Response:
|
|||||||
return web.json_response({"ok": True})
|
return web.json_response({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
|
async def robots_txt_route(request: web.Request) -> web.Response:
|
||||||
|
response = web.Response(text=ROBOTS_TX, content_type="text/plain")
|
||||||
|
response.headers["Cache-Control"] = "public, max-age=3600"
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
async def css_asset_route(request: web.Request) -> web.Response:
|
async def css_asset_route(request: web.Request) -> web.Response:
|
||||||
return await _css_asset_route(request, base_name="subscription_webapp")
|
return await _css_asset_route(request, base_name="subscription_webapp")
|
||||||
|
|
||||||
@@ -205,7 +211,7 @@ def _resolve_webapp_logo_url(settings: Settings) -> str:
|
|||||||
|
|
||||||
raw_logo_url = (getattr(settings, "WEBAPP_LOGO_URL", None) or "").strip()
|
raw_logo_url = (getattr(settings, "WEBAPP_LOGO_URL", None) or "").strip()
|
||||||
if not raw_logo_url:
|
if not raw_logo_url:
|
||||||
return ""
|
return WEBAPP_DEFAULT_LOGO_PATH
|
||||||
|
|
||||||
parsed_logo_url = urlsplit(raw_logo_url)
|
parsed_logo_url = urlsplit(raw_logo_url)
|
||||||
if parsed_logo_url.scheme == "https":
|
if parsed_logo_url.scheme == "https":
|
||||||
@@ -215,7 +221,7 @@ def _resolve_webapp_logo_url(settings: Settings) -> str:
|
|||||||
return raw_logo_url
|
return raw_logo_url
|
||||||
if raw_logo_url.startswith("/"):
|
if raw_logo_url.startswith("/"):
|
||||||
return raw_logo_url
|
return raw_logo_url
|
||||||
return ""
|
return WEBAPP_DEFAULT_LOGO_PATH
|
||||||
|
|
||||||
|
|
||||||
def _resolve_webapp_favicon_url(settings: Settings, logo_url: str = "") -> str:
|
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)
|
resolved = _resolve_webapp_asset_url(raw_logo_favicon_url)
|
||||||
if resolved:
|
if resolved:
|
||||||
return 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:
|
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)
|
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:
|
async def webapp_favicon_route(request: web.Request) -> web.Response:
|
||||||
settings: Settings = request.app["settings"]
|
settings: Settings = request.app["settings"]
|
||||||
if not settings.WEBAPP_ENABLED:
|
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")
|
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()
|
root = WEBAPP_FAVICON_DIR.expanduser().resolve()
|
||||||
path = (root / digest / filename).resolve()
|
path = (root / digest / filename).resolve()
|
||||||
try:
|
try:
|
||||||
@@ -477,6 +498,29 @@ def _webapp_favicon_file_response(digest: str, filename: str) -> web.Response:
|
|||||||
return 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:
|
async def webapp_animated_emoji_route(request: web.Request) -> web.Response:
|
||||||
codepoints = str(request.match_info.get("codepoints") or "").strip().lower()
|
codepoints = str(request.match_info.get("codepoints") or "").strip().lower()
|
||||||
ext = str(request.match_info.get("ext") 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("Referrer-Policy", "no-referrer")
|
||||||
response.headers.setdefault("X-Content-Type-Options", "nosniff")
|
response.headers.setdefault("X-Content-Type-Options", "nosniff")
|
||||||
|
response.headers.setdefault("X-Robots-Tag", "noindex, nofollow, noarchive")
|
||||||
response.headers.setdefault(
|
response.headers.setdefault(
|
||||||
"Permissions-Policy",
|
"Permissions-Policy",
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
# ruff: noqa: F401,F403,F405,I001
|
# ruff: noqa: F401,F403,F405,I001
|
||||||
from ._runtime import * # noqa: F403,F405
|
from ._runtime import * # noqa: F403,F405
|
||||||
from .common import _invalidate_webapp_user_caches
|
from .common import _invalidate_webapp_user_caches
|
||||||
|
from .telegram_notifications import _probe_telegram_notifications_for_user_id
|
||||||
|
|
||||||
|
|
||||||
def _resolve_telegram_bot_id(bot_token: str) -> Optional[int]:
|
def _resolve_telegram_bot_id(bot_token: str) -> Optional[int]:
|
||||||
@@ -436,6 +437,9 @@ async def telegram_oauth_callback_route(request: web.Request) -> web.Response:
|
|||||||
first_name=linked_user_for_panel.first_name,
|
first_name=linked_user_for_panel.first_name,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if final_user_id:
|
||||||
|
await _probe_telegram_notifications_for_user_id(request, int(final_user_id))
|
||||||
|
|
||||||
token = create_webapp_session_token(settings, int(final_user_id))
|
token = create_webapp_session_token(settings, int(final_user_id))
|
||||||
response = web.HTTPFound(_telegram_oauth_redirect_url(redirect_path, status="success"))
|
response = web.HTTPFound(_telegram_oauth_redirect_url(redirect_path, status="success"))
|
||||||
_clear_telegram_oauth_state_cookie(response)
|
_clear_telegram_oauth_state_cookie(response)
|
||||||
@@ -531,6 +535,7 @@ async def auth_token_route(request: web.Request) -> web.Response:
|
|||||||
return _json_error(500, "auth_failed", "Auth failed")
|
return _json_error(500, "auth_failed", "Auth failed")
|
||||||
|
|
||||||
await _invalidate_webapp_user_caches(settings, authenticated_user_id, include_devices=True)
|
await _invalidate_webapp_user_caches(settings, authenticated_user_id, include_devices=True)
|
||||||
|
await _probe_telegram_notifications_for_user_id(request, int(authenticated_user_id))
|
||||||
token = create_webapp_session_token(settings, int(authenticated_user_id))
|
token = create_webapp_session_token(settings, int(authenticated_user_id))
|
||||||
return _build_webapp_auth_response(settings, {"ok": True}, token=token)
|
return _build_webapp_auth_response(settings, {"ok": True}, token=token)
|
||||||
|
|
||||||
@@ -1203,7 +1208,7 @@ def _apply_telegram_profile_to_user(
|
|||||||
settings: Settings,
|
settings: Settings,
|
||||||
) -> None:
|
) -> None:
|
||||||
language_code = _normalize_language(
|
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"])
|
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:
|
if not existing_telegram_user and int(current_user.user_id) < 0:
|
||||||
language_code = _normalize_language(
|
language_code = _normalize_language(
|
||||||
telegram_user.get("language_code")
|
current_user.language_code
|
||||||
or current_user.language_code
|
or telegram_user.get("language_code")
|
||||||
or settings.DEFAULT_LANGUAGE
|
or settings.DEFAULT_LANGUAGE
|
||||||
)
|
)
|
||||||
target_user, _ = await user_dal.create_user(
|
target_user, _ = await user_dal.create_user(
|
||||||
@@ -1400,20 +1405,19 @@ async def _ensure_user_from_telegram(
|
|||||||
referral_param: Optional[str] = None,
|
referral_param: Optional[str] = None,
|
||||||
) -> User:
|
) -> User:
|
||||||
user_id = int(telegram_user["id"])
|
user_id = int(telegram_user["id"])
|
||||||
language_code = _normalize_language(
|
telegram_language_code = _normalize_language(
|
||||||
telegram_user.get("language_code") or settings.DEFAULT_LANGUAGE
|
telegram_user.get("language_code") or settings.DEFAULT_LANGUAGE
|
||||||
)
|
)
|
||||||
|
|
||||||
update_data = {
|
profile_data = {
|
||||||
"telegram_id": user_id,
|
"telegram_id": user_id,
|
||||||
"username": sanitize_username(telegram_user.get("username")),
|
"username": sanitize_username(telegram_user.get("username")),
|
||||||
"first_name": sanitize_display_name(telegram_user.get("first_name")),
|
"first_name": sanitize_display_name(telegram_user.get("first_name")),
|
||||||
"last_name": sanitize_display_name(telegram_user.get("last_name")),
|
"last_name": sanitize_display_name(telegram_user.get("last_name")),
|
||||||
"language_code": language_code,
|
|
||||||
}
|
}
|
||||||
telegram_photo_url = _telegram_photo_url_value(telegram_user)
|
telegram_photo_url = _telegram_photo_url_value(telegram_user)
|
||||||
if telegram_photo_url:
|
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)
|
db_user = await user_dal.get_user_by_telegram_id(session, user_id)
|
||||||
if not db_user:
|
if not db_user:
|
||||||
@@ -1428,7 +1432,8 @@ async def _ensure_user_from_telegram(
|
|||||||
session,
|
session,
|
||||||
{
|
{
|
||||||
"user_id": user_id,
|
"user_id": user_id,
|
||||||
**update_data,
|
**profile_data,
|
||||||
|
"language_code": telegram_language_code,
|
||||||
"referred_by_id": referred_by_id,
|
"referred_by_id": referred_by_id,
|
||||||
"registration_date": datetime.now(timezone.utc),
|
"registration_date": datetime.now(timezone.utc),
|
||||||
},
|
},
|
||||||
@@ -1436,6 +1441,10 @@ async def _ensure_user_from_telegram(
|
|||||||
setattr(db_user, "_webapp_created", bool(created))
|
setattr(db_user, "_webapp_created", bool(created))
|
||||||
return db_user
|
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}
|
changed = {key: value for key, value in update_data.items() if getattr(db_user, key) != value}
|
||||||
if changed:
|
if changed:
|
||||||
db_user = await user_dal.update_user(session, db_user.user_id, changed) or db_user
|
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
|
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:
|
async def apply_promo_route(request: web.Request) -> web.Response:
|
||||||
user_id = _require_user_id(request)
|
user_id = _require_user_id(request)
|
||||||
payload = await _read_json(request)
|
payload = await _read_json(request)
|
||||||
@@ -64,6 +100,7 @@ async def create_payment_route(request: web.Request) -> web.Response:
|
|||||||
return validation_error
|
return validation_error
|
||||||
method = str(payment_payload.method or "").strip().lower()
|
method = str(payment_payload.method or "").strip().lower()
|
||||||
settings: Settings = request.app["settings"]
|
settings: Settings = request.app["settings"]
|
||||||
|
subscription_service: SubscriptionService = request.app["subscription_service"]
|
||||||
cached = _get_cached_webapp_settings(request)
|
cached = _get_cached_webapp_settings(request)
|
||||||
tariffs_config = settings.tariffs_config
|
tariffs_config = settings.tariffs_config
|
||||||
traffic_mode = bool(settings.traffic_sale_mode)
|
traffic_mode = bool(settings.traffic_sale_mode)
|
||||||
@@ -86,15 +123,12 @@ async def create_payment_route(request: web.Request) -> web.Response:
|
|||||||
return _json_error(400, "invalid_plan", "Tariff is not available")
|
return _json_error(400, "invalid_plan", "Tariff is not available")
|
||||||
if tariff.billing_model != "period":
|
if tariff.billing_model != "period":
|
||||||
return _json_error(400, "invalid_plan", "Device top-up is not available")
|
return _json_error(400, "invalid_plan", "Device top-up is not available")
|
||||||
try:
|
device_count = _parse_positive_int_units(
|
||||||
device_count = int(
|
|
||||||
float(
|
|
||||||
payment_payload.device_count
|
payment_payload.device_count
|
||||||
if payment_payload.device_count is not None
|
if payment_payload.device_count is not None
|
||||||
else payment_payload.months
|
else payment_payload.months
|
||||||
)
|
)
|
||||||
)
|
if device_count is None:
|
||||||
except (TypeError, ValueError):
|
|
||||||
return _json_error(400, "invalid_plan", "Invalid device package")
|
return _json_error(400, "invalid_plan", "Invalid device package")
|
||||||
if not tariff.hwid_device_packages:
|
if not tariff.hwid_device_packages:
|
||||||
return _json_error(400, "invalid_plan", "Device package is not available")
|
return _json_error(400, "invalid_plan", "Device package is not available")
|
||||||
@@ -613,12 +647,14 @@ async def device_topup_options_route(request: web.Request) -> web.Response:
|
|||||||
)
|
)
|
||||||
tariff = config.require(sub.tariff_key)
|
tariff = config.require(sub.tariff_key)
|
||||||
if tariff.billing_model != "period":
|
if tariff.billing_model != "period":
|
||||||
return _json_error(
|
return _json_error(400, "device_topup_unavailable", "Device top-up is not available")
|
||||||
400, "device_topup_unavailable", "Device top-up is not available"
|
|
||||||
)
|
|
||||||
lang = db_user.language_code or settings.DEFAULT_LANGUAGE
|
lang = db_user.language_code or settings.DEFAULT_LANGUAGE
|
||||||
active = await subscription_service.get_active_subscription_details(session, user_id)
|
active = await subscription_service.get_active_subscription_details(session, user_id)
|
||||||
renewal_available = bool(active and active.get("device_topup_renewal_available"))
|
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
|
packages = tariff.hwid_device_packages
|
||||||
rub_counts = {int(package.count) for package in (packages.rub if packages else [])}
|
rub_counts = {int(package.count) for package in (packages.rub if packages else [])}
|
||||||
stars_counts = {int(package.count) for package in (packages.stars if packages else [])}
|
stars_counts = {int(package.count) for package in (packages.stars if packages else [])}
|
||||||
@@ -663,16 +699,8 @@ async def device_topup_options_route(request: web.Request) -> web.Response:
|
|||||||
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
|
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
|
||||||
"title": f"+{count}",
|
"title": f"+{count}",
|
||||||
"subtitle": tariff.name(lang),
|
"subtitle": tariff.name(lang),
|
||||||
"valid_from": (
|
"valid_from": _billing_iso_datetime((rub_quote or stars_quote).get("valid_from")),
|
||||||
(rub_quote or stars_quote)["valid_from"].isoformat()
|
"valid_until": _billing_iso_datetime((rub_quote or stars_quote).get("valid_until")),
|
||||||
if (rub_quote or stars_quote).get("valid_from")
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
"valid_until": (
|
|
||||||
(rub_quote or stars_quote)["valid_until"].isoformat()
|
|
||||||
if (rub_quote or stars_quote).get("valid_until")
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
"proration_ratio": float((rub_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:
|
if stars_quote and int(stars_quote.get("price") or 0) > 0:
|
||||||
@@ -687,14 +715,8 @@ async def device_topup_options_route(request: web.Request) -> web.Response:
|
|||||||
"extra_hwid_devices": int(active.get("extra_hwid_devices") or 0)
|
"extra_hwid_devices": int(active.get("extra_hwid_devices") or 0)
|
||||||
if active
|
if active
|
||||||
else int(sub.extra_hwid_devices or 0),
|
else int(sub.extra_hwid_devices or 0),
|
||||||
"extra_hwid_devices_valid_until": active.get("extra_hwid_devices_valid_until")
|
"extra_hwid_devices_valid_until": _billing_iso_datetime(extra_hwid_valid_until),
|
||||||
if active
|
"extra_hwid_devices_valid_until_text": extra_hwid_valid_until_text,
|
||||||
else None,
|
|
||||||
"extra_hwid_devices_valid_until_text": active.get(
|
|
||||||
"extra_hwid_devices_valid_until_text"
|
|
||||||
)
|
|
||||||
if active
|
|
||||||
else None,
|
|
||||||
"renewal_available": renewal_available,
|
"renewal_available": renewal_available,
|
||||||
"renewal_recommended_count": int(active.get("extra_hwid_devices") or 0)
|
"renewal_recommended_count": int(active.get("extra_hwid_devices") or 0)
|
||||||
if active and renewal_available
|
if active and renewal_available
|
||||||
@@ -953,9 +975,7 @@ async def _create_subscription_payment(
|
|||||||
hwid_pricing_period_months=hwid_quote.get("pricing_period_months")
|
hwid_pricing_period_months=hwid_quote.get("pricing_period_months")
|
||||||
if hwid_quote
|
if hwid_quote
|
||||||
else None,
|
else None,
|
||||||
hwid_proration_ratio=hwid_quote.get("proration_ratio")
|
hwid_proration_ratio=hwid_quote.get("proration_ratio") if hwid_quote else None,
|
||||||
if hwid_quote
|
|
||||||
else None,
|
|
||||||
hwid_full_price=hwid_quote.get("full_price") 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",
|
"devices",
|
||||||
user_id,
|
user_id,
|
||||||
int(getattr(settings, "WEBAPP_DEVICES_CACHE_TTL_SECONDS", 5) or 0),
|
int(getattr(settings, "WEBAPP_DEVICES_CACHE_TTL_SECONDS", 5) or 0),
|
||||||
lambda: _load_devices_payload(subscription_service, session, user_id),
|
lambda: _load_devices_payload(
|
||||||
|
subscription_service,
|
||||||
|
session,
|
||||||
|
user_id,
|
||||||
|
fallback_panel_user_uuid=str(getattr(db_user, "panel_user_uuid", "") or "").strip()
|
||||||
|
or None,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
if isinstance(result, dict) and result.get("ok") is True:
|
if isinstance(result, dict) and result.get("ok") is True:
|
||||||
return web.json_response({"ok": True, **(result.get("payload") or {})})
|
return web.json_response({"ok": True, **(result.get("payload") or {})})
|
||||||
@@ -45,16 +51,12 @@ async def _load_devices_payload(
|
|||||||
subscription_service: SubscriptionService,
|
subscription_service: SubscriptionService,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
user_id: int,
|
user_id: int,
|
||||||
|
fallback_panel_user_uuid: Optional[str] = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
active = await subscription_service.get_active_subscription_details(session, user_id)
|
active = await subscription_service.get_active_subscription_details(session, user_id)
|
||||||
panel_user_uuid = active.get("user_id") if active else None
|
panel_user_uuid = str((active or {}).get("user_id") or fallback_panel_user_uuid or "").strip()
|
||||||
if not panel_user_uuid:
|
if not panel_user_uuid:
|
||||||
return {
|
return _empty_inactive_devices_payload()
|
||||||
"ok": False,
|
|
||||||
"status": 400,
|
|
||||||
"error": "subscription_not_active",
|
|
||||||
"message": "Subscription is not active",
|
|
||||||
}
|
|
||||||
|
|
||||||
panel_service = getattr(subscription_service, "panel_service", None)
|
panel_service = getattr(subscription_service, "panel_service", None)
|
||||||
if not panel_service:
|
if not panel_service:
|
||||||
@@ -82,6 +84,7 @@ async def _load_devices_payload(
|
|||||||
"ok": True,
|
"ok": True,
|
||||||
"payload": {
|
"payload": {
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
|
"subscription_active": _devices_subscription_is_active(active),
|
||||||
"current_devices": len(devices),
|
"current_devices": len(devices),
|
||||||
"max_devices": max_devices,
|
"max_devices": max_devices,
|
||||||
"max_devices_label": _format_devices_limit(max_devices),
|
"max_devices_label": _format_devices_limit(max_devices),
|
||||||
@@ -92,6 +95,31 @@ async def _load_devices_payload(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _empty_inactive_devices_payload() -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"payload": {
|
||||||
|
"enabled": True,
|
||||||
|
"subscription_active": False,
|
||||||
|
"current_devices": 0,
|
||||||
|
"max_devices": None,
|
||||||
|
"max_devices_label": _format_devices_limit(None),
|
||||||
|
"devices": [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _devices_subscription_is_active(active: Optional[Dict[str, Any]]) -> bool:
|
||||||
|
if not active:
|
||||||
|
return False
|
||||||
|
end_date = active.get("end_date")
|
||||||
|
if not isinstance(end_date, datetime):
|
||||||
|
return False
|
||||||
|
if end_date.tzinfo is None:
|
||||||
|
end_date = end_date.replace(tzinfo=timezone.utc)
|
||||||
|
return end_date > datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
async def disconnect_device_route(request: web.Request) -> web.Response:
|
async def disconnect_device_route(request: web.Request) -> web.Response:
|
||||||
user_id = _require_user_id(request)
|
user_id = _require_user_id(request)
|
||||||
rate_limit_response = await _enforce_webapp_rate_limit(
|
rate_limit_response = await _enforce_webapp_rate_limit(
|
||||||
@@ -193,6 +221,15 @@ def _format_device_datetime(value: Any) -> str:
|
|||||||
return text
|
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]:
|
def _serialize_device(device: Dict[str, Any], index: int) -> Dict[str, Any]:
|
||||||
hwid = str(device.get("hwid") or "").strip()
|
hwid = str(device.get("hwid") or "").strip()
|
||||||
model = str(device.get("deviceModel") 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,
|
"os_version": os_version,
|
||||||
"platform_label": platform_label,
|
"platform_label": platform_label,
|
||||||
"user_agent": user_agent,
|
"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")),
|
"created_at_text": _format_device_datetime(device.get("createdAt")),
|
||||||
"hwid_short": _shorten_hwid_for_display(hwid),
|
"hwid_short": _shorten_hwid_for_display(hwid),
|
||||||
"token": _device_hwid_token(hwid) if hwid else "",
|
"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:
|
def setup_subscription_webapp_routes(app: web.Application) -> None:
|
||||||
|
app.router.add_get("/robots.txt", robots_txt_route)
|
||||||
app.router.add_get("/", index_route)
|
app.router.add_get("/", index_route)
|
||||||
app.router.add_get("/login/password", index_route)
|
app.router.add_get("/login/password", index_route)
|
||||||
app.router.add_get("/home", index_route)
|
app.router.add_get("/home", index_route)
|
||||||
@@ -19,7 +20,7 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
|
|||||||
app.router.add_get(
|
app.router.add_get(
|
||||||
(
|
(
|
||||||
"/admin/{section:stats|users|payments|promos|ads|broadcast|logs|tariffs|"
|
"/admin/{section:stats|users|payments|promos|ads|broadcast|logs|tariffs|"
|
||||||
"appearance|settings|translations|support}"
|
"appearance|settings|translations|support|backups}"
|
||||||
),
|
),
|
||||||
index_route,
|
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("/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-192.png", webapp_current_favicon_route)
|
||||||
app.router.add_get("/icon-512.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(WEBAPP_LOGO_PROXY_PATH, webapp_logo_route)
|
||||||
app.router.add_get(
|
app.router.add_get(
|
||||||
rf"{WEBAPP_UPLOADED_LOGO_PATH}/{{filename:[A-Za-z0-9_.-]+}}",
|
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/request", account_password_request_route)
|
||||||
app.router.add_post("/api/account/password/confirm", account_password_confirm_route)
|
app.router.add_post("/api/account/password/confirm", account_password_confirm_route)
|
||||||
app.router.add_post("/api/account/telegram/link", account_telegram_link_route)
|
app.router.add_post("/api/account/telegram/link", account_telegram_link_route)
|
||||||
|
app.router.add_post(
|
||||||
|
"/api/account/telegram/notifications/probe",
|
||||||
|
account_telegram_notifications_probe_route,
|
||||||
|
)
|
||||||
app.router.add_post("/api/promo/apply", apply_promo_route)
|
app.router.add_post("/api/promo/apply", apply_promo_route)
|
||||||
app.router.add_post("/api/trial/activate", activate_trial_route)
|
app.router.add_post("/api/trial/activate", activate_trial_route)
|
||||||
app.router.add_get("/api/devices", devices_route)
|
app.router.add_get("/api/devices", devices_route)
|
||||||
|
|||||||
@@ -3,6 +3,12 @@ from ._runtime import * # noqa: F403,F405
|
|||||||
|
|
||||||
from config.subscription_guides_config import subscription_guides_available
|
from config.subscription_guides_config import subscription_guides_available
|
||||||
from config.webapp_themes_config import public_themes_catalog_payload
|
from config.webapp_themes_config import public_themes_catalog_payload
|
||||||
|
from bot.services.telegram_notifications import (
|
||||||
|
TELEGRAM_NOTIFICATIONS_ENABLED,
|
||||||
|
normalize_telegram_notification_status,
|
||||||
|
telegram_notifications_need_prompt,
|
||||||
|
telegram_notifications_start_link,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, Any]:
|
async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, Any]:
|
||||||
@@ -72,6 +78,12 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
|
|||||||
lang = _normalize_language(db_user.language_code or settings.DEFAULT_LANGUAGE)
|
lang = _normalize_language(db_user.language_code or settings.DEFAULT_LANGUAGE)
|
||||||
admin_ids = {int(x) for x in (settings.ADMIN_IDS or [])}
|
admin_ids = {int(x) for x in (settings.ADMIN_IDS or [])}
|
||||||
is_admin = bool(db_user.telegram_id and int(db_user.telegram_id) in admin_ids)
|
is_admin = bool(db_user.telegram_id and int(db_user.telegram_id) in admin_ids)
|
||||||
|
telegram_notifications_status = normalize_telegram_notification_status(
|
||||||
|
getattr(db_user, "telegram_notifications_status", None)
|
||||||
|
)
|
||||||
|
telegram_notifications_link = telegram_notifications_start_link(
|
||||||
|
request.app.get("bot_username") or ""
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"user": {
|
"user": {
|
||||||
"id": user_id,
|
"id": user_id,
|
||||||
@@ -83,6 +95,12 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
|
|||||||
),
|
),
|
||||||
"telegram_id": db_user.telegram_id,
|
"telegram_id": db_user.telegram_id,
|
||||||
"telegram_linked": bool(_telegram_id_for_user(db_user)),
|
"telegram_linked": bool(_telegram_id_for_user(db_user)),
|
||||||
|
"telegram_notifications_status": telegram_notifications_status,
|
||||||
|
"telegram_notifications_enabled": (
|
||||||
|
telegram_notifications_status == TELEGRAM_NOTIFICATIONS_ENABLED
|
||||||
|
),
|
||||||
|
"telegram_notifications_need_prompt": telegram_notifications_need_prompt(db_user),
|
||||||
|
"telegram_notifications_start_link": telegram_notifications_link,
|
||||||
"telegram_photo_url": _telegram_avatar_url(avatar),
|
"telegram_photo_url": _telegram_avatar_url(avatar),
|
||||||
"first_name": db_user.first_name,
|
"first_name": db_user.first_name,
|
||||||
"language_code": lang,
|
"language_code": lang,
|
||||||
@@ -156,12 +174,79 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _serialize_referral_bonus_details(settings: Settings, lang: str) -> List[Dict[str, Any]]:
|
def _legacy_referral_bonus_periods(settings: Settings) -> List[int]:
|
||||||
if getattr(settings, "traffic_sale_mode", False):
|
if getattr(settings, "traffic_sale_mode", False):
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
return sorted(int(months) for months in settings.subscription_options)
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_tariff_period_referral_bonus_details(tariff: Any, lang: str) -> List[Dict[str, Any]]:
|
||||||
details: List[Dict[str, Any]] = []
|
details: List[Dict[str, Any]] = []
|
||||||
for months, _price in sorted(settings.subscription_options.items()):
|
for months in sorted(int(month) for month in tariff.enabled_periods):
|
||||||
|
inviter_days = tariff.referral_inviter_bonus_days(months)
|
||||||
|
friend_days = tariff.referral_referee_bonus_days(months)
|
||||||
|
if inviter_days is None and friend_days is None:
|
||||||
|
continue
|
||||||
|
details.append(
|
||||||
|
{
|
||||||
|
"id": f"{tariff.key}:{months}",
|
||||||
|
"tariff_key": tariff.key,
|
||||||
|
"tariff_name": tariff.name(lang),
|
||||||
|
"months": int(months),
|
||||||
|
"title": _format_months_title(int(months), lang),
|
||||||
|
"inviter_days": int(inviter_days or 0),
|
||||||
|
"friend_days": int(friend_days or 0),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return details
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_tariff_referral_bonus_details(settings: Settings, lang: str) -> List[Dict[str, Any]]:
|
||||||
|
tariffs_config = settings.tariffs_config
|
||||||
|
if not tariffs_config:
|
||||||
|
return []
|
||||||
|
|
||||||
|
period_tariffs = [
|
||||||
|
tariff for tariff in tariffs_config.enabled_tariffs if tariff.billing_model == "period"
|
||||||
|
]
|
||||||
|
if len(period_tariffs) <= 1:
|
||||||
|
return (
|
||||||
|
_serialize_tariff_period_referral_bonus_details(period_tariffs[0], lang)
|
||||||
|
if period_tariffs
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
|
||||||
|
summaries: List[Dict[str, Any]] = []
|
||||||
|
for tariff in period_tariffs:
|
||||||
|
details = _serialize_tariff_period_referral_bonus_details(tariff, lang)
|
||||||
|
if not details:
|
||||||
|
continue
|
||||||
|
inviter_values = [int(item["inviter_days"]) for item in details]
|
||||||
|
friend_values = [int(item["friend_days"]) for item in details]
|
||||||
|
summaries.append(
|
||||||
|
{
|
||||||
|
"id": f"tariff:{tariff.key}",
|
||||||
|
"type": "tariff_summary",
|
||||||
|
"tariff_key": tariff.key,
|
||||||
|
"tariff_name": tariff.name(lang),
|
||||||
|
"title": tariff.name(lang),
|
||||||
|
"inviter_min_days": min(inviter_values),
|
||||||
|
"inviter_max_days": max(inviter_values),
|
||||||
|
"friend_min_days": min(friend_values),
|
||||||
|
"friend_max_days": max(friend_values),
|
||||||
|
"details": details,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return summaries
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_referral_bonus_details(settings: Settings, lang: str) -> List[Dict[str, Any]]:
|
||||||
|
if settings.tariffs_config:
|
||||||
|
return _serialize_tariff_referral_bonus_details(settings, lang)
|
||||||
|
|
||||||
|
details: List[Dict[str, Any]] = []
|
||||||
|
for months in _legacy_referral_bonus_periods(settings):
|
||||||
inviter_days = settings.referral_bonus_inviter.get(months)
|
inviter_days = settings.referral_bonus_inviter.get(months)
|
||||||
friend_days = settings.referral_bonus_referee.get(months)
|
friend_days = settings.referral_bonus_referee.get(months)
|
||||||
if inviter_days is None and friend_days is None:
|
if inviter_days is None and friend_days is None:
|
||||||
@@ -391,6 +476,7 @@ def _serialize_plans(
|
|||||||
for tariff in tariffs_config.enabled_tariffs:
|
for tariff in tariffs_config.enabled_tariffs:
|
||||||
common = {
|
common = {
|
||||||
"tariff_key": tariff.key,
|
"tariff_key": tariff.key,
|
||||||
|
"is_default_tariff": tariff.key == tariffs_config.default_tariff,
|
||||||
"tariff_name": tariff.name(lang),
|
"tariff_name": tariff.name(lang),
|
||||||
"billing_model": tariff.billing_model,
|
"billing_model": tariff.billing_model,
|
||||||
"description": tariff.description(lang),
|
"description": tariff.description(lang),
|
||||||
@@ -613,9 +699,7 @@ def _serialize_tariff_change_target(
|
|||||||
"title": "recalc_days",
|
"title": "recalc_days",
|
||||||
"days_after": int(options.get("recalc_days") or 0),
|
"days_after": int(options.get("recalc_days") or 0),
|
||||||
"remaining_days": int(options.get("remaining_days") or 0),
|
"remaining_days": int(options.get("remaining_days") or 0),
|
||||||
"converted_hwid_value_rub": float(
|
"converted_hwid_value_rub": float(options.get("converted_hwid_value_rub") or 0),
|
||||||
options.get("converted_hwid_value_rub") or 0
|
|
||||||
),
|
|
||||||
"converted_hwid_days": int(options.get("converted_hwid_days") or 0),
|
"converted_hwid_days": int(options.get("converted_hwid_days") or 0),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -638,9 +722,7 @@ def _serialize_tariff_change_target(
|
|||||||
"title": "convert_days_to_gb",
|
"title": "convert_days_to_gb",
|
||||||
"converted_gb": float(options.get("converted_gb") or 0),
|
"converted_gb": float(options.get("converted_gb") or 0),
|
||||||
"remaining_days": int(options.get("remaining_days") or 0),
|
"remaining_days": int(options.get("remaining_days") or 0),
|
||||||
"converted_hwid_value_rub": float(
|
"converted_hwid_value_rub": float(options.get("converted_hwid_value_rub") or 0),
|
||||||
options.get("converted_hwid_value_rub") or 0
|
|
||||||
),
|
|
||||||
"converted_hwid_gb": float(options.get("converted_hwid_gb") or 0),
|
"converted_hwid_gb": float(options.get("converted_hwid_gb") or 0),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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})
|
||||||
@@ -71,11 +71,17 @@ async def show_statistics_handler(
|
|||||||
f"📊 {_('admin_user_stats_total_label')}: <b>{user_stats['total_users']}</b>"
|
f"📊 {_('admin_user_stats_total_label')}: <b>{user_stats['total_users']}</b>"
|
||||||
)
|
)
|
||||||
# Removed: Active today moved to panel stats
|
# 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(
|
stats_text_parts.append(
|
||||||
f"💳 {_('admin_user_stats_paid_subs_label')}: <b>{user_stats['paid_subscriptions']}</b>"
|
f"💳 {_('admin_user_stats_paid_subs_label')}: <b>{user_stats['paid_subscriptions']}</b>"
|
||||||
)
|
)
|
||||||
stats_text_parts.append(
|
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(
|
stats_text_parts.append(
|
||||||
f"😴 {_('admin_user_stats_inactive_label')}: <b>{user_stats['inactive_users']}</b>"
|
f"😴 {_('admin_user_stats_inactive_label')}: <b>{user_stats['inactive_users']}</b>"
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from bot.middlewares.i18n import JsonI18n
|
|||||||
from bot.services.panel_api_service import PanelApiService
|
from bot.services.panel_api_service import PanelApiService
|
||||||
from bot.utils.text_sanitizer import panel_description_from_profile
|
from bot.utils.text_sanitizer import panel_description_from_profile
|
||||||
from config.settings import Settings
|
from config.settings import Settings
|
||||||
|
from db.advisory_locks import acquire_subscription_background_sync_lock
|
||||||
from db.dal import panel_sync_dal, subscription_dal, user_dal
|
from db.dal import panel_sync_dal, subscription_dal, user_dal
|
||||||
from db.models import Subscription, User
|
from db.models import Subscription, User
|
||||||
|
|
||||||
@@ -928,6 +929,7 @@ async def _perform_sync_impl(
|
|||||||
|
|
||||||
total_panel_users = len(panel_users_data)
|
total_panel_users = len(panel_users_data)
|
||||||
logging.info(f"Starting sync for {total_panel_users} panel users.")
|
logging.info(f"Starting sync for {total_panel_users} panel users.")
|
||||||
|
await acquire_subscription_background_sync_lock(session)
|
||||||
sync_indexes = await _prefetch_sync_indexes(session, panel_users_data)
|
sync_indexes = await _prefetch_sync_indexes(session, panel_users_data)
|
||||||
users_by_telegram_id = sync_indexes["users_by_telegram_id"]
|
users_by_telegram_id = sync_indexes["users_by_telegram_id"]
|
||||||
users_by_user_id = sync_indexes["users_by_user_id"]
|
users_by_user_id = sync_indexes["users_by_user_id"]
|
||||||
@@ -1203,15 +1205,13 @@ async def _perform_sync_impl(
|
|||||||
merge_panel_patches = int(merge_result.get("panel_patches", 0))
|
merge_panel_patches = int(merge_result.get("panel_patches", 0))
|
||||||
if merge_panel_patches:
|
if merge_panel_patches:
|
||||||
panel_patch_count += merge_panel_patches
|
panel_patch_count += merge_panel_patches
|
||||||
panel_patch_reason_counts[
|
panel_patch_reason_counts["duplicate_panel_merge_extend"] += (
|
||||||
"duplicate_panel_merge_extend"
|
merge_panel_patches
|
||||||
] += merge_panel_patches
|
)
|
||||||
if merge_result["resolved"]:
|
if merge_result["resolved"]:
|
||||||
users_updated += 1
|
users_updated += 1
|
||||||
users_uuid_updated += 1
|
users_uuid_updated += 1
|
||||||
local_update_reason_counts.update(
|
local_update_reason_counts.update(["duplicate_panel_identity_resolved"])
|
||||||
["duplicate_panel_identity_resolved"]
|
|
||||||
)
|
|
||||||
panel_uuids_by_telegram_id.get(telegram_id_from_panel, set()).discard(
|
panel_uuids_by_telegram_id.get(telegram_id_from_panel, set()).discard(
|
||||||
str(panel_uuid)
|
str(panel_uuid)
|
||||||
)
|
)
|
||||||
@@ -1322,9 +1322,7 @@ async def _perform_sync_impl(
|
|||||||
current_panel_user=panel_user_for_identity,
|
current_panel_user=panel_user_for_identity,
|
||||||
reasons=panel_reasons,
|
reasons=panel_reasons,
|
||||||
panel_view=(
|
panel_view=(
|
||||||
"list"
|
"list" if missing_identity_fields_match else "full_fetch"
|
||||||
if missing_identity_fields_match
|
|
||||||
else "full_fetch"
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
panel_patch_count += 1
|
panel_patch_count += 1
|
||||||
|
|||||||
@@ -166,8 +166,10 @@ async def create_user_stats_result(
|
|||||||
"inline_user_stats_message",
|
"inline_user_stats_message",
|
||||||
total=user_stats["total_users"],
|
total=user_stats["total_users"],
|
||||||
active_today=user_stats["active_today"],
|
active_today=user_stats["active_today"],
|
||||||
|
active=user_stats["active_subscriptions"],
|
||||||
paid=user_stats["paid_subscriptions"],
|
paid=user_stats["paid_subscriptions"],
|
||||||
trial=user_stats["trial_users"],
|
trial=user_stats["trial_users"],
|
||||||
|
free=user_stats["free_subscription_users"],
|
||||||
inactive=user_stats["inactive_users"],
|
inactive=user_stats["inactive_users"],
|
||||||
banned=user_stats["banned_users"],
|
banned=user_stats["banned_users"],
|
||||||
referral=user_stats["referral_users"],
|
referral=user_stats["referral_users"],
|
||||||
@@ -179,7 +181,7 @@ async def create_user_stats_result(
|
|||||||
description=_(
|
description=_(
|
||||||
"inline_user_stats_description",
|
"inline_user_stats_description",
|
||||||
total=user_stats["total_users"],
|
total=user_stats["total_users"],
|
||||||
active=user_stats["paid_subscriptions"],
|
active=user_stats["active_subscriptions"],
|
||||||
),
|
),
|
||||||
input_message_content=InputTextMessageContent(
|
input_message_content=InputTextMessageContent(
|
||||||
message_text=stats_text, parse_mode="HTML"
|
message_text=stats_text, parse_mode="HTML"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import logging
|
import logging
|
||||||
from typing import Optional, Union
|
from typing import Any, Callable, Optional, Union
|
||||||
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
||||||
|
|
||||||
from aiogram import Bot, F, Router, types
|
from aiogram import Bot, F, Router, types
|
||||||
@@ -76,31 +76,10 @@ async def referral_command_handler(
|
|||||||
await event.answer()
|
await event.answer()
|
||||||
return
|
return
|
||||||
|
|
||||||
bonus_info_parts = []
|
|
||||||
if getattr(settings, "traffic_sale_mode", False):
|
if getattr(settings, "traffic_sale_mode", False):
|
||||||
bonus_details_str = _("referral_not_available_for_traffic")
|
bonus_details_str = _("referral_not_available_for_traffic")
|
||||||
else:
|
else:
|
||||||
if settings.subscription_options:
|
bonus_details_str = _build_referral_bonus_details_text(settings, _, current_lang)
|
||||||
for months_period_key, _price in sorted(settings.subscription_options.items()):
|
|
||||||
inv_bonus = settings.referral_bonus_inviter.get(months_period_key)
|
|
||||||
ref_bonus = settings.referral_bonus_referee.get(months_period_key)
|
|
||||||
if inv_bonus is not None or ref_bonus is not None:
|
|
||||||
bonus_info_parts.append(
|
|
||||||
_(
|
|
||||||
"referral_bonus_per_period",
|
|
||||||
months=months_period_key,
|
|
||||||
inviter_bonus_days=inv_bonus
|
|
||||||
if inv_bonus is not None
|
|
||||||
else _("no_bonus_placeholder"),
|
|
||||||
referee_bonus_days=ref_bonus
|
|
||||||
if ref_bonus is not None
|
|
||||||
else _("no_bonus_placeholder"),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
bonus_details_str = (
|
|
||||||
"\n".join(bonus_info_parts) if bonus_info_parts else _("referral_no_bonuses_configured")
|
|
||||||
)
|
|
||||||
|
|
||||||
referral_stats = await referral_service.get_referral_stats(session, inviter_user_id)
|
referral_stats = await referral_service.get_referral_stats(session, inviter_user_id)
|
||||||
|
|
||||||
@@ -208,6 +187,132 @@ async def referral_action_handler(
|
|||||||
await callback.answer()
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
|
Translator = Callable[..., str]
|
||||||
|
|
||||||
|
|
||||||
|
def _period_bonus_text(
|
||||||
|
translator: Translator,
|
||||||
|
*,
|
||||||
|
months: int,
|
||||||
|
inviter_days: Optional[int],
|
||||||
|
referee_days: Optional[int],
|
||||||
|
) -> str:
|
||||||
|
return translator(
|
||||||
|
"referral_bonus_per_period",
|
||||||
|
months=months,
|
||||||
|
inviter_bonus_days=(
|
||||||
|
inviter_days if inviter_days is not None else translator("no_bonus_placeholder")
|
||||||
|
),
|
||||||
|
referee_bonus_days=(
|
||||||
|
referee_days if referee_days is not None else translator("no_bonus_placeholder")
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _tariff_period_bonus_entries(tariff: Any) -> list[dict[str, Optional[int]]]:
|
||||||
|
entries: list[dict[str, Optional[int]]] = []
|
||||||
|
for months in sorted(int(month) for month in getattr(tariff, "enabled_periods", [])):
|
||||||
|
inviter_days = tariff.referral_inviter_bonus_days(months)
|
||||||
|
referee_days = tariff.referral_referee_bonus_days(months)
|
||||||
|
if inviter_days is None and referee_days is None:
|
||||||
|
continue
|
||||||
|
entries.append(
|
||||||
|
{
|
||||||
|
"months": months,
|
||||||
|
"inviter_days": inviter_days,
|
||||||
|
"referee_days": referee_days,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
def _legacy_period_bonus_entries(settings: Settings) -> list[dict[str, Optional[int]]]:
|
||||||
|
entries: list[dict[str, Optional[int]]] = []
|
||||||
|
for months, _price in sorted(settings.subscription_options.items()):
|
||||||
|
inviter_days = settings.referral_bonus_inviter.get(months)
|
||||||
|
referee_days = settings.referral_bonus_referee.get(months)
|
||||||
|
if inviter_days is None and referee_days is None:
|
||||||
|
continue
|
||||||
|
entries.append(
|
||||||
|
{
|
||||||
|
"months": int(months),
|
||||||
|
"inviter_days": inviter_days,
|
||||||
|
"referee_days": referee_days,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
def _bonus_days_range(translator: Translator, values: list[int]) -> str:
|
||||||
|
return translator(
|
||||||
|
"referral_bonus_days_range",
|
||||||
|
min_days=min(values),
|
||||||
|
max_days=max(values),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_referral_bonus_details_text(
|
||||||
|
settings: Settings, translator: Translator, current_lang: str
|
||||||
|
) -> str:
|
||||||
|
tariffs_config = settings.tariffs_config
|
||||||
|
if not tariffs_config:
|
||||||
|
bonus_info_parts = [
|
||||||
|
_period_bonus_text(
|
||||||
|
translator,
|
||||||
|
months=int(entry["months"] or 0),
|
||||||
|
inviter_days=entry["inviter_days"],
|
||||||
|
referee_days=entry["referee_days"],
|
||||||
|
)
|
||||||
|
for entry in _legacy_period_bonus_entries(settings)
|
||||||
|
]
|
||||||
|
return (
|
||||||
|
"\n".join(bonus_info_parts)
|
||||||
|
if bonus_info_parts
|
||||||
|
else translator("referral_no_bonuses_configured")
|
||||||
|
)
|
||||||
|
|
||||||
|
period_tariffs = [
|
||||||
|
tariff for tariff in tariffs_config.enabled_tariffs if tariff.billing_model == "period"
|
||||||
|
]
|
||||||
|
if len(period_tariffs) <= 1:
|
||||||
|
entries = _tariff_period_bonus_entries(period_tariffs[0]) if period_tariffs else []
|
||||||
|
bonus_info_parts = [
|
||||||
|
_period_bonus_text(
|
||||||
|
translator,
|
||||||
|
months=int(entry["months"] or 0),
|
||||||
|
inviter_days=entry["inviter_days"],
|
||||||
|
referee_days=entry["referee_days"],
|
||||||
|
)
|
||||||
|
for entry in entries
|
||||||
|
]
|
||||||
|
return (
|
||||||
|
"\n".join(bonus_info_parts)
|
||||||
|
if bonus_info_parts
|
||||||
|
else translator("referral_no_bonuses_configured")
|
||||||
|
)
|
||||||
|
|
||||||
|
bonus_info_parts = []
|
||||||
|
for tariff in period_tariffs:
|
||||||
|
entries = _tariff_period_bonus_entries(tariff)
|
||||||
|
if not entries:
|
||||||
|
continue
|
||||||
|
inviter_values = [int(entry["inviter_days"] or 0) for entry in entries]
|
||||||
|
referee_values = [int(entry["referee_days"] or 0) for entry in entries]
|
||||||
|
bonus_info_parts.append(
|
||||||
|
translator(
|
||||||
|
"referral_bonus_tariff_range",
|
||||||
|
tariff_name=tariff.name(current_lang),
|
||||||
|
inviter_bonus_range=_bonus_days_range(translator, inviter_values),
|
||||||
|
referee_bonus_range=_bonus_days_range(translator, referee_values),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
"\n".join(bonus_info_parts)
|
||||||
|
if bonus_info_parts
|
||||||
|
else translator("referral_no_bonuses_configured")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _build_webapp_referral_link(
|
def _build_webapp_referral_link(
|
||||||
base_url: Optional[str], referral_code: Optional[str]
|
base_url: Optional[str], referral_code: Optional[str]
|
||||||
) -> Optional[str]:
|
) -> Optional[str]:
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from bot.services.panel_api_service import PanelApiService
|
|||||||
from bot.services.promo_code_service import PromoCodeService
|
from bot.services.promo_code_service import PromoCodeService
|
||||||
from bot.services.referral_service import ReferralService
|
from bot.services.referral_service import ReferralService
|
||||||
from bot.services.subscription_service import SubscriptionService
|
from bot.services.subscription_service import SubscriptionService
|
||||||
|
from bot.services.telegram_notifications import TELEGRAM_NOTIFICATIONS_ENABLED
|
||||||
from bot.utils.callback_answer import safe_answer_callback
|
from bot.utils.callback_answer import safe_answer_callback
|
||||||
from bot.utils.install_links import (
|
from bot.utils.install_links import (
|
||||||
append_install_share_link_text,
|
append_install_share_link_text,
|
||||||
@@ -392,11 +393,12 @@ async def ensure_required_channel_subscription(
|
|||||||
@router.message(CommandStart(magic=F.args.regexp(r"^promo_(\w+)$").as_("promo_match")))
|
@router.message(CommandStart(magic=F.args.regexp(r"^promo_(\w+)$").as_("promo_match")))
|
||||||
@router.message(CommandStart(magic=F.args.regexp(r"^admin_user_(\d+)$").as_("admin_user_match")))
|
@router.message(CommandStart(magic=F.args.regexp(r"^admin_user_(\d+)$").as_("admin_user_match")))
|
||||||
@router.message(CommandStart(magic=F.args.regexp(r"^ticket_(\d+)$").as_("ticket_match")))
|
@router.message(CommandStart(magic=F.args.regexp(r"^ticket_(\d+)$").as_("ticket_match")))
|
||||||
|
@router.message(CommandStart(magic=F.args.regexp(r"^notifications$").as_("notifications_match")))
|
||||||
@router.message(CommandStart(magic=F.args.regexp(r"^page_ref$").as_("page_ref_match")))
|
@router.message(CommandStart(magic=F.args.regexp(r"^page_ref$").as_("page_ref_match")))
|
||||||
@router.message(
|
@router.message(
|
||||||
CommandStart(
|
CommandStart(
|
||||||
magic=F.args.regexp(
|
magic=F.args.regexp(
|
||||||
r"^(?!ref_|promo_|admin_user_|ticket_|page_ref$|webapp_auth_)([A-Za-z0-9_\-]{2,64})$"
|
r"^(?!ref_|promo_|admin_user_|ticket_|notifications$|page_ref$|webapp_auth_)([A-Za-z0-9_\-]{2,64})$"
|
||||||
).as_("ad_param_match")
|
).as_("ad_param_match")
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -414,6 +416,7 @@ async def start_command_handler(
|
|||||||
ad_param_match: Optional[re.Match] = None,
|
ad_param_match: Optional[re.Match] = None,
|
||||||
admin_user_match: Optional[re.Match] = None,
|
admin_user_match: Optional[re.Match] = None,
|
||||||
ticket_match: Optional[re.Match] = None,
|
ticket_match: Optional[re.Match] = None,
|
||||||
|
notifications_match: Optional[re.Match] = None,
|
||||||
):
|
):
|
||||||
await state.clear()
|
await state.clear()
|
||||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||||
@@ -500,6 +503,7 @@ async def start_command_handler(
|
|||||||
promo_code_to_apply: Optional[str] = None
|
promo_code_to_apply: Optional[str] = None
|
||||||
should_open_referral_from_start = False
|
should_open_referral_from_start = False
|
||||||
ad_start_param: Optional[str] = None
|
ad_start_param: Optional[str] = None
|
||||||
|
notifications_start_requested = bool(notifications_match)
|
||||||
|
|
||||||
if ref_match:
|
if ref_match:
|
||||||
raw_ref_value = ref_match.group(1)
|
raw_ref_value = ref_match.group(1)
|
||||||
@@ -522,6 +526,8 @@ async def start_command_handler(
|
|||||||
elif promo_match:
|
elif promo_match:
|
||||||
promo_code_to_apply = promo_match.group(1)
|
promo_code_to_apply = promo_match.group(1)
|
||||||
logging.info(f"User {user_id} started with promo code: {promo_code_to_apply}")
|
logging.info(f"User {user_id} started with promo code: {promo_code_to_apply}")
|
||||||
|
elif notifications_start_requested:
|
||||||
|
logging.info("User %s started bot from notifications deep-link.", user_id)
|
||||||
elif page_ref_match:
|
elif page_ref_match:
|
||||||
should_open_referral_from_start = True
|
should_open_referral_from_start = True
|
||||||
logging.info(f"User {user_id} started with page_ref deep-link.")
|
logging.info(f"User {user_id} started with page_ref deep-link.")
|
||||||
@@ -532,18 +538,24 @@ async def start_command_handler(
|
|||||||
sanitized_username = sanitize_username(user.username)
|
sanitized_username = sanitize_username(user.username)
|
||||||
sanitized_first_name = sanitize_display_name(user.first_name)
|
sanitized_first_name = sanitize_display_name(user.first_name)
|
||||||
sanitized_last_name = sanitize_display_name(user.last_name)
|
sanitized_last_name = sanitize_display_name(user.last_name)
|
||||||
|
notification_status_now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||||
is_existing_user = db_user is not None
|
is_existing_user = db_user is not None
|
||||||
if not db_user:
|
if not db_user:
|
||||||
user_data_to_create = {
|
user_data_to_create = {
|
||||||
"user_id": user_id,
|
"user_id": user_id,
|
||||||
|
"telegram_id": user_id,
|
||||||
"username": sanitized_username,
|
"username": sanitized_username,
|
||||||
"first_name": sanitized_first_name,
|
"first_name": sanitized_first_name,
|
||||||
"last_name": sanitized_last_name,
|
"last_name": sanitized_last_name,
|
||||||
"language_code": current_lang,
|
"language_code": current_lang,
|
||||||
"referred_by_id": referred_by_user_id,
|
"referred_by_id": referred_by_user_id,
|
||||||
"registration_date": datetime.now(timezone.utc),
|
"registration_date": datetime.now(timezone.utc),
|
||||||
|
"telegram_notifications_status": TELEGRAM_NOTIFICATIONS_ENABLED,
|
||||||
|
"telegram_notifications_checked_at": notification_status_now,
|
||||||
|
"telegram_notifications_enabled_at": notification_status_now,
|
||||||
|
"telegram_notifications_blocked_at": None,
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
db_user, created = await user_dal.create_user(session, user_data_to_create)
|
db_user, created = await user_dal.create_user(session, user_data_to_create)
|
||||||
@@ -631,6 +643,13 @@ async def start_command_handler(
|
|||||||
update_payload = {}
|
update_payload = {}
|
||||||
if db_user.language_code != current_lang:
|
if db_user.language_code != current_lang:
|
||||||
update_payload["language_code"] = current_lang
|
update_payload["language_code"] = current_lang
|
||||||
|
if db_user.telegram_id != user_id:
|
||||||
|
update_payload["telegram_id"] = user_id
|
||||||
|
if db_user.telegram_notifications_status != TELEGRAM_NOTIFICATIONS_ENABLED:
|
||||||
|
update_payload["telegram_notifications_status"] = TELEGRAM_NOTIFICATIONS_ENABLED
|
||||||
|
update_payload["telegram_notifications_checked_at"] = notification_status_now
|
||||||
|
update_payload["telegram_notifications_enabled_at"] = notification_status_now
|
||||||
|
update_payload["telegram_notifications_blocked_at"] = None
|
||||||
# Set referral only if not already set AND user is not currently active.
|
# Set referral only if not already set AND user is not currently active.
|
||||||
# This allows previously subscribed but currently inactive users to be attributed.
|
# This allows previously subscribed but currently inactive users to be attributed.
|
||||||
if referred_by_user_id and db_user.referred_by_id is None:
|
if referred_by_user_id and db_user.referred_by_id is None:
|
||||||
@@ -684,9 +703,16 @@ async def start_command_handler(
|
|||||||
open_referral_page_for_existing_user = should_open_referral_from_start and is_existing_user
|
open_referral_page_for_existing_user = should_open_referral_from_start and is_existing_user
|
||||||
|
|
||||||
# Send welcome message if not disabled
|
# Send welcome message if not disabled
|
||||||
if not settings.DISABLE_WELCOME_MESSAGE and not open_referral_page_for_existing_user:
|
if (
|
||||||
|
not settings.DISABLE_WELCOME_MESSAGE
|
||||||
|
and not open_referral_page_for_existing_user
|
||||||
|
and not notifications_start_requested
|
||||||
|
):
|
||||||
await message.answer(_(key="welcome", user_name=hd.quote(user.full_name)))
|
await message.answer(_(key="welcome", user_name=hd.quote(user.full_name)))
|
||||||
|
|
||||||
|
if notifications_start_requested:
|
||||||
|
await message.answer(_("telegram_notifications_started"), parse_mode="HTML")
|
||||||
|
|
||||||
# Auto-apply promo code if provided via start parameter
|
# Auto-apply promo code if provided via start parameter
|
||||||
if promo_code_to_apply:
|
if promo_code_to_apply:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -114,6 +114,20 @@ def _with_subscription_purchase_description(
|
|||||||
return f"{description}\n\n{text}"
|
return f"{description}\n\n{text}"
|
||||||
|
|
||||||
|
|
||||||
|
def _format_premium_bytes(value: object) -> str:
|
||||||
|
try:
|
||||||
|
bytes_value = max(0, int(value or 0))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
bytes_value = 0
|
||||||
|
return f"{bytes_value / 2**30:.2f} GB"
|
||||||
|
|
||||||
|
|
||||||
|
def _format_premium_usage_limit(active: dict[str, object]) -> str:
|
||||||
|
used = _format_premium_bytes(active.get("premium_used_bytes"))
|
||||||
|
limit = _format_premium_bytes(active.get("premium_limit_bytes"))
|
||||||
|
return f"{used} из {limit}"
|
||||||
|
|
||||||
|
|
||||||
async def display_subscription_options(
|
async def display_subscription_options(
|
||||||
event: Union[types.Message, types.CallbackQuery],
|
event: Union[types.Message, types.CallbackQuery],
|
||||||
i18n_data: dict,
|
i18n_data: dict,
|
||||||
@@ -456,7 +470,7 @@ async def tariff_topup_list_callback(
|
|||||||
if len(labels) > len(visible):
|
if len(labels) > len(visible):
|
||||||
premium_lines.append(f"• ... еще {len(labels) - len(visible)}")
|
premium_lines.append(f"• ... еще {len(labels) - len(visible)}")
|
||||||
premium_lines.append(
|
premium_lines.append(
|
||||||
f"Premium использовано: {active.get('premium_used')} из {active.get('premium_limit')}. Осталось: {premium_left / 2**30:.2f} GB." # noqa: E501
|
f"Premium использовано: {_format_premium_usage_limit(active)}. Осталось: {premium_left / 2**30:.2f} GB." # noqa: E501
|
||||||
)
|
)
|
||||||
text = get_text("choose_payment_method_traffic")
|
text = get_text("choose_payment_method_traffic")
|
||||||
if carryover_lines:
|
if carryover_lines:
|
||||||
@@ -543,9 +557,7 @@ async def hwid_devices_list_callback(
|
|||||||
renewal=renewal_available,
|
renewal=renewal_available,
|
||||||
)
|
)
|
||||||
text_key = (
|
text_key = (
|
||||||
"select_hwid_device_renewal_package"
|
"select_hwid_device_renewal_package" if renewal_available else "select_hwid_device_package"
|
||||||
if renewal_available
|
|
||||||
else "select_hwid_device_package"
|
|
||||||
)
|
)
|
||||||
await callback.message.edit_text(
|
await callback.message.edit_text(
|
||||||
get_text(
|
get_text(
|
||||||
@@ -1067,7 +1079,7 @@ async def my_subscription_command_handler(
|
|||||||
text += (
|
text += (
|
||||||
"\n\n🚀 <b>Premium-серверы</b>\n"
|
"\n\n🚀 <b>Premium-серверы</b>\n"
|
||||||
f"Статус: <b>{premium_status}</b>\n"
|
f"Статус: <b>{premium_status}</b>\n"
|
||||||
f"Лимит: <b>{active.get('premium_used')} из {active.get('premium_limit')}</b>\n"
|
f"Лимит: <b>{_format_premium_usage_limit(active)}</b>\n"
|
||||||
f"Осталось: <b>{premium_left / 2**30:.2f} GB</b>\n"
|
f"Осталось: <b>{premium_left / 2**30:.2f} GB</b>\n"
|
||||||
f"Докупленный остаток: <b>{premium_balance / 2**30:.2f} GB</b>\n"
|
f"Докупленный остаток: <b>{premium_balance / 2**30:.2f} GB</b>\n"
|
||||||
"Отдельный лимит действует на:\n"
|
"Отдельный лимит действует на:\n"
|
||||||
|
|||||||
@@ -160,10 +160,7 @@ def locale_language_flag(code: object) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def sort_locale_language_codes(codes: Iterable[object]) -> List[str]:
|
def sort_locale_language_codes(codes: Iterable[object]) -> List[str]:
|
||||||
normalized = {
|
normalized = {normalize_locale_language_code(code, prefer_known_base=False) for code in codes}
|
||||||
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)}
|
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]
|
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)
|
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.locales_data: Dict[str, Dict[str, str]] = {}
|
||||||
self._overrides_path: Optional[Path] = None
|
self._overrides_path: Optional[Path] = None
|
||||||
self._overrides_file_mtime_ns: Optional[int] = None
|
self._overrides_file_mtime_ns: Optional[int] = None
|
||||||
|
self._overrides_file_content: Optional[str] = None
|
||||||
self._overrides_file_next_check = 0.0
|
self._overrides_file_next_check = 0.0
|
||||||
self._overrides_file_check_interval_seconds = 1.0
|
self._overrides_file_check_interval_seconds = 1.0
|
||||||
self._load_locales()
|
self._load_locales()
|
||||||
@@ -422,6 +420,7 @@ class JsonI18n:
|
|||||||
if self._overrides_file_mtime_ns is None:
|
if self._overrides_file_mtime_ns is None:
|
||||||
return False
|
return False
|
||||||
self._overrides_file_mtime_ns = None
|
self._overrides_file_mtime_ns = None
|
||||||
|
self._overrides_file_content = None
|
||||||
logging.info(
|
logging.info(
|
||||||
"Locale overrides file removed; keeping current in-memory overrides until "
|
"Locale overrides file removed; keeping current in-memory overrides until "
|
||||||
"the DB fallback is reloaded"
|
"the DB fallback is reloaded"
|
||||||
@@ -435,19 +434,8 @@ class JsonI18n:
|
|||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if not force and stat.st_mtime_ns == self._overrides_file_mtime_ns:
|
|
||||||
return False
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
payload = json.loads(self._overrides_path.read_text(encoding="utf-8"))
|
content = self._overrides_path.read_text(encoding="utf-8")
|
||||||
except json.JSONDecodeError as exc:
|
|
||||||
logging.warning(
|
|
||||||
"Failed to parse locale overrides file %s: %s",
|
|
||||||
self._overrides_path,
|
|
||||||
exc,
|
|
||||||
)
|
|
||||||
self._overrides_file_mtime_ns = stat.st_mtime_ns
|
|
||||||
return False
|
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
logging.warning(
|
logging.warning(
|
||||||
"Failed to read locale overrides file %s: %s",
|
"Failed to read locale overrides file %s: %s",
|
||||||
@@ -456,7 +444,27 @@ class JsonI18n:
|
|||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
if (
|
||||||
|
not force
|
||||||
|
and stat.st_mtime_ns == self._overrides_file_mtime_ns
|
||||||
|
and content == self._overrides_file_content
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = json.loads(content)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
logging.warning(
|
||||||
|
"Failed to parse locale overrides file %s: %s",
|
||||||
|
self._overrides_path,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
self._overrides_file_mtime_ns = stat.st_mtime_ns
|
self._overrides_file_mtime_ns = stat.st_mtime_ns
|
||||||
|
self._overrides_file_content = content
|
||||||
|
return False
|
||||||
|
|
||||||
|
self._overrides_file_mtime_ns = stat.st_mtime_ns
|
||||||
|
self._overrides_file_content = content
|
||||||
self.set_locale_overrides(payload)
|
self.set_locale_overrides(payload)
|
||||||
logging.info("Locale overrides reloaded from %s", self._overrides_path)
|
logging.info("Locale overrides reloaded from %s", self._overrides_path)
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -270,6 +270,14 @@ class CryptoPayService:
|
|||||||
referral_service: ReferralService = app["referral_service"]
|
referral_service: ReferralService = app["referral_service"]
|
||||||
|
|
||||||
async with async_session_factory() as session:
|
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:
|
try:
|
||||||
await payment_dal.update_provider_payment_and_status(
|
await payment_dal.update_provider_payment_and_status(
|
||||||
session,
|
session,
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ from .shared import (
|
|||||||
parse_payment_callback,
|
parse_payment_callback,
|
||||||
payment_failed,
|
payment_failed,
|
||||||
payment_unavailable,
|
payment_unavailable,
|
||||||
|
payment_units_for_activation,
|
||||||
post_json_request,
|
post_json_request,
|
||||||
quote_hwid_callback_parts,
|
quote_hwid_callback_parts,
|
||||||
render_link_or_fail,
|
render_link_or_fail,
|
||||||
@@ -382,10 +383,10 @@ class FreeKassaService(HttpClientMixin):
|
|||||||
)
|
)
|
||||||
return web.Response(status=500, text="processing_error")
|
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 (
|
sale_mode = payment.sale_mode or (
|
||||||
"traffic" if self.settings.traffic_sale_mode else "subscription"
|
"traffic" if self.settings.traffic_sale_mode else "subscription"
|
||||||
)
|
)
|
||||||
|
months = payment_units_for_activation(payment, sale_mode)
|
||||||
|
|
||||||
success_prefix: Optional[str] = None
|
success_prefix: Optional[str] = None
|
||||||
if provider_payment_id:
|
if provider_payment_id:
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ from .shared import (
|
|||||||
parse_payment_callback,
|
parse_payment_callback,
|
||||||
payment_failed,
|
payment_failed,
|
||||||
payment_unavailable,
|
payment_unavailable,
|
||||||
|
payment_units_for_activation,
|
||||||
quote_hwid_callback_parts,
|
quote_hwid_callback_parts,
|
||||||
render_link_or_fail,
|
render_link_or_fail,
|
||||||
)
|
)
|
||||||
@@ -469,10 +470,10 @@ class HeleketService(HttpClientMixin):
|
|||||||
)
|
)
|
||||||
return web.Response(status=500, text="processing_error")
|
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 (
|
sale_mode = payment.sale_mode or (
|
||||||
"traffic" if self.settings.traffic_sale_mode else "subscription"
|
"traffic" if self.settings.traffic_sale_mode else "subscription"
|
||||||
)
|
)
|
||||||
|
payment_units = payment_units_for_activation(payment, sale_mode)
|
||||||
|
|
||||||
outcome = await finalize_successful_payment(
|
outcome = await finalize_successful_payment(
|
||||||
PaymentSuccessRequest(
|
PaymentSuccessRequest(
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ from .shared import (
|
|||||||
payment_failed,
|
payment_failed,
|
||||||
payment_record_amounts,
|
payment_record_amounts,
|
||||||
payment_unavailable,
|
payment_unavailable,
|
||||||
|
payment_units_for_activation,
|
||||||
post_json_request,
|
post_json_request,
|
||||||
quote_hwid_callback_parts,
|
quote_hwid_callback_parts,
|
||||||
render_link_or_fail,
|
render_link_or_fail,
|
||||||
@@ -303,10 +304,10 @@ class PlategaService(HttpClientMixin):
|
|||||||
if payment.status == "succeeded" and status == "CONFIRMED":
|
if payment.status == "succeeded" and status == "CONFIRMED":
|
||||||
return web.Response(text="ok")
|
return web.Response(text="ok")
|
||||||
|
|
||||||
payment_months = payment.purchased_gb or payment.subscription_duration_months or 1
|
|
||||||
sale_mode = payment.sale_mode or (
|
sale_mode = payment.sale_mode or (
|
||||||
"traffic" if self.settings.traffic_sale_mode else "subscription"
|
"traffic" if self.settings.traffic_sale_mode else "subscription"
|
||||||
)
|
)
|
||||||
|
payment_months = payment_units_for_activation(payment, sale_mode)
|
||||||
|
|
||||||
if status == "CONFIRMED":
|
if status == "CONFIRMED":
|
||||||
if amount_raw is not None:
|
if amount_raw is not None:
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ from .shared import (
|
|||||||
parse_payment_callback,
|
parse_payment_callback,
|
||||||
payment_failed,
|
payment_failed,
|
||||||
payment_unavailable,
|
payment_unavailable,
|
||||||
|
payment_units_for_activation,
|
||||||
post_json_request,
|
post_json_request,
|
||||||
quote_hwid_callback_parts,
|
quote_hwid_callback_parts,
|
||||||
render_link_or_fail,
|
render_link_or_fail,
|
||||||
@@ -270,12 +271,19 @@ class SeverPayService(HttpClientMixin):
|
|||||||
return web.json_response({"status": False, "msg": "payment_not_found"}, status=404)
|
return web.json_response({"status": False, "msg": "payment_not_found"}, status=404)
|
||||||
|
|
||||||
resolved_provider_id = provider_payment_id or str(payment.payment_id)
|
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 (
|
sale_mode = payment.sale_mode or (
|
||||||
"traffic" if self.settings.traffic_sale_mode else "subscription"
|
"traffic" if self.settings.traffic_sale_mode else "subscription"
|
||||||
)
|
)
|
||||||
|
payment_months = payment_units_for_activation(payment, sale_mode)
|
||||||
|
|
||||||
if status == "success":
|
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:
|
try:
|
||||||
await payment_dal.update_provider_payment_and_status(
|
await payment_dal.update_provider_payment_and_status(
|
||||||
session,
|
session,
|
||||||
|
|||||||
@@ -36,10 +36,12 @@ from .common import (
|
|||||||
json_error,
|
json_error,
|
||||||
make_translator,
|
make_translator,
|
||||||
mark_payment_failed_creation,
|
mark_payment_failed_creation,
|
||||||
|
parse_positive_int_units,
|
||||||
payment_failed,
|
payment_failed,
|
||||||
payment_link_response,
|
payment_link_response,
|
||||||
payment_record_amounts,
|
payment_record_amounts,
|
||||||
payment_unavailable,
|
payment_unavailable,
|
||||||
|
payment_units_for_activation,
|
||||||
sale_mode_base,
|
sale_mode_base,
|
||||||
sale_mode_is_hwid_devices,
|
sale_mode_is_hwid_devices,
|
||||||
sale_mode_is_traffic,
|
sale_mode_is_traffic,
|
||||||
@@ -103,6 +105,7 @@ __all__ = [
|
|||||||
"lookup_payment_by_order_or_provider_id",
|
"lookup_payment_by_order_or_provider_id",
|
||||||
"make_translator",
|
"make_translator",
|
||||||
"mark_payment_failed_creation",
|
"mark_payment_failed_creation",
|
||||||
|
"parse_positive_int_units",
|
||||||
"notify_admins_payment_received",
|
"notify_admins_payment_received",
|
||||||
"notify_callback_parse_error",
|
"notify_callback_parse_error",
|
||||||
"notify_payment_gateway_failure",
|
"notify_payment_gateway_failure",
|
||||||
@@ -114,6 +117,7 @@ __all__ = [
|
|||||||
"payment_link_message_text",
|
"payment_link_message_text",
|
||||||
"payment_link_response",
|
"payment_link_response",
|
||||||
"payment_record_amounts",
|
"payment_record_amounts",
|
||||||
|
"payment_units_for_activation",
|
||||||
"payment_unavailable",
|
"payment_unavailable",
|
||||||
"post_json_request",
|
"post_json_request",
|
||||||
"quote_hwid_callback_parts",
|
"quote_hwid_callback_parts",
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from .common import (
|
|||||||
build_payment_description,
|
build_payment_description,
|
||||||
format_human_units,
|
format_human_units,
|
||||||
mark_payment_failed_creation,
|
mark_payment_failed_creation,
|
||||||
|
parse_positive_int_units,
|
||||||
sale_mode_base,
|
sale_mode_base,
|
||||||
sale_mode_is_hwid_devices,
|
sale_mode_is_hwid_devices,
|
||||||
sale_mode_tariff_key,
|
sale_mode_tariff_key,
|
||||||
@@ -124,10 +125,13 @@ async def quote_hwid_callback_parts(
|
|||||||
) -> tuple[Optional[PaymentCallbackParts], Optional[dict]]:
|
) -> tuple[Optional[PaymentCallbackParts], Optional[dict]]:
|
||||||
if not sale_mode_is_hwid_devices(parts.sale_mode):
|
if not sale_mode_is_hwid_devices(parts.sale_mode):
|
||||||
return parts, None
|
return parts, None
|
||||||
|
device_count = parse_positive_int_units(parts.months)
|
||||||
|
if device_count is None:
|
||||||
|
return None, None
|
||||||
quote = await subscription_service.quote_hwid_device_topup(
|
quote = await subscription_service.quote_hwid_device_topup(
|
||||||
session,
|
session,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
device_count=int(parts.months),
|
device_count=device_count,
|
||||||
tariff_key=sale_mode_tariff_key(parts.sale_mode),
|
tariff_key=sale_mode_tariff_key(parts.sale_mode),
|
||||||
renewal=sale_mode_base(parts.sale_mode) == "hwid_devices_renewal",
|
renewal=sale_mode_base(parts.sale_mode) == "hwid_devices_renewal",
|
||||||
currency=currency,
|
currency=currency,
|
||||||
@@ -135,7 +139,7 @@ async def quote_hwid_callback_parts(
|
|||||||
if not quote:
|
if not quote:
|
||||||
return None, None
|
return None, None
|
||||||
quoted_parts = PaymentCallbackParts(
|
quoted_parts = PaymentCallbackParts(
|
||||||
months=parts.months,
|
months=device_count,
|
||||||
price=float(quote.get("price") or 0),
|
price=float(quote.get("price") or 0),
|
||||||
sale_mode=parts.sale_mode,
|
sale_mode=parts.sale_mode,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
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 typing import Any, Callable, Optional
|
||||||
|
|
||||||
from aiohttp import web
|
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)
|
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:
|
def format_human_units(value: Any) -> str:
|
||||||
"""Render numeric units the way the UI expects: integers w/o decimals, floats with %g."""
|
"""Render numeric units the way the UI expects: integers w/o decimals, floats with %g."""
|
||||||
numeric = float(value)
|
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:
|
def json_error(status: int, code: str, message: str) -> web.Response:
|
||||||
return web.json_response({"ok": False, "error": code, "message": message}, status=status)
|
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.dal import payment_dal, user_dal
|
||||||
from db.models import Payment, User
|
from db.models import Payment, User
|
||||||
|
|
||||||
from .common import Translator, format_human_units, make_translator, sale_mode_base
|
from .common import (
|
||||||
|
Translator,
|
||||||
|
format_human_units,
|
||||||
|
make_translator,
|
||||||
|
sale_mode_base,
|
||||||
|
sale_mode_tariff_key,
|
||||||
|
)
|
||||||
|
|
||||||
_TRAFFIC_MODES = {"traffic", "traffic_package", "topup", "premium_topup"}
|
_TRAFFIC_MODES = {"traffic", "traffic_package", "topup", "premium_topup"}
|
||||||
_HWID_DEVICE_MODES = {"hwid_device", "hwid_devices", "hwid_devices_renewal"}
|
_HWID_DEVICE_MODES = {"hwid_device", "hwid_devices", "hwid_devices_renewal"}
|
||||||
@@ -304,6 +310,7 @@ async def finalize_successful_payment(
|
|||||||
activation_months or 1,
|
activation_months or 1,
|
||||||
current_payment_db_id=req.payment.payment_id,
|
current_payment_db_id=req.payment.payment_id,
|
||||||
skip_if_active_before_payment=False,
|
skip_if_active_before_payment=False,
|
||||||
|
tariff_key=sale_mode_tariff_key(req.sale_mode),
|
||||||
)
|
)
|
||||||
await req.session.commit()
|
await req.session.commit()
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from typing import Any, Optional
|
|||||||
from aiogram import Bot
|
from aiogram import Bot
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from bot.services.user_email_notifications import send_user_notification_email
|
||||||
from db.dal import payment_dal, user_dal
|
from db.dal import payment_dal, user_dal
|
||||||
from db.models import Payment
|
from db.models import Payment
|
||||||
|
|
||||||
@@ -55,11 +56,21 @@ async def notify_user_payment_failed(
|
|||||||
db_user.language_code if db_user and db_user.language_code else settings.DEFAULT_LANGUAGE
|
db_user.language_code if db_user and db_user.language_code else settings.DEFAULT_LANGUAGE
|
||||||
)
|
)
|
||||||
translator = make_translator(i18n, language)
|
translator = make_translator(i18n, language)
|
||||||
|
message_text = translator(message_key)
|
||||||
try:
|
try:
|
||||||
await bot.send_message(payment.user_id, translator(message_key))
|
await bot.send_message(payment.user_id, message_text)
|
||||||
except Exception:
|
except Exception:
|
||||||
logging.exception(
|
logging.exception(
|
||||||
"Webhook helper: failed to notify user %s about %s.",
|
"Webhook helper: failed to notify user %s about %s.",
|
||||||
payment.user_id,
|
payment.user_id,
|
||||||
message_key,
|
message_key,
|
||||||
)
|
)
|
||||||
|
if db_user:
|
||||||
|
await send_user_notification_email(
|
||||||
|
settings=settings,
|
||||||
|
i18n=i18n,
|
||||||
|
user=db_user,
|
||||||
|
subject_key="email_payment_failed_subject",
|
||||||
|
message_text=message_text,
|
||||||
|
dashboard_url=(getattr(settings, "SUBSCRIPTION_MINI_APP_URL", "") or None),
|
||||||
|
)
|
||||||
|
|||||||
@@ -143,6 +143,14 @@ class StarsService:
|
|||||||
i18n_data: dict,
|
i18n_data: dict,
|
||||||
sale_mode: str = "subscription",
|
sale_mode: str = "subscription",
|
||||||
) -> None:
|
) -> 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:
|
try:
|
||||||
payment_record = await payment_dal.update_provider_payment_and_status(
|
payment_record = await payment_dal.update_provider_payment_and_status(
|
||||||
session,
|
session,
|
||||||
@@ -162,9 +170,6 @@ class StarsService:
|
|||||||
else int(message.from_user.id)
|
else int(message.from_user.id)
|
||||||
)
|
)
|
||||||
payment = await payment_dal.get_payment_by_db_id(session, payment_db_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(
|
await finalize_successful_payment(
|
||||||
PaymentSuccessRequest(
|
PaymentSuccessRequest(
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ from .shared import (
|
|||||||
payment_link_response,
|
payment_link_response,
|
||||||
payment_record_amounts,
|
payment_record_amounts,
|
||||||
payment_unavailable,
|
payment_unavailable,
|
||||||
|
payment_units_for_activation,
|
||||||
post_json_request,
|
post_json_request,
|
||||||
quote_hwid_callback_parts,
|
quote_hwid_callback_parts,
|
||||||
render_link_or_fail,
|
render_link_or_fail,
|
||||||
@@ -531,10 +532,10 @@ class WataService(HttpClientMixin):
|
|||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
payment_units = payment.purchased_gb or payment.subscription_duration_months or 1
|
|
||||||
sale_mode = payment.sale_mode or (
|
sale_mode = payment.sale_mode or (
|
||||||
"traffic" if self.settings.traffic_sale_mode else "subscription"
|
"traffic" if self.settings.traffic_sale_mode else "subscription"
|
||||||
)
|
)
|
||||||
|
payment_units = payment_units_for_activation(payment, sale_mode)
|
||||||
outcome = await finalize_successful_payment(
|
outcome = await finalize_successful_payment(
|
||||||
PaymentSuccessRequest(
|
PaymentSuccessRequest(
|
||||||
bot=self.bot,
|
bot=self.bot,
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ from bot.services.lknpd_service import LknpdService
|
|||||||
from bot.services.panel_api_service import PanelApiService
|
from bot.services.panel_api_service import PanelApiService
|
||||||
from bot.services.referral_service import ReferralService
|
from bot.services.referral_service import ReferralService
|
||||||
from bot.services.subscription_service import SubscriptionService
|
from bot.services.subscription_service import SubscriptionService
|
||||||
|
from bot.services.user_email_notifications import send_user_notification_email
|
||||||
from bot.utils.config_link import prepare_config_links
|
from bot.utils.config_link import prepare_config_links
|
||||||
from bot.utils.install_links import ensure_user_install_guide_links
|
from bot.utils.install_links import ensure_user_install_guide_links
|
||||||
from bot.utils.request_security import ip_in_allowlist, request_client_ip
|
from bot.utils.request_security import ip_in_allowlist, request_client_ip
|
||||||
@@ -63,6 +64,7 @@ from .shared import (
|
|||||||
make_translator,
|
make_translator,
|
||||||
mark_payment_failed_creation,
|
mark_payment_failed_creation,
|
||||||
notify_admins_payment_received,
|
notify_admins_payment_received,
|
||||||
|
parse_positive_int_units,
|
||||||
payment_failed,
|
payment_failed,
|
||||||
payment_link_response,
|
payment_link_response,
|
||||||
payment_record_amounts,
|
payment_record_amounts,
|
||||||
@@ -74,6 +76,9 @@ from .shared import (
|
|||||||
from .shared import (
|
from .shared import (
|
||||||
sale_mode_base as _sale_mode_base,
|
sale_mode_base as _sale_mode_base,
|
||||||
)
|
)
|
||||||
|
from .shared import (
|
||||||
|
sale_mode_tariff_key as _sale_mode_tariff_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class YooKassaConfig(ProviderEnvConfig):
|
class YooKassaConfig(ProviderEnvConfig):
|
||||||
@@ -144,10 +149,12 @@ class YooKassaService:
|
|||||||
bot_username_for_default_return: Optional[str] = None,
|
bot_username_for_default_return: Optional[str] = None,
|
||||||
settings_obj: Optional[Settings] = None,
|
settings_obj: Optional[Settings] = None,
|
||||||
config: Optional[YooKassaConfig] = None,
|
config: Optional[YooKassaConfig] = None,
|
||||||
|
subscription_service: Optional[SubscriptionService] = None,
|
||||||
):
|
):
|
||||||
|
|
||||||
self.settings = settings_obj
|
self.settings = settings_obj
|
||||||
self.config = config or YooKassaConfig()
|
self.config = config or YooKassaConfig()
|
||||||
|
self.subscription_service = subscription_service
|
||||||
self._bot_username_for_default_return = bot_username_for_default_return
|
self._bot_username_for_default_return = bot_username_for_default_return
|
||||||
self._configured_return_url_override = configured_return_url
|
self._configured_return_url_override = configured_return_url
|
||||||
self._sdk_configured_for = (
|
self._sdk_configured_for = (
|
||||||
@@ -169,9 +176,7 @@ class YooKassaService:
|
|||||||
@property
|
@property
|
||||||
def configured(self) -> bool:
|
def configured(self) -> bool:
|
||||||
if not (
|
if not (
|
||||||
provider_runtime_enabled(self.config)
|
provider_runtime_enabled(self.config) and self.config.SHOP_ID and self.config.SECRET_KEY
|
||||||
and self.config.SHOP_ID
|
|
||||||
and self.config.SECRET_KEY
|
|
||||||
):
|
):
|
||||||
return False
|
return False
|
||||||
self._ensure_sdk_configured()
|
self._ensure_sdk_configured()
|
||||||
@@ -444,11 +449,17 @@ def _resolve_yookassa_activation_amounts(
|
|||||||
traffic_amount_gb = (
|
traffic_amount_gb = (
|
||||||
float(traffic_gb_raw) if _metadata_value_present(traffic_gb_raw) else subscription_months
|
float(traffic_gb_raw) if _metadata_value_present(traffic_gb_raw) else subscription_months
|
||||||
)
|
)
|
||||||
hwid_devices_count = (
|
hwid_devices_count = 0
|
||||||
int(float(hwid_devices_raw))
|
if _metadata_value_present(hwid_devices_raw):
|
||||||
if _metadata_value_present(hwid_devices_raw)
|
parsed_hwid_devices = parse_positive_int_units(hwid_devices_raw)
|
||||||
else (int(subscription_months) if _is_hwid_device_sale_base(sale_mode_base) else 0)
|
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":
|
if sale_mode_base == "subscription":
|
||||||
months_for_activation = int(subscription_months)
|
months_for_activation = int(subscription_months)
|
||||||
@@ -742,6 +753,7 @@ async def process_successful_payment(
|
|||||||
months_for_activation or int(subscription_months) or 1,
|
months_for_activation or int(subscription_months) or 1,
|
||||||
current_payment_db_id=payment_db_id,
|
current_payment_db_id=payment_db_id,
|
||||||
skip_if_active_before_payment=False,
|
skip_if_active_before_payment=False,
|
||||||
|
tariff_key=_sale_mode_tariff_key(sale_mode),
|
||||||
)
|
)
|
||||||
applied_referee_bonus_days_from_referral: Optional[int] = None
|
applied_referee_bonus_days_from_referral: Optional[int] = None
|
||||||
if referral_bonus_info and referral_bonus_info.get("referee_new_end_date"):
|
if referral_bonus_info and referral_bonus_info.get("referee_new_end_date"):
|
||||||
@@ -942,7 +954,20 @@ async def process_cancelled_payment(
|
|||||||
user_lang = db_user.language_code
|
user_lang = db_user.language_code
|
||||||
|
|
||||||
_ = lambda key, **kwargs: i18n.gettext(user_lang, key, **kwargs)
|
_ = lambda key, **kwargs: i18n.gettext(user_lang, key, **kwargs)
|
||||||
await bot.send_message(user_id, _("payment_failed"))
|
message_text = _("payment_failed")
|
||||||
|
try:
|
||||||
|
await bot.send_message(user_id, message_text)
|
||||||
|
except Exception:
|
||||||
|
logging.exception("Failed to notify YooKassa user %s about cancelled payment.", user_id)
|
||||||
|
if db_user:
|
||||||
|
await send_user_notification_email(
|
||||||
|
settings=settings,
|
||||||
|
i18n=i18n,
|
||||||
|
user=db_user,
|
||||||
|
subject_key="email_payment_failed_subject",
|
||||||
|
message_text=message_text,
|
||||||
|
dashboard_url=(settings.SUBSCRIPTION_MINI_APP_URL or None),
|
||||||
|
)
|
||||||
|
|
||||||
except Exception as e_process_cancel:
|
except Exception as e_process_cancel:
|
||||||
logging.error(
|
logging.error(
|
||||||
@@ -1195,13 +1220,32 @@ async def yookassa_webhook_route(request: web.Request):
|
|||||||
get_back_to_payment_methods_keyboard,
|
get_back_to_payment_methods_keyboard,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
message_text = _("payment_method_bound_success")
|
||||||
|
try:
|
||||||
await bot.send_message(
|
await bot.send_message(
|
||||||
chat_id=user_id,
|
chat_id=user_id,
|
||||||
text=_("payment_method_bound_success"),
|
text=message_text,
|
||||||
reply_markup=get_back_to_payment_methods_keyboard(
|
reply_markup=get_back_to_payment_methods_keyboard(
|
||||||
i18n_lang, i18n_instance
|
i18n_lang, i18n_instance
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
except Exception:
|
||||||
|
logging.exception(
|
||||||
|
"Failed to notify user %s "
|
||||||
|
"about payment method binding.",
|
||||||
|
user_id,
|
||||||
|
)
|
||||||
|
if db_user:
|
||||||
|
await send_user_notification_email(
|
||||||
|
settings=settings,
|
||||||
|
i18n=i18n_instance,
|
||||||
|
user=db_user,
|
||||||
|
subject_key="email_payment_method_bound_subject",
|
||||||
|
message_text=message_text,
|
||||||
|
dashboard_url=(
|
||||||
|
settings.SUBSCRIPTION_MINI_APP_URL or None
|
||||||
|
),
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
# Attempt to cancel the authorization to avoid charge hold
|
# Attempt to cancel the authorization to avoid charge hold
|
||||||
@@ -1327,9 +1371,7 @@ async def _initiate_yk_payment(
|
|||||||
"purchased_gb": float(months)
|
"purchased_gb": float(months)
|
||||||
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
|
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
|
||||||
else None,
|
else None,
|
||||||
"purchased_hwid_devices": int(months)
|
"purchased_hwid_devices": int(months) if sale_base in HWID_DEVICE_SALE_BASES else None,
|
||||||
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_from": hwid_quote.get("valid_from") if hwid_quote else None,
|
||||||
"hwid_valid_until": hwid_quote.get("valid_until") if hwid_quote else None,
|
"hwid_valid_until": hwid_quote.get("valid_until") if hwid_quote else None,
|
||||||
"hwid_pricing_period_months": hwid_quote.get("pricing_period_months")
|
"hwid_pricing_period_months": hwid_quote.get("pricing_period_months")
|
||||||
@@ -2684,6 +2726,7 @@ def create_service(ctx: ServiceFactoryContext) -> YooKassaService:
|
|||||||
bot_username_for_default_return=ctx.bot_username_for_default_return,
|
bot_username_for_default_return=ctx.bot_username_for_default_return,
|
||||||
settings_obj=ctx.settings,
|
settings_obj=ctx.settings,
|
||||||
config=config,
|
config=config,
|
||||||
|
subscription_service=ctx.subscription_service,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,642 @@
|
|||||||
|
import asyncio
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import zipfile
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path, PurePosixPath
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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$")
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
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,
|
||||||
|
"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
|
||||||
|
if db_member is not None:
|
||||||
|
dump_path = self._extract_database_dump(archive, db_member, temp_dir)
|
||||||
|
self._run_pg_restore(dump_path)
|
||||||
|
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,
|
||||||
|
warnings=warnings,
|
||||||
|
)
|
||||||
|
|
||||||
|
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")
|
||||||
@@ -202,6 +202,41 @@ def _format_traffic(traffic_gb: Optional[float]) -> str:
|
|||||||
return str(int(value)) if value.is_integer() else f"{value:g}"
|
return str(int(value)) if value.is_integer() else f"{value:g}"
|
||||||
|
|
||||||
|
|
||||||
|
_ALLOWED_INLINE_TAGS = {
|
||||||
|
"b": "strong",
|
||||||
|
"strong": "strong",
|
||||||
|
"i": "em",
|
||||||
|
"em": "em",
|
||||||
|
"u": "u",
|
||||||
|
"s": "s",
|
||||||
|
"code": "code",
|
||||||
|
}
|
||||||
|
_INLINE_TAG_RE = re.compile(r"</?(?:b|strong|i|em|u|s|code)>", re.IGNORECASE)
|
||||||
|
_ANY_TAG_RE = re.compile(r"<[^>]+>")
|
||||||
|
|
||||||
|
|
||||||
|
def _telegram_html_to_email_html(value: str) -> str:
|
||||||
|
"""Escape arbitrary text while preserving the tiny Telegram HTML subset we use."""
|
||||||
|
source = str(value or "")
|
||||||
|
chunks: list[str] = []
|
||||||
|
cursor = 0
|
||||||
|
for match in _INLINE_TAG_RE.finditer(source):
|
||||||
|
chunks.append(html.escape(source[cursor : match.start()]))
|
||||||
|
raw_tag = match.group(0)
|
||||||
|
closing = raw_tag.startswith("</")
|
||||||
|
tag_name = raw_tag.strip("</>").lower()
|
||||||
|
mapped = _ALLOWED_INLINE_TAGS.get(tag_name)
|
||||||
|
if mapped:
|
||||||
|
chunks.append(f"</{mapped}>" if closing else f"<{mapped}>")
|
||||||
|
cursor = match.end()
|
||||||
|
chunks.append(html.escape(source[cursor:]))
|
||||||
|
return "".join(chunks).replace("\n", "<br>")
|
||||||
|
|
||||||
|
|
||||||
|
def _telegram_html_to_text(value: str) -> str:
|
||||||
|
return html.unescape(_ANY_TAG_RE.sub("", str(value or "")))
|
||||||
|
|
||||||
|
|
||||||
def _format_minutes(seconds: int) -> int:
|
def _format_minutes(seconds: int) -> int:
|
||||||
return max(1, int(seconds) // 60)
|
return max(1, int(seconds) // 60)
|
||||||
|
|
||||||
@@ -351,12 +386,15 @@ def render_payment_success(
|
|||||||
lang = _normalize_lang(language_code, settings)
|
lang = _normalize_lang(language_code, settings)
|
||||||
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
|
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
|
||||||
brand = _brand_title(settings)
|
brand = _brand_title(settings)
|
||||||
is_traffic = (sale_mode or "").split("@", 1)[0].split("|", 1)[0] in {
|
sale_base = (sale_mode or "").split("@", 1)[0].split("|", 1)[0]
|
||||||
|
is_traffic = sale_base in {
|
||||||
"traffic",
|
"traffic",
|
||||||
"traffic_package",
|
"traffic_package",
|
||||||
"topup",
|
"topup",
|
||||||
"premium_topup",
|
"premium_topup",
|
||||||
}
|
}
|
||||||
|
is_hwid = sale_base in {"hwid_device", "hwid_devices", "hwid_devices_renewal"}
|
||||||
|
is_tariff_upgrade = sale_base == "tariff_upgrade"
|
||||||
amount_text = _format_amount(amount, currency)
|
amount_text = _format_amount(amount, currency)
|
||||||
safe_dashboard_url = (dashboard_url or "").strip()
|
safe_dashboard_url = (dashboard_url or "").strip()
|
||||||
end_date = end_date_text or "—"
|
end_date = end_date_text or "—"
|
||||||
@@ -370,7 +408,12 @@ def render_payment_success(
|
|||||||
cta_label = _t_text(i18n, lang, "email_payment_success_cta")
|
cta_label = _t_text(i18n, lang, "email_payment_success_cta")
|
||||||
|
|
||||||
if is_traffic:
|
if is_traffic:
|
||||||
intro = _t_text(i18n, lang, "email_payment_success_intro_traffic", traffic_gb=traffic_label)
|
intro_key = (
|
||||||
|
"email_payment_success_intro_premium_topup"
|
||||||
|
if sale_base == "premium_topup"
|
||||||
|
else "email_payment_success_intro_traffic"
|
||||||
|
)
|
||||||
|
intro = _t_text(i18n, lang, intro_key, traffic_gb=traffic_label)
|
||||||
period_label = _t_text(i18n, lang, "email_payment_success_row_traffic")
|
period_label = _t_text(i18n, lang, "email_payment_success_row_traffic")
|
||||||
period_value = _t_text(
|
period_value = _t_text(
|
||||||
i18n, lang, "email_payment_success_traffic_value", traffic_gb=traffic_label
|
i18n, lang, "email_payment_success_traffic_value", traffic_gb=traffic_label
|
||||||
@@ -383,6 +426,30 @@ def render_payment_success(
|
|||||||
traffic_gb=traffic_label,
|
traffic_gb=traffic_label,
|
||||||
end_date=end_date,
|
end_date=end_date,
|
||||||
)
|
)
|
||||||
|
elif is_hwid:
|
||||||
|
devices_count = max(0, int(months or 0))
|
||||||
|
intro = _t_text(i18n, lang, "email_payment_success_intro_hwid", count=devices_count)
|
||||||
|
period_label = _t_text(i18n, lang, "email_payment_success_row_hwid")
|
||||||
|
period_value = _t_text(i18n, lang, "email_payment_success_hwid_value", count=devices_count)
|
||||||
|
text = _t_text(
|
||||||
|
i18n,
|
||||||
|
lang,
|
||||||
|
"email_payment_success_text_hwid",
|
||||||
|
amount=amount_text,
|
||||||
|
count=devices_count,
|
||||||
|
end_date=end_date,
|
||||||
|
)
|
||||||
|
elif is_tariff_upgrade:
|
||||||
|
intro = _t_text(i18n, lang, "email_payment_success_intro_tariff_upgrade")
|
||||||
|
period_label = _t_text(i18n, lang, "email_payment_success_row_operation")
|
||||||
|
period_value = _t_text(i18n, lang, "email_payment_success_tariff_upgrade_value")
|
||||||
|
text = _t_text(
|
||||||
|
i18n,
|
||||||
|
lang,
|
||||||
|
"email_payment_success_text_tariff_upgrade",
|
||||||
|
amount=amount_text,
|
||||||
|
end_date=end_date,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
months_int = int(months or 0)
|
months_int = int(months or 0)
|
||||||
intro = _t_text(i18n, lang, "email_payment_success_intro_subscription", months=months_int)
|
intro = _t_text(i18n, lang, "email_payment_success_intro_subscription", months=months_int)
|
||||||
@@ -434,6 +501,66 @@ def render_payment_success(
|
|||||||
return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered)
|
return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered)
|
||||||
|
|
||||||
|
|
||||||
|
def render_user_notification(
|
||||||
|
settings: Settings,
|
||||||
|
*,
|
||||||
|
language_code: Optional[str],
|
||||||
|
subject: str,
|
||||||
|
message_text: str,
|
||||||
|
dashboard_url: Optional[str] = None,
|
||||||
|
cta_label: Optional[str] = None,
|
||||||
|
heading: Optional[str] = None,
|
||||||
|
intro: Optional[str] = None,
|
||||||
|
i18n: Optional[JsonI18n] = None,
|
||||||
|
) -> EmailContent:
|
||||||
|
i18n = _resolve_i18n(i18n)
|
||||||
|
lang = _normalize_lang(language_code, settings)
|
||||||
|
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
|
||||||
|
brand = _brand_title(settings)
|
||||||
|
safe_dashboard_url = (dashboard_url or "").strip()
|
||||||
|
final_subject = (subject or "").strip() or _t_text(
|
||||||
|
i18n, lang, "email_user_notification_subject"
|
||||||
|
)
|
||||||
|
final_heading = (heading or "").strip() or final_subject
|
||||||
|
final_intro = (intro or "").strip() or _t_text(i18n, lang, "email_user_notification_intro")
|
||||||
|
final_cta_label = (cta_label or "").strip() or _t_text(
|
||||||
|
i18n,
|
||||||
|
lang,
|
||||||
|
"email_user_notification_cta",
|
||||||
|
)
|
||||||
|
footer = _t_html(i18n, lang, "email_footer_auto", brand=brand)
|
||||||
|
message_html = (
|
||||||
|
f'<div style="margin:0 0 16px 0;background:{_BG};border:1px solid {_BORDER};'
|
||||||
|
f"border-radius:14px;padding:14px 16px;font-size:14px;line-height:1.55;color:{_TEXT};"
|
||||||
|
f'white-space:pre-wrap;">{_telegram_html_to_email_html(message_text)}</div>'
|
||||||
|
)
|
||||||
|
body_parts = [message_html]
|
||||||
|
if safe_dashboard_url:
|
||||||
|
body_parts.append(
|
||||||
|
_cta_button_html(label=final_cta_label, url=safe_dashboard_url, accent=accent)
|
||||||
|
)
|
||||||
|
|
||||||
|
rendered = _layout(
|
||||||
|
settings=settings,
|
||||||
|
preheader=final_subject,
|
||||||
|
heading=final_heading,
|
||||||
|
intro_html=html.escape(final_intro),
|
||||||
|
body_html="".join(body_parts),
|
||||||
|
footer_html=footer,
|
||||||
|
)
|
||||||
|
text_lines = [final_subject, "", _telegram_html_to_text(message_text)]
|
||||||
|
if safe_dashboard_url:
|
||||||
|
text_lines.extend(
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
_t_text(
|
||||||
|
i18n, lang, "email_user_notification_text_dashboard", url=safe_dashboard_url
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return EmailContent(subject=final_subject, text="\n".join(text_lines), html=rendered)
|
||||||
|
|
||||||
|
|
||||||
def render_subscription_expiring(
|
def render_subscription_expiring(
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
*,
|
*,
|
||||||
@@ -497,6 +624,107 @@ def render_subscription_expiring(
|
|||||||
return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered)
|
return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered)
|
||||||
|
|
||||||
|
|
||||||
|
def _subscription_lifecycle_title(
|
||||||
|
i18n: JsonI18n,
|
||||||
|
lang: str,
|
||||||
|
notification_key: str,
|
||||||
|
*,
|
||||||
|
days_left: Optional[int],
|
||||||
|
hours_before: Optional[int],
|
||||||
|
) -> str:
|
||||||
|
if notification_key == "before_2d_autorenew":
|
||||||
|
return _t_text(i18n, lang, "email_subscription_lifecycle_subject_autorenew")
|
||||||
|
if notification_key == "expired":
|
||||||
|
return _t_text(i18n, lang, "email_subscription_lifecycle_subject_expired")
|
||||||
|
if notification_key == "expired_24h_after":
|
||||||
|
return _t_text(i18n, lang, "email_subscription_lifecycle_subject_expired_after")
|
||||||
|
if hours_before is not None:
|
||||||
|
return _t_text(
|
||||||
|
i18n,
|
||||||
|
lang,
|
||||||
|
"email_subscription_lifecycle_subject_before_hours",
|
||||||
|
hours=hours_before,
|
||||||
|
)
|
||||||
|
return _t_text(
|
||||||
|
i18n,
|
||||||
|
lang,
|
||||||
|
"email_subscription_lifecycle_subject_before_days",
|
||||||
|
days=max(0, int(days_left or 0)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def render_subscription_lifecycle_notification(
|
||||||
|
settings: Settings,
|
||||||
|
*,
|
||||||
|
language_code: Optional[str],
|
||||||
|
notification_key: str,
|
||||||
|
message_text: str,
|
||||||
|
end_date_text: str,
|
||||||
|
dashboard_url: Optional[str],
|
||||||
|
mirrored_from_telegram: bool = False,
|
||||||
|
days_left: Optional[int] = None,
|
||||||
|
hours_before: Optional[int] = None,
|
||||||
|
i18n: Optional[JsonI18n] = None,
|
||||||
|
) -> EmailContent:
|
||||||
|
i18n = _resolve_i18n(i18n)
|
||||||
|
lang = _normalize_lang(language_code, settings)
|
||||||
|
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
|
||||||
|
brand = _brand_title(settings)
|
||||||
|
safe_dashboard_url = (dashboard_url or "").strip()
|
||||||
|
end_date = end_date_text or "—"
|
||||||
|
subject = _subscription_lifecycle_title(
|
||||||
|
i18n,
|
||||||
|
lang,
|
||||||
|
notification_key,
|
||||||
|
days_left=days_left,
|
||||||
|
hours_before=hours_before,
|
||||||
|
)
|
||||||
|
intro_key = (
|
||||||
|
"email_subscription_lifecycle_intro_mirrored"
|
||||||
|
if mirrored_from_telegram
|
||||||
|
else "email_subscription_lifecycle_intro_direct"
|
||||||
|
)
|
||||||
|
intro = _t_text(i18n, lang, intro_key)
|
||||||
|
footer = _t_html(i18n, lang, "email_footer_auto", brand=brand)
|
||||||
|
cta_label = _t_text(i18n, lang, "email_subscription_lifecycle_cta")
|
||||||
|
|
||||||
|
rows = [
|
||||||
|
(_t_text(i18n, lang, "email_subscription_lifecycle_row_end_date"), end_date),
|
||||||
|
]
|
||||||
|
message_html = (
|
||||||
|
f'<div style="margin:0 0 16px 0;background:{_BG};border:1px solid {_BORDER};'
|
||||||
|
f"border-radius:14px;padding:14px 16px;font-size:14px;line-height:1.55;color:{_TEXT};"
|
||||||
|
f'white-space:pre-wrap;">{html.escape(message_text or "")}</div>'
|
||||||
|
)
|
||||||
|
body_parts = [_info_rows_html(rows), message_html]
|
||||||
|
if safe_dashboard_url:
|
||||||
|
body_parts.append(_cta_button_html(label=cta_label, url=safe_dashboard_url, accent=accent))
|
||||||
|
|
||||||
|
rendered = _layout(
|
||||||
|
settings=settings,
|
||||||
|
preheader=subject,
|
||||||
|
heading=subject,
|
||||||
|
intro_html=html.escape(intro),
|
||||||
|
body_html="".join(body_parts),
|
||||||
|
footer_html=footer,
|
||||||
|
)
|
||||||
|
|
||||||
|
text_lines = [subject, "", message_text]
|
||||||
|
if safe_dashboard_url:
|
||||||
|
text_lines.extend(
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
_t_text(
|
||||||
|
i18n,
|
||||||
|
lang,
|
||||||
|
"email_subscription_lifecycle_text_renew",
|
||||||
|
url=safe_dashboard_url,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered)
|
||||||
|
|
||||||
|
|
||||||
def _support_email(
|
def _support_email(
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
i18n: Optional[JsonI18n],
|
i18n: Optional[JsonI18n],
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ LOCALE_GROUPS = [
|
|||||||
"admin_sort_",
|
"admin_sort_",
|
||||||
"admin_status_",
|
"admin_status_",
|
||||||
"admin_badge_",
|
"admin_badge_",
|
||||||
|
"admin_backups_",
|
||||||
"admin_aria_",
|
"admin_aria_",
|
||||||
"admin_search",
|
"admin_search",
|
||||||
"admin_clear",
|
"admin_clear",
|
||||||
@@ -257,6 +258,7 @@ LOCALE_GROUPS = [
|
|||||||
"audience": "internal",
|
"audience": "internal",
|
||||||
"prefixes": (
|
"prefixes": (
|
||||||
"admin_settings_field_log_",
|
"admin_settings_field_log_",
|
||||||
|
"admin_settings_field_backup_",
|
||||||
"admin_settings_field_support_",
|
"admin_settings_field_support_",
|
||||||
"admin_settings_field_subscription_notifications_",
|
"admin_settings_field_subscription_notifications_",
|
||||||
"admin_settings_field_subscription_notify_",
|
"admin_settings_field_subscription_notify_",
|
||||||
|
|||||||
@@ -328,12 +328,112 @@ class PanelApiService:
|
|||||||
async def _get_user_by_uuid_uncached(
|
async def _get_user_by_uuid_uncached(
|
||||||
self, user_uuid: str, log_response: bool = False
|
self, user_uuid: str, log_response: bool = False
|
||||||
) -> Optional[Dict[str, Any]]:
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
lookup = await self.get_user_by_uuid_lookup(user_uuid, log_response=log_response)
|
||||||
|
if lookup.get("ok") and isinstance(lookup.get("user"), dict):
|
||||||
|
return lookup["user"]
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _panel_response_details(response_data: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
||||||
|
if not isinstance(response_data, dict):
|
||||||
|
return {}
|
||||||
|
details = response_data.get("details")
|
||||||
|
return details if isinstance(details, dict) else {}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _panel_response_error_code(cls, response_data: Optional[Dict[str, Any]]) -> Optional[str]:
|
||||||
|
if not isinstance(response_data, dict):
|
||||||
|
return None
|
||||||
|
details = cls._panel_response_details(response_data)
|
||||||
|
error_code = (
|
||||||
|
response_data.get("errorCode")
|
||||||
|
or response_data.get("code")
|
||||||
|
or details.get("errorCode")
|
||||||
|
or details.get("code")
|
||||||
|
)
|
||||||
|
return str(error_code) if error_code else None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _panel_response_message(cls, response_data: Optional[Dict[str, Any]]) -> Optional[str]:
|
||||||
|
if not isinstance(response_data, dict):
|
||||||
|
return None
|
||||||
|
details = cls._panel_response_details(response_data)
|
||||||
|
message = (
|
||||||
|
response_data.get("message")
|
||||||
|
or details.get("message")
|
||||||
|
or details.get("error")
|
||||||
|
or details.get("raw_response_text")
|
||||||
|
)
|
||||||
|
if message is None:
|
||||||
|
return None
|
||||||
|
message = str(message).replace("\n", " ").strip()
|
||||||
|
return message[:500] if message else None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _is_user_not_found_response(cls, response_data: Optional[Dict[str, Any]]) -> bool:
|
||||||
|
if not isinstance(response_data, dict):
|
||||||
|
return False
|
||||||
|
status_code = response_data.get("status_code")
|
||||||
|
error_code = cls._panel_response_error_code(response_data)
|
||||||
|
if error_code in {"A040", "A062", "USER_NOT_FOUND", "NOT_FOUND"}:
|
||||||
|
return True
|
||||||
|
return status_code == 404
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _describe_user_lookup_failure(
|
||||||
|
cls,
|
||||||
|
response_data: Optional[Dict[str, Any]],
|
||||||
|
*,
|
||||||
|
not_found: bool,
|
||||||
|
) -> str:
|
||||||
|
if not isinstance(response_data, dict):
|
||||||
|
return "classification=panel_lookup_failed response=empty"
|
||||||
|
|
||||||
|
classification = "confirmed_not_found" if not_found else "panel_lookup_failed"
|
||||||
|
parts = [f"classification={classification}"]
|
||||||
|
status_code = response_data.get("status_code")
|
||||||
|
if status_code is not None:
|
||||||
|
parts.append(f"status_code={status_code}")
|
||||||
|
error_code = cls._panel_response_error_code(response_data)
|
||||||
|
if error_code:
|
||||||
|
parts.append(f"error_code={error_code}")
|
||||||
|
message = cls._panel_response_message(response_data)
|
||||||
|
if message:
|
||||||
|
parts.append(f"message={message}")
|
||||||
|
return " ".join(parts)
|
||||||
|
|
||||||
|
async def get_user_by_uuid_lookup(
|
||||||
|
self, user_uuid: str, log_response: bool = False
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Fetch a panel user and preserve whether a miss was confirmed.
|
||||||
|
|
||||||
|
``get_user_by_uuid`` historically returned ``None`` both for a real
|
||||||
|
404/not-found and for transient panel/API failures. Callers that may
|
||||||
|
mutate local state need this richer result to avoid treating an outage
|
||||||
|
as a deleted panel user.
|
||||||
|
"""
|
||||||
endpoint = f"/users/{user_uuid}"
|
endpoint = f"/users/{user_uuid}"
|
||||||
full_response = await self._request("GET", endpoint, log_full_response=log_response)
|
full_response = await self._request("GET", endpoint, log_full_response=log_response)
|
||||||
if full_response and not full_response.get("error") and "response" in full_response:
|
if full_response and not full_response.get("error") and "response" in full_response:
|
||||||
return full_response.get("response")
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"user": full_response.get("response"),
|
||||||
|
"not_found": False,
|
||||||
|
"failure_reason": None,
|
||||||
|
"response": full_response,
|
||||||
|
}
|
||||||
|
|
||||||
return None
|
not_found = self._is_user_not_found_response(full_response)
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"user": None,
|
||||||
|
"not_found": not_found,
|
||||||
|
"failure_reason": self._describe_user_lookup_failure(
|
||||||
|
full_response,
|
||||||
|
not_found=not_found,
|
||||||
|
),
|
||||||
|
"response": full_response,
|
||||||
|
}
|
||||||
|
|
||||||
async def get_user(
|
async def get_user(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -3,12 +3,15 @@ import hashlib
|
|||||||
import hmac
|
import hmac
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from aiogram import Bot
|
from aiogram import Bot
|
||||||
from aiogram.types import InlineKeyboardMarkup
|
from aiogram.types import InlineKeyboardMarkup
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy import or_, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload, sessionmaker
|
||||||
|
|
||||||
from bot.infra.webhook_queue import enqueue_webhook_event
|
from bot.infra.webhook_queue import enqueue_webhook_event
|
||||||
from bot.keyboards.inline.user_keyboards import (
|
from bot.keyboards.inline.user_keyboards import (
|
||||||
@@ -16,18 +19,40 @@ from bot.keyboards.inline.user_keyboards import (
|
|||||||
get_subscribe_only_markup,
|
get_subscribe_only_markup,
|
||||||
)
|
)
|
||||||
from bot.middlewares.i18n import JsonI18n
|
from bot.middlewares.i18n import JsonI18n
|
||||||
|
from bot.services.subscription_lifecycle_notifications import (
|
||||||
|
SubscriptionLifecycleNotificationService,
|
||||||
|
SubscriptionNotificationStage,
|
||||||
|
)
|
||||||
from config.settings import Settings
|
from config.settings import Settings
|
||||||
from db.dal import tariff_dal, user_dal
|
from db.dal import subscription_dal, tariff_dal, user_dal
|
||||||
|
from db.models import Subscription, User
|
||||||
|
|
||||||
from .email_auth_service import EmailAuthService
|
|
||||||
from .email_templates import render_subscription_expiring
|
|
||||||
from .panel_api_service import PanelApiService
|
from .panel_api_service import PanelApiService
|
||||||
|
|
||||||
EVENT_MAP = {
|
EVENT_MAP = {
|
||||||
"user.expires_in_72_hours": (3, "subscription_72h_notification"),
|
"user.expires_in_72_hours": SubscriptionNotificationStage(
|
||||||
"user.expires_in_48_hours": (2, "subscription_48h_notification"),
|
key="before_3d",
|
||||||
"user.expires_in_24_hours": (1, "subscription_24h_notification"),
|
message_key="subscription_72h_notification",
|
||||||
|
days_left=3,
|
||||||
|
),
|
||||||
|
"user.expires_in_48_hours": SubscriptionNotificationStage(
|
||||||
|
key="before_2d",
|
||||||
|
message_key="subscription_48h_notification",
|
||||||
|
days_left=2,
|
||||||
|
),
|
||||||
|
"user.expires_in_24_hours": SubscriptionNotificationStage(
|
||||||
|
key="before_1d",
|
||||||
|
message_key="subscription_24h_notification",
|
||||||
|
days_left=1,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
ACTIONABLE_EVENTS = frozenset(
|
||||||
|
{
|
||||||
|
*EVENT_MAP.keys(),
|
||||||
|
"user.expired",
|
||||||
|
"user.expired_24_hours_ago",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class PanelWebhookService:
|
class PanelWebhookService:
|
||||||
@@ -48,6 +73,11 @@ class PanelWebhookService:
|
|||||||
self.i18n = i18n
|
self.i18n = i18n
|
||||||
self.async_session_factory = async_session_factory
|
self.async_session_factory = async_session_factory
|
||||||
self.panel_service = panel_service
|
self.panel_service = panel_service
|
||||||
|
self.lifecycle_notifications = SubscriptionLifecycleNotificationService(
|
||||||
|
settings,
|
||||||
|
bot,
|
||||||
|
i18n,
|
||||||
|
)
|
||||||
self._event_semaphore = asyncio.Semaphore(self._MAX_CONCURRENT_EVENTS)
|
self._event_semaphore = asyncio.Semaphore(self._MAX_CONCURRENT_EVENTS)
|
||||||
if not self.settings.PANEL_WEBHOOK_SECRET:
|
if not self.settings.PANEL_WEBHOOK_SECRET:
|
||||||
logging.error(
|
logging.error(
|
||||||
@@ -68,9 +98,7 @@ class PanelWebhookService:
|
|||||||
text = _(message_key, **kwargs)
|
text = _(message_key, **kwargs)
|
||||||
if extra_text:
|
if extra_text:
|
||||||
text = f"{text}\n\n{extra_text}"
|
text = f"{text}\n\n{extra_text}"
|
||||||
await self.bot.send_message(
|
await self.bot.send_message(user_id, text, reply_markup=reply_markup)
|
||||||
user_id, text, reply_markup=reply_markup
|
|
||||||
)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
logging.exception("Failed to send notification to %s", user_id)
|
logging.exception("Failed to send notification to %s", user_id)
|
||||||
|
|
||||||
@@ -104,112 +132,200 @@ class PanelWebhookService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def handle_event(self, event_name: str, user_payload: dict):
|
async def handle_event(self, event_name: str, user_payload: dict):
|
||||||
telegram_id = user_payload.get("telegramId")
|
|
||||||
if not telegram_id:
|
|
||||||
logging.warning("Panel webhook without telegramId received")
|
|
||||||
return
|
|
||||||
user_id = int(telegram_id)
|
|
||||||
|
|
||||||
if not self.settings.SUBSCRIPTION_NOTIFICATIONS_ENABLED:
|
if not self.settings.SUBSCRIPTION_NOTIFICATIONS_ENABLED:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if event_name not in ACTIONABLE_EVENTS:
|
||||||
|
logging.info(
|
||||||
|
"Panel webhook event %s ignored: event is not used for subscription "
|
||||||
|
"notifications; %s",
|
||||||
|
event_name,
|
||||||
|
self._payload_log_context(user_payload),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
async with self.async_session_factory() as session:
|
async with self.async_session_factory() as session:
|
||||||
db_user = await user_dal.get_user_by_telegram_id(session, user_id)
|
db_user = await self._user_for_payload(session, user_payload)
|
||||||
if not db_user:
|
sub = await self._subscription_for_payload(session, user_payload, db_user)
|
||||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
telegram_id = self._payload_telegram_id(user_payload)
|
||||||
internal_user_id = db_user.user_id if db_user else user_id
|
internal_user_id = (
|
||||||
|
int(db_user.user_id)
|
||||||
|
if db_user
|
||||||
|
else int(getattr(sub, "user_id", 0) or telegram_id or 0)
|
||||||
|
)
|
||||||
lang = (
|
lang = (
|
||||||
db_user.language_code
|
db_user.language_code
|
||||||
if db_user and db_user.language_code
|
if db_user and db_user.language_code
|
||||||
else self.settings.DEFAULT_LANGUAGE
|
else self.settings.DEFAULT_LANGUAGE
|
||||||
)
|
)
|
||||||
first_name = db_user.first_name or f"User {user_id}" if db_user else f"User {user_id}"
|
if not sub:
|
||||||
user_email = (db_user.email or "").strip() if db_user else ""
|
if not telegram_id:
|
||||||
|
local_user_id = getattr(db_user, "user_id", None) if db_user else None
|
||||||
|
logging.warning(
|
||||||
|
"Panel webhook event %s cannot be matched to a local subscription; "
|
||||||
|
"notification skipped. %s local_user_id=%s. Possible causes: "
|
||||||
|
"panel user was created outside the bot, subscription was deleted "
|
||||||
|
"or not synced, panel identifiers changed, or skip_notifications "
|
||||||
|
"is enabled for the local subscription.",
|
||||||
|
event_name,
|
||||||
|
self._payload_log_context(user_payload),
|
||||||
|
local_user_id or "N/A",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
await self._send_legacy_without_dedupe(
|
||||||
|
event_name,
|
||||||
|
user_payload,
|
||||||
|
int(telegram_id),
|
||||||
|
lang,
|
||||||
|
db_user,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
markup = get_subscribe_only_markup(lang, self.i18n)
|
markup = get_subscribe_only_markup(lang, self.i18n)
|
||||||
|
end_date_text = self._payload_expire_date(user_payload)
|
||||||
|
|
||||||
if event_name in EVENT_MAP:
|
if event_name in EVENT_MAP:
|
||||||
days_left, msg_key = EVENT_MAP[event_name]
|
stage = EVENT_MAP[event_name]
|
||||||
|
days_left = int(stage.days_left or 0)
|
||||||
hwid_renewal_note = await self._hwid_renewal_note(internal_user_id, lang)
|
hwid_renewal_note = await self._hwid_renewal_note(internal_user_id, lang)
|
||||||
if days_left == 1:
|
if days_left == 1:
|
||||||
# Trigger auto-renew via SubscriptionService (wired in at factory)
|
# Trigger auto-renew via SubscriptionService (wired in at factory)
|
||||||
try:
|
try:
|
||||||
subscription_service = getattr(self, "subscription_service", None)
|
subscription_service = getattr(self, "subscription_service", None)
|
||||||
if subscription_service:
|
if subscription_service:
|
||||||
async with self.async_session_factory() as session:
|
async with self.async_session_factory() as renewal_session:
|
||||||
from db.dal import subscription_dal
|
active_sub = (
|
||||||
|
await subscription_dal.get_active_subscription_by_user_id(
|
||||||
sub = await subscription_dal.get_active_subscription_by_user_id(
|
renewal_session,
|
||||||
session, internal_user_id
|
internal_user_id,
|
||||||
)
|
)
|
||||||
if sub and sub.auto_renew_enabled and sub.provider == "yookassa":
|
)
|
||||||
|
if (
|
||||||
|
active_sub
|
||||||
|
and active_sub.auto_renew_enabled
|
||||||
|
and active_sub.provider == "yookassa"
|
||||||
|
):
|
||||||
try:
|
try:
|
||||||
ok = await subscription_service.charge_subscription_renewal(
|
ok = await subscription_service.charge_subscription_renewal(
|
||||||
session, sub
|
renewal_session,
|
||||||
|
active_sub,
|
||||||
)
|
)
|
||||||
# If initiation succeeded, suppress the 24h reminder by returning early # noqa: E501
|
# If initiation succeeded, suppress the 24h reminder by returning early # noqa: E501
|
||||||
if ok:
|
if ok:
|
||||||
await session.commit()
|
await renewal_session.commit()
|
||||||
return
|
return
|
||||||
else:
|
await renewal_session.rollback()
|
||||||
await session.rollback()
|
|
||||||
except Exception:
|
except Exception:
|
||||||
await session.rollback()
|
await renewal_session.rollback()
|
||||||
logging.exception("Auto-renew attempt (24h) failed")
|
logging.exception("Auto-renew attempt (24h) failed")
|
||||||
except Exception:
|
except Exception:
|
||||||
logging.exception("Auto-renew trigger (24h) failed pre-check")
|
logging.exception("Auto-renew trigger (24h) failed pre-check")
|
||||||
if days_left <= self.settings.SUBSCRIPTION_NOTIFY_DAYS_BEFORE:
|
if days_left <= self.settings.SUBSCRIPTION_NOTIFY_DAYS_BEFORE:
|
||||||
# For 48h event, if auto-renew is enabled, show special notice with cancel button
|
# For 48h, auto-renew users get a cancel button instead.
|
||||||
if days_left == 2:
|
if days_left == 2:
|
||||||
async with self.async_session_factory() as session:
|
active_sub = await subscription_dal.get_active_subscription_by_user_id(
|
||||||
from db.dal import subscription_dal
|
session,
|
||||||
|
internal_user_id,
|
||||||
sub = await subscription_dal.get_active_subscription_by_user_id(
|
|
||||||
session, internal_user_id
|
|
||||||
)
|
)
|
||||||
logging.info(
|
logging.info(
|
||||||
"48h webhook check: user_id=%s sub_found=%s auto_renew=%s provider=%s",
|
"48h webhook check: user_id=%s sub_found=%s auto_renew=%s provider=%s",
|
||||||
user_id,
|
internal_user_id,
|
||||||
bool(sub),
|
bool(active_sub),
|
||||||
getattr(sub, "auto_renew_enabled", None) if sub else None,
|
getattr(active_sub, "auto_renew_enabled", None) if active_sub else None,
|
||||||
getattr(sub, "provider", None) if sub else None,
|
getattr(active_sub, "provider", None) if active_sub else None,
|
||||||
)
|
)
|
||||||
if sub and sub.auto_renew_enabled and sub.provider == "yookassa":
|
if (
|
||||||
|
active_sub
|
||||||
|
and active_sub.auto_renew_enabled
|
||||||
|
and active_sub.provider == "yookassa"
|
||||||
|
):
|
||||||
cancel_kb = get_autorenew_cancel_keyboard(lang, self.i18n)
|
cancel_kb = get_autorenew_cancel_keyboard(lang, self.i18n)
|
||||||
await self._send_message(
|
await self.lifecycle_notifications.send_stage(
|
||||||
user_id,
|
session,
|
||||||
lang,
|
sub,
|
||||||
"autorenew_48h_charge_tomorrow_notice",
|
SubscriptionNotificationStage(
|
||||||
reply_markup=cancel_kb,
|
key="before_2d_autorenew",
|
||||||
user_name=first_name,
|
message_key="autorenew_48h_charge_tomorrow_notice",
|
||||||
|
days_left=2,
|
||||||
|
),
|
||||||
|
user=db_user,
|
||||||
|
telegram_markup=cancel_kb,
|
||||||
extra_text=hwid_renewal_note,
|
extra_text=hwid_renewal_note,
|
||||||
|
end_date_text=end_date_text,
|
||||||
)
|
)
|
||||||
|
await session.commit()
|
||||||
return
|
return
|
||||||
await self._send_message(
|
await self.lifecycle_notifications.send_stage(
|
||||||
user_id,
|
session,
|
||||||
lang,
|
sub,
|
||||||
msg_key,
|
stage,
|
||||||
reply_markup=markup,
|
user=db_user,
|
||||||
user_name=first_name,
|
telegram_markup=markup,
|
||||||
end_date=user_payload.get("expireAt", "")[:10],
|
|
||||||
extra_text=hwid_renewal_note,
|
extra_text=hwid_renewal_note,
|
||||||
|
end_date_text=end_date_text,
|
||||||
)
|
)
|
||||||
if days_left == 3 and user_email:
|
await session.commit()
|
||||||
await self._send_subscription_expiring_email(
|
|
||||||
recipient=user_email,
|
|
||||||
lang=lang,
|
|
||||||
days_left=days_left,
|
|
||||||
end_date_text=user_payload.get("expireAt", "")[:10],
|
|
||||||
)
|
|
||||||
elif event_name == "user.expired":
|
elif event_name == "user.expired":
|
||||||
if self.settings.SUBSCRIPTION_NOTIFY_ON_EXPIRE:
|
if self.settings.SUBSCRIPTION_NOTIFY_ON_EXPIRE:
|
||||||
|
await self.lifecycle_notifications.send_stage(
|
||||||
|
session,
|
||||||
|
sub,
|
||||||
|
SubscriptionNotificationStage(
|
||||||
|
key="expired",
|
||||||
|
message_key="subscription_expired_notification",
|
||||||
|
days_left=0,
|
||||||
|
),
|
||||||
|
user=db_user,
|
||||||
|
telegram_markup=markup,
|
||||||
|
end_date_text=end_date_text,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
elif (
|
||||||
|
event_name == "user.expired_24_hours_ago"
|
||||||
|
and self.settings.SUBSCRIPTION_NOTIFY_AFTER_EXPIRE
|
||||||
|
):
|
||||||
|
await self.lifecycle_notifications.send_stage(
|
||||||
|
session,
|
||||||
|
sub,
|
||||||
|
SubscriptionNotificationStage(
|
||||||
|
key="expired_24h_after",
|
||||||
|
message_key="subscription_expired_yesterday_notification",
|
||||||
|
days_left=0,
|
||||||
|
),
|
||||||
|
user=db_user,
|
||||||
|
telegram_markup=markup,
|
||||||
|
end_date_text=end_date_text,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
async def _send_legacy_without_dedupe(
|
||||||
|
self,
|
||||||
|
event_name: str,
|
||||||
|
user_payload: dict,
|
||||||
|
user_id: int,
|
||||||
|
lang: str,
|
||||||
|
db_user: Optional[User],
|
||||||
|
) -> None:
|
||||||
|
first_name = getattr(db_user, "first_name", None) or f"User {user_id}"
|
||||||
|
markup = get_subscribe_only_markup(lang, self.i18n)
|
||||||
|
if event_name in EVENT_MAP:
|
||||||
|
stage = EVENT_MAP[event_name]
|
||||||
|
await self._send_message(
|
||||||
|
user_id,
|
||||||
|
lang,
|
||||||
|
stage.message_key,
|
||||||
|
reply_markup=markup,
|
||||||
|
user_name=first_name,
|
||||||
|
end_date=self._payload_expire_date(user_payload),
|
||||||
|
)
|
||||||
|
elif event_name == "user.expired" and self.settings.SUBSCRIPTION_NOTIFY_ON_EXPIRE:
|
||||||
await self._send_message(
|
await self._send_message(
|
||||||
user_id,
|
user_id,
|
||||||
lang,
|
lang,
|
||||||
"subscription_expired_notification",
|
"subscription_expired_notification",
|
||||||
reply_markup=markup,
|
reply_markup=markup,
|
||||||
user_name=first_name,
|
user_name=first_name,
|
||||||
end_date=user_payload.get("expireAt", "")[:10],
|
end_date=self._payload_expire_date(user_payload),
|
||||||
)
|
)
|
||||||
elif (
|
elif (
|
||||||
event_name == "user.expired_24_hours_ago"
|
event_name == "user.expired_24_hours_ago"
|
||||||
@@ -221,33 +337,138 @@ class PanelWebhookService:
|
|||||||
"subscription_expired_yesterday_notification",
|
"subscription_expired_yesterday_notification",
|
||||||
reply_markup=markup,
|
reply_markup=markup,
|
||||||
user_name=first_name,
|
user_name=first_name,
|
||||||
end_date=user_payload.get("expireAt", "")[:10],
|
end_date=self._payload_expire_date(user_payload),
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _send_subscription_expiring_email(
|
async def _user_for_payload(
|
||||||
self,
|
self,
|
||||||
*,
|
session: AsyncSession,
|
||||||
recipient: str,
|
user_payload: dict,
|
||||||
lang: str,
|
) -> Optional[User]:
|
||||||
days_left: int,
|
telegram_id = self._payload_telegram_id(user_payload)
|
||||||
end_date_text: str,
|
if telegram_id:
|
||||||
) -> None:
|
user = await user_dal.get_user_by_telegram_id(session, telegram_id)
|
||||||
"""Best-effort branded reminder; silently no-ops without SMTP config."""
|
if user:
|
||||||
if not self.settings.email_auth_configured:
|
return user
|
||||||
return
|
user = await user_dal.get_user_by_id(session, telegram_id)
|
||||||
try:
|
if user:
|
||||||
content = render_subscription_expiring(
|
return user
|
||||||
self.settings,
|
|
||||||
language_code=lang,
|
panel_uuid = self._payload_panel_uuid(user_payload)
|
||||||
days_left=days_left,
|
if panel_uuid:
|
||||||
end_date_text=end_date_text,
|
user = await user_dal.get_user_by_panel_uuid(session, panel_uuid)
|
||||||
dashboard_url=(self.settings.SUBSCRIPTION_MINI_APP_URL or "").strip() or None,
|
if user:
|
||||||
i18n=self.i18n,
|
return user
|
||||||
|
|
||||||
|
email = str(user_payload.get("email") or "").strip()
|
||||||
|
if email:
|
||||||
|
return await user_dal.get_user_by_email(session, email)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _subscription_for_payload(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
user_payload: dict,
|
||||||
|
db_user: Optional[User],
|
||||||
|
) -> Optional[Subscription]:
|
||||||
|
conditions = []
|
||||||
|
if db_user:
|
||||||
|
conditions.append(Subscription.user_id == db_user.user_id)
|
||||||
|
panel_uuid = self._payload_panel_uuid(user_payload)
|
||||||
|
if panel_uuid:
|
||||||
|
conditions.append(Subscription.panel_user_uuid == panel_uuid)
|
||||||
|
if not conditions:
|
||||||
|
return None
|
||||||
|
base_stmt = (
|
||||||
|
select(Subscription)
|
||||||
|
.where(
|
||||||
|
Subscription.skip_notifications == False,
|
||||||
|
or_(*conditions),
|
||||||
)
|
)
|
||||||
email_service = EmailAuthService(self.settings, self.i18n)
|
.options(selectinload(Subscription.user))
|
||||||
await email_service.send_rendered_email(email=recipient, content=content)
|
)
|
||||||
except Exception:
|
|
||||||
logging.exception("Failed to send subscription-expiring email to %s", recipient)
|
expire_at = self._payload_expire_datetime(user_payload)
|
||||||
|
if expire_at is not None:
|
||||||
|
window_stmt = (
|
||||||
|
base_stmt.where(
|
||||||
|
Subscription.end_date >= expire_at - timedelta(days=1),
|
||||||
|
Subscription.end_date <= expire_at + timedelta(days=1),
|
||||||
|
)
|
||||||
|
.order_by(Subscription.end_date.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
result = await session.execute(window_stmt)
|
||||||
|
found = result.scalars().first()
|
||||||
|
if found:
|
||||||
|
return found
|
||||||
|
|
||||||
|
stmt = base_stmt.order_by(Subscription.end_date.desc()).limit(1)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
return result.scalars().first()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _payload_telegram_id(user_payload: dict) -> Optional[int]:
|
||||||
|
raw = user_payload.get("telegramId")
|
||||||
|
try:
|
||||||
|
value = int(raw or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
return value if value > 0 else None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _payload_panel_uuid(user_payload: dict) -> str:
|
||||||
|
return str(
|
||||||
|
user_payload.get("uuid")
|
||||||
|
or user_payload.get("userUuid")
|
||||||
|
or user_payload.get("shortUuid")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _payload_expire_date(user_payload: dict) -> str:
|
||||||
|
return str(user_payload.get("expireAt") or "")[:10]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _payload_log_context(user_payload: dict) -> str:
|
||||||
|
telegram_id = PanelWebhookService._payload_telegram_id(user_payload)
|
||||||
|
panel_uuid = PanelWebhookService._payload_panel_uuid(user_payload)
|
||||||
|
email = PanelWebhookService._mask_email(str(user_payload.get("email") or "").strip())
|
||||||
|
expire_at = str(user_payload.get("expireAt") or "").strip()
|
||||||
|
payload_keys = ",".join(sorted(str(key) for key in user_payload.keys())) or "none"
|
||||||
|
return (
|
||||||
|
f"telegramId={telegram_id or 'N/A'} "
|
||||||
|
f"panel_uuid={panel_uuid or 'N/A'} "
|
||||||
|
f"email={email or 'N/A'} "
|
||||||
|
f"expireAt={expire_at or 'N/A'} "
|
||||||
|
f"payload_keys={payload_keys}"
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _mask_email(email: str) -> str:
|
||||||
|
if not email:
|
||||||
|
return ""
|
||||||
|
local_part, separator, domain = email.partition("@")
|
||||||
|
if not separator or not domain:
|
||||||
|
return "present"
|
||||||
|
visible = local_part[:2] if len(local_part) > 2 else local_part[:1]
|
||||||
|
return f"{visible}***@{domain}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _payload_expire_datetime(user_payload: dict) -> Optional[datetime]:
|
||||||
|
raw = str(user_payload.get("expireAt") or "").strip()
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
value = datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
||||||
|
except ValueError:
|
||||||
|
try:
|
||||||
|
value = datetime.fromisoformat(raw[:10])
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if value.tzinfo is None:
|
||||||
|
return value.replace(tzinfo=timezone.utc)
|
||||||
|
return value.astimezone(timezone.utc)
|
||||||
|
|
||||||
async def handle_webhook(
|
async def handle_webhook(
|
||||||
self, raw_body: bytes, signature_header: Optional[str]
|
self, raw_body: bytes, signature_header: Optional[str]
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from sqlalchemy import text
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from bot.middlewares.i18n import JsonI18n
|
from bot.middlewares.i18n import JsonI18n
|
||||||
|
from bot.services.user_email_notifications import send_user_notification_email
|
||||||
from config.settings import Settings
|
from config.settings import Settings
|
||||||
from db.dal import payment_dal, subscription_dal, user_dal
|
from db.dal import payment_dal, subscription_dal, user_dal
|
||||||
|
|
||||||
@@ -33,6 +34,7 @@ class ReferralService:
|
|||||||
purchased_subscription_months: int,
|
purchased_subscription_months: int,
|
||||||
current_payment_db_id: Optional[int] = None,
|
current_payment_db_id: Optional[int] = None,
|
||||||
skip_if_active_before_payment: bool = True,
|
skip_if_active_before_payment: bool = True,
|
||||||
|
tariff_key: Optional[str] = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
|
|
||||||
referee_final_end_date: Optional[datetime] = None
|
referee_final_end_date: Optional[datetime] = None
|
||||||
@@ -94,11 +96,9 @@ class ReferralService:
|
|||||||
else self.i18n.gettext(default_lang_for_placeholder, "friend_placeholder")
|
else self.i18n.gettext(default_lang_for_placeholder, "friend_placeholder")
|
||||||
)
|
)
|
||||||
|
|
||||||
inviter_bonus_days = self.settings.referral_bonus_inviter.get(
|
inviter_bonus_days, referee_bonus_days = self._referral_bonus_days_for_payment(
|
||||||
purchased_subscription_months
|
purchased_subscription_months,
|
||||||
)
|
tariff_key=tariff_key,
|
||||||
referee_bonus_days = self.settings.referral_bonus_referee.get(
|
|
||||||
purchased_subscription_months
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if inviter_bonus_days and inviter_bonus_days > 0:
|
if inviter_bonus_days and inviter_bonus_days > 0:
|
||||||
@@ -142,18 +142,36 @@ class ReferralService:
|
|||||||
inviter_user_model.language_code or default_lang_for_placeholder
|
inviter_user_model.language_code or default_lang_for_placeholder
|
||||||
)
|
)
|
||||||
_i = lambda k, **kw: self.i18n.gettext(inviter_lang, k, **kw)
|
_i = lambda k, **kw: self.i18n.gettext(inviter_lang, k, **kw)
|
||||||
await self.bot.send_message(
|
message_text = _i(
|
||||||
inviter_user_id,
|
|
||||||
_i(
|
|
||||||
"referral_bonus_inviter_notification_extended",
|
"referral_bonus_inviter_notification_extended",
|
||||||
days=inviter_bonus_days,
|
days=inviter_bonus_days,
|
||||||
referee_name=referee_name_for_msg,
|
referee_name=referee_name_for_msg,
|
||||||
new_end_date=new_end_date_inviter.strftime("%Y-%m-%d"),
|
new_end_date=new_end_date_inviter.strftime("%Y-%m-%d"),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await self.bot.send_message(inviter_user_id, message_text)
|
||||||
|
except Exception as e_notify_inviter:
|
||||||
|
logging.error(
|
||||||
|
f"Failed to send bonus notification to inviter {inviter_user_id}: {e_notify_inviter}" # noqa: E501
|
||||||
|
)
|
||||||
|
await send_user_notification_email(
|
||||||
|
settings=self.settings,
|
||||||
|
i18n=self.i18n,
|
||||||
|
user=inviter_user_model,
|
||||||
|
subject_key="email_referral_bonus_subject",
|
||||||
|
message_text=message_text,
|
||||||
|
dashboard_url=(
|
||||||
|
getattr(
|
||||||
|
self.settings,
|
||||||
|
"SUBSCRIPTION_MINI_APP_URL",
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
or None
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
except Exception as e_notify_inviter:
|
except Exception as e_notify_inviter:
|
||||||
logging.error(
|
logging.error(
|
||||||
f"Failed to send bonus notification to inviter {inviter_user_id}: {e_notify_inviter}" # noqa: E501
|
f"Failed to prepare bonus notification for inviter {inviter_user_id}: {e_notify_inviter}" # noqa: E501
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logging.info(
|
logging.info(
|
||||||
@@ -210,13 +228,34 @@ class ReferralService:
|
|||||||
_i = lambda k, **kw: self.i18n.gettext(
|
_i = lambda k, **kw: self.i18n.gettext(
|
||||||
inviter_lang, k, **kw
|
inviter_lang, k, **kw
|
||||||
)
|
)
|
||||||
await self.bot.send_message(
|
message_text = _i(
|
||||||
inviter_user_id,
|
|
||||||
_i(
|
|
||||||
"referral_bonus_inviter_notification_new_sub",
|
"referral_bonus_inviter_notification_new_sub",
|
||||||
days=inviter_bonus_days,
|
days=inviter_bonus_days,
|
||||||
referee_name=referee_name_for_msg,
|
referee_name=referee_name_for_msg,
|
||||||
new_end_date=bonus_end_date.strftime("%Y-%m-%d"),
|
new_end_date=bonus_end_date.strftime("%Y-%m-%d"),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await self.bot.send_message(
|
||||||
|
inviter_user_id,
|
||||||
|
message_text,
|
||||||
|
)
|
||||||
|
except Exception as e_notify_inviter:
|
||||||
|
logging.error(
|
||||||
|
f"Failed to send bonus notification to inviter {inviter_user_id}: {e_notify_inviter}" # noqa: E501
|
||||||
|
)
|
||||||
|
await send_user_notification_email(
|
||||||
|
settings=self.settings,
|
||||||
|
i18n=self.i18n,
|
||||||
|
user=inviter_user_model,
|
||||||
|
subject_key="email_referral_bonus_subject",
|
||||||
|
message_text=message_text,
|
||||||
|
dashboard_url=(
|
||||||
|
getattr(
|
||||||
|
self.settings,
|
||||||
|
"SUBSCRIPTION_MINI_APP_URL",
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
or None
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
@@ -263,6 +302,35 @@ class ReferralService:
|
|||||||
|
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
def _referral_bonus_days_for_payment(
|
||||||
|
self,
|
||||||
|
purchased_subscription_months: int,
|
||||||
|
*,
|
||||||
|
tariff_key: Optional[str] = None,
|
||||||
|
) -> tuple[Optional[int], Optional[int]]:
|
||||||
|
months = int(purchased_subscription_months)
|
||||||
|
tariffs_config = getattr(self.settings, "tariffs_config", None)
|
||||||
|
if tariff_key and tariffs_config:
|
||||||
|
try:
|
||||||
|
tariff = tariffs_config.require(str(tariff_key))
|
||||||
|
except Exception:
|
||||||
|
logging.warning(
|
||||||
|
"Referral bonuses skipped: tariff %s was not found.",
|
||||||
|
tariff_key,
|
||||||
|
)
|
||||||
|
return None, None
|
||||||
|
if tariff.billing_model != "period":
|
||||||
|
return None, None
|
||||||
|
return (
|
||||||
|
tariff.referral_inviter_bonus_days(months),
|
||||||
|
tariff.referral_referee_bonus_days(months),
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
self.settings.referral_bonus_inviter.get(months),
|
||||||
|
self.settings.referral_bonus_referee.get(months),
|
||||||
|
)
|
||||||
|
|
||||||
async def generate_referral_link(
|
async def generate_referral_link(
|
||||||
self, session: AsyncSession, bot_username: str, inviter_user_id: int
|
self, session: AsyncSession, bot_username: str, inviter_user_id: int
|
||||||
) -> Optional[str]:
|
) -> Optional[str]:
|
||||||
|
|||||||
@@ -263,6 +263,35 @@ async def load_overrides_from_db(settings: Settings, async_session_factory: sess
|
|||||||
return applied
|
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(
|
async def update_overrides(
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
async_session_factory: sessionmaker,
|
async_session_factory: sessionmaker,
|
||||||
|
|||||||
@@ -0,0 +1,361 @@
|
|||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Optional
|
||||||
|
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
||||||
|
|
||||||
|
from aiogram import Bot
|
||||||
|
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||||
|
from aiogram.types import InlineKeyboardMarkup
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from bot.keyboards.inline.user_keyboards import get_subscribe_only_markup
|
||||||
|
from bot.middlewares.i18n import JsonI18n
|
||||||
|
from bot.services.email_auth_service import EmailAuthService
|
||||||
|
from bot.services.email_templates import render_subscription_lifecycle_notification
|
||||||
|
from bot.services.telegram_notifications import (
|
||||||
|
TELEGRAM_NOTIFICATIONS_BLOCKED,
|
||||||
|
TELEGRAM_NOTIFICATIONS_ENABLED,
|
||||||
|
TELEGRAM_NOTIFICATIONS_NEEDS_START,
|
||||||
|
mark_telegram_notifications_status,
|
||||||
|
normalize_telegram_notification_status,
|
||||||
|
telegram_notification_status_from_error,
|
||||||
|
)
|
||||||
|
from config.settings import Settings
|
||||||
|
from db.dal import subscription_dal
|
||||||
|
from db.models import Subscription, User
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SubscriptionNotificationStage:
|
||||||
|
key: str
|
||||||
|
message_key: str
|
||||||
|
days_left: Optional[int] = None
|
||||||
|
hours_before: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SubscriptionNotificationDelivery:
|
||||||
|
telegram_sent: bool = False
|
||||||
|
email_sent: bool = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def any_sent(self) -> bool:
|
||||||
|
return self.telegram_sent or self.email_sent
|
||||||
|
|
||||||
|
|
||||||
|
class SubscriptionLifecycleNotificationService:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
settings: Settings,
|
||||||
|
bot: Bot,
|
||||||
|
i18n: JsonI18n,
|
||||||
|
*,
|
||||||
|
email_service: Optional[EmailAuthService] = None,
|
||||||
|
) -> None:
|
||||||
|
self.settings = settings
|
||||||
|
self.bot = bot
|
||||||
|
self.i18n = i18n
|
||||||
|
self.email_service = email_service
|
||||||
|
|
||||||
|
async def send_stage(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
sub: Subscription,
|
||||||
|
stage: SubscriptionNotificationStage,
|
||||||
|
*,
|
||||||
|
user: Optional[User] = None,
|
||||||
|
telegram_markup: Optional[InlineKeyboardMarkup] = None,
|
||||||
|
extra_text: str = "",
|
||||||
|
end_date_text: Optional[str] = None,
|
||||||
|
sent_at: Optional[datetime] = None,
|
||||||
|
) -> SubscriptionNotificationDelivery:
|
||||||
|
if sent_at is None:
|
||||||
|
sent_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
resolved_user = user or getattr(sub, "user", None)
|
||||||
|
lang = getattr(resolved_user, "language_code", None) or self.settings.DEFAULT_LANGUAGE
|
||||||
|
user_id = int(getattr(sub, "user_id", 0) or 0)
|
||||||
|
final_end_date_text = end_date_text
|
||||||
|
if final_end_date_text is None:
|
||||||
|
end_date = self._as_utc(getattr(sub, "end_date", None))
|
||||||
|
final_end_date_text = end_date.strftime("%Y-%m-%d") if end_date else ""
|
||||||
|
|
||||||
|
recipient_email = self._email_recipient(resolved_user)
|
||||||
|
telegram_user_name = self._telegram_display_name(resolved_user, user_id)
|
||||||
|
email_user_name = self._email_display_name(
|
||||||
|
resolved_user,
|
||||||
|
recipient_email=recipient_email,
|
||||||
|
fallback=telegram_user_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
kwargs = {"user_name": telegram_user_name, "end_date": final_end_date_text}
|
||||||
|
if stage.hours_before is not None:
|
||||||
|
kwargs["hours"] = stage.hours_before
|
||||||
|
|
||||||
|
message_text = self.i18n.gettext(lang, stage.message_key, **kwargs)
|
||||||
|
email_kwargs = {**kwargs, "user_name": email_user_name}
|
||||||
|
email_message_text = self.i18n.gettext(lang, stage.message_key, **email_kwargs)
|
||||||
|
final_extra_text = str(extra_text or "").strip()
|
||||||
|
if final_extra_text:
|
||||||
|
message_text = f"{message_text}\n\n{final_extra_text}"
|
||||||
|
email_message_text = f"{email_message_text}\n\n{final_extra_text}"
|
||||||
|
|
||||||
|
telegram_sent = await self._send_telegram(
|
||||||
|
session,
|
||||||
|
sub,
|
||||||
|
stage,
|
||||||
|
resolved_user,
|
||||||
|
lang=lang,
|
||||||
|
message_text=message_text,
|
||||||
|
markup=telegram_markup or get_subscribe_only_markup(lang, self.i18n),
|
||||||
|
sent_at=sent_at,
|
||||||
|
)
|
||||||
|
email_sent = await self._send_email(
|
||||||
|
session,
|
||||||
|
sub,
|
||||||
|
stage,
|
||||||
|
resolved_user,
|
||||||
|
lang=lang,
|
||||||
|
message_text=email_message_text,
|
||||||
|
end_date_text=final_end_date_text,
|
||||||
|
recipient=recipient_email,
|
||||||
|
telegram_sent=telegram_sent,
|
||||||
|
sent_at=sent_at,
|
||||||
|
)
|
||||||
|
return SubscriptionNotificationDelivery(
|
||||||
|
telegram_sent=telegram_sent,
|
||||||
|
email_sent=email_sent,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _send_telegram(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
sub: Subscription,
|
||||||
|
stage: SubscriptionNotificationStage,
|
||||||
|
user: Optional[User],
|
||||||
|
*,
|
||||||
|
lang: str,
|
||||||
|
message_text: str,
|
||||||
|
markup: Optional[InlineKeyboardMarkup],
|
||||||
|
sent_at: datetime,
|
||||||
|
) -> bool:
|
||||||
|
chat_id = self._telegram_chat_id(user, getattr(sub, "user_id", None))
|
||||||
|
if chat_id is None:
|
||||||
|
return False
|
||||||
|
if user:
|
||||||
|
status = normalize_telegram_notification_status(
|
||||||
|
getattr(user, "telegram_notifications_status", None)
|
||||||
|
)
|
||||||
|
if status in {TELEGRAM_NOTIFICATIONS_NEEDS_START, TELEGRAM_NOTIFICATIONS_BLOCKED}:
|
||||||
|
return False
|
||||||
|
if await self._already_sent(session, sub.subscription_id, stage.key, "telegram"):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
await self.bot.send_message(chat_id, message_text, reply_markup=markup)
|
||||||
|
except (TelegramBadRequest, TelegramForbiddenError) as exc:
|
||||||
|
delivery_status = telegram_notification_status_from_error(exc)
|
||||||
|
if user and delivery_status:
|
||||||
|
await mark_telegram_notifications_status(
|
||||||
|
session,
|
||||||
|
int(user.user_id),
|
||||||
|
delivery_status,
|
||||||
|
)
|
||||||
|
if delivery_status:
|
||||||
|
logging.warning(
|
||||||
|
"Skipping subscription notification %s for unreachable Telegram user %s: %s",
|
||||||
|
stage.key,
|
||||||
|
chat_id,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
logging.exception(
|
||||||
|
"Failed to send subscription notification %s to Telegram user %s",
|
||||||
|
stage.key,
|
||||||
|
chat_id,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
except Exception:
|
||||||
|
logging.exception(
|
||||||
|
"Failed to send subscription notification %s to Telegram user %s",
|
||||||
|
stage.key,
|
||||||
|
chat_id,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
await subscription_dal.record_subscription_notification(
|
||||||
|
session,
|
||||||
|
sub.subscription_id,
|
||||||
|
self._channel_key(stage.key, "telegram"),
|
||||||
|
sent_at=sent_at,
|
||||||
|
)
|
||||||
|
if user:
|
||||||
|
status = normalize_telegram_notification_status(
|
||||||
|
getattr(user, "telegram_notifications_status", None)
|
||||||
|
)
|
||||||
|
if status != TELEGRAM_NOTIFICATIONS_ENABLED:
|
||||||
|
await mark_telegram_notifications_status(
|
||||||
|
session,
|
||||||
|
int(user.user_id),
|
||||||
|
TELEGRAM_NOTIFICATIONS_ENABLED,
|
||||||
|
telegram_id=chat_id,
|
||||||
|
checked_at=sent_at,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def _send_email(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
sub: Subscription,
|
||||||
|
stage: SubscriptionNotificationStage,
|
||||||
|
user: Optional[User],
|
||||||
|
*,
|
||||||
|
lang: str,
|
||||||
|
message_text: str,
|
||||||
|
end_date_text: str,
|
||||||
|
recipient: str,
|
||||||
|
telegram_sent: bool,
|
||||||
|
sent_at: datetime,
|
||||||
|
) -> bool:
|
||||||
|
if not getattr(self.settings, "SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED", True):
|
||||||
|
return False
|
||||||
|
if not getattr(self.settings, "email_auth_configured", False):
|
||||||
|
return False
|
||||||
|
if not recipient:
|
||||||
|
return False
|
||||||
|
if await self._already_sent(session, sub.subscription_id, stage.key, "email"):
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
content = render_subscription_lifecycle_notification(
|
||||||
|
self.settings,
|
||||||
|
language_code=lang,
|
||||||
|
notification_key=stage.key,
|
||||||
|
message_text=message_text,
|
||||||
|
end_date_text=end_date_text,
|
||||||
|
dashboard_url=self._renewal_dashboard_url(recipient, sub),
|
||||||
|
mirrored_from_telegram=telegram_sent,
|
||||||
|
days_left=stage.days_left,
|
||||||
|
hours_before=stage.hours_before,
|
||||||
|
i18n=self.i18n,
|
||||||
|
)
|
||||||
|
email_service = self.email_service or EmailAuthService(self.settings, self.i18n)
|
||||||
|
await email_service.send_rendered_email(email=recipient, content=content)
|
||||||
|
except Exception:
|
||||||
|
logging.exception(
|
||||||
|
"Failed to send subscription notification %s to email %s",
|
||||||
|
stage.key,
|
||||||
|
recipient,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
await subscription_dal.record_subscription_notification(
|
||||||
|
session,
|
||||||
|
sub.subscription_id,
|
||||||
|
self._channel_key(stage.key, "email"),
|
||||||
|
sent_at=sent_at,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def _already_sent(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
subscription_id: int,
|
||||||
|
stage_key: str,
|
||||||
|
channel: str,
|
||||||
|
) -> bool:
|
||||||
|
channel_key = self._channel_key(stage_key, channel)
|
||||||
|
if await subscription_dal.has_subscription_notification(
|
||||||
|
session,
|
||||||
|
subscription_id,
|
||||||
|
channel_key,
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Legacy rows were stored without a channel. Treat them as Telegram-only
|
||||||
|
# history so existing installs do not re-send old bot messages, while
|
||||||
|
# still allowing the newly introduced email channel to catch up.
|
||||||
|
return channel == "telegram" and await subscription_dal.has_subscription_notification(
|
||||||
|
session,
|
||||||
|
subscription_id,
|
||||||
|
stage_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _channel_key(stage_key: str, channel: str) -> str:
|
||||||
|
return f"{stage_key}:{channel}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _email_recipient(user: Optional[User]) -> str:
|
||||||
|
return str(getattr(user, "email", "") or "").strip().lower() if user else ""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _telegram_display_name(user: Optional[User], fallback_user_id: int) -> str:
|
||||||
|
return str(getattr(user, "first_name", "") or "").strip() or f"User {fallback_user_id}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _email_display_name(
|
||||||
|
user: Optional[User],
|
||||||
|
*,
|
||||||
|
recipient_email: str,
|
||||||
|
fallback: str,
|
||||||
|
) -> str:
|
||||||
|
return str(getattr(user, "first_name", "") or "").strip() or recipient_email or fallback
|
||||||
|
|
||||||
|
def _renewal_dashboard_url(self, recipient_email: str, sub: Subscription) -> Optional[str]:
|
||||||
|
base_url = (self.settings.SUBSCRIPTION_MINI_APP_URL or "").strip()
|
||||||
|
if not base_url:
|
||||||
|
return None
|
||||||
|
parsed = urlsplit(base_url)
|
||||||
|
if parsed.scheme not in ("http", "https") or not parsed.netloc:
|
||||||
|
return None
|
||||||
|
|
||||||
|
query = dict(parse_qsl(parsed.query, keep_blank_values=True))
|
||||||
|
query.update(
|
||||||
|
{
|
||||||
|
"login": "email_code",
|
||||||
|
"login_email": recipient_email,
|
||||||
|
"after_login": "renew",
|
||||||
|
"renew": "1",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
tariff_key = self._renewal_tariff_key(sub)
|
||||||
|
if tariff_key:
|
||||||
|
query["renew_tariff"] = tariff_key
|
||||||
|
else:
|
||||||
|
query.pop("renew_tariff", None)
|
||||||
|
|
||||||
|
return urlunsplit(
|
||||||
|
(
|
||||||
|
parsed.scheme,
|
||||||
|
parsed.netloc,
|
||||||
|
parsed.path or "/",
|
||||||
|
urlencode(query),
|
||||||
|
parsed.fragment,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _renewal_tariff_key(sub: Subscription) -> str:
|
||||||
|
provider = str(getattr(sub, "provider", "") or "").strip().lower()
|
||||||
|
status = str(getattr(sub, "status_from_panel", "") or "").strip().upper()
|
||||||
|
if provider == "trial" or status == "TRIAL":
|
||||||
|
return ""
|
||||||
|
return str(getattr(sub, "tariff_key", "") or "").strip()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _telegram_chat_id(user: Optional[User], fallback_user_id: Optional[int]) -> Optional[int]:
|
||||||
|
for candidate in (getattr(user, "telegram_id", None), fallback_user_id):
|
||||||
|
try:
|
||||||
|
chat_id = int(candidate or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if chat_id > 0:
|
||||||
|
return chat_id
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _as_utc(value: Optional[datetime]) -> Optional[datetime]:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if value.tzinfo is None:
|
||||||
|
return value.replace(tzinfo=timezone.utc)
|
||||||
|
return value.astimezone(timezone.utc)
|
||||||
@@ -0,0 +1,384 @@
|
|||||||
|
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.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
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
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"
|
||||||
@@ -87,9 +87,9 @@ class HwidDeviceMixin:
|
|||||||
full_price = float(package.price_for_period(period_months))
|
full_price = float(package.price_for_period(period_months))
|
||||||
period_start = self._as_aware_utc(getattr(sub, "start_date", None))
|
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
|
period_end = self._as_aware_utc(getattr(sub, "end_date", None)) or valid_until
|
||||||
if not period_start or period_start >= period_end:
|
inferred_period_start = add_months(period_end, -period_months)
|
||||||
period_start = valid_from
|
if not period_start or period_start >= period_end or period_start < inferred_period_start:
|
||||||
period_end = valid_until
|
period_start = inferred_period_start
|
||||||
|
|
||||||
basis_seconds = max(1.0, (period_end - period_start).total_seconds())
|
basis_seconds = max(1.0, (period_end - period_start).total_seconds())
|
||||||
billable_start = max(now, valid_from)
|
billable_start = max(now, valid_from)
|
||||||
@@ -357,6 +357,15 @@ class HwidDeviceMixin:
|
|||||||
valid_from=valid_from,
|
valid_from=valid_from,
|
||||||
valid_until=valid_until,
|
valid_until=valid_until,
|
||||||
)
|
)
|
||||||
|
await self._send_payment_success_email(
|
||||||
|
db_user=db_user,
|
||||||
|
sale_mode="hwid_devices_renewal" if renewal else "hwid_devices",
|
||||||
|
months=purchased_devices,
|
||||||
|
traffic_gb=None,
|
||||||
|
payment_amount=payment_amount,
|
||||||
|
end_date=valid_until,
|
||||||
|
provider=provider,
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"subscription_id": updated_sub.subscription_id,
|
"subscription_id": updated_sub.subscription_id,
|
||||||
"end_date": updated_sub.end_date,
|
"end_date": updated_sub.end_date,
|
||||||
|
|||||||
@@ -3,6 +3,175 @@ from ._runtime import * # noqa: F403,F405
|
|||||||
|
|
||||||
|
|
||||||
class SubscriptionLifecycleMixin:
|
class SubscriptionLifecycleMixin:
|
||||||
|
async def _lookup_panel_user_for_subscription_details(
|
||||||
|
self,
|
||||||
|
panel_user_uuid: str,
|
||||||
|
) -> Tuple[Optional[Dict[str, Any]], bool, str]:
|
||||||
|
lookup_method = getattr(self.panel_service, "get_user_by_uuid_lookup", None)
|
||||||
|
if callable(lookup_method):
|
||||||
|
try:
|
||||||
|
lookup = await lookup_method(panel_user_uuid, log_response=False)
|
||||||
|
except TypeError:
|
||||||
|
try:
|
||||||
|
lookup = await lookup_method(panel_user_uuid)
|
||||||
|
except Exception as exc:
|
||||||
|
logging.exception(
|
||||||
|
"Failed to fetch panel user %s for subscription details",
|
||||||
|
panel_user_uuid,
|
||||||
|
)
|
||||||
|
return None, False, self._panel_lookup_exception_reason(exc)
|
||||||
|
except Exception as exc:
|
||||||
|
logging.exception(
|
||||||
|
"Failed to fetch panel user %s for subscription details",
|
||||||
|
panel_user_uuid,
|
||||||
|
)
|
||||||
|
return None, False, self._panel_lookup_exception_reason(exc)
|
||||||
|
|
||||||
|
if isinstance(lookup, dict) and ("ok" in lookup or "not_found" in lookup):
|
||||||
|
user = lookup.get("user")
|
||||||
|
if lookup.get("ok") and isinstance(user, dict):
|
||||||
|
return user, False, ""
|
||||||
|
reason = str(lookup.get("failure_reason") or "classification=panel_lookup_failed")
|
||||||
|
return None, bool(lookup.get("not_found")), reason
|
||||||
|
|
||||||
|
try:
|
||||||
|
panel_user = await self.panel_service.get_user_by_uuid(panel_user_uuid)
|
||||||
|
except Exception as exc:
|
||||||
|
logging.exception(
|
||||||
|
"Failed to fetch panel user %s for subscription details",
|
||||||
|
panel_user_uuid,
|
||||||
|
)
|
||||||
|
return None, False, self._panel_lookup_exception_reason(exc)
|
||||||
|
return (panel_user if isinstance(panel_user, dict) else None), False, ""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _panel_lookup_exception_reason(exc: Exception) -> str:
|
||||||
|
message = str(exc).replace("\n", " ").strip()
|
||||||
|
if len(message) > 300:
|
||||||
|
message = f"{message[:300]}..."
|
||||||
|
reason = f"classification=panel_lookup_failed exception={type(exc).__name__}"
|
||||||
|
if message:
|
||||||
|
reason = f"{reason} message={message}"
|
||||||
|
return reason
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _display_datetime_text(value: Optional[Any]) -> Optional[str]:
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
normalized = value if value.tzinfo else value.replace(tzinfo=timezone.utc)
|
||||||
|
return normalized.strftime("%d.%m.%Y %H:%M")
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _device_topup_renewal_available(
|
||||||
|
extra_hwid_devices: int,
|
||||||
|
extra_hwid_valid_until: Optional[Any],
|
||||||
|
subscription_end_date: Optional[Any],
|
||||||
|
) -> bool:
|
||||||
|
if not isinstance(extra_hwid_valid_until, datetime) or not isinstance(
|
||||||
|
subscription_end_date, datetime
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
valid_until = (
|
||||||
|
extra_hwid_valid_until
|
||||||
|
if extra_hwid_valid_until.tzinfo
|
||||||
|
else extra_hwid_valid_until.replace(tzinfo=timezone.utc)
|
||||||
|
)
|
||||||
|
end_date = (
|
||||||
|
subscription_end_date
|
||||||
|
if subscription_end_date.tzinfo
|
||||||
|
else subscription_end_date.replace(tzinfo=timezone.utc)
|
||||||
|
)
|
||||||
|
return bool(int(extra_hwid_devices or 0) > 0 and valid_until < end_date)
|
||||||
|
|
||||||
|
async def _local_active_subscription_details_fallback(
|
||||||
|
self,
|
||||||
|
db_user: User,
|
||||||
|
local_active_sub: Subscription,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
panel_sub_id = str(local_active_sub.panel_subscription_uuid or "").strip()
|
||||||
|
config_link_raw = (
|
||||||
|
await self.panel_service.get_subscription_link(panel_sub_id) if panel_sub_id else None
|
||||||
|
)
|
||||||
|
display_link, connect_button_url = await prepare_config_links(
|
||||||
|
self.settings,
|
||||||
|
config_link_raw,
|
||||||
|
)
|
||||||
|
tariff = None
|
||||||
|
if local_active_sub.tariff_key and self._tariffs_config():
|
||||||
|
try:
|
||||||
|
tariff = self._resolve_tariff(local_active_sub.tariff_key)
|
||||||
|
except Exception:
|
||||||
|
tariff = None
|
||||||
|
language = db_user.language_code or self.settings.DEFAULT_LANGUAGE
|
||||||
|
premium_access = (
|
||||||
|
await self.premium_access_for_tariff(tariff)
|
||||||
|
if tariff
|
||||||
|
else {"squad_uuids": [], "squad_labels": [], "node_labels": []}
|
||||||
|
)
|
||||||
|
premium_baseline = int(local_active_sub.premium_baseline_bytes or 0)
|
||||||
|
premium_topup_balance = int(local_active_sub.premium_topup_balance_bytes or 0)
|
||||||
|
premium_topup_used = int(getattr(local_active_sub, "premium_topup_used_bytes", 0) or 0)
|
||||||
|
premium_bonus_bytes = int(getattr(local_active_sub, "premium_bonus_bytes", 0) or 0)
|
||||||
|
return {
|
||||||
|
"user_id": db_user.panel_user_uuid,
|
||||||
|
"panel_subscription_uuid": local_active_sub.panel_subscription_uuid,
|
||||||
|
"panel_short_uuid": local_active_sub.panel_subscription_uuid,
|
||||||
|
"end_date": local_active_sub.end_date,
|
||||||
|
"status_from_panel": local_active_sub.status_from_panel or "LOCAL_CACHE",
|
||||||
|
"config_link": display_link,
|
||||||
|
"connect_button_url": connect_button_url,
|
||||||
|
"traffic_limit_bytes": local_active_sub.traffic_limit_bytes,
|
||||||
|
"traffic_used_bytes": local_active_sub.traffic_used_bytes,
|
||||||
|
"traffic_limit_strategy": "",
|
||||||
|
"tariff_key": local_active_sub.tariff_key,
|
||||||
|
"tariff_name": tariff.name(language) if tariff else None,
|
||||||
|
"tariff_description": tariff.description(language) if tariff else None,
|
||||||
|
"premium_title": tariff.premium_name(language) if tariff else None,
|
||||||
|
"billing_model": tariff.billing_model
|
||||||
|
if tariff
|
||||||
|
else ("traffic" if getattr(self.settings, "traffic_sale_mode", False) else "period"),
|
||||||
|
"tier_baseline_bytes": local_active_sub.tier_baseline_bytes,
|
||||||
|
"topup_balance_bytes": local_active_sub.topup_balance_bytes,
|
||||||
|
"regular_bonus_bytes": int(getattr(local_active_sub, "regular_bonus_bytes", 0) or 0),
|
||||||
|
"regular_unlimited_override": bool(
|
||||||
|
getattr(local_active_sub, "regular_unlimited_override", False)
|
||||||
|
),
|
||||||
|
"premium_baseline_bytes": premium_baseline,
|
||||||
|
"premium_topup_balance_bytes": premium_topup_balance,
|
||||||
|
"premium_topup_used_bytes": premium_topup_used,
|
||||||
|
"premium_used_bytes": local_active_sub.premium_used_bytes,
|
||||||
|
"premium_bonus_bytes": premium_bonus_bytes,
|
||||||
|
"premium_unlimited_override": bool(
|
||||||
|
getattr(local_active_sub, "premium_unlimited_override", False)
|
||||||
|
),
|
||||||
|
"premium_limit_bytes": self._premium_effective_limit_bytes(
|
||||||
|
premium_baseline,
|
||||||
|
premium_topup_balance,
|
||||||
|
premium_topup_used,
|
||||||
|
premium_bonus_bytes,
|
||||||
|
),
|
||||||
|
"premium_is_limited": bool(local_active_sub.premium_is_limited),
|
||||||
|
"premium_period_start_at": getattr(local_active_sub, "premium_period_start_at", None),
|
||||||
|
"premium_squad_labels": premium_access.get("squad_labels") or [],
|
||||||
|
"premium_node_labels": premium_access.get("node_labels") or [],
|
||||||
|
"period_start_at": local_active_sub.period_start_at,
|
||||||
|
"is_throttled": bool(local_active_sub.is_throttled),
|
||||||
|
"base_hwid_device_limit": local_active_sub.hwid_device_limit,
|
||||||
|
"extra_hwid_devices": int(local_active_sub.extra_hwid_devices or 0),
|
||||||
|
"extra_hwid_devices_valid_until": None,
|
||||||
|
"extra_hwid_devices_valid_until_text": None,
|
||||||
|
"extra_hwid_devices_next_valid_from": None,
|
||||||
|
"device_topup_renewal_available": False,
|
||||||
|
"user_bot_username": db_user.username,
|
||||||
|
"is_panel_data": False,
|
||||||
|
"max_devices": self._effective_hwid_limit(
|
||||||
|
local_active_sub.hwid_device_limit,
|
||||||
|
int(local_active_sub.extra_hwid_devices or 0),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
async def switch_tariff_without_payment(
|
async def switch_tariff_without_payment(
|
||||||
self,
|
self,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
@@ -308,6 +477,17 @@ class SubscriptionLifecycleMixin:
|
|||||||
)
|
)
|
||||||
result["end_date"] = sub.end_date
|
result["end_date"] = sub.end_date
|
||||||
result["is_active"] = sub.is_active
|
result["is_active"] = sub.is_active
|
||||||
|
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||||
|
if db_user:
|
||||||
|
await self._send_payment_success_email(
|
||||||
|
db_user=db_user,
|
||||||
|
sale_mode="tariff_upgrade",
|
||||||
|
months=0,
|
||||||
|
traffic_gb=None,
|
||||||
|
payment_amount=payment_amount,
|
||||||
|
end_date=sub.end_date,
|
||||||
|
provider=provider,
|
||||||
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
tariff = self._resolve_tariff(tariff_key, "period") if self._tariffs_config() else None
|
tariff = self._resolve_tariff(tariff_key, "period") if self._tariffs_config() else None
|
||||||
@@ -416,9 +596,7 @@ class SubscriptionLifecycleMixin:
|
|||||||
"Failed to recalculate active HWID devices for renewal of user %s",
|
"Failed to recalculate active HWID devices for renewal of user %s",
|
||||||
user_id,
|
user_id,
|
||||||
)
|
)
|
||||||
extra_hwid_devices = int(
|
extra_hwid_devices = int(getattr(current_active_sub, "extra_hwid_devices", 0) or 0)
|
||||||
getattr(current_active_sub, "extra_hwid_devices", 0) or 0
|
|
||||||
)
|
|
||||||
premium_topup_balance_bytes = int(
|
premium_topup_balance_bytes = int(
|
||||||
getattr(current_active_sub, "premium_topup_balance_bytes", 0) or 0
|
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(
|
active_sub = await subscription_dal.get_active_subscription_by_user_id(
|
||||||
session, user_id, panel_uuid
|
session, user_id, panel_uuid
|
||||||
)
|
)
|
||||||
|
preserve_tariff_limits = bool(
|
||||||
|
active_sub and active_sub.tariff_key and self._tariffs_config()
|
||||||
|
)
|
||||||
if not active_sub or not active_sub.end_date:
|
if not active_sub or not active_sub.end_date:
|
||||||
logging.info(
|
logging.info(
|
||||||
f"No active subscription found for user {user_id}. Creating new one for {bonus_days} days." # noqa: E501
|
f"No active subscription found for user {user_id}. Creating new one for {bonus_days} days." # noqa: E501
|
||||||
@@ -614,6 +795,7 @@ class SubscriptionLifecycleMixin:
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
apply_main_traffic_limit
|
apply_main_traffic_limit
|
||||||
|
and not preserve_tariff_limits
|
||||||
and updated_sub_model
|
and updated_sub_model
|
||||||
and updated_sub_model.traffic_limit_bytes != self.settings.user_traffic_limit_bytes
|
and updated_sub_model.traffic_limit_bytes != self.settings.user_traffic_limit_bytes
|
||||||
):
|
):
|
||||||
@@ -628,7 +810,9 @@ class SubscriptionLifecycleMixin:
|
|||||||
panel_update_payload = self._build_panel_update_payload(
|
panel_update_payload = self._build_panel_update_payload(
|
||||||
expire_at=new_end_date_obj,
|
expire_at=new_end_date_obj,
|
||||||
traffic_limit_bytes=(
|
traffic_limit_bytes=(
|
||||||
self.settings.user_traffic_limit_bytes if apply_main_traffic_limit else None
|
self.settings.user_traffic_limit_bytes
|
||||||
|
if apply_main_traffic_limit and not preserve_tariff_limits
|
||||||
|
else None
|
||||||
),
|
),
|
||||||
include_uuid=False,
|
include_uuid=False,
|
||||||
include_default_squads=False,
|
include_default_squads=False,
|
||||||
@@ -665,15 +849,37 @@ class SubscriptionLifecycleMixin:
|
|||||||
local_active_sub = await subscription_dal.get_active_subscription_by_user_id(
|
local_active_sub = await subscription_dal.get_active_subscription_by_user_id(
|
||||||
session, user_id, panel_user_uuid
|
session, user_id, panel_user_uuid
|
||||||
)
|
)
|
||||||
panel_user_data = await self.panel_service.get_user_by_uuid(panel_user_uuid)
|
(
|
||||||
|
panel_user_data,
|
||||||
|
panel_user_confirmed_absent,
|
||||||
|
panel_lookup_failure_reason,
|
||||||
|
) = await self._lookup_panel_user_for_subscription_details(panel_user_uuid)
|
||||||
|
|
||||||
if not panel_user_data:
|
if not panel_user_data:
|
||||||
|
if panel_user_confirmed_absent:
|
||||||
logging.warning(
|
logging.warning(
|
||||||
f"Panel user {panel_user_uuid} not found on panel for user {user_id}. Clearing local linkage." # noqa: E501
|
"Panel user %s confirmed absent on panel for user %s. "
|
||||||
|
"Clearing local linkage. reason=%s",
|
||||||
|
panel_user_uuid,
|
||||||
|
user_id,
|
||||||
|
panel_lookup_failure_reason,
|
||||||
)
|
)
|
||||||
await subscription_dal.deactivate_all_user_subscriptions(session, user_id)
|
await subscription_dal.deactivate_all_user_subscriptions(session, user_id)
|
||||||
await user_dal.update_user(session, user_id, {"panel_user_uuid": None})
|
await user_dal.update_user(session, user_id, {"panel_user_uuid": None})
|
||||||
return None
|
return None
|
||||||
|
logging.warning(
|
||||||
|
"Panel user %s lookup failed for user %s; treating it as a panel access/API "
|
||||||
|
"problem and preserving local linkage/subscription. reason=%s",
|
||||||
|
panel_user_uuid,
|
||||||
|
user_id,
|
||||||
|
panel_lookup_failure_reason,
|
||||||
|
)
|
||||||
|
if local_active_sub:
|
||||||
|
return await self._local_active_subscription_details_fallback(
|
||||||
|
db_user,
|
||||||
|
local_active_sub,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
panel_lifetime_used = self._extract_lifetime_used_traffic(panel_user_data)
|
panel_lifetime_used = self._extract_lifetime_used_traffic(panel_user_data)
|
||||||
if (
|
if (
|
||||||
@@ -813,9 +1019,7 @@ class SubscriptionLifecycleMixin:
|
|||||||
subscription_id=local_active_sub.subscription_id,
|
subscription_id=local_active_sub.subscription_id,
|
||||||
at=datetime.now(timezone.utc),
|
at=datetime.now(timezone.utc),
|
||||||
)
|
)
|
||||||
active_extra_hwid_devices = int(
|
active_extra_hwid_devices = int(hwid_entitlement_summary.get("active_devices") or 0)
|
||||||
hwid_entitlement_summary.get("active_devices") or 0
|
|
||||||
)
|
|
||||||
if active_extra_hwid_devices != int(local_active_sub.extra_hwid_devices or 0):
|
if active_extra_hwid_devices != int(local_active_sub.extra_hwid_devices or 0):
|
||||||
await subscription_dal.update_subscription(
|
await subscription_dal.update_subscription(
|
||||||
session,
|
session,
|
||||||
@@ -840,6 +1044,14 @@ class SubscriptionLifecycleMixin:
|
|||||||
if expected_hwid_limit is not None:
|
if expected_hwid_limit is not None:
|
||||||
hwid_limit = expected_hwid_limit
|
hwid_limit = expected_hwid_limit
|
||||||
|
|
||||||
|
extra_hwid_valid_until = hwid_entitlement_summary.get("active_until")
|
||||||
|
extra_hwid_next_valid_from = hwid_entitlement_summary.get("next_valid_from")
|
||||||
|
device_topup_renewal_available = self._device_topup_renewal_available(
|
||||||
|
active_extra_hwid_devices,
|
||||||
|
extra_hwid_valid_until,
|
||||||
|
panel_end_date,
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"user_id": panel_user_data.get("uuid"),
|
"user_id": panel_user_data.get("uuid"),
|
||||||
"panel_subscription_uuid": panel_user_data.get("subscriptionUuid")
|
"panel_subscription_uuid": panel_user_data.get("subscriptionUuid")
|
||||||
@@ -900,10 +1112,12 @@ class SubscriptionLifecycleMixin:
|
|||||||
if local_active_sub
|
if local_active_sub
|
||||||
else None,
|
else None,
|
||||||
"extra_hwid_devices": active_extra_hwid_devices,
|
"extra_hwid_devices": active_extra_hwid_devices,
|
||||||
"extra_hwid_devices_valid_until": hwid_entitlement_summary.get("active_until"),
|
"extra_hwid_devices_valid_until": extra_hwid_valid_until,
|
||||||
"extra_hwid_devices_next_valid_from": hwid_entitlement_summary.get(
|
"extra_hwid_devices_valid_until_text": self._display_datetime_text(
|
||||||
"next_valid_from"
|
extra_hwid_valid_until
|
||||||
),
|
),
|
||||||
|
"extra_hwid_devices_next_valid_from": extra_hwid_next_valid_from,
|
||||||
|
"device_topup_renewal_available": device_topup_renewal_available,
|
||||||
"user_bot_username": db_user.username,
|
"user_bot_username": db_user.username,
|
||||||
"is_panel_data": True,
|
"is_panel_data": True,
|
||||||
"max_devices": hwid_limit,
|
"max_devices": hwid_limit,
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ class PaymentContextMixin:
|
|||||||
"""Best-effort branded email confirming the payment. No-op if SMTP or
|
"""Best-effort branded email confirming the payment. No-op if SMTP or
|
||||||
the user's email aren't set. Failures are logged and swallowed so the
|
the user's email aren't set. Failures are logged and swallowed so the
|
||||||
payment flow is never blocked by mail delivery."""
|
payment flow is never blocked by mail delivery."""
|
||||||
if not self.settings.email_auth_configured:
|
if not getattr(self.settings, "email_auth_configured", False):
|
||||||
return
|
return
|
||||||
recipient = (db_user.email or "").strip() if db_user else ""
|
recipient = (db_user.email or "").strip() if db_user else ""
|
||||||
if not recipient:
|
if not recipient:
|
||||||
|
|||||||
@@ -441,9 +441,7 @@ class TariffMixin:
|
|||||||
|
|
||||||
if options.get("mode") == "period_to_period":
|
if options.get("mode") == "period_to_period":
|
||||||
target_monthly = float(options.get("target_monthly_rub") or 0)
|
target_monthly = float(options.get("target_monthly_rub") or 0)
|
||||||
hwid_days = (
|
hwid_days = math.floor((value_rub / target_monthly) * 30) if target_monthly > 0 else 0
|
||||||
math.floor((value_rub / target_monthly) * 30) if target_monthly > 0 else 0
|
|
||||||
)
|
|
||||||
options["converted_hwid_days"] = max(0, hwid_days)
|
options["converted_hwid_days"] = max(0, hwid_days)
|
||||||
options["recalc_days"] = int(options.get("recalc_days") or 0) + max(0, hwid_days)
|
options["recalc_days"] = int(options.get("recalc_days") or 0) + max(0, hwid_days)
|
||||||
options["paid_diff_rub"] = max(
|
options["paid_diff_rub"] = max(
|
||||||
|
|||||||
@@ -54,9 +54,7 @@ class TrafficMixin:
|
|||||||
|
|
||||||
purchase_bytes = self.gb_to_bytes(traffic_gb)
|
purchase_bytes = self.gb_to_bytes(traffic_gb)
|
||||||
extra_hwid_devices = (
|
extra_hwid_devices = (
|
||||||
await self._active_hwid_extra_devices_for_sub(session, active_sub)
|
await self._active_hwid_extra_devices_for_sub(session, active_sub) if active_sub else 0
|
||||||
if active_sub
|
|
||||||
else 0
|
|
||||||
)
|
)
|
||||||
base_hwid_limit = self._base_hwid_limit_for_tariff(tariff)
|
base_hwid_limit = self._base_hwid_limit_for_tariff(tariff)
|
||||||
effective_hwid_limit = self._effective_hwid_limit(base_hwid_limit, extra_hwid_devices)
|
effective_hwid_limit = self._effective_hwid_limit(base_hwid_limit, extra_hwid_devices)
|
||||||
@@ -271,6 +269,15 @@ class TrafficMixin:
|
|||||||
purchased_bytes=purchase_bytes,
|
purchased_bytes=purchase_bytes,
|
||||||
kind="topup",
|
kind="topup",
|
||||||
)
|
)
|
||||||
|
await self._send_payment_success_email(
|
||||||
|
db_user=db_user,
|
||||||
|
sale_mode="topup",
|
||||||
|
months=0,
|
||||||
|
traffic_gb=float(traffic_gb),
|
||||||
|
payment_amount=payment_amount,
|
||||||
|
end_date=getattr(updated_sub, "end_date", None),
|
||||||
|
provider=provider,
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"subscription_id": sub.subscription_id,
|
"subscription_id": sub.subscription_id,
|
||||||
"traffic_limit_bytes": new_limit,
|
"traffic_limit_bytes": new_limit,
|
||||||
@@ -378,6 +385,15 @@ class TrafficMixin:
|
|||||||
purchased_bytes=purchase_bytes,
|
purchased_bytes=purchase_bytes,
|
||||||
kind="premium_topup",
|
kind="premium_topup",
|
||||||
)
|
)
|
||||||
|
await self._send_payment_success_email(
|
||||||
|
db_user=db_user,
|
||||||
|
sale_mode="premium_topup",
|
||||||
|
months=0,
|
||||||
|
traffic_gb=float(traffic_gb),
|
||||||
|
payment_amount=payment_amount,
|
||||||
|
end_date=getattr(sub, "end_date", None),
|
||||||
|
provider=provider,
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"subscription_id": sub.subscription_id,
|
"subscription_id": sub.subscription_id,
|
||||||
"premium_limit_bytes": premium_limit,
|
"premium_limit_bytes": premium_limit,
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ class TrialSubscriptionMixin:
|
|||||||
"status_from_panel": "TRIAL",
|
"status_from_panel": "TRIAL",
|
||||||
"traffic_limit_bytes": self.settings.trial_traffic_limit_bytes,
|
"traffic_limit_bytes": self.settings.trial_traffic_limit_bytes,
|
||||||
"auto_renew_enabled": False,
|
"auto_renew_enabled": False,
|
||||||
|
"provider": "trial",
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
await subscription_dal.upsert_subscription(session, trial_sub_data)
|
await subscription_dal.upsert_subscription(session, trial_sub_data)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
@@ -134,6 +135,16 @@ class SupportService:
|
|||||||
email_auth_service=self.email_auth_service,
|
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:
|
async def _ensure_user_allowed(self, session, user_id: int) -> User:
|
||||||
user = await user_dal.get_user_by_id(session, user_id)
|
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:
|
if not user or user.is_banned or not self.settings.SUPPORT_TICKETS_ENABLED:
|
||||||
@@ -218,8 +229,8 @@ class SupportService:
|
|||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
if notification_decision.send_telegram or notification_decision.send_email:
|
if notification_decision.send_telegram or notification_decision.send_email:
|
||||||
try:
|
self._schedule_notification(
|
||||||
await self.notification_service.notify_support_user_reply(
|
self.notification_service.notify_support_user_reply(
|
||||||
ticket,
|
ticket,
|
||||||
message,
|
message,
|
||||||
user,
|
user,
|
||||||
@@ -227,9 +238,10 @@ class SupportService:
|
|||||||
unread_count=int(ticket.unread_admin_count or 0),
|
unread_count=int(ticket.unread_admin_count or 0),
|
||||||
send_telegram=notification_decision.send_telegram,
|
send_telegram=notification_decision.send_telegram,
|
||||||
send_email=notification_decision.send_email,
|
send_email=notification_decision.send_email,
|
||||||
|
),
|
||||||
|
"Failed to notify about support user reply %s",
|
||||||
|
ticket_id,
|
||||||
)
|
)
|
||||||
except Exception:
|
|
||||||
logger.exception("Failed to notify about support user reply %s", ticket_id)
|
|
||||||
return ticket, message
|
return ticket, message
|
||||||
|
|
||||||
async def reply_as_admin(
|
async def reply_as_admin(
|
||||||
@@ -275,10 +287,11 @@ class SupportService:
|
|||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
if user and not is_internal_note:
|
if user and not is_internal_note:
|
||||||
try:
|
self._schedule_notification(
|
||||||
await self.notification_service.notify_support_admin_reply(ticket, message, user)
|
self.notification_service.notify_support_admin_reply(ticket, message, user),
|
||||||
except Exception:
|
"Failed to notify user about support admin reply %s",
|
||||||
logger.exception("Failed to notify user about support admin reply %s", ticket_id)
|
ticket_id,
|
||||||
|
)
|
||||||
return ticket, message
|
return ticket, message
|
||||||
|
|
||||||
async def change_status(self, admin_id: int, ticket_id: int, status: str) -> SupportTicket:
|
async def change_status(self, admin_id: int, ticket_id: int, status: str) -> SupportTicket:
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any, Optional
|
from typing import Any, Awaitable, Callable, Optional
|
||||||
|
|
||||||
from aiogram import Bot
|
from aiogram import Bot
|
||||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, WebAppInfo
|
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, WebAppInfo
|
||||||
@@ -15,9 +15,11 @@ from bot.infra.redis import redis_lock
|
|||||||
from bot.middlewares.i18n import JsonI18n
|
from bot.middlewares.i18n import JsonI18n
|
||||||
from bot.services.panel_api_service import PanelApiService
|
from bot.services.panel_api_service import PanelApiService
|
||||||
from bot.services.subscription_service import SubscriptionService
|
from bot.services.subscription_service import SubscriptionService
|
||||||
|
from bot.services.user_email_notifications import send_user_notification_email
|
||||||
from bot.utils.date_utils import month_start
|
from bot.utils.date_utils import month_start
|
||||||
from bot.utils.mini_app_url import subscription_mini_app_topup_url
|
from bot.utils.mini_app_url import subscription_mini_app_topup_url
|
||||||
from config.settings import Settings
|
from config.settings import Settings
|
||||||
|
from db.advisory_locks import acquire_subscription_background_sync_lock
|
||||||
from db.dal import subscription_dal, tariff_dal, user_dal
|
from db.dal import subscription_dal, tariff_dal, user_dal
|
||||||
from db.models import Subscription
|
from db.models import Subscription
|
||||||
|
|
||||||
@@ -31,6 +33,10 @@ TARIFF_WORKER_BATCH_SIZE = 50
|
|||||||
TARIFF_WORKER_PANEL_CONCURRENCY = 10
|
TARIFF_WORKER_PANEL_CONCURRENCY = 10
|
||||||
TARIFF_WORKER_BULK_PANEL_FETCH_THRESHOLD = 50
|
TARIFF_WORKER_BULK_PANEL_FETCH_THRESHOLD = 50
|
||||||
TARIFF_WORKER_SQUAD_CONFIRMATION_CACHE_TTL_SECONDS = 900
|
TARIFF_WORKER_SQUAD_CONFIRMATION_CACHE_TTL_SECONDS = 900
|
||||||
|
TARIFF_WORKER_DB_RETRY_ATTEMPTS = 3
|
||||||
|
TARIFF_WORKER_DB_RETRY_BASE_SLEEP_SECONDS = 0.5
|
||||||
|
POSTGRES_RETRYABLE_SQLSTATES = {"40001", "40P01"}
|
||||||
|
POSTGRES_RETRYABLE_ERROR_NAMES = {"DeadlockDetectedError", "SerializationError"}
|
||||||
|
|
||||||
|
|
||||||
class TariffTrafficWorker:
|
class TariffTrafficWorker:
|
||||||
@@ -97,6 +103,36 @@ class TariffTrafficWorker:
|
|||||||
button = InlineKeyboardButton(text=_(fallback_key), callback_data="tariff_topup:list")
|
button = InlineKeyboardButton(text=_(fallback_key), callback_data="tariff_topup:list")
|
||||||
return InlineKeyboardMarkup(inline_keyboard=[[button]])
|
return InlineKeyboardMarkup(inline_keyboard=[[button]])
|
||||||
|
|
||||||
|
async def _send_traffic_warning_email(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
user_id: int,
|
||||||
|
subject_key: str,
|
||||||
|
message_text: str,
|
||||||
|
kind: str,
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
user = await user_dal.get_user_by_id(session, user_id)
|
||||||
|
except Exception:
|
||||||
|
logging.exception("TariffTrafficWorker: failed to load user %s for email", user_id)
|
||||||
|
return
|
||||||
|
if not user:
|
||||||
|
return
|
||||||
|
await send_user_notification_email(
|
||||||
|
settings=self.settings,
|
||||||
|
i18n=self.i18n,
|
||||||
|
user=user,
|
||||||
|
subject_key=subject_key,
|
||||||
|
message_text=message_text,
|
||||||
|
dashboard_url=subscription_mini_app_topup_url(self.settings, kind),
|
||||||
|
cta_label_key=(
|
||||||
|
"email_traffic_warning_premium_cta"
|
||||||
|
if kind == "premium"
|
||||||
|
else "email_traffic_warning_regular_cta"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
async def run(self) -> None:
|
async def run(self) -> None:
|
||||||
if not self.settings.tariffs_config:
|
if not self.settings.tariffs_config:
|
||||||
return
|
return
|
||||||
@@ -111,12 +147,14 @@ class TariffTrafficWorker:
|
|||||||
logging.info("TariffTrafficWorker tick skipped: Redis lock is held")
|
logging.info("TariffTrafficWorker tick skipped: Redis lock is held")
|
||||||
else:
|
else:
|
||||||
started = time.monotonic()
|
started = time.monotonic()
|
||||||
async with self.session_factory() as session:
|
await self._run_db_tick_with_retry(
|
||||||
await self.traffic_period_tick(session)
|
"traffic_period",
|
||||||
await session.commit()
|
self.traffic_period_tick,
|
||||||
async with self.session_factory() as session:
|
)
|
||||||
await self.legacy_throttle_recovery_tick(session)
|
await self._run_db_tick_with_retry(
|
||||||
await session.commit()
|
"legacy_throttle_recovery",
|
||||||
|
self.legacy_throttle_recovery_tick,
|
||||||
|
)
|
||||||
logging.info(
|
logging.info(
|
||||||
"metric worker_tick_duration_seconds=%.3f worker=tariff",
|
"metric worker_tick_duration_seconds=%.3f worker=tariff",
|
||||||
time.monotonic() - started,
|
time.monotonic() - started,
|
||||||
@@ -134,16 +172,80 @@ class TariffTrafficWorker:
|
|||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
self._stopped.set()
|
self._stopped.set()
|
||||||
|
|
||||||
|
async def _run_db_tick_with_retry(
|
||||||
|
self,
|
||||||
|
tick_name: str,
|
||||||
|
tick: Callable[[AsyncSession], Awaitable[None]],
|
||||||
|
) -> None:
|
||||||
|
for attempt in range(1, TARIFF_WORKER_DB_RETRY_ATTEMPTS + 1):
|
||||||
|
async with self.session_factory() as session:
|
||||||
|
try:
|
||||||
|
await acquire_subscription_background_sync_lock(session)
|
||||||
|
await tick(session)
|
||||||
|
await session.commit()
|
||||||
|
return
|
||||||
|
except Exception as exc:
|
||||||
|
await session.rollback()
|
||||||
|
if (
|
||||||
|
attempt < TARIFF_WORKER_DB_RETRY_ATTEMPTS
|
||||||
|
and self._is_retryable_db_exception(exc)
|
||||||
|
):
|
||||||
|
delay = TARIFF_WORKER_DB_RETRY_BASE_SLEEP_SECONDS * attempt
|
||||||
|
logging.warning(
|
||||||
|
"TariffTrafficWorker %s retrying after database concurrency "
|
||||||
|
"error, attempt %s/%s: %s",
|
||||||
|
tick_name,
|
||||||
|
attempt + 1,
|
||||||
|
TARIFF_WORKER_DB_RETRY_ATTEMPTS,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
continue
|
||||||
|
raise
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_retryable_db_exception(exc: BaseException) -> bool:
|
||||||
|
pending: list[BaseException] = [exc]
|
||||||
|
seen: set[int] = set()
|
||||||
|
while pending:
|
||||||
|
current = pending.pop()
|
||||||
|
current_id = id(current)
|
||||||
|
if current_id in seen:
|
||||||
|
continue
|
||||||
|
seen.add(current_id)
|
||||||
|
|
||||||
|
sqlstate = getattr(current, "sqlstate", None) or getattr(current, "pgcode", None)
|
||||||
|
if sqlstate in POSTGRES_RETRYABLE_SQLSTATES:
|
||||||
|
return True
|
||||||
|
|
||||||
|
error_name = type(current).__name__
|
||||||
|
message = str(current).lower()
|
||||||
|
if (
|
||||||
|
error_name in POSTGRES_RETRYABLE_ERROR_NAMES
|
||||||
|
or "deadlock detected" in message
|
||||||
|
or "could not serialize access" in message
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
|
||||||
|
for attr in ("orig", "__cause__", "__context__"):
|
||||||
|
nested = getattr(current, attr, None)
|
||||||
|
if isinstance(nested, BaseException):
|
||||||
|
pending.append(nested)
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
async def traffic_period_tick(self, session: AsyncSession) -> None:
|
async def traffic_period_tick(self, session: AsyncSession) -> None:
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
self._premium_node_usage_tick_cache = {}
|
self._premium_node_usage_tick_cache = {}
|
||||||
warning_period_start = month_start(now)
|
warning_period_start = month_start(now)
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(Subscription).where(
|
select(Subscription)
|
||||||
|
.where(
|
||||||
Subscription.is_active == True,
|
Subscription.is_active == True,
|
||||||
Subscription.end_date > now,
|
Subscription.end_date > now,
|
||||||
Subscription.tariff_key.is_not(None),
|
Subscription.tariff_key.is_not(None),
|
||||||
)
|
)
|
||||||
|
.order_by(Subscription.subscription_id.asc())
|
||||||
)
|
)
|
||||||
subs = list(result.scalars().all())
|
subs = list(result.scalars().all())
|
||||||
if not subs:
|
if not subs:
|
||||||
@@ -454,6 +556,8 @@ class TariffTrafficWorker:
|
|||||||
return
|
return
|
||||||
ratio = used_val / limit_val
|
ratio = used_val / limit_val
|
||||||
levels = list(getattr(self.settings, "tariff_traffic_warning_levels", [85, 90, 95]))
|
levels = list(getattr(self.settings, "tariff_traffic_warning_levels", [85, 90, 95]))
|
||||||
|
if 100 not in levels:
|
||||||
|
levels.append(100)
|
||||||
for level in levels:
|
for level in levels:
|
||||||
threshold = level / 100
|
threshold = level / 100
|
||||||
if ratio < threshold:
|
if ratio < threshold:
|
||||||
@@ -474,8 +578,6 @@ class TariffTrafficWorker:
|
|||||||
level=level,
|
level=level,
|
||||||
traffic_limit_bytes=limit_val if tariff.billing_model == "traffic" else None,
|
traffic_limit_bytes=limit_val if tariff.billing_model == "traffic" else None,
|
||||||
)
|
)
|
||||||
if self.bot:
|
|
||||||
try:
|
|
||||||
user_lang = await self._user_lang(session, sub.user_id)
|
user_lang = await self._user_lang(session, sub.user_id)
|
||||||
_ = (
|
_ = (
|
||||||
(lambda k, **kw: self.i18n.gettext(user_lang, k, **kw))
|
(lambda k, **kw: self.i18n.gettext(user_lang, k, **kw))
|
||||||
@@ -492,12 +594,16 @@ class TariffTrafficWorker:
|
|||||||
left_pct=left_pct,
|
left_pct=left_pct,
|
||||||
**usage,
|
**usage,
|
||||||
)
|
)
|
||||||
|
subject_key = "email_traffic_warning_regular_almost_subject"
|
||||||
else:
|
else:
|
||||||
text = _(
|
text = _(
|
||||||
"traffic_warning_regular_depleted",
|
"traffic_warning_regular_depleted",
|
||||||
tariff_name=tariff_name,
|
tariff_name=tariff_name,
|
||||||
**usage,
|
**usage,
|
||||||
)
|
)
|
||||||
|
subject_key = "email_traffic_warning_regular_depleted_subject"
|
||||||
|
if self.bot:
|
||||||
|
try:
|
||||||
markup = self._traffic_topup_markup(user_lang, "regular")
|
markup = self._traffic_topup_markup(user_lang, "regular")
|
||||||
await self.bot.send_message(
|
await self.bot.send_message(
|
||||||
sub.user_id,
|
sub.user_id,
|
||||||
@@ -507,6 +613,13 @@ class TariffTrafficWorker:
|
|||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logging.exception("Failed to send traffic warning to user %s", sub.user_id)
|
logging.exception("Failed to send traffic warning to user %s", sub.user_id)
|
||||||
|
await self._send_traffic_warning_email(
|
||||||
|
session,
|
||||||
|
user_id=sub.user_id,
|
||||||
|
subject_key=subject_key,
|
||||||
|
message_text=text,
|
||||||
|
kind="regular",
|
||||||
|
)
|
||||||
if ratio >= 1.0 and not sub.is_throttled:
|
if ratio >= 1.0 and not sub.is_throttled:
|
||||||
logging.info(
|
logging.info(
|
||||||
"Tariff traffic limit reached for user %s subscription %s. "
|
"Tariff traffic limit reached for user %s subscription %s. "
|
||||||
@@ -795,8 +908,7 @@ class TariffTrafficWorker:
|
|||||||
if not cached_at:
|
if not cached_at:
|
||||||
return False
|
return False
|
||||||
return (
|
return (
|
||||||
time.monotonic() - float(cached_at)
|
time.monotonic() - float(cached_at) < TARIFF_WORKER_SQUAD_CONFIRMATION_CACHE_TTL_SECONDS
|
||||||
< TARIFF_WORKER_SQUAD_CONFIRMATION_CACHE_TTL_SECONDS
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def _remember_premium_squad_match(self, cache_key: tuple[str, tuple[str, ...]]) -> None:
|
def _remember_premium_squad_match(self, cache_key: tuple[str, tuple[str, ...]]) -> None:
|
||||||
@@ -931,8 +1043,6 @@ class TariffTrafficWorker:
|
|||||||
level=PREMIUM_WARNING_DEPLETED_LEVEL,
|
level=PREMIUM_WARNING_DEPLETED_LEVEL,
|
||||||
traffic_limit_bytes=None,
|
traffic_limit_bytes=None,
|
||||||
)
|
)
|
||||||
if self.bot:
|
|
||||||
try:
|
|
||||||
user_lang = await self._user_lang(session, sub.user_id)
|
user_lang = await self._user_lang(session, sub.user_id)
|
||||||
_ = (
|
_ = (
|
||||||
(lambda k, **kw: self.i18n.gettext(user_lang, k, **kw))
|
(lambda k, **kw: self.i18n.gettext(user_lang, k, **kw))
|
||||||
@@ -956,6 +1066,8 @@ class TariffTrafficWorker:
|
|||||||
servers=servers,
|
servers=servers,
|
||||||
**usage,
|
**usage,
|
||||||
)
|
)
|
||||||
|
if self.bot:
|
||||||
|
try:
|
||||||
markup = self._traffic_topup_markup(user_lang, "premium")
|
markup = self._traffic_topup_markup(user_lang, "premium")
|
||||||
await self.bot.send_message(
|
await self.bot.send_message(
|
||||||
sub.user_id,
|
sub.user_id,
|
||||||
@@ -967,6 +1079,13 @@ class TariffTrafficWorker:
|
|||||||
logging.exception(
|
logging.exception(
|
||||||
"Failed to send premium traffic depleted warning to user %s", sub.user_id
|
"Failed to send premium traffic depleted warning to user %s", sub.user_id
|
||||||
)
|
)
|
||||||
|
await self._send_traffic_warning_email(
|
||||||
|
session,
|
||||||
|
user_id=sub.user_id,
|
||||||
|
subject_key="email_traffic_warning_premium_depleted_subject",
|
||||||
|
message_text=text,
|
||||||
|
kind="premium",
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
for level in levels:
|
for level in levels:
|
||||||
@@ -990,9 +1109,6 @@ class TariffTrafficWorker:
|
|||||||
level=storage_level,
|
level=storage_level,
|
||||||
traffic_limit_bytes=None,
|
traffic_limit_bytes=None,
|
||||||
)
|
)
|
||||||
if not self.bot:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
user_lang = await self._user_lang(session, sub.user_id)
|
user_lang = await self._user_lang(session, sub.user_id)
|
||||||
_ = (
|
_ = (
|
||||||
(lambda k, **kw: self.i18n.gettext(user_lang, k, **kw))
|
(lambda k, **kw: self.i18n.gettext(user_lang, k, **kw))
|
||||||
@@ -1018,6 +1134,8 @@ class TariffTrafficWorker:
|
|||||||
servers=servers,
|
servers=servers,
|
||||||
**usage,
|
**usage,
|
||||||
)
|
)
|
||||||
|
if self.bot:
|
||||||
|
try:
|
||||||
markup = self._traffic_topup_markup(user_lang, "premium")
|
markup = self._traffic_topup_markup(user_lang, "premium")
|
||||||
await self.bot.send_message(
|
await self.bot.send_message(
|
||||||
sub.user_id,
|
sub.user_id,
|
||||||
@@ -1026,7 +1144,16 @@ class TariffTrafficWorker:
|
|||||||
parse_mode="HTML",
|
parse_mode="HTML",
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logging.exception("Failed to send premium traffic warning to user %s", sub.user_id)
|
logging.exception(
|
||||||
|
"Failed to send premium traffic warning to user %s", sub.user_id
|
||||||
|
)
|
||||||
|
await self._send_traffic_warning_email(
|
||||||
|
session,
|
||||||
|
user_id=sub.user_id,
|
||||||
|
subject_key="email_traffic_warning_premium_almost_subject",
|
||||||
|
message_text=text,
|
||||||
|
kind="premium",
|
||||||
|
)
|
||||||
|
|
||||||
async def _premium_node_uuids_for_tariff(self, tariff) -> list[str]:
|
async def _premium_node_uuids_for_tariff(self, tariff) -> list[str]:
|
||||||
cache_key = tuple(sorted(tariff.premium_squad_uuids or []))
|
cache_key = tuple(sorted(tariff.premium_squad_uuids or []))
|
||||||
@@ -1160,10 +1287,12 @@ class TariffTrafficWorker:
|
|||||||
from Internal Squads.
|
from Internal Squads.
|
||||||
"""
|
"""
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(Subscription).where(
|
select(Subscription)
|
||||||
|
.where(
|
||||||
Subscription.is_active == True,
|
Subscription.is_active == True,
|
||||||
Subscription.is_throttled == True,
|
Subscription.is_throttled == True,
|
||||||
)
|
)
|
||||||
|
.order_by(Subscription.subscription_id.asc())
|
||||||
)
|
)
|
||||||
for sub in result.scalars().all():
|
for sub in result.scalars().all():
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -0,0 +1,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,84 @@
|
|||||||
|
import logging
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from bot.middlewares.i18n import JsonI18n
|
||||||
|
from bot.services.email_auth_service import EmailAuthService
|
||||||
|
from bot.services.email_templates import render_user_notification
|
||||||
|
from config.settings import Settings
|
||||||
|
|
||||||
|
|
||||||
|
def _translate(
|
||||||
|
i18n: Optional[JsonI18n],
|
||||||
|
language: str,
|
||||||
|
key: Optional[str],
|
||||||
|
fallback: str = "",
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> str:
|
||||||
|
if not key:
|
||||||
|
return fallback
|
||||||
|
if not i18n:
|
||||||
|
return fallback or key
|
||||||
|
text = i18n.gettext(language, key, **kwargs)
|
||||||
|
return fallback if text == key and fallback else text
|
||||||
|
|
||||||
|
|
||||||
|
async def send_user_notification_email(
|
||||||
|
*,
|
||||||
|
settings: Settings,
|
||||||
|
i18n: Optional[JsonI18n],
|
||||||
|
user: Any,
|
||||||
|
subject_key: str,
|
||||||
|
message_text: str,
|
||||||
|
dashboard_url: Optional[str] = None,
|
||||||
|
cta_label_key: str = "email_user_notification_cta",
|
||||||
|
subject_kwargs: Optional[dict[str, Any]] = None,
|
||||||
|
heading_key: Optional[str] = None,
|
||||||
|
intro_key: Optional[str] = None,
|
||||||
|
) -> bool:
|
||||||
|
if not getattr(settings, "email_auth_configured", False):
|
||||||
|
return False
|
||||||
|
recipient = str(getattr(user, "email", "") or "").strip()
|
||||||
|
if not recipient:
|
||||||
|
return False
|
||||||
|
|
||||||
|
language = (
|
||||||
|
str(getattr(user, "language_code", "") or "").strip()
|
||||||
|
or getattr(settings, "DEFAULT_LANGUAGE", "ru")
|
||||||
|
or "ru"
|
||||||
|
)
|
||||||
|
kwargs = subject_kwargs or {}
|
||||||
|
subject = _translate(i18n, language, subject_key, subject_key, **kwargs)
|
||||||
|
heading = _translate(i18n, language, heading_key, subject, **kwargs)
|
||||||
|
intro = _translate(
|
||||||
|
i18n,
|
||||||
|
language,
|
||||||
|
intro_key or "email_user_notification_intro",
|
||||||
|
"Notification from your account.",
|
||||||
|
)
|
||||||
|
cta_label = _translate(
|
||||||
|
i18n,
|
||||||
|
language,
|
||||||
|
cta_label_key or "email_user_notification_cta",
|
||||||
|
"Open dashboard",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
content = render_user_notification(
|
||||||
|
settings,
|
||||||
|
language_code=language,
|
||||||
|
subject=subject,
|
||||||
|
heading=heading,
|
||||||
|
intro=intro,
|
||||||
|
message_text=message_text,
|
||||||
|
dashboard_url=dashboard_url,
|
||||||
|
cta_label=cta_label,
|
||||||
|
i18n=i18n,
|
||||||
|
)
|
||||||
|
await EmailAuthService(settings, i18n).send_rendered_email(
|
||||||
|
email=recipient,
|
||||||
|
content=content,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
logging.exception("Failed to send user notification email to %s.", recipient)
|
||||||
|
return False
|
||||||
@@ -114,6 +114,33 @@ class Settings(BaseSettings):
|
|||||||
TARIFF_WORKER_LOCK_TTL_SECONDS: int = Field(default=240)
|
TARIFF_WORKER_LOCK_TTL_SECONDS: int = Field(default=240)
|
||||||
TARIFF_WORKER_TICK_SECONDS: int = Field(default=300)
|
TARIFF_WORKER_TICK_SECONDS: int = Field(default=300)
|
||||||
TARIFF_WORKER_BULK_PANEL_FETCH_THRESHOLD: int = Field(default=50)
|
TARIFF_WORKER_BULK_PANEL_FETCH_THRESHOLD: int = Field(default=50)
|
||||||
|
BACKUP_ENABLED: bool = Field(
|
||||||
|
default=False,
|
||||||
|
description="Run periodic backup jobs from the worker container.",
|
||||||
|
)
|
||||||
|
BACKUP_INTERVAL_SECONDS: int = Field(default=60 * 60)
|
||||||
|
BACKUP_LOCK_TTL_SECONDS: int = Field(default=2 * 60 * 60)
|
||||||
|
BACKUP_DIR: str = Field(default="data/backups")
|
||||||
|
BACKUP_LOCAL_RETENTION: int = Field(default=100)
|
||||||
|
BACKUP_CHAT_ID: Optional[int] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Telegram chat ID for backup archives. Falls back to LOG_CHAT_ID.",
|
||||||
|
)
|
||||||
|
BACKUP_THREAD_ID: Optional[int] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Telegram topic/thread ID for backup archives. Falls back to LOG_THREAD_ID.",
|
||||||
|
)
|
||||||
|
BACKUP_POSTGRES_DUMP_ENABLED: bool = Field(default=True)
|
||||||
|
BACKUP_PG_DUMP_PATH: str = Field(default="pg_dump")
|
||||||
|
BACKUP_PG_DUMP_TIMEOUT_SECONDS: int = Field(default=30 * 60)
|
||||||
|
BACKUP_PG_RESTORE_PATH: str = Field(default="pg_restore")
|
||||||
|
BACKUP_PG_RESTORE_TIMEOUT_SECONDS: int = Field(default=30 * 60)
|
||||||
|
BACKUP_COMPOSE_ENABLED: bool = Field(default=True)
|
||||||
|
BACKUP_COMPOSE_SOURCE_DIR: Optional[str] = Field(default="/app/compose-source")
|
||||||
|
BACKUP_COMPOSE_RESTORE_DIR: Optional[str] = Field(default=None)
|
||||||
|
BACKUP_COMPOSE_EXCLUDE_DIRS: str = Field(
|
||||||
|
default=".git,node_modules,__pycache__,.pytest_cache,.ruff_cache,postgres-data,redis-data,shop-data,backups"
|
||||||
|
)
|
||||||
|
|
||||||
DEFAULT_LANGUAGE: str = Field(default="ru")
|
DEFAULT_LANGUAGE: str = Field(default="ru")
|
||||||
DEFAULT_CURRENCY_SYMBOL: str = Field(default="RUB")
|
DEFAULT_CURRENCY_SYMBOL: str = Field(default="RUB")
|
||||||
@@ -187,10 +214,10 @@ class Settings(BaseSettings):
|
|||||||
MONTH_6_ENABLED: bool = Field(default=True, alias="6_MONTHS_ENABLED")
|
MONTH_6_ENABLED: bool = Field(default=True, alias="6_MONTHS_ENABLED")
|
||||||
MONTH_12_ENABLED: bool = Field(default=True, alias="12_MONTHS_ENABLED")
|
MONTH_12_ENABLED: bool = Field(default=True, alias="12_MONTHS_ENABLED")
|
||||||
|
|
||||||
RUB_PRICE_1_MONTH: Optional[int] = Field(default=None)
|
RUB_PRICE_1_MONTH: Optional[int] = Field(default=200)
|
||||||
RUB_PRICE_3_MONTHS: Optional[int] = Field(default=None)
|
RUB_PRICE_3_MONTHS: Optional[int] = Field(default=600)
|
||||||
RUB_PRICE_6_MONTHS: Optional[int] = Field(default=None)
|
RUB_PRICE_6_MONTHS: Optional[int] = Field(default=1200)
|
||||||
RUB_PRICE_12_MONTHS: Optional[int] = Field(default=None)
|
RUB_PRICE_12_MONTHS: Optional[int] = Field(default=2400)
|
||||||
|
|
||||||
STARS_PRICE_1_MONTH: Optional[int] = Field(default=None)
|
STARS_PRICE_1_MONTH: Optional[int] = Field(default=None)
|
||||||
STARS_PRICE_3_MONTHS: Optional[int] = Field(default=None)
|
STARS_PRICE_3_MONTHS: Optional[int] = Field(default=None)
|
||||||
@@ -213,9 +240,12 @@ class Settings(BaseSettings):
|
|||||||
)
|
)
|
||||||
|
|
||||||
SUBSCRIPTION_NOTIFICATIONS_ENABLED: bool = Field(default=True)
|
SUBSCRIPTION_NOTIFICATIONS_ENABLED: bool = Field(default=True)
|
||||||
|
SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED: bool = Field(default=True)
|
||||||
SUBSCRIPTION_NOTIFY_ON_EXPIRE: bool = Field(default=True)
|
SUBSCRIPTION_NOTIFY_ON_EXPIRE: bool = Field(default=True)
|
||||||
SUBSCRIPTION_NOTIFY_AFTER_EXPIRE: bool = Field(default=True)
|
SUBSCRIPTION_NOTIFY_AFTER_EXPIRE: bool = Field(default=True)
|
||||||
SUBSCRIPTION_NOTIFY_DAYS_BEFORE: int = Field(default=3)
|
SUBSCRIPTION_NOTIFY_DAYS_BEFORE: int = Field(default=3)
|
||||||
|
SUBSCRIPTION_NOTIFY_HOURS_BEFORE: int = Field(default=3)
|
||||||
|
SUBSCRIPTION_NOTIFICATION_WORKER_TICK_SECONDS: int = Field(default=300)
|
||||||
|
|
||||||
REFERRAL_BONUS_DAYS_INVITER_1_MONTH: Optional[int] = Field(
|
REFERRAL_BONUS_DAYS_INVITER_1_MONTH: Optional[int] = Field(
|
||||||
default=3, alias="REFERRAL_BONUS_DAYS_1_MONTH"
|
default=3, alias="REFERRAL_BONUS_DAYS_1_MONTH"
|
||||||
@@ -303,7 +333,7 @@ class Settings(BaseSettings):
|
|||||||
)
|
)
|
||||||
WEBAPP_SERVER_HOST: str = Field(default="0.0.0.0")
|
WEBAPP_SERVER_HOST: str = Field(default="0.0.0.0")
|
||||||
WEBAPP_SERVER_PORT: int = Field(default=8081)
|
WEBAPP_SERVER_PORT: int = Field(default=8081)
|
||||||
WEBAPP_TITLE: str = Field(default="Моя подписка")
|
WEBAPP_TITLE: str = Field(default="/minishop")
|
||||||
WEBAPP_PRIMARY_COLOR: str = Field(default="#00fe7a")
|
WEBAPP_PRIMARY_COLOR: str = Field(default="#00fe7a")
|
||||||
WEBAPP_THEMES_DIR: str = Field(
|
WEBAPP_THEMES_DIR: str = Field(
|
||||||
default="data/themes",
|
default="data/themes",
|
||||||
@@ -947,7 +977,14 @@ class Settings(BaseSettings):
|
|||||||
return v
|
return v
|
||||||
return secrets.token_urlsafe(32)
|
return secrets.token_urlsafe(32)
|
||||||
|
|
||||||
@field_validator("LOG_CHAT_ID", "LOG_THREAD_ID", "LOG_SUPPORT_THREAD_ID", mode="before")
|
@field_validator(
|
||||||
|
"LOG_CHAT_ID",
|
||||||
|
"LOG_THREAD_ID",
|
||||||
|
"LOG_SUPPORT_THREAD_ID",
|
||||||
|
"BACKUP_CHAT_ID",
|
||||||
|
"BACKUP_THREAD_ID",
|
||||||
|
mode="before",
|
||||||
|
)
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_optional_int_fields(cls, v):
|
def validate_optional_int_fields(cls, v):
|
||||||
"""Convert empty strings to None for optional integer fields"""
|
"""Convert empty strings to None for optional integer fields"""
|
||||||
@@ -969,6 +1006,8 @@ class Settings(BaseSettings):
|
|||||||
"SMTP_FROM_EMAIL",
|
"SMTP_FROM_EMAIL",
|
||||||
"SMTP_FROM_NAME",
|
"SMTP_FROM_NAME",
|
||||||
"SMTP_FALLBACK_PORTS",
|
"SMTP_FALLBACK_PORTS",
|
||||||
|
"BACKUP_COMPOSE_SOURCE_DIR",
|
||||||
|
"BACKUP_COMPOSE_RESTORE_DIR",
|
||||||
mode="before",
|
mode="before",
|
||||||
)
|
)
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
@@ -95,6 +95,8 @@ class Tariff(BaseModel):
|
|||||||
monthly_gb: Optional[float] = None
|
monthly_gb: Optional[float] = None
|
||||||
prices_rub: Dict[str, float] = Field(default_factory=dict)
|
prices_rub: Dict[str, float] = Field(default_factory=dict)
|
||||||
prices_stars: Dict[str, float] = Field(default_factory=dict)
|
prices_stars: Dict[str, float] = Field(default_factory=dict)
|
||||||
|
referral_bonus_days_inviter: Dict[str, int] = Field(default_factory=dict)
|
||||||
|
referral_bonus_days_referee: Dict[str, int] = Field(default_factory=dict)
|
||||||
enabled_periods: List[int] = Field(default_factory=list)
|
enabled_periods: List[int] = Field(default_factory=list)
|
||||||
topup_packages: Optional[PackageSet] = None
|
topup_packages: Optional[PackageSet] = None
|
||||||
|
|
||||||
@@ -129,6 +131,14 @@ class Tariff(BaseModel):
|
|||||||
if self.billing_model == "period":
|
if self.billing_model == "period":
|
||||||
if self.monthly_gb is None or self.monthly_gb < 0:
|
if self.monthly_gb is None or self.monthly_gb < 0:
|
||||||
raise ValueError(f"period tariff {self.key}: monthly_gb must be >= 0")
|
raise ValueError(f"period tariff {self.key}: monthly_gb must be >= 0")
|
||||||
|
self.referral_bonus_days_inviter = self._normalize_referral_bonus_map(
|
||||||
|
self.referral_bonus_days_inviter,
|
||||||
|
"referral_bonus_days_inviter",
|
||||||
|
)
|
||||||
|
self.referral_bonus_days_referee = self._normalize_referral_bonus_map(
|
||||||
|
self.referral_bonus_days_referee,
|
||||||
|
"referral_bonus_days_referee",
|
||||||
|
)
|
||||||
if not self.enabled_periods:
|
if not self.enabled_periods:
|
||||||
raise ValueError(f"period tariff {self.key}: enabled_periods is required")
|
raise ValueError(f"period tariff {self.key}: enabled_periods is required")
|
||||||
for months in self.enabled_periods:
|
for months in self.enabled_periods:
|
||||||
@@ -152,6 +162,23 @@ class Tariff(BaseModel):
|
|||||||
)
|
)
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
def _normalize_referral_bonus_map(
|
||||||
|
self, values: Dict[str, int], field_name: str
|
||||||
|
) -> Dict[str, int]:
|
||||||
|
normalized: Dict[str, int] = {}
|
||||||
|
for period, days in (values or {}).items():
|
||||||
|
try:
|
||||||
|
months = int(float(str(period).strip()))
|
||||||
|
bonus_days = int(float(days))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise ValueError(f"tariff {self.key}: {field_name} contains invalid entry")
|
||||||
|
if months <= 0:
|
||||||
|
raise ValueError(f"tariff {self.key}: {field_name} periods must be positive")
|
||||||
|
if bonus_days < 0:
|
||||||
|
raise ValueError(f"tariff {self.key}: {field_name} days must be >= 0")
|
||||||
|
normalized[str(months)] = bonus_days
|
||||||
|
return normalized
|
||||||
|
|
||||||
def name(self, lang: str, fallback: str = "ru") -> str:
|
def name(self, lang: str, fallback: str = "ru") -> str:
|
||||||
return self.names.get(lang) or self.names.get(fallback) or self.key
|
return self.names.get(lang) or self.names.get(fallback) or self.key
|
||||||
|
|
||||||
@@ -173,6 +200,14 @@ class Tariff(BaseModel):
|
|||||||
value = source.get(str(months))
|
value = source.get(str(months))
|
||||||
return float(value) if value is not None else None
|
return float(value) if value is not None else None
|
||||||
|
|
||||||
|
def referral_inviter_bonus_days(self, months: int) -> Optional[int]:
|
||||||
|
value = self.referral_bonus_days_inviter.get(str(int(months)))
|
||||||
|
return int(value) if value is not None else None
|
||||||
|
|
||||||
|
def referral_referee_bonus_days(self, months: int) -> Optional[int]:
|
||||||
|
value = self.referral_bonus_days_referee.get(str(int(months)))
|
||||||
|
return int(value) if value is not None else None
|
||||||
|
|
||||||
def min_period_price_rub(self) -> Optional[float]:
|
def min_period_price_rub(self) -> Optional[float]:
|
||||||
prices = [
|
prices = [
|
||||||
float(self.prices_rub[str(months)])
|
float(self.prices_rub[str(months)])
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
# Serializes background jobs that rewrite subscription rows from panel state.
|
||||||
|
SUBSCRIPTION_BACKGROUND_SYNC_LOCK_ID = 817512404897421338
|
||||||
|
|
||||||
|
|
||||||
|
async def acquire_subscription_background_sync_lock(session: AsyncSession) -> None:
|
||||||
|
await session.execute(
|
||||||
|
text("SELECT pg_advisory_xact_lock(:lock_id)"),
|
||||||
|
{"lock_id": SUBSCRIPTION_BACKGROUND_SYNC_LOCK_ID},
|
||||||
|
)
|
||||||
@@ -9,7 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from sqlalchemy.future import select
|
from sqlalchemy.future import select
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from db.models import Subscription
|
from db.models import Subscription, SubscriptionNotification
|
||||||
|
|
||||||
INSTALL_SHARE_TOKEN_BYTES = 16
|
INSTALL_SHARE_TOKEN_BYTES = 16
|
||||||
|
|
||||||
@@ -318,6 +318,45 @@ async def update_subscription_notification_time(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def has_subscription_notification(
|
||||||
|
session: AsyncSession,
|
||||||
|
subscription_id: int,
|
||||||
|
notification_key: str,
|
||||||
|
) -> bool:
|
||||||
|
stmt = (
|
||||||
|
select(SubscriptionNotification.notification_id)
|
||||||
|
.where(
|
||||||
|
SubscriptionNotification.subscription_id == subscription_id,
|
||||||
|
SubscriptionNotification.notification_key == notification_key,
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
return result.scalar_one_or_none() is not None
|
||||||
|
|
||||||
|
|
||||||
|
async def record_subscription_notification(
|
||||||
|
session: AsyncSession,
|
||||||
|
subscription_id: int,
|
||||||
|
notification_key: str,
|
||||||
|
*,
|
||||||
|
sent_at: Optional[datetime] = None,
|
||||||
|
) -> None:
|
||||||
|
if sent_at is None:
|
||||||
|
sent_at = datetime.now(timezone.utc)
|
||||||
|
existing = await has_subscription_notification(session, subscription_id, notification_key)
|
||||||
|
if existing:
|
||||||
|
return
|
||||||
|
session.add(
|
||||||
|
SubscriptionNotification(
|
||||||
|
subscription_id=subscription_id,
|
||||||
|
notification_key=notification_key,
|
||||||
|
sent_at=sent_at,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await update_subscription_notification_time(session, subscription_id, sent_at)
|
||||||
|
|
||||||
|
|
||||||
async def find_subscription_for_notification_update(
|
async def find_subscription_for_notification_update(
|
||||||
session: AsyncSession, user_id: int, subscription_end_date_to_match: datetime
|
session: AsyncSession, user_id: int, subscription_end_date_to_match: datetime
|
||||||
) -> Optional[Subscription]:
|
) -> Optional[Subscription]:
|
||||||
|
|||||||
@@ -41,9 +41,7 @@ async def sum_traffic_topups(
|
|||||||
if created_at_gte is not None:
|
if created_at_gte is not None:
|
||||||
conditions.append(TrafficTopup.created_at >= created_at_gte)
|
conditions.append(TrafficTopup.created_at >= created_at_gte)
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(func.coalesce(func.sum(TrafficTopup.purchased_bytes), 0)).where(
|
select(func.coalesce(func.sum(TrafficTopup.purchased_bytes), 0)).where(and_(*conditions))
|
||||||
and_(*conditions)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
return int(result.scalar() or 0)
|
return int(result.scalar() or 0)
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from ..models import (
|
|||||||
Payment,
|
Payment,
|
||||||
PromoCodeActivation,
|
PromoCodeActivation,
|
||||||
Subscription,
|
Subscription,
|
||||||
|
SubscriptionNotification,
|
||||||
SupportTicket,
|
SupportTicket,
|
||||||
SupportTicketMessage,
|
SupportTicketMessage,
|
||||||
TariffChange,
|
TariffChange,
|
||||||
@@ -407,6 +408,27 @@ async def merge_users(
|
|||||||
target.channel_subscription_checked_at = source.channel_subscription_checked_at
|
target.channel_subscription_checked_at = source.channel_subscription_checked_at
|
||||||
if not target.channel_subscription_verified_for and source.channel_subscription_verified_for:
|
if not target.channel_subscription_verified_for and source.channel_subscription_verified_for:
|
||||||
target.channel_subscription_verified_for = source.channel_subscription_verified_for
|
target.channel_subscription_verified_for = source.channel_subscription_verified_for
|
||||||
|
source_tg_status = str(getattr(source, "telegram_notifications_status", None) or "unknown")
|
||||||
|
target_tg_status = str(getattr(target, "telegram_notifications_status", None) or "unknown")
|
||||||
|
if source_tg_status == "enabled" and target_tg_status != "enabled":
|
||||||
|
target.telegram_notifications_status = source_tg_status
|
||||||
|
elif target_tg_status == "unknown" and source_tg_status != "unknown":
|
||||||
|
target.telegram_notifications_status = source_tg_status
|
||||||
|
if getattr(source, "telegram_notifications_checked_at", None) and (
|
||||||
|
not getattr(target, "telegram_notifications_checked_at", None)
|
||||||
|
or source.telegram_notifications_checked_at > target.telegram_notifications_checked_at
|
||||||
|
):
|
||||||
|
target.telegram_notifications_checked_at = source.telegram_notifications_checked_at
|
||||||
|
if getattr(source, "telegram_notifications_enabled_at", None) and (
|
||||||
|
not getattr(target, "telegram_notifications_enabled_at", None)
|
||||||
|
or source.telegram_notifications_enabled_at > target.telegram_notifications_enabled_at
|
||||||
|
):
|
||||||
|
target.telegram_notifications_enabled_at = source.telegram_notifications_enabled_at
|
||||||
|
if getattr(source, "telegram_notifications_blocked_at", None) and (
|
||||||
|
not getattr(target, "telegram_notifications_blocked_at", None)
|
||||||
|
or source.telegram_notifications_blocked_at > target.telegram_notifications_blocked_at
|
||||||
|
):
|
||||||
|
target.telegram_notifications_blocked_at = source.telegram_notifications_blocked_at
|
||||||
if source.lifetime_used_traffic_bytes is not None:
|
if source.lifetime_used_traffic_bytes is not None:
|
||||||
target.lifetime_used_traffic_bytes = (
|
target.lifetime_used_traffic_bytes = (
|
||||||
target.lifetime_used_traffic_bytes or 0
|
target.lifetime_used_traffic_bytes or 0
|
||||||
@@ -644,17 +666,33 @@ async def get_enhanced_user_statistics(session: AsyncSession) -> Dict[str, Any]:
|
|||||||
active_today = int(user_counts[2] or 0)
|
active_today = int(user_counts[2] or 0)
|
||||||
referral_users = int(user_counts[3] or 0)
|
referral_users = int(user_counts[3] or 0)
|
||||||
|
|
||||||
subscription_counts_stmt = (
|
provider_value = func.lower(func.coalesce(Subscription.provider, ""))
|
||||||
|
panel_status_value = func.upper(func.coalesce(Subscription.status_from_panel, ""))
|
||||||
|
trial_subscription_condition = or_(
|
||||||
|
provider_value == "trial",
|
||||||
|
panel_status_value == "TRIAL",
|
||||||
|
)
|
||||||
|
paid_subscription_condition = and_(
|
||||||
|
provider_value != "",
|
||||||
|
provider_value != "trial",
|
||||||
|
panel_status_value != "TRIAL",
|
||||||
|
)
|
||||||
|
free_subscription_condition = and_(
|
||||||
|
provider_value == "",
|
||||||
|
panel_status_value != "TRIAL",
|
||||||
|
)
|
||||||
|
|
||||||
|
active_subscription_flags_sq = (
|
||||||
select(
|
select(
|
||||||
func.count(
|
Subscription.user_id.label("user_id"),
|
||||||
func.distinct(
|
func.max(case((paid_subscription_condition, 1), else_=0)).label(
|
||||||
case((Subscription.provider.is_not(None), Subscription.user_id), else_=None)
|
"has_paid_subscription"
|
||||||
)
|
|
||||||
),
|
),
|
||||||
func.count(
|
func.max(case((trial_subscription_condition, 1), else_=0)).label(
|
||||||
func.distinct(
|
"has_trial_subscription"
|
||||||
case((Subscription.provider.is_(None), Subscription.user_id), else_=None)
|
),
|
||||||
)
|
func.max(case((free_subscription_condition, 1), else_=0)).label(
|
||||||
|
"has_free_subscription"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.join(User, Subscription.user_id == User.user_id)
|
.join(User, Subscription.user_id == User.user_id)
|
||||||
@@ -664,27 +702,64 @@ async def get_enhanced_user_statistics(session: AsyncSession) -> Dict[str, Any]:
|
|||||||
Subscription.end_date > now,
|
Subscription.end_date > now,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
.group_by(Subscription.user_id)
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
|
||||||
|
subscription_counts_stmt = select(
|
||||||
|
func.count(active_subscription_flags_sq.c.user_id),
|
||||||
|
func.coalesce(func.sum(active_subscription_flags_sq.c.has_paid_subscription), 0),
|
||||||
|
func.coalesce(
|
||||||
|
func.sum(
|
||||||
|
case(
|
||||||
|
(
|
||||||
|
active_subscription_flags_sq.c.has_paid_subscription == 0,
|
||||||
|
active_subscription_flags_sq.c.has_trial_subscription,
|
||||||
|
),
|
||||||
|
else_=0,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
func.coalesce(
|
||||||
|
func.sum(
|
||||||
|
case(
|
||||||
|
(
|
||||||
|
and_(
|
||||||
|
active_subscription_flags_sq.c.has_paid_subscription == 0,
|
||||||
|
active_subscription_flags_sq.c.has_trial_subscription == 0,
|
||||||
|
),
|
||||||
|
active_subscription_flags_sq.c.has_free_subscription,
|
||||||
|
),
|
||||||
|
else_=0,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
0,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
subscription_counts = (await session.execute(subscription_counts_stmt)).one()
|
subscription_counts = (await session.execute(subscription_counts_stmt)).one()
|
||||||
paid_subs_users = int(subscription_counts[0] or 0)
|
active_subscription_users = int(subscription_counts[0] or 0)
|
||||||
trial_users = int(subscription_counts[1] or 0)
|
paid_subs_users = int(subscription_counts[1] or 0)
|
||||||
|
trial_users = int(subscription_counts[2] or 0)
|
||||||
|
free_subscription_users = int(subscription_counts[3] or 0)
|
||||||
|
|
||||||
# Inactive users (no active subscription)
|
inactive_users = total_users - active_subscription_users
|
||||||
inactive_users = total_users - paid_subs_users - trial_users - banned_users
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"total_users": total_users,
|
"total_users": total_users,
|
||||||
"banned_users": banned_users,
|
"banned_users": banned_users,
|
||||||
"active_today": active_today,
|
"active_today": active_today,
|
||||||
|
"active_subscriptions": active_subscription_users,
|
||||||
"paid_subscriptions": paid_subs_users,
|
"paid_subscriptions": paid_subs_users,
|
||||||
"trial_users": trial_users,
|
"trial_users": trial_users,
|
||||||
|
"free_subscription_users": free_subscription_users,
|
||||||
"inactive_users": max(0, inactive_users),
|
"inactive_users": max(0, inactive_users),
|
||||||
"referral_users": referral_users,
|
"referral_users": referral_users,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async def get_user_ids_with_active_subscription(session: AsyncSession) -> List[int]:
|
async def get_user_ids_with_active_subscription(session: AsyncSession) -> List[int]:
|
||||||
"""Return non-banned user IDs who have an active subscription (paid or trial)."""
|
"""Return non-banned user IDs who have any active subscription."""
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
@@ -779,6 +854,11 @@ async def delete_user_and_relations(session: AsyncSession, user_id: int) -> bool
|
|||||||
await session.execute(
|
await session.execute(
|
||||||
delete(TrafficWarning).where(TrafficWarning.subscription_id.in_(subscription_ids))
|
delete(TrafficWarning).where(TrafficWarning.subscription_id.in_(subscription_ids))
|
||||||
)
|
)
|
||||||
|
await session.execute(
|
||||||
|
delete(SubscriptionNotification).where(
|
||||||
|
SubscriptionNotification.subscription_id.in_(subscription_ids)
|
||||||
|
)
|
||||||
|
)
|
||||||
await session.execute(
|
await session.execute(
|
||||||
delete(SupportTicketMessage).where(SupportTicketMessage.ticket_id.in_(support_ticket_ids))
|
delete(SupportTicketMessage).where(SupportTicketMessage.ticket_id.in_(support_ticket_ids))
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -925,9 +925,7 @@ def _migration_0029_add_hwid_device_purchase_validity(connection: Connection) ->
|
|||||||
if "hwid_device_purchases" not in table_names or "subscriptions" not in table_names:
|
if "hwid_device_purchases" not in table_names or "subscriptions" not in table_names:
|
||||||
return
|
return
|
||||||
|
|
||||||
columns: Set[str] = {
|
columns: Set[str] = {col["name"] for col in inspector.get_columns("hwid_device_purchases")}
|
||||||
col["name"] for col in inspector.get_columns("hwid_device_purchases")
|
|
||||||
}
|
|
||||||
if "valid_from" not in columns:
|
if "valid_from" not in columns:
|
||||||
connection.execute(
|
connection.execute(
|
||||||
text("ALTER TABLE hwid_device_purchases ADD COLUMN valid_from TIMESTAMPTZ")
|
text("ALTER TABLE hwid_device_purchases ADD COLUMN valid_from TIMESTAMPTZ")
|
||||||
@@ -1002,9 +1000,7 @@ def _migration_0030_add_hwid_pricing_metadata(connection: Connection) -> None:
|
|||||||
connection.execute(text(f"ALTER TABLE payments ADD COLUMN {column} {ddl_type}"))
|
connection.execute(text(f"ALTER TABLE payments ADD COLUMN {column} {ddl_type}"))
|
||||||
|
|
||||||
if "tariff_changes" in table_names:
|
if "tariff_changes" in table_names:
|
||||||
change_columns: Set[str] = {
|
change_columns: Set[str] = {col["name"] for col in inspector.get_columns("tariff_changes")}
|
||||||
col["name"] for col in inspector.get_columns("tariff_changes")
|
|
||||||
}
|
|
||||||
change_additions = {
|
change_additions = {
|
||||||
"converted_hwid_value_rub": "NUMERIC",
|
"converted_hwid_value_rub": "NUMERIC",
|
||||||
"converted_hwid_days": "INTEGER",
|
"converted_hwid_days": "INTEGER",
|
||||||
@@ -1016,6 +1012,55 @@ def _migration_0030_add_hwid_pricing_metadata(connection: Connection) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _migration_0031_add_subscription_notifications(connection: Connection) -> None:
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS subscription_notifications (
|
||||||
|
notification_id SERIAL PRIMARY KEY,
|
||||||
|
subscription_id INTEGER NOT NULL REFERENCES subscriptions(subscription_id),
|
||||||
|
notification_key VARCHAR(64) NOT NULL,
|
||||||
|
sent_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT uq_subscription_notification_key UNIQUE (
|
||||||
|
subscription_id,
|
||||||
|
notification_key
|
||||||
|
)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_subscription_notifications_subscription_id
|
||||||
|
ON subscription_notifications (subscription_id)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_subscription_notifications_notification_key
|
||||||
|
ON subscription_notifications (notification_key)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _migration_0032_add_telegram_notification_status(connection: Connection) -> None:
|
||||||
|
inspector = inspect(connection)
|
||||||
|
columns: Set[str] = {col["name"] for col in inspector.get_columns("users")}
|
||||||
|
additions = {
|
||||||
|
"telegram_notifications_status": "VARCHAR(32) NOT NULL DEFAULT 'unknown'",
|
||||||
|
"telegram_notifications_checked_at": "TIMESTAMPTZ",
|
||||||
|
"telegram_notifications_enabled_at": "TIMESTAMPTZ",
|
||||||
|
"telegram_notifications_blocked_at": "TIMESTAMPTZ",
|
||||||
|
}
|
||||||
|
for column, ddl_type in additions.items():
|
||||||
|
if column not in columns:
|
||||||
|
connection.execute(text(f"ALTER TABLE users ADD COLUMN {column} {ddl_type}"))
|
||||||
|
|
||||||
|
|
||||||
MIGRATIONS: List[Migration] = [
|
MIGRATIONS: List[Migration] = [
|
||||||
Migration(
|
Migration(
|
||||||
id="0001_add_channel_subscription_fields",
|
id="0001_add_channel_subscription_fields",
|
||||||
@@ -1178,6 +1223,16 @@ MIGRATIONS: List[Migration] = [
|
|||||||
description="Persist quoted HWID top-up pricing windows and conversion audit",
|
description="Persist quoted HWID top-up pricing windows and conversion audit",
|
||||||
upgrade=_migration_0030_add_hwid_pricing_metadata,
|
upgrade=_migration_0030_add_hwid_pricing_metadata,
|
||||||
),
|
),
|
||||||
|
Migration(
|
||||||
|
id="0031_add_subscription_notifications",
|
||||||
|
description="Track sent subscription notification stages",
|
||||||
|
upgrade=_migration_0031_add_subscription_notifications,
|
||||||
|
),
|
||||||
|
Migration(
|
||||||
|
id="0032_add_telegram_notification_status",
|
||||||
|
description="Track whether the bot can message Telegram-linked users",
|
||||||
|
upgrade=_migration_0032_add_telegram_notification_status,
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ class User(Base):
|
|||||||
password_set_at = Column(DateTime(timezone=True), nullable=True)
|
password_set_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
telegram_id = Column(BigInteger, nullable=True, unique=True, index=True)
|
telegram_id = Column(BigInteger, nullable=True, unique=True, index=True)
|
||||||
telegram_photo_url = Column(Text, nullable=True)
|
telegram_photo_url = Column(Text, nullable=True)
|
||||||
|
telegram_notifications_status = Column(String(32), nullable=False, default="unknown")
|
||||||
|
telegram_notifications_checked_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
telegram_notifications_enabled_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
telegram_notifications_blocked_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
first_name = Column(String, nullable=True)
|
first_name = Column(String, nullable=True)
|
||||||
last_name = Column(String, nullable=True)
|
last_name = Column(String, nullable=True)
|
||||||
language_code = Column(String, default="ru")
|
language_code = Column(String, default="ru")
|
||||||
@@ -277,6 +281,26 @@ class TrafficWarning(Base):
|
|||||||
subscription = relationship("Subscription")
|
subscription = relationship("Subscription")
|
||||||
|
|
||||||
|
|
||||||
|
class SubscriptionNotification(Base):
|
||||||
|
__tablename__ = "subscription_notifications"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"subscription_id",
|
||||||
|
"notification_key",
|
||||||
|
name="uq_subscription_notification_key",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
notification_id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
subscription_id = Column(
|
||||||
|
Integer, ForeignKey("subscriptions.subscription_id"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
notification_key = Column(String(64), nullable=False, index=True)
|
||||||
|
sent_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
subscription = relationship("Subscription")
|
||||||
|
|
||||||
|
|
||||||
class TariffChange(Base):
|
class TariffChange(Base):
|
||||||
__tablename__ = "tariff_changes"
|
__tablename__ = "tariff_changes"
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,9 @@ from bot.payment_providers.yookassa import (
|
|||||||
process_cancelled_payment,
|
process_cancelled_payment,
|
||||||
process_successful_payment,
|
process_successful_payment,
|
||||||
)
|
)
|
||||||
|
from bot.services.backup_worker import BackupWorker
|
||||||
from bot.services.locale_override_service import load_locale_overrides
|
from bot.services.locale_override_service import load_locale_overrides
|
||||||
|
from bot.services.subscription_notification_worker import SubscriptionNotificationWorker
|
||||||
from bot.services.tariff_worker import TariffTrafficWorker
|
from bot.services.tariff_worker import TariffTrafficWorker
|
||||||
from bot.utils.message_queue import init_queue_manager
|
from bot.utils.message_queue import init_queue_manager
|
||||||
from config.settings import get_settings
|
from config.settings import get_settings
|
||||||
@@ -191,6 +193,22 @@ async def main() -> None:
|
|||||||
tasks = []
|
tasks = []
|
||||||
if settings.tariffs_config:
|
if settings.tariffs_config:
|
||||||
tasks.append(asyncio.create_task(tariff_worker.run(), name="TariffTrafficWorker"))
|
tasks.append(asyncio.create_task(tariff_worker.run(), name="TariffTrafficWorker"))
|
||||||
|
subscription_notification_worker = SubscriptionNotificationWorker(
|
||||||
|
settings,
|
||||||
|
session_factory,
|
||||||
|
bot,
|
||||||
|
i18n,
|
||||||
|
services["panel_service"],
|
||||||
|
services["subscription_service"],
|
||||||
|
)
|
||||||
|
tasks.append(
|
||||||
|
asyncio.create_task(
|
||||||
|
subscription_notification_worker.run(),
|
||||||
|
name="SubscriptionNotificationWorker",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
backup_worker = BackupWorker(settings, bot, session_factory=session_factory)
|
||||||
|
tasks.append(asyncio.create_task(backup_worker.run(), name="BackupWorker"))
|
||||||
tasks.append(asyncio.create_task(_panel_sync_loop(settings, session_factory, i18n, services)))
|
tasks.append(asyncio.create_task(_panel_sync_loop(settings, session_factory, i18n, services)))
|
||||||
for idx in range(max(1, settings.WEBHOOK_QUEUE_CONCURRENCY)):
|
for idx in range(max(1, settings.WEBHOOK_QUEUE_CONCURRENCY)):
|
||||||
tasks.append(
|
tasks.append(
|
||||||
|
|||||||
@@ -40,8 +40,10 @@
|
|||||||
{ "count": 1, "price": 2500 }
|
{ "count": 1, "price": 2500 }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"prices_rub": { "1": 150, "3": 400, "6": 750, "12": 1400 },
|
"prices_rub": { "1": 200, "3": 600, "6": 1200, "12": 2400 },
|
||||||
"prices_stars": { "1": 0, "3": 0, "6": 0, "12": 0 },
|
"prices_stars": { "1": 0, "3": 0, "6": 0, "12": 0 },
|
||||||
|
"referral_bonus_days_inviter": { "1": 3, "3": 7, "6": 15, "12": 30 },
|
||||||
|
"referral_bonus_days_referee": { "1": 1, "3": 3, "6": 7, "12": 15 },
|
||||||
"enabled_periods": [1, 3, 6, 12],
|
"enabled_periods": [1, 3, 6, 12],
|
||||||
"enabled": true
|
"enabled": true
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -53,21 +53,35 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN set -eux; \
|
||||||
|
apt-get update; \
|
||||||
|
apt-get install -y --no-install-recommends ca-certificates curl gnupg; \
|
||||||
|
. /etc/os-release; \
|
||||||
|
install -d /usr/share/postgresql-common/pgdg; \
|
||||||
|
curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc \
|
||||||
|
| gpg --dearmor -o /usr/share/postgresql-common/pgdg/apt.postgresql.org.gpg; \
|
||||||
|
echo "deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.gpg] https://apt.postgresql.org/pub/repos/apt ${VERSION_CODENAME}-pgdg main" \
|
||||||
|
> /etc/apt/sources.list.d/pgdg.list; \
|
||||||
|
apt-get update; \
|
||||||
|
apt-get install -y --no-install-recommends postgresql-client-17; \
|
||||||
|
apt-get purge -y --auto-remove curl gnupg; \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
RUN useradd -u 10001 -m appuser
|
RUN useradd -u 10001 -m appuser
|
||||||
|
|
||||||
COPY backend/requirements.txt backend/requirements.txt
|
COPY backend/requirements.txt backend/requirements.txt
|
||||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||||
pip install --no-cache-dir -r backend/requirements.txt
|
pip install --no-cache-dir -r backend/requirements.txt
|
||||||
|
|
||||||
COPY backend ./backend
|
|
||||||
COPY locales ./locales
|
COPY locales ./locales
|
||||||
COPY data ./data
|
COPY data ./data
|
||||||
COPY --from=version-builder /build-version /app/.build-version
|
COPY backend ./backend
|
||||||
COPY --from=version-builder /build-tag /app/.build-tag
|
|
||||||
COPY --from=version-builder /build-commit /app/.build-commit
|
|
||||||
RUN mkdir -p /app/logs /app/data \
|
RUN mkdir -p /app/logs /app/data \
|
||||||
&& if [ ! -f /app/data/locales-overrides.json ]; then printf '{}\n' > /app/data/locales-overrides.json; fi \
|
&& if [ ! -f /app/data/locales-overrides.json ]; then printf '{}\n' > /app/data/locales-overrides.json; fi \
|
||||||
&& chown -R appuser:appuser /app/logs /app/data
|
&& chown -R appuser:appuser /app/logs /app/data
|
||||||
|
COPY --from=version-builder /build-version /app/.build-version
|
||||||
|
COPY --from=version-builder /build-tag /app/.build-tag
|
||||||
|
COPY --from=version-builder /build-commit /app/.build-commit
|
||||||
|
|
||||||
USER appuser
|
USER appuser
|
||||||
|
|
||||||
@@ -102,7 +116,7 @@ RUN --mount=type=cache,target=/root/.npm \
|
|||||||
if [ -f package-lock.json ]; then npm ci; else npm install; fi
|
if [ -f package-lock.json ]; then npm ci; else npm install; fi
|
||||||
|
|
||||||
COPY frontend ./
|
COPY frontend ./
|
||||||
COPY backend/bot/app/web/templates ../backend/bot/app/web/templates
|
RUN mkdir -p ../backend/bot/app/web/templates
|
||||||
|
|
||||||
RUN npm run build:webapp
|
RUN npm run build:webapp
|
||||||
|
|
||||||
@@ -116,8 +130,7 @@ LABEL org.opencontainers.image.source="https://github.com/3252a8/remnawave-minis
|
|||||||
|
|
||||||
COPY deploy/docker/frontend/nginx.conf /etc/nginx/conf.d/default.conf
|
COPY deploy/docker/frontend/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
COPY deploy/docker/frontend/00-startup-banner.sh /docker-entrypoint.d/00-startup-banner.sh
|
COPY deploy/docker/frontend/00-startup-banner.sh /docker-entrypoint.d/00-startup-banner.sh
|
||||||
COPY --from=version-builder /build-tag /build-tag
|
COPY deploy/docker/frontend/robots.txt /usr/share/nginx/html/robots.txt
|
||||||
COPY --from=version-builder /build-commit /build-commit
|
|
||||||
COPY backend/bot/app/web/templates/subscription_webapp.html /usr/share/nginx/html/index.html
|
COPY backend/bot/app/web/templates/subscription_webapp.html /usr/share/nginx/html/index.html
|
||||||
COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp.css /usr/share/nginx/html/subscription_webapp.css
|
COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp.css /usr/share/nginx/html/subscription_webapp.css
|
||||||
COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp.*.css /usr/share/nginx/html/
|
COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp.*.css /usr/share/nginx/html/
|
||||||
@@ -169,5 +182,7 @@ RUN set -eu; \
|
|||||||
-e '/subscription_webapp.js" defer/d' \
|
-e '/subscription_webapp.js" defer/d' \
|
||||||
/usr/share/nginx/html/index.html; \
|
/usr/share/nginx/html/index.html; \
|
||||||
chmod +x /docker-entrypoint.d/00-startup-banner.sh
|
chmod +x /docker-entrypoint.d/00-startup-banner.sh
|
||||||
|
COPY --from=version-builder /build-tag /build-tag
|
||||||
|
COPY --from=version-builder /build-commit /build-commit
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|||||||
@@ -23,6 +23,13 @@ server {
|
|||||||
return 200 "ok\n";
|
return 200 "ok\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location = /robots.txt {
|
||||||
|
expires 1h;
|
||||||
|
add_header Cache-Control "public, max-age=3600";
|
||||||
|
add_header X-Robots-Tag "noindex, nofollow, noarchive";
|
||||||
|
try_files /robots.txt =404;
|
||||||
|
}
|
||||||
|
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://backend:8081;
|
proxy_pass http://backend:8081;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
@@ -59,6 +66,15 @@ server {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location = /webapp-default-logo.webp {
|
||||||
|
proxy_pass http://backend:8081;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
location ~ ^/(favicon\.ico|apple-touch-icon(?:-precomposed)?\.png|icon-(?:192|512)\.png)$ {
|
location ~ ^/(favicon\.ico|apple-touch-icon(?:-precomposed)?\.png|icon-(?:192|512)\.png)$ {
|
||||||
proxy_pass http://backend:8081;
|
proxy_pass http://backend:8081;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
@@ -80,23 +96,27 @@ server {
|
|||||||
location ~* ^/subscription_webapp(_admin)?\.(min\.)?[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]\.(css|js)$ {
|
location ~* ^/subscription_webapp(_admin)?\.(min\.)?[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]\.(css|js)$ {
|
||||||
expires off;
|
expires off;
|
||||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||||
|
add_header X-Robots-Tag "noindex, nofollow, noarchive";
|
||||||
try_files $uri =404;
|
try_files $uri =404;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~* ^/subscription_webapp(_admin)?\.(css|js)$ {
|
location ~* ^/subscription_webapp(_admin)?\.(css|js)$ {
|
||||||
expires off;
|
expires off;
|
||||||
add_header Cache-Control "no-cache";
|
add_header Cache-Control "no-cache";
|
||||||
|
add_header X-Robots-Tag "noindex, nofollow, noarchive";
|
||||||
try_files $uri =404;
|
try_files $uri =404;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|webp)$ {
|
location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|webp)$ {
|
||||||
expires 30d;
|
expires 30d;
|
||||||
add_header Cache-Control "public";
|
add_header Cache-Control "public";
|
||||||
|
add_header X-Robots-Tag "noindex, nofollow, noarchive";
|
||||||
try_files $uri =404;
|
try_files $uri =404;
|
||||||
}
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
add_header Cache-Control "no-cache";
|
add_header Cache-Control "no-cache";
|
||||||
|
add_header X-Robots-Tag "noindex, nofollow, noarchive";
|
||||||
try_files $uri /index.html;
|
try_files $uri /index.html;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
User-agent: *
|
||||||
|
Disallow: /
|
||||||
|
|
||||||
|
User-agent: GPTBot
|
||||||
|
Disallow: /
|
||||||
|
|
||||||
|
User-agent: ChatGPT-User
|
||||||
|
Disallow: /
|
||||||
|
|
||||||
|
User-agent: OAI-SearchBot
|
||||||
|
Disallow: /
|
||||||
|
|
||||||
|
User-agent: Google-Extended
|
||||||
|
Disallow: /
|
||||||
|
|
||||||
|
User-agent: ClaudeBot
|
||||||
|
Disallow: /
|
||||||
|
|
||||||
|
User-agent: anthropic-ai
|
||||||
|
Disallow: /
|
||||||
|
|
||||||
|
User-agent: PerplexityBot
|
||||||
|
Disallow: /
|
||||||
|
|
||||||
|
User-agent: Applebot-Extended
|
||||||
|
Disallow: /
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
**/.env
|
**/.env
|
||||||
|
**/data/
|
||||||
nginx/ssl/*
|
nginx/ssl/*
|
||||||
!nginx/ssl/README.md
|
!nginx/ssl/README.md
|
||||||
|
|
||||||
|
|||||||
@@ -1,33 +1,14 @@
|
|||||||
# Готовые варианты запуска
|
# Примеры Docker Compose
|
||||||
|
|
||||||
В этой папке лежат самодостаточные compose-примеры. Каждый вариант запускается из своей директории обычной командой:
|
Каноничная документация по вариантам запуска живет в [docs/getting-started/deployment.md](../../docs/getting-started/deployment.md).
|
||||||
|
|
||||||
```bash
|
Эта папка хранит только рабочие compose-примеры и конфиги. Подробное описание не дублируется здесь, чтобы сайт документации и навигация из README использовали один источник.
|
||||||
cp .env.example .env
|
|
||||||
nano .env
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
После старта полезно проверить:
|
Файлы приложения (`/app/data`: тарифы, темы, логотипы) монтируются из папки `data` рядом с выбранным `docker-compose.yml`. Для кастомных тем создайте `data/themes`.
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose ps
|
|
||||||
docker compose logs -f backend worker frontend
|
|
||||||
```
|
|
||||||
|
|
||||||
## Какой вариант выбрать
|
|
||||||
|
|
||||||
| Папка | Когда использовать | Что править |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| [`caddy`](caddy) | Нужен самый простой публичный HTTPS с автоматическими сертификатами Let's Encrypt. | `.env`; при нестандартной схеме можно поправить `Caddyfile`. |
|
|
||||||
| [`nginx`](nginx) | Уже используете Nginx и готовы положить TLS-сертификаты рядом с примером. | `.env`, `nginx.conf.template`, файлы в `ssl/`. |
|
|
||||||
| [`newt`](newt) | Публикуете сервисы через Pangolin/Newt без входящих портов на сервере приложения. | `.env` и ресурсы в панели Pangolin. |
|
|
||||||
| [`no-proxy`](no-proxy) | Нужно напрямую открыть порты backend/frontend или проверить стек без reverse proxy. | `.env`. |
|
|
||||||
|
|
||||||
Для всех вариантов нужны два публичных URL:
|
|
||||||
|
|
||||||
- webhook/backend URL для Telegram, платежных систем и Remnawave webhooks;
|
|
||||||
- Mini App/frontend URL для Telegram Mini App и Web App.
|
|
||||||
|
|
||||||
Обычно это два домена, например `webhooks.example.com` и `app.example.com`.
|
|
||||||
|
|
||||||
|
| Папка | Документация |
|
||||||
|
| --- | --- |
|
||||||
|
| `caddy` | [Развертывание с Caddy](../../docs/getting-started/deployment.md#caddy-рекомендуемый-вариант) |
|
||||||
|
| `nginx` | [Развертывание с Nginx](../../docs/getting-started/deployment.md#nginx) |
|
||||||
|
| `newt` | [Развертывание через Pangolin / Newt](../../docs/getting-started/deployment.md#pangolin--newt) |
|
||||||
|
| `no-proxy` | [Запуск без обратного прокси](../../docs/getting-started/deployment.md#без-обратного-прокси) |
|
||||||
|
|||||||
@@ -38,4 +38,3 @@ PANEL_WEBHOOK_SECRET=change_me
|
|||||||
|
|
||||||
# Caddy and Docker network ranges that may set X-Forwarded-For.
|
# Caddy and Docker network ranges that may set X-Forwarded-For.
|
||||||
TRUSTED_PROXIES=127.0.0.1,::1,172.16.0.0/12
|
TRUSTED_PROXIES=127.0.0.1,::1,172.16.0.0/12
|
||||||
|
|
||||||
|
|||||||
@@ -1,31 +1,5 @@
|
|||||||
# Запуск с Caddy
|
# Caddy
|
||||||
|
|
||||||
Caddy сам выпускает и продлевает HTTPS-сертификаты. На сервере должны быть открыты входящие `80/tcp` и `443/tcp`, а DNS-записи `WEBHOOK_HOST` и `MINIAPP_HOST` должны смотреть на этот сервер.
|
Каноничная инструкция: [docs/getting-started/deployment.md](../../../docs/getting-started/deployment.md#caddy-рекомендуемый-вариант).
|
||||||
|
|
||||||
```bash
|
|
||||||
cp .env.example .env
|
|
||||||
nano .env
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
Минимально поменяйте в `.env`:
|
|
||||||
|
|
||||||
- `WEBHOOK_HOST` и `MINIAPP_HOST`;
|
|
||||||
- `BOT_TOKEN`, `ADMIN_IDS`;
|
|
||||||
- `POSTGRES_PASSWORD`;
|
|
||||||
- `WEBAPP_SESSION_SECRET`, `WEBHOOK_SECRET_TOKEN`;
|
|
||||||
- `PANEL_API_URL`, `PANEL_API_KEY`, `PANEL_WEBHOOK_SECRET`.
|
|
||||||
|
|
||||||
`Caddyfile` лежит рядом и использует домены из `.env`. Если нужна нестандартная логика Caddy, правьте его и перезапускайте:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose up -d --force-recreate caddy
|
|
||||||
```
|
|
||||||
|
|
||||||
Проверка:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose ps
|
|
||||||
docker compose logs -f caddy backend worker frontend
|
|
||||||
```
|
|
||||||
|
|
||||||
|
Файлы этого примера остаются рядом: `docker-compose.yml`, `.env.example` и `Caddyfile`.
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
<<: *app_environment
|
<<: *app_environment
|
||||||
volumes:
|
volumes:
|
||||||
- shop-data:/app/data
|
- ./data:/app/data
|
||||||
networks:
|
networks:
|
||||||
- remnawave-shop
|
- remnawave-shop
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -66,7 +66,8 @@ services:
|
|||||||
WEBAPP_ENABLED: ${WEBAPP_ENABLED:-true}
|
WEBAPP_ENABLED: ${WEBAPP_ENABLED:-true}
|
||||||
TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.1,::1,172.16.0.0/12}
|
TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.1,::1,172.16.0.0/12}
|
||||||
volumes:
|
volumes:
|
||||||
- shop-data:/app/data
|
- ./data:/app/data
|
||||||
|
- ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:${COMPOSE_RESTORE_MODE:-rw}
|
||||||
networks:
|
networks:
|
||||||
- remnawave-shop
|
- remnawave-shop
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -90,7 +91,8 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
<<: *app_environment
|
<<: *app_environment
|
||||||
volumes:
|
volumes:
|
||||||
- shop-data:/app/data
|
- ./data:/app/data
|
||||||
|
- ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:ro
|
||||||
networks:
|
networks:
|
||||||
- remnawave-shop
|
- remnawave-shop
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -147,8 +149,6 @@ volumes:
|
|||||||
name: remnawave-minishop-db-data
|
name: remnawave-minishop-db-data
|
||||||
redis-data:
|
redis-data:
|
||||||
name: remnawave-minishop-redis-data
|
name: remnawave-minishop-redis-data
|
||||||
shop-data:
|
|
||||||
name: remnawave-minishop-shop-data
|
|
||||||
caddy-data:
|
caddy-data:
|
||||||
name: remnawave-minishop-caddy-data
|
name: remnawave-minishop-caddy-data
|
||||||
caddy-config:
|
caddy-config:
|
||||||
|
|||||||
@@ -39,4 +39,3 @@ PANEL_WEBHOOK_SECRET=change_me
|
|||||||
|
|
||||||
# Pangolin/Newt and Docker network ranges that may set X-Forwarded-For.
|
# Pangolin/Newt and Docker network ranges that may set X-Forwarded-For.
|
||||||
TRUSTED_PROXIES=127.0.0.1,::1,172.16.0.0/12
|
TRUSTED_PROXIES=127.0.0.1,::1,172.16.0.0/12
|
||||||
|
|
||||||
|
|||||||
@@ -1,33 +1,5 @@
|
|||||||
# Запуск через Newt / Pangolin
|
# Pangolin / Newt
|
||||||
|
|
||||||
Этот вариант не открывает входящие порты на сервере приложения. Newt подключается к Pangolin, а публичные домены настраиваются ресурсами в панели Pangolin.
|
Каноничная инструкция: [docs/getting-started/deployment.md](../../../docs/getting-started/deployment.md#pangolin--newt).
|
||||||
|
|
||||||
```bash
|
Файлы этого примера остаются рядом: `docker-compose.yml` и `.env.example`.
|
||||||
cp .env.example .env
|
|
||||||
nano .env
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
В `.env` заполните:
|
|
||||||
|
|
||||||
- `WEBHOOK_HOST` и `MINIAPP_HOST` - публичные домены ресурсов в Pangolin;
|
|
||||||
- `PANGOLIN_ENDPOINT`, `NEWT_ID`, `NEWT_SECRET` - значения из настроек site/client в Pangolin;
|
|
||||||
- обычные переменные приложения: `BOT_TOKEN`, `ADMIN_IDS`, `POSTGRES_PASSWORD`, секреты и доступ к Remnawave.
|
|
||||||
|
|
||||||
Официальная инструкция Pangolin по установке Newt site: <https://docs.pangolin.net/manage/sites/install-site>.
|
|
||||||
|
|
||||||
В Pangolin создайте два HTTP-ресурса для этого Newt site:
|
|
||||||
|
|
||||||
| Публичный домен | Upstream |
|
|
||||||
| --- | --- |
|
|
||||||
| `https://webhooks.example.com` | `http://backend:8080` |
|
|
||||||
| `https://app.example.com` | `http://frontend:80` |
|
|
||||||
|
|
||||||
Домены в Pangolin должны совпадать с `WEBHOOK_HOST` и `MINIAPP_HOST`.
|
|
||||||
|
|
||||||
Проверка:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose ps
|
|
||||||
docker compose logs -f newt backend worker frontend
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
<<: *app_environment
|
<<: *app_environment
|
||||||
volumes:
|
volumes:
|
||||||
- shop-data:/app/data
|
- ./data:/app/data
|
||||||
networks:
|
networks:
|
||||||
- remnawave-shop
|
- remnawave-shop
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -66,7 +66,8 @@ services:
|
|||||||
WEBAPP_ENABLED: ${WEBAPP_ENABLED:-true}
|
WEBAPP_ENABLED: ${WEBAPP_ENABLED:-true}
|
||||||
TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.1,::1,172.16.0.0/12}
|
TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.1,::1,172.16.0.0/12}
|
||||||
volumes:
|
volumes:
|
||||||
- shop-data:/app/data
|
- ./data:/app/data
|
||||||
|
- ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:${COMPOSE_RESTORE_MODE:-rw}
|
||||||
networks:
|
networks:
|
||||||
- remnawave-shop
|
- remnawave-shop
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -90,7 +91,8 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
<<: *app_environment
|
<<: *app_environment
|
||||||
volumes:
|
volumes:
|
||||||
- shop-data:/app/data
|
- ./data:/app/data
|
||||||
|
- ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:ro
|
||||||
networks:
|
networks:
|
||||||
- remnawave-shop
|
- remnawave-shop
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -142,5 +144,3 @@ volumes:
|
|||||||
name: remnawave-minishop-db-data
|
name: remnawave-minishop-db-data
|
||||||
redis-data:
|
redis-data:
|
||||||
name: remnawave-minishop-redis-data
|
name: remnawave-minishop-redis-data
|
||||||
shop-data:
|
|
||||||
name: remnawave-minishop-shop-data
|
|
||||||
|
|||||||
@@ -38,4 +38,3 @@ PANEL_WEBHOOK_SECRET=change_me
|
|||||||
|
|
||||||
# Nginx and Docker network ranges that may set X-Forwarded-For.
|
# Nginx and Docker network ranges that may set X-Forwarded-For.
|
||||||
TRUSTED_PROXIES=127.0.0.1,::1,172.16.0.0/12
|
TRUSTED_PROXIES=127.0.0.1,::1,172.16.0.0/12
|
||||||
|
|
||||||
|
|||||||
@@ -1,42 +1,5 @@
|
|||||||
# Запуск с Nginx
|
# Nginx
|
||||||
|
|
||||||
Этот пример поднимает Nginx в той же Docker-сети, что и приложение:
|
Каноничная инструкция: [docs/getting-started/deployment.md](../../../docs/getting-started/deployment.md#nginx).
|
||||||
|
|
||||||
- `WEBHOOK_HOST` проксируется в `backend:8080`;
|
|
||||||
- `MINIAPP_HOST` проксируется в `frontend:80`;
|
|
||||||
- `frontend` сам проксирует внутренние `/api`, `/auth` и ассеты тем в `backend:8081`.
|
|
||||||
|
|
||||||
## Подготовка
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp .env.example .env
|
|
||||||
nano .env
|
|
||||||
```
|
|
||||||
|
|
||||||
Положите TLS-сертификаты в `ssl/`:
|
|
||||||
|
|
||||||
```text
|
|
||||||
ssl/
|
|
||||||
webhooks.example.com/
|
|
||||||
fullchain.pem
|
|
||||||
privkey.pem
|
|
||||||
app.example.com/
|
|
||||||
fullchain.pem
|
|
||||||
privkey.pem
|
|
||||||
```
|
|
||||||
|
|
||||||
Имена папок должны совпадать с `WEBHOOK_HOST` и `MINIAPP_HOST` в `.env`.
|
|
||||||
|
|
||||||
## Запуск
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose up -d
|
|
||||||
docker compose logs -f nginx backend worker frontend
|
|
||||||
```
|
|
||||||
|
|
||||||
Если нужно поменять заголовки, лимиты или TLS-настройки, правьте `nginx.conf.template` рядом с compose и перезапускайте Nginx:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose up -d --force-recreate nginx
|
|
||||||
```
|
|
||||||
|
|
||||||
|
Файлы этого примера остаются рядом: `docker-compose.yml`, `.env.example`, `nginx.conf.template` и папка `ssl/` для сертификатов.
|
||||||
|
|||||||