Compare commits

..
149 Commits
Author SHA1 Message Date
3252a8andGitHub 9393962510 Merge pull request #22 from 3252a8/dev
Multicurrency (future-proof feature), panel dry-run mode (dev feature), install telemetry and admin user tools
2026-06-01 22:55:30 +03:00
3252a8 2c32e81638 fix: build dry-run log entirely from constant labels
CodeQL still traced taint through the previous helpers: the endpoint
regex fallback returned source-derived text and the payload dict keys
flowed verbatim into the log. Resolve both by sourcing every logged
token from literal lookup tables — endpoints map to constant path
labels and payload keys map through a field-name allow-list, with
unknown keys/paths collapsing to <field>/<other>. No source-derived
string (key, value or id) reaches the logger.
2026-06-01 22:53:48 +03:00
3252a8 0032c1804b fix: drop clear-text values and ids from panel dry-run logs
Rebuild logged endpoints from constant path templates and reduce every
payload leaf to a JSON type token, so user/squad UUIDs and PII (email,
telegramId) can never reach the dry-run log as clear text. Resolves
CodeQL py/clear-text-logging-sensitive-data findings.
2026-06-01 22:48:21 +03:00
3252a8andGitHub a98ea65ef4 Merge pull request #21 from 3252a8/feature/ui-improvements
Improve admin UX, trial reset behavior, and channel subscription checks
2026-06-01 22:29:24 +03:00
3252a8 5218ede0f1 fix: normalize required channel checks 2026-06-01 22:13:35 +03:00
3252a8 5b19ba2c2f fix: keep deeplink gateway open during app prompt 2026-06-01 20:34:49 +03:00
3252a8 687fc03e8c fix: reset trial eligibility from web admin 2026-06-01 19:30:16 +03:00
3252a8 74272039c5 feat: add login language selector 2026-06-01 19:06:27 +03:00
3252a8 eadb86faf2 fix: restore mobile admin language select 2026-06-01 18:33:45 +03:00
3252a8 4fa18a1262 fix: enlarge mobile renewal button 2026-06-01 18:17:02 +03:00
3252a8 7d3178bd48 feat: improve admin user filters 2026-06-01 18:04:08 +03:00
3252a8andGitHub 88d99fb578 Merge pull request #20 from 3252a8/feature/users-details
Enhance admin users and subscription workflows
2026-06-01 15:59:52 +03:00
3252a8 8366a73575 style: format admin users api 2026-06-01 15:58:28 +03:00
3252a8 dcdddeb1c5 feat: add sortable admin user columns 2026-06-01 15:53:03 +03:00
3252a8 d7840d3a86 feat: expose admin user list metrics 2026-06-01 15:52:54 +03:00
3252a8 6a40d0c9ce feat: add expired subscription broadcast target 2026-06-01 15:05:10 +03:00
3252a8andGitHub 3d579b12d4 Merge pull request #19 from 3252a8/feature/telemetry
Feature/telemetry
2026-06-01 14:24:28 +03:00
3252a8 0d932b0915 style: ruff format panel dry-run service 2026-06-01 14:22:33 +03:00
3252a8 2b763efef9 fix: redact secrets and ids from panel dry-run logs
CodeQL flagged clear-text logging of sensitive information in the panel
dry-run logger: it dumped the full request payload (which can include
proxy credentials like trojanPassword/ssPassword and PII such as email
and telegramId) and the raw endpoint (embedding user UUIDs).

Recursively redact values under sensitive keys before building the
payload preview, and mask opaque id-like segments in logged endpoints.
2026-06-01 14:20:38 +03:00
3252a8 a0ea2261f4 feat: anonymous opt-out install telemetry beacon
Add a once-a-day anonymous heartbeat (PostHog) so maintainers can see
active installs and version/OS breakdowns. Self-hosted friendly: opt out
via TELEMETRY_ENABLED in .env or the Admin -> System toggle (applied
without a restart), or by clearing the endpoint/key.

- Share version resolution in bot/utils/app_version.py so the admin
  sidebar and the beacon report the same build version
- TelemetryWorker sends an opaque install id plus coarse facts only
  (version, OS/arch, python, locale, enabled providers, user-count
  range); never tokens, domains or user data
- Register the worker in main_worker.py behind a Redis single-flight lock
- Expose TELEMETRY_* settings and an Admin -> System manifest toggle
- Document the payload and opt-out in docs/configuration/telemetry.md
- Cover bucketing, payload shape and anonymity with tests
2026-06-01 14:14:02 +03:00
3252a8 5bb1400917 chore: improve tariff admin layout 2026-06-01 11:55:41 +03:00
3252a8andGitHub 63ec3a6152 Merge pull request #18 from 3252a8/feature/multicurrency
Add ability to use different currency than rub
2026-06-01 11:26:18 +03:00
3252a8 3896c455b8 fix: decouple docs email previews from bot deps 2026-06-01 11:23:45 +03:00
3252a8 d97afbec18 fix: apply ruff formatting 2026-06-01 11:16:28 +03:00
3252a8 21079f78dc fix: satisfy ruff line length 2026-06-01 11:11:15 +03:00
3252a8 3e922d8edb docs: document panel dry-run development mode 2026-06-01 10:35:22 +03:00
3252a8 391487811b feat: add remnawave panel dry-run mode 2026-06-01 10:32:38 +03:00
3252a8 939bc37995 Merge branch 'dev' into feature/multicurrency 2026-06-01 08:18:25 +03:00
3252a8 c2344824dc feat: show referral relationships in admin user cards 2026-06-01 00:36:05 +03:00
3252a8 7cf1d577f3 fix: restore pending email code screens 2026-05-31 23:27:38 +03:00
3252a8 ecf779763c feat: audit outbound user notifications 2026-05-31 23:05:40 +03:00
3252a8 1578a9da36 fix: align traffic top-up flows and unlimited overrides 2026-05-31 23:05:29 +03:00
3252a8 80e5f0c80d feat: add multicurrency tariff payments 2026-05-31 22:17:28 +03:00
3252a8 df8f2636d2 docs: render email previews from templates 2026-05-31 15:08:22 +03:00
3252a8 45543983c2 fix: run migrations after database restore 2026-05-31 14:57:32 +03:00
3252a8 b531d4de3b docs: add email preview demo section 2026-05-31 14:41:40 +03:00
3252a8 eda5d3e633 fix: localize support email templates 2026-05-31 14:41:26 +03:00
3252a8 e4ef52df8b ci: tag dev images only with dev (drop per-commit dev-<sha> tags) 2026-05-31 07:59:18 +03:00
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
298 changed files with 172070 additions and 3115 deletions
+32
View File
@@ -13,6 +13,10 @@ scratch/
*.local.*
node_modules/
frontend/node_modules/
docs-site/node_modules/
docs-site/.astro/
docs-site/dist/
docs-site/src/content/docs/
deploy/compose/docker-compose-dev.yml
data/*
!data/tariffs.example.json
@@ -31,10 +35,38 @@ deploy/compose/*.yml
tmp/
# WebApp build artifacts (regenerated inside Docker)
bot/app/web/templates/subscription_webapp.css
bot/app/web/templates/subscription_webapp.js
bot/app/web/templates/subscription_webapp.min.*.js
bot/app/web/templates/subscription_webapp.*.css
bot/app/web/templates/subscription_webapp.min.*.js.br
bot/app/web/templates/subscription_webapp.min.*.js.gz
bot/app/web/templates/subscription_webapp.*.css.br
bot/app/web/templates/subscription_webapp.*.css.gz
bot/app/web/templates/subscription_webapp_admin.css
bot/app/web/templates/subscription_webapp_admin.js
bot/app/web/templates/subscription_webapp_admin.min.*.js
bot/app/web/templates/subscription_webapp_admin.*.css
bot/app/web/templates/subscription_webapp_admin.min.*.js.br
bot/app/web/templates/subscription_webapp_admin.min.*.js.gz
bot/app/web/templates/subscription_webapp_admin.*.css.br
bot/app/web/templates/subscription_webapp_admin.*.css.gz
backend/bot/app/web/templates/subscription_webapp.css
backend/bot/app/web/templates/subscription_webapp.js
backend/bot/app/web/templates/subscription_webapp.min.*.js
backend/bot/app/web/templates/subscription_webapp.*.css
backend/bot/app/web/templates/subscription_webapp.min.*.js.br
backend/bot/app/web/templates/subscription_webapp.min.*.js.gz
backend/bot/app/web/templates/subscription_webapp.*.css.br
backend/bot/app/web/templates/subscription_webapp.*.css.gz
backend/bot/app/web/templates/subscription_webapp_admin.css
backend/bot/app/web/templates/subscription_webapp_admin.js
backend/bot/app/web/templates/subscription_webapp_admin.min.*.js
backend/bot/app/web/templates/subscription_webapp_admin.*.css
backend/bot/app/web/templates/subscription_webapp_admin.min.*.js.br
backend/bot/app/web/templates/subscription_webapp_admin.min.*.js.gz
backend/bot/app/web/templates/subscription_webapp_admin.*.css.br
backend/bot/app/web/templates/subscription_webapp_admin.*.css.gz
# Byte-compiled / optimized / DLL files
**/__pycache__/
+16 -1
View File
@@ -56,7 +56,10 @@ PANEL_API_URL=https://panel.yourdomain.tld/api
PANEL_API_KEY=
# Shared secret for validating incoming Remnawave webhooks.
# Use the same value when configuring the webhook in Remnawave panel.
# Create or set this secret in Remnawave Panel, then paste the same value here
# or into Admin -> System -> Settings -> Remnawave Panel.
# In Remnawave Panel, set WEBHOOK_URL to WEBHOOK_BASE_URL + /webhook/panel,
# for example: https://app.example.com/webhook/panel
PANEL_WEBHOOK_SECRET=
# Host port that publishes the backend webhook server from Docker Compose.
@@ -70,3 +73,15 @@ FRONTEND_PORT=8082
# Reverse proxy IPs/CIDRs trusted for X-Forwarded-For.
# Keep loopback for local proxy; add your proxy network if needed.
TRUSTED_PROXIES=127.0.0.1,::1
# ─── Anonymous install telemetry (opt-out) ──────────────────────────────
# Once a day the worker sends a single anonymous "heartbeat" so the project
# maintainer can see how many installs are active and which versions/OSes are
# used. It contains an opaque random install id and coarse facts only:
# version, OS/arch, Python version, language, enabled payment providers and a
# user-count RANGE (e.g. "51-200"). No bot token, domain, user data or any
# personal information is ever sent. Full details: docs/configuration/telemetry.md
#
# Set to False to disable, or toggle it any time in Admin -> System ->
# "Anonymous install analytics" (applies without a restart).
TELEMETRY_ENABLED=True
+7
View File
@@ -1,2 +1,9 @@
.gitattributes text eol=lf
*.sh text eol=lf
.github/workflows/*.yml text eol=lf
deploy/docker/frontend/*.sh text eol=lf
frontend/src/*.js text eol=lf
frontend/src/**/*.js text eol=lf
frontend/src/**/*.svelte text eol=lf
frontend/scripts/*.mjs text eol=lf
frontend/scripts/**/*.mjs text eol=lf
+116
View File
@@ -0,0 +1,116 @@
name: Docker build & push (reusable)
# Reusable workflow that builds the three image targets defined in
# deploy/docker/Dockerfile (backend, worker, frontend) and optionally pushes
# them to both ghcr.io and Docker Hub under the 3252a8/ namespace.
#
# Called by:
# - docker-dev.yml (tag_mode: dev, push: true) on pushes to dev
# - docker-release.yml (tag_mode: release, push: true) on pushes to main
# - ci.yml (tag_mode: dev, push: false) on pull requests
on:
workflow_call:
inputs:
push:
description: "Push the built images to the registries"
type: boolean
default: true
tag_mode:
description: "Tagging strategy: 'dev' or 'release'"
type: string
required: true
# No permissions block here on purpose: a reusable workflow cannot request more
# than its caller grants, so the token scope is set by each caller
# (docker-dev.yml / docker-release.yml grant packages: write to push; ci.yml
# only needs contents: read for a no-push build).
jobs:
build:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- target: backend
image: remnawave-minishop-backend
- target: worker
image: remnawave-minishop-worker
- target: frontend
image: remnawave-minishop-frontend
steps:
- name: Checkout
uses: actions/checkout@v4
with:
# Full history + tags: the Dockerfile's version-builder stage runs
# `git describe --tags` against the copied .git tree.
fetch-depth: 0
- name: Resolve release version
id: version
if: inputs.tag_mode == 'release'
run: |
# On a tag push github.ref_name is the tag (e.g. v3.4.5); for a
# manual workflow_dispatch on a branch, fall back to the latest tag.
if [ "${{ github.ref_type }}" = "tag" ]; then
raw="${{ github.ref_name }}"
else
raw="$(git describe --tags --abbrev=0 2>/dev/null)"
fi
version="${raw#v}"
if [ -z "$version" ]; then
echo "::error::No git tag found to derive the release version from"
exit 1
fi
echo "version=${version}" >> "$GITHUB_OUTPUT"
echo "Release version: ${version}"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
if: inputs.push
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Log in to Docker Hub
if: inputs.push
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: |
3252a8/${{ matrix.image }}
ghcr.io/3252a8/${{ matrix.image }}
tags: |
type=raw,value=dev,enable=${{ inputs.tag_mode == 'dev' }}
type=raw,value=latest,enable=${{ inputs.tag_mode == 'release' }}
type=raw,value=${{ steps.version.outputs.version }},enable=${{ inputs.tag_mode == 'release' }}
- name: Build${{ inputs.push && ' & push' || '' }} ${{ matrix.image }}
uses: docker/build-push-action@v6
with:
context: .
file: deploy/docker/Dockerfile
target: ${{ matrix.target }}
platforms: linux/amd64
push: ${{ inputs.push }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
# The Dockerfile's version-builder appends a "-<branch>" suffix to the
# internal version string for non-main builds. Force "main" on release
# (the ref is the tag, not a branch) so release images stay un-suffixed.
build-args: |
GITHUB_REF_NAME=${{ inputs.tag_mode == 'release' && 'main' || github.ref_name }}
cache-from: type=gha,scope=${{ matrix.target }}
cache-to: type=gha,mode=max,scope=${{ matrix.target }}
provenance: false
+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/
.git/
# Documentation site build artifacts
docs-site/.astro/
docs-site/dist/
docs-site/public/demo/runtime/
docs-site/src/content/docs/
# WebApp build artifacts (regenerated by `npm run build:webapp` / Docker build)
bot/app/web/templates/subscription_webapp.css
bot/app/web/templates/subscription_webapp.js
@@ -29,6 +35,11 @@ bot/app/web/templates/subscription_webapp_admin.min.*.js.br
bot/app/web/templates/subscription_webapp_admin.min.*.js.gz
bot/app/web/templates/subscription_webapp_admin.*.css.br
bot/app/web/templates/subscription_webapp_admin.*.css.gz
bot/app/web/templates/subscription_webapp_docs_demo.css
bot/app/web/templates/subscription_webapp_docs_demo.js
bot/app/web/templates/subscription_webapp_docs_demo.*.css
bot/app/web/templates/subscription_webapp_docs_demo.*.css.br
bot/app/web/templates/subscription_webapp_docs_demo.*.css.gz
backend/bot/app/web/templates/subscription_webapp.css
backend/bot/app/web/templates/subscription_webapp.js
backend/bot/app/web/templates/subscription_webapp.min.*.js
@@ -45,6 +56,11 @@ backend/bot/app/web/templates/subscription_webapp_admin.min.*.js.br
backend/bot/app/web/templates/subscription_webapp_admin.min.*.js.gz
backend/bot/app/web/templates/subscription_webapp_admin.*.css.br
backend/bot/app/web/templates/subscription_webapp_admin.*.css.gz
backend/bot/app/web/templates/subscription_webapp_docs_demo.css
backend/bot/app/web/templates/subscription_webapp_docs_demo.js
backend/bot/app/web/templates/subscription_webapp_docs_demo.*.css
backend/bot/app/web/templates/subscription_webapp_docs_demo.*.css.br
backend/bot/app/web/templates/subscription_webapp_docs_demo.*.css.gz
tmp
.claude
+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-коду.
Проект является переработанным форком [kavore/remnawave-tg-shop](https://github.com/kavore/remnawave-tg-shop). Для переноса данных из прежнего стека используйте [инструкцию по миграции](docs/migration-to-minishop.md).
Проект является переработанным форком [kavore/remnawave-tg-shop](https://github.com/kavore/remnawave-tg-shop). Для переноса данных из прежнего стека и других ботов используйте [раздел миграций](docs/migrations/index.md).
## Возможности
@@ -26,21 +26,25 @@ Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи
- статистика пользователей, подписок, платежей и синхронизации с Remnawave;
- список пользователей с поиском, фильтрами и колонкой premium-трафика;
- блокировка пользователей, поддержка через тикеты, рассылки, промокоды, логи действий и настройка разрешенных параметров приложения поверх `.env`;
- редактор JSON-каталога тарифов с period/traffic-моделями, Internal Squads, premium-сквадами и HWID-пакетами;
- настройки инструкций подключения: чтение конфига Subscription Page из Remnawave Panel, опциональный JSON-override и переключатель поведения кнопок бота;
- редактор JSON-каталога тарифов с моделями на срок/по трафику, Internal Squads, premium-сквадами и HWID-пакетами;
- настройки инструкций подключения: чтение конфига Subscription Page из Remnawave Panel, опциональное JSON-переопределение и переключатель поведения кнопок бота;
- ручная синхронизация пользователей и подписок с панелью.
## Документация
- [Настройка окружения](docs/configuration.md) - bootstrap `.env` и рекомендуемая настройка через Web App админку.
- [Переменные `.env`](docs/env-vars.md) - полный справочник всех env-ключей по разделам.
- [Тарифы](docs/tariffs.md) - каталог тарифов, period- и traffic-модели, обычные и premium-докупки, premium-сквады, смена тарифа, HWID-лимиты и обработка трафика.
- [Админ-панель](docs/admin.md) - права доступа, настройки, редактор тарифов, premium-сквады и сохранение JSON-каталога.
- [Web App / Mini App](docs/webapp.md) - отдельный порт, домен, Telegram OAuth, email-вход, инструкции установки и реферальные ссылки.
- [Поддержка](docs/support.md) - тикеты в Mini App, входящий список админки, уведомления, лимиты и внешняя ссылка поддержки.
- [Темы Web App](docs/webapp-themes.md) - кастомные темы, настройка внешнего вида, логотипы, CSS/ассеты и пайплайн создания новой темы.
- [Развертывание](docs/deployment.md) - Docker Compose, reverse proxy, Nginx, Caddy, вебхуки, запуск из образа и обновление версии (`IMAGE_TAG`).
- [Миграция с remnawave-tg-shop](docs/migration-to-minishop.md) - перенос данных из прежнего стека.
- [Входная страница документации](docs/index.md) - маршрут по установке, настройке, платежам, админке и диагностике.
- [Развертывание](docs/getting-started/deployment.md) - Docker Compose, Caddy, Nginx, Pangolin/Newt и запуск без обратного прокси.
- [Настройка окружения](docs/getting-started/configuration.md) - bootstrap `.env` и рекомендуемая настройка через Web App админку.
- [Переменные `.env`](docs/configuration/env-vars.md) - полный справочник всех env-ключей по разделам.
- [Бэкапы и восстановление](docs/features/backups.md) - автоматические архивы, Telegram-отправка и restore через админку.
- [Тарифы](docs/features/tariffs.md) - каталог тарифов, модели на срок и по трафику, обычные и premium-докупки, premium-сквады, смена тарифа, HWID-лимиты и обработка трафика.
- [Админ-панель](docs/features/admin-panel.md) - права доступа, настройки, редактор тарифов, premium-сквады и сохранение JSON-каталога.
- [Веб-приложение / Mini App](docs/features/web-app.md) - отдельный порт, домен, инструкции установки и реферальные ссылки.
- [Telegram-авторизация](docs/features/telegram-auth.md) и [вход по email](docs/features/email-login.md) - настройка BotFather/OAuth и SMTP-логина.
- [Поддержка пользователей / тикеты](docs/features/support.md) - тикеты в Mini App, входящий список админки, уведомления, лимиты и внешняя ссылка поддержки.
- [Темы Web App](docs/features/webapp-themes.md) - кастомные темы, настройка внешнего вида, логотипы, CSS/ассеты и пайплайн создания новой темы.
- [Миграции](docs/migrations/index.md) - готовые сценарии переноса с других ботов; сейчас описан `remnawave-tg-shop`.
- [Миграция с remnawave-tg-shop](docs/migrations/remnawave-tg-shop.md) - готовый сценарий для legacy-стека.
## Совместимость
@@ -88,11 +92,13 @@ docker compose logs -f backend worker frontend
- `PANEL_API_URL`, `PANEL_API_KEY`, `PANEL_WEBHOOK_SECRET` - доступ к Remnawave;
- остальные настройки удобнее задать в Web App админке.
После первого входа в админку настройте тарифы, платежные провайдеры, внешний вид, поддержку, уведомления и инструкции подключения через UI. Инструкции установки включены по умолчанию, читают Subscription Page config из Remnawave Panel и при проблемах с конфигом откатываются к обычной ссылке подключения. Полный справочник env-переменных: [docs/env-vars.md](docs/env-vars.md).
В Remnawave Panel укажите `WEBHOOK_URL` как публичный адрес Minishop с путем `/webhook/panel`, например `https://app.example.com/webhook/panel`. Секрет вебхука задается в самой Remnawave Panel; это же значение вставьте в `PANEL_WEBHOOK_SECRET` в `.env` или в **Система -> Настройки -> Remnawave Panel** в админке.
Для каталога тарифов используется `TARIFFS_CONFIG_PATH` со значением по умолчанию `data/tariffs.json`. Пример формата лежит в [data/tariffs.example.json](data/tariffs.example.json), подробности - в [docs/tariffs.md](docs/tariffs.md).
После первого входа в админку настройте тарифы, платежные провайдеры, внешний вид, поддержку, уведомления и инструкции подключения через UI. Инструкции установки включены по умолчанию, читают Subscription Page config из Remnawave Panel и при проблемах с конфигом откатываются к обычной ссылке подключения. Полный справочник env-переменных: [docs/configuration/env-vars.md](docs/configuration/env-vars.md).
Если в Docker Compose включаете bind mount `./data:/app/data`, заранее создайте каталог и отдайте его пользователю контейнера. Это нужно для сохранения `data/tariffs.json`, каталога тем `data/themes`, кеша логотипа Web App и animated emoji:
Для каталога тарифов используется `TARIFFS_CONFIG_PATH` со значением по умолчанию `data/tariffs.json`. Пример формата лежит в [data/tariffs.example.json](data/tariffs.example.json), подробности - в [docs/features/tariffs.md](docs/features/tariffs.md).
В compose-примерах `/app/data` монтируется из папки `./data` рядом с `docker-compose.yml`. Заранее создайте каталог и отдайте его пользователю контейнера. Это нужно для сохранения `data/tariffs.json`, каталога тем `data/themes`, кеша логотипа Web App и animated emoji:
```bash
mkdir -p data/themes data/webapp-logo data/webapp-emoji
@@ -110,7 +116,7 @@ docker compose up -d --build
# Логи приложения
docker compose logs -f backend worker frontend
# Готовые production-примеры
# Рекомендуемый продакшен-вариант с Caddy
cd deploy/examples/caddy # или nginx, newt, no-proxy
cp .env.example .env
nano .env
@@ -120,13 +126,24 @@ docker compose up -d
IMAGE_TAG=3.1.0 docker compose up -d
```
Для production-запуска удобнее брать готовые папки из [`deploy/examples`](deploy/examples): там отдельно собраны варианты для Caddy, Nginx, Newt/Pangolin и прямой публикации портов без reverse proxy. В каждой папке рядом лежат `docker-compose.yml`, `.env.example`, README и нужный proxy-конфиг.
Для продакшен-запуска удобнее брать готовые папки из [`deploy/examples`](deploy/examples), а читать каноничные инструкции в [docs/getting-started/deployment.md](docs/getting-started/deployment.md). Предпочтительный вариант для обычного публичного сервера - Caddy: он сам выпускает и продлевает HTTPS-сертификаты. В папках рядом с compose лежат только конфиги и короткие ссылки на документацию.
GHCR image names for releases:
Имена образов для релизов:
- `ghcr.io/3252a8/remnawave-minishop-backend`
- `ghcr.io/3252a8/remnawave-minishop-worker`
- `ghcr.io/3252a8/remnawave-minishop-frontend`
- `docker.io/3252a8/remnawave-minishop-backend`
- `docker.io/3252a8/remnawave-minishop-worker`
- `docker.io/3252a8/remnawave-minishop-frontend`
Сборка и публикация сразу в GHCR и Docker Hub:
```bash
docker login ghcr.io
docker login docker.io
IMAGE_TAG=v3.4.3 bash scripts/docker-build-push-images.sh
```
## Поддержать проект
+6 -1
View File
@@ -11,6 +11,7 @@ from bot.services.email_auth_service import EmailAuthService
from bot.services.lknpd_service import LknpdService
from bot.services.notification_service import NotificationService
from bot.services.panel_api_service import PanelApiService
from bot.services.panel_dry_run_api_service import PanelDryRunApiService
from bot.services.panel_webhook_service import PanelWebhookService
from bot.services.promo_code_service import PromoCodeService
from bot.services.referral_service import ReferralService
@@ -26,7 +27,11 @@ def build_core_services(
i18n: JsonI18n,
bot_username_for_default_return: str,
):
panel_service = PanelApiService(settings)
panel_service = (
PanelDryRunApiService(settings)
if bool(getattr(settings, "panel_dry_run_enabled", False))
else PanelApiService(settings)
)
subscription_service = SubscriptionService(settings, panel_service, bot, i18n)
referral_service = ReferralService(settings, subscription_service, bot, i18n)
promo_code_service = PromoCodeService(settings, subscription_service, bot, i18n)
+2
View File
@@ -6,6 +6,7 @@ from bot.app.web.admin_api_impl import (
_runtime as _runtime,
ads as _ads,
auth as _auth,
backups as _backups,
broadcast as _broadcast,
common as _common,
logs as _logs,
@@ -36,6 +37,7 @@ _MODULES = (
_broadcast,
_sync,
_ads,
_backups,
_settings,
_tariffs,
_themes,
@@ -37,7 +37,7 @@ from bot.services.settings_override_service import (
from bot.utils import MessageContent, send_message_via_queue
from bot.utils.message_queue import get_queue_manager
from config.settings import Settings
from config.tariffs_config import TariffsConfig
from config.tariffs_config import TariffsConfig, default_payment_currency_code_for_settings
from db.dal import (
ad_dal,
app_settings_dal,
@@ -0,0 +1,181 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
import secrets
import subprocess
from bot.infra.redis import redis_lock
from bot.services.backup_restore_service import (
BACKUP_UPLOAD_MAX_BYTES,
BackupArchiveError,
BackupArchiveInfo,
BackupRestoreError,
BackupRestoreService,
)
from bot.services.backup_worker import BackupWorker
def _backup_archive_payload(archive) -> Dict[str, Any]:
return archive.to_payload()
async def _read_uploaded_backup_file(request: web.Request) -> BackupArchiveInfo:
settings: Settings = request.app["settings"]
service = BackupRestoreService(settings)
backup_dir = service.backup_dir()
temp_path: Optional[Path] = None
reader = await request.multipart()
try:
async for part in reader:
if part.name != "file":
continue
original_filename = part.filename or "backup.zip"
temp_path = backup_dir / f".upload-{secrets.token_urlsafe(12)}.zip.tmp"
size = 0
with temp_path.open("wb") as handle:
while True:
chunk = await part.read_chunk(size=1024 * 1024)
if not chunk:
break
size += len(chunk)
if size > BACKUP_UPLOAD_MAX_BYTES:
raise BackupArchiveError("Backup archive is too large")
handle.write(chunk)
if size <= 0:
raise BackupArchiveError("Uploaded archive is empty")
archive = service.import_uploaded_archive(temp_path, original_filename)
temp_path = None
return archive
finally:
if temp_path is not None and temp_path.exists():
try:
temp_path.unlink()
except OSError:
logger.warning("Failed to remove temporary backup upload %s", temp_path)
raise BackupArchiveError("file field is required")
async def admin_backups_list_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
settings: Settings = request.app["settings"]
try:
service = BackupRestoreService(settings)
archives = service.list_archives()
except OSError as exc:
logger.exception("Failed to list backup archives")
return _error(500, "backup_list_failed", str(exc))
return _ok(
{
"backup_dir": str(service.backup_dir()),
"archives": [_backup_archive_payload(archive) for archive in archives],
}
)
async def admin_backups_upload_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
content_type = (request.headers.get("Content-Type") or "").lower()
if not content_type.startswith("multipart/form-data"):
return _error(400, "invalid_backup_archive", "multipart file upload is required")
try:
archive = await _read_uploaded_backup_file(request)
except BackupArchiveError as exc:
return _error(400, "invalid_backup_archive", str(exc))
except OSError as exc:
logger.exception("Failed to save uploaded backup archive")
return _error(500, "backup_upload_failed", str(exc))
return _ok({"archive": _backup_archive_payload(archive)})
async def admin_backups_create_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
settings: Settings = request.app["settings"]
bot = request.app["bot"]
session_factory = request.app.get("async_session_factory")
worker = BackupWorker(settings, bot, session_factory=session_factory)
ttl_seconds = max(
60,
int(
max(
getattr(settings, "BACKUP_LOCK_TTL_SECONDS", 7200) or 7200,
getattr(settings, "BACKUP_PG_DUMP_TIMEOUT_SECONDS", 1800) or 1800,
)
),
)
try:
async with redis_lock(settings, "backup-worker", ttl_seconds=ttl_seconds) as acquired:
if not acquired:
return _error(409, "backup_create_busy", "Backup or restore is already running")
await worker.refresh_settings()
result = await worker.create_and_send_backup(backup_type="manual")
archive = BackupRestoreService(settings).inspect_archive(result.archive_path)
except BackupArchiveError as exc:
return _error(400, "invalid_backup_archive", str(exc))
except (OSError, RuntimeError, subprocess.SubprocessError, TimeoutError) as exc:
logger.exception("Manual backup creation failed")
return _error(500, "backup_create_failed", str(exc))
except Exception as exc:
logger.exception("Manual backup creation failed")
return _error(500, "backup_create_failed", str(exc))
return _ok(
{
"result": result.to_payload(),
"archive": _backup_archive_payload(archive),
}
)
async def admin_backups_restore_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
settings: Settings = request.app["settings"]
payload = await _read_json(request)
archive_name = str(payload.get("archive_name") or "").strip()
restore_database = bool(payload.get("restore_database"))
restore_compose = bool(payload.get("restore_compose"))
confirm = bool(payload.get("confirm"))
if not confirm:
return _error(400, "restore_confirmation_required")
service = BackupRestoreService(settings)
ttl_seconds = max(
60,
int(
max(
getattr(settings, "BACKUP_LOCK_TTL_SECONDS", 7200) or 7200,
getattr(settings, "BACKUP_PG_RESTORE_TIMEOUT_SECONDS", 1800) or 1800,
)
),
)
try:
async with redis_lock(settings, "backup-worker", ttl_seconds=ttl_seconds) as acquired:
if not acquired:
return _error(409, "backup_restore_busy", "Backup or restore is already running")
result = await service.restore_archive(
archive_name,
restore_database=restore_database,
restore_compose=restore_compose,
)
except BackupArchiveError as exc:
return _error(400, "invalid_backup_archive", str(exc))
except BackupRestoreError as exc:
logger.exception("Backup restore failed")
return _error(500, "backup_restore_failed", str(exc))
except (OSError, subprocess.SubprocessError, TimeoutError) as exc:
logger.exception("Backup restore failed")
return _error(500, "backup_restore_failed", str(exc))
finally:
try:
from db import database_setup
if restore_database and database_setup.async_engine is not None:
await database_setup.async_engine.dispose()
except Exception:
logger.exception("Failed to dispose DB engine after backup restore")
return _ok({"result": result.to_payload()})
@@ -9,7 +9,7 @@ async def admin_broadcast_route(request: web.Request) -> web.Response:
target = str(payload.get("target") or "all").strip().lower()
if not text:
return _error(400, "empty_text")
if target not in {"all", "active", "inactive"}:
if target not in {"all", "active", "inactive", "expired"}:
target = "all"
queue_manager = get_queue_manager()
@@ -22,6 +22,8 @@ async def admin_broadcast_route(request: web.Request) -> web.Response:
user_ids = await user_dal.get_user_ids_with_active_subscription(session)
elif target == "inactive":
user_ids = await user_dal.get_user_ids_without_active_subscription(session)
elif target == "expired":
user_ids = await user_dal.get_user_ids_with_expired_subscription(session)
else:
user_ids = await user_dal.get_all_active_user_ids_for_broadcast(session)
+49 -12
View File
@@ -143,12 +143,18 @@ def _payment_traffic_gb_split(payment: Payment) -> Tuple[Optional[float], Option
return None, None
def _payment_user_display_label(loaded_user: Any, payment_user_id: int) -> str:
"""Human-facing name for payments tables: TG profile name, else email, else user id."""
if loaded_user is None:
return str(payment_user_id)
def _user_display_label(
loaded_user: Any,
fallback_user_id: Optional[int],
*,
first_name: Optional[str] = None,
last_name: Optional[str] = None,
username: Optional[str] = None,
email: Optional[str] = None,
) -> Optional[str]:
"""Human-facing name: TG profile name, else email, else user id."""
tid = getattr(loaded_user, "telegram_id", None)
if tid is not None:
if loaded_user is not None and tid is not None:
fn = (getattr(loaded_user, "first_name", None) or "").strip()
ln = (getattr(loaded_user, "last_name", None) or "").strip()
full = f"{fn} {ln}".strip()
@@ -157,10 +163,30 @@ def _payment_user_display_label(loaded_user: Any, payment_user_id: int) -> str:
un = (getattr(loaded_user, "username", None) or "").strip()
if un:
return un if un.startswith("@") else f"@{un}"
return str(payment_user_id)
email = (getattr(loaded_user, "email", None) or "").strip()
if email:
return email
elif loaded_user is not None:
email = (getattr(loaded_user, "email", None) or "").strip()
if email:
return email
fn = (first_name or "").strip()
ln = (last_name or "").strip()
full = f"{fn} {ln}".strip()
if full:
return full
un = (username or "").strip()
if un:
return un if un.startswith("@") else f"@{un}"
email_value = (email or "").strip()
if email_value:
return email_value
if fallback_user_id is None:
return None
return str(fallback_user_id)
def _payment_user_display_label(loaded_user: Any, payment_user_id: int) -> str:
label = _user_display_label(loaded_user, payment_user_id)
if label:
return label
return str(payment_user_id)
@@ -229,16 +255,27 @@ def _serialize_ad(campaign: AdCampaign, totals: Optional[Dict[str, Any]] = None)
def _serialize_log(entry: MessageLog) -> Dict[str, Any]:
author_user = entry.__dict__.get("author_user")
target_user = entry.__dict__.get("target_user")
user_id = int(entry.user_id) if entry.user_id is not None else None
target_user_id = int(entry.target_user_id) if entry.target_user_id is not None else None
return {
"log_id": int(entry.log_id),
"user_id": int(entry.user_id) if entry.user_id else None,
"user_id": user_id,
"user_label": _user_display_label(
author_user,
user_id,
first_name=entry.telegram_first_name,
username=entry.telegram_username,
),
"telegram_username": entry.telegram_username,
"telegram_first_name": entry.telegram_first_name,
"email": getattr(getattr(entry, "author_user", None), "email", None),
"email": getattr(author_user, "email", None),
"event_type": entry.event_type,
"content": entry.content,
"is_admin_event": bool(entry.is_admin_event),
"target_user_id": int(entry.target_user_id) if entry.target_user_id else None,
"target_user_id": target_user_id,
"target_user_label": _user_display_label(target_user, target_user_id),
"timestamp": entry.timestamp.isoformat() if entry.timestamp else None,
}
@@ -9,6 +9,7 @@ def setup_admin_routes(app: web.Application) -> None:
router.add_get("/api/admin/users", admin_users_list_route)
router.add_get("/api/admin/users/{user_id:-?\\d+}", admin_user_detail_route)
router.add_get("/api/admin/users/{user_id:-?\\d+}/referrals", admin_user_referrals_route)
router.add_get("/api/admin/users/{user_id:-?\\d+}/avatar", admin_user_avatar_route)
router.add_post("/api/admin/users/{user_id:-?\\d+}/ban", admin_user_ban_route)
router.add_post("/api/admin/users/{user_id:-?\\d+}/message", admin_user_message_route)
@@ -75,4 +76,8 @@ def setup_admin_routes(app: web.Application) -> None:
router.add_put("/api/admin/themes", admin_themes_save_route)
router.add_post("/api/admin/appearance/logo", admin_appearance_logo_upload_route)
router.add_post("/api/admin/appearance/favicon", admin_appearance_favicon_upload_route)
router.add_get("/api/admin/backups", admin_backups_list_route)
router.add_post("/api/admin/backups/create", admin_backups_create_route)
router.add_post("/api/admin/backups/upload", admin_backups_upload_route)
router.add_post("/api/admin/backups/restore", admin_backups_restore_route)
router.add_get("/api/admin/panel/internal-squads", admin_panel_internal_squads_route)
+1 -1
View File
@@ -34,7 +34,7 @@ async def admin_stats_route(request: web.Request) -> web.Response:
except Exception: # pragma: no cover - defensive
payload["queue"] = None
payload["currency_symbol"] = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
payload["currency_symbol"] = default_payment_currency_code_for_settings(settings)
return _ok(payload)
+54 -1
View File
@@ -21,9 +21,14 @@ async def admin_tariffs_get_route(request: web.Request) -> web.Response:
"path": str(path),
"catalog": {
"default_tariff": "",
"default_currency": "rub",
"topup_packages_default": {"rub": [], "stars": []},
"tariffs": [],
},
"provider_currency_support": _provider_currency_support_payload(
settings,
request.app,
),
}
)
@@ -32,6 +37,7 @@ async def admin_tariffs_get_route(request: web.Request) -> web.Response:
"exists": True,
"path": str(path),
"catalog": _tariffs_config_payload(config),
"provider_currency_support": _provider_currency_support_payload(settings, request.app),
}
)
@@ -58,4 +64,51 @@ async def admin_tariffs_save_route(request: web.Request) -> web.Response:
await refresh_webapp_runtime_after_settings_change(request, updates={}, deletes=[])
return _ok({"exists": True, "path": str(path), "catalog": _tariffs_config_payload(config)})
return _ok(
{
"exists": True,
"path": str(path),
"catalog": _tariffs_config_payload(config),
"provider_currency_support": _provider_currency_support_payload(settings, request.app),
}
)
def _provider_currency_support_payload(
settings: Settings,
app: web.Application,
) -> List[Dict[str, Any]]:
from bot.payment_providers import iter_provider_specs, resolve_provider_presentation
default_currency = default_payment_currency_code_for_settings(settings)
providers: List[Dict[str, Any]] = []
for spec in iter_provider_specs():
presentation = resolve_provider_presentation(spec, settings)
supported = spec.supported_currency_codes(settings)
providers.append(
{
"id": spec.id,
"provider_key": spec.provider_key,
"label": presentation.webapp_label or spec.label,
"telegram_label": presentation.telegram_label,
"icon": presentation.webapp_icon,
"enabled": spec.is_effectively_enabled(settings),
"configured": spec.is_service_configured(app),
"admin_only": spec.is_admin_only_enabled(settings),
"price_source": spec.price_source,
"currencies": list(supported) if supported is not None else None,
"accepts_any_currency": supported is None,
"supports_default_currency": spec.is_usable_for_payment_currency(
settings,
default_currency,
),
"directly_supports_default_currency": spec.supports_currency(
settings,
default_currency,
),
"default_currency": default_currency,
"note": spec.currency_support_note,
"docs_url": spec.currency_support_url,
}
)
return providers
@@ -11,6 +11,7 @@ from bot.services.locale_override_service import (
update_locale_overrides,
)
def _locale_languages(
i18n: JsonI18n,
overrides: Optional[List[Dict[str, Any]]] = None,
+247 -18
View File
@@ -16,6 +16,7 @@ import hashlib
from html import escape as html_escape
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from sqlalchemy.orm import aliased
from bot.app.web.webapp.cache_helpers import invalidate_webapp_user_caches
from bot.infra.redis import cache_delete_pattern, redis_key
@@ -127,12 +128,15 @@ async def _load_admin_users_list_payload_uncached(
active_subs = await _bulk_active_subscriptions_for_users(
session, [u.user_id for u in users]
)
payment_summaries = await _bulk_user_payment_summaries(session, [u.user_id for u in users])
referral_counts = await _bulk_user_referral_counts(session, [u.user_id for u in users])
serialized = []
for user in users:
payload = _serialize_user(user)
status_payload = statuses.get(user.user_id) or {"status": "bot_only", "end_date": None}
payload["panel_status"] = status_payload.get("status")
payload["subscription_expires_at"] = status_payload.get("end_date")
if status_payload.get("status") == "expired" and status_payload.get("end_date"):
payload["panel_status_expired_at"] = status_payload["end_date"]
payload["avatar_url"] = (
@@ -141,6 +145,11 @@ async def _load_admin_users_list_payload_uncached(
else None
)
payload["premium_traffic"] = _premium_traffic_list_payload(active_subs.get(user.user_id))
payment_summary = payment_summaries.get(user.user_id) or {}
payload["payments_total_amount"] = float(payment_summary.get("total_amount") or 0)
payload["payments_count"] = int(payment_summary.get("count") or 0)
payload["payments_currency"] = payment_summary.get("currency")
payload["invited_users_count"] = int(referral_counts.get(user.user_id) or 0)
serialized.append(payload)
return {
@@ -255,6 +264,17 @@ async def _bulk_user_avatar_keys(session: AsyncSession, user_ids: List[int]) ->
return {int(uid): (updated_at.isoformat() if updated_at else "") for uid, updated_at in rows}
def _serialize_admin_user_with_avatar(user: User, avatar_keys: Dict[int, str]) -> Dict[str, Any]:
payload = _serialize_user(user)
user_id = int(user.user_id)
payload["avatar_url"] = (
f"/api/admin/users/{user_id}/avatar?v={avatar_keys[user_id]}"
if user_id in avatar_keys
else None
)
return payload
async def admin_user_avatar_route(request: web.Request) -> web.Response:
"""Serve the cached Telegram avatar for any user (admin-only).
@@ -353,6 +373,88 @@ async def _bulk_active_subscriptions_for_users(
return out
def _user_payment_summary_sq():
return (
select(
Payment.user_id.label("user_id"),
sa_func.coalesce(sa_func.sum(Payment.amount), 0.0).label("payments_total_amount"),
sa_func.count(Payment.payment_id).label("payments_count"),
)
.where(Payment.status == "succeeded")
.group_by(Payment.user_id)
.subquery(name="user_payment_summary")
)
def _user_referral_count_sq():
referred_user = aliased(User)
return (
select(
referred_user.referred_by_id.label("user_id"),
sa_func.count(referred_user.user_id).label("invited_users_count"),
)
.where(referred_user.referred_by_id.is_not(None))
.group_by(referred_user.referred_by_id)
.subquery(name="user_referral_count")
)
def _user_subscription_expiry_sq():
return (
select(
Subscription.user_id.label("user_id"),
sa_func.max(Subscription.end_date).label("subscription_expires_at"),
)
.group_by(Subscription.user_id)
.subquery(name="user_subscription_expiry")
)
async def _bulk_user_payment_summaries(
session: AsyncSession,
user_ids: List[int],
) -> Dict[int, Dict[str, Any]]:
if not user_ids:
return {}
stmt = (
select(
Payment.user_id,
sa_func.coalesce(sa_func.sum(Payment.amount), 0.0),
sa_func.count(Payment.payment_id),
sa_func.max(Payment.currency),
)
.where(Payment.user_id.in_(user_ids), Payment.status == "succeeded")
.group_by(Payment.user_id)
)
rows = (await session.execute(stmt)).all()
return {
int(user_id): {
"total_amount": float(total_amount or 0),
"count": int(payments_count or 0),
"currency": currency,
}
for user_id, total_amount, payments_count, currency in rows
}
async def _bulk_user_referral_counts(
session: AsyncSession,
user_ids: List[int],
) -> Dict[int, int]:
if not user_ids:
return {}
referred_user = aliased(User)
stmt = (
select(referred_user.referred_by_id, sa_func.count(referred_user.user_id))
.where(referred_user.referred_by_id.in_(user_ids))
.group_by(referred_user.referred_by_id)
)
rows = (await session.execute(stmt)).all()
return {int(user_id): int(count or 0) for user_id, count in rows}
async def _filter_and_sort_users(
session: AsyncSession,
*,
@@ -381,6 +483,13 @@ async def _filter_and_sort_users(
ratio_expr = None
plim_expr = None
pu_expr = None
payment_summary_sq = None
payment_total_expr = None
payment_count_expr = None
referral_count_sq = None
referral_count_expr = None
subscription_expiry_sq = None
subscription_expires_expr = None
if needs_premium_sq:
sq = _ranked_active_subscriptions_sq(now)
@@ -401,6 +510,42 @@ async def _filter_and_sort_users(
else_=cast(pu_expr, Float) / cast(plim_expr, Float),
)
if sort_key in {
"payments_total_asc",
"payments_total_desc",
"payments_count_asc",
"payments_count_desc",
}:
payment_summary_sq = _user_payment_summary_sq()
stmt = stmt.outerjoin(payment_summary_sq, User.user_id == payment_summary_sq.c.user_id)
count_stmt = count_stmt.outerjoin(
payment_summary_sq,
User.user_id == payment_summary_sq.c.user_id,
)
payment_total_expr = sa_func.coalesce(payment_summary_sq.c.payments_total_amount, 0.0)
payment_count_expr = sa_func.coalesce(payment_summary_sq.c.payments_count, 0)
if sort_key in {"invited_users_count_asc", "invited_users_count_desc"}:
referral_count_sq = _user_referral_count_sq()
stmt = stmt.outerjoin(referral_count_sq, User.user_id == referral_count_sq.c.user_id)
count_stmt = count_stmt.outerjoin(
referral_count_sq,
User.user_id == referral_count_sq.c.user_id,
)
referral_count_expr = sa_func.coalesce(referral_count_sq.c.invited_users_count, 0)
if sort_key in {"subscription_expires_at_asc", "subscription_expires_at_desc"}:
subscription_expiry_sq = _user_subscription_expiry_sq()
stmt = stmt.outerjoin(
subscription_expiry_sq,
User.user_id == subscription_expiry_sq.c.user_id,
)
count_stmt = count_stmt.outerjoin(
subscription_expiry_sq,
User.user_id == subscription_expiry_sq.c.user_id,
)
subscription_expires_expr = subscription_expiry_sq.c.subscription_expires_at
search_cond = _user_search_condition(query)
if search_cond is not None:
stmt = stmt.where(search_cond)
@@ -499,6 +644,22 @@ async def _filter_and_sort_users(
stmt = stmt.order_by(ratio_expr.asc().nullslast(), User.user_id.asc())
elif needs_premium_sq and ratio_expr is not None and sort_key == "premium_ratio_desc":
stmt = stmt.order_by(ratio_expr.desc().nullslast(), User.user_id.desc())
elif payment_total_expr is not None and sort_key == "payments_total_asc":
stmt = stmt.order_by(payment_total_expr.asc(), User.user_id.asc())
elif payment_total_expr is not None and sort_key == "payments_total_desc":
stmt = stmt.order_by(payment_total_expr.desc(), User.user_id.desc())
elif payment_count_expr is not None and sort_key == "payments_count_asc":
stmt = stmt.order_by(payment_count_expr.asc(), User.user_id.asc())
elif payment_count_expr is not None and sort_key == "payments_count_desc":
stmt = stmt.order_by(payment_count_expr.desc(), User.user_id.desc())
elif referral_count_expr is not None and sort_key == "invited_users_count_asc":
stmt = stmt.order_by(referral_count_expr.asc(), User.user_id.asc())
elif referral_count_expr is not None and sort_key == "invited_users_count_desc":
stmt = stmt.order_by(referral_count_expr.desc(), User.user_id.desc())
elif subscription_expires_expr is not None and sort_key == "subscription_expires_at_asc":
stmt = stmt.order_by(subscription_expires_expr.asc().nullslast(), User.user_id.asc())
elif subscription_expires_expr is not None and sort_key == "subscription_expires_at_desc":
stmt = stmt.order_by(subscription_expires_expr.desc().nullslast(), User.user_id.desc())
else:
order = sort_map.get(sort_key, sort_map["registered_desc"])
if isinstance(order, tuple):
@@ -527,9 +688,34 @@ def _user_panel_status_condition(panel_status: str):
normalized_status == "active", blank_status & Subscription.is_active.is_(True)
)
elif status == "expired":
status_cond = or_(
normalized_status == "expired", blank_status & Subscription.is_active.is_(False)
now = datetime.now(timezone.utc)
expired_subs = aliased(Subscription)
active_subs = aliased(Subscription)
expired_status = sa_func.lower(sa_func.coalesce(expired_subs.status_from_panel, ""))
expired_blank_status = or_(
expired_subs.status_from_panel.is_(None),
expired_subs.status_from_panel == "",
)
expired_condition = or_(
expired_status == "expired",
expired_blank_status & expired_subs.is_active.is_(False),
expired_subs.end_date <= now,
)
expired_exists = (
select(expired_subs.subscription_id)
.where(expired_subs.user_id == User.user_id, expired_condition)
.exists()
)
active_exists = (
select(active_subs.subscription_id)
.where(
active_subs.user_id == User.user_id,
active_subs.is_active.is_(True),
active_subs.end_date > now,
)
.exists()
)
return and_(expired_exists, ~active_exists)
else:
status_cond = normalized_status == "limited"
@@ -587,7 +773,12 @@ async def admin_user_detail_route(request: web.Request) -> web.Response:
)
recent_payments = (await session.execute(recent_payments_stmt)).scalars().all()
log_count = await message_log_dal.count_user_message_logs(session, target_id)
avatar_keys = await _bulk_user_avatar_keys(session, [target_id])
inviter = await user_dal.get_referrer_for_user(session, user)
invitees_total = await user_dal.count_users_referred_by(session, target_id)
avatar_user_ids = [target_id]
if inviter is not None:
avatar_user_ids.append(int(inviter.user_id))
avatar_keys = await _bulk_user_avatar_keys(session, avatar_user_ids)
# Referral links — both the bot deep-link and the webapp deep-link.
referral_code: Optional[str] = None
@@ -635,11 +826,9 @@ async def admin_user_detail_route(request: web.Request) -> web.Response:
exc_panel,
)
serialized_user = _serialize_user(user)
serialized_user["avatar_url"] = (
f"/api/admin/users/{target_id}/avatar?v={avatar_keys[target_id]}"
if target_id in avatar_keys
else None
serialized_user = _serialize_admin_user_with_avatar(user, avatar_keys)
serialized_inviter = (
_serialize_admin_user_with_avatar(inviter, avatar_keys) if inviter is not None else None
)
return _ok(
@@ -655,11 +844,54 @@ async def admin_user_detail_route(request: web.Request) -> web.Response:
"code": referral_code,
"bot_link": referral_bot_link,
"webapp_link": referral_webapp_link,
"inviter": serialized_inviter,
"invitees_total": int(invitees_total or 0),
},
}
)
async def admin_user_referrals_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
target_id = int(request.match_info["user_id"])
page = max(0, int(request.query.get("page", 0) or 0))
page_size = min(100, max(1, int(request.query.get("page_size", 25) or 25)))
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
user = await user_dal.get_user_by_id(session, target_id)
if not user:
return _error(404, "not_found", "User not found")
inviter = await user_dal.get_referrer_for_user(session, user)
invitees_total = await user_dal.count_users_referred_by(session, target_id)
invitees = await user_dal.get_users_referred_by(
session,
target_id,
limit=page_size,
offset=page * page_size,
)
avatar_user_ids = [target_id, *(int(u.user_id) for u in invitees)]
if inviter is not None:
avatar_user_ids.append(int(inviter.user_id))
avatar_keys = await _bulk_user_avatar_keys(session, avatar_user_ids)
return _ok(
{
"user": _serialize_admin_user_with_avatar(user, avatar_keys),
"inviter": _serialize_admin_user_with_avatar(inviter, avatar_keys)
if inviter is not None
else None,
"invitees": [
_serialize_admin_user_with_avatar(invitee, avatar_keys) for invitee in invitees
],
"total": int(invitees_total or 0),
"page": page,
"page_size": page_size,
}
)
async def admin_user_ban_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
target_id = int(request.match_info["user_id"])
@@ -940,10 +1172,6 @@ async def admin_user_reset_trial_route(request: web.Request) -> web.Response:
actor_id = _require_admin_user_id(request)
target_id = int(request.match_info["user_id"])
settings: Settings = request.app["settings"]
panel_service = request.app.get("panel_service")
subscription_service = request.app.get("subscription_service")
if panel_service is None or subscription_service is None:
return _error(503, "service_unavailable")
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
@@ -951,16 +1179,17 @@ async def admin_user_reset_trial_route(request: web.Request) -> web.Response:
if not user:
return _error(404, "not_found")
active = await subscription_dal.get_active_subscription_by_user_id(session, target_id)
if active:
await session.delete(active)
reset_at = await user_dal.mark_trial_eligibility_reset(session, target_id)
if reset_at is None:
await session.rollback()
return _error(404, "not_found")
await message_log_dal.create_message_log(
await message_log_dal.create_message_log_no_commit(
session,
{
"user_id": actor_id,
"event_type": "admin_reset_trial_webapp",
"content": f"Reset trial for user_id={target_id}",
"content": f"Reset trial eligibility for user_id={target_id}",
"is_admin_event": True,
"target_user_id": target_id,
},
@@ -1031,7 +1260,7 @@ async def admin_user_premium_override_route(request: web.Request) -> web.Respons
async def admin_user_regular_traffic_override_route(request: web.Request) -> web.Response:
"""Main (regular) traffic: unlimited-style ceiling + admin bonus GB."""
"""Main (regular) traffic: native unlimited panel limit + admin bonus GB."""
actor_id = _require_admin_user_id(request)
target_id = int(request.match_info["user_id"])
settings: Settings = request.app["settings"]
+184 -68
View File
@@ -30,6 +30,11 @@ class SettingField:
i18n_label_key: Optional[str] = None
i18n_description_key: Optional[str] = None
i18n_subsection_key: Optional[str] = None
webhook_path: Optional[str] = None
webhook_requires_base_url: bool = False
webhook_provider_id: Optional[str] = None
webhook_hint_i18n_key: Optional[str] = None
webhook_hint: str = ""
SETTINGS_MANIFEST: List[SettingField] = [
@@ -84,44 +89,44 @@ SETTINGS_MANIFEST: List[SettingField] = [
SettingField(
"PANEL_API_URL",
"url",
"general",
"remnawave",
"URL API Remnawave",
"Например, https://panel.example.com/api.",
subsection="Remnawave",
),
SettingField(
"PANEL_API_KEY",
"string",
"general",
"remnawave",
"API-ключ Remnawave",
"Секретный ключ API панели.",
secret=True,
subsection="Remnawave",
),
SettingField(
"PANEL_WEBHOOK_SECRET",
"string",
"general",
"remnawave",
"Секрет вебхуков Remnawave",
"Используется для проверки входящих вебхуков панели.",
secret=True,
subsection="Remnawave",
webhook_path="/webhook/panel",
webhook_requires_base_url=True,
webhook_provider_id="remnawave",
webhook_hint_i18n_key="admin_settings_panel_webhook_url_hint",
webhook_hint="Use this URL as WEBHOOK_URL in Remnawave Panel.",
),
SettingField(
"USER_SQUAD_UUIDS",
"string",
"general",
"remnawave",
"Internal Squads по умолчанию",
"UUID через запятую для legacy-режима без JSON-каталога тарифов.",
subsection="Remnawave",
),
SettingField(
"USER_EXTERNAL_SQUAD_UUID",
"string",
"general",
"remnawave",
"External Squad по умолчанию",
"Необязательный UUID External Squad для новых пользователей.",
subsection="Remnawave",
),
# ─── Web app appearance ────────────────────────────────────────
SettingField(
@@ -230,6 +235,70 @@ SETTINGS_MANIFEST: List[SettingField] = [
SettingField("STARS_PRICE_3_MONTHS", "int", "pricing", "Цена 3 мес. (Stars)"),
SettingField("STARS_PRICE_6_MONTHS", "int", "pricing", "Цена 6 мес. (Stars)"),
SettingField("STARS_PRICE_12_MONTHS", "int", "pricing", "Цена 12 мес. (Stars)"),
SettingField(
"REFERRAL_BONUS_DAYS_INVITER_1_MONTH",
"int",
"pricing",
"Бонус приглашающему: 1 мес.",
min=0,
subsection="legacy_tariffs",
),
SettingField(
"REFERRAL_BONUS_DAYS_INVITER_3_MONTHS",
"int",
"pricing",
"Бонус приглашающему: 3 мес.",
min=0,
subsection="legacy_tariffs",
),
SettingField(
"REFERRAL_BONUS_DAYS_INVITER_6_MONTHS",
"int",
"pricing",
"Бонус приглашающему: 6 мес.",
min=0,
subsection="legacy_tariffs",
),
SettingField(
"REFERRAL_BONUS_DAYS_INVITER_12_MONTHS",
"int",
"pricing",
"Бонус приглашающему: 12 мес.",
min=0,
subsection="legacy_tariffs",
),
SettingField(
"REFERRAL_BONUS_DAYS_REFEREE_1_MONTH",
"int",
"pricing",
"Бонус приглашённому: 1 мес.",
min=0,
subsection="legacy_tariffs",
),
SettingField(
"REFERRAL_BONUS_DAYS_REFEREE_3_MONTHS",
"int",
"pricing",
"Бонус приглашённому: 3 мес.",
min=0,
subsection="legacy_tariffs",
),
SettingField(
"REFERRAL_BONUS_DAYS_REFEREE_6_MONTHS",
"int",
"pricing",
"Бонус приглашённому: 6 мес.",
min=0,
subsection="legacy_tariffs",
),
SettingField(
"REFERRAL_BONUS_DAYS_REFEREE_12_MONTHS",
"int",
"pricing",
"Бонус приглашённому: 12 мес.",
min=0,
subsection="legacy_tariffs",
),
SettingField(
"TRAFFIC_PACKAGES", "string", "pricing", "Пакеты трафика", "Формат: 10:199,50:799 (ГБ:цена)"
),
@@ -283,12 +352,20 @@ SETTINGS_MANIFEST: List[SettingField] = [
subsection="common",
),
# ─── Trial ─────────────────────────────────────────────────────
SettingField("TRIAL_ENABLED", "bool", "pricing", "Триал включён", subsection="trial"),
SettingField(
"TRIAL_ENABLED",
"bool",
"pricing",
"Триал включён",
optional=False,
subsection="trial",
),
SettingField(
"TRIAL_DURATION_DAYS",
"int",
"pricing",
"Длительность триала (дней)",
optional=False,
min=0,
subsection="trial",
),
@@ -297,6 +374,7 @@ SETTINGS_MANIFEST: List[SettingField] = [
"float",
"pricing",
"Лимит трафика триала (ГБ)",
optional=False,
min=0,
subsection="trial",
),
@@ -305,6 +383,7 @@ SETTINGS_MANIFEST: List[SettingField] = [
"string",
"pricing",
"Стратегия сброса трафика триала",
optional=False,
subsection="trial",
),
SettingField(
@@ -323,62 +402,6 @@ SETTINGS_MANIFEST: List[SettingField] = [
"REFERRAL_WELCOME_BONUS_DAYS", "int", "referral", "Приветственный бонус (дней)", min=0
),
SettingField("LEGACY_REFS", "bool", "referral", "Поддержка старых ref-ссылок"),
SettingField(
"REFERRAL_BONUS_DAYS_INVITER_1_MONTH",
"int",
"referral",
"Бонус приглашающему: 1 мес.",
min=0,
),
SettingField(
"REFERRAL_BONUS_DAYS_INVITER_3_MONTHS",
"int",
"referral",
"Бонус приглашающему: 3 мес.",
min=0,
),
SettingField(
"REFERRAL_BONUS_DAYS_INVITER_6_MONTHS",
"int",
"referral",
"Бонус приглашающему: 6 мес.",
min=0,
),
SettingField(
"REFERRAL_BONUS_DAYS_INVITER_12_MONTHS",
"int",
"referral",
"Бонус приглашающему: 12 мес.",
min=0,
),
SettingField(
"REFERRAL_BONUS_DAYS_REFEREE_1_MONTH",
"int",
"referral",
"Бонус приглашённому: 1 мес.",
min=0,
),
SettingField(
"REFERRAL_BONUS_DAYS_REFEREE_3_MONTHS",
"int",
"referral",
"Бонус приглашённому: 3 мес.",
min=0,
),
SettingField(
"REFERRAL_BONUS_DAYS_REFEREE_6_MONTHS",
"int",
"referral",
"Бонус приглашённому: 6 мес.",
min=0,
),
SettingField(
"REFERRAL_BONUS_DAYS_REFEREE_12_MONTHS",
"int",
"referral",
"Бонус приглашённому: 12 мес.",
min=0,
),
# ─── Notifications ─────────────────────────────────────────────
SettingField(
"SUBSCRIPTION_NOTIFICATIONS_ENABLED",
@@ -386,6 +409,13 @@ SETTINGS_MANIFEST: List[SettingField] = [
"notifications",
"Включены уведомления о подписке",
),
SettingField(
"SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED",
"bool",
"notifications",
"Дублировать уведомления о подписке на email",
"Письма отправляются только пользователям с привязанным email и рабочим SMTP.",
),
SettingField(
"SUBSCRIPTION_NOTIFY_ON_EXPIRE", "bool", "notifications", "Уведомлять об истечении"
),
@@ -399,6 +429,14 @@ SETTINGS_MANIFEST: List[SettingField] = [
"За сколько дней предупреждать",
min=0,
),
SettingField(
"SUBSCRIPTION_NOTIFY_HOURS_BEFORE",
"int",
"notifications",
"За сколько часов предупреждать",
min=0,
max=23,
),
SettingField("LOG_NEW_USERS", "bool", "notifications", "Логировать новых пользователей"),
SettingField("LOG_PAYMENTS", "bool", "notifications", "Логировать платежи"),
SettingField("LOG_SUPPORT", "bool", "notifications", "Логировать тикеты поддержки"),
@@ -434,6 +472,55 @@ SETTINGS_MANIFEST: List[SettingField] = [
"ID треда поддержки",
"Тред лог-чата для уведомлений о тикетах поддержки.",
),
SettingField(
"BACKUP_ENABLED",
"bool",
"backups",
"Бэкапы включены",
"Worker будет периодически собирать ZIP-архив и отправлять его в Telegram.",
),
SettingField(
"BACKUP_CHAT_ID",
"int",
"backups",
"ID чата для бэкапов",
"Куда отправлять ZIP-архивы. Если пусто, используется LOG_CHAT_ID.",
),
SettingField(
"BACKUP_THREAD_ID",
"int",
"backups",
"ID треда для бэкапов",
"Необязательный topic/thread ID. Если пусто, используется LOG_THREAD_ID.",
),
SettingField(
"BACKUP_INTERVAL_SECONDS",
"int",
"backups",
"Период бэкапов (сек.)",
"По умолчанию 3600: запуск на границе часа (12:00, 13:00 и т.д.).",
optional=False,
min=60,
),
SettingField(
"BACKUP_LOCAL_RETENTION",
"int",
"backups",
"Сколько архивов хранить",
"Сколько последних ZIP-архивов оставлять в data/backups на сервере.",
optional=False,
min=1,
),
SettingField(
"BACKUP_COMPOSE_ENABLED",
"bool",
"backups",
"Добавлять compose-папку",
(
"Добавляет snapshot /app/compose-source. Если папка не смонтирована, "
"бэкап БД все равно будет создан."
),
),
SettingField(
"SUPPORT_TICKETS_ENABLED",
"bool",
@@ -501,6 +588,17 @@ SETTINGS_MANIFEST: List[SettingField] = [
),
SettingField("USER_TRAFFIC_LIMIT_GB", "float", "devices", "Лимит трафика пользователя (ГБ)"),
SettingField("USER_TRAFFIC_STRATEGY", "string", "devices", "Стратегия сброса трафика"),
# ─── System ────────────────────────────────────────────────────
SettingField(
"TELEMETRY_ENABLED",
"bool",
"system",
"Анонимная статистика установки",
"Раз в сутки отправляет обезличенный сигнал: версия, ОС, локаль и число "
"пользователей в виде диапазона. Без персональных данных, токенов и "
"доменов. Помогает понять число активных установок и какие версии "
"используются. Можно отключить здесь без перезапуска.",
),
]
@@ -561,6 +659,8 @@ def coerce_value(field: SettingField, raw: Any) -> Any:
return text
if raw is None or (isinstance(raw, str) and raw.strip() == ""):
if not field.optional:
raise ValueError(f"{field.key}: value required")
return None
if field.type == "bool":
@@ -623,14 +723,17 @@ def manifest_payload() -> List[dict]:
sections_order = {
"general": 1,
"appearance": 2,
"remnawave": 3,
"pricing": 11,
"payments": 4,
"trial": 5,
"referral": 6,
"notifications": 7,
"support": 8,
"devices": 9,
"backups": 9,
"devices": 10,
"subscription_guides": 10,
"system": 12,
}
exclusive_map = {
key: opposite
@@ -678,12 +781,25 @@ def manifest_payload() -> List[dict]:
"optional": field.optional,
"secret": field.secret,
}
if field.min is not None:
item["min"] = field.min
if field.max is not None:
item["max"] = field.max
if field.key in exclusive_map:
item["mutually_exclusive_key"] = exclusive_map[field.key]
if default_value is not None:
item["default"] = default_value
if webhook_metadata:
item.update(webhook_metadata)
if field.webhook_path:
item["webhook_path"] = field.webhook_path
item["webhook_requires_base_url"] = field.webhook_requires_base_url
if field.webhook_provider_id:
item["provider_id"] = field.webhook_provider_id
if field.webhook_hint_i18n_key:
item["webhook_hint_i18n_key"] = field.webhook_hint_i18n_key
if field.webhook_hint:
item["webhook_hint"] = field.webhook_hint
if field.choices:
item["choices"] = [
{
@@ -16,6 +16,7 @@ from bot.app.web.webapp import (
routes as _routes,
serializers as _serializers,
support as _support,
telegram_notifications as _telegram_notifications,
)
_MODULES = (
@@ -25,6 +26,7 @@ _MODULES = (
_assets,
_auth,
_account,
_telegram_notifications,
_serializers,
_billing,
_devices,
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

@@ -109,6 +109,8 @@
let attempted = false;
let pageLeft = false;
let state = "opening";
let closeAttemptTimer = null;
const CLOSE_ATTEMPT_DELAY_MS = 2500;
function hasControlChars(value) {
return Array.from(String(value || "")).some((char) => {
@@ -167,7 +169,10 @@
function markDone() {
if (state === "done" || isUnsafe) return;
render("done");
window.setTimeout(tryCloseWindow, 120);
if (closeAttemptTimer) window.clearTimeout(closeAttemptTimer);
closeAttemptTimer = window.setTimeout(() => {
if (pageLeft || document.hidden) tryCloseWindow();
}, CLOSE_ATTEMPT_DELAY_MS);
}
function notePageLeft() {
@@ -201,7 +206,6 @@
render("done");
});
window.addEventListener("pagehide", notePageLeft);
window.addEventListener("blur", notePageLeft);
document.addEventListener("visibilitychange", () => {
if (!attempted) return;
if (document.hidden) {
+38
View File
@@ -55,6 +55,11 @@ from bot.utils.text_sanitizer import (
sanitize_username,
)
from config.settings import Settings
from config.tariffs_config import (
default_currency_key_for_settings,
default_payment_currency_code_for_settings,
payment_currency_code,
)
from db.dal import payment_dal, security_dal, subscription_dal, support_dal, user_dal
from db.dal.user_dal import UserMergeConflictError
from db.models import Payment, User, UserTelegramAvatar
@@ -71,6 +76,12 @@ WEBAPP_UPLOADED_LOGO_DIR = WEBAPP_LOGO_CACHE_DIR / "uploads"
WEBAPP_UPLOADED_LOGO_PATH = "/webapp-uploaded-logo"
WEBAPP_FAVICON_DIR = WEBAPP_LOGO_CACHE_DIR / "favicons"
WEBAPP_FAVICON_PATH = "/webapp-favicon"
WEBAPP_DEFAULT_BRAND_DIR = ASSET_DIR / "default-brand"
WEBAPP_DEFAULT_LOGO_FILE = WEBAPP_DEFAULT_BRAND_DIR / "default-logo.webp"
WEBAPP_DEFAULT_LOGO_PATH = "/webapp-default-logo.webp"
WEBAPP_DEFAULT_FAVICON_DIGEST = "19b2a242e5b7bc2d"
WEBAPP_DEFAULT_FAVICON_DIR = WEBAPP_DEFAULT_BRAND_DIR / "favicons" / WEBAPP_DEFAULT_FAVICON_DIGEST
WEBAPP_DEFAULT_FAVICON_URL = f"{WEBAPP_FAVICON_PATH}/{WEBAPP_DEFAULT_FAVICON_DIGEST}/icon-180.png"
WEBAPP_EMOJI_CACHE_DIR = APP_ROOT / "data" / "webapp-emoji"
WEBAPP_CONFIG_PLACEHOLDER = "<!-- WEBAPP_CONFIG_SCRIPT -->"
WEBAPP_I18N_PLACEHOLDER = "<!-- WEBAPP_I18N_SCRIPT -->"
@@ -101,6 +112,33 @@ WEBAPP_CSRF_COOKIE_NAME = "rw_webapp_csrf"
WEBAPP_TELEGRAM_OAUTH_STATE_COOKIE_NAME = "rw_tg_oauth_state"
WEBAPP_CSRF_HEADER_NAME = "X-CSRF-Token"
WEBAPP_STATE_CHANGING_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
ROBOTS_TX = """User-agent: *
Disallow: /
User-agent: GPTBot
Disallow: /
User-agent: ChatGPT-User
Disallow: /
User-agent: OAI-SearchBot
Disallow: /
User-agent: Google-Extended
Disallow: /
User-agent: ClaudeBot
Disallow: /
User-agent: anthropic-ai
Disallow: /
User-agent: PerplexityBot
Disallow: /
User-agent: Applebot-Extended
Disallow: /
"""
_APP_VERSION_CACHE: Optional[str] = None
WEBAPP_CSRF_EXEMPT_PATHS = {
"/api/auth/telegram/nonce",
+4
View File
@@ -8,6 +8,7 @@ from .auth import (
_sync_merged_panel_identity_for_user,
)
from .common import _invalidate_webapp_user_caches
from .telegram_notifications import _probe_telegram_notifications_for_user_id
async def account_email_request_route(request: web.Request) -> web.Response:
@@ -416,6 +417,8 @@ async def account_telegram_link_route(request: web.Request) -> web.Response:
except Exception:
logger.exception("Failed to send account Telegram linked notification")
await _probe_telegram_notifications_for_user_id(request, int(final_user_id))
token = create_webapp_session_token(settings, int(final_user_id))
response_payload: Dict[str, Any] = {
"ok": True,
@@ -482,6 +485,7 @@ async def account_avatar_route(request: web.Request) -> web.Response:
async def account_language_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
settings: Settings = request.app["settings"]
payload = await _read_json(request)
language_payload, validation_error = _validate_model_payload(WebAppLanguagePayload, payload)
if validation_error:
+52 -82
View File
@@ -23,6 +23,12 @@ async def health_route(request: web.Request) -> web.Response:
return web.json_response({"ok": True})
async def robots_txt_route(request: web.Request) -> web.Response:
response = web.Response(text=ROBOTS_TX, content_type="text/plain")
response.headers["Cache-Control"] = "public, max-age=3600"
return response
async def css_asset_route(request: web.Request) -> web.Response:
return await _css_asset_route(request, base_name="subscription_webapp")
@@ -205,7 +211,7 @@ def _resolve_webapp_logo_url(settings: Settings) -> str:
raw_logo_url = (getattr(settings, "WEBAPP_LOGO_URL", None) or "").strip()
if not raw_logo_url:
return ""
return WEBAPP_DEFAULT_LOGO_PATH
parsed_logo_url = urlsplit(raw_logo_url)
if parsed_logo_url.scheme == "https":
@@ -215,7 +221,7 @@ def _resolve_webapp_logo_url(settings: Settings) -> str:
return raw_logo_url
if raw_logo_url.startswith("/"):
return raw_logo_url
return ""
return WEBAPP_DEFAULT_LOGO_PATH
def _resolve_webapp_favicon_url(settings: Settings, logo_url: str = "") -> str:
@@ -227,7 +233,9 @@ def _resolve_webapp_favicon_url(settings: Settings, logo_url: str = "") -> str:
resolved = _resolve_webapp_asset_url(raw_logo_favicon_url)
if resolved:
return resolved
return logo_url or ""
if logo_url and logo_url != WEBAPP_DEFAULT_LOGO_PATH:
return logo_url
return WEBAPP_DEFAULT_FAVICON_URL
def _resolve_webapp_asset_url(raw_url: str) -> str:
@@ -364,6 +372,16 @@ async def webapp_uploaded_logo_route(request: web.Request) -> web.Response:
return _uploaded_webapp_logo_response(filename)
async def webapp_default_logo_route(request: web.Request) -> web.Response:
settings: Settings = request.app["settings"]
if not settings.WEBAPP_ENABLED:
raise web.HTTPNotFound(text="webapp_disabled")
response = _webapp_default_brand_file_response(WEBAPP_DEFAULT_LOGO_FILE, "image/webp")
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
return response
async def webapp_favicon_route(request: web.Request) -> web.Response:
settings: Settings = request.app["settings"]
if not settings.WEBAPP_ENABLED:
@@ -451,6 +469,9 @@ def _webapp_favicon_file_response(digest: str, filename: str) -> web.Response:
):
raise web.HTTPNotFound(text="webapp_favicon_not_found")
if digest == WEBAPP_DEFAULT_FAVICON_DIGEST:
return _webapp_default_favicon_file_response(filename)
root = WEBAPP_FAVICON_DIR.expanduser().resolve()
path = (root / digest / filename).resolve()
try:
@@ -477,6 +498,29 @@ def _webapp_favicon_file_response(digest: str, filename: str) -> web.Response:
return response
def _webapp_default_favicon_file_response(filename: str) -> web.Response:
path = WEBAPP_DEFAULT_FAVICON_DIR / filename
content_type = WEBAPP_THEME_ASSET_CONTENT_TYPES.get(path.suffix.lower())
if not content_type:
raise web.HTTPNotFound(text="webapp_favicon_not_found")
response = _webapp_default_brand_file_response(path, content_type)
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
return response
def _webapp_default_brand_file_response(path: Path, content_type: str) -> web.Response:
try:
body = _read_template_binary_cached(path)
except OSError:
raise web.HTTPNotFound(text="webapp_default_brand_not_found") from None
if not body or len(body) > WEBAPP_LOGO_MAX_BYTES:
raise web.HTTPNotFound(text="webapp_default_brand_not_found")
return web.Response(body=body, content_type=content_type)
async def webapp_animated_emoji_route(request: web.Request) -> web.Response:
codepoints = str(request.match_info.get("codepoints") or "").strip().lower()
ext = str(request.match_info.get("ext") or "").strip().lower()
@@ -831,6 +875,7 @@ async def _security_headers_middleware(request: web.Request, handler):
)
response.headers.setdefault("Referrer-Policy", "no-referrer")
response.headers.setdefault("X-Content-Type-Options", "nosniff")
response.headers.setdefault("X-Robots-Tag", "noindex, nofollow, noarchive")
response.headers.setdefault(
"Permissions-Policy",
(
@@ -889,87 +934,12 @@ def _get_cached_webapp_settings(request: web.Request) -> Dict[str, Any]:
return cache["data"]
def _run_git_command(*args: str) -> str:
repo_root = APP_ROOT
try:
result = subprocess.run(
["git", *args],
cwd=repo_root,
check=True,
capture_output=True,
text=True,
timeout=1.5,
)
except (OSError, subprocess.SubprocessError):
return ""
return result.stdout.strip()
def _normalize_version_branch(raw_branch: str) -> str:
branch = str(raw_branch or "").strip()
for prefix in ("refs/heads/", "refs/remotes/origin/", "origin/"):
if branch.startswith(prefix):
branch = branch[len(prefix) :]
break
if branch == "HEAD":
return ""
return re.sub(r"[^A-Za-z0-9._-]+", "-", branch).strip("-")[:48]
def _resolve_version_branch() -> str:
for env_name in (
"REMNAWAVE_MINISHOP_BRANCH",
"GIT_BRANCH",
"BRANCH_NAME",
"GITHUB_REF_NAME",
"CI_COMMIT_REF_NAME",
):
branch = _normalize_version_branch(os.getenv(env_name, ""))
if branch:
return branch
return _normalize_version_branch(
_run_git_command("branch", "--show-current")
or _run_git_command("symbolic-ref", "--quiet", "--short", "HEAD")
)
def _format_app_version(tag: str, sha: str, branch: str) -> str:
branch_suffix = "" if not branch or branch == "main" else f"-{branch}"
if tag and sha:
return f"{tag}{branch_suffix}+g{sha}"
if sha:
return f"dev{branch_suffix}+g{sha}"
if tag:
return f"{tag}{branch_suffix}"
return f"dev{branch_suffix}+unknown"
def _resolve_app_version() -> str:
global _APP_VERSION_CACHE
if _APP_VERSION_CACHE:
return _APP_VERSION_CACHE
# Single source of truth shared with the telemetry worker so the admin
# sidebar and the install beacon always report the same version.
from bot.utils.app_version import resolve_app_version
env_version = os.getenv("REMNAWAVE_MINISHOP_VERSION", "").strip()
if env_version:
_APP_VERSION_CACHE = env_version
return env_version
build_version_path = APP_ROOT / ".build-version"
try:
build_version = build_version_path.read_text(encoding="utf-8").strip()
except OSError:
build_version = ""
if build_version:
_APP_VERSION_CACHE = build_version
return build_version
tag = _run_git_command("describe", "--tags", "--abbrev=0")
sha = _run_git_command("rev-parse", "--short", "HEAD")
branch = _resolve_version_branch()
version = _format_app_version(tag, sha, branch)
_APP_VERSION_CACHE = version
return version
return resolve_app_version()
async def _enforce_webapp_rate_limit(
+17 -8
View File
@@ -1,6 +1,7 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
from .common import _invalidate_webapp_user_caches
from .telegram_notifications import _probe_telegram_notifications_for_user_id
def _resolve_telegram_bot_id(bot_token: str) -> Optional[int]:
@@ -436,6 +437,9 @@ async def telegram_oauth_callback_route(request: web.Request) -> web.Response:
first_name=linked_user_for_panel.first_name,
)
if final_user_id:
await _probe_telegram_notifications_for_user_id(request, int(final_user_id))
token = create_webapp_session_token(settings, int(final_user_id))
response = web.HTTPFound(_telegram_oauth_redirect_url(redirect_path, status="success"))
_clear_telegram_oauth_state_cookie(response)
@@ -531,6 +535,7 @@ async def auth_token_route(request: web.Request) -> web.Response:
return _json_error(500, "auth_failed", "Auth failed")
await _invalidate_webapp_user_caches(settings, authenticated_user_id, include_devices=True)
await _probe_telegram_notifications_for_user_id(request, int(authenticated_user_id))
token = create_webapp_session_token(settings, int(authenticated_user_id))
return _build_webapp_auth_response(settings, {"ok": True}, token=token)
@@ -1203,7 +1208,7 @@ def _apply_telegram_profile_to_user(
settings: Settings,
) -> None:
language_code = _normalize_language(
telegram_user.get("language_code") or user.language_code or settings.DEFAULT_LANGUAGE
user.language_code or telegram_user.get("language_code") or settings.DEFAULT_LANGUAGE
)
user.telegram_id = int(telegram_user["id"])
@@ -1251,8 +1256,8 @@ async def _link_telegram_to_user(
if not existing_telegram_user and int(current_user.user_id) < 0:
language_code = _normalize_language(
telegram_user.get("language_code")
or current_user.language_code
current_user.language_code
or telegram_user.get("language_code")
or settings.DEFAULT_LANGUAGE
)
target_user, _ = await user_dal.create_user(
@@ -1400,20 +1405,19 @@ async def _ensure_user_from_telegram(
referral_param: Optional[str] = None,
) -> User:
user_id = int(telegram_user["id"])
language_code = _normalize_language(
telegram_language_code = _normalize_language(
telegram_user.get("language_code") or settings.DEFAULT_LANGUAGE
)
update_data = {
profile_data = {
"telegram_id": user_id,
"username": sanitize_username(telegram_user.get("username")),
"first_name": sanitize_display_name(telegram_user.get("first_name")),
"last_name": sanitize_display_name(telegram_user.get("last_name")),
"language_code": language_code,
}
telegram_photo_url = _telegram_photo_url_value(telegram_user)
if telegram_photo_url:
update_data["telegram_photo_url"] = telegram_photo_url
profile_data["telegram_photo_url"] = telegram_photo_url
db_user = await user_dal.get_user_by_telegram_id(session, user_id)
if not db_user:
@@ -1428,7 +1432,8 @@ async def _ensure_user_from_telegram(
session,
{
"user_id": user_id,
**update_data,
**profile_data,
"language_code": telegram_language_code,
"referred_by_id": referred_by_id,
"registration_date": datetime.now(timezone.utc),
},
@@ -1436,6 +1441,10 @@ async def _ensure_user_from_telegram(
setattr(db_user, "_webapp_created", bool(created))
return db_user
update_data = {
**profile_data,
"language_code": _normalize_language(db_user.language_code or telegram_language_code),
}
changed = {key: value for key, value in update_data.items() if getattr(db_user, key) != value}
if changed:
db_user = await user_dal.update_user(session, db_user.user_id, changed) or db_user
+105 -50
View File
@@ -4,6 +4,42 @@ from ._runtime import * # noqa: F403,F405
from bot.app.web.webapp.cache_helpers import invalidate_webapp_user_caches
def _billing_iso_datetime(value: Optional[Any]) -> Optional[str]:
if not value:
return None
if isinstance(value, datetime):
normalized = value if value.tzinfo else value.replace(tzinfo=timezone.utc)
return normalized.isoformat()
return str(value)
def _billing_datetime_text(value: Optional[Any]) -> Optional[str]:
if not value:
return None
if isinstance(value, datetime):
normalized = value if value.tzinfo else value.replace(tzinfo=timezone.utc)
return normalized.strftime("%d.%m.%Y %H:%M")
text = str(value)
try:
normalized = datetime.fromisoformat(text.replace("Z", "+00:00"))
return normalized.strftime("%d.%m.%Y %H:%M")
except Exception:
return text
def _parse_positive_int_units(value: Any) -> Optional[int]:
if isinstance(value, bool):
return None
try:
number = float(value)
except (TypeError, ValueError):
return None
if not number.is_integer():
return None
integer = int(number)
return integer if integer > 0 else None
async def apply_promo_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
payload = await _read_json(request)
@@ -64,8 +100,11 @@ async def create_payment_route(request: web.Request) -> web.Response:
return validation_error
method = str(payment_payload.method or "").strip().lower()
settings: Settings = request.app["settings"]
subscription_service: SubscriptionService = request.app["subscription_service"]
cached = _get_cached_webapp_settings(request)
tariffs_config = settings.tariffs_config
default_currency = default_currency_key_for_settings(settings)
default_currency_code = payment_currency_code(default_currency)
traffic_mode = bool(settings.traffic_sale_mode)
sale_mode = "subscription"
traffic_gb_for_payment: Optional[float] = None
@@ -86,15 +125,12 @@ async def create_payment_route(request: web.Request) -> web.Response:
return _json_error(400, "invalid_plan", "Tariff is not available")
if tariff.billing_model != "period":
return _json_error(400, "invalid_plan", "Device top-up is not available")
try:
device_count = int(
float(
payment_payload.device_count
if payment_payload.device_count is not None
else payment_payload.months
)
)
except (TypeError, ValueError):
device_count = _parse_positive_int_units(
payment_payload.device_count
if payment_payload.device_count is not None
else payment_payload.months
)
if device_count is None:
return _json_error(400, "invalid_plan", "Invalid device package")
if not tariff.hwid_device_packages:
return _json_error(400, "invalid_plan", "Device package is not available")
@@ -121,17 +157,17 @@ async def create_payment_route(request: web.Request) -> web.Response:
if requested_sale_mode == "premium_topup"
else tariffs_config.topup_packages_for(tariff)
)
rub_packages = {
currency_packages = {
float(package.gb): float(package.price)
for package in (packages.rub if packages else [])
for package in (packages.for_currency(default_currency) if packages else [])
}
stars_packages = {
float(package.gb): int(float(package.price))
for package in (packages.stars if packages else [])
}
package_key = _resolve_numeric_option_key(rub_packages, traffic_gb)
package_key = _resolve_numeric_option_key(currency_packages, traffic_gb)
stars_package_key = _resolve_numeric_option_key(stars_packages, traffic_gb)
price = rub_packages.get(package_key) if package_key is not None else None
price = currency_packages.get(package_key) if package_key is not None else None
stars_price = (
stars_packages.get(stars_package_key) if stars_package_key is not None else None
)
@@ -162,17 +198,21 @@ async def create_payment_route(request: web.Request) -> web.Response:
return _json_error(400, "invalid_plan", "Invalid traffic package")
if traffic_gb <= 0:
return _json_error(400, "invalid_plan", "Invalid traffic package")
rub_packages = {
currency_packages = {
float(package.gb): float(package.price)
for package in (tariff.traffic_packages.rub if tariff.traffic_packages else [])
for package in (
tariff.traffic_packages.for_currency(default_currency)
if tariff.traffic_packages
else []
)
}
stars_packages = {
float(package.gb): int(float(package.price))
for package in (tariff.traffic_packages.stars if tariff.traffic_packages else [])
}
package_key = _resolve_numeric_option_key(rub_packages, traffic_gb)
package_key = _resolve_numeric_option_key(currency_packages, traffic_gb)
stars_package_key = _resolve_numeric_option_key(stars_packages, traffic_gb)
price = rub_packages.get(package_key) if package_key is not None else None
price = currency_packages.get(package_key) if package_key is not None else None
stars_price = (
stars_packages.get(stars_package_key) if stars_package_key is not None else None
)
@@ -190,7 +230,7 @@ async def create_payment_route(request: web.Request) -> web.Response:
return _json_error(400, "invalid_plan", "Invalid subscription period")
if months not in tariff.enabled_periods:
return _json_error(400, "invalid_plan", "Subscription period is not available")
price = tariff.period_price(months, "rub")
price = tariff.period_price(months, default_currency)
stars_price_raw = tariff.period_price(months, "stars")
stars_price = int(stars_price_raw) if stars_price_raw and stars_price_raw > 0 else None
if price is None and method != "stars":
@@ -262,7 +302,7 @@ async def create_payment_route(request: web.Request) -> web.Response:
active_tariff = None
if not active_tariff or active_tariff.billing_model != "period":
return _json_error(400, "invalid_plan", "Device top-up is not available")
currency = "stars" if method == "stars" else "rub"
currency = "stars" if method == "stars" else default_currency
hwid_quote = await subscription_service.quote_hwid_device_topup(
session,
user_id=user_id,
@@ -291,6 +331,7 @@ async def create_payment_route(request: web.Request) -> web.Response:
months=payment_units,
price=float(price or 0),
stars_price=stars_price,
currency=default_currency_code,
lang=lang,
sale_mode=sale_mode,
traffic_gb=traffic_gb_for_payment,
@@ -549,6 +590,7 @@ async def tariff_change_payment_route(request: web.Request) -> web.Response:
tariff_key = str(payment_payload.tariff_key or "").strip()
settings: Settings = request.app["settings"]
config = settings.tariffs_config
default_currency_code = default_payment_currency_code_for_settings(settings)
if not config:
return _json_error(404, "tariffs_unavailable", "Tariffs are not configured")
if not tariff_key:
@@ -584,6 +626,7 @@ async def tariff_change_payment_route(request: web.Request) -> web.Response:
months=1,
price=price,
stars_price=None,
currency=default_currency_code,
lang=db_user.language_code or settings.DEFAULT_LANGUAGE,
sale_mode=f"tariff_upgrade@{target.key}",
)
@@ -613,27 +656,35 @@ async def device_topup_options_route(request: web.Request) -> web.Response:
)
tariff = config.require(sub.tariff_key)
if tariff.billing_model != "period":
return _json_error(
400, "device_topup_unavailable", "Device top-up is not available"
)
return _json_error(400, "device_topup_unavailable", "Device top-up is not available")
lang = db_user.language_code or settings.DEFAULT_LANGUAGE
active = await subscription_service.get_active_subscription_details(session, user_id)
renewal_available = bool(active and active.get("device_topup_renewal_available"))
extra_hwid_valid_until = active.get("extra_hwid_devices_valid_until") if active else None
extra_hwid_valid_until_text = (
active.get("extra_hwid_devices_valid_until_text") if active else None
) or _billing_datetime_text(extra_hwid_valid_until)
packages = tariff.hwid_device_packages
rub_counts = {int(package.count) for package in (packages.rub if packages else [])}
default_currency = default_currency_key_for_settings(settings)
default_currency_code = payment_currency_code(default_currency)
if packages and hasattr(packages, "for_currency"):
default_packages = packages.for_currency(default_currency)
else:
default_packages = getattr(packages, default_currency, []) if packages else []
currency_counts = {int(package.count) for package in default_packages}
stars_counts = {int(package.count) for package in (packages.stars if packages else [])}
plans = []
for count in sorted(rub_counts | stars_counts):
rub_quote = (
for count in sorted(currency_counts | stars_counts):
currency_quote = (
await subscription_service.quote_hwid_device_topup(
session,
user_id=user_id,
device_count=count,
tariff_key=tariff.key,
renewal=renewal_available,
currency="rub",
currency=default_currency,
)
if count in rub_counts
if count in currency_counts
else None
)
stars_quote = (
@@ -648,7 +699,7 @@ async def device_topup_options_route(request: web.Request) -> web.Response:
if count in stars_counts
else None
)
if not rub_quote and not stars_quote:
if not currency_quote and not stars_quote:
continue
sale_mode_for_plan = "hwid_devices_renewal" if renewal_available else "hwid_devices"
plan = {
@@ -659,21 +710,19 @@ async def device_topup_options_route(request: web.Request) -> web.Response:
"sale_mode": sale_mode_for_plan,
"months": count,
"device_count": count,
"price": float(rub_quote.get("price") if rub_quote else 0),
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
"price": float(currency_quote.get("price") if currency_quote else 0),
"currency": default_currency_code,
"title": f"+{count}",
"subtitle": tariff.name(lang),
"valid_from": (
(rub_quote or stars_quote)["valid_from"].isoformat()
if (rub_quote or stars_quote).get("valid_from")
else None
"valid_from": _billing_iso_datetime(
(currency_quote or stars_quote).get("valid_from")
),
"valid_until": (
(rub_quote or stars_quote)["valid_until"].isoformat()
if (rub_quote or stars_quote).get("valid_until")
else None
"valid_until": _billing_iso_datetime(
(currency_quote or stars_quote).get("valid_until")
),
"proration_ratio": float(
(currency_quote or stars_quote).get("proration_ratio") or 0
),
"proration_ratio": float((rub_quote or stars_quote).get("proration_ratio") or 0),
}
if stars_quote and int(stars_quote.get("price") or 0) > 0:
plan["stars_price"] = int(stars_quote["price"])
@@ -687,14 +736,8 @@ async def device_topup_options_route(request: web.Request) -> web.Response:
"extra_hwid_devices": int(active.get("extra_hwid_devices") or 0)
if active
else int(sub.extra_hwid_devices or 0),
"extra_hwid_devices_valid_until": active.get("extra_hwid_devices_valid_until")
if active
else None,
"extra_hwid_devices_valid_until_text": active.get(
"extra_hwid_devices_valid_until_text"
)
if active
else None,
"extra_hwid_devices_valid_until": _billing_iso_datetime(extra_hwid_valid_until),
"extra_hwid_devices_valid_until_text": extra_hwid_valid_until_text,
"renewal_available": renewal_available,
"renewal_recommended_count": int(active.get("extra_hwid_devices") or 0)
if active and renewal_available
@@ -907,12 +950,14 @@ async def _create_subscription_payment(
price: float,
stars_price: Optional[int],
lang: str,
currency: Optional[str] = None,
sale_mode: str = "subscription",
traffic_gb: Optional[float] = None,
is_admin: bool = False,
hwid_quote: Optional[Dict[str, Any]] = None,
) -> web.Response:
settings: Settings = request.app["settings"]
payment_currency = (currency or default_payment_currency_code_for_settings(settings)).upper()
sale_mode = str(sale_mode or "subscription")
traffic_sale = _sale_mode_is_traffic(sale_mode)
hwid_devices_sale = _sale_mode_is_hwid_devices(sale_mode)
@@ -936,6 +981,17 @@ async def _create_subscription_payment(
provider_spec.is_service_configured(request.app),
)
return _json_error(400, "payment_unavailable", "Payment method unavailable")
if not provider_spec.is_usable_for_payment_currency(settings, payment_currency):
logger.warning(
"WebApp payment method does not support currency: method=%s currency=%s",
method,
payment_currency,
)
return _json_error(
400,
"unsupported_currency",
"Payment method does not support this currency",
)
return await provider_spec.create_webapp_payment(
WebAppPaymentContext(
request=request,
@@ -945,6 +1001,7 @@ async def _create_subscription_payment(
months=months,
price=price,
stars_price=stars_price,
currency=payment_currency,
description=description,
sale_mode=sale_mode,
traffic_gb=traffic_gb,
@@ -953,9 +1010,7 @@ async def _create_subscription_payment(
hwid_pricing_period_months=hwid_quote.get("pricing_period_months")
if hwid_quote
else None,
hwid_proration_ratio=hwid_quote.get("proration_ratio")
if hwid_quote
else None,
hwid_proration_ratio=hwid_quote.get("proration_ratio") if hwid_quote else None,
hwid_full_price=hwid_quote.get("full_price") if hwid_quote else None,
)
)
+46 -9
View File
@@ -22,7 +22,13 @@ async def devices_route(request: web.Request) -> web.Response:
"devices",
user_id,
int(getattr(settings, "WEBAPP_DEVICES_CACHE_TTL_SECONDS", 5) or 0),
lambda: _load_devices_payload(subscription_service, session, user_id),
lambda: _load_devices_payload(
subscription_service,
session,
user_id,
fallback_panel_user_uuid=str(getattr(db_user, "panel_user_uuid", "") or "").strip()
or None,
),
)
if isinstance(result, dict) and result.get("ok") is True:
return web.json_response({"ok": True, **(result.get("payload") or {})})
@@ -45,16 +51,12 @@ async def _load_devices_payload(
subscription_service: SubscriptionService,
session: AsyncSession,
user_id: int,
fallback_panel_user_uuid: Optional[str] = None,
) -> Dict[str, Any]:
active = await subscription_service.get_active_subscription_details(session, user_id)
panel_user_uuid = active.get("user_id") if active else None
panel_user_uuid = str((active or {}).get("user_id") or fallback_panel_user_uuid or "").strip()
if not panel_user_uuid:
return {
"ok": False,
"status": 400,
"error": "subscription_not_active",
"message": "Subscription is not active",
}
return _empty_inactive_devices_payload()
panel_service = getattr(subscription_service, "panel_service", None)
if not panel_service:
@@ -82,6 +84,7 @@ async def _load_devices_payload(
"ok": True,
"payload": {
"enabled": True,
"subscription_active": _devices_subscription_is_active(active),
"current_devices": len(devices),
"max_devices": max_devices,
"max_devices_label": _format_devices_limit(max_devices),
@@ -92,6 +95,31 @@ async def _load_devices_payload(
}
def _empty_inactive_devices_payload() -> Dict[str, Any]:
return {
"ok": True,
"payload": {
"enabled": True,
"subscription_active": False,
"current_devices": 0,
"max_devices": None,
"max_devices_label": _format_devices_limit(None),
"devices": [],
},
}
def _devices_subscription_is_active(active: Optional[Dict[str, Any]]) -> bool:
if not active:
return False
end_date = active.get("end_date")
if not isinstance(end_date, datetime):
return False
if end_date.tzinfo is None:
end_date = end_date.replace(tzinfo=timezone.utc)
return end_date > datetime.now(timezone.utc)
async def disconnect_device_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
rate_limit_response = await _enforce_webapp_rate_limit(
@@ -193,6 +221,15 @@ def _format_device_datetime(value: Any) -> str:
return text
def _serialize_device_datetime(value: Any) -> Optional[str]:
if not value:
return None
if isinstance(value, datetime):
normalized = value if value.tzinfo else value.replace(tzinfo=timezone.utc)
return normalized.isoformat()
return str(value)
def _serialize_device(device: Dict[str, Any], index: int) -> Dict[str, Any]:
hwid = str(device.get("hwid") or "").strip()
model = str(device.get("deviceModel") or "").strip()
@@ -208,7 +245,7 @@ def _serialize_device(device: Dict[str, Any], index: int) -> Dict[str, Any]:
"os_version": os_version,
"platform_label": platform_label,
"user_agent": user_agent,
"created_at": device.get("createdAt"),
"created_at": _serialize_device_datetime(device.get("createdAt")),
"created_at_text": _format_device_datetime(device.get("createdAt")),
"hwid_short": _shorten_hwid_for_display(hwid),
"token": _device_hwid_token(hwid) if hwid else "",
+7 -1
View File
@@ -3,6 +3,7 @@ from ._runtime import * # noqa: F403,F405
def setup_subscription_webapp_routes(app: web.Application) -> None:
app.router.add_get("/robots.txt", robots_txt_route)
app.router.add_get("/", index_route)
app.router.add_get("/login/password", index_route)
app.router.add_get("/home", index_route)
@@ -19,7 +20,7 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
app.router.add_get(
(
"/admin/{section:stats|users|payments|promos|ads|broadcast|logs|tariffs|"
"appearance|settings|translations|support}"
"appearance|settings|translations|support|backups}"
),
index_route,
)
@@ -35,6 +36,7 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
app.router.add_get("/apple-touch-icon-precomposed.png", webapp_current_favicon_route)
app.router.add_get("/icon-192.png", webapp_current_favicon_route)
app.router.add_get("/icon-512.png", webapp_current_favicon_route)
app.router.add_get(WEBAPP_DEFAULT_LOGO_PATH, webapp_default_logo_route)
app.router.add_get(WEBAPP_LOGO_PROXY_PATH, webapp_logo_route)
app.router.add_get(
rf"{WEBAPP_UPLOADED_LOGO_PATH}/{{filename:[A-Za-z0-9_.-]+}}",
@@ -83,6 +85,10 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
app.router.add_post("/api/account/password/request", account_password_request_route)
app.router.add_post("/api/account/password/confirm", account_password_confirm_route)
app.router.add_post("/api/account/telegram/link", account_telegram_link_route)
app.router.add_post(
"/api/account/telegram/notifications/probe",
account_telegram_notifications_probe_route,
)
app.router.add_post("/api/promo/apply", apply_promo_route)
app.router.add_post("/api/trial/activate", activate_trial_route)
app.router.add_get("/api/devices", devices_route)
+141 -36
View File
@@ -3,6 +3,12 @@ from ._runtime import * # noqa: F403,F405
from config.subscription_guides_config import subscription_guides_available
from config.webapp_themes_config import public_themes_catalog_payload
from bot.services.telegram_notifications import (
TELEGRAM_NOTIFICATIONS_ENABLED,
normalize_telegram_notification_status,
telegram_notifications_need_prompt,
telegram_notifications_start_link,
)
async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, Any]:
@@ -61,7 +67,7 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
trial_available = bool(
settings.TRIAL_ENABLED
and settings.TRIAL_DURATION_DAYS > 0
and not await subscription_service.has_had_any_subscription(session, user_id)
and not await subscription_service.has_trial_blocking_subscription(session, user_id)
)
avatar = await _ensure_cached_telegram_avatar(request, session, db_user)
try:
@@ -72,6 +78,12 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
lang = _normalize_language(db_user.language_code or settings.DEFAULT_LANGUAGE)
admin_ids = {int(x) for x in (settings.ADMIN_IDS or [])}
is_admin = bool(db_user.telegram_id and int(db_user.telegram_id) in admin_ids)
telegram_notifications_status = normalize_telegram_notification_status(
getattr(db_user, "telegram_notifications_status", None)
)
telegram_notifications_link = telegram_notifications_start_link(
request.app.get("bot_username") or ""
)
return {
"user": {
"id": user_id,
@@ -83,6 +95,12 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
),
"telegram_id": db_user.telegram_id,
"telegram_linked": bool(_telegram_id_for_user(db_user)),
"telegram_notifications_status": telegram_notifications_status,
"telegram_notifications_enabled": (
telegram_notifications_status == TELEGRAM_NOTIFICATIONS_ENABLED
),
"telegram_notifications_need_prompt": telegram_notifications_need_prompt(db_user),
"telegram_notifications_start_link": telegram_notifications_link,
"telegram_photo_url": _telegram_avatar_url(avatar),
"first_name": db_user.first_name,
"language_code": lang,
@@ -156,12 +174,79 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
}
def _serialize_referral_bonus_details(settings: Settings, lang: str) -> List[Dict[str, Any]]:
def _legacy_referral_bonus_periods(settings: Settings) -> List[int]:
if getattr(settings, "traffic_sale_mode", False):
return []
return sorted(int(months) for months in settings.subscription_options)
def _serialize_tariff_period_referral_bonus_details(tariff: Any, lang: str) -> List[Dict[str, Any]]:
details: List[Dict[str, Any]] = []
for months, _price in sorted(settings.subscription_options.items()):
for months in sorted(int(month) for month in tariff.enabled_periods):
inviter_days = tariff.referral_inviter_bonus_days(months)
friend_days = tariff.referral_referee_bonus_days(months)
if inviter_days is None and friend_days is None:
continue
details.append(
{
"id": f"{tariff.key}:{months}",
"tariff_key": tariff.key,
"tariff_name": tariff.name(lang),
"months": int(months),
"title": _format_months_title(int(months), lang),
"inviter_days": int(inviter_days or 0),
"friend_days": int(friend_days or 0),
}
)
return details
def _serialize_tariff_referral_bonus_details(settings: Settings, lang: str) -> List[Dict[str, Any]]:
tariffs_config = settings.tariffs_config
if not tariffs_config:
return []
period_tariffs = [
tariff for tariff in tariffs_config.enabled_tariffs if tariff.billing_model == "period"
]
if len(period_tariffs) <= 1:
return (
_serialize_tariff_period_referral_bonus_details(period_tariffs[0], lang)
if period_tariffs
else []
)
summaries: List[Dict[str, Any]] = []
for tariff in period_tariffs:
details = _serialize_tariff_period_referral_bonus_details(tariff, lang)
if not details:
continue
inviter_values = [int(item["inviter_days"]) for item in details]
friend_values = [int(item["friend_days"]) for item in details]
summaries.append(
{
"id": f"tariff:{tariff.key}",
"type": "tariff_summary",
"tariff_key": tariff.key,
"tariff_name": tariff.name(lang),
"title": tariff.name(lang),
"inviter_min_days": min(inviter_values),
"inviter_max_days": max(inviter_values),
"friend_min_days": min(friend_values),
"friend_max_days": max(friend_values),
"details": details,
}
)
return summaries
def _serialize_referral_bonus_details(settings: Settings, lang: str) -> List[Dict[str, Any]]:
if settings.tariffs_config:
return _serialize_tariff_referral_bonus_details(settings, lang)
details: List[Dict[str, Any]] = []
for months in _legacy_referral_bonus_periods(settings):
inviter_days = settings.referral_bonus_inviter.get(months)
friend_days = settings.referral_bonus_referee.get(months)
if inviter_days is None and friend_days is None:
@@ -387,15 +472,18 @@ def _serialize_plans(
) -> List[Dict[str, Any]]:
tariffs_config = settings.tariffs_config
if tariffs_config:
default_currency = default_currency_key_for_settings(settings)
default_currency_code = payment_currency_code(default_currency)
plans: List[Dict[str, Any]] = []
for tariff in tariffs_config.enabled_tariffs:
common = {
"tariff_key": tariff.key,
"is_default_tariff": tariff.key == tariffs_config.default_tariff,
"tariff_name": tariff.name(lang),
"billing_model": tariff.billing_model,
"description": tariff.description(lang),
"squad_uuids": tariff.squad_uuids,
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
"currency": default_currency_code,
"hwid_device_limit": tariff.hwid_device_limit,
"hwid_device_packages": _serialize_hwid_device_packages(
settings,
@@ -408,7 +496,7 @@ def _serialize_plans(
}
if tariff.billing_model == "period":
for months in sorted(tariff.enabled_periods):
price = tariff.period_price(int(months), "rub")
price = tariff.period_price(int(months), default_currency)
stars_price = tariff.period_price(int(months), "stars")
if price is None and (stars_price is None or int(stars_price) <= 0):
continue
@@ -426,9 +514,13 @@ def _serialize_plans(
plan["stars_price"] = int(stars_price)
plans.append(plan)
else:
rub_packages = {
currency_packages = {
float(package.gb): float(package.price)
for package in (tariff.traffic_packages.rub if tariff.traffic_packages else [])
for package in (
tariff.traffic_packages.for_currency(default_currency)
if tariff.traffic_packages
else []
)
}
stars_packages = {
float(package.gb): int(float(package.price))
@@ -436,8 +528,8 @@ def _serialize_plans(
tariff.traffic_packages.stars if tariff.traffic_packages else []
)
}
for traffic_gb in sorted(set(rub_packages) | set(stars_packages)):
price = rub_packages.get(traffic_gb)
for traffic_gb in sorted(set(currency_packages) | set(stars_packages)):
price = currency_packages.get(traffic_gb)
stars_price = stars_packages.get(traffic_gb)
if price is None and (stars_price is None or int(stars_price) <= 0):
continue
@@ -523,16 +615,19 @@ def _serialize_topup_packages(
sale_mode: str = "topup",
title_prefix: str = "",
) -> List[Dict[str, Any]]:
rub_packages = {
float(package.gb): float(package.price) for package in (packages.rub if packages else [])
default_currency = default_currency_key_for_settings(settings)
default_currency_code = payment_currency_code(default_currency)
currency_packages = {
float(package.gb): float(package.price)
for package in (packages.for_currency(default_currency) if packages else [])
}
stars_packages = {
float(package.gb): int(float(package.price))
for package in (packages.stars if packages else [])
}
plans: List[Dict[str, Any]] = []
for traffic_gb in sorted(set(rub_packages) | set(stars_packages)):
price = rub_packages.get(traffic_gb)
for traffic_gb in sorted(set(currency_packages) | set(stars_packages)):
price = currency_packages.get(traffic_gb)
stars_price = stars_packages.get(traffic_gb)
if price is None and (stars_price is None or int(stars_price) <= 0):
continue
@@ -546,7 +641,7 @@ def _serialize_topup_packages(
"months": int(traffic_value) if traffic_value.is_integer() else traffic_value,
"traffic_gb": traffic_value,
"price": float(price or 0),
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
"currency": default_currency_code,
"title": f"{title_prefix}{_format_traffic_title(traffic_value, lang)}",
"subtitle": tariff.premium_name(lang)
if sale_mode == "premium_topup"
@@ -564,16 +659,19 @@ def _serialize_hwid_device_packages(
packages: Optional[Any],
lang: str,
) -> List[Dict[str, Any]]:
rub_packages = {
int(package.count): float(package.price) for package in (packages.rub if packages else [])
default_currency = default_currency_key_for_settings(settings)
default_currency_code = payment_currency_code(default_currency)
currency_packages = {
int(package.count): float(package.price)
for package in (packages.for_currency(default_currency) if packages else [])
}
stars_packages = {
int(package.count): int(float(package.price))
for package in (packages.stars if packages else [])
}
plans: List[Dict[str, Any]] = []
for count in sorted(set(rub_packages) | set(stars_packages)):
price = rub_packages.get(count)
for count in sorted(set(currency_packages) | set(stars_packages)):
price = currency_packages.get(count)
stars_price = stars_packages.get(count)
if price is None and (stars_price is None or int(stars_price) <= 0):
continue
@@ -586,7 +684,7 @@ def _serialize_hwid_device_packages(
"months": int(count),
"device_count": int(count),
"price": float(price or 0),
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
"currency": default_currency_code,
"title": f"+{count}",
"subtitle": tariff.name(lang),
}
@@ -603,6 +701,8 @@ def _serialize_tariff_change_target(
options: Dict[str, Any],
lang: str,
) -> Dict[str, Any]:
default_currency = default_currency_key_for_settings(settings)
default_currency_code = payment_currency_code(default_currency)
actions: List[Dict[str, Any]] = []
mode = str(options.get("mode") or "")
if mode == "period_to_period":
@@ -611,14 +711,12 @@ def _serialize_tariff_change_target(
"mode": "recalc_days",
"kind": "free",
"title": "recalc_days",
"days_after": int(options.get("recalc_days") or 0),
"remaining_days": int(options.get("remaining_days") or 0),
"converted_hwid_value_rub": float(
options.get("converted_hwid_value_rub") or 0
),
"converted_hwid_days": int(options.get("converted_hwid_days") or 0),
}
)
"days_after": int(options.get("recalc_days") or 0),
"remaining_days": int(options.get("remaining_days") or 0),
"converted_hwid_value_rub": float(options.get("converted_hwid_value_rub") or 0),
"converted_hwid_days": int(options.get("converted_hwid_days") or 0),
}
)
paid_diff = float(options.get("paid_diff_rub") or 0)
if paid_diff > 0:
actions.append(
@@ -627,7 +725,7 @@ def _serialize_tariff_change_target(
"kind": "payment",
"title": "paid_diff",
"price": paid_diff,
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
"currency": default_currency_code,
}
)
elif mode == "period_to_traffic":
@@ -638,9 +736,7 @@ def _serialize_tariff_change_target(
"title": "convert_days_to_gb",
"converted_gb": float(options.get("converted_gb") or 0),
"remaining_days": int(options.get("remaining_days") or 0),
"converted_hwid_value_rub": float(
options.get("converted_hwid_value_rub") or 0
),
"converted_hwid_value_rub": float(options.get("converted_hwid_value_rub") or 0),
"converted_hwid_gb": float(options.get("converted_hwid_gb") or 0),
}
)
@@ -651,13 +747,17 @@ def _serialize_tariff_change_target(
"title": f"+{package.gb:g} GB",
"traffic_gb": float(package.gb),
"price": float(package.price),
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
"currency": default_currency_code,
}
for package in (tariff.traffic_packages.rub if tariff.traffic_packages else [])
for package in (
tariff.traffic_packages.for_currency(default_currency)
if tariff.traffic_packages
else []
)
)
else:
for months in tariff.enabled_periods:
price = tariff.period_price(int(months), "rub")
price = tariff.period_price(int(months), default_currency)
if price:
actions.append(
{
@@ -666,7 +766,7 @@ def _serialize_tariff_change_target(
"months": int(months),
"title": _format_months_title(int(months), lang),
"price": float(price),
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
"currency": default_currency_code,
}
)
return {
@@ -690,10 +790,15 @@ def _serialize_payment_methods(
from bot.payment_providers import get_provider_spec, resolve_provider_presentation
methods: List[Dict[str, Any]] = []
payment_currency = default_payment_currency_code_for_settings(settings)
for method in settings.payment_methods_order:
method = method.lower()
spec = get_provider_spec(method)
if spec and spec.is_visible_for_user(settings, app, is_admin=is_admin):
if (
spec
and spec.is_visible_for_user(settings, app, is_admin=is_admin)
and spec.is_usable_for_payment_currency(settings, payment_currency)
):
presentation = resolve_provider_presentation(spec, settings, language=lang)
methods.append(
{
@@ -0,0 +1,70 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
from bot.services.telegram_notifications import (
TELEGRAM_NOTIFICATIONS_ENABLED,
probe_telegram_notifications,
telegram_notifications_start_link,
)
from .common import _invalidate_webapp_user_caches
async def _probe_telegram_notifications_for_user_id(
request: web.Request,
user_id: int,
*,
force: bool = False,
) -> Dict[str, Any]:
settings: Settings = request.app["settings"]
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
try:
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user or db_user.is_banned:
await session.rollback()
return {
"ok": False,
"status": "access_denied",
"enabled": False,
"start_link": telegram_notifications_start_link(
request.app.get("bot_username") or ""
),
}
result = await probe_telegram_notifications(
session=session,
bot=request.app["bot"],
settings=settings,
i18n=request.app.get("i18n"),
user=db_user,
bot_username=request.app.get("bot_username") or "",
force=force,
)
await session.commit()
status = str(result.get("status") or "")
await _invalidate_webapp_user_caches(settings, int(db_user.user_id))
return {
"ok": bool(result.get("ok")),
"status": status,
"enabled": status == TELEGRAM_NOTIFICATIONS_ENABLED,
"start_link": result.get("start_link"),
}
except Exception:
await session.rollback()
logger.exception("Telegram notification probe failed")
return {
"ok": False,
"status": "unknown",
"enabled": False,
"start_link": telegram_notifications_start_link(
request.app.get("bot_username") or ""
),
}
async def account_telegram_notifications_probe_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
force = True
result = await _probe_telegram_notifications_for_user_id(request, user_id, force=force)
if result.get("status") == "access_denied":
return _json_error(403, "access_denied", "Access denied")
return web.json_response({"ok": True, "telegram_notifications": result})
+3 -1
View File
@@ -155,7 +155,7 @@ async def change_broadcast_target_handler(
return
new_target = callback.data.split(":")[1]
if new_target not in {"all", "active", "inactive"}:
if new_target not in {"all", "active", "inactive", "expired"}:
await callback.answer("Unknown target.", show_alert=True)
return
@@ -247,6 +247,8 @@ async def confirm_broadcast_callback_handler(
user_ids = await user_dal.get_user_ids_with_active_subscription(session)
elif target == "inactive":
user_ids = await user_dal.get_user_ids_without_active_subscription(session)
elif target == "expired":
user_ids = await user_dal.get_user_ids_with_expired_subscription(session)
else:
user_ids = await user_dal.get_all_active_user_ids_for_broadcast(session)
+13 -5
View File
@@ -13,6 +13,7 @@ from bot.middlewares.i18n import JsonI18n
from bot.payment_providers import pending_statuses
from bot.services.panel_api_service import PanelApiService
from config.settings import Settings
from config.tariffs_config import default_payment_currency_code_for_settings
from db.dal import panel_sync_dal, payment_dal, user_dal
from db.models import PanelSyncStatus, Payment
@@ -71,11 +72,17 @@ async def show_statistics_handler(
f"📊 {_('admin_user_stats_total_label')}: <b>{user_stats['total_users']}</b>"
)
# Removed: Active today moved to panel stats
stats_text_parts.append(
f"📡 {_('admin_user_stats_active_subscription_label')}: <b>{user_stats['active_subscriptions']}</b>" # noqa: E501
)
stats_text_parts.append(
f"💳 {_('admin_user_stats_paid_subs_label')}: <b>{user_stats['paid_subscriptions']}</b>"
)
stats_text_parts.append(
f"🆓 {_('admin_user_stats_trial_label')}: <b>{user_stats['trial_users']}</b>"
f"🧪 {_('admin_user_stats_trial_label')}: <b>{user_stats['trial_users']}</b>"
)
stats_text_parts.append(
f"🎁 {_('admin_user_stats_free_subscription_label')}: <b>{user_stats['free_subscription_users']}</b>" # noqa: E501
)
stats_text_parts.append(
f"😴 {_('admin_user_stats_inactive_label')}: <b>{user_stats['inactive_users']}</b>"
@@ -189,19 +196,20 @@ async def show_statistics_handler(
# Financial statistics
financial_stats = await payment_dal.get_financial_statistics(session)
currency = default_payment_currency_code_for_settings(settings)
stats_text_parts.append(f"\n<b>💰 {_('admin_financial_stats_header')}</b>")
stats_text_parts.append(
f"📅 {_('admin_financial_today_label')}: <b>{financial_stats['today_revenue']:.2f} RUB</b> ({financial_stats['today_payments_count']} {_('admin_financial_payments_label')})" # noqa: E501
f"📅 {_('admin_financial_today_label')}: <b>{financial_stats['today_revenue']:.2f} {currency}</b> ({financial_stats['today_payments_count']} {_('admin_financial_payments_label')})" # noqa: E501
)
stats_text_parts.append(
f"📅 {_('admin_financial_week_label')}: <b>{financial_stats['week_revenue']:.2f} RUB</b>"
f"📅 {_('admin_financial_week_label')}: <b>{financial_stats['week_revenue']:.2f} {currency}</b>" # noqa: E501
)
stats_text_parts.append(
f"📅 {_('admin_financial_month_label')}: <b>{financial_stats['month_revenue']:.2f} RUB</b>"
f"📅 {_('admin_financial_month_label')}: <b>{financial_stats['month_revenue']:.2f} {currency}</b>" # noqa: E501
)
stats_text_parts.append(
f"🏆 {_('admin_financial_all_time_label')}: <b>{financial_stats['all_time_revenue']:.2f} RUB</b>" # noqa: E501
f"🏆 {_('admin_financial_all_time_label')}: <b>{financial_stats['all_time_revenue']:.2f} {currency}</b>" # noqa: E501
)
last_payments_models: List[Payment] = await payment_dal.get_recent_payment_logs_with_user(
+7 -9
View File
@@ -14,6 +14,7 @@ from bot.middlewares.i18n import JsonI18n
from bot.services.panel_api_service import PanelApiService
from bot.utils.text_sanitizer import panel_description_from_profile
from config.settings import Settings
from db.advisory_locks import acquire_subscription_background_sync_lock
from db.dal import panel_sync_dal, subscription_dal, user_dal
from db.models import Subscription, User
@@ -928,6 +929,7 @@ async def _perform_sync_impl(
total_panel_users = len(panel_users_data)
logging.info(f"Starting sync for {total_panel_users} panel users.")
await acquire_subscription_background_sync_lock(session)
sync_indexes = await _prefetch_sync_indexes(session, panel_users_data)
users_by_telegram_id = sync_indexes["users_by_telegram_id"]
users_by_user_id = sync_indexes["users_by_user_id"]
@@ -1203,15 +1205,13 @@ async def _perform_sync_impl(
merge_panel_patches = int(merge_result.get("panel_patches", 0))
if merge_panel_patches:
panel_patch_count += merge_panel_patches
panel_patch_reason_counts[
"duplicate_panel_merge_extend"
] += merge_panel_patches
panel_patch_reason_counts["duplicate_panel_merge_extend"] += (
merge_panel_patches
)
if merge_result["resolved"]:
users_updated += 1
users_uuid_updated += 1
local_update_reason_counts.update(
["duplicate_panel_identity_resolved"]
)
local_update_reason_counts.update(["duplicate_panel_identity_resolved"])
panel_uuids_by_telegram_id.get(telegram_id_from_panel, set()).discard(
str(panel_uuid)
)
@@ -1322,9 +1322,7 @@ async def _perform_sync_impl(
current_panel_user=panel_user_for_identity,
reasons=panel_reasons,
panel_view=(
"list"
if missing_identity_fields_match
else "full_fetch"
"list" if missing_identity_fields_match else "full_fetch"
),
)
panel_patch_count += 1
+167 -11
View File
@@ -27,6 +27,7 @@ from bot.utils.text_sanitizer import (
username_for_display,
)
from config.settings import Settings
from config.tariffs_config import default_payment_currency_code_for_settings
from db.dal import message_log_dal, subscription_dal, user_dal
from db.models import User
@@ -88,6 +89,31 @@ async def _find_user_by_admin_input(
return None
def _admin_user_reference_label(
user: Optional[User], fallback_user_id: Optional[int] = None
) -> str:
if user is None:
return f"ID {fallback_user_id}" if fallback_user_id is not None else "N/A"
first_name = sanitize_display_name(user.first_name) if user.first_name else ""
last_name = sanitize_display_name(user.last_name) if user.last_name else ""
full_name = f"{first_name} {last_name}".strip()
if full_name:
label = full_name
elif user.username:
label = username_for_display(user.username, with_at=True)
elif user.email:
label = user.email
else:
label = f"ID {user.user_id}"
return f"{label} · ID {user.user_id}"
def _admin_user_button_label(user: User) -> str:
label = _admin_user_reference_label(user)
return label[:64]
async def users_list_handler(
callback: types.CallbackQuery,
i18n_data: dict,
@@ -195,7 +221,13 @@ def get_user_card_keyboard(
text=_(key="admin_user_refresh_button"), callback_data=f"user_action:refresh:{user_id}"
)
# Row 3b: Premium override + traffic grant
# Row 3b: Referral details
builder.button(
text=_(key="admin_user_invitees_button"),
callback_data=f"user_action:invitees:{user_id}:0",
)
# Row 4: Premium override + traffic grant
builder.button(
text=_(key="admin_user_premium_override_button"),
callback_data=f"user_action:premium_override:{user_id}",
@@ -229,9 +261,9 @@ def get_user_card_keyboard(
quick_links_count = (1 if has_self_link else 0) + (1 if has_referrer_link else 0)
if quick_links_count == 0:
builder.adjust(2, 2, 2, 2, 1, 2)
builder.adjust(2, 2, 2, 1, 2, 1, 2)
else:
builder.adjust(2, 2, 2, 2, quick_links_count, 1, 2)
builder.adjust(2, 2, 2, 1, 2, quick_links_count, 1, 2)
return builder
@@ -314,7 +346,11 @@ async def format_user_card(
# Referral info
if user.referred_by_id:
card_parts.append(f"{_('admin_user_referral_label')} {hcode(str(user.referred_by_id))}")
referrer = await user_dal.get_referrer_for_user(session, user)
card_parts.append(
f"{_('admin_user_invited_by_label')} "
f"{hcode(_admin_user_reference_label(referrer, user.referred_by_id))}"
)
# Panel info
if user.panel_user_uuid:
@@ -407,17 +443,18 @@ async def format_user_card(
try:
from db.dal import payment_dal
currency = default_payment_currency_code_for_settings(settings)
# Total amount paid by this user
total_paid = await payment_dal.get_user_total_paid(session, user.user_id)
card_parts.append(
f"{_('admin_user_total_paid_label')} {hcode(f'{total_paid:.2f} RUB')}"
f"{_('admin_user_total_paid_label')} {hcode(f'{total_paid:.2f} {currency}')}"
)
# Total revenue from referrals
referral_revenue = await payment_dal.get_referral_revenue(session, user.user_id)
card_parts.append(
f"{_('admin_user_referral_revenue_label')} {hcode(f'{referral_revenue:.2f} RUB')}"
)
referral_revenue_text = hcode(f"{referral_revenue:.2f} {currency}")
card_parts.append(f"{_('admin_user_referral_revenue_label')} {referral_revenue_text}")
except Exception as e_fin:
logging.error(
f"Failed to build financial analytics for admin card {user.user_id}: {e_fin}"
@@ -619,6 +656,12 @@ async def user_action_handler(
await handle_send_message_prompt(callback, state, user, i18n, current_lang)
elif action == "view_logs":
await handle_view_user_logs(callback, user, session, settings, i18n, current_lang)
elif action == "invitees":
try:
page = max(0, int(parts[3])) if len(parts) > 3 else 0
except (TypeError, ValueError):
page = 0
await handle_view_user_invitees(callback, user, session, i18n, current_lang, page=page)
elif action == "refresh":
await handle_refresh_user_card(
callback, user, subscription_service, session, settings, i18n, current_lang
@@ -889,8 +932,7 @@ async def handle_reset_trial(
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
try:
# Delete all user subscriptions to reset trial eligibility
await subscription_dal.delete_all_user_subscriptions(session, user.user_id)
await user_dal.mark_trial_eligibility_reset(session, user.user_id)
await session.commit()
await callback.answer(_("admin_user_trial_reset_success"), show_alert=True)
@@ -1056,6 +1098,120 @@ async def handle_view_user_logs(
await callback.answer(_("admin_user_logs_error"), show_alert=True)
async def handle_view_user_invitees(
callback: types.CallbackQuery,
user: User,
session: AsyncSession,
i18n_instance,
lang: str,
*,
page: int = 0,
):
"""Show users invited by the selected account."""
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
page_size = 10
safe_page = max(0, int(page or 0))
try:
total = await user_dal.count_users_referred_by(session, user.user_id)
total_pages = max(1, (total + page_size - 1) // page_size)
if safe_page >= total_pages:
safe_page = total_pages - 1
invitees = await user_dal.get_users_referred_by(
session,
user.user_id,
limit=page_size,
offset=safe_page * page_size,
)
header = _(
"admin_user_invitees_message_title",
user=hcode(_admin_user_reference_label(user)),
total=total,
current=safe_page + 1,
total_pages=total_pages,
)
if total <= 0:
invitees_text = f"{header}\n\n{_('admin_user_invitees_empty')}"
else:
lines = []
for index, invitee in enumerate(invitees, start=safe_page * page_size + 1):
registered = (
invitee.registration_date.strftime("%Y-%m-%d")
if invitee.registration_date
else ""
)
suffix = (
_("admin_user_invitee_registered_suffix", date=registered) if registered else ""
)
lines.append(
_(
"admin_user_invitee_item",
index=index,
user=hcode(_admin_user_reference_label(invitee)),
suffix=suffix,
)
)
invitees_text = "\n".join([header, "", *lines])
builder = InlineKeyboardBuilder()
for invitee in invitees:
builder.row(
types.InlineKeyboardButton(
text=_admin_user_button_label(invitee),
callback_data=f"user_action:refresh:{invitee.user_id}",
)
)
pagination_buttons = []
if safe_page > 0:
pagination_buttons.append(
types.InlineKeyboardButton(
text=_("prev_page_button"),
callback_data=f"user_action:invitees:{user.user_id}:{safe_page - 1}",
)
)
if safe_page < total_pages - 1:
pagination_buttons.append(
types.InlineKeyboardButton(
text=_("next_page_button"),
callback_data=f"user_action:invitees:{user.user_id}:{safe_page + 1}",
)
)
if pagination_buttons:
builder.row(*pagination_buttons)
builder.row(
types.InlineKeyboardButton(
text=_("admin_user_back_to_card_button"),
callback_data=f"user_action:refresh:{user.user_id}",
)
)
builder.row(
types.InlineKeyboardButton(
text=_("back_to_admin_panel_button"), callback_data="admin_action:main"
)
)
try:
await callback.message.edit_text(
invitees_text, reply_markup=builder.as_markup(), parse_mode="HTML"
)
except Exception:
await callback.message.answer(
invitees_text, reply_markup=builder.as_markup(), parse_mode="HTML"
)
await callback.answer()
except Exception as exc:
logging.error(
"Error viewing invitees for user %s: %s",
user.user_id,
exc,
exc_info=True,
)
await callback.answer(_("admin_user_invitees_error"), show_alert=True)
async def handle_refresh_user_card(
callback: types.CallbackQuery,
user: User,
@@ -1964,7 +2120,7 @@ async def user_card_from_list_handler(
text=_("admin_user_back_to_list_button"), callback_data=f"admin_action:users_list:{page}"
)
quick_links_width = 2 if user.referred_by_id else 1
keyboard.adjust(2, 2, 2, 2, quick_links_width, 1, 2, 1)
keyboard.adjust(2, 2, 2, 1, 2, quick_links_width, 1, 2, 1)
# Format user card
try:
+3 -1
View File
@@ -166,8 +166,10 @@ async def create_user_stats_result(
"inline_user_stats_message",
total=user_stats["total_users"],
active_today=user_stats["active_today"],
active=user_stats["active_subscriptions"],
paid=user_stats["paid_subscriptions"],
trial=user_stats["trial_users"],
free=user_stats["free_subscription_users"],
inactive=user_stats["inactive_users"],
banned=user_stats["banned_users"],
referral=user_stats["referral_users"],
@@ -179,7 +181,7 @@ async def create_user_stats_result(
description=_(
"inline_user_stats_description",
total=user_stats["total_users"],
active=user_stats["paid_subscriptions"],
active=user_stats["active_subscriptions"],
),
input_message_content=InputTextMessageContent(
message_text=stats_text, parse_mode="HTML"
+128 -23
View File
@@ -1,5 +1,5 @@
import logging
from typing import Optional, Union
from typing import Any, Callable, Optional, Union
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
from aiogram import Bot, F, Router, types
@@ -76,31 +76,10 @@ async def referral_command_handler(
await event.answer()
return
bonus_info_parts = []
if getattr(settings, "traffic_sale_mode", False):
bonus_details_str = _("referral_not_available_for_traffic")
else:
if settings.subscription_options:
for months_period_key, _price in sorted(settings.subscription_options.items()):
inv_bonus = settings.referral_bonus_inviter.get(months_period_key)
ref_bonus = settings.referral_bonus_referee.get(months_period_key)
if inv_bonus is not None or ref_bonus is not None:
bonus_info_parts.append(
_(
"referral_bonus_per_period",
months=months_period_key,
inviter_bonus_days=inv_bonus
if inv_bonus is not None
else _("no_bonus_placeholder"),
referee_bonus_days=ref_bonus
if ref_bonus is not None
else _("no_bonus_placeholder"),
)
)
bonus_details_str = (
"\n".join(bonus_info_parts) if bonus_info_parts else _("referral_no_bonuses_configured")
)
bonus_details_str = _build_referral_bonus_details_text(settings, _, current_lang)
referral_stats = await referral_service.get_referral_stats(session, inviter_user_id)
@@ -208,6 +187,132 @@ async def referral_action_handler(
await callback.answer()
Translator = Callable[..., str]
def _period_bonus_text(
translator: Translator,
*,
months: int,
inviter_days: Optional[int],
referee_days: Optional[int],
) -> str:
return translator(
"referral_bonus_per_period",
months=months,
inviter_bonus_days=(
inviter_days if inviter_days is not None else translator("no_bonus_placeholder")
),
referee_bonus_days=(
referee_days if referee_days is not None else translator("no_bonus_placeholder")
),
)
def _tariff_period_bonus_entries(tariff: Any) -> list[dict[str, Optional[int]]]:
entries: list[dict[str, Optional[int]]] = []
for months in sorted(int(month) for month in getattr(tariff, "enabled_periods", [])):
inviter_days = tariff.referral_inviter_bonus_days(months)
referee_days = tariff.referral_referee_bonus_days(months)
if inviter_days is None and referee_days is None:
continue
entries.append(
{
"months": months,
"inviter_days": inviter_days,
"referee_days": referee_days,
}
)
return entries
def _legacy_period_bonus_entries(settings: Settings) -> list[dict[str, Optional[int]]]:
entries: list[dict[str, Optional[int]]] = []
for months, _price in sorted(settings.subscription_options.items()):
inviter_days = settings.referral_bonus_inviter.get(months)
referee_days = settings.referral_bonus_referee.get(months)
if inviter_days is None and referee_days is None:
continue
entries.append(
{
"months": int(months),
"inviter_days": inviter_days,
"referee_days": referee_days,
}
)
return entries
def _bonus_days_range(translator: Translator, values: list[int]) -> str:
return translator(
"referral_bonus_days_range",
min_days=min(values),
max_days=max(values),
)
def _build_referral_bonus_details_text(
settings: Settings, translator: Translator, current_lang: str
) -> str:
tariffs_config = settings.tariffs_config
if not tariffs_config:
bonus_info_parts = [
_period_bonus_text(
translator,
months=int(entry["months"] or 0),
inviter_days=entry["inviter_days"],
referee_days=entry["referee_days"],
)
for entry in _legacy_period_bonus_entries(settings)
]
return (
"\n".join(bonus_info_parts)
if bonus_info_parts
else translator("referral_no_bonuses_configured")
)
period_tariffs = [
tariff for tariff in tariffs_config.enabled_tariffs if tariff.billing_model == "period"
]
if len(period_tariffs) <= 1:
entries = _tariff_period_bonus_entries(period_tariffs[0]) if period_tariffs else []
bonus_info_parts = [
_period_bonus_text(
translator,
months=int(entry["months"] or 0),
inviter_days=entry["inviter_days"],
referee_days=entry["referee_days"],
)
for entry in entries
]
return (
"\n".join(bonus_info_parts)
if bonus_info_parts
else translator("referral_no_bonuses_configured")
)
bonus_info_parts = []
for tariff in period_tariffs:
entries = _tariff_period_bonus_entries(tariff)
if not entries:
continue
inviter_values = [int(entry["inviter_days"] or 0) for entry in entries]
referee_values = [int(entry["referee_days"] or 0) for entry in entries]
bonus_info_parts.append(
translator(
"referral_bonus_tariff_range",
tariff_name=tariff.name(current_lang),
inviter_bonus_range=_bonus_days_range(translator, inviter_values),
referee_bonus_range=_bonus_days_range(translator, referee_values),
)
)
return (
"\n".join(bonus_info_parts)
if bonus_info_parts
else translator("referral_no_bonuses_configured")
)
def _build_webapp_referral_link(
base_url: Optional[str], referral_code: Optional[str]
) -> Optional[str]:
+60 -7
View File
@@ -22,7 +22,12 @@ from bot.services.panel_api_service import PanelApiService
from bot.services.promo_code_service import PromoCodeService
from bot.services.referral_service import ReferralService
from bot.services.subscription_service import SubscriptionService
from bot.services.telegram_notifications import TELEGRAM_NOTIFICATIONS_ENABLED
from bot.utils.callback_answer import safe_answer_callback
from bot.utils.channel_subscription import (
is_required_channel_access_error,
normalize_required_channel_id,
)
from bot.utils.install_links import (
append_install_share_link_text,
ensure_user_install_guide_links,
@@ -44,12 +49,12 @@ async def should_show_trial_button(
if not settings.TRIAL_ENABLED:
return False
if hasattr(subscription_service, "has_had_any_subscription") and callable(
getattr(subscription_service, "has_had_any_subscription")
if hasattr(subscription_service, "has_trial_blocking_subscription") and callable(
getattr(subscription_service, "has_trial_blocking_subscription")
):
return not await subscription_service.has_had_any_subscription(session, user_id)
return not await subscription_service.has_trial_blocking_subscription(session, user_id)
logging.error("Method has_had_any_subscription is missing in SubscriptionService!")
logging.error("Method has_trial_blocking_subscription is missing in SubscriptionService!")
return False
@@ -214,7 +219,7 @@ async def ensure_required_channel_subscription(
Verify that the user is a member of the required channel (if configured).
Returns True when access can proceed, False when user must subscribe first.
"""
required_channel_id = settings.REQUIRED_CHANNEL_ID
required_channel_id = normalize_required_channel_id(settings.REQUIRED_CHANNEL_ID)
if not required_channel_id:
return True
@@ -278,6 +283,29 @@ async def ensure_required_channel_subscription(
if status_value in allowed_statuses:
is_member = True
except TelegramBadRequest as bad_request:
if is_required_channel_access_error(bad_request):
logging.error(
"Required channel check failed due to channel access/configuration error "
"(configured=%s, normalized=%s): %s",
settings.REQUIRED_CHANNEL_ID,
required_channel_id,
bad_request,
)
error_text = translate("channel_subscription_check_failed")
if isinstance(event, types.CallbackQuery):
try:
await event.answer(error_text, show_alert=True)
except Exception:
pass
if message_obj:
try:
await message_obj.answer(error_text)
except Exception:
pass
else:
await event.answer(error_text)
return False
logging.info(
"Required channel check: user %s not subscribed (details: %s)",
user_id,
@@ -392,11 +420,12 @@ async def ensure_required_channel_subscription(
@router.message(CommandStart(magic=F.args.regexp(r"^promo_(\w+)$").as_("promo_match")))
@router.message(CommandStart(magic=F.args.regexp(r"^admin_user_(\d+)$").as_("admin_user_match")))
@router.message(CommandStart(magic=F.args.regexp(r"^ticket_(\d+)$").as_("ticket_match")))
@router.message(CommandStart(magic=F.args.regexp(r"^notifications$").as_("notifications_match")))
@router.message(CommandStart(magic=F.args.regexp(r"^page_ref$").as_("page_ref_match")))
@router.message(
CommandStart(
magic=F.args.regexp(
r"^(?!ref_|promo_|admin_user_|ticket_|page_ref$|webapp_auth_)([A-Za-z0-9_\-]{2,64})$"
r"^(?!ref_|promo_|admin_user_|ticket_|notifications$|page_ref$|webapp_auth_)([A-Za-z0-9_\-]{2,64})$"
).as_("ad_param_match")
)
)
@@ -414,6 +443,7 @@ async def start_command_handler(
ad_param_match: Optional[re.Match] = None,
admin_user_match: Optional[re.Match] = None,
ticket_match: Optional[re.Match] = None,
notifications_match: Optional[re.Match] = None,
):
await state.clear()
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
@@ -500,6 +530,7 @@ async def start_command_handler(
promo_code_to_apply: Optional[str] = None
should_open_referral_from_start = False
ad_start_param: Optional[str] = None
notifications_start_requested = bool(notifications_match)
if ref_match:
raw_ref_value = ref_match.group(1)
@@ -522,6 +553,8 @@ async def start_command_handler(
elif promo_match:
promo_code_to_apply = promo_match.group(1)
logging.info(f"User {user_id} started with promo code: {promo_code_to_apply}")
elif notifications_start_requested:
logging.info("User %s started bot from notifications deep-link.", user_id)
elif page_ref_match:
should_open_referral_from_start = True
logging.info(f"User {user_id} started with page_ref deep-link.")
@@ -532,18 +565,24 @@ async def start_command_handler(
sanitized_username = sanitize_username(user.username)
sanitized_first_name = sanitize_display_name(user.first_name)
sanitized_last_name = sanitize_display_name(user.last_name)
notification_status_now = datetime.now(timezone.utc)
db_user = await user_dal.get_user_by_id(session, user_id)
is_existing_user = db_user is not None
if not db_user:
user_data_to_create = {
"user_id": user_id,
"telegram_id": user_id,
"username": sanitized_username,
"first_name": sanitized_first_name,
"last_name": sanitized_last_name,
"language_code": current_lang,
"referred_by_id": referred_by_user_id,
"registration_date": datetime.now(timezone.utc),
"telegram_notifications_status": TELEGRAM_NOTIFICATIONS_ENABLED,
"telegram_notifications_checked_at": notification_status_now,
"telegram_notifications_enabled_at": notification_status_now,
"telegram_notifications_blocked_at": None,
}
try:
db_user, created = await user_dal.create_user(session, user_data_to_create)
@@ -631,6 +670,13 @@ async def start_command_handler(
update_payload = {}
if db_user.language_code != current_lang:
update_payload["language_code"] = current_lang
if db_user.telegram_id != user_id:
update_payload["telegram_id"] = user_id
if db_user.telegram_notifications_status != TELEGRAM_NOTIFICATIONS_ENABLED:
update_payload["telegram_notifications_status"] = TELEGRAM_NOTIFICATIONS_ENABLED
update_payload["telegram_notifications_checked_at"] = notification_status_now
update_payload["telegram_notifications_enabled_at"] = notification_status_now
update_payload["telegram_notifications_blocked_at"] = None
# Set referral only if not already set AND user is not currently active.
# This allows previously subscribed but currently inactive users to be attributed.
if referred_by_user_id and db_user.referred_by_id is None:
@@ -684,9 +730,16 @@ async def start_command_handler(
open_referral_page_for_existing_user = should_open_referral_from_start and is_existing_user
# Send welcome message if not disabled
if not settings.DISABLE_WELCOME_MESSAGE and not open_referral_page_for_existing_user:
if (
not settings.DISABLE_WELCOME_MESSAGE
and not open_referral_page_for_existing_user
and not notifications_start_requested
):
await message.answer(_(key="welcome", user_name=hd.quote(user.full_name)))
if notifications_start_requested:
await message.answer(_("telegram_notifications_started"), parse_mode="HTML")
# Auto-apply promo code if provided via start parameter
if promo_code_to_apply:
try:
+94 -35
View File
@@ -32,6 +32,10 @@ from bot.utils.install_links import (
ensure_user_install_guide_links,
)
from config.settings import Settings
from config.tariffs_config import (
default_currency_key_for_settings,
default_payment_currency_code_for_settings,
)
from db.dal import subscription_dal, user_billing_dal
from db.models import Subscription
@@ -80,11 +84,13 @@ def _tariff_purchase_markup(
back_callback=back_callback,
callback_context=callback_context,
)
default_currency = default_currency_key_for_settings(settings)
return get_tariff_packages_keyboard(
tariff,
tariff.traffic_packages.rub,
tariff.traffic_packages.for_currency(default_currency),
current_lang,
i18n,
currency_symbol=default_payment_currency_code_for_settings(settings),
back_callback=back_callback,
callback_context=callback_context,
)
@@ -114,6 +120,20 @@ def _with_subscription_purchase_description(
return f"{description}\n\n{text}"
def _format_premium_bytes(value: object) -> str:
try:
bytes_value = max(0, int(value or 0))
except (TypeError, ValueError):
bytes_value = 0
return f"{bytes_value / 2**30:.2f} GB"
def _format_premium_usage_limit(active: dict[str, object]) -> str:
used = _format_premium_bytes(active.get("premium_used_bytes"))
limit = _format_premium_bytes(active.get("premium_limit_bytes"))
return f"{used} из {limit}"
async def display_subscription_options(
event: Union[types.Message, types.CallbackQuery],
i18n_data: dict,
@@ -171,6 +191,7 @@ async def display_subscription_options(
enabled_tariffs,
current_lang,
i18n,
settings=settings,
back_callback=back_callback,
callback_context=callback_context,
)
@@ -315,7 +336,9 @@ async def select_tariff_period_callback(
callback_context = parts[4] if len(parts) > 4 else None
tariff = config.require(tariff_key)
months = int(months_raw)
price_rub = tariff.period_price(months, "rub")
default_currency = default_currency_key_for_settings(settings)
currency_code = default_payment_currency_code_for_settings(settings)
price_rub = tariff.period_price(months, default_currency)
stars_price = tariff.period_price(months, "stars")
if price_rub is None:
await callback.answer(get_text("error_try_again"), show_alert=True)
@@ -324,7 +347,7 @@ async def select_tariff_period_callback(
months,
price_rub,
int(stars_price) if stars_price else None,
settings.DEFAULT_CURRENCY_SYMBOL,
currency_code,
current_lang,
i18n,
settings,
@@ -355,10 +378,16 @@ async def select_tariff_package_callback(
callback_context = parts[4] if len(parts) > 4 else None
tariff = config.require(tariff_key)
gb = float(gb_raw)
default_currency = default_currency_key_for_settings(settings)
currency_code = default_payment_currency_code_for_settings(settings)
packages = (
tariff.traffic_packages.rub
tariff.traffic_packages.for_currency(default_currency)
if tariff.billing_model == "traffic"
else (config.topup_packages_for(tariff).rub if config.topup_packages_for(tariff) else [])
else (
config.topup_packages_for(tariff).for_currency(default_currency)
if config.topup_packages_for(tariff)
else []
)
)
package = next((pkg for pkg in packages if float(pkg.gb) == gb), None)
if not package:
@@ -377,7 +406,7 @@ async def select_tariff_package_callback(
gb,
package.price,
None,
settings.DEFAULT_CURRENCY_SYMBOL,
currency_code,
current_lang,
i18n,
settings,
@@ -409,14 +438,19 @@ async def tariff_topup_list_callback(
return
tariff = config.require(active["tariff_key"])
packages = config.topup_packages_for(tariff)
rub_packages = packages.rub if packages else []
premium_packages = tariff.premium_topup_packages.rub if tariff.premium_topup_packages else []
if not rub_packages and not premium_packages:
default_currency = default_currency_key_for_settings(settings)
currency = default_payment_currency_code_for_settings(settings)
currency_packages = packages.for_currency(default_currency) if packages else []
premium_packages = (
tariff.premium_topup_packages.for_currency(default_currency)
if tariff.premium_topup_packages
else []
)
if not currency_packages and not premium_packages:
await callback.answer(get_text("no_subscription_options_available"), show_alert=True)
return
builder = InlineKeyboardBuilder()
currency = settings.DEFAULT_CURRENCY_SYMBOL
for package in rub_packages:
for package in currency_packages:
builder.row(
InlineKeyboardButton(
text=f"Обычный трафик +{package.gb:g} GB — {package.price:g} {currency}",
@@ -438,7 +472,7 @@ async def tariff_topup_list_callback(
premium_lines = []
carryover_lines = []
if rub_packages or premium_packages:
if currency_packages or premium_packages:
carryover_lines.append(
"Докупленный трафик не сгорает: сначала расходуется месячный лимит, затем докупленный остаток." # noqa: E501
)
@@ -456,7 +490,7 @@ async def tariff_topup_list_callback(
if len(labels) > len(visible):
premium_lines.append(f"• ... еще {len(labels) - len(visible)}")
premium_lines.append(
f"Premium использовано: {active.get('premium_used')} из {active.get('premium_limit')}. Осталось: {premium_left / 2**30:.2f} GB." # noqa: E501
f"Premium использовано: {_format_premium_usage_limit(active)}. Осталось: {premium_left / 2**30:.2f} GB." # noqa: E501
)
text = get_text("choose_payment_method_traffic")
if carryover_lines:
@@ -481,7 +515,13 @@ async def select_tariff_premium_package_callback(
_, _, tariff_key, gb_raw = callback.data.split(":", 3)
tariff = config.require(tariff_key)
gb = float(gb_raw)
packages = tariff.premium_topup_packages.rub if tariff.premium_topup_packages else []
default_currency = default_currency_key_for_settings(settings)
currency_code = default_payment_currency_code_for_settings(settings)
packages = (
tariff.premium_topup_packages.for_currency(default_currency)
if tariff.premium_topup_packages
else []
)
package = next((pkg for pkg in packages if float(pkg.gb) == gb), None)
if not package:
await callback.answer(get_text("error_try_again"), show_alert=True)
@@ -490,7 +530,7 @@ async def select_tariff_premium_package_callback(
gb,
package.price,
None,
settings.DEFAULT_CURRENCY_SYMBOL,
currency_code,
current_lang,
i18n,
settings,
@@ -528,7 +568,12 @@ async def hwid_devices_list_callback(
if tariff.billing_model != "period":
await callback.answer(get_text("no_hwid_device_packages_available"), show_alert=True)
return
packages = tariff.hwid_device_packages.rub if tariff.hwid_device_packages else []
default_currency = default_currency_key_for_settings(settings)
packages = (
tariff.hwid_device_packages.for_currency(default_currency)
if tariff.hwid_device_packages
else []
)
if not packages:
await callback.answer(get_text("no_hwid_device_packages_available"), show_alert=True)
return
@@ -543,9 +588,7 @@ async def hwid_devices_list_callback(
renewal=renewal_available,
)
text_key = (
"select_hwid_device_renewal_package"
if renewal_available
else "select_hwid_device_package"
"select_hwid_device_renewal_package" if renewal_available else "select_hwid_device_package"
)
await callback.message.edit_text(
get_text(
@@ -582,7 +625,13 @@ async def hwid_devices_package_callback(
package = next(
(
pkg
for pkg in (tariff.hwid_device_packages.rub if tariff.hwid_device_packages else [])
for pkg in (
tariff.hwid_device_packages.for_currency(
default_currency_key_for_settings(settings)
)
if tariff.hwid_device_packages
else []
)
if int(pkg.count) == count
),
None,
@@ -591,13 +640,15 @@ async def hwid_devices_package_callback(
await callback.answer(get_text("error_try_again"), show_alert=True)
return
sale_mode_base = "hwid_devices_renewal" if action == "renewal_package" else "hwid_devices"
rub_quote = await subscription_service.quote_hwid_device_topup(
default_currency = default_currency_key_for_settings(settings)
currency_code = default_payment_currency_code_for_settings(settings)
currency_quote = await subscription_service.quote_hwid_device_topup(
session,
user_id=callback.from_user.id,
device_count=count,
tariff_key=tariff.key,
renewal=action == "renewal_package",
currency="rub",
currency=default_currency,
)
stars_quote = await subscription_service.quote_hwid_device_topup(
session,
@@ -607,16 +658,16 @@ async def hwid_devices_package_callback(
renewal=action == "renewal_package",
currency="stars",
)
if not rub_quote and not stars_quote:
if not currency_quote and not stars_quote:
await callback.answer(get_text("error_try_again"), show_alert=True)
return
markup = get_payment_method_keyboard(
count,
float(rub_quote.get("price") if rub_quote else 0),
float(currency_quote.get("price") if currency_quote else 0),
int(stars_quote["price"])
if stars_quote and int(stars_quote.get("price") or 0) > 0
else None,
settings.DEFAULT_CURRENCY_SYMBOL,
currency_code,
current_lang,
i18n,
settings,
@@ -703,6 +754,8 @@ async def tariff_change_select_callback(
options = await subscription_service.calculate_tariff_switch_options_with_hwid(
session, db_sub, target
)
default_currency = default_currency_key_for_settings(settings)
currency_code = default_payment_currency_code_for_settings(settings)
rows = []
if options["mode"] == "period_to_period":
rows.append(
@@ -717,7 +770,7 @@ async def tariff_change_select_callback(
rows.append(
[
InlineKeyboardButton(
text=f"Доплатить {options['paid_diff_rub']} RUB",
text=f"Доплатить {options['paid_diff_rub']} {currency_code}",
callback_data=f"tariff_change:confirm_pay:{target.key}:{options['paid_diff_rub']}",
)
]
@@ -731,23 +784,23 @@ async def tariff_change_select_callback(
)
]
)
for package in target.traffic_packages.rub:
for package in target.traffic_packages.for_currency(default_currency):
rows.append(
[
InlineKeyboardButton(
text=f"+ {package.gb:g} GB за {package.price:g} RUB",
text=f"+ {package.gb:g} GB за {package.price:g} {currency_code}",
callback_data=f"tariff:package:{target.key}:{package.gb:g}",
)
]
)
else:
for months in target.enabled_periods:
price = target.period_price(months, "rub")
price = target.period_price(months, default_currency)
if price:
rows.append(
[
InlineKeyboardButton(
text=f"{months} мес. за {price:g} RUB",
text=f"{months} мес. за {price:g} {currency_code}",
callback_data=f"tariff:period:{target.key}:{months}",
)
]
@@ -830,6 +883,7 @@ async def tariff_change_confirm_pay_callback(
return
_, _, tariff_key, amount_raw = callback.data.split(":", 3)
target = config.require(tariff_key)
currency_code = default_payment_currency_code_for_settings(settings)
rows = [
[
InlineKeyboardButton(
@@ -845,7 +899,7 @@ async def tariff_change_confirm_pay_callback(
],
]
await callback.message.edit_text(
f"Подтвердите смену тарифа\n\nНовый тариф: {target.name(current_lang)}\nБудет создана оплата на {amount_raw} RUB.", # noqa: E501
f"Подтвердите смену тарифа\n\nНовый тариф: {target.name(current_lang)}\nБудет создана оплата на {amount_raw} {currency_code}.", # noqa: E501
reply_markup=InlineKeyboardMarkup(inline_keyboard=rows),
)
await callback.answer()
@@ -887,11 +941,12 @@ async def tariff_change_pay_callback(
i18n: JsonI18n = i18n_data.get("i18n_instance")
_, _, tariff_key, amount_raw = callback.data.split(":", 3)
amount = float(amount_raw)
currency_code = default_payment_currency_code_for_settings(settings)
markup = get_payment_method_keyboard(
1,
amount,
None,
settings.DEFAULT_CURRENCY_SYMBOL,
currency_code,
current_lang,
i18n,
settings,
@@ -1067,7 +1122,7 @@ async def my_subscription_command_handler(
text += (
"\n\n🚀 <b>Premium-серверы</b>\n"
f"Статус: <b>{premium_status}</b>\n"
f"Лимит: <b>{active.get('premium_used')} из {active.get('premium_limit')}</b>\n"
f"Лимит: <b>{_format_premium_usage_limit(active)}</b>\n"
f"Осталось: <b>{premium_left / 2**30:.2f} GB</b>\n"
f"Докупленный остаток: <b>{premium_balance / 2**30:.2f} GB</b>\n"
"Отдельный лимит действует на:\n"
@@ -1214,7 +1269,9 @@ async def my_subscription_command_handler(
if (
tariff_for_devices.billing_model == "period"
and tariff_for_devices.hwid_device_packages
and tariff_for_devices.hwid_device_packages.rub
and tariff_for_devices.hwid_device_packages.for_currency(
default_currency_key_for_settings(settings)
)
):
prepend_rows.append(
[
@@ -1431,7 +1488,9 @@ async def my_devices_command_handler(
if (
tariff_for_devices.billing_model == "period"
and tariff_for_devices.hwid_device_packages
and tariff_for_devices.hwid_device_packages.rub
and tariff_for_devices.hwid_device_packages.for_currency(
default_currency_key_for_settings(settings)
)
):
devices_kb.append(
[
+9 -7
View File
@@ -46,7 +46,7 @@ async def request_trial_confirmation_handler(
return
if settings.TRIAL_ENABLED:
if not await subscription_service.has_had_any_subscription(session, user_id):
if not await subscription_service.has_trial_blocking_subscription(session, user_id):
pass
if not settings.TRIAL_ENABLED:
@@ -60,7 +60,7 @@ async def request_trial_confirmation_handler(
pass
return
if await subscription_service.has_had_any_subscription(session, user_id):
if await subscription_service.has_trial_blocking_subscription(session, user_id):
await callback.message.edit_text(
_("trial_already_had_subscription_or_trial"),
reply_markup=get_main_menu_inline_keyboard(current_lang, i18n, settings, False),
@@ -147,8 +147,9 @@ async def request_trial_confirmation_handler(
await callback.answer(final_message_text_in_chat, show_alert=True)
except Exception:
pass
if settings.TRIAL_ENABLED and not await subscription_service.has_had_any_subscription(
session, user_id
if (
settings.TRIAL_ENABLED
and not await subscription_service.has_trial_blocking_subscription(session, user_id)
):
show_trial_button_after_action = True
@@ -218,7 +219,7 @@ async def confirm_activate_trial_handler(
callback, settings, i18n_data, subscription_service, session, is_edit=True
)
return
if await subscription_service.has_had_any_subscription(session, user_id):
if await subscription_service.has_trial_blocking_subscription(session, user_id):
try:
await callback.answer(_("trial_already_had_subscription_or_trial"), show_alert=True)
except Exception:
@@ -283,8 +284,9 @@ async def confirm_activate_trial_handler(
await callback.answer(final_message_text_in_chat, show_alert=True)
except Exception:
pass
if settings.TRIAL_ENABLED and not await subscription_service.has_had_any_subscription(
session, user_id
if (
settings.TRIAL_ENABLED
and not await subscription_service.has_trial_blocking_subscription(session, user_id)
):
show_trial_button_after_action = True
@@ -452,10 +452,11 @@ def get_broadcast_confirmation_keyboard(
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
# Row: target selection (all / active / inactive)
# Row: target selection (all / active / inactive / expired)
target_all_label = _(key="broadcast_target_all_button")
target_active_label = _(key="broadcast_target_active_button")
target_inactive_label = _(key="broadcast_target_inactive_button")
target_expired_label = _(key="broadcast_target_expired_button")
# Highlight current selection with a prefix
def mark_selected(label: str, is_selected: bool) -> str:
@@ -473,7 +474,10 @@ def get_broadcast_confirmation_keyboard(
text=mark_selected(target_inactive_label, target == "inactive"),
callback_data="broadcast_target:inactive",
)
builder.adjust(3)
builder.button(
text=mark_selected(target_expired_label, target == "expired"),
callback_data="broadcast_target:expired",
)
# Row: confirmation
builder.button(
@@ -482,7 +486,7 @@ def get_broadcast_confirmation_keyboard(
builder.button(
text=_(key="cancel_broadcast_button"), callback_data="broadcast_final_action:cancel"
)
builder.adjust(2)
builder.adjust(2, 2, 2)
return builder.as_markup()
+27 -6
View File
@@ -7,6 +7,10 @@ from bot.middlewares.i18n import locale_language_options
from bot.utils.install_links import bot_install_guide_url
from bot.utils.mini_app_url import subscription_mini_app_trial_url
from config.settings import Settings
from config.tariffs_config import (
default_currency_key_for_settings,
default_payment_currency_code_for_settings,
)
BOT_MENU_CONTEXT = "bot"
@@ -328,19 +332,31 @@ def get_tariff_catalog_keyboard(
tariffs: List[Any],
lang: str,
i18n_instance,
settings: Optional[Settings] = None,
back_callback: str = "main_action:back_to_main",
callback_context: Optional[str] = None,
) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
callback_context = callback_context or callback_context_from_back_callback(back_callback)
default_currency = default_currency_key_for_settings(settings) if settings else "rub"
for tariff in tariffs:
label = tariff.name(lang)
if tariff.billing_model == "period":
min_price = tariff.min_period_price_rub()
if hasattr(tariff, "min_period_price"):
min_price = tariff.min_period_price(default_currency)
elif default_currency == "rub" and hasattr(tariff, "min_period_price_rub"):
min_price = tariff.min_period_price_rub()
else:
min_price = None
if min_price is not None:
label = f"{label} от {min_price:g}"
else:
package = tariff.min_traffic_package_rub()
if hasattr(tariff, "min_traffic_package"):
package = tariff.min_traffic_package(default_currency)
elif default_currency == "rub" and hasattr(tariff, "min_traffic_package_rub"):
package = tariff.min_traffic_package_rub()
else:
package = None
if package:
label = f"{label} от {package.price:g} / {package.gb:g} GB"
builder.row(
@@ -368,8 +384,10 @@ def get_tariff_periods_keyboard(
builder = InlineKeyboardBuilder()
callback_context = callback_context or callback_context_from_back_callback(back_callback)
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
default_currency = default_currency_key_for_settings(settings)
currency_code = default_payment_currency_code_for_settings(settings)
for months in tariff.enabled_periods:
rub_price = tariff.period_price(months, "rub")
rub_price = tariff.period_price(months, default_currency)
if rub_price and rub_price > 0:
builder.row(
InlineKeyboardButton(
@@ -377,7 +395,7 @@ def get_tariff_periods_keyboard(
"subscribe_for_months_button",
months=months,
price=rub_price,
currency_symbol=settings.DEFAULT_CURRENCY_SYMBOL,
currency_symbol=currency_code,
),
callback_data=f"tariff:period:{tariff.key}:{months}"
f"{callback_suffix_for_context(callback_context)}",
@@ -394,6 +412,7 @@ def get_tariff_packages_keyboard(
packages: List[Any],
lang: str,
i18n_instance,
currency_symbol: str = "RUB",
back_callback: str = "main_action:subscribe",
callback_context: Optional[str] = None,
) -> InlineKeyboardMarkup:
@@ -407,7 +426,7 @@ def get_tariff_packages_keyboard(
"buy_traffic_package_button",
traffic_gb=f"{package.gb:g}",
price=package.price,
currency_symbol="RUB",
currency_symbol=currency_symbol,
),
callback_data=f"tariff:package:{tariff.key}:{package.gb:g}"
f"{callback_suffix_for_context(callback_context)}",
@@ -430,6 +449,7 @@ def get_hwid_device_packages_keyboard(
) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
currency_code = default_payment_currency_code_for_settings(settings)
for package in packages:
builder.row(
InlineKeyboardButton(
@@ -437,7 +457,7 @@ def get_hwid_device_packages_keyboard(
"buy_hwid_devices_button",
count=package.count,
price=package.price,
currency_symbol=settings.DEFAULT_CURRENCY_SYMBOL,
currency_symbol=currency_code,
),
callback_data=(
f"hwid_devices:{'renewal_package' if renewal else 'package'}:"
@@ -484,6 +504,7 @@ def get_payment_method_keyboard(
if (
not spec
or not spec.callback_prefix
or not spec.is_usable_for_payment_currency(settings, currency_symbol_val)
or not spec.is_available_to_user(
settings,
user_id=user_id,
@@ -11,6 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.inline.user_keyboards import get_channel_subscription_keyboard
from bot.middlewares.i18n import JsonI18n
from bot.utils.channel_subscription import normalize_required_channel_id
from config.settings import Settings
from db.dal import user_dal
@@ -32,7 +33,7 @@ class ChannelSubscriptionMiddleware(BaseMiddleware):
event: Update,
data: Dict[str, Any],
) -> Any:
required_channel_id = self.settings.REQUIRED_CHANNEL_ID
required_channel_id = normalize_required_channel_id(self.settings.REQUIRED_CHANNEL_ID)
if not required_channel_id:
return await handler(event, data)
+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]:
normalized = {
normalize_locale_language_code(code, prefer_known_base=False)
for code in codes
}
normalized = {normalize_locale_language_code(code, prefer_known_base=False) for code in codes}
normalized = {code for code in normalized if code and is_valid_locale_language_code(code)}
preferred = [code for code in DEFAULT_LANGUAGE_ORDER if code in normalized]
rest = sorted(code for code in normalized if code not in DEFAULT_LANGUAGE_ORDER)
@@ -310,6 +307,7 @@ class JsonI18n:
self.locales_data: Dict[str, Dict[str, str]] = {}
self._overrides_path: Optional[Path] = None
self._overrides_file_mtime_ns: Optional[int] = None
self._overrides_file_content: Optional[str] = None
self._overrides_file_next_check = 0.0
self._overrides_file_check_interval_seconds = 1.0
self._load_locales()
@@ -422,6 +420,7 @@ class JsonI18n:
if self._overrides_file_mtime_ns is None:
return False
self._overrides_file_mtime_ns = None
self._overrides_file_content = None
logging.info(
"Locale overrides file removed; keeping current in-memory overrides until "
"the DB fallback is reloaded"
@@ -435,19 +434,8 @@ class JsonI18n:
)
return False
if not force and stat.st_mtime_ns == self._overrides_file_mtime_ns:
return False
try:
payload = json.loads(self._overrides_path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
logging.warning(
"Failed to parse locale overrides file %s: %s",
self._overrides_path,
exc,
)
self._overrides_file_mtime_ns = stat.st_mtime_ns
return False
content = self._overrides_path.read_text(encoding="utf-8")
except OSError as exc:
logging.warning(
"Failed to read locale overrides file %s: %s",
@@ -456,7 +444,27 @@ class JsonI18n:
)
return False
if (
not force
and stat.st_mtime_ns == self._overrides_file_mtime_ns
and content == self._overrides_file_content
):
return False
try:
payload = json.loads(content)
except json.JSONDecodeError as exc:
logging.warning(
"Failed to parse locale overrides file %s: %s",
self._overrides_path,
exc,
)
self._overrides_file_mtime_ns = stat.st_mtime_ns
self._overrides_file_content = content
return False
self._overrides_file_mtime_ns = stat.st_mtime_ns
self._overrides_file_content = content
self.set_locale_overrides(payload)
logging.info("Locale overrides reloaded from %s", self._overrides_path)
return True
+68
View File
@@ -112,6 +112,7 @@ class WebAppPaymentContext:
stars_price: Optional[int]
description: str
sale_mode: str
currency: str = "RUB"
traffic_gb: Optional[float] = None
hwid_valid_from: Optional[Any] = None
hwid_valid_until: Optional[Any] = None
@@ -125,6 +126,36 @@ ServiceFactory = Callable[[ServiceFactoryContext], Any]
WebhookPathGetter = Callable[[Any], str]
WebhookRoute = Callable[[Any], Awaitable[Any]]
WebAppPaymentFactory = Callable[[WebAppPaymentContext], Awaitable[Any]]
CurrencySupportResolver = Callable[[Any], Optional[Sequence[str]]]
def normalize_payment_currency_code(value: Any, default: str = "RUB") -> str:
text = str(value or "").strip().upper()
if not text:
text = str(default).strip().upper() if default is not None else ""
if not text:
return ""
aliases = {"RUR": "RUB", "STARS": "XTR", "STAR": "XTR"}
normalized = aliases.get(text, text)
return "".join(ch for ch in normalized if ch.isalnum() or ch in {"_", "-"}).strip("_-")
def parse_supported_currency_codes(value: Any) -> tuple[str, ...]:
if value is None:
return ()
if isinstance(value, str):
raw_items = value.replace(";", ",").split(",")
else:
raw_items = list(value)
currencies: list[str] = []
seen: set[str] = set()
for item in raw_items:
code = normalize_payment_currency_code(item, default="")
if not code or code in seen:
continue
seen.add(code)
currencies.append(code)
return tuple(currencies)
@dataclass(frozen=True)
@@ -158,6 +189,10 @@ class PaymentProviderSpec:
admin_only_manifest_key: Optional[str] = None
admin_only_config_attr: str = "ADMIN_ONLY_ENABLED"
admin_only_enabled: Optional[EnabledPredicate] = None
supported_currencies: Optional[Sequence[str]] = ("RUB",)
supported_currencies_resolver: Optional[CurrencySupportResolver] = None
currency_support_note: str = ""
currency_support_url: Optional[str] = None
@property
def settings_key(self) -> str:
@@ -237,6 +272,39 @@ class PaymentProviderSpec:
service = app.get(self.service_key) if hasattr(app, "get") else None
return bool(service and getattr(service, "configured", False))
def _currency_source(self, source: Any) -> Any:
if self.config_class is not None and self.service_key:
from .registry import get_provider_bundle
bundle = get_provider_bundle(self.service_key)
if bundle and bundle.config is not None:
return bundle.config
return source
def supported_currency_codes(self, source: Any = None) -> Optional[tuple[str, ...]]:
if self.price_source == "stars":
return ("XTR",)
source_for_currency = self._currency_source(source)
if self.supported_currencies_resolver is not None:
resolved = self.supported_currencies_resolver(source_for_currency)
if resolved is None:
return None
return parse_supported_currency_codes(resolved)
if self.supported_currencies is None:
return None
return parse_supported_currency_codes(self.supported_currencies)
def supports_currency(self, source: Any, currency: Any) -> bool:
supported = self.supported_currency_codes(source)
if supported is None:
return True
return normalize_payment_currency_code(currency) in supported
def is_usable_for_payment_currency(self, source: Any, currency: Any) -> bool:
if self.price_source == "stars":
return True
return self.supports_currency(source, currency)
def is_visible(self, source: Any, app: Any) -> bool:
return self.is_enabled(source) and self.is_service_configured(app)
+66 -5
View File
@@ -17,6 +17,10 @@ from bot.middlewares.i18n import JsonI18n
from bot.services.referral_service import ReferralService
from bot.services.subscription_service import SubscriptionService
from config.settings import Settings
from config.tariffs_config import (
default_currency_key_for_settings,
default_payment_currency_code_for_settings,
)
from db.dal import payment_dal
from .base import (
@@ -25,6 +29,7 @@ from .base import (
ProviderManifestField,
ServiceFactoryContext,
WebAppPaymentContext,
normalize_payment_currency_code,
provider_env_file,
provider_runtime_enabled,
)
@@ -49,6 +54,34 @@ from .shared import (
logger = logging.getLogger(__name__)
_LOG = "cryptopay"
CRYPTOPAY_FIAT_CURRENCIES = (
"USD",
"EUR",
"RUB",
"BYN",
"UAH",
"GBP",
"CNY",
"KZT",
"UZS",
"GEL",
"TRY",
"AMD",
"THB",
"INR",
"BRL",
"IDR",
"AZN",
"AED",
"PLN",
"ILS",
)
CRYPTOPAY_CRYPTO_ASSETS = ("USDT", "TON", "BTC", "ETH", "LTC", "BNB", "TRX", "USDC")
def _cryptopay_supported_currencies(config) -> tuple[str, ...]:
currency_type = str(getattr(config, "CURRENCY_TYPE", "fiat") or "fiat").strip().lower()
return CRYPTOPAY_CRYPTO_ASSETS if currency_type == "crypto" else CRYPTOPAY_FIAT_CURRENCIES
class CryptoPayConfig(ProviderEnvConfig):
@@ -159,11 +192,23 @@ class CryptoPayService:
sale_mode: str = "subscription",
url_kind: str = "bot",
hwid_quote: Optional[dict] = None,
currency: Optional[str] = None,
) -> Optional[str]:
if not self.configured or not self.client:
logging.error("CryptoPayService not configured")
return None
currency_code = normalize_payment_currency_code(currency or self.config.ASSET)
currency_type = str(self.config.CURRENCY_TYPE or "fiat").strip().lower()
supported = _cryptopay_supported_currencies(self.config)
if currency_code not in supported:
logging.error(
"CryptoPay currency %s is not supported for currency_type=%s",
currency_code,
currency_type,
)
return None
sale_base = sale_mode_base(sale_mode)
amounts = payment_record_amounts(months=months, sale_mode=sale_mode)
try:
@@ -172,7 +217,7 @@ class CryptoPayService:
{
"user_id": user_id,
"amount": float(amount),
"currency": self.config.ASSET,
"currency": currency_code,
"status": "pending_cryptopay",
"description": description,
"subscription_duration_months": (
@@ -212,9 +257,9 @@ class CryptoPayService:
try:
invoice = await self.client.create_invoice(
amount=amount,
currency_type=self.config.CURRENCY_TYPE,
fiat=self.config.ASSET if self.config.CURRENCY_TYPE == "fiat" else None,
asset=self.config.ASSET if self.config.CURRENCY_TYPE == "crypto" else None,
currency_type=currency_type,
fiat=currency_code if currency_type == "fiat" else None,
asset=currency_code if currency_type == "crypto" else None,
description=description,
payload=payload,
)
@@ -270,6 +315,14 @@ class CryptoPayService:
referral_service: ReferralService = app["referral_service"]
async with async_session_factory() as session:
payment = await payment_dal.get_payment_by_db_id(session, payment_db_id)
if not payment:
logging.error("CryptoPay webhook: payment %s not found.", payment_db_id)
return
if payment.status == "succeeded":
logging.info("CryptoPay webhook: payment %s already succeeded.", payment_db_id)
return
try:
await payment_dal.update_provider_payment_and_status(
session,
@@ -385,7 +438,7 @@ async def pay_crypto_callback_handler(
user_id=callback.from_user.id,
parts=parts,
subscription_service=cryptopay_service.subscription_service,
currency="rub",
currency=default_currency_key_for_settings(settings),
)
if not parts:
await notify_callback_parse_error(callback, translator)
@@ -400,6 +453,7 @@ async def pay_crypto_callback_handler(
description=payment_description,
sale_mode=parts.sale_mode,
hwid_quote=hwid_quote,
currency=default_payment_currency_code_for_settings(settings),
)
if invoice_url:
@@ -449,6 +503,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
description=ctx.description,
sale_mode=ctx.sale_mode,
url_kind="web",
currency=ctx.currency,
hwid_quote={
"valid_from": ctx.hwid_valid_from,
"valid_until": ctx.hwid_valid_until,
@@ -584,4 +639,10 @@ SPEC = PaymentProviderSpec(
config_class=CryptoPayConfig,
presentation_class=CryptoPayPresentation,
manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST,
supported_currencies_resolver=_cryptopay_supported_currencies,
currency_support_note=(
"Crypto Pay supports different sets for fiat invoices and crypto invoices; "
"CURRENCY_TYPE selects which set is active."
),
currency_support_url="https://help.crypt.bot/crypto-pay-api/",
)
+26 -7
View File
@@ -20,6 +20,10 @@ from bot.services.referral_service import ReferralService
from bot.services.subscription_service import SubscriptionService
from bot.utils.request_security import ip_in_allowlist, request_client_ip
from config.settings import Settings
from config.tariffs_config import (
default_currency_key_for_settings,
default_payment_currency_code_for_settings,
)
from db.dal import payment_dal
from .base import (
@@ -28,6 +32,7 @@ from .base import (
ProviderManifestField,
ServiceFactoryContext,
WebAppPaymentContext,
normalize_payment_currency_code,
provider_env_file,
provider_runtime_enabled,
)
@@ -49,12 +54,14 @@ from .shared import (
parse_payment_callback,
payment_failed,
payment_unavailable,
payment_units_for_activation,
post_json_request,
quote_hwid_callback_parts,
render_link_or_fail,
)
_LOG = "freekassa"
FREEKASSA_SUPPORTED_CURRENCIES = ("RUB", "USD", "EUR", "UAH", "KZT")
class FreeKassaConfig(ProviderEnvConfig):
@@ -143,7 +150,7 @@ class FreeKassaService(HttpClientMixin):
self.subscription_service = subscription_service
self.referral_service = referral_service
self.default_currency: str = (settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
self.default_currency: str = default_payment_currency_code_for_settings(settings).upper()
self.api_base_url: str = "https://api.fk.life/v1"
self._init_http_client(total_timeout=15)
@@ -206,7 +213,13 @@ class FreeKassaService(HttpClientMixin):
return False, {"message": "missing_ip"}
email = email or f"{user_id}@telegram.org"
currency_code = (currency or self.default_currency or "RUB").upper()
currency_code = normalize_payment_currency_code(currency or self.default_currency or "RUB")
if currency_code not in FREEKASSA_SUPPORTED_CURRENCIES:
return False, {
"message": "unsupported_currency",
"currency": currency_code,
"supported_currencies": list(FREEKASSA_SUPPORTED_CURRENCIES),
}
payload: Dict[str, Any] = {
"shopId": int(self.shop_id),
@@ -382,10 +395,10 @@ class FreeKassaService(HttpClientMixin):
)
return web.Response(status=500, text="processing_error")
months = payment.purchased_gb or payment.subscription_duration_months or 1
sale_mode = payment.sale_mode or (
"traffic" if self.settings.traffic_sale_mode else "subscription"
)
months = payment_units_for_activation(payment, sale_mode)
success_prefix: Optional[str] = None
if provider_payment_id:
@@ -476,7 +489,7 @@ async def pay_fk_callback_handler(
user_id=callback.from_user.id,
parts=parts,
subscription_service=freekassa_service.subscription_service,
currency="rub",
currency=default_currency_key_for_settings(settings),
)
if not parts:
await notify_callback_parse_error(callback, translator)
@@ -484,7 +497,7 @@ async def pay_fk_callback_handler(
currency_code = (
getattr(freekassa_service, "default_currency", None)
or settings.DEFAULT_CURRENCY_SYMBOL
or default_payment_currency_code_for_settings(settings)
or "RUB"
)
payment_description = describe_payment(translator, parts)
@@ -577,12 +590,13 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
service: FreeKassaService = ctx.request.app["freekassa_service"]
if not service or not service.configured or not service.payment_method_id:
return payment_unavailable()
currency = ctx.currency or service.default_currency
try:
payment = await create_webapp_payment_record(
ctx,
amount=ctx.price,
currency=service.default_currency,
currency=currency,
status="pending_freekassa",
provider="freekassa",
)
@@ -591,7 +605,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
user_id=ctx.user_id,
months=ctx.months,
amount=ctx.price,
currency=service.default_currency,
currency=currency,
payment_method_id=service.payment_method_id,
ip_address=service.server_ip,
extra_params={"us_method": service.payment_method_id},
@@ -761,4 +775,9 @@ SPEC = PaymentProviderSpec(
config_class=FreeKassaConfig,
presentation_class=FreeKassaPresentation,
manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST,
supported_currencies=FREEKASSA_SUPPORTED_CURRENCIES,
currency_support_note=(
"FreeKassa SCI documents the payment currency parameter as RUB, USD, EUR, UAH or KZT."
),
currency_support_url="https://docs.freekassa.net/",
)
+46 -5
View File
@@ -18,6 +18,10 @@ from bot.services.referral_service import ReferralService
from bot.services.subscription_service import SubscriptionService
from bot.utils.request_security import ip_in_allowlist, request_client_ip
from config.settings import Settings
from config.tariffs_config import (
default_currency_key_for_settings,
default_payment_currency_code_for_settings,
)
from db.dal import payment_dal
from .base import (
@@ -26,6 +30,8 @@ from .base import (
ProviderManifestField,
ServiceFactoryContext,
WebAppPaymentContext,
normalize_payment_currency_code,
parse_supported_currency_codes,
provider_env_file,
provider_runtime_enabled,
)
@@ -49,6 +55,7 @@ from .shared import (
parse_payment_callback,
payment_failed,
payment_unavailable,
payment_units_for_activation,
quote_hwid_callback_parts,
render_link_or_fail,
)
@@ -58,6 +65,10 @@ _LOG = "heleket"
_SUCCESS_STATUSES = {"paid", "paid_over"}
_FAILED_STATUSES = {"fail", "wrong_amount", "cancel", "system_fail"}
HELEKET_DEFAULT_SUPPORTED_CURRENCIES = (
"RUB,USD,EUR,USDT,USDC,BTC,ETH,LTC,TON,TRX,BNB,BCH,DASH,DAI,DOGE,"
"MATIC,SHIB,SOL,XMR,AVAX,BUSD,VERSE"
)
class HeleketConfig(ProviderEnvConfig):
@@ -82,6 +93,7 @@ class HeleketConfig(ProviderEnvConfig):
LIFETIME_SECONDS: int = Field(default=3600)
VERIFY_WEBHOOK_SIGNATURE: bool = Field(default=True)
TRUSTED_IPS: str = Field(default="31.133.220.8")
SUPPORTED_CURRENCIES: str = Field(default=HELEKET_DEFAULT_SUPPORTED_CURRENCIES)
@field_validator("LIFETIME_SECONDS", mode="before")
@classmethod
@@ -298,9 +310,18 @@ class HeleketService(HttpClientMixin):
logging.error("HeleketService is not configured. Cannot create payment link.")
return False, {"message": "service_not_configured"}
currency_code = normalize_payment_currency_code(currency or self.currency)
supported = parse_supported_currency_codes(self.config.SUPPORTED_CURRENCIES)
if supported and currency_code not in supported:
return False, {
"message": "unsupported_currency",
"currency": currency_code,
"supported_currencies": list(supported),
}
body: Dict[str, Any] = {
"amount": str(format_decimal_amount(amount)),
"currency": (currency or self.currency).upper(),
"currency": currency_code,
"order_id": str(payment_db_id),
"url_return": self.return_url,
"url_success": self.success_url,
@@ -469,10 +490,10 @@ class HeleketService(HttpClientMixin):
)
return web.Response(status=500, text="processing_error")
payment_units = payment.purchased_gb or payment.subscription_duration_months or 1
sale_mode = payment.sale_mode or (
"traffic" if self.settings.traffic_sale_mode else "subscription"
)
payment_units = payment_units_for_activation(payment, sale_mode)
outcome = await finalize_successful_payment(
PaymentSuccessRequest(
@@ -571,13 +592,13 @@ async def pay_heleket_callback_handler(
user_id=callback.from_user.id,
parts=parts,
subscription_service=heleket_service.subscription_service,
currency="rub",
currency=default_currency_key_for_settings(settings),
)
if not parts:
await notify_callback_parse_error(callback, translator)
return
currency_code = (heleket_service.currency or settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
currency_code = default_payment_currency_code_for_settings(settings)
payment_description = describe_payment(translator, parts)
record_payload = build_payment_record_payload(
user_id=callback.from_user.id,
@@ -631,7 +652,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
if not service or not service.configured:
return payment_unavailable()
currency = (service.currency or settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
currency = ctx.currency or default_payment_currency_code_for_settings(settings)
try:
payment = await create_webapp_payment_record(
ctx,
@@ -786,6 +807,18 @@ _CONFIG_MANIFEST = (
subsection="Heleket",
attr="CURRENCY",
),
ProviderManifestField(
"HELEKET_SUPPORTED_CURRENCIES",
"string",
"Supported currencies",
description=(
"Comma-separated invoice currencies allowed for Heleket in this shop. "
"Heleket can reject unsupported codes per account/service."
),
placeholder=HELEKET_DEFAULT_SUPPORTED_CURRENCIES,
subsection="Heleket",
attr="SUPPORTED_CURRENCIES",
),
ProviderManifestField(
"HELEKET_TO_CURRENCY",
"string",
@@ -858,4 +891,12 @@ SPEC = PaymentProviderSpec(
config_class=HeleketConfig,
presentation_class=HeleketPresentation,
manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST,
supported_currencies_resolver=lambda config: getattr(
config, "SUPPORTED_CURRENCIES", HELEKET_DEFAULT_SUPPORTED_CURRENCIES
),
currency_support_note=(
"Heleket supports crypto and fiat invoice currencies, but exact availability "
"can depend on service/account settings."
),
currency_support_url="https://doc.heleket.com/methods/payments/creating-invoice",
)
+48 -6
View File
@@ -14,6 +14,10 @@ from bot.middlewares.i18n import JsonI18n
from bot.services.referral_service import ReferralService
from bot.services.subscription_service import SubscriptionService
from config.settings import Settings
from config.tariffs_config import (
default_currency_key_for_settings,
default_payment_currency_code_for_settings,
)
from db.dal import payment_dal
from .base import (
@@ -22,6 +26,8 @@ from .base import (
ProviderManifestField,
ServiceFactoryContext,
WebAppPaymentContext,
normalize_payment_currency_code,
parse_supported_currency_codes,
provider_env_file,
provider_runtime_enabled,
)
@@ -45,6 +51,7 @@ from .shared import (
payment_failed,
payment_record_amounts,
payment_unavailable,
payment_units_for_activation,
post_json_request,
quote_hwid_callback_parts,
render_link_or_fail,
@@ -75,6 +82,7 @@ class PlategaConfig(ProviderEnvConfig):
CRYPTO_METHOD: int = Field(default=13)
RETURN_URL: Optional[str] = None
FAILED_URL: Optional[str] = None
SUPPORTED_CURRENCIES: str = Field(default="RUB")
@field_validator("MERCHANT_ID", "SECRET", "RETURN_URL", "FAILED_URL", mode="before")
@classmethod
@@ -228,9 +236,19 @@ class PlategaService(HttpClientMixin):
logging.error("PlategaService is not configured. Cannot create transaction.")
return False, {"message": "service_not_configured"}
currency_code = normalize_payment_currency_code(
currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
)
supported = parse_supported_currency_codes(self.config.SUPPORTED_CURRENCIES)
if supported and currency_code not in supported:
return False, {
"message": "unsupported_currency",
"currency": currency_code,
"supported_currencies": list(supported),
}
session = await self._get_session()
url = f"{self.base_url}/transaction/process"
currency_code = (currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
method_id = int(payment_method if payment_method is not None else self.payment_method)
body: Dict[str, Any] = {
@@ -303,10 +321,10 @@ class PlategaService(HttpClientMixin):
if payment.status == "succeeded" and status == "CONFIRMED":
return web.Response(text="ok")
payment_months = payment.purchased_gb or payment.subscription_duration_months or 1
sale_mode = payment.sale_mode or (
"traffic" if self.settings.traffic_sale_mode else "subscription"
)
payment_months = payment_units_for_activation(payment, sale_mode)
if status == "CONFIRMED":
if amount_raw is not None:
@@ -481,13 +499,13 @@ async def pay_platega_callback_handler(
user_id=callback.from_user.id,
parts=parts,
subscription_service=platega_service.subscription_service,
currency="rub",
currency=default_currency_key_for_settings(settings),
)
if not parts:
await notify_callback_parse_error(callback, translator)
return
currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
currency_code = default_payment_currency_code_for_settings(settings)
payment_description = describe_payment(translator, parts)
record_payload = build_payment_record_payload(
user_id=callback.from_user.id,
@@ -595,7 +613,7 @@ async def _create_webapp_payment(ctx: WebAppPaymentContext, variant: str) -> web
payment = await create_webapp_payment_record(
ctx,
amount=ctx.price,
currency=settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
currency=ctx.currency or settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
status="pending_platega",
provider="platega",
)
@@ -615,7 +633,7 @@ async def _create_webapp_payment(ctx: WebAppPaymentContext, variant: str) -> web
)
success, response_data = await service.create_transaction(
amount=ctx.price,
currency=settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
currency=ctx.currency or settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
description=ctx.description,
payload=payload,
payment_method=platega_method_id,
@@ -756,6 +774,18 @@ _CONFIG_MANIFEST = (
subsection="Platega",
attr="CRYPTO_METHOD",
),
ProviderManifestField(
"PLATEGA_SUPPORTED_CURRENCIES",
"string",
"Supported currencies",
description=(
"Comma-separated payment currencies enabled for your Platega merchant. "
"Public docs expose currency per method/limits but do not publish a fixed global list."
),
placeholder="RUB",
subsection="Platega",
attr="SUPPORTED_CURRENCIES",
),
ProviderManifestField(
"PLATEGA_RETURN_URL", "url", "Return URL", subsection="Platega", attr="RETURN_URL"
),
@@ -792,6 +822,12 @@ SBP_SPEC = PaymentProviderSpec(
presentation_class=PlategaSbpPresentation,
manifest_fields=_CONFIG_MANIFEST
+ _platega_presentation_manifest("Platega", "CreditCard", "PLATEGA_SBP"),
supported_currencies_resolver=lambda config: getattr(config, "SUPPORTED_CURRENCIES", "RUB"),
currency_support_note=(
"Platega currencies are merchant/method-specific; configure the codes "
"enabled for your account."
),
currency_support_url="https://docs.platega.io/",
)
CRYPTO_SPEC = PaymentProviderSpec(
@@ -817,6 +853,12 @@ CRYPTO_SPEC = PaymentProviderSpec(
config_class=PlategaConfig,
presentation_class=PlategaCryptoPresentation,
manifest_fields=_platega_presentation_manifest("Platega", "Bitcoin", "PLATEGA_CRYPTO"),
supported_currencies_resolver=lambda config: getattr(config, "SUPPORTED_CURRENCIES", "RUB"),
currency_support_note=(
"Platega currencies are merchant/method-specific; configure the codes "
"enabled for your account."
),
currency_support_url="https://docs.platega.io/",
)
SPECS = (SBP_SPEC, CRYPTO_SPEC)
+47 -5
View File
@@ -16,6 +16,10 @@ from bot.middlewares.i18n import JsonI18n
from bot.services.referral_service import ReferralService
from bot.services.subscription_service import SubscriptionService
from config.settings import Settings
from config.tariffs_config import (
default_currency_key_for_settings,
default_payment_currency_code_for_settings,
)
from db.dal import payment_dal
from .base import (
@@ -24,6 +28,8 @@ from .base import (
ProviderManifestField,
ServiceFactoryContext,
WebAppPaymentContext,
normalize_payment_currency_code,
parse_supported_currency_codes,
provider_env_file,
provider_runtime_enabled,
)
@@ -46,6 +52,7 @@ from .shared import (
parse_payment_callback,
payment_failed,
payment_unavailable,
payment_units_for_activation,
post_json_request,
quote_hwid_callback_parts,
render_link_or_fail,
@@ -68,6 +75,7 @@ class SeverPayConfig(ProviderEnvConfig):
RETURN_URL: Optional[str] = None
BASE_URL: str = Field(default="https://severpay.io/api/merchant")
LIFETIME_MINUTES: Optional[int] = None
SUPPORTED_CURRENCIES: str = Field(default="RUB,USD")
@field_validator("MID", "LIFETIME_MINUTES", mode="before")
@classmethod
@@ -200,9 +208,19 @@ class SeverPayService(HttpClientMixin):
logging.error("SeverPayService is not configured. Cannot create payment.")
return False, {"message": "service_not_configured"}
currency_code = normalize_payment_currency_code(
currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
)
supported = parse_supported_currency_codes(self.config.SUPPORTED_CURRENCIES)
if supported and currency_code not in supported:
return False, {
"message": "unsupported_currency",
"currency": currency_code,
"supported_currencies": list(supported),
}
session = await self._get_session()
url = f"{self.base_url}/payin/create"
currency_code = (currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
body = {
"order_id": str(payment_db_id),
@@ -270,12 +288,19 @@ class SeverPayService(HttpClientMixin):
return web.json_response({"status": False, "msg": "payment_not_found"}, status=404)
resolved_provider_id = provider_payment_id or str(payment.payment_id)
payment_months = payment.purchased_gb or payment.subscription_duration_months or 1
sale_mode = payment.sale_mode or (
"traffic" if self.settings.traffic_sale_mode else "subscription"
)
payment_months = payment_units_for_activation(payment, sale_mode)
if status == "success":
if payment.status == "succeeded":
logging.info(
"SeverPay webhook: payment %s already succeeded.",
payment.payment_id,
)
return web.json_response({"status": True})
try:
await payment_dal.update_provider_payment_and_status(
session,
@@ -420,13 +445,13 @@ async def pay_severpay_callback_handler(
user_id=callback.from_user.id,
parts=parts,
subscription_service=severpay_service.subscription_service,
currency="rub",
currency=default_currency_key_for_settings(settings),
)
if not parts:
await notify_callback_parse_error(callback, translator)
return
currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
currency_code = default_payment_currency_code_for_settings(settings)
payment_description = describe_payment(translator, parts)
record_payload = build_payment_record_payload(
user_id=callback.from_user.id,
@@ -495,7 +520,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
if not service or not service.configured:
return payment_unavailable()
currency = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
currency = ctx.currency or settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
try:
payment = await create_webapp_payment_record(
ctx,
@@ -619,6 +644,18 @@ _CONFIG_MANIFEST = (
max=4320,
attr="LIFETIME_MINUTES",
),
ProviderManifestField(
"SEVERPAY_SUPPORTED_CURRENCIES",
"string",
"Supported currencies",
description=(
"Comma-separated currencies enabled for your SeverPay merchant. "
"The public PayIn docs show USD examples but do not publish a fixed global list."
),
placeholder="RUB,USD",
subsection="SeverPay",
attr="SUPPORTED_CURRENCIES",
),
)
@@ -643,4 +680,9 @@ SPEC = PaymentProviderSpec(
config_class=SeverPayConfig,
presentation_class=SeverPayPresentation,
manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST,
supported_currencies_resolver=lambda config: getattr(config, "SUPPORTED_CURRENCIES", "RUB,USD"),
currency_support_note=(
"SeverPay PayIn requires a currency; keep this list aligned with your merchant account."
),
currency_support_url="https://docs.severpay.io/ru/payin/create",
)
@@ -36,10 +36,12 @@ from .common import (
json_error,
make_translator,
mark_payment_failed_creation,
parse_positive_int_units,
payment_failed,
payment_link_response,
payment_record_amounts,
payment_unavailable,
payment_units_for_activation,
sale_mode_base,
sale_mode_is_hwid_devices,
sale_mode_is_traffic,
@@ -103,6 +105,7 @@ __all__ = [
"lookup_payment_by_order_or_provider_id",
"make_translator",
"mark_payment_failed_creation",
"parse_positive_int_units",
"notify_admins_payment_received",
"notify_callback_parse_error",
"notify_payment_gateway_failure",
@@ -114,6 +117,7 @@ __all__ = [
"payment_link_message_text",
"payment_link_response",
"payment_record_amounts",
"payment_units_for_activation",
"payment_unavailable",
"post_json_request",
"quote_hwid_callback_parts",
@@ -20,6 +20,7 @@ from .common import (
build_payment_description,
format_human_units,
mark_payment_failed_creation,
parse_positive_int_units,
sale_mode_base,
sale_mode_is_hwid_devices,
sale_mode_tariff_key,
@@ -124,10 +125,13 @@ async def quote_hwid_callback_parts(
) -> tuple[Optional[PaymentCallbackParts], Optional[dict]]:
if not sale_mode_is_hwid_devices(parts.sale_mode):
return parts, None
device_count = parse_positive_int_units(parts.months)
if device_count is None:
return None, None
quote = await subscription_service.quote_hwid_device_topup(
session,
user_id=user_id,
device_count=int(parts.months),
device_count=device_count,
tariff_key=sale_mode_tariff_key(parts.sale_mode),
renewal=sale_mode_base(parts.sale_mode) == "hwid_devices_renewal",
currency=currency,
@@ -135,7 +139,7 @@ async def quote_hwid_callback_parts(
if not quote:
return None, None
quoted_parts = PaymentCallbackParts(
months=parts.months,
months=device_count,
price=float(quote.get("price") or 0),
sale_mode=parts.sale_mode,
)
+33 -1
View File
@@ -1,7 +1,7 @@
from __future__ import annotations
from dataclasses import dataclass
from decimal import ROUND_HALF_UP, Decimal
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
from typing import Any, Callable, Optional
from aiohttp import web
@@ -36,6 +36,20 @@ def decimal_amounts_equal(left: Any, right: Any, places: int = 2) -> bool:
return format_decimal_amount(left, places) == format_decimal_amount(right, places)
def parse_positive_int_units(value: Any) -> Optional[int]:
"""Return a positive integer only when the input represents whole units exactly."""
if isinstance(value, bool):
return None
try:
decimal_value = Decimal(str(value).strip())
except (InvalidOperation, ValueError):
return None
if not decimal_value.is_finite() or decimal_value != decimal_value.to_integral_value():
return None
integer_value = int(decimal_value)
return integer_value if integer_value > 0 else None
def format_human_units(value: Any) -> str:
"""Render numeric units the way the UI expects: integers w/o decimals, floats with %g."""
numeric = float(value)
@@ -164,6 +178,24 @@ def payment_record_amounts(
)
def payment_units_for_activation(payment: Any, sale_mode: str) -> Any:
"""Resolve purchased units from a payment record for webhook activation."""
base = sale_mode_base(sale_mode)
if sale_mode_is_traffic(base):
return (
getattr(payment, "purchased_gb", None)
or getattr(payment, "subscription_duration_months", None)
or 1
)
if sale_mode_is_hwid_devices(base):
return (
getattr(payment, "purchased_hwid_devices", None)
or getattr(payment, "subscription_duration_months", None)
or 1
)
return getattr(payment, "subscription_duration_months", None) or 1
def json_error(status: int, code: str, message: str) -> web.Response:
return web.json_response({"ok": False, "error": code, "message": message}, status=status)
@@ -16,7 +16,13 @@ from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
from db.dal import payment_dal, user_dal
from db.models import Payment, User
from .common import Translator, format_human_units, make_translator, sale_mode_base
from .common import (
Translator,
format_human_units,
make_translator,
sale_mode_base,
sale_mode_tariff_key,
)
_TRAFFIC_MODES = {"traffic", "traffic_package", "topup", "premium_topup"}
_HWID_DEVICE_MODES = {"hwid_device", "hwid_devices", "hwid_devices_renewal"}
@@ -304,6 +310,7 @@ async def finalize_successful_payment(
activation_months or 1,
current_payment_db_id=req.payment.payment_id,
skip_if_active_before_payment=False,
tariff_key=sale_mode_tariff_key(req.sale_mode),
)
await req.session.commit()
except Exception:
@@ -6,6 +6,7 @@ from typing import Any, Optional
from aiogram import Bot
from sqlalchemy.ext.asyncio import AsyncSession
from bot.services.user_email_notifications import send_user_notification_email
from db.dal import payment_dal, user_dal
from db.models import Payment
@@ -55,11 +56,21 @@ async def notify_user_payment_failed(
db_user.language_code if db_user and db_user.language_code else settings.DEFAULT_LANGUAGE
)
translator = make_translator(i18n, language)
message_text = translator(message_key)
try:
await bot.send_message(payment.user_id, translator(message_key))
await bot.send_message(payment.user_id, message_text)
except Exception:
logging.exception(
"Webhook helper: failed to notify user %s about %s.",
payment.user_id,
message_key,
)
if db_user:
await send_user_notification_email(
settings=settings,
i18n=i18n,
user=db_user,
subject_key="email_payment_failed_subject",
message_text=message_text,
dashboard_url=(getattr(settings, "SUBSCRIPTION_MINI_APP_URL", "") or None),
)
+10 -3
View File
@@ -143,6 +143,14 @@ class StarsService:
i18n_data: dict,
sale_mode: str = "subscription",
) -> None:
payment = await payment_dal.get_payment_by_db_id(session, payment_db_id)
if not payment:
logging.error("Stars: payment %s not found.", payment_db_id)
return
if payment.status == "succeeded":
logging.info("Stars: payment %s already succeeded.", payment_db_id)
return
try:
payment_record = await payment_dal.update_provider_payment_and_status(
session,
@@ -162,9 +170,6 @@ class StarsService:
else int(message.from_user.id)
)
payment = await payment_dal.get_payment_by_db_id(session, payment_db_id)
if not payment:
logging.error("Stars: payment %s vanished after status update.", payment_db_id)
return
await finalize_successful_payment(
PaymentSuccessRequest(
@@ -479,4 +484,6 @@ SPEC = PaymentProviderSpec(
telegram_emoji="",
presentation_class=StarsPresentation,
manifest_fields=_PRESENTATION_MANIFEST,
supported_currencies=("XTR",),
currency_support_note="Telegram Stars use Telegram's XTR currency and separate Stars prices.",
)
+27 -5
View File
@@ -19,6 +19,10 @@ from bot.services.referral_service import ReferralService
from bot.services.subscription_service import SubscriptionService
from bot.utils.request_security import ip_in_allowlist, request_client_ip
from config.settings import Settings
from config.tariffs_config import (
default_currency_key_for_settings,
default_payment_currency_code_for_settings,
)
from db.dal import payment_dal
from .base import (
@@ -27,6 +31,7 @@ from .base import (
ProviderManifestField,
ServiceFactoryContext,
WebAppPaymentContext,
normalize_payment_currency_code,
provider_env_file,
provider_runtime_enabled,
)
@@ -52,6 +57,7 @@ from .shared import (
payment_link_response,
payment_record_amounts,
payment_unavailable,
payment_units_for_activation,
post_json_request,
quote_hwid_callback_parts,
render_link_or_fail,
@@ -62,6 +68,7 @@ from .shared import (
router = Router(name="user_subscription_payments_wata_router")
_LOG = "wata"
WATA_SUPPORTED_CURRENCIES = ("RUB", "USD", "EUR")
_WATA_IN_PROGRESS_STATUSES = {"created", "pending"}
_WATA_LINK_OPENED_STATUSES = {"opened", "open"}
_WATA_LINK_DEFAULT_TTL_MINUTES = 15
@@ -257,13 +264,23 @@ class WataService(HttpClientMixin):
logging.error("WataService is not configured. Cannot create payment link.")
return False, {"message": "service_not_configured"}
currency_code = normalize_payment_currency_code(
currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
)
if currency_code not in WATA_SUPPORTED_CURRENCIES:
return False, {
"message": "unsupported_currency",
"currency": currency_code,
"supported_currencies": list(WATA_SUPPORTED_CURRENCIES),
}
session = await self._get_session()
expires_at = (
datetime.now(timezone.utc) + timedelta(minutes=self.payment_link_ttl_minutes)
).replace(microsecond=0)
body: Dict[str, Any] = {
"amount": float(format_decimal_amount(amount)),
"currency": (currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper(),
"currency": currency_code,
"description": description,
"orderId": str(payment_db_id),
"successRedirectUrl": self.return_url,
@@ -531,10 +548,10 @@ class WataService(HttpClientMixin):
)
return None
payment_units = payment.purchased_gb or payment.subscription_duration_months or 1
sale_mode = payment.sale_mode or (
"traffic" if self.settings.traffic_sale_mode else "subscription"
)
payment_units = payment_units_for_activation(payment, sale_mode)
outcome = await finalize_successful_payment(
PaymentSuccessRequest(
bot=self.bot,
@@ -862,13 +879,13 @@ async def pay_wata_callback_handler(
user_id=callback.from_user.id,
parts=parts,
subscription_service=wata_service.subscription_service,
currency="rub",
currency=default_currency_key_for_settings(settings),
)
if not parts:
await notify_callback_parse_error(callback, translator)
return
currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
currency_code = default_payment_currency_code_for_settings(settings)
payment_description = describe_payment(translator, parts)
reuse_amounts = payment_record_amounts(months=parts.months, sale_mode=parts.sale_mode)
@@ -955,7 +972,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
if not service or not service.configured:
return payment_unavailable()
currency = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
currency = ctx.currency or settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
reuse_amounts = payment_record_amounts(
months=ctx.months,
@@ -1189,4 +1206,9 @@ SPEC = PaymentProviderSpec(
config_class=WataConfig,
presentation_class=WataPresentation,
manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST,
supported_currencies=WATA_SUPPORTED_CURRENCIES,
currency_support_note=(
"WATA H2H payment links and widget document RUB, USD and EUR as payment currencies."
),
currency_support_url="https://wata.pro/api",
)
+90 -27
View File
@@ -35,10 +35,15 @@ from bot.services.lknpd_service import LknpdService
from bot.services.panel_api_service import PanelApiService
from bot.services.referral_service import ReferralService
from bot.services.subscription_service import SubscriptionService
from bot.services.user_email_notifications import send_user_notification_email
from bot.utils.config_link import prepare_config_links
from bot.utils.install_links import ensure_user_install_guide_links
from bot.utils.request_security import ip_in_allowlist, request_client_ip
from config.settings import Settings
from config.tariffs_config import (
default_currency_key_for_settings,
default_payment_currency_code_for_settings,
)
from db.dal import payment_dal, user_billing_dal, user_dal
from db.models import Payment
@@ -48,6 +53,7 @@ from .base import (
ProviderManifestField,
ServiceFactoryContext,
WebAppPaymentContext,
normalize_payment_currency_code,
provider_env_file,
provider_runtime_enabled,
)
@@ -63,6 +69,7 @@ from .shared import (
make_translator,
mark_payment_failed_creation,
notify_admins_payment_received,
parse_positive_int_units,
payment_failed,
payment_link_response,
payment_record_amounts,
@@ -74,6 +81,9 @@ from .shared import (
from .shared import (
sale_mode_base as _sale_mode_base,
)
from .shared import (
sale_mode_tariff_key as _sale_mode_tariff_key,
)
class YooKassaConfig(ProviderEnvConfig):
@@ -144,10 +154,12 @@ class YooKassaService:
bot_username_for_default_return: Optional[str] = None,
settings_obj: Optional[Settings] = None,
config: Optional[YooKassaConfig] = None,
subscription_service: Optional[SubscriptionService] = None,
):
self.settings = settings_obj
self.config = config or YooKassaConfig()
self.subscription_service = subscription_service
self._bot_username_for_default_return = bot_username_for_default_return
self._configured_return_url_override = configured_return_url
self._sdk_configured_for = (
@@ -169,9 +181,7 @@ class YooKassaService:
@property
def configured(self) -> bool:
if not (
provider_runtime_enabled(self.config)
and self.config.SHOP_ID
and self.config.SECRET_KEY
provider_runtime_enabled(self.config) and self.config.SHOP_ID and self.config.SECRET_KEY
):
return False
self._ensure_sdk_configured()
@@ -229,6 +239,11 @@ class YooKassaService:
"internal_message": "Service settings (Settings object) not initialized.",
}
currency = normalize_payment_currency_code(currency)
if currency != "RUB":
logging.error("YooKassa currency %s is not supported by this integration", currency)
return None
customer_contact_for_receipt = {}
if receipt_email:
customer_contact_for_receipt["email"] = receipt_email
@@ -444,11 +459,17 @@ def _resolve_yookassa_activation_amounts(
traffic_amount_gb = (
float(traffic_gb_raw) if _metadata_value_present(traffic_gb_raw) else subscription_months
)
hwid_devices_count = (
int(float(hwid_devices_raw))
if _metadata_value_present(hwid_devices_raw)
else (int(subscription_months) if _is_hwid_device_sale_base(sale_mode_base) else 0)
)
hwid_devices_count = 0
if _metadata_value_present(hwid_devices_raw):
parsed_hwid_devices = parse_positive_int_units(hwid_devices_raw)
if parsed_hwid_devices is None:
raise ValueError("Invalid HWID device count")
hwid_devices_count = parsed_hwid_devices
elif _is_hwid_device_sale_base(sale_mode_base):
parsed_hwid_devices = parse_positive_int_units(subscription_months_raw)
if parsed_hwid_devices is None:
raise ValueError("Invalid HWID device count")
hwid_devices_count = parsed_hwid_devices
if sale_mode_base == "subscription":
months_for_activation = int(subscription_months)
@@ -742,6 +763,7 @@ async def process_successful_payment(
months_for_activation or int(subscription_months) or 1,
current_payment_db_id=payment_db_id,
skip_if_active_before_payment=False,
tariff_key=_sale_mode_tariff_key(sale_mode),
)
applied_referee_bonus_days_from_referral: Optional[int] = None
if referral_bonus_info and referral_bonus_info.get("referee_new_end_date"):
@@ -873,7 +895,10 @@ async def process_successful_payment(
i18n=i18n,
user_id=user_id,
amount=payment_value,
currency=settings.DEFAULT_CURRENCY_SYMBOL,
currency=amount_data.get(
"currency",
default_payment_currency_code_for_settings(settings),
),
months_for_admin=int(subscription_months) if sale_mode_base == "subscription" else 0,
traffic_gb_for_admin=(
traffic_amount_gb if is_traffic_sale_base(sale_mode_base) else None
@@ -942,7 +967,20 @@ async def process_cancelled_payment(
user_lang = db_user.language_code
_ = lambda key, **kwargs: i18n.gettext(user_lang, key, **kwargs)
await bot.send_message(user_id, _("payment_failed"))
message_text = _("payment_failed")
try:
await bot.send_message(user_id, message_text)
except Exception:
logging.exception("Failed to notify YooKassa user %s about cancelled payment.", user_id)
if db_user:
await send_user_notification_email(
settings=settings,
i18n=i18n,
user=db_user,
subject_key="email_payment_failed_subject",
message_text=message_text,
dashboard_url=(settings.SUBSCRIPTION_MINI_APP_URL or None),
)
except Exception as e_process_cancel:
logging.error(
@@ -1195,13 +1233,32 @@ async def yookassa_webhook_route(request: web.Request):
get_back_to_payment_methods_keyboard,
)
await bot.send_message(
chat_id=user_id,
text=_("payment_method_bound_success"),
reply_markup=get_back_to_payment_methods_keyboard(
i18n_lang, i18n_instance
),
)
message_text = _("payment_method_bound_success")
try:
await bot.send_message(
chat_id=user_id,
text=message_text,
reply_markup=get_back_to_payment_methods_keyboard(
i18n_lang, i18n_instance
),
)
except Exception:
logging.exception(
"Failed to notify user %s "
"about payment method binding.",
user_id,
)
if db_user:
await send_user_notification_email(
settings=settings,
i18n=i18n_instance,
user=db_user,
subject_key="email_payment_method_bound_subject",
message_text=message_text,
dashboard_url=(
settings.SUBSCRIPTION_MINI_APP_URL or None
),
)
except Exception:
pass
# Attempt to cancel the authorization to avoid charge hold
@@ -1327,9 +1384,7 @@ async def _initiate_yk_payment(
"purchased_gb": float(months)
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else None,
"purchased_hwid_devices": int(months)
if sale_base in HWID_DEVICE_SALE_BASES
else None,
"purchased_hwid_devices": int(months) if sale_base in HWID_DEVICE_SALE_BASES else None,
"hwid_valid_from": hwid_quote.get("valid_from") if hwid_quote else None,
"hwid_valid_until": hwid_quote.get("valid_until") if hwid_quote else None,
"hwid_pricing_period_months": hwid_quote.get("pricing_period_months")
@@ -1660,7 +1715,7 @@ async def pay_yk_callback_handler(
user_id=callback.from_user.id,
parts=PaymentCallbackParts(months=months, price=price_rub, sale_mode=sale_mode),
subscription_service=yookassa_service.subscription_service,
currency="rub",
currency=default_currency_key_for_settings(settings),
)
if not quoted_parts:
try:
@@ -1671,7 +1726,7 @@ async def pay_yk_callback_handler(
months = quoted_parts.months
price_rub = quoted_parts.price
user_id = callback.from_user.id
currency_code_for_yk = "RUB"
currency_code_for_yk = default_payment_currency_code_for_settings(settings)
autopay_enabled = bool(
settings.yookassa_autopayments_active
and _sale_mode_base(sale_mode) == "subscription"
@@ -1809,7 +1864,7 @@ async def pay_yk_new_card_handler(
months, price_rub, sale_mode = parsed
user_id = callback.from_user.id
currency_code_for_yk = "RUB"
currency_code_for_yk = default_payment_currency_code_for_settings(settings)
autopay_enabled = bool(
settings.yookassa_autopayments_active
and _sale_mode_base(sale_mode) == "subscription"
@@ -2112,7 +2167,7 @@ async def pay_yk_use_saved_handler(
pass
return
currency_code_for_yk = "RUB"
currency_code_for_yk = default_payment_currency_code_for_settings(settings)
await _initiate_yk_payment(
callback,
@@ -2218,7 +2273,7 @@ async def payment_method_bind(
metadata = {"user_id": str(callback.from_user.id), "bind_only": "1"}
resp = await yookassa_service.create_payment(
amount=1.00,
currency="RUB",
currency=default_payment_currency_code_for_settings(settings),
description="Bind card",
metadata=metadata,
receipt_email=yookassa_service.config.DEFAULT_RECEIPT_EMAIL,
@@ -2684,6 +2739,7 @@ def create_service(ctx: ServiceFactoryContext) -> YooKassaService:
bot_username_for_default_return=ctx.bot_username_for_default_return,
settings_obj=ctx.settings,
config=config,
subscription_service=ctx.subscription_service,
)
@@ -2691,6 +2747,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
service: YooKassaService = ctx.request.app["yookassa_service"]
if not service or not service.configured:
return payment_unavailable()
currency = (ctx.currency or "RUB").upper()
try:
amounts = payment_record_amounts(
@@ -2701,7 +2758,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
payment = await create_webapp_payment_record(
ctx,
amount=ctx.price,
currency="RUB",
currency=currency,
status="pending_yookassa",
provider="yookassa",
)
@@ -2724,7 +2781,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
metadata["tariff_key"] = amounts.tariff_key
response = await service.create_payment(
amount=ctx.price,
currency="RUB",
currency=currency,
description=ctx.description,
metadata=metadata,
receipt_email=service.config.DEFAULT_RECEIPT_EMAIL,
@@ -2886,4 +2943,10 @@ SPEC = PaymentProviderSpec(
config_class=YooKassaConfig,
presentation_class=YooKassaPresentation,
manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST,
supported_currencies=("RUB",),
currency_support_note=(
"YooKassa public payment API examples and limits are RUB-based; "
"treat non-RUB as unsupported unless your YooKassa contract confirms otherwise."
),
currency_support_url="https://yookassa.ru/developers/payment-acceptance/integration-scenarios/smart-payment",
)
+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,711 @@
import asyncio
import hashlib
import hmac
import json
import logging
import os
import re
import shutil
import subprocess
import tempfile
import zipfile
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path, PurePosixPath
from typing import Any, Optional
from sqlalchemy import inspect, text
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import create_async_engine
from bot.services.backup_archive import (
BACKUP_APP_ID,
BACKUP_FILENAME_PREFIX,
BACKUP_FORMAT_VERSION,
BACKUP_MANIFEST_NAME,
attach_archive_integrity,
backup_filename_timestamp,
build_file_records,
write_manifest,
write_zip_from_directory,
)
from bot.services.backup_worker import (
DEFAULT_COMPOSE_EXCLUDED_DIRS,
)
from config.settings import Settings
from db.migrator import MIGRATIONS, run_database_migrations
from db.models import Base
logger = logging.getLogger(__name__)
BACKUP_UPLOAD_MAX_BYTES = 2 * 1024 * 1024 * 1024
BACKUP_MAX_MEMBERS = 20_000
BACKUP_MAX_MEMBER_BYTES = 4 * 1024 * 1024 * 1024
BACKUP_MAX_UNCOMPRESSED_BYTES = 16 * 1024 * 1024 * 1024
BACKUP_MAX_COMPOSE_BYTES = 1024 * 1024 * 1024
BACKUP_MAX_COMPOSE_MEMBER_BYTES = 256 * 1024 * 1024
BACKUP_MAX_COMPRESSION_RATIO = 200
BACKUP_ZIP_BOMB_MIN_BYTES = 100 * 1024 * 1024
COMPOSE_PRE_RESTORE_PREFIX = "minishop-pre-restore-"
SAFE_ARCHIVE_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.@+-]{0,220}\.zip$")
DB_RESTORE_MIGRATION_ADVISORY_LOCK_ID = 817512404897421337
def _applied_migration_ids(connection: Connection) -> set[str]:
inspector = inspect(connection)
if "schema_migrations" not in inspector.get_table_names():
return set()
return {row[0] for row in connection.execute(text("SELECT id FROM schema_migrations"))}
def _create_missing_tables_and_migrate(connection: Connection) -> list[str]:
before = _applied_migration_ids(connection)
Base.metadata.create_all(connection)
run_database_migrations(connection)
after = _applied_migration_ids(connection)
newly_applied = after - before
return [migration.id for migration in MIGRATIONS if migration.id in newly_applied]
class BackupArchiveError(ValueError):
"""The selected archive cannot be used for restore."""
class BackupRestoreError(RuntimeError):
"""Restore command failed after archive validation."""
@dataclass
class BackupArchiveInfo:
name: str
path: Path
size_bytes: int
modified_at: datetime
created_at: Optional[str] = None
created_at_local: Optional[str] = None
has_database: bool = False
has_compose: bool = False
database_name: Optional[str] = None
compose_files_count: int = 0
warnings: list[str] = field(default_factory=list)
manifest: dict[str, Any] = field(default_factory=dict)
def to_payload(self) -> dict[str, Any]:
return {
"name": self.name,
"size_bytes": self.size_bytes,
"modified_at": self.modified_at.isoformat(),
"created_at": self.created_at,
"created_at_local": self.created_at_local,
"has_database": self.has_database,
"has_compose": self.has_compose,
"database_name": self.database_name,
"compose_files_count": self.compose_files_count,
"warnings": self.warnings,
"manifest": self.manifest,
}
@dataclass
class BackupRestoreResult:
archive_name: str
started_at: datetime
completed_at: datetime
database_restored: bool = False
compose_files_restored: int = 0
compose_target_dir: Optional[str] = None
compose_pre_restore_archive: Optional[str] = None
database_migrations_applied: list[str] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
def to_payload(self) -> dict[str, Any]:
return {
"archive_name": self.archive_name,
"started_at": self.started_at.isoformat(),
"completed_at": self.completed_at.isoformat(),
"database_restored": self.database_restored,
"compose_files_restored": self.compose_files_restored,
"compose_target_dir": self.compose_target_dir,
"compose_pre_restore_archive": self.compose_pre_restore_archive,
"database_migrations_applied": self.database_migrations_applied,
"warnings": self.warnings,
}
class BackupRestoreService:
def __init__(self, settings: Settings):
self.settings = settings
def backup_dir(self) -> Path:
path = Path(self.settings.BACKUP_DIR).expanduser()
path.mkdir(parents=True, exist_ok=True)
return path
def list_archives(self) -> list[BackupArchiveInfo]:
backup_dir = self.backup_dir()
archives = []
for path in backup_dir.glob("*.zip"):
if not path.is_file():
continue
try:
archives.append(self.inspect_archive(path))
except BackupArchiveError as exc:
logger.warning("Skipping invalid backup archive %s: %s", path, exc)
return sorted(archives, key=lambda item: item.modified_at, reverse=True)
def archive_path_for_name(self, archive_name: str) -> Path:
raw_name = str(archive_name or "").strip()
safe_name = Path(raw_name).name
if not raw_name or safe_name != raw_name or not SAFE_ARCHIVE_NAME_RE.fullmatch(safe_name):
raise BackupArchiveError("Invalid archive name")
backup_dir = self.backup_dir().resolve()
archive_path = (backup_dir / safe_name).resolve()
try:
archive_path.relative_to(backup_dir)
except ValueError as exc:
raise BackupArchiveError("Archive path escapes backup directory") from exc
if not archive_path.is_file():
raise BackupArchiveError("Archive does not exist")
return archive_path
def inspect_archive(self, archive_path: Path) -> BackupArchiveInfo:
if not zipfile.is_zipfile(archive_path):
raise BackupArchiveError("Archive is not a valid ZIP file")
stat = archive_path.stat()
warnings: list[str] = []
with zipfile.ZipFile(archive_path) as archive:
self._validate_zip_members(archive.infolist())
manifest = self._read_manifest(archive)
has_database = self._find_database_dump_member(archive) is not None
compose_members = self._compose_file_members(archive)
manifest_warnings = manifest.get("warnings")
if isinstance(manifest_warnings, list):
warnings.extend(str(item) for item in manifest_warnings if item)
postgres = manifest.get("postgres") if isinstance(manifest.get("postgres"), dict) else {}
compose = manifest.get("compose") if isinstance(manifest.get("compose"), dict) else {}
return BackupArchiveInfo(
name=archive_path.name,
path=archive_path,
size_bytes=int(stat.st_size),
modified_at=datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc),
created_at=str(manifest.get("created_at") or "") or None,
created_at_local=str(manifest.get("created_at_local") or "") or None,
has_database=has_database,
has_compose=bool(compose_members),
database_name=str(postgres.get("database") or "") or None,
compose_files_count=int(compose.get("files_count") or len(compose_members)),
warnings=warnings,
manifest=manifest,
)
def import_uploaded_archive(
self,
temp_path: Path,
original_filename: str = "",
) -> BackupArchiveInfo:
self._validate_archive_for_restore(temp_path)
digest = self._file_digest(temp_path)
stamp = backup_filename_timestamp()
archive_name = f"{BACKUP_FILENAME_PREFIX}uploaded-{stamp}-{digest}.zip"
target_path = self._unique_archive_path(archive_name)
temp_path.replace(target_path)
return self.inspect_archive(target_path)
async def restore_archive(
self,
archive_name: str,
*,
restore_database: bool,
restore_compose: bool,
) -> BackupRestoreResult:
return await asyncio.to_thread(
self.restore_archive_sync,
archive_name,
restore_database=restore_database,
restore_compose=restore_compose,
)
def restore_archive_sync(
self,
archive_name: str,
*,
restore_database: bool,
restore_compose: bool,
) -> BackupRestoreResult:
if not restore_database and not restore_compose:
raise BackupArchiveError("Select at least one restore target")
archive_path = self.archive_path_for_name(archive_name)
self._validate_archive_for_restore(archive_path)
started_at = datetime.now(timezone.utc)
warnings: list[str] = []
with tempfile.TemporaryDirectory(
prefix=f"restore-{archive_path.stem}-",
dir=self.backup_dir(),
) as tmp:
temp_dir = Path(tmp)
with zipfile.ZipFile(archive_path) as archive:
self._validate_zip_members(archive.infolist())
db_member = self._find_database_dump_member(archive) if restore_database else None
compose_members = self._compose_file_members(archive) if restore_compose else []
if restore_database and db_member is None:
raise BackupArchiveError("Archive does not contain a database dump")
if restore_compose and not compose_members:
raise BackupArchiveError("Archive does not contain compose files")
compose_target_dir: Optional[Path] = None
compose_pre_restore_archive: Optional[Path] = None
if restore_compose:
compose_target_dir = self._compose_restore_target_dir()
self._assert_compose_target_writable(compose_target_dir)
compose_pre_restore_archive = self._snapshot_current_compose(compose_target_dir)
database_restored = False
database_migrations_applied: list[str] = []
if db_member is not None:
dump_path = self._extract_database_dump(archive, db_member, temp_dir)
self._run_pg_restore(dump_path)
database_migrations_applied = self._run_post_restore_migrations()
database_restored = True
compose_files_restored = 0
if compose_target_dir is not None:
compose_files_restored = self._restore_compose_members(
archive,
compose_members,
compose_target_dir,
)
return BackupRestoreResult(
archive_name=archive_path.name,
started_at=started_at,
completed_at=datetime.now(timezone.utc),
database_restored=database_restored,
compose_files_restored=compose_files_restored,
compose_target_dir=str(compose_target_dir) if compose_target_dir else None,
compose_pre_restore_archive=str(compose_pre_restore_archive)
if compose_pre_restore_archive
else None,
database_migrations_applied=database_migrations_applied,
warnings=warnings,
)
def _run_post_restore_migrations(self) -> list[str]:
try:
asyncio.get_running_loop()
except RuntimeError:
run_migrations = lambda: asyncio.run(self._run_post_restore_migrations_async())
else:
run_migrations = self._run_post_restore_migrations_in_thread
try:
return run_migrations()
except BackupRestoreError:
raise
except Exception as exc:
raise BackupRestoreError(
f"Database restore completed, but post-restore migrations failed: {str(exc)[:500]}"
) from exc
def _run_post_restore_migrations_in_thread(self) -> list[str]:
with ThreadPoolExecutor(max_workers=1, thread_name_prefix="backup-restore-migrate") as pool:
return pool.submit(
lambda: asyncio.run(self._run_post_restore_migrations_async())
).result()
async def _run_post_restore_migrations_async(self) -> list[str]:
engine = create_async_engine(
self.settings.DATABASE_URL,
echo=False,
pool_pre_ping=True,
pool_size=1,
max_overflow=0,
)
try:
async with engine.begin() as connection:
await connection.execute(
text(f"SELECT pg_advisory_xact_lock({DB_RESTORE_MIGRATION_ADVISORY_LOCK_ID})")
)
return await connection.run_sync(_create_missing_tables_and_migrate)
finally:
await engine.dispose()
def _run_pg_restore(self, dump_path: Path) -> None:
pg_restore_path = str(getattr(self.settings, "BACKUP_PG_RESTORE_PATH", "pg_restore") or "")
pg_restore_path = pg_restore_path or "pg_restore"
if shutil.which(pg_restore_path) is None and Path(pg_restore_path).name == pg_restore_path:
raise BackupRestoreError(
"pg_restore executable was not found. Rebuild the backend image with "
"PostgreSQL client tools."
)
env = os.environ.copy()
env["PGPASSWORD"] = self.settings.POSTGRES_PASSWORD
command = [
pg_restore_path,
"-h",
self.settings.POSTGRES_HOST,
"-p",
str(self.settings.POSTGRES_PORT),
"-U",
self.settings.POSTGRES_USER,
"-d",
self.settings.POSTGRES_DB,
"--clean",
"--if-exists",
"--no-owner",
"--no-privileges",
str(dump_path),
]
timeout = max(
30,
int(
getattr(
self.settings,
"BACKUP_PG_RESTORE_TIMEOUT_SECONDS",
self.settings.BACKUP_PG_DUMP_TIMEOUT_SECONDS,
)
or 1800
),
)
result = subprocess.run(
command,
check=False,
capture_output=True,
env=env,
text=True,
timeout=timeout,
)
if result.returncode != 0:
stderr = (result.stderr or result.stdout or "").strip()
raise BackupRestoreError(
f"pg_restore failed with exit code {result.returncode}: {stderr[:500]}"
)
def _compose_restore_target_dir(self) -> Path:
target_raw = (
getattr(self.settings, "BACKUP_COMPOSE_RESTORE_DIR", None)
or self.settings.BACKUP_COMPOSE_SOURCE_DIR
or ""
)
if not str(target_raw).strip():
raise BackupArchiveError("Compose restore directory is not configured")
return Path(str(target_raw)).expanduser()
def _assert_compose_target_writable(self, target_dir: Path) -> None:
if not target_dir.exists() or not target_dir.is_dir():
raise BackupArchiveError(
f"Compose restore directory is unavailable: {target_dir}. "
"Mount the compose folder into the backend container."
)
probe = target_dir / f".restore-write-test-{os.getpid()}"
try:
probe.write_text("", encoding="utf-8")
probe.unlink()
except OSError as exc:
raise BackupArchiveError(
f"Compose restore directory is not writable: {target_dir}"
) from exc
def _snapshot_current_compose(self, target_dir: Path) -> Optional[Path]:
stamp = backup_filename_timestamp()
archive_path = self._unique_archive_path(f"{COMPOSE_PRE_RESTORE_PREFIX}{stamp}.zip")
excluded_dirs = self._compose_excluded_dirs()
files_count = 0
with tempfile.TemporaryDirectory(
prefix=f"{archive_path.stem}-",
dir=self.backup_dir(),
) as tmp:
staging_dir = Path(tmp)
compose_dir = staging_dir / "compose"
for path in sorted(target_dir.rglob("*")):
relative = path.relative_to(target_dir)
if any(part in excluded_dirs for part in relative.parts):
continue
if path.is_dir() or path.is_symlink():
continue
destination = compose_dir / relative
destination.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(path, destination)
files_count += 1
if files_count <= 0:
return None
completed_at = datetime.now(timezone.utc)
manifest = {
"app": BACKUP_APP_ID,
"format_version": BACKUP_FORMAT_VERSION,
"type": "compose-pre-restore",
"created_at": completed_at.isoformat(),
"created_at_local": completed_at.astimezone().isoformat(),
"postgres": {
"database": self.settings.POSTGRES_DB,
"included": False,
},
"compose": {
"source_dir": str(target_dir),
"included": True,
"files_count": files_count,
},
"warnings": [],
}
attach_archive_integrity(
manifest,
file_records=build_file_records(staging_dir),
)
write_manifest(staging_dir, manifest)
tmp_archive = archive_path.with_name(f"{archive_path.name}.tmp")
try:
write_zip_from_directory(staging_dir, tmp_archive)
tmp_archive.replace(archive_path)
finally:
if tmp_archive.exists():
try:
tmp_archive.unlink()
except OSError:
logger.warning("Failed to remove temporary snapshot %s", tmp_archive)
return archive_path
def _restore_compose_members(
self,
archive: zipfile.ZipFile,
members: list[zipfile.ZipInfo],
target_dir: Path,
) -> int:
target_root = target_dir.resolve()
restored = 0
for member in members:
relative = PurePosixPath(member.filename).relative_to("compose")
destination = target_root.joinpath(*relative.parts).resolve()
try:
destination.relative_to(target_root)
except ValueError as exc:
raise BackupArchiveError(
f"Unsafe compose archive member: {member.filename}"
) from exc
destination.parent.mkdir(parents=True, exist_ok=True)
temp_destination = destination.with_name(
f".{destination.name}.restore-{os.getpid()}.tmp"
)
try:
with archive.open(member) as source, temp_destination.open("wb") as target:
shutil.copyfileobj(source, target)
temp_destination.replace(destination)
finally:
if temp_destination.exists():
try:
temp_destination.unlink()
except OSError:
logger.warning(
"Failed to remove temporary restore file %s",
temp_destination,
)
restored += 1
return restored
def _extract_database_dump(
self,
archive: zipfile.ZipFile,
member: zipfile.ZipInfo,
temp_dir: Path,
) -> Path:
dump_dir = temp_dir / "database"
dump_dir.mkdir(parents=True, exist_ok=True)
dump_path = dump_dir / Path(member.filename).name
with archive.open(member) as source, dump_path.open("wb") as target:
shutil.copyfileobj(source, target)
return dump_path
def _find_database_dump_member(self, archive: zipfile.ZipFile) -> Optional[zipfile.ZipInfo]:
candidates = [
item
for item in archive.infolist()
if not item.is_dir()
and item.filename.startswith("database/")
and PurePosixPath(item.filename).suffix.lower() in {".dump", ".backup"}
]
return sorted(candidates, key=lambda item: item.filename)[0] if candidates else None
def _compose_file_members(self, archive: zipfile.ZipFile) -> list[zipfile.ZipInfo]:
members = [
item
for item in archive.infolist()
if not item.is_dir() and item.filename.startswith("compose/")
]
self._validate_compose_members(members)
return members
def _validate_zip_members(self, members: list[zipfile.ZipInfo]) -> None:
if len(members) > BACKUP_MAX_MEMBERS:
raise BackupArchiveError("Archive contains too many files")
seen: set[str] = set()
total_size = 0
for member in members:
filename = member.filename
if "\\" in filename or "\x00" in filename:
raise BackupArchiveError(f"Unsafe archive member path: {filename}")
path = PurePosixPath(member.filename)
if (
not path.parts
or path.is_absolute()
or ".." in path.parts
or any(part in {"", "."} for part in path.parts)
):
raise BackupArchiveError(f"Unsafe archive member path: {member.filename}")
if member.is_dir():
continue
if filename in seen:
raise BackupArchiveError(f"Duplicate archive member path: {filename}")
seen.add(filename)
if member.file_size > BACKUP_MAX_MEMBER_BYTES:
raise BackupArchiveError(f"Archive member is too large: {filename}")
total_size += int(member.file_size)
if total_size > BACKUP_MAX_UNCOMPRESSED_BYTES:
raise BackupArchiveError("Archive uncompressed size is too large")
compressed = max(1, int(member.compress_size or 1))
ratio = int(member.file_size) / compressed
if (
member.file_size >= BACKUP_ZIP_BOMB_MIN_BYTES
and ratio > BACKUP_MAX_COMPRESSION_RATIO
):
raise BackupArchiveError(
f"Archive member compression ratio is too high: {filename}"
)
def _validate_compose_members(self, members: list[zipfile.ZipInfo]) -> None:
total_size = 0
for member in members:
if member.file_size > BACKUP_MAX_COMPOSE_MEMBER_BYTES:
raise BackupArchiveError(f"Compose archive member is too large: {member.filename}")
total_size += int(member.file_size)
if total_size > BACKUP_MAX_COMPOSE_BYTES:
raise BackupArchiveError("Compose archive contents are too large")
def _read_manifest(self, archive: zipfile.ZipFile) -> dict[str, Any]:
if BACKUP_MANIFEST_NAME not in archive.namelist():
raise BackupArchiveError("Archive does not contain manifest.json")
try:
manifest = json.loads(archive.read(BACKUP_MANIFEST_NAME).decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise BackupArchiveError("manifest.json is not valid JSON") from exc
if not isinstance(manifest, dict):
raise BackupArchiveError("manifest.json must contain an object")
if manifest.get("app") != BACKUP_APP_ID:
raise BackupArchiveError("Archive manifest belongs to another application")
try:
format_version = int(manifest.get("format_version") or 0)
except (TypeError, ValueError) as exc:
raise BackupArchiveError("Archive manifest format is not supported") from exc
if format_version != BACKUP_FORMAT_VERSION:
raise BackupArchiveError("Archive manifest format is not supported")
return manifest
def _validate_archive_for_restore(self, archive_path: Path) -> None:
if not zipfile.is_zipfile(archive_path):
raise BackupArchiveError("Archive is not a valid ZIP file")
with zipfile.ZipFile(archive_path) as archive:
self._validate_zip_members(archive.infolist())
manifest = self._read_manifest(archive)
self._validate_archive_integrity(archive, manifest)
def _validate_archive_integrity(
self,
archive: zipfile.ZipFile,
manifest: dict[str, Any],
) -> None:
archive_manifest = (
manifest.get("archive") if isinstance(manifest.get("archive"), dict) else {}
)
file_records = archive_manifest.get("files")
if not isinstance(file_records, list):
raise BackupArchiveError("Archive manifest does not contain file checksums")
expected: dict[str, dict[str, Any]] = {}
for record in file_records:
if not isinstance(record, dict):
raise BackupArchiveError("Archive manifest contains invalid file record")
filename = str(record.get("path") or "")
if not filename:
raise BackupArchiveError("Archive manifest contains empty file path")
if filename in expected:
raise BackupArchiveError(
f"Archive manifest contains duplicate file path: {filename}"
)
expected[filename] = record
actual = {
item.filename
for item in archive.infolist()
if not item.is_dir() and item.filename != BACKUP_MANIFEST_NAME
}
if actual != set(expected):
raise BackupArchiveError("Archive contents do not match manifest")
for info in archive.infolist():
if info.is_dir() or info.filename == BACKUP_MANIFEST_NAME:
continue
record = expected[info.filename]
try:
expected_size = int(record.get("size_bytes") or -1)
except (TypeError, ValueError) as exc:
raise BackupArchiveError(
f"Archive manifest size is invalid: {info.filename}"
) from exc
expected_hash = str(record.get("sha256") or "")
if expected_size != int(info.file_size):
raise BackupArchiveError(
f"Archive member size does not match manifest: {info.filename}"
)
if not re.fullmatch(r"[0-9a-f]{64}", expected_hash):
raise BackupArchiveError(f"Archive manifest checksum is invalid: {info.filename}")
digest = hashlib.sha256()
with archive.open(info) as source:
for chunk in iter(lambda: source.read(1024 * 1024), b""):
digest.update(chunk)
if not hmac.compare_digest(digest.hexdigest(), expected_hash):
raise BackupArchiveError(
f"Archive member checksum does not match manifest: {info.filename}"
)
def _compose_excluded_dirs(self) -> set[str]:
configured = self._split_csv(self.settings.BACKUP_COMPOSE_EXCLUDE_DIRS)
return DEFAULT_COMPOSE_EXCLUDED_DIRS | set(configured)
@staticmethod
def _split_csv(value: Optional[str]) -> list[str]:
if not value:
return []
return [item.strip() for item in value.split(",") if item.strip()]
@staticmethod
def _file_digest(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()[:16]
@staticmethod
def _safe_original_stem(filename: str) -> str:
stem = Path(str(filename or "backup")).stem
safe = re.sub(r"[^A-Za-z0-9_.+-]+", "-", stem).strip(".-")
return (safe or "backup")[:72]
def _unique_archive_path(self, archive_name: str) -> Path:
backup_dir = self.backup_dir()
stem = Path(archive_name).stem
suffix = Path(archive_name).suffix
candidate = backup_dir / archive_name
counter = 2
while candidate.exists():
candidate = backup_dir / f"{stem}-{counter}{suffix}"
counter += 1
return candidate
+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")
+24 -1
View File
@@ -17,8 +17,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
from bot.middlewares.i18n import JsonI18n
from bot.services.email_templates import EmailContent, render_login_code
from bot.services.message_audit import log_user_message_delivery
from config.settings import Settings
from db.dal import security_dal
from db.dal import security_dal, user_dal
from db.models import EmailVerificationCode
logger = logging.getLogger(__name__)
@@ -222,6 +223,28 @@ class EmailAuthService:
magic_link=magic_link,
purpose=purpose,
)
resolved_target_user_id = target_user_id
if resolved_target_user_id is None:
try:
existing_user = await user_dal.get_user_by_email(session, normalized_email)
resolved_target_user_id = (
int(existing_user.user_id) if existing_user is not None else None
)
except Exception:
logger.exception(
"Failed to resolve email auth target user for audit log: %s",
normalized_email,
)
await log_user_message_delivery(
session,
target_user_id=resolved_target_user_id,
event_type="email_login_code_sent"
if purpose == "login"
else "email_verification_code_sent",
channel="email",
recipient=normalized_email,
content=f"purpose={purpose} magic_link={bool(magic_link)}",
)
return EmailCodeRequestResult(ok=True)
async def verify_code(
+312 -43
View File
@@ -12,11 +12,12 @@ from __future__ import annotations
import html
import re
from dataclasses import dataclass
from typing import Optional, Sequence, Tuple
from typing import TYPE_CHECKING, Optional, Sequence, Tuple
from urllib.parse import urlsplit
from bot.middlewares.i18n import JsonI18n, get_i18n_instance, normalize_locale_language_code
from config.settings import Settings
if TYPE_CHECKING:
from bot.middlewares.i18n import JsonI18n
from config.settings import Settings
_BG = "#05070a"
_CARD_BG = "#0e1116"
@@ -64,14 +65,16 @@ def _brand_title(settings: Settings) -> str:
def _normalize_lang(language_code: Optional[str], settings: Settings) -> str:
return normalize_locale_language_code(
language_code or settings.DEFAULT_LANGUAGE or "ru",
prefer_known_base=False,
)
value = str(language_code or settings.DEFAULT_LANGUAGE or "ru").strip().lower()
return value.replace("_", "-") or "ru"
def _resolve_i18n(i18n: Optional[JsonI18n]) -> JsonI18n:
return i18n or get_i18n_instance()
if i18n is not None:
return i18n
from bot.middlewares.i18n import get_i18n_instance
return get_i18n_instance()
def _t_html(i18n: JsonI18n, lang: str, key: str, **kwargs) -> str:
@@ -89,6 +92,7 @@ def _t_text(i18n: JsonI18n, lang: str, key: str, **kwargs) -> str:
def _layout(
*,
settings: Settings,
language_code: str,
preheader: str,
heading: str,
intro_html: str,
@@ -98,6 +102,7 @@ def _layout(
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
brand_title = html.escape(_brand_title(settings))
logo_url = _public_logo_url(settings)
html_lang = html.escape((language_code or "en").replace("_", "-"), quote=True)
logo_block = ""
if logo_url:
logo_block = (
@@ -107,7 +112,7 @@ def _layout(
)
return f"""<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
<html lang="{html_lang}" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
@@ -202,6 +207,41 @@ def _format_traffic(traffic_gb: Optional[float]) -> str:
return str(int(value)) if value.is_integer() else f"{value:g}"
_ALLOWED_INLINE_TAGS = {
"b": "strong",
"strong": "strong",
"i": "em",
"em": "em",
"u": "u",
"s": "s",
"code": "code",
}
_INLINE_TAG_RE = re.compile(r"</?(?:b|strong|i|em|u|s|code)>", re.IGNORECASE)
_ANY_TAG_RE = re.compile(r"<[^>]+>")
def _telegram_html_to_email_html(value: str) -> str:
"""Escape arbitrary text while preserving the tiny Telegram HTML subset we use."""
source = str(value or "")
chunks: list[str] = []
cursor = 0
for match in _INLINE_TAG_RE.finditer(source):
chunks.append(html.escape(source[cursor : match.start()]))
raw_tag = match.group(0)
closing = raw_tag.startswith("</")
tag_name = raw_tag.strip("</>").lower()
mapped = _ALLOWED_INLINE_TAGS.get(tag_name)
if mapped:
chunks.append(f"</{mapped}>" if closing else f"<{mapped}>")
cursor = match.end()
chunks.append(html.escape(source[cursor:]))
return "".join(chunks).replace("\n", "<br>")
def _telegram_html_to_text(value: str) -> str:
return html.unescape(_ANY_TAG_RE.sub("", str(value or "")))
def _format_minutes(seconds: int) -> int:
return max(1, int(seconds) // 60)
@@ -272,6 +312,7 @@ def render_login_code(
rendered = _layout(
settings=settings,
language_code=lang,
preheader=preheader,
heading=heading,
intro_html=html.escape(intro),
@@ -324,6 +365,7 @@ def render_account_merged(
rendered = _layout(
settings=settings,
language_code=lang,
preheader=preheader,
heading=heading,
intro_html=html.escape(intro),
@@ -351,12 +393,15 @@ def render_payment_success(
lang = _normalize_lang(language_code, settings)
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
brand = _brand_title(settings)
is_traffic = (sale_mode or "").split("@", 1)[0].split("|", 1)[0] in {
sale_base = (sale_mode or "").split("@", 1)[0].split("|", 1)[0]
is_traffic = sale_base in {
"traffic",
"traffic_package",
"topup",
"premium_topup",
}
is_hwid = sale_base in {"hwid_device", "hwid_devices", "hwid_devices_renewal"}
is_tariff_upgrade = sale_base == "tariff_upgrade"
amount_text = _format_amount(amount, currency)
safe_dashboard_url = (dashboard_url or "").strip()
end_date = end_date_text or ""
@@ -370,7 +415,12 @@ def render_payment_success(
cta_label = _t_text(i18n, lang, "email_payment_success_cta")
if is_traffic:
intro = _t_text(i18n, lang, "email_payment_success_intro_traffic", traffic_gb=traffic_label)
intro_key = (
"email_payment_success_intro_premium_topup"
if sale_base == "premium_topup"
else "email_payment_success_intro_traffic"
)
intro = _t_text(i18n, lang, intro_key, traffic_gb=traffic_label)
period_label = _t_text(i18n, lang, "email_payment_success_row_traffic")
period_value = _t_text(
i18n, lang, "email_payment_success_traffic_value", traffic_gb=traffic_label
@@ -383,6 +433,30 @@ def render_payment_success(
traffic_gb=traffic_label,
end_date=end_date,
)
elif is_hwid:
devices_count = max(0, int(months or 0))
intro = _t_text(i18n, lang, "email_payment_success_intro_hwid", count=devices_count)
period_label = _t_text(i18n, lang, "email_payment_success_row_hwid")
period_value = _t_text(i18n, lang, "email_payment_success_hwid_value", count=devices_count)
text = _t_text(
i18n,
lang,
"email_payment_success_text_hwid",
amount=amount_text,
count=devices_count,
end_date=end_date,
)
elif is_tariff_upgrade:
intro = _t_text(i18n, lang, "email_payment_success_intro_tariff_upgrade")
period_label = _t_text(i18n, lang, "email_payment_success_row_operation")
period_value = _t_text(i18n, lang, "email_payment_success_tariff_upgrade_value")
text = _t_text(
i18n,
lang,
"email_payment_success_text_tariff_upgrade",
amount=amount_text,
end_date=end_date,
)
else:
months_int = int(months or 0)
intro = _t_text(i18n, lang, "email_payment_success_intro_subscription", months=months_int)
@@ -425,6 +499,7 @@ def render_payment_success(
rendered = _layout(
settings=settings,
language_code=lang,
preheader=preheader,
heading=heading,
intro_html=html.escape(intro),
@@ -434,6 +509,67 @@ def render_payment_success(
return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered)
def render_user_notification(
settings: Settings,
*,
language_code: Optional[str],
subject: str,
message_text: str,
dashboard_url: Optional[str] = None,
cta_label: Optional[str] = None,
heading: Optional[str] = None,
intro: Optional[str] = None,
i18n: Optional[JsonI18n] = None,
) -> EmailContent:
i18n = _resolve_i18n(i18n)
lang = _normalize_lang(language_code, settings)
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
brand = _brand_title(settings)
safe_dashboard_url = (dashboard_url or "").strip()
final_subject = (subject or "").strip() or _t_text(
i18n, lang, "email_user_notification_subject"
)
final_heading = (heading or "").strip() or final_subject
final_intro = (intro or "").strip() or _t_text(i18n, lang, "email_user_notification_intro")
final_cta_label = (cta_label or "").strip() or _t_text(
i18n,
lang,
"email_user_notification_cta",
)
footer = _t_html(i18n, lang, "email_footer_auto", brand=brand)
message_html = (
f'<div style="margin:0 0 16px 0;background:{_BG};border:1px solid {_BORDER};'
f"border-radius:14px;padding:14px 16px;font-size:14px;line-height:1.55;color:{_TEXT};"
f'white-space:pre-wrap;">{_telegram_html_to_email_html(message_text)}</div>'
)
body_parts = [message_html]
if safe_dashboard_url:
body_parts.append(
_cta_button_html(label=final_cta_label, url=safe_dashboard_url, accent=accent)
)
rendered = _layout(
settings=settings,
language_code=lang,
preheader=final_subject,
heading=final_heading,
intro_html=html.escape(final_intro),
body_html="".join(body_parts),
footer_html=footer,
)
text_lines = [final_subject, "", _telegram_html_to_text(message_text)]
if safe_dashboard_url:
text_lines.extend(
[
"",
_t_text(
i18n, lang, "email_user_notification_text_dashboard", url=safe_dashboard_url
),
]
)
return EmailContent(subject=final_subject, text="\n".join(text_lines), html=rendered)
def render_subscription_expiring(
settings: Settings,
*,
@@ -488,6 +624,7 @@ def render_subscription_expiring(
rendered = _layout(
settings=settings,
language_code=lang,
preheader=preheader,
heading=heading,
intro_html=html.escape(intro),
@@ -497,6 +634,108 @@ def render_subscription_expiring(
return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered)
def _subscription_lifecycle_title(
i18n: JsonI18n,
lang: str,
notification_key: str,
*,
days_left: Optional[int],
hours_before: Optional[int],
) -> str:
if notification_key == "before_2d_autorenew":
return _t_text(i18n, lang, "email_subscription_lifecycle_subject_autorenew")
if notification_key == "expired":
return _t_text(i18n, lang, "email_subscription_lifecycle_subject_expired")
if notification_key == "expired_24h_after":
return _t_text(i18n, lang, "email_subscription_lifecycle_subject_expired_after")
if hours_before is not None:
return _t_text(
i18n,
lang,
"email_subscription_lifecycle_subject_before_hours",
hours=hours_before,
)
return _t_text(
i18n,
lang,
"email_subscription_lifecycle_subject_before_days",
days=max(0, int(days_left or 0)),
)
def render_subscription_lifecycle_notification(
settings: Settings,
*,
language_code: Optional[str],
notification_key: str,
message_text: str,
end_date_text: str,
dashboard_url: Optional[str],
mirrored_from_telegram: bool = False,
days_left: Optional[int] = None,
hours_before: Optional[int] = None,
i18n: Optional[JsonI18n] = None,
) -> EmailContent:
i18n = _resolve_i18n(i18n)
lang = _normalize_lang(language_code, settings)
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
brand = _brand_title(settings)
safe_dashboard_url = (dashboard_url or "").strip()
end_date = end_date_text or ""
subject = _subscription_lifecycle_title(
i18n,
lang,
notification_key,
days_left=days_left,
hours_before=hours_before,
)
intro_key = (
"email_subscription_lifecycle_intro_mirrored"
if mirrored_from_telegram
else "email_subscription_lifecycle_intro_direct"
)
intro = _t_text(i18n, lang, intro_key)
footer = _t_html(i18n, lang, "email_footer_auto", brand=brand)
cta_label = _t_text(i18n, lang, "email_subscription_lifecycle_cta")
rows = [
(_t_text(i18n, lang, "email_subscription_lifecycle_row_end_date"), end_date),
]
message_html = (
f'<div style="margin:0 0 16px 0;background:{_BG};border:1px solid {_BORDER};'
f"border-radius:14px;padding:14px 16px;font-size:14px;line-height:1.55;color:{_TEXT};"
f'white-space:pre-wrap;">{html.escape(message_text or "")}</div>'
)
body_parts = [_info_rows_html(rows), message_html]
if safe_dashboard_url:
body_parts.append(_cta_button_html(label=cta_label, url=safe_dashboard_url, accent=accent))
rendered = _layout(
settings=settings,
language_code=lang,
preheader=subject,
heading=subject,
intro_html=html.escape(intro),
body_html="".join(body_parts),
footer_html=footer,
)
text_lines = [subject, "", message_text]
if safe_dashboard_url:
text_lines.extend(
[
"",
_t_text(
i18n,
lang,
"email_subscription_lifecycle_text_renew",
url=safe_dashboard_url,
),
]
)
return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered)
def _support_email(
settings: Settings,
i18n: Optional[JsonI18n],
@@ -510,28 +749,40 @@ def _support_email(
ticket_url: Optional[str],
cta_label: str,
) -> EmailContent:
i18n = _resolve_i18n(i18n)
lang = _normalize_lang(language, settings)
brand = _brand_title(settings)
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
safe_url = (ticket_url or "").strip()
footer = _t_html(_resolve_i18n(i18n), lang, "email_footer_auto", brand=brand)
footer = _t_html(i18n, lang, "email_footer_auto", brand=brand)
localized_rows = [
(_t_text(i18n, lang, label) if str(label).startswith("email_") else str(label), value)
for label, value in rows
]
preview_block = (
f'<div style="margin:0 0 16px 0;background:{_BG};border:1px solid {_BORDER};'
f"border-radius:14px;padding:14px 16px;font-size:14px;line-height:1.55;color:{_TEXT};"
f'white-space:pre-wrap;">{html.escape(body_preview or "")}</div>'
)
body_parts = [_info_rows_html(rows), preview_block]
body_parts = [_info_rows_html(localized_rows), preview_block]
if safe_url:
body_parts.append(_cta_button_html(label=cta_label, url=safe_url, accent=accent))
rendered = _layout(
settings=settings,
language_code=lang,
preheader=intro,
heading=heading,
intro_html=html.escape(intro),
body_html="".join(body_parts),
footer_html=footer,
)
text_lines = [intro, "", *[f"{label}: {value}" for label, value in rows], "", body_preview]
text_lines = [
intro,
"",
*[f"{label}: {value}" for label, value in localized_rows],
"",
body_preview,
]
if safe_url:
text_lines.extend(["", safe_url])
return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered)
@@ -549,23 +800,25 @@ def render_support_new_ticket_admin(
snapshot_rows: Sequence[Tuple[str, str]],
ticket_url: Optional[str],
) -> EmailContent:
i18n = _resolve_i18n(i18n)
lang = _normalize_lang(language, settings)
rows = [
("Ticket", f"#{ticket_id}"),
("User", user_display),
("Subject", subject),
("email_support_row_ticket", f"#{ticket_id}"),
("email_support_row_user", user_display),
("email_support_row_subject", subject),
*snapshot_rows,
]
return _support_email(
settings,
i18n,
language,
subject=f"New support ticket #{ticket_id}",
heading=f"New support ticket #{ticket_id}",
intro="A user opened a new support ticket.",
lang,
subject=_t_text(i18n, lang, "email_support_new_ticket_admin_subject", ticket_id=ticket_id),
heading=_t_text(i18n, lang, "email_support_new_ticket_admin_heading", ticket_id=ticket_id),
intro=_t_text(i18n, lang, "email_support_new_ticket_admin_intro"),
rows=rows,
body_preview=body_preview,
ticket_url=ticket_url,
cta_label="Open ticket",
cta_label=_t_text(i18n, lang, "email_support_cta_open_ticket"),
)
@@ -581,23 +834,25 @@ def render_support_user_reply_admin(
snapshot_rows: Sequence[Tuple[str, str]],
ticket_url: Optional[str],
) -> EmailContent:
i18n = _resolve_i18n(i18n)
lang = _normalize_lang(language, settings)
rows = [
("Ticket", f"#{ticket_id}"),
("User", user_display),
("Subject", subject),
("email_support_row_ticket", f"#{ticket_id}"),
("email_support_row_user", user_display),
("email_support_row_subject", subject),
*snapshot_rows,
]
return _support_email(
settings,
i18n,
language,
subject=f"New user reply in ticket #{ticket_id}",
heading=f"User replied in ticket #{ticket_id}",
intro="A user sent a new support message.",
lang,
subject=_t_text(i18n, lang, "email_support_user_reply_admin_subject", ticket_id=ticket_id),
heading=_t_text(i18n, lang, "email_support_user_reply_admin_heading", ticket_id=ticket_id),
intro=_t_text(i18n, lang, "email_support_user_reply_admin_intro"),
rows=rows,
body_preview=body_preview,
ticket_url=ticket_url,
cta_label="Open ticket",
cta_label=_t_text(i18n, lang, "email_support_cta_open_ticket"),
)
@@ -611,17 +866,22 @@ def render_support_admin_reply_user(
body_preview: str,
ticket_url: Optional[str],
) -> EmailContent:
i18n = _resolve_i18n(i18n)
lang = _normalize_lang(language, settings)
return _support_email(
settings,
i18n,
language,
subject=f"New reply for ticket #{ticket_id}",
heading=f"New reply for ticket #{ticket_id}",
intro="Support has replied to your ticket.",
rows=[("Ticket", f"#{ticket_id}"), ("Subject", subject)],
lang,
subject=_t_text(i18n, lang, "email_support_admin_reply_user_subject", ticket_id=ticket_id),
heading=_t_text(i18n, lang, "email_support_admin_reply_user_heading", ticket_id=ticket_id),
intro=_t_text(i18n, lang, "email_support_admin_reply_user_intro"),
rows=[
("email_support_row_ticket", f"#{ticket_id}"),
("email_support_row_subject", subject),
],
body_preview=body_preview,
ticket_url=ticket_url,
cta_label="Open in Mini App",
cta_label=_t_text(i18n, lang, "email_support_cta_open_mini_app"),
)
@@ -635,15 +895,24 @@ def render_support_ticket_closed_user(
body_preview: str = "",
ticket_url: Optional[str],
) -> EmailContent:
i18n = _resolve_i18n(i18n)
lang = _normalize_lang(language, settings)
return _support_email(
settings,
i18n,
language,
subject=f"Ticket #{ticket_id} was closed",
heading=f"Ticket #{ticket_id} was closed",
intro="Your support ticket has been closed.",
rows=[("Ticket", f"#{ticket_id}"), ("Subject", subject)],
body_preview=body_preview or "The ticket is closed.",
lang,
subject=_t_text(
i18n, lang, "email_support_ticket_closed_user_subject", ticket_id=ticket_id
),
heading=_t_text(
i18n, lang, "email_support_ticket_closed_user_heading", ticket_id=ticket_id
),
intro=_t_text(i18n, lang, "email_support_ticket_closed_user_intro"),
rows=[
("email_support_row_ticket", f"#{ticket_id}"),
("email_support_row_subject", subject),
],
body_preview=body_preview or _t_text(i18n, lang, "email_support_ticket_closed_user_body"),
ticket_url=ticket_url,
cta_label="Open in Mini App",
cta_label=_t_text(i18n, lang, "email_support_cta_open_mini_app"),
)
@@ -53,6 +53,7 @@ LOCALE_GROUPS = [
"admin_sort_",
"admin_status_",
"admin_badge_",
"admin_backups_",
"admin_aria_",
"admin_search",
"admin_clear",
@@ -257,6 +258,7 @@ LOCALE_GROUPS = [
"audience": "internal",
"prefixes": (
"admin_settings_field_log_",
"admin_settings_field_backup_",
"admin_settings_field_support_",
"admin_settings_field_subscription_notifications_",
"admin_settings_field_subscription_notify_",
+57
View File
@@ -0,0 +1,57 @@
import logging
from datetime import datetime, timezone
from typing import Optional
from sqlalchemy.ext.asyncio import AsyncSession
from db.dal import message_log_dal
logger = logging.getLogger(__name__)
def _clean_piece(value: Optional[object]) -> str:
return str(value or "").strip()
async def log_user_message_delivery(
session: AsyncSession,
*,
target_user_id: Optional[int],
event_type: str,
channel: str,
content: str,
recipient: Optional[str] = None,
timestamp: Optional[datetime] = None,
) -> None:
"""Add a best-effort user log entry for important outbound messages."""
clean_event = _clean_piece(event_type)
clean_channel = _clean_piece(channel)
if not clean_event or not clean_channel:
return
parts = [f"channel={clean_channel}"]
clean_recipient = _clean_piece(recipient)
if clean_recipient:
parts.append(f"recipient={clean_recipient}")
clean_content = _clean_piece(content)
if clean_content:
parts.append(clean_content)
try:
await message_log_dal.create_message_log_no_commit(
session,
{
"user_id": None,
"event_type": clean_event,
"content": " | ".join(parts)[:4000],
"is_admin_event": False,
"target_user_id": int(target_user_id) if target_user_id is not None else None,
"timestamp": timestamp or datetime.now(timezone.utc),
},
)
except Exception:
logger.exception(
"Failed to add outbound message audit log for user %s event %s",
target_user_id,
clean_event,
)
+4 -4
View File
@@ -273,10 +273,10 @@ class NotificationService:
return []
rows = []
for key, label in (
("tariff", "Tariff"),
("end_date", "End date"),
("remaining", "Remaining"),
("panel_status", "Panel status"),
("tariff", "email_support_row_tariff"),
("end_date", "email_support_row_end_date"),
("remaining", "email_support_row_remaining"),
("panel_status", "email_support_row_panel_status"),
):
value = snapshot.get(key)
if value:
+102 -2
View File
@@ -328,12 +328,112 @@ class PanelApiService:
async def _get_user_by_uuid_uncached(
self, user_uuid: str, log_response: bool = False
) -> Optional[Dict[str, Any]]:
lookup = await self.get_user_by_uuid_lookup(user_uuid, log_response=log_response)
if lookup.get("ok") and isinstance(lookup.get("user"), dict):
return lookup["user"]
return None
@staticmethod
def _panel_response_details(response_data: Optional[Dict[str, Any]]) -> Dict[str, Any]:
if not isinstance(response_data, dict):
return {}
details = response_data.get("details")
return details if isinstance(details, dict) else {}
@classmethod
def _panel_response_error_code(cls, response_data: Optional[Dict[str, Any]]) -> Optional[str]:
if not isinstance(response_data, dict):
return None
details = cls._panel_response_details(response_data)
error_code = (
response_data.get("errorCode")
or response_data.get("code")
or details.get("errorCode")
or details.get("code")
)
return str(error_code) if error_code else None
@classmethod
def _panel_response_message(cls, response_data: Optional[Dict[str, Any]]) -> Optional[str]:
if not isinstance(response_data, dict):
return None
details = cls._panel_response_details(response_data)
message = (
response_data.get("message")
or details.get("message")
or details.get("error")
or details.get("raw_response_text")
)
if message is None:
return None
message = str(message).replace("\n", " ").strip()
return message[:500] if message else None
@classmethod
def _is_user_not_found_response(cls, response_data: Optional[Dict[str, Any]]) -> bool:
if not isinstance(response_data, dict):
return False
status_code = response_data.get("status_code")
error_code = cls._panel_response_error_code(response_data)
if error_code in {"A040", "A062", "USER_NOT_FOUND", "NOT_FOUND"}:
return True
return status_code == 404
@classmethod
def _describe_user_lookup_failure(
cls,
response_data: Optional[Dict[str, Any]],
*,
not_found: bool,
) -> str:
if not isinstance(response_data, dict):
return "classification=panel_lookup_failed response=empty"
classification = "confirmed_not_found" if not_found else "panel_lookup_failed"
parts = [f"classification={classification}"]
status_code = response_data.get("status_code")
if status_code is not None:
parts.append(f"status_code={status_code}")
error_code = cls._panel_response_error_code(response_data)
if error_code:
parts.append(f"error_code={error_code}")
message = cls._panel_response_message(response_data)
if message:
parts.append(f"message={message}")
return " ".join(parts)
async def get_user_by_uuid_lookup(
self, user_uuid: str, log_response: bool = False
) -> Dict[str, Any]:
"""Fetch a panel user and preserve whether a miss was confirmed.
``get_user_by_uuid`` historically returned ``None`` both for a real
404/not-found and for transient panel/API failures. Callers that may
mutate local state need this richer result to avoid treating an outage
as a deleted panel user.
"""
endpoint = f"/users/{user_uuid}"
full_response = await self._request("GET", endpoint, log_full_response=log_response)
if full_response and not full_response.get("error") and "response" in full_response:
return full_response.get("response")
return {
"ok": True,
"user": full_response.get("response"),
"not_found": False,
"failure_reason": None,
"response": full_response,
}
return None
not_found = self._is_user_not_found_response(full_response)
return {
"ok": False,
"user": None,
"not_found": not_found,
"failure_reason": self._describe_user_lookup_failure(
full_response,
not_found=not_found,
),
"response": full_response,
}
async def get_user(
self,
@@ -0,0 +1,622 @@
import json
import logging
import re
import uuid
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any, Dict, List, Optional
from config.settings import Settings
from .panel_api_service import PanelApiService
logger = logging.getLogger(__name__)
_USER_ACTION_RE = re.compile(
r"^/users/(?P<user_uuid>[^/]+)/actions/(?P<action>enable|disable|reset-traffic)$"
)
_INTERNAL_SQUAD_BULK_RE = re.compile(
r"^/internal-squads/(?P<squad_uuid>[^/]+)/bulk-actions/"
r"(?P<action>add-users|remove-users)$"
)
_LIVE_POST_ENDPOINTS = frozenset({"/system/tools/happ/encrypt"})
_KNOWN_TRAFFIC_STRATEGIES = frozenset({"NO_RESET", "DAY", "WEEK", "MONTH"})
# Constant path templates for intercepted endpoints. The logged path is rebuilt
# from these literals (never from the raw endpoint) so user/squad UUIDs and any
# other id-like segment can never reach the log as clear text.
_USER_ACTION_TEMPLATES = {
"enable": "/users/<id>/actions/enable",
"disable": "/users/<id>/actions/disable",
"reset-traffic": "/users/<id>/actions/reset-traffic",
}
_SQUAD_BULK_TEMPLATES = {
"add-users": "/internal-squads/<id>/bulk-actions/add-users",
"remove-users": "/internal-squads/<id>/bulk-actions/remove-users",
}
# Exact intercepted endpoints that carry no id and are safe to log verbatim.
# Mapped to themselves so the logged value comes from this literal table, not
# from the (tainted) request endpoint.
_SAFE_LITERAL_ENDPOINTS = {
"/users": "/users",
"/hwid/devices/delete": "/hwid/devices/delete",
}
# Panel payloads can carry proxy credentials (e.g. trojanPassword, ssPassword,
# vless/vmess uuids) and PII (email, telegramId). Redact such values before they
# reach the dry-run log so secrets are never written in clear text.
_SENSITIVE_KEY_RE = re.compile(
r"pass|pwd|secret|token|key|credential|auth|cookie|session|"
r"email|mail|phone|telegram|mnemonic",
re.IGNORECASE,
)
# Field names the dry-run validator understands. Keys are echoed into the log
# only via this table (value == name), so the logged key is always a literal and
# never the raw, source-derived dict key. Unknown keys collapse to "<field>".
_FIELD_LABELS = {
name: name
for name in (
"uuid",
"username",
"status",
"expireAt",
"trafficLimitBytes",
"trafficLimitStrategy",
"hwidDeviceLimit",
"telegramId",
"email",
"description",
"tag",
"activeInternalSquads",
"activeUserInbounds",
"externalSquadUuid",
"userUuid",
"userUuids",
"users",
"hwid",
)
}
_REDACTED = "***"
_UNKNOWN_FIELD = "<field>"
_UNKNOWN_ENDPOINT = "<other>"
@dataclass
class _DryRunValidation:
errors: List[str] = field(default_factory=list)
@property
def ok(self) -> bool:
return not self.errors
def add(self, message: str) -> None:
self.errors.append(message)
class PanelDryRunApiService(PanelApiService):
"""Panel API client that reads live data but never mutates Remnawave users."""
def __init__(self, settings: Settings):
super().__init__(settings)
self._synthetic_users: Dict[str, Dict[str, Any]] = {}
async def _request(
self, method: str, endpoint: str, log_full_response: bool = False, **kwargs
) -> Optional[Dict[str, Any]]:
method_upper = method.upper()
normalized_endpoint = self._normalize_endpoint(endpoint)
if not self._should_intercept(method_upper, normalized_endpoint):
return await super()._request(
method_upper,
endpoint,
log_full_response=log_full_response,
**kwargs,
)
validation = await self._validate_dry_run_request(
method_upper,
normalized_endpoint,
kwargs.get("json"),
)
if not validation.ok:
self._log_dry_run(
"BLOCKED",
method_upper,
normalized_endpoint,
kwargs.get("json"),
errors=validation.errors,
)
return {
"error": True,
"status_code": 400,
"errorCode": "DRY_RUN_VALIDATION_FAILED",
"message": "Panel dry-run validation failed.",
"details": {"errors": validation.errors},
}
response = await self._dry_run_response(
method_upper,
normalized_endpoint,
kwargs.get("json"),
)
self._log_dry_run("OK", method_upper, normalized_endpoint, kwargs.get("json"))
return {"response": response, "dryRun": True}
@staticmethod
def _normalize_endpoint(endpoint: str) -> str:
return f"/{str(endpoint or '').lstrip('/')}"
@staticmethod
def _safe_endpoint(endpoint: str) -> str:
"""Map the request path to a constant log label.
Every return value comes from a literal template/table, never from the
(tainted) endpoint itself, so user/squad UUIDs and any other id-like
segment can never reach the log as clear text. The raw path is only
matched against, not echoed.
"""
raw = str(endpoint or "")
if match := _USER_ACTION_RE.match(raw):
return _USER_ACTION_TEMPLATES.get(match.group("action"), "/users/<id>/actions/<action>")
if match := _INTERNAL_SQUAD_BULK_RE.match(raw):
return _SQUAD_BULK_TEMPLATES.get(
match.group("action"), "/internal-squads/<id>/bulk-actions/<action>"
)
literal = _SAFE_LITERAL_ENDPOINTS.get(raw)
if literal is not None:
return literal
if raw.startswith("/users/"):
return "/users/<id>"
if raw.startswith("/internal-squads/"):
return "/internal-squads/<id>"
return _UNKNOWN_ENDPOINT
@staticmethod
def _summarize_leaf(value: Any) -> Any:
"""Reduce a scalar to a non-sensitive type token.
Leaf values can carry PII or proxy credentials, so the log never echoes
them only their JSON type. ``None`` is kept so absent fields stay
distinguishable from present ones.
"""
if value is None:
return None
if isinstance(value, bool):
return "<bool>"
if isinstance(value, int):
return "<int>"
if isinstance(value, float):
return "<float>"
if isinstance(value, str):
return "<str>"
return f"<{type(value).__name__}>"
@staticmethod
def _safe_key(key: Any) -> str:
"""Return a constant label for a payload key.
Known field names are echoed from the ``_FIELD_LABELS`` table (the value,
not the source-derived key); anything else collapses to ``<field>``. This
keeps the raw dict key out of the log entirely.
"""
if isinstance(key, str):
return _FIELD_LABELS.get(key, _UNKNOWN_FIELD)
return _UNKNOWN_FIELD
@classmethod
def _redact(cls, value: Any, _depth: int = 0) -> Any:
"""Recursively summarize values, keeping only the JSON shape.
Keys are replaced by constant labels, sensitive keys collapse to a
placeholder, and every scalar leaf becomes a type token. The result shows
which fields a mutation would touch without logging any source-derived
string (key or value).
"""
if _depth > 6:
return "..."
if isinstance(value, dict):
return {
cls._safe_key(k): (
_REDACTED
if isinstance(k, str) and _SENSITIVE_KEY_RE.search(k)
else cls._redact(v, _depth + 1)
)
for k, v in value.items()
}
if isinstance(value, (list, tuple)):
return [cls._redact(item, _depth + 1) for item in value]
return cls._summarize_leaf(value)
@classmethod
def _payload_preview(cls, payload: Any) -> str:
redacted = cls._redact(payload)
try:
text = json.dumps(redacted, ensure_ascii=False, default=str, sort_keys=True)
except Exception:
text = str(redacted)
if len(text) > 1200:
return f"{text[:1200]}..."
return text
def _log_dry_run(
self,
status: str,
method: str,
endpoint: str,
payload: Any,
*,
errors: Optional[List[str]] = None,
) -> None:
logger.info(
"[PANEL DRY-RUN %s] would %s %s payload=%s%s",
status,
method,
self._safe_endpoint(endpoint),
self._payload_preview(payload),
f" errors={errors}" if errors else "",
)
@staticmethod
def _should_intercept(method: str, endpoint: str) -> bool:
if method in PanelApiService._SAFE_METHODS:
return False
if method == "POST" and endpoint in _LIVE_POST_ENDPOINTS:
return False
return True
async def _validate_dry_run_request(
self,
method: str,
endpoint: str,
payload: Any,
) -> _DryRunValidation:
validation = _DryRunValidation()
data = payload if isinstance(payload, dict) else {}
if payload is not None and not isinstance(payload, dict):
validation.add("JSON payload must be an object.")
return validation
if method == "POST" and endpoint == "/users":
await self._validate_create_user_payload(data, validation)
return validation
if method == "PATCH" and endpoint == "/users":
await self._validate_update_user_payload(data, validation)
return validation
if method == "POST" and (match := _USER_ACTION_RE.match(endpoint)):
user_uuid = match.group("user_uuid")
self._validate_non_empty_string(user_uuid, "user uuid", validation)
await self._validate_remote_user(user_uuid, validation)
return validation
if method == "DELETE" and endpoint.startswith("/users/"):
user_uuid = endpoint.removeprefix("/users/").strip()
self._validate_non_empty_string(user_uuid, "user uuid", validation)
await self._validate_remote_user(user_uuid, validation)
return validation
if method == "POST" and endpoint == "/hwid/devices/delete":
user_uuid = self._validate_non_empty_string(
data.get("userUuid"),
"userUuid",
validation,
)
self._validate_non_empty_string(data.get("hwid"), "hwid", validation)
await self._validate_remote_user(user_uuid, validation)
return validation
if match := _INTERNAL_SQUAD_BULK_RE.match(endpoint):
squad_uuid = match.group("squad_uuid")
self._validate_non_empty_string(squad_uuid, "squad uuid", validation)
user_uuids = self._validate_string_list(data.get("userUuids"), "userUuids", validation)
if not user_uuids:
user_uuids = self._validate_string_list(data.get("users"), "users", validation)
await self._validate_remote_squads([squad_uuid], validation)
for user_uuid in user_uuids:
await self._validate_remote_user(user_uuid, validation)
return validation
if payload is None:
return validation
self._validate_json_serializable(payload, validation)
return validation
async def _validate_create_user_payload(
self,
payload: Dict[str, Any],
validation: _DryRunValidation,
) -> None:
username = self._validate_non_empty_string(payload.get("username"), "username", validation)
if username and (
not (3 <= len(username) <= 36) or not re.match(r"^[A-Za-z0-9_-]+$", username)
):
validation.add("username must be 3-36 chars and contain only A-Z, 0-9, _ or -.")
self._validate_user_mutation_payload(payload, validation, require_uuid=False)
await self._validate_remote_squads(
self._validate_string_list(
payload.get("activeInternalSquads"),
"activeInternalSquads",
validation,
required=False,
),
validation,
)
if not bool(getattr(self.settings, "PANEL_DRY_RUN_SYNTHETIC_CREATE", True)):
validation.add("PANEL_DRY_RUN_SYNTHETIC_CREATE is disabled.")
if self._remote_validation_enabled and username:
await self._validate_create_uniqueness(payload, validation)
async def _validate_update_user_payload(
self,
payload: Dict[str, Any],
validation: _DryRunValidation,
) -> None:
user_uuid = self._validate_non_empty_string(payload.get("uuid"), "uuid", validation)
self._validate_user_mutation_payload(payload, validation, require_uuid=True)
await self._validate_remote_user(user_uuid, validation)
await self._validate_remote_squads(
self._validate_string_list(
payload.get("activeInternalSquads"),
"activeInternalSquads",
validation,
required=False,
),
validation,
)
def _validate_user_mutation_payload(
self,
payload: Dict[str, Any],
validation: _DryRunValidation,
*,
require_uuid: bool,
) -> None:
if require_uuid:
self._validate_non_empty_string(payload.get("uuid"), "uuid", validation)
if "expireAt" in payload:
self._validate_datetime(payload.get("expireAt"), "expireAt", validation)
if "trafficLimitBytes" in payload:
self._validate_non_negative_int(
payload.get("trafficLimitBytes"),
"trafficLimitBytes",
validation,
)
if "trafficLimitStrategy" in payload:
strategy = self._validate_non_empty_string(
payload.get("trafficLimitStrategy"),
"trafficLimitStrategy",
validation,
)
if strategy and strategy.upper() not in _KNOWN_TRAFFIC_STRATEGIES:
validation.add(f"trafficLimitStrategy {strategy!r} is not supported.")
if "hwidDeviceLimit" in payload:
self._validate_non_negative_int(
payload.get("hwidDeviceLimit"),
"hwidDeviceLimit",
validation,
)
if "telegramId" in payload:
self._validate_positive_int(payload.get("telegramId"), "telegramId", validation)
if "email" in payload and payload.get("email") is not None:
self._validate_non_empty_string(payload.get("email"), "email", validation)
if "externalSquadUuid" in payload and payload.get("externalSquadUuid") is not None:
self._validate_non_empty_string(
payload.get("externalSquadUuid"),
"externalSquadUuid",
validation,
)
self._validate_json_serializable(payload, validation)
@property
def _remote_validation_enabled(self) -> bool:
return bool(getattr(self.settings, "PANEL_DRY_RUN_VALIDATE_REMOTE", True))
async def _validate_remote_user(
self,
user_uuid: Optional[str],
validation: _DryRunValidation,
) -> Optional[Dict[str, Any]]:
if not user_uuid or not self._remote_validation_enabled:
return self._synthetic_users.get(str(user_uuid or ""))
user = self._synthetic_users.get(str(user_uuid))
if user:
return user
try:
user = await super().get_user_by_uuid(str(user_uuid), log_response=False)
except Exception as exc:
validation.add(f"failed to validate panel user {user_uuid}: {type(exc).__name__}")
return None
if not user:
validation.add(f"panel user {user_uuid} was not found.")
return user
async def _validate_remote_squads(
self,
squad_uuids: List[str],
validation: _DryRunValidation,
) -> None:
if not squad_uuids or not self._remote_validation_enabled:
return
try:
squads = await super().get_internal_squads()
except Exception as exc:
validation.add(f"failed to validate panel squads: {type(exc).__name__}")
return
if squads is None:
validation.add("failed to validate panel squads: empty panel response.")
return
known = {
str(squad.get("uuid") or squad.get("id") or "").strip()
for squad in squads
if isinstance(squad, dict)
}
missing = sorted({squad_uuid for squad_uuid in squad_uuids if squad_uuid not in known})
if missing:
validation.add(f"panel squads were not found: {', '.join(missing)}.")
async def _validate_create_uniqueness(
self,
payload: Dict[str, Any],
validation: _DryRunValidation,
) -> None:
checks = (
("username", "username", payload.get("username")),
("telegramId", "telegram_id", payload.get("telegramId")),
("email", "email", payload.get("email")),
)
for label, argument_name, value in checks:
if value in (None, ""):
continue
try:
users = await super().get_users_by_filter(**{argument_name: value})
except Exception as exc:
validation.add(f"failed to validate unique {label}: {type(exc).__name__}")
continue
if users:
validation.add(f"panel user with {label} {value!r} already exists.")
@staticmethod
def _validate_non_empty_string(
value: Any,
name: str,
validation: _DryRunValidation,
) -> Optional[str]:
if not isinstance(value, str) or not value.strip():
validation.add(f"{name} must be a non-empty string.")
return None
return value.strip()
@staticmethod
def _validate_string_list(
value: Any,
name: str,
validation: _DryRunValidation,
*,
required: bool = True,
) -> List[str]:
if value is None:
if required:
validation.add(f"{name} must be a list of strings.")
return []
if not isinstance(value, list):
validation.add(f"{name} must be a list of strings.")
return []
result = []
for item in value:
if not isinstance(item, str) or not item.strip():
validation.add(f"{name} contains an empty or non-string value.")
continue
result.append(item.strip())
return result
@staticmethod
def _validate_non_negative_int(
value: Any,
name: str,
validation: _DryRunValidation,
) -> None:
try:
parsed = int(value)
except (TypeError, ValueError):
validation.add(f"{name} must be an integer.")
return
if parsed < 0:
validation.add(f"{name} must be >= 0.")
@staticmethod
def _validate_positive_int(value: Any, name: str, validation: _DryRunValidation) -> None:
try:
parsed = int(value)
except (TypeError, ValueError):
validation.add(f"{name} must be an integer.")
return
if parsed <= 0:
validation.add(f"{name} must be > 0.")
@staticmethod
def _validate_datetime(value: Any, name: str, validation: _DryRunValidation) -> None:
if not isinstance(value, str) or not value.strip():
validation.add(f"{name} must be an ISO datetime string.")
return
try:
datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
validation.add(f"{name} must be a valid ISO datetime string.")
@staticmethod
def _validate_json_serializable(value: Any, validation: _DryRunValidation) -> None:
try:
json.dumps(value, default=str)
except (TypeError, ValueError):
validation.add("payload must be JSON serializable.")
async def _dry_run_response(
self,
method: str,
endpoint: str,
payload: Any,
) -> Dict[str, Any]:
data = payload if isinstance(payload, dict) else {}
if method == "POST" and endpoint == "/users":
return self._dry_run_create_user_response(data)
if method == "PATCH" and endpoint == "/users":
return await self._dry_run_patch_user_response(data)
if method == "POST" and (match := _USER_ACTION_RE.match(endpoint)):
return self._dry_run_user_action_response(
match.group("user_uuid"),
match.group("action"),
)
if method == "DELETE" and endpoint.startswith("/users/"):
return {"uuid": endpoint.removeprefix("/users/"), "deleted": True, "dryRun": True}
if method == "POST" and endpoint == "/hwid/devices/delete":
return {"userUuid": data.get("userUuid"), "hwid": data.get("hwid"), "dryRun": True}
if match := _INTERNAL_SQUAD_BULK_RE.match(endpoint):
return {
"squadUuid": match.group("squad_uuid"),
"action": match.group("action"),
"users": data.get("userUuids") or data.get("users") or [],
"dryRun": True,
}
return {"dryRun": True}
def _dry_run_create_user_response(self, payload: Dict[str, Any]) -> Dict[str, Any]:
identity = ":".join(
str(payload.get(key) or "") for key in ("username", "telegramId", "email")
)
user_uuid = str(uuid.uuid5(uuid.NAMESPACE_URL, f"remnawave-minishop:dry-run:{identity}"))
short_uuid = user_uuid.split("-")[0]
response = {
**payload,
"uuid": user_uuid,
"shortUuid": short_uuid,
"subscriptionUuid": short_uuid,
"subscriptionUrl": self._subscription_url(short_uuid),
"dryRun": True,
}
self._synthetic_users[user_uuid] = response
return response
async def _dry_run_patch_user_response(self, payload: Dict[str, Any]) -> Dict[str, Any]:
user_uuid = str(payload.get("uuid") or "")
existing = self._synthetic_users.get(user_uuid)
if not existing and self._remote_validation_enabled:
try:
existing = await super().get_user_by_uuid(user_uuid, log_response=False)
except Exception:
existing = None
response = {**(existing or {"uuid": user_uuid}), **payload, "dryRun": True}
if user_uuid in self._synthetic_users:
self._synthetic_users[user_uuid] = response
return response
@staticmethod
def _dry_run_user_action_response(user_uuid: str, action: str) -> Dict[str, Any]:
response: Dict[str, Any] = {"uuid": user_uuid, "action": action, "dryRun": True}
if action == "enable":
response["status"] = "ACTIVE"
elif action == "disable":
response["status"] = "DISABLED"
elif action == "reset-traffic":
response["userTraffic"] = {"usedTrafficBytes": 0}
return response
def _subscription_url(self, short_uuid: str) -> Optional[str]:
if not self.settings.PANEL_API_URL:
return None
return f"{self.settings.PANEL_API_URL.rstrip('/')}/sub/{short_uuid}"
+339 -118
View File
@@ -3,12 +3,15 @@ import hashlib
import hmac
import json
import logging
from datetime import datetime, timedelta, timezone
from typing import Optional
from aiogram import Bot
from aiogram.types import InlineKeyboardMarkup
from aiohttp import web
from sqlalchemy.orm import sessionmaker
from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload, sessionmaker
from bot.infra.webhook_queue import enqueue_webhook_event
from bot.keyboards.inline.user_keyboards import (
@@ -16,18 +19,40 @@ from bot.keyboards.inline.user_keyboards import (
get_subscribe_only_markup,
)
from bot.middlewares.i18n import JsonI18n
from bot.services.subscription_lifecycle_notifications import (
SubscriptionLifecycleNotificationService,
SubscriptionNotificationStage,
)
from config.settings import Settings
from db.dal import tariff_dal, user_dal
from db.dal import subscription_dal, tariff_dal, user_dal
from db.models import Subscription, User
from .email_auth_service import EmailAuthService
from .email_templates import render_subscription_expiring
from .panel_api_service import PanelApiService
EVENT_MAP = {
"user.expires_in_72_hours": (3, "subscription_72h_notification"),
"user.expires_in_48_hours": (2, "subscription_48h_notification"),
"user.expires_in_24_hours": (1, "subscription_24h_notification"),
"user.expires_in_72_hours": SubscriptionNotificationStage(
key="before_3d",
message_key="subscription_72h_notification",
days_left=3,
),
"user.expires_in_48_hours": SubscriptionNotificationStage(
key="before_2d",
message_key="subscription_48h_notification",
days_left=2,
),
"user.expires_in_24_hours": SubscriptionNotificationStage(
key="before_1d",
message_key="subscription_24h_notification",
days_left=1,
),
}
ACTIONABLE_EVENTS = frozenset(
{
*EVENT_MAP.keys(),
"user.expired",
"user.expired_24_hours_ago",
}
)
class PanelWebhookService:
@@ -48,6 +73,11 @@ class PanelWebhookService:
self.i18n = i18n
self.async_session_factory = async_session_factory
self.panel_service = panel_service
self.lifecycle_notifications = SubscriptionLifecycleNotificationService(
settings,
bot,
i18n,
)
self._event_semaphore = asyncio.Semaphore(self._MAX_CONCURRENT_EVENTS)
if not self.settings.PANEL_WEBHOOK_SECRET:
logging.error(
@@ -68,9 +98,7 @@ class PanelWebhookService:
text = _(message_key, **kwargs)
if extra_text:
text = f"{text}\n\n{extra_text}"
await self.bot.send_message(
user_id, text, reply_markup=reply_markup
)
await self.bot.send_message(user_id, text, reply_markup=reply_markup)
except Exception:
logging.exception("Failed to send notification to %s", user_id)
@@ -104,113 +132,201 @@ class PanelWebhookService:
)
async def handle_event(self, event_name: str, user_payload: dict):
telegram_id = user_payload.get("telegramId")
if not telegram_id:
logging.warning("Panel webhook without telegramId received")
return
user_id = int(telegram_id)
if not self.settings.SUBSCRIPTION_NOTIFICATIONS_ENABLED:
return
if event_name not in ACTIONABLE_EVENTS:
logging.info(
"Panel webhook event %s ignored: event is not used for subscription "
"notifications; %s",
event_name,
self._payload_log_context(user_payload),
)
return
async with self.async_session_factory() as session:
db_user = await user_dal.get_user_by_telegram_id(session, user_id)
if not db_user:
db_user = await user_dal.get_user_by_id(session, user_id)
internal_user_id = db_user.user_id if db_user else user_id
db_user = await self._user_for_payload(session, user_payload)
sub = await self._subscription_for_payload(session, user_payload, db_user)
telegram_id = self._payload_telegram_id(user_payload)
internal_user_id = (
int(db_user.user_id)
if db_user
else int(getattr(sub, "user_id", 0) or telegram_id or 0)
)
lang = (
db_user.language_code
if db_user and db_user.language_code
else self.settings.DEFAULT_LANGUAGE
)
first_name = db_user.first_name or f"User {user_id}" if db_user else f"User {user_id}"
user_email = (db_user.email or "").strip() if db_user else ""
if not sub:
if not telegram_id:
local_user_id = getattr(db_user, "user_id", None) if db_user else None
logging.warning(
"Panel webhook event %s cannot be matched to a local subscription; "
"notification skipped. %s local_user_id=%s. Possible causes: "
"panel user was created outside the bot, subscription was deleted "
"or not synced, panel identifiers changed, or skip_notifications "
"is enabled for the local subscription.",
event_name,
self._payload_log_context(user_payload),
local_user_id or "N/A",
)
return
await self._send_legacy_without_dedupe(
event_name,
user_payload,
int(telegram_id),
lang,
db_user,
)
return
markup = get_subscribe_only_markup(lang, self.i18n)
markup = get_subscribe_only_markup(lang, self.i18n)
end_date_text = self._payload_expire_date(user_payload)
if event_name in EVENT_MAP:
days_left, msg_key = EVENT_MAP[event_name]
hwid_renewal_note = await self._hwid_renewal_note(internal_user_id, lang)
if days_left == 1:
# Trigger auto-renew via SubscriptionService (wired in at factory)
try:
subscription_service = getattr(self, "subscription_service", None)
if subscription_service:
async with self.async_session_factory() as session:
from db.dal import subscription_dal
sub = await subscription_dal.get_active_subscription_by_user_id(
session, internal_user_id
)
if sub and sub.auto_renew_enabled and sub.provider == "yookassa":
try:
ok = await subscription_service.charge_subscription_renewal(
session, sub
if event_name in EVENT_MAP:
stage = EVENT_MAP[event_name]
days_left = int(stage.days_left or 0)
hwid_renewal_note = await self._hwid_renewal_note(internal_user_id, lang)
if days_left == 1:
# Trigger auto-renew via SubscriptionService (wired in at factory)
try:
subscription_service = getattr(self, "subscription_service", None)
if subscription_service:
async with self.async_session_factory() as renewal_session:
active_sub = (
await subscription_dal.get_active_subscription_by_user_id(
renewal_session,
internal_user_id,
)
# If initiation succeeded, suppress the 24h reminder by returning early # noqa: E501
if ok:
await session.commit()
return
else:
await session.rollback()
except Exception:
await session.rollback()
logging.exception("Auto-renew attempt (24h) failed")
except Exception:
logging.exception("Auto-renew trigger (24h) failed pre-check")
if days_left <= self.settings.SUBSCRIPTION_NOTIFY_DAYS_BEFORE:
# For 48h event, if auto-renew is enabled, show special notice with cancel button
if days_left == 2:
async with self.async_session_factory() as session:
from db.dal import subscription_dal
sub = await subscription_dal.get_active_subscription_by_user_id(
session, internal_user_id
)
if (
active_sub
and active_sub.auto_renew_enabled
and active_sub.provider == "yookassa"
):
try:
ok = await subscription_service.charge_subscription_renewal(
renewal_session,
active_sub,
)
# If initiation succeeded, suppress the 24h reminder by returning early # noqa: E501
if ok:
await renewal_session.commit()
return
await renewal_session.rollback()
except Exception:
await renewal_session.rollback()
logging.exception("Auto-renew attempt (24h) failed")
except Exception:
logging.exception("Auto-renew trigger (24h) failed pre-check")
if days_left <= self.settings.SUBSCRIPTION_NOTIFY_DAYS_BEFORE:
# For 48h, auto-renew users get a cancel button instead.
if days_left == 2:
active_sub = await subscription_dal.get_active_subscription_by_user_id(
session,
internal_user_id,
)
logging.info(
"48h webhook check: user_id=%s sub_found=%s auto_renew=%s provider=%s",
user_id,
bool(sub),
getattr(sub, "auto_renew_enabled", None) if sub else None,
getattr(sub, "provider", None) if sub else None,
internal_user_id,
bool(active_sub),
getattr(active_sub, "auto_renew_enabled", None) if active_sub else None,
getattr(active_sub, "provider", None) if active_sub else None,
)
if sub and sub.auto_renew_enabled and sub.provider == "yookassa":
if (
active_sub
and active_sub.auto_renew_enabled
and active_sub.provider == "yookassa"
):
cancel_kb = get_autorenew_cancel_keyboard(lang, self.i18n)
await self._send_message(
user_id,
lang,
"autorenew_48h_charge_tomorrow_notice",
reply_markup=cancel_kb,
user_name=first_name,
await self.lifecycle_notifications.send_stage(
session,
sub,
SubscriptionNotificationStage(
key="before_2d_autorenew",
message_key="autorenew_48h_charge_tomorrow_notice",
days_left=2,
),
user=db_user,
telegram_markup=cancel_kb,
extra_text=hwid_renewal_note,
end_date_text=end_date_text,
)
await session.commit()
return
await self._send_message(
user_id,
lang,
msg_key,
reply_markup=markup,
user_name=first_name,
end_date=user_payload.get("expireAt", "")[:10],
extra_text=hwid_renewal_note,
)
if days_left == 3 and user_email:
await self._send_subscription_expiring_email(
recipient=user_email,
lang=lang,
days_left=days_left,
end_date_text=user_payload.get("expireAt", "")[:10],
await self.lifecycle_notifications.send_stage(
session,
sub,
stage,
user=db_user,
telegram_markup=markup,
extra_text=hwid_renewal_note,
end_date_text=end_date_text,
)
elif event_name == "user.expired":
if self.settings.SUBSCRIPTION_NOTIFY_ON_EXPIRE:
await self._send_message(
user_id,
lang,
"subscription_expired_notification",
reply_markup=markup,
user_name=first_name,
end_date=user_payload.get("expireAt", "")[:10],
await session.commit()
elif event_name == "user.expired":
if self.settings.SUBSCRIPTION_NOTIFY_ON_EXPIRE:
await self.lifecycle_notifications.send_stage(
session,
sub,
SubscriptionNotificationStage(
key="expired",
message_key="subscription_expired_notification",
days_left=0,
),
user=db_user,
telegram_markup=markup,
end_date_text=end_date_text,
)
await session.commit()
elif (
event_name == "user.expired_24_hours_ago"
and self.settings.SUBSCRIPTION_NOTIFY_AFTER_EXPIRE
):
await self.lifecycle_notifications.send_stage(
session,
sub,
SubscriptionNotificationStage(
key="expired_24h_after",
message_key="subscription_expired_yesterday_notification",
days_left=0,
),
user=db_user,
telegram_markup=markup,
end_date_text=end_date_text,
)
await session.commit()
async def _send_legacy_without_dedupe(
self,
event_name: str,
user_payload: dict,
user_id: int,
lang: str,
db_user: Optional[User],
) -> None:
first_name = getattr(db_user, "first_name", None) or f"User {user_id}"
markup = get_subscribe_only_markup(lang, self.i18n)
if event_name in EVENT_MAP:
stage = EVENT_MAP[event_name]
await self._send_message(
user_id,
lang,
stage.message_key,
reply_markup=markup,
user_name=first_name,
end_date=self._payload_expire_date(user_payload),
)
elif event_name == "user.expired" and self.settings.SUBSCRIPTION_NOTIFY_ON_EXPIRE:
await self._send_message(
user_id,
lang,
"subscription_expired_notification",
reply_markup=markup,
user_name=first_name,
end_date=self._payload_expire_date(user_payload),
)
elif (
event_name == "user.expired_24_hours_ago"
and self.settings.SUBSCRIPTION_NOTIFY_AFTER_EXPIRE
@@ -221,33 +337,138 @@ class PanelWebhookService:
"subscription_expired_yesterday_notification",
reply_markup=markup,
user_name=first_name,
end_date=user_payload.get("expireAt", "")[:10],
end_date=self._payload_expire_date(user_payload),
)
async def _send_subscription_expiring_email(
async def _user_for_payload(
self,
*,
recipient: str,
lang: str,
days_left: int,
end_date_text: str,
) -> None:
"""Best-effort branded reminder; silently no-ops without SMTP config."""
if not self.settings.email_auth_configured:
return
try:
content = render_subscription_expiring(
self.settings,
language_code=lang,
days_left=days_left,
end_date_text=end_date_text,
dashboard_url=(self.settings.SUBSCRIPTION_MINI_APP_URL or "").strip() or None,
i18n=self.i18n,
session: AsyncSession,
user_payload: dict,
) -> Optional[User]:
telegram_id = self._payload_telegram_id(user_payload)
if telegram_id:
user = await user_dal.get_user_by_telegram_id(session, telegram_id)
if user:
return user
user = await user_dal.get_user_by_id(session, telegram_id)
if user:
return user
panel_uuid = self._payload_panel_uuid(user_payload)
if panel_uuid:
user = await user_dal.get_user_by_panel_uuid(session, panel_uuid)
if user:
return user
email = str(user_payload.get("email") or "").strip()
if email:
return await user_dal.get_user_by_email(session, email)
return None
async def _subscription_for_payload(
self,
session: AsyncSession,
user_payload: dict,
db_user: Optional[User],
) -> Optional[Subscription]:
conditions = []
if db_user:
conditions.append(Subscription.user_id == db_user.user_id)
panel_uuid = self._payload_panel_uuid(user_payload)
if panel_uuid:
conditions.append(Subscription.panel_user_uuid == panel_uuid)
if not conditions:
return None
base_stmt = (
select(Subscription)
.where(
Subscription.skip_notifications == False,
or_(*conditions),
)
email_service = EmailAuthService(self.settings, self.i18n)
await email_service.send_rendered_email(email=recipient, content=content)
except Exception:
logging.exception("Failed to send subscription-expiring email to %s", recipient)
.options(selectinload(Subscription.user))
)
expire_at = self._payload_expire_datetime(user_payload)
if expire_at is not None:
window_stmt = (
base_stmt.where(
Subscription.end_date >= expire_at - timedelta(days=1),
Subscription.end_date <= expire_at + timedelta(days=1),
)
.order_by(Subscription.end_date.desc())
.limit(1)
)
result = await session.execute(window_stmt)
found = result.scalars().first()
if found:
return found
stmt = base_stmt.order_by(Subscription.end_date.desc()).limit(1)
result = await session.execute(stmt)
return result.scalars().first()
@staticmethod
def _payload_telegram_id(user_payload: dict) -> Optional[int]:
raw = user_payload.get("telegramId")
try:
value = int(raw or 0)
except (TypeError, ValueError):
return None
return value if value > 0 else None
@staticmethod
def _payload_panel_uuid(user_payload: dict) -> str:
return str(
user_payload.get("uuid")
or user_payload.get("userUuid")
or user_payload.get("shortUuid")
or ""
).strip()
@staticmethod
def _payload_expire_date(user_payload: dict) -> str:
return str(user_payload.get("expireAt") or "")[:10]
@staticmethod
def _payload_log_context(user_payload: dict) -> str:
telegram_id = PanelWebhookService._payload_telegram_id(user_payload)
panel_uuid = PanelWebhookService._payload_panel_uuid(user_payload)
email = PanelWebhookService._mask_email(str(user_payload.get("email") or "").strip())
expire_at = str(user_payload.get("expireAt") or "").strip()
payload_keys = ",".join(sorted(str(key) for key in user_payload.keys())) or "none"
return (
f"telegramId={telegram_id or 'N/A'} "
f"panel_uuid={panel_uuid or 'N/A'} "
f"email={email or 'N/A'} "
f"expireAt={expire_at or 'N/A'} "
f"payload_keys={payload_keys}"
)
@staticmethod
def _mask_email(email: str) -> str:
if not email:
return ""
local_part, separator, domain = email.partition("@")
if not separator or not domain:
return "present"
visible = local_part[:2] if len(local_part) > 2 else local_part[:1]
return f"{visible}***@{domain}"
@staticmethod
def _payload_expire_datetime(user_payload: dict) -> Optional[datetime]:
raw = str(user_payload.get("expireAt") or "").strip()
if not raw:
return None
try:
value = datetime.fromisoformat(raw.replace("Z", "+00:00"))
except ValueError:
try:
value = datetime.fromisoformat(raw[:10])
except ValueError:
return None
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
async def handle_webhook(
self, raw_body: bytes, signature_header: Optional[str]
+88 -20
View File
@@ -7,6 +7,7 @@ from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from bot.middlewares.i18n import JsonI18n
from bot.services.user_email_notifications import send_user_notification_email
from config.settings import Settings
from db.dal import payment_dal, subscription_dal, user_dal
@@ -33,6 +34,7 @@ class ReferralService:
purchased_subscription_months: int,
current_payment_db_id: Optional[int] = None,
skip_if_active_before_payment: bool = True,
tariff_key: Optional[str] = None,
) -> Dict[str, Any]:
referee_final_end_date: Optional[datetime] = None
@@ -94,11 +96,9 @@ class ReferralService:
else self.i18n.gettext(default_lang_for_placeholder, "friend_placeholder")
)
inviter_bonus_days = self.settings.referral_bonus_inviter.get(
purchased_subscription_months
)
referee_bonus_days = self.settings.referral_bonus_referee.get(
purchased_subscription_months
inviter_bonus_days, referee_bonus_days = self._referral_bonus_days_for_payment(
purchased_subscription_months,
tariff_key=tariff_key,
)
if inviter_bonus_days and inviter_bonus_days > 0:
@@ -142,18 +142,36 @@ class ReferralService:
inviter_user_model.language_code or default_lang_for_placeholder
)
_i = lambda k, **kw: self.i18n.gettext(inviter_lang, k, **kw)
await self.bot.send_message(
inviter_user_id,
_i(
"referral_bonus_inviter_notification_extended",
days=inviter_bonus_days,
referee_name=referee_name_for_msg,
new_end_date=new_end_date_inviter.strftime("%Y-%m-%d"),
message_text = _i(
"referral_bonus_inviter_notification_extended",
days=inviter_bonus_days,
referee_name=referee_name_for_msg,
new_end_date=new_end_date_inviter.strftime("%Y-%m-%d"),
)
try:
await self.bot.send_message(inviter_user_id, message_text)
except Exception as e_notify_inviter:
logging.error(
f"Failed to send bonus notification to inviter {inviter_user_id}: {e_notify_inviter}" # noqa: E501
)
await send_user_notification_email(
settings=self.settings,
i18n=self.i18n,
user=inviter_user_model,
subject_key="email_referral_bonus_subject",
message_text=message_text,
dashboard_url=(
getattr(
self.settings,
"SUBSCRIPTION_MINI_APP_URL",
"",
)
or None
),
)
except Exception as e_notify_inviter:
logging.error(
f"Failed to send bonus notification to inviter {inviter_user_id}: {e_notify_inviter}" # noqa: E501
f"Failed to prepare bonus notification for inviter {inviter_user_id}: {e_notify_inviter}" # noqa: E501
)
else:
logging.info(
@@ -210,13 +228,34 @@ class ReferralService:
_i = lambda k, **kw: self.i18n.gettext(
inviter_lang, k, **kw
)
await self.bot.send_message(
inviter_user_id,
_i(
"referral_bonus_inviter_notification_new_sub",
days=inviter_bonus_days,
referee_name=referee_name_for_msg,
new_end_date=bonus_end_date.strftime("%Y-%m-%d"),
message_text = _i(
"referral_bonus_inviter_notification_new_sub",
days=inviter_bonus_days,
referee_name=referee_name_for_msg,
new_end_date=bonus_end_date.strftime("%Y-%m-%d"),
)
try:
await self.bot.send_message(
inviter_user_id,
message_text,
)
except Exception as e_notify_inviter:
logging.error(
f"Failed to send bonus notification to inviter {inviter_user_id}: {e_notify_inviter}" # noqa: E501
)
await send_user_notification_email(
settings=self.settings,
i18n=self.i18n,
user=inviter_user_model,
subject_key="email_referral_bonus_subject",
message_text=message_text,
dashboard_url=(
getattr(
self.settings,
"SUBSCRIPTION_MINI_APP_URL",
"",
)
or None
),
)
else:
@@ -263,6 +302,35 @@ class ReferralService:
raise
def _referral_bonus_days_for_payment(
self,
purchased_subscription_months: int,
*,
tariff_key: Optional[str] = None,
) -> tuple[Optional[int], Optional[int]]:
months = int(purchased_subscription_months)
tariffs_config = getattr(self.settings, "tariffs_config", None)
if tariff_key and tariffs_config:
try:
tariff = tariffs_config.require(str(tariff_key))
except Exception:
logging.warning(
"Referral bonuses skipped: tariff %s was not found.",
tariff_key,
)
return None, None
if tariff.billing_model != "period":
return None, None
return (
tariff.referral_inviter_bonus_days(months),
tariff.referral_referee_bonus_days(months),
)
return (
self.settings.referral_bonus_inviter.get(months),
self.settings.referral_bonus_referee.get(months),
)
async def generate_referral_link(
self, session: AsyncSession, bot_username: str, inviter_user_id: int
) -> Optional[str]:
@@ -263,6 +263,35 @@ async def load_overrides_from_db(settings: Settings, async_session_factory: sess
return applied
async def refresh_overrides_from_db(
settings: Settings,
async_session_factory: sessionmaker,
*,
keys: Optional[set[str]] = None,
) -> int:
"""Refresh already-known runtime overrides without startup restore side effects."""
try:
async with async_session_factory() as session:
overrides = await app_settings_dal.get_all_overrides(session)
except Exception as exc:
logger.warning("Could not refresh setting overrides from DB: %s", exc)
return 0
if keys is not None:
try:
env_only = Settings()
for key in keys:
if key in overrides:
continue
attr_name = _resolve_attribute_name(env_only, key)
if attr_name and hasattr(env_only, attr_name):
setattr(settings, attr_name, getattr(env_only, attr_name))
except Exception as exc:
logger.warning("Failed to restore env defaults while refreshing overrides: %s", exc)
overrides = {key: value for key, value in overrides.items() if key in keys}
return apply_overrides(settings, overrides)
async def update_overrides(
settings: Settings,
async_session_factory: sessionmaker,
@@ -0,0 +1,386 @@
import logging
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Optional
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
from aiogram import Bot
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
from aiogram.types import InlineKeyboardMarkup
from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.inline.user_keyboards import get_subscribe_only_markup
from bot.middlewares.i18n import JsonI18n
from bot.services.email_auth_service import EmailAuthService
from bot.services.email_templates import render_subscription_lifecycle_notification
from bot.services.message_audit import log_user_message_delivery
from bot.services.telegram_notifications import (
TELEGRAM_NOTIFICATIONS_BLOCKED,
TELEGRAM_NOTIFICATIONS_ENABLED,
TELEGRAM_NOTIFICATIONS_NEEDS_START,
mark_telegram_notifications_status,
normalize_telegram_notification_status,
telegram_notification_status_from_error,
)
from config.settings import Settings
from db.dal import subscription_dal
from db.models import Subscription, User
@dataclass(frozen=True)
class SubscriptionNotificationStage:
key: str
message_key: str
days_left: Optional[int] = None
hours_before: Optional[int] = None
@dataclass(frozen=True)
class SubscriptionNotificationDelivery:
telegram_sent: bool = False
email_sent: bool = False
@property
def any_sent(self) -> bool:
return self.telegram_sent or self.email_sent
class SubscriptionLifecycleNotificationService:
def __init__(
self,
settings: Settings,
bot: Bot,
i18n: JsonI18n,
*,
email_service: Optional[EmailAuthService] = None,
) -> None:
self.settings = settings
self.bot = bot
self.i18n = i18n
self.email_service = email_service
async def send_stage(
self,
session: AsyncSession,
sub: Subscription,
stage: SubscriptionNotificationStage,
*,
user: Optional[User] = None,
telegram_markup: Optional[InlineKeyboardMarkup] = None,
extra_text: str = "",
end_date_text: Optional[str] = None,
sent_at: Optional[datetime] = None,
) -> SubscriptionNotificationDelivery:
if sent_at is None:
sent_at = datetime.now(timezone.utc)
resolved_user = user or getattr(sub, "user", None)
lang = getattr(resolved_user, "language_code", None) or self.settings.DEFAULT_LANGUAGE
user_id = int(getattr(sub, "user_id", 0) or 0)
final_end_date_text = end_date_text
if final_end_date_text is None:
end_date = self._as_utc(getattr(sub, "end_date", None))
final_end_date_text = end_date.strftime("%Y-%m-%d") if end_date else ""
recipient_email = self._email_recipient(resolved_user)
telegram_user_name = self._telegram_display_name(resolved_user, user_id)
email_user_name = self._email_display_name(
resolved_user,
recipient_email=recipient_email,
fallback=telegram_user_name,
)
kwargs = {"user_name": telegram_user_name, "end_date": final_end_date_text}
if stage.hours_before is not None:
kwargs["hours"] = stage.hours_before
message_text = self.i18n.gettext(lang, stage.message_key, **kwargs)
email_kwargs = {**kwargs, "user_name": email_user_name}
email_message_text = self.i18n.gettext(lang, stage.message_key, **email_kwargs)
final_extra_text = str(extra_text or "").strip()
if final_extra_text:
message_text = f"{message_text}\n\n{final_extra_text}"
email_message_text = f"{email_message_text}\n\n{final_extra_text}"
telegram_sent = await self._send_telegram(
session,
sub,
stage,
resolved_user,
lang=lang,
message_text=message_text,
markup=telegram_markup or get_subscribe_only_markup(lang, self.i18n),
sent_at=sent_at,
)
email_sent = await self._send_email(
session,
sub,
stage,
resolved_user,
lang=lang,
message_text=email_message_text,
end_date_text=final_end_date_text,
recipient=recipient_email,
telegram_sent=telegram_sent,
sent_at=sent_at,
)
return SubscriptionNotificationDelivery(
telegram_sent=telegram_sent,
email_sent=email_sent,
)
async def _send_telegram(
self,
session: AsyncSession,
sub: Subscription,
stage: SubscriptionNotificationStage,
user: Optional[User],
*,
lang: str,
message_text: str,
markup: Optional[InlineKeyboardMarkup],
sent_at: datetime,
) -> bool:
chat_id = self._telegram_chat_id(user, getattr(sub, "user_id", None))
if chat_id is None:
return False
if user:
status = normalize_telegram_notification_status(
getattr(user, "telegram_notifications_status", None)
)
if status in {TELEGRAM_NOTIFICATIONS_NEEDS_START, TELEGRAM_NOTIFICATIONS_BLOCKED}:
return False
if await self._already_sent(session, sub.subscription_id, stage.key, "telegram"):
return False
try:
await self.bot.send_message(chat_id, message_text, reply_markup=markup)
except (TelegramBadRequest, TelegramForbiddenError) as exc:
delivery_status = telegram_notification_status_from_error(exc)
if user and delivery_status:
await mark_telegram_notifications_status(
session,
int(user.user_id),
delivery_status,
)
if delivery_status:
logging.warning(
"Skipping subscription notification %s for unreachable Telegram user %s: %s",
stage.key,
chat_id,
exc,
)
return False
logging.exception(
"Failed to send subscription notification %s to Telegram user %s",
stage.key,
chat_id,
)
return False
except Exception:
logging.exception(
"Failed to send subscription notification %s to Telegram user %s",
stage.key,
chat_id,
)
return False
await subscription_dal.record_subscription_notification(
session,
sub.subscription_id,
self._channel_key(stage.key, "telegram"),
sent_at=sent_at,
)
await log_user_message_delivery(
session,
target_user_id=getattr(sub, "user_id", None),
event_type="telegram_subscription_notification_sent",
channel="telegram",
recipient=str(chat_id),
content=(
f"stage={stage.key} message_key={stage.message_key} "
f"subscription_id={getattr(sub, 'subscription_id', '')}"
),
timestamp=sent_at,
)
if user:
status = normalize_telegram_notification_status(
getattr(user, "telegram_notifications_status", None)
)
if status != TELEGRAM_NOTIFICATIONS_ENABLED:
await mark_telegram_notifications_status(
session,
int(user.user_id),
TELEGRAM_NOTIFICATIONS_ENABLED,
telegram_id=chat_id,
checked_at=sent_at,
)
return True
async def _send_email(
self,
session: AsyncSession,
sub: Subscription,
stage: SubscriptionNotificationStage,
user: Optional[User],
*,
lang: str,
message_text: str,
end_date_text: str,
recipient: str,
telegram_sent: bool,
sent_at: datetime,
) -> bool:
if not getattr(self.settings, "SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED", True):
return False
if not getattr(self.settings, "email_auth_configured", False):
return False
if not recipient:
return False
if await self._already_sent(session, sub.subscription_id, stage.key, "email"):
return False
try:
content = render_subscription_lifecycle_notification(
self.settings,
language_code=lang,
notification_key=stage.key,
message_text=message_text,
end_date_text=end_date_text,
dashboard_url=self._renewal_dashboard_url(recipient, sub),
mirrored_from_telegram=telegram_sent,
days_left=stage.days_left,
hours_before=stage.hours_before,
i18n=self.i18n,
)
email_service = self.email_service or EmailAuthService(self.settings, self.i18n)
await email_service.send_rendered_email(email=recipient, content=content)
except Exception:
logging.exception(
"Failed to send subscription notification %s to email %s",
stage.key,
recipient,
)
return False
await subscription_dal.record_subscription_notification(
session,
sub.subscription_id,
self._channel_key(stage.key, "email"),
sent_at=sent_at,
)
await log_user_message_delivery(
session,
target_user_id=getattr(sub, "user_id", None),
event_type="email_subscription_notification_sent",
channel="email",
recipient=recipient,
content=(
f"stage={stage.key} message_key={stage.message_key} "
f"subscription_id={getattr(sub, 'subscription_id', '')}"
),
timestamp=sent_at,
)
return True
async def _already_sent(
self,
session: AsyncSession,
subscription_id: int,
stage_key: str,
channel: str,
) -> bool:
channel_key = self._channel_key(stage_key, channel)
if await subscription_dal.has_subscription_notification(
session,
subscription_id,
channel_key,
):
return True
# Legacy rows were stored without a channel. Treat them as Telegram-only
# history so existing installs do not re-send old bot messages, while
# still allowing the newly introduced email channel to catch up.
return channel == "telegram" and await subscription_dal.has_subscription_notification(
session,
subscription_id,
stage_key,
)
@staticmethod
def _channel_key(stage_key: str, channel: str) -> str:
return f"{stage_key}:{channel}"
@staticmethod
def _email_recipient(user: Optional[User]) -> str:
return str(getattr(user, "email", "") or "").strip().lower() if user else ""
@staticmethod
def _telegram_display_name(user: Optional[User], fallback_user_id: int) -> str:
return str(getattr(user, "first_name", "") or "").strip() or f"User {fallback_user_id}"
@staticmethod
def _email_display_name(
user: Optional[User],
*,
recipient_email: str,
fallback: str,
) -> str:
return str(getattr(user, "first_name", "") or "").strip() or recipient_email or fallback
def _renewal_dashboard_url(self, recipient_email: str, sub: Subscription) -> Optional[str]:
base_url = (self.settings.SUBSCRIPTION_MINI_APP_URL or "").strip()
if not base_url:
return None
parsed = urlsplit(base_url)
if parsed.scheme not in ("http", "https") or not parsed.netloc:
return None
query = dict(parse_qsl(parsed.query, keep_blank_values=True))
query.update(
{
"login": "email_code",
"login_email": recipient_email,
"after_login": "renew",
"renew": "1",
}
)
tariff_key = self._renewal_tariff_key(sub)
if tariff_key:
query["renew_tariff"] = tariff_key
else:
query.pop("renew_tariff", None)
return urlunsplit(
(
parsed.scheme,
parsed.netloc,
parsed.path or "/",
urlencode(query),
parsed.fragment,
)
)
@staticmethod
def _renewal_tariff_key(sub: Subscription) -> str:
provider = str(getattr(sub, "provider", "") or "").strip().lower()
status = str(getattr(sub, "status_from_panel", "") or "").strip().upper()
if provider == "trial" or status == "TRIAL":
return ""
return str(getattr(sub, "tariff_key", "") or "").strip()
@staticmethod
def _telegram_chat_id(user: Optional[User], fallback_user_id: Optional[int]) -> Optional[int]:
for candidate in (getattr(user, "telegram_id", None), fallback_user_id):
try:
chat_id = int(candidate or 0)
except (TypeError, ValueError):
continue
if chat_id > 0:
return chat_id
return None
@staticmethod
def _as_utc(value: Optional[datetime]) -> Optional[datetime]:
if value is None:
return None
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
@@ -0,0 +1,402 @@
import asyncio
import logging
import time
from datetime import datetime, timedelta, timezone
from typing import Optional
from aiogram import Bot
from aiogram.utils.text_decorations import html_decoration as hd
from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload, sessionmaker
from bot.infra.redis import redis_lock
from bot.keyboards.inline.user_keyboards import get_subscribe_only_markup
from bot.middlewares.i18n import JsonI18n
from bot.services.message_audit import log_user_message_delivery
from bot.services.panel_api_service import PanelApiService
from bot.services.subscription_lifecycle_notifications import (
SubscriptionLifecycleNotificationService,
SubscriptionNotificationStage,
)
from bot.services.subscription_service import SubscriptionService
from bot.services.telegram_notifications import (
TELEGRAM_NOTIFICATIONS_BLOCKED,
TELEGRAM_NOTIFICATIONS_ENABLED,
TELEGRAM_NOTIFICATIONS_NEEDS_START,
mark_telegram_notifications_status,
normalize_telegram_notification_status,
telegram_notification_status_from_error,
)
from bot.services.user_email_notifications import send_user_notification_email
from config.settings import Settings
from db.advisory_locks import acquire_subscription_background_sync_lock
from db.dal import subscription_dal
from db.models import Subscription
SUBSCRIPTION_NOTIFICATION_LOCK = "subscription-notification-worker"
DEFAULT_SUBSCRIPTION_NOTIFICATION_TICK_SECONDS = 300
EXPIRED_NOTIFICATION_WINDOW = timedelta(hours=24)
EXPIRED_AFTER_NOTIFICATION_WINDOW = timedelta(hours=48)
class SubscriptionNotificationWorker:
def __init__(
self,
settings: Settings,
session_factory: sessionmaker,
bot: Bot,
i18n: JsonI18n,
panel_service: PanelApiService,
subscription_service: SubscriptionService,
) -> None:
self.settings = settings
self.session_factory = session_factory
self.bot = bot
self.i18n = i18n
self.panel_service = panel_service
self.subscription_service = subscription_service
self.lifecycle_notifications = SubscriptionLifecycleNotificationService(
settings,
bot,
i18n,
)
self._stopped = asyncio.Event()
async def run(self) -> None:
while not self._stopped.is_set():
try:
async with redis_lock(
self.settings,
SUBSCRIPTION_NOTIFICATION_LOCK,
ttl_seconds=max(60, self._tick_seconds() - 10),
) as acquired:
if not acquired:
logging.info(
"SubscriptionNotificationWorker tick skipped: Redis lock is held"
)
else:
started = time.monotonic()
async with self.session_factory() as session:
await acquire_subscription_background_sync_lock(session)
await self.expiry_tick(session)
await self.trial_traffic_tick(session)
await session.commit()
logging.info(
"metric worker_tick_duration_seconds=%.3f "
"worker=subscription_notification",
time.monotonic() - started,
)
except Exception:
logging.exception("SubscriptionNotificationWorker tick failed")
try:
await asyncio.wait_for(self._stopped.wait(), timeout=self._tick_seconds())
except asyncio.TimeoutError:
pass
def stop(self) -> None:
self._stopped.set()
def _tick_seconds(self) -> int:
return int(
getattr(
self.settings,
"SUBSCRIPTION_NOTIFICATION_WORKER_TICK_SECONDS",
DEFAULT_SUBSCRIPTION_NOTIFICATION_TICK_SECONDS,
)
or DEFAULT_SUBSCRIPTION_NOTIFICATION_TICK_SECONDS
)
async def expiry_tick(self, session: AsyncSession) -> None:
if not getattr(self.settings, "SUBSCRIPTION_NOTIFICATIONS_ENABLED", True):
return
now = datetime.now(timezone.utc)
lower = now - EXPIRED_AFTER_NOTIFICATION_WINDOW
upper = now + self._max_before_window()
result = await session.execute(
select(Subscription)
.where(
Subscription.skip_notifications == False,
Subscription.end_date >= lower,
Subscription.end_date <= upper,
)
.options(selectinload(Subscription.user))
.order_by(Subscription.end_date.asc())
)
for sub in result.scalars().all():
stage = self.stage_for_subscription(sub, now)
if stage is None:
continue
await self.lifecycle_notifications.send_stage(
session,
sub,
stage,
sent_at=now,
)
def stage_for_subscription(
self,
sub: Subscription,
now: datetime,
) -> Optional[SubscriptionNotificationStage]:
end_date = self._as_utc(getattr(sub, "end_date", None))
if end_date is None:
return None
seconds_left = (end_date - now).total_seconds()
if seconds_left > 0:
hours_before = int(getattr(self.settings, "SUBSCRIPTION_NOTIFY_HOURS_BEFORE", 0) or 0)
if 0 < hours_before <= 23 and seconds_left <= hours_before * 3600:
return SubscriptionNotificationStage(
key=f"before_{hours_before}h",
message_key="subscription_hours_notification",
hours_before=hours_before,
)
days_before_limit = max(
0,
int(getattr(self.settings, "SUBSCRIPTION_NOTIFY_DAYS_BEFORE", 0) or 0),
)
day_stages = (
(1, "subscription_24h_notification"),
(2, "subscription_48h_notification"),
(3, "subscription_72h_notification"),
)
for days_before, message_key in day_stages:
if days_before > days_before_limit:
continue
if seconds_left <= days_before * 24 * 3600:
return SubscriptionNotificationStage(
key=f"before_{days_before}d",
message_key=message_key,
days_left=days_before,
)
return None
expired_for = now - end_date
if (
getattr(self.settings, "SUBSCRIPTION_NOTIFY_ON_EXPIRE", True)
and expired_for <= EXPIRED_NOTIFICATION_WINDOW
):
return SubscriptionNotificationStage(
key="expired",
message_key="subscription_expired_notification",
days_left=0,
)
if (
getattr(self.settings, "SUBSCRIPTION_NOTIFY_AFTER_EXPIRE", True)
and EXPIRED_NOTIFICATION_WINDOW < expired_for <= EXPIRED_AFTER_NOTIFICATION_WINDOW
):
return SubscriptionNotificationStage(
key="expired_24h_after",
message_key="subscription_expired_yesterday_notification",
days_left=0,
)
return None
async def trial_traffic_tick(self, session: AsyncSession) -> None:
if not getattr(self.settings, "SUBSCRIPTION_NOTIFICATIONS_ENABLED", True):
return
now = datetime.now(timezone.utc)
result = await session.execute(
select(Subscription)
.where(
Subscription.skip_notifications == False,
Subscription.is_active == True,
Subscription.end_date > now,
Subscription.traffic_limit_bytes.is_not(None),
Subscription.traffic_limit_bytes > 0,
or_(
Subscription.provider == "trial",
Subscription.status_from_panel == "TRIAL",
Subscription.duration_months == 0,
),
)
.options(selectinload(Subscription.user))
.order_by(Subscription.end_date.asc())
)
for sub in result.scalars().all():
legacy_sent = await subscription_dal.has_subscription_notification(
session,
sub.subscription_id,
"trial_traffic_depleted",
)
telegram_done = legacy_sent or await subscription_dal.has_subscription_notification(
session,
sub.subscription_id,
"trial_traffic_depleted:telegram",
)
email_done = await subscription_dal.has_subscription_notification(
session,
sub.subscription_id,
"trial_traffic_depleted:email",
)
if telegram_done and email_done:
continue
used = int(getattr(sub, "traffic_used_bytes", 0) or 0)
limit = int(getattr(sub, "traffic_limit_bytes", 0) or 0)
panel_data = await self._panel_user(sub)
if panel_data:
panel_used, panel_limit, _ = (
self.subscription_service._extract_panel_traffic_details(panel_data)
)
if panel_used is not None:
used = int(panel_used)
sub.traffic_used_bytes = used
if panel_limit is not None:
limit = int(panel_limit)
sub.traffic_limit_bytes = limit
panel_status = str(panel_data.get("status") or "").upper()
if panel_status:
sub.status_from_panel = panel_status
if limit <= 0 or used < limit:
continue
delivery = await self._send_trial_traffic_depleted(
session,
sub,
used=used,
limit=limit,
send_telegram=not telegram_done,
send_email=not email_done,
)
if not delivery["telegram"] and not delivery["email"]:
continue
if delivery["telegram"]:
await subscription_dal.record_subscription_notification(
session,
sub.subscription_id,
"trial_traffic_depleted:telegram",
sent_at=now,
)
if delivery["email"]:
await subscription_dal.record_subscription_notification(
session,
sub.subscription_id,
"trial_traffic_depleted:email",
sent_at=now,
)
async def _panel_user(self, sub: Subscription) -> Optional[dict]:
panel_uuid = str(getattr(sub, "panel_user_uuid", "") or "").strip()
if not panel_uuid:
return None
try:
data = await self.panel_service.get_user_by_uuid(panel_uuid, log_response=False)
except Exception:
logging.exception(
"SubscriptionNotificationWorker: failed to fetch panel user %s",
panel_uuid,
)
return None
return data if isinstance(data, dict) else None
async def _send_trial_traffic_depleted(
self,
session: AsyncSession,
sub: Subscription,
*,
used: int,
limit: int,
send_telegram: bool = True,
send_email: bool = True,
) -> dict[str, bool]:
user_id = int(getattr(sub, "user_id", 0) or 0)
user = getattr(sub, "user", None)
lang = getattr(user, "language_code", None) or self.settings.DEFAULT_LANGUAGE
translate = lambda k, **kw: self.i18n.gettext(lang, k, **kw)
remaining = max(0, limit - used)
message_text = translate(
"trial_traffic_depleted_notification",
used=hd.quote(self._fmt_bytes(used)),
remaining=hd.quote(self._fmt_bytes(remaining)),
limit_total=hd.quote(self._fmt_bytes(limit)),
)
telegram_sent = False
email_sent = False
telegram_chat_id = int(getattr(user, "telegram_id", 0) or user_id or 0)
telegram_status = normalize_telegram_notification_status(
getattr(user, "telegram_notifications_status", None)
)
can_try_telegram = telegram_status not in {
TELEGRAM_NOTIFICATIONS_NEEDS_START,
TELEGRAM_NOTIFICATIONS_BLOCKED,
}
if send_telegram and telegram_chat_id > 0 and can_try_telegram:
try:
await self.bot.send_message(
telegram_chat_id,
message_text,
reply_markup=get_subscribe_only_markup(lang, self.i18n),
parse_mode="HTML",
)
telegram_sent = True
await log_user_message_delivery(
session,
target_user_id=user_id,
event_type="telegram_traffic_warning_sent",
channel="telegram",
recipient=str(telegram_chat_id),
content=(
"kind=trial warning_key=trial_traffic_depleted "
f"used_bytes={used} limit_bytes={limit}"
),
)
except Exception as exc:
status = telegram_notification_status_from_error(exc)
if status and user and user_id:
await mark_telegram_notifications_status(session, user_id, status)
logging.exception(
"Failed to send trial traffic depleted warning to user %s",
telegram_chat_id,
)
else:
if user and telegram_status != TELEGRAM_NOTIFICATIONS_ENABLED and user_id:
await mark_telegram_notifications_status(
session,
user_id,
TELEGRAM_NOTIFICATIONS_ENABLED,
telegram_id=telegram_chat_id,
)
if send_email and user:
email_sent = await send_user_notification_email(
settings=self.settings,
i18n=self.i18n,
user=user,
subject_key="email_trial_traffic_depleted_subject",
message_text=message_text,
dashboard_url=(getattr(self.settings, "SUBSCRIPTION_MINI_APP_URL", "") or None),
session=session,
audit_event_type="email_traffic_warning_sent",
audit_content=(
"kind=trial warning_key=trial_traffic_depleted "
f"used_bytes={used} limit_bytes={limit}"
),
)
return {"telegram": telegram_sent, "email": email_sent}
def _max_before_window(self) -> timedelta:
days_before = max(0, int(getattr(self.settings, "SUBSCRIPTION_NOTIFY_DAYS_BEFORE", 0) or 0))
hours_before = max(
0,
int(getattr(self.settings, "SUBSCRIPTION_NOTIFY_HOURS_BEFORE", 0) or 0),
)
return max(timedelta(days=min(days_before, 3)), timedelta(hours=hours_before))
@staticmethod
def _as_utc(value: Optional[datetime]) -> Optional[datetime]:
if value is None:
return None
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
@staticmethod
def _fmt_bytes(value: int) -> str:
size = float(max(0, int(value or 0)))
for unit in ("B", "KB", "MB", "GB", "TB"):
if size < 1024 or unit == "TB":
return f"{size:.1f} {unit}" if unit != "B" else f"{int(size)} B"
size /= 1024
return f"{size:.1f} TB"
@@ -11,7 +11,11 @@ from bot.middlewares.i18n import JsonI18n
from bot.utils.config_link import prepare_config_links
from bot.utils.date_utils import add_months, month_start
from config.settings import Settings
from config.tariffs_config import Tariff
from config.tariffs_config import (
Tariff,
default_currency_key_for_settings,
default_payment_currency_code_for_settings,
)
from db.dal import (
payment_dal,
promo_code_dal,
@@ -70,7 +70,7 @@ class HwidDeviceMixin:
package_set = tariff.hwid_device_packages
if not package_set:
return None
packages = package_set.for_currency("stars" if currency == "stars" else "rub")
packages = package_set.for_currency(currency)
return next((pkg for pkg in packages if int(pkg.count) == int(device_count)), None)
def _quote_hwid_package_price(
@@ -87,9 +87,9 @@ class HwidDeviceMixin:
full_price = float(package.price_for_period(period_months))
period_start = self._as_aware_utc(getattr(sub, "start_date", None))
period_end = self._as_aware_utc(getattr(sub, "end_date", None)) or valid_until
if not period_start or period_start >= period_end:
period_start = valid_from
period_end = valid_until
inferred_period_start = add_months(period_end, -period_months)
if not period_start or period_start >= period_end or period_start < inferred_period_start:
period_start = inferred_period_start
basis_seconds = max(1.0, (period_end - period_start).total_seconds())
billable_start = max(now, valid_from)
@@ -173,7 +173,7 @@ class HwidDeviceMixin:
valid_from=valid_from,
valid_until=valid_until,
now=now,
currency="stars" if currency == "stars" else "rub",
currency=currency,
)
quote.update(
{
@@ -227,7 +227,11 @@ class HwidDeviceMixin:
)
return None
packages = (
[*tariff.hwid_device_packages.rub, *tariff.hwid_device_packages.stars]
[
package
for currency_packages in tariff.hwid_device_packages.root.values()
for package in currency_packages
]
if tariff.hwid_device_packages
else []
)
@@ -357,6 +361,15 @@ class HwidDeviceMixin:
valid_from=valid_from,
valid_until=valid_until,
)
await self._send_payment_success_email(
db_user=db_user,
sale_mode="hwid_devices_renewal" if renewal else "hwid_devices",
months=purchased_devices,
traffic_gb=None,
payment_amount=payment_amount,
end_date=valid_until,
provider=provider,
)
return {
"subscription_id": updated_sub.subscription_id,
"end_date": updated_sub.end_date,
@@ -3,6 +3,175 @@ from ._runtime import * # noqa: F403,F405
class SubscriptionLifecycleMixin:
async def _lookup_panel_user_for_subscription_details(
self,
panel_user_uuid: str,
) -> Tuple[Optional[Dict[str, Any]], bool, str]:
lookup_method = getattr(self.panel_service, "get_user_by_uuid_lookup", None)
if callable(lookup_method):
try:
lookup = await lookup_method(panel_user_uuid, log_response=False)
except TypeError:
try:
lookup = await lookup_method(panel_user_uuid)
except Exception as exc:
logging.exception(
"Failed to fetch panel user %s for subscription details",
panel_user_uuid,
)
return None, False, self._panel_lookup_exception_reason(exc)
except Exception as exc:
logging.exception(
"Failed to fetch panel user %s for subscription details",
panel_user_uuid,
)
return None, False, self._panel_lookup_exception_reason(exc)
if isinstance(lookup, dict) and ("ok" in lookup or "not_found" in lookup):
user = lookup.get("user")
if lookup.get("ok") and isinstance(user, dict):
return user, False, ""
reason = str(lookup.get("failure_reason") or "classification=panel_lookup_failed")
return None, bool(lookup.get("not_found")), reason
try:
panel_user = await self.panel_service.get_user_by_uuid(panel_user_uuid)
except Exception as exc:
logging.exception(
"Failed to fetch panel user %s for subscription details",
panel_user_uuid,
)
return None, False, self._panel_lookup_exception_reason(exc)
return (panel_user if isinstance(panel_user, dict) else None), False, ""
@staticmethod
def _panel_lookup_exception_reason(exc: Exception) -> str:
message = str(exc).replace("\n", " ").strip()
if len(message) > 300:
message = f"{message[:300]}..."
reason = f"classification=panel_lookup_failed exception={type(exc).__name__}"
if message:
reason = f"{reason} message={message}"
return reason
@staticmethod
def _display_datetime_text(value: Optional[Any]) -> Optional[str]:
if not value:
return None
if isinstance(value, datetime):
normalized = value if value.tzinfo else value.replace(tzinfo=timezone.utc)
return normalized.strftime("%d.%m.%Y %H:%M")
return str(value)
@staticmethod
def _device_topup_renewal_available(
extra_hwid_devices: int,
extra_hwid_valid_until: Optional[Any],
subscription_end_date: Optional[Any],
) -> bool:
if not isinstance(extra_hwid_valid_until, datetime) or not isinstance(
subscription_end_date, datetime
):
return False
valid_until = (
extra_hwid_valid_until
if extra_hwid_valid_until.tzinfo
else extra_hwid_valid_until.replace(tzinfo=timezone.utc)
)
end_date = (
subscription_end_date
if subscription_end_date.tzinfo
else subscription_end_date.replace(tzinfo=timezone.utc)
)
return bool(int(extra_hwid_devices or 0) > 0 and valid_until < end_date)
async def _local_active_subscription_details_fallback(
self,
db_user: User,
local_active_sub: Subscription,
) -> Dict[str, Any]:
panel_sub_id = str(local_active_sub.panel_subscription_uuid or "").strip()
config_link_raw = (
await self.panel_service.get_subscription_link(panel_sub_id) if panel_sub_id else None
)
display_link, connect_button_url = await prepare_config_links(
self.settings,
config_link_raw,
)
tariff = None
if local_active_sub.tariff_key and self._tariffs_config():
try:
tariff = self._resolve_tariff(local_active_sub.tariff_key)
except Exception:
tariff = None
language = db_user.language_code or self.settings.DEFAULT_LANGUAGE
premium_access = (
await self.premium_access_for_tariff(tariff)
if tariff
else {"squad_uuids": [], "squad_labels": [], "node_labels": []}
)
premium_baseline = int(local_active_sub.premium_baseline_bytes or 0)
premium_topup_balance = int(local_active_sub.premium_topup_balance_bytes or 0)
premium_topup_used = int(getattr(local_active_sub, "premium_topup_used_bytes", 0) or 0)
premium_bonus_bytes = int(getattr(local_active_sub, "premium_bonus_bytes", 0) or 0)
return {
"user_id": db_user.panel_user_uuid,
"panel_subscription_uuid": local_active_sub.panel_subscription_uuid,
"panel_short_uuid": local_active_sub.panel_subscription_uuid,
"end_date": local_active_sub.end_date,
"status_from_panel": local_active_sub.status_from_panel or "LOCAL_CACHE",
"config_link": display_link,
"connect_button_url": connect_button_url,
"traffic_limit_bytes": local_active_sub.traffic_limit_bytes,
"traffic_used_bytes": local_active_sub.traffic_used_bytes,
"traffic_limit_strategy": "",
"tariff_key": local_active_sub.tariff_key,
"tariff_name": tariff.name(language) if tariff else None,
"tariff_description": tariff.description(language) if tariff else None,
"premium_title": tariff.premium_name(language) if tariff else None,
"billing_model": tariff.billing_model
if tariff
else ("traffic" if getattr(self.settings, "traffic_sale_mode", False) else "period"),
"tier_baseline_bytes": local_active_sub.tier_baseline_bytes,
"topup_balance_bytes": local_active_sub.topup_balance_bytes,
"regular_bonus_bytes": int(getattr(local_active_sub, "regular_bonus_bytes", 0) or 0),
"regular_unlimited_override": bool(
getattr(local_active_sub, "regular_unlimited_override", False)
),
"premium_baseline_bytes": premium_baseline,
"premium_topup_balance_bytes": premium_topup_balance,
"premium_topup_used_bytes": premium_topup_used,
"premium_used_bytes": local_active_sub.premium_used_bytes,
"premium_bonus_bytes": premium_bonus_bytes,
"premium_unlimited_override": bool(
getattr(local_active_sub, "premium_unlimited_override", False)
),
"premium_limit_bytes": self._premium_effective_limit_bytes(
premium_baseline,
premium_topup_balance,
premium_topup_used,
premium_bonus_bytes,
),
"premium_is_limited": bool(local_active_sub.premium_is_limited),
"premium_period_start_at": getattr(local_active_sub, "premium_period_start_at", None),
"premium_squad_labels": premium_access.get("squad_labels") or [],
"premium_node_labels": premium_access.get("node_labels") or [],
"period_start_at": local_active_sub.period_start_at,
"is_throttled": bool(local_active_sub.is_throttled),
"base_hwid_device_limit": local_active_sub.hwid_device_limit,
"extra_hwid_devices": int(local_active_sub.extra_hwid_devices or 0),
"extra_hwid_devices_valid_until": None,
"extra_hwid_devices_valid_until_text": None,
"extra_hwid_devices_next_valid_from": None,
"device_topup_renewal_available": False,
"user_bot_username": db_user.username,
"is_panel_data": False,
"max_devices": self._effective_hwid_limit(
local_active_sub.hwid_device_limit,
int(local_active_sub.extra_hwid_devices or 0),
),
}
async def switch_tariff_without_payment(
self,
session: AsyncSession,
@@ -79,9 +248,9 @@ class SubscriptionLifecycleMixin:
traffic_used_bytes=used_sub,
)
update_data["period_start_at"] = None
update_data["effective_monthly_price_rub"] = (
target.period_price(1, "rub") or target.min_period_price_rub()
)
update_data["effective_monthly_price_rub"] = target.period_price(
1, default_currency_key_for_settings(self.settings)
) or target.min_period_price(default_currency_key_for_settings(self.settings))
if mode == "recalc_days" and options.get("recalc_days") is not None:
update_data["end_date"] = now + timedelta(days=int(options["recalc_days"]))
else:
@@ -308,6 +477,17 @@ class SubscriptionLifecycleMixin:
)
result["end_date"] = sub.end_date
result["is_active"] = sub.is_active
db_user = await user_dal.get_user_by_id(session, user_id)
if db_user:
await self._send_payment_success_email(
db_user=db_user,
sale_mode="tariff_upgrade",
months=0,
traffic_gb=None,
payment_amount=payment_amount,
end_date=sub.end_date,
provider=provider,
)
return result
tariff = self._resolve_tariff(tariff_key, "period") if self._tariffs_config() else None
@@ -416,9 +596,7 @@ class SubscriptionLifecycleMixin:
"Failed to recalculate active HWID devices for renewal of user %s",
user_id,
)
extra_hwid_devices = int(
getattr(current_active_sub, "extra_hwid_devices", 0) or 0
)
extra_hwid_devices = int(getattr(current_active_sub, "extra_hwid_devices", 0) or 0)
premium_topup_balance_bytes = int(
getattr(current_active_sub, "premium_topup_balance_bytes", 0) or 0
)
@@ -570,6 +748,9 @@ class SubscriptionLifecycleMixin:
active_sub = await subscription_dal.get_active_subscription_by_user_id(
session, user_id, panel_uuid
)
preserve_tariff_limits = bool(
active_sub and active_sub.tariff_key and self._tariffs_config()
)
if not active_sub or not active_sub.end_date:
logging.info(
f"No active subscription found for user {user_id}. Creating new one for {bonus_days} days." # noqa: E501
@@ -614,6 +795,7 @@ class SubscriptionLifecycleMixin:
if (
apply_main_traffic_limit
and not preserve_tariff_limits
and updated_sub_model
and updated_sub_model.traffic_limit_bytes != self.settings.user_traffic_limit_bytes
):
@@ -628,7 +810,9 @@ class SubscriptionLifecycleMixin:
panel_update_payload = self._build_panel_update_payload(
expire_at=new_end_date_obj,
traffic_limit_bytes=(
self.settings.user_traffic_limit_bytes if apply_main_traffic_limit else None
self.settings.user_traffic_limit_bytes
if apply_main_traffic_limit and not preserve_tariff_limits
else None
),
include_uuid=False,
include_default_squads=False,
@@ -665,14 +849,36 @@ class SubscriptionLifecycleMixin:
local_active_sub = await subscription_dal.get_active_subscription_by_user_id(
session, user_id, panel_user_uuid
)
panel_user_data = await self.panel_service.get_user_by_uuid(panel_user_uuid)
(
panel_user_data,
panel_user_confirmed_absent,
panel_lookup_failure_reason,
) = await self._lookup_panel_user_for_subscription_details(panel_user_uuid)
if not panel_user_data:
if panel_user_confirmed_absent:
logging.warning(
"Panel user %s confirmed absent on panel for user %s. "
"Clearing local linkage. reason=%s",
panel_user_uuid,
user_id,
panel_lookup_failure_reason,
)
await subscription_dal.deactivate_all_user_subscriptions(session, user_id)
await user_dal.update_user(session, user_id, {"panel_user_uuid": None})
return None
logging.warning(
f"Panel user {panel_user_uuid} not found on panel for user {user_id}. Clearing local linkage." # noqa: E501
"Panel user %s lookup failed for user %s; treating it as a panel access/API "
"problem and preserving local linkage/subscription. reason=%s",
panel_user_uuid,
user_id,
panel_lookup_failure_reason,
)
await subscription_dal.deactivate_all_user_subscriptions(session, user_id)
await user_dal.update_user(session, user_id, {"panel_user_uuid": None})
if local_active_sub:
return await self._local_active_subscription_details_fallback(
db_user,
local_active_sub,
)
return None
panel_lifetime_used = self._extract_lifetime_used_traffic(panel_user_data)
@@ -813,9 +1019,7 @@ class SubscriptionLifecycleMixin:
subscription_id=local_active_sub.subscription_id,
at=datetime.now(timezone.utc),
)
active_extra_hwid_devices = int(
hwid_entitlement_summary.get("active_devices") or 0
)
active_extra_hwid_devices = int(hwid_entitlement_summary.get("active_devices") or 0)
if active_extra_hwid_devices != int(local_active_sub.extra_hwid_devices or 0):
await subscription_dal.update_subscription(
session,
@@ -840,6 +1044,14 @@ class SubscriptionLifecycleMixin:
if expected_hwid_limit is not None:
hwid_limit = expected_hwid_limit
extra_hwid_valid_until = hwid_entitlement_summary.get("active_until")
extra_hwid_next_valid_from = hwid_entitlement_summary.get("next_valid_from")
device_topup_renewal_available = self._device_topup_renewal_available(
active_extra_hwid_devices,
extra_hwid_valid_until,
panel_end_date,
)
return {
"user_id": panel_user_data.get("uuid"),
"panel_subscription_uuid": panel_user_data.get("subscriptionUuid")
@@ -900,10 +1112,12 @@ class SubscriptionLifecycleMixin:
if local_active_sub
else None,
"extra_hwid_devices": active_extra_hwid_devices,
"extra_hwid_devices_valid_until": hwid_entitlement_summary.get("active_until"),
"extra_hwid_devices_next_valid_from": hwid_entitlement_summary.get(
"next_valid_from"
"extra_hwid_devices_valid_until": extra_hwid_valid_until,
"extra_hwid_devices_valid_until_text": self._display_datetime_text(
extra_hwid_valid_until
),
"extra_hwid_devices_next_valid_from": extra_hwid_next_valid_from,
"device_topup_renewal_available": device_topup_renewal_available,
"user_bot_username": db_user.username,
"is_panel_data": True,
"max_devices": hwid_limit,
@@ -62,6 +62,9 @@ class PaymentContextMixin:
async def has_had_any_subscription(self, session: AsyncSession, user_id: int) -> bool:
return await subscription_dal.has_any_subscription_for_user(session, user_id)
async def has_trial_blocking_subscription(self, session: AsyncSession, user_id: int) -> bool:
return await subscription_dal.has_trial_blocking_subscription_for_user(session, user_id)
async def has_active_subscription(self, session: AsyncSession, user_id: int) -> bool:
"""Return True if user currently has an active subscription (end_date in future)."""
try:
@@ -93,7 +96,7 @@ class PaymentContextMixin:
"""Best-effort branded email confirming the payment. No-op if SMTP or
the user's email aren't set. Failures are logged and swallowed so the
payment flow is never blocked by mail delivery."""
if not self.settings.email_auth_configured:
if not getattr(self.settings, "email_auth_configured", False):
return
recipient = (db_user.email or "").strip() if db_user else ""
if not recipient:
@@ -120,7 +123,7 @@ class PaymentContextMixin:
months=int(months or 0),
traffic_gb=traffic_gb,
amount=float(payment_amount or 0),
currency=self.settings.DEFAULT_CURRENCY_SYMBOL,
currency=default_payment_currency_code_for_settings(self.settings),
end_date_text=end_date_text,
dashboard_url=dashboard_url,
provider_label=provider_label,
@@ -41,7 +41,23 @@ class RenewalMixin:
return False
months = sub.duration_months or 1
amount = self.settings.subscription_options.get(months)
currency = default_payment_currency_code_for_settings(self.settings)
amount = None
tariffs_config = (
self._tariffs_config() if callable(getattr(self, "_tariffs_config", None)) else None
)
if tariffs_config and callable(getattr(self, "_resolve_tariff", None)):
try:
tariff = self._resolve_tariff(getattr(sub, "tariff_key", None))
except Exception:
tariff = None
if tariff and tariff.billing_model == "period":
amount = tariff.period_price(
months,
default_currency_key_for_settings(self.settings),
)
if amount is None:
amount = self.settings.subscription_options.get(months)
if not amount:
logging.error(f"Auto-renew price missing for {months} months")
return False
@@ -53,7 +69,7 @@ class RenewalMixin:
}
resp = await yk.create_payment(
amount=float(amount),
currency="RUB",
currency=currency,
description=f"Auto-renewal for {months} months",
metadata=metadata,
payment_method_id=default_pm.provider_payment_method_id,
@@ -112,15 +112,14 @@ class TariffMixin:
regular_unlimited_override: bool,
traffic_used_bytes: int,
) -> int:
"""Numeric cap sent to the panel; ``regular_unlimited_override`` uses a large practical ceiling.""" # noqa: E501
"""Numeric cap sent to the panel; Remnawave treats ``0`` as unlimited."""
floor = (
int(tier_baseline_bytes or 0)
+ max(0, int(topup_balance_bytes or 0))
+ max(0, int(regular_bonus_bytes or 0))
)
if regular_unlimited_override:
used = max(0, int(traffic_used_bytes or 0))
return max(floor, used + 512 * (1024**3), 1024**5)
return 0
return floor
async def premium_access_for_tariff(self, tariff: Optional[Tariff]) -> Dict[str, Any]:
@@ -331,11 +330,12 @@ class TariffMixin:
remaining_days = max(0, (sub.end_date - now).days) if sub.end_date else 0
effective = float(sub.effective_monthly_price_rub or 0)
current_model = current_tariff.billing_model if current_tariff else "period"
default_currency = default_currency_key_for_settings(self.settings)
if current_model == "period" and target_tariff.billing_model == "period":
target_monthly = (
target_tariff.period_price(1, "rub")
or target_tariff.min_period_price_rub()
target_tariff.period_price(1, default_currency)
or target_tariff.min_period_price(default_currency)
or effective
or 1
)
@@ -355,11 +355,14 @@ class TariffMixin:
"remaining_days": remaining_days,
"recalc_days": max(0, days_after),
"paid_diff_rub": paid_diff,
"paid_diff": paid_diff,
"target_monthly_rub": float(target_monthly),
"target_monthly_price": float(target_monthly),
"currency": default_currency,
}
if current_model == "period" and target_tariff.billing_model == "traffic":
rub_per_gb = target_tariff.rub_per_gb_for_conversion()
rub_per_gb = target_tariff.currency_per_gb_for_conversion(default_currency)
remaining_value = remaining_days * (effective / 30) if effective else 0
converted_gb = math.floor(remaining_value / rub_per_gb) if rub_per_gb else 0
return {
@@ -367,9 +370,15 @@ class TariffMixin:
"remaining_days": remaining_days,
"converted_gb": max(0, converted_gb),
"rub_per_gb": rub_per_gb,
"currency_per_gb": rub_per_gb,
"currency": default_currency,
}
return {"mode": "traffic_to_period", "remaining_days": remaining_days}
return {
"mode": "traffic_to_period",
"remaining_days": remaining_days,
"currency": default_currency,
}
@staticmethod
def _aware_utc(value: Optional[datetime]) -> Optional[datetime]:
@@ -434,6 +443,7 @@ class TariffMixin:
credit = await self._hwid_conversion_credit(session, sub, at=now)
value_rub = float(credit.get("value_rub") or 0)
options["converted_hwid_value_rub"] = round(value_rub, 2)
options["converted_hwid_value"] = round(value_rub, 2)
options["convertible_hwid_purchase_ids"] = list(credit.get("purchase_ids") or [])
options["nonconverted_hwid_devices"] = int(credit.get("skipped_devices") or 0)
if value_rub <= 0:
@@ -441,9 +451,7 @@ class TariffMixin:
if options.get("mode") == "period_to_period":
target_monthly = float(options.get("target_monthly_rub") or 0)
hwid_days = (
math.floor((value_rub / target_monthly) * 30) if target_monthly > 0 else 0
)
hwid_days = math.floor((value_rub / target_monthly) * 30) if target_monthly > 0 else 0
options["converted_hwid_days"] = max(0, hwid_days)
options["recalc_days"] = int(options.get("recalc_days") or 0) + max(0, hwid_days)
options["paid_diff_rub"] = max(
@@ -54,9 +54,7 @@ class TrafficMixin:
purchase_bytes = self.gb_to_bytes(traffic_gb)
extra_hwid_devices = (
await self._active_hwid_extra_devices_for_sub(session, active_sub)
if active_sub
else 0
await self._active_hwid_extra_devices_for_sub(session, active_sub) if active_sub else 0
)
base_hwid_limit = self._base_hwid_limit_for_tariff(tariff)
effective_hwid_limit = self._effective_hwid_limit(base_hwid_limit, extra_hwid_devices)
@@ -271,6 +269,15 @@ class TrafficMixin:
purchased_bytes=purchase_bytes,
kind="topup",
)
await self._send_payment_success_email(
db_user=db_user,
sale_mode="topup",
months=0,
traffic_gb=float(traffic_gb),
payment_amount=payment_amount,
end_date=getattr(updated_sub, "end_date", None),
provider=provider,
)
return {
"subscription_id": sub.subscription_id,
"traffic_limit_bytes": new_limit,
@@ -378,6 +385,15 @@ class TrafficMixin:
purchased_bytes=purchase_bytes,
kind="premium_topup",
)
await self._send_payment_success_email(
db_user=db_user,
sale_mode="premium_topup",
months=0,
traffic_gb=float(traffic_gb),
payment_amount=payment_amount,
end_date=getattr(sub, "end_date", None),
provider=provider,
)
return {
"subscription_id": sub.subscription_id,
"premium_limit_bytes": premium_limit,
@@ -22,7 +22,7 @@ class TrialSubscriptionMixin:
"message_key": "user_not_found_for_trial",
}
if await self.has_had_any_subscription(session, user_id):
if await self.has_trial_blocking_subscription(session, user_id):
return {
"eligible": False,
"activated": False,
@@ -62,6 +62,7 @@ class TrialSubscriptionMixin:
"status_from_panel": "TRIAL",
"traffic_limit_bytes": self.settings.trial_traffic_limit_bytes,
"auto_renew_enabled": False,
"provider": "trial",
}
try:
await subscription_dal.upsert_subscription(session, trial_sub_data)
+22 -9
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import asyncio
import logging
from dataclasses import dataclass
from datetime import datetime, timezone
@@ -134,6 +135,16 @@ class SupportService:
email_auth_service=self.email_auth_service,
)
@staticmethod
def _schedule_notification(coro, error_message: str, *error_args: Any) -> None:
async def _runner():
try:
await coro
except Exception:
logger.exception(error_message, *error_args)
asyncio.create_task(_runner(), name="support-notification")
async def _ensure_user_allowed(self, session, user_id: int) -> User:
user = await user_dal.get_user_by_id(session, user_id)
if not user or user.is_banned or not self.settings.SUPPORT_TICKETS_ENABLED:
@@ -218,8 +229,8 @@ class SupportService:
await session.commit()
if notification_decision.send_telegram or notification_decision.send_email:
try:
await self.notification_service.notify_support_user_reply(
self._schedule_notification(
self.notification_service.notify_support_user_reply(
ticket,
message,
user,
@@ -227,9 +238,10 @@ class SupportService:
unread_count=int(ticket.unread_admin_count or 0),
send_telegram=notification_decision.send_telegram,
send_email=notification_decision.send_email,
)
except Exception:
logger.exception("Failed to notify about support user reply %s", ticket_id)
),
"Failed to notify about support user reply %s",
ticket_id,
)
return ticket, message
async def reply_as_admin(
@@ -275,10 +287,11 @@ class SupportService:
await session.commit()
if user and not is_internal_note:
try:
await self.notification_service.notify_support_admin_reply(ticket, message, user)
except Exception:
logger.exception("Failed to notify user about support admin reply %s", ticket_id)
self._schedule_notification(
self.notification_service.notify_support_admin_reply(ticket, message, user),
"Failed to notify user about support admin reply %s",
ticket_id,
)
return ticket, message
async def change_status(self, admin_id: int, ticket_id: int, status: str) -> SupportTicket:
+277 -93
View File
@@ -2,7 +2,7 @@ import asyncio
import logging
import time
from datetime import datetime, timezone
from typing import Any, Optional
from typing import Any, Awaitable, Callable, Optional
from aiogram import Bot
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, WebAppInfo
@@ -13,11 +13,14 @@ from sqlalchemy.orm import sessionmaker
from bot.infra.redis import redis_lock
from bot.middlewares.i18n import JsonI18n
from bot.services.message_audit import log_user_message_delivery
from bot.services.panel_api_service import PanelApiService
from bot.services.subscription_service import SubscriptionService
from bot.services.user_email_notifications import send_user_notification_email
from bot.utils.date_utils import month_start
from bot.utils.mini_app_url import subscription_mini_app_topup_url
from config.settings import Settings
from db.advisory_locks import acquire_subscription_background_sync_lock
from db.dal import subscription_dal, tariff_dal, user_dal
from db.models import Subscription
@@ -31,6 +34,10 @@ TARIFF_WORKER_BATCH_SIZE = 50
TARIFF_WORKER_PANEL_CONCURRENCY = 10
TARIFF_WORKER_BULK_PANEL_FETCH_THRESHOLD = 50
TARIFF_WORKER_SQUAD_CONFIRMATION_CACHE_TTL_SECONDS = 900
TARIFF_WORKER_DB_RETRY_ATTEMPTS = 3
TARIFF_WORKER_DB_RETRY_BASE_SLEEP_SECONDS = 0.5
POSTGRES_RETRYABLE_SQLSTATES = {"40001", "40P01"}
POSTGRES_RETRYABLE_ERROR_NAMES = {"DeadlockDetectedError", "SerializationError"}
class TariffTrafficWorker:
@@ -97,6 +104,41 @@ class TariffTrafficWorker:
button = InlineKeyboardButton(text=_(fallback_key), callback_data="tariff_topup:list")
return InlineKeyboardMarkup(inline_keyboard=[[button]])
async def _send_traffic_warning_email(
self,
session: AsyncSession,
*,
user_id: int,
subject_key: str,
message_text: str,
kind: str,
warning_key: str,
audit_content: str,
) -> None:
try:
user = await user_dal.get_user_by_id(session, user_id)
except Exception:
logging.exception("TariffTrafficWorker: failed to load user %s for email", user_id)
return
if not user:
return
await send_user_notification_email(
settings=self.settings,
i18n=self.i18n,
user=user,
subject_key=subject_key,
message_text=message_text,
dashboard_url=subscription_mini_app_topup_url(self.settings, kind),
cta_label_key=(
"email_traffic_warning_premium_cta"
if kind == "premium"
else "email_traffic_warning_regular_cta"
),
session=session,
audit_event_type="email_traffic_warning_sent",
audit_content=f"{audit_content} subject_key={subject_key} warning_key={warning_key}",
)
async def run(self) -> None:
if not self.settings.tariffs_config:
return
@@ -111,12 +153,14 @@ class TariffTrafficWorker:
logging.info("TariffTrafficWorker tick skipped: Redis lock is held")
else:
started = time.monotonic()
async with self.session_factory() as session:
await self.traffic_period_tick(session)
await session.commit()
async with self.session_factory() as session:
await self.legacy_throttle_recovery_tick(session)
await session.commit()
await self._run_db_tick_with_retry(
"traffic_period",
self.traffic_period_tick,
)
await self._run_db_tick_with_retry(
"legacy_throttle_recovery",
self.legacy_throttle_recovery_tick,
)
logging.info(
"metric worker_tick_duration_seconds=%.3f worker=tariff",
time.monotonic() - started,
@@ -134,16 +178,80 @@ class TariffTrafficWorker:
def stop(self) -> None:
self._stopped.set()
async def _run_db_tick_with_retry(
self,
tick_name: str,
tick: Callable[[AsyncSession], Awaitable[None]],
) -> None:
for attempt in range(1, TARIFF_WORKER_DB_RETRY_ATTEMPTS + 1):
async with self.session_factory() as session:
try:
await acquire_subscription_background_sync_lock(session)
await tick(session)
await session.commit()
return
except Exception as exc:
await session.rollback()
if (
attempt < TARIFF_WORKER_DB_RETRY_ATTEMPTS
and self._is_retryable_db_exception(exc)
):
delay = TARIFF_WORKER_DB_RETRY_BASE_SLEEP_SECONDS * attempt
logging.warning(
"TariffTrafficWorker %s retrying after database concurrency "
"error, attempt %s/%s: %s",
tick_name,
attempt + 1,
TARIFF_WORKER_DB_RETRY_ATTEMPTS,
exc,
)
await asyncio.sleep(delay)
continue
raise
@staticmethod
def _is_retryable_db_exception(exc: BaseException) -> bool:
pending: list[BaseException] = [exc]
seen: set[int] = set()
while pending:
current = pending.pop()
current_id = id(current)
if current_id in seen:
continue
seen.add(current_id)
sqlstate = getattr(current, "sqlstate", None) or getattr(current, "pgcode", None)
if sqlstate in POSTGRES_RETRYABLE_SQLSTATES:
return True
error_name = type(current).__name__
message = str(current).lower()
if (
error_name in POSTGRES_RETRYABLE_ERROR_NAMES
or "deadlock detected" in message
or "could not serialize access" in message
):
return True
for attr in ("orig", "__cause__", "__context__"):
nested = getattr(current, attr, None)
if isinstance(nested, BaseException):
pending.append(nested)
return False
async def traffic_period_tick(self, session: AsyncSession) -> None:
now = datetime.now(timezone.utc)
self._premium_node_usage_tick_cache = {}
warning_period_start = month_start(now)
result = await session.execute(
select(Subscription).where(
select(Subscription)
.where(
Subscription.is_active == True,
Subscription.end_date > now,
Subscription.tariff_key.is_not(None),
)
.order_by(Subscription.subscription_id.asc())
)
subs = list(result.scalars().all())
if not subs:
@@ -454,6 +562,8 @@ class TariffTrafficWorker:
return
ratio = used_val / limit_val
levels = list(getattr(self.settings, "tariff_traffic_warning_levels", [85, 90, 95]))
if 100 not in levels:
levels.append(100)
for level in levels:
threshold = level / 100
if ratio < threshold:
@@ -474,30 +584,41 @@ class TariffTrafficWorker:
level=level,
traffic_limit_bytes=limit_val if tariff.billing_model == "traffic" else None,
)
user_lang = await self._user_lang(session, sub.user_id)
_ = (
(lambda k, **kw: self.i18n.gettext(user_lang, k, **kw))
if self.i18n
else (lambda k, **kw: k)
)
left_pct = max(0, 100 - level)
tariff_name = hd.quote(str(tariff.name(user_lang)))
usage = self._usage_placeholders(used_val, limit_val)
if level < 100:
text = _(
"traffic_warning_regular_almost",
tariff_name=tariff_name,
left_pct=left_pct,
**usage,
)
subject_key = "email_traffic_warning_regular_almost_subject"
else:
text = _(
"traffic_warning_regular_depleted",
tariff_name=tariff_name,
**usage,
)
subject_key = "email_traffic_warning_regular_depleted_subject"
warning_key = (
"traffic_warning_regular_almost"
if level < 100
else "traffic_warning_regular_depleted"
)
audit_content = (
f"kind=regular warning_key={warning_key} level={level} "
f"used_bytes={used_val} limit_bytes={limit_val}"
)
if self.bot:
try:
user_lang = await self._user_lang(session, sub.user_id)
_ = (
(lambda k, **kw: self.i18n.gettext(user_lang, k, **kw))
if self.i18n
else (lambda k, **kw: k)
)
left_pct = max(0, 100 - level)
tariff_name = hd.quote(str(tariff.name(user_lang)))
usage = self._usage_placeholders(used_val, limit_val)
if level < 100:
text = _(
"traffic_warning_regular_almost",
tariff_name=tariff_name,
left_pct=left_pct,
**usage,
)
else:
text = _(
"traffic_warning_regular_depleted",
tariff_name=tariff_name,
**usage,
)
markup = self._traffic_topup_markup(user_lang, "regular")
await self.bot.send_message(
sub.user_id,
@@ -505,8 +626,25 @@ class TariffTrafficWorker:
reply_markup=markup,
parse_mode="HTML",
)
await log_user_message_delivery(
session,
target_user_id=sub.user_id,
event_type="telegram_traffic_warning_sent",
channel="telegram",
recipient=str(sub.user_id),
content=audit_content,
)
except Exception:
logging.exception("Failed to send traffic warning to user %s", sub.user_id)
await self._send_traffic_warning_email(
session,
user_id=sub.user_id,
subject_key=subject_key,
message_text=text,
kind="regular",
warning_key=warning_key,
audit_content=audit_content,
)
if ratio >= 1.0 and not sub.is_throttled:
logging.info(
"Tariff traffic limit reached for user %s subscription %s. "
@@ -795,8 +933,7 @@ class TariffTrafficWorker:
if not cached_at:
return False
return (
time.monotonic() - float(cached_at)
< TARIFF_WORKER_SQUAD_CONFIRMATION_CACHE_TTL_SECONDS
time.monotonic() - float(cached_at) < TARIFF_WORKER_SQUAD_CONFIRMATION_CACHE_TTL_SECONDS
)
def _remember_premium_squad_match(self, cache_key: tuple[str, tuple[str, ...]]) -> None:
@@ -931,31 +1068,36 @@ class TariffTrafficWorker:
level=PREMIUM_WARNING_DEPLETED_LEVEL,
traffic_limit_bytes=None,
)
user_lang = await self._user_lang(session, sub.user_id)
_ = (
(lambda k, **kw: self.i18n.gettext(user_lang, k, **kw))
if self.i18n
else (lambda k, **kw: k)
)
access = await self.subscription_service.premium_access_for_tariff(tariff)
labels = access.get("node_labels") or access.get("squad_labels") or []
if labels:
visible = [hd.quote(str(x)) for x in labels[:8]]
servers = "\n".join(f"{label}" for label in visible)
if len(labels) > len(visible):
more = len(labels) - len(visible)
servers += "\n" + _("traffic_warning_premium_servers_more", count=more)
else:
servers = _("traffic_warning_premium_generic_servers")
usage = self._usage_placeholders(used_val, limit_val)
text = _(
"traffic_warning_premium_depleted",
tariff_name=hd.quote(str(tariff.name(user_lang))),
servers=servers,
**usage,
)
warning_key = "traffic_warning_premium_depleted"
audit_content = (
f"kind=premium warning_key={warning_key} "
f"used_bytes={used_val} limit_bytes={limit_val}"
)
if self.bot:
try:
user_lang = await self._user_lang(session, sub.user_id)
_ = (
(lambda k, **kw: self.i18n.gettext(user_lang, k, **kw))
if self.i18n
else (lambda k, **kw: k)
)
access = await self.subscription_service.premium_access_for_tariff(tariff)
labels = access.get("node_labels") or access.get("squad_labels") or []
if labels:
visible = [hd.quote(str(x)) for x in labels[:8]]
servers = "\n".join(f"{label}" for label in visible)
if len(labels) > len(visible):
more = len(labels) - len(visible)
servers += "\n" + _("traffic_warning_premium_servers_more", count=more)
else:
servers = _("traffic_warning_premium_generic_servers")
usage = self._usage_placeholders(used_val, limit_val)
text = _(
"traffic_warning_premium_depleted",
tariff_name=hd.quote(str(tariff.name(user_lang))),
servers=servers,
**usage,
)
markup = self._traffic_topup_markup(user_lang, "premium")
await self.bot.send_message(
sub.user_id,
@@ -963,10 +1105,27 @@ class TariffTrafficWorker:
reply_markup=markup,
parse_mode="HTML",
)
await log_user_message_delivery(
session,
target_user_id=sub.user_id,
event_type="telegram_traffic_warning_sent",
channel="telegram",
recipient=str(sub.user_id),
content=audit_content,
)
except Exception:
logging.exception(
"Failed to send premium traffic depleted warning to user %s", sub.user_id
)
await self._send_traffic_warning_email(
session,
user_id=sub.user_id,
subject_key="email_traffic_warning_premium_depleted_subject",
message_text=text,
kind="premium",
warning_key=warning_key,
audit_content=audit_content,
)
return
for level in levels:
@@ -990,43 +1149,66 @@ class TariffTrafficWorker:
level=storage_level,
traffic_limit_bytes=None,
)
if not self.bot:
continue
try:
user_lang = await self._user_lang(session, sub.user_id)
_ = (
(lambda k, **kw: self.i18n.gettext(user_lang, k, **kw))
if self.i18n
else (lambda k, **kw: k)
)
access = await self.subscription_service.premium_access_for_tariff(tariff)
labels = access.get("node_labels") or access.get("squad_labels") or []
if labels:
visible = [hd.quote(str(x)) for x in labels[:8]]
servers = "\n".join(f"{label}" for label in visible)
if len(labels) > len(visible):
more = len(labels) - len(visible)
servers += "\n" + _("traffic_warning_premium_servers_more", count=more)
else:
servers = _("traffic_warning_premium_generic_servers")
left_pct = max(0, 100 - int(level))
usage = self._usage_placeholders(used_val, limit_val)
text = _(
"traffic_warning_premium_almost",
tariff_name=hd.quote(str(tariff.name(user_lang))),
left_pct=left_pct,
servers=servers,
**usage,
)
markup = self._traffic_topup_markup(user_lang, "premium")
await self.bot.send_message(
sub.user_id,
text,
reply_markup=markup,
parse_mode="HTML",
)
except Exception:
logging.exception("Failed to send premium traffic warning to user %s", sub.user_id)
user_lang = await self._user_lang(session, sub.user_id)
_ = (
(lambda k, **kw: self.i18n.gettext(user_lang, k, **kw))
if self.i18n
else (lambda k, **kw: k)
)
access = await self.subscription_service.premium_access_for_tariff(tariff)
labels = access.get("node_labels") or access.get("squad_labels") or []
if labels:
visible = [hd.quote(str(x)) for x in labels[:8]]
servers = "\n".join(f"{label}" for label in visible)
if len(labels) > len(visible):
more = len(labels) - len(visible)
servers += "\n" + _("traffic_warning_premium_servers_more", count=more)
else:
servers = _("traffic_warning_premium_generic_servers")
left_pct = max(0, 100 - int(level))
usage = self._usage_placeholders(used_val, limit_val)
text = _(
"traffic_warning_premium_almost",
tariff_name=hd.quote(str(tariff.name(user_lang))),
left_pct=left_pct,
servers=servers,
**usage,
)
warning_key = "traffic_warning_premium_almost"
audit_content = (
f"kind=premium warning_key={warning_key} level={int(level)} "
f"used_bytes={used_val} limit_bytes={limit_val}"
)
if self.bot:
try:
markup = self._traffic_topup_markup(user_lang, "premium")
await self.bot.send_message(
sub.user_id,
text,
reply_markup=markup,
parse_mode="HTML",
)
await log_user_message_delivery(
session,
target_user_id=sub.user_id,
event_type="telegram_traffic_warning_sent",
channel="telegram",
recipient=str(sub.user_id),
content=audit_content,
)
except Exception:
logging.exception(
"Failed to send premium traffic warning to user %s", sub.user_id
)
await self._send_traffic_warning_email(
session,
user_id=sub.user_id,
subject_key="email_traffic_warning_premium_almost_subject",
message_text=text,
kind="premium",
warning_key=warning_key,
audit_content=audit_content,
)
async def _premium_node_uuids_for_tariff(self, tariff) -> list[str]:
cache_key = tuple(sorted(tariff.premium_squad_uuids or []))
@@ -1160,10 +1342,12 @@ class TariffTrafficWorker:
from Internal Squads.
"""
result = await session.execute(
select(Subscription).where(
select(Subscription)
.where(
Subscription.is_active == True,
Subscription.is_throttled == True,
)
.order_by(Subscription.subscription_id.asc())
)
for sub in result.scalars().all():
try:
@@ -0,0 +1,196 @@
import logging
from datetime import datetime, timezone
from typing import Any, Optional
from aiogram import Bot
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
from sqlalchemy.ext.asyncio import AsyncSession
from bot.middlewares.i18n import JsonI18n
from config.settings import Settings
from db.dal import user_dal
from db.models import User
logger = logging.getLogger(__name__)
TELEGRAM_NOTIFICATIONS_UNKNOWN = "unknown"
TELEGRAM_NOTIFICATIONS_ENABLED = "enabled"
TELEGRAM_NOTIFICATIONS_NEEDS_START = "needs_start"
TELEGRAM_NOTIFICATIONS_BLOCKED = "blocked"
TELEGRAM_NOTIFICATION_STATUSES = {
TELEGRAM_NOTIFICATIONS_UNKNOWN,
TELEGRAM_NOTIFICATIONS_ENABLED,
TELEGRAM_NOTIFICATIONS_NEEDS_START,
TELEGRAM_NOTIFICATIONS_BLOCKED,
}
def normalize_telegram_notification_status(value: Optional[str]) -> str:
status = str(value or "").strip().lower()
return status if status in TELEGRAM_NOTIFICATION_STATUSES else TELEGRAM_NOTIFICATIONS_UNKNOWN
def telegram_notifications_enabled(user: Optional[User]) -> bool:
return (
bool(getattr(user, "telegram_id", None))
and normalize_telegram_notification_status(
getattr(user, "telegram_notifications_status", None)
)
== TELEGRAM_NOTIFICATIONS_ENABLED
)
def telegram_notifications_need_prompt(user: Optional[User]) -> bool:
status = normalize_telegram_notification_status(
getattr(user, "telegram_notifications_status", None)
)
return bool(getattr(user, "telegram_id", None)) and status in {
TELEGRAM_NOTIFICATIONS_NEEDS_START,
TELEGRAM_NOTIFICATIONS_BLOCKED,
}
def telegram_notifications_start_link(bot_username: Optional[str]) -> Optional[str]:
username = str(bot_username or "").strip().lstrip("@")
if not username or username == "your_bot_username":
return None
return f"https://t.me/{username}?start=notifications"
def telegram_notification_status_from_error(exc: Exception) -> Optional[str]:
if isinstance(exc, TelegramForbiddenError):
return TELEGRAM_NOTIFICATIONS_BLOCKED
if not isinstance(exc, TelegramBadRequest):
return None
message = str(exc).lower()
if any(
token in message
for token in (
"bot was blocked",
"user is deactivated",
"forbidden",
)
):
return TELEGRAM_NOTIFICATIONS_BLOCKED
if any(
token in message
for token in (
"chat not found",
"bot can't initiate conversation",
"bot can't initiate",
"user not found",
)
):
return TELEGRAM_NOTIFICATIONS_NEEDS_START
return None
async def mark_telegram_notifications_status(
session: AsyncSession,
user_id: int,
status: str,
*,
telegram_id: Optional[int] = None,
checked_at: Optional[datetime] = None,
) -> Optional[User]:
normalized = normalize_telegram_notification_status(status)
now = checked_at or datetime.now(timezone.utc)
update_data: dict[str, Any] = {
"telegram_notifications_status": normalized,
"telegram_notifications_checked_at": now,
}
if telegram_id:
update_data["telegram_id"] = int(telegram_id)
if normalized == TELEGRAM_NOTIFICATIONS_ENABLED:
update_data["telegram_notifications_enabled_at"] = now
update_data["telegram_notifications_blocked_at"] = None
elif normalized == TELEGRAM_NOTIFICATIONS_BLOCKED:
update_data["telegram_notifications_blocked_at"] = now
return await user_dal.update_user(session, user_id, update_data)
async def mark_telegram_notifications_enabled_for_telegram_user(
session: AsyncSession,
telegram_id: int,
) -> Optional[User]:
db_user = await user_dal.get_user_by_telegram_id(session, telegram_id)
if not db_user:
db_user = await user_dal.get_user_by_id(session, telegram_id)
if not db_user:
return None
return await mark_telegram_notifications_status(
session,
int(db_user.user_id),
TELEGRAM_NOTIFICATIONS_ENABLED,
telegram_id=telegram_id,
)
async def probe_telegram_notifications(
*,
session: AsyncSession,
bot: Bot,
settings: Settings,
i18n: Optional[JsonI18n],
user: User,
bot_username: Optional[str] = None,
force: bool = False,
) -> dict[str, Any]:
telegram_id = getattr(user, "telegram_id", None)
if not telegram_id:
return {
"ok": False,
"status": TELEGRAM_NOTIFICATIONS_UNKNOWN,
"start_link": telegram_notifications_start_link(bot_username),
}
current_status = normalize_telegram_notification_status(
getattr(user, "telegram_notifications_status", None)
)
if current_status == TELEGRAM_NOTIFICATIONS_ENABLED and not force:
return {
"ok": True,
"status": TELEGRAM_NOTIFICATIONS_ENABLED,
"start_link": telegram_notifications_start_link(bot_username),
}
try:
await bot.get_chat(int(telegram_id))
except Exception as exc:
status = telegram_notification_status_from_error(exc)
if status:
await mark_telegram_notifications_status(session, int(user.user_id), status)
return {
"ok": False,
"status": status,
"start_link": telegram_notifications_start_link(bot_username),
}
logger.warning(
"Telegram notification chat probe failed for user %s / telegram %s: %s",
user.user_id,
telegram_id,
exc,
)
await mark_telegram_notifications_status(
session,
int(user.user_id),
TELEGRAM_NOTIFICATIONS_UNKNOWN,
)
return {
"ok": False,
"status": TELEGRAM_NOTIFICATIONS_UNKNOWN,
"start_link": telegram_notifications_start_link(bot_username),
}
await mark_telegram_notifications_status(
session,
int(user.user_id),
TELEGRAM_NOTIFICATIONS_ENABLED,
telegram_id=int(telegram_id),
)
return {
"ok": True,
"status": TELEGRAM_NOTIFICATIONS_ENABLED,
"start_link": telegram_notifications_start_link(bot_username),
}
+212
View File
@@ -0,0 +1,212 @@
"""Anonymous install telemetry beacon (self-hosted friendly, opt-out).
Once per ``TELEMETRY_INTERVAL_HOURS`` the worker sends a single obfuscation-free
but fully anonymous "heartbeat" to a PostHog ingestion endpoint so the project
maintainer can see how many installs are active and which versions/OSes are in
use. No personal data, bot tokens, domains or user identities are sent only
an opaque per-install UUID plus coarse environment facts.
Operators can opt out in three independent ways, any of which stops the beacon:
* ``TELEMETRY_ENABLED=false`` in ``.env``
* the *System Anonymous install analytics* toggle in the web admin (stored
as a DB override and re-read every tick, so no restart is required)
* leaving ``TELEMETRY_ENDPOINT`` / ``TELEMETRY_API_KEY`` empty in the image
Delivery is strictly fire-and-forget: every failure is swallowed so telemetry
can never delay, block or crash the worker.
"""
from __future__ import annotations
import asyncio
import logging
import platform
import uuid
from typing import Any, Dict, List
import aiohttp
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import sessionmaker
from bot.infra.redis import redis_lock
from bot.utils.app_version import resolve_app_version, resolve_app_version_tag
from config.settings import Settings
from db.dal import app_settings_dal, user_dal
logger = logging.getLogger(__name__)
INSTALLATION_ID_KEY = "TELEMETRY_INSTALLATION_ID"
TELEMETRY_ENABLED_KEY = "TELEMETRY_ENABLED"
HEARTBEAT_EVENT = "installation_heartbeat"
INITIAL_DELAY_SECONDS = 300
HTTP_TIMEOUT_SECONDS = 10
# Report the user count as a coarse range so individual installs stay anonymous
# and the property keeps a low cardinality for breakdowns.
_USER_BUCKETS = (
(0, "0"),
(10, "1-10"),
(50, "11-50"),
(200, "51-200"),
(1000, "201-1000"),
(5000, "1001-5000"),
)
def _bucket_users(count: int) -> str:
for upper, label in _USER_BUCKETS:
if count <= upper:
return label
return "5000+"
class TelemetryWorker:
def __init__(self, settings: Settings, session_factory: sessionmaker):
self.settings = settings
self.session_factory = session_factory
self._stopped = asyncio.Event()
def stop(self) -> None:
self._stopped.set()
def _delivery_configured(self) -> bool:
return bool(
str(self.settings.TELEMETRY_ENDPOINT or "").strip()
and str(self.settings.TELEMETRY_API_KEY or "").strip()
)
async def run(self) -> None:
if not self._delivery_configured():
logger.info("Telemetry endpoint/key not configured; anonymous beacon disabled")
return
logger.info(
"Anonymous install telemetry is ON (endpoint=%s, every %sh). "
"It sends an opaque install id, version, OS and a user-count range — "
"no personal data. Opt out via TELEMETRY_ENABLED=false or "
"Admin -> System -> Anonymous install analytics. "
"See docs/configuration/telemetry.md.",
self.settings.TELEMETRY_ENDPOINT,
self.settings.TELEMETRY_INTERVAL_HOURS,
)
await self._sleep(INITIAL_DELAY_SECONDS)
while not self._stopped.is_set():
try:
await self._beacon_tick()
except Exception:
logger.exception("Telemetry beacon tick failed")
await self._sleep(self._interval_seconds())
def _interval_seconds(self) -> int:
return max(1, int(self.settings.TELEMETRY_INTERVAL_HOURS or 24)) * 3600
async def _sleep(self, seconds: float) -> None:
try:
await asyncio.wait_for(self._stopped.wait(), timeout=seconds)
except asyncio.TimeoutError:
pass
async def _beacon_tick(self) -> None:
# A short-lived lock keeps a single beacon per interval even when the
# worker is scaled to several replicas. Without Redis the lock yields
# True, which is correct for the common single-worker deployment.
async with redis_lock(
self.settings,
"telemetry-beacon",
ttl_seconds=max(60, self._interval_seconds() // 2),
) as acquired:
if not acquired:
return
async with self.session_factory() as session:
if not await self._is_enabled(session):
return
installation_id = await self._get_or_create_installation_id(session)
payload = await self._build_payload(session, installation_id)
await session.commit()
await self._send(payload)
async def _is_enabled(self, session: AsyncSession) -> bool:
# The web admin writes the toggle as a DB override. The worker process
# does not apply overrides onto its in-memory Settings, so read it
# straight from the table; the env default applies when unset.
present, value = await app_settings_dal.get_override_value(session, TELEMETRY_ENABLED_KEY)
if present:
return bool(value)
return bool(self.settings.TELEMETRY_ENABLED)
async def _get_or_create_installation_id(self, session: AsyncSession) -> str:
present, value = await app_settings_dal.get_override_value(session, INSTALLATION_ID_KEY)
if present and value:
return str(value)
installation_id = str(uuid.uuid4())
await app_settings_dal.upsert_override(
session,
key=INSTALLATION_ID_KEY,
value=installation_id,
updated_by=None,
)
return installation_id
def _enabled_payment_providers(self) -> List[str]:
try:
from bot.payment_providers import iter_provider_specs
providers = [
str(spec.id)
for spec in iter_provider_specs()
if spec.is_effectively_enabled(self.settings)
]
return sorted(set(providers))
except Exception:
logger.debug("Telemetry: failed to enumerate payment providers", exc_info=True)
return []
async def _build_payload(self, session: AsyncSession, installation_id: str) -> Dict[str, Any]:
try:
user_count = await user_dal.count_all_users(session)
except Exception:
logger.debug("Telemetry: failed to count users", exc_info=True)
user_count = 0
version = resolve_app_version()
version_tag = resolve_app_version_tag()
# Person properties (``$set``) snapshot the latest state per install, so
# "version breakdown" in PostHog is a person-property breakdown.
person_props = {
"app_version": version,
"app_version_tag": version_tag,
"os": platform.system().lower() or "unknown",
"arch": platform.machine().lower() or "unknown",
"python_version": platform.python_version(),
"locale": str(self.settings.DEFAULT_LANGUAGE or ""),
"users_bucket": _bucket_users(int(user_count or 0)),
"webapp_enabled": bool(self.settings.WEBAPP_ENABLED),
"panel_configured": bool(str(self.settings.PANEL_API_URL or "").strip()),
"payment_providers": self._enabled_payment_providers(),
}
properties = {
**person_props,
"$lib": "remnawave-minishop",
"$lib_version": version,
"$set": person_props,
}
return {
"api_key": str(self.settings.TELEMETRY_API_KEY or "").strip(),
"event": HEARTBEAT_EVENT,
"distinct_id": installation_id,
"properties": properties,
}
async def _send(self, payload: Dict[str, Any]) -> None:
url = str(self.settings.TELEMETRY_ENDPOINT or "").strip().rstrip("/") + "/capture/"
timeout = aiohttp.ClientTimeout(total=HTTP_TIMEOUT_SECONDS)
try:
async with aiohttp.ClientSession(timeout=timeout) as http:
async with http.post(url, json=payload) as resp:
if resp.status >= 400:
body = (await resp.text())[:200]
logger.warning("Telemetry beacon rejected: HTTP %s %s", resp.status, body)
else:
logger.debug("Telemetry beacon delivered (HTTP %s)", resp.status)
except Exception:
# Never let telemetry surface as an error to operators.
logger.debug("Telemetry beacon delivery failed", exc_info=True)
@@ -0,0 +1,99 @@
import logging
from typing import Any, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from bot.middlewares.i18n import JsonI18n
from bot.services.email_auth_service import EmailAuthService
from bot.services.email_templates import render_user_notification
from bot.services.message_audit import log_user_message_delivery
from config.settings import Settings
def _translate(
i18n: Optional[JsonI18n],
language: str,
key: Optional[str],
fallback: str = "",
**kwargs: Any,
) -> str:
if not key:
return fallback
if not i18n:
return fallback or key
text = i18n.gettext(language, key, **kwargs)
return fallback if text == key and fallback else text
async def send_user_notification_email(
*,
settings: Settings,
i18n: Optional[JsonI18n],
user: Any,
subject_key: str,
message_text: str,
dashboard_url: Optional[str] = None,
cta_label_key: str = "email_user_notification_cta",
subject_kwargs: Optional[dict[str, Any]] = None,
heading_key: Optional[str] = None,
intro_key: Optional[str] = None,
session: Optional[AsyncSession] = None,
audit_event_type: Optional[str] = None,
audit_content: Optional[str] = None,
) -> bool:
if not getattr(settings, "email_auth_configured", False):
return False
recipient = str(getattr(user, "email", "") or "").strip()
if not recipient:
return False
language = (
str(getattr(user, "language_code", "") or "").strip()
or getattr(settings, "DEFAULT_LANGUAGE", "ru")
or "ru"
)
kwargs = subject_kwargs or {}
subject = _translate(i18n, language, subject_key, subject_key, **kwargs)
heading = _translate(i18n, language, heading_key, subject, **kwargs)
intro = _translate(
i18n,
language,
intro_key or "email_user_notification_intro",
"Notification from your account.",
)
cta_label = _translate(
i18n,
language,
cta_label_key or "email_user_notification_cta",
"Open dashboard",
)
try:
content = render_user_notification(
settings,
language_code=language,
subject=subject,
heading=heading,
intro=intro,
message_text=message_text,
dashboard_url=dashboard_url,
cta_label=cta_label,
i18n=i18n,
)
await EmailAuthService(settings, i18n).send_rendered_email(
email=recipient,
content=content,
)
if session is not None and audit_event_type:
await log_user_message_delivery(
session,
target_user_id=getattr(user, "user_id", None),
event_type=audit_event_type,
channel="email",
recipient=recipient,
content=audit_content or f"subject_key={subject_key}",
)
return True
except Exception:
logging.exception("Failed to send user notification email to %s.", recipient)
return False
+126
View File
@@ -0,0 +1,126 @@
"""Single source of truth for the application version string.
Resolution order mirrors the Dockerfile build chain so the dev checkout and
the runtime container agree on the value:
REMNAWAVE_MINISHOP_VERSION env > .build-version file > live ``git describe``
> ``dev+unknown``
The same value powers the admin sidebar (web process) and the anonymous
telemetry beacon (worker process), so "active installs" and version
breakdowns line up across both.
"""
from __future__ import annotations
import os
import re
import subprocess
from pathlib import Path
from typing import Optional
# ``/app`` in the container (parent of ``/app/backend``); repo root in dev.
# Matches where the Dockerfile drops .build-version / .build-tag / .build-commit.
APP_ROOT = Path(__file__).resolve().parents[3]
_APP_VERSION_CACHE: Optional[str] = None
def _run_git_command(*args: str) -> str:
try:
result = subprocess.run(
["git", *args],
cwd=APP_ROOT,
check=True,
capture_output=True,
text=True,
timeout=1.5,
)
except (OSError, subprocess.SubprocessError):
return ""
return result.stdout.strip()
def _normalize_version_branch(raw_branch: str) -> str:
branch = str(raw_branch or "").strip()
for prefix in ("refs/heads/", "refs/remotes/origin/", "origin/"):
if branch.startswith(prefix):
branch = branch[len(prefix) :]
break
if branch in ("", "HEAD"):
return ""
return re.sub(r"[^A-Za-z0-9._-]+", "-", branch).strip("-")[:48]
def _resolve_version_branch() -> str:
for env_name in (
"REMNAWAVE_MINISHOP_BRANCH",
"GIT_BRANCH",
"BRANCH_NAME",
"GITHUB_REF_NAME",
"CI_COMMIT_REF_NAME",
):
branch = _normalize_version_branch(os.getenv(env_name, ""))
if branch:
return branch
return _normalize_version_branch(
_run_git_command("branch", "--show-current")
or _run_git_command("symbolic-ref", "--quiet", "--short", "HEAD")
)
def _format_app_version(tag: str, sha: str, branch: str) -> str:
branch_suffix = "" if not branch or branch == "main" else f"-{branch}"
if tag and sha:
return f"{tag}{branch_suffix}+g{sha}"
if sha:
return f"dev{branch_suffix}+g{sha}"
if tag:
return f"{tag}{branch_suffix}"
return f"dev{branch_suffix}+unknown"
def _read_build_file(name: str) -> str:
try:
return (APP_ROOT / name).read_text(encoding="utf-8").strip()
except OSError:
return ""
def resolve_app_version() -> str:
"""Full version string (cached), e.g. ``v3.4.6+gabc1234``."""
global _APP_VERSION_CACHE
if _APP_VERSION_CACHE:
return _APP_VERSION_CACHE
env_version = os.getenv("REMNAWAVE_MINISHOP_VERSION", "").strip()
if env_version:
_APP_VERSION_CACHE = env_version
return env_version
build_version = _read_build_file(".build-version")
if build_version:
_APP_VERSION_CACHE = build_version
return build_version
tag = _run_git_command("describe", "--tags", "--abbrev=0")
sha = _run_git_command("rev-parse", "--short", "HEAD")
branch = _resolve_version_branch()
version = _format_app_version(tag, sha, branch)
_APP_VERSION_CACHE = version
return version
def resolve_app_version_tag() -> str:
"""Clean release tag for low-cardinality breakdowns, e.g. ``v3.4.6``.
Prefers the build-time ``.build-tag`` artifact, then a live ``git
describe``; falls back to the full version string when no tag is known.
"""
build_tag = _read_build_file(".build-tag")
if build_tag and build_tag != "unknown":
return build_tag
tag = _run_git_command("describe", "--tags", "--abbrev=0")
if tag:
return tag
return resolve_app_version()
+40
View File
@@ -0,0 +1,40 @@
from typing import Optional
def normalize_required_channel_id(value: object) -> Optional[int]:
if value is None:
return None
raw = str(value).strip()
if not raw:
return None
try:
channel_id = int(raw)
except (TypeError, ValueError):
return None
if channel_id == 0:
return None
if channel_id > 0:
return int(f"-100{channel_id}")
raw_abs = str(abs(channel_id))
if raw.startswith("-100"):
return channel_id
if abs(channel_id) < 1_000_000_000:
return channel_id
return -int(f"100{raw_abs}")
def is_required_channel_access_error(error: BaseException) -> bool:
message = str(error).lower()
configuration_markers = (
"chat not found",
"bot is not a member",
"not enough rights",
"have no rights",
"kicked",
)
return any(marker in message for marker in configuration_markers)

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