Compare commits

...
539 Commits
Author SHA1 Message Date
3252a8 f30e729f1f Merge branch 'dev' into 'main'
Install guides, payment fixes, and runtime translations

See merge request 3252a8/remnawave-minshop!1
2026-05-26 10:39:29 +00:00
3252a8 1e63431ae3 chore: merge main into dev 2026-05-26 12:51:00 +03:00
3252a8 072e7273c4 docs: standardize MIT license file 2026-05-26 12:48:58 +03:00
3252a8 df079138ee docs: standardize MIT license file 2026-05-26 12:44:27 +03:00
3252a8 87f32114d7 docs: update license file attribution 2026-05-26 12:29:58 +03:00
3252a8 b330e604f6 chore: use Docker Hub images in compose files 2026-05-26 11:15:10 +03:00
3252a8 827d69231c feat: add Docker Hub image script 2026-05-26 08:58:09 +03:00
3252a8 0623850c3f fix: open Telegram Stars invoices inside Mini App 2026-05-25 22:13:11 +03:00
3252a8 4c77d129e7 fix: update support page content without page refresh 2026-05-25 22:03:30 +03:00
3252a8 c68cb97964 fix: bind HWID top-ups to subscription periods 2026-05-25 19:33:50 +03:00
3252a8 59fa301344 feat: add runtime locale overrides 2026-05-25 17:47:45 +03:00
3252a8 23784d00fe chore: update custom themes styles 2026-05-25 13:52:25 +03:00
3252a8 17cff74b7c chore: tune sub activate modal view 2026-05-25 13:11:48 +03:00
3252a8 2a3a3c21ba chore: adjust webapp payment layout 2026-05-25 12:42:17 +03:00
3252a8 ba7811621e fix: preserve premium topup squad access 2026-05-25 12:18:58 +03:00
3252a8 340afe1b80 fix: stop premium squad sync churn 2026-05-25 11:54:03 +03:00
3252a8 11b7823188 fix: expire Wata payment links sooner 2026-05-25 11:34:10 +03:00
3252a8 de1763cfe1 feat: enable payment provider for admin only 2026-05-25 11:19:54 +03:00
3252a8 d30b069876 fix: reuse pending Wata payment links on retry 2026-05-25 10:49:57 +03:00
3252a8 0d68da9624 fix: handle Wata payment links 2026-05-25 09:17:39 +03:00
3252a8 4d857b386b fix: remove inline startup panel sync 2026-05-25 00:40:24 +03:00
3252a8 be7a3bc153 fix: queue bot panel sync requests 2026-05-25 00:36:15 +03:00
3252a8 5c71fc0de2 fix: hide links from payment success message 2026-05-25 00:23:48 +03:00
3252a8 82cc33587c fix: stop panel description churn 2026-05-25 00:06:29 +03:00
3252a8 121f3c6ddf feat: add compact panel sync update diagnostics 2026-05-24 23:44:29 +03:00
3252a8 3410eeaec1 fix(admin): delete Remnawave user with bot account 2026-05-24 23:35:19 +03:00
3252a8 85ecd644b8 fix: refresh webapp profile after activation 2026-05-24 23:25:21 +03:00
3252a8 7cffb4667f fix: expose iOS home screen icons for web app 2026-05-24 23:13:15 +03:00
3252a8 0449ded505 feat: render webapp preview metadata 2026-05-24 22:54:01 +03:00
3252a8 e15cbffdb1 feat: expose webapp title in admin settings 2026-05-24 22:53:50 +03:00
3252a8 9674c674a2 feat: resume activation handoff after payments 2026-05-24 22:45:54 +03:00
3252a8 bafc5f4709 feat: show linked emails in telegram logs 2026-05-24 22:32:35 +03:00
3252a8 a60de46173 feat: guide users after subscription activation 2026-05-24 22:24:02 +03:00
3252a8 1165dc3fa6 chore: verify formatting checks 2026-05-24 22:04:12 +03:00
3252a8 f5ab18a67b feat: redesign webapp trial offer 2026-05-24 22:02:22 +03:00
3252a8 65db4aaff6 fix: clean legacy emails from panel descriptions 2026-05-24 21:45:38 +03:00
3252a8 d9a23235e5 fix: stop syncing email in panel descriptions 2026-05-24 21:36:00 +03:00
3252a8 00b54e1f15 feat: improve admin trial settings controls 2026-05-24 19:19:39 +03:00
3252a8 3c4ff66150 feat: streamline admin tariff settings 2026-05-24 19:10:36 +03:00
3252a8 2b3078fcce chore: fix formatting checks 2026-05-24 18:51:37 +03:00
3252a8 6f4074e8d6 feat: surface trial activation in mini app 2026-05-24 18:48:01 +03:00
3252a8 235ee25d9f fix: refresh YooKassa webapp payments 2026-05-24 18:47:37 +03:00
3252a8 d1b9990bac feat: manage trial settings on tariffs page 2026-05-24 18:46:23 +03:00
3252a8 60d1ba4efc fix: handle Wata prepayment webhooks 2026-05-24 14:04:21 +03:00
3252a8 29b6e6eb24 fix: keep payment user cards in payments 2026-05-24 09:32:47 +03:00
3252a8 aa65972483 feat: add admin payment detail view 2026-05-24 09:30:58 +03:00
3252a8 ad275a7c83 feat: polish admin settings layout 2026-05-24 09:11:43 +03:00
3252a8 02b57ead00 feat: separate legacy tariff settings 2026-05-24 09:11:34 +03:00
3252a8 7f484fa7b3 feat: warn about legacy tariff settings 2026-05-24 00:05:16 +03:00
3252a8 3ea906a74d feat: group Platega admin settings 2026-05-24 00:01:58 +03:00
3252a8 939c40ccbf feat: collapse admin settings by default 2026-05-24 00:01:18 +03:00
3252a8 f0eb291f56 feat: tune visual of install instructions page 2026-05-23 23:55:06 +03:00
3252a8 46391b10e2 feat: tune deeplink fallback page 2026-05-23 23:26:10 +03:00
3252a8 5918a6cc71 fix: open install guide deeplinks via external app gateway 2026-05-23 23:07:42 +03:00
3252a8 0eeabc7b3a fix: process yookassa hwid device topups 2026-05-23 22:28:05 +03:00
3252a8 31eb5c06ad fix: repair linked panel email during sync 2026-05-23 22:27:51 +03:00
3252a8 60d8c297f9 docs: update deploy examples 2026-05-23 22:01:40 +03:00
3252a8 dab70d5a97 docs: update install guides documentation 2026-05-23 16:22:57 +03:00
3252a8 520a6289a8 Merge branch 'feature/install-page' into dev 2026-05-23 16:16:46 +03:00
3252a8 7adca53116 feat: enable bot install guides by default 2026-05-23 16:05:41 +03:00
3252a8 fbc3e6c084 chore: fix frontend formatting checks 2026-05-23 16:01:07 +03:00
3252a8 19ba5c8f11 feat: support install guide app deeplinks 2026-05-23 09:52:26 +03:00
3252a8 ce0d4dccf7 feat: open bot install guides in mini app 2026-05-23 09:48:30 +03:00
3252a8 7c874cd4aa feat: support install guides in custom themes 2026-05-22 23:39:17 +03:00
3252a8 17224b4f74 feat: harden public install guide loading 2026-05-22 23:29:58 +03:00
3252a8 40414264be feat: polish install guide loading state 2026-05-22 23:24:59 +03:00
3252a8 d9a4a007e2 feat: add install guide share tokens and animations 2026-05-22 23:20:25 +03:00
3252a8 d3925cad22 Merge branch 'dev' into feature/install-page
# Conflicts:
#	backend/bot/app/web/admin_api_impl/settings.py
#	backend/bot/app/web/webapp/cache_helpers.py
#	frontend/src/admin/sections/SettingsSection.svelte
#	tests/test_admin_settings_manifest_i18n.py
2026-05-22 22:57:24 +03:00
3252a8 443e2e62db fix: auto-merge duplicate panel identities 2026-05-22 22:39:45 +03:00
3252a8andGitHub 4b7c58a6c9 Merge pull request #1 from 3252a8/dev
Stabilize account linking, auth, and admin settings
2026-05-22 22:24:07 +03:00
3252a8 19aa2ec9c9 fix: log account merge notifications 2026-05-22 22:02:28 +03:00
3252a8 c4c5b8e3a0 fix: merge active sub email account with expired sub telegram account 2026-05-22 18:23:38 +03:00
3252a8 33a7dbc0e6 fix: app version display pattern in admin panel sidebar 2026-05-22 18:23:11 +03:00
3252a8 72921b9a8f fix: telegram oauth in logged in email account 2026-05-22 17:48:17 +03:00
3252a8 835436fa1a fix: ensure web app pay button is spawning when enable payment provider 2026-05-22 16:29:59 +03:00
3252a8 648f4ba4bc feat: show payment provider webhook urls 2026-05-22 15:57:49 +03:00
3252a8 f5023dc46b fix: repair missing panel user references 2026-05-22 15:39:51 +03:00
3252a8 4c0a798050 fix: load admin assets from stable paths 2026-05-22 15:39:45 +03:00
3252a8 cfe7c4ec5f fix: style mobile admin button 2026-05-22 15:11:28 +03:00
3252a8 c82779e15b fix: restore telegram mini app auth 2026-05-22 14:47:01 +03:00
3252a8 9e56715e77 fix: cover email telegram account linking 2026-05-22 14:41:56 +03:00
3252a8 2254b9ad19 fix: avoid repeated panel identity syncs 2026-05-22 14:11:38 +03:00
3252a8 81707d9c7c fix: clean merged panel identities 2026-05-22 14:11:31 +03:00
3252a8 9e61a3d8a8 feat: install instruction inside web app 2026-05-22 13:58:34 +03:00
3252a8 8a38524774 docs: add url to remnawave 2026-05-21 23:09:22 +03:00
3252a8 87d3f1b410 docs: add nginx example 2026-05-21 22:40:53 +03:00
3252a8 1094a65852 Merge branch 'dev' 2026-05-21 14:24:44 +03:00
3252a8 0ab4b11f97 fix: windows 95 theme support tickets list 2026-05-21 14:05:54 +03:00
3252a8 6d4fb5888f refactor: apply custom theme without cache reload 2026-05-21 13:56:05 +03:00
3252a8 e7301e2c48 refactor: optimize support polling 2026-05-21 13:41:01 +03:00
3252a8 f69f6546f0 docs: add details to env.example 2026-05-21 11:39:07 +03:00
3252a8 7fe53993aa docs: update docs and minimize env.example 2026-05-21 11:26:59 +03:00
3252a8 028f0680c6 feat: allow to edit remnawave panel settings in admin panel, ensure using i18n 2026-05-21 11:26:03 +03:00
3252a8 b3f67a5398 fix: admin panel loading 2026-05-21 10:43:55 +03:00
3252a8 db3611487e perf: optimize webapp asset delivery, split admin webapp bundle 2026-05-21 10:17:23 +03:00
3252a8 f2c722bdfc perf: reduce webapp and admin cache stampedes 2026-05-21 09:44:50 +03:00
3252a8 ccb125ab9d fix: retry Telegram startup setup until reachable 2026-05-21 08:28:27 +03:00
3252a8 a300a4a9c0 feat: add new sections to custom themes 2026-05-21 08:22:40 +03:00
3252a8 044cb4de7e refactor(redis): strengthen shared cache invalidation 2026-05-21 06:26:48 +03:00
3252a8 3405d12696 refactor: improve admin stats cache usage and etc 2026-05-20 23:35:45 +03:00
3252a8 11cd35373e fix: remove unnecessary caption in premium squad servers list in web app 2026-05-20 23:25:18 +03:00
3252a8 5001185bf8 refactor: improve panel sync performance 2026-05-20 23:18:03 +03:00
3252a8 a7f298743d refactor: improve startup and sync performance 2026-05-20 23:04:29 +03:00
3252a8 8192eaf55b refactor: improve premium squads feature performance, add benchmarks 2026-05-20 22:31:37 +03:00
3252a8 5a0e0033ec chore: rename tg support button contact 2026-05-20 22:05:33 +03:00
3252a8 7521f89ffd feat: show optional serivce description before payment 2026-05-20 21:55:22 +03:00
3252a8 d8a1da1f13 feat: add support tickets and imrpove web app loading 2026-05-20 16:38:24 +03:00
3252a8 3b846f0d44 fix: tune visual of setup password button 2026-05-19 15:38:04 +03:00
3252a8 f5006af6c0 feat: email and password login 2026-05-19 15:21:02 +03:00
3252a8 3152631911 fix: preserve Telegram usernames with underscores 2026-05-19 00:07:32 +03:00
3252a8 1d9f069f45 feat: add admin telegram profile links and avatar preview 2026-05-18 23:46:46 +03:00
3252a8 707f569f62 fix: heleket signature verify 2026-05-18 22:56:56 +03:00
3252a8 e3643ee9a0 fix: handle Caddy-proxied payment webhooks and method selection 2026-05-18 22:45:42 +03:00
3252a8 49cf5ebad8 fix: auto-append new providers to PAYMENT_METHODS_ORDER 2026-05-18 22:03:40 +03:00
3252a8 b79f5f4d7d fix: make provider services reactive to runtime config changes 2026-05-18 21:53:53 +03:00
3252a8 d870915dc0 fix: apply persisted provider overrides on startup 2026-05-18 21:40:45 +03:00
3252a8 fcd494f815 fix: restore settings binding in platega webapp payment creator 2026-05-18 21:23:57 +03:00
3252a8 a7728f80d0 fix: resolve yookassa webhook path via provider spec in startup log 2026-05-18 21:13:30 +03:00
3252a8 9eb6387973 feat: expose provider button defaults in admin settings manifest 2026-05-18 20:59:26 +03:00
3252a8 7be5510208 refactor: move yookassa env-config into module 2026-05-18 20:46:54 +03:00
3252a8 e0269bbf86 refactor: move platega env-config into module 2026-05-18 20:37:15 +03:00
3252a8 e76558d68b refactor: move stars presentation into module 2026-05-18 20:30:30 +03:00
3252a8 cceba2e98e refactor: move cryptopay env-config into module 2026-05-18 16:32:19 +03:00
3252a8 1e93d25a40 refactor: move freekassa env-config into module 2026-05-18 16:22:49 +03:00
3252a8 74ef9d31b4 refactor: move wata env-config into module 2026-05-18 16:18:04 +03:00
3252a8 15b3f9c084 refactor: move severpay env-config into module 2026-05-18 16:13:58 +03:00
3252a8 87efa6c77b refactor: provider env-config lives in modules (heleket poc) 2026-05-18 16:03:01 +03:00
3252a8 07b35036e7 refactor: keep provider presentation self-contained in spec 2026-05-18 15:35:55 +03:00
3252a8 c9c12b9ed6 feat: add heleket payment provider 2026-05-18 15:26:07 +03:00
3252a8 960754c54e fix: fix menus and modal views after payment providers refactor 2026-05-18 11:01:32 +03:00
3252a8 51c9c8b4f0 refactor: payments providers 2026-05-18 10:29:00 +03:00
3252a8 fff7e90e14 feat: add wata payment provider 2026-05-17 23:09:04 +03:00
3252a8 11429e887e Merge branch 'dev' 2026-05-17 22:23:14 +03:00
3252a8 820548cb2d fix: include git tag context in startup banners 2026-05-17 22:20:45 +03:00
3252a8 e2038085ca fix: show commit SHA in startup banners 2026-05-17 22:11:17 +03:00
3252a8 3676bf1ae7 Merge branch 'feature/arch-refactoring' into dev 2026-05-17 21:41:55 +03:00
3252a8 57f766dda2 docs: add themes screenshot 2026-05-17 21:41:36 +03:00
3252a8 da8f2d08df docs: caddy update 2026-05-17 21:36:32 +03:00
3252a8 9579f019d4 chore: lock migration doc and script in sync with current compose layout 2026-05-17 18:08:08 +03:00
3252a8 7c27337e0a chore: extend KNOWN_CONTAINERS to cover split-arch containers 2026-05-17 18:08:08 +03:00
3252a8 2dffce4244 docs: update migration guide for v2.7.0 to v3.4+ split-architecture upgrade 2026-05-17 18:08:08 +03:00
3252a8 27a9f0aff8 chore: cover env, baked file and live git paths of _resolve_app_version 2026-05-17 17:38:35 +03:00
3252a8 67920040ea fix: bake app version into image via throwaway version-builder stage 2026-05-17 17:38:30 +03:00
3252a8 fd1b910236 chore: cover can_topup_devices flag derivation 2026-05-17 17:26:49 +03:00
3252a8 09ba53d185 fix: stop rendering duplicate period grid and pay button in legacy mode 2026-05-17 17:26:42 +03:00
3252a8 91e79388d1 fix: hide device topup button when no HWID packages or unlimited devices 2026-05-17 17:26:37 +03:00
3252a8 cb5d59571e chore: cover referral bonus skip-paths, inviter and referee award flows 2026-05-17 10:23:01 +03:00
3252a8 365c6c7858 chore: cover HWID device topup gating, audit and panel failure paths 2026-05-17 10:22:57 +03:00
3252a8 6886a90ee4 chore: cover topup, premium-topup and tariff-switch panel failure paths 2026-05-17 10:22:53 +03:00
3252a8 7d1c9ee373 fix: surface panel update failures in topup, premium-topup and tariff switch 2026-05-17 10:22:48 +03:00
3252a8 dc05595b9b chore: attach orphaned type:ignore comment to its statement 2026-05-17 10:09:41 +03:00
3252a8 d2581b1c52 chore: pin yookassa and subscription wiring in build_core_services 2026-05-17 10:03:09 +03:00
3252a8 eb37ed4c74 refactor: stop swallowing critical service wiring failures 2026-05-17 10:03:05 +03:00
3252a8 6800734136 chore: cover auto-renew wiring contract end-to-end 2026-05-17 09:58:45 +03:00
3252a8 d4b7da3a54 fix: read yookassa_service directly in auto-renew instead of broken local import 2026-05-17 09:58:39 +03:00
3252a8 8382fa9232 chore: cover provider label regression in payment success email path 2026-05-17 09:46:52 +03:00
3252a8 071b9f2b25 fix: add missing _PROVIDER_LABELS to PaymentContextMixin 2026-05-17 09:46:48 +03:00
3252a8 1e8ffa15d0 chore: add tests for redis infra, webhook queue and split-arch entrypoints 2026-05-17 00:31:12 +03:00
3252a8 fc7f97c136 refactor: drop unused legacy run_bot flags and dead backend/main.py 2026-05-17 00:31:08 +03:00
3252a8 99732211b7 docs: correct backend path prefix for admin manifest references 2026-05-17 00:30:59 +03:00
3252a8 aaa8e957f6 fix: serve hashed minified webapp bundle from frontend image 2026-05-17 00:30:54 +03:00
3252a8 30fb774d93 refactor: project architecture refactor, container splitting 2026-05-17 00:01:28 +03:00
3252a8 e0b5218037 chore: print remnawave-minishop ascii banner on startup 2026-05-16 00:16:21 +03:00
3252a8 387bdb6abc fix: stop logging full panel response bodies by default to avoid secret leakage 2026-05-16 00:09:37 +03:00
3252a8 3298dc3e77 test: align CSP expectation with current img-src that allows blob 2026-05-16 00:04:42 +03:00
3252a8 55dce99d34 refactor: ack panel webhooks immediately and run event handling in bounded background task 2026-05-16 00:02:22 +03:00
3252a8 87f9e23bae refactor: cache panel squad and host lookups in-memory with TTL 2026-05-16 00:01:40 +03:00
3252a8 1e97dd9fe5 refactor: add per-chat throttle in message queue to avoid 429 on rapid same-chat sends 2026-05-15 23:59:49 +03:00
3252a8 a05c54fc66 refactor: tighten panel API timeouts and retry safe requests once on transient errors 2026-05-15 23:59:05 +03:00
3252a8 aeb51b9ccc refactor: add indexes on payments(status, created_at) and message_logs(timestamp) 2026-05-15 23:57:39 +03:00
3252a8 b72a23f1e1 refactor: parallelize tariff worker panel calls and unblock startup sync 2026-05-15 23:18:24 +03:00
3252a8 4d96a3e646 Merge branch 'feature/custom-themes' into dev 2026-05-15 23:10:27 +03:00
3252a8 fc8e573243 feat: tune light theme, reduce accent color usage overall 2026-05-15 23:01:23 +03:00
3252a8 f80e336e1f feat: tune visual of windows 95 theme 2026-05-15 21:46:10 +03:00
3252a8 2a1ff8e45e feat: separate webapp favicon configuration in appearance admin panel section 2026-05-15 19:27:33 +03:00
3252a8 e9ad259957 docs: update web app theme specific docs 2026-05-15 15:46:08 +03:00
3252a8 86ddceb646 feat: custom themes polishing 2026-05-15 14:38:39 +03:00
3252a8 a1725a6872 feat: custom themes logo size tuning 2026-05-15 13:54:47 +03:00
3252a8 af7cee5014 feat: custom themes polishing 2026-05-15 11:17:27 +03:00
3252a8 b6ee5e8790 feat: custom themes initial 2026-05-14 16:33:50 +03:00
3252a8 46520fb37c Merge branch 'dev' 2026-05-13 23:07:39 +03:00
3252a8 18ba00c98a feat: show user logs in admin panel user card 2026-05-13 22:51:04 +03:00
3252a8 e065c85a3e docs: update docs 2026-05-13 22:34:24 +03:00
3252a8 7401649841 refactor: split backend domains and add API behavior coverage 2026-05-13 18:38:10 +03:00
3252a8 a96007d48a fix: avoid redundant premium squad restore updates 2026-05-13 15:11:56 +03:00
3252a8 2169fd8b50 chore: harden webapp auth and update deps 2026-05-13 15:05:30 +03:00
3252a8 2b7d1f0be8 chore: fix lint warnings 2026-05-13 14:13:39 +03:00
3252a8 eec5635541 refactor: brand logo customization and emoji font selection 2026-05-13 14:02:35 +03:00
3252a8 e21a0758c3 fix: user card open in payments list for email only users 2026-05-12 23:53:35 +03:00
3252a8 defbac43d7 feat: tune admin panel visual, add animations, expand dashboard 2026-05-12 23:40:41 +03:00
3252a8 94612e11f2 fix: fix unlimited premium squads applying via admin panel 2026-05-12 22:01:11 +03:00
3252a8 11187487b4 chore: run lint and prettifier 2026-05-12 21:54:12 +03:00
3252a8 f31540afdb fix: dedupe traffic billing warnings when panel limit drifts 2026-05-12 21:32:47 +03:00
3252a8 6afbb72a34 feat: lock traffic topup when under 80% of traffic used 2026-05-12 21:27:37 +03:00
3252a8 af66103111 docs: update docs 2026-05-12 16:34:23 +03:00
3252a8 92277b2e27 feat: show and filter premium squad status in admin panel users list 2026-05-12 16:14:11 +03:00
3252a8 475a1953d1 feat: tune visual of web app admin panel, tune limit messages and etc 2026-05-12 15:42:17 +03:00
3252a8 8af59750c6 fix(tariff-worker): match premium bandwidth stats by username, not uuid 2026-05-12 11:20:23 +03:00
3252a8 00a0792c35 fix: reconcile legacy database schema for admin user detail API 2026-05-12 11:07:44 +03:00
3252a8 b9c0dfb9a1 fix: locales in admin panel settings page 2026-05-12 10:31:21 +03:00
3252a8 9679190709 feat: ability to turn off logs for admins users 2026-05-12 10:16:59 +03:00
3252a8 c8d4b3565c feat: tune visual of web app admin panel 2026-05-12 09:59:15 +03:00
3252a8 fe976022ec refactor: migrate ui elements to lib 2026-05-12 09:05:22 +03:00
3252a8 6a367c1dec refactor: consolidate web app UI foundation 2026-05-11 23:25:34 +03:00
3252a8 9f338c3416 feat: tune visual of web app admin panel 2026-05-11 23:06:16 +03:00
3252a8 a575cb057b refactor: slice web app wip 2026-05-11 22:32:23 +03:00
3252a8 116b0a5c08 fix: tune tariffs folder 2026-05-11 22:12:19 +03:00
3252a8 ed6b214b29 refactor: slice web app wip 2026-05-11 21:41:31 +03:00
3252a8 7d8bbbc904 refactor: slice web app wip 2026-05-11 08:31:07 +03:00
3252a8 763c4ad1bb refactor: slice web app wip 2026-05-11 07:57:34 +03:00
3252a8 5243cc4284 docs: update docs 2026-05-11 06:34:45 +03:00
3252a8 5a015fe8b4 Merge branch 'feature/new-tariffs' into dev 2026-05-11 06:10:36 +03:00
3252a8 b759ddb0f5 feat: tune premium squads visual in web app 2026-05-11 00:15:29 +03:00
3252a8 57821ba2a9 feat: tune visual of web app admin panel 2026-05-10 23:36:05 +03:00
3252a8 410cb89f3e fix: do not change squad for referrer 2026-05-10 21:57:36 +03:00
3252a8 e01f56bc34 feat: tune visual of web app admin panel 2026-05-10 20:19:43 +03:00
3252a8 ff59eda3d7 feat: web app setting reload without container reloading 2026-05-10 18:07:20 +03:00
3252a8 b7e497e241 Merge branch 'dev' into feature/new-tariffs 2026-05-10 13:18:39 +03:00
3252a8 7a81a2f20b fix: telegram oauth 2026-05-10 13:17:21 +03:00
3252a8 990eba1e1e feat: premium squads inside one tariff 2026-05-10 12:51:53 +03:00
3252a8 6f9a87cf98 feat: admin dashboard 2026-05-09 22:32:44 +03:00
3252a8 0a9d929181 docs: update docs 2026-05-08 18:14:30 +03:00
3252a8 7dc4806be7 Merge branch 'dev' into feature/new-tariffs 2026-05-07 22:23:04 +03:00
3252a8 8783129c16 fix: webapp accesability when tg is unreachable 2026-05-06 21:39:11 +03:00
3252a8 50d78c8746 feat: hide tariff options and etc when only one exist 2026-05-04 21:50:44 +03:00
3252a8 63784bc036 fix: use limited tag for mark exhausted traffic users 2026-05-03 22:46:12 +03:00
3252a8 aa5a9496ed feat: hwid limits and topups 2026-05-03 22:36:33 +03:00
3252a8 00f8e5fb20 Merge branch 'dev' into feature/new-tariffs
# Conflicts:
#	bot/services/stars_service.py
2026-05-03 22:19:04 +03:00
3252a8 ca8df10eab fix: telegram stars payment in web app 2026-05-03 18:10:20 +03:00
3252a8 b461838c0d Merge branch 'dev' into feature/new-tariffs 2026-05-02 13:21:11 +03:00
3252a8 50857d5283 fix: lang select dropdown wasn't work on android 2026-05-02 13:20:42 +03:00
3252a8 449bc0c780 Merge branch 'dev' into feature/new-tariffs 2026-05-01 22:51:36 +03:00
3252a8 e1223f8c57 fix: connect button disappeard in web app 2026-05-01 22:48:27 +03:00
3252a8 7822ec1b09 Merge branch 'dev' into feature/new-tariffs 2026-05-01 22:37:58 +03:00
3252a8 81f5168726 chore: improve telegram bot menu navigation and referrals 2026-05-01 22:16:00 +03:00
3252a8 77f2420ae3 Merge branch 'dev' into feature/new-tariffs 2026-04-30 22:58:12 +03:00
3252a8 4de730bf5a feat: add Telegram OAuth login flow 2026-04-30 19:50:17 +03:00
3252a8 77da0d97c0 chore: tariffs 2026-04-30 18:57:10 +03:00
3252a8 e8b395cf2f Merge branch 'dev' into feature/new-tariffs 2026-04-30 14:05:19 +03:00
3252a8 d15b1be3b9 fix: click on another button when switching lang 2026-04-30 14:05:01 +03:00
3252a8 8a6b2704d4 feat: new tariffs web app and bot interactions 2026-04-30 13:48:32 +03:00
3252a8 ce610b4804 Merge branch 'dev' into feature/new-tariffs
# Conflicts:
#	.gitignore
#	bot/app/web/subscription_webapp.py
#	tests/test_settings.py
2026-04-30 10:52:20 +03:00
3252a8 4d0241a0ea fix: remove logo bg in email templates 2026-04-30 10:29:01 +03:00
3252a8 2c1e86863d feat: support hwid in web app 2026-04-30 09:25:43 +03:00
3252a8 16b11327ee fix: traffic strategy 2026-04-30 07:57:14 +03:00
3252a8 3d5998b8fb feat: support traffic and trial flow in web app 2026-04-30 07:34:29 +03:00
3252a8 2df4d2d770 feat: tune webapp visual 2026-04-30 00:06:31 +03:00
3252a8 4ce78f277e feat: migrate to svelte initial 2026-04-29 11:10:12 +03:00
3252a8 d7d5c2b1ef refactor: remove local storing tg js 2026-04-28 23:33:24 +03:00
3252a8 4958e1fdfd fix: delegate tariff resets to panel 2026-04-28 15:36:13 +03:00
3252a8 258b2f4dec feat: align tariff resets to calendar month 2026-04-28 15:25:46 +03:00
3252a8 338008bbd0 docs: document tariff catalog 2026-04-28 14:34:25 +03:00
3252a8 829758dc44 feat: update tariff UI and mini app 2026-04-28 14:34:12 +03:00
3252a8 9deb98bbe7 feat: add traffic periods topups and throttling 2026-04-28 14:34:00 +03:00
3252a8 26af92b5ac feat: route purchases through tariff catalog 2026-04-28 14:33:52 +03:00
3252a8 87b2443a06 feat: add tariff config and database schema 2026-04-28 14:33:38 +03:00
3252a8 aabc0e312d feat: log email and tg linking 2026-04-28 13:54:05 +03:00
3252a8 d15d58df36 docs: tariffs documentation 2026-04-28 10:47:59 +03:00
3252a8 21f2ac4534 fix: show red traffic bar in web app when sub is expired 2026-04-28 10:32:56 +03:00
3252a8 dd0e6e75af feat: add magic login link to email with login codes 2026-04-28 09:50:17 +03:00
3252a8 0251f939d7 feat: log users when reg via email 2026-04-27 23:29:28 +03:00
3252a8 a00bc0f345 feat: design for emails 2026-04-27 23:05:14 +03:00
3252a8 ae4ce43e1c chore: tune default web app logo 2026-04-27 22:28:34 +03:00
3252a8 19ed8916f4 fix: migration and docker image name/tag 2026-04-27 21:31:06 +03:00
3252a8 f7de08bfd7 docs: update readme 2026-04-27 21:06:21 +03:00
3252a8 6e42f67931 docs: donation wallet 2026-04-27 21:02:49 +03:00
3252a8 b87b6cd378 fix: webapp session 2026-04-27 20:50:24 +03:00
3252a8 b39fb73c30 docs: project name change, migration script 2026-04-27 16:11:06 +03:00
3252a8 3120884ed5 docs: update readme 2026-04-27 15:15:25 +03:00
3252a8 c927244b1a docs: update readme 2026-04-27 15:13:00 +03:00
3252a8 e049f38c7c fix: readme format 2026-04-27 15:08:29 +03:00
3252a8 ddc6ee0024 feat: build webapp when docker build 2026-04-27 14:59:35 +03:00
3252a8 fa5f1460ab fix: separate platega buttons not showed up in webapp 2026-04-27 14:57:49 +03:00
3252a8 9f556f3e04 fix: add platega debug 2026-04-27 14:48:58 +03:00
3252a8 70b618a94b feat: use web page for cryptopay in web app 2026-04-27 14:38:09 +03:00
3252a8 0610156f80 feat: add multiple platega buttons 2026-04-27 14:34:41 +03:00
3252a8 587b39de1d fix(bot): connect button opens subscription page instead of mini app
Previously the connect button in post-payment messages and in 'My subscription' section opened the mini app (personal cabinet) when SUBSCRIPTION_MINI_APP_URL was set, ignoring the actual subscription URL. Now the real subscription URL is preferred, with the mini app used only as a fallback.
2026-04-27 11:06:32 +03:00
3252a8 e35885b54b docs: update readme 2026-04-27 09:44:28 +03:00
3252a8 c1b7ace876 fix(webapp): merge bundled FALLBACK_I18N with server locales
The bot's locales/*.json files do not contain the webapp-specific keys
(connect, extend_subscription, loading, etc.), so applyI18n was
overwriting the localized HTML defaults with raw key names.

Merge the bundled FALLBACK_I18N table with whatever the server provides
per language, letting server values override but falling back to the
in-bundle translations for keys the bot does not ship.
2026-04-27 09:42:18 +03:00
3252a8 f416b0aed1 fix(webapp): allow oauth.telegram.org iframe in CSP
The Telegram Login Widget embeds oauth.telegram.org in an iframe; without
an explicit frame-src directive it fell back to default-src 'self' and was
blocked. Add frame-src https://oauth.telegram.org so the login flow loads.
2026-04-27 09:34:09 +03:00
3252a8 c8192e4427 fix(webapp): allow inline JSON config and Telegram widget eval under CSP
Add a per-request nonce to inline <script type="application/json"> blocks
(webapp-config, i18n) so they survive script-src 'self' and locales/config
actually load in the browser. Also add 'unsafe-eval' to script-src so the
vendored telegram-widget.js (which uses eval/new Function) can initialise.
2026-04-27 09:31:52 +03:00
3252a8 dae1a6889b feat(ops): add /healthz endpoint and container healthcheck
Expose a lightweight /healthz route on the main aiohttp server and
wire docker-compose healthcheck so orchestrators can detect failures.
2026-04-27 08:43:58 +03:00
3252a8 4740666d63 chore: annotate XTR provider_token and add missing return type 2026-04-27 08:43:54 +03:00
3252a8 d6b703debb refactor(logging): use logger.exception to preserve stack traces
Unify error logging across services: replace logger.error(f"...{e}")
and logger.error(..., exc_info=True) with logger.exception() so the
stack trace is consistently captured.
2026-04-27 08:43:48 +03:00
3252a8 f1113eb80a fix(webhooks): return 5xx on YooKassa processing errors
Replace 200-on-error with 500 so the payment provider retries transient
failures instead of treating them as successfully processed.
2026-04-27 08:43:44 +03:00
3252a8 604f0d9656 security: audit inline Mini App handlers 2026-04-26 20:34:02 +03:00
3252a8 8ce5a92625 chore: harden ignore rules 2026-04-26 20:33:59 +03:00
3252a8 72b6e93c94 docs: add Telegram clock-skew note for auth_date 2026-04-26 20:33:57 +03:00
3252a8 d60952718e security: harden Mini App auth, CSRF, and validation 2026-04-26 20:33:54 +03:00
3252a8 53dcc59770 security: double-check CryptoPay webhook signatures 2026-04-26 20:33:49 +03:00
3252a8 5ec179b6d6 webapp: add hashed minified asset pipeline 2026-04-26 20:04:30 +03:00
3252a8 250df445f0 docker: run app as non-root user 2026-04-26 19:47:56 +03:00
3252a8 12376e5070 db: add composite indexes and merge-user optimizations 2026-04-26 19:47:14 +03:00
3252a8 c2afc6107a webapp: harden mini app rendering and controls 2026-04-26 19:47:06 +03:00
3252a8 77370eb963 security: harden webhooks and session secrets 2026-04-26 19:46:57 +03:00
3252a8 94b0787cad feat: use i18n 2026-04-26 12:34:16 +03:00
3252a8 9c499fe3c2 feat: add caddy docker compose example 2026-04-24 23:16:31 +03:00
3252a8 7e26f9da9b feat: automatic merge two paid subs (email and tg) 2026-04-24 23:07:57 +03:00
3252a8 778615a97f feat: tune web app visual 2026-04-24 22:35:55 +03:00
3252a8 651572f15f refactor: show tg avatar in detached browser 2026-04-24 21:31:58 +03:00
3252a8 5ccb8ddabe refactor: promo and email bruteforce defence 2026-04-24 21:18:29 +03:00
3252a8 86f944e544 feat: tune web app visual 2026-04-24 21:04:53 +03:00
3252a8 bc29f5ebd6 feat: promocode and ref in web app 2026-04-24 13:52:53 +03:00
3252a8 9a84be85c6 feat: move tg bot interaction keyboard to separate command, show only web app and support buttons by default 2026-04-24 12:43:21 +03:00
3252a8 c08ac854b2 fix: get email from panel if exist, tune webapp visual 2026-04-24 11:14:50 +03:00
3252a8 807a8933b9 feat: email login, smtp codes 2026-04-23 21:20:32 +03:00
3252a8 b9cb1fec06 feat: tune webapp visual, use telegram widget for login 2026-04-23 14:06:21 +03:00
3252a8 eab803652b fix: tg bot wont start 2026-04-22 22:44:19 +03:00
3252a8 259d0646bc fix: tune webapp visual and fix some errors 2026-04-22 22:35:10 +03:00
3252a8 4f1b7d0832 feat: web app 2026-04-22 16:00:55 +03:00
3252a8 1aa529ab23 Remove GitHub Actions and stale GHCR defaults 2026-04-22 15:19:54 +03:00
3252a8 a46502380c Remove locales volume from bot 2026-04-03 20:49:14 +03:00
3252a8 85c276bcf5 Setup ghcr 2026-04-03 20:36:34 +03:00
3252a8 55f8db11ce Add setup instructions 2026-02-15 18:58:52 +03:00
3252a8 79af61a48e Add configurable welcome bonus days for referred signups
- add REFERRAL_WELCOME_BONUS_DAYS setting (default 3) in settings.py
- document REFERRAL_WELCOME_BONUS_DAYS in .env.example
- apply welcome bonus on first /start only for newly created users with referred_by_id
- replace hardcoded 3 days with settings.REFERRAL_WELCOME_BONUS_DAYS
- skip bonus flow when value is 0 or less
- send user notification after successful bonus application
- add i18n key referral_welcome_bonus_applied to ru.json and en.json
2026-02-11 13:26:56 +03:00
3252a8 69d2e2a899 Fix promo failed message when using deeplink 2026-02-10 19:53:55 +03:00
3252a8 069ad967b5 Add user ref page deeplink 2026-02-10 19:36:29 +03:00
3252a8 18a6b3e18d Tune users rating 2026-02-09 10:29:40 +03:00
3252a8 3b9043332c Move user links to id's from buttons 2026-02-09 10:22:06 +03:00
3252a8 d94b57bd0b User card links in ratings 2026-02-09 10:09:16 +03:00
3252a8 5247092ca2 Add users rating feature 2026-02-09 10:03:55 +03:00
3252a8 049789c9c5 Tune invite buttons 2026-02-06 12:48:37 +03:00
3252a8 6b74ae4a8e Update locals 2026-01-25 10:20:54 +03:00
3252a8 c13c01ea43 Update traffic limit info in user profile 2026-01-25 10:16:00 +03:00
3252a8 48e7d3569f Fix used traffic display 2026-01-25 10:11:16 +03:00
3252a8 4f3b45cbd8 Merge branch 'main' into fork-new
# Conflicts:
#	docker-compose.yml
2026-01-25 09:57:57 +03:00
kavoreandGitHub a7ec55d741 Merge pull request #152 from VAQYBIN/main
Add step-by-step install into README.md
2026-01-18 23:09:37 +03:00
VAQYBIN 0701af0f35 feat(docs): Добавлена пошаговая инструкция в README.md по установке бота 2026-01-19 01:06:44 +05:00
kavoreandGitHub c0851c5339 Merge pull request #151 from kavore/dev
bugfix
2026-01-17 23:55:25 +03:00
kavore 785b6c2d41 merge 2026-01-17 23:54:58 +03:00
kavore cce3fd4f58 bugfix #4 2026-01-17 23:23:06 +03:00
kavore 0dd6beebb2 remove nalogo and use custom client 2026-01-17 21:58:12 +03:00
kavore 8fb2a76698 bugfix 2026-01-17 21:25:02 +03:00
kavoreandGitHub 3d6713caa9 Merge pull request #150 from kavore/dev
bugfix and nalogo custom label
2026-01-17 21:11:41 +03:00
kavore 8d8fdce519 bugfix 2026-01-17 21:10:46 +03:00
kavore c385a1466c added custom label 2026-01-17 21:06:58 +03:00
kavoreandGitHub 8e1fdb33d2 Merge pull request #149 from kavore/dev
added nalogo
2026-01-17 16:18:38 +03:00
machka paslaandkavore 29222be1fa .env.example and docker-compose upd 2026-01-17 16:01:29 +03:00
machka pasla 08aca3b28d Add LOG_LEVEL env config and fix Nalogo receipt trigger 2026-01-14 16:03:21 +03:00
machka paslaandGitHub 658139d607 Merge pull request #148 from machka-pasla/lknpd
Lknpd
2026-01-14 14:54:31 +03:00
machka paslaandGitHub 1d4abf7977 Merge branch 'dev' into lknpd 2026-01-14 14:54:11 +03:00
3252a8 90ab186a7c Add platega payment request logging 2026-01-07 22:42:00 +03:00
machka pasla 62e8bc08d5 version update 2026-01-06 12:22:30 +03:00
3252a8 dc725ccd68 Update locales 2026-01-05 23:51:33 +03:00
3252a8 d88aee03bb Add user agreement and privacy policy links 2026-01-05 23:42:51 +03:00
machka pasla 475a30caf4 added my nalog management 2026-01-05 19:43:40 +03:00
machka paslaandGitHub c1593d7868 Merge pull request #145 from tdlcompiler/crypt4-fix 2025-12-30 07:00:41 +03:00
Denis Kartsev 0dbcfe3770 removed crypt4 prefix
panel api already returns a link with the prefix
2025-12-30 04:58:34 +03:00
machka pasla e3ffb87cfc updated crypt 2025-12-29 14:51:50 +03:00
machka pasla b4218ec6a7 Идемпотентная обработка вебхуков YooKassa 2025-12-26 13:44:51 +03:00
machka paslaandGitHub 48f8656ef8 Merge pull request #143 from machka-pasla/dev
bug fix in platega
2025-12-21 11:56:12 +03:00
machka pasla cee8491b3d bug fix 2025-12-21 11:55:49 +03:00
machka paslaandGitHub 4bc3f3b4b3 Merge pull request #142 from machka-pasla/dev
added severpay, platega, happ crypt4 support
2025-12-20 17:42:07 +03:00
machka pasla adb1105621 bug fix 2025-12-20 14:40:47 +03:00
machka pasla c8fd79e4ba shorten hwid 2025-12-20 09:41:10 +03:00
machka pasla 52bc9f71cc bug fix 2025-12-11 22:28:51 +03:00
machka pasla a1d3db0e16 added happ crypt4 links 2025-12-11 22:17:43 +03:00
machka pasla d0e34126e1 upd 2025-12-11 16:13:22 +03:00
machka pasla c96ebea077 upd 2025-12-11 13:51:54 +03:00
machka pasla f00a72ad15 yookassa fix 2025-12-11 12:59:39 +03:00
machka pasla 22171eb66f added gb packets selling 2025-12-11 12:47:32 +03:00
machka pasla 061fdeb72b removed tribute 2025-12-11 09:49:31 +03:00
machka pasla 8eba23574b some yookassa changes 2025-12-09 22:32:10 +03:00
machka pasla 7b650a74a6 severpay fix 2025-12-09 20:48:16 +03:00
machka pasla 805bd4cf60 readme upd 2025-12-09 20:21:46 +03:00
machka pasla 56a32d493f refactor 2025-12-09 20:17:46 +03:00
machka pasla b24a685066 added severpay.io 2025-12-09 19:50:14 +03:00
machka pasla fb06fbd0e1 added platega.io 2025-12-07 20:24:24 +03:00
machka pasla 719369057f fix privacy error 2025-12-04 12:13:17 +03:00
machka paslaandGitHub cea9d98155 Update and rename docker-compose-removed-server.yml to docker-compose-remote-server.yml 2025-11-28 13:16:02 +03:00
machka paslaandGitHub 6cf15e0698 Merge pull request #139
added external squad
2025-11-19 11:30:56 +03:00
machka pasla 2971b9ad2e added external squad 2025-11-19 11:30:34 +03:00
machka paslaandGitHub 3e86fc76b6 Merge pull request #135 from machka-pasla/dev
upd
2025-11-13 23:04:43 +03:00
machka paslaandGitHub 829a18715a Merge pull request #134 from machka-pasla/revert-133-revert-132-fix/sync-admin-duplicate-subscriptions
Revert "Revert "Устранены дубли активных записей, ошибка уникальности и улучшена идемпотентность""
2025-11-13 23:04:13 +03:00
machka paslaandGitHub 844c8e12a7 Revert "Revert "Устранены дубли активных записей, ошибка уникальности и улучшена идемпотентность"" 2025-11-13 23:03:56 +03:00
machka paslaandGitHub ef75f905f4 Merge pull request #133 from machka-pasla/revert-132-fix/sync-admin-duplicate-subscriptions
Revert "Устранены дубли активных записей, ошибка уникальности и улучшена идемпотентность"
2025-11-13 23:01:58 +03:00
machka paslaandGitHub cd816cbe5c Revert "Устранены дубли активных записей, ошибка уникальности и улучшена идемпотентность" 2025-11-13 23:01:46 +03:00
machka paslaandGitHub 2b0fa3a314 Merge pull request #132 from orryxvpn/fix/sync-admin-duplicate-subscriptions
Устранены дубли активных записей, ошибка уникальности и улучшена идемпотентность
2025-11-13 23:01:30 +03:00
machka paslaandGitHub ead990e61c Merge pull request #129 from 3252a8/feature/ref-link
Ref link improvement
2025-11-13 23:00:43 +03:00
orryxvpnandGitHub eb6e343e4c Refactor perform_sync function for readability 2025-11-13 21:30:15 +05:00
3252a8 caf5b88eef Use random string in ref link instead of tg ID
- Enable old links with tg id by default in .env with LEGACY_REFS=true
2025-11-11 22:32:30 +03:00
machka paslaandGitHub 183bef070d Merge pull request #124 from machka-pasla/dev
yookassa bug fix and other
2025-11-10 15:49:45 +03:00
machka pasla 643bf5fabb fix bug 2025-11-07 20:47:09 +03:00
machka pasla 7d446d5f64 upd promo bug 2025-11-06 19:03:34 +03:00
machka paslaandGitHub 73ce689d5e Merge pull request #120 from streletskiy/feature/user-profile-links
Feature/user profile links
2025-11-03 17:27:49 +03:00
Bogdan Strielecki 5791f2e58c Add user profile links
User profile buttons quickly opens user's telegram profile

Add user profile link button to log messages
Add user profile link button to user card in admin panel
Add referrer profile link button to log messages
Add referrer profile link button to user card in admin panel
2025-11-03 16:00:50 +03:00
machka pasla 69cc2cbe29 upd 2025-11-02 15:16:27 +03:00
machka pasla 7df2e127a4 fix 2025-10-31 22:41:48 +03:00
machka pasla d503cf7f7f YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING param added 2025-10-31 21:38:41 +03:00
machka paslaandGitHub c0d70030c3 Merge pull request #117 from machka-pasla/dev
Dev
2025-10-30 20:46:31 +03:00
machka paslaandGitHub 4e7c36dbf7 Merge pull request #115 from streletskiy/feature/paged-users-list
Paged list of all users
2025-10-30 17:57:00 +03:00
Bogdan Strielecki 3b0b88e9a0 Add admin button for delete user from bot and panel 2025-10-30 17:55:08 +03:00
Bogdan Strielecki 207e7751cd Add paged list of all users to admin panel 2025-10-28 15:13:19 +03:00
machka paslaandGitHub c65b608f0b Merge pull request #114 from machka-pasla/dev
more payment info in admin, docker compose for other servers, fixed channel sub check
2025-10-28 14:40:59 +03:00
machka pasla cff8368b7c docker hub push 2025-10-28 14:39:47 +03:00
machka paslaandGitHub 2f367bbd6b Merge pull request #112 from streletskiy/feature/display-user-income
Additional payment info in admin panel user card
2025-10-28 14:31:47 +03:00
machka pasla c3d6324ec6 fixed channel sub check 2025-10-27 15:58:59 +03:00
Bogdan Strielecki 4800a2da80 Additional payment info in admin panel user card
- total income
- total income from all user referrals
2025-10-27 13:38:37 +03:00
machka paslaandGitHub 70a0cd44a1 Merge pull request #111 from freize/main
An example Docker file for deploying a bot on a separate server
2025-10-25 20:02:43 +03:00
machka paslaandGitHub 545c9c7d07 Merge pull request #109 from Snowy-Fluffy/custom-payment-id
Добавление возможности выставлять ID способа оплаты для FreeKassa в env
2025-10-25 20:02:14 +03:00
FreizeandGitHub cd60f496fa Update docker-compose-removed-server.yml 2025-10-25 19:27:57 +03:00
FreizeandGitHub f883065bb9 Add Docker Compose configuration for removed server
This configuration file was created as an example showing the installation of the bot on a separate server.
2025-10-25 19:24:50 +03:00
Snowy-Fluffy b71bd71d8d allows you to set custom payment id for fk 2025-10-24 20:45:43 +03:00
Snowy-Fluffy 0524d24b13 set custom payment id for fk 2025-10-24 20:21:35 +03:00
machka paslaandGitHub cdfd94c814 Merge pull request #108 from machka-pasla/dev
fixed locales and traffic after adding days
2025-10-24 10:34:25 +03:00
machka pasla 6513681125 fixed locales and traffic after adding days 2025-10-24 10:33:10 +03:00
machka paslaandGitHub f321fd04cf Merge pull request #107
fixed ad
2025-10-21 09:45:32 +03:00
machka pasla baede17adf fixed ad 2025-10-21 09:44:21 +03:00
machka paslaandGitHub 17bf8720a3 Merge pull request #105 from machka-pasla/dev
fixed my devices keyboard
2025-10-20 22:28:36 +03:00
machka pasla 280aced20e fixed my devices keyboard 2025-10-20 22:26:50 +03:00
machka paslaandGitHub d672032201 Merge pull request #104 from machka-pasla/dev
Added freekassa, HWID Devices managment, mandatory sub to channel
2025-10-18 22:45:02 +03:00
machka pasla e826c4309d locales 2025-10-18 22:43:38 +03:00
machka pasla 9f525dc7da hwid devices integration 2025-10-18 22:39:56 +03:00
machka paslaandGitHub b880032b9b Merge pull request #103 from gldkru/feature/sub-devices-managment
Add 'My Devices' feature to user subscription management
2025-10-18 21:32:48 +03:00
Kirill Gladkikh 8f9484b6ec Add 'My Devices' feature to user subscription management
- Implemented a new command handler for displaying user devices.
- Added functionality to disconnect devices from the user's account.
- Updated subscription service to retrieve and manage device information.
- Enhanced inline keyboard to include device management options.
- Added new translations for device-related messages in English and Russian locales.
2025-10-18 02:27:19 +03:00
machka pasla c3622d9c2b channel require and db update 2025-10-17 09:36:25 +03:00
machka pasla 51ffbbfa1d pay from saved card 2025-10-16 22:04:04 +03:00
machka paslaandGitHub 394e8dcc6c Merge pull request #100 from zerodata731/freekassa-codex
Freekassa codex
2025-10-14 09:38:47 +03:00
machka paslaandGitHub 5f3fc13c2b Merge branch 'dev' into freekassa-codex 2025-10-14 09:38:39 +03:00
raufakchurin 4d43c9cf0f ORDER ID added to SUccess info 2025-10-14 11:28:41 +05:00
raufakchurin c891122064 before review 2025-10-14 10:31:55 +05:00
raufakchurin 59d07314e2 Удалили все личшние кроме СБП 2025-10-14 09:44:04 +05:00
machka pasla c438672ced fix 2025-10-13 10:16:30 +03:00
machka pasla 49c532e4db bruh 2025-10-13 10:06:58 +03:00
machka paslaandGitHub d50324d098 Merge pull request #99 from zerodata731/freekassa-codex
v_0.0
2025-10-13 10:05:45 +03:00
raufakchurin 52893a0629 v0.1 2025-10-12 15:27:48 +05:00
machka pasla 664cea447c errors when broadcast fix 2025-10-11 16:59:44 +03:00
machka pasla 5f50cfeaee broadcast real time logs 2025-10-11 16:53:26 +03:00
raufakchurin bdade47758 v_0.0 2025-10-11 18:06:49 +05:00
machka paslaandGitHub a3406451cb Merge pull request #98 from machka-pasla/dev
auto renew message with trial fix
2025-10-10 20:05:26 +03:00
machka pasla 9938671af9 auto renew message fix 2025-10-10 20:04:50 +03:00
machka paslaandGitHub 1d57c9be21 Merge pull request #96 from machka-pasla/dev
name filtration
2025-10-09 21:11:17 +03:00
machka pasla fde0f1b135 username filtration 2025-10-09 21:08:01 +03:00
Machka PaslaandGitHub 67df164498 Merge pull request #94 from machka-pasla/dev
tribute grace fix
2025-09-28 19:24:45 +03:00
machka-pasla 48e666f605 grace fix 2025-09-28 23:24:16 +07:00
machka-pasla 3cb15fa254 Add recent actions title to user management translations in English and Russian locales. 2025-09-16 15:19:36 +03:00
machka-pasla 2100949470 Add referral statistics to user card formatting. Updated user management to include referral service and added new translations for invited friends and purchased subscriptions in English and Russian locales. 2025-09-16 15:14:22 +03:00
Machka PaslaandGitHub c2a475b062 Merge pull request #90 from machka-pasla/dev
Bugfixes
2025-09-12 13:15:04 +03:00
machka-pasla 585cbe8fb6 Refactor subscription retrieval logic to limit results to the most recent active subscription. Updated query execution to return the first scalar result for improved performance. 2025-09-12 12:57:35 +03:00
machka-pasla a243ddcac8 Refactor ads deletion callback data parsing to improve clarity and maintainability. Updated the way campaign ID and back page are extracted from the callback data. 2025-09-11 23:05:57 +03:00
machka-pasla 357d7caf26 Refactor i18n handling in ads deletion prompt to use direct gettext calls. This improves language support and error messaging consistency. 2025-09-11 23:03:41 +03:00
machka-pasla 2713b3bae5 Add ad campaign deletion functionality with confirmation prompts and error handling. Updated inline keyboards and translations for delete actions. Enhanced subscription command handler to include fallback for config link URL. 2025-09-11 22:58:12 +03:00
Machka PaslaandGitHub 2215ee8ad2 Merge pull request #89 from machka-pasla/dev
Автопродление подписки при платеже через ЮКассу, микрофиксы и прочее-прочее
2025-09-11 09:58:05 +03:00
Machka PaslaandGitHub 1b6acff6d4 Merge pull request #88 from ProtoPes/add-dockerignore
Добавление файла .dockerignore
2025-09-08 22:36:03 +05:00
Machka PaslaandGitHub 25d9cc7446 Update .dockerignore
оставил локали, потому что не всегда идет прокидывание локалей через докер вольюм
2025-09-08 22:35:43 +05:00
ProtoPes e1e73ec736 Added ".dockerignore" to reduce the size of a docker image 2025-09-08 19:07:46 +03:00
Machka PaslaandGitHub cc21089f18 Merge pull request #84 from freize/patch-1
Correction of translation into Russian.
2025-09-07 23:10:20 +05:00
Machka PaslaandGitHub 46120210f1 Merge pull request #85 from freize/patch-2
Correction of the English translation.
2025-09-07 23:09:40 +05:00
Machka PaslaandGitHub df43283971 Update .env.example 2025-09-07 23:08:10 +05:00
Machka PaslaandGitHub a51b20ee54 Merge pull request #86 from freize/patch-3
Update .env.example
2025-09-07 23:06:26 +05:00
Machka PaslaandGitHub 0915022b52 Merge branch 'dev' into patch-3 2025-09-07 23:05:49 +05:00
FreizeandGitHub 11f5ec4a5b Update .env.example
Comments for bot configuration. Just in case.
2025-09-07 20:58:50 +03:00
FreizeandGitHub d187a79be6 Correction of the English translation. 2025-09-07 20:09:22 +03:00
FreizeandGitHub 7b05ad4cb1 Correction of translation into Russian. 2025-09-07 20:07:45 +03:00
machka-pasla 79ac5cb797 Enhance error handling and logging in CryptoPayService payment processing
- Added error handling for payment record creation and updates, ensuring database transactions are rolled back on failure.
- Implemented logging for errors during payment record creation and updates, improving traceability and debugging capabilities.
- Committed changes to ensure that payment records are properly persisted or rolled back in case of exceptions.
2025-09-07 12:55:54 +05:00
machka-pasla c208541525 Add autorenew cancellation handler and logging for subscription service
- Implemented a new callback handler to allow users to cancel auto-renewal of their subscriptions via a webhook button.
- Added logging to track subscription status and auto-renew settings during webhook checks, improving monitoring and debugging capabilities.
- Enhanced user feedback with appropriate alerts for unsupported actions and confirmation of updates to subscription settings.
2025-09-05 22:10:56 +03:00
machka-pasla 2b592e8abe Implement auto-renew confirmation and cancellation features in subscription module
- Added confirmation popups for enabling and disabling auto-renewal, enhancing user interaction and clarity.
- Introduced inline keyboard options for auto-renewal management, allowing users to confirm their choices easily.
- Updated notification messages to inform users about upcoming automatic charges and provide cancellation options.
- Enhanced localization files to include new strings for auto-renewal features in both English and Russian.
2025-09-05 22:02:52 +03:00
machka-pasla 43de4cc08b Add cryptocurrency and stars payment handlers to subscription module
- Implemented new callback handlers for cryptocurrency and stars payments, enhancing payment options for users.
- Integrated error handling and user feedback mechanisms for payment processes, improving overall user experience.
- Updated the pre-checkout and successful payment handling to accommodate new payment methods, ensuring seamless transaction processing.
- Organized imports for better modularity and clarity in the payments module.
2025-09-05 15:46:50 +03:00
machka-pasla 9a6ff6f636 Refactor pagination logic in admin ads list keyboard
- Updated the pagination row to only display when multiple pages are available, improving the user interface for ad navigation.
- Enhanced the inline keyboard structure for better clarity and usability in the admin panel's ads management section.
2025-09-05 15:17:01 +03:00
machka-pasla 8113908a98 Implement pagination and detailed views for ad campaigns in admin panel
- Added pagination support for the ads list, allowing admins to navigate through multiple pages of campaigns.
- Introduced detailed views for individual ad campaigns, displaying comprehensive statistics and information.
- Enhanced the database access layer with new methods for counting and listing campaigns with pagination.
- Updated localization files to include new strings for the ads overview and campaign details.
- Improved the inline keyboard structure for better navigation within the ads management interface.
2025-09-05 15:08:52 +03:00
machka-pasla 81155c9151 Add ads creation action to admin panel
- Introduced a new action for starting the ad creation process in the admin panel, enhancing ad management capabilities.
- Organized imports related to ad handling for better modularity and clarity in the codebase.
2025-09-05 15:00:47 +03:00
machka-pasla 7fa670c1e8 Add ads menu handler to admin panel actions
- Introduced a new action for displaying the ads menu in the admin panel, enhancing the admin interface for ad management.
- Improved the organization of imports related to ad handling, ensuring better modularity in the codebase.
2025-09-05 14:58:20 +03:00
machka-pasla d94430c602 Enhance ad creation flow with state-based filtering
- Introduced StateFilter to the ads creation flow, ensuring that messages are processed only when the bot is in specific admin states.
- Improved the organization of imports by removing redundant imports and clarifying state management for ad-related interactions.
2025-09-05 14:55:54 +03:00
machka-pasla c6292a1c8e Add ad campaign management features and enhance user attribution
- Introduced ad campaign management functionality, including the ability to create campaigns and attribute users based on ad parameters.
- Updated user command handlers to process new ad-related parameters and log user interactions with ad campaigns.
- Enhanced admin interfaces with new keyboard options for managing ads and displaying campaign information.
- Added database models for ad campaigns and attributions, improving data management for advertising features.
- Updated localization files to support new ad-related strings in both English and Russian.
2025-09-05 14:53:32 +03:00
machka-pasla d7d8409919 Refactor subscription command handler to improve user experience
- Updated the subscription command handler to dynamically adjust inline keyboard options based on user subscription status.
- Enhanced the mini-app connect button functionality, allowing access to additional features when the URL is configured.
- Improved the layout of inline keyboard buttons to prioritize new options, streamlining user interaction in subscription management.
2025-09-04 20:46:20 +03:00
machka-pasla 8f603ad51c Enhance subscription command handler with dynamic inline keyboard options
- Added support for a mini-app connect button in the subscription command handler, allowing users to access additional features if the URL is configured.
- Implemented an auto-renew toggle button and payment methods management button, improving user interaction based on subscription status and settings.
- Refactored inline keyboard construction to prepend new buttons above the existing markup, enhancing the user experience in subscription management.
2025-09-04 20:20:27 +03:00
machka-pasla 6f3b727cc7 Update .env.example to include YooKassa autopayments configuration
- Added new environment variables for enabling/disabling YooKassa autopayments, enhancing payment method management.
- Updated comments to clarify the relationship between autopayments and receipt fields, improving documentation for developers.
2025-09-04 20:08:37 +03:00
machka-pasla b7a723a088 Implement YooKassa autopayments feature toggle across payment and subscription handlers
- Introduced a global setting for enabling or disabling YooKassa autopayments, allowing for better control over payment method management and subscription renewals.
- Updated various handlers to check the autopayments setting before executing payment-related logic, ensuring that features are only available when enabled.
- Enhanced error handling to notify users when autopayments are disabled, improving user experience and clarity in payment operations.
- Refactored receipt generation logic to derive fields based on the autopayments setting, streamlining configuration management.
2025-09-04 20:03:47 +03:00
machka-pasla 807e3dadd3 Update database setup to include simple migrations and enhance .gitignore
- Added the `run_simple_migrations` function call in the database initialization process to ensure any missing columns are added during setup.
- Updated the .gitignore file to exclude the `models_old.py` file, improving project cleanliness.
2025-09-04 19:52:57 +03:00
machka-pasla f11ec6a676 Add re-exports for backward compatibility in subscription module
- Re-exported commonly used entrypoints from the core module to maintain backward compatibility.
- Ensured that existing functionality remains accessible after recent refactoring efforts in the subscription handling codebase.
2025-09-04 19:29:13 +03:00
machka-pasla d83f64c5fe Refactor subscription handling and payment method management
- Updated the user router to import the subscription router directly, preparing for future package separation.
- Removed the outdated subscription handler file, streamlining the codebase and improving maintainability.
- Introduced a new method for deleting user payment methods by provider ID, enhancing flexibility in payment management.
- Improved error handling and clarity in payment method operations, ensuring a smoother user experience.
2025-09-04 19:21:14 +03:00
machka-pasla 72eaed991a Improve payment method filtering logic in subscription history handler
- Added support for distinguishing between internal method IDs and direct provider payment method IDs, enhancing the accuracy of payment method filtering.
- Introduced a flag to track if a payment method filter was requested, allowing for clearer handling of cases where the method cannot be resolved.
- Updated error handling to ensure that empty payment histories are displayed appropriately when a filter is requested but cannot be matched.
2025-09-04 19:06:20 +03:00
machka-pasla 868c48ccb9 Remove outdated comments and fallback logic in payment method history handler
- Eliminated unnecessary comments regarding auto-renewal payment methods to enhance code clarity.
- Removed fallback logic for including auto-renew entries, streamlining the filtering process for user payments.
2025-09-04 19:00:03 +03:00
machka-pasla 2b855ed11b Refactor payment method ID handling in subscription history
- Updated the payment method history handler to improve the extraction of payment method IDs from callback data, enhancing reliability in filtering and navigation.
- Changed variable names for clarity, ensuring better readability and maintainability of the code.
- Improved error handling during ID extraction to prevent potential issues when no payment history is available.
2025-09-04 18:53:58 +03:00
machka-pasla 2bc7bea29d Enhance payment method history filtering and YooKassa integration
- Updated the payment method history handler to support filtering by specific saved payment methods, improving user experience when viewing payment logs.
- Increased the limit of recent payment logs retrieved from 10 to 30 for better visibility.
- Enhanced the YooKassa service to include detailed payment method information, including card details and last four digits, improving clarity in payment history.
- Refactored error handling in the YooKassa service to ensure robust fetching of payment information.
2025-09-04 18:49:31 +03:00
machka-pasla b6a2bf2329 Implement YooMoney payment method enhancements and localization updates
- Added functionality to save multiple YooMoney payment methods, marking the first entry as default.
- Refactored payment method display logic to standardize the presentation of YooMoney wallet details across various handlers.
- Introduced new localization strings for improved clarity in user-facing messages regarding payment methods.
- Enhanced error handling during payment method operations to ensure smoother user experience.
2025-09-04 18:42:48 +03:00
machka-pasla ba8be7c3f9 Standardize YooMoney wallet display across payment handlers and localization
- Updated the display logic for YooMoney wallet in payment and subscription handlers to ensure consistent naming and avoid leaking sensitive account information.
- Introduced a new localization string for wallet display, enhancing clarity in user-facing messages.
- Refactored relevant sections to improve overall code maintainability and user experience.
2025-09-04 18:26:15 +03:00
machka-pasla cb464059d9 Enhance payment method display for YooMoney integration
- Updated the logic for displaying payment method details, specifically for YooMoney, to include account number handling and last four digits extraction.
- Improved the consistency of payment method display across different handlers, ensuring a clearer user experience.
- Refactored relevant sections in both payment and subscription handlers to accommodate the new display logic.
2025-09-04 18:18:36 +03:00
machka-pasla a5aecb0f86 Enhance payment method display logic and localization updates
- Improved the logic for displaying payment method details, including card type and last four digits, to provide a clearer user experience.
- Updated localization strings to reflect changes in payment method terminology, ensuring consistency across user-facing messages.
- Refactored payment method handlers to utilize the new display logic, enhancing the overall management of payment methods.
2025-09-04 18:14:32 +03:00
machka-pasla cf7f0eae51 Refactor payment method ID extraction for improved reliability
- Updated the logic for extracting payment method IDs from callback data to handle cases with multiple segments, ensuring more robust parsing.
- Enhanced the consistency of ID retrieval across different payment method handlers, improving overall code reliability and user experience.
2025-09-04 18:00:04 +03:00
machka-pasla 850f135bf8 Implement multi-card deletion for payment methods with enhanced error handling
- Added functionality to delete specific payment methods based on their ID, supporting multi-card management.
- Improved error handling during deletion processes, including rollback mechanisms for database transactions.
- Updated user notifications to reflect the success or failure of deletion attempts, ensuring a consistent user experience.
- Refactored the response messages to include updated lists of remaining payment methods after deletion.
2025-09-04 17:52:21 +03:00
machka-pasla 793412c472 Enhance payment method history handling and navigation
- Added a new keyboard function to facilitate navigation back to specific payment method details.
- Updated the payment method history handler to utilize the new back navigation option, improving user experience when no payment history is available.
- Enhanced error handling for extracting payment method IDs from callback data to ensure smoother navigation.
2025-09-04 17:44:31 +03:00
machka-pasla a510ab1021 Refactor payment methods management to utilize a paginated list view
- Updated the payment methods management handler to directly build and display a paginated list of user payment methods, enhancing user experience.
- Removed legacy support for single card checks and deprecated keyboard functions in favor of the new list view.
- Improved localization handling for payment method titles and added fallback options for missing data.
2025-09-04 17:32:27 +03:00
machka-pasla 50cea15c92 Enhance localization for user-facing messages across payment and subscription services
- Updated various services to utilize the user's database language for all user-facing messages, improving the localization experience.
- Refactored message retrieval logic in payment processing, subscription management, and notification handling to ensure consistency in language usage.
- Added comments to clarify the purpose of language settings in relevant sections of the code.
2025-09-04 17:24:24 +03:00
machka-pasla 6b5828ea51 Implement multi-card payment method management and enhance user notifications
- Added support for saving multiple payment methods, allowing users to bind and manage their cards effectively.
- Introduced a new paginated list view for displaying saved payment methods, improving user experience.
- Enhanced user notifications for successful binding of payment methods, including localized messages.
- Updated the database models and data access layer to accommodate multi-card functionality.
- Refactored existing payment method handlers to integrate with the new multi-card system.
2025-09-04 17:11:18 +03:00
machka-pasla 257597ccb7 Enhance payment processing and user detail updates in YooKassa integration
- Added handling for 'waiting_for_capture' event in the YooKassa webhook to manage bind-only payment flows, including saving payment methods and canceling authorizations.
- Introduced a new method in the YooKassaService to cancel payments, improving error handling and logging for payment cancellations.
- Updated user detail synchronization to conditionally update descriptions only when they differ from the current panel state, enhancing efficiency.
2025-09-04 16:30:45 +03:00
Machka PaslaandGitHub dba82cfb97 Create LICENSE
MIT
2025-09-04 15:04:53 +03:00
machka-pasla e0e2cde9a7 Remove unused import for PaymentMethodType in yookassa_service.py to clean up code and improve readability. 2025-09-03 19:56:31 +03:00
machka-pasla 5b98e1a40c Implement payment methods management and binding functionality
- Added new handlers for managing payment methods, including viewing, binding, and deleting payment methods.
- Introduced new inline keyboard options for payment method management in user interactions.
- Enhanced the YooKassa service to support card binding with minimal payment requirements.
- Updated localization files to include new messages related to payment methods and their management.
2025-09-03 18:07:18 +03:00
machka-pasla 0f69823192 Refactor subscription fetching logic in toggle_autorenew_handler
- Updated the subscription retrieval process to directly fetch the Subscription model by ID, improving clarity and reducing potential import cycle issues.
- Enhanced code readability by adding a comment to clarify the purpose of the subscription fetch operation.
2025-09-03 16:32:18 +03:00
machka-pasla 38f3b1ad74 Enhance localization and fallback logic in i18n middleware
- Improved the effective language determination process by adding robust fallbacks to ensure a valid language is always used.
- Updated the gettext method to include explicit fallback to English if the requested language data is unavailable.
- Corrected a typo in the Russian localization for auto-renewal messaging to ensure clarity in user notifications.
2025-09-03 16:28:02 +03:00
machka-pasla 9b4dab84ad Enhance auto-renewal messaging and payment processing logic
- Introduced a new flag for auto-renew subscriptions to streamline messaging and avoid redundant configuration links.
- Updated localization files to include a new message for auto-renewal notifications in both English and Russian.
- Improved error handling and logging in the payment processing flow to ensure clarity and reliability during subscription renewals.
2025-09-03 10:44:20 +03:00
machka-pasla c607b6ef85 Enhance payment processing for auto-renew subscriptions
- Updated the payment processing logic to handle cases where payment_db_id may be absent for auto-renewal scenarios, ensuring idempotent creation of payment records using provider payment IDs.
- Implemented error handling for ensuring payment records and backfilling yookassa_payment_id, improving reliability in processing auto-renewal webhooks.
- Enhanced logging for better traceability of payment record creation and updates.
2025-09-03 10:32:39 +03:00
machka-pasla 09f0de78c6 Refactor subscription renewal process to utilize panel webhook
- Removed the recurring billing task from the bot, shifting the auto-renew functionality to the panel webhook service, which now triggers renewals 24 hours before expiry.
- Updated service dependencies to wire the subscription service with the panel webhook for seamless renewal handling.
- Adjusted the Subscription model to enable auto-renew by default, enhancing subscription management.
2025-09-03 10:22:02 +03:00
machka-pasla 6a3b4a6947 Safely extract and serialize payment method details in YooKassa webhook handling
- Enhanced the yookassa_webhook_route to safely extract payment method details, including card information, from the payment notification.
- Implemented error handling to log exceptions during serialization, improving reliability and debugging capabilities.
- Updated the payment processing dictionary to use the newly structured payment method data.
2025-09-03 10:12:15 +03:00
machka-pasla d6f707d386 Implement auto-renew subscription feature and enhance payment method handling
- Added a recurring billing task to automatically charge users one day before subscription expiry, improving subscription management.
- Introduced a new UserBilling model to store saved payment methods for off-session charges, enhancing user experience.
- Updated YooKassa service to support saving payment methods and capturing payments for auto-renewals.
- Enhanced subscription handling to toggle auto-renew settings and provide user feedback through localized messages.
- Improved error handling and logging for payment method persistence and subscription renewal processes.
2025-09-03 09:40:42 +03:00
Machka PaslaandGitHub dace4c2938 Merge pull request #82 from machka-pasla/dev
I HATE tribute
2025-09-01 21:26:30 +03:00
machka-pasla b69c2ab18d Enhance PanelWebhookService to support panel expiry updates and auto-renew payments
- Updated the PanelWebhookService to accept a new PanelApiService dependency for managing panel user details.
- Implemented functionality to update panel expiry upon subscription renewal, ensuring users maintain access to services.
- Added error handling and logging for both panel expiry updates and auto-renew payment record creation, improving reliability and user feedback.
2025-09-01 21:23:55 +03:00
machka-pasla 56fc88c10e Refactor trial confirmation and activation handlers to utilize new keyboard options
- Updated the trial confirmation and activation handlers to use a dynamic reply markup based on activation status, improving user interaction.
- Enhanced localization for trial activation details in both English and Russian, providing clearer messaging about trial status and connection instructions.
- Ensured consistency in the user experience across different scenarios by integrating the new keyboard options.
2025-08-30 13:29:42 +03:00
machka-pasla e74f801aec Add new error messages and subscription details in English and Russian localization
- Introduced additional error messages for improved user feedback, including service unavailability and payment gateway errors.
- Added subscription details localization to enhance user experience, providing information on subscription status and traffic usage.
- Ensured consistency in messaging across both English and Russian locales.
2025-08-29 13:09:01 +03:00
machka-pasla acf5f060a6 Enhance error handling in subscription response methods
- Implemented try-except blocks around callback answer methods to prevent exceptions from disrupting user interactions.
- Ensured consistent use of the `answer` method across various subscription-related handlers, improving reliability in user notifications.
2025-08-29 12:27:13 +03:00
machka-pasla fb2672732d Improve error handling in user interaction responses
- Refactored the handling of callback answers and message responses to include try-except blocks, ensuring that exceptions are caught and logged without disrupting the user experience.
- Updated message sending methods to utilize the `answer` method consistently, enhancing the reliability of user notifications in various scenarios.
2025-08-29 12:10:00 +03:00
machka-pasla f4730c3ceb Enhance user description synchronization with Telegram profile
- Added functionality to update user descriptions on the panel during synchronization, incorporating Telegram fields such as username, first name, and last name.
- Implemented error handling and logging for failed description updates in both admin sync and profile sync processes.
- Updated user creation and details update methods in the subscription service to include user descriptions, improving consistency across user data management.
2025-08-29 11:05:21 +03:00
machka-pasla e6b3a7c66f Add trial activation details in English and Russian localization
- Introduced a new message for trial activation details, providing users with information about the duration, validity, and configuration link.
- Enhanced user experience by ensuring clarity and consistency in both English and Russian locales.
2025-08-29 10:51:40 +03:00
machka-pasla 24cf193f3c Refactor broadcast and user logs messages for localization support
- Updated the broadcast confirmation message to utilize localization for dynamic content, enhancing multilingual support.
- Modified user logs display to incorporate localization for the title, improving consistency across languages.
- Added new localization entries in both English and Russian for better user experience and clarity.
2025-08-28 22:03:48 +03:00
machka-pasla 85bf0fb6fd Enhance promo export functionality with English localization
- Added support for exporting promo activations and all promo codes with captions and CSV headers in English.
- Updated the promo export handlers to ensure consistent English messaging for user notifications and CSV content.
- Introduced new localization entries for English in the locales files to support the changes.
2025-08-28 21:52:36 +03:00
machka-pasla b576d04501 Add welcome messages in English and Russian localization
- Introduced a new "welcome" message for users in both English and Russian, enhancing the user experience with personalized greetings.
2025-08-28 21:46:59 +03:00
machka-pasla 97268b9906 Add subscription expiration notifications in English and Russian
- Introduced new localized messages for notifying users about upcoming subscription expirations at 72, 48, and 24 hours.
- Added notifications for expired subscriptions and a message for cancelled Tribute subscriptions, enhancing user communication regarding their subscription status.
2025-08-28 21:41:40 +03:00
machka-pasla 1091b7b846 Update bot functionality and localization improvements
- Added new entries to .gitignore for backup JSON files.
- Enhanced user statistics in inline mode to include active users today.
- Improved message handling in broadcast functionality to differentiate between text and media messages, ensuring proper parameter usage.
- Updated payment handling to reflect pagination information in English and Russian.
- Refactored promo management to utilize localized button texts for better user experience.
2025-08-28 21:24:31 +03:00
Machka PaslaandGitHub f191c1813e Merge pull request #79 from ProtoPes/add-referral-check
Added check for referral user, small refactoring
2025-08-26 20:38:45 +03:00
ProtoPes e78f9bf291 Added check for referral user, small refactoring
With regular expressions we can be sure that the data is already valid
2025-08-26 20:28:50 +03:00
Machka PaslaandGitHub 5c74a08ed8 Merge pull request #78 from machka-pasla/dev
Implement user creation and synchronization enhancements in admin syn…
2025-08-26 18:14:40 +03:00
machka-pasla 14950fd559 Implement user creation and synchronization enhancements in admin sync handler
- Added functionality to create new users during synchronization if they are not found in the local database and have a valid Telegram ID.
- Introduced logging for newly created users to improve tracking and debugging.
- Enhanced synchronization statistics to include the count of newly created users, with localization support for both English and Russian.
- Updated the details of synchronization status to reflect additional statistics, improving clarity in admin reports.
2025-08-26 18:11:52 +03:00
Machka PaslaandGitHub 711b9a2487 Merge pull request #77 from machka-pasla/dev
Update broadcast and some other small bugs 🪲
2025-08-25 14:13:27 +03:00
machka-pasla 6e7eb6acfd Enhance message sending functionality to filter unsupported parameters
- Introduced a `SUPPORTED_PARAMS` dictionary to define valid parameters for each message type.
- Added a `filter_kwargs` utility function to filter out unsupported parameters based on the content type.
- Updated `send_message_by_type`, `send_message_via_queue`, and `send_direct_message` functions to utilize the new filtering logic, ensuring only valid parameters are passed during message sending.
- Improved handling for unknown content types by sending a default text message.
2025-08-25 13:49:31 +03:00
machka-pasla b42fae8772 Refactor message handling in broadcast and user management to utilize new utility functions
- Introduced `get_message_content` and `send_message_by_type` utility functions to streamline content type handling and message sending for various media types.
- Updated `process_broadcast_message_handler` and `process_direct_message_handler` to leverage these new functions, reducing code duplication and improving maintainability.
- Enhanced error handling for empty messages and improved message formatting with admin signatures.
2025-08-25 13:44:37 +03:00
machka-pasla f707662125 Refactor bot initialization and routing logic to enforce webhook mode requirement
- Updated the bot's startup logic to require a configured WEBHOOK_BASE_URL, exiting if not set, and logging appropriate error messages.
- Simplified the decision-making process for running the AIOHTTP server, ensuring it only runs in webhook mode.
- Enhanced the router configuration to filter updates for private chats, improving message handling security.
2025-08-25 13:24:30 +03:00
machka-pasla 60c6e0e961 Refactor subscription duration calculation in PanelWebhookService and SubscriptionService
- Introduced a utility function `add_months` to handle subscription duration calculations based on calendar months instead of a fixed 30-day period.
- Updated the auto-renewal logic in `PanelWebhookService` to use the new function for extending subscription end dates.
- Adjusted the duration calculation in `SubscriptionService` to derive the end date after a specified number of months, improving accuracy in subscription management.
2025-08-25 12:30:44 +03:00
machka-pasla b23b75b72e Refactor username update logic in SubscriptionService to prevent overwriting Telegram usernames
- Removed the conditional logic that updated the local user's username with the panel username, ensuring that the Telegram username remains unchanged.
- Added a comment to clarify the purpose of the update, focusing on maintaining the linkage to the panel UUID.
2025-08-25 12:24:30 +03:00
machka-pasla c69f02f7c0 Enhance direct message handling in user management to support multiple content types
- Updated the process_direct_message_handler to determine the content type of incoming messages (text, photo, video, etc.) and send them accordingly to the target user.
- Added error handling for empty messages and invalid HTML content, improving user feedback.
- Included admin signature in messages, ensuring consistent formatting across different content types.
2025-08-20 17:12:40 +03:00
machka-pasla f22e359684 Refactor user creation logic in DAL to support race-safe inserts and return creation status
- Updated the create_user function to use PostgreSQL upsert for concurrent user creation, preventing IntegrityError.
- Modified the function to return a tuple containing the user object and a boolean indicating if the user was newly created.
- Adjusted the start_command_handler to log user registration only if a new user was created, improving logging clarity.
2025-08-20 15:49:12 +03:00
machka-pasla d2402fea77 Add preview message functionality for multiple content types in broadcast handler
- Implemented preview message sending for various content types (text, photo, video, animation, document, audio, voice, sticker, video_note) in the process_broadcast_message_handler.
- Added error handling for invalid HTML content in broadcast messages, improving user feedback and experience.
- Enhanced the confirmation prompt to provide a concise message preview without duplicating text.
2025-08-20 15:39:22 +03:00
machka-pasla 9b8ddb39da Enhance broadcast message handling to support multiple content types
- Updated the process_broadcast_message_handler to determine the content type of incoming messages (text, photo, video, etc.) and store relevant data in the state.
- Implemented new methods in MessageQueueManager for queuing various media types, improving the flexibility of the broadcast system.
- Adjusted confirmation prompts to provide a concise message preview, enhancing user experience.
- Added localization for the new confirmation prompt in both English and Russian.
2025-08-20 15:30:06 +03:00
Machka PaslaandGitHub 459b655ae3 Merge pull request #76 from machka-pasla/dev
add new referral param and broadcast for groups of users
2025-08-19 14:54:30 +03:00
machka-pasla d81ab4137d Update payment processing logic to disable skipping active subscriptions
- Modified the payment processing functions across multiple services to set skip_if_active_before_payment to False, ensuring that active subscriptions are not skipped during payment processing.
- This change enhances the handling of user subscriptions and improves overall payment logic consistency.
2025-08-19 12:26:56 +03:00
Machka PaslaandGitHub cb6ae1e052 Update README.md 2025-08-18 15:22:29 +03:00
machka-pasla f4e2ae5fbd Enhance expired subscription handling in PanelWebhookService
- Updated the _handle_expired_subscription method to return a boolean indicating whether an auto-renewal was performed.
- Adjusted the handle_event method to suppress expiration notifications if an auto-renewal occurs, improving user experience and notification management.
2025-08-17 18:18:31 +03:00
machka-pasla 157c3a7c61 Add referral bonus configuration and enhance payment processing logic
- Introduced a new environment variable REFERRAL_ONE_BONUS_PER_REFEREE to control referral bonus application.
- Updated referral bonus application logic to skip bonuses for users with active subscriptions at the time of payment.
- Enhanced payment processing functions across multiple services to include current payment ID and skip logic for active users.
- Added a new database method to count succeeded payments for users, improving referral bonus eligibility checks.
2025-08-17 17:27:33 +03:00
machka-pasla ef9ebc1918 Refactor startup and shutdown handlers in bot initialization for compatibility with aiogram event signature
- Wrapped the startup and shutdown handlers to ensure they conform to the aiogram event signature, allowing for proper argument handling.
- Updated the web server to access dispatcher workflow data directly, preventing sequence protocol issues and enhancing stability.
2025-08-17 12:13:08 +03:00
machka-pasla 87664a7735 Remove unused router inclusion from dispatcher setup to streamline bot initialization 2025-08-17 12:08:42 +03:00
machka-pasla 3d58f60a4d Refactor bot initialization and service registration for improved modularity
- Integrated new service building functions to streamline bot initialization, enhancing maintainability and clarity.
- Updated the dispatcher setup to include core services dynamically, reducing redundancy in service registration.
- Added support for multiple broadcast targets in the admin handler, allowing for more flexible message distribution.
- Enhanced localization for new broadcast target options, improving user experience in both English and Russian.
- Implemented new database access methods to retrieve user IDs based on subscription status, optimizing broadcast logic.
2025-08-17 12:05:06 +03:00
machka-pasla a75a1f483c Add ProfileSyncMiddleware to keep user profile data updated in the database
- Integrated ProfileSyncMiddleware to ensure that user profile information (username, first_name, last_name) remains current in the database.
- Updated get_enhanced_user_statistics to use timezone-aware datetime for accurate SQL queries, preventing naive/aware comparison issues.
- Clarified comments in the user statistics function for better understanding of active user metrics and subscription handling.
2025-08-15 23:40:23 +03:00
Machka PaslaandGitHub 70d472e71c Merge pull request #71 from machka-pasla/dev
i hate tribute
2025-08-09 22:32:58 +03:00
machka-pasla 13a9e58e27 Refactor response handling in TributeService for improved clarity and consistency
- Introduced helper functions for generating standardized JSON responses, enhancing readability and maintainability.
- Updated response handling for various error conditions to return structured JSON instead of plain text.
- Acknowledged missing user ID with an "ignored" status to prevent unnecessary retries, improving webhook processing reliability.
2025-08-09 22:18:06 +03:00
machka-pasla 7219a6ac30 Refactor provider payment ID generation in TributeService for improved uniqueness
- Updated the logic to generate a unique, idempotent provider payment ID based on explicit event/payment identifiers or a combination of subscription ID and a hash of the raw payload.
- This change ensures better handling of webhook events and prevents potential conflicts in payment identification.
2025-08-09 21:29:04 +03:00
Machka PaslaandGitHub dbb27ee9ca Merge pull request #70 from machka-pasla/dev
Broadcast fix
2025-08-09 10:00:30 +03:00
machka-pasla 4e58fda4a5 Update broadcast message handler to enforce HTML parsing and disable web page previews
- Changed the parse_mode to "HTML" for broadcast messages to ensure proper formatting.
- Added disable_web_page_preview option to enhance message presentation and control over content display.
2025-08-09 09:51:21 +03:00
machka-pasla 8eb5daada5 Add HTML validation for broadcast messages in admin handler
- Implemented a preliminary check for HTML validity in broadcast messages by attempting to send a test message before processing.
- Added error handling for invalid HTML, providing user feedback in both English and Russian.
- Updated localization files to include new error messages for invalid HTML input.
2025-08-09 09:46:33 +03:00
machka-pasla 4c28d3868c Implement validation for broadcast message input in admin handler
- Updated the broadcast message handler to trim whitespace from the input text and added a check for empty messages.
- If the message is empty, an error response is sent to prompt the user for valid input, enhancing user experience and preventing empty broadcasts.
2025-08-09 09:43:02 +03:00
529 changed files with 129617 additions and 11805 deletions
+110
View File
@@ -0,0 +1,110 @@
# Git
.gitignore
.github
.gitattributes
LICENSE
README.md
.env
.env.*
!.env.example
scratch_*.py
scratch/
.claude/
*.local.*
node_modules/
frontend/node_modules/
deploy/compose/docker-compose-dev.yml
data/*
!data/tariffs.example.json
!data/locales-overrides.example.json
# CI
.codeclimate.yml
.travis.yml
.taskcluster.yml
# Docker
docker-compose.yml
deploy/compose/*.yml
.dockerignore
tmp/
# WebApp build artifacts (regenerated inside Docker)
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
# Byte-compiled / optimized / DLL files
**/__pycache__/
**/*.py[cod]
# C extensions
*.so
# Distribution / packaging
.Python
env/
build/
develop-eggs/
dist/
downloads/
eggs/
lib/
lib64/
parts/
sdist/
var/
*.egg-info/
.installed.cfg
*.egg
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.coverage
.cache
nosetests.xml
coverage.xml
# Translations
*.mo
*.pot
# Django stuff:
*.log
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# Virtual environment
.env*
.venv/
venv/
# PyCharm
.idea
# Python mode for VIM
.ropeproject
**/.ropeproject
# Vim swap files
**/*.swp
# VS Code
.vscode/
+54 -110
View File
@@ -1,128 +1,72 @@
# Telegram Bot Token and Admin IDs
# Minimal bootstrap env.
# Most product settings are configured later in Web App admin:
# Admin -> System -> Settings, Admin -> System -> Tariffs, Admin -> Appearance.
# Full reference: docs/env-vars.md
# Telegram bot token from @BotFather.
# Example: 1234567890:AA...
BOT_TOKEN=your_bot_token_here
ADMIN_IDS=comma_separated_admin_ids
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_HOST=remnawave-tg-shop-db
POSTGRES_PORT=5432
POSTGRES_DB=postgres
# Telegram numeric user IDs allowed to open the admin panel.
# Use commas for several admins, for example: 123456789,987654321
ADMIN_IDS=123456789
# Localization and Display
DEFAULT_LANGUAGE="ru" # or "en"
DEFAULT_CURRENCY_SYMBOL="RUB" # e.g., RUB, USD, EUR
# External Links
SUPPORT_LINK=https://t.me/your_support_link
SERVER_STATUS_URL=https://status.yourdomain.tld/status/your_service
TERMS_OF_SERVICE_URL=https://example.com/tos
SUBSCRIPTION_MINI_APP_URL=
START_COMMAND_DESCRIPTION=
DISABLE_WELCOME_MESSAGE=
# Webhook Base URL (used for Telegram and payment providers)
# Public HTTPS base URL of the backend webhook server.
# Telegram, payment providers and Remnawave call webhook endpoints under this domain.
# This is usually the backend/API domain, not the Mini App frontend domain.
# Example: https://bot.yourdomain.tld
WEBHOOK_BASE_URL=https://webhooks.yourdomain.tld
# YooKassa Payment Gateway Configuration
YOOKASSA_SHOP_ID=your_shop_id
YOOKASSA_SECRET_KEY=your_secret_key
YOOKASSA_RETURN_URL=https://t.me/your_bot
YOOKASSA_DEFAULT_RECEIPT_EMAIL=your_email@example.com
YOOKASSA_VAT_CODE=1
YOOKASSA_PAYMENT_MODE=full_prepayment
YOOKASSA_PAYMENT_SUBJECT=payment
# PostgreSQL user created by Docker Compose and used by the backend.
POSTGRES_USER=remnawave_minishop
# CryptoBot Payment Gateway Configuration
CRYPTOPAY_TOKEN=
CRYPTOPAY_NETWORK=mainnet
CRYPTOPAY_CURRENCY_TYPE=fiat
CRYPTOPAY_ASSET=RUB
# PostgreSQL password. Change it before production deploy.
POSTGRES_PASSWORD=change_me
# Tribute Payment Gateway Configuration
TRIBUTE_API_KEY= # API key for verifying Tribute webhook signatures
TRIBUTE_SKIP_NOTIFICATIONS=True # Skip renewal notifications for Tribute payments
TRIBUTE_SKIP_CANCELLATION_NOTIFICATIONS=False # Skip cancellation notifications for Tribute payments
# PostgreSQL database name created by Docker Compose.
POSTGRES_DB=remnawave_minishop
# Payment Method Toggles
YOOKASSA_ENABLED=True
STARS_ENABLED=True
TRIBUTE_ENABLED=True
CRYPTOPAY_ENABLED=True
# Enables the Web App and the Web App admin panel.
# Keep True for the first setup. If set to False, the admin UI is unavailable
# until you change it back to True in .env and restart the app.
WEBAPP_ENABLED=True
# Subscription Options
1_MONTH_ENABLED=True
RUB_PRICE_1_MONTH=150
STARS_PRICE_1_MONTH=0
TRIBUTE_LINK_1_MONTH=
# Stable secret for Web App sessions.
# Generate with: openssl rand -hex 32
# If empty, sessions are invalidated on every restart.
WEBAPP_SESSION_SECRET=
3_MONTHS_ENABLED=True
RUB_PRICE_3_MONTHS=300
STARS_PRICE_3_MONTHS=0
TRIBUTE_LINK_3_MONTHS=
# Stable Telegram webhook secret_token.
# Generate with: openssl rand -hex 32
# If empty, a new token can be generated on process start.
WEBHOOK_SECRET_TOKEN=
6_MONTHS_ENABLED=True
RUB_PRICE_6_MONTHS=500
STARS_PRICE_6_MONTHS=0
TRIBUTE_LINK_6_MONTHS=
# Public HTTPS URL of the Mini App frontend, with trailing slash.
# This URL is opened by Telegram buttons and BotFather Mini App settings.
# Do not put /api or webhook paths here.
# Example: https://app.yourdomain.tld/
SUBSCRIPTION_MINI_APP_URL=https://app.yourdomain.tld/
12_MONTHS_ENABLED=True
RUB_PRICE_12_MONTHS=900
STARS_PRICE_12_MONTHS=0
TRIBUTE_LINK_12_MONTHS=
# Remnawave panel API URL. Usually the panel domain plus /api.
# Example: https://panel.yourdomain.tld/api
PANEL_API_URL=https://panel.yourdomain.tld/api
# Subscription Notifications
SUBSCRIPTION_NOTIFICATIONS_ENABLED=True
SUBSCRIPTION_NOTIFY_ON_EXPIRE=True
SUBSCRIPTION_NOTIFY_AFTER_EXPIRE=True
SUBSCRIPTION_NOTIFY_DAYS_BEFORE=3
# Remnawave API key with permissions to manage users, subscriptions and squads.
# Keep this secret. It can be overridden later in the admin panel if needed.
PANEL_API_KEY=
# Referral Bonus Days
REFERRAL_BONUS_DAYS_1_MONTH=3
REFERRAL_BONUS_DAYS_3_MONTHS=7
REFERRAL_BONUS_DAYS_6_MONTHS=15
REFERRAL_BONUS_DAYS_12_MONTHS=30
REFEREE_BONUS_DAYS_1_MONTH=1
REFEREE_BONUS_DAYS_3_MONTHS=3
REFEREE_BONUS_DAYS_6_MONTHS=7
REFEREE_BONUS_DAYS_12_MONTHS=15
# Shared secret for validating incoming Remnawave webhooks.
# Use the same value when configuring the webhook in Remnawave panel.
PANEL_WEBHOOK_SECRET=
# Panel API Configuration
PANEL_API_URL=http://your_panel_api_url/api
PANEL_API_KEY=your_panel_api_key
PANEL_WEBHOOK_SECRET= # secret used to verify panel webhook signatures
# User traffic limits (applied for all users)
# 0 means unlimited
USER_TRAFFIC_LIMIT_GB=0
USER_TRAFFIC_STRATEGY="NO_RESET"
# Default Internal Squads for Users (Optional, comma-separated UUIDs)
USER_SQUAD_UUIDS=uuid1,uuid2,uuid3
# Trial Settings
TRIAL_ENABLED=True
TRIAL_DURATION_DAYS=5
TRIAL_TRAFFIC_LIMIT_GB=0 # 0 for unlimited
TRIAL_TRAFFIC_STRATEGY="NO_RESET"
# Web Server Settings (for handling webhooks)
WEB_SERVER_HOST="0.0.0.0"
# Host port that publishes the backend webhook server from Docker Compose.
# Your reverse proxy should route WEBHOOK_BASE_URL traffic to this port.
WEB_SERVER_PORT=8080
# Admin Panel Log Pagination
LOGS_PAGE_SIZE=10
# Host port that publishes the frontend nginx from Docker Compose.
# Your reverse proxy should route SUBSCRIPTION_MINI_APP_URL traffic to this port.
FRONTEND_PORT=8082
# Admin Logging Configuration
LOG_CHAT_ID=-1001234567890 # Telegram chat/group ID for admin notifications
LOG_THREAD_ID= # Optional: Thread ID for supergroup messages
LOG_NEW_USERS=True
LOG_PAYMENTS=True
LOG_PROMO_ACTIVATIONS=True
LOG_TRIAL_ACTIVATIONS=True
LOG_SUSPICIOUS_ACTIVITY=True
# Inline Mode Thumbnails
INLINE_REFERRAL_THUMBNAIL_URL=https://cdn-icons-png.flaticon.com/512/1077/1077114.png
INLINE_USER_STATS_THUMBNAIL_URL=https://cdn-icons-png.flaticon.com/512/681/681494.png
INLINE_FINANCIAL_STATS_THUMBNAIL_URL=https://cdn-icons-png.flaticon.com/512/2769/2769339.png
INLINE_SYSTEM_STATS_THUMBNAIL_URL=https://cdn-icons-png.flaticon.com/512/2920/2920277.png
# Reverse proxy IPs/CIDRs trusted for X-Forwarded-For.
# Keep loopback for local proxy; add your proxy network if needed.
TRUSTED_PROXIES=127.0.0.1,::1
+2
View File
@@ -0,0 +1,2 @@
*.sh text eol=lf
deploy/docker/frontend/*.sh text eol=lf
-59
View File
@@ -1,59 +0,0 @@
name: Build and Push Dev Docker Image
on:
push:
branches:
- dev
pull_request:
branches:
- dev
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=sha,prefix={{branch}}-
flavor: |
latest=false
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Image digest
run: echo ${{ steps.meta.outputs.digest }}
-44
View File
@@ -1,44 +0,0 @@
name: Build and Publish multi-arch Docker Image
on:
push:
branches:
- main
tags:
- 'v*.*.*'
paths-ignore:
- 'README.md'
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU (для эмуляции arm64 на x86)
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push multi-arch Docker image
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: |
ghcr.io/${{ github.repository }}:latest
ghcr.io/${{ github.repository }}:${{ github.ref_name }}
+50
View File
@@ -3,6 +3,50 @@ bot_database.sqlite3
# Игнорировать файлы окружения
.env
.env.*
!.env.example
docker-compose-dev.yml
scratch_*.py
scratch/
*.local.*
node_modules/
.git/
# WebApp build artifacts (regenerated by `npm run build:webapp` / Docker build)
bot/app/web/templates/subscription_webapp.css
bot/app/web/templates/subscription_webapp.js
bot/app/web/templates/subscription_webapp.min.*.js
bot/app/web/templates/subscription_webapp.*.css
bot/app/web/templates/subscription_webapp.min.*.js.br
bot/app/web/templates/subscription_webapp.min.*.js.gz
bot/app/web/templates/subscription_webapp.*.css.br
bot/app/web/templates/subscription_webapp.*.css.gz
bot/app/web/templates/subscription_webapp_admin.css
bot/app/web/templates/subscription_webapp_admin.js
bot/app/web/templates/subscription_webapp_admin.min.*.js
bot/app/web/templates/subscription_webapp_admin.*.css
bot/app/web/templates/subscription_webapp_admin.min.*.js.br
bot/app/web/templates/subscription_webapp_admin.min.*.js.gz
bot/app/web/templates/subscription_webapp_admin.*.css.br
bot/app/web/templates/subscription_webapp_admin.*.css.gz
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
tmp
.claude
# Игнорировать кэш Python
__pycache__/
@@ -14,3 +58,9 @@ __pycache__/
# Игнорировать Docker артефакты (если вдруг)
*.log
*.pid
locales/ru_backup.json
locales/en_backup.json
db/models_old.py
data/*
!data/tariffs.example.json
!data/locales-overrides.example.json
-20
View File
@@ -1,20 +0,0 @@
FROM python:3.11-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
pip install --no-cache-dir -r requirements.txt
FROM python:3.11-slim
WORKDIR /app
COPY --from=builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
COPY . .
RUN rm -rf /root/.cache
CMD ["python", "main.py"]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025-2026 machka-pasla, 3252a8 and other contributors
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 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.
+109 -159
View File
@@ -1,183 +1,133 @@
# Telegram-бот для продажи подписок Remnawave
# Remnawave Minishop
Этот Telegram-бот предназначен для автоматизации продажи и управления подписками для панели **Remnawave**. Он интегрируется с API Remnawave для управления пользователями и подписками, а также использует различные платежные системы для приема платежей.
![Remnawave Minishop](docs/remnawave-minishop.webp)
## ✨ Ключевые возможности
Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи и управления подписками панели [Remnawave](https://docs.rw/). Бот обрабатывает регистрацию, оплату, продление, пробный период, промокоды, рефералов и поддержку в чате. Web App показывает ссылку подключения, срок действия, трафик, оплату, устройства и вход по Telegram Mini Apps `initData`, Telegram OAuth / OpenID Connect и одноразовому email-коду.
### Для пользователей:
- **Регистрация и выбор языка:** Поддержка русского и английского языков.
- **Просмотр подписки:** Пользователи могут видеть статус своей подписки, дату окончания и ссылку на конфигурацию.
- **Пробная подписка:** Система пробных подписок для новых пользователей (активируется вручную по кнопке).
- **Промокоды:** Возможность применять промокоды для получения скидок или бонусных дней.
- **Реферальная программа:** Пользователи могут приглашать друзей и получать за это бонусные дни подписки.
- **Оплата:** Поддержка оплаты через YooKassa, CryptoPay, Telegram Stars и Tribute.
Проект является переработанным форком [kavore/remnawave-tg-shop](https://github.com/kavore/remnawave-tg-shop). Для переноса данных из прежнего стека используйте [инструкцию по миграции](docs/migration-to-minishop.md).
### Для администраторов:
- **Защищенная админ-панель:** Доступ только для администраторов, указанных в `ADMIN_IDS`.
- **Статистика:** Просмотр статистики использования бота (общее количество пользователей, забаненные, активные подписки), недавние платежи и статус синхронизации с панелью.
- **Управление пользователями:** Блокировка/разблокировка пользователей, просмотр списка забаненных и детальной информации о пользователе.
- **Рассылка:** Отправка сообщений всем пользователям, пользователям с активной или истекшей подпиской.
- **Управление промокодами:** Создание и просмотр промокодов.
- **Синхронизация с панелью:** Ручной запуск синхронизации пользователей и подписок с панелью Remnawave.
- **Логи действий:** Просмотр логов всех действий пользователей.
## Возможности
## 🚀 Технологии
Для пользователей:
- **Python 3.11**
- **Aiogram 3.x:** Асинхронный фреймворк для Telegram ботов.
- **aiohttp:** Для запуска веб-сервера (вебхуки).
- **SQLAlchemy 2.x & asyncpg:** Асинхронная работа с базой данных PostgreSQL.
- **YooKassa, aiocryptopay:** SDK для интеграции с платежными системами.
- **APScheduler:** Для выполнения отложенных задач (например, уведомления об окончании подписки).
- **Pydantic:** Для управления настройками из `.env` файла.
- **Docker & Docker Compose:** Для контейнеризации и развертывания.
- регистрация с выбором русского или английского языка;
- просмотр статуса подписки, даты окончания, ссылки подключения и трафика;
- покупка подписок, пакетов трафика, обычная и premium-докупка трафика, докупка устройств по настроенному каталогу тарифов;
- Web App / Mini App с входом через Telegram или email;
- встроенные инструкции установки в Mini App: личный экран `/install` и публичная ссылка `/s/<token>` для передачи инструкции;
- пробный период, промокоды и реферальная программа;
- оплата через YooKassa, FreeKassa, Platega, SeverPay, Wata, CryptoPay, Heleket и Telegram Stars;
- тикеты поддержки в Web App и внешняя ссылка на поддержку;
- раздел "Мои устройства" при включенном `MY_DEVICES_SECTION_ENABLED`.
## ⚙️ Установка и запуск
Для администраторов:
### Предварительные требования
- админ-панель для пользователей из `ADMIN_IDS` (только при входе через Telegram, не для аккаунтов только с email);
- статистика пользователей, подписок, платежей и синхронизации с Remnawave;
- список пользователей с поиском, фильтрами и колонкой premium-трафика;
- блокировка пользователей, поддержка через тикеты, рассылки, промокоды, логи действий и настройка разрешенных параметров приложения поверх `.env`;
- редактор JSON-каталога тарифов с period/traffic-моделями, Internal Squads, premium-сквадами и HWID-пакетами;
- настройки инструкций подключения: чтение конфига Subscription Page из Remnawave Panel, опциональный JSON-override и переключатель поведения кнопок бота;
- ручная синхронизация пользователей и подписок с панелью.
- Установленные Docker и Docker Compose.
- Рабочая панель Remnawave.
- Токен Telegram-бота.
- Данные для подключения к платежным системам (YooKassa, CryptoPay и т.д.).
## Документация
### Шаги установки
- [Настройка окружения](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) - перенос данных из прежнего стека.
1. **Клонируйте репозиторий:**
```bash
git clone https://github.com/machka-pasla/remnawave-tg-shop
cd remnawave-tg-shop
```
## Совместимость
2. **Создайте и настройте файл `.env`:**
Скопируйте `env.example` в `.env` и заполните своими данными.
```bash
cp .env.example .env
nano .env
```
Ниже перечислены ключевые переменные.
Интеграция с API панели Remnawave (вебхуки, пользователи, подписки, статистика в админке и т.д.) **протестирована** на панели Remnawave версии **`> 2.7.0`**. Более старые версии могут работать частично или не работать из‑за изменений в API.
<details>
<summary><b>Основные настройки</b></summary>
## Стек
| Переменная | Описание | Пример |
| --- | --- | --- |
| `BOT_TOKEN` | **Обязательно.** Токен вашего Telegram-бота. | `1234567890:ABC-DEF1234ghIkl-zyx57W2v1u123ew11` |
| `ADMIN_IDS` | **Обязательно.** ID администраторов в Telegram через запятую. | `12345678,98765432` |
| `DEFAULT_LANGUAGE` | Язык по умолчанию для новых пользователей. | `ru` |
| `SUPPORT_LINK` | (Опционально) Ссылка на поддержку. | `https://t.me/your_support` |
| `SUBSCRIPTION_MINI_APP_URL` | (Опционально) URL Mini App для показа подписки. | `https://t.me/your_bot/app` |
</details>
Сборка и runtime задаются **deploy/docker/Dockerfile** и **docker-compose.yml**; точные версии пакетов — в **backend/requirements.txt** и **frontend/package.json**.
<details>
<summary><b>Настройки платежей и вебхуков</b></summary>
| Слой | Технологии |
| --- | --- |
| Backend | Python **3.12**, [aiogram](https://docs.aiogram.dev/) 3.x (Telegram), **aiohttp** (HTTP и Web App), **SQLAlchemy** 2 async, **asyncpg**, **Pydantic** / pydantic-settings, **httpx**, платёжные SDK (в т.ч. YooKassa, aiocryptopay), **PyJWT** |
| Данные | **PostgreSQL** **17** (сервис `postgres` в Compose) и **Redis** **7** (сервис `redis`) |
| Сборка Web App | **Node.js** **22**, **Svelte** **5**, **Vite**, **Tailwind CSS** 4; артефакты попадают в шаблоны `backend/bot/app/web/templates/` |
| Переменная | Описание |
| --- | --- |
| `WEBHOOK_BASE_URL`| **Обязательно.** Базовый URL для вебхуков, например `https://your.domain.com`. |
| `WEB_SERVER_HOST` | Хост для веб-сервера. | `0.0.0.0` |
| `WEB_SERVER_PORT` | Порт для веб-сервера. | `8080` |
| `YOOKASSA_ENABLED` | Включить/выключить YooKassa (`true`/`false`). |
| `YOOKASSA_SHOP_ID` | ID вашего магазина в YooKassa. |
| `YOOKASSA_SECRET_KEY`| Секретный ключ магазина YooKassa. |
| `CRYPTOPAY_ENABLED` | Включить/выключить CryptoPay (`true`/`false`). |
| `CRYPTOPAY_TOKEN` | Токен из вашего CryptoPay App. |
| `STARS_ENABLED` | Включить/выключить Telegram Stars (`true`/`false`). |
| `TRIBUTE_ENABLED`| Включить/выключить Tribute (`true`/`false`). |
</details>
Локальная разработка без Docker возможна при установленных Python 3.12, PostgreSQL и (для пересборки фронта) Node 22; типичный сценарий — всё через Compose.
<details>
<summary><b>Настройки подписок</b></summary>
## Быстрый старт
Для каждого периода (1, 3, 6, 12 месяцев) можно настроить доступность и цены:
- `1_MONTH_ENABLED`: `true` или `false`
- `RUB_PRICE_1_MONTH`: Цена в рублях
- `STARS_PRICE_1_MONTH`: Цена в Telegram Stars
- `TRIBUTE_LINK_1_MONTH`: Ссылка для оплаты через Tribute
Аналогичные переменные есть для `3_MONTHS`, `6_MONTHS`, `12_MONTHS`.
</details>
Требования:
<details>
<summary><b>Настройки панели Remnawave</b></summary>
| Переменная | Описание |
| --- | --- |
| `PANEL_API_URL` | URL API вашей панели Remnawave. |
| `PANEL_API_KEY` | API ключ для доступа к панели. |
| `PANEL_WEBHOOK_SECRET`| Секретный ключ для проверки вебхуков от панели. |
| `USER_SQUAD_UUIDS` | ID отрядов для новых пользователей. |
| `USER_TRAFFIC_LIMIT_GB`| Лимит трафика в ГБ (0 - безлимит). |
</gidetails>
- Docker и Docker Compose;
- рабочая панель Remnawave версии **`> 2.7.0`** (см. раздел «Совместимость»);
- токен Telegram-бота;
- публичные домены для webhook и Mini App.
<details>
<summary><b>Настройки пробного периода</b></summary>
| Переменная | Описание |
| --- | --- |
| `TRIAL_ENABLED` | Включить/выключить пробный период (`true`/`false`). |
| `TRIAL_DURATION_DAYS`| Длительность пробного периода в днях. |
| `TRIAL_TRAFFIC_LIMIT_GB`| Лимит трафика для пробного периода в ГБ. |
</details>
3. **Запустите контейнеры:**
```bash
docker compose up -d
```
Эта команда скачает образ и запустит сервис в фоновом режиме.
4. **Настройка вебхуков (Обязательно):**
Вебхуки являются **обязательным** компонентом для работы бота, так как они используются для получения уведомлений от платежных систем (YooKassa, CryptoPay, Tribute) и панели Remnawave.
Вам понадобится обратный прокси (например, Nginx) для обработки HTTPS-трафика и перенаправления запросов на контейнер с ботом.
**Пути для перенаправления:**
- `https://<ваш_домен>/webhook/yookassa` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/yookassa`
- `https://<ваш_домен>/webhook/cryptopay` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/cryptopay`
- `https://<ваш_домен>/webhook/tribute` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/tribute`
- `https://<ваш_домен>/webhook/panel` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/panel`
- **Для Telegram:** Бот автоматически установит вебхук, если в `.env` указан `WEBHOOK_BASE_URL`. Путь будет `https://<ваш_домен>/<BOT_TOKEN>`.
Где `remnawave-tg-shop` — это имя сервиса из `docker-compose.yml`, а `<WEB_SERVER_PORT>` — порт, указанный в `.env`.
5. **Просмотр логов:**
```bash
docker compose logs -f remnawave-tg-shop
```
## 🐳 Docker
Файлы `Dockerfile` и `docker-compose.yml` уже настроены для сборки и запуска проекта. `docker-compose.yml` использует готовый образ с GitHub Container Registry, но вы можете раскомментировать `build: .` для локальной сборки.
## 📁 Структура проекта
```
.
├── bot/
│ ├── filters/ # Пользовательские фильтры Aiogram
│ ├── handlers/ # Обработчики сообщений и колбэков
│ ├── keyboards/ # Клавиатуры
│ ├── middlewares/ # Промежуточные слои (i18n, проверка бана)
│ ├── services/ # Бизнес-логика (платежи, API панели)
│ ├── states/ # Состояния FSM
│ └── main_bot.py # Основная логика бота
├── config/
│ └── settings.py # Настройки Pydantic
├── db/
│ ├── dal/ # Слой доступа к данным (DAL)
│ ├── database_setup.py # Настройка БД
│ └── models.py # Модели SQLAlchemy
├── locales/ # Файлы локализации (ru, en)
├── .env.example # Пример файла с переменными окружения
├── Dockerfile # Инструкции для сборки Docker-образа
├── docker-compose.yml # Файл для оркестрации контейнеров
├── requirements.txt # Зависимости Python
└── main.py # Точка входа в приложение
```bash
git clone https://github.com/3252a8/remnawave-minishop
cd remnawave-minishop
cp .env.example .env
nano .env
docker compose up -d --build
docker compose logs -f backend worker frontend
```
## 🔮 Планы на будущее
Минимально заполните в `.env`:
- Расширенные типы промокодов (например, скидки в процентах).
- `BOT_TOKEN` - токен Telegram-бота;
- `ADMIN_IDS` - Telegram ID администраторов через запятую;
- `WEBHOOK_BASE_URL` - публичный URL вебхуков;
- `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB` - доступы PostgreSQL;
- `WEBAPP_ENABLED=True` - включает Web App и админку для первого входа;
- `WEBAPP_SESSION_SECRET`, `WEBHOOK_SECRET_TOKEN` - стабильные секреты;
- `SUBSCRIPTION_MINI_APP_URL` - публичный HTTPS URL Mini App/frontend, например `https://app.domain.com/`;
- `PANEL_API_URL`, `PANEL_API_KEY`, `PANEL_WEBHOOK_SECRET` - доступ к Remnawave;
- остальные настройки удобнее задать в Web App админке.
## ❤️ Поддержка
- Карты РФ и зарубежные: [Tribute](https://t.me/tribute/app?startapp=dqdg)
- Crypto: `USDT TRC-20 TT3SqBbfU4vYm6SUwUVNZsy278m2xbM4GE`
После первого входа в админку настройте тарифы, платежные провайдеры, внешний вид, поддержку, уведомления и инструкции подключения через UI. Инструкции установки включены по умолчанию, читают Subscription Page config из Remnawave Panel и при проблемах с конфигом откатываются к обычной ссылке подключения. Полный справочник env-переменных: [docs/env-vars.md](docs/env-vars.md).
Для каталога тарифов используется `TARIFFS_CONFIG_PATH` со значением по умолчанию `data/tariffs.json`. Пример формата лежит в [data/tariffs.example.json](data/tariffs.example.json), подробности - в [docs/tariffs.md](docs/tariffs.md).
Если в Docker Compose включаете bind mount `./data:/app/data`, заранее создайте каталог и отдайте его пользователю контейнера. Это нужно для сохранения `data/tariffs.json`, каталога тем `data/themes`, кеша логотипа Web App и animated emoji:
```bash
mkdir -p data/themes data/webapp-logo data/webapp-emoji
touch data/locales-overrides.json
chown -R 10001:10001 data
chmod -R u+rwX data
```
## Полезные команды
```bash
# Локальная сборка и запуск
docker compose up -d --build
# Логи приложения
docker compose logs -f backend worker frontend
# Готовые production-примеры
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 up -d
```
Для production-запуска удобнее брать готовые папки из [`deploy/examples`](deploy/examples): там отдельно собраны варианты для Caddy, Nginx, Newt/Pangolin и прямой публикации портов без reverse proxy. В каждой папке рядом лежат `docker-compose.yml`, `.env.example`, README и нужный proxy-конфиг.
GHCR image names for releases:
- `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)`
+12
View File
@@ -0,0 +1,12 @@
import logging
import os
import sys
def configure_logging() -> None:
level = getattr(logging, os.getenv("LOG_LEVEL", "INFO").upper(), logging.INFO)
logging.basicConfig(
level=level,
stream=sys.stdout,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
)
+1
View File
@@ -0,0 +1 @@
@@ -0,0 +1,50 @@
from typing import Dict
from aiogram import Bot, Dispatcher
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from aiogram.fsm.storage.memory import MemoryStorage
from sqlalchemy.orm import sessionmaker
try:
from aiogram.fsm.storage.redis import RedisStorage
except ModuleNotFoundError: # pragma: no cover - dependency is installed in Docker image
RedisStorage = None # type: ignore[assignment]
from bot.middlewares.action_logger_middleware import ActionLoggerMiddleware
from bot.middlewares.ban_check_middleware import BanCheckMiddleware
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 config.settings import Settings
def build_dispatcher(
settings: Settings, async_session_factory: sessionmaker
) -> tuple[Dispatcher, Bot, Dict]:
storage = (
RedisStorage.from_url(settings.REDIS_URL)
if settings.REDIS_URL and RedisStorage is not None
else MemoryStorage()
)
default_props = DefaultBotProperties(parse_mode=ParseMode.HTML)
bot = Bot(token=settings.BOT_TOKEN, default=default_props)
dp = Dispatcher(storage=storage, settings=settings, bot_instance=bot)
i18n_instance = get_i18n_instance(path="locales", default=settings.DEFAULT_LANGUAGE)
dp["i18n_instance"] = i18n_instance
dp["async_session_factory"] = async_session_factory
dp.update.outer_middleware(DBSessionMiddleware(async_session_factory))
dp.update.outer_middleware(I18nMiddleware(i18n=i18n_instance, settings=settings))
dp.update.outer_middleware(ProfileSyncMiddleware())
dp.update.outer_middleware(BanCheckMiddleware(settings=settings, i18n_instance=i18n_instance))
dp.update.outer_middleware(
ChannelSubscriptionMiddleware(settings=settings, i18n_instance=i18n_instance)
)
dp.update.outer_middleware(ActionLoggerMiddleware(settings=settings))
return dp, bot, {"i18n_instance": i18n_instance}
+1
View File
@@ -0,0 +1 @@
@@ -0,0 +1,88 @@
from aiogram import Bot
from sqlalchemy.orm import sessionmaker
from bot.middlewares.i18n import JsonI18n
from bot.payment_providers import (
ServiceFactoryContext,
build_provider_configs,
build_provider_services,
)
from bot.services.email_auth_service import EmailAuthService
from bot.services.lknpd_service import LknpdService
from bot.services.notification_service import NotificationService
from bot.services.panel_api_service import PanelApiService
from bot.services.panel_webhook_service import PanelWebhookService
from bot.services.promo_code_service import PromoCodeService
from bot.services.referral_service import ReferralService
from bot.services.subscription_service import SubscriptionService
from bot.services.support_service import SupportService
from config.settings import Settings
def build_core_services(
settings: Settings,
bot: Bot,
async_session_factory: sessionmaker,
i18n: JsonI18n,
bot_username_for_default_return: str,
):
panel_service = 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, i18n)
notification_service = NotificationService(
bot,
settings,
i18n,
session_factory=async_session_factory,
email_auth_service=email_auth_service,
bot_username=bot_username_for_default_return,
)
support_service = SupportService(
async_session_factory,
settings,
bot,
i18n,
notification_service,
email_auth_service,
)
panel_webhook_service = PanelWebhookService(
bot, settings, i18n, async_session_factory, panel_service
)
provider_configs = build_provider_configs()
payment_services = build_provider_services(
ServiceFactoryContext(
settings=settings,
bot=bot,
async_session_factory=async_session_factory,
i18n=i18n,
bot_username_for_default_return=bot_username_for_default_return,
subscription_service=subscription_service,
referral_service=referral_service,
provider_configs=provider_configs,
)
)
lknpd_service = LknpdService(
settings.LKNPD_INN,
settings.LKNPD_PASSWORD,
api_url=settings.LKNPD_API_URL,
)
# These attachments are critical for auto-renew and panel pre-expiry hooks.
subscription_service.yookassa_service = payment_services.get("yookassa_service")
panel_webhook_service.subscription_service = subscription_service
services = {
"panel_service": panel_service,
"subscription_service": subscription_service,
"referral_service": referral_service,
"promo_code_service": promo_code_service,
"notification_service": notification_service,
"email_auth_service": email_auth_service,
"support_service": support_service,
"panel_webhook_service": panel_webhook_service,
"lknpd_service": lknpd_service,
}
services.update(payment_services)
return services
+1
View File
@@ -0,0 +1 @@
+62
View File
@@ -0,0 +1,62 @@
"""Compatibility facade for the admin Mini App API."""
# ruff: noqa: I001
from bot.app.web.admin_api_impl import (
_runtime as _runtime,
ads as _ads,
auth as _auth,
broadcast as _broadcast,
common as _common,
logs as _logs,
panel as _panel,
payments as _payments,
promos as _promos,
routes as _routes,
settings as _settings,
stats as _stats,
support as _support,
sync as _sync,
tariffs as _tariffs,
themes as _themes,
translations as _translations,
users as _users,
)
_MODULES = (
_runtime,
_auth,
_common,
_stats,
_users,
_payments,
_promos,
_logs,
_support,
_broadcast,
_sync,
_ads,
_settings,
_tariffs,
_themes,
_translations,
_panel,
_routes,
)
_NAMESPACE = {}
for _module in _MODULES:
_NAMESPACE.update(
{
_name: _value
for _name, _value in vars(_module).items()
if not _name.startswith("__") and _name != "annotations"
}
)
for _module in _MODULES:
vars(_module).update(_NAMESPACE)
globals().update(_NAMESPACE)
__all__ = sorted(_name for _name in _NAMESPACE if not _name.startswith("__"))
@@ -0,0 +1 @@
"""Domain modules for the admin Mini App API."""
@@ -0,0 +1,67 @@
# ruff: noqa: F401,F403,F405,I001
"""HTTP API powering the admin section of the subscription Mini App.
All routes require an authenticated webapp session (cookie or Bearer
token) AND the resolved Telegram user id must appear in
``settings.ADMIN_IDS``. Authorization is enforced via the
``_require_admin_user_id`` helper, never trusted from the client.
"""
from __future__ import annotations
import csv
import io
import json
import logging
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from urllib.parse import parse_qsl, urlsplit, urlunsplit
from aiohttp import web
from pydantic import ValidationError
from sqlalchemy import Float, and_, case, cast, or_, select
from sqlalchemy import func as sa_func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import sessionmaker
from bot.app.web.admin_settings_manifest import (
manifest_payload,
)
from bot.infra.webhook_queue import enqueue_webhook_event
from bot.services.referral_service import ReferralService
from bot.services.settings_override_service import (
current_value,
update_overrides,
)
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 db.dal import (
ad_dal,
app_settings_dal,
locale_overrides_dal,
message_log_dal,
panel_sync_dal,
payment_dal,
promo_code_dal,
subscription_dal,
user_dal,
)
from db.models import (
AdCampaign,
MessageLog,
Payment,
PromoCode,
Subscription,
User,
UserTelegramAvatar,
)
logger = logging.getLogger(__name__)
# ─── Auth ──────────────────────────────────────────────────────────
__all__ = [name for name in globals() if not name.startswith("__")]
+69
View File
@@ -0,0 +1,69 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
async def admin_ads_list_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
campaigns = await ad_dal.list_campaigns(session)
totals = await ad_dal.get_totals(session)
results = []
for campaign in campaigns:
try:
stats = await ad_dal.get_campaign_stats(session, campaign.ad_campaign_id)
except Exception:
stats = {}
results.append(_serialize_ad(campaign, stats))
return _ok({"campaigns": results, "totals": totals})
async def admin_ad_create_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
payload = await _read_json(request)
source = str(payload.get("source") or "").strip()
start_param = str(payload.get("start_param") or "").strip()
cost = float(payload.get("cost") or 0.0)
if not source or not start_param:
return _error(400, "invalid_payload")
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
existing = await ad_dal.get_campaign_by_start_param(session, start_param)
if existing:
return _error(409, "duplicate_start_param")
campaign = await ad_dal.create_campaign(
session,
source=source,
start_param=start_param,
cost=cost,
)
await session.commit()
await session.refresh(campaign)
return _ok({"campaign": _serialize_ad(campaign)})
async def admin_ad_toggle_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
campaign_id = int(request.match_info["campaign_id"])
payload = await _read_json(request)
is_active = bool(payload.get("is_active", True))
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
ok = await ad_dal.toggle_campaign_active(session, campaign_id, is_active)
if not ok:
return _error(404, "not_found")
await session.commit()
return _ok({})
async def admin_ad_delete_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
campaign_id = int(request.match_info["campaign_id"])
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
ok = await ad_dal.delete_campaign(session, campaign_id)
if not ok:
return _error(404, "not_found")
await session.commit()
return _ok({})
@@ -0,0 +1,58 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
def _require_admin_user_id(request: web.Request) -> int:
"""Return the authenticated user id, or raise 401/403 for non-admins."""
from bot.app.web.session import extract_authenticated_user_id
settings: Settings = request.app["settings"]
user_id = extract_authenticated_user_id(request)
if not user_id:
raise web.HTTPUnauthorized(
text=json.dumps({"ok": False, "error": "unauthorized"}),
content_type="application/json",
)
admin_ids = settings.ADMIN_IDS or []
db_user_telegram_id = request.get("admin_telegram_id")
if db_user_telegram_id is None:
raise web.HTTPForbidden(
text=json.dumps({"ok": False, "error": "forbidden"}),
content_type="application/json",
)
if int(db_user_telegram_id) not in {int(x) for x in admin_ids}:
raise web.HTTPForbidden(
text=json.dumps({"ok": False, "error": "forbidden"}),
content_type="application/json",
)
return int(user_id)
@web.middleware
async def admin_auth_middleware(request: web.Request, handler):
"""Resolve the Telegram id of the current user and stash it on the request.
Doing this once per request lets every admin route call
``_require_admin_user_id`` without re-querying the DB.
"""
if not request.path.startswith("/api/admin"):
return await handler(request)
from bot.app.web.session import extract_authenticated_user_id
user_id = extract_authenticated_user_id(request)
if user_id:
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
db_user = await user_dal.get_user_by_id(session, user_id)
if db_user and db_user.telegram_id:
request["admin_telegram_id"] = int(db_user.telegram_id)
elif db_user:
# No telegram_id yet (email-only user) — can't be an admin
request["admin_telegram_id"] = None
return await handler(request)
@@ -0,0 +1,54 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
async def admin_broadcast_route(request: web.Request) -> web.Response:
actor_id = _require_admin_user_id(request)
payload = await _read_json(request)
text = str(payload.get("text") or "").strip()
target = str(payload.get("target") or "all").strip().lower()
if not text:
return _error(400, "empty_text")
if target not in {"all", "active", "inactive"}:
target = "all"
queue_manager = get_queue_manager()
if not queue_manager:
return _error(503, "queue_unavailable")
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
if 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)
else:
user_ids = await user_dal.get_all_active_user_ids_for_broadcast(session)
sent = 0
failed = 0
for uid in user_ids:
try:
await send_message_via_queue(
queue_manager,
int(uid),
MessageContent(content_type="text", text=text),
parse_mode="HTML",
disable_web_page_preview=True,
)
sent += 1
except Exception as exc:
failed += 1
logger.debug("Broadcast queue failed for %s: %s", uid, exc)
await message_log_dal.create_message_log(
session,
{
"user_id": actor_id,
"event_type": "admin_broadcast_webapp",
"content": f"target={target} sent={sent} failed={failed} text={text[:120]}",
"is_admin_event": True,
},
)
return _ok({"queued": sent, "failed": failed, "target": target})
@@ -0,0 +1,372 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
def _ok(payload: Dict[str, Any], **extra) -> web.Response:
body = {"ok": True, **payload, **extra}
return web.json_response(body)
def _error(status: int, code: str, message: str = "") -> web.Response:
return web.json_response(
{"ok": False, "error": code, "message": message or code},
status=status,
)
async def _read_json(request: web.Request) -> Dict[str, Any]:
try:
data = await request.json()
return data if isinstance(data, dict) else {}
except Exception:
return {}
def _serialize_user(user: User) -> Dict[str, Any]:
return {
"user_id": int(user.user_id),
"telegram_id": int(user.telegram_id) if user.telegram_id else None,
"telegram_photo_url": user.telegram_photo_url,
"username": user.username,
"first_name": user.first_name,
"last_name": user.last_name,
"email": user.email,
"language_code": user.language_code,
"is_banned": bool(user.is_banned),
"registration_date": user.registration_date.isoformat() if user.registration_date else None,
"panel_user_uuid": user.panel_user_uuid,
"referral_code": user.referral_code,
"referred_by_id": int(user.referred_by_id) if user.referred_by_id else None,
}
def _premium_limit_bytes_from_subscription(sub: Subscription) -> int:
premium_bonus_bytes = int(getattr(sub, "premium_bonus_bytes", 0) or 0)
return (
int(sub.premium_baseline_bytes or 0)
+ int(sub.premium_topup_balance_bytes or 0)
+ int(getattr(sub, "premium_topup_used_bytes", 0) or 0)
+ premium_bonus_bytes
)
def _premium_traffic_list_payload(sub: Optional[Subscription]) -> Dict[str, Any]:
"""Premium traffic column when subscription has a finite premium quota (bytes > 0).
Note: ``Subscription.premium_is_limited`` in the DB means *quota exhausted* for panel
routing, not 'tariff includes premium traffic' — do not use it here.
"""
if sub is None:
return {"state": "none"}
if bool(getattr(sub, "premium_unlimited_override", False)):
return {
"state": "unlimited",
"unlimited": True,
"used_bytes": int(sub.premium_used_bytes or 0),
"limit_bytes": None,
"percent": None,
}
limit_bytes = _premium_limit_bytes_from_subscription(sub)
if limit_bytes <= 0:
return {"state": "none"}
used_bytes = int(sub.premium_used_bytes or 0)
ratio = float(used_bytes) / float(limit_bytes) if limit_bytes else 0.0
pct = int(max(0, min(100, round(ratio * 100))))
if ratio >= 1.0:
state = "critical"
elif ratio >= 0.85:
state = "warn"
else:
state = "good"
return {
"state": state,
"unlimited": False,
"used_bytes": used_bytes,
"limit_bytes": limit_bytes,
"percent": pct,
}
def _serialize_subscription(sub: Subscription) -> Dict[str, Any]:
premium_bonus_bytes = int(getattr(sub, "premium_bonus_bytes", 0) or 0)
regular_bonus_bytes = int(getattr(sub, "regular_bonus_bytes", 0) or 0)
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)
return {
"subscription_id": int(sub.subscription_id),
"panel_user_uuid": sub.panel_user_uuid,
"panel_subscription_uuid": sub.panel_subscription_uuid,
"start_date": sub.start_date.isoformat() if sub.start_date else None,
"end_date": sub.end_date.isoformat() if sub.end_date else None,
"duration_months": sub.duration_months,
"is_active": bool(sub.is_active),
"status_from_panel": sub.status_from_panel,
"traffic_limit_bytes": sub.traffic_limit_bytes,
"traffic_used_bytes": sub.traffic_used_bytes,
"tier_baseline_bytes": sub.tier_baseline_bytes,
"topup_balance_bytes": sub.topup_balance_bytes,
"premium_used_bytes": sub.premium_used_bytes,
"premium_limit_bytes": premium_limit_bytes,
"premium_baseline_bytes": sub.premium_baseline_bytes,
"premium_topup_balance_bytes": sub.premium_topup_balance_bytes,
"premium_topup_used_bytes": getattr(sub, "premium_topup_used_bytes", 0),
"premium_bonus_bytes": premium_bonus_bytes,
"regular_bonus_bytes": regular_bonus_bytes,
"regular_unlimited_override": regular_unlimited_override,
"premium_unlimited_override": premium_unlimited_override,
"premium_is_limited": bool(sub.premium_is_limited),
"tariff_key": sub.tariff_key,
"auto_renew_enabled": bool(sub.auto_renew_enabled),
"provider": sub.provider,
"is_throttled": bool(sub.is_throttled),
}
def _payment_traffic_gb_split(payment: Payment) -> Tuple[Optional[float], Optional[float]]:
"""For traffic purchases: ``(regular_gb, premium_gb)``. Other payments → (None, None)."""
if payment.purchased_gb is None:
return None, None
try:
gb = float(payment.purchased_gb)
except (TypeError, ValueError):
return None, None
sm = (payment.sale_mode or "").strip()
if not sm:
return None, None
base = sm.split("@", 1)[0].split("|", 1)[0].lower()
if base == "premium_topup":
return None, gb
if base in {"traffic", "traffic_package", "topup"}:
return gb, None
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)
tid = getattr(loaded_user, "telegram_id", None)
if 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()
if full:
return full
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
return str(payment_user_id)
def _serialize_payment(payment: Payment) -> Dict[str, Any]:
# Avoid lazy-loading `payment.user` outside an active SQLAlchemy session.
# Some admin routes serialize payments after the session scope is closed.
telegram_id = None
loaded_user = payment.__dict__.get("user")
user_label = _payment_user_display_label(loaded_user, int(payment.user_id))
if loaded_user is not None:
tid = getattr(loaded_user, "telegram_id", None)
if tid is not None:
try:
telegram_id = int(tid)
except (TypeError, ValueError):
telegram_id = None
reg_gb, prem_gb = _payment_traffic_gb_split(payment)
return {
"payment_id": int(payment.payment_id),
"user_id": int(payment.user_id),
"user_label": user_label,
"telegram_id": telegram_id,
"traffic_regular_gb": reg_gb,
"traffic_premium_gb": prem_gb,
"provider": payment.provider,
"provider_payment_id": payment.provider_payment_id,
"amount": float(payment.amount),
"currency": payment.currency,
"status": payment.status,
"description": payment.description,
"subscription_duration_months": payment.subscription_duration_months,
"sale_mode": payment.sale_mode,
"tariff_key": payment.tariff_key,
"purchased_gb": payment.purchased_gb,
"purchased_hwid_devices": payment.purchased_hwid_devices,
"created_at": payment.created_at.isoformat() if payment.created_at else None,
}
def _serialize_promo(promo: PromoCode) -> Dict[str, Any]:
return {
"id": int(promo.promo_code_id),
"code": promo.code,
"bonus_days": int(promo.bonus_days),
"max_activations": int(promo.max_activations),
"current_activations": int(promo.current_activations or 0),
"is_active": bool(promo.is_active),
"valid_until": promo.valid_until.isoformat() if promo.valid_until else None,
"created_at": promo.created_at.isoformat() if promo.created_at else None,
"created_by_admin_id": int(promo.created_by_admin_id)
if promo.created_by_admin_id
else None,
}
def _serialize_ad(campaign: AdCampaign, totals: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
return {
"id": int(campaign.ad_campaign_id),
"source": campaign.source,
"start_param": campaign.start_param,
"cost": float(campaign.cost or 0),
"is_active": bool(campaign.is_active),
"created_at": campaign.created_at.isoformat() if campaign.created_at else None,
"stats": totals or {},
}
def _serialize_log(entry: MessageLog) -> Dict[str, Any]:
return {
"log_id": int(entry.log_id),
"user_id": int(entry.user_id) if entry.user_id else None,
"telegram_username": entry.telegram_username,
"telegram_first_name": entry.telegram_first_name,
"email": getattr(getattr(entry, "author_user", None), "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,
"timestamp": entry.timestamp.isoformat() if entry.timestamp else None,
}
def _tariffs_config_path(settings: Settings) -> Path:
return Path(settings.TARIFFS_CONFIG_PATH).expanduser()
def _tariffs_config_payload(config: TariffsConfig) -> Dict[str, Any]:
return config.model_dump(mode="json", exclude_none=True)
def _write_tariffs_config_file(path: Path, config: TariffsConfig) -> None:
data = _tariffs_config_payload(config)
path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = path.with_suffix(f"{path.suffix}.tmp")
payload = json.dumps(data, ensure_ascii=False, indent=2) + "\n"
try:
tmp_path.write_text(payload, encoding="utf-8")
tmp_path.replace(path)
except PermissionError:
# A docker-compose single-file bind mount can make /app/config
# unwritable while the mounted tariffs.json itself is writable.
# Fall back to updating the existing file in-place.
if tmp_path.exists():
try:
tmp_path.unlink()
except OSError:
pass
path.write_text(payload, encoding="utf-8")
def _webapp_themes_catalog_payload(config: Any) -> Dict[str, Any]:
return config.model_dump(mode="json", exclude_none=True)
def _panel_node_uuid_key(node: Dict[str, Any]) -> str:
uid = node.get("nodeUuid") or node.get("node_uuid") or node.get("uuid") or node.get("id")
return str(uid).strip().lower() if uid else ""
def _panel_node_users_online(node: Dict[str, Any]) -> Optional[int]:
uo = node.get("usersOnline")
if uo is None:
uo = node.get("users_online")
if uo is None:
uo = node.get("onlineUsers") or node.get("online_users")
if uo is None:
mg = node.get("metricGroups")
if isinstance(mg, dict):
uo = mg.get("onlineUsers") or mg.get("online_users")
if uo is None:
return None
try:
return int(uo)
except (TypeError, ValueError):
return None
def _panel_nodes_online_by_uuid(nodes_payload: Any) -> Dict[str, int]:
"""Build node_uuid(lower) -> usersOnline from GET /system/stats/nodes payload."""
out: Dict[str, int] = {}
raw_list: Optional[List[Any]] = None
if isinstance(nodes_payload, list):
raw_list = nodes_payload
elif isinstance(nodes_payload, dict):
raw_list = nodes_payload.get("nodes")
if raw_list is None:
raw_list = nodes_payload.get("items") or nodes_payload.get("data")
if not isinstance(raw_list, list):
return out
for n in raw_list:
if not isinstance(n, dict):
continue
key = _panel_node_uuid_key(n)
if not key:
continue
online = _panel_node_users_online(n)
if online is not None:
out[key] = online
return out
def _enrich_bandwidth_nodes_with_online(
bw: Any,
online_by_uuid: Dict[str, int],
online_by_name: Optional[Dict[str, int]] = None,
) -> None:
"""Attach usersOnline to topNodes/series (UUID and optional node name)."""
if not isinstance(bw, dict):
return
if not online_by_uuid and not online_by_name:
return
for key in ("topNodes", "series"):
arr = bw.get(key)
if not isinstance(arr, list):
continue
for item in arr:
if not isinstance(item, dict):
continue
if item.get("usersOnline") is not None:
continue
uid = item.get("uuid") or item.get("nodeUuid") or item.get("node_uuid")
if uid and online_by_uuid:
hit = online_by_uuid.get(str(uid).strip().lower())
if hit is not None:
item["usersOnline"] = hit
continue
if online_by_name:
nm = item.get("name")
if nm and isinstance(nm, str):
hitn = online_by_name.get(nm.strip().lower())
if hitn is not None:
item["usersOnline"] = hitn
def _build_admin_webapp_referral_link(
base_url: Optional[str], referral_code: Optional[str]
) -> Optional[str]:
"""Mirror of ``subscription_webapp._build_webapp_referral_link``.
Kept local to avoid a cross-module import cycle (subscription_webapp
imports admin_api).
"""
if not base_url or not referral_code:
return None
parts = urlsplit(base_url)
query = dict(parse_qsl(parts.query, keep_blank_values=True))
query["ref"] = f"u{referral_code}"
new_query = "&".join(f"{k}={v}" for k, v in query.items())
return urlunsplit((parts.scheme, parts.netloc, parts.path, new_query, parts.fragment))
@@ -0,0 +1,36 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
async def admin_logs_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
async_session_factory: sessionmaker = request.app["async_session_factory"]
page = max(0, int(request.query.get("page", 0) or 0))
page_size = min(200, max(1, int(request.query.get("page_size", 50) or 50)))
user_filter = request.query.get("user_id")
async with async_session_factory() as session:
if user_filter:
try:
user_id = int(user_filter)
except (TypeError, ValueError):
return _error(400, "invalid_user_id")
entries = await message_log_dal.get_user_message_logs(
session, user_id, page_size, page * page_size
)
total = await message_log_dal.count_user_message_logs(session, user_id)
else:
entries = await message_log_dal.get_all_message_logs(
session, page_size, page * page_size
)
total = await message_log_dal.count_all_message_logs(session)
return _ok(
{
"logs": [_serialize_log(entry) for entry in entries],
"page": page,
"page_size": page_size,
"total": int(total or 0),
}
)
@@ -0,0 +1,35 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
async def admin_panel_internal_squads_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
panel_service = request.app.get("panel_service")
if panel_service is None:
return _error(503, "panel_unavailable", "Panel service unavailable")
try:
squads = await panel_service.get_internal_squads()
except Exception as exc:
logger.exception("Failed to load internal squads from panel")
return _error(502, "panel_request_failed", str(exc))
if squads is None:
return _error(502, "panel_request_failed", "Unable to load internal squads")
items = []
for squad in squads:
if not isinstance(squad, dict):
continue
uuid = squad.get("uuid") or squad.get("id")
if not uuid:
continue
items.append(
{
"uuid": str(uuid),
"name": squad.get("name") or squad.get("title") or str(uuid),
"members_count": squad.get("membersCount")
or squad.get("usersCount")
or squad.get("members_count"),
"active_inbounds_count": squad.get("activeInboundsCount")
or squad.get("active_inbounds_count"),
}
)
return _ok({"squads": items})
@@ -0,0 +1,124 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
async def admin_payments_list_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
async_session_factory: sessionmaker = request.app["async_session_factory"]
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 with async_session_factory() as session:
from sqlalchemy.orm import selectinload
stmt = (
select(Payment)
.options(selectinload(Payment.user))
.order_by(Payment.created_at.desc())
.offset(page * page_size)
.limit(page_size)
)
rows = (await session.execute(stmt)).scalars().all()
total = await payment_dal.get_payments_count(session)
return _ok(
{
"payments": [_serialize_payment(p) for p in rows],
"page": page,
"page_size": page_size,
"total": int(total or 0),
}
)
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"]
async with async_session_factory() as session:
from sqlalchemy.orm import selectinload
stmt = (
select(Payment)
.options(selectinload(Payment.user))
.order_by(Payment.created_at.desc())
.limit(10000)
)
rows = (await session.execute(stmt)).scalars().all()
buffer = io.StringIO()
writer = csv.writer(buffer)
writer.writerow(
[
"payment_id",
"user_id",
"user_label",
"provider",
"provider_payment_id",
"amount",
"currency",
"status",
"description",
"duration_months",
"sale_mode",
"tariff_key",
"created_at",
]
)
for p in rows:
label = _payment_user_display_label(p.user, int(p.user_id)) if p.user else str(p.user_id)
writer.writerow(
[
p.payment_id,
p.user_id,
label,
p.provider,
p.provider_payment_id or "",
p.amount,
p.currency,
p.status,
p.description or "",
p.subscription_duration_months or "",
p.sale_mode or "",
p.tariff_key or "",
p.created_at.isoformat() if p.created_at else "",
]
)
response = web.Response(
body=buffer.getvalue().encode("utf-8-sig"),
content_type="text/csv",
charset="utf-8",
)
response.headers["Content-Disposition"] = 'attachment; filename="payments.csv"'
return response
@@ -0,0 +1,96 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
async def admin_promos_list_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
async_session_factory: sessionmaker = request.app["async_session_factory"]
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 with async_session_factory() as session:
promos = await promo_code_dal.get_all_promo_codes_with_details(
session, limit=page_size, offset=page * page_size
)
total = await promo_code_dal.get_promo_codes_count(session)
return _ok(
{
"promos": [_serialize_promo(p) for p in promos],
"page": page,
"page_size": page_size,
"total": int(total or 0),
}
)
async def admin_promo_create_route(request: web.Request) -> web.Response:
actor_id = _require_admin_user_id(request)
payload = await _read_json(request)
code = str(payload.get("code") or "").strip().upper()
bonus_days = int(payload.get("bonus_days") or 0)
max_activations = int(payload.get("max_activations") or 0)
valid_days = payload.get("valid_days")
if not code or bonus_days <= 0 or max_activations <= 0:
return _error(400, "invalid_payload")
valid_until = None
if valid_days:
try:
valid_until = datetime.now(timezone.utc) + timedelta(days=int(valid_days))
except (TypeError, ValueError):
return _error(400, "invalid_valid_days")
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
existing = await promo_code_dal.get_promo_code_by_code(session, code)
if existing:
return _error(409, "duplicate_code")
promo = await promo_code_dal.create_promo_code(
session,
{
"code": code,
"bonus_days": bonus_days,
"max_activations": max_activations,
"valid_until": valid_until,
"created_by_admin_id": actor_id,
"is_active": True,
},
)
await session.commit()
return _ok({"promo": _serialize_promo(promo)})
async def admin_promo_update_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
promo_id = int(request.match_info["promo_id"])
payload = await _read_json(request)
update_data: Dict[str, Any] = {}
if "is_active" in payload:
update_data["is_active"] = bool(payload["is_active"])
if "bonus_days" in payload and payload["bonus_days"] is not None:
update_data["bonus_days"] = int(payload["bonus_days"])
if "max_activations" in payload and payload["max_activations"] is not None:
update_data["max_activations"] = int(payload["max_activations"])
if not update_data:
return _error(400, "no_changes")
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
promo = await promo_code_dal.update_promo_code(session, promo_id, update_data)
if not promo:
return _error(404, "not_found")
await session.commit()
await session.refresh(promo)
return _ok({"promo": _serialize_promo(promo)})
async def admin_promo_delete_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
promo_id = int(request.match_info["promo_id"])
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
promo = await promo_code_dal.delete_promo_code(session, promo_id)
if not promo:
return _error(404, "not_found")
await session.commit()
return _ok({})
@@ -0,0 +1,78 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
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/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+}/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)
router.add_post(
"/api/admin/users/{user_id:-?\\d+}/message/preview", admin_user_message_preview_route
)
router.add_post(
"/api/admin/users/{user_id:-?\\d+}/telegram-profile-link",
admin_user_telegram_profile_link_route,
)
router.add_post("/api/admin/users/{user_id:-?\\d+}/reset-trial", admin_user_reset_trial_route)
router.add_post("/api/admin/users/{user_id:-?\\d+}/extend", admin_user_extend_route)
router.add_post(
"/api/admin/users/{user_id:-?\\d+}/premium-override",
admin_user_premium_override_route,
)
router.add_post(
"/api/admin/users/{user_id:-?\\d+}/regular-traffic-override",
admin_user_regular_traffic_override_route,
)
router.add_post(
"/api/admin/users/{user_id:-?\\d+}/traffic-grant",
admin_user_traffic_grant_route,
)
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)
router.add_post("/api/admin/promos", admin_promo_create_route)
router.add_patch("/api/admin/promos/{promo_id:\\d+}", admin_promo_update_route)
router.add_delete("/api/admin/promos/{promo_id:\\d+}", admin_promo_delete_route)
router.add_get("/api/admin/logs", admin_logs_route)
router.add_get("/api/admin/support/tickets", admin_support_tickets_route)
router.add_get("/api/admin/support/tickets/{id:\\d+}", admin_support_ticket_detail_route)
router.add_post(
"/api/admin/support/tickets/{id:\\d+}/messages",
admin_support_ticket_reply_route,
)
router.add_patch("/api/admin/support/tickets/{id:\\d+}", admin_support_ticket_patch_route)
router.add_post("/api/admin/support/tickets/{id:\\d+}/read", admin_support_ticket_read_route)
router.add_get("/api/admin/support/stats", admin_support_stats_route)
router.add_post("/api/admin/broadcast", admin_broadcast_route)
router.add_post("/api/admin/sync", admin_sync_route)
router.add_get("/api/admin/ads", admin_ads_list_route)
router.add_post("/api/admin/ads", admin_ad_create_route)
router.add_post("/api/admin/ads/{campaign_id:\\d+}/toggle", admin_ad_toggle_route)
router.add_delete("/api/admin/ads/{campaign_id:\\d+}", admin_ad_delete_route)
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)
router.add_get("/api/admin/themes", admin_themes_get_route)
router.add_put("/api/admin/themes", admin_themes_save_route)
router.add_post("/api/admin/appearance/logo", admin_appearance_logo_upload_route)
router.add_post("/api/admin/appearance/favicon", admin_appearance_favicon_upload_route)
router.add_get("/api/admin/panel/internal-squads", admin_panel_internal_squads_route)
@@ -0,0 +1,105 @@
# 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:
_require_admin_user_id(request)
settings: Settings = request.app["settings"]
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
overrides = await app_settings_dal.get_overrides_with_meta(session)
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"]
section_id = field["section"]
if section_id not in sections:
sections[section_id] = {
"id": section_id,
"order": field["section_order"],
"fields": [],
}
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": 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"])
return _ok({"sections": ordered_sections})
async def admin_settings_patch_route(request: web.Request) -> web.Response:
actor_id = _require_admin_user_id(request)
settings: Settings = request.app["settings"]
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")
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,
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,
)
await refresh_webapp_runtime_after_settings_change(request, updates=updates, deletes=deletes)
return _ok({"applied": result.get("applied", 0), "reverted": result.get("reverted", 0)})
+168
View File
@@ -0,0 +1,168 @@
# ruff: noqa: F401,F403,F405,I001
import asyncio
from ._runtime import * # noqa: F403,F405
from .auth import _require_admin_user_id
from .common import _ok, _serialize_payment
from bot.utils.ttl_cache import AsyncTTLCache
_ADMIN_PANEL_STATS_CACHES: Dict[tuple[int, int], AsyncTTLCache] = {}
_ADMIN_DB_STATS_CACHES: Dict[tuple[int, int], AsyncTTLCache] = {}
async def admin_me_route(request: web.Request) -> web.Response:
user_id = _require_admin_user_id(request)
settings: Settings = request.app["settings"]
return _ok({}, user_id=user_id, admin_ids=list(settings.ADMIN_IDS or []))
async def admin_stats_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
settings: Settings = request.app["settings"]
async_session_factory: sessionmaker = request.app["async_session_factory"]
payload = dict(await _load_admin_db_stats(settings, async_session_factory))
panel_service = request.app.get("panel_service")
if panel_service is not None:
payload["panel"] = await _load_admin_panel_stats(request, settings, panel_service)
queue_manager = get_queue_manager()
if queue_manager:
try:
payload["queue"] = queue_manager.get_queue_stats()
except Exception: # pragma: no cover - defensive
payload["queue"] = None
payload["currency_symbol"] = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
return _ok(payload)
async def _load_admin_db_stats(
settings: Settings,
async_session_factory: sessionmaker,
) -> Dict[str, Any]:
cache = _admin_db_stats_cache(settings)
if cache is None:
return await _load_admin_db_stats_uncached(async_session_factory)
return await cache.get_or_load(
"db",
lambda: _load_admin_db_stats_uncached(async_session_factory),
)
async def _load_admin_db_stats_uncached(async_session_factory: sessionmaker) -> Dict[str, Any]:
async with async_session_factory() as session:
user_stats = await user_dal.get_enhanced_user_statistics(session)
financial_stats = await payment_dal.get_financial_statistics(session)
sync_status = await panel_sync_dal.get_panel_sync_status(session)
recent_payments = await payment_dal.get_recent_payment_logs_with_user(session, limit=10)
return {
"users": user_stats,
"financial": financial_stats,
"panel_sync": {
"status": sync_status.status if sync_status else "never_run",
"last_sync_time": sync_status.last_sync_time.isoformat()
if sync_status and sync_status.last_sync_time
else None,
"details": sync_status.details if sync_status else None,
"users_processed": sync_status.users_processed_from_panel if sync_status else 0,
"subscriptions_synced": sync_status.subscriptions_synced if sync_status else 0,
},
"recent_payments": [_serialize_payment(p) for p in recent_payments],
}
def _admin_db_stats_cache(settings: Settings) -> Optional[AsyncTTLCache]:
ttl_seconds = int(getattr(settings, "ADMIN_DB_STATS_CACHE_TTL_SECONDS", 5) or 0)
if ttl_seconds <= 0:
return None
cache_key = (id(settings), ttl_seconds)
cache = _ADMIN_DB_STATS_CACHES.get(cache_key)
if cache is None:
cache = AsyncTTLCache(
ttl_seconds=ttl_seconds,
settings=settings,
namespace="admin:db_stats",
)
_ADMIN_DB_STATS_CACHES[cache_key] = cache
return cache
async def _load_admin_panel_stats(
request: web.Request,
settings: Settings,
panel_service,
) -> Dict[str, Any]:
cache = _admin_panel_stats_cache(settings)
if cache is None:
return await _load_admin_panel_stats_uncached(panel_service)
return await cache.get_or_load("panel", lambda: _load_admin_panel_stats_uncached(panel_service))
def _admin_panel_stats_cache(settings: Settings) -> Optional[AsyncTTLCache]:
ttl_seconds = int(getattr(settings, "ADMIN_PANEL_STATS_CACHE_TTL_SECONDS", 15) or 0)
if ttl_seconds <= 0:
return None
cache_key = (id(settings), ttl_seconds)
cache = _ADMIN_PANEL_STATS_CACHES.get(cache_key)
if cache is None:
cache = AsyncTTLCache(
ttl_seconds=ttl_seconds,
settings=settings,
namespace="admin:panel_stats",
)
_ADMIN_PANEL_STATS_CACHES[cache_key] = cache
return cache
async def _load_admin_panel_stats_uncached(panel_service) -> Dict[str, Any]:
try:
today = datetime.now(timezone.utc).date()
start_d = today - timedelta(days=7)
system, bandwidth, nodes, nodes_bw, lookups = await asyncio.gather(
_safe_panel_call(panel_service.get_system_stats(), "system stats"),
_safe_panel_call(panel_service.get_bandwidth_stats(), "bandwidth stats"),
_safe_panel_call(panel_service.get_nodes_statistics(), "nodes stats"),
_safe_panel_call(
panel_service.get_nodes_bandwidth_usage(
start=start_d.isoformat(),
end=today.isoformat(),
top_nodes_limit=64,
),
"nodes bandwidth range",
),
_safe_panel_call(panel_service.get_nodes_online_lookups(), "nodes online lookups"),
)
panel_body: Dict[str, Any] = {
"system": system or {},
"bandwidth": bandwidth or {},
"nodes": nodes or {},
"nodes_bandwidth": nodes_bw or {},
}
if isinstance(lookups, dict):
try:
online_map = _panel_nodes_online_by_uuid(panel_body.get("nodes"))
for k, v in lookups.get("byUuid", {}).items():
online_map[k] = v
_enrich_bandwidth_nodes_with_online(
panel_body.get("nodes_bandwidth"),
online_map,
lookups.get("byName") or {},
)
except Exception as exc_merge: # pragma: no cover
logger.debug("Panel nodes online merge skipped: %s", exc_merge)
return panel_body
except Exception as exc:
logger.debug("Panel stats unavailable: %s", exc)
return {"error": "unavailable"}
async def _safe_panel_call(awaitable, label: str) -> Any:
try:
return await awaitable
except Exception as exc: # pragma: no cover - optional panel endpoints
logger.debug("Panel %s unavailable: %s", label, exc)
return None
@@ -0,0 +1,251 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
from typing import Literal, Optional
from pydantic import BaseModel, ConfigDict, constr, field_validator
from bot.services.support_service import TicketNotFound
from db.dal import support_dal, user_dal
from db.models import SupportTicket, SupportTicketMessage
class AdminTicketReplyPayload(BaseModel):
model_config = ConfigDict(extra="ignore")
body: constr(min_length=1, max_length=4000)
is_internal_note: bool = False
@field_validator("body")
@classmethod
def _strip_body(cls, value: str) -> str:
stripped = value.strip()
if not stripped:
raise ValueError("empty_text")
return stripped
class AdminTicketPatchPayload(BaseModel):
model_config = ConfigDict(extra="ignore")
status: Optional[Literal["open", "awaiting_user", "awaiting_admin", "resolved", "closed"]] = (
None
)
priority: Optional[Literal["low", "normal", "high", "urgent"]] = None
category: Optional[Literal["billing", "technical", "account", "other"]] = None
assigned_admin_id: Optional[int] = None
def _validate_model_payload(model_cls, payload: Dict[str, Any]):
try:
return model_cls.model_validate(payload), None
except ValidationError:
return None, _error(400, "invalid_request", "Invalid request")
def _support_ticket_payload(ticket: SupportTicket) -> Dict[str, Any]:
return {
"ticket_id": ticket.ticket_id,
"user_id": ticket.user_id,
"subject": ticket.subject,
"category": ticket.category,
"priority": ticket.priority,
"status": ticket.status,
"assigned_admin_id": ticket.assigned_admin_id,
"last_message_at": ticket.last_message_at.isoformat() if ticket.last_message_at else None,
"last_message_role": ticket.last_message_role,
"unread_user_count": int(ticket.unread_user_count or 0),
"unread_admin_count": int(ticket.unread_admin_count or 0),
"created_at": ticket.created_at.isoformat() if ticket.created_at else None,
"updated_at": ticket.updated_at.isoformat() if ticket.updated_at else None,
"closed_at": ticket.closed_at.isoformat() if ticket.closed_at else None,
}
def _user_display_name(user) -> Optional[str]:
if not user:
return None
name = " ".join(
part.strip() for part in [user.first_name, user.last_name] if part and part.strip()
).strip()
return name or user.username or user.email or str(user.user_id)
def _support_message_payload(
message: SupportTicketMessage,
*,
authors: Optional[Dict[int, Any]] = None,
) -> Dict[str, Any]:
author = authors.get(message.author_user_id) if authors and message.author_user_id else None
return {
"message_id": message.message_id,
"ticket_id": message.ticket_id,
"author_role": message.author_role,
"author_user_id": message.author_user_id,
"author_name": _user_display_name(author),
"body": message.body,
"is_internal_note": bool(message.is_internal_note),
"created_at": message.created_at.isoformat() if message.created_at else None,
"read_by_user_at": message.read_by_user_at.isoformat() if message.read_by_user_at else None,
"read_by_admin_at": message.read_by_admin_at.isoformat()
if message.read_by_admin_at
else None,
}
def _admin_support_user_payload(user) -> Dict[str, Any]:
if not user:
return {}
return {
"user_id": user.user_id,
"telegram_id": user.telegram_id,
"username": user.username,
"first_name": user.first_name,
"last_name": user.last_name,
"email": user.email,
"telegram_photo_url": user.telegram_photo_url,
"is_banned": bool(user.is_banned),
"registration_date": user.registration_date.isoformat() if user.registration_date else None,
}
def _support_limit_offset(request: web.Request) -> tuple[int, int]:
limit = max(1, min(100, int(request.query.get("limit", 25) or 25)))
offset = max(0, int(request.query.get("offset", 0) or 0))
return limit, offset
async def admin_support_tickets_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
limit, offset = _support_limit_offset(request)
assigned_raw = request.query.get("assigned")
assigned_admin_id = None
if assigned_raw and assigned_raw not in {"all", "any"}:
assigned_admin_id = int(assigned_raw)
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
tickets = await support_dal.list_admin_tickets(
session,
status=request.query.get("status") or None,
priority=request.query.get("priority") or None,
category=request.query.get("category") or None,
assigned_admin_id=assigned_admin_id,
search=request.query.get("search") or None,
sort=request.query.get("sort") or "updated_desc",
limit=limit,
offset=offset,
)
return web.json_response(
{
"ok": True,
"tickets": [
{
**_support_ticket_payload(ticket),
"user": _admin_support_user_payload(getattr(ticket, "user", None)),
}
for ticket in tickets
],
}
)
async def admin_support_ticket_detail_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
ticket_id = int(request.match_info["id"])
async_session_factory: sessionmaker = request.app["async_session_factory"]
service = request.app["support_service"]
async with async_session_factory() as session:
ticket, messages = await support_dal.get_ticket(session, ticket_id, include_internal=True)
if not ticket:
return _error(404, "not_found", "Ticket not found")
user = await user_dal.get_user_by_id(session, ticket.user_id)
snapshot = await service.build_user_snapshot(user, session=session) if user else {}
author_ids = {m.author_user_id for m in messages if m.author_user_id is not None}
authors = {}
for author_id in author_ids:
author = await user_dal.get_user_by_id(session, author_id)
if author:
authors[author_id] = author
return web.json_response(
{
"ok": True,
"ticket": {
**_support_ticket_payload(ticket),
"user": _admin_support_user_payload(user),
},
"messages": [_support_message_payload(m, authors=authors) for m in messages],
"user_snapshot": snapshot,
}
)
async def admin_support_ticket_reply_route(request: web.Request) -> web.Response:
admin_id = _require_admin_user_id(request)
ticket_id = int(request.match_info["id"])
payload, error = _validate_model_payload(AdminTicketReplyPayload, await _read_json(request))
if error:
return error
try:
ticket, message = await request.app["support_service"].reply_as_admin(
admin_id,
ticket_id,
payload.body,
is_internal_note=payload.is_internal_note,
)
except TicketNotFound:
return _error(404, "not_found", "Ticket not found")
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
admin = await user_dal.get_user_by_id(session, admin_id)
return web.json_response(
{
"ok": True,
"ticket": _support_ticket_payload(ticket),
"message": _support_message_payload(
message, authors={admin_id: admin} if admin else {}
),
}
)
async def admin_support_ticket_patch_route(request: web.Request) -> web.Response:
admin_id = _require_admin_user_id(request)
ticket_id = int(request.match_info["id"])
payload, error = _validate_model_payload(AdminTicketPatchPayload, await _read_json(request))
if error:
return error
updates = payload.model_dump(exclude_unset=True)
try:
if updates.get("status") == "closed":
ticket = await request.app["support_service"].close_ticket(admin_id, ticket_id)
updates.pop("status", None)
if updates:
ticket = await request.app["support_service"]._update_and_audit(
admin_id,
ticket_id,
**updates,
)
else:
ticket = await request.app["support_service"]._update_and_audit(
admin_id,
ticket_id,
**updates,
)
except TicketNotFound:
return _error(404, "not_found", "Ticket not found")
return web.json_response({"ok": True, "ticket": _support_ticket_payload(ticket)})
async def admin_support_ticket_read_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
ticket_id = int(request.match_info["id"])
await request.app["support_service"].mark_read_as_admin(ticket_id)
return web.json_response({"ok": True})
async def admin_support_stats_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
stats = await support_dal.admin_stats(session)
return web.json_response({"ok": True, "stats": stats})
@@ -0,0 +1,16 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
async def admin_sync_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
settings: Settings = request.app["settings"]
queued = await enqueue_webhook_event(
settings,
"panel_sync",
{"requested_by": _require_admin_user_id(request)},
event_id=None,
)
if queued:
return _ok({"result": {"status": "queued"}})
return _error(503, "queue_unavailable")
@@ -0,0 +1,61 @@
# 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:
_require_admin_user_id(request)
settings: Settings = request.app["settings"]
path = _tariffs_config_path(settings)
try:
config = settings.tariffs_config
except Exception as exc:
logger.warning("Invalid tariffs config requested from admin UI: %s", exc)
return _error(400, "invalid_tariffs_config", str(exc))
if config is None:
return _ok(
{
"exists": path.exists(),
"path": str(path),
"catalog": {
"default_tariff": "",
"topup_packages_default": {"rub": [], "stars": []},
"tariffs": [],
},
}
)
return _ok(
{
"exists": True,
"path": str(path),
"catalog": _tariffs_config_payload(config),
}
)
async def admin_tariffs_save_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
settings: Settings = request.app["settings"]
payload = await _read_json(request)
catalog = payload.get("catalog") if "catalog" in payload else payload
if not isinstance(catalog, dict):
return _error(400, "invalid_payload", "catalog must be an object")
try:
config = TariffsConfig.model_validate(catalog)
except (ValidationError, ValueError) as exc:
return _error(400, "invalid_tariffs_config", str(exc))
path = _tariffs_config_path(settings)
try:
_write_tariffs_config_file(path, config)
except OSError as exc:
logger.exception("Failed to write tariffs config to %s", path)
return _error(500, "write_failed", str(exc))
await refresh_webapp_runtime_after_settings_change(request, updates={}, deletes=[])
return _ok({"exists": True, "path": str(path), "catalog": _tariffs_config_payload(config)})
@@ -0,0 +1,489 @@
# 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
import ipaddress
import shutil
import re
import socket
from aiohttp import ClientSession, ClientTimeout
from PIL import Image, ImageOps, UnidentifiedImageError
from config.webapp_themes_config import (
WebappThemesConfig,
ensure_webapp_core_themes,
resolved_webapp_themes_catalog,
write_webapp_theme_dir,
)
WEBAPP_LOGO_MAX_BYTES = 2 * 1024 * 1024
WEBAPP_UPLOADED_LOGO_DIR = Path(__file__).resolve().parents[5] / "data" / "webapp-logo" / "uploads"
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",
".ico": "image/x-icon",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".svg": "image/svg+xml",
".webp": "image/webp",
}
def _theme_payload_for_version_compare(theme: Any) -> Dict[str, Any]:
if hasattr(theme, "model_dump"):
data = theme.model_dump(mode="json", exclude_none=True)
elif isinstance(theme, dict):
data = dict(theme)
else:
data = {}
data.pop("assets_version", None)
data.pop("default", None)
return data
def _bump_theme_asset_versions(
config: WebappThemesConfig,
previous: WebappThemesConfig,
) -> WebappThemesConfig:
previous_by_key = {theme.key: theme for theme in previous.themes}
default_changed = config.default_theme != previous.default_theme
data = config.model_dump(mode="json", exclude_none=True)
for theme in data.get("themes", []):
if not isinstance(theme, dict):
continue
if not str(theme.get("css_file") or "").strip():
continue
key = str(theme.get("key") or "")
previous_theme = previous_by_key.get(key)
previous_version = int(getattr(previous_theme, "assets_version", 0) or 0)
current_version = int(theme.get("assets_version") or 1)
theme_changed = previous_theme is None or _theme_payload_for_version_compare(
theme
) != _theme_payload_for_version_compare(previous_theme)
if theme_changed or (default_changed and key == config.default_theme):
theme["assets_version"] = max(previous_version + 1, current_version, 1)
elif previous_version > current_version:
theme["assets_version"] = previous_version
return WebappThemesConfig.model_validate(data)
def _detect_logo_extension(
body: bytes, content_type: str = "", filename: str = ""
) -> Optional[str]:
content_type = (content_type or "").split(";", 1)[0].strip().lower()
suffix = Path(filename or "").suffix.lower()
if content_type == "image/png" or body.startswith(b"\x89PNG\r\n\x1a\n"):
return ".png"
if content_type == "image/jpeg" or body.startswith(b"\xff\xd8\xff"):
return ".jpg"
if content_type == "image/gif" or body.startswith((b"GIF87a", b"GIF89a")):
return ".gif"
if content_type == "image/webp" or (
len(body) > 12 and body[:4] == b"RIFF" and body[8:12] == b"WEBP"
):
return ".webp"
if content_type in {"image/svg+xml", "image/svg"} or suffix == ".svg":
head = body[:512].lstrip().lower()
if head.startswith(b"<svg") or b"<svg" in head:
return ".svg"
if content_type == "image/x-icon" or suffix == ".ico":
if body.startswith(b"\x00\x00\x01\x00"):
return ".ico"
return suffix if suffix in WEBAPP_LOGO_UPLOAD_CONTENT_TYPES else None
def _write_uploaded_logo(body: bytes, content_type: str = "", filename: str = "") -> str:
if not body or len(body) > WEBAPP_LOGO_MAX_BYTES:
raise ValueError("logo must be a non-empty image up to 2 MiB")
ext = _detect_logo_extension(body, content_type, filename)
if ext not in WEBAPP_LOGO_UPLOAD_CONTENT_TYPES:
raise ValueError("unsupported image type")
digest = hashlib.sha256(body).hexdigest()[:16]
safe_name = f"logo-{digest}{ext}"
WEBAPP_UPLOADED_LOGO_DIR.mkdir(parents=True, exist_ok=True)
(WEBAPP_UPLOADED_LOGO_DIR / safe_name).write_bytes(body)
return f"{WEBAPP_UPLOADED_LOGO_PATH}/{safe_name}"
def _uploaded_logo_filename(url: str) -> Optional[str]:
parsed = urlsplit(str(url or ""))
path = parsed.path if parsed.scheme or parsed.netloc else str(url or "")
prefix = f"{WEBAPP_UPLOADED_LOGO_PATH}/"
if not path.startswith(prefix):
return None
filename = path.removeprefix(prefix)
if re.fullmatch(r"logo-[0-9a-f]{16}\.(?:gif|ico|jpe?g|png|svg|webp)", filename):
return filename
return None
def _favicon_digest(url: str) -> Optional[str]:
parsed = urlsplit(str(url or ""))
path = parsed.path if parsed.scheme or parsed.netloc else str(url or "")
match = re.fullmatch(
rf"{re.escape(WEBAPP_FAVICON_PATH)}/([0-9a-f]{{16}})/(?:[A-Za-z0-9_.-]+)",
path,
)
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
for filename in [
_uploaded_logo_filename(getattr(settings, "WEBAPP_LOGO_URL", "")),
]
if filename
}
keep_favicons = {
digest
for digest in [
_favicon_digest(getattr(settings, "WEBAPP_FAVICON_URL", "")),
_favicon_digest(getattr(settings, "WEBAPP_LOGO_FAVICON_URL", "")),
]
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:
try:
path.unlink()
except OSError:
logger.warning("Failed to remove unused webapp logo %s", path, exc_info=True)
for path in WEBAPP_FAVICON_DIR.glob("*"):
if (
path.is_dir()
and re.fullmatch(r"[0-9a-f]{16}", path.name)
and path.name not in keep_favicons
):
try:
shutil.rmtree(path)
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,
updates: Dict[str, Any],
actor_id: int,
) -> bool:
settings: Settings = request.app["settings"]
async_session_factory: sessionmaker = request.app["async_session_factory"]
result = await update_overrides(
settings,
async_session_factory,
updates=updates,
deletes=[],
actor_id=actor_id,
)
if not result.get("ok"):
logger.warning("Failed to persist uploaded appearance asset settings: %s", result)
return False
await refresh_webapp_runtime_after_settings_change(request, updates=updates, deletes=[])
return True
def _image_to_square_icon(source: Image.Image, size: int) -> Image.Image:
fitted = source.copy()
fitted.thumbnail((size, size), Image.Resampling.LANCZOS)
canvas = Image.new("RGBA", (size, size), (0, 0, 0, 0))
left = (size - fitted.width) // 2
top = (size - fitted.height) // 2
canvas.alpha_composite(fitted, (left, top))
return canvas
def _write_favicon_set(body: bytes, content_type: str = "", filename: str = "") -> Dict[str, Any]:
if not body or len(body) > WEBAPP_LOGO_MAX_BYTES:
raise ValueError("favicon source must be a non-empty image up to 2 MiB")
ext = _detect_logo_extension(body, content_type, filename)
digest = hashlib.sha256(body).hexdigest()[:16]
target_dir = WEBAPP_FAVICON_DIR / digest
target_dir.mkdir(parents=True, exist_ok=True)
if ext == ".svg":
safe_name = "favicon.svg"
(target_dir / safe_name).write_bytes(body)
return {
"favicon_url": f"{WEBAPP_FAVICON_PATH}/{digest}/{safe_name}",
"variants": {"svg": f"{WEBAPP_FAVICON_PATH}/{digest}/{safe_name}"},
}
try:
with Image.open(io.BytesIO(body)) as image:
image.seek(0)
source = ImageOps.exif_transpose(image).convert("RGBA")
except (OSError, UnidentifiedImageError, ValueError) as exc:
raise ValueError("favicon source must be a raster image") from exc
if source.width < 1 or source.height < 1 or source.width > 8192 or source.height > 8192:
raise ValueError("favicon source dimensions are not supported")
variants: Dict[str, str] = {}
png_icons: Dict[int, Image.Image] = {}
for size in WEBAPP_FAVICON_SIZES:
icon = _image_to_square_icon(source, size)
png_icons[size] = icon
filename = f"icon-{size}.png"
icon.save(target_dir / filename, format="PNG", optimize=True)
variants[f"{size}"] = f"{WEBAPP_FAVICON_PATH}/{digest}/{filename}"
png_icons[180].save(target_dir / "apple-touch-icon.png", format="PNG", optimize=True)
variants["apple_touch"] = f"{WEBAPP_FAVICON_PATH}/{digest}/apple-touch-icon.png"
png_icons[32].save(
target_dir / "favicon.ico",
format="ICO",
sizes=[(16, 16), (32, 32), (48, 48)],
)
variants["ico"] = f"{WEBAPP_FAVICON_PATH}/{digest}/favicon.ico"
return {
"favicon_url": variants["180"],
"variants": variants,
}
async def _read_uploaded_logo_file(request: web.Request) -> tuple[bytes, str, str]:
reader = await request.multipart()
async for part in reader:
if part.name != "file":
continue
body = bytearray()
while True:
chunk = await part.read_chunk(size=64 * 1024)
if not chunk:
break
body.extend(chunk)
if len(body) > WEBAPP_LOGO_MAX_BYTES:
raise ValueError("logo must be up to 2 MiB")
return bytes(body), part.headers.get("Content-Type", ""), part.filename or ""
raise ValueError("file field is required")
async def _hostname_resolves_to_public_address(hostname: str) -> bool:
if not hostname:
return False
try:
ip_obj = ipaddress.ip_address(hostname)
return not (
ip_obj.is_private
or ip_obj.is_loopback
or ip_obj.is_link_local
or ip_obj.is_unspecified
or ip_obj.is_reserved
)
except ValueError:
pass
loop = asyncio.get_running_loop()
try:
resolved = await loop.getaddrinfo(hostname, None, type=socket.SOCK_STREAM)
except Exception:
return False
found_public_ip = False
for entry in resolved:
sockaddr = entry[4]
candidate = sockaddr[0] if sockaddr else ""
try:
ip_obj = ipaddress.ip_address(candidate)
except ValueError:
continue
if (
ip_obj.is_private
or ip_obj.is_loopback
or ip_obj.is_link_local
or ip_obj.is_unspecified
or ip_obj.is_reserved
):
return False
found_public_ip = True
return found_public_ip
async def _fetch_logo_from_url(url: str) -> tuple[bytes, str, str]:
parsed = urlsplit(url)
if parsed.scheme != "https" or not parsed.hostname:
raise ValueError("only https image URLs are supported")
if not await _hostname_resolves_to_public_address(parsed.hostname):
raise ValueError("logo URL must resolve to a public address")
timeout = ClientTimeout(total=5)
async with ClientSession(timeout=timeout, headers={"User-Agent": "Mozilla/5.0"}) as session:
async with session.get(
url,
allow_redirects=False,
headers={"Accept": "image/avif,image/webp,image/svg+xml,image/png,image/*,*/*;q=0.8"},
) as response:
if response.status != 200:
raise ValueError(f"logo URL returned HTTP {response.status}")
content_type = (
(response.headers.get("Content-Type") or "").split(";", 1)[0].strip().lower()
)
if content_type and not content_type.startswith("image/"):
raise ValueError("logo URL returned non-image content")
body = bytearray()
async for chunk in response.content.iter_chunked(64 * 1024):
body.extend(chunk)
if len(body) > WEBAPP_LOGO_MAX_BYTES:
raise ValueError("logo must be up to 2 MiB")
return bytes(body), content_type, Path(parsed.path).name
async def admin_appearance_logo_upload_route(request: web.Request) -> web.Response:
actor_id = _require_admin_user_id(request)
content_type = (request.headers.get("Content-Type") or "").lower()
try:
if content_type.startswith("multipart/form-data"):
body, detected_content_type, filename = await _read_uploaded_logo_file(request)
else:
payload = await _read_json(request)
source_url = str(payload.get("url") or "").strip()
if not source_url:
return _error(400, "invalid_payload", "url or file is required")
body, detected_content_type, filename = await _fetch_logo_from_url(source_url)
logo_url = _write_uploaded_logo(body, detected_content_type, filename)
try:
favicon_payload = _write_favicon_set(body, detected_content_type, filename)
except ValueError:
favicon_payload = {}
except ValueError as exc:
return _error(400, "invalid_logo", str(exc))
except OSError as exc:
logger.exception("Failed to save uploaded webapp logo")
return _error(500, "write_failed", str(exc))
persisted = await _persist_appearance_upload(
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")
else {}
),
},
actor_id,
)
return _ok({"logo_url": logo_url, "persisted": persisted, **favicon_payload})
async def admin_appearance_favicon_upload_route(request: web.Request) -> web.Response:
actor_id = _require_admin_user_id(request)
content_type = (request.headers.get("Content-Type") or "").lower()
try:
if content_type.startswith("multipart/form-data"):
body, detected_content_type, filename = await _read_uploaded_logo_file(request)
else:
payload = await _read_json(request)
source_url = str(payload.get("url") or "").strip()
if not source_url:
return _error(400, "invalid_payload", "url or file is required")
body, detected_content_type, filename = await _fetch_logo_from_url(source_url)
favicon_payload = _write_favicon_set(body, detected_content_type, filename)
except ValueError as exc:
return _error(400, "invalid_favicon", str(exc))
except OSError as exc:
logger.exception("Failed to save uploaded webapp favicon")
return _error(500, "write_failed", str(exc))
persisted = await _persist_appearance_upload(
request,
{
"WEBAPP_FAVICON_URL": favicon_payload["favicon_url"],
"WEBAPP_FAVICON_USE_CUSTOM": True,
},
actor_id,
)
return _ok({"persisted": persisted, **favicon_payload})
async def admin_themes_get_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
settings: Settings = request.app["settings"]
primary = settings.WEBAPP_PRIMARY_COLOR or "#00fe7a"
catalog = resolved_webapp_themes_catalog(
primary_accent=primary,
env_default_theme=settings.WEBAPP_DEFAULT_THEME,
theme_dir=settings.WEBAPP_THEMES_DIR,
)
return _ok(
{
"exists": Path(settings.WEBAPP_THEMES_DIR).expanduser().exists(),
"themes_dir": str(Path(settings.WEBAPP_THEMES_DIR).expanduser()),
"catalog": _webapp_themes_catalog_payload(catalog),
}
)
async def admin_themes_save_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
settings: Settings = request.app["settings"]
previous_config = resolved_webapp_themes_catalog(
primary_accent=settings.WEBAPP_PRIMARY_COLOR or "#00fe7a",
env_default_theme=settings.WEBAPP_DEFAULT_THEME,
theme_dir=settings.WEBAPP_THEMES_DIR,
)
payload = await _read_json(request)
catalog = payload.get("catalog") if "catalog" in payload else payload
if not isinstance(catalog, dict):
return _error(400, "invalid_payload", "catalog must be an object")
try:
config = WebappThemesConfig.model_validate(catalog)
except (ValidationError, ValueError) as exc:
return _error(400, "invalid_webapp_themes_config", str(exc))
config, _changed = ensure_webapp_core_themes(config, settings.WEBAPP_PRIMARY_COLOR or "#00fe7a")
config = _bump_theme_asset_versions(config, previous_config)
try:
write_webapp_theme_dir(settings.WEBAPP_THEMES_DIR, config, delete_missing=True)
except OSError as exc:
logger.exception("Failed to write webapp themes to %s", settings.WEBAPP_THEMES_DIR)
return _error(500, "write_failed", str(exc))
await refresh_webapp_runtime_after_settings_change(request, updates={}, deletes=[])
return _ok(
{
"exists": True,
"themes_dir": str(Path(settings.WEBAPP_THEMES_DIR).expanduser()),
"catalog": _webapp_themes_catalog_payload(config),
}
)
@@ -0,0 +1,145 @@
# 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),
}
)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,67 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from typing import Any
from bot.app.web.webapp.cache_helpers import (
invalidate_all_webapp_user_payloads,
reset_subscription_guides_cache,
reset_webapp_settings_cache,
)
WEBAPP_APPEARANCE_SETTING_KEYS = frozenset(
{
"WEBAPP_TITLE",
"WEBAPP_LOGO_URL",
"WEBAPP_LOGO_USE_EMOJI",
"WEBAPP_LOGO_EMOJI",
"WEBAPP_LOGO_EMOJI_FONT",
"WEBAPP_FAVICON_URL",
"WEBAPP_FAVICON_USE_CUSTOM",
"WEBAPP_LOGO_FAVICON_URL",
}
)
WEBAPP_DEVICE_PAYLOAD_SETTING_KEYS = frozenset(
{
"MY_DEVICES_SECTION_ENABLED",
"USER_HWID_DEVICE_LIMIT",
"USER_TRAFFIC_LIMIT_GB",
"USER_TRAFFIC_STRATEGY",
}
)
def changed_setting_keys(
updates: Mapping[str, Any] | None = None,
deletes: Sequence[Any] | None = None,
) -> set[str]:
keys = {str(key) for key in (updates or {}).keys()}
keys.update(str(key) for key in (deletes or []) if key is not None)
return keys
async def refresh_webapp_runtime_after_settings_change(
request: Any,
*,
updates: Mapping[str, Any] | None = None,
deletes: Sequence[Any] | None = None,
include_user_payloads: bool = True,
) -> None:
settings = request.app["settings"]
keys = changed_setting_keys(updates, deletes)
reset_webapp_settings_cache(request.app)
reset_subscription_guides_cache(request.app)
if include_user_payloads:
await invalidate_all_webapp_user_payloads(
settings,
include_devices=bool(keys & WEBAPP_DEVICE_PAYLOAD_SETTING_KEYS),
)
if keys & WEBAPP_APPEARANCE_SETTING_KEYS:
request.app["webapp_logo_cache"] = None
from bot.app.web.admin_api_impl.themes import prune_unused_appearance_assets
prune_unused_appearance_assets(settings)
@@ -0,0 +1,699 @@
"""Manifest of settings editable from the admin web app.
Each entry describes a single overridable attribute on the global
``Settings`` instance. The manifest is the only contract between the
admin UI and the backend: keys not listed here cannot be changed via
the API, even by an admin.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from typing import Any, List, Optional, Tuple
@dataclass(frozen=True)
class SettingField:
key: str
type: str # "string" | "int" | "float" | "bool" | "text" | "url" | "color" | "icon" | "json"
section: str
label: str
description: str = ""
placeholder: str = ""
optional: bool = True
secret: bool = False
min: Optional[float] = None
max: Optional[float] = None
choices: Optional[Tuple[Tuple[str, str], ...]] = None
subsection: Optional[str] = None # group label inside a section
i18n_label_key: Optional[str] = None
i18n_description_key: Optional[str] = None
i18n_subsection_key: Optional[str] = None
SETTINGS_MANIFEST: List[SettingField] = [
# ─── General ────────────────────────────────────────────────────
SettingField(
"WEBAPP_TITLE",
"string",
"general",
"Web App title",
placeholder="My subscription",
),
SettingField(
"DEFAULT_LANGUAGE",
"string",
"general",
"Язык по умолчанию",
"Используется для приветственных сообщений и публичных страниц.",
),
SettingField(
"DEFAULT_CURRENCY_SYMBOL",
"string",
"general",
"Валюта",
"Например, RUB, USD, EUR.",
placeholder="RUB",
),
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"),
SettingField(
"START_COMMAND_DESCRIPTION", "string", "general", "Описание /start", placeholder=""
),
SettingField(
"REQUIRED_CHANNEL_ID",
"int",
"general",
"ID обязательного канала",
"Telegram ID канала, в котором нужно состоять.",
),
SettingField(
"REQUIRED_CHANNEL_LINK",
"string",
"general",
"Ссылка на канал",
"Имя пользователя или invite-link.",
),
SettingField(
"PANEL_API_URL",
"url",
"general",
"URL API Remnawave",
"Например, https://panel.example.com/api.",
subsection="Remnawave",
),
SettingField(
"PANEL_API_KEY",
"string",
"general",
"API-ключ Remnawave",
"Секретный ключ API панели.",
secret=True,
subsection="Remnawave",
),
SettingField(
"PANEL_WEBHOOK_SECRET",
"string",
"general",
"Секрет вебхуков Remnawave",
"Используется для проверки входящих вебхуков панели.",
secret=True,
subsection="Remnawave",
),
SettingField(
"USER_SQUAD_UUIDS",
"string",
"general",
"Internal Squads по умолчанию",
"UUID через запятую для legacy-режима без JSON-каталога тарифов.",
subsection="Remnawave",
),
SettingField(
"USER_EXTERNAL_SQUAD_UUID",
"string",
"general",
"External Squad по умолчанию",
"Необязательный UUID External Squad для новых пользователей.",
subsection="Remnawave",
),
# ─── Web app appearance ────────────────────────────────────────
SettingField(
"SUBSCRIPTION_MINI_APP_URL",
"url",
"appearance",
"Публичный URL Mini App",
"Например, https://app.example.com/.",
),
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",
"appearance",
"Использовать отдельную favicon",
),
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 месяца"),
SettingField("MONTH_6_ENABLED", "bool", "pricing", "Тариф 6 месяцев"),
SettingField("MONTH_12_ENABLED", "bool", "pricing", "Тариф 12 месяцев"),
SettingField("RUB_PRICE_1_MONTH", "int", "pricing", "Цена 1 мес. (RUB)"),
SettingField("RUB_PRICE_3_MONTHS", "int", "pricing", "Цена 3 мес. (RUB)"),
SettingField("RUB_PRICE_6_MONTHS", "int", "pricing", "Цена 6 мес. (RUB)"),
SettingField("RUB_PRICE_12_MONTHS", "int", "pricing", "Цена 12 мес. (RUB)"),
SettingField("STARS_PRICE_1_MONTH", "int", "pricing", "Цена 1 мес. (Stars)"),
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(
"TRAFFIC_PACKAGES", "string", "pricing", "Пакеты трафика", "Формат: 10:199,50:799 (ГБ:цена)"
),
SettingField("STARS_TRAFFIC_PACKAGES", "string", "pricing", "Пакеты трафика (Stars)"),
SettingField(
"SUBSCRIPTION_PURCHASE_DESCRIPTION_ENABLED",
"bool",
"payments",
"Показывать описание подписки",
"Текст появится перед выбором срока покупки или продления.",
subsection="checkout",
),
SettingField(
"SUBSCRIPTION_PURCHASE_DESCRIPTION_RU",
"text",
"payments",
"Описание подписки (RU)",
"Русская версия текста на этапе оплаты.",
subsection="checkout",
),
SettingField(
"SUBSCRIPTION_PURCHASE_DESCRIPTION_EN",
"text",
"payments",
"Описание подписки (EN)",
"Английская версия текста на этапе оплаты.",
subsection="checkout",
),
# ─── Payment providers (toggles) ───────────────────────────────
# Common
SettingField("STARS_ENABLED", "bool", "payments", "Telegram Stars", subsection="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",
subsection="common",
),
# ─── Trial ─────────────────────────────────────────────────────
SettingField("TRIAL_ENABLED", "bool", "pricing", "Триал включён", subsection="trial"),
SettingField(
"TRIAL_DURATION_DAYS",
"int",
"pricing",
"Длительность триала (дней)",
min=0,
subsection="trial",
),
SettingField(
"TRIAL_TRAFFIC_LIMIT_GB",
"float",
"pricing",
"Лимит трафика триала (ГБ)",
min=0,
subsection="trial",
),
SettingField(
"TRIAL_TRAFFIC_STRATEGY",
"string",
"pricing",
"Стратегия сброса трафика триала",
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", "Один бонус на приглашённого"
),
SettingField(
"REFERRAL_WELCOME_BONUS_DAYS", "int", "referral", "Приветственный бонус (дней)", min=0
),
SettingField("LEGACY_REFS", "bool", "referral", "Поддержка старых ref-ссылок"),
SettingField(
"REFERRAL_BONUS_DAYS_INVITER_1_MONTH",
"int",
"referral",
"Бонус приглашающему: 1 мес.",
min=0,
),
SettingField(
"REFERRAL_BONUS_DAYS_INVITER_3_MONTHS",
"int",
"referral",
"Бонус приглашающему: 3 мес.",
min=0,
),
SettingField(
"REFERRAL_BONUS_DAYS_INVITER_6_MONTHS",
"int",
"referral",
"Бонус приглашающему: 6 мес.",
min=0,
),
SettingField(
"REFERRAL_BONUS_DAYS_INVITER_12_MONTHS",
"int",
"referral",
"Бонус приглашающему: 12 мес.",
min=0,
),
SettingField(
"REFERRAL_BONUS_DAYS_REFEREE_1_MONTH",
"int",
"referral",
"Бонус приглашённому: 1 мес.",
min=0,
),
SettingField(
"REFERRAL_BONUS_DAYS_REFEREE_3_MONTHS",
"int",
"referral",
"Бонус приглашённому: 3 мес.",
min=0,
),
SettingField(
"REFERRAL_BONUS_DAYS_REFEREE_6_MONTHS",
"int",
"referral",
"Бонус приглашённому: 6 мес.",
min=0,
),
SettingField(
"REFERRAL_BONUS_DAYS_REFEREE_12_MONTHS",
"int",
"referral",
"Бонус приглашённому: 12 мес.",
min=0,
),
# ─── Notifications ─────────────────────────────────────────────
SettingField(
"SUBSCRIPTION_NOTIFICATIONS_ENABLED",
"bool",
"notifications",
"Включены уведомления о подписке",
),
SettingField(
"SUBSCRIPTION_NOTIFY_ON_EXPIRE", "bool", "notifications", "Уведомлять об истечении"
),
SettingField(
"SUBSCRIPTION_NOTIFY_AFTER_EXPIRE", "bool", "notifications", "Уведомлять после истечения"
),
SettingField(
"SUBSCRIPTION_NOTIFY_DAYS_BEFORE",
"int",
"notifications",
"За сколько дней предупреждать",
min=0,
),
SettingField("LOG_NEW_USERS", "bool", "notifications", "Логировать новых пользователей"),
SettingField("LOG_PAYMENTS", "bool", "notifications", "Логировать платежи"),
SettingField("LOG_SUPPORT", "bool", "notifications", "Логировать тикеты поддержки"),
SettingField(
"LOG_PROMO_ACTIVATIONS", "bool", "notifications", "Логировать активации промокодов"
),
SettingField("LOG_TRIAL_ACTIVATIONS", "bool", "notifications", "Логировать активации триала"),
SettingField(
"LOG_SUSPICIOUS_ACTIVITY", "bool", "notifications", "Логировать подозрительные действия"
),
SettingField(
"LOG_ADMIN_ACTIONS",
"bool",
"notifications",
"Логировать действия администраторов",
"Если выключено, события от пользователей из ADMIN_IDS не записываются в message logs.",
i18n_label_key="admin_settings_field_log_admin_actions_label",
i18n_description_key="admin_settings_field_log_admin_actions_description",
),
SettingField(
"LOG_LEVEL",
"string",
"notifications",
"Глобальный уровень логов",
"DEBUG / INFO / WARNING / ERROR",
),
SettingField("LOG_CHAT_ID", "int", "notifications", "ID чата для логов"),
SettingField("LOG_THREAD_ID", "int", "notifications", "ID треда (для супергрупп)"),
SettingField(
"LOG_SUPPORT_THREAD_ID",
"int",
"notifications",
"ID треда поддержки",
"Тред лог-чата для уведомлений о тикетах поддержки.",
),
SettingField(
"SUPPORT_TICKETS_ENABLED",
"bool",
"support",
"Тикеты поддержки включены",
"Показывает раздел поддержки в ЛК и включает создание тикетов.",
),
SettingField(
"SUPPORT_ADMIN_EMAIL_NOTIFICATIONS_ENABLED",
"bool",
"support",
"Email-уведомления админам",
(
"Если выключено, новые тикеты и ответы пользователей останутся "
"только в Telegram и лог-чате."
),
),
SettingField(
"SUPPORT_ADMIN_NOTIFICATION_COOLDOWN_SECONDS",
"int",
"support",
"Пауза Telegram-уведомлений",
(
"Минимум секунд между повторными Telegram/log уведомлениями "
"по одному непрочитанному тикету."
),
min=0,
),
SettingField(
"SUPPORT_ADMIN_EMAIL_COOLDOWN_SECONDS",
"int",
"support",
"Пауза email-уведомлений",
"Минимум секунд между повторными email-уведомлениями по одному непрочитанному тикету.",
min=0,
),
SettingField(
"SUPPORT_TICKET_MAX_BODY_LENGTH",
"int",
"support",
"Макс. длина сообщения",
"Максимальное количество символов в сообщении тикета.",
min=1,
),
SettingField(
"SUPPORT_TICKET_MAX_SUBJECT_LENGTH",
"int",
"support",
"Макс. длина темы",
"Максимальное количество символов в теме тикета.",
min=1,
),
SettingField(
"SUPPORT_TICKET_RATE_LIMIT_PER_HOUR",
"int",
"support",
"Лимит тикетов в час",
"Сколько новых тикетов пользователь может создать за час. 0 — без лимита.",
min=0,
),
# ─── Devices ───────────────────────────────────────────────────
SettingField("MY_DEVICES_SECTION_ENABLED", "bool", "devices", "Раздел «Мои устройства»"),
SettingField(
"USER_HWID_DEVICE_LIMIT", "int", "devices", "Лимит устройств по умолчанию (0 = ∞)", min=0
),
SettingField("USER_TRAFFIC_LIMIT_GB", "float", "devices", "Лимит трафика пользователя (ГБ)"),
SettingField("USER_TRAFFIC_STRATEGY", "string", "devices", "Стратегия сброса трафика"),
]
def _provider_field_to_setting_field(spec: Any, manifest_field: Any) -> SettingField:
return SettingField(
key=manifest_field.key,
type=manifest_field.type,
section="payments",
label=manifest_field.label,
description=manifest_field.description,
placeholder=manifest_field.placeholder,
optional=manifest_field.optional,
secret=manifest_field.secret,
min=manifest_field.min,
max=manifest_field.max,
choices=tuple(manifest_field.choices) if manifest_field.choices else None,
subsection=manifest_field.subsection,
i18n_label_key=getattr(manifest_field, "i18n_label_key", None),
i18n_description_key=getattr(manifest_field, "i18n_description_key", None),
i18n_subsection_key=getattr(manifest_field, "i18n_subsection_key", None),
)
def aggregated_manifest() -> List[SettingField]:
"""SETTINGS_MANIFEST + per-provider fragments declared in provider SPECs."""
from bot.payment_providers import iter_provider_manifest_fields # local to avoid cycle
fields: List[SettingField] = list(SETTINGS_MANIFEST)
for spec, manifest_field in iter_provider_manifest_fields():
fields.append(_provider_field_to_setting_field(spec, manifest_field))
return fields
def get_field_by_key(key: str) -> Optional[SettingField]:
for field in aggregated_manifest():
if field.key == key:
return field
return None
def manifest_keys() -> List[str]:
return [f.key for f in aggregated_manifest()]
def coerce_value(field: SettingField, raw: Any) -> Any:
"""Coerce a value coming from JSON to the type declared by the field."""
if field.type == "json":
if raw is None:
return ""
text = raw if isinstance(raw, str) else str(raw)
text = text.strip()
if not text:
return ""
from config.subscription_guides_config import validate_subscription_guides_config_text
validate_subscription_guides_config_text(text)
return text
if raw is None or (isinstance(raw, str) and raw.strip() == ""):
return None
if field.type == "bool":
if isinstance(raw, bool):
return raw
if isinstance(raw, (int, float)):
return bool(raw)
if isinstance(raw, str):
return raw.strip().lower() in {"1", "true", "yes", "on"}
return bool(raw)
if field.type == "int":
try:
value = int(str(raw).strip())
except (TypeError, ValueError) as exc:
raise ValueError(f"{field.key}: integer expected") from exc
if field.min is not None and value < field.min:
raise ValueError(f"{field.key}: must be >= {field.min:g}")
if field.max is not None and value > field.max:
raise ValueError(f"{field.key}: must be <= {field.max:g}")
return value
if field.type == "float":
try:
value = float(str(raw).strip())
except (TypeError, ValueError) as exc:
raise ValueError(f"{field.key}: number expected") from exc
if field.min is not None and value < field.min:
raise ValueError(f"{field.key}: must be >= {field.min:g}")
if field.max is not None and value > field.max:
raise ValueError(f"{field.key}: must be <= {field.max:g}")
return value
if isinstance(raw, str):
return raw.strip()
return str(raw)
def _i18n_slug(value: str) -> str:
slug = re.sub(r"[^a-z0-9]+", "_", value.strip().lower()).strip("_")
return slug or "default"
def manifest_payload() -> List[dict]:
"""Serialize the manifest for the admin UI.
For provider presentation fields we resolve the SPEC-declared default
(e.g. the button text the bot would use if the admin leaves the override
blank) and expose it as ``default``; ``placeholder`` falls back to the
same value so existing UIs that only read ``placeholder`` also show the
hint inside the empty input.
"""
from bot.payment_providers import (
find_manifest_owner,
manifest_field_default,
provider_admin_only_pairs,
provider_webhook_metadata,
)
sections_order = {
"general": 1,
"appearance": 2,
"pricing": 11,
"payments": 4,
"trial": 5,
"referral": 6,
"notifications": 7,
"support": 8,
"devices": 9,
"subscription_guides": 10,
}
exclusive_map = {
key: opposite
for public_key, admin_key in provider_admin_only_pairs()
for key, opposite in ((public_key, admin_key), (admin_key, public_key))
}
items: List[dict] = []
for field in aggregated_manifest():
auto_label_i18n_key = f"admin_settings_field_{field.key.lower()}_label"
auto_description_i18n_key = f"admin_settings_field_{field.key.lower()}_description"
auto_subsection_i18n_key = (
f"admin_settings_subsection_{_i18n_slug(field.subsection)}"
if field.subsection
else None
)
default_value: Optional[str] = None
webhook_metadata: Optional[dict] = None
owner = find_manifest_owner(field.key)
if owner is not None:
spec, manifest_field = owner
default_value = manifest_field_default(spec, manifest_field)
webhook_metadata = provider_webhook_metadata(spec)
placeholder = field.placeholder
if not placeholder and default_value:
placeholder = default_value
item = {
"key": field.key,
"type": field.type,
"section": field.section,
"section_order": sections_order.get(field.section, 99),
"subsection": field.subsection,
"label": field.label,
"description": field.description,
"i18n_label_key": field.i18n_label_key or auto_label_i18n_key,
"i18n_description_key": field.i18n_description_key
or (auto_description_i18n_key if field.description else None),
"i18n_subsection_key": field.i18n_subsection_key or auto_subsection_i18n_key,
"i18n_placeholder_key": (
f"admin_settings_field_{field.key.lower()}_placeholder" if placeholder else None
),
"placeholder": placeholder,
"optional": field.optional,
"secret": field.secret,
}
if field.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.choices:
item["choices"] = [
{
"value": v,
"label": lbl,
"i18n_label_key": (
f"admin_settings_field_{field.key.lower()}_choice_{_i18n_slug(str(v))}"
),
}
for v, lbl in field.choices
]
items.append(item)
return items
+29
View File
@@ -0,0 +1,29 @@
from __future__ import annotations
from typing import Optional
from aiohttp import web
from bot.app.web.webapp_auth import verify_webapp_session_token
from config.settings import Settings
WEBAPP_SESSION_COOKIE_NAME = "rw_webapp_session"
def extract_authenticated_user_id(request: web.Request) -> Optional[int]:
settings: Settings = request.app["settings"]
auth_header = request.headers.get("Authorization", "")
if auth_header.startswith("Bearer "):
user_id = verify_webapp_session_token(
settings,
auth_header.removeprefix("Bearer ").strip(),
)
if user_id:
return user_id
session_cookie = request.cookies.get(WEBAPP_SESSION_COOKIE_NAME)
if session_cookie:
return verify_webapp_session_token(settings, session_cookie)
return None
@@ -0,0 +1,52 @@
"""Compatibility facade for the subscription Mini App backend."""
# ruff: noqa: I001
from bot.app.web.webapp import (
_runtime as _runtime,
account as _account,
application as _application,
assets as _assets,
auth as _auth,
billing as _billing,
common as _common,
devices as _devices,
guides as _guides,
payloads as _payloads,
routes as _routes,
serializers as _serializers,
support as _support,
)
_MODULES = (
_runtime,
_payloads,
_common,
_assets,
_auth,
_account,
_serializers,
_billing,
_devices,
_guides,
_support,
_routes,
_application,
)
_NAMESPACE = {}
for _module in _MODULES:
_NAMESPACE.update(
{
_name: _value
for _name, _value in vars(_module).items()
if not _name.startswith("__") and _name != "annotations"
}
)
for _module in _MODULES:
vars(_module).update(_NAMESPACE)
globals().update(_NAMESPACE)
__all__ = sorted(_name for _name in _NAMESPACE if not _name.startswith("__"))
@@ -0,0 +1,219 @@
<!doctype html>
<html lang="__LANG__">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link id="app-favicon" rel="icon" href="data:," sizes="any">
<title>__PAGE_TITLE__</title>
<style nonce="__NONCE__">
:root {
color-scheme: dark light;
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
"Segoe UI", sans-serif;
background: #0b1017;
color: #f7fafc;
}
body {
min-height: 100dvh;
margin: 0;
display: grid;
place-items: center;
padding: 24px;
box-sizing: border-box;
}
main {
width: min(100%, 420px);
display: grid;
gap: 14px;
text-align: center;
}
h1 {
margin: 0;
font-size: 24px;
line-height: 1.2;
}
p {
margin: 0;
color: #aeb8c5;
font-size: 15px;
line-height: 1.55;
}
.actions {
display: grid;
gap: 10px;
margin-top: 4px;
}
.button {
display: inline-flex;
min-height: 46px;
align-items: center;
justify-content: center;
border: 1px solid transparent;
border-radius: 8px;
background: #14b86f;
color: #03120b;
padding: 0 18px;
box-sizing: border-box;
font: inherit;
font-weight: 800;
text-decoration: none;
cursor: pointer;
}
.button.secondary {
border-color: #2d3847;
background: transparent;
color: #f7fafc;
}
.button[aria-disabled="true"] {
pointer-events: none;
background: #344052;
color: #aeb8c5;
}
[hidden] {
display: none !important;
}
</style>
</head>
<body>
<main>
<h1 id="title"></h1>
<p id="status"></p>
<div class="actions">
<a id="open-link" class="button" href="#" rel="noreferrer"></a>
<button id="close-button" class="button secondary" type="button" hidden></button>
</div>
</main>
<script nonce="__NONCE__">
(() => {
const messages = __MESSAGES_JSON__;
const titleEl = document.getElementById("title");
const statusEl = document.getElementById("status");
const openLink = document.getElementById("open-link");
const closeButton = document.getElementById("close-button");
const params = new URLSearchParams(window.location.hash.replace(/^#/, ""));
const target = String(params.get("url") || "").trim();
const isUnsafe =
!target ||
hasControlChars(target) ||
/^(?:javascript|data|vbscript|https?):/i.test(target);
let attempted = false;
let pageLeft = false;
let state = "opening";
function hasControlChars(value) {
return Array.from(String(value || "")).some((char) => {
const code = char.charCodeAt(0);
return code <= 31 || code === 127;
});
}
function text(key, fallback) {
const value = messages && messages[key];
return typeof value === "string" && value ? value : fallback;
}
function tryCloseWindow() {
try {
window.close();
} catch (_error) {
void _error;
}
}
function render(nextState) {
state = nextState;
if (nextState === "unavailable") {
titleEl.textContent = text("unavailableTitle", "App link unavailable");
statusEl.textContent = text("unavailableHint", "Return to Telegram and try again.");
openLink.textContent = text("button", "Open app");
openLink.setAttribute("aria-disabled", "true");
openLink.removeAttribute("href");
closeButton.hidden = true;
return;
}
if (nextState === "done") {
titleEl.textContent = text("doneTitle", "Settings added");
statusEl.textContent = text("doneHint", "You can close this window.");
openLink.textContent = text("retryButton", "Open again");
openLink.removeAttribute("aria-disabled");
openLink.href = target;
closeButton.textContent = text("closeButton", "Close window");
closeButton.hidden = false;
return;
}
titleEl.textContent = text("title", "Opening app");
statusEl.textContent =
nextState === "manual"
? text("manualHint", "If the app did not open automatically, tap the button below.")
: text("hint", "Opening the app on this device...");
openLink.textContent = text("button", "Open app");
openLink.removeAttribute("aria-disabled");
openLink.href = target;
closeButton.hidden = true;
}
function markDone() {
if (state === "done" || isUnsafe) return;
render("done");
window.setTimeout(tryCloseWindow, 120);
}
function notePageLeft() {
if (!attempted) return;
pageLeft = true;
window.setTimeout(markDone, 900);
}
function openTarget() {
if (isUnsafe) return;
attempted = true;
pageLeft = false;
render("opening");
window.location.href = target;
window.setTimeout(() => {
if (state === "opening" && !pageLeft) render("manual");
}, 1600);
}
if (isUnsafe) {
render("unavailable");
return;
}
openLink.addEventListener("click", (event) => {
event.preventDefault();
openTarget();
});
closeButton.addEventListener("click", () => {
tryCloseWindow();
render("done");
});
window.addEventListener("pagehide", notePageLeft);
window.addEventListener("blur", notePageLeft);
document.addEventListener("visibilitychange", () => {
if (!attempted) return;
if (document.hidden) {
pageLeft = true;
} else if (pageLeft) {
markDone();
}
});
render("opening");
window.setTimeout(openTarget, 80);
})();
</script>
</body>
</html>
@@ -0,0 +1,73 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
/>
<meta name="robots" content="noindex, nofollow" />
<meta name="theme-color" content="#03070b" />
<link id="app-favicon" rel="icon" href="/favicon.ico" sizes="any" />
<link rel="icon" type="image/png" sizes="192x192" href="/icon-192.png" />
<link rel="icon" type="image/png" sizes="512x512" href="/icon-512.png" />
<link
id="app-apple-touch-icon"
rel="apple-touch-icon"
sizes="180x180"
href="/apple-touch-icon.png"
/>
<link
rel="apple-touch-icon-precomposed"
sizes="180x180"
href="/apple-touch-icon-precomposed.png"
/>
<title>/minishop</title>
<link rel="stylesheet" href="/subscription_webapp.css" />
<style>
.app-boot-fallback {
min-height: 100dvh;
display: grid;
place-items: center;
padding: 24px;
background: #03070b;
}
.app-boot-fallback__spinner {
width: 28px;
height: 28px;
border: 2px solid rgba(242, 247, 244, 0.18);
border-top-color: #00fe7a;
border-radius: 999px;
animation: appBootSpin 0.8s linear infinite;
}
@media (prefers-reduced-motion: reduce) {
.app-boot-fallback__spinner {
animation: none;
}
}
@keyframes appBootSpin {
to {
transform: rotate(360deg);
}
}
</style>
</head>
<body>
<main id="app">
<div class="app-boot-fallback" role="status" aria-label="Загрузка">
<div class="app-boot-fallback__spinner" aria-hidden="true"></div>
</div>
</main>
<!-- WEBAPP_I18N_SCRIPT -->
<!-- WEBAPP_CONFIG_SCRIPT -->
<!-- WEBAPP_JS_SCRIPT -->
<!-- WEBAPP_DEV_MOCK_START -->
<script src="/subscription_webapp.js" defer></script>
<!-- WEBAPP_DEV_MOCK_END -->
</body>
</html>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,17 @@
{
"key": "ascii",
"names": {
"ru": "ASCII",
"en": "ASCII"
},
"enabled": true,
"default": false,
"use_primary_accent": false,
"use_in_admin": true,
"css_file": "style.css",
"assets_version": 4,
"tokens": {
"color_scheme": "dark",
"style_preset": "ascii"
}
}
@@ -0,0 +1,27 @@
{
"key": "dark",
"names": {
"ru": "Темная",
"en": "Dark"
},
"enabled": true,
"default": true,
"use_primary_accent": true,
"use_in_admin": true,
"assets_version": 1,
"tokens": {
"color_scheme": "dark",
"bg": "#03070b",
"panel": "#111820",
"panel_2": "#0b1118",
"panel_3": "#17212b",
"border": "rgba(255, 255, 255, 0.12)",
"border_strong": "rgba(255, 255, 255, 0.2)",
"text": "#f2f7f4",
"muted": "#a9b4b0",
"dim": "#68736f",
"danger": "#ff6b6b",
"blue": "#2d9cff",
"radius": "8px"
}
}
+206
View File
@@ -0,0 +1,206 @@
.theme-key-light {
color-scheme: light;
--accent: #047857;
--bg: #f7f8fb;
--panel: #ffffff;
--panel-2: #f1f5f9;
--panel-3: #e8edf3;
--border: rgba(15, 23, 42, 0.11);
--border-strong: rgba(15, 23, 42, 0.2);
--text: #0f172a;
--muted: #475569;
--dim: #64748b;
--danger: #dc2626;
--danger-text: #b91c1c;
--danger-soft: color-mix(in srgb, var(--danger) 9%, var(--panel));
--danger-border: color-mix(in srgb, var(--danger) 34%, var(--border));
--success: #16a34a;
--success-text: #166534;
--success-soft: color-mix(in srgb, var(--success) 10%, var(--panel));
--success-border: color-mix(in srgb, var(--success) 34%, var(--border));
--warning: #d97706;
--warning-text: #92400e;
--warning-soft: color-mix(in srgb, var(--warning) 11%, var(--panel));
--warning-border: color-mix(in srgb, var(--warning) 34%, var(--border));
--info: #2563eb;
--info-text: #1d4ed8;
--info-soft: color-mix(in srgb, var(--info) 9%, var(--panel));
--info-border: color-mix(in srgb, var(--info) 30%, var(--border));
--blue: #2563eb;
--radius: 8px;
--accent-contrast: #ffffff;
--surface-sheen: rgba(15, 23, 42, 0.035);
--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);
--rail-bg: rgba(255, 255, 255, 0.72);
--shadow-soft: 0 6px 18px rgba(15, 23, 42, 0.06);
--shadow-strong: 0 18px 44px rgba(15, 23, 42, 0.12);
--shadow-popover: 0 14px 28px rgba(15, 23, 42, 0.12);
--inset-highlight: rgba(255, 255, 255, 0.75);
--admin-bg: #f7f8fb;
--admin-surface: #ffffff;
--admin-surface-2: #f1f5f9;
--admin-elev: #e8edf3;
--admin-border: rgba(15, 23, 42, 0.1);
--admin-border-strong: rgba(15, 23, 42, 0.18);
--admin-text: #0f172a;
--admin-muted: #64748b;
--admin-dim: #64748b;
--admin-chart-stroke: #065f46;
--admin-chart-fill: rgba(6, 95, 70, 0.22);
}
.theme-key-light .ui-spinner,
.theme-key-light .brand-mark-spinner {
color: inherit;
}
.theme-key-light .telegram-button-spinner {
border-color: rgba(255, 255, 255, 0.35);
border-top-color: #ffffff;
}
.theme-key-light .btn-primary,
.theme-key-light .admin-btn.admin-btn-primary,
.theme-key-light .admin-extend-control .admin-btn.admin-btn-primary {
background: color-mix(in srgb, var(--accent) 50%, #000000);
border-color: color-mix(in srgb, var(--accent) 42%, #000000);
color: #ffffff;
}
.theme-key-light .btn-primary:hover:not(:disabled),
.theme-key-light .admin-btn.admin-btn-primary:hover:not(:disabled),
.theme-key-light .admin-extend-control .admin-btn.admin-btn-primary:hover:not(:disabled) {
background: color-mix(in srgb, var(--accent) 52%, #000000);
}
.theme-key-light.app-shell {
background: var(--bg) !important;
}
.theme-key-light .phone-screen {
background: var(--bg);
}
/* Flatten Settings rows: no gradient sheen, no inset highlight that reads as a 3D bevel */
.theme-key-light .settings-row {
background: var(--panel);
box-shadow: none;
}
.theme-key-light .settings-row-linked {
background: var(--success-soft);
}
/* Avatar/profile card: bigger lift, but rows below have an opaque background and
stack above, so the shadow stays visually under them instead of bleeding through. */
.theme-key-light .settings-profile {
box-shadow:
0 10px 24px rgba(15, 23, 42, 0.10),
inset 0 1px 0 var(--inset-highlight);
}
.theme-key-light .settings-links-block {
position: relative;
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;
}
/* Bonus section: drop accent color from body strongs; only the bonus-system heading
and explicitly-accent card headings stay tinted — and they use the same darkened
accent technique as .btn-primary on light, so they remain readable on white. */
.theme-key-light .bonus-card strong {
color: var(--text);
}
.theme-key-light .bonus-card-head strong,
.theme-key-light .card-heading-accent {
color: color-mix(in srgb, var(--accent) 50%, #000000);
}
.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);
}
@@ -0,0 +1,16 @@
{
"key": "light",
"names": {
"ru": "Светлая",
"en": "Light"
},
"enabled": true,
"default": false,
"use_primary_accent": true,
"use_in_admin": true,
"css_file": "style.css",
"assets_version": 3,
"tokens": {
"color_scheme": "light"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 375 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 424 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 356 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 419 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 372 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 388 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 424 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 636 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 390 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 385 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 415 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 356 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 393 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 474 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 395 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 461 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 327 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 411 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 395 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 415 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 393 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 478 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 500 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 589 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 392 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 392 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 419 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 403 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 371 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 B

File diff suppressed because it is too large Load Diff
@@ -0,0 +1,17 @@
{
"key": "windows95",
"names": {
"ru": "Windows 95",
"en": "Windows 95"
},
"enabled": true,
"default": false,
"use_primary_accent": false,
"use_in_admin": true,
"css_file": "style.css",
"assets_version": 11,
"tokens": {
"color_scheme": "light",
"style_preset": "win95"
}
}
+158
View File
@@ -0,0 +1,158 @@
import asyncio
import hmac
import logging
from aiogram import Bot, Dispatcher
from aiogram.webhook.aiohttp_server import SimpleRequestHandler, setup_application
from aiohttp import web
from sqlalchemy.orm import sessionmaker
from bot.payment_providers import iter_provider_specs, iter_service_keys
from config.settings import Settings
class SecureSimpleRequestHandler(SimpleRequestHandler):
def verify_secret(self, telegram_secret_token: str, bot: Bot) -> bool:
if not self.secret_token:
return False
return hmac.compare_digest(telegram_secret_token, self.secret_token)
def _inject_shared_instances(
app: web.Application,
dp: Dispatcher,
bot: Bot,
settings: Settings,
async_session_factory: sessionmaker,
) -> None:
app["bot"] = bot
app["dp"] = dp
app["settings"] = settings
app["async_session_factory"] = async_session_factory
app["i18n"] = dp.get("i18n_instance")
shared_keys = [
"subscription_service",
"referral_service",
"panel_service",
"panel_webhook_service",
"lknpd_service",
*iter_service_keys(),
]
for key in shared_keys:
if hasattr(dp, "workflow_data") and key in dp.workflow_data: # type: ignore
app[key] = dp.workflow_data[key] # type: ignore
async def build_and_start_web_app(
dp: Dispatcher,
bot: Bot,
settings: Settings,
async_session_factory: sessionmaker,
):
app = web.Application()
_inject_shared_instances(app, dp, bot, settings, async_session_factory)
async def _healthcheck(request: web.Request) -> web.Response:
payload = {"status": "ok"}
try:
from db.database_setup import async_engine
pool = async_engine.pool if async_engine is not None else None
if pool is not None:
payload["db_pool"] = {
"checked_in": pool.checkedin(),
"checked_out": pool.checkedout(),
"size": pool.size(),
"overflow": pool.overflow(),
}
except Exception:
logging.exception("Failed to collect DB pool health metrics")
return web.json_response(payload)
app.router.add_get("/healthz", _healthcheck)
app.router.add_get("/health", _healthcheck)
setup_application(app, dp, bot=bot)
telegram_uses_webhook_mode = bool(settings.WEBHOOK_BASE_URL)
if telegram_uses_webhook_mode:
telegram_webhook_path = settings.telegram_webhook_path
SecureSimpleRequestHandler(
dispatcher=dp,
bot=bot,
secret_token=settings.WEBHOOK_SECRET_TOKEN,
).register(app, path=telegram_webhook_path)
logging.info(
f"Telegram webhook route configured at: [POST] {telegram_webhook_path} (relative to base URL)" # noqa: E501
)
from bot.services.panel_webhook_service import panel_webhook_route
registered_webhook_paths: set[str] = set()
for spec in iter_provider_specs():
webhook_route = spec.load_webhook_route()
if not spec.webhook_path or not webhook_route:
continue
if spec.webhook_requires_base_url and not settings.WEBHOOK_BASE_URL:
continue
path = spec.webhook_path(settings)
if not path or not path.startswith("/") or path in registered_webhook_paths:
continue
registered_webhook_paths.add(path)
app.router.add_post(path, webhook_route)
logging.info("%s webhook route configured at: [POST] %s", spec.label, path)
panel_path = settings.panel_webhook_path
if panel_path.startswith("/"):
app.router.add_post(panel_path, panel_webhook_route)
logging.info(f"Panel webhook route configured at: [POST] {panel_path}")
runners = []
webhooks_runner = web.AppRunner(app)
await webhooks_runner.setup()
runners.append(webhooks_runner)
site = web.TCPSite(
webhooks_runner,
host=settings.WEB_SERVER_HOST,
port=settings.WEB_SERVER_PORT,
)
await site.start()
logging.info(
f"AIOHTTP server started on http://{settings.WEB_SERVER_HOST}:{settings.WEB_SERVER_PORT}"
)
if settings.WEBAPP_ENABLED:
from bot.app.web.subscription_webapp import create_subscription_webapp_application
subscription_app = create_subscription_webapp_application(
dp,
bot,
settings,
async_session_factory,
)
subscription_runner = web.AppRunner(subscription_app)
await subscription_runner.setup()
runners.append(subscription_runner)
subscription_site = web.TCPSite(
subscription_runner,
host=settings.WEBAPP_SERVER_HOST,
port=settings.WEBAPP_SERVER_PORT,
)
await subscription_site.start()
logging.info(
"Subscription WebApp server started on http://%s:%s",
settings.WEBAPP_SERVER_HOST,
settings.WEBAPP_SERVER_PORT,
)
try:
await asyncio.Event().wait()
finally:
for runner in reversed(runners):
try:
await runner.cleanup()
except Exception as cleanup_error:
logging.warning("Failed to cleanup aiohttp runner: %s", cleanup_error)
+1
View File
@@ -0,0 +1 @@
"""Domain modules for the subscription Mini App backend."""
+118
View File
@@ -0,0 +1,118 @@
# ruff: noqa: F401,F403,F405,I001
import asyncio
import base64
import hashlib
import html
import hmac
import io
import ipaddress
import json
import logging
import os
import re
import secrets
import socket
import subprocess
import time
from collections import deque
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from urllib.parse import parse_qsl, quote, urlencode, urlsplit, urlunsplit
from aiogram import Bot, Dispatcher
from aiohttp import ClientSession, ClientTimeout, web
from pydantic import BaseModel, ConfigDict, EmailStr, ValidationError, constr, field_validator
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import sessionmaker
from bot.app.web.admin_api import (
admin_auth_middleware,
setup_admin_routes,
)
from bot.app.web.webapp_auth import (
create_signed_telegram_oauth_state,
create_telegram_oauth_nonce,
create_webapp_session_token,
validate_telegram_login_widget_data,
validate_telegram_oauth_id_token,
validate_telegram_webapp_init_data,
verify_signed_telegram_oauth_state,
verify_telegram_oauth_nonce,
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_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 (
panel_description_from_profile,
sanitize_display_name,
sanitize_username,
)
from config.settings import Settings
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
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"
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_CONFIG_PLACEHOLDER = "<!-- WEBAPP_CONFIG_SCRIPT -->"
WEBAPP_I18N_PLACEHOLDER = "<!-- WEBAPP_I18N_SCRIPT -->"
WEBAPP_JS_PLACEHOLDER = "<!-- WEBAPP_JS_SCRIPT -->"
APP_REPOSITORY_URL = "https://github.com/3252a8/remnawave-minishop"
DEV_MOCK_START_MARKER = "<!-- WEBAPP_DEV_MOCK_START -->"
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 = {
".gif": "image/gif",
".ico": "image/x-icon",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".svg": "image/svg+xml",
".webp": "image/webp",
}
WEBAPP_TELEGRAM_AVATAR_MAX_BYTES = 128 * 1024
WEBAPP_TELEGRAM_AVATAR_REFRESH_SECONDS = 24 * 60 * 60
WEBAPP_TELEGRAM_AVATAR_FETCH_TIMEOUT_SECONDS = 4
WEBAPP_SESSION_COOKIE_NAME = "rw_webapp_session"
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"}
_APP_VERSION_CACHE: Optional[str] = None
WEBAPP_CSRF_EXEMPT_PATHS = {
"/api/auth/telegram/nonce",
"/api/auth/token",
"/api/auth/email/request",
"/api/auth/email/verify",
"/api/auth/email/magic",
"/api/auth/email/password",
"/api/auth/logout",
}
_SHARED_HTTP_SESSION: Optional[ClientSession] = None
_SHARED_HTTP_SESSION_LOCK = asyncio.Lock()
__all__ = [name for name in globals() if not name.startswith("__")]
+637
View File
@@ -0,0 +1,637 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
from bot.app.web.webapp.cache_helpers import webapp_cached_user_payload
from .auth import (
_hash_email_password,
_notify_account_merged,
_sync_merged_panel_identity_for_user,
)
from .common import _invalidate_webapp_user_caches
async def account_email_request_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
settings: Settings = request.app["settings"]
payload = await _read_json(request)
email_payload, validation_error = _validate_model_payload(WebAppEmailPayload, payload)
if validation_error:
return validation_error
email = email_payload.email
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user or db_user.is_banned:
return _json_error(403, "access_denied", "Access denied")
if db_user.email == email and db_user.email_verified_at:
return web.json_response({"ok": True, "already_linked": True})
lang = _normalize_language(db_user.language_code or settings.DEFAULT_LANGUAGE)
return await _request_email_code(
request,
email=email,
purpose="link_email",
language_code=lang,
target_user_id=user_id,
)
async def account_email_verify_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="account_email_verify",
)
if rate_limit_response:
return rate_limit_response
payload = await _read_json(request)
email_payload, validation_error = _validate_model_payload(WebAppEmailCodePayload, payload)
if validation_error:
return validation_error
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
final_user_id = user_id
final_email = email
final_telegram_id: Optional[int] = None
final_username: Optional[str] = None
final_first_name: Optional[str] = None
final_panel_uuid: Optional[str] = None
should_notify_email_linked = False
async with async_session_factory() as session:
try:
verify_result = await email_service.verify_code(
session,
email=email,
purpose="link_email",
code=code,
target_user_id=user_id,
)
if not verify_result.ok:
await session.commit()
status = 429 if verify_result.error == "rate_limited" else 400
return web.json_response(
{
"ok": False,
"error": verify_result.error or "invalid_code",
"retry_after": verify_result.retry_after,
"message": "Invalid code",
},
status=status,
)
current_user = await user_dal.get_user_by_id(session, user_id)
if not current_user or current_user.is_banned:
await session.rollback()
return _json_error(403, "access_denied", "Access denied")
should_notify_email_linked = (
bool(_telegram_id_for_user(current_user)) and not current_user.email
)
existing_email_user = await user_dal.get_user_by_email(session, email)
if existing_email_user and existing_email_user.user_id != current_user.user_id:
source_panel_uuid = existing_email_user.panel_user_uuid
current_user = await user_dal.merge_users(
session,
source_user_id=existing_email_user.user_id,
target_user_id=current_user.user_id,
)
merge_notice = await _build_account_merge_notice(
session,
merged_user=current_user,
source_user_id=existing_email_user.user_id,
source_panel_uuid=source_panel_uuid,
settings=settings,
)
current_user.email = email
current_user.email_verified_at = datetime.now(timezone.utc)
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)
final_username = current_user.username
final_first_name = current_user.first_name
final_panel_uuid = current_user.panel_user_uuid
if merge_notice:
merge_end_date_raw = 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,
current_user,
source_panel_uuid=source_panel_uuid,
final_panel_uuid=final_panel_uuid,
expire_at=merge_end_date,
)
email_service: EmailAuthService = request.app.get("email_auth_service")
if email_service and final_email:
email_content = render_account_merged(
settings,
language_code=merge_notice.get("language") or settings.DEFAULT_LANGUAGE,
primary_user_id=merge_notice.get("primary_user_id"),
removed_user_id=merge_notice.get("removed_user_id"),
final_end_date_text=str(
merge_notice.get("final_end_date_text")
or merge_notice.get("final_end_date")
or ""
),
)
try:
await email_service.send_rendered_email(
email=final_email,
content=email_content,
)
except Exception as exc:
logger.warning(
"Failed to send account merge email to %s: %s",
final_email,
exc,
)
except UserMergeConflictError as exc:
await session.rollback()
return _json_error(409, "account_merge_conflict", str(exc))
except Exception:
await session.rollback()
logger.exception("Email account link failed")
return _json_error(500, "link_failed", "Link failed")
await _invalidate_webapp_user_caches(settings, user_id, final_user_id, include_devices=True)
if merge_notice:
await _notify_account_merged(
request,
settings,
merge_notice=merge_notice,
email=final_email,
telegram_id=final_telegram_id,
username=final_username,
first_name=final_first_name,
)
if should_notify_email_linked:
try:
from bot.services.notification_service import NotificationService
bot: Bot = request.app["bot"]
notification_service = NotificationService(
bot,
settings,
request.app.get("i18n"),
)
await notification_service.notify_account_email_linked(
user_id=int(final_user_id),
email=final_email,
telegram_id=final_telegram_id,
username=final_username,
first_name=final_first_name,
)
except Exception:
logger.exception("Failed to send account email linked notification")
token = create_webapp_session_token(settings, int(final_user_id))
response_payload: Dict[str, Any] = {"ok": True}
if merge_notice:
response_payload["account_merge"] = merge_notice
response_payload["user_id"] = final_user_id
return _build_webapp_auth_response(settings, response_payload, token=token)
async def account_password_request_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
settings: Settings = request.app["settings"]
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user or db_user.is_banned:
return _json_error(403, "access_denied", "Access denied")
if not db_user.email or not db_user.email_verified_at:
return _json_error(400, "email_not_linked", "Email is not linked")
email = db_user.email
lang = _normalize_language(db_user.language_code or settings.DEFAULT_LANGUAGE)
return await _request_email_code(
request,
email=email,
purpose="set_password",
language_code=lang,
target_user_id=user_id,
)
async def account_password_confirm_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
payload = await _read_json(request)
password_payload, validation_error = _validate_model_payload(WebAppSetPasswordPayload, payload)
if validation_error:
return validation_error
if password_payload.password != password_payload.password_confirm:
return _json_error(400, "password_mismatch", "Passwords do not match")
settings: Settings = request.app["settings"]
email_service: EmailAuthService = request.app["email_auth_service"]
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
try:
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user or db_user.is_banned:
await session.rollback()
return _json_error(403, "access_denied", "Access denied")
if not db_user.email or not db_user.email_verified_at:
await session.rollback()
return _json_error(400, "email_not_linked", "Email is not linked")
verify_result = await email_service.verify_code(
session,
email=db_user.email,
purpose="set_password",
code=str(password_payload.code or ""),
target_user_id=user_id,
)
if not verify_result.ok:
await session.commit()
status = 429 if verify_result.error == "rate_limited" else 400
return web.json_response(
{
"ok": False,
"error": verify_result.error or "invalid_code",
"retry_after": verify_result.retry_after,
"message": "Invalid code",
},
status=status,
)
db_user.password_hash = _hash_email_password(str(password_payload.password))
db_user.password_set_at = datetime.now(timezone.utc)
await session.flush()
await session.commit()
except Exception:
await session.rollback()
logger.exception("Email password setup failed")
return _json_error(500, "password_setup_failed", "Password setup failed")
await _invalidate_webapp_user_caches(settings, user_id)
return web.json_response({"ok": True, "password_auth_enabled": True})
async def account_telegram_link_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
settings: Settings = request.app["settings"]
payload = await _read_json(request)
telegram_user = await _validate_telegram_auth_payload(request, payload)
if not telegram_user:
return _json_error(401, "invalid_auth", "Invalid Telegram auth data")
async_session_factory: sessionmaker = request.app["async_session_factory"]
merge_notice: Optional[Dict[str, Any]] = None
source_panel_uuid: Optional[str] = None
final_user_id = user_id
final_telegram_id: Optional[int] = None
final_email: Optional[str] = None
final_username: Optional[str] = None
final_first_name: Optional[str] = None
final_panel_uuid: Optional[str] = None
should_notify_telegram_linked = False
async with async_session_factory() as session:
try:
current_user_before_link = await user_dal.get_user_by_id(session, user_id)
if not current_user_before_link or current_user_before_link.is_banned:
await session.rollback()
return _json_error(403, "access_denied", "Access denied")
should_notify_telegram_linked = bool(
current_user_before_link.email
) and not _telegram_id_for_user(current_user_before_link)
source_panel_uuid = current_user_before_link.panel_user_uuid
db_user = await _link_telegram_to_user(
request,
session,
current_user_id=user_id,
telegram_user=telegram_user,
settings=settings,
)
if db_user.is_banned:
await session.rollback()
return _json_error(403, "banned", "Access denied")
final_user_id = int(db_user.user_id)
final_telegram_id = _telegram_id_for_user(db_user)
final_email = db_user.email
final_username = db_user.username
final_first_name = db_user.first_name
final_panel_uuid = db_user.panel_user_uuid
if final_user_id != user_id:
merge_notice = await _build_account_merge_notice(
session,
merged_user=db_user,
source_user_id=user_id,
source_panel_uuid=source_panel_uuid,
settings=settings,
)
await session.commit()
if merge_notice:
merge_end_date_raw = 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,
db_user,
source_panel_uuid=source_panel_uuid,
final_panel_uuid=final_panel_uuid,
expire_at=merge_end_date,
)
email_service: EmailAuthService = request.app.get("email_auth_service")
if email_service and final_email:
email_content = render_account_merged(
settings,
language_code=merge_notice.get("language") or settings.DEFAULT_LANGUAGE,
primary_user_id=merge_notice.get("primary_user_id"),
removed_user_id=merge_notice.get("removed_user_id"),
final_end_date_text=str(
merge_notice.get("final_end_date_text")
or merge_notice.get("final_end_date")
or ""
),
)
try:
await email_service.send_rendered_email(
email=final_email,
content=email_content,
)
except Exception as exc:
logger.warning(
"Failed to send account merge email to %s: %s",
final_email,
exc,
)
except UserMergeConflictError as exc:
await session.rollback()
return _json_error(409, "account_merge_conflict", str(exc))
except Exception:
await session.rollback()
logger.exception("Telegram account link failed")
return _json_error(500, "link_failed", "Link failed")
await _invalidate_webapp_user_caches(settings, user_id, final_user_id, include_devices=True)
if merge_notice:
await _notify_account_merged(
request,
settings,
merge_notice=merge_notice,
email=final_email,
telegram_id=final_telegram_id,
username=final_username,
first_name=final_first_name,
)
if should_notify_telegram_linked and final_telegram_id:
try:
from bot.services.notification_service import NotificationService
bot: Bot = request.app["bot"]
notification_service = NotificationService(
bot,
settings,
request.app.get("i18n"),
)
await notification_service.notify_account_telegram_linked(
user_id=int(final_user_id),
email=final_email,
telegram_id=int(final_telegram_id),
username=final_username,
first_name=final_first_name,
)
except Exception:
logger.exception("Failed to send account Telegram linked notification")
token = create_webapp_session_token(settings, int(final_user_id))
response_payload: Dict[str, Any] = {
"ok": True,
"user_id": int(final_user_id),
"telegram_id": final_telegram_id,
}
if merge_notice:
response_payload["account_merge"] = merge_notice
return _build_webapp_auth_response(settings, response_payload, token=token)
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",
user_id,
int(getattr(settings, "WEBAPP_ME_CACHE_TTL_SECONDS", 15) or 0),
lambda: _build_user_payload(request, user_id),
)
return web.json_response({"ok": True, **data})
async def account_avatar_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
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")
avatar = await _ensure_cached_telegram_avatar(request, session, db_user)
await session.commit()
if not avatar:
raise web.HTTPNotFound(text="avatar_not_cached")
etag = _telegram_avatar_etag(avatar)
if etag and request.headers.get("If-None-Match") == etag:
return web.Response(status=304, headers={"ETag": etag})
response = web.Response(
body=bytes(avatar.image_bytes),
content_type=avatar.content_type or "image/jpeg",
)
response.headers["Cache-Control"] = "private, max-age=3600"
if etag:
response.headers["ETag"] = etag
return response
async def account_language_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
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)
if not db_user or db_user.is_banned:
await session.rollback()
return _json_error(403, "access_denied", "Access denied")
if _normalize_language(db_user.language_code or "") != language:
db_user.language_code = language
await session.flush()
await session.commit()
await _invalidate_webapp_user_caches(settings, user_id)
return web.json_response({"ok": True, "language": language})
def _format_webapp_datetime(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")
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
def _telegram_avatar_is_stale(avatar: Optional[UserTelegramAvatar]) -> bool:
if not avatar or not avatar.updated_at:
return True
updated_at = avatar.updated_at
if updated_at.tzinfo is None:
updated_at = updated_at.replace(tzinfo=timezone.utc)
return (
datetime.now(timezone.utc) - updated_at
).total_seconds() >= WEBAPP_TELEGRAM_AVATAR_REFRESH_SECONDS
def _telegram_avatar_etag(avatar: UserTelegramAvatar) -> str:
digest = hashlib.sha256(bytes(avatar.image_bytes)).hexdigest()[:16]
return f'"tg-avatar-{int(avatar.user_id)}-{digest}"'
def _telegram_avatar_url(avatar: Optional[UserTelegramAvatar]) -> str:
if not avatar:
return ""
updated_at = avatar.updated_at
if updated_at and updated_at.tzinfo is None:
updated_at = updated_at.replace(tzinfo=timezone.utc)
version = (
int(updated_at.timestamp())
if updated_at
else hashlib.sha256(bytes(avatar.image_bytes)).hexdigest()[:8]
)
return f"/api/account/avatar?v={version}"
def _select_compact_telegram_photo_size(sizes: List[Any]) -> Optional[Any]:
if not sizes:
return None
suitable = [size for size in sizes if int(getattr(size, "width", 0) or 0) >= 160]
candidates = suitable or sizes
return min(
candidates,
key=lambda size: (
int(getattr(size, "file_size", 0) or 0)
or int(getattr(size, "width", 0) or 0) * int(getattr(size, "height", 0) or 0),
int(getattr(size, "width", 0) or 0),
),
)
def _telegram_file_content_type(file_path: Optional[str]) -> str:
path = str(file_path or "").lower()
if path.endswith(".png"):
return "image/png"
if path.endswith(".webp"):
return "image/webp"
return "image/jpeg"
async def _fetch_compact_telegram_avatar(
bot: Bot, telegram_id: int
) -> Optional[Tuple[bytes, str, Optional[str]]]:
photos = await bot.get_user_profile_photos(user_id=telegram_id, limit=1)
if not photos or not photos.photos:
return None
photo_size = _select_compact_telegram_photo_size(list(photos.photos[0] or []))
if not photo_size:
return None
file_info = await bot.get_file(photo_size.file_id)
destination = io.BytesIO()
await bot.download_file(file_info.file_path, destination=destination)
body = destination.getvalue()
if not body or len(body) > WEBAPP_TELEGRAM_AVATAR_MAX_BYTES:
return None
return (
body,
_telegram_file_content_type(file_info.file_path),
getattr(photo_size, "file_unique_id", None),
)
async def _ensure_cached_telegram_avatar(
request: web.Request,
session: AsyncSession,
user: User,
) -> Optional[UserTelegramAvatar]:
avatar = await user_dal.get_user_telegram_avatar(session, int(user.user_id))
telegram_id = _telegram_id_for_user(user)
if not telegram_id:
return avatar
if avatar and not _telegram_avatar_is_stale(avatar):
return avatar
bot: Bot = request.app["bot"]
try:
fetched = await asyncio.wait_for(
_fetch_compact_telegram_avatar(bot, int(telegram_id)),
timeout=WEBAPP_TELEGRAM_AVATAR_FETCH_TIMEOUT_SECONDS,
)
except Exception as exc:
logger.info("Failed to refresh Telegram avatar for user %s: %s", user.user_id, exc)
return avatar
if not fetched:
return avatar
body, content_type, file_unique_id = fetched
return await user_dal.upsert_user_telegram_avatar(
session,
user_id=int(user.user_id),
file_unique_id=file_unique_id,
content_type=content_type,
image_bytes=body,
)
+64
View File
@@ -0,0 +1,64 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
from .guides import warm_subscription_guides_config
def create_subscription_webapp_application(
dp: Dispatcher,
bot: Bot,
settings: Settings,
async_session_factory: sessionmaker,
) -> web.Application:
app = web.Application(
middlewares=[
_security_headers_middleware,
_csrf_protection_middleware,
admin_auth_middleware,
]
)
app["bot"] = bot
app["dp"] = dp
app["settings"] = settings
app["async_session_factory"] = async_session_factory
app["i18n"] = dp.get("i18n_instance")
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()
app.on_startup.append(_startup)
app.on_shutdown.append(_shutdown)
from bot.payment_providers import iter_service_keys
for key in (
"subscription_service",
"promo_code_service",
"referral_service",
"support_service",
"notification_service",
"email_auth_service",
"panel_service",
*iter_service_keys(),
):
if hasattr(dp, "workflow_data") and key in dp.workflow_data: # type: ignore[attr-defined]
app[key] = dp.workflow_data[key] # type: ignore[index]
if hasattr(dp, "workflow_data") and "bot_username" in dp.workflow_data: # type: ignore[attr-defined]
app["bot_username"] = dp.workflow_data["bot_username"] # type: ignore[index]
setup_subscription_webapp_routes(app)
return app
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+963
View File
@@ -0,0 +1,963 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
from bot.app.web.webapp.cache_helpers import invalidate_webapp_user_caches
async def apply_promo_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
payload = await _read_json(request)
code = str(payload.get("code") or "").strip()
if not code:
return _json_error(400, "empty_code", "Promo code is empty")
settings: Settings = request.app["settings"]
promo_code_service: PromoCodeService = request.app.get("promo_code_service")
if not promo_code_service:
return _json_error(503, "service_unavailable", "Promo service unavailable")
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")
lang = _normalize_language(db_user.language_code or settings.DEFAULT_LANGUAGE)
success, result = await promo_code_service.apply_promo_code(
session,
user_id,
code,
lang,
)
if not success:
await session.commit()
return _json_error(400, "promo_apply_failed", str(result))
await session.commit()
end_date = result if isinstance(result, datetime) else None
return web.json_response(
{
"ok": True,
"end_date": end_date.isoformat() if end_date else None,
"end_date_text": end_date.strftime("%d.%m.%Y %H:%M") if end_date else None,
}
)
except Exception:
await session.rollback()
logger.exception("WebApp promo apply failed")
return _json_error(500, "promo_apply_failed", "Promo apply failed")
async def create_payment_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="payments_create",
)
if rate_limit_response:
return rate_limit_response
payload = await _read_json(request)
payment_payload, validation_error = _validate_model_payload(WebAppPaymentCreatePayload, payload)
if validation_error:
return validation_error
method = str(payment_payload.method or "").strip().lower()
settings: Settings = request.app["settings"]
cached = _get_cached_webapp_settings(request)
tariffs_config = settings.tariffs_config
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",
"hwid_devices_renewal",
}:
tariff_key = str(payment_payload.tariff_key or "").strip()
if not tariff_key:
return _json_error(400, "invalid_plan", "Tariff is not selected")
try:
tariff = tariffs_config.require(tariff_key)
except Exception:
return _json_error(400, "invalid_plan", "Tariff is not available")
if tariff.billing_model != "period":
return _json_error(400, "invalid_plan", "Device top-up is not available")
try:
device_count = int(
float(
payment_payload.device_count
if payment_payload.device_count is not None
else payment_payload.months
)
)
except (TypeError, ValueError):
return _json_error(400, "invalid_plan", "Invalid device package")
if not tariff.hwid_device_packages:
return _json_error(400, "invalid_plan", "Device package is not available")
payment_units = device_count
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:
return _json_error(400, "invalid_plan", "Tariff is not selected")
try:
tariff = tariffs_config.require(tariff_key)
except Exception:
return _json_error(400, "invalid_plan", "Tariff is not available")
try:
traffic_gb = float(
payment_payload.traffic_gb
if payment_payload.traffic_gb is not None
else payment_payload.months
)
except (TypeError, ValueError):
return _json_error(400, "invalid_plan", "Invalid traffic package")
packages = (
tariff.premium_topup_packages
if requested_sale_mode == "premium_topup"
else tariffs_config.topup_packages_for(tariff)
)
rub_packages = {
float(package.gb): float(package.price)
for package in (packages.rub 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)
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
stars_price = (
stars_packages.get(stars_package_key) if stars_package_key is not None else None
)
if price is None and method != "stars":
return _json_error(400, "invalid_plan", "Traffic 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 = int(traffic_gb) if float(traffic_gb).is_integer() else traffic_gb
traffic_gb_for_payment = float(payment_units)
sale_mode = f"{requested_sale_mode}@{tariff.key}"
elif tariffs_config:
tariff_key = str(payment_payload.tariff_key or "").strip()
if not tariff_key:
return _json_error(400, "invalid_plan", "Tariff is not selected")
try:
tariff = tariffs_config.require(tariff_key)
except Exception:
return _json_error(400, "invalid_plan", "Tariff is not available")
if tariff.billing_model == "traffic":
try:
traffic_gb = float(
payment_payload.traffic_gb
if payment_payload.traffic_gb is not None
else payment_payload.months
)
except (TypeError, ValueError):
return _json_error(400, "invalid_plan", "Invalid traffic package")
if traffic_gb <= 0:
return _json_error(400, "invalid_plan", "Invalid traffic package")
rub_packages = {
float(package.gb): float(package.price)
for package in (tariff.traffic_packages.rub 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)
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
stars_price = (
stars_packages.get(stars_package_key) if stars_package_key is not None else None
)
if price is None and method != "stars":
return _json_error(400, "invalid_plan", "Traffic 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 = int(traffic_gb) if float(traffic_gb).is_integer() else traffic_gb
traffic_gb_for_payment = float(payment_units)
sale_mode = f"traffic_package@{tariff.key}"
else:
try:
months = int(float(payment_payload.months))
except (TypeError, ValueError):
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")
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":
return _json_error(400, "invalid_plan", "Subscription period 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 = months
sale_mode = f"subscription@{tariff.key}"
elif traffic_mode:
try:
traffic_gb = float(
payment_payload.traffic_gb
if payment_payload.traffic_gb is not None
else payment_payload.months
)
except (TypeError, ValueError):
return _json_error(400, "invalid_plan", "Invalid traffic package")
if traffic_gb <= 0:
return _json_error(400, "invalid_plan", "Invalid traffic package")
package_key = _resolve_numeric_option_key(cached["traffic_packages"], traffic_gb)
stars_package_key = _resolve_numeric_option_key(
cached["stars_traffic_packages"], traffic_gb
)
price = cached["traffic_packages"].get(package_key) if package_key is not None else None
stars_price = (
cached["stars_traffic_packages"].get(stars_package_key)
if stars_package_key is not None
else None
)
if price is None and method != "stars":
return _json_error(400, "invalid_plan", "Traffic 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 = int(traffic_gb) if float(traffic_gb).is_integer() else traffic_gb
traffic_gb_for_payment = float(payment_units)
sale_mode = "traffic"
else:
try:
months = int(float(payment_payload.months))
except (TypeError, ValueError):
return _json_error(400, "invalid_plan", "Invalid subscription period")
price = cached["subscription_options"].get(months)
stars_price = cached["stars_subscription_options"].get(months)
if price is None and method != "stars":
return _json_error(400, "invalid_plan", "Subscription period 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 = months
sale_mode = "subscription"
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user or db_user.is_banned:
return _json_error(403, "access_denied", "Access denied")
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 "rub"
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=_sale_mode_base(sale_mode) == "hwid_devices_renewal",
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
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,
user_id=user_id,
method=method,
months=payment_units,
price=float(price or 0),
stars_price=stars_price,
lang=lang,
sale_mode=sale_mode,
traffic_gb=traffic_gb_for_payment,
is_admin=is_admin,
hwid_quote=hwid_quote,
)
async def activate_trial_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="trial_activate",
)
if rate_limit_response:
return rate_limit_response
settings: Settings = request.app["settings"]
if not settings.TRIAL_ENABLED or settings.TRIAL_DURATION_DAYS <= 0:
return _json_error(400, "trial_unavailable", "Trial is not available")
async_session_factory: sessionmaker = request.app["async_session_factory"]
subscription_service: SubscriptionService = request.app["subscription_service"]
async with async_session_factory() as session:
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user or db_user.is_banned:
return _json_error(403, "access_denied", "Access denied")
activation_result = await subscription_service.activate_trial_subscription(session, user_id)
if not activation_result or not activation_result.get("activated"):
await session.rollback()
message_key = (
activation_result.get("message_key", "trial_activation_failed")
if activation_result
else "trial_activation_failed"
)
status = 400 if message_key != "trial_activation_failed_panel_update" else 502
return _json_error(status, message_key, message_key)
end_date = activation_result.get("end_date")
config_link, connect_url = await prepare_config_links(
settings,
activation_result.get("subscription_url"),
)
i18n_instance = request.app.get("i18n")
if settings.LOG_TRIAL_ACTIVATIONS and i18n_instance:
try:
from bot.services.notification_service import NotificationService
notification_service = NotificationService(
request.app["bot"], settings, i18n_instance
)
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:
from db.dal import ad_dal as _ad_dal
await _ad_dal.mark_trial_activated(session, user_id)
await session.commit()
except Exception:
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,
"activated": True,
"days": activation_result.get("days", settings.TRIAL_DURATION_DAYS),
"end_date": end_date.isoformat() if isinstance(end_date, datetime) else None,
"end_date_text": _format_webapp_datetime(end_date)
if isinstance(end_date, datetime)
else None,
"traffic_gb": activation_result.get("traffic_gb", settings.TRIAL_TRAFFIC_LIMIT_GB),
"config_link": config_link,
"connect_url": connect_url or config_link,
}
)
async def tariff_topup_options_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
settings: Settings = request.app["settings"]
config = settings.tariffs_config
topup_kind = str(request.query.get("kind") or "all").strip().lower()
if topup_kind not in {"all", "regular", "premium"}:
return _json_error(400, "invalid_topup_kind", "Invalid topup kind")
if not config:
return _json_error(404, "tariffs_unavailable", "Tariffs are not configured")
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user or db_user.is_banned:
return _json_error(403, "access_denied", "Access denied")
sub = await subscription_dal.get_active_subscription_by_user_id(
session, user_id, db_user.panel_user_uuid
)
if not sub or not sub.tariff_key:
return _json_error(
400, "subscription_required", "Active tariff subscription is required"
)
lang = db_user.language_code or settings.DEFAULT_LANGUAGE
tariff = config.require(sub.tariff_key)
plans = (
_serialize_topup_packages(settings, tariff, config.topup_packages_for(tariff), lang)
if topup_kind in {"all", "regular"}
else []
)
premium_plans = (
_serialize_topup_packages(
settings,
tariff,
tariff.premium_topup_packages,
lang,
sale_mode="premium_topup",
title_prefix=f"{tariff.premium_name(lang)} ",
)
if topup_kind in {"all", "premium"} and tariff.premium_squad_uuids
else []
)
premium_bonus_bytes = int(getattr(sub, "premium_bonus_bytes", 0) or 0)
premium_unlimited_override = bool(getattr(sub, "premium_unlimited_override", False))
premium_limit_bytes = (
int(sub.premium_baseline_bytes or 0)
+ int(sub.premium_topup_balance_bytes or 0)
+ int(getattr(sub, "premium_topup_used_bytes", 0) or 0)
+ premium_bonus_bytes
)
premium_access = await request.app["subscription_service"].premium_access_for_tariff(tariff)
return web.json_response(
{
"ok": True,
"tariff_key": tariff.key,
"tariff_name": tariff.name(lang),
"topup_kind": topup_kind,
"premium_title": tariff.premium_name(lang),
"traffic_percent": _traffic_percent(
sub.traffic_used_bytes, sub.traffic_limit_bytes
),
"premium_traffic_percent": 0
if premium_unlimited_override
else _traffic_percent(
sub.premium_used_bytes,
premium_limit_bytes,
),
"premium_limit_bytes": premium_limit_bytes,
"premium_used_bytes": int(sub.premium_used_bytes or 0),
"premium_baseline_bytes": int(sub.premium_baseline_bytes or 0),
"premium_topup_balance_bytes": int(sub.premium_topup_balance_bytes or 0),
"premium_topup_used_bytes": int(getattr(sub, "premium_topup_used_bytes", 0) or 0),
"premium_bonus_bytes": premium_bonus_bytes,
"premium_unlimited_override": premium_unlimited_override,
"premium_is_limited": bool(sub.premium_is_limited),
"premium_squad_labels": premium_access.get("squad_labels") or [],
"premium_node_labels": premium_access.get("node_labels") or [],
"warning_levels": settings.tariff_traffic_warning_levels,
"plans": plans + premium_plans,
}
)
async def tariff_change_options_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
settings: Settings = request.app["settings"]
config = settings.tariffs_config
if not config:
return _json_error(404, "tariffs_unavailable", "Tariffs are not configured")
async_session_factory: sessionmaker = request.app["async_session_factory"]
subscription_service: SubscriptionService = request.app["subscription_service"]
async with async_session_factory() as session:
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user or db_user.is_banned:
return _json_error(403, "access_denied", "Access denied")
sub = await subscription_dal.get_active_subscription_by_user_id(
session, user_id, db_user.panel_user_uuid
)
if not sub or not sub.tariff_key:
return _json_error(
400, "subscription_required", "Active tariff subscription is required"
)
lang = db_user.language_code or settings.DEFAULT_LANGUAGE
current = config.require(sub.tariff_key)
targets = []
for tariff in config.enabled_tariffs:
if tariff.key == current.key:
continue
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(
{
"ok": True,
"current": {
"tariff_key": current.key,
"title": current.name(lang),
"description": current.description(lang),
"billing_model": current.billing_model,
},
"targets": targets,
}
)
async def tariff_change_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
payload = await _read_json(request)
change_payload, validation_error = _validate_model_payload(WebAppTariffChangePayload, payload)
if validation_error:
return validation_error
mode = str(change_payload.mode or "").strip()
if mode not in {"recalc_days", "convert_days_to_gb"}:
return _json_error(400, "invalid_change_mode", "This tariff change requires payment")
settings: Settings = request.app["settings"]
if not settings.tariffs_config:
return _json_error(404, "tariffs_unavailable", "Tariffs are not configured")
async_session_factory: sessionmaker = request.app["async_session_factory"]
subscription_service: SubscriptionService = request.app["subscription_service"]
async with async_session_factory() as session:
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user or db_user.is_banned:
return _json_error(403, "access_denied", "Access denied")
result = await subscription_service.switch_tariff_without_payment(
session,
user_id,
str(change_payload.tariff_key),
mode,
)
if not result:
await session.rollback()
return _json_error(400, "change_failed", "Tariff change failed")
await session.commit()
return web.json_response({"ok": True, **result})
async def tariff_change_payment_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
payload = await _read_json(request)
payment_payload, validation_error = _validate_model_payload(WebAppPaymentCreatePayload, payload)
if validation_error:
return validation_error
method = str(payment_payload.method or "").strip().lower()
tariff_key = str(payment_payload.tariff_key or "").strip()
settings: Settings = request.app["settings"]
config = settings.tariffs_config
if not config:
return _json_error(404, "tariffs_unavailable", "Tariffs are not configured")
if not tariff_key:
return _json_error(400, "invalid_plan", "Tariff is not selected")
async_session_factory: sessionmaker = request.app["async_session_factory"]
subscription_service: SubscriptionService = request.app["subscription_service"]
async with async_session_factory() as session:
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user or db_user.is_banned:
return _json_error(403, "access_denied", "Access denied")
sub = await subscription_dal.get_active_subscription_by_user_id(
session, user_id, db_user.panel_user_uuid
)
if not sub:
return _json_error(
400, "subscription_required", "Active tariff subscription is required"
)
target = config.require(tariff_key)
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(
400, "payment_not_required", "Payment is not required for this tariff change"
)
return await _create_subscription_payment(
request=request,
session=session,
user_id=user_id,
method=method,
months=1,
price=price,
stars_price=None,
lang=db_user.language_code or settings.DEFAULT_LANGUAGE,
sale_mode=f"tariff_upgrade@{target.key}",
)
async def device_topup_options_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
settings: Settings = request.app["settings"]
config = settings.tariffs_config
if not settings.MY_DEVICES_SECTION_ENABLED:
return _json_error(404, "devices_disabled", "Devices section is disabled")
if not config:
return _json_error(404, "tariffs_unavailable", "Tariffs are not configured")
async_session_factory: sessionmaker = request.app["async_session_factory"]
subscription_service: SubscriptionService = request.app["subscription_service"]
async with async_session_factory() as session:
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user or db_user.is_banned:
return _json_error(403, "access_denied", "Access denied")
sub = await subscription_dal.get_active_subscription_by_user_id(
session, user_id, db_user.panel_user_uuid
)
if not sub or not sub.tariff_key:
return _json_error(
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)
renewal_available = bool(active and active.get("device_topup_renewal_available"))
packages = tariff.hwid_device_packages
rub_counts = {int(package.count) for package in (packages.rub if packages else [])}
stars_counts = {int(package.count) for package in (packages.stars if packages else [])}
plans = []
for count in sorted(rub_counts | stars_counts):
rub_quote = (
await subscription_service.quote_hwid_device_topup(
session,
user_id=user_id,
device_count=count,
tariff_key=tariff.key,
renewal=renewal_available,
currency="rub",
)
if count in rub_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=renewal_available,
currency="stars",
)
if count in stars_counts
else None
)
if not rub_quote and not stars_quote:
continue
sale_mode_for_plan = "hwid_devices_renewal" if renewal_available else "hwid_devices"
plan = {
"id": f"{tariff.key}:hwid:{count}{':renewal' if renewal_available else ''}",
"tariff_key": tariff.key,
"tariff_name": tariff.name(lang),
"billing_model": tariff.billing_model,
"sale_mode": sale_mode_for_plan,
"months": count,
"device_count": count,
"price": float(rub_quote.get("price") if rub_quote else 0),
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
"title": f"+{count}",
"subtitle": tariff.name(lang),
"valid_from": (
(rub_quote or stars_quote)["valid_from"].isoformat()
if (rub_quote or stars_quote).get("valid_from")
else None
),
"valid_until": (
(rub_quote or stars_quote)["valid_until"].isoformat()
if (rub_quote or stars_quote).get("valid_until")
else None
),
"proration_ratio": float((rub_quote or stars_quote).get("proration_ratio") or 0),
}
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(lang),
"current_limit": _coerce_int_or_none(active.get("max_devices")) if active else None,
"extra_hwid_devices": int(active.get("extra_hwid_devices") or 0)
if active
else int(sub.extra_hwid_devices or 0),
"extra_hwid_devices_valid_until": active.get("extra_hwid_devices_valid_until")
if active
else None,
"extra_hwid_devices_valid_until_text": active.get(
"extra_hwid_devices_valid_until_text"
)
if active
else None,
"renewal_available": renewal_available,
"renewal_recommended_count": int(active.get("extra_hwid_devices") or 0)
if active and renewal_available
else 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:
payment_id = int(request.match_info["payment_id"])
except (TypeError, ValueError):
return _json_error(400, "invalid_payment", "Invalid payment id")
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
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)
return web.json_response(
{
"ok": True,
"payment_id": payment.payment_id,
"status": payment.status,
"paid": payment.status == "succeeded",
}
)
def _sale_mode_base(sale_mode: str) -> str:
return str(sale_mode or "subscription").split("@", 1)[0].split("|", 1)[0]
def _sale_mode_tariff_key(sale_mode: str) -> Optional[str]:
if "@" not in str(sale_mode or ""):
return None
return str(sale_mode).split("@", 1)[1].split("|", 1)[0] or None
def _sale_mode_is_traffic(sale_mode: str) -> bool:
return _sale_mode_base(sale_mode) in {"traffic", "traffic_package", "topup", "premium_topup"}
def _sale_mode_is_hwid_devices(sale_mode: str) -> bool:
return _sale_mode_base(sale_mode) in {
"hwid_device",
"hwid_devices",
"hwid_devices_renewal",
}
async def _create_subscription_payment(
*,
request: web.Request,
session: AsyncSession,
user_id: int,
method: str,
months: Any,
price: float,
stars_price: Optional[int],
lang: str,
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"]
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)
description = (
_traffic_payment_description(float(traffic_gb if traffic_gb is not None else months), lang)
if traffic_sale
else _hwid_devices_payment_description(int(float(months)), lang)
if hwid_devices_sale
else _payment_description(int(months), lang)
)
from bot.payment_providers import WebAppPaymentContext, get_provider_spec
provider_spec = get_provider_spec(method)
if provider_spec and provider_spec.create_webapp_payment:
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_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,
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,
)
)
return _json_error(400, "payment_unavailable", "Payment method unavailable")
+140
View File
@@ -0,0 +1,140 @@
from __future__ import annotations
from typing import Any, Awaitable, Callable, Optional
from bot.infra.redis import cache_delete, cache_delete_pattern, redis_key
from bot.utils.ttl_cache import AsyncTTLCache
from config.settings import Settings
_WEBAPP_USER_PAYLOAD_CACHES: dict[tuple[int, str, int], AsyncTTLCache] = {}
def reset_webapp_settings_cache(app: Any) -> None:
cache = app.get("webapp_settings_cache") if hasattr(app, "get") else None
if isinstance(cache, dict):
cache["ts"] = 0.0
cache["data"] = {}
def reset_subscription_guides_cache(app: Any) -> None:
cache = app.get("subscription_guides_config_cache") if hasattr(app, "get") else None
if isinstance(cache, dict):
cache["fingerprint"] = None
cache["status"] = None
def _payload_namespaces(include_devices: bool = False) -> tuple[str, ...]:
return ("me", "devices") if include_devices else ("me",)
def _webapp_user_payload_cache(
settings: Settings,
namespace: str,
ttl_seconds: int,
) -> Optional[AsyncTTLCache]:
ttl = max(0, int(ttl_seconds or 0))
if ttl <= 0:
return None
cache_key = (id(settings), namespace, ttl)
cache = _WEBAPP_USER_PAYLOAD_CACHES.get(cache_key)
if cache is None:
cache = AsyncTTLCache(
ttl_seconds=ttl,
settings=settings,
namespace=f"webapp:{namespace}",
)
_WEBAPP_USER_PAYLOAD_CACHES[cache_key] = cache
return cache
async def webapp_cached_user_payload(
settings: Settings,
namespace: str,
user_id: int,
ttl_seconds: int,
loader: Callable[[], Awaitable[Any]],
) -> Any:
cache = _webapp_user_payload_cache(settings, namespace, ttl_seconds)
if cache is None:
return await loader()
return await cache.get_or_load(str(int(user_id)), loader)
def invalidate_local_webapp_user_payload(
settings: Settings,
namespace: str,
user_id: int,
) -> None:
key = str(int(user_id))
for (settings_id, cache_namespace, _ttl), cache in tuple(_WEBAPP_USER_PAYLOAD_CACHES.items()):
if settings_id == id(settings) and cache_namespace == namespace:
cache.invalidate(key)
def invalidate_all_local_webapp_user_payloads(
settings: Settings,
namespace: Optional[str] = None,
*,
include_devices: Optional[bool] = None,
) -> None:
if include_devices is not None:
namespaces: Optional[set[str]] = set(_payload_namespaces(include_devices))
elif namespace is not None:
namespaces = {namespace}
else:
namespaces = None
for (settings_id, cache_namespace, _ttl), cache in tuple(_WEBAPP_USER_PAYLOAD_CACHES.items()):
if settings_id != id(settings):
continue
if namespaces is not None and cache_namespace not in namespaces:
continue
cache.invalidate()
async def invalidate_webapp_user_caches(
settings: Settings,
*user_ids: Optional[int],
include_devices: bool = False,
) -> None:
keys: list[str] = []
seen: set[int] = set()
for raw_user_id in user_ids:
if raw_user_id is None:
continue
try:
user_id = int(raw_user_id)
except (TypeError, ValueError):
continue
if user_id in seen:
continue
seen.add(user_id)
keys.append(redis_key(settings, "cache", "webapp", "me", user_id))
invalidate_local_webapp_user_payload(settings, "me", user_id)
if include_devices:
keys.append(redis_key(settings, "cache", "webapp", "devices", user_id))
invalidate_local_webapp_user_payload(settings, "devices", user_id)
if keys:
await cache_delete(settings, *keys)
async def invalidate_all_webapp_user_payloads(
settings: Settings,
*,
include_devices: bool = False,
) -> None:
for namespace in _payload_namespaces(include_devices):
invalidate_all_local_webapp_user_payloads(settings, namespace=namespace)
try:
pattern = redis_key(settings, "cache", "webapp", namespace, "*")
await cache_delete_pattern(settings, pattern)
except Exception:
continue
async def invalidate_all_webapp_user_caches(
settings: Settings,
*,
include_devices: bool = False,
) -> None:
await invalidate_all_webapp_user_payloads(settings, include_devices=include_devices)
+179
View File
@@ -0,0 +1,179 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
from bot.app.web.webapp.cache_helpers import (
invalidate_webapp_user_caches as _invalidate_user_payload_caches,
)
from bot.middlewares.i18n import (
is_valid_locale_language_code,
normalize_locale_language_code,
)
async def _read_json(request: web.Request) -> Dict[str, Any]:
try:
data = await request.json()
return data if isinstance(data, dict) else {}
except Exception:
return {}
def _json_error(status: int, code: str, message: str) -> web.Response:
return web.json_response(
{"ok": False, "error": code, "message": message},
status=status,
)
async def _invalidate_webapp_user_caches(
settings: Settings,
*user_ids: Optional[int],
include_devices: bool = False,
) -> None:
await _invalidate_user_payload_caches(settings, *user_ids, include_devices=include_devices)
def _validation_error_response(exc: ValidationError) -> web.Response:
for error in exc.errors():
loc = error.get("loc") or ()
field = str(loc[0]) if loc else ""
error_type = str(error.get("type") or "")
message = str(error.get("msg") or "")
message_lower = message.lower()
if field == "email":
if (
"too_long" in message_lower
or "too long" in message_lower
or error_type == "string_too_long"
):
return _json_error(400, "email_too_long", "Email is too long")
return _json_error(400, "invalid_email", "Invalid email")
if field in {"description", "comment", "note"} and error_type == "string_too_long":
return _json_error(400, f"{field}_too_long", f"{field.capitalize()} is too long")
if field in {"password", "password_confirm"}:
if error_type == "string_too_short":
return _json_error(400, "password_too_short", "Password is too short")
if error_type == "string_too_long":
return _json_error(400, "password_too_long", "Password is too long")
return _json_error(400, "invalid_password", "Invalid password")
if error_type == "string_too_long":
return _json_error(400, "text_too_long", "Text is too long")
return _json_error(400, "invalid_request", "Invalid request")
def _validate_model_payload(
model_cls: type[BaseModel],
payload: Dict[str, Any],
) -> tuple[Optional[BaseModel], Optional[web.Response]]:
try:
return model_cls.model_validate(payload), None
except ValidationError as exc:
return None, _validation_error_response(exc)
def _normalize_language(lang: Optional[str]) -> str:
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:
if seconds <= 0:
if lang == "en":
return "Subscription inactive"
return "Подписка не активна"
days, rem = divmod(seconds, 86400)
hours, rem = divmod(rem, 3600)
minutes = rem // 60
if lang == "en":
if days > 0:
return f"{days} d. {hours} h."
if hours > 0:
return f"{hours} h. {minutes} min."
return f"{max(1, minutes)} min."
if days > 0:
return f"{days} д. {hours} ч."
if hours > 0:
return f"{hours} ч. {minutes} мин."
return f"{max(1, minutes)} мин."
def _coerce_int_or_none(value: Optional[Any]) -> Optional[int]:
if value is None:
return None
try:
return int(value)
except (TypeError, ValueError):
return None
def _format_bytes(value: Optional[Any], *, zero_as_unlimited: bool = False) -> str:
if value is None:
return "N/A"
try:
size = float(value)
except (TypeError, ValueError):
return str(value)
if size <= 0 and zero_as_unlimited:
return ""
if size <= 0:
size = 0
units = ["B", "KB", "MB", "GB", "TB"]
index = 0
while size >= 1024 and index < len(units) - 1:
size /= 1024
index += 1
return f"{size:.2f} {units[index]}"
def _format_months_title(months: int, lang: str) -> str:
if lang == "en":
if months == 1:
return "1 month"
return f"{months} months"
if months == 1:
return "1 месяц"
if 2 <= months <= 4:
return f"{months} месяца"
return f"{months} месяцев"
def _format_number_for_payload(value: Any) -> str:
numeric = float(value or 0)
return str(int(numeric)) if numeric.is_integer() else f"{numeric:g}"
def _format_traffic_title(traffic_gb: float, lang: str) -> str:
return f"{_format_number_for_payload(traffic_gb)} GB"
def _traffic_payment_description(traffic_gb: float, lang: str) -> str:
if lang == "en":
return f"Traffic package {_format_traffic_title(traffic_gb, lang)}"
return f"Пакет трафика {_format_traffic_title(traffic_gb, lang)}"
def _hwid_devices_payment_description(device_count: int, lang: str) -> str:
if lang == "en":
return f"HWID device package +{device_count}"
return f"Докупка устройств HWID +{device_count}"
def _resolve_numeric_option_key(options: Dict[Any, Any], target: float) -> Optional[Any]:
for key in options:
try:
if abs(float(key) - float(target)) < 0.000001:
return key
except (TypeError, ValueError):
continue
return None
def _payment_description(months: int, lang: str) -> str:
if lang == "en":
return f"Subscription for {_format_months_title(months, lang)}"
return f"Подписка на {_format_months_title(months, lang)}"
+216
View File
@@ -0,0 +1,216 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
from bot.app.web.webapp.cache_helpers import webapp_cached_user_payload
async def devices_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
settings: Settings = request.app["settings"]
if not settings.MY_DEVICES_SECTION_ENABLED:
return _json_error(404, "devices_disabled", "Devices section is disabled")
async_session_factory: sessionmaker = request.app["async_session_factory"]
subscription_service: SubscriptionService = request.app["subscription_service"]
async with async_session_factory() as session:
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user or db_user.is_banned:
return _json_error(403, "access_denied", "Access denied")
result = await webapp_cached_user_payload(
settings,
"devices",
user_id,
int(getattr(settings, "WEBAPP_DEVICES_CACHE_TTL_SECONDS", 5) or 0),
lambda: _load_devices_payload(subscription_service, session, user_id),
)
if isinstance(result, dict) and result.get("ok") is True:
return web.json_response({"ok": True, **(result.get("payload") or {})})
if isinstance(result, dict) and not result.get("error"):
# Backward-compatible with payloads written by older versions under
# the same Redis cache key.
return web.json_response({"ok": True, **result})
if not isinstance(result, dict):
result = {}
if not result.get("ok"):
return _json_error(
int(result.get("status") or 500),
str(result.get("error") or "devices_load_failed"),
str(result.get("message") or "Failed to load devices"),
)
return web.json_response({"ok": True, **(result.get("payload") or {})})
async def _load_devices_payload(
subscription_service: SubscriptionService,
session: AsyncSession,
user_id: int,
) -> 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
if not panel_user_uuid:
return {
"ok": False,
"status": 400,
"error": "subscription_not_active",
"message": "Subscription is not active",
}
panel_service = getattr(subscription_service, "panel_service", None)
if not panel_service:
return {
"ok": False,
"status": 503,
"error": "panel_unavailable",
"message": "Panel service unavailable",
}
try:
devices_response = await panel_service.get_user_devices(panel_user_uuid)
except Exception:
logger.exception("Failed to load WebApp devices for user %s", user_id)
return {
"ok": False,
"status": 502,
"error": "devices_load_failed",
"message": "Failed to load devices",
}
devices = _normalize_devices_response(devices_response)
max_devices = _coerce_int_or_none(active.get("max_devices")) if active else None
return {
"ok": True,
"payload": {
"enabled": True,
"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)
],
},
}
async def disconnect_device_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="devices_disconnect",
)
if rate_limit_response:
return rate_limit_response
settings: Settings = request.app["settings"]
if not settings.MY_DEVICES_SECTION_ENABLED:
return _json_error(404, "devices_disabled", "Devices section is disabled")
payload = await _read_json(request)
disconnect_payload, validation_error = _validate_model_payload(
WebAppDeviceDisconnectPayload, payload
)
if validation_error:
return validation_error
token = str(disconnect_payload.token or "").strip()
async_session_factory: sessionmaker = request.app["async_session_factory"]
subscription_service: SubscriptionService = request.app["subscription_service"]
async with async_session_factory() as session:
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user or db_user.is_banned:
return _json_error(403, "access_denied", "Access denied")
active = await subscription_service.get_active_subscription_details(session, user_id)
panel_user_uuid = active.get("user_id") if active else None
if not panel_user_uuid:
return _json_error(400, "subscription_not_active", "Subscription is not active")
panel_service = getattr(subscription_service, "panel_service", None)
if not panel_service:
return _json_error(503, "panel_unavailable", "Panel service unavailable")
try:
devices_response = await panel_service.get_user_devices(panel_user_uuid)
except Exception:
logger.exception("Failed to load WebApp devices before disconnect for user %s", user_id)
return _json_error(502, "devices_load_failed", "Failed to load devices")
target_hwid = None
for device in _normalize_devices_response(devices_response):
hwid = str(device.get("hwid") or "").strip()
if hwid and hmac.compare_digest(_device_hwid_token(hwid), token):
target_hwid = hwid
break
if not target_hwid:
return _json_error(404, "device_not_found", "Device not found")
success = await panel_service.disconnect_device(panel_user_uuid, target_hwid)
if not success:
return _json_error(502, "device_disconnect_failed", "Failed to disconnect device")
await cache_delete(settings, redis_key(settings, "cache", "webapp", "devices", user_id))
await session.commit()
return web.json_response({"ok": True})
def _device_hwid_token(hwid: str) -> str:
return hashlib.sha256(str(hwid or "").encode()).hexdigest()[:32]
def _shorten_hwid_for_display(hwid: Optional[str], max_length: int = 24) -> str:
value = str(hwid or "").strip()
if len(value) <= max_length:
return value
return f"{value[:8]}...{value[-6:]}"
def _normalize_devices_response(devices_response: Any) -> List[Dict[str, Any]]:
if isinstance(devices_response, dict):
devices = devices_response.get("devices") or []
else:
devices = devices_response or []
if not isinstance(devices, list):
return []
return [device for device in devices if isinstance(device, dict)]
def _format_devices_limit(max_devices: Optional[int]) -> str:
if max_devices in (None, 0):
return "Unlimited"
return str(max_devices)
def _format_device_datetime(value: Any) -> str:
if not value:
return ""
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 _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()
platform = str(device.get("platform") or "").strip()
os_version = str(device.get("osVersion") or "").strip()
user_agent = str(device.get("userAgent") or "").strip()
display_name = model or platform or f"Device {index}"
platform_label = " ".join(part for part in (platform, os_version) if part).strip()
return {
"index": index,
"display_name": display_name,
"platform": platform,
"os_version": os_version,
"platform_label": platform_label,
"user_agent": user_agent,
"created_at": 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 "",
"can_disconnect": bool(hwid),
}
+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",
}
+122
View File
@@ -0,0 +1,122 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
from typing import Literal
class WebAppEmailPayload(BaseModel):
model_config = ConfigDict(extra="ignore")
email: EmailStr
@field_validator("email")
@classmethod
def _normalize_and_limit_email(cls, value: EmailStr) -> str:
normalized = normalize_email(str(value))
if len(normalized) > 254:
raise ValueError("email_too_long")
return normalized
class WebAppEmailCodePayload(WebAppEmailPayload):
code: str = ""
class WebAppEmailPasswordPayload(WebAppEmailPayload):
password: constr(min_length=1, max_length=128)
class WebAppSetPasswordPayload(BaseModel):
model_config = ConfigDict(extra="ignore")
password: constr(min_length=8, max_length=128)
password_confirm: constr(min_length=8, max_length=128)
code: constr(min_length=1, max_length=32)
class WebAppEmailMagicPayload(BaseModel):
model_config = ConfigDict(extra="ignore")
token: constr(min_length=8, max_length=512)
class WebAppPaymentCreatePayload(BaseModel):
model_config = ConfigDict(extra="ignore")
method: str = ""
months: Any = None
traffic_gb: Any = None
device_count: Any = None
tariff_key: Optional[constr(max_length=128)] = None
sale_mode: Optional[constr(max_length=64)] = None
description: Optional[constr(max_length=4096)] = None
comment: Optional[constr(max_length=4096)] = None
note: Optional[constr(max_length=4096)] = None
class WebAppTariffChangePayload(BaseModel):
model_config = ConfigDict(extra="ignore")
tariff_key: constr(min_length=1, max_length=128)
mode: constr(min_length=1, max_length=64)
class WebAppLanguagePayload(BaseModel):
model_config = ConfigDict(extra="ignore")
language: constr(min_length=2, max_length=16)
class WebAppDeviceDisconnectPayload(BaseModel):
model_config = ConfigDict(extra="ignore")
token: constr(min_length=8, max_length=128)
SupportCategory = Literal["billing", "technical", "account", "other"]
SupportPriority = Literal["low", "normal", "high", "urgent"]
SupportStatus = Literal["open", "awaiting_user", "awaiting_admin", "resolved", "closed"]
class CreateTicketPayload(BaseModel):
model_config = ConfigDict(extra="ignore")
subject: constr(min_length=1, max_length=160)
category: SupportCategory = "other"
priority: Literal["normal", "high"] = "normal"
body: constr(min_length=1, max_length=4000)
@field_validator("subject", "body")
@classmethod
def _strip_required_text(cls, value: str) -> str:
stripped = value.strip()
if not stripped:
raise ValueError("empty_text")
return stripped
class TicketReplyPayload(BaseModel):
model_config = ConfigDict(extra="ignore")
body: constr(min_length=1, max_length=4000)
@field_validator("body")
@classmethod
def _strip_body(cls, value: str) -> str:
stripped = value.strip()
if not stripped:
raise ValueError("empty_text")
return stripped
class AdminTicketReplyPayload(TicketReplyPayload):
is_internal_note: bool = False
class AdminTicketPatchPayload(BaseModel):
model_config = ConfigDict(extra="ignore")
status: Optional[SupportStatus] = None
priority: Optional[SupportPriority] = None
category: Optional[SupportCategory] = None
assigned_admin_id: Optional[int] = None

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