Compare commits

..
111 Commits
Author SHA1 Message Date
3252a8andGitHub 3e1f1cb787 Merge pull request #15 from 3252a8/dev
Split subscription stats by access type, CI/CD pipelines and notification/docs-demo fixesDev
2026-05-31 00:30:11 +03:00
3252a8 74da8ab98e chore: compact admin subscription stats cards 2026-05-31 00:26:42 +03:00
3252a8 19f7daff6c fix: stabilize docs demo runtime routing 2026-05-31 00:25:23 +03:00
3252a8 cf3af17243 fix: avoid Telegram notification probe messages 2026-05-31 00:17:20 +03:00
3252a8 93353db511 feat: split subscription stats by access type 2026-05-31 00:08:31 +03:00
3252a8andGitHub e7f93a5f47 Merge pull request #14 from 3252a8/dependabot/npm_and_yarn/frontend/npm_and_yarn-de2076f775
chore(deps-dev): bump svelte from 5.55.5 to 5.56.0 in /frontend in the npm_and_yarn group across 1 directory
2026-05-30 23:26:04 +03:00
3252a8andGitHub c2ab881be2 Merge pull request #13 from 3252a8/feature/ci-cd-workflows
ci: add GitHub Actions for image builds, PR checks and security scans
2026-05-30 23:22:51 +03:00
3252a8 4a84bba697 ci: use v-prefixed trivy-action tag (v0.36.0) 2026-05-30 23:20:22 +03:00
3252a8 cda3b741a1 ci: fix workflow failures on PR
- drop permissions block from reusable build workflow so callers set token
  scope (fixes PR-checks startup failure: ci.yml grants only contents:read
  while the reusable demanded packages:write)
- remove codeql.yml: repo already uses CodeQL default setup, which conflicts
  with an advanced configuration
- pin trivy-action to 0.36.0 (0.28.0 tag does not exist; <0.35.0 is the
  compromised supply-chain release flagged by dependency-review)
- make pip-audit and npm audit informational (continue-on-error); they flag
  upstream/transitive advisories, dependency-review stays the PR gate
2026-05-30 23:14:46 +03:00
dependabot[bot]andGitHub 2e82febdcc chore(deps-dev): bump svelte
Bumps the npm_and_yarn group with 1 update in the /frontend directory: [svelte](https://github.com/sveltejs/svelte/tree/HEAD/packages/svelte).


Updates `svelte` from 5.55.5 to 5.56.0
- [Release notes](https://github.com/sveltejs/svelte/releases)
- [Changelog](https://github.com/sveltejs/svelte/blob/main/packages/svelte/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/svelte/commits/svelte@5.56.0/packages/svelte)

---
updated-dependencies:
- dependency-name: svelte
  dependency-version: 5.56.0
  dependency-type: direct:development
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-30 20:09:10 +00:00
3252a8 bd7710d03c ci: add GitHub Actions for image builds, PR checks and security scans
- dev-images: build/push backend, worker, frontend to ghcr.io and Docker
  Hub on every push to dev (tags: dev, dev-<sha>)
- release-images: same images on v* tag push (tags: latest, <version>)
- PR checks (into main/dev): ruff lint+format, eslint+prettier, no-push
  Docker build of all targets
- CodeQL (python, js/ts), dependency-review, pip-audit, npm audit, Trivy fs
- pin .github/workflows/*.yml to LF
2026-05-30 22:57:41 +03:00
3252a8andGitHub 1a66d67e44 Merge pull request #12 from 3252a8/dev
Telegram delivery tracking, subscription lifecycle mirroring and per-tariff referral bonuses
2026-05-30 22:28:57 +03:00
3252a8 c1e2fe2c95 chore: improve admin logs user cards 2026-05-30 22:15:33 +03:00
3252a8 ea4ee4c4a7 fix: restore admin content scrolling 2026-05-30 21:49:40 +03:00
3252a8 fbc3e193bf chore: improve custom scroll areas 2026-05-30 21:41:32 +03:00
3252a8 3541f2f78b feat: block crawlers from production webapp 2026-05-30 21:25:03 +03:00
3252a8 acc222da41 feat: prompt users to start Telegram bot for notifications 2026-05-30 21:15:17 +03:00
3252a8 8c0e778388 chore: standardize web controls 2026-05-30 12:16:02 +03:00
3252a8 c6c5352813 fix: format premium traffic limits from bytes 2026-05-30 12:00:01 +03:00
3252a8 067d6fb59f chore: clarify panel webhook subscription logs 2026-05-30 11:52:07 +03:00
3252a8 c3e55bc853 feature: sync user email notifications 2026-05-30 11:46:07 +03:00
3252a8 09be82aad5 fix: improve subscription email renewal flow 2026-05-30 00:23:53 +03:00
3252a8 49781af921 feat: surface remnawave panel webhook settings 2026-05-29 23:55:12 +03:00
3252a8 604ba873dc docs: clarify remnawave panel webhook setup 2026-05-29 23:54:35 +03:00
3252a8 923ff9b208 fix: hide inactive devices usage summary 2026-05-29 23:23:55 +03:00
3252a8 7e7a2e58a2 fix: show telegram login without email auth 2026-05-29 23:09:49 +03:00
3252a8 e27886e15d docs: update referral bonus tariff docs 2026-05-29 23:00:18 +03:00
3252a8 7fe8e676cd feat: group referral bonus display by tariff 2026-05-29 22:55:01 +03:00
3252a8 6803c7801f fix: configure referral bonuses per tariff period 2026-05-29 22:30:31 +03:00
3252a8 cab963dcdc fix: prevent subscription worker deadlocks 2026-05-29 22:07:16 +03:00
3252a8 001e54cfe2 fix: preserve subscriptions on panel lookup failures 2026-05-29 21:46:42 +03:00
3252a8 5e257c0d3a chore: enforce frontend formatting line endings 2026-05-29 18:14:35 +03:00
3252a8 19f0f27a3b feat: mirror subscription lifecycle notifications 2026-05-29 18:08:28 +03:00
3252a8 6fbb8eebec fix: serve docs demo runtime in dev 2026-05-29 18:04:00 +03:00
3252a8 32616c80ab docs: add notifications table 2026-05-29 17:50:30 +03:00
3252a8 3186be1e3e docs: update deployment examples 2026-05-29 15:33:09 +03:00
3252a8 1329eb4fe2 fix: avoid docs demo redirect loop 2026-05-28 23:44:52 +03:00
3252a8 913b2d428e fix: handle docs demo app route 2026-05-28 23:33:40 +03:00
3252a8andGitHub 5892a72575 Merge pull request #7 from 3252a8/dev
Backups, docs demo, and Mini App improvements
2026-05-28 22:57:40 +03:00
3252a8 d299a3c524 feature: Add local subscription notification worker 2026-05-28 22:48:48 +03:00
3252a8 1c1ef06e29 fix: Fix trial settings persistence 2026-05-28 22:48:48 +03:00
3252a8 bea62d75a9 fix: persist account language changes 2026-05-28 22:07:43 +03:00
3252a8 e998b9ddd1 chore: compact traffic cards 2026-05-28 21:52:38 +03:00
3252a8 2f192447ce fix: hide premium traffic when regular limit is depleted 2026-05-28 21:00:06 +03:00
3252a8 e89ed870dc fix: compact mobile subscription status card 2026-05-28 19:44:50 +03:00
3252a8 6a722bfa17 refactor: share email code confirmation screen 2026-05-28 17:11:15 +03:00
3252a8 a96aaa763f feat: use admin identity in demo auth flow 2026-05-28 17:02:22 +03:00
3252a8 bbe05d7f57 feat: support telegram auth in demo flow 2026-05-28 16:49:23 +03:00
3252a8 da28b69461 feat: add demo auth flow 2026-05-28 16:40:16 +03:00
3252a8 cd469ae2bb feat: add demo device top-up flow 2026-05-28 15:54:35 +03:00
3252a8 9af49453f8 fix: refresh devices after billing updates 2026-05-28 15:54:30 +03:00
3252a8 e543665704 fix: add favicon to docs demo shell 2026-05-28 15:32:45 +03:00
3252a8 39c0696e53 fix: show activation dialog for forced actions 2026-05-28 15:29:58 +03:00
3252a8 c6cc4ab963 fix: use standard purchase copy in demo 2026-05-28 15:25:18 +03:00
3252a8 4dfe71f392 fix: align demo state flows 2026-05-28 15:21:55 +03:00
3252a8 cf05c6580d fix: keep demo mock switch on app shell 2026-05-28 15:05:48 +03:00
3252a8 d544ebd879 feat: add fullscreen docs demo routes 2026-05-28 14:59:54 +03:00
3252a8 7e0e8ff319 feat: enrich docs demo mock data 2026-05-28 14:59:43 +03:00
3252a8 613a9860a0 refactor: support prefixed webapp routes 2026-05-28 14:59:34 +03:00
3252a8 92f0277dad docs: refresh docs demo data 2026-05-28 13:10:07 +03:00
3252a8 749693078b fix: use app favicon fallback in admin appearance 2026-05-28 13:10:01 +03:00
3252a8 b566a34725 fix: mark depleted traffic on webapp home 2026-05-28 13:09:55 +03:00
3252a8 3ab6c18a76 fix: preserve account language on telegram auth 2026-05-28 11:31:45 +03:00
3252a8 52458eb223 fix: align tariff action in status card 2026-05-28 11:31:41 +03:00
3252a8 790946d89e fix: label docs demo admin version 2026-05-28 10:09:26 +03:00
3252a8 30c5d9ae11 fix: refine home tariff actions 2026-05-28 10:06:10 +03:00
3252a8 12e60629fe fix: preserve admin view on language change 2026-05-28 09:54:27 +03:00
3252a8 6c0d2932c3 docs: make demo bar collapsible on mobile 2026-05-28 09:12:58 +03:00
3252a8 ce19c7e2d1 docs: add mobile docs navigation menu 2026-05-28 08:21:58 +03:00
3252a8 93c9dde572 chore: harden docs demo config fetch 2026-05-28 08:21:54 +03:00
3252a8 d46324cb6a docs: fix mobile homepage navigation 2026-05-28 08:07:09 +03:00
3252a8 f153ca5bf4 fix: complete english localization refresh 2026-05-28 00:55:22 +03:00
3252a8 62e950f5c4 docs: refine static demo experience 2026-05-28 00:55:11 +03:00
3252a8 2b8bcd10b8 docs: make demo open fullscreen 2026-05-28 00:19:22 +03:00
3252a8 4c2ee19957 docs: add interactive demo page 2026-05-27 23:56:52 +03:00
3252a8 8217d13cd6 feat: add docs demo runtime build 2026-05-27 23:56:48 +03:00
3252a8 a2a887b898 fix: recognize backups admin route 2026-05-27 23:56:41 +03:00
3252a8 1620de9a01 refactor: isolate mock runtime from production webapp 2026-05-27 23:56:37 +03:00
3252a8 b6c6887842 chore: improve backup admin controls 2026-05-27 23:22:20 +03:00
3252a8 ef4b493e65 chore: change backup archive name style 2026-05-27 23:01:42 +03:00
3252a8 50dd1951c4 fix: prevent support message row stretching 2026-05-27 22:37:50 +03:00
3252a8 b3894e53e4 fix: tighten support chat message spacing 2026-05-27 22:21:44 +03:00
3252a8 ded044b4c0 fix: make support replies finish promptly 2026-05-27 22:21:24 +03:00
3252a8 bce78c4f28 chore: backup warning details 2026-05-27 19:08:49 +03:00
3252a8 75586d8883 fix: harden hwid provider payment edge cases 2026-05-27 18:31:14 +03:00
3252a8 f2fc335221 fix: anchor hwid pricing to paid period 2026-05-27 18:13:27 +03:00
3252a8 1c9e55d797 fix: wire subscription service for yookassa hwid payments 2026-05-27 18:13:22 +03:00
3252a8 25056602d8 fix: serialize webapp datetime payloads 2026-05-27 14:55:05 +03:00
3252a8 bd2e67059f chore: optimize docker layer caching 2026-05-27 14:44:58 +03:00
3252a8 fe34edfe73 chore: verify formatting checks 2026-05-27 14:29:45 +03:00
3252a8 4706be53ab feat: manual backup button 2026-05-27 14:25:37 +03:00
3252a8 4bd547f06a chore: tune backup function and related docs 2026-05-27 14:11:22 +03:00
3252a8 0250264fa0 fix: serialize HWID top-up validity dates 2026-05-27 13:59:03 +03:00
3252a8 3aede8fe95 feat: add backups feature 2026-05-27 13:53:30 +03:00
3252a8 e90988ea5c feat: persist support ticket drafts 2026-05-27 08:10:08 +03:00
3252a8 69d3400310 fix: stack admin badges on mobile 2026-05-27 07:55:05 +03:00
3252a8 6a44da1f31 fix: refresh trial settings in admin tariffs 2026-05-27 07:52:20 +03:00
3252a8 77c8785ec0 chore: set default webapp title to minishop 2026-05-27 07:22:34 +03:00
3252a8 f2c0a9f6d5 fix: preserve home logo scale after admin navigation 2026-05-27 07:13:53 +03:00
3252a8 f77a6ea46d feat: add default webapp brand assets 2026-05-27 07:07:31 +03:00
3252a8 a92ad32b23 docs: update docs 2026-05-27 00:11:24 +03:00
3252a8 a6af8f8415 docs: tune docs visual 2026-05-26 23:55:07 +03:00
3252a8andGitHub da9db7b7f8 Merge pull request #5 from 3252a8/dev
Update and structurize docs
2026-05-26 23:42:59 +03:00
3252a8 11048a6ed8 docs: refactor docs structure 2026-05-26 23:31:04 +03:00
3252a8 c3381bdd31 chore: add image publish and mirror sync scripts 2026-05-26 22:30:10 +03:00
3252a8 53f1cec401 docs: point repository links to GitHub 2026-05-26 22:21:00 +03:00
3252a8 bf46a15446 docs: update GitLab repository links 2026-05-26 21:57:49 +03:00
3252a8 0df52d0235 docs: refactor docs structure 2026-05-26 21:45:44 +03:00
3252a8 0c167c8f09 docs: refactor docs structure 2026-05-26 17:26:46 +03:00
3252a8 804ccdabec docs: use nova starlight theme 2026-05-26 16:18:15 +03:00
3252a8 5833b18052 docs: docs-site initial 2026-05-26 16:04:04 +03:00
257 changed files with 164703 additions and 2445 deletions
+32
View File
@@ -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__/
+4 -1
View File
@@ -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.
+7
View File
@@ -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
+117
View File
@@ -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
+64
View File
@@ -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
+27
View File
@@ -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
+25
View File
@@ -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
+28
View File
@@ -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
+96
View File
@@ -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
+16
View File
@@ -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
+35 -18
View File
@@ -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
```
## Поддержать проект ## Поддержать проект
+2
View File
@@ -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()})
+46 -9
View File
@@ -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,
+172 -68
View File
@@ -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,
Binary file not shown.

After

Width:  |  Height:  |  Size: 436 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 738 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

+33
View File
@@ -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",
+4
View File
@@ -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:
+48 -3
View File
@@ -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",
( (
+17 -8
View File
@@ -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
+49 -29
View File
@@ -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,
) )
) )
+46 -9
View File
@@ -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 "",
+7 -1
View File
@@ -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)
+90 -8
View File
@@ -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})
+7 -1
View File
@@ -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>"
+7 -9
View File
@@ -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
+3 -1
View File
@@ -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"
+128 -23
View File
@@ -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]:
+28 -2
View File
@@ -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:
+17 -5
View File
@@ -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"
+24 -16
View File
@@ -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,
+2 -1
View File
@@ -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:
+2 -1
View File
@@ -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(
+2 -1
View File
@@ -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:
+9 -1
View File
@@ -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,
) )
+33 -1
View File
@@ -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),
)
+8 -3
View File
@@ -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(
+2 -1
View File
@@ -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,
+56 -13
View File
@@ -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,
) )
+73
View File
@@ -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
+479
View File
@@ -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")
+230 -2
View File
@@ -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_",
+102 -2
View File
@@ -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,
+310 -89
View File
@@ -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]
+80 -12
View File
@@ -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)
+21 -8
View File
@@ -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:
+148 -19
View File
@@ -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
+45 -6
View File
@@ -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
+35
View File
@@ -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)])
+12
View File
@@ -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},
)
+40 -1
View File
@@ -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]:
+1 -3
View File
@@ -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)
+94 -14
View File
@@ -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))
) )
+61 -6
View File
@@ -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,
),
] ]
+24
View File
@@ -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"
+18
View File
@@ -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(
+3 -1
View File
@@ -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
}, },
+22 -7
View File
@@ -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
+20
View File
@@ -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;
} }
} }
+26
View File
@@ -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
View File
@@ -1,4 +1,5 @@
**/.env **/.env
**/data/
nginx/ssl/* nginx/ssl/*
!nginx/ssl/README.md !nginx/ssl/README.md
+10 -29
View File
@@ -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#без-обратного-прокси) |
-1
View File
@@ -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
+3 -29
View File
@@ -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`.
+5 -5
View File
@@ -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:
-1
View File
@@ -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
+3 -31
View File
@@ -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
```
+5 -5
View File
@@ -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
-1
View File
@@ -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
+3 -40
View File
@@ -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/` для сертификатов.

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