Compare commits

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


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

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

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

See merge request 3252a8/remnawave-minshop!1
2026-05-26 10:39:29 +00:00
3252a8 1e63431ae3 chore: merge main into dev 2026-05-26 12:51:00 +03:00
3252a8 072e7273c4 docs: standardize MIT license file 2026-05-26 12:48:58 +03:00
3252a8 df079138ee docs: standardize MIT license file 2026-05-26 12:44:27 +03:00
3252a8 87f32114d7 docs: update license file attribution 2026-05-26 12:29:58 +03:00
3252a8 b330e604f6 chore: use Docker Hub images in compose files 2026-05-26 11:15:10 +03:00
3252a8 827d69231c feat: add Docker Hub image script 2026-05-26 08:58:09 +03:00
3252a8 0623850c3f fix: open Telegram Stars invoices inside Mini App 2026-05-25 22:13:11 +03:00
3252a8 4c77d129e7 fix: update support page content without page refresh 2026-05-25 22:03:30 +03:00
3252a8 c68cb97964 fix: bind HWID top-ups to subscription periods 2026-05-25 19:33:50 +03:00
3252a8 59fa301344 feat: add runtime locale overrides 2026-05-25 17:47:45 +03:00
3252a8 23784d00fe chore: update custom themes styles 2026-05-25 13:52:25 +03:00
3252a8 17cff74b7c chore: tune sub activate modal view 2026-05-25 13:11:48 +03:00
3252a8 2a3a3c21ba chore: adjust webapp payment layout 2026-05-25 12:42:17 +03:00
3252a8 ba7811621e fix: preserve premium topup squad access 2026-05-25 12:18:58 +03:00
3252a8 340afe1b80 fix: stop premium squad sync churn 2026-05-25 11:54:03 +03:00
3252a8 11b7823188 fix: expire Wata payment links sooner 2026-05-25 11:34:10 +03:00
3252a8 de1763cfe1 feat: enable payment provider for admin only 2026-05-25 11:19:54 +03:00
3252a8 d30b069876 fix: reuse pending Wata payment links on retry 2026-05-25 10:49:57 +03:00
3252a8 0d68da9624 fix: handle Wata payment links 2026-05-25 09:17:39 +03:00
3252a8 4d857b386b fix: remove inline startup panel sync 2026-05-25 00:40:24 +03:00
3252a8 be7a3bc153 fix: queue bot panel sync requests 2026-05-25 00:36:15 +03:00
3252a8 5c71fc0de2 fix: hide links from payment success message 2026-05-25 00:23:48 +03:00
3252a8 82cc33587c fix: stop panel description churn 2026-05-25 00:06:29 +03:00
3252a8 121f3c6ddf feat: add compact panel sync update diagnostics 2026-05-24 23:44:29 +03:00
3252a8 3410eeaec1 fix(admin): delete Remnawave user with bot account 2026-05-24 23:35:19 +03:00
3252a8 85ecd644b8 fix: refresh webapp profile after activation 2026-05-24 23:25:21 +03:00
3252a8 7cffb4667f fix: expose iOS home screen icons for web app 2026-05-24 23:13:15 +03:00
3252a8 0449ded505 feat: render webapp preview metadata 2026-05-24 22:54:01 +03:00
3252a8 e15cbffdb1 feat: expose webapp title in admin settings 2026-05-24 22:53:50 +03:00
3252a8 9674c674a2 feat: resume activation handoff after payments 2026-05-24 22:45:54 +03:00
3252a8 bafc5f4709 feat: show linked emails in telegram logs 2026-05-24 22:32:35 +03:00
3252a8 a60de46173 feat: guide users after subscription activation 2026-05-24 22:24:02 +03:00
3252a8 1165dc3fa6 chore: verify formatting checks 2026-05-24 22:04:12 +03:00
3252a8 f5ab18a67b feat: redesign webapp trial offer 2026-05-24 22:02:22 +03:00
3252a8 65db4aaff6 fix: clean legacy emails from panel descriptions 2026-05-24 21:45:38 +03:00
3252a8 d9a23235e5 fix: stop syncing email in panel descriptions 2026-05-24 21:36:00 +03:00
3252a8 00b54e1f15 feat: improve admin trial settings controls 2026-05-24 19:19:39 +03:00
3252a8 3c4ff66150 feat: streamline admin tariff settings 2026-05-24 19:10:36 +03:00
3252a8 2b3078fcce chore: fix formatting checks 2026-05-24 18:51:37 +03:00
3252a8 6f4074e8d6 feat: surface trial activation in mini app 2026-05-24 18:48:01 +03:00
3252a8 235ee25d9f fix: refresh YooKassa webapp payments 2026-05-24 18:47:37 +03:00
3252a8 d1b9990bac feat: manage trial settings on tariffs page 2026-05-24 18:46:23 +03:00
3252a8 60d1ba4efc fix: handle Wata prepayment webhooks 2026-05-24 14:04:21 +03:00
3252a8 29b6e6eb24 fix: keep payment user cards in payments 2026-05-24 09:32:47 +03:00
3252a8 aa65972483 feat: add admin payment detail view 2026-05-24 09:30:58 +03:00
3252a8 ad275a7c83 feat: polish admin settings layout 2026-05-24 09:11:43 +03:00
3252a8 02b57ead00 feat: separate legacy tariff settings 2026-05-24 09:11:34 +03:00
3252a8 7f484fa7b3 feat: warn about legacy tariff settings 2026-05-24 00:05:16 +03:00
3252a8 3ea906a74d feat: group Platega admin settings 2026-05-24 00:01:58 +03:00
3252a8 939c40ccbf feat: collapse admin settings by default 2026-05-24 00:01:18 +03:00
3252a8 f0eb291f56 feat: tune visual of install instructions page 2026-05-23 23:55:06 +03:00
3252a8 46391b10e2 feat: tune deeplink fallback page 2026-05-23 23:26:10 +03:00
3252a8 5918a6cc71 fix: open install guide deeplinks via external app gateway 2026-05-23 23:07:42 +03:00
3252a8 0eeabc7b3a fix: process yookassa hwid device topups 2026-05-23 22:28:05 +03:00
3252a8 31eb5c06ad fix: repair linked panel email during sync 2026-05-23 22:27:51 +03:00
3252a8 60d8c297f9 docs: update deploy examples 2026-05-23 22:01:40 +03:00
3252a8 dab70d5a97 docs: update install guides documentation 2026-05-23 16:22:57 +03:00
3252a8 520a6289a8 Merge branch 'feature/install-page' into dev 2026-05-23 16:16:46 +03:00
3252a8 7adca53116 feat: enable bot install guides by default 2026-05-23 16:05:41 +03:00
3252a8 fbc3e6c084 chore: fix frontend formatting checks 2026-05-23 16:01:07 +03:00
3252a8 19ba5c8f11 feat: support install guide app deeplinks 2026-05-23 09:52:26 +03:00
3252a8 ce0d4dccf7 feat: open bot install guides in mini app 2026-05-23 09:48:30 +03:00
3252a8 7c874cd4aa feat: support install guides in custom themes 2026-05-22 23:39:17 +03:00
3252a8 17224b4f74 feat: harden public install guide loading 2026-05-22 23:29:58 +03:00
3252a8 40414264be feat: polish install guide loading state 2026-05-22 23:24:59 +03:00
3252a8 d9a4a007e2 feat: add install guide share tokens and animations 2026-05-22 23:20:25 +03:00
3252a8 d3925cad22 Merge branch 'dev' into feature/install-page
# Conflicts:
#	backend/bot/app/web/admin_api_impl/settings.py
#	backend/bot/app/web/webapp/cache_helpers.py
#	frontend/src/admin/sections/SettingsSection.svelte
#	tests/test_admin_settings_manifest_i18n.py
2026-05-22 22:57:24 +03:00
3252a8 443e2e62db fix: auto-merge duplicate panel identities 2026-05-22 22:39:45 +03:00
3252a8andGitHub 4b7c58a6c9 Merge pull request #1 from 3252a8/dev
Stabilize account linking, auth, and admin settings
2026-05-22 22:24:07 +03:00
3252a8 19aa2ec9c9 fix: log account merge notifications 2026-05-22 22:02:28 +03:00
3252a8 c4c5b8e3a0 fix: merge active sub email account with expired sub telegram account 2026-05-22 18:23:38 +03:00
3252a8 33a7dbc0e6 fix: app version display pattern in admin panel sidebar 2026-05-22 18:23:11 +03:00
3252a8 72921b9a8f fix: telegram oauth in logged in email account 2026-05-22 17:48:17 +03:00
3252a8 835436fa1a fix: ensure web app pay button is spawning when enable payment provider 2026-05-22 16:29:59 +03:00
3252a8 648f4ba4bc feat: show payment provider webhook urls 2026-05-22 15:57:49 +03:00
3252a8 f5023dc46b fix: repair missing panel user references 2026-05-22 15:39:51 +03:00
3252a8 4c0a798050 fix: load admin assets from stable paths 2026-05-22 15:39:45 +03:00
3252a8 cfe7c4ec5f fix: style mobile admin button 2026-05-22 15:11:28 +03:00
3252a8 c82779e15b fix: restore telegram mini app auth 2026-05-22 14:47:01 +03:00
3252a8 9e56715e77 fix: cover email telegram account linking 2026-05-22 14:41:56 +03:00
3252a8 2254b9ad19 fix: avoid repeated panel identity syncs 2026-05-22 14:11:38 +03:00
3252a8 81707d9c7c fix: clean merged panel identities 2026-05-22 14:11:31 +03:00
3252a8 9e61a3d8a8 feat: install instruction inside web app 2026-05-22 13:58:34 +03:00
3252a8 8a38524774 docs: add url to remnawave 2026-05-21 23:09:22 +03:00
3252a8 87d3f1b410 docs: add nginx example 2026-05-21 22:40:53 +03:00
3252a8 1094a65852 Merge branch 'dev' 2026-05-21 14:24:44 +03:00
3252a8 0ab4b11f97 fix: windows 95 theme support tickets list 2026-05-21 14:05:54 +03:00
3252a8 6d4fb5888f refactor: apply custom theme without cache reload 2026-05-21 13:56:05 +03:00
3252a8 e7301e2c48 refactor: optimize support polling 2026-05-21 13:41:01 +03:00
3252a8 f69f6546f0 docs: add details to env.example 2026-05-21 11:39:07 +03:00
3252a8 7fe53993aa docs: update docs and minimize env.example 2026-05-21 11:26:59 +03:00
3252a8 028f0680c6 feat: allow to edit remnawave panel settings in admin panel, ensure using i18n 2026-05-21 11:26:03 +03:00
3252a8 b3f67a5398 fix: admin panel loading 2026-05-21 10:43:55 +03:00
3252a8 db3611487e perf: optimize webapp asset delivery, split admin webapp bundle 2026-05-21 10:17:23 +03:00
3252a8 f2c722bdfc perf: reduce webapp and admin cache stampedes 2026-05-21 09:44:50 +03:00
3252a8 ccb125ab9d fix: retry Telegram startup setup until reachable 2026-05-21 08:28:27 +03:00
3252a8 a300a4a9c0 feat: add new sections to custom themes 2026-05-21 08:22:40 +03:00
3252a8 044cb4de7e refactor(redis): strengthen shared cache invalidation 2026-05-21 06:26:48 +03:00
3252a8 3405d12696 refactor: improve admin stats cache usage and etc 2026-05-20 23:35:45 +03:00
3252a8 11cd35373e fix: remove unnecessary caption in premium squad servers list in web app 2026-05-20 23:25:18 +03:00
3252a8 5001185bf8 refactor: improve panel sync performance 2026-05-20 23:18:03 +03:00
3252a8 a7f298743d refactor: improve startup and sync performance 2026-05-20 23:04:29 +03:00
3252a8 8192eaf55b refactor: improve premium squads feature performance, add benchmarks 2026-05-20 22:31:37 +03:00
3252a8 5a0e0033ec chore: rename tg support button contact 2026-05-20 22:05:33 +03:00
3252a8 7521f89ffd feat: show optional serivce description before payment 2026-05-20 21:55:22 +03:00
3252a8 d8a1da1f13 feat: add support tickets and imrpove web app loading 2026-05-20 16:38:24 +03:00
3252a8 3b846f0d44 fix: tune visual of setup password button 2026-05-19 15:38:04 +03:00
3252a8 f5006af6c0 feat: email and password login 2026-05-19 15:21:02 +03:00
3252a8 3152631911 fix: preserve Telegram usernames with underscores 2026-05-19 00:07:32 +03:00
3252a8 1d9f069f45 feat: add admin telegram profile links and avatar preview 2026-05-18 23:46:46 +03:00
3252a8 707f569f62 fix: heleket signature verify 2026-05-18 22:56:56 +03:00
3252a8 e3643ee9a0 fix: handle Caddy-proxied payment webhooks and method selection 2026-05-18 22:45:42 +03:00
3252a8 49cf5ebad8 fix: auto-append new providers to PAYMENT_METHODS_ORDER 2026-05-18 22:03:40 +03:00
3252a8 b79f5f4d7d fix: make provider services reactive to runtime config changes 2026-05-18 21:53:53 +03:00
3252a8 d870915dc0 fix: apply persisted provider overrides on startup 2026-05-18 21:40:45 +03:00
3252a8 fcd494f815 fix: restore settings binding in platega webapp payment creator 2026-05-18 21:23:57 +03:00
3252a8 a7728f80d0 fix: resolve yookassa webhook path via provider spec in startup log 2026-05-18 21:13:30 +03:00
3252a8 9eb6387973 feat: expose provider button defaults in admin settings manifest 2026-05-18 20:59:26 +03:00
3252a8 7be5510208 refactor: move yookassa env-config into module 2026-05-18 20:46:54 +03:00
3252a8 e0269bbf86 refactor: move platega env-config into module 2026-05-18 20:37:15 +03:00
3252a8 e76558d68b refactor: move stars presentation into module 2026-05-18 20:30:30 +03:00
3252a8 cceba2e98e refactor: move cryptopay env-config into module 2026-05-18 16:32:19 +03:00
3252a8 1e93d25a40 refactor: move freekassa env-config into module 2026-05-18 16:22:49 +03:00
3252a8 74ef9d31b4 refactor: move wata env-config into module 2026-05-18 16:18:04 +03:00
3252a8 15b3f9c084 refactor: move severpay env-config into module 2026-05-18 16:13:58 +03:00
3252a8 87efa6c77b refactor: provider env-config lives in modules (heleket poc) 2026-05-18 16:03:01 +03:00
3252a8 07b35036e7 refactor: keep provider presentation self-contained in spec 2026-05-18 15:35:55 +03:00
3252a8 c9c12b9ed6 feat: add heleket payment provider 2026-05-18 15:26:07 +03:00
3252a8 960754c54e fix: fix menus and modal views after payment providers refactor 2026-05-18 11:01:32 +03:00
3252a8 51c9c8b4f0 refactor: payments providers 2026-05-18 10:29:00 +03:00
3252a8 fff7e90e14 feat: add wata payment provider 2026-05-17 23:09:04 +03:00
420 changed files with 219273 additions and 10771 deletions
+34
View File
@@ -13,9 +13,14 @@ 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
!data/locales-overrides.example.json
# CI
@@ -30,9 +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__/
+57 -217
View File
@@ -1,235 +1,75 @@
# Telegram Bot Token and Admin IDs
BOT_TOKEN=your_bot_token_here # Telegram bot token
ADMIN_IDS=comma_separated_admin_ids # Your telegram ID
# Minimal bootstrap env.
# Most product settings are configured later in Web App admin:
# Admin -> System -> Settings, Admin -> System -> Tariffs, Admin -> Appearance.
# Full reference: docs/env-vars.md
# PostgreSQL Database Connection Settings
POSTGRES_USER= # Required: database user name
POSTGRES_PASSWORD= # Required: database password
POSTGRES_HOST=remnawave-minishop-db # Database container name
POSTGRES_PORT=5432 # Port
POSTGRES_DB=postgres # Database name
DB_POOL_SIZE=20 # SQLAlchemy async pool size per backend/worker process
DB_MAX_OVERFLOW=10 # Extra transient DB connections above pool size
DB_POOL_TIMEOUT_SECONDS=30 # Seconds to wait for a DB pool connection
DB_POOL_RECYCLE_SECONDS=1800 # Recycle DB connections to avoid stale sockets
# Telegram bot token from @BotFather.
# Example: 1234567890:AA...
BOT_TOKEN=your_bot_token_here
REDIS_URL=redis://redis:6379/0 # Shared Redis for FSM, rate limits, cache, locks and queues
REDIS_KEY_PREFIX=remnawave-tg-shop # Prefix for Redis keys
WEBAPP_ME_CACHE_TTL_SECONDS=15 # Short TTL for /api/me payload cache
WEBAPP_RATE_LIMIT_TTL_SECONDS=60 # Redis rate-limit window
WEBAPP_RATE_LIMIT_MAX_REQUESTS=30 # Requests per window/action/user/IP
WEBHOOK_QUEUE_NAME=webhook-events # Redis queue for heavy webhook processing
WEBHOOK_QUEUE_CONCURRENCY=4 # Worker webhook consumers
WORKER_PANEL_SYNC_INTERVAL_SECONDS=900 # Worker panel sync interval
TARIFF_WORKER_LOCK_TTL_SECONDS=240 # Redis lock TTL for tariff tick
TARIFF_WORKER_TICK_SECONDS=300 # Tariff worker tick interval
# Telegram numeric user IDs allowed to open the admin panel.
# Use commas for several admins, for example: 123456789,987654321
ADMIN_IDS=123456789
# Localization and Display
DEFAULT_LANGUAGE="ru" # or "en"
DEFAULT_CURRENCY_SYMBOL="RUB" # e.g., RUB, USD, EUR
# External Links
SUPPORT_LINK=https://t.me/your_support_link # Link to the support chat
SERVER_STATUS_URL=https://status.yourdomain.tld/status/your_service # Link to the server status page
TERMS_OF_SERVICE_URL=https://example.com/tos # Link to the terms of service
PRIVACY_POLICY_URL=https://example.com/privacy # Link to the privacy policy
USER_AGREEMENT_URL=https://example.com/user-agreement # Link to the user agreement
SUBSCRIPTION_MINI_APP_URL= # Public URL of the subscription Mini App, e.g. https://app.yourdomain.tld/
START_COMMAND_DESCRIPTION= # Description of the /start command
DISABLE_WELCOME_MESSAGE= # Disable the welcome message
MY_DEVICES_SECTION_ENABLED=False # Enable the My Devices section in the subscription menu
USER_HWID_DEVICE_LIMIT=0 # Default HWID/device limit for panel users (0 = unlimited)
# Required channel subscription
REQUIRED_CHANNEL_ID= # Telegram channel ID (e.g. -1001234567890) the user must join
REQUIRED_CHANNEL_LINK=https://t.me/your_channel # Optional: public link/invite button text opens
# Webhook Base URL (used for Telegram and payment providers)
# Public HTTPS base URL of the backend webhook server.
# Telegram, payment providers and Remnawave call webhook endpoints under this domain.
# This is usually the backend/API domain, not the Mini App frontend domain.
# Example: https://bot.yourdomain.tld
WEBHOOK_BASE_URL=https://webhooks.yourdomain.tld
TRUSTED_PROXIES=127.0.0.1,::1 # Reverse proxies trusted for X-Forwarded-For
# Subscription Mini App (same container, separate port)
WEBAPP_ENABLED=True # Run Mini App HTTP server
WEBAPP_SERVER_HOST=0.0.0.0 # Internal listen host
WEBAPP_SERVER_PORT=8081 # Internal/published Mini App port
WEBAPP_TITLE="/minishop" # Mini App title
WEBAPP_THEMES_DIR=data/themes # Folder with theme subfolders: <key>/theme.json and optional CSS/assets
WEBAPP_DEFAULT_THEME= # Optional: override descriptor default theme key (e.g. light)
WEBAPP_SESSION_SECRET= # Optional: HMAC secret for webapp sessions; generated if empty
WEBHOOK_SECRET_TOKEN= # Optional: Telegram webhook secret token; generated if empty
WEBAPP_SESSION_TTL_SECONDS=86400 # Web App session lifetime (24h)
WEBAPP_AUTH_MAX_AGE_SECONDS=86400 # Max Telegram initData age
WEBAPP_LOGIN_TOKEN_TTL_SECONDS=600 # External browser login link lifetime
TELEGRAM_OAUTH_CLIENT_ID= # Telegram Web Login Client ID from BotFather; defaults to bot ID from BOT_TOKEN
TELEGRAM_OAUTH_CLIENT_SECRET= # Optional Telegram Web Login Client Secret; reserved for full OIDC code flow
TELEGRAM_OAUTH_REQUEST_ACCESS=write # Optional comma-separated permissions: write,phone; empty = OpenID profile only
# PostgreSQL user created by Docker Compose and used by the backend.
POSTGRES_USER=remnawave_minishop
# Email login and account linking via SMTP (Brevo SMTP relay defaults)
SMTP_HOST=smtp-relay.brevo.com # SMTP server
SMTP_PORT=587 # Brevo recommends 587 with STARTTLS
SMTP_FALLBACK_PORTS=2525,465 # Tried after SMTP_PORT; 465 uses SSL automatically
SMTP_TIMEOUT_SECONDS=30 # Per SMTP connection/send attempt timeout
SMTP_USERNAME= # Brevo SMTP login
SMTP_PASSWORD= # Brevo SMTP key/password
SMTP_FROM_EMAIL= # Verified sender email
SMTP_FROM_NAME= # Optional sender name
SMTP_STARTTLS=True # Use STARTTLS on SMTP_PORT
SMTP_USE_SSL=False # Use SSL wrapper, usually only for port 465
EMAIL_CODE_TTL_SECONDS=600 # Email verification code lifetime
EMAIL_CODE_RESEND_SECONDS=60 # Minimum delay between code sends
EMAIL_CODE_MAX_ATTEMPTS=5 # Max attempts per code
BRUTE_FORCE_MAX_FAILURES=5 # Max failed code attempts in the throttle window
BRUTE_FORCE_WINDOW_SECONDS=900 # Rolling window used to count failures
BRUTE_FORCE_LOCK_SECONDS=1800 # Temporary lockout duration after too many failures
# PostgreSQL password. Change it before production deploy.
POSTGRES_PASSWORD=change_me
# Payment Method Toggles
YOOKASSA_ENABLED=True # Turn on YOOKASSA
FREEKASSA_ENABLED=True # Turn on FreeKassa
STARS_ENABLED=True # Turn on STARS
CRYPTOPAY_ENABLED=True # Turn on CRYPTOPAY
PLATEGA_ENABLED=False # Turn on PLATEGA
SEVERPAY_ENABLED=False # Turn on SeverPay
# Order of payment methods (top to bottom). Supported: severpay, freekassa, platega, yookassa, stars, cryptopay
PAYMENT_METHODS_ORDER=severpay,yookassa,cryptopay,freekassa,platega,stars
# PostgreSQL database name created by Docker Compose.
POSTGRES_DB=remnawave_minishop
# YooKassa Payment Gateway Configuration
YOOKASSA_SHOP_ID=your_shop_id # Your store ID in YooKassa
YOOKASSA_SECRET_KEY=your_secret_key # Your secret key for YooKassa
YOOKASSA_RETURN_URL=https://t.me/your_bot # URL to which the user will be returned after payment
YOOKASSA_DEFAULT_RECEIPT_EMAIL=your_email@example.com # Default email for sending receipts
YOOKASSA_VAT_CODE=1 # VAT code
YOOKASSA_AUTOPAYMENTS_ENABLED=False # Auto-renew toggle
YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING=True # Force automatic card binding when autopay is enabled (set to False to show the save-card checkbox)
# Enables the Web App and the Web App admin panel.
# Keep True for the first setup. If set to False, the admin UI is unavailable
# until you change it back to True in .env and restart the app.
WEBAPP_ENABLED=True
# Nalogo (self-employed receipts)
NALOGO_INN=your_inn # INN for nalog.ru
NALOGO_PASSWORD=your_nalogo_password # Password for nalog.ru
NALOGO_RECEIPT_NAME_SUBSCRIPTION=subscription {months} months # Receipt name for time-based subscriptions ({months} = duration)
NALOGO_RECEIPT_NAME_TRAFFIC=traffic package {gb} GB # Receipt name for traffic packages ({gb} = traffic amount)
# Stable secret for Web App sessions.
# Generate with: openssl rand -hex 32
# If empty, sessions are invalidated on every restart.
WEBAPP_SESSION_SECRET=
# FreeKassa Payment Gateway Configuration
FREEKASSA_MERCHANT_ID=your_shop_id # Your shop ID in FreeKassa
FREEKASSA_API_KEY=your_api_key # API key for REST requests
FREEKASSA_SECOND_SECRET=your_second_secret # Secret word #2 (used to verify notifications)
FREEKASSA_PAYMENT_IP= # Public IP address reported to FreeKassa
FREEKASSA_PAYMENT_METHOD_ID=44 # Payment method ID, you can get it from https://merchant.freekassa.net/settings/currencies
FREEKASSA_TRUSTED_IPS=168.119.157.136,168.119.60.227,178.154.197.79,51.250.54.238 # FreeKassa webhook source IP allowlist
# Stable Telegram webhook secret_token.
# Generate with: openssl rand -hex 32
# If empty, a new token can be generated on process start.
WEBHOOK_SECRET_TOKEN=
# CryptoBot Payment Gateway Configuration
CRYPTOPAY_TOKEN= # API token for CryptoPay
CRYPTOPAY_NETWORK=mainnet # Network (mainnet or testnet)
CRYPTOPAY_CURRENCY_TYPE=fiat # Currency type (fiat or crypto)
CRYPTOPAY_ASSET=RUB # Asset, e.g., RUB, BTC, USDT
# Public HTTPS URL of the Mini App frontend, with trailing slash.
# This URL is opened by Telegram buttons and BotFather Mini App settings.
# Do not put /api or webhook paths here.
# Example: https://app.yourdomain.tld/
SUBSCRIPTION_MINI_APP_URL=https://app.yourdomain.tld/
# Platega Payment Gateway Configuration
PLATEGA_BASE_URL=https://app.platega.io # Base API URL
PLATEGA_MERCHANT_ID= # Your MerchantId from Platega
PLATEGA_SECRET= # API secret from Platega
PLATEGA_PAYMENT_METHOD=2 # Legacy method ID; fallback for the SBP button when PLATEGA_SBP_METHOD stays default
PLATEGA_SBP_ENABLED=False # Show a separate "Pay via SBP" Platega button
PLATEGA_CRYPTO_ENABLED=False # Show a separate "Pay with crypto" Platega button
PLATEGA_SBP_METHOD=2 # Platega method ID for SBP QR (default 2)
PLATEGA_CRYPTO_METHOD=13 # Platega method ID for crypto (default 13)
PLATEGA_RETURN_URL= # Optional: redirect after successful payment (defaults to bot link)
PLATEGA_FAILED_URL= # Optional: redirect after failed/cancelled payment (defaults to return URL)
# Remnawave panel API URL. Usually the panel domain plus /api.
# Example: https://panel.yourdomain.tld/api
PANEL_API_URL=https://panel.yourdomain.tld/api
# SeverPay Payment Gateway Configuration
SEVERPAY_BASE_URL=https://severpay.io/api/merchant # Base API URL
SEVERPAY_MID= # Your MID from SeverPay
SEVERPAY_TOKEN= # API token/secret for signing requests
SEVERPAY_RETURN_URL= # Optional: redirect URL after payment (defaults to bot link)
SEVERPAY_LIFETIME_MINUTES= # Optional: payment link lifetime in minutes (30-4320, leave empty for default)
# Remnawave API key with permissions to manage users, subscriptions and squads.
# Keep this secret. It can be overridden later in the admin panel if needed.
PANEL_API_KEY=
# Subscription Options. Specify cost parameters or payment links here.
1_MONTH_ENABLED=True
RUB_PRICE_1_MONTH=150
STARS_PRICE_1_MONTH=0
# Shared secret for validating incoming Remnawave webhooks.
# 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=
3_MONTHS_ENABLED=True
RUB_PRICE_3_MONTHS=300
STARS_PRICE_3_MONTHS=0
6_MONTHS_ENABLED=True
RUB_PRICE_6_MONTHS=500
STARS_PRICE_6_MONTHS=0
12_MONTHS_ENABLED=True
RUB_PRICE_12_MONTHS=900
STARS_PRICE_12_MONTHS=0
# Traffic Packages (enables traffic sale mode when set)
TRAFFIC_PACKAGES=10:199,50:799 # Format: "<GB>:<price>", comma-separated
STARS_TRAFFIC_PACKAGES=10:2500 # Optional: traffic packages priced in Stars
TARIFFS_CONFIG_PATH=data/tariffs.json # Optional Tariffs 2.0 JSON config. If missing, legacy .env pricing is used.
TARIFF_TRAFFIC_WARNING_LEVELS=85,90,95 # Tariffs 2.0 traffic warning levels, percent used
# Subscription Notifications
SUBSCRIPTION_NOTIFICATIONS_ENABLED=True # Enable subscription
SUBSCRIPTION_NOTIFY_ON_EXPIRE=True # Notify on subscription
SUBSCRIPTION_NOTIFY_AFTER_EXPIRE=True # Notify after
SUBSCRIPTION_NOTIFY_DAYS_BEFORE=3 # Days before expiration to notify
REFERRAL_ONE_BONUS_PER_REFEREE=False # Give a bonus only once per referee
REFERRAL_WELCOME_BONUS_DAYS=3 # Welcome bonus for newly registered user from referral link
LEGACY_REFS=true # Allow ref_<tg_id> links. Leave unset/true unless you want to disable old links
# Referral Bonus Days
# Bonus for the inviting user
REFERRAL_BONUS_DAYS_1_MONTH=3
REFERRAL_BONUS_DAYS_3_MONTHS=7
REFERRAL_BONUS_DAYS_6_MONTHS=15
REFERRAL_BONUS_DAYS_12_MONTHS=30
# Invited User Bonus
REFEREE_BONUS_DAYS_1_MONTH=1
REFEREE_BONUS_DAYS_3_MONTHS=3
REFEREE_BONUS_DAYS_6_MONTHS=7
REFEREE_BONUS_DAYS_12_MONTHS=15
# Panel API Configuration
PANEL_API_URL=http://your_panel_api_url/api # URL of the panel API
PANEL_API_KEY=your_panel_api_key # Panel API key
PANEL_WEBHOOK_SECRET= # secret used to verify panel webhook signatures
# User traffic limits (applied for all users)
# 0 means unlimited
USER_TRAFFIC_LIMIT_GB=0 # Traffic limit for users (0 unlimited)
USER_TRAFFIC_STRATEGY="NO_RESET" # Traffic reset strategy (NO_RESET, WEEK, MONTH)
# Default Internal Squads for Users (Optional, comma-separated UUIDs)
USER_SQUAD_UUIDS=uuid1,uuid2,uuid3
# Default External Squad for Users (Optional, single UUID)
USER_EXTERNAL_SQUAD_UUID= # Optional: UUID from Remnawave External Squads to auto-link new panel users
# Trial Settings
TRIAL_ENABLED=True # Enable the trial period
TRIAL_DURATION_DAYS=5 # Duration of the trial period in days
TRIAL_TRAFFIC_LIMIT_GB=0 # Traffic limit for the trial period (0 = unlimited)
TRIAL_TRAFFIC_STRATEGY="NO_RESET" # Traffic reset strategy for the trial period (NO_RESET, WEEK, MONTH)
# Connection link handling (happ crypt4)
CRYPT4_ENABLED=False # Enable happ crypt4 encryption for subscription URLs
CRYPT4_REDIRECT_URL= # Base redirect to wrap the connect button, e.g. https://redir.example.com?url=
# Web Server Settings (for handling webhooks)
WEB_SERVER_HOST="0.0.0.0"
# Host port that publishes the backend webhook server from Docker Compose.
# Your reverse proxy should route WEBHOOK_BASE_URL traffic to this port.
WEB_SERVER_PORT=8080
# Admin Panel Log Pagination
LOGS_PAGE_SIZE=10 # Number of events in the log
LOG_LEVEL=INFO # Global log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
# Host port that publishes the frontend nginx from Docker Compose.
# Your reverse proxy should route SUBSCRIPTION_MINI_APP_URL traffic to this port.
FRONTEND_PORT=8082
# Admin Logging Configuration
LOG_CHAT_ID=-1001234567890 # Telegram chat/group ID for admin notifications
LOG_THREAD_ID= # Optional: Thread ID for supergroup messages
LOG_NEW_USERS=True # Log new user registrations
LOG_PAYMENTS=True # Log payments
LOG_PROMO_ACTIVATIONS=True # Log promo code activations
LOG_TRIAL_ACTIVATIONS=True # Log trial activations
LOG_SUSPICIOUS_ACTIVITY=True # Log suspicious activity
LOG_ADMIN_ACTIONS=True # Log actions from users listed in ADMIN_IDS
# Embedded mode thumbnails. Please don't touch this if you don't know what it is.
INLINE_REFERRAL_THUMBNAIL_URL=https://cdn-icons-png.flaticon.com/512/1077/1077114.png
INLINE_USER_STATS_THUMBNAIL_URL=https://cdn-icons-png.flaticon.com/512/681/681494.png
INLINE_FINANCIAL_STATS_THUMBNAIL_URL=https://cdn-icons-png.flaticon.com/512/2769/2769339.png
INLINE_SYSTEM_STATS_THUMBNAIL_URL=https://cdn-icons-png.flaticon.com/512/2920/2920277.png
# 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
+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
+117
View File
@@ -0,0 +1,117 @@
name: Docker build & push (reusable)
# Reusable workflow that builds the three image targets defined in
# deploy/docker/Dockerfile (backend, worker, frontend) and optionally pushes
# them to both ghcr.io and Docker Hub under the 3252a8/ namespace.
#
# Called by:
# - docker-dev.yml (tag_mode: dev, push: true) on pushes to dev
# - docker-release.yml (tag_mode: release, push: true) on pushes to main
# - ci.yml (tag_mode: dev, push: false) on pull requests
on:
workflow_call:
inputs:
push:
description: "Push the built images to the registries"
type: boolean
default: true
tag_mode:
description: "Tagging strategy: 'dev' or 'release'"
type: string
required: true
# No permissions block here on purpose: a reusable workflow cannot request more
# than its caller grants, so the token scope is set by each caller
# (docker-dev.yml / docker-release.yml grant packages: write to push; ci.yml
# only needs contents: read for a no-push build).
jobs:
build:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- target: backend
image: remnawave-minishop-backend
- target: worker
image: remnawave-minishop-worker
- target: frontend
image: remnawave-minishop-frontend
steps:
- name: Checkout
uses: actions/checkout@v4
with:
# Full history + tags: the Dockerfile's version-builder stage runs
# `git describe --tags` against the copied .git tree.
fetch-depth: 0
- name: Resolve release version
id: version
if: inputs.tag_mode == 'release'
run: |
# On a tag push github.ref_name is the tag (e.g. v3.4.5); for a
# manual workflow_dispatch on a branch, fall back to the latest tag.
if [ "${{ github.ref_type }}" = "tag" ]; then
raw="${{ github.ref_name }}"
else
raw="$(git describe --tags --abbrev=0 2>/dev/null)"
fi
version="${raw#v}"
if [ -z "$version" ]; then
echo "::error::No git tag found to derive the release version from"
exit 1
fi
echo "version=${version}" >> "$GITHUB_OUTPUT"
echo "Release version: ${version}"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
if: inputs.push
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Log in to Docker Hub
if: inputs.push
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: |
3252a8/${{ matrix.image }}
ghcr.io/3252a8/${{ matrix.image }}
tags: |
type=raw,value=dev,enable=${{ inputs.tag_mode == 'dev' }}
type=sha,prefix=dev-,format=short,enable=${{ inputs.tag_mode == 'dev' }}
type=raw,value=latest,enable=${{ inputs.tag_mode == 'release' }}
type=raw,value=${{ steps.version.outputs.version }},enable=${{ inputs.tag_mode == 'release' }}
- name: Build${{ inputs.push && ' & push' || '' }} ${{ matrix.image }}
uses: docker/build-push-action@v6
with:
context: .
file: deploy/docker/Dockerfile
target: ${{ matrix.target }}
platforms: linux/amd64
push: ${{ inputs.push }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
# The Dockerfile's version-builder appends a "-<branch>" suffix to the
# internal version string for non-main builds. Force "main" on release
# (the ref is the tag, not a branch) so release images stay un-suffixed.
build-args: |
GITHUB_REF_NAME=${{ inputs.tag_mode == 'release' && 'main' || github.ref_name }}
cache-from: type=gha,scope=${{ matrix.target }}
cache-to: type=gha,mode=max,scope=${{ matrix.target }}
provenance: false
+64
View File
@@ -0,0 +1,64 @@
name: PR checks
# Runs on pull requests into main (typically from dev) and into dev (typically
# from feature/* branches): lint + format checks and a no-push image build to
# prove the Docker images still build.
on:
pull_request:
branches: [main, dev]
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
lint:
name: Lint & format
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install ruff
run: pip install "ruff>=0.8.0"
- name: Ruff lint (Python)
run: ruff check .
- name: Ruff format check (Python)
run: ruff format --check .
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Install frontend deps
run: npm ci
working-directory: frontend
- name: ESLint (frontend)
run: npm run lint
working-directory: frontend
- name: Prettier check (frontend)
run: npm run format:check
working-directory: frontend
build:
name: Docker build
uses: ./.github/workflows/_docker-build-push.yml
with:
push: false
tag_mode: dev
+27
View File
@@ -0,0 +1,27 @@
name: Dependency review
# On PRs into main/dev, flag any newly added dependency that has a known
# vulnerability or an incompatible license before it gets merged.
on:
pull_request:
branches: [main, dev]
permissions:
contents: read
jobs:
dependency-review:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Dependency review
uses: actions/dependency-review-action@v4
with:
fail-on-severity: high
comment-summary-in-pr: on-failure
+25
View File
@@ -0,0 +1,25 @@
name: Dev images
# On every push to the dev branch, build all three images and push them to
# ghcr.io and Docker Hub tagged `dev` and `dev-<short-sha>`.
on:
push:
branches: [dev]
workflow_dispatch:
concurrency:
group: docker-dev-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
packages: write
jobs:
build-push:
uses: ./.github/workflows/_docker-build-push.yml
with:
push: true
tag_mode: dev
secrets: inherit
+28
View File
@@ -0,0 +1,28 @@
name: Release images
# Build all three images and push them to ghcr.io and Docker Hub tagged
# `latest` and the release version (the pushed tag with its leading `v`
# stripped, e.g. v3.4.5 -> 3.4.5). Triggered only when a new v* tag is pushed,
# so images are built once per release rather than on every commit to main.
on:
push:
tags:
- "v*"
workflow_dispatch:
concurrency:
group: docker-release-${{ github.ref }}
cancel-in-progress: false
permissions:
contents: read
packages: write
jobs:
build-push:
uses: ./.github/workflows/_docker-build-push.yml
with:
push: true
tag_mode: release
secrets: inherit
+96
View File
@@ -0,0 +1,96 @@
name: Security
# Audits the full dependency set (pip-audit, npm audit) and runs a Trivy
# filesystem scan (dependencies + Dockerfile/IaC misconfig). Trivy results are
# uploaded to the Security -> Code scanning tab.
#
# pip-audit / npm audit are informational (continue-on-error): they surface
# upstream/transitive advisories that aren't necessarily fixable in a given PR,
# so they report in the logs without blocking merges. The PR gate for newly
# introduced vulnerable deps is dependency-review.yml.
on:
pull_request:
branches: [main, dev]
push:
branches: [main, dev]
schedule:
- cron: "27 4 * * 1" # weekly, Monday 04:27 UTC
workflow_dispatch:
concurrency:
group: security-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
python-audit:
name: pip-audit
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install pip-audit
run: pip install pip-audit
- name: Audit Python dependencies
continue-on-error: true
run: pip-audit -r backend/requirements.txt
npm-audit:
name: npm audit
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Install frontend deps
run: npm ci
working-directory: frontend
- name: Audit npm dependencies
continue-on-error: true
run: npm audit --audit-level=high
working-directory: frontend
trivy:
name: Trivy filesystem scan
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Run Trivy
uses: aquasecurity/trivy-action@v0.36.0
with:
scan-type: fs
scan-ref: .
format: sarif
output: trivy-results.sarif
severity: CRITICAL,HIGH
ignore-unfixed: true
- name: Upload Trivy results
uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: trivy-results.sarif
category: trivy-fs
+44
View File
@@ -7,17 +7,60 @@ bot_database.sqlite3
!.env.example
docker-compose-dev.yml
scratch_*.py
scratch/
*.local.*
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
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
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
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
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
@@ -36,3 +79,4 @@ locales/en_backup.json
db/models_old.py
data/*
!data/tariffs.example.json
!data/locales-overrides.example.json
+18 -4
View File
@@ -1,7 +1,21 @@
Copyright 2025 machka-pasla
MIT License
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
Copyright (c) 2025-2026 machka-pasla, 3252a8 and other contributors
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+56 -24
View File
@@ -2,9 +2,9 @@
![Remnawave Minishop](docs/remnawave-minishop.webp)
Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи и управления подписками Remnawave. Бот обрабатывает регистрацию, оплату, продление, пробный период, промокоды, рефералов и поддержку в чате. Web App показывает ссылку подключения, срок действия, трафик, оплату, устройства и вход по Telegram Mini Apps `initData`, Telegram OAuth / OpenID Connect и одноразовому email-коду.
Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи и управления подписками панели [Remnawave](https://docs.rw/). Бот обрабатывает регистрацию, оплату, продление, пробный период, промокоды, рефералов и поддержку в чате. Web App показывает ссылку подключения, срок действия, трафик, оплату, устройства и вход по Telegram Mini Apps `initData`, Telegram OAuth / OpenID Connect и одноразовому email-коду.
Проект является переработанным форком [kavore/remnawave-tg-shop](https://github.com/kavore/remnawave-tg-shop). Для переноса данных из прежнего стека используйте [инструкцию по миграции](docs/migration-to-minishop.md).
Проект является переработанным форком [kavore/remnawave-tg-shop](https://github.com/kavore/remnawave-tg-shop). Для переноса данных из прежнего стека и других ботов используйте [раздел миграций](docs/migrations/index.md).
## Возможности
@@ -14,8 +14,10 @@ Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи
- просмотр статуса подписки, даты окончания, ссылки подключения и трафика;
- покупка подписок, пакетов трафика, обычная и premium-докупка трафика, докупка устройств по настроенному каталогу тарифов;
- Web App / Mini App с входом через Telegram или email;
- встроенные инструкции установки в Mini App: личный экран `/install` и публичная ссылка `/s/<token>` для передачи инструкции;
- пробный период, промокоды и реферальная программа;
- оплата через YooKassa, FreeKassa, Platega, SeverPay, CryptoPay и Telegram Stars;
- оплата через YooKassa, FreeKassa, Platega, SeverPay, Wata, CryptoPay, Heleket и Telegram Stars;
- тикеты поддержки в Web App и внешняя ссылка на поддержку;
- раздел "Мои устройства" при включенном `MY_DEVICES_SECTION_ENABLED`.
Для администраторов:
@@ -23,19 +25,26 @@ Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи
- админ-панель для пользователей из `ADMIN_IDS` (только при входе через Telegram, не для аккаунтов только с email);
- статистика пользователей, подписок, платежей и синхронизации с Remnawave;
- список пользователей с поиском, фильтрами и колонкой premium-трафика;
- блокировка пользователей, рассылки, промокоды, логи действий и настройка разрешенных параметров приложения поверх `.env`;
- редактор JSON-каталога тарифов с period/traffic-моделями, Internal Squads, premium-сквадами и HWID-пакетами;
- блокировка пользователей, поддержка через тикеты, рассылки, промокоды, логи действий и настройка разрешенных параметров приложения поверх `.env`;
- редактор JSON-каталога тарифов с моделями на срок/по трафику, Internal Squads, premium-сквадами и HWID-пакетами;
- настройки инструкций подключения: чтение конфига Subscription Page из Remnawave Panel, опциональное JSON-переопределение и переключатель поведения кнопок бота;
- ручная синхронизация пользователей и подписок с панелью.
## Документация
- [Настройка окружения](docs/configuration.md) - основные переменные `.env`, платежи, Remnawave, пробный период, SMTP для email-входа и секреты.
- [Тарифы](docs/tariffs.md) - каталог тарифов, period- и traffic-модели, обычные и premium-докупки, premium-сквады, смена тарифа, HWID-лимиты и обработка трафика.
- [Админ-панель](docs/admin.md) - права доступа, настройки, редактор тарифов, premium-сквады и сохранение JSON-каталога.
- [Web App / Mini App](docs/webapp.md) - отдельный порт, домен, Telegram OAuth, email-вход и реферальные ссылки.
- [Темы 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-стека.
## Совместимость
@@ -60,7 +69,7 @@ Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи
- Docker и Docker Compose;
- рабочая панель Remnawave версии **`> 2.7.0`** (см. раздел «Совместимость»);
- токен Telegram-бота;
- параметры хотя бы одного платежного провайдера.
- публичные домены для webhook и Mini App.
```bash
git clone https://github.com/3252a8/remnawave-minishop
@@ -76,17 +85,24 @@ docker compose logs -f backend worker frontend
- `BOT_TOKEN` - токен Telegram-бота;
- `ADMIN_IDS` - Telegram ID администраторов через запятую;
- `WEBHOOK_BASE_URL` - публичный URL вебхуков;
- `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB` - доступы PostgreSQL;
- `WEBAPP_ENABLED=True` - включает Web App и админку для первого входа;
- `WEBAPP_SESSION_SECRET`, `WEBHOOK_SECRET_TOKEN` - стабильные секреты;
- `SUBSCRIPTION_MINI_APP_URL` - публичный HTTPS URL Mini App/frontend, например `https://app.domain.com/`;
- `PANEL_API_URL`, `PANEL_API_KEY`, `PANEL_WEBHOOK_SECRET` - доступ к Remnawave;
- `USER_SQUAD_UUIDS` - Internal Squads для пользователей;
- настройки платежного провайдера;
- `SUBSCRIPTION_MINI_APP_URL`, если используется Web App.
- остальные настройки удобнее задать в Web App админке.
Для каталога тарифов используется `TARIFFS_CONFIG_PATH` со значением по умолчанию `data/tariffs.json`. Пример формата лежит в [data/tariffs.example.json](data/tariffs.example.json), подробности - в [docs/tariffs.md](docs/tariffs.md).
В Remnawave Panel укажите `WEBHOOK_URL` как публичный адрес Minishop с путем `/webhook/panel`, например `https://app.example.com/webhook/panel`. Секрет вебхука задается в самой Remnawave Panel; это же значение вставьте в `PANEL_WEBHOOK_SECRET` в `.env` или в **Система -> Настройки -> Remnawave Panel** в админке.
Если в Docker Compose включаете bind mount `./data:/app/data`, заранее создайте каталог и отдайте его пользователю контейнера. Это нужно для сохранения `data/tariffs.json`, каталога тем `data/themes`, кеша логотипа Web App и animated emoji:
После первого входа в админку настройте тарифы, платежные провайдеры, внешний вид, поддержку, уведомления и инструкции подключения через UI. Инструкции установки включены по умолчанию, читают Subscription Page config из Remnawave Panel и при проблемах с конфигом откатываются к обычной ссылке подключения. Полный справочник env-переменных: [docs/configuration/env-vars.md](docs/configuration/env-vars.md).
Для каталога тарифов используется `TARIFFS_CONFIG_PATH` со значением по умолчанию `data/tariffs.json`. Пример формата лежит в [data/tariffs.example.json](data/tariffs.example.json), подробности - в [docs/features/tariffs.md](docs/features/tariffs.md).
В 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
touch data/locales-overrides.json
chown -R 10001:10001 data
chmod -R u+rwX data
```
@@ -100,19 +116,35 @@ docker compose up -d --build
# Логи приложения
docker compose logs -f backend worker frontend
# Запуск с Caddy
docker compose -f deploy/compose/docker-compose-caddy.yml up -d
# Рекомендуемый продакшен-вариант с Caddy
cd deploy/examples/caddy # или nginx, newt, no-proxy
cp .env.example .env
nano .env
docker compose up -d
# Запуск из готового образа
IMAGE_TAG=3.1.0 docker compose -f deploy/compose/docker-compose-remote-server.yml up -d
# Запуск из готового образа с конкретным тегом
IMAGE_TAG=3.1.0 docker compose up -d
```
GHCR image names for releases:
Для продакшен-запуска удобнее брать готовые папки из [`deploy/examples`](deploy/examples), а читать каноничные инструкции в [docs/getting-started/deployment.md](docs/getting-started/deployment.md). Предпочтительный вариант для обычного публичного сервера - Caddy: он сам выпускает и продлевает HTTPS-сертификаты. В папках рядом с compose лежат только конфиги и короткие ссылки на документацию.
Имена образов для релизов:
- `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
```
## Поддержать проект
- Crypto: `USDT/Other ERC-20 0xeD506D44aae634fEc0E01C8835744fBedb7B2a44 (Ethereum/Polygon/Gnosis)`
+40 -56
View File
@@ -2,18 +2,20 @@ from aiogram import Bot
from sqlalchemy.orm import sessionmaker
from bot.middlewares.i18n import JsonI18n
from bot.services.crypto_pay_service import CryptoPayService
from bot.services.freekassa_service import FreeKassaService
from bot.payment_providers import (
ServiceFactoryContext,
build_provider_configs,
build_provider_services,
)
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_webhook_service import PanelWebhookService
from bot.services.platega_service import PlategaService
from bot.services.promo_code_service import PromoCodeService
from bot.services.referral_service import ReferralService
from bot.services.severpay_service import SeverPayService
from bot.services.stars_service import StarsService
from bot.services.subscription_service import SubscriptionService
from bot.services.yookassa_service import YooKassaService
from bot.services.support_service import SupportService
from config.settings import Settings
@@ -28,52 +30,38 @@ def build_core_services(
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)
stars_service = StarsService(bot, settings, i18n, subscription_service, referral_service)
cryptopay_service = CryptoPayService(
settings.CRYPTOPAY_TOKEN,
settings.CRYPTOPAY_NETWORK,
email_auth_service = EmailAuthService(settings, i18n)
notification_service = NotificationService(
bot,
settings,
i18n,
session_factory=async_session_factory,
email_auth_service=email_auth_service,
bot_username=bot_username_for_default_return,
)
support_service = SupportService(
async_session_factory,
subscription_service,
referral_service,
)
freekassa_service = FreeKassaService(
bot=bot,
settings=settings,
i18n=i18n,
async_session_factory=async_session_factory,
subscription_service=subscription_service,
referral_service=referral_service,
)
platega_service = PlategaService(
bot=bot,
settings=settings,
i18n=i18n,
async_session_factory=async_session_factory,
subscription_service=subscription_service,
referral_service=referral_service,
default_return_url=bot_username_for_default_return,
)
severpay_service = SeverPayService(
bot=bot,
settings=settings,
i18n=i18n,
async_session_factory=async_session_factory,
subscription_service=subscription_service,
referral_service=referral_service,
default_return_url=bot_username_for_default_return,
settings,
bot,
i18n,
notification_service,
email_auth_service,
)
panel_webhook_service = PanelWebhookService(
bot, settings, i18n, async_session_factory, panel_service
)
yookassa_service = YooKassaService(
shop_id=settings.YOOKASSA_SHOP_ID,
secret_key=settings.YOOKASSA_SECRET_KEY,
configured_return_url=settings.YOOKASSA_RETURN_URL,
bot_username_for_default_return=bot_username_for_default_return,
settings_obj=settings,
provider_configs = build_provider_configs()
payment_services = build_provider_services(
ServiceFactoryContext(
settings=settings,
bot=bot,
async_session_factory=async_session_factory,
i18n=i18n,
bot_username_for_default_return=bot_username_for_default_return,
subscription_service=subscription_service,
referral_service=referral_service,
provider_configs=provider_configs,
)
)
lknpd_service = LknpdService(
settings.LKNPD_INN,
@@ -81,24 +69,20 @@ def build_core_services(
api_url=settings.LKNPD_API_URL,
)
# Wire services that depend on each other. These attachments are critical
# for auto-renew (subscription_service.yookassa_service) and for the panel
# webhook handler's 24h pre-expiry renewal trigger; do NOT swallow errors —
# silent wiring failures previously caused auto-renew to disappear.
subscription_service.yookassa_service = yookassa_service
# These attachments are critical for auto-renew and panel pre-expiry hooks.
subscription_service.yookassa_service = payment_services.get("yookassa_service")
panel_webhook_service.subscription_service = subscription_service
return {
services = {
"panel_service": panel_service,
"subscription_service": subscription_service,
"referral_service": referral_service,
"promo_code_service": promo_code_service,
"stars_service": stars_service,
"cryptopay_service": cryptopay_service,
"freekassa_service": freekassa_service,
"notification_service": notification_service,
"email_auth_service": email_auth_service,
"support_service": support_service,
"panel_webhook_service": panel_webhook_service,
"yookassa_service": yookassa_service,
"lknpd_service": lknpd_service,
"platega_service": platega_service,
"severpay_service": severpay_service,
}
services.update(payment_services)
return services
+6
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,
@@ -15,9 +16,11 @@ from bot.app.web.admin_api_impl import (
routes as _routes,
settings as _settings,
stats as _stats,
support as _support,
sync as _sync,
tariffs as _tariffs,
themes as _themes,
translations as _translations,
users as _users,
)
@@ -30,12 +33,15 @@ _MODULES = (
_payments,
_promos,
_logs,
_support,
_broadcast,
_sync,
_ads,
_backups,
_settings,
_tariffs,
_themes,
_translations,
_panel,
_routes,
)
@@ -41,6 +41,7 @@ from config.tariffs_config import TariffsConfig
from db.dal import (
ad_dal,
app_settings_dal,
locale_overrides_dal,
message_log_dal,
panel_sync_dal,
payment_dal,
@@ -0,0 +1,182 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
import secrets
import subprocess
from bot.infra.redis import redis_lock
from bot.services.backup_restore_service import (
BACKUP_UPLOAD_MAX_BYTES,
BackupArchiveError,
BackupArchiveInfo,
BackupRestoreError,
BackupRestoreService,
)
from bot.services.backup_worker import BackupWorker
def _backup_archive_payload(archive) -> Dict[str, Any]:
return archive.to_payload()
async def _read_uploaded_backup_file(request: web.Request) -> BackupArchiveInfo:
settings: Settings = request.app["settings"]
service = BackupRestoreService(settings)
backup_dir = service.backup_dir()
temp_path: Optional[Path] = None
reader = await request.multipart()
try:
async for part in reader:
if part.name != "file":
continue
original_filename = part.filename or "backup.zip"
temp_path = backup_dir / f".upload-{secrets.token_urlsafe(12)}.zip.tmp"
size = 0
with temp_path.open("wb") as handle:
while True:
chunk = await part.read_chunk(size=1024 * 1024)
if not chunk:
break
size += len(chunk)
if size > BACKUP_UPLOAD_MAX_BYTES:
raise BackupArchiveError("Backup archive is too large")
handle.write(chunk)
if size <= 0:
raise BackupArchiveError("Uploaded archive is empty")
archive = service.import_uploaded_archive(temp_path, original_filename)
temp_path = None
return archive
finally:
if temp_path is not None and temp_path.exists():
try:
temp_path.unlink()
except OSError:
logger.warning("Failed to remove temporary backup upload %s", temp_path)
raise BackupArchiveError("file field is required")
async def admin_backups_list_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
settings: Settings = request.app["settings"]
try:
service = BackupRestoreService(settings)
archives = service.list_archives()
except OSError as exc:
logger.exception("Failed to list backup archives")
return _error(500, "backup_list_failed", str(exc))
return _ok(
{
"backup_dir": str(service.backup_dir()),
"archives": [_backup_archive_payload(archive) for archive in archives],
}
)
async def admin_backups_upload_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
content_type = (request.headers.get("Content-Type") or "").lower()
if not content_type.startswith("multipart/form-data"):
return _error(400, "invalid_backup_archive", "multipart file upload is required")
try:
archive = await _read_uploaded_backup_file(request)
except BackupArchiveError as exc:
return _error(400, "invalid_backup_archive", str(exc))
except OSError as exc:
logger.exception("Failed to save uploaded backup archive")
return _error(500, "backup_upload_failed", str(exc))
return _ok({"archive": _backup_archive_payload(archive)})
async def admin_backups_create_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
settings: Settings = request.app["settings"]
bot = request.app["bot"]
session_factory = request.app.get("async_session_factory")
worker = BackupWorker(settings, bot, session_factory=session_factory)
ttl_seconds = max(
60,
int(
max(
getattr(settings, "BACKUP_LOCK_TTL_SECONDS", 7200) or 7200,
getattr(settings, "BACKUP_PG_DUMP_TIMEOUT_SECONDS", 1800) or 1800,
)
),
)
try:
async with redis_lock(settings, "backup-worker", ttl_seconds=ttl_seconds) as acquired:
if not acquired:
return _error(409, "backup_create_busy", "Backup or restore is already running")
await worker.refresh_settings()
result = await worker.create_and_send_backup(backup_type="manual")
archive = BackupRestoreService(settings).inspect_archive(result.archive_path)
except BackupArchiveError as exc:
return _error(400, "invalid_backup_archive", str(exc))
except (OSError, RuntimeError, subprocess.SubprocessError, TimeoutError) as exc:
logger.exception("Manual backup creation failed")
return _error(500, "backup_create_failed", str(exc))
except Exception as exc:
logger.exception("Manual backup creation failed")
return _error(500, "backup_create_failed", str(exc))
return _ok(
{
"result": result.to_payload(),
"archive": _backup_archive_payload(archive),
}
)
async def admin_backups_restore_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
settings: Settings = request.app["settings"]
payload = await _read_json(request)
archive_name = str(payload.get("archive_name") or "").strip()
restore_database = bool(payload.get("restore_database"))
restore_compose = bool(payload.get("restore_compose"))
confirm = bool(payload.get("confirm"))
if not confirm:
return _error(400, "restore_confirmation_required")
service = BackupRestoreService(settings)
ttl_seconds = max(
60,
int(
max(
getattr(settings, "BACKUP_LOCK_TTL_SECONDS", 7200) or 7200,
getattr(settings, "BACKUP_PG_RESTORE_TIMEOUT_SECONDS", 1800) or 1800,
)
),
)
try:
async with redis_lock(settings, "backup-worker", ttl_seconds=ttl_seconds) as acquired:
if not acquired:
return _error(409, "backup_restore_busy", "Backup or restore is already running")
result = await service.restore_archive(
archive_name,
restore_database=restore_database,
restore_compose=restore_compose,
)
except BackupArchiveError as exc:
return _error(400, "invalid_backup_archive", str(exc))
except BackupRestoreError as exc:
logger.exception("Backup restore failed")
return _error(500, "backup_restore_failed", str(exc))
except (OSError, subprocess.SubprocessError, TimeoutError) as exc:
logger.exception("Backup restore failed")
return _error(500, "backup_restore_failed", str(exc))
if result.database_restored:
try:
from db import database_setup
if database_setup.async_engine is not None:
await database_setup.async_engine.dispose()
except Exception:
logger.exception("Failed to dispose DB engine after backup restore")
return _ok({"result": result.to_payload()})
+49 -11
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,15 +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(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,
}
@@ -32,6 +32,35 @@ async def admin_payments_list_route(request: web.Request) -> web.Response:
)
async def admin_payment_detail_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
async_session_factory: sessionmaker = request.app["async_session_factory"]
try:
payment_id = int(request.match_info["payment_id"])
except (TypeError, ValueError):
return _error(400, "invalid_payment", "Invalid payment id")
async with async_session_factory() as session:
payment = await payment_dal.get_payment_by_db_id(session, payment_id)
if not payment:
return _error(404, "not_found", "Payment not found")
payload = _serialize_payment(payment)
payload.update(
{
"yookassa_payment_id": payment.yookassa_payment_id,
"idempotence_key": payment.idempotence_key,
"promo_code": (
payment.promo_code_used.code if payment.promo_code_used is not None else None
),
"updated_at": payment.updated_at.isoformat() if payment.updated_at else None,
}
)
return _ok({"payment": payload})
async def admin_payments_export_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
async_session_factory: sessionmaker = request.app["async_session_factory"]
@@ -15,6 +15,10 @@ def setup_admin_routes(app: web.Application) -> None:
router.add_post(
"/api/admin/users/{user_id:-?\\d+}/message/preview", admin_user_message_preview_route
)
router.add_post(
"/api/admin/users/{user_id:-?\\d+}/telegram-profile-link",
admin_user_telegram_profile_link_route,
)
router.add_post("/api/admin/users/{user_id:-?\\d+}/reset-trial", admin_user_reset_trial_route)
router.add_post("/api/admin/users/{user_id:-?\\d+}/extend", admin_user_extend_route)
router.add_post(
@@ -32,6 +36,7 @@ def setup_admin_routes(app: web.Application) -> None:
router.add_delete("/api/admin/users/{user_id:-?\\d+}", admin_user_delete_route)
router.add_get("/api/admin/payments", admin_payments_list_route)
router.add_get("/api/admin/payments/{payment_id:\\d+}", admin_payment_detail_route)
router.add_get("/api/admin/payments/export.csv", admin_payments_export_route)
router.add_get("/api/admin/promos", admin_promos_list_route)
@@ -41,6 +46,16 @@ def setup_admin_routes(app: web.Application) -> None:
router.add_get("/api/admin/logs", admin_logs_route)
router.add_get("/api/admin/support/tickets", admin_support_tickets_route)
router.add_get("/api/admin/support/tickets/{id:\\d+}", admin_support_ticket_detail_route)
router.add_post(
"/api/admin/support/tickets/{id:\\d+}/messages",
admin_support_ticket_reply_route,
)
router.add_patch("/api/admin/support/tickets/{id:\\d+}", admin_support_ticket_patch_route)
router.add_post("/api/admin/support/tickets/{id:\\d+}/read", admin_support_ticket_read_route)
router.add_get("/api/admin/support/stats", admin_support_stats_route)
router.add_post("/api/admin/broadcast", admin_broadcast_route)
router.add_post("/api/admin/sync", admin_sync_route)
@@ -51,6 +66,8 @@ def setup_admin_routes(app: web.Application) -> None:
router.add_get("/api/admin/settings", admin_settings_get_route)
router.add_patch("/api/admin/settings", admin_settings_patch_route)
router.add_get("/api/admin/translations", admin_translations_get_route)
router.add_patch("/api/admin/translations", admin_translations_patch_route)
router.add_get("/api/admin/tariffs", admin_tariffs_get_route)
router.add_put("/api/admin/tariffs", admin_tariffs_save_route)
@@ -58,4 +75,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)
+37 -22
View File
@@ -1,5 +1,11 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
from .webapp_runtime import refresh_webapp_runtime_after_settings_change
from config.subscription_guides_config import (
SubscriptionGuidesConfigError,
subscription_guides_admin_config_json,
)
async def admin_settings_get_route(request: web.Request) -> web.Response:
@@ -13,6 +19,7 @@ async def admin_settings_get_route(request: web.Request) -> web.Response:
overrides_by_key = {entry["key"]: entry for entry in overrides}
fields = manifest_payload()
webhook_base_url = str(settings.WEBHOOK_BASE_URL or "").strip().rstrip("/")
sections: Dict[str, Dict[str, Any]] = {}
for field in fields:
key = field["key"]
@@ -26,14 +33,35 @@ async def admin_settings_get_route(request: web.Request) -> web.Response:
override = overrides_by_key.get(key)
value = current_value(settings, key)
is_secret = bool(field.get("secret"))
overridden = bool(override)
source = None
read_error = None
if key == "SUBSCRIPTION_PAGE_CONFIG_JSON":
try:
value, source = subscription_guides_admin_config_json(settings)
overridden = source == "admin_json"
except SubscriptionGuidesConfigError as exc:
read_error = str(exc)
response_field = {
**field,
"value": "" if is_secret else value,
"overridden": bool(override),
"overridden": overridden,
"updated_at": override.get("updated_at") if override else None,
}
if source:
response_field["source"] = source
if read_error:
response_field["read_error"] = read_error
if is_secret:
response_field["has_value"] = bool(value)
webhook_path = str(response_field.get("webhook_path") or "").strip()
if webhook_path:
if not webhook_path.startswith("/"):
webhook_path = f"/{webhook_path}"
response_field["webhook_path"] = webhook_path
response_field["webhook_base_url_configured"] = bool(webhook_base_url)
if webhook_base_url:
response_field["webhook_url"] = f"{webhook_base_url}{webhook_path}"
sections[section_id]["fields"].append(response_field)
ordered_sections = sorted(sections.values(), key=lambda s: s["order"])
@@ -51,6 +79,13 @@ async def admin_settings_patch_route(request: web.Request) -> web.Response:
return _error(400, "invalid_updates")
if not isinstance(deletes, list):
return _error(400, "invalid_deletes")
if (
"SUBSCRIPTION_PAGE_CONFIG_JSON" in updates
and not str(updates.get("SUBSCRIPTION_PAGE_CONFIG_JSON") or "").strip()
):
updates = dict(updates)
updates.pop("SUBSCRIPTION_PAGE_CONFIG_JSON", None)
deletes = [*deletes, "SUBSCRIPTION_PAGE_CONFIG_JSON"]
result = await update_overrides(
settings,
@@ -65,26 +100,6 @@ async def admin_settings_patch_route(request: web.Request) -> web.Response:
status=400,
)
# Bust the public webapp settings cache so users see new values immediately.
cache = request.app.get("webapp_settings_cache")
if isinstance(cache, dict):
cache["ts"] = 0.0
cache["data"] = {}
if (
"WEBAPP_LOGO_URL" in updates
or "WEBAPP_LOGO_URL" in deletes
or "WEBAPP_LOGO_USE_EMOJI" in updates
or "WEBAPP_LOGO_USE_EMOJI" in deletes
or "WEBAPP_FAVICON_URL" in updates
or "WEBAPP_FAVICON_URL" in deletes
or "WEBAPP_FAVICON_USE_CUSTOM" in updates
or "WEBAPP_FAVICON_USE_CUSTOM" in deletes
or "WEBAPP_LOGO_FAVICON_URL" in updates
or "WEBAPP_LOGO_FAVICON_URL" in deletes
):
request.app["webapp_logo_cache"] = None
from bot.app.web.admin_api_impl.themes import prune_unused_appearance_assets
prune_unused_appearance_assets(settings)
await refresh_webapp_runtime_after_settings_change(request, updates=updates, deletes=deletes)
return _ok({"applied": result.get("applied", 0), "reverted": result.get("reverted", 0)})
+117 -38
View File
@@ -1,5 +1,13 @@
# ruff: noqa: F401,F403,F405,I001
import asyncio
from ._runtime import * # noqa: F403,F405
from .auth import _require_admin_user_id
from .common import _ok, _serialize_payment
from bot.utils.ttl_cache import AsyncTTLCache
_ADMIN_PANEL_STATS_CACHES: Dict[tuple[int, int], AsyncTTLCache] = {}
_ADMIN_DB_STATS_CACHES: Dict[tuple[int, int], AsyncTTLCache] = {}
async def admin_me_route(request: web.Request) -> web.Response:
@@ -13,13 +21,44 @@ async def admin_stats_route(request: web.Request) -> web.Response:
settings: Settings = request.app["settings"]
async_session_factory: sessionmaker = request.app["async_session_factory"]
payload = dict(await _load_admin_db_stats(settings, async_session_factory))
panel_service = request.app.get("panel_service")
if panel_service is not None:
payload["panel"] = await _load_admin_panel_stats(request, settings, panel_service)
queue_manager = get_queue_manager()
if queue_manager:
try:
payload["queue"] = queue_manager.get_queue_stats()
except Exception: # pragma: no cover - defensive
payload["queue"] = None
payload["currency_symbol"] = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
return _ok(payload)
async def _load_admin_db_stats(
settings: Settings,
async_session_factory: sessionmaker,
) -> Dict[str, Any]:
cache = _admin_db_stats_cache(settings)
if cache is None:
return await _load_admin_db_stats_uncached(async_session_factory)
return await cache.get_or_load(
"db",
lambda: _load_admin_db_stats_uncached(async_session_factory),
)
async def _load_admin_db_stats_uncached(async_session_factory: sessionmaker) -> Dict[str, Any]:
async with async_session_factory() as session:
user_stats = await user_dal.get_enhanced_user_statistics(session)
financial_stats = await payment_dal.get_financial_statistics(session)
sync_status = await panel_sync_dal.get_panel_sync_status(session)
recent_payments = await payment_dal.get_recent_payment_logs_with_user(session, limit=10)
payload = {
return {
"users": user_stats,
"financial": financial_stats,
"panel_sync": {
@@ -34,36 +73,78 @@ async def admin_stats_route(request: web.Request) -> web.Response:
"recent_payments": [_serialize_payment(p) for p in recent_payments],
}
panel_service = request.app.get("panel_service")
if panel_service is not None:
try:
system = await panel_service.get_system_stats()
bandwidth = await panel_service.get_bandwidth_stats()
panel_body: Dict[str, Any] = {
"system": system or {},
"bandwidth": bandwidth or {},
}
try:
nodes = await panel_service.get_nodes_statistics()
panel_body["nodes"] = nodes or {}
except Exception as exc_nodes: # pragma: no cover - optional endpoint
logger.debug("Panel nodes stats unavailable: %s", exc_nodes)
panel_body["nodes"] = {}
try:
today = datetime.now(timezone.utc).date()
start_d = today - timedelta(days=7)
nodes_bw = await panel_service.get_nodes_bandwidth_usage(
def _admin_db_stats_cache(settings: Settings) -> Optional[AsyncTTLCache]:
ttl_seconds = int(getattr(settings, "ADMIN_DB_STATS_CACHE_TTL_SECONDS", 5) or 0)
if ttl_seconds <= 0:
return None
cache_key = (id(settings), ttl_seconds)
cache = _ADMIN_DB_STATS_CACHES.get(cache_key)
if cache is None:
cache = AsyncTTLCache(
ttl_seconds=ttl_seconds,
settings=settings,
namespace="admin:db_stats",
)
_ADMIN_DB_STATS_CACHES[cache_key] = cache
return cache
async def _load_admin_panel_stats(
request: web.Request,
settings: Settings,
panel_service,
) -> Dict[str, Any]:
cache = _admin_panel_stats_cache(settings)
if cache is None:
return await _load_admin_panel_stats_uncached(panel_service)
return await cache.get_or_load("panel", lambda: _load_admin_panel_stats_uncached(panel_service))
def _admin_panel_stats_cache(settings: Settings) -> Optional[AsyncTTLCache]:
ttl_seconds = int(getattr(settings, "ADMIN_PANEL_STATS_CACHE_TTL_SECONDS", 15) or 0)
if ttl_seconds <= 0:
return None
cache_key = (id(settings), ttl_seconds)
cache = _ADMIN_PANEL_STATS_CACHES.get(cache_key)
if cache is None:
cache = AsyncTTLCache(
ttl_seconds=ttl_seconds,
settings=settings,
namespace="admin:panel_stats",
)
_ADMIN_PANEL_STATS_CACHES[cache_key] = cache
return cache
async def _load_admin_panel_stats_uncached(panel_service) -> Dict[str, Any]:
try:
today = datetime.now(timezone.utc).date()
start_d = today - timedelta(days=7)
system, bandwidth, nodes, nodes_bw, lookups = await asyncio.gather(
_safe_panel_call(panel_service.get_system_stats(), "system stats"),
_safe_panel_call(panel_service.get_bandwidth_stats(), "bandwidth stats"),
_safe_panel_call(panel_service.get_nodes_statistics(), "nodes stats"),
_safe_panel_call(
panel_service.get_nodes_bandwidth_usage(
start=start_d.isoformat(),
end=today.isoformat(),
top_nodes_limit=64,
)
panel_body["nodes_bandwidth"] = nodes_bw or {}
except Exception as exc_nb: # pragma: no cover - optional endpoint
logger.debug("Panel nodes bandwidth range unavailable: %s", exc_nb)
panel_body["nodes_bandwidth"] = {}
),
"nodes bandwidth range",
),
_safe_panel_call(panel_service.get_nodes_online_lookups(), "nodes online lookups"),
)
panel_body: Dict[str, Any] = {
"system": system or {},
"bandwidth": bandwidth or {},
"nodes": nodes or {},
"nodes_bandwidth": nodes_bw or {},
}
if isinstance(lookups, dict):
try:
online_map = _panel_nodes_online_by_uuid(panel_body.get("nodes"))
lookups = await panel_service.get_nodes_online_lookups()
for k, v in lookups.get("byUuid", {}).items():
online_map[k] = v
_enrich_bandwidth_nodes_with_online(
@@ -73,17 +154,15 @@ async def admin_stats_route(request: web.Request) -> web.Response:
)
except Exception as exc_merge: # pragma: no cover
logger.debug("Panel nodes online merge skipped: %s", exc_merge)
payload["panel"] = panel_body
except Exception as exc:
logger.debug("Panel stats unavailable: %s", exc)
payload["panel"] = {"error": "unavailable"}
return panel_body
except Exception as exc:
logger.debug("Panel stats unavailable: %s", exc)
return {"error": "unavailable"}
queue_manager = get_queue_manager()
if queue_manager:
try:
payload["queue"] = queue_manager.get_queue_stats()
except Exception: # pragma: no cover - defensive
payload["queue"] = None
payload["currency_symbol"] = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
return _ok(payload)
async def _safe_panel_call(awaitable, label: str) -> Any:
try:
return await awaitable
except Exception as exc: # pragma: no cover - optional panel endpoints
logger.debug("Panel %s unavailable: %s", label, exc)
return None
@@ -0,0 +1,251 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
from typing import Literal, Optional
from pydantic import BaseModel, ConfigDict, constr, field_validator
from bot.services.support_service import TicketNotFound
from db.dal import support_dal, user_dal
from db.models import SupportTicket, SupportTicketMessage
class AdminTicketReplyPayload(BaseModel):
model_config = ConfigDict(extra="ignore")
body: constr(min_length=1, max_length=4000)
is_internal_note: bool = False
@field_validator("body")
@classmethod
def _strip_body(cls, value: str) -> str:
stripped = value.strip()
if not stripped:
raise ValueError("empty_text")
return stripped
class AdminTicketPatchPayload(BaseModel):
model_config = ConfigDict(extra="ignore")
status: Optional[Literal["open", "awaiting_user", "awaiting_admin", "resolved", "closed"]] = (
None
)
priority: Optional[Literal["low", "normal", "high", "urgent"]] = None
category: Optional[Literal["billing", "technical", "account", "other"]] = None
assigned_admin_id: Optional[int] = None
def _validate_model_payload(model_cls, payload: Dict[str, Any]):
try:
return model_cls.model_validate(payload), None
except ValidationError:
return None, _error(400, "invalid_request", "Invalid request")
def _support_ticket_payload(ticket: SupportTicket) -> Dict[str, Any]:
return {
"ticket_id": ticket.ticket_id,
"user_id": ticket.user_id,
"subject": ticket.subject,
"category": ticket.category,
"priority": ticket.priority,
"status": ticket.status,
"assigned_admin_id": ticket.assigned_admin_id,
"last_message_at": ticket.last_message_at.isoformat() if ticket.last_message_at else None,
"last_message_role": ticket.last_message_role,
"unread_user_count": int(ticket.unread_user_count or 0),
"unread_admin_count": int(ticket.unread_admin_count or 0),
"created_at": ticket.created_at.isoformat() if ticket.created_at else None,
"updated_at": ticket.updated_at.isoformat() if ticket.updated_at else None,
"closed_at": ticket.closed_at.isoformat() if ticket.closed_at else None,
}
def _user_display_name(user) -> Optional[str]:
if not user:
return None
name = " ".join(
part.strip() for part in [user.first_name, user.last_name] if part and part.strip()
).strip()
return name or user.username or user.email or str(user.user_id)
def _support_message_payload(
message: SupportTicketMessage,
*,
authors: Optional[Dict[int, Any]] = None,
) -> Dict[str, Any]:
author = authors.get(message.author_user_id) if authors and message.author_user_id else None
return {
"message_id": message.message_id,
"ticket_id": message.ticket_id,
"author_role": message.author_role,
"author_user_id": message.author_user_id,
"author_name": _user_display_name(author),
"body": message.body,
"is_internal_note": bool(message.is_internal_note),
"created_at": message.created_at.isoformat() if message.created_at else None,
"read_by_user_at": message.read_by_user_at.isoformat() if message.read_by_user_at else None,
"read_by_admin_at": message.read_by_admin_at.isoformat()
if message.read_by_admin_at
else None,
}
def _admin_support_user_payload(user) -> Dict[str, Any]:
if not user:
return {}
return {
"user_id": user.user_id,
"telegram_id": user.telegram_id,
"username": user.username,
"first_name": user.first_name,
"last_name": user.last_name,
"email": user.email,
"telegram_photo_url": user.telegram_photo_url,
"is_banned": bool(user.is_banned),
"registration_date": user.registration_date.isoformat() if user.registration_date else None,
}
def _support_limit_offset(request: web.Request) -> tuple[int, int]:
limit = max(1, min(100, int(request.query.get("limit", 25) or 25)))
offset = max(0, int(request.query.get("offset", 0) or 0))
return limit, offset
async def admin_support_tickets_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
limit, offset = _support_limit_offset(request)
assigned_raw = request.query.get("assigned")
assigned_admin_id = None
if assigned_raw and assigned_raw not in {"all", "any"}:
assigned_admin_id = int(assigned_raw)
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
tickets = await support_dal.list_admin_tickets(
session,
status=request.query.get("status") or None,
priority=request.query.get("priority") or None,
category=request.query.get("category") or None,
assigned_admin_id=assigned_admin_id,
search=request.query.get("search") or None,
sort=request.query.get("sort") or "updated_desc",
limit=limit,
offset=offset,
)
return web.json_response(
{
"ok": True,
"tickets": [
{
**_support_ticket_payload(ticket),
"user": _admin_support_user_payload(getattr(ticket, "user", None)),
}
for ticket in tickets
],
}
)
async def admin_support_ticket_detail_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
ticket_id = int(request.match_info["id"])
async_session_factory: sessionmaker = request.app["async_session_factory"]
service = request.app["support_service"]
async with async_session_factory() as session:
ticket, messages = await support_dal.get_ticket(session, ticket_id, include_internal=True)
if not ticket:
return _error(404, "not_found", "Ticket not found")
user = await user_dal.get_user_by_id(session, ticket.user_id)
snapshot = await service.build_user_snapshot(user, session=session) if user else {}
author_ids = {m.author_user_id for m in messages if m.author_user_id is not None}
authors = {}
for author_id in author_ids:
author = await user_dal.get_user_by_id(session, author_id)
if author:
authors[author_id] = author
return web.json_response(
{
"ok": True,
"ticket": {
**_support_ticket_payload(ticket),
"user": _admin_support_user_payload(user),
},
"messages": [_support_message_payload(m, authors=authors) for m in messages],
"user_snapshot": snapshot,
}
)
async def admin_support_ticket_reply_route(request: web.Request) -> web.Response:
admin_id = _require_admin_user_id(request)
ticket_id = int(request.match_info["id"])
payload, error = _validate_model_payload(AdminTicketReplyPayload, await _read_json(request))
if error:
return error
try:
ticket, message = await request.app["support_service"].reply_as_admin(
admin_id,
ticket_id,
payload.body,
is_internal_note=payload.is_internal_note,
)
except TicketNotFound:
return _error(404, "not_found", "Ticket not found")
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
admin = await user_dal.get_user_by_id(session, admin_id)
return web.json_response(
{
"ok": True,
"ticket": _support_ticket_payload(ticket),
"message": _support_message_payload(
message, authors={admin_id: admin} if admin else {}
),
}
)
async def admin_support_ticket_patch_route(request: web.Request) -> web.Response:
admin_id = _require_admin_user_id(request)
ticket_id = int(request.match_info["id"])
payload, error = _validate_model_payload(AdminTicketPatchPayload, await _read_json(request))
if error:
return error
updates = payload.model_dump(exclude_unset=True)
try:
if updates.get("status") == "closed":
ticket = await request.app["support_service"].close_ticket(admin_id, ticket_id)
updates.pop("status", None)
if updates:
ticket = await request.app["support_service"]._update_and_audit(
admin_id,
ticket_id,
**updates,
)
else:
ticket = await request.app["support_service"]._update_and_audit(
admin_id,
ticket_id,
**updates,
)
except TicketNotFound:
return _error(404, "not_found", "Ticket not found")
return web.json_response({"ok": True, "ticket": _support_ticket_payload(ticket)})
async def admin_support_ticket_read_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
ticket_id = int(request.match_info["id"])
await request.app["support_service"].mark_read_as_admin(ticket_id)
return web.json_response({"ok": True})
async def admin_support_stats_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
stats = await support_dal.admin_stats(session)
return web.json_response({"ok": True, "stats": stats})
@@ -1,5 +1,6 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
from .webapp_runtime import refresh_webapp_runtime_after_settings_change
async def admin_tariffs_get_route(request: web.Request) -> web.Response:
@@ -55,9 +56,6 @@ async def admin_tariffs_save_route(request: web.Request) -> web.Response:
logger.exception("Failed to write tariffs config to %s", path)
return _error(500, "write_failed", str(exc))
cache = request.app.get("webapp_settings_cache")
if isinstance(cache, dict):
cache["ts"] = 0.0
cache["data"] = {}
await refresh_webapp_runtime_after_settings_change(request, updates={}, deletes=[])
return _ok({"exists": True, "path": str(path), "catalog": _tariffs_config_payload(config)})
+47 -10
View File
@@ -1,5 +1,6 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
from .webapp_runtime import refresh_webapp_runtime_after_settings_change
import asyncio
import hashlib
@@ -37,6 +38,44 @@ WEBAPP_LOGO_UPLOAD_CONTENT_TYPES = {
}
def _theme_payload_for_version_compare(theme: Any) -> Dict[str, Any]:
if hasattr(theme, "model_dump"):
data = theme.model_dump(mode="json", exclude_none=True)
elif isinstance(theme, dict):
data = dict(theme)
else:
data = {}
data.pop("assets_version", None)
data.pop("default", None)
return data
def _bump_theme_asset_versions(
config: WebappThemesConfig,
previous: WebappThemesConfig,
) -> WebappThemesConfig:
previous_by_key = {theme.key: theme for theme in previous.themes}
default_changed = config.default_theme != previous.default_theme
data = config.model_dump(mode="json", exclude_none=True)
for theme in data.get("themes", []):
if not isinstance(theme, dict):
continue
if not str(theme.get("css_file") or "").strip():
continue
key = str(theme.get("key") or "")
previous_theme = previous_by_key.get(key)
previous_version = int(getattr(previous_theme, "assets_version", 0) or 0)
current_version = int(theme.get("assets_version") or 1)
theme_changed = previous_theme is None or _theme_payload_for_version_compare(
theme
) != _theme_payload_for_version_compare(previous_theme)
if theme_changed or (default_changed and key == config.default_theme):
theme["assets_version"] = max(previous_version + 1, current_version, 1)
elif previous_version > current_version:
theme["assets_version"] = previous_version
return WebappThemesConfig.model_validate(data)
def _detect_logo_extension(
body: bytes, content_type: str = "", filename: str = ""
) -> Optional[str]:
@@ -173,12 +212,7 @@ async def _persist_appearance_upload(
logger.warning("Failed to persist uploaded appearance asset settings: %s", result)
return False
cache = request.app.get("webapp_settings_cache")
if isinstance(cache, dict):
cache["ts"] = 0.0
cache["data"] = {}
request.app["webapp_logo_cache"] = None
prune_unused_appearance_assets(settings)
await refresh_webapp_runtime_after_settings_change(request, updates=updates, deletes=[])
return True
@@ -420,6 +454,11 @@ async def admin_themes_get_route(request: web.Request) -> web.Response:
async def admin_themes_save_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
settings: Settings = request.app["settings"]
previous_config = resolved_webapp_themes_catalog(
primary_accent=settings.WEBAPP_PRIMARY_COLOR or "#00fe7a",
env_default_theme=settings.WEBAPP_DEFAULT_THEME,
theme_dir=settings.WEBAPP_THEMES_DIR,
)
payload = await _read_json(request)
catalog = payload.get("catalog") if "catalog" in payload else payload
if not isinstance(catalog, dict):
@@ -431,6 +470,7 @@ async def admin_themes_save_route(request: web.Request) -> web.Response:
return _error(400, "invalid_webapp_themes_config", str(exc))
config, _changed = ensure_webapp_core_themes(config, settings.WEBAPP_PRIMARY_COLOR or "#00fe7a")
config = _bump_theme_asset_versions(config, previous_config)
try:
write_webapp_theme_dir(settings.WEBAPP_THEMES_DIR, config, delete_missing=True)
@@ -438,10 +478,7 @@ async def admin_themes_save_route(request: web.Request) -> web.Response:
logger.exception("Failed to write webapp themes to %s", settings.WEBAPP_THEMES_DIR)
return _error(500, "write_failed", str(exc))
cache = request.app.get("webapp_settings_cache")
if isinstance(cache, dict):
cache["ts"] = 0.0
cache["data"] = {}
await refresh_webapp_runtime_after_settings_change(request, updates={}, deletes=[])
return _ok(
{
@@ -0,0 +1,146 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
from bot.middlewares.i18n import JsonI18n, locale_language_options, resolve_locale_key
from bot.services.locale_override_service import (
LOCALE_OVERRIDES_PATH,
audience_for_locale_key,
group_id_for_locale_key,
locale_group_catalog,
load_locale_overrides,
update_locale_overrides,
)
def _locale_languages(
i18n: JsonI18n,
overrides: Optional[List[Dict[str, Any]]] = None,
) -> List[Dict[str, Any]]:
base_languages = set((i18n.base_locales_data or {}).keys())
override_languages = {str(entry.get("lang") or "") for entry in overrides or []}
override_languages.update((i18n.locale_overrides or {}).keys())
return locale_language_options(
base_languages | override_languages,
base_languages=base_languages,
)
def _locale_override_meta_map(overrides: List[Dict[str, Any]]) -> Dict[Tuple[str, str], Dict]:
result: Dict[Tuple[str, str], Dict] = {}
for entry in overrides:
lang = str(entry.get("lang") or "")
raw_key = str(entry.get("key") or "")
key = resolve_locale_key(raw_key)
if lang and key:
if raw_key != key and (lang, key) in result:
continue
result[(lang, key)] = entry
return result
def _admin_translations_payload(
i18n: JsonI18n,
overrides: List[Dict[str, Any]],
) -> Dict[str, Any]:
base_data = i18n.base_locales_data or i18n.locales_data or {}
effective_data = i18n.locales_data or {}
override_meta = _locale_override_meta_map(overrides)
language_items = _locale_languages(i18n, overrides)
languages = [item["code"] for item in language_items]
all_keys = sorted(
{key for messages in base_data.values() for key in messages.keys()}
| {key for _, key in override_meta.keys()}
)
groups_by_id = {
group["id"]: {
**group,
"items": [],
}
for group in locale_group_catalog()
}
for key in all_keys:
values: Dict[str, Dict[str, Any]] = {}
for lang in languages:
meta = override_meta.get((lang, key))
fallback_base = base_data.get(i18n.default_lang, {}).get(key, "")
values[lang] = {
"base": base_data.get(lang, {}).get(key, ""),
"fallback": fallback_base,
"effective": effective_data.get(lang, {}).get(key, ""),
"override": meta.get("value") if meta else "",
"overridden": bool(meta),
"updated_at": meta.get("updated_at") if meta else None,
"updated_by": meta.get("updated_by") if meta else None,
}
group_id = group_id_for_locale_key(key)
groups_by_id.setdefault(
group_id,
{"id": group_id, "title": group_id, "description": "", "items": []},
)
groups_by_id[group_id]["items"].append(
{
"key": key,
"audience": audience_for_locale_key(key),
"values": values,
}
)
groups = [group for group in groups_by_id.values() if group["items"]]
return {
"languages": language_items,
"groups": groups,
"path": str(LOCALE_OVERRIDES_PATH),
"override_count": len(overrides),
}
async def admin_translations_get_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
i18n: Optional[JsonI18n] = request.app.get("i18n")
if i18n is None:
return _error(503, "i18n_unavailable")
async_session_factory: sessionmaker = request.app["async_session_factory"]
await load_locale_overrides(i18n, async_session_factory)
async with async_session_factory() as session:
overrides = await locale_overrides_dal.get_overrides_with_meta(session)
return _ok(_admin_translations_payload(i18n, overrides))
async def admin_translations_patch_route(request: web.Request) -> web.Response:
actor_id = _require_admin_user_id(request)
i18n: Optional[JsonI18n] = request.app.get("i18n")
if i18n is None:
return _error(503, "i18n_unavailable")
async_session_factory: sessionmaker = request.app["async_session_factory"]
payload = await _read_json(request)
updates = payload.get("updates") or {}
deletes = payload.get("deletes") or []
if not isinstance(updates, dict):
return _error(400, "invalid_updates")
if not isinstance(deletes, list):
return _error(400, "invalid_deletes")
result = await update_locale_overrides(
i18n,
async_session_factory,
updates=updates,
deletes=deletes,
actor_id=actor_id,
)
if not result.get("ok"):
return web.json_response(
{"ok": False, "error": "validation_failed", "errors": result.get("errors", {})},
status=400,
)
return _ok(
{
"applied": result.get("applied", 0),
"reverted": result.get("reverted", 0),
"file_written": result.get("file_written", False),
}
)
+314 -15
View File
@@ -1,9 +1,32 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
from .auth import _require_admin_user_id
from .common import (
_build_admin_webapp_referral_link,
_error,
_ok,
_premium_traffic_list_payload,
_read_json,
_serialize_payment,
_serialize_subscription,
_serialize_user,
)
import hashlib
from html import escape as html_escape
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from bot.app.web.webapp.cache_helpers import invalidate_webapp_user_caches
from bot.infra.redis import cache_delete_pattern, redis_key
from bot.utils.ttl_cache import AsyncTTLCache
_ADMIN_USERS_LIST_CACHES: Dict[tuple[int, int], AsyncTTLCache] = {}
async def admin_users_list_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
settings: Settings = request.app["settings"]
async_session_factory: sessionmaker = request.app["async_session_factory"]
page = max(0, int(request.query.get("page", 0) or 0))
@@ -14,6 +37,79 @@ async def admin_users_list_route(request: web.Request) -> web.Response:
premium_traffic = (request.query.get("premium_traffic") or "all").lower()
sort_value = (request.query.get("sort") or "registered_desc").lower()
payload = await _load_admin_users_list_payload(
settings,
async_session_factory,
page=page,
page_size=page_size,
query=query,
filter_value=filter_value,
panel_status=panel_status,
premium_traffic=premium_traffic,
sort_value=sort_value,
)
return _ok(payload)
async def _load_admin_users_list_payload(
settings: Settings,
async_session_factory: sessionmaker,
*,
page: int,
page_size: int,
query: str,
filter_value: str,
panel_status: str,
premium_traffic: str,
sort_value: str,
) -> Dict[str, Any]:
cache = _admin_users_list_cache(settings)
cache_key = _admin_users_list_cache_key(
page=page,
page_size=page_size,
query=query,
filter_value=filter_value,
panel_status=panel_status,
premium_traffic=premium_traffic,
sort_value=sort_value,
)
if cache is None:
return await _load_admin_users_list_payload_uncached(
async_session_factory,
page=page,
page_size=page_size,
query=query,
filter_value=filter_value,
panel_status=panel_status,
premium_traffic=premium_traffic,
sort_value=sort_value,
)
return await cache.get_or_load(
cache_key,
lambda: _load_admin_users_list_payload_uncached(
async_session_factory,
page=page,
page_size=page_size,
query=query,
filter_value=filter_value,
panel_status=panel_status,
premium_traffic=premium_traffic,
sort_value=sort_value,
),
)
async def _load_admin_users_list_payload_uncached(
async_session_factory: sessionmaker,
*,
page: int,
page_size: int,
query: str,
filter_value: str,
panel_status: str,
premium_traffic: str,
sort_value: str,
) -> Dict[str, Any]:
async with async_session_factory() as session:
users, total = await _filter_and_sort_users(
session,
@@ -47,14 +143,58 @@ async def admin_users_list_route(request: web.Request) -> web.Response:
payload["premium_traffic"] = _premium_traffic_list_payload(active_subs.get(user.user_id))
serialized.append(payload)
return _ok(
{
"users": serialized,
"page": page,
"page_size": page_size,
"total": total,
}
)
return {
"users": serialized,
"page": page,
"page_size": page_size,
"total": total,
}
def _admin_users_list_cache(settings: Settings) -> Optional[AsyncTTLCache]:
ttl_seconds = int(getattr(settings, "ADMIN_USERS_LIST_CACHE_TTL_SECONDS", 3) or 0)
if ttl_seconds <= 0:
return None
cache_key = (id(settings), ttl_seconds)
cache = _ADMIN_USERS_LIST_CACHES.get(cache_key)
if cache is None:
cache = AsyncTTLCache(
ttl_seconds=ttl_seconds,
settings=settings,
namespace="admin:users_list",
)
_ADMIN_USERS_LIST_CACHES[cache_key] = cache
return cache
def _admin_users_list_cache_key(**params: Any) -> str:
raw = json.dumps(params, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
async def _invalidate_admin_users_list_cache(settings: Settings) -> None:
for settings_id, _ttl in tuple(_ADMIN_USERS_LIST_CACHES):
if settings_id == id(settings):
_ADMIN_USERS_LIST_CACHES[(settings_id, _ttl)].invalidate()
try:
await cache_delete_pattern(settings, redis_key(settings, "cache", "admin:users_list", "*"))
except Exception:
return
async def _invalidate_after_admin_user_mutation(
settings: Settings,
user_id: Optional[int] = None,
*,
include_devices: bool = True,
) -> None:
await _invalidate_admin_users_list_cache(settings)
if user_id is not None:
await invalidate_webapp_user_caches(
settings,
user_id,
include_devices=include_devices,
)
async def _bulk_user_statuses(
@@ -526,6 +666,7 @@ async def admin_user_ban_route(request: web.Request) -> web.Response:
payload = await _read_json(request)
desired = bool(payload.get("banned"))
settings: Settings = request.app["settings"]
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)
@@ -534,6 +675,7 @@ async def admin_user_ban_route(request: web.Request) -> web.Response:
user.is_banned = bool(desired)
await session.commit()
await session.refresh(user)
await _invalidate_after_admin_user_mutation(settings, target_id)
return _ok({"user": _serialize_user(user)})
@@ -624,32 +766,180 @@ async def admin_user_message_preview_route(request: web.Request) -> web.Response
return _ok({})
async def admin_user_delete_route(request: web.Request) -> web.Response:
actor_id = _require_admin_user_id(request)
target_id = int(request.match_info["user_id"])
def _admin_user_display_name_for_message(user: User) -> str:
full = " ".join(
part
for part in [getattr(user, "first_name", None), getattr(user, "last_name", None)]
if part
).strip()
return (
full
or (f"@{user.username}" if getattr(user, "username", None) else None)
or getattr(user, "email", None)
or f"User #{user.user_id}"
)
async def admin_user_telegram_profile_link_route(request: web.Request) -> web.Response:
actor_id = _require_admin_user_id(request)
admin_telegram_id = request.get("admin_telegram_id")
if not admin_telegram_id:
return _error(403, "admin_telegram_unavailable")
queue_manager = get_queue_manager()
if not queue_manager:
return _error(503, "queue_unavailable")
target_id = int(request.match_info["user_id"])
settings: Settings = request.app["settings"]
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
ok = await user_dal.delete_user_and_relations(session, target_id)
if not ok:
await session.rollback()
target_user = await user_dal.get_user_by_id(session, target_id)
if not target_user:
return _error(404, "not_found")
if not target_user.telegram_id:
return _error(404, "no_telegram_account")
admin_user = await user_dal.get_user_by_id(session, actor_id)
lang = (
getattr(admin_user, "language_code", None)
or getattr(settings, "DEFAULT_LANGUAGE", None)
or "ru"
)
await message_log_dal.create_message_log(
session,
{
"user_id": actor_id,
"event_type": "admin_profile_link_webapp",
"content": f"Requested Telegram profile link for user_id={target_id}",
"is_admin_event": True,
"target_user_id": target_id,
},
)
await session.commit()
i18n_instance = request.app.get("i18n")
translate = (
(lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs))
if i18n_instance is not None
else (lambda key, **kwargs: key.format(**kwargs) if kwargs else key)
)
target_name = _admin_user_display_name_for_message(target_user)
telegram_id = int(target_user.telegram_id)
profile_url = f"tg://user?id={telegram_id}"
message_text = translate(
"admin_user_profile_link_message",
name=html_escape(target_name),
user_id=target_user.user_id,
telegram_id=telegram_id,
)
if message_text == "admin_user_profile_link_message":
message_text = (
f"Профиль пользователя: <b>{html_escape(target_name)}</b>\n"
f"User ID: <code>{target_user.user_id}</code>\n"
f"Telegram ID: <code>{telegram_id}</code>\n\n"
"Нажмите кнопку ниже, чтобы открыть профиль в Telegram."
)
button_text = translate("user_card_open_profile_button")
if button_text == "user_card_open_profile_button":
button_text = "👤 Открыть профиль"
markup = InlineKeyboardMarkup(
inline_keyboard=[[InlineKeyboardButton(text=button_text, url=profile_url)]]
)
try:
await send_message_via_queue(
queue_manager,
int(admin_telegram_id),
MessageContent(content_type="text", text=message_text),
parse_mode="HTML",
disable_web_page_preview=True,
reply_markup=markup,
)
except Exception as exc:
logger.warning("Admin profile link message enqueue failed: %s", exc)
return _error(502, "send_failed", str(exc))
return _ok({"queued": True})
async def admin_user_delete_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")
if panel_service is None:
subscription_service = request.app.get("subscription_service")
panel_service = getattr(subscription_service, "panel_service", None)
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")
panel_user_uuids = await user_dal.get_panel_user_uuids_for_user(
session,
target_id,
user=user,
)
if panel_user_uuids and panel_service is None:
await session.rollback()
return _error(503, "panel_service_unavailable")
for panel_uuid in panel_user_uuids:
try:
panel_deleted = await panel_service.delete_user_from_panel(
panel_uuid,
log_response=False,
)
except Exception as exc:
logger.warning(
"Admin webapp failed to delete panel user %s for user %s: %s",
panel_uuid,
target_id,
exc,
)
await session.rollback()
return _error(502, "panel_delete_failed", str(exc))
if not panel_deleted:
await session.rollback()
return _error(
502,
"panel_delete_failed",
f"Failed to delete panel user {panel_uuid}",
)
ok = await user_dal.delete_user_and_relations(session, target_id)
if not ok:
await session.rollback()
return _error(404, "not_found")
await message_log_dal.create_message_log_no_commit(
session,
{
"user_id": actor_id if actor_id != target_id else None,
"event_type": "admin_delete_user_webapp",
"content": f"Deleted user_id={target_id}",
"content": (
f"Deleted user_id={target_id}; "
f"panel_uuids={','.join(panel_user_uuids) or 'none'}"
),
"is_admin_event": True,
},
)
await session.commit()
await _invalidate_after_admin_user_mutation(settings, target_id)
return _ok({})
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:
@@ -676,6 +966,7 @@ async def admin_user_reset_trial_route(request: web.Request) -> web.Response:
},
)
await session.commit()
await _invalidate_after_admin_user_mutation(settings, target_id)
return _ok({})
@@ -683,6 +974,7 @@ async def admin_user_premium_override_route(request: web.Request) -> web.Respons
"""Premium-squad traffic overrides only (unlimited toggle + bonus GB)."""
actor_id = _require_admin_user_id(request)
target_id = int(request.match_info["user_id"])
settings: Settings = request.app["settings"]
payload = await _read_json(request)
subscription_service = request.app.get("subscription_service")
@@ -734,6 +1026,7 @@ async def admin_user_premium_override_route(request: web.Request) -> web.Respons
await session.commit()
await session.refresh(active)
await _invalidate_after_admin_user_mutation(settings, target_id)
return _ok({"subscription": _serialize_subscription(active)})
@@ -741,6 +1034,7 @@ async def admin_user_regular_traffic_override_route(request: web.Request) -> web
"""Main (regular) traffic: unlimited-style ceiling + admin bonus GB."""
actor_id = _require_admin_user_id(request)
target_id = int(request.match_info["user_id"])
settings: Settings = request.app["settings"]
payload = await _read_json(request)
unlimited = bool(payload.get("unlimited"))
@@ -791,6 +1085,7 @@ async def admin_user_regular_traffic_override_route(request: web.Request) -> web
await session.commit()
await session.refresh(active)
await _invalidate_after_admin_user_mutation(settings, target_id)
return _ok({"subscription": _serialize_subscription(active)})
@@ -805,6 +1100,7 @@ async def admin_user_traffic_grant_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"]
payload = await _read_json(request)
kind = str(payload.get("kind") or "regular").strip().lower()
@@ -865,6 +1161,7 @@ async def admin_user_traffic_grant_route(request: web.Request) -> web.Response:
refreshed = await subscription_dal.get_active_subscription_by_user_id(session, target_id)
await _invalidate_after_admin_user_mutation(settings, target_id)
return _ok(
{
"subscription": _serialize_subscription(refreshed) if refreshed else None,
@@ -880,6 +1177,7 @@ async def admin_user_traffic_grant_route(request: web.Request) -> web.Response:
async def admin_user_extend_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"]
payload = await _read_json(request)
try:
days = int(payload.get("days") or 0)
@@ -918,6 +1216,7 @@ async def admin_user_extend_route(request: web.Request) -> web.Response:
refreshed = await subscription_dal.get_active_subscription_by_user_id(session, target_id)
await _invalidate_after_admin_user_mutation(settings, target_id)
return _ok(
{
"subscription": _serialize_subscription(refreshed) if refreshed else None,
@@ -0,0 +1,67 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from typing import Any
from bot.app.web.webapp.cache_helpers import (
invalidate_all_webapp_user_payloads,
reset_subscription_guides_cache,
reset_webapp_settings_cache,
)
WEBAPP_APPEARANCE_SETTING_KEYS = frozenset(
{
"WEBAPP_TITLE",
"WEBAPP_LOGO_URL",
"WEBAPP_LOGO_USE_EMOJI",
"WEBAPP_LOGO_EMOJI",
"WEBAPP_LOGO_EMOJI_FONT",
"WEBAPP_FAVICON_URL",
"WEBAPP_FAVICON_USE_CUSTOM",
"WEBAPP_LOGO_FAVICON_URL",
}
)
WEBAPP_DEVICE_PAYLOAD_SETTING_KEYS = frozenset(
{
"MY_DEVICES_SECTION_ENABLED",
"USER_HWID_DEVICE_LIMIT",
"USER_TRAFFIC_LIMIT_GB",
"USER_TRAFFIC_STRATEGY",
}
)
def changed_setting_keys(
updates: Mapping[str, Any] | None = None,
deletes: Sequence[Any] | None = None,
) -> set[str]:
keys = {str(key) for key in (updates or {}).keys()}
keys.update(str(key) for key in (deletes or []) if key is not None)
return keys
async def refresh_webapp_runtime_after_settings_change(
request: Any,
*,
updates: Mapping[str, Any] | None = None,
deletes: Sequence[Any] | None = None,
include_user_payloads: bool = True,
) -> None:
settings = request.app["settings"]
keys = changed_setting_keys(updates, deletes)
reset_webapp_settings_cache(request.app)
reset_subscription_guides_cache(request.app)
if include_user_payloads:
await invalidate_all_webapp_user_payloads(
settings,
include_devices=bool(keys & WEBAPP_DEVICE_PAYLOAD_SETTING_KEYS),
)
if keys & WEBAPP_APPEARANCE_SETTING_KEYS:
request.app["webapp_logo_cache"] = None
from bot.app.web.admin_api_impl.themes import prune_unused_appearance_assets
prune_unused_appearance_assets(settings)
+524 -257
View File
@@ -8,6 +8,7 @@ the API, even by an admin.
from __future__ import annotations
import re
from dataclasses import dataclass
from typing import Any, List, Optional, Tuple
@@ -15,7 +16,7 @@ from typing import Any, List, Optional, Tuple
@dataclass(frozen=True)
class SettingField:
key: str
type: str # "string" | "int" | "float" | "bool" | "text" | "url" | "color" | "secret"
type: str # "string" | "int" | "float" | "bool" | "text" | "url" | "color" | "icon" | "json"
section: str
label: str
description: str = ""
@@ -28,10 +29,23 @@ class SettingField:
subsection: Optional[str] = None # group label inside a section
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] = [
# ─── General ────────────────────────────────────────────────────
SettingField(
"WEBAPP_TITLE",
"string",
"general",
"Web App title",
placeholder="My subscription",
),
SettingField(
"DEFAULT_LANGUAGE",
"string",
@@ -72,9 +86,55 @@ SETTINGS_MANIFEST: List[SettingField] = [
"Ссылка на канал",
"Имя пользователя или invite-link.",
),
SettingField(
"PANEL_API_URL",
"url",
"remnawave",
"URL API Remnawave",
"Например, https://panel.example.com/api.",
),
SettingField(
"PANEL_API_KEY",
"string",
"remnawave",
"API-ключ Remnawave",
"Секретный ключ API панели.",
secret=True,
),
SettingField(
"PANEL_WEBHOOK_SECRET",
"string",
"remnawave",
"Секрет вебхуков Remnawave",
"Используется для проверки входящих вебхуков панели.",
secret=True,
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",
"remnawave",
"Internal Squads по умолчанию",
"UUID через запятую для legacy-режима без JSON-каталога тарифов.",
),
SettingField(
"USER_EXTERNAL_SQUAD_UUID",
"string",
"remnawave",
"External Squad по умолчанию",
"Необязательный UUID External Squad для новых пользователей.",
),
# ─── Web app appearance ────────────────────────────────────────
SettingField(
"WEBAPP_TITLE", "string", "appearance", "Название Web App", placeholder="Моя подписка"
"SUBSCRIPTION_MINI_APP_URL",
"url",
"appearance",
"Публичный URL Mini App",
"Например, https://app.example.com/.",
),
SettingField(
"WEBAPP_PRIMARY_COLOR", "color", "appearance", "Основной цвет", placeholder="#00fe7a"
@@ -109,6 +169,59 @@ SETTINGS_MANIFEST: List[SettingField] = [
SettingField("WEBAPP_FAVICON_URL", "url", "appearance", "URL отдельной favicon"),
SettingField("WEBAPP_LOGO_FAVICON_URL", "url", "appearance", "Favicon из логотипа"),
SettingField("WEBAPP_ENABLED", "bool", "appearance", "Web App включён"),
SettingField(
"SUBSCRIPTION_GUIDES_ENABLED",
"bool",
"subscription_guides",
"Embedded install guides",
"Open install instructions inside the Web App instead of an external connect page.",
),
SettingField(
"SUBSCRIPTION_GUIDES_BOT_MENU_ENABLED",
"bool",
"subscription_guides",
"Open install guides from bot",
(
"Use the Telegram Mini App install screen for bot connect buttons and show "
"public install guide links."
),
),
SettingField(
"SUBSCRIPTION_PAGE_CONFIG_PANEL_ENABLED",
"bool",
"subscription_guides",
"Use Remnawave Panel config",
(
"Fetch Subscription Page config from Remnawave Panel by the user's "
"subscription short UUID."
),
),
SettingField(
"SUBSCRIPTION_PAGE_CONFIG_JSON_OVERRIDE_ENABLED",
"bool",
"subscription_guides",
"Enable admin JSON override",
"Use the JSON field below instead of Remnawave Panel config. Disabled by default.",
),
SettingField(
"SUBSCRIPTION_PAGE_CONFIG_PATH",
"string",
"subscription_guides",
"Subscription Page config path",
"Fallback path to a Remnawave Subscription Page v1 JSON config file.",
placeholder="data/subpage-config/multiapp.json",
),
SettingField(
"SUBSCRIPTION_PAGE_CONFIG_JSON",
"json",
"subscription_guides",
"Subscription Page config JSON",
(
"Optional admin JSON override. It is applied only when the JSON override "
"switch is enabled."
),
placeholder='{\n "version": "1"\n}',
),
# ─── Subscription periods & pricing ────────────────────────────
SettingField("MONTH_1_ENABLED", "bool", "pricing", "Тариф 1 месяц"),
SettingField("MONTH_3_ENABLED", "bool", "pricing", "Тариф 3 месяца"),
@@ -122,208 +235,165 @@ 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 (ГБ:цена)"
),
SettingField("STARS_TRAFFIC_PACKAGES", "string", "pricing", "Пакеты трафика (Stars)"),
SettingField(
"PAYMENT_METHODS_ORDER",
"string",
"pricing",
"Порядок методов оплаты",
"Через запятую, например: severpay,freekassa,yookassa",
"SUBSCRIPTION_PURCHASE_DESCRIPTION_ENABLED",
"bool",
"payments",
"Показывать описание подписки",
"Текст появится перед выбором срока покупки или продления.",
subsection="checkout",
),
SettingField(
"SUBSCRIPTION_PURCHASE_DESCRIPTION_RU",
"text",
"payments",
"Описание подписки (RU)",
"Русская версия текста на этапе оплаты.",
subsection="checkout",
),
SettingField(
"SUBSCRIPTION_PURCHASE_DESCRIPTION_EN",
"text",
"payments",
"Описание подписки (EN)",
"Английская версия текста на этапе оплаты.",
subsection="checkout",
),
# ─── Payment providers (toggles) ───────────────────────────────
# Common
SettingField("STARS_ENABLED", "bool", "payments", "Telegram Stars", subsection="Общие"),
SettingField("STARS_ENABLED", "bool", "payments", "Telegram Stars", subsection="common"),
SettingField(
"STARS_ADMIN_ONLY_ENABLED",
"bool",
"payments",
"Telegram Stars admin-only",
(
"Shows Telegram Stars only to users from ADMIN_IDS. "
"Payment callbacks remain active for admin test payments."
),
subsection="common",
i18n_label_key="admin_settings_provider_admin_only_label",
i18n_description_key="admin_settings_provider_admin_only_description",
),
SettingField(
"PAYMENT_METHODS_ORDER",
"string",
"payments",
"Порядок методов оплаты",
"Через запятую: severpay,freekassa,yookassa,platega,stars,cryptopay",
subsection="Общие",
),
# YooKassa
SettingField("YOOKASSA_ENABLED", "bool", "payments", "Включена", subsection="YooKassa"),
SettingField("YOOKASSA_SHOP_ID", "string", "payments", "Shop ID", subsection="YooKassa"),
SettingField(
"YOOKASSA_SECRET_KEY",
"string",
"payments",
"Secret key",
subsection="YooKassa",
secret=True,
),
SettingField("YOOKASSA_RETURN_URL", "url", "payments", "Return URL", subsection="YooKassa"),
SettingField(
"YOOKASSA_DEFAULT_RECEIPT_EMAIL",
"string",
"payments",
"Email для чека по умолчанию",
subsection="YooKassa",
),
SettingField(
"YOOKASSA_VAT_CODE",
"int",
"payments",
"VAT code",
"1..6 в зависимости от системы налогообложения",
subsection="YooKassa",
min=1,
max=6,
),
SettingField(
"YOOKASSA_AUTOPAYMENTS_ENABLED",
"bool",
"payments",
"Автоплатежи (recurring)",
subsection="YooKassa",
),
SettingField(
"YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING",
"bool",
"payments",
"Принудительная привязка карты",
subsection="YooKassa",
),
# FreeKassa
SettingField("FREEKASSA_ENABLED", "bool", "payments", "Включена", subsection="FreeKassa"),
SettingField(
"FREEKASSA_MERCHANT_ID", "string", "payments", "Merchant ID", subsection="FreeKassa"
),
SettingField(
"FREEKASSA_FIRST_SECRET",
"string",
"payments",
"First secret",
subsection="FreeKassa",
secret=True,
),
SettingField(
"FREEKASSA_SECOND_SECRET",
"string",
"payments",
"Second secret",
"Используется для проверки подписи входящих уведомлений",
subsection="FreeKassa",
secret=True,
),
SettingField(
"FREEKASSA_API_KEY", "string", "payments", "API key", subsection="FreeKassa", secret=True
),
SettingField(
"FREEKASSA_PAYMENT_URL",
"url",
"payments",
"Payment URL",
placeholder="https://pay.freekassa.ru/",
subsection="FreeKassa",
),
SettingField(
"FREEKASSA_PAYMENT_METHOD_ID",
"int",
"payments",
"Метод оплаты по умолчанию",
subsection="FreeKassa",
),
SettingField(
"FREEKASSA_PAYMENT_IP",
"string",
"payments",
"IP сервера",
"Передаётся в подпись запроса при создании платежа",
subsection="FreeKassa",
),
SettingField(
"FREEKASSA_TRUSTED_IPS",
"string",
"payments",
"Доверенные IP",
"Через запятую — IP-адреса, с которых принимаются нотификации",
subsection="FreeKassa",
),
# Platega
SettingField("PLATEGA_ENABLED", "bool", "payments", "Включена", subsection="Platega"),
SettingField(
"PLATEGA_BASE_URL",
"url",
"payments",
"Base URL",
placeholder="https://app.platega.io",
subsection="Platega",
),
SettingField("PLATEGA_MERCHANT_ID", "string", "payments", "Merchant ID", subsection="Platega"),
SettingField(
"PLATEGA_SECRET", "string", "payments", "Secret", subsection="Platega", secret=True
),
SettingField(
"PLATEGA_PAYMENT_METHOD", "int", "payments", "Метод оплаты (legacy)", subsection="Platega"
),
SettingField("PLATEGA_SBP_ENABLED", "bool", "payments", "SBP-кнопка", subsection="Platega"),
SettingField("PLATEGA_SBP_METHOD", "int", "payments", "SBP method ID", subsection="Platega"),
SettingField(
"PLATEGA_CRYPTO_ENABLED", "bool", "payments", "Crypto-кнопка", subsection="Platega"
),
SettingField(
"PLATEGA_CRYPTO_METHOD", "int", "payments", "Crypto method ID", subsection="Platega"
),
SettingField("PLATEGA_RETURN_URL", "url", "payments", "Return URL", subsection="Platega"),
SettingField("PLATEGA_FAILED_URL", "url", "payments", "Failed URL", subsection="Platega"),
# SeverPay
SettingField("SEVERPAY_ENABLED", "bool", "payments", "Включена", subsection="SeverPay"),
SettingField("SEVERPAY_MID", "int", "payments", "MID", subsection="SeverPay"),
SettingField(
"SEVERPAY_TOKEN", "string", "payments", "Token", subsection="SeverPay", secret=True
),
SettingField(
"SEVERPAY_BASE_URL",
"url",
"payments",
"Base URL",
placeholder="https://severpay.io/api/merchant",
subsection="SeverPay",
),
SettingField("SEVERPAY_RETURN_URL", "url", "payments", "Return URL", subsection="SeverPay"),
SettingField(
"SEVERPAY_LIFETIME_MINUTES",
"int",
"payments",
"Срок жизни ссылки (мин)",
"30..4320; пусто — значение провайдера",
subsection="SeverPay",
min=30,
max=4320,
),
# CryptoPay
SettingField("CRYPTOPAY_ENABLED", "bool", "payments", "Включена", subsection="CryptoPay"),
SettingField(
"CRYPTOPAY_TOKEN", "string", "payments", "Token", subsection="CryptoPay", secret=True
),
SettingField(
"CRYPTOPAY_NETWORK",
"string",
"payments",
"Network",
"mainnet или testnet",
subsection="CryptoPay",
),
SettingField(
"CRYPTOPAY_CURRENCY_TYPE",
"string",
"payments",
"Currency type",
"fiat или crypto",
subsection="CryptoPay",
),
SettingField(
"CRYPTOPAY_ASSET", "string", "payments", "Asset", placeholder="RUB", subsection="CryptoPay"
"Через запятую: severpay,freekassa,yookassa,platega,stars,cryptopay,heleket",
subsection="common",
),
# ─── Trial ─────────────────────────────────────────────────────
SettingField("TRIAL_ENABLED", "bool", "trial", "Триал включён"),
SettingField("TRIAL_DURATION_DAYS", "int", "trial", "Длительность триала (дней)", min=0),
SettingField("TRIAL_TRAFFIC_LIMIT_GB", "float", "trial", "Лимит трафика триала (ГБ)", min=0),
SettingField("TRIAL_TRAFFIC_STRATEGY", "string", "trial", "Стратегия сброса трафика триала"),
SettingField(
"TRIAL_ENABLED",
"bool",
"pricing",
"Триал включён",
optional=False,
subsection="trial",
),
SettingField(
"TRIAL_DURATION_DAYS",
"int",
"pricing",
"Длительность триала (дней)",
optional=False,
min=0,
subsection="trial",
),
SettingField(
"TRIAL_TRAFFIC_LIMIT_GB",
"float",
"pricing",
"Лимит трафика триала (ГБ)",
optional=False,
min=0,
subsection="trial",
),
SettingField(
"TRIAL_TRAFFIC_STRATEGY",
"string",
"pricing",
"Стратегия сброса трафика триала",
optional=False,
subsection="trial",
),
SettingField(
"TRIAL_SQUAD_UUIDS",
"string",
"pricing",
"Internal Squads для триала",
"UUID через запятую. Если пусто, используется USER_SQUAD_UUIDS.",
subsection="trial",
),
# ─── Referral program ──────────────────────────────────────────
SettingField(
"REFERRAL_ONE_BONUS_PER_REFEREE", "bool", "referral", "Один бонус на приглашённого"
@@ -332,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",
@@ -395,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", "Уведомлять об истечении"
),
@@ -408,8 +429,17 @@ 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", "Логировать тикеты поддержки"),
SettingField(
"LOG_PROMO_ACTIVATIONS", "bool", "notifications", "Логировать активации промокодов"
),
@@ -423,8 +453,8 @@ SETTINGS_MANIFEST: List[SettingField] = [
"notifications",
"Логировать действия администраторов",
"Если выключено, события от пользователей из ADMIN_IDS не записываются в message logs.",
i18n_label_key="settings_field_log_admin_actions_label",
i18n_description_key="settings_field_log_admin_actions_description",
i18n_label_key="admin_settings_field_log_admin_actions_label",
i18n_description_key="admin_settings_field_log_admin_actions_description",
),
SettingField(
"LOG_LEVEL",
@@ -435,6 +465,122 @@ SETTINGS_MANIFEST: List[SettingField] = [
),
SettingField("LOG_CHAT_ID", "int", "notifications", "ID чата для логов"),
SettingField("LOG_THREAD_ID", "int", "notifications", "ID треда (для супергрупп)"),
SettingField(
"LOG_SUPPORT_THREAD_ID",
"int",
"notifications",
"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",
"support",
"Тикеты поддержки включены",
"Показывает раздел поддержки в ЛК и включает создание тикетов.",
),
SettingField(
"SUPPORT_ADMIN_EMAIL_NOTIFICATIONS_ENABLED",
"bool",
"support",
"Email-уведомления админам",
(
"Если выключено, новые тикеты и ответы пользователей останутся "
"только в Telegram и лог-чате."
),
),
SettingField(
"SUPPORT_ADMIN_NOTIFICATION_COOLDOWN_SECONDS",
"int",
"support",
"Пауза Telegram-уведомлений",
(
"Минимум секунд между повторными Telegram/log уведомлениями "
"по одному непрочитанному тикету."
),
min=0,
),
SettingField(
"SUPPORT_ADMIN_EMAIL_COOLDOWN_SECONDS",
"int",
"support",
"Пауза email-уведомлений",
"Минимум секунд между повторными email-уведомлениями по одному непрочитанному тикету.",
min=0,
),
SettingField(
"SUPPORT_TICKET_MAX_BODY_LENGTH",
"int",
"support",
"Макс. длина сообщения",
"Максимальное количество символов в сообщении тикета.",
min=1,
),
SettingField(
"SUPPORT_TICKET_MAX_SUBJECT_LENGTH",
"int",
"support",
"Макс. длина темы",
"Максимальное количество символов в теме тикета.",
min=1,
),
SettingField(
"SUPPORT_TICKET_RATE_LIMIT_PER_HOUR",
"int",
"support",
"Лимит тикетов в час",
"Сколько новых тикетов пользователь может создать за час. 0 — без лимита.",
min=0,
),
# ─── Devices ───────────────────────────────────────────────────
SettingField("MY_DEVICES_SECTION_ENABLED", "bool", "devices", "Раздел «Мои устройства»"),
SettingField(
@@ -445,21 +591,65 @@ SETTINGS_MANIFEST: List[SettingField] = [
]
def _provider_field_to_setting_field(spec: Any, manifest_field: Any) -> SettingField:
return SettingField(
key=manifest_field.key,
type=manifest_field.type,
section="payments",
label=manifest_field.label,
description=manifest_field.description,
placeholder=manifest_field.placeholder,
optional=manifest_field.optional,
secret=manifest_field.secret,
min=manifest_field.min,
max=manifest_field.max,
choices=tuple(manifest_field.choices) if manifest_field.choices else None,
subsection=manifest_field.subsection,
i18n_label_key=getattr(manifest_field, "i18n_label_key", None),
i18n_description_key=getattr(manifest_field, "i18n_description_key", None),
i18n_subsection_key=getattr(manifest_field, "i18n_subsection_key", None),
)
def aggregated_manifest() -> List[SettingField]:
"""SETTINGS_MANIFEST + per-provider fragments declared in provider SPECs."""
from bot.payment_providers import iter_provider_manifest_fields # local to avoid cycle
fields: List[SettingField] = list(SETTINGS_MANIFEST)
for spec, manifest_field in iter_provider_manifest_fields():
fields.append(_provider_field_to_setting_field(spec, manifest_field))
return fields
def get_field_by_key(key: str) -> Optional[SettingField]:
for field in SETTINGS_MANIFEST:
for field in aggregated_manifest():
if field.key == key:
return field
return None
def manifest_keys() -> List[str]:
return [f.key for f in SETTINGS_MANIFEST]
return [f.key for f in aggregated_manifest()]
def coerce_value(field: SettingField, raw: Any) -> Any:
"""Coerce a value coming from JSON to the type declared by the field."""
if field.type == "json":
if raw is None:
return ""
text = raw if isinstance(raw, str) else str(raw)
text = text.strip()
if not text:
return ""
from config.subscription_guides_config import validate_subscription_guides_config_text
validate_subscription_guides_config_text(text)
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":
@@ -498,23 +688,68 @@ def coerce_value(field: SettingField, raw: Any) -> Any:
return str(raw)
def _i18n_slug(value: str) -> str:
slug = re.sub(r"[^a-z0-9]+", "_", value.strip().lower()).strip("_")
return slug or "default"
def manifest_payload() -> List[dict]:
"""Serialize the manifest for the admin UI."""
"""Serialize the manifest for the admin UI.
For provider presentation fields we resolve the SPEC-declared default
(e.g. the button text the bot would use if the admin leaves the override
blank) and expose it as ``default``; ``placeholder`` falls back to the
same value so existing UIs that only read ``placeholder`` also show the
hint inside the empty input.
"""
from bot.payment_providers import (
find_manifest_owner,
manifest_field_default,
provider_admin_only_pairs,
provider_webhook_metadata,
)
sections_order = {
"general": 1,
"appearance": 2,
"pricing": 3,
"remnawave": 3,
"pricing": 11,
"payments": 4,
"trial": 5,
"referral": 6,
"notifications": 7,
"devices": 8,
"support": 8,
"backups": 9,
"devices": 10,
"subscription_guides": 10,
}
exclusive_map = {
key: opposite
for public_key, admin_key in provider_admin_only_pairs()
for key, opposite in ((public_key, admin_key), (admin_key, public_key))
}
items: List[dict] = []
for field in SETTINGS_MANIFEST:
auto_label_i18n_key = f"settings_field_{field.key.lower()}_label"
auto_description_i18n_key = f"settings_field_{field.key.lower()}_description"
for field in aggregated_manifest():
auto_label_i18n_key = f"admin_settings_field_{field.key.lower()}_label"
auto_description_i18n_key = f"admin_settings_field_{field.key.lower()}_description"
auto_subsection_i18n_key = (
f"admin_settings_subsection_{_i18n_slug(field.subsection)}"
if field.subsection
else None
)
default_value: Optional[str] = None
webhook_metadata: Optional[dict] = None
owner = find_manifest_owner(field.key)
if owner is not None:
spec, manifest_field = owner
default_value = manifest_field_default(spec, manifest_field)
webhook_metadata = provider_webhook_metadata(spec)
placeholder = field.placeholder
if not placeholder and default_value:
placeholder = default_value
item = {
"key": field.key,
"type": field.type,
@@ -526,11 +761,43 @@ def manifest_payload() -> List[dict]:
"i18n_label_key": field.i18n_label_key or auto_label_i18n_key,
"i18n_description_key": field.i18n_description_key
or (auto_description_i18n_key if field.description else None),
"placeholder": field.placeholder,
"i18n_subsection_key": field.i18n_subsection_key or auto_subsection_i18n_key,
"i18n_placeholder_key": (
f"admin_settings_field_{field.key.lower()}_placeholder" if placeholder else None
),
"placeholder": placeholder,
"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"] = [{"value": v, "label": lbl} for v, lbl in field.choices]
item["choices"] = [
{
"value": v,
"label": lbl,
"i18n_label_key": (
f"admin_settings_field_{field.key.lower()}_choice_{_i18n_slug(str(v))}"
),
}
for v, lbl in field.choices
]
items.append(item)
return items
@@ -11,9 +11,12 @@ from bot.app.web.webapp import (
billing as _billing,
common as _common,
devices as _devices,
guides as _guides,
payloads as _payloads,
routes as _routes,
serializers as _serializers,
support as _support,
telegram_notifications as _telegram_notifications,
)
_MODULES = (
@@ -23,9 +26,12 @@ _MODULES = (
_assets,
_auth,
_account,
_telegram_notifications,
_serializers,
_billing,
_devices,
_guides,
_support,
_routes,
_application,
)
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

@@ -0,0 +1,219 @@
<!doctype html>
<html lang="__LANG__">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link id="app-favicon" rel="icon" href="data:," sizes="any">
<title>__PAGE_TITLE__</title>
<style nonce="__NONCE__">
:root {
color-scheme: dark light;
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
"Segoe UI", sans-serif;
background: #0b1017;
color: #f7fafc;
}
body {
min-height: 100dvh;
margin: 0;
display: grid;
place-items: center;
padding: 24px;
box-sizing: border-box;
}
main {
width: min(100%, 420px);
display: grid;
gap: 14px;
text-align: center;
}
h1 {
margin: 0;
font-size: 24px;
line-height: 1.2;
}
p {
margin: 0;
color: #aeb8c5;
font-size: 15px;
line-height: 1.55;
}
.actions {
display: grid;
gap: 10px;
margin-top: 4px;
}
.button {
display: inline-flex;
min-height: 46px;
align-items: center;
justify-content: center;
border: 1px solid transparent;
border-radius: 8px;
background: #14b86f;
color: #03120b;
padding: 0 18px;
box-sizing: border-box;
font: inherit;
font-weight: 800;
text-decoration: none;
cursor: pointer;
}
.button.secondary {
border-color: #2d3847;
background: transparent;
color: #f7fafc;
}
.button[aria-disabled="true"] {
pointer-events: none;
background: #344052;
color: #aeb8c5;
}
[hidden] {
display: none !important;
}
</style>
</head>
<body>
<main>
<h1 id="title"></h1>
<p id="status"></p>
<div class="actions">
<a id="open-link" class="button" href="#" rel="noreferrer"></a>
<button id="close-button" class="button secondary" type="button" hidden></button>
</div>
</main>
<script nonce="__NONCE__">
(() => {
const messages = __MESSAGES_JSON__;
const titleEl = document.getElementById("title");
const statusEl = document.getElementById("status");
const openLink = document.getElementById("open-link");
const closeButton = document.getElementById("close-button");
const params = new URLSearchParams(window.location.hash.replace(/^#/, ""));
const target = String(params.get("url") || "").trim();
const isUnsafe =
!target ||
hasControlChars(target) ||
/^(?:javascript|data|vbscript|https?):/i.test(target);
let attempted = false;
let pageLeft = false;
let state = "opening";
function hasControlChars(value) {
return Array.from(String(value || "")).some((char) => {
const code = char.charCodeAt(0);
return code <= 31 || code === 127;
});
}
function text(key, fallback) {
const value = messages && messages[key];
return typeof value === "string" && value ? value : fallback;
}
function tryCloseWindow() {
try {
window.close();
} catch (_error) {
void _error;
}
}
function render(nextState) {
state = nextState;
if (nextState === "unavailable") {
titleEl.textContent = text("unavailableTitle", "App link unavailable");
statusEl.textContent = text("unavailableHint", "Return to Telegram and try again.");
openLink.textContent = text("button", "Open app");
openLink.setAttribute("aria-disabled", "true");
openLink.removeAttribute("href");
closeButton.hidden = true;
return;
}
if (nextState === "done") {
titleEl.textContent = text("doneTitle", "Settings added");
statusEl.textContent = text("doneHint", "You can close this window.");
openLink.textContent = text("retryButton", "Open again");
openLink.removeAttribute("aria-disabled");
openLink.href = target;
closeButton.textContent = text("closeButton", "Close window");
closeButton.hidden = false;
return;
}
titleEl.textContent = text("title", "Opening app");
statusEl.textContent =
nextState === "manual"
? text("manualHint", "If the app did not open automatically, tap the button below.")
: text("hint", "Opening the app on this device...");
openLink.textContent = text("button", "Open app");
openLink.removeAttribute("aria-disabled");
openLink.href = target;
closeButton.hidden = true;
}
function markDone() {
if (state === "done" || isUnsafe) return;
render("done");
window.setTimeout(tryCloseWindow, 120);
}
function notePageLeft() {
if (!attempted) return;
pageLeft = true;
window.setTimeout(markDone, 900);
}
function openTarget() {
if (isUnsafe) return;
attempted = true;
pageLeft = false;
render("opening");
window.location.href = target;
window.setTimeout(() => {
if (state === "opening" && !pageLeft) render("manual");
}, 1600);
}
if (isUnsafe) {
render("unavailable");
return;
}
openLink.addEventListener("click", (event) => {
event.preventDefault();
openTarget();
});
closeButton.addEventListener("click", () => {
tryCloseWindow();
render("done");
});
window.addEventListener("pagehide", notePageLeft);
window.addEventListener("blur", notePageLeft);
document.addEventListener("visibilitychange", () => {
if (!attempted) return;
if (document.hidden) {
pageLeft = true;
} else if (pageLeft) {
markDone();
}
});
render("opening");
window.setTimeout(openTarget, 80);
})();
</script>
</body>
</html>
@@ -1,25 +1,73 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
/>
<meta name="robots" content="noindex, nofollow" />
<meta name="theme-color" content="#03070b" />
<link id="app-favicon" rel="icon" href="/favicon.ico" sizes="any" />
<link rel="icon" type="image/png" sizes="192x192" href="/icon-192.png" />
<link rel="icon" type="image/png" sizes="512x512" href="/icon-512.png" />
<link
id="app-apple-touch-icon"
rel="apple-touch-icon"
sizes="180x180"
href="/apple-touch-icon.png"
/>
<link
rel="apple-touch-icon-precomposed"
sizes="180x180"
href="/apple-touch-icon-precomposed.png"
/>
<title>/minishop</title>
<link rel="stylesheet" href="/subscription_webapp.css" />
<style>
.app-boot-fallback {
min-height: 100dvh;
display: grid;
place-items: center;
padding: 24px;
background: #03070b;
}
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
<meta name="robots" content="noindex, nofollow">
<meta name="theme-color" content="#03070b">
<link id="app-favicon" rel="icon" href="data:," sizes="any">
<title>/minishop</title>
<link rel="stylesheet" href="/subscription_webapp.css">
</head>
.app-boot-fallback__spinner {
width: 28px;
height: 28px;
border: 2px solid rgba(242, 247, 244, 0.18);
border-top-color: #00fe7a;
border-radius: 999px;
animation: appBootSpin 0.8s linear infinite;
}
<body>
<main id="app"></main>
@media (prefers-reduced-motion: reduce) {
.app-boot-fallback__spinner {
animation: none;
}
}
<!-- WEBAPP_I18N_SCRIPT -->
<!-- WEBAPP_CONFIG_SCRIPT -->
<!-- WEBAPP_JS_SCRIPT -->
<!-- WEBAPP_DEV_MOCK_START -->
<script src="/subscription_webapp.js" defer></script>
<!-- WEBAPP_DEV_MOCK_END -->
</body>
@keyframes appBootSpin {
to {
transform: rotate(360deg);
}
}
</style>
</head>
<body>
<main id="app">
<div class="app-boot-fallback" role="status" aria-label="Загрузка">
<div class="app-boot-fallback__spinner" aria-hidden="true"></div>
</div>
</main>
<!-- WEBAPP_I18N_SCRIPT -->
<!-- WEBAPP_CONFIG_SCRIPT -->
<!-- WEBAPP_JS_SCRIPT -->
<!-- WEBAPP_DEV_MOCK_START -->
<script src="/subscription_webapp.js" defer></script>
<!-- WEBAPP_DEV_MOCK_END -->
</body>
</html>
+295 -3
View File
@@ -18,6 +18,21 @@
--muted: #b0b0b0;
--dim: #6a6a6a;
--danger: #ff5555;
--danger-text: #ff5555;
--danger-soft: #000000;
--danger-border: #ff5555;
--success: #ffffff;
--success-text: #ffffff;
--success-soft: #000000;
--success-border: #ffffff;
--warning: #ffffff;
--warning-text: #ffffff;
--warning-soft: #000000;
--warning-border: #ffffff;
--info: #ffffff;
--info-text: #ffffff;
--info-soft: #000000;
--info-border: #ffffff;
--blue: #ffffff;
--radius: 0px;
--font-sans: "JetBrains Mono", "Cascadia Code", "Fira Code", "Consolas",
@@ -30,6 +45,7 @@
--surface-sheen-soft: transparent;
--surface-hover: rgba(255, 255, 255, 0.08);
--surface-muted: #0a0a0a;
--surface-subtle: #000000;
--surface-subtle-border: #ffffff;
--overlay-scrim: rgba(0, 0, 0, 0.85);
--nav-bg: #000000;
@@ -104,6 +120,8 @@
/* ---------- Panels / cards ---------- */
.theme-key-ascii .card,
.theme-key-ascii .trial-card-facts span,
.theme-key-ascii .trial-activation-facts div,
.theme-key-ascii .period-card,
.theme-key-ascii .method-card,
.theme-key-ascii .settings-row,
@@ -112,11 +130,34 @@
.theme-key-ascii .tariff-action-card,
.theme-key-ascii .tariff-warning-card,
.theme-key-ascii .topup-carryover-note,
.theme-key-ascii .input,
.theme-key-ascii .subscription-purchase-description,
.theme-key-ascii .dialog-card,
.theme-key-ascii .language-select-content,
.theme-key-ascii .bottom-nav,
.theme-key-ascii .toast,
.theme-key-ascii .auth-card,
.theme-key-ascii .support-overview-card,
.theme-key-ascii .support-list-card,
.theme-key-ascii .support-ticket-card,
.theme-key-ascii .support-conversation-card,
.theme-key-ascii .support-new-ticket-button,
.theme-key-ascii .support-create-panel,
.theme-key-ascii .support-select-trigger,
.theme-key-ascii .support-select-content,
.theme-key-ascii .support-status-tabs-list,
.theme-key-ascii .support-user-ticket-skeleton,
.theme-key-ascii .ticket-card,
.theme-key-ascii .support-empty-state,
.theme-key-ascii .support-message-scroll,
.theme-key-ascii .ticket-message-avatar,
.theme-key-ascii .ticket-message-bubble,
.theme-key-ascii .ticket-composer,
.theme-key-ascii .install-platform-trigger,
.theme-key-ascii .install-app-button,
.theme-key-ascii .install-step,
.theme-key-ascii .install-subscription-card,
.theme-key-ascii .install-qr-wrap,
.theme-key-ascii .install-loading,
.theme-key-ascii .admin-sidebar,
.theme-key-ascii .admin-header,
.theme-key-ascii .admin-card,
@@ -147,6 +188,12 @@
.theme-key-ascii .btn,
.theme-key-ascii .language-select-trigger,
.theme-key-ascii .bottom-nav button,
.theme-key-ascii .link-button,
.theme-key-ascii .support-new-ticket-button,
.theme-key-ascii .support-select-trigger,
.theme-key-ascii .support-status-tabs-trigger,
.theme-key-ascii .install-platform-trigger,
.theme-key-ascii .install-app-button,
.theme-key-ascii .admin-btn,
.theme-key-ascii .admin-chip,
.theme-key-ascii .admin-tabs-trigger,
@@ -176,6 +223,8 @@
.theme-key-ascii .btn:active:not(:disabled),
.theme-key-ascii .bottom-nav button:active,
.theme-key-ascii .support-new-ticket-button:active,
.theme-key-ascii .support-status-tabs-trigger:active,
.theme-key-ascii .admin-btn:active:not(:disabled) {
background: #ffffff;
color: #000000;
@@ -188,6 +237,11 @@
.theme-key-ascii .period-card.active,
.theme-key-ascii .method-card.active,
.theme-key-ascii .option-row.active,
.theme-key-ascii .support-new-ticket-button.active,
.theme-key-ascii .support-status-tabs-trigger[data-state="active"],
.theme-key-ascii .support-select-item[data-highlighted],
.theme-key-ascii .support-select-item[data-selected],
.theme-key-ascii .install-app-button.active,
.theme-key-ascii .admin-nav-item.active,
.theme-key-ascii .admin-tabs-trigger[data-state="active"],
.theme-key-ascii .admin-revenue-period-btn.is-active {
@@ -217,6 +271,9 @@
.theme-key-ascii .admin-revenue-period-btn:focus-visible,
.theme-key-ascii .admin-mobile-toggle:focus-visible,
.theme-key-ascii .language-select-trigger:focus-visible,
.theme-key-ascii .install-platform-trigger:focus-visible,
.theme-key-ascii .install-platform-trigger[data-state="open"],
.theme-key-ascii .install-app-button:focus-visible,
.theme-key-ascii .bottom-nav button:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 1px;
@@ -226,6 +283,7 @@
/* ---------- Inputs ---------- */
.theme-key-ascii .input,
.theme-key-ascii .textarea,
.theme-key-ascii .admin-input,
.theme-key-ascii .admin-textarea,
.theme-key-ascii .admin-screen-wrap textarea,
@@ -240,6 +298,7 @@
}
.theme-key-ascii .input::placeholder,
.theme-key-ascii .textarea::placeholder,
.theme-key-ascii .admin-input::placeholder,
.theme-key-ascii .admin-textarea::placeholder,
.theme-key-ascii .admin-screen-wrap textarea::placeholder,
@@ -249,6 +308,7 @@
}
.theme-key-ascii .input:focus,
.theme-key-ascii .textarea:focus,
.theme-key-ascii .admin-input:focus,
.theme-key-ascii .admin-textarea:focus,
.theme-key-ascii .admin-screen-wrap textarea:focus,
@@ -258,6 +318,156 @@
box-shadow: inset 0 0 0 1px #ffffff;
}
/* ---------- New webapp surfaces: support, purchase info, password login ---------- */
.theme-key-ascii .trial-offer-card,
.theme-key-ascii .trial-card-facts span,
.theme-key-ascii .trial-activation-card,
.theme-key-ascii .trial-activation-facts div,
.theme-key-ascii .activation-success-dialog,
.theme-key-ascii .subscription-purchase-description,
.theme-key-ascii .support-create-panel,
.theme-key-ascii .ticket-composer {
background: #000000;
}
.theme-key-ascii .trial-card-head > svg,
.theme-key-ascii .dialog-title-icon {
color: #ffffff;
}
.theme-key-ascii .support-heading-icon,
.theme-key-ascii .support-new-ticket-icon,
.theme-key-ascii .support-empty-state svg,
.theme-key-ascii .ticket-card-title svg,
.theme-key-ascii .field-error-icon {
color: #ffffff;
}
.theme-key-ascii .password-switch-divider,
.theme-key-ascii .or-line span {
height: 1px;
background: #ffffff;
}
.theme-key-ascii .link-button {
min-height: 28px;
padding: 0 8px;
color: #ffffff;
cursor: pointer;
}
.theme-key-ascii .link-button:hover:not(:disabled),
.theme-key-ascii .support-status-tabs-trigger:hover {
background: #ffffff;
color: #000000;
}
.theme-key-ascii .ticket-card::before {
inset: 0 auto 0 0;
width: 1px;
border-radius: 0;
background: #ffffff;
}
.theme-key-ascii .ticket-status-badge,
.theme-key-ascii .ticket-priority-badge,
.theme-key-ascii .ticket-message-role-badge {
border-color: #ffffff;
background: #000000;
color: #ffffff;
}
.theme-key-ascii .ticket-status-badge::before,
.theme-key-ascii .ticket-priority-badge::before,
.theme-key-ascii .support-status-tabs-trigger b::before {
content: "[";
}
.theme-key-ascii .ticket-status-badge::after,
.theme-key-ascii .ticket-priority-badge::after,
.theme-key-ascii .support-status-tabs-trigger b::after {
content: "]";
}
.theme-key-ascii .ticket-message-row,
.theme-key-ascii .ticket-message-row--admin,
.theme-key-ascii .ticket-message-row--user,
.theme-key-ascii .ticket-message-row--system,
.theme-key-ascii .ticket-message-row--internal {
--ticket-bubble-bg: #000000;
--ticket-bubble-border: #ffffff;
--ticket-bubble-text: #ffffff;
}
.theme-key-ascii .ticket-message-bubble,
.theme-key-ascii .ticket-message-row--outgoing .ticket-message-bubble {
border-radius: 0;
}
.theme-key-ascii .ticket-message-row--outgoing .ticket-message-bubble::after,
.theme-key-ascii .ticket-message-row--incoming .ticket-message-bubble::before {
color: #ffffff;
font-family: var(--font-mono);
}
.theme-key-ascii .ticket-message-row--outgoing .ticket-message-bubble::after {
content: " >";
}
.theme-key-ascii .ticket-message-row--incoming .ticket-message-bubble::before {
content: "< ";
}
.theme-key-ascii .ticket-composer:focus-within,
.theme-key-ascii .support-select-trigger:focus-visible,
.theme-key-ascii .ticket-card:focus-visible,
.theme-key-ascii .support-status-tabs-trigger:focus-visible {
border-color: #ffffff;
box-shadow: inset 0 0 0 1px #ffffff;
}
.theme-key-ascii .support-message-scroll,
.theme-key-ascii .scroll-area--mono::-webkit-scrollbar-thumb {
border-radius: 0;
}
.theme-key-ascii .auth-card {
transition: none;
}
body:has(.theme-key-ascii) .support-select-content,
body:has(.theme-key-ascii) .install-platform-content,
body:has(.theme-key-ascii) .field-error-tooltip {
border: 1px solid #ffffff;
border-radius: 0;
background: #000000;
color: #ffffff;
box-shadow: 0 0 0 1px #ffffff;
}
body:has(.theme-key-ascii) .support-select-item,
body:has(.theme-key-ascii) .install-platform-item {
border-radius: 0;
color: #ffffff;
}
body:has(.theme-key-ascii) .support-select-item[data-highlighted],
body:has(.theme-key-ascii) .support-select-item[data-selected],
body:has(.theme-key-ascii) .install-platform-item[data-highlighted],
body:has(.theme-key-ascii) .install-platform-item[data-selected] {
background: #ffffff;
color: #000000 !important;
}
body:has(.theme-key-ascii) .support-select-item[data-highlighted] svg,
body:has(.theme-key-ascii) .support-select-item[data-selected] svg,
body:has(.theme-key-ascii) .install-platform-item[data-highlighted] svg,
body:has(.theme-key-ascii) .install-platform-item[data-selected] svg {
color: #000000 !important;
stroke: #000000 !important;
}
/* ---------- Bottom nav (desktop rail) ---------- */
@media (min-width: 1024px) {
@@ -516,7 +726,8 @@
.theme-key-ascii .admin-btn-primary svg.lucide,
.theme-key-ascii .admin-nav-item.active svg.lucide,
.theme-key-ascii .admin-tabs-trigger[data-state="active"] svg.lucide,
.theme-key-ascii .admin-revenue-period-btn.is-active svg.lucide {
.theme-key-ascii .admin-revenue-period-btn.is-active svg.lucide,
.theme-key-ascii .install-app-button.active svg.lucide {
color: #000000 !important;
stroke: #000000 !important;
}
@@ -865,10 +1076,23 @@
.theme-key-ascii :is(
.card, .dialog-card, .toast,
.btn, .input,
.trial-card-facts span, .trial-activation-facts div,
.period-card, .method-card, .settings-row, .option-row,
.tariff-selected-card, .tariff-action-card, .tariff-warning-card,
.topup-carryover-note, .language-select-content, .language-select-item,
.topup-carryover-note, .subscription-purchase-description,
.language-select-content, .language-select-item,
.language-select-trigger, .bottom-nav, .bottom-nav button,
.link-button,
.install-platform-trigger, .install-platform-content, .install-platform-item,
.install-app-button, .install-step, .install-subscription-card,
.install-qr-wrap, .install-subscription-header-icon, .install-loading,
.support-overview-card, .support-list-card, .support-ticket-card,
.support-conversation-card, .support-new-ticket-button,
.support-create-panel, .support-select-trigger, .support-select-content,
.support-select-item, .support-status-tabs-list, .support-status-tabs-trigger,
.support-user-ticket-skeleton, .ticket-card, .support-empty-state,
.support-message-scroll, .ticket-message-avatar, .ticket-message-bubble,
.ticket-composer, .textarea,
.field-error-tooltip,
.admin-card, .admin-card-head, .admin-card-body,
.admin-stat-card, .admin-stat-skeleton-card, .admin-stat-skeleton-wide,
@@ -895,6 +1119,9 @@
.admin-tariff-card, .admin-toolbar-card, .admin-table-card,
.admin-panel-dash-card,
.admin-select-trigger, .admin-select-content,
.install-platform-trigger, .install-platform-content,
.install-app-button, .install-step, .install-subscription-card,
.install-qr-wrap, .install-loading,
.admin-cn-card,
.admin-input, .admin-textarea, .admin-btn,
.admin-nav-item, .admin-tabs-trigger
@@ -908,6 +1135,71 @@
border-radius: 0 !important;
}
/* ---------- Install guide theme surfaces ---------- */
.theme-key-ascii .install-platform-trigger,
.theme-key-ascii .install-app-button,
.theme-key-ascii .install-step,
.theme-key-ascii .install-subscription-card,
.theme-key-ascii .install-qr-wrap,
.theme-key-ascii .install-loading,
body:has(.theme-key-ascii) .install-platform-content {
border: 1px solid #ffffff !important;
border-radius: 0 !important;
background: #000000 !important;
box-shadow: none !important;
}
.theme-key-ascii .install-platform-trigger:hover,
.theme-key-ascii .install-app-button:hover:not(:disabled) {
background: #ffffff !important;
color: #000000 !important;
transform: none !important;
}
.theme-key-ascii .install-app-button.active,
.theme-key-ascii .install-app-button.active:hover:not(:disabled),
body:has(.theme-key-ascii) .install-platform-item[data-highlighted],
body:has(.theme-key-ascii) .install-platform-item[data-selected] {
background: #ffffff !important;
color: #000000 !important;
border-color: #ffffff !important;
}
.theme-key-ascii .install-app-button.active svg,
body:has(.theme-key-ascii) .install-platform-item[data-highlighted] svg,
body:has(.theme-key-ascii) .install-platform-item[data-selected] svg {
color: #000000 !important;
stroke: #000000 !important;
}
.theme-key-ascii .install-step:hover,
.theme-key-ascii .install-subscription-card:hover {
transform: none !important;
box-shadow: none !important;
}
.theme-key-ascii .install-step-icon,
.theme-key-ascii .install-subscription-header-icon {
border: 1px solid currentColor !important;
background: #000000 !important;
color: #ffffff !important;
}
.theme-key-ascii .install-qr-divider {
color: #ffffff !important;
opacity: 0.72;
}
.theme-key-ascii .install-feature-star.attention-dot {
background: #ffffff !important;
animation: ascii-caret 1s steps(1) infinite !important;
}
.theme-key-ascii .install-loading .ui-spinner {
color: #ffffff;
}
/* ============================================================
* Console-style tables: cell borders, header underline,
* row separator using dashed line.
+1 -1
View File
@@ -9,7 +9,7 @@
"use_primary_accent": false,
"use_in_admin": true,
"css_file": "style.css",
"assets_version": 1,
"assets_version": 4,
"tokens": {
"color_scheme": "dark",
"style_preset": "ascii"
@@ -33,6 +33,7 @@
--surface-sheen-soft: rgba(15, 23, 42, 0.012);
--surface-hover: rgba(15, 23, 42, 0.045);
--surface-muted: rgba(15, 23, 42, 0.035);
--surface-subtle: rgba(15, 23, 42, 0.025);
--surface-subtle-border: rgba(15, 23, 42, 0.1);
--overlay-scrim: rgba(15, 23, 42, 0.34);
--nav-bg: rgba(255, 255, 255, 0.88);
@@ -109,6 +110,26 @@
z-index: 1;
}
/* New user-facing activation surfaces */
.theme-key-light .trial-offer-card,
.theme-key-light .trial-activation-card,
.theme-key-light .activation-success-dialog {
border-color: color-mix(in srgb, var(--accent) 24%, var(--border));
background: #ffffff;
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.08);
}
.theme-key-light .trial-card-head > svg,
.theme-key-light .dialog-title-icon {
color: color-mix(in srgb, var(--accent) 54%, #000000);
}
.theme-key-light .trial-card-facts span,
.theme-key-light .trial-activation-facts div {
border-color: rgba(15, 23, 42, 0.12);
background: rgba(15, 23, 42, 0.025);
}
/* Slightly stronger axis/grid contrast for the revenue chart on a light surface */
.theme-key-light .admin-revenue-svg-frame {
background: #ffffff;
@@ -129,3 +150,57 @@
.theme-key-light .bonus-card-head > svg {
color: color-mix(in srgb, var(--accent) 50%, #000000);
}
/* Install guide theme surfaces */
.theme-key-light .install-platform-trigger,
.theme-key-light .install-app-button,
.theme-key-light .install-step,
.theme-key-light .install-subscription-card,
.theme-key-light .install-qr-wrap {
background: #ffffff;
border-color: rgba(15, 23, 42, 0.12);
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.055);
}
.theme-key-light .install-app-button.active {
border-color: color-mix(in srgb, var(--accent) 42%, var(--border));
background: color-mix(in srgb, var(--accent) 8%, #ffffff);
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.08);
}
.theme-key-light .install-platform-trigger:focus-visible,
.theme-key-light .install-platform-trigger[data-state="open"],
.theme-key-light .install-app-button:focus-visible {
border-color: color-mix(in srgb, var(--accent) 48%, var(--border));
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 16%, transparent);
}
body:has(.theme-key-light) .install-platform-content {
background: #ffffff;
border-color: rgba(15, 23, 42, 0.14);
box-shadow: 0 14px 28px rgba(15, 23, 42, 0.12);
}
body:has(.theme-key-light) .install-platform-item[data-highlighted],
body:has(.theme-key-light) .install-platform-item[data-selected] {
background: color-mix(in srgb, var(--accent) 9%, #ffffff);
}
.theme-key-light .install-step-icon,
.theme-key-light .install-subscription-header-icon {
background: color-mix(in srgb, var(--accent) 8%, #ffffff);
color: color-mix(in srgb, var(--accent) 55%, #000000);
}
.theme-key-light .install-qr-divider {
color: rgba(15, 23, 42, 0.24);
}
.theme-key-light .install-feature-star.attention-dot {
background: #f59e0b;
}
.theme-key-light .install-loading .ui-spinner {
color: color-mix(in srgb, var(--accent) 55%, #000000);
}
+1 -1
View File
@@ -9,7 +9,7 @@
"use_primary_accent": true,
"use_in_admin": true,
"css_file": "style.css",
"assets_version": 2,
"assets_version": 3,
"tokens": {
"color_scheme": "light"
}
+356 -45
View File
@@ -14,6 +14,21 @@
--muted: #202020;
--dim: #404040;
--danger: #800000;
--danger-text: #800000;
--danger-soft: #f7d6d6;
--danger-border: #800000;
--success: #008000;
--success-text: #006000;
--success-soft: #d9f0d9;
--success-border: #008000;
--warning: #808000;
--warning-text: #606000;
--warning-soft: #ffffcc;
--warning-border: #808000;
--info: #000080;
--info-text: #000080;
--info-soft: #d8e6ff;
--info-border: #000080;
--blue: #000080;
--radius: 0px;
--font-sans: "MS Sans Serif", Tahoma, "Segoe UI", sans-serif;
@@ -24,6 +39,7 @@
--surface-sheen-soft: transparent;
--surface-hover: rgba(0, 0, 128, 0.14);
--surface-muted: #c0c0c0;
--surface-subtle: #dfdfdf;
--surface-subtle-border: #808080;
--overlay-scrim: rgba(0, 0, 0, 0.35);
--nav-bg: #c0c0c0;
@@ -41,45 +57,45 @@
--admin-text: #000000;
--admin-muted: #202020;
--admin-dim: #404040;
--win95-icon-arrow-left: url("/webapp-theme-assets/windows95/icons/arrow-left.png?v=6");
--win95-icon-arrow-right: url("/webapp-theme-assets/windows95/icons/arrow-right.png?v=6");
--win95-icon-bitcoin: url("/webapp-theme-assets/windows95/icons/bitcoin.png?v=6");
--win95-icon-check: url("/webapp-theme-assets/windows95/icons/check.png?v=6");
--win95-icon-chevrons: url("/webapp-theme-assets/windows95/icons/chevrons.png?v=6");
--win95-icon-coins: url("/webapp-theme-assets/windows95/icons/coins.png?v=6");
--win95-icon-dashboard: url("/webapp-theme-assets/windows95/icons/dashboard.png?v=6");
--win95-icon-database: url("/webapp-theme-assets/windows95/icons/database.png?v=6");
--win95-icon-download: url("/webapp-theme-assets/windows95/icons/download.png?v=6");
--win95-icon-error: url("/webapp-theme-assets/windows95/icons/error.png?v=6");
--win95-icon-file-text: url("/webapp-theme-assets/windows95/icons/file-text.png?v=6");
--win95-icon-folder: url("/webapp-theme-assets/windows95/icons/folder.png?v=6");
--win95-icon-gift: url("/webapp-theme-assets/windows95/icons/gift.png?v=6");
--win95-icon-globe: url("/webapp-theme-assets/windows95/icons/globe.png?v=6");
--win95-icon-help: url("/webapp-theme-assets/windows95/icons/help.png?v=6");
--win95-icon-home: url("/webapp-theme-assets/windows95/icons/home.png?v=6");
--win95-icon-info: url("/webapp-theme-assets/windows95/icons/info.png?v=6");
--win95-icon-key: url("/webapp-theme-assets/windows95/icons/key.png?v=6");
--win95-icon-lock: url("/webapp-theme-assets/windows95/icons/lock.png?v=6");
--win95-icon-megaphone: url("/webapp-theme-assets/windows95/icons/megaphone.png?v=6");
--win95-icon-paintbrush: url("/webapp-theme-assets/windows95/icons/paintbrush.png?v=6");
--win95-icon-payment-card: url("/webapp-theme-assets/windows95/icons/payment-card.png?v=6");
--win95-icon-print: url("/webapp-theme-assets/windows95/icons/print.png?v=6");
--win95-icon-refresh: url("/webapp-theme-assets/windows95/icons/refresh.png?v=6");
--win95-icon-save: url("/webapp-theme-assets/windows95/icons/save.png?v=6");
--win95-icon-search: url("/webapp-theme-assets/windows95/icons/search.png?v=6");
--win95-icon-send: url("/webapp-theme-assets/windows95/icons/send.png?v=6");
--win95-icon-settings: url("/webapp-theme-assets/windows95/icons/settings.png?v=6");
--win95-icon-shield: url("/webapp-theme-assets/windows95/icons/shield.png?v=6");
--win95-icon-smartphone: url("/webapp-theme-assets/windows95/icons/smartphone.png?v=6");
--win95-icon-sliders: url("/webapp-theme-assets/windows95/icons/sliders.png?v=6");
--win95-icon-sparkles: url("/webapp-theme-assets/windows95/icons/sparkles.png?v=6");
--win95-icon-tag: url("/webapp-theme-assets/windows95/icons/tag.png?v=6");
--win95-icon-ticket: url("/webapp-theme-assets/windows95/icons/ticket.png?v=6");
--win95-icon-trash: url("/webapp-theme-assets/windows95/icons/trash.png?v=6");
--win95-icon-user: url("/webapp-theme-assets/windows95/icons/user.png?v=6");
--win95-icon-users: url("/webapp-theme-assets/windows95/icons/users.png?v=6");
--win95-icon-warning: url("/webapp-theme-assets/windows95/icons/warning.png?v=6");
--win95-icon-x: url("/webapp-theme-assets/windows95/icons/x.png?v=6");
--win95-icon-arrow-left: url("/webapp-theme-assets/windows95/icons/arrow-left.png?v=9");
--win95-icon-arrow-right: url("/webapp-theme-assets/windows95/icons/arrow-right.png?v=9");
--win95-icon-bitcoin: url("/webapp-theme-assets/windows95/icons/bitcoin.png?v=9");
--win95-icon-check: url("/webapp-theme-assets/windows95/icons/check.png?v=9");
--win95-icon-chevrons: url("/webapp-theme-assets/windows95/icons/chevrons.png?v=9");
--win95-icon-coins: url("/webapp-theme-assets/windows95/icons/coins.png?v=9");
--win95-icon-dashboard: url("/webapp-theme-assets/windows95/icons/dashboard.png?v=9");
--win95-icon-database: url("/webapp-theme-assets/windows95/icons/database.png?v=9");
--win95-icon-download: url("/webapp-theme-assets/windows95/icons/download.png?v=9");
--win95-icon-error: url("/webapp-theme-assets/windows95/icons/error.png?v=9");
--win95-icon-file-text: url("/webapp-theme-assets/windows95/icons/file-text.png?v=9");
--win95-icon-folder: url("/webapp-theme-assets/windows95/icons/folder.png?v=9");
--win95-icon-gift: url("/webapp-theme-assets/windows95/icons/gift.png?v=9");
--win95-icon-globe: url("/webapp-theme-assets/windows95/icons/globe.png?v=9");
--win95-icon-help: url("/webapp-theme-assets/windows95/icons/help.png?v=9");
--win95-icon-home: url("/webapp-theme-assets/windows95/icons/home.png?v=9");
--win95-icon-info: url("/webapp-theme-assets/windows95/icons/info.png?v=9");
--win95-icon-key: url("/webapp-theme-assets/windows95/icons/key.png?v=9");
--win95-icon-lock: url("/webapp-theme-assets/windows95/icons/lock.png?v=9");
--win95-icon-megaphone: url("/webapp-theme-assets/windows95/icons/megaphone.png?v=9");
--win95-icon-paintbrush: url("/webapp-theme-assets/windows95/icons/paintbrush.png?v=9");
--win95-icon-payment-card: url("/webapp-theme-assets/windows95/icons/payment-card.png?v=9");
--win95-icon-print: url("/webapp-theme-assets/windows95/icons/print.png?v=9");
--win95-icon-refresh: url("/webapp-theme-assets/windows95/icons/refresh.png?v=9");
--win95-icon-save: url("/webapp-theme-assets/windows95/icons/save.png?v=9");
--win95-icon-search: url("/webapp-theme-assets/windows95/icons/search.png?v=9");
--win95-icon-send: url("/webapp-theme-assets/windows95/icons/send.png?v=9");
--win95-icon-settings: url("/webapp-theme-assets/windows95/icons/settings.png?v=9");
--win95-icon-shield: url("/webapp-theme-assets/windows95/icons/shield.png?v=9");
--win95-icon-smartphone: url("/webapp-theme-assets/windows95/icons/smartphone.png?v=9");
--win95-icon-sliders: url("/webapp-theme-assets/windows95/icons/sliders.png?v=9");
--win95-icon-sparkles: url("/webapp-theme-assets/windows95/icons/sparkles.png?v=9");
--win95-icon-tag: url("/webapp-theme-assets/windows95/icons/tag.png?v=9");
--win95-icon-ticket: url("/webapp-theme-assets/windows95/icons/ticket.png?v=9");
--win95-icon-trash: url("/webapp-theme-assets/windows95/icons/trash.png?v=9");
--win95-icon-user: url("/webapp-theme-assets/windows95/icons/user.png?v=9");
--win95-icon-users: url("/webapp-theme-assets/windows95/icons/users.png?v=9");
--win95-icon-warning: url("/webapp-theme-assets/windows95/icons/warning.png?v=9");
--win95-icon-x: url("/webapp-theme-assets/windows95/icons/x.png?v=9");
}
.theme-key-windows95.app-shell {
@@ -115,17 +131,23 @@
.theme-key-windows95 svg.lucide-house,
.theme-key-windows95 svg.lucide-info,
.theme-key-windows95 svg.lucide-key,
.theme-key-windows95 svg.lucide-life-buoy,
.theme-key-windows95 svg.lucide-layout-dashboard,
.theme-key-windows95 svg.lucide-lock,
.theme-key-windows95 svg.lucide-lock-keyhole,
.theme-key-windows95 svg.lucide-mail,
.theme-key-windows95 svg.lucide-megaphone,
.theme-key-windows95 svg.lucide-message-square,
.theme-key-windows95 svg.lucide-message-square-plus,
.theme-key-windows95 svg.lucide-monitor,
.theme-key-windows95 svg.lucide-paintbrush,
.theme-key-windows95 svg.lucide-plus,
.theme-key-windows95 svg.lucide-qr-code,
.theme-key-windows95 svg.lucide-refresh-cw,
.theme-key-windows95 svg.lucide-save,
.theme-key-windows95 svg.lucide-search,
.theme-key-windows95 svg.lucide-send,
.theme-key-windows95 svg.lucide-share-2,
.theme-key-windows95 svg.lucide-settings,
.theme-key-windows95 svg.lucide-shield,
.theme-key-windows95 svg.lucide-sliders,
@@ -231,10 +253,15 @@
--win95-button-icon: var(--win95-icon-key);
}
.theme-key-windows95 svg.lucide-life-buoy {
--win95-button-icon: var(--win95-icon-help);
}
.theme-key-windows95 svg.lucide-layout-dashboard {
--win95-button-icon: var(--win95-icon-dashboard);
}
.theme-key-windows95 svg.lucide-lock,
.theme-key-windows95 svg.lucide-lock-keyhole {
--win95-button-icon: var(--win95-icon-lock);
}
@@ -245,10 +272,15 @@
.theme-key-windows95 svg.lucide-mail,
.theme-key-windows95 svg.lucide-message-square,
.theme-key-windows95 svg.lucide-message-square-plus,
.theme-key-windows95 svg.lucide-send {
--win95-button-icon: var(--win95-icon-send);
}
.theme-key-windows95 svg.lucide-monitor {
--win95-button-icon: var(--win95-icon-dashboard);
}
.theme-key-windows95 svg.lucide-paintbrush {
--win95-button-icon: var(--win95-icon-paintbrush);
}
@@ -257,6 +289,10 @@
--win95-button-icon: var(--win95-icon-folder);
}
.theme-key-windows95 svg.lucide-qr-code {
--win95-button-icon: var(--win95-icon-key);
}
.theme-key-windows95 svg.lucide-refresh-cw {
--win95-button-icon: var(--win95-icon-refresh);
}
@@ -269,6 +305,10 @@
--win95-button-icon: var(--win95-icon-search);
}
.theme-key-windows95 svg.lucide-share-2 {
--win95-button-icon: var(--win95-icon-send);
}
.theme-key-windows95 svg.lucide-settings {
--win95-button-icon: var(--win95-icon-settings);
}
@@ -339,17 +379,23 @@
svg.lucide-house,
svg.lucide-info,
svg.lucide-key,
svg.lucide-life-buoy,
svg.lucide-layout-dashboard,
svg.lucide-lock,
svg.lucide-lock-keyhole,
svg.lucide-mail,
svg.lucide-megaphone,
svg.lucide-message-square,
svg.lucide-message-square-plus,
svg.lucide-monitor,
svg.lucide-paintbrush,
svg.lucide-plus,
svg.lucide-qr-code,
svg.lucide-refresh-cw,
svg.lucide-save,
svg.lucide-search,
svg.lucide-send,
svg.lucide-share-2,
svg.lucide-settings,
svg.lucide-shield,
svg.lucide-sliders,
@@ -390,17 +436,23 @@
svg.lucide-house,
svg.lucide-info,
svg.lucide-key,
svg.lucide-life-buoy,
svg.lucide-layout-dashboard,
svg.lucide-lock,
svg.lucide-lock-keyhole,
svg.lucide-mail,
svg.lucide-megaphone,
svg.lucide-message-square,
svg.lucide-message-square-plus,
svg.lucide-monitor,
svg.lucide-paintbrush,
svg.lucide-plus,
svg.lucide-qr-code,
svg.lucide-refresh-cw,
svg.lucide-save,
svg.lucide-search,
svg.lucide-send,
svg.lucide-share-2,
svg.lucide-settings,
svg.lucide-shield,
svg.lucide-sliders,
@@ -438,11 +490,33 @@
.theme-key-windows95 .tariff-action-card,
.theme-key-windows95 .tariff-warning-card,
.theme-key-windows95 .topup-carryover-note,
.theme-key-windows95 .input,
.theme-key-windows95 .subscription-purchase-description,
.theme-key-windows95 .dialog-card,
.theme-key-windows95 .language-select-content,
.theme-key-windows95 .bottom-nav,
.theme-key-windows95 .toast {
.theme-key-windows95 .toast,
.theme-key-windows95 .support-overview-card,
.theme-key-windows95 .support-list-card,
.theme-key-windows95 .support-ticket-card,
.theme-key-windows95 .support-conversation-card,
.theme-key-windows95 .support-new-ticket-button,
.theme-key-windows95 .support-create-panel,
.theme-key-windows95 .support-select-trigger,
.theme-key-windows95 .support-select-content,
.theme-key-windows95 .support-status-tabs-list,
.theme-key-windows95 .support-user-ticket-skeleton,
.theme-key-windows95 .ticket-card,
.theme-key-windows95 .support-empty-state,
.theme-key-windows95 .support-message-scroll,
.theme-key-windows95 .ticket-message-avatar,
.theme-key-windows95 .ticket-message-bubble,
.theme-key-windows95 .ticket-composer,
.theme-key-windows95 .install-platform-trigger,
.theme-key-windows95 .install-app-button,
.theme-key-windows95 .install-step,
.theme-key-windows95 .install-subscription-card,
.theme-key-windows95 .install-qr-wrap,
.theme-key-windows95 .install-loading {
border-width: 2px;
border-style: solid;
border-color: #ffffff #404040 #404040 #ffffff;
@@ -463,6 +537,9 @@
}
body:has(.theme-key-windows95) .language-select-content,
body:has(.theme-key-windows95) .support-select-content,
body:has(.theme-key-windows95) .install-platform-content,
body:has(.theme-key-windows95) .field-error-tooltip,
body:has(.theme-key-windows95) .admin-select-content {
border-width: 2px;
border-style: solid;
@@ -476,10 +553,27 @@ body:has(.theme-key-windows95) .admin-select-content {
}
body:has(.theme-key-windows95) .language-select-item,
body:has(.theme-key-windows95) .support-select-item,
body:has(.theme-key-windows95) .install-platform-item,
body:has(.theme-key-windows95) .admin-select-item {
border-radius: 0 !important;
}
body:has(.theme-key-windows95) .support-select-item[data-highlighted],
body:has(.theme-key-windows95) .support-select-item[data-selected],
body:has(.theme-key-windows95) .install-platform-item[data-highlighted],
body:has(.theme-key-windows95) .install-platform-item[data-selected] {
background: #000080;
color: #ffffff !important;
}
body:has(.theme-key-windows95) .support-select-item[data-highlighted] svg,
body:has(.theme-key-windows95) .support-select-item[data-selected] svg,
body:has(.theme-key-windows95) .install-platform-item[data-highlighted] svg,
body:has(.theme-key-windows95) .install-platform-item[data-selected] svg {
filter: brightness(0) invert(1);
}
.theme-key-windows95 .card::before,
.theme-key-windows95 .dialog-card::before {
content: "";
@@ -496,7 +590,13 @@ body:has(.theme-key-windows95) .admin-select-item {
.theme-key-windows95 .btn,
.theme-key-windows95 .language-select-trigger,
.theme-key-windows95 .bottom-nav button {
.theme-key-windows95 .bottom-nav button,
.theme-key-windows95 .link-button,
.theme-key-windows95 .support-new-ticket-button,
.theme-key-windows95 .support-select-trigger,
.theme-key-windows95 .support-status-tabs-trigger,
.theme-key-windows95 .install-platform-trigger,
.theme-key-windows95 .install-app-button {
min-height: 34px;
border: 2px solid;
border-color: #ffffff #404040 #404040 #ffffff;
@@ -557,11 +657,216 @@ body:has(.theme-key-windows95) .admin-select-item {
.theme-key-windows95 .bottom-nav button.active,
.theme-key-windows95 .period-card.active,
.theme-key-windows95 .method-card.active,
.theme-key-windows95 .option-row.active {
.theme-key-windows95 .option-row.active,
.theme-key-windows95 .support-new-ticket-button.active,
.theme-key-windows95 .support-status-tabs-trigger[data-state="active"],
.theme-key-windows95 .support-select-item[data-highlighted],
.theme-key-windows95 .support-select-item[data-selected],
.theme-key-windows95 .install-app-button.active {
background: var(--accent);
color: #ffffff;
}
/* ---------- New webapp surfaces: support, purchase info, password login ---------- */
.theme-key-windows95 .trial-offer-card,
.theme-key-windows95 .trial-activation-card,
.theme-key-windows95 .activation-success-dialog,
.theme-key-windows95 .subscription-purchase-description,
.theme-key-windows95 .support-create-panel,
.theme-key-windows95 .ticket-composer,
.theme-key-windows95 .support-message-scroll {
background: #c0c0c0;
}
.theme-key-windows95 .trial-card-facts span,
.theme-key-windows95 .trial-activation-facts div {
border: 2px solid;
border-color: #404040 #ffffff #ffffff #404040;
background: #dfdfdf;
box-shadow:
inset 1px 1px 0 #808080,
inset -1px -1px 0 #ffffff;
}
.theme-key-windows95 .dialog-title-icon {
color: var(--accent);
}
.theme-key-windows95 .support-heading-icon,
.theme-key-windows95 .support-new-ticket-icon,
.theme-key-windows95 .support-empty-state svg,
.theme-key-windows95 .ticket-card-title svg {
color: var(--accent);
}
.theme-key-windows95 .password-switch-divider,
.theme-key-windows95 .or-line span {
height: 2px;
background: #808080;
border-bottom: 1px solid #ffffff;
}
.theme-key-windows95 .link-button {
min-height: 28px;
padding: 2px 8px;
color: var(--text);
cursor: pointer;
}
.theme-key-windows95 .link-button:hover:not(:disabled),
.theme-key-windows95 .support-status-tabs-trigger:hover {
background: color-mix(in srgb, var(--accent) 16%, #c0c0c0);
}
.theme-key-windows95 .support-status-tabs-trigger b,
.theme-key-windows95 .ticket-status-badge,
.theme-key-windows95 .ticket-priority-badge,
.theme-key-windows95 .ticket-message-role-badge {
border: 1px solid #000000;
background: #c0c0c0;
color: #000000;
box-shadow: none;
}
.theme-key-windows95 .ticket-card::before {
inset: 0 auto 0 0;
width: 4px;
border-radius: 0;
background: var(--ticket-accent, var(--accent));
}
@media (min-width: 1024px) {
.theme-key-windows95 .support-list-card {
grid-template-rows: auto auto minmax(0, 1fr);
}
.theme-key-windows95 .support-conversation-card {
grid-template-rows: auto minmax(0, 1fr) auto;
}
.theme-key-windows95 .support-conversation-card--loading {
grid-template-rows: auto minmax(0, 1fr);
}
}
.theme-key-windows95 .ticket-message-row,
.theme-key-windows95 .ticket-message-row--admin,
.theme-key-windows95 .ticket-message-row--user,
.theme-key-windows95 .ticket-message-row--system,
.theme-key-windows95 .ticket-message-row--internal {
--ticket-bubble-bg: #ffffff;
--ticket-bubble-border: #808080;
--ticket-bubble-text: #000000;
}
.theme-key-windows95 .ticket-message-row--outgoing {
--ticket-bubble-bg: #ffffe1;
}
.theme-key-windows95 .ticket-message-bubble,
.theme-key-windows95 .ticket-message-row--outgoing .ticket-message-bubble {
border-radius: 0;
color: #000000;
}
.theme-key-windows95 .ticket-composer:focus-within,
.theme-key-windows95 .support-select-trigger:focus-visible,
.theme-key-windows95 .install-platform-trigger:focus-visible,
.theme-key-windows95 .install-platform-trigger[data-state="open"],
.theme-key-windows95 .install-app-button:focus-visible,
.theme-key-windows95 .ticket-card:focus-visible,
.theme-key-windows95 .support-status-tabs-trigger:focus-visible {
outline: 1px dotted #000000;
outline-offset: -4px;
box-shadow:
inset 1px 1px 0 #dfdfdf,
inset -1px -1px 0 #808080;
}
.theme-key-windows95 .auth-card {
transition: none;
}
/* ---------- Install guide theme surfaces ---------- */
.theme-key-windows95 .install-platform-trigger,
.theme-key-windows95 .install-app-button {
background: #c0c0c0;
color: #000000;
transition: none;
transform: none;
}
.theme-key-windows95 .install-platform-trigger:hover,
.theme-key-windows95 .install-app-button:hover:not(:disabled):not(.active) {
background: #dfdfdf;
transform: none;
}
.theme-key-windows95 .install-app-button.active,
.theme-key-windows95 .install-app-button.active:hover:not(:disabled) {
background: var(--accent);
color: #ffffff;
border-color: #404040 #ffffff #ffffff #404040;
box-shadow:
inset 1px 1px 0 #000000,
inset -1px -1px 0 #dfdfdf;
transform: none;
}
.theme-key-windows95 .install-step,
.theme-key-windows95 .install-subscription-card,
.theme-key-windows95 .install-qr-wrap,
.theme-key-windows95 .install-loading {
background: #c0c0c0;
transition: none;
}
.theme-key-windows95 .install-step:hover,
.theme-key-windows95 .install-subscription-card:hover {
transform: none;
box-shadow:
inset 1px 1px 0 #dfdfdf,
inset -1px -1px 0 #808080;
}
.theme-key-windows95 .install-step-icon,
.theme-key-windows95 .install-subscription-header-icon {
border: 2px solid;
border-color: #ffffff #404040 #404040 #ffffff;
background: #dfdfdf;
color: var(--accent);
box-shadow:
inset 1px 1px 0 #ffffff,
inset -1px -1px 0 #808080;
}
body:has(.theme-key-windows95) .install-platform-content {
background: #c0c0c0;
}
body:has(.theme-key-windows95) .install-platform-item[data-highlighted],
body:has(.theme-key-windows95) .install-platform-item[data-selected] {
background: var(--accent);
color: #ffffff !important;
}
.theme-key-windows95 .install-qr-divider {
color: #404040;
opacity: 1;
}
.theme-key-windows95 .install-feature-star.attention-dot {
background: #ffff00 !important;
border: 1px solid #000000;
box-shadow: 1px 1px 0 #000000;
}
.theme-key-windows95 .install-loading .ui-spinner {
color: var(--accent);
}
.theme-key-windows95 .card-heading-accent,
.theme-key-windows95 .brand-row strong,
.theme-key-windows95 .login-brand h1,
@@ -746,6 +1051,7 @@ body:has(.theme-key-windows95) .admin-select-item {
.theme-key-windows95 .admin-screen-wrap textarea,
.theme-key-windows95 .admin-dialog textarea,
.theme-key-windows95 .input,
.theme-key-windows95 .textarea,
.theme-key-windows95 input:not([type="checkbox"]):not([type="radio"]):not([type="range"]),
.theme-key-windows95 textarea,
.theme-key-windows95 select {
@@ -867,7 +1173,8 @@ body:has(.theme-key-windows95) .admin-select-item {
.theme-key-windows95 input::placeholder,
.theme-key-windows95 textarea::placeholder,
.theme-key-windows95 .input::placeholder {
.theme-key-windows95 .input::placeholder,
.theme-key-windows95 .textarea::placeholder {
color: #808080;
}
@@ -888,6 +1195,9 @@ body:has(.theme-key-windows95) .admin-select-item {
.theme-key-windows95 .admin-revenue-period-btn:focus-visible,
.theme-key-windows95 .admin-mobile-toggle:focus-visible,
.theme-key-windows95 .language-select-trigger:focus-visible,
.theme-key-windows95 .install-platform-trigger:focus-visible,
.theme-key-windows95 .install-platform-trigger[data-state="open"],
.theme-key-windows95 .install-app-button:focus-visible,
.theme-key-windows95 .bottom-nav button:focus-visible {
outline: 1px dotted #000000;
outline-offset: -4px;
@@ -898,6 +1208,7 @@ body:has(.theme-key-windows95) .admin-select-item {
.theme-key-windows95 .admin-screen-wrap textarea:focus,
.theme-key-windows95 .admin-dialog textarea:focus,
.theme-key-windows95 .input:focus,
.theme-key-windows95 .textarea:focus,
.theme-key-windows95 input:not([type="checkbox"]):not([type="radio"]):not([type="range"]):focus,
.theme-key-windows95 textarea:focus,
.theme-key-windows95 select:focus {
@@ -944,6 +1255,7 @@ body:has(.theme-key-windows95) .admin-select-item {
.theme-key-windows95 .admin-nav-item.active svg.lucide,
.theme-key-windows95 .admin-tabs-trigger[data-state="active"] svg.lucide,
.theme-key-windows95 .admin-revenue-period-btn.is-active svg.lucide,
.theme-key-windows95 .install-app-button.active svg.lucide,
.theme-key-windows95 .admin-header svg.lucide {
filter: brightness(0) invert(1);
}
@@ -961,7 +1273,6 @@ body:has(.theme-key-windows95) .admin-select-item {
.theme-key-windows95 svg.lucide-map,
.theme-key-windows95 svg.lucide-menu,
.theme-key-windows95 svg.lucide-mouse-pointer-click,
.theme-key-windows95 svg.lucide-qr-code,
.theme-key-windows95 svg.lucide-radio,
.theme-key-windows95 svg.lucide-repeat-2,
.theme-key-windows95 svg.lucide-server,
@@ -9,7 +9,7 @@
"use_primary_accent": false,
"use_in_admin": true,
"css_file": "style.css",
"assets_version": 6,
"assets_version": 11,
"tokens": {
"color_scheme": "light",
"style_preset": "win95"
+19 -39
View File
@@ -7,6 +7,7 @@ from aiogram.webhook.aiohttp_server import SimpleRequestHandler, setup_applicati
from aiohttp import web
from sqlalchemy.orm import sessionmaker
from bot.payment_providers import iter_provider_specs, iter_service_keys
from config.settings import Settings
@@ -29,19 +30,15 @@ def _inject_shared_instances(
app["settings"] = settings
app["async_session_factory"] = async_session_factory
app["i18n"] = dp.get("i18n_instance")
for key in (
"yookassa_service",
"lknpd_service",
shared_keys = [
"subscription_service",
"referral_service",
"panel_service",
"stars_service",
"freekassa_service",
"cryptopay_service",
"panel_webhook_service",
"platega_service",
"severpay_service",
):
"lknpd_service",
*iter_service_keys(),
]
for key in shared_keys:
if hasattr(dp, "workflow_data") and key in dp.workflow_data: # type: ignore
app[key] = dp.workflow_data[key] # type: ignore
@@ -90,38 +87,21 @@ async def build_and_start_web_app(
f"Telegram webhook route configured at: [POST] {telegram_webhook_path} (relative to base URL)" # noqa: E501
)
from bot.handlers.user.payment import yookassa_webhook_route
from bot.services.crypto_pay_service import cryptopay_webhook_route
from bot.services.freekassa_service import freekassa_webhook_route
from bot.services.panel_webhook_service import panel_webhook_route
from bot.services.platega_service import platega_webhook_route
from bot.services.severpay_service import severpay_webhook_route
cp_path = settings.cryptopay_webhook_path
if cp_path.startswith("/"):
app.router.add_post(cp_path, cryptopay_webhook_route)
logging.info(f"CryptoPay webhook route configured at: [POST] {cp_path}")
fk_path = settings.freekassa_webhook_path
if fk_path.startswith("/"):
app.router.add_post(fk_path, freekassa_webhook_route)
logging.info(f"FreeKassa webhook route configured at: [POST] {fk_path}")
pg_path = settings.platega_webhook_path
if pg_path.startswith("/"):
app.router.add_post(pg_path, platega_webhook_route)
logging.info(f"Platega webhook route configured at: [POST] {pg_path}")
sp_path = settings.severpay_webhook_path
if sp_path.startswith("/"):
app.router.add_post(sp_path, severpay_webhook_route)
logging.info(f"SeverPay webhook route configured at: [POST] {sp_path}")
# YooKassa webhook (register only when base URL present and path configured)
yk_path = settings.yookassa_webhook_path
if settings.WEBHOOK_BASE_URL and yk_path and yk_path.startswith("/"):
app.router.add_post(yk_path, yookassa_webhook_route)
logging.info(f"YooKassa webhook route configured at: [POST] {yk_path}")
registered_webhook_paths: set[str] = set()
for spec in iter_provider_specs():
webhook_route = spec.load_webhook_route()
if not spec.webhook_path or not webhook_route:
continue
if spec.webhook_requires_base_url and not settings.WEBHOOK_BASE_URL:
continue
path = spec.webhook_path(settings)
if not path or not path.startswith("/") or path in registered_webhook_paths:
continue
registered_webhook_paths.add(path)
app.router.add_post(path, webhook_route)
logging.info("%s webhook route configured at: [POST] %s", spec.label, path)
panel_path = settings.panel_webhook_path
if panel_path.startswith("/"):
+42 -9
View File
@@ -21,7 +21,6 @@ from typing import Any, Dict, List, Optional, Tuple
from urllib.parse import parse_qsl, quote, urlencode, urlsplit, urlunsplit
from aiogram import Bot, Dispatcher
from aiogram.types import LabeledPrice
from aiohttp import ClientSession, ClientTimeout, web
from pydantic import BaseModel, ConfigDict, EmailStr, ValidationError, constr, field_validator
from sqlalchemy.ext.asyncio import AsyncSession
@@ -42,22 +41,21 @@ from bot.app.web.webapp_auth import (
verify_telegram_oauth_nonce,
verify_webapp_session_token,
)
from bot.infra.redis import cache_get_json, cache_set_json, get_redis, redis_key
from bot.services.crypto_pay_service import CryptoPayService
from bot.infra.redis import cache_delete, cache_get_json, cache_set_json, get_redis, redis_key
from bot.services.email_auth_service import EmailAuthService, normalize_email
from bot.services.email_templates import render_account_merged
from bot.services.freekassa_service import FreeKassaService
from bot.services.platega_service import PlategaService
from bot.services.promo_code_service import PromoCodeService
from bot.services.referral_service import ReferralService
from bot.services.severpay_service import SeverPayService
from bot.services.subscription_service import SubscriptionService
from bot.services.yookassa_service import YooKassaService
from bot.utils.config_link import prepare_config_links
from bot.utils.request_security import parse_ip_entries, request_client_ip
from bot.utils.text_sanitizer import sanitize_display_name, sanitize_username
from bot.utils.text_sanitizer import (
panel_description_from_profile,
sanitize_display_name,
sanitize_username,
)
from config.settings import Settings
from db.dal import payment_dal, subscription_dal, user_dal
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
@@ -65,6 +63,7 @@ logger = logging.getLogger(__name__)
TEMPLATE_PATH = Path(__file__).resolve().parents[1] / "templates" / "subscription_webapp.html"
ASSET_DIR = TEMPLATE_PATH.parent
APP_DEEPLINK_TEMPLATE_PATH = ASSET_DIR / "open_app_gateway.html"
APP_ROOT = Path(__file__).resolve().parents[5]
WEBAPP_LOGO_PROXY_PATH = "/webapp-logo"
WEBAPP_LOGO_CACHE_DIR = APP_ROOT / "data" / "webapp-logo"
@@ -72,6 +71,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 -->"
@@ -102,6 +107,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",
@@ -109,6 +141,7 @@ WEBAPP_CSRF_EXEMPT_PATHS = {
"/api/auth/email/request",
"/api/auth/email/verify",
"/api/auth/email/magic",
"/api/auth/email/password",
"/api/auth/logout",
}
+144 -43
View File
@@ -1,6 +1,15 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
from bot.app.web.webapp.cache_helpers import webapp_cached_user_payload
from .auth import (
_hash_email_password,
_notify_account_merged,
_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:
user_id = _require_user_id(request)
@@ -105,7 +114,8 @@ async def account_email_verify_route(request: web.Request) -> web.Response:
)
current_user.email = email
current_user.email_verified_at = datetime.now(timezone.utc)
await _sync_panel_identity_for_user(request, current_user)
if not merge_notice:
await _sync_panel_identity_for_user(request, current_user)
await session.commit()
final_user_id = int(current_user.user_id)
final_telegram_id = _telegram_id_for_user(current_user)
@@ -118,28 +128,13 @@ async def account_email_verify_route(request: web.Request) -> web.Response:
merge_end_date = (
datetime.fromisoformat(merge_end_date_raw) if merge_end_date_raw else None
)
await _sync_panel_identity_for_user(
await _sync_merged_panel_identity_for_user(
request,
current_user,
source_panel_uuid=source_panel_uuid,
final_panel_uuid=final_panel_uuid,
expire_at=merge_end_date,
)
# Best-effort cleanup of the removed panel account after the DB merge.
if source_panel_uuid and final_panel_uuid and source_panel_uuid != final_panel_uuid:
subscription_service: SubscriptionService = request.app.get(
"subscription_service"
)
if subscription_service and subscription_service.panel_service:
try:
await subscription_service.panel_service.delete_user_from_panel(
source_panel_uuid,
log_response=False,
)
except Exception as exc:
logger.warning(
"Failed to delete merged source panel user %s: %s",
source_panel_uuid,
exc,
)
email_service: EmailAuthService = request.app.get("email_auth_service")
if email_service and final_email:
@@ -173,6 +168,17 @@ async def account_email_verify_route(request: web.Request) -> web.Response:
logger.exception("Email account link failed")
return _json_error(500, "link_failed", "Link failed")
await _invalidate_webapp_user_caches(settings, user_id, final_user_id, include_devices=True)
if merge_notice:
await _notify_account_merged(
request,
settings,
merge_notice=merge_notice,
email=final_email,
telegram_id=final_telegram_id,
username=final_username,
first_name=final_first_name,
)
if should_notify_email_linked:
try:
from bot.services.notification_service import NotificationService
@@ -201,6 +207,84 @@ async def account_email_verify_route(request: web.Request) -> web.Response:
return _build_webapp_auth_response(settings, response_payload, token=token)
async def account_password_request_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
settings: Settings = request.app["settings"]
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user or db_user.is_banned:
return _json_error(403, "access_denied", "Access denied")
if not db_user.email or not db_user.email_verified_at:
return _json_error(400, "email_not_linked", "Email is not linked")
email = db_user.email
lang = _normalize_language(db_user.language_code or settings.DEFAULT_LANGUAGE)
return await _request_email_code(
request,
email=email,
purpose="set_password",
language_code=lang,
target_user_id=user_id,
)
async def account_password_confirm_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
payload = await _read_json(request)
password_payload, validation_error = _validate_model_payload(WebAppSetPasswordPayload, payload)
if validation_error:
return validation_error
if password_payload.password != password_payload.password_confirm:
return _json_error(400, "password_mismatch", "Passwords do not match")
settings: Settings = request.app["settings"]
email_service: EmailAuthService = request.app["email_auth_service"]
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 _json_error(403, "access_denied", "Access denied")
if not db_user.email or not db_user.email_verified_at:
await session.rollback()
return _json_error(400, "email_not_linked", "Email is not linked")
verify_result = await email_service.verify_code(
session,
email=db_user.email,
purpose="set_password",
code=str(password_payload.code or ""),
target_user_id=user_id,
)
if not verify_result.ok:
await session.commit()
status = 429 if verify_result.error == "rate_limited" else 400
return web.json_response(
{
"ok": False,
"error": verify_result.error or "invalid_code",
"retry_after": verify_result.retry_after,
"message": "Invalid code",
},
status=status,
)
db_user.password_hash = _hash_email_password(str(password_payload.password))
db_user.password_set_at = datetime.now(timezone.utc)
await session.flush()
await session.commit()
except Exception:
await session.rollback()
logger.exception("Email password setup failed")
return _json_error(500, "password_setup_failed", "Password setup failed")
await _invalidate_webapp_user_caches(settings, user_id)
return web.json_response({"ok": True, "password_auth_enabled": True})
async def account_telegram_link_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
settings: Settings = request.app["settings"]
@@ -262,28 +346,13 @@ async def account_telegram_link_route(request: web.Request) -> web.Response:
merge_end_date = (
datetime.fromisoformat(merge_end_date_raw) if merge_end_date_raw else None
)
await _sync_panel_identity_for_user(
await _sync_merged_panel_identity_for_user(
request,
db_user,
source_panel_uuid=source_panel_uuid,
final_panel_uuid=final_panel_uuid,
expire_at=merge_end_date,
)
# Best-effort cleanup of the removed panel account after the DB merge.
if source_panel_uuid and final_panel_uuid and source_panel_uuid != final_panel_uuid:
subscription_service: SubscriptionService = request.app.get(
"subscription_service"
)
if subscription_service and subscription_service.panel_service:
try:
await subscription_service.panel_service.delete_user_from_panel(
source_panel_uuid,
log_response=False,
)
except Exception as exc:
logger.warning(
"Failed to delete merged source panel user %s: %s",
source_panel_uuid,
exc,
)
email_service: EmailAuthService = request.app.get("email_auth_service")
if email_service and final_email:
@@ -317,6 +386,17 @@ async def account_telegram_link_route(request: web.Request) -> web.Response:
logger.exception("Telegram account link failed")
return _json_error(500, "link_failed", "Link failed")
await _invalidate_webapp_user_caches(settings, user_id, final_user_id, include_devices=True)
if merge_notice:
await _notify_account_merged(
request,
settings,
merge_notice=merge_notice,
email=final_email,
telegram_id=final_telegram_id,
username=final_username,
first_name=final_first_name,
)
if should_notify_telegram_linked and final_telegram_id:
try:
from bot.services.notification_service import NotificationService
@@ -337,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,
@@ -351,12 +433,24 @@ async def account_telegram_link_route(request: web.Request) -> web.Response:
async def me_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
settings: Settings = request.app["settings"]
cache_key = redis_key(settings, "cache", "webapp", "me", user_id)
cached = await cache_get_json(settings, cache_key)
if cached:
return web.json_response({"ok": True, **cached})
data = await _build_user_payload(request, user_id)
await cache_set_json(settings, cache_key, data, settings.WEBAPP_ME_CACHE_TTL_SECONDS)
fresh = str(request.query.get("fresh") or "").strip().lower() in {
"1",
"true",
"yes",
"on",
}
if fresh:
await _invalidate_webapp_user_caches(settings, user_id)
data = await _build_user_payload(request, user_id)
return web.json_response({"ok": True, **data})
data = await webapp_cached_user_payload(
settings,
"me",
user_id,
int(getattr(settings, "WEBAPP_ME_CACHE_TTL_SECONDS", 15) or 0),
lambda: _build_user_payload(request, user_id),
)
return web.json_response({"ok": True, **data})
@@ -391,12 +485,18 @@ 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:
return validation_error
language = _normalize_language(str(language_payload.language or ""))
i18n = request.app.get("i18n")
if i18n and hasattr(i18n, "reload_overrides_from_file"):
i18n.reload_overrides_from_file()
if i18n and language not in getattr(i18n, "locales_data", {}):
return _json_error(400, "unsupported_language", "Unsupported language")
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
db_user = await user_dal.get_user_by_id(session, user_id)
@@ -409,6 +509,7 @@ async def account_language_route(request: web.Request) -> web.Response:
await session.flush()
await session.commit()
await _invalidate_webapp_user_caches(settings, user_id)
return web.json_response({"ok": True, "language": language})
+11 -6
View File
@@ -1,5 +1,6 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
from .guides import warm_subscription_guides_config
def create_subscription_webapp_application(
@@ -20,10 +21,12 @@ def create_subscription_webapp_application(
app["settings"] = settings
app["async_session_factory"] = async_session_factory
app["i18n"] = dp.get("i18n_instance")
app["email_auth_service"] = EmailAuthService(settings)
app["email_auth_service"] = EmailAuthService(settings, app["i18n"])
app["webapp_logo_cache"] = None
app["webapp_logo_cache_lock"] = asyncio.Lock()
app["webapp_settings_cache"] = {"ts": 0.0, "data": {}}
app["subscription_guides_config_cache"] = {"fingerprint": None, "status": None}
app["subscription_guides_config_lock"] = asyncio.Lock()
app["webapp_rate_limit_buckets"] = {}
app["webapp_rate_limit_lock"] = asyncio.Lock()
@@ -31,6 +34,7 @@ def create_subscription_webapp_application(
await _ensure_shared_http_session()
await _warm_webapp_logo_cache(app_obj)
await _warm_webapp_animated_emoji_cache(app_obj)
await warm_subscription_guides_config(app_obj)
async def _shutdown(app_obj: web.Application) -> None:
await _close_shared_http_session()
@@ -38,16 +42,17 @@ def create_subscription_webapp_application(
app.on_startup.append(_startup)
app.on_shutdown.append(_shutdown)
from bot.payment_providers import iter_service_keys
for key in (
"subscription_service",
"yookassa_service",
"freekassa_service",
"cryptopay_service",
"platega_service",
"severpay_service",
"promo_code_service",
"referral_service",
"support_service",
"notification_service",
"email_auth_service",
"panel_service",
*iter_service_keys(),
):
if hasattr(dp, "workflow_data") and key in dp.workflow_data: # type: ignore[attr-defined]
app[key] = dp.workflow_data[key] # type: ignore[index]
File diff suppressed because it is too large Load Diff
+344 -29
View File
@@ -1,5 +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]:
@@ -132,6 +134,58 @@ def _urlsafe_sha256(value: str) -> str:
return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
PASSWORD_HASH_ALGORITHM = "pbkdf2_sha256"
PASSWORD_HASH_ITERATIONS = 260_000
def _password_hash_b64(value: bytes) -> str:
return base64.urlsafe_b64encode(value).decode("ascii").rstrip("=")
def _password_hash_unb64(value: str) -> bytes:
padding = "=" * (-len(value) % 4)
return base64.urlsafe_b64decode((value + padding).encode("ascii"))
def _hash_email_password(password: str) -> str:
salt = secrets.token_bytes(18)
digest = hashlib.pbkdf2_hmac(
"sha256",
password.encode("utf-8"),
salt,
PASSWORD_HASH_ITERATIONS,
)
return "$".join(
[
PASSWORD_HASH_ALGORITHM,
str(PASSWORD_HASH_ITERATIONS),
_password_hash_b64(salt),
_password_hash_b64(digest),
]
)
def _verify_email_password(password: str, stored_hash: Optional[str]) -> bool:
if not stored_hash:
return False
try:
algorithm, iterations_raw, salt_raw, digest_raw = stored_hash.split("$", 3)
if algorithm != PASSWORD_HASH_ALGORITHM:
return False
iterations = int(iterations_raw)
salt = _password_hash_unb64(salt_raw)
expected_digest = _password_hash_unb64(digest_raw)
actual_digest = hashlib.pbkdf2_hmac(
"sha256",
password.encode("utf-8"),
salt,
iterations,
)
except Exception:
return False
return hmac.compare_digest(actual_digest, expected_digest)
async def _exchange_telegram_oauth_code(
request: web.Request,
*,
@@ -286,10 +340,20 @@ async def telegram_oauth_callback_route(request: web.Request) -> web.Response:
redirect_path = "/settings" if purpose == "link" else "/"
async_session_factory: sessionmaker = request.app["async_session_factory"]
final_user_id: Optional[int] = None
source_user_id_for_cache: Optional[int] = None
linked_user_for_panel: Optional[User] = None
link_source_panel_uuid: Optional[str] = None
link_final_panel_uuid: Optional[str] = None
link_merge_notice: Optional[Dict[str, Any]] = None
async with async_session_factory() as session:
try:
if purpose == "link":
current_user_id = int(state.get("user_id") or 0)
source_user_id_for_cache = current_user_id
current_user_before_link = await user_dal.get_user_by_id(session, current_user_id)
link_source_panel_uuid = (
current_user_before_link.panel_user_uuid if current_user_before_link else None
)
db_user = await _link_telegram_to_user(
request,
session,
@@ -297,6 +361,16 @@ async def telegram_oauth_callback_route(request: web.Request) -> web.Response:
telegram_user=telegram_user,
settings=settings,
)
if int(db_user.user_id) != current_user_id:
link_final_panel_uuid = db_user.panel_user_uuid
link_merge_notice = await _build_account_merge_notice(
session,
merged_user=db_user,
source_user_id=current_user_id,
source_panel_uuid=link_source_panel_uuid,
settings=settings,
)
linked_user_for_panel = db_user
else:
db_user = await _ensure_user_from_telegram(
session,
@@ -334,6 +408,38 @@ async def telegram_oauth_callback_route(request: web.Request) -> web.Response:
logger.exception("Telegram OAuth callback failed")
raise redirect(redirect_path, "failed")
await _invalidate_webapp_user_caches(settings, final_user_id, include_devices=True)
if source_user_id_for_cache and source_user_id_for_cache != final_user_id:
await _invalidate_webapp_user_caches(
settings,
source_user_id_for_cache,
final_user_id,
include_devices=True,
)
if purpose == "link" and link_merge_notice and linked_user_for_panel:
merge_end_date_raw = link_merge_notice.get("final_end_date")
merge_end_date = datetime.fromisoformat(merge_end_date_raw) if merge_end_date_raw else None
await _sync_merged_panel_identity_for_user(
request,
linked_user_for_panel,
source_panel_uuid=link_source_panel_uuid,
final_panel_uuid=link_final_panel_uuid,
expire_at=merge_end_date,
)
await _notify_account_merged(
request,
settings,
merge_notice=link_merge_notice,
email=linked_user_for_panel.email,
telegram_id=_telegram_id_for_user(linked_user_for_panel),
username=linked_user_for_panel.username,
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)
@@ -428,6 +534,8 @@ async def auth_token_route(request: web.Request) -> web.Response:
logger.exception("WebApp auth failed")
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)
@@ -438,6 +546,114 @@ async def logout_route(request: web.Request) -> web.Response:
return response
def _password_login_failure_response(
*,
status: int = 401,
retry_after: Optional[int] = None,
) -> web.Response:
payload: Dict[str, Any] = {
"ok": False,
"error": "password_login_failed",
"fallback": "email_code",
"message": "Password login failed",
}
if retry_after is not None:
payload["retry_after"] = retry_after
return web.json_response(payload, status=status)
async def email_password_auth_route(request: web.Request) -> web.Response:
settings: Settings = request.app["settings"]
if not settings.email_auth_configured:
return _json_error(503, "email_auth_not_configured", "Email auth is not configured")
payload = await _read_json(request)
password_payload, validation_error = _validate_model_payload(
WebAppEmailPasswordPayload,
payload,
)
if validation_error:
return validation_error
email = password_payload.email
password = str(password_payload.password or "")
now = datetime.now(timezone.utc)
async_session_factory: sessionmaker = request.app["async_session_factory"]
authenticated_user_id: Optional[int] = None
authenticated_telegram_id: Optional[int] = None
async with async_session_factory() as session:
try:
throttle = await security_dal.check_throttle(
session,
scope=security_dal.EMAIL_PASSWORD_LOGIN_SCOPE,
identifier=email,
now=now,
)
if throttle.locked:
await session.commit()
return _json_error(
429,
"rate_limited",
"Too many password attempts",
)
db_user = await user_dal.get_user_by_email(session, email)
password_ok = bool(
db_user
and db_user.email_verified_at
and db_user.password_hash
and _verify_email_password(password, db_user.password_hash)
)
if not password_ok:
throttle_result = await security_dal.record_throttle_failure(
session,
scope=security_dal.EMAIL_PASSWORD_LOGIN_SCOPE,
identifier=email,
max_failures=settings.BRUTE_FORCE_MAX_FAILURES,
window_seconds=settings.BRUTE_FORCE_WINDOW_SECONDS,
lock_seconds=settings.BRUTE_FORCE_LOCK_SECONDS,
now=now,
)
await session.commit()
if throttle_result.locked:
return _json_error(
429,
"rate_limited",
"Too many password attempts",
)
return _password_login_failure_response()
if db_user.is_banned:
await session.rollback()
return _json_error(403, "banned", "Access denied")
await security_dal.clear_throttle_state(
session,
scope=security_dal.EMAIL_PASSWORD_LOGIN_SCOPE,
identifier=email,
)
authenticated_user_id = int(db_user.user_id)
authenticated_telegram_id = _telegram_id_for_user(db_user)
await session.commit()
except Exception:
await session.rollback()
logger.exception("Email password auth failed")
return _json_error(500, "auth_failed", "Auth failed")
token = create_webapp_session_token(settings, int(authenticated_user_id))
return _build_webapp_auth_response(
settings,
{
"ok": True,
"user_id": int(authenticated_user_id),
"telegram_id": authenticated_telegram_id,
},
token=token,
)
async def email_auth_request_route(request: web.Request) -> web.Response:
settings: Settings = request.app["settings"]
payload = await _read_json(request)
@@ -534,6 +750,7 @@ async def email_auth_verify_route(request: web.Request) -> web.Response:
logger.exception("Email WebApp auth failed")
return _json_error(500, "auth_failed", "Auth failed")
await _invalidate_webapp_user_caches(settings, int(db_user.user_id), include_devices=True)
if created_user:
try:
from bot.services.notification_service import NotificationService
@@ -641,6 +858,7 @@ async def email_auth_magic_route(request: web.Request) -> web.Response:
logger.exception("Email magic-link auth failed")
return _json_error(500, "auth_failed", "Auth failed")
await _invalidate_webapp_user_caches(settings, int(db_user.user_id), include_devices=True)
if created_user and verified_email:
try:
from bot.services.notification_service import NotificationService
@@ -800,13 +1018,19 @@ def _telegram_id_for_user(user: User) -> Optional[int]:
def _panel_description_for_user(user: User) -> str:
lines = [
user.email or "",
user.username or "",
user.first_name or "",
user.last_name or "",
]
return "\n".join(line for line in lines if line).strip()
return panel_description_from_profile(
user.username,
user.first_name,
user.last_name,
)
def _telegram_photo_url_value(telegram_user: Dict[str, Any]) -> Optional[str]:
raw_value = telegram_user.get("photo_url")
if not raw_value:
return None
value = str(raw_value).strip()
return value or None
async def _sync_panel_identity_for_user(
@@ -821,23 +1045,33 @@ async def _sync_panel_identity_for_user(
if not subscription_service or not subscription_service.panel_service:
return False
payload: Dict[str, Any] = {
"description": _panel_description_for_user(user),
}
payload: Dict[str, Any] = {}
telegram_id = _telegram_id_for_user(user)
if telegram_id:
payload["telegramId"] = telegram_id
if user.email:
payload["email"] = user.email
if expire_at is not None:
if expire_at.tzinfo is None:
expire_at = expire_at.replace(tzinfo=timezone.utc)
payload["expireAt"] = expire_at.isoformat(timespec="milliseconds").replace("+00:00", "Z")
if expire_at > datetime.now(timezone.utc):
payload["status"] = "ACTIVE"
try:
await subscription_service.panel_service.update_user_details_on_panel(
updated_panel_user = await subscription_service.panel_service.update_user_details_on_panel(
user.panel_user_uuid,
payload,
log_response=False,
)
if not updated_panel_user or (
isinstance(updated_panel_user, dict) and updated_panel_user.get("error")
):
logger.warning(
"Panel identity update returned no success payload for user %s",
user.user_id,
)
return False
return True
except Exception as exc:
logger.warning(
@@ -848,6 +1082,53 @@ async def _sync_panel_identity_for_user(
return False
async def _delete_merged_source_panel_user(
request: web.Request,
*,
source_panel_uuid: Optional[str],
final_panel_uuid: Optional[str],
) -> bool:
if not source_panel_uuid or not final_panel_uuid or source_panel_uuid == final_panel_uuid:
return True
subscription_service: SubscriptionService = request.app.get("subscription_service")
if not subscription_service or not subscription_service.panel_service:
return False
try:
return bool(
await subscription_service.panel_service.delete_user_from_panel(
source_panel_uuid,
log_response=False,
)
)
except Exception as exc:
logger.warning(
"Failed to delete merged source panel user %s: %s",
source_panel_uuid,
exc,
)
return False
async def _sync_merged_panel_identity_for_user(
request: web.Request,
user: User,
*,
source_panel_uuid: Optional[str],
final_panel_uuid: Optional[str],
expire_at: Optional[datetime] = None,
) -> bool:
# Remnawave keeps email/telegramId unique. Remove the losing panel identity
# before patching the surviving one so merged accounts can accept both IDs.
await _delete_merged_source_panel_user(
request,
source_panel_uuid=source_panel_uuid,
final_panel_uuid=final_panel_uuid or user.panel_user_uuid,
)
return await _sync_panel_identity_for_user(request, user, expire_at=expire_at)
async def _build_account_merge_notice(
session: AsyncSession,
*,
@@ -885,16 +1166,50 @@ async def _build_account_merge_notice(
}
async def _notify_account_merged(
request: web.Request,
settings: Settings,
*,
merge_notice: Optional[Dict[str, Any]],
email: Optional[str],
telegram_id: Optional[int],
username: Optional[str],
first_name: Optional[str],
) -> None:
if not merge_notice:
return
try:
from bot.services.notification_service import NotificationService
bot: Bot = request.app["bot"]
notification_service = NotificationService(
bot,
settings,
request.app.get("i18n"),
)
await notification_service.notify_account_merged(
primary_user_id=int(merge_notice.get("primary_user_id") or 0),
removed_user_id=int(merge_notice.get("removed_user_id") or 0),
email=email,
telegram_id=telegram_id,
username=username,
first_name=first_name,
final_end_date_text=str(merge_notice.get("final_end_date_text") or ""),
primary_panel_user_uuid=merge_notice.get("primary_panel_user_uuid"),
removed_panel_user_uuid=merge_notice.get("removed_panel_user_uuid"),
)
except Exception:
logger.exception("Failed to send account merged notification")
def _apply_telegram_profile_to_user(
user: User,
telegram_user: Dict[str, Any],
settings: Settings,
) -> None:
language_code = (
telegram_user.get("language_code") or user.language_code or settings.DEFAULT_LANGUAGE
language_code = _normalize_language(
user.language_code or telegram_user.get("language_code") or settings.DEFAULT_LANGUAGE
)
if language_code not in {"ru", "en"}:
language_code = user.language_code or settings.DEFAULT_LANGUAGE
user.telegram_id = int(telegram_user["id"])
user.username = sanitize_username(telegram_user.get("username"))
@@ -937,17 +1252,14 @@ async def _link_telegram_to_user(
)
_apply_telegram_profile_to_user(merged_user, telegram_user, settings)
await session.flush()
await _sync_panel_identity_for_user(request, merged_user)
return merged_user
if not existing_telegram_user and int(current_user.user_id) < 0:
language_code = (
telegram_user.get("language_code")
or current_user.language_code
language_code = _normalize_language(
current_user.language_code
or telegram_user.get("language_code")
or settings.DEFAULT_LANGUAGE
)
if language_code not in {"ru", "en"}:
language_code = current_user.language_code or settings.DEFAULT_LANGUAGE
target_user, _ = await user_dal.create_user(
session,
{
@@ -969,7 +1281,6 @@ async def _link_telegram_to_user(
)
_apply_telegram_profile_to_user(merged_user, telegram_user, settings)
await session.flush()
await _sync_panel_identity_for_user(request, merged_user)
return merged_user
if current_user.telegram_id and int(current_user.telegram_id) != telegram_id:
@@ -1094,20 +1405,19 @@ async def _ensure_user_from_telegram(
referral_param: Optional[str] = None,
) -> User:
user_id = int(telegram_user["id"])
language_code = telegram_user.get("language_code") or settings.DEFAULT_LANGUAGE
if language_code not in {"ru", "en"}:
language_code = settings.DEFAULT_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:
@@ -1122,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),
},
@@ -1130,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
File diff suppressed because it is too large Load Diff
+140
View File
@@ -0,0 +1,140 @@
from __future__ import annotations
from typing import Any, Awaitable, Callable, Optional
from bot.infra.redis import cache_delete, cache_delete_pattern, redis_key
from bot.utils.ttl_cache import AsyncTTLCache
from config.settings import Settings
_WEBAPP_USER_PAYLOAD_CACHES: dict[tuple[int, str, int], AsyncTTLCache] = {}
def reset_webapp_settings_cache(app: Any) -> None:
cache = app.get("webapp_settings_cache") if hasattr(app, "get") else None
if isinstance(cache, dict):
cache["ts"] = 0.0
cache["data"] = {}
def reset_subscription_guides_cache(app: Any) -> None:
cache = app.get("subscription_guides_config_cache") if hasattr(app, "get") else None
if isinstance(cache, dict):
cache["fingerprint"] = None
cache["status"] = None
def _payload_namespaces(include_devices: bool = False) -> tuple[str, ...]:
return ("me", "devices") if include_devices else ("me",)
def _webapp_user_payload_cache(
settings: Settings,
namespace: str,
ttl_seconds: int,
) -> Optional[AsyncTTLCache]:
ttl = max(0, int(ttl_seconds or 0))
if ttl <= 0:
return None
cache_key = (id(settings), namespace, ttl)
cache = _WEBAPP_USER_PAYLOAD_CACHES.get(cache_key)
if cache is None:
cache = AsyncTTLCache(
ttl_seconds=ttl,
settings=settings,
namespace=f"webapp:{namespace}",
)
_WEBAPP_USER_PAYLOAD_CACHES[cache_key] = cache
return cache
async def webapp_cached_user_payload(
settings: Settings,
namespace: str,
user_id: int,
ttl_seconds: int,
loader: Callable[[], Awaitable[Any]],
) -> Any:
cache = _webapp_user_payload_cache(settings, namespace, ttl_seconds)
if cache is None:
return await loader()
return await cache.get_or_load(str(int(user_id)), loader)
def invalidate_local_webapp_user_payload(
settings: Settings,
namespace: str,
user_id: int,
) -> None:
key = str(int(user_id))
for (settings_id, cache_namespace, _ttl), cache in tuple(_WEBAPP_USER_PAYLOAD_CACHES.items()):
if settings_id == id(settings) and cache_namespace == namespace:
cache.invalidate(key)
def invalidate_all_local_webapp_user_payloads(
settings: Settings,
namespace: Optional[str] = None,
*,
include_devices: Optional[bool] = None,
) -> None:
if include_devices is not None:
namespaces: Optional[set[str]] = set(_payload_namespaces(include_devices))
elif namespace is not None:
namespaces = {namespace}
else:
namespaces = None
for (settings_id, cache_namespace, _ttl), cache in tuple(_WEBAPP_USER_PAYLOAD_CACHES.items()):
if settings_id != id(settings):
continue
if namespaces is not None and cache_namespace not in namespaces:
continue
cache.invalidate()
async def invalidate_webapp_user_caches(
settings: Settings,
*user_ids: Optional[int],
include_devices: bool = False,
) -> None:
keys: list[str] = []
seen: set[int] = set()
for raw_user_id in user_ids:
if raw_user_id is None:
continue
try:
user_id = int(raw_user_id)
except (TypeError, ValueError):
continue
if user_id in seen:
continue
seen.add(user_id)
keys.append(redis_key(settings, "cache", "webapp", "me", user_id))
invalidate_local_webapp_user_payload(settings, "me", user_id)
if include_devices:
keys.append(redis_key(settings, "cache", "webapp", "devices", user_id))
invalidate_local_webapp_user_payload(settings, "devices", user_id)
if keys:
await cache_delete(settings, *keys)
async def invalidate_all_webapp_user_payloads(
settings: Settings,
*,
include_devices: bool = False,
) -> None:
for namespace in _payload_namespaces(include_devices):
invalidate_all_local_webapp_user_payloads(settings, namespace=namespace)
try:
pattern = redis_key(settings, "cache", "webapp", namespace, "*")
await cache_delete_pattern(settings, pattern)
except Exception:
continue
async def invalidate_all_webapp_user_caches(
settings: Settings,
*,
include_devices: bool = False,
) -> None:
await invalidate_all_webapp_user_payloads(settings, include_devices=include_devices)
+25 -2
View File
@@ -1,6 +1,14 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
from bot.app.web.webapp.cache_helpers import (
invalidate_webapp_user_caches as _invalidate_user_payload_caches,
)
from bot.middlewares.i18n import (
is_valid_locale_language_code,
normalize_locale_language_code,
)
async def _read_json(request: web.Request) -> Dict[str, Any]:
try:
@@ -17,6 +25,14 @@ def _json_error(status: int, code: str, message: str) -> web.Response:
)
async def _invalidate_webapp_user_caches(
settings: Settings,
*user_ids: Optional[int],
include_devices: bool = False,
) -> None:
await _invalidate_user_payload_caches(settings, *user_ids, include_devices=include_devices)
def _validation_error_response(exc: ValidationError) -> web.Response:
for error in exc.errors():
loc = error.get("loc") or ()
@@ -37,6 +53,13 @@ def _validation_error_response(exc: ValidationError) -> web.Response:
if field in {"description", "comment", "note"} and error_type == "string_too_long":
return _json_error(400, f"{field}_too_long", f"{field.capitalize()} is too long")
if field in {"password", "password_confirm"}:
if error_type == "string_too_short":
return _json_error(400, "password_too_short", "Password is too short")
if error_type == "string_too_long":
return _json_error(400, "password_too_long", "Password is too long")
return _json_error(400, "invalid_password", "Invalid password")
if error_type == "string_too_long":
return _json_error(400, "text_too_long", "Text is too long")
@@ -54,8 +77,8 @@ def _validate_model_payload(
def _normalize_language(lang: Optional[str]) -> str:
value = (lang or "ru").split("-")[0].lower()
return value if value in {"ru", "en"} else "ru"
value = normalize_locale_language_code(lang, prefer_known_base=False)
return value if is_valid_locale_language_code(value) else "ru"
def _format_remaining(seconds: int, lang: str) -> str:
+102 -18
View File
@@ -1,6 +1,8 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
from bot.app.web.webapp.cache_helpers import webapp_cached_user_payload
async def devices_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
@@ -15,35 +17,107 @@ async def devices_route(request: web.Request) -> web.Response:
if not db_user or db_user.is_banned:
return _json_error(403, "access_denied", "Access denied")
active = await subscription_service.get_active_subscription_details(session, user_id)
panel_user_uuid = active.get("user_id") if active else None
if not panel_user_uuid:
return _json_error(400, "subscription_not_active", "Subscription is not active")
result = await webapp_cached_user_payload(
settings,
"devices",
user_id,
int(getattr(settings, "WEBAPP_DEVICES_CACHE_TTL_SECONDS", 5) or 0),
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 {})})
if isinstance(result, dict) and not result.get("error"):
# Backward-compatible with payloads written by older versions under
# the same Redis cache key.
return web.json_response({"ok": True, **result})
if not isinstance(result, dict):
result = {}
if not result.get("ok"):
return _json_error(
int(result.get("status") or 500),
str(result.get("error") or "devices_load_failed"),
str(result.get("message") or "Failed to load devices"),
)
return web.json_response({"ok": True, **(result.get("payload") or {})})
panel_service = getattr(subscription_service, "panel_service", None)
if not panel_service:
return _json_error(503, "panel_unavailable", "Panel service unavailable")
try:
devices_response = await panel_service.get_user_devices(panel_user_uuid)
except Exception:
logger.exception("Failed to load WebApp devices for user %s", user_id)
return _json_error(502, "devices_load_failed", "Failed to load devices")
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 = str((active or {}).get("user_id") or fallback_panel_user_uuid or "").strip()
if not panel_user_uuid:
return _empty_inactive_devices_payload()
panel_service = getattr(subscription_service, "panel_service", None)
if not panel_service:
return {
"ok": False,
"status": 503,
"error": "panel_unavailable",
"message": "Panel service unavailable",
}
try:
devices_response = await panel_service.get_user_devices(panel_user_uuid)
except Exception:
logger.exception("Failed to load WebApp devices for user %s", user_id)
return {
"ok": False,
"status": 502,
"error": "devices_load_failed",
"message": "Failed to load devices",
}
devices = _normalize_devices_response(devices_response)
max_devices = _coerce_int_or_none(active.get("max_devices")) if active else None
return web.json_response(
{
"ok": True,
return {
"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),
"devices": [
_serialize_device(device, index) for index, device in enumerate(devices, start=1)
],
}
)
},
}
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:
@@ -103,6 +177,7 @@ async def disconnect_device_route(request: web.Request) -> web.Response:
success = await panel_service.disconnect_device(panel_user_uuid, target_hwid)
if not success:
return _json_error(502, "device_disconnect_failed", "Failed to disconnect device")
await cache_delete(settings, redis_key(settings, "cache", "webapp", "devices", user_id))
await session.commit()
return web.json_response({"ok": True})
@@ -146,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()
@@ -161,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 "",
+287
View File
@@ -0,0 +1,287 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
from config.subscription_guides_config import (
SubscriptionGuidesConfigError,
subscription_guides_status,
validate_panel_subscription_guides_config,
)
PANEL_DEFAULT_SUBPAGE_CONFIG_UUID = "00000000-0000-0000-0000-000000000000"
SUBSCRIPTION_GUIDES_CACHE_ERROR_TTL_SECONDS = 30
async def warm_subscription_guides_config(app: web.Application) -> None:
try:
await _subscription_guides_status_shared(app)
except Exception as exc:
logger.warning("Failed to warm subscription guides config: %s", exc)
async def subscription_guides_route(request: web.Request) -> web.Response:
_require_user_id(request)
status = await _subscription_guides_status_shared(request.app)
payload = {
"enabled": bool(status.get("enabled")),
"config": status.get("config") if status.get("enabled") else None,
"source": status.get("source"),
}
if status.get("error"):
payload["error"] = status["error"]
return web.json_response({"ok": True, **payload})
async def public_subscription_guides_route(request: web.Request) -> web.Response:
share_token = subscription_dal.normalize_install_share_token(
request.match_info.get("share_token")
)
if not share_token:
return web.json_response({"ok": False, "error": "invalid_share_token"}, status=404)
subscription = await _public_subscription_payload(request, share_token)
if not subscription.get("active"):
return web.json_response(
{
"ok": False,
"enabled": False,
"config": None,
"source": None,
"subscription": subscription,
"error": "subscription_unavailable",
},
status=404,
)
status = await _subscription_guides_status_shared(request.app)
payload = {
"enabled": bool(status.get("enabled")),
"config": status.get("config") if status.get("enabled") else None,
"source": status.get("source"),
"subscription": subscription,
}
if status.get("error"):
payload["error"] = status["error"]
return web.json_response({"ok": True, **payload})
async def _subscription_guides_status_shared(app: web.Application) -> Dict[str, Any]:
settings: Settings = app["settings"]
cache = app.setdefault("subscription_guides_config_cache", {})
lock: asyncio.Lock = app.setdefault("subscription_guides_config_lock", asyncio.Lock())
fingerprint = _subscription_guides_settings_fingerprint(settings)
now = time.monotonic()
cached = cache.get("status")
if cached is not None and cache.get("fingerprint") == fingerprint:
if cached.get("enabled") or now - float(cache.get("ts", 0.0)) < (
SUBSCRIPTION_GUIDES_CACHE_ERROR_TTL_SECONDS
):
return cached
async with lock:
cached = cache.get("status")
if cached is not None and cache.get("fingerprint") == fingerprint:
if cached.get("enabled") or now - float(cache.get("ts", 0.0)) < (
SUBSCRIPTION_GUIDES_CACHE_ERROR_TTL_SECONDS
):
return cached
status = await _load_subscription_guides_status(app, settings)
cache["fingerprint"] = fingerprint
cache["status"] = status
cache["ts"] = time.monotonic()
return status
async def _load_subscription_guides_status(
app: web.Application,
settings: Settings,
) -> Dict[str, Any]:
if not bool(getattr(settings, "SUBSCRIPTION_GUIDES_ENABLED", False)):
return {"enabled": False, "config": None, "source": None, "error": None}
admin_json = str(getattr(settings, "SUBSCRIPTION_PAGE_CONFIG_JSON", "") or "").strip()
json_override_enabled = bool(
getattr(settings, "SUBSCRIPTION_PAGE_CONFIG_JSON_OVERRIDE_ENABLED", False)
)
if admin_json and json_override_enabled:
return subscription_guides_status(settings)
if bool(getattr(settings, "SUBSCRIPTION_PAGE_CONFIG_PANEL_ENABLED", True)):
panel_status = await _subscription_guides_status_from_panel_config(app, settings)
if panel_status.get("enabled"):
return panel_status
return subscription_guides_status(settings)
async def _subscription_guides_status_from_panel_config(
app: web.Application,
settings: Settings,
) -> Dict[str, Any]:
panel_service = _panel_service_from_app(app)
if panel_service is None:
return {
"enabled": False,
"config": None,
"source": "panel",
"error": "Panel service is unavailable",
}
try:
config_uuid = str(getattr(settings, "SUBSCRIPTION_PAGE_CONFIG_UUID", "") or "").strip()
if not config_uuid:
config_uuid = await _default_panel_subscription_page_config_uuid(panel_service)
config_uuid = config_uuid or PANEL_DEFAULT_SUBPAGE_CONFIG_UUID
detail = await panel_service.get_subscription_page_config_by_uuid(config_uuid)
if detail is None and config_uuid != PANEL_DEFAULT_SUBPAGE_CONFIG_UUID:
detail = await panel_service.get_subscription_page_config_by_uuid(
PANEL_DEFAULT_SUBPAGE_CONFIG_UUID
)
if detail is None:
raise SubscriptionGuidesConfigError(
f"Panel subscription page config {config_uuid} is unavailable"
)
config = validate_panel_subscription_guides_config(detail)
except (SubscriptionGuidesConfigError, Exception) as exc:
logger.warning("Failed to load subscription guides config from Remnawave Panel: %s", exc)
return {"enabled": False, "config": None, "source": "panel", "error": str(exc)}
return {"enabled": True, "config": config, "source": "panel", "error": None}
async def _default_panel_subscription_page_config_uuid(panel_service: Any) -> str:
get_list = getattr(panel_service, "get_subscription_page_config_list", None)
if not callable(get_list):
return ""
payload = await get_list()
configs = (payload or {}).get("configs")
if not isinstance(configs, list):
return ""
candidates: list[Dict[str, Any]] = [item for item in configs if isinstance(item, dict)]
for item in candidates:
uuid = str(item.get("uuid") or "").strip()
if uuid == PANEL_DEFAULT_SUBPAGE_CONFIG_UUID:
return uuid
candidates.sort(key=lambda item: int(item.get("viewPosition") or 0))
for item in candidates:
uuid = str(item.get("uuid") or "").strip()
if uuid:
return uuid
return ""
async def _public_subscription_payload(
request: web.Request,
share_token: str,
) -> Dict[str, Any]:
settings: Settings = request.app["settings"]
panel_service = _panel_service_from_app(request.app)
raw_link = ""
username = ""
resolved_short_uuid = ""
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
local_sub = await subscription_dal.get_subscription_by_install_share_token(
session,
share_token,
)
if (
local_sub
and getattr(local_sub, "panel_user_uuid", None)
and _local_subscription_is_publicly_active(local_sub)
and panel_service
):
panel_user = await panel_service.get_user_by_uuid(local_sub.panel_user_uuid)
if panel_user:
raw_link = str(panel_user.get("subscriptionUrl") or "").strip()
username = str(panel_user.get("username") or "").strip()
resolved_short_uuid = str(panel_user.get("shortUuid") or "").strip()
display_link, connect_url = await prepare_config_links(settings, raw_link)
return {
"active": bool(display_link),
"config_link": display_link,
"connect_url": connect_url or display_link,
"panel_short_uuid": resolved_short_uuid or None,
"install_share_token": share_token,
"username": username,
"share_url": _public_install_url(request, share_token),
}
def _panel_service_from_app(app: web.Application) -> Any:
subscription_service: Optional[SubscriptionService] = app.get("subscription_service")
panel_service = (
getattr(subscription_service, "panel_service", None) if subscription_service else None
)
return panel_service or app.get("panel_service")
def _subscription_guides_settings_fingerprint(settings: Settings) -> Tuple[Any, ...]:
admin_json = str(getattr(settings, "SUBSCRIPTION_PAGE_CONFIG_JSON", "") or "")
return (
bool(getattr(settings, "SUBSCRIPTION_GUIDES_ENABLED", False)),
bool(getattr(settings, "SUBSCRIPTION_PAGE_CONFIG_PANEL_ENABLED", True)),
bool(getattr(settings, "SUBSCRIPTION_PAGE_CONFIG_JSON_OVERRIDE_ENABLED", False)),
str(getattr(settings, "SUBSCRIPTION_PAGE_CONFIG_PATH", "") or ""),
str(getattr(settings, "SUBSCRIPTION_PAGE_CONFIG_UUID", "") or ""),
hashlib.sha256(admin_json.encode("utf-8")).hexdigest(),
str(getattr(settings, "PANEL_API_URL", "") or ""),
bool(getattr(settings, "PANEL_API_KEY", "") or ""),
)
def _local_subscription_is_publicly_active(subscription: Any) -> bool:
end_date = getattr(subscription, "end_date", None)
if end_date and end_date.tzinfo is None:
end_date = end_date.replace(tzinfo=timezone.utc)
return bool(
getattr(subscription, "is_active", False)
and end_date
and end_date > datetime.now(timezone.utc)
)
def _public_install_url(request: web.Request, share_token: str) -> str:
settings: Settings = request.app["settings"]
configured_base = str(getattr(settings, "SUBSCRIPTION_MINI_APP_URL", "") or "").strip()
if configured_base:
parts = urlsplit(configured_base)
if parts.scheme and parts.netloc:
base = urlunsplit((parts.scheme, parts.netloc, "", "", ""))
else:
base = configured_base.rstrip("/")
else:
host = (
request.headers.get("X-Forwarded-Host") or request.headers.get("Host") or request.host
)
proto = request.headers.get("X-Forwarded-Proto") or request.scheme or "https"
base = f"{proto}://{host}"
return f"{base.rstrip('/')}/s/{quote(share_token)}"
def _subscription_page_request_headers(request: web.Request) -> Dict[str, str]:
headers = request.headers
host = headers.get("X-Forwarded-Host") or headers.get("Host") or request.host
proto = headers.get("X-Forwarded-Proto") or request.scheme or "https"
user_agent = headers.get(
"User-Agent",
"Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko) Chrome Safari",
)
return {
"host": host,
"x-forwarded-host": host,
"x-forwarded-proto": proto,
"user-agent": user_agent,
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"accept-language": headers.get("Accept-Language", "ru,en;q=0.9"),
"sec-fetch-dest": "document",
"sec-fetch-mode": "navigate",
"sec-fetch-site": "none",
"upgrade-insecure-requests": "1",
}
+63
View File
@@ -1,6 +1,8 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
from typing import Literal
class WebAppEmailPayload(BaseModel):
model_config = ConfigDict(extra="ignore")
@@ -20,6 +22,18 @@ class WebAppEmailCodePayload(WebAppEmailPayload):
code: str = ""
class WebAppEmailPasswordPayload(WebAppEmailPayload):
password: constr(min_length=1, max_length=128)
class WebAppSetPasswordPayload(BaseModel):
model_config = ConfigDict(extra="ignore")
password: constr(min_length=8, max_length=128)
password_confirm: constr(min_length=8, max_length=128)
code: constr(min_length=1, max_length=32)
class WebAppEmailMagicPayload(BaseModel):
model_config = ConfigDict(extra="ignore")
@@ -57,3 +71,52 @@ class WebAppDeviceDisconnectPayload(BaseModel):
model_config = ConfigDict(extra="ignore")
token: constr(min_length=8, max_length=128)
SupportCategory = Literal["billing", "technical", "account", "other"]
SupportPriority = Literal["low", "normal", "high", "urgent"]
SupportStatus = Literal["open", "awaiting_user", "awaiting_admin", "resolved", "closed"]
class CreateTicketPayload(BaseModel):
model_config = ConfigDict(extra="ignore")
subject: constr(min_length=1, max_length=160)
category: SupportCategory = "other"
priority: Literal["normal", "high"] = "normal"
body: constr(min_length=1, max_length=4000)
@field_validator("subject", "body")
@classmethod
def _strip_required_text(cls, value: str) -> str:
stripped = value.strip()
if not stripped:
raise ValueError("empty_text")
return stripped
class TicketReplyPayload(BaseModel):
model_config = ConfigDict(extra="ignore")
body: constr(min_length=1, max_length=4000)
@field_validator("body")
@classmethod
def _strip_body(cls, value: str) -> str:
stripped = value.strip()
if not stripped:
raise ValueError("empty_text")
return stripped
class AdminTicketReplyPayload(TicketReplyPayload):
is_internal_note: bool = False
class AdminTicketPatchPayload(BaseModel):
model_config = ConfigDict(extra="ignore")
status: Optional[SupportStatus] = None
priority: Optional[SupportPriority] = None
category: Optional[SupportCategory] = None
assigned_admin_id: Optional[int] = None
+45 -1
View File
@@ -3,23 +3,40 @@ 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)
app.router.add_get("/install", index_route)
app.router.add_get("/trial", index_route)
app.router.add_get("/open-app", app_deeplink_route)
app.router.add_get(r"/s/{share_token:[a-f0-9]{32}}", index_route)
app.router.add_get("/invite", index_route)
app.router.add_get("/devices", index_route)
app.router.add_get("/settings", index_route)
app.router.add_get("/support", index_route)
app.router.add_get("/support/{ticket_id:\\d+}", index_route)
app.router.add_get("/admin", index_route)
app.router.add_get(
(
"/admin/{section:stats|users|payments|promos|ads|broadcast|logs|tariffs|"
"appearance|settings}"
"appearance|settings|translations|support|backups}"
),
index_route,
)
app.router.add_get("/admin/users/{user_id:-?[0-9]+}", index_route)
app.router.add_get("/admin/payments/users/{user_id:-?[0-9]+}", index_route)
app.router.add_get("/admin/payments/{payment_id:\\d+}", index_route)
app.router.add_get("/admin/support/{ticket_id:\\d+}", index_route)
app.router.add_get("/auth/telegram/start", telegram_oauth_start_route)
app.router.add_get("/auth/telegram/callback", telegram_oauth_callback_route)
app.router.add_get("/health", health_route)
app.router.add_get("/favicon.ico", webapp_current_favicon_route)
app.router.add_get("/apple-touch-icon.png", webapp_current_favicon_route)
app.router.add_get("/apple-touch-icon-precomposed.png", webapp_current_favicon_route)
app.router.add_get("/icon-192.png", webapp_current_favicon_route)
app.router.add_get("/icon-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_.-]+}}",
@@ -33,29 +50,56 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
r"/webapp-emoji/{codepoints:[0-9a-f_]+}/512.{ext:gif|webp}",
webapp_animated_emoji_route,
)
app.router.add_get("/subscription_webapp.{asset_hash:[0-9a-f]{8}}.css", css_asset_route)
app.router.add_get("/subscription_webapp.css", css_asset_route)
app.router.add_get(
"/subscription_webapp_admin.{asset_hash:[0-9a-f]{8}}.css",
admin_css_asset_route,
)
app.router.add_get("/subscription_webapp_admin.css", admin_css_asset_route)
app.router.add_get(r"/webapp-theme-css/{path:.+}", theme_css_asset_route)
app.router.add_get(r"/webapp-theme-assets/{path:.+}", theme_asset_route)
app.router.add_get("/subscription_webapp.min.{asset_hash}.js", js_asset_route)
app.router.add_get("/subscription_webapp.js", js_asset_route)
app.router.add_get("/subscription_webapp_admin.min.{asset_hash}.js", admin_js_asset_route)
app.router.add_get("/subscription_webapp_admin.js", admin_js_asset_route)
app.router.add_post("/api/auth/telegram/nonce", telegram_oauth_nonce_route)
app.router.add_post("/api/auth/token", auth_token_route)
app.router.add_post("/api/auth/email/request", email_auth_request_route)
app.router.add_post("/api/auth/email/verify", email_auth_verify_route)
app.router.add_post("/api/auth/email/magic", email_auth_magic_route)
app.router.add_post("/api/auth/email/password", email_password_auth_route)
app.router.add_post("/api/auth/logout", logout_route)
app.router.add_get("/api/bootstrap", bootstrap_route)
app.router.add_get("/api/i18n", i18n_route)
app.router.add_get("/api/me", me_route)
app.router.add_get("/api/subscription-guides", subscription_guides_route)
app.router.add_get(
r"/api/subscription-guides/public/{share_token:[a-f0-9]{32}}",
public_subscription_guides_route,
)
app.router.add_get("/api/account/avatar", account_avatar_route)
app.router.add_post("/api/account/language", account_language_route)
app.router.add_post("/api/account/email/request", account_email_request_route)
app.router.add_post("/api/account/email/verify", account_email_verify_route)
app.router.add_post("/api/account/password/request", account_password_request_route)
app.router.add_post("/api/account/password/confirm", account_password_confirm_route)
app.router.add_post("/api/account/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)
app.router.add_post("/api/devices/disconnect", disconnect_device_route)
app.router.add_get("/api/devices/topup-options", device_topup_options_route)
app.router.add_get("/api/support/tickets", support_tickets_route)
app.router.add_post("/api/support/tickets", support_create_ticket_route)
app.router.add_get("/api/support/tickets/{id:\\d+}", support_ticket_detail_route)
app.router.add_post("/api/support/tickets/{id:\\d+}/messages", support_ticket_reply_route)
app.router.add_post("/api/support/tickets/{id:\\d+}/read", support_ticket_read_route)
app.router.add_get("/api/support/unread", support_unread_route)
app.router.add_get("/api/tariffs/topup-options", tariff_topup_options_route)
app.router.add_get("/api/tariffs/change-options", tariff_change_options_route)
app.router.add_post("/api/tariffs/change", tariff_change_route)
+225 -60
View File
@@ -1,7 +1,14 @@
# ruff: noqa: F401,F403,F405,I001
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]:
@@ -38,6 +45,11 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
if referral_service
else {"invited_count": 0, "purchased_count": 0}
)
support_unread_count = (
await support_dal.count_user_unread(session, user_id)
if settings.SUPPORT_TICKETS_ENABLED
else 0
)
local_sub = (
await subscription_dal.get_active_subscription_by_user_id(
session,
@@ -47,6 +59,11 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
if db_user.panel_user_uuid
else None
)
install_share_token = (
await subscription_dal.ensure_install_share_token(session, local_sub)
if active and local_sub
else None
)
trial_available = bool(
settings.TRIAL_ENABLED
and settings.TRIAL_DURATION_DAYS > 0
@@ -61,20 +78,42 @@ 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,
"username": db_user.username,
"email": db_user.email,
"email_verified": bool(db_user.email_verified_at),
"password_auth_enabled": bool(
db_user.email and db_user.email_verified_at and db_user.password_hash
),
"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,
"is_admin": is_admin,
},
"subscription": _serialize_subscription(settings, active, local_sub, lang),
"subscription": _serialize_subscription(
request,
settings,
active,
local_sub,
lang,
install_share_token=install_share_token,
),
"referral": {
"code": referral_code,
"bot_link": referral_link,
@@ -97,14 +136,25 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
traffic_packages=cached["traffic_packages"],
stars_traffic_packages=cached["stars_traffic_packages"],
),
"payment_methods": _serialize_payment_methods(settings, request.app),
"payment_methods": _serialize_payment_methods(
settings,
request.app,
lang,
is_admin=is_admin,
),
"themes_catalog": public_themes_catalog_payload(
settings.webapp_themes_catalog,
settings.WEBAPP_PRIMARY_COLOR or "#00fe7a",
enabled_only=True,
),
"support_unread_count": int(support_unread_count or 0),
"settings": {
"support_url": settings.SUPPORT_LINK,
"support_tickets_enabled": bool(settings.SUPPORT_TICKETS_ENABLED),
"support_ticket_max_body_length": int(settings.SUPPORT_TICKET_MAX_BODY_LENGTH or 4000),
"support_ticket_max_subject_length": int(
settings.SUPPORT_TICKET_MAX_SUBJECT_LENGTH or 160
),
"traffic_mode": bool(settings.traffic_sale_mode),
"my_devices_enabled": bool(settings.MY_DEVICES_SECTION_ENABLED),
"user_hwid_device_limit": (
@@ -117,17 +167,86 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
"trial_duration_days": int(settings.TRIAL_DURATION_DAYS or 0),
"trial_traffic_limit_gb": float(settings.TRIAL_TRAFFIC_LIMIT_GB or 0),
"trial_traffic_strategy": getattr(settings, "TRIAL_TRAFFIC_STRATEGY", "NO_RESET"),
"subscription_purchase_description": settings.subscription_purchase_description(lang),
"subscription_guides_enabled": subscription_guides_available(settings),
"email_auth_enabled": settings.email_auth_configured,
},
}
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:
@@ -164,11 +283,26 @@ def _build_webapp_referral_link(
def _serialize_subscription(
settings: Settings,
active: Optional[Dict[str, Any]],
local_sub: Optional[Any],
lang: str,
request_or_settings: Any,
settings_or_active: Any,
active_or_local_sub: Optional[Any] = None,
local_sub_or_lang: Optional[Any] = None,
lang: Optional[str] = None,
*,
install_share_token: Optional[str] = None,
) -> Dict[str, Any]:
if lang is None:
request = None
settings = request_or_settings
active = settings_or_active
local_sub = active_or_local_sub
lang = str(local_sub_or_lang or "ru")
else:
request = request_or_settings
settings = settings_or_active
active = active_or_local_sub
local_sub = local_sub_or_lang
if not active:
return {
"active": False,
@@ -177,6 +311,9 @@ def _serialize_subscription(
"days_left": 0,
"config_link": None,
"connect_url": None,
"panel_short_uuid": None,
"install_share_token": None,
"install_share_url": None,
}
end_date = active.get("end_date")
@@ -207,7 +344,8 @@ def _serialize_subscription(
can_topup_traffic = bool(can_topup_regular_traffic or can_topup_premium_traffic)
# max_devices == 0 means unlimited — top-up is pointless in that case.
can_topup_devices = bool(
tariff.has_hwid_device_packages()
tariff.billing_model == "period"
and tariff.has_hwid_device_packages()
and _coerce_int_or_none(active.get("max_devices")) != 0
)
except Exception:
@@ -216,6 +354,23 @@ def _serialize_subscription(
can_topup_traffic = False
can_topup_devices = False
panel_short_uuid = str(active.get("panel_short_uuid") or "").strip()
share_token = str(
install_share_token or getattr(local_sub, "install_share_token", "") or ""
).strip()
extra_hwid_valid_until = active.get("extra_hwid_devices_valid_until")
if extra_hwid_valid_until and extra_hwid_valid_until.tzinfo is None:
extra_hwid_valid_until = extra_hwid_valid_until.replace(tzinfo=timezone.utc)
extra_hwid_next_valid_from = active.get("extra_hwid_devices_next_valid_from")
if extra_hwid_next_valid_from and extra_hwid_next_valid_from.tzinfo is None:
extra_hwid_next_valid_from = extra_hwid_next_valid_from.replace(tzinfo=timezone.utc)
extra_hwid_count = _coerce_int_or_none(active.get("extra_hwid_devices")) or 0
device_topup_renewal_available = bool(
extra_hwid_count > 0
and extra_hwid_valid_until
and end_date
and extra_hwid_valid_until < end_date
)
return {
"active": seconds_left > 0,
"status": active.get("status_from_panel") or "UNKNOWN",
@@ -225,6 +380,9 @@ def _serialize_subscription(
"remaining_text": _format_remaining(seconds_left, lang),
"config_link": active.get("config_link"),
"connect_url": active.get("connect_button_url") or active.get("config_link"),
"panel_short_uuid": panel_short_uuid or None,
"install_share_token": subscription_dal.normalize_install_share_token(share_token) or None,
"install_share_url": _build_install_share_link(request, settings, share_token),
"traffic_limit": _format_bytes(active.get("traffic_limit_bytes"), zero_as_unlimited=True),
"traffic_used": _format_bytes(active.get("traffic_used_bytes")),
"traffic_limit_bytes": _coerce_int_or_none(active.get("traffic_limit_bytes")),
@@ -263,12 +421,46 @@ def _serialize_subscription(
"is_throttled": bool(active.get("is_throttled")),
"max_devices": _coerce_int_or_none(active.get("max_devices")),
"base_hwid_device_limit": _coerce_int_or_none(active.get("base_hwid_device_limit")),
"extra_hwid_devices": _coerce_int_or_none(active.get("extra_hwid_devices")) or 0,
"extra_hwid_devices": extra_hwid_count,
"extra_hwid_devices_valid_until": extra_hwid_valid_until.isoformat()
if extra_hwid_valid_until
else None,
"extra_hwid_devices_valid_until_text": extra_hwid_valid_until.strftime("%d.%m.%Y %H:%M")
if extra_hwid_valid_until
else None,
"extra_hwid_devices_next_valid_from": extra_hwid_next_valid_from.isoformat()
if extra_hwid_next_valid_from
else None,
"device_topup_renewal_available": device_topup_renewal_available,
"auto_renew_enabled": bool(getattr(local_sub, "auto_renew_enabled", False)),
"provider": getattr(local_sub, "provider", None),
}
def _build_install_share_link(
request: Optional[web.Request],
settings: Settings,
share_token: str,
) -> Optional[str]:
share_token = subscription_dal.normalize_install_share_token(share_token)
if not share_token or request is None:
return None
configured_base = str(getattr(settings, "SUBSCRIPTION_MINI_APP_URL", "") or "").strip()
if configured_base:
parts = urlsplit(configured_base)
if parts.scheme and parts.netloc:
base = urlunsplit((parts.scheme, parts.netloc, "", "", ""))
else:
base = configured_base.rstrip("/")
else:
host = (
request.headers.get("X-Forwarded-Host") or request.headers.get("Host") or request.host
)
proto = request.headers.get("X-Forwarded-Proto") or request.scheme or "https"
base = f"{proto}://{host}"
return f"{base.rstrip('/')}/s/{quote(share_token)}"
def _serialize_plans(
settings: Settings,
lang: str,
@@ -284,6 +476,7 @@ def _serialize_plans(
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),
@@ -295,7 +488,9 @@ def _serialize_plans(
tariff,
tariff.hwid_device_packages,
lang,
),
)
if tariff.billing_model == "period"
else [],
}
if tariff.billing_model == "period":
for months in sorted(tariff.enabled_periods):
@@ -504,6 +699,8 @@ def _serialize_tariff_change_target(
"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),
}
)
paid_diff = float(options.get("paid_diff_rub") or 0)
@@ -525,6 +722,8 @@ 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_gb": float(options.get("converted_hwid_gb") or 0),
}
)
actions.extend(
@@ -566,59 +765,25 @@ def _serialize_tariff_change_target(
def _serialize_payment_methods(
settings: Settings,
app: web.Application,
lang: str = "ru",
*,
is_admin: bool = False,
) -> List[Dict[str, Any]]:
labels = {
"severpay": "SeverPay",
"freekassa": "FreeKassa / СБП",
"platega_sbp": "Platega · СБП",
"platega_crypto": "Platega · Crypto",
"yookassa": "Банковская карта",
"stars": "Telegram Stars",
"cryptopay": "CryptoPay",
}
from bot.payment_providers import get_provider_spec, resolve_provider_presentation
methods: List[Dict[str, Any]] = []
for method in settings.payment_methods_order:
method = method.lower()
if (
method == "severpay"
and settings.SEVERPAY_ENABLED
and _service_configured(app, "severpay_service")
):
methods.append({"id": method, "name": labels[method]})
elif (
method == "freekassa"
and settings.FREEKASSA_ENABLED
and _service_configured(app, "freekassa_service")
):
methods.append({"id": method, "name": labels[method]})
elif (
method == "platega_sbp"
and settings.PLATEGA_ENABLED
and settings.PLATEGA_SBP_ENABLED
and _service_configured(app, "platega_service")
):
methods.append({"id": method, "name": labels[method]})
elif (
method == "platega_crypto"
and settings.PLATEGA_ENABLED
and settings.PLATEGA_CRYPTO_ENABLED
and _service_configured(app, "platega_service")
):
methods.append({"id": method, "name": labels[method]})
elif (
method == "yookassa"
and settings.YOOKASSA_ENABLED
and _service_configured(app, "yookassa_service")
):
methods.append({"id": method, "name": labels[method]})
elif method == "stars" and settings.STARS_ENABLED:
methods.append({"id": method, "name": labels[method]})
elif (
method == "cryptopay"
and settings.CRYPTOPAY_ENABLED
and _service_configured(app, "cryptopay_service")
):
methods.append({"id": method, "name": labels[method]})
spec = get_provider_spec(method)
if spec and spec.is_visible_for_user(settings, app, is_admin=is_admin):
presentation = resolve_provider_presentation(spec, settings, language=lang)
methods.append(
{
"id": method,
"name": presentation.webapp_label,
"icon": presentation.webapp_icon,
}
)
return methods
+152
View File
@@ -0,0 +1,152 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
from bot.services.support_service import TicketForbidden, TicketNotFound, TicketRateLimited
from db.dal import support_dal, user_dal
from db.models import SupportTicket, SupportTicketMessage
def _support_ticket_payload(ticket: SupportTicket) -> Dict[str, Any]:
return {
"ticket_id": ticket.ticket_id,
"user_id": ticket.user_id,
"subject": ticket.subject,
"category": ticket.category,
"priority": ticket.priority,
"status": ticket.status,
"assigned_admin_id": ticket.assigned_admin_id,
"last_message_at": ticket.last_message_at.isoformat() if ticket.last_message_at else None,
"last_message_role": ticket.last_message_role,
"unread_user_count": int(ticket.unread_user_count or 0),
"unread_admin_count": int(ticket.unread_admin_count or 0),
"created_at": ticket.created_at.isoformat() if ticket.created_at else None,
"updated_at": ticket.updated_at.isoformat() if ticket.updated_at else None,
"closed_at": ticket.closed_at.isoformat() if ticket.closed_at else None,
}
def _support_message_payload(message: SupportTicketMessage) -> Dict[str, Any]:
return {
"message_id": message.message_id,
"ticket_id": message.ticket_id,
"author_role": message.author_role,
"author_user_id": message.author_user_id,
"body": message.body,
"is_internal_note": bool(message.is_internal_note),
"created_at": message.created_at.isoformat() if message.created_at else None,
"read_by_user_at": message.read_by_user_at.isoformat() if message.read_by_user_at else None,
"read_by_admin_at": message.read_by_admin_at.isoformat()
if message.read_by_admin_at
else None,
}
def _support_limit_offset(request: web.Request) -> tuple[int, int]:
limit = max(1, min(100, int(request.query.get("limit", 25) or 25)))
offset = max(0, int(request.query.get("offset", 0) or 0))
return limit, offset
async def support_tickets_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
limit, offset = _support_limit_offset(request)
status_filter = request.query.get("status") or None
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
tickets = await support_dal.list_user_tickets(
session,
user_id,
limit=limit,
offset=offset,
status_filter=status_filter,
)
counts = await support_dal.user_ticket_counts(session, user_id)
return web.json_response(
{
"ok": True,
"tickets": [_support_ticket_payload(t) for t in tickets],
"counts": counts,
}
)
async def support_create_ticket_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
payload, error = _validate_model_payload(CreateTicketPayload, await _read_json(request))
if error:
return error
service = request.app["support_service"]
try:
ticket = await service.create_ticket(
user_id,
payload.subject,
payload.category,
payload.priority,
payload.body,
)
except TicketForbidden:
return _json_error(403, "ticket_forbidden", "Support ticket action is forbidden")
except TicketRateLimited:
return _json_error(429, "ticket_rate_limited", "Too many support tickets")
return web.json_response({"ok": True, "ticket": _support_ticket_payload(ticket)})
async def support_ticket_detail_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
ticket_id = int(request.match_info["id"])
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
ticket, messages = await support_dal.get_ticket(session, ticket_id, include_internal=False)
if not ticket or ticket.user_id != user_id:
return _json_error(404, "not_found", "Ticket not found")
return web.json_response(
{
"ok": True,
"ticket": _support_ticket_payload(ticket),
"messages": [_support_message_payload(m) for m in messages],
}
)
async def support_ticket_reply_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
ticket_id = int(request.match_info["id"])
payload, error = _validate_model_payload(TicketReplyPayload, await _read_json(request))
if error:
return error
service = request.app["support_service"]
try:
ticket, message = await service.reply_as_user(user_id, ticket_id, payload.body)
except TicketForbidden:
return _json_error(403, "ticket_forbidden", "Support ticket action is forbidden")
except TicketNotFound:
return _json_error(404, "not_found", "Ticket not found")
return web.json_response(
{
"ok": True,
"ticket": _support_ticket_payload(ticket),
"message": _support_message_payload(message),
}
)
async def support_ticket_read_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
ticket_id = int(request.match_info["id"])
service = request.app["support_service"]
try:
await service.mark_read_as_user(user_id, ticket_id)
except TicketNotFound:
return _json_error(404, "not_found", "Ticket not found")
return web.json_response({"ok": True})
async def support_unread_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
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, user_id)
if user and user.is_banned:
return _json_error(403, "ticket_forbidden", "Support ticket action is forbidden")
unread = await support_dal.count_user_unread(session, user_id)
return web.json_response({"ok": True, "unread": unread})
@@ -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})
-1
View File
@@ -156,7 +156,6 @@ async def admin_panel_actions_callback_handler(
panel_service=panel_service,
session=session,
)
await callback.answer(_("admin_sync_initiated_from_panel"))
elif action == "queue_status":
await show_queue_status_handler(callback, i18n_data)
elif action == "view_payments":
+52 -21
View File
@@ -8,6 +8,7 @@ from typing import Any, Dict, List, Optional
from aiogram import F, Router, types
from aiogram.fsm.context import FSMContext
from aiogram.utils.text_decorations import html_decoration as hd
from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.inline.admin_keyboards import (
@@ -25,6 +26,44 @@ USERNAME_REGEX = re.compile(r"^[a-zA-Z0-9_]{5,32}$")
EMAIL_REGEX = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
def _user_email(user: Optional[User]) -> str:
return str(getattr(user, "email", None) or "").strip()
def _format_user_with_email(
*,
first_name: Optional[str] = None,
username: Optional[str] = None,
email: Optional[str] = None,
fallback: str = "",
) -> str:
parts = []
if first_name:
parts.append(first_name)
if username:
parts.append(f"(@{username})")
display = " ".join(parts).strip() or str(fallback or "").strip()
clean_email = str(email or "").strip()
if clean_email:
display = (
f"{display} · {clean_email}" if display and display != clean_email else clean_email
)
return hd.quote(display)
def _format_log_entry_user(log_entry: MessageLog, translate) -> str:
fallback = (
translate("system_or_unknown_user") if not log_entry.user_id else f"ID: {log_entry.user_id}"
)
return _format_user_with_email(
first_name=log_entry.telegram_first_name,
username=log_entry.telegram_username,
email=_user_email(getattr(log_entry, "author_user", None)),
fallback=fallback,
)
async def display_logs_menu(
callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession
):
@@ -94,19 +133,7 @@ async def _display_formatted_logs(
log_entries_text = []
for log_entry_model in logs:
user_display_parts = []
if log_entry_model.telegram_first_name:
user_display_parts.append(log_entry_model.telegram_first_name)
if log_entry_model.telegram_username:
user_display_parts.append(f"(@{log_entry_model.telegram_username})")
user_display = " ".join(user_display_parts).strip()
if not user_display:
user_display = (
_("system_or_unknown_user")
if not log_entry_model.user_id
else f"ID: {log_entry_model.user_id}"
)
user_display = _format_log_entry_user(log_entry_model, _)
user_id_display = (
str(log_entry_model.user_id) if log_entry_model.user_id is not None else "N/A"
@@ -270,10 +297,11 @@ async def process_user_id_for_logs_handler(
return
target_user_id = user_model_for_logs.user_id
user_display_name = user_model_for_logs.first_name or (
f"@{user_model_for_logs.username}"
if user_model_for_logs.username
else (user_model_for_logs.email or f"ID {target_user_id}")
user_display_name = _format_user_with_email(
first_name=user_model_for_logs.first_name,
username=user_model_for_logs.username,
email=user_model_for_logs.email,
fallback=f"ID {target_user_id}",
)
logs_models = await message_log_dal.get_user_message_logs(
@@ -319,10 +347,11 @@ async def view_user_logs_paginated_handler(
await callback.answer()
return
user_display_name = user_model_for_logs.first_name or (
f"@{user_model_for_logs.username}"
if user_model_for_logs.username
else (user_model_for_logs.email or f"ID {target_user_id}")
user_display_name = _format_user_with_email(
first_name=user_model_for_logs.first_name,
username=user_model_for_logs.username,
email=user_model_for_logs.email,
fallback=f"ID {target_user_id}",
)
logs_models = await message_log_dal.get_user_message_logs(
@@ -392,6 +421,7 @@ async def export_logs_csv_handler(
_("admin_csv_header_user_id"),
_("admin_csv_header_telegram_username"),
_("admin_csv_header_telegram_first_name"),
_("admin_csv_header_email"),
_("admin_csv_header_event_type"),
_("admin_csv_header_content"),
_("admin_csv_header_is_admin_event"),
@@ -417,6 +447,7 @@ async def export_logs_csv_handler(
log.user_id or "",
log.telegram_username or "",
log.telegram_first_name or "",
_user_email(getattr(log, "author_user", None)),
log.event_type or "",
content_clean,
"Yes" if log.is_admin_event else "No",
+6 -17
View File
@@ -10,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.inline.admin_keyboards import get_back_to_admin_panel_keyboard
from bot.middlewares.i18n import JsonI18n
from bot.payment_providers import pending_statuses, provider_label_map
from config.settings import Settings
from db.dal import payment_dal
from db.models import Payment
@@ -38,18 +39,10 @@ def format_payment_text(payment: Payment, i18n: JsonI18n, lang: str, settings: S
"""Format single payment info as text."""
_ = lambda key, **kwargs: i18n.gettext(lang, key, **kwargs)
pending_statuses = [
"pending",
"pending_yookassa",
"pending_freekassa",
"pending_platega",
"pending_severpay",
"pending_cryptopay",
]
status_emoji = (
""
if payment.status == "succeeded"
else ("" if payment.status in pending_statuses else "")
else ("" if payment.status in pending_statuses() else "")
)
user_info = f"User {payment.user_id}"
@@ -60,14 +53,10 @@ def format_payment_text(payment: Payment, i18n: JsonI18n, lang: str, settings: S
payment_date = payment.created_at.strftime("%Y-%m-%d %H:%M") if payment.created_at else "N/A"
provider_text = {
"yookassa": "YooKassa",
"telegram_stars": "Telegram Stars",
"cryptopay": "CryptoPay",
"freekassa": "FreeKassa",
"severpay": "SeverPay",
"platega": "Platega",
}.get(payment.provider, payment.provider or "Unknown")
provider_text = provider_label_map(settings, lang).get(
payment.provider,
payment.provider or "Unknown",
)
sale_base = (payment.sale_mode or "").split("@", 1)[0].split("|", 1)[0]
traffic_like = sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
+9 -10
View File
@@ -10,6 +10,7 @@ from bot.keyboards.inline.admin_keyboards import (
get_back_to_user_management_keyboard,
)
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 db.dal import panel_sync_dal, payment_dal, user_dal
@@ -70,11 +71,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>"
@@ -209,19 +216,11 @@ async def show_statistics_handler(
if last_payments_models:
stats_text_parts.append(f"\n<b>{_('admin_stats_recent_payments_header')}</b>")
for payment in last_payments_models:
pending_statuses = [
"pending",
"pending_yookassa",
"pending_freekassa",
"pending_platega",
"pending_severpay",
"pending_cryptopay",
]
status_emoji = (
""
if payment.status == "succeeded"
else ""
if payment.status in pending_statuses
if payment.status in pending_statuses()
else ""
)
File diff suppressed because it is too large Load Diff
@@ -1264,8 +1264,13 @@ async def process_delete_user_confirmation_handler(
return
try:
if user_model.panel_user_uuid:
panel_deleted = await panel_service.delete_user_from_panel(user_model.panel_user_uuid)
panel_user_uuids = await user_dal.get_panel_user_uuids_for_user(
session,
target_user_id,
user=user_model,
)
for panel_uuid in panel_user_uuids:
panel_deleted = await panel_service.delete_user_from_panel(panel_uuid)
if not panel_deleted:
await message.answer(
_(
+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"
-800
View File
@@ -1,800 +0,0 @@
import asyncio
import json
import logging
from datetime import datetime, timezone
from typing import Optional
from aiogram import Bot
from aiohttp import web
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import sessionmaker
from yookassa.domain.notification import WebhookNotification
from bot.infra.webhook_queue import enqueue_webhook_event
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
from bot.middlewares.i18n import JsonI18n
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.referral_service import ReferralService
from bot.services.subscription_service import SubscriptionService
from bot.services.yookassa_service import YooKassaService
from bot.utils.config_link import prepare_config_links
from bot.utils.request_security import ip_in_allowlist, request_client_ip
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
from config.settings import Settings
from db.dal import payment_dal, user_billing_dal, user_dal
payment_processing_lock = asyncio.Lock()
YOOKASSA_EVENT_PAYMENT_SUCCEEDED = "payment.succeeded"
YOOKASSA_EVENT_PAYMENT_CANCELED = "payment.canceled"
YOOKASSA_EVENT_PAYMENT_WAITING_FOR_CAPTURE = "payment.waiting_for_capture"
YOOKASSA_WEBHOOK_ALLOWED_IPS = [
"185.71.76.0/27",
"185.71.77.0/27",
"77.75.153.0/25",
"77.75.156.11",
"77.75.156.35",
"77.75.154.128/25",
"2a02:5180::/32",
]
async def process_successful_payment(
session: AsyncSession,
bot: Bot,
payment_info_from_webhook: dict,
i18n: JsonI18n,
settings: Settings,
panel_service: PanelApiService,
subscription_service: SubscriptionService,
referral_service: ReferralService,
lknpd_service: Optional[LknpdService] = None,
):
metadata = payment_info_from_webhook.get("metadata", {})
user_id_str = metadata.get("user_id")
subscription_months_str = metadata.get("subscription_months")
traffic_gb_str = metadata.get("traffic_gb")
sale_mode = metadata.get("sale_mode") or (
"traffic" if settings.traffic_sale_mode else "subscription"
)
sale_mode_base = sale_mode.split("@", 1)[0].split("|", 1)[0]
promo_code_id_str = metadata.get("promo_code_id")
payment_db_id_str = metadata.get("payment_db_id")
auto_renew_subscription_id_str = metadata.get("auto_renew_for_subscription_id")
# For auto-renew payments, payment_db_id may be absent. In that case,
# we will create/ensure a payment record idempotently using provider payment id.
if (
not user_id_str
or (not subscription_months_str and not traffic_gb_str)
or (not payment_db_id_str and not auto_renew_subscription_id_str)
):
logging.error(
f"Missing crucial metadata for payment: {payment_info_from_webhook.get('id')}, metadata: {metadata}" # noqa: E501
)
return
db_user = None
try:
user_id = int(user_id_str)
subscription_months = float(subscription_months_str or 0)
traffic_amount_gb = float(traffic_gb_str) if traffic_gb_str else subscription_months
payment_db_id = (
int(payment_db_id_str) if payment_db_id_str and payment_db_id_str.isdigit() else None
)
is_auto_renew = bool(
auto_renew_subscription_id_str
and not payment_db_id
and sale_mode_base == "subscription"
)
promo_code_id = (
int(promo_code_id_str) if promo_code_id_str and promo_code_id_str.isdigit() else None
)
amount_data = payment_info_from_webhook.get("amount", {})
months_for_record = int(subscription_months) if sale_mode_base == "subscription" else 0
payment_value = float(amount_data.get("value", 0.0))
yk_payment_id_from_hook = payment_info_from_webhook.get("id")
payment_record = None
# If this is an auto-renewal (no payment_db_id in metadata), ensure a payment record exists
if payment_db_id is None and auto_renew_subscription_id_str:
try:
if not yk_payment_id_from_hook:
logging.error(
"Auto-renew webhook missing YooKassa payment id; cannot ensure payment record." # noqa: E501
)
return
from db.dal import payment_dal as _payment_dal
payment_record = await _payment_dal.get_payment_by_provider_payment_id(
session, yk_payment_id_from_hook
)
if not payment_record:
payment_record = await _payment_dal.ensure_payment_with_provider_id(
session,
user_id=user_id,
amount=payment_value,
currency=amount_data.get("currency", settings.DEFAULT_CURRENCY_SYMBOL),
months=months_for_record or 1,
description=payment_info_from_webhook.get("description")
or f"Auto-renewal for {months_for_record or subscription_months} months",
provider="yookassa",
provider_payment_id=yk_payment_id_from_hook,
)
payment_db_id = payment_record.payment_id
except Exception as e_ensure:
logging.error(
f"Failed to ensure payment record for auto-renew webhook (YK {payment_info_from_webhook.get('id')}): {e_ensure}", # noqa: E501
exc_info=True,
)
return
elif payment_db_id is not None:
payment_record = await payment_dal.get_payment_by_db_id(session, payment_db_id)
if not payment_record:
logging.error(
f"Payment record {payment_db_id} not found for YK ID {yk_payment_id_from_hook}."
)
return
if payment_record and payment_record.status == "succeeded":
logging.info(
f"Skipping duplicate YooKassa webhook for payment {payment_db_id} (YK: {yk_payment_id_from_hook})." # noqa: E501
)
return
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user:
logging.error(
f"User {user_id} not found in DB during successful payment processing for YK ID {payment_info_from_webhook.get('id')}. Payment record {payment_db_id}." # noqa: E501
)
await payment_dal.update_payment_status_by_db_id(
session, payment_db_id, "failed_user_not_found", payment_info_from_webhook.get("id")
)
return
except (TypeError, ValueError) as e:
logging.error(f"Invalid metadata format for payment processing: {metadata} - {e}")
if payment_db_id_str and payment_db_id_str.isdigit():
try:
await payment_dal.update_payment_status_by_db_id(
session,
int(payment_db_id_str),
"failed_metadata_error",
payment_info_from_webhook.get("id"),
)
except Exception as e_upd:
logging.error(f"Failed to update payment status after metadata error: {e_upd}")
return
try:
yk_payment_id_from_hook = payment_info_from_webhook.get("id")
payment_before_update = None
if payment_db_id is not None:
payment_before_update = await payment_dal.get_payment_by_db_id(
session,
payment_db_id,
)
should_send_lknpd_receipt = bool(
lknpd_service
and lknpd_service.configured
and payment_info_from_webhook.get("paid") is True
and payment_info_from_webhook.get("status") == "succeeded"
and payment_before_update
and payment_before_update.status != "succeeded"
)
# Try to capture and save payment method for future charges if available
try:
payment_method = payment_info_from_webhook.get("payment_method")
if (
settings.yookassa_autopayments_active
and isinstance(payment_method, dict)
and payment_method.get("saved", False)
):
pm_id = payment_method.get("id")
pm_type = payment_method.get("type")
title = payment_method.get("title")
card = payment_method.get("card") or {}
account_number = payment_method.get("account_number") or payment_method.get(
"account"
)
display_network = None
display_last4 = None
# Build generic display for various instrument types
if (pm_type or "").lower() in {"bank_card", "bank-card", "card"}:
display_network = card.get("card_type") or title or "Card"
display_last4 = card.get("last4")
elif (pm_type or "").lower() in {"yoo_money", "yoomoney", "yoo-money", "wallet"}:
# Normalize wallet display name to avoid leaking full account from title
display_network = "YooMoney"
if isinstance(account_number, str) and len(account_number) >= 4:
display_last4 = account_number[-4:]
else:
display_last4 = None
else:
# Wallets, SBP, etc. — use provided title/type; no last4
display_network = title or (pm_type.upper() if pm_type else "Payment method")
display_last4 = None
await user_billing_dal.upsert_yk_payment_method(
session,
user_id=user_id,
payment_method_id=pm_id,
card_last4=display_last4,
card_network=display_network,
)
try:
await user_billing_dal.upsert_user_payment_method(
session,
user_id=user_id,
provider_payment_method_id=pm_id,
provider="yookassa",
card_last4=display_last4,
card_network=display_network,
set_default=True,
)
except Exception:
logging.exception("Failed to persist multi-card YooKassa method from webhook")
except Exception:
logging.exception("Failed to persist YooKassa payment method from webhook")
months_for_activation = (
int(subscription_months) if sale_mode_base == "subscription" else int(traffic_amount_gb)
)
activation_details = await subscription_service.activate_subscription(
session,
user_id,
months_for_activation,
payment_value,
payment_db_id,
promo_code_id_from_payment=promo_code_id,
provider="yookassa",
sale_mode=sale_mode,
traffic_gb=traffic_amount_gb
if sale_mode_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else None,
)
if not activation_details or not activation_details.get("end_date"):
logging.error(
f"Failed to activate subscription for user {user_id} after payment {yk_payment_id_from_hook}" # noqa: E501
)
raise Exception(f"Subscription Error: Failed to activate for user {user_id}")
updated_payment_record = await payment_dal.update_payment_status_by_db_id(
session,
payment_db_id=payment_db_id,
new_status=payment_info_from_webhook.get("status", "succeeded"),
yk_payment_id=yk_payment_id_from_hook,
)
if not updated_payment_record:
logging.error(
f"Failed to update payment record {payment_db_id} for yk_id {yk_payment_id_from_hook}" # noqa: E501
)
raise Exception(f"DB Error: Could not update payment record {payment_db_id}")
base_subscription_end_date = activation_details["end_date"]
final_end_date_for_user = base_subscription_end_date
applied_promo_bonus_days = activation_details.get("applied_promo_bonus_days", 0)
referral_bonus_info = None
if sale_mode_base == "subscription":
referral_bonus_info = await referral_service.apply_referral_bonuses_for_payment(
session,
user_id,
months_for_activation or int(subscription_months) or 1,
current_payment_db_id=payment_db_id,
skip_if_active_before_payment=False,
)
applied_referee_bonus_days_from_referral: Optional[int] = None
if referral_bonus_info and referral_bonus_info.get("referee_new_end_date"):
final_end_date_for_user = referral_bonus_info["referee_new_end_date"]
applied_referee_bonus_days_from_referral = referral_bonus_info.get(
"referee_bonus_applied_days"
)
# Use user's DB language for all user-facing messages
user_lang = (
db_user.language_code
if db_user and db_user.language_code
else settings.DEFAULT_LANGUAGE
)
_ = lambda key, **kwargs: i18n.gettext(user_lang, key, **kwargs)
traffic_label = (
str(int(traffic_amount_gb))
if float(traffic_amount_gb).is_integer()
else f"{traffic_amount_gb:g}"
)
if should_send_lknpd_receipt:
receipt_item_name = payment_info_from_webhook.get("description")
if not receipt_item_name:
if sale_mode_base in {"traffic", "traffic_package", "topup", "premium_topup"}:
receipt_item_name = settings.LKNPD_RECEIPT_NAME_TRAFFIC.format(gb=traffic_label)
else:
receipt_item_name = settings.LKNPD_RECEIPT_NAME_SUBSCRIPTION.format(
months=int(subscription_months)
)
try:
await lknpd_service.create_income_receipt(
item_name=receipt_item_name,
amount=payment_value,
quantity=1.0,
operation_time=datetime.now(timezone.utc),
)
except Exception:
logging.exception(
"Failed to send LKNPD receipt for payment %s",
yk_payment_id_from_hook,
)
config_link_display, connect_button_url = await prepare_config_links(
settings, activation_details.get("subscription_url") if activation_details else None
)
config_link_text = config_link_display or _("config_link_not_available")
# For auto-renew charges, avoid re-sending config link; send concise message
if sale_mode_base == "subscription" and is_auto_renew and final_end_date_for_user:
details_message = _(
"yookassa_auto_renewal",
months=int(subscription_months),
end_date=final_end_date_for_user.strftime("%Y-%m-%d"),
)
details_markup = None
elif sale_mode_base in {"traffic", "traffic_package", "topup", "premium_topup"}:
details_message = _(
"payment_successful_traffic_full",
traffic_gb=traffic_label,
end_date=final_end_date_for_user.strftime("%Y-%m-%d")
if final_end_date_for_user
else "",
config_link=config_link_text,
)
details_markup = get_connect_and_main_keyboard(
user_lang,
i18n,
settings,
config_link_display,
connect_button_url=connect_button_url,
preserve_message=True,
)
else:
if applied_referee_bonus_days_from_referral and final_end_date_for_user:
inviter_name_display = _("friend_placeholder")
if db_user and db_user.referred_by_id:
inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id)
if inviter:
safe_name = (
sanitize_display_name(inviter.first_name)
if inviter.first_name
else None
)
if safe_name:
inviter_name_display = safe_name
elif inviter.username:
inviter_name_display = username_for_display(
inviter.username, with_at=False
)
details_message = _(
"payment_successful_with_referral_bonus_full",
months=int(subscription_months),
base_end_date=base_subscription_end_date.strftime("%Y-%m-%d"),
bonus_days=applied_referee_bonus_days_from_referral,
final_end_date=final_end_date_for_user.strftime("%Y-%m-%d"),
inviter_name=inviter_name_display,
config_link=config_link_text,
)
elif applied_promo_bonus_days > 0 and final_end_date_for_user:
details_message = _(
"payment_successful_with_promo_full",
months=int(subscription_months),
bonus_days=applied_promo_bonus_days,
end_date=final_end_date_for_user.strftime("%Y-%m-%d"),
config_link=config_link_text,
)
elif final_end_date_for_user:
details_message = _(
"payment_successful_full",
months=int(subscription_months),
end_date=final_end_date_for_user.strftime("%Y-%m-%d"),
config_link=config_link_text,
)
else:
logging.error(
f"Critical error: final_end_date_for_user is None for user {user_id} after successful payment logic." # noqa: E501
)
details_message = _("payment_successful_error_details")
details_markup = get_connect_and_main_keyboard(
user_lang,
i18n,
settings,
config_link_display,
connect_button_url=connect_button_url,
preserve_message=True,
)
try:
await bot.send_message(
user_id,
details_message,
reply_markup=details_markup,
parse_mode="HTML",
disable_web_page_preview=True,
)
except Exception as e_notify:
logging.error(f"Failed to send payment details message to user {user_id}: {e_notify}")
# Send notification about payment
try:
notification_service = NotificationService(bot, settings, i18n)
user = await user_dal.get_user_by_id(session, user_id)
tariff_for_log = None
if payment_before_update and getattr(payment_before_update, "tariff_key", None):
tariff_for_log = payment_before_update.tariff_key
elif updated_payment_record and getattr(updated_payment_record, "tariff_key", None):
tariff_for_log = updated_payment_record.tariff_key
elif payment_record and getattr(payment_record, "tariff_key", None):
tariff_for_log = payment_record.tariff_key
await notification_service.notify_payment_received(
user_id=user_id,
amount=payment_value,
currency=settings.DEFAULT_CURRENCY_SYMBOL,
months=int(subscription_months) if sale_mode_base == "subscription" else 0,
payment_provider="yookassa", # This is specifically for YooKassa webhook
username=user.username if user else None,
traffic_gb=traffic_amount_gb
if sale_mode_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else None,
traffic_is_premium=sale_mode_base == "premium_topup",
tariff_key=tariff_for_log,
)
except Exception as e:
logging.error(f"Failed to send payment notification: {e}")
except Exception as e_process:
logging.error(
f"Error during process_successful_payment main try block for user {user_id}: {e_process}", # noqa: E501
exc_info=True,
)
raise
async def process_cancelled_payment(
session: AsyncSession,
bot: Bot,
payment_info_from_webhook: dict,
i18n: JsonI18n,
settings: Settings,
):
metadata = payment_info_from_webhook.get("metadata", {})
user_id_str = metadata.get("user_id")
payment_db_id_str = metadata.get("payment_db_id")
if not user_id_str or not payment_db_id_str:
logging.warning(
f"Missing metadata in cancelled payment webhook: {payment_info_from_webhook.get('id')}"
)
return
try:
user_id = int(user_id_str)
payment_db_id = int(payment_db_id_str)
except ValueError:
logging.error(f"Invalid metadata in cancelled payment webhook: {metadata}")
return
try:
updated_payment = await payment_dal.update_payment_status_by_db_id(
session,
payment_db_id=payment_db_id,
new_status=payment_info_from_webhook.get("status", "canceled"),
yk_payment_id=payment_info_from_webhook.get("id"),
)
if updated_payment:
logging.info(
f"Payment {payment_db_id} (YK: {payment_info_from_webhook.get('id')}) status updated to cancelled for user {user_id}." # noqa: E501
)
else:
logging.warning(
f"Could not find payment record {payment_db_id} to update status to cancelled for user {user_id}." # noqa: E501
)
db_user = await user_dal.get_user_by_id(session, user_id)
user_lang = settings.DEFAULT_LANGUAGE
if db_user and db_user.language_code:
user_lang = db_user.language_code
_ = lambda key, **kwargs: i18n.gettext(user_lang, key, **kwargs)
await bot.send_message(user_id, _("payment_failed"))
except Exception as e_process_cancel:
logging.error(
f"Error processing cancelled payment for user {user_id}, payment_db_id {payment_db_id}: {e_process_cancel}", # noqa: E501
exc_info=True,
)
raise
async def yookassa_webhook_route(request: web.Request):
try:
bot: Bot = request.app["bot"]
i18n_instance: JsonI18n = request.app["i18n"]
settings: Settings = request.app["settings"]
panel_service: PanelApiService = request.app["panel_service"]
subscription_service: SubscriptionService = request.app["subscription_service"]
referral_service: ReferralService = request.app["referral_service"]
lknpd_service: Optional[LknpdService] = request.app.get("lknpd_service")
async_session_factory: sessionmaker = request.app["async_session_factory"]
except KeyError:
logging.exception("KeyError accessing app context in yookassa_webhook_route.")
return web.Response(status=500, text="Internal Server Error: Missing app context component")
client_ip = request_client_ip(request, trusted_proxies=settings.trusted_proxies)
if not ip_in_allowlist(client_ip, YOOKASSA_WEBHOOK_ALLOWED_IPS):
logging.warning("YooKassa webhook denied from unauthorized IP source.")
return web.Response(status=403)
try:
event_json = await request.json()
notification_object = WebhookNotification(event_json)
payment_data_from_notification = notification_object.object
logging.info(
f"YooKassa Webhook Parsed: Event='{notification_object.event}', "
f"PaymentId='{payment_data_from_notification.id}', Status='{payment_data_from_notification.status}'" # noqa: E501
)
if (
not payment_data_from_notification
or not hasattr(payment_data_from_notification, "metadata")
or payment_data_from_notification.metadata is None
):
logging.error(
f"YooKassa webhook payment {payment_data_from_notification.id} lacks metadata. Cannot process." # noqa: E501
)
return web.Response(status=200, text="ok_error_no_metadata")
# Safely extract payment_method details (SDK objects may not have to_dict)
pm_obj = getattr(payment_data_from_notification, "payment_method", None)
pm_dict = None
if pm_obj is not None:
try:
card_obj = getattr(pm_obj, "card", None)
pm_dict = {
"id": getattr(pm_obj, "id", None),
"type": getattr(pm_obj, "type", None),
"saved": bool(getattr(pm_obj, "saved", False)),
"title": getattr(pm_obj, "title", None),
"account_number": (
getattr(pm_obj, "account_number", None)
if hasattr(pm_obj, "account_number")
else (
getattr(pm_obj, "account", None) if hasattr(pm_obj, "account") else None
)
),
"card": (
{
"first6": getattr(card_obj, "first6", None),
"last4": getattr(card_obj, "last4", None),
"expiry_month": getattr(card_obj, "expiry_month", None),
"expiry_year": getattr(card_obj, "expiry_year", None),
"card_type": getattr(card_obj, "card_type", None),
}
if card_obj is not None
else None
),
}
except Exception:
logging.exception("Failed to serialize YooKassa payment_method from webhook")
pm_dict = None
payment_dict_for_processing = {
"id": str(payment_data_from_notification.id),
"status": str(payment_data_from_notification.status),
"paid": bool(payment_data_from_notification.paid),
"amount": {
"value": str(payment_data_from_notification.amount.value),
"currency": str(payment_data_from_notification.amount.currency),
}
if payment_data_from_notification.amount
else {},
"metadata": dict(payment_data_from_notification.metadata),
"description": str(payment_data_from_notification.description)
if payment_data_from_notification.description
else None,
"payment_method": pm_dict,
}
if notification_object.event in {
YOOKASSA_EVENT_PAYMENT_SUCCEEDED,
YOOKASSA_EVENT_PAYMENT_CANCELED,
}:
queued = await enqueue_webhook_event(
settings,
"yookassa",
{
"event": notification_object.event,
"payment": payment_dict_for_processing,
},
event_id=f"{notification_object.event}:{payment_dict_for_processing.get('id')}",
)
if queued:
return web.Response(status=200, text="queued")
async with payment_processing_lock:
async with async_session_factory() as session:
try:
if notification_object.event == YOOKASSA_EVENT_PAYMENT_SUCCEEDED:
if (
payment_dict_for_processing.get("paid")
and payment_dict_for_processing.get("status") == "succeeded"
):
await process_successful_payment(
session,
bot,
payment_dict_for_processing,
i18n_instance,
settings,
panel_service,
subscription_service,
referral_service,
lknpd_service,
)
await session.commit()
else:
logging.warning(
f"Payment Succeeded event for {payment_dict_for_processing.get('id')} " # noqa: E501
f"but data not as expected: status='{payment_dict_for_processing.get('status')}', " # noqa: E501
f"paid='{payment_dict_for_processing.get('paid')}'"
)
elif notification_object.event == YOOKASSA_EVENT_PAYMENT_CANCELED:
await process_cancelled_payment(
session, bot, payment_dict_for_processing, i18n_instance, settings
)
await session.commit()
elif notification_object.event == YOOKASSA_EVENT_PAYMENT_WAITING_FOR_CAPTURE:
# Bind-only flow: save method and cancel auth if metadata has bind_only
metadata = payment_dict_for_processing.get("metadata", {}) or {}
if (
settings.yookassa_autopayments_active
and metadata.get("bind_only") == "1"
):
try:
user_id_str = metadata.get("user_id")
if user_id_str and user_id_str.isdigit():
user_id = int(user_id_str)
payment_method = payment_dict_for_processing.get(
"payment_method"
)
if isinstance(payment_method, dict) and payment_method.get(
"id"
):
pm_type = payment_method.get("type")
title = payment_method.get("title")
card = payment_method.get("card") or {}
account_number = payment_method.get(
"account_number"
) or payment_method.get("account")
display_network = None
display_last4 = None
if (pm_type or "").lower() in {
"bank_card",
"bank-card",
"card",
}:
display_network = (
card.get("card_type") or title or "Card"
)
display_last4 = card.get("last4")
elif (pm_type or "").lower() in {
"yoo_money",
"yoomoney",
"yoo-money",
"wallet",
}:
# Normalize wallet display name to avoid leaking full account from title # noqa: E501
display_network = "YooMoney"
if (
isinstance(account_number, str)
and len(account_number) >= 4
):
display_last4 = account_number[-4:]
else:
display_last4 = None
else:
display_network = title or (
pm_type.upper() if pm_type else "Payment method"
)
display_last4 = None
await user_billing_dal.upsert_yk_payment_method(
session,
user_id=user_id,
payment_method_id=payment_method.get("id"),
card_last4=display_last4,
card_network=display_network,
)
await session.commit()
# Save multi-card entry and mark default if first
try:
from db.dal import user_billing_dal as ub
await ub.upsert_user_payment_method(
session,
user_id=user_id,
provider_payment_method_id=payment_method.get("id"),
provider="yookassa",
card_last4=display_last4,
card_network=display_network,
set_default=True,
)
await session.commit()
except Exception:
await session.rollback()
# Notify user about successful binding with Back button
try:
# Use user's DB language for bind success notification
i18n_lang = settings.DEFAULT_LANGUAGE
from db.dal import user_dal
db_user = await user_dal.get_user_by_id(
session, user_id
)
if db_user and db_user.language_code:
i18n_lang = db_user.language_code
_ = lambda key, **kwargs: i18n_instance.gettext(
i18n_lang, key, **kwargs
)
from bot.keyboards.inline.user_keyboards import (
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
),
)
except Exception:
pass
# Attempt to cancel the authorization to avoid charge hold
try:
yk: YooKassaService = request.app.get(
"yookassa_service"
)
if yk:
await yk.cancel_payment(
payment_dict_for_processing.get("id")
)
except Exception:
logging.exception(
"Failed to cancel bind-only payment auth"
)
except Exception:
logging.exception(
"Failed to handle bind-only waiting_for_capture webhook"
)
except Exception:
await session.rollback()
logging.exception(
"Error processing YooKassa webhook event '%s' for YK Payment ID %s in DB transaction.", # noqa: E501
notification_object.event,
payment_dict_for_processing.get("id"),
)
return web.Response(status=500, text="internal_processing_error")
return web.Response(status=200, text="ok")
except json.JSONDecodeError:
logging.error("YooKassa Webhook: Invalid JSON received.")
return web.Response(status=400, text="bad_request_invalid_json")
except Exception:
logging.exception("YooKassa Webhook general processing error.")
return web.Response(status=500, text="internal_error")
+25
View File
@@ -16,7 +16,12 @@ from bot.services.promo_code_service import PromoCodeService
from bot.services.subscription_service import SubscriptionService
from bot.states.user_states import UserPromoStates
from bot.utils.callback_answer import safe_answer_callback
from bot.utils.install_links import (
append_install_share_link_text,
ensure_user_install_guide_links,
)
from config.settings import Settings
from db.dal import user_dal
from .start import send_main_menu
@@ -133,10 +138,12 @@ async def process_promo_code_input(
from bot.services.notification_service import NotificationService
notification_service = NotificationService(bot, settings, i18n)
db_user = await user_dal.get_user_by_id(session, user.id)
await notification_service.notify_suspicious_promo_attempt(
user_id=user.id,
username=user.username,
first_name=user.first_name,
email=getattr(db_user, "email", None) if db_user else None,
suspicious_input=code_input,
)
except Exception as e:
@@ -160,12 +167,30 @@ async def process_promo_code_input(
end_date=(new_end_date.strftime("%d.%m.%Y %H:%M:%S") if new_end_date else "N/A"),
config_link=config_link_text,
)
install_links = await ensure_user_install_guide_links(session, settings, user.id)
install_share_url = install_links.public_share_url
if install_share_url:
try:
await session.commit()
response_to_user_text = append_install_share_link_text(
response_to_user_text,
_,
install_share_url,
)
except Exception:
await session.rollback()
logging.exception(
"Failed to persist install guide share token for promo user %s.",
user.id,
)
install_share_url = None
reply_markup = get_connect_and_main_keyboard(
current_lang,
i18n,
settings,
config_link_display,
connect_button_url=connect_button_url,
install_share_url=install_share_url,
)
else:
await session.commit()
+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]:
+91 -4
View File
@@ -17,12 +17,17 @@ from bot.keyboards.inline.user_keyboards import (
get_language_selection_keyboard,
get_main_menu_inline_keyboard,
)
from bot.middlewares.i18n import JsonI18n
from bot.middlewares.i18n import JsonI18n, normalize_locale_language_code
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.install_links import (
append_install_share_link_text,
ensure_user_install_guide_links,
)
from bot.utils.text_sanitizer import sanitize_display_name, sanitize_username
from config.settings import Settings
from db.dal import user_dal
@@ -387,11 +392,13 @@ 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_|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")
)
)
@@ -408,6 +415,8 @@ async def start_command_handler(
page_ref_match: Optional[re.Match] = None,
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)
@@ -465,10 +474,36 @@ async def start_command_handler(
await message.answer(_("admin_user_card_error"))
return
if ticket_match:
ticket_id = int(ticket_match.group(1))
base_url = (settings.SUBSCRIPTION_MINI_APP_URL or "").strip()
if base_url:
ticket_url = f"{base_url.rstrip('/')}/support/{ticket_id}"
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=i18n.gettext(current_lang, "wa_support_open_ticket")
if i18n
else "Открыть тикет",
web_app=types.WebAppInfo(url=ticket_url),
)
]
]
)
await message.answer(
i18n.gettext(current_lang, "wa_support_open_ticket_hint")
if i18n
else "Откройте тикет в Mini App.",
reply_markup=keyboard,
)
return
referred_by_user_id: Optional[int] = None
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)
@@ -491,6 +526,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.")
@@ -501,18 +538,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)
@@ -600,6 +643,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:
@@ -653,9 +703,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:
@@ -688,6 +745,23 @@ async def start_command_handler(
),
config_link=config_link_text,
)
install_links = await ensure_user_install_guide_links(session, settings, user_id)
install_share_url = install_links.public_share_url
if install_share_url:
try:
await session.commit()
promo_success_text = append_install_share_link_text(
promo_success_text,
_,
install_share_url,
)
except Exception:
await session.rollback()
logging.exception(
"Failed to persist install guide share token for promo user %s.",
user_id,
)
install_share_url = None
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
@@ -699,6 +773,7 @@ async def start_command_handler(
settings,
config_link_display,
connect_button_url=connect_button_url,
install_share_url=install_share_url,
),
parse_mode="HTML",
)
@@ -864,7 +939,12 @@ async def select_language_callback_handler(
try:
lang_payload = callback.data.split("_", 2)[2]
lang_code, _, return_target = lang_payload.partition(":")
raw_lang_code, _, return_target = lang_payload.partition(":")
lang_code = normalize_locale_language_code(
raw_lang_code,
set(i18n.locales_data.keys()),
prefer_known_base=True,
)
except IndexError:
await safe_answer_callback(
callback,
@@ -872,6 +952,13 @@ async def select_language_callback_handler(
show_alert=True,
)
return
if lang_code not in i18n.locales_data:
await safe_answer_callback(
callback,
"Unsupported language.",
show_alert=True,
)
return
user_id = callback.from_user.id
try:
@@ -1,13 +1,12 @@
from aiogram import Router
from . import core, payment_methods, payments
from . import core, payments
router = Router(name="user_subscription_router")
# Include sub-routers
router.include_router(core.router)
router.include_router(payments.router)
router.include_router(payment_methods.router)
# Re-export commonly used entrypoints for backward compatibility
from .core import ( # noqa: E402,F401
+242 -23
View File
@@ -11,6 +11,8 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder
from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.inline.user_keyboards import (
callback_context_from_back_callback,
callback_suffix_for_context,
get_autorenew_confirm_keyboard,
get_back_to_main_menu_markup,
get_hwid_device_packages_keyboard,
@@ -19,10 +21,16 @@ from bot.keyboards.inline.user_keyboards import (
get_tariff_catalog_keyboard,
get_tariff_packages_keyboard,
get_tariff_periods_keyboard,
sale_mode_with_callback_context,
subscription_options_callback,
)
from bot.middlewares.i18n import JsonI18n
from bot.services.panel_api_service import PanelApiService
from bot.services.subscription_service import SubscriptionService
from bot.utils.install_links import (
append_install_share_link_text,
ensure_user_install_guide_links,
)
from config.settings import Settings
from db.dal import subscription_dal, user_billing_dal
from db.models import Subscription
@@ -56,11 +64,30 @@ def _has_multiple_enabled_tariffs(settings: Settings) -> bool:
def _tariff_purchase_markup(
tariff, current_lang: str, i18n: JsonI18n, settings: Settings
tariff,
current_lang: str,
i18n: JsonI18n,
settings: Settings,
back_callback: str = "main_action:subscribe",
callback_context: Optional[str] = None,
) -> InlineKeyboardMarkup:
if tariff.billing_model == "period":
return get_tariff_periods_keyboard(tariff, current_lang, i18n, settings)
return get_tariff_packages_keyboard(tariff, tariff.traffic_packages.rub, current_lang, i18n)
return get_tariff_periods_keyboard(
tariff,
current_lang,
i18n,
settings,
back_callback=back_callback,
callback_context=callback_context,
)
return get_tariff_packages_keyboard(
tariff,
tariff.traffic_packages.rub,
current_lang,
i18n,
back_callback=back_callback,
callback_context=callback_context,
)
def _tariff_purchase_text(tariff, current_lang: str, i18n: JsonI18n, settings: Settings) -> str:
@@ -71,6 +98,36 @@ def _tariff_purchase_text(tariff, current_lang: str, i18n: JsonI18n, settings: S
return f"{tariff.name(current_lang)}\n{tariff.description(current_lang)}".strip()
def _with_subscription_purchase_description(
text: str,
settings: Settings,
current_lang: str,
*,
include: bool,
) -> str:
if not include:
return text
description_resolver = getattr(settings, "subscription_purchase_description", None)
description = description_resolver(current_lang) if callable(description_resolver) else ""
if not description:
return text
return f"{description}\n\n{text}"
def _format_premium_bytes(value: object) -> str:
try:
bytes_value = max(0, int(value or 0))
except (TypeError, ValueError):
bytes_value = 0
return f"{bytes_value / 2**30:.2f} GB"
def _format_premium_usage_limit(active: dict[str, object]) -> str:
used = _format_premium_bytes(active.get("premium_used_bytes"))
limit = _format_premium_bytes(active.get("premium_limit_bytes"))
return f"{used} из {limit}"
async def display_subscription_options(
event: Union[types.Message, types.CallbackQuery],
i18n_data: dict,
@@ -98,13 +155,39 @@ async def display_subscription_options(
tariffs_config = getattr(settings, "tariffs_config", None)
if tariffs_config:
enabled_tariffs = list(tariffs_config.enabled_tariffs)
callback_context = callback_context_from_back_callback(back_callback)
if len(enabled_tariffs) == 1:
tariff = enabled_tariffs[0]
text_content = _tariff_purchase_text(tariff, current_lang, i18n, settings)
reply_markup = _tariff_purchase_markup(tariff, current_lang, i18n, settings)
text_content = _with_subscription_purchase_description(
text_content,
settings,
current_lang,
include=tariff.billing_model == "period",
)
reply_markup = _tariff_purchase_markup(
tariff,
current_lang,
i18n,
settings,
back_callback=back_callback,
callback_context=callback_context,
)
else:
text_content = get_text("select_subscription_period")
reply_markup = get_tariff_catalog_keyboard(enabled_tariffs, current_lang, i18n)
text_content = _with_subscription_purchase_description(
text_content,
settings,
current_lang,
include=any(tariff.billing_model == "period" for tariff in enabled_tariffs),
)
reply_markup = get_tariff_catalog_keyboard(
enabled_tariffs,
current_lang,
i18n,
back_callback=back_callback,
callback_context=callback_context,
)
target_message_obj = event.message if isinstance(event, types.CallbackQuery) else event
if isinstance(event, types.CallbackQuery):
try:
@@ -137,6 +220,12 @@ async def display_subscription_options(
if traffic_mode
else get_text("select_subscription_period")
)
text_content = _with_subscription_purchase_description(
text_content,
settings,
current_lang,
include=not traffic_mode,
)
reply_markup = get_subscription_options_keyboard(
options,
currency_symbol_val,
@@ -144,6 +233,7 @@ async def display_subscription_options(
i18n,
traffic_mode=traffic_mode,
back_callback=back_callback,
callback_context=callback_context_from_back_callback(back_callback),
)
else:
text_content = get_text("no_subscription_options_available")
@@ -193,14 +283,29 @@ async def select_tariff_callback(
if not config or not callback.message:
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
return
tariff_key = callback.data.split(":", 2)[2]
parts = callback.data.split(":")
tariff_key = parts[2] if len(parts) > 2 else ""
callback_context = parts[3] if len(parts) > 3 else None
try:
tariff = config.require(tariff_key)
except Exception:
await callback.answer(get_text("error_try_again"), show_alert=True)
return
markup = _tariff_purchase_markup(tariff, current_lang, i18n, settings)
markup = _tariff_purchase_markup(
tariff,
current_lang,
i18n,
settings,
back_callback=subscription_options_callback(callback_context),
callback_context=callback_context,
)
text = _tariff_purchase_text(tariff, current_lang, i18n, settings)
text = _with_subscription_purchase_description(
text,
settings,
current_lang,
include=tariff.billing_model == "period",
)
await callback.message.edit_text(text, reply_markup=markup)
await callback.answer()
@@ -216,7 +321,12 @@ async def select_tariff_period_callback(
if not config or not callback.message:
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
return
_, _, tariff_key, months_raw = callback.data.split(":", 3)
parts = callback.data.split(":")
if len(parts) < 4:
await callback.answer(get_text("error_try_again"), show_alert=True)
return
tariff_key, months_raw = parts[2], parts[3]
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")
@@ -232,7 +342,9 @@ async def select_tariff_period_callback(
current_lang,
i18n,
settings,
sale_mode=f"subscription@{tariff.key}",
sale_mode=sale_mode_with_callback_context(f"subscription@{tariff.key}", callback_context),
back_callback=f"tariff:select:{tariff.key}{callback_suffix_for_context(callback_context)}",
user_id=callback.from_user.id,
)
await callback.message.edit_text(get_text("choose_payment_method"), reply_markup=markup)
await callback.answer()
@@ -249,7 +361,12 @@ async def select_tariff_package_callback(
if not config or not callback.message:
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
return
_, _, tariff_key, gb_raw = callback.data.split(":", 3)
parts = callback.data.split(":")
if len(parts) < 4:
await callback.answer(get_text("error_try_again"), show_alert=True)
return
tariff_key, gb_raw = parts[2], parts[3]
callback_context = parts[4] if len(parts) > 4 else None
tariff = config.require(tariff_key)
gb = float(gb_raw)
packages = (
@@ -264,6 +381,12 @@ async def select_tariff_package_callback(
sale_mode = (
f"{'traffic_package' if tariff.billing_model == 'traffic' else 'topup'}@{tariff.key}"
)
sale_mode = sale_mode_with_callback_context(sale_mode, callback_context)
back_callback = (
f"tariff:select:{tariff.key}{callback_suffix_for_context(callback_context)}"
if tariff.billing_model == "traffic"
else "tariff_topup:list"
)
markup = get_payment_method_keyboard(
gb,
package.price,
@@ -273,6 +396,8 @@ async def select_tariff_package_callback(
i18n,
settings,
sale_mode=sale_mode,
back_callback=back_callback,
user_id=callback.from_user.id,
)
await callback.message.edit_text(get_text("choose_payment_method_traffic"), reply_markup=markup)
await callback.answer()
@@ -345,7 +470,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:
@@ -384,6 +509,8 @@ async def select_tariff_premium_package_callback(
i18n,
settings,
sale_mode=f"premium_topup@{tariff.key}",
back_callback="tariff_topup:list",
user_id=callback.from_user.id,
)
await callback.message.edit_text(get_text("choose_payment_method_traffic"), reply_markup=markup)
await callback.answer()
@@ -412,10 +539,14 @@ async def hwid_devices_list_callback(
await callback.answer(get_text("hwid_devices_unlimited_no_topup"), show_alert=True)
return
tariff = config.require(active["tariff_key"])
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 []
if not packages:
await callback.answer(get_text("no_hwid_device_packages_available"), show_alert=True)
return
renewal_available = bool(active.get("device_topup_renewal_available"))
markup = get_hwid_device_packages_keyboard(
tariff,
packages,
@@ -423,14 +554,29 @@ async def hwid_devices_list_callback(
i18n,
settings,
back_callback="main_action:my_devices",
renewal=renewal_available,
)
text_key = (
"select_hwid_device_renewal_package" if renewal_available else "select_hwid_device_package"
)
await callback.message.edit_text(
get_text(
text_key,
date=active.get("extra_hwid_devices_valid_until_text") or "",
),
reply_markup=markup,
)
await callback.message.edit_text(get_text("select_hwid_device_package"), reply_markup=markup)
await callback.answer()
@router.callback_query(F.data.startswith("hwid_devices:package:"))
@router.callback_query(F.data.startswith("hwid_devices:renewal_package:"))
async def hwid_devices_package_callback(
callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession
callback: types.CallbackQuery,
i18n_data: dict,
settings: Settings,
session: AsyncSession,
subscription_service: SubscriptionService,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: JsonI18n = i18n_data.get("i18n_instance")
@@ -439,8 +585,11 @@ async def hwid_devices_package_callback(
if not config or not callback.message:
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
return
_, _, tariff_key, count_raw = callback.data.split(":", 3)
_, action, tariff_key, count_raw = callback.data.split(":", 3)
tariff = config.require(tariff_key)
if tariff.billing_model != "period":
await callback.answer(get_text("no_hwid_device_packages_available"), show_alert=True)
return
count = int(count_raw)
package = next(
(
@@ -453,15 +602,39 @@ async def hwid_devices_package_callback(
if not package:
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(
session,
user_id=callback.from_user.id,
device_count=count,
tariff_key=tariff.key,
renewal=action == "renewal_package",
currency="rub",
)
stars_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="stars",
)
if not rub_quote and not stars_quote:
await callback.answer(get_text("error_try_again"), show_alert=True)
return
markup = get_payment_method_keyboard(
count,
package.price,
None,
float(rub_quote.get("price") if rub_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,
current_lang,
i18n,
settings,
sale_mode=f"hwid_devices@{tariff.key}",
sale_mode=f"{sale_mode_base}@{tariff.key}",
back_callback="hwid_devices:list",
user_id=callback.from_user.id,
)
await callback.message.edit_text(
get_text("choose_payment_method_hwid_devices"), reply_markup=markup
@@ -539,7 +712,9 @@ async def tariff_change_select_callback(
if not db_sub:
await callback.answer("Error", show_alert=True)
return
options = subscription_service.calculate_tariff_switch_options(db_sub, target)
options = await subscription_service.calculate_tariff_switch_options_with_hwid(
session, db_sub, target
)
rows = []
if options["mode"] == "period_to_period":
rows.append(
@@ -626,7 +801,9 @@ async def tariff_change_confirm_apply_callback(
if not db_sub:
await callback.answer("Error", show_alert=True)
return
options = subscription_service.calculate_tariff_switch_options(db_sub, target)
options = await subscription_service.calculate_tariff_switch_options_with_hwid(
session, db_sub, target
)
if mode == "recalc_days":
action_text = f"после перехода останется {options.get('recalc_days', 0)} дн."
elif mode == "convert_days_to_gb":
@@ -731,6 +908,8 @@ async def tariff_change_pay_callback(
i18n,
settings,
sale_mode=f"tariff_upgrade@{tariff_key}",
back_callback=f"tariff_change:confirm_pay:{tariff_key}:{amount_raw}",
user_id=callback.from_user.id,
)
await callback.message.edit_text("Выберите способ оплаты", reply_markup=markup)
await callback.answer()
@@ -900,7 +1079,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"
@@ -918,12 +1097,50 @@ async def my_subscription_command_handler(
local_sub = await subscription_dal.get_active_subscription_by_user_id(
session, event.from_user.id
)
install_links = await ensure_user_install_guide_links(
session,
settings,
event.from_user.id,
local_subscription=local_sub,
)
install_url = install_links.personal_url
install_share_url = install_links.public_share_url
if install_share_url:
try:
await session.commit()
text = append_install_share_link_text(text, get_text, install_share_url)
except Exception:
await session.rollback()
logging.exception(
"Failed to persist install guide share token for user %s.",
event.from_user.id,
)
install_share_url = None
# Build rows to prepend above the base "back" markup
prepend_rows = []
# 1) Connect button: prefer the actual subscription URL; fall back to mini-app
cfg_link_val = connect_button_url or config_link_display
if cfg_link_val:
if install_url:
prepend_rows.append(
[
InlineKeyboardButton(
text=get_text("connect_button"),
web_app=WebAppInfo(url=install_url),
)
]
)
if install_share_url:
prepend_rows.append(
[
InlineKeyboardButton(
text=get_text("install_guide_share_button"),
url=install_share_url,
)
]
)
elif cfg_link_val:
prepend_rows.append(
[
InlineKeyboardButton(
@@ -1007,7 +1224,8 @@ async def my_subscription_command_handler(
try:
tariff_for_devices = settings.tariffs_config.require(local_sub.tariff_key)
if (
tariff_for_devices.hwid_device_packages
tariff_for_devices.billing_model == "period"
and tariff_for_devices.hwid_device_packages
and tariff_for_devices.hwid_device_packages.rub
):
prepend_rows.append(
@@ -1223,7 +1441,8 @@ async def my_devices_command_handler(
try:
tariff_for_devices = settings.tariffs_config.require(active["tariff_key"])
if (
tariff_for_devices.hwid_device_packages
tariff_for_devices.billing_model == "period"
and tariff_for_devices.hwid_device_packages
and tariff_for_devices.hwid_device_packages.rub
):
devices_kb.append(
@@ -1,550 +0,0 @@
from typing import List, Optional
from aiogram import F, Router, types
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from bot.keyboards.inline.user_keyboards import (
get_bind_url_keyboard,
get_payment_method_delete_confirm_keyboard,
get_payment_method_details_keyboard,
get_payment_methods_list_keyboard,
)
from bot.middlewares.i18n import JsonI18n
from bot.services.yookassa_service import YooKassaService
from config.settings import Settings
from db.dal import user_billing_dal
from db.models import Payment
router = Router(name="user_subscription_payment_methods_router")
@router.callback_query(F.data == "pm:manage")
async def payment_methods_manage(
callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not settings.yookassa_autopayments_active:
try:
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
await callback.answer(_("error_service_unavailable"), show_alert=True)
except Exception:
pass
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
from db.dal.user_billing_dal import list_user_payment_methods
get_text = _
methods = await list_user_payment_methods(session, callback.from_user.id)
cards: List[tuple] = []
def _is_yoomoney_network(network: Optional[str]) -> bool:
s = (network or "").lower()
return "yoomoney" in s or "yoo money" in s or "yoo-money" in s
def _extract_last4(text: str) -> Optional[str]:
digits = "".join(ch for ch in text if ch.isdigit())
return digits[-4:] if len(digits) >= 4 else None
def _format_pm_title(network: Optional[str], last4: Optional[str]) -> str:
if _is_yoomoney_network(network):
l4 = last4 or _extract_last4(network or "")
if l4:
return get_text("payment_method_wallet_title", last4=l4)
return get_text("payment_method_wallet_title", last4="****")
if last4:
network_name = network or get_text("payment_network_card")
return get_text("payment_method_card_title", network=network_name, last4=last4)
network_name = network or get_text("payment_network_generic")
return get_text("payment_method_generic_title", network=network_name)
for m in methods:
title = _format_pm_title(m.card_network, m.card_last4)
cards.append((str(m.method_id), title if not m.is_default else f"{title}"))
text = get_text("payment_methods_title")
if not cards:
text += "\n\n" + get_text("payment_method_none")
await callback.message.edit_text(
text, reply_markup=get_payment_methods_list_keyboard(cards, 0, current_lang, i18n)
)
try:
await callback.answer()
except Exception:
pass
@router.callback_query(F.data == "pm:bind")
async def payment_method_bind(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
session: AsyncSession,
yookassa_service: YooKassaService,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not settings.yookassa_autopayments_active:
try:
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
await callback.answer(_("error_service_unavailable"), show_alert=True)
except Exception:
pass
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
metadata = {"user_id": str(callback.from_user.id), "bind_only": "1"}
resp = await yookassa_service.create_payment(
amount=1.00,
currency="RUB",
description="Bind card",
metadata=metadata,
receipt_email=settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL,
save_payment_method=True,
capture=False,
bind_only=True,
)
if not resp or not resp.get("confirmation_url"):
await callback.answer(_("error_payment_gateway"), show_alert=True)
return
await callback.message.edit_text(
_("payment_methods_title"),
reply_markup=get_bind_url_keyboard(resp["confirmation_url"], current_lang, i18n),
)
try:
await callback.answer()
except Exception:
pass
@router.callback_query(F.data.startswith("pm:delete_confirm"))
async def payment_method_delete_confirm(
callback: types.CallbackQuery, settings: Settings, i18n_data: dict
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not settings.yookassa_autopayments_active:
try:
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
await callback.answer(_("error_service_unavailable"), show_alert=True)
except Exception:
pass
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
parts = callback.data.split(":", 2)
pm_id = parts[2] if len(parts) >= 3 else ""
await callback.message.edit_text(
_("payment_method_delete_confirm"),
reply_markup=get_payment_method_delete_confirm_keyboard(pm_id, current_lang, i18n),
)
try:
await callback.answer()
except Exception:
pass
@router.callback_query(F.data.startswith("pm:delete"))
async def payment_method_delete(
callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not settings.yookassa_autopayments_active:
try:
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
await callback.answer(_("error_service_unavailable"), show_alert=True)
except Exception:
pass
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
parts = callback.data.split(":", 2)
pm_id_raw = parts[2] if len(parts) >= 3 else ""
deleted = False
try:
from db.dal.user_billing_dal import (
delete_user_payment_method,
delete_user_payment_method_by_provider_id,
list_user_payment_methods,
)
if pm_id_raw:
if pm_id_raw.isdigit():
deleted = await delete_user_payment_method(
session, callback.from_user.id, int(pm_id_raw)
)
else:
deleted = await delete_user_payment_method_by_provider_id(
session, callback.from_user.id, pm_id_raw
)
try:
legacy_deleted = await user_billing_dal.delete_yk_payment_method(
session, callback.from_user.id
)
deleted = deleted or legacy_deleted
except Exception:
pass
await session.commit()
methods = await list_user_payment_methods(session, callback.from_user.id)
text = _("payment_methods_title")
cards = []
for m in methods:
def _is_yoomoney_network(network: Optional[str]) -> bool:
s = (network or "").lower()
return "yoomoney" in s or "yoo money" in s or "yoo-money" in s
def _extract_last4(text: str) -> Optional[str]:
digits = "".join(ch for ch in text if ch.isdigit())
return digits[-4:] if len(digits) >= 4 else None
def _format_pm_title(network: Optional[str], last4: Optional[str]) -> str:
if _is_yoomoney_network(network):
l4 = last4 or _extract_last4(network or "")
if l4:
return _("payment_method_wallet_title", last4=l4)
return _("payment_method_wallet_title", last4="****")
if last4:
network_name = network or _("payment_network_card")
return _("payment_method_card_title", network=network_name, last4=last4)
network_name = network or _("payment_network_generic")
return _("payment_method_generic_title", network=network_name)
title = _format_pm_title(m.card_network, m.card_last4)
cards.append((str(m.method_id), title if not m.is_default else f"{title}"))
if not cards:
text += "\n\n" + _("payment_method_none")
msg = _("payment_method_deleted_success") if deleted else _("error_try_again")
await callback.message.edit_text(
f"{msg}\n\n{text}",
reply_markup=get_payment_methods_list_keyboard(cards, 0, current_lang, i18n),
)
try:
await callback.answer()
except Exception:
pass
return
except Exception:
await session.rollback()
try:
await callback.answer(_("error_try_again"), show_alert=True)
except Exception:
pass
@router.callback_query(F.data.startswith("pm:view"))
async def payment_method_view(
callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not settings.yookassa_autopayments_active:
try:
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
await callback.answer(_("error_service_unavailable"), show_alert=True)
except Exception:
pass
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
billing = await user_billing_dal.get_user_billing(session, callback.from_user.id)
if not billing or not billing.yookassa_payment_method_id:
from db.dal.user_billing_dal import list_user_payment_methods
methods = await list_user_payment_methods(session, callback.from_user.id)
if not methods:
await callback.answer(_("payment_method_none"), show_alert=True)
return
parts = callback.data.split(":", 2)
pm_id = parts[2] if len(parts) >= 3 else str(methods[0].method_id)
sel = next(
(
m
for m in methods
if str(m.method_id) == pm_id or m.provider_payment_method_id == pm_id
),
methods[0],
)
def _is_yoomoney_network(network: Optional[str]) -> bool:
s = (network or "").lower()
return "yoomoney" in s or "yoo money" in s or "yoo-money" in s
def _extract_last4(text: str) -> Optional[str]:
digits = "".join(ch for ch in text if ch.isdigit())
return digits[-4:] if len(digits) >= 4 else None
def _format_pm_title(network: Optional[str], last4: Optional[str]) -> str:
if _is_yoomoney_network(network):
l4 = last4 or _extract_last4(network or "")
if l4:
return _("payment_method_wallet_title", last4=l4)
return _("payment_method_wallet_title", last4="****")
if last4:
network_name = network or _("payment_network_card")
return _("payment_method_card_title", network=network_name, last4=last4)
network_name = network or _("payment_network_generic")
return _("payment_method_generic_title", network=network_name)
title = _format_pm_title(sel.card_network, sel.card_last4)
added_at = sel.created_at.strftime("%Y-%m-%d") if getattr(sel, "created_at", None) else ""
last_tx = ""
try:
stmt = (
select(Payment)
.where(
Payment.user_id == callback.from_user.id,
Payment.status == "succeeded",
Payment.provider == "yookassa",
)
.order_by(Payment.created_at.desc())
.limit(1)
)
result = await session.execute(stmt)
lp = result.scalar_one_or_none()
if lp and lp.created_at:
last_tx = lp.created_at.strftime("%Y-%m-%d")
except Exception:
pass
details = f"{title}\n{_('payment_method_added_at', date=added_at)}\n{_('payment_method_last_tx', date=last_tx)}" # noqa: E501
await callback.message.edit_text(
details,
reply_markup=get_payment_method_details_keyboard(
str(sel.method_id), current_lang, i18n
),
)
try:
await callback.answer()
except Exception:
pass
return
added_at = (
billing.created_at.strftime("%Y-%m-%d") if getattr(billing, "created_at", None) else ""
)
last_tx = ""
try:
stmt = (
select(Payment)
.where(
Payment.user_id == callback.from_user.id,
Payment.status == "succeeded",
Payment.provider == "yookassa",
)
.order_by(Payment.created_at.desc())
.limit(1)
)
result = await session.execute(stmt)
last_payment = result.scalar_one_or_none()
if last_payment and last_payment.created_at:
last_tx = last_payment.created_at.strftime("%Y-%m-%d")
except Exception:
pass
def _is_yoomoney_network(network: Optional[str]) -> bool:
s = (network or "").lower()
return "yoomoney" in s or "yoo money" in s or "yoo-money" in s
def _extract_last4(text: str) -> Optional[str]:
digits = "".join(ch for ch in text if ch.isdigit())
return digits[-4:] if len(digits) >= 4 else None
def _format_pm_title(network: Optional[str], last4: Optional[str]) -> str:
if _is_yoomoney_network(network):
l4 = last4 or _extract_last4(network or "")
if l4:
return _("payment_method_wallet_title", last4=l4)
return _("payment_method_wallet_title", last4="****")
if last4:
network_name = network or _("payment_network_card")
return _("payment_method_card_title", network=network_name, last4=last4)
network_name = network or _("payment_network_generic")
return _("payment_method_generic_title", network=network_name)
title = _format_pm_title(billing.card_network, billing.card_last4)
details = f"{title}\n{_('payment_method_added_at', date=added_at)}\n{_('payment_method_last_tx', date=last_tx)}" # noqa: E501
await callback.message.edit_text(
details,
reply_markup=get_payment_method_details_keyboard(
billing.yookassa_payment_method_id, current_lang, i18n
),
)
try:
await callback.answer()
except Exception:
pass
@router.callback_query(F.data.startswith("pm:history"))
async def payment_method_history(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
session: AsyncSession,
yookassa_service: YooKassaService,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not settings.yookassa_autopayments_active:
try:
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
await callback.answer(_("error_service_unavailable"), show_alert=True)
except Exception:
pass
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
from db.dal import payment_dal
payments = await payment_dal.get_recent_payment_logs_with_user(session, limit=30, offset=0)
user_payments = [p for p in payments if p.user_id == callback.from_user.id]
selected_pm_provider_id: Optional[str] = None
pm_filter_requested: bool = False
try:
split_a, split_b, split_pm_id = callback.data.split(":", 2)
if split_pm_id:
pm_filter_requested = True
if split_pm_id.isdigit():
from db.dal.user_billing_dal import list_user_payment_methods
methods = await list_user_payment_methods(session, callback.from_user.id)
sel = next((m for m in methods if str(m.method_id) == split_pm_id), None)
if sel and sel.provider_payment_method_id:
selected_pm_provider_id = sel.provider_payment_method_id
else:
selected_pm_provider_id = split_pm_id
except Exception:
selected_pm_provider_id = None
pm_filter_requested = False
if pm_filter_requested and not selected_pm_provider_id:
user_payments = []
if selected_pm_provider_id:
filtered: List[Payment] = []
for p in user_payments:
if p.provider != "yookassa":
continue
if p.yookassa_payment_id and yookassa_service:
try:
info = await yookassa_service.get_payment_info(p.yookassa_payment_id)
pm = (info or {}).get("payment_method") or {}
if pm.get("id") == selected_pm_provider_id:
filtered.append(p)
continue
except Exception:
pass
user_payments = filtered
if not user_payments:
from bot.keyboards.inline.user_keyboards import (
get_back_to_payment_method_details_keyboard,
get_payment_methods_manage_keyboard,
)
back_pm_id = ""
try:
split_a, split_b, back_pm_id = callback.data.split(":", 2)
except Exception:
back_pm_id = ""
back_markup = (
get_back_to_payment_method_details_keyboard(back_pm_id, current_lang, i18n)
if back_pm_id
else get_payment_methods_manage_keyboard(current_lang, i18n, has_card=True)
)
await callback.message.edit_text(_("payment_method_no_history"), reply_markup=back_markup)
return
traffic_mode = getattr(settings, "traffic_sale_mode", False)
def _format_item(p: Payment) -> str:
if traffic_mode:
units_val = p.subscription_duration_months or 0
units_display = (
str(int(units_val)) if float(units_val).is_integer() else f"{units_val:g}"
)
title = p.description or _("traffic_purchase_title", traffic_gb=units_display)
else:
title = p.description or _(
"subscription_purchase_title", months=p.subscription_duration_months or 1
)
date_str = p.created_at.strftime("%Y-%m-%d") if p.created_at else "N/A"
return f"{date_str}{title}{p.amount:.2f} {p.currency}"
lines = [_format_item(p) for p in user_payments]
text = _("payment_method_tx_history_title") + "\n\n" + "\n".join(lines)
try:
split_a, split_b, split_pm_id_for_back = callback.data.split(":", 2)
except Exception:
split_pm_id_for_back = ""
from bot.keyboards.inline.user_keyboards import (
get_back_to_payment_method_details_keyboard,
get_payment_methods_manage_keyboard,
)
back_markup = (
get_back_to_payment_method_details_keyboard(split_pm_id_for_back, current_lang, i18n)
if split_pm_id_for_back
else get_payment_methods_manage_keyboard(current_lang, i18n, has_card=True)
)
await callback.message.edit_text(text, reply_markup=back_markup)
@router.callback_query(F.data.startswith("pm:list:"))
async def payment_methods_list(
callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
from db.dal.user_billing_dal import list_user_payment_methods
cards: List[tuple] = []
methods = await list_user_payment_methods(session, callback.from_user.id)
for m in methods:
def _is_yoomoney_network(network: Optional[str]) -> bool:
s = (network or "").lower()
return "yoomoney" in s or "yoo money" in s or "yoo-money" in s
def _extract_last4(text: str) -> Optional[str]:
digits = "".join(ch for ch in text if ch.isdigit())
return digits[-4:] if len(digits) >= 4 else None
def _format_pm_title(network: Optional[str], last4: Optional[str]) -> str:
if _is_yoomoney_network(network):
l4 = last4 or _extract_last4(network or "")
if l4:
return get_text("payment_method_wallet_title", last4=l4)
return get_text("payment_method_wallet_title", last4="****")
if last4:
network_name = network or get_text("payment_network_card")
return get_text("payment_method_card_title", network=network_name, last4=last4)
network_name = network or get_text("payment_network_generic")
return get_text("payment_method_generic_title", network=network_name)
title = _format_pm_title(m.card_network, m.card_last4)
cards.append((str(m.method_id), title if not m.is_default else f"{title}"))
try:
_, _, page_str = callback.data.split(":", 2)
page = int(page_str)
except Exception:
page = 0
text = get_text("payment_methods_title")
if not cards:
text += "\n\n" + get_text("payment_method_none")
await callback.message.edit_text(
text, reply_markup=get_payment_methods_list_keyboard(cards, page, current_lang, i18n)
)
try:
await callback.answer()
except Exception:
pass
@@ -1,21 +1,13 @@
from aiogram import Router
from .payments_crypto import router as crypto_router
from .payments_freekassa import router as freekassa_router
from .payments_platega import router as platega_router
from .payments_severpay import router as severpay_router
from .payments_stars import router as stars_router
from bot.payment_providers import iter_unique_provider_routers
from .payments_subscription import router as subscription_selection_router
from .payments_yookassa import router as yookassa_router
router = Router(name="user_subscription_payments_router")
router.include_router(subscription_selection_router)
router.include_router(yookassa_router)
router.include_router(freekassa_router)
router.include_router(platega_router)
router.include_router(severpay_router)
router.include_router(crypto_router)
router.include_router(stars_router)
for provider_router in iter_unique_provider_routers():
router.include_router(provider_router)
__all__ = ["router"]
@@ -1,128 +0,0 @@
from typing import Optional
from aiogram import F, Router, types
from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.inline.user_keyboards import get_payment_url_keyboard
from bot.middlewares.i18n import JsonI18n
from bot.services.crypto_pay_service import CryptoPayService
from config.settings import Settings
router = Router(name="user_subscription_payments_crypto_router")
@router.callback_query(F.data.startswith("pay_crypto:"))
async def pay_crypto_callback_handler(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
session: AsyncSession,
cryptopay_service: CryptoPayService,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
if not i18n or not callback.message:
try:
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
except Exception:
pass
return
if (
not settings.CRYPTOPAY_ENABLED
or not cryptopay_service
or not getattr(cryptopay_service, "configured", False)
):
try:
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
except Exception:
pass
return
try:
_, data_payload = callback.data.split(":", 1)
parts = data_payload.split(":")
months = float(parts[0])
price_amount = float(parts[1])
sale_mode = parts[2] if len(parts) > 2 else "subscription"
except (ValueError, IndexError):
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
user_id = callback.from_user.id
human_value = str(int(months)) if float(months).is_integer() else f"{months:g}"
sale_base = sale_mode.split("@", 1)[0].split("|", 1)[0]
payment_description = (
get_text("payment_description_traffic", traffic_gb=human_value)
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else (
get_text("payment_description_hwid_devices", count=int(months))
if sale_base in {"hwid_device", "hwid_devices"}
else get_text("payment_description_subscription", months=int(months))
)
)
invoice_url = await cryptopay_service.create_invoice(
session=session,
user_id=user_id,
months=months,
amount=price_amount,
description=payment_description,
sale_mode=sale_mode,
)
if invoice_url:
try:
await callback.message.edit_text(
get_text(
key="payment_link_message_traffic"
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else "payment_link_message",
months=int(months),
traffic_gb=human_value,
),
reply_markup=get_payment_url_keyboard(
invoice_url,
current_lang,
i18n,
back_callback=f"subscribe_period:{human_value}",
back_text_key="back_to_payment_methods_button",
),
disable_web_page_preview=False,
)
except Exception:
try:
await callback.message.answer(
get_text(
key="payment_link_message_traffic"
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else "payment_link_message",
months=int(months),
traffic_gb=human_value,
),
reply_markup=get_payment_url_keyboard(
invoice_url,
current_lang,
i18n,
back_callback=f"subscribe_period:{human_value}",
back_text_key="back_to_payment_methods_button",
),
disable_web_page_preview=False,
)
except Exception:
pass
try:
await callback.answer()
except Exception:
pass
return
try:
await callback.answer(get_text("error_payment_gateway"), show_alert=True)
except Exception:
pass
@@ -1,244 +0,0 @@
import logging
from datetime import datetime
from typing import Optional
from aiogram import F, Router, types
from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.inline.user_keyboards import get_payment_url_keyboard
from bot.middlewares.i18n import JsonI18n
from bot.services.freekassa_service import FreeKassaService
from config.settings import Settings
from db.dal import payment_dal
router = Router(name="user_subscription_payments_freekassa_router")
@router.callback_query(F.data.startswith("pay_fk:"))
async def pay_fk_callback_handler(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
freekassa_service: FreeKassaService,
session: AsyncSession,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
if not i18n or not callback.message:
try:
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
except Exception:
pass
return
if not freekassa_service or not freekassa_service.configured:
logging.error("FreeKassa service is not configured or unavailable.")
try:
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
except Exception:
pass
try:
await callback.message.edit_text(get_text("payment_service_unavailable"))
except Exception:
pass
return
try:
_, data_payload = callback.data.split(":", 1)
parts = data_payload.split(":")
months = float(parts[0])
price_rub = float(parts[1])
sale_mode = parts[2] if len(parts) > 2 else "subscription"
except (ValueError, IndexError):
logging.error(f"Invalid pay_fk data in callback: {callback.data}")
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
user_id = callback.from_user.id
human_value = str(int(months)) if float(months).is_integer() else f"{months:g}"
sale_base = sale_mode.split("@", 1)[0].split("|", 1)[0]
payment_description = (
get_text("payment_description_traffic", traffic_gb=human_value)
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else (
get_text("payment_description_hwid_devices", count=int(months))
if sale_base in {"hwid_device", "hwid_devices"}
else get_text("payment_description_subscription", months=int(months))
)
)
currency_code = (
getattr(freekassa_service, "default_currency", None)
or settings.DEFAULT_CURRENCY_SYMBOL
or "RUB"
)
payment_record_payload = {
"user_id": user_id,
"amount": price_rub,
"currency": currency_code,
"status": "pending_freekassa",
"description": payment_description,
"subscription_duration_months": int(months) if sale_base == "subscription" else None,
"provider": "freekassa",
"sale_mode": sale_mode,
"tariff_key": sale_mode.split("@", 1)[1] if "@" in sale_mode else None,
"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", "hwid_devices"}
else None,
}
try:
payment_record = await payment_dal.create_payment_record(session, payment_record_payload)
await session.commit()
except Exception as e_db_create:
await session.rollback()
logging.error(
f"FreeKassa: failed to create payment record for user {user_id}: {e_db_create}",
exc_info=True,
)
try:
await callback.message.edit_text(get_text("error_creating_payment_record"))
except Exception:
pass
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
success, response_data = await freekassa_service.create_order(
payment_db_id=payment_record.payment_id,
user_id=payment_record.user_id,
months=months,
amount=price_rub,
currency=freekassa_service.default_currency,
payment_method_id=freekassa_service.payment_method_id,
ip_address=freekassa_service.server_ip,
extra_params={
"us_method": freekassa_service.payment_method_id,
},
)
if success:
location = response_data.get("location")
order_hash = response_data.get("orderHash")
order_id_api = response_data.get("orderId")
provider_identifier = order_hash or order_id_api
if provider_identifier:
try:
await payment_dal.update_provider_payment_and_status(
session,
payment_record.payment_id,
str(provider_identifier),
payment_record.status,
)
await session.commit()
except Exception as e_status:
await session.rollback()
logging.error(
f"FreeKassa: failed to store provider order id for payment {payment_record.payment_id}: {e_status}", # noqa: E501
exc_info=True,
)
if location:
order_identifier_display = str(
order_id_api or provider_identifier or payment_record.payment_id
)
order_info_text = get_text(
"free_kassa_order_info",
order_id=order_identifier_display,
date=datetime.now().strftime("%Y-%m-%d"),
)
try:
await callback.message.edit_text(
f"{order_info_text}\n\n"
+ get_text(
key="payment_link_message_traffic"
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else "payment_link_message",
months=int(months),
traffic_gb=human_value,
),
reply_markup=get_payment_url_keyboard(
location,
current_lang,
i18n,
back_callback=f"subscribe_period:{human_value}",
back_text_key="back_to_payment_methods_button",
),
disable_web_page_preview=False,
)
except Exception as e_edit:
logging.warning(
f"FreeKassa: failed to display payment link ({e_edit}), sending new message."
)
try:
await callback.message.answer(
f"{order_info_text}\n\n"
+ get_text(
key="payment_link_message_traffic"
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else "payment_link_message",
months=int(months),
traffic_gb=human_value,
),
reply_markup=get_payment_url_keyboard(
location,
current_lang,
i18n,
back_callback=f"subscribe_period:{human_value}",
back_text_key="back_to_payment_methods_button",
),
disable_web_page_preview=False,
)
except Exception:
pass
try:
await callback.answer()
except Exception:
pass
return
logging.error(
"FreeKassa: create_order succeeded but no payment link returned for payment %s. Response: %s", # noqa: E501
payment_record.payment_id,
response_data,
)
else:
logging.error(
"FreeKassa: create_order failed for payment %s with response %s",
payment_record.payment_id,
response_data,
)
try:
await payment_dal.update_payment_status_by_db_id(
session,
payment_record.payment_id,
"failed_creation",
)
await session.commit()
except Exception as e_status:
await session.rollback()
logging.error(
f"FreeKassa: failed to mark payment {payment_record.payment_id} as failed_creation: {e_status}", # noqa: E501
exc_info=True,
)
try:
await callback.message.edit_text(get_text("error_payment_gateway"))
except Exception:
pass
try:
await callback.answer(get_text("error_payment_gateway"), show_alert=True)
except Exception:
pass
@@ -1,261 +0,0 @@
import json
import logging
from typing import Optional
from aiogram import F, Router, types
from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.inline.user_keyboards import get_payment_url_keyboard
from bot.middlewares.i18n import JsonI18n
from bot.services.platega_service import PlategaService
from config.settings import Settings
from db.dal import payment_dal
router = Router(name="user_subscription_payments_platega_router")
@router.callback_query(
F.data.startswith("pay_platega_sbp:")
| F.data.startswith("pay_platega_crypto:")
| F.data.startswith("pay_platega:")
)
async def pay_platega_callback_handler(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
platega_service: PlategaService,
session: AsyncSession,
):
callback_prefix, _, _ = (callback.data or "").partition(":")
if callback_prefix == "pay_platega_crypto":
platega_method_id = settings.PLATEGA_CRYPTO_METHOD
platega_variant = "crypto"
if not settings.PLATEGA_CRYPTO_ENABLED:
try:
await callback.answer()
except Exception:
pass
return
elif callback_prefix == "pay_platega_sbp":
platega_method_id = settings.platega_sbp_method_resolved
platega_variant = "sbp"
if not settings.PLATEGA_SBP_ENABLED:
try:
await callback.answer()
except Exception:
pass
return
else:
# Legacy callback (pre-split): keep working as SBP
platega_method_id = settings.platega_sbp_method_resolved
platega_variant = "sbp"
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
if not i18n or not callback.message:
try:
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
except Exception:
pass
return
if not platega_service or not platega_service.configured:
logging.error("Platega service is not configured or unavailable.")
try:
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
except Exception:
pass
try:
await callback.message.edit_text(get_text("payment_service_unavailable"))
except Exception:
pass
return
try:
_, data_payload = callback.data.split(":", 1)
parts = data_payload.split(":")
months = float(parts[0])
price_rub = float(parts[1])
sale_mode = parts[2] if len(parts) > 2 else "subscription"
except (ValueError, IndexError):
logging.error(f"Invalid pay_platega data in callback: {callback.data}")
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
user_id = callback.from_user.id
human_value = str(int(months)) if float(months).is_integer() else f"{months:g}"
sale_base = sale_mode.split("@", 1)[0].split("|", 1)[0]
payment_description = (
get_text("payment_description_traffic", traffic_gb=human_value)
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else (
get_text("payment_description_hwid_devices", count=int(months))
if sale_base in {"hwid_device", "hwid_devices"}
else get_text("payment_description_subscription", months=int(months))
)
)
currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
payment_record_payload = {
"user_id": user_id,
"amount": price_rub,
"currency": currency_code,
"status": "pending_platega",
"description": payment_description,
"subscription_duration_months": int(months) if sale_base == "subscription" else None,
"provider": "platega",
"sale_mode": sale_mode,
"tariff_key": sale_mode.split("@", 1)[1] if "@" in sale_mode else None,
"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", "hwid_devices"}
else None,
}
try:
payment_record = await payment_dal.create_payment_record(session, payment_record_payload)
await session.commit()
except Exception as e_db_create:
await session.rollback()
logging.error(
f"Platega: failed to create payment record for user {user_id}: {e_db_create}",
exc_info=True,
)
try:
await callback.message.edit_text(get_text("error_creating_payment_record"))
except Exception:
pass
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
payload_meta = json.dumps(
{
"payment_db_id": payment_record.payment_id,
"user_id": user_id,
"months": months,
"sale_mode": sale_mode,
"platega_variant": platega_variant,
}
)
success, response_data = await platega_service.create_transaction(
payment_db_id=payment_record.payment_id,
user_id=user_id,
months=months,
amount=price_rub,
currency=currency_code,
description=payment_description,
payload=payload_meta,
payment_method=platega_method_id,
)
if success:
transaction_id = response_data.get("transactionId") or response_data.get("id")
redirect_url = (
response_data.get("redirect")
or response_data.get("url")
or response_data.get("paymentUrl")
)
provider_status = response_data.get("status", payment_record.status)
if transaction_id and redirect_url:
try:
await payment_dal.update_provider_payment_and_status(
session,
payment_record.payment_id,
str(transaction_id),
str(provider_status),
)
await session.commit()
except Exception as e_status:
await session.rollback()
logging.error(
f"Platega: failed to store transaction id for payment {payment_record.payment_id}: {e_status}", # noqa: E501
exc_info=True,
)
try:
await callback.message.edit_text(
get_text(
key="payment_link_message_traffic"
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else "payment_link_message",
months=int(months),
traffic_gb=human_value,
),
reply_markup=get_payment_url_keyboard(
redirect_url,
current_lang,
i18n,
back_callback=f"subscribe_period:{human_value}",
back_text_key="back_to_payment_methods_button",
),
disable_web_page_preview=False,
)
except Exception as e_edit:
logging.warning(
f"Platega: failed to display payment link ({e_edit}), sending new message."
)
try:
await callback.message.answer(
get_text(
key="payment_link_message_traffic"
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else "payment_link_message",
months=int(months),
traffic_gb=human_value,
),
reply_markup=get_payment_url_keyboard(
redirect_url,
current_lang,
i18n,
back_callback=f"subscribe_period:{human_value}",
back_text_key="back_to_payment_methods_button",
),
disable_web_page_preview=False,
)
except Exception:
pass
try:
await callback.answer()
except Exception:
pass
return
logging.error(
"Platega: transaction created but missing transaction id or payment link for payment %s. Response: %s", # noqa: E501
payment_record.payment_id,
response_data,
)
try:
await payment_dal.update_payment_status_by_db_id(
session,
payment_record.payment_id,
"failed_creation",
)
await session.commit()
except Exception as e_status:
await session.rollback()
logging.error(
f"Platega: failed to mark payment {payment_record.payment_id} as failed_creation: {e_status}", # noqa: E501
exc_info=True,
)
try:
await callback.message.edit_text(get_text("error_payment_gateway"))
except Exception:
pass
try:
await callback.answer(get_text("error_payment_gateway"), show_alert=True)
except Exception:
pass
@@ -1,221 +0,0 @@
import logging
from typing import Optional
from aiogram import F, Router, types
from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.inline.user_keyboards import get_payment_url_keyboard
from bot.middlewares.i18n import JsonI18n
from bot.services.severpay_service import SeverPayService
from config.settings import Settings
from db.dal import payment_dal
router = Router(name="user_subscription_payments_severpay_router")
@router.callback_query(F.data.startswith("pay_severpay:"))
async def pay_severpay_callback_handler(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
severpay_service: SeverPayService,
session: AsyncSession,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
if not i18n or not callback.message:
try:
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
except Exception:
pass
return
if not severpay_service or not severpay_service.configured:
logging.error("SeverPay service is not configured or unavailable.")
try:
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
except Exception:
pass
try:
await callback.message.edit_text(get_text("payment_service_unavailable"))
except Exception:
pass
return
try:
_, data_payload = callback.data.split(":", 1)
parts = data_payload.split(":")
months = float(parts[0])
price_rub = float(parts[1])
sale_mode = parts[2] if len(parts) > 2 else "subscription"
except (ValueError, IndexError):
logging.error(f"Invalid pay_severpay data in callback: {callback.data}")
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
user_id = callback.from_user.id
human_value = str(int(months)) if float(months).is_integer() else f"{months:g}"
sale_base = sale_mode.split("@", 1)[0].split("|", 1)[0]
payment_description = (
get_text("payment_description_traffic", traffic_gb=human_value)
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else (
get_text("payment_description_hwid_devices", count=int(months))
if sale_base in {"hwid_device", "hwid_devices"}
else get_text("payment_description_subscription", months=int(months))
)
)
currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
payment_record_payload = {
"user_id": user_id,
"amount": price_rub,
"currency": currency_code,
"status": "pending_severpay",
"description": payment_description,
"subscription_duration_months": int(months) if sale_base == "subscription" else None,
"provider": "severpay",
"sale_mode": sale_mode,
"tariff_key": sale_mode.split("@", 1)[1] if "@" in sale_mode else None,
"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", "hwid_devices"}
else None,
}
try:
payment_record = await payment_dal.create_payment_record(session, payment_record_payload)
await session.commit()
except Exception as e_db_create:
await session.rollback()
logging.error(
f"SeverPay: failed to create payment record for user {user_id}: {e_db_create}",
exc_info=True,
)
try:
await callback.message.edit_text(get_text("error_creating_payment_record"))
except Exception:
pass
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
success, response_data = await severpay_service.create_payment(
payment_db_id=payment_record.payment_id,
user_id=user_id,
months=months,
amount=price_rub,
currency=currency_code,
description=payment_description,
)
if success:
payment_link = (
response_data.get("url")
or response_data.get("payment_url")
or response_data.get("paymentUrl")
)
provider_identifier = response_data.get("id") or response_data.get("uid")
if provider_identifier:
try:
await payment_dal.update_provider_payment_and_status(
session,
payment_record.payment_id,
str(provider_identifier),
payment_record.status,
)
await session.commit()
except Exception as e_status:
await session.rollback()
logging.error(
f"SeverPay: failed to store provider payment id for payment {payment_record.payment_id}: {e_status}", # noqa: E501
exc_info=True,
)
if payment_link:
try:
await callback.message.edit_text(
get_text(
key="payment_link_message_traffic"
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else "payment_link_message",
months=int(months),
traffic_gb=human_value,
),
reply_markup=get_payment_url_keyboard(
payment_link,
current_lang,
i18n,
back_callback=f"subscribe_period:{human_value}",
back_text_key="back_to_payment_methods_button",
),
disable_web_page_preview=False,
)
except Exception as e_edit:
logging.warning(
f"SeverPay: failed to display payment link ({e_edit}), sending new message."
)
try:
await callback.message.answer(
get_text(
key="payment_link_message_traffic"
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else "payment_link_message",
months=int(months),
traffic_gb=human_value,
),
reply_markup=get_payment_url_keyboard(
payment_link,
current_lang,
i18n,
back_callback=f"subscribe_period:{human_value}",
back_text_key="back_to_payment_methods_button",
),
disable_web_page_preview=False,
)
except Exception:
pass
try:
await callback.answer()
except Exception:
pass
return
logging.error(
"SeverPay: payment created but missing payment link for payment %s. Response: %s",
payment_record.payment_id,
response_data,
)
try:
await payment_dal.update_payment_status_by_db_id(
session,
payment_record.payment_id,
"failed_creation",
)
await session.commit()
except Exception as e_status:
await session.rollback()
logging.error(
f"SeverPay: failed to mark payment {payment_record.payment_id} as failed_creation: {e_status}", # noqa: E501
exc_info=True,
)
try:
await callback.message.edit_text(get_text("error_payment_gateway"))
except Exception:
pass
try:
await callback.answer(get_text("error_payment_gateway"), show_alert=True)
except Exception:
pass
@@ -1,148 +0,0 @@
import logging
from typing import Optional
from aiogram import F, Router, types
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from sqlalchemy.ext.asyncio import AsyncSession
from bot.middlewares.i18n import JsonI18n
from bot.services.stars_service import StarsService
from config.settings import Settings
router = Router(name="user_subscription_payments_stars_router")
@router.callback_query(F.data.startswith("pay_stars:"))
async def pay_stars_callback_handler(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
session: AsyncSession,
stars_service: StarsService,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
if not i18n or not callback.message:
try:
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
except Exception:
pass
return
if not settings.STARS_ENABLED:
try:
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
except Exception:
pass
return
try:
_, data_payload = callback.data.split(":", 1)
parts = data_payload.split(":")
months = float(parts[0])
stars_price = int(float(parts[1]))
sale_mode = parts[2] if len(parts) > 2 else "subscription"
except (ValueError, IndexError):
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
user_id = callback.from_user.id
human_value = str(int(months)) if float(months).is_integer() else f"{months:g}"
sale_base = sale_mode.split("@", 1)[0].split("|", 1)[0]
payment_description = (
get_text("payment_description_traffic", traffic_gb=human_value)
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else (
get_text("payment_description_hwid_devices", count=int(months))
if sale_base in {"hwid_device", "hwid_devices"}
else get_text("payment_description_subscription", months=int(months))
)
)
payment_db_id = await stars_service.create_invoice(
session=session,
user_id=user_id,
months=months,
stars_price=stars_price,
description=payment_description,
sale_mode=sale_mode,
)
if payment_db_id:
try:
await callback.message.edit_text(
get_text(
"payment_invoice_sent_message_traffic"
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else "payment_invoice_sent_message",
months=int(months),
traffic_gb=human_value,
),
reply_markup=InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=get_text("back_to_payment_methods_button"),
callback_data=f"subscribe_period:{human_value}",
)
]
]
),
)
except Exception as e_edit:
logging.warning(f"Stars payment: failed to show invoice info message ({e_edit})")
try:
await callback.answer()
except Exception:
pass
return
try:
await callback.answer(get_text("error_payment_gateway"), show_alert=True)
except Exception:
pass
@router.pre_checkout_query()
async def handle_pre_checkout_query(query: types.PreCheckoutQuery):
try:
await query.answer(ok=True)
except Exception:
# Nothing else to do here; Telegram will show an error if not answered
pass
@router.message(F.successful_payment)
async def handle_successful_stars_payment(
message: types.Message,
settings: Settings,
i18n_data: dict,
session: AsyncSession,
stars_service: StarsService,
):
payload = (
message.successful_payment.invoice_payload if message and message.successful_payment else ""
)
try:
parts = (payload or "").split(":")
payment_db_id = int(parts[0])
months = float(parts[1]) if len(parts) > 1 else 0
sale_mode = parts[2] if len(parts) > 2 else "subscription"
except Exception:
return
stars_amount = int(message.successful_payment.total_amount) if message.successful_payment else 0
await stars_service.process_successful_payment(
session=session,
message=message,
payment_db_id=payment_db_id,
months=months,
stars_amount=stars_amount,
i18n_data=i18n_data,
sale_mode=sale_mode,
)
@@ -4,7 +4,11 @@ from typing import Optional
from aiogram import F, Router, types
from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.inline.user_keyboards import get_payment_method_keyboard
from bot.keyboards.inline.user_keyboards import (
get_payment_method_keyboard,
sale_mode_with_callback_context,
subscription_options_callback,
)
from bot.middlewares.i18n import JsonI18n
from config.settings import Settings
@@ -32,8 +36,10 @@ async def select_subscription_period_callback_handler(
traffic_packages = getattr(settings, "traffic_packages", {}) or {}
stars_traffic_packages = getattr(settings, "stars_traffic_packages", {}) or {}
traffic_mode = bool(getattr(settings, "traffic_sale_mode", False) or stars_traffic_packages)
parts = callback.data.split(":")
callback_context = parts[2] if len(parts) > 2 else None
try:
months = float(callback.data.split(":")[-1])
months = float(parts[1])
except (ValueError, IndexError):
logging.error(f"Invalid subscription period in callback_data: {callback.data}")
try:
@@ -53,14 +59,16 @@ async def select_subscription_period_callback_handler(
if price_rub is None:
if traffic_mode and not price_source and stars_price is not None:
from bot.payment_providers import iter_provider_specs
currency_methods_enabled = any(
[
settings.FREEKASSA_ENABLED,
settings.PLATEGA_ENABLED,
settings.SEVERPAY_ENABLED,
settings.YOOKASSA_ENABLED,
settings.CRYPTOPAY_ENABLED,
]
spec.price_source != "stars"
and spec.is_available_to_user(
settings,
user_id=callback.from_user.id,
require_configured=False,
)
for spec in iter_provider_specs()
)
if currency_methods_enabled:
logging.error(
@@ -97,7 +105,11 @@ async def select_subscription_period_callback_handler(
current_lang,
i18n,
settings,
sale_mode="traffic" if traffic_mode else "subscription",
sale_mode=sale_mode_with_callback_context(
"traffic" if traffic_mode else "subscription", callback_context
),
back_callback=subscription_options_callback(callback_context),
user_id=callback.from_user.id,
)
try:
@@ -1,842 +0,0 @@
import logging
from typing import List, Optional, Tuple
from aiogram import F, Router, types
from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.inline.user_keyboards import (
get_back_to_main_menu_markup,
get_payment_url_keyboard,
get_yk_autopay_choice_keyboard,
get_yk_saved_cards_keyboard,
)
from bot.middlewares.i18n import JsonI18n
from bot.services.yookassa_service import YooKassaService
from config.settings import Settings
from db.dal import payment_dal, user_billing_dal
router = Router(name="user_subscription_payments_yookassa_router")
def _format_value(val: float) -> str:
return str(int(val)) if float(val).is_integer() else f"{val:g}"
def _parse_offer_payload(payload: str) -> Optional[Tuple[float, float, str]]:
try:
parts = payload.split(":")
value = float(parts[0])
price = float(parts[1])
sale_mode = parts[2] if len(parts) > 2 else "subscription"
return value, price, sale_mode
except (ValueError, IndexError):
return None
def _sale_mode_base(sale_mode: str) -> str:
return (sale_mode or "subscription").split("@", 1)[0].split("|", 1)[0]
def _format_saved_payment_method_title(
get_text, network: Optional[str], last4: Optional[str], is_default: bool
) -> str:
def _is_yoomoney_network(name: Optional[str]) -> bool:
s = (name or "").lower()
return "yoomoney" in s or "yoo money" in s or "yoo-money" in s
def _extract_last4(text: str) -> Optional[str]:
digits = "".join(ch for ch in text if ch.isdigit())
return digits[-4:] if len(digits) >= 4 else None
if _is_yoomoney_network(network):
inferred_last4 = last4 or (_extract_last4(network or "") or "****")
title = get_text("payment_method_wallet_title", last4=inferred_last4)
elif last4:
network_name = network or get_text("payment_network_card")
title = get_text("payment_method_card_title", network=network_name, last4=last4)
else:
network_name = network or get_text("payment_network_generic")
title = get_text("payment_method_generic_title", network=network_name)
return f"{title}" if is_default else title
async def _initiate_yk_payment(
callback: types.CallbackQuery,
*,
settings: Settings,
session: AsyncSession,
yookassa_service: YooKassaService,
i18n: Optional[JsonI18n],
current_lang: str,
get_text,
user_id: int,
months: int,
price_rub: float,
currency_code_for_yk: str,
save_payment_method: bool,
back_callback: str,
payment_method_id: Optional[str] = None,
selected_method_internal_id: Optional[int] = None,
sale_mode: str = "subscription",
) -> bool:
"""Create payment record and initiate YooKassa payment (new card or saved card)."""
if not callback.message:
return False
sale_base = _sale_mode_base(sale_mode)
payment_description = (
get_text("payment_description_traffic", traffic_gb=_format_value(months))
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else (
get_text("payment_description_hwid_devices", count=int(months))
if sale_base in {"hwid_device", "hwid_devices"}
else get_text("payment_description_subscription", months=int(months))
)
)
payment_record_data = {
"user_id": user_id,
"amount": price_rub,
"currency": currency_code_for_yk,
"status": "pending_yookassa",
"description": payment_description,
"subscription_duration_months": int(months) if sale_base == "subscription" else None,
"sale_mode": sale_base,
"tariff_key": sale_mode.split("@", 1)[1] if "@" in sale_mode else None,
"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", "hwid_devices"}
else None,
}
db_payment_record = None
try:
db_payment_record = await payment_dal.create_payment_record(session, payment_record_data)
await session.commit()
logging.info(
f"Payment record {db_payment_record.payment_id} created for user {user_id} with status 'pending_yookassa'." # noqa: E501
)
except Exception as e_db_payment:
await session.rollback()
logging.error(
f"Failed to create payment record in DB for user {user_id}: {e_db_payment}",
exc_info=True,
)
try:
await callback.message.edit_text(get_text("error_creating_payment_record"))
except Exception:
pass
return False
if not db_payment_record:
try:
await callback.message.edit_text(get_text("error_creating_payment_record"))
except Exception:
pass
return False
yookassa_metadata = {
"user_id": str(user_id),
"subscription_months": str(months),
"payment_db_id": str(db_payment_record.payment_id),
"sale_mode": sale_mode,
}
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}:
yookassa_metadata["traffic_gb"] = str(months)
if payment_method_id:
yookassa_metadata["used_saved_payment_method_id"] = payment_method_id
receipt_email_for_yk = settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL
payment_response_yk = await yookassa_service.create_payment(
amount=price_rub,
currency=currency_code_for_yk,
description=payment_description,
metadata=yookassa_metadata,
receipt_email=receipt_email_for_yk,
save_payment_method=save_payment_method,
payment_method_id=payment_method_id,
)
if payment_response_yk and payment_response_yk.get("confirmation_url"):
pm = payment_response_yk.get("payment_method")
try:
if pm and pm.get("id"):
pm_type = pm.get("type")
title = pm.get("title")
card = pm.get("card") or {}
account_number = pm.get("account_number") or pm.get("account")
if isinstance(card, dict) and (pm_type or "").lower() in {
"bank_card",
"bank-card",
"card",
}:
display_network = card.get("card_type") or title or "Card"
display_last4 = card.get("last4")
elif (pm_type or "").lower() in {"yoo_money", "yoomoney", "yoo-money", "wallet"}:
display_network = "YooMoney"
display_last4 = (
account_number[-4:]
if isinstance(account_number, str) and len(account_number) >= 4
else None
)
else:
display_network = title or (pm_type.upper() if pm_type else "Payment method")
display_last4 = None
await user_billing_dal.upsert_yk_payment_method(
session,
user_id=user_id,
payment_method_id=pm["id"],
card_last4=display_last4,
card_network=display_network,
)
try:
await user_billing_dal.upsert_user_payment_method(
session,
user_id=user_id,
provider_payment_method_id=pm["id"],
provider="yookassa",
card_last4=display_last4,
card_network=display_network,
set_default=save_payment_method,
)
except Exception:
pass
await session.commit()
except Exception:
await session.rollback()
logging.exception("Failed to save YooKassa payment method preliminarily")
try:
await payment_dal.update_payment_status_by_db_id(
session,
payment_db_id=db_payment_record.payment_id,
new_status=payment_response_yk.get("status", "pending"),
yk_payment_id=payment_response_yk.get("id"),
)
if selected_method_internal_id is not None:
try:
await user_billing_dal.set_user_default_payment_method(
session, user_id, selected_method_internal_id
)
except Exception:
logging.exception(
"Failed to set default payment method after initiating payment"
)
await session.commit()
except Exception as e_db_update_ykid:
await session.rollback()
logging.error(
f"Failed to update payment record {db_payment_record.payment_id} with YK ID: {e_db_update_ykid}", # noqa: E501
exc_info=True,
)
try:
await callback.message.edit_text(get_text("error_payment_gateway_link_failed"))
except Exception:
pass
return False
try:
await callback.message.edit_text(
get_text(
key="payment_link_message_traffic"
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else "payment_link_message",
months=int(months),
traffic_gb=_format_value(months),
),
reply_markup=get_payment_url_keyboard(
payment_response_yk["confirmation_url"],
current_lang,
i18n,
back_callback=back_callback,
back_text_key="back_to_payment_methods_button",
),
disable_web_page_preview=False,
)
except Exception as e_edit:
logging.warning(f"Edit message for payment link failed: {e_edit}. Sending new one.")
try:
await callback.message.answer(
get_text(
key="payment_link_message_traffic"
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else "payment_link_message",
months=int(months),
traffic_gb=_format_value(months),
),
reply_markup=get_payment_url_keyboard(
payment_response_yk["confirmation_url"],
current_lang,
i18n,
back_callback=back_callback,
back_text_key="back_to_payment_methods_button",
),
disable_web_page_preview=False,
)
except Exception:
pass
return True
if payment_response_yk and payment_method_id:
status_to_store = payment_response_yk.get("status", "pending")
try:
await payment_dal.update_payment_status_by_db_id(
session,
payment_db_id=db_payment_record.payment_id,
new_status=status_to_store,
yk_payment_id=payment_response_yk.get("id"),
)
if selected_method_internal_id is not None:
try:
await user_billing_dal.set_user_default_payment_method(
session, user_id, selected_method_internal_id
)
except Exception:
logging.exception(
"Failed to set default payment method after saved-card payment start"
)
await session.commit()
except Exception as e_db_update_saved:
await session.rollback()
logging.error(
f"Failed to update saved-card payment record {db_payment_record.payment_id}: {e_db_update_saved}", # noqa: E501
exc_info=True,
)
try:
await callback.message.edit_text(get_text("error_payment_gateway"))
except Exception:
pass
return False
message_text = get_text("yookassa_autopay_charge_initiated")
try:
await callback.message.edit_text(
message_text,
reply_markup=get_back_to_main_menu_markup(current_lang, i18n),
)
except Exception as e_edit:
logging.warning(f"Failed to notify about saved-card charge start: {e_edit}")
try:
await callback.message.answer(
message_text,
reply_markup=get_back_to_main_menu_markup(current_lang, i18n),
)
except Exception:
pass
return True
try:
await payment_dal.update_payment_status_by_db_id(
session, db_payment_record.payment_id, "failed_creation"
)
await session.commit()
except Exception as e_db_fail_create:
await session.rollback()
logging.error(
f"Additionally failed to update payment record to 'failed_creation': {e_db_fail_create}", # noqa: E501
exc_info=True,
)
logging.error(
f"Failed to create payment in YooKassa for user {user_id}, payment_db_id {db_payment_record.payment_id}. Response: {payment_response_yk}" # noqa: E501
)
try:
await callback.message.edit_text(get_text("error_payment_gateway"))
except Exception:
pass
return False
@router.callback_query(F.data.startswith("pay_yk:"))
async def pay_yk_callback_handler(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
yookassa_service: YooKassaService,
session: AsyncSession,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
if not i18n or not callback.message:
try:
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
except Exception:
pass
return
if not yookassa_service or not yookassa_service.configured:
logging.error("YooKassa service is not configured or unavailable.")
target_msg_edit = callback.message
await target_msg_edit.edit_text(get_text("payment_service_unavailable"))
try:
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
except Exception:
pass
return
try:
_, data_payload = callback.data.split(":", 1)
except ValueError:
logging.error(f"Invalid pay_yk data in callback: {callback.data}")
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
parsed = _parse_offer_payload(data_payload)
if not parsed:
logging.error(f"Invalid pay_yk payload structure: {callback.data}")
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
months, price_rub, sale_mode = parsed
user_id = callback.from_user.id
currency_code_for_yk = "RUB"
autopay_enabled = bool(
settings.yookassa_autopayments_active
and _sale_mode_base(sale_mode) == "subscription"
and not settings.traffic_sale_mode
)
autopay_require_binding = bool(
getattr(settings, "YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING", True)
)
saved_methods: List = []
if autopay_enabled:
try:
saved_methods = await user_billing_dal.list_user_payment_methods(
session, user_id, provider="yookassa"
)
except Exception as e_list:
logging.exception(f"Failed to load saved payment methods for user {user_id}: {e_list}")
saved_methods = []
if autopay_enabled and saved_methods:
try:
await callback.message.edit_text(
get_text("yookassa_autopay_flow_prompt"),
reply_markup=get_yk_autopay_choice_keyboard(
months,
price_rub,
current_lang,
i18n,
has_saved_cards=True,
sale_mode=sale_mode,
),
)
except Exception as e_edit:
logging.warning(f"Failed to show autopay choice: {e_edit}. Sending new message.")
try:
await callback.message.answer(
get_text("yookassa_autopay_flow_prompt"),
reply_markup=get_yk_autopay_choice_keyboard(
months,
price_rub,
current_lang,
i18n,
has_saved_cards=True,
sale_mode=sale_mode,
),
)
except Exception:
pass
try:
await callback.answer()
except Exception:
pass
return
await _initiate_yk_payment(
callback,
settings=settings,
session=session,
yookassa_service=yookassa_service,
i18n=i18n,
current_lang=current_lang,
get_text=get_text,
user_id=user_id,
months=months,
price_rub=price_rub,
currency_code_for_yk=currency_code_for_yk,
save_payment_method=autopay_enabled and autopay_require_binding,
back_callback=f"subscribe_period:{_format_value(months)}",
sale_mode=sale_mode,
)
try:
await callback.answer()
except Exception:
pass
@router.callback_query(F.data.startswith("pay_yk_new:"))
async def pay_yk_new_card_handler(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
yookassa_service: YooKassaService,
session: AsyncSession,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
if not i18n or not callback.message:
try:
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
except Exception:
pass
return
if not yookassa_service or not yookassa_service.configured:
logging.error("YooKassa service unavailable for pay_yk_new.")
try:
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
except Exception:
pass
try:
await callback.message.edit_text(get_text("payment_service_unavailable"))
except Exception:
pass
return
try:
_, data_payload = callback.data.split(":", 1)
except ValueError:
logging.error(f"Invalid pay_yk_new data in callback: {callback.data}")
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
parsed = _parse_offer_payload(data_payload)
if not parsed:
logging.error(f"Invalid pay_yk_new payload structure: {callback.data}")
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
months, price_rub, sale_mode = parsed
user_id = callback.from_user.id
currency_code_for_yk = "RUB"
autopay_enabled = bool(
settings.yookassa_autopayments_active
and _sale_mode_base(sale_mode) == "subscription"
and not settings.traffic_sale_mode
)
autopay_require_binding = bool(
getattr(settings, "YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING", True)
)
await _initiate_yk_payment(
callback,
settings=settings,
session=session,
yookassa_service=yookassa_service,
i18n=i18n,
current_lang=current_lang,
get_text=get_text,
user_id=user_id,
months=months,
price_rub=price_rub,
currency_code_for_yk=currency_code_for_yk,
save_payment_method=autopay_enabled and autopay_require_binding,
back_callback=f"subscribe_period:{_format_value(months)}",
sale_mode=sale_mode,
)
try:
await callback.answer()
except Exception:
pass
@router.callback_query(F.data.startswith("pay_yk_saved_list:"))
async def pay_yk_saved_list_handler(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
yookassa_service: YooKassaService,
session: AsyncSession,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
if not i18n or not callback.message:
try:
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
except Exception:
pass
return
try:
_, data_payload = callback.data.split(":", 1)
except ValueError:
logging.error(f"Invalid pay_yk_saved_list data: {callback.data}")
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
parts = data_payload.split(":")
if len(parts) < 2:
logging.error(f"pay_yk_saved_list payload missing components: {callback.data}")
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
try:
months = float(parts[0])
price_rub = float(parts[1])
page = int(parts[2]) if len(parts) > 2 else 0
sale_mode = parts[3] if len(parts) > 3 else "subscription"
except (ValueError, IndexError):
logging.error(f"pay_yk_saved_list payload parsing error: {callback.data}")
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
autopay_enabled = bool(
settings.yookassa_autopayments_active
and _sale_mode_base(sale_mode) == "subscription"
and not settings.traffic_sale_mode
)
if not autopay_enabled:
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
user_id = callback.from_user.id
try:
saved_methods = await user_billing_dal.list_user_payment_methods(
session, user_id, provider="yookassa"
)
except Exception as e_list:
logging.exception(f"Failed to list saved payment methods for user {user_id}: {e_list}")
saved_methods = []
if not saved_methods:
try:
await callback.message.edit_text(
get_text("yookassa_autopay_no_saved_cards"),
reply_markup=get_yk_autopay_choice_keyboard(
months,
price_rub,
current_lang,
i18n,
has_saved_cards=False,
sale_mode=sale_mode,
),
)
except Exception as e_edit:
logging.warning(f"Failed to display no-saved-card notice: {e_edit}")
try:
await callback.message.answer(
get_text("yookassa_autopay_no_saved_cards"),
reply_markup=get_yk_autopay_choice_keyboard(
months,
price_rub,
current_lang,
i18n,
has_saved_cards=False,
sale_mode=sale_mode,
),
)
except Exception:
pass
try:
await callback.answer()
except Exception:
pass
return
cards: List[Tuple[str, str]] = []
for method in saved_methods:
title = _format_saved_payment_method_title(
get_text, method.card_network, method.card_last4, method.is_default
)
cards.append((str(method.method_id), title))
per_page = 5
max_page = max(0, (len(cards) - 1) // per_page)
page = max(0, min(page, max_page))
try:
await callback.message.edit_text(
get_text("yookassa_autopay_choose_saved_card"),
reply_markup=get_yk_saved_cards_keyboard(
cards,
months,
price_rub,
current_lang,
i18n,
page=page,
sale_mode=sale_mode,
),
)
except Exception as e_edit:
logging.warning(f"Failed to display saved card list: {e_edit}")
try:
await callback.message.answer(
get_text("yookassa_autopay_choose_saved_card"),
reply_markup=get_yk_saved_cards_keyboard(
cards,
months,
price_rub,
current_lang,
i18n,
page=page,
sale_mode=sale_mode,
),
)
except Exception:
pass
try:
await callback.answer()
except Exception:
pass
@router.callback_query(F.data.startswith("pay_yk_use_saved:"))
async def pay_yk_use_saved_handler(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
yookassa_service: YooKassaService,
session: AsyncSession,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
if not i18n or not callback.message:
try:
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
except Exception:
pass
return
if not yookassa_service or not yookassa_service.configured:
logging.error("YooKassa service unavailable for pay_yk_use_saved.")
try:
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
except Exception:
pass
try:
await callback.message.edit_text(get_text("payment_service_unavailable"))
except Exception:
pass
return
try:
_, data_payload = callback.data.split(":", 1)
except ValueError:
logging.error(f"Invalid pay_yk_use_saved data: {callback.data}")
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
parts = data_payload.split(":")
if len(parts) < 3:
logging.error(f"pay_yk_use_saved payload missing components: {callback.data}")
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
try:
months = float(parts[0])
price_rub = float(parts[1])
sale_mode = parts[3] if len(parts) > 3 else "subscription"
except (ValueError, IndexError):
logging.error(f"pay_yk_use_saved months/price parsing error: {callback.data}")
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
autopay_enabled = bool(
settings.yookassa_autopayments_active
and _sale_mode_base(sale_mode) == "subscription"
and not settings.traffic_sale_mode
)
if not autopay_enabled:
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
method_identifier = parts[2]
user_id = callback.from_user.id
try:
saved_methods = await user_billing_dal.list_user_payment_methods(
session, user_id, provider="yookassa"
)
except Exception as e_list:
logging.exception(f"Failed to list saved payment methods for user {user_id}: {e_list}")
saved_methods = []
selected_method = None
for method in saved_methods:
if method_identifier.isdigit():
if method.method_id == int(method_identifier):
selected_method = method
break
if method.provider_payment_method_id == method_identifier:
selected_method = method
break
if not selected_method:
logging.warning(
f"Selected payment method not found for user {user_id}: {method_identifier}"
)
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
currency_code_for_yk = "RUB"
await _initiate_yk_payment(
callback,
settings=settings,
session=session,
yookassa_service=yookassa_service,
i18n=i18n,
current_lang=current_lang,
get_text=get_text,
user_id=user_id,
months=months,
price_rub=price_rub,
currency_code_for_yk=currency_code_for_yk,
save_payment_method=False,
back_callback=f"pay_yk_saved_list:{_format_value(months)}:{price_rub}:{sale_mode}",
payment_method_id=selected_method.provider_payment_method_id,
selected_method_internal_id=selected_method.method_id,
sale_mode=sale_mode,
)
try:
await callback.answer()
except Exception:
pass
+38 -2
View File
@@ -14,7 +14,12 @@ from bot.services.notification_service import NotificationService
from bot.services.panel_api_service import PanelApiService
from bot.services.subscription_service import SubscriptionService
from bot.utils.config_link import prepare_config_links
from bot.utils.install_links import (
append_install_share_link_text,
ensure_user_install_guide_links,
)
from config.settings import Settings
from db.dal import user_dal
from .start import send_main_menu
@@ -74,6 +79,7 @@ async def request_trial_confirmation_handler(
config_link_display_for_trial = None
config_link_for_trial = None
connect_button_url_for_trial = None
install_share_url = None
if activation_result and activation_result.get("activated"):
try:
@@ -104,9 +110,23 @@ async def request_trial_confirmation_handler(
traffic_gb=traffic_display,
)
install_links = await ensure_user_install_guide_links(session, settings, user_id)
install_share_url = install_links.public_share_url
final_message_text_in_chat = append_install_share_link_text(
final_message_text_in_chat,
_,
install_share_url,
)
# Send notification to admin about new trial
notification_service = NotificationService(callback.bot, settings, i18n)
await notification_service.notify_trial_activation(user_id, end_date_obj)
db_user = await user_dal.get_user_by_id(session, user_id)
await notification_service.notify_trial_activation(
user_id,
end_date_obj,
username=db_user.username if db_user else callback.from_user.username,
email=getattr(db_user, "email", None) if db_user else None,
)
# Mark ad attribution trial if exists
try:
from db.dal import ad_dal as _ad_dal
@@ -139,6 +159,7 @@ async def request_trial_confirmation_handler(
settings,
config_link_display_for_trial,
connect_button_url=connect_button_url_for_trial,
install_share_url=install_share_url,
)
if activation_result and activation_result.get("activated")
else get_main_menu_inline_keyboard(
@@ -214,6 +235,7 @@ async def confirm_activate_trial_handler(
config_link_display_for_trial = None
config_link_for_trial = None
connect_button_url_for_trial = None
install_share_url = None
if activation_result and activation_result.get("activated"):
try:
@@ -243,6 +265,13 @@ async def confirm_activate_trial_handler(
config_link=config_link_for_trial,
traffic_gb=traffic_display,
)
install_links = await ensure_user_install_guide_links(session, settings, user_id)
install_share_url = install_links.public_share_url
final_message_text_in_chat = append_install_share_link_text(
final_message_text_in_chat,
_,
install_share_url,
)
else:
message_key_from_service = (
activation_result.get("message_key", "trial_activation_failed")
@@ -266,6 +295,7 @@ async def confirm_activate_trial_handler(
settings,
config_link_display_for_trial,
connect_button_url=connect_button_url_for_trial,
install_share_url=install_share_url,
)
if activation_result and activation_result.get("activated")
else get_main_menu_inline_keyboard(
@@ -293,7 +323,13 @@ async def confirm_activate_trial_handler(
if activation_result and activation_result.get("activated") and end_date_obj:
notification_service = NotificationService(callback.bot, settings, i18n)
await notification_service.notify_trial_activation(user_id, end_date_obj)
db_user = await user_dal.get_user_by_id(session, user_id)
await notification_service.notify_trial_activation(
user_id,
end_date_obj,
username=db_user.username if db_user else callback.from_user.username,
email=getattr(db_user, "email", None) if db_user else None,
)
try:
from db.dal import ad_dal as _ad_dal
+32 -3
View File
@@ -51,7 +51,11 @@ async def cache_get_json(settings: Settings, key: str) -> Any:
redis = await get_redis(settings)
if redis is None:
return None
raw = await redis.get(key)
try:
raw = await redis.get(key)
except Exception as exc:
logger.warning("Redis cache get failed for key %s: %s", key, exc)
return None
if raw is None:
return None
try:
@@ -65,14 +69,39 @@ async def cache_set_json(settings: Settings, key: str, value: Any, ttl_seconds:
redis = await get_redis(settings)
if redis is None:
return
await redis.set(key, json.dumps(value, ensure_ascii=False, default=str), ex=ttl_seconds)
try:
await redis.set(key, json.dumps(value, ensure_ascii=False, default=str), ex=ttl_seconds)
except Exception as exc:
logger.warning("Redis cache set failed for key %s: %s", key, exc)
async def cache_delete(settings: Settings, *keys: str) -> None:
redis = await get_redis(settings)
if redis is None or not keys:
return
await redis.delete(*keys)
try:
await redis.delete(*keys)
except Exception as exc:
logger.warning("Redis cache delete failed for %s key(s): %s", len(keys), exc)
async def cache_delete_pattern(settings: Settings, pattern: str) -> int:
redis = await get_redis(settings)
if redis is None or not pattern:
return 0
deleted = 0
batch = []
try:
async for key in redis.scan_iter(match=pattern, count=100):
batch.append(key)
if len(batch) >= 100:
deleted += int(await redis.delete(*batch))
batch.clear()
if batch:
deleted += int(await redis.delete(*batch))
except Exception as exc:
logger.warning("Redis cache pattern delete failed for %s: %s", pattern, exc)
return deleted
@asynccontextmanager
+28 -16
View File
@@ -24,28 +24,36 @@ async def enqueue_webhook_event(
if redis is None:
return False
dedupe_id = event_id or payload.get("id") or payload.get("event_id")
if dedupe_id:
dedupe_key = redis_key(settings, "webhook", "seen", provider, dedupe_id)
if not await redis.set(dedupe_key, "1", nx=True, ex=24 * 60 * 60):
logger.info("Skipping duplicate %s webhook event %s", provider, dedupe_id)
return True
try:
dedupe_id = event_id or payload.get("id") or payload.get("event_id")
if dedupe_id:
dedupe_key = redis_key(settings, "webhook", "seen", provider, dedupe_id)
if not await redis.set(dedupe_key, "1", nx=True, ex=24 * 60 * 60):
logger.info("Skipping duplicate %s webhook event %s", provider, dedupe_id)
return True
message = {
"provider": provider,
"event_id": dedupe_id,
"payload": payload,
"enqueued_at": time.time(),
}
await redis.lpush(webhook_queue_key(settings), json.dumps(message, ensure_ascii=False))
return True
message = {
"provider": provider,
"event_id": dedupe_id,
"payload": payload,
"enqueued_at": time.time(),
}
await redis.lpush(webhook_queue_key(settings), json.dumps(message, ensure_ascii=False))
return True
except Exception as exc:
logger.warning("Redis webhook enqueue failed for %s: %s", provider, exc)
return False
async def pop_webhook_event(settings: Settings, timeout_seconds: int = 5) -> Optional[dict]:
redis = await get_redis(settings)
if redis is None:
return None
item = await redis.brpop(webhook_queue_key(settings), timeout=timeout_seconds)
try:
item = await redis.brpop(webhook_queue_key(settings), timeout=timeout_seconds)
except Exception as exc:
logger.warning("Redis webhook pop failed: %s", exc)
return None
if not item:
return None
_, raw = item
@@ -60,4 +68,8 @@ async def webhook_queue_depth(settings: Settings) -> int:
redis = await get_redis(settings)
if redis is None:
return 0
return int(await redis.llen(webhook_queue_key(settings)))
try:
return int(await redis.llen(webhook_queue_key(settings)))
except Exception as exc:
logger.warning("Redis webhook queue depth failed: %s", exc)
return 0
+202 -72
View File
@@ -3,8 +3,101 @@ from typing import Any, Dict, List, Optional, Tuple
from aiogram.types import InlineKeyboardMarkup, WebAppInfo
from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton
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
BOT_MENU_CONTEXT = "bot"
def callback_context_from_back_callback(back_callback: Optional[str]) -> Optional[str]:
if back_callback == "main_action:bot_interface":
return BOT_MENU_CONTEXT
return None
def sale_mode_with_callback_context(sale_mode: str, context: Optional[str]) -> str:
sale_mode = sale_mode or "subscription"
if not context or "|" in sale_mode:
return sale_mode
return f"{sale_mode}|{context}"
def callback_context_from_sale_mode(sale_mode: Optional[str]) -> Optional[str]:
if not sale_mode or "|" not in sale_mode:
return None
context = str(sale_mode).split("|", 1)[1].strip()
return context or None
def callback_suffix_for_context(context: Optional[str]) -> str:
return f":{context}" if context else ""
def subscription_options_callback(context: Optional[str]) -> str:
return "main_action:bot_subscribe" if context == BOT_MENU_CONTEXT else "main_action:subscribe"
def payment_methods_back_callback(
value: str, sale_mode: str = "subscription", price: Optional[float] = None
) -> str:
sale_mode = sale_mode or "subscription"
context = callback_context_from_sale_mode(sale_mode)
context_suffix = callback_suffix_for_context(context)
sale_mode_main = sale_mode.split("|", 1)[0]
sale_base = sale_mode_main.split("@", 1)[0]
tariff_key = sale_mode_main.split("@", 1)[1] if "@" in sale_mode_main else None
if sale_base == "subscription" and tariff_key:
return f"tariff:period:{tariff_key}:{value}{context_suffix}"
if sale_base == "traffic_package" and tariff_key:
return f"tariff:package:{tariff_key}:{value}{context_suffix}"
if sale_base == "topup" and tariff_key:
return f"tariff:package:{tariff_key}:{value}"
if sale_base == "premium_topup" and tariff_key:
return f"tariff:premium_package:{tariff_key}:{value}"
if sale_base in {"hwid_device", "hwid_devices", "hwid_devices_renewal"} and tariff_key:
action = "renewal_package" if sale_base == "hwid_devices_renewal" else "package"
return f"hwid_devices:{action}:{tariff_key}:{value}"
if sale_base == "tariff_upgrade" and tariff_key:
amount = str(price) if price is not None else value
return f"tariff_change:pay:{tariff_key}:{amount}"
if sale_base in {"subscription", "traffic"}:
return f"subscribe_period:{value}{context_suffix}"
return subscription_options_callback(context)
def payment_options_back_callback(sale_mode: str = "subscription") -> str:
sale_mode = sale_mode or "subscription"
context = callback_context_from_sale_mode(sale_mode)
context_suffix = callback_suffix_for_context(context)
sale_mode_main = sale_mode.split("|", 1)[0]
sale_base = sale_mode_main.split("@", 1)[0]
tariff_key = sale_mode_main.split("@", 1)[1] if "@" in sale_mode_main else None
if sale_base in {"subscription", "traffic_package"} and tariff_key:
return f"tariff:select:{tariff_key}{context_suffix}"
if sale_base in {"topup", "premium_topup"}:
return "tariff_topup:list"
if sale_base in {"hwid_device", "hwid_devices", "hwid_devices_renewal"}:
return "hwid_devices:list"
return subscription_options_callback(context)
def _trial_activation_button(lang: str, i18n_instance, settings: Settings) -> InlineKeyboardButton:
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
if settings.SUBSCRIPTION_MINI_APP_URL:
trial_url = subscription_mini_app_trial_url(settings) or settings.SUBSCRIPTION_MINI_APP_URL
return InlineKeyboardButton(
text=_(key="menu_activate_trial_button"),
web_app=WebAppInfo(url=trial_url),
)
return InlineKeyboardButton(
text=_(key="menu_activate_trial_button"),
callback_data="main_action:request_trial",
)
def get_main_menu_inline_keyboard(
lang: str, i18n_instance, settings: Settings, show_trial_button: bool = False
@@ -12,6 +105,9 @@ def get_main_menu_inline_keyboard(
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
if show_trial_button and settings.TRIAL_ENABLED:
builder.row(_trial_activation_button(lang, i18n_instance, settings))
if settings.SUBSCRIPTION_MINI_APP_URL:
builder.row(
InlineKeyboardButton(
@@ -54,11 +150,7 @@ def get_bot_interface_inline_keyboard(
builder = InlineKeyboardBuilder()
if show_trial_button and settings.TRIAL_ENABLED:
builder.row(
InlineKeyboardButton(
text=_(key="menu_activate_trial_button"), callback_data="main_action:request_trial"
)
)
builder.row(_trial_activation_button(lang, i18n_instance, settings))
if settings.SUBSCRIPTION_MINI_APP_URL:
builder.row(
@@ -156,14 +248,18 @@ def get_language_selection_keyboard(
_ = lambda key, **kwargs: i18n_instance.gettext(current_lang, key, **kwargs)
callback_suffix = ":bot" if back_callback == "main_action:bot_interface" else ""
builder = InlineKeyboardBuilder()
builder.button(
text=f"🇬🇧 English {'' if current_lang == 'en' else ''}",
callback_data=f"set_lang_en{callback_suffix}",
)
builder.button(
text=f"🇷🇺 Русский {'' if current_lang == 'ru' else ''}",
callback_data=f"set_lang_ru{callback_suffix}",
)
if hasattr(i18n_instance, "language_options"):
languages = i18n_instance.language_options()
else:
locales_data = getattr(i18n_instance, "locales_data", {}) or {"ru": {}, "en": {}}
languages = locale_language_options(locales_data.keys(), base_languages=locales_data.keys())
for language in languages:
lang_code = language["code"]
checked = "" if current_lang == lang_code else ""
builder.button(
text=f"{language['flag']} {language['label']}{checked}",
callback_data=f"set_lang_{lang_code}{callback_suffix}",
)
builder.button(text=_(key="back_to_main_menu_button"), callback_data=back_callback)
builder.adjust(1)
return builder.as_markup()
@@ -187,9 +283,11 @@ def get_subscription_options_keyboard(
i18n_instance,
traffic_mode: bool = False,
back_callback: str = "main_action:back_to_main",
callback_context: Optional[str] = None,
) -> InlineKeyboardMarkup:
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
callback_context = callback_context or callback_context_from_back_callback(back_callback)
def _format_gb(val: float) -> str:
return str(int(val)) if float(val).is_integer() else f"{val:g}"
@@ -204,7 +302,10 @@ def get_subscription_options_keyboard(
price=price,
currency_symbol=currency_symbol_val,
)
callback_data = f"subscribe_period:{_format_gb(months)}"
callback_data = (
f"subscribe_period:{_format_gb(months)}"
f"{callback_suffix_for_context(callback_context)}"
)
else:
button_text = _(
"subscribe_for_months_button",
@@ -212,7 +313,9 @@ def get_subscription_options_keyboard(
price=price,
currency_symbol=currency_symbol_val,
)
callback_data = f"subscribe_period:{months}"
callback_data = (
f"subscribe_period:{months}{callback_suffix_for_context(callback_context)}"
)
builder.button(text=button_text, callback_data=callback_data)
builder.adjust(1)
builder.row(
@@ -222,9 +325,14 @@ def get_subscription_options_keyboard(
def get_tariff_catalog_keyboard(
tariffs: List[Any], lang: str, i18n_instance
tariffs: List[Any],
lang: str,
i18n_instance,
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)
for tariff in tariffs:
label = tariff.name(lang)
if tariff.billing_model == "period":
@@ -235,20 +343,30 @@ def get_tariff_catalog_keyboard(
package = tariff.min_traffic_package_rub()
if package:
label = f"{label} от {package.price:g} / {package.gb:g} GB"
builder.row(InlineKeyboardButton(text=label, callback_data=f"tariff:select:{tariff.key}"))
builder.row(
InlineKeyboardButton(
text=label,
callback_data=f"tariff:select:{tariff.key}"
f"{callback_suffix_for_context(callback_context)}",
)
)
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder.row(
InlineKeyboardButton(
text=_(key="back_to_main_menu_button"), callback_data="main_action:back_to_main"
)
InlineKeyboardButton(text=_(key="back_to_main_menu_button"), callback_data=back_callback)
)
return builder.as_markup()
def get_tariff_periods_keyboard(
tariff: Any, lang: str, i18n_instance, settings: Settings
tariff: Any,
lang: str,
i18n_instance,
settings: Settings,
back_callback: str = "main_action:subscribe",
callback_context: Optional[str] = None,
) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
callback_context = callback_context or callback_context_from_back_callback(back_callback)
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
for months in tariff.enabled_periods:
rub_price = tariff.period_price(months, "rub")
@@ -261,13 +379,12 @@ def get_tariff_periods_keyboard(
price=rub_price,
currency_symbol=settings.DEFAULT_CURRENCY_SYMBOL,
),
callback_data=f"tariff:period:{tariff.key}:{months}",
callback_data=f"tariff:period:{tariff.key}:{months}"
f"{callback_suffix_for_context(callback_context)}",
)
)
builder.row(
InlineKeyboardButton(
text=_(key="back_to_main_menu_button"), callback_data="main_action:subscribe"
)
InlineKeyboardButton(text=_(key="back_to_main_menu_button"), callback_data=back_callback)
)
return builder.as_markup()
@@ -278,8 +395,10 @@ def get_tariff_packages_keyboard(
lang: str,
i18n_instance,
back_callback: str = "main_action:subscribe",
callback_context: Optional[str] = None,
) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
callback_context = callback_context or callback_context_from_back_callback(back_callback)
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
for package in packages:
builder.row(
@@ -290,7 +409,8 @@ def get_tariff_packages_keyboard(
price=package.price,
currency_symbol="RUB",
),
callback_data=f"tariff:package:{tariff.key}:{package.gb:g}",
callback_data=f"tariff:package:{tariff.key}:{package.gb:g}"
f"{callback_suffix_for_context(callback_context)}",
)
)
builder.row(
@@ -306,6 +426,7 @@ def get_hwid_device_packages_keyboard(
i18n_instance,
settings: Settings,
back_callback: str = "main_action:my_subscription",
renewal: bool = False,
) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
@@ -318,7 +439,10 @@ def get_hwid_device_packages_keyboard(
price=package.price,
currency_symbol=settings.DEFAULT_CURRENCY_SYMBOL,
),
callback_data=f"hwid_devices:package:{tariff.key}:{package.count}",
callback_data=(
f"hwid_devices:{'renewal_package' if renewal else 'package'}:"
f"{tariff.key}:{package.count}"
),
)
)
builder.row(
@@ -336,6 +460,9 @@ def get_payment_method_keyboard(
i18n_instance,
settings: Settings,
sale_mode: str = "subscription",
back_callback: Optional[str] = None,
user_id: Optional[int] = None,
is_admin: Optional[bool] = None,
) -> InlineKeyboardMarkup:
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
@@ -344,57 +471,43 @@ def get_payment_method_keyboard(
return str(int(val)) if float(val).is_integer() else f"{val:g}"
value_str = _format_value(months)
mode_suffix = f":{sale_mode}"
import logging as _kbd_logging
_kbd_logging.info(
"payment_method_keyboard build: order=%s | platega_enabled=%s sbp=%s crypto=%s",
"payment_method_keyboard build: order=%s",
settings.payment_methods_order,
settings.PLATEGA_ENABLED,
settings.PLATEGA_SBP_ENABLED,
settings.PLATEGA_CRYPTO_ENABLED,
)
from bot.payment_providers import get_provider_spec, provider_telegram_button_text
for method in settings.payment_methods_order:
if method == "severpay" and getattr(settings, "SEVERPAY_ENABLED", False):
builder.button(
text=_("pay_with_severpay_button"),
callback_data=f"pay_severpay:{value_str}:{price}{mode_suffix}",
spec = get_provider_spec(method)
if (
not spec
or not spec.callback_prefix
or not spec.is_available_to_user(
settings,
user_id=user_id,
is_admin=is_admin,
require_configured=False,
)
elif method == "freekassa" and settings.FREEKASSA_ENABLED:
builder.button(
text=_("pay_with_sbp_button"),
callback_data=f"pay_fk:{value_str}:{price}{mode_suffix}",
)
elif method == "platega_sbp" and settings.PLATEGA_ENABLED and settings.PLATEGA_SBP_ENABLED:
builder.button(
text=_("pay_with_platega_sbp_button"),
callback_data=f"pay_platega_sbp:{value_str}:{price}{mode_suffix}",
)
elif (
method == "platega_crypto"
and settings.PLATEGA_ENABLED
and settings.PLATEGA_CRYPTO_ENABLED
):
builder.button(
text=_("pay_with_platega_crypto_button"),
callback_data=f"pay_platega_crypto:{value_str}:{price}{mode_suffix}",
)
elif method == "yookassa" and settings.YOOKASSA_ENABLED:
builder.button(
text=_("pay_with_yookassa_button"),
callback_data=f"pay_yk:{value_str}:{price}{mode_suffix}",
)
elif method == "stars" and settings.STARS_ENABLED and stars_price is not None:
builder.button(
text=_("pay_with_stars_button"),
callback_data=f"pay_stars:{value_str}:{stars_price}{mode_suffix}",
)
elif method == "cryptopay" and settings.CRYPTOPAY_ENABLED:
builder.button(
text=_("pay_with_cryptopay_button"),
callback_data=f"pay_crypto:{value_str}:{price}{mode_suffix}",
)
builder.button(text=_(key="cancel_button"), callback_data="main_action:subscribe")
continue
callback_data = spec.callback_data(
value=value_str,
rub_price=price,
stars_price=stars_price,
sale_mode=sale_mode,
)
if not callback_data:
continue
builder.button(
text=provider_telegram_button_text(spec, settings, language=lang),
callback_data=callback_data,
)
builder.button(
text=_(key="cancel_button"),
callback_data=back_callback or payment_options_back_callback(sale_mode),
)
builder.adjust(1)
return builder.as_markup()
@@ -426,6 +539,7 @@ def get_yk_autopay_choice_keyboard(
i18n_instance,
has_saved_cards: bool = True,
sale_mode: str = "subscription",
back_callback: Optional[str] = None,
) -> InlineKeyboardMarkup:
"""Keyboard for choosing between saved card charge or new card payment when auto-renew is enabled.""" # noqa: E501
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
@@ -453,7 +567,7 @@ def get_yk_autopay_choice_keyboard(
builder.row(
InlineKeyboardButton(
text=_(key="back_to_payment_methods_button"),
callback_data=f"subscribe_period:{value_str}",
callback_data=back_callback or payment_methods_back_callback(value_str, sale_mode),
)
)
return builder.as_markup()
@@ -611,13 +725,29 @@ def get_connect_and_main_keyboard(
config_link: Optional[str],
connect_button_url: Optional[str] = None,
preserve_message: bool = False,
install_share_url: Optional[str] = None,
) -> InlineKeyboardMarkup:
"""Keyboard with a connect button and a back to main menu button."""
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
install_url = bot_install_guide_url(settings)
button_target = connect_button_url or config_link
if button_target:
if install_url:
builder.row(
InlineKeyboardButton(
text=_("connect_button"),
web_app=WebAppInfo(url=install_url),
)
)
if install_share_url:
builder.row(
InlineKeyboardButton(
text=_("install_guide_share_button"),
url=install_share_url,
)
)
elif button_target:
builder.row(InlineKeyboardButton(text=_("connect_button"), url=button_target))
elif settings.SUBSCRIPTION_MINI_APP_URL:
builder.row(
+111 -61
View File
@@ -1,23 +1,23 @@
import asyncio
import logging
from typing import Optional
from typing import Awaitable, Callable, Optional
from aiogram import Bot, Dispatcher
from aiogram.exceptions import TelegramNetworkError
from aiogram.types import BotCommand, MenuButtonDefault, MenuButtonWebApp, WebAppInfo
from sqlalchemy.orm import sessionmaker
from bot.app.controllers.dispatcher_controller import build_dispatcher
from bot.app.factories.build_services import build_core_services
from bot.app.web.web_server import build_and_start_web_app
from bot.handlers.admin.sync_admin import perform_sync
from bot.infra.redis import close_redis
from bot.middlewares.i18n import JsonI18n
from bot.routers import build_root_router
from bot.services.panel_api_service import PanelApiService
from bot.services.settings_override_service import load_overrides_from_db
from bot.services.locale_override_service import load_locale_overrides
from bot.utils.message_queue import init_queue_manager
from config.settings import Settings
from db.database_setup import init_db_connection
from db.database_setup import init_db, init_db_connection
TELEGRAM_STARTUP_RETRY_DELAY_SECONDS = 2.0
def redact_token(value: str, token: Optional[str]) -> str:
@@ -26,6 +26,66 @@ def redact_token(value: str, token: Optional[str]) -> str:
return value.replace(token, "***")
def _telegram_network_error_detail(exc: TelegramNetworkError) -> str:
root_cause = exc.__cause__ or exc.__context__
detail = str(exc)
if root_cause:
root_detail = f"{type(root_cause).__name__}: {root_cause}"
if root_detail not in detail:
detail = f"{detail} ({root_detail})"
return detail
async def _run_telegram_startup_step(
action: str,
step: Callable[[], Awaitable[object]],
unexpected_log_message: str,
*,
attempts: Optional[int] = None,
retry_delay_seconds: float = TELEGRAM_STARTUP_RETRY_DELAY_SECONDS,
) -> bool:
attempt = 1
max_attempts = max(1, attempts) if attempts is not None else None
while True:
try:
await step()
if attempt > 1:
logging.info(
"STARTUP: Telegram step succeeded while %s on attempt %s%s.",
action,
attempt,
f"/{max_attempts}" if max_attempts is not None else "",
)
return True
except TelegramNetworkError as exc:
detail = _telegram_network_error_detail(exc)
attempt_label = (
f"{attempt}/{max_attempts}" if max_attempts is not None else str(attempt)
)
if max_attempts is not None and attempt >= max_attempts:
logging.warning(
"STARTUP: Telegram network error while %s after %s attempts: %s.",
action,
max_attempts,
detail,
)
return False
logging.warning(
"STARTUP: Telegram network error while %s on attempt %s: %s. "
"Retrying in %.1fs and will keep trying until Telegram is reachable.",
action,
attempt_label,
detail,
retry_delay_seconds,
)
attempt += 1
await asyncio.sleep(retry_delay_seconds)
continue
except Exception:
logging.exception(unexpected_log_message)
return False
async def register_all_routers(dp: Dispatcher, settings: Settings):
dp.include_router(build_root_router(settings))
logging.info("All application routers registered.")
@@ -49,7 +109,7 @@ async def on_startup_configured(dispatcher: Dispatcher):
redact_token(full_telegram_webhook_url, settings.BOT_TOKEN),
)
try:
async def _configure_webhook() -> None:
current_webhook_info = await bot.get_webhook_info()
logging.info(
f"STARTUP: Current Telegram webhook info BEFORE setting: {current_webhook_info.model_dump_json(exclude_none=True, indent=2)}" # noqa: E501
@@ -81,8 +141,11 @@ async def on_startup_configured(dispatcher: Dispatcher):
"STARTUP: CRITICAL - Telegram Webhook URL is EMPTY after set attempt. Check bot token and URL validity." # noqa: E501
)
except Exception:
logging.exception("STARTUP: EXCEPTION during set/get Telegram webhook.")
await _run_telegram_startup_step(
"configuring Telegram webhook",
_configure_webhook,
"STARTUP: EXCEPTION during set/get Telegram webhook.",
)
else:
logging.error(
"STARTUP: WEBHOOK_BASE_URL not set in environment. Webhook mode is required. Exiting."
@@ -90,7 +153,8 @@ async def on_startup_configured(dispatcher: Dispatcher):
raise SystemExit("WEBHOOK_BASE_URL is required. Polling mode is disabled.")
if settings.SUBSCRIPTION_MINI_APP_URL:
try:
async def _configure_mini_app_menu() -> None:
menu_text = i18n_instance.gettext(
settings.DEFAULT_LANGUAGE,
"menu_personal_account_button",
@@ -103,10 +167,14 @@ async def on_startup_configured(dispatcher: Dispatcher):
)
await bot.set_chat_menu_button(menu_button=MenuButtonDefault())
logging.info("STARTUP: Mini app domain registered and default menu button restored.")
except Exception:
logging.exception("STARTUP: Failed to register mini app domain.")
try:
await _run_telegram_startup_step(
"registering mini app menu button",
_configure_mini_app_menu,
"STARTUP: Failed to register mini app domain.",
)
async def _configure_bot_commands() -> None:
bot_commands = [
BotCommand(command="tg", description="Интерфейс в боте"),
]
@@ -117,8 +185,12 @@ async def on_startup_configured(dispatcher: Dispatcher):
)
await bot.set_my_commands(bot_commands)
logging.info("STARTUP: bot command descriptions set.")
except Exception:
logging.exception("STARTUP: Failed to set bot commands.")
await _run_telegram_startup_step(
"setting bot commands",
_configure_bot_commands,
"STARTUP: Failed to set bot commands.",
)
# Initialize message queue manager
try:
@@ -128,42 +200,9 @@ async def on_startup_configured(dispatcher: Dispatcher):
except Exception:
logging.exception("STARTUP: Failed to initialize message queue manager.")
# Automatic sync on startup — runs in background so the dispatcher can
# start serving Telegram webhooks immediately even if the panel is slow.
# perform_sync is single-flight, so concurrent admin-triggered runs will
# be skipped while this one is in progress.
logging.info("STARTUP: Bot on_startup_configured completed.")
async def _background_startup_sync(
*,
panel_service: PanelApiService,
session_factory: sessionmaker,
settings: Settings,
i18n_instance: JsonI18n,
) -> None:
try:
async with session_factory() as session:
sync_result = await perform_sync(
panel_service=panel_service,
session=session,
settings=settings,
i18n_instance=i18n_instance,
)
status = sync_result.get("status")
details = sync_result.get("details", "N/A")
if status == "completed":
logging.info(f"STARTUP: Background sync completed successfully. Details: {details}")
elif status == "skipped":
logging.info(f"STARTUP: Background sync skipped: {details}")
else:
logging.warning(
f"STARTUP: Background sync finished with status '{status}'. Details: {details}"
)
except Exception:
logging.exception("STARTUP: Background sync failed.")
async def on_shutdown_configured(dispatcher: Dispatcher):
logging.warning("SHUTDOWN: on_shutdown_configured executing...")
@@ -187,19 +226,19 @@ async def on_shutdown_configured(dispatcher: Dispatcher):
except Exception as e:
logging.warning(f"Failed to close session for {key}: {e}")
from bot.payment_providers import iter_service_keys
for service_key in (
"panel_service",
"cryptopay_service",
"freekassa_service",
"panel_webhook_service",
"yookassa_service",
"lknpd_service",
"promo_code_service",
"stars_service",
"subscription_service",
"referral_service",
"platega_service",
"severpay_service",
"support_service",
"notification_service",
"email_auth_service",
*iter_service_keys(),
):
await close_service(service_key)
@@ -227,13 +266,16 @@ async def run_bot(settings_param: Settings):
if local_async_session_factory is None:
logging.critical("Failed to initialize database connection and session factory. Exiting.")
return
await load_overrides_from_db(settings_param, local_async_session_factory)
await init_db(settings_param, local_async_session_factory)
dp, bot, extra = build_dispatcher(settings_param, local_async_session_factory)
i18n_instance = extra["i18n_instance"]
await load_locale_overrides(i18n_instance, local_async_session_factory)
# Get bot username for YooKassa default return URL if needed
actual_bot_username = "your_bot_username"
try:
async def _resolve_bot_username() -> None:
nonlocal actual_bot_username
bot_info = await bot.get_me()
if bot_info.username:
actual_bot_username = bot_info.username
@@ -241,10 +283,14 @@ async def run_bot(settings_param: Settings):
logging.info(f"Bot username resolved: @{actual_bot_username}")
else:
logging.warning("Bot username is empty; Telegram Login Widget will be unavailable.")
except Exception as e:
logging.error(
f"Failed to get bot info (e.g., for YooKassa default URL): {e}. Using fallback: {actual_bot_username}" # noqa: E501
)
bot_username_resolved = await _run_telegram_startup_step(
"getting bot info from Telegram",
_resolve_bot_username,
f"Failed to get bot info (e.g., for YooKassa default URL). Using fallback: {actual_bot_username}", # noqa: E501
)
if not bot_username_resolved:
logging.warning("Using fallback bot username: %s", actual_bot_username)
services = build_core_services(
settings_param,
@@ -275,10 +321,14 @@ async def run_bot(settings_param: Settings):
await dp.emit_shutdown()
raise SystemExit("WEBHOOK_BASE_URL is required. Polling mode is disabled.")
from bot.payment_providers import get_provider_spec
_yk_spec = get_provider_spec("yookassa")
_yk_path = _yk_spec.webhook_path(settings_param) if _yk_spec and _yk_spec.webhook_path else "-"
logging.info(
"Starting AIOHTTP server: webhook_base=%s yookassa_path=%s",
settings_param.WEBHOOK_BASE_URL,
settings_param.yookassa_webhook_path,
_yk_path,
)
async def web_server_task():
+447 -9
View File
@@ -1,7 +1,10 @@
import json
import logging
import os
from typing import Any, Awaitable, Callable, Dict, Optional
import re
import time
from pathlib import Path
from typing import Any, Awaitable, Callable, Dict, Iterable, List, Optional, Set, Tuple
from aiogram import BaseMiddleware
from aiogram.types import Update, User
@@ -10,14 +13,307 @@ from sqlalchemy.ext.asyncio import AsyncSession
from config.settings import Settings
from db.dal import user_dal
LocaleOverrides = Dict[str, Dict[str, str]]
_LOCALE_LANGUAGE_CODE_RE = re.compile(r"^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$")
LANGUAGE_LABELS: Dict[str, str] = {
"ru": "Русский",
"en": "English",
"de": "Deutsch",
"es": "Español",
"fr": "Français",
"pt-br": "Português (BR)",
"tr": "Türkçe",
"uk": "Українська",
}
LANGUAGE_FLAGS: Dict[str, str] = {
"ru": "🇷🇺",
"en": "🇬🇧",
"de": "🇩🇪",
"es": "🇪🇸",
"fr": "🇫🇷",
"pt-br": "🇧🇷",
"tr": "🇹🇷",
"uk": "🇺🇦",
}
DEFAULT_LANGUAGE_ORDER = ("ru", "en")
LOCALE_KEY_ALIASES: Dict[str, str] = {
"admin_apply": "wa_apply",
"admin_ads_col_status": "admin_status",
"admin_ad_label_source": "admin_ads_col_source",
"admin_back": "wa_back",
"admin_btn_refresh": "admin_refresh",
"admin_btn_save": "admin_save",
"admin_btn_saving": "admin_saving",
"admin_close": "wa_close",
"admin_copied": "wa_copied",
"admin_copy": "wa_copy",
"admin_csv_amount": "admin_amount",
"admin_csv_description": "admin_description",
"admin_csv_payment_id": "admin_id",
"admin_csv_status": "admin_status",
"admin_link_copied": "wa_link_copied",
"admin_next": "wa_next",
"admin_payment_detail_copied": "wa_copied",
"admin_payment_detail_provider": "admin_provider",
"admin_payment_detail_provider_section": "admin_provider",
"admin_payment_detail_user_section": "admin_user",
"admin_payments_col_user_id": "admin_id",
"admin_promo_col_code": "admin_promo_csv_code",
"admin_promo_col_status": "admin_status",
"admin_promo_csv_is_active": "admin_badge_active",
"admin_promo_csv_status": "admin_status",
"admin_promo_label_code": "admin_promo_csv_code",
"admin_promo_unlimited_validity": "admin_promo_unlimited",
"admin_stats_revenue_custom_range_apply": "wa_apply",
"admin_stats_revenue_tooltip_amount": "admin_amount",
"admin_stats_sync_status": "admin_status",
"admin_status_active": "admin_badge_active",
"admin_support_category": "wa_support_category",
"admin_support_category_account": "wa_support_category_account",
"admin_support_category_billing": "wa_support_category_billing",
"admin_support_category_other": "wa_support_category_other",
"admin_support_category_technical": "wa_support_category_technical",
"admin_support_close_ticket": "wa_close",
"admin_support_empty": "wa_support_empty",
"admin_support_filter_active": "wa_support_filter_active",
"admin_support_filter_all": "wa_support_filter_all",
"admin_support_internal_note": "wa_support_internal_note",
"admin_support_no_messages": "wa_support_no_messages",
"admin_support_priority": "wa_support_priority",
"admin_support_priority_high": "wa_support_priority_high",
"admin_support_priority_low": "wa_support_priority_low",
"admin_support_priority_normal": "wa_support_priority_normal",
"admin_support_priority_urgent": "wa_support_priority_urgent",
"admin_support_role_system": "wa_support_role_system",
"admin_support_role_user": "admin_user",
"admin_support_search": "admin_search",
"admin_support_status": "admin_status",
"admin_support_status_awaiting_admin": "wa_support_status_awaiting_admin",
"admin_support_status_awaiting_user": "wa_support_status_awaiting_user",
"admin_support_status_closed": "wa_support_status_closed",
"admin_support_status_open": "wa_support_status_open",
"admin_support_status_resolved": "wa_support_status_resolved",
"admin_support_ticket_number": "wa_support_ticket_number",
"admin_support_user_context": "admin_user",
"admin_tariffs_legacy_traffic_packages": "admin_tariff_traffic_packages",
"admin_tariffs_stat_enabled": "admin_enabled",
"admin_user_btn_cancel": "wa_cancel",
"admin_user_history_until": "wa_until_date",
"admin_user_label_provider": "admin_provider",
"admin_user_short": "admin_user",
"admin_user_stats_total_label": "admin_total",
"back_to_autopay_method_choice_button": "back_to_main_menu_button",
"back_to_payment_methods_button": "back_to_main_menu_button",
"cancel_broadcast_button": "cancel_button",
"csv_no": "no_button",
"csv_yes": "yes_button",
"user_premium_override_status_unlimited": "user_regular_override_status_unlimited",
"user_regular_override_save": "admin_save",
"wa_devices_disconnect_title": "wa_devices_disconnect",
"wa_install_link_copied": "wa_link_copied",
"wa_link_email_modal_title": "wa_settings_link_email_action",
}
def resolve_locale_key(key: object) -> str:
value = str(key or "").strip()
seen: Set[str] = set()
while value in LOCALE_KEY_ALIASES and value not in seen:
seen.add(value)
value = LOCALE_KEY_ALIASES[value]
return value
def is_valid_locale_language_code(value: str) -> bool:
return 2 <= len(value) <= 16 and bool(_LOCALE_LANGUAGE_CODE_RE.fullmatch(value))
def normalize_locale_language_code(
raw: object,
valid_languages: Optional[Set[str]] = None,
*,
prefer_known_base: bool = True,
) -> str:
value = str(raw or "").strip().lower().replace("_", "-")
if not value:
return ""
if prefer_known_base and valid_languages and value not in valid_languages:
base = value.split("-", 1)[0]
if base in valid_languages:
return base
return value
def _normalize_language_code(raw: object, valid_languages: Optional[Set[str]] = None) -> str:
return normalize_locale_language_code(raw, valid_languages)
def locale_language_label(code: object) -> str:
value = normalize_locale_language_code(code, prefer_known_base=False)
return LANGUAGE_LABELS.get(value, value.upper())
def locale_language_flag(code: object) -> str:
value = normalize_locale_language_code(code, prefer_known_base=False)
return LANGUAGE_FLAGS.get(value, "🏳️")
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 = {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)
return [*preferred, *rest]
def locale_language_options(
codes: Iterable[object],
*,
base_languages: Iterable[object] = (),
) -> List[Dict[str, Any]]:
base_set = set(sort_locale_language_codes(base_languages))
return [
{
"code": code,
"label": locale_language_label(code),
"flag": locale_language_flag(code),
"base": code in base_set,
}
for code in sort_locale_language_codes(codes)
]
def _valid_locale_keys_by_language(
locales_data: Dict[str, Dict[str, str]],
) -> Dict[str, Set[str]]:
return {
lang: {str(key) for key in messages.keys()}
for lang, messages in locales_data.items()
if isinstance(messages, dict)
}
def normalize_locale_overrides_payload(
payload: object,
*,
valid_languages: Optional[Iterable[str]] = None,
valid_keys_by_language: Optional[Dict[str, Set[str]]] = None,
allow_extra_languages: bool = False,
key_aliases: Optional[Dict[str, str]] = None,
) -> Tuple[LocaleOverrides, Dict[str, str]]:
"""Normalize a user/admin supplied locale override JSON payload.
The canonical shape is ``{"ru": {"welcome": "..."}, "en": {...}}``.
For convenience, files may also wrap it as ``{"overrides": {...}}`` or
``{"locales": {...}}``.
"""
if not isinstance(payload, dict):
return {}, {"_payload": "invalid_payload"}
raw_payload = payload
for wrapper_key in ("overrides", "locales"):
wrapped = raw_payload.get(wrapper_key)
if isinstance(wrapped, dict):
raw_payload = wrapped
break
valid_lang_set = {str(lang).lower() for lang in valid_languages or []}
aliases = key_aliases or LOCALE_KEY_ALIASES
def resolve_payload_key(raw_key: str) -> str:
value = raw_key
seen: Set[str] = set()
while value in aliases and value not in seen:
seen.add(value)
value = aliases[value]
return value
all_valid_keys: Set[str] = set()
if valid_keys_by_language:
for keys in valid_keys_by_language.values():
all_valid_keys.update(str(key) for key in keys)
overrides: LocaleOverrides = {}
errors: Dict[str, str] = {}
for raw_lang, raw_messages in raw_payload.items():
lang = normalize_locale_language_code(
raw_lang,
valid_lang_set or None,
prefer_known_base=not allow_extra_languages,
)
error_key = str(raw_lang or "_language")
if not lang:
errors[error_key] = "invalid_language"
continue
if valid_lang_set and lang not in valid_lang_set:
if not allow_extra_languages:
errors[error_key] = "unknown_language"
continue
if not is_valid_locale_language_code(lang):
errors[error_key] = "invalid_language"
continue
elif allow_extra_languages and not is_valid_locale_language_code(lang):
errors[error_key] = "invalid_language"
continue
if not isinstance(raw_messages, dict):
errors[lang] = "invalid_language_bucket"
continue
lang_keys = valid_keys_by_language.get(lang, set()) if valid_keys_by_language else set()
bucket: Dict[str, str] = {}
for raw_key, raw_value in raw_messages.items():
raw_key_text = str(raw_key or "").strip()
key = resolve_payload_key(raw_key_text)
item_error_key = f"{lang}.{raw_key_text or '_key'}"
if not raw_key_text or not key:
errors[item_error_key] = "invalid_key"
continue
if all_valid_keys and key not in all_valid_keys and key not in lang_keys:
errors[item_error_key] = "unknown_key"
continue
if raw_value is None:
continue
if not isinstance(raw_value, str):
errors[item_error_key] = "invalid_value"
continue
if len(raw_value) > 20000:
errors[item_error_key] = "value_too_long"
continue
if raw_key_text in aliases and key in bucket:
continue
bucket[key] = raw_value
if bucket:
overrides[lang] = dict(sorted(bucket.items()))
return dict(sorted(overrides.items())), errors
class JsonI18n:
def __init__(self, path: str, default: str = "en", domain: str = "bot"):
def __init__(
self,
path: str,
default: str = "en",
domain: str = "bot",
overrides_path: Optional[str] = None,
):
self.domain = domain
self.path = path
self.default_lang = default
self.base_locales_data: Dict[str, Dict[str, str]] = {}
self.locale_overrides: LocaleOverrides = {}
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()
if overrides_path:
self.configure_overrides_file(overrides_path)
self.reload_overrides_from_file(force=True)
logging.info(
f"JsonI18n initialized. Loaded languages: {list(self.locales_data.keys())}. Default: {self.default_lang}" # noqa: E501
)
@@ -26,13 +322,26 @@ class JsonI18n:
if not os.path.isdir(self.path):
logging.error(f"Locales path not found or not a directory: {self.path}")
return
loaded: Dict[str, Dict[str, str]] = {}
for item in os.listdir(self.path):
if item.endswith(".json"):
lang_code = item.split(".")[0]
file_path = os.path.join(self.path, item)
try:
with open(file_path, "r", encoding="utf-8") as f:
self.locales_data[lang_code] = json.load(f)
data = json.load(f)
if isinstance(data, dict):
loaded[lang_code] = {
str(key): str(value)
for key, value in data.items()
if isinstance(value, str)
}
else:
logging.error(
"Locale %s from %s is not a JSON object",
lang_code,
file_path,
)
except json.JSONDecodeError as e_json_load:
logging.error(
f"Error loading locale {lang_code} from {file_path} (JSON Decode Error): {e_json_load}" # noqa: E501
@@ -42,24 +351,153 @@ class JsonI18n:
f"Error loading locale {lang_code} from {file_path}: {e_load}",
exc_info=True,
)
self.base_locales_data = loaded
self._rebuild_effective_locales()
def _rebuild_effective_locales(self) -> None:
effective: Dict[str, Dict[str, str]] = {}
for lang, messages in self.base_locales_data.items():
merged = dict(messages)
merged.update(self.locale_overrides.get(lang, {}))
effective[lang] = merged
fallback_base = (
self.base_locales_data.get(self.default_lang)
or self.base_locales_data.get("en")
or next(iter(self.base_locales_data.values()), {})
)
for lang, messages in self.locale_overrides.items():
if lang in effective:
continue
merged = dict(fallback_base)
merged.update(messages)
effective[lang] = merged
self.locales_data = effective
def _valid_keys_by_language(self) -> Dict[str, Set[str]]:
return _valid_locale_keys_by_language(self.base_locales_data)
def language_options(self) -> List[Dict[str, Any]]:
self.reload_overrides_from_file()
return locale_language_options(
self.locales_data.keys(),
base_languages=self.base_locales_data.keys(),
)
def set_locale_overrides(self, overrides: object) -> Dict[str, str]:
normalized, errors = normalize_locale_overrides_payload(
overrides,
valid_languages=set(self.base_locales_data.keys()),
valid_keys_by_language=self._valid_keys_by_language(),
allow_extra_languages=True,
)
if errors:
logging.warning("Some locale overrides were skipped: %s", errors)
self.locale_overrides = normalized
self._rebuild_effective_locales()
return errors
def configure_overrides_file(self, path: str | Path) -> None:
self._overrides_path = Path(path)
try:
self._overrides_file_mtime_ns = self._overrides_path.stat().st_mtime_ns
except FileNotFoundError:
self._overrides_file_mtime_ns = None
except OSError as exc:
logging.warning("Failed to stat locale overrides file %s: %s", path, exc)
self._overrides_file_mtime_ns = None
def reload_overrides_from_file(self, *, force: bool = False) -> bool:
if self._overrides_path is None:
return False
now = time.monotonic()
if not force and now < self._overrides_file_next_check:
return False
self._overrides_file_next_check = now + self._overrides_file_check_interval_seconds
try:
stat = self._overrides_path.stat()
except FileNotFoundError:
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"
)
return False
except OSError as exc:
logging.warning(
"Failed to stat locale overrides file %s: %s",
self._overrides_path,
exc,
)
return False
try:
content = self._overrides_path.read_text(encoding="utf-8")
except OSError as exc:
logging.warning(
"Failed to read locale overrides file %s: %s",
self._overrides_path,
exc,
)
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
def gettext(self, lang_code: Optional[str], key: str, **kwargs) -> str:
self.reload_overrides_from_file()
lookup_key = resolve_locale_key(key)
requested_lang_code = normalize_locale_language_code(
lang_code,
set(self.locales_data.keys()),
prefer_known_base=False,
)
requested_base_lang_code = requested_lang_code.split("-", 1)[0]
# Determine effective language with robust fallback
if lang_code and lang_code in self.locales_data:
effective_lang_code = lang_code
if requested_lang_code and requested_lang_code in self.locales_data:
effective_lang_code = requested_lang_code
elif requested_base_lang_code and requested_base_lang_code in self.locales_data:
effective_lang_code = requested_base_lang_code
elif self.default_lang in self.locales_data:
effective_lang_code = self.default_lang
elif "en" in self.locales_data:
effective_lang_code = "en"
else:
effective_lang_code = lang_code or self.default_lang
effective_lang_code = requested_lang_code or self.default_lang
lang_data = self.locales_data.get(effective_lang_code)
if lang_data is None:
# Try explicit fallback to English if available
fallback_data = self.locales_data.get("en")
if fallback_data is not None:
text = fallback_data.get(key)
text = fallback_data.get(lookup_key)
if text is not None:
try:
return text.format(**kwargs) if kwargs else text
@@ -70,11 +508,11 @@ class JsonI18n:
)
return key.format(**kwargs) if kwargs else key
text = lang_data.get(key)
text = lang_data.get(lookup_key)
if text is None:
if effective_lang_code != self.default_lang:
default_lang_data = self.locales_data.get(self.default_lang, {})
text = default_lang_data.get(key)
text = default_lang_data.get(lookup_key)
if text is None:
logging.warning(
+51 -14
View File
@@ -1,4 +1,5 @@
import logging
import time
from typing import Any, Awaitable, Callable, Dict, Optional
from aiogram import BaseMiddleware
@@ -6,9 +7,13 @@ from aiogram.types import Update
from aiogram.types import User as TgUser
from sqlalchemy.ext.asyncio import AsyncSession
from bot.utils.text_sanitizer import sanitize_display_name, sanitize_username, username_for_display
from bot.infra.redis import cache_get_json, cache_set_json, redis_key
from bot.utils.text_sanitizer import sanitize_display_name, sanitize_username
from config.settings import Settings
from db.dal import user_dal
_LOCAL_PROFILE_SYNC_CHECKS: Dict[int, float] = {}
class ProfileSyncMiddleware(BaseMiddleware):
async def __call__(
@@ -19,8 +24,12 @@ class ProfileSyncMiddleware(BaseMiddleware):
) -> Any:
session: AsyncSession = data.get("session")
tg_user: Optional[TgUser] = data.get("event_from_user")
settings: Optional[Settings] = data.get("settings")
if session and tg_user:
if settings and await _profile_sync_recently_checked(settings, int(tg_user.id)):
return await handler(event, data)
try:
db_user = await user_dal.get_user_by_telegram_id(session, tg_user.id)
if not db_user:
@@ -46,22 +55,13 @@ class ProfileSyncMiddleware(BaseMiddleware):
f"ProfileSyncMiddleware: Updated user {tg_user.id} profile fields: {list(update_payload.keys())}" # noqa: E501
)
# Also update description on panel if linked
# Keep panel identity fields fresh, but do not rewrite
# description from profile changes. Remnawave may return
# description with lossy encoding in list views.
try:
panel_service = data.get("panel_service")
if panel_service and db_user.panel_user_uuid:
description_text = "\n".join(
[
db_user.email or "",
username_for_display(tg_user.username, with_at=False)
if sanitized_username is not None
else "",
sanitized_first_name or "",
sanitized_last_name or "",
]
).strip()
panel_payload = {
"description": description_text,
"telegramId": tg_user.id,
}
if db_user.email:
@@ -72,12 +72,49 @@ class ProfileSyncMiddleware(BaseMiddleware):
)
except Exception as e_upd_desc:
logging.warning(
f"ProfileSyncMiddleware: Failed to update panel description for user {tg_user.id}: {e_upd_desc}" # noqa: E501
f"ProfileSyncMiddleware: Failed to update panel identity for user {tg_user.id}: {e_upd_desc}" # noqa: E501
)
except Exception as e:
logging.error(
f"ProfileSyncMiddleware: Failed to sync profile for user {getattr(tg_user, 'id', 'N/A')}: {e}", # noqa: E501
exc_info=True,
)
finally:
if settings:
await _mark_profile_sync_checked(settings, int(tg_user.id))
return await handler(event, data)
async def _profile_sync_recently_checked(settings: Settings, telegram_id: int) -> bool:
ttl_seconds = int(getattr(settings, "PROFILE_SYNC_CACHE_TTL_SECONDS", 900) or 0)
if ttl_seconds <= 0:
return False
now = time.monotonic()
expires_at = _LOCAL_PROFILE_SYNC_CHECKS.get(telegram_id)
if expires_at and expires_at > now:
return True
key = redis_key(settings, "cache", "profile-sync", telegram_id)
try:
cached = await cache_get_json(settings, key)
except Exception:
cached = None
if cached:
_LOCAL_PROFILE_SYNC_CHECKS[telegram_id] = now + ttl_seconds
return True
return False
async def _mark_profile_sync_checked(settings: Settings, telegram_id: int) -> None:
ttl_seconds = int(getattr(settings, "PROFILE_SYNC_CACHE_TTL_SECONDS", 900) or 0)
if ttl_seconds <= 0:
return
_LOCAL_PROFILE_SYNC_CHECKS[telegram_id] = time.monotonic() + ttl_seconds
key = redis_key(settings, "cache", "profile-sync", telegram_id)
try:
await cache_set_json(settings, key, {"checked": True}, ttl_seconds)
except Exception:
pass
+61
View File
@@ -0,0 +1,61 @@
from .base import (
PaymentProviderPresentation,
PaymentProviderSpec,
ProviderConfigBundle,
ProviderEnvConfig,
ProviderManifestField,
ServiceFactoryContext,
WebAppPaymentContext,
)
from .registry import (
PAYMENT_PROVIDER_SPECS,
build_provider_configs,
build_provider_services,
current_provider_configs,
find_manifest_owner,
get_provider_bundle,
get_provider_spec,
get_spec_presentation,
iter_provider_manifest_fields,
iter_provider_specs,
iter_service_keys,
iter_unique_provider_routers,
manifest_field_default,
pending_statuses,
provider_admin_only_pairs,
provider_emoji_map,
provider_label_map,
provider_telegram_button_text,
provider_webhook_metadata,
resolve_provider_presentation,
)
__all__ = [
"PAYMENT_PROVIDER_SPECS",
"PaymentProviderPresentation",
"PaymentProviderSpec",
"ProviderConfigBundle",
"ProviderEnvConfig",
"ProviderManifestField",
"ServiceFactoryContext",
"WebAppPaymentContext",
"build_provider_configs",
"build_provider_services",
"current_provider_configs",
"find_manifest_owner",
"get_provider_bundle",
"get_provider_spec",
"get_spec_presentation",
"iter_provider_manifest_fields",
"iter_provider_specs",
"manifest_field_default",
"iter_service_keys",
"iter_unique_provider_routers",
"pending_statuses",
"provider_telegram_button_text",
"provider_emoji_map",
"provider_admin_only_pairs",
"provider_label_map",
"provider_webhook_metadata",
"resolve_provider_presentation",
]
+311
View File
@@ -0,0 +1,311 @@
from __future__ import annotations
import os
from dataclasses import dataclass, field
from typing import Any, Awaitable, Callable, Mapping, Optional, Sequence, Type
from pydantic_settings import BaseSettings, SettingsConfigDict
def provider_env_file() -> Optional[str]:
"""Resolve the env file every provider config should read.
Tests set ``PROVIDER_ENV_FILE=""`` via conftest so per-provider
BaseSettings models don't pick up real credentials from the project's
.env. Production reads from ``.env`` as usual.
"""
value = os.environ.get("PROVIDER_ENV_FILE")
if value is None:
return ".env"
return value or None
class ProviderEnvConfig(BaseSettings):
"""Base class for per-provider env-config models.
Subclasses declare their own ``env_prefix`` (e.g. ``HELEKET_``) and
fields, so the provider module is the single source of truth for the
env vars it consumes no edits in the global ``Settings`` required.
"""
ADMIN_ONLY_ENABLED: bool = False
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
populate_by_name=True,
)
def provider_runtime_enabled(config: Any, *admin_only_attrs: str) -> bool:
"""Return True when a provider should run for public or admin-only payments."""
if bool(getattr(config, "ENABLED", False)):
return True
attrs = admin_only_attrs or ("ADMIN_ONLY_ENABLED",)
return any(bool(getattr(config, attr, False)) for attr in attrs)
@dataclass(frozen=True)
class ProviderConfigBundle:
"""Functional config + presentation overrides for a single provider."""
config: Optional[ProviderEnvConfig] = None
presentation: Optional[ProviderEnvConfig] = None
@dataclass(frozen=True)
class ProviderManifestField:
"""Self-contained manifest entry declared by a provider module.
Aggregated by the registry into the admin settings manifest, so the
admin UI gets per-provider fields without anyone editing
``admin_settings_manifest.py``.
"""
key: str
type: str
label: str
description: str = ""
placeholder: str = ""
secret: bool = False
optional: bool = True
min: Optional[float] = None
max: Optional[float] = None
choices: Optional[Sequence[tuple[str, str]]] = None
subsection: Optional[str] = None
target: str = "config" # "config" or "presentation" — which bundle slot it writes to
attr: Optional[str] = (
None # attribute name on the target model; defaults to key without env_prefix
)
i18n_label_key: Optional[str] = None
i18n_description_key: Optional[str] = None
i18n_subsection_key: Optional[str] = None
@dataclass(frozen=True)
class ServiceFactoryContext:
settings: Any
bot: Any
async_session_factory: Any
i18n: Any
bot_username_for_default_return: str
subscription_service: Any
referral_service: Any
provider_configs: Mapping[str, ProviderConfigBundle] = field(default_factory=dict)
def config_for(self, service_key: Optional[str]) -> Optional[ProviderConfigBundle]:
if not service_key:
return None
return self.provider_configs.get(service_key)
@dataclass(frozen=True)
class WebAppPaymentContext:
request: Any
session: Any
user_id: int
method: str
months: Any
price: float
stars_price: Optional[int]
description: str
sale_mode: str
traffic_gb: Optional[float] = None
hwid_valid_from: Optional[Any] = None
hwid_valid_until: Optional[Any] = None
hwid_pricing_period_months: Optional[int] = None
hwid_proration_ratio: Optional[float] = None
hwid_full_price: Optional[float] = None
EnabledPredicate = Callable[[Any], bool]
ServiceFactory = Callable[[ServiceFactoryContext], Any]
WebhookPathGetter = Callable[[Any], str]
WebhookRoute = Callable[[Any], Awaitable[Any]]
WebAppPaymentFactory = Callable[[WebAppPaymentContext], Awaitable[Any]]
@dataclass(frozen=True)
class PaymentProviderSpec:
id: str
provider_key: str
label: str
pending_status: str
enabled: EnabledPredicate
service_key: Optional[str] = None
callback_prefix: Optional[str] = None
webapp_label: Optional[str] = None
webapp_labels: Optional[Mapping[str, str]] = None
telegram_labels: Optional[Mapping[str, str]] = None
aliases: Sequence[str] = ()
router: Any = None
create_service: Optional[ServiceFactory] = None
webhook_path: Optional[WebhookPathGetter] = None
webhook_route: Optional[WebhookRoute] = None
webhook_requires_base_url: bool = False
create_webapp_payment: Optional[WebAppPaymentFactory] = None
requires_configured_service: bool = True
price_source: str = "rub"
emoji: str = "💳"
webapp_icon: Optional[str] = None
telegram_emoji: Optional[str] = None
config_class: Optional[Type[ProviderEnvConfig]] = None
presentation_class: Optional[Type[ProviderEnvConfig]] = None
manifest_fields: Sequence[ProviderManifestField] = ()
enabled_manifest_key: Optional[str] = None
admin_only_manifest_key: Optional[str] = None
admin_only_config_attr: str = "ADMIN_ONLY_ENABLED"
admin_only_enabled: Optional[EnabledPredicate] = None
@property
def settings_key(self) -> str:
return self.id.upper()
@property
def enabled_field_key(self) -> str:
return self.enabled_manifest_key or f"{self.settings_key}_ENABLED"
@property
def admin_only_field_key(self) -> str:
return self.admin_only_manifest_key or f"{self.settings_key}_ADMIN_ONLY_ENABLED"
@property
def default_telegram_emoji(self) -> str:
return self.telegram_emoji or self.emoji
@property
def method_ids(self) -> tuple[str, ...]:
return (self.id, *tuple(self.aliases))
def _predicate_value(self, predicate: EnabledPredicate, source: Any) -> bool:
# If this spec carries a provider-local config_class, prefer the live
# config bundle so callers can pass plain Settings without having to
# know about provider-local env layouts.
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 bool(predicate(bundle.config))
return bool(predicate(source))
def is_enabled(self, source: Any) -> bool:
return self._predicate_value(self.enabled, source)
def is_admin_only_enabled(self, source: Any) -> bool:
if self.admin_only_enabled is not None:
return self._predicate_value(self.admin_only_enabled, source)
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 bool(getattr(bundle.config, self.admin_only_config_attr, False))
return bool(getattr(source, self.admin_only_field_key, False))
def is_effectively_enabled(self, source: Any) -> bool:
return self.is_enabled(source) or self.is_admin_only_enabled(source)
def _is_admin_user(
self,
source: Any,
*,
user_id: Optional[int] = None,
is_admin: Optional[bool] = None,
) -> bool:
if is_admin is not None:
return bool(is_admin)
if user_id is None:
return False
try:
normalized_user_id = int(user_id)
except (TypeError, ValueError):
return False
try:
admin_ids = {int(item) for item in (getattr(source, "ADMIN_IDS", None) or [])}
except (TypeError, ValueError):
return False
return normalized_user_id in admin_ids
def is_service_configured(self, app: Any) -> bool:
if not self.requires_configured_service:
return True
if not self.service_key:
return True
service = app.get(self.service_key) if hasattr(app, "get") else None
return bool(service and getattr(service, "configured", False))
def is_visible(self, source: Any, app: Any) -> bool:
return self.is_enabled(source) and self.is_service_configured(app)
def is_available_to_user(
self,
source: Any,
app: Any = None,
*,
user_id: Optional[int] = None,
is_admin: Optional[bool] = None,
require_configured: bool = True,
) -> bool:
public_enabled = self.is_enabled(source)
admin_only_visible = self.is_admin_only_enabled(source) and self._is_admin_user(
source,
user_id=user_id,
is_admin=is_admin,
)
if not (public_enabled or admin_only_visible):
return False
if require_configured and app is not None and not self.is_service_configured(app):
return False
return True
def is_visible_for_user(
self,
source: Any,
app: Any,
*,
user_id: Optional[int] = None,
is_admin: Optional[bool] = None,
) -> bool:
return self.is_available_to_user(
source,
app,
user_id=user_id,
is_admin=is_admin,
require_configured=True,
)
def load_router(self) -> Any:
return self.router
def load_webhook_route(self) -> Optional[WebhookRoute]:
return self.webhook_route
def callback_data(
self,
*,
value: str,
rub_price: float,
stars_price: Optional[int],
sale_mode: str,
) -> Optional[str]:
if not self.callback_prefix:
return None
if self.price_source == "stars":
if stars_price is None:
return None
price: Any = stars_price
else:
price = rub_price
return f"{self.callback_prefix}:{value}:{price}:{sale_mode}"
@dataclass(frozen=True)
class PaymentProviderPresentation:
webapp_label: str
webapp_icon: Optional[str]
telegram_label: str
telegram_emoji: str
telegram_customized: bool
+595
View File
@@ -0,0 +1,595 @@
import hashlib
import hmac
import json
import logging
from typing import Optional
from aiocryptopay import AioCryptoPay, Networks
from aiocryptopay.models.update import Update
from aiogram import Bot, F, Router, types
from aiohttp import web
from pydantic import Field, field_validator
from pydantic_settings import SettingsConfigDict
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import sessionmaker
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 db.dal import payment_dal
from .base import (
PaymentProviderSpec,
ProviderEnvConfig,
ProviderManifestField,
ServiceFactoryContext,
WebAppPaymentContext,
provider_env_file,
provider_runtime_enabled,
)
from .shared import (
PaymentSuccessRequest,
describe_payment,
finalize_successful_payment,
make_translator,
notify_callback_parse_error,
notify_service_unavailable,
parse_payment_callback,
payment_failed,
payment_link_response,
payment_record_amounts,
payment_unavailable,
quote_hwid_callback_parts,
render_payment_link,
sale_mode_base,
sale_mode_is_traffic,
sale_mode_tariff_key,
)
logger = logging.getLogger(__name__)
_LOG = "cryptopay"
class CryptoPayConfig(ProviderEnvConfig):
model_config = SettingsConfigDict(
env_file=provider_env_file(),
env_file_encoding="utf-8",
env_prefix="CRYPTOPAY_",
extra="ignore",
)
ENABLED: bool = Field(default=True)
TOKEN: Optional[str] = None
NETWORK: str = Field(default="mainnet")
CURRENCY_TYPE: str = Field(default="fiat")
ASSET: str = Field(default="RUB")
@field_validator("TOKEN", mode="before")
@classmethod
def _strip_optional(cls, v):
if isinstance(v, str) and not v.strip():
return None
return v
@property
def webhook_path(self) -> str:
return "/webhook/cryptopay"
class CryptoPayPresentation(ProviderEnvConfig):
model_config = SettingsConfigDict(
env_file=provider_env_file(),
env_file_encoding="utf-8",
env_prefix="PAYMENT_CRYPTOPAY_",
extra="ignore",
)
WEBAPP_LABEL_RU: Optional[str] = None
WEBAPP_LABEL_EN: Optional[str] = None
WEBAPP_ICON: Optional[str] = None
TELEGRAM_LABEL_RU: Optional[str] = None
TELEGRAM_LABEL_EN: Optional[str] = None
TELEGRAM_EMOJI: Optional[str] = None
class CryptoPayService:
def __init__(
self,
bot: Bot,
settings: Settings,
config: CryptoPayConfig,
i18n: JsonI18n,
async_session_factory: sessionmaker,
subscription_service: SubscriptionService,
referral_service: ReferralService,
):
self.bot = bot
self.settings = settings
self.config = config
self.i18n = i18n
self.async_session_factory = async_session_factory
self.subscription_service = subscription_service
self.referral_service = referral_service
self._client = None
self._client_token = None
self._client_network = None
if not self.config.TOKEN:
logging.warning("CryptoPay token not provided. CryptoPay disabled")
@property
def token(self):
return self.config.TOKEN
@property
def configured(self) -> bool:
return bool(provider_runtime_enabled(self.config) and self.config.TOKEN)
@property
def client(self):
# Recreate the SDK client whenever the admin changes the token / network
# at runtime — otherwise we'd keep talking to the old account.
token = self.config.TOKEN
network = self.config.NETWORK
if not token:
return None
if self._client is None or token != self._client_token or network != self._client_network:
net = Networks.TEST_NET if str(network).lower() == "testnet" else Networks.MAIN_NET
self._client = AioCryptoPay(token=token, network=net)
self._client.register_pay_handler(self._invoice_paid_handler)
self._client_token = token
self._client_network = network
return self._client
async def close(self):
if self._client:
try:
await self._client.close()
logging.info("CryptoPay client session closed.")
except Exception as e:
logging.warning("Failed to close CryptoPay client: %s", e)
async def create_invoice(
self,
session: AsyncSession,
user_id: int,
months: int,
amount: float,
description: str,
sale_mode: str = "subscription",
url_kind: str = "bot",
hwid_quote: Optional[dict] = None,
) -> Optional[str]:
if not self.configured or not self.client:
logging.error("CryptoPayService not configured")
return None
sale_base = sale_mode_base(sale_mode)
amounts = payment_record_amounts(months=months, sale_mode=sale_mode)
try:
payment_record = await payment_dal.create_payment_record(
session,
{
"user_id": user_id,
"amount": float(amount),
"currency": self.config.ASSET,
"status": "pending_cryptopay",
"description": description,
"subscription_duration_months": (
int(months) if sale_base == "subscription" else None
),
"provider": "cryptopay",
"sale_mode": sale_mode,
"tariff_key": sale_mode_tariff_key(sale_mode),
"purchased_gb": amounts.purchased_gb,
"purchased_hwid_devices": amounts.purchased_hwid_devices,
"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")
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,
},
)
await session.commit()
except Exception:
await session.rollback()
logging.exception("Failed to create cryptopay payment record for user %s.", user_id)
return None
payload = json.dumps(
{
"user_id": str(user_id),
"subscription_months": str(months),
"payment_db_id": str(payment_record.payment_id),
"sale_mode": sale_mode,
"traffic_gb": str(months) if sale_mode_is_traffic(sale_mode) else None,
}
)
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,
description=description,
payload=payload,
)
try:
await payment_dal.update_provider_payment_and_status(
session,
payment_record.payment_id,
str(invoice.invoice_id),
str(invoice.status),
)
await session.commit()
except Exception:
await session.rollback()
logging.exception(
"Failed to update cryptopay payment record %s.",
payment_record.payment_id,
)
return None
if url_kind == "web":
return (
getattr(invoice, "web_app_invoice_url", None)
or getattr(invoice, "mini_app_invoice_url", None)
or invoice.bot_invoice_url
)
return invoice.bot_invoice_url
except Exception:
logging.exception("CryptoPay invoice creation failed.")
return None
async def _invoice_paid_handler(self, update: Update, app: web.Application):
invoice = update.payload
if not invoice.payload:
logging.warning("CryptoPay webhook without payload")
return
try:
meta = json.loads(invoice.payload)
user_id = int(meta["user_id"])
months = float(meta.get("subscription_months") or 0)
payment_db_id = int(meta["payment_db_id"])
sale_mode = meta.get("sale_mode") or (
"traffic" if self.settings.traffic_sale_mode else "subscription"
)
traffic_gb = float(meta.get("traffic_gb")) if meta.get("traffic_gb") else months
except Exception:
logging.exception("Failed to parse CryptoPay payload.")
return
async_session_factory: sessionmaker = app["async_session_factory"]
bot: Bot = app["bot"]
settings: Settings = app["settings"]
i18n: JsonI18n = app["i18n"]
subscription_service: SubscriptionService = app["subscription_service"]
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,
payment_db_id,
str(invoice.invoice_id),
"succeeded",
)
await session.commit()
except Exception:
await session.rollback()
logging.exception(
"Failed to mark CryptoPay invoice %s as succeeded.",
payment_db_id,
)
return
payment = await payment_dal.get_payment_by_db_id(session, payment_db_id)
if not payment:
logging.error(
"CryptoPay webhook: payment %s vanished after status update.",
payment_db_id,
)
return
currency = invoice.asset or settings.DEFAULT_CURRENCY_SYMBOL
await finalize_successful_payment(
PaymentSuccessRequest(
bot=bot,
settings=settings,
i18n=i18n,
session=session,
subscription_service=subscription_service,
referral_service=referral_service,
payment=payment,
user_id=user_id,
amount=float(invoice.amount),
currency=str(currency),
sale_mode=sale_mode,
months=int(months) if months else int(traffic_gb),
traffic_amount=float(traffic_gb),
provider_subscription="cryptopay",
provider_notification="crypto_pay",
log_prefix="CryptoPay webhook",
)
)
def _validate_webhook_signature(self, raw_body: bytes, signature: str) -> bool:
if not self.token:
return False
expected_signature = hmac.new(
hashlib.sha256(self.token.encode("utf-8")).digest(),
raw_body,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected_signature, signature or ""):
logger.error("CryptoPay signature mismatch")
return False
return True
async def webhook_route(self, request: web.Request) -> web.Response:
if not self.configured or not self.client:
return web.Response(status=503, text="cryptopay_disabled")
raw_body = await request.read()
signature = request.headers.get("crypto-pay-api-signature", "")
if not self._validate_webhook_signature(raw_body, signature):
return web.Response(status=401)
return await self.client.get_updates(request)
async def cryptopay_webhook_route(request: web.Request) -> web.Response:
service: CryptoPayService = request.app["cryptopay_service"]
return await service.webhook_route(request)
router = Router(name="user_subscription_payments_crypto_router")
@router.callback_query(F.data.startswith("pay_crypto:"))
async def pay_crypto_callback_handler(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
session: AsyncSession,
cryptopay_service: CryptoPayService,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
translator = make_translator(i18n, current_lang)
if not i18n or not callback.message:
await notify_callback_parse_error(callback, translator)
return
if not SPEC.is_available_to_user(
settings,
user_id=callback.from_user.id,
require_configured=False,
):
await notify_service_unavailable(callback, translator)
return
if not cryptopay_service or not getattr(cryptopay_service, "configured", False):
await notify_service_unavailable(callback, translator)
return
parts = parse_payment_callback(callback.data or "")
if not parts:
await notify_callback_parse_error(callback, translator)
return
parts, hwid_quote = await quote_hwid_callback_parts(
session=session,
user_id=callback.from_user.id,
parts=parts,
subscription_service=cryptopay_service.subscription_service,
currency="rub",
)
if not parts:
await notify_callback_parse_error(callback, translator)
return
payment_description = describe_payment(translator, parts)
invoice_url = await cryptopay_service.create_invoice(
session=session,
user_id=callback.from_user.id,
months=parts.months,
amount=parts.price,
description=payment_description,
sale_mode=parts.sale_mode,
hwid_quote=hwid_quote,
)
if invoice_url:
await render_payment_link(
callback,
translator=translator,
current_lang=current_lang,
i18n=i18n,
parts=parts,
payment_url=invoice_url,
log_prefix=_LOG,
)
return
from .shared import safe_callback_answer
await safe_callback_answer(callback, translator("error_payment_gateway"), show_alert=True)
def create_service(ctx: ServiceFactoryContext) -> CryptoPayService:
bundle = ctx.config_for("cryptopay_service")
config = (
bundle.config
if bundle and isinstance(bundle.config, CryptoPayConfig)
else CryptoPayConfig()
)
return CryptoPayService(
bot=ctx.bot,
settings=ctx.settings,
config=config,
i18n=ctx.i18n,
async_session_factory=ctx.async_session_factory,
subscription_service=ctx.subscription_service,
referral_service=ctx.referral_service,
)
async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
service: CryptoPayService = ctx.request.app["cryptopay_service"]
if not service or not service.configured:
return payment_unavailable()
url = await service.create_invoice(
session=ctx.session,
user_id=ctx.user_id,
months=ctx.months,
amount=ctx.price,
description=ctx.description,
sale_mode=ctx.sale_mode,
url_kind="web",
hwid_quote={
"valid_from": ctx.hwid_valid_from,
"valid_until": ctx.hwid_valid_until,
"pricing_period_months": ctx.hwid_pricing_period_months,
"proration_ratio": ctx.hwid_proration_ratio,
"full_price": ctx.hwid_full_price,
}
if ctx.hwid_valid_from and ctx.hwid_valid_until
else None,
)
if not url:
return payment_failed()
return payment_link_response(payment_url=url, payment_id=None)
_PRESENTATION_MANIFEST = tuple(
ProviderManifestField(
key=key,
type=type_,
label=label,
description=description,
placeholder=placeholder,
subsection="CryptoPay",
target="presentation",
attr=attr,
)
for key, type_, label, description, placeholder, attr in (
(
"PAYMENT_CRYPTOPAY_WEBAPP_LABEL_RU",
"string",
"WebApp button text (RU)",
"Custom Russian text shown in the Web App payment method button.",
"",
"WEBAPP_LABEL_RU",
),
(
"PAYMENT_CRYPTOPAY_WEBAPP_LABEL_EN",
"string",
"WebApp button text (EN)",
"Custom English text shown in the Web App payment method button.",
"",
"WEBAPP_LABEL_EN",
),
(
"PAYMENT_CRYPTOPAY_WEBAPP_ICON",
"icon",
"WebApp button icon",
"Lucide icon name rendered inside the Web App payment method button.",
"Bitcoin",
"WEBAPP_ICON",
),
(
"PAYMENT_CRYPTOPAY_TELEGRAM_LABEL_RU",
"string",
"Telegram button text (RU)",
"Custom Russian text shown in Telegram bot payment buttons.",
"",
"TELEGRAM_LABEL_RU",
),
(
"PAYMENT_CRYPTOPAY_TELEGRAM_LABEL_EN",
"string",
"Telegram button text (EN)",
"Custom English text shown in Telegram bot payment buttons.",
"",
"TELEGRAM_LABEL_EN",
),
(
"PAYMENT_CRYPTOPAY_TELEGRAM_EMOJI",
"string",
"Telegram button emoji",
"Emoji prepended to the Telegram bot payment button when customized.",
"",
"TELEGRAM_EMOJI",
),
)
)
_CONFIG_MANIFEST = (
ProviderManifestField(
"CRYPTOPAY_ENABLED", "bool", "Включена", subsection="CryptoPay", attr="ENABLED"
),
ProviderManifestField(
"CRYPTOPAY_TOKEN", "string", "Token", subsection="CryptoPay", secret=True, attr="TOKEN"
),
ProviderManifestField(
"CRYPTOPAY_NETWORK",
"string",
"Network",
placeholder="mainnet",
subsection="CryptoPay",
attr="NETWORK",
),
ProviderManifestField(
"CRYPTOPAY_CURRENCY_TYPE",
"string",
"Currency type",
description="fiat or crypto.",
placeholder="fiat",
subsection="CryptoPay",
attr="CURRENCY_TYPE",
),
ProviderManifestField(
"CRYPTOPAY_ASSET",
"string",
"Asset",
placeholder="RUB",
subsection="CryptoPay",
attr="ASSET",
),
)
SPEC = PaymentProviderSpec(
id="cryptopay",
provider_key="cryptopay",
label="CryptoPay",
webapp_label="CryptoPay",
webapp_labels={"ru": "CryptoPay", "en": "CryptoPay"},
webapp_icon="Bitcoin",
telegram_labels={"ru": "CryptoBot", "en": "CryptoBot"},
pending_status="pending_cryptopay",
enabled=lambda config: bool(getattr(config, "ENABLED", False)),
service_key="cryptopay_service",
callback_prefix="pay_crypto",
router=router,
create_service=create_service,
webhook_path=lambda source: "/webhook/cryptopay",
webhook_route=cryptopay_webhook_route,
create_webapp_payment=create_webapp_payment,
emoji="",
telegram_emoji="",
config_class=CryptoPayConfig,
presentation_class=CryptoPayPresentation,
manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST,
)
+765
View File
@@ -0,0 +1,765 @@
import asyncio
import hashlib
import hmac
import json
import logging
import time
from datetime import datetime
from typing import Any, Dict, Optional, Tuple
from urllib.parse import parse_qsl
from aiogram import Bot, F, Router, types
from aiohttp import web
from pydantic import Field, field_validator
from pydantic_settings import SettingsConfigDict
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import sessionmaker
from bot.middlewares.i18n import JsonI18n
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 db.dal import payment_dal
from .base import (
PaymentProviderSpec,
ProviderEnvConfig,
ProviderManifestField,
ServiceFactoryContext,
WebAppPaymentContext,
provider_env_file,
provider_runtime_enabled,
)
from .shared import (
HttpClientMixin,
PaymentSuccessRequest,
build_payment_record_payload,
create_webapp_payment_record,
decimal_amounts_equal,
describe_payment,
finalize_successful_payment,
finalize_webapp_link_payment,
first_value,
format_decimal_amount,
make_translator,
notify_callback_parse_error,
notify_payment_record_failure,
notify_service_unavailable,
parse_payment_callback,
payment_failed,
payment_unavailable,
payment_units_for_activation,
post_json_request,
quote_hwid_callback_parts,
render_link_or_fail,
)
_LOG = "freekassa"
class FreeKassaConfig(ProviderEnvConfig):
model_config = SettingsConfigDict(
env_file=provider_env_file(),
env_file_encoding="utf-8",
env_prefix="FREEKASSA_",
extra="ignore",
)
ENABLED: bool = Field(default=False)
MERCHANT_ID: Optional[str] = None
FIRST_SECRET: Optional[str] = None
SECOND_SECRET: Optional[str] = None
PAYMENT_URL: str = Field(default="https://pay.freekassa.ru/")
API_KEY: Optional[str] = None
PAYMENT_IP: Optional[str] = None
PAYMENT_METHOD_ID: Optional[int] = None
TRUSTED_IPS: str = Field(default="168.119.157.136,168.119.60.227,178.154.197.79,51.250.54.238")
@field_validator("PAYMENT_METHOD_ID", mode="before")
@classmethod
def _empty_to_none_int(cls, v):
if isinstance(v, str):
v = v.strip()
if not v:
return None
return v
@field_validator(
"MERCHANT_ID",
"FIRST_SECRET",
"SECOND_SECRET",
"API_KEY",
"PAYMENT_IP",
mode="before",
)
@classmethod
def _strip_optional(cls, v):
if isinstance(v, str) and not v.strip():
return None
return v
@property
def webhook_path(self) -> str:
return "/webhook/freekassa"
@property
def trusted_ips_list(self) -> list:
return [item.strip() for item in (self.TRUSTED_IPS or "").split(",") if item.strip()]
class FreeKassaPresentation(ProviderEnvConfig):
model_config = SettingsConfigDict(
env_file=provider_env_file(),
env_file_encoding="utf-8",
env_prefix="PAYMENT_FREEKASSA_",
extra="ignore",
)
WEBAPP_LABEL_RU: Optional[str] = None
WEBAPP_LABEL_EN: Optional[str] = None
WEBAPP_ICON: Optional[str] = None
TELEGRAM_LABEL_RU: Optional[str] = None
TELEGRAM_LABEL_EN: Optional[str] = None
TELEGRAM_EMOJI: Optional[str] = None
class FreeKassaService(HttpClientMixin):
def __init__(
self,
*,
bot: Bot,
settings: Settings,
config: FreeKassaConfig,
i18n: JsonI18n,
async_session_factory: sessionmaker,
subscription_service: SubscriptionService,
referral_service: ReferralService,
):
self.bot = bot
self.settings = settings
self.config = config
self.i18n = i18n
self.async_session_factory = async_session_factory
self.subscription_service = subscription_service
self.referral_service = referral_service
self.default_currency: str = (settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
self.api_base_url: str = "https://api.fk.life/v1"
self._init_http_client(total_timeout=15)
self._nonce_lock = asyncio.Lock()
self._last_nonce = int(time.time() * 1000)
if not self.configured:
logging.warning(
"FreeKassaService initialized but not fully configured. Payments disabled."
)
if provider_runtime_enabled(config) and not self.server_ip:
logging.warning(
"FreeKassaService: FREEKASSA_PAYMENT_IP is not set. Requests may be rejected by the provider." # noqa: E501
)
@property
def configured(self) -> bool:
return bool(provider_runtime_enabled(self.config) and self.shop_id and self.api_key)
@property
def shop_id(self):
return self.config.MERCHANT_ID
@property
def api_key(self):
return self.config.API_KEY
@property
def second_secret(self):
return self.config.SECOND_SECRET
@property
def server_ip(self):
return self.config.PAYMENT_IP
@property
def payment_method_id(self):
return self.config.PAYMENT_METHOD_ID
async def create_order(
self,
*,
payment_db_id: int,
user_id: int,
months: int,
amount: float,
currency: Optional[str],
email: Optional[str] = None,
ip_address: Optional[str] = None,
payment_method_id: Optional[int] = None,
extra_params: Optional[Dict[str, Any]] = None,
) -> Tuple[bool, Dict[str, Any]]:
if not self.configured:
logging.error("FreeKassaService is not configured. Cannot create order.")
return False, {"message": "service_not_configured"}
ip_address = ip_address or self.server_ip
if not ip_address:
logging.error("FreeKassaService: payment IP is required but not configured.")
return False, {"message": "missing_ip"}
email = email or f"{user_id}@telegram.org"
currency_code = (currency or self.default_currency or "RUB").upper()
payload: Dict[str, Any] = {
"shopId": int(self.shop_id),
"nonce": await self._generate_nonce(),
"paymentId": str(payment_db_id),
"i": int(payment_method_id),
"amount": f"{format_decimal_amount(amount):.2f}",
"currency": currency_code,
"email": email,
"ip": ip_address,
"us_user_id": str(user_id),
"us_months": str(months),
"us_payment_db_id": str(payment_db_id),
}
if extra_params:
for key, value in extra_params.items():
if value is None:
continue
payload[key] = value
payload["signature"] = self._sign_payload(payload)
session = await self._get_session()
return await post_json_request(
session,
f"{self.api_base_url}/orders/create",
body=payload,
log_prefix="FreeKassa create_order",
# FreeKassa returns ``{"type": "success", ...}`` on success.
is_success=lambda status, data: status == 200 and (data or {}).get("type") == "success",
)
async def _generate_nonce(self) -> int:
async with self._nonce_lock:
candidate = int(time.time() * 1000)
if candidate <= self._last_nonce:
candidate = self._last_nonce + 1
self._last_nonce = candidate
return candidate
def _sign_payload(self, payload: Dict[str, Any]) -> str:
if not self.api_key:
raise RuntimeError("FreeKassa API key is not configured.")
items = [
(key, value)
for key, value in payload.items()
if key != "signature" and value is not None
]
items.sort(key=lambda pair: pair[0])
message = "|".join(str(value) for _, value in items)
return hmac.new(
self.api_key.encode("utf-8"), message.encode("utf-8"), hashlib.sha256
).hexdigest()
def _validate_signature(self, raw_body: bytes, provided_signature: str) -> bool:
if not provided_signature or not self.second_secret:
return False
expected_signature = hmac.new(
self.second_secret.encode("utf-8"),
raw_body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected_signature, provided_signature)
async def webhook_route(self, request: web.Request) -> web.Response:
if not self.configured:
return web.Response(status=503, text="freekassa_disabled")
try:
client_ip = request_client_ip(request, trusted_proxies=self.settings.trusted_proxies)
trusted = self.config.trusted_ips_list
if not ip_in_allowlist(client_ip, trusted):
logging.warning(
"FreeKassa webhook denied from unauthorized IP source "
"(client_ip=%s remote=%s x_forwarded_for=%s trusted_ips=%s "
"trusted_proxies=%s).",
client_ip,
request.remote,
request.headers.get("X-Forwarded-For"),
trusted,
self.settings.trusted_proxies,
)
return web.Response(status=403)
raw_body = await request.read()
except Exception:
logging.exception("FreeKassa webhook: failed to read request body.")
return web.Response(status=400, text="bad_request")
payload_dict: Dict[str, Any] = {}
if raw_body:
try:
if request.content_type.startswith("application/json"):
decoded_json = json.loads(raw_body.decode("utf-8"))
if isinstance(decoded_json, dict):
payload_dict = {str(k): v for k, v in decoded_json.items()}
else:
payload_dict = {
str(key): value
for key, value in parse_qsl(
raw_body.decode("utf-8"), keep_blank_values=True
)
}
except Exception:
payload_dict = {}
def _get(key: str, default: Optional[str] = None) -> Optional[str]:
return payload_dict.get(key) or payload_dict.get(key.lower()) or default
merchant_id = _get("MERCHANT_ID")
if merchant_id != self.shop_id:
return web.Response(status=403)
signature = _get("SIGN") or _get("signature")
if not signature:
return web.Response(status=400, text="missing_signature")
order_id_str = _get("MERCHANT_ORDER_ID") or _get("ORDER_ID") or _get("o")
amount_str = _get("AMOUNT") or _get("OA") or _get("amount")
provider_payment_id = _get("intid") or _get("payment_id") or _get("transaction_id")
if not order_id_str or not amount_str:
return web.Response(status=400, text="missing_data")
if not self._validate_signature(raw_body, signature):
return web.Response(status=403, text="invalid_signature")
try:
payment_db_id = int(order_id_str)
except (TypeError, ValueError):
logging.error("FreeKassa webhook: invalid order_id value %r", order_id_str)
return web.Response(status=400, text="invalid_order_id")
async with self.async_session_factory() as session:
payment = await payment_dal.get_payment_by_db_id(session, payment_db_id)
if not payment:
logging.error("FreeKassa webhook: payment %s not found", payment_db_id)
return web.Response(status=404, text="payment_not_found")
if payment.status == "succeeded":
logging.info("FreeKassa webhook: payment %s already succeeded", payment_db_id)
return web.Response(text="YES")
try:
if not decimal_amounts_equal(amount_str, payment.amount):
logging.warning(
"FreeKassa webhook: amount mismatch for payment %s (expected %s, got %s)",
payment_db_id,
format_decimal_amount(payment.amount),
format_decimal_amount(amount_str),
)
except Exception as exc:
logging.warning(
"FreeKassa webhook: failed to compare amount for payment %s: %s",
payment_db_id,
exc,
)
resolved_provider_id = str(provider_payment_id or f"freekassa:{order_id_str}")
try:
await payment_dal.update_provider_payment_and_status(
session=session,
payment_db_id=payment.payment_id,
provider_payment_id=resolved_provider_id,
new_status="succeeded",
)
await session.commit()
except Exception:
await session.rollback()
logging.exception(
"FreeKassa webhook: failed to mark payment %s as succeeded.", payment_db_id
)
return web.Response(status=500, text="processing_error")
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:
# FreeKassa-specific: prepend "Order N from YYYY-MM-DD" to the success text.
# ``i18n`` is resolved inside ``finalize_successful_payment`` per user language,
# so the prefix is built in the admin/default language too — kept here for parity
# with the original behavior, which used the same key.
admin_lang = self.settings.DEFAULT_LANGUAGE
translator = make_translator(self.i18n, admin_lang)
success_prefix = translator(
"free_kassa_order_full",
order_id=provider_payment_id,
date=datetime.now().strftime("%Y-%m-%d"),
)
outcome = await finalize_successful_payment(
PaymentSuccessRequest(
bot=self.bot,
settings=self.settings,
i18n=self.i18n,
session=session,
subscription_service=self.subscription_service,
referral_service=self.referral_service,
payment=payment,
user_id=payment.user_id,
amount=float(payment.amount),
currency=self.default_currency,
sale_mode=sale_mode,
months=months,
traffic_amount=float(months),
provider_subscription="freekassa",
provider_notification="freekassa",
db_user=payment.user,
log_prefix="FreeKassa webhook",
text_prefix=success_prefix,
)
)
if outcome is None:
return web.Response(status=500, text="processing_error")
return web.Response(text="YES")
async def freekassa_webhook_route(request: web.Request) -> web.Response:
service: FreeKassaService = request.app["freekassa_service"]
return await service.webhook_route(request)
router = Router(name="user_subscription_payments_freekassa_router")
@router.callback_query(F.data.startswith("pay_fk:"))
async def pay_fk_callback_handler(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
freekassa_service: FreeKassaService,
session: AsyncSession,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
translator = make_translator(i18n, current_lang)
if not i18n or not callback.message:
await notify_callback_parse_error(callback, translator)
return
if not SPEC.is_available_to_user(
settings,
user_id=callback.from_user.id,
require_configured=False,
):
await notify_service_unavailable(callback, translator)
return
if not freekassa_service or not freekassa_service.configured:
logging.error("FreeKassa service is not configured or unavailable.")
await notify_service_unavailable(callback, translator)
return
parts = parse_payment_callback(callback.data or "")
if not parts:
logging.error("Invalid pay_fk data in callback: %s", callback.data)
await notify_callback_parse_error(callback, translator)
return
parts, hwid_quote = await quote_hwid_callback_parts(
session=session,
user_id=callback.from_user.id,
parts=parts,
subscription_service=freekassa_service.subscription_service,
currency="rub",
)
if not parts:
await notify_callback_parse_error(callback, translator)
return
currency_code = (
getattr(freekassa_service, "default_currency", None)
or settings.DEFAULT_CURRENCY_SYMBOL
or "RUB"
)
payment_description = describe_payment(translator, parts)
record_payload = build_payment_record_payload(
user_id=callback.from_user.id,
amount=parts.price,
currency=currency_code,
status="pending_freekassa",
description=payment_description,
months=parts.months,
provider="freekassa",
sale_mode=parts.sale_mode,
hwid_quote=hwid_quote,
)
try:
payment_record = await payment_dal.create_payment_record(session, record_payload)
await session.commit()
except Exception:
await session.rollback()
logging.exception(
"FreeKassa: failed to create payment record for user %s.", callback.from_user.id
)
await notify_payment_record_failure(callback, translator)
return
success, response_data = await freekassa_service.create_order(
payment_db_id=payment_record.payment_id,
user_id=payment_record.user_id,
months=parts.months,
amount=parts.price,
currency=freekassa_service.default_currency,
payment_method_id=freekassa_service.payment_method_id,
ip_address=freekassa_service.server_ip,
extra_params={
"us_method": freekassa_service.payment_method_id,
},
)
location = first_value(response_data, "location")
provider_identifier = first_value(response_data, "orderHash", "orderId")
lead_text: Optional[str] = None
if success and location:
order_id_display = (
first_value(response_data, "orderId")
or provider_identifier
or str(payment_record.payment_id)
)
lead_text = translator(
"free_kassa_order_info",
order_id=order_id_display,
date=datetime.now().strftime("%Y-%m-%d"),
)
await render_link_or_fail(
callback,
translator=translator,
current_lang=current_lang,
i18n=i18n,
parts=parts,
session=session,
payment=payment_record,
api_success=success,
payment_url=location,
provider_payment_id=provider_identifier,
lead_text=lead_text,
log_prefix=_LOG,
)
def create_service(ctx: ServiceFactoryContext) -> FreeKassaService:
bundle = ctx.config_for("freekassa_service")
config = (
bundle.config
if bundle and isinstance(bundle.config, FreeKassaConfig)
else FreeKassaConfig()
)
return FreeKassaService(
bot=ctx.bot,
settings=ctx.settings,
config=config,
i18n=ctx.i18n,
async_session_factory=ctx.async_session_factory,
subscription_service=ctx.subscription_service,
referral_service=ctx.referral_service,
)
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()
try:
payment = await create_webapp_payment_record(
ctx,
amount=ctx.price,
currency=service.default_currency,
status="pending_freekassa",
provider="freekassa",
)
success, response_data = await service.create_order(
payment_db_id=payment.payment_id,
user_id=ctx.user_id,
months=ctx.months,
amount=ctx.price,
currency=service.default_currency,
payment_method_id=service.payment_method_id,
ip_address=service.server_ip,
extra_params={"us_method": service.payment_method_id},
)
except Exception:
await ctx.session.rollback()
logging.exception("FreeKassa WebApp payment failed")
return payment_failed()
return await finalize_webapp_link_payment(
session=ctx.session,
payment=payment,
api_success=success,
payment_url=first_value(response_data, "location") if success else None,
provider_payment_id=first_value(response_data, "orderHash", "orderId"),
log_prefix="FreeKassa",
)
_PRESENTATION_MANIFEST = tuple(
ProviderManifestField(
key=key,
type=type_,
label=label,
description=description,
placeholder=placeholder,
subsection="FreeKassa",
target="presentation",
attr=attr,
)
for key, type_, label, description, placeholder, attr in (
(
"PAYMENT_FREEKASSA_WEBAPP_LABEL_RU",
"string",
"WebApp button text (RU)",
"Custom Russian text shown in the Web App payment method button.",
"",
"WEBAPP_LABEL_RU",
),
(
"PAYMENT_FREEKASSA_WEBAPP_LABEL_EN",
"string",
"WebApp button text (EN)",
"Custom English text shown in the Web App payment method button.",
"",
"WEBAPP_LABEL_EN",
),
(
"PAYMENT_FREEKASSA_WEBAPP_ICON",
"icon",
"WebApp button icon",
"Lucide icon name rendered inside the Web App payment method button.",
"Smartphone",
"WEBAPP_ICON",
),
(
"PAYMENT_FREEKASSA_TELEGRAM_LABEL_RU",
"string",
"Telegram button text (RU)",
"Custom Russian text shown in Telegram bot payment buttons.",
"",
"TELEGRAM_LABEL_RU",
),
(
"PAYMENT_FREEKASSA_TELEGRAM_LABEL_EN",
"string",
"Telegram button text (EN)",
"Custom English text shown in Telegram bot payment buttons.",
"",
"TELEGRAM_LABEL_EN",
),
(
"PAYMENT_FREEKASSA_TELEGRAM_EMOJI",
"string",
"Telegram button emoji",
"Emoji prepended to the Telegram bot payment button when customized.",
"📱",
"TELEGRAM_EMOJI",
),
)
)
_CONFIG_MANIFEST = (
ProviderManifestField(
"FREEKASSA_ENABLED", "bool", "Включена", subsection="FreeKassa", attr="ENABLED"
),
ProviderManifestField(
"FREEKASSA_MERCHANT_ID", "string", "Merchant ID", subsection="FreeKassa", attr="MERCHANT_ID"
),
ProviderManifestField(
"FREEKASSA_FIRST_SECRET",
"string",
"First secret",
subsection="FreeKassa",
secret=True,
attr="FIRST_SECRET",
),
ProviderManifestField(
"FREEKASSA_SECOND_SECRET",
"string",
"Second secret",
subsection="FreeKassa",
secret=True,
attr="SECOND_SECRET",
),
ProviderManifestField(
"FREEKASSA_API_KEY",
"string",
"API key",
subsection="FreeKassa",
secret=True,
attr="API_KEY",
),
ProviderManifestField(
"FREEKASSA_PAYMENT_URL",
"url",
"Payment URL",
placeholder="https://pay.freekassa.ru/",
subsection="FreeKassa",
attr="PAYMENT_URL",
),
ProviderManifestField(
"FREEKASSA_PAYMENT_METHOD_ID",
"int",
"Payment method ID",
description="See https://merchant.freekassa.net/settings/currencies",
subsection="FreeKassa",
attr="PAYMENT_METHOD_ID",
),
ProviderManifestField(
"FREEKASSA_PAYMENT_IP",
"string",
"Server IP",
description="Public IP address reported to FreeKassa.",
subsection="FreeKassa",
attr="PAYMENT_IP",
),
ProviderManifestField(
"FREEKASSA_TRUSTED_IPS",
"string",
"Trusted IPs",
description="Comma-separated IP addresses accepted for FreeKassa webhooks.",
subsection="FreeKassa",
attr="TRUSTED_IPS",
),
)
SPEC = PaymentProviderSpec(
id="freekassa",
provider_key="freekassa",
label="FreeKassa",
webapp_label="FreeKassa / СБП",
webapp_labels={"ru": "FreeKassa / СБП", "en": "FreeKassa / SBP"},
webapp_icon="Smartphone",
telegram_labels={"ru": "СБП", "en": "SBP"},
telegram_emoji="📱",
pending_status="pending_freekassa",
enabled=lambda config: bool(getattr(config, "ENABLED", False)),
service_key="freekassa_service",
callback_prefix="pay_fk",
router=router,
create_service=create_service,
webhook_path=lambda source: "/webhook/freekassa",
webhook_route=freekassa_webhook_route,
create_webapp_payment=create_webapp_payment,
config_class=FreeKassaConfig,
presentation_class=FreeKassaPresentation,
manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST,
)
+862
View File
@@ -0,0 +1,862 @@
import base64
import hashlib
import hmac
import json
import logging
from collections import OrderedDict
from typing import Any, Dict, List, Optional, Tuple
from aiogram import Bot, F, Router, types
from aiohttp import web
from pydantic import Field, field_validator
from pydantic_settings import SettingsConfigDict
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import sessionmaker
from bot.middlewares.i18n import JsonI18n
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 db.dal import payment_dal
from .base import (
PaymentProviderSpec,
ProviderEnvConfig,
ProviderManifestField,
ServiceFactoryContext,
WebAppPaymentContext,
provider_env_file,
provider_runtime_enabled,
)
from .shared import (
HttpClientMixin,
PaymentSuccessRequest,
build_payment_record_payload,
create_webapp_payment_record,
decimal_amounts_equal,
describe_payment,
finalize_successful_payment,
finalize_webapp_link_payment,
first_value,
format_decimal_amount,
lookup_payment_by_order_or_provider_id,
make_translator,
notify_callback_parse_error,
notify_payment_record_failure,
notify_service_unavailable,
notify_user_payment_failed,
parse_payment_callback,
payment_failed,
payment_unavailable,
payment_units_for_activation,
quote_hwid_callback_parts,
render_link_or_fail,
)
router = Router(name="user_subscription_payments_heleket_router")
_LOG = "heleket"
_SUCCESS_STATUSES = {"paid", "paid_over"}
_FAILED_STATUSES = {"fail", "wrong_amount", "cancel", "system_fail"}
class HeleketConfig(ProviderEnvConfig):
"""All Heleket-specific env vars. Lives inside the provider module."""
model_config = SettingsConfigDict(
env_file=provider_env_file(),
env_file_encoding="utf-8",
env_prefix="HELEKET_",
extra="ignore",
)
ENABLED: bool = Field(default=False)
MERCHANT_ID: Optional[str] = None
API_KEY: Optional[str] = None
BASE_URL: str = Field(default="https://api.heleket.com")
CURRENCY: str = Field(default="RUB")
TO_CURRENCY: Optional[str] = None
NETWORK: Optional[str] = None
RETURN_URL: Optional[str] = None
SUCCESS_URL: Optional[str] = None
LIFETIME_SECONDS: int = Field(default=3600)
VERIFY_WEBHOOK_SIGNATURE: bool = Field(default=True)
TRUSTED_IPS: str = Field(default="31.133.220.8")
@field_validator("LIFETIME_SECONDS", mode="before")
@classmethod
def _clamp_lifetime(cls, v):
if isinstance(v, str):
v = v.strip()
try:
value = int(v)
except (TypeError, ValueError):
return 3600
return min(43200, max(300, value))
@field_validator(
"MERCHANT_ID",
"API_KEY",
"TO_CURRENCY",
"NETWORK",
"RETURN_URL",
"SUCCESS_URL",
mode="before",
)
@classmethod
def _strip_optional(cls, v):
if isinstance(v, str) and not v.strip():
return None
return v
@property
def webhook_path(self) -> str:
return "/webhook/heleket"
def full_webhook_url(self, base: Optional[str]) -> Optional[str]:
if not base:
return None
return f"{base.rstrip('/')}{self.webhook_path}"
@property
def trusted_ips_list(self) -> List[str]:
return [item.strip() for item in (self.TRUSTED_IPS or "").split(",") if item.strip()]
class HeleketPresentation(ProviderEnvConfig):
"""Admin-tunable button text/icon overrides for Heleket."""
model_config = SettingsConfigDict(
env_file=provider_env_file(),
env_file_encoding="utf-8",
env_prefix="PAYMENT_HELEKET_",
extra="ignore",
)
WEBAPP_LABEL_RU: Optional[str] = None
WEBAPP_LABEL_EN: Optional[str] = None
WEBAPP_ICON: Optional[str] = None
TELEGRAM_LABEL_RU: Optional[str] = None
TELEGRAM_LABEL_EN: Optional[str] = None
TELEGRAM_EMOJI: Optional[str] = None
def _serialize_for_signature(
payload: Dict[str, Any],
*,
ensure_ascii: bool = False,
escape_slashes: bool = True,
) -> str:
"""Serialize JSON exactly the way Heleket signs it.
Heleket's PHP example uses ``json_encode`` with ``JSON_UNESCAPED_UNICODE``
and default forward-slash escaping. We replicate both: ``ensure_ascii=False``
keeps unicode untouched, then we manually escape ``/`` so that the base64
payload matches the one signed on the Heleket side.
"""
encoded = json.dumps(payload, ensure_ascii=ensure_ascii, separators=(",", ":"))
return encoded.replace("/", "\\/") if escape_slashes else encoded
def _compute_signature(
payload: Dict[str, Any],
api_key: str,
*,
ensure_ascii: bool = False,
escape_slashes: bool = True,
) -> str:
body = _serialize_for_signature(
payload,
ensure_ascii=ensure_ascii,
escape_slashes=escape_slashes,
).encode("utf-8")
b64 = base64.b64encode(body).decode("ascii")
return hashlib.md5((b64 + str(api_key or "")).encode("utf-8")).hexdigest()
def _signature_candidates(payload: Dict[str, Any], api_key: str) -> List[Tuple[str, str, str]]:
variants = (
("php_unicode_slash", False, True),
("unicode_no_slash", False, False),
("ascii_slash", True, True),
("ascii_no_slash", True, False),
)
candidates: List[Tuple[str, str, str]] = []
seen: set[str] = set()
for name, ensure_ascii, escape_slashes in variants:
signature = _compute_signature(
payload,
api_key,
ensure_ascii=ensure_ascii,
escape_slashes=escape_slashes,
)
if signature in seen:
continue
seen.add(signature)
canonical = _serialize_for_signature(
payload,
ensure_ascii=ensure_ascii,
escape_slashes=escape_slashes,
)
candidates.append((name, signature, canonical))
return candidates
def _signature_preview(signature: str) -> str:
signature = str(signature or "")
if len(signature) <= 12:
return signature
return f"{signature[:6]}...{signature[-6:]}"
class HeleketService(HttpClientMixin):
def __init__(
self,
*,
bot: Bot,
settings: Settings,
config: HeleketConfig,
i18n: JsonI18n,
async_session_factory: sessionmaker,
subscription_service: SubscriptionService,
referral_service: ReferralService,
default_return_url: str,
):
self.bot = bot
self.settings = settings
self.config = config
self.i18n = i18n
self.async_session_factory = async_session_factory
self.subscription_service = subscription_service
self.referral_service = referral_service
self._default_return_url = default_return_url
self._init_http_client(total_timeout=20)
if not self.configured:
logging.warning(
"HeleketService initialized but not fully configured. Payments disabled."
)
# All of the following are properties on top of the live ``self.config``
# so admin UI changes (which mutate the config bundle) take effect without
# restarting the bot — otherwise ``configured`` would be frozen to the
# ``False`` state from startup and the button would never appear.
@property
def configured(self) -> bool:
return bool(provider_runtime_enabled(self.config) and self.merchant_id and self.api_key)
@property
def base_url(self) -> str:
return (self.config.BASE_URL or "https://api.heleket.com").rstrip("/")
@property
def merchant_id(self) -> str:
return (self.config.MERCHANT_ID or "").strip()
@property
def api_key(self) -> str:
return (self.config.API_KEY or "").strip()
@property
def currency(self) -> str:
return (self.config.CURRENCY or "RUB").upper()
@property
def to_currency(self):
return (self.config.TO_CURRENCY or "").strip() or None
@property
def network(self):
return (self.config.NETWORK or "").strip() or None
@property
def return_url(self) -> str:
return self.config.RETURN_URL or f"https://t.me/{self._default_return_url}"
@property
def success_url(self) -> str:
return self.config.SUCCESS_URL or self.return_url
@property
def lifetime_seconds(self) -> int:
return self.config.LIFETIME_SECONDS
@property
def verify_webhook_signature(self) -> bool:
return self.config.VERIFY_WEBHOOK_SIGNATURE
async def create_payment_link(
self,
*,
payment_db_id: int,
amount: float,
currency: Optional[str],
description: str,
url_callback: Optional[str],
) -> Tuple[bool, Dict[str, Any]]:
if not self.configured:
logging.error("HeleketService is not configured. Cannot create payment link.")
return False, {"message": "service_not_configured"}
body: Dict[str, Any] = {
"amount": str(format_decimal_amount(amount)),
"currency": (currency or self.currency).upper(),
"order_id": str(payment_db_id),
"url_return": self.return_url,
"url_success": self.success_url,
"lifetime": int(self.lifetime_seconds),
}
if description:
body["additional_data"] = description[:255]
if self.to_currency:
body["to_currency"] = self.to_currency
if self.network:
body["network"] = self.network
if url_callback:
body["url_callback"] = url_callback
signed_body = _serialize_for_signature(body).encode("utf-8")
headers = {
"merchant": self.merchant_id,
"sign": _compute_signature(body, self.api_key),
"Content-Type": "application/json",
}
session = await self._get_session()
try:
async with session.post(
f"{self.base_url}/v1/payment",
data=signed_body,
headers=headers,
) as response:
response_text = await response.text()
try:
response_data = json.loads(response_text) if response_text else {}
except json.JSONDecodeError:
logging.error("Heleket create_payment_link: invalid JSON: %s", response_text)
return False, {
"status": response.status,
"message": "invalid_json",
"raw": response_text,
}
state = response_data.get("state") if isinstance(response_data, dict) else None
if response.status != 200 or state != 0:
logging.error(
"Heleket create_payment_link: API error (status=%s, body=%s)",
response.status,
response_data,
)
return False, {"status": response.status, "message": response_data}
return True, response_data
except Exception as exc:
logging.exception("Heleket create_payment_link: request failed.")
return False, {"message": str(exc)}
def _verify_signature(self, payload: Dict[str, Any]) -> bool:
received = payload.get("sign")
if not isinstance(received, str) or not received:
return False
data = OrderedDict((k, v) for k, v in payload.items() if k != "sign")
candidates = _signature_candidates(data, self.api_key)
for name, expected, _canonical in candidates:
if hmac.compare_digest(expected, received):
if name != "php_unicode_slash":
logging.info("Heleket webhook: signature matched variant %s.", name)
return True
logging.warning(
"Heleket webhook: invalid signature "
"(received=%s expected=%s canonical_json_sha256=%s api_key_len=%s).",
_signature_preview(received),
[_signature_preview(expected) for _name, expected, _canonical in candidates],
[
hashlib.sha256(canonical.encode("utf-8")).hexdigest()
for _name, _expected, canonical in candidates
],
len(self.api_key),
)
return False
async def webhook_route(self, request: web.Request) -> web.Response:
if not self.configured:
return web.Response(status=503, text="heleket_disabled")
client_ip = request_client_ip(request, trusted_proxies=self.settings.trusted_proxies)
trusted = self.config.trusted_ips_list
if trusted and not ip_in_allowlist(client_ip, trusted):
logging.warning(
"Heleket webhook denied from unauthorized IP source "
"(client_ip=%s remote=%s x_forwarded_for=%s trusted_ips=%s trusted_proxies=%s).",
client_ip,
request.remote,
request.headers.get("X-Forwarded-For"),
trusted,
self.settings.trusted_proxies,
)
return web.Response(status=403, text="forbidden")
raw_body = await request.read()
try:
payload = json.loads(raw_body.decode("utf-8"))
except Exception:
logging.exception("Heleket webhook: failed to parse JSON.")
return web.Response(status=400, text="bad_request")
if not isinstance(payload, dict):
return web.Response(status=400, text="bad_request")
if self.verify_webhook_signature and not self._verify_signature(payload):
return web.Response(status=403, text="invalid_signature")
uuid_value = str(payload.get("uuid") or "").strip()
status = str(payload.get("status") or "").strip().lower()
order_id_raw = payload.get("order_id")
amount_raw = payload.get("amount")
currency = payload.get("currency") or self.currency
if not status or not (uuid_value or order_id_raw):
logging.error("Heleket webhook: missing status or ids: %s", payload)
return web.Response(status=400, text="missing_fields")
async with self.async_session_factory() as session:
payment = await lookup_payment_by_order_or_provider_id(
session,
order_id_raw=order_id_raw,
provider_payment_id=uuid_value or None,
)
if not payment:
logging.error(
"Heleket webhook: payment not found (order_id=%s, uuid=%s)",
order_id_raw,
uuid_value,
)
return web.Response(status=404, text="payment_not_found")
if payment.status == "succeeded" and status in _SUCCESS_STATUSES:
return web.Response(text="ok")
resolved_id = uuid_value or str(payment.payment_id)
if status in _SUCCESS_STATUSES:
if amount_raw is not None:
try:
if not decimal_amounts_equal(amount_raw, payment.amount):
logging.warning(
"Heleket webhook: amount mismatch for payment %s "
"(expected %s, got %s)",
payment.payment_id,
format_decimal_amount(payment.amount),
format_decimal_amount(amount_raw),
)
except Exception as exc:
logging.warning(
"Heleket webhook: failed to compare amounts for %s: %s",
payment.payment_id,
exc,
)
try:
await payment_dal.update_provider_payment_and_status(
session,
payment.payment_id,
resolved_id,
"succeeded",
)
await session.commit()
except Exception:
await session.rollback()
logging.exception(
"Heleket webhook: failed to mark payment %s as succeeded.",
resolved_id,
)
return web.Response(status=500, text="processing_error")
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,
settings=self.settings,
i18n=self.i18n,
session=session,
subscription_service=self.subscription_service,
referral_service=self.referral_service,
payment=payment,
user_id=payment.user_id,
amount=float(payment.amount),
currency=str(currency),
sale_mode=sale_mode,
months=payment_units,
traffic_amount=float(payment_units),
provider_subscription="heleket",
provider_notification="heleket",
db_user=payment.user,
log_prefix="Heleket webhook",
)
)
if outcome is None:
return web.Response(status=500, text="processing_error")
return web.Response(text="ok")
if status in _FAILED_STATUSES:
try:
await payment_dal.update_provider_payment_and_status(
session,
payment.payment_id,
resolved_id,
"failed",
)
await session.commit()
except Exception:
await session.rollback()
logging.exception(
"Heleket webhook: failed to mark payment %s as failed.",
resolved_id,
)
return web.Response(status=500, text="processing_error")
await notify_user_payment_failed(
bot=self.bot,
settings=self.settings,
i18n=self.i18n,
session=session,
payment=payment,
)
return web.Response(text="ok")
logging.info(
"Heleket webhook: intermediate status '%s' for payment %s",
status,
resolved_id,
)
return web.Response(status=202, text="status_ignored")
@router.callback_query(F.data.startswith("pay_heleket:"))
async def pay_heleket_callback_handler(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
heleket_service: HeleketService,
session: AsyncSession,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
translator = make_translator(i18n, current_lang)
if not i18n or not callback.message:
await notify_callback_parse_error(callback, translator)
return
if not SPEC.is_available_to_user(
settings,
user_id=callback.from_user.id,
require_configured=False,
):
await notify_service_unavailable(callback, translator)
return
if not heleket_service or not heleket_service.configured:
logging.error("Heleket service is not configured or unavailable.")
await notify_service_unavailable(callback, translator)
return
parts = parse_payment_callback(callback.data or "")
if not parts:
logging.error("Invalid pay_heleket data in callback: %s", callback.data)
await notify_callback_parse_error(callback, translator)
return
parts, hwid_quote = await quote_hwid_callback_parts(
session=session,
user_id=callback.from_user.id,
parts=parts,
subscription_service=heleket_service.subscription_service,
currency="rub",
)
if not parts:
await notify_callback_parse_error(callback, translator)
return
currency_code = (heleket_service.currency or settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
payment_description = describe_payment(translator, parts)
record_payload = build_payment_record_payload(
user_id=callback.from_user.id,
amount=parts.price,
currency=currency_code,
status="pending_heleket",
description=payment_description,
months=parts.months,
provider="heleket",
sale_mode=parts.sale_mode,
hwid_quote=hwid_quote,
)
try:
payment_record = await payment_dal.create_payment_record(session, record_payload)
await session.commit()
except Exception:
await session.rollback()
logging.exception(
"Heleket: failed to create payment record for user %s.", callback.from_user.id
)
await notify_payment_record_failure(callback, translator)
return
success, response_data = await heleket_service.create_payment_link(
payment_db_id=payment_record.payment_id,
amount=parts.price,
currency=currency_code,
description=payment_description,
url_callback=heleket_service.config.full_webhook_url(settings.WEBHOOK_BASE_URL),
)
result = response_data.get("result") if isinstance(response_data, dict) else None
await render_link_or_fail(
callback,
translator=translator,
current_lang=current_lang,
i18n=i18n,
parts=parts,
session=session,
payment=payment_record,
api_success=success,
payment_url=first_value(result, "url"),
provider_payment_id=first_value(result, "uuid"),
log_prefix=_LOG,
)
async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
settings: Settings = ctx.request.app["settings"]
service: HeleketService = ctx.request.app["heleket_service"]
if not service or not service.configured:
return payment_unavailable()
currency = (service.currency or settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
try:
payment = await create_webapp_payment_record(
ctx,
amount=ctx.price,
currency=currency,
status="pending_heleket",
provider="heleket",
)
success, response_data = await service.create_payment_link(
payment_db_id=payment.payment_id,
amount=ctx.price,
currency=currency,
description=ctx.description,
url_callback=service.config.full_webhook_url(settings.WEBHOOK_BASE_URL),
)
except Exception:
await ctx.session.rollback()
logging.exception("Heleket WebApp payment failed")
return payment_failed()
result = response_data.get("result") if isinstance(response_data, dict) else None
return await finalize_webapp_link_payment(
session=ctx.session,
payment=payment,
api_success=success,
payment_url=first_value(result, "url") if success else None,
provider_payment_id=first_value(result, "uuid"),
log_prefix="Heleket",
)
async def heleket_webhook_route(request: web.Request) -> web.Response:
service: HeleketService = request.app["heleket_service"]
return await service.webhook_route(request)
def create_service(ctx: ServiceFactoryContext) -> HeleketService:
bundle = ctx.config_for("heleket_service")
config = (
bundle.config if bundle and isinstance(bundle.config, HeleketConfig) else HeleketConfig()
)
return HeleketService(
bot=ctx.bot,
settings=ctx.settings,
config=config,
i18n=ctx.i18n,
async_session_factory=ctx.async_session_factory,
subscription_service=ctx.subscription_service,
referral_service=ctx.referral_service,
default_return_url=ctx.bot_username_for_default_return,
)
_PRESENTATION_MANIFEST = tuple(
ProviderManifestField(
key=key,
type=type_,
label=label,
description=description,
placeholder=placeholder,
subsection="Heleket",
target="presentation",
attr=attr,
)
for key, type_, label, description, placeholder, attr in (
(
"PAYMENT_HELEKET_WEBAPP_LABEL_RU",
"string",
"WebApp button text (RU)",
"Custom Russian text shown in the Web App payment method button.",
"",
"WEBAPP_LABEL_RU",
),
(
"PAYMENT_HELEKET_WEBAPP_LABEL_EN",
"string",
"WebApp button text (EN)",
"Custom English text shown in the Web App payment method button.",
"",
"WEBAPP_LABEL_EN",
),
(
"PAYMENT_HELEKET_WEBAPP_ICON",
"icon",
"WebApp button icon",
"Lucide icon name rendered inside the Web App payment method button.",
"Bitcoin",
"WEBAPP_ICON",
),
(
"PAYMENT_HELEKET_TELEGRAM_LABEL_RU",
"string",
"Telegram button text (RU)",
"Custom Russian text shown in Telegram bot payment buttons.",
"",
"TELEGRAM_LABEL_RU",
),
(
"PAYMENT_HELEKET_TELEGRAM_LABEL_EN",
"string",
"Telegram button text (EN)",
"Custom English text shown in Telegram bot payment buttons.",
"",
"TELEGRAM_LABEL_EN",
),
(
"PAYMENT_HELEKET_TELEGRAM_EMOJI",
"string",
"Telegram button emoji",
"Emoji prepended to the Telegram bot payment button when customized.",
"🪙",
"TELEGRAM_EMOJI",
),
)
)
_CONFIG_MANIFEST = (
ProviderManifestField(
"HELEKET_ENABLED", "bool", "Enabled", subsection="Heleket", attr="ENABLED"
),
ProviderManifestField(
"HELEKET_MERCHANT_ID",
"string",
"Merchant ID",
subsection="Heleket",
secret=True,
attr="MERCHANT_ID",
),
ProviderManifestField(
"HELEKET_API_KEY",
"string",
"Payment API key",
subsection="Heleket",
secret=True,
attr="API_KEY",
),
ProviderManifestField(
"HELEKET_BASE_URL",
"url",
"Base URL",
placeholder="https://api.heleket.com",
subsection="Heleket",
attr="BASE_URL",
),
ProviderManifestField(
"HELEKET_CURRENCY",
"string",
"Invoice currency",
description="Fiat or crypto code (RUB, USD, USDT).",
placeholder="RUB",
subsection="Heleket",
attr="CURRENCY",
),
ProviderManifestField(
"HELEKET_TO_CURRENCY",
"string",
"Target crypto",
description="Optional target cryptocurrency for conversion.",
subsection="Heleket",
attr="TO_CURRENCY",
),
ProviderManifestField(
"HELEKET_NETWORK",
"string",
"Blockchain network",
description="Optional blockchain network code (tron, bsc, eth).",
subsection="Heleket",
attr="NETWORK",
),
ProviderManifestField(
"HELEKET_RETURN_URL", "url", "Return URL", subsection="Heleket", attr="RETURN_URL"
),
ProviderManifestField(
"HELEKET_SUCCESS_URL", "url", "Success URL", subsection="Heleket", attr="SUCCESS_URL"
),
ProviderManifestField(
"HELEKET_LIFETIME_SECONDS",
"int",
"Invoice lifetime (seconds)",
description="300..43200; Heleket defaults to 3600.",
subsection="Heleket",
min=300,
max=43200,
attr="LIFETIME_SECONDS",
),
ProviderManifestField(
"HELEKET_VERIFY_WEBHOOK_SIGNATURE",
"bool",
"Verify webhook signature",
subsection="Heleket",
attr="VERIFY_WEBHOOK_SIGNATURE",
),
ProviderManifestField(
"HELEKET_TRUSTED_IPS",
"string",
"Trusted IPs",
description="Comma-separated IP addresses accepted for Heleket webhooks.",
subsection="Heleket",
attr="TRUSTED_IPS",
),
)
SPEC = PaymentProviderSpec(
id="heleket",
provider_key="heleket",
label="Heleket",
webapp_label="Heleket",
webapp_labels={"ru": "Heleket", "en": "Heleket"},
webapp_icon="Bitcoin",
telegram_labels={"ru": "Heleket", "en": "Heleket"},
telegram_emoji="🪙",
pending_status="pending_heleket",
enabled=lambda config: bool(getattr(config, "ENABLED", False)),
service_key="heleket_service",
callback_prefix="pay_heleket",
router=router,
create_service=create_service,
webhook_path=lambda source: "/webhook/heleket",
webhook_route=heleket_webhook_route,
create_webapp_payment=create_webapp_payment,
emoji="🪙",
config_class=HeleketConfig,
presentation_class=HeleketPresentation,
manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST,
)
+823
View File
@@ -0,0 +1,823 @@
import hmac
import json
import logging
from typing import Any, Dict, Optional, Tuple
from aiogram import Bot, F, Router, types
from aiohttp import web
from pydantic import Field, field_validator
from pydantic_settings import SettingsConfigDict
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import sessionmaker
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 db.dal import payment_dal
from .base import (
PaymentProviderSpec,
ProviderEnvConfig,
ProviderManifestField,
ServiceFactoryContext,
WebAppPaymentContext,
provider_env_file,
provider_runtime_enabled,
)
from .shared import (
HttpClientMixin,
PaymentSuccessRequest,
build_payment_record_payload,
create_webapp_payment_record,
decimal_amounts_equal,
describe_payment,
finalize_successful_payment,
finalize_webapp_link_payment,
first_value,
format_decimal_amount,
format_number_for_payload,
notify_callback_parse_error,
notify_payment_record_failure,
notify_service_unavailable,
notify_user_payment_failed,
parse_payment_callback,
payment_failed,
payment_record_amounts,
payment_unavailable,
payment_units_for_activation,
post_json_request,
quote_hwid_callback_parts,
render_link_or_fail,
safe_callback_answer,
)
_LOG = "platega"
class PlategaConfig(ProviderEnvConfig):
model_config = SettingsConfigDict(
env_file=provider_env_file(),
env_file_encoding="utf-8",
env_prefix="PLATEGA_",
extra="ignore",
)
ENABLED: bool = Field(default=False)
BASE_URL: str = Field(default="https://app.platega.io")
MERCHANT_ID: Optional[str] = None
SECRET: Optional[str] = None
PAYMENT_METHOD: int = Field(default=2)
SBP_ENABLED: bool = Field(default=False)
SBP_ADMIN_ONLY_ENABLED: bool = Field(default=False)
CRYPTO_ENABLED: bool = Field(default=False)
CRYPTO_ADMIN_ONLY_ENABLED: bool = Field(default=False)
SBP_METHOD: int = Field(default=2)
CRYPTO_METHOD: int = Field(default=13)
RETURN_URL: Optional[str] = None
FAILED_URL: Optional[str] = None
@field_validator("MERCHANT_ID", "SECRET", "RETURN_URL", "FAILED_URL", mode="before")
@classmethod
def _strip_optional(cls, v):
if isinstance(v, str) and not v.strip():
return None
return v
@property
def sbp_method_resolved(self) -> int:
"""Falls back to the legacy ``PAYMENT_METHOD`` for backwards compat."""
if self.SBP_METHOD != 2:
return self.SBP_METHOD
return self.PAYMENT_METHOD or 2
@property
def webhook_path(self) -> str:
return "/webhook/platega"
class PlategaSbpPresentation(ProviderEnvConfig):
model_config = SettingsConfigDict(
env_file=provider_env_file(),
env_file_encoding="utf-8",
env_prefix="PAYMENT_PLATEGA_SBP_",
extra="ignore",
)
WEBAPP_LABEL_RU: Optional[str] = None
WEBAPP_LABEL_EN: Optional[str] = None
WEBAPP_ICON: Optional[str] = None
TELEGRAM_LABEL_RU: Optional[str] = None
TELEGRAM_LABEL_EN: Optional[str] = None
TELEGRAM_EMOJI: Optional[str] = None
class PlategaCryptoPresentation(ProviderEnvConfig):
model_config = SettingsConfigDict(
env_file=provider_env_file(),
env_file_encoding="utf-8",
env_prefix="PAYMENT_PLATEGA_CRYPTO_",
extra="ignore",
)
WEBAPP_LABEL_RU: Optional[str] = None
WEBAPP_LABEL_EN: Optional[str] = None
WEBAPP_ICON: Optional[str] = None
TELEGRAM_LABEL_RU: Optional[str] = None
TELEGRAM_LABEL_EN: Optional[str] = None
TELEGRAM_EMOJI: Optional[str] = None
class PlategaService(HttpClientMixin):
def __init__(
self,
*,
bot: Bot,
settings: Settings,
config: PlategaConfig,
i18n: JsonI18n,
async_session_factory: sessionmaker,
subscription_service: SubscriptionService,
referral_service: ReferralService,
default_return_url: str,
):
self.bot = bot
self.settings = settings
self.config = config
self.i18n = i18n
self.async_session_factory = async_session_factory
self.subscription_service = subscription_service
self.referral_service = referral_service
self._default_return_url = default_return_url
self._init_http_client(total_timeout=20)
if not self.configured:
logging.warning(
"PlategaService initialized but not fully configured. Payments disabled."
)
else:
logging.info(
"PlategaService configured. SBP button: %s (method=%s), Crypto button: %s (method=%s)", # noqa: E501
"ON" if config.SBP_ENABLED else "OFF",
self.sbp_method,
"ON" if config.CRYPTO_ENABLED else "OFF",
self.crypto_method,
)
@property
def configured(self) -> bool:
return bool(
provider_runtime_enabled(
self.config,
"SBP_ADMIN_ONLY_ENABLED",
"CRYPTO_ADMIN_ONLY_ENABLED",
)
and self.merchant_id
and self.secret
)
@property
def base_url(self) -> str:
return (self.config.BASE_URL or "https://app.platega.io").rstrip("/")
@property
def merchant_id(self):
return self.config.MERCHANT_ID
@property
def secret(self):
return self.config.SECRET
@property
def payment_method(self) -> int:
return self.config.PAYMENT_METHOD
@property
def sbp_method(self) -> int:
return self.config.sbp_method_resolved
@property
def crypto_method(self) -> int:
return self.config.CRYPTO_METHOD
@property
def return_url(self) -> str:
return self.config.RETURN_URL or f"https://t.me/{self._default_return_url}"
@property
def failed_url(self) -> str:
return self.config.FAILED_URL or self.return_url
@property
def _auth_headers(self) -> dict:
return {
"X-MerchantId": self.merchant_id or "",
"X-Secret": self.secret or "",
"Content-Type": "application/json",
}
async def create_transaction(
self,
*,
amount: float,
currency: Optional[str],
description: str,
payload: Optional[str] = None,
payment_method: Optional[int] = None,
) -> Tuple[bool, Dict[str, Any]]:
if not self.configured:
logging.error("PlategaService is not configured. Cannot create transaction.")
return False, {"message": "service_not_configured"}
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] = {
"paymentMethod": method_id,
"paymentDetails": {"amount": float(amount), "currency": currency_code},
"description": description,
"return": self.return_url,
"failedUrl": self.failed_url,
"payload": payload,
}
# Remove optional keys with falsy values to avoid validation errors
clean_body = {k: v for k, v in body.items() if v not in (None, "")}
safe_headers = {
"X-MerchantId": self._auth_headers.get("X-MerchantId"),
"X-Secret": "***" if self._auth_headers.get("X-Secret") else "",
"Content-Type": self._auth_headers.get("Content-Type"),
}
logging.info(
"Platega create_transaction request: url=%s headers=%s body=%s",
url,
safe_headers,
clean_body,
)
return await post_json_request(
session,
url,
body=clean_body,
headers=self._auth_headers,
log_prefix="Platega create_transaction",
)
async def webhook_route(self, request: web.Request) -> web.Response:
if not self.configured:
return web.Response(status=503, text="platega_disabled")
try:
data = await request.json()
except Exception:
logging.exception("Platega webhook: failed to parse JSON.")
return web.Response(status=400, text="bad_request")
header_merchant = request.headers.get("X-MerchantId")
header_secret = request.headers.get("X-Secret")
if not (
hmac.compare_digest(str(header_merchant or ""), str(self.merchant_id or ""))
and hmac.compare_digest(str(header_secret or ""), str(self.secret or ""))
):
logging.error("Platega webhook: invalid auth headers")
return web.Response(status=403, text="forbidden")
transaction_id = str(data.get("id") or data.get("transactionId") or "").strip()
status = str(data.get("status") or "").upper()
amount_raw = data.get("amount")
currency = data.get("currency") or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
if not transaction_id or not status:
logging.error("Platega webhook: missing transaction id or status in payload: %s", data)
return web.Response(status=400, text="missing_fields")
async with self.async_session_factory() as session:
payment = await payment_dal.get_payment_by_provider_payment_id(session, transaction_id)
if not payment:
logging.error(
"Platega webhook: payment not found for transaction %s", transaction_id
)
return web.Response(status=404, text="payment_not_found")
if payment.status == "succeeded" and status == "CONFIRMED":
return web.Response(text="ok")
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:
try:
if not decimal_amounts_equal(amount_raw, payment.amount):
logging.warning(
"Platega webhook: amount mismatch for payment %s (expected %s, got %s)", # noqa: E501
payment.payment_id,
format_decimal_amount(payment.amount),
format_decimal_amount(amount_raw),
)
except Exception as exc:
logging.warning(
"Platega webhook: failed to compare amounts for %s: %s",
payment.payment_id,
exc,
)
try:
await payment_dal.update_provider_payment_and_status(
session,
payment.payment_id,
transaction_id,
"succeeded",
)
await session.commit()
except Exception:
await session.rollback()
logging.exception(
"Platega webhook: failed to mark payment %s as succeeded.", transaction_id
)
return web.Response(status=500, text="processing_error")
outcome = await finalize_successful_payment(
PaymentSuccessRequest(
bot=self.bot,
settings=self.settings,
i18n=self.i18n,
session=session,
subscription_service=self.subscription_service,
referral_service=self.referral_service,
payment=payment,
user_id=payment.user_id,
amount=float(payment.amount),
currency=str(currency),
sale_mode=sale_mode,
months=payment_months,
traffic_amount=float(payment_months),
provider_subscription="platega",
provider_notification="platega",
log_prefix="Platega webhook",
)
)
if outcome is None:
return web.Response(status=500, text="processing_error")
return web.Response(text="ok")
if status in {"CANCELED", "CANCELLED", "CHARGEBACKED"}:
try:
await payment_dal.update_provider_payment_and_status(
session,
payment.payment_id,
transaction_id,
"canceled",
)
await session.commit()
except Exception:
await session.rollback()
logging.exception(
"Platega webhook: failed to cancel payment %s.", transaction_id
)
return web.Response(status=500, text="processing_error")
await notify_user_payment_failed(
bot=self.bot,
settings=self.settings,
i18n=self.i18n,
session=session,
payment=payment,
)
return web.Response(text="ok_canceled")
logging.warning(
"Platega webhook: unhandled status '%s' for transaction %s", status, transaction_id
)
return web.Response(status=202, text="status_ignored")
async def platega_webhook_route(request: web.Request) -> web.Response:
service: PlategaService = request.app["platega_service"]
return await service.webhook_route(request)
router = Router(name="user_subscription_payments_platega_router")
def _resolve_platega_variant(
callback_prefix: str, config: PlategaConfig
) -> Optional[Tuple[str, int]]:
"""Map the callback prefix to (variant, payment_method_id) or ``None`` if disabled."""
if callback_prefix == "pay_platega_crypto":
if not (config.CRYPTO_ENABLED or config.CRYPTO_ADMIN_ONLY_ENABLED):
return None
return "crypto", config.CRYPTO_METHOD
if callback_prefix == "pay_platega_sbp":
if not (config.SBP_ENABLED or config.SBP_ADMIN_ONLY_ENABLED):
return None
return "sbp", config.sbp_method_resolved
# Legacy "pay_platega:" callback — keep working as SBP.
return "sbp", config.sbp_method_resolved
def _platega_spec_for_callback_prefix(callback_prefix: str) -> PaymentProviderSpec:
if callback_prefix == "pay_platega_crypto":
return CRYPTO_SPEC
return SBP_SPEC
@router.callback_query(
F.data.startswith("pay_platega_sbp:")
| F.data.startswith("pay_platega_crypto:")
| F.data.startswith("pay_platega:")
)
async def pay_platega_callback_handler(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
platega_service: PlategaService,
session: AsyncSession,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
from .shared import make_translator
translator = make_translator(i18n, current_lang)
if not i18n or not callback.message:
await notify_callback_parse_error(callback, translator)
return
callback_prefix, _, _ = (callback.data or "").partition(":")
spec = _platega_spec_for_callback_prefix(callback_prefix)
if not spec.is_available_to_user(
settings,
user_id=callback.from_user.id,
require_configured=False,
):
await notify_service_unavailable(callback, translator)
return
variant = (
_resolve_platega_variant(callback_prefix, platega_service.config)
if platega_service
else None
)
if variant is None:
await safe_callback_answer(callback)
return
platega_variant, platega_method_id = variant
if not platega_service or not platega_service.configured:
logging.error("Platega service is not configured or unavailable.")
await notify_service_unavailable(callback, translator)
return
parts = parse_payment_callback(callback.data or "")
if not parts:
logging.error("Invalid pay_platega data in callback: %s", callback.data)
await notify_callback_parse_error(callback, translator)
return
parts, hwid_quote = await quote_hwid_callback_parts(
session=session,
user_id=callback.from_user.id,
parts=parts,
subscription_service=platega_service.subscription_service,
currency="rub",
)
if not parts:
await notify_callback_parse_error(callback, translator)
return
currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
payment_description = describe_payment(translator, parts)
record_payload = build_payment_record_payload(
user_id=callback.from_user.id,
amount=parts.price,
currency=currency_code,
status="pending_platega",
description=payment_description,
months=parts.months,
provider="platega",
sale_mode=parts.sale_mode,
hwid_quote=hwid_quote,
)
try:
payment_record = await payment_dal.create_payment_record(session, record_payload)
await session.commit()
except Exception:
await session.rollback()
logging.exception(
"Platega: failed to create payment record for user %s.", callback.from_user.id
)
await notify_payment_record_failure(callback, translator)
return
payload_meta = json.dumps(
{
"payment_db_id": payment_record.payment_id,
"user_id": callback.from_user.id,
"months": parts.months,
"sale_mode": parts.sale_mode,
"platega_variant": platega_variant,
}
)
success, response_data = await platega_service.create_transaction(
amount=parts.price,
currency=currency_code,
description=payment_description,
payload=payload_meta,
payment_method=platega_method_id,
)
transaction_id = first_value(response_data, "transactionId", "id")
redirect_url = first_value(response_data, "redirect", "url", "paymentUrl")
provider_status = str((response_data or {}).get("status") or payment_record.status)
# Platega requires *both* a transaction id and a redirect url to count as a
# usable payment — neither field is sufficient on its own. Skipping the
# persistence step when the redirect is missing matches the pre-refactor
# behavior (we never stored a transaction id without a link).
persistable_id = transaction_id if redirect_url else None
await render_link_or_fail(
callback,
translator=translator,
current_lang=current_lang,
i18n=i18n,
parts=parts,
session=session,
payment=payment_record,
api_success=success,
payment_url=redirect_url,
provider_payment_id=persistable_id,
new_status=provider_status if persistable_id else None,
log_prefix=_LOG,
)
def create_service(ctx: ServiceFactoryContext) -> PlategaService:
bundle = ctx.config_for("platega_service")
config = (
bundle.config if bundle and isinstance(bundle.config, PlategaConfig) else PlategaConfig()
)
return PlategaService(
bot=ctx.bot,
settings=ctx.settings,
config=config,
i18n=ctx.i18n,
async_session_factory=ctx.async_session_factory,
subscription_service=ctx.subscription_service,
referral_service=ctx.referral_service,
default_return_url=ctx.bot_username_for_default_return,
)
async def _create_webapp_payment(ctx: WebAppPaymentContext, variant: str) -> web.Response:
settings: Settings = ctx.request.app["settings"]
service: PlategaService = ctx.request.app["platega_service"]
if not service or not service.configured:
return payment_unavailable()
if variant == "platega_crypto":
if not (service.config.CRYPTO_ENABLED or service.config.CRYPTO_ADMIN_ONLY_ENABLED):
return payment_unavailable()
platega_method_id = service.config.CRYPTO_METHOD
else:
if variant == "platega_sbp" and not (
service.config.SBP_ENABLED or service.config.SBP_ADMIN_ONLY_ENABLED
):
return payment_unavailable()
platega_method_id = service.config.sbp_method_resolved
try:
amounts = payment_record_amounts(
months=ctx.months,
sale_mode=ctx.sale_mode,
traffic_gb=ctx.traffic_gb,
)
payment = await create_webapp_payment_record(
ctx,
amount=ctx.price,
currency=settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
status="pending_platega",
provider="platega",
)
payload = json.dumps(
{
"payment_db_id": payment.payment_id,
"user_id": ctx.user_id,
"months": amounts.months if not amounts.traffic_sale else 0,
"sale_mode": ctx.sale_mode,
"traffic_gb": format_number_for_payload(ctx.traffic_gb or ctx.months)
if amounts.traffic_sale
else None,
"hwid_devices": amounts.purchased_hwid_devices,
"source": "webapp",
"platega_variant": "crypto" if variant == "platega_crypto" else "sbp",
}
)
success, response_data = await service.create_transaction(
amount=ctx.price,
currency=settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
description=ctx.description,
payload=payload,
payment_method=platega_method_id,
)
except Exception:
await ctx.session.rollback()
logging.exception("Platega WebApp payment failed")
return payment_failed()
return await finalize_webapp_link_payment(
session=ctx.session,
payment=payment,
api_success=success,
payment_url=(
first_value(response_data, "redirect", "url", "paymentUrl") if success else None
),
provider_payment_id=first_value(response_data, "transactionId", "id"),
new_status=str((response_data or {}).get("status") or payment.status),
log_prefix="Platega",
)
async def create_sbp_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
return await _create_webapp_payment(ctx, "platega_sbp")
async def create_crypto_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
return await _create_webapp_payment(ctx, "platega_crypto")
def _platega_presentation_manifest(subsection: str, default_icon: str, prefix: str) -> tuple:
return tuple(
ProviderManifestField(
key=f"PAYMENT_{prefix}_{suffix_key}",
type=type_,
label=label,
description=description,
placeholder=placeholder,
subsection=subsection,
target="presentation",
attr=attr,
)
for suffix_key, type_, label, description, placeholder, attr in (
(
"WEBAPP_LABEL_RU",
"string",
"WebApp button text (RU)",
"Custom Russian text shown in the Web App payment method button.",
"",
"WEBAPP_LABEL_RU",
),
(
"WEBAPP_LABEL_EN",
"string",
"WebApp button text (EN)",
"Custom English text shown in the Web App payment method button.",
"",
"WEBAPP_LABEL_EN",
),
(
"WEBAPP_ICON",
"icon",
"WebApp button icon",
"Lucide icon name rendered inside the Web App payment method button.",
default_icon,
"WEBAPP_ICON",
),
(
"TELEGRAM_LABEL_RU",
"string",
"Telegram button text (RU)",
"Custom Russian text shown in Telegram bot payment buttons.",
"",
"TELEGRAM_LABEL_RU",
),
(
"TELEGRAM_LABEL_EN",
"string",
"Telegram button text (EN)",
"Custom English text shown in Telegram bot payment buttons.",
"",
"TELEGRAM_LABEL_EN",
),
(
"TELEGRAM_EMOJI",
"string",
"Telegram button emoji",
"Emoji prepended to the Telegram bot payment button when customized.",
"",
"TELEGRAM_EMOJI",
),
)
)
_CONFIG_MANIFEST = (
ProviderManifestField(
"PLATEGA_ENABLED", "bool", "Включена", subsection="Platega", attr="ENABLED"
),
ProviderManifestField(
"PLATEGA_BASE_URL",
"url",
"Base URL",
placeholder="https://app.platega.io",
subsection="Platega",
attr="BASE_URL",
),
ProviderManifestField(
"PLATEGA_MERCHANT_ID", "string", "Merchant ID", subsection="Platega", attr="MERCHANT_ID"
),
ProviderManifestField(
"PLATEGA_SECRET", "string", "Secret", subsection="Platega", secret=True, attr="SECRET"
),
ProviderManifestField(
"PLATEGA_PAYMENT_METHOD",
"int",
"Метод оплаты (legacy)",
subsection="Platega",
attr="PAYMENT_METHOD",
),
ProviderManifestField(
"PLATEGA_SBP_ENABLED", "bool", "SBP-кнопка", subsection="Platega", attr="SBP_ENABLED"
),
ProviderManifestField(
"PLATEGA_SBP_METHOD", "int", "SBP method ID", subsection="Platega", attr="SBP_METHOD"
),
ProviderManifestField(
"PLATEGA_CRYPTO_ENABLED",
"bool",
"Crypto-кнопка",
subsection="Platega",
attr="CRYPTO_ENABLED",
),
ProviderManifestField(
"PLATEGA_CRYPTO_METHOD",
"int",
"Crypto method ID",
subsection="Platega",
attr="CRYPTO_METHOD",
),
ProviderManifestField(
"PLATEGA_RETURN_URL", "url", "Return URL", subsection="Platega", attr="RETURN_URL"
),
ProviderManifestField(
"PLATEGA_FAILED_URL", "url", "Failed URL", subsection="Platega", attr="FAILED_URL"
),
)
SBP_SPEC = PaymentProviderSpec(
id="platega_sbp",
provider_key="platega",
label="Platega",
webapp_label="Platega · СБП",
webapp_labels={"ru": "Оплата картой (СБП)", "en": "Pay with card (SBP)"},
webapp_icon="CreditCard",
telegram_labels={"ru": "Оплата через СБП", "en": "Pay via SBP"},
telegram_emoji="🏦",
pending_status="pending_platega",
enabled=lambda config: bool(
getattr(config, "ENABLED", False) and getattr(config, "SBP_ENABLED", False)
),
admin_only_enabled=lambda config: bool(getattr(config, "SBP_ADMIN_ONLY_ENABLED", False)),
admin_only_config_attr="SBP_ADMIN_ONLY_ENABLED",
service_key="platega_service",
callback_prefix="pay_platega_sbp",
aliases=("platega",),
router=router,
create_service=create_service,
webhook_path=lambda source: "/webhook/platega",
webhook_route=platega_webhook_route,
create_webapp_payment=create_sbp_webapp_payment,
config_class=PlategaConfig,
presentation_class=PlategaSbpPresentation,
manifest_fields=_CONFIG_MANIFEST
+ _platega_presentation_manifest("Platega", "CreditCard", "PLATEGA_SBP"),
)
CRYPTO_SPEC = PaymentProviderSpec(
id="platega_crypto",
provider_key="platega",
label="Platega",
webapp_label="Platega · Crypto",
webapp_labels={"ru": "Крипта", "en": "Crypto"},
webapp_icon="Bitcoin",
telegram_labels={"ru": "Оплата криптой", "en": "Pay with crypto"},
telegram_emoji="🪙",
pending_status="pending_platega",
# Uses the same PlategaConfig as SBP_SPEC (shared service_key); enable
# flag combines the global PLATEGA_ENABLED with the per-button toggle.
enabled=lambda config: bool(
getattr(config, "ENABLED", False) and getattr(config, "CRYPTO_ENABLED", False)
),
admin_only_enabled=lambda config: bool(getattr(config, "CRYPTO_ADMIN_ONLY_ENABLED", False)),
admin_only_config_attr="CRYPTO_ADMIN_ONLY_ENABLED",
service_key="platega_service",
callback_prefix="pay_platega_crypto",
create_webapp_payment=create_crypto_webapp_payment,
config_class=PlategaConfig,
presentation_class=PlategaCryptoPresentation,
manifest_fields=_platega_presentation_manifest("Platega", "Bitcoin", "PLATEGA_CRYPTO"),
)
SPECS = (SBP_SPEC, CRYPTO_SPEC)
+446
View File
@@ -0,0 +1,446 @@
from __future__ import annotations
from typing import Any, Dict, Iterable, List, Mapping, Optional
from . import cryptopay, freekassa, heleket, platega, severpay, stars, wata, yookassa
from .base import (
PaymentProviderPresentation,
PaymentProviderSpec,
ProviderConfigBundle,
ProviderManifestField,
ServiceFactoryContext,
)
PAYMENT_PROVIDER_SPECS: tuple[PaymentProviderSpec, ...] = (
freekassa.SPEC,
platega.SBP_SPEC,
platega.CRYPTO_SPEC,
severpay.SPEC,
wata.SPEC,
yookassa.SPEC,
stars.SPEC,
cryptopay.SPEC,
heleket.SPEC,
)
# Provider configs (env-loaded BaseSettings models) live here as a process-wide
# singleton populated by build_provider_configs() on startup. Modules that need
# to read provider configs without changing call signatures (e.g. presentation
# resolution from arbitrary callers) can look them up via current_provider_configs().
#
# Keyed by ``service_key`` because multiple SPECs (Platega SBP / Platega Crypto)
# can share the same backing service. Per-SPEC presentation overrides live in
# ``_provider_presentations`` instead, indexed by ``spec.id``.
_provider_configs: Dict[str, ProviderConfigBundle] = {}
_provider_presentations: Dict[str, Any] = {}
def iter_provider_specs() -> Iterable[PaymentProviderSpec]:
return PAYMENT_PROVIDER_SPECS
def get_provider_spec(method: str) -> Optional[PaymentProviderSpec]:
normalized = str(method or "").strip().lower()
for spec in PAYMENT_PROVIDER_SPECS:
if normalized in spec.method_ids:
return spec
return None
def build_provider_configs(*, force: bool = False) -> Dict[str, ProviderConfigBundle]:
"""Instantiate per-provider BaseSettings models declared on each SPEC.
Returns a mapping ``service_key`` ``ProviderConfigBundle(config, presentation)``.
For SPECs that share a service (Platega SBP + Platega Crypto), only the
first one's presentation lands in the shared bundle — per-SPEC presentation
overrides live separately in ``_provider_presentations`` keyed by ``spec.id``.
Idempotent by default: if bundles already exist in the process-wide cache
we return them as-is. Pass ``force=True`` to rebuild from env (used by
tests after monkeypatching env vars). The non-force path is critical for
runtime: ``load_overrides_from_db`` builds bundles first and then writes
DB-persisted overrides into them a later non-force rebuild from
``build_core_services`` would otherwise wipe those overrides.
"""
if _provider_configs and not force:
return dict(_provider_configs)
from .base import provider_env_file
env_file = provider_env_file()
init_kwargs = {"_env_file": env_file}
bundles: Dict[str, ProviderConfigBundle] = {}
presentations: Dict[str, Any] = {}
seen_services: set[str] = set()
for spec in PAYMENT_PROVIDER_SPECS:
if spec.presentation_class is not None:
presentations[spec.id] = spec.presentation_class(**init_kwargs)
if not spec.service_key or spec.service_key in seen_services:
continue
if spec.config_class is None and spec.presentation_class is None:
continue
seen_services.add(spec.service_key)
bundles[spec.service_key] = ProviderConfigBundle(
config=spec.config_class(**init_kwargs) if spec.config_class else None,
presentation=presentations.get(spec.id),
)
_provider_configs.clear()
_provider_configs.update(bundles)
_provider_presentations.clear()
_provider_presentations.update(presentations)
return bundles
def get_spec_presentation(spec_id: str) -> Optional[Any]:
return _provider_presentations.get(spec_id)
def current_provider_configs() -> Mapping[str, ProviderConfigBundle]:
return _provider_configs
def get_provider_bundle(service_key: Optional[str]) -> Optional[ProviderConfigBundle]:
if not service_key:
return None
return _provider_configs.get(service_key)
def _setting_value(source: Any, key: str) -> Optional[str]:
if source is None:
return None
value = getattr(source, key, None)
if value is None:
return None
value = str(value).strip()
return value or None
def _presentation_setting(spec: PaymentProviderSpec, suffix: str) -> str:
return f"PAYMENT_{spec.settings_key}_{suffix}"
def _normalize_language(language: Optional[str], settings: Any = None) -> str:
value = language or getattr(settings, "DEFAULT_LANGUAGE", None) or "ru"
normalized = str(value).strip().lower().split("-", 1)[0].split("_", 1)[0]
return normalized or "ru"
def _presentation_attr(suffix: str, language: Optional[str] = None) -> str:
"""Attribute name on a provider's presentation BaseSettings model.
Mirrors the legacy ``PAYMENT_<ID>_<suffix>`` env name, but without the
``PAYMENT_<ID>_`` prefix (which is supplied by env_prefix on the model).
"""
if language:
return f"{suffix}_{language.upper()}"
return suffix
def _provider_presentation_value(
spec: PaymentProviderSpec,
suffix: str,
*,
language: Optional[str] = None,
) -> Optional[str]:
presentation = _provider_presentations.get(spec.id)
if presentation is None:
bundle = _provider_configs.get(spec.service_key) if spec.service_key else None
presentation = bundle.presentation if bundle else None
if presentation is None:
return None
attr = _presentation_attr(suffix, language=language)
return _setting_value(presentation, attr)
def _localized_setting_value(
settings: Any,
spec: PaymentProviderSpec,
suffix: str,
language: str,
) -> Optional[str]:
# 1) Per-provider presentation model (new pattern) takes priority.
provider_value = _provider_presentation_value(spec, suffix, language=language)
if provider_value is not None:
return provider_value
# 2) Legacy: PAYMENT_<ID>_<suffix>_<LANG> on the global Settings.
return _setting_value(
settings,
_presentation_setting(spec, f"{suffix}_{language.upper()}"),
)
def _bare_setting_value(
settings: Any,
spec: PaymentProviderSpec,
suffix: str,
) -> Optional[str]:
provider_value = _provider_presentation_value(spec, suffix)
if provider_value is not None:
return provider_value
return _setting_value(settings, _presentation_setting(spec, suffix))
def _localized_default(
values: Optional[Mapping[str, str]],
language: str,
fallback: Optional[str],
) -> Optional[str]:
if not values:
return fallback
return (
values.get(language)
or values.get("en")
or values.get("ru")
or next(iter(values.values()), None)
or fallback
)
def resolve_provider_presentation(
spec: PaymentProviderSpec,
settings: Any = None,
*,
language: Optional[str] = None,
) -> PaymentProviderPresentation:
lang = _normalize_language(language, settings)
webapp_label = (
_localized_setting_value(settings, spec, "WEBAPP_LABEL", lang)
or _localized_default(spec.webapp_labels, lang, spec.webapp_label)
or spec.label
)
webapp_icon = _bare_setting_value(settings, spec, "WEBAPP_ICON") or spec.webapp_icon
telegram_label_override = _localized_setting_value(
settings,
spec,
"TELEGRAM_LABEL",
lang,
)
telegram_emoji_override = _bare_setting_value(settings, spec, "TELEGRAM_EMOJI")
telegram_label = (
telegram_label_override
or _localized_default(spec.telegram_labels, lang, None)
or spec.label
)
telegram_emoji = telegram_emoji_override or spec.default_telegram_emoji
return PaymentProviderPresentation(
webapp_label=webapp_label,
webapp_icon=webapp_icon,
telegram_label=telegram_label,
telegram_emoji=telegram_emoji,
telegram_customized=bool(telegram_label_override or telegram_emoji_override),
)
def provider_telegram_button_text(
spec: PaymentProviderSpec,
settings: Any,
*,
language: Optional[str] = None,
) -> str:
presentation = resolve_provider_presentation(spec, settings, language=language)
if presentation.telegram_emoji:
return f"{presentation.telegram_emoji} {presentation.telegram_label}".strip()
return presentation.telegram_label
def iter_unique_provider_routers():
seen: set[int] = set()
for spec in PAYMENT_PROVIDER_SPECS:
router = spec.load_router()
if not router:
continue
marker = id(router)
if marker in seen:
continue
seen.add(marker)
yield router
def iter_service_keys() -> Iterable[str]:
seen: set[str] = set()
for spec in PAYMENT_PROVIDER_SPECS:
if not spec.service_key or spec.service_key in seen:
continue
seen.add(spec.service_key)
yield spec.service_key
def iter_service_specs() -> Iterable[PaymentProviderSpec]:
seen: set[str] = set()
for spec in PAYMENT_PROVIDER_SPECS:
if not spec.service_key or not spec.create_service or spec.service_key in seen:
continue
seen.add(spec.service_key)
yield spec
def build_provider_services(ctx: ServiceFactoryContext) -> Dict[str, Any]:
services: Dict[str, Any] = {}
for spec in iter_service_specs():
services[spec.service_key] = spec.create_service(ctx)
return services
def provider_label_map(settings: Any = None, language: Optional[str] = None) -> Dict[str, str]:
labels: Dict[str, str] = {}
for spec in PAYMENT_PROVIDER_SPECS:
presentation = resolve_provider_presentation(
spec,
settings,
language=language,
)
label = presentation.telegram_label if presentation.telegram_customized else spec.label
labels.setdefault(spec.provider_key, label)
for method in spec.method_ids:
labels.setdefault(method, label)
return labels
def provider_emoji_map(settings: Any = None) -> Dict[str, str]:
emojis: Dict[str, str] = {}
for spec in PAYMENT_PROVIDER_SPECS:
emoji = resolve_provider_presentation(spec, settings).telegram_emoji
emojis.setdefault(spec.provider_key, emoji)
for method in spec.method_ids:
emojis.setdefault(method, emoji)
return emojis
def pending_statuses() -> List[str]:
statuses = ["pending"]
for spec in PAYMENT_PROVIDER_SPECS:
if spec.pending_status not in statuses:
statuses.append(spec.pending_status)
return statuses
def iter_provider_manifest_fields() -> Iterable[tuple[PaymentProviderSpec, ProviderManifestField]]:
"""Yield (spec, manifest_field) for every fragment declared on a provider SPEC."""
for spec in PAYMENT_PROVIDER_SPECS:
emitted_keys: set[str] = set()
for field in spec.manifest_fields:
emitted_keys.add(field.key)
yield spec, field
admin_only_field = provider_admin_only_manifest_field(spec)
if admin_only_field is not None and admin_only_field.key not in emitted_keys:
yield spec, admin_only_field
def find_manifest_owner(key: str) -> Optional[tuple[PaymentProviderSpec, ProviderManifestField]]:
"""Find which provider owns a manifest key (if any)."""
for spec, field in iter_provider_manifest_fields():
if field.key == key:
return spec, field
return None
def provider_admin_only_manifest_field(
spec: PaymentProviderSpec,
) -> Optional[ProviderManifestField]:
if spec.config_class is None:
return None
subsection = spec.label
for field in spec.manifest_fields:
if field.subsection:
subsection = field.subsection
break
return ProviderManifestField(
spec.admin_only_field_key,
"bool",
"Only for admins",
(
"Shows this payment method only to users from ADMIN_IDS. "
"Webhooks and provider services remain active for admin test payments."
),
subsection=subsection,
attr=spec.admin_only_config_attr,
i18n_label_key="admin_settings_provider_admin_only_label",
i18n_description_key="admin_settings_provider_admin_only_description",
)
def provider_admin_only_pairs() -> List[tuple[str, str]]:
pairs: List[tuple[str, str]] = []
seen: set[tuple[str, str]] = set()
for spec in PAYMENT_PROVIDER_SPECS:
pair = (spec.enabled_field_key, spec.admin_only_field_key)
if pair in seen:
continue
seen.add(pair)
pairs.append(pair)
return pairs
def _webhook_spec_for(spec: PaymentProviderSpec) -> Optional[PaymentProviderSpec]:
if spec.webhook_path and spec.webhook_route:
return spec
if not spec.service_key:
return None
for candidate in PAYMENT_PROVIDER_SPECS:
if (
candidate.service_key == spec.service_key
and candidate.webhook_path
and candidate.webhook_route
):
return candidate
return None
def provider_webhook_metadata(spec: PaymentProviderSpec) -> Optional[Dict[str, Any]]:
"""Return admin-manifest webhook metadata for a provider SPEC.
Some visible payment buttons share one backing service and webhook route
(for example Platega SBP and Platega Crypto), so presentation-only specs
inherit the route from their service sibling.
"""
webhook_spec = _webhook_spec_for(spec)
if webhook_spec is None or not webhook_spec.webhook_path:
return None
try:
path = str(webhook_spec.webhook_path(None) or "").strip()
except Exception:
return None
if not path:
return None
return {
"provider_id": spec.id,
"provider_label": spec.label,
"webhook_provider_id": webhook_spec.id,
"webhook_path": path,
"webhook_requires_base_url": bool(webhook_spec.webhook_requires_base_url),
}
def manifest_field_default(
spec: PaymentProviderSpec,
manifest_field: ProviderManifestField,
) -> Optional[str]:
"""Resolve the SPEC-declared default for a presentation manifest field.
Used by the admin UI to render a placeholder/hint that shows users what
button text or icon the bot falls back to when they leave the override
blank. Returns None for non-presentation fields or attributes we don't
have a mapping for (admin shouldn't render a misleading hint).
"""
if manifest_field.target != "presentation":
return None
attr = manifest_field.attr or manifest_field.key
if attr == "WEBAPP_LABEL_RU":
return _localized_default(spec.webapp_labels, "ru", spec.webapp_label) or spec.label
if attr == "WEBAPP_LABEL_EN":
return _localized_default(spec.webapp_labels, "en", spec.webapp_label) or spec.label
if attr == "WEBAPP_ICON":
return spec.webapp_icon
if attr == "TELEGRAM_LABEL_RU":
return _localized_default(spec.telegram_labels, "ru", None) or spec.label
if attr == "TELEGRAM_LABEL_EN":
return _localized_default(spec.telegram_labels, "en", None) or spec.label
if attr == "TELEGRAM_EMOJI":
return spec.default_telegram_emoji
return None
+654
View File
@@ -0,0 +1,654 @@
import hashlib
import hmac
import json
import logging
import secrets
from typing import Any, Dict, Optional, Tuple
from aiogram import Bot, F, Router, types
from aiohttp import web
from pydantic import Field, field_validator
from pydantic_settings import SettingsConfigDict
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import sessionmaker
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 db.dal import payment_dal
from .base import (
PaymentProviderSpec,
ProviderEnvConfig,
ProviderManifestField,
ServiceFactoryContext,
WebAppPaymentContext,
provider_env_file,
provider_runtime_enabled,
)
from .shared import (
HttpClientMixin,
PaymentSuccessRequest,
build_payment_record_payload,
create_webapp_payment_record,
describe_payment,
finalize_successful_payment,
finalize_webapp_link_payment,
first_value,
format_decimal_amount,
lookup_payment_by_order_or_provider_id,
make_translator,
notify_callback_parse_error,
notify_payment_record_failure,
notify_service_unavailable,
notify_user_payment_failed,
parse_payment_callback,
payment_failed,
payment_unavailable,
payment_units_for_activation,
post_json_request,
quote_hwid_callback_parts,
render_link_or_fail,
)
_LOG = "severpay"
class SeverPayConfig(ProviderEnvConfig):
model_config = SettingsConfigDict(
env_file=provider_env_file(),
env_file_encoding="utf-8",
env_prefix="SEVERPAY_",
extra="ignore",
)
ENABLED: bool = Field(default=False)
MID: Optional[int] = None
TOKEN: Optional[str] = None
RETURN_URL: Optional[str] = None
BASE_URL: str = Field(default="https://severpay.io/api/merchant")
LIFETIME_MINUTES: Optional[int] = None
@field_validator("MID", "LIFETIME_MINUTES", mode="before")
@classmethod
def _empty_to_none_int(cls, v):
if isinstance(v, str):
v = v.strip()
if not v:
return None
return v
@field_validator("TOKEN", "RETURN_URL", mode="before")
@classmethod
def _strip_optional(cls, v):
if isinstance(v, str) and not v.strip():
return None
return v
@property
def webhook_path(self) -> str:
return "/webhook/severpay"
class SeverPayPresentation(ProviderEnvConfig):
model_config = SettingsConfigDict(
env_file=provider_env_file(),
env_file_encoding="utf-8",
env_prefix="PAYMENT_SEVERPAY_",
extra="ignore",
)
WEBAPP_LABEL_RU: Optional[str] = None
WEBAPP_LABEL_EN: Optional[str] = None
WEBAPP_ICON: Optional[str] = None
TELEGRAM_LABEL_RU: Optional[str] = None
TELEGRAM_LABEL_EN: Optional[str] = None
TELEGRAM_EMOJI: Optional[str] = None
class SeverPayService(HttpClientMixin):
def __init__(
self,
*,
bot: Bot,
settings: Settings,
config: SeverPayConfig,
i18n: JsonI18n,
async_session_factory: sessionmaker,
subscription_service: SubscriptionService,
referral_service: ReferralService,
default_return_url: str,
):
self.bot = bot
self.settings = settings
self.config = config
self.i18n = i18n
self.async_session_factory = async_session_factory
self.subscription_service = subscription_service
self.referral_service = referral_service
self._default_return_url = default_return_url
self._init_http_client(total_timeout=15)
if not self.configured:
logging.warning(
"SeverPayService initialized but not fully configured. Payments disabled."
)
@property
def configured(self) -> bool:
return bool(provider_runtime_enabled(self.config) and self.mid and self.token)
@property
def base_url(self) -> str:
return (self.config.BASE_URL or "https://severpay.io/api/merchant").rstrip("/")
@property
def mid(self):
return self.config.MID
@property
def token(self) -> str:
return self.config.TOKEN or ""
@property
def return_url(self) -> str:
return self.config.RETURN_URL or f"https://t.me/{self._default_return_url}"
@property
def lifetime_minutes(self):
return self.config.LIFETIME_MINUTES
@staticmethod
def _format_amount(amount: float) -> str:
return f"{format_decimal_amount(amount):.2f}"
def _sign_payload(self, payload: Dict[str, Any]) -> str:
message = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
return hmac.new(
self.token.encode("utf-8"), message.encode("utf-8"), hashlib.sha256
).hexdigest()
def _build_signed_body(self, extra: Dict[str, Any]) -> Dict[str, Any]:
body: Dict[str, Any] = {
"mid": self.mid,
"salt": secrets.token_hex(8),
}
body.update(extra)
sorted_body = dict(sorted(body.items()))
sorted_body["sign"] = self._sign_payload(sorted_body)
return sorted_body
def _validate_signature(self, payload: Dict[str, Any]) -> bool:
provided_sign = str(payload.get("sign") or "")
if not provided_sign or not self.token:
return False
# Webhook signatures are calculated on the original payload order (without sorting).
data = {k: v for k, v in payload.items() if k != "sign"}
expected_sign = self._sign_payload(data)
return hmac.compare_digest(provided_sign, expected_sign)
async def create_payment(
self,
*,
payment_db_id: int,
user_id: int,
amount: float,
currency: Optional[str],
) -> Tuple[bool, Dict[str, Any]]:
if not self.configured:
logging.error("SeverPayService is not configured. Cannot create payment.")
return False, {"message": "service_not_configured"}
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),
"amount": self._format_amount(amount),
"currency": currency_code,
"client_email": f"{user_id}@telegram.org",
"client_id": str(user_id),
"url_return": self.return_url,
}
if self.lifetime_minutes:
body["lifetime"] = int(self.lifetime_minutes)
success, response_data = await post_json_request(
session,
url,
body=self._build_signed_body(body),
log_prefix="SeverPay create_payment",
# SeverPay marks success with both HTTP 200 *and* the top-level ``status`` flag.
is_success=lambda status, data: status == 200 and bool((data or {}).get("status")),
)
if success:
# SeverPay wraps the useful response inside ``data``; unwrap so callers
# don't have to know that detail.
return True, response_data.get("data") or response_data
return False, response_data
async def webhook_route(self, request: web.Request) -> web.Response:
if not self.configured:
return web.json_response({"status": False, "msg": "severpay_disabled"}, status=503)
try:
payload = await request.json()
except Exception:
logging.exception("SeverPay webhook: failed to parse JSON.")
return web.json_response({"status": False, "msg": "bad_request"}, status=400)
if not isinstance(payload, dict) or not self._validate_signature(payload):
logging.error("SeverPay webhook: invalid signature or payload.")
return web.json_response({"status": False, "msg": "invalid_signature"}, status=403)
event_type = str(payload.get("type") or "").lower()
data = payload.get("data") or {}
if event_type != "payin" or not isinstance(data, dict):
logging.warning("SeverPay webhook: unsupported event type '%s'", event_type)
return web.json_response({"status": True})
provider_payment_id = str(data.get("id") or data.get("uid") or "")
order_id_raw = data.get("order_id")
status = str(data.get("status") or "").lower()
async with self.async_session_factory() as session:
payment = await lookup_payment_by_order_or_provider_id(
session,
order_id_raw=order_id_raw,
provider_payment_id=provider_payment_id or None,
)
if not payment:
logging.error(
"SeverPay webhook: payment not found (order_id=%s, provider_id=%s)",
order_id_raw,
provider_payment_id,
)
return web.json_response({"status": False, "msg": "payment_not_found"}, status=404)
resolved_provider_id = provider_payment_id or str(payment.payment_id)
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,
payment.payment_id,
resolved_provider_id,
"succeeded",
)
await session.commit()
except Exception:
await session.rollback()
logging.exception(
"SeverPay webhook: failed to mark payment %s as succeeded.",
resolved_provider_id,
)
return web.json_response(
{"status": False, "msg": "processing_error"}, status=500
)
outcome = await finalize_successful_payment(
PaymentSuccessRequest(
bot=self.bot,
settings=self.settings,
i18n=self.i18n,
session=session,
subscription_service=self.subscription_service,
referral_service=self.referral_service,
payment=payment,
user_id=payment.user_id,
amount=float(payment.amount),
currency=payment.currency,
sale_mode=sale_mode,
months=payment_months,
traffic_amount=float(payment_months),
provider_subscription="severpay",
provider_notification="severpay",
db_user=payment.user,
log_prefix="SeverPay webhook",
)
)
if outcome is None:
return web.json_response(
{"status": False, "msg": "processing_error"}, status=500
)
return web.json_response({"status": True})
if status in {"fail", "decline"}:
try:
await payment_dal.update_provider_payment_and_status(
session,
payment.payment_id,
resolved_provider_id,
"failed",
)
await session.commit()
except Exception:
await session.rollback()
logging.exception(
"SeverPay webhook: failed to mark payment %s as failed.",
resolved_provider_id,
)
return web.json_response(
{"status": False, "msg": "processing_error"}, status=500
)
await notify_user_payment_failed(
bot=self.bot,
settings=self.settings,
i18n=self.i18n,
session=session,
payment=payment,
)
return web.json_response({"status": True})
if status in {"process", "new"}:
try:
await payment_dal.update_provider_payment_and_status(
session,
payment.payment_id,
resolved_provider_id,
"pending_severpay",
)
await session.commit()
except Exception:
await session.rollback()
logging.exception(
"SeverPay webhook: failed to update pending status for %s.",
resolved_provider_id,
)
return web.json_response({"status": True})
logging.warning(
"SeverPay webhook: unhandled status '%s' for payment %s",
status,
resolved_provider_id,
)
return web.json_response({"status": True})
async def severpay_webhook_route(request: web.Request) -> web.Response:
service: SeverPayService = request.app["severpay_service"]
return await service.webhook_route(request)
router = Router(name="user_subscription_payments_severpay_router")
@router.callback_query(F.data.startswith("pay_severpay:"))
async def pay_severpay_callback_handler(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
severpay_service: SeverPayService,
session: AsyncSession,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
translator = make_translator(i18n, current_lang)
if not i18n or not callback.message:
await notify_callback_parse_error(callback, translator)
return
if not SPEC.is_available_to_user(
settings,
user_id=callback.from_user.id,
require_configured=False,
):
await notify_service_unavailable(callback, translator)
return
if not severpay_service or not severpay_service.configured:
logging.error("SeverPay service is not configured or unavailable.")
await notify_service_unavailable(callback, translator)
return
parts = parse_payment_callback(callback.data or "")
if not parts:
logging.error("Invalid pay_severpay data in callback: %s", callback.data)
await notify_callback_parse_error(callback, translator)
return
parts, hwid_quote = await quote_hwid_callback_parts(
session=session,
user_id=callback.from_user.id,
parts=parts,
subscription_service=severpay_service.subscription_service,
currency="rub",
)
if not parts:
await notify_callback_parse_error(callback, translator)
return
currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
payment_description = describe_payment(translator, parts)
record_payload = build_payment_record_payload(
user_id=callback.from_user.id,
amount=parts.price,
currency=currency_code,
status="pending_severpay",
description=payment_description,
months=parts.months,
provider="severpay",
sale_mode=parts.sale_mode,
hwid_quote=hwid_quote,
)
try:
payment_record = await payment_dal.create_payment_record(session, record_payload)
await session.commit()
except Exception:
await session.rollback()
logging.exception(
"SeverPay: failed to create payment record for user %s.", callback.from_user.id
)
await notify_payment_record_failure(callback, translator)
return
success, response_data = await severpay_service.create_payment(
payment_db_id=payment_record.payment_id,
user_id=callback.from_user.id,
amount=parts.price,
currency=currency_code,
)
await render_link_or_fail(
callback,
translator=translator,
current_lang=current_lang,
i18n=i18n,
parts=parts,
session=session,
payment=payment_record,
api_success=success,
payment_url=first_value(response_data, "url", "payment_url", "paymentUrl"),
provider_payment_id=first_value(response_data, "id", "uid"),
log_prefix=_LOG,
)
def create_service(ctx: ServiceFactoryContext) -> SeverPayService:
bundle = ctx.config_for("severpay_service")
config = (
bundle.config if bundle and isinstance(bundle.config, SeverPayConfig) else SeverPayConfig()
)
return SeverPayService(
bot=ctx.bot,
settings=ctx.settings,
config=config,
i18n=ctx.i18n,
async_session_factory=ctx.async_session_factory,
subscription_service=ctx.subscription_service,
referral_service=ctx.referral_service,
default_return_url=ctx.bot_username_for_default_return,
)
async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
settings = ctx.request.app["settings"]
service: SeverPayService = ctx.request.app["severpay_service"]
if not service or not service.configured:
return payment_unavailable()
currency = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
try:
payment = await create_webapp_payment_record(
ctx,
amount=ctx.price,
currency=currency,
status="pending_severpay",
provider="severpay",
)
success, response_data = await service.create_payment(
payment_db_id=payment.payment_id,
user_id=ctx.user_id,
amount=ctx.price,
currency=currency,
)
except Exception:
await ctx.session.rollback()
logging.exception("SeverPay WebApp payment failed")
return payment_failed()
return await finalize_webapp_link_payment(
session=ctx.session,
payment=payment,
api_success=success,
payment_url=(
first_value(response_data, "url", "payment_url", "paymentUrl") if success else None
),
provider_payment_id=first_value(response_data, "id", "uid"),
log_prefix="SeverPay",
)
_PRESENTATION_MANIFEST = tuple(
ProviderManifestField(
key=key,
type=type_,
label=label,
description=description,
placeholder=placeholder,
subsection="SeverPay",
target="presentation",
attr=attr,
)
for key, type_, label, description, placeholder, attr in (
(
"PAYMENT_SEVERPAY_WEBAPP_LABEL_RU",
"string",
"WebApp button text (RU)",
"Custom Russian text shown in the Web App payment method button.",
"",
"WEBAPP_LABEL_RU",
),
(
"PAYMENT_SEVERPAY_WEBAPP_LABEL_EN",
"string",
"WebApp button text (EN)",
"Custom English text shown in the Web App payment method button.",
"",
"WEBAPP_LABEL_EN",
),
(
"PAYMENT_SEVERPAY_WEBAPP_ICON",
"icon",
"WebApp button icon",
"Lucide icon name rendered inside the Web App payment method button.",
"CreditCard",
"WEBAPP_ICON",
),
(
"PAYMENT_SEVERPAY_TELEGRAM_LABEL_RU",
"string",
"Telegram button text (RU)",
"Custom Russian text shown in Telegram bot payment buttons.",
"",
"TELEGRAM_LABEL_RU",
),
(
"PAYMENT_SEVERPAY_TELEGRAM_LABEL_EN",
"string",
"Telegram button text (EN)",
"Custom English text shown in Telegram bot payment buttons.",
"",
"TELEGRAM_LABEL_EN",
),
(
"PAYMENT_SEVERPAY_TELEGRAM_EMOJI",
"string",
"Telegram button emoji",
"Emoji prepended to the Telegram bot payment button when customized.",
"💳",
"TELEGRAM_EMOJI",
),
)
)
_CONFIG_MANIFEST = (
ProviderManifestField(
"SEVERPAY_ENABLED", "bool", "Включена", subsection="SeverPay", attr="ENABLED"
),
ProviderManifestField("SEVERPAY_MID", "int", "MID", subsection="SeverPay", attr="MID"),
ProviderManifestField(
"SEVERPAY_TOKEN", "string", "Token", subsection="SeverPay", secret=True, attr="TOKEN"
),
ProviderManifestField(
"SEVERPAY_BASE_URL",
"url",
"Base URL",
placeholder="https://severpay.io/api/merchant",
subsection="SeverPay",
attr="BASE_URL",
),
ProviderManifestField(
"SEVERPAY_RETURN_URL", "url", "Return URL", subsection="SeverPay", attr="RETURN_URL"
),
ProviderManifestField(
"SEVERPAY_LIFETIME_MINUTES",
"int",
"Payment link lifetime (minutes)",
description="30..4320; leave empty for the SeverPay default.",
subsection="SeverPay",
min=30,
max=4320,
attr="LIFETIME_MINUTES",
),
)
SPEC = PaymentProviderSpec(
id="severpay",
provider_key="severpay",
label="SeverPay",
webapp_label="SeverPay",
webapp_labels={"ru": "SeverPay", "en": "SeverPay"},
webapp_icon="CreditCard",
telegram_labels={"ru": "SeverPay", "en": "SeverPay"},
telegram_emoji="💳",
pending_status="pending_severpay",
enabled=lambda config: bool(getattr(config, "ENABLED", False)),
service_key="severpay_service",
callback_prefix="pay_severpay",
router=router,
create_service=create_service,
webhook_path=lambda source: "/webhook/severpay",
webhook_route=severpay_webhook_route,
create_webapp_payment=create_webapp_payment,
config_class=SeverPayConfig,
presentation_class=SeverPayPresentation,
manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST,
)
@@ -0,0 +1,136 @@
"""Cross-provider helpers.
Provider modules at ``bot.payment_providers.<name>`` import the building
blocks they need from here. Nothing in this package depends on any
specific provider it is the layer below them.
"""
from .callbacks import (
PaymentCallbackParts,
describe_payment,
edit_or_answer,
notify_callback_parse_error,
notify_payment_gateway_failure,
notify_payment_record_failure,
notify_service_unavailable,
parse_payment_callback,
payment_link_message_text,
quote_hwid_callback_parts,
render_link_or_fail,
render_payment_link,
safe_callback_answer,
safe_mark_failed_creation,
safe_store_provider_payment_id,
)
from .common import (
PaymentRecordAmounts,
Translator,
build_payment_description,
build_payment_record_payload,
create_base_payment_record,
create_webapp_payment_record,
decimal_amounts_equal,
format_decimal_amount,
format_human_units,
format_number_for_payload,
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,
sale_mode_tariff_key,
)
from .http_client import (
HttpClientMixin,
SuccessCheck,
first_value,
http_ok,
post_json_request,
)
from .success import (
PaymentSuccessOutcome,
PaymentSuccessRequest,
SuccessMessage,
append_hwid_renewal_note,
build_success_message,
finalize_successful_payment,
is_traffic_sale_base,
notify_admins_payment_received,
resolve_inviter_name,
resolve_user_language,
send_success_message_to_user,
)
from .webapp import finalize_webapp_link_payment
from .webhooks import (
coerce_payment_db_id,
lookup_payment_by_order_or_provider_id,
notify_user_payment_failed,
)
__all__ = [
"HttpClientMixin",
"PaymentCallbackParts",
"PaymentRecordAmounts",
"PaymentSuccessOutcome",
"PaymentSuccessRequest",
"SuccessCheck",
"SuccessMessage",
"Translator",
"build_payment_description",
"build_payment_record_payload",
"build_success_message",
"append_hwid_renewal_note",
"coerce_payment_db_id",
"create_base_payment_record",
"create_webapp_payment_record",
"decimal_amounts_equal",
"describe_payment",
"edit_or_answer",
"finalize_successful_payment",
"finalize_webapp_link_payment",
"first_value",
"format_decimal_amount",
"format_human_units",
"format_number_for_payload",
"http_ok",
"is_traffic_sale_base",
"json_error",
"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",
"notify_payment_record_failure",
"notify_service_unavailable",
"notify_user_payment_failed",
"parse_payment_callback",
"payment_failed",
"payment_link_message_text",
"payment_link_response",
"payment_record_amounts",
"payment_units_for_activation",
"payment_unavailable",
"post_json_request",
"quote_hwid_callback_parts",
"render_link_or_fail",
"render_payment_link",
"resolve_inviter_name",
"resolve_user_language",
"safe_callback_answer",
"safe_mark_failed_creation",
"safe_store_provider_payment_id",
"sale_mode_base",
"sale_mode_is_hwid_devices",
"sale_mode_is_traffic",
"sale_mode_tariff_key",
"send_success_message_to_user",
]

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