Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11429e887e | ||
|
|
820548cb2d | ||
|
|
e2038085ca | ||
|
|
3676bf1ae7 | ||
|
|
57f766dda2 | ||
|
|
da8f2d08df | ||
|
|
9579f019d4 | ||
|
|
7c27337e0a | ||
|
|
2dffce4244 | ||
|
|
27a9f0aff8 | ||
|
|
67920040ea | ||
|
|
fd1b910236 | ||
|
|
09ba53d185 | ||
|
|
91e79388d1 | ||
|
|
cb5d59571e | ||
|
|
365c6c7858 | ||
|
|
6886a90ee4 | ||
|
|
7d1c9ee373 | ||
|
|
dc05595b9b | ||
|
|
d2581b1c52 | ||
|
|
eb37ed4c74 | ||
|
|
6800734136 | ||
|
|
d4b7da3a54 | ||
|
|
8382fa9232 | ||
|
|
071b9f2b25 | ||
|
|
1e8ffa15d0 | ||
|
|
fc7f97c136 | ||
|
|
99732211b7 | ||
|
|
aaa8e957f6 | ||
|
|
30fb774d93 | ||
|
|
e0b5218037 | ||
|
|
387bdb6abc | ||
|
|
3298dc3e77 | ||
|
|
55dce99d34 | ||
|
|
87f9e23bae | ||
|
|
1e97dd9fe5 | ||
|
|
a05c54fc66 | ||
|
|
aeb51b9ccc | ||
|
|
b72a23f1e1 | ||
|
|
4d96a3e646 | ||
|
|
fc8e573243 | ||
|
|
f80e336e1f | ||
|
|
2a1ff8e45e | ||
|
|
e9ad259957 | ||
|
|
86ddceb646 | ||
|
|
a1725a6872 | ||
|
|
af7cee5014 | ||
|
|
b6ee5e8790 | ||
|
|
46520fb37c | ||
|
|
18ba00c98a | ||
|
|
e065c85a3e | ||
|
|
7401649841 | ||
|
|
a96007d48a | ||
|
|
2169fd8b50 | ||
|
|
2b7d1f0be8 | ||
|
|
eec5635541 | ||
|
|
e21a0758c3 | ||
|
|
defbac43d7 | ||
|
|
94612e11f2 | ||
|
|
11187487b4 | ||
|
|
f31540afdb | ||
|
|
6afbb72a34 | ||
|
|
af66103111 | ||
|
|
92277b2e27 | ||
|
|
475a1953d1 | ||
|
|
8af59750c6 | ||
|
|
00a0792c35 | ||
|
|
b9c0dfb9a1 | ||
|
|
9679190709 | ||
|
|
c8d4b3565c | ||
|
|
fe976022ec | ||
|
|
6a367c1dec | ||
|
|
9f338c3416 | ||
|
|
a575cb057b | ||
|
|
116b0a5c08 | ||
|
|
ed6b214b29 | ||
|
|
7d8bbbc904 | ||
|
|
763c4ad1bb | ||
|
|
5243cc4284 | ||
|
|
5a015fe8b4 | ||
|
|
b759ddb0f5 | ||
|
|
57821ba2a9 | ||
|
|
410cb89f3e | ||
|
|
e01f56bc34 | ||
|
|
ff59eda3d7 | ||
|
|
b7e497e241 | ||
|
|
7a81a2f20b | ||
|
|
990eba1e1e | ||
|
|
6f9a87cf98 | ||
|
|
0a9d929181 | ||
|
|
7dc4806be7 | ||
|
|
8783129c16 | ||
|
|
50d78c8746 | ||
|
|
63784bc036 | ||
|
|
aa5a9496ed | ||
|
|
00f8e5fb20 | ||
|
|
ca8df10eab | ||
|
|
b461838c0d | ||
|
|
50857d5283 | ||
|
|
449bc0c780 | ||
|
|
e1223f8c57 | ||
|
|
7822ec1b09 | ||
|
|
81f5168726 | ||
|
|
77f2420ae3 | ||
|
|
4de730bf5a | ||
|
|
77da0d97c0 | ||
|
|
e8b395cf2f | ||
|
|
d15b1be3b9 | ||
|
|
8a6b2704d4 | ||
|
|
ce610b4804 | ||
|
|
4d0241a0ea | ||
|
|
2c1e86863d | ||
|
|
16b11327ee | ||
|
|
3d5998b8fb | ||
|
|
2df4d2d770 | ||
|
|
4ce78f277e | ||
|
|
d7d5c2b1ef | ||
|
|
4958e1fdfd | ||
|
|
258b2f4dec | ||
|
|
338008bbd0 | ||
|
|
829758dc44 | ||
|
|
9deb98bbe7 | ||
|
|
26af92b5ac | ||
|
|
87b2443a06 | ||
|
|
aabc0e312d | ||
|
|
d15d58df36 | ||
|
|
21f2ac4534 | ||
|
|
dd0e6e75af | ||
|
|
0251f939d7 | ||
|
|
a00bc0f345 | ||
|
|
ae4ce43e1c | ||
|
|
19ed8916f4 | ||
|
|
f7de08bfd7 | ||
|
|
6e42f67931 | ||
|
|
b87b6cd378 | ||
|
|
b39fb73c30 | ||
|
|
3120884ed5 | ||
|
|
c927244b1a | ||
|
|
e049f38c7c | ||
|
|
ddc6ee0024 | ||
|
|
fa5f1460ab | ||
|
|
9f556f3e04 | ||
|
|
70b618a94b | ||
|
|
0610156f80 | ||
|
|
587b39de1d | ||
|
|
e35885b54b | ||
|
|
c1b7ace876 | ||
|
|
f416b0aed1 | ||
|
|
c8192e4427 | ||
|
|
dae1a6889b | ||
|
|
4740666d63 | ||
|
|
d6b703debb | ||
|
|
f1113eb80a | ||
|
|
604f0d9656 | ||
|
|
8ce5a92625 | ||
|
|
72b6e93c94 | ||
|
|
d60952718e | ||
|
|
53dcc59770 | ||
|
|
5ec179b6d6 | ||
|
|
250df445f0 | ||
|
|
12376e5070 | ||
|
|
c2afc6107a | ||
|
|
77370eb963 | ||
|
|
94b0787cad | ||
|
|
9c499fe3c2 | ||
|
|
7e26f9da9b | ||
|
|
778615a97f | ||
|
|
651572f15f | ||
|
|
5ccb8ddabe | ||
|
|
86f944e544 | ||
|
|
bc29f5ebd6 | ||
|
|
9a84be85c6 | ||
|
|
c08ac854b2 | ||
|
|
807a8933b9 | ||
|
|
b9cb1fec06 | ||
|
|
eab803652b | ||
|
|
259d0646bc | ||
|
|
4f1b7d0832 | ||
|
|
1aa529ab23 | ||
|
|
a46502380c | ||
|
|
85c276bcf5 | ||
|
|
55f8db11ce | ||
|
|
79af61a48e | ||
|
|
69d2e2a899 | ||
|
|
069ad967b5 | ||
|
|
18a6b3e18d | ||
|
|
3b9043332c | ||
|
|
d94b57bd0b | ||
|
|
5247092ca2 | ||
|
|
049789c9c5 | ||
|
|
6b74ae4a8e | ||
|
|
c13c01ea43 | ||
|
|
48e7d3569f | ||
|
|
4f3b45cbd8 | ||
|
|
a7ec55d741 | ||
|
|
0701af0f35 | ||
|
|
c0851c5339 | ||
|
|
785b6c2d41 | ||
|
|
cce3fd4f58 | ||
|
|
0dd6beebb2 | ||
|
|
8fb2a76698 | ||
|
|
3d6713caa9 | ||
|
|
8d8fdce519 | ||
|
|
c385a1466c | ||
|
|
8e1fdb33d2 | ||
|
|
29222be1fa | ||
|
|
08aca3b28d | ||
|
|
658139d607 | ||
|
|
1d4abf7977 | ||
|
|
90ab186a7c | ||
|
|
62e8bc08d5 | ||
|
|
dc725ccd68 | ||
|
|
d88aee03bb | ||
|
|
475a30caf4 | ||
|
|
c1593d7868 | ||
|
|
0dbcfe3770 | ||
|
|
e3ffb87cfc | ||
|
|
b4218ec6a7 | ||
|
|
48f8656ef8 | ||
|
|
cee8491b3d | ||
|
|
4bc3f3b4b3 | ||
|
|
adb1105621 | ||
|
|
c8fd79e4ba | ||
|
|
52bc9f71cc | ||
|
|
a1d3db0e16 | ||
|
|
d0e34126e1 | ||
|
|
c96ebea077 | ||
|
|
f00a72ad15 | ||
|
|
22171eb66f | ||
|
|
061fdeb72b | ||
|
|
8eba23574b | ||
|
|
7b650a74a6 | ||
|
|
805bd4cf60 | ||
|
|
56a32d493f | ||
|
|
b24a685066 | ||
|
|
fb06fbd0e1 | ||
|
|
719369057f | ||
|
|
cea9d98155 | ||
|
|
6cf15e0698 | ||
|
|
2971b9ad2e | ||
|
|
3e86fc76b6 | ||
|
|
829a18715a | ||
|
|
844c8e12a7 | ||
|
|
ef75f905f4 | ||
|
|
cd816cbe5c | ||
|
|
2b0fa3a314 | ||
|
|
ead990e61c | ||
|
|
eb6e343e4c | ||
|
|
caf5b88eef | ||
|
|
183bef070d | ||
|
|
643bf5fabb | ||
|
|
7d446d5f64 | ||
|
|
73ce689d5e | ||
|
|
5791f2e58c | ||
|
|
69cc2cbe29 | ||
|
|
7df2e127a4 | ||
|
|
d503cf7f7f | ||
|
|
c0d70030c3 | ||
|
|
4e7c36dbf7 | ||
|
|
3b0b88e9a0 | ||
|
|
207e7751cd | ||
|
|
c65b608f0b | ||
|
|
cff8368b7c | ||
|
|
2f367bbd6b | ||
|
|
c3d6324ec6 | ||
|
|
4800a2da80 | ||
|
|
70a0cd44a1 | ||
|
|
545c9c7d07 | ||
|
|
cd60f496fa | ||
|
|
f883065bb9 | ||
|
|
b71bd71d8d | ||
|
|
0524d24b13 | ||
|
|
cdfd94c814 | ||
|
|
6513681125 | ||
|
|
f321fd04cf | ||
|
|
baede17adf | ||
|
|
17bf8720a3 | ||
|
|
280aced20e | ||
|
|
d672032201 | ||
|
|
e826c4309d | ||
|
|
9f525dc7da | ||
|
|
b880032b9b | ||
|
|
8f9484b6ec | ||
|
|
c3622d9c2b | ||
|
|
51ffbbfa1d | ||
|
|
394e8dcc6c | ||
|
|
5f3fc13c2b | ||
|
|
4d43c9cf0f | ||
|
|
c891122064 | ||
|
|
59d07314e2 | ||
|
|
c438672ced | ||
|
|
49c532e4db | ||
|
|
d50324d098 | ||
|
|
52893a0629 | ||
|
|
664cea447c | ||
|
|
5f50cfeaee | ||
|
|
bdade47758 | ||
|
|
a3406451cb | ||
|
|
9938671af9 | ||
|
|
1d57c9be21 | ||
|
|
fde0f1b135 | ||
|
|
67df164498 | ||
|
|
48e666f605 | ||
|
|
3cb15fa254 | ||
|
|
2100949470 | ||
|
|
c2a475b062 | ||
|
|
585cbe8fb6 | ||
|
|
a243ddcac8 | ||
|
|
357d7caf26 | ||
|
|
2713b3bae5 | ||
|
|
2215ee8ad2 | ||
|
|
1b6acff6d4 | ||
|
|
25d9cc7446 | ||
|
|
e1e73ec736 | ||
|
|
cc21089f18 | ||
|
|
46120210f1 | ||
|
|
df43283971 | ||
|
|
a51b20ee54 | ||
|
|
0915022b52 | ||
|
|
11f5ec4a5b | ||
|
|
d187a79be6 | ||
|
|
7b05ad4cb1 | ||
|
|
79ac5cb797 | ||
|
|
c208541525 | ||
|
|
2b592e8abe | ||
|
|
43de4cc08b | ||
|
|
9a6ff6f636 | ||
|
|
8113908a98 | ||
|
|
81155c9151 | ||
|
|
7fa670c1e8 | ||
|
|
d94430c602 | ||
|
|
c6292a1c8e | ||
|
|
d7d8409919 | ||
|
|
8f603ad51c | ||
|
|
6f3b727cc7 | ||
|
|
b7a723a088 | ||
|
|
807e3dadd3 | ||
|
|
f11ec6a676 | ||
|
|
d83f64c5fe | ||
|
|
72eaed991a | ||
|
|
868c48ccb9 | ||
|
|
2b855ed11b | ||
|
|
2bc7bea29d | ||
|
|
b6a2bf2329 | ||
|
|
ba8be7c3f9 | ||
|
|
cb464059d9 | ||
|
|
a5aecb0f86 | ||
|
|
cf7f0eae51 | ||
|
|
850f135bf8 | ||
|
|
793412c472 | ||
|
|
a510ab1021 | ||
|
|
50cea15c92 | ||
|
|
6b5828ea51 | ||
|
|
257597ccb7 | ||
|
|
dba82cfb97 | ||
|
|
e0e2cde9a7 | ||
|
|
5b98e1a40c | ||
|
|
0f69823192 | ||
|
|
38f3b1ad74 | ||
|
|
9b4dab84ad | ||
|
|
c607b6ef85 | ||
|
|
09f0de78c6 | ||
|
|
6a3b4a6947 | ||
|
|
d6f707d386 | ||
|
|
dace4c2938 | ||
|
|
b69c2ab18d | ||
|
|
56fc88c10e | ||
|
|
e74f801aec | ||
|
|
acf5f060a6 | ||
|
|
fb2672732d | ||
|
|
f4730c3ceb | ||
|
|
e6b3a7c66f | ||
|
|
24cf193f3c | ||
|
|
85bf0fb6fd | ||
|
|
b576d04501 | ||
|
|
97268b9906 | ||
|
|
1091b7b846 | ||
|
|
f191c1813e | ||
|
|
e78f9bf291 | ||
|
|
5c74a08ed8 | ||
|
|
14950fd559 | ||
|
|
711b9a2487 | ||
|
|
6e7eb6acfd | ||
|
|
b42fae8772 | ||
|
|
f707662125 | ||
|
|
60c6e0e961 | ||
|
|
b23b75b72e | ||
|
|
c69f02f7c0 | ||
|
|
f22e359684 | ||
|
|
d2402fea77 | ||
|
|
9b8ddb39da | ||
|
|
459b655ae3 | ||
|
|
d81ab4137d | ||
|
|
cb6ae1e052 | ||
|
|
f4e2ae5fbd | ||
|
|
157c3a7c61 | ||
|
|
ef9ebc1918 | ||
|
|
87664a7735 | ||
|
|
3d58f60a4d | ||
|
|
a75a1f483c | ||
|
|
70d472e71c | ||
|
|
13a9e58e27 | ||
|
|
7219a6ac30 | ||
|
|
dbb27ee9ca | ||
|
|
4e58fda4a5 | ||
|
|
8eb5daada5 | ||
|
|
4c28d3868c | ||
|
|
08810eb2e0 | ||
|
|
b5d3b7b9c7 | ||
|
|
6330d57c60 | ||
|
|
149ff057a9 | ||
|
|
bb7641bb74 | ||
|
|
18f65ea493 | ||
|
|
5853b9da63 | ||
|
|
91cfe0baf3 | ||
|
|
194f1b9e49 | ||
|
|
df15cfd25e | ||
|
|
57e693fa37 | ||
|
|
60ea6fff0d | ||
|
|
3cee4b243a | ||
|
|
a42f80160b | ||
|
|
74548527f1 | ||
|
|
f8e3bee52a | ||
|
|
649528e165 | ||
|
|
859263dc2d | ||
|
|
a126c05365 | ||
|
|
d0c09f9e06 | ||
|
|
9f7171a5c3 | ||
|
|
990b08cfdc | ||
|
|
359a3c46a4 | ||
|
|
00ffec13d8 | ||
|
|
5c8099168d | ||
|
|
4675141974 | ||
|
|
67f31d62e4 | ||
|
|
c969d52ce1 | ||
|
|
3bd29511e1 | ||
|
|
0c31256fd1 | ||
|
|
04e45096be | ||
|
|
e537203209 | ||
|
|
283fc6bad4 | ||
|
|
a46e5a43d6 | ||
|
|
9cf5a93625 | ||
|
|
c5f41a261b | ||
|
|
74719669fb | ||
|
|
c8831718ed | ||
|
|
ad8f05e20b | ||
|
|
b53e6a2cc3 | ||
|
|
bcd95fb56c | ||
|
|
ce041758f1 | ||
|
|
b13883c039 | ||
|
|
fc48cd7a09 | ||
|
|
9688b0f773 | ||
|
|
52c4f83dd1 | ||
|
|
23aa3934e4 | ||
|
|
ebca408227 | ||
|
|
a5b408d204 | ||
|
|
aa97618165 | ||
|
|
973e97c54e | ||
|
|
4f6822f8e4 | ||
|
|
0f26c9bdba | ||
|
|
39ba57951c | ||
|
|
ffa61f673d | ||
|
|
f0bbe90f80 | ||
|
|
35d798a429 | ||
|
|
b34eb733b9 | ||
|
|
fcd456dce9 | ||
|
|
8246e9a590 | ||
|
|
14f244ab79 | ||
|
|
956ec5ea71 | ||
|
|
2ef420ff47 | ||
|
|
7de1cdf9d6 | ||
|
|
860c2298bd | ||
|
|
2eff9300d3 | ||
|
|
1bd09be005 | ||
|
|
ce571f6566 | ||
|
|
856129fce2 | ||
|
|
b67f659746 | ||
|
|
a102903814 | ||
|
|
7f6cca2f59 | ||
|
|
3e6ef9de32 | ||
|
|
9cf395c103 | ||
|
|
69429dce81 | ||
|
|
d3bfe774c7 | ||
|
|
e2df8a01c8 | ||
|
|
dbeee31081 | ||
|
|
aa5a68c32c | ||
|
|
a7b4863889 | ||
|
|
16a00efcc9 | ||
|
|
2779e62e43 | ||
|
|
d7fdcc25cd | ||
|
|
24c4bbc27e | ||
|
|
5b08ad5449 | ||
|
|
fa321426b8 | ||
|
|
a4839ffc63 | ||
|
|
0aa38d3806 | ||
|
|
c7b124ad4c | ||
|
|
b5b740611c | ||
|
|
68459a7075 | ||
|
|
0e84a480fc | ||
|
|
1ac368354d | ||
|
|
831e61cd99 | ||
|
|
981d63c53e | ||
|
|
4d8cf45bda | ||
|
|
df987fc52a | ||
|
|
07cb68457f | ||
|
|
1e666f90d3 | ||
|
|
6241f766a2 | ||
|
|
451581f792 | ||
|
|
a015946b20 | ||
|
|
d1d701bff3 | ||
|
|
ea50567a42 | ||
|
|
a8b62ccc03 | ||
|
|
0e03d72dd3 | ||
|
|
aa5eb74d75 |
@@ -0,0 +1,108 @@
|
||||
# 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
|
||||
|
||||
|
||||
# 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
|
||||
|
||||
# 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/
|
||||
@@ -1,100 +1,235 @@
|
||||
# Telegram Bot Token and Admin IDs
|
||||
BOT_TOKEN=your_bot_token_here
|
||||
ADMIN_IDS=comma_separated_admin_ids
|
||||
BOT_TOKEN=your_bot_token_here # Telegram bot token
|
||||
ADMIN_IDS=comma_separated_admin_ids # Your telegram ID
|
||||
|
||||
POSTGRES_USER=postgres
|
||||
POSTGRES_PASSWORD=postgres
|
||||
POSTGRES_HOST=remnawave-tg-shop-db
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_DB=postgres
|
||||
# PostgreSQL Database Connection Settings
|
||||
POSTGRES_USER= # Required: database user name
|
||||
POSTGRES_PASSWORD= # Required: database password
|
||||
POSTGRES_HOST=remnawave-minishop-db # Database container name
|
||||
POSTGRES_PORT=5432 # Port
|
||||
POSTGRES_DB=postgres # Database name
|
||||
DB_POOL_SIZE=20 # SQLAlchemy async pool size per backend/worker process
|
||||
DB_MAX_OVERFLOW=10 # Extra transient DB connections above pool size
|
||||
DB_POOL_TIMEOUT_SECONDS=30 # Seconds to wait for a DB pool connection
|
||||
DB_POOL_RECYCLE_SECONDS=1800 # Recycle DB connections to avoid stale sockets
|
||||
|
||||
REDIS_URL=redis://redis:6379/0 # Shared Redis for FSM, rate limits, cache, locks and queues
|
||||
REDIS_KEY_PREFIX=remnawave-tg-shop # Prefix for Redis keys
|
||||
WEBAPP_ME_CACHE_TTL_SECONDS=15 # Short TTL for /api/me payload cache
|
||||
WEBAPP_RATE_LIMIT_TTL_SECONDS=60 # Redis rate-limit window
|
||||
WEBAPP_RATE_LIMIT_MAX_REQUESTS=30 # Requests per window/action/user/IP
|
||||
WEBHOOK_QUEUE_NAME=webhook-events # Redis queue for heavy webhook processing
|
||||
WEBHOOK_QUEUE_CONCURRENCY=4 # Worker webhook consumers
|
||||
WORKER_PANEL_SYNC_INTERVAL_SECONDS=900 # Worker panel sync interval
|
||||
TARIFF_WORKER_LOCK_TTL_SECONDS=240 # Redis lock TTL for tariff tick
|
||||
TARIFF_WORKER_TICK_SECONDS=300 # Tariff worker tick interval
|
||||
|
||||
# Localization and Display
|
||||
DEFAULT_LANGUAGE="ru" # or "en"
|
||||
DEFAULT_CURRENCY_SYMBOL="RUB" # e.g., RUB, USD, EUR
|
||||
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
|
||||
SUPPORT_LINK=https://t.me/your_support_link # Link to the support chat
|
||||
SERVER_STATUS_URL=https://status.yourdomain.tld/status/your_service # Link to the server status page
|
||||
TERMS_OF_SERVICE_URL=https://example.com/tos # Link to the terms of service
|
||||
PRIVACY_POLICY_URL=https://example.com/privacy # Link to the privacy policy
|
||||
USER_AGREEMENT_URL=https://example.com/user-agreement # Link to the user agreement
|
||||
SUBSCRIPTION_MINI_APP_URL= # Public URL of the subscription Mini App, e.g. https://app.yourdomain.tld/
|
||||
START_COMMAND_DESCRIPTION= # Description of the /start command
|
||||
DISABLE_WELCOME_MESSAGE= # Disable the welcome message
|
||||
MY_DEVICES_SECTION_ENABLED=False # Enable the My Devices section in the subscription menu
|
||||
USER_HWID_DEVICE_LIMIT=0 # Default HWID/device limit for panel users (0 = unlimited)
|
||||
|
||||
# Required channel subscription
|
||||
REQUIRED_CHANNEL_ID= # Telegram channel ID (e.g. -1001234567890) the user must join
|
||||
REQUIRED_CHANNEL_LINK=https://t.me/your_channel # Optional: public link/invite button text opens
|
||||
|
||||
# Webhook Base URL (used for Telegram and payment providers)
|
||||
WEBHOOK_BASE_URL=https://webhooks.yourdomain.tld
|
||||
TRUSTED_PROXIES=127.0.0.1,::1 # Reverse proxies trusted for X-Forwarded-For
|
||||
|
||||
# Subscription Mini App (same container, separate port)
|
||||
WEBAPP_ENABLED=True # Run Mini App HTTP server
|
||||
WEBAPP_SERVER_HOST=0.0.0.0 # Internal listen host
|
||||
WEBAPP_SERVER_PORT=8081 # Internal/published Mini App port
|
||||
WEBAPP_TITLE="/minishop" # Mini App title
|
||||
WEBAPP_THEMES_DIR=data/themes # Folder with theme subfolders: <key>/theme.json and optional CSS/assets
|
||||
WEBAPP_DEFAULT_THEME= # Optional: override descriptor default theme key (e.g. light)
|
||||
WEBAPP_SESSION_SECRET= # Optional: HMAC secret for webapp sessions; generated if empty
|
||||
WEBHOOK_SECRET_TOKEN= # Optional: Telegram webhook secret token; generated if empty
|
||||
WEBAPP_SESSION_TTL_SECONDS=86400 # Web App session lifetime (24h)
|
||||
WEBAPP_AUTH_MAX_AGE_SECONDS=86400 # Max Telegram initData age
|
||||
WEBAPP_LOGIN_TOKEN_TTL_SECONDS=600 # External browser login link lifetime
|
||||
TELEGRAM_OAUTH_CLIENT_ID= # Telegram Web Login Client ID from BotFather; defaults to bot ID from BOT_TOKEN
|
||||
TELEGRAM_OAUTH_CLIENT_SECRET= # Optional Telegram Web Login Client Secret; reserved for full OIDC code flow
|
||||
TELEGRAM_OAUTH_REQUEST_ACCESS=write # Optional comma-separated permissions: write,phone; empty = OpenID profile only
|
||||
|
||||
# Email login and account linking via SMTP (Brevo SMTP relay defaults)
|
||||
SMTP_HOST=smtp-relay.brevo.com # SMTP server
|
||||
SMTP_PORT=587 # Brevo recommends 587 with STARTTLS
|
||||
SMTP_FALLBACK_PORTS=2525,465 # Tried after SMTP_PORT; 465 uses SSL automatically
|
||||
SMTP_TIMEOUT_SECONDS=30 # Per SMTP connection/send attempt timeout
|
||||
SMTP_USERNAME= # Brevo SMTP login
|
||||
SMTP_PASSWORD= # Brevo SMTP key/password
|
||||
SMTP_FROM_EMAIL= # Verified sender email
|
||||
SMTP_FROM_NAME= # Optional sender name
|
||||
SMTP_STARTTLS=True # Use STARTTLS on SMTP_PORT
|
||||
SMTP_USE_SSL=False # Use SSL wrapper, usually only for port 465
|
||||
EMAIL_CODE_TTL_SECONDS=600 # Email verification code lifetime
|
||||
EMAIL_CODE_RESEND_SECONDS=60 # Minimum delay between code sends
|
||||
EMAIL_CODE_MAX_ATTEMPTS=5 # Max attempts per code
|
||||
BRUTE_FORCE_MAX_FAILURES=5 # Max failed code attempts in the throttle window
|
||||
BRUTE_FORCE_WINDOW_SECONDS=900 # Rolling window used to count failures
|
||||
BRUTE_FORCE_LOCK_SECONDS=1800 # Temporary lockout duration after too many failures
|
||||
|
||||
# Payment Method Toggles
|
||||
YOOKASSA_ENABLED=True # Turn on YOOKASSA
|
||||
FREEKASSA_ENABLED=True # Turn on FreeKassa
|
||||
STARS_ENABLED=True # Turn on STARS
|
||||
CRYPTOPAY_ENABLED=True # Turn on CRYPTOPAY
|
||||
PLATEGA_ENABLED=False # Turn on PLATEGA
|
||||
SEVERPAY_ENABLED=False # Turn on SeverPay
|
||||
# Order of payment methods (top to bottom). Supported: severpay, freekassa, platega, yookassa, stars, cryptopay
|
||||
PAYMENT_METHODS_ORDER=severpay,yookassa,cryptopay,freekassa,platega,stars
|
||||
|
||||
# YooKassa Payment Gateway Configuration
|
||||
YOOKASSA_SHOP_ID=your_shop_id
|
||||
YOOKASSA_SECRET_KEY=your_secret_key
|
||||
YOOKASSA_WEBHOOK_BASE_URL=https://webhooks.yourdomain.tld
|
||||
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
|
||||
YOOKASSA_SHOP_ID=your_shop_id # Your store ID in YooKassa
|
||||
YOOKASSA_SECRET_KEY=your_secret_key # Your secret key for YooKassa
|
||||
YOOKASSA_RETURN_URL=https://t.me/your_bot # URL to which the user will be returned after payment
|
||||
YOOKASSA_DEFAULT_RECEIPT_EMAIL=your_email@example.com # Default email for sending receipts
|
||||
YOOKASSA_VAT_CODE=1 # VAT code
|
||||
YOOKASSA_AUTOPAYMENTS_ENABLED=False # Auto-renew toggle
|
||||
YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING=True # Force automatic card binding when autopay is enabled (set to False to show the save-card checkbox)
|
||||
|
||||
# Telegram Webhook Configuration (Optional)
|
||||
# If unset, bot will use polling while YooKassa uses webhooks.
|
||||
TELEGRAM_WEBHOOK_BASE_URL=https://webhooks.yourdomain.tld
|
||||
# Nalogo (self-employed receipts)
|
||||
NALOGO_INN=your_inn # INN for nalog.ru
|
||||
NALOGO_PASSWORD=your_nalogo_password # Password for nalog.ru
|
||||
NALOGO_RECEIPT_NAME_SUBSCRIPTION=subscription {months} months # Receipt name for time-based subscriptions ({months} = duration)
|
||||
NALOGO_RECEIPT_NAME_TRAFFIC=traffic package {gb} GB # Receipt name for traffic packages ({gb} = traffic amount)
|
||||
|
||||
# Payment Methods
|
||||
YOOKASSA_ENABLED=True
|
||||
STARS_ENABLED=True
|
||||
TRIBUTE_ENABLED=True
|
||||
# FreeKassa Payment Gateway Configuration
|
||||
FREEKASSA_MERCHANT_ID=your_shop_id # Your shop ID in FreeKassa
|
||||
FREEKASSA_API_KEY=your_api_key # API key for REST requests
|
||||
FREEKASSA_SECOND_SECRET=your_second_secret # Secret word #2 (used to verify notifications)
|
||||
FREEKASSA_PAYMENT_IP= # Public IP address reported to FreeKassa
|
||||
FREEKASSA_PAYMENT_METHOD_ID=44 # Payment method ID, you can get it from https://merchant.freekassa.net/settings/currencies
|
||||
FREEKASSA_TRUSTED_IPS=168.119.157.136,168.119.60.227,178.154.197.79,51.250.54.238 # FreeKassa webhook source IP allowlist
|
||||
|
||||
# Subscription Options
|
||||
# CryptoBot Payment Gateway Configuration
|
||||
CRYPTOPAY_TOKEN= # API token for CryptoPay
|
||||
CRYPTOPAY_NETWORK=mainnet # Network (mainnet or testnet)
|
||||
CRYPTOPAY_CURRENCY_TYPE=fiat # Currency type (fiat or crypto)
|
||||
CRYPTOPAY_ASSET=RUB # Asset, e.g., RUB, BTC, USDT
|
||||
|
||||
# Platega Payment Gateway Configuration
|
||||
PLATEGA_BASE_URL=https://app.platega.io # Base API URL
|
||||
PLATEGA_MERCHANT_ID= # Your MerchantId from Platega
|
||||
PLATEGA_SECRET= # API secret from Platega
|
||||
PLATEGA_PAYMENT_METHOD=2 # Legacy method ID; fallback for the SBP button when PLATEGA_SBP_METHOD stays default
|
||||
PLATEGA_SBP_ENABLED=False # Show a separate "Pay via SBP" Platega button
|
||||
PLATEGA_CRYPTO_ENABLED=False # Show a separate "Pay with crypto" Platega button
|
||||
PLATEGA_SBP_METHOD=2 # Platega method ID for SBP QR (default 2)
|
||||
PLATEGA_CRYPTO_METHOD=13 # Platega method ID for crypto (default 13)
|
||||
PLATEGA_RETURN_URL= # Optional: redirect after successful payment (defaults to bot link)
|
||||
PLATEGA_FAILED_URL= # Optional: redirect after failed/cancelled payment (defaults to return URL)
|
||||
|
||||
# SeverPay Payment Gateway Configuration
|
||||
SEVERPAY_BASE_URL=https://severpay.io/api/merchant # Base API URL
|
||||
SEVERPAY_MID= # Your MID from SeverPay
|
||||
SEVERPAY_TOKEN= # API token/secret for signing requests
|
||||
SEVERPAY_RETURN_URL= # Optional: redirect URL after payment (defaults to bot link)
|
||||
SEVERPAY_LIFETIME_MINUTES= # Optional: payment link lifetime in minutes (30-4320, leave empty for default)
|
||||
|
||||
# Subscription Options. Specify cost parameters or payment links here.
|
||||
1_MONTH_ENABLED=True
|
||||
RUB_PRICE_1_MONTH=150
|
||||
STARS_PRICE_1_MONTH=0
|
||||
TRIBUTE_LINK_1_MONTH=
|
||||
|
||||
3_MONTHS_ENABLED=True
|
||||
RUB_PRICE_3_MONTHS=300
|
||||
STARS_PRICE_3_MONTHS=0
|
||||
TRIBUTE_LINK_3_MONTHS=
|
||||
|
||||
6_MONTHS_ENABLED=True
|
||||
RUB_PRICE_6_MONTHS=500
|
||||
STARS_PRICE_6_MONTHS=0
|
||||
TRIBUTE_LINK_6_MONTHS=
|
||||
|
||||
12_MONTHS_ENABLED=True
|
||||
RUB_PRICE_12_MONTHS=900
|
||||
STARS_PRICE_12_MONTHS=0
|
||||
TRIBUTE_LINK_12_MONTHS=
|
||||
# API key for verifying Tribute webhook signatures
|
||||
TRIBUTE_API_KEY=
|
||||
|
||||
# Subscription Expiration Notifications
|
||||
SUBSCRIPTION_EXPIRATION_NOTIFICATION_DAYS=7
|
||||
SUBSCRIPTION_NOTIFICATION_HOUR_UTC=13
|
||||
SUBSCRIPTION_NOTIFICATION_MINUTE_UTC=0
|
||||
# Traffic Packages (enables traffic sale mode when set)
|
||||
TRAFFIC_PACKAGES=10:199,50:799 # Format: "<GB>:<price>", comma-separated
|
||||
STARS_TRAFFIC_PACKAGES=10:2500 # Optional: traffic packages priced in Stars
|
||||
TARIFFS_CONFIG_PATH=data/tariffs.json # Optional Tariffs 2.0 JSON config. If missing, legacy .env pricing is used.
|
||||
TARIFF_TRAFFIC_WARNING_LEVELS=85,90,95 # Tariffs 2.0 traffic warning levels, percent used
|
||||
|
||||
# Subscription Notifications
|
||||
SUBSCRIPTION_NOTIFICATIONS_ENABLED=True # Enable subscription
|
||||
SUBSCRIPTION_NOTIFY_ON_EXPIRE=True # Notify on subscription
|
||||
SUBSCRIPTION_NOTIFY_AFTER_EXPIRE=True # Notify after
|
||||
SUBSCRIPTION_NOTIFY_DAYS_BEFORE=3 # Days before expiration to notify
|
||||
|
||||
|
||||
REFERRAL_ONE_BONUS_PER_REFEREE=False # Give a bonus only once per referee
|
||||
REFERRAL_WELCOME_BONUS_DAYS=3 # Welcome bonus for newly registered user from referral link
|
||||
LEGACY_REFS=true # Allow ref_<tg_id> links. Leave unset/true unless you want to disable old links
|
||||
# Referral Bonus Days
|
||||
# Bonus for the inviting user
|
||||
REFERRAL_BONUS_DAYS_1_MONTH=3
|
||||
REFERRAL_BONUS_DAYS_3_MONTHS=7
|
||||
REFERRAL_BONUS_DAYS_6_MONTHS=15
|
||||
REFERRAL_BONUS_DAYS_12_MONTHS=30
|
||||
# Invited User Bonus
|
||||
REFEREE_BONUS_DAYS_1_MONTH=1
|
||||
REFEREE_BONUS_DAYS_3_MONTHS=3
|
||||
REFEREE_BONUS_DAYS_6_MONTHS=7
|
||||
REFEREE_BONUS_DAYS_12_MONTHS=15
|
||||
|
||||
# Panel API Configuration
|
||||
PANEL_API_URL=http://your_panel_api_url/api
|
||||
PANEL_API_KEY=your_panel_api_key
|
||||
PANEL_API_URL=http://your_panel_api_url/api # URL of the panel API
|
||||
PANEL_API_KEY=your_panel_api_key # Panel API key
|
||||
PANEL_WEBHOOK_SECRET= # secret used to verify panel webhook signatures
|
||||
|
||||
# Default settings for NEW panel users
|
||||
PANEL_USER_DEFAULT_EXPIRE_DAYS=1
|
||||
PANEL_USER_DEFAULT_TRAFFIC_BYTES=0
|
||||
PANEL_USER_DEFAULT_TRAFFIC_STRATEGY="NO_RESET"
|
||||
# User traffic limits (applied for all users)
|
||||
# 0 means unlimited
|
||||
USER_TRAFFIC_LIMIT_GB=0 # Traffic limit for users (0 unlimited)
|
||||
USER_TRAFFIC_STRATEGY="NO_RESET" # Traffic reset strategy (NO_RESET, WEEK, MONTH)
|
||||
|
||||
# Default Inbounds for Panel Users (Optional, comma-separated UUIDs)
|
||||
PANEL_USER_DEFAULT_INBOUND_UUIDS=uuid1,uuid2,uuid3
|
||||
# Default Internal Squads for Users (Optional, comma-separated UUIDs)
|
||||
USER_SQUAD_UUIDS=uuid1,uuid2,uuid3
|
||||
# Default External Squad for Users (Optional, single UUID)
|
||||
USER_EXTERNAL_SQUAD_UUID= # Optional: UUID from Remnawave External Squads to auto-link new panel users
|
||||
|
||||
# Trial Settings
|
||||
TRIAL_ENABLED=True
|
||||
TRIAL_DURATION_DAYS=5
|
||||
TRIAL_TRAFFIC_LIMIT_GB=0 # 0 for unlimited
|
||||
TRIAL_TRAFFIC_STRATEGY="NO_RESET"
|
||||
TRIAL_ENABLED=True # Enable the trial period
|
||||
TRIAL_DURATION_DAYS=5 # Duration of the trial period in days
|
||||
TRIAL_TRAFFIC_LIMIT_GB=0 # Traffic limit for the trial period (0 = unlimited)
|
||||
TRIAL_TRAFFIC_STRATEGY="NO_RESET" # Traffic reset strategy for the trial period (NO_RESET, WEEK, MONTH)
|
||||
|
||||
# Connection link handling (happ crypt4)
|
||||
CRYPT4_ENABLED=False # Enable happ crypt4 encryption for subscription URLs
|
||||
CRYPT4_REDIRECT_URL= # Base redirect to wrap the connect button, e.g. https://redir.example.com?url=
|
||||
|
||||
# Web Server Settings (for handling webhooks)
|
||||
WEB_SERVER_HOST="0.0.0.0"
|
||||
WEB_SERVER_PORT=8080
|
||||
|
||||
# Admin Panel Log Pagination
|
||||
LOGS_PAGE_SIZE=10
|
||||
LOGS_PAGE_SIZE=10 # Number of events in the log
|
||||
LOG_LEVEL=INFO # Global log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
||||
|
||||
# Admin Logging Configuration
|
||||
LOG_CHAT_ID=-1001234567890 # Telegram chat/group ID for admin notifications
|
||||
LOG_THREAD_ID= # Optional: Thread ID for supergroup messages
|
||||
LOG_NEW_USERS=True # Log new user registrations
|
||||
LOG_PAYMENTS=True # Log payments
|
||||
LOG_PROMO_ACTIVATIONS=True # Log promo code activations
|
||||
LOG_TRIAL_ACTIVATIONS=True # Log trial activations
|
||||
LOG_SUSPICIOUS_ACTIVITY=True # Log suspicious activity
|
||||
LOG_ADMIN_ACTIONS=True # Log actions from users listed in ADMIN_IDS
|
||||
|
||||
# Embedded mode thumbnails. Please don't touch this if you don't know what it is.
|
||||
INLINE_REFERRAL_THUMBNAIL_URL=https://cdn-icons-png.flaticon.com/512/1077/1077114.png
|
||||
INLINE_USER_STATS_THUMBNAIL_URL=https://cdn-icons-png.flaticon.com/512/681/681494.png
|
||||
INLINE_FINANCIAL_STATS_THUMBNAIL_URL=https://cdn-icons-png.flaticon.com/512/2769/2769339.png
|
||||
INLINE_SYSTEM_STATS_THUMBNAIL_URL=https://cdn-icons-png.flaticon.com/512/2920/2920277.png
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
*.sh text eol=lf
|
||||
deploy/docker/frontend/*.sh text eol=lf
|
||||
@@ -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 }}
|
||||
@@ -3,6 +3,23 @@ bot_database.sqlite3
|
||||
|
||||
# Игнорировать файлы окружения
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
docker-compose-dev.yml
|
||||
scratch_*.py
|
||||
*.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
|
||||
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
|
||||
tmp
|
||||
.claude
|
||||
|
||||
# Игнорировать кэш Python
|
||||
__pycache__/
|
||||
@@ -14,3 +31,8 @@ __pycache__/
|
||||
# Игнорировать Docker артефакты (если вдруг)
|
||||
*.log
|
||||
*.pid
|
||||
locales/ru_backup.json
|
||||
locales/en_backup.json
|
||||
db/models_old.py
|
||||
data/*
|
||||
!data/tariffs.example.json
|
||||
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,7 @@
|
||||
Copyright 2025 machka-pasla
|
||||
|
||||
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.
|
||||
@@ -1,238 +1,118 @@
|
||||
# Remnawave Minishop
|
||||
|
||||
# Remnawave Subscription Sales Telegram Bot
|
||||

|
||||
|
||||
This Telegram bot is designed to automate the sale and management of subscriptions for a **Remnawave panel**. It integrates with the Remnawave API for user and subscription management and uses YooKassa for processing payments.
|
||||
Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи и управления подписками Remnawave. Бот обрабатывает регистрацию, оплату, продление, пробный период, промокоды, рефералов и поддержку в чате. Web App показывает ссылку подключения, срок действия, трафик, оплату, устройства и вход по Telegram Mini Apps `initData`, Telegram OAuth / OpenID Connect и одноразовому email-коду.
|
||||
|
||||
## ✨ Features
|
||||
Проект является переработанным форком [kavore/remnawave-tg-shop](https://github.com/kavore/remnawave-tg-shop). Для переноса данных из прежнего стека используйте [инструкцию по миграции](docs/migration-to-minishop.md).
|
||||
|
||||
* **User Interaction:**
|
||||
* User registration with language selection (English/Russian).
|
||||
* Display of main menu with available actions via inline keyboards.
|
||||
* Ability for users to view their current subscription status, expiration date, and configuration link.
|
||||
* Trial subscription system for new users (configurable, manual activation via button).
|
||||
* Promo code system for users to apply discounts or bonuses.
|
||||
* Referral program for users to earn bonus subscription days.
|
||||
* **Subscription Management:**
|
||||
* Handles subscription purchases for various periods (1, 3, 6, 12 months).
|
||||
* Integrates with **YooKassa** for payment processing, including fiscal receipt data.
|
||||
* Automatic subscription activation/extension upon successful payment.
|
||||
* Link and syncs users with a **Remnawave panel** account, primarily matching by Telegram ID.
|
||||
* Updates user status, expiration dates, traffic limits, and inbounds on the Remnawave panel.
|
||||
* **Admin Panel:**
|
||||
* Protected by `ADMIN_IDS` (supports multiple administrators).
|
||||
* **Statistics:** View bot usage (total users, banned, active subscriptions), recent payments, and panel sync status.
|
||||
* **User Management:**
|
||||
* Ban/Unban users by Telegram ID or @username (updates local DB and panel).
|
||||
* View a paginated list of banned users.
|
||||
* View a "user card" with detailed information and unban option.
|
||||
* **Broadcast:** Send messages to all users, users with active subscriptions, or users with expired subscriptions.
|
||||
* **Promo Codes:** Create and view promo codes (bonus days, activation limits, validity).
|
||||
* **Panel Sync:** Manually trigger synchronization of users and subscriptions from the Remnawave panel to the bot's database, matching by Telegram ID.
|
||||
* **Activity Logs:** View a paginated list of all user actions (messages, commands, callbacks) or logs for a specific user.
|
||||
* **Notifications:**
|
||||
* Automated daily notifications to users about expiring subscriptions (via APScheduler).
|
||||
* Notifications to users and inviters upon successful referral bonus application.
|
||||
* Notifications to admin(s) about suspicious promo code input attempts.
|
||||
* **Security & Technical:**
|
||||
* Uses parameterized queries to prevent SQL injection.
|
||||
* Proactive check for suspicious input in promo code field (notifies admin).
|
||||
* Middleware for checking banned users on every interaction.
|
||||
* Middleware for logging user actions.
|
||||
* Webhook support for Telegram and YooKassa for efficient updates.
|
||||
* Configurable via `.env` file.
|
||||
* Dockerized for easy deployment.
|
||||
## Возможности
|
||||
|
||||
## 🚀 Technologies Used
|
||||
Для пользователей:
|
||||
|
||||
* **Python 3.11**
|
||||
* **Aiogram 3.x:** Asynchronous Telegram Bot Framework
|
||||
* **aiohttp:** For running the webhook server
|
||||
* **sqlalchemy:** Asynchronous PostgreSQL database interaction
|
||||
* **YooKassa SDK:** For payment processing
|
||||
* **APScheduler:** For scheduled tasks (e.g., notifications)
|
||||
* **Pydantic:** For settings management (loading from `.env`)
|
||||
* **Docker & Docker Compose:** For containerization and deployment
|
||||
- регистрация с выбором русского или английского языка;
|
||||
- просмотр статуса подписки, даты окончания, ссылки подключения и трафика;
|
||||
- покупка подписок, пакетов трафика, обычная и premium-докупка трафика, докупка устройств по настроенному каталогу тарифов;
|
||||
- Web App / Mini App с входом через Telegram или email;
|
||||
- пробный период, промокоды и реферальная программа;
|
||||
- оплата через YooKassa, FreeKassa, Platega, SeverPay, CryptoPay и Telegram Stars;
|
||||
- раздел "Мои устройства" при включенном `MY_DEVICES_SECTION_ENABLED`.
|
||||
|
||||
## ⚙️ Setup and Configuration
|
||||
Для администраторов:
|
||||
|
||||
### Prerequisites
|
||||
- админ-панель для пользователей из `ADMIN_IDS` (только при входе через Telegram, не для аккаунтов только с email);
|
||||
- статистика пользователей, подписок, платежей и синхронизации с Remnawave;
|
||||
- список пользователей с поиском, фильтрами и колонкой premium-трафика;
|
||||
- блокировка пользователей, рассылки, промокоды, логи действий и настройка разрешенных параметров приложения поверх `.env`;
|
||||
- редактор JSON-каталога тарифов с period/traffic-моделями, Internal Squads, premium-сквадами и HWID-пакетами;
|
||||
- ручная синхронизация пользователей и подписок с панелью.
|
||||
|
||||
* Docker and Docker Compose installed.
|
||||
* A running instance of a Remnawave panel.
|
||||
* A Telegram Bot Token.
|
||||
* A YooKassa Shop ID and Secret Key.
|
||||
## Документация
|
||||
|
||||
### Configuration Steps
|
||||
- [Настройка окружения](docs/configuration.md) - основные переменные `.env`, платежи, Remnawave, пробный период, SMTP для email-входа и секреты.
|
||||
- [Тарифы](docs/tariffs.md) - каталог тарифов, period- и traffic-модели, обычные и premium-докупки, premium-сквады, смена тарифа, HWID-лимиты и обработка трафика.
|
||||
- [Админ-панель](docs/admin.md) - права доступа, настройки, редактор тарифов, premium-сквады и сохранение JSON-каталога.
|
||||
- [Web App / Mini App](docs/webapp.md) - отдельный порт, домен, Telegram OAuth, email-вход и реферальные ссылки.
|
||||
- [Темы Web App](docs/webapp-themes.md) - кастомные темы, настройка внешнего вида, логотипы, CSS/ассеты и пайплайн создания новой темы.
|
||||
- [Развертывание](docs/deployment.md) - Docker Compose, reverse proxy, Nginx, Caddy, вебхуки, запуск из образа и обновление версии (`IMAGE_TAG`).
|
||||
- [Миграция с remnawave-tg-shop](docs/migration-to-minishop.md) - перенос данных из прежнего стека.
|
||||
|
||||
1. **Clone the Repository:**
|
||||
```bash
|
||||
git clone https://github.com/machka-pasla/remnawave-tg-shop
|
||||
cd remnawave-tg-shop
|
||||
```
|
||||
## Совместимость
|
||||
|
||||
2. **Create an `.env` File:**
|
||||
Copy the `env.example` file to `.env` and fill in your specific values:
|
||||
```bash
|
||||
cp .env.example .env
|
||||
nano .env
|
||||
```
|
||||
Key variables to configure in `.env`:
|
||||
* `BOT_TOKEN`: Your Telegram Bot Token from BotFather.
|
||||
* `ADMIN_IDS`: Comma-separated list of your Telegram User IDs for admin access (e.g., `12345678,98765432`). **Crucial for bot management.**
|
||||
* `DEFAULT_LANGUAGE`: Default language for new users (e.g., `ru` or `en`).
|
||||
* `DEFAULT_CURRENCY_SYMBOL`: e.g., `RUB`, `USD`.
|
||||
* `SUPPORT_LINK`: (Optional) URL for a support chat/contact (e.g., `https://t.me/your_support`).
|
||||
* `SERVER_STATUS_URL`: (Optional) URL to a server status page (e.g., Uptime Kuma).
|
||||
* **YooKassa Settings:**
|
||||
* `YOOKASSA_SHOP_ID`: Your shop ID from YooKassa.
|
||||
* `YOOKASSA_SECRET_KEY`: Your secret key from YooKassa.
|
||||
* `YOOKASSA_WEBHOOK_BASE_URL`: Your publicly accessible HTTPS base URL where the bot will listen for YooKassa webhooks (e.g., `https://your.domain.com`). The full path will be `{YOOKASSA_WEBHOOK_BASE_URL}/webhook/yookassa`.
|
||||
* `YOOKASSA_RETURN_URL`: (Optional) URL user is redirected to after payment, often `https://t.me/your_bot_username`.
|
||||
* `YOOKASSA_DEFAULT_RECEIPT_EMAIL`: **Important for 54-FZ (Russian fiscalization).** A default email for sending fiscal receipts.
|
||||
* `YOOKASSA_VAT_CODE`: VAT code for items in receipt (e.g., `1` for "No VAT". Consult YooKassa documentation and tax advisor).
|
||||
* `YOOKASSA_PAYMENT_MODE`: e.g., `full_prepayment`.
|
||||
* `YOOKASSA_PAYMENT_SUBJECT`: e.g., `service`.
|
||||
* `TELEGRAM_WEBHOOK_BASE_URL`: (Optional) If you want Telegram updates via webhook. Can be the same as `YOOKASSA_WEBHOOK_BASE_URL`. If not set, the bot will use polling for Telegram updates.
|
||||
* **Payment Method Toggles:** `YOOKASSA_ENABLED`, `STARS_ENABLED`, `TRIBUTE_ENABLED`.
|
||||
* **Subscription Options:** For each duration you can use variables like
|
||||
`1_MONTH_ENABLED`, `RUB_PRICE_1_MONTH`, `STARS_PRICE_1_MONTH`, `TRIBUTE_LINK_1_MONTH`
|
||||
(and corresponding variables for `3_MONTHS`, `6_MONTHS`, `12_MONTHS`).
|
||||
* **Panel API Settings:**
|
||||
* `PANEL_API_URL`: Full URL to your Remnawave panel's API (e.g., `http://remnawave:3000/api` or `https://panel.yourdomain.com/api`).
|
||||
* `PANEL_API_KEY`: API Key for authenticating with the Remnawave panel.
|
||||
* `PANEL_USER_DEFAULT_INBOUND_UUIDS`: (Optional) Comma-separated list of inbound UUIDs from your panel to assign to users. If empty, `activateAllInbounds: true` (panel default) is used for new users.
|
||||
* `TRIAL_ENABLED`, `TRIAL_DURATION_DAYS`, `TRIAL_TRAFFIC_LIMIT_GB`: Settings for the trial period.
|
||||
* `WEB_SERVER_HOST`, `WEB_SERVER_PORT`: Host and port for the bot's internal webhook server.
|
||||
* `LOGS_PAGE_SIZE`: For admin panel log pagination.
|
||||
Интеграция с API панели Remnawave (вебхуки, пользователи, подписки, статистика в админке и т.д.) **протестирована** на панели Remnawave версии **`> 2.7.0`**. Более старые версии могут работать частично или не работать из‑за изменений в API.
|
||||
|
||||
3. **Locales:**
|
||||
* Translation files are in the `locales/` directory (`en.json`, `ru.json`). Ensure they are present and correctly formatted. `locales` mounting is optional.
|
||||
## Стек
|
||||
|
||||
4. **Run with Docker Compose:**
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
This command will pull the Docker image (if it doesn't exist or if `Dockerfile` changed) and start the `remnawave-tg-shop` service in detached mode.
|
||||
Сборка и runtime задаются **deploy/docker/Dockerfile** и **docker-compose.yml**; точные версии пакетов — в **backend/requirements.txt** и **frontend/package.json**.
|
||||
|
||||
5. **Webhook Setup (Important if using webhooks):**
|
||||
* **Reverse Proxy (Nginx, Caddy, etc.):** You need a reverse proxy to handle incoming HTTPS traffic, manage SSL certificates (e.g., from Let's Encrypt), and forward requests to your bot's container.
|
||||
* Forward requests for `https://{YOOKASSA_WEBHOOK_BASE_URL_domain}/webhook/yookassa` to `http://remnawave-tg-shop:{WEB_SERVER_PORT}/webhook/yookassa` (where `remnawave-tg-shop` is the service name in `docker-compose.yml`).
|
||||
* If using Telegram webhooks, forward requests for `https://{TELEGRAM_WEBHOOK_BASE_URL_domain}/<YOUR_BOT_TOKEN>` to `http://remnawave-tg-shop:{WEB_SERVER_PORT}/<YOUR_BOT_TOKEN>`.
|
||||
* **Telegram Webhook Registration:** The bot attempts to set its Telegram webhook URL on startup if `TELEGRAM_WEBHOOK_BASE_URL` is configured in `.env`. Check the bot logs to confirm if this was successful. You can also manually check using the Telegram Bot API method `getWebhookInfo`.
|
||||
| Слой | Технологии |
|
||||
| --- | --- |
|
||||
| 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/` |
|
||||
|
||||
6. **Database:**
|
||||
* A PostgreSQL database will be created in the docker container. The schema is initialized automatically on the first run if the database doesn't exist.
|
||||
Локальная разработка без Docker возможна при установленных Python 3.12, PostgreSQL и (для пересборки фронта) Node 22; типичный сценарий — всё через Compose.
|
||||
|
||||
7. **Viewing Logs:**
|
||||
```bash
|
||||
docker compose logs -f remnawave-tg-shop
|
||||
```
|
||||
## Быстрый старт
|
||||
|
||||
## 🐳 Docker Setup
|
||||
Требования:
|
||||
|
||||
### `Dockerfile`
|
||||
|
||||
```dockerfile
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
|
||||
# Consider adding build arguments for proxy if needed in your environment
|
||||
# ARG HTTP_PROXY
|
||||
# ARG HTTPS_PROXY
|
||||
# ENV http_proxy=$HTTP_PROXY
|
||||
# ENV https_proxy=$HTTPS_PROXY
|
||||
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
# Ensure main.py is executable if needed, though python command handles it
|
||||
# RUN chmod +x main.py
|
||||
|
||||
CMD ["python", "main.py"]
|
||||
- Docker и Docker Compose;
|
||||
- рабочая панель Remnawave версии **`> 2.7.0`** (см. раздел «Совместимость»);
|
||||
- токен Telegram-бота;
|
||||
- параметры хотя бы одного платежного провайдера.
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
### `docker-compose.yml`
|
||||
```
|
||||
services:
|
||||
remnawave-tg-shop:
|
||||
image: ghcr.io/machka-pasla/remnawave-tg-shop:latest
|
||||
# build: .
|
||||
container_name: remnawave-tg-shop
|
||||
hostname: remnawave-tg-shop
|
||||
env_file:
|
||||
- .env
|
||||
# networks:
|
||||
# - remnawave-network
|
||||
# volumes:
|
||||
# - ./locales:/app/locales
|
||||
restart: unless-stopped
|
||||
Минимально заполните в `.env`:
|
||||
|
||||
postgres:
|
||||
image: postgres:17
|
||||
container_name: remnawave-tg-shop-db
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- remnawave-tg-shop-db-data:/var/lib/postgresql/data
|
||||
# networks:
|
||||
# - remnawave-network
|
||||
restart: unless-stopped
|
||||
- `BOT_TOKEN` - токен Telegram-бота;
|
||||
- `ADMIN_IDS` - Telegram ID администраторов через запятую;
|
||||
- `WEBHOOK_BASE_URL` - публичный URL вебхуков;
|
||||
- `PANEL_API_URL`, `PANEL_API_KEY`, `PANEL_WEBHOOK_SECRET` - доступ к Remnawave;
|
||||
- `USER_SQUAD_UUIDS` - Internal Squads для пользователей;
|
||||
- настройки платежного провайдера;
|
||||
- `SUBSCRIPTION_MINI_APP_URL`, если используется Web App.
|
||||
|
||||
# networks:
|
||||
# remnawave-network:
|
||||
# external: true
|
||||
Для каталога тарифов используется `TARIFFS_CONFIG_PATH` со значением по умолчанию `data/tariffs.json`. Пример формата лежит в [data/tariffs.example.json](data/tariffs.example.json), подробности - в [docs/tariffs.md](docs/tariffs.md).
|
||||
|
||||
volumes:
|
||||
remnawave-tg-shop-db-data:
|
||||
name: remnawave-tg-shop-db-data
|
||||
Если в 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
|
||||
chown -R 10001:10001 data
|
||||
chmod -R u+rwX data
|
||||
```
|
||||
|
||||
**Note on `remnawave-network`:** The `docker-compose.yml` assumes an external network named `remnawave-network`. If this network doesn't exist or you want the bot on a different network (e.g., a default bridge or a new one defined in this compose file), you'll need to adjust the `networks` section. If the Remnawave panel is also running in Docker on the same host, putting them on the same user-defined network allows them to communicate using service names.
|
||||
## Полезные команды
|
||||
|
||||
## 🛠️ Project Structure (Overview)
|
||||
```bash
|
||||
# Локальная сборка и запуск
|
||||
docker compose up -d --build
|
||||
|
||||
```
|
||||
.
|
||||
├── bot/
|
||||
│ ├── filters/ # Custom Aiogram filters (e.g., AdminFilter)
|
||||
│ ├── handlers/ # Message and callback query handlers (admin and user)
|
||||
│ ├── keyboards/ # Inline and reply keyboard generators
|
||||
│ ├── middlewares/ # Custom Aiogram middlewares (i18n, ban check, logger)
|
||||
│ ├── services/ # Business logic (payments, subscriptions, panel API interaction)
|
||||
│ ├── states/ # FSM states
|
||||
│ └── main_bot.py # Core bot logic, dispatcher setup, startup/shutdown
|
||||
├── config/
|
||||
│ └── settings.py # Pydantic settings and config parser
|
||||
├── db/
|
||||
│ ├── dal/ # Data Access Layer (queries, transactions)
|
||||
│ ├── database_setup.py # DB connection/init setup
|
||||
│ └── models.py # ORM models (e.g., SQLAlchemy)
|
||||
├── locales/ # Localization files
|
||||
│ ├── en.json # English locale
|
||||
│ └── ru.json # Russian locale
|
||||
├── .env.example # Example environment variables for local setup
|
||||
├── .env # Actual environment variables (ignored by Git)
|
||||
├── Dockerfile # Docker image build instructions
|
||||
├── docker-compose.yml # Docker Compose orchestration config
|
||||
├── requirements.txt # List of Python dependencies
|
||||
├── README.md # Project documentation
|
||||
└── main.py # Entry point to launch the bot
|
||||
# Логи приложения
|
||||
docker compose logs -f backend worker frontend
|
||||
|
||||
# Запуск с Caddy
|
||||
docker compose -f deploy/compose/docker-compose-caddy.yml up -d
|
||||
|
||||
# Запуск из готового образа
|
||||
IMAGE_TAG=3.1.0 docker compose -f deploy/compose/docker-compose-remote-server.yml up -d
|
||||
```
|
||||
|
||||
## 🤝 Contributing
|
||||
GHCR image names for releases:
|
||||
|
||||
Contributions are welcome!
|
||||
- `ghcr.io/3252a8/remnawave-minishop-backend`
|
||||
- `ghcr.io/3252a8/remnawave-minishop-worker`
|
||||
- `ghcr.io/3252a8/remnawave-minishop-frontend`
|
||||
|
||||
## 🔮 Future Enhancements
|
||||
## Поддержка
|
||||
|
||||
- More detailed analytics for admin.
|
||||
- Support for different payment methods.
|
||||
- Advanced promo code types (e.g., percentage discounts).
|
||||
|
||||
## Donations (pls)
|
||||
- Russian and international cards [LINK](https://t.me/tribute/app?startapp=dqdg)
|
||||
- Crypto: `USDT/Other ERC-20 0xeD506D44aae634fEc0E01C8835744fBedb7B2a44 (Ethereum/Polygon/Gnosis)`
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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}
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
from aiogram import Bot
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.crypto_pay_service import CryptoPayService
|
||||
from bot.services.freekassa_service import FreeKassaService
|
||||
from bot.services.lknpd_service import LknpdService
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
from bot.services.panel_webhook_service import PanelWebhookService
|
||||
from bot.services.platega_service import PlategaService
|
||||
from bot.services.promo_code_service import PromoCodeService
|
||||
from bot.services.referral_service import ReferralService
|
||||
from bot.services.severpay_service import SeverPayService
|
||||
from bot.services.stars_service import StarsService
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.services.yookassa_service import YooKassaService
|
||||
from 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)
|
||||
stars_service = StarsService(bot, settings, i18n, subscription_service, referral_service)
|
||||
cryptopay_service = CryptoPayService(
|
||||
settings.CRYPTOPAY_TOKEN,
|
||||
settings.CRYPTOPAY_NETWORK,
|
||||
bot,
|
||||
settings,
|
||||
i18n,
|
||||
async_session_factory,
|
||||
subscription_service,
|
||||
referral_service,
|
||||
)
|
||||
freekassa_service = FreeKassaService(
|
||||
bot=bot,
|
||||
settings=settings,
|
||||
i18n=i18n,
|
||||
async_session_factory=async_session_factory,
|
||||
subscription_service=subscription_service,
|
||||
referral_service=referral_service,
|
||||
)
|
||||
platega_service = PlategaService(
|
||||
bot=bot,
|
||||
settings=settings,
|
||||
i18n=i18n,
|
||||
async_session_factory=async_session_factory,
|
||||
subscription_service=subscription_service,
|
||||
referral_service=referral_service,
|
||||
default_return_url=bot_username_for_default_return,
|
||||
)
|
||||
severpay_service = SeverPayService(
|
||||
bot=bot,
|
||||
settings=settings,
|
||||
i18n=i18n,
|
||||
async_session_factory=async_session_factory,
|
||||
subscription_service=subscription_service,
|
||||
referral_service=referral_service,
|
||||
default_return_url=bot_username_for_default_return,
|
||||
)
|
||||
panel_webhook_service = PanelWebhookService(
|
||||
bot, settings, i18n, async_session_factory, panel_service
|
||||
)
|
||||
yookassa_service = YooKassaService(
|
||||
shop_id=settings.YOOKASSA_SHOP_ID,
|
||||
secret_key=settings.YOOKASSA_SECRET_KEY,
|
||||
configured_return_url=settings.YOOKASSA_RETURN_URL,
|
||||
bot_username_for_default_return=bot_username_for_default_return,
|
||||
settings_obj=settings,
|
||||
)
|
||||
lknpd_service = LknpdService(
|
||||
settings.LKNPD_INN,
|
||||
settings.LKNPD_PASSWORD,
|
||||
api_url=settings.LKNPD_API_URL,
|
||||
)
|
||||
|
||||
# Wire services that depend on each other. These attachments are critical
|
||||
# for auto-renew (subscription_service.yookassa_service) and for the panel
|
||||
# webhook handler's 24h pre-expiry renewal trigger; do NOT swallow errors —
|
||||
# silent wiring failures previously caused auto-renew to disappear.
|
||||
subscription_service.yookassa_service = yookassa_service
|
||||
panel_webhook_service.subscription_service = subscription_service
|
||||
|
||||
return {
|
||||
"panel_service": panel_service,
|
||||
"subscription_service": subscription_service,
|
||||
"referral_service": referral_service,
|
||||
"promo_code_service": promo_code_service,
|
||||
"stars_service": stars_service,
|
||||
"cryptopay_service": cryptopay_service,
|
||||
"freekassa_service": freekassa_service,
|
||||
"panel_webhook_service": panel_webhook_service,
|
||||
"yookassa_service": yookassa_service,
|
||||
"lknpd_service": lknpd_service,
|
||||
"platega_service": platega_service,
|
||||
"severpay_service": severpay_service,
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"""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,
|
||||
sync as _sync,
|
||||
tariffs as _tariffs,
|
||||
themes as _themes,
|
||||
users as _users,
|
||||
)
|
||||
|
||||
_MODULES = (
|
||||
_runtime,
|
||||
_auth,
|
||||
_common,
|
||||
_stats,
|
||||
_users,
|
||||
_payments,
|
||||
_promos,
|
||||
_logs,
|
||||
_broadcast,
|
||||
_sync,
|
||||
_ads,
|
||||
_settings,
|
||||
_tariffs,
|
||||
_themes,
|
||||
_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,66 @@
|
||||
# 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,
|
||||
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("__")]
|
||||
@@ -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,371 @@
|
||||
# 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,
|
||||
"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,95 @@
|
||||
# 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_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,61 @@
|
||||
# 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+}/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/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_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/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,90 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
|
||||
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()
|
||||
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"))
|
||||
response_field = {
|
||||
**field,
|
||||
"value": "" if is_secret else value,
|
||||
"overridden": bool(override),
|
||||
"updated_at": override.get("updated_at") if override else None,
|
||||
}
|
||||
if is_secret:
|
||||
response_field["has_value"] = bool(value)
|
||||
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")
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
# Bust the public webapp settings cache so users see new values immediately.
|
||||
cache = request.app.get("webapp_settings_cache")
|
||||
if isinstance(cache, dict):
|
||||
cache["ts"] = 0.0
|
||||
cache["data"] = {}
|
||||
if (
|
||||
"WEBAPP_LOGO_URL" in updates
|
||||
or "WEBAPP_LOGO_URL" in deletes
|
||||
or "WEBAPP_LOGO_USE_EMOJI" in updates
|
||||
or "WEBAPP_LOGO_USE_EMOJI" in deletes
|
||||
or "WEBAPP_FAVICON_URL" in updates
|
||||
or "WEBAPP_FAVICON_URL" in deletes
|
||||
or "WEBAPP_FAVICON_USE_CUSTOM" in updates
|
||||
or "WEBAPP_FAVICON_USE_CUSTOM" in deletes
|
||||
or "WEBAPP_LOGO_FAVICON_URL" in updates
|
||||
or "WEBAPP_LOGO_FAVICON_URL" in deletes
|
||||
):
|
||||
request.app["webapp_logo_cache"] = None
|
||||
from bot.app.web.admin_api_impl.themes import prune_unused_appearance_assets
|
||||
|
||||
prune_unused_appearance_assets(settings)
|
||||
|
||||
return _ok({"applied": result.get("applied", 0), "reverted": result.get("reverted", 0)})
|
||||
@@ -0,0 +1,89 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
|
||||
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"]
|
||||
|
||||
async with async_session_factory() as session:
|
||||
user_stats = await user_dal.get_enhanced_user_statistics(session)
|
||||
financial_stats = await payment_dal.get_financial_statistics(session)
|
||||
sync_status = await panel_sync_dal.get_panel_sync_status(session)
|
||||
recent_payments = await payment_dal.get_recent_payment_logs_with_user(session, limit=10)
|
||||
|
||||
payload = {
|
||||
"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],
|
||||
}
|
||||
|
||||
panel_service = request.app.get("panel_service")
|
||||
if panel_service is not None:
|
||||
try:
|
||||
system = await panel_service.get_system_stats()
|
||||
bandwidth = await panel_service.get_bandwidth_stats()
|
||||
panel_body: Dict[str, Any] = {
|
||||
"system": system or {},
|
||||
"bandwidth": bandwidth or {},
|
||||
}
|
||||
try:
|
||||
nodes = await panel_service.get_nodes_statistics()
|
||||
panel_body["nodes"] = nodes or {}
|
||||
except Exception as exc_nodes: # pragma: no cover - optional endpoint
|
||||
logger.debug("Panel nodes stats unavailable: %s", exc_nodes)
|
||||
panel_body["nodes"] = {}
|
||||
try:
|
||||
today = datetime.now(timezone.utc).date()
|
||||
start_d = today - timedelta(days=7)
|
||||
nodes_bw = await panel_service.get_nodes_bandwidth_usage(
|
||||
start=start_d.isoformat(),
|
||||
end=today.isoformat(),
|
||||
top_nodes_limit=64,
|
||||
)
|
||||
panel_body["nodes_bandwidth"] = nodes_bw or {}
|
||||
except Exception as exc_nb: # pragma: no cover - optional endpoint
|
||||
logger.debug("Panel nodes bandwidth range unavailable: %s", exc_nb)
|
||||
panel_body["nodes_bandwidth"] = {}
|
||||
try:
|
||||
online_map = _panel_nodes_online_by_uuid(panel_body.get("nodes"))
|
||||
lookups = await panel_service.get_nodes_online_lookups()
|
||||
for k, v in lookups.get("byUuid", {}).items():
|
||||
online_map[k] = v
|
||||
_enrich_bandwidth_nodes_with_online(
|
||||
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)
|
||||
payload["panel"] = panel_body
|
||||
except Exception as exc:
|
||||
logger.debug("Panel stats unavailable: %s", exc)
|
||||
payload["panel"] = {"error": "unavailable"}
|
||||
|
||||
queue_manager = get_queue_manager()
|
||||
if queue_manager:
|
||||
try:
|
||||
payload["queue"] = queue_manager.get_queue_stats()
|
||||
except Exception: # pragma: no cover - defensive
|
||||
payload["queue"] = None
|
||||
|
||||
payload["currency_symbol"] = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
|
||||
return _ok(payload)
|
||||
@@ -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,63 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
|
||||
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))
|
||||
|
||||
cache = request.app.get("webapp_settings_cache")
|
||||
if isinstance(cache, dict):
|
||||
cache["ts"] = 0.0
|
||||
cache["data"] = {}
|
||||
|
||||
return _ok({"exists": True, "path": str(path), "catalog": _tariffs_config_payload(config)})
|
||||
@@ -0,0 +1,452 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
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 _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
|
||||
|
||||
cache = request.app.get("webapp_settings_cache")
|
||||
if isinstance(cache, dict):
|
||||
cache["ts"] = 0.0
|
||||
cache["data"] = {}
|
||||
request.app["webapp_logo_cache"] = None
|
||||
prune_unused_appearance_assets(settings)
|
||||
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"]
|
||||
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")
|
||||
|
||||
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))
|
||||
|
||||
cache = request.app.get("webapp_settings_cache")
|
||||
if isinstance(cache, dict):
|
||||
cache["ts"] = 0.0
|
||||
cache["data"] = {}
|
||||
|
||||
return _ok(
|
||||
{
|
||||
"exists": True,
|
||||
"themes_dir": str(Path(settings.WEBAPP_THEMES_DIR).expanduser()),
|
||||
"catalog": _webapp_themes_catalog_payload(config),
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,925 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
|
||||
async def admin_users_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)))
|
||||
query = (request.query.get("q") or "").strip()
|
||||
filter_value = (request.query.get("filter") or "all").lower()
|
||||
panel_status = (request.query.get("panel_status") or "all").lower()
|
||||
premium_traffic = (request.query.get("premium_traffic") or "all").lower()
|
||||
sort_value = (request.query.get("sort") or "registered_desc").lower()
|
||||
|
||||
async with async_session_factory() as session:
|
||||
users, total = await _filter_and_sort_users(
|
||||
session,
|
||||
query=query,
|
||||
filter_value=filter_value,
|
||||
panel_status=panel_status,
|
||||
premium_traffic=premium_traffic,
|
||||
sort_value=sort_value,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
statuses = await _bulk_user_statuses(session, [u.user_id for u in users])
|
||||
cached_avatar_ids = await _bulk_user_avatar_keys(session, [u.user_id for u in users])
|
||||
active_subs = await _bulk_active_subscriptions_for_users(
|
||||
session, [u.user_id for u in users]
|
||||
)
|
||||
|
||||
serialized = []
|
||||
for user in users:
|
||||
payload = _serialize_user(user)
|
||||
status_payload = statuses.get(user.user_id) or {"status": "bot_only", "end_date": None}
|
||||
payload["panel_status"] = status_payload.get("status")
|
||||
if status_payload.get("status") == "expired" and status_payload.get("end_date"):
|
||||
payload["panel_status_expired_at"] = status_payload["end_date"]
|
||||
payload["avatar_url"] = (
|
||||
f"/api/admin/users/{user.user_id}/avatar?v={cached_avatar_ids[user.user_id]}"
|
||||
if user.user_id in cached_avatar_ids
|
||||
else None
|
||||
)
|
||||
payload["premium_traffic"] = _premium_traffic_list_payload(active_subs.get(user.user_id))
|
||||
serialized.append(payload)
|
||||
|
||||
return _ok(
|
||||
{
|
||||
"users": serialized,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total": total,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def _bulk_user_statuses(
|
||||
session: AsyncSession, user_ids: List[int]
|
||||
) -> Dict[int, Dict[str, Optional[str]]]:
|
||||
"""Return active subscription status for a batch of users.
|
||||
|
||||
Returns the panel status (active/expired/limited/disabled) when an active
|
||||
subscription exists, otherwise ``"bot_only"`` when the user is in the bot
|
||||
but has no panel subscription.
|
||||
"""
|
||||
|
||||
if not user_ids:
|
||||
return {}
|
||||
stmt = (
|
||||
select(
|
||||
Subscription.user_id,
|
||||
Subscription.status_from_panel,
|
||||
Subscription.is_active,
|
||||
Subscription.end_date,
|
||||
)
|
||||
.where(Subscription.user_id.in_(user_ids))
|
||||
.order_by(Subscription.is_active.desc(), Subscription.end_date.desc().nullslast())
|
||||
)
|
||||
rows = (await session.execute(stmt)).all()
|
||||
out: Dict[int, Dict[str, Optional[str]]] = {}
|
||||
for uid, panel_status, is_active, end_date in rows:
|
||||
if uid in out:
|
||||
continue
|
||||
if is_active:
|
||||
status = (panel_status or "active").lower()
|
||||
else:
|
||||
status = (panel_status or "expired").lower()
|
||||
out[uid] = {
|
||||
"status": status,
|
||||
"end_date": end_date.isoformat() if end_date else None,
|
||||
}
|
||||
for uid in user_ids:
|
||||
if uid not in out:
|
||||
out[uid] = {"status": "bot_only", "end_date": None}
|
||||
return out
|
||||
|
||||
|
||||
async def _bulk_user_avatar_keys(session: AsyncSession, user_ids: List[int]) -> Dict[int, str]:
|
||||
"""Return ``{user_id: cache_key}`` for users with a cached Telegram avatar.
|
||||
|
||||
The cache key is the row's ``updated_at`` timestamp — used as a
|
||||
cache-buster query param so the browser refetches when the avatar
|
||||
changes.
|
||||
"""
|
||||
|
||||
if not user_ids:
|
||||
return {}
|
||||
stmt = select(UserTelegramAvatar.user_id, UserTelegramAvatar.updated_at).where(
|
||||
UserTelegramAvatar.user_id.in_(user_ids)
|
||||
)
|
||||
rows = (await session.execute(stmt)).all()
|
||||
return {int(uid): (updated_at.isoformat() if updated_at else "") for uid, updated_at in rows}
|
||||
|
||||
|
||||
async def admin_user_avatar_route(request: web.Request) -> web.Response:
|
||||
"""Serve the cached Telegram avatar for any user (admin-only).
|
||||
|
||||
Mirrors ``/api/account/avatar`` but takes a ``user_id`` from the URL
|
||||
and uses admin auth. Only the cached blob from
|
||||
``user_telegram_avatars`` is served — refreshing from Telegram is the
|
||||
job of the user-facing endpoint, so the admin list never blocks on a
|
||||
Telegram round-trip.
|
||||
"""
|
||||
|
||||
_require_admin_user_id(request)
|
||||
target_id = int(request.match_info["user_id"])
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
avatar = await session.get(UserTelegramAvatar, target_id)
|
||||
|
||||
if not avatar:
|
||||
raise web.HTTPNotFound(text="avatar_not_cached")
|
||||
|
||||
etag = (
|
||||
f'W/"avatar-{target_id}-{int(avatar.updated_at.timestamp())}"'
|
||||
if avatar.updated_at
|
||||
else None
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
def _ranked_active_subscriptions_sq(now: datetime):
|
||||
"""Latest active subscription per user (same ordering as subscription_dal)."""
|
||||
|
||||
rn = sa_func.row_number().over(
|
||||
partition_by=Subscription.user_id,
|
||||
order_by=(
|
||||
Subscription.end_date.desc(),
|
||||
Subscription.subscription_id.desc(),
|
||||
),
|
||||
)
|
||||
inner = (
|
||||
select(
|
||||
Subscription.user_id,
|
||||
Subscription.subscription_id,
|
||||
Subscription.premium_used_bytes,
|
||||
Subscription.premium_baseline_bytes,
|
||||
Subscription.premium_topup_balance_bytes,
|
||||
Subscription.premium_topup_used_bytes,
|
||||
Subscription.premium_bonus_bytes,
|
||||
Subscription.premium_unlimited_override,
|
||||
rn.label("rn"),
|
||||
)
|
||||
.where(
|
||||
Subscription.is_active.is_(True),
|
||||
Subscription.end_date > now,
|
||||
)
|
||||
.subquery()
|
||||
)
|
||||
return select(inner).where(inner.c.rn == 1).subquery(name="ranked_active_sub")
|
||||
|
||||
|
||||
async def _bulk_active_subscriptions_for_users(
|
||||
session: AsyncSession, user_ids: List[int]
|
||||
) -> Dict[int, Subscription]:
|
||||
"""Active subscription row per user (for admin list premium traffic column)."""
|
||||
|
||||
if not user_ids:
|
||||
return {}
|
||||
now = datetime.now(timezone.utc)
|
||||
stmt = (
|
||||
select(Subscription)
|
||||
.where(
|
||||
Subscription.user_id.in_(user_ids),
|
||||
Subscription.is_active.is_(True),
|
||||
Subscription.end_date > now,
|
||||
)
|
||||
.order_by(
|
||||
Subscription.user_id.asc(),
|
||||
Subscription.end_date.desc(),
|
||||
Subscription.subscription_id.desc(),
|
||||
)
|
||||
)
|
||||
rows = (await session.execute(stmt)).scalars().all()
|
||||
out: Dict[int, Subscription] = {}
|
||||
for sub in rows:
|
||||
uid = int(sub.user_id)
|
||||
if uid not in out:
|
||||
out[uid] = sub
|
||||
return out
|
||||
|
||||
|
||||
async def _filter_and_sort_users(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
query: str = "",
|
||||
filter_value: str,
|
||||
panel_status: str = "all",
|
||||
premium_traffic: str = "all",
|
||||
sort_value: str,
|
||||
page: int,
|
||||
page_size: int,
|
||||
) -> tuple[List[User], int]:
|
||||
"""Return paginated users with optional search, filter and sort applied."""
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
sort_key = (sort_value or "registered_desc").lower()
|
||||
pt_filter = (premium_traffic or "all").lower()
|
||||
needs_premium_sq = pt_filter != "all" or sort_key in {
|
||||
"premium_ratio_asc",
|
||||
"premium_ratio_desc",
|
||||
}
|
||||
|
||||
stmt = select(User)
|
||||
count_stmt = select(sa_func.count(User.user_id))
|
||||
|
||||
sq = None
|
||||
ratio_expr = None
|
||||
plim_expr = None
|
||||
pu_expr = None
|
||||
|
||||
if needs_premium_sq:
|
||||
sq = _ranked_active_subscriptions_sq(now)
|
||||
stmt = stmt.outerjoin(sq, User.user_id == sq.c.user_id)
|
||||
count_stmt = count_stmt.outerjoin(sq, User.user_id == sq.c.user_id)
|
||||
pb = sa_func.coalesce(sq.c.premium_bonus_bytes, 0)
|
||||
plim_expr = (
|
||||
sa_func.coalesce(sq.c.premium_baseline_bytes, 0)
|
||||
+ sa_func.coalesce(sq.c.premium_topup_balance_bytes, 0)
|
||||
+ sa_func.coalesce(sq.c.premium_topup_used_bytes, 0)
|
||||
+ pb
|
||||
)
|
||||
pu_expr = sa_func.coalesce(sq.c.premium_used_bytes, 0)
|
||||
ratio_expr = case(
|
||||
(sq.c.user_id.is_(None), None),
|
||||
(sq.c.premium_unlimited_override.is_(True), None),
|
||||
(plim_expr <= 0, None),
|
||||
else_=cast(pu_expr, Float) / cast(plim_expr, Float),
|
||||
)
|
||||
|
||||
search_cond = _user_search_condition(query)
|
||||
if search_cond is not None:
|
||||
stmt = stmt.where(search_cond)
|
||||
count_stmt = count_stmt.where(search_cond)
|
||||
|
||||
f = (filter_value or "all").lower()
|
||||
if f == "banned":
|
||||
cond = User.is_banned.is_(True)
|
||||
elif f == "active":
|
||||
cond = User.is_banned.is_(False)
|
||||
elif f == "tg_linked":
|
||||
cond = User.telegram_id.is_not(None)
|
||||
elif f == "no_tg":
|
||||
cond = User.telegram_id.is_(None)
|
||||
elif f == "email_linked":
|
||||
cond = User.email.is_not(None)
|
||||
elif f == "no_email":
|
||||
cond = User.email.is_(None)
|
||||
elif f == "panel_linked":
|
||||
cond = User.panel_user_uuid.is_not(None)
|
||||
else:
|
||||
cond = None
|
||||
|
||||
if cond is not None:
|
||||
stmt = stmt.where(cond)
|
||||
count_stmt = count_stmt.where(cond)
|
||||
|
||||
panel_cond = _user_panel_status_condition(panel_status)
|
||||
if panel_cond is not None:
|
||||
stmt = stmt.where(panel_cond)
|
||||
count_stmt = count_stmt.where(panel_cond)
|
||||
|
||||
if needs_premium_sq and sq is not None and plim_expr is not None and pu_expr is not None:
|
||||
if pt_filter == "none":
|
||||
premium_cond = or_(
|
||||
sq.c.user_id.is_(None),
|
||||
and_(
|
||||
sq.c.premium_unlimited_override.is_(False),
|
||||
plim_expr <= 0,
|
||||
),
|
||||
)
|
||||
stmt = stmt.where(premium_cond)
|
||||
count_stmt = count_stmt.where(premium_cond)
|
||||
elif pt_filter == "unlimited":
|
||||
premium_cond = and_(
|
||||
sq.c.user_id.isnot(None),
|
||||
sq.c.premium_unlimited_override.is_(True),
|
||||
)
|
||||
stmt = stmt.where(premium_cond)
|
||||
count_stmt = count_stmt.where(premium_cond)
|
||||
elif pt_filter == "good":
|
||||
premium_cond = and_(
|
||||
sq.c.user_id.isnot(None),
|
||||
sq.c.premium_unlimited_override.is_(False),
|
||||
plim_expr > 0,
|
||||
(100 * pu_expr) < (85 * plim_expr),
|
||||
)
|
||||
stmt = stmt.where(premium_cond)
|
||||
count_stmt = count_stmt.where(premium_cond)
|
||||
elif pt_filter == "warn":
|
||||
premium_cond = and_(
|
||||
sq.c.user_id.isnot(None),
|
||||
sq.c.premium_unlimited_override.is_(False),
|
||||
plim_expr > 0,
|
||||
(100 * pu_expr) >= (85 * plim_expr),
|
||||
pu_expr < plim_expr,
|
||||
)
|
||||
stmt = stmt.where(premium_cond)
|
||||
count_stmt = count_stmt.where(premium_cond)
|
||||
elif pt_filter == "critical":
|
||||
premium_cond = and_(
|
||||
sq.c.user_id.isnot(None),
|
||||
sq.c.premium_unlimited_override.is_(False),
|
||||
plim_expr > 0,
|
||||
pu_expr >= plim_expr,
|
||||
)
|
||||
stmt = stmt.where(premium_cond)
|
||||
count_stmt = count_stmt.where(premium_cond)
|
||||
|
||||
sort_map = {
|
||||
"registered_desc": User.registration_date.desc().nullslast(),
|
||||
"registered_asc": User.registration_date.asc().nullslast(),
|
||||
"name_asc": (
|
||||
sa_func.coalesce(User.first_name, User.username, User.email).asc(),
|
||||
User.user_id.asc(),
|
||||
),
|
||||
"name_desc": (
|
||||
sa_func.coalesce(User.first_name, User.username, User.email).desc(),
|
||||
User.user_id.desc(),
|
||||
),
|
||||
"id_asc": User.user_id.asc(),
|
||||
"id_desc": User.user_id.desc(),
|
||||
}
|
||||
|
||||
if needs_premium_sq and ratio_expr is not None and sort_key == "premium_ratio_asc":
|
||||
stmt = stmt.order_by(ratio_expr.asc().nullslast(), User.user_id.asc())
|
||||
elif needs_premium_sq and ratio_expr is not None and sort_key == "premium_ratio_desc":
|
||||
stmt = stmt.order_by(ratio_expr.desc().nullslast(), User.user_id.desc())
|
||||
else:
|
||||
order = sort_map.get(sort_key, sort_map["registered_desc"])
|
||||
if isinstance(order, tuple):
|
||||
stmt = stmt.order_by(*order)
|
||||
else:
|
||||
stmt = stmt.order_by(order)
|
||||
|
||||
stmt = stmt.offset(max(page, 0) * max(page_size, 1)).limit(max(page_size, 1))
|
||||
|
||||
users = (await session.execute(stmt)).scalars().all()
|
||||
total = (await session.execute(count_stmt)).scalar_one()
|
||||
return users, int(total)
|
||||
|
||||
|
||||
def _user_panel_status_condition(panel_status: str):
|
||||
status = (panel_status or "all").lower()
|
||||
if status not in {"active", "expired", "limited"}:
|
||||
return None
|
||||
|
||||
normalized_status = sa_func.lower(sa_func.coalesce(Subscription.status_from_panel, ""))
|
||||
blank_status = or_(
|
||||
Subscription.status_from_panel.is_(None), Subscription.status_from_panel == ""
|
||||
)
|
||||
if status == "active":
|
||||
status_cond = or_(
|
||||
normalized_status == "active", blank_status & Subscription.is_active.is_(True)
|
||||
)
|
||||
elif status == "expired":
|
||||
status_cond = or_(
|
||||
normalized_status == "expired", blank_status & Subscription.is_active.is_(False)
|
||||
)
|
||||
else:
|
||||
status_cond = normalized_status == "limited"
|
||||
|
||||
return (
|
||||
select(Subscription.subscription_id)
|
||||
.where(Subscription.user_id == User.user_id, status_cond)
|
||||
.exists()
|
||||
)
|
||||
|
||||
|
||||
def _user_search_condition(query: str):
|
||||
raw = (query or "").strip().lstrip("@")
|
||||
if not raw:
|
||||
return None
|
||||
|
||||
like = f"%{raw}%"
|
||||
conditions = [
|
||||
User.username.ilike(like),
|
||||
User.first_name.ilike(like),
|
||||
User.last_name.ilike(like),
|
||||
User.email.ilike(like),
|
||||
]
|
||||
if raw.isdigit():
|
||||
numeric = int(raw)
|
||||
conditions.extend([User.user_id == numeric, User.telegram_id == numeric])
|
||||
|
||||
return or_(*conditions)
|
||||
|
||||
|
||||
async def admin_user_detail_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
target_id = int(request.match_info["user_id"])
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
settings: Settings = request.app["settings"]
|
||||
|
||||
async with async_session_factory() as session:
|
||||
user = await user_dal.get_user_by_id(session, target_id)
|
||||
if not user:
|
||||
return _error(404, "not_found", "User not found")
|
||||
|
||||
active_sub = await subscription_dal.get_active_subscription_by_user_id(session, target_id)
|
||||
latest_subs_stmt = (
|
||||
select(Subscription)
|
||||
.where(Subscription.user_id == target_id)
|
||||
.order_by(Subscription.start_date.desc().nullslast())
|
||||
.limit(20)
|
||||
)
|
||||
latest_subs = (await session.execute(latest_subs_stmt)).scalars().all()
|
||||
total_paid = await payment_dal.get_user_total_paid(session, target_id)
|
||||
recent_payments_stmt = (
|
||||
select(Payment)
|
||||
.where(Payment.user_id == target_id)
|
||||
.order_by(Payment.created_at.desc())
|
||||
.limit(20)
|
||||
)
|
||||
recent_payments = (await session.execute(recent_payments_stmt)).scalars().all()
|
||||
log_count = await message_log_dal.count_user_message_logs(session, target_id)
|
||||
avatar_keys = await _bulk_user_avatar_keys(session, [target_id])
|
||||
|
||||
# Referral links — both the bot deep-link and the webapp deep-link.
|
||||
referral_code: Optional[str] = None
|
||||
try:
|
||||
referral_code = await user_dal.ensure_referral_code(session, user)
|
||||
await session.commit()
|
||||
except Exception as exc_ref: # pragma: no cover — defensive
|
||||
logger.warning("Failed to ensure referral code for user %s: %s", target_id, exc_ref)
|
||||
await session.rollback()
|
||||
|
||||
referral_service: Optional[ReferralService] = request.app.get("referral_service")
|
||||
bot_username = request.app.get("bot_username") or ""
|
||||
referral_bot_link: Optional[str] = None
|
||||
if referral_service and bot_username and referral_code:
|
||||
try:
|
||||
async with async_session_factory() as session:
|
||||
referral_bot_link = await referral_service.generate_referral_link(
|
||||
session, bot_username, target_id
|
||||
)
|
||||
except Exception as exc_link: # pragma: no cover
|
||||
logger.warning("Failed to build bot referral link for %s: %s", target_id, exc_link)
|
||||
referral_webapp_link = _build_admin_webapp_referral_link(
|
||||
getattr(settings, "SUBSCRIPTION_MINI_APP_URL", None),
|
||||
referral_code,
|
||||
)
|
||||
|
||||
# Subscription page URL — the raw panel `subscriptionUrl` that the user
|
||||
# imports into their VPN client. May be missing if the user has never
|
||||
# been provisioned on the panel.
|
||||
subscription_url: Optional[str] = None
|
||||
panel_uuid = getattr(user, "panel_user_uuid", None)
|
||||
if panel_uuid:
|
||||
subscription_service = request.app.get("subscription_service")
|
||||
panel_service = getattr(subscription_service, "panel_service", None)
|
||||
if panel_service is not None:
|
||||
try:
|
||||
panel_data = await panel_service.get_user_by_uuid(panel_uuid)
|
||||
if panel_data:
|
||||
subscription_url = panel_data.get("subscriptionUrl") or None
|
||||
except Exception as exc_panel: # pragma: no cover
|
||||
logger.warning(
|
||||
"Failed to fetch subscriptionUrl for user %s (uuid=%s): %s",
|
||||
target_id,
|
||||
panel_uuid,
|
||||
exc_panel,
|
||||
)
|
||||
|
||||
serialized_user = _serialize_user(user)
|
||||
serialized_user["avatar_url"] = (
|
||||
f"/api/admin/users/{target_id}/avatar?v={avatar_keys[target_id]}"
|
||||
if target_id in avatar_keys
|
||||
else None
|
||||
)
|
||||
|
||||
return _ok(
|
||||
{
|
||||
"user": serialized_user,
|
||||
"active_subscription": _serialize_subscription(active_sub) if active_sub else None,
|
||||
"subscriptions": [_serialize_subscription(s) for s in (latest_subs or [])],
|
||||
"total_paid": float(total_paid),
|
||||
"recent_payments": [_serialize_payment(p) for p in recent_payments],
|
||||
"log_count": int(log_count or 0),
|
||||
"subscription_url": subscription_url,
|
||||
"referral": {
|
||||
"code": referral_code,
|
||||
"bot_link": referral_bot_link,
|
||||
"webapp_link": referral_webapp_link,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def admin_user_ban_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
target_id = int(request.match_info["user_id"])
|
||||
payload = await _read_json(request)
|
||||
desired = bool(payload.get("banned"))
|
||||
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
user = await user_dal.get_user_by_id(session, target_id)
|
||||
if not user:
|
||||
return _error(404, "not_found")
|
||||
user.is_banned = bool(desired)
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
return _ok({"user": _serialize_user(user)})
|
||||
|
||||
|
||||
async def admin_user_message_route(request: web.Request) -> web.Response:
|
||||
actor_id = _require_admin_user_id(request)
|
||||
target_id = int(request.match_info["user_id"])
|
||||
payload = await _read_json(request)
|
||||
text = str(payload.get("text") or "").strip()
|
||||
if not text:
|
||||
return _error(400, "empty_text")
|
||||
|
||||
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:
|
||||
target_user = await user_dal.get_user_by_id(session, target_id)
|
||||
if not target_user or not target_user.telegram_id:
|
||||
return _error(404, "no_telegram_account")
|
||||
|
||||
try:
|
||||
await send_message_via_queue(
|
||||
queue_manager,
|
||||
int(target_user.telegram_id),
|
||||
MessageContent(content_type="text", text=text),
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Admin direct message failed: %s", exc)
|
||||
return _error(502, "send_failed", str(exc))
|
||||
|
||||
await message_log_dal.create_message_log(
|
||||
session,
|
||||
{
|
||||
"user_id": actor_id,
|
||||
"event_type": "admin_direct_message_webapp",
|
||||
"content": text[:4000],
|
||||
"is_admin_event": True,
|
||||
"target_user_id": target_id,
|
||||
},
|
||||
)
|
||||
|
||||
return _ok({})
|
||||
|
||||
|
||||
async def admin_user_message_preview_route(request: web.Request) -> web.Response:
|
||||
actor_id = _require_admin_user_id(request)
|
||||
admin_telegram_id = request.get("admin_telegram_id")
|
||||
target_id = int(request.match_info["user_id"])
|
||||
payload = await _read_json(request)
|
||||
text = str(payload.get("text") or "").strip()
|
||||
if not text:
|
||||
return _error(400, "empty_text")
|
||||
if not admin_telegram_id:
|
||||
return _error(403, "admin_telegram_unavailable")
|
||||
|
||||
queue_manager = get_queue_manager()
|
||||
if not queue_manager:
|
||||
return _error(503, "queue_unavailable")
|
||||
|
||||
try:
|
||||
await send_message_via_queue(
|
||||
queue_manager,
|
||||
int(admin_telegram_id),
|
||||
MessageContent(content_type="text", text=text),
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Admin direct message preview failed: %s", exc)
|
||||
return _error(502, "preview_failed", str(exc))
|
||||
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
await message_log_dal.create_message_log(
|
||||
session,
|
||||
{
|
||||
"user_id": actor_id,
|
||||
"event_type": "admin_direct_message_preview_webapp",
|
||||
"content": text[:4000],
|
||||
"is_admin_event": True,
|
||||
"target_user_id": target_id,
|
||||
},
|
||||
)
|
||||
|
||||
return _ok({})
|
||||
|
||||
|
||||
async def admin_user_delete_route(request: web.Request) -> web.Response:
|
||||
actor_id = _require_admin_user_id(request)
|
||||
target_id = int(request.match_info["user_id"])
|
||||
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
ok = await user_dal.delete_user_and_relations(session, target_id)
|
||||
if not ok:
|
||||
await session.rollback()
|
||||
return _error(404, "not_found")
|
||||
await message_log_dal.create_message_log(
|
||||
session,
|
||||
{
|
||||
"user_id": actor_id,
|
||||
"event_type": "admin_delete_user_webapp",
|
||||
"content": f"Deleted user_id={target_id}",
|
||||
"is_admin_event": True,
|
||||
},
|
||||
)
|
||||
await session.commit()
|
||||
return _ok({})
|
||||
|
||||
|
||||
async def admin_user_reset_trial_route(request: web.Request) -> web.Response:
|
||||
actor_id = _require_admin_user_id(request)
|
||||
target_id = int(request.match_info["user_id"])
|
||||
panel_service = request.app.get("panel_service")
|
||||
subscription_service = request.app.get("subscription_service")
|
||||
if panel_service is None or subscription_service is None:
|
||||
return _error(503, "service_unavailable")
|
||||
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
user = await user_dal.get_user_by_id(session, target_id)
|
||||
if not user:
|
||||
return _error(404, "not_found")
|
||||
|
||||
active = await subscription_dal.get_active_subscription_by_user_id(session, target_id)
|
||||
if active:
|
||||
await session.delete(active)
|
||||
|
||||
await message_log_dal.create_message_log(
|
||||
session,
|
||||
{
|
||||
"user_id": actor_id,
|
||||
"event_type": "admin_reset_trial_webapp",
|
||||
"content": f"Reset trial for user_id={target_id}",
|
||||
"is_admin_event": True,
|
||||
"target_user_id": target_id,
|
||||
},
|
||||
)
|
||||
await session.commit()
|
||||
return _ok({})
|
||||
|
||||
|
||||
async def admin_user_premium_override_route(request: web.Request) -> web.Response:
|
||||
"""Premium-squad traffic overrides only (unlimited toggle + bonus GB)."""
|
||||
actor_id = _require_admin_user_id(request)
|
||||
target_id = int(request.match_info["user_id"])
|
||||
payload = await _read_json(request)
|
||||
subscription_service = request.app.get("subscription_service")
|
||||
|
||||
unlimited = bool(payload.get("unlimited"))
|
||||
bonus_bytes_raw = payload.get("bonus_bytes")
|
||||
bonus_gb_raw = payload.get("bonus_gb")
|
||||
if bonus_bytes_raw is None and bonus_gb_raw is None:
|
||||
bonus_bytes = 0
|
||||
elif bonus_bytes_raw is not None:
|
||||
try:
|
||||
bonus_bytes = int(bonus_bytes_raw)
|
||||
except (TypeError, ValueError):
|
||||
return _error(400, "invalid_bonus", "bonus_bytes must be an integer")
|
||||
else:
|
||||
try:
|
||||
bonus_bytes = int(round(float(bonus_gb_raw) * (1024**3)))
|
||||
except (TypeError, ValueError):
|
||||
return _error(400, "invalid_bonus", "bonus_gb must be a number")
|
||||
|
||||
if bonus_bytes < 0:
|
||||
return _error(400, "invalid_bonus", "bonus must be non-negative")
|
||||
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
active = await subscription_dal.get_active_subscription_by_user_id(session, target_id)
|
||||
if not active:
|
||||
return _error(404, "no_active_subscription")
|
||||
|
||||
active.premium_unlimited_override = bool(unlimited)
|
||||
active.premium_bonus_bytes = int(bonus_bytes)
|
||||
if active.premium_unlimited_override:
|
||||
active.premium_is_limited = False
|
||||
|
||||
await message_log_dal.create_message_log(
|
||||
session,
|
||||
{
|
||||
"user_id": actor_id,
|
||||
"event_type": "admin_premium_override_webapp",
|
||||
"content": (f"unlimited={bool(unlimited)} bonus_bytes={int(bonus_bytes)}"),
|
||||
"is_admin_event": True,
|
||||
"target_user_id": target_id,
|
||||
},
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(active)
|
||||
|
||||
if subscription_service is not None:
|
||||
await subscription_service.sync_premium_squad_access_to_panel(session, target_id)
|
||||
await session.commit()
|
||||
await session.refresh(active)
|
||||
|
||||
return _ok({"subscription": _serialize_subscription(active)})
|
||||
|
||||
|
||||
async def admin_user_regular_traffic_override_route(request: web.Request) -> web.Response:
|
||||
"""Main (regular) traffic: unlimited-style ceiling + admin bonus GB."""
|
||||
actor_id = _require_admin_user_id(request)
|
||||
target_id = int(request.match_info["user_id"])
|
||||
payload = await _read_json(request)
|
||||
|
||||
unlimited = bool(payload.get("unlimited"))
|
||||
regular_bonus_bytes_raw = payload.get("regular_bonus_bytes")
|
||||
regular_bonus_gb_raw = payload.get("regular_bonus_gb")
|
||||
if regular_bonus_bytes_raw is None and regular_bonus_gb_raw is None:
|
||||
regular_bonus_bytes = 0
|
||||
elif regular_bonus_bytes_raw is not None:
|
||||
try:
|
||||
regular_bonus_bytes = int(regular_bonus_bytes_raw)
|
||||
except (TypeError, ValueError):
|
||||
return _error(400, "invalid_regular_bonus", "regular_bonus_bytes must be an integer")
|
||||
else:
|
||||
try:
|
||||
regular_bonus_bytes = int(round(float(regular_bonus_gb_raw) * (1024**3)))
|
||||
except (TypeError, ValueError):
|
||||
return _error(400, "invalid_regular_bonus", "regular_bonus_gb must be a number")
|
||||
|
||||
if regular_bonus_bytes < 0:
|
||||
return _error(400, "invalid_regular_bonus", "regular bonus must be non-negative")
|
||||
|
||||
subscription_service = request.app.get("subscription_service")
|
||||
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
active = await subscription_dal.get_active_subscription_by_user_id(session, target_id)
|
||||
if not active:
|
||||
return _error(404, "no_active_subscription")
|
||||
|
||||
active.regular_unlimited_override = bool(unlimited)
|
||||
active.regular_bonus_bytes = int(regular_bonus_bytes)
|
||||
|
||||
if subscription_service is not None:
|
||||
await subscription_service.sync_main_traffic_limit_to_panel(session, target_id)
|
||||
|
||||
await message_log_dal.create_message_log(
|
||||
session,
|
||||
{
|
||||
"user_id": actor_id,
|
||||
"event_type": "admin_regular_traffic_override_webapp",
|
||||
"content": (
|
||||
f"unlimited={bool(unlimited)} regular_bonus_bytes={int(regular_bonus_bytes)}"
|
||||
),
|
||||
"is_admin_event": True,
|
||||
"target_user_id": target_id,
|
||||
},
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(active)
|
||||
|
||||
return _ok({"subscription": _serialize_subscription(active)})
|
||||
|
||||
|
||||
async def admin_user_traffic_grant_route(request: web.Request) -> web.Response:
|
||||
"""Credit regular or premium traffic to a user without a payment.
|
||||
|
||||
Body: ``{"kind": "regular" | "premium", "gb": float}`` (alternatively
|
||||
``"bytes": int``). Mirrors the same effect as a user-purchased top-up:
|
||||
the chosen balance grows, panel limit/squads are refreshed, and an entry
|
||||
is added to ``traffic_topups`` with ``kind="admin_topup"`` or
|
||||
``kind="admin_premium_topup"`` and ``payment_id=NULL``.
|
||||
"""
|
||||
actor_id = _require_admin_user_id(request)
|
||||
target_id = int(request.match_info["user_id"])
|
||||
payload = await _read_json(request)
|
||||
|
||||
kind = str(payload.get("kind") or "regular").strip().lower()
|
||||
if kind not in {"regular", "premium"}:
|
||||
return _error(400, "invalid_kind", "kind must be 'regular' or 'premium'")
|
||||
|
||||
bytes_raw = payload.get("bytes")
|
||||
gb_raw = payload.get("gb")
|
||||
if bytes_raw is None and gb_raw is None:
|
||||
return _error(400, "missing_amount", "either 'gb' or 'bytes' is required")
|
||||
try:
|
||||
if bytes_raw is not None:
|
||||
grant_bytes = int(bytes_raw)
|
||||
gb_value = grant_bytes / (1024**3)
|
||||
else:
|
||||
gb_value = float(gb_raw)
|
||||
grant_bytes = int(round(gb_value * (1024**3)))
|
||||
except (TypeError, ValueError):
|
||||
return _error(400, "invalid_amount", "amount must be a positive number")
|
||||
if gb_value <= 0 or grant_bytes <= 0:
|
||||
return _error(400, "invalid_amount", "amount must be positive")
|
||||
|
||||
subscription_service = request.app.get("subscription_service")
|
||||
if subscription_service is None:
|
||||
return _error(503, "subscription_service_unavailable")
|
||||
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
active = await subscription_dal.get_active_subscription_by_user_id(session, target_id)
|
||||
if not active:
|
||||
return _error(404, "no_active_subscription")
|
||||
|
||||
if kind == "regular":
|
||||
result = await subscription_service.admin_grant_topup(session, target_id, gb_value)
|
||||
else:
|
||||
result = await subscription_service.admin_grant_premium_topup(
|
||||
session, target_id, gb_value
|
||||
)
|
||||
if not result:
|
||||
await session.rollback()
|
||||
return _error(
|
||||
422,
|
||||
"grant_failed",
|
||||
"Unable to credit traffic (missing tariff/squads or panel error)",
|
||||
)
|
||||
|
||||
await message_log_dal.create_message_log(
|
||||
session,
|
||||
{
|
||||
"user_id": actor_id,
|
||||
"event_type": "admin_traffic_grant_webapp",
|
||||
"content": f"kind={kind} bytes={grant_bytes}",
|
||||
"is_admin_event": True,
|
||||
"target_user_id": target_id,
|
||||
},
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
refreshed = await subscription_dal.get_active_subscription_by_user_id(session, target_id)
|
||||
|
||||
return _ok(
|
||||
{
|
||||
"subscription": _serialize_subscription(refreshed) if refreshed else None,
|
||||
"grant": {
|
||||
"kind": kind,
|
||||
"granted_bytes": grant_bytes,
|
||||
"granted_gb": gb_value,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def admin_user_extend_route(request: web.Request) -> web.Response:
|
||||
actor_id = _require_admin_user_id(request)
|
||||
target_id = int(request.match_info["user_id"])
|
||||
payload = await _read_json(request)
|
||||
try:
|
||||
days = int(payload.get("days") or 0)
|
||||
except (TypeError, ValueError):
|
||||
return _error(400, "invalid_days")
|
||||
if days <= 0:
|
||||
return _error(400, "invalid_days")
|
||||
|
||||
subscription_service = request.app.get("subscription_service")
|
||||
if subscription_service is None:
|
||||
return _error(503, "subscription_service_unavailable")
|
||||
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
new_end = await subscription_service.extend_active_subscription_days(
|
||||
session,
|
||||
target_id,
|
||||
days,
|
||||
"admin_extend_subscription_webapp",
|
||||
)
|
||||
if not new_end:
|
||||
await session.rollback()
|
||||
return _error(500, "extend_failed")
|
||||
|
||||
await message_log_dal.create_message_log(
|
||||
session,
|
||||
{
|
||||
"user_id": actor_id,
|
||||
"event_type": "admin_extend_subscription_webapp",
|
||||
"content": f"+{days}d -> {new_end.isoformat()}",
|
||||
"is_admin_event": True,
|
||||
"target_user_id": target_id,
|
||||
},
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
refreshed = await subscription_dal.get_active_subscription_by_user_id(session, target_id)
|
||||
|
||||
return _ok(
|
||||
{
|
||||
"subscription": _serialize_subscription(refreshed) if refreshed else None,
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,536 @@
|
||||
"""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
|
||||
|
||||
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" | "secret"
|
||||
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
|
||||
|
||||
|
||||
SETTINGS_MANIFEST: List[SettingField] = [
|
||||
# ─── General ────────────────────────────────────────────────────
|
||||
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.",
|
||||
),
|
||||
# ─── Web app appearance ────────────────────────────────────────
|
||||
SettingField(
|
||||
"WEBAPP_TITLE", "string", "appearance", "Название Web App", placeholder="Моя подписка"
|
||||
),
|
||||
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 включён"),
|
||||
# ─── 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(
|
||||
"PAYMENT_METHODS_ORDER",
|
||||
"string",
|
||||
"pricing",
|
||||
"Порядок методов оплаты",
|
||||
"Через запятую, например: severpay,freekassa,yookassa",
|
||||
),
|
||||
# ─── Payment providers (toggles) ───────────────────────────────
|
||||
# Common
|
||||
SettingField("STARS_ENABLED", "bool", "payments", "Telegram Stars", subsection="Общие"),
|
||||
SettingField(
|
||||
"PAYMENT_METHODS_ORDER",
|
||||
"string",
|
||||
"payments",
|
||||
"Порядок методов оплаты",
|
||||
"Через запятую: severpay,freekassa,yookassa,platega,stars,cryptopay",
|
||||
subsection="Общие",
|
||||
),
|
||||
# YooKassa
|
||||
SettingField("YOOKASSA_ENABLED", "bool", "payments", "Включена", subsection="YooKassa"),
|
||||
SettingField("YOOKASSA_SHOP_ID", "string", "payments", "Shop ID", subsection="YooKassa"),
|
||||
SettingField(
|
||||
"YOOKASSA_SECRET_KEY",
|
||||
"string",
|
||||
"payments",
|
||||
"Secret key",
|
||||
subsection="YooKassa",
|
||||
secret=True,
|
||||
),
|
||||
SettingField("YOOKASSA_RETURN_URL", "url", "payments", "Return URL", subsection="YooKassa"),
|
||||
SettingField(
|
||||
"YOOKASSA_DEFAULT_RECEIPT_EMAIL",
|
||||
"string",
|
||||
"payments",
|
||||
"Email для чека по умолчанию",
|
||||
subsection="YooKassa",
|
||||
),
|
||||
SettingField(
|
||||
"YOOKASSA_VAT_CODE",
|
||||
"int",
|
||||
"payments",
|
||||
"VAT code",
|
||||
"1..6 в зависимости от системы налогообложения",
|
||||
subsection="YooKassa",
|
||||
min=1,
|
||||
max=6,
|
||||
),
|
||||
SettingField(
|
||||
"YOOKASSA_AUTOPAYMENTS_ENABLED",
|
||||
"bool",
|
||||
"payments",
|
||||
"Автоплатежи (recurring)",
|
||||
subsection="YooKassa",
|
||||
),
|
||||
SettingField(
|
||||
"YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING",
|
||||
"bool",
|
||||
"payments",
|
||||
"Принудительная привязка карты",
|
||||
subsection="YooKassa",
|
||||
),
|
||||
# FreeKassa
|
||||
SettingField("FREEKASSA_ENABLED", "bool", "payments", "Включена", subsection="FreeKassa"),
|
||||
SettingField(
|
||||
"FREEKASSA_MERCHANT_ID", "string", "payments", "Merchant ID", subsection="FreeKassa"
|
||||
),
|
||||
SettingField(
|
||||
"FREEKASSA_FIRST_SECRET",
|
||||
"string",
|
||||
"payments",
|
||||
"First secret",
|
||||
subsection="FreeKassa",
|
||||
secret=True,
|
||||
),
|
||||
SettingField(
|
||||
"FREEKASSA_SECOND_SECRET",
|
||||
"string",
|
||||
"payments",
|
||||
"Second secret",
|
||||
"Используется для проверки подписи входящих уведомлений",
|
||||
subsection="FreeKassa",
|
||||
secret=True,
|
||||
),
|
||||
SettingField(
|
||||
"FREEKASSA_API_KEY", "string", "payments", "API key", subsection="FreeKassa", secret=True
|
||||
),
|
||||
SettingField(
|
||||
"FREEKASSA_PAYMENT_URL",
|
||||
"url",
|
||||
"payments",
|
||||
"Payment URL",
|
||||
placeholder="https://pay.freekassa.ru/",
|
||||
subsection="FreeKassa",
|
||||
),
|
||||
SettingField(
|
||||
"FREEKASSA_PAYMENT_METHOD_ID",
|
||||
"int",
|
||||
"payments",
|
||||
"Метод оплаты по умолчанию",
|
||||
subsection="FreeKassa",
|
||||
),
|
||||
SettingField(
|
||||
"FREEKASSA_PAYMENT_IP",
|
||||
"string",
|
||||
"payments",
|
||||
"IP сервера",
|
||||
"Передаётся в подпись запроса при создании платежа",
|
||||
subsection="FreeKassa",
|
||||
),
|
||||
SettingField(
|
||||
"FREEKASSA_TRUSTED_IPS",
|
||||
"string",
|
||||
"payments",
|
||||
"Доверенные IP",
|
||||
"Через запятую — IP-адреса, с которых принимаются нотификации",
|
||||
subsection="FreeKassa",
|
||||
),
|
||||
# Platega
|
||||
SettingField("PLATEGA_ENABLED", "bool", "payments", "Включена", subsection="Platega"),
|
||||
SettingField(
|
||||
"PLATEGA_BASE_URL",
|
||||
"url",
|
||||
"payments",
|
||||
"Base URL",
|
||||
placeholder="https://app.platega.io",
|
||||
subsection="Platega",
|
||||
),
|
||||
SettingField("PLATEGA_MERCHANT_ID", "string", "payments", "Merchant ID", subsection="Platega"),
|
||||
SettingField(
|
||||
"PLATEGA_SECRET", "string", "payments", "Secret", subsection="Platega", secret=True
|
||||
),
|
||||
SettingField(
|
||||
"PLATEGA_PAYMENT_METHOD", "int", "payments", "Метод оплаты (legacy)", subsection="Platega"
|
||||
),
|
||||
SettingField("PLATEGA_SBP_ENABLED", "bool", "payments", "SBP-кнопка", subsection="Platega"),
|
||||
SettingField("PLATEGA_SBP_METHOD", "int", "payments", "SBP method ID", subsection="Platega"),
|
||||
SettingField(
|
||||
"PLATEGA_CRYPTO_ENABLED", "bool", "payments", "Crypto-кнопка", subsection="Platega"
|
||||
),
|
||||
SettingField(
|
||||
"PLATEGA_CRYPTO_METHOD", "int", "payments", "Crypto method ID", subsection="Platega"
|
||||
),
|
||||
SettingField("PLATEGA_RETURN_URL", "url", "payments", "Return URL", subsection="Platega"),
|
||||
SettingField("PLATEGA_FAILED_URL", "url", "payments", "Failed URL", subsection="Platega"),
|
||||
# SeverPay
|
||||
SettingField("SEVERPAY_ENABLED", "bool", "payments", "Включена", subsection="SeverPay"),
|
||||
SettingField("SEVERPAY_MID", "int", "payments", "MID", subsection="SeverPay"),
|
||||
SettingField(
|
||||
"SEVERPAY_TOKEN", "string", "payments", "Token", subsection="SeverPay", secret=True
|
||||
),
|
||||
SettingField(
|
||||
"SEVERPAY_BASE_URL",
|
||||
"url",
|
||||
"payments",
|
||||
"Base URL",
|
||||
placeholder="https://severpay.io/api/merchant",
|
||||
subsection="SeverPay",
|
||||
),
|
||||
SettingField("SEVERPAY_RETURN_URL", "url", "payments", "Return URL", subsection="SeverPay"),
|
||||
SettingField(
|
||||
"SEVERPAY_LIFETIME_MINUTES",
|
||||
"int",
|
||||
"payments",
|
||||
"Срок жизни ссылки (мин)",
|
||||
"30..4320; пусто — значение провайдера",
|
||||
subsection="SeverPay",
|
||||
min=30,
|
||||
max=4320,
|
||||
),
|
||||
# CryptoPay
|
||||
SettingField("CRYPTOPAY_ENABLED", "bool", "payments", "Включена", subsection="CryptoPay"),
|
||||
SettingField(
|
||||
"CRYPTOPAY_TOKEN", "string", "payments", "Token", subsection="CryptoPay", secret=True
|
||||
),
|
||||
SettingField(
|
||||
"CRYPTOPAY_NETWORK",
|
||||
"string",
|
||||
"payments",
|
||||
"Network",
|
||||
"mainnet или testnet",
|
||||
subsection="CryptoPay",
|
||||
),
|
||||
SettingField(
|
||||
"CRYPTOPAY_CURRENCY_TYPE",
|
||||
"string",
|
||||
"payments",
|
||||
"Currency type",
|
||||
"fiat или crypto",
|
||||
subsection="CryptoPay",
|
||||
),
|
||||
SettingField(
|
||||
"CRYPTOPAY_ASSET", "string", "payments", "Asset", placeholder="RUB", subsection="CryptoPay"
|
||||
),
|
||||
# ─── Trial ─────────────────────────────────────────────────────
|
||||
SettingField("TRIAL_ENABLED", "bool", "trial", "Триал включён"),
|
||||
SettingField("TRIAL_DURATION_DAYS", "int", "trial", "Длительность триала (дней)", min=0),
|
||||
SettingField("TRIAL_TRAFFIC_LIMIT_GB", "float", "trial", "Лимит трафика триала (ГБ)", min=0),
|
||||
SettingField("TRIAL_TRAFFIC_STRATEGY", "string", "trial", "Стратегия сброса трафика триала"),
|
||||
# ─── 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_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="settings_field_log_admin_actions_label",
|
||||
i18n_description_key="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 треда (для супергрупп)"),
|
||||
# ─── 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 get_field_by_key(key: str) -> Optional[SettingField]:
|
||||
for field in SETTINGS_MANIFEST:
|
||||
if field.key == key:
|
||||
return field
|
||||
return None
|
||||
|
||||
|
||||
def manifest_keys() -> List[str]:
|
||||
return [f.key for f in SETTINGS_MANIFEST]
|
||||
|
||||
|
||||
def coerce_value(field: SettingField, raw: Any) -> Any:
|
||||
"""Coerce a value coming from JSON to the type declared by the field."""
|
||||
|
||||
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 manifest_payload() -> List[dict]:
|
||||
"""Serialize the manifest for the admin UI."""
|
||||
|
||||
sections_order = {
|
||||
"general": 1,
|
||||
"appearance": 2,
|
||||
"pricing": 3,
|
||||
"payments": 4,
|
||||
"trial": 5,
|
||||
"referral": 6,
|
||||
"notifications": 7,
|
||||
"devices": 8,
|
||||
}
|
||||
items: List[dict] = []
|
||||
for field in SETTINGS_MANIFEST:
|
||||
auto_label_i18n_key = f"settings_field_{field.key.lower()}_label"
|
||||
auto_description_i18n_key = f"settings_field_{field.key.lower()}_description"
|
||||
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),
|
||||
"placeholder": field.placeholder,
|
||||
"optional": field.optional,
|
||||
"secret": field.secret,
|
||||
}
|
||||
if field.choices:
|
||||
item["choices"] = [{"value": v, "label": lbl} for v, lbl in field.choices]
|
||||
items.append(item)
|
||||
return items
|
||||
@@ -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,48 @@
|
||||
"""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,
|
||||
payloads as _payloads,
|
||||
routes as _routes,
|
||||
serializers as _serializers,
|
||||
)
|
||||
|
||||
_MODULES = (
|
||||
_runtime,
|
||||
_payloads,
|
||||
_common,
|
||||
_assets,
|
||||
_auth,
|
||||
_account,
|
||||
_serializers,
|
||||
_billing,
|
||||
_devices,
|
||||
_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,25 @@
|
||||
<!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="data:," sizes="any">
|
||||
<title>/minishop</title>
|
||||
<link rel="stylesheet" href="/subscription_webapp.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<main id="app"></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>
|
||||
@@ -0,0 +1,953 @@
|
||||
/*
|
||||
* ASCII / console theme.
|
||||
* Pure black background, white foreground, monospace everything,
|
||||
* 1px white borders, animated ASCII spinners and block-progress bars.
|
||||
*/
|
||||
|
||||
.theme-key-ascii {
|
||||
color-scheme: dark;
|
||||
--accent: #ffffff;
|
||||
--accent-contrast: #000000;
|
||||
--bg: #000000;
|
||||
--panel: #000000;
|
||||
--panel-2: #050505;
|
||||
--panel-3: #0c0c0c;
|
||||
--border: #ffffff;
|
||||
--border-strong: #ffffff;
|
||||
--text: #ffffff;
|
||||
--muted: #b0b0b0;
|
||||
--dim: #6a6a6a;
|
||||
--danger: #ff5555;
|
||||
--blue: #ffffff;
|
||||
--radius: 0px;
|
||||
--font-sans: "JetBrains Mono", "Cascadia Code", "Fira Code", "Consolas",
|
||||
"Source Code Pro", "Courier New", ui-monospace, monospace;
|
||||
--font-logo: "JetBrains Mono", "Cascadia Code", "Consolas", "Courier New",
|
||||
ui-monospace, monospace;
|
||||
--font-mono: "JetBrains Mono", "Cascadia Code", "Consolas", "Courier New",
|
||||
ui-monospace, monospace;
|
||||
--surface-sheen: transparent;
|
||||
--surface-sheen-soft: transparent;
|
||||
--surface-hover: rgba(255, 255, 255, 0.08);
|
||||
--surface-muted: #0a0a0a;
|
||||
--surface-subtle-border: #ffffff;
|
||||
--overlay-scrim: rgba(0, 0, 0, 0.85);
|
||||
--nav-bg: #000000;
|
||||
--rail-bg: #000000;
|
||||
--shadow-soft: none;
|
||||
--shadow-strong: none;
|
||||
--shadow-popover: 0 0 0 1px #ffffff;
|
||||
--inset-highlight: transparent;
|
||||
--admin-bg: #000000;
|
||||
--admin-surface: #000000;
|
||||
--admin-surface-2: #050505;
|
||||
--admin-elev: #0c0c0c;
|
||||
--admin-border: #ffffff;
|
||||
--admin-border-strong: #ffffff;
|
||||
--admin-text: #ffffff;
|
||||
--admin-muted: #b0b0b0;
|
||||
--admin-dim: #6a6a6a;
|
||||
}
|
||||
|
||||
/* ---------- Base typography ---------- */
|
||||
|
||||
.theme-key-ascii,
|
||||
.theme-key-ascii body,
|
||||
.theme-key-ascii button,
|
||||
.theme-key-ascii input,
|
||||
.theme-key-ascii textarea,
|
||||
.theme-key-ascii select {
|
||||
font-family: var(--font-sans);
|
||||
letter-spacing: 0;
|
||||
font-synthesis: none;
|
||||
-webkit-font-smoothing: none;
|
||||
font-smooth: never;
|
||||
font-variant-ligatures: none;
|
||||
}
|
||||
|
||||
.theme-key-ascii.app-shell {
|
||||
background: var(--bg) !important;
|
||||
background-image:
|
||||
repeating-linear-gradient(
|
||||
0deg,
|
||||
rgba(255, 255, 255, 0.025) 0,
|
||||
rgba(255, 255, 255, 0.025) 1px,
|
||||
transparent 1px,
|
||||
transparent 3px
|
||||
) !important;
|
||||
}
|
||||
|
||||
/* Slight CRT-like flicker on the shell. */
|
||||
@keyframes ascii-flicker {
|
||||
0%, 96%, 100% { opacity: 1; }
|
||||
97% { opacity: 0.96; }
|
||||
98% { opacity: 1; }
|
||||
99% { opacity: 0.94; }
|
||||
}
|
||||
|
||||
.theme-key-ascii.app-shell::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 9998;
|
||||
background: repeating-linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 255, 0.02) 0,
|
||||
rgba(255, 255, 255, 0.02) 1px,
|
||||
transparent 1px,
|
||||
transparent 2px
|
||||
);
|
||||
animation: ascii-flicker 5s infinite;
|
||||
}
|
||||
|
||||
/* ---------- Panels / cards ---------- */
|
||||
|
||||
.theme-key-ascii .card,
|
||||
.theme-key-ascii .period-card,
|
||||
.theme-key-ascii .method-card,
|
||||
.theme-key-ascii .settings-row,
|
||||
.theme-key-ascii .option-row,
|
||||
.theme-key-ascii .tariff-selected-card,
|
||||
.theme-key-ascii .tariff-action-card,
|
||||
.theme-key-ascii .tariff-warning-card,
|
||||
.theme-key-ascii .topup-carryover-note,
|
||||
.theme-key-ascii .input,
|
||||
.theme-key-ascii .dialog-card,
|
||||
.theme-key-ascii .language-select-content,
|
||||
.theme-key-ascii .bottom-nav,
|
||||
.theme-key-ascii .toast,
|
||||
.theme-key-ascii .admin-sidebar,
|
||||
.theme-key-ascii .admin-header,
|
||||
.theme-key-ascii .admin-card,
|
||||
.theme-key-ascii .admin-stat-card,
|
||||
.theme-key-ascii .admin-revenue-panel,
|
||||
.theme-key-ascii .admin-empty,
|
||||
.theme-key-ascii .admin-tariff-card,
|
||||
.theme-key-ascii .admin-toolbar-card,
|
||||
.theme-key-ascii .admin-table-card,
|
||||
.theme-key-ascii .admin-panel-dash-card,
|
||||
.theme-key-ascii .admin-select-trigger,
|
||||
.theme-key-ascii .admin-select-content,
|
||||
.theme-key-ascii .admin-cn-card[data-slot="card"],
|
||||
.theme-key-ascii .admin-dialog .dialog-card,
|
||||
.theme-key-ascii .admin-theme-editor-section {
|
||||
border: 1px solid #ffffff;
|
||||
border-radius: 0;
|
||||
background: var(--panel);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* No ribbon/corner overlays: those caused dialog overflow scrollbars.
|
||||
* The console feel comes from the crisp 1px borders, monospace text,
|
||||
* and the animated marquees / glitches applied to interactive elements. */
|
||||
|
||||
/* ---------- Buttons ---------- */
|
||||
|
||||
.theme-key-ascii .btn,
|
||||
.theme-key-ascii .language-select-trigger,
|
||||
.theme-key-ascii .bottom-nav button,
|
||||
.theme-key-ascii .admin-btn,
|
||||
.theme-key-ascii .admin-chip,
|
||||
.theme-key-ascii .admin-tabs-trigger,
|
||||
.theme-key-ascii .admin-revenue-period-btn,
|
||||
.theme-key-ascii .admin-mobile-toggle,
|
||||
.theme-key-ascii .admin-nav-item {
|
||||
border: 1px solid #ffffff;
|
||||
border-radius: 0;
|
||||
background: #000000;
|
||||
color: #ffffff;
|
||||
box-shadow: none;
|
||||
text-transform: none;
|
||||
font-family: var(--font-sans);
|
||||
transform: none;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.theme-key-ascii .btn:hover:not(:disabled),
|
||||
.theme-key-ascii .admin-btn:hover:not(:disabled),
|
||||
.theme-key-ascii .admin-nav-item:hover,
|
||||
.theme-key-ascii .admin-tabs-trigger:hover,
|
||||
.theme-key-ascii .admin-revenue-period-btn:hover,
|
||||
.theme-key-ascii .bottom-nav button:hover {
|
||||
background: #ffffff;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.theme-key-ascii .btn:active:not(:disabled),
|
||||
.theme-key-ascii .bottom-nav button:active,
|
||||
.theme-key-ascii .admin-btn:active:not(:disabled) {
|
||||
background: #ffffff;
|
||||
color: #000000;
|
||||
transform: translate(1px, 1px);
|
||||
}
|
||||
|
||||
.theme-key-ascii .btn-primary,
|
||||
.theme-key-ascii .admin-btn-primary,
|
||||
.theme-key-ascii .bottom-nav button.active,
|
||||
.theme-key-ascii .period-card.active,
|
||||
.theme-key-ascii .method-card.active,
|
||||
.theme-key-ascii .option-row.active,
|
||||
.theme-key-ascii .admin-nav-item.active,
|
||||
.theme-key-ascii .admin-tabs-trigger[data-state="active"],
|
||||
.theme-key-ascii .admin-revenue-period-btn.is-active {
|
||||
background: #ffffff;
|
||||
color: #000000;
|
||||
border-color: #ffffff;
|
||||
}
|
||||
|
||||
.theme-key-ascii .btn-primary:hover:not(:disabled),
|
||||
.theme-key-ascii .admin-btn-primary:hover:not(:disabled) {
|
||||
background: #000000;
|
||||
color: #ffffff;
|
||||
outline: 1px solid #ffffff;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* Blinking caret-style focus ring. */
|
||||
@keyframes ascii-caret {
|
||||
0%, 49% { outline-color: #ffffff; }
|
||||
50%, 100% { outline-color: transparent; }
|
||||
}
|
||||
|
||||
.theme-key-ascii .btn:focus-visible,
|
||||
.theme-key-ascii .admin-btn:focus-visible,
|
||||
.theme-key-ascii .admin-nav-item:focus-visible,
|
||||
.theme-key-ascii .admin-tabs-trigger:focus-visible,
|
||||
.theme-key-ascii .admin-revenue-period-btn:focus-visible,
|
||||
.theme-key-ascii .admin-mobile-toggle:focus-visible,
|
||||
.theme-key-ascii .language-select-trigger:focus-visible,
|
||||
.theme-key-ascii .bottom-nav button:focus-visible {
|
||||
outline: 2px solid #ffffff;
|
||||
outline-offset: 1px;
|
||||
animation: ascii-caret 1s steps(1) infinite;
|
||||
}
|
||||
|
||||
/* ---------- Inputs ---------- */
|
||||
|
||||
.theme-key-ascii .input,
|
||||
.theme-key-ascii .admin-input,
|
||||
.theme-key-ascii .admin-textarea,
|
||||
.theme-key-ascii .admin-screen-wrap textarea,
|
||||
.theme-key-ascii .admin-dialog textarea {
|
||||
border: 1px solid #ffffff;
|
||||
border-radius: 0;
|
||||
background: #000000;
|
||||
color: #ffffff;
|
||||
box-shadow: none;
|
||||
font-family: var(--font-mono);
|
||||
caret-color: #ffffff;
|
||||
}
|
||||
|
||||
.theme-key-ascii .input::placeholder,
|
||||
.theme-key-ascii .admin-input::placeholder,
|
||||
.theme-key-ascii .admin-textarea::placeholder,
|
||||
.theme-key-ascii .admin-screen-wrap textarea::placeholder,
|
||||
.theme-key-ascii .admin-dialog textarea::placeholder {
|
||||
color: var(--dim);
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.theme-key-ascii .input:focus,
|
||||
.theme-key-ascii .admin-input:focus,
|
||||
.theme-key-ascii .admin-textarea:focus,
|
||||
.theme-key-ascii .admin-screen-wrap textarea:focus,
|
||||
.theme-key-ascii .admin-dialog textarea:focus {
|
||||
outline: none;
|
||||
border-color: #ffffff;
|
||||
box-shadow: inset 0 0 0 1px #ffffff;
|
||||
}
|
||||
|
||||
/* ---------- Bottom nav (desktop rail) ---------- */
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.theme-key-ascii .bottom-nav {
|
||||
border-right: 1px solid #ffffff !important;
|
||||
background: var(--rail-bg) !important;
|
||||
backdrop-filter: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.theme-key-ascii .bottom-nav button {
|
||||
border: 1px solid #ffffff !important;
|
||||
border-radius: 0 !important;
|
||||
background: #000000 !important;
|
||||
color: #ffffff !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.theme-key-ascii .bottom-nav button.active {
|
||||
background: #ffffff !important;
|
||||
color: #000000 !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- ASCII progress bar ---------- *
|
||||
* Empty track: ░░░░░░░░░░░ (low-contrast dotted fill).
|
||||
* Filled span: ██████ (solid white blocks).
|
||||
*/
|
||||
|
||||
.theme-key-ascii .progress {
|
||||
height: 14px;
|
||||
border: 1px solid #ffffff;
|
||||
border-radius: 0 !important;
|
||||
background-color: #000000;
|
||||
background-image: repeating-linear-gradient(
|
||||
90deg,
|
||||
rgba(255, 255, 255, 0.22) 0,
|
||||
rgba(255, 255, 255, 0.22) 1px,
|
||||
transparent 1px,
|
||||
transparent 4px
|
||||
);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.theme-key-ascii .progress span {
|
||||
border-radius: 0 !important;
|
||||
background: #ffffff !important;
|
||||
background-image: repeating-linear-gradient(
|
||||
90deg,
|
||||
rgba(0, 0, 0, 0.0) 0,
|
||||
rgba(0, 0, 0, 0.0) 5px,
|
||||
rgba(0, 0, 0, 0.35) 5px,
|
||||
rgba(0, 0, 0, 0.35) 6px
|
||||
) !important;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Indeterminate scanning effect for any progress lacking a width-set span. */
|
||||
@keyframes ascii-scan {
|
||||
0% { background-position: 0 0; }
|
||||
100% { background-position: 12px 0; }
|
||||
}
|
||||
|
||||
/* ---------- ASCII spinner replacement ---------- */
|
||||
|
||||
.theme-key-ascii .ui-spinner,
|
||||
.theme-key-ascii .telegram-button-spinner,
|
||||
.theme-key-ascii .brand-mark-spinner {
|
||||
border: none !important;
|
||||
border-radius: 0 !important;
|
||||
width: 1ch !important;
|
||||
height: 1em !important;
|
||||
background: transparent !important;
|
||||
position: relative;
|
||||
animation: none !important;
|
||||
color: currentColor;
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.theme-key-ascii .ui-spinner::before,
|
||||
.theme-key-ascii .telegram-button-spinner::before,
|
||||
.theme-key-ascii .brand-mark-spinner::before {
|
||||
content: "|";
|
||||
display: inline-block;
|
||||
animation: ascii-spin 0.8s steps(1) infinite;
|
||||
font-family: var(--font-mono);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
@keyframes ascii-spin {
|
||||
0% { content: "|"; }
|
||||
25% { content: "/"; }
|
||||
50% { content: "-"; }
|
||||
75% { content: "\\"; }
|
||||
100% { content: "|"; }
|
||||
}
|
||||
|
||||
/* Some browsers don't animate content; fallback rotation of a glyph. */
|
||||
@supports not (animation-name: ascii-spin) {
|
||||
.theme-key-ascii .ui-spinner::before,
|
||||
.theme-key-ascii .telegram-button-spinner::before,
|
||||
.theme-key-ascii .brand-mark-spinner::before {
|
||||
content: "+";
|
||||
animation: ascii-spin-rotate 0.8s steps(4) infinite;
|
||||
}
|
||||
@keyframes ascii-spin-rotate {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
}
|
||||
|
||||
/* Blinking cursor appended to brand text. */
|
||||
.theme-key-ascii .login-brand h1::after,
|
||||
.theme-key-ascii .admin-sidebar-brand strong::after,
|
||||
.theme-key-ascii .brand-row strong::after {
|
||||
content: "_";
|
||||
display: inline-block;
|
||||
margin-left: 0.2ch;
|
||||
color: #ffffff;
|
||||
animation: ascii-blink 1s steps(1) infinite;
|
||||
}
|
||||
|
||||
@keyframes ascii-blink {
|
||||
0%, 49% { opacity: 1; }
|
||||
50%, 100% { opacity: 0; }
|
||||
}
|
||||
|
||||
/* Section heading prompt prefix. */
|
||||
.theme-key-ascii .admin-card-head h2::before,
|
||||
.theme-key-ascii .admin-card-head h3::before,
|
||||
.theme-key-ascii .card > h2:first-child::before,
|
||||
.theme-key-ascii .card > h3:first-child::before {
|
||||
content: "> ";
|
||||
color: #ffffff;
|
||||
opacity: 0.85;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* ---------- Tables ---------- */
|
||||
|
||||
.theme-key-ascii .admin-table thead th {
|
||||
background: #000000;
|
||||
color: #ffffff;
|
||||
border-bottom: 1px solid #ffffff;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.theme-key-ascii .admin-table tbody tr {
|
||||
border-bottom: 1px dashed #ffffff;
|
||||
}
|
||||
|
||||
.theme-key-ascii .admin-table tbody tr:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
/* ---------- Badges / chips ---------- */
|
||||
|
||||
.theme-key-ascii .admin-badge,
|
||||
.theme-key-ascii .admin-cn-badge {
|
||||
border: 1px solid #ffffff;
|
||||
border-radius: 0;
|
||||
background: #000000;
|
||||
color: #ffffff;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.theme-key-ascii .admin-badge::before,
|
||||
.theme-key-ascii .admin-cn-badge::before {
|
||||
content: "[";
|
||||
}
|
||||
|
||||
.theme-key-ascii .admin-badge::after,
|
||||
.theme-key-ascii .admin-cn-badge::after {
|
||||
content: "]";
|
||||
}
|
||||
|
||||
/* ---------- Links ---------- */
|
||||
|
||||
.theme-key-ascii a:not(.btn):not(.bottom-nav button):not([class*="-trigger"]),
|
||||
.theme-key-ascii .admin-screen-wrap a:not(.admin-btn):not(.admin-nav-item) {
|
||||
color: #ffffff;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.theme-key-ascii a:not(.btn):not(.bottom-nav button):not([class*="-trigger"]):hover,
|
||||
.theme-key-ascii .admin-screen-wrap a:not(.admin-btn):not(.admin-nav-item):hover {
|
||||
background: #ffffff;
|
||||
color: #000000;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* ---------- Selection ---------- */
|
||||
|
||||
.theme-key-ascii ::selection {
|
||||
background: #ffffff;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
/* ---------- Scrollbars ---------- */
|
||||
|
||||
.theme-key-ascii ::-webkit-scrollbar {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.theme-key-ascii ::-webkit-scrollbar-track {
|
||||
background-color: #000000;
|
||||
background-image: repeating-linear-gradient(
|
||||
0deg,
|
||||
#ffffff 0,
|
||||
#ffffff 1px,
|
||||
transparent 1px,
|
||||
transparent 4px
|
||||
);
|
||||
}
|
||||
|
||||
.theme-key-ascii ::-webkit-scrollbar-thumb {
|
||||
background: #ffffff;
|
||||
border: 1px solid #000000;
|
||||
}
|
||||
|
||||
.theme-key-ascii ::-webkit-scrollbar-thumb:active {
|
||||
background: #b0b0b0;
|
||||
}
|
||||
|
||||
.theme-key-ascii ::-webkit-scrollbar-corner {
|
||||
background: #000000;
|
||||
}
|
||||
|
||||
/* ---------- Lucide icons: render as crisp white outlines ---------- */
|
||||
|
||||
.theme-key-ascii svg.lucide,
|
||||
.theme-key-ascii svg[class*="lucide-"] {
|
||||
color: #ffffff !important;
|
||||
stroke: #ffffff !important;
|
||||
fill: none !important;
|
||||
stroke-width: 1.75;
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.theme-key-ascii .btn-primary svg.lucide,
|
||||
.theme-key-ascii .bottom-nav button.active svg.lucide,
|
||||
.theme-key-ascii .period-card.active svg.lucide,
|
||||
.theme-key-ascii .method-card.active svg.lucide,
|
||||
.theme-key-ascii .option-row.active svg.lucide,
|
||||
.theme-key-ascii .admin-btn-primary svg.lucide,
|
||||
.theme-key-ascii .admin-nav-item.active svg.lucide,
|
||||
.theme-key-ascii .admin-tabs-trigger[data-state="active"] svg.lucide,
|
||||
.theme-key-ascii .admin-revenue-period-btn.is-active svg.lucide {
|
||||
color: #000000 !important;
|
||||
stroke: #000000 !important;
|
||||
}
|
||||
|
||||
/* ---------- Toast / language select polish ---------- */
|
||||
|
||||
.theme-key-ascii .toast {
|
||||
background: #000000;
|
||||
border: 1px solid #ffffff;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.theme-key-ascii .language-select-item {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.theme-key-ascii .language-select-item[data-highlighted],
|
||||
.theme-key-ascii .language-select-item[data-selected] {
|
||||
background: #ffffff;
|
||||
color: #000000 !important;
|
||||
}
|
||||
|
||||
/* ---------- Headings: stronger console feel ---------- */
|
||||
|
||||
.theme-key-ascii h1,
|
||||
.theme-key-ascii h2,
|
||||
.theme-key-ascii h3,
|
||||
.theme-key-ascii h4 {
|
||||
font-family: var(--font-mono);
|
||||
letter-spacing: 0;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.theme-key-ascii .admin-header {
|
||||
background: #000000;
|
||||
border-bottom: 1px solid #ffffff;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.theme-key-ascii .admin-header-title h2,
|
||||
.theme-key-ascii .admin-header-title small {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* Make any element with role progressbar but no inner span show animated stripes. */
|
||||
.theme-key-ascii [role="progressbar"]:not(.progress) {
|
||||
background:
|
||||
repeating-linear-gradient(
|
||||
90deg,
|
||||
#ffffff 0,
|
||||
#ffffff 6px,
|
||||
#000000 6px,
|
||||
#000000 8px
|
||||
);
|
||||
animation: ascii-scan 0.6s linear infinite;
|
||||
border: 1px solid #ffffff;
|
||||
border-radius: 0;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* Console-themed extras
|
||||
* ============================================================ */
|
||||
|
||||
/* ---------- ASCII skeletons ---------- *
|
||||
* Subtle dark shimmer with a single bright scan line moving across.
|
||||
*/
|
||||
|
||||
@keyframes ascii-skeleton-scan {
|
||||
0% { background-position: -120% 0; }
|
||||
100% { background-position: 220% 0; }
|
||||
}
|
||||
|
||||
.theme-key-ascii .ui-skeleton,
|
||||
.theme-key-ascii .admin-skeleton,
|
||||
.theme-key-ascii .skeleton-line,
|
||||
.theme-key-ascii .skeleton-dot,
|
||||
.theme-key-ascii .skeleton-pay-button,
|
||||
.theme-key-ascii .ui-skeleton-line,
|
||||
.theme-key-ascii .admin-skeleton-line,
|
||||
.theme-key-ascii .admin-skeleton-line-strong,
|
||||
.theme-key-ascii .admin-skeleton-line-soft,
|
||||
.theme-key-ascii .admin-skeleton-line-short,
|
||||
.theme-key-ascii .admin-skeleton-line-tiny,
|
||||
.theme-key-ascii .ui-skeleton-title,
|
||||
.theme-key-ascii .ui-skeleton-short,
|
||||
.theme-key-ascii .ui-skeleton-tiny,
|
||||
.theme-key-ascii .ui-skeleton-badge,
|
||||
.theme-key-ascii .admin-skeleton-badge,
|
||||
.theme-key-ascii .admin-skeleton-avatar,
|
||||
.theme-key-ascii .admin-stat-skeleton-card,
|
||||
.theme-key-ascii .admin-stat-skeleton-wide,
|
||||
.theme-key-ascii .admin-cn-card-skeleton--tall {
|
||||
border-radius: 0 !important;
|
||||
border: 1px solid #ffffff !important;
|
||||
background-color: #050505 !important;
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
transparent 40%,
|
||||
rgba(255, 255, 255, 0.18) 50%,
|
||||
transparent 60%,
|
||||
transparent 100%
|
||||
) !important;
|
||||
background-size: 200% 100% !important;
|
||||
background-repeat: no-repeat !important;
|
||||
color: #ffffff !important;
|
||||
animation: ascii-skeleton-scan 1.6s linear infinite !important;
|
||||
}
|
||||
|
||||
.theme-key-ascii .admin-skeleton-avatar {
|
||||
width: 32px !important;
|
||||
height: 32px !important;
|
||||
}
|
||||
|
||||
/* ---------- Empty / loading state console message ---------- */
|
||||
.theme-key-ascii .admin-empty {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.theme-key-ascii .admin-empty::before {
|
||||
content: "$ tail -f /var/log/empty.log";
|
||||
display: block;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--muted);
|
||||
margin-bottom: 8px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
/* ---------- Buttons: glitch on hover ---------- */
|
||||
|
||||
@keyframes ascii-glitch {
|
||||
0%, 100% { transform: translate(0, 0); clip-path: inset(0 0 0 0); }
|
||||
20% { transform: translate(-1px, 0); clip-path: inset(20% 0 50% 0); }
|
||||
40% { transform: translate(1px, 0); clip-path: inset(40% 0 30% 0); }
|
||||
60% { transform: translate(-1px, 0); clip-path: inset(10% 0 70% 0); }
|
||||
80% { transform: translate(1px, 0); clip-path: inset(60% 0 10% 0); }
|
||||
}
|
||||
|
||||
.theme-key-ascii .btn:hover:not(:disabled)::after,
|
||||
.theme-key-ascii .admin-btn:hover:not(:disabled)::after {
|
||||
content: attr(data-label, "");
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Disable glitch text duplication if the button has no data-label.
|
||||
* Apply a subtle scanline overlay instead, which is content-agnostic. */
|
||||
.theme-key-ascii .btn,
|
||||
.theme-key-ascii .admin-btn,
|
||||
.theme-key-ascii .admin-nav-item,
|
||||
.theme-key-ascii .bottom-nav button {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.theme-key-ascii .btn:hover:not(:disabled)::before,
|
||||
.theme-key-ascii .admin-btn:hover:not(:disabled)::before,
|
||||
.theme-key-ascii .admin-nav-item:hover::before,
|
||||
.theme-key-ascii .bottom-nav button:hover::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background: repeating-linear-gradient(
|
||||
0deg,
|
||||
rgba(0, 0, 0, 0.4) 0,
|
||||
rgba(0, 0, 0, 0.4) 1px,
|
||||
transparent 1px,
|
||||
transparent 3px
|
||||
);
|
||||
animation: ascii-glitch 0.6s steps(1) infinite;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* ---------- Bottom nav active markers "> item <" ---------- */
|
||||
|
||||
.theme-key-ascii .bottom-nav button.active::before,
|
||||
.theme-key-ascii .admin-nav-item.active::before {
|
||||
content: ">";
|
||||
position: absolute;
|
||||
left: 6px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-family: var(--font-mono);
|
||||
color: #000000;
|
||||
animation: ascii-blink 1s steps(1) infinite;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.theme-key-ascii .bottom-nav button.active::after,
|
||||
.theme-key-ascii .admin-nav-item.active::after {
|
||||
content: "<";
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-family: var(--font-mono);
|
||||
color: #000000;
|
||||
animation: ascii-blink 1s steps(1) infinite;
|
||||
animation-delay: 0.5s;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.theme-key-ascii .bottom-nav button.active,
|
||||
.theme-key-ascii .admin-nav-item.active {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* On the mobile bottom-bar (compact) hide the markers to avoid overlap. */
|
||||
@media (max-width: 1023px) {
|
||||
.theme-key-ascii .bottom-nav button.active::before,
|
||||
.theme-key-ascii .bottom-nav button.active::after {
|
||||
content: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- ASCII block progress fill ---------- *
|
||||
* The actual fill renders as alternating █ blocks via the existing span
|
||||
* gradient. We also overlay a slow scanning highlight to make it feel
|
||||
* "live", and add a soft typed counter to the right edge.
|
||||
*/
|
||||
|
||||
/* (duplicate progress fill rules removed — see definition above) */
|
||||
|
||||
/* ---------- Headings: subtle CRT glitch on hover ---------- */
|
||||
|
||||
@keyframes ascii-heading-jitter {
|
||||
0%, 92%, 100% { transform: translate(0, 0); }
|
||||
93% { transform: translate(-1px, 0); }
|
||||
94% { transform: translate(1px, 0); }
|
||||
95% { transform: translate(0, -1px); }
|
||||
96% { transform: translate(0, 1px); }
|
||||
}
|
||||
|
||||
.theme-key-ascii h1,
|
||||
.theme-key-ascii h2,
|
||||
.theme-key-ascii h3,
|
||||
.theme-key-ascii .login-brand h1,
|
||||
.theme-key-ascii .admin-sidebar-brand strong,
|
||||
.theme-key-ascii .admin-card-head h2,
|
||||
.theme-key-ascii .admin-card-head h3 {
|
||||
display: inline-block;
|
||||
animation: ascii-heading-jitter 7s steps(1) infinite;
|
||||
}
|
||||
|
||||
/* ---------- App-shell boot banner ---------- *
|
||||
* A non-blocking strip at the very top of the viewport that displays a
|
||||
* typed "booting…" line, then settles. Pure CSS so it cannot interfere
|
||||
* with any DOM. The animation runs once on mount.
|
||||
*/
|
||||
|
||||
@keyframes ascii-boot-type {
|
||||
0% { width: 0; }
|
||||
85% { width: 28ch; }
|
||||
100% { width: 28ch; }
|
||||
}
|
||||
|
||||
@keyframes ascii-boot-fade {
|
||||
0%, 70% { opacity: 1; }
|
||||
100% { opacity: 0; visibility: hidden; }
|
||||
}
|
||||
|
||||
.theme-key-ascii.app-shell::after {
|
||||
content: "$ remnawave --start --tty=0";
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 9999;
|
||||
display: block;
|
||||
padding: 2px 8px;
|
||||
max-width: 28ch;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
background: #000000;
|
||||
color: #ffffff;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
border-right: 1px solid #ffffff;
|
||||
border-bottom: 1px solid #ffffff;
|
||||
pointer-events: none;
|
||||
animation:
|
||||
ascii-boot-type 1.6s steps(28) 1 both,
|
||||
ascii-boot-fade 3s linear 1.6s 1 forwards;
|
||||
}
|
||||
|
||||
/* ---------- Toggle / checkbox squareification (best-effort) ---------- */
|
||||
|
||||
.theme-key-ascii input[type="checkbox"],
|
||||
.theme-key-ascii input[type="radio"] {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
border: 1px solid #ffffff;
|
||||
background: #000000;
|
||||
border-radius: 0 !important;
|
||||
position: relative;
|
||||
vertical-align: middle;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.theme-key-ascii input[type="checkbox"]:checked::after,
|
||||
.theme-key-ascii input[type="radio"]:checked::after {
|
||||
content: "x";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* ---------- Code-like "$ " prefix on toast messages ---------- */
|
||||
|
||||
.theme-key-ascii .toast::before {
|
||||
content: "$ ";
|
||||
color: #ffffff;
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* ---------- Disabled state — strikethrough hatching ---------- */
|
||||
|
||||
.theme-key-ascii .btn:disabled,
|
||||
.theme-key-ascii .admin-btn:disabled,
|
||||
.theme-key-ascii button:disabled {
|
||||
background-image: repeating-linear-gradient(
|
||||
-45deg,
|
||||
transparent 0,
|
||||
transparent 4px,
|
||||
rgba(255, 255, 255, 0.18) 4px,
|
||||
rgba(255, 255, 255, 0.18) 5px
|
||||
);
|
||||
color: var(--dim) !important;
|
||||
border-color: var(--dim) !important;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* Square everything: drop all rounded corners on touched surfaces.
|
||||
* ============================================================ */
|
||||
|
||||
.theme-key-ascii :is(
|
||||
.card, .dialog-card, .toast,
|
||||
.btn, .input,
|
||||
.period-card, .method-card, .settings-row, .option-row,
|
||||
.tariff-selected-card, .tariff-action-card, .tariff-warning-card,
|
||||
.topup-carryover-note, .language-select-content, .language-select-item,
|
||||
.language-select-trigger, .bottom-nav, .bottom-nav button,
|
||||
.field-error-tooltip,
|
||||
.admin-card, .admin-card-head, .admin-card-body,
|
||||
.admin-stat-card, .admin-stat-skeleton-card, .admin-stat-skeleton-wide,
|
||||
.admin-revenue-panel, .admin-empty,
|
||||
.admin-tariff-card, .admin-toolbar-card, .admin-table-card,
|
||||
.admin-panel-dash-card,
|
||||
.admin-select-trigger, .admin-select-content, .admin-select-item,
|
||||
.admin-cn-card, .admin-cn-badge, .admin-badge,
|
||||
.admin-cn-card-skeleton--tall,
|
||||
.admin-input, .admin-textarea, .admin-btn, .admin-chip,
|
||||
.admin-tabs-trigger, .admin-tabs-list,
|
||||
.admin-nav-item, .admin-revenue-period-btn, .admin-mobile-toggle,
|
||||
.admin-header, .admin-sidebar, .admin-sidebar-brand,
|
||||
.admin-dialog,
|
||||
.admin-theme-editor-section,
|
||||
[data-slot="card"], [data-slot="card-header"],
|
||||
[data-slot="card-content"], [data-slot="card-footer"]
|
||||
),
|
||||
.theme-key-ascii :is(
|
||||
.card, .dialog-card, .toast,
|
||||
.btn, .input,
|
||||
.admin-card, .admin-card-head, .admin-card-body,
|
||||
.admin-stat-card, .admin-revenue-panel, .admin-empty,
|
||||
.admin-tariff-card, .admin-toolbar-card, .admin-table-card,
|
||||
.admin-panel-dash-card,
|
||||
.admin-select-trigger, .admin-select-content,
|
||||
.admin-cn-card,
|
||||
.admin-input, .admin-textarea, .admin-btn,
|
||||
.admin-nav-item, .admin-tabs-trigger
|
||||
) * {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
.theme-key-ascii img,
|
||||
.theme-key-ascii .admin-avatar,
|
||||
.theme-key-ascii .admin-skeleton-avatar {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* Console-style tables: cell borders, header underline,
|
||||
* row separator using dashed line.
|
||||
* ============================================================ */
|
||||
|
||||
.theme-key-ascii .admin-table,
|
||||
.theme-key-ascii table {
|
||||
border-collapse: collapse;
|
||||
border: 1px solid #ffffff;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.theme-key-ascii .admin-table th,
|
||||
.theme-key-ascii .admin-table td,
|
||||
.theme-key-ascii table th,
|
||||
.theme-key-ascii table td {
|
||||
border: 1px solid #ffffff;
|
||||
border-radius: 0 !important;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.theme-key-ascii .admin-table thead th,
|
||||
.theme-key-ascii table thead th {
|
||||
background: #000000;
|
||||
border-bottom: 2px solid #ffffff;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.theme-key-ascii .admin-table tbody tr,
|
||||
.theme-key-ascii table tbody tr {
|
||||
border-bottom: 1px solid #ffffff;
|
||||
}
|
||||
|
||||
.theme-key-ascii .admin-table tbody tr:hover,
|
||||
.theme-key-ascii table tbody tr:hover {
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
}
|
||||
|
||||
.theme-key-ascii .admin-table tbody tr:hover td,
|
||||
.theme-key-ascii table tbody tr:hover td {
|
||||
color: #ffffff;
|
||||
}
|
||||
@@ -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": 1,
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
.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-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;
|
||||
}
|
||||
|
||||
/* 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);
|
||||
}
|
||||
@@ -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": 2,
|
||||
"tokens": {
|
||||
"color_scheme": "light"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 340 B |
|
After Width: | Height: | Size: 375 B |
|
After Width: | Height: | Size: 424 B |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 356 B |
|
After Width: | Height: | Size: 419 B |
|
After Width: | Height: | Size: 372 B |
|
After Width: | Height: | Size: 388 B |
|
After Width: | Height: | Size: 378 B |
|
After Width: | Height: | Size: 424 B |
|
After Width: | Height: | Size: 636 B |
|
After Width: | Height: | Size: 364 B |
|
After Width: | Height: | Size: 390 B |
|
After Width: | Height: | Size: 385 B |
|
After Width: | Height: | Size: 415 B |
|
After Width: | Height: | Size: 356 B |
|
After Width: | Height: | Size: 393 B |
|
After Width: | Height: | Size: 474 B |
|
After Width: | Height: | Size: 395 B |
|
After Width: | Height: | Size: 461 B |
|
After Width: | Height: | Size: 327 B |
|
After Width: | Height: | Size: 411 B |
|
After Width: | Height: | Size: 395 B |
|
After Width: | Height: | Size: 415 B |
|
After Width: | Height: | Size: 393 B |
|
After Width: | Height: | Size: 422 B |
|
After Width: | Height: | Size: 478 B |
|
After Width: | Height: | Size: 500 B |
|
After Width: | Height: | Size: 589 B |
|
After Width: | Height: | Size: 392 B |
|
After Width: | Height: | Size: 392 B |
|
After Width: | Height: | Size: 384 B |
|
After Width: | Height: | Size: 419 B |
|
After Width: | Height: | Size: 403 B |
|
After Width: | Height: | Size: 371 B |
|
After Width: | Height: | Size: 344 B |
@@ -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": 6,
|
||||
"tokens": {
|
||||
"color_scheme": "light",
|
||||
"style_preset": "win95"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
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 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")
|
||||
for key in (
|
||||
"yookassa_service",
|
||||
"lknpd_service",
|
||||
"subscription_service",
|
||||
"referral_service",
|
||||
"panel_service",
|
||||
"stars_service",
|
||||
"freekassa_service",
|
||||
"cryptopay_service",
|
||||
"panel_webhook_service",
|
||||
"platega_service",
|
||||
"severpay_service",
|
||||
):
|
||||
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.handlers.user.payment import yookassa_webhook_route
|
||||
from bot.services.crypto_pay_service import cryptopay_webhook_route
|
||||
from bot.services.freekassa_service import freekassa_webhook_route
|
||||
from bot.services.panel_webhook_service import panel_webhook_route
|
||||
from bot.services.platega_service import platega_webhook_route
|
||||
from bot.services.severpay_service import severpay_webhook_route
|
||||
|
||||
cp_path = settings.cryptopay_webhook_path
|
||||
if cp_path.startswith("/"):
|
||||
app.router.add_post(cp_path, cryptopay_webhook_route)
|
||||
logging.info(f"CryptoPay webhook route configured at: [POST] {cp_path}")
|
||||
|
||||
fk_path = settings.freekassa_webhook_path
|
||||
if fk_path.startswith("/"):
|
||||
app.router.add_post(fk_path, freekassa_webhook_route)
|
||||
logging.info(f"FreeKassa webhook route configured at: [POST] {fk_path}")
|
||||
|
||||
pg_path = settings.platega_webhook_path
|
||||
if pg_path.startswith("/"):
|
||||
app.router.add_post(pg_path, platega_webhook_route)
|
||||
logging.info(f"Platega webhook route configured at: [POST] {pg_path}")
|
||||
|
||||
sp_path = settings.severpay_webhook_path
|
||||
if sp_path.startswith("/"):
|
||||
app.router.add_post(sp_path, severpay_webhook_route)
|
||||
logging.info(f"SeverPay webhook route configured at: [POST] {sp_path}")
|
||||
|
||||
# YooKassa webhook (register only when base URL present and path configured)
|
||||
yk_path = settings.yookassa_webhook_path
|
||||
if settings.WEBHOOK_BASE_URL and yk_path and yk_path.startswith("/"):
|
||||
app.router.add_post(yk_path, yookassa_webhook_route)
|
||||
logging.info(f"YooKassa webhook route configured at: [POST] {yk_path}")
|
||||
|
||||
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)
|
||||
@@ -0,0 +1 @@
|
||||
"""Domain modules for the subscription Mini App backend."""
|
||||
@@ -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 aiogram.types import LabeledPrice
|
||||
from aiohttp import ClientSession, ClientTimeout, web
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr, ValidationError, constr, field_validator
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
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_get_json, cache_set_json, get_redis, redis_key
|
||||
from bot.services.crypto_pay_service import CryptoPayService
|
||||
from bot.services.email_auth_service import EmailAuthService, normalize_email
|
||||
from bot.services.email_templates import render_account_merged
|
||||
from bot.services.freekassa_service import FreeKassaService
|
||||
from bot.services.platega_service import PlategaService
|
||||
from bot.services.promo_code_service import PromoCodeService
|
||||
from bot.services.referral_service import ReferralService
|
||||
from bot.services.severpay_service import SeverPayService
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.services.yookassa_service import YooKassaService
|
||||
from bot.utils.config_link import prepare_config_links
|
||||
from bot.utils.request_security import parse_ip_entries, request_client_ip
|
||||
from bot.utils.text_sanitizer import sanitize_display_name, sanitize_username
|
||||
from config.settings import Settings
|
||||
from db.dal import payment_dal, subscription_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_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/logout",
|
||||
}
|
||||
|
||||
_SHARED_HTTP_SESSION: Optional[ClientSession] = None
|
||||
_SHARED_HTTP_SESSION_LOCK = asyncio.Lock()
|
||||
|
||||
__all__ = [name for name in globals() if not name.startswith("__")]
|
||||
@@ -0,0 +1,540 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
|
||||
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)
|
||||
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_panel_identity_for_user(
|
||||
request,
|
||||
current_user,
|
||||
expire_at=merge_end_date,
|
||||
)
|
||||
# Best-effort cleanup of the removed panel account after the DB merge.
|
||||
if source_panel_uuid and final_panel_uuid and source_panel_uuid != final_panel_uuid:
|
||||
subscription_service: SubscriptionService = request.app.get(
|
||||
"subscription_service"
|
||||
)
|
||||
if subscription_service and subscription_service.panel_service:
|
||||
try:
|
||||
await subscription_service.panel_service.delete_user_from_panel(
|
||||
source_panel_uuid,
|
||||
log_response=False,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to delete merged source panel user %s: %s",
|
||||
source_panel_uuid,
|
||||
exc,
|
||||
)
|
||||
|
||||
email_service: EmailAuthService = request.app.get("email_auth_service")
|
||||
if email_service and final_email:
|
||||
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")
|
||||
|
||||
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_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_panel_identity_for_user(
|
||||
request,
|
||||
db_user,
|
||||
expire_at=merge_end_date,
|
||||
)
|
||||
# Best-effort cleanup of the removed panel account after the DB merge.
|
||||
if source_panel_uuid and final_panel_uuid and source_panel_uuid != final_panel_uuid:
|
||||
subscription_service: SubscriptionService = request.app.get(
|
||||
"subscription_service"
|
||||
)
|
||||
if subscription_service and subscription_service.panel_service:
|
||||
try:
|
||||
await subscription_service.panel_service.delete_user_from_panel(
|
||||
source_panel_uuid,
|
||||
log_response=False,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to delete merged source panel user %s: %s",
|
||||
source_panel_uuid,
|
||||
exc,
|
||||
)
|
||||
|
||||
email_service: EmailAuthService = request.app.get("email_auth_service")
|
||||
if email_service and final_email:
|
||||
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")
|
||||
|
||||
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"]
|
||||
cache_key = redis_key(settings, "cache", "webapp", "me", user_id)
|
||||
cached = await cache_get_json(settings, cache_key)
|
||||
if cached:
|
||||
return web.json_response({"ok": True, **cached})
|
||||
data = await _build_user_payload(request, user_id)
|
||||
await cache_set_json(settings, cache_key, data, settings.WEBAPP_ME_CACHE_TTL_SECONDS)
|
||||
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 ""))
|
||||
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()
|
||||
|
||||
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,
|
||||
)
|
||||
@@ -0,0 +1,59 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
|
||||
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["webapp_logo_cache"] = None
|
||||
app["webapp_logo_cache_lock"] = asyncio.Lock()
|
||||
app["webapp_settings_cache"] = {"ts": 0.0, "data": {}}
|
||||
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)
|
||||
|
||||
async def _shutdown(app_obj: web.Application) -> None:
|
||||
await _close_shared_http_session()
|
||||
|
||||
app.on_startup.append(_startup)
|
||||
app.on_shutdown.append(_shutdown)
|
||||
|
||||
for key in (
|
||||
"subscription_service",
|
||||
"yookassa_service",
|
||||
"freekassa_service",
|
||||
"cryptopay_service",
|
||||
"platega_service",
|
||||
"severpay_service",
|
||||
"promo_code_service",
|
||||
"referral_service",
|
||||
"panel_service",
|
||||
):
|
||||
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
|
||||
@@ -0,0 +1,156 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
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 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 = (lang or "ru").split("-")[0].lower()
|
||||
return value if value in {"ru", "en"} 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)}"
|
||||
@@ -0,0 +1,169 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
|
||||
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")
|
||||
|
||||
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 for user %s", user_id)
|
||||
return _json_error(502, "devices_load_failed", "Failed to load devices")
|
||||
|
||||
devices = _normalize_devices_response(devices_response)
|
||||
max_devices = _coerce_int_or_none(active.get("max_devices")) if active else None
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": True,
|
||||
"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 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),
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
|
||||
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 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)
|
||||
@@ -0,0 +1,65 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
|
||||
def setup_subscription_webapp_routes(app: web.Application) -> None:
|
||||
app.router.add_get("/", index_route)
|
||||
app.router.add_get("/home", index_route)
|
||||
app.router.add_get("/invite", index_route)
|
||||
app.router.add_get("/devices", index_route)
|
||||
app.router.add_get("/settings", index_route)
|
||||
app.router.add_get("/admin", index_route)
|
||||
app.router.add_get(
|
||||
(
|
||||
"/admin/{section:stats|users|payments|promos|ads|broadcast|logs|tariffs|"
|
||||
"appearance|settings}"
|
||||
),
|
||||
index_route,
|
||||
)
|
||||
app.router.add_get("/admin/users/{user_id:-?[0-9]+}", index_route)
|
||||
app.router.add_get("/auth/telegram/start", telegram_oauth_start_route)
|
||||
app.router.add_get("/auth/telegram/callback", telegram_oauth_callback_route)
|
||||
app.router.add_get("/health", health_route)
|
||||
app.router.add_get(WEBAPP_LOGO_PROXY_PATH, webapp_logo_route)
|
||||
app.router.add_get(
|
||||
rf"{WEBAPP_UPLOADED_LOGO_PATH}/{{filename:[A-Za-z0-9_.-]+}}",
|
||||
webapp_uploaded_logo_route,
|
||||
)
|
||||
app.router.add_get(
|
||||
rf"{WEBAPP_FAVICON_PATH}/{{digest:[0-9a-f]{{16}}}}/{{filename:[A-Za-z0-9_.-]+}}",
|
||||
webapp_favicon_route,
|
||||
)
|
||||
app.router.add_get(
|
||||
r"/webapp-emoji/{codepoints:[0-9a-f_]+}/512.{ext:gif|webp}",
|
||||
webapp_animated_emoji_route,
|
||||
)
|
||||
app.router.add_get("/subscription_webapp.css", css_asset_route)
|
||||
app.router.add_get(r"/webapp-theme-css/{path:.+}", theme_css_asset_route)
|
||||
app.router.add_get(r"/webapp-theme-assets/{path:.+}", theme_asset_route)
|
||||
app.router.add_get("/subscription_webapp.min.{asset_hash}.js", js_asset_route)
|
||||
app.router.add_get("/subscription_webapp.js", js_asset_route)
|
||||
app.router.add_post("/api/auth/telegram/nonce", telegram_oauth_nonce_route)
|
||||
app.router.add_post("/api/auth/token", auth_token_route)
|
||||
app.router.add_post("/api/auth/email/request", email_auth_request_route)
|
||||
app.router.add_post("/api/auth/email/verify", email_auth_verify_route)
|
||||
app.router.add_post("/api/auth/email/magic", email_auth_magic_route)
|
||||
app.router.add_post("/api/auth/logout", logout_route)
|
||||
app.router.add_get("/api/bootstrap", bootstrap_route)
|
||||
app.router.add_get("/api/me", me_route)
|
||||
app.router.add_get("/api/account/avatar", account_avatar_route)
|
||||
app.router.add_post("/api/account/language", account_language_route)
|
||||
app.router.add_post("/api/account/email/request", account_email_request_route)
|
||||
app.router.add_post("/api/account/email/verify", account_email_verify_route)
|
||||
app.router.add_post("/api/account/telegram/link", account_telegram_link_route)
|
||||
app.router.add_post("/api/promo/apply", apply_promo_route)
|
||||
app.router.add_post("/api/trial/activate", activate_trial_route)
|
||||
app.router.add_get("/api/devices", devices_route)
|
||||
app.router.add_post("/api/devices/disconnect", disconnect_device_route)
|
||||
app.router.add_get("/api/devices/topup-options", device_topup_options_route)
|
||||
app.router.add_get("/api/tariffs/topup-options", tariff_topup_options_route)
|
||||
app.router.add_get("/api/tariffs/change-options", tariff_change_options_route)
|
||||
app.router.add_post("/api/tariffs/change", tariff_change_route)
|
||||
app.router.add_post("/api/tariffs/change-payment", tariff_change_payment_route)
|
||||
app.router.add_post("/api/payments", create_payment_route)
|
||||
app.router.add_get("/api/payments/{payment_id}", payment_status_route)
|
||||
setup_admin_routes(app)
|
||||
@@ -0,0 +1,627 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
from config.webapp_themes_config import public_themes_catalog_payload
|
||||
|
||||
|
||||
async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, Any]:
|
||||
settings: Settings = request.app["settings"]
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
subscription_service: SubscriptionService = request.app["subscription_service"]
|
||||
cached = _get_cached_webapp_settings(request)
|
||||
|
||||
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:
|
||||
raise web.HTTPForbidden(
|
||||
text=json.dumps({"ok": False, "error": "access_denied"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
active = await subscription_service.get_active_subscription_details(session, user_id)
|
||||
referral_code = await user_dal.ensure_referral_code(session, db_user)
|
||||
referral_service: Optional[ReferralService] = request.app.get("referral_service")
|
||||
bot_username = request.app.get("bot_username") or ""
|
||||
referral_link = None
|
||||
if referral_service and bot_username:
|
||||
referral_link = await referral_service.generate_referral_link(
|
||||
session,
|
||||
bot_username,
|
||||
user_id,
|
||||
)
|
||||
webapp_referral_link = _build_webapp_referral_link(
|
||||
request.app["settings"].SUBSCRIPTION_MINI_APP_URL,
|
||||
referral_code,
|
||||
)
|
||||
referral_stats = (
|
||||
await referral_service.get_referral_stats(session, user_id)
|
||||
if referral_service
|
||||
else {"invited_count": 0, "purchased_count": 0}
|
||||
)
|
||||
local_sub = (
|
||||
await subscription_dal.get_active_subscription_by_user_id(
|
||||
session,
|
||||
user_id,
|
||||
db_user.panel_user_uuid,
|
||||
)
|
||||
if db_user.panel_user_uuid
|
||||
else None
|
||||
)
|
||||
trial_available = bool(
|
||||
settings.TRIAL_ENABLED
|
||||
and settings.TRIAL_DURATION_DAYS > 0
|
||||
and not await subscription_service.has_had_any_subscription(session, user_id)
|
||||
)
|
||||
avatar = await _ensure_cached_telegram_avatar(request, session, db_user)
|
||||
try:
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
|
||||
lang = _normalize_language(db_user.language_code or settings.DEFAULT_LANGUAGE)
|
||||
admin_ids = {int(x) for x in (settings.ADMIN_IDS or [])}
|
||||
is_admin = bool(db_user.telegram_id and int(db_user.telegram_id) in admin_ids)
|
||||
return {
|
||||
"user": {
|
||||
"id": user_id,
|
||||
"username": db_user.username,
|
||||
"email": db_user.email,
|
||||
"email_verified": bool(db_user.email_verified_at),
|
||||
"telegram_id": db_user.telegram_id,
|
||||
"telegram_linked": bool(_telegram_id_for_user(db_user)),
|
||||
"telegram_photo_url": _telegram_avatar_url(avatar),
|
||||
"first_name": db_user.first_name,
|
||||
"language_code": lang,
|
||||
"is_admin": is_admin,
|
||||
},
|
||||
"subscription": _serialize_subscription(settings, active, local_sub, lang),
|
||||
"referral": {
|
||||
"code": referral_code,
|
||||
"bot_link": referral_link,
|
||||
"webapp_link": webapp_referral_link,
|
||||
"invited_count": referral_stats.get("invited_count", 0),
|
||||
"purchased_count": referral_stats.get("purchased_count", 0),
|
||||
"welcome_bonus_days": max(
|
||||
0, int(getattr(settings, "REFERRAL_WELCOME_BONUS_DAYS", 0) or 0)
|
||||
),
|
||||
"one_bonus_per_referee": bool(
|
||||
getattr(settings, "REFERRAL_ONE_BONUS_PER_REFEREE", False)
|
||||
),
|
||||
"bonus_details": _serialize_referral_bonus_details(settings, lang),
|
||||
},
|
||||
"plans": _serialize_plans(
|
||||
settings,
|
||||
lang,
|
||||
subscription_options=cached["subscription_options"],
|
||||
stars_subscription_options=cached["stars_subscription_options"],
|
||||
traffic_packages=cached["traffic_packages"],
|
||||
stars_traffic_packages=cached["stars_traffic_packages"],
|
||||
),
|
||||
"payment_methods": _serialize_payment_methods(settings, request.app),
|
||||
"themes_catalog": public_themes_catalog_payload(
|
||||
settings.webapp_themes_catalog,
|
||||
settings.WEBAPP_PRIMARY_COLOR or "#00fe7a",
|
||||
enabled_only=True,
|
||||
),
|
||||
"settings": {
|
||||
"support_url": settings.SUPPORT_LINK,
|
||||
"traffic_mode": bool(settings.traffic_sale_mode),
|
||||
"my_devices_enabled": bool(settings.MY_DEVICES_SECTION_ENABLED),
|
||||
"user_hwid_device_limit": (
|
||||
int(settings.USER_HWID_DEVICE_LIMIT)
|
||||
if settings.USER_HWID_DEVICE_LIMIT is not None
|
||||
else None
|
||||
),
|
||||
"trial_enabled": bool(settings.TRIAL_ENABLED),
|
||||
"trial_available": trial_available,
|
||||
"trial_duration_days": int(settings.TRIAL_DURATION_DAYS or 0),
|
||||
"trial_traffic_limit_gb": float(settings.TRIAL_TRAFFIC_LIMIT_GB or 0),
|
||||
"trial_traffic_strategy": getattr(settings, "TRIAL_TRAFFIC_STRATEGY", "NO_RESET"),
|
||||
"email_auth_enabled": settings.email_auth_configured,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _serialize_referral_bonus_details(settings: Settings, lang: str) -> List[Dict[str, Any]]:
|
||||
if getattr(settings, "traffic_sale_mode", False):
|
||||
return []
|
||||
|
||||
details: List[Dict[str, Any]] = []
|
||||
for months, _price in sorted(settings.subscription_options.items()):
|
||||
inviter_days = settings.referral_bonus_inviter.get(months)
|
||||
friend_days = settings.referral_bonus_referee.get(months)
|
||||
if inviter_days is None and friend_days is None:
|
||||
continue
|
||||
details.append(
|
||||
{
|
||||
"months": int(months),
|
||||
"title": _format_months_title(int(months), lang),
|
||||
"inviter_days": int(inviter_days or 0),
|
||||
"friend_days": int(friend_days or 0),
|
||||
}
|
||||
)
|
||||
return details
|
||||
|
||||
|
||||
def _build_webapp_referral_link(
|
||||
base_url: Optional[str],
|
||||
referral_code: Optional[str],
|
||||
) -> Optional[str]:
|
||||
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}"
|
||||
return urlunsplit(
|
||||
(
|
||||
parts.scheme,
|
||||
parts.netloc,
|
||||
parts.path or "/",
|
||||
urlencode(query),
|
||||
parts.fragment,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _serialize_subscription(
|
||||
settings: Settings,
|
||||
active: Optional[Dict[str, Any]],
|
||||
local_sub: Optional[Any],
|
||||
lang: str,
|
||||
) -> Dict[str, Any]:
|
||||
if not active:
|
||||
return {
|
||||
"active": False,
|
||||
"status": "INACTIVE",
|
||||
"remaining_text": _format_remaining(0, lang),
|
||||
"days_left": 0,
|
||||
"config_link": None,
|
||||
"connect_url": None,
|
||||
}
|
||||
|
||||
end_date = active.get("end_date")
|
||||
if end_date and end_date.tzinfo is None:
|
||||
end_date = end_date.replace(tzinfo=timezone.utc)
|
||||
|
||||
seconds_left = 0
|
||||
if end_date:
|
||||
seconds_left = max(
|
||||
0,
|
||||
int((end_date - datetime.now(timezone.utc)).total_seconds()),
|
||||
)
|
||||
|
||||
can_topup_regular_traffic = False
|
||||
can_topup_premium_traffic = False
|
||||
can_topup_traffic = False
|
||||
can_topup_devices = False
|
||||
if settings.tariffs_config and active.get("tariff_key"):
|
||||
try:
|
||||
tariff = settings.tariffs_config.require(str(active.get("tariff_key")))
|
||||
packages = settings.tariffs_config.topup_packages_for(tariff)
|
||||
can_topup_regular_traffic = bool(packages and packages.has_any())
|
||||
can_topup_premium_traffic = bool(
|
||||
tariff.premium_squad_uuids
|
||||
and tariff.premium_topup_packages
|
||||
and tariff.premium_topup_packages.has_any()
|
||||
)
|
||||
can_topup_traffic = bool(can_topup_regular_traffic or can_topup_premium_traffic)
|
||||
# max_devices == 0 means unlimited — top-up is pointless in that case.
|
||||
can_topup_devices = bool(
|
||||
tariff.has_hwid_device_packages()
|
||||
and _coerce_int_or_none(active.get("max_devices")) != 0
|
||||
)
|
||||
except Exception:
|
||||
can_topup_regular_traffic = False
|
||||
can_topup_premium_traffic = False
|
||||
can_topup_traffic = False
|
||||
can_topup_devices = False
|
||||
|
||||
return {
|
||||
"active": seconds_left > 0,
|
||||
"status": active.get("status_from_panel") or "UNKNOWN",
|
||||
"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 "N/A",
|
||||
"days_left": seconds_left // 86400,
|
||||
"remaining_text": _format_remaining(seconds_left, lang),
|
||||
"config_link": active.get("config_link"),
|
||||
"connect_url": active.get("connect_button_url") or active.get("config_link"),
|
||||
"traffic_limit": _format_bytes(active.get("traffic_limit_bytes"), zero_as_unlimited=True),
|
||||
"traffic_used": _format_bytes(active.get("traffic_used_bytes")),
|
||||
"traffic_limit_bytes": _coerce_int_or_none(active.get("traffic_limit_bytes")),
|
||||
"traffic_used_bytes": _coerce_int_or_none(active.get("traffic_used_bytes")),
|
||||
"tariff_key": active.get("tariff_key"),
|
||||
"tariff_name": active.get("tariff_name"),
|
||||
"tariff_description": active.get("tariff_description"),
|
||||
"premium_title": active.get("premium_title"),
|
||||
"billing_model": active.get("billing_model"),
|
||||
"traffic_limit_strategy": str(active.get("traffic_limit_strategy") or ""),
|
||||
"tier_baseline_bytes": _coerce_int_or_none(active.get("tier_baseline_bytes")),
|
||||
"topup_balance_bytes": _coerce_int_or_none(active.get("topup_balance_bytes")),
|
||||
"premium_limit": _format_bytes(active.get("premium_limit_bytes"), zero_as_unlimited=True),
|
||||
"premium_used": _format_bytes(active.get("premium_used_bytes")),
|
||||
"premium_limit_bytes": _coerce_int_or_none(active.get("premium_limit_bytes")),
|
||||
"premium_used_bytes": _coerce_int_or_none(active.get("premium_used_bytes")),
|
||||
"premium_baseline_bytes": _coerce_int_or_none(active.get("premium_baseline_bytes")),
|
||||
"premium_topup_balance_bytes": _coerce_int_or_none(
|
||||
active.get("premium_topup_balance_bytes")
|
||||
),
|
||||
"premium_topup_used_bytes": _coerce_int_or_none(active.get("premium_topup_used_bytes")),
|
||||
"premium_bonus_bytes": _coerce_int_or_none(active.get("premium_bonus_bytes")) or 0,
|
||||
"regular_bonus_bytes": _coerce_int_or_none(active.get("regular_bonus_bytes")) or 0,
|
||||
"regular_unlimited_override": bool(active.get("regular_unlimited_override")),
|
||||
"premium_unlimited_override": bool(active.get("premium_unlimited_override")),
|
||||
"premium_is_limited": bool(active.get("premium_is_limited")),
|
||||
"premium_squad_labels": list(active.get("premium_squad_labels") or []),
|
||||
"premium_node_labels": list(active.get("premium_node_labels") or []),
|
||||
"can_topup_traffic": can_topup_traffic,
|
||||
"can_topup_regular_traffic": can_topup_regular_traffic,
|
||||
"can_topup_premium_traffic": can_topup_premium_traffic,
|
||||
"can_topup_devices": can_topup_devices,
|
||||
"period_start_at": active.get("period_start_at").isoformat()
|
||||
if active.get("period_start_at")
|
||||
else None,
|
||||
"is_throttled": bool(active.get("is_throttled")),
|
||||
"max_devices": _coerce_int_or_none(active.get("max_devices")),
|
||||
"base_hwid_device_limit": _coerce_int_or_none(active.get("base_hwid_device_limit")),
|
||||
"extra_hwid_devices": _coerce_int_or_none(active.get("extra_hwid_devices")) or 0,
|
||||
"auto_renew_enabled": bool(getattr(local_sub, "auto_renew_enabled", False)),
|
||||
"provider": getattr(local_sub, "provider", None),
|
||||
}
|
||||
|
||||
|
||||
def _serialize_plans(
|
||||
settings: Settings,
|
||||
lang: str,
|
||||
*,
|
||||
subscription_options: Optional[Dict[int, float]] = None,
|
||||
stars_subscription_options: Optional[Dict[int, int]] = None,
|
||||
traffic_packages: Optional[Dict[float, float]] = None,
|
||||
stars_traffic_packages: Optional[Dict[float, int]] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
tariffs_config = settings.tariffs_config
|
||||
if tariffs_config:
|
||||
plans: List[Dict[str, Any]] = []
|
||||
for tariff in tariffs_config.enabled_tariffs:
|
||||
common = {
|
||||
"tariff_key": tariff.key,
|
||||
"tariff_name": tariff.name(lang),
|
||||
"billing_model": tariff.billing_model,
|
||||
"description": tariff.description(lang),
|
||||
"squad_uuids": tariff.squad_uuids,
|
||||
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
|
||||
"hwid_device_limit": tariff.hwid_device_limit,
|
||||
"hwid_device_packages": _serialize_hwid_device_packages(
|
||||
settings,
|
||||
tariff,
|
||||
tariff.hwid_device_packages,
|
||||
lang,
|
||||
),
|
||||
}
|
||||
if tariff.billing_model == "period":
|
||||
for months in sorted(tariff.enabled_periods):
|
||||
price = tariff.period_price(int(months), "rub")
|
||||
stars_price = tariff.period_price(int(months), "stars")
|
||||
if price is None and (stars_price is None or int(stars_price) <= 0):
|
||||
continue
|
||||
plan = {
|
||||
**common,
|
||||
"id": f"{tariff.key}:period:{int(months)}",
|
||||
"sale_mode": "subscription",
|
||||
"months": int(months),
|
||||
"price": float(price or 0),
|
||||
"title": tariff.name(lang),
|
||||
"subtitle": _format_months_title(int(months), lang),
|
||||
"monthly_gb": tariff.monthly_gb,
|
||||
}
|
||||
if stars_price is not None and int(stars_price) > 0:
|
||||
plan["stars_price"] = int(stars_price)
|
||||
plans.append(plan)
|
||||
else:
|
||||
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 []
|
||||
)
|
||||
}
|
||||
for traffic_gb in sorted(set(rub_packages) | set(stars_packages)):
|
||||
price = rub_packages.get(traffic_gb)
|
||||
stars_price = stars_packages.get(traffic_gb)
|
||||
if price is None and (stars_price is None or int(stars_price) <= 0):
|
||||
continue
|
||||
traffic_value = float(traffic_gb)
|
||||
plan = {
|
||||
**common,
|
||||
"id": f"{tariff.key}:traffic:{_format_number_for_payload(traffic_value)}",
|
||||
"sale_mode": "traffic_package",
|
||||
"months": int(traffic_value)
|
||||
if traffic_value.is_integer()
|
||||
else traffic_value,
|
||||
"traffic_gb": traffic_value,
|
||||
"price": float(price or 0),
|
||||
"title": tariff.name(lang),
|
||||
"subtitle": _format_traffic_title(traffic_value, lang),
|
||||
}
|
||||
if stars_price is not None and int(stars_price) > 0:
|
||||
plan["stars_price"] = int(stars_price)
|
||||
plans.append(plan)
|
||||
return plans
|
||||
|
||||
if getattr(settings, "traffic_sale_mode", False):
|
||||
active_traffic_packages = traffic_packages or settings.traffic_packages
|
||||
active_stars_traffic_packages = stars_traffic_packages or settings.stars_traffic_packages
|
||||
traffic_units = sorted(set(active_traffic_packages) | set(active_stars_traffic_packages))
|
||||
plans: List[Dict[str, Any]] = []
|
||||
for traffic_gb in traffic_units:
|
||||
price = active_traffic_packages.get(traffic_gb)
|
||||
stars_price = active_stars_traffic_packages.get(traffic_gb)
|
||||
if price is None and (stars_price is None or int(stars_price) <= 0):
|
||||
continue
|
||||
traffic_value = float(traffic_gb)
|
||||
plan = {
|
||||
"months": int(traffic_value) if traffic_value.is_integer() else traffic_value,
|
||||
"traffic_gb": traffic_value,
|
||||
"price": float(price or 0),
|
||||
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
|
||||
"title": _format_traffic_title(traffic_value, lang),
|
||||
"sale_mode": "traffic",
|
||||
}
|
||||
if stars_price is not None and int(stars_price) > 0:
|
||||
plan["stars_price"] = int(stars_price)
|
||||
plans.append(plan)
|
||||
return plans
|
||||
|
||||
active_subscription_options = subscription_options or settings.subscription_options
|
||||
active_stars_subscription_options = (
|
||||
stars_subscription_options or settings.stars_subscription_options
|
||||
)
|
||||
plans: List[Dict[str, Any]] = []
|
||||
for months in sorted(set(active_subscription_options) | set(active_stars_subscription_options)):
|
||||
price = active_subscription_options.get(months)
|
||||
stars_price = active_stars_subscription_options.get(months)
|
||||
if price is None and (stars_price is None or int(stars_price) <= 0):
|
||||
continue
|
||||
plan = {
|
||||
"months": int(months),
|
||||
"price": float(price or 0),
|
||||
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
|
||||
"title": _format_months_title(int(months), lang),
|
||||
"sale_mode": "subscription",
|
||||
}
|
||||
if stars_price is not None and int(stars_price) > 0:
|
||||
plan["stars_price"] = int(stars_price)
|
||||
plans.append(plan)
|
||||
return plans
|
||||
|
||||
|
||||
def _traffic_percent(used: Optional[int], limit: Optional[int]) -> int:
|
||||
used_val = int(used or 0)
|
||||
limit_val = int(limit or 0)
|
||||
if limit_val <= 0:
|
||||
return 0
|
||||
return max(0, min(100, round((used_val / limit_val) * 100)))
|
||||
|
||||
|
||||
def _serialize_topup_packages(
|
||||
settings: Settings,
|
||||
tariff: Any,
|
||||
packages: Optional[Any],
|
||||
lang: str,
|
||||
*,
|
||||
sale_mode: str = "topup",
|
||||
title_prefix: str = "",
|
||||
) -> List[Dict[str, Any]]:
|
||||
rub_packages = {
|
||||
float(package.gb): float(package.price) for package in (packages.rub if packages else [])
|
||||
}
|
||||
stars_packages = {
|
||||
float(package.gb): int(float(package.price))
|
||||
for package in (packages.stars if packages else [])
|
||||
}
|
||||
plans: List[Dict[str, Any]] = []
|
||||
for traffic_gb in sorted(set(rub_packages) | set(stars_packages)):
|
||||
price = rub_packages.get(traffic_gb)
|
||||
stars_price = stars_packages.get(traffic_gb)
|
||||
if price is None and (stars_price is None or int(stars_price) <= 0):
|
||||
continue
|
||||
traffic_value = float(traffic_gb)
|
||||
plan: Dict[str, Any] = {
|
||||
"id": f"{tariff.key}:{sale_mode}:{_format_number_for_payload(traffic_value)}",
|
||||
"tariff_key": tariff.key,
|
||||
"tariff_name": tariff.name(lang),
|
||||
"billing_model": tariff.billing_model,
|
||||
"sale_mode": sale_mode,
|
||||
"months": int(traffic_value) if traffic_value.is_integer() else traffic_value,
|
||||
"traffic_gb": traffic_value,
|
||||
"price": float(price or 0),
|
||||
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
|
||||
"title": f"{title_prefix}{_format_traffic_title(traffic_value, lang)}",
|
||||
"subtitle": tariff.premium_name(lang)
|
||||
if sale_mode == "premium_topup"
|
||||
else tariff.name(lang),
|
||||
}
|
||||
if stars_price is not None and int(stars_price) > 0:
|
||||
plan["stars_price"] = int(stars_price)
|
||||
plans.append(plan)
|
||||
return plans
|
||||
|
||||
|
||||
def _serialize_hwid_device_packages(
|
||||
settings: Settings,
|
||||
tariff: Any,
|
||||
packages: Optional[Any],
|
||||
lang: str,
|
||||
) -> List[Dict[str, Any]]:
|
||||
rub_packages = {
|
||||
int(package.count): float(package.price) for package in (packages.rub if packages else [])
|
||||
}
|
||||
stars_packages = {
|
||||
int(package.count): int(float(package.price))
|
||||
for package in (packages.stars if packages else [])
|
||||
}
|
||||
plans: List[Dict[str, Any]] = []
|
||||
for count in sorted(set(rub_packages) | set(stars_packages)):
|
||||
price = rub_packages.get(count)
|
||||
stars_price = stars_packages.get(count)
|
||||
if price is None and (stars_price is None or int(stars_price) <= 0):
|
||||
continue
|
||||
plan: Dict[str, Any] = {
|
||||
"id": f"{tariff.key}:hwid:{count}",
|
||||
"tariff_key": tariff.key,
|
||||
"tariff_name": tariff.name(lang),
|
||||
"billing_model": tariff.billing_model,
|
||||
"sale_mode": "hwid_devices",
|
||||
"months": int(count),
|
||||
"device_count": int(count),
|
||||
"price": float(price or 0),
|
||||
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
|
||||
"title": f"+{count}",
|
||||
"subtitle": tariff.name(lang),
|
||||
}
|
||||
if stars_price is not None and int(stars_price) > 0:
|
||||
plan["stars_price"] = int(stars_price)
|
||||
plans.append(plan)
|
||||
return plans
|
||||
|
||||
|
||||
def _serialize_tariff_change_target(
|
||||
settings: Settings,
|
||||
config: Any,
|
||||
tariff: Any,
|
||||
options: Dict[str, Any],
|
||||
lang: str,
|
||||
) -> Dict[str, Any]:
|
||||
actions: List[Dict[str, Any]] = []
|
||||
mode = str(options.get("mode") or "")
|
||||
if mode == "period_to_period":
|
||||
actions.append(
|
||||
{
|
||||
"mode": "recalc_days",
|
||||
"kind": "free",
|
||||
"title": "recalc_days",
|
||||
"days_after": int(options.get("recalc_days") or 0),
|
||||
"remaining_days": int(options.get("remaining_days") or 0),
|
||||
}
|
||||
)
|
||||
paid_diff = float(options.get("paid_diff_rub") or 0)
|
||||
if paid_diff > 0:
|
||||
actions.append(
|
||||
{
|
||||
"mode": "paid_diff",
|
||||
"kind": "payment",
|
||||
"title": "paid_diff",
|
||||
"price": paid_diff,
|
||||
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
|
||||
}
|
||||
)
|
||||
elif mode == "period_to_traffic":
|
||||
actions.append(
|
||||
{
|
||||
"mode": "convert_days_to_gb",
|
||||
"kind": "free",
|
||||
"title": "convert_days_to_gb",
|
||||
"converted_gb": float(options.get("converted_gb") or 0),
|
||||
"remaining_days": int(options.get("remaining_days") or 0),
|
||||
}
|
||||
)
|
||||
actions.extend(
|
||||
{
|
||||
"mode": "buy_package",
|
||||
"kind": "payment",
|
||||
"title": f"+{package.gb:g} GB",
|
||||
"traffic_gb": float(package.gb),
|
||||
"price": float(package.price),
|
||||
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
|
||||
}
|
||||
for package in (tariff.traffic_packages.rub if tariff.traffic_packages else [])
|
||||
)
|
||||
else:
|
||||
for months in tariff.enabled_periods:
|
||||
price = tariff.period_price(int(months), "rub")
|
||||
if price:
|
||||
actions.append(
|
||||
{
|
||||
"mode": "buy_period",
|
||||
"kind": "payment",
|
||||
"months": int(months),
|
||||
"title": _format_months_title(int(months), lang),
|
||||
"price": float(price),
|
||||
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
|
||||
}
|
||||
)
|
||||
return {
|
||||
"tariff_key": tariff.key,
|
||||
"title": tariff.name(lang),
|
||||
"description": tariff.description(lang),
|
||||
"billing_model": tariff.billing_model,
|
||||
"monthly_gb": tariff.monthly_gb,
|
||||
"options": options,
|
||||
"actions": actions,
|
||||
}
|
||||
|
||||
|
||||
def _serialize_payment_methods(
|
||||
settings: Settings,
|
||||
app: web.Application,
|
||||
) -> List[Dict[str, Any]]:
|
||||
labels = {
|
||||
"severpay": "SeverPay",
|
||||
"freekassa": "FreeKassa / СБП",
|
||||
"platega_sbp": "Platega · СБП",
|
||||
"platega_crypto": "Platega · Crypto",
|
||||
"yookassa": "Банковская карта",
|
||||
"stars": "Telegram Stars",
|
||||
"cryptopay": "CryptoPay",
|
||||
}
|
||||
methods: List[Dict[str, Any]] = []
|
||||
for method in settings.payment_methods_order:
|
||||
method = method.lower()
|
||||
if (
|
||||
method == "severpay"
|
||||
and settings.SEVERPAY_ENABLED
|
||||
and _service_configured(app, "severpay_service")
|
||||
):
|
||||
methods.append({"id": method, "name": labels[method]})
|
||||
elif (
|
||||
method == "freekassa"
|
||||
and settings.FREEKASSA_ENABLED
|
||||
and _service_configured(app, "freekassa_service")
|
||||
):
|
||||
methods.append({"id": method, "name": labels[method]})
|
||||
elif (
|
||||
method == "platega_sbp"
|
||||
and settings.PLATEGA_ENABLED
|
||||
and settings.PLATEGA_SBP_ENABLED
|
||||
and _service_configured(app, "platega_service")
|
||||
):
|
||||
methods.append({"id": method, "name": labels[method]})
|
||||
elif (
|
||||
method == "platega_crypto"
|
||||
and settings.PLATEGA_ENABLED
|
||||
and settings.PLATEGA_CRYPTO_ENABLED
|
||||
and _service_configured(app, "platega_service")
|
||||
):
|
||||
methods.append({"id": method, "name": labels[method]})
|
||||
elif (
|
||||
method == "yookassa"
|
||||
and settings.YOOKASSA_ENABLED
|
||||
and _service_configured(app, "yookassa_service")
|
||||
):
|
||||
methods.append({"id": method, "name": labels[method]})
|
||||
elif method == "stars" and settings.STARS_ENABLED:
|
||||
methods.append({"id": method, "name": labels[method]})
|
||||
elif (
|
||||
method == "cryptopay"
|
||||
and settings.CRYPTOPAY_ENABLED
|
||||
and _service_configured(app, "cryptopay_service")
|
||||
):
|
||||
methods.append({"id": method, "name": labels[method]})
|
||||
return methods
|
||||
|
||||
|
||||
def _service_configured(app: web.Application, key: str) -> bool:
|
||||
service = app.get(key)
|
||||
return bool(service and getattr(service, "configured", False))
|
||||
@@ -0,0 +1,362 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
import time
|
||||
from typing import Any, Dict, Optional
|
||||
from urllib.parse import parse_qsl
|
||||
|
||||
from config.settings import Settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 5 minutes clock skew tolerance for Telegram clients
|
||||
TELEGRAM_CLOCK_SKEW_SECONDS = 300
|
||||
TELEGRAM_OAUTH_ISSUER = "https://oauth.telegram.org"
|
||||
TELEGRAM_OAUTH_JWKS_URL = "https://oauth.telegram.org/.well-known/jwks.json"
|
||||
TELEGRAM_OAUTH_ALGORITHMS = ["RS256", "ES256", "EdDSA"]
|
||||
|
||||
|
||||
def _urlsafe_b64encode(raw: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
|
||||
|
||||
|
||||
def _urlsafe_b64decode(raw: str) -> bytes:
|
||||
padded = raw + ("=" * (-len(raw) % 4))
|
||||
return base64.urlsafe_b64decode(padded.encode("ascii"))
|
||||
|
||||
|
||||
def _session_secret(settings: Settings) -> bytes:
|
||||
return hmac.new(
|
||||
settings.WEBAPP_SESSION_SECRET.encode("utf-8"),
|
||||
b"remnawave-tg-shop-webapp-session",
|
||||
hashlib.sha256,
|
||||
).digest()
|
||||
|
||||
|
||||
def create_webapp_session_token(settings: Settings, user_id: int) -> str:
|
||||
now = int(time.time())
|
||||
payload = {
|
||||
"sub": int(user_id),
|
||||
"iat": now,
|
||||
"exp": now + max(60, int(settings.WEBAPP_SESSION_TTL_SECONDS)),
|
||||
}
|
||||
payload_part = _urlsafe_b64encode(json.dumps(payload, separators=(",", ":")).encode("utf-8"))
|
||||
signature = hmac.new(
|
||||
_session_secret(settings),
|
||||
payload_part.encode("ascii"),
|
||||
hashlib.sha256,
|
||||
).digest()
|
||||
return f"{payload_part}.{_urlsafe_b64encode(signature)}"
|
||||
|
||||
|
||||
def verify_webapp_session_token(settings: Settings, token: str) -> Optional[int]:
|
||||
if not token or "." not in token:
|
||||
return None
|
||||
|
||||
try:
|
||||
payload_part, signature_part = token.split(".", 1)
|
||||
expected_signature = hmac.new(
|
||||
_session_secret(settings),
|
||||
payload_part.encode("ascii"),
|
||||
hashlib.sha256,
|
||||
).digest()
|
||||
received_signature = _urlsafe_b64decode(signature_part)
|
||||
if not hmac.compare_digest(expected_signature, received_signature):
|
||||
return None
|
||||
|
||||
payload = json.loads(_urlsafe_b64decode(payload_part).decode("utf-8"))
|
||||
if int(payload.get("exp", 0)) < int(time.time()):
|
||||
return None
|
||||
return int(payload["sub"])
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to verify webapp session token: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def create_telegram_oauth_nonce(settings: Settings, *, ttl_seconds: int = 600) -> str:
|
||||
now = int(time.time())
|
||||
payload = {
|
||||
"n": secrets.token_urlsafe(24),
|
||||
"iat": now,
|
||||
"exp": now + max(60, int(ttl_seconds)),
|
||||
}
|
||||
payload_part = _urlsafe_b64encode(json.dumps(payload, separators=(",", ":")).encode("utf-8"))
|
||||
signature = hmac.new(
|
||||
_session_secret(settings),
|
||||
f"telegram-oauth-nonce.{payload_part}".encode("ascii"),
|
||||
hashlib.sha256,
|
||||
).digest()
|
||||
return f"{payload_part}.{_urlsafe_b64encode(signature)}"
|
||||
|
||||
|
||||
def verify_telegram_oauth_nonce(settings: Settings, nonce: str) -> bool:
|
||||
if not nonce or "." not in nonce:
|
||||
return False
|
||||
|
||||
try:
|
||||
payload_part, signature_part = nonce.split(".", 1)
|
||||
expected_signature = hmac.new(
|
||||
_session_secret(settings),
|
||||
f"telegram-oauth-nonce.{payload_part}".encode("ascii"),
|
||||
hashlib.sha256,
|
||||
).digest()
|
||||
received_signature = _urlsafe_b64decode(signature_part)
|
||||
if not hmac.compare_digest(expected_signature, received_signature):
|
||||
return False
|
||||
|
||||
payload = json.loads(_urlsafe_b64decode(payload_part).decode("utf-8"))
|
||||
now = int(time.time())
|
||||
if int(payload.get("exp", 0)) < now:
|
||||
return False
|
||||
if int(payload.get("iat", 0)) > now + TELEGRAM_CLOCK_SKEW_SECONDS:
|
||||
return False
|
||||
return bool(payload.get("n"))
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to verify Telegram OAuth nonce: %s", exc)
|
||||
return False
|
||||
|
||||
|
||||
def create_signed_telegram_oauth_state(
|
||||
settings: Settings,
|
||||
payload: Dict[str, Any],
|
||||
*,
|
||||
ttl_seconds: int = 600,
|
||||
) -> str:
|
||||
now = int(time.time())
|
||||
state_payload = {
|
||||
**payload,
|
||||
"iat": now,
|
||||
"exp": now + max(60, int(ttl_seconds)),
|
||||
}
|
||||
payload_part = _urlsafe_b64encode(
|
||||
json.dumps(state_payload, separators=(",", ":")).encode("utf-8")
|
||||
)
|
||||
signature = hmac.new(
|
||||
_session_secret(settings),
|
||||
f"telegram-oauth-state.{payload_part}".encode("ascii"),
|
||||
hashlib.sha256,
|
||||
).digest()
|
||||
return f"{payload_part}.{_urlsafe_b64encode(signature)}"
|
||||
|
||||
|
||||
def verify_signed_telegram_oauth_state(
|
||||
settings: Settings,
|
||||
state: str,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
if not state or "." not in state:
|
||||
return None
|
||||
|
||||
try:
|
||||
payload_part, signature_part = state.split(".", 1)
|
||||
expected_signature = hmac.new(
|
||||
_session_secret(settings),
|
||||
f"telegram-oauth-state.{payload_part}".encode("ascii"),
|
||||
hashlib.sha256,
|
||||
).digest()
|
||||
received_signature = _urlsafe_b64decode(signature_part)
|
||||
if not hmac.compare_digest(expected_signature, received_signature):
|
||||
return None
|
||||
|
||||
payload = json.loads(_urlsafe_b64decode(payload_part).decode("utf-8"))
|
||||
now = int(time.time())
|
||||
if int(payload.get("exp", 0)) < now:
|
||||
return None
|
||||
if int(payload.get("iat", 0)) > now + TELEGRAM_CLOCK_SKEW_SECONDS:
|
||||
return None
|
||||
return payload
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to verify Telegram OAuth state: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
async def validate_telegram_oauth_id_token(
|
||||
id_token: str,
|
||||
*,
|
||||
client_id: int,
|
||||
expected_nonce: str,
|
||||
max_age_seconds: int,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Validate Telegram OIDC ID token and return a Telegram-like user payload."""
|
||||
|
||||
if not id_token or not client_id or not expected_nonce:
|
||||
return None
|
||||
|
||||
try:
|
||||
import jwt
|
||||
from jwt import PyJWKClient
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"PyJWT is not installed; Telegram OAuth ID token validation is unavailable: %s",
|
||||
exc,
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
jwks_client = PyJWKClient(TELEGRAM_OAUTH_JWKS_URL)
|
||||
signing_key = await asyncio.to_thread(
|
||||
jwks_client.get_signing_key_from_jwt,
|
||||
id_token,
|
||||
)
|
||||
claims = await asyncio.to_thread(
|
||||
jwt.decode,
|
||||
id_token,
|
||||
signing_key.key,
|
||||
algorithms=TELEGRAM_OAUTH_ALGORITHMS,
|
||||
audience=str(client_id),
|
||||
issuer=TELEGRAM_OAUTH_ISSUER,
|
||||
leeway=TELEGRAM_CLOCK_SKEW_SECONDS,
|
||||
options={"require": ["exp", "iat", "iss", "aud"]},
|
||||
)
|
||||
|
||||
if not hmac.compare_digest(str(claims.get("nonce") or ""), expected_nonce):
|
||||
logger.warning("Telegram OAuth nonce mismatch.")
|
||||
return None
|
||||
|
||||
now = int(time.time())
|
||||
issued_at = int(claims.get("iat") or 0)
|
||||
max_age = max(60, int(max_age_seconds))
|
||||
if issued_at > now + TELEGRAM_CLOCK_SKEW_SECONDS or now - issued_at > max_age:
|
||||
logger.warning("Telegram OAuth ID token is stale.")
|
||||
return None
|
||||
|
||||
telegram_id_raw = claims.get("id")
|
||||
if not telegram_id_raw:
|
||||
return None
|
||||
telegram_id = int(telegram_id_raw)
|
||||
|
||||
full_name = str(claims.get("name") or "").strip()
|
||||
first_name = str(claims.get("given_name") or "").strip()
|
||||
last_name = str(claims.get("family_name") or "").strip()
|
||||
if full_name and not first_name:
|
||||
name_parts = full_name.split(None, 1)
|
||||
first_name = name_parts[0]
|
||||
if len(name_parts) > 1 and not last_name:
|
||||
last_name = name_parts[1]
|
||||
|
||||
return {
|
||||
"id": telegram_id,
|
||||
"username": claims.get("preferred_username") or claims.get("username"),
|
||||
"first_name": first_name or full_name or "Telegram",
|
||||
"last_name": last_name,
|
||||
"photo_url": claims.get("picture"),
|
||||
"language_code": claims.get("locale"),
|
||||
}
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to validate Telegram OAuth ID token: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def validate_telegram_webapp_init_data(
|
||||
init_data: str,
|
||||
bot_token: str,
|
||||
*,
|
||||
max_age_seconds: int,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Validate Telegram Mini App initData and return the trusted user payload."""
|
||||
|
||||
try:
|
||||
parsed_data = dict(parse_qsl(init_data or "", keep_blank_values=True))
|
||||
received_hash = parsed_data.pop("hash", None)
|
||||
if not received_hash:
|
||||
return None
|
||||
|
||||
data_check_string = "\n".join(
|
||||
f"{key}={value}" for key, value in sorted(parsed_data.items())
|
||||
)
|
||||
secret_key = hmac.new(
|
||||
b"WebAppData",
|
||||
bot_token.encode("utf-8"),
|
||||
hashlib.sha256,
|
||||
).digest()
|
||||
calculated_hash = hmac.new(
|
||||
secret_key,
|
||||
data_check_string.encode("utf-8"),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
if not hmac.compare_digest(calculated_hash, received_hash):
|
||||
logger.warning("Telegram WebApp initData hash mismatch.")
|
||||
return None
|
||||
|
||||
auth_date_raw = parsed_data.get("auth_date")
|
||||
if auth_date_raw:
|
||||
auth_date = int(auth_date_raw)
|
||||
now = int(time.time())
|
||||
max_age = max(60, int(max_age_seconds))
|
||||
if auth_date > now + TELEGRAM_CLOCK_SKEW_SECONDS or now - auth_date > max_age:
|
||||
logger.warning("Telegram WebApp initData auth_date is stale.")
|
||||
return None
|
||||
|
||||
user_json = parsed_data.get("user")
|
||||
if not user_json:
|
||||
return None
|
||||
user_data = json.loads(user_json)
|
||||
if not user_data.get("id"):
|
||||
return None
|
||||
if parsed_data.get("start_param"):
|
||||
user_data["start_param"] = parsed_data.get("start_param")
|
||||
return user_data
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to validate Telegram WebApp initData: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def validate_telegram_login_widget_data(
|
||||
auth_data: Any,
|
||||
bot_token: str,
|
||||
*,
|
||||
max_age_seconds: int,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Validate Telegram Login Widget data and return the trusted user payload."""
|
||||
|
||||
try:
|
||||
if isinstance(auth_data, str):
|
||||
parsed_data = dict(parse_qsl(auth_data or "", keep_blank_values=True))
|
||||
elif isinstance(auth_data, dict):
|
||||
parsed_data = {
|
||||
str(key): str(value) for key, value in auth_data.items() if value is not None
|
||||
}
|
||||
else:
|
||||
return None
|
||||
|
||||
received_hash = str(parsed_data.pop("hash", "") or "")
|
||||
if not received_hash:
|
||||
return None
|
||||
|
||||
data_check_string = "\n".join(
|
||||
f"{key}={value}" for key, value in sorted(parsed_data.items())
|
||||
)
|
||||
secret_key = hashlib.sha256(bot_token.encode("utf-8")).digest()
|
||||
calculated_hash = hmac.new(
|
||||
secret_key,
|
||||
data_check_string.encode("utf-8"),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
if not hmac.compare_digest(calculated_hash, received_hash):
|
||||
logger.warning("Telegram Login Widget hash mismatch.")
|
||||
return None
|
||||
|
||||
auth_date_raw = parsed_data.get("auth_date")
|
||||
if auth_date_raw:
|
||||
auth_date = int(auth_date_raw)
|
||||
now = int(time.time())
|
||||
max_age = max(60, int(max_age_seconds))
|
||||
if auth_date > now + TELEGRAM_CLOCK_SKEW_SECONDS or now - auth_date > max_age:
|
||||
logger.warning("Telegram Login Widget auth_date is stale.")
|
||||
return None
|
||||
|
||||
user_id_raw = parsed_data.get("id")
|
||||
if not user_id_raw:
|
||||
return None
|
||||
int(user_id_raw)
|
||||
|
||||
if not parsed_data.get("first_name"):
|
||||
return None
|
||||
|
||||
return parsed_data
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to validate Telegram Login Widget data: %s", exc)
|
||||
return None
|
||||
@@ -1,15 +1,14 @@
|
||||
from typing import List, Union
|
||||
|
||||
from aiogram.filters import Filter
|
||||
from aiogram.types import Message, CallbackQuery, User
|
||||
from aiogram.types import CallbackQuery, Message, User
|
||||
|
||||
|
||||
class AdminFilter(Filter):
|
||||
|
||||
def __init__(self, admin_ids: List[int]):
|
||||
self.admin_ids = admin_ids
|
||||
|
||||
async def __call__(self, event: Union[Message, CallbackQuery],
|
||||
event_from_user: User) -> bool:
|
||||
async def __call__(self, event: Union[Message, CallbackQuery], event_from_user: User) -> bool:
|
||||
if not event_from_user:
|
||||
return False
|
||||
if not self.admin_ids:
|
||||
@@ -1,21 +1,18 @@
|
||||
from aiogram import Router
|
||||
|
||||
from . import common
|
||||
from . import broadcast
|
||||
from . import promo_codes
|
||||
from . import user_management
|
||||
from . import statistics
|
||||
from . import sync_admin
|
||||
from . import logs_admin
|
||||
from . import ads, broadcast, common, logs_admin, payments, statistics, sync_admin, user_management
|
||||
from .promo import promo_router_aggregate
|
||||
|
||||
admin_router_aggregate = Router(name="admin_features_router")
|
||||
|
||||
admin_router_aggregate.include_router(common.router)
|
||||
admin_router_aggregate.include_router(broadcast.router)
|
||||
admin_router_aggregate.include_router(promo_codes.router)
|
||||
admin_router_aggregate.include_router(promo_router_aggregate)
|
||||
admin_router_aggregate.include_router(user_management.router)
|
||||
admin_router_aggregate.include_router(statistics.router)
|
||||
admin_router_aggregate.include_router(sync_admin.router)
|
||||
admin_router_aggregate.include_router(logs_admin.router)
|
||||
admin_router_aggregate.include_router(payments.router)
|
||||
admin_router_aggregate.include_router(ads.router)
|
||||
|
||||
__all__ = ("admin_router_aggregate", )
|
||||
__all__ = ("admin_router_aggregate",)
|
||||
@@ -0,0 +1,398 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import F, Router, types
|
||||
from aiogram.filters import StateFilter
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.states.admin_states import AdminStates
|
||||
from config.settings import Settings
|
||||
from db.dal import ad_dal
|
||||
|
||||
router = Router(name="admin_ads_router")
|
||||
|
||||
|
||||
PAGE_SIZE = 5
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_action:ads")
|
||||
async def show_ads_menu(
|
||||
callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
if not i18n or not callback.message:
|
||||
await callback.answer("Language error.", show_alert=True)
|
||||
return
|
||||
|
||||
totals = await ad_dal.get_totals(session)
|
||||
total_cost = totals.get("cost", 0.0)
|
||||
total_revenue = totals.get("revenue", 0.0)
|
||||
overview = _("admin_ads_overview", revenue=f"{total_revenue:.2f}", cost=f"{total_cost:.2f}")
|
||||
|
||||
total_count = await ad_dal.count_campaigns(session)
|
||||
if total_count == 0:
|
||||
text = overview + "\n\n" + _("admin_ads_empty")
|
||||
from bot.keyboards.inline.admin_keyboards import get_ads_menu_keyboard
|
||||
|
||||
reply_markup = get_ads_menu_keyboard(i18n, current_lang)
|
||||
else:
|
||||
current_page = 0
|
||||
total_pages = max(1, (total_count + PAGE_SIZE - 1) // PAGE_SIZE)
|
||||
campaigns = await ad_dal.list_campaigns_paged(
|
||||
session, page=current_page, page_size=PAGE_SIZE
|
||||
)
|
||||
text = overview + "\n\n" + _("admin_ads_header")
|
||||
from bot.keyboards.inline.admin_keyboards import get_ads_list_keyboard
|
||||
|
||||
reply_markup = get_ads_list_keyboard(
|
||||
i18n, current_lang, campaigns, current_page, total_pages
|
||||
)
|
||||
await callback.message.edit_text(text, reply_markup=reply_markup)
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("admin_ads:page:"))
|
||||
async def ads_list_pagination(
|
||||
callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
if not i18n or not callback.message:
|
||||
await callback.answer("Language error.", show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
page = int(callback.data.split(":")[2])
|
||||
except Exception:
|
||||
page = 0
|
||||
|
||||
totals = await ad_dal.get_totals(session)
|
||||
overview = _(
|
||||
"admin_ads_overview",
|
||||
revenue=f"{totals.get('revenue', 0.0):.2f}",
|
||||
cost=f"{totals.get('cost', 0.0):.2f}",
|
||||
)
|
||||
total_count = await ad_dal.count_campaigns(session)
|
||||
total_pages = max(1, (total_count + PAGE_SIZE - 1) // PAGE_SIZE)
|
||||
page = max(0, min(page, total_pages - 1))
|
||||
|
||||
campaigns = await ad_dal.list_campaigns_paged(session, page=page, page_size=PAGE_SIZE)
|
||||
text = overview + "\n\n" + _("admin_ads_header")
|
||||
from bot.keyboards.inline.admin_keyboards import get_ads_list_keyboard
|
||||
|
||||
reply_markup = get_ads_list_keyboard(i18n, current_lang, campaigns, page, total_pages)
|
||||
try:
|
||||
await callback.message.edit_text(text, reply_markup=reply_markup)
|
||||
await callback.answer()
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to paginate ads list: {e}")
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("admin_ads:card:"))
|
||||
async def show_ad_card(
|
||||
callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
if not i18n or not callback.message:
|
||||
await callback.answer("Language error.", show_alert=True)
|
||||
return
|
||||
|
||||
parts = callback.data.split(":")
|
||||
camp_id = int(parts[2])
|
||||
back_page = int(parts[3]) if len(parts) > 3 else 0
|
||||
|
||||
camp = await ad_dal.get_campaign_by_id(session, camp_id)
|
||||
if not camp:
|
||||
await callback.answer(_("admin_promo_not_found"), show_alert=True)
|
||||
return
|
||||
try:
|
||||
stats = await ad_dal.get_campaign_stats(session, camp_id)
|
||||
except Exception:
|
||||
stats = {"starts": 0, "trials": 0, "payers": 0, "revenue": 0.0}
|
||||
|
||||
text = _(
|
||||
"admin_ads_card",
|
||||
id=camp.ad_campaign_id,
|
||||
source=camp.source,
|
||||
start_param=camp.start_param,
|
||||
cost=f"{camp.cost:.2f}",
|
||||
active=_("csv_yes") if camp.is_active else _("csv_no"),
|
||||
starts=stats["starts"],
|
||||
trials=stats["trials"],
|
||||
payers=stats["payers"],
|
||||
revenue=f"{stats['revenue']:.2f}",
|
||||
)
|
||||
|
||||
from bot.keyboards.inline.admin_keyboards import get_ad_card_keyboard
|
||||
|
||||
reply_markup = get_ad_card_keyboard(i18n, current_lang, camp.ad_campaign_id, back_page)
|
||||
try:
|
||||
await callback.message.edit_text(text, reply_markup=reply_markup, parse_mode="HTML")
|
||||
await callback.answer()
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to show ad card: {e}")
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("admin_ads:delete:"))
|
||||
async def ads_delete_prompt(callback: types.CallbackQuery, settings: Settings, i18n_data: dict):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n or not callback.message:
|
||||
await callback.answer("Language error.", show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
_, _, camp_id_str, back_page_str = callback.data.split(":", 3)
|
||||
camp_id = int(camp_id_str)
|
||||
back_page = int(back_page_str)
|
||||
except Exception:
|
||||
await callback.answer(i18n.gettext(current_lang, "error_try_again"), show_alert=True)
|
||||
return
|
||||
|
||||
from bot.keyboards.inline.admin_keyboards import get_confirmation_keyboard
|
||||
|
||||
confirm_text = i18n.gettext(current_lang, "admin_ads_delete_confirm", id=camp_id)
|
||||
kb = get_confirmation_keyboard(
|
||||
yes_callback_data=f"admin_ads:delete_confirm:{camp_id}:{back_page}",
|
||||
no_callback_data=f"admin_ads:delete_cancel:{camp_id}:{back_page}",
|
||||
i18n_instance=i18n,
|
||||
lang=current_lang,
|
||||
)
|
||||
try:
|
||||
await callback.message.edit_text(confirm_text, reply_markup=kb)
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("admin_ads:delete_cancel:"))
|
||||
async def ads_delete_cancel(
|
||||
callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession
|
||||
):
|
||||
# Return to the ad card view
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
if not i18n or not callback.message:
|
||||
await callback.answer("Language error.", show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
parts = callback.data.split(":", 3)
|
||||
camp_id = int(parts[2])
|
||||
back_page = int(parts[3])
|
||||
except Exception:
|
||||
await callback.answer(_("error_try_again"), show_alert=True)
|
||||
return
|
||||
|
||||
camp = await ad_dal.get_campaign_by_id(session, camp_id)
|
||||
if not camp:
|
||||
await callback.answer(_("admin_ads_not_found"), show_alert=True)
|
||||
return
|
||||
try:
|
||||
stats = await ad_dal.get_campaign_stats(session, camp_id)
|
||||
except Exception:
|
||||
stats = {"starts": 0, "trials": 0, "payers": 0, "revenue": 0.0}
|
||||
text = _(
|
||||
"admin_ads_card",
|
||||
id=camp.ad_campaign_id,
|
||||
source=camp.source,
|
||||
start_param=camp.start_param,
|
||||
cost=f"{camp.cost:.2f}",
|
||||
active=_("csv_yes") if camp.is_active else _("csv_no"),
|
||||
starts=stats["starts"],
|
||||
trials=stats["trials"],
|
||||
payers=stats["payers"],
|
||||
revenue=f"{stats['revenue']:.2f}",
|
||||
)
|
||||
from bot.keyboards.inline.admin_keyboards import get_ad_card_keyboard
|
||||
|
||||
reply_markup = get_ad_card_keyboard(i18n, current_lang, camp.ad_campaign_id, back_page)
|
||||
try:
|
||||
await callback.message.edit_text(text, reply_markup=reply_markup, parse_mode="HTML")
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("admin_ads:delete_confirm:"))
|
||||
async def ads_delete_confirm(
|
||||
callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
if not i18n or not callback.message:
|
||||
await callback.answer("Language error.", show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
parts = callback.data.split(":", 3)
|
||||
camp_id = int(parts[2])
|
||||
back_page = int(parts[3])
|
||||
except Exception:
|
||||
await callback.answer(_("error_try_again"), show_alert=True)
|
||||
return
|
||||
|
||||
existed = await ad_dal.delete_campaign(session, camp_id)
|
||||
if not existed:
|
||||
await callback.answer(_("admin_ads_not_found"), show_alert=True)
|
||||
return
|
||||
await session.commit()
|
||||
|
||||
# After delete, show list page (may shift due to fewer items)
|
||||
totals = await ad_dal.get_totals(session)
|
||||
overview = _(
|
||||
"admin_ads_overview",
|
||||
revenue=f"{totals.get('revenue', 0.0):.2f}",
|
||||
cost=f"{totals.get('cost', 0.0):.2f}",
|
||||
)
|
||||
total_count = await ad_dal.count_campaigns(session)
|
||||
total_pages = max(1, (total_count + PAGE_SIZE - 1) // PAGE_SIZE)
|
||||
page = max(0, min(back_page, total_pages - 1))
|
||||
campaigns = await ad_dal.list_campaigns_paged(session, page=page, page_size=PAGE_SIZE)
|
||||
text = overview + "\n\n" + _("admin_ads_header")
|
||||
from bot.keyboards.inline.admin_keyboards import get_ads_list_keyboard
|
||||
|
||||
reply_markup = get_ads_list_keyboard(i18n, current_lang, campaigns, page, total_pages)
|
||||
try:
|
||||
await callback.message.edit_text(text, reply_markup=reply_markup)
|
||||
await callback.answer(_("admin_ads_deleted_success"), show_alert=True)
|
||||
except Exception:
|
||||
await callback.answer(_("admin_ads_deleted_success"), show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_action:ads_create")
|
||||
async def ads_create_start(
|
||||
callback: types.CallbackQuery, state: FSMContext, settings: Settings, i18n_data: dict
|
||||
):
|
||||
from bot.states.admin_states import AdminStates
|
||||
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
if not i18n or not callback.message:
|
||||
await callback.answer("Language error.", show_alert=True)
|
||||
return
|
||||
|
||||
await state.set_state(AdminStates.waiting_for_ad_source)
|
||||
await callback.message.edit_text(_("admin_ads_create_source_prompt"))
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@router.message(
|
||||
StateFilter(
|
||||
AdminStates.waiting_for_ad_source,
|
||||
AdminStates.waiting_for_ad_start_param,
|
||||
AdminStates.waiting_for_ad_cost,
|
||||
),
|
||||
F.text,
|
||||
)
|
||||
async def ads_create_flow(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
session: AsyncSession,
|
||||
):
|
||||
current_state = await state.get_state()
|
||||
if current_state not in (
|
||||
AdminStates.waiting_for_ad_source.state,
|
||||
AdminStates.waiting_for_ad_start_param.state,
|
||||
AdminStates.waiting_for_ad_cost.state,
|
||||
):
|
||||
return
|
||||
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
if current_state == AdminStates.waiting_for_ad_source.state:
|
||||
source = message.text.strip()
|
||||
if not source or len(source) > 64:
|
||||
await message.answer(_("admin_ads_invalid_source"))
|
||||
return
|
||||
await state.update_data(ad_source=source)
|
||||
await state.set_state(AdminStates.waiting_for_ad_start_param)
|
||||
await message.answer(_("admin_ads_create_start_param_prompt"))
|
||||
return
|
||||
|
||||
if current_state == AdminStates.waiting_for_ad_start_param.state:
|
||||
start_param = message.text.strip()
|
||||
# Allow alnum underscore dash only
|
||||
import re as _re
|
||||
|
||||
if not _re.match(r"^[A-Za-z0-9_\-]{2,64}$", start_param):
|
||||
await message.answer(_("admin_ads_invalid_start_param"))
|
||||
return
|
||||
await state.update_data(ad_start_param=start_param)
|
||||
await state.set_state(AdminStates.waiting_for_ad_cost)
|
||||
await message.answer(_("admin_ads_create_cost_prompt"))
|
||||
return
|
||||
|
||||
if current_state == AdminStates.waiting_for_ad_cost.state:
|
||||
text = message.text.replace(",", ".").strip()
|
||||
try:
|
||||
cost = float(text)
|
||||
if cost < 0 or cost > 1e8:
|
||||
raise ValueError()
|
||||
except Exception:
|
||||
await message.answer(_("admin_ads_invalid_cost"))
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
try:
|
||||
campaign = await ad_dal.create_campaign(
|
||||
session,
|
||||
source=data.get("ad_source", "unknown"),
|
||||
start_param=data.get("ad_start_param", "NA"),
|
||||
cost=cost,
|
||||
)
|
||||
await session.commit()
|
||||
except ValueError as ve:
|
||||
await session.rollback()
|
||||
if str(ve) == "ad_campaign_start_param_exists":
|
||||
await message.answer(_("admin_ads_start_param_exists"))
|
||||
else:
|
||||
await message.answer(_("error_occurred_try_again"))
|
||||
return
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
logging.error(f"Failed to create ad campaign: {e}", exc_info=True)
|
||||
await message.answer(_("error_occurred_try_again"))
|
||||
return
|
||||
|
||||
await state.clear()
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
await message.answer(
|
||||
_(
|
||||
"admin_ads_created_success",
|
||||
id=campaign.ad_campaign_id,
|
||||
source=campaign.source,
|
||||
start_param=campaign.start_param,
|
||||
cost=f"{campaign.cost:.2f}",
|
||||
)
|
||||
)
|
||||
# Offer back to ads menu
|
||||
from bot.keyboards.inline.admin_keyboards import get_ads_menu_keyboard
|
||||
|
||||
await message.answer(
|
||||
_("admin_ads_back_to_menu_hint"), reply_markup=get_ads_menu_keyboard(i18n, current_lang)
|
||||
)
|
||||
@@ -0,0 +1,402 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import Bot, F, Router, types
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot.keyboards.inline.admin_keyboards import (
|
||||
get_admin_panel_keyboard,
|
||||
get_back_to_admin_panel_keyboard,
|
||||
get_broadcast_confirmation_keyboard,
|
||||
)
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.states.admin_states import AdminStates
|
||||
from bot.utils import (
|
||||
MessageContent,
|
||||
get_message_content,
|
||||
send_message_by_type,
|
||||
send_message_via_queue,
|
||||
)
|
||||
from bot.utils.message_queue import get_queue_manager
|
||||
from config.settings import Settings
|
||||
from db.dal import message_log_dal, user_dal
|
||||
|
||||
router = Router(name="admin_broadcast_router")
|
||||
|
||||
|
||||
async def broadcast_message_prompt_handler(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
i18n_data: dict,
|
||||
settings: Settings,
|
||||
session: AsyncSession,
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n:
|
||||
logging.error("i18n missing in broadcast_message_prompt_handler")
|
||||
await callback.answer("Language service error.", show_alert=True)
|
||||
return
|
||||
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
prompt_text = _("admin_broadcast_enter_message")
|
||||
|
||||
if callback.message:
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
prompt_text,
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
|
||||
)
|
||||
except Exception as e:
|
||||
logging.warning(f"Could not edit message for broadcast prompt: {e}. Sending new.")
|
||||
await callback.message.answer(
|
||||
prompt_text,
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
|
||||
)
|
||||
await callback.answer()
|
||||
await state.set_state(AdminStates.waiting_for_broadcast_message)
|
||||
|
||||
|
||||
@router.message(AdminStates.waiting_for_broadcast_message)
|
||||
async def process_broadcast_message_handler(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
i18n_data: dict,
|
||||
settings: Settings,
|
||||
session: AsyncSession,
|
||||
bot: Bot,
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n:
|
||||
logging.error("i18n missing in process_broadcast_message_handler")
|
||||
await message.reply("Language service error.")
|
||||
return
|
||||
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
# Определяем тип содержимого и сохраняем данные в state
|
||||
entities = message.entities or message.caption_entities or []
|
||||
content = get_message_content(message)
|
||||
|
||||
# Если нет ни текста, ни медиа — ошибка
|
||||
if not content.text and not content.file_id:
|
||||
await message.answer(_("admin_broadcast_error_no_message"))
|
||||
return
|
||||
|
||||
# Сохраняем данные для рассылки
|
||||
await state.update_data(
|
||||
broadcast_text=content.text,
|
||||
broadcast_entities=entities,
|
||||
broadcast_content_type=content.content_type,
|
||||
broadcast_file_id=content.file_id,
|
||||
broadcast_target="all",
|
||||
)
|
||||
|
||||
# Отправляем превью-копию того, что будет разослано
|
||||
try:
|
||||
# Для медиа-сообщений используем caption_entities, для текста - entities
|
||||
if content.content_type == "text":
|
||||
await send_message_by_type(
|
||||
bot,
|
||||
chat_id=message.chat.id,
|
||||
content=content,
|
||||
parse_mode="HTML",
|
||||
entities=entities,
|
||||
disable_web_page_preview=True,
|
||||
disable_notification=True,
|
||||
)
|
||||
else:
|
||||
await send_message_by_type(
|
||||
bot,
|
||||
chat_id=message.chat.id,
|
||||
content=content,
|
||||
parse_mode="HTML",
|
||||
caption_entities=entities,
|
||||
disable_web_page_preview=True,
|
||||
disable_notification=True,
|
||||
)
|
||||
except TelegramBadRequest as e:
|
||||
await message.answer(
|
||||
_(
|
||||
"admin_broadcast_invalid_html",
|
||||
error=str(e),
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
# Показываем короткое подтверждение без дублирования текста — сообщение выше служит превью
|
||||
confirmation_prompt = _("admin_broadcast_confirm_prompt_short")
|
||||
|
||||
await message.answer(
|
||||
confirmation_prompt,
|
||||
reply_markup=get_broadcast_confirmation_keyboard(current_lang, i18n, target="all"),
|
||||
)
|
||||
await state.set_state(AdminStates.confirming_broadcast)
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
F.data.startswith("broadcast_target:"),
|
||||
AdminStates.confirming_broadcast,
|
||||
)
|
||||
async def change_broadcast_target_handler(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
i18n_data: dict,
|
||||
settings: Settings,
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n or not callback.message:
|
||||
await callback.answer("Error updating selection.", show_alert=True)
|
||||
return
|
||||
|
||||
new_target = callback.data.split(":")[1]
|
||||
if new_target not in {"all", "active", "inactive"}:
|
||||
await callback.answer("Unknown target.", show_alert=True)
|
||||
return
|
||||
|
||||
await state.update_data(broadcast_target=new_target)
|
||||
await state.get_data()
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
confirmation_prompt = _("admin_broadcast_confirm_prompt_short")
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
confirmation_prompt,
|
||||
reply_markup=get_broadcast_confirmation_keyboard(current_lang, i18n, target=new_target),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_action:main", AdminStates.waiting_for_broadcast_message)
|
||||
async def cancel_broadcast_at_prompt_stage(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
session: AsyncSession,
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n or not callback.message:
|
||||
await callback.answer("Error cancelling.", show_alert=True)
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(_("admin_broadcast_cancelled_nav_back"), reply_markup=None)
|
||||
except Exception:
|
||||
await callback.message.answer(_("admin_broadcast_cancelled_nav_back"))
|
||||
|
||||
await callback.answer(_("admin_broadcast_cancelled_alert"))
|
||||
await state.clear()
|
||||
|
||||
await callback.message.answer(
|
||||
_(key="admin_panel_title"),
|
||||
reply_markup=get_admin_panel_keyboard(i18n, current_lang, settings),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
F.data.startswith("broadcast_final_action:"),
|
||||
AdminStates.confirming_broadcast,
|
||||
)
|
||||
async def confirm_broadcast_callback_handler(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
i18n_data: dict,
|
||||
bot: Bot,
|
||||
settings: Settings,
|
||||
session: AsyncSession,
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n or not callback.message:
|
||||
await callback.answer("Error processing broadcast confirmation.", show_alert=True)
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
action = callback.data.split(":")[1]
|
||||
user_fsm_data = await state.get_data()
|
||||
|
||||
if action == "send":
|
||||
# Создаем объект контента из сохраненных данных
|
||||
content = MessageContent(
|
||||
content_type=user_fsm_data.get("broadcast_content_type", "text"),
|
||||
file_id=user_fsm_data.get("broadcast_file_id"),
|
||||
text=user_fsm_data.get("broadcast_text"),
|
||||
)
|
||||
entities = user_fsm_data.get("broadcast_entities", [])
|
||||
|
||||
if not content.text and content.content_type == "text":
|
||||
await callback.message.edit_text(_("admin_broadcast_error_no_message"))
|
||||
await state.clear()
|
||||
await callback.answer(_("admin_broadcast_error_no_message_alert"), show_alert=True)
|
||||
return
|
||||
|
||||
await callback.message.edit_text(_("admin_broadcast_sending_started"), reply_markup=None)
|
||||
await callback.answer()
|
||||
|
||||
target = user_fsm_data.get("broadcast_target", "all")
|
||||
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_count = 0
|
||||
failed_count = 0
|
||||
admin_user = callback.from_user
|
||||
logging.info(
|
||||
f"Admin {admin_user.id} broadcasting '{(content.text or '')[:50]}...' to {len(user_ids)} users." # noqa: E501
|
||||
)
|
||||
|
||||
# Get message queue manager
|
||||
queue_manager = get_queue_manager()
|
||||
if not queue_manager:
|
||||
await callback.message.edit_text(
|
||||
"❌ Ошибка: система очередей не инициализирована", reply_markup=None
|
||||
)
|
||||
return
|
||||
|
||||
# Queue all messages for sending
|
||||
for uid in user_ids:
|
||||
try:
|
||||
# Для медиа-сообщений используем caption_entities, для текста - entities
|
||||
if content.content_type == "text":
|
||||
await send_message_via_queue(
|
||||
queue_manager,
|
||||
uid,
|
||||
content,
|
||||
parse_mode="HTML",
|
||||
entities=entities,
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
else:
|
||||
await send_message_via_queue(
|
||||
queue_manager,
|
||||
uid,
|
||||
content,
|
||||
parse_mode="HTML",
|
||||
caption_entities=entities,
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
sent_count += 1
|
||||
|
||||
# Log successful queuing
|
||||
await message_log_dal.create_message_log(
|
||||
session,
|
||||
{
|
||||
"user_id": admin_user.id,
|
||||
"telegram_username": admin_user.username,
|
||||
"telegram_first_name": admin_user.first_name,
|
||||
"event_type": "admin_broadcast_queued",
|
||||
"content": f"To user {uid}: [{content.content_type}] {(content.text or '')[:70]}...", # noqa: E501
|
||||
"is_admin_event": True,
|
||||
"target_user_id": uid,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
failed_count += 1
|
||||
logging.warning(f"Failed to queue broadcast to {uid}: {type(e).__name__} – {e}")
|
||||
await message_log_dal.create_message_log(
|
||||
session,
|
||||
{
|
||||
"user_id": admin_user.id,
|
||||
"telegram_username": admin_user.username,
|
||||
"telegram_first_name": admin_user.first_name,
|
||||
"event_type": "admin_broadcast_failed",
|
||||
"content": f"For user {uid}: {type(e).__name__} – {str(e)[:70]}...",
|
||||
"is_admin_event": True,
|
||||
"target_user_id": uid,
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
await session.commit()
|
||||
except Exception as e_commit:
|
||||
await session.rollback()
|
||||
logging.error(f"Error committing broadcast logs: {e_commit}")
|
||||
|
||||
# Prepare queue stats presentation
|
||||
queue_stats = queue_manager.get_queue_stats()
|
||||
back_keyboard = get_back_to_admin_panel_keyboard(current_lang, i18n)
|
||||
initial_user_failed = queue_stats.get("user_failed_messages", 0)
|
||||
initial_group_failed = queue_stats.get("group_failed_messages", 0)
|
||||
|
||||
def build_queue_status(stats: dict) -> str:
|
||||
dynamic_failed = max(
|
||||
0, stats.get("user_failed_messages", 0) - initial_user_failed
|
||||
) + max(0, stats.get("group_failed_messages", 0) - initial_group_failed)
|
||||
total_failed = failed_count + dynamic_failed
|
||||
return _(
|
||||
"broadcast_queue_result",
|
||||
sent_count=sent_count,
|
||||
failed_count=total_failed,
|
||||
user_queue_size=stats["user_queue_size"],
|
||||
group_queue_size=stats["group_queue_size"],
|
||||
)
|
||||
|
||||
result_message = build_queue_status(queue_stats)
|
||||
|
||||
status_message = await callback.message.answer(
|
||||
result_message,
|
||||
reply_markup=back_keyboard,
|
||||
)
|
||||
|
||||
async def auto_update_queue_status() -> None:
|
||||
"""Refresh queue stats message twice per second via message edit."""
|
||||
last_text = result_message
|
||||
# Update for up to 2 minutes (240 iterations at 0.5s intervals)
|
||||
max_iterations = 240
|
||||
for _ in range(max_iterations):
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
stats = queue_manager.get_queue_stats()
|
||||
new_text = build_queue_status(stats)
|
||||
queues_drained = (
|
||||
stats["user_queue_size"] == 0
|
||||
and stats["group_queue_size"] == 0
|
||||
and not stats.get("user_queue_processing")
|
||||
and not stats.get("group_queue_processing")
|
||||
)
|
||||
|
||||
if new_text != last_text:
|
||||
try:
|
||||
await status_message.edit_text(
|
||||
new_text,
|
||||
reply_markup=back_keyboard,
|
||||
)
|
||||
last_text = new_text
|
||||
except TelegramBadRequest as e:
|
||||
if "message is not modified" in str(e):
|
||||
last_text = new_text
|
||||
else:
|
||||
logging.debug("Broadcast queue auto-update stopped: %s", e)
|
||||
break
|
||||
except Exception as e:
|
||||
logging.debug("Broadcast queue auto-update unexpected error: %s", e)
|
||||
break
|
||||
|
||||
if queues_drained:
|
||||
# Final refresh already attempted; exit loop.
|
||||
break
|
||||
else:
|
||||
logging.debug("Broadcast queue auto-update reached time limit.")
|
||||
|
||||
asyncio.create_task(auto_update_queue_status())
|
||||
|
||||
elif action == "cancel":
|
||||
await callback.message.edit_text(
|
||||
_("admin_broadcast_cancelled"),
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
await state.clear()
|
||||