Compare commits

...
388 Commits
Author SHA1 Message Date
3252a8 dc4da78fc4 docs: document LAVA provider setup
Describe LAVA Business configuration: env keys, webhook URL and
signature scheme, RUB-only invoices, and the include-services filter.
Mention LAVA in the provider lists of the README, admin panel page,
and tariff currency matrix.
2026-06-10 23:32:39 +03:00
3252a8 2074e934ce test(payments): cover LAVA provider contract
Pin the LAVA integration contract: raw-body HMAC signature in the
Signature header (never a body-embedded field or sorted re-encode),
tolerant webhook signature verification, duplicate-success and
amount-mismatch webhook handling, pending invoice reuse rules, and
RUB-only invoice currency. Extend the registry, wiring, label, and
env-isolation suites with the new provider.
2026-06-10 23:32:30 +03:00
3252a8 bd1cfd1d63 feat(webapp): expose LAVA settings in admin UI and demo
Add RU/EN admin locale strings for the LAVA settings subsection and
field labels, regenerate the demo settings manifest snapshot, and list
LAVA among the demo dataset payment methods.
2026-06-10 23:32:20 +03:00
3252a8 bb844869ee feat(payments): add LAVA Business payment provider
Add a new provider module for LAVA Business (api.lava.ru):

- invoice creation via POST /business/invoice/create signed with
  HMAC-SHA256 over the raw request body in the Signature header
- webhook handling with Authorization-header signature verification
  that accepts both raw-body and sorted-keys JSON canonicalizations
  (legacy PHP SDK shops sign the latter), plus amount cross-check
  before finalizing a successful payment
- pending invoice reuse through /business/invoice/status
- Telegram pay_lava callback flow and Web App payment creation
- admin settings manifest fields, presentation overrides, and
  RUB-only currency support declared on the provider SPEC

Register the SPEC in the provider registry and wire the provider into
the payment method order, success-email labels, and locale override
prefixes.
2026-06-10 23:32:09 +03:00
3252a8 1d43606446 docs: drop manual image build instructions from README 2026-06-10 23:10:27 +03:00
3252a8 5ca1ecfce6 test: parse installer download host instead of substring check
Extract the raw_url() template from install.sh and compare the parsed
hostname to raw.githubusercontent.com. Resolves the CodeQL "incomplete
URL substring sanitization" alert on the old substring assertion.
2026-06-10 23:09:38 +03:00
3252a8 86ac925b19 ci: move dev Docker Hub publishing back to GitHub Actions
Publish dev images to both GHCR and Docker Hub from the dev workflow
and drop the GitLab CI pipeline, so all images are built by GitHub
Actions only.
2026-06-10 22:55:43 +03:00
3252a8andGitHub c459cabaae Merge pull request #29 from 3252a8/dev
PayKilla provider, Telegram anti-flood, install wizard and Remnashop migration (test)
2026-06-10 22:40:11 +03:00
3252a8 0864413e11 fix(security): keep private identifiers and allowlists out of logs 2026-06-10 22:37:11 +03:00
3252a8andGitHub ff2ed5b9f3 Merge pull request #28 from 3252a8/feature/telegram-flood-hardening 2026-06-10 17:24:12 +03:00
3252a8 02cb2f0d27 ci: skip Docker Hub secret check for PR builds 2026-06-10 16:56:27 +03:00
3252a8 ddb372a4e5 style: format action logger test 2026-06-10 16:54:14 +03:00
3252a8 73474f70c8 Merge origin/dev into feature/telegram-flood-hardening 2026-06-10 16:49:53 +03:00
3252a8 6bb2709244 feat: add telemetry build provenance
Stamp official Docker builds with a low-cardinality provenance marker and report build_provenance/image_modified in anonymous telemetry. Local and fork builds default to custom, while official GitHub/GitLab release paths mark images as official.
2026-06-10 15:18:32 +03:00
3252a8 fd931581f5 fix(themes): style admin health alerts
Add custom-theme styles for the admin configuration alerts and bump built-in theme asset versions so existing installations refresh stale theme CSS.
2026-06-10 14:25:24 +03:00
3252a8 217bed3c5d fix(admin): avoid stale Telegram webhook alerts
Only surface Telegram delivery errors while updates are still pending, and register the webhook after the aiohttp webhook site starts listening.
2026-06-10 13:04:04 +03:00
3252a8 ab21253d4f chore(admin): drop emoji-logo cache leftovers
Emoji logos were removed long ago; the only remaining trace was a
purge of stale data/webapp-emoji cache files on theme save. Remove
the purge, the WEBAPP_EMOJI_CACHE_DIR constant and the emoji part of
the prune test.
2026-06-10 12:31:52 +03:00
3252a8 1b2290ea66 feat(admin): surface configuration problems in the admin panel
Add GET /api/admin/health powered by a config health service that
detects common deployment mistakes: missing or read-only data volume,
broken tariffs/locale-override/guides JSON files, payment providers
enabled without credentials, webhook providers without
WEBHOOK_BASE_URL, no enabled payment methods, missing or non-https
mini app URL, missing Redis, partially configured SMTP, untrusted
reverse proxy, invalid bot token, missing/mismatched/failing Telegram
webhook and unreachable Remnawave panel. Network checks (Telegram,
panel) are cached for two minutes; ?refresh=1 forces a re-check.

The admin UI shows the alerts as a banner on the dashboard with
per-section navigation chips and a manual re-check button, and as a
filtered banner inside each affected section. Alerts are localized
via admin_health_* keys with built-in Russian fallbacks.
2026-06-10 12:31:41 +03:00
3252a8 ce6273a652 fix(payments): apply request timeout changes without restart
PAYMENT_REQUEST_TIMEOUT_SECONDS was read once in each provider's
__init__ and baked into the aiohttp session, so admin overrides
(applied in-process) only took effect after a container restart.
Providers now hand HttpClientMixin a timeout source callable; the
mixin builds the session with the current value and swaps in a fresh
session when the value changes, closing the replaced one only after
any in-flight request on it is bound by its own total timeout.

Also:
- check the Heleket payment-info success flag before reading the
  payload so a non-dict provider response cannot raise in the
  pending-payment reuse path
- add PAYMENT_REQUEST_TIMEOUT_SECONDS to the FreeKassa settings stub
  in test_security.py (fixes three tests broken by the new field)
2026-06-10 11:21:15 +03:00
BADtochka 3170b966b5 fix(payments): reuse pending PayKilla invoices 2026-06-10 04:09:46 +03:00
BADtochka 1fca62de75 fix(payments): reuse pending links by provider identity 2026-06-10 03:42:49 +03:00
BADtochka 5c96fcd519 fix(payments): pedning status in transactions 2026-06-09 16:36:04 +03:00
BADtochka ca88c995b8 fix(ci): use configured Docker Hub namespace 2026-06-09 14:27:04 +03:00
BADtochka 234fc69505 feat(payments): reuse pending provider payments 2026-06-09 14:19:50 +03:00
BADtochka 1362edde42 Merge GitHub dev into GitLab dev 2026-06-09 13:21:26 +03:00
3252a8 194bf64ebb fix: serve admin bundle as hashed immutable assets
The lazy-loaded admin CSS/JS resolved to bare runtime names served
no-store, the same scheme that left the main bundle vulnerable to stale
CSS in iOS WebViews after a deploy. The original reason for keeping them
bare (hashed admin files could 404 when nginx fronts aiohttp) no longer
holds: the backend image now carries the same deterministically hashed
assets nginx serves, and the App.svelte loader already falls back to the
bare name if a hashed asset ever 404s.

Resolve the admin assets through the same hashed/version-stable path as
the main bundle so they are emitted as immutable, cache-busting URLs.

Also drop the inert <meta http-equiv="Cache-Control/Pragma/Expires">
tags from the shell: browsers ignore http-equiv caching directives for
the document and use the real HTTP headers, which are already set.
2026-06-08 22:47:44 +03:00
3252a8 a2ce29da45 fix: ship hashed webapp assets in backend image
The backend renders the Mini App shell and rewrites the stylesheet and
script tags to content-hashed names (subscription_webapp.<hash>.css).
Those hashed files are gitignored build artifacts, so a clean checkout
has none of them and the backend image was built without any webapp
assets. The resolver therefore stat()-ed a missing file and fell back to
the bare /subscription_webapp.css URL.

That bare URL never changes between deploys and is served no-store. Most
clients re-fetch it, but iOS WebViews (WKWebView) ignore no-store for
subresources and keep serving a stale cached copy, so after every deploy
the CSS no longer matched the markup and the Mini App looked broken on
iOS only. The earlier no-store / ?v= / Clear-Site-Data attempts could not
help because none of them gave iOS a new URL to fetch.

Copy the freshly built assets from the frontend-builder stage into the
backend image (frontend-builder is reordered ahead of the backend stage
so the copy resolves). The build is deterministic, so the hash matches
the one the nginx image serves; the shell now emits immutable, hashed
URLs that change on every asset change and force iOS to fetch fresh CSS.
2026-06-08 22:34:03 +03:00
3252a8 0db3a68c09 fix: drop Clear-Site-Data reset breaking mini app styles
The once-per-version Clear-Site-Data: "cache" header on the index
navigation raced the page's own CSS/JS subresource loads in the
Telegram WebView, intermittently evicting or aborting the main
stylesheet so the mini app rendered half-styled on mobile.

It also could not fix stale HTML: it only fires when the document
actually reaches the backend, never when the WebView serves a cached
page. The no-store HTML plus immutable content-hashed asset filenames
already guarantee freshness without clearing the cache, so remove the
reset header, its helpers, constants, and tests.
2026-06-08 22:12:16 +03:00
3252a8 c2f0ae0b8b fix: resolve webhook client IP behind proxies 2026-06-08 11:25:02 +03:00
3252a8 23ad893f69 fix: reset stale webapp cache once per asset version 2026-06-08 10:59:52 +03:00
3252a8 d2149357c6 fix: stabilize mini app mobile navigation 2026-06-08 10:32:53 +03:00
3252a8 63192659e4 fix: prevent stale mini app mobile styles 2026-06-08 10:02:46 +03:00
3252a8 3e58e01d53 ci: improve GitLab dev image builds 2026-06-08 09:41:50 +03:00
3252a8 8cbe7e01ec docs: clarify migrator data mount 2026-06-08 09:35:11 +03:00
3252a8 a99aeec0d4 fix: return from bot tariff prices to bot menu 2026-06-08 09:22:24 +03:00
3252a8 2697c30c0f fix: allow docs previews without pillow 2026-06-08 00:00:35 +03:00
3252a8 2077c27252 chore: align local checks 2026-06-07 23:57:06 +03:00
3252a8 a86f5d75e1 build: reduce docker image layer churn 2026-06-07 23:47:36 +03:00
3252a8 24faabc20a fix: improve mobile tariff row editor layout 2026-06-07 23:35:53 +03:00
3252a8 00e1f51abe fix: preserve transparent email logos 2026-06-07 23:21:34 +03:00
3252a8 4b8f939a25 perf: cache broadcast audience counts 2026-06-07 23:15:44 +03:00
3252a8 d1c4a6de80 feat: improve admin pagination controls 2026-06-07 23:03:03 +03:00
3252a8 bddcd16a07 fix: align tariff row delete buttons 2026-06-07 22:52:10 +03:00
3252a8 a8e229d530 fix: remove manual trial squad input 2026-06-07 22:48:23 +03:00
3252a8 5cee619dd0 fix: assign default tariff to referral welcome bonuses 2026-06-07 22:28:30 +03:00
3252a8 d5b23d8306 fix: quote frontend route regex in nginx 2026-06-07 22:25:08 +03:00
3252a8 9de000e78c fix: assign default tariff to promo bonuses 2026-06-07 22:23:42 +03:00
3252a8 2b6f25f0f8 fix: avoid backend theme dependency in docs build 2026-06-07 22:06:47 +03:00
3252a8 3c82f43c84 ci: split dev image publishing by registry 2026-06-06 23:44:24 +03:00
3252a8 724e936660 fix: hide email prompts when auth is disabled 2026-06-06 23:35:38 +03:00
3252a8 d263651b48 fix: route support tickets to configured topic 2026-06-06 23:27:22 +03:00
3252a8 3d5190639f docs: add GitLab links and Docker Hub compose images 2026-06-06 23:20:22 +03:00
BADtochka 9adcbf103a fix(admin): stabilize user modal lifecycle 2026-06-06 20:33:04 +03:00
BADtochka fcb8e51ec0 fix(admin): clear user route on modal close 2026-06-06 20:16:43 +03:00
BADtochka ae3d6a9b99 fix(admin): clear user route on modal close 2026-06-06 20:05:47 +03:00
BADtochka e0b3940b98 fix(admin): reset user modal state consistently 2026-06-06 19:47:57 +03:00
BADtochka 2ec7376981 fix(admin): ignore stale user modal loads 2026-06-06 19:30:29 +03:00
BADtochka 6d0084dd3a fix(admin): close support user modal reliably 2026-06-06 18:20:44 +03:00
BADtochka e3f35a461c fix(payments): restore default provider connections 2026-06-06 17:53:16 +03:00
BADtochka a75d2d7ac0 fix(payments): retry provider connect failures 2026-06-06 17:10:52 +03:00
BADtochka e5b0daf639 fix(payments): avoid stale provider connections 2026-06-06 15:39:34 +03:00
BADtochka f07031f32c fix(security): resolve forwarded client ip chain 2026-06-06 00:45:21 +03:00
BADtochka cc74ddec10 Log trusted forwarded client IPs 2026-06-06 00:37:03 +03:00
BADtochka 766f2a5780 Add global payment request timeout setting 2026-06-05 23:40:02 +03:00
BADtochka 1158a2d835 chore: update action jobs for fork support 2026-06-05 21:50:07 +03:00
BADtochka 1d8ad0f24d fix(payment): increase timeout error on create_transaction 2026-06-05 21:37:37 +03:00
3252a8 6913676420 fix: support nested remnawave activity fields 2026-06-05 16:19:53 +03:00
3252a8 6890b58ced feat: show user vpn connection activity 2026-06-05 16:14:34 +03:00
3252a8 4cbd4dedf5 feat: add unconnected subscriber broadcast audience 2026-06-05 16:08:40 +03:00
3252a8 7700b294b7 fix: render configured webapp title on entry 2026-06-05 15:55:38 +03:00
3252a8 4b2faa87bb fix: refresh current favicon aliases 2026-06-05 15:47:37 +03:00
3252a8 1200e8ff70 fix: prevent devices limit flicker 2026-06-05 15:45:57 +03:00
3252a8 728599c882 feat: expose Telegram anti-flood settings 2026-06-05 11:38:42 +03:00
3252a8 fd1edf38b5 feat: skip action logs for dropped Telegram updates 2026-06-05 11:24:03 +03:00
3252a8 2e4599d068 feat: add Telegram payment callback cooldowns 2026-06-05 11:19:48 +03:00
3252a8 516d699cf3 feat: drop non-private Telegram updates early 2026-06-05 11:16:16 +03:00
3252a8 6dc43182ab feat: add Telegram anti-flood action buckets 2026-06-05 11:14:34 +03:00
3252a8 5e1fe59396 feat: add early Telegram anti-flood guard 2026-06-05 11:12:14 +03:00
3252a8 af1487731a deps: allow patched aiohttp release 2026-06-05 10:39:55 +03:00
3252a8 77b124d61e fix: apply webapp theme accent in emails and deeplinks 2026-06-04 23:22:53 +03:00
3252a8 090c88603e Merge branch 'pr-27' into dev
# Conflicts:
#	docs/features/payments.md
2026-06-04 23:10:21 +03:00
3252a8andGitHub 7bb0a94918 Merge pull request #26 from austnv/main
Добавлена поддержка локального SMTP-сервера на базе Docker Mailserver
2026-06-04 22:56:32 +03:00
BADtochkaandGitHub a456718193 docs(payment): tip and note 2026-06-04 16:35:52 +03:00
3252a8 358ef6ded7 chore: expand default PayKilla payment currencies 2026-06-04 16:33:50 +03:00
3252a8 ae7bfb9621 fix: gate PayKilla by minimum payment amount 2026-06-04 16:21:10 +03:00
BADtochkaandGitHub 976c81c11c docs: restore dev note about webhooks 2026-06-04 16:16:23 +03:00
BADtochkaandGitHub 02e7bb48d6 Merge branch 'dev' into patch-1 2026-06-04 16:11:34 +03:00
BADtochkaandGitHub 81c9f6bfd3 fix(payments): replace default tld in freekassa provider 2026-06-04 15:59:35 +03:00
3252a8 bdf0622be5 fix: convert PayKilla invoices to supported currency 2026-06-04 15:57:55 +03:00
BADtochkaandGitHub cf34d8971d docs: add webhook info to remaining providers 2026-06-04 15:55:33 +03:00
BADtochkaandGitHub 0afe4f0bc5 docs: payments structure
Updated payment methods documentation for clarity and consistency. Adjusted setup instructions and links for better readability.
2026-06-04 15:46:47 +03:00
3252a8 69985e44dd fix: send tariff currency to PayKilla 2026-06-04 15:34:26 +03:00
3252a8 8d6f91b292 fix: honor PayKilla invoice currency 2026-06-04 15:24:23 +03:00
3252a8 cd4b40af6b fix: remove PayKilla redirect fields 2026-06-04 15:14:13 +03:00
3252a8 e108cc0baa fix: omit default PayKilla redirect urls 2026-06-04 15:01:40 +03:00
3252a8 5b37306061 fix: use English PayKilla invoice text 2026-06-04 14:26:32 +03:00
3252a8 5527bf0247 fix: sanitize PayKilla invoice text 2026-06-04 14:18:35 +03:00
3252a8 c46aaad7e3 feat: add PayKilla payment provider 2026-06-04 13:32:15 +03:00
austnv c733d830f6 docs: add to deploy/emaples 2026-06-04 13:07:26 +03:00
austnv bd7361e9dc feature: add docker-compose.yml for docker-mailserver; edit documentation for local SMTP-server installation 2026-06-04 12:39:59 +03:00
3252a8andGitHub 1ccda9ebd1 Merge pull request #25 from 3252a8/dependabot/pip/pip-654a3705d3
chore(deps): bump the pip group across 2 directories with 1 update
2026-06-04 11:38:05 +03:00
3252a8 661d3f7953 fix: format webapp serializer 2026-06-04 11:35:42 +03:00
3252a8 7313f90350 fix: keep aiohttp within aiogram constraints 2026-06-04 11:33:33 +03:00
dependabot[bot]and3252a8 d988812ecf chore(deps): bump the pip group across 2 directories with 1 update
---
updated-dependencies:
- dependency-name: aiohttp
  dependency-version: 3.14.0
  dependency-type: direct:production
  dependency-group: pip
- dependency-name: aiohttp
  dependency-version: 3.14.0
  dependency-type: direct:production
  dependency-group: pip
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-04 11:33:33 +03:00
3252a8 1ee0b0a7cd feat: add email-only telegram demo states 2026-06-04 11:15:28 +03:00
3252a8 2b4a8d6953 fix: load docs demo runtime index directly 2026-06-04 11:02:06 +03:00
3252a8 2ad6b14513 feat: add telegram guardrails for trials and referrals 2026-06-04 10:51:58 +03:00
3252a8 33707b7257 chore: remove terms of service setting
Keep privacy policy and user agreement links as the supported legal documents.

Refresh the admin settings manifest, demo dataset, locales, docs, and bot menu tests.
2026-06-04 00:03:28 +03:00
3252a8 5f22c2081e docs: document payment provider webhook URLs 2026-06-03 23:55:08 +03:00
3252a8 fbb89793cb fix: separate HWID device renewal flows
Keep one-off device top-ups scoped to the active subscription term and move device renewal into subscription checkout.

Carry HWID renewal metadata through provider callbacks and webhooks, including YooKassa saved-card flows.

Add admin extension controls, docs, demo data, and regression coverage.
2026-06-03 23:51:46 +03:00
3252a8 a06884d816 feat: add admin HWID device limit overrides 2026-06-03 14:47:47 +03:00
3252a8 7b8feee99a feat: smooth sortable row interactions 2026-06-03 12:40:57 +03:00
3252a8 0a5575c1e6 fix: cover admin controls in css themes 2026-06-03 12:36:17 +03:00
3252a8 1055e58d0e docs: refresh admin tariff and appearance docs 2026-06-03 12:29:28 +03:00
3252a8 d8fe88eb01 feat: split logo scale by viewport 2026-06-03 12:24:18 +03:00
3252a8 602cbc7ee6 fix: restore draggable logo scale slider 2026-06-03 11:53:11 +03:00
3252a8 bcb7372500 fix: apply theme logo scale reliably 2026-06-03 11:42:03 +03:00
3252a8 e52c75538f feat: unify tariff package price rows 2026-06-03 11:34:08 +03:00
3252a8 5d1fd3304b docs: refresh branded email previews 2026-06-03 11:20:50 +03:00
3252a8 2d43033f98 fix: embed uploaded logo in emails 2026-06-03 11:06:19 +03:00
3252a8 101119911a refactor(webapp): remove emoji web app logo option
Drop the emoji-logo feature (and its font picker) from the Web App. Only
an uploaded/linked image logo and favicon remain; when no logo is set,
the default project logo is shown. Existing emoji-logo overrides are
ignored — the keys are gone from the manifest, so the override service
skips them and the app falls back to the default logo.

- Remove WEBAPP_LOGO_USE_EMOJI / WEBAPP_LOGO_EMOJI / WEBAPP_LOGO_EMOJI_FONT
  settings, validators, manifest entries and override/runtime plumbing.
- Strip the animated-emoji fetch/cache subsystem, the /webapp-emoji route
  and emoji branches from logo/favicon resolution; leftover emoji cache
  files are now purged on appearance save.
- Simplify BrandMark to an image-only component and drop the emoji UI
  from the admin Appearance section.
- Regenerate the demo settings manifest and clean docs, locales, nginx
  and demo data of emoji-logo references.
2026-06-03 10:55:52 +03:00
3252a8 58de153370 feat(tariffs): configurable purchase order for periods and packages
The order of enabled_periods (period tariffs) and traffic_packages
(traffic tariffs) is now the storefront order everywhere — both the
Telegram keyboard and the web app. Only new tariffs-config tariffs are
affected; legacy subscription/traffic options are untouched.

- Stop sorting periods and traffic packages in the web app plans
  serializer so it follows the configured order, matching the bot
  keyboards that already iterate the lists as-is.
- Preserve the row order through the admin draft (load and save) instead
  of sorting by months.
- Add a reusable Sortable component to the UI library (native HTML5
  drag & drop with a grip handle; bits-ui/shadcn have no such primitive)
  and use it to reorder period rows and traffic package rows in the
  tariff editor.
2026-06-03 10:14:40 +03:00
3252a8 0a294b8bf8 fix(miniapp): strip HTML tags from web app promo error messages
Localized promo errors carry Telegram-style <code> markup, which the
web app rendered as literal text. Strip tags and unescape entities
before returning the message in the JSON error.
2026-06-03 09:53:41 +03:00
3252a8 1605be0dfa deps: drop certifi pin that broke dependency resolution
certifi==2026.5.20 conflicts with aiocryptopay 0.4.8, which hard-caps
certifi<2024.0.0, so pip could not resolve and the Docker image build
failed. Revert to the transitive certifi; PYSEC-2024-230 stays unfixable
until aiocryptopay relaxes its cap. Keeps the PyJWT 2.13.0 bump.
2026-06-02 22:39:13 +03:00
3252a8 6f2bd2c443 deps: patch pyjwt and certifi security advisories
pip-audit flagged 6 advisories: PyJWT 2.12.1 (PYSEC-2026-175/177/178/179,
fixed in 2.13.0) and transitive certifi 2023.11.17 (PYSEC-2024-230, fixed
in 2024.7.4). Bump PyJWT and pin certifi to its latest release to enforce
the resolved version.
2026-06-02 22:34:36 +03:00
3252a8 36e0e4f462 fix: defer expiry reminders for trial and bonus subscriptions
Trial and registration/referral-bonus subscriptions usually last only a
few days, so the multi-day ending-soon reminders fired almost the moment
they were granted and needlessly alarmed newcomers.

Track this with a new subscription flag (suppress_early_expiry_notifications,
migration 0035): trial activation, referral welcome bonus and inviter bonus
grants set it, while a real paid purchase clears it on upsert. While set, the
notification worker skips the day-before stages but still sends the
hours-before reminder and the expiry/after-expiry notices, so users are
warned shortly before access ends. Once they pay for a full subscription the
complete reminder spectrum resumes.
2026-06-02 19:53:13 +03:00
3252a8 e15d6559aa docs: add remnashop migration to sidebar
The migrations/remnashop guide existed in docs/ but was missing from the
docs-site navigation. Add its sidebar entry after remnawave-tg-shop and a
curated sync description so it renders with the rest of the migrations.
2026-06-02 19:36:44 +03:00
3252a8 dc66cf6a06 ci: guard demo settings manifest against drift
Add a PR job that installs the backend deps and runs the
settings-manifest drift guard, so a change to admin_settings_manifest.py
without regenerating settingsManifest.generated.json fails CI instead of
silently leaving the docs demo Settings screen stale.
2026-06-02 19:31:06 +03:00
3252a8 70e6fa382b fix(docs-demo): sync admin settings sections with manifest
The demo Settings screen was fed by a frozen snapshot baked into the
externally generated demoDataset.js, so it drifted from the real
manifest: the Remnawave Panel (plus System and Migrations) sections
were missing and trial/checkout/common still showed as top-level
sections instead of subsections.

Generate frontend/src/lib/webapp/settingsManifest.generated.json from
manifest_payload() (the same source the live /admin/settings endpoint
uses) via scripts/export_settings_manifest.py, and build the demo
section structure from it, overlaying realistic demo values per field
key. A pytest drift guard fails if the Python manifest changes without
regenerating the snapshot, so the demo stays in sync going forward.
2026-06-02 19:18:11 +03:00
3252a8 20f1228218 chore: update custom themes for new webapp surfaces
Bring the ASCII, Windows 95 and light themes up to date with the
webapp changes since the last theme refresh:

- Flatten/bevel the new Telegram notifications banner icon badge.
- Square off the premium-server and referral-tariff dropdown help
  pills and bevel the standalone referral surfaces (win95).
- Fix the selected-language check vanishing on the highlighted row
  (black on ASCII, inverted bitmap on win95).
- Square the login-screen language trigger and render flag emoji
  monochrome in the ASCII console palette.
- Add a soft drop shadow to the banner on the light theme.

Bump assets_version for all three CSS themes and update the theme
config tests accordingly.
2026-06-02 18:44:30 +03:00
3252a8andGitHub e4f523a79d Merge pull request #23 from 3252a8/feature/migration-remnashop
remnashop migration feature, install wizard
2026-06-02 15:59:40 +03:00
3252a8 8e5124e4cf style: apply prettier formatting to UserDetailModal 2026-06-02 15:57:04 +03:00
3252a8 4d86015d25 style: apply ruff formatting 2026-06-02 15:32:19 +03:00
3252a8 c4d2d0a2fe fix: wrap long route contract line 2026-06-02 15:26:37 +03:00
3252a8 2c3f2c32bc Merge branch 'dev' into feature/migration-remnashop 2026-06-02 14:48:11 +03:00
3252a8 21f9ed803d fix(webapp): clarify expiring subscription countdown 2026-06-02 14:33:28 +03:00
3252a8 966a18045d fix(bot): resolve required channel join link 2026-06-02 14:10:49 +03:00
3252a8 4263cb7c99 fix(webapp): show expiring subscription countdown 2026-06-02 14:03:14 +03:00
3252a8 1ee8af338c fix(admin): show trial history and log activations 2026-06-02 12:57:15 +03:00
3252a8 ee840a7e6d fix(admin): keep mobile extend button full height 2026-06-02 12:43:57 +03:00
3252a8 8f009c8caf fix: show email avatars without Telegram 2026-06-02 12:33:59 +03:00
3252a8 4d7577f4ec fix: harden Remnawave panel timeouts 2026-06-02 12:29:43 +03:00
3252a8 e9e028fc00 fix: narrow Remnashop settings import 2026-06-02 11:31:03 +03:00
3252a8 9508eece5f feat: import Remnashop payment settings 2026-06-02 11:21:26 +03:00
3252a8 2ccead9b49 refactor: consolidate migration installer 2026-06-02 10:57:28 +03:00
3252a8 0473829f7a fix: prepare writable data mount in installer 2026-06-02 10:30:43 +03:00
3252a8 162e9e20b5 fix: keep installer from preparing data directories 2026-06-02 10:21:08 +03:00
3252a8 3b40369606 feat: add legacy tg-shop migration to installer 2026-06-02 10:17:50 +03:00
3252a8 d22ed238b4 feat: add shell install wizard 2026-06-02 10:08:59 +03:00
3252a8 5e47a1496b feat: add Remnashop migration import
Add the Remnashop legacy importer, compatibility tables, admin toggles, referral and promo lookup compatibility, and tests for the migration flow.
2026-06-02 00:13:54 +03:00
3252a8 56796d9f22 feat(admin): add never-subscribed broadcast target with audience counts
Add a broadcast audience for users who registered but never had any subscription or trial (no Subscription rows at all), backed by a new get_user_ids_without_any_subscription DAL helper and a 'never' target in the webapp broadcast route.

Add GET /api/admin/broadcast/audience-counts so the audience dropdown shows the recipient count next to each option, with graceful fallback when counts are unavailable.
2026-06-01 23:37:59 +03:00
3252a8 619a13128c fix(admin): enlarge mobile extend control and reset trial button to 46px
On mobile the extend input/button and the reset-trial button rendered at 36px, which felt cramped for touch. Bump them to 46px in the max-width:640px block.
2026-06-01 23:29:50 +03:00
3252a8 023ab375ec fix(admin): hide users filter toggle on desktop
The toggle carried both .admin-btn and .admin-users-filter-toggle, which tied on specificity with the base button display rule and kept the button visible on desktop. Scope the show/hide rules to .admin-btn.admin-users-filter-toggle so it only appears on mobile.
2026-06-01 23:22:22 +03:00
3252a8andGitHub 9393962510 Merge pull request #22 from 3252a8/dev
Multicurrency (future-proof feature), panel dry-run mode (dev feature), install telemetry and admin user tools
2026-06-01 22:55:30 +03:00
3252a8 2c32e81638 fix: build dry-run log entirely from constant labels
CodeQL still traced taint through the previous helpers: the endpoint
regex fallback returned source-derived text and the payload dict keys
flowed verbatim into the log. Resolve both by sourcing every logged
token from literal lookup tables — endpoints map to constant path
labels and payload keys map through a field-name allow-list, with
unknown keys/paths collapsing to <field>/<other>. No source-derived
string (key, value or id) reaches the logger.
2026-06-01 22:53:48 +03:00
3252a8 0032c1804b fix: drop clear-text values and ids from panel dry-run logs
Rebuild logged endpoints from constant path templates and reduce every
payload leaf to a JSON type token, so user/squad UUIDs and PII (email,
telegramId) can never reach the dry-run log as clear text. Resolves
CodeQL py/clear-text-logging-sensitive-data findings.
2026-06-01 22:48:21 +03:00
3252a8andGitHub a98ea65ef4 Merge pull request #21 from 3252a8/feature/ui-improvements
Improve admin UX, trial reset behavior, and channel subscription checks
2026-06-01 22:29:24 +03:00
3252a8 5218ede0f1 fix: normalize required channel checks 2026-06-01 22:13:35 +03:00
3252a8 5b19ba2c2f fix: keep deeplink gateway open during app prompt 2026-06-01 20:34:49 +03:00
3252a8 687fc03e8c fix: reset trial eligibility from web admin 2026-06-01 19:30:16 +03:00
3252a8 74272039c5 feat: add login language selector 2026-06-01 19:06:27 +03:00
3252a8 eadb86faf2 fix: restore mobile admin language select 2026-06-01 18:33:45 +03:00
3252a8 4fa18a1262 fix: enlarge mobile renewal button 2026-06-01 18:17:02 +03:00
3252a8 7d3178bd48 feat: improve admin user filters 2026-06-01 18:04:08 +03:00
3252a8andGitHub 88d99fb578 Merge pull request #20 from 3252a8/feature/users-details
Enhance admin users and subscription workflows
2026-06-01 15:59:52 +03:00
3252a8 8366a73575 style: format admin users api 2026-06-01 15:58:28 +03:00
3252a8 dcdddeb1c5 feat: add sortable admin user columns 2026-06-01 15:53:03 +03:00
3252a8 d7840d3a86 feat: expose admin user list metrics 2026-06-01 15:52:54 +03:00
3252a8 6a40d0c9ce feat: add expired subscription broadcast target 2026-06-01 15:05:10 +03:00
3252a8andGitHub 3d579b12d4 Merge pull request #19 from 3252a8/feature/telemetry
Feature/telemetry
2026-06-01 14:24:28 +03:00
3252a8 0d932b0915 style: ruff format panel dry-run service 2026-06-01 14:22:33 +03:00
3252a8 2b763efef9 fix: redact secrets and ids from panel dry-run logs
CodeQL flagged clear-text logging of sensitive information in the panel
dry-run logger: it dumped the full request payload (which can include
proxy credentials like trojanPassword/ssPassword and PII such as email
and telegramId) and the raw endpoint (embedding user UUIDs).

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

- Share version resolution in bot/utils/app_version.py so the admin
  sidebar and the beacon report the same build version
- TelemetryWorker sends an opaque install id plus coarse facts only
  (version, OS/arch, python, locale, enabled providers, user-count
  range); never tokens, domains or user data
- Register the worker in main_worker.py behind a Redis single-flight lock
- Expose TELEMETRY_* settings and an Admin -> System manifest toggle
- Document the payload and opt-out in docs/configuration/telemetry.md
- Cover bucketing, payload shape and anonymity with tests
2026-06-01 14:14:02 +03:00
3252a8 5bb1400917 chore: improve tariff admin layout 2026-06-01 11:55:41 +03:00
3252a8andGitHub 63ec3a6152 Merge pull request #18 from 3252a8/feature/multicurrency
Add ability to use different currency than rub
2026-06-01 11:26:18 +03:00
3252a8 3896c455b8 fix: decouple docs email previews from bot deps 2026-06-01 11:23:45 +03:00
3252a8 d97afbec18 fix: apply ruff formatting 2026-06-01 11:16:28 +03:00
3252a8 21079f78dc fix: satisfy ruff line length 2026-06-01 11:11:15 +03:00
3252a8 3e922d8edb docs: document panel dry-run development mode 2026-06-01 10:35:22 +03:00
3252a8 391487811b feat: add remnawave panel dry-run mode 2026-06-01 10:32:38 +03:00
3252a8 939bc37995 Merge branch 'dev' into feature/multicurrency 2026-06-01 08:18:25 +03:00
3252a8 c2344824dc feat: show referral relationships in admin user cards 2026-06-01 00:36:05 +03:00
3252a8 7cf1d577f3 fix: restore pending email code screens 2026-05-31 23:27:38 +03:00
3252a8 ecf779763c feat: audit outbound user notifications 2026-05-31 23:05:40 +03:00
3252a8 1578a9da36 fix: align traffic top-up flows and unlimited overrides 2026-05-31 23:05:29 +03:00
3252a8 80e5f0c80d feat: add multicurrency tariff payments 2026-05-31 22:17:28 +03:00
3252a8 df8f2636d2 docs: render email previews from templates 2026-05-31 15:08:22 +03:00
3252a8 45543983c2 fix: run migrations after database restore 2026-05-31 14:57:32 +03:00
3252a8 b531d4de3b docs: add email preview demo section 2026-05-31 14:41:40 +03:00
3252a8 eda5d3e633 fix: localize support email templates 2026-05-31 14:41:26 +03:00
3252a8 e4ef52df8b ci: tag dev images only with dev (drop per-commit dev-<sha> tags) 2026-05-31 07:59:18 +03:00
3252a8andGitHub 3e1f1cb787 Merge pull request #15 from 3252a8/dev
Split subscription stats by access type, CI/CD pipelines and notification/docs-demo fixesDev
2026-05-31 00:30:11 +03:00
3252a8 74da8ab98e chore: compact admin subscription stats cards 2026-05-31 00:26:42 +03:00
3252a8 19f7daff6c fix: stabilize docs demo runtime routing 2026-05-31 00:25:23 +03:00
3252a8 cf3af17243 fix: avoid Telegram notification probe messages 2026-05-31 00:17:20 +03:00
3252a8 93353db511 feat: split subscription stats by access type 2026-05-31 00:08:31 +03:00
3252a8andGitHub e7f93a5f47 Merge pull request #14 from 3252a8/dependabot/npm_and_yarn/frontend/npm_and_yarn-de2076f775
chore(deps-dev): bump svelte from 5.55.5 to 5.56.0 in /frontend in the npm_and_yarn group across 1 directory
2026-05-30 23:26:04 +03:00
3252a8andGitHub c2ab881be2 Merge pull request #13 from 3252a8/feature/ci-cd-workflows
ci: add GitHub Actions for image builds, PR checks and security scans
2026-05-30 23:22:51 +03:00
3252a8 4a84bba697 ci: use v-prefixed trivy-action tag (v0.36.0) 2026-05-30 23:20:22 +03:00
3252a8 cda3b741a1 ci: fix workflow failures on PR
- drop permissions block from reusable build workflow so callers set token
  scope (fixes PR-checks startup failure: ci.yml grants only contents:read
  while the reusable demanded packages:write)
- remove codeql.yml: repo already uses CodeQL default setup, which conflicts
  with an advanced configuration
- pin trivy-action to 0.36.0 (0.28.0 tag does not exist; <0.35.0 is the
  compromised supply-chain release flagged by dependency-review)
- make pip-audit and npm audit informational (continue-on-error); they flag
  upstream/transitive advisories, dependency-review stays the PR gate
2026-05-30 23:14:46 +03:00
dependabot[bot]andGitHub 2e82febdcc chore(deps-dev): bump svelte
Bumps the npm_and_yarn group with 1 update in the /frontend directory: [svelte](https://github.com/sveltejs/svelte/tree/HEAD/packages/svelte).


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

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-30 20:09:10 +00:00
3252a8 bd7710d03c ci: add GitHub Actions for image builds, PR checks and security scans
- dev-images: build/push backend, worker, frontend to ghcr.io and Docker
  Hub on every push to dev (tags: dev, dev-<sha>)
- release-images: same images on v* tag push (tags: latest, <version>)
- PR checks (into main/dev): ruff lint+format, eslint+prettier, no-push
  Docker build of all targets
- CodeQL (python, js/ts), dependency-review, pip-audit, npm audit, Trivy fs
- pin .github/workflows/*.yml to LF
2026-05-30 22:57:41 +03:00
3252a8andGitHub 1a66d67e44 Merge pull request #12 from 3252a8/dev
Telegram delivery tracking, subscription lifecycle mirroring and per-tariff referral bonuses
2026-05-30 22:28:57 +03:00
3252a8 c1e2fe2c95 chore: improve admin logs user cards 2026-05-30 22:15:33 +03:00
3252a8 ea4ee4c4a7 fix: restore admin content scrolling 2026-05-30 21:49:40 +03:00
3252a8 fbc3e193bf chore: improve custom scroll areas 2026-05-30 21:41:32 +03:00
3252a8 3541f2f78b feat: block crawlers from production webapp 2026-05-30 21:25:03 +03:00
3252a8 acc222da41 feat: prompt users to start Telegram bot for notifications 2026-05-30 21:15:17 +03:00
3252a8 8c0e778388 chore: standardize web controls 2026-05-30 12:16:02 +03:00
3252a8 c6c5352813 fix: format premium traffic limits from bytes 2026-05-30 12:00:01 +03:00
3252a8 067d6fb59f chore: clarify panel webhook subscription logs 2026-05-30 11:52:07 +03:00
3252a8 c3e55bc853 feature: sync user email notifications 2026-05-30 11:46:07 +03:00
3252a8 09be82aad5 fix: improve subscription email renewal flow 2026-05-30 00:23:53 +03:00
3252a8 49781af921 feat: surface remnawave panel webhook settings 2026-05-29 23:55:12 +03:00
3252a8 604ba873dc docs: clarify remnawave panel webhook setup 2026-05-29 23:54:35 +03:00
3252a8 923ff9b208 fix: hide inactive devices usage summary 2026-05-29 23:23:55 +03:00
3252a8 7e7a2e58a2 fix: show telegram login without email auth 2026-05-29 23:09:49 +03:00
3252a8 e27886e15d docs: update referral bonus tariff docs 2026-05-29 23:00:18 +03:00
3252a8 7fe8e676cd feat: group referral bonus display by tariff 2026-05-29 22:55:01 +03:00
3252a8 6803c7801f fix: configure referral bonuses per tariff period 2026-05-29 22:30:31 +03:00
3252a8 cab963dcdc fix: prevent subscription worker deadlocks 2026-05-29 22:07:16 +03:00
3252a8 001e54cfe2 fix: preserve subscriptions on panel lookup failures 2026-05-29 21:46:42 +03:00
3252a8 5e257c0d3a chore: enforce frontend formatting line endings 2026-05-29 18:14:35 +03:00
3252a8 19f0f27a3b feat: mirror subscription lifecycle notifications 2026-05-29 18:08:28 +03:00
3252a8 6fbb8eebec fix: serve docs demo runtime in dev 2026-05-29 18:04:00 +03:00
3252a8 32616c80ab docs: add notifications table 2026-05-29 17:50:30 +03:00
3252a8 3186be1e3e docs: update deployment examples 2026-05-29 15:33:09 +03:00
3252a8 1329eb4fe2 fix: avoid docs demo redirect loop 2026-05-28 23:44:52 +03:00
3252a8 913b2d428e fix: handle docs demo app route 2026-05-28 23:33:40 +03:00
3252a8andGitHub 5892a72575 Merge pull request #7 from 3252a8/dev
Backups, docs demo, and Mini App improvements
2026-05-28 22:57:40 +03:00
3252a8 d299a3c524 feature: Add local subscription notification worker 2026-05-28 22:48:48 +03:00
3252a8 1c1ef06e29 fix: Fix trial settings persistence 2026-05-28 22:48:48 +03:00
3252a8 bea62d75a9 fix: persist account language changes 2026-05-28 22:07:43 +03:00
3252a8 e998b9ddd1 chore: compact traffic cards 2026-05-28 21:52:38 +03:00
3252a8 2f192447ce fix: hide premium traffic when regular limit is depleted 2026-05-28 21:00:06 +03:00
3252a8 e89ed870dc fix: compact mobile subscription status card 2026-05-28 19:44:50 +03:00
3252a8 6a722bfa17 refactor: share email code confirmation screen 2026-05-28 17:11:15 +03:00
3252a8 a96aaa763f feat: use admin identity in demo auth flow 2026-05-28 17:02:22 +03:00
3252a8 bbe05d7f57 feat: support telegram auth in demo flow 2026-05-28 16:49:23 +03:00
3252a8 da28b69461 feat: add demo auth flow 2026-05-28 16:40:16 +03:00
3252a8 cd469ae2bb feat: add demo device top-up flow 2026-05-28 15:54:35 +03:00
3252a8 9af49453f8 fix: refresh devices after billing updates 2026-05-28 15:54:30 +03:00
3252a8 e543665704 fix: add favicon to docs demo shell 2026-05-28 15:32:45 +03:00
3252a8 39c0696e53 fix: show activation dialog for forced actions 2026-05-28 15:29:58 +03:00
3252a8 c6cc4ab963 fix: use standard purchase copy in demo 2026-05-28 15:25:18 +03:00
3252a8 4dfe71f392 fix: align demo state flows 2026-05-28 15:21:55 +03:00
3252a8 cf05c6580d fix: keep demo mock switch on app shell 2026-05-28 15:05:48 +03:00
3252a8 d544ebd879 feat: add fullscreen docs demo routes 2026-05-28 14:59:54 +03:00
3252a8 7e0e8ff319 feat: enrich docs demo mock data 2026-05-28 14:59:43 +03:00
3252a8 613a9860a0 refactor: support prefixed webapp routes 2026-05-28 14:59:34 +03:00
3252a8 92f0277dad docs: refresh docs demo data 2026-05-28 13:10:07 +03:00
3252a8 749693078b fix: use app favicon fallback in admin appearance 2026-05-28 13:10:01 +03:00
3252a8 b566a34725 fix: mark depleted traffic on webapp home 2026-05-28 13:09:55 +03:00
3252a8 3ab6c18a76 fix: preserve account language on telegram auth 2026-05-28 11:31:45 +03:00
3252a8 52458eb223 fix: align tariff action in status card 2026-05-28 11:31:41 +03:00
3252a8 790946d89e fix: label docs demo admin version 2026-05-28 10:09:26 +03:00
3252a8 30c5d9ae11 fix: refine home tariff actions 2026-05-28 10:06:10 +03:00
3252a8 12e60629fe fix: preserve admin view on language change 2026-05-28 09:54:27 +03:00
3252a8 6c0d2932c3 docs: make demo bar collapsible on mobile 2026-05-28 09:12:58 +03:00
3252a8 ce19c7e2d1 docs: add mobile docs navigation menu 2026-05-28 08:21:58 +03:00
3252a8 93c9dde572 chore: harden docs demo config fetch 2026-05-28 08:21:54 +03:00
3252a8 d46324cb6a docs: fix mobile homepage navigation 2026-05-28 08:07:09 +03:00
3252a8 f153ca5bf4 fix: complete english localization refresh 2026-05-28 00:55:22 +03:00
3252a8 62e950f5c4 docs: refine static demo experience 2026-05-28 00:55:11 +03:00
3252a8 2b8bcd10b8 docs: make demo open fullscreen 2026-05-28 00:19:22 +03:00
3252a8 4c2ee19957 docs: add interactive demo page 2026-05-27 23:56:52 +03:00
3252a8 8217d13cd6 feat: add docs demo runtime build 2026-05-27 23:56:48 +03:00
3252a8 a2a887b898 fix: recognize backups admin route 2026-05-27 23:56:41 +03:00
3252a8 1620de9a01 refactor: isolate mock runtime from production webapp 2026-05-27 23:56:37 +03:00
3252a8 b6c6887842 chore: improve backup admin controls 2026-05-27 23:22:20 +03:00
3252a8 ef4b493e65 chore: change backup archive name style 2026-05-27 23:01:42 +03:00
3252a8 50dd1951c4 fix: prevent support message row stretching 2026-05-27 22:37:50 +03:00
3252a8 b3894e53e4 fix: tighten support chat message spacing 2026-05-27 22:21:44 +03:00
3252a8 ded044b4c0 fix: make support replies finish promptly 2026-05-27 22:21:24 +03:00
3252a8 bce78c4f28 chore: backup warning details 2026-05-27 19:08:49 +03:00
3252a8 75586d8883 fix: harden hwid provider payment edge cases 2026-05-27 18:31:14 +03:00
3252a8 f2fc335221 fix: anchor hwid pricing to paid period 2026-05-27 18:13:27 +03:00
3252a8 1c9e55d797 fix: wire subscription service for yookassa hwid payments 2026-05-27 18:13:22 +03:00
3252a8 25056602d8 fix: serialize webapp datetime payloads 2026-05-27 14:55:05 +03:00
3252a8 bd2e67059f chore: optimize docker layer caching 2026-05-27 14:44:58 +03:00
3252a8 fe34edfe73 chore: verify formatting checks 2026-05-27 14:29:45 +03:00
3252a8 4706be53ab feat: manual backup button 2026-05-27 14:25:37 +03:00
3252a8 4bd547f06a chore: tune backup function and related docs 2026-05-27 14:11:22 +03:00
3252a8 0250264fa0 fix: serialize HWID top-up validity dates 2026-05-27 13:59:03 +03:00
3252a8 3aede8fe95 feat: add backups feature 2026-05-27 13:53:30 +03:00
3252a8 e90988ea5c feat: persist support ticket drafts 2026-05-27 08:10:08 +03:00
3252a8 69d3400310 fix: stack admin badges on mobile 2026-05-27 07:55:05 +03:00
3252a8 6a44da1f31 fix: refresh trial settings in admin tariffs 2026-05-27 07:52:20 +03:00
3252a8 77c8785ec0 chore: set default webapp title to minishop 2026-05-27 07:22:34 +03:00
3252a8 f2c0a9f6d5 fix: preserve home logo scale after admin navigation 2026-05-27 07:13:53 +03:00
3252a8 f77a6ea46d feat: add default webapp brand assets 2026-05-27 07:07:31 +03:00
3252a8 a92ad32b23 docs: update docs 2026-05-27 00:11:24 +03:00
3252a8 a6af8f8415 docs: tune docs visual 2026-05-26 23:55:07 +03:00
3252a8andGitHub da9db7b7f8 Merge pull request #5 from 3252a8/dev
Update and structurize docs
2026-05-26 23:42:59 +03:00
3252a8 11048a6ed8 docs: refactor docs structure 2026-05-26 23:31:04 +03:00
3252a8 c3381bdd31 chore: add image publish and mirror sync scripts 2026-05-26 22:30:10 +03:00
3252a8 53f1cec401 docs: point repository links to GitHub 2026-05-26 22:21:00 +03:00
3252a8 bf46a15446 docs: update GitLab repository links 2026-05-26 21:57:49 +03:00
3252a8 0df52d0235 docs: refactor docs structure 2026-05-26 21:45:44 +03:00
3252a8 0c167c8f09 docs: refactor docs structure 2026-05-26 17:26:46 +03:00
3252a8 804ccdabec docs: use nova starlight theme 2026-05-26 16:18:15 +03:00
3252a8 5833b18052 docs: docs-site initial 2026-05-26 16:04:04 +03:00
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
444 changed files with 230979 additions and 7278 deletions
+45
View File
@@ -13,9 +13,16 @@ scratch/
*.local.*
node_modules/
frontend/node_modules/
docs-site/node_modules/
docs-site/.astro/
docs-site/dist/
docs-site/public/demo/runtime/
docs-site/src/content/docs/
frontend-nginx-dist/
deploy/compose/docker-compose-dev.yml
data/*
!data/tariffs.example.json
!data/locales-overrides.example.json
# CI
@@ -30,10 +37,48 @@ 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
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
# Byte-compiled / optimized / DLL files
**/__pycache__/
+20 -3
View File
@@ -56,7 +56,10 @@ PANEL_API_URL=https://panel.yourdomain.tld/api
PANEL_API_KEY=
# Shared secret for validating incoming Remnawave webhooks.
# Use the same value when configuring the webhook in Remnawave panel.
# Create or set this secret in Remnawave Panel, then paste the same value here
# or into Admin -> System -> Settings -> Remnawave Panel.
# In Remnawave Panel, set WEBHOOK_URL to WEBHOOK_BASE_URL + /webhook/panel,
# for example: https://app.example.com/webhook/panel
PANEL_WEBHOOK_SECRET=
# Host port that publishes the backend webhook server from Docker Compose.
@@ -68,5 +71,19 @@ WEB_SERVER_PORT=8080
FRONTEND_PORT=8082
# Reverse proxy IPs/CIDRs trusted for X-Forwarded-For.
# Keep loopback for local proxy; add your proxy network if needed.
TRUSTED_PROXIES=127.0.0.1,::1
# Keep loopback and private network ranges so payment provider IP allowlists
# see the original webhook sender behind Docker/LAN/Kubernetes proxies.
TRUSTED_PROXIES=127.0.0.1,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,fc00::/7
# ─── Anonymous install telemetry (opt-out) ──────────────────────────────
# Once a day the worker sends a single anonymous "heartbeat" so the project
# maintainer can see how many installs are active and which versions/OSes are
# used. It contains an opaque random install id and coarse facts only:
# version, official/custom image provenance, OS/arch, Python version, language,
# enabled payment providers and a user-count RANGE (e.g. "51-200"). No bot
# token, domain, user data or any personal information is ever sent.
# Full details: docs/configuration/telemetry.md
#
# Set to False to disable, or toggle it any time in Admin -> System ->
# "Anonymous install analytics" (applies without a restart).
TELEMETRY_ENABLED=True
+7
View File
@@ -1,2 +1,9 @@
.gitattributes text eol=lf
*.sh text eol=lf
.github/workflows/*.yml text eol=lf
deploy/docker/frontend/*.sh text eol=lf
frontend/src/*.js text eol=lf
frontend/src/**/*.js text eol=lf
frontend/src/**/*.svelte text eol=lf
frontend/scripts/*.mjs text eol=lf
frontend/scripts/**/*.mjs text eol=lf
+138
View File
@@ -0,0 +1,138 @@
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 the selected registries under the repository owner's namespace.
#
# Called by:
# - docker-dev.yml (tag_mode: dev, push: true) on pushes to dev
# - docker-release.yml (tag_mode: release, push: true) on release tags
# - 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
publish_dockerhub:
description: "Include Docker Hub tags and login when pushing"
type: boolean
default: 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 && inputs.publish_dockerhub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Resolve image namespaces
id: image_namespaces
env:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
run: |
github_owner="${{ github.repository_owner }}"
echo "github_owner=${github_owner,,}" >> "$GITHUB_OUTPUT"
dockerhub_owner="${github_owner,,}"
if [ "${{ inputs.push }}" = "true" ] && [ "${{ inputs.publish_dockerhub }}" = "true" ]; then
if [ -z "$DOCKERHUB_USERNAME" ]; then
echo "::error::DOCKERHUB_USERNAME secret is required for Docker Hub publishing"
exit 1
fi
dockerhub_owner="${DOCKERHUB_USERNAME,,}"
fi
echo "dockerhub_owner=$dockerhub_owner" >> "$GITHUB_OUTPUT"
- name: Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: |
name=${{ steps.image_namespaces.outputs.dockerhub_owner }}/${{ matrix.image }},enable=${{ inputs.push && inputs.publish_dockerhub }}
name=ghcr.io/${{ steps.image_namespaces.outputs.github_owner }}/${{ matrix.image }},enable=true
tags: |
type=raw,value=dev,enable=${{ inputs.tag_mode == 'dev' }}
type=raw,value=latest,enable=${{ inputs.tag_mode == 'release' }}
type=raw,value=${{ steps.version.outputs.version }},enable=${{ inputs.tag_mode == 'release' }}
- name: Build${{ inputs.push && ' & push' || '' }} ${{ matrix.image }}
uses: docker/build-push-action@v6
with:
context: .
file: deploy/docker/Dockerfile
target: ${{ matrix.target }}
platforms: linux/amd64
push: ${{ inputs.push }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
# The Dockerfile's version-builder appends a "-<branch>" suffix to the
# internal version string for non-main builds. Force "main" on release
# (the ref is the tag, not a branch) so release images stay un-suffixed.
build-args: |
GITHUB_REF_NAME=${{ inputs.tag_mode == 'release' && 'main' || github.ref_name }}
REMNAWAVE_MINISHOP_BUILD_PROVENANCE=${{ github.repository == '3252a8/remnawave-minishop' && 'official' || 'custom' }}
cache-from: type=gha,scope=${{ matrix.target }}
cache-to: type=gha,mode=max,scope=${{ matrix.target }}
provenance: false
+88
View File
@@ -0,0 +1,88 @@
name: PR checks
# Runs on pull requests into main (typically from dev) and into dev (typically
# from feature/* branches): lint + format checks, a demo settings-manifest
# drift guard, 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
demo-manifest:
name: Demo settings manifest in sync
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"
cache: pip
cache-dependency-path: backend/requirements.txt
- name: Install backend deps + pytest
run: pip install -r backend/requirements.txt pytest
# Fails if admin_settings_manifest.py changed without regenerating the
# demo snapshot. Fix: `python scripts/export_settings_manifest.py` then
# `npx --prefix frontend prettier --write \
# src/lib/webapp/settingsManifest.generated.json`, and commit the result.
- name: Check demo settings manifest is in sync
run: python -m pytest tests/test_settings_manifest_demo_sync.py -q
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 and Docker Hub tagged `dev`.
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
+18
View File
@@ -12,6 +12,13 @@ scratch/
node_modules/
.git/
# Documentation site build artifacts
docs-site/.astro/
docs-site/dist/
docs-site/public/demo/runtime/
docs-site/src/content/docs/
frontend-nginx-dist/
# WebApp build artifacts (regenerated by `npm run build:webapp` / Docker build)
bot/app/web/templates/subscription_webapp.css
bot/app/web/templates/subscription_webapp.js
@@ -29,6 +36,11 @@ bot/app/web/templates/subscription_webapp_admin.min.*.js.br
bot/app/web/templates/subscription_webapp_admin.min.*.js.gz
bot/app/web/templates/subscription_webapp_admin.*.css.br
bot/app/web/templates/subscription_webapp_admin.*.css.gz
bot/app/web/templates/subscription_webapp_docs_demo.css
bot/app/web/templates/subscription_webapp_docs_demo.js
bot/app/web/templates/subscription_webapp_docs_demo.*.css
bot/app/web/templates/subscription_webapp_docs_demo.*.css.br
bot/app/web/templates/subscription_webapp_docs_demo.*.css.gz
backend/bot/app/web/templates/subscription_webapp.css
backend/bot/app/web/templates/subscription_webapp.js
backend/bot/app/web/templates/subscription_webapp.min.*.js
@@ -45,6 +57,11 @@ backend/bot/app/web/templates/subscription_webapp_admin.min.*.js.br
backend/bot/app/web/templates/subscription_webapp_admin.min.*.js.gz
backend/bot/app/web/templates/subscription_webapp_admin.*.css.br
backend/bot/app/web/templates/subscription_webapp_admin.*.css.gz
backend/bot/app/web/templates/subscription_webapp_docs_demo.css
backend/bot/app/web/templates/subscription_webapp_docs_demo.js
backend/bot/app/web/templates/subscription_webapp_docs_demo.*.css
backend/bot/app/web/templates/subscription_webapp_docs_demo.*.css.br
backend/bot/app/web/templates/subscription_webapp_docs_demo.*.css.gz
tmp
.claude
@@ -63,3 +80,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.
+42 -26
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,9 @@ Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи
- просмотр статуса подписки, даты окончания, ссылки подключения и трафика;
- покупка подписок, пакетов трафика, обычная и premium-докупка трафика, докупка устройств по настроенному каталогу тарифов;
- Web App / Mini App с входом через Telegram или email;
- встроенные инструкции установки в Mini App: личный экран `/install` и публичная ссылка `/s/<token>` для передачи инструкции;
- пробный период, промокоды и реферальная программа;
- оплата через YooKassa, FreeKassa, Platega, SeverPay, Wata, CryptoPay, Heleket и Telegram Stars;
- оплата через YooKassa, FreeKassa, Platega, SeverPay, Wata, CryptoPay, Heleket, PayKilla, LAVA и Telegram Stars;
- тикеты поддержки в Web App и внешняя ссылка на поддержку;
- раздел "Мои устройства" при включенном `MY_DEVICES_SECTION_ENABLED`.
@@ -25,20 +26,25 @@ Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи
- статистика пользователей, подписок, платежей и синхронизации с Remnawave;
- список пользователей с поиском, фильтрами и колонкой premium-трафика;
- блокировка пользователей, поддержка через тикеты, рассылки, промокоды, логи действий и настройка разрешенных параметров приложения поверх `.env`;
- редактор JSON-каталога тарифов с period/traffic-моделями, Internal Squads, premium-сквадами и HWID-пакетами;
- редактор JSON-каталога тарифов с моделями на срок/по трафику, Internal Squads, premium-сквадами и HWID-пакетами;
- настройки инструкций подключения: чтение конфига Subscription Page из Remnawave Panel, опциональное JSON-переопределение и переключатель поведения кнопок бота;
- ручная синхронизация пользователей и подписок с панелью.
## Документация
- [Настройка окружения](docs/configuration.md) - bootstrap `.env` и рекомендуемая настройка через Web App админку.
- [Переменные `.env`](docs/env-vars.md) - полный справочник всех env-ключей по разделам.
- [Тарифы](docs/tariffs.md) - каталог тарифов, period- и traffic-модели, обычные и premium-докупки, premium-сквады, смена тарифа, HWID-лимиты и обработка трафика.
- [Админ-панель](docs/admin.md) - права доступа, настройки, редактор тарифов, premium-сквады и сохранение JSON-каталога.
- [Web App / Mini App](docs/webapp.md) - отдельный порт, домен, Telegram OAuth, email-вход и реферальные ссылки.
- [Поддержка](docs/support.md) - тикеты в Mini App, входящий список админки, уведомления, лимиты и внешняя ссылка поддержки.
- [Темы Web App](docs/webapp-themes.md) - кастомные темы, настройка внешнего вида, логотипы, CSS/ассеты и пайплайн создания новой темы.
- [Развертывание](docs/deployment.md) - Docker Compose, reverse proxy, Nginx, Caddy, вебхуки, запуск из образа и обновление версии (`IMAGE_TAG`).
- [Миграция с remnawave-tg-shop](docs/migration-to-minishop.md) - перенос данных из прежнего стека.
- [Входная страница документации](docs/index.md) - маршрут по установке, настройке, платежам, админке и диагностике.
- [Развертывание](docs/getting-started/deployment.md) - Docker Compose, Caddy, Nginx, Pangolin/Newt и запуск без обратного прокси.
- [Настройка окружения](docs/getting-started/configuration.md) - bootstrap `.env` и рекомендуемая настройка через Web App админку.
- [Переменные `.env`](docs/configuration/env-vars.md) - полный справочник всех env-ключей по разделам.
- [Бэкапы и восстановление](docs/features/backups.md) - автоматические архивы, Telegram-отправка и restore через админку.
- [Тарифы](docs/features/tariffs.md) - каталог тарифов, модели на срок и по трафику, обычные и premium-докупки, premium-сквады, смена тарифа, HWID-лимиты и обработка трафика.
- [Админ-панель](docs/features/admin-panel.md) - права доступа, настройки, редактор тарифов, premium-сквады и сохранение JSON-каталога.
- [Веб-приложение / Mini App](docs/features/web-app.md) - отдельный порт, домен, инструкции установки и реферальные ссылки.
- [Telegram-авторизация](docs/features/telegram-auth.md) и [вход по email](docs/features/email-login.md) - настройка BotFather/OAuth и SMTP-логина.
- [Поддержка пользователей / тикеты](docs/features/support.md) - тикеты в Mini App, входящий список админки, уведомления, лимиты и внешняя ссылка поддержки.
- [Темы Web App](docs/features/webapp-themes.md) - кастомные темы, настройка внешнего вида, логотипы, CSS/ассеты и пайплайн создания новой темы.
- [Миграции](docs/migrations/index.md) - готовые сценарии переноса с `remnawave-tg-shop` и Remnashop.
- [Миграция с remnawave-tg-shop](docs/migrations/remnawave-tg-shop.md) и [Remnashop](docs/migrations/remnashop.md) - сценарии через общий install wizard.
## Совместимость
@@ -84,16 +90,22 @@ docker compose logs -f backend worker frontend
- `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;
- `TRUSTED_PROXIES` - оставьте дефолт для Docker/Caddy/Nginx/Newt или укажите IP/CIDR своего reverse proxy, чтобы IP allowlist платежных webhook видел реального провайдера;
- остальные настройки удобнее задать в Web App админке.
После первого входа в админку настройте тарифы, платежные провайдеры, внешний вид, поддержку и уведомления через UI. Полный справочник env-переменных: [docs/env-vars.md](docs/env-vars.md).
В Remnawave Panel укажите `WEBHOOK_URL` как публичный адрес Minishop с путем `/webhook/panel`, например `https://app.example.com/webhook/panel`. Секрет вебхука задается в самой Remnawave Panel; это же значение вставьте в `PANEL_WEBHOOK_SECRET` в `.env` или в **Система -> Настройки -> Remnawave Panel** в админке.
Для каталога тарифов используется `TARIFFS_CONFIG_PATH` со значением по умолчанию `data/tariffs.json`. Пример формата лежит в [data/tariffs.example.json](data/tariffs.example.json), подробности - в [docs/tariffs.md](docs/tariffs.md).
После первого входа в админку настройте тарифы, платежные провайдеры, внешний вид, поддержку, уведомления и инструкции подключения через UI. Инструкции установки включены по умолчанию, читают Subscription Page config из Remnawave Panel и при проблемах с конфигом откатываются к обычной ссылке подключения. Полный справочник env-переменных: [docs/configuration/env-vars.md](docs/configuration/env-vars.md).
Если в Docker Compose включаете bind mount `./data:/app/data`, заранее создайте каталог и отдайте его пользователю контейнера. Это нужно для сохранения `data/tariffs.json`, каталога тем `data/themes`, кеша логотипа Web App и animated emoji:
Для каталога тарифов используется `TARIFFS_CONFIG_PATH` со значением по умолчанию `data/tariffs.json`. Пример формата лежит в [data/tariffs.example.json](data/tariffs.example.json), подробности - в [docs/features/tariffs.md](docs/features/tariffs.md).
В Docker этот файл должен быть доступен не только `backend` и `worker`, но и одноразовому сервису `migrate`: мигратор читает каталог тарифов при привязке существующих подписок к тарифу по умолчанию. В текущих compose-файлах весь `/app/data` уже смонтирован в `migrate`, `backend` и `worker`; если переносите compose вручную, сохраните одинаковый mount для всех трех сервисов.
В compose-примерах `/app/data` монтируется из папки `./data` рядом с `docker-compose.yml`. Заранее создайте каталог и отдайте его пользователю контейнера. Это нужно для сохранения `data/tariffs.json`, каталога тем `data/themes` и кеша логотипа Web App:
```bash
mkdir -p data/themes data/webapp-logo data/webapp-emoji
mkdir -p data/themes data/webapp-logo data/tariffs
touch data/locales-overrides.json
chown -R 10001:10001 data
chmod -R u+rwX data
```
@@ -107,19 +119,23 @@ 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`
## Поддержать проект
- Crypto: `USDT/Other ERC-20 0xeD506D44aae634fEc0E01C8835744fBedb7B2a44 (Ethereum/Polygon/Gnosis)`
- `docker.io/3252a8/remnawave-minishop-backend`
- `docker.io/3252a8/remnawave-minishop-worker`
- `docker.io/3252a8/remnawave-minishop-frontend`
@@ -17,6 +17,7 @@ from bot.middlewares.channel_subscription import ChannelSubscriptionMiddleware
from bot.middlewares.db_session import DBSessionMiddleware
from bot.middlewares.i18n import I18nMiddleware, get_i18n_instance
from bot.middlewares.profile_sync import ProfileSyncMiddleware
from bot.middlewares.update_antiflood import UpdateAntiFloodMiddleware
from config.settings import Settings
@@ -38,6 +39,7 @@ def build_dispatcher(
dp["i18n_instance"] = i18n_instance
dp["async_session_factory"] = async_session_factory
dp.update.outer_middleware(UpdateAntiFloodMiddleware(settings=settings))
dp.update.outer_middleware(DBSessionMiddleware(async_session_factory))
dp.update.outer_middleware(I18nMiddleware(i18n=i18n_instance, settings=settings))
dp.update.outer_middleware(ProfileSyncMiddleware())
+7 -2
View File
@@ -11,6 +11,7 @@ from bot.services.email_auth_service import EmailAuthService
from bot.services.lknpd_service import LknpdService
from bot.services.notification_service import NotificationService
from bot.services.panel_api_service import PanelApiService
from bot.services.panel_dry_run_api_service import PanelDryRunApiService
from bot.services.panel_webhook_service import PanelWebhookService
from bot.services.promo_code_service import PromoCodeService
from bot.services.referral_service import ReferralService
@@ -26,11 +27,15 @@ def build_core_services(
i18n: JsonI18n,
bot_username_for_default_return: str,
):
panel_service = PanelApiService(settings)
panel_service = (
PanelDryRunApiService(settings)
if bool(getattr(settings, "panel_dry_run_enabled", False))
else PanelApiService(settings)
)
subscription_service = SubscriptionService(settings, panel_service, bot, i18n)
referral_service = ReferralService(settings, subscription_service, bot, i18n)
promo_code_service = PromoCodeService(settings, subscription_service, bot, i18n)
email_auth_service = EmailAuthService(settings)
email_auth_service = EmailAuthService(settings, i18n)
notification_service = NotificationService(
bot,
settings,
+6
View File
@@ -6,8 +6,10 @@ 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,
health as _health,
logs as _logs,
panel as _panel,
payments as _payments,
@@ -19,6 +21,7 @@ from bot.app.web.admin_api_impl import (
sync as _sync,
tariffs as _tariffs,
themes as _themes,
translations as _translations,
users as _users,
)
@@ -26,6 +29,7 @@ _MODULES = (
_runtime,
_auth,
_common,
_health,
_stats,
_users,
_payments,
@@ -35,9 +39,11 @@ _MODULES = (
_broadcast,
_sync,
_ads,
_backups,
_settings,
_tariffs,
_themes,
_translations,
_panel,
_routes,
)
@@ -37,10 +37,11 @@ from bot.services.settings_override_service import (
from bot.utils import MessageContent, send_message_via_queue
from bot.utils.message_queue import get_queue_manager
from config.settings import Settings
from config.tariffs_config import TariffsConfig
from config.tariffs_config import TariffsConfig, default_payment_currency_code_for_settings
from db.dal import (
ad_dal,
app_settings_dal,
locale_overrides_dal,
message_log_dal,
panel_sync_dal,
payment_dal,
@@ -0,0 +1,181 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
import secrets
import subprocess
from bot.infra.redis import redis_lock
from bot.services.backup_restore_service import (
BACKUP_UPLOAD_MAX_BYTES,
BackupArchiveError,
BackupArchiveInfo,
BackupRestoreError,
BackupRestoreService,
)
from bot.services.backup_worker import BackupWorker
def _backup_archive_payload(archive) -> Dict[str, Any]:
return archive.to_payload()
async def _read_uploaded_backup_file(request: web.Request) -> BackupArchiveInfo:
settings: Settings = request.app["settings"]
service = BackupRestoreService(settings)
backup_dir = service.backup_dir()
temp_path: Optional[Path] = None
reader = await request.multipart()
try:
async for part in reader:
if part.name != "file":
continue
original_filename = part.filename or "backup.zip"
temp_path = backup_dir / f".upload-{secrets.token_urlsafe(12)}.zip.tmp"
size = 0
with temp_path.open("wb") as handle:
while True:
chunk = await part.read_chunk(size=1024 * 1024)
if not chunk:
break
size += len(chunk)
if size > BACKUP_UPLOAD_MAX_BYTES:
raise BackupArchiveError("Backup archive is too large")
handle.write(chunk)
if size <= 0:
raise BackupArchiveError("Uploaded archive is empty")
archive = service.import_uploaded_archive(temp_path, original_filename)
temp_path = None
return archive
finally:
if temp_path is not None and temp_path.exists():
try:
temp_path.unlink()
except OSError:
logger.warning("Failed to remove temporary backup upload %s", temp_path)
raise BackupArchiveError("file field is required")
async def admin_backups_list_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
settings: Settings = request.app["settings"]
try:
service = BackupRestoreService(settings)
archives = service.list_archives()
except OSError as exc:
logger.exception("Failed to list backup archives")
return _error(500, "backup_list_failed", str(exc))
return _ok(
{
"backup_dir": str(service.backup_dir()),
"archives": [_backup_archive_payload(archive) for archive in archives],
}
)
async def admin_backups_upload_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
content_type = (request.headers.get("Content-Type") or "").lower()
if not content_type.startswith("multipart/form-data"):
return _error(400, "invalid_backup_archive", "multipart file upload is required")
try:
archive = await _read_uploaded_backup_file(request)
except BackupArchiveError as exc:
return _error(400, "invalid_backup_archive", str(exc))
except OSError as exc:
logger.exception("Failed to save uploaded backup archive")
return _error(500, "backup_upload_failed", str(exc))
return _ok({"archive": _backup_archive_payload(archive)})
async def admin_backups_create_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
settings: Settings = request.app["settings"]
bot = request.app["bot"]
session_factory = request.app.get("async_session_factory")
worker = BackupWorker(settings, bot, session_factory=session_factory)
ttl_seconds = max(
60,
int(
max(
getattr(settings, "BACKUP_LOCK_TTL_SECONDS", 7200) or 7200,
getattr(settings, "BACKUP_PG_DUMP_TIMEOUT_SECONDS", 1800) or 1800,
)
),
)
try:
async with redis_lock(settings, "backup-worker", ttl_seconds=ttl_seconds) as acquired:
if not acquired:
return _error(409, "backup_create_busy", "Backup or restore is already running")
await worker.refresh_settings()
result = await worker.create_and_send_backup(backup_type="manual")
archive = BackupRestoreService(settings).inspect_archive(result.archive_path)
except BackupArchiveError as exc:
return _error(400, "invalid_backup_archive", str(exc))
except (OSError, RuntimeError, subprocess.SubprocessError, TimeoutError) as exc:
logger.exception("Manual backup creation failed")
return _error(500, "backup_create_failed", str(exc))
except Exception as exc:
logger.exception("Manual backup creation failed")
return _error(500, "backup_create_failed", str(exc))
return _ok(
{
"result": result.to_payload(),
"archive": _backup_archive_payload(archive),
}
)
async def admin_backups_restore_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
settings: Settings = request.app["settings"]
payload = await _read_json(request)
archive_name = str(payload.get("archive_name") or "").strip()
restore_database = bool(payload.get("restore_database"))
restore_compose = bool(payload.get("restore_compose"))
confirm = bool(payload.get("confirm"))
if not confirm:
return _error(400, "restore_confirmation_required")
service = BackupRestoreService(settings)
ttl_seconds = max(
60,
int(
max(
getattr(settings, "BACKUP_LOCK_TTL_SECONDS", 7200) or 7200,
getattr(settings, "BACKUP_PG_RESTORE_TIMEOUT_SECONDS", 1800) or 1800,
)
),
)
try:
async with redis_lock(settings, "backup-worker", ttl_seconds=ttl_seconds) as acquired:
if not acquired:
return _error(409, "backup_restore_busy", "Backup or restore is already running")
result = await service.restore_archive(
archive_name,
restore_database=restore_database,
restore_compose=restore_compose,
)
except BackupArchiveError as exc:
return _error(400, "invalid_backup_archive", str(exc))
except BackupRestoreError as exc:
logger.exception("Backup restore failed")
return _error(500, "backup_restore_failed", str(exc))
except (OSError, subprocess.SubprocessError, TimeoutError) as exc:
logger.exception("Backup restore failed")
return _error(500, "backup_restore_failed", str(exc))
finally:
try:
from db import database_setup
if restore_database and database_setup.async_engine is not None:
await database_setup.async_engine.dispose()
except Exception:
logger.exception("Failed to dispose DB engine after backup restore")
return _ok({"result": result.to_payload()})
+190 -2
View File
@@ -1,5 +1,165 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
from .common import _panel_user_connection_activity
import asyncio
from collections import defaultdict
from bot.utils.ttl_cache import AsyncTTLCache
BROADCAST_TARGET_ACTIVE_NEVER_CONNECTED = "active_never_connected"
BROADCAST_TARGETS = {
"all",
"active",
"inactive",
"expired",
"never",
BROADCAST_TARGET_ACTIVE_NEVER_CONNECTED,
}
PANEL_ACTIVITY_LOOKUP_CONCURRENCY = 10
_ADMIN_BROADCAST_AUDIENCE_COUNT_CACHES: Dict[tuple[int, int], AsyncTTLCache] = {}
def _resolve_panel_service(request: web.Request) -> Any:
subscription_service = request.app.get("subscription_service")
return getattr(subscription_service, "panel_service", None)
async def _active_subscription_panel_uuids_by_user(
session: AsyncSession,
) -> Dict[int, List[str]]:
now = datetime.now(timezone.utc)
stmt = (
select(Subscription.user_id, Subscription.panel_user_uuid)
.join(User, Subscription.user_id == User.user_id)
.where(
User.is_banned == False,
Subscription.is_active == True,
Subscription.end_date > now,
Subscription.panel_user_uuid.is_not(None),
Subscription.panel_user_uuid != "",
)
.order_by(Subscription.user_id.asc(), Subscription.end_date.desc())
)
result = await session.execute(stmt)
grouped: Dict[int, List[str]] = defaultdict(list)
seen: Dict[int, set[str]] = defaultdict(set)
for user_id, panel_uuid in result.all():
user_id_int = int(user_id)
panel_uuid_str = str(panel_uuid or "").strip()
if panel_uuid_str and panel_uuid_str not in seen[user_id_int]:
grouped[user_id_int].append(panel_uuid_str)
seen[user_id_int].add(panel_uuid_str)
return dict(grouped)
async def _panel_connection_status(panel_service: Any, panel_uuid: str) -> str:
try:
panel_user = await panel_service.get_user_by_uuid(panel_uuid)
except Exception as exc:
logger.warning("Failed to fetch panel user activity uuid=%s: %s", panel_uuid, exc)
return "unknown"
activity = _panel_user_connection_activity(panel_user)
return str(activity.get("status") or "unknown")
async def _user_ids_with_active_subscription_never_connected(
session: AsyncSession,
panel_service: Any,
) -> List[int]:
panel_uuids_by_user = await _active_subscription_panel_uuids_by_user(session)
semaphore = asyncio.Semaphore(PANEL_ACTIVITY_LOOKUP_CONCURRENCY)
async def lookup(panel_uuid: str) -> str:
async with semaphore:
return await _panel_connection_status(panel_service, panel_uuid)
panel_uuids = list(
dict.fromkeys(
panel_uuid
for user_panel_uuids in panel_uuids_by_user.values()
for panel_uuid in user_panel_uuids
)
)
statuses_by_uuid = dict(
zip(
panel_uuids,
await asyncio.gather(*(lookup(uuid) for uuid in panel_uuids)),
)
)
user_ids: List[int] = []
for user_id, panel_uuids in panel_uuids_by_user.items():
statuses = [statuses_by_uuid.get(panel_uuid, "unknown") for panel_uuid in panel_uuids]
if statuses and all(status == "never" for status in statuses):
user_ids.append(user_id)
return user_ids
def _admin_broadcast_audience_counts_cache(settings: Settings) -> Optional[AsyncTTLCache]:
ttl_seconds = int(
getattr(settings, "ADMIN_BROADCAST_AUDIENCE_COUNTS_CACHE_TTL_SECONDS", 30) or 0
)
if ttl_seconds <= 0:
return None
cache_key = (id(settings), ttl_seconds)
cache = _ADMIN_BROADCAST_AUDIENCE_COUNT_CACHES.get(cache_key)
if cache is None:
cache = AsyncTTLCache(
ttl_seconds=ttl_seconds,
settings=settings,
namespace="admin:broadcast_audience_counts",
)
_ADMIN_BROADCAST_AUDIENCE_COUNT_CACHES[cache_key] = cache
return cache
async def _load_broadcast_audience_counts(
settings: Settings,
async_session_factory: sessionmaker,
panel_service: Any,
) -> Dict[str, Optional[int]]:
cache = _admin_broadcast_audience_counts_cache(settings)
if cache is None:
return await _load_broadcast_audience_counts_uncached(
async_session_factory,
panel_service,
)
cache_key = "with-panel" if panel_service is not None else "without-panel"
return await cache.get_or_load(
cache_key,
lambda: _load_broadcast_audience_counts_uncached(
async_session_factory,
panel_service,
),
)
async def _load_broadcast_audience_counts_uncached(
async_session_factory: sessionmaker,
panel_service: Any,
) -> Dict[str, Optional[int]]:
async with async_session_factory() as session:
counts: Dict[str, Optional[int]] = {
"all": await user_dal.count_all_active_users_for_broadcast(session),
"active": await user_dal.count_users_with_active_subscription_for_broadcast(session),
"inactive": await user_dal.count_users_without_active_subscription_for_broadcast(
session
),
"expired": await user_dal.count_users_with_expired_subscription_for_broadcast(session),
"never": await user_dal.count_users_without_any_subscription_for_broadcast(session),
BROADCAST_TARGET_ACTIVE_NEVER_CONNECTED: None,
}
if panel_service is not None:
counts[BROADCAST_TARGET_ACTIVE_NEVER_CONNECTED] = len(
await _user_ids_with_active_subscription_never_connected(
session,
panel_service,
)
)
return counts
async def admin_broadcast_route(request: web.Request) -> web.Response:
@@ -9,7 +169,7 @@ async def admin_broadcast_route(request: web.Request) -> web.Response:
target = str(payload.get("target") or "all").strip().lower()
if not text:
return _error(400, "empty_text")
if target not in {"all", "active", "inactive"}:
if target not in BROADCAST_TARGETS:
target = "all"
queue_manager = get_queue_manager()
@@ -18,10 +178,22 @@ async def admin_broadcast_route(request: web.Request) -> web.Response:
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
if target == "active":
if target == BROADCAST_TARGET_ACTIVE_NEVER_CONNECTED:
panel_service = _resolve_panel_service(request)
if panel_service is None:
return _error(503, "panel_service_unavailable")
user_ids = await _user_ids_with_active_subscription_never_connected(
session,
panel_service,
)
elif target == "active":
user_ids = await user_dal.get_user_ids_with_active_subscription(session)
elif target == "inactive":
user_ids = await user_dal.get_user_ids_without_active_subscription(session)
elif target == "expired":
user_ids = await user_dal.get_user_ids_with_expired_subscription(session)
elif target == "never":
user_ids = await user_dal.get_user_ids_without_any_subscription(session)
else:
user_ids = await user_dal.get_all_active_user_ids_for_broadcast(session)
@@ -52,3 +224,19 @@ async def admin_broadcast_route(request: web.Request) -> web.Response:
)
return _ok({"queued": sent, "failed": failed, "target": target})
async def admin_broadcast_audience_counts_route(request: web.Request) -> web.Response:
"""Return how many users each broadcast audience currently resolves to."""
_require_admin_user_id(request)
settings: Settings = request.app["settings"]
async_session_factory: sessionmaker = request.app["async_session_factory"]
panel_service = _resolve_panel_service(request)
counts = await _load_broadcast_audience_counts(
settings,
async_session_factory,
panel_service,
)
return _ok({"counts": counts})
+226 -12
View File
@@ -22,6 +22,175 @@ async def _read_json(request: web.Request) -> Dict[str, Any]:
return {}
_PANEL_LAST_CONNECTED_KEYS = (
"onlineAt",
"online_at",
"lastSeenAt",
"last_seen_at",
"lastConnectedAt",
"last_connected_at",
"lastConnectionAt",
"last_connection_at",
)
_PANEL_CONNECTION_MARKER_KEYS = (
*_PANEL_LAST_CONNECTED_KEYS,
"firstConnectedAt",
"first_connected_at",
"lastConnectedNodeUuid",
"last_connected_node_uuid",
)
_PANEL_CONNECTION_MARKER_OBJECT_KEYS = ("lastConnectedNode", "last_connected_node")
_PANEL_TRAFFIC_OBJECT_KEYS = ("userTraffic", "user_traffic", "traffic", "trafficStats")
_PANEL_TRAFFIC_USED_KEYS = (
"lifetimeUsedTrafficBytes",
"lifetime_used_traffic_bytes",
"usedTrafficBytes",
"used_traffic_bytes",
"trafficUsedBytes",
"traffic_used_bytes",
"downloadBytes",
"download_bytes",
"uploadBytes",
"upload_bytes",
)
def _panel_user_payload(panel_user_data: Any) -> Dict[str, Any]:
if not isinstance(panel_user_data, dict):
return {}
response = panel_user_data.get("response")
if isinstance(response, dict) and not any(
key in panel_user_data
for key in ("uuid", "shortUuid", "subscriptionUrl", "userTraffic", "status")
):
return response
return panel_user_data
def _coerce_panel_datetime(value: Any) -> Optional[str]:
if value is None or value is False:
return None
if isinstance(value, datetime):
return value.isoformat()
if isinstance(value, (int, float)):
if value <= 0:
return None
seconds = float(value) / 1000.0 if value > 10_000_000_000 else float(value)
try:
return datetime.fromtimestamp(seconds, tz=timezone.utc).isoformat()
except (OSError, OverflowError, ValueError):
return None
text = str(value).strip()
if not text or text.lower() in {"0", "null", "none", "never"}:
return None
if text.isdigit():
return _coerce_panel_datetime(int(text))
try:
parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
except ValueError:
return None
return parsed.isoformat()
def _coerce_panel_int(value: Any) -> Optional[int]:
try:
if value is None or value == "":
return None
return int(float(value))
except (TypeError, ValueError):
return None
def _panel_nested_dicts(panel_user: Dict[str, Any], keys: Tuple[str, ...]) -> List[Dict[str, Any]]:
out: List[Dict[str, Any]] = []
for key in keys:
value = panel_user.get(key)
if isinstance(value, dict):
out.append(value)
return out
def _panel_user_connection_containers(panel_user: Dict[str, Any]) -> List[Dict[str, Any]]:
traffic_containers = _panel_nested_dicts(panel_user, _PANEL_TRAFFIC_OBJECT_KEYS)
marker_containers = _panel_nested_dicts(
panel_user,
_PANEL_CONNECTION_MARKER_OBJECT_KEYS,
)
for traffic_container in traffic_containers:
marker_containers.extend(
_panel_nested_dicts(traffic_container, _PANEL_CONNECTION_MARKER_OBJECT_KEYS)
)
return [panel_user, *traffic_containers, *marker_containers]
def _panel_user_last_connected_at(panel_user_data: Any) -> Optional[str]:
panel_user = _panel_user_payload(panel_user_data)
if not panel_user:
return None
for container in _panel_user_connection_containers(panel_user):
for key in _PANEL_LAST_CONNECTED_KEYS:
connected_at = _coerce_panel_datetime(container.get(key))
if connected_at:
return connected_at
return None
def _panel_user_positive_traffic_bytes(panel_user: Dict[str, Any]) -> bool:
containers = [panel_user, *_panel_nested_dicts(panel_user, _PANEL_TRAFFIC_OBJECT_KEYS)]
for container in containers:
for key in _PANEL_TRAFFIC_USED_KEYS:
value = _coerce_panel_int(container.get(key))
if value is not None and value > 0:
return True
return False
def _panel_user_has_connection_marker(panel_user: Dict[str, Any]) -> bool:
for container in _panel_user_connection_containers(panel_user):
for key in _PANEL_CONNECTION_MARKER_KEYS:
if key in container:
return True
for container in [panel_user, *_panel_nested_dicts(panel_user, _PANEL_TRAFFIC_OBJECT_KEYS)]:
for key in _PANEL_CONNECTION_MARKER_OBJECT_KEYS:
if key in container:
return True
return False
def _panel_user_has_connected_marker_value(panel_user: Dict[str, Any]) -> bool:
for container in _panel_user_connection_containers(panel_user):
for key in (*_PANEL_LAST_CONNECTED_KEYS, "firstConnectedAt", "first_connected_at"):
if _coerce_panel_datetime(container.get(key)):
return True
for key in ("lastConnectedNodeUuid", "last_connected_node_uuid"):
if str(container.get(key) or "").strip():
return True
for container in [panel_user, *_panel_nested_dicts(panel_user, _PANEL_TRAFFIC_OBJECT_KEYS)]:
for key in _PANEL_CONNECTION_MARKER_OBJECT_KEYS:
marker = container.get(key)
if isinstance(marker, dict) and any(
str(value or "").strip() for value in marker.values()
):
return True
if marker and not isinstance(marker, dict):
return True
return False
def _panel_user_connection_activity(panel_user_data: Any) -> Dict[str, Any]:
panel_user = _panel_user_payload(panel_user_data)
last_connected_at = _panel_user_last_connected_at(panel_user)
if not panel_user:
return {"status": "unknown", "last_connected_at": None}
if last_connected_at or _panel_user_positive_traffic_bytes(panel_user):
return {"status": "connected", "last_connected_at": last_connected_at}
if _panel_user_has_connected_marker_value(panel_user):
return {"status": "connected", "last_connected_at": last_connected_at}
if _panel_user_has_connection_marker(panel_user):
return {"status": "never", "last_connected_at": None}
return {"status": "unknown", "last_connected_at": None}
def _serialize_user(user: User) -> Dict[str, Any]:
return {
"user_id": int(user.user_id),
@@ -94,6 +263,9 @@ def _serialize_subscription(sub: Subscription) -> Dict[str, Any]:
regular_unlimited_override = bool(getattr(sub, "regular_unlimited_override", False))
premium_unlimited_override = bool(getattr(sub, "premium_unlimited_override", False))
premium_limit_bytes = _premium_limit_bytes_from_subscription(sub)
provider = sub.provider
is_trial = str(provider or "").strip().lower() == "trial"
display_label = "Trial" if is_trial else sub.tariff_key
return {
"subscription_id": int(sub.subscription_id),
"panel_user_uuid": sub.panel_user_uuid,
@@ -117,9 +289,13 @@ def _serialize_subscription(sub: Subscription) -> Dict[str, Any]:
"regular_unlimited_override": regular_unlimited_override,
"premium_unlimited_override": premium_unlimited_override,
"premium_is_limited": bool(sub.premium_is_limited),
"hwid_device_limit": getattr(sub, "hwid_device_limit", None),
"extra_hwid_devices": int(getattr(sub, "extra_hwid_devices", 0) or 0),
"tariff_key": sub.tariff_key,
"display_label": display_label,
"is_trial": is_trial,
"auto_renew_enabled": bool(sub.auto_renew_enabled),
"provider": sub.provider,
"provider": provider,
"is_throttled": bool(sub.is_throttled),
}
@@ -143,12 +319,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 +339,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 +431,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,
}
@@ -0,0 +1,19 @@
# ruff: noqa: F401,F403,F405,I001
from datetime import datetime, timezone
from ._runtime import * # noqa: F403,F405
from .auth import _require_admin_user_id
from .common import _ok
from bot.services.config_health_service import collect_config_alerts
async def admin_health_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
refresh = str(request.query.get("refresh", "")).strip().lower() in {"1", "true", "yes"}
alerts = await collect_config_alerts(request, refresh=refresh)
return _ok(
{
"alerts": alerts,
"checked_at": datetime.now(timezone.utc).isoformat(),
}
)
@@ -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"]
@@ -6,9 +6,11 @@ def setup_admin_routes(app: web.Application) -> None:
router = app.router
router.add_get("/api/admin/me", admin_me_route)
router.add_get("/api/admin/stats", admin_stats_route)
router.add_get("/api/admin/health", admin_health_route)
router.add_get("/api/admin/users", admin_users_list_route)
router.add_get("/api/admin/users/{user_id:-?\\d+}", admin_user_detail_route)
router.add_get("/api/admin/users/{user_id:-?\\d+}/referrals", admin_user_referrals_route)
router.add_get("/api/admin/users/{user_id:-?\\d+}/avatar", admin_user_avatar_route)
router.add_post("/api/admin/users/{user_id:-?\\d+}/ban", admin_user_ban_route)
router.add_post("/api/admin/users/{user_id:-?\\d+}/message", admin_user_message_route)
@@ -29,6 +31,10 @@ def setup_admin_routes(app: web.Application) -> None:
"/api/admin/users/{user_id:-?\\d+}/regular-traffic-override",
admin_user_regular_traffic_override_route,
)
router.add_post(
"/api/admin/users/{user_id:-?\\d+}/hwid-device-limit",
admin_user_hwid_device_limit_route,
)
router.add_post(
"/api/admin/users/{user_id:-?\\d+}/traffic-grant",
admin_user_traffic_grant_route,
@@ -36,6 +42,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)
@@ -55,6 +62,7 @@ def setup_admin_routes(app: web.Application) -> None:
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_get("/api/admin/broadcast/audience-counts", admin_broadcast_audience_counts_route)
router.add_post("/api/admin/broadcast", admin_broadcast_route)
router.add_post("/api/admin/sync", admin_sync_route)
@@ -65,6 +73,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)
@@ -72,4 +82,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)})
+1 -1
View File
@@ -34,7 +34,7 @@ async def admin_stats_route(request: web.Request) -> web.Response:
except Exception: # pragma: no cover - defensive
payload["queue"] = None
payload["currency_symbol"] = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
payload["currency_symbol"] = default_payment_currency_code_for_settings(settings)
return _ok(payload)
+56 -5
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
async def admin_tariffs_get_route(request: web.Request) -> web.Response:
@@ -20,9 +21,14 @@ async def admin_tariffs_get_route(request: web.Request) -> web.Response:
"path": str(path),
"catalog": {
"default_tariff": "",
"default_currency": "rub",
"topup_packages_default": {"rub": [], "stars": []},
"tariffs": [],
},
"provider_currency_support": _provider_currency_support_payload(
settings,
request.app,
),
}
)
@@ -31,6 +37,7 @@ async def admin_tariffs_get_route(request: web.Request) -> web.Response:
"exists": True,
"path": str(path),
"catalog": _tariffs_config_payload(config),
"provider_currency_support": _provider_currency_support_payload(settings, request.app),
}
)
@@ -55,9 +62,53 @@ 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)})
return _ok(
{
"exists": True,
"path": str(path),
"catalog": _tariffs_config_payload(config),
"provider_currency_support": _provider_currency_support_payload(settings, request.app),
}
)
def _provider_currency_support_payload(
settings: Settings,
app: web.Application,
) -> List[Dict[str, Any]]:
from bot.payment_providers import iter_provider_specs, resolve_provider_presentation
default_currency = default_payment_currency_code_for_settings(settings)
providers: List[Dict[str, Any]] = []
for spec in iter_provider_specs():
presentation = resolve_provider_presentation(spec, settings)
supported = spec.supported_currency_codes(settings)
providers.append(
{
"id": spec.id,
"provider_key": spec.provider_key,
"label": presentation.webapp_label or spec.label,
"telegram_label": presentation.telegram_label,
"icon": presentation.webapp_icon,
"enabled": spec.is_effectively_enabled(settings),
"configured": spec.is_service_configured(app),
"admin_only": spec.is_admin_only_enabled(settings),
"price_source": spec.price_source,
"currencies": list(supported) if supported is not None else None,
"accepts_any_currency": supported is None,
"supports_default_currency": spec.is_usable_for_payment_currency(
settings,
default_currency,
),
"directly_supports_default_currency": spec.supports_currency(
settings,
default_currency,
),
"default_currency": default_currency,
"note": spec.currency_support_note,
"docs_url": spec.currency_support_url,
}
)
return providers
+6 -39
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
@@ -24,7 +25,6 @@ WEBAPP_UPLOADED_LOGO_DIR = Path(__file__).resolve().parents[5] / "data" / "webap
WEBAPP_UPLOADED_LOGO_PATH = "/webapp-uploaded-logo"
WEBAPP_FAVICON_DIR = Path(__file__).resolve().parents[5] / "data" / "webapp-logo" / "favicons"
WEBAPP_FAVICON_PATH = "/webapp-favicon"
WEBAPP_EMOJI_CACHE_DIR = Path(__file__).resolve().parents[5] / "data" / "webapp-emoji"
WEBAPP_FAVICON_SIZES = (16, 32, 48, 180, 192, 512)
WEBAPP_LOGO_UPLOAD_CONTENT_TYPES = {
".gif": "image/gif",
@@ -65,11 +65,9 @@ def _bump_theme_asset_versions(
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)
)
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:
@@ -137,10 +135,6 @@ def _favicon_digest(url: str) -> Optional[str]:
return match.group(1) if match else None
def _emoji_to_codepoints(value: str) -> str:
return "_".join(f"{ord(char):x}" for char in str(value or "").strip())
def prune_unused_appearance_assets(settings: Settings) -> None:
keep_logos = {
filename
@@ -157,15 +151,6 @@ def prune_unused_appearance_assets(settings: Settings) -> None:
]
if digest
}
keep_emoji_prefixes = set()
if (
getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False)
and str(getattr(settings, "WEBAPP_LOGO_EMOJI_FONT", "") or "").strip()
== "noto-color-animated"
):
codepoints = _emoji_to_codepoints(getattr(settings, "WEBAPP_LOGO_EMOJI", ""))
if codepoints:
keep_emoji_prefixes.add(f"{codepoints}.512.")
for path in WEBAPP_UPLOADED_LOGO_DIR.glob("logo-*"):
if path.is_file() and path.name not in keep_logos:
@@ -185,15 +170,6 @@ def prune_unused_appearance_assets(settings: Settings) -> None:
except OSError:
logger.warning("Failed to remove unused webapp favicon set %s", path, exc_info=True)
for path in WEBAPP_EMOJI_CACHE_DIR.glob("*.512.*"):
if path.is_file() and not any(
path.name.startswith(prefix) for prefix in keep_emoji_prefixes
):
try:
path.unlink()
except OSError:
logger.warning("Failed to remove unused webapp emoji asset %s", path, exc_info=True)
async def _persist_appearance_upload(
request: web.Request,
@@ -213,12 +189,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
@@ -395,7 +366,6 @@ async def admin_appearance_logo_upload_route(request: web.Request) -> web.Respon
request,
{
"WEBAPP_LOGO_URL": logo_url,
"WEBAPP_LOGO_USE_EMOJI": False,
**(
{"WEBAPP_LOGO_FAVICON_URL": favicon_payload["favicon_url"]}
if favicon_payload.get("favicon_url")
@@ -484,10 +454,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),
}
)
+427 -24
View File
@@ -1,10 +1,23 @@
# 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,
_panel_user_connection_activity,
_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 sqlalchemy.orm import aliased
from bot.app.web.webapp.cache_helpers import invalidate_webapp_user_caches
from bot.infra.redis import cache_delete_pattern, redis_key
@@ -116,12 +129,15 @@ async def _load_admin_users_list_payload_uncached(
active_subs = await _bulk_active_subscriptions_for_users(
session, [u.user_id for u in users]
)
payment_summaries = await _bulk_user_payment_summaries(session, [u.user_id for u in users])
referral_counts = await _bulk_user_referral_counts(session, [u.user_id for u in users])
serialized = []
for user in users:
payload = _serialize_user(user)
status_payload = statuses.get(user.user_id) or {"status": "bot_only", "end_date": None}
payload["panel_status"] = status_payload.get("status")
payload["subscription_expires_at"] = status_payload.get("end_date")
if status_payload.get("status") == "expired" and status_payload.get("end_date"):
payload["panel_status_expired_at"] = status_payload["end_date"]
payload["avatar_url"] = (
@@ -130,6 +146,11 @@ async def _load_admin_users_list_payload_uncached(
else None
)
payload["premium_traffic"] = _premium_traffic_list_payload(active_subs.get(user.user_id))
payment_summary = payment_summaries.get(user.user_id) or {}
payload["payments_total_amount"] = float(payment_summary.get("total_amount") or 0)
payload["payments_count"] = int(payment_summary.get("count") or 0)
payload["payments_currency"] = payment_summary.get("currency")
payload["invited_users_count"] = int(referral_counts.get(user.user_id) or 0)
serialized.append(payload)
return {
@@ -244,6 +265,17 @@ async def _bulk_user_avatar_keys(session: AsyncSession, user_ids: List[int]) ->
return {int(uid): (updated_at.isoformat() if updated_at else "") for uid, updated_at in rows}
def _serialize_admin_user_with_avatar(user: User, avatar_keys: Dict[int, str]) -> Dict[str, Any]:
payload = _serialize_user(user)
user_id = int(user.user_id)
payload["avatar_url"] = (
f"/api/admin/users/{user_id}/avatar?v={avatar_keys[user_id]}"
if user_id in avatar_keys
else None
)
return payload
async def admin_user_avatar_route(request: web.Request) -> web.Response:
"""Serve the cached Telegram avatar for any user (admin-only).
@@ -342,6 +374,88 @@ async def _bulk_active_subscriptions_for_users(
return out
def _user_payment_summary_sq():
return (
select(
Payment.user_id.label("user_id"),
sa_func.coalesce(sa_func.sum(Payment.amount), 0.0).label("payments_total_amount"),
sa_func.count(Payment.payment_id).label("payments_count"),
)
.where(Payment.status == "succeeded")
.group_by(Payment.user_id)
.subquery(name="user_payment_summary")
)
def _user_referral_count_sq():
referred_user = aliased(User)
return (
select(
referred_user.referred_by_id.label("user_id"),
sa_func.count(referred_user.user_id).label("invited_users_count"),
)
.where(referred_user.referred_by_id.is_not(None))
.group_by(referred_user.referred_by_id)
.subquery(name="user_referral_count")
)
def _user_subscription_expiry_sq():
return (
select(
Subscription.user_id.label("user_id"),
sa_func.max(Subscription.end_date).label("subscription_expires_at"),
)
.group_by(Subscription.user_id)
.subquery(name="user_subscription_expiry")
)
async def _bulk_user_payment_summaries(
session: AsyncSession,
user_ids: List[int],
) -> Dict[int, Dict[str, Any]]:
if not user_ids:
return {}
stmt = (
select(
Payment.user_id,
sa_func.coalesce(sa_func.sum(Payment.amount), 0.0),
sa_func.count(Payment.payment_id),
sa_func.max(Payment.currency),
)
.where(Payment.user_id.in_(user_ids), Payment.status == "succeeded")
.group_by(Payment.user_id)
)
rows = (await session.execute(stmt)).all()
return {
int(user_id): {
"total_amount": float(total_amount or 0),
"count": int(payments_count or 0),
"currency": currency,
}
for user_id, total_amount, payments_count, currency in rows
}
async def _bulk_user_referral_counts(
session: AsyncSession,
user_ids: List[int],
) -> Dict[int, int]:
if not user_ids:
return {}
referred_user = aliased(User)
stmt = (
select(referred_user.referred_by_id, sa_func.count(referred_user.user_id))
.where(referred_user.referred_by_id.in_(user_ids))
.group_by(referred_user.referred_by_id)
)
rows = (await session.execute(stmt)).all()
return {int(user_id): int(count or 0) for user_id, count in rows}
async def _filter_and_sort_users(
session: AsyncSession,
*,
@@ -370,6 +484,13 @@ async def _filter_and_sort_users(
ratio_expr = None
plim_expr = None
pu_expr = None
payment_summary_sq = None
payment_total_expr = None
payment_count_expr = None
referral_count_sq = None
referral_count_expr = None
subscription_expiry_sq = None
subscription_expires_expr = None
if needs_premium_sq:
sq = _ranked_active_subscriptions_sq(now)
@@ -390,6 +511,42 @@ async def _filter_and_sort_users(
else_=cast(pu_expr, Float) / cast(plim_expr, Float),
)
if sort_key in {
"payments_total_asc",
"payments_total_desc",
"payments_count_asc",
"payments_count_desc",
}:
payment_summary_sq = _user_payment_summary_sq()
stmt = stmt.outerjoin(payment_summary_sq, User.user_id == payment_summary_sq.c.user_id)
count_stmt = count_stmt.outerjoin(
payment_summary_sq,
User.user_id == payment_summary_sq.c.user_id,
)
payment_total_expr = sa_func.coalesce(payment_summary_sq.c.payments_total_amount, 0.0)
payment_count_expr = sa_func.coalesce(payment_summary_sq.c.payments_count, 0)
if sort_key in {"invited_users_count_asc", "invited_users_count_desc"}:
referral_count_sq = _user_referral_count_sq()
stmt = stmt.outerjoin(referral_count_sq, User.user_id == referral_count_sq.c.user_id)
count_stmt = count_stmt.outerjoin(
referral_count_sq,
User.user_id == referral_count_sq.c.user_id,
)
referral_count_expr = sa_func.coalesce(referral_count_sq.c.invited_users_count, 0)
if sort_key in {"subscription_expires_at_asc", "subscription_expires_at_desc"}:
subscription_expiry_sq = _user_subscription_expiry_sq()
stmt = stmt.outerjoin(
subscription_expiry_sq,
User.user_id == subscription_expiry_sq.c.user_id,
)
count_stmt = count_stmt.outerjoin(
subscription_expiry_sq,
User.user_id == subscription_expiry_sq.c.user_id,
)
subscription_expires_expr = subscription_expiry_sq.c.subscription_expires_at
search_cond = _user_search_condition(query)
if search_cond is not None:
stmt = stmt.where(search_cond)
@@ -488,6 +645,22 @@ async def _filter_and_sort_users(
stmt = stmt.order_by(ratio_expr.asc().nullslast(), User.user_id.asc())
elif needs_premium_sq and ratio_expr is not None and sort_key == "premium_ratio_desc":
stmt = stmt.order_by(ratio_expr.desc().nullslast(), User.user_id.desc())
elif payment_total_expr is not None and sort_key == "payments_total_asc":
stmt = stmt.order_by(payment_total_expr.asc(), User.user_id.asc())
elif payment_total_expr is not None and sort_key == "payments_total_desc":
stmt = stmt.order_by(payment_total_expr.desc(), User.user_id.desc())
elif payment_count_expr is not None and sort_key == "payments_count_asc":
stmt = stmt.order_by(payment_count_expr.asc(), User.user_id.asc())
elif payment_count_expr is not None and sort_key == "payments_count_desc":
stmt = stmt.order_by(payment_count_expr.desc(), User.user_id.desc())
elif referral_count_expr is not None and sort_key == "invited_users_count_asc":
stmt = stmt.order_by(referral_count_expr.asc(), User.user_id.asc())
elif referral_count_expr is not None and sort_key == "invited_users_count_desc":
stmt = stmt.order_by(referral_count_expr.desc(), User.user_id.desc())
elif subscription_expires_expr is not None and sort_key == "subscription_expires_at_asc":
stmt = stmt.order_by(subscription_expires_expr.asc().nullslast(), User.user_id.asc())
elif subscription_expires_expr is not None and sort_key == "subscription_expires_at_desc":
stmt = stmt.order_by(subscription_expires_expr.desc().nullslast(), User.user_id.desc())
else:
order = sort_map.get(sort_key, sort_map["registered_desc"])
if isinstance(order, tuple):
@@ -516,9 +689,34 @@ def _user_panel_status_condition(panel_status: str):
normalized_status == "active", blank_status & Subscription.is_active.is_(True)
)
elif status == "expired":
status_cond = or_(
normalized_status == "expired", blank_status & Subscription.is_active.is_(False)
now = datetime.now(timezone.utc)
expired_subs = aliased(Subscription)
active_subs = aliased(Subscription)
expired_status = sa_func.lower(sa_func.coalesce(expired_subs.status_from_panel, ""))
expired_blank_status = or_(
expired_subs.status_from_panel.is_(None),
expired_subs.status_from_panel == "",
)
expired_condition = or_(
expired_status == "expired",
expired_blank_status & expired_subs.is_active.is_(False),
expired_subs.end_date <= now,
)
expired_exists = (
select(expired_subs.subscription_id)
.where(expired_subs.user_id == User.user_id, expired_condition)
.exists()
)
active_exists = (
select(active_subs.subscription_id)
.where(
active_subs.user_id == User.user_id,
active_subs.is_active.is_(True),
active_subs.end_date > now,
)
.exists()
)
return and_(expired_exists, ~active_exists)
else:
status_cond = normalized_status == "limited"
@@ -548,6 +746,24 @@ def _user_search_condition(query: str):
return or_(*conditions)
def _serialize_trial_summary(user: User, trial_subs: List[Subscription]) -> Dict[str, Any]:
first_trial_sub = trial_subs[0] if trial_subs else None
latest_trial_sub = trial_subs[-1] if trial_subs else None
first_start = getattr(first_trial_sub, "start_date", None)
latest_start = getattr(latest_trial_sub, "start_date", None)
latest_end = getattr(latest_trial_sub, "end_date", None)
reset_at = getattr(user, "trial_eligibility_reset_at", None)
return {
"used": bool(trial_subs),
"count": len(trial_subs),
"first_activated_at": first_start.isoformat() if first_start else None,
"latest_activated_at": latest_start.isoformat() if latest_start else None,
"latest_end_date": latest_end.isoformat() if latest_end else None,
"active": bool(latest_trial_sub and getattr(latest_trial_sub, "is_active", False)),
"last_reset_at": reset_at.isoformat() if reset_at else None,
}
async def admin_user_detail_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
target_id = int(request.match_info["user_id"])
@@ -567,6 +783,15 @@ async def admin_user_detail_route(request: web.Request) -> web.Response:
.limit(20)
)
latest_subs = (await session.execute(latest_subs_stmt)).scalars().all()
trial_subs_stmt = (
select(Subscription)
.where(
Subscription.user_id == target_id,
sa_func.lower(sa_func.coalesce(Subscription.provider, "")) == "trial",
)
.order_by(Subscription.start_date.asc().nullslast(), Subscription.end_date.asc())
)
trial_subs = (await session.execute(trial_subs_stmt)).scalars().all()
total_paid = await payment_dal.get_user_total_paid(session, target_id)
recent_payments_stmt = (
select(Payment)
@@ -576,7 +801,12 @@ async def admin_user_detail_route(request: web.Request) -> web.Response:
)
recent_payments = (await session.execute(recent_payments_stmt)).scalars().all()
log_count = await message_log_dal.count_user_message_logs(session, target_id)
avatar_keys = await _bulk_user_avatar_keys(session, [target_id])
inviter = await user_dal.get_referrer_for_user(session, user)
invitees_total = await user_dal.count_users_referred_by(session, target_id)
avatar_user_ids = [target_id]
if inviter is not None:
avatar_user_ids.append(int(inviter.user_id))
avatar_keys = await _bulk_user_avatar_keys(session, avatar_user_ids)
# Referral links — both the bot deep-link and the webapp deep-link.
referral_code: Optional[str] = None
@@ -607,7 +837,13 @@ async def admin_user_detail_route(request: web.Request) -> web.Response:
# imports into their VPN client. May be missing if the user has never
# been provisioned on the panel.
subscription_url: Optional[str] = None
panel_uuid = getattr(user, "panel_user_uuid", None)
last_vpn_connected_at: Optional[str] = None
vpn_connection_status = "unknown"
panel_uuid = getattr(user, "panel_user_uuid", None) or getattr(
active_sub,
"panel_user_uuid",
None,
)
if panel_uuid:
subscription_service = request.app.get("subscription_service")
panel_service = getattr(subscription_service, "panel_service", None)
@@ -616,39 +852,87 @@ async def admin_user_detail_route(request: web.Request) -> web.Response:
panel_data = await panel_service.get_user_by_uuid(panel_uuid)
if panel_data:
subscription_url = panel_data.get("subscriptionUrl") or None
vpn_activity = _panel_user_connection_activity(panel_data)
vpn_connection_status = str(vpn_activity.get("status") or "unknown")
last_vpn_connected_at = vpn_activity.get("last_connected_at")
except Exception as exc_panel: # pragma: no cover
logger.warning(
"Failed to fetch subscriptionUrl for user %s (uuid=%s): %s",
"Failed to fetch panel details for user %s (uuid=%s): %s",
target_id,
panel_uuid,
exc_panel,
)
serialized_user = _serialize_user(user)
serialized_user["avatar_url"] = (
f"/api/admin/users/{target_id}/avatar?v={avatar_keys[target_id]}"
if target_id in avatar_keys
else None
serialized_user = _serialize_admin_user_with_avatar(user, avatar_keys)
serialized_inviter = (
_serialize_admin_user_with_avatar(inviter, avatar_keys) if inviter is not None else None
)
trial_payload = _serialize_trial_summary(user, trial_subs)
return _ok(
{
"user": serialized_user,
"active_subscription": _serialize_subscription(active_sub) if active_sub else None,
"subscriptions": [_serialize_subscription(s) for s in (latest_subs or [])],
"trial": trial_payload,
"total_paid": float(total_paid),
"recent_payments": [_serialize_payment(p) for p in recent_payments],
"log_count": int(log_count or 0),
"subscription_url": subscription_url,
"last_vpn_connected_at": last_vpn_connected_at,
"vpn_connection_status": vpn_connection_status,
"referral": {
"code": referral_code,
"bot_link": referral_bot_link,
"webapp_link": referral_webapp_link,
"inviter": serialized_inviter,
"invitees_total": int(invitees_total or 0),
},
}
)
async def admin_user_referrals_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
target_id = int(request.match_info["user_id"])
page = max(0, int(request.query.get("page", 0) or 0))
page_size = min(100, max(1, int(request.query.get("page_size", 25) or 25)))
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
user = await user_dal.get_user_by_id(session, target_id)
if not user:
return _error(404, "not_found", "User not found")
inviter = await user_dal.get_referrer_for_user(session, user)
invitees_total = await user_dal.count_users_referred_by(session, target_id)
invitees = await user_dal.get_users_referred_by(
session,
target_id,
limit=page_size,
offset=page * page_size,
)
avatar_user_ids = [target_id, *(int(u.user_id) for u in invitees)]
if inviter is not None:
avatar_user_ids.append(int(inviter.user_id))
avatar_keys = await _bulk_user_avatar_keys(session, avatar_user_ids)
return _ok(
{
"user": _serialize_admin_user_with_avatar(user, avatar_keys),
"inviter": _serialize_admin_user_with_avatar(inviter, avatar_keys)
if inviter is not None
else None,
"invitees": [
_serialize_admin_user_with_avatar(invitee, avatar_keys) for invitee in invitees
],
"total": int(invitees_total or 0),
"page": page,
"page_size": page_size,
}
)
async def admin_user_ban_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
target_id = int(request.match_info["user_id"])
@@ -861,18 +1145,62 @@ async def admin_user_delete_route(request: web.Request) -> web.Response:
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(
await message_log_dal.create_message_log_no_commit(
session,
{
"user_id": actor_id,
"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,
},
)
@@ -885,10 +1213,6 @@ async def admin_user_reset_trial_route(request: web.Request) -> web.Response:
actor_id = _require_admin_user_id(request)
target_id = int(request.match_info["user_id"])
settings: Settings = request.app["settings"]
panel_service = request.app.get("panel_service")
subscription_service = request.app.get("subscription_service")
if panel_service is None or subscription_service is None:
return _error(503, "service_unavailable")
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
@@ -896,16 +1220,17 @@ async def admin_user_reset_trial_route(request: web.Request) -> web.Response:
if not user:
return _error(404, "not_found")
active = await subscription_dal.get_active_subscription_by_user_id(session, target_id)
if active:
await session.delete(active)
reset_at = await user_dal.mark_trial_eligibility_reset(session, target_id)
if reset_at is None:
await session.rollback()
return _error(404, "not_found")
await message_log_dal.create_message_log(
await message_log_dal.create_message_log_no_commit(
session,
{
"user_id": actor_id,
"event_type": "admin_reset_trial_webapp",
"content": f"Reset trial for user_id={target_id}",
"content": f"Reset trial eligibility for user_id={target_id}",
"is_admin_event": True,
"target_user_id": target_id,
},
@@ -976,7 +1301,7 @@ async def admin_user_premium_override_route(request: web.Request) -> web.Respons
async def admin_user_regular_traffic_override_route(request: web.Request) -> web.Response:
"""Main (regular) traffic: unlimited-style ceiling + admin bonus GB."""
"""Main (regular) traffic: native unlimited panel limit + admin bonus GB."""
actor_id = _require_admin_user_id(request)
target_id = int(request.match_info["user_id"])
settings: Settings = request.app["settings"]
@@ -1034,6 +1359,78 @@ async def admin_user_regular_traffic_override_route(request: web.Request) -> web
return _ok({"subscription": _serialize_subscription(active)})
async def admin_user_hwid_device_limit_route(request: web.Request) -> web.Response:
"""Override the user's base HWID device limit.
``hwid_device_limit == 0`` means unlimited; ``NULL`` means the tariff/.env
default is used. Purchased extra devices remain tracked separately and are
added when syncing the effective panel limit.
"""
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"))
use_default = bool(payload.get("use_default") or payload.get("reset_to_default"))
limit_raw = payload.get("hwid_device_limit", payload.get("limit"))
if unlimited:
hwid_device_limit: Optional[int] = 0
elif use_default or limit_raw is None or limit_raw == "":
hwid_device_limit = None
else:
try:
hwid_device_limit = int(limit_raw)
except (TypeError, ValueError):
return _error(
400,
"invalid_hwid_device_limit",
"hwid_device_limit must be a non-negative integer",
)
if hwid_device_limit < 0 or hwid_device_limit > 1_000_000:
return _error(
400,
"invalid_hwid_device_limit",
"hwid_device_limit must be an integer from 0 to 1000000",
)
subscription_service = request.app.get("subscription_service")
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
active = await subscription_dal.get_active_subscription_by_user_id(session, target_id)
if not active:
return _error(404, "no_active_subscription")
active.hwid_device_limit = hwid_device_limit
effective_limit = None
if subscription_service is not None:
effective_limit = await subscription_service.sync_hwid_device_limit_to_panel(
session, target_id
)
await message_log_dal.create_message_log(
session,
{
"user_id": actor_id,
"event_type": "admin_hwid_device_limit_webapp",
"content": (
f"hwid_device_limit={hwid_device_limit!r} "
f"effective_hwid_device_limit={effective_limit!r}"
),
"is_admin_event": True,
"target_user_id": target_id,
},
)
await session.commit()
await session.refresh(active)
await _invalidate_after_admin_user_mutation(settings, target_id)
return _ok({"subscription": _serialize_subscription(active)})
async def admin_user_traffic_grant_route(request: web.Request) -> web.Response:
"""Credit regular or premium traffic to a user without a payment.
@@ -1130,6 +1527,8 @@ async def admin_user_extend_route(request: web.Request) -> web.Response:
return _error(400, "invalid_days")
if days <= 0:
return _error(400, "invalid_days")
extend_hwid_devices = payload.get("extend_hwid_devices")
extend_hwid_devices = True if extend_hwid_devices is None else bool(extend_hwid_devices)
subscription_service = request.app.get("subscription_service")
if subscription_service is None:
@@ -1142,6 +1541,7 @@ async def admin_user_extend_route(request: web.Request) -> web.Response:
target_id,
days,
"admin_extend_subscription_webapp",
extend_hwid_devices=extend_hwid_devices,
)
if not new_end:
await session.rollback()
@@ -1152,7 +1552,10 @@ async def admin_user_extend_route(request: web.Request) -> web.Response:
{
"user_id": actor_id,
"event_type": "admin_extend_subscription_webapp",
"content": f"+{days}d -> {new_end.isoformat()}",
"content": (
f"+{days}d -> {new_end.isoformat()} "
f"(hwid={'yes' if extend_hwid_devices else 'no'})"
),
"is_admin_event": True,
"target_user_id": target_id,
},
@@ -0,0 +1,64 @@
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_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)
+566 -98
View File
@@ -16,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" | "icon"
type: str # "string" | "int" | "float" | "bool" | "text" | "url" | "color" | "icon" | "json"
section: str
label: str
description: str = ""
@@ -30,10 +30,22 @@ class SettingField:
i18n_label_key: Optional[str] = None
i18n_description_key: Optional[str] = None
i18n_subsection_key: Optional[str] = None
webhook_path: Optional[str] = None
webhook_requires_base_url: bool = False
webhook_provider_id: Optional[str] = None
webhook_hint_i18n_key: Optional[str] = None
webhook_hint: str = ""
SETTINGS_MANIFEST: List[SettingField] = [
# ─── General ────────────────────────────────────────────────────
SettingField(
"WEBAPP_TITLE",
"string",
"general",
"Web App title",
placeholder="My subscription",
),
SettingField(
"DEFAULT_LANGUAGE",
"string",
@@ -53,7 +65,6 @@ SETTINGS_MANIFEST: List[SettingField] = [
"SUPPORT_LINK", "url", "general", "Ссылка поддержки", "Куда вести пользователей за помощью."
),
SettingField("SERVER_STATUS_URL", "url", "general", "Ссылка на статус серверов"),
SettingField("TERMS_OF_SERVICE_URL", "url", "general", "Условия использования"),
SettingField("PRIVACY_POLICY_URL", "url", "general", "Политика конфиденциальности"),
SettingField("USER_AGREEMENT_URL", "url", "general", "Пользовательское соглашение"),
SettingField("DISABLE_WELCOME_MESSAGE", "bool", "general", "Скрыть приветствие /start"),
@@ -65,61 +76,100 @@ SETTINGS_MANIFEST: List[SettingField] = [
"int",
"general",
"ID обязательного канала",
"Telegram ID канала, в котором нужно состоять.",
(
"Telegram ID канала для проверки подписки. Если бот видит канал, "
"ссылка кнопки будет получена автоматически."
),
),
SettingField(
"REQUIRED_CHANNEL_LINK",
"string",
"general",
"Ссылка на канал",
"Имя пользователя или invite-link.",
(
"Необязательно: публичный @username или invite-link, "
"если ссылку нельзя получить по ID канала."
),
),
SettingField(
"PANEL_API_URL",
"url",
"general",
"remnawave",
"URL API Remnawave",
"Например, https://panel.example.com/api.",
subsection="Remnawave",
),
SettingField(
"PANEL_API_KEY",
"string",
"general",
"remnawave",
"API-ключ Remnawave",
"Секретный ключ API панели.",
secret=True,
subsection="Remnawave",
),
SettingField(
"PANEL_API_TOTAL_TIMEOUT_SECONDS",
"float",
"remnawave",
"Panel API total timeout",
"Maximum total time for one Remnawave API request, in seconds.",
optional=False,
min=1,
),
SettingField(
"PANEL_API_CONNECT_TIMEOUT_SECONDS",
"float",
"remnawave",
"Panel API connect timeout",
"Maximum time to get or open a Remnawave API connection, in seconds.",
optional=False,
min=1,
),
SettingField(
"PANEL_API_SOCK_CONNECT_TIMEOUT_SECONDS",
"float",
"remnawave",
"Panel API socket connect timeout",
"Maximum TCP/TLS connection time for Remnawave API, in seconds.",
optional=False,
min=1,
),
SettingField(
"PANEL_API_SOCK_READ_TIMEOUT_SECONDS",
"float",
"remnawave",
"Panel API socket read timeout",
"Maximum time to wait for response data from Remnawave API, in seconds.",
optional=False,
min=1,
),
SettingField(
"PANEL_WEBHOOK_SECRET",
"string",
"general",
"remnawave",
"Секрет вебхуков Remnawave",
"Используется для проверки входящих вебхуков панели.",
secret=True,
subsection="Remnawave",
webhook_path="/webhook/panel",
webhook_requires_base_url=True,
webhook_provider_id="remnawave",
webhook_hint_i18n_key="admin_settings_panel_webhook_url_hint",
webhook_hint="Use this URL as WEBHOOK_URL in Remnawave Panel.",
),
SettingField(
"USER_SQUAD_UUIDS",
"string",
"general",
"remnawave",
"Internal Squads по умолчанию",
"UUID через запятую для legacy-режима без JSON-каталога тарифов.",
subsection="Remnawave",
),
SettingField(
"USER_EXTERNAL_SQUAD_UUID",
"string",
"general",
"remnawave",
"External Squad по умолчанию",
"Необязательный UUID External Squad для новых пользователей.",
subsection="Remnawave",
),
# ─── Web app appearance ────────────────────────────────────────
SettingField(
"WEBAPP_TITLE", "string", "appearance", "Название Web App", placeholder="Моя подписка"
),
SettingField(
"SUBSCRIPTION_MINI_APP_URL",
"url",
@@ -130,27 +180,7 @@ SETTINGS_MANIFEST: List[SettingField] = [
SettingField(
"WEBAPP_PRIMARY_COLOR", "color", "appearance", "Основной цвет", placeholder="#00fe7a"
),
SettingField("WEBAPP_LOGO_USE_EMOJI", "bool", "appearance", "Использовать эмоджи-логотип"),
SettingField("WEBAPP_LOGO_URL", "url", "appearance", "URL логотипа"),
SettingField("WEBAPP_LOGO_EMOJI", "string", "appearance", "Эмоджи-логотип", placeholder="🫥"),
SettingField(
"WEBAPP_LOGO_EMOJI_FONT",
"string",
"appearance",
"Шрифт эмоджи-логотипа",
"Выберите шрифт для отображения эмодзи-логотипа",
choices=(
("system", "Системный (по умолчанию)"),
("noto-color", "Noto Color Emoji"),
("noto-color-animated", "Noto Color Emoji Animated"),
("noto-emoji", "Noto Emoji"),
("twemoji", "Twitter Emoji"),
("openmoji", "OpenMoji"),
("apple", "Apple Color Emoji (local)"),
("segoe", "Segoe UI Emoji (local)"),
("noto-local", "Noto Emoji (local)"),
),
),
SettingField(
"WEBAPP_FAVICON_USE_CUSTOM",
"bool",
@@ -160,6 +190,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 месяца"),
@@ -173,117 +256,264 @@ 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,heleket",
),
SettingField(
"SUBSCRIPTION_PURCHASE_DESCRIPTION_ENABLED",
"bool",
"pricing",
"payments",
"Показывать описание подписки",
"Текст появится перед выбором срока покупки или продления.",
subsection="checkout",
),
SettingField(
"SUBSCRIPTION_PURCHASE_DESCRIPTION_RU",
"text",
"pricing",
"payments",
"Описание подписки (RU)",
"Русская версия текста на этапе оплаты.",
subsection="checkout",
),
SettingField(
"SUBSCRIPTION_PURCHASE_DESCRIPTION_EN",
"text",
"pricing",
"payments",
"Описание подписки (EN)",
"Английская версия текста на этапе оплаты.",
subsection="checkout",
),
SettingField(
"PAYMENT_REQUEST_TIMEOUT_SECONDS",
"float",
"payments",
"Таймаут запроса к провайдеру",
"Максимальное общее время одного API-запроса к платёжному провайдеру, в секундах.",
optional=False,
min=1,
subsection="checkout",
),
# ─── Payment providers (toggles) ───────────────────────────────
# Common
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,heleket",
"Через запятую: severpay,freekassa,yookassa,platega,stars,cryptopay,heleket,paykilla,lava",
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_WITHOUT_TELEGRAM_ENABLED",
"bool",
"pricing",
"Триал без Telegram",
(
"Если выключено, email-only пользователю нужно привязать Telegram для "
"активации триала. Disposable email домены всегда требуют Telegram."
),
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", "Один бонус на приглашённого"
"REFERRAL_ONE_BONUS_PER_REFEREE",
"bool",
"pricing",
"Один бонус на приглашённого",
subsection="referral",
),
SettingField(
"REFERRAL_WELCOME_BONUS_DAYS", "int", "referral", "Приветственный бонус (дней)", min=0
),
SettingField("LEGACY_REFS", "bool", "referral", "Поддержка старых ref-ссылок"),
SettingField(
"REFERRAL_BONUS_DAYS_INVITER_1_MONTH",
"REFERRAL_WELCOME_BONUS_DAYS",
"int",
"referral",
"Бонус приглашающему: 1 мес.",
"pricing",
"Приветственный бонус (дней)",
min=0,
subsection="referral",
),
SettingField(
"REFERRAL_BONUS_DAYS_INVITER_3_MONTHS",
"int",
"referral",
"Бонус приглашающему: 3 мес.",
min=0,
"REFERRAL_WELCOME_BONUS_WITHOUT_TELEGRAM_ENABLED",
"bool",
"pricing",
"Приветственный бонус без Telegram",
(
"Если выключено, email-only пользователю нужно привязать Telegram для получения "
"реферального приветственного бонуса. Disposable email домены всегда требуют Telegram."
),
subsection="referral",
),
SettingField(
"REFERRAL_BONUS_DAYS_INVITER_6_MONTHS",
"int",
"referral",
"Бонус приглашающему: 6 мес.",
min=0,
"LEGACY_REFS",
"bool",
"pricing",
"Поддержка старых ref-ссылок",
subsection="referral",
),
SettingField(
"REFERRAL_BONUS_DAYS_INVITER_12_MONTHS",
"int",
"referral",
"Бонус приглашающему: 12 мес.",
min=0,
"DISPOSABLE_EMAIL_DOMAINS",
"text",
"pricing",
"Disposable email домены",
(
"Домены по одному на строку или через запятую. Пользователи без Telegram с такими "
"email не смогут получить trial или реферальный приветственный бонус."
),
placeholder="mailinator.com\ntemp-mail.org\nyopmail.com",
subsection="referral",
),
SettingField(
"REFERRAL_BONUS_DAYS_REFEREE_1_MONTH",
"int",
"referral",
"Бонус приглашённому: 1 мес.",
min=0,
"MIGRATION_REMNASHOP_REFERRAL_CODE_COMPAT_ENABLED",
"bool",
"migrations",
"Старые ref-ссылки Remnashop",
"Принимать импортированные ref-коды Remnashop вместе с текущими кодами пользователей.",
subsection="Remnashop",
),
SettingField(
"REFERRAL_BONUS_DAYS_REFEREE_3_MONTHS",
"int",
"referral",
"Бонус приглашённому: 3 мес.",
min=0,
"MIGRATION_REMNASHOP_PROMO_CODE_COMPAT_ENABLED",
"bool",
"migrations",
"Старые промокоды Remnashop",
"Пробовать точное совпадение промокода перед обычной uppercase-нормализацией.",
subsection="Remnashop",
),
SettingField(
"REFERRAL_BONUS_DAYS_REFEREE_6_MONTHS",
"int",
"referral",
"Бонус приглашённому: 6 мес.",
min=0,
"MIGRATION_REMNASHOP_IMPORTED_AT",
"string",
"migrations",
"Последний импорт Remnashop",
"Заполняется скриптом импорта. Можно очистить, если отметка больше не нужна.",
subsection="Remnashop",
),
SettingField(
"REFERRAL_BONUS_DAYS_REFEREE_12_MONTHS",
"int",
"referral",
"Бонус приглашённому: 12 мес.",
min=0,
"MIGRATION_REMNASHOP_NOTES",
"text",
"migrations",
"Заметки по миграции Remnashop",
"Внутренние заметки оператора по перенесенному инстансу.",
subsection="Remnashop",
),
# ─── Notifications ─────────────────────────────────────────────
SettingField(
@@ -292,6 +522,13 @@ SETTINGS_MANIFEST: List[SettingField] = [
"notifications",
"Включены уведомления о подписке",
),
SettingField(
"SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED",
"bool",
"notifications",
"Дублировать уведомления о подписке на email",
"Письма отправляются только пользователям с привязанным email и рабочим SMTP.",
),
SettingField(
"SUBSCRIPTION_NOTIFY_ON_EXPIRE", "bool", "notifications", "Уведомлять об истечении"
),
@@ -305,6 +542,14 @@ SETTINGS_MANIFEST: List[SettingField] = [
"За сколько дней предупреждать",
min=0,
),
SettingField(
"SUBSCRIPTION_NOTIFY_HOURS_BEFORE",
"int",
"notifications",
"За сколько часов предупреждать",
min=0,
max=23,
),
SettingField("LOG_NEW_USERS", "bool", "notifications", "Логировать новых пользователей"),
SettingField("LOG_PAYMENTS", "bool", "notifications", "Логировать платежи"),
SettingField("LOG_SUPPORT", "bool", "notifications", "Логировать тикеты поддержки"),
@@ -340,6 +585,55 @@ SETTINGS_MANIFEST: List[SettingField] = [
"ID треда поддержки",
"Тред лог-чата для уведомлений о тикетах поддержки.",
),
SettingField(
"BACKUP_ENABLED",
"bool",
"backups",
"Бэкапы включены",
"Worker будет периодически собирать ZIP-архив и отправлять его в Telegram.",
),
SettingField(
"BACKUP_CHAT_ID",
"int",
"backups",
"ID чата для бэкапов",
"Куда отправлять ZIP-архивы. Если пусто, используется LOG_CHAT_ID.",
),
SettingField(
"BACKUP_THREAD_ID",
"int",
"backups",
"ID треда для бэкапов",
"Необязательный topic/thread ID. Если пусто, используется LOG_THREAD_ID.",
),
SettingField(
"BACKUP_INTERVAL_SECONDS",
"int",
"backups",
"Период бэкапов (сек.)",
"По умолчанию 3600: запуск на границе часа (12:00, 13:00 и т.д.).",
optional=False,
min=60,
),
SettingField(
"BACKUP_LOCAL_RETENTION",
"int",
"backups",
"Сколько архивов хранить",
"Сколько последних ZIP-архивов оставлять в data/backups на сервере.",
optional=False,
min=1,
),
SettingField(
"BACKUP_COMPOSE_ENABLED",
"bool",
"backups",
"Добавлять compose-папку",
(
"Добавляет snapshot /app/compose-source. Если папка не смонтирована, "
"бэкап БД все равно будет создан."
),
),
SettingField(
"SUPPORT_TICKETS_ENABLED",
"bool",
@@ -407,6 +701,132 @@ SETTINGS_MANIFEST: List[SettingField] = [
),
SettingField("USER_TRAFFIC_LIMIT_GB", "float", "devices", "Лимит трафика пользователя (ГБ)"),
SettingField("USER_TRAFFIC_STRATEGY", "string", "devices", "Стратегия сброса трафика"),
# ─── System ────────────────────────────────────────────────────
SettingField(
"TELEGRAM_DROP_NON_PRIVATE_UPDATES",
"bool",
"system",
"Drop non-private Telegram updates",
"Drops group/channel messages and callbacks before DB-backed middleware runs.",
subsection="telegram_antiflood",
),
SettingField(
"TELEGRAM_ANTIFLOOD_ENABLED",
"bool",
"system",
"Telegram anti-flood enabled",
"Enables soft per-user limits for extreme Telegram update floods.",
subsection="telegram_antiflood",
),
SettingField(
"TELEGRAM_ANTIFLOOD_WINDOW_SECONDS",
"int",
"system",
"Anti-flood window",
"Rolling window, in seconds, used by all Telegram anti-flood buckets.",
min=1,
subsection="telegram_antiflood",
),
SettingField(
"TELEGRAM_ANTIFLOOD_MAX_UPDATES_PER_WINDOW",
"int",
"system",
"All updates limit",
"Maximum total Telegram updates from one actor during the window. 0 disables this bucket.",
min=0,
subsection="telegram_antiflood",
),
SettingField(
"TELEGRAM_ANTIFLOOD_MESSAGE_MAX_PER_WINDOW",
"int",
"system",
"Messages limit",
"Maximum message updates from one actor during the window. 0 disables this bucket.",
min=0,
subsection="telegram_antiflood",
),
SettingField(
"TELEGRAM_ANTIFLOOD_CALLBACK_MAX_PER_WINDOW",
"int",
"system",
"Button callbacks limit",
"Maximum callback-query updates from one actor during the window. 0 disables this bucket.",
min=0,
subsection="telegram_antiflood",
),
SettingField(
"TELEGRAM_ANTIFLOOD_INLINE_MAX_PER_WINDOW",
"int",
"system",
"Inline queries limit",
"Maximum inline-query updates from one actor during the window. 0 disables this bucket.",
min=0,
subsection="telegram_antiflood",
),
SettingField(
"TELEGRAM_ANTIFLOOD_START_MAX_PER_WINDOW",
"int",
"system",
"/start limit",
"Maximum /start messages from one actor during the window. 0 disables this bucket.",
min=0,
subsection="telegram_antiflood",
),
SettingField(
"TELEGRAM_ANTIFLOOD_EXPENSIVE_CALLBACK_MAX_PER_WINDOW",
"int",
"system",
"Expensive callbacks limit",
(
"Maximum payment, trial, promo and account-changing callbacks from one actor "
"during the window. 0 disables this bucket."
),
min=0,
subsection="telegram_antiflood",
),
SettingField(
"TELEGRAM_ACTION_COOLDOWN_ENABLED",
"bool",
"system",
"Action cooldowns enabled",
"Deduplicates repeated payment and trial button presses from the same user.",
subsection="telegram_antiflood",
),
SettingField(
"TELEGRAM_PAYMENT_CALLBACK_COOLDOWN_SECONDS",
"int",
"system",
"Payment callback cooldown",
(
"Seconds to suppress an exact repeated payment callback from the same user. "
"0 disables this cooldown."
),
min=0,
subsection="telegram_antiflood",
),
SettingField(
"TELEGRAM_TRIAL_CALLBACK_COOLDOWN_SECONDS",
"int",
"system",
"Trial callback cooldown",
(
"Seconds to suppress an exact repeated trial activation callback from the same user. "
"0 disables this cooldown."
),
min=0,
subsection="telegram_antiflood",
),
SettingField(
"TELEMETRY_ENABLED",
"bool",
"system",
"Анонимная статистика установки",
"Раз в сутки отправляет обезличенный сигнал: версия, маркер образа "
"official/custom, ОС, локаль и число пользователей в виде диапазона. Без персональных "
"данных, токенов и доменов. Помогает понять число активных установок, какие "
"версии используются и долю изменённых сборок. Можно отключить здесь без "
"перезапуска.",
),
]
@@ -454,7 +874,21 @@ def manifest_keys() -> List[str]:
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":
@@ -507,18 +941,33 @@ def manifest_payload() -> List[dict]:
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
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,
"support": 8,
"devices": 9,
"backups": 9,
"devices": 10,
"subscription_guides": 10,
"system": 12,
"migrations": 13,
}
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 aggregated_manifest():
@@ -531,10 +980,12 @@ def manifest_payload() -> List[dict]:
)
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:
@@ -559,8 +1010,25 @@ def manifest_payload() -> List[dict]:
"optional": field.optional,
"secret": field.secret,
}
if field.min is not None:
item["min"] = field.min
if field.max is not None:
item["max"] = field.max
if field.key in exclusive_map:
item["mutually_exclusive_key"] = exclusive_map[field.key]
if default_value is not None:
item["default"] = default_value
if webhook_metadata:
item.update(webhook_metadata)
if field.webhook_path:
item["webhook_path"] = field.webhook_path
item["webhook_requires_base_url"] = field.webhook_requires_base_url
if field.webhook_provider_id:
item["provider_id"] = field.webhook_provider_id
if field.webhook_hint_i18n_key:
item["webhook_hint_i18n_key"] = field.webhook_hint_i18n_key
if field.webhook_hint:
item["webhook_hint"] = field.webhook_hint
if field.choices:
item["choices"] = [
{
@@ -11,10 +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 = (
@@ -24,9 +26,11 @@ _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,230 @@
<!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;
--accent: #14b86f;
--accent-contrast: #03120b;
--bg: #0b1017;
--panel-3: #344052;
--border: #2d3847;
--text: #f7fafc;
--muted: #aeb8c5;
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
"Segoe UI", sans-serif;
background: var(--bg);
color: var(--text);
}
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: var(--muted);
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: var(--accent);
color: var(--accent-contrast);
padding: 0 18px;
box-sizing: border-box;
font: inherit;
font-weight: 800;
text-decoration: none;
cursor: pointer;
}
.button.secondary {
border-color: var(--border);
background: transparent;
color: var(--text);
}
.button[aria-disabled="true"] {
pointer-events: none;
background: var(--panel-3);
color: var(--muted);
}
[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";
let closeAttemptTimer = null;
const CLOSE_ATTEMPT_DELAY_MS = 2500;
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");
if (closeAttemptTimer) window.clearTimeout(closeAttemptTimer);
closeAttemptTimer = window.setTimeout(() => {
if (pageLeft || document.hidden) tryCloseWindow();
}, CLOSE_ATTEMPT_DELAY_MS);
}
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);
document.addEventListener("visibilitychange", () => {
if (!attempted) return;
if (document.hidden) {
pageLeft = true;
} else if (pageLeft) {
markDone();
}
});
render("opening");
window.setTimeout(openTarget, 80);
})();
</script>
</body>
</html>
@@ -8,8 +8,21 @@
/>
<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 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>Subscription</title>
<link rel="stylesheet" href="/subscription_webapp.css" />
<style>
.app-boot-fallback {
+289 -6
View File
@@ -45,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;
@@ -119,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,
@@ -149,6 +152,12 @@
.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,
@@ -159,6 +168,7 @@
.theme-key-ascii .admin-toolbar-card,
.theme-key-ascii .admin-table-card,
.theme-key-ascii .admin-panel-dash-card,
.theme-key-ascii .admin-config-alerts,
.theme-key-ascii .admin-select-trigger,
.theme-key-ascii .admin-select-content,
.theme-key-ascii .admin-cn-card[data-slot="card"],
@@ -183,12 +193,15 @@
.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,
.theme-key-ascii .admin-revenue-period-btn,
.theme-key-ascii .admin-mobile-toggle,
.theme-key-ascii .admin-nav-item {
.theme-key-ascii .admin-nav-item,
.theme-key-ascii .admin-config-alert-link {
border: 1px solid #ffffff;
border-radius: 0;
background: #000000;
@@ -205,7 +218,8 @@
.theme-key-ascii .admin-nav-item:hover,
.theme-key-ascii .admin-tabs-trigger:hover,
.theme-key-ascii .admin-revenue-period-btn:hover,
.theme-key-ascii .bottom-nav button:hover {
.theme-key-ascii .bottom-nav button:hover,
.theme-key-ascii .admin-config-alert-link:hover {
background: #ffffff;
color: #000000;
}
@@ -230,6 +244,7 @@
.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 {
@@ -259,6 +274,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;
@@ -303,14 +321,151 @@
box-shadow: inset 0 0 0 1px #ffffff;
}
/* ---------- Admin controls: range sliders and sortable rows ---------- */
.theme-key-ascii .ui-range-input {
height: 20px;
}
.theme-key-ascii .ui-range-input::before {
height: 8px;
border: 1px solid #ffffff;
background: #000000;
}
.theme-key-ascii .ui-range-input__range {
height: 8px;
background: #ffffff;
}
.theme-key-ascii .ui-range-input__thumb {
width: 16px;
height: 18px;
border: 1px solid #ffffff;
border-radius: 0;
background: #000000;
box-shadow: none;
transition: none;
}
.theme-key-ascii .ui-range-input__thumb:hover,
.theme-key-ascii .ui-range-input__thumb:focus-visible {
background: #ffffff;
color: #000000;
box-shadow: 0 0 0 1px #000000;
}
.theme-key-ascii .ui-sortable {
--sortable-drop-line: #ffffff;
--sortable-drop-soft: rgba(255, 255, 255, 0.08);
gap: 6px;
}
.theme-key-ascii .ui-sortable-item.is-dragging {
opacity: 0.72;
}
.theme-key-ascii .ui-sortable-item.is-drop-target {
outline: 1px dashed #ffffff;
outline-offset: 2px;
background: rgba(255, 255, 255, 0.08);
box-shadow: none;
}
.theme-key-ascii .ui-sortable-item.is-drop-target::before {
top: -5px;
height: 1px;
border-radius: 0;
background: #ffffff;
box-shadow: none;
}
.theme-key-ascii .ui-sortable-handle {
align-self: center;
height: 28px;
border: 1px solid #ffffff;
background: #000000;
color: #ffffff;
box-shadow: none;
}
.theme-key-ascii .ui-sortable-handle:hover,
.theme-key-ascii .ui-sortable-handle:focus-visible,
.theme-key-ascii .ui-sortable-handle:active {
background: #ffffff;
color: #000000;
}
/* ---------- Admin health config alerts ---------- */
.theme-key-ascii .admin-config-alerts {
position: relative;
padding-left: 18px;
color: #ffffff;
}
.theme-key-ascii .admin-config-alerts::before {
content: "!";
position: absolute;
top: 11px;
left: 7px;
color: #ffffff;
font-family: var(--font-mono);
font-weight: 700;
}
.theme-key-ascii .admin-config-alerts-error {
border-color: #ff5555;
color: #ffaaaa;
}
.theme-key-ascii .admin-config-alerts-error::before {
color: #ff5555;
}
.theme-key-ascii .admin-config-alert-dot {
width: auto;
height: auto;
border-radius: 0;
background: transparent;
color: currentColor;
transform: none;
}
.theme-key-ascii .admin-config-alert-dot::before {
content: ">";
font-family: var(--font-mono);
}
.theme-key-ascii .admin-config-alert-error .admin-config-alert-dot {
background: transparent;
color: #ff5555;
}
.theme-key-ascii .admin-config-alert-link {
padding: 1px 7px;
font-family: var(--font-mono);
opacity: 1;
}
/* ---------- 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,
@@ -412,6 +567,7 @@
}
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;
@@ -420,19 +576,24 @@ body:has(.theme-key-ascii) .field-error-tooltip {
box-shadow: 0 0 0 1px #ffffff;
}
body:has(.theme-key-ascii) .support-select-item {
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) .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) .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;
}
@@ -695,7 +856,8 @@ body:has(.theme-key-ascii) .support-select-item[data-selected] svg {
.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;
}
@@ -1044,12 +1206,16 @@ body:has(.theme-key-ascii) .support-select-item[data-selected] svg {
.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, .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,
@@ -1068,6 +1234,7 @@ body:has(.theme-key-ascii) .support-select-item[data-selected] svg {
.admin-cn-card-skeleton--tall,
.admin-input, .admin-textarea, .admin-btn, .admin-chip,
.admin-tabs-trigger, .admin-tabs-list,
.ui-range-input__thumb, .ui-sortable-item, .ui-sortable-handle,
.admin-nav-item, .admin-revenue-period-btn, .admin-mobile-toggle,
.admin-header, .admin-sidebar, .admin-sidebar-brand,
.admin-dialog,
@@ -1083,6 +1250,9 @@ body:has(.theme-key-ascii) .support-select-item[data-selected] svg {
.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
@@ -1096,6 +1266,71 @@ body:has(.theme-key-ascii) .support-select-item[data-selected] svg {
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.
@@ -1139,3 +1374,51 @@ body:has(.theme-key-ascii) .support-select-item[data-selected] svg {
.theme-key-ascii table tbody tr:hover td {
color: #ffffff;
}
/* ============================================================
* Newer webapp surfaces: telegram banner, traffic/referral
* dropdowns, login language picker. Flatten the accent pills,
* rounded badges and colored gradients these ship with so they
* read as plain console boxes.
* ============================================================ */
/* Telegram notifications banner: the .card chrome is already
* flattened above; only the rounded, color-tinted icon badge needs
* squaring off (the Send glyph itself is whitened by the global rule). */
.theme-key-ascii .telegram-notifications-icon {
border: 1px solid #ffffff;
border-radius: 0;
background: #000000;
color: #ffffff;
}
/* Premium-server / referral-tariff dropdown help glyph: drop the
* pill background in every state (the accent maps to white here,
* which would otherwise paint a white blob behind the icon). */
.theme-key-ascii .premium-server-help-icon,
.theme-key-ascii .premium-server-dropdown summary:hover .premium-server-help-icon,
.theme-key-ascii .premium-server-dropdown[open] .premium-server-help-icon,
.theme-key-ascii .referral-tariff-dropdown summary:hover .premium-server-help-icon,
.theme-key-ascii .referral-tariff-dropdown[open] .premium-server-help-icon {
padding: 0;
border-radius: 0;
background: transparent;
color: #ffffff;
}
/* The check on the selected language sits on a solid white row, so a
* white glyph would vanish — invert it to black to keep it readable. */
.theme-key-ascii .language-select-item[data-selected] .language-select-item-check {
color: #000000 !important;
stroke: #000000 !important;
}
/* Login-screen language trigger: square the rounded chip. */
.theme-key-ascii .auth-language-trigger {
border-radius: 0;
}
/* Render flag emoji as monochrome glyphs to stay in the console palette. */
.theme-key-ascii .emoji-flag {
filter: grayscale(1) contrast(1.05);
}
+1 -1
View File
@@ -9,7 +9,7 @@
"use_primary_accent": false,
"use_in_admin": true,
"css_file": "style.css",
"assets_version": 3,
"assets_version": 7,
"tokens": {
"color_scheme": "dark",
"style_preset": "ascii"
+149
View File
@@ -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,131 @@
.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);
}
/* Admin controls: range sliders and sortable rows */
.theme-key-light .ui-range-input::before {
background: rgba(15, 23, 42, 0.12);
}
.theme-key-light .ui-range-input__range {
background: color-mix(in srgb, var(--accent) 70%, #0f172a);
}
.theme-key-light .ui-range-input__thumb {
border-color: color-mix(in srgb, var(--accent) 68%, #0f172a);
background: #ffffff;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.18);
}
.theme-key-light .ui-range-input__thumb:focus-visible {
box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 18%, transparent);
}
.theme-key-light .ui-sortable-handle {
border-radius: 6px;
color: color-mix(in srgb, var(--admin-muted) 82%, var(--admin-text));
}
.theme-key-light .ui-sortable-handle:hover,
.theme-key-light .ui-sortable-handle:focus-visible {
background: rgba(15, 23, 42, 0.055);
color: color-mix(in srgb, var(--accent) 58%, #0f172a);
}
.theme-key-light .ui-sortable {
--sortable-drop-line: color-mix(in srgb, var(--accent) 64%, #0f172a);
}
.theme-key-light .ui-sortable-item.is-drop-target {
background: color-mix(in srgb, var(--accent) 8%, #ffffff);
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--accent) 26%, transparent),
0 10px 22px color-mix(in srgb, var(--accent) 7%, transparent);
}
.theme-key-light .ui-sortable-item.is-drop-target::before {
background: var(--sortable-drop-line);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 14%, transparent);
}
/* Admin health config alerts */
.theme-key-light .admin-config-alerts {
border-color: color-mix(in srgb, var(--warning) 38%, var(--admin-border));
background: color-mix(in srgb, var(--warning) 9%, #ffffff);
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.06);
}
.theme-key-light .admin-config-alerts-error {
border-color: color-mix(in srgb, var(--danger) 38%, var(--admin-border));
background: color-mix(in srgb, var(--danger) 8%, #ffffff);
}
.theme-key-light .admin-config-alert-link {
background: rgba(255, 255, 255, 0.58);
}
.theme-key-light .admin-config-alert-link:hover {
background: #ffffff;
}
/* Telegram notifications banner: keep the warm warning tint but swap the
* dark-theme inset bevel for the soft drop shadow the other light cards use. */
.theme-key-light .telegram-notifications-card {
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.07);
}
+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": 6,
"tokens": {
"color_scheme": "light"
}
+355 -6
View File
@@ -39,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;
@@ -126,6 +127,7 @@
.theme-key-windows95 svg.lucide-file-text,
.theme-key-windows95 svg.lucide-gift,
.theme-key-windows95 svg.lucide-globe-2,
.theme-key-windows95 svg.lucide-grip-vertical,
.theme-key-windows95 svg.lucide-home,
.theme-key-windows95 svg.lucide-house,
.theme-key-windows95 svg.lucide-info,
@@ -138,12 +140,15 @@
.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,
@@ -228,6 +233,10 @@
--win95-button-icon: var(--win95-icon-globe);
}
.theme-key-windows95 svg.lucide-grip-vertical {
--win95-button-icon: var(--win95-icon-sliders);
}
.theme-key-windows95 svg.lucide-file-text {
--win95-button-icon: var(--win95-icon-file-text);
}
@@ -273,6 +282,10 @@
--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);
}
@@ -281,6 +294,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);
}
@@ -293,6 +310,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);
}
@@ -359,6 +380,7 @@
svg.lucide-file-text,
svg.lucide-gift,
svg.lucide-globe-2,
svg.lucide-grip-vertical,
svg.lucide-home,
svg.lucide-house,
svg.lucide-info,
@@ -371,12 +393,15 @@
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,
@@ -425,12 +450,15 @@
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,
@@ -488,7 +516,13 @@
.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 .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;
@@ -510,6 +544,7 @@
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;
@@ -525,18 +560,23 @@ 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) .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) .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);
}
@@ -560,7 +600,9 @@ body:has(.theme-key-windows95) .support-select-item[data-selected] svg {
.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 .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;
@@ -625,13 +667,17 @@ body:has(.theme-key-windows95) .support-select-item[data-selected] svg {
.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 .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,
@@ -639,6 +685,20 @@ body:has(.theme-key-windows95) .support-select-item[data-selected] svg {
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,
@@ -718,6 +778,9 @@ body:has(.theme-key-windows95) .support-select-item[data-selected] svg {
.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;
@@ -731,6 +794,85 @@ body:has(.theme-key-windows95) .support-select-item[data-selected] svg {
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,
@@ -1035,6 +1177,149 @@ body:has(.theme-key-windows95) .support-select-item[data-selected] svg {
opacity: 0.52;
}
/* Admin controls: range sliders and sortable rows */
.theme-key-windows95 .ui-range-input {
height: 22px;
}
.theme-key-windows95 .ui-range-input::before {
height: 8px;
border: 2px solid;
border-color: #404040 #ffffff #ffffff #404040;
border-radius: 0 !important;
background: #ffffff;
box-shadow:
inset 1px 1px 0 #808080,
inset -1px -1px 0 #dfdfdf;
}
.theme-key-windows95 .ui-range-input__range {
height: 8px;
border-radius: 0 !important;
background: var(--accent);
}
.theme-key-windows95 .ui-range-input__thumb {
width: 14px;
height: 20px;
border: 2px solid;
border-color: #ffffff #404040 #404040 #ffffff;
border-radius: 0 !important;
background: #c0c0c0;
box-shadow:
inset 1px 1px 0 #dfdfdf,
inset -1px -1px 0 #808080;
transition: none;
}
.theme-key-windows95 .ui-range-input__thumb:hover,
.theme-key-windows95 .ui-range-input__thumb:focus-visible {
background: #dfdfdf;
}
.theme-key-windows95 .ui-range-input__thumb[data-active] {
border-color: #404040 #ffffff #ffffff #404040;
box-shadow:
inset 1px 1px 0 #808080,
inset -1px -1px 0 #dfdfdf;
}
.theme-key-windows95 .ui-sortable-item.is-drop-target {
outline: 1px dotted #000000;
outline-offset: 3px;
background: color-mix(in srgb, var(--accent) 12%, var(--admin-surface));
}
.theme-key-windows95 .ui-sortable-item.is-drop-target::before {
top: -7px;
height: 2px;
border-radius: 0;
background: #000080;
box-shadow:
0 1px 0 #ffffff,
0 -1px 0 #000000;
}
.theme-key-windows95 .ui-sortable-handle {
align-self: center;
width: 24px;
height: 28px;
border: 2px solid;
border-color: #ffffff #404040 #404040 #ffffff;
background: #c0c0c0;
color: #000000;
box-shadow:
inset 1px 1px 0 #dfdfdf,
inset -1px -1px 0 #808080;
transition: none;
}
.theme-key-windows95 .ui-sortable-handle:hover,
.theme-key-windows95 .ui-sortable-handle:focus-visible {
background: #dfdfdf;
}
.theme-key-windows95 .ui-sortable-handle:active {
border-color: #404040 #ffffff #ffffff #404040;
box-shadow:
inset 1px 1px 0 #808080,
inset -1px -1px 0 #dfdfdf;
}
/* Admin health config alerts */
.theme-key-windows95 .admin-config-alerts {
border: 2px solid;
border-color: #ffffff #404040 #404040 #ffffff;
background: #ffffcc;
color: #000000;
box-shadow:
inset 1px 1px 0 #dfdfdf,
inset -1px -1px 0 #808080;
}
.theme-key-windows95 .admin-config-alerts-error {
border-color: #ffffff #404040 #404040 #ffffff;
background: #f7d6d6;
color: #000000;
}
.theme-key-windows95 .admin-config-alert-dot {
border-radius: 0;
background: #808000;
box-shadow:
1px 1px 0 #ffffff,
-1px -1px 0 #404040;
}
.theme-key-windows95 .admin-config-alert-error .admin-config-alert-dot {
background: #800000;
}
.theme-key-windows95 .admin-config-alert-link {
border: 2px solid;
border-color: #ffffff #404040 #404040 #ffffff;
border-radius: 0;
background: #c0c0c0;
color: #000000;
box-shadow:
inset 1px 1px 0 #dfdfdf,
inset -1px -1px 0 #808080;
opacity: 1;
}
.theme-key-windows95 .admin-config-alert-link:hover {
background: #dfdfdf;
}
.theme-key-windows95 .admin-config-alert-link:active {
border-color: #404040 #ffffff #ffffff #404040;
box-shadow:
inset 1px 1px 0 #808080,
inset -1px -1px 0 #dfdfdf;
}
.theme-key-windows95 input::placeholder,
.theme-key-windows95 textarea::placeholder,
.theme-key-windows95 .input::placeholder,
@@ -1059,6 +1344,9 @@ body:has(.theme-key-windows95) .support-select-item[data-selected] svg {
.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;
@@ -1116,6 +1404,7 @@ body:has(.theme-key-windows95) .support-select-item[data-selected] svg {
.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);
}
@@ -1133,7 +1422,6 @@ body:has(.theme-key-windows95) .support-select-item[data-selected] svg {
.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,
@@ -1213,3 +1501,64 @@ body:has(.theme-key-windows95) .support-select-item[data-selected] svg {
.theme-key-windows95 a:not(.btn):not(.bottom-nav button):not([class*="-trigger"]):visited {
color: #800080;
}
/* ---------- Newer webapp surfaces: telegram banner, traffic /
* referral dropdowns, login language picker ---------- */
/* Telegram notifications banner: the Card chrome is already beveled by
* the shared .card rule; give the icon badge a raised chip look instead
* of the rounded, color-tinted default (the Send glyph maps to send.png). */
.theme-key-windows95 .telegram-notifications-icon {
border-width: 2px;
border-style: solid;
border-color: #ffffff #404040 #404040 #ffffff;
border-radius: 0;
background: var(--panel);
color: var(--text);
box-shadow:
inset 1px 1px 0 #dfdfdf,
inset -1px -1px 0 #808080;
}
/* Standalone referral-tariff dropdown and bonus rows: bevel them like the
* rest of the surfaces so they don't read as flat 1px boxes. */
.theme-key-windows95 .referral-tariff-dropdown,
.theme-key-windows95 .referral-bonus-row {
border-width: 2px;
border-style: solid;
border-color: #ffffff #404040 #404040 #ffffff;
border-radius: 0;
background: var(--panel);
box-shadow:
inset 1px 1px 0 #dfdfdf,
inset -1px -1px 0 #808080;
}
.theme-key-windows95 .referral-bonus-row-nested {
background: #dfdfdf;
}
/* Premium-server / referral help glyph: drop the rounded accent pill so it
* sits inline as a plain stroked question mark. */
.theme-key-windows95 .premium-server-help-icon,
.theme-key-windows95 .premium-server-dropdown summary:hover .premium-server-help-icon,
.theme-key-windows95 .premium-server-dropdown[open] .premium-server-help-icon,
.theme-key-windows95 .referral-tariff-dropdown summary:hover .premium-server-help-icon,
.theme-key-windows95 .referral-tariff-dropdown[open] .premium-server-help-icon {
padding: 0;
border-radius: 0;
background: transparent;
color: var(--text);
}
/* The selected language row turns navy; its check maps to a dark bitmap,
* so invert it to white to keep it visible. */
.theme-key-windows95 .language-select-item[data-highlighted] .language-select-item-check,
.theme-key-windows95 .language-select-item[data-selected] .language-select-item-check {
filter: brightness(0) invert(1);
}
/* Login-screen language trigger: square the rounded chip. */
.theme-key-windows95 .auth-language-trigger {
border-radius: 0;
}
@@ -9,7 +9,7 @@
"use_primary_accent": false,
"use_in_admin": true,
"css_file": "style.css",
"assets_version": 9,
"assets_version": 14,
"tokens": {
"color_scheme": "light",
"style_preset": "win95"
+46 -2
View File
@@ -1,13 +1,17 @@
import asyncio
import functools
import hmac
import logging
from typing import Awaitable, Callable, Optional
from aiogram import Bot, Dispatcher
from aiogram.webhook.aiohttp_server import SimpleRequestHandler, setup_application
from aiohttp import web
from aiohttp.web_log import AccessLogger, KeyMethod
from sqlalchemy.orm import sessionmaker
from bot.payment_providers import iter_provider_specs, iter_service_keys
from bot.utils.request_security import request_client_ip
from config.settings import Settings
@@ -18,6 +22,39 @@ class SecureSimpleRequestHandler(SimpleRequestHandler):
return hmac.compare_digest(telegram_secret_token, self.secret_token)
class TrustedProxyAccessLogger(AccessLogger):
"""Aiohttp access logger that respects trusted X-Forwarded-For headers."""
def compile_format(self, log_format):
methods = []
for atom in self.FORMAT_RE.findall(log_format):
if atom[1] == "":
format_key = self.LOG_FORMAT_MAP[atom[0]]
method = getattr(type(self), f"_format_{atom[0]}", None)
if method is None:
method = getattr(AccessLogger, f"_format_{atom[0]}")
methods.append(KeyMethod(format_key, method))
else:
format_key = (self.LOG_FORMAT_MAP[atom[2]], atom[1])
method = getattr(type(self), f"_format_{atom[2]}", None)
if method is None:
method = getattr(AccessLogger, f"_format_{atom[2]}")
methods.append(KeyMethod(format_key, functools.partial(method, atom[1])))
compiled = self.FORMAT_RE.sub(r"%s", log_format)
compiled = self.CLEANUP_RE.sub(r"%\1", compiled)
return compiled, methods
@staticmethod
def _format_a(request, response, time):
if request is None:
return "-"
settings = request.app.get("settings") if hasattr(request, "app") else None
trusted_proxies = getattr(settings, "trusted_proxies", None)
client_ip = request_client_ip(request, trusted_proxies=trusted_proxies)
return client_ip or "-"
def _inject_shared_instances(
app: web.Application,
dp: Dispatcher,
@@ -48,6 +85,8 @@ async def build_and_start_web_app(
bot: Bot,
settings: Settings,
async_session_factory: sessionmaker,
*,
after_webhooks_started: Optional[Callable[[], Awaitable[None]]] = None,
):
app = web.Application()
_inject_shared_instances(app, dp, bot, settings, async_session_factory)
@@ -110,7 +149,7 @@ async def build_and_start_web_app(
runners = []
webhooks_runner = web.AppRunner(app)
webhooks_runner = web.AppRunner(app, access_log_class=TrustedProxyAccessLogger)
await webhooks_runner.setup()
runners.append(webhooks_runner)
site = web.TCPSite(
@@ -123,6 +162,8 @@ async def build_and_start_web_app(
logging.info(
f"AIOHTTP server started on http://{settings.WEB_SERVER_HOST}:{settings.WEB_SERVER_PORT}"
)
if after_webhooks_started is not None:
await after_webhooks_started()
if settings.WEBAPP_ENABLED:
from bot.app.web.subscription_webapp import create_subscription_webapp_application
@@ -133,7 +174,10 @@ async def build_and_start_web_app(
settings,
async_session_factory,
)
subscription_runner = web.AppRunner(subscription_app)
subscription_runner = web.AppRunner(
subscription_app,
access_log_class=TrustedProxyAccessLogger,
)
await subscription_runner.setup()
runners.append(subscription_runner)
subscription_site = web.TCPSite(
+45 -4
View File
@@ -42,15 +42,24 @@ from bot.app.web.webapp_auth import (
verify_webapp_session_token,
)
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_auth_service import EmailAuthService, is_disposable_email, normalize_email
from bot.services.email_templates import render_account_merged
from bot.services.promo_code_service import PromoCodeService
from bot.services.referral_service import ReferralService
from bot.services.subscription_service import SubscriptionService
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 config.tariffs_config import (
default_currency_key_for_settings,
default_payment_currency_code_for_settings,
payment_currency_code,
)
from db.dal import payment_dal, security_dal, subscription_dal, support_dal, user_dal
from db.dal.user_dal import UserMergeConflictError
from db.models import Payment, User, UserTelegramAvatar
@@ -59,6 +68,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"
@@ -66,7 +76,12 @@ WEBAPP_UPLOADED_LOGO_DIR = WEBAPP_LOGO_CACHE_DIR / "uploads"
WEBAPP_UPLOADED_LOGO_PATH = "/webapp-uploaded-logo"
WEBAPP_FAVICON_DIR = WEBAPP_LOGO_CACHE_DIR / "favicons"
WEBAPP_FAVICON_PATH = "/webapp-favicon"
WEBAPP_EMOJI_CACHE_DIR = APP_ROOT / "data" / "webapp-emoji"
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_CONFIG_PLACEHOLDER = "<!-- WEBAPP_CONFIG_SCRIPT -->"
WEBAPP_I18N_PLACEHOLDER = "<!-- WEBAPP_I18N_SCRIPT -->"
WEBAPP_JS_PLACEHOLDER = "<!-- WEBAPP_JS_SCRIPT -->"
@@ -76,7 +91,6 @@ DEV_MOCK_END_MARKER = "<!-- WEBAPP_DEV_MOCK_END -->"
WEBAPP_RATE_LIMIT_WINDOW_SECONDS = 60
WEBAPP_RATE_LIMIT_MAX_REQUESTS = 30
WEBAPP_LOGO_MAX_BYTES = 2 * 1024 * 1024
WEBAPP_EMOJI_MAX_BYTES = 4 * 1024 * 1024
WEBAPP_THEME_CSS_MAX_BYTES = 512 * 1024
WEBAPP_THEME_ASSET_MAX_BYTES = 1024 * 1024
WEBAPP_THEME_ASSET_CONTENT_TYPES = {
@@ -96,6 +110,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",
+75 -39
View File
@@ -2,13 +2,29 @@
from ._runtime import * # noqa: F403,F405
from bot.app.web.webapp.cache_helpers import webapp_cached_user_payload
from .auth import _hash_email_password
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
def _email_auth_enabled(settings: Settings) -> bool:
return bool(getattr(settings, "email_auth_configured", True))
def _email_auth_not_configured_response() -> web.Response:
return _json_error(503, "email_auth_not_configured", "Email auth is not configured")
async def account_email_request_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
settings: Settings = request.app["settings"]
if not _email_auth_enabled(settings):
return _email_auth_not_configured_response()
payload = await _read_json(request)
email_payload, validation_error = _validate_model_payload(WebAppEmailPayload, payload)
if validation_error:
@@ -35,6 +51,10 @@ async def account_email_request_route(request: web.Request) -> web.Response:
async def account_email_verify_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
settings: Settings = request.app["settings"]
if not _email_auth_enabled(settings):
return _email_auth_not_configured_response()
rate_limit_response = await _enforce_webapp_rate_limit(
request,
user_id=user_id,
@@ -50,7 +70,6 @@ async def account_email_verify_route(request: web.Request) -> web.Response:
email = email_payload.email
code = str(email_payload.code or "")
email_service: EmailAuthService = request.app["email_auth_service"]
settings: Settings = request.app["settings"]
async_session_factory: sessionmaker = request.app["async_session_factory"]
merge_notice: Optional[Dict[str, Any]] = None
source_panel_uuid: Optional[str] = None
@@ -109,7 +128,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)
@@ -122,28 +142,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:
@@ -178,6 +183,16 @@ async def account_email_verify_route(request: web.Request) -> web.Response:
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
@@ -209,6 +224,9 @@ async def account_email_verify_route(request: web.Request) -> web.Response:
async def account_password_request_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
settings: Settings = request.app["settings"]
if not _email_auth_enabled(settings):
return _email_auth_not_configured_response()
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
@@ -231,6 +249,10 @@ async def account_password_request_route(request: web.Request) -> web.Response:
async def account_password_confirm_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
settings = request.app.get("settings")
if not _email_auth_enabled(settings):
return _email_auth_not_configured_response()
payload = await _read_json(request)
password_payload, validation_error = _validate_model_payload(WebAppSetPasswordPayload, payload)
if validation_error:
@@ -345,28 +367,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:
@@ -401,6 +408,16 @@ async def account_telegram_link_route(request: web.Request) -> web.Response:
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
@@ -421,6 +438,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,
@@ -435,6 +454,17 @@ 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"]
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",
@@ -476,12 +506,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)
+5 -2
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,17 +21,19 @@ 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()
async def _startup(app_obj: web.Application) -> None:
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()
+397 -236
View File
@@ -5,10 +5,12 @@ import gzip
from config.webapp_themes_config import (
default_webapp_theme_asset_file,
default_webapp_theme_css_files,
effective_webapp_theme_accent,
ensure_default_webapp_theme_descriptor_files,
public_theme_payload,
public_themes_catalog_payload,
)
from bot.middlewares.i18n import locale_language_options
_TEXT_FILE_CACHE: Dict[tuple[str, bool], tuple[int, int, str]] = {}
_BINARY_FILE_CACHE: Dict[str, tuple[int, int, bytes]] = {}
@@ -16,12 +18,20 @@ _GZIP_BODY_CACHE: Dict[str, bytes] = {}
_ASSET_NAME_CACHE: Dict[tuple[str, str], tuple[float, str]] = {}
_I18N_PAYLOAD_CACHE: Dict[tuple[int, str, tuple[tuple[str, int, int], ...]], Dict[str, Any]] = {}
_ASSET_NAME_CACHE_TTL_SECONDS = 30.0
WEBAPP_HTML_CACHE_CONTROL = "no-store, no-cache, must-revalidate, max-age=0"
WEBAPP_LEGACY_ASSET_CACHE_CONTROL = "no-store, no-cache, must-revalidate, max-age=0"
async def health_route(request: web.Request) -> web.Response:
return web.json_response({"ok": True})
async def robots_txt_route(request: web.Request) -> web.Response:
response = web.Response(text=ROBOTS_TX, content_type="text/plain")
response.headers["Cache-Control"] = "public, max-age=3600"
return response
async def css_asset_route(request: web.Request) -> web.Response:
return await _css_asset_route(request, base_name="subscription_webapp")
@@ -40,7 +50,7 @@ async def _css_asset_route(request: web.Request, *, base_name: str) -> web.Respo
allow_precompressed=bool(asset_hash),
)
response.headers["Cache-Control"] = (
"public, max-age=31536000, immutable" if asset_hash else "no-cache"
"public, max-age=31536000, immutable" if asset_hash else WEBAPP_LEGACY_ASSET_CACHE_CONTROL
)
return response
@@ -95,9 +105,7 @@ async def theme_css_asset_route(request: web.Request) -> web.Response:
raise web.HTTPNotFound(text="theme_css_not_found") from None
query = getattr(request, "query", {}) or {}
cache_control = (
"public, max-age=31536000, immutable" if query.get("v") else "no-cache"
)
cache_control = "public, max-age=31536000, immutable" if query.get("v") else "no-cache"
try:
stat = path.stat()
if stat.st_size > WEBAPP_THEME_CSS_MAX_BYTES:
@@ -201,12 +209,9 @@ async def theme_asset_route(request: web.Request) -> web.Response:
def _resolve_webapp_logo_url(settings: Settings) -> str:
if getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False):
return ""
raw_logo_url = (settings.WEBAPP_LOGO_URL or "").strip()
raw_logo_url = (getattr(settings, "WEBAPP_LOGO_URL", None) or "").strip()
if not raw_logo_url:
return ""
return WEBAPP_DEFAULT_LOGO_PATH
parsed_logo_url = urlsplit(raw_logo_url)
if parsed_logo_url.scheme == "https":
@@ -216,7 +221,7 @@ def _resolve_webapp_logo_url(settings: Settings) -> str:
return raw_logo_url
if raw_logo_url.startswith("/"):
return raw_logo_url
return ""
return WEBAPP_DEFAULT_LOGO_PATH
def _resolve_webapp_favicon_url(settings: Settings, logo_url: str = "") -> str:
@@ -228,7 +233,9 @@ def _resolve_webapp_favicon_url(settings: Settings, logo_url: str = "") -> str:
resolved = _resolve_webapp_asset_url(raw_logo_favicon_url)
if resolved:
return resolved
return logo_url or ""
if logo_url and logo_url != WEBAPP_DEFAULT_LOGO_PATH:
return logo_url
return WEBAPP_DEFAULT_FAVICON_URL
def _resolve_webapp_asset_url(raw_url: str) -> str:
@@ -296,29 +303,8 @@ def _uploaded_webapp_logo_response(filename: str) -> web.Response:
return response
def _emoji_to_codepoints(value: str) -> str:
return "_".join(f"{ord(char):x}" for char in str(value or "").strip())
def _webapp_emoji_disk_path(codepoints: str, ext: str) -> Path:
return WEBAPP_EMOJI_CACHE_DIR / f"{codepoints}.512.{ext}"
def _webapp_animated_emoji_source_url(codepoints: str, ext: str) -> str:
return f"https://fonts.gstatic.com/s/e/notoemoji/latest/{codepoints}/512.{ext}"
def _webapp_animated_emoji_asset_path(emoji: str, ext: str = "gif") -> str:
codepoints = _emoji_to_codepoints(emoji)
if not codepoints or ext not in {"gif", "webp"}:
return ""
return f"/webapp-emoji/{codepoints}/512.{ext}"
async def webapp_logo_route(request: web.Request) -> web.Response:
settings: Settings = request.app["settings"]
if getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False):
raise web.HTTPNotFound(text="webapp_logo_disabled")
raw_logo_url = (settings.WEBAPP_LOGO_URL or "").strip()
if not raw_logo_url:
raise web.HTTPNotFound(text="webapp_logo_not_configured")
@@ -365,6 +351,16 @@ async def webapp_uploaded_logo_route(request: web.Request) -> web.Response:
return _uploaded_webapp_logo_response(filename)
async def webapp_default_logo_route(request: web.Request) -> web.Response:
settings: Settings = request.app["settings"]
if not settings.WEBAPP_ENABLED:
raise web.HTTPNotFound(text="webapp_disabled")
response = _webapp_default_brand_file_response(WEBAPP_DEFAULT_LOGO_FILE, "image/webp")
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
return response
async def webapp_favicon_route(request: web.Request) -> web.Response:
settings: Settings = request.app["settings"]
if not settings.WEBAPP_ENABLED:
@@ -372,6 +368,82 @@ async def webapp_favicon_route(request: web.Request) -> web.Response:
digest = str(request.match_info.get("digest") or "").strip().lower()
filename = str(request.match_info.get("filename") or "").strip()
return _webapp_favicon_file_response(digest, filename)
async def webapp_current_favicon_route(request: web.Request) -> web.Response:
settings: Settings = request.app["settings"]
if not settings.WEBAPP_ENABLED:
raise web.HTTPNotFound(text="webapp_disabled")
requested_filename = str(request.path.rsplit("/", 1)[-1] or "").strip()
target_filename = _webapp_root_favicon_target_filename(requested_filename)
if not target_filename:
raise web.HTTPNotFound(text="webapp_favicon_not_found")
favicon_url = _resolve_webapp_favicon_url(settings, _resolve_webapp_logo_url(settings))
digest = _webapp_generated_favicon_digest(favicon_url)
if digest:
response = _webapp_favicon_file_response(digest, target_filename)
response.headers["Cache-Control"] = "no-cache"
return response
redirect_url = _webapp_redirectable_favicon_url(favicon_url, target_filename)
if redirect_url:
redirect = web.HTTPFound(location=redirect_url)
redirect.headers["Cache-Control"] = "no-cache"
raise redirect
raise web.HTTPNotFound(text="webapp_favicon_not_found")
def _webapp_root_favicon_target_filename(filename: str) -> str:
if filename == "apple-touch-icon-precomposed.png":
return "apple-touch-icon.png"
if filename in {
"apple-touch-icon.png",
"favicon.ico",
"icon-192.png",
"icon-512.png",
}:
return filename
return ""
def _webapp_generated_favicon_digest(favicon_url: str) -> str:
parsed = urlsplit(str(favicon_url or ""))
path = parsed.path if parsed.scheme or parsed.netloc else str(favicon_url or "")
match = re.fullmatch(
rf"{re.escape(WEBAPP_FAVICON_PATH)}/([0-9a-f]{{16}})/"
r"(?:icon-(?:16|32|48|180|192|512)\.png|apple-touch-icon\.png|favicon\.(?:ico|svg))",
path,
)
return match.group(1) if match else ""
def _webapp_redirectable_favicon_url(favicon_url: str, target_filename: str) -> str:
href = str(favicon_url or "").strip()
if not href:
return ""
parsed = urlsplit(href)
path = parsed.path if parsed.scheme or parsed.netloc else href
suffix = Path(path).suffix.lower()
if target_filename in {"apple-touch-icon.png", "icon-192.png", "icon-512.png"}:
if suffix != ".png":
return ""
elif target_filename == "favicon.ico":
if suffix != ".ico":
return ""
else:
return ""
if parsed.scheme in {"http", "https"} or href.startswith("/"):
return href
return ""
def _webapp_favicon_file_response(digest: str, filename: str) -> web.Response:
if not re.fullmatch(r"[0-9a-f]{16}", digest):
raise web.HTTPNotFound(text="webapp_favicon_not_found")
if not re.fullmatch(
@@ -380,6 +452,9 @@ async def webapp_favicon_route(request: web.Request) -> web.Response:
):
raise web.HTTPNotFound(text="webapp_favicon_not_found")
if digest == WEBAPP_DEFAULT_FAVICON_DIGEST:
return _webapp_default_favicon_file_response(filename)
root = WEBAPP_FAVICON_DIR.expanduser().resolve()
path = (root / digest / filename).resolve()
try:
@@ -406,37 +481,31 @@ async def webapp_favicon_route(request: web.Request) -> web.Response:
return response
async def webapp_animated_emoji_route(request: web.Request) -> web.Response:
codepoints = str(request.match_info.get("codepoints") or "").strip().lower()
ext = str(request.match_info.get("ext") or "").strip().lower()
if not re.fullmatch(r"[0-9a-f]+(?:_[0-9a-f]+)*", codepoints) or ext not in {"gif", "webp"}:
raise web.HTTPNotFound(text="webapp_emoji_not_found")
def _webapp_default_favicon_file_response(filename: str) -> web.Response:
path = WEBAPP_DEFAULT_FAVICON_DIR / filename
content_type = WEBAPP_THEME_ASSET_CONTENT_TYPES.get(path.suffix.lower())
if not content_type:
raise web.HTTPNotFound(text="webapp_favicon_not_found")
emoji_cache_key = f"{codepoints}:{ext}"
emoji_caches: Dict[str, Tuple[bytes, str]] = request.app.setdefault("webapp_emoji_cache", {})
emoji_cache = emoji_caches.get(emoji_cache_key)
if emoji_cache is None:
cache_lock: asyncio.Lock = request.app.setdefault("webapp_emoji_cache_lock", asyncio.Lock())
async with cache_lock:
emoji_cache = emoji_caches.get(emoji_cache_key)
if emoji_cache is None:
emoji_cache = await _load_or_fetch_webapp_animated_emoji(codepoints, ext)
if emoji_cache:
emoji_caches[emoji_cache_key] = emoji_cache
if not emoji_cache:
raise web.HTTPNotFound(text="webapp_emoji_unavailable")
body, content_type = emoji_cache
response = web.Response(body=body, content_type=content_type)
response = _webapp_default_brand_file_response(path, content_type)
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
return response
def _webapp_default_brand_file_response(path: Path, content_type: str) -> web.Response:
try:
body = _read_template_binary_cached(path)
except OSError:
raise web.HTTPNotFound(text="webapp_default_brand_not_found") from None
if not body or len(body) > WEBAPP_LOGO_MAX_BYTES:
raise web.HTTPNotFound(text="webapp_default_brand_not_found")
return web.Response(body=body, content_type=content_type)
async def _warm_webapp_logo_cache(app: web.Application) -> None:
settings: Settings = app["settings"]
if getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False):
return
raw_logo_url = (settings.WEBAPP_LOGO_URL or "").strip()
if not raw_logo_url or not _is_proxyable_webapp_logo_url(raw_logo_url):
return
@@ -458,111 +527,6 @@ async def _warm_webapp_logo_cache(app: web.Application) -> None:
)
async def _warm_webapp_animated_emoji_cache(app: web.Application) -> None:
settings: Settings = app["settings"]
if not getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False):
return
if str(settings.WEBAPP_LOGO_EMOJI_FONT or "").strip() != "noto-color-animated":
return
codepoints = _emoji_to_codepoints(settings.WEBAPP_LOGO_EMOJI)
if not codepoints:
return
app.setdefault("webapp_emoji_cache", {})
app.setdefault("webapp_emoji_cache_lock", asyncio.Lock())
emoji_caches: Dict[str, Tuple[bytes, str]] = app["webapp_emoji_cache"]
for ext in ("gif", "webp"):
emoji_cache_key = f"{codepoints}:{ext}"
if emoji_cache_key in emoji_caches:
continue
loaded_emoji = await _load_or_fetch_webapp_animated_emoji(codepoints, ext)
if loaded_emoji:
emoji_caches[emoji_cache_key] = loaded_emoji
if ext == "gif":
return
async def _load_or_fetch_webapp_animated_emoji(
codepoints: str, ext: str
) -> Optional[Tuple[bytes, str]]:
disk_emoji = await asyncio.to_thread(_read_webapp_animated_emoji_from_disk, codepoints, ext)
if disk_emoji:
return disk_emoji
fetched_emoji = await _fetch_webapp_animated_emoji(codepoints, ext)
if fetched_emoji:
await asyncio.to_thread(
_write_webapp_animated_emoji_to_disk, codepoints, ext, fetched_emoji
)
return fetched_emoji
def _read_webapp_animated_emoji_from_disk(codepoints: str, ext: str) -> Optional[Tuple[bytes, str]]:
path = _webapp_emoji_disk_path(codepoints, ext)
try:
body = path.read_bytes()
except OSError:
return None
if not body or len(body) > WEBAPP_EMOJI_MAX_BYTES:
return None
return body, "image/gif" if ext == "gif" else "image/webp"
def _write_webapp_animated_emoji_to_disk(
codepoints: str, ext: str, emoji: Tuple[bytes, str]
) -> None:
body, _content_type = emoji
if not body or len(body) > WEBAPP_EMOJI_MAX_BYTES:
return
path = _webapp_emoji_disk_path(codepoints, ext)
try:
WEBAPP_EMOJI_CACHE_DIR.mkdir(parents=True, exist_ok=True)
path.write_bytes(body)
except OSError as exc:
logger.warning("Failed to write WEBAPP animated emoji cache: %s", exc)
async def _fetch_webapp_animated_emoji(codepoints: str, ext: str) -> Optional[Tuple[bytes, str]]:
try:
session = await _get_shared_http_session()
timeout = ClientTimeout(total=4)
source_url = _webapp_animated_emoji_source_url(codepoints, ext)
async with session.get(
source_url,
allow_redirects=False,
headers={"Accept": "image/gif,image/webp,image/*,*/*;q=0.8"},
timeout=timeout,
) as response:
if response.status != 200:
return None
content_type = (
(response.headers.get("Content-Type") or "").split(";", 1)[0].strip().lower()
)
expected_content_type = "image/gif" if ext == "gif" else "image/webp"
if content_type and content_type != expected_content_type:
return None
body = bytearray()
async for chunk in response.content.iter_chunked(64 * 1024):
body.extend(chunk)
if len(body) > WEBAPP_EMOJI_MAX_BYTES:
logger.warning("WEBAPP animated emoji exceeded the 4 MiB limit.")
return None
if not body:
return None
return bytes(body), expected_content_type
except Exception as exc:
logger.warning("Failed to fetch WEBAPP animated emoji: %s", exc)
return None
async def _load_or_fetch_webapp_logo(logo_url: str) -> Optional[Tuple[bytes, str]]:
disk_logo = await asyncio.to_thread(_read_webapp_logo_from_disk, logo_url)
if disk_logo:
@@ -760,6 +724,7 @@ async def _security_headers_middleware(request: web.Request, handler):
)
response.headers.setdefault("Referrer-Policy", "no-referrer")
response.headers.setdefault("X-Content-Type-Options", "nosniff")
response.headers.setdefault("X-Robots-Tag", "noindex, nofollow, noarchive")
response.headers.setdefault(
"Permissions-Policy",
(
@@ -807,7 +772,6 @@ def _get_cached_webapp_settings(request: web.Request) -> Dict[str, Any]:
"traffic_packages": settings.traffic_packages,
"stars_traffic_packages": settings.stars_traffic_packages,
"support_url": settings.SUPPORT_LINK or "",
"terms_url": settings.TERMS_OF_SERVICE_URL or "",
"privacy_policy_url": settings.PRIVACY_POLICY_URL or "",
"user_agreement_url": settings.USER_AGREEMENT_URL or "",
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
@@ -818,12 +782,26 @@ def _get_cached_webapp_settings(request: web.Request) -> Dict[str, Any]:
return cache["data"]
def _resolve_app_version() -> str:
# Single source of truth shared with the telemetry worker so the admin
# sidebar and the install beacon always report the same version.
from bot.utils import app_version as app_version_module
global _APP_VERSION_CACHE
app_version_module.APP_ROOT = APP_ROOT
app_version_module._run_git_command = _run_git_command
app_version_module._APP_VERSION_CACHE = _APP_VERSION_CACHE
version = app_version_module.resolve_app_version()
_APP_VERSION_CACHE = app_version_module._APP_VERSION_CACHE
return version
def _run_git_command(*args: str) -> str:
repo_root = APP_ROOT
try:
result = subprocess.run(
["git", *args],
cwd=repo_root,
cwd=APP_ROOT,
check=True,
capture_output=True,
text=True,
@@ -834,47 +812,6 @@ def _run_git_command(*args: str) -> str:
return result.stdout.strip()
def _resolve_app_version() -> str:
global _APP_VERSION_CACHE
if _APP_VERSION_CACHE:
return _APP_VERSION_CACHE
env_version = os.getenv("REMNAWAVE_MINISHOP_VERSION", "").strip()
if env_version:
_APP_VERSION_CACHE = env_version
return env_version
build_version_path = APP_ROOT / ".build-version"
try:
build_version = build_version_path.read_text(encoding="utf-8").strip()
except OSError:
build_version = ""
if build_version:
_APP_VERSION_CACHE = build_version
return build_version
tag = _run_git_command("describe", "--tags", "--abbrev=0")
sha = _run_git_command("rev-parse", "--short", "HEAD")
dirty = bool(_run_git_command("status", "--porcelain"))
if tag and sha:
commits_since_tag = _run_git_command("rev-list", f"{tag}..HEAD", "--count")
if commits_since_tag and commits_since_tag != "0":
version = f"{tag}+{commits_since_tag}.g{sha}"
else:
version = tag
elif sha:
version = f"dev+g{sha}"
else:
version = "dev+unknown"
if dirty:
version = f"{version}-dirty"
_APP_VERSION_CACHE = version
return version
async def _enforce_webapp_rate_limit(
request: web.Request,
*,
@@ -966,7 +903,7 @@ async def _js_asset_route(request: web.Request, *, base_name: str) -> web.Respon
strip_dev_mock=not asset_hash,
)
response.headers["Cache-Control"] = (
"public, max-age=31536000, immutable" if asset_hash else "no-cache"
"public, max-age=31536000, immutable" if asset_hash else WEBAPP_LEGACY_ASSET_CACHE_CONTROL
)
return response
@@ -974,6 +911,30 @@ async def _js_asset_route(request: web.Request, *, base_name: str) -> web.Respon
WEBAPP_BOOTSTRAP_I18N_PREFIXES = ("wa_",)
WEBAPP_BOOTSTRAP_I18N_KEYS = {"menu_support_button"}
WEBAPP_I18N_SCOPES = {"webapp", "admin"}
APP_DEEPLINK_I18N_KEYS = {
"title": "wa_app_launch_title",
"hint": "wa_app_launch_opening_hint",
"manualHint": "wa_app_launch_hint",
"button": "wa_app_launch_button",
"retryButton": "wa_app_launch_retry_button",
"doneTitle": "wa_app_launch_done_title",
"doneHint": "wa_app_launch_done_hint",
"closeButton": "wa_app_launch_close_button",
"unavailableTitle": "wa_app_launch_unavailable_title",
"unavailableHint": "wa_app_launch_unavailable_hint",
}
APP_DEEPLINK_I18N_FALLBACKS = {
"wa_app_launch_title": "Opening app",
"wa_app_launch_opening_hint": "Opening the app on this device...",
"wa_app_launch_hint": "If the app did not open automatically, tap the button below.",
"wa_app_launch_button": "Open app",
"wa_app_launch_retry_button": "Open again",
"wa_app_launch_done_title": "Settings added",
"wa_app_launch_done_hint": "If the app opened, you can close this window.",
"wa_app_launch_close_button": "Close window",
"wa_app_launch_unavailable_title": "App link unavailable",
"wa_app_launch_unavailable_hint": "Return to Telegram and try again.",
}
def _is_webapp_bootstrap_i18n_key(key: str) -> bool:
@@ -1037,7 +998,10 @@ def _build_webapp_bootstrap_payload(request: web.Request) -> Dict[str, Any]:
preview_key = ""
i18n_instance: Optional[object] = request.app.get("i18n")
i18n_scope = _normalize_i18n_scope(request.query.get("i18n_scope") or "webapp")
if i18n_instance and hasattr(i18n_instance, "reload_overrides_from_file"):
i18n_instance.reload_overrides_from_file()
locales_data = getattr(i18n_instance, "locales_data", {}) if i18n_instance else {}
base_locales_data = getattr(i18n_instance, "base_locales_data", {}) if i18n_instance else {}
return {
"config": {
"title": settings.WEBAPP_TITLE,
@@ -1050,9 +1014,6 @@ def _build_webapp_bootstrap_payload(request: web.Request) -> Dict[str, Any]:
"themesDir": settings.WEBAPP_THEMES_DIR,
"themePreviewKey": preview_key,
"logoUrl": cached["logo_url"],
"logoUseEmoji": bool(settings.WEBAPP_LOGO_USE_EMOJI),
"logoEmoji": settings.WEBAPP_LOGO_EMOJI,
"logoEmojiFont": settings.WEBAPP_LOGO_EMOJI_FONT,
"faviconUrl": cached["favicon_url"],
"faviconUseCustom": bool(settings.WEBAPP_FAVICON_USE_CUSTOM),
"apiBase": "/api",
@@ -1063,11 +1024,14 @@ def _build_webapp_bootstrap_payload(request: web.Request) -> Dict[str, Any]:
"telegramOAuthClientId": _resolve_telegram_oauth_client_id(settings) or 0,
"telegramOAuthRequestAccess": _resolve_telegram_oauth_request_access(settings),
"supportUrl": cached["support_url"],
"termsUrl": cached["terms_url"],
"privacyPolicyUrl": cached["privacy_policy_url"],
"userAgreementUrl": cached["user_agreement_url"],
"currency": cached["currency"],
"language": cached["language"],
"languages": locale_language_options(
locales_data.keys(),
base_languages=base_locales_data.keys(),
),
"emailAuthEnabled": cached["email_auth_enabled"],
"appVersion": _resolve_app_version(),
"appRepositoryUrl": APP_REPOSITORY_URL,
@@ -1084,6 +1048,8 @@ async def bootstrap_route(request: web.Request) -> web.Response:
async def i18n_route(request: web.Request) -> web.Response:
i18n_instance: Optional[object] = request.app.get("i18n")
if i18n_instance and hasattr(i18n_instance, "reload_overrides_from_file"):
i18n_instance.reload_overrides_from_file()
scope = _normalize_i18n_scope(request.query.get("scope") or "webapp")
locales_data = getattr(i18n_instance, "locales_data", {}) if i18n_instance else {}
response = web.json_response(
@@ -1097,6 +1063,72 @@ async def i18n_route(request: web.Request) -> web.Response:
return response
def _webapp_page_title(settings: Settings, suffix: str = "") -> str:
base = str(getattr(settings, "WEBAPP_TITLE", "") or "").strip() or "Subscription"
suffix = str(suffix or "").strip()
return f"{base} - {suffix}" if suffix else base
def _webapp_preview_meta_markup(page_title: str) -> str:
escaped_title = html.escape(str(page_title or ""), quote=True)
return "\n".join(
[
f'<meta name="application-name" content="{escaped_title}">',
f'<meta name="apple-mobile-web-app-title" content="{escaped_title}">',
f'<meta property="og:title" content="{escaped_title}">',
'<meta property="og:type" content="website">',
f'<meta property="og:site_name" content="{escaped_title}">',
'<meta name="twitter:card" content="summary">',
f'<meta name="twitter:title" content="{escaped_title}">',
]
)
def _replace_webapp_title(html_text: str, page_title: str) -> str:
escaped_title = html.escape(str(page_title or ""), quote=False)
next_title = f"<title>{escaped_title}</title>"
replaced = re.sub(
r"<title\b[^>]*>.*?</title>",
next_title,
html_text,
count=1,
flags=re.IGNORECASE | re.DOTALL,
)
if replaced != html_text:
return replaced
return html_text.replace("</head>", f"{next_title}\n</head>", 1)
def _replace_webapp_favicon(html_text: str, favicon_markup: str) -> str:
markup = str(favicon_markup or "").strip()
if not markup:
return html_text
replaced = re.sub(
r"<link\b(?=[^>]*\bid=[\"']app-favicon[\"'])[^>]*>",
markup,
html_text,
count=1,
flags=re.IGNORECASE,
)
if replaced != html_text:
return replaced
return html_text.replace("</head>", f"{markup}\n</head>", 1)
def _apply_webapp_head_metadata(html_text: str, page_title: str, favicon_url: str = "") -> str:
html_text = _replace_webapp_title(html_text, page_title)
if 'property="og:title"' not in html_text and "property='og:title'" not in html_text:
meta_markup = _webapp_preview_meta_markup(page_title)
html_text = re.sub(
r"(<title\b[^>]*>.*?</title>)",
lambda match: f"{match.group(1)}\n{meta_markup}",
html_text,
count=1,
flags=re.IGNORECASE | re.DOTALL,
)
return _replace_webapp_favicon(html_text, _favicon_head_markup(favicon_url))
async def index_route(request: web.Request) -> web.Response:
settings: Settings = request.app["settings"]
if not settings.WEBAPP_ENABLED:
@@ -1110,14 +1142,17 @@ async def index_route(request: web.Request) -> web.Response:
bootstrap = _build_webapp_bootstrap_payload(request)
config = bootstrap["config"]
html = _strip_marked_block(html, DEV_MOCK_START_MARKER, DEV_MOCK_END_MARKER)
css_asset_name = _resolve_webapp_css_asset_name()
js_asset_name = _resolve_webapp_js_asset_name()
html = html.replace(
'href="/subscription_webapp.css"',
f'href="/{_resolve_webapp_css_asset_name()}"',
f'href="/{css_asset_name}"',
1,
)
initial_theme_markup = _initial_theme_head_markup(request, initial_theme, primary_color)
if initial_theme_markup:
html = html.replace("</head>", f"{initial_theme_markup}\n</head>", 1)
html = _apply_webapp_head_metadata(html, _webapp_page_title(settings), cached["favicon_url"])
i18n_payload = bootstrap["i18n"]
nonce = request.get("csp_nonce", "")
html = html.replace(
@@ -1138,21 +1173,9 @@ async def index_route(request: web.Request) -> web.Response:
)
html = html.replace(
WEBAPP_JS_PLACEHOLDER,
f'<script src="/{_resolve_webapp_js_asset_name()}" defer></script>',
f'<script src="/{js_asset_name}" defer></script>',
)
favicon_markup = _favicon_head_markup(cached["favicon_url"])
if favicon_markup:
html = html.replace(
'<link id="app-favicon" rel="icon" href="data:," sizes="any">',
favicon_markup,
)
brand_asset_url = cached["logo_url"]
if (
not brand_asset_url
and settings.WEBAPP_LOGO_USE_EMOJI
and settings.WEBAPP_LOGO_EMOJI_FONT == "noto-color-animated"
):
brand_asset_url = _webapp_animated_emoji_asset_path(settings.WEBAPP_LOGO_EMOJI)
if brand_asset_url:
html = html.replace(
"</head>",
@@ -1163,10 +1186,69 @@ async def index_route(request: web.Request) -> web.Response:
1,
)
response = web.Response(text=html, content_type="text/html", charset="utf-8")
response.headers["Cache-Control"] = "no-cache"
response.headers["Cache-Control"] = WEBAPP_HTML_CACHE_CONTROL
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
return response
async def app_deeplink_route(request: web.Request) -> web.Response:
settings: Settings = request.app["settings"]
if not getattr(settings, "WEBAPP_ENABLED", True):
raise web.HTTPNotFound(text="webapp_disabled")
nonce = html.escape(str(request.get("csp_nonce", "")), quote=True)
query = getattr(request, "query", {}) or {}
themes_catalog = getattr(settings, "webapp_themes_catalog", None)
primary_color = getattr(settings, "WEBAPP_PRIMARY_COLOR", None) or "#00fe7a"
initial_theme = (
_initial_theme_for_request(request, themes_catalog) if themes_catalog is not None else None
)
lang = _normalize_language(query.get("lang") or getattr(settings, "DEFAULT_LANGUAGE", "ru"))
messages = _app_deeplink_i18n_payload(request, lang)
page_title = _webapp_page_title(settings, messages["title"])
messages_json = json.dumps(
messages,
ensure_ascii=False,
separators=(",", ":"),
).replace("</", "<\\/")
favicon_url = _resolve_webapp_favicon_url(settings, _resolve_webapp_logo_url(settings))
html_text = (
_read_template_text_cached(APP_DEEPLINK_TEMPLATE_PATH)
.replace("__LANG__", html.escape(lang, quote=True))
.replace("__PAGE_TITLE__", html.escape(page_title, quote=False))
.replace("__NONCE__", nonce)
.replace("__MESSAGES_JSON__", messages_json)
)
initial_theme_markup = _app_deeplink_theme_head_markup(
request,
initial_theme,
themes_catalog,
primary_color,
)
if initial_theme_markup:
html_text = html_text.replace("</head>", f"{initial_theme_markup}\n</head>", 1)
html_text = _apply_webapp_head_metadata(html_text, page_title, favicon_url)
response = web.Response(text=html_text, content_type="text/html", charset="utf-8")
response.headers["Cache-Control"] = "no-store"
return response
def _app_deeplink_i18n_payload(request: web.Request, lang: str) -> Dict[str, str]:
i18n_instance: Optional[object] = request.app.get("i18n")
payload: Dict[str, str] = {}
for payload_key, i18n_key in APP_DEEPLINK_I18N_KEYS.items():
fallback = APP_DEEPLINK_I18N_FALLBACKS[i18n_key]
value = ""
if i18n_instance is not None:
try:
value = str(i18n_instance.gettext(lang, i18n_key) or "")
except Exception as exc:
logger.debug("Failed to resolve open-app i18n key %s: %s", i18n_key, exc)
payload[payload_key] = value if value and value != i18n_key else fallback
return payload
async def _serve_template_asset(
request: web.Request,
filename: str,
@@ -1372,6 +1454,11 @@ def _resolve_webapp_js_asset_name() -> str:
def _resolve_webapp_admin_js_asset_name() -> str:
# The admin bundle is lazy-loaded from the already running Mini App. It now
# ships content-hashed alongside the main bundle (same build, deterministic
# hashes, served immutable), so iOS WebViews fetch fresh admin assets on every
# deploy. The App.svelte loader falls back to the bare runtime build name if a
# hashed asset ever 404s.
return _resolve_hashed_js_asset_name(
kind="admin-js",
base_name="subscription_webapp_admin",
@@ -1394,7 +1481,7 @@ def _resolve_hashed_js_asset_name(*, kind: str, base_name: str) -> str:
if minified_assets:
minified_assets.sort(reverse=True)
return _set_cached_asset_name(kind, minified_assets[0][1])
return _set_cached_asset_name(kind, f"{base_name}.js")
return _set_cached_asset_name(kind, _stable_asset_name_with_version(f"{base_name}.js"))
def _resolve_webapp_css_asset_name() -> str:
@@ -1405,6 +1492,7 @@ def _resolve_webapp_css_asset_name() -> str:
def _resolve_webapp_admin_css_asset_name() -> str:
# Content-hashed and immutable, same rationale as the admin JS bundle above.
return _resolve_hashed_css_asset_name(
kind="admin-css",
base_name="subscription_webapp_admin",
@@ -1427,7 +1515,19 @@ def _resolve_hashed_css_asset_name(*, kind: str, base_name: str) -> str:
if hashed_assets:
hashed_assets.sort(reverse=True)
return _set_cached_asset_name(kind, hashed_assets[0][1])
return _set_cached_asset_name(kind, f"{base_name}.css")
return _set_cached_asset_name(kind, _stable_asset_name_with_version(f"{base_name}.css"))
def _stable_asset_name_with_version(filename: str) -> str:
path = ASSET_DIR / filename
try:
stat = path.stat()
except OSError:
return filename
raw_version = f"{filename}:{int(stat.st_mtime_ns)}:{int(stat.st_size)}"
version = hashlib.sha256(raw_version.encode("utf-8")).hexdigest()[:8]
return f"{filename}?v={version}"
def _get_cached_asset_name(kind: str) -> Optional[str]:
@@ -1479,6 +1579,9 @@ _INITIAL_THEME_TOKEN_CSS_MAP = {
"font_sans": "--font-sans",
"font_logo": "--font-logo",
"font_mono": "--font-mono",
"home_logo_scale": "--home-logo-scale",
"home_logo_scale_desktop": "--home-logo-scale-desktop",
"home_logo_scale_mobile": "--home-logo-scale-mobile",
"admin_bg": "--admin-bg",
"admin_surface": "--admin-surface",
"admin_surface_2": "--admin-surface-2",
@@ -1490,6 +1593,12 @@ _INITIAL_THEME_TOKEN_CSS_MAP = {
"admin_dim": "--admin-dim",
}
_INITIAL_THEME_LOGO_SCALE_TOKENS = {
"home_logo_scale",
"home_logo_scale_desktop",
"home_logo_scale_mobile",
}
def _theme_css_href_for_html(theme: Any) -> str:
css_file = str(getattr(theme, "css_file", "") or "").strip()
@@ -1512,7 +1621,8 @@ def _theme_css_href_for_html(theme: Any) -> str:
def _initial_theme_for_request(request: web.Request, catalog: Any) -> Any:
preview_key = str(request.query.get("theme_preview") or "").strip()
query = getattr(request, "query", {}) or {}
preview_key = str(query.get("theme_preview") or "").strip()
if preview_key:
preview_theme = catalog.theme_by_key(preview_key)
if preview_theme is not None and preview_theme.enabled:
@@ -1524,18 +1634,38 @@ def _initial_theme_for_request(request: web.Request, catalog: Any) -> Any:
return catalog.enabled_themes()[0] if catalog.enabled_themes() else None
def _initial_theme_tokens(theme: Any, primary_color: str) -> Dict[str, Any]:
if theme is None:
return {}
payload = public_theme_payload(theme, primary_color)
tokens = payload.get("tokens") if isinstance(payload, dict) else {}
return tokens if isinstance(tokens, dict) else {}
def _initial_theme_declarations(tokens: Dict[str, Any]) -> List[str]:
declarations = []
for token_key, css_name in _INITIAL_THEME_TOKEN_CSS_MAP.items():
if token_key in _INITIAL_THEME_LOGO_SCALE_TOKENS:
try:
scale = float(tokens.get(token_key) or 0)
except (TypeError, ValueError):
continue
if scale > 0:
declarations.append(f"{css_name}:{scale / 100:g}")
continue
value = str(tokens.get(token_key) or "").strip()
if value:
declarations.append(f"{css_name}:{value}")
return declarations
def _initial_theme_head_markup(request: web.Request, theme: Any, primary_color: str) -> str:
if theme is None:
return ""
payload = public_theme_payload(theme, primary_color)
tokens = payload.get("tokens") if isinstance(payload, dict) else {}
tokens = tokens if isinstance(tokens, dict) else {}
declarations = []
for token_key, css_name in _INITIAL_THEME_TOKEN_CSS_MAP.items():
value = str(tokens.get(token_key) or "").strip()
if value:
declarations.append(f"{css_name}:{value}")
tokens = _initial_theme_tokens(theme, primary_color)
declarations = _initial_theme_declarations(tokens)
scheme = "light" if tokens.get("color_scheme") == "light" else "dark"
bg = str(tokens.get("bg") or "").strip()
@@ -1559,6 +1689,37 @@ def _initial_theme_head_markup(request: web.Request, theme: Any, primary_color:
return stylesheet + "\n" + style_tag
def _app_deeplink_theme_head_markup(
request: web.Request,
theme: Any,
catalog: Any,
primary_color: str,
) -> str:
tokens = _initial_theme_tokens(theme, primary_color)
declarations = _initial_theme_declarations(tokens)
try:
accent = effective_webapp_theme_accent(
catalog,
primary_color,
theme_key=str(getattr(theme, "key", "") or "") or None,
)
except Exception:
accent = str(primary_color or "#00fe7a").strip() or "#00fe7a"
if accent and not any(item.startswith("--accent:") for item in declarations):
declarations.insert(0, f"--accent:{accent}")
if not declarations:
return ""
scheme = "light" if tokens.get("color_scheme") == "light" else "dark"
nonce = html.escape(str(request.get("csp_nonce", "")), quote=True)
return (
f'<style id="webapp-initial-theme" nonce="{nonce}">'
f"html{{color-scheme:{scheme};}}"
f":root{{{';'.join(declarations)}}}"
"</style>"
)
def _favicon_head_markup(favicon_url: str) -> str:
href = str(favicon_url or "").strip()
if not href:
+380 -49
View File
@@ -1,6 +1,7 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
from .common import _invalidate_webapp_user_caches
from .telegram_notifications import _probe_telegram_notifications_for_user_id
def _resolve_telegram_bot_id(bot_token: str) -> Optional[int]:
@@ -339,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,
@@ -350,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,
@@ -388,6 +409,37 @@ async def telegram_oauth_callback_route(request: web.Request) -> web.Response:
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)
@@ -483,6 +535,7 @@ async def auth_token_route(request: web.Request) -> web.Response:
return _json_error(500, "auth_failed", "Auth failed")
await _invalidate_webapp_user_caches(settings, authenticated_user_id, include_devices=True)
await _probe_telegram_notifications_for_user_id(request, int(authenticated_user_id))
token = create_webapp_session_token(settings, int(authenticated_user_id))
return _build_webapp_auth_response(settings, {"ok": True}, token=token)
@@ -660,6 +713,7 @@ async def email_auth_verify_route(request: web.Request) -> web.Response:
session,
referral_param,
current_user_id=None,
settings=settings,
)
db_user, _ = await user_dal.create_email_user(
session,
@@ -768,6 +822,7 @@ async def email_auth_magic_route(request: web.Request) -> web.Response:
session,
referral_param,
current_user_id=None,
settings=settings,
)
db_user, _ = await user_dal.create_email_user(
session,
@@ -957,21 +1012,67 @@ async def _request_email_code(
def _telegram_id_for_user(user: User) -> Optional[int]:
if user.telegram_id:
return int(user.telegram_id)
if user.user_id and int(user.user_id) > 0:
return int(user.user_id)
telegram_id = getattr(user, "telegram_id", None)
if telegram_id:
return int(telegram_id)
user_id = getattr(user, "user_id", None)
if user_id and int(user_id) > 0:
return int(user_id)
return None
def _user_has_linked_telegram(user: User) -> bool:
return bool(getattr(user, "telegram_id", None))
def _email_only_telegram_required_reason(
settings: Settings,
user: User,
*,
without_telegram_enabled_attr: str,
) -> Optional[str]:
if _user_has_linked_telegram(user):
return None
if is_disposable_email(getattr(user, "email", None), settings):
return "disposable_email"
if not bool(getattr(settings, without_telegram_enabled_attr, True)):
return "telegram_required"
return None
def _trial_telegram_required_reason(settings: Settings, user: User) -> Optional[str]:
return _email_only_telegram_required_reason(
settings,
user,
without_telegram_enabled_attr="TRIAL_WITHOUT_TELEGRAM_ENABLED",
)
def _referral_welcome_telegram_required_reason(
settings: Settings,
user: User,
) -> Optional[str]:
return _email_only_telegram_required_reason(
settings,
user,
without_telegram_enabled_attr="REFERRAL_WELCOME_BONUS_WITHOUT_TELEGRAM_ENABLED",
)
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(
@@ -986,23 +1087,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(
@@ -1013,6 +1124,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,
*,
@@ -1050,16 +1208,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"))
@@ -1102,17 +1294,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,
{
@@ -1134,7 +1323,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:
@@ -1146,17 +1334,35 @@ async def _link_telegram_to_user(
return current_user
def _normalize_referral_param(raw: Optional[str]) -> Optional[str]:
def _remnashop_referral_compat_enabled(settings: Optional[Settings]) -> bool:
if settings is None:
return False
return bool(getattr(settings, "MIGRATION_REMNASHOP_REFERRAL_CODE_COMPAT_ENABLED", False))
def _strip_referral_param_prefix(
raw: Optional[str],
*,
preserve_current_u_prefix: bool,
) -> str:
value = (raw or "").strip()
if not value:
return None
return ""
value_lower = value.lower()
if value_lower.startswith("ref_u"):
if value_lower.startswith("ref_u") and not preserve_current_u_prefix:
value = value[5:]
elif value_lower.startswith("ref_"):
value = value[4:]
elif value and value[0].lower() == "u" and len(value) == 10:
return value
def _normalize_referral_param(raw: Optional[str]) -> Optional[str]:
value = _strip_referral_param_prefix(raw, preserve_current_u_prefix=False)
if not value:
return None
if value and value[0].lower() == "u" and len(value) == 10:
value = value[1:]
if not re.fullmatch(r"[A-Za-z0-9]{1,32}", value):
@@ -1164,26 +1370,64 @@ def _normalize_referral_param(raw: Optional[str]) -> Optional[str]:
return value.upper()
def _referral_param_lookup_candidates(
raw: Optional[str],
*,
remnashop_compat: bool,
) -> List[str]:
if not remnashop_compat:
normalized = _normalize_referral_param(raw)
return [normalized] if normalized else []
value = _strip_referral_param_prefix(raw, preserve_current_u_prefix=True)
if not value or not re.fullmatch(r"[A-Za-z0-9._:-]{1,128}", value):
return []
candidates = [value]
if value and value[0].lower() == "u":
candidates.append(value[1:])
unique: List[str] = []
for candidate in candidates:
if candidate and candidate not in unique:
unique.append(candidate)
return unique
async def _resolve_referrer_id(
session: AsyncSession,
raw_referral_param: Optional[str],
*,
current_user_id: Optional[int],
settings: Optional[Settings] = None,
) -> Optional[int]:
normalized = _normalize_referral_param(raw_referral_param)
if not normalized:
remnashop_compat = _remnashop_referral_compat_enabled(settings)
candidates = _referral_param_lookup_candidates(
raw_referral_param,
remnashop_compat=remnashop_compat,
)
if not candidates:
return None
ref_user = None
if normalized.isdigit():
ref_user = await user_dal.get_user_by_id(session, int(normalized))
if not ref_user:
ref_user = await user_dal.get_user_by_referral_code(session, normalized)
if not ref_user:
return None
if current_user_id is not None and int(ref_user.user_id) == int(current_user_id):
return None
return int(ref_user.user_id)
for normalized in candidates:
ref_user = None
if normalized.isdigit() and not remnashop_compat:
ref_user = await user_dal.get_user_by_id(session, int(normalized))
if not ref_user:
ref_user = await user_dal.get_user_by_referral_code(
session,
normalized,
include_legacy=remnashop_compat,
)
if not ref_user and normalized.isdigit() and remnashop_compat:
ref_user = await user_dal.get_user_by_id(session, int(normalized))
if not ref_user:
continue
if current_user_id is not None and int(ref_user.user_id) == int(current_user_id):
continue
return int(ref_user.user_id)
return None
async def _apply_referral_to_existing_user(
@@ -1199,6 +1443,7 @@ async def _apply_referral_to_existing_user(
session,
raw_referral_param,
current_user_id=int(user.user_id),
settings=request.app["settings"],
)
if not referred_by_id:
return False
@@ -1228,6 +1473,21 @@ async def _apply_referral_welcome_bonus_if_needed(
if not raw_referral_param or not user.referred_by_id:
return None
settings: Settings = request.app["settings"]
if _referral_welcome_telegram_required_reason(settings, user):
return None
return await _grant_referral_welcome_bonus_if_eligible(request, session, user)
async def _grant_referral_welcome_bonus_if_eligible(
request: web.Request,
session: AsyncSession,
user: User,
) -> Optional[datetime]:
if not user.referred_by_id:
return None
settings: Settings = request.app["settings"]
referral_welcome_days = max(
0,
@@ -1237,6 +1497,10 @@ async def _apply_referral_welcome_bonus_if_needed(
return None
subscription_service: SubscriptionService = request.app["subscription_service"]
default_tariff_key = None
tariffs_config = getattr(settings, "tariffs_config", None)
if tariffs_config:
default_tariff_key = getattr(tariffs_config, "default_tariff", None)
try:
if await subscription_service.has_active_subscription(session, int(user.user_id)):
return None
@@ -1248,6 +1512,68 @@ async def _apply_referral_welcome_bonus_if_needed(
int(user.user_id),
referral_welcome_days,
reason="referral_welcome_bonus",
tariff_key=default_tariff_key,
)
def _webapp_datetime_text(value: Optional[datetime]) -> Optional[str]:
if not value:
return None
normalized = value if value.tzinfo else value.replace(tzinfo=timezone.utc)
return normalized.strftime("%d.%m.%Y %H:%M")
async def referral_welcome_bonus_claim_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
rate_limit_response = await _enforce_webapp_rate_limit(
request,
user_id=user_id,
action="referral_welcome_claim",
)
if rate_limit_response:
return rate_limit_response
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 _json_error(403, "access_denied", "Access denied")
reason = _referral_welcome_telegram_required_reason(settings, db_user)
if reason:
await session.rollback()
return _json_error(400, "referral_welcome_telegram_required", reason)
end_date = await _grant_referral_welcome_bonus_if_eligible(
request,
session,
db_user,
)
if not end_date:
await session.rollback()
return _json_error(
400,
"referral_welcome_unavailable",
"Referral welcome bonus is not available",
)
await session.commit()
except Exception:
await session.rollback()
logger.exception("Referral welcome bonus claim failed")
return _json_error(500, "referral_welcome_failed", "Referral welcome bonus failed")
await _invalidate_webapp_user_caches(settings, user_id, include_devices=True)
return web.json_response(
{
"ok": True,
"claimed": True,
"end_date": end_date.isoformat() if isinstance(end_date, datetime) else None,
"end_date_text": _webapp_datetime_text(end_date),
}
)
@@ -1259,20 +1585,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:
@@ -1282,12 +1607,14 @@ async def _ensure_user_from_telegram(
session,
referral_param or telegram_user.get("start_param"),
current_user_id=user_id,
settings=settings,
)
db_user, created = await user_dal.create_user(
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),
},
@@ -1295,6 +1622,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
+491 -61
View File
@@ -1,6 +1,55 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
from bot.app.web.webapp.auth import _trial_telegram_required_reason
from bot.app.web.webapp.cache_helpers import invalidate_webapp_user_caches
from db.dal import message_log_dal
_HTML_TAG_RE = re.compile(r"<[^>]+>")
def _plain_text_message(value: Any) -> str:
"""Strip Telegram-style HTML markup from a localized message for the web app."""
text = _HTML_TAG_RE.sub("", str(value))
return html.unescape(text).strip()
def _billing_iso_datetime(value: Optional[Any]) -> Optional[str]:
if not value:
return None
if isinstance(value, datetime):
normalized = value if value.tzinfo else value.replace(tzinfo=timezone.utc)
return normalized.isoformat()
return str(value)
def _billing_datetime_text(value: Optional[Any]) -> Optional[str]:
if not value:
return None
if isinstance(value, datetime):
normalized = value if value.tzinfo else value.replace(tzinfo=timezone.utc)
return normalized.strftime("%d.%m.%Y %H:%M")
text = str(value)
try:
normalized = datetime.fromisoformat(text.replace("Z", "+00:00"))
return normalized.strftime("%d.%m.%Y %H:%M")
except Exception:
return text
def _parse_positive_int_units(value: Any) -> Optional[int]:
if isinstance(value, bool):
return None
try:
number = float(value)
except (TypeError, ValueError):
return None
if not number.is_integer():
return None
integer = int(number)
return integer if integer > 0 else None
async def apply_promo_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
@@ -30,7 +79,7 @@ async def apply_promo_route(request: web.Request) -> web.Response:
)
if not success:
await session.commit()
return _json_error(400, "promo_apply_failed", str(result))
return _json_error(400, "promo_apply_failed", _plain_text_message(result))
await session.commit()
end_date = result if isinstance(result, datetime) else None
return web.json_response(
@@ -62,14 +111,23 @@ async def create_payment_route(request: web.Request) -> web.Response:
return validation_error
method = str(payment_payload.method or "").strip().lower()
settings: Settings = request.app["settings"]
subscription_service: SubscriptionService = request.app["subscription_service"]
cached = _get_cached_webapp_settings(request)
tariffs_config = settings.tariffs_config
default_currency = default_currency_key_for_settings(settings)
default_currency_code = payment_currency_code(default_currency)
traffic_mode = bool(settings.traffic_sale_mode)
sale_mode = "subscription"
traffic_gb_for_payment: Optional[float] = None
hwid_quote: Optional[Dict[str, Any]] = None
requested_sale_mode = _sale_mode_base(str(payment_payload.sale_mode or ""))
if tariffs_config and requested_sale_mode in {"hwid_device", "hwid_devices"}:
if tariffs_config and requested_sale_mode == "hwid_devices_renewal":
return _json_error(400, "invalid_plan", "Device renewal is part of subscription renewal")
if tariffs_config and requested_sale_mode in {
"hwid_device",
"hwid_devices",
}:
tariff_key = str(payment_payload.tariff_key or "").strip()
if not tariff_key:
return _json_error(400, "invalid_plan", "Tariff is not selected")
@@ -77,33 +135,19 @@ async def create_payment_route(request: web.Request) -> web.Response:
tariff = tariffs_config.require(tariff_key)
except Exception:
return _json_error(400, "invalid_plan", "Tariff is not available")
try:
device_count = int(
float(
payment_payload.device_count
if payment_payload.device_count is not None
else payment_payload.months
)
)
except (TypeError, ValueError):
if tariff.billing_model != "period":
return _json_error(400, "invalid_plan", "Device top-up is not available")
device_count = _parse_positive_int_units(
payment_payload.device_count
if payment_payload.device_count is not None
else payment_payload.months
)
if device_count is None:
return _json_error(400, "invalid_plan", "Invalid device package")
packages = tariff.hwid_device_packages
rub_packages = {
int(package.count): float(package.price)
for package in (packages.rub if packages else [])
}
stars_packages = {
int(package.count): int(float(package.price))
for package in (packages.stars if packages else [])
}
price = rub_packages.get(device_count)
stars_price = stars_packages.get(device_count)
if price is None and method != "stars":
if not tariff.hwid_device_packages:
return _json_error(400, "invalid_plan", "Device package is not available")
if method == "stars" and (stars_price is None or int(stars_price) <= 0):
return _json_error(400, "invalid_plan", "Stars price is not configured")
payment_units = device_count
sale_mode = f"hwid_devices@{tariff.key}"
sale_mode = f"{requested_sale_mode}@{tariff.key}"
elif tariffs_config and requested_sale_mode in {"topup", "premium_topup"}:
tariff_key = str(payment_payload.tariff_key or "").strip()
if not tariff_key:
@@ -125,17 +169,17 @@ async def create_payment_route(request: web.Request) -> web.Response:
if requested_sale_mode == "premium_topup"
else tariffs_config.topup_packages_for(tariff)
)
rub_packages = {
currency_packages = {
float(package.gb): float(package.price)
for package in (packages.rub if packages else [])
for package in (packages.for_currency(default_currency) if packages else [])
}
stars_packages = {
float(package.gb): int(float(package.price))
for package in (packages.stars if packages else [])
}
package_key = _resolve_numeric_option_key(rub_packages, traffic_gb)
package_key = _resolve_numeric_option_key(currency_packages, traffic_gb)
stars_package_key = _resolve_numeric_option_key(stars_packages, traffic_gb)
price = rub_packages.get(package_key) if package_key is not None else None
price = currency_packages.get(package_key) if package_key is not None else None
stars_price = (
stars_packages.get(stars_package_key) if stars_package_key is not None else None
)
@@ -166,17 +210,21 @@ async def create_payment_route(request: web.Request) -> web.Response:
return _json_error(400, "invalid_plan", "Invalid traffic package")
if traffic_gb <= 0:
return _json_error(400, "invalid_plan", "Invalid traffic package")
rub_packages = {
currency_packages = {
float(package.gb): float(package.price)
for package in (tariff.traffic_packages.rub if tariff.traffic_packages else [])
for package in (
tariff.traffic_packages.for_currency(default_currency)
if tariff.traffic_packages
else []
)
}
stars_packages = {
float(package.gb): int(float(package.price))
for package in (tariff.traffic_packages.stars if tariff.traffic_packages else [])
}
package_key = _resolve_numeric_option_key(rub_packages, traffic_gb)
package_key = _resolve_numeric_option_key(currency_packages, traffic_gb)
stars_package_key = _resolve_numeric_option_key(stars_packages, traffic_gb)
price = rub_packages.get(package_key) if package_key is not None else None
price = currency_packages.get(package_key) if package_key is not None else None
stars_price = (
stars_packages.get(stars_package_key) if stars_package_key is not None else None
)
@@ -194,7 +242,7 @@ async def create_payment_route(request: web.Request) -> web.Response:
return _json_error(400, "invalid_plan", "Invalid subscription period")
if months not in tariff.enabled_periods:
return _json_error(400, "invalid_plan", "Subscription period is not available")
price = tariff.period_price(months, "rub")
price = tariff.period_price(months, default_currency)
stars_price_raw = tariff.period_price(months, "stars")
stars_price = int(stars_price_raw) if stars_price_raw and stars_price_raw > 0 else None
if price is None and method != "stars":
@@ -251,6 +299,61 @@ async def create_payment_route(request: web.Request) -> web.Response:
if not db_user or db_user.is_banned:
return _json_error(403, "access_denied", "Access denied")
lang = db_user.language_code or settings.DEFAULT_LANGUAGE
if _sale_mode_is_hwid_devices(sale_mode):
sub = await subscription_dal.get_active_subscription_by_user_id(
session, user_id, db_user.panel_user_uuid
)
sale_tariff_key = _sale_mode_tariff_key(sale_mode)
if not sub or not sub.tariff_key or sub.tariff_key != sale_tariff_key:
return _json_error(
400, "subscription_required", "Active tariff subscription is required"
)
try:
active_tariff = tariffs_config.require(sub.tariff_key) if tariffs_config else None
except Exception:
active_tariff = None
if not active_tariff or active_tariff.billing_model != "period":
return _json_error(400, "invalid_plan", "Device top-up is not available")
currency = "stars" if method == "stars" else default_currency
hwid_quote = await subscription_service.quote_hwid_device_topup(
session,
user_id=user_id,
device_count=int(payment_units),
tariff_key=sale_tariff_key,
renewal=False,
currency=currency,
)
if not hwid_quote:
return _json_error(400, "invalid_plan", "Device package is not available")
if method == "stars":
stars_price = int(hwid_quote["price"])
price = 0.0
if stars_price <= 0:
return _json_error(400, "invalid_plan", "Stars price is not configured")
else:
price = float(hwid_quote["price"])
stars_price = None
elif _sale_mode_base(sale_mode) == "subscription" and bool(
payment_payload.renew_hwid_devices
):
currency = "stars" if method == "stars" else default_currency
sale_tariff_key = _sale_mode_tariff_key(sale_mode)
if sale_tariff_key:
hwid_quote = await subscription_service.quote_hwid_device_renewal_for_subscription(
session,
user_id=user_id,
target_tariff_key=sale_tariff_key,
months=int(payment_units),
currency=currency,
)
if hwid_quote:
if method == "stars":
stars_price = int(stars_price or 0) + int(hwid_quote["price"])
else:
price = float(price or 0) + float(hwid_quote["price"])
stars_price = None
admin_ids = {int(item) for item in (settings.ADMIN_IDS or [])}
is_admin = bool(db_user.telegram_id and int(db_user.telegram_id) in admin_ids)
return await _create_subscription_payment(
request=request,
session=session,
@@ -259,9 +362,12 @@ async def create_payment_route(request: web.Request) -> web.Response:
months=payment_units,
price=float(price or 0),
stars_price=stars_price,
currency=default_currency_code,
lang=lang,
sale_mode=sale_mode,
traffic_gb=traffic_gb_for_payment,
is_admin=is_admin,
hwid_quote=hwid_quote,
)
@@ -285,6 +391,13 @@ async def activate_trial_route(request: web.Request) -> web.Response:
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")
telegram_required_reason = _trial_telegram_required_reason(settings, db_user)
if telegram_required_reason:
return _json_error(
400,
"trial_telegram_required",
telegram_required_reason,
)
activation_result = await subscription_service.activate_trial_subscription(session, user_id)
if not activation_result or not activation_result.get("activated"):
@@ -311,10 +424,37 @@ async def activate_trial_route(request: web.Request) -> web.Response:
notification_service = NotificationService(
request.app["bot"], settings, i18n_instance
)
await notification_service.notify_trial_activation(user_id, end_date)
await notification_service.notify_trial_activation(
user_id,
end_date,
username=db_user.username,
email=getattr(db_user, "email", None),
)
except Exception:
logger.exception("Failed to send WebApp trial activation notification")
try:
await message_log_dal.create_message_log_no_commit(
session,
{
"user_id": user_id,
"telegram_username": getattr(db_user, "username", None),
"telegram_first_name": getattr(db_user, "first_name", None),
"event_type": "webapp_trial_activate",
"content": (
f"Trial activated via WebApp for user_id={user_id}; "
f"email={getattr(db_user, 'email', None) or 'N/A'}"
),
"is_admin_event": False,
"target_user_id": user_id,
"timestamp": datetime.now(timezone.utc),
},
)
except Exception:
logger.exception("Failed to add WebApp trial activation audit log")
await session.commit()
try:
from db.dal import ad_dal as _ad_dal
@@ -324,6 +464,8 @@ async def activate_trial_route(request: web.Request) -> web.Response:
await session.rollback()
logger.exception("Failed to mark WebApp trial activation for ad attribution")
await invalidate_webapp_user_caches(settings, user_id)
return web.json_response(
{
"ok": True,
@@ -448,7 +590,9 @@ async def tariff_change_options_route(request: web.Request) -> web.Response:
for tariff in config.enabled_tariffs:
if tariff.key == current.key:
continue
options = subscription_service.calculate_tariff_switch_options(sub, tariff)
options = await subscription_service.calculate_tariff_switch_options_with_hwid(
session, sub, tariff
)
targets.append(_serialize_tariff_change_target(settings, config, tariff, options, lang))
return web.json_response(
{
@@ -506,6 +650,7 @@ async def tariff_change_payment_route(request: web.Request) -> web.Response:
tariff_key = str(payment_payload.tariff_key or "").strip()
settings: Settings = request.app["settings"]
config = settings.tariffs_config
default_currency_code = default_payment_currency_code_for_settings(settings)
if not config:
return _json_error(404, "tariffs_unavailable", "Tariffs are not configured")
if not tariff_key:
@@ -525,7 +670,9 @@ async def tariff_change_payment_route(request: web.Request) -> web.Response:
400, "subscription_required", "Active tariff subscription is required"
)
target = config.require(tariff_key)
options = subscription_service.calculate_tariff_switch_options(sub, target)
options = await subscription_service.calculate_tariff_switch_options_with_hwid(
session, sub, target
)
price = float(options.get("paid_diff_rub") or 0)
if price <= 0:
return _json_error(
@@ -539,6 +686,7 @@ async def tariff_change_payment_route(request: web.Request) -> web.Response:
months=1,
price=price,
stars_price=None,
currency=default_currency_code,
lang=db_user.language_code or settings.DEFAULT_LANGUAGE,
sale_mode=f"tariff_upgrade@{target.key}",
)
@@ -567,25 +715,239 @@ async def device_topup_options_route(request: web.Request) -> web.Response:
400, "subscription_required", "Active tariff subscription is required"
)
tariff = config.require(sub.tariff_key)
if tariff.billing_model != "period":
return _json_error(400, "device_topup_unavailable", "Device top-up is not available")
lang = db_user.language_code or settings.DEFAULT_LANGUAGE
active = await subscription_service.get_active_subscription_details(session, user_id)
plans = _serialize_hwid_device_packages(
settings,
tariff,
tariff.hwid_device_packages,
db_user.language_code or settings.DEFAULT_LANGUAGE,
)
extra_hwid_valid_until = active.get("extra_hwid_devices_valid_until") if active else None
extra_hwid_valid_until_text = (
active.get("extra_hwid_devices_valid_until_text") if active else None
) or _billing_datetime_text(extra_hwid_valid_until)
packages = tariff.hwid_device_packages
default_currency = default_currency_key_for_settings(settings)
default_currency_code = payment_currency_code(default_currency)
if packages and hasattr(packages, "for_currency"):
default_packages = packages.for_currency(default_currency)
else:
default_packages = getattr(packages, default_currency, []) if packages else []
currency_counts = {int(package.count) for package in default_packages}
stars_counts = {int(package.count) for package in (packages.stars if packages else [])}
plans = []
for count in sorted(currency_counts | stars_counts):
currency_quote = (
await subscription_service.quote_hwid_device_topup(
session,
user_id=user_id,
device_count=count,
tariff_key=tariff.key,
renewal=False,
currency=default_currency,
)
if count in currency_counts
else None
)
stars_quote = (
await subscription_service.quote_hwid_device_topup(
session,
user_id=user_id,
device_count=count,
tariff_key=tariff.key,
renewal=False,
currency="stars",
)
if count in stars_counts
else None
)
if not currency_quote and not stars_quote:
continue
quote = currency_quote or stars_quote
valid_from = quote.get("valid_from")
valid_until = quote.get("valid_until")
plan = {
"id": f"{tariff.key}:hwid:{count}",
"tariff_key": tariff.key,
"tariff_name": tariff.name(lang),
"billing_model": tariff.billing_model,
"sale_mode": "hwid_devices",
"renewal": False,
"months": count,
"device_count": count,
"price": float(currency_quote.get("price") if currency_quote else 0),
"currency": default_currency_code,
"title": f"+{count}",
"subtitle": tariff.name(lang),
"valid_from": _billing_iso_datetime(valid_from),
"valid_from_text": _billing_datetime_text(valid_from),
"valid_until": _billing_iso_datetime(valid_until),
"valid_until_text": _billing_datetime_text(valid_until),
"proration_ratio": float(quote.get("proration_ratio") or 0),
}
if stars_quote and int(stars_quote.get("price") or 0) > 0:
plan["stars_price"] = int(stars_quote["price"])
plans.append(plan)
return web.json_response(
{
"ok": True,
"tariff_key": tariff.key,
"tariff_name": tariff.name(db_user.language_code or settings.DEFAULT_LANGUAGE),
"tariff_name": tariff.name(lang),
"current_limit": _coerce_int_or_none(active.get("max_devices")) if active else None,
"extra_hwid_devices": int(sub.extra_hwid_devices or 0),
"extra_hwid_devices": int(active.get("extra_hwid_devices") or 0)
if active
else int(sub.extra_hwid_devices or 0),
"extra_hwid_devices_valid_until": _billing_iso_datetime(extra_hwid_valid_until),
"extra_hwid_devices_valid_until_text": extra_hwid_valid_until_text,
"renewal_available": False,
"renewal_recommended_count": 0,
"plans": plans,
}
)
def _yookassa_payment_payload_for_processing(payload: Dict[str, Any]) -> Dict[str, Any]:
normalized = dict(payload or {})
if not isinstance(normalized.get("amount"), dict):
amount_value = normalized.get("amount_value")
amount_currency = normalized.get("amount_currency")
if amount_value is not None or amount_currency:
normalized["amount"] = {
"value": str(amount_value if amount_value is not None else 0),
"currency": amount_currency or "RUB",
}
return normalized
def _payment_status_can_be_refreshed(payment: Payment) -> bool:
normalized = str(getattr(payment, "status", "") or "").lower()
if normalized == "succeeded":
return False
if normalized in {"failed", "canceled", "cancelled", "failed_creation"}:
return False
return normalized.startswith("pending") or normalized in {"waiting_for_capture", "created"}
async def _refresh_yookassa_payment_status(
request: web.Request,
session: AsyncSession,
payment: Payment,
) -> Payment:
if str(getattr(payment, "provider", "") or "").lower() != "yookassa":
return payment
if not _payment_status_can_be_refreshed(payment):
return payment
yookassa_payment_id = payment.yookassa_payment_id or payment.provider_payment_id
yookassa_service = request.app.get("yookassa_service")
if (
not yookassa_payment_id
or not yookassa_service
or not getattr(yookassa_service, "configured", False)
or not hasattr(yookassa_service, "get_payment_info")
):
return payment
try:
provider_payload = await yookassa_service.get_payment_info(yookassa_payment_id)
except Exception:
logger.exception("Failed to refresh YooKassa payment %s status", payment.payment_id)
return payment
if not provider_payload:
return payment
provider_payload = _yookassa_payment_payload_for_processing(provider_payload)
provider_status = str(provider_payload.get("status") or "").lower()
if provider_status == "succeeded" and provider_payload.get("paid") is True:
from bot.payment_providers.yookassa import (
payment_processing_lock,
process_successful_payment,
)
async with payment_processing_lock:
current = await payment_dal.get_payment_by_db_id(session, payment.payment_id)
if not current:
return payment
if current.status == "succeeded":
return current
try:
await process_successful_payment(
session,
request.app["bot"],
provider_payload,
request.app["i18n"],
request.app["settings"],
request.app["panel_service"],
request.app["subscription_service"],
request.app["referral_service"],
request.app.get("lknpd_service"),
)
await session.commit()
except Exception:
await session.rollback()
logger.exception(
"Failed to process refreshed YooKassa payment %s",
payment.payment_id,
)
return current
return await payment_dal.get_payment_by_db_id(session, payment.payment_id) or current
if provider_status in {"canceled", "cancelled"}:
from bot.payment_providers.yookassa import (
payment_processing_lock,
process_cancelled_payment,
)
async with payment_processing_lock:
current = await payment_dal.get_payment_by_db_id(session, payment.payment_id)
if not current:
return payment
if not _payment_status_can_be_refreshed(current):
return current
try:
await process_cancelled_payment(
session,
request.app["bot"],
provider_payload,
request.app["i18n"],
request.app["settings"],
)
await session.commit()
except Exception:
await session.rollback()
logger.exception(
"Failed to process refreshed cancelled YooKassa payment %s",
payment.payment_id,
)
return current
return await payment_dal.get_payment_by_db_id(session, payment.payment_id) or current
return payment
async def _refresh_wata_payment_status(
request: web.Request,
session: AsyncSession,
payment: Payment,
) -> Payment:
if str(getattr(payment, "provider", "") or "").lower() != "wata":
return payment
if not _payment_status_can_be_refreshed(payment):
return payment
wata_service = request.app.get("wata_service")
if (
not wata_service
or not getattr(wata_service, "configured", False)
or not hasattr(wata_service, "refresh_payment_status")
):
return payment
try:
return await wata_service.refresh_payment_status(session, payment)
except Exception:
logger.exception("Failed to refresh Wata payment %s status", payment.payment_id)
return payment
async def payment_status_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
try:
@@ -598,6 +960,14 @@ async def payment_status_route(request: web.Request) -> web.Response:
payment = await payment_dal.get_payment_by_db_id(session, payment_id)
if not payment or payment.user_id != user_id:
return _json_error(404, "not_found", "Payment not found")
payment = await _refresh_yookassa_payment_status(request, session, payment)
payment = await _refresh_wata_payment_status(request, session, payment)
if payment.status == "succeeded":
await invalidate_webapp_user_caches(
request.app["settings"],
user_id,
include_devices=True,
)
return web.json_response(
{
"ok": True,
@@ -623,7 +993,11 @@ def _sale_mode_is_traffic(sale_mode: str) -> bool:
def _sale_mode_is_hwid_devices(sale_mode: str) -> bool:
return _sale_mode_base(sale_mode) in {"hwid_device", "hwid_devices"}
return _sale_mode_base(sale_mode) in {
"hwid_device",
"hwid_devices",
"hwid_devices_renewal",
}
async def _create_subscription_payment(
@@ -636,10 +1010,14 @@ async def _create_subscription_payment(
price: float,
stars_price: Optional[int],
lang: str,
currency: Optional[str] = None,
sale_mode: str = "subscription",
traffic_gb: Optional[float] = None,
is_admin: bool = False,
hwid_quote: Optional[Dict[str, Any]] = None,
) -> web.Response:
settings: Settings = request.app["settings"]
payment_currency = (currency or default_payment_currency_code_for_settings(settings)).upper()
sale_mode = str(sale_mode or "subscription")
traffic_sale = _sale_mode_is_traffic(sale_mode)
hwid_devices_sale = _sale_mode_is_hwid_devices(sale_mode)
@@ -655,27 +1033,79 @@ async def _create_subscription_payment(
provider_spec = get_provider_spec(method)
if provider_spec and provider_spec.create_webapp_payment:
if not provider_spec.is_visible(settings, request.app):
if not provider_spec.is_visible_for_user(settings, request.app, is_admin=is_admin):
logger.warning(
"WebApp payment method unavailable: method=%s enabled=%s configured=%s",
method,
provider_spec.is_enabled(settings),
provider_spec.is_effectively_enabled(settings),
provider_spec.is_service_configured(request.app),
)
return _json_error(400, "payment_unavailable", "Payment method unavailable")
return await provider_spec.create_webapp_payment(
WebAppPaymentContext(
request=request,
session=session,
user_id=user_id,
method=method,
months=months,
price=price,
stars_price=stars_price,
description=description,
sale_mode=sale_mode,
traffic_gb=traffic_gb,
if not provider_spec.is_usable_for_payment_currency(settings, payment_currency):
logger.warning(
"WebApp payment method does not support currency: method=%s currency=%s",
method,
payment_currency,
)
return _json_error(
400,
"unsupported_currency",
"Payment method does not support this currency",
)
if not provider_spec.is_usable_for_payment_amount(
settings,
payment_currency,
price,
):
logger.warning(
"WebApp payment method does not support amount: method=%s amount=%s currency=%s",
method,
price,
payment_currency,
)
return _json_error(
400,
"payment_amount_below_minimum",
"Payment amount is below the provider minimum",
)
payment_context = WebAppPaymentContext(
request=request,
session=session,
user_id=user_id,
method=method,
months=months,
price=price,
stars_price=stars_price,
currency=payment_currency,
description=description,
sale_mode=sale_mode,
traffic_gb=traffic_gb,
hwid_device_count=hwid_quote.get("device_count") if hwid_quote else None,
hwid_valid_from=hwid_quote.get("valid_from") if hwid_quote else None,
hwid_valid_until=hwid_quote.get("valid_until") if hwid_quote else None,
hwid_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,
)
if provider_spec.reuse_webapp_payment:
from bot.payment_providers.shared import reusable_webapp_payment_response
try:
reusable_response = await reusable_webapp_payment_response(
payment_context,
provider_spec,
)
except Exception:
logger.exception(
"Failed to verify reusable payment: user_id=%s provider=%s",
user_id,
provider_spec.provider_key,
)
reusable_response = None
if reusable_response is not None:
return reusable_response
return await provider_spec.create_webapp_payment(payment_context)
return _json_error(400, "payment_unavailable", "Payment method unavailable")
+63 -4
View File
@@ -2,13 +2,31 @@ from __future__ import annotations
from typing import Any, Awaitable, Callable, Optional
from bot.infra.redis import cache_delete, redis_key
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,
@@ -48,13 +66,32 @@ def invalidate_local_webapp_user_payload(
user_id: int,
) -> None:
key = str(int(user_id))
for (settings_id, cache_namespace, _ttl), cache in tuple(
_WEBAPP_USER_PAYLOAD_CACHES.items()
):
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],
@@ -79,3 +116,25 @@ async def invalidate_webapp_user_caches(
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)
+8 -22
View File
@@ -2,7 +2,11 @@
from ._runtime import * # noqa: F403,F405
from bot.app.web.webapp.cache_helpers import (
invalidate_local_webapp_user_payload,
invalidate_webapp_user_caches as _invalidate_user_payload_caches,
)
from bot.middlewares.i18n import (
is_valid_locale_language_code,
normalize_locale_language_code,
)
@@ -26,25 +30,7 @@ async def _invalidate_webapp_user_caches(
*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)
await _invalidate_user_payload_caches(settings, *user_ids, include_devices=include_devices)
def _validation_error_response(exc: ValidationError) -> web.Response:
@@ -91,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:
+47 -11
View File
@@ -22,7 +22,13 @@ async def devices_route(request: web.Request) -> web.Response:
"devices",
user_id,
int(getattr(settings, "WEBAPP_DEVICES_CACHE_TTL_SECONDS", 5) or 0),
lambda: _load_devices_payload(subscription_service, session, user_id),
lambda: _load_devices_payload(
subscription_service,
session,
user_id,
fallback_panel_user_uuid=str(getattr(db_user, "panel_user_uuid", "") or "").strip()
or None,
),
)
if isinstance(result, dict) and result.get("ok") is True:
return web.json_response({"ok": True, **(result.get("payload") or {})})
@@ -45,16 +51,12 @@ async def _load_devices_payload(
subscription_service: SubscriptionService,
session: AsyncSession,
user_id: int,
fallback_panel_user_uuid: Optional[str] = None,
) -> Dict[str, Any]:
active = await subscription_service.get_active_subscription_details(session, user_id)
panel_user_uuid = active.get("user_id") if active else None
panel_user_uuid = str((active or {}).get("user_id") or fallback_panel_user_uuid or "").strip()
if not panel_user_uuid:
return {
"ok": False,
"status": 400,
"error": "subscription_not_active",
"message": "Subscription is not active",
}
return _empty_inactive_devices_payload()
panel_service = getattr(subscription_service, "panel_service", None)
if not panel_service:
@@ -82,17 +84,42 @@ async def _load_devices_payload(
"ok": True,
"payload": {
"enabled": True,
"subscription_active": _devices_subscription_is_active(active),
"current_devices": len(devices),
"max_devices": max_devices,
"max_devices_label": _format_devices_limit(max_devices),
"devices": [
_serialize_device(device, index)
for index, device in enumerate(devices, start=1)
_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:
user_id = _require_user_id(request)
rate_limit_response = await _enforce_webapp_rate_limit(
@@ -194,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()
@@ -209,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",
}
+1
View File
@@ -49,6 +49,7 @@ class WebAppPaymentCreatePayload(BaseModel):
device_count: Any = None
tariff_key: Optional[constr(max_length=128)] = None
sale_mode: Optional[constr(max_length=64)] = None
renew_hwid_devices: Optional[bool] = None
description: Optional[constr(max_length=4096)] = None
comment: Optional[constr(max_length=4096)] = None
note: Optional[constr(max_length=4096)] = None
+24 -5
View File
@@ -3,9 +3,14 @@ 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)
@@ -15,15 +20,23 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
app.router.add_get(
(
"/admin/{section:stats|users|payments|promos|ads|broadcast|logs|tariffs|"
"appearance|settings|support}"
"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,10 +46,6 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
rf"{WEBAPP_FAVICON_PATH}/{{digest:[0-9a-f]{{16}}}}/{{filename:[A-Za-z0-9_.-]+}}",
webapp_favicon_route,
)
app.router.add_get(
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(
@@ -60,6 +69,11 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
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)
@@ -67,6 +81,11 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
app.router.add_post("/api/account/password/request", account_password_request_route)
app.router.add_post("/api/account/password/confirm", account_password_confirm_route)
app.router.add_post("/api/account/telegram/link", account_telegram_link_route)
app.router.add_post(
"/api/account/telegram/notifications/probe",
account_telegram_notifications_probe_route,
)
app.router.add_post("/api/referral/welcome-bonus/claim", referral_welcome_bonus_claim_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)
+408 -54
View File
@@ -1,7 +1,19 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
from bot.app.web.webapp.auth import (
_referral_welcome_telegram_required_reason,
_trial_telegram_required_reason,
_user_has_linked_telegram,
)
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]:
@@ -52,10 +64,37 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
if db_user.panel_user_uuid
else None
)
trial_available = bool(
install_share_token = (
await subscription_dal.ensure_install_share_token(session, local_sub)
if active and local_sub
else None
)
trial_base_available = bool(
settings.TRIAL_ENABLED
and settings.TRIAL_DURATION_DAYS > 0
and not await subscription_service.has_had_any_subscription(session, user_id)
and not await subscription_service.has_trial_blocking_subscription(session, user_id)
)
trial_telegram_required_reason = (
_trial_telegram_required_reason(settings, db_user) if trial_base_available else None
)
trial_available = bool(trial_base_available and not trial_telegram_required_reason)
lang = _normalize_language(db_user.language_code or settings.DEFAULT_LANGUAGE)
plans_payload = _serialize_plans(
settings,
lang,
subscription_options=cached["subscription_options"],
stars_subscription_options=cached["stars_subscription_options"],
traffic_packages=cached["traffic_packages"],
stars_traffic_packages=cached["stars_traffic_packages"],
)
await _attach_hwid_renewal_quotes_to_plans(
session,
subscription_service,
user_id=user_id,
settings=settings,
active=active,
local_sub=local_sub,
plans=plans_payload,
)
avatar = await _ensure_cached_telegram_avatar(request, session, db_user)
try:
@@ -63,9 +102,21 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
except Exception:
await session.rollback()
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_linked = _user_has_linked_telegram(db_user)
referral_welcome_days = max(0, int(getattr(settings, "REFERRAL_WELCOME_BONUS_DAYS", 0) or 0))
referral_welcome_telegram_required_reason = (
_referral_welcome_telegram_required_reason(settings, db_user)
if db_user.referred_by_id and not active and referral_welcome_days > 0
else None
)
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,
@@ -76,36 +127,52 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
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_linked": telegram_linked,
"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,
"webapp_link": webapp_referral_link,
"invited_count": referral_stats.get("invited_count", 0),
"purchased_count": referral_stats.get("purchased_count", 0),
"welcome_bonus_days": max(
0, int(getattr(settings, "REFERRAL_WELCOME_BONUS_DAYS", 0) or 0)
"welcome_bonus_days": referral_welcome_days,
"welcome_bonus_without_telegram_enabled": bool(
getattr(settings, "REFERRAL_WELCOME_BONUS_WITHOUT_TELEGRAM_ENABLED", True)
),
"welcome_bonus_requires_telegram": bool(
referral_welcome_telegram_required_reason and not telegram_linked
),
"welcome_bonus_block_reason": referral_welcome_telegram_required_reason,
"one_bonus_per_referee": bool(
getattr(settings, "REFERRAL_ONE_BONUS_PER_REFEREE", False)
),
"bonus_details": _serialize_referral_bonus_details(settings, lang),
},
"plans": _serialize_plans(
"plans": plans_payload,
"payment_methods": _serialize_payment_methods(
settings,
request.app,
lang,
subscription_options=cached["subscription_options"],
stars_subscription_options=cached["stars_subscription_options"],
traffic_packages=cached["traffic_packages"],
stars_traffic_packages=cached["stars_traffic_packages"],
is_admin=is_admin,
),
"payment_methods": _serialize_payment_methods(settings, request.app, lang),
"themes_catalog": public_themes_catalog_payload(
settings.webapp_themes_catalog,
settings.WEBAPP_PRIMARY_COLOR or "#00fe7a",
@@ -128,21 +195,94 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
),
"trial_enabled": bool(settings.TRIAL_ENABLED),
"trial_available": trial_available,
"trial_without_telegram_enabled": bool(
getattr(settings, "TRIAL_WITHOUT_TELEGRAM_ENABLED", True)
),
"trial_requires_telegram": bool(trial_telegram_required_reason and not telegram_linked),
"trial_block_reason": trial_telegram_required_reason,
"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:
@@ -179,11 +319,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,
@@ -192,6 +347,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")
@@ -220,10 +378,12 @@ def _serialize_subscription(
and tariff.premium_topup_packages.has_any()
)
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.
max_devices = _coerce_int_or_none(active.get("max_devices"))
# max_devices == 0 or None means unlimited — top-up is pointless in that case.
can_topup_devices = bool(
tariff.has_hwid_device_packages()
and _coerce_int_or_none(active.get("max_devices")) != 0
tariff.billing_model == "period"
and tariff.has_hwid_device_packages()
and max_devices not in (None, 0)
)
except Exception:
can_topup_regular_traffic = False
@@ -231,6 +391,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",
@@ -240,6 +417,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")),
@@ -278,12 +458,142 @@ 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 _webapp_iso_datetime(value: Optional[Any]) -> Optional[str]:
if not value:
return None
if isinstance(value, datetime):
normalized = value if value.tzinfo else value.replace(tzinfo=timezone.utc)
return normalized.isoformat()
return str(value)
def _webapp_datetime_text(value: Optional[Any]) -> Optional[str]:
if not value:
return None
if isinstance(value, datetime):
normalized = value if value.tzinfo else value.replace(tzinfo=timezone.utc)
return normalized.strftime("%d.%m.%Y %H:%M")
return str(value)
async def _attach_hwid_renewal_quotes_to_plans(
session: AsyncSession,
subscription_service: SubscriptionService,
*,
user_id: int,
settings: Settings,
active: Optional[Dict[str, Any]],
local_sub: Optional[Any],
plans: List[Dict[str, Any]],
) -> None:
quote_method = getattr(subscription_service, "quote_hwid_device_renewal_for_subscription", None)
if not callable(quote_method):
return
if not active or not local_sub or not settings.tariffs_config:
return
if not active.get("end_date") or int(active.get("extra_hwid_devices") or 0) <= 0:
return
default_currency = default_currency_key_for_settings(settings)
default_currency_code = payment_currency_code(default_currency)
for plan in plans:
if str(plan.get("sale_mode") or "subscription") != "subscription":
continue
target_tariff_key = str(plan.get("tariff_key") or "").strip()
if not target_tariff_key:
continue
try:
months = int(plan.get("months") or 0)
except (TypeError, ValueError):
continue
if months <= 0:
continue
try:
currency_quote = await quote_method(
session,
user_id=user_id,
target_tariff_key=target_tariff_key,
months=months,
currency=default_currency,
)
stars_quote = await quote_method(
session,
user_id=user_id,
target_tariff_key=target_tariff_key,
months=months,
currency="stars",
)
except Exception:
logger.exception(
"Failed to quote HWID renewal for plan %s/%s",
target_tariff_key,
months,
)
continue
quote = currency_quote or stars_quote
if not quote:
continue
valid_from = quote.get("valid_from")
valid_until = quote.get("valid_until")
active_until = quote.get("active_until")
renewal = {
"available": True,
"device_count": int(quote.get("device_count") or 0),
"price": float(currency_quote.get("price") if currency_quote else 0),
"currency": default_currency_code,
"valid_from": _webapp_iso_datetime(valid_from),
"valid_from_text": _webapp_datetime_text(valid_from),
"valid_until": _webapp_iso_datetime(valid_until),
"valid_until_text": _webapp_datetime_text(valid_until),
"active_until": _webapp_iso_datetime(active_until),
"active_until_text": _webapp_datetime_text(active_until),
"pricing_period_months": int(quote.get("pricing_period_months") or months),
}
if stars_quote and int(stars_quote.get("price") or 0) > 0:
renewal["stars_price"] = int(stars_quote["price"])
plan["hwid_renewal"] = renewal
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,
@@ -295,26 +605,34 @@ def _serialize_plans(
) -> List[Dict[str, Any]]:
tariffs_config = settings.tariffs_config
if tariffs_config:
default_currency = default_currency_key_for_settings(settings)
default_currency_code = payment_currency_code(default_currency)
plans: List[Dict[str, Any]] = []
for tariff in tariffs_config.enabled_tariffs:
common = {
"tariff_key": tariff.key,
"is_default_tariff": tariff.key == tariffs_config.default_tariff,
"tariff_name": tariff.name(lang),
"billing_model": tariff.billing_model,
"description": tariff.description(lang),
"squad_uuids": tariff.squad_uuids,
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
"currency": default_currency_code,
"hwid_device_limit": tariff.hwid_device_limit,
"hwid_device_packages": _serialize_hwid_device_packages(
settings,
tariff,
tariff.hwid_device_packages,
lang,
),
)
if tariff.billing_model == "period"
else [],
}
if tariff.billing_model == "period":
for months in sorted(tariff.enabled_periods):
price = tariff.period_price(int(months), "rub")
# Render periods in the configured order (enabled_periods is the
# source of truth for purchase-period ordering, matching the bot
# keyboards). Do not sort so admins can reorder via drag & drop.
for months in tariff.enabled_periods:
price = tariff.period_price(int(months), default_currency)
stars_price = tariff.period_price(int(months), "stars")
if price is None and (stars_price is None or int(stars_price) <= 0):
continue
@@ -332,9 +650,13 @@ def _serialize_plans(
plan["stars_price"] = int(stars_price)
plans.append(plan)
else:
rub_packages = {
currency_packages = {
float(package.gb): float(package.price)
for package in (tariff.traffic_packages.rub if tariff.traffic_packages else [])
for package in (
tariff.traffic_packages.for_currency(default_currency)
if tariff.traffic_packages
else []
)
}
stars_packages = {
float(package.gb): int(float(package.price))
@@ -342,8 +664,15 @@ def _serialize_plans(
tariff.traffic_packages.stars if tariff.traffic_packages else []
)
}
for traffic_gb in sorted(set(rub_packages) | set(stars_packages)):
price = rub_packages.get(traffic_gb)
# Preserve the configured package order (default-currency list first,
# then any Stars-only volumes) so admins can reorder via drag & drop.
# Matches the bot keyboard, which iterates the package list as-is.
ordered_gb: List[float] = []
for traffic_gb in list(currency_packages) + list(stars_packages):
if traffic_gb not in ordered_gb:
ordered_gb.append(traffic_gb)
for traffic_gb in ordered_gb:
price = currency_packages.get(traffic_gb)
stars_price = stars_packages.get(traffic_gb)
if price is None and (stars_price is None or int(stars_price) <= 0):
continue
@@ -429,16 +758,19 @@ def _serialize_topup_packages(
sale_mode: str = "topup",
title_prefix: str = "",
) -> List[Dict[str, Any]]:
rub_packages = {
float(package.gb): float(package.price) for package in (packages.rub if packages else [])
default_currency = default_currency_key_for_settings(settings)
default_currency_code = payment_currency_code(default_currency)
currency_packages = {
float(package.gb): float(package.price)
for package in (packages.for_currency(default_currency) if packages else [])
}
stars_packages = {
float(package.gb): int(float(package.price))
for package in (packages.stars if packages else [])
}
plans: List[Dict[str, Any]] = []
for traffic_gb in sorted(set(rub_packages) | set(stars_packages)):
price = rub_packages.get(traffic_gb)
for traffic_gb in sorted(set(currency_packages) | set(stars_packages)):
price = currency_packages.get(traffic_gb)
stars_price = stars_packages.get(traffic_gb)
if price is None and (stars_price is None or int(stars_price) <= 0):
continue
@@ -452,7 +784,7 @@ def _serialize_topup_packages(
"months": int(traffic_value) if traffic_value.is_integer() else traffic_value,
"traffic_gb": traffic_value,
"price": float(price or 0),
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
"currency": default_currency_code,
"title": f"{title_prefix}{_format_traffic_title(traffic_value, lang)}",
"subtitle": tariff.premium_name(lang)
if sale_mode == "premium_topup"
@@ -470,16 +802,19 @@ def _serialize_hwid_device_packages(
packages: Optional[Any],
lang: str,
) -> List[Dict[str, Any]]:
rub_packages = {
int(package.count): float(package.price) for package in (packages.rub if packages else [])
default_currency = default_currency_key_for_settings(settings)
default_currency_code = payment_currency_code(default_currency)
currency_packages = {
int(package.count): float(package.price)
for package in (packages.for_currency(default_currency) if packages else [])
}
stars_packages = {
int(package.count): int(float(package.price))
for package in (packages.stars if packages else [])
}
plans: List[Dict[str, Any]] = []
for count in sorted(set(rub_packages) | set(stars_packages)):
price = rub_packages.get(count)
for count in sorted(set(currency_packages) | set(stars_packages)):
price = currency_packages.get(count)
stars_price = stars_packages.get(count)
if price is None and (stars_price is None or int(stars_price) <= 0):
continue
@@ -492,7 +827,7 @@ def _serialize_hwid_device_packages(
"months": int(count),
"device_count": int(count),
"price": float(price or 0),
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
"currency": default_currency_code,
"title": f"+{count}",
"subtitle": tariff.name(lang),
}
@@ -509,6 +844,8 @@ def _serialize_tariff_change_target(
options: Dict[str, Any],
lang: str,
) -> Dict[str, Any]:
default_currency = default_currency_key_for_settings(settings)
default_currency_code = payment_currency_code(default_currency)
actions: List[Dict[str, Any]] = []
mode = str(options.get("mode") or "")
if mode == "period_to_period":
@@ -519,6 +856,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)
@@ -529,7 +868,7 @@ def _serialize_tariff_change_target(
"kind": "payment",
"title": "paid_diff",
"price": paid_diff,
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
"currency": default_currency_code,
}
)
elif mode == "period_to_traffic":
@@ -540,6 +879,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(
@@ -549,13 +890,17 @@ def _serialize_tariff_change_target(
"title": f"+{package.gb:g} GB",
"traffic_gb": float(package.gb),
"price": float(package.price),
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
"currency": default_currency_code,
}
for package in (tariff.traffic_packages.rub if tariff.traffic_packages else [])
for package in (
tariff.traffic_packages.for_currency(default_currency)
if tariff.traffic_packages
else []
)
)
else:
for months in tariff.enabled_periods:
price = tariff.period_price(int(months), "rub")
price = tariff.period_price(int(months), default_currency)
if price:
actions.append(
{
@@ -564,7 +909,7 @@ def _serialize_tariff_change_target(
"months": int(months),
"title": _format_months_title(int(months), lang),
"price": float(price),
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
"currency": default_currency_code,
}
)
return {
@@ -582,22 +927,31 @@ def _serialize_payment_methods(
settings: Settings,
app: web.Application,
lang: str = "ru",
*,
is_admin: bool = False,
) -> List[Dict[str, Any]]:
from bot.payment_providers import get_provider_spec, resolve_provider_presentation
methods: List[Dict[str, Any]] = []
payment_currency = default_payment_currency_code_for_settings(settings)
for method in settings.payment_methods_order:
method = method.lower()
spec = get_provider_spec(method)
if spec and spec.is_visible(settings, app):
if (
spec
and spec.is_visible_for_user(settings, app, is_admin=is_admin)
and spec.is_usable_for_payment_currency(settings, payment_currency)
):
presentation = resolve_provider_presentation(spec, settings, language=lang)
methods.append(
{
"id": method,
"name": presentation.webapp_label,
"icon": presentation.webapp_icon,
}
)
payload = {
"id": method,
"name": presentation.webapp_label,
"icon": presentation.webapp_icon,
}
minimum = spec.payment_minimum(settings, payment_currency)
if minimum:
payload.update(minimum)
methods.append(payload)
return methods
@@ -0,0 +1,70 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
from bot.services.telegram_notifications import (
TELEGRAM_NOTIFICATIONS_ENABLED,
probe_telegram_notifications,
telegram_notifications_start_link,
)
from .common import _invalidate_webapp_user_caches
async def _probe_telegram_notifications_for_user_id(
request: web.Request,
user_id: int,
*,
force: bool = False,
) -> Dict[str, Any]:
settings: Settings = request.app["settings"]
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
try:
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user or db_user.is_banned:
await session.rollback()
return {
"ok": False,
"status": "access_denied",
"enabled": False,
"start_link": telegram_notifications_start_link(
request.app.get("bot_username") or ""
),
}
result = await probe_telegram_notifications(
session=session,
bot=request.app["bot"],
settings=settings,
i18n=request.app.get("i18n"),
user=db_user,
bot_username=request.app.get("bot_username") or "",
force=force,
)
await session.commit()
status = str(result.get("status") or "")
await _invalidate_webapp_user_caches(settings, int(db_user.user_id))
return {
"ok": bool(result.get("ok")),
"status": status,
"enabled": status == TELEGRAM_NOTIFICATIONS_ENABLED,
"start_link": result.get("start_link"),
}
except Exception:
await session.rollback()
logger.exception("Telegram notification probe failed")
return {
"ok": False,
"status": "unknown",
"enabled": False,
"start_link": telegram_notifications_start_link(
request.app.get("bot_username") or ""
),
}
async def account_telegram_notifications_probe_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
force = True
result = await _probe_telegram_notifications_for_user_id(request, user_id, force=force)
if result.get("status") == "access_denied":
return _json_error(403, "access_denied", "Access denied")
return web.json_response({"ok": True, "telegram_notifications": result})
+3 -1
View File
@@ -155,7 +155,7 @@ async def change_broadcast_target_handler(
return
new_target = callback.data.split(":")[1]
if new_target not in {"all", "active", "inactive"}:
if new_target not in {"all", "active", "inactive", "expired"}:
await callback.answer("Unknown target.", show_alert=True)
return
@@ -247,6 +247,8 @@ async def confirm_broadcast_callback_handler(
user_ids = await user_dal.get_user_ids_with_active_subscription(session)
elif target == "inactive":
user_ids = await user_dal.get_user_ids_without_active_subscription(session)
elif target == "expired":
user_ids = await user_dal.get_user_ids_with_expired_subscription(session)
else:
user_ids = await user_dal.get_all_active_user_ids_for_broadcast(session)
-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",
+13 -5
View File
@@ -13,6 +13,7 @@ from bot.middlewares.i18n import JsonI18n
from bot.payment_providers import pending_statuses
from bot.services.panel_api_service import PanelApiService
from config.settings import Settings
from config.tariffs_config import default_payment_currency_code_for_settings
from db.dal import panel_sync_dal, payment_dal, user_dal
from db.models import PanelSyncStatus, Payment
@@ -71,11 +72,17 @@ async def show_statistics_handler(
f"📊 {_('admin_user_stats_total_label')}: <b>{user_stats['total_users']}</b>"
)
# Removed: Active today moved to panel stats
stats_text_parts.append(
f"📡 {_('admin_user_stats_active_subscription_label')}: <b>{user_stats['active_subscriptions']}</b>" # noqa: E501
)
stats_text_parts.append(
f"💳 {_('admin_user_stats_paid_subs_label')}: <b>{user_stats['paid_subscriptions']}</b>"
)
stats_text_parts.append(
f"🆓 {_('admin_user_stats_trial_label')}: <b>{user_stats['trial_users']}</b>"
f"🧪 {_('admin_user_stats_trial_label')}: <b>{user_stats['trial_users']}</b>"
)
stats_text_parts.append(
f"🎁 {_('admin_user_stats_free_subscription_label')}: <b>{user_stats['free_subscription_users']}</b>" # noqa: E501
)
stats_text_parts.append(
f"😴 {_('admin_user_stats_inactive_label')}: <b>{user_stats['inactive_users']}</b>"
@@ -189,19 +196,20 @@ async def show_statistics_handler(
# Financial statistics
financial_stats = await payment_dal.get_financial_statistics(session)
currency = default_payment_currency_code_for_settings(settings)
stats_text_parts.append(f"\n<b>💰 {_('admin_financial_stats_header')}</b>")
stats_text_parts.append(
f"📅 {_('admin_financial_today_label')}: <b>{financial_stats['today_revenue']:.2f} RUB</b> ({financial_stats['today_payments_count']} {_('admin_financial_payments_label')})" # noqa: E501
f"📅 {_('admin_financial_today_label')}: <b>{financial_stats['today_revenue']:.2f} {currency}</b> ({financial_stats['today_payments_count']} {_('admin_financial_payments_label')})" # noqa: E501
)
stats_text_parts.append(
f"📅 {_('admin_financial_week_label')}: <b>{financial_stats['week_revenue']:.2f} RUB</b>"
f"📅 {_('admin_financial_week_label')}: <b>{financial_stats['week_revenue']:.2f} {currency}</b>" # noqa: E501
)
stats_text_parts.append(
f"📅 {_('admin_financial_month_label')}: <b>{financial_stats['month_revenue']:.2f} RUB</b>"
f"📅 {_('admin_financial_month_label')}: <b>{financial_stats['month_revenue']:.2f} {currency}</b>" # noqa: E501
)
stats_text_parts.append(
f"🏆 {_('admin_financial_all_time_label')}: <b>{financial_stats['all_time_revenue']:.2f} RUB</b>" # noqa: E501
f"🏆 {_('admin_financial_all_time_label')}: <b>{financial_stats['all_time_revenue']:.2f} {currency}</b>" # noqa: E501
)
last_payments_models: List[Payment] = await payment_dal.get_recent_payment_logs_with_user(
File diff suppressed because it is too large Load Diff
+488 -13
View File
@@ -27,6 +27,7 @@ from bot.utils.text_sanitizer import (
username_for_display,
)
from config.settings import Settings
from config.tariffs_config import default_payment_currency_code_for_settings
from db.dal import message_log_dal, subscription_dal, user_dal
from db.models import User
@@ -88,6 +89,31 @@ async def _find_user_by_admin_input(
return None
def _admin_user_reference_label(
user: Optional[User], fallback_user_id: Optional[int] = None
) -> str:
if user is None:
return f"ID {fallback_user_id}" if fallback_user_id is not None else "N/A"
first_name = sanitize_display_name(user.first_name) if user.first_name else ""
last_name = sanitize_display_name(user.last_name) if user.last_name else ""
full_name = f"{first_name} {last_name}".strip()
if full_name:
label = full_name
elif user.username:
label = username_for_display(user.username, with_at=True)
elif user.email:
label = user.email
else:
label = f"ID {user.user_id}"
return f"{label} · ID {user.user_id}"
def _admin_user_button_label(user: User) -> str:
label = _admin_user_reference_label(user)
return label[:64]
async def users_list_handler(
callback: types.CallbackQuery,
i18n_data: dict,
@@ -195,7 +221,13 @@ def get_user_card_keyboard(
text=_(key="admin_user_refresh_button"), callback_data=f"user_action:refresh:{user_id}"
)
# Row 3b: Premium override + traffic grant
# Row 3b: Referral details
builder.button(
text=_(key="admin_user_invitees_button"),
callback_data=f"user_action:invitees:{user_id}:0",
)
# Row 4: Premium override + traffic grant
builder.button(
text=_(key="admin_user_premium_override_button"),
callback_data=f"user_action:premium_override:{user_id}",
@@ -204,6 +236,10 @@ def get_user_card_keyboard(
text=_(key="admin_user_traffic_grant_button"),
callback_data=f"user_action:traffic_grant:{user_id}",
)
builder.button(
text=_(key="admin_user_hwid_limit_button"),
callback_data=f"user_action:hwid_limit:{user_id}",
)
# Row 4: Quick links — only for users with a real Telegram profile
# (synthetic email-only users have a negative user_id with no tg profile).
@@ -229,9 +265,9 @@ def get_user_card_keyboard(
quick_links_count = (1 if has_self_link else 0) + (1 if has_referrer_link else 0)
if quick_links_count == 0:
builder.adjust(2, 2, 2, 2, 1, 2)
builder.adjust(2, 2, 2, 1, 3, 1, 2)
else:
builder.adjust(2, 2, 2, 2, quick_links_count, 1, 2)
builder.adjust(2, 2, 2, 1, 3, quick_links_count, 1, 2)
return builder
@@ -314,7 +350,11 @@ async def format_user_card(
# Referral info
if user.referred_by_id:
card_parts.append(f"{_('admin_user_referral_label')} {hcode(str(user.referred_by_id))}")
referrer = await user_dal.get_referrer_for_user(session, user)
card_parts.append(
f"{_('admin_user_invited_by_label')} "
f"{hcode(_admin_user_reference_label(referrer, user.referred_by_id))}"
)
# Panel info
if user.panel_user_uuid:
@@ -367,6 +407,26 @@ async def format_user_card(
f"{_('admin_user_traffic_label')} {hcode(f'{used_display} / {limit_display}')}"
)
max_devices = subscription_details.get("max_devices")
extra_hwid_devices = int(subscription_details.get("extra_hwid_devices") or 0)
if max_devices is not None:
if int(max_devices) == 0:
devices_display = _("admin_hwid_limit_state_unlimited")
elif extra_hwid_devices > 0:
base_hwid_limit = subscription_details.get("base_hwid_device_limit")
if base_hwid_limit is None:
devices_display = _("admin_hwid_limit_state_count", count=int(max_devices))
else:
devices_display = _(
"admin_hwid_limit_state_with_extra",
total=int(max_devices),
base=int(base_hwid_limit),
extra=extra_hwid_devices,
)
else:
devices_display = _("admin_hwid_limit_state_count", count=int(max_devices))
card_parts.append(f"{_('admin_user_hwid_limit_label')} {hcode(devices_display)}")
premium_unlimited = bool(subscription_details.get("premium_unlimited_override"))
premium_bonus_bytes = int(subscription_details.get("premium_bonus_bytes") or 0)
if premium_unlimited:
@@ -407,17 +467,18 @@ async def format_user_card(
try:
from db.dal import payment_dal
currency = default_payment_currency_code_for_settings(settings)
# Total amount paid by this user
total_paid = await payment_dal.get_user_total_paid(session, user.user_id)
card_parts.append(
f"{_('admin_user_total_paid_label')} {hcode(f'{total_paid:.2f} RUB')}"
f"{_('admin_user_total_paid_label')} {hcode(f'{total_paid:.2f} {currency}')}"
)
# Total revenue from referrals
referral_revenue = await payment_dal.get_referral_revenue(session, user.user_id)
card_parts.append(
f"{_('admin_user_referral_revenue_label')} {hcode(f'{referral_revenue:.2f} RUB')}"
)
referral_revenue_text = hcode(f"{referral_revenue:.2f} {currency}")
card_parts.append(f"{_('admin_user_referral_revenue_label')} {referral_revenue_text}")
except Exception as e_fin:
logging.error(
f"Failed to build financial analytics for admin card {user.user_id}: {e_fin}"
@@ -619,6 +680,12 @@ async def user_action_handler(
await handle_send_message_prompt(callback, state, user, i18n, current_lang)
elif action == "view_logs":
await handle_view_user_logs(callback, user, session, settings, i18n, current_lang)
elif action == "invitees":
try:
page = max(0, int(parts[3])) if len(parts) > 3 else 0
except (TypeError, ValueError):
page = 0
await handle_view_user_invitees(callback, user, session, i18n, current_lang, page=page)
elif action == "refresh":
await handle_refresh_user_card(
callback, user, subscription_service, session, settings, i18n, current_lang
@@ -663,6 +730,32 @@ async def user_action_handler(
await handle_traffic_grant_prompt(callback, state, user, "regular", i18n, current_lang)
elif action == "traffic_grant_premium":
await handle_traffic_grant_prompt(callback, state, user, "premium", i18n, current_lang)
elif action == "hwid_limit":
await handle_hwid_limit_menu(callback, state, user, session, i18n, current_lang)
elif action == "hwid_limit_set_unlimited":
await handle_hwid_limit_apply(
callback,
user,
subscription_service,
session,
settings,
i18n,
current_lang,
hwid_device_limit=0,
)
elif action == "hwid_limit_reset":
await handle_hwid_limit_apply(
callback,
user,
subscription_service,
session,
settings,
i18n,
current_lang,
hwid_device_limit=None,
)
elif action == "hwid_limit_set_number":
await handle_hwid_limit_prompt(callback, state, user, i18n, current_lang)
else:
await callback.answer(_("admin_unknown_action"), show_alert=True)
@@ -807,6 +900,162 @@ async def handle_premium_override_bonus_prompt(
await callback.answer()
def _admin_hwid_limit_state_text(
get_text: Callable[..., str],
hwid_device_limit: Optional[int],
extra_hwid_devices: int = 0,
) -> str:
if hwid_device_limit is None:
return get_text("admin_hwid_limit_state_default")
base_limit = int(hwid_device_limit)
if base_limit == 0:
return get_text("admin_hwid_limit_state_unlimited")
extra = max(0, int(extra_hwid_devices or 0))
if extra > 0:
return get_text(
"admin_hwid_limit_state_with_extra",
total=base_limit + extra,
base=base_limit,
extra=extra,
)
return get_text("admin_hwid_limit_state_count", count=base_limit)
async def handle_hwid_limit_menu(
callback: types.CallbackQuery,
state: FSMContext,
user: User,
session: AsyncSession,
i18n_instance,
lang: str,
) -> None:
"""Show HWID device limit override controls."""
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
active_sub = await subscription_dal.get_active_subscription_by_user_id(session, user.user_id)
if not active_sub:
await callback.answer(_("admin_hwid_limit_no_subscription"), show_alert=True)
return
current_text = _admin_hwid_limit_state_text(
_,
getattr(active_sub, "hwid_device_limit", None),
int(getattr(active_sub, "extra_hwid_devices", 0) or 0),
)
text = "\n".join(
[
f"<b>{_('admin_hwid_limit_title')}</b>",
"",
_("admin_hwid_limit_hint"),
"",
_("admin_hwid_limit_current", current=current_text),
]
)
builder = InlineKeyboardBuilder()
builder.button(
text=_("admin_hwid_limit_btn_set_number"),
callback_data=f"user_action:hwid_limit_set_number:{user.user_id}",
)
builder.button(
text=_("admin_hwid_limit_btn_unlimited"),
callback_data=f"user_action:hwid_limit_set_unlimited:{user.user_id}",
)
builder.button(
text=_("admin_hwid_limit_btn_reset"),
callback_data=f"user_action:hwid_limit_reset:{user.user_id}",
)
builder.button(
text=_("admin_user_back_to_card_button"),
callback_data=f"user_action:refresh:{user.user_id}",
)
builder.adjust(1, 1, 1, 1)
try:
await callback.message.edit_text(text, reply_markup=builder.as_markup(), parse_mode="HTML")
except Exception:
await callback.message.answer(text, reply_markup=builder.as_markup(), parse_mode="HTML")
await state.update_data(target_user_id=user.user_id)
await callback.answer()
async def handle_hwid_limit_apply(
callback: types.CallbackQuery,
user: User,
subscription_service: SubscriptionService,
session: AsyncSession,
settings: Settings,
i18n_instance,
lang: str,
*,
hwid_device_limit: Optional[int],
) -> None:
"""Persist a HWID device base limit override and push it to the panel."""
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
try:
active_sub = await subscription_dal.get_active_subscription_by_user_id(
session, user.user_id
)
if not active_sub:
await callback.answer(_("admin_hwid_limit_no_subscription"), show_alert=True)
return
active_sub.hwid_device_limit = hwid_device_limit
effective_limit = await subscription_service.sync_hwid_device_limit_to_panel(
session, user.user_id
)
await message_log_dal.create_message_log_no_commit(
session,
{
"user_id": callback.from_user.id if callback.from_user else user.user_id,
"event_type": "admin:hwid_device_limit",
"content": (
f"hwid_device_limit={hwid_device_limit!r} "
f"effective_hwid_device_limit={effective_limit!r}"
),
"is_admin_event": True,
"target_user_id": user.user_id,
"timestamp": datetime.now(timezone.utc),
},
)
await session.commit()
await callback.answer(_("admin_hwid_limit_saved"), show_alert=False)
await handle_refresh_user_card(
callback, user, subscription_service, session, settings, i18n_instance, lang
)
except Exception as exc:
logging.error(
"Failed to apply HWID device limit for user %s: %s",
user.user_id,
exc,
exc_info=True,
)
await session.rollback()
await callback.answer(_("admin_hwid_limit_save_error"), show_alert=True)
async def handle_hwid_limit_prompt(
callback: types.CallbackQuery,
state: FSMContext,
user: User,
i18n_instance,
lang: str,
) -> None:
"""Ask admin for an explicit HWID device limit."""
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
await state.update_data(target_user_id=user.user_id)
await state.set_state(AdminStates.waiting_for_hwid_device_limit)
prompt = _("admin_hwid_limit_prompt", user_id=user.user_id)
try:
await callback.message.edit_text(prompt)
except Exception:
await callback.message.answer(prompt)
await callback.answer()
async def handle_traffic_grant_menu(
callback: types.CallbackQuery,
user: User,
@@ -889,8 +1138,7 @@ async def handle_reset_trial(
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
try:
# Delete all user subscriptions to reset trial eligibility
await subscription_dal.delete_all_user_subscriptions(session, user.user_id)
await user_dal.mark_trial_eligibility_reset(session, user.user_id)
await session.commit()
await callback.answer(_("admin_user_trial_reset_success"), show_alert=True)
@@ -1056,6 +1304,120 @@ async def handle_view_user_logs(
await callback.answer(_("admin_user_logs_error"), show_alert=True)
async def handle_view_user_invitees(
callback: types.CallbackQuery,
user: User,
session: AsyncSession,
i18n_instance,
lang: str,
*,
page: int = 0,
):
"""Show users invited by the selected account."""
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
page_size = 10
safe_page = max(0, int(page or 0))
try:
total = await user_dal.count_users_referred_by(session, user.user_id)
total_pages = max(1, (total + page_size - 1) // page_size)
if safe_page >= total_pages:
safe_page = total_pages - 1
invitees = await user_dal.get_users_referred_by(
session,
user.user_id,
limit=page_size,
offset=safe_page * page_size,
)
header = _(
"admin_user_invitees_message_title",
user=hcode(_admin_user_reference_label(user)),
total=total,
current=safe_page + 1,
total_pages=total_pages,
)
if total <= 0:
invitees_text = f"{header}\n\n{_('admin_user_invitees_empty')}"
else:
lines = []
for index, invitee in enumerate(invitees, start=safe_page * page_size + 1):
registered = (
invitee.registration_date.strftime("%Y-%m-%d")
if invitee.registration_date
else ""
)
suffix = (
_("admin_user_invitee_registered_suffix", date=registered) if registered else ""
)
lines.append(
_(
"admin_user_invitee_item",
index=index,
user=hcode(_admin_user_reference_label(invitee)),
suffix=suffix,
)
)
invitees_text = "\n".join([header, "", *lines])
builder = InlineKeyboardBuilder()
for invitee in invitees:
builder.row(
types.InlineKeyboardButton(
text=_admin_user_button_label(invitee),
callback_data=f"user_action:refresh:{invitee.user_id}",
)
)
pagination_buttons = []
if safe_page > 0:
pagination_buttons.append(
types.InlineKeyboardButton(
text=_("prev_page_button"),
callback_data=f"user_action:invitees:{user.user_id}:{safe_page - 1}",
)
)
if safe_page < total_pages - 1:
pagination_buttons.append(
types.InlineKeyboardButton(
text=_("next_page_button"),
callback_data=f"user_action:invitees:{user.user_id}:{safe_page + 1}",
)
)
if pagination_buttons:
builder.row(*pagination_buttons)
builder.row(
types.InlineKeyboardButton(
text=_("admin_user_back_to_card_button"),
callback_data=f"user_action:refresh:{user.user_id}",
)
)
builder.row(
types.InlineKeyboardButton(
text=_("back_to_admin_panel_button"), callback_data="admin_action:main"
)
)
try:
await callback.message.edit_text(
invitees_text, reply_markup=builder.as_markup(), parse_mode="HTML"
)
except Exception:
await callback.message.answer(
invitees_text, reply_markup=builder.as_markup(), parse_mode="HTML"
)
await callback.answer()
except Exception as exc:
logging.error(
"Error viewing invitees for user %s: %s",
user.user_id,
exc,
exc_info=True,
)
await callback.answer(_("admin_user_invitees_error"), show_alert=True)
async def handle_refresh_user_card(
callback: types.CallbackQuery,
user: User,
@@ -1264,8 +1626,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(
_(
@@ -1810,6 +2177,114 @@ async def process_premium_override_bonus_handler(
await state.clear()
@router.message(AdminStates.waiting_for_hwid_device_limit, F.text)
async def process_hwid_device_limit_handler(
message: types.Message,
state: FSMContext,
settings: Settings,
i18n_data: dict,
subscription_service: SubscriptionService,
session: AsyncSession,
):
"""Read explicit HWID device limit and apply it."""
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n:
await message.reply("Language service error.")
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
data = await state.get_data()
target_user_id = data.get("target_user_id")
if not target_user_id:
await message.answer(_("admin_hwid_limit_state_missing"))
await state.clear()
return
raw = (message.text or "").strip()
try:
hwid_device_limit = int(raw)
if hwid_device_limit < 0 or hwid_device_limit > 1_000_000:
raise ValueError("out_of_range")
except (TypeError, ValueError):
await message.answer(_("admin_hwid_limit_invalid"))
return
target_user = await user_dal.get_user_by_id(session, target_user_id)
if not target_user:
await message.answer(_("admin_user_not_found_action"))
await state.clear()
return
try:
active_sub = await subscription_dal.get_active_subscription_by_user_id(
session, target_user_id
)
if not active_sub:
await message.answer(_("admin_hwid_limit_no_subscription"))
await state.clear()
return
active_sub.hwid_device_limit = hwid_device_limit
effective_limit = await subscription_service.sync_hwid_device_limit_to_panel(
session, target_user_id
)
await message_log_dal.create_message_log_no_commit(
session,
{
"user_id": message.from_user.id if message.from_user else target_user_id,
"event_type": "admin:hwid_device_limit",
"content": (
f"hwid_device_limit={hwid_device_limit!r} "
f"effective_hwid_device_limit={effective_limit!r}"
),
"is_admin_event": True,
"target_user_id": target_user_id,
"timestamp": datetime.now(timezone.utc),
},
)
await session.commit()
current_text = _admin_hwid_limit_state_text(_, hwid_device_limit)
await message.answer(
_("admin_hwid_limit_set", current=current_text, user_id=target_user_id)
)
referral_service = ReferralService(settings, subscription_service, message.bot, i18n)
bot_username = await _resolve_bot_username(message.bot)
user_card_text = await format_user_card(
target_user,
session,
subscription_service,
i18n,
current_lang,
referral_service,
settings=settings,
bot_username=bot_username,
)
keyboard = get_user_card_keyboard(
target_user.user_id, i18n, current_lang, target_user.referred_by_id
)
await _send_with_profile_link_fallback(
message.answer,
text=user_card_text,
markup=keyboard.as_markup(),
user_id=target_user.user_id,
parse_mode="HTML",
)
except Exception as exc:
logging.error(
"Error setting HWID device limit for user %s: %s",
target_user_id,
exc,
exc_info=True,
)
await session.rollback()
await message.answer(_("admin_hwid_limit_save_error"))
finally:
await state.clear()
@router.message(AdminStates.waiting_for_traffic_grant_gb, F.text)
async def process_traffic_grant_gb_handler(
message: types.Message,
@@ -1959,7 +2434,7 @@ async def user_card_from_list_handler(
text=_("admin_user_back_to_list_button"), callback_data=f"admin_action:users_list:{page}"
)
quick_links_width = 2 if user.referred_by_id else 1
keyboard.adjust(2, 2, 2, 2, quick_links_width, 1, 2, 1)
keyboard.adjust(2, 2, 2, 1, 2, quick_links_width, 1, 2, 1)
# Format user card
try:
+3 -1
View File
@@ -166,8 +166,10 @@ async def create_user_stats_result(
"inline_user_stats_message",
total=user_stats["total_users"],
active_today=user_stats["active_today"],
active=user_stats["active_subscriptions"],
paid=user_stats["paid_subscriptions"],
trial=user_stats["trial_users"],
free=user_stats["free_subscription_users"],
inactive=user_stats["inactive_users"],
banned=user_stats["banned_users"],
referral=user_stats["referral_users"],
@@ -179,7 +181,7 @@ async def create_user_stats_result(
description=_(
"inline_user_stats_description",
total=user_stats["total_users"],
active=user_stats["paid_subscriptions"],
active=user_stats["active_subscriptions"],
),
input_message_content=InputTextMessageContent(
message_text=stats_text, parse_mode="HTML"
+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]:
+177 -36
View File
@@ -17,12 +17,22 @@ 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.channel_subscription import (
is_required_channel_access_error,
normalize_required_channel_id,
resolve_required_channel_link,
)
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
@@ -31,6 +41,67 @@ from db.models import User
router = Router(name="user_start_router")
def _remnashop_referral_compat_enabled(settings: Settings) -> bool:
return bool(getattr(settings, "MIGRATION_REMNASHOP_REFERRAL_CODE_COMPAT_ENABLED", False))
def _referral_code_lookup_candidates(
raw_ref_value: str,
*,
remnashop_compat: bool,
) -> list[str]:
value = str(raw_ref_value or "").strip()
if not value:
return []
candidates = [value]
if value and value[0].lower() == "u":
stripped_current_prefix = value[1:]
if remnashop_compat:
candidates.append(stripped_current_prefix)
else:
candidates = [stripped_current_prefix]
unique: list[str] = []
for candidate in candidates:
candidate = candidate.strip()
if candidate and candidate not in unique:
unique.append(candidate)
return unique
async def _resolve_referrer_from_start_ref(
session: AsyncSession,
raw_ref_value: str,
*,
settings: Settings,
current_user_id: int,
) -> Optional[int]:
ref_user: Optional[User] = None
if raw_ref_value.isdigit() and settings.LEGACY_REFS:
potential_referrer_id = int(raw_ref_value)
if potential_referrer_id != current_user_id:
ref_user = await user_dal.get_user_by_id(session, potential_referrer_id)
include_legacy = _remnashop_referral_compat_enabled(settings)
if not ref_user:
for code in _referral_code_lookup_candidates(
raw_ref_value,
remnashop_compat=include_legacy,
):
ref_user = await user_dal.get_user_by_referral_code(
session,
code,
include_legacy=include_legacy,
)
if ref_user:
break
if ref_user and ref_user.user_id != current_user_id:
return int(ref_user.user_id)
return None
async def should_show_trial_button(
settings: Settings,
subscription_service: SubscriptionService,
@@ -40,12 +111,12 @@ async def should_show_trial_button(
if not settings.TRIAL_ENABLED:
return False
if hasattr(subscription_service, "has_had_any_subscription") and callable(
getattr(subscription_service, "has_had_any_subscription")
if hasattr(subscription_service, "has_trial_blocking_subscription") and callable(
getattr(subscription_service, "has_trial_blocking_subscription")
):
return not await subscription_service.has_had_any_subscription(session, user_id)
return not await subscription_service.has_trial_blocking_subscription(session, user_id)
logging.error("Method has_had_any_subscription is missing in SubscriptionService!")
logging.error("Method has_trial_blocking_subscription is missing in SubscriptionService!")
return False
@@ -210,7 +281,7 @@ async def ensure_required_channel_subscription(
Verify that the user is a member of the required channel (if configured).
Returns True when access can proceed, False when user must subscribe first.
"""
required_channel_id = settings.REQUIRED_CHANNEL_ID
required_channel_id = normalize_required_channel_id(settings.REQUIRED_CHANNEL_ID)
if not required_channel_id:
return True
@@ -274,6 +345,29 @@ async def ensure_required_channel_subscription(
if status_value in allowed_statuses:
is_member = True
except TelegramBadRequest as bad_request:
if is_required_channel_access_error(bad_request):
logging.error(
"Required channel check failed due to channel access/configuration error "
"(configured=%s, normalized=%s): %s",
settings.REQUIRED_CHANNEL_ID,
required_channel_id,
bad_request,
)
error_text = translate("channel_subscription_check_failed")
if isinstance(event, types.CallbackQuery):
try:
await event.answer(error_text, show_alert=True)
except Exception:
pass
if message_obj:
try:
await message_obj.answer(error_text)
except Exception:
pass
else:
await event.answer(error_text)
return False
logging.info(
"Required channel check: user %s not subscribed (details: %s)",
user_id,
@@ -344,11 +438,12 @@ async def ensure_required_channel_subscription(
)
return True
keyboard = (
get_channel_subscription_keyboard(current_lang, i18n, settings.REQUIRED_CHANNEL_LINK)
if i18n
else None
channel_link = await resolve_required_channel_link(
bot_instance,
required_channel_id,
settings.REQUIRED_CHANNEL_LINK,
)
keyboard = get_channel_subscription_keyboard(current_lang, i18n, channel_link) if i18n else None
prompt_text = translate("channel_subscription_required")
@@ -378,21 +473,18 @@ async def ensure_required_channel_subscription(
@router.message(CommandStart())
@router.message(CommandStart(magic=F.args.regexp(r"^ref_([A-Za-z0-9_-]{1,64})$").as_("ref_match")))
@router.message(
CommandStart(
magic=F.args.regexp(r"^ref_((?:[uU][A-Za-z0-9]{9})|(?:[A-Za-z0-9]{9})|\d+)$").as_(
"ref_match"
)
)
CommandStart(magic=F.args.regexp(r"^promo_([A-Za-z0-9_-]{1,100})$").as_("promo_match"))
)
@router.message(CommandStart(magic=F.args.regexp(r"^promo_(\w+)$").as_("promo_match")))
@router.message(CommandStart(magic=F.args.regexp(r"^admin_user_(\d+)$").as_("admin_user_match")))
@router.message(CommandStart(magic=F.args.regexp(r"^ticket_(\d+)$").as_("ticket_match")))
@router.message(CommandStart(magic=F.args.regexp(r"^notifications$").as_("notifications_match")))
@router.message(CommandStart(magic=F.args.regexp(r"^page_ref$").as_("page_ref_match")))
@router.message(
CommandStart(
magic=F.args.regexp(
r"^(?!ref_|promo_|admin_user_|ticket_|page_ref$|webapp_auth_)([A-Za-z0-9_\-]{2,64})$"
r"^(?!ref_|promo_|admin_user_|ticket_|notifications$|page_ref$|webapp_auth_)([A-Za-z0-9_\-]{2,64})$"
).as_("ad_param_match")
)
)
@@ -410,6 +502,7 @@ async def start_command_handler(
ad_param_match: Optional[re.Match] = None,
admin_user_match: Optional[re.Match] = None,
ticket_match: Optional[re.Match] = None,
notifications_match: Optional[re.Match] = None,
):
await state.clear()
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
@@ -496,28 +589,21 @@ async def start_command_handler(
promo_code_to_apply: Optional[str] = None
should_open_referral_from_start = False
ad_start_param: Optional[str] = None
notifications_start_requested = bool(notifications_match)
if ref_match:
raw_ref_value = ref_match.group(1)
if raw_ref_value.isdigit():
if settings.LEGACY_REFS:
potential_referrer_id = int(raw_ref_value)
if potential_referrer_id != user_id and await user_dal.get_user_by_id(
session, potential_referrer_id
):
referred_by_user_id = potential_referrer_id
else:
normalized_code = raw_ref_value.strip()
if normalized_code and normalized_code[0].lower() == "u":
normalized_code = normalized_code[1:]
ref_user = None
if normalized_code:
ref_user = await user_dal.get_user_by_referral_code(session, normalized_code)
if ref_user and ref_user.user_id != user_id:
referred_by_user_id = ref_user.user_id
referred_by_user_id = await _resolve_referrer_from_start_ref(
session,
raw_ref_value,
settings=settings,
current_user_id=user_id,
)
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.")
@@ -528,18 +614,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)
@@ -566,12 +658,17 @@ async def start_command_handler(
)
if referred_by_user_id and referral_welcome_days > 0:
try:
default_tariff_key = None
tariffs_config = getattr(settings, "tariffs_config", None)
if tariffs_config:
default_tariff_key = getattr(tariffs_config, "default_tariff", None)
referral_bonus_end_date = (
await subscription_service.extend_active_subscription_days(
session,
user_id,
referral_welcome_days,
reason="referral_welcome_bonus",
tariff_key=default_tariff_key,
)
)
if referral_bonus_end_date:
@@ -627,6 +724,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:
@@ -680,9 +784,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:
@@ -715,6 +826,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
@@ -726,6 +854,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",
)
@@ -891,7 +1020,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,
@@ -899,6 +1033,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:
@@ -1048,7 +1189,7 @@ async def main_action_callback_handler(
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
privacy_url = settings.PRIVACY_POLICY_URL
user_agreement_url = settings.USER_AGREEMENT_URL or settings.TERMS_OF_SERVICE_URL
user_agreement_url = settings.USER_AGREEMENT_URL
if not privacy_url and not user_agreement_url:
await safe_answer_callback(
+223 -43
View File
@@ -22,12 +22,20 @@ from bot.keyboards.inline.user_keyboards import (
get_tariff_packages_keyboard,
get_tariff_periods_keyboard,
sale_mode_with_callback_context,
subscription_options_callback,
tariff_purchase_back_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 config.tariffs_config import (
default_currency_key_for_settings,
default_payment_currency_code_for_settings,
)
from db.dal import subscription_dal, user_billing_dal
from db.models import Subscription
@@ -76,11 +84,13 @@ def _tariff_purchase_markup(
back_callback=back_callback,
callback_context=callback_context,
)
default_currency = default_currency_key_for_settings(settings)
return get_tariff_packages_keyboard(
tariff,
tariff.traffic_packages.rub,
tariff.traffic_packages.for_currency(default_currency),
current_lang,
i18n,
currency_symbol=default_payment_currency_code_for_settings(settings),
back_callback=back_callback,
callback_context=callback_context,
)
@@ -110,6 +120,20 @@ def _with_subscription_purchase_description(
return f"{description}\n\n{text}"
def _format_premium_bytes(value: object) -> str:
try:
bytes_value = max(0, int(value or 0))
except (TypeError, ValueError):
bytes_value = 0
return f"{bytes_value / 2**30:.2f} GB"
def _format_premium_usage_limit(active: dict[str, object]) -> str:
used = _format_premium_bytes(active.get("premium_used_bytes"))
limit = _format_premium_bytes(active.get("premium_limit_bytes"))
return f"{used} из {limit}"
async def display_subscription_options(
event: Union[types.Message, types.CallbackQuery],
i18n_data: dict,
@@ -167,6 +191,7 @@ async def display_subscription_options(
enabled_tariffs,
current_lang,
i18n,
settings=settings,
back_callback=back_callback,
callback_context=callback_context,
)
@@ -278,7 +303,7 @@ async def select_tariff_callback(
current_lang,
i18n,
settings,
back_callback=subscription_options_callback(callback_context),
back_callback=tariff_purchase_back_callback(callback_context),
callback_context=callback_context,
)
text = _tariff_purchase_text(tariff, current_lang, i18n, settings)
@@ -294,7 +319,11 @@ async def select_tariff_callback(
@router.callback_query(F.data.startswith("tariff:period:"))
async def select_tariff_period_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")
@@ -308,24 +337,48 @@ async def select_tariff_period_callback(
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
callback_tokens = [part for part in parts[4:] if part]
callback_context = "bot" if "bot" in callback_tokens else None
renew_hwid_devices = "no_hwid" not in callback_tokens
tariff = config.require(tariff_key)
months = int(months_raw)
price_rub = tariff.period_price(months, "rub")
default_currency = default_currency_key_for_settings(settings)
currency_code = default_payment_currency_code_for_settings(settings)
price_rub = tariff.period_price(months, default_currency)
stars_price = tariff.period_price(months, "stars")
if price_rub is None:
await callback.answer(get_text("error_try_again"), show_alert=True)
return
hwid_renewal_quote = await subscription_service.quote_hwid_device_renewal_for_subscription(
session,
user_id=callback.from_user.id,
target_tariff_key=tariff.key,
months=months,
currency=default_currency,
)
hwid_renewal_stars_quote = (
await subscription_service.quote_hwid_device_renewal_for_subscription(
session,
user_id=callback.from_user.id,
target_tariff_key=tariff.key,
months=months,
currency="stars",
)
)
markup = get_payment_method_keyboard(
months,
price_rub,
int(stars_price) if stars_price else None,
settings.DEFAULT_CURRENCY_SYMBOL,
currency_code,
current_lang,
i18n,
settings,
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,
hwid_renewal_quote=hwid_renewal_quote,
hwid_renewal_stars_quote=hwid_renewal_stars_quote,
hwid_renewal_selected=bool(renew_hwid_devices),
)
await callback.message.edit_text(get_text("choose_payment_method"), reply_markup=markup)
await callback.answer()
@@ -350,10 +403,16 @@ async def select_tariff_package_callback(
callback_context = parts[4] if len(parts) > 4 else None
tariff = config.require(tariff_key)
gb = float(gb_raw)
default_currency = default_currency_key_for_settings(settings)
currency_code = default_payment_currency_code_for_settings(settings)
packages = (
tariff.traffic_packages.rub
tariff.traffic_packages.for_currency(default_currency)
if tariff.billing_model == "traffic"
else (config.topup_packages_for(tariff).rub if config.topup_packages_for(tariff) else [])
else (
config.topup_packages_for(tariff).for_currency(default_currency)
if config.topup_packages_for(tariff)
else []
)
)
package = next((pkg for pkg in packages if float(pkg.gb) == gb), None)
if not package:
@@ -372,12 +431,13 @@ async def select_tariff_package_callback(
gb,
package.price,
None,
settings.DEFAULT_CURRENCY_SYMBOL,
currency_code,
current_lang,
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()
@@ -403,14 +463,19 @@ async def tariff_topup_list_callback(
return
tariff = config.require(active["tariff_key"])
packages = config.topup_packages_for(tariff)
rub_packages = packages.rub if packages else []
premium_packages = tariff.premium_topup_packages.rub if tariff.premium_topup_packages else []
if not rub_packages and not premium_packages:
default_currency = default_currency_key_for_settings(settings)
currency = default_payment_currency_code_for_settings(settings)
currency_packages = packages.for_currency(default_currency) if packages else []
premium_packages = (
tariff.premium_topup_packages.for_currency(default_currency)
if tariff.premium_topup_packages
else []
)
if not currency_packages and not premium_packages:
await callback.answer(get_text("no_subscription_options_available"), show_alert=True)
return
builder = InlineKeyboardBuilder()
currency = settings.DEFAULT_CURRENCY_SYMBOL
for package in rub_packages:
for package in currency_packages:
builder.row(
InlineKeyboardButton(
text=f"Обычный трафик +{package.gb:g} GB — {package.price:g} {currency}",
@@ -432,7 +497,7 @@ async def tariff_topup_list_callback(
premium_lines = []
carryover_lines = []
if rub_packages or premium_packages:
if currency_packages or premium_packages:
carryover_lines.append(
"Докупленный трафик не сгорает: сначала расходуется месячный лимит, затем докупленный остаток." # noqa: E501
)
@@ -450,7 +515,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:
@@ -475,7 +540,13 @@ async def select_tariff_premium_package_callback(
_, _, tariff_key, gb_raw = callback.data.split(":", 3)
tariff = config.require(tariff_key)
gb = float(gb_raw)
packages = tariff.premium_topup_packages.rub if tariff.premium_topup_packages else []
default_currency = default_currency_key_for_settings(settings)
currency_code = default_payment_currency_code_for_settings(settings)
packages = (
tariff.premium_topup_packages.for_currency(default_currency)
if tariff.premium_topup_packages
else []
)
package = next((pkg for pkg in packages if float(pkg.gb) == gb), None)
if not package:
await callback.answer(get_text("error_try_again"), show_alert=True)
@@ -484,12 +555,13 @@ async def select_tariff_premium_package_callback(
gb,
package.price,
None,
settings.DEFAULT_CURRENCY_SYMBOL,
currency_code,
current_lang,
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()
@@ -518,7 +590,15 @@ 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"])
packages = tariff.hwid_device_packages.rub if tariff.hwid_device_packages else []
if tariff.billing_model != "period":
await callback.answer(get_text("no_hwid_device_packages_available"), show_alert=True)
return
default_currency = default_currency_key_for_settings(settings)
packages = (
tariff.hwid_device_packages.for_currency(default_currency)
if tariff.hwid_device_packages
else []
)
if not packages:
await callback.answer(get_text("no_hwid_device_packages_available"), show_alert=True)
return
@@ -529,14 +609,26 @@ async def hwid_devices_list_callback(
i18n,
settings,
back_callback="main_action:my_devices",
renewal=False,
)
await callback.message.edit_text(
get_text(
"select_hwid_device_package",
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")
@@ -545,13 +637,22 @@ 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(
(
pkg
for pkg in (tariff.hwid_device_packages.rub if tariff.hwid_device_packages else [])
for pkg in (
tariff.hwid_device_packages.for_currency(
default_currency_key_for_settings(settings)
)
if tariff.hwid_device_packages
else []
)
if int(pkg.count) == count
),
None,
@@ -559,16 +660,42 @@ 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"
renewal = action == "renewal_package"
default_currency = default_currency_key_for_settings(settings)
currency_code = default_payment_currency_code_for_settings(settings)
currency_quote = await subscription_service.quote_hwid_device_topup(
session,
user_id=callback.from_user.id,
device_count=count,
tariff_key=tariff.key,
renewal=renewal,
currency=default_currency,
)
stars_quote = await subscription_service.quote_hwid_device_topup(
session,
user_id=callback.from_user.id,
device_count=count,
tariff_key=tariff.key,
renewal=renewal,
currency="stars",
)
if not currency_quote and not stars_quote:
await callback.answer(get_text("error_try_again"), show_alert=True)
return
markup = get_payment_method_keyboard(
count,
package.price,
None,
settings.DEFAULT_CURRENCY_SYMBOL,
float(currency_quote.get("price") if currency_quote else 0),
int(stars_quote["price"])
if stars_quote and int(stars_quote.get("price") or 0) > 0
else None,
currency_code,
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
@@ -646,7 +773,11 @@ 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
)
default_currency = default_currency_key_for_settings(settings)
currency_code = default_payment_currency_code_for_settings(settings)
rows = []
if options["mode"] == "period_to_period":
rows.append(
@@ -661,7 +792,7 @@ async def tariff_change_select_callback(
rows.append(
[
InlineKeyboardButton(
text=f"Доплатить {options['paid_diff_rub']} RUB",
text=f"Доплатить {options['paid_diff_rub']} {currency_code}",
callback_data=f"tariff_change:confirm_pay:{target.key}:{options['paid_diff_rub']}",
)
]
@@ -675,23 +806,23 @@ async def tariff_change_select_callback(
)
]
)
for package in target.traffic_packages.rub:
for package in target.traffic_packages.for_currency(default_currency):
rows.append(
[
InlineKeyboardButton(
text=f"+ {package.gb:g} GB за {package.price:g} RUB",
text=f"+ {package.gb:g} GB за {package.price:g} {currency_code}",
callback_data=f"tariff:package:{target.key}:{package.gb:g}",
)
]
)
else:
for months in target.enabled_periods:
price = target.period_price(months, "rub")
price = target.period_price(months, default_currency)
if price:
rows.append(
[
InlineKeyboardButton(
text=f"{months} мес. за {price:g} RUB",
text=f"{months} мес. за {price:g} {currency_code}",
callback_data=f"tariff:period:{target.key}:{months}",
)
]
@@ -733,7 +864,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":
@@ -772,6 +905,7 @@ async def tariff_change_confirm_pay_callback(
return
_, _, tariff_key, amount_raw = callback.data.split(":", 3)
target = config.require(tariff_key)
currency_code = default_payment_currency_code_for_settings(settings)
rows = [
[
InlineKeyboardButton(
@@ -787,7 +921,7 @@ async def tariff_change_confirm_pay_callback(
],
]
await callback.message.edit_text(
f"Подтвердите смену тарифа\n\nНовый тариф: {target.name(current_lang)}\nБудет создана оплата на {amount_raw} RUB.", # noqa: E501
f"Подтвердите смену тарифа\n\nНовый тариф: {target.name(current_lang)}\nБудет создана оплата на {amount_raw} {currency_code}.", # noqa: E501
reply_markup=InlineKeyboardMarkup(inline_keyboard=rows),
)
await callback.answer()
@@ -829,16 +963,18 @@ async def tariff_change_pay_callback(
i18n: JsonI18n = i18n_data.get("i18n_instance")
_, _, tariff_key, amount_raw = callback.data.split(":", 3)
amount = float(amount_raw)
currency_code = default_payment_currency_code_for_settings(settings)
markup = get_payment_method_keyboard(
1,
amount,
None,
settings.DEFAULT_CURRENCY_SYMBOL,
currency_code,
current_lang,
i18n,
settings,
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()
@@ -1008,7 +1144,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"
@@ -1026,12 +1162,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(
@@ -1115,8 +1289,11 @@ 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
and tariff_for_devices.hwid_device_packages.rub
tariff_for_devices.billing_model == "period"
and tariff_for_devices.hwid_device_packages
and tariff_for_devices.hwid_device_packages.for_currency(
default_currency_key_for_settings(settings)
)
):
prepend_rows.append(
[
@@ -1331,8 +1508,11 @@ async def my_devices_command_handler(
try:
tariff_for_devices = settings.tariffs_config.require(active["tariff_key"])
if (
tariff_for_devices.hwid_device_packages
and tariff_for_devices.hwid_device_packages.rub
tariff_for_devices.billing_model == "period"
and tariff_for_devices.hwid_device_packages
and tariff_for_devices.hwid_device_packages.for_currency(
default_currency_key_for_settings(settings)
)
):
devices_kb.append(
[
@@ -62,7 +62,12 @@ async def select_subscription_period_callback_handler(
from bot.payment_providers import iter_provider_specs
currency_methods_enabled = any(
spec.price_source != "stars" and spec.is_enabled(settings)
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:
@@ -104,6 +109,7 @@ async def select_subscription_period_callback_handler(
"traffic" if traffic_mode else "subscription", callback_context
),
back_callback=subscription_options_callback(callback_context),
user_id=callback.from_user.id,
)
try:
+47 -9
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
@@ -41,7 +46,7 @@ async def request_trial_confirmation_handler(
return
if settings.TRIAL_ENABLED:
if not await subscription_service.has_had_any_subscription(session, user_id):
if not await subscription_service.has_trial_blocking_subscription(session, user_id):
pass
if not settings.TRIAL_ENABLED:
@@ -55,7 +60,7 @@ async def request_trial_confirmation_handler(
pass
return
if await subscription_service.has_had_any_subscription(session, user_id):
if await subscription_service.has_trial_blocking_subscription(session, user_id):
await callback.message.edit_text(
_("trial_already_had_subscription_or_trial"),
reply_markup=get_main_menu_inline_keyboard(current_lang, i18n, settings, False),
@@ -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
@@ -127,8 +147,9 @@ async def request_trial_confirmation_handler(
await callback.answer(final_message_text_in_chat, show_alert=True)
except Exception:
pass
if settings.TRIAL_ENABLED and not await subscription_service.has_had_any_subscription(
session, user_id
if (
settings.TRIAL_ENABLED
and not await subscription_service.has_trial_blocking_subscription(session, user_id)
):
show_trial_button_after_action = True
@@ -139,6 +160,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(
@@ -197,7 +219,7 @@ async def confirm_activate_trial_handler(
callback, settings, i18n_data, subscription_service, session, is_edit=True
)
return
if await subscription_service.has_had_any_subscription(session, user_id):
if await subscription_service.has_trial_blocking_subscription(session, user_id):
try:
await callback.answer(_("trial_already_had_subscription_or_trial"), show_alert=True)
except Exception:
@@ -214,6 +236,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 +266,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")
@@ -254,8 +284,9 @@ async def confirm_activate_trial_handler(
await callback.answer(final_message_text_in_chat, show_alert=True)
except Exception:
pass
if settings.TRIAL_ENABLED and not await subscription_service.has_had_any_subscription(
session, user_id
if (
settings.TRIAL_ENABLED
and not await subscription_service.has_trial_blocking_subscription(session, user_id)
):
show_trial_button_after_action = True
@@ -266,6 +297,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 +325,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
@@ -452,10 +452,11 @@ def get_broadcast_confirmation_keyboard(
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
# Row: target selection (all / active / inactive)
# Row: target selection (all / active / inactive / expired)
target_all_label = _(key="broadcast_target_all_button")
target_active_label = _(key="broadcast_target_active_button")
target_inactive_label = _(key="broadcast_target_inactive_button")
target_expired_label = _(key="broadcast_target_expired_button")
# Highlight current selection with a prefix
def mark_selected(label: str, is_selected: bool) -> str:
@@ -473,7 +474,10 @@ def get_broadcast_confirmation_keyboard(
text=mark_selected(target_inactive_label, target == "inactive"),
callback_data="broadcast_target:inactive",
)
builder.adjust(3)
builder.button(
text=mark_selected(target_expired_label, target == "expired"),
callback_data="broadcast_target:expired",
)
# Row: confirmation
builder.button(
@@ -482,7 +486,7 @@ def get_broadcast_confirmation_keyboard(
builder.button(
text=_(key="cancel_broadcast_button"), callback_data="broadcast_final_action:cancel"
)
builder.adjust(2)
builder.adjust(2, 2, 2)
return builder.as_markup()
+180 -44
View File
@@ -3,9 +3,24 @@ 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.channel_subscription import normalize_required_channel_link
from bot.utils.install_links import bot_install_guide_url
from bot.utils.mini_app_url import subscription_mini_app_trial_url
from config.settings import Settings
from config.tariffs_config import (
default_currency_key_for_settings,
default_payment_currency_code_for_settings,
)
BOT_MENU_CONTEXT = "bot"
HWID_RENEWAL_TOKEN = "hwid_renewal"
def sale_mode_tokens(sale_mode: Optional[str]) -> Tuple[str, ...]:
if not sale_mode or "|" not in sale_mode:
return ()
return tuple(token.strip() for token in str(sale_mode).split("|")[1:] if token.strip())
def callback_context_from_back_callback(back_callback: Optional[str]) -> Optional[str]:
@@ -16,16 +31,36 @@ def callback_context_from_back_callback(back_callback: Optional[str]) -> Optiona
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:
if not context or context in sale_mode_tokens(sale_mode):
return sale_mode
return f"{sale_mode}|{context}"
def sale_mode_with_token(sale_mode: str, token: str) -> str:
sale_mode = sale_mode or "subscription"
token = str(token or "").strip()
if not token or token in sale_mode_tokens(sale_mode):
return sale_mode
return f"{sale_mode}|{token}"
def sale_mode_without_token(sale_mode: str, token: str) -> str:
sale_mode = sale_mode or "subscription"
token = str(token or "").strip()
if not token or "|" not in sale_mode:
return sale_mode
base, *tokens = sale_mode.split("|")
kept = [item for item in tokens if item.strip() and item.strip() != token]
return "|".join([base, *kept])
def sale_mode_has_token(sale_mode: Optional[str], token: str) -> bool:
return str(token or "").strip() in sale_mode_tokens(sale_mode)
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
tokens = sale_mode_tokens(sale_mode)
return BOT_MENU_CONTEXT if BOT_MENU_CONTEXT in tokens else None
def callback_suffix_for_context(context: Optional[str]) -> str:
@@ -36,6 +71,12 @@ def subscription_options_callback(context: Optional[str]) -> str:
return "main_action:bot_subscribe" if context == BOT_MENU_CONTEXT else "main_action:subscribe"
def tariff_purchase_back_callback(context: Optional[str]) -> str:
if context == BOT_MENU_CONTEXT:
return "main_action:bot_interface"
return subscription_options_callback(context)
def payment_methods_back_callback(
value: str, sale_mode: str = "subscription", price: Optional[float] = None
) -> str:
@@ -54,8 +95,9 @@ def payment_methods_back_callback(
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"} and tariff_key:
return f"hwid_devices: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}"
@@ -76,17 +118,34 @@ def payment_options_back_callback(sale_mode: str = "subscription") -> str:
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"}:
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
) -> InlineKeyboardMarkup:
_ = 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(
@@ -113,8 +172,7 @@ def get_main_menu_inline_keyboard(
InlineKeyboardButton(text=_(key="menu_support_button"), url=settings.SUPPORT_LINK)
)
user_agreement_url = settings.USER_AGREEMENT_URL or settings.TERMS_OF_SERVICE_URL
if settings.PRIVACY_POLICY_URL or user_agreement_url:
if settings.PRIVACY_POLICY_URL or settings.USER_AGREEMENT_URL:
builder.row(
InlineKeyboardButton(text=_(key="menu_info_button"), callback_data="main_action:info")
)
@@ -129,11 +187,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(
@@ -185,8 +239,7 @@ def get_bot_interface_inline_keyboard(
InlineKeyboardButton(text=_(key="menu_support_button"), url=settings.SUPPORT_LINK)
)
user_agreement_url = settings.USER_AGREEMENT_URL or settings.TERMS_OF_SERVICE_URL
if settings.PRIVACY_POLICY_URL or user_agreement_url:
if settings.PRIVACY_POLICY_URL or settings.USER_AGREEMENT_URL:
builder.row(
InlineKeyboardButton(
text=_(key="menu_info_button"), callback_data="main_action:bot_info"
@@ -231,14 +284,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()
@@ -307,19 +364,31 @@ def get_tariff_catalog_keyboard(
tariffs: List[Any],
lang: str,
i18n_instance,
settings: Optional[Settings] = None,
back_callback: str = "main_action:back_to_main",
callback_context: Optional[str] = None,
) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
callback_context = callback_context or callback_context_from_back_callback(back_callback)
default_currency = default_currency_key_for_settings(settings) if settings else "rub"
for tariff in tariffs:
label = tariff.name(lang)
if tariff.billing_model == "period":
min_price = tariff.min_period_price_rub()
if hasattr(tariff, "min_period_price"):
min_price = tariff.min_period_price(default_currency)
elif default_currency == "rub" and hasattr(tariff, "min_period_price_rub"):
min_price = tariff.min_period_price_rub()
else:
min_price = None
if min_price is not None:
label = f"{label} от {min_price:g}"
else:
package = tariff.min_traffic_package_rub()
if hasattr(tariff, "min_traffic_package"):
package = tariff.min_traffic_package(default_currency)
elif default_currency == "rub" and hasattr(tariff, "min_traffic_package_rub"):
package = tariff.min_traffic_package_rub()
else:
package = None
if package:
label = f"{label} от {package.price:g} / {package.gb:g} GB"
builder.row(
@@ -347,8 +416,10 @@ def get_tariff_periods_keyboard(
builder = InlineKeyboardBuilder()
callback_context = callback_context or callback_context_from_back_callback(back_callback)
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
default_currency = default_currency_key_for_settings(settings)
currency_code = default_payment_currency_code_for_settings(settings)
for months in tariff.enabled_periods:
rub_price = tariff.period_price(months, "rub")
rub_price = tariff.period_price(months, default_currency)
if rub_price and rub_price > 0:
builder.row(
InlineKeyboardButton(
@@ -356,7 +427,7 @@ def get_tariff_periods_keyboard(
"subscribe_for_months_button",
months=months,
price=rub_price,
currency_symbol=settings.DEFAULT_CURRENCY_SYMBOL,
currency_symbol=currency_code,
),
callback_data=f"tariff:period:{tariff.key}:{months}"
f"{callback_suffix_for_context(callback_context)}",
@@ -373,6 +444,7 @@ def get_tariff_packages_keyboard(
packages: List[Any],
lang: str,
i18n_instance,
currency_symbol: str = "RUB",
back_callback: str = "main_action:subscribe",
callback_context: Optional[str] = None,
) -> InlineKeyboardMarkup:
@@ -386,7 +458,7 @@ def get_tariff_packages_keyboard(
"buy_traffic_package_button",
traffic_gb=f"{package.gb:g}",
price=package.price,
currency_symbol="RUB",
currency_symbol=currency_symbol,
),
callback_data=f"tariff:package:{tariff.key}:{package.gb:g}"
f"{callback_suffix_for_context(callback_context)}",
@@ -405,9 +477,11 @@ 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)
currency_code = default_payment_currency_code_for_settings(settings)
for package in packages:
builder.row(
InlineKeyboardButton(
@@ -415,9 +489,12 @@ def get_hwid_device_packages_keyboard(
"buy_hwid_devices_button",
count=package.count,
price=package.price,
currency_symbol=settings.DEFAULT_CURRENCY_SYMBOL,
currency_symbol=currency_code,
),
callback_data=(
f"hwid_devices:{'renewal_package' if renewal else 'package'}:"
f"{tariff.key}:{package.count}"
),
callback_data=f"hwid_devices:package:{tariff.key}:{package.count}",
)
)
builder.row(
@@ -436,6 +513,11 @@ def get_payment_method_keyboard(
settings: Settings,
sale_mode: str = "subscription",
back_callback: Optional[str] = None,
user_id: Optional[int] = None,
is_admin: Optional[bool] = None,
hwid_renewal_quote: Optional[Dict[str, Any]] = None,
hwid_renewal_stars_quote: Optional[Dict[str, Any]] = None,
hwid_renewal_selected: bool = True,
) -> InlineKeyboardMarkup:
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
@@ -444,23 +526,60 @@ def get_payment_method_keyboard(
return str(int(val)) if float(val).is_integer() else f"{val:g}"
value_str = _format_value(months)
import logging as _kbd_logging
_kbd_logging.info(
"payment_method_keyboard build: order=%s",
settings.payment_methods_order,
)
payment_sale_mode = sale_mode
selected_hwid_quote = hwid_renewal_quote or hwid_renewal_stars_quote
if selected_hwid_quote:
tariff_key = None
sale_mode_main = str(sale_mode or "").split("|", 1)[0]
if "@" in sale_mode_main:
tariff_key = sale_mode_main.split("@", 1)[1]
context = callback_context_from_sale_mode(sale_mode)
toggle_tokens = [f"tariff:period:{tariff_key}:{value_str}"]
if context:
toggle_tokens.append(context)
toggle_tokens.append("no_hwid" if hwid_renewal_selected else "hwid")
builder.row(
InlineKeyboardButton(
text=_(
"payment_hwid_renewal_toggle_on"
if hwid_renewal_selected
else "payment_hwid_renewal_toggle_off",
count=int(selected_hwid_quote.get("device_count") or 0),
price=(
hwid_renewal_quote.get("price")
if hwid_renewal_quote
else hwid_renewal_stars_quote.get("price")
),
currency_symbol=currency_symbol_val,
),
callback_data=":".join(toggle_tokens),
)
)
if hwid_renewal_selected:
payment_sale_mode = sale_mode_with_token(sale_mode, HWID_RENEWAL_TOKEN)
else:
payment_sale_mode = sale_mode_without_token(sale_mode, HWID_RENEWAL_TOKEN)
from bot.payment_providers import get_provider_spec, provider_telegram_button_text
for method in settings.payment_methods_order:
spec = get_provider_spec(method)
if not spec or not spec.callback_prefix or not spec.is_enabled(settings):
if (
not spec
or not spec.callback_prefix
or not spec.is_usable_for_payment(settings, currency_symbol_val, price)
or not spec.is_available_to_user(
settings,
user_id=user_id,
is_admin=is_admin,
require_configured=False,
)
):
continue
callback_data = spec.callback_data(
value=value_str,
rub_price=price,
stars_price=stars_price,
sale_mode=sale_mode,
sale_mode=payment_sale_mode,
)
if not callback_data:
continue
@@ -519,7 +638,7 @@ def get_yk_autopay_choice_keyboard(
builder.row(
InlineKeyboardButton(
text=_(key="yookassa_autopay_pay_saved_card_button"),
callback_data=f"pay_yk_saved_list:{value_str}:{price_str}{suffix}",
callback_data=f"pay_yk_saved_list:{value_str}:{price_str}:0{suffix}",
)
)
builder.row(
@@ -661,10 +780,11 @@ def get_channel_subscription_keyboard(
has_buttons = False
if channel_link:
channel_url = normalize_required_channel_link(channel_link)
if channel_url:
builder.button(
text=_(key="channel_subscription_join_button"),
url=channel_link,
url=channel_url,
)
has_buttons = True
@@ -689,13 +809,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(
+26 -44
View File
@@ -5,20 +5,17 @@ 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
@@ -94,12 +91,9 @@ async def register_all_routers(dp: Dispatcher, settings: Settings):
logging.info("All application routers registered.")
async def on_startup_configured(dispatcher: Dispatcher):
async def configure_telegram_webhook(dispatcher: Dispatcher) -> None:
bot: Bot = dispatcher["bot_instance"]
settings: Settings = dispatcher["settings"]
i18n_instance: JsonI18n = dispatcher["i18n_instance"]
logging.info("STARTUP: on_startup_configured executing...")
telegram_webhook_url_to_set = settings.WEBHOOK_BASE_URL
if telegram_webhook_url_to_set:
@@ -155,7 +149,16 @@ async def on_startup_configured(dispatcher: Dispatcher):
)
raise SystemExit("WEBHOOK_BASE_URL is required. Polling mode is disabled.")
async def on_startup_configured(dispatcher: Dispatcher):
bot: Bot = dispatcher["bot_instance"]
settings: Settings = dispatcher["settings"]
i18n_instance: JsonI18n = dispatcher["i18n_instance"]
logging.info("STARTUP: on_startup_configured executing...")
if settings.SUBSCRIPTION_MINI_APP_URL:
async def _configure_mini_app_menu() -> None:
menu_text = i18n_instance.gettext(
settings.DEFAULT_LANGUAGE,
@@ -169,6 +172,7 @@ 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.")
await _run_telegram_startup_step(
"registering mini app menu button",
_configure_mini_app_menu,
@@ -186,6 +190,7 @@ async def on_startup_configured(dispatcher: Dispatcher):
)
await bot.set_my_commands(bot_commands)
logging.info("STARTUP: bot command descriptions set.")
await _run_telegram_startup_step(
"setting bot commands",
_configure_bot_commands,
@@ -200,42 +205,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...")
@@ -299,9 +271,10 @@ 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"
@@ -363,8 +336,17 @@ async def run_bot(settings_param: Settings):
_yk_path,
)
async def _after_webhooks_started() -> None:
await configure_telegram_webhook(dp)
async def web_server_task():
await build_and_start_web_app(dp, bot, settings_param, local_async_session_factory)
await build_and_start_web_app(
dp,
bot,
settings_param,
local_async_session_factory,
after_webhooks_started=_after_webhooks_started,
)
main_tasks = [asyncio.create_task(web_server_task(), name="AIOHTTPServerTask")]
@@ -24,6 +24,9 @@ class ActionLoggerMiddleware(BaseMiddleware):
result = await handler(event, data)
if data.get("skip_action_log") or data.get("antiflood_dropped"):
return result
session: AsyncSession = data["session"]
event_user: Optional[User] = data.get("event_from_user")
@@ -11,6 +11,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.inline.user_keyboards import get_channel_subscription_keyboard
from bot.middlewares.i18n import JsonI18n
from bot.utils.channel_subscription import (
normalize_required_channel_id,
resolve_required_channel_link,
)
from config.settings import Settings
from db.dal import user_dal
@@ -32,7 +36,7 @@ class ChannelSubscriptionMiddleware(BaseMiddleware):
event: Update,
data: Dict[str, Any],
) -> Any:
required_channel_id = self.settings.REQUIRED_CHANNEL_ID
required_channel_id = normalize_required_channel_id(self.settings.REQUIRED_CHANNEL_ID)
if not required_channel_id:
return await handler(event, data)
@@ -85,10 +89,14 @@ class ChannelSubscriptionMiddleware(BaseMiddleware):
return i18n_instance.gettext(current_lang, key)
return key
bot_instance = data.get("bot") or data.get("bot_instance")
channel_link = await resolve_required_channel_link(
bot_instance,
required_channel_id,
self.settings.REQUIRED_CHANNEL_LINK,
)
keyboard = (
get_channel_subscription_keyboard(
current_lang, i18n_instance, self.settings.REQUIRED_CHANNEL_LINK
)
get_channel_subscription_keyboard(current_lang, i18n_instance, channel_link)
if i18n_instance
else None
)
+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(
+5 -14
View File
@@ -8,7 +8,7 @@ from aiogram.types import User as TgUser
from sqlalchemy.ext.asyncio import AsyncSession
from bot.infra.redis import cache_get_json, cache_set_json, redis_key
from bot.utils.text_sanitizer import sanitize_display_name, sanitize_username, username_for_display
from bot.utils.text_sanitizer import sanitize_display_name, sanitize_username
from config.settings import Settings
from db.dal import user_dal
@@ -55,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:
@@ -81,7 +72,7 @@ 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(
+371
View File
@@ -0,0 +1,371 @@
import asyncio
import hashlib
import logging
import time
from collections import defaultdict, deque
from dataclasses import dataclass
from typing import Any, Awaitable, Callable, Deque, Dict, Optional
from aiogram import BaseMiddleware
from aiogram.types import Update
from bot.infra.redis import get_redis, redis_key
from config.settings import Settings
logger = logging.getLogger(__name__)
DEFAULT_WINDOW_SECONDS = 60
DEFAULT_MAX_UPDATES_PER_WINDOW = 180
DEFAULT_MESSAGE_MAX_PER_WINDOW = 120
DEFAULT_CALLBACK_MAX_PER_WINDOW = 240
DEFAULT_INLINE_MAX_PER_WINDOW = 60
DEFAULT_START_MAX_PER_WINDOW = 30
DEFAULT_EXPENSIVE_CALLBACK_MAX_PER_WINDOW = 60
DEFAULT_PAYMENT_CALLBACK_COOLDOWN_SECONDS = 20
DEFAULT_TRIAL_CALLBACK_COOLDOWN_SECONDS = 30
EXPENSIVE_CALLBACK_PREFIXES = (
"pay_",
"trial_action:confirm_activate",
"main_action:request_trial",
"main_action:apply_promo",
"main_action:bot_apply_promo",
"tariff_change:apply:",
"tariff_change:confirm_pay:",
"tariff_change:pay:",
"autorenew:confirm:",
"disconnect_device:",
)
TRIAL_CALLBACK_PREFIXES = (
"trial_action:confirm_activate",
"main_action:request_trial",
)
@dataclass(frozen=True)
class RateLimitRule:
window_seconds: int
max_events: int
class UpdateAntiFloodMiddleware(BaseMiddleware):
"""Drop extreme update floods before DB-backed middleware runs."""
def __init__(
self,
settings: Settings,
*,
default_rule: Optional[RateLimitRule] = None,
action_rules: Optional[Dict[str, RateLimitRule]] = None,
) -> None:
super().__init__()
self.settings = settings
self.default_rule = default_rule or RateLimitRule(
window_seconds=int(
getattr(settings, "TELEGRAM_ANTIFLOOD_WINDOW_SECONDS", DEFAULT_WINDOW_SECONDS)
or DEFAULT_WINDOW_SECONDS
),
max_events=int(
getattr(
settings,
"TELEGRAM_ANTIFLOOD_MAX_UPDATES_PER_WINDOW",
DEFAULT_MAX_UPDATES_PER_WINDOW,
)
or DEFAULT_MAX_UPDATES_PER_WINDOW
),
)
self.action_rules = action_rules or _default_action_rules(settings)
self._local_buckets: Dict[str, Deque[float]] = defaultdict(deque)
self._local_cooldowns: Dict[str, float] = {}
self._local_lock = asyncio.Lock()
async def __call__(
self,
handler: Callable[[Update, Dict[str, Any]], Awaitable[Any]],
event: Update,
data: Dict[str, Any],
) -> Any:
if bool(getattr(self.settings, "TELEGRAM_DROP_NON_PRIVATE_UPDATES", True)):
chat_type = _message_or_callback_chat_type(event)
if chat_type is not None and chat_type != "private":
logger.info(
"Telegram update dropped outside private chat: chat_type=%s update_type=%s",
chat_type,
getattr(event, "event_type", "unknown"),
)
_mark_dropped(data)
return None
if not bool(getattr(self.settings, "TELEGRAM_ANTIFLOOD_ENABLED", True)):
return await handler(event, data)
actor_key = _update_actor_key(event)
if not actor_key:
return await handler(event, data)
action_key = _update_action_key(event)
cooldown = _update_action_cooldown(event, self.settings)
if cooldown and await self._is_cooldown_active(cooldown[0], cooldown[1]):
logger.info(
"Telegram callback dropped by action cooldown: actor=%s cooldown=%s",
actor_key,
cooldown[0],
)
_mark_dropped(data)
await _quietly_answer_callback(event)
return None
if await self._is_limited("updates", actor_key, self.default_rule) or (
action_key
and action_key in self.action_rules
and await self._is_limited(action_key, actor_key, self.action_rules[action_key])
):
logger.warning(
"Telegram update dropped by anti-flood: actor=%s update_type=%s",
actor_key,
action_key or getattr(event, "event_type", "unknown"),
)
_mark_dropped(data)
return None
return await handler(event, data)
async def _is_limited(self, bucket_name: str, actor_key: str, rule: RateLimitRule) -> bool:
if rule.window_seconds <= 0 or rule.max_events <= 0:
return False
try:
redis = await get_redis(self.settings)
if redis is not None:
key = redis_key(
self.settings,
"rate-limit",
"telegram",
bucket_name,
actor_key,
)
current = int(await redis.incr(key))
if current == 1:
await redis.expire(key, rule.window_seconds)
return current > rule.max_events
except Exception as exc:
logger.warning("Redis telegram anti-flood unavailable; using local fallback: %s", exc)
return await self._is_limited_local(f"{bucket_name}:{actor_key}", rule)
async def _is_cooldown_active(self, cooldown_key: str, ttl_seconds: int) -> bool:
if ttl_seconds <= 0:
return False
try:
redis = await get_redis(self.settings)
if redis is not None:
key = redis_key(
self.settings,
"cooldown",
"telegram",
cooldown_key,
)
acquired = await redis.set(key, "1", nx=True, ex=ttl_seconds)
return not bool(acquired)
except Exception as exc:
logger.warning("Redis telegram cooldown unavailable; using local fallback: %s", exc)
return await self._is_cooldown_active_local(cooldown_key, ttl_seconds)
async def _is_cooldown_active_local(self, cooldown_key: str, ttl_seconds: int) -> bool:
now = time.monotonic()
async with self._local_lock:
expired = [
key for key, expires_at in self._local_cooldowns.items() if expires_at <= now
]
for key in expired:
self._local_cooldowns.pop(key, None)
expires_at = self._local_cooldowns.get(cooldown_key)
if expires_at and expires_at > now:
return True
self._local_cooldowns[cooldown_key] = now + ttl_seconds
return False
async def _is_limited_local(self, actor_key: str, rule: RateLimitRule) -> bool:
now = time.monotonic()
cutoff = now - rule.window_seconds
async with self._local_lock:
bucket = self._local_buckets[actor_key]
while bucket and bucket[0] <= cutoff:
bucket.popleft()
bucket.append(now)
if len(bucket) > rule.max_events:
return True
if not bucket:
self._local_buckets.pop(actor_key, None)
return False
def _update_actor_key(update: Update) -> Optional[str]:
user_id = None
chat_id = None
if update.message:
user_id = update.message.from_user.id if update.message.from_user else None
chat_id = update.message.chat.id if update.message.chat else None
elif update.callback_query:
user_id = update.callback_query.from_user.id if update.callback_query.from_user else None
if update.callback_query.message and update.callback_query.message.chat:
chat_id = update.callback_query.message.chat.id
elif update.inline_query:
user_id = update.inline_query.from_user.id if update.inline_query.from_user else None
if user_id is not None:
return f"user:{int(user_id)}"
if chat_id is not None:
return f"chat:{int(chat_id)}"
return None
def _message_or_callback_chat_type(update: Update) -> Optional[str]:
if update.message and update.message.chat:
return str(update.message.chat.type)
if (
update.callback_query
and update.callback_query.message
and update.callback_query.message.chat
):
return str(update.callback_query.message.chat.type)
return None
def _update_action_key(update: Update) -> str:
if update.message:
text = update.message.text or ""
if text.startswith("/start"):
return "start"
return "message"
if update.callback_query:
data = update.callback_query.data or ""
if data.startswith(EXPENSIVE_CALLBACK_PREFIXES):
return "expensive_callback"
return "callback"
if update.inline_query:
return "inline"
return "updates"
def _update_action_cooldown(update: Update, settings: Settings) -> Optional[tuple[str, int]]:
if not bool(getattr(settings, "TELEGRAM_ACTION_COOLDOWN_ENABLED", True)):
return None
if not update.callback_query or not update.callback_query.from_user:
return None
callback_data = update.callback_query.data or ""
if not callback_data:
return None
user_id = int(update.callback_query.from_user.id)
data_digest = hashlib.sha256(callback_data.encode("utf-8")).hexdigest()[:24]
if callback_data.startswith("pay_"):
ttl = int(
getattr(
settings,
"TELEGRAM_PAYMENT_CALLBACK_COOLDOWN_SECONDS",
DEFAULT_PAYMENT_CALLBACK_COOLDOWN_SECONDS,
)
or DEFAULT_PAYMENT_CALLBACK_COOLDOWN_SECONDS
)
return f"payment:user:{user_id}:data:{data_digest}", ttl
if callback_data.startswith(TRIAL_CALLBACK_PREFIXES):
ttl = int(
getattr(
settings,
"TELEGRAM_TRIAL_CALLBACK_COOLDOWN_SECONDS",
DEFAULT_TRIAL_CALLBACK_COOLDOWN_SECONDS,
)
or DEFAULT_TRIAL_CALLBACK_COOLDOWN_SECONDS
)
return f"trial:user:{user_id}:data:{data_digest}", ttl
return None
async def _quietly_answer_callback(update: Update) -> None:
callback = update.callback_query
if not callback:
return
try:
await callback.answer()
except Exception:
pass
def _mark_dropped(data: Dict[str, Any]) -> None:
data["antiflood_dropped"] = True
data["skip_action_log"] = True
def _default_action_rules(settings: Settings) -> Dict[str, RateLimitRule]:
window_seconds = int(
getattr(settings, "TELEGRAM_ANTIFLOOD_WINDOW_SECONDS", DEFAULT_WINDOW_SECONDS)
or DEFAULT_WINDOW_SECONDS
)
return {
"message": RateLimitRule(
window_seconds,
int(
getattr(
settings,
"TELEGRAM_ANTIFLOOD_MESSAGE_MAX_PER_WINDOW",
DEFAULT_MESSAGE_MAX_PER_WINDOW,
)
or DEFAULT_MESSAGE_MAX_PER_WINDOW
),
),
"callback": RateLimitRule(
window_seconds,
int(
getattr(
settings,
"TELEGRAM_ANTIFLOOD_CALLBACK_MAX_PER_WINDOW",
DEFAULT_CALLBACK_MAX_PER_WINDOW,
)
or DEFAULT_CALLBACK_MAX_PER_WINDOW
),
),
"inline": RateLimitRule(
window_seconds,
int(
getattr(
settings,
"TELEGRAM_ANTIFLOOD_INLINE_MAX_PER_WINDOW",
DEFAULT_INLINE_MAX_PER_WINDOW,
)
or DEFAULT_INLINE_MAX_PER_WINDOW
),
),
"start": RateLimitRule(
window_seconds,
int(
getattr(
settings,
"TELEGRAM_ANTIFLOOD_START_MAX_PER_WINDOW",
DEFAULT_START_MAX_PER_WINDOW,
)
or DEFAULT_START_MAX_PER_WINDOW
),
),
"expensive_callback": RateLimitRule(
window_seconds,
int(
getattr(
settings,
"TELEGRAM_ANTIFLOOD_EXPENSIVE_CALLBACK_MAX_PER_WINDOW",
DEFAULT_EXPENSIVE_CALLBACK_MAX_PER_WINDOW,
)
or DEFAULT_EXPENSIVE_CALLBACK_MAX_PER_WINDOW
),
),
}
@@ -22,9 +22,11 @@ from .registry import (
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,
)
@@ -52,6 +54,8 @@ __all__ = [
"pending_statuses",
"provider_telegram_button_text",
"provider_emoji_map",
"provider_admin_only_pairs",
"provider_label_map",
"provider_webhook_metadata",
"resolve_provider_presentation",
]
+208 -3
View File
@@ -28,6 +28,8 @@ class ProviderEnvConfig(BaseSettings):
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",
@@ -36,6 +38,15 @@ class ProviderEnvConfig(BaseSettings):
)
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."""
@@ -68,6 +79,9 @@ class ProviderManifestField:
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)
@@ -98,7 +112,14 @@ class WebAppPaymentContext:
stars_price: Optional[int]
description: str
sale_mode: str
currency: str = "RUB"
traffic_gb: Optional[float] = None
hwid_device_count: Optional[int] = 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]
@@ -106,6 +127,39 @@ ServiceFactory = Callable[[ServiceFactoryContext], Any]
WebhookPathGetter = Callable[[Any], str]
WebhookRoute = Callable[[Any], Awaitable[Any]]
WebAppPaymentFactory = Callable[[WebAppPaymentContext], Awaitable[Any]]
ReusableWebAppPaymentResolver = Callable[[WebAppPaymentContext, Any], Awaitable[Optional[str]]]
CurrencySupportResolver = Callable[[Any], Optional[Sequence[str]]]
PaymentAmountResolver = Callable[[Any, Any, Any], bool]
PaymentMinimumResolver = Callable[[Any, Any], Optional[Mapping[str, Any]]]
def normalize_payment_currency_code(value: Any, default: str = "RUB") -> str:
text = str(value or "").strip().upper()
if not text:
text = str(default).strip().upper() if default is not None else ""
if not text:
return ""
aliases = {"RUR": "RUB", "STARS": "XTR", "STAR": "XTR"}
normalized = aliases.get(text, text)
return "".join(ch for ch in normalized if ch.isalnum() or ch in {"_", "-"}).strip("_-")
def parse_supported_currency_codes(value: Any) -> tuple[str, ...]:
if value is None:
return ()
if isinstance(value, str):
raw_items = value.replace(";", ",").split(",")
else:
raw_items = list(value)
currencies: list[str] = []
seen: set[str] = set()
for item in raw_items:
code = normalize_payment_currency_code(item, default="")
if not code or code in seen:
continue
seen.add(code)
currencies.append(code)
return tuple(currencies)
@dataclass(frozen=True)
@@ -127,6 +181,7 @@ class PaymentProviderSpec:
webhook_route: Optional[WebhookRoute] = None
webhook_requires_base_url: bool = False
create_webapp_payment: Optional[WebAppPaymentFactory] = None
reuse_webapp_payment: Optional[ReusableWebAppPaymentResolver] = None
requires_configured_service: bool = True
price_source: str = "rub"
emoji: str = "💳"
@@ -135,11 +190,29 @@ class PaymentProviderSpec:
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
supported_currencies: Optional[Sequence[str]] = ("RUB",)
supported_currencies_resolver: Optional[CurrencySupportResolver] = None
payment_amount_resolver: Optional[PaymentAmountResolver] = None
payment_minimum_resolver: Optional[PaymentMinimumResolver] = None
currency_support_note: str = ""
currency_support_url: Optional[str] = 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
@@ -148,7 +221,7 @@ class PaymentProviderSpec:
def method_ids(self) -> tuple[str, ...]:
return (self.id, *tuple(self.aliases))
def is_enabled(self, source: Any) -> bool:
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.
@@ -157,8 +230,46 @@ class PaymentProviderSpec:
bundle = get_provider_bundle(self.service_key)
if bundle and bundle.config is not None:
return bool(self.enabled(bundle.config))
return bool(self.enabled(source))
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:
@@ -168,9 +279,103 @@ class PaymentProviderSpec:
service = app.get(self.service_key) if hasattr(app, "get") else None
return bool(service and getattr(service, "configured", False))
def _currency_source(self, source: Any) -> Any:
if self.config_class is not None and self.service_key:
from .registry import get_provider_bundle
bundle = get_provider_bundle(self.service_key)
if bundle and bundle.config is not None:
return bundle.config
return source
def supported_currency_codes(self, source: Any = None) -> Optional[tuple[str, ...]]:
if self.price_source == "stars":
return ("XTR",)
source_for_currency = self._currency_source(source)
if self.supported_currencies_resolver is not None:
resolved = self.supported_currencies_resolver(source_for_currency)
if resolved is None:
return None
return parse_supported_currency_codes(resolved)
if self.supported_currencies is None:
return None
return parse_supported_currency_codes(self.supported_currencies)
def supports_currency(self, source: Any, currency: Any) -> bool:
supported = self.supported_currency_codes(source)
if supported is None:
return True
return normalize_payment_currency_code(currency) in supported
def is_usable_for_payment_currency(self, source: Any, currency: Any) -> bool:
if self.price_source == "stars":
return True
return self.supports_currency(source, currency)
def payment_minimum(self, source: Any, currency: Any) -> Optional[Mapping[str, Any]]:
if self.payment_minimum_resolver is None:
return None
source_for_amount = self._currency_source(source)
try:
return self.payment_minimum_resolver(source_for_amount, currency)
except Exception:
return None
def is_usable_for_payment_amount(self, source: Any, currency: Any, amount: Any) -> bool:
if self.price_source == "stars" or self.payment_amount_resolver is None:
return True
source_for_amount = self._currency_source(source)
try:
return bool(self.payment_amount_resolver(source_for_amount, currency, amount))
except Exception:
return True
def is_usable_for_payment(self, source: Any, currency: Any, amount: Any) -> bool:
return self.is_usable_for_payment_currency(
source,
currency,
) and self.is_usable_for_payment_amount(source, currency, amount)
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
+119 -8
View File
@@ -17,6 +17,10 @@ from bot.middlewares.i18n import JsonI18n
from bot.services.referral_service import ReferralService
from bot.services.subscription_service import SubscriptionService
from config.settings import Settings
from config.tariffs_config import (
default_currency_key_for_settings,
default_payment_currency_code_for_settings,
)
from db.dal import payment_dal
from .base import (
@@ -25,7 +29,9 @@ from .base import (
ProviderManifestField,
ServiceFactoryContext,
WebAppPaymentContext,
normalize_payment_currency_code,
provider_env_file,
provider_runtime_enabled,
)
from .shared import (
PaymentSuccessRequest,
@@ -37,14 +43,45 @@ from .shared import (
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"
CRYPTOPAY_FIAT_CURRENCIES = (
"USD",
"EUR",
"RUB",
"BYN",
"UAH",
"GBP",
"CNY",
"KZT",
"UZS",
"GEL",
"TRY",
"AMD",
"THB",
"INR",
"BRL",
"IDR",
"AZN",
"AED",
"PLN",
"ILS",
)
CRYPTOPAY_CRYPTO_ASSETS = ("USDT", "TON", "BTC", "ETH", "LTC", "BNB", "TRX", "USDC")
def _cryptopay_supported_currencies(config) -> tuple[str, ...]:
currency_type = str(getattr(config, "CURRENCY_TYPE", "fiat") or "fiat").strip().lower()
return CRYPTOPAY_CRYPTO_ASSETS if currency_type == "crypto" else CRYPTOPAY_FIAT_CURRENCIES
class CryptoPayConfig(ProviderEnvConfig):
@@ -119,7 +156,7 @@ class CryptoPayService:
@property
def configured(self) -> bool:
return bool(self.config.ENABLED and self.config.TOKEN)
return bool(provider_runtime_enabled(self.config) and self.config.TOKEN)
@property
def client(self):
@@ -154,20 +191,38 @@ class CryptoPayService:
description: str,
sale_mode: str = "subscription",
url_kind: str = "bot",
hwid_quote: Optional[dict] = None,
hwid_device_count: Optional[int] = None,
currency: Optional[str] = None,
) -> Optional[str]:
if not self.configured or not self.client:
logging.error("CryptoPayService not configured")
return None
currency_code = normalize_payment_currency_code(currency or self.config.ASSET)
currency_type = str(self.config.CURRENCY_TYPE or "fiat").strip().lower()
supported = _cryptopay_supported_currencies(self.config)
if currency_code not in supported:
logging.error(
"CryptoPay currency %s is not supported for currency_type=%s",
currency_code,
currency_type,
)
return None
sale_base = sale_mode_base(sale_mode)
is_traffic = sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
amounts = payment_record_amounts(
months=months,
sale_mode=sale_mode,
hwid_device_count=hwid_device_count,
)
try:
payment_record = await payment_dal.create_payment_record(
session,
{
"user_id": user_id,
"amount": float(amount),
"currency": self.config.ASSET,
"currency": currency_code,
"status": "pending_cryptopay",
"description": description,
"subscription_duration_months": (
@@ -176,7 +231,17 @@ class CryptoPayService:
"provider": "cryptopay",
"sale_mode": sale_mode,
"tariff_key": sale_mode_tariff_key(sale_mode),
"purchased_gb": float(months) if is_traffic else None,
"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()
@@ -191,15 +256,16 @@ class CryptoPayService:
"subscription_months": str(months),
"payment_db_id": str(payment_record.payment_id),
"sale_mode": sale_mode,
"traffic_gb": str(months) if is_traffic else None,
"traffic_gb": str(months) if sale_mode_is_traffic(sale_mode) else None,
"hwid_devices": amounts.purchased_hwid_devices,
}
)
try:
invoice = await self.client.create_invoice(
amount=amount,
currency_type=self.config.CURRENCY_TYPE,
fiat=self.config.ASSET if self.config.CURRENCY_TYPE == "fiat" else None,
asset=self.config.ASSET if self.config.CURRENCY_TYPE == "crypto" else None,
currency_type=currency_type,
fiat=currency_code if currency_type == "fiat" else None,
asset=currency_code if currency_type == "crypto" else None,
description=description,
payload=payload,
)
@@ -255,6 +321,14 @@ class CryptoPayService:
referral_service: ReferralService = app["referral_service"]
async with async_session_factory() as session:
payment = await payment_dal.get_payment_by_db_id(session, payment_db_id)
if not payment:
logging.error("CryptoPay webhook: payment %s not found.", payment_db_id)
return
if payment.status == "succeeded":
logging.info("CryptoPay webhook: payment %s already succeeded.", payment_db_id)
return
try:
await payment_dal.update_provider_payment_and_status(
session,
@@ -349,11 +423,29 @@ async def pay_crypto_callback_handler(
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=default_currency_key_for_settings(settings),
)
if not parts:
await notify_callback_parse_error(callback, translator)
return
@@ -366,6 +458,8 @@ async def pay_crypto_callback_handler(
amount=parts.price,
description=payment_description,
sale_mode=parts.sale_mode,
hwid_quote=hwid_quote,
currency=default_payment_currency_code_for_settings(settings),
)
if invoice_url:
@@ -415,6 +509,17 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
description=ctx.description,
sale_mode=ctx.sale_mode,
url_kind="web",
currency=ctx.currency,
hwid_quote={
"valid_from": ctx.hwid_valid_from,
"valid_until": ctx.hwid_valid_until,
"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,
hwid_device_count=ctx.hwid_device_count,
)
if not url:
return payment_failed()
@@ -541,4 +646,10 @@ SPEC = PaymentProviderSpec(
config_class=CryptoPayConfig,
presentation_class=CryptoPayPresentation,
manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST,
supported_currencies_resolver=_cryptopay_supported_currencies,
currency_support_note=(
"Crypto Pay supports different sets for fiat invoices and crypto invoices; "
"CURRENCY_TYPE selects which set is active."
),
currency_support_url="https://help.crypt.bot/crypto-pay-api/",
)
+152 -11
View File
@@ -20,6 +20,10 @@ from bot.services.referral_service import ReferralService
from bot.services.subscription_service import SubscriptionService
from bot.utils.request_security import ip_in_allowlist, request_client_ip
from config.settings import Settings
from config.tariffs_config import (
default_currency_key_for_settings,
default_payment_currency_code_for_settings,
)
from db.dal import payment_dal
from .base import (
@@ -28,7 +32,9 @@ from .base import (
ProviderManifestField,
ServiceFactoryContext,
WebAppPaymentContext,
normalize_payment_currency_code,
provider_env_file,
provider_runtime_enabled,
)
from .shared import (
HttpClientMixin,
@@ -47,12 +53,18 @@ from .shared import (
notify_service_unavailable,
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,
render_payment_link,
safe_callback_answer,
)
_LOG = "freekassa"
FREEKASSA_SUPPORTED_CURRENCIES = ("RUB", "USD", "EUR", "UAH", "KZT")
class FreeKassaConfig(ProviderEnvConfig):
@@ -67,7 +79,7 @@ class FreeKassaConfig(ProviderEnvConfig):
MERCHANT_ID: Optional[str] = None
FIRST_SECRET: Optional[str] = None
SECOND_SECRET: Optional[str] = None
PAYMENT_URL: str = Field(default="https://pay.freekassa.ru/")
PAYMENT_URL: str = Field(default="https://pay.freekassa.net/")
API_KEY: Optional[str] = None
PAYMENT_IP: Optional[str] = None
PAYMENT_METHOD_ID: Optional[int] = None
@@ -141,10 +153,10 @@ class FreeKassaService(HttpClientMixin):
self.subscription_service = subscription_service
self.referral_service = referral_service
self.default_currency: str = (settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
self.default_currency: str = default_payment_currency_code_for_settings(settings).upper()
self.api_base_url: str = "https://api.fk.life/v1"
self._init_http_client(total_timeout=15)
self._init_http_client(total_timeout=lambda: self.settings.PAYMENT_REQUEST_TIMEOUT_SECONDS)
self._nonce_lock = asyncio.Lock()
self._last_nonce = int(time.time() * 1000)
@@ -152,14 +164,14 @@ class FreeKassaService(HttpClientMixin):
logging.warning(
"FreeKassaService initialized but not fully configured. Payments disabled."
)
if config.ENABLED and not self.server_ip:
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(self.config.ENABLED and self.shop_id and self.api_key)
return bool(provider_runtime_enabled(self.config) and self.shop_id and self.api_key)
@property
def shop_id(self):
@@ -204,7 +216,13 @@ class FreeKassaService(HttpClientMixin):
return False, {"message": "missing_ip"}
email = email or f"{user_id}@telegram.org"
currency_code = (currency or self.default_currency or "RUB").upper()
currency_code = normalize_payment_currency_code(currency or self.default_currency or "RUB")
if currency_code not in FREEKASSA_SUPPORTED_CURRENCIES:
return False, {
"message": "unsupported_currency",
"currency": currency_code,
"supported_currencies": list(FREEKASSA_SUPPORTED_CURRENCIES),
}
payload: Dict[str, Any] = {
"shopId": int(self.shop_id),
@@ -238,6 +256,62 @@ class FreeKassaService(HttpClientMixin):
is_success=lambda status, data: status == 200 and (data or {}).get("type") == "success",
)
async def get_orders(
self,
*,
payment_id: int,
order_status: Optional[int] = None,
) -> Tuple[bool, Dict[str, Any]]:
if not self.configured:
return False, {"message": "service_not_configured"}
payload: Dict[str, Any] = {
"shopId": int(self.shop_id),
"nonce": await self._generate_nonce(),
"paymentId": str(payment_id),
}
if order_status is not None:
payload["orderStatus"] = int(order_status)
payload["signature"] = self._sign_payload(payload)
session = await self._get_session()
return await post_json_request(
session,
f"{self.api_base_url}/orders",
body=payload,
log_prefix="FreeKassa get_orders",
is_success=lambda status, data: status == 200 and (data or {}).get("type") == "success",
)
async def try_reuse_pending_order(self, payment: Any) -> Optional[str]:
order_hash = str(getattr(payment, "provider_payment_id", None) or "").strip()
if not order_hash:
return None
success, response_data = await self.get_orders(
payment_id=payment.payment_id,
order_status=0,
)
if not success:
return None
for order in response_data.get("orders") or []:
if not isinstance(order, dict):
continue
try:
is_new = int(order.get("status", -1)) == 0
except (TypeError, ValueError):
continue
if not is_new:
continue
if str(order.get("merchant_order_id") or "") != str(payment.payment_id):
continue
fk_order_id = str(order.get("fk_order_id") or "").strip()
if fk_order_id:
payment_url = (self.config.PAYMENT_URL or "https://pay.freekassa.net/").rstrip("/")
return f"{payment_url}/form/{fk_order_id}/{order_hash}"
return None
async def _generate_nonce(self) -> int:
async with self._nonce_lock:
candidate = int(time.time() * 1000)
@@ -380,10 +454,10 @@ class FreeKassaService(HttpClientMixin):
)
return web.Response(status=500, text="processing_error")
months = payment.purchased_gb or payment.subscription_duration_months or 1
sale_mode = payment.sale_mode or (
"traffic" if self.settings.traffic_sale_mode else "subscription"
)
months = payment_units_for_activation(payment, sale_mode)
success_prefix: Optional[str] = None
if provider_payment_id:
@@ -451,6 +525,14 @@ async def pay_fk_callback_handler(
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)
@@ -461,10 +543,20 @@ async def pay_fk_callback_handler(
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=default_currency_key_for_settings(settings),
)
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 default_payment_currency_code_for_settings(settings)
or "RUB"
)
payment_description = describe_payment(translator, parts)
@@ -477,8 +569,42 @@ async def pay_fk_callback_handler(
months=parts.months,
provider="freekassa",
sale_mode=parts.sale_mode,
hwid_quote=hwid_quote,
)
reuse_amounts = payment_record_amounts(
months=parts.months,
sale_mode=parts.sale_mode,
hwid_device_count=hwid_quote.get("device_count") if hwid_quote else None,
)
reusable_payment = await payment_dal.find_recent_pending_provider_payment(
session,
user_id=callback.from_user.id,
provider="freekassa",
pending_status="pending_freekassa",
amount=parts.price,
currency=currency_code,
sale_mode=parts.sale_mode,
months=reuse_amounts.months,
purchased_gb=reuse_amounts.purchased_gb,
purchased_hwid_devices=reuse_amounts.purchased_hwid_devices,
tariff_key=reuse_amounts.tariff_key,
)
if reusable_payment is not None:
reusable_url = await freekassa_service.try_reuse_pending_order(reusable_payment)
if reusable_url:
await safe_callback_answer(callback)
await render_payment_link(
callback,
translator=translator,
current_lang=current_lang,
i18n=i18n,
parts=parts,
payment_url=reusable_url,
log_prefix=_LOG,
)
return
try:
payment_record = await payment_dal.create_payment_record(session, record_payload)
await session.commit()
@@ -556,12 +682,13 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
service: FreeKassaService = ctx.request.app["freekassa_service"]
if not service or not service.configured or not service.payment_method_id:
return payment_unavailable()
currency = ctx.currency or service.default_currency
try:
payment = await create_webapp_payment_record(
ctx,
amount=ctx.price,
currency=service.default_currency,
currency=currency,
status="pending_freekassa",
provider="freekassa",
)
@@ -570,7 +697,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
user_id=ctx.user_id,
months=ctx.months,
amount=ctx.price,
currency=service.default_currency,
currency=currency,
payment_method_id=service.payment_method_id,
ip_address=service.server_ip,
extra_params={"us_method": service.payment_method_id},
@@ -590,6 +717,14 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
)
async def reuse_webapp_payment(ctx: WebAppPaymentContext, payment: Any) -> Optional[str]:
service: FreeKassaService = ctx.request.app.get("freekassa_service")
if not service or not service.configured:
return None
return await service.try_reuse_pending_order(payment)
_PRESENTATION_MANIFEST = tuple(
ProviderManifestField(
key=key,
@@ -688,7 +823,7 @@ _CONFIG_MANIFEST = (
"FREEKASSA_PAYMENT_URL",
"url",
"Payment URL",
placeholder="https://pay.freekassa.ru/",
placeholder="https://pay.freekassa.net/",
subsection="FreeKassa",
attr="PAYMENT_URL",
),
@@ -737,7 +872,13 @@ SPEC = PaymentProviderSpec(
webhook_path=lambda source: "/webhook/freekassa",
webhook_route=freekassa_webhook_route,
create_webapp_payment=create_webapp_payment,
reuse_webapp_payment=reuse_webapp_payment,
config_class=FreeKassaConfig,
presentation_class=FreeKassaPresentation,
manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST,
supported_currencies=FREEKASSA_SUPPORTED_CURRENCIES,
currency_support_note=(
"FreeKassa SCI documents the payment currency parameter as RUB, USD, EUR, UAH or KZT."
),
currency_support_url="https://docs.freekassa.net/",
)
+175 -6
View File
@@ -3,6 +3,7 @@ import hashlib
import hmac
import json
import logging
import time
from collections import OrderedDict
from typing import Any, Dict, List, Optional, Tuple
@@ -18,6 +19,10 @@ from bot.services.referral_service import ReferralService
from bot.services.subscription_service import SubscriptionService
from bot.utils.request_security import ip_in_allowlist, request_client_ip
from config.settings import Settings
from config.tariffs_config import (
default_currency_key_for_settings,
default_payment_currency_code_for_settings,
)
from db.dal import payment_dal
from .base import (
@@ -26,7 +31,10 @@ from .base import (
ProviderManifestField,
ServiceFactoryContext,
WebAppPaymentContext,
normalize_payment_currency_code,
parse_supported_currency_codes,
provider_env_file,
provider_runtime_enabled,
)
from .shared import (
HttpClientMixin,
@@ -47,8 +55,12 @@ from .shared import (
notify_user_payment_failed,
parse_payment_callback,
payment_failed,
payment_record_amounts,
payment_unavailable,
payment_units_for_activation,
quote_hwid_callback_parts,
render_link_or_fail,
render_payment_link,
)
router = Router(name="user_subscription_payments_heleket_router")
@@ -56,6 +68,10 @@ _LOG = "heleket"
_SUCCESS_STATUSES = {"paid", "paid_over"}
_FAILED_STATUSES = {"fail", "wrong_amount", "cancel", "system_fail"}
HELEKET_DEFAULT_SUPPORTED_CURRENCIES = (
"RUB,USD,EUR,USDT,USDC,BTC,ETH,LTC,TON,TRX,BNB,BCH,DASH,DAI,DOGE,"
"MATIC,SHIB,SOL,XMR,AVAX,BUSD,VERSE"
)
class HeleketConfig(ProviderEnvConfig):
@@ -80,6 +96,7 @@ class HeleketConfig(ProviderEnvConfig):
LIFETIME_SECONDS: int = Field(default=3600)
VERIFY_WEBHOOK_SIGNATURE: bool = Field(default=True)
TRUSTED_IPS: str = Field(default="31.133.220.8")
SUPPORTED_CURRENCIES: str = Field(default=HELEKET_DEFAULT_SUPPORTED_CURRENCIES)
@field_validator("LIFETIME_SECONDS", mode="before")
@classmethod
@@ -229,7 +246,7 @@ class HeleketService(HttpClientMixin):
self.referral_service = referral_service
self._default_return_url = default_return_url
self._init_http_client(total_timeout=20)
self._init_http_client(total_timeout=lambda: self.settings.PAYMENT_REQUEST_TIMEOUT_SECONDS)
if not self.configured:
logging.warning(
"HeleketService initialized but not fully configured. Payments disabled."
@@ -241,7 +258,7 @@ class HeleketService(HttpClientMixin):
# ``False`` state from startup and the button would never appear.
@property
def configured(self) -> bool:
return bool(self.config.ENABLED and self.merchant_id and self.api_key)
return bool(provider_runtime_enabled(self.config) and self.merchant_id and self.api_key)
@property
def base_url(self) -> str:
@@ -296,9 +313,18 @@ class HeleketService(HttpClientMixin):
logging.error("HeleketService is not configured. Cannot create payment link.")
return False, {"message": "service_not_configured"}
currency_code = normalize_payment_currency_code(currency or self.currency)
supported = parse_supported_currency_codes(self.config.SUPPORTED_CURRENCIES)
if supported and currency_code not in supported:
return False, {
"message": "unsupported_currency",
"currency": currency_code,
"supported_currencies": list(supported),
}
body: Dict[str, Any] = {
"amount": str(format_decimal_amount(amount)),
"currency": (currency or self.currency).upper(),
"currency": currency_code,
"order_id": str(payment_db_id),
"url_return": self.return_url,
"url_success": self.success_url,
@@ -349,6 +375,70 @@ class HeleketService(HttpClientMixin):
logging.exception("Heleket create_payment_link: request failed.")
return False, {"message": str(exc)}
async def get_payment_info(self, payment_uuid: str) -> Tuple[bool, Dict[str, Any]]:
if not self.configured:
return False, {"message": "service_not_configured"}
payment_uuid = str(payment_uuid or "").strip()
if not payment_uuid:
return False, {"message": "missing_payment_uuid"}
body = {"uuid": payment_uuid}
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/info",
data=_serialize_for_signature(body).encode("utf-8"),
headers=headers,
) as response:
response_data = await response.json(content_type=None)
state = response_data.get("state") if isinstance(response_data, dict) else None
if response.status != 200 or state != 0:
logging.warning(
"Heleket get_payment_info failed: uuid=%s status=%s body=%s",
payment_uuid,
response.status,
response_data,
)
return False, {"status": response.status, "message": response_data}
result = response_data.get("result") or {}
return isinstance(result, dict), result
except Exception as exc:
logging.exception("Heleket get_payment_info request failed: uuid=%s", payment_uuid)
return False, {"message": str(exc)}
async def try_reuse_pending_payment(self, payment: Any) -> Optional[str]:
payment_uuid = str(getattr(payment, "provider_payment_id", None) or "").strip()
if not payment_uuid:
return None
success, data = await self.get_payment_info(payment_uuid)
if not success or not isinstance(data, dict):
return None
status = str(data.get("payment_status") or data.get("status") or "").lower()
if status != "check" or bool(data.get("is_final")):
return None
if str(data.get("uuid") or "") != payment_uuid:
return None
if str(data.get("order_id") or "") != str(payment.payment_id):
return None
try:
expired_at = int(data.get("expired_at") or 0)
except (TypeError, ValueError):
return None
if expired_at and expired_at <= int(time.time()):
return None
return (
str(data.get("url") or "").strip()
or str(getattr(payment, "provider_payment_url", None) or "").strip()
or None
)
def _verify_signature(self, payload: Dict[str, Any]) -> bool:
received = payload.get("sign")
if not isinstance(received, str) or not received:
@@ -467,10 +557,10 @@ class HeleketService(HttpClientMixin):
)
return web.Response(status=500, text="processing_error")
payment_units = payment.purchased_gb or payment.subscription_duration_months or 1
sale_mode = payment.sale_mode or (
"traffic" if self.settings.traffic_sale_mode else "subscription"
)
payment_units = payment_units_for_activation(payment, sale_mode)
outcome = await finalize_successful_payment(
PaymentSuccessRequest(
@@ -546,6 +636,14 @@ async def pay_heleket_callback_handler(
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)
@@ -556,8 +654,18 @@ async def pay_heleket_callback_handler(
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=default_currency_key_for_settings(settings),
)
if not parts:
await notify_callback_parse_error(callback, translator)
return
currency_code = (heleket_service.currency or settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
currency_code = default_payment_currency_code_for_settings(settings)
payment_description = describe_payment(translator, parts)
record_payload = build_payment_record_payload(
user_id=callback.from_user.id,
@@ -568,8 +676,41 @@ async def pay_heleket_callback_handler(
months=parts.months,
provider="heleket",
sale_mode=parts.sale_mode,
hwid_quote=hwid_quote,
)
reuse_amounts = payment_record_amounts(
months=parts.months,
sale_mode=parts.sale_mode,
hwid_device_count=hwid_quote.get("device_count") if hwid_quote else None,
)
reusable_payment = await payment_dal.find_recent_pending_provider_payment(
session,
user_id=callback.from_user.id,
provider="heleket",
pending_status="pending_heleket",
amount=parts.price,
currency=currency_code,
sale_mode=parts.sale_mode,
months=reuse_amounts.months,
purchased_gb=reuse_amounts.purchased_gb,
purchased_hwid_devices=reuse_amounts.purchased_hwid_devices,
tariff_key=reuse_amounts.tariff_key,
)
if reusable_payment is not None:
reusable_url = await heleket_service.try_reuse_pending_payment(reusable_payment)
if reusable_url:
await render_payment_link(
callback,
translator=translator,
current_lang=current_lang,
i18n=i18n,
parts=parts,
payment_url=reusable_url,
log_prefix=_LOG,
)
return
try:
payment_record = await payment_dal.create_payment_record(session, record_payload)
await session.commit()
@@ -610,7 +751,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
if not service or not service.configured:
return payment_unavailable()
currency = (service.currency or settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
currency = ctx.currency or default_payment_currency_code_for_settings(settings)
try:
payment = await create_webapp_payment_record(
ctx,
@@ -642,6 +783,13 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
)
async def reuse_webapp_payment(ctx: WebAppPaymentContext, payment: Any) -> Optional[str]:
service: HeleketService = ctx.request.app.get("heleket_service")
if not service or not service.configured:
return None
return await service.try_reuse_pending_payment(payment)
async def heleket_webhook_route(request: web.Request) -> web.Response:
service: HeleketService = request.app["heleket_service"]
return await service.webhook_route(request)
@@ -765,6 +913,18 @@ _CONFIG_MANIFEST = (
subsection="Heleket",
attr="CURRENCY",
),
ProviderManifestField(
"HELEKET_SUPPORTED_CURRENCIES",
"string",
"Supported currencies",
description=(
"Comma-separated invoice currencies allowed for Heleket in this shop. "
"Heleket can reject unsupported codes per account/service."
),
placeholder=HELEKET_DEFAULT_SUPPORTED_CURRENCIES,
subsection="Heleket",
attr="SUPPORTED_CURRENCIES",
),
ProviderManifestField(
"HELEKET_TO_CURRENCY",
"string",
@@ -833,8 +993,17 @@ SPEC = PaymentProviderSpec(
webhook_path=lambda source: "/webhook/heleket",
webhook_route=heleket_webhook_route,
create_webapp_payment=create_webapp_payment,
reuse_webapp_payment=reuse_webapp_payment,
emoji="🪙",
config_class=HeleketConfig,
presentation_class=HeleketPresentation,
manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST,
supported_currencies_resolver=lambda config: getattr(
config, "SUPPORTED_CURRENCIES", HELEKET_DEFAULT_SUPPORTED_CURRENCIES
),
currency_support_note=(
"Heleket supports crypto and fiat invoice currencies, but exact availability "
"can depend on service/account settings."
),
currency_support_url="https://doc.heleket.com/methods/payments/creating-invoice",
)
+877
View File
@@ -0,0 +1,877 @@
import hashlib
import hmac
import json
import logging
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 config.settings import Settings
from config.tariffs_config import (
default_currency_key_for_settings,
default_payment_currency_code_for_settings,
)
from db.dal import payment_dal
from .base import (
PaymentProviderSpec,
ProviderEnvConfig,
ProviderManifestField,
ServiceFactoryContext,
WebAppPaymentContext,
normalize_payment_currency_code,
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_record_amounts,
payment_unavailable,
payment_units_for_activation,
quote_hwid_callback_parts,
render_link_or_fail,
render_payment_link,
)
_LOG = "lava"
# LAVA Business invoice statuses (https://dev.lava.ru/business-objects-invoice).
_SUCCESS_STATUSES = {"success"}
_FAILED_STATUSES = {"cancel", "cancelled", "error", "failed", "expired"}
_PENDING_STATUSES = {"created", "pending", "processing"}
class LavaConfig(ProviderEnvConfig):
"""All LAVA Business env vars. Lives inside the provider module."""
model_config = SettingsConfigDict(
env_file=provider_env_file(),
env_file_encoding="utf-8",
env_prefix="LAVA_",
extra="ignore",
)
ENABLED: bool = Field(default=False)
SHOP_ID: Optional[str] = None
SECRET_KEY: Optional[str] = None
WEBHOOK_SECRET: Optional[str] = None
BASE_URL: str = Field(default="https://api.lava.ru")
RETURN_URL: Optional[str] = None
LIFETIME_MINUTES: Optional[int] = None
INCLUDE_SERVICES: Optional[str] = None
@field_validator("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("SHOP_ID", "SECRET_KEY", "WEBHOOK_SECRET", "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/lava"
def full_webhook_url(self, base: Optional[str]) -> Optional[str]:
if not base:
return None
return f"{base.rstrip('/')}{self.webhook_path}"
@property
def include_services_list(self) -> List[str]:
return [item.strip() for item in (self.INCLUDE_SERVICES or "").split(",") if item.strip()]
class LavaPresentation(ProviderEnvConfig):
"""Admin-tunable button text/icon overrides for LAVA."""
model_config = SettingsConfigDict(
env_file=provider_env_file(),
env_file_encoding="utf-8",
env_prefix="PAYMENT_LAVA_",
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 _canonical_json(payload: Dict[str, Any]) -> str:
"""JSON with sorted keys, the way legacy LAVA PHP-SDK shops sign webhooks.
Only used as a webhook-verification fallback: outgoing requests sign the
exact raw bytes that go on the wire, never a re-serialization. The
``signature`` field is dropped and ``float n.0`` collapses to ``int``
for PHP ``json_encode`` compatibility.
"""
def normalize(value: Any) -> Any:
if isinstance(value, float) and value.is_integer():
return int(value)
if isinstance(value, dict):
return {key: normalize(item) for key, item in value.items() if key != "signature"}
if isinstance(value, list):
return [normalize(item) for item in value]
return value
without_sig = {key: normalize(value) for key, value in payload.items() if key != "signature"}
return json.dumps(without_sig, sort_keys=True, separators=(",", ":"))
class LavaService(HttpClientMixin):
"""Client for LAVA Business API (api.lava.ru).
Outgoing requests are signed with HMAC-SHA256 over the exact raw body
bytes using ``LAVA_SECRET_KEY``; the hex digest travels in the
``Signature`` HTTP header. Webhooks arrive signed with the shop's
additional key (``LAVA_WEBHOOK_SECRET``) in the ``Authorization`` header;
some shops sign the raw body, others a sorted-keys re-serialization, so
verification accepts either canonicalization.
"""
def __init__(
self,
*,
bot: Bot,
settings: Settings,
config: LavaConfig,
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=lambda: self.settings.PAYMENT_REQUEST_TIMEOUT_SECONDS)
if not self.configured:
logging.warning("LavaService initialized but not fully configured. Payments disabled.")
@property
def configured(self) -> bool:
return bool(provider_runtime_enabled(self.config) and self.shop_id and self.secret_key)
@property
def base_url(self) -> str:
return (self.config.BASE_URL or "https://api.lava.ru").rstrip("/")
@property
def shop_id(self) -> str:
return (self.config.SHOP_ID or "").strip()
@property
def secret_key(self) -> str:
return (self.config.SECRET_KEY or "").strip()
@property
def webhook_secret(self) -> str:
# LAVA signs webhooks with the shop's "additional key"; merchants that
# use a single key can leave WEBHOOK_SECRET empty to reuse SECRET_KEY.
return (self.config.WEBHOOK_SECRET or "").strip() or self.secret_key
@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) -> Optional[int]:
return self.config.LIFETIME_MINUTES
def _hmac_hex(self, message: bytes, key: str) -> str:
return hmac.new(key.encode("utf-8"), message, hashlib.sha256).hexdigest()
async def _post_signed(self, path: str, payload: Dict[str, Any]) -> Tuple[bool, Dict[str, Any]]:
"""POST to LAVA signing the exact bytes that go on the wire."""
url = f"{self.base_url}/{path.lstrip('/')}"
body_bytes = json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
headers = {
"Accept": "application/json",
"Content-Type": "application/json",
"Signature": self._hmac_hex(body_bytes, self.secret_key),
}
session = await self._get_session()
try:
async with session.post(url, data=body_bytes, 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("LAVA %s: invalid JSON response: %s", path, response_text[:500])
return False, {"status": response.status, "message": "invalid_json"}
if not isinstance(response_data, dict):
response_data = {"data": response_data}
api_status = str(response_data.get("status") or "").lower()
if response.status != 200 or api_status == "error":
logging.error(
"LAVA %s: API error (http=%s, body=%s)",
path,
response.status,
response_data,
)
return False, {
"status": response.status,
"message": response_data.get("error")
or response_data.get("message")
or "lava_api_error",
"code": response_data.get("code"),
}
data = response_data.get("data")
return True, data if isinstance(data, dict) else response_data
except Exception as exc:
logging.exception("LAVA %s: request failed.", path)
return False, {"message": str(exc)}
async def create_payment(
self,
*,
payment_db_id: int,
amount: float,
currency: Optional[str],
description: Optional[str] = None,
) -> Tuple[bool, Dict[str, Any]]:
if not self.configured:
logging.error("LavaService is not configured. Cannot create payment.")
return False, {"message": "service_not_configured"}
currency_code = normalize_payment_currency_code(
currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
)
if currency_code != "RUB":
return False, {
"message": "unsupported_currency",
"currency": currency_code,
"supported_currencies": ["RUB"],
}
body: Dict[str, Any] = {
"sum": float(format_decimal_amount(amount)),
"orderId": str(payment_db_id),
"shopId": self.shop_id,
}
hook_url = self.config.full_webhook_url(getattr(self.settings, "WEBHOOK_BASE_URL", None))
if hook_url:
body["hookUrl"] = hook_url[:500]
if self.return_url:
body["successUrl"] = self.return_url[:500]
body["failUrl"] = self.return_url[:500]
if self.lifetime_minutes:
# LAVA accepts 1..7200 minutes (5 days).
body["expire"] = max(1, min(7200, int(self.lifetime_minutes)))
if description:
body["comment"] = description[:255]
include_services = self.config.include_services_list
if include_services:
body["includeService"] = include_services
return await self._post_signed("/business/invoice/create", body)
async def get_invoice_status(
self,
*,
order_id: Optional[str] = None,
invoice_id: Optional[str] = None,
) -> Tuple[bool, Dict[str, Any]]:
if not self.configured:
return False, {"message": "service_not_configured"}
if not order_id and not invoice_id:
return False, {"message": "missing_identifier"}
body: Dict[str, Any] = {"shopId": self.shop_id}
if invoice_id:
body["invoiceId"] = str(invoice_id)
if order_id:
body["orderId"] = str(order_id)
return await self._post_signed("/business/invoice/status", body)
async def try_reuse_pending_payment(self, payment: Any) -> Optional[str]:
provider_payment_id = str(getattr(payment, "provider_payment_id", None) or "").strip()
payment_url = str(getattr(payment, "provider_payment_url", None) or "").strip()
if not provider_payment_id or not payment_url:
return None
success, data = await self.get_invoice_status(
order_id=str(payment.payment_id),
invoice_id=provider_payment_id,
)
if not success or str(data.get("status") or "").lower() not in _PENDING_STATUSES:
return None
returned_ids = {str(data.get("id") or ""), str(data.get("invoice_id") or "")}
if provider_payment_id not in returned_ids:
return None
returned_order_id = str(data.get("order_id") or data.get("orderId") or "")
if returned_order_id and returned_order_id != str(payment.payment_id):
return None
return payment_url
def verify_webhook_signature(self, raw_body: bytes, received_signature: str) -> bool:
"""Verify the ``Authorization`` header HMAC on a LAVA webhook.
Accepts HMAC of the raw body (current api.lava.ru contract) or of a
sorted-keys re-serialization (legacy PHP-SDK shops sign that instead).
"""
received = str(received_signature or "").strip()
if not received:
logging.warning("LAVA webhook: missing signature header.")
return False
secret = self.webhook_secret
if not secret:
logging.error("LAVA webhook: no webhook secret configured.")
return False
expected_raw = self._hmac_hex(raw_body, secret)
if hmac.compare_digest(expected_raw.lower(), received.lower()):
return True
try:
payload = json.loads(raw_body)
except (ValueError, TypeError):
return False
if not isinstance(payload, dict):
return False
expected_canonical = self._hmac_hex(_canonical_json(payload).encode("utf-8"), secret)
return hmac.compare_digest(expected_canonical.lower(), received.lower())
async def webhook_route(self, request: web.Request) -> web.Response:
if not self.configured:
return web.json_response({"status": False, "msg": "lava_disabled"}, status=503)
raw_body = await request.read()
signature = request.headers.get("Authorization") or request.headers.get("Signature") or ""
if not self.verify_webhook_signature(raw_body, signature):
logging.error("LAVA webhook: invalid signature.")
return web.json_response({"status": False, "msg": "invalid_signature"}, status=403)
try:
payload = json.loads(raw_body)
except (ValueError, TypeError):
logging.exception("LAVA webhook: failed to parse JSON.")
return web.json_response({"status": False, "msg": "bad_request"}, status=400)
if not isinstance(payload, dict):
logging.error("LAVA webhook: unexpected payload type.")
return web.json_response({"status": False, "msg": "bad_request"}, status=400)
provider_payment_id = str(payload.get("invoice_id") or payload.get("id") or "")
order_id_raw = payload.get("order_id") or payload.get("orderId")
status = str(payload.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(
"LAVA 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 in _SUCCESS_STATUSES:
if payment.status == "succeeded":
logging.info("LAVA webhook: payment %s already succeeded.", payment.payment_id)
return web.json_response({"status": True})
webhook_amount = payload.get("amount")
if webhook_amount is not None and not decimal_amounts_equal(
webhook_amount, payment.amount
):
logging.error(
"LAVA webhook: amount mismatch for payment %s (expected=%s, received=%s)",
payment.payment_id,
payment.amount,
webhook_amount,
)
return web.json_response(
{"status": False, "msg": "amount_mismatch"}, status=400
)
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(
"LAVA 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="lava",
provider_notification="lava",
db_user=payment.user,
log_prefix="LAVA 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 _FAILED_STATUSES:
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(
"LAVA 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 _PENDING_STATUSES:
try:
await payment_dal.update_provider_payment_and_status(
session,
payment.payment_id,
resolved_provider_id,
"pending_lava",
)
await session.commit()
except Exception:
await session.rollback()
logging.exception(
"LAVA webhook: failed to update pending status for %s.",
resolved_provider_id,
)
return web.json_response({"status": True})
logging.warning(
"LAVA webhook: unhandled status '%s' for payment %s",
status,
resolved_provider_id,
)
return web.json_response({"status": True})
async def lava_webhook_route(request: web.Request) -> web.Response:
service: LavaService = request.app["lava_service"]
return await service.webhook_route(request)
router = Router(name="user_subscription_payments_lava_router")
@router.callback_query(F.data.startswith("pay_lava:"))
async def pay_lava_callback_handler(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
lava_service: LavaService,
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 lava_service or not lava_service.configured:
logging.error("LAVA 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_lava 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=lava_service.subscription_service,
currency=default_currency_key_for_settings(settings),
)
if not parts:
await notify_callback_parse_error(callback, translator)
return
currency_code = default_payment_currency_code_for_settings(settings)
payment_description = describe_payment(translator, parts)
record_payload = build_payment_record_payload(
user_id=callback.from_user.id,
amount=parts.price,
currency=currency_code,
status="pending_lava",
description=payment_description,
months=parts.months,
provider="lava",
sale_mode=parts.sale_mode,
hwid_quote=hwid_quote,
)
reuse_amounts = payment_record_amounts(
months=parts.months,
sale_mode=parts.sale_mode,
hwid_device_count=hwid_quote.get("device_count") if hwid_quote else None,
)
reusable_payment = await payment_dal.find_recent_pending_provider_payment(
session,
user_id=callback.from_user.id,
provider="lava",
pending_status="pending_lava",
amount=parts.price,
currency=currency_code,
sale_mode=parts.sale_mode,
months=reuse_amounts.months,
purchased_gb=reuse_amounts.purchased_gb,
purchased_hwid_devices=reuse_amounts.purchased_hwid_devices,
tariff_key=reuse_amounts.tariff_key,
)
if reusable_payment is not None:
reusable_url = await lava_service.try_reuse_pending_payment(reusable_payment)
if reusable_url:
await render_payment_link(
callback,
translator=translator,
current_lang=current_lang,
i18n=i18n,
parts=parts,
payment_url=reusable_url,
log_prefix=_LOG,
)
return
try:
payment_record = await payment_dal.create_payment_record(session, record_payload)
await session.commit()
except Exception:
await session.rollback()
logging.exception(
"LAVA: failed to create payment record for user %s.", callback.from_user.id
)
await notify_payment_record_failure(callback, translator)
return
success, response_data = await lava_service.create_payment(
payment_db_id=payment_record.payment_id,
amount=parts.price,
currency=currency_code,
description=payment_description,
)
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", "invoice_id"),
log_prefix=_LOG,
)
def create_service(ctx: ServiceFactoryContext) -> LavaService:
bundle = ctx.config_for("lava_service")
config = bundle.config if bundle and isinstance(bundle.config, LavaConfig) else LavaConfig()
return LavaService(
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: LavaService = ctx.request.app["lava_service"]
if not service or not service.configured:
return payment_unavailable()
currency = ctx.currency or settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
try:
payment = await create_webapp_payment_record(
ctx,
amount=ctx.price,
currency=currency,
status="pending_lava",
provider="lava",
)
success, response_data = await service.create_payment(
payment_db_id=payment.payment_id,
amount=ctx.price,
currency=currency,
description=ctx.description,
)
except Exception:
await ctx.session.rollback()
logging.exception("LAVA 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", "invoice_id"),
log_prefix="LAVA",
)
async def reuse_webapp_payment(ctx: WebAppPaymentContext, payment: Any) -> Optional[str]:
service: LavaService = ctx.request.app.get("lava_service")
if not service or not service.configured:
return None
return await service.try_reuse_pending_payment(payment)
_PRESENTATION_MANIFEST = tuple(
ProviderManifestField(
key=key,
type=type_,
label=label,
description=description,
placeholder=placeholder,
subsection="LAVA",
target="presentation",
attr=attr,
)
for key, type_, label, description, placeholder, attr in (
(
"PAYMENT_LAVA_WEBAPP_LABEL_RU",
"string",
"WebApp button text (RU)",
"Custom Russian text shown in the Web App payment method button.",
"",
"WEBAPP_LABEL_RU",
),
(
"PAYMENT_LAVA_WEBAPP_LABEL_EN",
"string",
"WebApp button text (EN)",
"Custom English text shown in the Web App payment method button.",
"",
"WEBAPP_LABEL_EN",
),
(
"PAYMENT_LAVA_WEBAPP_ICON",
"icon",
"WebApp button icon",
"Lucide icon name rendered inside the Web App payment method button.",
"CreditCard",
"WEBAPP_ICON",
),
(
"PAYMENT_LAVA_TELEGRAM_LABEL_RU",
"string",
"Telegram button text (RU)",
"Custom Russian text shown in Telegram bot payment buttons.",
"",
"TELEGRAM_LABEL_RU",
),
(
"PAYMENT_LAVA_TELEGRAM_LABEL_EN",
"string",
"Telegram button text (EN)",
"Custom English text shown in Telegram bot payment buttons.",
"",
"TELEGRAM_LABEL_EN",
),
(
"PAYMENT_LAVA_TELEGRAM_EMOJI",
"string",
"Telegram button emoji",
"Emoji prepended to the Telegram bot payment button when customized.",
"💳",
"TELEGRAM_EMOJI",
),
)
)
_CONFIG_MANIFEST = (
ProviderManifestField("LAVA_ENABLED", "bool", "Включена", subsection="LAVA", attr="ENABLED"),
ProviderManifestField("LAVA_SHOP_ID", "string", "Shop ID", subsection="LAVA", attr="SHOP_ID"),
ProviderManifestField(
"LAVA_SECRET_KEY",
"string",
"Secret key",
description="Signs outgoing API requests (HMAC-SHA256 in the Signature header).",
subsection="LAVA",
secret=True,
attr="SECRET_KEY",
),
ProviderManifestField(
"LAVA_WEBHOOK_SECRET",
"string",
"Webhook secret",
description=(
"The shop's additional key used to verify webhook signatures. "
"Leave empty to reuse the secret key."
),
subsection="LAVA",
secret=True,
attr="WEBHOOK_SECRET",
),
ProviderManifestField(
"LAVA_BASE_URL",
"url",
"Base URL",
placeholder="https://api.lava.ru",
subsection="LAVA",
attr="BASE_URL",
),
ProviderManifestField(
"LAVA_RETURN_URL", "url", "Return URL", subsection="LAVA", attr="RETURN_URL"
),
ProviderManifestField(
"LAVA_LIFETIME_MINUTES",
"int",
"Payment link lifetime (minutes)",
description="1..7200; leave empty for the LAVA default.",
subsection="LAVA",
min=1,
max=7200,
attr="LIFETIME_MINUTES",
),
ProviderManifestField(
"LAVA_INCLUDE_SERVICES",
"string",
"Payment services filter",
description=(
"Comma-separated LAVA pay services to show on the payment page "
"(e.g. card,sbp). Empty shows everything enabled for the shop."
),
placeholder="card,sbp",
subsection="LAVA",
attr="INCLUDE_SERVICES",
),
)
SPEC = PaymentProviderSpec(
id="lava",
provider_key="lava",
label="LAVA",
webapp_label="LAVA",
webapp_labels={"ru": "LAVA", "en": "LAVA"},
webapp_icon="CreditCard",
telegram_labels={"ru": "LAVA", "en": "LAVA"},
telegram_emoji="💳",
pending_status="pending_lava",
enabled=lambda config: bool(getattr(config, "ENABLED", False)),
service_key="lava_service",
callback_prefix="pay_lava",
router=router,
create_service=create_service,
webhook_path=lambda source: "/webhook/lava",
webhook_route=lava_webhook_route,
create_webapp_payment=create_webapp_payment,
reuse_webapp_payment=reuse_webapp_payment,
config_class=LavaConfig,
presentation_class=LavaPresentation,
manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST,
supported_currencies=("RUB",),
currency_support_note="LAVA Business invoices are issued in RUB only.",
currency_support_url="https://dev.lava.ru/",
)
File diff suppressed because it is too large Load Diff
+216 -16
View File
@@ -14,6 +14,10 @@ from bot.middlewares.i18n import JsonI18n
from bot.services.referral_service import ReferralService
from bot.services.subscription_service import SubscriptionService
from config.settings import Settings
from config.tariffs_config import (
default_currency_key_for_settings,
default_payment_currency_code_for_settings,
)
from db.dal import payment_dal
from .base import (
@@ -22,7 +26,10 @@ from .base import (
ProviderManifestField,
ServiceFactoryContext,
WebAppPaymentContext,
normalize_payment_currency_code,
parse_supported_currency_codes,
provider_env_file,
provider_runtime_enabled,
)
from .shared import (
HttpClientMixin,
@@ -44,8 +51,11 @@ from .shared import (
payment_failed,
payment_record_amounts,
payment_unavailable,
payment_units_for_activation,
post_json_request,
quote_hwid_callback_parts,
render_link_or_fail,
render_payment_link,
safe_callback_answer,
)
@@ -66,11 +76,14 @@ class PlategaConfig(ProviderEnvConfig):
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
SUPPORTED_CURRENCIES: str = Field(default="RUB")
@field_validator("MERCHANT_ID", "SECRET", "RETURN_URL", "FAILED_URL", mode="before")
@classmethod
@@ -145,7 +158,7 @@ class PlategaService(HttpClientMixin):
self.referral_service = referral_service
self._default_return_url = default_return_url
self._init_http_client(total_timeout=20)
self._init_http_client(total_timeout=lambda: self.settings.PAYMENT_REQUEST_TIMEOUT_SECONDS)
if not self.configured:
logging.warning(
"PlategaService initialized but not fully configured. Payments disabled."
@@ -161,7 +174,15 @@ class PlategaService(HttpClientMixin):
@property
def configured(self) -> bool:
return bool(self.config.ENABLED and self.merchant_id and self.secret)
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:
@@ -216,9 +237,19 @@ class PlategaService(HttpClientMixin):
logging.error("PlategaService is not configured. Cannot create transaction.")
return False, {"message": "service_not_configured"}
currency_code = normalize_payment_currency_code(
currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
)
supported = parse_supported_currency_codes(self.config.SUPPORTED_CURRENCIES)
if supported and currency_code not in supported:
return False, {
"message": "unsupported_currency",
"currency": currency_code,
"supported_currencies": list(supported),
}
session = await self._get_session()
url = f"{self.base_url}/transaction/process"
currency_code = (currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
method_id = int(payment_method if payment_method is not None else self.payment_method)
body: Dict[str, Any] = {
@@ -252,6 +283,69 @@ class PlategaService(HttpClientMixin):
log_prefix="Platega create_transaction",
)
async def get_transaction(self, transaction_id: str) -> Tuple[bool, Dict[str, Any]]:
if not self.configured:
return False, {"message": "service_not_configured"}
transaction_id = str(transaction_id or "").strip()
if not transaction_id:
return False, {"message": "missing_transaction_id"}
session = await self._get_session()
try:
async with session.get(
f"{self.base_url}/transaction/{transaction_id}",
headers=self._auth_headers,
) as response:
data = await response.json(content_type=None)
if response.status != 200 or not isinstance(data, dict):
logging.warning(
"Platega get_transaction failed: id=%s status=%s body=%s",
transaction_id,
response.status,
data,
)
return False, {"status": response.status, "message": data}
return True, data
except Exception as exc:
logging.exception("Platega get_transaction request failed: id=%s", transaction_id)
return False, {"message": str(exc)}
async def try_reuse_pending_transaction(
self,
payment: Any,
*,
user_id: int,
sale_mode: str,
variant: str,
) -> Optional[str]:
transaction_id = str(getattr(payment, "provider_payment_id", None) or "").strip()
payment_url = str(getattr(payment, "provider_payment_url", None) or "").strip()
if not transaction_id or not payment_url:
return None
success, data = await self.get_transaction(transaction_id)
if not success or str(data.get("status") or "").upper() != "PENDING":
return None
if str(data.get("id") or "") != transaction_id:
return None
try:
payload = json.loads(str(data.get("payload") or ""))
except (TypeError, ValueError, json.JSONDecodeError):
return None
expected = {
"payment_db_id": str(payment.payment_id),
"user_id": str(user_id),
"sale_mode": str(sale_mode),
"platega_variant": str(variant),
}
if not isinstance(payload, dict) or any(
str(payload.get(key) or "") != value for key, value in expected.items()
):
return None
return payment_url
async def webhook_route(self, request: web.Request) -> web.Response:
if not self.configured:
return web.Response(status=503, text="platega_disabled")
@@ -291,10 +385,10 @@ class PlategaService(HttpClientMixin):
if payment.status == "succeeded" and status == "CONFIRMED":
return web.Response(text="ok")
payment_months = payment.purchased_gb or payment.subscription_duration_months or 1
sale_mode = payment.sale_mode or (
"traffic" if self.settings.traffic_sale_mode else "subscription"
)
payment_months = payment_units_for_activation(payment, sale_mode)
if status == "CONFIRMED":
if amount_raw is not None:
@@ -395,17 +489,23 @@ def _resolve_platega_variant(
) -> 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:
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:
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:")
@@ -429,6 +529,15 @@ async def pay_platega_callback_handler(
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
@@ -449,8 +558,18 @@ async def pay_platega_callback_handler(
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=default_currency_key_for_settings(settings),
)
if not parts:
await notify_callback_parse_error(callback, translator)
return
currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
currency_code = default_payment_currency_code_for_settings(settings)
payment_description = describe_payment(translator, parts)
record_payload = build_payment_record_payload(
user_id=callback.from_user.id,
@@ -461,8 +580,46 @@ async def pay_platega_callback_handler(
months=parts.months,
provider="platega",
sale_mode=parts.sale_mode,
hwid_quote=hwid_quote,
)
reuse_amounts = payment_record_amounts(
months=parts.months,
sale_mode=parts.sale_mode,
hwid_device_count=hwid_quote.get("device_count") if hwid_quote else None,
)
reusable_payment = await payment_dal.find_recent_pending_provider_payment(
session,
user_id=callback.from_user.id,
provider="platega",
pending_status="pending_platega",
amount=parts.price,
currency=currency_code,
sale_mode=parts.sale_mode,
months=reuse_amounts.months,
purchased_gb=reuse_amounts.purchased_gb,
purchased_hwid_devices=reuse_amounts.purchased_hwid_devices,
tariff_key=reuse_amounts.tariff_key,
)
if reusable_payment is not None:
reusable_url = await platega_service.try_reuse_pending_transaction(
reusable_payment,
user_id=callback.from_user.id,
sale_mode=parts.sale_mode,
variant=platega_variant,
)
if reusable_url:
await render_payment_link(
callback,
translator=translator,
current_lang=current_lang,
i18n=i18n,
parts=parts,
payment_url=reusable_url,
log_prefix=_LOG,
)
return
try:
payment_record = await payment_dal.create_payment_record(session, record_payload)
await session.commit()
@@ -493,7 +650,6 @@ async def pay_platega_callback_handler(
)
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
@@ -510,7 +666,6 @@ async def pay_platega_callback_handler(
api_success=success,
payment_url=redirect_url,
provider_payment_id=persistable_id,
new_status=provider_status if persistable_id else None,
log_prefix=_LOG,
)
@@ -538,11 +693,13 @@ async def _create_webapp_payment(ctx: WebAppPaymentContext, variant: str) -> web
if not service or not service.configured:
return payment_unavailable()
if variant == "platega_crypto":
if not service.config.CRYPTO_ENABLED:
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:
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
@@ -551,11 +708,12 @@ async def _create_webapp_payment(ctx: WebAppPaymentContext, variant: str) -> web
months=ctx.months,
sale_mode=ctx.sale_mode,
traffic_gb=ctx.traffic_gb,
hwid_device_count=ctx.hwid_device_count,
)
payment = await create_webapp_payment_record(
ctx,
amount=ctx.price,
currency=settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
currency=ctx.currency or settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
status="pending_platega",
provider="platega",
)
@@ -575,7 +733,7 @@ async def _create_webapp_payment(ctx: WebAppPaymentContext, variant: str) -> web
)
success, response_data = await service.create_transaction(
amount=ctx.price,
currency=settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
currency=ctx.currency or settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
description=ctx.description,
payload=payload,
payment_method=platega_method_id,
@@ -593,7 +751,6 @@ async def _create_webapp_payment(ctx: WebAppPaymentContext, variant: str) -> web
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",
)
@@ -606,6 +763,19 @@ async def create_crypto_webapp_payment(ctx: WebAppPaymentContext) -> web.Respons
return await _create_webapp_payment(ctx, "platega_crypto")
async def reuse_webapp_payment(ctx: WebAppPaymentContext, payment: Any) -> Optional[str]:
service: PlategaService = ctx.request.app.get("platega_service")
if not service or not service.configured:
return None
variant = "crypto" if ctx.method == "platega_crypto" else "sbp"
return await service.try_reuse_pending_transaction(
payment,
user_id=ctx.user_id,
sale_mode=ctx.sale_mode,
variant=variant,
)
def _platega_presentation_manifest(subsection: str, default_icon: str, prefix: str) -> tuple:
return tuple(
ProviderManifestField(
@@ -716,6 +886,18 @@ _CONFIG_MANIFEST = (
subsection="Platega",
attr="CRYPTO_METHOD",
),
ProviderManifestField(
"PLATEGA_SUPPORTED_CURRENCIES",
"string",
"Supported currencies",
description=(
"Comma-separated payment currencies enabled for your Platega merchant. "
"Public docs expose currency per method/limits but do not publish a fixed global list."
),
placeholder="RUB",
subsection="Platega",
attr="SUPPORTED_CURRENCIES",
),
ProviderManifestField(
"PLATEGA_RETURN_URL", "url", "Return URL", subsection="Platega", attr="RETURN_URL"
),
@@ -738,6 +920,8 @@ SBP_SPEC = PaymentProviderSpec(
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",),
@@ -746,10 +930,17 @@ SBP_SPEC = PaymentProviderSpec(
webhook_path=lambda source: "/webhook/platega",
webhook_route=platega_webhook_route,
create_webapp_payment=create_sbp_webapp_payment,
reuse_webapp_payment=reuse_webapp_payment,
config_class=PlategaConfig,
presentation_class=PlategaSbpPresentation,
manifest_fields=_CONFIG_MANIFEST
+ _platega_presentation_manifest("Platega SBP", "CreditCard", "PLATEGA_SBP"),
+ _platega_presentation_manifest("Platega", "CreditCard", "PLATEGA_SBP"),
supported_currencies_resolver=lambda config: getattr(config, "SUPPORTED_CURRENCIES", "RUB"),
currency_support_note=(
"Platega currencies are merchant/method-specific; configure the codes "
"enabled for your account."
),
currency_support_url="https://docs.platega.io/",
)
CRYPTO_SPEC = PaymentProviderSpec(
@@ -767,12 +958,21 @@ CRYPTO_SPEC = PaymentProviderSpec(
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,
reuse_webapp_payment=reuse_webapp_payment,
config_class=PlategaConfig,
presentation_class=PlategaCryptoPresentation,
manifest_fields=_platega_presentation_manifest("Platega Crypto", "Bitcoin", "PLATEGA_CRYPTO"),
manifest_fields=_platega_presentation_manifest("Platega", "Bitcoin", "PLATEGA_CRYPTO"),
supported_currencies_resolver=lambda config: getattr(config, "SUPPORTED_CURRENCIES", "RUB"),
currency_support_note=(
"Platega currencies are merchant/method-specific; configure the codes "
"enabled for your account."
),
currency_support_url="https://docs.platega.io/",
)
SPECS = (SBP_SPEC, CRYPTO_SPEC)
+98 -1
View File
@@ -2,7 +2,18 @@ from __future__ import annotations
from typing import Any, Dict, Iterable, List, Mapping, Optional
from . import cryptopay, freekassa, heleket, platega, severpay, stars, wata, yookassa
from . import (
cryptopay,
freekassa,
heleket,
lava,
paykilla,
platega,
severpay,
stars,
wata,
yookassa,
)
from .base import (
PaymentProviderPresentation,
PaymentProviderSpec,
@@ -21,6 +32,8 @@ PAYMENT_PROVIDER_SPECS: tuple[PaymentProviderSpec, ...] = (
stars.SPEC,
cryptopay.SPEC,
heleket.SPEC,
paykilla.SPEC,
lava.SPEC,
)
@@ -321,8 +334,13 @@ def pending_statuses() -> List[str]:
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]]:
@@ -333,6 +351,85 @@ def find_manifest_owner(key: str) -> Optional[tuple[PaymentProviderSpec, Provide
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,
+153 -6
View File
@@ -16,6 +16,10 @@ from bot.middlewares.i18n import JsonI18n
from bot.services.referral_service import ReferralService
from bot.services.subscription_service import SubscriptionService
from config.settings import Settings
from config.tariffs_config import (
default_currency_key_for_settings,
default_payment_currency_code_for_settings,
)
from db.dal import payment_dal
from .base import (
@@ -24,7 +28,10 @@ from .base import (
ProviderManifestField,
ServiceFactoryContext,
WebAppPaymentContext,
normalize_payment_currency_code,
parse_supported_currency_codes,
provider_env_file,
provider_runtime_enabled,
)
from .shared import (
HttpClientMixin,
@@ -44,9 +51,13 @@ from .shared import (
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,
render_payment_link,
)
_LOG = "severpay"
@@ -66,6 +77,7 @@ class SeverPayConfig(ProviderEnvConfig):
RETURN_URL: Optional[str] = None
BASE_URL: str = Field(default="https://severpay.io/api/merchant")
LIFETIME_MINUTES: Optional[int] = None
SUPPORTED_CURRENCIES: str = Field(default="RUB,USD")
@field_validator("MID", "LIFETIME_MINUTES", mode="before")
@classmethod
@@ -126,7 +138,7 @@ class SeverPayService(HttpClientMixin):
self.referral_service = referral_service
self._default_return_url = default_return_url
self._init_http_client(total_timeout=15)
self._init_http_client(total_timeout=lambda: self.settings.PAYMENT_REQUEST_TIMEOUT_SECONDS)
if not self.configured:
logging.warning(
@@ -135,7 +147,7 @@ class SeverPayService(HttpClientMixin):
@property
def configured(self) -> bool:
return bool(self.config.ENABLED and self.mid and self.token)
return bool(provider_runtime_enabled(self.config) and self.mid and self.token)
@property
def base_url(self) -> str:
@@ -198,9 +210,19 @@ class SeverPayService(HttpClientMixin):
logging.error("SeverPayService is not configured. Cannot create payment.")
return False, {"message": "service_not_configured"}
currency_code = normalize_payment_currency_code(
currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
)
supported = parse_supported_currency_codes(self.config.SUPPORTED_CURRENCIES)
if supported and currency_code not in supported:
return False, {
"message": "unsupported_currency",
"currency": currency_code,
"supported_currencies": list(supported),
}
session = await self._get_session()
url = f"{self.base_url}/payin/create"
currency_code = (currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
body = {
"order_id": str(payment_db_id),
@@ -228,6 +250,48 @@ class SeverPayService(HttpClientMixin):
return True, response_data.get("data") or response_data
return False, response_data
async def get_payment(self, provider_payment_id: str) -> Tuple[bool, Dict[str, Any]]:
if not self.configured:
return False, {"message": "service_not_configured"}
provider_payment_id = str(provider_payment_id or "").strip()
if not provider_payment_id:
return False, {"message": "missing_payment_id"}
identifier: Dict[str, Any]
if provider_payment_id.isdigit():
identifier = {"id": int(provider_payment_id)}
else:
identifier = {"uid": provider_payment_id}
session = await self._get_session()
success, response_data = await post_json_request(
session,
f"{self.base_url}/payin/get",
body=self._build_signed_body(identifier),
log_prefix="SeverPay get_payment",
is_success=lambda status, data: status == 200 and bool((data or {}).get("status")),
)
if success:
return True, response_data.get("data") or response_data
return False, response_data
async def try_reuse_pending_payment(self, payment: Any) -> Optional[str]:
provider_payment_id = str(getattr(payment, "provider_payment_id", None) or "").strip()
payment_url = str(getattr(payment, "provider_payment_url", None) or "").strip()
if not provider_payment_id or not payment_url:
return None
success, data = await self.get_payment(provider_payment_id)
if not success or str(data.get("status") or "").lower() not in {"new", "process"}:
return None
returned_ids = {str(data.get("id") or ""), str(data.get("uid") or "")}
if provider_payment_id not in returned_ids:
return None
if str(data.get("order_id") or "") != str(payment.payment_id):
return None
return payment_url
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)
@@ -268,12 +332,19 @@ class SeverPayService(HttpClientMixin):
return web.json_response({"status": False, "msg": "payment_not_found"}, status=404)
resolved_provider_id = provider_payment_id or str(payment.payment_id)
payment_months = payment.purchased_gb or payment.subscription_duration_months or 1
sale_mode = payment.sale_mode or (
"traffic" if self.settings.traffic_sale_mode else "subscription"
)
payment_months = payment_units_for_activation(payment, sale_mode)
if status == "success":
if payment.status == "succeeded":
logging.info(
"SeverPay webhook: payment %s already succeeded.",
payment.payment_id,
)
return web.json_response({"status": True})
try:
await payment_dal.update_provider_payment_and_status(
session,
@@ -395,6 +466,14 @@ async def pay_severpay_callback_handler(
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)
@@ -405,8 +484,18 @@ async def pay_severpay_callback_handler(
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=default_currency_key_for_settings(settings),
)
if not parts:
await notify_callback_parse_error(callback, translator)
return
currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
currency_code = default_payment_currency_code_for_settings(settings)
payment_description = describe_payment(translator, parts)
record_payload = build_payment_record_payload(
user_id=callback.from_user.id,
@@ -417,8 +506,41 @@ async def pay_severpay_callback_handler(
months=parts.months,
provider="severpay",
sale_mode=parts.sale_mode,
hwid_quote=hwid_quote,
)
reuse_amounts = payment_record_amounts(
months=parts.months,
sale_mode=parts.sale_mode,
hwid_device_count=hwid_quote.get("device_count") if hwid_quote else None,
)
reusable_payment = await payment_dal.find_recent_pending_provider_payment(
session,
user_id=callback.from_user.id,
provider="severpay",
pending_status="pending_severpay",
amount=parts.price,
currency=currency_code,
sale_mode=parts.sale_mode,
months=reuse_amounts.months,
purchased_gb=reuse_amounts.purchased_gb,
purchased_hwid_devices=reuse_amounts.purchased_hwid_devices,
tariff_key=reuse_amounts.tariff_key,
)
if reusable_payment is not None:
reusable_url = await severpay_service.try_reuse_pending_payment(reusable_payment)
if reusable_url:
await render_payment_link(
callback,
translator=translator,
current_lang=current_lang,
i18n=i18n,
parts=parts,
payment_url=reusable_url,
log_prefix=_LOG,
)
return
try:
payment_record = await payment_dal.create_payment_record(session, record_payload)
await session.commit()
@@ -474,7 +596,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
if not service or not service.configured:
return payment_unavailable()
currency = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
currency = ctx.currency or settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
try:
payment = await create_webapp_payment_record(
ctx,
@@ -506,6 +628,13 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
)
async def reuse_webapp_payment(ctx: WebAppPaymentContext, payment: Any) -> Optional[str]:
service: SeverPayService = ctx.request.app.get("severpay_service")
if not service or not service.configured:
return None
return await service.try_reuse_pending_payment(payment)
_PRESENTATION_MANIFEST = tuple(
ProviderManifestField(
key=key,
@@ -598,6 +727,18 @@ _CONFIG_MANIFEST = (
max=4320,
attr="LIFETIME_MINUTES",
),
ProviderManifestField(
"SEVERPAY_SUPPORTED_CURRENCIES",
"string",
"Supported currencies",
description=(
"Comma-separated currencies enabled for your SeverPay merchant. "
"The public PayIn docs show USD examples but do not publish a fixed global list."
),
placeholder="RUB,USD",
subsection="SeverPay",
attr="SUPPORTED_CURRENCIES",
),
)
@@ -619,7 +760,13 @@ SPEC = PaymentProviderSpec(
webhook_path=lambda source: "/webhook/severpay",
webhook_route=severpay_webhook_route,
create_webapp_payment=create_webapp_payment,
reuse_webapp_payment=reuse_webapp_payment,
config_class=SeverPayConfig,
presentation_class=SeverPayPresentation,
manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST,
supported_currencies_resolver=lambda config: getattr(config, "SUPPORTED_CURRENCIES", "RUB,USD"),
currency_support_note=(
"SeverPay PayIn requires a currency; keep this list aligned with your merchant account."
),
currency_support_url="https://docs.severpay.io/ru/payin/create",
)
@@ -15,6 +15,7 @@ from .callbacks import (
notify_service_unavailable,
parse_payment_callback,
payment_link_message_text,
quote_hwid_callback_parts,
render_link_or_fail,
render_payment_link,
safe_callback_answer,
@@ -35,10 +36,13 @@ from .common import (
json_error,
make_translator,
mark_payment_failed_creation,
parse_positive_int_units,
payment_failed,
payment_link_response,
payment_record_amounts,
payment_unavailable,
payment_units_for_activation,
reusable_webapp_payment_response,
sale_mode_base,
sale_mode_is_hwid_devices,
sale_mode_is_traffic,
@@ -55,6 +59,7 @@ from .success import (
PaymentSuccessOutcome,
PaymentSuccessRequest,
SuccessMessage,
append_hwid_renewal_note,
build_success_message,
finalize_successful_payment,
is_traffic_sale_base,
@@ -82,6 +87,7 @@ __all__ = [
"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",
@@ -100,6 +106,7 @@ __all__ = [
"lookup_payment_by_order_or_provider_id",
"make_translator",
"mark_payment_failed_creation",
"parse_positive_int_units",
"notify_admins_payment_received",
"notify_callback_parse_error",
"notify_payment_gateway_failure",
@@ -111,8 +118,11 @@ __all__ = [
"payment_link_message_text",
"payment_link_response",
"payment_record_amounts",
"reusable_webapp_payment_response",
"payment_units_for_activation",
"payment_unavailable",
"post_json_request",
"quote_hwid_callback_parts",
"render_link_or_fail",
"render_payment_link",
"resolve_inviter_name",
@@ -8,8 +8,10 @@ from aiogram import types
from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.inline.user_keyboards import (
HWID_RENEWAL_TOKEN,
get_payment_url_keyboard,
payment_methods_back_callback,
sale_mode_has_token,
)
from bot.middlewares.i18n import JsonI18n
from db.dal import payment_dal
@@ -20,7 +22,10 @@ from .common import (
build_payment_description,
format_human_units,
mark_payment_failed_creation,
parse_positive_int_units,
sale_mode_base,
sale_mode_is_hwid_devices,
sale_mode_tariff_key,
)
@@ -112,6 +117,58 @@ def describe_payment(translator: Translator, parts: PaymentCallbackParts) -> str
)
async def quote_hwid_callback_parts(
*,
session: AsyncSession,
user_id: int,
parts: PaymentCallbackParts,
subscription_service,
currency: str = "rub",
) -> tuple[Optional[PaymentCallbackParts], Optional[dict]]:
base = sale_mode_base(parts.sale_mode)
if base == "subscription" and sale_mode_has_token(parts.sale_mode, HWID_RENEWAL_TOKEN):
try:
months = int(parts.months)
except (TypeError, ValueError):
return None, None
quote = await subscription_service.quote_hwid_device_renewal_for_subscription(
session,
user_id=user_id,
target_tariff_key=sale_mode_tariff_key(parts.sale_mode),
months=months,
currency=currency,
)
if not quote:
return parts, None
quoted_parts = PaymentCallbackParts(
months=months,
price=float(parts.price or 0) + float(quote.get("price") or 0),
sale_mode=parts.sale_mode,
)
return quoted_parts, quote
if not sale_mode_is_hwid_devices(parts.sale_mode):
return parts, None
device_count = parse_positive_int_units(parts.months)
if device_count is None:
return None, None
quote = await subscription_service.quote_hwid_device_topup(
session,
user_id=user_id,
device_count=device_count,
tariff_key=sale_mode_tariff_key(parts.sale_mode),
renewal=sale_mode_base(parts.sale_mode) == "hwid_devices_renewal",
currency=currency,
)
if not quote:
return None, None
quoted_parts = PaymentCallbackParts(
months=device_count,
price=float(quote.get("price") or 0),
sale_mode=parts.sale_mode,
)
return quoted_parts, quote
def payment_link_message_text(
translator: Translator,
parts: PaymentCallbackParts,
@@ -228,6 +285,7 @@ async def safe_store_provider_payment_id(
payment: Payment,
*,
provider_payment_id: str,
provider_payment_url: Optional[str] = None,
new_status: Optional[str] = None,
log_prefix: str,
) -> bool:
@@ -243,6 +301,7 @@ async def safe_store_provider_payment_id(
payment.payment_id,
str(provider_payment_id),
new_status or payment.status,
provider_payment_url=provider_payment_url,
)
await session.commit()
return True
@@ -297,11 +356,12 @@ async def render_link_or_fail(
payment as ``failed_creation``. Every link-style provider used to inline
this same sequence.
"""
if api_success and provider_payment_id:
if api_success and provider_payment_id and payment_url:
await safe_store_provider_payment_id(
session,
payment,
provider_payment_id=provider_payment_id,
provider_payment_url=payment_url,
new_status=new_status,
log_prefix=log_prefix,
)
+116 -6
View File
@@ -1,7 +1,7 @@
from __future__ import annotations
from dataclasses import dataclass
from decimal import ROUND_HALF_UP, Decimal
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
from typing import Any, Callable, Optional
from aiohttp import web
@@ -36,6 +36,20 @@ def decimal_amounts_equal(left: Any, right: Any, places: int = 2) -> bool:
return format_decimal_amount(left, places) == format_decimal_amount(right, places)
def parse_positive_int_units(value: Any) -> Optional[int]:
"""Return a positive integer only when the input represents whole units exactly."""
if isinstance(value, bool):
return None
try:
decimal_value = Decimal(str(value).strip())
except (InvalidOperation, ValueError):
return None
if not decimal_value.is_finite() or decimal_value != decimal_value.to_integral_value():
return None
integer_value = int(decimal_value)
return integer_value if integer_value > 0 else None
def format_human_units(value: Any) -> str:
"""Render numeric units the way the UI expects: integers w/o decimals, floats with %g."""
numeric = float(value)
@@ -60,7 +74,7 @@ def build_payment_description(
"payment_description_traffic",
traffic_gb=human_value if human_value is not None else format_human_units(months),
)
if base in {"hwid_device", "hwid_devices"}:
if base in {"hwid_device", "hwid_devices", "hwid_devices_renewal"}:
return translator("payment_description_hwid_devices", count=int(float(months)))
return translator("payment_description_subscription", months=int(float(months)))
@@ -75,6 +89,7 @@ def build_payment_record_payload(
months: Any,
provider: str,
sale_mode: str,
hwid_quote: Optional[dict] = None,
) -> dict:
"""Assemble the payment-record dict that every callback handler used to inline.
@@ -85,7 +100,12 @@ def build_payment_record_payload(
base = sale_mode_base(sale_mode)
is_traffic = sale_mode_is_traffic(sale_mode)
is_hwid = sale_mode_is_hwid_devices(sale_mode)
return {
hwid_devices = int(float(months)) if is_hwid else None
if hwid_quote:
quote_devices = parse_positive_int_units(hwid_quote.get("device_count"))
if quote_devices is not None:
hwid_devices = quote_devices
payload = {
"user_id": user_id,
"amount": amount,
"currency": currency,
@@ -96,8 +116,19 @@ def build_payment_record_payload(
"sale_mode": sale_mode,
"tariff_key": sale_mode_tariff_key(sale_mode),
"purchased_gb": float(months) if is_traffic else None,
"purchased_hwid_devices": int(float(months)) if is_hwid else None,
"purchased_hwid_devices": hwid_devices,
}
if hwid_quote and hwid_devices is not None:
payload.update(
{
"hwid_valid_from": hwid_quote.get("valid_from"),
"hwid_valid_until": hwid_quote.get("valid_until"),
"hwid_pricing_period_months": hwid_quote.get("pricing_period_months"),
"hwid_proration_ratio": hwid_quote.get("proration_ratio"),
"hwid_full_price": hwid_quote.get("full_price"),
}
)
return payload
@dataclass(frozen=True)
@@ -119,7 +150,7 @@ def sale_mode_is_traffic(sale_mode: str) -> bool:
def sale_mode_is_hwid_devices(sale_mode: str) -> bool:
return sale_mode_base(sale_mode) in {"hwid_device", "hwid_devices"}
return sale_mode_base(sale_mode) in {"hwid_device", "hwid_devices", "hwid_devices_renewal"}
def sale_mode_tariff_key(sale_mode: str) -> Optional[str]:
@@ -138,20 +169,44 @@ def payment_record_amounts(
months: Any,
sale_mode: str,
traffic_gb: Optional[float] = None,
hwid_device_count: Optional[int] = None,
) -> PaymentRecordAmounts:
traffic_sale = sale_mode_is_traffic(sale_mode)
hwid_devices_sale = sale_mode_is_hwid_devices(sale_mode)
units = traffic_gb if traffic_sale and traffic_gb is not None else months
purchased_hwid_devices = int(float(months)) if hwid_devices_sale else None
if not hwid_devices_sale and hwid_device_count is not None:
parsed_hwid_devices = parse_positive_int_units(hwid_device_count)
if parsed_hwid_devices is not None:
purchased_hwid_devices = parsed_hwid_devices
return PaymentRecordAmounts(
months=int(float(units)) if traffic_sale else int(float(months)),
purchased_gb=float(units) if traffic_sale else None,
purchased_hwid_devices=int(float(months)) if hwid_devices_sale else None,
purchased_hwid_devices=purchased_hwid_devices,
tariff_key=sale_mode_tariff_key(sale_mode),
traffic_sale=traffic_sale,
hwid_devices_sale=hwid_devices_sale,
)
def payment_units_for_activation(payment: Any, sale_mode: str) -> Any:
"""Resolve purchased units from a payment record for webhook activation."""
base = sale_mode_base(sale_mode)
if sale_mode_is_traffic(base):
return (
getattr(payment, "purchased_gb", None)
or getattr(payment, "subscription_duration_months", None)
or 1
)
if sale_mode_is_hwid_devices(base):
return (
getattr(payment, "purchased_hwid_devices", None)
or getattr(payment, "subscription_duration_months", None)
or 1
)
return getattr(payment, "subscription_duration_months", None) or 1
def json_error(status: int, code: str, message: str) -> web.Response:
return web.json_response({"ok": False, "error": code, "message": message}, status=status)
@@ -194,6 +249,11 @@ async def create_base_payment_record(
tariff_key: Optional[str] = None,
purchased_gb: Optional[float] = None,
purchased_hwid_devices: Optional[int] = 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,
) -> Payment:
payment = await payment_dal.create_payment_record(
session,
@@ -209,6 +269,11 @@ async def create_base_payment_record(
"tariff_key": tariff_key,
"purchased_gb": purchased_gb,
"purchased_hwid_devices": purchased_hwid_devices,
"hwid_valid_from": hwid_valid_from,
"hwid_valid_until": hwid_valid_until,
"hwid_pricing_period_months": hwid_pricing_period_months,
"hwid_proration_ratio": hwid_proration_ratio,
"hwid_full_price": hwid_full_price,
},
)
await session.commit()
@@ -227,6 +292,7 @@ async def create_webapp_payment_record(
months=ctx.months,
sale_mode=ctx.sale_mode,
traffic_gb=ctx.traffic_gb,
hwid_device_count=ctx.hwid_device_count,
)
return await create_base_payment_record(
ctx.session,
@@ -241,9 +307,53 @@ async def create_webapp_payment_record(
tariff_key=amounts.tariff_key,
purchased_gb=amounts.purchased_gb,
purchased_hwid_devices=amounts.purchased_hwid_devices,
hwid_valid_from=ctx.hwid_valid_from,
hwid_valid_until=ctx.hwid_valid_until,
hwid_pricing_period_months=ctx.hwid_pricing_period_months,
hwid_proration_ratio=ctx.hwid_proration_ratio,
hwid_full_price=ctx.hwid_full_price,
)
async def reusable_webapp_payment_response(
ctx: WebAppPaymentContext,
provider_spec: Any,
*,
since_minutes: Optional[int] = None,
) -> Optional[web.Response]:
resolver = getattr(provider_spec, "reuse_webapp_payment", None)
if resolver is None:
return None
amounts = payment_record_amounts(
months=ctx.months,
sale_mode=ctx.sale_mode,
traffic_gb=ctx.traffic_gb,
hwid_device_count=ctx.hwid_device_count,
)
payment = await payment_dal.find_recent_pending_provider_payment(
ctx.session,
user_id=ctx.user_id,
provider=provider_spec.provider_key,
pending_status=provider_spec.pending_status,
amount=ctx.price,
currency=ctx.currency,
sale_mode=ctx.sale_mode,
months=amounts.months,
purchased_gb=amounts.purchased_gb,
purchased_hwid_devices=amounts.purchased_hwid_devices,
tariff_key=amounts.tariff_key,
since_minutes=since_minutes,
)
if payment is None:
return None
payment_url = await resolver(ctx, payment)
if not payment_url:
return None
return payment_link_response(payment_url=payment_url, payment_id=payment.payment_id)
async def mark_payment_failed_creation(session: AsyncSession, payment_id: int) -> None:
await payment_dal.update_payment_status_by_db_id(session, payment_id, "failed_creation")
await session.commit()
@@ -1,12 +1,16 @@
from __future__ import annotations
import asyncio
import json
import logging
from typing import Any, Callable, Dict, Mapping, Optional, Tuple
from typing import Any, Callable, Dict, List, Mapping, Optional, Set, Tuple, Union
from aiohttp import ClientSession, ClientTimeout
from aiohttp import ClientError, ClientSession, ClientTimeout, TraceConfig
SuccessCheck = Callable[[int, Any], bool]
TimeoutSource = Union[float, Callable[[], float]]
_TRANSPORT_ATTEMPTS = 2
_DEFAULT_TIMEOUT_SECONDS = 20.0
def http_ok(status: int, _body: Any) -> bool:
@@ -14,6 +18,29 @@ def http_ok(status: int, _body: Any) -> bool:
return status == 200
def _trace_request_ctx(trace_config_ctx: Any) -> Optional[dict]:
ctx = getattr(trace_config_ctx, "trace_request_ctx", None)
return ctx if isinstance(ctx, dict) else None
async def _mark_request_headers_sent(session, trace_config_ctx, params) -> None:
ctx = _trace_request_ctx(trace_config_ctx)
if ctx is not None:
ctx["headers_sent"] = True
def _payment_trace_config() -> TraceConfig:
trace_config = TraceConfig()
trace_config.on_request_headers_sent.append(_mark_request_headers_sent)
return trace_config
def _should_retry_transport_error(exc: Exception, trace_ctx: Mapping[str, Any]) -> bool:
if trace_ctx.get("headers_sent"):
return False
return isinstance(exc, (asyncio.TimeoutError, ClientError, OSError))
async def post_json_request(
session: ClientSession,
url: str,
@@ -29,34 +56,47 @@ async def post_json_request(
returns ``(False, {"status": ..., "message": ..., "raw": ...?})`` so callers
can decide what to do (typically: mark the payment as ``failed_creation``).
"""
try:
async with session.post(
url,
json=body,
headers=dict(headers) if headers else None,
) as response:
response_text = await response.text()
try:
response_data = json.loads(response_text) if response_text else {}
except json.JSONDecodeError:
logging.error("%s: invalid JSON response: %s", log_prefix, response_text)
return False, {
"status": response.status,
"message": "invalid_json",
"raw": response_text,
}
if not is_success(response.status, response_data):
logging.error(
"%s: API returned error (status=%s, body=%s)",
for attempt in range(1, _TRANSPORT_ATTEMPTS + 1):
trace_ctx: dict[str, Any] = {"headers_sent": False}
try:
async with session.post(
url,
json=body,
headers=dict(headers) if headers else None,
trace_request_ctx=trace_ctx,
) as response:
response_text = await response.text()
try:
response_data = json.loads(response_text) if response_text else {}
except json.JSONDecodeError:
logging.error("%s: invalid JSON response: %s", log_prefix, response_text)
return False, {
"status": response.status,
"message": "invalid_json",
"raw": response_text,
}
if not is_success(response.status, response_data):
logging.error(
"%s: API returned error (status=%s, body=%s)",
log_prefix,
response.status,
response_data,
)
return False, {"status": response.status, "message": response_data}
return True, response_data
except Exception as exc:
if attempt < _TRANSPORT_ATTEMPTS and _should_retry_transport_error(exc, trace_ctx):
logging.warning(
"%s: transport failed before request headers were sent; retrying (%s/%s): %s", # noqa: E501
log_prefix,
response.status,
response_data,
attempt + 1,
_TRANSPORT_ATTEMPTS,
exc,
)
return False, {"status": response.status, "message": response_data}
return True, response_data
except Exception as exc:
logging.exception("%s: request failed.", log_prefix)
return False, {"message": str(exc)}
continue
logging.exception("%s: request failed.", log_prefix)
return False, {"message": str(exc)}
return False, {"message": "request_failed"}
def first_value(data: Optional[Mapping[str, Any]], *keys: str) -> Optional[str]:
@@ -76,20 +116,69 @@ class HttpClientMixin:
Each subclass calls ``self._init_http_client(total_timeout=...)`` from
``__init__`` and inherits ``_get_session`` / ``close``. The session is
created on first use and recreated transparently if it was closed.
``total_timeout`` may be a callable so the timeout follows runtime
settings changes (admin overrides apply in-process without a restart).
When the value changes, the next request gets a fresh session; the old
session stays open until its own in-flight requests cannot outlive it.
Provider API calls are traced so callers can retry transport failures only
when aiohttp has not sent request headers yet.
"""
_timeout: ClientTimeout
_timeout_source: TimeoutSource
_session: Optional[ClientSession]
_stale_sessions: List[ClientSession]
_session_cleanup_tasks: Set["asyncio.Task[None]"]
def _init_http_client(self, *, total_timeout: float = 20.0) -> None:
self._timeout = ClientTimeout(total=total_timeout)
def _init_http_client(self, *, total_timeout: TimeoutSource = _DEFAULT_TIMEOUT_SECONDS) -> None:
self._timeout_source = total_timeout
self._session = None
self._stale_sessions = []
self._session_cleanup_tasks = set()
def _current_timeout_seconds(self) -> float:
source = self._timeout_source
try:
seconds = float(source() if callable(source) else source)
except Exception:
return _DEFAULT_TIMEOUT_SECONDS
return seconds if seconds > 0 else _DEFAULT_TIMEOUT_SECONDS
async def _get_session(self) -> ClientSession:
if self._session is None or self._session.closed:
self._session = ClientSession(timeout=self._timeout)
return self._session
timeout_seconds = self._current_timeout_seconds()
session = self._session
if session is not None and not session.closed and session.timeout.total != timeout_seconds:
self._session = None
self._stale_sessions.append(session)
task = asyncio.create_task(self._close_stale_session(session))
self._session_cleanup_tasks.add(task)
task.add_done_callback(self._session_cleanup_tasks.discard)
session = None
if session is None or session.closed:
session = ClientSession(
timeout=ClientTimeout(total=timeout_seconds),
trace_configs=[_payment_trace_config()],
)
self._session = session
return session
async def _close_stale_session(self, session: ClientSession) -> None:
# Any request started on this session is bound by its total timeout,
# so after that long it is safe to close without cutting one off.
await asyncio.sleep((session.timeout.total or _DEFAULT_TIMEOUT_SECONDS) + 1.0)
if session in self._stale_sessions:
self._stale_sessions.remove(session)
if not session.closed:
await session.close()
async def close(self) -> None:
if self._session and not self._session.closed:
await self._session.close()
for task in list(self._session_cleanup_tasks):
task.cancel()
self._session_cleanup_tasks.clear()
sessions = [self._session, *self._stale_sessions]
self._session = None
self._stale_sessions = []
for session in sessions:
if session and not session.closed:
await session.close()
+129 -8
View File
@@ -11,13 +11,21 @@ from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
from bot.services.notification_service import NotificationService
from bot.utils.config_link import prepare_config_links
from bot.utils.install_links import ensure_user_install_guide_links
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
from db.dal import payment_dal, user_dal
from db.models import Payment, User
from .common import Translator, format_human_units, make_translator, sale_mode_base
from .common import (
Translator,
format_human_units,
make_translator,
sale_mode_base,
sale_mode_tariff_key,
)
_TRAFFIC_MODES = {"traffic", "traffic_package", "topup", "premium_topup"}
_HWID_DEVICE_MODES = {"hwid_device", "hwid_devices", "hwid_devices_renewal"}
def is_traffic_sale_base(sale_base: str) -> bool:
@@ -70,7 +78,6 @@ class SuccessMessage:
months: Any
base_end_date: Optional[datetime]
final_end_date: Optional[datetime]
config_link_text: str
applied_referee_bonus_days: int = 0
applied_promo_bonus_days: int = 0
inviter_name: Optional[str] = None
@@ -97,7 +104,11 @@ def build_success_message(payload: SuccessMessage) -> str:
"payment_successful_traffic_full",
traffic_gb=format_human_units(payload.months),
end_date=end_text,
config_link=payload.config_link_text,
)
if base in _HWID_DEVICE_MODES:
return _(
"payment_successful_hwid_devices_full",
count=format_human_units(payload.months),
)
if payload.applied_referee_bonus_days and payload.final_end_date:
base_end_text = _fmt_date(payload.base_end_date or payload.final_end_date, end_text)
@@ -108,7 +119,6 @@ def build_success_message(payload: SuccessMessage) -> str:
bonus_days=payload.applied_referee_bonus_days,
final_end_date=end_text,
inviter_name=payload.inviter_name or _("friend_placeholder"),
config_link=payload.config_link_text,
)
if payload.applied_promo_bonus_days and payload.final_end_date:
return _(
@@ -116,16 +126,58 @@ def build_success_message(payload: SuccessMessage) -> str:
months=payload.months,
bonus_days=payload.applied_promo_bonus_days,
end_date=end_text,
config_link=payload.config_link_text,
)
return _(
"payment_successful_full",
months=payload.months,
end_date=end_text,
config_link=payload.config_link_text,
)
def append_hwid_renewal_note(
text: str,
translator: Translator,
*,
count: Any,
valid_until: Optional[datetime],
) -> str:
try:
count_int = int(count or 0)
except (TypeError, ValueError):
count_int = 0
if count_int <= 0:
return text
date_text = valid_until.strftime("%Y-%m-%d") if valid_until else ""
note = translator(
"payment_successful_hwid_devices_renewal_note",
count=format_human_units(count_int),
date=date_text,
)
return f"{text}\n\n{note}"
def append_hwid_renewed_note(
text: str,
translator: Translator,
*,
count: Any,
valid_until: Optional[datetime],
) -> str:
try:
count_int = int(count or 0)
except (TypeError, ValueError):
count_int = 0
if count_int <= 0:
return text
date_text = valid_until.strftime("%Y-%m-%d") if valid_until else ""
note = translator(
"payment_successful_hwid_devices_renewed_note",
count=format_human_units(count_int),
date=date_text,
)
return f"{text}\n\n{note}"
async def send_success_message_to_user(
*,
bot: Bot,
@@ -136,6 +188,7 @@ async def send_success_message_to_user(
settings: Any,
config_link_display: Optional[str],
connect_button_url: Optional[str],
install_share_url: Optional[str] = None,
include_keyboard: bool = True,
log_prefix: str = "payment_providers",
) -> None:
@@ -148,6 +201,7 @@ async def send_success_message_to_user(
settings,
config_link_display,
connect_button_url=connect_button_url,
install_share_url=install_share_url,
preserve_message=True,
)
try:
@@ -177,6 +231,7 @@ async def notify_admins_payment_received(
traffic_is_premium: bool,
tariff_key: Optional[str],
log_prefix: str = "payment_providers",
email: Optional[str] = None,
) -> None:
"""Push the standard ``notify_payment_received`` to the admin log channel."""
try:
@@ -189,6 +244,7 @@ async def notify_admins_payment_received(
traffic_gb=traffic_gb_for_admin,
payment_provider=payment_provider,
username=username,
email=email,
traffic_is_premium=traffic_is_premium,
tariff_key=tariff_key,
)
@@ -276,6 +332,7 @@ async def finalize_successful_payment(
activation_months or 1,
current_payment_db_id=req.payment.payment_id,
skip_if_active_before_payment=False,
tariff_key=sale_mode_tariff_key(req.sale_mode),
)
await req.session.commit()
except Exception:
@@ -285,8 +342,37 @@ async def finalize_successful_payment(
req.log_prefix,
req.payment.payment_id,
)
try:
await payment_dal.update_payment_status_by_db_id(
req.session,
req.payment.payment_id,
"activation_failed",
)
await req.session.commit()
except Exception:
await req.session.rollback()
logging.exception(
"%s: failed to mark payment %s activation_failed.",
req.log_prefix,
req.payment.payment_id,
)
return None
try:
from bot.app.web.webapp.cache_helpers import invalidate_webapp_user_caches
await invalidate_webapp_user_caches(
req.settings,
req.user_id,
include_devices=True,
)
except Exception:
logging.exception(
"%s: failed to invalidate webapp caches for user %s.",
req.log_prefix,
req.user_id,
)
db_user, language = await resolve_user_language(
req.session,
user_id=req.user_id,
@@ -299,7 +385,6 @@ async def finalize_successful_payment(
config_link_display, connect_button_url = await prepare_config_links(
req.settings, raw_config_link
)
config_link_text = config_link_display or translator("config_link_not_available")
base_end_date = activation.get("end_date") if activation else None
final_end_date = base_end_date
@@ -323,15 +408,49 @@ async def finalize_successful_payment(
),
base_end_date=base_end_date,
final_end_date=final_end_date,
config_link_text=config_link_text,
applied_referee_bonus_days=applied_referee_bonus_days,
applied_promo_bonus_days=applied_promo_bonus_days,
inviter_name=inviter_name,
)
)
if is_subscription and activation:
if activation.get("hwid_devices_renewed_count"):
success_text = append_hwid_renewed_note(
success_text,
translator,
count=activation.get("hwid_devices_renewed_count"),
valid_until=final_end_date or activation.get("hwid_devices_renewed_until"),
)
else:
success_text = append_hwid_renewal_note(
success_text,
translator,
count=activation.get("hwid_devices_renewal_recommended_count"),
valid_until=activation.get("hwid_devices_valid_until"),
)
if req.text_prefix:
success_text = f"{req.text_prefix}\n{success_text}"
install_share_url = None
if not req.skip_keyboard:
install_links = await ensure_user_install_guide_links(
req.session,
req.settings,
req.user_id,
)
install_share_url = install_links.public_share_url
if install_share_url:
try:
await req.session.commit()
except Exception:
await req.session.rollback()
logging.exception(
"%s: failed to persist install guide share token for user %s.",
req.log_prefix,
req.user_id,
)
install_share_url = None
await send_success_message_to_user(
bot=req.bot,
user_id=req.user_id,
@@ -341,6 +460,7 @@ async def finalize_successful_payment(
settings=req.settings,
config_link_display=config_link_display,
connect_button_url=connect_button_url,
install_share_url=install_share_url,
include_keyboard=not req.skip_keyboard,
log_prefix=req.log_prefix,
)
@@ -359,6 +479,7 @@ async def finalize_successful_payment(
traffic_gb_for_admin=traffic_gb_for_activation,
payment_provider=req.provider_notification,
username=db_user.username if db_user else None,
email=getattr(db_user, "email", None) if db_user else None,
traffic_is_premium=base == "premium_topup",
tariff_key=tariff_key,
log_prefix=req.log_prefix,
@@ -39,13 +39,16 @@ async def finalize_webapp_link_payment(
log_prefix="Wata",
)
"""
if api_success and provider_payment_id:
# Reuse logic needs both a provider id and a redirect URL; persisting only
# the id creates orphan records that match find_recent but fail verification.
if api_success and provider_payment_id and payment_url:
try:
await payment_dal.update_provider_payment_and_status(
session,
payment.payment_id,
str(provider_payment_id),
new_status or payment.status,
provider_payment_url=payment_url,
)
await session.commit()
except Exception:

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