Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c459cabaae | ||
|
|
0864413e11 | ||
|
|
ff2ed5b9f3 | ||
|
|
02cb2f0d27 | ||
|
|
ddb372a4e5 | ||
|
|
73474f70c8 | ||
|
|
6bb2709244 | ||
|
|
fd931581f5 | ||
|
|
217bed3c5d | ||
|
|
ab21253d4f | ||
|
|
1b2290ea66 | ||
|
|
ce6273a652 | ||
|
|
3170b966b5 | ||
|
|
1fca62de75 | ||
|
|
5c96fcd519 | ||
|
|
ca88c995b8 | ||
|
|
234fc69505 | ||
|
|
1362edde42 | ||
|
|
194bf64ebb | ||
|
|
a2ce29da45 | ||
|
|
0db3a68c09 | ||
|
|
c2f0ae0b8b | ||
|
|
23ad893f69 | ||
|
|
d2149357c6 | ||
|
|
63192659e4 | ||
|
|
3e58e01d53 | ||
|
|
8cbe7e01ec | ||
|
|
a99aeec0d4 | ||
|
|
2697c30c0f | ||
|
|
2077c27252 | ||
|
|
a86f5d75e1 | ||
|
|
24faabc20a | ||
|
|
00e1f51abe | ||
|
|
4b8f939a25 | ||
|
|
d1c4a6de80 | ||
|
|
bddcd16a07 | ||
|
|
a8e229d530 | ||
|
|
5cee619dd0 | ||
|
|
d5b23d8306 | ||
|
|
9de000e78c | ||
|
|
2b6f25f0f8 | ||
|
|
3c82f43c84 | ||
|
|
724e936660 | ||
|
|
d263651b48 | ||
|
|
3d5190639f | ||
|
|
9adcbf103a | ||
|
|
fcb8e51ec0 | ||
|
|
ae3d6a9b99 | ||
|
|
e0b3940b98 | ||
|
|
2ec7376981 | ||
|
|
6d0084dd3a | ||
|
|
e3f35a461c | ||
|
|
a75d2d7ac0 | ||
|
|
e5b0daf639 | ||
|
|
f07031f32c | ||
|
|
cc74ddec10 | ||
|
|
766f2a5780 | ||
|
|
1158a2d835 | ||
|
|
1d8ad0f24d | ||
|
|
6913676420 | ||
|
|
6890b58ced | ||
|
|
4cbd4dedf5 | ||
|
|
7700b294b7 | ||
|
|
4b2faa87bb | ||
|
|
1200e8ff70 | ||
|
|
728599c882 | ||
|
|
fd1edf38b5 | ||
|
|
2e4599d068 | ||
|
|
516d699cf3 | ||
|
|
6dc43182ab | ||
|
|
5e1fe59396 | ||
|
|
af1487731a | ||
|
|
77b124d61e | ||
|
|
090c88603e | ||
|
|
7bb0a94918 | ||
|
|
a456718193 | ||
|
|
358ef6ded7 | ||
|
|
ae7bfb9621 | ||
|
|
976c81c11c | ||
|
|
02e7bb48d6 | ||
|
|
81c9f6bfd3 | ||
|
|
bdf0622be5 | ||
|
|
cf34d8971d | ||
|
|
0afe4f0bc5 | ||
|
|
69985e44dd | ||
|
|
8d6f91b292 | ||
|
|
cd4b40af6b | ||
|
|
e108cc0baa | ||
|
|
5b37306061 | ||
|
|
5527bf0247 | ||
|
|
c46aaad7e3 | ||
|
|
c733d830f6 | ||
|
|
bd7361e9dc | ||
|
|
1ccda9ebd1 | ||
|
|
661d3f7953 | ||
|
|
7313f90350 | ||
|
|
d988812ecf | ||
|
|
1ee0b0a7cd | ||
|
|
2b4a8d6953 | ||
|
|
2ad6b14513 | ||
|
|
33707b7257 | ||
|
|
5f22c2081e | ||
|
|
fbb89793cb | ||
|
|
a06884d816 | ||
|
|
7b8feee99a | ||
|
|
0a5575c1e6 | ||
|
|
1055e58d0e | ||
|
|
d8fe88eb01 | ||
|
|
602cbc7ee6 | ||
|
|
bcb7372500 | ||
|
|
e52c75538f | ||
|
|
5d1fd3304b | ||
|
|
2d43033f98 | ||
|
|
101119911a | ||
|
|
58de153370 | ||
|
|
0a294b8bf8 | ||
|
|
1605be0dfa | ||
|
|
6f2bd2c443 | ||
|
|
36e0e4f462 | ||
|
|
e15d6559aa | ||
|
|
dc66cf6a06 | ||
|
|
70e6fa382b | ||
|
|
20f1228218 | ||
|
|
e4f523a79d | ||
|
|
8e5124e4cf | ||
|
|
4d86015d25 | ||
|
|
c4d2d0a2fe | ||
|
|
2c3f2c32bc | ||
|
|
21f9ed803d | ||
|
|
966a18045d | ||
|
|
4263cb7c99 | ||
|
|
1ee8af338c | ||
|
|
ee840a7e6d | ||
|
|
8f009c8caf | ||
|
|
4d7577f4ec | ||
|
|
e9e028fc00 | ||
|
|
9508eece5f | ||
|
|
2ccead9b49 | ||
|
|
0473829f7a | ||
|
|
162e9e20b5 | ||
|
|
3b40369606 | ||
|
|
d22ed238b4 | ||
|
|
5e47a1496b | ||
|
|
56796d9f22 | ||
|
|
619a13128c | ||
|
|
023ab375ec | ||
|
|
9393962510 | ||
|
|
2c32e81638 | ||
|
|
0032c1804b | ||
|
|
a98ea65ef4 | ||
|
|
5218ede0f1 | ||
|
|
5b19ba2c2f | ||
|
|
687fc03e8c | ||
|
|
74272039c5 | ||
|
|
eadb86faf2 | ||
|
|
4fa18a1262 | ||
|
|
7d3178bd48 | ||
|
|
88d99fb578 | ||
|
|
8366a73575 | ||
|
|
dcdddeb1c5 | ||
|
|
d7840d3a86 | ||
|
|
6a40d0c9ce | ||
|
|
3d579b12d4 | ||
|
|
0d932b0915 | ||
|
|
2b763efef9 | ||
|
|
a0ea2261f4 | ||
|
|
5bb1400917 | ||
|
|
63ec3a6152 | ||
|
|
3896c455b8 | ||
|
|
d97afbec18 | ||
|
|
21079f78dc | ||
|
|
3e922d8edb | ||
|
|
391487811b | ||
|
|
939bc37995 | ||
|
|
c2344824dc | ||
|
|
7cf1d577f3 | ||
|
|
ecf779763c | ||
|
|
1578a9da36 | ||
|
|
80e5f0c80d | ||
|
|
df8f2636d2 | ||
|
|
45543983c2 | ||
|
|
b531d4de3b | ||
|
|
eda5d3e633 | ||
|
|
e4ef52df8b | ||
|
|
3e1f1cb787 | ||
|
|
74da8ab98e | ||
|
|
19f7daff6c | ||
|
|
cf3af17243 | ||
|
|
93353db511 | ||
|
|
e7f93a5f47 | ||
|
|
c2ab881be2 | ||
|
|
4a84bba697 | ||
|
|
cda3b741a1 | ||
|
|
2e82febdcc | ||
|
|
bd7710d03c | ||
|
|
1a66d67e44 | ||
|
|
c1e2fe2c95 | ||
|
|
ea4ee4c4a7 | ||
|
|
fbc3e193bf | ||
|
|
3541f2f78b | ||
|
|
acc222da41 | ||
|
|
8c0e778388 | ||
|
|
c6c5352813 | ||
|
|
067d6fb59f | ||
|
|
c3e55bc853 | ||
|
|
09be82aad5 | ||
|
|
49781af921 | ||
|
|
604ba873dc | ||
|
|
923ff9b208 | ||
|
|
7e7a2e58a2 | ||
|
|
e27886e15d | ||
|
|
7fe8e676cd | ||
|
|
6803c7801f | ||
|
|
cab963dcdc | ||
|
|
001e54cfe2 | ||
|
|
5e257c0d3a | ||
|
|
19f0f27a3b | ||
|
|
6fbb8eebec | ||
|
|
32616c80ab | ||
|
|
3186be1e3e | ||
|
|
1329eb4fe2 | ||
|
|
913b2d428e | ||
|
|
5892a72575 | ||
|
|
d299a3c524 | ||
|
|
1c1ef06e29 | ||
|
|
bea62d75a9 | ||
|
|
e998b9ddd1 | ||
|
|
2f192447ce | ||
|
|
e89ed870dc | ||
|
|
6a722bfa17 | ||
|
|
a96aaa763f | ||
|
|
bbe05d7f57 | ||
|
|
da28b69461 | ||
|
|
cd469ae2bb | ||
|
|
9af49453f8 | ||
|
|
e543665704 | ||
|
|
39c0696e53 | ||
|
|
c6cc4ab963 | ||
|
|
4dfe71f392 | ||
|
|
cf05c6580d | ||
|
|
d544ebd879 | ||
|
|
7e0e8ff319 | ||
|
|
613a9860a0 | ||
|
|
92f0277dad | ||
|
|
749693078b | ||
|
|
b566a34725 | ||
|
|
3ab6c18a76 | ||
|
|
52458eb223 | ||
|
|
790946d89e | ||
|
|
30c5d9ae11 | ||
|
|
12e60629fe | ||
|
|
6c0d2932c3 | ||
|
|
ce19c7e2d1 | ||
|
|
93c9dde572 | ||
|
|
d46324cb6a | ||
|
|
f153ca5bf4 | ||
|
|
62e950f5c4 | ||
|
|
2b8bcd10b8 | ||
|
|
4c2ee19957 | ||
|
|
8217d13cd6 | ||
|
|
a2a887b898 | ||
|
|
1620de9a01 | ||
|
|
b6c6887842 | ||
|
|
ef4b493e65 | ||
|
|
50dd1951c4 | ||
|
|
b3894e53e4 | ||
|
|
ded044b4c0 | ||
|
|
bce78c4f28 | ||
|
|
75586d8883 | ||
|
|
f2fc335221 | ||
|
|
1c9e55d797 | ||
|
|
25056602d8 | ||
|
|
bd2e67059f | ||
|
|
fe34edfe73 | ||
|
|
4706be53ab | ||
|
|
4bd547f06a | ||
|
|
0250264fa0 | ||
|
|
3aede8fe95 | ||
|
|
e90988ea5c | ||
|
|
69d3400310 | ||
|
|
6a44da1f31 | ||
|
|
77c8785ec0 | ||
|
|
f2c0a9f6d5 | ||
|
|
f77a6ea46d | ||
|
|
a92ad32b23 | ||
|
|
a6af8f8415 | ||
|
|
da9db7b7f8 | ||
|
|
11048a6ed8 | ||
|
|
c3381bdd31 | ||
|
|
53f1cec401 | ||
|
|
bf46a15446 | ||
|
|
0df52d0235 | ||
|
|
0c167c8f09 | ||
|
|
804ccdabec | ||
|
|
5833b18052 | ||
|
|
f30e729f1f | ||
|
|
1e63431ae3 | ||
|
|
072e7273c4 | ||
|
|
df079138ee | ||
|
|
87f32114d7 | ||
|
|
b330e604f6 | ||
|
|
827d69231c | ||
|
|
0623850c3f | ||
|
|
4c77d129e7 | ||
|
|
c68cb97964 | ||
|
|
59fa301344 | ||
|
|
23784d00fe | ||
|
|
17cff74b7c | ||
|
|
2a3a3c21ba | ||
|
|
ba7811621e | ||
|
|
340afe1b80 | ||
|
|
11b7823188 | ||
|
|
de1763cfe1 | ||
|
|
d30b069876 | ||
|
|
0d68da9624 | ||
|
|
4d857b386b | ||
|
|
be7a3bc153 | ||
|
|
5c71fc0de2 | ||
|
|
82cc33587c | ||
|
|
121f3c6ddf | ||
|
|
3410eeaec1 | ||
|
|
85ecd644b8 | ||
|
|
7cffb4667f | ||
|
|
0449ded505 | ||
|
|
e15cbffdb1 | ||
|
|
9674c674a2 | ||
|
|
bafc5f4709 | ||
|
|
a60de46173 | ||
|
|
1165dc3fa6 | ||
|
|
f5ab18a67b | ||
|
|
65db4aaff6 | ||
|
|
d9a23235e5 | ||
|
|
00b54e1f15 | ||
|
|
3c4ff66150 | ||
|
|
2b3078fcce | ||
|
|
6f4074e8d6 | ||
|
|
235ee25d9f | ||
|
|
d1b9990bac | ||
|
|
60d1ba4efc | ||
|
|
29b6e6eb24 | ||
|
|
aa65972483 | ||
|
|
ad275a7c83 | ||
|
|
02b57ead00 | ||
|
|
7f484fa7b3 | ||
|
|
3ea906a74d | ||
|
|
939c40ccbf | ||
|
|
f0eb291f56 | ||
|
|
46391b10e2 | ||
|
|
5918a6cc71 | ||
|
|
0eeabc7b3a | ||
|
|
31eb5c06ad | ||
|
|
60d8c297f9 | ||
|
|
dab70d5a97 | ||
|
|
520a6289a8 | ||
|
|
7adca53116 | ||
|
|
fbc3e6c084 | ||
|
|
19ba5c8f11 | ||
|
|
ce0d4dccf7 | ||
|
|
7c874cd4aa | ||
|
|
17224b4f74 | ||
|
|
40414264be | ||
|
|
d9a4a007e2 | ||
|
|
d3925cad22 | ||
|
|
443e2e62db | ||
|
|
4b7c58a6c9 | ||
|
|
19aa2ec9c9 | ||
|
|
c4c5b8e3a0 | ||
|
|
33a7dbc0e6 | ||
|
|
72921b9a8f | ||
|
|
835436fa1a | ||
|
|
648f4ba4bc | ||
|
|
f5023dc46b | ||
|
|
4c0a798050 | ||
|
|
cfe7c4ec5f | ||
|
|
c82779e15b | ||
|
|
9e56715e77 | ||
|
|
2254b9ad19 | ||
|
|
81707d9c7c | ||
|
|
9e61a3d8a8 | ||
|
|
8a38524774 | ||
|
|
87d3f1b410 | ||
|
|
1094a65852 | ||
|
|
0ab4b11f97 | ||
|
|
6d4fb5888f | ||
|
|
e7301e2c48 | ||
|
|
f69f6546f0 | ||
|
|
7fe53993aa | ||
|
|
028f0680c6 | ||
|
|
b3f67a5398 | ||
|
|
db3611487e | ||
|
|
f2c722bdfc | ||
|
|
ccb125ab9d | ||
|
|
a300a4a9c0 | ||
|
|
044cb4de7e | ||
|
|
3405d12696 | ||
|
|
11cd35373e | ||
|
|
5001185bf8 | ||
|
|
a7f298743d | ||
|
|
8192eaf55b | ||
|
|
5a0e0033ec | ||
|
|
7521f89ffd | ||
|
|
d8a1da1f13 | ||
|
|
3b846f0d44 | ||
|
|
f5006af6c0 | ||
|
|
3152631911 | ||
|
|
1d9f069f45 | ||
|
|
707f569f62 | ||
|
|
e3643ee9a0 | ||
|
|
49cf5ebad8 | ||
|
|
b79f5f4d7d | ||
|
|
d870915dc0 | ||
|
|
fcd494f815 | ||
|
|
a7728f80d0 | ||
|
|
9eb6387973 | ||
|
|
7be5510208 | ||
|
|
e0269bbf86 | ||
|
|
e76558d68b | ||
|
|
cceba2e98e | ||
|
|
1e93d25a40 | ||
|
|
74ef9d31b4 | ||
|
|
15b3f9c084 | ||
|
|
87efa6c77b | ||
|
|
07b35036e7 | ||
|
|
c9c12b9ed6 | ||
|
|
960754c54e | ||
|
|
51c9c8b4f0 | ||
|
|
fff7e90e14 |
@@ -13,9 +13,16 @@ scratch/
|
||||
*.local.*
|
||||
node_modules/
|
||||
frontend/node_modules/
|
||||
docs-site/node_modules/
|
||||
docs-site/.astro/
|
||||
docs-site/dist/
|
||||
docs-site/public/demo/runtime/
|
||||
docs-site/src/content/docs/
|
||||
frontend-nginx-dist/
|
||||
deploy/compose/docker-compose-dev.yml
|
||||
data/*
|
||||
!data/tariffs.example.json
|
||||
!data/locales-overrides.example.json
|
||||
|
||||
|
||||
# CI
|
||||
@@ -30,9 +37,48 @@ deploy/compose/*.yml
|
||||
tmp/
|
||||
|
||||
# WebApp build artifacts (regenerated inside Docker)
|
||||
bot/app/web/templates/subscription_webapp.css
|
||||
bot/app/web/templates/subscription_webapp.js
|
||||
bot/app/web/templates/subscription_webapp.min.*.js
|
||||
bot/app/web/templates/subscription_webapp.*.css
|
||||
bot/app/web/templates/subscription_webapp.min.*.js.br
|
||||
bot/app/web/templates/subscription_webapp.min.*.js.gz
|
||||
bot/app/web/templates/subscription_webapp.*.css.br
|
||||
bot/app/web/templates/subscription_webapp.*.css.gz
|
||||
bot/app/web/templates/subscription_webapp_admin.css
|
||||
bot/app/web/templates/subscription_webapp_admin.js
|
||||
bot/app/web/templates/subscription_webapp_admin.min.*.js
|
||||
bot/app/web/templates/subscription_webapp_admin.*.css
|
||||
bot/app/web/templates/subscription_webapp_admin.min.*.js.br
|
||||
bot/app/web/templates/subscription_webapp_admin.min.*.js.gz
|
||||
bot/app/web/templates/subscription_webapp_admin.*.css.br
|
||||
bot/app/web/templates/subscription_webapp_admin.*.css.gz
|
||||
bot/app/web/templates/subscription_webapp_docs_demo.css
|
||||
bot/app/web/templates/subscription_webapp_docs_demo.js
|
||||
bot/app/web/templates/subscription_webapp_docs_demo.*.css
|
||||
bot/app/web/templates/subscription_webapp_docs_demo.*.css.br
|
||||
bot/app/web/templates/subscription_webapp_docs_demo.*.css.gz
|
||||
backend/bot/app/web/templates/subscription_webapp.css
|
||||
backend/bot/app/web/templates/subscription_webapp.js
|
||||
backend/bot/app/web/templates/subscription_webapp.min.*.js
|
||||
backend/bot/app/web/templates/subscription_webapp.*.css
|
||||
backend/bot/app/web/templates/subscription_webapp.min.*.js.br
|
||||
backend/bot/app/web/templates/subscription_webapp.min.*.js.gz
|
||||
backend/bot/app/web/templates/subscription_webapp.*.css.br
|
||||
backend/bot/app/web/templates/subscription_webapp.*.css.gz
|
||||
backend/bot/app/web/templates/subscription_webapp_admin.css
|
||||
backend/bot/app/web/templates/subscription_webapp_admin.js
|
||||
backend/bot/app/web/templates/subscription_webapp_admin.min.*.js
|
||||
backend/bot/app/web/templates/subscription_webapp_admin.*.css
|
||||
backend/bot/app/web/templates/subscription_webapp_admin.min.*.js.br
|
||||
backend/bot/app/web/templates/subscription_webapp_admin.min.*.js.gz
|
||||
backend/bot/app/web/templates/subscription_webapp_admin.*.css.br
|
||||
backend/bot/app/web/templates/subscription_webapp_admin.*.css.gz
|
||||
backend/bot/app/web/templates/subscription_webapp_docs_demo.css
|
||||
backend/bot/app/web/templates/subscription_webapp_docs_demo.js
|
||||
backend/bot/app/web/templates/subscription_webapp_docs_demo.*.css
|
||||
backend/bot/app/web/templates/subscription_webapp_docs_demo.*.css.br
|
||||
backend/bot/app/web/templates/subscription_webapp_docs_demo.*.css.gz
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
**/__pycache__/
|
||||
|
||||
@@ -1,235 +1,89 @@
|
||||
# Telegram Bot Token and Admin IDs
|
||||
BOT_TOKEN=your_bot_token_here # Telegram bot token
|
||||
ADMIN_IDS=comma_separated_admin_ids # Your telegram ID
|
||||
# Minimal bootstrap env.
|
||||
# Most product settings are configured later in Web App admin:
|
||||
# Admin -> System -> Settings, Admin -> System -> Tariffs, Admin -> Appearance.
|
||||
# Full reference: docs/env-vars.md
|
||||
|
||||
# PostgreSQL Database Connection Settings
|
||||
POSTGRES_USER= # Required: database user name
|
||||
POSTGRES_PASSWORD= # Required: database password
|
||||
POSTGRES_HOST=remnawave-minishop-db # Database container name
|
||||
POSTGRES_PORT=5432 # Port
|
||||
POSTGRES_DB=postgres # Database name
|
||||
DB_POOL_SIZE=20 # SQLAlchemy async pool size per backend/worker process
|
||||
DB_MAX_OVERFLOW=10 # Extra transient DB connections above pool size
|
||||
DB_POOL_TIMEOUT_SECONDS=30 # Seconds to wait for a DB pool connection
|
||||
DB_POOL_RECYCLE_SECONDS=1800 # Recycle DB connections to avoid stale sockets
|
||||
# Telegram bot token from @BotFather.
|
||||
# Example: 1234567890:AA...
|
||||
BOT_TOKEN=your_bot_token_here
|
||||
|
||||
REDIS_URL=redis://redis:6379/0 # Shared Redis for FSM, rate limits, cache, locks and queues
|
||||
REDIS_KEY_PREFIX=remnawave-tg-shop # Prefix for Redis keys
|
||||
WEBAPP_ME_CACHE_TTL_SECONDS=15 # Short TTL for /api/me payload cache
|
||||
WEBAPP_RATE_LIMIT_TTL_SECONDS=60 # Redis rate-limit window
|
||||
WEBAPP_RATE_LIMIT_MAX_REQUESTS=30 # Requests per window/action/user/IP
|
||||
WEBHOOK_QUEUE_NAME=webhook-events # Redis queue for heavy webhook processing
|
||||
WEBHOOK_QUEUE_CONCURRENCY=4 # Worker webhook consumers
|
||||
WORKER_PANEL_SYNC_INTERVAL_SECONDS=900 # Worker panel sync interval
|
||||
TARIFF_WORKER_LOCK_TTL_SECONDS=240 # Redis lock TTL for tariff tick
|
||||
TARIFF_WORKER_TICK_SECONDS=300 # Tariff worker tick interval
|
||||
# Telegram numeric user IDs allowed to open the admin panel.
|
||||
# Use commas for several admins, for example: 123456789,987654321
|
||||
ADMIN_IDS=123456789
|
||||
|
||||
# Localization and Display
|
||||
DEFAULT_LANGUAGE="ru" # or "en"
|
||||
DEFAULT_CURRENCY_SYMBOL="RUB" # e.g., RUB, USD, EUR
|
||||
|
||||
# External Links
|
||||
SUPPORT_LINK=https://t.me/your_support_link # Link to the support chat
|
||||
SERVER_STATUS_URL=https://status.yourdomain.tld/status/your_service # Link to the server status page
|
||||
TERMS_OF_SERVICE_URL=https://example.com/tos # Link to the terms of service
|
||||
PRIVACY_POLICY_URL=https://example.com/privacy # Link to the privacy policy
|
||||
USER_AGREEMENT_URL=https://example.com/user-agreement # Link to the user agreement
|
||||
SUBSCRIPTION_MINI_APP_URL= # Public URL of the subscription Mini App, e.g. https://app.yourdomain.tld/
|
||||
START_COMMAND_DESCRIPTION= # Description of the /start command
|
||||
DISABLE_WELCOME_MESSAGE= # Disable the welcome message
|
||||
MY_DEVICES_SECTION_ENABLED=False # Enable the My Devices section in the subscription menu
|
||||
USER_HWID_DEVICE_LIMIT=0 # Default HWID/device limit for panel users (0 = unlimited)
|
||||
|
||||
# Required channel subscription
|
||||
REQUIRED_CHANNEL_ID= # Telegram channel ID (e.g. -1001234567890) the user must join
|
||||
REQUIRED_CHANNEL_LINK=https://t.me/your_channel # Optional: public link/invite button text opens
|
||||
|
||||
# Webhook Base URL (used for Telegram and payment providers)
|
||||
# Public HTTPS base URL of the backend webhook server.
|
||||
# Telegram, payment providers and Remnawave call webhook endpoints under this domain.
|
||||
# This is usually the backend/API domain, not the Mini App frontend domain.
|
||||
# Example: https://bot.yourdomain.tld
|
||||
WEBHOOK_BASE_URL=https://webhooks.yourdomain.tld
|
||||
TRUSTED_PROXIES=127.0.0.1,::1 # Reverse proxies trusted for X-Forwarded-For
|
||||
|
||||
# Subscription Mini App (same container, separate port)
|
||||
WEBAPP_ENABLED=True # Run Mini App HTTP server
|
||||
WEBAPP_SERVER_HOST=0.0.0.0 # Internal listen host
|
||||
WEBAPP_SERVER_PORT=8081 # Internal/published Mini App port
|
||||
WEBAPP_TITLE="/minishop" # Mini App title
|
||||
WEBAPP_THEMES_DIR=data/themes # Folder with theme subfolders: <key>/theme.json and optional CSS/assets
|
||||
WEBAPP_DEFAULT_THEME= # Optional: override descriptor default theme key (e.g. light)
|
||||
WEBAPP_SESSION_SECRET= # Optional: HMAC secret for webapp sessions; generated if empty
|
||||
WEBHOOK_SECRET_TOKEN= # Optional: Telegram webhook secret token; generated if empty
|
||||
WEBAPP_SESSION_TTL_SECONDS=86400 # Web App session lifetime (24h)
|
||||
WEBAPP_AUTH_MAX_AGE_SECONDS=86400 # Max Telegram initData age
|
||||
WEBAPP_LOGIN_TOKEN_TTL_SECONDS=600 # External browser login link lifetime
|
||||
TELEGRAM_OAUTH_CLIENT_ID= # Telegram Web Login Client ID from BotFather; defaults to bot ID from BOT_TOKEN
|
||||
TELEGRAM_OAUTH_CLIENT_SECRET= # Optional Telegram Web Login Client Secret; reserved for full OIDC code flow
|
||||
TELEGRAM_OAUTH_REQUEST_ACCESS=write # Optional comma-separated permissions: write,phone; empty = OpenID profile only
|
||||
# PostgreSQL user created by Docker Compose and used by the backend.
|
||||
POSTGRES_USER=remnawave_minishop
|
||||
|
||||
# Email login and account linking via SMTP (Brevo SMTP relay defaults)
|
||||
SMTP_HOST=smtp-relay.brevo.com # SMTP server
|
||||
SMTP_PORT=587 # Brevo recommends 587 with STARTTLS
|
||||
SMTP_FALLBACK_PORTS=2525,465 # Tried after SMTP_PORT; 465 uses SSL automatically
|
||||
SMTP_TIMEOUT_SECONDS=30 # Per SMTP connection/send attempt timeout
|
||||
SMTP_USERNAME= # Brevo SMTP login
|
||||
SMTP_PASSWORD= # Brevo SMTP key/password
|
||||
SMTP_FROM_EMAIL= # Verified sender email
|
||||
SMTP_FROM_NAME= # Optional sender name
|
||||
SMTP_STARTTLS=True # Use STARTTLS on SMTP_PORT
|
||||
SMTP_USE_SSL=False # Use SSL wrapper, usually only for port 465
|
||||
EMAIL_CODE_TTL_SECONDS=600 # Email verification code lifetime
|
||||
EMAIL_CODE_RESEND_SECONDS=60 # Minimum delay between code sends
|
||||
EMAIL_CODE_MAX_ATTEMPTS=5 # Max attempts per code
|
||||
BRUTE_FORCE_MAX_FAILURES=5 # Max failed code attempts in the throttle window
|
||||
BRUTE_FORCE_WINDOW_SECONDS=900 # Rolling window used to count failures
|
||||
BRUTE_FORCE_LOCK_SECONDS=1800 # Temporary lockout duration after too many failures
|
||||
# PostgreSQL password. Change it before production deploy.
|
||||
POSTGRES_PASSWORD=change_me
|
||||
|
||||
# Payment Method Toggles
|
||||
YOOKASSA_ENABLED=True # Turn on YOOKASSA
|
||||
FREEKASSA_ENABLED=True # Turn on FreeKassa
|
||||
STARS_ENABLED=True # Turn on STARS
|
||||
CRYPTOPAY_ENABLED=True # Turn on CRYPTOPAY
|
||||
PLATEGA_ENABLED=False # Turn on PLATEGA
|
||||
SEVERPAY_ENABLED=False # Turn on SeverPay
|
||||
# Order of payment methods (top to bottom). Supported: severpay, freekassa, platega, yookassa, stars, cryptopay
|
||||
PAYMENT_METHODS_ORDER=severpay,yookassa,cryptopay,freekassa,platega,stars
|
||||
# PostgreSQL database name created by Docker Compose.
|
||||
POSTGRES_DB=remnawave_minishop
|
||||
|
||||
# YooKassa Payment Gateway Configuration
|
||||
YOOKASSA_SHOP_ID=your_shop_id # Your store ID in YooKassa
|
||||
YOOKASSA_SECRET_KEY=your_secret_key # Your secret key for YooKassa
|
||||
YOOKASSA_RETURN_URL=https://t.me/your_bot # URL to which the user will be returned after payment
|
||||
YOOKASSA_DEFAULT_RECEIPT_EMAIL=your_email@example.com # Default email for sending receipts
|
||||
YOOKASSA_VAT_CODE=1 # VAT code
|
||||
YOOKASSA_AUTOPAYMENTS_ENABLED=False # Auto-renew toggle
|
||||
YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING=True # Force automatic card binding when autopay is enabled (set to False to show the save-card checkbox)
|
||||
# Enables the Web App and the Web App admin panel.
|
||||
# Keep True for the first setup. If set to False, the admin UI is unavailable
|
||||
# until you change it back to True in .env and restart the app.
|
||||
WEBAPP_ENABLED=True
|
||||
|
||||
# Nalogo (self-employed receipts)
|
||||
NALOGO_INN=your_inn # INN for nalog.ru
|
||||
NALOGO_PASSWORD=your_nalogo_password # Password for nalog.ru
|
||||
NALOGO_RECEIPT_NAME_SUBSCRIPTION=subscription {months} months # Receipt name for time-based subscriptions ({months} = duration)
|
||||
NALOGO_RECEIPT_NAME_TRAFFIC=traffic package {gb} GB # Receipt name for traffic packages ({gb} = traffic amount)
|
||||
# Stable secret for Web App sessions.
|
||||
# Generate with: openssl rand -hex 32
|
||||
# If empty, sessions are invalidated on every restart.
|
||||
WEBAPP_SESSION_SECRET=
|
||||
|
||||
# FreeKassa Payment Gateway Configuration
|
||||
FREEKASSA_MERCHANT_ID=your_shop_id # Your shop ID in FreeKassa
|
||||
FREEKASSA_API_KEY=your_api_key # API key for REST requests
|
||||
FREEKASSA_SECOND_SECRET=your_second_secret # Secret word #2 (used to verify notifications)
|
||||
FREEKASSA_PAYMENT_IP= # Public IP address reported to FreeKassa
|
||||
FREEKASSA_PAYMENT_METHOD_ID=44 # Payment method ID, you can get it from https://merchant.freekassa.net/settings/currencies
|
||||
FREEKASSA_TRUSTED_IPS=168.119.157.136,168.119.60.227,178.154.197.79,51.250.54.238 # FreeKassa webhook source IP allowlist
|
||||
# Stable Telegram webhook secret_token.
|
||||
# Generate with: openssl rand -hex 32
|
||||
# If empty, a new token can be generated on process start.
|
||||
WEBHOOK_SECRET_TOKEN=
|
||||
|
||||
# CryptoBot Payment Gateway Configuration
|
||||
CRYPTOPAY_TOKEN= # API token for CryptoPay
|
||||
CRYPTOPAY_NETWORK=mainnet # Network (mainnet or testnet)
|
||||
CRYPTOPAY_CURRENCY_TYPE=fiat # Currency type (fiat or crypto)
|
||||
CRYPTOPAY_ASSET=RUB # Asset, e.g., RUB, BTC, USDT
|
||||
# Public HTTPS URL of the Mini App frontend, with trailing slash.
|
||||
# This URL is opened by Telegram buttons and BotFather Mini App settings.
|
||||
# Do not put /api or webhook paths here.
|
||||
# Example: https://app.yourdomain.tld/
|
||||
SUBSCRIPTION_MINI_APP_URL=https://app.yourdomain.tld/
|
||||
|
||||
# Platega Payment Gateway Configuration
|
||||
PLATEGA_BASE_URL=https://app.platega.io # Base API URL
|
||||
PLATEGA_MERCHANT_ID= # Your MerchantId from Platega
|
||||
PLATEGA_SECRET= # API secret from Platega
|
||||
PLATEGA_PAYMENT_METHOD=2 # Legacy method ID; fallback for the SBP button when PLATEGA_SBP_METHOD stays default
|
||||
PLATEGA_SBP_ENABLED=False # Show a separate "Pay via SBP" Platega button
|
||||
PLATEGA_CRYPTO_ENABLED=False # Show a separate "Pay with crypto" Platega button
|
||||
PLATEGA_SBP_METHOD=2 # Platega method ID for SBP QR (default 2)
|
||||
PLATEGA_CRYPTO_METHOD=13 # Platega method ID for crypto (default 13)
|
||||
PLATEGA_RETURN_URL= # Optional: redirect after successful payment (defaults to bot link)
|
||||
PLATEGA_FAILED_URL= # Optional: redirect after failed/cancelled payment (defaults to return URL)
|
||||
# Remnawave panel API URL. Usually the panel domain plus /api.
|
||||
# Example: https://panel.yourdomain.tld/api
|
||||
PANEL_API_URL=https://panel.yourdomain.tld/api
|
||||
|
||||
# SeverPay Payment Gateway Configuration
|
||||
SEVERPAY_BASE_URL=https://severpay.io/api/merchant # Base API URL
|
||||
SEVERPAY_MID= # Your MID from SeverPay
|
||||
SEVERPAY_TOKEN= # API token/secret for signing requests
|
||||
SEVERPAY_RETURN_URL= # Optional: redirect URL after payment (defaults to bot link)
|
||||
SEVERPAY_LIFETIME_MINUTES= # Optional: payment link lifetime in minutes (30-4320, leave empty for default)
|
||||
# Remnawave API key with permissions to manage users, subscriptions and squads.
|
||||
# Keep this secret. It can be overridden later in the admin panel if needed.
|
||||
PANEL_API_KEY=
|
||||
|
||||
# Subscription Options. Specify cost parameters or payment links here.
|
||||
1_MONTH_ENABLED=True
|
||||
RUB_PRICE_1_MONTH=150
|
||||
STARS_PRICE_1_MONTH=0
|
||||
# Shared secret for validating incoming Remnawave webhooks.
|
||||
# Create or set this secret in Remnawave Panel, then paste the same value here
|
||||
# or into Admin -> System -> Settings -> Remnawave Panel.
|
||||
# In Remnawave Panel, set WEBHOOK_URL to WEBHOOK_BASE_URL + /webhook/panel,
|
||||
# for example: https://app.example.com/webhook/panel
|
||||
PANEL_WEBHOOK_SECRET=
|
||||
|
||||
3_MONTHS_ENABLED=True
|
||||
RUB_PRICE_3_MONTHS=300
|
||||
STARS_PRICE_3_MONTHS=0
|
||||
|
||||
6_MONTHS_ENABLED=True
|
||||
RUB_PRICE_6_MONTHS=500
|
||||
STARS_PRICE_6_MONTHS=0
|
||||
|
||||
12_MONTHS_ENABLED=True
|
||||
RUB_PRICE_12_MONTHS=900
|
||||
STARS_PRICE_12_MONTHS=0
|
||||
|
||||
# Traffic Packages (enables traffic sale mode when set)
|
||||
TRAFFIC_PACKAGES=10:199,50:799 # Format: "<GB>:<price>", comma-separated
|
||||
STARS_TRAFFIC_PACKAGES=10:2500 # Optional: traffic packages priced in Stars
|
||||
TARIFFS_CONFIG_PATH=data/tariffs.json # Optional Tariffs 2.0 JSON config. If missing, legacy .env pricing is used.
|
||||
TARIFF_TRAFFIC_WARNING_LEVELS=85,90,95 # Tariffs 2.0 traffic warning levels, percent used
|
||||
|
||||
# Subscription Notifications
|
||||
SUBSCRIPTION_NOTIFICATIONS_ENABLED=True # Enable subscription
|
||||
SUBSCRIPTION_NOTIFY_ON_EXPIRE=True # Notify on subscription
|
||||
SUBSCRIPTION_NOTIFY_AFTER_EXPIRE=True # Notify after
|
||||
SUBSCRIPTION_NOTIFY_DAYS_BEFORE=3 # Days before expiration to notify
|
||||
|
||||
|
||||
REFERRAL_ONE_BONUS_PER_REFEREE=False # Give a bonus only once per referee
|
||||
REFERRAL_WELCOME_BONUS_DAYS=3 # Welcome bonus for newly registered user from referral link
|
||||
LEGACY_REFS=true # Allow ref_<tg_id> links. Leave unset/true unless you want to disable old links
|
||||
# Referral Bonus Days
|
||||
# Bonus for the inviting user
|
||||
REFERRAL_BONUS_DAYS_1_MONTH=3
|
||||
REFERRAL_BONUS_DAYS_3_MONTHS=7
|
||||
REFERRAL_BONUS_DAYS_6_MONTHS=15
|
||||
REFERRAL_BONUS_DAYS_12_MONTHS=30
|
||||
# Invited User Bonus
|
||||
REFEREE_BONUS_DAYS_1_MONTH=1
|
||||
REFEREE_BONUS_DAYS_3_MONTHS=3
|
||||
REFEREE_BONUS_DAYS_6_MONTHS=7
|
||||
REFEREE_BONUS_DAYS_12_MONTHS=15
|
||||
|
||||
# Panel API Configuration
|
||||
PANEL_API_URL=http://your_panel_api_url/api # URL of the panel API
|
||||
PANEL_API_KEY=your_panel_api_key # Panel API key
|
||||
PANEL_WEBHOOK_SECRET= # secret used to verify panel webhook signatures
|
||||
|
||||
# User traffic limits (applied for all users)
|
||||
# 0 means unlimited
|
||||
USER_TRAFFIC_LIMIT_GB=0 # Traffic limit for users (0 unlimited)
|
||||
USER_TRAFFIC_STRATEGY="NO_RESET" # Traffic reset strategy (NO_RESET, WEEK, MONTH)
|
||||
|
||||
# Default Internal Squads for Users (Optional, comma-separated UUIDs)
|
||||
USER_SQUAD_UUIDS=uuid1,uuid2,uuid3
|
||||
# Default External Squad for Users (Optional, single UUID)
|
||||
USER_EXTERNAL_SQUAD_UUID= # Optional: UUID from Remnawave External Squads to auto-link new panel users
|
||||
|
||||
# Trial Settings
|
||||
TRIAL_ENABLED=True # Enable the trial period
|
||||
TRIAL_DURATION_DAYS=5 # Duration of the trial period in days
|
||||
TRIAL_TRAFFIC_LIMIT_GB=0 # Traffic limit for the trial period (0 = unlimited)
|
||||
TRIAL_TRAFFIC_STRATEGY="NO_RESET" # Traffic reset strategy for the trial period (NO_RESET, WEEK, MONTH)
|
||||
|
||||
# Connection link handling (happ crypt4)
|
||||
CRYPT4_ENABLED=False # Enable happ crypt4 encryption for subscription URLs
|
||||
CRYPT4_REDIRECT_URL= # Base redirect to wrap the connect button, e.g. https://redir.example.com?url=
|
||||
|
||||
# Web Server Settings (for handling webhooks)
|
||||
WEB_SERVER_HOST="0.0.0.0"
|
||||
# Host port that publishes the backend webhook server from Docker Compose.
|
||||
# Your reverse proxy should route WEBHOOK_BASE_URL traffic to this port.
|
||||
WEB_SERVER_PORT=8080
|
||||
|
||||
# Admin Panel Log Pagination
|
||||
LOGS_PAGE_SIZE=10 # Number of events in the log
|
||||
LOG_LEVEL=INFO # Global log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
||||
# Host port that publishes the frontend nginx from Docker Compose.
|
||||
# Your reverse proxy should route SUBSCRIPTION_MINI_APP_URL traffic to this port.
|
||||
FRONTEND_PORT=8082
|
||||
|
||||
# Admin Logging Configuration
|
||||
LOG_CHAT_ID=-1001234567890 # Telegram chat/group ID for admin notifications
|
||||
LOG_THREAD_ID= # Optional: Thread ID for supergroup messages
|
||||
LOG_NEW_USERS=True # Log new user registrations
|
||||
LOG_PAYMENTS=True # Log payments
|
||||
LOG_PROMO_ACTIVATIONS=True # Log promo code activations
|
||||
LOG_TRIAL_ACTIVATIONS=True # Log trial activations
|
||||
LOG_SUSPICIOUS_ACTIVITY=True # Log suspicious activity
|
||||
LOG_ADMIN_ACTIONS=True # Log actions from users listed in ADMIN_IDS
|
||||
# Reverse proxy IPs/CIDRs trusted for X-Forwarded-For.
|
||||
# Keep loopback and private network ranges so payment provider IP allowlists
|
||||
# see the original webhook sender behind Docker/LAN/Kubernetes proxies.
|
||||
TRUSTED_PROXIES=127.0.0.1,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,fc00::/7
|
||||
|
||||
# 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
|
||||
# ─── Anonymous install telemetry (opt-out) ──────────────────────────────
|
||||
# Once a day the worker sends a single anonymous "heartbeat" so the project
|
||||
# maintainer can see how many installs are active and which versions/OSes are
|
||||
# used. It contains an opaque random install id and coarse facts only:
|
||||
# version, official/custom image provenance, OS/arch, Python version, language,
|
||||
# enabled payment providers and a user-count RANGE (e.g. "51-200"). No bot
|
||||
# token, domain, user data or any personal information is ever sent.
|
||||
# Full details: docs/configuration/telemetry.md
|
||||
#
|
||||
# Set to False to disable, or toggle it any time in Admin -> System ->
|
||||
# "Anonymous install analytics" (applies without a restart).
|
||||
TELEMETRY_ENABLED=True
|
||||
|
||||
@@ -1,2 +1,9 @@
|
||||
.gitattributes text eol=lf
|
||||
*.sh text eol=lf
|
||||
.github/workflows/*.yml text eol=lf
|
||||
deploy/docker/frontend/*.sh text eol=lf
|
||||
frontend/src/*.js text eol=lf
|
||||
frontend/src/**/*.js text eol=lf
|
||||
frontend/src/**/*.svelte text eol=lf
|
||||
frontend/scripts/*.mjs text eol=lf
|
||||
frontend/scripts/**/*.mjs text eol=lf
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
name: Docker build & push (reusable)
|
||||
|
||||
# Reusable workflow that builds the three image targets defined in
|
||||
# deploy/docker/Dockerfile (backend, worker, frontend) and optionally pushes
|
||||
# them to the selected registries under the repository owner's namespace.
|
||||
#
|
||||
# Called by:
|
||||
# - docker-dev.yml (tag_mode: dev, push: true) on pushes to dev
|
||||
# - docker-release.yml (tag_mode: release, push: true) on release tags
|
||||
# - ci.yml (tag_mode: dev, push: false) on pull requests
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
push:
|
||||
description: "Push the built images to the registries"
|
||||
type: boolean
|
||||
default: true
|
||||
tag_mode:
|
||||
description: "Tagging strategy: 'dev' or 'release'"
|
||||
type: string
|
||||
required: true
|
||||
publish_dockerhub:
|
||||
description: "Include Docker Hub tags and login when pushing"
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
# No permissions block here on purpose: a reusable workflow cannot request more
|
||||
# than its caller grants, so the token scope is set by each caller
|
||||
# (docker-dev.yml / docker-release.yml grant packages: write to push; ci.yml
|
||||
# only needs contents: read for a no-push build).
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- target: backend
|
||||
image: remnawave-minishop-backend
|
||||
- target: worker
|
||||
image: remnawave-minishop-worker
|
||||
- target: frontend
|
||||
image: remnawave-minishop-frontend
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# Full history + tags: the Dockerfile's version-builder stage runs
|
||||
# `git describe --tags` against the copied .git tree.
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Resolve release version
|
||||
id: version
|
||||
if: inputs.tag_mode == 'release'
|
||||
run: |
|
||||
# On a tag push github.ref_name is the tag (e.g. v3.4.5); for a
|
||||
# manual workflow_dispatch on a branch, fall back to the latest tag.
|
||||
if [ "${{ github.ref_type }}" = "tag" ]; then
|
||||
raw="${{ github.ref_name }}"
|
||||
else
|
||||
raw="$(git describe --tags --abbrev=0 2>/dev/null)"
|
||||
fi
|
||||
version="${raw#v}"
|
||||
if [ -z "$version" ]; then
|
||||
echo "::error::No git tag found to derive the release version from"
|
||||
exit 1
|
||||
fi
|
||||
echo "version=${version}" >> "$GITHUB_OUTPUT"
|
||||
echo "Release version: ${version}"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
if: inputs.push
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: inputs.push && inputs.publish_dockerhub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Resolve image namespaces
|
||||
id: image_namespaces
|
||||
env:
|
||||
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
run: |
|
||||
github_owner="${{ github.repository_owner }}"
|
||||
echo "github_owner=${github_owner,,}" >> "$GITHUB_OUTPUT"
|
||||
dockerhub_owner="${github_owner,,}"
|
||||
if [ "${{ inputs.push }}" = "true" ] && [ "${{ inputs.publish_dockerhub }}" = "true" ]; then
|
||||
if [ -z "$DOCKERHUB_USERNAME" ]; then
|
||||
echo "::error::DOCKERHUB_USERNAME secret is required for Docker Hub publishing"
|
||||
exit 1
|
||||
fi
|
||||
dockerhub_owner="${DOCKERHUB_USERNAME,,}"
|
||||
fi
|
||||
echo "dockerhub_owner=$dockerhub_owner" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
name=${{ steps.image_namespaces.outputs.dockerhub_owner }}/${{ matrix.image }},enable=${{ inputs.push && inputs.publish_dockerhub }}
|
||||
name=ghcr.io/${{ steps.image_namespaces.outputs.github_owner }}/${{ matrix.image }},enable=true
|
||||
tags: |
|
||||
type=raw,value=dev,enable=${{ inputs.tag_mode == 'dev' }}
|
||||
type=raw,value=latest,enable=${{ inputs.tag_mode == 'release' }}
|
||||
type=raw,value=${{ steps.version.outputs.version }},enable=${{ inputs.tag_mode == 'release' }}
|
||||
|
||||
- name: Build${{ inputs.push && ' & push' || '' }} ${{ matrix.image }}
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: deploy/docker/Dockerfile
|
||||
target: ${{ matrix.target }}
|
||||
platforms: linux/amd64
|
||||
push: ${{ inputs.push }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
# The Dockerfile's version-builder appends a "-<branch>" suffix to the
|
||||
# internal version string for non-main builds. Force "main" on release
|
||||
# (the ref is the tag, not a branch) so release images stay un-suffixed.
|
||||
build-args: |
|
||||
GITHUB_REF_NAME=${{ inputs.tag_mode == 'release' && 'main' || github.ref_name }}
|
||||
REMNAWAVE_MINISHOP_BUILD_PROVENANCE=${{ github.repository == '3252a8/remnawave-minishop' && 'official' || 'custom' }}
|
||||
cache-from: type=gha,scope=${{ matrix.target }}
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.target }}
|
||||
provenance: false
|
||||
@@ -0,0 +1,88 @@
|
||||
name: PR checks
|
||||
|
||||
# Runs on pull requests into main (typically from dev) and into dev (typically
|
||||
# from feature/* branches): lint + format checks, a demo settings-manifest
|
||||
# drift guard, and a no-push image build to prove the Docker images still build.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Lint & format
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install ruff
|
||||
run: pip install "ruff>=0.8.0"
|
||||
|
||||
- name: Ruff lint (Python)
|
||||
run: ruff check .
|
||||
|
||||
- name: Ruff format check (Python)
|
||||
run: ruff format --check .
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install frontend deps
|
||||
run: npm ci
|
||||
working-directory: frontend
|
||||
|
||||
- name: ESLint (frontend)
|
||||
run: npm run lint
|
||||
working-directory: frontend
|
||||
|
||||
- name: Prettier check (frontend)
|
||||
run: npm run format:check
|
||||
working-directory: frontend
|
||||
|
||||
demo-manifest:
|
||||
name: Demo settings manifest in sync
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: pip
|
||||
cache-dependency-path: backend/requirements.txt
|
||||
|
||||
- name: Install backend deps + pytest
|
||||
run: pip install -r backend/requirements.txt pytest
|
||||
|
||||
# Fails if admin_settings_manifest.py changed without regenerating the
|
||||
# demo snapshot. Fix: `python scripts/export_settings_manifest.py` then
|
||||
# `npx --prefix frontend prettier --write \
|
||||
# src/lib/webapp/settingsManifest.generated.json`, and commit the result.
|
||||
- name: Check demo settings manifest is in sync
|
||||
run: python -m pytest tests/test_settings_manifest_demo_sync.py -q
|
||||
|
||||
build:
|
||||
name: Docker build
|
||||
uses: ./.github/workflows/_docker-build-push.yml
|
||||
with:
|
||||
push: false
|
||||
tag_mode: dev
|
||||
@@ -0,0 +1,27 @@
|
||||
name: Dependency review
|
||||
|
||||
# On PRs into main/dev, flag any newly added dependency that has a known
|
||||
# vulnerability or an incompatible license before it gets merged.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
dependency-review:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Dependency review
|
||||
uses: actions/dependency-review-action@v4
|
||||
with:
|
||||
fail-on-severity: high
|
||||
comment-summary-in-pr: on-failure
|
||||
@@ -0,0 +1,26 @@
|
||||
name: Dev images
|
||||
|
||||
# On every push to the dev branch, build all three images and push them to
|
||||
# GHCR tagged `dev`. Docker Hub dev images are published by GitLab CI.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: docker-dev-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
build-push:
|
||||
uses: ./.github/workflows/_docker-build-push.yml
|
||||
with:
|
||||
push: true
|
||||
tag_mode: dev
|
||||
publish_dockerhub: false
|
||||
secrets: inherit
|
||||
@@ -0,0 +1,28 @@
|
||||
name: Release images
|
||||
|
||||
# Build all three images and push them to ghcr.io and Docker Hub tagged
|
||||
# `latest` and the release version (the pushed tag with its leading `v`
|
||||
# stripped, e.g. v3.4.5 -> 3.4.5). Triggered only when a new v* tag is pushed,
|
||||
# so images are built once per release rather than on every commit to main.
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: docker-release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
build-push:
|
||||
uses: ./.github/workflows/_docker-build-push.yml
|
||||
with:
|
||||
push: true
|
||||
tag_mode: release
|
||||
secrets: inherit
|
||||
@@ -0,0 +1,96 @@
|
||||
name: Security
|
||||
|
||||
# Audits the full dependency set (pip-audit, npm audit) and runs a Trivy
|
||||
# filesystem scan (dependencies + Dockerfile/IaC misconfig). Trivy results are
|
||||
# uploaded to the Security -> Code scanning tab.
|
||||
#
|
||||
# pip-audit / npm audit are informational (continue-on-error): they surface
|
||||
# upstream/transitive advisories that aren't necessarily fixable in a given PR,
|
||||
# so they report in the logs without blocking merges. The PR gate for newly
|
||||
# introduced vulnerable deps is dependency-review.yml.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
push:
|
||||
branches: [main, dev]
|
||||
schedule:
|
||||
- cron: "27 4 * * 1" # weekly, Monday 04:27 UTC
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: security-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
python-audit:
|
||||
name: pip-audit
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install pip-audit
|
||||
run: pip install pip-audit
|
||||
|
||||
- name: Audit Python dependencies
|
||||
continue-on-error: true
|
||||
run: pip-audit -r backend/requirements.txt
|
||||
|
||||
npm-audit:
|
||||
name: npm audit
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install frontend deps
|
||||
run: npm ci
|
||||
working-directory: frontend
|
||||
|
||||
- name: Audit npm dependencies
|
||||
continue-on-error: true
|
||||
run: npm audit --audit-level=high
|
||||
working-directory: frontend
|
||||
|
||||
trivy:
|
||||
name: Trivy filesystem scan
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Run Trivy
|
||||
uses: aquasecurity/trivy-action@v0.36.0
|
||||
with:
|
||||
scan-type: fs
|
||||
scan-ref: .
|
||||
format: sarif
|
||||
output: trivy-results.sarif
|
||||
severity: CRITICAL,HIGH
|
||||
ignore-unfixed: true
|
||||
|
||||
- name: Upload Trivy results
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
if: always()
|
||||
with:
|
||||
sarif_file: trivy-results.sarif
|
||||
category: trivy-fs
|
||||
@@ -7,17 +7,61 @@ bot_database.sqlite3
|
||||
!.env.example
|
||||
docker-compose-dev.yml
|
||||
scratch_*.py
|
||||
scratch/
|
||||
*.local.*
|
||||
node_modules/
|
||||
.git/
|
||||
|
||||
# Documentation site build artifacts
|
||||
docs-site/.astro/
|
||||
docs-site/dist/
|
||||
docs-site/public/demo/runtime/
|
||||
docs-site/src/content/docs/
|
||||
frontend-nginx-dist/
|
||||
|
||||
# WebApp build artifacts (regenerated by `npm run build:webapp` / Docker build)
|
||||
bot/app/web/templates/subscription_webapp.css
|
||||
bot/app/web/templates/subscription_webapp.js
|
||||
bot/app/web/templates/subscription_webapp.min.*.js
|
||||
bot/app/web/templates/subscription_webapp.*.css
|
||||
bot/app/web/templates/subscription_webapp.min.*.js.br
|
||||
bot/app/web/templates/subscription_webapp.min.*.js.gz
|
||||
bot/app/web/templates/subscription_webapp.*.css.br
|
||||
bot/app/web/templates/subscription_webapp.*.css.gz
|
||||
bot/app/web/templates/subscription_webapp_admin.css
|
||||
bot/app/web/templates/subscription_webapp_admin.js
|
||||
bot/app/web/templates/subscription_webapp_admin.min.*.js
|
||||
bot/app/web/templates/subscription_webapp_admin.*.css
|
||||
bot/app/web/templates/subscription_webapp_admin.min.*.js.br
|
||||
bot/app/web/templates/subscription_webapp_admin.min.*.js.gz
|
||||
bot/app/web/templates/subscription_webapp_admin.*.css.br
|
||||
bot/app/web/templates/subscription_webapp_admin.*.css.gz
|
||||
bot/app/web/templates/subscription_webapp_docs_demo.css
|
||||
bot/app/web/templates/subscription_webapp_docs_demo.js
|
||||
bot/app/web/templates/subscription_webapp_docs_demo.*.css
|
||||
bot/app/web/templates/subscription_webapp_docs_demo.*.css.br
|
||||
bot/app/web/templates/subscription_webapp_docs_demo.*.css.gz
|
||||
backend/bot/app/web/templates/subscription_webapp.css
|
||||
backend/bot/app/web/templates/subscription_webapp.js
|
||||
backend/bot/app/web/templates/subscription_webapp.min.*.js
|
||||
backend/bot/app/web/templates/subscription_webapp.*.css
|
||||
backend/bot/app/web/templates/subscription_webapp.min.*.js.br
|
||||
backend/bot/app/web/templates/subscription_webapp.min.*.js.gz
|
||||
backend/bot/app/web/templates/subscription_webapp.*.css.br
|
||||
backend/bot/app/web/templates/subscription_webapp.*.css.gz
|
||||
backend/bot/app/web/templates/subscription_webapp_admin.css
|
||||
backend/bot/app/web/templates/subscription_webapp_admin.js
|
||||
backend/bot/app/web/templates/subscription_webapp_admin.min.*.js
|
||||
backend/bot/app/web/templates/subscription_webapp_admin.*.css
|
||||
backend/bot/app/web/templates/subscription_webapp_admin.min.*.js.br
|
||||
backend/bot/app/web/templates/subscription_webapp_admin.min.*.js.gz
|
||||
backend/bot/app/web/templates/subscription_webapp_admin.*.css.br
|
||||
backend/bot/app/web/templates/subscription_webapp_admin.*.css.gz
|
||||
backend/bot/app/web/templates/subscription_webapp_docs_demo.css
|
||||
backend/bot/app/web/templates/subscription_webapp_docs_demo.js
|
||||
backend/bot/app/web/templates/subscription_webapp_docs_demo.*.css
|
||||
backend/bot/app/web/templates/subscription_webapp_docs_demo.*.css.br
|
||||
backend/bot/app/web/templates/subscription_webapp_docs_demo.*.css.gz
|
||||
tmp
|
||||
.claude
|
||||
|
||||
@@ -36,3 +80,4 @@ locales/en_backup.json
|
||||
db/models_old.py
|
||||
data/*
|
||||
!data/tariffs.example.json
|
||||
!data/locales-overrides.example.json
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
stages:
|
||||
- docker
|
||||
|
||||
workflow:
|
||||
rules:
|
||||
- if: '$CI_COMMIT_BRANCH == "dev"'
|
||||
- when: never
|
||||
|
||||
variables:
|
||||
DOCKER_BUILDKIT: "1"
|
||||
DOCKER_DRIVER: overlay2
|
||||
DOCKER_HOST: tcp://docker:2375
|
||||
DOCKER_TLS_CERTDIR: ""
|
||||
GIT_DEPTH: "0"
|
||||
|
||||
docker-dev:
|
||||
stage: docker
|
||||
image: docker:27.5.1
|
||||
services:
|
||||
- name: docker:27.5.1-dind
|
||||
alias: docker
|
||||
interruptible: true
|
||||
parallel:
|
||||
matrix:
|
||||
- TARGET: backend
|
||||
IMAGE: remnawave-minishop-backend
|
||||
- TARGET: worker
|
||||
IMAGE: remnawave-minishop-worker
|
||||
- TARGET: frontend
|
||||
IMAGE: remnawave-minishop-frontend
|
||||
before_script:
|
||||
- apk add --no-cache git
|
||||
- test -n "$DOCKERHUB_USERNAME"
|
||||
- test -n "$DOCKERHUB_TOKEN"
|
||||
- echo "$DOCKERHUB_TOKEN" | docker login --username "$DOCKERHUB_USERNAME" --password-stdin
|
||||
- docker buildx create --name gitlab-builder --driver docker-container --use
|
||||
- docker buildx inspect --bootstrap
|
||||
script:
|
||||
- git fetch origin dev --tags
|
||||
- |
|
||||
if [ "$(git rev-parse HEAD)" != "$(git rev-parse origin/dev)" ]; then
|
||||
echo "A newer dev commit exists; skipping Docker Hub build for $IMAGE."
|
||||
exit 0
|
||||
fi
|
||||
- |
|
||||
provenance="${REMNAWAVE_MINISHOP_BUILD_PROVENANCE:-}"
|
||||
if [ -z "$provenance" ]; then
|
||||
dockerhub_owner="$(printf '%s' "${DOCKERHUB_USERNAME:-}" | tr '[:upper:]' '[:lower:]')"
|
||||
if [ "${CI_PROJECT_PATH:-}" = "3252a8/remnawave-minishop" ] || [ "$dockerhub_owner" = "3252a8" ]; then
|
||||
provenance="official"
|
||||
else
|
||||
provenance="custom"
|
||||
fi
|
||||
fi
|
||||
docker buildx build \
|
||||
--load \
|
||||
--platform linux/amd64 \
|
||||
--file deploy/docker/Dockerfile \
|
||||
--target "$TARGET" \
|
||||
--build-arg "CI_COMMIT_REF_NAME=$CI_COMMIT_REF_NAME" \
|
||||
--build-arg "REMNAWAVE_MINISHOP_BUILD_PROVENANCE=$provenance" \
|
||||
--build-arg "BUILDKIT_INLINE_CACHE=1" \
|
||||
--cache-from "type=registry,ref=$DOCKERHUB_USERNAME/$IMAGE:dev" \
|
||||
--tag "$DOCKERHUB_USERNAME/$IMAGE:dev" \
|
||||
.
|
||||
- git fetch origin dev --tags
|
||||
- |
|
||||
if [ "$(git rev-parse HEAD)" != "$(git rev-parse origin/dev)" ]; then
|
||||
echo "A newer dev commit exists; skipping Docker Hub push for $IMAGE."
|
||||
exit 0
|
||||
fi
|
||||
- docker push "$DOCKERHUB_USERNAME/$IMAGE:dev"
|
||||
@@ -1,7 +1,21 @@
|
||||
Copyright 2025 machka-pasla
|
||||
MIT License
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
Copyright (c) 2025-2026 machka-pasla, 3252a8 and other contributors
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||

|
||||
|
||||
Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи и управления подписками Remnawave. Бот обрабатывает регистрацию, оплату, продление, пробный период, промокоды, рефералов и поддержку в чате. Web App показывает ссылку подключения, срок действия, трафик, оплату, устройства и вход по Telegram Mini Apps `initData`, Telegram OAuth / OpenID Connect и одноразовому email-коду.
|
||||
Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи и управления подписками панели [Remnawave](https://docs.rw/). Бот обрабатывает регистрацию, оплату, продление, пробный период, промокоды, рефералов и поддержку в чате. Web App показывает ссылку подключения, срок действия, трафик, оплату, устройства и вход по Telegram Mini Apps `initData`, Telegram OAuth / OpenID Connect и одноразовому email-коду.
|
||||
|
||||
Проект является переработанным форком [kavore/remnawave-tg-shop](https://github.com/kavore/remnawave-tg-shop). Для переноса данных из прежнего стека используйте [инструкцию по миграции](docs/migration-to-minishop.md).
|
||||
Проект является переработанным форком [kavore/remnawave-tg-shop](https://github.com/kavore/remnawave-tg-shop). Для переноса данных из прежнего стека и других ботов используйте [раздел миграций](docs/migrations/index.md).
|
||||
|
||||
## Возможности
|
||||
|
||||
@@ -14,8 +14,10 @@ Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи
|
||||
- просмотр статуса подписки, даты окончания, ссылки подключения и трафика;
|
||||
- покупка подписок, пакетов трафика, обычная и premium-докупка трафика, докупка устройств по настроенному каталогу тарифов;
|
||||
- Web App / Mini App с входом через Telegram или email;
|
||||
- встроенные инструкции установки в Mini App: личный экран `/install` и публичная ссылка `/s/<token>` для передачи инструкции;
|
||||
- пробный период, промокоды и реферальная программа;
|
||||
- оплата через YooKassa, FreeKassa, Platega, SeverPay, CryptoPay и Telegram Stars;
|
||||
- оплата через YooKassa, FreeKassa, Platega, SeverPay, Wata, CryptoPay, Heleket, PayKilla и Telegram Stars;
|
||||
- тикеты поддержки в Web App и внешняя ссылка на поддержку;
|
||||
- раздел "Мои устройства" при включенном `MY_DEVICES_SECTION_ENABLED`.
|
||||
|
||||
Для администраторов:
|
||||
@@ -23,19 +25,26 @@ Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи
|
||||
- админ-панель для пользователей из `ADMIN_IDS` (только при входе через Telegram, не для аккаунтов только с email);
|
||||
- статистика пользователей, подписок, платежей и синхронизации с Remnawave;
|
||||
- список пользователей с поиском, фильтрами и колонкой premium-трафика;
|
||||
- блокировка пользователей, рассылки, промокоды, логи действий и настройка разрешенных параметров приложения поверх `.env`;
|
||||
- редактор JSON-каталога тарифов с period/traffic-моделями, Internal Squads, premium-сквадами и HWID-пакетами;
|
||||
- блокировка пользователей, поддержка через тикеты, рассылки, промокоды, логи действий и настройка разрешенных параметров приложения поверх `.env`;
|
||||
- редактор JSON-каталога тарифов с моделями на срок/по трафику, Internal Squads, premium-сквадами и HWID-пакетами;
|
||||
- настройки инструкций подключения: чтение конфига Subscription Page из Remnawave Panel, опциональное JSON-переопределение и переключатель поведения кнопок бота;
|
||||
- ручная синхронизация пользователей и подписок с панелью.
|
||||
|
||||
## Документация
|
||||
|
||||
- [Настройка окружения](docs/configuration.md) - основные переменные `.env`, платежи, Remnawave, пробный период, SMTP для email-входа и секреты.
|
||||
- [Тарифы](docs/tariffs.md) - каталог тарифов, period- и traffic-модели, обычные и premium-докупки, premium-сквады, смена тарифа, HWID-лимиты и обработка трафика.
|
||||
- [Админ-панель](docs/admin.md) - права доступа, настройки, редактор тарифов, premium-сквады и сохранение JSON-каталога.
|
||||
- [Web App / Mini App](docs/webapp.md) - отдельный порт, домен, Telegram OAuth, email-вход и реферальные ссылки.
|
||||
- [Темы Web App](docs/webapp-themes.md) - кастомные темы, настройка внешнего вида, логотипы, CSS/ассеты и пайплайн создания новой темы.
|
||||
- [Развертывание](docs/deployment.md) - Docker Compose, reverse proxy, Nginx, Caddy, вебхуки, запуск из образа и обновление версии (`IMAGE_TAG`).
|
||||
- [Миграция с remnawave-tg-shop](docs/migration-to-minishop.md) - перенос данных из прежнего стека.
|
||||
- [Входная страница документации](docs/index.md) - маршрут по установке, настройке, платежам, админке и диагностике.
|
||||
- [Развертывание](docs/getting-started/deployment.md) - Docker Compose, Caddy, Nginx, Pangolin/Newt и запуск без обратного прокси.
|
||||
- [Настройка окружения](docs/getting-started/configuration.md) - bootstrap `.env` и рекомендуемая настройка через Web App админку.
|
||||
- [Переменные `.env`](docs/configuration/env-vars.md) - полный справочник всех env-ключей по разделам.
|
||||
- [Бэкапы и восстановление](docs/features/backups.md) - автоматические архивы, Telegram-отправка и restore через админку.
|
||||
- [Тарифы](docs/features/tariffs.md) - каталог тарифов, модели на срок и по трафику, обычные и premium-докупки, premium-сквады, смена тарифа, HWID-лимиты и обработка трафика.
|
||||
- [Админ-панель](docs/features/admin-panel.md) - права доступа, настройки, редактор тарифов, premium-сквады и сохранение JSON-каталога.
|
||||
- [Веб-приложение / Mini App](docs/features/web-app.md) - отдельный порт, домен, инструкции установки и реферальные ссылки.
|
||||
- [Telegram-авторизация](docs/features/telegram-auth.md) и [вход по email](docs/features/email-login.md) - настройка BotFather/OAuth и SMTP-логина.
|
||||
- [Поддержка пользователей / тикеты](docs/features/support.md) - тикеты в Mini App, входящий список админки, уведомления, лимиты и внешняя ссылка поддержки.
|
||||
- [Темы Web App](docs/features/webapp-themes.md) - кастомные темы, настройка внешнего вида, логотипы, CSS/ассеты и пайплайн создания новой темы.
|
||||
- [Миграции](docs/migrations/index.md) - готовые сценарии переноса с `remnawave-tg-shop` и Remnashop.
|
||||
- [Миграция с remnawave-tg-shop](docs/migrations/remnawave-tg-shop.md) и [Remnashop](docs/migrations/remnashop.md) - сценарии через общий install wizard.
|
||||
|
||||
## Совместимость
|
||||
|
||||
@@ -60,7 +69,7 @@ Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи
|
||||
- Docker и Docker Compose;
|
||||
- рабочая панель Remnawave версии **`> 2.7.0`** (см. раздел «Совместимость»);
|
||||
- токен Telegram-бота;
|
||||
- параметры хотя бы одного платежного провайдера.
|
||||
- публичные домены для webhook и Mini App.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/3252a8/remnawave-minishop
|
||||
@@ -76,17 +85,27 @@ docker compose logs -f backend worker frontend
|
||||
- `BOT_TOKEN` - токен Telegram-бота;
|
||||
- `ADMIN_IDS` - Telegram ID администраторов через запятую;
|
||||
- `WEBHOOK_BASE_URL` - публичный URL вебхуков;
|
||||
- `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB` - доступы PostgreSQL;
|
||||
- `WEBAPP_ENABLED=True` - включает Web App и админку для первого входа;
|
||||
- `WEBAPP_SESSION_SECRET`, `WEBHOOK_SECRET_TOKEN` - стабильные секреты;
|
||||
- `SUBSCRIPTION_MINI_APP_URL` - публичный HTTPS URL Mini App/frontend, например `https://app.domain.com/`;
|
||||
- `PANEL_API_URL`, `PANEL_API_KEY`, `PANEL_WEBHOOK_SECRET` - доступ к Remnawave;
|
||||
- `USER_SQUAD_UUIDS` - Internal Squads для пользователей;
|
||||
- настройки платежного провайдера;
|
||||
- `SUBSCRIPTION_MINI_APP_URL`, если используется Web App.
|
||||
- `TRUSTED_PROXIES` - оставьте дефолт для Docker/Caddy/Nginx/Newt или укажите IP/CIDR своего reverse proxy, чтобы IP allowlist платежных webhook видел реального провайдера;
|
||||
- остальные настройки удобнее задать в Web App админке.
|
||||
|
||||
Для каталога тарифов используется `TARIFFS_CONFIG_PATH` со значением по умолчанию `data/tariffs.json`. Пример формата лежит в [data/tariffs.example.json](data/tariffs.example.json), подробности - в [docs/tariffs.md](docs/tariffs.md).
|
||||
В Remnawave Panel укажите `WEBHOOK_URL` как публичный адрес Minishop с путем `/webhook/panel`, например `https://app.example.com/webhook/panel`. Секрет вебхука задается в самой Remnawave Panel; это же значение вставьте в `PANEL_WEBHOOK_SECRET` в `.env` или в **Система -> Настройки -> Remnawave Panel** в админке.
|
||||
|
||||
Если в Docker Compose включаете bind mount `./data:/app/data`, заранее создайте каталог и отдайте его пользователю контейнера. Это нужно для сохранения `data/tariffs.json`, каталога тем `data/themes`, кеша логотипа Web App и animated emoji:
|
||||
После первого входа в админку настройте тарифы, платежные провайдеры, внешний вид, поддержку, уведомления и инструкции подключения через UI. Инструкции установки включены по умолчанию, читают Subscription Page config из Remnawave Panel и при проблемах с конфигом откатываются к обычной ссылке подключения. Полный справочник env-переменных: [docs/configuration/env-vars.md](docs/configuration/env-vars.md).
|
||||
|
||||
Для каталога тарифов используется `TARIFFS_CONFIG_PATH` со значением по умолчанию `data/tariffs.json`. Пример формата лежит в [data/tariffs.example.json](data/tariffs.example.json), подробности - в [docs/features/tariffs.md](docs/features/tariffs.md).
|
||||
|
||||
В Docker этот файл должен быть доступен не только `backend` и `worker`, но и одноразовому сервису `migrate`: мигратор читает каталог тарифов при привязке существующих подписок к тарифу по умолчанию. В текущих compose-файлах весь `/app/data` уже смонтирован в `migrate`, `backend` и `worker`; если переносите compose вручную, сохраните одинаковый mount для всех трех сервисов.
|
||||
|
||||
В compose-примерах `/app/data` монтируется из папки `./data` рядом с `docker-compose.yml`. Заранее создайте каталог и отдайте его пользователю контейнера. Это нужно для сохранения `data/tariffs.json`, каталога тем `data/themes` и кеша логотипа Web App:
|
||||
|
||||
```bash
|
||||
mkdir -p data/themes data/webapp-logo data/webapp-emoji
|
||||
mkdir -p data/themes data/webapp-logo data/tariffs
|
||||
touch data/locales-overrides.json
|
||||
chown -R 10001:10001 data
|
||||
chmod -R u+rwX data
|
||||
```
|
||||
@@ -100,19 +119,35 @@ docker compose up -d --build
|
||||
# Логи приложения
|
||||
docker compose logs -f backend worker frontend
|
||||
|
||||
# Запуск с Caddy
|
||||
docker compose -f deploy/compose/docker-compose-caddy.yml up -d
|
||||
# Рекомендуемый продакшен-вариант с Caddy
|
||||
cd deploy/examples/caddy # или nginx, newt, no-proxy
|
||||
cp .env.example .env
|
||||
nano .env
|
||||
docker compose up -d
|
||||
|
||||
# Запуск из готового образа
|
||||
IMAGE_TAG=3.1.0 docker compose -f deploy/compose/docker-compose-remote-server.yml up -d
|
||||
# Запуск из готового образа с конкретным тегом
|
||||
IMAGE_TAG=3.1.0 docker compose up -d
|
||||
```
|
||||
|
||||
GHCR image names for releases:
|
||||
Для продакшен-запуска удобнее брать готовые папки из [`deploy/examples`](deploy/examples), а читать каноничные инструкции в [docs/getting-started/deployment.md](docs/getting-started/deployment.md). Предпочтительный вариант для обычного публичного сервера - Caddy: он сам выпускает и продлевает HTTPS-сертификаты. В папках рядом с compose лежат только конфиги и короткие ссылки на документацию.
|
||||
|
||||
Имена образов для релизов:
|
||||
|
||||
- `ghcr.io/3252a8/remnawave-minishop-backend`
|
||||
- `ghcr.io/3252a8/remnawave-minishop-worker`
|
||||
- `ghcr.io/3252a8/remnawave-minishop-frontend`
|
||||
- `docker.io/3252a8/remnawave-minishop-backend`
|
||||
- `docker.io/3252a8/remnawave-minishop-worker`
|
||||
- `docker.io/3252a8/remnawave-minishop-frontend`
|
||||
|
||||
## Поддержка
|
||||
Сборка и публикация сразу в GHCR и Docker Hub:
|
||||
|
||||
```bash
|
||||
docker login ghcr.io
|
||||
docker login docker.io
|
||||
IMAGE_TAG=v3.4.3 bash scripts/docker-build-push-images.sh
|
||||
```
|
||||
|
||||
## Поддержать проект
|
||||
|
||||
- Crypto: `USDT/Other ERC-20 0xeD506D44aae634fEc0E01C8835744fBedb7B2a44 (Ethereum/Polygon/Gnosis)`
|
||||
|
||||
@@ -17,6 +17,7 @@ from bot.middlewares.channel_subscription import ChannelSubscriptionMiddleware
|
||||
from bot.middlewares.db_session import DBSessionMiddleware
|
||||
from bot.middlewares.i18n import I18nMiddleware, get_i18n_instance
|
||||
from bot.middlewares.profile_sync import ProfileSyncMiddleware
|
||||
from bot.middlewares.update_antiflood import UpdateAntiFloodMiddleware
|
||||
from config.settings import Settings
|
||||
|
||||
|
||||
@@ -38,6 +39,7 @@ def build_dispatcher(
|
||||
dp["i18n_instance"] = i18n_instance
|
||||
dp["async_session_factory"] = async_session_factory
|
||||
|
||||
dp.update.outer_middleware(UpdateAntiFloodMiddleware(settings=settings))
|
||||
dp.update.outer_middleware(DBSessionMiddleware(async_session_factory))
|
||||
dp.update.outer_middleware(I18nMiddleware(i18n=i18n_instance, settings=settings))
|
||||
dp.update.outer_middleware(ProfileSyncMiddleware())
|
||||
|
||||
@@ -2,18 +2,21 @@ from aiogram import Bot
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.crypto_pay_service import CryptoPayService
|
||||
from bot.services.freekassa_service import FreeKassaService
|
||||
from bot.payment_providers import (
|
||||
ServiceFactoryContext,
|
||||
build_provider_configs,
|
||||
build_provider_services,
|
||||
)
|
||||
from bot.services.email_auth_service import EmailAuthService
|
||||
from bot.services.lknpd_service import LknpdService
|
||||
from bot.services.notification_service import NotificationService
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
from bot.services.panel_dry_run_api_service import PanelDryRunApiService
|
||||
from bot.services.panel_webhook_service import PanelWebhookService
|
||||
from bot.services.platega_service import PlategaService
|
||||
from bot.services.promo_code_service import PromoCodeService
|
||||
from bot.services.referral_service import ReferralService
|
||||
from bot.services.severpay_service import SeverPayService
|
||||
from bot.services.stars_service import StarsService
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.services.yookassa_service import YooKassaService
|
||||
from bot.services.support_service import SupportService
|
||||
from config.settings import Settings
|
||||
|
||||
|
||||
@@ -24,56 +27,46 @@ def build_core_services(
|
||||
i18n: JsonI18n,
|
||||
bot_username_for_default_return: str,
|
||||
):
|
||||
panel_service = PanelApiService(settings)
|
||||
panel_service = (
|
||||
PanelDryRunApiService(settings)
|
||||
if bool(getattr(settings, "panel_dry_run_enabled", False))
|
||||
else PanelApiService(settings)
|
||||
)
|
||||
subscription_service = SubscriptionService(settings, panel_service, bot, i18n)
|
||||
referral_service = ReferralService(settings, subscription_service, bot, i18n)
|
||||
promo_code_service = PromoCodeService(settings, subscription_service, bot, i18n)
|
||||
stars_service = StarsService(bot, settings, i18n, subscription_service, referral_service)
|
||||
cryptopay_service = CryptoPayService(
|
||||
settings.CRYPTOPAY_TOKEN,
|
||||
settings.CRYPTOPAY_NETWORK,
|
||||
email_auth_service = EmailAuthService(settings, i18n)
|
||||
notification_service = NotificationService(
|
||||
bot,
|
||||
settings,
|
||||
i18n,
|
||||
session_factory=async_session_factory,
|
||||
email_auth_service=email_auth_service,
|
||||
bot_username=bot_username_for_default_return,
|
||||
)
|
||||
support_service = SupportService(
|
||||
async_session_factory,
|
||||
subscription_service,
|
||||
referral_service,
|
||||
)
|
||||
freekassa_service = FreeKassaService(
|
||||
bot=bot,
|
||||
settings=settings,
|
||||
i18n=i18n,
|
||||
async_session_factory=async_session_factory,
|
||||
subscription_service=subscription_service,
|
||||
referral_service=referral_service,
|
||||
)
|
||||
platega_service = PlategaService(
|
||||
bot=bot,
|
||||
settings=settings,
|
||||
i18n=i18n,
|
||||
async_session_factory=async_session_factory,
|
||||
subscription_service=subscription_service,
|
||||
referral_service=referral_service,
|
||||
default_return_url=bot_username_for_default_return,
|
||||
)
|
||||
severpay_service = SeverPayService(
|
||||
bot=bot,
|
||||
settings=settings,
|
||||
i18n=i18n,
|
||||
async_session_factory=async_session_factory,
|
||||
subscription_service=subscription_service,
|
||||
referral_service=referral_service,
|
||||
default_return_url=bot_username_for_default_return,
|
||||
settings,
|
||||
bot,
|
||||
i18n,
|
||||
notification_service,
|
||||
email_auth_service,
|
||||
)
|
||||
panel_webhook_service = PanelWebhookService(
|
||||
bot, settings, i18n, async_session_factory, panel_service
|
||||
)
|
||||
yookassa_service = YooKassaService(
|
||||
shop_id=settings.YOOKASSA_SHOP_ID,
|
||||
secret_key=settings.YOOKASSA_SECRET_KEY,
|
||||
configured_return_url=settings.YOOKASSA_RETURN_URL,
|
||||
bot_username_for_default_return=bot_username_for_default_return,
|
||||
settings_obj=settings,
|
||||
provider_configs = build_provider_configs()
|
||||
payment_services = build_provider_services(
|
||||
ServiceFactoryContext(
|
||||
settings=settings,
|
||||
bot=bot,
|
||||
async_session_factory=async_session_factory,
|
||||
i18n=i18n,
|
||||
bot_username_for_default_return=bot_username_for_default_return,
|
||||
subscription_service=subscription_service,
|
||||
referral_service=referral_service,
|
||||
provider_configs=provider_configs,
|
||||
)
|
||||
)
|
||||
lknpd_service = LknpdService(
|
||||
settings.LKNPD_INN,
|
||||
@@ -81,24 +74,20 @@ def build_core_services(
|
||||
api_url=settings.LKNPD_API_URL,
|
||||
)
|
||||
|
||||
# Wire services that depend on each other. These attachments are critical
|
||||
# for auto-renew (subscription_service.yookassa_service) and for the panel
|
||||
# webhook handler's 24h pre-expiry renewal trigger; do NOT swallow errors —
|
||||
# silent wiring failures previously caused auto-renew to disappear.
|
||||
subscription_service.yookassa_service = yookassa_service
|
||||
# These attachments are critical for auto-renew and panel pre-expiry hooks.
|
||||
subscription_service.yookassa_service = payment_services.get("yookassa_service")
|
||||
panel_webhook_service.subscription_service = subscription_service
|
||||
|
||||
return {
|
||||
services = {
|
||||
"panel_service": panel_service,
|
||||
"subscription_service": subscription_service,
|
||||
"referral_service": referral_service,
|
||||
"promo_code_service": promo_code_service,
|
||||
"stars_service": stars_service,
|
||||
"cryptopay_service": cryptopay_service,
|
||||
"freekassa_service": freekassa_service,
|
||||
"notification_service": notification_service,
|
||||
"email_auth_service": email_auth_service,
|
||||
"support_service": support_service,
|
||||
"panel_webhook_service": panel_webhook_service,
|
||||
"yookassa_service": yookassa_service,
|
||||
"lknpd_service": lknpd_service,
|
||||
"platega_service": platega_service,
|
||||
"severpay_service": severpay_service,
|
||||
}
|
||||
services.update(payment_services)
|
||||
return services
|
||||
|
||||
@@ -6,8 +6,10 @@ from bot.app.web.admin_api_impl import (
|
||||
_runtime as _runtime,
|
||||
ads as _ads,
|
||||
auth as _auth,
|
||||
backups as _backups,
|
||||
broadcast as _broadcast,
|
||||
common as _common,
|
||||
health as _health,
|
||||
logs as _logs,
|
||||
panel as _panel,
|
||||
payments as _payments,
|
||||
@@ -15,9 +17,11 @@ from bot.app.web.admin_api_impl import (
|
||||
routes as _routes,
|
||||
settings as _settings,
|
||||
stats as _stats,
|
||||
support as _support,
|
||||
sync as _sync,
|
||||
tariffs as _tariffs,
|
||||
themes as _themes,
|
||||
translations as _translations,
|
||||
users as _users,
|
||||
)
|
||||
|
||||
@@ -25,17 +29,21 @@ _MODULES = (
|
||||
_runtime,
|
||||
_auth,
|
||||
_common,
|
||||
_health,
|
||||
_stats,
|
||||
_users,
|
||||
_payments,
|
||||
_promos,
|
||||
_logs,
|
||||
_support,
|
||||
_broadcast,
|
||||
_sync,
|
||||
_ads,
|
||||
_backups,
|
||||
_settings,
|
||||
_tariffs,
|
||||
_themes,
|
||||
_translations,
|
||||
_panel,
|
||||
_routes,
|
||||
)
|
||||
|
||||
@@ -37,10 +37,11 @@ from bot.services.settings_override_service import (
|
||||
from bot.utils import MessageContent, send_message_via_queue
|
||||
from bot.utils.message_queue import get_queue_manager
|
||||
from config.settings import Settings
|
||||
from config.tariffs_config import TariffsConfig
|
||||
from config.tariffs_config import TariffsConfig, default_payment_currency_code_for_settings
|
||||
from db.dal import (
|
||||
ad_dal,
|
||||
app_settings_dal,
|
||||
locale_overrides_dal,
|
||||
message_log_dal,
|
||||
panel_sync_dal,
|
||||
payment_dal,
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
import secrets
|
||||
import subprocess
|
||||
|
||||
from bot.infra.redis import redis_lock
|
||||
from bot.services.backup_restore_service import (
|
||||
BACKUP_UPLOAD_MAX_BYTES,
|
||||
BackupArchiveError,
|
||||
BackupArchiveInfo,
|
||||
BackupRestoreError,
|
||||
BackupRestoreService,
|
||||
)
|
||||
from bot.services.backup_worker import BackupWorker
|
||||
|
||||
|
||||
def _backup_archive_payload(archive) -> Dict[str, Any]:
|
||||
return archive.to_payload()
|
||||
|
||||
|
||||
async def _read_uploaded_backup_file(request: web.Request) -> BackupArchiveInfo:
|
||||
settings: Settings = request.app["settings"]
|
||||
service = BackupRestoreService(settings)
|
||||
backup_dir = service.backup_dir()
|
||||
temp_path: Optional[Path] = None
|
||||
|
||||
reader = await request.multipart()
|
||||
try:
|
||||
async for part in reader:
|
||||
if part.name != "file":
|
||||
continue
|
||||
|
||||
original_filename = part.filename or "backup.zip"
|
||||
temp_path = backup_dir / f".upload-{secrets.token_urlsafe(12)}.zip.tmp"
|
||||
size = 0
|
||||
with temp_path.open("wb") as handle:
|
||||
while True:
|
||||
chunk = await part.read_chunk(size=1024 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
size += len(chunk)
|
||||
if size > BACKUP_UPLOAD_MAX_BYTES:
|
||||
raise BackupArchiveError("Backup archive is too large")
|
||||
handle.write(chunk)
|
||||
if size <= 0:
|
||||
raise BackupArchiveError("Uploaded archive is empty")
|
||||
archive = service.import_uploaded_archive(temp_path, original_filename)
|
||||
temp_path = None
|
||||
return archive
|
||||
finally:
|
||||
if temp_path is not None and temp_path.exists():
|
||||
try:
|
||||
temp_path.unlink()
|
||||
except OSError:
|
||||
logger.warning("Failed to remove temporary backup upload %s", temp_path)
|
||||
|
||||
raise BackupArchiveError("file field is required")
|
||||
|
||||
|
||||
async def admin_backups_list_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
settings: Settings = request.app["settings"]
|
||||
try:
|
||||
service = BackupRestoreService(settings)
|
||||
archives = service.list_archives()
|
||||
except OSError as exc:
|
||||
logger.exception("Failed to list backup archives")
|
||||
return _error(500, "backup_list_failed", str(exc))
|
||||
return _ok(
|
||||
{
|
||||
"backup_dir": str(service.backup_dir()),
|
||||
"archives": [_backup_archive_payload(archive) for archive in archives],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def admin_backups_upload_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
content_type = (request.headers.get("Content-Type") or "").lower()
|
||||
if not content_type.startswith("multipart/form-data"):
|
||||
return _error(400, "invalid_backup_archive", "multipart file upload is required")
|
||||
try:
|
||||
archive = await _read_uploaded_backup_file(request)
|
||||
except BackupArchiveError as exc:
|
||||
return _error(400, "invalid_backup_archive", str(exc))
|
||||
except OSError as exc:
|
||||
logger.exception("Failed to save uploaded backup archive")
|
||||
return _error(500, "backup_upload_failed", str(exc))
|
||||
return _ok({"archive": _backup_archive_payload(archive)})
|
||||
|
||||
|
||||
async def admin_backups_create_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
settings: Settings = request.app["settings"]
|
||||
bot = request.app["bot"]
|
||||
session_factory = request.app.get("async_session_factory")
|
||||
worker = BackupWorker(settings, bot, session_factory=session_factory)
|
||||
|
||||
ttl_seconds = max(
|
||||
60,
|
||||
int(
|
||||
max(
|
||||
getattr(settings, "BACKUP_LOCK_TTL_SECONDS", 7200) or 7200,
|
||||
getattr(settings, "BACKUP_PG_DUMP_TIMEOUT_SECONDS", 1800) or 1800,
|
||||
)
|
||||
),
|
||||
)
|
||||
try:
|
||||
async with redis_lock(settings, "backup-worker", ttl_seconds=ttl_seconds) as acquired:
|
||||
if not acquired:
|
||||
return _error(409, "backup_create_busy", "Backup or restore is already running")
|
||||
await worker.refresh_settings()
|
||||
result = await worker.create_and_send_backup(backup_type="manual")
|
||||
archive = BackupRestoreService(settings).inspect_archive(result.archive_path)
|
||||
except BackupArchiveError as exc:
|
||||
return _error(400, "invalid_backup_archive", str(exc))
|
||||
except (OSError, RuntimeError, subprocess.SubprocessError, TimeoutError) as exc:
|
||||
logger.exception("Manual backup creation failed")
|
||||
return _error(500, "backup_create_failed", str(exc))
|
||||
except Exception as exc:
|
||||
logger.exception("Manual backup creation failed")
|
||||
return _error(500, "backup_create_failed", str(exc))
|
||||
|
||||
return _ok(
|
||||
{
|
||||
"result": result.to_payload(),
|
||||
"archive": _backup_archive_payload(archive),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def admin_backups_restore_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
settings: Settings = request.app["settings"]
|
||||
payload = await _read_json(request)
|
||||
|
||||
archive_name = str(payload.get("archive_name") or "").strip()
|
||||
restore_database = bool(payload.get("restore_database"))
|
||||
restore_compose = bool(payload.get("restore_compose"))
|
||||
confirm = bool(payload.get("confirm"))
|
||||
if not confirm:
|
||||
return _error(400, "restore_confirmation_required")
|
||||
|
||||
service = BackupRestoreService(settings)
|
||||
ttl_seconds = max(
|
||||
60,
|
||||
int(
|
||||
max(
|
||||
getattr(settings, "BACKUP_LOCK_TTL_SECONDS", 7200) or 7200,
|
||||
getattr(settings, "BACKUP_PG_RESTORE_TIMEOUT_SECONDS", 1800) or 1800,
|
||||
)
|
||||
),
|
||||
)
|
||||
try:
|
||||
async with redis_lock(settings, "backup-worker", ttl_seconds=ttl_seconds) as acquired:
|
||||
if not acquired:
|
||||
return _error(409, "backup_restore_busy", "Backup or restore is already running")
|
||||
result = await service.restore_archive(
|
||||
archive_name,
|
||||
restore_database=restore_database,
|
||||
restore_compose=restore_compose,
|
||||
)
|
||||
except BackupArchiveError as exc:
|
||||
return _error(400, "invalid_backup_archive", str(exc))
|
||||
except BackupRestoreError as exc:
|
||||
logger.exception("Backup restore failed")
|
||||
return _error(500, "backup_restore_failed", str(exc))
|
||||
except (OSError, subprocess.SubprocessError, TimeoutError) as exc:
|
||||
logger.exception("Backup restore failed")
|
||||
return _error(500, "backup_restore_failed", str(exc))
|
||||
finally:
|
||||
try:
|
||||
from db import database_setup
|
||||
|
||||
if restore_database and database_setup.async_engine is not None:
|
||||
await database_setup.async_engine.dispose()
|
||||
except Exception:
|
||||
logger.exception("Failed to dispose DB engine after backup restore")
|
||||
|
||||
return _ok({"result": result.to_payload()})
|
||||
@@ -1,5 +1,165 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
from .common import _panel_user_connection_activity
|
||||
|
||||
import asyncio
|
||||
from collections import defaultdict
|
||||
|
||||
from bot.utils.ttl_cache import AsyncTTLCache
|
||||
|
||||
|
||||
BROADCAST_TARGET_ACTIVE_NEVER_CONNECTED = "active_never_connected"
|
||||
BROADCAST_TARGETS = {
|
||||
"all",
|
||||
"active",
|
||||
"inactive",
|
||||
"expired",
|
||||
"never",
|
||||
BROADCAST_TARGET_ACTIVE_NEVER_CONNECTED,
|
||||
}
|
||||
PANEL_ACTIVITY_LOOKUP_CONCURRENCY = 10
|
||||
_ADMIN_BROADCAST_AUDIENCE_COUNT_CACHES: Dict[tuple[int, int], AsyncTTLCache] = {}
|
||||
|
||||
|
||||
def _resolve_panel_service(request: web.Request) -> Any:
|
||||
subscription_service = request.app.get("subscription_service")
|
||||
return getattr(subscription_service, "panel_service", None)
|
||||
|
||||
|
||||
async def _active_subscription_panel_uuids_by_user(
|
||||
session: AsyncSession,
|
||||
) -> Dict[int, List[str]]:
|
||||
now = datetime.now(timezone.utc)
|
||||
stmt = (
|
||||
select(Subscription.user_id, Subscription.panel_user_uuid)
|
||||
.join(User, Subscription.user_id == User.user_id)
|
||||
.where(
|
||||
User.is_banned == False,
|
||||
Subscription.is_active == True,
|
||||
Subscription.end_date > now,
|
||||
Subscription.panel_user_uuid.is_not(None),
|
||||
Subscription.panel_user_uuid != "",
|
||||
)
|
||||
.order_by(Subscription.user_id.asc(), Subscription.end_date.desc())
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
|
||||
grouped: Dict[int, List[str]] = defaultdict(list)
|
||||
seen: Dict[int, set[str]] = defaultdict(set)
|
||||
for user_id, panel_uuid in result.all():
|
||||
user_id_int = int(user_id)
|
||||
panel_uuid_str = str(panel_uuid or "").strip()
|
||||
if panel_uuid_str and panel_uuid_str not in seen[user_id_int]:
|
||||
grouped[user_id_int].append(panel_uuid_str)
|
||||
seen[user_id_int].add(panel_uuid_str)
|
||||
return dict(grouped)
|
||||
|
||||
|
||||
async def _panel_connection_status(panel_service: Any, panel_uuid: str) -> str:
|
||||
try:
|
||||
panel_user = await panel_service.get_user_by_uuid(panel_uuid)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to fetch panel user activity uuid=%s: %s", panel_uuid, exc)
|
||||
return "unknown"
|
||||
activity = _panel_user_connection_activity(panel_user)
|
||||
return str(activity.get("status") or "unknown")
|
||||
|
||||
|
||||
async def _user_ids_with_active_subscription_never_connected(
|
||||
session: AsyncSession,
|
||||
panel_service: Any,
|
||||
) -> List[int]:
|
||||
panel_uuids_by_user = await _active_subscription_panel_uuids_by_user(session)
|
||||
semaphore = asyncio.Semaphore(PANEL_ACTIVITY_LOOKUP_CONCURRENCY)
|
||||
|
||||
async def lookup(panel_uuid: str) -> str:
|
||||
async with semaphore:
|
||||
return await _panel_connection_status(panel_service, panel_uuid)
|
||||
|
||||
panel_uuids = list(
|
||||
dict.fromkeys(
|
||||
panel_uuid
|
||||
for user_panel_uuids in panel_uuids_by_user.values()
|
||||
for panel_uuid in user_panel_uuids
|
||||
)
|
||||
)
|
||||
statuses_by_uuid = dict(
|
||||
zip(
|
||||
panel_uuids,
|
||||
await asyncio.gather(*(lookup(uuid) for uuid in panel_uuids)),
|
||||
)
|
||||
)
|
||||
|
||||
user_ids: List[int] = []
|
||||
for user_id, panel_uuids in panel_uuids_by_user.items():
|
||||
statuses = [statuses_by_uuid.get(panel_uuid, "unknown") for panel_uuid in panel_uuids]
|
||||
if statuses and all(status == "never" for status in statuses):
|
||||
user_ids.append(user_id)
|
||||
return user_ids
|
||||
|
||||
|
||||
def _admin_broadcast_audience_counts_cache(settings: Settings) -> Optional[AsyncTTLCache]:
|
||||
ttl_seconds = int(
|
||||
getattr(settings, "ADMIN_BROADCAST_AUDIENCE_COUNTS_CACHE_TTL_SECONDS", 30) or 0
|
||||
)
|
||||
if ttl_seconds <= 0:
|
||||
return None
|
||||
cache_key = (id(settings), ttl_seconds)
|
||||
cache = _ADMIN_BROADCAST_AUDIENCE_COUNT_CACHES.get(cache_key)
|
||||
if cache is None:
|
||||
cache = AsyncTTLCache(
|
||||
ttl_seconds=ttl_seconds,
|
||||
settings=settings,
|
||||
namespace="admin:broadcast_audience_counts",
|
||||
)
|
||||
_ADMIN_BROADCAST_AUDIENCE_COUNT_CACHES[cache_key] = cache
|
||||
return cache
|
||||
|
||||
|
||||
async def _load_broadcast_audience_counts(
|
||||
settings: Settings,
|
||||
async_session_factory: sessionmaker,
|
||||
panel_service: Any,
|
||||
) -> Dict[str, Optional[int]]:
|
||||
cache = _admin_broadcast_audience_counts_cache(settings)
|
||||
if cache is None:
|
||||
return await _load_broadcast_audience_counts_uncached(
|
||||
async_session_factory,
|
||||
panel_service,
|
||||
)
|
||||
cache_key = "with-panel" if panel_service is not None else "without-panel"
|
||||
return await cache.get_or_load(
|
||||
cache_key,
|
||||
lambda: _load_broadcast_audience_counts_uncached(
|
||||
async_session_factory,
|
||||
panel_service,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def _load_broadcast_audience_counts_uncached(
|
||||
async_session_factory: sessionmaker,
|
||||
panel_service: Any,
|
||||
) -> Dict[str, Optional[int]]:
|
||||
async with async_session_factory() as session:
|
||||
counts: Dict[str, Optional[int]] = {
|
||||
"all": await user_dal.count_all_active_users_for_broadcast(session),
|
||||
"active": await user_dal.count_users_with_active_subscription_for_broadcast(session),
|
||||
"inactive": await user_dal.count_users_without_active_subscription_for_broadcast(
|
||||
session
|
||||
),
|
||||
"expired": await user_dal.count_users_with_expired_subscription_for_broadcast(session),
|
||||
"never": await user_dal.count_users_without_any_subscription_for_broadcast(session),
|
||||
BROADCAST_TARGET_ACTIVE_NEVER_CONNECTED: None,
|
||||
}
|
||||
if panel_service is not None:
|
||||
counts[BROADCAST_TARGET_ACTIVE_NEVER_CONNECTED] = len(
|
||||
await _user_ids_with_active_subscription_never_connected(
|
||||
session,
|
||||
panel_service,
|
||||
)
|
||||
)
|
||||
return counts
|
||||
|
||||
|
||||
async def admin_broadcast_route(request: web.Request) -> web.Response:
|
||||
@@ -9,7 +169,7 @@ async def admin_broadcast_route(request: web.Request) -> web.Response:
|
||||
target = str(payload.get("target") or "all").strip().lower()
|
||||
if not text:
|
||||
return _error(400, "empty_text")
|
||||
if target not in {"all", "active", "inactive"}:
|
||||
if target not in BROADCAST_TARGETS:
|
||||
target = "all"
|
||||
|
||||
queue_manager = get_queue_manager()
|
||||
@@ -18,10 +178,22 @@ async def admin_broadcast_route(request: web.Request) -> web.Response:
|
||||
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
if target == "active":
|
||||
if target == BROADCAST_TARGET_ACTIVE_NEVER_CONNECTED:
|
||||
panel_service = _resolve_panel_service(request)
|
||||
if panel_service is None:
|
||||
return _error(503, "panel_service_unavailable")
|
||||
user_ids = await _user_ids_with_active_subscription_never_connected(
|
||||
session,
|
||||
panel_service,
|
||||
)
|
||||
elif target == "active":
|
||||
user_ids = await user_dal.get_user_ids_with_active_subscription(session)
|
||||
elif target == "inactive":
|
||||
user_ids = await user_dal.get_user_ids_without_active_subscription(session)
|
||||
elif target == "expired":
|
||||
user_ids = await user_dal.get_user_ids_with_expired_subscription(session)
|
||||
elif target == "never":
|
||||
user_ids = await user_dal.get_user_ids_without_any_subscription(session)
|
||||
else:
|
||||
user_ids = await user_dal.get_all_active_user_ids_for_broadcast(session)
|
||||
|
||||
@@ -52,3 +224,19 @@ async def admin_broadcast_route(request: web.Request) -> web.Response:
|
||||
)
|
||||
|
||||
return _ok({"queued": sent, "failed": failed, "target": target})
|
||||
|
||||
|
||||
async def admin_broadcast_audience_counts_route(request: web.Request) -> web.Response:
|
||||
"""Return how many users each broadcast audience currently resolves to."""
|
||||
_require_admin_user_id(request)
|
||||
|
||||
settings: Settings = request.app["settings"]
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
panel_service = _resolve_panel_service(request)
|
||||
counts = await _load_broadcast_audience_counts(
|
||||
settings,
|
||||
async_session_factory,
|
||||
panel_service,
|
||||
)
|
||||
|
||||
return _ok({"counts": counts})
|
||||
|
||||
@@ -22,6 +22,175 @@ async def _read_json(request: web.Request) -> Dict[str, Any]:
|
||||
return {}
|
||||
|
||||
|
||||
_PANEL_LAST_CONNECTED_KEYS = (
|
||||
"onlineAt",
|
||||
"online_at",
|
||||
"lastSeenAt",
|
||||
"last_seen_at",
|
||||
"lastConnectedAt",
|
||||
"last_connected_at",
|
||||
"lastConnectionAt",
|
||||
"last_connection_at",
|
||||
)
|
||||
_PANEL_CONNECTION_MARKER_KEYS = (
|
||||
*_PANEL_LAST_CONNECTED_KEYS,
|
||||
"firstConnectedAt",
|
||||
"first_connected_at",
|
||||
"lastConnectedNodeUuid",
|
||||
"last_connected_node_uuid",
|
||||
)
|
||||
_PANEL_CONNECTION_MARKER_OBJECT_KEYS = ("lastConnectedNode", "last_connected_node")
|
||||
_PANEL_TRAFFIC_OBJECT_KEYS = ("userTraffic", "user_traffic", "traffic", "trafficStats")
|
||||
_PANEL_TRAFFIC_USED_KEYS = (
|
||||
"lifetimeUsedTrafficBytes",
|
||||
"lifetime_used_traffic_bytes",
|
||||
"usedTrafficBytes",
|
||||
"used_traffic_bytes",
|
||||
"trafficUsedBytes",
|
||||
"traffic_used_bytes",
|
||||
"downloadBytes",
|
||||
"download_bytes",
|
||||
"uploadBytes",
|
||||
"upload_bytes",
|
||||
)
|
||||
|
||||
|
||||
def _panel_user_payload(panel_user_data: Any) -> Dict[str, Any]:
|
||||
if not isinstance(panel_user_data, dict):
|
||||
return {}
|
||||
response = panel_user_data.get("response")
|
||||
if isinstance(response, dict) and not any(
|
||||
key in panel_user_data
|
||||
for key in ("uuid", "shortUuid", "subscriptionUrl", "userTraffic", "status")
|
||||
):
|
||||
return response
|
||||
return panel_user_data
|
||||
|
||||
|
||||
def _coerce_panel_datetime(value: Any) -> Optional[str]:
|
||||
if value is None or value is False:
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
return value.isoformat()
|
||||
if isinstance(value, (int, float)):
|
||||
if value <= 0:
|
||||
return None
|
||||
seconds = float(value) / 1000.0 if value > 10_000_000_000 else float(value)
|
||||
try:
|
||||
return datetime.fromtimestamp(seconds, tz=timezone.utc).isoformat()
|
||||
except (OSError, OverflowError, ValueError):
|
||||
return None
|
||||
text = str(value).strip()
|
||||
if not text or text.lower() in {"0", "null", "none", "never"}:
|
||||
return None
|
||||
if text.isdigit():
|
||||
return _coerce_panel_datetime(int(text))
|
||||
try:
|
||||
parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
return parsed.isoformat()
|
||||
|
||||
|
||||
def _coerce_panel_int(value: Any) -> Optional[int]:
|
||||
try:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
return int(float(value))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _panel_nested_dicts(panel_user: Dict[str, Any], keys: Tuple[str, ...]) -> List[Dict[str, Any]]:
|
||||
out: List[Dict[str, Any]] = []
|
||||
for key in keys:
|
||||
value = panel_user.get(key)
|
||||
if isinstance(value, dict):
|
||||
out.append(value)
|
||||
return out
|
||||
|
||||
|
||||
def _panel_user_connection_containers(panel_user: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
traffic_containers = _panel_nested_dicts(panel_user, _PANEL_TRAFFIC_OBJECT_KEYS)
|
||||
marker_containers = _panel_nested_dicts(
|
||||
panel_user,
|
||||
_PANEL_CONNECTION_MARKER_OBJECT_KEYS,
|
||||
)
|
||||
for traffic_container in traffic_containers:
|
||||
marker_containers.extend(
|
||||
_panel_nested_dicts(traffic_container, _PANEL_CONNECTION_MARKER_OBJECT_KEYS)
|
||||
)
|
||||
return [panel_user, *traffic_containers, *marker_containers]
|
||||
|
||||
|
||||
def _panel_user_last_connected_at(panel_user_data: Any) -> Optional[str]:
|
||||
panel_user = _panel_user_payload(panel_user_data)
|
||||
if not panel_user:
|
||||
return None
|
||||
for container in _panel_user_connection_containers(panel_user):
|
||||
for key in _PANEL_LAST_CONNECTED_KEYS:
|
||||
connected_at = _coerce_panel_datetime(container.get(key))
|
||||
if connected_at:
|
||||
return connected_at
|
||||
return None
|
||||
|
||||
|
||||
def _panel_user_positive_traffic_bytes(panel_user: Dict[str, Any]) -> bool:
|
||||
containers = [panel_user, *_panel_nested_dicts(panel_user, _PANEL_TRAFFIC_OBJECT_KEYS)]
|
||||
for container in containers:
|
||||
for key in _PANEL_TRAFFIC_USED_KEYS:
|
||||
value = _coerce_panel_int(container.get(key))
|
||||
if value is not None and value > 0:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _panel_user_has_connection_marker(panel_user: Dict[str, Any]) -> bool:
|
||||
for container in _panel_user_connection_containers(panel_user):
|
||||
for key in _PANEL_CONNECTION_MARKER_KEYS:
|
||||
if key in container:
|
||||
return True
|
||||
for container in [panel_user, *_panel_nested_dicts(panel_user, _PANEL_TRAFFIC_OBJECT_KEYS)]:
|
||||
for key in _PANEL_CONNECTION_MARKER_OBJECT_KEYS:
|
||||
if key in container:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _panel_user_has_connected_marker_value(panel_user: Dict[str, Any]) -> bool:
|
||||
for container in _panel_user_connection_containers(panel_user):
|
||||
for key in (*_PANEL_LAST_CONNECTED_KEYS, "firstConnectedAt", "first_connected_at"):
|
||||
if _coerce_panel_datetime(container.get(key)):
|
||||
return True
|
||||
for key in ("lastConnectedNodeUuid", "last_connected_node_uuid"):
|
||||
if str(container.get(key) or "").strip():
|
||||
return True
|
||||
for container in [panel_user, *_panel_nested_dicts(panel_user, _PANEL_TRAFFIC_OBJECT_KEYS)]:
|
||||
for key in _PANEL_CONNECTION_MARKER_OBJECT_KEYS:
|
||||
marker = container.get(key)
|
||||
if isinstance(marker, dict) and any(
|
||||
str(value or "").strip() for value in marker.values()
|
||||
):
|
||||
return True
|
||||
if marker and not isinstance(marker, dict):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _panel_user_connection_activity(panel_user_data: Any) -> Dict[str, Any]:
|
||||
panel_user = _panel_user_payload(panel_user_data)
|
||||
last_connected_at = _panel_user_last_connected_at(panel_user)
|
||||
if not panel_user:
|
||||
return {"status": "unknown", "last_connected_at": None}
|
||||
if last_connected_at or _panel_user_positive_traffic_bytes(panel_user):
|
||||
return {"status": "connected", "last_connected_at": last_connected_at}
|
||||
if _panel_user_has_connected_marker_value(panel_user):
|
||||
return {"status": "connected", "last_connected_at": last_connected_at}
|
||||
if _panel_user_has_connection_marker(panel_user):
|
||||
return {"status": "never", "last_connected_at": None}
|
||||
return {"status": "unknown", "last_connected_at": None}
|
||||
|
||||
|
||||
def _serialize_user(user: User) -> Dict[str, Any]:
|
||||
return {
|
||||
"user_id": int(user.user_id),
|
||||
@@ -94,6 +263,9 @@ def _serialize_subscription(sub: Subscription) -> Dict[str, Any]:
|
||||
regular_unlimited_override = bool(getattr(sub, "regular_unlimited_override", False))
|
||||
premium_unlimited_override = bool(getattr(sub, "premium_unlimited_override", False))
|
||||
premium_limit_bytes = _premium_limit_bytes_from_subscription(sub)
|
||||
provider = sub.provider
|
||||
is_trial = str(provider or "").strip().lower() == "trial"
|
||||
display_label = "Trial" if is_trial else sub.tariff_key
|
||||
return {
|
||||
"subscription_id": int(sub.subscription_id),
|
||||
"panel_user_uuid": sub.panel_user_uuid,
|
||||
@@ -117,9 +289,13 @@ def _serialize_subscription(sub: Subscription) -> Dict[str, Any]:
|
||||
"regular_unlimited_override": regular_unlimited_override,
|
||||
"premium_unlimited_override": premium_unlimited_override,
|
||||
"premium_is_limited": bool(sub.premium_is_limited),
|
||||
"hwid_device_limit": getattr(sub, "hwid_device_limit", None),
|
||||
"extra_hwid_devices": int(getattr(sub, "extra_hwid_devices", 0) or 0),
|
||||
"tariff_key": sub.tariff_key,
|
||||
"display_label": display_label,
|
||||
"is_trial": is_trial,
|
||||
"auto_renew_enabled": bool(sub.auto_renew_enabled),
|
||||
"provider": sub.provider,
|
||||
"provider": provider,
|
||||
"is_throttled": bool(sub.is_throttled),
|
||||
}
|
||||
|
||||
@@ -143,12 +319,18 @@ def _payment_traffic_gb_split(payment: Payment) -> Tuple[Optional[float], Option
|
||||
return None, None
|
||||
|
||||
|
||||
def _payment_user_display_label(loaded_user: Any, payment_user_id: int) -> str:
|
||||
"""Human-facing name for payments tables: TG profile name, else email, else user id."""
|
||||
if loaded_user is None:
|
||||
return str(payment_user_id)
|
||||
def _user_display_label(
|
||||
loaded_user: Any,
|
||||
fallback_user_id: Optional[int],
|
||||
*,
|
||||
first_name: Optional[str] = None,
|
||||
last_name: Optional[str] = None,
|
||||
username: Optional[str] = None,
|
||||
email: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
"""Human-facing name: TG profile name, else email, else user id."""
|
||||
tid = getattr(loaded_user, "telegram_id", None)
|
||||
if tid is not None:
|
||||
if loaded_user is not None and tid is not None:
|
||||
fn = (getattr(loaded_user, "first_name", None) or "").strip()
|
||||
ln = (getattr(loaded_user, "last_name", None) or "").strip()
|
||||
full = f"{fn} {ln}".strip()
|
||||
@@ -157,10 +339,30 @@ def _payment_user_display_label(loaded_user: Any, payment_user_id: int) -> str:
|
||||
un = (getattr(loaded_user, "username", None) or "").strip()
|
||||
if un:
|
||||
return un if un.startswith("@") else f"@{un}"
|
||||
return str(payment_user_id)
|
||||
email = (getattr(loaded_user, "email", None) or "").strip()
|
||||
if email:
|
||||
return email
|
||||
elif loaded_user is not None:
|
||||
email = (getattr(loaded_user, "email", None) or "").strip()
|
||||
if email:
|
||||
return email
|
||||
fn = (first_name or "").strip()
|
||||
ln = (last_name or "").strip()
|
||||
full = f"{fn} {ln}".strip()
|
||||
if full:
|
||||
return full
|
||||
un = (username or "").strip()
|
||||
if un:
|
||||
return un if un.startswith("@") else f"@{un}"
|
||||
email_value = (email or "").strip()
|
||||
if email_value:
|
||||
return email_value
|
||||
if fallback_user_id is None:
|
||||
return None
|
||||
return str(fallback_user_id)
|
||||
|
||||
|
||||
def _payment_user_display_label(loaded_user: Any, payment_user_id: int) -> str:
|
||||
label = _user_display_label(loaded_user, payment_user_id)
|
||||
if label:
|
||||
return label
|
||||
return str(payment_user_id)
|
||||
|
||||
|
||||
@@ -229,15 +431,27 @@ def _serialize_ad(campaign: AdCampaign, totals: Optional[Dict[str, Any]] = None)
|
||||
|
||||
|
||||
def _serialize_log(entry: MessageLog) -> Dict[str, Any]:
|
||||
author_user = entry.__dict__.get("author_user")
|
||||
target_user = entry.__dict__.get("target_user")
|
||||
user_id = int(entry.user_id) if entry.user_id is not None else None
|
||||
target_user_id = int(entry.target_user_id) if entry.target_user_id is not None else None
|
||||
return {
|
||||
"log_id": int(entry.log_id),
|
||||
"user_id": int(entry.user_id) if entry.user_id else None,
|
||||
"user_id": user_id,
|
||||
"user_label": _user_display_label(
|
||||
author_user,
|
||||
user_id,
|
||||
first_name=entry.telegram_first_name,
|
||||
username=entry.telegram_username,
|
||||
),
|
||||
"telegram_username": entry.telegram_username,
|
||||
"telegram_first_name": entry.telegram_first_name,
|
||||
"email": getattr(author_user, "email", None),
|
||||
"event_type": entry.event_type,
|
||||
"content": entry.content,
|
||||
"is_admin_event": bool(entry.is_admin_event),
|
||||
"target_user_id": int(entry.target_user_id) if entry.target_user_id else None,
|
||||
"target_user_id": target_user_id,
|
||||
"target_user_label": _user_display_label(target_user, target_user_id),
|
||||
"timestamp": entry.timestamp.isoformat() if entry.timestamp else None,
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
from .auth import _require_admin_user_id
|
||||
from .common import _ok
|
||||
from bot.services.config_health_service import collect_config_alerts
|
||||
|
||||
|
||||
async def admin_health_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
refresh = str(request.query.get("refresh", "")).strip().lower() in {"1", "true", "yes"}
|
||||
alerts = await collect_config_alerts(request, refresh=refresh)
|
||||
return _ok(
|
||||
{
|
||||
"alerts": alerts,
|
||||
"checked_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
)
|
||||
@@ -32,6 +32,35 @@ async def admin_payments_list_route(request: web.Request) -> web.Response:
|
||||
)
|
||||
|
||||
|
||||
async def admin_payment_detail_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
|
||||
try:
|
||||
payment_id = int(request.match_info["payment_id"])
|
||||
except (TypeError, ValueError):
|
||||
return _error(400, "invalid_payment", "Invalid payment id")
|
||||
|
||||
async with async_session_factory() as session:
|
||||
payment = await payment_dal.get_payment_by_db_id(session, payment_id)
|
||||
if not payment:
|
||||
return _error(404, "not_found", "Payment not found")
|
||||
|
||||
payload = _serialize_payment(payment)
|
||||
payload.update(
|
||||
{
|
||||
"yookassa_payment_id": payment.yookassa_payment_id,
|
||||
"idempotence_key": payment.idempotence_key,
|
||||
"promo_code": (
|
||||
payment.promo_code_used.code if payment.promo_code_used is not None else None
|
||||
),
|
||||
"updated_at": payment.updated_at.isoformat() if payment.updated_at else None,
|
||||
}
|
||||
)
|
||||
|
||||
return _ok({"payment": payload})
|
||||
|
||||
|
||||
async def admin_payments_export_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
|
||||
@@ -6,15 +6,21 @@ def setup_admin_routes(app: web.Application) -> None:
|
||||
router = app.router
|
||||
router.add_get("/api/admin/me", admin_me_route)
|
||||
router.add_get("/api/admin/stats", admin_stats_route)
|
||||
router.add_get("/api/admin/health", admin_health_route)
|
||||
|
||||
router.add_get("/api/admin/users", admin_users_list_route)
|
||||
router.add_get("/api/admin/users/{user_id:-?\\d+}", admin_user_detail_route)
|
||||
router.add_get("/api/admin/users/{user_id:-?\\d+}/referrals", admin_user_referrals_route)
|
||||
router.add_get("/api/admin/users/{user_id:-?\\d+}/avatar", admin_user_avatar_route)
|
||||
router.add_post("/api/admin/users/{user_id:-?\\d+}/ban", admin_user_ban_route)
|
||||
router.add_post("/api/admin/users/{user_id:-?\\d+}/message", admin_user_message_route)
|
||||
router.add_post(
|
||||
"/api/admin/users/{user_id:-?\\d+}/message/preview", admin_user_message_preview_route
|
||||
)
|
||||
router.add_post(
|
||||
"/api/admin/users/{user_id:-?\\d+}/telegram-profile-link",
|
||||
admin_user_telegram_profile_link_route,
|
||||
)
|
||||
router.add_post("/api/admin/users/{user_id:-?\\d+}/reset-trial", admin_user_reset_trial_route)
|
||||
router.add_post("/api/admin/users/{user_id:-?\\d+}/extend", admin_user_extend_route)
|
||||
router.add_post(
|
||||
@@ -25,6 +31,10 @@ def setup_admin_routes(app: web.Application) -> None:
|
||||
"/api/admin/users/{user_id:-?\\d+}/regular-traffic-override",
|
||||
admin_user_regular_traffic_override_route,
|
||||
)
|
||||
router.add_post(
|
||||
"/api/admin/users/{user_id:-?\\d+}/hwid-device-limit",
|
||||
admin_user_hwid_device_limit_route,
|
||||
)
|
||||
router.add_post(
|
||||
"/api/admin/users/{user_id:-?\\d+}/traffic-grant",
|
||||
admin_user_traffic_grant_route,
|
||||
@@ -32,6 +42,7 @@ def setup_admin_routes(app: web.Application) -> None:
|
||||
router.add_delete("/api/admin/users/{user_id:-?\\d+}", admin_user_delete_route)
|
||||
|
||||
router.add_get("/api/admin/payments", admin_payments_list_route)
|
||||
router.add_get("/api/admin/payments/{payment_id:\\d+}", admin_payment_detail_route)
|
||||
router.add_get("/api/admin/payments/export.csv", admin_payments_export_route)
|
||||
|
||||
router.add_get("/api/admin/promos", admin_promos_list_route)
|
||||
@@ -41,6 +52,17 @@ def setup_admin_routes(app: web.Application) -> None:
|
||||
|
||||
router.add_get("/api/admin/logs", admin_logs_route)
|
||||
|
||||
router.add_get("/api/admin/support/tickets", admin_support_tickets_route)
|
||||
router.add_get("/api/admin/support/tickets/{id:\\d+}", admin_support_ticket_detail_route)
|
||||
router.add_post(
|
||||
"/api/admin/support/tickets/{id:\\d+}/messages",
|
||||
admin_support_ticket_reply_route,
|
||||
)
|
||||
router.add_patch("/api/admin/support/tickets/{id:\\d+}", admin_support_ticket_patch_route)
|
||||
router.add_post("/api/admin/support/tickets/{id:\\d+}/read", admin_support_ticket_read_route)
|
||||
router.add_get("/api/admin/support/stats", admin_support_stats_route)
|
||||
|
||||
router.add_get("/api/admin/broadcast/audience-counts", admin_broadcast_audience_counts_route)
|
||||
router.add_post("/api/admin/broadcast", admin_broadcast_route)
|
||||
router.add_post("/api/admin/sync", admin_sync_route)
|
||||
|
||||
@@ -51,6 +73,8 @@ def setup_admin_routes(app: web.Application) -> None:
|
||||
|
||||
router.add_get("/api/admin/settings", admin_settings_get_route)
|
||||
router.add_patch("/api/admin/settings", admin_settings_patch_route)
|
||||
router.add_get("/api/admin/translations", admin_translations_get_route)
|
||||
router.add_patch("/api/admin/translations", admin_translations_patch_route)
|
||||
|
||||
router.add_get("/api/admin/tariffs", admin_tariffs_get_route)
|
||||
router.add_put("/api/admin/tariffs", admin_tariffs_save_route)
|
||||
@@ -58,4 +82,8 @@ def setup_admin_routes(app: web.Application) -> None:
|
||||
router.add_put("/api/admin/themes", admin_themes_save_route)
|
||||
router.add_post("/api/admin/appearance/logo", admin_appearance_logo_upload_route)
|
||||
router.add_post("/api/admin/appearance/favicon", admin_appearance_favicon_upload_route)
|
||||
router.add_get("/api/admin/backups", admin_backups_list_route)
|
||||
router.add_post("/api/admin/backups/create", admin_backups_create_route)
|
||||
router.add_post("/api/admin/backups/upload", admin_backups_upload_route)
|
||||
router.add_post("/api/admin/backups/restore", admin_backups_restore_route)
|
||||
router.add_get("/api/admin/panel/internal-squads", admin_panel_internal_squads_route)
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
from .webapp_runtime import refresh_webapp_runtime_after_settings_change
|
||||
|
||||
from config.subscription_guides_config import (
|
||||
SubscriptionGuidesConfigError,
|
||||
subscription_guides_admin_config_json,
|
||||
)
|
||||
|
||||
|
||||
async def admin_settings_get_route(request: web.Request) -> web.Response:
|
||||
@@ -13,6 +19,7 @@ async def admin_settings_get_route(request: web.Request) -> web.Response:
|
||||
overrides_by_key = {entry["key"]: entry for entry in overrides}
|
||||
|
||||
fields = manifest_payload()
|
||||
webhook_base_url = str(settings.WEBHOOK_BASE_URL or "").strip().rstrip("/")
|
||||
sections: Dict[str, Dict[str, Any]] = {}
|
||||
for field in fields:
|
||||
key = field["key"]
|
||||
@@ -26,14 +33,35 @@ async def admin_settings_get_route(request: web.Request) -> web.Response:
|
||||
override = overrides_by_key.get(key)
|
||||
value = current_value(settings, key)
|
||||
is_secret = bool(field.get("secret"))
|
||||
overridden = bool(override)
|
||||
source = None
|
||||
read_error = None
|
||||
if key == "SUBSCRIPTION_PAGE_CONFIG_JSON":
|
||||
try:
|
||||
value, source = subscription_guides_admin_config_json(settings)
|
||||
overridden = source == "admin_json"
|
||||
except SubscriptionGuidesConfigError as exc:
|
||||
read_error = str(exc)
|
||||
response_field = {
|
||||
**field,
|
||||
"value": "" if is_secret else value,
|
||||
"overridden": bool(override),
|
||||
"overridden": overridden,
|
||||
"updated_at": override.get("updated_at") if override else None,
|
||||
}
|
||||
if source:
|
||||
response_field["source"] = source
|
||||
if read_error:
|
||||
response_field["read_error"] = read_error
|
||||
if is_secret:
|
||||
response_field["has_value"] = bool(value)
|
||||
webhook_path = str(response_field.get("webhook_path") or "").strip()
|
||||
if webhook_path:
|
||||
if not webhook_path.startswith("/"):
|
||||
webhook_path = f"/{webhook_path}"
|
||||
response_field["webhook_path"] = webhook_path
|
||||
response_field["webhook_base_url_configured"] = bool(webhook_base_url)
|
||||
if webhook_base_url:
|
||||
response_field["webhook_url"] = f"{webhook_base_url}{webhook_path}"
|
||||
sections[section_id]["fields"].append(response_field)
|
||||
|
||||
ordered_sections = sorted(sections.values(), key=lambda s: s["order"])
|
||||
@@ -51,6 +79,13 @@ async def admin_settings_patch_route(request: web.Request) -> web.Response:
|
||||
return _error(400, "invalid_updates")
|
||||
if not isinstance(deletes, list):
|
||||
return _error(400, "invalid_deletes")
|
||||
if (
|
||||
"SUBSCRIPTION_PAGE_CONFIG_JSON" in updates
|
||||
and not str(updates.get("SUBSCRIPTION_PAGE_CONFIG_JSON") or "").strip()
|
||||
):
|
||||
updates = dict(updates)
|
||||
updates.pop("SUBSCRIPTION_PAGE_CONFIG_JSON", None)
|
||||
deletes = [*deletes, "SUBSCRIPTION_PAGE_CONFIG_JSON"]
|
||||
|
||||
result = await update_overrides(
|
||||
settings,
|
||||
@@ -65,26 +100,6 @@ async def admin_settings_patch_route(request: web.Request) -> web.Response:
|
||||
status=400,
|
||||
)
|
||||
|
||||
# Bust the public webapp settings cache so users see new values immediately.
|
||||
cache = request.app.get("webapp_settings_cache")
|
||||
if isinstance(cache, dict):
|
||||
cache["ts"] = 0.0
|
||||
cache["data"] = {}
|
||||
if (
|
||||
"WEBAPP_LOGO_URL" in updates
|
||||
or "WEBAPP_LOGO_URL" in deletes
|
||||
or "WEBAPP_LOGO_USE_EMOJI" in updates
|
||||
or "WEBAPP_LOGO_USE_EMOJI" in deletes
|
||||
or "WEBAPP_FAVICON_URL" in updates
|
||||
or "WEBAPP_FAVICON_URL" in deletes
|
||||
or "WEBAPP_FAVICON_USE_CUSTOM" in updates
|
||||
or "WEBAPP_FAVICON_USE_CUSTOM" in deletes
|
||||
or "WEBAPP_LOGO_FAVICON_URL" in updates
|
||||
or "WEBAPP_LOGO_FAVICON_URL" in deletes
|
||||
):
|
||||
request.app["webapp_logo_cache"] = None
|
||||
from bot.app.web.admin_api_impl.themes import prune_unused_appearance_assets
|
||||
|
||||
prune_unused_appearance_assets(settings)
|
||||
await refresh_webapp_runtime_after_settings_change(request, updates=updates, deletes=deletes)
|
||||
|
||||
return _ok({"applied": result.get("applied", 0), "reverted": result.get("reverted", 0)})
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
import asyncio
|
||||
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
from .auth import _require_admin_user_id
|
||||
from .common import _ok, _serialize_payment
|
||||
from bot.utils.ttl_cache import AsyncTTLCache
|
||||
|
||||
_ADMIN_PANEL_STATS_CACHES: Dict[tuple[int, int], AsyncTTLCache] = {}
|
||||
_ADMIN_DB_STATS_CACHES: Dict[tuple[int, int], AsyncTTLCache] = {}
|
||||
|
||||
|
||||
async def admin_me_route(request: web.Request) -> web.Response:
|
||||
@@ -13,13 +21,44 @@ async def admin_stats_route(request: web.Request) -> web.Response:
|
||||
settings: Settings = request.app["settings"]
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
|
||||
payload = dict(await _load_admin_db_stats(settings, async_session_factory))
|
||||
|
||||
panel_service = request.app.get("panel_service")
|
||||
if panel_service is not None:
|
||||
payload["panel"] = await _load_admin_panel_stats(request, settings, panel_service)
|
||||
|
||||
queue_manager = get_queue_manager()
|
||||
if queue_manager:
|
||||
try:
|
||||
payload["queue"] = queue_manager.get_queue_stats()
|
||||
except Exception: # pragma: no cover - defensive
|
||||
payload["queue"] = None
|
||||
|
||||
payload["currency_symbol"] = default_payment_currency_code_for_settings(settings)
|
||||
return _ok(payload)
|
||||
|
||||
|
||||
async def _load_admin_db_stats(
|
||||
settings: Settings,
|
||||
async_session_factory: sessionmaker,
|
||||
) -> Dict[str, Any]:
|
||||
cache = _admin_db_stats_cache(settings)
|
||||
if cache is None:
|
||||
return await _load_admin_db_stats_uncached(async_session_factory)
|
||||
return await cache.get_or_load(
|
||||
"db",
|
||||
lambda: _load_admin_db_stats_uncached(async_session_factory),
|
||||
)
|
||||
|
||||
|
||||
async def _load_admin_db_stats_uncached(async_session_factory: sessionmaker) -> Dict[str, Any]:
|
||||
async with async_session_factory() as session:
|
||||
user_stats = await user_dal.get_enhanced_user_statistics(session)
|
||||
financial_stats = await payment_dal.get_financial_statistics(session)
|
||||
sync_status = await panel_sync_dal.get_panel_sync_status(session)
|
||||
recent_payments = await payment_dal.get_recent_payment_logs_with_user(session, limit=10)
|
||||
|
||||
payload = {
|
||||
return {
|
||||
"users": user_stats,
|
||||
"financial": financial_stats,
|
||||
"panel_sync": {
|
||||
@@ -34,36 +73,78 @@ async def admin_stats_route(request: web.Request) -> web.Response:
|
||||
"recent_payments": [_serialize_payment(p) for p in recent_payments],
|
||||
}
|
||||
|
||||
panel_service = request.app.get("panel_service")
|
||||
if panel_service is not None:
|
||||
try:
|
||||
system = await panel_service.get_system_stats()
|
||||
bandwidth = await panel_service.get_bandwidth_stats()
|
||||
panel_body: Dict[str, Any] = {
|
||||
"system": system or {},
|
||||
"bandwidth": bandwidth or {},
|
||||
}
|
||||
try:
|
||||
nodes = await panel_service.get_nodes_statistics()
|
||||
panel_body["nodes"] = nodes or {}
|
||||
except Exception as exc_nodes: # pragma: no cover - optional endpoint
|
||||
logger.debug("Panel nodes stats unavailable: %s", exc_nodes)
|
||||
panel_body["nodes"] = {}
|
||||
try:
|
||||
today = datetime.now(timezone.utc).date()
|
||||
start_d = today - timedelta(days=7)
|
||||
nodes_bw = await panel_service.get_nodes_bandwidth_usage(
|
||||
|
||||
def _admin_db_stats_cache(settings: Settings) -> Optional[AsyncTTLCache]:
|
||||
ttl_seconds = int(getattr(settings, "ADMIN_DB_STATS_CACHE_TTL_SECONDS", 5) or 0)
|
||||
if ttl_seconds <= 0:
|
||||
return None
|
||||
cache_key = (id(settings), ttl_seconds)
|
||||
cache = _ADMIN_DB_STATS_CACHES.get(cache_key)
|
||||
if cache is None:
|
||||
cache = AsyncTTLCache(
|
||||
ttl_seconds=ttl_seconds,
|
||||
settings=settings,
|
||||
namespace="admin:db_stats",
|
||||
)
|
||||
_ADMIN_DB_STATS_CACHES[cache_key] = cache
|
||||
return cache
|
||||
|
||||
|
||||
async def _load_admin_panel_stats(
|
||||
request: web.Request,
|
||||
settings: Settings,
|
||||
panel_service,
|
||||
) -> Dict[str, Any]:
|
||||
cache = _admin_panel_stats_cache(settings)
|
||||
if cache is None:
|
||||
return await _load_admin_panel_stats_uncached(panel_service)
|
||||
return await cache.get_or_load("panel", lambda: _load_admin_panel_stats_uncached(panel_service))
|
||||
|
||||
|
||||
def _admin_panel_stats_cache(settings: Settings) -> Optional[AsyncTTLCache]:
|
||||
ttl_seconds = int(getattr(settings, "ADMIN_PANEL_STATS_CACHE_TTL_SECONDS", 15) or 0)
|
||||
if ttl_seconds <= 0:
|
||||
return None
|
||||
cache_key = (id(settings), ttl_seconds)
|
||||
cache = _ADMIN_PANEL_STATS_CACHES.get(cache_key)
|
||||
if cache is None:
|
||||
cache = AsyncTTLCache(
|
||||
ttl_seconds=ttl_seconds,
|
||||
settings=settings,
|
||||
namespace="admin:panel_stats",
|
||||
)
|
||||
_ADMIN_PANEL_STATS_CACHES[cache_key] = cache
|
||||
return cache
|
||||
|
||||
|
||||
async def _load_admin_panel_stats_uncached(panel_service) -> Dict[str, Any]:
|
||||
try:
|
||||
today = datetime.now(timezone.utc).date()
|
||||
start_d = today - timedelta(days=7)
|
||||
system, bandwidth, nodes, nodes_bw, lookups = await asyncio.gather(
|
||||
_safe_panel_call(panel_service.get_system_stats(), "system stats"),
|
||||
_safe_panel_call(panel_service.get_bandwidth_stats(), "bandwidth stats"),
|
||||
_safe_panel_call(panel_service.get_nodes_statistics(), "nodes stats"),
|
||||
_safe_panel_call(
|
||||
panel_service.get_nodes_bandwidth_usage(
|
||||
start=start_d.isoformat(),
|
||||
end=today.isoformat(),
|
||||
top_nodes_limit=64,
|
||||
)
|
||||
panel_body["nodes_bandwidth"] = nodes_bw or {}
|
||||
except Exception as exc_nb: # pragma: no cover - optional endpoint
|
||||
logger.debug("Panel nodes bandwidth range unavailable: %s", exc_nb)
|
||||
panel_body["nodes_bandwidth"] = {}
|
||||
),
|
||||
"nodes bandwidth range",
|
||||
),
|
||||
_safe_panel_call(panel_service.get_nodes_online_lookups(), "nodes online lookups"),
|
||||
)
|
||||
|
||||
panel_body: Dict[str, Any] = {
|
||||
"system": system or {},
|
||||
"bandwidth": bandwidth or {},
|
||||
"nodes": nodes or {},
|
||||
"nodes_bandwidth": nodes_bw or {},
|
||||
}
|
||||
if isinstance(lookups, dict):
|
||||
try:
|
||||
online_map = _panel_nodes_online_by_uuid(panel_body.get("nodes"))
|
||||
lookups = await panel_service.get_nodes_online_lookups()
|
||||
for k, v in lookups.get("byUuid", {}).items():
|
||||
online_map[k] = v
|
||||
_enrich_bandwidth_nodes_with_online(
|
||||
@@ -73,17 +154,15 @@ async def admin_stats_route(request: web.Request) -> web.Response:
|
||||
)
|
||||
except Exception as exc_merge: # pragma: no cover
|
||||
logger.debug("Panel nodes online merge skipped: %s", exc_merge)
|
||||
payload["panel"] = panel_body
|
||||
except Exception as exc:
|
||||
logger.debug("Panel stats unavailable: %s", exc)
|
||||
payload["panel"] = {"error": "unavailable"}
|
||||
return panel_body
|
||||
except Exception as exc:
|
||||
logger.debug("Panel stats unavailable: %s", exc)
|
||||
return {"error": "unavailable"}
|
||||
|
||||
queue_manager = get_queue_manager()
|
||||
if queue_manager:
|
||||
try:
|
||||
payload["queue"] = queue_manager.get_queue_stats()
|
||||
except Exception: # pragma: no cover - defensive
|
||||
payload["queue"] = None
|
||||
|
||||
payload["currency_symbol"] = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
|
||||
return _ok(payload)
|
||||
async def _safe_panel_call(awaitable, label: str) -> Any:
|
||||
try:
|
||||
return await awaitable
|
||||
except Exception as exc: # pragma: no cover - optional panel endpoints
|
||||
logger.debug("Panel %s unavailable: %s", label, exc)
|
||||
return None
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
from typing import Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, constr, field_validator
|
||||
|
||||
from bot.services.support_service import TicketNotFound
|
||||
from db.dal import support_dal, user_dal
|
||||
from db.models import SupportTicket, SupportTicketMessage
|
||||
|
||||
|
||||
class AdminTicketReplyPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
body: constr(min_length=1, max_length=4000)
|
||||
is_internal_note: bool = False
|
||||
|
||||
@field_validator("body")
|
||||
@classmethod
|
||||
def _strip_body(cls, value: str) -> str:
|
||||
stripped = value.strip()
|
||||
if not stripped:
|
||||
raise ValueError("empty_text")
|
||||
return stripped
|
||||
|
||||
|
||||
class AdminTicketPatchPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
status: Optional[Literal["open", "awaiting_user", "awaiting_admin", "resolved", "closed"]] = (
|
||||
None
|
||||
)
|
||||
priority: Optional[Literal["low", "normal", "high", "urgent"]] = None
|
||||
category: Optional[Literal["billing", "technical", "account", "other"]] = None
|
||||
assigned_admin_id: Optional[int] = None
|
||||
|
||||
|
||||
def _validate_model_payload(model_cls, payload: Dict[str, Any]):
|
||||
try:
|
||||
return model_cls.model_validate(payload), None
|
||||
except ValidationError:
|
||||
return None, _error(400, "invalid_request", "Invalid request")
|
||||
|
||||
|
||||
def _support_ticket_payload(ticket: SupportTicket) -> Dict[str, Any]:
|
||||
return {
|
||||
"ticket_id": ticket.ticket_id,
|
||||
"user_id": ticket.user_id,
|
||||
"subject": ticket.subject,
|
||||
"category": ticket.category,
|
||||
"priority": ticket.priority,
|
||||
"status": ticket.status,
|
||||
"assigned_admin_id": ticket.assigned_admin_id,
|
||||
"last_message_at": ticket.last_message_at.isoformat() if ticket.last_message_at else None,
|
||||
"last_message_role": ticket.last_message_role,
|
||||
"unread_user_count": int(ticket.unread_user_count or 0),
|
||||
"unread_admin_count": int(ticket.unread_admin_count or 0),
|
||||
"created_at": ticket.created_at.isoformat() if ticket.created_at else None,
|
||||
"updated_at": ticket.updated_at.isoformat() if ticket.updated_at else None,
|
||||
"closed_at": ticket.closed_at.isoformat() if ticket.closed_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _user_display_name(user) -> Optional[str]:
|
||||
if not user:
|
||||
return None
|
||||
name = " ".join(
|
||||
part.strip() for part in [user.first_name, user.last_name] if part and part.strip()
|
||||
).strip()
|
||||
return name or user.username or user.email or str(user.user_id)
|
||||
|
||||
|
||||
def _support_message_payload(
|
||||
message: SupportTicketMessage,
|
||||
*,
|
||||
authors: Optional[Dict[int, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
author = authors.get(message.author_user_id) if authors and message.author_user_id else None
|
||||
return {
|
||||
"message_id": message.message_id,
|
||||
"ticket_id": message.ticket_id,
|
||||
"author_role": message.author_role,
|
||||
"author_user_id": message.author_user_id,
|
||||
"author_name": _user_display_name(author),
|
||||
"body": message.body,
|
||||
"is_internal_note": bool(message.is_internal_note),
|
||||
"created_at": message.created_at.isoformat() if message.created_at else None,
|
||||
"read_by_user_at": message.read_by_user_at.isoformat() if message.read_by_user_at else None,
|
||||
"read_by_admin_at": message.read_by_admin_at.isoformat()
|
||||
if message.read_by_admin_at
|
||||
else None,
|
||||
}
|
||||
|
||||
|
||||
def _admin_support_user_payload(user) -> Dict[str, Any]:
|
||||
if not user:
|
||||
return {}
|
||||
return {
|
||||
"user_id": user.user_id,
|
||||
"telegram_id": user.telegram_id,
|
||||
"username": user.username,
|
||||
"first_name": user.first_name,
|
||||
"last_name": user.last_name,
|
||||
"email": user.email,
|
||||
"telegram_photo_url": user.telegram_photo_url,
|
||||
"is_banned": bool(user.is_banned),
|
||||
"registration_date": user.registration_date.isoformat() if user.registration_date else None,
|
||||
}
|
||||
|
||||
|
||||
def _support_limit_offset(request: web.Request) -> tuple[int, int]:
|
||||
limit = max(1, min(100, int(request.query.get("limit", 25) or 25)))
|
||||
offset = max(0, int(request.query.get("offset", 0) or 0))
|
||||
return limit, offset
|
||||
|
||||
|
||||
async def admin_support_tickets_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
limit, offset = _support_limit_offset(request)
|
||||
assigned_raw = request.query.get("assigned")
|
||||
assigned_admin_id = None
|
||||
if assigned_raw and assigned_raw not in {"all", "any"}:
|
||||
assigned_admin_id = int(assigned_raw)
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
tickets = await support_dal.list_admin_tickets(
|
||||
session,
|
||||
status=request.query.get("status") or None,
|
||||
priority=request.query.get("priority") or None,
|
||||
category=request.query.get("category") or None,
|
||||
assigned_admin_id=assigned_admin_id,
|
||||
search=request.query.get("search") or None,
|
||||
sort=request.query.get("sort") or "updated_desc",
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": True,
|
||||
"tickets": [
|
||||
{
|
||||
**_support_ticket_payload(ticket),
|
||||
"user": _admin_support_user_payload(getattr(ticket, "user", None)),
|
||||
}
|
||||
for ticket in tickets
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def admin_support_ticket_detail_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
ticket_id = int(request.match_info["id"])
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
service = request.app["support_service"]
|
||||
async with async_session_factory() as session:
|
||||
ticket, messages = await support_dal.get_ticket(session, ticket_id, include_internal=True)
|
||||
if not ticket:
|
||||
return _error(404, "not_found", "Ticket not found")
|
||||
user = await user_dal.get_user_by_id(session, ticket.user_id)
|
||||
snapshot = await service.build_user_snapshot(user, session=session) if user else {}
|
||||
author_ids = {m.author_user_id for m in messages if m.author_user_id is not None}
|
||||
authors = {}
|
||||
for author_id in author_ids:
|
||||
author = await user_dal.get_user_by_id(session, author_id)
|
||||
if author:
|
||||
authors[author_id] = author
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": True,
|
||||
"ticket": {
|
||||
**_support_ticket_payload(ticket),
|
||||
"user": _admin_support_user_payload(user),
|
||||
},
|
||||
"messages": [_support_message_payload(m, authors=authors) for m in messages],
|
||||
"user_snapshot": snapshot,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def admin_support_ticket_reply_route(request: web.Request) -> web.Response:
|
||||
admin_id = _require_admin_user_id(request)
|
||||
ticket_id = int(request.match_info["id"])
|
||||
payload, error = _validate_model_payload(AdminTicketReplyPayload, await _read_json(request))
|
||||
if error:
|
||||
return error
|
||||
try:
|
||||
ticket, message = await request.app["support_service"].reply_as_admin(
|
||||
admin_id,
|
||||
ticket_id,
|
||||
payload.body,
|
||||
is_internal_note=payload.is_internal_note,
|
||||
)
|
||||
except TicketNotFound:
|
||||
return _error(404, "not_found", "Ticket not found")
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
admin = await user_dal.get_user_by_id(session, admin_id)
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": True,
|
||||
"ticket": _support_ticket_payload(ticket),
|
||||
"message": _support_message_payload(
|
||||
message, authors={admin_id: admin} if admin else {}
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def admin_support_ticket_patch_route(request: web.Request) -> web.Response:
|
||||
admin_id = _require_admin_user_id(request)
|
||||
ticket_id = int(request.match_info["id"])
|
||||
payload, error = _validate_model_payload(AdminTicketPatchPayload, await _read_json(request))
|
||||
if error:
|
||||
return error
|
||||
updates = payload.model_dump(exclude_unset=True)
|
||||
try:
|
||||
if updates.get("status") == "closed":
|
||||
ticket = await request.app["support_service"].close_ticket(admin_id, ticket_id)
|
||||
updates.pop("status", None)
|
||||
if updates:
|
||||
ticket = await request.app["support_service"]._update_and_audit(
|
||||
admin_id,
|
||||
ticket_id,
|
||||
**updates,
|
||||
)
|
||||
else:
|
||||
ticket = await request.app["support_service"]._update_and_audit(
|
||||
admin_id,
|
||||
ticket_id,
|
||||
**updates,
|
||||
)
|
||||
except TicketNotFound:
|
||||
return _error(404, "not_found", "Ticket not found")
|
||||
return web.json_response({"ok": True, "ticket": _support_ticket_payload(ticket)})
|
||||
|
||||
|
||||
async def admin_support_ticket_read_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
ticket_id = int(request.match_info["id"])
|
||||
await request.app["support_service"].mark_read_as_admin(ticket_id)
|
||||
return web.json_response({"ok": True})
|
||||
|
||||
|
||||
async def admin_support_stats_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
stats = await support_dal.admin_stats(session)
|
||||
return web.json_response({"ok": True, "stats": stats})
|
||||
@@ -1,5 +1,6 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
from .webapp_runtime import refresh_webapp_runtime_after_settings_change
|
||||
|
||||
|
||||
async def admin_tariffs_get_route(request: web.Request) -> web.Response:
|
||||
@@ -20,9 +21,14 @@ async def admin_tariffs_get_route(request: web.Request) -> web.Response:
|
||||
"path": str(path),
|
||||
"catalog": {
|
||||
"default_tariff": "",
|
||||
"default_currency": "rub",
|
||||
"topup_packages_default": {"rub": [], "stars": []},
|
||||
"tariffs": [],
|
||||
},
|
||||
"provider_currency_support": _provider_currency_support_payload(
|
||||
settings,
|
||||
request.app,
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -31,6 +37,7 @@ async def admin_tariffs_get_route(request: web.Request) -> web.Response:
|
||||
"exists": True,
|
||||
"path": str(path),
|
||||
"catalog": _tariffs_config_payload(config),
|
||||
"provider_currency_support": _provider_currency_support_payload(settings, request.app),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -55,9 +62,53 @@ async def admin_tariffs_save_route(request: web.Request) -> web.Response:
|
||||
logger.exception("Failed to write tariffs config to %s", path)
|
||||
return _error(500, "write_failed", str(exc))
|
||||
|
||||
cache = request.app.get("webapp_settings_cache")
|
||||
if isinstance(cache, dict):
|
||||
cache["ts"] = 0.0
|
||||
cache["data"] = {}
|
||||
await refresh_webapp_runtime_after_settings_change(request, updates={}, deletes=[])
|
||||
|
||||
return _ok({"exists": True, "path": str(path), "catalog": _tariffs_config_payload(config)})
|
||||
return _ok(
|
||||
{
|
||||
"exists": True,
|
||||
"path": str(path),
|
||||
"catalog": _tariffs_config_payload(config),
|
||||
"provider_currency_support": _provider_currency_support_payload(settings, request.app),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _provider_currency_support_payload(
|
||||
settings: Settings,
|
||||
app: web.Application,
|
||||
) -> List[Dict[str, Any]]:
|
||||
from bot.payment_providers import iter_provider_specs, resolve_provider_presentation
|
||||
|
||||
default_currency = default_payment_currency_code_for_settings(settings)
|
||||
providers: List[Dict[str, Any]] = []
|
||||
for spec in iter_provider_specs():
|
||||
presentation = resolve_provider_presentation(spec, settings)
|
||||
supported = spec.supported_currency_codes(settings)
|
||||
providers.append(
|
||||
{
|
||||
"id": spec.id,
|
||||
"provider_key": spec.provider_key,
|
||||
"label": presentation.webapp_label or spec.label,
|
||||
"telegram_label": presentation.telegram_label,
|
||||
"icon": presentation.webapp_icon,
|
||||
"enabled": spec.is_effectively_enabled(settings),
|
||||
"configured": spec.is_service_configured(app),
|
||||
"admin_only": spec.is_admin_only_enabled(settings),
|
||||
"price_source": spec.price_source,
|
||||
"currencies": list(supported) if supported is not None else None,
|
||||
"accepts_any_currency": supported is None,
|
||||
"supports_default_currency": spec.is_usable_for_payment_currency(
|
||||
settings,
|
||||
default_currency,
|
||||
),
|
||||
"directly_supports_default_currency": spec.supports_currency(
|
||||
settings,
|
||||
default_currency,
|
||||
),
|
||||
"default_currency": default_currency,
|
||||
"note": spec.currency_support_note,
|
||||
"docs_url": spec.currency_support_url,
|
||||
}
|
||||
)
|
||||
return providers
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
from .webapp_runtime import refresh_webapp_runtime_after_settings_change
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
@@ -24,7 +25,6 @@ WEBAPP_UPLOADED_LOGO_DIR = Path(__file__).resolve().parents[5] / "data" / "webap
|
||||
WEBAPP_UPLOADED_LOGO_PATH = "/webapp-uploaded-logo"
|
||||
WEBAPP_FAVICON_DIR = Path(__file__).resolve().parents[5] / "data" / "webapp-logo" / "favicons"
|
||||
WEBAPP_FAVICON_PATH = "/webapp-favicon"
|
||||
WEBAPP_EMOJI_CACHE_DIR = Path(__file__).resolve().parents[5] / "data" / "webapp-emoji"
|
||||
WEBAPP_FAVICON_SIZES = (16, 32, 48, 180, 192, 512)
|
||||
WEBAPP_LOGO_UPLOAD_CONTENT_TYPES = {
|
||||
".gif": "image/gif",
|
||||
@@ -37,6 +37,44 @@ WEBAPP_LOGO_UPLOAD_CONTENT_TYPES = {
|
||||
}
|
||||
|
||||
|
||||
def _theme_payload_for_version_compare(theme: Any) -> Dict[str, Any]:
|
||||
if hasattr(theme, "model_dump"):
|
||||
data = theme.model_dump(mode="json", exclude_none=True)
|
||||
elif isinstance(theme, dict):
|
||||
data = dict(theme)
|
||||
else:
|
||||
data = {}
|
||||
data.pop("assets_version", None)
|
||||
data.pop("default", None)
|
||||
return data
|
||||
|
||||
|
||||
def _bump_theme_asset_versions(
|
||||
config: WebappThemesConfig,
|
||||
previous: WebappThemesConfig,
|
||||
) -> WebappThemesConfig:
|
||||
previous_by_key = {theme.key: theme for theme in previous.themes}
|
||||
default_changed = config.default_theme != previous.default_theme
|
||||
data = config.model_dump(mode="json", exclude_none=True)
|
||||
for theme in data.get("themes", []):
|
||||
if not isinstance(theme, dict):
|
||||
continue
|
||||
if not str(theme.get("css_file") or "").strip():
|
||||
continue
|
||||
key = str(theme.get("key") or "")
|
||||
previous_theme = previous_by_key.get(key)
|
||||
previous_version = int(getattr(previous_theme, "assets_version", 0) or 0)
|
||||
current_version = int(theme.get("assets_version") or 1)
|
||||
theme_changed = previous_theme is None or _theme_payload_for_version_compare(
|
||||
theme
|
||||
) != _theme_payload_for_version_compare(previous_theme)
|
||||
if theme_changed or (default_changed and key == config.default_theme):
|
||||
theme["assets_version"] = max(previous_version + 1, current_version, 1)
|
||||
elif previous_version > current_version:
|
||||
theme["assets_version"] = previous_version
|
||||
return WebappThemesConfig.model_validate(data)
|
||||
|
||||
|
||||
def _detect_logo_extension(
|
||||
body: bytes, content_type: str = "", filename: str = ""
|
||||
) -> Optional[str]:
|
||||
@@ -97,10 +135,6 @@ def _favicon_digest(url: str) -> Optional[str]:
|
||||
return match.group(1) if match else None
|
||||
|
||||
|
||||
def _emoji_to_codepoints(value: str) -> str:
|
||||
return "_".join(f"{ord(char):x}" for char in str(value or "").strip())
|
||||
|
||||
|
||||
def prune_unused_appearance_assets(settings: Settings) -> None:
|
||||
keep_logos = {
|
||||
filename
|
||||
@@ -117,15 +151,6 @@ def prune_unused_appearance_assets(settings: Settings) -> None:
|
||||
]
|
||||
if digest
|
||||
}
|
||||
keep_emoji_prefixes = set()
|
||||
if (
|
||||
getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False)
|
||||
and str(getattr(settings, "WEBAPP_LOGO_EMOJI_FONT", "") or "").strip()
|
||||
== "noto-color-animated"
|
||||
):
|
||||
codepoints = _emoji_to_codepoints(getattr(settings, "WEBAPP_LOGO_EMOJI", ""))
|
||||
if codepoints:
|
||||
keep_emoji_prefixes.add(f"{codepoints}.512.")
|
||||
|
||||
for path in WEBAPP_UPLOADED_LOGO_DIR.glob("logo-*"):
|
||||
if path.is_file() and path.name not in keep_logos:
|
||||
@@ -145,15 +170,6 @@ def prune_unused_appearance_assets(settings: Settings) -> None:
|
||||
except OSError:
|
||||
logger.warning("Failed to remove unused webapp favicon set %s", path, exc_info=True)
|
||||
|
||||
for path in WEBAPP_EMOJI_CACHE_DIR.glob("*.512.*"):
|
||||
if path.is_file() and not any(
|
||||
path.name.startswith(prefix) for prefix in keep_emoji_prefixes
|
||||
):
|
||||
try:
|
||||
path.unlink()
|
||||
except OSError:
|
||||
logger.warning("Failed to remove unused webapp emoji asset %s", path, exc_info=True)
|
||||
|
||||
|
||||
async def _persist_appearance_upload(
|
||||
request: web.Request,
|
||||
@@ -173,12 +189,7 @@ async def _persist_appearance_upload(
|
||||
logger.warning("Failed to persist uploaded appearance asset settings: %s", result)
|
||||
return False
|
||||
|
||||
cache = request.app.get("webapp_settings_cache")
|
||||
if isinstance(cache, dict):
|
||||
cache["ts"] = 0.0
|
||||
cache["data"] = {}
|
||||
request.app["webapp_logo_cache"] = None
|
||||
prune_unused_appearance_assets(settings)
|
||||
await refresh_webapp_runtime_after_settings_change(request, updates=updates, deletes=[])
|
||||
return True
|
||||
|
||||
|
||||
@@ -355,7 +366,6 @@ async def admin_appearance_logo_upload_route(request: web.Request) -> web.Respon
|
||||
request,
|
||||
{
|
||||
"WEBAPP_LOGO_URL": logo_url,
|
||||
"WEBAPP_LOGO_USE_EMOJI": False,
|
||||
**(
|
||||
{"WEBAPP_LOGO_FAVICON_URL": favicon_payload["favicon_url"]}
|
||||
if favicon_payload.get("favicon_url")
|
||||
@@ -420,6 +430,11 @@ async def admin_themes_get_route(request: web.Request) -> web.Response:
|
||||
async def admin_themes_save_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
settings: Settings = request.app["settings"]
|
||||
previous_config = resolved_webapp_themes_catalog(
|
||||
primary_accent=settings.WEBAPP_PRIMARY_COLOR or "#00fe7a",
|
||||
env_default_theme=settings.WEBAPP_DEFAULT_THEME,
|
||||
theme_dir=settings.WEBAPP_THEMES_DIR,
|
||||
)
|
||||
payload = await _read_json(request)
|
||||
catalog = payload.get("catalog") if "catalog" in payload else payload
|
||||
if not isinstance(catalog, dict):
|
||||
@@ -431,6 +446,7 @@ async def admin_themes_save_route(request: web.Request) -> web.Response:
|
||||
return _error(400, "invalid_webapp_themes_config", str(exc))
|
||||
|
||||
config, _changed = ensure_webapp_core_themes(config, settings.WEBAPP_PRIMARY_COLOR or "#00fe7a")
|
||||
config = _bump_theme_asset_versions(config, previous_config)
|
||||
|
||||
try:
|
||||
write_webapp_theme_dir(settings.WEBAPP_THEMES_DIR, config, delete_missing=True)
|
||||
@@ -438,10 +454,7 @@ async def admin_themes_save_route(request: web.Request) -> web.Response:
|
||||
logger.exception("Failed to write webapp themes to %s", settings.WEBAPP_THEMES_DIR)
|
||||
return _error(500, "write_failed", str(exc))
|
||||
|
||||
cache = request.app.get("webapp_settings_cache")
|
||||
if isinstance(cache, dict):
|
||||
cache["ts"] = 0.0
|
||||
cache["data"] = {}
|
||||
await refresh_webapp_runtime_after_settings_change(request, updates={}, deletes=[])
|
||||
|
||||
return _ok(
|
||||
{
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
from bot.middlewares.i18n import JsonI18n, locale_language_options, resolve_locale_key
|
||||
from bot.services.locale_override_service import (
|
||||
LOCALE_OVERRIDES_PATH,
|
||||
audience_for_locale_key,
|
||||
group_id_for_locale_key,
|
||||
locale_group_catalog,
|
||||
load_locale_overrides,
|
||||
update_locale_overrides,
|
||||
)
|
||||
|
||||
|
||||
def _locale_languages(
|
||||
i18n: JsonI18n,
|
||||
overrides: Optional[List[Dict[str, Any]]] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
base_languages = set((i18n.base_locales_data or {}).keys())
|
||||
override_languages = {str(entry.get("lang") or "") for entry in overrides or []}
|
||||
override_languages.update((i18n.locale_overrides or {}).keys())
|
||||
return locale_language_options(
|
||||
base_languages | override_languages,
|
||||
base_languages=base_languages,
|
||||
)
|
||||
|
||||
|
||||
def _locale_override_meta_map(overrides: List[Dict[str, Any]]) -> Dict[Tuple[str, str], Dict]:
|
||||
result: Dict[Tuple[str, str], Dict] = {}
|
||||
for entry in overrides:
|
||||
lang = str(entry.get("lang") or "")
|
||||
raw_key = str(entry.get("key") or "")
|
||||
key = resolve_locale_key(raw_key)
|
||||
if lang and key:
|
||||
if raw_key != key and (lang, key) in result:
|
||||
continue
|
||||
result[(lang, key)] = entry
|
||||
return result
|
||||
|
||||
|
||||
def _admin_translations_payload(
|
||||
i18n: JsonI18n,
|
||||
overrides: List[Dict[str, Any]],
|
||||
) -> Dict[str, Any]:
|
||||
base_data = i18n.base_locales_data or i18n.locales_data or {}
|
||||
effective_data = i18n.locales_data or {}
|
||||
override_meta = _locale_override_meta_map(overrides)
|
||||
language_items = _locale_languages(i18n, overrides)
|
||||
languages = [item["code"] for item in language_items]
|
||||
all_keys = sorted(
|
||||
{key for messages in base_data.values() for key in messages.keys()}
|
||||
| {key for _, key in override_meta.keys()}
|
||||
)
|
||||
|
||||
groups_by_id = {
|
||||
group["id"]: {
|
||||
**group,
|
||||
"items": [],
|
||||
}
|
||||
for group in locale_group_catalog()
|
||||
}
|
||||
|
||||
for key in all_keys:
|
||||
values: Dict[str, Dict[str, Any]] = {}
|
||||
for lang in languages:
|
||||
meta = override_meta.get((lang, key))
|
||||
fallback_base = base_data.get(i18n.default_lang, {}).get(key, "")
|
||||
values[lang] = {
|
||||
"base": base_data.get(lang, {}).get(key, ""),
|
||||
"fallback": fallback_base,
|
||||
"effective": effective_data.get(lang, {}).get(key, ""),
|
||||
"override": meta.get("value") if meta else "",
|
||||
"overridden": bool(meta),
|
||||
"updated_at": meta.get("updated_at") if meta else None,
|
||||
"updated_by": meta.get("updated_by") if meta else None,
|
||||
}
|
||||
group_id = group_id_for_locale_key(key)
|
||||
groups_by_id.setdefault(
|
||||
group_id,
|
||||
{"id": group_id, "title": group_id, "description": "", "items": []},
|
||||
)
|
||||
groups_by_id[group_id]["items"].append(
|
||||
{
|
||||
"key": key,
|
||||
"audience": audience_for_locale_key(key),
|
||||
"values": values,
|
||||
}
|
||||
)
|
||||
|
||||
groups = [group for group in groups_by_id.values() if group["items"]]
|
||||
return {
|
||||
"languages": language_items,
|
||||
"groups": groups,
|
||||
"path": str(LOCALE_OVERRIDES_PATH),
|
||||
"override_count": len(overrides),
|
||||
}
|
||||
|
||||
|
||||
async def admin_translations_get_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
i18n: Optional[JsonI18n] = request.app.get("i18n")
|
||||
if i18n is None:
|
||||
return _error(503, "i18n_unavailable")
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
|
||||
await load_locale_overrides(i18n, async_session_factory)
|
||||
async with async_session_factory() as session:
|
||||
overrides = await locale_overrides_dal.get_overrides_with_meta(session)
|
||||
|
||||
return _ok(_admin_translations_payload(i18n, overrides))
|
||||
|
||||
|
||||
async def admin_translations_patch_route(request: web.Request) -> web.Response:
|
||||
actor_id = _require_admin_user_id(request)
|
||||
i18n: Optional[JsonI18n] = request.app.get("i18n")
|
||||
if i18n is None:
|
||||
return _error(503, "i18n_unavailable")
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
payload = await _read_json(request)
|
||||
updates = payload.get("updates") or {}
|
||||
deletes = payload.get("deletes") or []
|
||||
if not isinstance(updates, dict):
|
||||
return _error(400, "invalid_updates")
|
||||
if not isinstance(deletes, list):
|
||||
return _error(400, "invalid_deletes")
|
||||
|
||||
result = await update_locale_overrides(
|
||||
i18n,
|
||||
async_session_factory,
|
||||
updates=updates,
|
||||
deletes=deletes,
|
||||
actor_id=actor_id,
|
||||
)
|
||||
if not result.get("ok"):
|
||||
return web.json_response(
|
||||
{"ok": False, "error": "validation_failed", "errors": result.get("errors", {})},
|
||||
status=400,
|
||||
)
|
||||
|
||||
return _ok(
|
||||
{
|
||||
"applied": result.get("applied", 0),
|
||||
"reverted": result.get("reverted", 0),
|
||||
"file_written": result.get("file_written", False),
|
||||
}
|
||||
)
|
||||
@@ -1,9 +1,34 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
from .auth import _require_admin_user_id
|
||||
from .common import (
|
||||
_build_admin_webapp_referral_link,
|
||||
_error,
|
||||
_ok,
|
||||
_panel_user_connection_activity,
|
||||
_premium_traffic_list_payload,
|
||||
_read_json,
|
||||
_serialize_payment,
|
||||
_serialize_subscription,
|
||||
_serialize_user,
|
||||
)
|
||||
|
||||
import hashlib
|
||||
from html import escape as html_escape
|
||||
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from sqlalchemy.orm import aliased
|
||||
|
||||
from bot.app.web.webapp.cache_helpers import invalidate_webapp_user_caches
|
||||
from bot.infra.redis import cache_delete_pattern, redis_key
|
||||
from bot.utils.ttl_cache import AsyncTTLCache
|
||||
|
||||
_ADMIN_USERS_LIST_CACHES: Dict[tuple[int, int], AsyncTTLCache] = {}
|
||||
|
||||
|
||||
async def admin_users_list_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
settings: Settings = request.app["settings"]
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
|
||||
page = max(0, int(request.query.get("page", 0) or 0))
|
||||
@@ -14,6 +39,79 @@ async def admin_users_list_route(request: web.Request) -> web.Response:
|
||||
premium_traffic = (request.query.get("premium_traffic") or "all").lower()
|
||||
sort_value = (request.query.get("sort") or "registered_desc").lower()
|
||||
|
||||
payload = await _load_admin_users_list_payload(
|
||||
settings,
|
||||
async_session_factory,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
query=query,
|
||||
filter_value=filter_value,
|
||||
panel_status=panel_status,
|
||||
premium_traffic=premium_traffic,
|
||||
sort_value=sort_value,
|
||||
)
|
||||
return _ok(payload)
|
||||
|
||||
|
||||
async def _load_admin_users_list_payload(
|
||||
settings: Settings,
|
||||
async_session_factory: sessionmaker,
|
||||
*,
|
||||
page: int,
|
||||
page_size: int,
|
||||
query: str,
|
||||
filter_value: str,
|
||||
panel_status: str,
|
||||
premium_traffic: str,
|
||||
sort_value: str,
|
||||
) -> Dict[str, Any]:
|
||||
cache = _admin_users_list_cache(settings)
|
||||
cache_key = _admin_users_list_cache_key(
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
query=query,
|
||||
filter_value=filter_value,
|
||||
panel_status=panel_status,
|
||||
premium_traffic=premium_traffic,
|
||||
sort_value=sort_value,
|
||||
)
|
||||
if cache is None:
|
||||
return await _load_admin_users_list_payload_uncached(
|
||||
async_session_factory,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
query=query,
|
||||
filter_value=filter_value,
|
||||
panel_status=panel_status,
|
||||
premium_traffic=premium_traffic,
|
||||
sort_value=sort_value,
|
||||
)
|
||||
return await cache.get_or_load(
|
||||
cache_key,
|
||||
lambda: _load_admin_users_list_payload_uncached(
|
||||
async_session_factory,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
query=query,
|
||||
filter_value=filter_value,
|
||||
panel_status=panel_status,
|
||||
premium_traffic=premium_traffic,
|
||||
sort_value=sort_value,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def _load_admin_users_list_payload_uncached(
|
||||
async_session_factory: sessionmaker,
|
||||
*,
|
||||
page: int,
|
||||
page_size: int,
|
||||
query: str,
|
||||
filter_value: str,
|
||||
panel_status: str,
|
||||
premium_traffic: str,
|
||||
sort_value: str,
|
||||
) -> Dict[str, Any]:
|
||||
async with async_session_factory() as session:
|
||||
users, total = await _filter_and_sort_users(
|
||||
session,
|
||||
@@ -31,12 +129,15 @@ async def admin_users_list_route(request: web.Request) -> web.Response:
|
||||
active_subs = await _bulk_active_subscriptions_for_users(
|
||||
session, [u.user_id for u in users]
|
||||
)
|
||||
payment_summaries = await _bulk_user_payment_summaries(session, [u.user_id for u in users])
|
||||
referral_counts = await _bulk_user_referral_counts(session, [u.user_id for u in users])
|
||||
|
||||
serialized = []
|
||||
for user in users:
|
||||
payload = _serialize_user(user)
|
||||
status_payload = statuses.get(user.user_id) or {"status": "bot_only", "end_date": None}
|
||||
payload["panel_status"] = status_payload.get("status")
|
||||
payload["subscription_expires_at"] = status_payload.get("end_date")
|
||||
if status_payload.get("status") == "expired" and status_payload.get("end_date"):
|
||||
payload["panel_status_expired_at"] = status_payload["end_date"]
|
||||
payload["avatar_url"] = (
|
||||
@@ -45,16 +146,65 @@ async def admin_users_list_route(request: web.Request) -> web.Response:
|
||||
else None
|
||||
)
|
||||
payload["premium_traffic"] = _premium_traffic_list_payload(active_subs.get(user.user_id))
|
||||
payment_summary = payment_summaries.get(user.user_id) or {}
|
||||
payload["payments_total_amount"] = float(payment_summary.get("total_amount") or 0)
|
||||
payload["payments_count"] = int(payment_summary.get("count") or 0)
|
||||
payload["payments_currency"] = payment_summary.get("currency")
|
||||
payload["invited_users_count"] = int(referral_counts.get(user.user_id) or 0)
|
||||
serialized.append(payload)
|
||||
|
||||
return _ok(
|
||||
{
|
||||
"users": serialized,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total": total,
|
||||
}
|
||||
)
|
||||
return {
|
||||
"users": serialized,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total": total,
|
||||
}
|
||||
|
||||
|
||||
def _admin_users_list_cache(settings: Settings) -> Optional[AsyncTTLCache]:
|
||||
ttl_seconds = int(getattr(settings, "ADMIN_USERS_LIST_CACHE_TTL_SECONDS", 3) or 0)
|
||||
if ttl_seconds <= 0:
|
||||
return None
|
||||
cache_key = (id(settings), ttl_seconds)
|
||||
cache = _ADMIN_USERS_LIST_CACHES.get(cache_key)
|
||||
if cache is None:
|
||||
cache = AsyncTTLCache(
|
||||
ttl_seconds=ttl_seconds,
|
||||
settings=settings,
|
||||
namespace="admin:users_list",
|
||||
)
|
||||
_ADMIN_USERS_LIST_CACHES[cache_key] = cache
|
||||
return cache
|
||||
|
||||
|
||||
def _admin_users_list_cache_key(**params: Any) -> str:
|
||||
raw = json.dumps(params, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
async def _invalidate_admin_users_list_cache(settings: Settings) -> None:
|
||||
for settings_id, _ttl in tuple(_ADMIN_USERS_LIST_CACHES):
|
||||
if settings_id == id(settings):
|
||||
_ADMIN_USERS_LIST_CACHES[(settings_id, _ttl)].invalidate()
|
||||
try:
|
||||
await cache_delete_pattern(settings, redis_key(settings, "cache", "admin:users_list", "*"))
|
||||
except Exception:
|
||||
return
|
||||
|
||||
|
||||
async def _invalidate_after_admin_user_mutation(
|
||||
settings: Settings,
|
||||
user_id: Optional[int] = None,
|
||||
*,
|
||||
include_devices: bool = True,
|
||||
) -> None:
|
||||
await _invalidate_admin_users_list_cache(settings)
|
||||
if user_id is not None:
|
||||
await invalidate_webapp_user_caches(
|
||||
settings,
|
||||
user_id,
|
||||
include_devices=include_devices,
|
||||
)
|
||||
|
||||
|
||||
async def _bulk_user_statuses(
|
||||
@@ -115,6 +265,17 @@ async def _bulk_user_avatar_keys(session: AsyncSession, user_ids: List[int]) ->
|
||||
return {int(uid): (updated_at.isoformat() if updated_at else "") for uid, updated_at in rows}
|
||||
|
||||
|
||||
def _serialize_admin_user_with_avatar(user: User, avatar_keys: Dict[int, str]) -> Dict[str, Any]:
|
||||
payload = _serialize_user(user)
|
||||
user_id = int(user.user_id)
|
||||
payload["avatar_url"] = (
|
||||
f"/api/admin/users/{user_id}/avatar?v={avatar_keys[user_id]}"
|
||||
if user_id in avatar_keys
|
||||
else None
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
async def admin_user_avatar_route(request: web.Request) -> web.Response:
|
||||
"""Serve the cached Telegram avatar for any user (admin-only).
|
||||
|
||||
@@ -213,6 +374,88 @@ async def _bulk_active_subscriptions_for_users(
|
||||
return out
|
||||
|
||||
|
||||
def _user_payment_summary_sq():
|
||||
return (
|
||||
select(
|
||||
Payment.user_id.label("user_id"),
|
||||
sa_func.coalesce(sa_func.sum(Payment.amount), 0.0).label("payments_total_amount"),
|
||||
sa_func.count(Payment.payment_id).label("payments_count"),
|
||||
)
|
||||
.where(Payment.status == "succeeded")
|
||||
.group_by(Payment.user_id)
|
||||
.subquery(name="user_payment_summary")
|
||||
)
|
||||
|
||||
|
||||
def _user_referral_count_sq():
|
||||
referred_user = aliased(User)
|
||||
return (
|
||||
select(
|
||||
referred_user.referred_by_id.label("user_id"),
|
||||
sa_func.count(referred_user.user_id).label("invited_users_count"),
|
||||
)
|
||||
.where(referred_user.referred_by_id.is_not(None))
|
||||
.group_by(referred_user.referred_by_id)
|
||||
.subquery(name="user_referral_count")
|
||||
)
|
||||
|
||||
|
||||
def _user_subscription_expiry_sq():
|
||||
return (
|
||||
select(
|
||||
Subscription.user_id.label("user_id"),
|
||||
sa_func.max(Subscription.end_date).label("subscription_expires_at"),
|
||||
)
|
||||
.group_by(Subscription.user_id)
|
||||
.subquery(name="user_subscription_expiry")
|
||||
)
|
||||
|
||||
|
||||
async def _bulk_user_payment_summaries(
|
||||
session: AsyncSession,
|
||||
user_ids: List[int],
|
||||
) -> Dict[int, Dict[str, Any]]:
|
||||
if not user_ids:
|
||||
return {}
|
||||
|
||||
stmt = (
|
||||
select(
|
||||
Payment.user_id,
|
||||
sa_func.coalesce(sa_func.sum(Payment.amount), 0.0),
|
||||
sa_func.count(Payment.payment_id),
|
||||
sa_func.max(Payment.currency),
|
||||
)
|
||||
.where(Payment.user_id.in_(user_ids), Payment.status == "succeeded")
|
||||
.group_by(Payment.user_id)
|
||||
)
|
||||
rows = (await session.execute(stmt)).all()
|
||||
return {
|
||||
int(user_id): {
|
||||
"total_amount": float(total_amount or 0),
|
||||
"count": int(payments_count or 0),
|
||||
"currency": currency,
|
||||
}
|
||||
for user_id, total_amount, payments_count, currency in rows
|
||||
}
|
||||
|
||||
|
||||
async def _bulk_user_referral_counts(
|
||||
session: AsyncSession,
|
||||
user_ids: List[int],
|
||||
) -> Dict[int, int]:
|
||||
if not user_ids:
|
||||
return {}
|
||||
|
||||
referred_user = aliased(User)
|
||||
stmt = (
|
||||
select(referred_user.referred_by_id, sa_func.count(referred_user.user_id))
|
||||
.where(referred_user.referred_by_id.in_(user_ids))
|
||||
.group_by(referred_user.referred_by_id)
|
||||
)
|
||||
rows = (await session.execute(stmt)).all()
|
||||
return {int(user_id): int(count or 0) for user_id, count in rows}
|
||||
|
||||
|
||||
async def _filter_and_sort_users(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
@@ -241,6 +484,13 @@ async def _filter_and_sort_users(
|
||||
ratio_expr = None
|
||||
plim_expr = None
|
||||
pu_expr = None
|
||||
payment_summary_sq = None
|
||||
payment_total_expr = None
|
||||
payment_count_expr = None
|
||||
referral_count_sq = None
|
||||
referral_count_expr = None
|
||||
subscription_expiry_sq = None
|
||||
subscription_expires_expr = None
|
||||
|
||||
if needs_premium_sq:
|
||||
sq = _ranked_active_subscriptions_sq(now)
|
||||
@@ -261,6 +511,42 @@ async def _filter_and_sort_users(
|
||||
else_=cast(pu_expr, Float) / cast(plim_expr, Float),
|
||||
)
|
||||
|
||||
if sort_key in {
|
||||
"payments_total_asc",
|
||||
"payments_total_desc",
|
||||
"payments_count_asc",
|
||||
"payments_count_desc",
|
||||
}:
|
||||
payment_summary_sq = _user_payment_summary_sq()
|
||||
stmt = stmt.outerjoin(payment_summary_sq, User.user_id == payment_summary_sq.c.user_id)
|
||||
count_stmt = count_stmt.outerjoin(
|
||||
payment_summary_sq,
|
||||
User.user_id == payment_summary_sq.c.user_id,
|
||||
)
|
||||
payment_total_expr = sa_func.coalesce(payment_summary_sq.c.payments_total_amount, 0.0)
|
||||
payment_count_expr = sa_func.coalesce(payment_summary_sq.c.payments_count, 0)
|
||||
|
||||
if sort_key in {"invited_users_count_asc", "invited_users_count_desc"}:
|
||||
referral_count_sq = _user_referral_count_sq()
|
||||
stmt = stmt.outerjoin(referral_count_sq, User.user_id == referral_count_sq.c.user_id)
|
||||
count_stmt = count_stmt.outerjoin(
|
||||
referral_count_sq,
|
||||
User.user_id == referral_count_sq.c.user_id,
|
||||
)
|
||||
referral_count_expr = sa_func.coalesce(referral_count_sq.c.invited_users_count, 0)
|
||||
|
||||
if sort_key in {"subscription_expires_at_asc", "subscription_expires_at_desc"}:
|
||||
subscription_expiry_sq = _user_subscription_expiry_sq()
|
||||
stmt = stmt.outerjoin(
|
||||
subscription_expiry_sq,
|
||||
User.user_id == subscription_expiry_sq.c.user_id,
|
||||
)
|
||||
count_stmt = count_stmt.outerjoin(
|
||||
subscription_expiry_sq,
|
||||
User.user_id == subscription_expiry_sq.c.user_id,
|
||||
)
|
||||
subscription_expires_expr = subscription_expiry_sq.c.subscription_expires_at
|
||||
|
||||
search_cond = _user_search_condition(query)
|
||||
if search_cond is not None:
|
||||
stmt = stmt.where(search_cond)
|
||||
@@ -359,6 +645,22 @@ async def _filter_and_sort_users(
|
||||
stmt = stmt.order_by(ratio_expr.asc().nullslast(), User.user_id.asc())
|
||||
elif needs_premium_sq and ratio_expr is not None and sort_key == "premium_ratio_desc":
|
||||
stmt = stmt.order_by(ratio_expr.desc().nullslast(), User.user_id.desc())
|
||||
elif payment_total_expr is not None and sort_key == "payments_total_asc":
|
||||
stmt = stmt.order_by(payment_total_expr.asc(), User.user_id.asc())
|
||||
elif payment_total_expr is not None and sort_key == "payments_total_desc":
|
||||
stmt = stmt.order_by(payment_total_expr.desc(), User.user_id.desc())
|
||||
elif payment_count_expr is not None and sort_key == "payments_count_asc":
|
||||
stmt = stmt.order_by(payment_count_expr.asc(), User.user_id.asc())
|
||||
elif payment_count_expr is not None and sort_key == "payments_count_desc":
|
||||
stmt = stmt.order_by(payment_count_expr.desc(), User.user_id.desc())
|
||||
elif referral_count_expr is not None and sort_key == "invited_users_count_asc":
|
||||
stmt = stmt.order_by(referral_count_expr.asc(), User.user_id.asc())
|
||||
elif referral_count_expr is not None and sort_key == "invited_users_count_desc":
|
||||
stmt = stmt.order_by(referral_count_expr.desc(), User.user_id.desc())
|
||||
elif subscription_expires_expr is not None and sort_key == "subscription_expires_at_asc":
|
||||
stmt = stmt.order_by(subscription_expires_expr.asc().nullslast(), User.user_id.asc())
|
||||
elif subscription_expires_expr is not None and sort_key == "subscription_expires_at_desc":
|
||||
stmt = stmt.order_by(subscription_expires_expr.desc().nullslast(), User.user_id.desc())
|
||||
else:
|
||||
order = sort_map.get(sort_key, sort_map["registered_desc"])
|
||||
if isinstance(order, tuple):
|
||||
@@ -387,9 +689,34 @@ def _user_panel_status_condition(panel_status: str):
|
||||
normalized_status == "active", blank_status & Subscription.is_active.is_(True)
|
||||
)
|
||||
elif status == "expired":
|
||||
status_cond = or_(
|
||||
normalized_status == "expired", blank_status & Subscription.is_active.is_(False)
|
||||
now = datetime.now(timezone.utc)
|
||||
expired_subs = aliased(Subscription)
|
||||
active_subs = aliased(Subscription)
|
||||
expired_status = sa_func.lower(sa_func.coalesce(expired_subs.status_from_panel, ""))
|
||||
expired_blank_status = or_(
|
||||
expired_subs.status_from_panel.is_(None),
|
||||
expired_subs.status_from_panel == "",
|
||||
)
|
||||
expired_condition = or_(
|
||||
expired_status == "expired",
|
||||
expired_blank_status & expired_subs.is_active.is_(False),
|
||||
expired_subs.end_date <= now,
|
||||
)
|
||||
expired_exists = (
|
||||
select(expired_subs.subscription_id)
|
||||
.where(expired_subs.user_id == User.user_id, expired_condition)
|
||||
.exists()
|
||||
)
|
||||
active_exists = (
|
||||
select(active_subs.subscription_id)
|
||||
.where(
|
||||
active_subs.user_id == User.user_id,
|
||||
active_subs.is_active.is_(True),
|
||||
active_subs.end_date > now,
|
||||
)
|
||||
.exists()
|
||||
)
|
||||
return and_(expired_exists, ~active_exists)
|
||||
else:
|
||||
status_cond = normalized_status == "limited"
|
||||
|
||||
@@ -419,6 +746,24 @@ def _user_search_condition(query: str):
|
||||
return or_(*conditions)
|
||||
|
||||
|
||||
def _serialize_trial_summary(user: User, trial_subs: List[Subscription]) -> Dict[str, Any]:
|
||||
first_trial_sub = trial_subs[0] if trial_subs else None
|
||||
latest_trial_sub = trial_subs[-1] if trial_subs else None
|
||||
first_start = getattr(first_trial_sub, "start_date", None)
|
||||
latest_start = getattr(latest_trial_sub, "start_date", None)
|
||||
latest_end = getattr(latest_trial_sub, "end_date", None)
|
||||
reset_at = getattr(user, "trial_eligibility_reset_at", None)
|
||||
return {
|
||||
"used": bool(trial_subs),
|
||||
"count": len(trial_subs),
|
||||
"first_activated_at": first_start.isoformat() if first_start else None,
|
||||
"latest_activated_at": latest_start.isoformat() if latest_start else None,
|
||||
"latest_end_date": latest_end.isoformat() if latest_end else None,
|
||||
"active": bool(latest_trial_sub and getattr(latest_trial_sub, "is_active", False)),
|
||||
"last_reset_at": reset_at.isoformat() if reset_at else None,
|
||||
}
|
||||
|
||||
|
||||
async def admin_user_detail_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
target_id = int(request.match_info["user_id"])
|
||||
@@ -438,6 +783,15 @@ async def admin_user_detail_route(request: web.Request) -> web.Response:
|
||||
.limit(20)
|
||||
)
|
||||
latest_subs = (await session.execute(latest_subs_stmt)).scalars().all()
|
||||
trial_subs_stmt = (
|
||||
select(Subscription)
|
||||
.where(
|
||||
Subscription.user_id == target_id,
|
||||
sa_func.lower(sa_func.coalesce(Subscription.provider, "")) == "trial",
|
||||
)
|
||||
.order_by(Subscription.start_date.asc().nullslast(), Subscription.end_date.asc())
|
||||
)
|
||||
trial_subs = (await session.execute(trial_subs_stmt)).scalars().all()
|
||||
total_paid = await payment_dal.get_user_total_paid(session, target_id)
|
||||
recent_payments_stmt = (
|
||||
select(Payment)
|
||||
@@ -447,7 +801,12 @@ async def admin_user_detail_route(request: web.Request) -> web.Response:
|
||||
)
|
||||
recent_payments = (await session.execute(recent_payments_stmt)).scalars().all()
|
||||
log_count = await message_log_dal.count_user_message_logs(session, target_id)
|
||||
avatar_keys = await _bulk_user_avatar_keys(session, [target_id])
|
||||
inviter = await user_dal.get_referrer_for_user(session, user)
|
||||
invitees_total = await user_dal.count_users_referred_by(session, target_id)
|
||||
avatar_user_ids = [target_id]
|
||||
if inviter is not None:
|
||||
avatar_user_ids.append(int(inviter.user_id))
|
||||
avatar_keys = await _bulk_user_avatar_keys(session, avatar_user_ids)
|
||||
|
||||
# Referral links — both the bot deep-link and the webapp deep-link.
|
||||
referral_code: Optional[str] = None
|
||||
@@ -478,7 +837,13 @@ async def admin_user_detail_route(request: web.Request) -> web.Response:
|
||||
# imports into their VPN client. May be missing if the user has never
|
||||
# been provisioned on the panel.
|
||||
subscription_url: Optional[str] = None
|
||||
panel_uuid = getattr(user, "panel_user_uuid", None)
|
||||
last_vpn_connected_at: Optional[str] = None
|
||||
vpn_connection_status = "unknown"
|
||||
panel_uuid = getattr(user, "panel_user_uuid", None) or getattr(
|
||||
active_sub,
|
||||
"panel_user_uuid",
|
||||
None,
|
||||
)
|
||||
if panel_uuid:
|
||||
subscription_service = request.app.get("subscription_service")
|
||||
panel_service = getattr(subscription_service, "panel_service", None)
|
||||
@@ -487,45 +852,94 @@ async def admin_user_detail_route(request: web.Request) -> web.Response:
|
||||
panel_data = await panel_service.get_user_by_uuid(panel_uuid)
|
||||
if panel_data:
|
||||
subscription_url = panel_data.get("subscriptionUrl") or None
|
||||
vpn_activity = _panel_user_connection_activity(panel_data)
|
||||
vpn_connection_status = str(vpn_activity.get("status") or "unknown")
|
||||
last_vpn_connected_at = vpn_activity.get("last_connected_at")
|
||||
except Exception as exc_panel: # pragma: no cover
|
||||
logger.warning(
|
||||
"Failed to fetch subscriptionUrl for user %s (uuid=%s): %s",
|
||||
"Failed to fetch panel details for user %s (uuid=%s): %s",
|
||||
target_id,
|
||||
panel_uuid,
|
||||
exc_panel,
|
||||
)
|
||||
|
||||
serialized_user = _serialize_user(user)
|
||||
serialized_user["avatar_url"] = (
|
||||
f"/api/admin/users/{target_id}/avatar?v={avatar_keys[target_id]}"
|
||||
if target_id in avatar_keys
|
||||
else None
|
||||
serialized_user = _serialize_admin_user_with_avatar(user, avatar_keys)
|
||||
serialized_inviter = (
|
||||
_serialize_admin_user_with_avatar(inviter, avatar_keys) if inviter is not None else None
|
||||
)
|
||||
trial_payload = _serialize_trial_summary(user, trial_subs)
|
||||
|
||||
return _ok(
|
||||
{
|
||||
"user": serialized_user,
|
||||
"active_subscription": _serialize_subscription(active_sub) if active_sub else None,
|
||||
"subscriptions": [_serialize_subscription(s) for s in (latest_subs or [])],
|
||||
"trial": trial_payload,
|
||||
"total_paid": float(total_paid),
|
||||
"recent_payments": [_serialize_payment(p) for p in recent_payments],
|
||||
"log_count": int(log_count or 0),
|
||||
"subscription_url": subscription_url,
|
||||
"last_vpn_connected_at": last_vpn_connected_at,
|
||||
"vpn_connection_status": vpn_connection_status,
|
||||
"referral": {
|
||||
"code": referral_code,
|
||||
"bot_link": referral_bot_link,
|
||||
"webapp_link": referral_webapp_link,
|
||||
"inviter": serialized_inviter,
|
||||
"invitees_total": int(invitees_total or 0),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def admin_user_referrals_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
target_id = int(request.match_info["user_id"])
|
||||
page = max(0, int(request.query.get("page", 0) or 0))
|
||||
page_size = min(100, max(1, int(request.query.get("page_size", 25) or 25)))
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
|
||||
async with async_session_factory() as session:
|
||||
user = await user_dal.get_user_by_id(session, target_id)
|
||||
if not user:
|
||||
return _error(404, "not_found", "User not found")
|
||||
|
||||
inviter = await user_dal.get_referrer_for_user(session, user)
|
||||
invitees_total = await user_dal.count_users_referred_by(session, target_id)
|
||||
invitees = await user_dal.get_users_referred_by(
|
||||
session,
|
||||
target_id,
|
||||
limit=page_size,
|
||||
offset=page * page_size,
|
||||
)
|
||||
avatar_user_ids = [target_id, *(int(u.user_id) for u in invitees)]
|
||||
if inviter is not None:
|
||||
avatar_user_ids.append(int(inviter.user_id))
|
||||
avatar_keys = await _bulk_user_avatar_keys(session, avatar_user_ids)
|
||||
|
||||
return _ok(
|
||||
{
|
||||
"user": _serialize_admin_user_with_avatar(user, avatar_keys),
|
||||
"inviter": _serialize_admin_user_with_avatar(inviter, avatar_keys)
|
||||
if inviter is not None
|
||||
else None,
|
||||
"invitees": [
|
||||
_serialize_admin_user_with_avatar(invitee, avatar_keys) for invitee in invitees
|
||||
],
|
||||
"total": int(invitees_total or 0),
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def admin_user_ban_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
target_id = int(request.match_info["user_id"])
|
||||
payload = await _read_json(request)
|
||||
desired = bool(payload.get("banned"))
|
||||
|
||||
settings: Settings = request.app["settings"]
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
user = await user_dal.get_user_by_id(session, target_id)
|
||||
@@ -534,6 +948,7 @@ async def admin_user_ban_route(request: web.Request) -> web.Response:
|
||||
user.is_banned = bool(desired)
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
await _invalidate_after_admin_user_mutation(settings, target_id)
|
||||
return _ok({"user": _serialize_user(user)})
|
||||
|
||||
|
||||
@@ -624,36 +1039,180 @@ async def admin_user_message_preview_route(request: web.Request) -> web.Response
|
||||
return _ok({})
|
||||
|
||||
|
||||
async def admin_user_delete_route(request: web.Request) -> web.Response:
|
||||
actor_id = _require_admin_user_id(request)
|
||||
target_id = int(request.match_info["user_id"])
|
||||
def _admin_user_display_name_for_message(user: User) -> str:
|
||||
full = " ".join(
|
||||
part
|
||||
for part in [getattr(user, "first_name", None), getattr(user, "last_name", None)]
|
||||
if part
|
||||
).strip()
|
||||
return (
|
||||
full
|
||||
or (f"@{user.username}" if getattr(user, "username", None) else None)
|
||||
or getattr(user, "email", None)
|
||||
or f"User #{user.user_id}"
|
||||
)
|
||||
|
||||
|
||||
async def admin_user_telegram_profile_link_route(request: web.Request) -> web.Response:
|
||||
actor_id = _require_admin_user_id(request)
|
||||
admin_telegram_id = request.get("admin_telegram_id")
|
||||
if not admin_telegram_id:
|
||||
return _error(403, "admin_telegram_unavailable")
|
||||
|
||||
queue_manager = get_queue_manager()
|
||||
if not queue_manager:
|
||||
return _error(503, "queue_unavailable")
|
||||
|
||||
target_id = int(request.match_info["user_id"])
|
||||
settings: Settings = request.app["settings"]
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
|
||||
async with async_session_factory() as session:
|
||||
ok = await user_dal.delete_user_and_relations(session, target_id)
|
||||
if not ok:
|
||||
await session.rollback()
|
||||
target_user = await user_dal.get_user_by_id(session, target_id)
|
||||
if not target_user:
|
||||
return _error(404, "not_found")
|
||||
if not target_user.telegram_id:
|
||||
return _error(404, "no_telegram_account")
|
||||
|
||||
admin_user = await user_dal.get_user_by_id(session, actor_id)
|
||||
lang = (
|
||||
getattr(admin_user, "language_code", None)
|
||||
or getattr(settings, "DEFAULT_LANGUAGE", None)
|
||||
or "ru"
|
||||
)
|
||||
|
||||
await message_log_dal.create_message_log(
|
||||
session,
|
||||
{
|
||||
"user_id": actor_id,
|
||||
"event_type": "admin_profile_link_webapp",
|
||||
"content": f"Requested Telegram profile link for user_id={target_id}",
|
||||
"is_admin_event": True,
|
||||
"target_user_id": target_id,
|
||||
},
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
i18n_instance = request.app.get("i18n")
|
||||
translate = (
|
||||
(lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs))
|
||||
if i18n_instance is not None
|
||||
else (lambda key, **kwargs: key.format(**kwargs) if kwargs else key)
|
||||
)
|
||||
target_name = _admin_user_display_name_for_message(target_user)
|
||||
telegram_id = int(target_user.telegram_id)
|
||||
profile_url = f"tg://user?id={telegram_id}"
|
||||
message_text = translate(
|
||||
"admin_user_profile_link_message",
|
||||
name=html_escape(target_name),
|
||||
user_id=target_user.user_id,
|
||||
telegram_id=telegram_id,
|
||||
)
|
||||
if message_text == "admin_user_profile_link_message":
|
||||
message_text = (
|
||||
f"Профиль пользователя: <b>{html_escape(target_name)}</b>\n"
|
||||
f"User ID: <code>{target_user.user_id}</code>\n"
|
||||
f"Telegram ID: <code>{telegram_id}</code>\n\n"
|
||||
"Нажмите кнопку ниже, чтобы открыть профиль в Telegram."
|
||||
)
|
||||
|
||||
button_text = translate("user_card_open_profile_button")
|
||||
if button_text == "user_card_open_profile_button":
|
||||
button_text = "👤 Открыть профиль"
|
||||
|
||||
markup = InlineKeyboardMarkup(
|
||||
inline_keyboard=[[InlineKeyboardButton(text=button_text, url=profile_url)]]
|
||||
)
|
||||
|
||||
try:
|
||||
await send_message_via_queue(
|
||||
queue_manager,
|
||||
int(admin_telegram_id),
|
||||
MessageContent(content_type="text", text=message_text),
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True,
|
||||
reply_markup=markup,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Admin profile link message enqueue failed: %s", exc)
|
||||
return _error(502, "send_failed", str(exc))
|
||||
|
||||
return _ok({"queued": True})
|
||||
|
||||
|
||||
async def admin_user_delete_route(request: web.Request) -> web.Response:
|
||||
actor_id = _require_admin_user_id(request)
|
||||
target_id = int(request.match_info["user_id"])
|
||||
|
||||
settings: Settings = request.app["settings"]
|
||||
panel_service = request.app.get("panel_service")
|
||||
if panel_service is None:
|
||||
subscription_service = request.app.get("subscription_service")
|
||||
panel_service = getattr(subscription_service, "panel_service", None)
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
user = await user_dal.get_user_by_id(session, target_id)
|
||||
if not user:
|
||||
return _error(404, "not_found")
|
||||
|
||||
panel_user_uuids = await user_dal.get_panel_user_uuids_for_user(
|
||||
session,
|
||||
target_id,
|
||||
user=user,
|
||||
)
|
||||
if panel_user_uuids and panel_service is None:
|
||||
await session.rollback()
|
||||
return _error(503, "panel_service_unavailable")
|
||||
|
||||
for panel_uuid in panel_user_uuids:
|
||||
try:
|
||||
panel_deleted = await panel_service.delete_user_from_panel(
|
||||
panel_uuid,
|
||||
log_response=False,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Admin webapp failed to delete panel user %s for user %s: %s",
|
||||
panel_uuid,
|
||||
target_id,
|
||||
exc,
|
||||
)
|
||||
await session.rollback()
|
||||
return _error(502, "panel_delete_failed", str(exc))
|
||||
|
||||
if not panel_deleted:
|
||||
await session.rollback()
|
||||
return _error(
|
||||
502,
|
||||
"panel_delete_failed",
|
||||
f"Failed to delete panel user {panel_uuid}",
|
||||
)
|
||||
|
||||
ok = await user_dal.delete_user_and_relations(session, target_id)
|
||||
if not ok:
|
||||
await session.rollback()
|
||||
return _error(404, "not_found")
|
||||
await message_log_dal.create_message_log_no_commit(
|
||||
session,
|
||||
{
|
||||
"user_id": actor_id if actor_id != target_id else None,
|
||||
"event_type": "admin_delete_user_webapp",
|
||||
"content": f"Deleted user_id={target_id}",
|
||||
"content": (
|
||||
f"Deleted user_id={target_id}; "
|
||||
f"panel_uuids={','.join(panel_user_uuids) or 'none'}"
|
||||
),
|
||||
"is_admin_event": True,
|
||||
},
|
||||
)
|
||||
await session.commit()
|
||||
await _invalidate_after_admin_user_mutation(settings, target_id)
|
||||
return _ok({})
|
||||
|
||||
|
||||
async def admin_user_reset_trial_route(request: web.Request) -> web.Response:
|
||||
actor_id = _require_admin_user_id(request)
|
||||
target_id = int(request.match_info["user_id"])
|
||||
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")
|
||||
settings: Settings = request.app["settings"]
|
||||
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
@@ -661,21 +1220,23 @@ async def admin_user_reset_trial_route(request: web.Request) -> web.Response:
|
||||
if not user:
|
||||
return _error(404, "not_found")
|
||||
|
||||
active = await subscription_dal.get_active_subscription_by_user_id(session, target_id)
|
||||
if active:
|
||||
await session.delete(active)
|
||||
reset_at = await user_dal.mark_trial_eligibility_reset(session, target_id)
|
||||
if reset_at is None:
|
||||
await session.rollback()
|
||||
return _error(404, "not_found")
|
||||
|
||||
await message_log_dal.create_message_log(
|
||||
await message_log_dal.create_message_log_no_commit(
|
||||
session,
|
||||
{
|
||||
"user_id": actor_id,
|
||||
"event_type": "admin_reset_trial_webapp",
|
||||
"content": f"Reset trial for user_id={target_id}",
|
||||
"content": f"Reset trial eligibility for user_id={target_id}",
|
||||
"is_admin_event": True,
|
||||
"target_user_id": target_id,
|
||||
},
|
||||
)
|
||||
await session.commit()
|
||||
await _invalidate_after_admin_user_mutation(settings, target_id)
|
||||
return _ok({})
|
||||
|
||||
|
||||
@@ -683,6 +1244,7 @@ async def admin_user_premium_override_route(request: web.Request) -> web.Respons
|
||||
"""Premium-squad traffic overrides only (unlimited toggle + bonus GB)."""
|
||||
actor_id = _require_admin_user_id(request)
|
||||
target_id = int(request.match_info["user_id"])
|
||||
settings: Settings = request.app["settings"]
|
||||
payload = await _read_json(request)
|
||||
subscription_service = request.app.get("subscription_service")
|
||||
|
||||
@@ -734,13 +1296,15 @@ async def admin_user_premium_override_route(request: web.Request) -> web.Respons
|
||||
await session.commit()
|
||||
await session.refresh(active)
|
||||
|
||||
await _invalidate_after_admin_user_mutation(settings, target_id)
|
||||
return _ok({"subscription": _serialize_subscription(active)})
|
||||
|
||||
|
||||
async def admin_user_regular_traffic_override_route(request: web.Request) -> web.Response:
|
||||
"""Main (regular) traffic: unlimited-style ceiling + admin bonus GB."""
|
||||
"""Main (regular) traffic: native unlimited panel limit + admin bonus GB."""
|
||||
actor_id = _require_admin_user_id(request)
|
||||
target_id = int(request.match_info["user_id"])
|
||||
settings: Settings = request.app["settings"]
|
||||
payload = await _read_json(request)
|
||||
|
||||
unlimited = bool(payload.get("unlimited"))
|
||||
@@ -791,6 +1355,79 @@ async def admin_user_regular_traffic_override_route(request: web.Request) -> web
|
||||
await session.commit()
|
||||
await session.refresh(active)
|
||||
|
||||
await _invalidate_after_admin_user_mutation(settings, target_id)
|
||||
return _ok({"subscription": _serialize_subscription(active)})
|
||||
|
||||
|
||||
async def admin_user_hwid_device_limit_route(request: web.Request) -> web.Response:
|
||||
"""Override the user's base HWID device limit.
|
||||
|
||||
``hwid_device_limit == 0`` means unlimited; ``NULL`` means the tariff/.env
|
||||
default is used. Purchased extra devices remain tracked separately and are
|
||||
added when syncing the effective panel limit.
|
||||
"""
|
||||
actor_id = _require_admin_user_id(request)
|
||||
target_id = int(request.match_info["user_id"])
|
||||
settings: Settings = request.app["settings"]
|
||||
payload = await _read_json(request)
|
||||
|
||||
unlimited = bool(payload.get("unlimited"))
|
||||
use_default = bool(payload.get("use_default") or payload.get("reset_to_default"))
|
||||
limit_raw = payload.get("hwid_device_limit", payload.get("limit"))
|
||||
|
||||
if unlimited:
|
||||
hwid_device_limit: Optional[int] = 0
|
||||
elif use_default or limit_raw is None or limit_raw == "":
|
||||
hwid_device_limit = None
|
||||
else:
|
||||
try:
|
||||
hwid_device_limit = int(limit_raw)
|
||||
except (TypeError, ValueError):
|
||||
return _error(
|
||||
400,
|
||||
"invalid_hwid_device_limit",
|
||||
"hwid_device_limit must be a non-negative integer",
|
||||
)
|
||||
if hwid_device_limit < 0 or hwid_device_limit > 1_000_000:
|
||||
return _error(
|
||||
400,
|
||||
"invalid_hwid_device_limit",
|
||||
"hwid_device_limit must be an integer from 0 to 1000000",
|
||||
)
|
||||
|
||||
subscription_service = request.app.get("subscription_service")
|
||||
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
active = await subscription_dal.get_active_subscription_by_user_id(session, target_id)
|
||||
if not active:
|
||||
return _error(404, "no_active_subscription")
|
||||
|
||||
active.hwid_device_limit = hwid_device_limit
|
||||
|
||||
effective_limit = None
|
||||
if subscription_service is not None:
|
||||
effective_limit = await subscription_service.sync_hwid_device_limit_to_panel(
|
||||
session, target_id
|
||||
)
|
||||
|
||||
await message_log_dal.create_message_log(
|
||||
session,
|
||||
{
|
||||
"user_id": actor_id,
|
||||
"event_type": "admin_hwid_device_limit_webapp",
|
||||
"content": (
|
||||
f"hwid_device_limit={hwid_device_limit!r} "
|
||||
f"effective_hwid_device_limit={effective_limit!r}"
|
||||
),
|
||||
"is_admin_event": True,
|
||||
"target_user_id": target_id,
|
||||
},
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(active)
|
||||
|
||||
await _invalidate_after_admin_user_mutation(settings, target_id)
|
||||
return _ok({"subscription": _serialize_subscription(active)})
|
||||
|
||||
|
||||
@@ -805,6 +1442,7 @@ async def admin_user_traffic_grant_route(request: web.Request) -> web.Response:
|
||||
"""
|
||||
actor_id = _require_admin_user_id(request)
|
||||
target_id = int(request.match_info["user_id"])
|
||||
settings: Settings = request.app["settings"]
|
||||
payload = await _read_json(request)
|
||||
|
||||
kind = str(payload.get("kind") or "regular").strip().lower()
|
||||
@@ -865,6 +1503,7 @@ async def admin_user_traffic_grant_route(request: web.Request) -> web.Response:
|
||||
|
||||
refreshed = await subscription_dal.get_active_subscription_by_user_id(session, target_id)
|
||||
|
||||
await _invalidate_after_admin_user_mutation(settings, target_id)
|
||||
return _ok(
|
||||
{
|
||||
"subscription": _serialize_subscription(refreshed) if refreshed else None,
|
||||
@@ -880,6 +1519,7 @@ async def admin_user_traffic_grant_route(request: web.Request) -> web.Response:
|
||||
async def admin_user_extend_route(request: web.Request) -> web.Response:
|
||||
actor_id = _require_admin_user_id(request)
|
||||
target_id = int(request.match_info["user_id"])
|
||||
settings: Settings = request.app["settings"]
|
||||
payload = await _read_json(request)
|
||||
try:
|
||||
days = int(payload.get("days") or 0)
|
||||
@@ -887,6 +1527,8 @@ async def admin_user_extend_route(request: web.Request) -> web.Response:
|
||||
return _error(400, "invalid_days")
|
||||
if days <= 0:
|
||||
return _error(400, "invalid_days")
|
||||
extend_hwid_devices = payload.get("extend_hwid_devices")
|
||||
extend_hwid_devices = True if extend_hwid_devices is None else bool(extend_hwid_devices)
|
||||
|
||||
subscription_service = request.app.get("subscription_service")
|
||||
if subscription_service is None:
|
||||
@@ -899,6 +1541,7 @@ async def admin_user_extend_route(request: web.Request) -> web.Response:
|
||||
target_id,
|
||||
days,
|
||||
"admin_extend_subscription_webapp",
|
||||
extend_hwid_devices=extend_hwid_devices,
|
||||
)
|
||||
if not new_end:
|
||||
await session.rollback()
|
||||
@@ -909,7 +1552,10 @@ async def admin_user_extend_route(request: web.Request) -> web.Response:
|
||||
{
|
||||
"user_id": actor_id,
|
||||
"event_type": "admin_extend_subscription_webapp",
|
||||
"content": f"+{days}d -> {new_end.isoformat()}",
|
||||
"content": (
|
||||
f"+{days}d -> {new_end.isoformat()} "
|
||||
f"(hwid={'yes' if extend_hwid_devices else 'no'})"
|
||||
),
|
||||
"is_admin_event": True,
|
||||
"target_user_id": target_id,
|
||||
},
|
||||
@@ -918,6 +1564,7 @@ async def admin_user_extend_route(request: web.Request) -> web.Response:
|
||||
|
||||
refreshed = await subscription_dal.get_active_subscription_by_user_id(session, target_id)
|
||||
|
||||
await _invalidate_after_admin_user_mutation(settings, target_id)
|
||||
return _ok(
|
||||
{
|
||||
"subscription": _serialize_subscription(refreshed) if refreshed else None,
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import Any
|
||||
|
||||
from bot.app.web.webapp.cache_helpers import (
|
||||
invalidate_all_webapp_user_payloads,
|
||||
reset_subscription_guides_cache,
|
||||
reset_webapp_settings_cache,
|
||||
)
|
||||
|
||||
WEBAPP_APPEARANCE_SETTING_KEYS = frozenset(
|
||||
{
|
||||
"WEBAPP_TITLE",
|
||||
"WEBAPP_LOGO_URL",
|
||||
"WEBAPP_FAVICON_URL",
|
||||
"WEBAPP_FAVICON_USE_CUSTOM",
|
||||
"WEBAPP_LOGO_FAVICON_URL",
|
||||
}
|
||||
)
|
||||
|
||||
WEBAPP_DEVICE_PAYLOAD_SETTING_KEYS = frozenset(
|
||||
{
|
||||
"MY_DEVICES_SECTION_ENABLED",
|
||||
"USER_HWID_DEVICE_LIMIT",
|
||||
"USER_TRAFFIC_LIMIT_GB",
|
||||
"USER_TRAFFIC_STRATEGY",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def changed_setting_keys(
|
||||
updates: Mapping[str, Any] | None = None,
|
||||
deletes: Sequence[Any] | None = None,
|
||||
) -> set[str]:
|
||||
keys = {str(key) for key in (updates or {}).keys()}
|
||||
keys.update(str(key) for key in (deletes or []) if key is not None)
|
||||
return keys
|
||||
|
||||
|
||||
async def refresh_webapp_runtime_after_settings_change(
|
||||
request: Any,
|
||||
*,
|
||||
updates: Mapping[str, Any] | None = None,
|
||||
deletes: Sequence[Any] | None = None,
|
||||
include_user_payloads: bool = True,
|
||||
) -> None:
|
||||
settings = request.app["settings"]
|
||||
keys = changed_setting_keys(updates, deletes)
|
||||
|
||||
reset_webapp_settings_cache(request.app)
|
||||
reset_subscription_guides_cache(request.app)
|
||||
|
||||
if include_user_payloads:
|
||||
await invalidate_all_webapp_user_payloads(
|
||||
settings,
|
||||
include_devices=bool(keys & WEBAPP_DEVICE_PAYLOAD_SETTING_KEYS),
|
||||
)
|
||||
|
||||
if keys & WEBAPP_APPEARANCE_SETTING_KEYS:
|
||||
request.app["webapp_logo_cache"] = None
|
||||
from bot.app.web.admin_api_impl.themes import prune_unused_appearance_assets
|
||||
|
||||
prune_unused_appearance_assets(settings)
|
||||
@@ -11,9 +11,12 @@ from bot.app.web.webapp import (
|
||||
billing as _billing,
|
||||
common as _common,
|
||||
devices as _devices,
|
||||
guides as _guides,
|
||||
payloads as _payloads,
|
||||
routes as _routes,
|
||||
serializers as _serializers,
|
||||
support as _support,
|
||||
telegram_notifications as _telegram_notifications,
|
||||
)
|
||||
|
||||
_MODULES = (
|
||||
@@ -23,9 +26,12 @@ _MODULES = (
|
||||
_assets,
|
||||
_auth,
|
||||
_account,
|
||||
_telegram_notifications,
|
||||
_serializers,
|
||||
_billing,
|
||||
_devices,
|
||||
_guides,
|
||||
_support,
|
||||
_routes,
|
||||
_application,
|
||||
)
|
||||
|
||||
|
After Width: | Height: | Size: 436 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 738 B |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 27 KiB |
@@ -0,0 +1,230 @@
|
||||
<!doctype html>
|
||||
<html lang="__LANG__">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link id="app-favicon" rel="icon" href="data:," sizes="any">
|
||||
<title>__PAGE_TITLE__</title>
|
||||
<style nonce="__NONCE__">
|
||||
:root {
|
||||
color-scheme: dark light;
|
||||
--accent: #14b86f;
|
||||
--accent-contrast: #03120b;
|
||||
--bg: #0b1017;
|
||||
--panel-3: #344052;
|
||||
--border: #2d3847;
|
||||
--text: #f7fafc;
|
||||
--muted: #aeb8c5;
|
||||
font-family:
|
||||
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
|
||||
"Segoe UI", sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100dvh;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
main {
|
||||
width: min(100%, 420px);
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 15px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.button {
|
||||
display: inline-flex;
|
||||
min-height: 46px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
background: var(--accent);
|
||||
color: var(--accent-contrast);
|
||||
padding: 0 18px;
|
||||
box-sizing: border-box;
|
||||
font: inherit;
|
||||
font-weight: 800;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.button.secondary {
|
||||
border-color: var(--border);
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.button[aria-disabled="true"] {
|
||||
pointer-events: none;
|
||||
background: var(--panel-3);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1 id="title"></h1>
|
||||
<p id="status"></p>
|
||||
<div class="actions">
|
||||
<a id="open-link" class="button" href="#" rel="noreferrer"></a>
|
||||
<button id="close-button" class="button secondary" type="button" hidden></button>
|
||||
</div>
|
||||
</main>
|
||||
<script nonce="__NONCE__">
|
||||
(() => {
|
||||
const messages = __MESSAGES_JSON__;
|
||||
const titleEl = document.getElementById("title");
|
||||
const statusEl = document.getElementById("status");
|
||||
const openLink = document.getElementById("open-link");
|
||||
const closeButton = document.getElementById("close-button");
|
||||
const params = new URLSearchParams(window.location.hash.replace(/^#/, ""));
|
||||
const target = String(params.get("url") || "").trim();
|
||||
const isUnsafe =
|
||||
!target ||
|
||||
hasControlChars(target) ||
|
||||
/^(?:javascript|data|vbscript|https?):/i.test(target);
|
||||
let attempted = false;
|
||||
let pageLeft = false;
|
||||
let state = "opening";
|
||||
let closeAttemptTimer = null;
|
||||
const CLOSE_ATTEMPT_DELAY_MS = 2500;
|
||||
|
||||
function hasControlChars(value) {
|
||||
return Array.from(String(value || "")).some((char) => {
|
||||
const code = char.charCodeAt(0);
|
||||
return code <= 31 || code === 127;
|
||||
});
|
||||
}
|
||||
|
||||
function text(key, fallback) {
|
||||
const value = messages && messages[key];
|
||||
return typeof value === "string" && value ? value : fallback;
|
||||
}
|
||||
|
||||
function tryCloseWindow() {
|
||||
try {
|
||||
window.close();
|
||||
} catch (_error) {
|
||||
void _error;
|
||||
}
|
||||
}
|
||||
|
||||
function render(nextState) {
|
||||
state = nextState;
|
||||
if (nextState === "unavailable") {
|
||||
titleEl.textContent = text("unavailableTitle", "App link unavailable");
|
||||
statusEl.textContent = text("unavailableHint", "Return to Telegram and try again.");
|
||||
openLink.textContent = text("button", "Open app");
|
||||
openLink.setAttribute("aria-disabled", "true");
|
||||
openLink.removeAttribute("href");
|
||||
closeButton.hidden = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (nextState === "done") {
|
||||
titleEl.textContent = text("doneTitle", "Settings added");
|
||||
statusEl.textContent = text("doneHint", "You can close this window.");
|
||||
openLink.textContent = text("retryButton", "Open again");
|
||||
openLink.removeAttribute("aria-disabled");
|
||||
openLink.href = target;
|
||||
closeButton.textContent = text("closeButton", "Close window");
|
||||
closeButton.hidden = false;
|
||||
return;
|
||||
}
|
||||
|
||||
titleEl.textContent = text("title", "Opening app");
|
||||
statusEl.textContent =
|
||||
nextState === "manual"
|
||||
? text("manualHint", "If the app did not open automatically, tap the button below.")
|
||||
: text("hint", "Opening the app on this device...");
|
||||
openLink.textContent = text("button", "Open app");
|
||||
openLink.removeAttribute("aria-disabled");
|
||||
openLink.href = target;
|
||||
closeButton.hidden = true;
|
||||
}
|
||||
|
||||
function markDone() {
|
||||
if (state === "done" || isUnsafe) return;
|
||||
render("done");
|
||||
if (closeAttemptTimer) window.clearTimeout(closeAttemptTimer);
|
||||
closeAttemptTimer = window.setTimeout(() => {
|
||||
if (pageLeft || document.hidden) tryCloseWindow();
|
||||
}, CLOSE_ATTEMPT_DELAY_MS);
|
||||
}
|
||||
|
||||
function notePageLeft() {
|
||||
if (!attempted) return;
|
||||
pageLeft = true;
|
||||
window.setTimeout(markDone, 900);
|
||||
}
|
||||
|
||||
function openTarget() {
|
||||
if (isUnsafe) return;
|
||||
attempted = true;
|
||||
pageLeft = false;
|
||||
render("opening");
|
||||
window.location.href = target;
|
||||
window.setTimeout(() => {
|
||||
if (state === "opening" && !pageLeft) render("manual");
|
||||
}, 1600);
|
||||
}
|
||||
|
||||
if (isUnsafe) {
|
||||
render("unavailable");
|
||||
return;
|
||||
}
|
||||
|
||||
openLink.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
openTarget();
|
||||
});
|
||||
closeButton.addEventListener("click", () => {
|
||||
tryCloseWindow();
|
||||
render("done");
|
||||
});
|
||||
window.addEventListener("pagehide", notePageLeft);
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (!attempted) return;
|
||||
if (document.hidden) {
|
||||
pageLeft = true;
|
||||
} else if (pageLeft) {
|
||||
markDone();
|
||||
}
|
||||
});
|
||||
|
||||
render("opening");
|
||||
window.setTimeout(openTarget, 80);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,25 +1,73 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
|
||||
/>
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<meta name="theme-color" content="#03070b" />
|
||||
<link id="app-favicon" rel="icon" href="/favicon.ico" sizes="any" />
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/icon-192.png" />
|
||||
<link rel="icon" type="image/png" sizes="512x512" href="/icon-512.png" />
|
||||
<link
|
||||
id="app-apple-touch-icon"
|
||||
rel="apple-touch-icon"
|
||||
sizes="180x180"
|
||||
href="/apple-touch-icon.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon-precomposed"
|
||||
sizes="180x180"
|
||||
href="/apple-touch-icon-precomposed.png"
|
||||
/>
|
||||
<title>Subscription</title>
|
||||
<link rel="stylesheet" href="/subscription_webapp.css" />
|
||||
<style>
|
||||
.app-boot-fallback {
|
||||
min-height: 100dvh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background: #03070b;
|
||||
}
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<meta name="theme-color" content="#03070b">
|
||||
<link id="app-favicon" rel="icon" href="data:," sizes="any">
|
||||
<title>/minishop</title>
|
||||
<link rel="stylesheet" href="/subscription_webapp.css">
|
||||
</head>
|
||||
.app-boot-fallback__spinner {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 2px solid rgba(242, 247, 244, 0.18);
|
||||
border-top-color: #00fe7a;
|
||||
border-radius: 999px;
|
||||
animation: appBootSpin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
<body>
|
||||
<main id="app"></main>
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.app-boot-fallback__spinner {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
<!-- WEBAPP_I18N_SCRIPT -->
|
||||
<!-- WEBAPP_CONFIG_SCRIPT -->
|
||||
<!-- WEBAPP_JS_SCRIPT -->
|
||||
<!-- WEBAPP_DEV_MOCK_START -->
|
||||
<script src="/subscription_webapp.js" defer></script>
|
||||
<!-- WEBAPP_DEV_MOCK_END -->
|
||||
</body>
|
||||
@keyframes appBootSpin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<main id="app">
|
||||
<div class="app-boot-fallback" role="status" aria-label="Загрузка">
|
||||
<div class="app-boot-fallback__spinner" aria-hidden="true"></div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- WEBAPP_I18N_SCRIPT -->
|
||||
<!-- WEBAPP_CONFIG_SCRIPT -->
|
||||
<!-- WEBAPP_JS_SCRIPT -->
|
||||
<!-- WEBAPP_DEV_MOCK_START -->
|
||||
<script src="/subscription_webapp.js" defer></script>
|
||||
<!-- WEBAPP_DEV_MOCK_END -->
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -18,6 +18,21 @@
|
||||
--muted: #b0b0b0;
|
||||
--dim: #6a6a6a;
|
||||
--danger: #ff5555;
|
||||
--danger-text: #ff5555;
|
||||
--danger-soft: #000000;
|
||||
--danger-border: #ff5555;
|
||||
--success: #ffffff;
|
||||
--success-text: #ffffff;
|
||||
--success-soft: #000000;
|
||||
--success-border: #ffffff;
|
||||
--warning: #ffffff;
|
||||
--warning-text: #ffffff;
|
||||
--warning-soft: #000000;
|
||||
--warning-border: #ffffff;
|
||||
--info: #ffffff;
|
||||
--info-text: #ffffff;
|
||||
--info-soft: #000000;
|
||||
--info-border: #ffffff;
|
||||
--blue: #ffffff;
|
||||
--radius: 0px;
|
||||
--font-sans: "JetBrains Mono", "Cascadia Code", "Fira Code", "Consolas",
|
||||
@@ -30,6 +45,7 @@
|
||||
--surface-sheen-soft: transparent;
|
||||
--surface-hover: rgba(255, 255, 255, 0.08);
|
||||
--surface-muted: #0a0a0a;
|
||||
--surface-subtle: #000000;
|
||||
--surface-subtle-border: #ffffff;
|
||||
--overlay-scrim: rgba(0, 0, 0, 0.85);
|
||||
--nav-bg: #000000;
|
||||
@@ -104,6 +120,8 @@
|
||||
/* ---------- Panels / cards ---------- */
|
||||
|
||||
.theme-key-ascii .card,
|
||||
.theme-key-ascii .trial-card-facts span,
|
||||
.theme-key-ascii .trial-activation-facts div,
|
||||
.theme-key-ascii .period-card,
|
||||
.theme-key-ascii .method-card,
|
||||
.theme-key-ascii .settings-row,
|
||||
@@ -112,11 +130,34 @@
|
||||
.theme-key-ascii .tariff-action-card,
|
||||
.theme-key-ascii .tariff-warning-card,
|
||||
.theme-key-ascii .topup-carryover-note,
|
||||
.theme-key-ascii .input,
|
||||
.theme-key-ascii .subscription-purchase-description,
|
||||
.theme-key-ascii .dialog-card,
|
||||
.theme-key-ascii .language-select-content,
|
||||
.theme-key-ascii .bottom-nav,
|
||||
.theme-key-ascii .toast,
|
||||
.theme-key-ascii .auth-card,
|
||||
.theme-key-ascii .support-overview-card,
|
||||
.theme-key-ascii .support-list-card,
|
||||
.theme-key-ascii .support-ticket-card,
|
||||
.theme-key-ascii .support-conversation-card,
|
||||
.theme-key-ascii .support-new-ticket-button,
|
||||
.theme-key-ascii .support-create-panel,
|
||||
.theme-key-ascii .support-select-trigger,
|
||||
.theme-key-ascii .support-select-content,
|
||||
.theme-key-ascii .support-status-tabs-list,
|
||||
.theme-key-ascii .support-user-ticket-skeleton,
|
||||
.theme-key-ascii .ticket-card,
|
||||
.theme-key-ascii .support-empty-state,
|
||||
.theme-key-ascii .support-message-scroll,
|
||||
.theme-key-ascii .ticket-message-avatar,
|
||||
.theme-key-ascii .ticket-message-bubble,
|
||||
.theme-key-ascii .ticket-composer,
|
||||
.theme-key-ascii .install-platform-trigger,
|
||||
.theme-key-ascii .install-app-button,
|
||||
.theme-key-ascii .install-step,
|
||||
.theme-key-ascii .install-subscription-card,
|
||||
.theme-key-ascii .install-qr-wrap,
|
||||
.theme-key-ascii .install-loading,
|
||||
.theme-key-ascii .admin-sidebar,
|
||||
.theme-key-ascii .admin-header,
|
||||
.theme-key-ascii .admin-card,
|
||||
@@ -127,6 +168,7 @@
|
||||
.theme-key-ascii .admin-toolbar-card,
|
||||
.theme-key-ascii .admin-table-card,
|
||||
.theme-key-ascii .admin-panel-dash-card,
|
||||
.theme-key-ascii .admin-config-alerts,
|
||||
.theme-key-ascii .admin-select-trigger,
|
||||
.theme-key-ascii .admin-select-content,
|
||||
.theme-key-ascii .admin-cn-card[data-slot="card"],
|
||||
@@ -147,12 +189,19 @@
|
||||
.theme-key-ascii .btn,
|
||||
.theme-key-ascii .language-select-trigger,
|
||||
.theme-key-ascii .bottom-nav button,
|
||||
.theme-key-ascii .link-button,
|
||||
.theme-key-ascii .support-new-ticket-button,
|
||||
.theme-key-ascii .support-select-trigger,
|
||||
.theme-key-ascii .support-status-tabs-trigger,
|
||||
.theme-key-ascii .install-platform-trigger,
|
||||
.theme-key-ascii .install-app-button,
|
||||
.theme-key-ascii .admin-btn,
|
||||
.theme-key-ascii .admin-chip,
|
||||
.theme-key-ascii .admin-tabs-trigger,
|
||||
.theme-key-ascii .admin-revenue-period-btn,
|
||||
.theme-key-ascii .admin-mobile-toggle,
|
||||
.theme-key-ascii .admin-nav-item {
|
||||
.theme-key-ascii .admin-nav-item,
|
||||
.theme-key-ascii .admin-config-alert-link {
|
||||
border: 1px solid #ffffff;
|
||||
border-radius: 0;
|
||||
background: #000000;
|
||||
@@ -169,13 +218,16 @@
|
||||
.theme-key-ascii .admin-nav-item:hover,
|
||||
.theme-key-ascii .admin-tabs-trigger:hover,
|
||||
.theme-key-ascii .admin-revenue-period-btn:hover,
|
||||
.theme-key-ascii .bottom-nav button:hover {
|
||||
.theme-key-ascii .bottom-nav button:hover,
|
||||
.theme-key-ascii .admin-config-alert-link:hover {
|
||||
background: #ffffff;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.theme-key-ascii .btn:active:not(:disabled),
|
||||
.theme-key-ascii .bottom-nav button:active,
|
||||
.theme-key-ascii .support-new-ticket-button:active,
|
||||
.theme-key-ascii .support-status-tabs-trigger:active,
|
||||
.theme-key-ascii .admin-btn:active:not(:disabled) {
|
||||
background: #ffffff;
|
||||
color: #000000;
|
||||
@@ -188,6 +240,11 @@
|
||||
.theme-key-ascii .period-card.active,
|
||||
.theme-key-ascii .method-card.active,
|
||||
.theme-key-ascii .option-row.active,
|
||||
.theme-key-ascii .support-new-ticket-button.active,
|
||||
.theme-key-ascii .support-status-tabs-trigger[data-state="active"],
|
||||
.theme-key-ascii .support-select-item[data-highlighted],
|
||||
.theme-key-ascii .support-select-item[data-selected],
|
||||
.theme-key-ascii .install-app-button.active,
|
||||
.theme-key-ascii .admin-nav-item.active,
|
||||
.theme-key-ascii .admin-tabs-trigger[data-state="active"],
|
||||
.theme-key-ascii .admin-revenue-period-btn.is-active {
|
||||
@@ -217,6 +274,9 @@
|
||||
.theme-key-ascii .admin-revenue-period-btn:focus-visible,
|
||||
.theme-key-ascii .admin-mobile-toggle:focus-visible,
|
||||
.theme-key-ascii .language-select-trigger:focus-visible,
|
||||
.theme-key-ascii .install-platform-trigger:focus-visible,
|
||||
.theme-key-ascii .install-platform-trigger[data-state="open"],
|
||||
.theme-key-ascii .install-app-button:focus-visible,
|
||||
.theme-key-ascii .bottom-nav button:focus-visible {
|
||||
outline: 2px solid #ffffff;
|
||||
outline-offset: 1px;
|
||||
@@ -226,6 +286,7 @@
|
||||
/* ---------- Inputs ---------- */
|
||||
|
||||
.theme-key-ascii .input,
|
||||
.theme-key-ascii .textarea,
|
||||
.theme-key-ascii .admin-input,
|
||||
.theme-key-ascii .admin-textarea,
|
||||
.theme-key-ascii .admin-screen-wrap textarea,
|
||||
@@ -240,6 +301,7 @@
|
||||
}
|
||||
|
||||
.theme-key-ascii .input::placeholder,
|
||||
.theme-key-ascii .textarea::placeholder,
|
||||
.theme-key-ascii .admin-input::placeholder,
|
||||
.theme-key-ascii .admin-textarea::placeholder,
|
||||
.theme-key-ascii .admin-screen-wrap textarea::placeholder,
|
||||
@@ -249,6 +311,7 @@
|
||||
}
|
||||
|
||||
.theme-key-ascii .input:focus,
|
||||
.theme-key-ascii .textarea:focus,
|
||||
.theme-key-ascii .admin-input:focus,
|
||||
.theme-key-ascii .admin-textarea:focus,
|
||||
.theme-key-ascii .admin-screen-wrap textarea:focus,
|
||||
@@ -258,6 +321,283 @@
|
||||
box-shadow: inset 0 0 0 1px #ffffff;
|
||||
}
|
||||
|
||||
/* ---------- Admin controls: range sliders and sortable rows ---------- */
|
||||
|
||||
.theme-key-ascii .ui-range-input {
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.theme-key-ascii .ui-range-input::before {
|
||||
height: 8px;
|
||||
border: 1px solid #ffffff;
|
||||
background: #000000;
|
||||
}
|
||||
|
||||
.theme-key-ascii .ui-range-input__range {
|
||||
height: 8px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.theme-key-ascii .ui-range-input__thumb {
|
||||
width: 16px;
|
||||
height: 18px;
|
||||
border: 1px solid #ffffff;
|
||||
border-radius: 0;
|
||||
background: #000000;
|
||||
box-shadow: none;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.theme-key-ascii .ui-range-input__thumb:hover,
|
||||
.theme-key-ascii .ui-range-input__thumb:focus-visible {
|
||||
background: #ffffff;
|
||||
color: #000000;
|
||||
box-shadow: 0 0 0 1px #000000;
|
||||
}
|
||||
|
||||
.theme-key-ascii .ui-sortable {
|
||||
--sortable-drop-line: #ffffff;
|
||||
--sortable-drop-soft: rgba(255, 255, 255, 0.08);
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.theme-key-ascii .ui-sortable-item.is-dragging {
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.theme-key-ascii .ui-sortable-item.is-drop-target {
|
||||
outline: 1px dashed #ffffff;
|
||||
outline-offset: 2px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.theme-key-ascii .ui-sortable-item.is-drop-target::before {
|
||||
top: -5px;
|
||||
height: 1px;
|
||||
border-radius: 0;
|
||||
background: #ffffff;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.theme-key-ascii .ui-sortable-handle {
|
||||
align-self: center;
|
||||
height: 28px;
|
||||
border: 1px solid #ffffff;
|
||||
background: #000000;
|
||||
color: #ffffff;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.theme-key-ascii .ui-sortable-handle:hover,
|
||||
.theme-key-ascii .ui-sortable-handle:focus-visible,
|
||||
.theme-key-ascii .ui-sortable-handle:active {
|
||||
background: #ffffff;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
/* ---------- Admin health config alerts ---------- */
|
||||
|
||||
.theme-key-ascii .admin-config-alerts {
|
||||
position: relative;
|
||||
padding-left: 18px;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.theme-key-ascii .admin-config-alerts::before {
|
||||
content: "!";
|
||||
position: absolute;
|
||||
top: 11px;
|
||||
left: 7px;
|
||||
color: #ffffff;
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.theme-key-ascii .admin-config-alerts-error {
|
||||
border-color: #ff5555;
|
||||
color: #ffaaaa;
|
||||
}
|
||||
|
||||
.theme-key-ascii .admin-config-alerts-error::before {
|
||||
color: #ff5555;
|
||||
}
|
||||
|
||||
.theme-key-ascii .admin-config-alert-dot {
|
||||
width: auto;
|
||||
height: auto;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: currentColor;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.theme-key-ascii .admin-config-alert-dot::before {
|
||||
content: ">";
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.theme-key-ascii .admin-config-alert-error .admin-config-alert-dot {
|
||||
background: transparent;
|
||||
color: #ff5555;
|
||||
}
|
||||
|
||||
.theme-key-ascii .admin-config-alert-link {
|
||||
padding: 1px 7px;
|
||||
font-family: var(--font-mono);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ---------- New webapp surfaces: support, purchase info, password login ---------- */
|
||||
|
||||
.theme-key-ascii .trial-offer-card,
|
||||
.theme-key-ascii .trial-card-facts span,
|
||||
.theme-key-ascii .trial-activation-card,
|
||||
.theme-key-ascii .trial-activation-facts div,
|
||||
.theme-key-ascii .activation-success-dialog,
|
||||
.theme-key-ascii .subscription-purchase-description,
|
||||
.theme-key-ascii .support-create-panel,
|
||||
.theme-key-ascii .ticket-composer {
|
||||
background: #000000;
|
||||
}
|
||||
|
||||
.theme-key-ascii .trial-card-head > svg,
|
||||
.theme-key-ascii .dialog-title-icon {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.theme-key-ascii .support-heading-icon,
|
||||
.theme-key-ascii .support-new-ticket-icon,
|
||||
.theme-key-ascii .support-empty-state svg,
|
||||
.theme-key-ascii .ticket-card-title svg,
|
||||
.theme-key-ascii .field-error-icon {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.theme-key-ascii .password-switch-divider,
|
||||
.theme-key-ascii .or-line span {
|
||||
height: 1px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.theme-key-ascii .link-button {
|
||||
min-height: 28px;
|
||||
padding: 0 8px;
|
||||
color: #ffffff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.theme-key-ascii .link-button:hover:not(:disabled),
|
||||
.theme-key-ascii .support-status-tabs-trigger:hover {
|
||||
background: #ffffff;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.theme-key-ascii .ticket-card::before {
|
||||
inset: 0 auto 0 0;
|
||||
width: 1px;
|
||||
border-radius: 0;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.theme-key-ascii .ticket-status-badge,
|
||||
.theme-key-ascii .ticket-priority-badge,
|
||||
.theme-key-ascii .ticket-message-role-badge {
|
||||
border-color: #ffffff;
|
||||
background: #000000;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.theme-key-ascii .ticket-status-badge::before,
|
||||
.theme-key-ascii .ticket-priority-badge::before,
|
||||
.theme-key-ascii .support-status-tabs-trigger b::before {
|
||||
content: "[";
|
||||
}
|
||||
|
||||
.theme-key-ascii .ticket-status-badge::after,
|
||||
.theme-key-ascii .ticket-priority-badge::after,
|
||||
.theme-key-ascii .support-status-tabs-trigger b::after {
|
||||
content: "]";
|
||||
}
|
||||
|
||||
.theme-key-ascii .ticket-message-row,
|
||||
.theme-key-ascii .ticket-message-row--admin,
|
||||
.theme-key-ascii .ticket-message-row--user,
|
||||
.theme-key-ascii .ticket-message-row--system,
|
||||
.theme-key-ascii .ticket-message-row--internal {
|
||||
--ticket-bubble-bg: #000000;
|
||||
--ticket-bubble-border: #ffffff;
|
||||
--ticket-bubble-text: #ffffff;
|
||||
}
|
||||
|
||||
.theme-key-ascii .ticket-message-bubble,
|
||||
.theme-key-ascii .ticket-message-row--outgoing .ticket-message-bubble {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.theme-key-ascii .ticket-message-row--outgoing .ticket-message-bubble::after,
|
||||
.theme-key-ascii .ticket-message-row--incoming .ticket-message-bubble::before {
|
||||
color: #ffffff;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.theme-key-ascii .ticket-message-row--outgoing .ticket-message-bubble::after {
|
||||
content: " >";
|
||||
}
|
||||
|
||||
.theme-key-ascii .ticket-message-row--incoming .ticket-message-bubble::before {
|
||||
content: "< ";
|
||||
}
|
||||
|
||||
.theme-key-ascii .ticket-composer:focus-within,
|
||||
.theme-key-ascii .support-select-trigger:focus-visible,
|
||||
.theme-key-ascii .ticket-card:focus-visible,
|
||||
.theme-key-ascii .support-status-tabs-trigger:focus-visible {
|
||||
border-color: #ffffff;
|
||||
box-shadow: inset 0 0 0 1px #ffffff;
|
||||
}
|
||||
|
||||
.theme-key-ascii .support-message-scroll,
|
||||
.theme-key-ascii .scroll-area--mono::-webkit-scrollbar-thumb {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.theme-key-ascii .auth-card {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
body:has(.theme-key-ascii) .support-select-content,
|
||||
body:has(.theme-key-ascii) .install-platform-content,
|
||||
body:has(.theme-key-ascii) .field-error-tooltip {
|
||||
border: 1px solid #ffffff;
|
||||
border-radius: 0;
|
||||
background: #000000;
|
||||
color: #ffffff;
|
||||
box-shadow: 0 0 0 1px #ffffff;
|
||||
}
|
||||
|
||||
body:has(.theme-key-ascii) .support-select-item,
|
||||
body:has(.theme-key-ascii) .install-platform-item {
|
||||
border-radius: 0;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
body:has(.theme-key-ascii) .support-select-item[data-highlighted],
|
||||
body:has(.theme-key-ascii) .support-select-item[data-selected],
|
||||
body:has(.theme-key-ascii) .install-platform-item[data-highlighted],
|
||||
body:has(.theme-key-ascii) .install-platform-item[data-selected] {
|
||||
background: #ffffff;
|
||||
color: #000000 !important;
|
||||
}
|
||||
|
||||
body:has(.theme-key-ascii) .support-select-item[data-highlighted] svg,
|
||||
body:has(.theme-key-ascii) .support-select-item[data-selected] svg,
|
||||
body:has(.theme-key-ascii) .install-platform-item[data-highlighted] svg,
|
||||
body:has(.theme-key-ascii) .install-platform-item[data-selected] svg {
|
||||
color: #000000 !important;
|
||||
stroke: #000000 !important;
|
||||
}
|
||||
|
||||
/* ---------- Bottom nav (desktop rail) ---------- */
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
@@ -516,7 +856,8 @@
|
||||
.theme-key-ascii .admin-btn-primary svg.lucide,
|
||||
.theme-key-ascii .admin-nav-item.active svg.lucide,
|
||||
.theme-key-ascii .admin-tabs-trigger[data-state="active"] svg.lucide,
|
||||
.theme-key-ascii .admin-revenue-period-btn.is-active svg.lucide {
|
||||
.theme-key-ascii .admin-revenue-period-btn.is-active svg.lucide,
|
||||
.theme-key-ascii .install-app-button.active svg.lucide {
|
||||
color: #000000 !important;
|
||||
stroke: #000000 !important;
|
||||
}
|
||||
@@ -865,10 +1206,23 @@
|
||||
.theme-key-ascii :is(
|
||||
.card, .dialog-card, .toast,
|
||||
.btn, .input,
|
||||
.trial-card-facts span, .trial-activation-facts div,
|
||||
.period-card, .method-card, .settings-row, .option-row,
|
||||
.tariff-selected-card, .tariff-action-card, .tariff-warning-card,
|
||||
.topup-carryover-note, .language-select-content, .language-select-item,
|
||||
.topup-carryover-note, .subscription-purchase-description,
|
||||
.language-select-content, .language-select-item,
|
||||
.language-select-trigger, .bottom-nav, .bottom-nav button,
|
||||
.link-button,
|
||||
.install-platform-trigger, .install-platform-content, .install-platform-item,
|
||||
.install-app-button, .install-step, .install-subscription-card,
|
||||
.install-qr-wrap, .install-subscription-header-icon, .install-loading,
|
||||
.support-overview-card, .support-list-card, .support-ticket-card,
|
||||
.support-conversation-card, .support-new-ticket-button,
|
||||
.support-create-panel, .support-select-trigger, .support-select-content,
|
||||
.support-select-item, .support-status-tabs-list, .support-status-tabs-trigger,
|
||||
.support-user-ticket-skeleton, .ticket-card, .support-empty-state,
|
||||
.support-message-scroll, .ticket-message-avatar, .ticket-message-bubble,
|
||||
.ticket-composer, .textarea,
|
||||
.field-error-tooltip,
|
||||
.admin-card, .admin-card-head, .admin-card-body,
|
||||
.admin-stat-card, .admin-stat-skeleton-card, .admin-stat-skeleton-wide,
|
||||
@@ -880,6 +1234,7 @@
|
||||
.admin-cn-card-skeleton--tall,
|
||||
.admin-input, .admin-textarea, .admin-btn, .admin-chip,
|
||||
.admin-tabs-trigger, .admin-tabs-list,
|
||||
.ui-range-input__thumb, .ui-sortable-item, .ui-sortable-handle,
|
||||
.admin-nav-item, .admin-revenue-period-btn, .admin-mobile-toggle,
|
||||
.admin-header, .admin-sidebar, .admin-sidebar-brand,
|
||||
.admin-dialog,
|
||||
@@ -895,6 +1250,9 @@
|
||||
.admin-tariff-card, .admin-toolbar-card, .admin-table-card,
|
||||
.admin-panel-dash-card,
|
||||
.admin-select-trigger, .admin-select-content,
|
||||
.install-platform-trigger, .install-platform-content,
|
||||
.install-app-button, .install-step, .install-subscription-card,
|
||||
.install-qr-wrap, .install-loading,
|
||||
.admin-cn-card,
|
||||
.admin-input, .admin-textarea, .admin-btn,
|
||||
.admin-nav-item, .admin-tabs-trigger
|
||||
@@ -908,6 +1266,71 @@
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
/* ---------- Install guide theme surfaces ---------- */
|
||||
|
||||
.theme-key-ascii .install-platform-trigger,
|
||||
.theme-key-ascii .install-app-button,
|
||||
.theme-key-ascii .install-step,
|
||||
.theme-key-ascii .install-subscription-card,
|
||||
.theme-key-ascii .install-qr-wrap,
|
||||
.theme-key-ascii .install-loading,
|
||||
body:has(.theme-key-ascii) .install-platform-content {
|
||||
border: 1px solid #ffffff !important;
|
||||
border-radius: 0 !important;
|
||||
background: #000000 !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.theme-key-ascii .install-platform-trigger:hover,
|
||||
.theme-key-ascii .install-app-button:hover:not(:disabled) {
|
||||
background: #ffffff !important;
|
||||
color: #000000 !important;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
.theme-key-ascii .install-app-button.active,
|
||||
.theme-key-ascii .install-app-button.active:hover:not(:disabled),
|
||||
body:has(.theme-key-ascii) .install-platform-item[data-highlighted],
|
||||
body:has(.theme-key-ascii) .install-platform-item[data-selected] {
|
||||
background: #ffffff !important;
|
||||
color: #000000 !important;
|
||||
border-color: #ffffff !important;
|
||||
}
|
||||
|
||||
.theme-key-ascii .install-app-button.active svg,
|
||||
body:has(.theme-key-ascii) .install-platform-item[data-highlighted] svg,
|
||||
body:has(.theme-key-ascii) .install-platform-item[data-selected] svg {
|
||||
color: #000000 !important;
|
||||
stroke: #000000 !important;
|
||||
}
|
||||
|
||||
.theme-key-ascii .install-step:hover,
|
||||
.theme-key-ascii .install-subscription-card:hover {
|
||||
transform: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.theme-key-ascii .install-step-icon,
|
||||
.theme-key-ascii .install-subscription-header-icon {
|
||||
border: 1px solid currentColor !important;
|
||||
background: #000000 !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.theme-key-ascii .install-qr-divider {
|
||||
color: #ffffff !important;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.theme-key-ascii .install-feature-star.attention-dot {
|
||||
background: #ffffff !important;
|
||||
animation: ascii-caret 1s steps(1) infinite !important;
|
||||
}
|
||||
|
||||
.theme-key-ascii .install-loading .ui-spinner {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* Console-style tables: cell borders, header underline,
|
||||
* row separator using dashed line.
|
||||
@@ -951,3 +1374,51 @@
|
||||
.theme-key-ascii table tbody tr:hover td {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* Newer webapp surfaces: telegram banner, traffic/referral
|
||||
* dropdowns, login language picker. Flatten the accent pills,
|
||||
* rounded badges and colored gradients these ship with so they
|
||||
* read as plain console boxes.
|
||||
* ============================================================ */
|
||||
|
||||
/* Telegram notifications banner: the .card chrome is already
|
||||
* flattened above; only the rounded, color-tinted icon badge needs
|
||||
* squaring off (the Send glyph itself is whitened by the global rule). */
|
||||
.theme-key-ascii .telegram-notifications-icon {
|
||||
border: 1px solid #ffffff;
|
||||
border-radius: 0;
|
||||
background: #000000;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* Premium-server / referral-tariff dropdown help glyph: drop the
|
||||
* pill background in every state (the accent maps to white here,
|
||||
* which would otherwise paint a white blob behind the icon). */
|
||||
.theme-key-ascii .premium-server-help-icon,
|
||||
.theme-key-ascii .premium-server-dropdown summary:hover .premium-server-help-icon,
|
||||
.theme-key-ascii .premium-server-dropdown[open] .premium-server-help-icon,
|
||||
.theme-key-ascii .referral-tariff-dropdown summary:hover .premium-server-help-icon,
|
||||
.theme-key-ascii .referral-tariff-dropdown[open] .premium-server-help-icon {
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* The check on the selected language sits on a solid white row, so a
|
||||
* white glyph would vanish — invert it to black to keep it readable. */
|
||||
.theme-key-ascii .language-select-item[data-selected] .language-select-item-check {
|
||||
color: #000000 !important;
|
||||
stroke: #000000 !important;
|
||||
}
|
||||
|
||||
/* Login-screen language trigger: square the rounded chip. */
|
||||
.theme-key-ascii .auth-language-trigger {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
/* Render flag emoji as monochrome glyphs to stay in the console palette. */
|
||||
.theme-key-ascii .emoji-flag {
|
||||
filter: grayscale(1) contrast(1.05);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"use_primary_accent": false,
|
||||
"use_in_admin": true,
|
||||
"css_file": "style.css",
|
||||
"assets_version": 1,
|
||||
"assets_version": 7,
|
||||
"tokens": {
|
||||
"color_scheme": "dark",
|
||||
"style_preset": "ascii"
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
--surface-sheen-soft: rgba(15, 23, 42, 0.012);
|
||||
--surface-hover: rgba(15, 23, 42, 0.045);
|
||||
--surface-muted: rgba(15, 23, 42, 0.035);
|
||||
--surface-subtle: rgba(15, 23, 42, 0.025);
|
||||
--surface-subtle-border: rgba(15, 23, 42, 0.1);
|
||||
--overlay-scrim: rgba(15, 23, 42, 0.34);
|
||||
--nav-bg: rgba(255, 255, 255, 0.88);
|
||||
@@ -109,6 +110,26 @@
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* New user-facing activation surfaces */
|
||||
.theme-key-light .trial-offer-card,
|
||||
.theme-key-light .trial-activation-card,
|
||||
.theme-key-light .activation-success-dialog {
|
||||
border-color: color-mix(in srgb, var(--accent) 24%, var(--border));
|
||||
background: #ffffff;
|
||||
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.theme-key-light .trial-card-head > svg,
|
||||
.theme-key-light .dialog-title-icon {
|
||||
color: color-mix(in srgb, var(--accent) 54%, #000000);
|
||||
}
|
||||
|
||||
.theme-key-light .trial-card-facts span,
|
||||
.theme-key-light .trial-activation-facts div {
|
||||
border-color: rgba(15, 23, 42, 0.12);
|
||||
background: rgba(15, 23, 42, 0.025);
|
||||
}
|
||||
|
||||
/* Slightly stronger axis/grid contrast for the revenue chart on a light surface */
|
||||
.theme-key-light .admin-revenue-svg-frame {
|
||||
background: #ffffff;
|
||||
@@ -129,3 +150,131 @@
|
||||
.theme-key-light .bonus-card-head > svg {
|
||||
color: color-mix(in srgb, var(--accent) 50%, #000000);
|
||||
}
|
||||
|
||||
/* Install guide theme surfaces */
|
||||
|
||||
.theme-key-light .install-platform-trigger,
|
||||
.theme-key-light .install-app-button,
|
||||
.theme-key-light .install-step,
|
||||
.theme-key-light .install-subscription-card,
|
||||
.theme-key-light .install-qr-wrap {
|
||||
background: #ffffff;
|
||||
border-color: rgba(15, 23, 42, 0.12);
|
||||
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.055);
|
||||
}
|
||||
|
||||
.theme-key-light .install-app-button.active {
|
||||
border-color: color-mix(in srgb, var(--accent) 42%, var(--border));
|
||||
background: color-mix(in srgb, var(--accent) 8%, #ffffff);
|
||||
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.theme-key-light .install-platform-trigger:focus-visible,
|
||||
.theme-key-light .install-platform-trigger[data-state="open"],
|
||||
.theme-key-light .install-app-button:focus-visible {
|
||||
border-color: color-mix(in srgb, var(--accent) 48%, var(--border));
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 16%, transparent);
|
||||
}
|
||||
|
||||
body:has(.theme-key-light) .install-platform-content {
|
||||
background: #ffffff;
|
||||
border-color: rgba(15, 23, 42, 0.14);
|
||||
box-shadow: 0 14px 28px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
body:has(.theme-key-light) .install-platform-item[data-highlighted],
|
||||
body:has(.theme-key-light) .install-platform-item[data-selected] {
|
||||
background: color-mix(in srgb, var(--accent) 9%, #ffffff);
|
||||
}
|
||||
|
||||
.theme-key-light .install-step-icon,
|
||||
.theme-key-light .install-subscription-header-icon {
|
||||
background: color-mix(in srgb, var(--accent) 8%, #ffffff);
|
||||
color: color-mix(in srgb, var(--accent) 55%, #000000);
|
||||
}
|
||||
|
||||
.theme-key-light .install-qr-divider {
|
||||
color: rgba(15, 23, 42, 0.24);
|
||||
}
|
||||
|
||||
.theme-key-light .install-feature-star.attention-dot {
|
||||
background: #f59e0b;
|
||||
}
|
||||
|
||||
.theme-key-light .install-loading .ui-spinner {
|
||||
color: color-mix(in srgb, var(--accent) 55%, #000000);
|
||||
}
|
||||
|
||||
/* Admin controls: range sliders and sortable rows */
|
||||
|
||||
.theme-key-light .ui-range-input::before {
|
||||
background: rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
.theme-key-light .ui-range-input__range {
|
||||
background: color-mix(in srgb, var(--accent) 70%, #0f172a);
|
||||
}
|
||||
|
||||
.theme-key-light .ui-range-input__thumb {
|
||||
border-color: color-mix(in srgb, var(--accent) 68%, #0f172a);
|
||||
background: #ffffff;
|
||||
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.18);
|
||||
}
|
||||
|
||||
.theme-key-light .ui-range-input__thumb:focus-visible {
|
||||
box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 18%, transparent);
|
||||
}
|
||||
|
||||
.theme-key-light .ui-sortable-handle {
|
||||
border-radius: 6px;
|
||||
color: color-mix(in srgb, var(--admin-muted) 82%, var(--admin-text));
|
||||
}
|
||||
|
||||
.theme-key-light .ui-sortable-handle:hover,
|
||||
.theme-key-light .ui-sortable-handle:focus-visible {
|
||||
background: rgba(15, 23, 42, 0.055);
|
||||
color: color-mix(in srgb, var(--accent) 58%, #0f172a);
|
||||
}
|
||||
|
||||
.theme-key-light .ui-sortable {
|
||||
--sortable-drop-line: color-mix(in srgb, var(--accent) 64%, #0f172a);
|
||||
}
|
||||
|
||||
.theme-key-light .ui-sortable-item.is-drop-target {
|
||||
background: color-mix(in srgb, var(--accent) 8%, #ffffff);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px color-mix(in srgb, var(--accent) 26%, transparent),
|
||||
0 10px 22px color-mix(in srgb, var(--accent) 7%, transparent);
|
||||
}
|
||||
|
||||
.theme-key-light .ui-sortable-item.is-drop-target::before {
|
||||
background: var(--sortable-drop-line);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 14%, transparent);
|
||||
}
|
||||
|
||||
/* Admin health config alerts */
|
||||
|
||||
.theme-key-light .admin-config-alerts {
|
||||
border-color: color-mix(in srgb, var(--warning) 38%, var(--admin-border));
|
||||
background: color-mix(in srgb, var(--warning) 9%, #ffffff);
|
||||
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.theme-key-light .admin-config-alerts-error {
|
||||
border-color: color-mix(in srgb, var(--danger) 38%, var(--admin-border));
|
||||
background: color-mix(in srgb, var(--danger) 8%, #ffffff);
|
||||
}
|
||||
|
||||
.theme-key-light .admin-config-alert-link {
|
||||
background: rgba(255, 255, 255, 0.58);
|
||||
}
|
||||
|
||||
.theme-key-light .admin-config-alert-link:hover {
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
/* Telegram notifications banner: keep the warm warning tint but swap the
|
||||
* dark-theme inset bevel for the soft drop shadow the other light cards use. */
|
||||
.theme-key-light .telegram-notifications-card {
|
||||
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.07);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"use_primary_accent": true,
|
||||
"use_in_admin": true,
|
||||
"css_file": "style.css",
|
||||
"assets_version": 2,
|
||||
"assets_version": 6,
|
||||
"tokens": {
|
||||
"color_scheme": "light"
|
||||
}
|
||||
|
||||
@@ -14,6 +14,21 @@
|
||||
--muted: #202020;
|
||||
--dim: #404040;
|
||||
--danger: #800000;
|
||||
--danger-text: #800000;
|
||||
--danger-soft: #f7d6d6;
|
||||
--danger-border: #800000;
|
||||
--success: #008000;
|
||||
--success-text: #006000;
|
||||
--success-soft: #d9f0d9;
|
||||
--success-border: #008000;
|
||||
--warning: #808000;
|
||||
--warning-text: #606000;
|
||||
--warning-soft: #ffffcc;
|
||||
--warning-border: #808000;
|
||||
--info: #000080;
|
||||
--info-text: #000080;
|
||||
--info-soft: #d8e6ff;
|
||||
--info-border: #000080;
|
||||
--blue: #000080;
|
||||
--radius: 0px;
|
||||
--font-sans: "MS Sans Serif", Tahoma, "Segoe UI", sans-serif;
|
||||
@@ -24,6 +39,7 @@
|
||||
--surface-sheen-soft: transparent;
|
||||
--surface-hover: rgba(0, 0, 128, 0.14);
|
||||
--surface-muted: #c0c0c0;
|
||||
--surface-subtle: #dfdfdf;
|
||||
--surface-subtle-border: #808080;
|
||||
--overlay-scrim: rgba(0, 0, 0, 0.35);
|
||||
--nav-bg: #c0c0c0;
|
||||
@@ -41,45 +57,45 @@
|
||||
--admin-text: #000000;
|
||||
--admin-muted: #202020;
|
||||
--admin-dim: #404040;
|
||||
--win95-icon-arrow-left: url("/webapp-theme-assets/windows95/icons/arrow-left.png?v=6");
|
||||
--win95-icon-arrow-right: url("/webapp-theme-assets/windows95/icons/arrow-right.png?v=6");
|
||||
--win95-icon-bitcoin: url("/webapp-theme-assets/windows95/icons/bitcoin.png?v=6");
|
||||
--win95-icon-check: url("/webapp-theme-assets/windows95/icons/check.png?v=6");
|
||||
--win95-icon-chevrons: url("/webapp-theme-assets/windows95/icons/chevrons.png?v=6");
|
||||
--win95-icon-coins: url("/webapp-theme-assets/windows95/icons/coins.png?v=6");
|
||||
--win95-icon-dashboard: url("/webapp-theme-assets/windows95/icons/dashboard.png?v=6");
|
||||
--win95-icon-database: url("/webapp-theme-assets/windows95/icons/database.png?v=6");
|
||||
--win95-icon-download: url("/webapp-theme-assets/windows95/icons/download.png?v=6");
|
||||
--win95-icon-error: url("/webapp-theme-assets/windows95/icons/error.png?v=6");
|
||||
--win95-icon-file-text: url("/webapp-theme-assets/windows95/icons/file-text.png?v=6");
|
||||
--win95-icon-folder: url("/webapp-theme-assets/windows95/icons/folder.png?v=6");
|
||||
--win95-icon-gift: url("/webapp-theme-assets/windows95/icons/gift.png?v=6");
|
||||
--win95-icon-globe: url("/webapp-theme-assets/windows95/icons/globe.png?v=6");
|
||||
--win95-icon-help: url("/webapp-theme-assets/windows95/icons/help.png?v=6");
|
||||
--win95-icon-home: url("/webapp-theme-assets/windows95/icons/home.png?v=6");
|
||||
--win95-icon-info: url("/webapp-theme-assets/windows95/icons/info.png?v=6");
|
||||
--win95-icon-key: url("/webapp-theme-assets/windows95/icons/key.png?v=6");
|
||||
--win95-icon-lock: url("/webapp-theme-assets/windows95/icons/lock.png?v=6");
|
||||
--win95-icon-megaphone: url("/webapp-theme-assets/windows95/icons/megaphone.png?v=6");
|
||||
--win95-icon-paintbrush: url("/webapp-theme-assets/windows95/icons/paintbrush.png?v=6");
|
||||
--win95-icon-payment-card: url("/webapp-theme-assets/windows95/icons/payment-card.png?v=6");
|
||||
--win95-icon-print: url("/webapp-theme-assets/windows95/icons/print.png?v=6");
|
||||
--win95-icon-refresh: url("/webapp-theme-assets/windows95/icons/refresh.png?v=6");
|
||||
--win95-icon-save: url("/webapp-theme-assets/windows95/icons/save.png?v=6");
|
||||
--win95-icon-search: url("/webapp-theme-assets/windows95/icons/search.png?v=6");
|
||||
--win95-icon-send: url("/webapp-theme-assets/windows95/icons/send.png?v=6");
|
||||
--win95-icon-settings: url("/webapp-theme-assets/windows95/icons/settings.png?v=6");
|
||||
--win95-icon-shield: url("/webapp-theme-assets/windows95/icons/shield.png?v=6");
|
||||
--win95-icon-smartphone: url("/webapp-theme-assets/windows95/icons/smartphone.png?v=6");
|
||||
--win95-icon-sliders: url("/webapp-theme-assets/windows95/icons/sliders.png?v=6");
|
||||
--win95-icon-sparkles: url("/webapp-theme-assets/windows95/icons/sparkles.png?v=6");
|
||||
--win95-icon-tag: url("/webapp-theme-assets/windows95/icons/tag.png?v=6");
|
||||
--win95-icon-ticket: url("/webapp-theme-assets/windows95/icons/ticket.png?v=6");
|
||||
--win95-icon-trash: url("/webapp-theme-assets/windows95/icons/trash.png?v=6");
|
||||
--win95-icon-user: url("/webapp-theme-assets/windows95/icons/user.png?v=6");
|
||||
--win95-icon-users: url("/webapp-theme-assets/windows95/icons/users.png?v=6");
|
||||
--win95-icon-warning: url("/webapp-theme-assets/windows95/icons/warning.png?v=6");
|
||||
--win95-icon-x: url("/webapp-theme-assets/windows95/icons/x.png?v=6");
|
||||
--win95-icon-arrow-left: url("/webapp-theme-assets/windows95/icons/arrow-left.png?v=9");
|
||||
--win95-icon-arrow-right: url("/webapp-theme-assets/windows95/icons/arrow-right.png?v=9");
|
||||
--win95-icon-bitcoin: url("/webapp-theme-assets/windows95/icons/bitcoin.png?v=9");
|
||||
--win95-icon-check: url("/webapp-theme-assets/windows95/icons/check.png?v=9");
|
||||
--win95-icon-chevrons: url("/webapp-theme-assets/windows95/icons/chevrons.png?v=9");
|
||||
--win95-icon-coins: url("/webapp-theme-assets/windows95/icons/coins.png?v=9");
|
||||
--win95-icon-dashboard: url("/webapp-theme-assets/windows95/icons/dashboard.png?v=9");
|
||||
--win95-icon-database: url("/webapp-theme-assets/windows95/icons/database.png?v=9");
|
||||
--win95-icon-download: url("/webapp-theme-assets/windows95/icons/download.png?v=9");
|
||||
--win95-icon-error: url("/webapp-theme-assets/windows95/icons/error.png?v=9");
|
||||
--win95-icon-file-text: url("/webapp-theme-assets/windows95/icons/file-text.png?v=9");
|
||||
--win95-icon-folder: url("/webapp-theme-assets/windows95/icons/folder.png?v=9");
|
||||
--win95-icon-gift: url("/webapp-theme-assets/windows95/icons/gift.png?v=9");
|
||||
--win95-icon-globe: url("/webapp-theme-assets/windows95/icons/globe.png?v=9");
|
||||
--win95-icon-help: url("/webapp-theme-assets/windows95/icons/help.png?v=9");
|
||||
--win95-icon-home: url("/webapp-theme-assets/windows95/icons/home.png?v=9");
|
||||
--win95-icon-info: url("/webapp-theme-assets/windows95/icons/info.png?v=9");
|
||||
--win95-icon-key: url("/webapp-theme-assets/windows95/icons/key.png?v=9");
|
||||
--win95-icon-lock: url("/webapp-theme-assets/windows95/icons/lock.png?v=9");
|
||||
--win95-icon-megaphone: url("/webapp-theme-assets/windows95/icons/megaphone.png?v=9");
|
||||
--win95-icon-paintbrush: url("/webapp-theme-assets/windows95/icons/paintbrush.png?v=9");
|
||||
--win95-icon-payment-card: url("/webapp-theme-assets/windows95/icons/payment-card.png?v=9");
|
||||
--win95-icon-print: url("/webapp-theme-assets/windows95/icons/print.png?v=9");
|
||||
--win95-icon-refresh: url("/webapp-theme-assets/windows95/icons/refresh.png?v=9");
|
||||
--win95-icon-save: url("/webapp-theme-assets/windows95/icons/save.png?v=9");
|
||||
--win95-icon-search: url("/webapp-theme-assets/windows95/icons/search.png?v=9");
|
||||
--win95-icon-send: url("/webapp-theme-assets/windows95/icons/send.png?v=9");
|
||||
--win95-icon-settings: url("/webapp-theme-assets/windows95/icons/settings.png?v=9");
|
||||
--win95-icon-shield: url("/webapp-theme-assets/windows95/icons/shield.png?v=9");
|
||||
--win95-icon-smartphone: url("/webapp-theme-assets/windows95/icons/smartphone.png?v=9");
|
||||
--win95-icon-sliders: url("/webapp-theme-assets/windows95/icons/sliders.png?v=9");
|
||||
--win95-icon-sparkles: url("/webapp-theme-assets/windows95/icons/sparkles.png?v=9");
|
||||
--win95-icon-tag: url("/webapp-theme-assets/windows95/icons/tag.png?v=9");
|
||||
--win95-icon-ticket: url("/webapp-theme-assets/windows95/icons/ticket.png?v=9");
|
||||
--win95-icon-trash: url("/webapp-theme-assets/windows95/icons/trash.png?v=9");
|
||||
--win95-icon-user: url("/webapp-theme-assets/windows95/icons/user.png?v=9");
|
||||
--win95-icon-users: url("/webapp-theme-assets/windows95/icons/users.png?v=9");
|
||||
--win95-icon-warning: url("/webapp-theme-assets/windows95/icons/warning.png?v=9");
|
||||
--win95-icon-x: url("/webapp-theme-assets/windows95/icons/x.png?v=9");
|
||||
}
|
||||
|
||||
.theme-key-windows95.app-shell {
|
||||
@@ -111,21 +127,28 @@
|
||||
.theme-key-windows95 svg.lucide-file-text,
|
||||
.theme-key-windows95 svg.lucide-gift,
|
||||
.theme-key-windows95 svg.lucide-globe-2,
|
||||
.theme-key-windows95 svg.lucide-grip-vertical,
|
||||
.theme-key-windows95 svg.lucide-home,
|
||||
.theme-key-windows95 svg.lucide-house,
|
||||
.theme-key-windows95 svg.lucide-info,
|
||||
.theme-key-windows95 svg.lucide-key,
|
||||
.theme-key-windows95 svg.lucide-life-buoy,
|
||||
.theme-key-windows95 svg.lucide-layout-dashboard,
|
||||
.theme-key-windows95 svg.lucide-lock,
|
||||
.theme-key-windows95 svg.lucide-lock-keyhole,
|
||||
.theme-key-windows95 svg.lucide-mail,
|
||||
.theme-key-windows95 svg.lucide-megaphone,
|
||||
.theme-key-windows95 svg.lucide-message-square,
|
||||
.theme-key-windows95 svg.lucide-message-square-plus,
|
||||
.theme-key-windows95 svg.lucide-monitor,
|
||||
.theme-key-windows95 svg.lucide-paintbrush,
|
||||
.theme-key-windows95 svg.lucide-plus,
|
||||
.theme-key-windows95 svg.lucide-qr-code,
|
||||
.theme-key-windows95 svg.lucide-refresh-cw,
|
||||
.theme-key-windows95 svg.lucide-save,
|
||||
.theme-key-windows95 svg.lucide-search,
|
||||
.theme-key-windows95 svg.lucide-send,
|
||||
.theme-key-windows95 svg.lucide-share-2,
|
||||
.theme-key-windows95 svg.lucide-settings,
|
||||
.theme-key-windows95 svg.lucide-shield,
|
||||
.theme-key-windows95 svg.lucide-sliders,
|
||||
@@ -210,6 +233,10 @@
|
||||
--win95-button-icon: var(--win95-icon-globe);
|
||||
}
|
||||
|
||||
.theme-key-windows95 svg.lucide-grip-vertical {
|
||||
--win95-button-icon: var(--win95-icon-sliders);
|
||||
}
|
||||
|
||||
.theme-key-windows95 svg.lucide-file-text {
|
||||
--win95-button-icon: var(--win95-icon-file-text);
|
||||
}
|
||||
@@ -231,10 +258,15 @@
|
||||
--win95-button-icon: var(--win95-icon-key);
|
||||
}
|
||||
|
||||
.theme-key-windows95 svg.lucide-life-buoy {
|
||||
--win95-button-icon: var(--win95-icon-help);
|
||||
}
|
||||
|
||||
.theme-key-windows95 svg.lucide-layout-dashboard {
|
||||
--win95-button-icon: var(--win95-icon-dashboard);
|
||||
}
|
||||
|
||||
.theme-key-windows95 svg.lucide-lock,
|
||||
.theme-key-windows95 svg.lucide-lock-keyhole {
|
||||
--win95-button-icon: var(--win95-icon-lock);
|
||||
}
|
||||
@@ -245,10 +277,15 @@
|
||||
|
||||
.theme-key-windows95 svg.lucide-mail,
|
||||
.theme-key-windows95 svg.lucide-message-square,
|
||||
.theme-key-windows95 svg.lucide-message-square-plus,
|
||||
.theme-key-windows95 svg.lucide-send {
|
||||
--win95-button-icon: var(--win95-icon-send);
|
||||
}
|
||||
|
||||
.theme-key-windows95 svg.lucide-monitor {
|
||||
--win95-button-icon: var(--win95-icon-dashboard);
|
||||
}
|
||||
|
||||
.theme-key-windows95 svg.lucide-paintbrush {
|
||||
--win95-button-icon: var(--win95-icon-paintbrush);
|
||||
}
|
||||
@@ -257,6 +294,10 @@
|
||||
--win95-button-icon: var(--win95-icon-folder);
|
||||
}
|
||||
|
||||
.theme-key-windows95 svg.lucide-qr-code {
|
||||
--win95-button-icon: var(--win95-icon-key);
|
||||
}
|
||||
|
||||
.theme-key-windows95 svg.lucide-refresh-cw {
|
||||
--win95-button-icon: var(--win95-icon-refresh);
|
||||
}
|
||||
@@ -269,6 +310,10 @@
|
||||
--win95-button-icon: var(--win95-icon-search);
|
||||
}
|
||||
|
||||
.theme-key-windows95 svg.lucide-share-2 {
|
||||
--win95-button-icon: var(--win95-icon-send);
|
||||
}
|
||||
|
||||
.theme-key-windows95 svg.lucide-settings {
|
||||
--win95-button-icon: var(--win95-icon-settings);
|
||||
}
|
||||
@@ -335,21 +380,28 @@
|
||||
svg.lucide-file-text,
|
||||
svg.lucide-gift,
|
||||
svg.lucide-globe-2,
|
||||
svg.lucide-grip-vertical,
|
||||
svg.lucide-home,
|
||||
svg.lucide-house,
|
||||
svg.lucide-info,
|
||||
svg.lucide-key,
|
||||
svg.lucide-life-buoy,
|
||||
svg.lucide-layout-dashboard,
|
||||
svg.lucide-lock,
|
||||
svg.lucide-lock-keyhole,
|
||||
svg.lucide-mail,
|
||||
svg.lucide-megaphone,
|
||||
svg.lucide-message-square,
|
||||
svg.lucide-message-square-plus,
|
||||
svg.lucide-monitor,
|
||||
svg.lucide-paintbrush,
|
||||
svg.lucide-plus,
|
||||
svg.lucide-qr-code,
|
||||
svg.lucide-refresh-cw,
|
||||
svg.lucide-save,
|
||||
svg.lucide-search,
|
||||
svg.lucide-send,
|
||||
svg.lucide-share-2,
|
||||
svg.lucide-settings,
|
||||
svg.lucide-shield,
|
||||
svg.lucide-sliders,
|
||||
@@ -390,17 +442,23 @@
|
||||
svg.lucide-house,
|
||||
svg.lucide-info,
|
||||
svg.lucide-key,
|
||||
svg.lucide-life-buoy,
|
||||
svg.lucide-layout-dashboard,
|
||||
svg.lucide-lock,
|
||||
svg.lucide-lock-keyhole,
|
||||
svg.lucide-mail,
|
||||
svg.lucide-megaphone,
|
||||
svg.lucide-message-square,
|
||||
svg.lucide-message-square-plus,
|
||||
svg.lucide-monitor,
|
||||
svg.lucide-paintbrush,
|
||||
svg.lucide-plus,
|
||||
svg.lucide-qr-code,
|
||||
svg.lucide-refresh-cw,
|
||||
svg.lucide-save,
|
||||
svg.lucide-search,
|
||||
svg.lucide-send,
|
||||
svg.lucide-share-2,
|
||||
svg.lucide-settings,
|
||||
svg.lucide-shield,
|
||||
svg.lucide-sliders,
|
||||
@@ -438,11 +496,33 @@
|
||||
.theme-key-windows95 .tariff-action-card,
|
||||
.theme-key-windows95 .tariff-warning-card,
|
||||
.theme-key-windows95 .topup-carryover-note,
|
||||
.theme-key-windows95 .input,
|
||||
.theme-key-windows95 .subscription-purchase-description,
|
||||
.theme-key-windows95 .dialog-card,
|
||||
.theme-key-windows95 .language-select-content,
|
||||
.theme-key-windows95 .bottom-nav,
|
||||
.theme-key-windows95 .toast {
|
||||
.theme-key-windows95 .toast,
|
||||
.theme-key-windows95 .support-overview-card,
|
||||
.theme-key-windows95 .support-list-card,
|
||||
.theme-key-windows95 .support-ticket-card,
|
||||
.theme-key-windows95 .support-conversation-card,
|
||||
.theme-key-windows95 .support-new-ticket-button,
|
||||
.theme-key-windows95 .support-create-panel,
|
||||
.theme-key-windows95 .support-select-trigger,
|
||||
.theme-key-windows95 .support-select-content,
|
||||
.theme-key-windows95 .support-status-tabs-list,
|
||||
.theme-key-windows95 .support-user-ticket-skeleton,
|
||||
.theme-key-windows95 .ticket-card,
|
||||
.theme-key-windows95 .support-empty-state,
|
||||
.theme-key-windows95 .support-message-scroll,
|
||||
.theme-key-windows95 .ticket-message-avatar,
|
||||
.theme-key-windows95 .ticket-message-bubble,
|
||||
.theme-key-windows95 .ticket-composer,
|
||||
.theme-key-windows95 .install-platform-trigger,
|
||||
.theme-key-windows95 .install-app-button,
|
||||
.theme-key-windows95 .install-step,
|
||||
.theme-key-windows95 .install-subscription-card,
|
||||
.theme-key-windows95 .install-qr-wrap,
|
||||
.theme-key-windows95 .install-loading {
|
||||
border-width: 2px;
|
||||
border-style: solid;
|
||||
border-color: #ffffff #404040 #404040 #ffffff;
|
||||
@@ -463,6 +543,9 @@
|
||||
}
|
||||
|
||||
body:has(.theme-key-windows95) .language-select-content,
|
||||
body:has(.theme-key-windows95) .support-select-content,
|
||||
body:has(.theme-key-windows95) .install-platform-content,
|
||||
body:has(.theme-key-windows95) .field-error-tooltip,
|
||||
body:has(.theme-key-windows95) .admin-select-content {
|
||||
border-width: 2px;
|
||||
border-style: solid;
|
||||
@@ -476,10 +559,27 @@ body:has(.theme-key-windows95) .admin-select-content {
|
||||
}
|
||||
|
||||
body:has(.theme-key-windows95) .language-select-item,
|
||||
body:has(.theme-key-windows95) .support-select-item,
|
||||
body:has(.theme-key-windows95) .install-platform-item,
|
||||
body:has(.theme-key-windows95) .admin-select-item {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
body:has(.theme-key-windows95) .support-select-item[data-highlighted],
|
||||
body:has(.theme-key-windows95) .support-select-item[data-selected],
|
||||
body:has(.theme-key-windows95) .install-platform-item[data-highlighted],
|
||||
body:has(.theme-key-windows95) .install-platform-item[data-selected] {
|
||||
background: #000080;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
body:has(.theme-key-windows95) .support-select-item[data-highlighted] svg,
|
||||
body:has(.theme-key-windows95) .support-select-item[data-selected] svg,
|
||||
body:has(.theme-key-windows95) .install-platform-item[data-highlighted] svg,
|
||||
body:has(.theme-key-windows95) .install-platform-item[data-selected] svg {
|
||||
filter: brightness(0) invert(1);
|
||||
}
|
||||
|
||||
.theme-key-windows95 .card::before,
|
||||
.theme-key-windows95 .dialog-card::before {
|
||||
content: "";
|
||||
@@ -496,7 +596,13 @@ body:has(.theme-key-windows95) .admin-select-item {
|
||||
|
||||
.theme-key-windows95 .btn,
|
||||
.theme-key-windows95 .language-select-trigger,
|
||||
.theme-key-windows95 .bottom-nav button {
|
||||
.theme-key-windows95 .bottom-nav button,
|
||||
.theme-key-windows95 .link-button,
|
||||
.theme-key-windows95 .support-new-ticket-button,
|
||||
.theme-key-windows95 .support-select-trigger,
|
||||
.theme-key-windows95 .support-status-tabs-trigger,
|
||||
.theme-key-windows95 .install-platform-trigger,
|
||||
.theme-key-windows95 .install-app-button {
|
||||
min-height: 34px;
|
||||
border: 2px solid;
|
||||
border-color: #ffffff #404040 #404040 #ffffff;
|
||||
@@ -557,11 +663,216 @@ body:has(.theme-key-windows95) .admin-select-item {
|
||||
.theme-key-windows95 .bottom-nav button.active,
|
||||
.theme-key-windows95 .period-card.active,
|
||||
.theme-key-windows95 .method-card.active,
|
||||
.theme-key-windows95 .option-row.active {
|
||||
.theme-key-windows95 .option-row.active,
|
||||
.theme-key-windows95 .support-new-ticket-button.active,
|
||||
.theme-key-windows95 .support-status-tabs-trigger[data-state="active"],
|
||||
.theme-key-windows95 .support-select-item[data-highlighted],
|
||||
.theme-key-windows95 .support-select-item[data-selected],
|
||||
.theme-key-windows95 .install-app-button.active {
|
||||
background: var(--accent);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* ---------- New webapp surfaces: support, purchase info, password login ---------- */
|
||||
|
||||
.theme-key-windows95 .trial-offer-card,
|
||||
.theme-key-windows95 .trial-activation-card,
|
||||
.theme-key-windows95 .activation-success-dialog,
|
||||
.theme-key-windows95 .subscription-purchase-description,
|
||||
.theme-key-windows95 .support-create-panel,
|
||||
.theme-key-windows95 .ticket-composer,
|
||||
.theme-key-windows95 .support-message-scroll {
|
||||
background: #c0c0c0;
|
||||
}
|
||||
|
||||
.theme-key-windows95 .trial-card-facts span,
|
||||
.theme-key-windows95 .trial-activation-facts div {
|
||||
border: 2px solid;
|
||||
border-color: #404040 #ffffff #ffffff #404040;
|
||||
background: #dfdfdf;
|
||||
box-shadow:
|
||||
inset 1px 1px 0 #808080,
|
||||
inset -1px -1px 0 #ffffff;
|
||||
}
|
||||
|
||||
.theme-key-windows95 .dialog-title-icon {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.theme-key-windows95 .support-heading-icon,
|
||||
.theme-key-windows95 .support-new-ticket-icon,
|
||||
.theme-key-windows95 .support-empty-state svg,
|
||||
.theme-key-windows95 .ticket-card-title svg {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.theme-key-windows95 .password-switch-divider,
|
||||
.theme-key-windows95 .or-line span {
|
||||
height: 2px;
|
||||
background: #808080;
|
||||
border-bottom: 1px solid #ffffff;
|
||||
}
|
||||
|
||||
.theme-key-windows95 .link-button {
|
||||
min-height: 28px;
|
||||
padding: 2px 8px;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.theme-key-windows95 .link-button:hover:not(:disabled),
|
||||
.theme-key-windows95 .support-status-tabs-trigger:hover {
|
||||
background: color-mix(in srgb, var(--accent) 16%, #c0c0c0);
|
||||
}
|
||||
|
||||
.theme-key-windows95 .support-status-tabs-trigger b,
|
||||
.theme-key-windows95 .ticket-status-badge,
|
||||
.theme-key-windows95 .ticket-priority-badge,
|
||||
.theme-key-windows95 .ticket-message-role-badge {
|
||||
border: 1px solid #000000;
|
||||
background: #c0c0c0;
|
||||
color: #000000;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.theme-key-windows95 .ticket-card::before {
|
||||
inset: 0 auto 0 0;
|
||||
width: 4px;
|
||||
border-radius: 0;
|
||||
background: var(--ticket-accent, var(--accent));
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.theme-key-windows95 .support-list-card {
|
||||
grid-template-rows: auto auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.theme-key-windows95 .support-conversation-card {
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.theme-key-windows95 .support-conversation-card--loading {
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.theme-key-windows95 .ticket-message-row,
|
||||
.theme-key-windows95 .ticket-message-row--admin,
|
||||
.theme-key-windows95 .ticket-message-row--user,
|
||||
.theme-key-windows95 .ticket-message-row--system,
|
||||
.theme-key-windows95 .ticket-message-row--internal {
|
||||
--ticket-bubble-bg: #ffffff;
|
||||
--ticket-bubble-border: #808080;
|
||||
--ticket-bubble-text: #000000;
|
||||
}
|
||||
|
||||
.theme-key-windows95 .ticket-message-row--outgoing {
|
||||
--ticket-bubble-bg: #ffffe1;
|
||||
}
|
||||
|
||||
.theme-key-windows95 .ticket-message-bubble,
|
||||
.theme-key-windows95 .ticket-message-row--outgoing .ticket-message-bubble {
|
||||
border-radius: 0;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.theme-key-windows95 .ticket-composer:focus-within,
|
||||
.theme-key-windows95 .support-select-trigger:focus-visible,
|
||||
.theme-key-windows95 .install-platform-trigger:focus-visible,
|
||||
.theme-key-windows95 .install-platform-trigger[data-state="open"],
|
||||
.theme-key-windows95 .install-app-button:focus-visible,
|
||||
.theme-key-windows95 .ticket-card:focus-visible,
|
||||
.theme-key-windows95 .support-status-tabs-trigger:focus-visible {
|
||||
outline: 1px dotted #000000;
|
||||
outline-offset: -4px;
|
||||
box-shadow:
|
||||
inset 1px 1px 0 #dfdfdf,
|
||||
inset -1px -1px 0 #808080;
|
||||
}
|
||||
|
||||
.theme-key-windows95 .auth-card {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
/* ---------- Install guide theme surfaces ---------- */
|
||||
|
||||
.theme-key-windows95 .install-platform-trigger,
|
||||
.theme-key-windows95 .install-app-button {
|
||||
background: #c0c0c0;
|
||||
color: #000000;
|
||||
transition: none;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.theme-key-windows95 .install-platform-trigger:hover,
|
||||
.theme-key-windows95 .install-app-button:hover:not(:disabled):not(.active) {
|
||||
background: #dfdfdf;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.theme-key-windows95 .install-app-button.active,
|
||||
.theme-key-windows95 .install-app-button.active:hover:not(:disabled) {
|
||||
background: var(--accent);
|
||||
color: #ffffff;
|
||||
border-color: #404040 #ffffff #ffffff #404040;
|
||||
box-shadow:
|
||||
inset 1px 1px 0 #000000,
|
||||
inset -1px -1px 0 #dfdfdf;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.theme-key-windows95 .install-step,
|
||||
.theme-key-windows95 .install-subscription-card,
|
||||
.theme-key-windows95 .install-qr-wrap,
|
||||
.theme-key-windows95 .install-loading {
|
||||
background: #c0c0c0;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.theme-key-windows95 .install-step:hover,
|
||||
.theme-key-windows95 .install-subscription-card:hover {
|
||||
transform: none;
|
||||
box-shadow:
|
||||
inset 1px 1px 0 #dfdfdf,
|
||||
inset -1px -1px 0 #808080;
|
||||
}
|
||||
|
||||
.theme-key-windows95 .install-step-icon,
|
||||
.theme-key-windows95 .install-subscription-header-icon {
|
||||
border: 2px solid;
|
||||
border-color: #ffffff #404040 #404040 #ffffff;
|
||||
background: #dfdfdf;
|
||||
color: var(--accent);
|
||||
box-shadow:
|
||||
inset 1px 1px 0 #ffffff,
|
||||
inset -1px -1px 0 #808080;
|
||||
}
|
||||
|
||||
body:has(.theme-key-windows95) .install-platform-content {
|
||||
background: #c0c0c0;
|
||||
}
|
||||
|
||||
body:has(.theme-key-windows95) .install-platform-item[data-highlighted],
|
||||
body:has(.theme-key-windows95) .install-platform-item[data-selected] {
|
||||
background: var(--accent);
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.theme-key-windows95 .install-qr-divider {
|
||||
color: #404040;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.theme-key-windows95 .install-feature-star.attention-dot {
|
||||
background: #ffff00 !important;
|
||||
border: 1px solid #000000;
|
||||
box-shadow: 1px 1px 0 #000000;
|
||||
}
|
||||
|
||||
.theme-key-windows95 .install-loading .ui-spinner {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.theme-key-windows95 .card-heading-accent,
|
||||
.theme-key-windows95 .brand-row strong,
|
||||
.theme-key-windows95 .login-brand h1,
|
||||
@@ -746,6 +1057,7 @@ body:has(.theme-key-windows95) .admin-select-item {
|
||||
.theme-key-windows95 .admin-screen-wrap textarea,
|
||||
.theme-key-windows95 .admin-dialog textarea,
|
||||
.theme-key-windows95 .input,
|
||||
.theme-key-windows95 .textarea,
|
||||
.theme-key-windows95 input:not([type="checkbox"]):not([type="radio"]):not([type="range"]),
|
||||
.theme-key-windows95 textarea,
|
||||
.theme-key-windows95 select {
|
||||
@@ -865,9 +1177,153 @@ body:has(.theme-key-windows95) .admin-select-item {
|
||||
opacity: 0.52;
|
||||
}
|
||||
|
||||
/* Admin controls: range sliders and sortable rows */
|
||||
|
||||
.theme-key-windows95 .ui-range-input {
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.theme-key-windows95 .ui-range-input::before {
|
||||
height: 8px;
|
||||
border: 2px solid;
|
||||
border-color: #404040 #ffffff #ffffff #404040;
|
||||
border-radius: 0 !important;
|
||||
background: #ffffff;
|
||||
box-shadow:
|
||||
inset 1px 1px 0 #808080,
|
||||
inset -1px -1px 0 #dfdfdf;
|
||||
}
|
||||
|
||||
.theme-key-windows95 .ui-range-input__range {
|
||||
height: 8px;
|
||||
border-radius: 0 !important;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.theme-key-windows95 .ui-range-input__thumb {
|
||||
width: 14px;
|
||||
height: 20px;
|
||||
border: 2px solid;
|
||||
border-color: #ffffff #404040 #404040 #ffffff;
|
||||
border-radius: 0 !important;
|
||||
background: #c0c0c0;
|
||||
box-shadow:
|
||||
inset 1px 1px 0 #dfdfdf,
|
||||
inset -1px -1px 0 #808080;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.theme-key-windows95 .ui-range-input__thumb:hover,
|
||||
.theme-key-windows95 .ui-range-input__thumb:focus-visible {
|
||||
background: #dfdfdf;
|
||||
}
|
||||
|
||||
.theme-key-windows95 .ui-range-input__thumb[data-active] {
|
||||
border-color: #404040 #ffffff #ffffff #404040;
|
||||
box-shadow:
|
||||
inset 1px 1px 0 #808080,
|
||||
inset -1px -1px 0 #dfdfdf;
|
||||
}
|
||||
|
||||
.theme-key-windows95 .ui-sortable-item.is-drop-target {
|
||||
outline: 1px dotted #000000;
|
||||
outline-offset: 3px;
|
||||
background: color-mix(in srgb, var(--accent) 12%, var(--admin-surface));
|
||||
}
|
||||
|
||||
.theme-key-windows95 .ui-sortable-item.is-drop-target::before {
|
||||
top: -7px;
|
||||
height: 2px;
|
||||
border-radius: 0;
|
||||
background: #000080;
|
||||
box-shadow:
|
||||
0 1px 0 #ffffff,
|
||||
0 -1px 0 #000000;
|
||||
}
|
||||
|
||||
.theme-key-windows95 .ui-sortable-handle {
|
||||
align-self: center;
|
||||
width: 24px;
|
||||
height: 28px;
|
||||
border: 2px solid;
|
||||
border-color: #ffffff #404040 #404040 #ffffff;
|
||||
background: #c0c0c0;
|
||||
color: #000000;
|
||||
box-shadow:
|
||||
inset 1px 1px 0 #dfdfdf,
|
||||
inset -1px -1px 0 #808080;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.theme-key-windows95 .ui-sortable-handle:hover,
|
||||
.theme-key-windows95 .ui-sortable-handle:focus-visible {
|
||||
background: #dfdfdf;
|
||||
}
|
||||
|
||||
.theme-key-windows95 .ui-sortable-handle:active {
|
||||
border-color: #404040 #ffffff #ffffff #404040;
|
||||
box-shadow:
|
||||
inset 1px 1px 0 #808080,
|
||||
inset -1px -1px 0 #dfdfdf;
|
||||
}
|
||||
|
||||
/* Admin health config alerts */
|
||||
|
||||
.theme-key-windows95 .admin-config-alerts {
|
||||
border: 2px solid;
|
||||
border-color: #ffffff #404040 #404040 #ffffff;
|
||||
background: #ffffcc;
|
||||
color: #000000;
|
||||
box-shadow:
|
||||
inset 1px 1px 0 #dfdfdf,
|
||||
inset -1px -1px 0 #808080;
|
||||
}
|
||||
|
||||
.theme-key-windows95 .admin-config-alerts-error {
|
||||
border-color: #ffffff #404040 #404040 #ffffff;
|
||||
background: #f7d6d6;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.theme-key-windows95 .admin-config-alert-dot {
|
||||
border-radius: 0;
|
||||
background: #808000;
|
||||
box-shadow:
|
||||
1px 1px 0 #ffffff,
|
||||
-1px -1px 0 #404040;
|
||||
}
|
||||
|
||||
.theme-key-windows95 .admin-config-alert-error .admin-config-alert-dot {
|
||||
background: #800000;
|
||||
}
|
||||
|
||||
.theme-key-windows95 .admin-config-alert-link {
|
||||
border: 2px solid;
|
||||
border-color: #ffffff #404040 #404040 #ffffff;
|
||||
border-radius: 0;
|
||||
background: #c0c0c0;
|
||||
color: #000000;
|
||||
box-shadow:
|
||||
inset 1px 1px 0 #dfdfdf,
|
||||
inset -1px -1px 0 #808080;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.theme-key-windows95 .admin-config-alert-link:hover {
|
||||
background: #dfdfdf;
|
||||
}
|
||||
|
||||
.theme-key-windows95 .admin-config-alert-link:active {
|
||||
border-color: #404040 #ffffff #ffffff #404040;
|
||||
box-shadow:
|
||||
inset 1px 1px 0 #808080,
|
||||
inset -1px -1px 0 #dfdfdf;
|
||||
}
|
||||
|
||||
.theme-key-windows95 input::placeholder,
|
||||
.theme-key-windows95 textarea::placeholder,
|
||||
.theme-key-windows95 .input::placeholder {
|
||||
.theme-key-windows95 .input::placeholder,
|
||||
.theme-key-windows95 .textarea::placeholder {
|
||||
color: #808080;
|
||||
}
|
||||
|
||||
@@ -888,6 +1344,9 @@ body:has(.theme-key-windows95) .admin-select-item {
|
||||
.theme-key-windows95 .admin-revenue-period-btn:focus-visible,
|
||||
.theme-key-windows95 .admin-mobile-toggle:focus-visible,
|
||||
.theme-key-windows95 .language-select-trigger:focus-visible,
|
||||
.theme-key-windows95 .install-platform-trigger:focus-visible,
|
||||
.theme-key-windows95 .install-platform-trigger[data-state="open"],
|
||||
.theme-key-windows95 .install-app-button:focus-visible,
|
||||
.theme-key-windows95 .bottom-nav button:focus-visible {
|
||||
outline: 1px dotted #000000;
|
||||
outline-offset: -4px;
|
||||
@@ -898,6 +1357,7 @@ body:has(.theme-key-windows95) .admin-select-item {
|
||||
.theme-key-windows95 .admin-screen-wrap textarea:focus,
|
||||
.theme-key-windows95 .admin-dialog textarea:focus,
|
||||
.theme-key-windows95 .input:focus,
|
||||
.theme-key-windows95 .textarea:focus,
|
||||
.theme-key-windows95 input:not([type="checkbox"]):not([type="radio"]):not([type="range"]):focus,
|
||||
.theme-key-windows95 textarea:focus,
|
||||
.theme-key-windows95 select:focus {
|
||||
@@ -944,6 +1404,7 @@ body:has(.theme-key-windows95) .admin-select-item {
|
||||
.theme-key-windows95 .admin-nav-item.active svg.lucide,
|
||||
.theme-key-windows95 .admin-tabs-trigger[data-state="active"] svg.lucide,
|
||||
.theme-key-windows95 .admin-revenue-period-btn.is-active svg.lucide,
|
||||
.theme-key-windows95 .install-app-button.active svg.lucide,
|
||||
.theme-key-windows95 .admin-header svg.lucide {
|
||||
filter: brightness(0) invert(1);
|
||||
}
|
||||
@@ -961,7 +1422,6 @@ body:has(.theme-key-windows95) .admin-select-item {
|
||||
.theme-key-windows95 svg.lucide-map,
|
||||
.theme-key-windows95 svg.lucide-menu,
|
||||
.theme-key-windows95 svg.lucide-mouse-pointer-click,
|
||||
.theme-key-windows95 svg.lucide-qr-code,
|
||||
.theme-key-windows95 svg.lucide-radio,
|
||||
.theme-key-windows95 svg.lucide-repeat-2,
|
||||
.theme-key-windows95 svg.lucide-server,
|
||||
@@ -1041,3 +1501,64 @@ body:has(.theme-key-windows95) .admin-select-item {
|
||||
.theme-key-windows95 a:not(.btn):not(.bottom-nav button):not([class*="-trigger"]):visited {
|
||||
color: #800080;
|
||||
}
|
||||
|
||||
/* ---------- Newer webapp surfaces: telegram banner, traffic /
|
||||
* referral dropdowns, login language picker ---------- */
|
||||
|
||||
/* Telegram notifications banner: the Card chrome is already beveled by
|
||||
* the shared .card rule; give the icon badge a raised chip look instead
|
||||
* of the rounded, color-tinted default (the Send glyph maps to send.png). */
|
||||
.theme-key-windows95 .telegram-notifications-icon {
|
||||
border-width: 2px;
|
||||
border-style: solid;
|
||||
border-color: #ffffff #404040 #404040 #ffffff;
|
||||
border-radius: 0;
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
box-shadow:
|
||||
inset 1px 1px 0 #dfdfdf,
|
||||
inset -1px -1px 0 #808080;
|
||||
}
|
||||
|
||||
/* Standalone referral-tariff dropdown and bonus rows: bevel them like the
|
||||
* rest of the surfaces so they don't read as flat 1px boxes. */
|
||||
.theme-key-windows95 .referral-tariff-dropdown,
|
||||
.theme-key-windows95 .referral-bonus-row {
|
||||
border-width: 2px;
|
||||
border-style: solid;
|
||||
border-color: #ffffff #404040 #404040 #ffffff;
|
||||
border-radius: 0;
|
||||
background: var(--panel);
|
||||
box-shadow:
|
||||
inset 1px 1px 0 #dfdfdf,
|
||||
inset -1px -1px 0 #808080;
|
||||
}
|
||||
|
||||
.theme-key-windows95 .referral-bonus-row-nested {
|
||||
background: #dfdfdf;
|
||||
}
|
||||
|
||||
/* Premium-server / referral help glyph: drop the rounded accent pill so it
|
||||
* sits inline as a plain stroked question mark. */
|
||||
.theme-key-windows95 .premium-server-help-icon,
|
||||
.theme-key-windows95 .premium-server-dropdown summary:hover .premium-server-help-icon,
|
||||
.theme-key-windows95 .premium-server-dropdown[open] .premium-server-help-icon,
|
||||
.theme-key-windows95 .referral-tariff-dropdown summary:hover .premium-server-help-icon,
|
||||
.theme-key-windows95 .referral-tariff-dropdown[open] .premium-server-help-icon {
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* The selected language row turns navy; its check maps to a dark bitmap,
|
||||
* so invert it to white to keep it visible. */
|
||||
.theme-key-windows95 .language-select-item[data-highlighted] .language-select-item-check,
|
||||
.theme-key-windows95 .language-select-item[data-selected] .language-select-item-check {
|
||||
filter: brightness(0) invert(1);
|
||||
}
|
||||
|
||||
/* Login-screen language trigger: square the rounded chip. */
|
||||
.theme-key-windows95 .auth-language-trigger {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"use_primary_accent": false,
|
||||
"use_in_admin": true,
|
||||
"css_file": "style.css",
|
||||
"assets_version": 6,
|
||||
"assets_version": 14,
|
||||
"tokens": {
|
||||
"color_scheme": "light",
|
||||
"style_preset": "win95"
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import asyncio
|
||||
import functools
|
||||
import hmac
|
||||
import logging
|
||||
from typing import Awaitable, Callable, Optional
|
||||
|
||||
from aiogram import Bot, Dispatcher
|
||||
from aiogram.webhook.aiohttp_server import SimpleRequestHandler, setup_application
|
||||
from aiohttp import web
|
||||
from aiohttp.web_log import AccessLogger, KeyMethod
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from bot.payment_providers import iter_provider_specs, iter_service_keys
|
||||
from bot.utils.request_security import request_client_ip
|
||||
from config.settings import Settings
|
||||
|
||||
|
||||
@@ -17,6 +22,39 @@ class SecureSimpleRequestHandler(SimpleRequestHandler):
|
||||
return hmac.compare_digest(telegram_secret_token, self.secret_token)
|
||||
|
||||
|
||||
class TrustedProxyAccessLogger(AccessLogger):
|
||||
"""Aiohttp access logger that respects trusted X-Forwarded-For headers."""
|
||||
|
||||
def compile_format(self, log_format):
|
||||
methods = []
|
||||
for atom in self.FORMAT_RE.findall(log_format):
|
||||
if atom[1] == "":
|
||||
format_key = self.LOG_FORMAT_MAP[atom[0]]
|
||||
method = getattr(type(self), f"_format_{atom[0]}", None)
|
||||
if method is None:
|
||||
method = getattr(AccessLogger, f"_format_{atom[0]}")
|
||||
methods.append(KeyMethod(format_key, method))
|
||||
else:
|
||||
format_key = (self.LOG_FORMAT_MAP[atom[2]], atom[1])
|
||||
method = getattr(type(self), f"_format_{atom[2]}", None)
|
||||
if method is None:
|
||||
method = getattr(AccessLogger, f"_format_{atom[2]}")
|
||||
methods.append(KeyMethod(format_key, functools.partial(method, atom[1])))
|
||||
|
||||
compiled = self.FORMAT_RE.sub(r"%s", log_format)
|
||||
compiled = self.CLEANUP_RE.sub(r"%\1", compiled)
|
||||
return compiled, methods
|
||||
|
||||
@staticmethod
|
||||
def _format_a(request, response, time):
|
||||
if request is None:
|
||||
return "-"
|
||||
settings = request.app.get("settings") if hasattr(request, "app") else None
|
||||
trusted_proxies = getattr(settings, "trusted_proxies", None)
|
||||
client_ip = request_client_ip(request, trusted_proxies=trusted_proxies)
|
||||
return client_ip or "-"
|
||||
|
||||
|
||||
def _inject_shared_instances(
|
||||
app: web.Application,
|
||||
dp: Dispatcher,
|
||||
@@ -29,19 +67,15 @@ def _inject_shared_instances(
|
||||
app["settings"] = settings
|
||||
app["async_session_factory"] = async_session_factory
|
||||
app["i18n"] = dp.get("i18n_instance")
|
||||
for key in (
|
||||
"yookassa_service",
|
||||
"lknpd_service",
|
||||
shared_keys = [
|
||||
"subscription_service",
|
||||
"referral_service",
|
||||
"panel_service",
|
||||
"stars_service",
|
||||
"freekassa_service",
|
||||
"cryptopay_service",
|
||||
"panel_webhook_service",
|
||||
"platega_service",
|
||||
"severpay_service",
|
||||
):
|
||||
"lknpd_service",
|
||||
*iter_service_keys(),
|
||||
]
|
||||
for key in shared_keys:
|
||||
if hasattr(dp, "workflow_data") and key in dp.workflow_data: # type: ignore
|
||||
app[key] = dp.workflow_data[key] # type: ignore
|
||||
|
||||
@@ -51,6 +85,8 @@ async def build_and_start_web_app(
|
||||
bot: Bot,
|
||||
settings: Settings,
|
||||
async_session_factory: sessionmaker,
|
||||
*,
|
||||
after_webhooks_started: Optional[Callable[[], Awaitable[None]]] = None,
|
||||
):
|
||||
app = web.Application()
|
||||
_inject_shared_instances(app, dp, bot, settings, async_session_factory)
|
||||
@@ -90,38 +126,21 @@ async def build_and_start_web_app(
|
||||
f"Telegram webhook route configured at: [POST] {telegram_webhook_path} (relative to base URL)" # noqa: E501
|
||||
)
|
||||
|
||||
from bot.handlers.user.payment import yookassa_webhook_route
|
||||
from bot.services.crypto_pay_service import cryptopay_webhook_route
|
||||
from bot.services.freekassa_service import freekassa_webhook_route
|
||||
from bot.services.panel_webhook_service import panel_webhook_route
|
||||
from bot.services.platega_service import platega_webhook_route
|
||||
from bot.services.severpay_service import severpay_webhook_route
|
||||
|
||||
cp_path = settings.cryptopay_webhook_path
|
||||
if cp_path.startswith("/"):
|
||||
app.router.add_post(cp_path, cryptopay_webhook_route)
|
||||
logging.info(f"CryptoPay webhook route configured at: [POST] {cp_path}")
|
||||
|
||||
fk_path = settings.freekassa_webhook_path
|
||||
if fk_path.startswith("/"):
|
||||
app.router.add_post(fk_path, freekassa_webhook_route)
|
||||
logging.info(f"FreeKassa webhook route configured at: [POST] {fk_path}")
|
||||
|
||||
pg_path = settings.platega_webhook_path
|
||||
if pg_path.startswith("/"):
|
||||
app.router.add_post(pg_path, platega_webhook_route)
|
||||
logging.info(f"Platega webhook route configured at: [POST] {pg_path}")
|
||||
|
||||
sp_path = settings.severpay_webhook_path
|
||||
if sp_path.startswith("/"):
|
||||
app.router.add_post(sp_path, severpay_webhook_route)
|
||||
logging.info(f"SeverPay webhook route configured at: [POST] {sp_path}")
|
||||
|
||||
# YooKassa webhook (register only when base URL present and path configured)
|
||||
yk_path = settings.yookassa_webhook_path
|
||||
if settings.WEBHOOK_BASE_URL and yk_path and yk_path.startswith("/"):
|
||||
app.router.add_post(yk_path, yookassa_webhook_route)
|
||||
logging.info(f"YooKassa webhook route configured at: [POST] {yk_path}")
|
||||
registered_webhook_paths: set[str] = set()
|
||||
for spec in iter_provider_specs():
|
||||
webhook_route = spec.load_webhook_route()
|
||||
if not spec.webhook_path or not webhook_route:
|
||||
continue
|
||||
if spec.webhook_requires_base_url and not settings.WEBHOOK_BASE_URL:
|
||||
continue
|
||||
path = spec.webhook_path(settings)
|
||||
if not path or not path.startswith("/") or path in registered_webhook_paths:
|
||||
continue
|
||||
registered_webhook_paths.add(path)
|
||||
app.router.add_post(path, webhook_route)
|
||||
logging.info("%s webhook route configured at: [POST] %s", spec.label, path)
|
||||
|
||||
panel_path = settings.panel_webhook_path
|
||||
if panel_path.startswith("/"):
|
||||
@@ -130,7 +149,7 @@ async def build_and_start_web_app(
|
||||
|
||||
runners = []
|
||||
|
||||
webhooks_runner = web.AppRunner(app)
|
||||
webhooks_runner = web.AppRunner(app, access_log_class=TrustedProxyAccessLogger)
|
||||
await webhooks_runner.setup()
|
||||
runners.append(webhooks_runner)
|
||||
site = web.TCPSite(
|
||||
@@ -143,6 +162,8 @@ async def build_and_start_web_app(
|
||||
logging.info(
|
||||
f"AIOHTTP server started on http://{settings.WEB_SERVER_HOST}:{settings.WEB_SERVER_PORT}"
|
||||
)
|
||||
if after_webhooks_started is not None:
|
||||
await after_webhooks_started()
|
||||
|
||||
if settings.WEBAPP_ENABLED:
|
||||
from bot.app.web.subscription_webapp import create_subscription_webapp_application
|
||||
@@ -153,7 +174,10 @@ async def build_and_start_web_app(
|
||||
settings,
|
||||
async_session_factory,
|
||||
)
|
||||
subscription_runner = web.AppRunner(subscription_app)
|
||||
subscription_runner = web.AppRunner(
|
||||
subscription_app,
|
||||
access_log_class=TrustedProxyAccessLogger,
|
||||
)
|
||||
await subscription_runner.setup()
|
||||
runners.append(subscription_runner)
|
||||
subscription_site = web.TCPSite(
|
||||
|
||||
@@ -21,7 +21,6 @@ from typing import Any, Dict, List, Optional, Tuple
|
||||
from urllib.parse import parse_qsl, quote, urlencode, urlsplit, urlunsplit
|
||||
|
||||
from aiogram import Bot, Dispatcher
|
||||
from aiogram.types import LabeledPrice
|
||||
from aiohttp import ClientSession, ClientTimeout, web
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr, ValidationError, constr, field_validator
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -42,22 +41,26 @@ from bot.app.web.webapp_auth import (
|
||||
verify_telegram_oauth_nonce,
|
||||
verify_webapp_session_token,
|
||||
)
|
||||
from bot.infra.redis import cache_get_json, cache_set_json, get_redis, redis_key
|
||||
from bot.services.crypto_pay_service import CryptoPayService
|
||||
from bot.services.email_auth_service import EmailAuthService, normalize_email
|
||||
from bot.infra.redis import cache_delete, cache_get_json, cache_set_json, get_redis, redis_key
|
||||
from bot.services.email_auth_service import EmailAuthService, is_disposable_email, normalize_email
|
||||
from bot.services.email_templates import render_account_merged
|
||||
from bot.services.freekassa_service import FreeKassaService
|
||||
from bot.services.platega_service import PlategaService
|
||||
from bot.services.promo_code_service import PromoCodeService
|
||||
from bot.services.referral_service import ReferralService
|
||||
from bot.services.severpay_service import SeverPayService
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.services.yookassa_service import YooKassaService
|
||||
from bot.utils.config_link import prepare_config_links
|
||||
from bot.utils.request_security import parse_ip_entries, request_client_ip
|
||||
from bot.utils.text_sanitizer import sanitize_display_name, sanitize_username
|
||||
from bot.utils.text_sanitizer import (
|
||||
panel_description_from_profile,
|
||||
sanitize_display_name,
|
||||
sanitize_username,
|
||||
)
|
||||
from config.settings import Settings
|
||||
from db.dal import payment_dal, subscription_dal, user_dal
|
||||
from config.tariffs_config import (
|
||||
default_currency_key_for_settings,
|
||||
default_payment_currency_code_for_settings,
|
||||
payment_currency_code,
|
||||
)
|
||||
from db.dal import payment_dal, security_dal, subscription_dal, support_dal, user_dal
|
||||
from db.dal.user_dal import UserMergeConflictError
|
||||
from db.models import Payment, User, UserTelegramAvatar
|
||||
|
||||
@@ -65,6 +68,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
TEMPLATE_PATH = Path(__file__).resolve().parents[1] / "templates" / "subscription_webapp.html"
|
||||
ASSET_DIR = TEMPLATE_PATH.parent
|
||||
APP_DEEPLINK_TEMPLATE_PATH = ASSET_DIR / "open_app_gateway.html"
|
||||
APP_ROOT = Path(__file__).resolve().parents[5]
|
||||
WEBAPP_LOGO_PROXY_PATH = "/webapp-logo"
|
||||
WEBAPP_LOGO_CACHE_DIR = APP_ROOT / "data" / "webapp-logo"
|
||||
@@ -72,7 +76,12 @@ WEBAPP_UPLOADED_LOGO_DIR = WEBAPP_LOGO_CACHE_DIR / "uploads"
|
||||
WEBAPP_UPLOADED_LOGO_PATH = "/webapp-uploaded-logo"
|
||||
WEBAPP_FAVICON_DIR = WEBAPP_LOGO_CACHE_DIR / "favicons"
|
||||
WEBAPP_FAVICON_PATH = "/webapp-favicon"
|
||||
WEBAPP_EMOJI_CACHE_DIR = APP_ROOT / "data" / "webapp-emoji"
|
||||
WEBAPP_DEFAULT_BRAND_DIR = ASSET_DIR / "default-brand"
|
||||
WEBAPP_DEFAULT_LOGO_FILE = WEBAPP_DEFAULT_BRAND_DIR / "default-logo.webp"
|
||||
WEBAPP_DEFAULT_LOGO_PATH = "/webapp-default-logo.webp"
|
||||
WEBAPP_DEFAULT_FAVICON_DIGEST = "19b2a242e5b7bc2d"
|
||||
WEBAPP_DEFAULT_FAVICON_DIR = WEBAPP_DEFAULT_BRAND_DIR / "favicons" / WEBAPP_DEFAULT_FAVICON_DIGEST
|
||||
WEBAPP_DEFAULT_FAVICON_URL = f"{WEBAPP_FAVICON_PATH}/{WEBAPP_DEFAULT_FAVICON_DIGEST}/icon-180.png"
|
||||
WEBAPP_CONFIG_PLACEHOLDER = "<!-- WEBAPP_CONFIG_SCRIPT -->"
|
||||
WEBAPP_I18N_PLACEHOLDER = "<!-- WEBAPP_I18N_SCRIPT -->"
|
||||
WEBAPP_JS_PLACEHOLDER = "<!-- WEBAPP_JS_SCRIPT -->"
|
||||
@@ -82,7 +91,6 @@ DEV_MOCK_END_MARKER = "<!-- WEBAPP_DEV_MOCK_END -->"
|
||||
WEBAPP_RATE_LIMIT_WINDOW_SECONDS = 60
|
||||
WEBAPP_RATE_LIMIT_MAX_REQUESTS = 30
|
||||
WEBAPP_LOGO_MAX_BYTES = 2 * 1024 * 1024
|
||||
WEBAPP_EMOJI_MAX_BYTES = 4 * 1024 * 1024
|
||||
WEBAPP_THEME_CSS_MAX_BYTES = 512 * 1024
|
||||
WEBAPP_THEME_ASSET_MAX_BYTES = 1024 * 1024
|
||||
WEBAPP_THEME_ASSET_CONTENT_TYPES = {
|
||||
@@ -102,6 +110,33 @@ WEBAPP_CSRF_COOKIE_NAME = "rw_webapp_csrf"
|
||||
WEBAPP_TELEGRAM_OAUTH_STATE_COOKIE_NAME = "rw_tg_oauth_state"
|
||||
WEBAPP_CSRF_HEADER_NAME = "X-CSRF-Token"
|
||||
WEBAPP_STATE_CHANGING_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
|
||||
ROBOTS_TX = """User-agent: *
|
||||
Disallow: /
|
||||
|
||||
User-agent: GPTBot
|
||||
Disallow: /
|
||||
|
||||
User-agent: ChatGPT-User
|
||||
Disallow: /
|
||||
|
||||
User-agent: OAI-SearchBot
|
||||
Disallow: /
|
||||
|
||||
User-agent: Google-Extended
|
||||
Disallow: /
|
||||
|
||||
User-agent: ClaudeBot
|
||||
Disallow: /
|
||||
|
||||
User-agent: anthropic-ai
|
||||
Disallow: /
|
||||
|
||||
User-agent: PerplexityBot
|
||||
Disallow: /
|
||||
|
||||
User-agent: Applebot-Extended
|
||||
Disallow: /
|
||||
"""
|
||||
_APP_VERSION_CACHE: Optional[str] = None
|
||||
WEBAPP_CSRF_EXEMPT_PATHS = {
|
||||
"/api/auth/telegram/nonce",
|
||||
@@ -109,6 +144,7 @@ WEBAPP_CSRF_EXEMPT_PATHS = {
|
||||
"/api/auth/email/request",
|
||||
"/api/auth/email/verify",
|
||||
"/api/auth/email/magic",
|
||||
"/api/auth/email/password",
|
||||
"/api/auth/logout",
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,30 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
from bot.app.web.webapp.cache_helpers import webapp_cached_user_payload
|
||||
from .auth import (
|
||||
_hash_email_password,
|
||||
_notify_account_merged,
|
||||
_sync_merged_panel_identity_for_user,
|
||||
)
|
||||
from .common import _invalidate_webapp_user_caches
|
||||
from .telegram_notifications import _probe_telegram_notifications_for_user_id
|
||||
|
||||
|
||||
def _email_auth_enabled(settings: Settings) -> bool:
|
||||
return bool(getattr(settings, "email_auth_configured", True))
|
||||
|
||||
|
||||
def _email_auth_not_configured_response() -> web.Response:
|
||||
return _json_error(503, "email_auth_not_configured", "Email auth is not configured")
|
||||
|
||||
|
||||
async def account_email_request_route(request: web.Request) -> web.Response:
|
||||
user_id = _require_user_id(request)
|
||||
settings: Settings = request.app["settings"]
|
||||
if not _email_auth_enabled(settings):
|
||||
return _email_auth_not_configured_response()
|
||||
|
||||
payload = await _read_json(request)
|
||||
email_payload, validation_error = _validate_model_payload(WebAppEmailPayload, payload)
|
||||
if validation_error:
|
||||
@@ -31,6 +51,10 @@ async def account_email_request_route(request: web.Request) -> web.Response:
|
||||
|
||||
async def account_email_verify_route(request: web.Request) -> web.Response:
|
||||
user_id = _require_user_id(request)
|
||||
settings: Settings = request.app["settings"]
|
||||
if not _email_auth_enabled(settings):
|
||||
return _email_auth_not_configured_response()
|
||||
|
||||
rate_limit_response = await _enforce_webapp_rate_limit(
|
||||
request,
|
||||
user_id=user_id,
|
||||
@@ -46,7 +70,6 @@ async def account_email_verify_route(request: web.Request) -> web.Response:
|
||||
email = email_payload.email
|
||||
code = str(email_payload.code or "")
|
||||
email_service: EmailAuthService = request.app["email_auth_service"]
|
||||
settings: Settings = request.app["settings"]
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
merge_notice: Optional[Dict[str, Any]] = None
|
||||
source_panel_uuid: Optional[str] = None
|
||||
@@ -105,7 +128,8 @@ async def account_email_verify_route(request: web.Request) -> web.Response:
|
||||
)
|
||||
current_user.email = email
|
||||
current_user.email_verified_at = datetime.now(timezone.utc)
|
||||
await _sync_panel_identity_for_user(request, current_user)
|
||||
if not merge_notice:
|
||||
await _sync_panel_identity_for_user(request, current_user)
|
||||
await session.commit()
|
||||
final_user_id = int(current_user.user_id)
|
||||
final_telegram_id = _telegram_id_for_user(current_user)
|
||||
@@ -118,28 +142,13 @@ async def account_email_verify_route(request: web.Request) -> web.Response:
|
||||
merge_end_date = (
|
||||
datetime.fromisoformat(merge_end_date_raw) if merge_end_date_raw else None
|
||||
)
|
||||
await _sync_panel_identity_for_user(
|
||||
await _sync_merged_panel_identity_for_user(
|
||||
request,
|
||||
current_user,
|
||||
source_panel_uuid=source_panel_uuid,
|
||||
final_panel_uuid=final_panel_uuid,
|
||||
expire_at=merge_end_date,
|
||||
)
|
||||
# Best-effort cleanup of the removed panel account after the DB merge.
|
||||
if source_panel_uuid and final_panel_uuid and source_panel_uuid != final_panel_uuid:
|
||||
subscription_service: SubscriptionService = request.app.get(
|
||||
"subscription_service"
|
||||
)
|
||||
if subscription_service and subscription_service.panel_service:
|
||||
try:
|
||||
await subscription_service.panel_service.delete_user_from_panel(
|
||||
source_panel_uuid,
|
||||
log_response=False,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to delete merged source panel user %s: %s",
|
||||
source_panel_uuid,
|
||||
exc,
|
||||
)
|
||||
|
||||
email_service: EmailAuthService = request.app.get("email_auth_service")
|
||||
if email_service and final_email:
|
||||
@@ -173,6 +182,17 @@ async def account_email_verify_route(request: web.Request) -> web.Response:
|
||||
logger.exception("Email account link failed")
|
||||
return _json_error(500, "link_failed", "Link failed")
|
||||
|
||||
await _invalidate_webapp_user_caches(settings, user_id, final_user_id, include_devices=True)
|
||||
if merge_notice:
|
||||
await _notify_account_merged(
|
||||
request,
|
||||
settings,
|
||||
merge_notice=merge_notice,
|
||||
email=final_email,
|
||||
telegram_id=final_telegram_id,
|
||||
username=final_username,
|
||||
first_name=final_first_name,
|
||||
)
|
||||
if should_notify_email_linked:
|
||||
try:
|
||||
from bot.services.notification_service import NotificationService
|
||||
@@ -201,6 +221,91 @@ async def account_email_verify_route(request: web.Request) -> web.Response:
|
||||
return _build_webapp_auth_response(settings, response_payload, token=token)
|
||||
|
||||
|
||||
async def account_password_request_route(request: web.Request) -> web.Response:
|
||||
user_id = _require_user_id(request)
|
||||
settings: Settings = request.app["settings"]
|
||||
if not _email_auth_enabled(settings):
|
||||
return _email_auth_not_configured_response()
|
||||
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
|
||||
async with async_session_factory() as session:
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
if not db_user or db_user.is_banned:
|
||||
return _json_error(403, "access_denied", "Access denied")
|
||||
if not db_user.email or not db_user.email_verified_at:
|
||||
return _json_error(400, "email_not_linked", "Email is not linked")
|
||||
email = db_user.email
|
||||
lang = _normalize_language(db_user.language_code or settings.DEFAULT_LANGUAGE)
|
||||
|
||||
return await _request_email_code(
|
||||
request,
|
||||
email=email,
|
||||
purpose="set_password",
|
||||
language_code=lang,
|
||||
target_user_id=user_id,
|
||||
)
|
||||
|
||||
|
||||
async def account_password_confirm_route(request: web.Request) -> web.Response:
|
||||
user_id = _require_user_id(request)
|
||||
settings = request.app.get("settings")
|
||||
if not _email_auth_enabled(settings):
|
||||
return _email_auth_not_configured_response()
|
||||
|
||||
payload = await _read_json(request)
|
||||
password_payload, validation_error = _validate_model_payload(WebAppSetPasswordPayload, payload)
|
||||
if validation_error:
|
||||
return validation_error
|
||||
if password_payload.password != password_payload.password_confirm:
|
||||
return _json_error(400, "password_mismatch", "Passwords do not match")
|
||||
|
||||
settings: Settings = request.app["settings"]
|
||||
email_service: EmailAuthService = request.app["email_auth_service"]
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
if not db_user or db_user.is_banned:
|
||||
await session.rollback()
|
||||
return _json_error(403, "access_denied", "Access denied")
|
||||
if not db_user.email or not db_user.email_verified_at:
|
||||
await session.rollback()
|
||||
return _json_error(400, "email_not_linked", "Email is not linked")
|
||||
|
||||
verify_result = await email_service.verify_code(
|
||||
session,
|
||||
email=db_user.email,
|
||||
purpose="set_password",
|
||||
code=str(password_payload.code or ""),
|
||||
target_user_id=user_id,
|
||||
)
|
||||
if not verify_result.ok:
|
||||
await session.commit()
|
||||
status = 429 if verify_result.error == "rate_limited" else 400
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": False,
|
||||
"error": verify_result.error or "invalid_code",
|
||||
"retry_after": verify_result.retry_after,
|
||||
"message": "Invalid code",
|
||||
},
|
||||
status=status,
|
||||
)
|
||||
|
||||
db_user.password_hash = _hash_email_password(str(password_payload.password))
|
||||
db_user.password_set_at = datetime.now(timezone.utc)
|
||||
await session.flush()
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logger.exception("Email password setup failed")
|
||||
return _json_error(500, "password_setup_failed", "Password setup failed")
|
||||
|
||||
await _invalidate_webapp_user_caches(settings, user_id)
|
||||
return web.json_response({"ok": True, "password_auth_enabled": True})
|
||||
|
||||
|
||||
async def account_telegram_link_route(request: web.Request) -> web.Response:
|
||||
user_id = _require_user_id(request)
|
||||
settings: Settings = request.app["settings"]
|
||||
@@ -262,28 +367,13 @@ async def account_telegram_link_route(request: web.Request) -> web.Response:
|
||||
merge_end_date = (
|
||||
datetime.fromisoformat(merge_end_date_raw) if merge_end_date_raw else None
|
||||
)
|
||||
await _sync_panel_identity_for_user(
|
||||
await _sync_merged_panel_identity_for_user(
|
||||
request,
|
||||
db_user,
|
||||
source_panel_uuid=source_panel_uuid,
|
||||
final_panel_uuid=final_panel_uuid,
|
||||
expire_at=merge_end_date,
|
||||
)
|
||||
# Best-effort cleanup of the removed panel account after the DB merge.
|
||||
if source_panel_uuid and final_panel_uuid and source_panel_uuid != final_panel_uuid:
|
||||
subscription_service: SubscriptionService = request.app.get(
|
||||
"subscription_service"
|
||||
)
|
||||
if subscription_service and subscription_service.panel_service:
|
||||
try:
|
||||
await subscription_service.panel_service.delete_user_from_panel(
|
||||
source_panel_uuid,
|
||||
log_response=False,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to delete merged source panel user %s: %s",
|
||||
source_panel_uuid,
|
||||
exc,
|
||||
)
|
||||
|
||||
email_service: EmailAuthService = request.app.get("email_auth_service")
|
||||
if email_service and final_email:
|
||||
@@ -317,6 +407,17 @@ async def account_telegram_link_route(request: web.Request) -> web.Response:
|
||||
logger.exception("Telegram account link failed")
|
||||
return _json_error(500, "link_failed", "Link failed")
|
||||
|
||||
await _invalidate_webapp_user_caches(settings, user_id, final_user_id, include_devices=True)
|
||||
if merge_notice:
|
||||
await _notify_account_merged(
|
||||
request,
|
||||
settings,
|
||||
merge_notice=merge_notice,
|
||||
email=final_email,
|
||||
telegram_id=final_telegram_id,
|
||||
username=final_username,
|
||||
first_name=final_first_name,
|
||||
)
|
||||
if should_notify_telegram_linked and final_telegram_id:
|
||||
try:
|
||||
from bot.services.notification_service import NotificationService
|
||||
@@ -337,6 +438,8 @@ async def account_telegram_link_route(request: web.Request) -> web.Response:
|
||||
except Exception:
|
||||
logger.exception("Failed to send account Telegram linked notification")
|
||||
|
||||
await _probe_telegram_notifications_for_user_id(request, int(final_user_id))
|
||||
|
||||
token = create_webapp_session_token(settings, int(final_user_id))
|
||||
response_payload: Dict[str, Any] = {
|
||||
"ok": True,
|
||||
@@ -351,12 +454,24 @@ async def account_telegram_link_route(request: web.Request) -> web.Response:
|
||||
async def me_route(request: web.Request) -> web.Response:
|
||||
user_id = _require_user_id(request)
|
||||
settings: Settings = request.app["settings"]
|
||||
cache_key = redis_key(settings, "cache", "webapp", "me", user_id)
|
||||
cached = await cache_get_json(settings, cache_key)
|
||||
if cached:
|
||||
return web.json_response({"ok": True, **cached})
|
||||
data = await _build_user_payload(request, user_id)
|
||||
await cache_set_json(settings, cache_key, data, settings.WEBAPP_ME_CACHE_TTL_SECONDS)
|
||||
fresh = str(request.query.get("fresh") or "").strip().lower() in {
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
"on",
|
||||
}
|
||||
if fresh:
|
||||
await _invalidate_webapp_user_caches(settings, user_id)
|
||||
data = await _build_user_payload(request, user_id)
|
||||
return web.json_response({"ok": True, **data})
|
||||
|
||||
data = await webapp_cached_user_payload(
|
||||
settings,
|
||||
"me",
|
||||
user_id,
|
||||
int(getattr(settings, "WEBAPP_ME_CACHE_TTL_SECONDS", 15) or 0),
|
||||
lambda: _build_user_payload(request, user_id),
|
||||
)
|
||||
return web.json_response({"ok": True, **data})
|
||||
|
||||
|
||||
@@ -391,12 +506,18 @@ async def account_avatar_route(request: web.Request) -> web.Response:
|
||||
|
||||
async def account_language_route(request: web.Request) -> web.Response:
|
||||
user_id = _require_user_id(request)
|
||||
settings: Settings = request.app["settings"]
|
||||
payload = await _read_json(request)
|
||||
language_payload, validation_error = _validate_model_payload(WebAppLanguagePayload, payload)
|
||||
if validation_error:
|
||||
return validation_error
|
||||
|
||||
language = _normalize_language(str(language_payload.language or ""))
|
||||
i18n = request.app.get("i18n")
|
||||
if i18n and hasattr(i18n, "reload_overrides_from_file"):
|
||||
i18n.reload_overrides_from_file()
|
||||
if i18n and language not in getattr(i18n, "locales_data", {}):
|
||||
return _json_error(400, "unsupported_language", "Unsupported language")
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
@@ -409,6 +530,7 @@ async def account_language_route(request: web.Request) -> web.Response:
|
||||
await session.flush()
|
||||
await session.commit()
|
||||
|
||||
await _invalidate_webapp_user_caches(settings, user_id)
|
||||
return web.json_response({"ok": True, "language": language})
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
from .guides import warm_subscription_guides_config
|
||||
|
||||
|
||||
def create_subscription_webapp_application(
|
||||
@@ -20,17 +21,19 @@ def create_subscription_webapp_application(
|
||||
app["settings"] = settings
|
||||
app["async_session_factory"] = async_session_factory
|
||||
app["i18n"] = dp.get("i18n_instance")
|
||||
app["email_auth_service"] = EmailAuthService(settings)
|
||||
app["email_auth_service"] = EmailAuthService(settings, app["i18n"])
|
||||
app["webapp_logo_cache"] = None
|
||||
app["webapp_logo_cache_lock"] = asyncio.Lock()
|
||||
app["webapp_settings_cache"] = {"ts": 0.0, "data": {}}
|
||||
app["subscription_guides_config_cache"] = {"fingerprint": None, "status": None}
|
||||
app["subscription_guides_config_lock"] = asyncio.Lock()
|
||||
app["webapp_rate_limit_buckets"] = {}
|
||||
app["webapp_rate_limit_lock"] = asyncio.Lock()
|
||||
|
||||
async def _startup(app_obj: web.Application) -> None:
|
||||
await _ensure_shared_http_session()
|
||||
await _warm_webapp_logo_cache(app_obj)
|
||||
await _warm_webapp_animated_emoji_cache(app_obj)
|
||||
await warm_subscription_guides_config(app_obj)
|
||||
|
||||
async def _shutdown(app_obj: web.Application) -> None:
|
||||
await _close_shared_http_session()
|
||||
@@ -38,16 +41,17 @@ def create_subscription_webapp_application(
|
||||
app.on_startup.append(_startup)
|
||||
app.on_shutdown.append(_shutdown)
|
||||
|
||||
from bot.payment_providers import iter_service_keys
|
||||
|
||||
for key in (
|
||||
"subscription_service",
|
||||
"yookassa_service",
|
||||
"freekassa_service",
|
||||
"cryptopay_service",
|
||||
"platega_service",
|
||||
"severpay_service",
|
||||
"promo_code_service",
|
||||
"referral_service",
|
||||
"support_service",
|
||||
"notification_service",
|
||||
"email_auth_service",
|
||||
"panel_service",
|
||||
*iter_service_keys(),
|
||||
):
|
||||
if hasattr(dp, "workflow_data") and key in dp.workflow_data: # type: ignore[attr-defined]
|
||||
app[key] = dp.workflow_data[key] # type: ignore[index]
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
from .common import _invalidate_webapp_user_caches
|
||||
from .telegram_notifications import _probe_telegram_notifications_for_user_id
|
||||
|
||||
|
||||
def _resolve_telegram_bot_id(bot_token: str) -> Optional[int]:
|
||||
@@ -132,6 +134,58 @@ def _urlsafe_sha256(value: str) -> str:
|
||||
return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
|
||||
|
||||
|
||||
PASSWORD_HASH_ALGORITHM = "pbkdf2_sha256"
|
||||
PASSWORD_HASH_ITERATIONS = 260_000
|
||||
|
||||
|
||||
def _password_hash_b64(value: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(value).decode("ascii").rstrip("=")
|
||||
|
||||
|
||||
def _password_hash_unb64(value: str) -> bytes:
|
||||
padding = "=" * (-len(value) % 4)
|
||||
return base64.urlsafe_b64decode((value + padding).encode("ascii"))
|
||||
|
||||
|
||||
def _hash_email_password(password: str) -> str:
|
||||
salt = secrets.token_bytes(18)
|
||||
digest = hashlib.pbkdf2_hmac(
|
||||
"sha256",
|
||||
password.encode("utf-8"),
|
||||
salt,
|
||||
PASSWORD_HASH_ITERATIONS,
|
||||
)
|
||||
return "$".join(
|
||||
[
|
||||
PASSWORD_HASH_ALGORITHM,
|
||||
str(PASSWORD_HASH_ITERATIONS),
|
||||
_password_hash_b64(salt),
|
||||
_password_hash_b64(digest),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _verify_email_password(password: str, stored_hash: Optional[str]) -> bool:
|
||||
if not stored_hash:
|
||||
return False
|
||||
try:
|
||||
algorithm, iterations_raw, salt_raw, digest_raw = stored_hash.split("$", 3)
|
||||
if algorithm != PASSWORD_HASH_ALGORITHM:
|
||||
return False
|
||||
iterations = int(iterations_raw)
|
||||
salt = _password_hash_unb64(salt_raw)
|
||||
expected_digest = _password_hash_unb64(digest_raw)
|
||||
actual_digest = hashlib.pbkdf2_hmac(
|
||||
"sha256",
|
||||
password.encode("utf-8"),
|
||||
salt,
|
||||
iterations,
|
||||
)
|
||||
except Exception:
|
||||
return False
|
||||
return hmac.compare_digest(actual_digest, expected_digest)
|
||||
|
||||
|
||||
async def _exchange_telegram_oauth_code(
|
||||
request: web.Request,
|
||||
*,
|
||||
@@ -286,10 +340,20 @@ async def telegram_oauth_callback_route(request: web.Request) -> web.Response:
|
||||
redirect_path = "/settings" if purpose == "link" else "/"
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
final_user_id: Optional[int] = None
|
||||
source_user_id_for_cache: Optional[int] = None
|
||||
linked_user_for_panel: Optional[User] = None
|
||||
link_source_panel_uuid: Optional[str] = None
|
||||
link_final_panel_uuid: Optional[str] = None
|
||||
link_merge_notice: Optional[Dict[str, Any]] = None
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
if purpose == "link":
|
||||
current_user_id = int(state.get("user_id") or 0)
|
||||
source_user_id_for_cache = current_user_id
|
||||
current_user_before_link = await user_dal.get_user_by_id(session, current_user_id)
|
||||
link_source_panel_uuid = (
|
||||
current_user_before_link.panel_user_uuid if current_user_before_link else None
|
||||
)
|
||||
db_user = await _link_telegram_to_user(
|
||||
request,
|
||||
session,
|
||||
@@ -297,6 +361,16 @@ async def telegram_oauth_callback_route(request: web.Request) -> web.Response:
|
||||
telegram_user=telegram_user,
|
||||
settings=settings,
|
||||
)
|
||||
if int(db_user.user_id) != current_user_id:
|
||||
link_final_panel_uuid = db_user.panel_user_uuid
|
||||
link_merge_notice = await _build_account_merge_notice(
|
||||
session,
|
||||
merged_user=db_user,
|
||||
source_user_id=current_user_id,
|
||||
source_panel_uuid=link_source_panel_uuid,
|
||||
settings=settings,
|
||||
)
|
||||
linked_user_for_panel = db_user
|
||||
else:
|
||||
db_user = await _ensure_user_from_telegram(
|
||||
session,
|
||||
@@ -334,6 +408,38 @@ async def telegram_oauth_callback_route(request: web.Request) -> web.Response:
|
||||
logger.exception("Telegram OAuth callback failed")
|
||||
raise redirect(redirect_path, "failed")
|
||||
|
||||
await _invalidate_webapp_user_caches(settings, final_user_id, include_devices=True)
|
||||
if source_user_id_for_cache and source_user_id_for_cache != final_user_id:
|
||||
await _invalidate_webapp_user_caches(
|
||||
settings,
|
||||
source_user_id_for_cache,
|
||||
final_user_id,
|
||||
include_devices=True,
|
||||
)
|
||||
|
||||
if purpose == "link" and link_merge_notice and linked_user_for_panel:
|
||||
merge_end_date_raw = link_merge_notice.get("final_end_date")
|
||||
merge_end_date = datetime.fromisoformat(merge_end_date_raw) if merge_end_date_raw else None
|
||||
await _sync_merged_panel_identity_for_user(
|
||||
request,
|
||||
linked_user_for_panel,
|
||||
source_panel_uuid=link_source_panel_uuid,
|
||||
final_panel_uuid=link_final_panel_uuid,
|
||||
expire_at=merge_end_date,
|
||||
)
|
||||
await _notify_account_merged(
|
||||
request,
|
||||
settings,
|
||||
merge_notice=link_merge_notice,
|
||||
email=linked_user_for_panel.email,
|
||||
telegram_id=_telegram_id_for_user(linked_user_for_panel),
|
||||
username=linked_user_for_panel.username,
|
||||
first_name=linked_user_for_panel.first_name,
|
||||
)
|
||||
|
||||
if final_user_id:
|
||||
await _probe_telegram_notifications_for_user_id(request, int(final_user_id))
|
||||
|
||||
token = create_webapp_session_token(settings, int(final_user_id))
|
||||
response = web.HTTPFound(_telegram_oauth_redirect_url(redirect_path, status="success"))
|
||||
_clear_telegram_oauth_state_cookie(response)
|
||||
@@ -428,6 +534,8 @@ async def auth_token_route(request: web.Request) -> web.Response:
|
||||
logger.exception("WebApp auth failed")
|
||||
return _json_error(500, "auth_failed", "Auth failed")
|
||||
|
||||
await _invalidate_webapp_user_caches(settings, authenticated_user_id, include_devices=True)
|
||||
await _probe_telegram_notifications_for_user_id(request, int(authenticated_user_id))
|
||||
token = create_webapp_session_token(settings, int(authenticated_user_id))
|
||||
return _build_webapp_auth_response(settings, {"ok": True}, token=token)
|
||||
|
||||
@@ -438,6 +546,114 @@ async def logout_route(request: web.Request) -> web.Response:
|
||||
return response
|
||||
|
||||
|
||||
def _password_login_failure_response(
|
||||
*,
|
||||
status: int = 401,
|
||||
retry_after: Optional[int] = None,
|
||||
) -> web.Response:
|
||||
payload: Dict[str, Any] = {
|
||||
"ok": False,
|
||||
"error": "password_login_failed",
|
||||
"fallback": "email_code",
|
||||
"message": "Password login failed",
|
||||
}
|
||||
if retry_after is not None:
|
||||
payload["retry_after"] = retry_after
|
||||
return web.json_response(payload, status=status)
|
||||
|
||||
|
||||
async def email_password_auth_route(request: web.Request) -> web.Response:
|
||||
settings: Settings = request.app["settings"]
|
||||
if not settings.email_auth_configured:
|
||||
return _json_error(503, "email_auth_not_configured", "Email auth is not configured")
|
||||
|
||||
payload = await _read_json(request)
|
||||
password_payload, validation_error = _validate_model_payload(
|
||||
WebAppEmailPasswordPayload,
|
||||
payload,
|
||||
)
|
||||
if validation_error:
|
||||
return validation_error
|
||||
|
||||
email = password_payload.email
|
||||
password = str(password_payload.password or "")
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
authenticated_user_id: Optional[int] = None
|
||||
authenticated_telegram_id: Optional[int] = None
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
throttle = await security_dal.check_throttle(
|
||||
session,
|
||||
scope=security_dal.EMAIL_PASSWORD_LOGIN_SCOPE,
|
||||
identifier=email,
|
||||
now=now,
|
||||
)
|
||||
if throttle.locked:
|
||||
await session.commit()
|
||||
return _json_error(
|
||||
429,
|
||||
"rate_limited",
|
||||
"Too many password attempts",
|
||||
)
|
||||
|
||||
db_user = await user_dal.get_user_by_email(session, email)
|
||||
password_ok = bool(
|
||||
db_user
|
||||
and db_user.email_verified_at
|
||||
and db_user.password_hash
|
||||
and _verify_email_password(password, db_user.password_hash)
|
||||
)
|
||||
|
||||
if not password_ok:
|
||||
throttle_result = await security_dal.record_throttle_failure(
|
||||
session,
|
||||
scope=security_dal.EMAIL_PASSWORD_LOGIN_SCOPE,
|
||||
identifier=email,
|
||||
max_failures=settings.BRUTE_FORCE_MAX_FAILURES,
|
||||
window_seconds=settings.BRUTE_FORCE_WINDOW_SECONDS,
|
||||
lock_seconds=settings.BRUTE_FORCE_LOCK_SECONDS,
|
||||
now=now,
|
||||
)
|
||||
await session.commit()
|
||||
if throttle_result.locked:
|
||||
return _json_error(
|
||||
429,
|
||||
"rate_limited",
|
||||
"Too many password attempts",
|
||||
)
|
||||
return _password_login_failure_response()
|
||||
|
||||
if db_user.is_banned:
|
||||
await session.rollback()
|
||||
return _json_error(403, "banned", "Access denied")
|
||||
|
||||
await security_dal.clear_throttle_state(
|
||||
session,
|
||||
scope=security_dal.EMAIL_PASSWORD_LOGIN_SCOPE,
|
||||
identifier=email,
|
||||
)
|
||||
authenticated_user_id = int(db_user.user_id)
|
||||
authenticated_telegram_id = _telegram_id_for_user(db_user)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logger.exception("Email password auth failed")
|
||||
return _json_error(500, "auth_failed", "Auth failed")
|
||||
|
||||
token = create_webapp_session_token(settings, int(authenticated_user_id))
|
||||
return _build_webapp_auth_response(
|
||||
settings,
|
||||
{
|
||||
"ok": True,
|
||||
"user_id": int(authenticated_user_id),
|
||||
"telegram_id": authenticated_telegram_id,
|
||||
},
|
||||
token=token,
|
||||
)
|
||||
|
||||
|
||||
async def email_auth_request_route(request: web.Request) -> web.Response:
|
||||
settings: Settings = request.app["settings"]
|
||||
payload = await _read_json(request)
|
||||
@@ -497,6 +713,7 @@ async def email_auth_verify_route(request: web.Request) -> web.Response:
|
||||
session,
|
||||
referral_param,
|
||||
current_user_id=None,
|
||||
settings=settings,
|
||||
)
|
||||
db_user, _ = await user_dal.create_email_user(
|
||||
session,
|
||||
@@ -534,6 +751,7 @@ async def email_auth_verify_route(request: web.Request) -> web.Response:
|
||||
logger.exception("Email WebApp auth failed")
|
||||
return _json_error(500, "auth_failed", "Auth failed")
|
||||
|
||||
await _invalidate_webapp_user_caches(settings, int(db_user.user_id), include_devices=True)
|
||||
if created_user:
|
||||
try:
|
||||
from bot.services.notification_service import NotificationService
|
||||
@@ -604,6 +822,7 @@ async def email_auth_magic_route(request: web.Request) -> web.Response:
|
||||
session,
|
||||
referral_param,
|
||||
current_user_id=None,
|
||||
settings=settings,
|
||||
)
|
||||
db_user, _ = await user_dal.create_email_user(
|
||||
session,
|
||||
@@ -641,6 +860,7 @@ async def email_auth_magic_route(request: web.Request) -> web.Response:
|
||||
logger.exception("Email magic-link auth failed")
|
||||
return _json_error(500, "auth_failed", "Auth failed")
|
||||
|
||||
await _invalidate_webapp_user_caches(settings, int(db_user.user_id), include_devices=True)
|
||||
if created_user and verified_email:
|
||||
try:
|
||||
from bot.services.notification_service import NotificationService
|
||||
@@ -792,21 +1012,67 @@ async def _request_email_code(
|
||||
|
||||
|
||||
def _telegram_id_for_user(user: User) -> Optional[int]:
|
||||
if user.telegram_id:
|
||||
return int(user.telegram_id)
|
||||
if user.user_id and int(user.user_id) > 0:
|
||||
return int(user.user_id)
|
||||
telegram_id = getattr(user, "telegram_id", None)
|
||||
if telegram_id:
|
||||
return int(telegram_id)
|
||||
user_id = getattr(user, "user_id", None)
|
||||
if user_id and int(user_id) > 0:
|
||||
return int(user_id)
|
||||
return None
|
||||
|
||||
|
||||
def _user_has_linked_telegram(user: User) -> bool:
|
||||
return bool(getattr(user, "telegram_id", None))
|
||||
|
||||
|
||||
def _email_only_telegram_required_reason(
|
||||
settings: Settings,
|
||||
user: User,
|
||||
*,
|
||||
without_telegram_enabled_attr: str,
|
||||
) -> Optional[str]:
|
||||
if _user_has_linked_telegram(user):
|
||||
return None
|
||||
if is_disposable_email(getattr(user, "email", None), settings):
|
||||
return "disposable_email"
|
||||
if not bool(getattr(settings, without_telegram_enabled_attr, True)):
|
||||
return "telegram_required"
|
||||
return None
|
||||
|
||||
|
||||
def _trial_telegram_required_reason(settings: Settings, user: User) -> Optional[str]:
|
||||
return _email_only_telegram_required_reason(
|
||||
settings,
|
||||
user,
|
||||
without_telegram_enabled_attr="TRIAL_WITHOUT_TELEGRAM_ENABLED",
|
||||
)
|
||||
|
||||
|
||||
def _referral_welcome_telegram_required_reason(
|
||||
settings: Settings,
|
||||
user: User,
|
||||
) -> Optional[str]:
|
||||
return _email_only_telegram_required_reason(
|
||||
settings,
|
||||
user,
|
||||
without_telegram_enabled_attr="REFERRAL_WELCOME_BONUS_WITHOUT_TELEGRAM_ENABLED",
|
||||
)
|
||||
|
||||
|
||||
def _panel_description_for_user(user: User) -> str:
|
||||
lines = [
|
||||
user.email or "",
|
||||
user.username or "",
|
||||
user.first_name or "",
|
||||
user.last_name or "",
|
||||
]
|
||||
return "\n".join(line for line in lines if line).strip()
|
||||
return panel_description_from_profile(
|
||||
user.username,
|
||||
user.first_name,
|
||||
user.last_name,
|
||||
)
|
||||
|
||||
|
||||
def _telegram_photo_url_value(telegram_user: Dict[str, Any]) -> Optional[str]:
|
||||
raw_value = telegram_user.get("photo_url")
|
||||
if not raw_value:
|
||||
return None
|
||||
value = str(raw_value).strip()
|
||||
return value or None
|
||||
|
||||
|
||||
async def _sync_panel_identity_for_user(
|
||||
@@ -821,23 +1087,33 @@ async def _sync_panel_identity_for_user(
|
||||
if not subscription_service or not subscription_service.panel_service:
|
||||
return False
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
"description": _panel_description_for_user(user),
|
||||
}
|
||||
payload: Dict[str, Any] = {}
|
||||
telegram_id = _telegram_id_for_user(user)
|
||||
if telegram_id:
|
||||
payload["telegramId"] = telegram_id
|
||||
if user.email:
|
||||
payload["email"] = user.email
|
||||
if expire_at is not None:
|
||||
if expire_at.tzinfo is None:
|
||||
expire_at = expire_at.replace(tzinfo=timezone.utc)
|
||||
payload["expireAt"] = expire_at.isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
||||
if expire_at > datetime.now(timezone.utc):
|
||||
payload["status"] = "ACTIVE"
|
||||
|
||||
try:
|
||||
await subscription_service.panel_service.update_user_details_on_panel(
|
||||
updated_panel_user = await subscription_service.panel_service.update_user_details_on_panel(
|
||||
user.panel_user_uuid,
|
||||
payload,
|
||||
log_response=False,
|
||||
)
|
||||
if not updated_panel_user or (
|
||||
isinstance(updated_panel_user, dict) and updated_panel_user.get("error")
|
||||
):
|
||||
logger.warning(
|
||||
"Panel identity update returned no success payload for user %s",
|
||||
user.user_id,
|
||||
)
|
||||
return False
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
@@ -848,6 +1124,53 @@ async def _sync_panel_identity_for_user(
|
||||
return False
|
||||
|
||||
|
||||
async def _delete_merged_source_panel_user(
|
||||
request: web.Request,
|
||||
*,
|
||||
source_panel_uuid: Optional[str],
|
||||
final_panel_uuid: Optional[str],
|
||||
) -> bool:
|
||||
if not source_panel_uuid or not final_panel_uuid or source_panel_uuid == final_panel_uuid:
|
||||
return True
|
||||
|
||||
subscription_service: SubscriptionService = request.app.get("subscription_service")
|
||||
if not subscription_service or not subscription_service.panel_service:
|
||||
return False
|
||||
|
||||
try:
|
||||
return bool(
|
||||
await subscription_service.panel_service.delete_user_from_panel(
|
||||
source_panel_uuid,
|
||||
log_response=False,
|
||||
)
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to delete merged source panel user %s: %s",
|
||||
source_panel_uuid,
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
async def _sync_merged_panel_identity_for_user(
|
||||
request: web.Request,
|
||||
user: User,
|
||||
*,
|
||||
source_panel_uuid: Optional[str],
|
||||
final_panel_uuid: Optional[str],
|
||||
expire_at: Optional[datetime] = None,
|
||||
) -> bool:
|
||||
# Remnawave keeps email/telegramId unique. Remove the losing panel identity
|
||||
# before patching the surviving one so merged accounts can accept both IDs.
|
||||
await _delete_merged_source_panel_user(
|
||||
request,
|
||||
source_panel_uuid=source_panel_uuid,
|
||||
final_panel_uuid=final_panel_uuid or user.panel_user_uuid,
|
||||
)
|
||||
return await _sync_panel_identity_for_user(request, user, expire_at=expire_at)
|
||||
|
||||
|
||||
async def _build_account_merge_notice(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
@@ -885,16 +1208,50 @@ async def _build_account_merge_notice(
|
||||
}
|
||||
|
||||
|
||||
async def _notify_account_merged(
|
||||
request: web.Request,
|
||||
settings: Settings,
|
||||
*,
|
||||
merge_notice: Optional[Dict[str, Any]],
|
||||
email: Optional[str],
|
||||
telegram_id: Optional[int],
|
||||
username: Optional[str],
|
||||
first_name: Optional[str],
|
||||
) -> None:
|
||||
if not merge_notice:
|
||||
return
|
||||
try:
|
||||
from bot.services.notification_service import NotificationService
|
||||
|
||||
bot: Bot = request.app["bot"]
|
||||
notification_service = NotificationService(
|
||||
bot,
|
||||
settings,
|
||||
request.app.get("i18n"),
|
||||
)
|
||||
await notification_service.notify_account_merged(
|
||||
primary_user_id=int(merge_notice.get("primary_user_id") or 0),
|
||||
removed_user_id=int(merge_notice.get("removed_user_id") or 0),
|
||||
email=email,
|
||||
telegram_id=telegram_id,
|
||||
username=username,
|
||||
first_name=first_name,
|
||||
final_end_date_text=str(merge_notice.get("final_end_date_text") or ""),
|
||||
primary_panel_user_uuid=merge_notice.get("primary_panel_user_uuid"),
|
||||
removed_panel_user_uuid=merge_notice.get("removed_panel_user_uuid"),
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to send account merged notification")
|
||||
|
||||
|
||||
def _apply_telegram_profile_to_user(
|
||||
user: User,
|
||||
telegram_user: Dict[str, Any],
|
||||
settings: Settings,
|
||||
) -> None:
|
||||
language_code = (
|
||||
telegram_user.get("language_code") or user.language_code or settings.DEFAULT_LANGUAGE
|
||||
language_code = _normalize_language(
|
||||
user.language_code or telegram_user.get("language_code") or settings.DEFAULT_LANGUAGE
|
||||
)
|
||||
if language_code not in {"ru", "en"}:
|
||||
language_code = user.language_code or settings.DEFAULT_LANGUAGE
|
||||
|
||||
user.telegram_id = int(telegram_user["id"])
|
||||
user.username = sanitize_username(telegram_user.get("username"))
|
||||
@@ -937,17 +1294,14 @@ async def _link_telegram_to_user(
|
||||
)
|
||||
_apply_telegram_profile_to_user(merged_user, telegram_user, settings)
|
||||
await session.flush()
|
||||
await _sync_panel_identity_for_user(request, merged_user)
|
||||
return merged_user
|
||||
|
||||
if not existing_telegram_user and int(current_user.user_id) < 0:
|
||||
language_code = (
|
||||
telegram_user.get("language_code")
|
||||
or current_user.language_code
|
||||
language_code = _normalize_language(
|
||||
current_user.language_code
|
||||
or telegram_user.get("language_code")
|
||||
or settings.DEFAULT_LANGUAGE
|
||||
)
|
||||
if language_code not in {"ru", "en"}:
|
||||
language_code = current_user.language_code or settings.DEFAULT_LANGUAGE
|
||||
target_user, _ = await user_dal.create_user(
|
||||
session,
|
||||
{
|
||||
@@ -969,7 +1323,6 @@ async def _link_telegram_to_user(
|
||||
)
|
||||
_apply_telegram_profile_to_user(merged_user, telegram_user, settings)
|
||||
await session.flush()
|
||||
await _sync_panel_identity_for_user(request, merged_user)
|
||||
return merged_user
|
||||
|
||||
if current_user.telegram_id and int(current_user.telegram_id) != telegram_id:
|
||||
@@ -981,17 +1334,35 @@ async def _link_telegram_to_user(
|
||||
return current_user
|
||||
|
||||
|
||||
def _normalize_referral_param(raw: Optional[str]) -> Optional[str]:
|
||||
def _remnashop_referral_compat_enabled(settings: Optional[Settings]) -> bool:
|
||||
if settings is None:
|
||||
return False
|
||||
return bool(getattr(settings, "MIGRATION_REMNASHOP_REFERRAL_CODE_COMPAT_ENABLED", False))
|
||||
|
||||
|
||||
def _strip_referral_param_prefix(
|
||||
raw: Optional[str],
|
||||
*,
|
||||
preserve_current_u_prefix: bool,
|
||||
) -> str:
|
||||
value = (raw or "").strip()
|
||||
if not value:
|
||||
return None
|
||||
return ""
|
||||
|
||||
value_lower = value.lower()
|
||||
if value_lower.startswith("ref_u"):
|
||||
if value_lower.startswith("ref_u") and not preserve_current_u_prefix:
|
||||
value = value[5:]
|
||||
elif value_lower.startswith("ref_"):
|
||||
value = value[4:]
|
||||
elif value and value[0].lower() == "u" and len(value) == 10:
|
||||
return value
|
||||
|
||||
|
||||
def _normalize_referral_param(raw: Optional[str]) -> Optional[str]:
|
||||
value = _strip_referral_param_prefix(raw, preserve_current_u_prefix=False)
|
||||
if not value:
|
||||
return None
|
||||
|
||||
if value and value[0].lower() == "u" and len(value) == 10:
|
||||
value = value[1:]
|
||||
|
||||
if not re.fullmatch(r"[A-Za-z0-9]{1,32}", value):
|
||||
@@ -999,26 +1370,64 @@ def _normalize_referral_param(raw: Optional[str]) -> Optional[str]:
|
||||
return value.upper()
|
||||
|
||||
|
||||
def _referral_param_lookup_candidates(
|
||||
raw: Optional[str],
|
||||
*,
|
||||
remnashop_compat: bool,
|
||||
) -> List[str]:
|
||||
if not remnashop_compat:
|
||||
normalized = _normalize_referral_param(raw)
|
||||
return [normalized] if normalized else []
|
||||
|
||||
value = _strip_referral_param_prefix(raw, preserve_current_u_prefix=True)
|
||||
if not value or not re.fullmatch(r"[A-Za-z0-9._:-]{1,128}", value):
|
||||
return []
|
||||
|
||||
candidates = [value]
|
||||
if value and value[0].lower() == "u":
|
||||
candidates.append(value[1:])
|
||||
|
||||
unique: List[str] = []
|
||||
for candidate in candidates:
|
||||
if candidate and candidate not in unique:
|
||||
unique.append(candidate)
|
||||
return unique
|
||||
|
||||
|
||||
async def _resolve_referrer_id(
|
||||
session: AsyncSession,
|
||||
raw_referral_param: Optional[str],
|
||||
*,
|
||||
current_user_id: Optional[int],
|
||||
settings: Optional[Settings] = None,
|
||||
) -> Optional[int]:
|
||||
normalized = _normalize_referral_param(raw_referral_param)
|
||||
if not normalized:
|
||||
remnashop_compat = _remnashop_referral_compat_enabled(settings)
|
||||
candidates = _referral_param_lookup_candidates(
|
||||
raw_referral_param,
|
||||
remnashop_compat=remnashop_compat,
|
||||
)
|
||||
if not candidates:
|
||||
return None
|
||||
|
||||
ref_user = None
|
||||
if normalized.isdigit():
|
||||
ref_user = await user_dal.get_user_by_id(session, int(normalized))
|
||||
if not ref_user:
|
||||
ref_user = await user_dal.get_user_by_referral_code(session, normalized)
|
||||
if not ref_user:
|
||||
return None
|
||||
if current_user_id is not None and int(ref_user.user_id) == int(current_user_id):
|
||||
return None
|
||||
return int(ref_user.user_id)
|
||||
for normalized in candidates:
|
||||
ref_user = None
|
||||
if normalized.isdigit() and not remnashop_compat:
|
||||
ref_user = await user_dal.get_user_by_id(session, int(normalized))
|
||||
if not ref_user:
|
||||
ref_user = await user_dal.get_user_by_referral_code(
|
||||
session,
|
||||
normalized,
|
||||
include_legacy=remnashop_compat,
|
||||
)
|
||||
if not ref_user and normalized.isdigit() and remnashop_compat:
|
||||
ref_user = await user_dal.get_user_by_id(session, int(normalized))
|
||||
if not ref_user:
|
||||
continue
|
||||
if current_user_id is not None and int(ref_user.user_id) == int(current_user_id):
|
||||
continue
|
||||
return int(ref_user.user_id)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def _apply_referral_to_existing_user(
|
||||
@@ -1034,6 +1443,7 @@ async def _apply_referral_to_existing_user(
|
||||
session,
|
||||
raw_referral_param,
|
||||
current_user_id=int(user.user_id),
|
||||
settings=request.app["settings"],
|
||||
)
|
||||
if not referred_by_id:
|
||||
return False
|
||||
@@ -1063,6 +1473,21 @@ async def _apply_referral_welcome_bonus_if_needed(
|
||||
if not raw_referral_param or not user.referred_by_id:
|
||||
return None
|
||||
|
||||
settings: Settings = request.app["settings"]
|
||||
if _referral_welcome_telegram_required_reason(settings, user):
|
||||
return None
|
||||
|
||||
return await _grant_referral_welcome_bonus_if_eligible(request, session, user)
|
||||
|
||||
|
||||
async def _grant_referral_welcome_bonus_if_eligible(
|
||||
request: web.Request,
|
||||
session: AsyncSession,
|
||||
user: User,
|
||||
) -> Optional[datetime]:
|
||||
if not user.referred_by_id:
|
||||
return None
|
||||
|
||||
settings: Settings = request.app["settings"]
|
||||
referral_welcome_days = max(
|
||||
0,
|
||||
@@ -1072,6 +1497,10 @@ async def _apply_referral_welcome_bonus_if_needed(
|
||||
return None
|
||||
|
||||
subscription_service: SubscriptionService = request.app["subscription_service"]
|
||||
default_tariff_key = None
|
||||
tariffs_config = getattr(settings, "tariffs_config", None)
|
||||
if tariffs_config:
|
||||
default_tariff_key = getattr(tariffs_config, "default_tariff", None)
|
||||
try:
|
||||
if await subscription_service.has_active_subscription(session, int(user.user_id)):
|
||||
return None
|
||||
@@ -1083,6 +1512,68 @@ async def _apply_referral_welcome_bonus_if_needed(
|
||||
int(user.user_id),
|
||||
referral_welcome_days,
|
||||
reason="referral_welcome_bonus",
|
||||
tariff_key=default_tariff_key,
|
||||
)
|
||||
|
||||
|
||||
def _webapp_datetime_text(value: Optional[datetime]) -> Optional[str]:
|
||||
if not value:
|
||||
return None
|
||||
normalized = value if value.tzinfo else value.replace(tzinfo=timezone.utc)
|
||||
return normalized.strftime("%d.%m.%Y %H:%M")
|
||||
|
||||
|
||||
async def referral_welcome_bonus_claim_route(request: web.Request) -> web.Response:
|
||||
user_id = _require_user_id(request)
|
||||
rate_limit_response = await _enforce_webapp_rate_limit(
|
||||
request,
|
||||
user_id=user_id,
|
||||
action="referral_welcome_claim",
|
||||
)
|
||||
if rate_limit_response:
|
||||
return rate_limit_response
|
||||
|
||||
settings: Settings = request.app["settings"]
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
if not db_user or db_user.is_banned:
|
||||
await session.rollback()
|
||||
return _json_error(403, "access_denied", "Access denied")
|
||||
|
||||
reason = _referral_welcome_telegram_required_reason(settings, db_user)
|
||||
if reason:
|
||||
await session.rollback()
|
||||
return _json_error(400, "referral_welcome_telegram_required", reason)
|
||||
|
||||
end_date = await _grant_referral_welcome_bonus_if_eligible(
|
||||
request,
|
||||
session,
|
||||
db_user,
|
||||
)
|
||||
if not end_date:
|
||||
await session.rollback()
|
||||
return _json_error(
|
||||
400,
|
||||
"referral_welcome_unavailable",
|
||||
"Referral welcome bonus is not available",
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logger.exception("Referral welcome bonus claim failed")
|
||||
return _json_error(500, "referral_welcome_failed", "Referral welcome bonus failed")
|
||||
|
||||
await _invalidate_webapp_user_caches(settings, user_id, include_devices=True)
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": True,
|
||||
"claimed": True,
|
||||
"end_date": end_date.isoformat() if isinstance(end_date, datetime) else None,
|
||||
"end_date_text": _webapp_datetime_text(end_date),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -1094,20 +1585,19 @@ async def _ensure_user_from_telegram(
|
||||
referral_param: Optional[str] = None,
|
||||
) -> User:
|
||||
user_id = int(telegram_user["id"])
|
||||
language_code = telegram_user.get("language_code") or settings.DEFAULT_LANGUAGE
|
||||
if language_code not in {"ru", "en"}:
|
||||
language_code = settings.DEFAULT_LANGUAGE
|
||||
telegram_language_code = _normalize_language(
|
||||
telegram_user.get("language_code") or settings.DEFAULT_LANGUAGE
|
||||
)
|
||||
|
||||
update_data = {
|
||||
profile_data = {
|
||||
"telegram_id": user_id,
|
||||
"username": sanitize_username(telegram_user.get("username")),
|
||||
"first_name": sanitize_display_name(telegram_user.get("first_name")),
|
||||
"last_name": sanitize_display_name(telegram_user.get("last_name")),
|
||||
"language_code": language_code,
|
||||
}
|
||||
telegram_photo_url = _telegram_photo_url_value(telegram_user)
|
||||
if telegram_photo_url:
|
||||
update_data["telegram_photo_url"] = telegram_photo_url
|
||||
profile_data["telegram_photo_url"] = telegram_photo_url
|
||||
|
||||
db_user = await user_dal.get_user_by_telegram_id(session, user_id)
|
||||
if not db_user:
|
||||
@@ -1117,12 +1607,14 @@ async def _ensure_user_from_telegram(
|
||||
session,
|
||||
referral_param or telegram_user.get("start_param"),
|
||||
current_user_id=user_id,
|
||||
settings=settings,
|
||||
)
|
||||
db_user, created = await user_dal.create_user(
|
||||
session,
|
||||
{
|
||||
"user_id": user_id,
|
||||
**update_data,
|
||||
**profile_data,
|
||||
"language_code": telegram_language_code,
|
||||
"referred_by_id": referred_by_id,
|
||||
"registration_date": datetime.now(timezone.utc),
|
||||
},
|
||||
@@ -1130,6 +1622,10 @@ async def _ensure_user_from_telegram(
|
||||
setattr(db_user, "_webapp_created", bool(created))
|
||||
return db_user
|
||||
|
||||
update_data = {
|
||||
**profile_data,
|
||||
"language_code": _normalize_language(db_user.language_code or telegram_language_code),
|
||||
}
|
||||
changed = {key: value for key, value in update_data.items() if getattr(db_user, key) != value}
|
||||
if changed:
|
||||
db_user = await user_dal.update_user(session, db_user.user_id, changed) or db_user
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Awaitable, Callable, Optional
|
||||
|
||||
from bot.infra.redis import cache_delete, cache_delete_pattern, redis_key
|
||||
from bot.utils.ttl_cache import AsyncTTLCache
|
||||
from config.settings import Settings
|
||||
|
||||
_WEBAPP_USER_PAYLOAD_CACHES: dict[tuple[int, str, int], AsyncTTLCache] = {}
|
||||
|
||||
|
||||
def reset_webapp_settings_cache(app: Any) -> None:
|
||||
cache = app.get("webapp_settings_cache") if hasattr(app, "get") else None
|
||||
if isinstance(cache, dict):
|
||||
cache["ts"] = 0.0
|
||||
cache["data"] = {}
|
||||
|
||||
|
||||
def reset_subscription_guides_cache(app: Any) -> None:
|
||||
cache = app.get("subscription_guides_config_cache") if hasattr(app, "get") else None
|
||||
if isinstance(cache, dict):
|
||||
cache["fingerprint"] = None
|
||||
cache["status"] = None
|
||||
|
||||
|
||||
def _payload_namespaces(include_devices: bool = False) -> tuple[str, ...]:
|
||||
return ("me", "devices") if include_devices else ("me",)
|
||||
|
||||
|
||||
def _webapp_user_payload_cache(
|
||||
settings: Settings,
|
||||
namespace: str,
|
||||
ttl_seconds: int,
|
||||
) -> Optional[AsyncTTLCache]:
|
||||
ttl = max(0, int(ttl_seconds or 0))
|
||||
if ttl <= 0:
|
||||
return None
|
||||
cache_key = (id(settings), namespace, ttl)
|
||||
cache = _WEBAPP_USER_PAYLOAD_CACHES.get(cache_key)
|
||||
if cache is None:
|
||||
cache = AsyncTTLCache(
|
||||
ttl_seconds=ttl,
|
||||
settings=settings,
|
||||
namespace=f"webapp:{namespace}",
|
||||
)
|
||||
_WEBAPP_USER_PAYLOAD_CACHES[cache_key] = cache
|
||||
return cache
|
||||
|
||||
|
||||
async def webapp_cached_user_payload(
|
||||
settings: Settings,
|
||||
namespace: str,
|
||||
user_id: int,
|
||||
ttl_seconds: int,
|
||||
loader: Callable[[], Awaitable[Any]],
|
||||
) -> Any:
|
||||
cache = _webapp_user_payload_cache(settings, namespace, ttl_seconds)
|
||||
if cache is None:
|
||||
return await loader()
|
||||
return await cache.get_or_load(str(int(user_id)), loader)
|
||||
|
||||
|
||||
def invalidate_local_webapp_user_payload(
|
||||
settings: Settings,
|
||||
namespace: str,
|
||||
user_id: int,
|
||||
) -> None:
|
||||
key = str(int(user_id))
|
||||
for (settings_id, cache_namespace, _ttl), cache in tuple(_WEBAPP_USER_PAYLOAD_CACHES.items()):
|
||||
if settings_id == id(settings) and cache_namespace == namespace:
|
||||
cache.invalidate(key)
|
||||
|
||||
|
||||
def invalidate_all_local_webapp_user_payloads(
|
||||
settings: Settings,
|
||||
namespace: Optional[str] = None,
|
||||
*,
|
||||
include_devices: Optional[bool] = None,
|
||||
) -> None:
|
||||
if include_devices is not None:
|
||||
namespaces: Optional[set[str]] = set(_payload_namespaces(include_devices))
|
||||
elif namespace is not None:
|
||||
namespaces = {namespace}
|
||||
else:
|
||||
namespaces = None
|
||||
|
||||
for (settings_id, cache_namespace, _ttl), cache in tuple(_WEBAPP_USER_PAYLOAD_CACHES.items()):
|
||||
if settings_id != id(settings):
|
||||
continue
|
||||
if namespaces is not None and cache_namespace not in namespaces:
|
||||
continue
|
||||
cache.invalidate()
|
||||
|
||||
|
||||
async def invalidate_webapp_user_caches(
|
||||
settings: Settings,
|
||||
*user_ids: Optional[int],
|
||||
include_devices: bool = False,
|
||||
) -> None:
|
||||
keys: list[str] = []
|
||||
seen: set[int] = set()
|
||||
for raw_user_id in user_ids:
|
||||
if raw_user_id is None:
|
||||
continue
|
||||
try:
|
||||
user_id = int(raw_user_id)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if user_id in seen:
|
||||
continue
|
||||
seen.add(user_id)
|
||||
keys.append(redis_key(settings, "cache", "webapp", "me", user_id))
|
||||
invalidate_local_webapp_user_payload(settings, "me", user_id)
|
||||
if include_devices:
|
||||
keys.append(redis_key(settings, "cache", "webapp", "devices", user_id))
|
||||
invalidate_local_webapp_user_payload(settings, "devices", user_id)
|
||||
if keys:
|
||||
await cache_delete(settings, *keys)
|
||||
|
||||
|
||||
async def invalidate_all_webapp_user_payloads(
|
||||
settings: Settings,
|
||||
*,
|
||||
include_devices: bool = False,
|
||||
) -> None:
|
||||
for namespace in _payload_namespaces(include_devices):
|
||||
invalidate_all_local_webapp_user_payloads(settings, namespace=namespace)
|
||||
try:
|
||||
pattern = redis_key(settings, "cache", "webapp", namespace, "*")
|
||||
await cache_delete_pattern(settings, pattern)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
|
||||
async def invalidate_all_webapp_user_caches(
|
||||
settings: Settings,
|
||||
*,
|
||||
include_devices: bool = False,
|
||||
) -> None:
|
||||
await invalidate_all_webapp_user_payloads(settings, include_devices=include_devices)
|
||||
@@ -1,6 +1,14 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
from bot.app.web.webapp.cache_helpers import (
|
||||
invalidate_webapp_user_caches as _invalidate_user_payload_caches,
|
||||
)
|
||||
from bot.middlewares.i18n import (
|
||||
is_valid_locale_language_code,
|
||||
normalize_locale_language_code,
|
||||
)
|
||||
|
||||
|
||||
async def _read_json(request: web.Request) -> Dict[str, Any]:
|
||||
try:
|
||||
@@ -17,6 +25,14 @@ def _json_error(status: int, code: str, message: str) -> web.Response:
|
||||
)
|
||||
|
||||
|
||||
async def _invalidate_webapp_user_caches(
|
||||
settings: Settings,
|
||||
*user_ids: Optional[int],
|
||||
include_devices: bool = False,
|
||||
) -> None:
|
||||
await _invalidate_user_payload_caches(settings, *user_ids, include_devices=include_devices)
|
||||
|
||||
|
||||
def _validation_error_response(exc: ValidationError) -> web.Response:
|
||||
for error in exc.errors():
|
||||
loc = error.get("loc") or ()
|
||||
@@ -37,6 +53,13 @@ def _validation_error_response(exc: ValidationError) -> web.Response:
|
||||
if field in {"description", "comment", "note"} and error_type == "string_too_long":
|
||||
return _json_error(400, f"{field}_too_long", f"{field.capitalize()} is too long")
|
||||
|
||||
if field in {"password", "password_confirm"}:
|
||||
if error_type == "string_too_short":
|
||||
return _json_error(400, "password_too_short", "Password is too short")
|
||||
if error_type == "string_too_long":
|
||||
return _json_error(400, "password_too_long", "Password is too long")
|
||||
return _json_error(400, "invalid_password", "Invalid password")
|
||||
|
||||
if error_type == "string_too_long":
|
||||
return _json_error(400, "text_too_long", "Text is too long")
|
||||
|
||||
@@ -54,8 +77,8 @@ def _validate_model_payload(
|
||||
|
||||
|
||||
def _normalize_language(lang: Optional[str]) -> str:
|
||||
value = (lang or "ru").split("-")[0].lower()
|
||||
return value if value in {"ru", "en"} else "ru"
|
||||
value = normalize_locale_language_code(lang, prefer_known_base=False)
|
||||
return value if is_valid_locale_language_code(value) else "ru"
|
||||
|
||||
|
||||
def _format_remaining(seconds: int, lang: str) -> str:
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
from bot.app.web.webapp.cache_helpers import webapp_cached_user_payload
|
||||
|
||||
|
||||
async def devices_route(request: web.Request) -> web.Response:
|
||||
user_id = _require_user_id(request)
|
||||
@@ -15,35 +17,107 @@ async def devices_route(request: web.Request) -> web.Response:
|
||||
if not db_user or db_user.is_banned:
|
||||
return _json_error(403, "access_denied", "Access denied")
|
||||
|
||||
active = await subscription_service.get_active_subscription_details(session, user_id)
|
||||
panel_user_uuid = active.get("user_id") if active else None
|
||||
if not panel_user_uuid:
|
||||
return _json_error(400, "subscription_not_active", "Subscription is not active")
|
||||
result = await webapp_cached_user_payload(
|
||||
settings,
|
||||
"devices",
|
||||
user_id,
|
||||
int(getattr(settings, "WEBAPP_DEVICES_CACHE_TTL_SECONDS", 5) or 0),
|
||||
lambda: _load_devices_payload(
|
||||
subscription_service,
|
||||
session,
|
||||
user_id,
|
||||
fallback_panel_user_uuid=str(getattr(db_user, "panel_user_uuid", "") or "").strip()
|
||||
or None,
|
||||
),
|
||||
)
|
||||
if isinstance(result, dict) and result.get("ok") is True:
|
||||
return web.json_response({"ok": True, **(result.get("payload") or {})})
|
||||
if isinstance(result, dict) and not result.get("error"):
|
||||
# Backward-compatible with payloads written by older versions under
|
||||
# the same Redis cache key.
|
||||
return web.json_response({"ok": True, **result})
|
||||
if not isinstance(result, dict):
|
||||
result = {}
|
||||
if not result.get("ok"):
|
||||
return _json_error(
|
||||
int(result.get("status") or 500),
|
||||
str(result.get("error") or "devices_load_failed"),
|
||||
str(result.get("message") or "Failed to load devices"),
|
||||
)
|
||||
return web.json_response({"ok": True, **(result.get("payload") or {})})
|
||||
|
||||
panel_service = getattr(subscription_service, "panel_service", None)
|
||||
if not panel_service:
|
||||
return _json_error(503, "panel_unavailable", "Panel service unavailable")
|
||||
|
||||
try:
|
||||
devices_response = await panel_service.get_user_devices(panel_user_uuid)
|
||||
except Exception:
|
||||
logger.exception("Failed to load WebApp devices for user %s", user_id)
|
||||
return _json_error(502, "devices_load_failed", "Failed to load devices")
|
||||
async def _load_devices_payload(
|
||||
subscription_service: SubscriptionService,
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
fallback_panel_user_uuid: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
active = await subscription_service.get_active_subscription_details(session, user_id)
|
||||
panel_user_uuid = str((active or {}).get("user_id") or fallback_panel_user_uuid or "").strip()
|
||||
if not panel_user_uuid:
|
||||
return _empty_inactive_devices_payload()
|
||||
|
||||
panel_service = getattr(subscription_service, "panel_service", None)
|
||||
if not panel_service:
|
||||
return {
|
||||
"ok": False,
|
||||
"status": 503,
|
||||
"error": "panel_unavailable",
|
||||
"message": "Panel service unavailable",
|
||||
}
|
||||
|
||||
try:
|
||||
devices_response = await panel_service.get_user_devices(panel_user_uuid)
|
||||
except Exception:
|
||||
logger.exception("Failed to load WebApp devices for user %s", user_id)
|
||||
return {
|
||||
"ok": False,
|
||||
"status": 502,
|
||||
"error": "devices_load_failed",
|
||||
"message": "Failed to load devices",
|
||||
}
|
||||
|
||||
devices = _normalize_devices_response(devices_response)
|
||||
max_devices = _coerce_int_or_none(active.get("max_devices")) if active else None
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": True,
|
||||
return {
|
||||
"ok": True,
|
||||
"payload": {
|
||||
"enabled": True,
|
||||
"subscription_active": _devices_subscription_is_active(active),
|
||||
"current_devices": len(devices),
|
||||
"max_devices": max_devices,
|
||||
"max_devices_label": _format_devices_limit(max_devices),
|
||||
"devices": [
|
||||
_serialize_device(device, index) for index, device in enumerate(devices, start=1)
|
||||
],
|
||||
}
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _empty_inactive_devices_payload() -> Dict[str, Any]:
|
||||
return {
|
||||
"ok": True,
|
||||
"payload": {
|
||||
"enabled": True,
|
||||
"subscription_active": False,
|
||||
"current_devices": 0,
|
||||
"max_devices": None,
|
||||
"max_devices_label": _format_devices_limit(None),
|
||||
"devices": [],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _devices_subscription_is_active(active: Optional[Dict[str, Any]]) -> bool:
|
||||
if not active:
|
||||
return False
|
||||
end_date = active.get("end_date")
|
||||
if not isinstance(end_date, datetime):
|
||||
return False
|
||||
if end_date.tzinfo is None:
|
||||
end_date = end_date.replace(tzinfo=timezone.utc)
|
||||
return end_date > datetime.now(timezone.utc)
|
||||
|
||||
|
||||
async def disconnect_device_route(request: web.Request) -> web.Response:
|
||||
@@ -103,6 +177,7 @@ async def disconnect_device_route(request: web.Request) -> web.Response:
|
||||
success = await panel_service.disconnect_device(panel_user_uuid, target_hwid)
|
||||
if not success:
|
||||
return _json_error(502, "device_disconnect_failed", "Failed to disconnect device")
|
||||
await cache_delete(settings, redis_key(settings, "cache", "webapp", "devices", user_id))
|
||||
await session.commit()
|
||||
|
||||
return web.json_response({"ok": True})
|
||||
@@ -146,6 +221,15 @@ def _format_device_datetime(value: Any) -> str:
|
||||
return text
|
||||
|
||||
|
||||
def _serialize_device_datetime(value: Any) -> Optional[str]:
|
||||
if not value:
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
normalized = value if value.tzinfo else value.replace(tzinfo=timezone.utc)
|
||||
return normalized.isoformat()
|
||||
return str(value)
|
||||
|
||||
|
||||
def _serialize_device(device: Dict[str, Any], index: int) -> Dict[str, Any]:
|
||||
hwid = str(device.get("hwid") or "").strip()
|
||||
model = str(device.get("deviceModel") or "").strip()
|
||||
@@ -161,7 +245,7 @@ def _serialize_device(device: Dict[str, Any], index: int) -> Dict[str, Any]:
|
||||
"os_version": os_version,
|
||||
"platform_label": platform_label,
|
||||
"user_agent": user_agent,
|
||||
"created_at": device.get("createdAt"),
|
||||
"created_at": _serialize_device_datetime(device.get("createdAt")),
|
||||
"created_at_text": _format_device_datetime(device.get("createdAt")),
|
||||
"hwid_short": _shorten_hwid_for_display(hwid),
|
||||
"token": _device_hwid_token(hwid) if hwid else "",
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
from config.subscription_guides_config import (
|
||||
SubscriptionGuidesConfigError,
|
||||
subscription_guides_status,
|
||||
validate_panel_subscription_guides_config,
|
||||
)
|
||||
|
||||
PANEL_DEFAULT_SUBPAGE_CONFIG_UUID = "00000000-0000-0000-0000-000000000000"
|
||||
SUBSCRIPTION_GUIDES_CACHE_ERROR_TTL_SECONDS = 30
|
||||
|
||||
|
||||
async def warm_subscription_guides_config(app: web.Application) -> None:
|
||||
try:
|
||||
await _subscription_guides_status_shared(app)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to warm subscription guides config: %s", exc)
|
||||
|
||||
|
||||
async def subscription_guides_route(request: web.Request) -> web.Response:
|
||||
_require_user_id(request)
|
||||
status = await _subscription_guides_status_shared(request.app)
|
||||
payload = {
|
||||
"enabled": bool(status.get("enabled")),
|
||||
"config": status.get("config") if status.get("enabled") else None,
|
||||
"source": status.get("source"),
|
||||
}
|
||||
if status.get("error"):
|
||||
payload["error"] = status["error"]
|
||||
return web.json_response({"ok": True, **payload})
|
||||
|
||||
|
||||
async def public_subscription_guides_route(request: web.Request) -> web.Response:
|
||||
share_token = subscription_dal.normalize_install_share_token(
|
||||
request.match_info.get("share_token")
|
||||
)
|
||||
if not share_token:
|
||||
return web.json_response({"ok": False, "error": "invalid_share_token"}, status=404)
|
||||
|
||||
subscription = await _public_subscription_payload(request, share_token)
|
||||
if not subscription.get("active"):
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": False,
|
||||
"enabled": False,
|
||||
"config": None,
|
||||
"source": None,
|
||||
"subscription": subscription,
|
||||
"error": "subscription_unavailable",
|
||||
},
|
||||
status=404,
|
||||
)
|
||||
|
||||
status = await _subscription_guides_status_shared(request.app)
|
||||
payload = {
|
||||
"enabled": bool(status.get("enabled")),
|
||||
"config": status.get("config") if status.get("enabled") else None,
|
||||
"source": status.get("source"),
|
||||
"subscription": subscription,
|
||||
}
|
||||
if status.get("error"):
|
||||
payload["error"] = status["error"]
|
||||
return web.json_response({"ok": True, **payload})
|
||||
|
||||
|
||||
async def _subscription_guides_status_shared(app: web.Application) -> Dict[str, Any]:
|
||||
settings: Settings = app["settings"]
|
||||
cache = app.setdefault("subscription_guides_config_cache", {})
|
||||
lock: asyncio.Lock = app.setdefault("subscription_guides_config_lock", asyncio.Lock())
|
||||
fingerprint = _subscription_guides_settings_fingerprint(settings)
|
||||
now = time.monotonic()
|
||||
|
||||
cached = cache.get("status")
|
||||
if cached is not None and cache.get("fingerprint") == fingerprint:
|
||||
if cached.get("enabled") or now - float(cache.get("ts", 0.0)) < (
|
||||
SUBSCRIPTION_GUIDES_CACHE_ERROR_TTL_SECONDS
|
||||
):
|
||||
return cached
|
||||
|
||||
async with lock:
|
||||
cached = cache.get("status")
|
||||
if cached is not None and cache.get("fingerprint") == fingerprint:
|
||||
if cached.get("enabled") or now - float(cache.get("ts", 0.0)) < (
|
||||
SUBSCRIPTION_GUIDES_CACHE_ERROR_TTL_SECONDS
|
||||
):
|
||||
return cached
|
||||
|
||||
status = await _load_subscription_guides_status(app, settings)
|
||||
cache["fingerprint"] = fingerprint
|
||||
cache["status"] = status
|
||||
cache["ts"] = time.monotonic()
|
||||
return status
|
||||
|
||||
|
||||
async def _load_subscription_guides_status(
|
||||
app: web.Application,
|
||||
settings: Settings,
|
||||
) -> Dict[str, Any]:
|
||||
if not bool(getattr(settings, "SUBSCRIPTION_GUIDES_ENABLED", False)):
|
||||
return {"enabled": False, "config": None, "source": None, "error": None}
|
||||
|
||||
admin_json = str(getattr(settings, "SUBSCRIPTION_PAGE_CONFIG_JSON", "") or "").strip()
|
||||
json_override_enabled = bool(
|
||||
getattr(settings, "SUBSCRIPTION_PAGE_CONFIG_JSON_OVERRIDE_ENABLED", False)
|
||||
)
|
||||
if admin_json and json_override_enabled:
|
||||
return subscription_guides_status(settings)
|
||||
|
||||
if bool(getattr(settings, "SUBSCRIPTION_PAGE_CONFIG_PANEL_ENABLED", True)):
|
||||
panel_status = await _subscription_guides_status_from_panel_config(app, settings)
|
||||
if panel_status.get("enabled"):
|
||||
return panel_status
|
||||
|
||||
return subscription_guides_status(settings)
|
||||
|
||||
|
||||
async def _subscription_guides_status_from_panel_config(
|
||||
app: web.Application,
|
||||
settings: Settings,
|
||||
) -> Dict[str, Any]:
|
||||
panel_service = _panel_service_from_app(app)
|
||||
if panel_service is None:
|
||||
return {
|
||||
"enabled": False,
|
||||
"config": None,
|
||||
"source": "panel",
|
||||
"error": "Panel service is unavailable",
|
||||
}
|
||||
|
||||
try:
|
||||
config_uuid = str(getattr(settings, "SUBSCRIPTION_PAGE_CONFIG_UUID", "") or "").strip()
|
||||
if not config_uuid:
|
||||
config_uuid = await _default_panel_subscription_page_config_uuid(panel_service)
|
||||
config_uuid = config_uuid or PANEL_DEFAULT_SUBPAGE_CONFIG_UUID
|
||||
detail = await panel_service.get_subscription_page_config_by_uuid(config_uuid)
|
||||
if detail is None and config_uuid != PANEL_DEFAULT_SUBPAGE_CONFIG_UUID:
|
||||
detail = await panel_service.get_subscription_page_config_by_uuid(
|
||||
PANEL_DEFAULT_SUBPAGE_CONFIG_UUID
|
||||
)
|
||||
if detail is None:
|
||||
raise SubscriptionGuidesConfigError(
|
||||
f"Panel subscription page config {config_uuid} is unavailable"
|
||||
)
|
||||
config = validate_panel_subscription_guides_config(detail)
|
||||
except (SubscriptionGuidesConfigError, Exception) as exc:
|
||||
logger.warning("Failed to load subscription guides config from Remnawave Panel: %s", exc)
|
||||
return {"enabled": False, "config": None, "source": "panel", "error": str(exc)}
|
||||
|
||||
return {"enabled": True, "config": config, "source": "panel", "error": None}
|
||||
|
||||
|
||||
async def _default_panel_subscription_page_config_uuid(panel_service: Any) -> str:
|
||||
get_list = getattr(panel_service, "get_subscription_page_config_list", None)
|
||||
if not callable(get_list):
|
||||
return ""
|
||||
payload = await get_list()
|
||||
configs = (payload or {}).get("configs")
|
||||
if not isinstance(configs, list):
|
||||
return ""
|
||||
|
||||
candidates: list[Dict[str, Any]] = [item for item in configs if isinstance(item, dict)]
|
||||
for item in candidates:
|
||||
uuid = str(item.get("uuid") or "").strip()
|
||||
if uuid == PANEL_DEFAULT_SUBPAGE_CONFIG_UUID:
|
||||
return uuid
|
||||
|
||||
candidates.sort(key=lambda item: int(item.get("viewPosition") or 0))
|
||||
for item in candidates:
|
||||
uuid = str(item.get("uuid") or "").strip()
|
||||
if uuid:
|
||||
return uuid
|
||||
return ""
|
||||
|
||||
|
||||
async def _public_subscription_payload(
|
||||
request: web.Request,
|
||||
share_token: str,
|
||||
) -> Dict[str, Any]:
|
||||
settings: Settings = request.app["settings"]
|
||||
panel_service = _panel_service_from_app(request.app)
|
||||
raw_link = ""
|
||||
username = ""
|
||||
resolved_short_uuid = ""
|
||||
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
local_sub = await subscription_dal.get_subscription_by_install_share_token(
|
||||
session,
|
||||
share_token,
|
||||
)
|
||||
|
||||
if (
|
||||
local_sub
|
||||
and getattr(local_sub, "panel_user_uuid", None)
|
||||
and _local_subscription_is_publicly_active(local_sub)
|
||||
and panel_service
|
||||
):
|
||||
panel_user = await panel_service.get_user_by_uuid(local_sub.panel_user_uuid)
|
||||
if panel_user:
|
||||
raw_link = str(panel_user.get("subscriptionUrl") or "").strip()
|
||||
username = str(panel_user.get("username") or "").strip()
|
||||
resolved_short_uuid = str(panel_user.get("shortUuid") or "").strip()
|
||||
|
||||
display_link, connect_url = await prepare_config_links(settings, raw_link)
|
||||
return {
|
||||
"active": bool(display_link),
|
||||
"config_link": display_link,
|
||||
"connect_url": connect_url or display_link,
|
||||
"panel_short_uuid": resolved_short_uuid or None,
|
||||
"install_share_token": share_token,
|
||||
"username": username,
|
||||
"share_url": _public_install_url(request, share_token),
|
||||
}
|
||||
|
||||
|
||||
def _panel_service_from_app(app: web.Application) -> Any:
|
||||
subscription_service: Optional[SubscriptionService] = app.get("subscription_service")
|
||||
panel_service = (
|
||||
getattr(subscription_service, "panel_service", None) if subscription_service else None
|
||||
)
|
||||
return panel_service or app.get("panel_service")
|
||||
|
||||
|
||||
def _subscription_guides_settings_fingerprint(settings: Settings) -> Tuple[Any, ...]:
|
||||
admin_json = str(getattr(settings, "SUBSCRIPTION_PAGE_CONFIG_JSON", "") or "")
|
||||
return (
|
||||
bool(getattr(settings, "SUBSCRIPTION_GUIDES_ENABLED", False)),
|
||||
bool(getattr(settings, "SUBSCRIPTION_PAGE_CONFIG_PANEL_ENABLED", True)),
|
||||
bool(getattr(settings, "SUBSCRIPTION_PAGE_CONFIG_JSON_OVERRIDE_ENABLED", False)),
|
||||
str(getattr(settings, "SUBSCRIPTION_PAGE_CONFIG_PATH", "") or ""),
|
||||
str(getattr(settings, "SUBSCRIPTION_PAGE_CONFIG_UUID", "") or ""),
|
||||
hashlib.sha256(admin_json.encode("utf-8")).hexdigest(),
|
||||
str(getattr(settings, "PANEL_API_URL", "") or ""),
|
||||
bool(getattr(settings, "PANEL_API_KEY", "") or ""),
|
||||
)
|
||||
|
||||
|
||||
def _local_subscription_is_publicly_active(subscription: Any) -> bool:
|
||||
end_date = getattr(subscription, "end_date", None)
|
||||
if end_date and end_date.tzinfo is None:
|
||||
end_date = end_date.replace(tzinfo=timezone.utc)
|
||||
return bool(
|
||||
getattr(subscription, "is_active", False)
|
||||
and end_date
|
||||
and end_date > datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
|
||||
def _public_install_url(request: web.Request, share_token: str) -> str:
|
||||
settings: Settings = request.app["settings"]
|
||||
configured_base = str(getattr(settings, "SUBSCRIPTION_MINI_APP_URL", "") or "").strip()
|
||||
if configured_base:
|
||||
parts = urlsplit(configured_base)
|
||||
if parts.scheme and parts.netloc:
|
||||
base = urlunsplit((parts.scheme, parts.netloc, "", "", ""))
|
||||
else:
|
||||
base = configured_base.rstrip("/")
|
||||
else:
|
||||
host = (
|
||||
request.headers.get("X-Forwarded-Host") or request.headers.get("Host") or request.host
|
||||
)
|
||||
proto = request.headers.get("X-Forwarded-Proto") or request.scheme or "https"
|
||||
base = f"{proto}://{host}"
|
||||
return f"{base.rstrip('/')}/s/{quote(share_token)}"
|
||||
|
||||
|
||||
def _subscription_page_request_headers(request: web.Request) -> Dict[str, str]:
|
||||
headers = request.headers
|
||||
host = headers.get("X-Forwarded-Host") or headers.get("Host") or request.host
|
||||
proto = headers.get("X-Forwarded-Proto") or request.scheme or "https"
|
||||
user_agent = headers.get(
|
||||
"User-Agent",
|
||||
"Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko) Chrome Safari",
|
||||
)
|
||||
return {
|
||||
"host": host,
|
||||
"x-forwarded-host": host,
|
||||
"x-forwarded-proto": proto,
|
||||
"user-agent": user_agent,
|
||||
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
"accept-language": headers.get("Accept-Language", "ru,en;q=0.9"),
|
||||
"sec-fetch-dest": "document",
|
||||
"sec-fetch-mode": "navigate",
|
||||
"sec-fetch-site": "none",
|
||||
"upgrade-insecure-requests": "1",
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
from typing import Literal
|
||||
|
||||
|
||||
class WebAppEmailPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
@@ -20,6 +22,18 @@ class WebAppEmailCodePayload(WebAppEmailPayload):
|
||||
code: str = ""
|
||||
|
||||
|
||||
class WebAppEmailPasswordPayload(WebAppEmailPayload):
|
||||
password: constr(min_length=1, max_length=128)
|
||||
|
||||
|
||||
class WebAppSetPasswordPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
password: constr(min_length=8, max_length=128)
|
||||
password_confirm: constr(min_length=8, max_length=128)
|
||||
code: constr(min_length=1, max_length=32)
|
||||
|
||||
|
||||
class WebAppEmailMagicPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
@@ -35,6 +49,7 @@ class WebAppPaymentCreatePayload(BaseModel):
|
||||
device_count: Any = None
|
||||
tariff_key: Optional[constr(max_length=128)] = None
|
||||
sale_mode: Optional[constr(max_length=64)] = None
|
||||
renew_hwid_devices: Optional[bool] = None
|
||||
description: Optional[constr(max_length=4096)] = None
|
||||
comment: Optional[constr(max_length=4096)] = None
|
||||
note: Optional[constr(max_length=4096)] = None
|
||||
@@ -57,3 +72,52 @@ class WebAppDeviceDisconnectPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
token: constr(min_length=8, max_length=128)
|
||||
|
||||
|
||||
SupportCategory = Literal["billing", "technical", "account", "other"]
|
||||
SupportPriority = Literal["low", "normal", "high", "urgent"]
|
||||
SupportStatus = Literal["open", "awaiting_user", "awaiting_admin", "resolved", "closed"]
|
||||
|
||||
|
||||
class CreateTicketPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
subject: constr(min_length=1, max_length=160)
|
||||
category: SupportCategory = "other"
|
||||
priority: Literal["normal", "high"] = "normal"
|
||||
body: constr(min_length=1, max_length=4000)
|
||||
|
||||
@field_validator("subject", "body")
|
||||
@classmethod
|
||||
def _strip_required_text(cls, value: str) -> str:
|
||||
stripped = value.strip()
|
||||
if not stripped:
|
||||
raise ValueError("empty_text")
|
||||
return stripped
|
||||
|
||||
|
||||
class TicketReplyPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
body: constr(min_length=1, max_length=4000)
|
||||
|
||||
@field_validator("body")
|
||||
@classmethod
|
||||
def _strip_body(cls, value: str) -> str:
|
||||
stripped = value.strip()
|
||||
if not stripped:
|
||||
raise ValueError("empty_text")
|
||||
return stripped
|
||||
|
||||
|
||||
class AdminTicketReplyPayload(TicketReplyPayload):
|
||||
is_internal_note: bool = False
|
||||
|
||||
|
||||
class AdminTicketPatchPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
status: Optional[SupportStatus] = None
|
||||
priority: Optional[SupportPriority] = None
|
||||
category: Optional[SupportCategory] = None
|
||||
assigned_admin_id: Optional[int] = None
|
||||
|
||||
@@ -3,23 +3,40 @@ from ._runtime import * # noqa: F403,F405
|
||||
|
||||
|
||||
def setup_subscription_webapp_routes(app: web.Application) -> None:
|
||||
app.router.add_get("/robots.txt", robots_txt_route)
|
||||
app.router.add_get("/", index_route)
|
||||
app.router.add_get("/login/password", index_route)
|
||||
app.router.add_get("/home", index_route)
|
||||
app.router.add_get("/install", index_route)
|
||||
app.router.add_get("/trial", index_route)
|
||||
app.router.add_get("/open-app", app_deeplink_route)
|
||||
app.router.add_get(r"/s/{share_token:[a-f0-9]{32}}", index_route)
|
||||
app.router.add_get("/invite", index_route)
|
||||
app.router.add_get("/devices", index_route)
|
||||
app.router.add_get("/settings", index_route)
|
||||
app.router.add_get("/support", index_route)
|
||||
app.router.add_get("/support/{ticket_id:\\d+}", index_route)
|
||||
app.router.add_get("/admin", index_route)
|
||||
app.router.add_get(
|
||||
(
|
||||
"/admin/{section:stats|users|payments|promos|ads|broadcast|logs|tariffs|"
|
||||
"appearance|settings}"
|
||||
"appearance|settings|translations|support|backups}"
|
||||
),
|
||||
index_route,
|
||||
)
|
||||
app.router.add_get("/admin/users/{user_id:-?[0-9]+}", index_route)
|
||||
app.router.add_get("/admin/payments/users/{user_id:-?[0-9]+}", index_route)
|
||||
app.router.add_get("/admin/payments/{payment_id:\\d+}", index_route)
|
||||
app.router.add_get("/admin/support/{ticket_id:\\d+}", index_route)
|
||||
app.router.add_get("/auth/telegram/start", telegram_oauth_start_route)
|
||||
app.router.add_get("/auth/telegram/callback", telegram_oauth_callback_route)
|
||||
app.router.add_get("/health", health_route)
|
||||
app.router.add_get("/favicon.ico", webapp_current_favicon_route)
|
||||
app.router.add_get("/apple-touch-icon.png", webapp_current_favicon_route)
|
||||
app.router.add_get("/apple-touch-icon-precomposed.png", webapp_current_favicon_route)
|
||||
app.router.add_get("/icon-192.png", webapp_current_favicon_route)
|
||||
app.router.add_get("/icon-512.png", webapp_current_favicon_route)
|
||||
app.router.add_get(WEBAPP_DEFAULT_LOGO_PATH, webapp_default_logo_route)
|
||||
app.router.add_get(WEBAPP_LOGO_PROXY_PATH, webapp_logo_route)
|
||||
app.router.add_get(
|
||||
rf"{WEBAPP_UPLOADED_LOGO_PATH}/{{filename:[A-Za-z0-9_.-]+}}",
|
||||
@@ -29,33 +46,57 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
|
||||
rf"{WEBAPP_FAVICON_PATH}/{{digest:[0-9a-f]{{16}}}}/{{filename:[A-Za-z0-9_.-]+}}",
|
||||
webapp_favicon_route,
|
||||
)
|
||||
app.router.add_get(
|
||||
r"/webapp-emoji/{codepoints:[0-9a-f_]+}/512.{ext:gif|webp}",
|
||||
webapp_animated_emoji_route,
|
||||
)
|
||||
app.router.add_get("/subscription_webapp.{asset_hash:[0-9a-f]{8}}.css", css_asset_route)
|
||||
app.router.add_get("/subscription_webapp.css", css_asset_route)
|
||||
app.router.add_get(
|
||||
"/subscription_webapp_admin.{asset_hash:[0-9a-f]{8}}.css",
|
||||
admin_css_asset_route,
|
||||
)
|
||||
app.router.add_get("/subscription_webapp_admin.css", admin_css_asset_route)
|
||||
app.router.add_get(r"/webapp-theme-css/{path:.+}", theme_css_asset_route)
|
||||
app.router.add_get(r"/webapp-theme-assets/{path:.+}", theme_asset_route)
|
||||
app.router.add_get("/subscription_webapp.min.{asset_hash}.js", js_asset_route)
|
||||
app.router.add_get("/subscription_webapp.js", js_asset_route)
|
||||
app.router.add_get("/subscription_webapp_admin.min.{asset_hash}.js", admin_js_asset_route)
|
||||
app.router.add_get("/subscription_webapp_admin.js", admin_js_asset_route)
|
||||
app.router.add_post("/api/auth/telegram/nonce", telegram_oauth_nonce_route)
|
||||
app.router.add_post("/api/auth/token", auth_token_route)
|
||||
app.router.add_post("/api/auth/email/request", email_auth_request_route)
|
||||
app.router.add_post("/api/auth/email/verify", email_auth_verify_route)
|
||||
app.router.add_post("/api/auth/email/magic", email_auth_magic_route)
|
||||
app.router.add_post("/api/auth/email/password", email_password_auth_route)
|
||||
app.router.add_post("/api/auth/logout", logout_route)
|
||||
app.router.add_get("/api/bootstrap", bootstrap_route)
|
||||
app.router.add_get("/api/i18n", i18n_route)
|
||||
app.router.add_get("/api/me", me_route)
|
||||
app.router.add_get("/api/subscription-guides", subscription_guides_route)
|
||||
app.router.add_get(
|
||||
r"/api/subscription-guides/public/{share_token:[a-f0-9]{32}}",
|
||||
public_subscription_guides_route,
|
||||
)
|
||||
app.router.add_get("/api/account/avatar", account_avatar_route)
|
||||
app.router.add_post("/api/account/language", account_language_route)
|
||||
app.router.add_post("/api/account/email/request", account_email_request_route)
|
||||
app.router.add_post("/api/account/email/verify", account_email_verify_route)
|
||||
app.router.add_post("/api/account/password/request", account_password_request_route)
|
||||
app.router.add_post("/api/account/password/confirm", account_password_confirm_route)
|
||||
app.router.add_post("/api/account/telegram/link", account_telegram_link_route)
|
||||
app.router.add_post(
|
||||
"/api/account/telegram/notifications/probe",
|
||||
account_telegram_notifications_probe_route,
|
||||
)
|
||||
app.router.add_post("/api/referral/welcome-bonus/claim", referral_welcome_bonus_claim_route)
|
||||
app.router.add_post("/api/promo/apply", apply_promo_route)
|
||||
app.router.add_post("/api/trial/activate", activate_trial_route)
|
||||
app.router.add_get("/api/devices", devices_route)
|
||||
app.router.add_post("/api/devices/disconnect", disconnect_device_route)
|
||||
app.router.add_get("/api/devices/topup-options", device_topup_options_route)
|
||||
app.router.add_get("/api/support/tickets", support_tickets_route)
|
||||
app.router.add_post("/api/support/tickets", support_create_ticket_route)
|
||||
app.router.add_get("/api/support/tickets/{id:\\d+}", support_ticket_detail_route)
|
||||
app.router.add_post("/api/support/tickets/{id:\\d+}/messages", support_ticket_reply_route)
|
||||
app.router.add_post("/api/support/tickets/{id:\\d+}/read", support_ticket_read_route)
|
||||
app.router.add_get("/api/support/unread", support_unread_route)
|
||||
app.router.add_get("/api/tariffs/topup-options", tariff_topup_options_route)
|
||||
app.router.add_get("/api/tariffs/change-options", tariff_change_options_route)
|
||||
app.router.add_post("/api/tariffs/change", tariff_change_route)
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
from bot.app.web.webapp.auth import (
|
||||
_referral_welcome_telegram_required_reason,
|
||||
_trial_telegram_required_reason,
|
||||
_user_has_linked_telegram,
|
||||
)
|
||||
from config.subscription_guides_config import subscription_guides_available
|
||||
from config.webapp_themes_config import public_themes_catalog_payload
|
||||
from bot.services.telegram_notifications import (
|
||||
TELEGRAM_NOTIFICATIONS_ENABLED,
|
||||
normalize_telegram_notification_status,
|
||||
telegram_notifications_need_prompt,
|
||||
telegram_notifications_start_link,
|
||||
)
|
||||
|
||||
|
||||
async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, Any]:
|
||||
@@ -38,6 +50,11 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
|
||||
if referral_service
|
||||
else {"invited_count": 0, "purchased_count": 0}
|
||||
)
|
||||
support_unread_count = (
|
||||
await support_dal.count_user_unread(session, user_id)
|
||||
if settings.SUPPORT_TICKETS_ENABLED
|
||||
else 0
|
||||
)
|
||||
local_sub = (
|
||||
await subscription_dal.get_active_subscription_by_user_id(
|
||||
session,
|
||||
@@ -47,10 +64,37 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
|
||||
if db_user.panel_user_uuid
|
||||
else None
|
||||
)
|
||||
trial_available = bool(
|
||||
install_share_token = (
|
||||
await subscription_dal.ensure_install_share_token(session, local_sub)
|
||||
if active and local_sub
|
||||
else None
|
||||
)
|
||||
trial_base_available = bool(
|
||||
settings.TRIAL_ENABLED
|
||||
and settings.TRIAL_DURATION_DAYS > 0
|
||||
and not await subscription_service.has_had_any_subscription(session, user_id)
|
||||
and not await subscription_service.has_trial_blocking_subscription(session, user_id)
|
||||
)
|
||||
trial_telegram_required_reason = (
|
||||
_trial_telegram_required_reason(settings, db_user) if trial_base_available else None
|
||||
)
|
||||
trial_available = bool(trial_base_available and not trial_telegram_required_reason)
|
||||
lang = _normalize_language(db_user.language_code or settings.DEFAULT_LANGUAGE)
|
||||
plans_payload = _serialize_plans(
|
||||
settings,
|
||||
lang,
|
||||
subscription_options=cached["subscription_options"],
|
||||
stars_subscription_options=cached["stars_subscription_options"],
|
||||
traffic_packages=cached["traffic_packages"],
|
||||
stars_traffic_packages=cached["stars_traffic_packages"],
|
||||
)
|
||||
await _attach_hwid_renewal_quotes_to_plans(
|
||||
session,
|
||||
subscription_service,
|
||||
user_id=user_id,
|
||||
settings=settings,
|
||||
active=active,
|
||||
local_sub=local_sub,
|
||||
plans=plans_payload,
|
||||
)
|
||||
avatar = await _ensure_cached_telegram_avatar(request, session, db_user)
|
||||
try:
|
||||
@@ -58,53 +102,90 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
|
||||
lang = _normalize_language(db_user.language_code or settings.DEFAULT_LANGUAGE)
|
||||
admin_ids = {int(x) for x in (settings.ADMIN_IDS or [])}
|
||||
is_admin = bool(db_user.telegram_id and int(db_user.telegram_id) in admin_ids)
|
||||
telegram_linked = _user_has_linked_telegram(db_user)
|
||||
referral_welcome_days = max(0, int(getattr(settings, "REFERRAL_WELCOME_BONUS_DAYS", 0) or 0))
|
||||
referral_welcome_telegram_required_reason = (
|
||||
_referral_welcome_telegram_required_reason(settings, db_user)
|
||||
if db_user.referred_by_id and not active and referral_welcome_days > 0
|
||||
else None
|
||||
)
|
||||
telegram_notifications_status = normalize_telegram_notification_status(
|
||||
getattr(db_user, "telegram_notifications_status", None)
|
||||
)
|
||||
telegram_notifications_link = telegram_notifications_start_link(
|
||||
request.app.get("bot_username") or ""
|
||||
)
|
||||
return {
|
||||
"user": {
|
||||
"id": user_id,
|
||||
"username": db_user.username,
|
||||
"email": db_user.email,
|
||||
"email_verified": bool(db_user.email_verified_at),
|
||||
"password_auth_enabled": bool(
|
||||
db_user.email and db_user.email_verified_at and db_user.password_hash
|
||||
),
|
||||
"telegram_id": db_user.telegram_id,
|
||||
"telegram_linked": bool(_telegram_id_for_user(db_user)),
|
||||
"telegram_linked": telegram_linked,
|
||||
"telegram_notifications_status": telegram_notifications_status,
|
||||
"telegram_notifications_enabled": (
|
||||
telegram_notifications_status == TELEGRAM_NOTIFICATIONS_ENABLED
|
||||
),
|
||||
"telegram_notifications_need_prompt": telegram_notifications_need_prompt(db_user),
|
||||
"telegram_notifications_start_link": telegram_notifications_link,
|
||||
"telegram_photo_url": _telegram_avatar_url(avatar),
|
||||
"first_name": db_user.first_name,
|
||||
"language_code": lang,
|
||||
"is_admin": is_admin,
|
||||
},
|
||||
"subscription": _serialize_subscription(settings, active, local_sub, lang),
|
||||
"subscription": _serialize_subscription(
|
||||
request,
|
||||
settings,
|
||||
active,
|
||||
local_sub,
|
||||
lang,
|
||||
install_share_token=install_share_token,
|
||||
),
|
||||
"referral": {
|
||||
"code": referral_code,
|
||||
"bot_link": referral_link,
|
||||
"webapp_link": webapp_referral_link,
|
||||
"invited_count": referral_stats.get("invited_count", 0),
|
||||
"purchased_count": referral_stats.get("purchased_count", 0),
|
||||
"welcome_bonus_days": max(
|
||||
0, int(getattr(settings, "REFERRAL_WELCOME_BONUS_DAYS", 0) or 0)
|
||||
"welcome_bonus_days": referral_welcome_days,
|
||||
"welcome_bonus_without_telegram_enabled": bool(
|
||||
getattr(settings, "REFERRAL_WELCOME_BONUS_WITHOUT_TELEGRAM_ENABLED", True)
|
||||
),
|
||||
"welcome_bonus_requires_telegram": bool(
|
||||
referral_welcome_telegram_required_reason and not telegram_linked
|
||||
),
|
||||
"welcome_bonus_block_reason": referral_welcome_telegram_required_reason,
|
||||
"one_bonus_per_referee": bool(
|
||||
getattr(settings, "REFERRAL_ONE_BONUS_PER_REFEREE", False)
|
||||
),
|
||||
"bonus_details": _serialize_referral_bonus_details(settings, lang),
|
||||
},
|
||||
"plans": _serialize_plans(
|
||||
"plans": plans_payload,
|
||||
"payment_methods": _serialize_payment_methods(
|
||||
settings,
|
||||
request.app,
|
||||
lang,
|
||||
subscription_options=cached["subscription_options"],
|
||||
stars_subscription_options=cached["stars_subscription_options"],
|
||||
traffic_packages=cached["traffic_packages"],
|
||||
stars_traffic_packages=cached["stars_traffic_packages"],
|
||||
is_admin=is_admin,
|
||||
),
|
||||
"payment_methods": _serialize_payment_methods(settings, request.app),
|
||||
"themes_catalog": public_themes_catalog_payload(
|
||||
settings.webapp_themes_catalog,
|
||||
settings.WEBAPP_PRIMARY_COLOR or "#00fe7a",
|
||||
enabled_only=True,
|
||||
),
|
||||
"support_unread_count": int(support_unread_count or 0),
|
||||
"settings": {
|
||||
"support_url": settings.SUPPORT_LINK,
|
||||
"support_tickets_enabled": bool(settings.SUPPORT_TICKETS_ENABLED),
|
||||
"support_ticket_max_body_length": int(settings.SUPPORT_TICKET_MAX_BODY_LENGTH or 4000),
|
||||
"support_ticket_max_subject_length": int(
|
||||
settings.SUPPORT_TICKET_MAX_SUBJECT_LENGTH or 160
|
||||
),
|
||||
"traffic_mode": bool(settings.traffic_sale_mode),
|
||||
"my_devices_enabled": bool(settings.MY_DEVICES_SECTION_ENABLED),
|
||||
"user_hwid_device_limit": (
|
||||
@@ -114,20 +195,94 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
|
||||
),
|
||||
"trial_enabled": bool(settings.TRIAL_ENABLED),
|
||||
"trial_available": trial_available,
|
||||
"trial_without_telegram_enabled": bool(
|
||||
getattr(settings, "TRIAL_WITHOUT_TELEGRAM_ENABLED", True)
|
||||
),
|
||||
"trial_requires_telegram": bool(trial_telegram_required_reason and not telegram_linked),
|
||||
"trial_block_reason": trial_telegram_required_reason,
|
||||
"trial_duration_days": int(settings.TRIAL_DURATION_DAYS or 0),
|
||||
"trial_traffic_limit_gb": float(settings.TRIAL_TRAFFIC_LIMIT_GB or 0),
|
||||
"trial_traffic_strategy": getattr(settings, "TRIAL_TRAFFIC_STRATEGY", "NO_RESET"),
|
||||
"subscription_purchase_description": settings.subscription_purchase_description(lang),
|
||||
"subscription_guides_enabled": subscription_guides_available(settings),
|
||||
"email_auth_enabled": settings.email_auth_configured,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _serialize_referral_bonus_details(settings: Settings, lang: str) -> List[Dict[str, Any]]:
|
||||
def _legacy_referral_bonus_periods(settings: Settings) -> List[int]:
|
||||
if getattr(settings, "traffic_sale_mode", False):
|
||||
return []
|
||||
|
||||
return sorted(int(months) for months in settings.subscription_options)
|
||||
|
||||
|
||||
def _serialize_tariff_period_referral_bonus_details(tariff: Any, lang: str) -> List[Dict[str, Any]]:
|
||||
details: List[Dict[str, Any]] = []
|
||||
for months, _price in sorted(settings.subscription_options.items()):
|
||||
for months in sorted(int(month) for month in tariff.enabled_periods):
|
||||
inviter_days = tariff.referral_inviter_bonus_days(months)
|
||||
friend_days = tariff.referral_referee_bonus_days(months)
|
||||
if inviter_days is None and friend_days is None:
|
||||
continue
|
||||
details.append(
|
||||
{
|
||||
"id": f"{tariff.key}:{months}",
|
||||
"tariff_key": tariff.key,
|
||||
"tariff_name": tariff.name(lang),
|
||||
"months": int(months),
|
||||
"title": _format_months_title(int(months), lang),
|
||||
"inviter_days": int(inviter_days or 0),
|
||||
"friend_days": int(friend_days or 0),
|
||||
}
|
||||
)
|
||||
return details
|
||||
|
||||
|
||||
def _serialize_tariff_referral_bonus_details(settings: Settings, lang: str) -> List[Dict[str, Any]]:
|
||||
tariffs_config = settings.tariffs_config
|
||||
if not tariffs_config:
|
||||
return []
|
||||
|
||||
period_tariffs = [
|
||||
tariff for tariff in tariffs_config.enabled_tariffs if tariff.billing_model == "period"
|
||||
]
|
||||
if len(period_tariffs) <= 1:
|
||||
return (
|
||||
_serialize_tariff_period_referral_bonus_details(period_tariffs[0], lang)
|
||||
if period_tariffs
|
||||
else []
|
||||
)
|
||||
|
||||
summaries: List[Dict[str, Any]] = []
|
||||
for tariff in period_tariffs:
|
||||
details = _serialize_tariff_period_referral_bonus_details(tariff, lang)
|
||||
if not details:
|
||||
continue
|
||||
inviter_values = [int(item["inviter_days"]) for item in details]
|
||||
friend_values = [int(item["friend_days"]) for item in details]
|
||||
summaries.append(
|
||||
{
|
||||
"id": f"tariff:{tariff.key}",
|
||||
"type": "tariff_summary",
|
||||
"tariff_key": tariff.key,
|
||||
"tariff_name": tariff.name(lang),
|
||||
"title": tariff.name(lang),
|
||||
"inviter_min_days": min(inviter_values),
|
||||
"inviter_max_days": max(inviter_values),
|
||||
"friend_min_days": min(friend_values),
|
||||
"friend_max_days": max(friend_values),
|
||||
"details": details,
|
||||
}
|
||||
)
|
||||
return summaries
|
||||
|
||||
|
||||
def _serialize_referral_bonus_details(settings: Settings, lang: str) -> List[Dict[str, Any]]:
|
||||
if settings.tariffs_config:
|
||||
return _serialize_tariff_referral_bonus_details(settings, lang)
|
||||
|
||||
details: List[Dict[str, Any]] = []
|
||||
for months in _legacy_referral_bonus_periods(settings):
|
||||
inviter_days = settings.referral_bonus_inviter.get(months)
|
||||
friend_days = settings.referral_bonus_referee.get(months)
|
||||
if inviter_days is None and friend_days is None:
|
||||
@@ -164,11 +319,26 @@ def _build_webapp_referral_link(
|
||||
|
||||
|
||||
def _serialize_subscription(
|
||||
settings: Settings,
|
||||
active: Optional[Dict[str, Any]],
|
||||
local_sub: Optional[Any],
|
||||
lang: str,
|
||||
request_or_settings: Any,
|
||||
settings_or_active: Any,
|
||||
active_or_local_sub: Optional[Any] = None,
|
||||
local_sub_or_lang: Optional[Any] = None,
|
||||
lang: Optional[str] = None,
|
||||
*,
|
||||
install_share_token: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
if lang is None:
|
||||
request = None
|
||||
settings = request_or_settings
|
||||
active = settings_or_active
|
||||
local_sub = active_or_local_sub
|
||||
lang = str(local_sub_or_lang or "ru")
|
||||
else:
|
||||
request = request_or_settings
|
||||
settings = settings_or_active
|
||||
active = active_or_local_sub
|
||||
local_sub = local_sub_or_lang
|
||||
|
||||
if not active:
|
||||
return {
|
||||
"active": False,
|
||||
@@ -177,6 +347,9 @@ def _serialize_subscription(
|
||||
"days_left": 0,
|
||||
"config_link": None,
|
||||
"connect_url": None,
|
||||
"panel_short_uuid": None,
|
||||
"install_share_token": None,
|
||||
"install_share_url": None,
|
||||
}
|
||||
|
||||
end_date = active.get("end_date")
|
||||
@@ -205,10 +378,12 @@ def _serialize_subscription(
|
||||
and tariff.premium_topup_packages.has_any()
|
||||
)
|
||||
can_topup_traffic = bool(can_topup_regular_traffic or can_topup_premium_traffic)
|
||||
# max_devices == 0 means unlimited — top-up is pointless in that case.
|
||||
max_devices = _coerce_int_or_none(active.get("max_devices"))
|
||||
# max_devices == 0 or None means unlimited — top-up is pointless in that case.
|
||||
can_topup_devices = bool(
|
||||
tariff.has_hwid_device_packages()
|
||||
and _coerce_int_or_none(active.get("max_devices")) != 0
|
||||
tariff.billing_model == "period"
|
||||
and tariff.has_hwid_device_packages()
|
||||
and max_devices not in (None, 0)
|
||||
)
|
||||
except Exception:
|
||||
can_topup_regular_traffic = False
|
||||
@@ -216,6 +391,23 @@ def _serialize_subscription(
|
||||
can_topup_traffic = False
|
||||
can_topup_devices = False
|
||||
|
||||
panel_short_uuid = str(active.get("panel_short_uuid") or "").strip()
|
||||
share_token = str(
|
||||
install_share_token or getattr(local_sub, "install_share_token", "") or ""
|
||||
).strip()
|
||||
extra_hwid_valid_until = active.get("extra_hwid_devices_valid_until")
|
||||
if extra_hwid_valid_until and extra_hwid_valid_until.tzinfo is None:
|
||||
extra_hwid_valid_until = extra_hwid_valid_until.replace(tzinfo=timezone.utc)
|
||||
extra_hwid_next_valid_from = active.get("extra_hwid_devices_next_valid_from")
|
||||
if extra_hwid_next_valid_from and extra_hwid_next_valid_from.tzinfo is None:
|
||||
extra_hwid_next_valid_from = extra_hwid_next_valid_from.replace(tzinfo=timezone.utc)
|
||||
extra_hwid_count = _coerce_int_or_none(active.get("extra_hwid_devices")) or 0
|
||||
device_topup_renewal_available = bool(
|
||||
extra_hwid_count > 0
|
||||
and extra_hwid_valid_until
|
||||
and end_date
|
||||
and extra_hwid_valid_until < end_date
|
||||
)
|
||||
return {
|
||||
"active": seconds_left > 0,
|
||||
"status": active.get("status_from_panel") or "UNKNOWN",
|
||||
@@ -225,6 +417,9 @@ def _serialize_subscription(
|
||||
"remaining_text": _format_remaining(seconds_left, lang),
|
||||
"config_link": active.get("config_link"),
|
||||
"connect_url": active.get("connect_button_url") or active.get("config_link"),
|
||||
"panel_short_uuid": panel_short_uuid or None,
|
||||
"install_share_token": subscription_dal.normalize_install_share_token(share_token) or None,
|
||||
"install_share_url": _build_install_share_link(request, settings, share_token),
|
||||
"traffic_limit": _format_bytes(active.get("traffic_limit_bytes"), zero_as_unlimited=True),
|
||||
"traffic_used": _format_bytes(active.get("traffic_used_bytes")),
|
||||
"traffic_limit_bytes": _coerce_int_or_none(active.get("traffic_limit_bytes")),
|
||||
@@ -263,12 +458,142 @@ def _serialize_subscription(
|
||||
"is_throttled": bool(active.get("is_throttled")),
|
||||
"max_devices": _coerce_int_or_none(active.get("max_devices")),
|
||||
"base_hwid_device_limit": _coerce_int_or_none(active.get("base_hwid_device_limit")),
|
||||
"extra_hwid_devices": _coerce_int_or_none(active.get("extra_hwid_devices")) or 0,
|
||||
"extra_hwid_devices": extra_hwid_count,
|
||||
"extra_hwid_devices_valid_until": extra_hwid_valid_until.isoformat()
|
||||
if extra_hwid_valid_until
|
||||
else None,
|
||||
"extra_hwid_devices_valid_until_text": extra_hwid_valid_until.strftime("%d.%m.%Y %H:%M")
|
||||
if extra_hwid_valid_until
|
||||
else None,
|
||||
"extra_hwid_devices_next_valid_from": extra_hwid_next_valid_from.isoformat()
|
||||
if extra_hwid_next_valid_from
|
||||
else None,
|
||||
"device_topup_renewal_available": device_topup_renewal_available,
|
||||
"auto_renew_enabled": bool(getattr(local_sub, "auto_renew_enabled", False)),
|
||||
"provider": getattr(local_sub, "provider", None),
|
||||
}
|
||||
|
||||
|
||||
def _webapp_iso_datetime(value: Optional[Any]) -> Optional[str]:
|
||||
if not value:
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
normalized = value if value.tzinfo else value.replace(tzinfo=timezone.utc)
|
||||
return normalized.isoformat()
|
||||
return str(value)
|
||||
|
||||
|
||||
def _webapp_datetime_text(value: Optional[Any]) -> Optional[str]:
|
||||
if not value:
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
normalized = value if value.tzinfo else value.replace(tzinfo=timezone.utc)
|
||||
return normalized.strftime("%d.%m.%Y %H:%M")
|
||||
return str(value)
|
||||
|
||||
|
||||
async def _attach_hwid_renewal_quotes_to_plans(
|
||||
session: AsyncSession,
|
||||
subscription_service: SubscriptionService,
|
||||
*,
|
||||
user_id: int,
|
||||
settings: Settings,
|
||||
active: Optional[Dict[str, Any]],
|
||||
local_sub: Optional[Any],
|
||||
plans: List[Dict[str, Any]],
|
||||
) -> None:
|
||||
quote_method = getattr(subscription_service, "quote_hwid_device_renewal_for_subscription", None)
|
||||
if not callable(quote_method):
|
||||
return
|
||||
if not active or not local_sub or not settings.tariffs_config:
|
||||
return
|
||||
if not active.get("end_date") or int(active.get("extra_hwid_devices") or 0) <= 0:
|
||||
return
|
||||
|
||||
default_currency = default_currency_key_for_settings(settings)
|
||||
default_currency_code = payment_currency_code(default_currency)
|
||||
for plan in plans:
|
||||
if str(plan.get("sale_mode") or "subscription") != "subscription":
|
||||
continue
|
||||
target_tariff_key = str(plan.get("tariff_key") or "").strip()
|
||||
if not target_tariff_key:
|
||||
continue
|
||||
try:
|
||||
months = int(plan.get("months") or 0)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if months <= 0:
|
||||
continue
|
||||
try:
|
||||
currency_quote = await quote_method(
|
||||
session,
|
||||
user_id=user_id,
|
||||
target_tariff_key=target_tariff_key,
|
||||
months=months,
|
||||
currency=default_currency,
|
||||
)
|
||||
stars_quote = await quote_method(
|
||||
session,
|
||||
user_id=user_id,
|
||||
target_tariff_key=target_tariff_key,
|
||||
months=months,
|
||||
currency="stars",
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to quote HWID renewal for plan %s/%s",
|
||||
target_tariff_key,
|
||||
months,
|
||||
)
|
||||
continue
|
||||
quote = currency_quote or stars_quote
|
||||
if not quote:
|
||||
continue
|
||||
valid_from = quote.get("valid_from")
|
||||
valid_until = quote.get("valid_until")
|
||||
active_until = quote.get("active_until")
|
||||
renewal = {
|
||||
"available": True,
|
||||
"device_count": int(quote.get("device_count") or 0),
|
||||
"price": float(currency_quote.get("price") if currency_quote else 0),
|
||||
"currency": default_currency_code,
|
||||
"valid_from": _webapp_iso_datetime(valid_from),
|
||||
"valid_from_text": _webapp_datetime_text(valid_from),
|
||||
"valid_until": _webapp_iso_datetime(valid_until),
|
||||
"valid_until_text": _webapp_datetime_text(valid_until),
|
||||
"active_until": _webapp_iso_datetime(active_until),
|
||||
"active_until_text": _webapp_datetime_text(active_until),
|
||||
"pricing_period_months": int(quote.get("pricing_period_months") or months),
|
||||
}
|
||||
if stars_quote and int(stars_quote.get("price") or 0) > 0:
|
||||
renewal["stars_price"] = int(stars_quote["price"])
|
||||
plan["hwid_renewal"] = renewal
|
||||
|
||||
|
||||
def _build_install_share_link(
|
||||
request: Optional[web.Request],
|
||||
settings: Settings,
|
||||
share_token: str,
|
||||
) -> Optional[str]:
|
||||
share_token = subscription_dal.normalize_install_share_token(share_token)
|
||||
if not share_token or request is None:
|
||||
return None
|
||||
configured_base = str(getattr(settings, "SUBSCRIPTION_MINI_APP_URL", "") or "").strip()
|
||||
if configured_base:
|
||||
parts = urlsplit(configured_base)
|
||||
if parts.scheme and parts.netloc:
|
||||
base = urlunsplit((parts.scheme, parts.netloc, "", "", ""))
|
||||
else:
|
||||
base = configured_base.rstrip("/")
|
||||
else:
|
||||
host = (
|
||||
request.headers.get("X-Forwarded-Host") or request.headers.get("Host") or request.host
|
||||
)
|
||||
proto = request.headers.get("X-Forwarded-Proto") or request.scheme or "https"
|
||||
base = f"{proto}://{host}"
|
||||
return f"{base.rstrip('/')}/s/{quote(share_token)}"
|
||||
|
||||
|
||||
def _serialize_plans(
|
||||
settings: Settings,
|
||||
lang: str,
|
||||
@@ -280,26 +605,34 @@ def _serialize_plans(
|
||||
) -> List[Dict[str, Any]]:
|
||||
tariffs_config = settings.tariffs_config
|
||||
if tariffs_config:
|
||||
default_currency = default_currency_key_for_settings(settings)
|
||||
default_currency_code = payment_currency_code(default_currency)
|
||||
plans: List[Dict[str, Any]] = []
|
||||
for tariff in tariffs_config.enabled_tariffs:
|
||||
common = {
|
||||
"tariff_key": tariff.key,
|
||||
"is_default_tariff": tariff.key == tariffs_config.default_tariff,
|
||||
"tariff_name": tariff.name(lang),
|
||||
"billing_model": tariff.billing_model,
|
||||
"description": tariff.description(lang),
|
||||
"squad_uuids": tariff.squad_uuids,
|
||||
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
|
||||
"currency": default_currency_code,
|
||||
"hwid_device_limit": tariff.hwid_device_limit,
|
||||
"hwid_device_packages": _serialize_hwid_device_packages(
|
||||
settings,
|
||||
tariff,
|
||||
tariff.hwid_device_packages,
|
||||
lang,
|
||||
),
|
||||
)
|
||||
if tariff.billing_model == "period"
|
||||
else [],
|
||||
}
|
||||
if tariff.billing_model == "period":
|
||||
for months in sorted(tariff.enabled_periods):
|
||||
price = tariff.period_price(int(months), "rub")
|
||||
# Render periods in the configured order (enabled_periods is the
|
||||
# source of truth for purchase-period ordering, matching the bot
|
||||
# keyboards). Do not sort so admins can reorder via drag & drop.
|
||||
for months in tariff.enabled_periods:
|
||||
price = tariff.period_price(int(months), default_currency)
|
||||
stars_price = tariff.period_price(int(months), "stars")
|
||||
if price is None and (stars_price is None or int(stars_price) <= 0):
|
||||
continue
|
||||
@@ -317,9 +650,13 @@ def _serialize_plans(
|
||||
plan["stars_price"] = int(stars_price)
|
||||
plans.append(plan)
|
||||
else:
|
||||
rub_packages = {
|
||||
currency_packages = {
|
||||
float(package.gb): float(package.price)
|
||||
for package in (tariff.traffic_packages.rub if tariff.traffic_packages else [])
|
||||
for package in (
|
||||
tariff.traffic_packages.for_currency(default_currency)
|
||||
if tariff.traffic_packages
|
||||
else []
|
||||
)
|
||||
}
|
||||
stars_packages = {
|
||||
float(package.gb): int(float(package.price))
|
||||
@@ -327,8 +664,15 @@ def _serialize_plans(
|
||||
tariff.traffic_packages.stars if tariff.traffic_packages else []
|
||||
)
|
||||
}
|
||||
for traffic_gb in sorted(set(rub_packages) | set(stars_packages)):
|
||||
price = rub_packages.get(traffic_gb)
|
||||
# Preserve the configured package order (default-currency list first,
|
||||
# then any Stars-only volumes) so admins can reorder via drag & drop.
|
||||
# Matches the bot keyboard, which iterates the package list as-is.
|
||||
ordered_gb: List[float] = []
|
||||
for traffic_gb in list(currency_packages) + list(stars_packages):
|
||||
if traffic_gb not in ordered_gb:
|
||||
ordered_gb.append(traffic_gb)
|
||||
for traffic_gb in ordered_gb:
|
||||
price = currency_packages.get(traffic_gb)
|
||||
stars_price = stars_packages.get(traffic_gb)
|
||||
if price is None and (stars_price is None or int(stars_price) <= 0):
|
||||
continue
|
||||
@@ -414,16 +758,19 @@ def _serialize_topup_packages(
|
||||
sale_mode: str = "topup",
|
||||
title_prefix: str = "",
|
||||
) -> List[Dict[str, Any]]:
|
||||
rub_packages = {
|
||||
float(package.gb): float(package.price) for package in (packages.rub if packages else [])
|
||||
default_currency = default_currency_key_for_settings(settings)
|
||||
default_currency_code = payment_currency_code(default_currency)
|
||||
currency_packages = {
|
||||
float(package.gb): float(package.price)
|
||||
for package in (packages.for_currency(default_currency) if packages else [])
|
||||
}
|
||||
stars_packages = {
|
||||
float(package.gb): int(float(package.price))
|
||||
for package in (packages.stars if packages else [])
|
||||
}
|
||||
plans: List[Dict[str, Any]] = []
|
||||
for traffic_gb in sorted(set(rub_packages) | set(stars_packages)):
|
||||
price = rub_packages.get(traffic_gb)
|
||||
for traffic_gb in sorted(set(currency_packages) | set(stars_packages)):
|
||||
price = currency_packages.get(traffic_gb)
|
||||
stars_price = stars_packages.get(traffic_gb)
|
||||
if price is None and (stars_price is None or int(stars_price) <= 0):
|
||||
continue
|
||||
@@ -437,7 +784,7 @@ def _serialize_topup_packages(
|
||||
"months": int(traffic_value) if traffic_value.is_integer() else traffic_value,
|
||||
"traffic_gb": traffic_value,
|
||||
"price": float(price or 0),
|
||||
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
|
||||
"currency": default_currency_code,
|
||||
"title": f"{title_prefix}{_format_traffic_title(traffic_value, lang)}",
|
||||
"subtitle": tariff.premium_name(lang)
|
||||
if sale_mode == "premium_topup"
|
||||
@@ -455,16 +802,19 @@ def _serialize_hwid_device_packages(
|
||||
packages: Optional[Any],
|
||||
lang: str,
|
||||
) -> List[Dict[str, Any]]:
|
||||
rub_packages = {
|
||||
int(package.count): float(package.price) for package in (packages.rub if packages else [])
|
||||
default_currency = default_currency_key_for_settings(settings)
|
||||
default_currency_code = payment_currency_code(default_currency)
|
||||
currency_packages = {
|
||||
int(package.count): float(package.price)
|
||||
for package in (packages.for_currency(default_currency) if packages else [])
|
||||
}
|
||||
stars_packages = {
|
||||
int(package.count): int(float(package.price))
|
||||
for package in (packages.stars if packages else [])
|
||||
}
|
||||
plans: List[Dict[str, Any]] = []
|
||||
for count in sorted(set(rub_packages) | set(stars_packages)):
|
||||
price = rub_packages.get(count)
|
||||
for count in sorted(set(currency_packages) | set(stars_packages)):
|
||||
price = currency_packages.get(count)
|
||||
stars_price = stars_packages.get(count)
|
||||
if price is None and (stars_price is None or int(stars_price) <= 0):
|
||||
continue
|
||||
@@ -477,7 +827,7 @@ def _serialize_hwid_device_packages(
|
||||
"months": int(count),
|
||||
"device_count": int(count),
|
||||
"price": float(price or 0),
|
||||
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
|
||||
"currency": default_currency_code,
|
||||
"title": f"+{count}",
|
||||
"subtitle": tariff.name(lang),
|
||||
}
|
||||
@@ -494,6 +844,8 @@ def _serialize_tariff_change_target(
|
||||
options: Dict[str, Any],
|
||||
lang: str,
|
||||
) -> Dict[str, Any]:
|
||||
default_currency = default_currency_key_for_settings(settings)
|
||||
default_currency_code = payment_currency_code(default_currency)
|
||||
actions: List[Dict[str, Any]] = []
|
||||
mode = str(options.get("mode") or "")
|
||||
if mode == "period_to_period":
|
||||
@@ -504,6 +856,8 @@ def _serialize_tariff_change_target(
|
||||
"title": "recalc_days",
|
||||
"days_after": int(options.get("recalc_days") or 0),
|
||||
"remaining_days": int(options.get("remaining_days") or 0),
|
||||
"converted_hwid_value_rub": float(options.get("converted_hwid_value_rub") or 0),
|
||||
"converted_hwid_days": int(options.get("converted_hwid_days") or 0),
|
||||
}
|
||||
)
|
||||
paid_diff = float(options.get("paid_diff_rub") or 0)
|
||||
@@ -514,7 +868,7 @@ def _serialize_tariff_change_target(
|
||||
"kind": "payment",
|
||||
"title": "paid_diff",
|
||||
"price": paid_diff,
|
||||
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
|
||||
"currency": default_currency_code,
|
||||
}
|
||||
)
|
||||
elif mode == "period_to_traffic":
|
||||
@@ -525,6 +879,8 @@ def _serialize_tariff_change_target(
|
||||
"title": "convert_days_to_gb",
|
||||
"converted_gb": float(options.get("converted_gb") or 0),
|
||||
"remaining_days": int(options.get("remaining_days") or 0),
|
||||
"converted_hwid_value_rub": float(options.get("converted_hwid_value_rub") or 0),
|
||||
"converted_hwid_gb": float(options.get("converted_hwid_gb") or 0),
|
||||
}
|
||||
)
|
||||
actions.extend(
|
||||
@@ -534,13 +890,17 @@ def _serialize_tariff_change_target(
|
||||
"title": f"+{package.gb:g} GB",
|
||||
"traffic_gb": float(package.gb),
|
||||
"price": float(package.price),
|
||||
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
|
||||
"currency": default_currency_code,
|
||||
}
|
||||
for package in (tariff.traffic_packages.rub if tariff.traffic_packages else [])
|
||||
for package in (
|
||||
tariff.traffic_packages.for_currency(default_currency)
|
||||
if tariff.traffic_packages
|
||||
else []
|
||||
)
|
||||
)
|
||||
else:
|
||||
for months in tariff.enabled_periods:
|
||||
price = tariff.period_price(int(months), "rub")
|
||||
price = tariff.period_price(int(months), default_currency)
|
||||
if price:
|
||||
actions.append(
|
||||
{
|
||||
@@ -549,7 +909,7 @@ def _serialize_tariff_change_target(
|
||||
"months": int(months),
|
||||
"title": _format_months_title(int(months), lang),
|
||||
"price": float(price),
|
||||
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
|
||||
"currency": default_currency_code,
|
||||
}
|
||||
)
|
||||
return {
|
||||
@@ -566,59 +926,32 @@ def _serialize_tariff_change_target(
|
||||
def _serialize_payment_methods(
|
||||
settings: Settings,
|
||||
app: web.Application,
|
||||
lang: str = "ru",
|
||||
*,
|
||||
is_admin: bool = False,
|
||||
) -> List[Dict[str, Any]]:
|
||||
labels = {
|
||||
"severpay": "SeverPay",
|
||||
"freekassa": "FreeKassa / СБП",
|
||||
"platega_sbp": "Platega · СБП",
|
||||
"platega_crypto": "Platega · Crypto",
|
||||
"yookassa": "Банковская карта",
|
||||
"stars": "Telegram Stars",
|
||||
"cryptopay": "CryptoPay",
|
||||
}
|
||||
from bot.payment_providers import get_provider_spec, resolve_provider_presentation
|
||||
|
||||
methods: List[Dict[str, Any]] = []
|
||||
payment_currency = default_payment_currency_code_for_settings(settings)
|
||||
for method in settings.payment_methods_order:
|
||||
method = method.lower()
|
||||
spec = get_provider_spec(method)
|
||||
if (
|
||||
method == "severpay"
|
||||
and settings.SEVERPAY_ENABLED
|
||||
and _service_configured(app, "severpay_service")
|
||||
spec
|
||||
and spec.is_visible_for_user(settings, app, is_admin=is_admin)
|
||||
and spec.is_usable_for_payment_currency(settings, payment_currency)
|
||||
):
|
||||
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]})
|
||||
presentation = resolve_provider_presentation(spec, settings, language=lang)
|
||||
payload = {
|
||||
"id": method,
|
||||
"name": presentation.webapp_label,
|
||||
"icon": presentation.webapp_icon,
|
||||
}
|
||||
minimum = spec.payment_minimum(settings, payment_currency)
|
||||
if minimum:
|
||||
payload.update(minimum)
|
||||
methods.append(payload)
|
||||
return methods
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
from bot.services.support_service import TicketForbidden, TicketNotFound, TicketRateLimited
|
||||
from db.dal import support_dal, user_dal
|
||||
from db.models import SupportTicket, SupportTicketMessage
|
||||
|
||||
|
||||
def _support_ticket_payload(ticket: SupportTicket) -> Dict[str, Any]:
|
||||
return {
|
||||
"ticket_id": ticket.ticket_id,
|
||||
"user_id": ticket.user_id,
|
||||
"subject": ticket.subject,
|
||||
"category": ticket.category,
|
||||
"priority": ticket.priority,
|
||||
"status": ticket.status,
|
||||
"assigned_admin_id": ticket.assigned_admin_id,
|
||||
"last_message_at": ticket.last_message_at.isoformat() if ticket.last_message_at else None,
|
||||
"last_message_role": ticket.last_message_role,
|
||||
"unread_user_count": int(ticket.unread_user_count or 0),
|
||||
"unread_admin_count": int(ticket.unread_admin_count or 0),
|
||||
"created_at": ticket.created_at.isoformat() if ticket.created_at else None,
|
||||
"updated_at": ticket.updated_at.isoformat() if ticket.updated_at else None,
|
||||
"closed_at": ticket.closed_at.isoformat() if ticket.closed_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _support_message_payload(message: SupportTicketMessage) -> Dict[str, Any]:
|
||||
return {
|
||||
"message_id": message.message_id,
|
||||
"ticket_id": message.ticket_id,
|
||||
"author_role": message.author_role,
|
||||
"author_user_id": message.author_user_id,
|
||||
"body": message.body,
|
||||
"is_internal_note": bool(message.is_internal_note),
|
||||
"created_at": message.created_at.isoformat() if message.created_at else None,
|
||||
"read_by_user_at": message.read_by_user_at.isoformat() if message.read_by_user_at else None,
|
||||
"read_by_admin_at": message.read_by_admin_at.isoformat()
|
||||
if message.read_by_admin_at
|
||||
else None,
|
||||
}
|
||||
|
||||
|
||||
def _support_limit_offset(request: web.Request) -> tuple[int, int]:
|
||||
limit = max(1, min(100, int(request.query.get("limit", 25) or 25)))
|
||||
offset = max(0, int(request.query.get("offset", 0) or 0))
|
||||
return limit, offset
|
||||
|
||||
|
||||
async def support_tickets_route(request: web.Request) -> web.Response:
|
||||
user_id = _require_user_id(request)
|
||||
limit, offset = _support_limit_offset(request)
|
||||
status_filter = request.query.get("status") or None
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
tickets = await support_dal.list_user_tickets(
|
||||
session,
|
||||
user_id,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
status_filter=status_filter,
|
||||
)
|
||||
counts = await support_dal.user_ticket_counts(session, user_id)
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": True,
|
||||
"tickets": [_support_ticket_payload(t) for t in tickets],
|
||||
"counts": counts,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def support_create_ticket_route(request: web.Request) -> web.Response:
|
||||
user_id = _require_user_id(request)
|
||||
payload, error = _validate_model_payload(CreateTicketPayload, await _read_json(request))
|
||||
if error:
|
||||
return error
|
||||
service = request.app["support_service"]
|
||||
try:
|
||||
ticket = await service.create_ticket(
|
||||
user_id,
|
||||
payload.subject,
|
||||
payload.category,
|
||||
payload.priority,
|
||||
payload.body,
|
||||
)
|
||||
except TicketForbidden:
|
||||
return _json_error(403, "ticket_forbidden", "Support ticket action is forbidden")
|
||||
except TicketRateLimited:
|
||||
return _json_error(429, "ticket_rate_limited", "Too many support tickets")
|
||||
return web.json_response({"ok": True, "ticket": _support_ticket_payload(ticket)})
|
||||
|
||||
|
||||
async def support_ticket_detail_route(request: web.Request) -> web.Response:
|
||||
user_id = _require_user_id(request)
|
||||
ticket_id = int(request.match_info["id"])
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
ticket, messages = await support_dal.get_ticket(session, ticket_id, include_internal=False)
|
||||
if not ticket or ticket.user_id != user_id:
|
||||
return _json_error(404, "not_found", "Ticket not found")
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": True,
|
||||
"ticket": _support_ticket_payload(ticket),
|
||||
"messages": [_support_message_payload(m) for m in messages],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def support_ticket_reply_route(request: web.Request) -> web.Response:
|
||||
user_id = _require_user_id(request)
|
||||
ticket_id = int(request.match_info["id"])
|
||||
payload, error = _validate_model_payload(TicketReplyPayload, await _read_json(request))
|
||||
if error:
|
||||
return error
|
||||
service = request.app["support_service"]
|
||||
try:
|
||||
ticket, message = await service.reply_as_user(user_id, ticket_id, payload.body)
|
||||
except TicketForbidden:
|
||||
return _json_error(403, "ticket_forbidden", "Support ticket action is forbidden")
|
||||
except TicketNotFound:
|
||||
return _json_error(404, "not_found", "Ticket not found")
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": True,
|
||||
"ticket": _support_ticket_payload(ticket),
|
||||
"message": _support_message_payload(message),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def support_ticket_read_route(request: web.Request) -> web.Response:
|
||||
user_id = _require_user_id(request)
|
||||
ticket_id = int(request.match_info["id"])
|
||||
service = request.app["support_service"]
|
||||
try:
|
||||
await service.mark_read_as_user(user_id, ticket_id)
|
||||
except TicketNotFound:
|
||||
return _json_error(404, "not_found", "Ticket not found")
|
||||
return web.json_response({"ok": True})
|
||||
|
||||
|
||||
async def support_unread_route(request: web.Request) -> web.Response:
|
||||
user_id = _require_user_id(request)
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
user = await user_dal.get_user_by_id(session, user_id)
|
||||
if user and user.is_banned:
|
||||
return _json_error(403, "ticket_forbidden", "Support ticket action is forbidden")
|
||||
unread = await support_dal.count_user_unread(session, user_id)
|
||||
return web.json_response({"ok": True, "unread": unread})
|
||||
@@ -0,0 +1,70 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
from bot.services.telegram_notifications import (
|
||||
TELEGRAM_NOTIFICATIONS_ENABLED,
|
||||
probe_telegram_notifications,
|
||||
telegram_notifications_start_link,
|
||||
)
|
||||
from .common import _invalidate_webapp_user_caches
|
||||
|
||||
|
||||
async def _probe_telegram_notifications_for_user_id(
|
||||
request: web.Request,
|
||||
user_id: int,
|
||||
*,
|
||||
force: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
settings: Settings = request.app["settings"]
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
if not db_user or db_user.is_banned:
|
||||
await session.rollback()
|
||||
return {
|
||||
"ok": False,
|
||||
"status": "access_denied",
|
||||
"enabled": False,
|
||||
"start_link": telegram_notifications_start_link(
|
||||
request.app.get("bot_username") or ""
|
||||
),
|
||||
}
|
||||
result = await probe_telegram_notifications(
|
||||
session=session,
|
||||
bot=request.app["bot"],
|
||||
settings=settings,
|
||||
i18n=request.app.get("i18n"),
|
||||
user=db_user,
|
||||
bot_username=request.app.get("bot_username") or "",
|
||||
force=force,
|
||||
)
|
||||
await session.commit()
|
||||
status = str(result.get("status") or "")
|
||||
await _invalidate_webapp_user_caches(settings, int(db_user.user_id))
|
||||
return {
|
||||
"ok": bool(result.get("ok")),
|
||||
"status": status,
|
||||
"enabled": status == TELEGRAM_NOTIFICATIONS_ENABLED,
|
||||
"start_link": result.get("start_link"),
|
||||
}
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logger.exception("Telegram notification probe failed")
|
||||
return {
|
||||
"ok": False,
|
||||
"status": "unknown",
|
||||
"enabled": False,
|
||||
"start_link": telegram_notifications_start_link(
|
||||
request.app.get("bot_username") or ""
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def account_telegram_notifications_probe_route(request: web.Request) -> web.Response:
|
||||
user_id = _require_user_id(request)
|
||||
force = True
|
||||
result = await _probe_telegram_notifications_for_user_id(request, user_id, force=force)
|
||||
if result.get("status") == "access_denied":
|
||||
return _json_error(403, "access_denied", "Access denied")
|
||||
return web.json_response({"ok": True, "telegram_notifications": result})
|
||||
@@ -155,7 +155,7 @@ async def change_broadcast_target_handler(
|
||||
return
|
||||
|
||||
new_target = callback.data.split(":")[1]
|
||||
if new_target not in {"all", "active", "inactive"}:
|
||||
if new_target not in {"all", "active", "inactive", "expired"}:
|
||||
await callback.answer("Unknown target.", show_alert=True)
|
||||
return
|
||||
|
||||
@@ -247,6 +247,8 @@ async def confirm_broadcast_callback_handler(
|
||||
user_ids = await user_dal.get_user_ids_with_active_subscription(session)
|
||||
elif target == "inactive":
|
||||
user_ids = await user_dal.get_user_ids_without_active_subscription(session)
|
||||
elif target == "expired":
|
||||
user_ids = await user_dal.get_user_ids_with_expired_subscription(session)
|
||||
else:
|
||||
user_ids = await user_dal.get_all_active_user_ids_for_broadcast(session)
|
||||
|
||||
|
||||
@@ -156,7 +156,6 @@ async def admin_panel_actions_callback_handler(
|
||||
panel_service=panel_service,
|
||||
session=session,
|
||||
)
|
||||
await callback.answer(_("admin_sync_initiated_from_panel"))
|
||||
elif action == "queue_status":
|
||||
await show_queue_status_handler(callback, i18n_data)
|
||||
elif action == "view_payments":
|
||||
|
||||
@@ -8,6 +8,7 @@ from typing import Any, Dict, List, Optional
|
||||
|
||||
from aiogram import F, Router, types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.utils.text_decorations import html_decoration as hd
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot.keyboards.inline.admin_keyboards import (
|
||||
@@ -25,6 +26,44 @@ USERNAME_REGEX = re.compile(r"^[a-zA-Z0-9_]{5,32}$")
|
||||
EMAIL_REGEX = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
||||
|
||||
|
||||
def _user_email(user: Optional[User]) -> str:
|
||||
return str(getattr(user, "email", None) or "").strip()
|
||||
|
||||
|
||||
def _format_user_with_email(
|
||||
*,
|
||||
first_name: Optional[str] = None,
|
||||
username: Optional[str] = None,
|
||||
email: Optional[str] = None,
|
||||
fallback: str = "",
|
||||
) -> str:
|
||||
parts = []
|
||||
if first_name:
|
||||
parts.append(first_name)
|
||||
if username:
|
||||
parts.append(f"(@{username})")
|
||||
|
||||
display = " ".join(parts).strip() or str(fallback or "").strip()
|
||||
clean_email = str(email or "").strip()
|
||||
if clean_email:
|
||||
display = (
|
||||
f"{display} · {clean_email}" if display and display != clean_email else clean_email
|
||||
)
|
||||
return hd.quote(display)
|
||||
|
||||
|
||||
def _format_log_entry_user(log_entry: MessageLog, translate) -> str:
|
||||
fallback = (
|
||||
translate("system_or_unknown_user") if not log_entry.user_id else f"ID: {log_entry.user_id}"
|
||||
)
|
||||
return _format_user_with_email(
|
||||
first_name=log_entry.telegram_first_name,
|
||||
username=log_entry.telegram_username,
|
||||
email=_user_email(getattr(log_entry, "author_user", None)),
|
||||
fallback=fallback,
|
||||
)
|
||||
|
||||
|
||||
async def display_logs_menu(
|
||||
callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession
|
||||
):
|
||||
@@ -94,19 +133,7 @@ async def _display_formatted_logs(
|
||||
|
||||
log_entries_text = []
|
||||
for log_entry_model in logs:
|
||||
user_display_parts = []
|
||||
if log_entry_model.telegram_first_name:
|
||||
user_display_parts.append(log_entry_model.telegram_first_name)
|
||||
if log_entry_model.telegram_username:
|
||||
user_display_parts.append(f"(@{log_entry_model.telegram_username})")
|
||||
|
||||
user_display = " ".join(user_display_parts).strip()
|
||||
if not user_display:
|
||||
user_display = (
|
||||
_("system_or_unknown_user")
|
||||
if not log_entry_model.user_id
|
||||
else f"ID: {log_entry_model.user_id}"
|
||||
)
|
||||
user_display = _format_log_entry_user(log_entry_model, _)
|
||||
|
||||
user_id_display = (
|
||||
str(log_entry_model.user_id) if log_entry_model.user_id is not None else "N/A"
|
||||
@@ -270,10 +297,11 @@ async def process_user_id_for_logs_handler(
|
||||
return
|
||||
|
||||
target_user_id = user_model_for_logs.user_id
|
||||
user_display_name = user_model_for_logs.first_name or (
|
||||
f"@{user_model_for_logs.username}"
|
||||
if user_model_for_logs.username
|
||||
else (user_model_for_logs.email or f"ID {target_user_id}")
|
||||
user_display_name = _format_user_with_email(
|
||||
first_name=user_model_for_logs.first_name,
|
||||
username=user_model_for_logs.username,
|
||||
email=user_model_for_logs.email,
|
||||
fallback=f"ID {target_user_id}",
|
||||
)
|
||||
|
||||
logs_models = await message_log_dal.get_user_message_logs(
|
||||
@@ -319,10 +347,11 @@ async def view_user_logs_paginated_handler(
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
user_display_name = user_model_for_logs.first_name or (
|
||||
f"@{user_model_for_logs.username}"
|
||||
if user_model_for_logs.username
|
||||
else (user_model_for_logs.email or f"ID {target_user_id}")
|
||||
user_display_name = _format_user_with_email(
|
||||
first_name=user_model_for_logs.first_name,
|
||||
username=user_model_for_logs.username,
|
||||
email=user_model_for_logs.email,
|
||||
fallback=f"ID {target_user_id}",
|
||||
)
|
||||
|
||||
logs_models = await message_log_dal.get_user_message_logs(
|
||||
@@ -392,6 +421,7 @@ async def export_logs_csv_handler(
|
||||
_("admin_csv_header_user_id"),
|
||||
_("admin_csv_header_telegram_username"),
|
||||
_("admin_csv_header_telegram_first_name"),
|
||||
_("admin_csv_header_email"),
|
||||
_("admin_csv_header_event_type"),
|
||||
_("admin_csv_header_content"),
|
||||
_("admin_csv_header_is_admin_event"),
|
||||
@@ -417,6 +447,7 @@ async def export_logs_csv_handler(
|
||||
log.user_id or "",
|
||||
log.telegram_username or "",
|
||||
log.telegram_first_name or "",
|
||||
_user_email(getattr(log, "author_user", None)),
|
||||
log.event_type or "",
|
||||
content_clean,
|
||||
"Yes" if log.is_admin_event else "No",
|
||||
|
||||
@@ -10,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot.keyboards.inline.admin_keyboards import get_back_to_admin_panel_keyboard
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.payment_providers import pending_statuses, provider_label_map
|
||||
from config.settings import Settings
|
||||
from db.dal import payment_dal
|
||||
from db.models import Payment
|
||||
@@ -38,18 +39,10 @@ def format_payment_text(payment: Payment, i18n: JsonI18n, lang: str, settings: S
|
||||
"""Format single payment info as text."""
|
||||
_ = lambda key, **kwargs: i18n.gettext(lang, key, **kwargs)
|
||||
|
||||
pending_statuses = [
|
||||
"pending",
|
||||
"pending_yookassa",
|
||||
"pending_freekassa",
|
||||
"pending_platega",
|
||||
"pending_severpay",
|
||||
"pending_cryptopay",
|
||||
]
|
||||
status_emoji = (
|
||||
"✅"
|
||||
if payment.status == "succeeded"
|
||||
else ("⏳" if payment.status in pending_statuses else "❌")
|
||||
else ("⏳" if payment.status in pending_statuses() else "❌")
|
||||
)
|
||||
|
||||
user_info = f"User {payment.user_id}"
|
||||
@@ -60,14 +53,10 @@ def format_payment_text(payment: Payment, i18n: JsonI18n, lang: str, settings: S
|
||||
|
||||
payment_date = payment.created_at.strftime("%Y-%m-%d %H:%M") if payment.created_at else "N/A"
|
||||
|
||||
provider_text = {
|
||||
"yookassa": "YooKassa",
|
||||
"telegram_stars": "Telegram Stars",
|
||||
"cryptopay": "CryptoPay",
|
||||
"freekassa": "FreeKassa",
|
||||
"severpay": "SeverPay",
|
||||
"platega": "Platega",
|
||||
}.get(payment.provider, payment.provider or "Unknown")
|
||||
provider_text = provider_label_map(settings, lang).get(
|
||||
payment.provider,
|
||||
payment.provider or "Unknown",
|
||||
)
|
||||
|
||||
sale_base = (payment.sale_mode or "").split("@", 1)[0].split("|", 1)[0]
|
||||
traffic_like = sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
|
||||
|
||||
@@ -10,8 +10,10 @@ from bot.keyboards.inline.admin_keyboards import (
|
||||
get_back_to_user_management_keyboard,
|
||||
)
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.payment_providers import pending_statuses
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
from config.settings import Settings
|
||||
from config.tariffs_config import default_payment_currency_code_for_settings
|
||||
from db.dal import panel_sync_dal, payment_dal, user_dal
|
||||
from db.models import PanelSyncStatus, Payment
|
||||
|
||||
@@ -70,11 +72,17 @@ async def show_statistics_handler(
|
||||
f"📊 {_('admin_user_stats_total_label')}: <b>{user_stats['total_users']}</b>"
|
||||
)
|
||||
# Removed: Active today moved to panel stats
|
||||
stats_text_parts.append(
|
||||
f"📡 {_('admin_user_stats_active_subscription_label')}: <b>{user_stats['active_subscriptions']}</b>" # noqa: E501
|
||||
)
|
||||
stats_text_parts.append(
|
||||
f"💳 {_('admin_user_stats_paid_subs_label')}: <b>{user_stats['paid_subscriptions']}</b>"
|
||||
)
|
||||
stats_text_parts.append(
|
||||
f"🆓 {_('admin_user_stats_trial_label')}: <b>{user_stats['trial_users']}</b>"
|
||||
f"🧪 {_('admin_user_stats_trial_label')}: <b>{user_stats['trial_users']}</b>"
|
||||
)
|
||||
stats_text_parts.append(
|
||||
f"🎁 {_('admin_user_stats_free_subscription_label')}: <b>{user_stats['free_subscription_users']}</b>" # noqa: E501
|
||||
)
|
||||
stats_text_parts.append(
|
||||
f"😴 {_('admin_user_stats_inactive_label')}: <b>{user_stats['inactive_users']}</b>"
|
||||
@@ -188,19 +196,20 @@ async def show_statistics_handler(
|
||||
|
||||
# Financial statistics
|
||||
financial_stats = await payment_dal.get_financial_statistics(session)
|
||||
currency = default_payment_currency_code_for_settings(settings)
|
||||
|
||||
stats_text_parts.append(f"\n<b>💰 {_('admin_financial_stats_header')}</b>")
|
||||
stats_text_parts.append(
|
||||
f"📅 {_('admin_financial_today_label')}: <b>{financial_stats['today_revenue']:.2f} RUB</b> ({financial_stats['today_payments_count']} {_('admin_financial_payments_label')})" # noqa: E501
|
||||
f"📅 {_('admin_financial_today_label')}: <b>{financial_stats['today_revenue']:.2f} {currency}</b> ({financial_stats['today_payments_count']} {_('admin_financial_payments_label')})" # noqa: E501
|
||||
)
|
||||
stats_text_parts.append(
|
||||
f"📅 {_('admin_financial_week_label')}: <b>{financial_stats['week_revenue']:.2f} RUB</b>"
|
||||
f"📅 {_('admin_financial_week_label')}: <b>{financial_stats['week_revenue']:.2f} {currency}</b>" # noqa: E501
|
||||
)
|
||||
stats_text_parts.append(
|
||||
f"📅 {_('admin_financial_month_label')}: <b>{financial_stats['month_revenue']:.2f} RUB</b>"
|
||||
f"📅 {_('admin_financial_month_label')}: <b>{financial_stats['month_revenue']:.2f} {currency}</b>" # noqa: E501
|
||||
)
|
||||
stats_text_parts.append(
|
||||
f"🏆 {_('admin_financial_all_time_label')}: <b>{financial_stats['all_time_revenue']:.2f} RUB</b>" # noqa: E501
|
||||
f"🏆 {_('admin_financial_all_time_label')}: <b>{financial_stats['all_time_revenue']:.2f} {currency}</b>" # noqa: E501
|
||||
)
|
||||
|
||||
last_payments_models: List[Payment] = await payment_dal.get_recent_payment_logs_with_user(
|
||||
@@ -209,19 +218,11 @@ async def show_statistics_handler(
|
||||
if last_payments_models:
|
||||
stats_text_parts.append(f"\n<b>{_('admin_stats_recent_payments_header')}</b>")
|
||||
for payment in last_payments_models:
|
||||
pending_statuses = [
|
||||
"pending",
|
||||
"pending_yookassa",
|
||||
"pending_freekassa",
|
||||
"pending_platega",
|
||||
"pending_severpay",
|
||||
"pending_cryptopay",
|
||||
]
|
||||
status_emoji = (
|
||||
"✅"
|
||||
if payment.status == "succeeded"
|
||||
else "⏳"
|
||||
if payment.status in pending_statuses
|
||||
if payment.status in pending_statuses()
|
||||
else "❌"
|
||||
)
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ from bot.utils.text_sanitizer import (
|
||||
username_for_display,
|
||||
)
|
||||
from config.settings import Settings
|
||||
from config.tariffs_config import default_payment_currency_code_for_settings
|
||||
from db.dal import message_log_dal, subscription_dal, user_dal
|
||||
from db.models import User
|
||||
|
||||
@@ -88,6 +89,31 @@ async def _find_user_by_admin_input(
|
||||
return None
|
||||
|
||||
|
||||
def _admin_user_reference_label(
|
||||
user: Optional[User], fallback_user_id: Optional[int] = None
|
||||
) -> str:
|
||||
if user is None:
|
||||
return f"ID {fallback_user_id}" if fallback_user_id is not None else "N/A"
|
||||
|
||||
first_name = sanitize_display_name(user.first_name) if user.first_name else ""
|
||||
last_name = sanitize_display_name(user.last_name) if user.last_name else ""
|
||||
full_name = f"{first_name} {last_name}".strip()
|
||||
if full_name:
|
||||
label = full_name
|
||||
elif user.username:
|
||||
label = username_for_display(user.username, with_at=True)
|
||||
elif user.email:
|
||||
label = user.email
|
||||
else:
|
||||
label = f"ID {user.user_id}"
|
||||
return f"{label} · ID {user.user_id}"
|
||||
|
||||
|
||||
def _admin_user_button_label(user: User) -> str:
|
||||
label = _admin_user_reference_label(user)
|
||||
return label[:64]
|
||||
|
||||
|
||||
async def users_list_handler(
|
||||
callback: types.CallbackQuery,
|
||||
i18n_data: dict,
|
||||
@@ -195,7 +221,13 @@ def get_user_card_keyboard(
|
||||
text=_(key="admin_user_refresh_button"), callback_data=f"user_action:refresh:{user_id}"
|
||||
)
|
||||
|
||||
# Row 3b: Premium override + traffic grant
|
||||
# Row 3b: Referral details
|
||||
builder.button(
|
||||
text=_(key="admin_user_invitees_button"),
|
||||
callback_data=f"user_action:invitees:{user_id}:0",
|
||||
)
|
||||
|
||||
# Row 4: Premium override + traffic grant
|
||||
builder.button(
|
||||
text=_(key="admin_user_premium_override_button"),
|
||||
callback_data=f"user_action:premium_override:{user_id}",
|
||||
@@ -204,6 +236,10 @@ def get_user_card_keyboard(
|
||||
text=_(key="admin_user_traffic_grant_button"),
|
||||
callback_data=f"user_action:traffic_grant:{user_id}",
|
||||
)
|
||||
builder.button(
|
||||
text=_(key="admin_user_hwid_limit_button"),
|
||||
callback_data=f"user_action:hwid_limit:{user_id}",
|
||||
)
|
||||
|
||||
# Row 4: Quick links — only for users with a real Telegram profile
|
||||
# (synthetic email-only users have a negative user_id with no tg profile).
|
||||
@@ -229,9 +265,9 @@ def get_user_card_keyboard(
|
||||
|
||||
quick_links_count = (1 if has_self_link else 0) + (1 if has_referrer_link else 0)
|
||||
if quick_links_count == 0:
|
||||
builder.adjust(2, 2, 2, 2, 1, 2)
|
||||
builder.adjust(2, 2, 2, 1, 3, 1, 2)
|
||||
else:
|
||||
builder.adjust(2, 2, 2, 2, quick_links_count, 1, 2)
|
||||
builder.adjust(2, 2, 2, 1, 3, quick_links_count, 1, 2)
|
||||
return builder
|
||||
|
||||
|
||||
@@ -314,7 +350,11 @@ async def format_user_card(
|
||||
|
||||
# Referral info
|
||||
if user.referred_by_id:
|
||||
card_parts.append(f"{_('admin_user_referral_label')} {hcode(str(user.referred_by_id))}")
|
||||
referrer = await user_dal.get_referrer_for_user(session, user)
|
||||
card_parts.append(
|
||||
f"{_('admin_user_invited_by_label')} "
|
||||
f"{hcode(_admin_user_reference_label(referrer, user.referred_by_id))}"
|
||||
)
|
||||
|
||||
# Panel info
|
||||
if user.panel_user_uuid:
|
||||
@@ -367,6 +407,26 @@ async def format_user_card(
|
||||
f"{_('admin_user_traffic_label')} {hcode(f'{used_display} / {limit_display}')}"
|
||||
)
|
||||
|
||||
max_devices = subscription_details.get("max_devices")
|
||||
extra_hwid_devices = int(subscription_details.get("extra_hwid_devices") or 0)
|
||||
if max_devices is not None:
|
||||
if int(max_devices) == 0:
|
||||
devices_display = _("admin_hwid_limit_state_unlimited")
|
||||
elif extra_hwid_devices > 0:
|
||||
base_hwid_limit = subscription_details.get("base_hwid_device_limit")
|
||||
if base_hwid_limit is None:
|
||||
devices_display = _("admin_hwid_limit_state_count", count=int(max_devices))
|
||||
else:
|
||||
devices_display = _(
|
||||
"admin_hwid_limit_state_with_extra",
|
||||
total=int(max_devices),
|
||||
base=int(base_hwid_limit),
|
||||
extra=extra_hwid_devices,
|
||||
)
|
||||
else:
|
||||
devices_display = _("admin_hwid_limit_state_count", count=int(max_devices))
|
||||
card_parts.append(f"{_('admin_user_hwid_limit_label')} {hcode(devices_display)}")
|
||||
|
||||
premium_unlimited = bool(subscription_details.get("premium_unlimited_override"))
|
||||
premium_bonus_bytes = int(subscription_details.get("premium_bonus_bytes") or 0)
|
||||
if premium_unlimited:
|
||||
@@ -407,17 +467,18 @@ async def format_user_card(
|
||||
try:
|
||||
from db.dal import payment_dal
|
||||
|
||||
currency = default_payment_currency_code_for_settings(settings)
|
||||
|
||||
# Total amount paid by this user
|
||||
total_paid = await payment_dal.get_user_total_paid(session, user.user_id)
|
||||
card_parts.append(
|
||||
f"{_('admin_user_total_paid_label')} {hcode(f'{total_paid:.2f} RUB')}"
|
||||
f"{_('admin_user_total_paid_label')} {hcode(f'{total_paid:.2f} {currency}')}"
|
||||
)
|
||||
|
||||
# Total revenue from referrals
|
||||
referral_revenue = await payment_dal.get_referral_revenue(session, user.user_id)
|
||||
card_parts.append(
|
||||
f"{_('admin_user_referral_revenue_label')} {hcode(f'{referral_revenue:.2f} RUB')}"
|
||||
)
|
||||
referral_revenue_text = hcode(f"{referral_revenue:.2f} {currency}")
|
||||
card_parts.append(f"{_('admin_user_referral_revenue_label')} {referral_revenue_text}")
|
||||
except Exception as e_fin:
|
||||
logging.error(
|
||||
f"Failed to build financial analytics for admin card {user.user_id}: {e_fin}"
|
||||
@@ -619,6 +680,12 @@ async def user_action_handler(
|
||||
await handle_send_message_prompt(callback, state, user, i18n, current_lang)
|
||||
elif action == "view_logs":
|
||||
await handle_view_user_logs(callback, user, session, settings, i18n, current_lang)
|
||||
elif action == "invitees":
|
||||
try:
|
||||
page = max(0, int(parts[3])) if len(parts) > 3 else 0
|
||||
except (TypeError, ValueError):
|
||||
page = 0
|
||||
await handle_view_user_invitees(callback, user, session, i18n, current_lang, page=page)
|
||||
elif action == "refresh":
|
||||
await handle_refresh_user_card(
|
||||
callback, user, subscription_service, session, settings, i18n, current_lang
|
||||
@@ -663,6 +730,32 @@ async def user_action_handler(
|
||||
await handle_traffic_grant_prompt(callback, state, user, "regular", i18n, current_lang)
|
||||
elif action == "traffic_grant_premium":
|
||||
await handle_traffic_grant_prompt(callback, state, user, "premium", i18n, current_lang)
|
||||
elif action == "hwid_limit":
|
||||
await handle_hwid_limit_menu(callback, state, user, session, i18n, current_lang)
|
||||
elif action == "hwid_limit_set_unlimited":
|
||||
await handle_hwid_limit_apply(
|
||||
callback,
|
||||
user,
|
||||
subscription_service,
|
||||
session,
|
||||
settings,
|
||||
i18n,
|
||||
current_lang,
|
||||
hwid_device_limit=0,
|
||||
)
|
||||
elif action == "hwid_limit_reset":
|
||||
await handle_hwid_limit_apply(
|
||||
callback,
|
||||
user,
|
||||
subscription_service,
|
||||
session,
|
||||
settings,
|
||||
i18n,
|
||||
current_lang,
|
||||
hwid_device_limit=None,
|
||||
)
|
||||
elif action == "hwid_limit_set_number":
|
||||
await handle_hwid_limit_prompt(callback, state, user, i18n, current_lang)
|
||||
else:
|
||||
await callback.answer(_("admin_unknown_action"), show_alert=True)
|
||||
|
||||
@@ -807,6 +900,162 @@ async def handle_premium_override_bonus_prompt(
|
||||
await callback.answer()
|
||||
|
||||
|
||||
def _admin_hwid_limit_state_text(
|
||||
get_text: Callable[..., str],
|
||||
hwid_device_limit: Optional[int],
|
||||
extra_hwid_devices: int = 0,
|
||||
) -> str:
|
||||
if hwid_device_limit is None:
|
||||
return get_text("admin_hwid_limit_state_default")
|
||||
base_limit = int(hwid_device_limit)
|
||||
if base_limit == 0:
|
||||
return get_text("admin_hwid_limit_state_unlimited")
|
||||
extra = max(0, int(extra_hwid_devices or 0))
|
||||
if extra > 0:
|
||||
return get_text(
|
||||
"admin_hwid_limit_state_with_extra",
|
||||
total=base_limit + extra,
|
||||
base=base_limit,
|
||||
extra=extra,
|
||||
)
|
||||
return get_text("admin_hwid_limit_state_count", count=base_limit)
|
||||
|
||||
|
||||
async def handle_hwid_limit_menu(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
user: User,
|
||||
session: AsyncSession,
|
||||
i18n_instance,
|
||||
lang: str,
|
||||
) -> None:
|
||||
"""Show HWID device limit override controls."""
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
|
||||
active_sub = await subscription_dal.get_active_subscription_by_user_id(session, user.user_id)
|
||||
if not active_sub:
|
||||
await callback.answer(_("admin_hwid_limit_no_subscription"), show_alert=True)
|
||||
return
|
||||
|
||||
current_text = _admin_hwid_limit_state_text(
|
||||
_,
|
||||
getattr(active_sub, "hwid_device_limit", None),
|
||||
int(getattr(active_sub, "extra_hwid_devices", 0) or 0),
|
||||
)
|
||||
text = "\n".join(
|
||||
[
|
||||
f"<b>{_('admin_hwid_limit_title')}</b>",
|
||||
"",
|
||||
_("admin_hwid_limit_hint"),
|
||||
"",
|
||||
_("admin_hwid_limit_current", current=current_text),
|
||||
]
|
||||
)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(
|
||||
text=_("admin_hwid_limit_btn_set_number"),
|
||||
callback_data=f"user_action:hwid_limit_set_number:{user.user_id}",
|
||||
)
|
||||
builder.button(
|
||||
text=_("admin_hwid_limit_btn_unlimited"),
|
||||
callback_data=f"user_action:hwid_limit_set_unlimited:{user.user_id}",
|
||||
)
|
||||
builder.button(
|
||||
text=_("admin_hwid_limit_btn_reset"),
|
||||
callback_data=f"user_action:hwid_limit_reset:{user.user_id}",
|
||||
)
|
||||
builder.button(
|
||||
text=_("admin_user_back_to_card_button"),
|
||||
callback_data=f"user_action:refresh:{user.user_id}",
|
||||
)
|
||||
builder.adjust(1, 1, 1, 1)
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(text, reply_markup=builder.as_markup(), parse_mode="HTML")
|
||||
except Exception:
|
||||
await callback.message.answer(text, reply_markup=builder.as_markup(), parse_mode="HTML")
|
||||
await state.update_data(target_user_id=user.user_id)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def handle_hwid_limit_apply(
|
||||
callback: types.CallbackQuery,
|
||||
user: User,
|
||||
subscription_service: SubscriptionService,
|
||||
session: AsyncSession,
|
||||
settings: Settings,
|
||||
i18n_instance,
|
||||
lang: str,
|
||||
*,
|
||||
hwid_device_limit: Optional[int],
|
||||
) -> None:
|
||||
"""Persist a HWID device base limit override and push it to the panel."""
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
|
||||
try:
|
||||
active_sub = await subscription_dal.get_active_subscription_by_user_id(
|
||||
session, user.user_id
|
||||
)
|
||||
if not active_sub:
|
||||
await callback.answer(_("admin_hwid_limit_no_subscription"), show_alert=True)
|
||||
return
|
||||
|
||||
active_sub.hwid_device_limit = hwid_device_limit
|
||||
|
||||
effective_limit = await subscription_service.sync_hwid_device_limit_to_panel(
|
||||
session, user.user_id
|
||||
)
|
||||
await message_log_dal.create_message_log_no_commit(
|
||||
session,
|
||||
{
|
||||
"user_id": callback.from_user.id if callback.from_user else user.user_id,
|
||||
"event_type": "admin:hwid_device_limit",
|
||||
"content": (
|
||||
f"hwid_device_limit={hwid_device_limit!r} "
|
||||
f"effective_hwid_device_limit={effective_limit!r}"
|
||||
),
|
||||
"is_admin_event": True,
|
||||
"target_user_id": user.user_id,
|
||||
"timestamp": datetime.now(timezone.utc),
|
||||
},
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
await callback.answer(_("admin_hwid_limit_saved"), show_alert=False)
|
||||
await handle_refresh_user_card(
|
||||
callback, user, subscription_service, session, settings, i18n_instance, lang
|
||||
)
|
||||
except Exception as exc:
|
||||
logging.error(
|
||||
"Failed to apply HWID device limit for user %s: %s",
|
||||
user.user_id,
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
await session.rollback()
|
||||
await callback.answer(_("admin_hwid_limit_save_error"), show_alert=True)
|
||||
|
||||
|
||||
async def handle_hwid_limit_prompt(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
user: User,
|
||||
i18n_instance,
|
||||
lang: str,
|
||||
) -> None:
|
||||
"""Ask admin for an explicit HWID device limit."""
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
await state.update_data(target_user_id=user.user_id)
|
||||
await state.set_state(AdminStates.waiting_for_hwid_device_limit)
|
||||
prompt = _("admin_hwid_limit_prompt", user_id=user.user_id)
|
||||
try:
|
||||
await callback.message.edit_text(prompt)
|
||||
except Exception:
|
||||
await callback.message.answer(prompt)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def handle_traffic_grant_menu(
|
||||
callback: types.CallbackQuery,
|
||||
user: User,
|
||||
@@ -889,8 +1138,7 @@ async def handle_reset_trial(
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
|
||||
try:
|
||||
# Delete all user subscriptions to reset trial eligibility
|
||||
await subscription_dal.delete_all_user_subscriptions(session, user.user_id)
|
||||
await user_dal.mark_trial_eligibility_reset(session, user.user_id)
|
||||
await session.commit()
|
||||
|
||||
await callback.answer(_("admin_user_trial_reset_success"), show_alert=True)
|
||||
@@ -1056,6 +1304,120 @@ async def handle_view_user_logs(
|
||||
await callback.answer(_("admin_user_logs_error"), show_alert=True)
|
||||
|
||||
|
||||
async def handle_view_user_invitees(
|
||||
callback: types.CallbackQuery,
|
||||
user: User,
|
||||
session: AsyncSession,
|
||||
i18n_instance,
|
||||
lang: str,
|
||||
*,
|
||||
page: int = 0,
|
||||
):
|
||||
"""Show users invited by the selected account."""
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
page_size = 10
|
||||
safe_page = max(0, int(page or 0))
|
||||
|
||||
try:
|
||||
total = await user_dal.count_users_referred_by(session, user.user_id)
|
||||
total_pages = max(1, (total + page_size - 1) // page_size)
|
||||
if safe_page >= total_pages:
|
||||
safe_page = total_pages - 1
|
||||
invitees = await user_dal.get_users_referred_by(
|
||||
session,
|
||||
user.user_id,
|
||||
limit=page_size,
|
||||
offset=safe_page * page_size,
|
||||
)
|
||||
|
||||
header = _(
|
||||
"admin_user_invitees_message_title",
|
||||
user=hcode(_admin_user_reference_label(user)),
|
||||
total=total,
|
||||
current=safe_page + 1,
|
||||
total_pages=total_pages,
|
||||
)
|
||||
if total <= 0:
|
||||
invitees_text = f"{header}\n\n{_('admin_user_invitees_empty')}"
|
||||
else:
|
||||
lines = []
|
||||
for index, invitee in enumerate(invitees, start=safe_page * page_size + 1):
|
||||
registered = (
|
||||
invitee.registration_date.strftime("%Y-%m-%d")
|
||||
if invitee.registration_date
|
||||
else ""
|
||||
)
|
||||
suffix = (
|
||||
_("admin_user_invitee_registered_suffix", date=registered) if registered else ""
|
||||
)
|
||||
lines.append(
|
||||
_(
|
||||
"admin_user_invitee_item",
|
||||
index=index,
|
||||
user=hcode(_admin_user_reference_label(invitee)),
|
||||
suffix=suffix,
|
||||
)
|
||||
)
|
||||
invitees_text = "\n".join([header, "", *lines])
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
for invitee in invitees:
|
||||
builder.row(
|
||||
types.InlineKeyboardButton(
|
||||
text=_admin_user_button_label(invitee),
|
||||
callback_data=f"user_action:refresh:{invitee.user_id}",
|
||||
)
|
||||
)
|
||||
|
||||
pagination_buttons = []
|
||||
if safe_page > 0:
|
||||
pagination_buttons.append(
|
||||
types.InlineKeyboardButton(
|
||||
text=_("prev_page_button"),
|
||||
callback_data=f"user_action:invitees:{user.user_id}:{safe_page - 1}",
|
||||
)
|
||||
)
|
||||
if safe_page < total_pages - 1:
|
||||
pagination_buttons.append(
|
||||
types.InlineKeyboardButton(
|
||||
text=_("next_page_button"),
|
||||
callback_data=f"user_action:invitees:{user.user_id}:{safe_page + 1}",
|
||||
)
|
||||
)
|
||||
if pagination_buttons:
|
||||
builder.row(*pagination_buttons)
|
||||
builder.row(
|
||||
types.InlineKeyboardButton(
|
||||
text=_("admin_user_back_to_card_button"),
|
||||
callback_data=f"user_action:refresh:{user.user_id}",
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
types.InlineKeyboardButton(
|
||||
text=_("back_to_admin_panel_button"), callback_data="admin_action:main"
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
invitees_text, reply_markup=builder.as_markup(), parse_mode="HTML"
|
||||
)
|
||||
except Exception:
|
||||
await callback.message.answer(
|
||||
invitees_text, reply_markup=builder.as_markup(), parse_mode="HTML"
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
except Exception as exc:
|
||||
logging.error(
|
||||
"Error viewing invitees for user %s: %s",
|
||||
user.user_id,
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
await callback.answer(_("admin_user_invitees_error"), show_alert=True)
|
||||
|
||||
|
||||
async def handle_refresh_user_card(
|
||||
callback: types.CallbackQuery,
|
||||
user: User,
|
||||
@@ -1264,8 +1626,13 @@ async def process_delete_user_confirmation_handler(
|
||||
return
|
||||
|
||||
try:
|
||||
if user_model.panel_user_uuid:
|
||||
panel_deleted = await panel_service.delete_user_from_panel(user_model.panel_user_uuid)
|
||||
panel_user_uuids = await user_dal.get_panel_user_uuids_for_user(
|
||||
session,
|
||||
target_user_id,
|
||||
user=user_model,
|
||||
)
|
||||
for panel_uuid in panel_user_uuids:
|
||||
panel_deleted = await panel_service.delete_user_from_panel(panel_uuid)
|
||||
if not panel_deleted:
|
||||
await message.answer(
|
||||
_(
|
||||
@@ -1810,6 +2177,114 @@ async def process_premium_override_bonus_handler(
|
||||
await state.clear()
|
||||
|
||||
|
||||
@router.message(AdminStates.waiting_for_hwid_device_limit, F.text)
|
||||
async def process_hwid_device_limit_handler(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
subscription_service: SubscriptionService,
|
||||
session: AsyncSession,
|
||||
):
|
||||
"""Read explicit HWID device limit and apply it."""
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n:
|
||||
await message.reply("Language service error.")
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
data = await state.get_data()
|
||||
target_user_id = data.get("target_user_id")
|
||||
if not target_user_id:
|
||||
await message.answer(_("admin_hwid_limit_state_missing"))
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
raw = (message.text or "").strip()
|
||||
try:
|
||||
hwid_device_limit = int(raw)
|
||||
if hwid_device_limit < 0 or hwid_device_limit > 1_000_000:
|
||||
raise ValueError("out_of_range")
|
||||
except (TypeError, ValueError):
|
||||
await message.answer(_("admin_hwid_limit_invalid"))
|
||||
return
|
||||
|
||||
target_user = await user_dal.get_user_by_id(session, target_user_id)
|
||||
if not target_user:
|
||||
await message.answer(_("admin_user_not_found_action"))
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
try:
|
||||
active_sub = await subscription_dal.get_active_subscription_by_user_id(
|
||||
session, target_user_id
|
||||
)
|
||||
if not active_sub:
|
||||
await message.answer(_("admin_hwid_limit_no_subscription"))
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
active_sub.hwid_device_limit = hwid_device_limit
|
||||
effective_limit = await subscription_service.sync_hwid_device_limit_to_panel(
|
||||
session, target_user_id
|
||||
)
|
||||
await message_log_dal.create_message_log_no_commit(
|
||||
session,
|
||||
{
|
||||
"user_id": message.from_user.id if message.from_user else target_user_id,
|
||||
"event_type": "admin:hwid_device_limit",
|
||||
"content": (
|
||||
f"hwid_device_limit={hwid_device_limit!r} "
|
||||
f"effective_hwid_device_limit={effective_limit!r}"
|
||||
),
|
||||
"is_admin_event": True,
|
||||
"target_user_id": target_user_id,
|
||||
"timestamp": datetime.now(timezone.utc),
|
||||
},
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
current_text = _admin_hwid_limit_state_text(_, hwid_device_limit)
|
||||
await message.answer(
|
||||
_("admin_hwid_limit_set", current=current_text, user_id=target_user_id)
|
||||
)
|
||||
|
||||
referral_service = ReferralService(settings, subscription_service, message.bot, i18n)
|
||||
bot_username = await _resolve_bot_username(message.bot)
|
||||
user_card_text = await format_user_card(
|
||||
target_user,
|
||||
session,
|
||||
subscription_service,
|
||||
i18n,
|
||||
current_lang,
|
||||
referral_service,
|
||||
settings=settings,
|
||||
bot_username=bot_username,
|
||||
)
|
||||
keyboard = get_user_card_keyboard(
|
||||
target_user.user_id, i18n, current_lang, target_user.referred_by_id
|
||||
)
|
||||
await _send_with_profile_link_fallback(
|
||||
message.answer,
|
||||
text=user_card_text,
|
||||
markup=keyboard.as_markup(),
|
||||
user_id=target_user.user_id,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
except Exception as exc:
|
||||
logging.error(
|
||||
"Error setting HWID device limit for user %s: %s",
|
||||
target_user_id,
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
await session.rollback()
|
||||
await message.answer(_("admin_hwid_limit_save_error"))
|
||||
finally:
|
||||
await state.clear()
|
||||
|
||||
|
||||
@router.message(AdminStates.waiting_for_traffic_grant_gb, F.text)
|
||||
async def process_traffic_grant_gb_handler(
|
||||
message: types.Message,
|
||||
@@ -1959,7 +2434,7 @@ async def user_card_from_list_handler(
|
||||
text=_("admin_user_back_to_list_button"), callback_data=f"admin_action:users_list:{page}"
|
||||
)
|
||||
quick_links_width = 2 if user.referred_by_id else 1
|
||||
keyboard.adjust(2, 2, 2, 2, quick_links_width, 1, 2, 1)
|
||||
keyboard.adjust(2, 2, 2, 1, 2, quick_links_width, 1, 2, 1)
|
||||
|
||||
# Format user card
|
||||
try:
|
||||
|
||||
@@ -166,8 +166,10 @@ async def create_user_stats_result(
|
||||
"inline_user_stats_message",
|
||||
total=user_stats["total_users"],
|
||||
active_today=user_stats["active_today"],
|
||||
active=user_stats["active_subscriptions"],
|
||||
paid=user_stats["paid_subscriptions"],
|
||||
trial=user_stats["trial_users"],
|
||||
free=user_stats["free_subscription_users"],
|
||||
inactive=user_stats["inactive_users"],
|
||||
banned=user_stats["banned_users"],
|
||||
referral=user_stats["referral_users"],
|
||||
@@ -179,7 +181,7 @@ async def create_user_stats_result(
|
||||
description=_(
|
||||
"inline_user_stats_description",
|
||||
total=user_stats["total_users"],
|
||||
active=user_stats["paid_subscriptions"],
|
||||
active=user_stats["active_subscriptions"],
|
||||
),
|
||||
input_message_content=InputTextMessageContent(
|
||||
message_text=stats_text, parse_mode="HTML"
|
||||
|
||||
@@ -1,800 +0,0 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import Bot
|
||||
from aiohttp import web
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from yookassa.domain.notification import WebhookNotification
|
||||
|
||||
from bot.infra.webhook_queue import enqueue_webhook_event
|
||||
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.lknpd_service import LknpdService
|
||||
from bot.services.notification_service import NotificationService
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
from bot.services.referral_service import ReferralService
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.services.yookassa_service import YooKassaService
|
||||
from bot.utils.config_link import prepare_config_links
|
||||
from bot.utils.request_security import ip_in_allowlist, request_client_ip
|
||||
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
|
||||
from config.settings import Settings
|
||||
from db.dal import payment_dal, user_billing_dal, user_dal
|
||||
|
||||
payment_processing_lock = asyncio.Lock()
|
||||
|
||||
YOOKASSA_EVENT_PAYMENT_SUCCEEDED = "payment.succeeded"
|
||||
YOOKASSA_EVENT_PAYMENT_CANCELED = "payment.canceled"
|
||||
YOOKASSA_EVENT_PAYMENT_WAITING_FOR_CAPTURE = "payment.waiting_for_capture"
|
||||
YOOKASSA_WEBHOOK_ALLOWED_IPS = [
|
||||
"185.71.76.0/27",
|
||||
"185.71.77.0/27",
|
||||
"77.75.153.0/25",
|
||||
"77.75.156.11",
|
||||
"77.75.156.35",
|
||||
"77.75.154.128/25",
|
||||
"2a02:5180::/32",
|
||||
]
|
||||
|
||||
|
||||
async def process_successful_payment(
|
||||
session: AsyncSession,
|
||||
bot: Bot,
|
||||
payment_info_from_webhook: dict,
|
||||
i18n: JsonI18n,
|
||||
settings: Settings,
|
||||
panel_service: PanelApiService,
|
||||
subscription_service: SubscriptionService,
|
||||
referral_service: ReferralService,
|
||||
lknpd_service: Optional[LknpdService] = None,
|
||||
):
|
||||
metadata = payment_info_from_webhook.get("metadata", {})
|
||||
user_id_str = metadata.get("user_id")
|
||||
subscription_months_str = metadata.get("subscription_months")
|
||||
traffic_gb_str = metadata.get("traffic_gb")
|
||||
sale_mode = metadata.get("sale_mode") or (
|
||||
"traffic" if settings.traffic_sale_mode else "subscription"
|
||||
)
|
||||
sale_mode_base = sale_mode.split("@", 1)[0].split("|", 1)[0]
|
||||
promo_code_id_str = metadata.get("promo_code_id")
|
||||
payment_db_id_str = metadata.get("payment_db_id")
|
||||
auto_renew_subscription_id_str = metadata.get("auto_renew_for_subscription_id")
|
||||
|
||||
# For auto-renew payments, payment_db_id may be absent. In that case,
|
||||
# we will create/ensure a payment record idempotently using provider payment id.
|
||||
if (
|
||||
not user_id_str
|
||||
or (not subscription_months_str and not traffic_gb_str)
|
||||
or (not payment_db_id_str and not auto_renew_subscription_id_str)
|
||||
):
|
||||
logging.error(
|
||||
f"Missing crucial metadata for payment: {payment_info_from_webhook.get('id')}, metadata: {metadata}" # noqa: E501
|
||||
)
|
||||
return
|
||||
|
||||
db_user = None
|
||||
try:
|
||||
user_id = int(user_id_str)
|
||||
subscription_months = float(subscription_months_str or 0)
|
||||
traffic_amount_gb = float(traffic_gb_str) if traffic_gb_str else subscription_months
|
||||
payment_db_id = (
|
||||
int(payment_db_id_str) if payment_db_id_str and payment_db_id_str.isdigit() else None
|
||||
)
|
||||
is_auto_renew = bool(
|
||||
auto_renew_subscription_id_str
|
||||
and not payment_db_id
|
||||
and sale_mode_base == "subscription"
|
||||
)
|
||||
promo_code_id = (
|
||||
int(promo_code_id_str) if promo_code_id_str and promo_code_id_str.isdigit() else None
|
||||
)
|
||||
|
||||
amount_data = payment_info_from_webhook.get("amount", {})
|
||||
months_for_record = int(subscription_months) if sale_mode_base == "subscription" else 0
|
||||
payment_value = float(amount_data.get("value", 0.0))
|
||||
yk_payment_id_from_hook = payment_info_from_webhook.get("id")
|
||||
|
||||
payment_record = None
|
||||
# If this is an auto-renewal (no payment_db_id in metadata), ensure a payment record exists
|
||||
if payment_db_id is None and auto_renew_subscription_id_str:
|
||||
try:
|
||||
if not yk_payment_id_from_hook:
|
||||
logging.error(
|
||||
"Auto-renew webhook missing YooKassa payment id; cannot ensure payment record." # noqa: E501
|
||||
)
|
||||
return
|
||||
from db.dal import payment_dal as _payment_dal
|
||||
|
||||
payment_record = await _payment_dal.get_payment_by_provider_payment_id(
|
||||
session, yk_payment_id_from_hook
|
||||
)
|
||||
if not payment_record:
|
||||
payment_record = await _payment_dal.ensure_payment_with_provider_id(
|
||||
session,
|
||||
user_id=user_id,
|
||||
amount=payment_value,
|
||||
currency=amount_data.get("currency", settings.DEFAULT_CURRENCY_SYMBOL),
|
||||
months=months_for_record or 1,
|
||||
description=payment_info_from_webhook.get("description")
|
||||
or f"Auto-renewal for {months_for_record or subscription_months} months",
|
||||
provider="yookassa",
|
||||
provider_payment_id=yk_payment_id_from_hook,
|
||||
)
|
||||
payment_db_id = payment_record.payment_id
|
||||
except Exception as e_ensure:
|
||||
logging.error(
|
||||
f"Failed to ensure payment record for auto-renew webhook (YK {payment_info_from_webhook.get('id')}): {e_ensure}", # noqa: E501
|
||||
exc_info=True,
|
||||
)
|
||||
return
|
||||
elif payment_db_id is not None:
|
||||
payment_record = await payment_dal.get_payment_by_db_id(session, payment_db_id)
|
||||
if not payment_record:
|
||||
logging.error(
|
||||
f"Payment record {payment_db_id} not found for YK ID {yk_payment_id_from_hook}."
|
||||
)
|
||||
return
|
||||
|
||||
if payment_record and payment_record.status == "succeeded":
|
||||
logging.info(
|
||||
f"Skipping duplicate YooKassa webhook for payment {payment_db_id} (YK: {yk_payment_id_from_hook})." # noqa: E501
|
||||
)
|
||||
return
|
||||
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
if not db_user:
|
||||
logging.error(
|
||||
f"User {user_id} not found in DB during successful payment processing for YK ID {payment_info_from_webhook.get('id')}. Payment record {payment_db_id}." # noqa: E501
|
||||
)
|
||||
|
||||
await payment_dal.update_payment_status_by_db_id(
|
||||
session, payment_db_id, "failed_user_not_found", payment_info_from_webhook.get("id")
|
||||
)
|
||||
|
||||
return
|
||||
|
||||
except (TypeError, ValueError) as e:
|
||||
logging.error(f"Invalid metadata format for payment processing: {metadata} - {e}")
|
||||
|
||||
if payment_db_id_str and payment_db_id_str.isdigit():
|
||||
try:
|
||||
await payment_dal.update_payment_status_by_db_id(
|
||||
session,
|
||||
int(payment_db_id_str),
|
||||
"failed_metadata_error",
|
||||
payment_info_from_webhook.get("id"),
|
||||
)
|
||||
except Exception as e_upd:
|
||||
logging.error(f"Failed to update payment status after metadata error: {e_upd}")
|
||||
return
|
||||
|
||||
try:
|
||||
yk_payment_id_from_hook = payment_info_from_webhook.get("id")
|
||||
payment_before_update = None
|
||||
if payment_db_id is not None:
|
||||
payment_before_update = await payment_dal.get_payment_by_db_id(
|
||||
session,
|
||||
payment_db_id,
|
||||
)
|
||||
should_send_lknpd_receipt = bool(
|
||||
lknpd_service
|
||||
and lknpd_service.configured
|
||||
and payment_info_from_webhook.get("paid") is True
|
||||
and payment_info_from_webhook.get("status") == "succeeded"
|
||||
and payment_before_update
|
||||
and payment_before_update.status != "succeeded"
|
||||
)
|
||||
# Try to capture and save payment method for future charges if available
|
||||
try:
|
||||
payment_method = payment_info_from_webhook.get("payment_method")
|
||||
if (
|
||||
settings.yookassa_autopayments_active
|
||||
and isinstance(payment_method, dict)
|
||||
and payment_method.get("saved", False)
|
||||
):
|
||||
pm_id = payment_method.get("id")
|
||||
pm_type = payment_method.get("type")
|
||||
title = payment_method.get("title")
|
||||
card = payment_method.get("card") or {}
|
||||
account_number = payment_method.get("account_number") or payment_method.get(
|
||||
"account"
|
||||
)
|
||||
display_network = None
|
||||
display_last4 = None
|
||||
# Build generic display for various instrument types
|
||||
if (pm_type or "").lower() in {"bank_card", "bank-card", "card"}:
|
||||
display_network = card.get("card_type") or title or "Card"
|
||||
display_last4 = card.get("last4")
|
||||
elif (pm_type or "").lower() in {"yoo_money", "yoomoney", "yoo-money", "wallet"}:
|
||||
# Normalize wallet display name to avoid leaking full account from title
|
||||
display_network = "YooMoney"
|
||||
if isinstance(account_number, str) and len(account_number) >= 4:
|
||||
display_last4 = account_number[-4:]
|
||||
else:
|
||||
display_last4 = None
|
||||
else:
|
||||
# Wallets, SBP, etc. — use provided title/type; no last4
|
||||
display_network = title or (pm_type.upper() if pm_type else "Payment method")
|
||||
display_last4 = None
|
||||
|
||||
await user_billing_dal.upsert_yk_payment_method(
|
||||
session,
|
||||
user_id=user_id,
|
||||
payment_method_id=pm_id,
|
||||
card_last4=display_last4,
|
||||
card_network=display_network,
|
||||
)
|
||||
try:
|
||||
await user_billing_dal.upsert_user_payment_method(
|
||||
session,
|
||||
user_id=user_id,
|
||||
provider_payment_method_id=pm_id,
|
||||
provider="yookassa",
|
||||
card_last4=display_last4,
|
||||
card_network=display_network,
|
||||
set_default=True,
|
||||
)
|
||||
except Exception:
|
||||
logging.exception("Failed to persist multi-card YooKassa method from webhook")
|
||||
except Exception:
|
||||
logging.exception("Failed to persist YooKassa payment method from webhook")
|
||||
months_for_activation = (
|
||||
int(subscription_months) if sale_mode_base == "subscription" else int(traffic_amount_gb)
|
||||
)
|
||||
activation_details = await subscription_service.activate_subscription(
|
||||
session,
|
||||
user_id,
|
||||
months_for_activation,
|
||||
payment_value,
|
||||
payment_db_id,
|
||||
promo_code_id_from_payment=promo_code_id,
|
||||
provider="yookassa",
|
||||
sale_mode=sale_mode,
|
||||
traffic_gb=traffic_amount_gb
|
||||
if sale_mode_base in {"traffic", "traffic_package", "topup", "premium_topup"}
|
||||
else None,
|
||||
)
|
||||
|
||||
if not activation_details or not activation_details.get("end_date"):
|
||||
logging.error(
|
||||
f"Failed to activate subscription for user {user_id} after payment {yk_payment_id_from_hook}" # noqa: E501
|
||||
)
|
||||
raise Exception(f"Subscription Error: Failed to activate for user {user_id}")
|
||||
|
||||
updated_payment_record = await payment_dal.update_payment_status_by_db_id(
|
||||
session,
|
||||
payment_db_id=payment_db_id,
|
||||
new_status=payment_info_from_webhook.get("status", "succeeded"),
|
||||
yk_payment_id=yk_payment_id_from_hook,
|
||||
)
|
||||
if not updated_payment_record:
|
||||
logging.error(
|
||||
f"Failed to update payment record {payment_db_id} for yk_id {yk_payment_id_from_hook}" # noqa: E501
|
||||
)
|
||||
raise Exception(f"DB Error: Could not update payment record {payment_db_id}")
|
||||
|
||||
base_subscription_end_date = activation_details["end_date"]
|
||||
final_end_date_for_user = base_subscription_end_date
|
||||
applied_promo_bonus_days = activation_details.get("applied_promo_bonus_days", 0)
|
||||
|
||||
referral_bonus_info = None
|
||||
if sale_mode_base == "subscription":
|
||||
referral_bonus_info = await referral_service.apply_referral_bonuses_for_payment(
|
||||
session,
|
||||
user_id,
|
||||
months_for_activation or int(subscription_months) or 1,
|
||||
current_payment_db_id=payment_db_id,
|
||||
skip_if_active_before_payment=False,
|
||||
)
|
||||
applied_referee_bonus_days_from_referral: Optional[int] = None
|
||||
if referral_bonus_info and referral_bonus_info.get("referee_new_end_date"):
|
||||
final_end_date_for_user = referral_bonus_info["referee_new_end_date"]
|
||||
applied_referee_bonus_days_from_referral = referral_bonus_info.get(
|
||||
"referee_bonus_applied_days"
|
||||
)
|
||||
|
||||
# Use user's DB language for all user-facing messages
|
||||
user_lang = (
|
||||
db_user.language_code
|
||||
if db_user and db_user.language_code
|
||||
else settings.DEFAULT_LANGUAGE
|
||||
)
|
||||
_ = lambda key, **kwargs: i18n.gettext(user_lang, key, **kwargs)
|
||||
|
||||
traffic_label = (
|
||||
str(int(traffic_amount_gb))
|
||||
if float(traffic_amount_gb).is_integer()
|
||||
else f"{traffic_amount_gb:g}"
|
||||
)
|
||||
if should_send_lknpd_receipt:
|
||||
receipt_item_name = payment_info_from_webhook.get("description")
|
||||
if not receipt_item_name:
|
||||
if sale_mode_base in {"traffic", "traffic_package", "topup", "premium_topup"}:
|
||||
receipt_item_name = settings.LKNPD_RECEIPT_NAME_TRAFFIC.format(gb=traffic_label)
|
||||
else:
|
||||
receipt_item_name = settings.LKNPD_RECEIPT_NAME_SUBSCRIPTION.format(
|
||||
months=int(subscription_months)
|
||||
)
|
||||
try:
|
||||
await lknpd_service.create_income_receipt(
|
||||
item_name=receipt_item_name,
|
||||
amount=payment_value,
|
||||
quantity=1.0,
|
||||
operation_time=datetime.now(timezone.utc),
|
||||
)
|
||||
except Exception:
|
||||
logging.exception(
|
||||
"Failed to send LKNPD receipt for payment %s",
|
||||
yk_payment_id_from_hook,
|
||||
)
|
||||
config_link_display, connect_button_url = await prepare_config_links(
|
||||
settings, activation_details.get("subscription_url") if activation_details else None
|
||||
)
|
||||
config_link_text = config_link_display or _("config_link_not_available")
|
||||
# For auto-renew charges, avoid re-sending config link; send concise message
|
||||
if sale_mode_base == "subscription" and is_auto_renew and final_end_date_for_user:
|
||||
details_message = _(
|
||||
"yookassa_auto_renewal",
|
||||
months=int(subscription_months),
|
||||
end_date=final_end_date_for_user.strftime("%Y-%m-%d"),
|
||||
)
|
||||
details_markup = None
|
||||
elif sale_mode_base in {"traffic", "traffic_package", "topup", "premium_topup"}:
|
||||
details_message = _(
|
||||
"payment_successful_traffic_full",
|
||||
traffic_gb=traffic_label,
|
||||
end_date=final_end_date_for_user.strftime("%Y-%m-%d")
|
||||
if final_end_date_for_user
|
||||
else "—",
|
||||
config_link=config_link_text,
|
||||
)
|
||||
details_markup = get_connect_and_main_keyboard(
|
||||
user_lang,
|
||||
i18n,
|
||||
settings,
|
||||
config_link_display,
|
||||
connect_button_url=connect_button_url,
|
||||
preserve_message=True,
|
||||
)
|
||||
else:
|
||||
if applied_referee_bonus_days_from_referral and final_end_date_for_user:
|
||||
inviter_name_display = _("friend_placeholder")
|
||||
if db_user and db_user.referred_by_id:
|
||||
inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id)
|
||||
if inviter:
|
||||
safe_name = (
|
||||
sanitize_display_name(inviter.first_name)
|
||||
if inviter.first_name
|
||||
else None
|
||||
)
|
||||
if safe_name:
|
||||
inviter_name_display = safe_name
|
||||
elif inviter.username:
|
||||
inviter_name_display = username_for_display(
|
||||
inviter.username, with_at=False
|
||||
)
|
||||
|
||||
details_message = _(
|
||||
"payment_successful_with_referral_bonus_full",
|
||||
months=int(subscription_months),
|
||||
base_end_date=base_subscription_end_date.strftime("%Y-%m-%d"),
|
||||
bonus_days=applied_referee_bonus_days_from_referral,
|
||||
final_end_date=final_end_date_for_user.strftime("%Y-%m-%d"),
|
||||
inviter_name=inviter_name_display,
|
||||
config_link=config_link_text,
|
||||
)
|
||||
elif applied_promo_bonus_days > 0 and final_end_date_for_user:
|
||||
details_message = _(
|
||||
"payment_successful_with_promo_full",
|
||||
months=int(subscription_months),
|
||||
bonus_days=applied_promo_bonus_days,
|
||||
end_date=final_end_date_for_user.strftime("%Y-%m-%d"),
|
||||
config_link=config_link_text,
|
||||
)
|
||||
elif final_end_date_for_user:
|
||||
details_message = _(
|
||||
"payment_successful_full",
|
||||
months=int(subscription_months),
|
||||
end_date=final_end_date_for_user.strftime("%Y-%m-%d"),
|
||||
config_link=config_link_text,
|
||||
)
|
||||
else:
|
||||
logging.error(
|
||||
f"Critical error: final_end_date_for_user is None for user {user_id} after successful payment logic." # noqa: E501
|
||||
)
|
||||
details_message = _("payment_successful_error_details")
|
||||
|
||||
details_markup = get_connect_and_main_keyboard(
|
||||
user_lang,
|
||||
i18n,
|
||||
settings,
|
||||
config_link_display,
|
||||
connect_button_url=connect_button_url,
|
||||
preserve_message=True,
|
||||
)
|
||||
try:
|
||||
await bot.send_message(
|
||||
user_id,
|
||||
details_message,
|
||||
reply_markup=details_markup,
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
except Exception as e_notify:
|
||||
logging.error(f"Failed to send payment details message to user {user_id}: {e_notify}")
|
||||
|
||||
# Send notification about payment
|
||||
try:
|
||||
notification_service = NotificationService(bot, settings, i18n)
|
||||
user = await user_dal.get_user_by_id(session, user_id)
|
||||
tariff_for_log = None
|
||||
if payment_before_update and getattr(payment_before_update, "tariff_key", None):
|
||||
tariff_for_log = payment_before_update.tariff_key
|
||||
elif updated_payment_record and getattr(updated_payment_record, "tariff_key", None):
|
||||
tariff_for_log = updated_payment_record.tariff_key
|
||||
elif payment_record and getattr(payment_record, "tariff_key", None):
|
||||
tariff_for_log = payment_record.tariff_key
|
||||
await notification_service.notify_payment_received(
|
||||
user_id=user_id,
|
||||
amount=payment_value,
|
||||
currency=settings.DEFAULT_CURRENCY_SYMBOL,
|
||||
months=int(subscription_months) if sale_mode_base == "subscription" else 0,
|
||||
payment_provider="yookassa", # This is specifically for YooKassa webhook
|
||||
username=user.username if user else None,
|
||||
traffic_gb=traffic_amount_gb
|
||||
if sale_mode_base in {"traffic", "traffic_package", "topup", "premium_topup"}
|
||||
else None,
|
||||
traffic_is_premium=sale_mode_base == "premium_topup",
|
||||
tariff_key=tariff_for_log,
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to send payment notification: {e}")
|
||||
|
||||
except Exception as e_process:
|
||||
logging.error(
|
||||
f"Error during process_successful_payment main try block for user {user_id}: {e_process}", # noqa: E501
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
raise
|
||||
|
||||
|
||||
async def process_cancelled_payment(
|
||||
session: AsyncSession,
|
||||
bot: Bot,
|
||||
payment_info_from_webhook: dict,
|
||||
i18n: JsonI18n,
|
||||
settings: Settings,
|
||||
):
|
||||
|
||||
metadata = payment_info_from_webhook.get("metadata", {})
|
||||
user_id_str = metadata.get("user_id")
|
||||
payment_db_id_str = metadata.get("payment_db_id")
|
||||
|
||||
if not user_id_str or not payment_db_id_str:
|
||||
logging.warning(
|
||||
f"Missing metadata in cancelled payment webhook: {payment_info_from_webhook.get('id')}"
|
||||
)
|
||||
return
|
||||
try:
|
||||
user_id = int(user_id_str)
|
||||
payment_db_id = int(payment_db_id_str)
|
||||
except ValueError:
|
||||
logging.error(f"Invalid metadata in cancelled payment webhook: {metadata}")
|
||||
return
|
||||
|
||||
try:
|
||||
updated_payment = await payment_dal.update_payment_status_by_db_id(
|
||||
session,
|
||||
payment_db_id=payment_db_id,
|
||||
new_status=payment_info_from_webhook.get("status", "canceled"),
|
||||
yk_payment_id=payment_info_from_webhook.get("id"),
|
||||
)
|
||||
|
||||
if updated_payment:
|
||||
logging.info(
|
||||
f"Payment {payment_db_id} (YK: {payment_info_from_webhook.get('id')}) status updated to cancelled for user {user_id}." # noqa: E501
|
||||
)
|
||||
else:
|
||||
logging.warning(
|
||||
f"Could not find payment record {payment_db_id} to update status to cancelled for user {user_id}." # noqa: E501
|
||||
)
|
||||
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
user_lang = settings.DEFAULT_LANGUAGE
|
||||
if db_user and db_user.language_code:
|
||||
user_lang = db_user.language_code
|
||||
|
||||
_ = lambda key, **kwargs: i18n.gettext(user_lang, key, **kwargs)
|
||||
await bot.send_message(user_id, _("payment_failed"))
|
||||
|
||||
except Exception as e_process_cancel:
|
||||
logging.error(
|
||||
f"Error processing cancelled payment for user {user_id}, payment_db_id {payment_db_id}: {e_process_cancel}", # noqa: E501
|
||||
exc_info=True,
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
async def yookassa_webhook_route(request: web.Request):
|
||||
|
||||
try:
|
||||
bot: Bot = request.app["bot"]
|
||||
i18n_instance: JsonI18n = request.app["i18n"]
|
||||
settings: Settings = request.app["settings"]
|
||||
panel_service: PanelApiService = request.app["panel_service"]
|
||||
subscription_service: SubscriptionService = request.app["subscription_service"]
|
||||
referral_service: ReferralService = request.app["referral_service"]
|
||||
lknpd_service: Optional[LknpdService] = request.app.get("lknpd_service")
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
except KeyError:
|
||||
logging.exception("KeyError accessing app context in yookassa_webhook_route.")
|
||||
return web.Response(status=500, text="Internal Server Error: Missing app context component")
|
||||
|
||||
client_ip = request_client_ip(request, trusted_proxies=settings.trusted_proxies)
|
||||
if not ip_in_allowlist(client_ip, YOOKASSA_WEBHOOK_ALLOWED_IPS):
|
||||
logging.warning("YooKassa webhook denied from unauthorized IP source.")
|
||||
return web.Response(status=403)
|
||||
|
||||
try:
|
||||
event_json = await request.json()
|
||||
|
||||
notification_object = WebhookNotification(event_json)
|
||||
payment_data_from_notification = notification_object.object
|
||||
|
||||
logging.info(
|
||||
f"YooKassa Webhook Parsed: Event='{notification_object.event}', "
|
||||
f"PaymentId='{payment_data_from_notification.id}', Status='{payment_data_from_notification.status}'" # noqa: E501
|
||||
)
|
||||
|
||||
if (
|
||||
not payment_data_from_notification
|
||||
or not hasattr(payment_data_from_notification, "metadata")
|
||||
or payment_data_from_notification.metadata is None
|
||||
):
|
||||
logging.error(
|
||||
f"YooKassa webhook payment {payment_data_from_notification.id} lacks metadata. Cannot process." # noqa: E501
|
||||
)
|
||||
return web.Response(status=200, text="ok_error_no_metadata")
|
||||
|
||||
# Safely extract payment_method details (SDK objects may not have to_dict)
|
||||
pm_obj = getattr(payment_data_from_notification, "payment_method", None)
|
||||
pm_dict = None
|
||||
if pm_obj is not None:
|
||||
try:
|
||||
card_obj = getattr(pm_obj, "card", None)
|
||||
pm_dict = {
|
||||
"id": getattr(pm_obj, "id", None),
|
||||
"type": getattr(pm_obj, "type", None),
|
||||
"saved": bool(getattr(pm_obj, "saved", False)),
|
||||
"title": getattr(pm_obj, "title", None),
|
||||
"account_number": (
|
||||
getattr(pm_obj, "account_number", None)
|
||||
if hasattr(pm_obj, "account_number")
|
||||
else (
|
||||
getattr(pm_obj, "account", None) if hasattr(pm_obj, "account") else None
|
||||
)
|
||||
),
|
||||
"card": (
|
||||
{
|
||||
"first6": getattr(card_obj, "first6", None),
|
||||
"last4": getattr(card_obj, "last4", None),
|
||||
"expiry_month": getattr(card_obj, "expiry_month", None),
|
||||
"expiry_year": getattr(card_obj, "expiry_year", None),
|
||||
"card_type": getattr(card_obj, "card_type", None),
|
||||
}
|
||||
if card_obj is not None
|
||||
else None
|
||||
),
|
||||
}
|
||||
except Exception:
|
||||
logging.exception("Failed to serialize YooKassa payment_method from webhook")
|
||||
pm_dict = None
|
||||
|
||||
payment_dict_for_processing = {
|
||||
"id": str(payment_data_from_notification.id),
|
||||
"status": str(payment_data_from_notification.status),
|
||||
"paid": bool(payment_data_from_notification.paid),
|
||||
"amount": {
|
||||
"value": str(payment_data_from_notification.amount.value),
|
||||
"currency": str(payment_data_from_notification.amount.currency),
|
||||
}
|
||||
if payment_data_from_notification.amount
|
||||
else {},
|
||||
"metadata": dict(payment_data_from_notification.metadata),
|
||||
"description": str(payment_data_from_notification.description)
|
||||
if payment_data_from_notification.description
|
||||
else None,
|
||||
"payment_method": pm_dict,
|
||||
}
|
||||
|
||||
if notification_object.event in {
|
||||
YOOKASSA_EVENT_PAYMENT_SUCCEEDED,
|
||||
YOOKASSA_EVENT_PAYMENT_CANCELED,
|
||||
}:
|
||||
queued = await enqueue_webhook_event(
|
||||
settings,
|
||||
"yookassa",
|
||||
{
|
||||
"event": notification_object.event,
|
||||
"payment": payment_dict_for_processing,
|
||||
},
|
||||
event_id=f"{notification_object.event}:{payment_dict_for_processing.get('id')}",
|
||||
)
|
||||
if queued:
|
||||
return web.Response(status=200, text="queued")
|
||||
|
||||
async with payment_processing_lock:
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
if notification_object.event == YOOKASSA_EVENT_PAYMENT_SUCCEEDED:
|
||||
if (
|
||||
payment_dict_for_processing.get("paid")
|
||||
and payment_dict_for_processing.get("status") == "succeeded"
|
||||
):
|
||||
await process_successful_payment(
|
||||
session,
|
||||
bot,
|
||||
payment_dict_for_processing,
|
||||
i18n_instance,
|
||||
settings,
|
||||
panel_service,
|
||||
subscription_service,
|
||||
referral_service,
|
||||
lknpd_service,
|
||||
)
|
||||
await session.commit()
|
||||
else:
|
||||
logging.warning(
|
||||
f"Payment Succeeded event for {payment_dict_for_processing.get('id')} " # noqa: E501
|
||||
f"but data not as expected: status='{payment_dict_for_processing.get('status')}', " # noqa: E501
|
||||
f"paid='{payment_dict_for_processing.get('paid')}'"
|
||||
)
|
||||
elif notification_object.event == YOOKASSA_EVENT_PAYMENT_CANCELED:
|
||||
await process_cancelled_payment(
|
||||
session, bot, payment_dict_for_processing, i18n_instance, settings
|
||||
)
|
||||
await session.commit()
|
||||
elif notification_object.event == YOOKASSA_EVENT_PAYMENT_WAITING_FOR_CAPTURE:
|
||||
# Bind-only flow: save method and cancel auth if metadata has bind_only
|
||||
metadata = payment_dict_for_processing.get("metadata", {}) or {}
|
||||
if (
|
||||
settings.yookassa_autopayments_active
|
||||
and metadata.get("bind_only") == "1"
|
||||
):
|
||||
try:
|
||||
user_id_str = metadata.get("user_id")
|
||||
if user_id_str and user_id_str.isdigit():
|
||||
user_id = int(user_id_str)
|
||||
payment_method = payment_dict_for_processing.get(
|
||||
"payment_method"
|
||||
)
|
||||
if isinstance(payment_method, dict) and payment_method.get(
|
||||
"id"
|
||||
):
|
||||
pm_type = payment_method.get("type")
|
||||
title = payment_method.get("title")
|
||||
card = payment_method.get("card") or {}
|
||||
account_number = payment_method.get(
|
||||
"account_number"
|
||||
) or payment_method.get("account")
|
||||
display_network = None
|
||||
display_last4 = None
|
||||
if (pm_type or "").lower() in {
|
||||
"bank_card",
|
||||
"bank-card",
|
||||
"card",
|
||||
}:
|
||||
display_network = (
|
||||
card.get("card_type") or title or "Card"
|
||||
)
|
||||
display_last4 = card.get("last4")
|
||||
elif (pm_type or "").lower() in {
|
||||
"yoo_money",
|
||||
"yoomoney",
|
||||
"yoo-money",
|
||||
"wallet",
|
||||
}:
|
||||
# Normalize wallet display name to avoid leaking full account from title # noqa: E501
|
||||
display_network = "YooMoney"
|
||||
if (
|
||||
isinstance(account_number, str)
|
||||
and len(account_number) >= 4
|
||||
):
|
||||
display_last4 = account_number[-4:]
|
||||
else:
|
||||
display_last4 = None
|
||||
else:
|
||||
display_network = title or (
|
||||
pm_type.upper() if pm_type else "Payment method"
|
||||
)
|
||||
display_last4 = None
|
||||
await user_billing_dal.upsert_yk_payment_method(
|
||||
session,
|
||||
user_id=user_id,
|
||||
payment_method_id=payment_method.get("id"),
|
||||
card_last4=display_last4,
|
||||
card_network=display_network,
|
||||
)
|
||||
await session.commit()
|
||||
# Save multi-card entry and mark default if first
|
||||
try:
|
||||
from db.dal import user_billing_dal as ub
|
||||
|
||||
await ub.upsert_user_payment_method(
|
||||
session,
|
||||
user_id=user_id,
|
||||
provider_payment_method_id=payment_method.get("id"),
|
||||
provider="yookassa",
|
||||
card_last4=display_last4,
|
||||
card_network=display_network,
|
||||
set_default=True,
|
||||
)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
# Notify user about successful binding with Back button
|
||||
try:
|
||||
# Use user's DB language for bind success notification
|
||||
i18n_lang = settings.DEFAULT_LANGUAGE
|
||||
from db.dal import user_dal
|
||||
|
||||
db_user = await user_dal.get_user_by_id(
|
||||
session, user_id
|
||||
)
|
||||
if db_user and db_user.language_code:
|
||||
i18n_lang = db_user.language_code
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(
|
||||
i18n_lang, key, **kwargs
|
||||
)
|
||||
from bot.keyboards.inline.user_keyboards import (
|
||||
get_back_to_payment_methods_keyboard,
|
||||
)
|
||||
|
||||
await bot.send_message(
|
||||
chat_id=user_id,
|
||||
text=_("payment_method_bound_success"),
|
||||
reply_markup=get_back_to_payment_methods_keyboard(
|
||||
i18n_lang, i18n_instance
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
# Attempt to cancel the authorization to avoid charge hold
|
||||
try:
|
||||
yk: YooKassaService = request.app.get(
|
||||
"yookassa_service"
|
||||
)
|
||||
if yk:
|
||||
await yk.cancel_payment(
|
||||
payment_dict_for_processing.get("id")
|
||||
)
|
||||
except Exception:
|
||||
logging.exception(
|
||||
"Failed to cancel bind-only payment auth"
|
||||
)
|
||||
except Exception:
|
||||
logging.exception(
|
||||
"Failed to handle bind-only waiting_for_capture webhook"
|
||||
)
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logging.exception(
|
||||
"Error processing YooKassa webhook event '%s' for YK Payment ID %s in DB transaction.", # noqa: E501
|
||||
notification_object.event,
|
||||
payment_dict_for_processing.get("id"),
|
||||
)
|
||||
return web.Response(status=500, text="internal_processing_error")
|
||||
|
||||
return web.Response(status=200, text="ok")
|
||||
|
||||
except json.JSONDecodeError:
|
||||
logging.error("YooKassa Webhook: Invalid JSON received.")
|
||||
return web.Response(status=400, text="bad_request_invalid_json")
|
||||
except Exception:
|
||||
logging.exception("YooKassa Webhook general processing error.")
|
||||
return web.Response(status=500, text="internal_error")
|
||||
@@ -16,7 +16,12 @@ from bot.services.promo_code_service import PromoCodeService
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.states.user_states import UserPromoStates
|
||||
from bot.utils.callback_answer import safe_answer_callback
|
||||
from bot.utils.install_links import (
|
||||
append_install_share_link_text,
|
||||
ensure_user_install_guide_links,
|
||||
)
|
||||
from config.settings import Settings
|
||||
from db.dal import user_dal
|
||||
|
||||
from .start import send_main_menu
|
||||
|
||||
@@ -133,10 +138,12 @@ async def process_promo_code_input(
|
||||
from bot.services.notification_service import NotificationService
|
||||
|
||||
notification_service = NotificationService(bot, settings, i18n)
|
||||
db_user = await user_dal.get_user_by_id(session, user.id)
|
||||
await notification_service.notify_suspicious_promo_attempt(
|
||||
user_id=user.id,
|
||||
username=user.username,
|
||||
first_name=user.first_name,
|
||||
email=getattr(db_user, "email", None) if db_user else None,
|
||||
suspicious_input=code_input,
|
||||
)
|
||||
except Exception as e:
|
||||
@@ -160,12 +167,30 @@ async def process_promo_code_input(
|
||||
end_date=(new_end_date.strftime("%d.%m.%Y %H:%M:%S") if new_end_date else "N/A"),
|
||||
config_link=config_link_text,
|
||||
)
|
||||
install_links = await ensure_user_install_guide_links(session, settings, user.id)
|
||||
install_share_url = install_links.public_share_url
|
||||
if install_share_url:
|
||||
try:
|
||||
await session.commit()
|
||||
response_to_user_text = append_install_share_link_text(
|
||||
response_to_user_text,
|
||||
_,
|
||||
install_share_url,
|
||||
)
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logging.exception(
|
||||
"Failed to persist install guide share token for promo user %s.",
|
||||
user.id,
|
||||
)
|
||||
install_share_url = None
|
||||
reply_markup = get_connect_and_main_keyboard(
|
||||
current_lang,
|
||||
i18n,
|
||||
settings,
|
||||
config_link_display,
|
||||
connect_button_url=connect_button_url,
|
||||
install_share_url=install_share_url,
|
||||
)
|
||||
else:
|
||||
await session.commit()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import logging
|
||||
from typing import Optional, Union
|
||||
from typing import Any, Callable, Optional, Union
|
||||
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
||||
|
||||
from aiogram import Bot, F, Router, types
|
||||
@@ -76,31 +76,10 @@ async def referral_command_handler(
|
||||
await event.answer()
|
||||
return
|
||||
|
||||
bonus_info_parts = []
|
||||
if getattr(settings, "traffic_sale_mode", False):
|
||||
bonus_details_str = _("referral_not_available_for_traffic")
|
||||
else:
|
||||
if settings.subscription_options:
|
||||
for months_period_key, _price in sorted(settings.subscription_options.items()):
|
||||
inv_bonus = settings.referral_bonus_inviter.get(months_period_key)
|
||||
ref_bonus = settings.referral_bonus_referee.get(months_period_key)
|
||||
if inv_bonus is not None or ref_bonus is not None:
|
||||
bonus_info_parts.append(
|
||||
_(
|
||||
"referral_bonus_per_period",
|
||||
months=months_period_key,
|
||||
inviter_bonus_days=inv_bonus
|
||||
if inv_bonus is not None
|
||||
else _("no_bonus_placeholder"),
|
||||
referee_bonus_days=ref_bonus
|
||||
if ref_bonus is not None
|
||||
else _("no_bonus_placeholder"),
|
||||
)
|
||||
)
|
||||
|
||||
bonus_details_str = (
|
||||
"\n".join(bonus_info_parts) if bonus_info_parts else _("referral_no_bonuses_configured")
|
||||
)
|
||||
bonus_details_str = _build_referral_bonus_details_text(settings, _, current_lang)
|
||||
|
||||
referral_stats = await referral_service.get_referral_stats(session, inviter_user_id)
|
||||
|
||||
@@ -208,6 +187,132 @@ async def referral_action_handler(
|
||||
await callback.answer()
|
||||
|
||||
|
||||
Translator = Callable[..., str]
|
||||
|
||||
|
||||
def _period_bonus_text(
|
||||
translator: Translator,
|
||||
*,
|
||||
months: int,
|
||||
inviter_days: Optional[int],
|
||||
referee_days: Optional[int],
|
||||
) -> str:
|
||||
return translator(
|
||||
"referral_bonus_per_period",
|
||||
months=months,
|
||||
inviter_bonus_days=(
|
||||
inviter_days if inviter_days is not None else translator("no_bonus_placeholder")
|
||||
),
|
||||
referee_bonus_days=(
|
||||
referee_days if referee_days is not None else translator("no_bonus_placeholder")
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _tariff_period_bonus_entries(tariff: Any) -> list[dict[str, Optional[int]]]:
|
||||
entries: list[dict[str, Optional[int]]] = []
|
||||
for months in sorted(int(month) for month in getattr(tariff, "enabled_periods", [])):
|
||||
inviter_days = tariff.referral_inviter_bonus_days(months)
|
||||
referee_days = tariff.referral_referee_bonus_days(months)
|
||||
if inviter_days is None and referee_days is None:
|
||||
continue
|
||||
entries.append(
|
||||
{
|
||||
"months": months,
|
||||
"inviter_days": inviter_days,
|
||||
"referee_days": referee_days,
|
||||
}
|
||||
)
|
||||
return entries
|
||||
|
||||
|
||||
def _legacy_period_bonus_entries(settings: Settings) -> list[dict[str, Optional[int]]]:
|
||||
entries: list[dict[str, Optional[int]]] = []
|
||||
for months, _price in sorted(settings.subscription_options.items()):
|
||||
inviter_days = settings.referral_bonus_inviter.get(months)
|
||||
referee_days = settings.referral_bonus_referee.get(months)
|
||||
if inviter_days is None and referee_days is None:
|
||||
continue
|
||||
entries.append(
|
||||
{
|
||||
"months": int(months),
|
||||
"inviter_days": inviter_days,
|
||||
"referee_days": referee_days,
|
||||
}
|
||||
)
|
||||
return entries
|
||||
|
||||
|
||||
def _bonus_days_range(translator: Translator, values: list[int]) -> str:
|
||||
return translator(
|
||||
"referral_bonus_days_range",
|
||||
min_days=min(values),
|
||||
max_days=max(values),
|
||||
)
|
||||
|
||||
|
||||
def _build_referral_bonus_details_text(
|
||||
settings: Settings, translator: Translator, current_lang: str
|
||||
) -> str:
|
||||
tariffs_config = settings.tariffs_config
|
||||
if not tariffs_config:
|
||||
bonus_info_parts = [
|
||||
_period_bonus_text(
|
||||
translator,
|
||||
months=int(entry["months"] or 0),
|
||||
inviter_days=entry["inviter_days"],
|
||||
referee_days=entry["referee_days"],
|
||||
)
|
||||
for entry in _legacy_period_bonus_entries(settings)
|
||||
]
|
||||
return (
|
||||
"\n".join(bonus_info_parts)
|
||||
if bonus_info_parts
|
||||
else translator("referral_no_bonuses_configured")
|
||||
)
|
||||
|
||||
period_tariffs = [
|
||||
tariff for tariff in tariffs_config.enabled_tariffs if tariff.billing_model == "period"
|
||||
]
|
||||
if len(period_tariffs) <= 1:
|
||||
entries = _tariff_period_bonus_entries(period_tariffs[0]) if period_tariffs else []
|
||||
bonus_info_parts = [
|
||||
_period_bonus_text(
|
||||
translator,
|
||||
months=int(entry["months"] or 0),
|
||||
inviter_days=entry["inviter_days"],
|
||||
referee_days=entry["referee_days"],
|
||||
)
|
||||
for entry in entries
|
||||
]
|
||||
return (
|
||||
"\n".join(bonus_info_parts)
|
||||
if bonus_info_parts
|
||||
else translator("referral_no_bonuses_configured")
|
||||
)
|
||||
|
||||
bonus_info_parts = []
|
||||
for tariff in period_tariffs:
|
||||
entries = _tariff_period_bonus_entries(tariff)
|
||||
if not entries:
|
||||
continue
|
||||
inviter_values = [int(entry["inviter_days"] or 0) for entry in entries]
|
||||
referee_values = [int(entry["referee_days"] or 0) for entry in entries]
|
||||
bonus_info_parts.append(
|
||||
translator(
|
||||
"referral_bonus_tariff_range",
|
||||
tariff_name=tariff.name(current_lang),
|
||||
inviter_bonus_range=_bonus_days_range(translator, inviter_values),
|
||||
referee_bonus_range=_bonus_days_range(translator, referee_values),
|
||||
)
|
||||
)
|
||||
return (
|
||||
"\n".join(bonus_info_parts)
|
||||
if bonus_info_parts
|
||||
else translator("referral_no_bonuses_configured")
|
||||
)
|
||||
|
||||
|
||||
def _build_webapp_referral_link(
|
||||
base_url: Optional[str], referral_code: Optional[str]
|
||||
) -> Optional[str]:
|
||||
|
||||
@@ -17,12 +17,22 @@ from bot.keyboards.inline.user_keyboards import (
|
||||
get_language_selection_keyboard,
|
||||
get_main_menu_inline_keyboard,
|
||||
)
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.middlewares.i18n import JsonI18n, normalize_locale_language_code
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
from bot.services.promo_code_service import PromoCodeService
|
||||
from bot.services.referral_service import ReferralService
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.services.telegram_notifications import TELEGRAM_NOTIFICATIONS_ENABLED
|
||||
from bot.utils.callback_answer import safe_answer_callback
|
||||
from bot.utils.channel_subscription import (
|
||||
is_required_channel_access_error,
|
||||
normalize_required_channel_id,
|
||||
resolve_required_channel_link,
|
||||
)
|
||||
from bot.utils.install_links import (
|
||||
append_install_share_link_text,
|
||||
ensure_user_install_guide_links,
|
||||
)
|
||||
from bot.utils.text_sanitizer import sanitize_display_name, sanitize_username
|
||||
from config.settings import Settings
|
||||
from db.dal import user_dal
|
||||
@@ -31,6 +41,67 @@ from db.models import User
|
||||
router = Router(name="user_start_router")
|
||||
|
||||
|
||||
def _remnashop_referral_compat_enabled(settings: Settings) -> bool:
|
||||
return bool(getattr(settings, "MIGRATION_REMNASHOP_REFERRAL_CODE_COMPAT_ENABLED", False))
|
||||
|
||||
|
||||
def _referral_code_lookup_candidates(
|
||||
raw_ref_value: str,
|
||||
*,
|
||||
remnashop_compat: bool,
|
||||
) -> list[str]:
|
||||
value = str(raw_ref_value or "").strip()
|
||||
if not value:
|
||||
return []
|
||||
|
||||
candidates = [value]
|
||||
if value and value[0].lower() == "u":
|
||||
stripped_current_prefix = value[1:]
|
||||
if remnashop_compat:
|
||||
candidates.append(stripped_current_prefix)
|
||||
else:
|
||||
candidates = [stripped_current_prefix]
|
||||
|
||||
unique: list[str] = []
|
||||
for candidate in candidates:
|
||||
candidate = candidate.strip()
|
||||
if candidate and candidate not in unique:
|
||||
unique.append(candidate)
|
||||
return unique
|
||||
|
||||
|
||||
async def _resolve_referrer_from_start_ref(
|
||||
session: AsyncSession,
|
||||
raw_ref_value: str,
|
||||
*,
|
||||
settings: Settings,
|
||||
current_user_id: int,
|
||||
) -> Optional[int]:
|
||||
ref_user: Optional[User] = None
|
||||
if raw_ref_value.isdigit() and settings.LEGACY_REFS:
|
||||
potential_referrer_id = int(raw_ref_value)
|
||||
if potential_referrer_id != current_user_id:
|
||||
ref_user = await user_dal.get_user_by_id(session, potential_referrer_id)
|
||||
|
||||
include_legacy = _remnashop_referral_compat_enabled(settings)
|
||||
if not ref_user:
|
||||
for code in _referral_code_lookup_candidates(
|
||||
raw_ref_value,
|
||||
remnashop_compat=include_legacy,
|
||||
):
|
||||
ref_user = await user_dal.get_user_by_referral_code(
|
||||
session,
|
||||
code,
|
||||
include_legacy=include_legacy,
|
||||
)
|
||||
if ref_user:
|
||||
break
|
||||
|
||||
if ref_user and ref_user.user_id != current_user_id:
|
||||
return int(ref_user.user_id)
|
||||
return None
|
||||
|
||||
|
||||
async def should_show_trial_button(
|
||||
settings: Settings,
|
||||
subscription_service: SubscriptionService,
|
||||
@@ -40,12 +111,12 @@ async def should_show_trial_button(
|
||||
if not settings.TRIAL_ENABLED:
|
||||
return False
|
||||
|
||||
if hasattr(subscription_service, "has_had_any_subscription") and callable(
|
||||
getattr(subscription_service, "has_had_any_subscription")
|
||||
if hasattr(subscription_service, "has_trial_blocking_subscription") and callable(
|
||||
getattr(subscription_service, "has_trial_blocking_subscription")
|
||||
):
|
||||
return not await subscription_service.has_had_any_subscription(session, user_id)
|
||||
return not await subscription_service.has_trial_blocking_subscription(session, user_id)
|
||||
|
||||
logging.error("Method has_had_any_subscription is missing in SubscriptionService!")
|
||||
logging.error("Method has_trial_blocking_subscription is missing in SubscriptionService!")
|
||||
return False
|
||||
|
||||
|
||||
@@ -210,7 +281,7 @@ async def ensure_required_channel_subscription(
|
||||
Verify that the user is a member of the required channel (if configured).
|
||||
Returns True when access can proceed, False when user must subscribe first.
|
||||
"""
|
||||
required_channel_id = settings.REQUIRED_CHANNEL_ID
|
||||
required_channel_id = normalize_required_channel_id(settings.REQUIRED_CHANNEL_ID)
|
||||
if not required_channel_id:
|
||||
return True
|
||||
|
||||
@@ -274,6 +345,29 @@ async def ensure_required_channel_subscription(
|
||||
if status_value in allowed_statuses:
|
||||
is_member = True
|
||||
except TelegramBadRequest as bad_request:
|
||||
if is_required_channel_access_error(bad_request):
|
||||
logging.error(
|
||||
"Required channel check failed due to channel access/configuration error "
|
||||
"(configured=%s, normalized=%s): %s",
|
||||
settings.REQUIRED_CHANNEL_ID,
|
||||
required_channel_id,
|
||||
bad_request,
|
||||
)
|
||||
error_text = translate("channel_subscription_check_failed")
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
try:
|
||||
await event.answer(error_text, show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
if message_obj:
|
||||
try:
|
||||
await message_obj.answer(error_text)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
await event.answer(error_text)
|
||||
return False
|
||||
|
||||
logging.info(
|
||||
"Required channel check: user %s not subscribed (details: %s)",
|
||||
user_id,
|
||||
@@ -344,11 +438,12 @@ async def ensure_required_channel_subscription(
|
||||
)
|
||||
return True
|
||||
|
||||
keyboard = (
|
||||
get_channel_subscription_keyboard(current_lang, i18n, settings.REQUIRED_CHANNEL_LINK)
|
||||
if i18n
|
||||
else None
|
||||
channel_link = await resolve_required_channel_link(
|
||||
bot_instance,
|
||||
required_channel_id,
|
||||
settings.REQUIRED_CHANNEL_LINK,
|
||||
)
|
||||
keyboard = get_channel_subscription_keyboard(current_lang, i18n, channel_link) if i18n else None
|
||||
|
||||
prompt_text = translate("channel_subscription_required")
|
||||
|
||||
@@ -378,20 +473,18 @@ async def ensure_required_channel_subscription(
|
||||
|
||||
|
||||
@router.message(CommandStart())
|
||||
@router.message(CommandStart(magic=F.args.regexp(r"^ref_([A-Za-z0-9_-]{1,64})$").as_("ref_match")))
|
||||
@router.message(
|
||||
CommandStart(
|
||||
magic=F.args.regexp(r"^ref_((?:[uU][A-Za-z0-9]{9})|(?:[A-Za-z0-9]{9})|\d+)$").as_(
|
||||
"ref_match"
|
||||
)
|
||||
)
|
||||
CommandStart(magic=F.args.regexp(r"^promo_([A-Za-z0-9_-]{1,100})$").as_("promo_match"))
|
||||
)
|
||||
@router.message(CommandStart(magic=F.args.regexp(r"^promo_(\w+)$").as_("promo_match")))
|
||||
@router.message(CommandStart(magic=F.args.regexp(r"^admin_user_(\d+)$").as_("admin_user_match")))
|
||||
@router.message(CommandStart(magic=F.args.regexp(r"^ticket_(\d+)$").as_("ticket_match")))
|
||||
@router.message(CommandStart(magic=F.args.regexp(r"^notifications$").as_("notifications_match")))
|
||||
@router.message(CommandStart(magic=F.args.regexp(r"^page_ref$").as_("page_ref_match")))
|
||||
@router.message(
|
||||
CommandStart(
|
||||
magic=F.args.regexp(
|
||||
r"^(?!ref_|promo_|admin_user_|page_ref$|webapp_auth_)([A-Za-z0-9_\-]{2,64})$"
|
||||
r"^(?!ref_|promo_|admin_user_|ticket_|notifications$|page_ref$|webapp_auth_)([A-Za-z0-9_\-]{2,64})$"
|
||||
).as_("ad_param_match")
|
||||
)
|
||||
)
|
||||
@@ -408,6 +501,8 @@ async def start_command_handler(
|
||||
page_ref_match: Optional[re.Match] = None,
|
||||
ad_param_match: Optional[re.Match] = None,
|
||||
admin_user_match: Optional[re.Match] = None,
|
||||
ticket_match: Optional[re.Match] = None,
|
||||
notifications_match: Optional[re.Match] = None,
|
||||
):
|
||||
await state.clear()
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
@@ -465,32 +560,50 @@ async def start_command_handler(
|
||||
await message.answer(_("admin_user_card_error"))
|
||||
return
|
||||
|
||||
if ticket_match:
|
||||
ticket_id = int(ticket_match.group(1))
|
||||
base_url = (settings.SUBSCRIPTION_MINI_APP_URL or "").strip()
|
||||
if base_url:
|
||||
ticket_url = f"{base_url.rstrip('/')}/support/{ticket_id}"
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=i18n.gettext(current_lang, "wa_support_open_ticket")
|
||||
if i18n
|
||||
else "Открыть тикет",
|
||||
web_app=types.WebAppInfo(url=ticket_url),
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
await message.answer(
|
||||
i18n.gettext(current_lang, "wa_support_open_ticket_hint")
|
||||
if i18n
|
||||
else "Откройте тикет в Mini App.",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
return
|
||||
|
||||
referred_by_user_id: Optional[int] = None
|
||||
promo_code_to_apply: Optional[str] = None
|
||||
should_open_referral_from_start = False
|
||||
ad_start_param: Optional[str] = None
|
||||
notifications_start_requested = bool(notifications_match)
|
||||
|
||||
if ref_match:
|
||||
raw_ref_value = ref_match.group(1)
|
||||
if raw_ref_value.isdigit():
|
||||
if settings.LEGACY_REFS:
|
||||
potential_referrer_id = int(raw_ref_value)
|
||||
if potential_referrer_id != user_id and await user_dal.get_user_by_id(
|
||||
session, potential_referrer_id
|
||||
):
|
||||
referred_by_user_id = potential_referrer_id
|
||||
else:
|
||||
normalized_code = raw_ref_value.strip()
|
||||
if normalized_code and normalized_code[0].lower() == "u":
|
||||
normalized_code = normalized_code[1:]
|
||||
ref_user = None
|
||||
if normalized_code:
|
||||
ref_user = await user_dal.get_user_by_referral_code(session, normalized_code)
|
||||
if ref_user and ref_user.user_id != user_id:
|
||||
referred_by_user_id = ref_user.user_id
|
||||
referred_by_user_id = await _resolve_referrer_from_start_ref(
|
||||
session,
|
||||
raw_ref_value,
|
||||
settings=settings,
|
||||
current_user_id=user_id,
|
||||
)
|
||||
elif promo_match:
|
||||
promo_code_to_apply = promo_match.group(1)
|
||||
logging.info(f"User {user_id} started with promo code: {promo_code_to_apply}")
|
||||
elif notifications_start_requested:
|
||||
logging.info("User %s started bot from notifications deep-link.", user_id)
|
||||
elif page_ref_match:
|
||||
should_open_referral_from_start = True
|
||||
logging.info(f"User {user_id} started with page_ref deep-link.")
|
||||
@@ -501,18 +614,24 @@ async def start_command_handler(
|
||||
sanitized_username = sanitize_username(user.username)
|
||||
sanitized_first_name = sanitize_display_name(user.first_name)
|
||||
sanitized_last_name = sanitize_display_name(user.last_name)
|
||||
notification_status_now = datetime.now(timezone.utc)
|
||||
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
is_existing_user = db_user is not None
|
||||
if not db_user:
|
||||
user_data_to_create = {
|
||||
"user_id": user_id,
|
||||
"telegram_id": user_id,
|
||||
"username": sanitized_username,
|
||||
"first_name": sanitized_first_name,
|
||||
"last_name": sanitized_last_name,
|
||||
"language_code": current_lang,
|
||||
"referred_by_id": referred_by_user_id,
|
||||
"registration_date": datetime.now(timezone.utc),
|
||||
"telegram_notifications_status": TELEGRAM_NOTIFICATIONS_ENABLED,
|
||||
"telegram_notifications_checked_at": notification_status_now,
|
||||
"telegram_notifications_enabled_at": notification_status_now,
|
||||
"telegram_notifications_blocked_at": None,
|
||||
}
|
||||
try:
|
||||
db_user, created = await user_dal.create_user(session, user_data_to_create)
|
||||
@@ -539,12 +658,17 @@ async def start_command_handler(
|
||||
)
|
||||
if referred_by_user_id and referral_welcome_days > 0:
|
||||
try:
|
||||
default_tariff_key = None
|
||||
tariffs_config = getattr(settings, "tariffs_config", None)
|
||||
if tariffs_config:
|
||||
default_tariff_key = getattr(tariffs_config, "default_tariff", None)
|
||||
referral_bonus_end_date = (
|
||||
await subscription_service.extend_active_subscription_days(
|
||||
session,
|
||||
user_id,
|
||||
referral_welcome_days,
|
||||
reason="referral_welcome_bonus",
|
||||
tariff_key=default_tariff_key,
|
||||
)
|
||||
)
|
||||
if referral_bonus_end_date:
|
||||
@@ -600,6 +724,13 @@ async def start_command_handler(
|
||||
update_payload = {}
|
||||
if db_user.language_code != current_lang:
|
||||
update_payload["language_code"] = current_lang
|
||||
if db_user.telegram_id != user_id:
|
||||
update_payload["telegram_id"] = user_id
|
||||
if db_user.telegram_notifications_status != TELEGRAM_NOTIFICATIONS_ENABLED:
|
||||
update_payload["telegram_notifications_status"] = TELEGRAM_NOTIFICATIONS_ENABLED
|
||||
update_payload["telegram_notifications_checked_at"] = notification_status_now
|
||||
update_payload["telegram_notifications_enabled_at"] = notification_status_now
|
||||
update_payload["telegram_notifications_blocked_at"] = None
|
||||
# Set referral only if not already set AND user is not currently active.
|
||||
# This allows previously subscribed but currently inactive users to be attributed.
|
||||
if referred_by_user_id and db_user.referred_by_id is None:
|
||||
@@ -653,9 +784,16 @@ async def start_command_handler(
|
||||
open_referral_page_for_existing_user = should_open_referral_from_start and is_existing_user
|
||||
|
||||
# Send welcome message if not disabled
|
||||
if not settings.DISABLE_WELCOME_MESSAGE and not open_referral_page_for_existing_user:
|
||||
if (
|
||||
not settings.DISABLE_WELCOME_MESSAGE
|
||||
and not open_referral_page_for_existing_user
|
||||
and not notifications_start_requested
|
||||
):
|
||||
await message.answer(_(key="welcome", user_name=hd.quote(user.full_name)))
|
||||
|
||||
if notifications_start_requested:
|
||||
await message.answer(_("telegram_notifications_started"), parse_mode="HTML")
|
||||
|
||||
# Auto-apply promo code if provided via start parameter
|
||||
if promo_code_to_apply:
|
||||
try:
|
||||
@@ -688,6 +826,23 @@ async def start_command_handler(
|
||||
),
|
||||
config_link=config_link_text,
|
||||
)
|
||||
install_links = await ensure_user_install_guide_links(session, settings, user_id)
|
||||
install_share_url = install_links.public_share_url
|
||||
if install_share_url:
|
||||
try:
|
||||
await session.commit()
|
||||
promo_success_text = append_install_share_link_text(
|
||||
promo_success_text,
|
||||
_,
|
||||
install_share_url,
|
||||
)
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logging.exception(
|
||||
"Failed to persist install guide share token for promo user %s.",
|
||||
user_id,
|
||||
)
|
||||
install_share_url = None
|
||||
|
||||
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
|
||||
|
||||
@@ -699,6 +854,7 @@ async def start_command_handler(
|
||||
settings,
|
||||
config_link_display,
|
||||
connect_button_url=connect_button_url,
|
||||
install_share_url=install_share_url,
|
||||
),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
@@ -864,7 +1020,12 @@ async def select_language_callback_handler(
|
||||
|
||||
try:
|
||||
lang_payload = callback.data.split("_", 2)[2]
|
||||
lang_code, _, return_target = lang_payload.partition(":")
|
||||
raw_lang_code, _, return_target = lang_payload.partition(":")
|
||||
lang_code = normalize_locale_language_code(
|
||||
raw_lang_code,
|
||||
set(i18n.locales_data.keys()),
|
||||
prefer_known_base=True,
|
||||
)
|
||||
except IndexError:
|
||||
await safe_answer_callback(
|
||||
callback,
|
||||
@@ -872,6 +1033,13 @@ async def select_language_callback_handler(
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
if lang_code not in i18n.locales_data:
|
||||
await safe_answer_callback(
|
||||
callback,
|
||||
"Unsupported language.",
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
user_id = callback.from_user.id
|
||||
try:
|
||||
@@ -1021,7 +1189,7 @@ async def main_action_callback_handler(
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
privacy_url = settings.PRIVACY_POLICY_URL
|
||||
user_agreement_url = settings.USER_AGREEMENT_URL or settings.TERMS_OF_SERVICE_URL
|
||||
user_agreement_url = settings.USER_AGREEMENT_URL
|
||||
|
||||
if not privacy_url and not user_agreement_url:
|
||||
await safe_answer_callback(
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
from aiogram import Router
|
||||
|
||||
from . import core, payment_methods, payments
|
||||
from . import core, payments
|
||||
|
||||
router = Router(name="user_subscription_router")
|
||||
|
||||
# Include sub-routers
|
||||
router.include_router(core.router)
|
||||
router.include_router(payments.router)
|
||||
router.include_router(payment_methods.router)
|
||||
|
||||
# Re-export commonly used entrypoints for backward compatibility
|
||||
from .core import ( # noqa: E402,F401
|
||||
|
||||
@@ -11,6 +11,8 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot.keyboards.inline.user_keyboards import (
|
||||
callback_context_from_back_callback,
|
||||
callback_suffix_for_context,
|
||||
get_autorenew_confirm_keyboard,
|
||||
get_back_to_main_menu_markup,
|
||||
get_hwid_device_packages_keyboard,
|
||||
@@ -19,11 +21,21 @@ from bot.keyboards.inline.user_keyboards import (
|
||||
get_tariff_catalog_keyboard,
|
||||
get_tariff_packages_keyboard,
|
||||
get_tariff_periods_keyboard,
|
||||
sale_mode_with_callback_context,
|
||||
tariff_purchase_back_callback,
|
||||
)
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.utils.install_links import (
|
||||
append_install_share_link_text,
|
||||
ensure_user_install_guide_links,
|
||||
)
|
||||
from config.settings import Settings
|
||||
from config.tariffs_config import (
|
||||
default_currency_key_for_settings,
|
||||
default_payment_currency_code_for_settings,
|
||||
)
|
||||
from db.dal import subscription_dal, user_billing_dal
|
||||
from db.models import Subscription
|
||||
|
||||
@@ -56,11 +68,32 @@ def _has_multiple_enabled_tariffs(settings: Settings) -> bool:
|
||||
|
||||
|
||||
def _tariff_purchase_markup(
|
||||
tariff, current_lang: str, i18n: JsonI18n, settings: Settings
|
||||
tariff,
|
||||
current_lang: str,
|
||||
i18n: JsonI18n,
|
||||
settings: Settings,
|
||||
back_callback: str = "main_action:subscribe",
|
||||
callback_context: Optional[str] = None,
|
||||
) -> InlineKeyboardMarkup:
|
||||
if tariff.billing_model == "period":
|
||||
return get_tariff_periods_keyboard(tariff, current_lang, i18n, settings)
|
||||
return get_tariff_packages_keyboard(tariff, tariff.traffic_packages.rub, current_lang, i18n)
|
||||
return get_tariff_periods_keyboard(
|
||||
tariff,
|
||||
current_lang,
|
||||
i18n,
|
||||
settings,
|
||||
back_callback=back_callback,
|
||||
callback_context=callback_context,
|
||||
)
|
||||
default_currency = default_currency_key_for_settings(settings)
|
||||
return get_tariff_packages_keyboard(
|
||||
tariff,
|
||||
tariff.traffic_packages.for_currency(default_currency),
|
||||
current_lang,
|
||||
i18n,
|
||||
currency_symbol=default_payment_currency_code_for_settings(settings),
|
||||
back_callback=back_callback,
|
||||
callback_context=callback_context,
|
||||
)
|
||||
|
||||
|
||||
def _tariff_purchase_text(tariff, current_lang: str, i18n: JsonI18n, settings: Settings) -> str:
|
||||
@@ -71,6 +104,36 @@ def _tariff_purchase_text(tariff, current_lang: str, i18n: JsonI18n, settings: S
|
||||
return f"{tariff.name(current_lang)}\n{tariff.description(current_lang)}".strip()
|
||||
|
||||
|
||||
def _with_subscription_purchase_description(
|
||||
text: str,
|
||||
settings: Settings,
|
||||
current_lang: str,
|
||||
*,
|
||||
include: bool,
|
||||
) -> str:
|
||||
if not include:
|
||||
return text
|
||||
description_resolver = getattr(settings, "subscription_purchase_description", None)
|
||||
description = description_resolver(current_lang) if callable(description_resolver) else ""
|
||||
if not description:
|
||||
return text
|
||||
return f"{description}\n\n{text}"
|
||||
|
||||
|
||||
def _format_premium_bytes(value: object) -> str:
|
||||
try:
|
||||
bytes_value = max(0, int(value or 0))
|
||||
except (TypeError, ValueError):
|
||||
bytes_value = 0
|
||||
return f"{bytes_value / 2**30:.2f} GB"
|
||||
|
||||
|
||||
def _format_premium_usage_limit(active: dict[str, object]) -> str:
|
||||
used = _format_premium_bytes(active.get("premium_used_bytes"))
|
||||
limit = _format_premium_bytes(active.get("premium_limit_bytes"))
|
||||
return f"{used} из {limit}"
|
||||
|
||||
|
||||
async def display_subscription_options(
|
||||
event: Union[types.Message, types.CallbackQuery],
|
||||
i18n_data: dict,
|
||||
@@ -98,13 +161,40 @@ async def display_subscription_options(
|
||||
tariffs_config = getattr(settings, "tariffs_config", None)
|
||||
if tariffs_config:
|
||||
enabled_tariffs = list(tariffs_config.enabled_tariffs)
|
||||
callback_context = callback_context_from_back_callback(back_callback)
|
||||
if len(enabled_tariffs) == 1:
|
||||
tariff = enabled_tariffs[0]
|
||||
text_content = _tariff_purchase_text(tariff, current_lang, i18n, settings)
|
||||
reply_markup = _tariff_purchase_markup(tariff, current_lang, i18n, settings)
|
||||
text_content = _with_subscription_purchase_description(
|
||||
text_content,
|
||||
settings,
|
||||
current_lang,
|
||||
include=tariff.billing_model == "period",
|
||||
)
|
||||
reply_markup = _tariff_purchase_markup(
|
||||
tariff,
|
||||
current_lang,
|
||||
i18n,
|
||||
settings,
|
||||
back_callback=back_callback,
|
||||
callback_context=callback_context,
|
||||
)
|
||||
else:
|
||||
text_content = get_text("select_subscription_period")
|
||||
reply_markup = get_tariff_catalog_keyboard(enabled_tariffs, current_lang, i18n)
|
||||
text_content = _with_subscription_purchase_description(
|
||||
text_content,
|
||||
settings,
|
||||
current_lang,
|
||||
include=any(tariff.billing_model == "period" for tariff in enabled_tariffs),
|
||||
)
|
||||
reply_markup = get_tariff_catalog_keyboard(
|
||||
enabled_tariffs,
|
||||
current_lang,
|
||||
i18n,
|
||||
settings=settings,
|
||||
back_callback=back_callback,
|
||||
callback_context=callback_context,
|
||||
)
|
||||
target_message_obj = event.message if isinstance(event, types.CallbackQuery) else event
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
try:
|
||||
@@ -137,6 +227,12 @@ async def display_subscription_options(
|
||||
if traffic_mode
|
||||
else get_text("select_subscription_period")
|
||||
)
|
||||
text_content = _with_subscription_purchase_description(
|
||||
text_content,
|
||||
settings,
|
||||
current_lang,
|
||||
include=not traffic_mode,
|
||||
)
|
||||
reply_markup = get_subscription_options_keyboard(
|
||||
options,
|
||||
currency_symbol_val,
|
||||
@@ -144,6 +240,7 @@ async def display_subscription_options(
|
||||
i18n,
|
||||
traffic_mode=traffic_mode,
|
||||
back_callback=back_callback,
|
||||
callback_context=callback_context_from_back_callback(back_callback),
|
||||
)
|
||||
else:
|
||||
text_content = get_text("no_subscription_options_available")
|
||||
@@ -193,21 +290,40 @@ async def select_tariff_callback(
|
||||
if not config or not callback.message:
|
||||
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||
return
|
||||
tariff_key = callback.data.split(":", 2)[2]
|
||||
parts = callback.data.split(":")
|
||||
tariff_key = parts[2] if len(parts) > 2 else ""
|
||||
callback_context = parts[3] if len(parts) > 3 else None
|
||||
try:
|
||||
tariff = config.require(tariff_key)
|
||||
except Exception:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
return
|
||||
markup = _tariff_purchase_markup(tariff, current_lang, i18n, settings)
|
||||
markup = _tariff_purchase_markup(
|
||||
tariff,
|
||||
current_lang,
|
||||
i18n,
|
||||
settings,
|
||||
back_callback=tariff_purchase_back_callback(callback_context),
|
||||
callback_context=callback_context,
|
||||
)
|
||||
text = _tariff_purchase_text(tariff, current_lang, i18n, settings)
|
||||
text = _with_subscription_purchase_description(
|
||||
text,
|
||||
settings,
|
||||
current_lang,
|
||||
include=tariff.billing_model == "period",
|
||||
)
|
||||
await callback.message.edit_text(text, reply_markup=markup)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("tariff:period:"))
|
||||
async def select_tariff_period_callback(
|
||||
callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession
|
||||
callback: types.CallbackQuery,
|
||||
i18n_data: dict,
|
||||
settings: Settings,
|
||||
session: AsyncSession,
|
||||
subscription_service: SubscriptionService,
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: JsonI18n = i18n_data.get("i18n_instance")
|
||||
@@ -216,23 +332,53 @@ async def select_tariff_period_callback(
|
||||
if not config or not callback.message:
|
||||
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||
return
|
||||
_, _, tariff_key, months_raw = callback.data.split(":", 3)
|
||||
parts = callback.data.split(":")
|
||||
if len(parts) < 4:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
return
|
||||
tariff_key, months_raw = parts[2], parts[3]
|
||||
callback_tokens = [part for part in parts[4:] if part]
|
||||
callback_context = "bot" if "bot" in callback_tokens else None
|
||||
renew_hwid_devices = "no_hwid" not in callback_tokens
|
||||
tariff = config.require(tariff_key)
|
||||
months = int(months_raw)
|
||||
price_rub = tariff.period_price(months, "rub")
|
||||
default_currency = default_currency_key_for_settings(settings)
|
||||
currency_code = default_payment_currency_code_for_settings(settings)
|
||||
price_rub = tariff.period_price(months, default_currency)
|
||||
stars_price = tariff.period_price(months, "stars")
|
||||
if price_rub is None:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
return
|
||||
hwid_renewal_quote = await subscription_service.quote_hwid_device_renewal_for_subscription(
|
||||
session,
|
||||
user_id=callback.from_user.id,
|
||||
target_tariff_key=tariff.key,
|
||||
months=months,
|
||||
currency=default_currency,
|
||||
)
|
||||
hwid_renewal_stars_quote = (
|
||||
await subscription_service.quote_hwid_device_renewal_for_subscription(
|
||||
session,
|
||||
user_id=callback.from_user.id,
|
||||
target_tariff_key=tariff.key,
|
||||
months=months,
|
||||
currency="stars",
|
||||
)
|
||||
)
|
||||
markup = get_payment_method_keyboard(
|
||||
months,
|
||||
price_rub,
|
||||
int(stars_price) if stars_price else None,
|
||||
settings.DEFAULT_CURRENCY_SYMBOL,
|
||||
currency_code,
|
||||
current_lang,
|
||||
i18n,
|
||||
settings,
|
||||
sale_mode=f"subscription@{tariff.key}",
|
||||
sale_mode=sale_mode_with_callback_context(f"subscription@{tariff.key}", callback_context),
|
||||
back_callback=f"tariff:select:{tariff.key}{callback_suffix_for_context(callback_context)}",
|
||||
user_id=callback.from_user.id,
|
||||
hwid_renewal_quote=hwid_renewal_quote,
|
||||
hwid_renewal_stars_quote=hwid_renewal_stars_quote,
|
||||
hwid_renewal_selected=bool(renew_hwid_devices),
|
||||
)
|
||||
await callback.message.edit_text(get_text("choose_payment_method"), reply_markup=markup)
|
||||
await callback.answer()
|
||||
@@ -249,13 +395,24 @@ async def select_tariff_package_callback(
|
||||
if not config or not callback.message:
|
||||
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||
return
|
||||
_, _, tariff_key, gb_raw = callback.data.split(":", 3)
|
||||
parts = callback.data.split(":")
|
||||
if len(parts) < 4:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
return
|
||||
tariff_key, gb_raw = parts[2], parts[3]
|
||||
callback_context = parts[4] if len(parts) > 4 else None
|
||||
tariff = config.require(tariff_key)
|
||||
gb = float(gb_raw)
|
||||
default_currency = default_currency_key_for_settings(settings)
|
||||
currency_code = default_payment_currency_code_for_settings(settings)
|
||||
packages = (
|
||||
tariff.traffic_packages.rub
|
||||
tariff.traffic_packages.for_currency(default_currency)
|
||||
if tariff.billing_model == "traffic"
|
||||
else (config.topup_packages_for(tariff).rub if config.topup_packages_for(tariff) else [])
|
||||
else (
|
||||
config.topup_packages_for(tariff).for_currency(default_currency)
|
||||
if config.topup_packages_for(tariff)
|
||||
else []
|
||||
)
|
||||
)
|
||||
package = next((pkg for pkg in packages if float(pkg.gb) == gb), None)
|
||||
if not package:
|
||||
@@ -264,15 +421,23 @@ async def select_tariff_package_callback(
|
||||
sale_mode = (
|
||||
f"{'traffic_package' if tariff.billing_model == 'traffic' else 'topup'}@{tariff.key}"
|
||||
)
|
||||
sale_mode = sale_mode_with_callback_context(sale_mode, callback_context)
|
||||
back_callback = (
|
||||
f"tariff:select:{tariff.key}{callback_suffix_for_context(callback_context)}"
|
||||
if tariff.billing_model == "traffic"
|
||||
else "tariff_topup:list"
|
||||
)
|
||||
markup = get_payment_method_keyboard(
|
||||
gb,
|
||||
package.price,
|
||||
None,
|
||||
settings.DEFAULT_CURRENCY_SYMBOL,
|
||||
currency_code,
|
||||
current_lang,
|
||||
i18n,
|
||||
settings,
|
||||
sale_mode=sale_mode,
|
||||
back_callback=back_callback,
|
||||
user_id=callback.from_user.id,
|
||||
)
|
||||
await callback.message.edit_text(get_text("choose_payment_method_traffic"), reply_markup=markup)
|
||||
await callback.answer()
|
||||
@@ -298,14 +463,19 @@ async def tariff_topup_list_callback(
|
||||
return
|
||||
tariff = config.require(active["tariff_key"])
|
||||
packages = config.topup_packages_for(tariff)
|
||||
rub_packages = packages.rub if packages else []
|
||||
premium_packages = tariff.premium_topup_packages.rub if tariff.premium_topup_packages else []
|
||||
if not rub_packages and not premium_packages:
|
||||
default_currency = default_currency_key_for_settings(settings)
|
||||
currency = default_payment_currency_code_for_settings(settings)
|
||||
currency_packages = packages.for_currency(default_currency) if packages else []
|
||||
premium_packages = (
|
||||
tariff.premium_topup_packages.for_currency(default_currency)
|
||||
if tariff.premium_topup_packages
|
||||
else []
|
||||
)
|
||||
if not currency_packages and not premium_packages:
|
||||
await callback.answer(get_text("no_subscription_options_available"), show_alert=True)
|
||||
return
|
||||
builder = InlineKeyboardBuilder()
|
||||
currency = settings.DEFAULT_CURRENCY_SYMBOL
|
||||
for package in rub_packages:
|
||||
for package in currency_packages:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=f"Обычный трафик +{package.gb:g} GB — {package.price:g} {currency}",
|
||||
@@ -327,7 +497,7 @@ async def tariff_topup_list_callback(
|
||||
|
||||
premium_lines = []
|
||||
carryover_lines = []
|
||||
if rub_packages or premium_packages:
|
||||
if currency_packages or premium_packages:
|
||||
carryover_lines.append(
|
||||
"Докупленный трафик не сгорает: сначала расходуется месячный лимит, затем докупленный остаток." # noqa: E501
|
||||
)
|
||||
@@ -345,7 +515,7 @@ async def tariff_topup_list_callback(
|
||||
if len(labels) > len(visible):
|
||||
premium_lines.append(f"• ... еще {len(labels) - len(visible)}")
|
||||
premium_lines.append(
|
||||
f"Premium использовано: {active.get('premium_used')} из {active.get('premium_limit')}. Осталось: {premium_left / 2**30:.2f} GB." # noqa: E501
|
||||
f"Premium использовано: {_format_premium_usage_limit(active)}. Осталось: {premium_left / 2**30:.2f} GB." # noqa: E501
|
||||
)
|
||||
text = get_text("choose_payment_method_traffic")
|
||||
if carryover_lines:
|
||||
@@ -370,7 +540,13 @@ async def select_tariff_premium_package_callback(
|
||||
_, _, tariff_key, gb_raw = callback.data.split(":", 3)
|
||||
tariff = config.require(tariff_key)
|
||||
gb = float(gb_raw)
|
||||
packages = tariff.premium_topup_packages.rub if tariff.premium_topup_packages else []
|
||||
default_currency = default_currency_key_for_settings(settings)
|
||||
currency_code = default_payment_currency_code_for_settings(settings)
|
||||
packages = (
|
||||
tariff.premium_topup_packages.for_currency(default_currency)
|
||||
if tariff.premium_topup_packages
|
||||
else []
|
||||
)
|
||||
package = next((pkg for pkg in packages if float(pkg.gb) == gb), None)
|
||||
if not package:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
@@ -379,11 +555,13 @@ async def select_tariff_premium_package_callback(
|
||||
gb,
|
||||
package.price,
|
||||
None,
|
||||
settings.DEFAULT_CURRENCY_SYMBOL,
|
||||
currency_code,
|
||||
current_lang,
|
||||
i18n,
|
||||
settings,
|
||||
sale_mode=f"premium_topup@{tariff.key}",
|
||||
back_callback="tariff_topup:list",
|
||||
user_id=callback.from_user.id,
|
||||
)
|
||||
await callback.message.edit_text(get_text("choose_payment_method_traffic"), reply_markup=markup)
|
||||
await callback.answer()
|
||||
@@ -412,7 +590,15 @@ async def hwid_devices_list_callback(
|
||||
await callback.answer(get_text("hwid_devices_unlimited_no_topup"), show_alert=True)
|
||||
return
|
||||
tariff = config.require(active["tariff_key"])
|
||||
packages = tariff.hwid_device_packages.rub if tariff.hwid_device_packages else []
|
||||
if tariff.billing_model != "period":
|
||||
await callback.answer(get_text("no_hwid_device_packages_available"), show_alert=True)
|
||||
return
|
||||
default_currency = default_currency_key_for_settings(settings)
|
||||
packages = (
|
||||
tariff.hwid_device_packages.for_currency(default_currency)
|
||||
if tariff.hwid_device_packages
|
||||
else []
|
||||
)
|
||||
if not packages:
|
||||
await callback.answer(get_text("no_hwid_device_packages_available"), show_alert=True)
|
||||
return
|
||||
@@ -423,14 +609,26 @@ async def hwid_devices_list_callback(
|
||||
i18n,
|
||||
settings,
|
||||
back_callback="main_action:my_devices",
|
||||
renewal=False,
|
||||
)
|
||||
await callback.message.edit_text(
|
||||
get_text(
|
||||
"select_hwid_device_package",
|
||||
date=active.get("extra_hwid_devices_valid_until_text") or "",
|
||||
),
|
||||
reply_markup=markup,
|
||||
)
|
||||
await callback.message.edit_text(get_text("select_hwid_device_package"), reply_markup=markup)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("hwid_devices:package:"))
|
||||
@router.callback_query(F.data.startswith("hwid_devices:renewal_package:"))
|
||||
async def hwid_devices_package_callback(
|
||||
callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession
|
||||
callback: types.CallbackQuery,
|
||||
i18n_data: dict,
|
||||
settings: Settings,
|
||||
session: AsyncSession,
|
||||
subscription_service: SubscriptionService,
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: JsonI18n = i18n_data.get("i18n_instance")
|
||||
@@ -439,13 +637,22 @@ async def hwid_devices_package_callback(
|
||||
if not config or not callback.message:
|
||||
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||
return
|
||||
_, _, tariff_key, count_raw = callback.data.split(":", 3)
|
||||
_, action, tariff_key, count_raw = callback.data.split(":", 3)
|
||||
tariff = config.require(tariff_key)
|
||||
if tariff.billing_model != "period":
|
||||
await callback.answer(get_text("no_hwid_device_packages_available"), show_alert=True)
|
||||
return
|
||||
count = int(count_raw)
|
||||
package = next(
|
||||
(
|
||||
pkg
|
||||
for pkg in (tariff.hwid_device_packages.rub if tariff.hwid_device_packages else [])
|
||||
for pkg in (
|
||||
tariff.hwid_device_packages.for_currency(
|
||||
default_currency_key_for_settings(settings)
|
||||
)
|
||||
if tariff.hwid_device_packages
|
||||
else []
|
||||
)
|
||||
if int(pkg.count) == count
|
||||
),
|
||||
None,
|
||||
@@ -453,15 +660,42 @@ async def hwid_devices_package_callback(
|
||||
if not package:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
return
|
||||
sale_mode_base = "hwid_devices_renewal" if action == "renewal_package" else "hwid_devices"
|
||||
renewal = action == "renewal_package"
|
||||
default_currency = default_currency_key_for_settings(settings)
|
||||
currency_code = default_payment_currency_code_for_settings(settings)
|
||||
currency_quote = await subscription_service.quote_hwid_device_topup(
|
||||
session,
|
||||
user_id=callback.from_user.id,
|
||||
device_count=count,
|
||||
tariff_key=tariff.key,
|
||||
renewal=renewal,
|
||||
currency=default_currency,
|
||||
)
|
||||
stars_quote = await subscription_service.quote_hwid_device_topup(
|
||||
session,
|
||||
user_id=callback.from_user.id,
|
||||
device_count=count,
|
||||
tariff_key=tariff.key,
|
||||
renewal=renewal,
|
||||
currency="stars",
|
||||
)
|
||||
if not currency_quote and not stars_quote:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
return
|
||||
markup = get_payment_method_keyboard(
|
||||
count,
|
||||
package.price,
|
||||
None,
|
||||
settings.DEFAULT_CURRENCY_SYMBOL,
|
||||
float(currency_quote.get("price") if currency_quote else 0),
|
||||
int(stars_quote["price"])
|
||||
if stars_quote and int(stars_quote.get("price") or 0) > 0
|
||||
else None,
|
||||
currency_code,
|
||||
current_lang,
|
||||
i18n,
|
||||
settings,
|
||||
sale_mode=f"hwid_devices@{tariff.key}",
|
||||
sale_mode=f"{sale_mode_base}@{tariff.key}",
|
||||
back_callback="hwid_devices:list",
|
||||
user_id=callback.from_user.id,
|
||||
)
|
||||
await callback.message.edit_text(
|
||||
get_text("choose_payment_method_hwid_devices"), reply_markup=markup
|
||||
@@ -539,7 +773,11 @@ async def tariff_change_select_callback(
|
||||
if not db_sub:
|
||||
await callback.answer("Error", show_alert=True)
|
||||
return
|
||||
options = subscription_service.calculate_tariff_switch_options(db_sub, target)
|
||||
options = await subscription_service.calculate_tariff_switch_options_with_hwid(
|
||||
session, db_sub, target
|
||||
)
|
||||
default_currency = default_currency_key_for_settings(settings)
|
||||
currency_code = default_payment_currency_code_for_settings(settings)
|
||||
rows = []
|
||||
if options["mode"] == "period_to_period":
|
||||
rows.append(
|
||||
@@ -554,7 +792,7 @@ async def tariff_change_select_callback(
|
||||
rows.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=f"Доплатить {options['paid_diff_rub']} RUB",
|
||||
text=f"Доплатить {options['paid_diff_rub']} {currency_code}",
|
||||
callback_data=f"tariff_change:confirm_pay:{target.key}:{options['paid_diff_rub']}",
|
||||
)
|
||||
]
|
||||
@@ -568,23 +806,23 @@ async def tariff_change_select_callback(
|
||||
)
|
||||
]
|
||||
)
|
||||
for package in target.traffic_packages.rub:
|
||||
for package in target.traffic_packages.for_currency(default_currency):
|
||||
rows.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=f"+ {package.gb:g} GB за {package.price:g} RUB",
|
||||
text=f"+ {package.gb:g} GB за {package.price:g} {currency_code}",
|
||||
callback_data=f"tariff:package:{target.key}:{package.gb:g}",
|
||||
)
|
||||
]
|
||||
)
|
||||
else:
|
||||
for months in target.enabled_periods:
|
||||
price = target.period_price(months, "rub")
|
||||
price = target.period_price(months, default_currency)
|
||||
if price:
|
||||
rows.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=f"{months} мес. за {price:g} RUB",
|
||||
text=f"{months} мес. за {price:g} {currency_code}",
|
||||
callback_data=f"tariff:period:{target.key}:{months}",
|
||||
)
|
||||
]
|
||||
@@ -626,7 +864,9 @@ async def tariff_change_confirm_apply_callback(
|
||||
if not db_sub:
|
||||
await callback.answer("Error", show_alert=True)
|
||||
return
|
||||
options = subscription_service.calculate_tariff_switch_options(db_sub, target)
|
||||
options = await subscription_service.calculate_tariff_switch_options_with_hwid(
|
||||
session, db_sub, target
|
||||
)
|
||||
if mode == "recalc_days":
|
||||
action_text = f"после перехода останется {options.get('recalc_days', 0)} дн."
|
||||
elif mode == "convert_days_to_gb":
|
||||
@@ -665,6 +905,7 @@ async def tariff_change_confirm_pay_callback(
|
||||
return
|
||||
_, _, tariff_key, amount_raw = callback.data.split(":", 3)
|
||||
target = config.require(tariff_key)
|
||||
currency_code = default_payment_currency_code_for_settings(settings)
|
||||
rows = [
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
@@ -680,7 +921,7 @@ async def tariff_change_confirm_pay_callback(
|
||||
],
|
||||
]
|
||||
await callback.message.edit_text(
|
||||
f"Подтвердите смену тарифа\n\nНовый тариф: {target.name(current_lang)}\nБудет создана оплата на {amount_raw} RUB.", # noqa: E501
|
||||
f"Подтвердите смену тарифа\n\nНовый тариф: {target.name(current_lang)}\nБудет создана оплата на {amount_raw} {currency_code}.", # noqa: E501
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=rows),
|
||||
)
|
||||
await callback.answer()
|
||||
@@ -722,15 +963,18 @@ async def tariff_change_pay_callback(
|
||||
i18n: JsonI18n = i18n_data.get("i18n_instance")
|
||||
_, _, tariff_key, amount_raw = callback.data.split(":", 3)
|
||||
amount = float(amount_raw)
|
||||
currency_code = default_payment_currency_code_for_settings(settings)
|
||||
markup = get_payment_method_keyboard(
|
||||
1,
|
||||
amount,
|
||||
None,
|
||||
settings.DEFAULT_CURRENCY_SYMBOL,
|
||||
currency_code,
|
||||
current_lang,
|
||||
i18n,
|
||||
settings,
|
||||
sale_mode=f"tariff_upgrade@{tariff_key}",
|
||||
back_callback=f"tariff_change:confirm_pay:{tariff_key}:{amount_raw}",
|
||||
user_id=callback.from_user.id,
|
||||
)
|
||||
await callback.message.edit_text("Выберите способ оплаты", reply_markup=markup)
|
||||
await callback.answer()
|
||||
@@ -900,7 +1144,7 @@ async def my_subscription_command_handler(
|
||||
text += (
|
||||
"\n\n🚀 <b>Premium-серверы</b>\n"
|
||||
f"Статус: <b>{premium_status}</b>\n"
|
||||
f"Лимит: <b>{active.get('premium_used')} из {active.get('premium_limit')}</b>\n"
|
||||
f"Лимит: <b>{_format_premium_usage_limit(active)}</b>\n"
|
||||
f"Осталось: <b>{premium_left / 2**30:.2f} GB</b>\n"
|
||||
f"Докупленный остаток: <b>{premium_balance / 2**30:.2f} GB</b>\n"
|
||||
"Отдельный лимит действует на:\n"
|
||||
@@ -918,12 +1162,50 @@ async def my_subscription_command_handler(
|
||||
local_sub = await subscription_dal.get_active_subscription_by_user_id(
|
||||
session, event.from_user.id
|
||||
)
|
||||
install_links = await ensure_user_install_guide_links(
|
||||
session,
|
||||
settings,
|
||||
event.from_user.id,
|
||||
local_subscription=local_sub,
|
||||
)
|
||||
install_url = install_links.personal_url
|
||||
install_share_url = install_links.public_share_url
|
||||
if install_share_url:
|
||||
try:
|
||||
await session.commit()
|
||||
text = append_install_share_link_text(text, get_text, install_share_url)
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logging.exception(
|
||||
"Failed to persist install guide share token for user %s.",
|
||||
event.from_user.id,
|
||||
)
|
||||
install_share_url = None
|
||||
|
||||
# Build rows to prepend above the base "back" markup
|
||||
prepend_rows = []
|
||||
|
||||
# 1) Connect button: prefer the actual subscription URL; fall back to mini-app
|
||||
cfg_link_val = connect_button_url or config_link_display
|
||||
if cfg_link_val:
|
||||
if install_url:
|
||||
prepend_rows.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=get_text("connect_button"),
|
||||
web_app=WebAppInfo(url=install_url),
|
||||
)
|
||||
]
|
||||
)
|
||||
if install_share_url:
|
||||
prepend_rows.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=get_text("install_guide_share_button"),
|
||||
url=install_share_url,
|
||||
)
|
||||
]
|
||||
)
|
||||
elif cfg_link_val:
|
||||
prepend_rows.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
@@ -1007,8 +1289,11 @@ async def my_subscription_command_handler(
|
||||
try:
|
||||
tariff_for_devices = settings.tariffs_config.require(local_sub.tariff_key)
|
||||
if (
|
||||
tariff_for_devices.hwid_device_packages
|
||||
and tariff_for_devices.hwid_device_packages.rub
|
||||
tariff_for_devices.billing_model == "period"
|
||||
and tariff_for_devices.hwid_device_packages
|
||||
and tariff_for_devices.hwid_device_packages.for_currency(
|
||||
default_currency_key_for_settings(settings)
|
||||
)
|
||||
):
|
||||
prepend_rows.append(
|
||||
[
|
||||
@@ -1223,8 +1508,11 @@ async def my_devices_command_handler(
|
||||
try:
|
||||
tariff_for_devices = settings.tariffs_config.require(active["tariff_key"])
|
||||
if (
|
||||
tariff_for_devices.hwid_device_packages
|
||||
and tariff_for_devices.hwid_device_packages.rub
|
||||
tariff_for_devices.billing_model == "period"
|
||||
and tariff_for_devices.hwid_device_packages
|
||||
and tariff_for_devices.hwid_device_packages.for_currency(
|
||||
default_currency_key_for_settings(settings)
|
||||
)
|
||||
):
|
||||
devices_kb.append(
|
||||
[
|
||||
|
||||
@@ -1,550 +0,0 @@
|
||||
from typing import List, Optional
|
||||
|
||||
from aiogram import F, Router, types
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.future import select
|
||||
|
||||
from bot.keyboards.inline.user_keyboards import (
|
||||
get_bind_url_keyboard,
|
||||
get_payment_method_delete_confirm_keyboard,
|
||||
get_payment_method_details_keyboard,
|
||||
get_payment_methods_list_keyboard,
|
||||
)
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.yookassa_service import YooKassaService
|
||||
from config.settings import Settings
|
||||
from db.dal import user_billing_dal
|
||||
from db.models import Payment
|
||||
|
||||
router = Router(name="user_subscription_payment_methods_router")
|
||||
|
||||
|
||||
@router.callback_query(F.data == "pm:manage")
|
||||
async def payment_methods_manage(
|
||||
callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not settings.yookassa_autopayments_active:
|
||||
try:
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
await callback.answer(_("error_service_unavailable"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
from db.dal.user_billing_dal import list_user_payment_methods
|
||||
|
||||
get_text = _
|
||||
methods = await list_user_payment_methods(session, callback.from_user.id)
|
||||
cards: List[tuple] = []
|
||||
|
||||
def _is_yoomoney_network(network: Optional[str]) -> bool:
|
||||
s = (network or "").lower()
|
||||
return "yoomoney" in s or "yoo money" in s or "yoo-money" in s
|
||||
|
||||
def _extract_last4(text: str) -> Optional[str]:
|
||||
digits = "".join(ch for ch in text if ch.isdigit())
|
||||
return digits[-4:] if len(digits) >= 4 else None
|
||||
|
||||
def _format_pm_title(network: Optional[str], last4: Optional[str]) -> str:
|
||||
if _is_yoomoney_network(network):
|
||||
l4 = last4 or _extract_last4(network or "")
|
||||
if l4:
|
||||
return get_text("payment_method_wallet_title", last4=l4)
|
||||
return get_text("payment_method_wallet_title", last4="****")
|
||||
if last4:
|
||||
network_name = network or get_text("payment_network_card")
|
||||
return get_text("payment_method_card_title", network=network_name, last4=last4)
|
||||
network_name = network or get_text("payment_network_generic")
|
||||
return get_text("payment_method_generic_title", network=network_name)
|
||||
|
||||
for m in methods:
|
||||
title = _format_pm_title(m.card_network, m.card_last4)
|
||||
cards.append((str(m.method_id), title if not m.is_default else f"⭐ {title}"))
|
||||
|
||||
text = get_text("payment_methods_title")
|
||||
if not cards:
|
||||
text += "\n\n" + get_text("payment_method_none")
|
||||
|
||||
await callback.message.edit_text(
|
||||
text, reply_markup=get_payment_methods_list_keyboard(cards, 0, current_lang, i18n)
|
||||
)
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@router.callback_query(F.data == "pm:bind")
|
||||
async def payment_method_bind(
|
||||
callback: types.CallbackQuery,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
session: AsyncSession,
|
||||
yookassa_service: YooKassaService,
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not settings.yookassa_autopayments_active:
|
||||
try:
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
await callback.answer(_("error_service_unavailable"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
metadata = {"user_id": str(callback.from_user.id), "bind_only": "1"}
|
||||
resp = await yookassa_service.create_payment(
|
||||
amount=1.00,
|
||||
currency="RUB",
|
||||
description="Bind card",
|
||||
metadata=metadata,
|
||||
receipt_email=settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL,
|
||||
save_payment_method=True,
|
||||
capture=False,
|
||||
bind_only=True,
|
||||
)
|
||||
if not resp or not resp.get("confirmation_url"):
|
||||
await callback.answer(_("error_payment_gateway"), show_alert=True)
|
||||
return
|
||||
await callback.message.edit_text(
|
||||
_("payment_methods_title"),
|
||||
reply_markup=get_bind_url_keyboard(resp["confirmation_url"], current_lang, i18n),
|
||||
)
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("pm:delete_confirm"))
|
||||
async def payment_method_delete_confirm(
|
||||
callback: types.CallbackQuery, settings: Settings, i18n_data: dict
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not settings.yookassa_autopayments_active:
|
||||
try:
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
await callback.answer(_("error_service_unavailable"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
parts = callback.data.split(":", 2)
|
||||
pm_id = parts[2] if len(parts) >= 3 else ""
|
||||
await callback.message.edit_text(
|
||||
_("payment_method_delete_confirm"),
|
||||
reply_markup=get_payment_method_delete_confirm_keyboard(pm_id, current_lang, i18n),
|
||||
)
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("pm:delete"))
|
||||
async def payment_method_delete(
|
||||
callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not settings.yookassa_autopayments_active:
|
||||
try:
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
await callback.answer(_("error_service_unavailable"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
parts = callback.data.split(":", 2)
|
||||
pm_id_raw = parts[2] if len(parts) >= 3 else ""
|
||||
deleted = False
|
||||
|
||||
try:
|
||||
from db.dal.user_billing_dal import (
|
||||
delete_user_payment_method,
|
||||
delete_user_payment_method_by_provider_id,
|
||||
list_user_payment_methods,
|
||||
)
|
||||
|
||||
if pm_id_raw:
|
||||
if pm_id_raw.isdigit():
|
||||
deleted = await delete_user_payment_method(
|
||||
session, callback.from_user.id, int(pm_id_raw)
|
||||
)
|
||||
else:
|
||||
deleted = await delete_user_payment_method_by_provider_id(
|
||||
session, callback.from_user.id, pm_id_raw
|
||||
)
|
||||
try:
|
||||
legacy_deleted = await user_billing_dal.delete_yk_payment_method(
|
||||
session, callback.from_user.id
|
||||
)
|
||||
deleted = deleted or legacy_deleted
|
||||
except Exception:
|
||||
pass
|
||||
await session.commit()
|
||||
|
||||
methods = await list_user_payment_methods(session, callback.from_user.id)
|
||||
text = _("payment_methods_title")
|
||||
cards = []
|
||||
for m in methods:
|
||||
|
||||
def _is_yoomoney_network(network: Optional[str]) -> bool:
|
||||
s = (network or "").lower()
|
||||
return "yoomoney" in s or "yoo money" in s or "yoo-money" in s
|
||||
|
||||
def _extract_last4(text: str) -> Optional[str]:
|
||||
digits = "".join(ch for ch in text if ch.isdigit())
|
||||
return digits[-4:] if len(digits) >= 4 else None
|
||||
|
||||
def _format_pm_title(network: Optional[str], last4: Optional[str]) -> str:
|
||||
if _is_yoomoney_network(network):
|
||||
l4 = last4 or _extract_last4(network or "")
|
||||
if l4:
|
||||
return _("payment_method_wallet_title", last4=l4)
|
||||
return _("payment_method_wallet_title", last4="****")
|
||||
if last4:
|
||||
network_name = network or _("payment_network_card")
|
||||
return _("payment_method_card_title", network=network_name, last4=last4)
|
||||
network_name = network or _("payment_network_generic")
|
||||
return _("payment_method_generic_title", network=network_name)
|
||||
|
||||
title = _format_pm_title(m.card_network, m.card_last4)
|
||||
cards.append((str(m.method_id), title if not m.is_default else f"⭐ {title}"))
|
||||
if not cards:
|
||||
text += "\n\n" + _("payment_method_none")
|
||||
msg = _("payment_method_deleted_success") if deleted else _("error_try_again")
|
||||
await callback.message.edit_text(
|
||||
f"{msg}\n\n{text}",
|
||||
reply_markup=get_payment_methods_list_keyboard(cards, 0, current_lang, i18n),
|
||||
)
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
try:
|
||||
await callback.answer(_("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("pm:view"))
|
||||
async def payment_method_view(
|
||||
callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not settings.yookassa_autopayments_active:
|
||||
try:
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
await callback.answer(_("error_service_unavailable"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
billing = await user_billing_dal.get_user_billing(session, callback.from_user.id)
|
||||
if not billing or not billing.yookassa_payment_method_id:
|
||||
from db.dal.user_billing_dal import list_user_payment_methods
|
||||
|
||||
methods = await list_user_payment_methods(session, callback.from_user.id)
|
||||
if not methods:
|
||||
await callback.answer(_("payment_method_none"), show_alert=True)
|
||||
return
|
||||
parts = callback.data.split(":", 2)
|
||||
pm_id = parts[2] if len(parts) >= 3 else str(methods[0].method_id)
|
||||
sel = next(
|
||||
(
|
||||
m
|
||||
for m in methods
|
||||
if str(m.method_id) == pm_id or m.provider_payment_method_id == pm_id
|
||||
),
|
||||
methods[0],
|
||||
)
|
||||
|
||||
def _is_yoomoney_network(network: Optional[str]) -> bool:
|
||||
s = (network or "").lower()
|
||||
return "yoomoney" in s or "yoo money" in s or "yoo-money" in s
|
||||
|
||||
def _extract_last4(text: str) -> Optional[str]:
|
||||
digits = "".join(ch for ch in text if ch.isdigit())
|
||||
return digits[-4:] if len(digits) >= 4 else None
|
||||
|
||||
def _format_pm_title(network: Optional[str], last4: Optional[str]) -> str:
|
||||
if _is_yoomoney_network(network):
|
||||
l4 = last4 or _extract_last4(network or "")
|
||||
if l4:
|
||||
return _("payment_method_wallet_title", last4=l4)
|
||||
return _("payment_method_wallet_title", last4="****")
|
||||
if last4:
|
||||
network_name = network or _("payment_network_card")
|
||||
return _("payment_method_card_title", network=network_name, last4=last4)
|
||||
network_name = network or _("payment_network_generic")
|
||||
return _("payment_method_generic_title", network=network_name)
|
||||
|
||||
title = _format_pm_title(sel.card_network, sel.card_last4)
|
||||
added_at = sel.created_at.strftime("%Y-%m-%d") if getattr(sel, "created_at", None) else "—"
|
||||
last_tx = "—"
|
||||
try:
|
||||
stmt = (
|
||||
select(Payment)
|
||||
.where(
|
||||
Payment.user_id == callback.from_user.id,
|
||||
Payment.status == "succeeded",
|
||||
Payment.provider == "yookassa",
|
||||
)
|
||||
.order_by(Payment.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
lp = result.scalar_one_or_none()
|
||||
if lp and lp.created_at:
|
||||
last_tx = lp.created_at.strftime("%Y-%m-%d")
|
||||
except Exception:
|
||||
pass
|
||||
details = f"{title}\n{_('payment_method_added_at', date=added_at)}\n{_('payment_method_last_tx', date=last_tx)}" # noqa: E501
|
||||
await callback.message.edit_text(
|
||||
details,
|
||||
reply_markup=get_payment_method_details_keyboard(
|
||||
str(sel.method_id), current_lang, i18n
|
||||
),
|
||||
)
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
added_at = (
|
||||
billing.created_at.strftime("%Y-%m-%d") if getattr(billing, "created_at", None) else "—"
|
||||
)
|
||||
last_tx = "—"
|
||||
try:
|
||||
stmt = (
|
||||
select(Payment)
|
||||
.where(
|
||||
Payment.user_id == callback.from_user.id,
|
||||
Payment.status == "succeeded",
|
||||
Payment.provider == "yookassa",
|
||||
)
|
||||
.order_by(Payment.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
last_payment = result.scalar_one_or_none()
|
||||
if last_payment and last_payment.created_at:
|
||||
last_tx = last_payment.created_at.strftime("%Y-%m-%d")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _is_yoomoney_network(network: Optional[str]) -> bool:
|
||||
s = (network or "").lower()
|
||||
return "yoomoney" in s or "yoo money" in s or "yoo-money" in s
|
||||
|
||||
def _extract_last4(text: str) -> Optional[str]:
|
||||
digits = "".join(ch for ch in text if ch.isdigit())
|
||||
return digits[-4:] if len(digits) >= 4 else None
|
||||
|
||||
def _format_pm_title(network: Optional[str], last4: Optional[str]) -> str:
|
||||
if _is_yoomoney_network(network):
|
||||
l4 = last4 or _extract_last4(network or "")
|
||||
if l4:
|
||||
return _("payment_method_wallet_title", last4=l4)
|
||||
return _("payment_method_wallet_title", last4="****")
|
||||
if last4:
|
||||
network_name = network or _("payment_network_card")
|
||||
return _("payment_method_card_title", network=network_name, last4=last4)
|
||||
network_name = network or _("payment_network_generic")
|
||||
return _("payment_method_generic_title", network=network_name)
|
||||
|
||||
title = _format_pm_title(billing.card_network, billing.card_last4)
|
||||
details = f"{title}\n{_('payment_method_added_at', date=added_at)}\n{_('payment_method_last_tx', date=last_tx)}" # noqa: E501
|
||||
await callback.message.edit_text(
|
||||
details,
|
||||
reply_markup=get_payment_method_details_keyboard(
|
||||
billing.yookassa_payment_method_id, current_lang, i18n
|
||||
),
|
||||
)
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("pm:history"))
|
||||
async def payment_method_history(
|
||||
callback: types.CallbackQuery,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
session: AsyncSession,
|
||||
yookassa_service: YooKassaService,
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not settings.yookassa_autopayments_active:
|
||||
try:
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
await callback.answer(_("error_service_unavailable"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
from db.dal import payment_dal
|
||||
|
||||
payments = await payment_dal.get_recent_payment_logs_with_user(session, limit=30, offset=0)
|
||||
user_payments = [p for p in payments if p.user_id == callback.from_user.id]
|
||||
|
||||
selected_pm_provider_id: Optional[str] = None
|
||||
pm_filter_requested: bool = False
|
||||
try:
|
||||
split_a, split_b, split_pm_id = callback.data.split(":", 2)
|
||||
if split_pm_id:
|
||||
pm_filter_requested = True
|
||||
if split_pm_id.isdigit():
|
||||
from db.dal.user_billing_dal import list_user_payment_methods
|
||||
|
||||
methods = await list_user_payment_methods(session, callback.from_user.id)
|
||||
sel = next((m for m in methods if str(m.method_id) == split_pm_id), None)
|
||||
if sel and sel.provider_payment_method_id:
|
||||
selected_pm_provider_id = sel.provider_payment_method_id
|
||||
else:
|
||||
selected_pm_provider_id = split_pm_id
|
||||
except Exception:
|
||||
selected_pm_provider_id = None
|
||||
pm_filter_requested = False
|
||||
|
||||
if pm_filter_requested and not selected_pm_provider_id:
|
||||
user_payments = []
|
||||
|
||||
if selected_pm_provider_id:
|
||||
filtered: List[Payment] = []
|
||||
for p in user_payments:
|
||||
if p.provider != "yookassa":
|
||||
continue
|
||||
if p.yookassa_payment_id and yookassa_service:
|
||||
try:
|
||||
info = await yookassa_service.get_payment_info(p.yookassa_payment_id)
|
||||
pm = (info or {}).get("payment_method") or {}
|
||||
if pm.get("id") == selected_pm_provider_id:
|
||||
filtered.append(p)
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
user_payments = filtered
|
||||
|
||||
if not user_payments:
|
||||
from bot.keyboards.inline.user_keyboards import (
|
||||
get_back_to_payment_method_details_keyboard,
|
||||
get_payment_methods_manage_keyboard,
|
||||
)
|
||||
|
||||
back_pm_id = ""
|
||||
try:
|
||||
split_a, split_b, back_pm_id = callback.data.split(":", 2)
|
||||
except Exception:
|
||||
back_pm_id = ""
|
||||
back_markup = (
|
||||
get_back_to_payment_method_details_keyboard(back_pm_id, current_lang, i18n)
|
||||
if back_pm_id
|
||||
else get_payment_methods_manage_keyboard(current_lang, i18n, has_card=True)
|
||||
)
|
||||
await callback.message.edit_text(_("payment_method_no_history"), reply_markup=back_markup)
|
||||
return
|
||||
|
||||
traffic_mode = getattr(settings, "traffic_sale_mode", False)
|
||||
|
||||
def _format_item(p: Payment) -> str:
|
||||
if traffic_mode:
|
||||
units_val = p.subscription_duration_months or 0
|
||||
units_display = (
|
||||
str(int(units_val)) if float(units_val).is_integer() else f"{units_val:g}"
|
||||
)
|
||||
title = p.description or _("traffic_purchase_title", traffic_gb=units_display)
|
||||
else:
|
||||
title = p.description or _(
|
||||
"subscription_purchase_title", months=p.subscription_duration_months or 1
|
||||
)
|
||||
date_str = p.created_at.strftime("%Y-%m-%d") if p.created_at else "N/A"
|
||||
return f"{date_str} — {title} — {p.amount:.2f} {p.currency}"
|
||||
|
||||
lines = [_format_item(p) for p in user_payments]
|
||||
text = _("payment_method_tx_history_title") + "\n\n" + "\n".join(lines)
|
||||
try:
|
||||
split_a, split_b, split_pm_id_for_back = callback.data.split(":", 2)
|
||||
except Exception:
|
||||
split_pm_id_for_back = ""
|
||||
from bot.keyboards.inline.user_keyboards import (
|
||||
get_back_to_payment_method_details_keyboard,
|
||||
get_payment_methods_manage_keyboard,
|
||||
)
|
||||
|
||||
back_markup = (
|
||||
get_back_to_payment_method_details_keyboard(split_pm_id_for_back, current_lang, i18n)
|
||||
if split_pm_id_for_back
|
||||
else get_payment_methods_manage_keyboard(current_lang, i18n, has_card=True)
|
||||
)
|
||||
await callback.message.edit_text(text, reply_markup=back_markup)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("pm:list:"))
|
||||
async def payment_methods_list(
|
||||
callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
from db.dal.user_billing_dal import list_user_payment_methods
|
||||
|
||||
cards: List[tuple] = []
|
||||
methods = await list_user_payment_methods(session, callback.from_user.id)
|
||||
for m in methods:
|
||||
|
||||
def _is_yoomoney_network(network: Optional[str]) -> bool:
|
||||
s = (network or "").lower()
|
||||
return "yoomoney" in s or "yoo money" in s or "yoo-money" in s
|
||||
|
||||
def _extract_last4(text: str) -> Optional[str]:
|
||||
digits = "".join(ch for ch in text if ch.isdigit())
|
||||
return digits[-4:] if len(digits) >= 4 else None
|
||||
|
||||
def _format_pm_title(network: Optional[str], last4: Optional[str]) -> str:
|
||||
if _is_yoomoney_network(network):
|
||||
l4 = last4 or _extract_last4(network or "")
|
||||
if l4:
|
||||
return get_text("payment_method_wallet_title", last4=l4)
|
||||
return get_text("payment_method_wallet_title", last4="****")
|
||||
if last4:
|
||||
network_name = network or get_text("payment_network_card")
|
||||
return get_text("payment_method_card_title", network=network_name, last4=last4)
|
||||
network_name = network or get_text("payment_network_generic")
|
||||
return get_text("payment_method_generic_title", network=network_name)
|
||||
|
||||
title = _format_pm_title(m.card_network, m.card_last4)
|
||||
cards.append((str(m.method_id), title if not m.is_default else f"⭐ {title}"))
|
||||
|
||||
try:
|
||||
_, _, page_str = callback.data.split(":", 2)
|
||||
page = int(page_str)
|
||||
except Exception:
|
||||
page = 0
|
||||
|
||||
text = get_text("payment_methods_title")
|
||||
if not cards:
|
||||
text += "\n\n" + get_text("payment_method_none")
|
||||
await callback.message.edit_text(
|
||||
text, reply_markup=get_payment_methods_list_keyboard(cards, page, current_lang, i18n)
|
||||
)
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
@@ -1,21 +1,13 @@
|
||||
from aiogram import Router
|
||||
|
||||
from .payments_crypto import router as crypto_router
|
||||
from .payments_freekassa import router as freekassa_router
|
||||
from .payments_platega import router as platega_router
|
||||
from .payments_severpay import router as severpay_router
|
||||
from .payments_stars import router as stars_router
|
||||
from bot.payment_providers import iter_unique_provider_routers
|
||||
|
||||
from .payments_subscription import router as subscription_selection_router
|
||||
from .payments_yookassa import router as yookassa_router
|
||||
|
||||
router = Router(name="user_subscription_payments_router")
|
||||
|
||||
router.include_router(subscription_selection_router)
|
||||
router.include_router(yookassa_router)
|
||||
router.include_router(freekassa_router)
|
||||
router.include_router(platega_router)
|
||||
router.include_router(severpay_router)
|
||||
router.include_router(crypto_router)
|
||||
router.include_router(stars_router)
|
||||
for provider_router in iter_unique_provider_routers():
|
||||
router.include_router(provider_router)
|
||||
|
||||
__all__ = ["router"]
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import F, Router, types
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot.keyboards.inline.user_keyboards import get_payment_url_keyboard
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.crypto_pay_service import CryptoPayService
|
||||
from config.settings import Settings
|
||||
|
||||
router = Router(name="user_subscription_payments_crypto_router")
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("pay_crypto:"))
|
||||
async def pay_crypto_callback_handler(
|
||||
callback: types.CallbackQuery,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
session: AsyncSession,
|
||||
cryptopay_service: CryptoPayService,
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
if not i18n or not callback.message:
|
||||
try:
|
||||
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
if (
|
||||
not settings.CRYPTOPAY_ENABLED
|
||||
or not cryptopay_service
|
||||
or not getattr(cryptopay_service, "configured", False)
|
||||
):
|
||||
try:
|
||||
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
_, data_payload = callback.data.split(":", 1)
|
||||
parts = data_payload.split(":")
|
||||
months = float(parts[0])
|
||||
price_amount = float(parts[1])
|
||||
sale_mode = parts[2] if len(parts) > 2 else "subscription"
|
||||
except (ValueError, IndexError):
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
user_id = callback.from_user.id
|
||||
human_value = str(int(months)) if float(months).is_integer() else f"{months:g}"
|
||||
sale_base = sale_mode.split("@", 1)[0].split("|", 1)[0]
|
||||
payment_description = (
|
||||
get_text("payment_description_traffic", traffic_gb=human_value)
|
||||
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
|
||||
else (
|
||||
get_text("payment_description_hwid_devices", count=int(months))
|
||||
if sale_base in {"hwid_device", "hwid_devices"}
|
||||
else get_text("payment_description_subscription", months=int(months))
|
||||
)
|
||||
)
|
||||
|
||||
invoice_url = await cryptopay_service.create_invoice(
|
||||
session=session,
|
||||
user_id=user_id,
|
||||
months=months,
|
||||
amount=price_amount,
|
||||
description=payment_description,
|
||||
sale_mode=sale_mode,
|
||||
)
|
||||
|
||||
if invoice_url:
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
get_text(
|
||||
key="payment_link_message_traffic"
|
||||
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
|
||||
else "payment_link_message",
|
||||
months=int(months),
|
||||
traffic_gb=human_value,
|
||||
),
|
||||
reply_markup=get_payment_url_keyboard(
|
||||
invoice_url,
|
||||
current_lang,
|
||||
i18n,
|
||||
back_callback=f"subscribe_period:{human_value}",
|
||||
back_text_key="back_to_payment_methods_button",
|
||||
),
|
||||
disable_web_page_preview=False,
|
||||
)
|
||||
except Exception:
|
||||
try:
|
||||
await callback.message.answer(
|
||||
get_text(
|
||||
key="payment_link_message_traffic"
|
||||
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
|
||||
else "payment_link_message",
|
||||
months=int(months),
|
||||
traffic_gb=human_value,
|
||||
),
|
||||
reply_markup=get_payment_url_keyboard(
|
||||
invoice_url,
|
||||
current_lang,
|
||||
i18n,
|
||||
back_callback=f"subscribe_period:{human_value}",
|
||||
back_text_key="back_to_payment_methods_button",
|
||||
),
|
||||
disable_web_page_preview=False,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
await callback.answer(get_text("error_payment_gateway"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -1,244 +0,0 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import F, Router, types
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot.keyboards.inline.user_keyboards import get_payment_url_keyboard
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.freekassa_service import FreeKassaService
|
||||
from config.settings import Settings
|
||||
from db.dal import payment_dal
|
||||
|
||||
router = Router(name="user_subscription_payments_freekassa_router")
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("pay_fk:"))
|
||||
async def pay_fk_callback_handler(
|
||||
callback: types.CallbackQuery,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
freekassa_service: FreeKassaService,
|
||||
session: AsyncSession,
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
if not i18n or not callback.message:
|
||||
try:
|
||||
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
if not freekassa_service or not freekassa_service.configured:
|
||||
logging.error("FreeKassa service is not configured or unavailable.")
|
||||
try:
|
||||
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await callback.message.edit_text(get_text("payment_service_unavailable"))
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
_, data_payload = callback.data.split(":", 1)
|
||||
parts = data_payload.split(":")
|
||||
months = float(parts[0])
|
||||
price_rub = float(parts[1])
|
||||
sale_mode = parts[2] if len(parts) > 2 else "subscription"
|
||||
except (ValueError, IndexError):
|
||||
logging.error(f"Invalid pay_fk data in callback: {callback.data}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
user_id = callback.from_user.id
|
||||
human_value = str(int(months)) if float(months).is_integer() else f"{months:g}"
|
||||
sale_base = sale_mode.split("@", 1)[0].split("|", 1)[0]
|
||||
payment_description = (
|
||||
get_text("payment_description_traffic", traffic_gb=human_value)
|
||||
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
|
||||
else (
|
||||
get_text("payment_description_hwid_devices", count=int(months))
|
||||
if sale_base in {"hwid_device", "hwid_devices"}
|
||||
else get_text("payment_description_subscription", months=int(months))
|
||||
)
|
||||
)
|
||||
currency_code = (
|
||||
getattr(freekassa_service, "default_currency", None)
|
||||
or settings.DEFAULT_CURRENCY_SYMBOL
|
||||
or "RUB"
|
||||
)
|
||||
|
||||
payment_record_payload = {
|
||||
"user_id": user_id,
|
||||
"amount": price_rub,
|
||||
"currency": currency_code,
|
||||
"status": "pending_freekassa",
|
||||
"description": payment_description,
|
||||
"subscription_duration_months": int(months) if sale_base == "subscription" else None,
|
||||
"provider": "freekassa",
|
||||
"sale_mode": sale_mode,
|
||||
"tariff_key": sale_mode.split("@", 1)[1] if "@" in sale_mode else None,
|
||||
"purchased_gb": float(months)
|
||||
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
|
||||
else None,
|
||||
"purchased_hwid_devices": int(months)
|
||||
if sale_base in {"hwid_device", "hwid_devices"}
|
||||
else None,
|
||||
}
|
||||
|
||||
try:
|
||||
payment_record = await payment_dal.create_payment_record(session, payment_record_payload)
|
||||
await session.commit()
|
||||
except Exception as e_db_create:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"FreeKassa: failed to create payment record for user {user_id}: {e_db_create}",
|
||||
exc_info=True,
|
||||
)
|
||||
try:
|
||||
await callback.message.edit_text(get_text("error_creating_payment_record"))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
success, response_data = await freekassa_service.create_order(
|
||||
payment_db_id=payment_record.payment_id,
|
||||
user_id=payment_record.user_id,
|
||||
months=months,
|
||||
amount=price_rub,
|
||||
currency=freekassa_service.default_currency,
|
||||
payment_method_id=freekassa_service.payment_method_id,
|
||||
ip_address=freekassa_service.server_ip,
|
||||
extra_params={
|
||||
"us_method": freekassa_service.payment_method_id,
|
||||
},
|
||||
)
|
||||
|
||||
if success:
|
||||
location = response_data.get("location")
|
||||
order_hash = response_data.get("orderHash")
|
||||
order_id_api = response_data.get("orderId")
|
||||
provider_identifier = order_hash or order_id_api
|
||||
|
||||
if provider_identifier:
|
||||
try:
|
||||
await payment_dal.update_provider_payment_and_status(
|
||||
session,
|
||||
payment_record.payment_id,
|
||||
str(provider_identifier),
|
||||
payment_record.status,
|
||||
)
|
||||
await session.commit()
|
||||
except Exception as e_status:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"FreeKassa: failed to store provider order id for payment {payment_record.payment_id}: {e_status}", # noqa: E501
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if location:
|
||||
order_identifier_display = str(
|
||||
order_id_api or provider_identifier or payment_record.payment_id
|
||||
)
|
||||
order_info_text = get_text(
|
||||
"free_kassa_order_info",
|
||||
order_id=order_identifier_display,
|
||||
date=datetime.now().strftime("%Y-%m-%d"),
|
||||
)
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
f"{order_info_text}\n\n"
|
||||
+ get_text(
|
||||
key="payment_link_message_traffic"
|
||||
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
|
||||
else "payment_link_message",
|
||||
months=int(months),
|
||||
traffic_gb=human_value,
|
||||
),
|
||||
reply_markup=get_payment_url_keyboard(
|
||||
location,
|
||||
current_lang,
|
||||
i18n,
|
||||
back_callback=f"subscribe_period:{human_value}",
|
||||
back_text_key="back_to_payment_methods_button",
|
||||
),
|
||||
disable_web_page_preview=False,
|
||||
)
|
||||
except Exception as e_edit:
|
||||
logging.warning(
|
||||
f"FreeKassa: failed to display payment link ({e_edit}), sending new message."
|
||||
)
|
||||
try:
|
||||
await callback.message.answer(
|
||||
f"{order_info_text}\n\n"
|
||||
+ get_text(
|
||||
key="payment_link_message_traffic"
|
||||
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
|
||||
else "payment_link_message",
|
||||
months=int(months),
|
||||
traffic_gb=human_value,
|
||||
),
|
||||
reply_markup=get_payment_url_keyboard(
|
||||
location,
|
||||
current_lang,
|
||||
i18n,
|
||||
back_callback=f"subscribe_period:{human_value}",
|
||||
back_text_key="back_to_payment_methods_button",
|
||||
),
|
||||
disable_web_page_preview=False,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
logging.error(
|
||||
"FreeKassa: create_order succeeded but no payment link returned for payment %s. Response: %s", # noqa: E501
|
||||
payment_record.payment_id,
|
||||
response_data,
|
||||
)
|
||||
else:
|
||||
logging.error(
|
||||
"FreeKassa: create_order failed for payment %s with response %s",
|
||||
payment_record.payment_id,
|
||||
response_data,
|
||||
)
|
||||
|
||||
try:
|
||||
await payment_dal.update_payment_status_by_db_id(
|
||||
session,
|
||||
payment_record.payment_id,
|
||||
"failed_creation",
|
||||
)
|
||||
await session.commit()
|
||||
except Exception as e_status:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"FreeKassa: failed to mark payment {payment_record.payment_id} as failed_creation: {e_status}", # noqa: E501
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(get_text("error_payment_gateway"))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await callback.answer(get_text("error_payment_gateway"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -1,261 +0,0 @@
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import F, Router, types
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot.keyboards.inline.user_keyboards import get_payment_url_keyboard
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.platega_service import PlategaService
|
||||
from config.settings import Settings
|
||||
from db.dal import payment_dal
|
||||
|
||||
router = Router(name="user_subscription_payments_platega_router")
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
F.data.startswith("pay_platega_sbp:")
|
||||
| F.data.startswith("pay_platega_crypto:")
|
||||
| F.data.startswith("pay_platega:")
|
||||
)
|
||||
async def pay_platega_callback_handler(
|
||||
callback: types.CallbackQuery,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
platega_service: PlategaService,
|
||||
session: AsyncSession,
|
||||
):
|
||||
callback_prefix, _, _ = (callback.data or "").partition(":")
|
||||
if callback_prefix == "pay_platega_crypto":
|
||||
platega_method_id = settings.PLATEGA_CRYPTO_METHOD
|
||||
platega_variant = "crypto"
|
||||
if not settings.PLATEGA_CRYPTO_ENABLED:
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
elif callback_prefix == "pay_platega_sbp":
|
||||
platega_method_id = settings.platega_sbp_method_resolved
|
||||
platega_variant = "sbp"
|
||||
if not settings.PLATEGA_SBP_ENABLED:
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
else:
|
||||
# Legacy callback (pre-split): keep working as SBP
|
||||
platega_method_id = settings.platega_sbp_method_resolved
|
||||
platega_variant = "sbp"
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
if not i18n or not callback.message:
|
||||
try:
|
||||
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
if not platega_service or not platega_service.configured:
|
||||
logging.error("Platega service is not configured or unavailable.")
|
||||
try:
|
||||
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await callback.message.edit_text(get_text("payment_service_unavailable"))
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
_, data_payload = callback.data.split(":", 1)
|
||||
parts = data_payload.split(":")
|
||||
months = float(parts[0])
|
||||
price_rub = float(parts[1])
|
||||
sale_mode = parts[2] if len(parts) > 2 else "subscription"
|
||||
except (ValueError, IndexError):
|
||||
logging.error(f"Invalid pay_platega data in callback: {callback.data}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
user_id = callback.from_user.id
|
||||
human_value = str(int(months)) if float(months).is_integer() else f"{months:g}"
|
||||
sale_base = sale_mode.split("@", 1)[0].split("|", 1)[0]
|
||||
payment_description = (
|
||||
get_text("payment_description_traffic", traffic_gb=human_value)
|
||||
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
|
||||
else (
|
||||
get_text("payment_description_hwid_devices", count=int(months))
|
||||
if sale_base in {"hwid_device", "hwid_devices"}
|
||||
else get_text("payment_description_subscription", months=int(months))
|
||||
)
|
||||
)
|
||||
currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
|
||||
|
||||
payment_record_payload = {
|
||||
"user_id": user_id,
|
||||
"amount": price_rub,
|
||||
"currency": currency_code,
|
||||
"status": "pending_platega",
|
||||
"description": payment_description,
|
||||
"subscription_duration_months": int(months) if sale_base == "subscription" else None,
|
||||
"provider": "platega",
|
||||
"sale_mode": sale_mode,
|
||||
"tariff_key": sale_mode.split("@", 1)[1] if "@" in sale_mode else None,
|
||||
"purchased_gb": float(months)
|
||||
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
|
||||
else None,
|
||||
"purchased_hwid_devices": int(months)
|
||||
if sale_base in {"hwid_device", "hwid_devices"}
|
||||
else None,
|
||||
}
|
||||
|
||||
try:
|
||||
payment_record = await payment_dal.create_payment_record(session, payment_record_payload)
|
||||
await session.commit()
|
||||
except Exception as e_db_create:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"Platega: failed to create payment record for user {user_id}: {e_db_create}",
|
||||
exc_info=True,
|
||||
)
|
||||
try:
|
||||
await callback.message.edit_text(get_text("error_creating_payment_record"))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
payload_meta = json.dumps(
|
||||
{
|
||||
"payment_db_id": payment_record.payment_id,
|
||||
"user_id": user_id,
|
||||
"months": months,
|
||||
"sale_mode": sale_mode,
|
||||
"platega_variant": platega_variant,
|
||||
}
|
||||
)
|
||||
|
||||
success, response_data = await platega_service.create_transaction(
|
||||
payment_db_id=payment_record.payment_id,
|
||||
user_id=user_id,
|
||||
months=months,
|
||||
amount=price_rub,
|
||||
currency=currency_code,
|
||||
description=payment_description,
|
||||
payload=payload_meta,
|
||||
payment_method=platega_method_id,
|
||||
)
|
||||
|
||||
if success:
|
||||
transaction_id = response_data.get("transactionId") or response_data.get("id")
|
||||
redirect_url = (
|
||||
response_data.get("redirect")
|
||||
or response_data.get("url")
|
||||
or response_data.get("paymentUrl")
|
||||
)
|
||||
provider_status = response_data.get("status", payment_record.status)
|
||||
|
||||
if transaction_id and redirect_url:
|
||||
try:
|
||||
await payment_dal.update_provider_payment_and_status(
|
||||
session,
|
||||
payment_record.payment_id,
|
||||
str(transaction_id),
|
||||
str(provider_status),
|
||||
)
|
||||
await session.commit()
|
||||
except Exception as e_status:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"Platega: failed to store transaction id for payment {payment_record.payment_id}: {e_status}", # noqa: E501
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
get_text(
|
||||
key="payment_link_message_traffic"
|
||||
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
|
||||
else "payment_link_message",
|
||||
months=int(months),
|
||||
traffic_gb=human_value,
|
||||
),
|
||||
reply_markup=get_payment_url_keyboard(
|
||||
redirect_url,
|
||||
current_lang,
|
||||
i18n,
|
||||
back_callback=f"subscribe_period:{human_value}",
|
||||
back_text_key="back_to_payment_methods_button",
|
||||
),
|
||||
disable_web_page_preview=False,
|
||||
)
|
||||
except Exception as e_edit:
|
||||
logging.warning(
|
||||
f"Platega: failed to display payment link ({e_edit}), sending new message."
|
||||
)
|
||||
try:
|
||||
await callback.message.answer(
|
||||
get_text(
|
||||
key="payment_link_message_traffic"
|
||||
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
|
||||
else "payment_link_message",
|
||||
months=int(months),
|
||||
traffic_gb=human_value,
|
||||
),
|
||||
reply_markup=get_payment_url_keyboard(
|
||||
redirect_url,
|
||||
current_lang,
|
||||
i18n,
|
||||
back_callback=f"subscribe_period:{human_value}",
|
||||
back_text_key="back_to_payment_methods_button",
|
||||
),
|
||||
disable_web_page_preview=False,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
logging.error(
|
||||
"Platega: transaction created but missing transaction id or payment link for payment %s. Response: %s", # noqa: E501
|
||||
payment_record.payment_id,
|
||||
response_data,
|
||||
)
|
||||
|
||||
try:
|
||||
await payment_dal.update_payment_status_by_db_id(
|
||||
session,
|
||||
payment_record.payment_id,
|
||||
"failed_creation",
|
||||
)
|
||||
await session.commit()
|
||||
except Exception as e_status:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"Platega: failed to mark payment {payment_record.payment_id} as failed_creation: {e_status}", # noqa: E501
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(get_text("error_payment_gateway"))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await callback.answer(get_text("error_payment_gateway"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -1,221 +0,0 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import F, Router, types
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot.keyboards.inline.user_keyboards import get_payment_url_keyboard
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.severpay_service import SeverPayService
|
||||
from config.settings import Settings
|
||||
from db.dal import payment_dal
|
||||
|
||||
router = Router(name="user_subscription_payments_severpay_router")
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("pay_severpay:"))
|
||||
async def pay_severpay_callback_handler(
|
||||
callback: types.CallbackQuery,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
severpay_service: SeverPayService,
|
||||
session: AsyncSession,
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
if not i18n or not callback.message:
|
||||
try:
|
||||
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
if not severpay_service or not severpay_service.configured:
|
||||
logging.error("SeverPay service is not configured or unavailable.")
|
||||
try:
|
||||
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await callback.message.edit_text(get_text("payment_service_unavailable"))
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
_, data_payload = callback.data.split(":", 1)
|
||||
parts = data_payload.split(":")
|
||||
months = float(parts[0])
|
||||
price_rub = float(parts[1])
|
||||
sale_mode = parts[2] if len(parts) > 2 else "subscription"
|
||||
except (ValueError, IndexError):
|
||||
logging.error(f"Invalid pay_severpay data in callback: {callback.data}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
user_id = callback.from_user.id
|
||||
human_value = str(int(months)) if float(months).is_integer() else f"{months:g}"
|
||||
sale_base = sale_mode.split("@", 1)[0].split("|", 1)[0]
|
||||
payment_description = (
|
||||
get_text("payment_description_traffic", traffic_gb=human_value)
|
||||
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
|
||||
else (
|
||||
get_text("payment_description_hwid_devices", count=int(months))
|
||||
if sale_base in {"hwid_device", "hwid_devices"}
|
||||
else get_text("payment_description_subscription", months=int(months))
|
||||
)
|
||||
)
|
||||
currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
|
||||
|
||||
payment_record_payload = {
|
||||
"user_id": user_id,
|
||||
"amount": price_rub,
|
||||
"currency": currency_code,
|
||||
"status": "pending_severpay",
|
||||
"description": payment_description,
|
||||
"subscription_duration_months": int(months) if sale_base == "subscription" else None,
|
||||
"provider": "severpay",
|
||||
"sale_mode": sale_mode,
|
||||
"tariff_key": sale_mode.split("@", 1)[1] if "@" in sale_mode else None,
|
||||
"purchased_gb": float(months)
|
||||
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
|
||||
else None,
|
||||
"purchased_hwid_devices": int(months)
|
||||
if sale_base in {"hwid_device", "hwid_devices"}
|
||||
else None,
|
||||
}
|
||||
|
||||
try:
|
||||
payment_record = await payment_dal.create_payment_record(session, payment_record_payload)
|
||||
await session.commit()
|
||||
except Exception as e_db_create:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"SeverPay: failed to create payment record for user {user_id}: {e_db_create}",
|
||||
exc_info=True,
|
||||
)
|
||||
try:
|
||||
await callback.message.edit_text(get_text("error_creating_payment_record"))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
success, response_data = await severpay_service.create_payment(
|
||||
payment_db_id=payment_record.payment_id,
|
||||
user_id=user_id,
|
||||
months=months,
|
||||
amount=price_rub,
|
||||
currency=currency_code,
|
||||
description=payment_description,
|
||||
)
|
||||
|
||||
if success:
|
||||
payment_link = (
|
||||
response_data.get("url")
|
||||
or response_data.get("payment_url")
|
||||
or response_data.get("paymentUrl")
|
||||
)
|
||||
provider_identifier = response_data.get("id") or response_data.get("uid")
|
||||
|
||||
if provider_identifier:
|
||||
try:
|
||||
await payment_dal.update_provider_payment_and_status(
|
||||
session,
|
||||
payment_record.payment_id,
|
||||
str(provider_identifier),
|
||||
payment_record.status,
|
||||
)
|
||||
await session.commit()
|
||||
except Exception as e_status:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"SeverPay: failed to store provider payment id for payment {payment_record.payment_id}: {e_status}", # noqa: E501
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if payment_link:
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
get_text(
|
||||
key="payment_link_message_traffic"
|
||||
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
|
||||
else "payment_link_message",
|
||||
months=int(months),
|
||||
traffic_gb=human_value,
|
||||
),
|
||||
reply_markup=get_payment_url_keyboard(
|
||||
payment_link,
|
||||
current_lang,
|
||||
i18n,
|
||||
back_callback=f"subscribe_period:{human_value}",
|
||||
back_text_key="back_to_payment_methods_button",
|
||||
),
|
||||
disable_web_page_preview=False,
|
||||
)
|
||||
except Exception as e_edit:
|
||||
logging.warning(
|
||||
f"SeverPay: failed to display payment link ({e_edit}), sending new message."
|
||||
)
|
||||
try:
|
||||
await callback.message.answer(
|
||||
get_text(
|
||||
key="payment_link_message_traffic"
|
||||
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
|
||||
else "payment_link_message",
|
||||
months=int(months),
|
||||
traffic_gb=human_value,
|
||||
),
|
||||
reply_markup=get_payment_url_keyboard(
|
||||
payment_link,
|
||||
current_lang,
|
||||
i18n,
|
||||
back_callback=f"subscribe_period:{human_value}",
|
||||
back_text_key="back_to_payment_methods_button",
|
||||
),
|
||||
disable_web_page_preview=False,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
logging.error(
|
||||
"SeverPay: payment created but missing payment link for payment %s. Response: %s",
|
||||
payment_record.payment_id,
|
||||
response_data,
|
||||
)
|
||||
|
||||
try:
|
||||
await payment_dal.update_payment_status_by_db_id(
|
||||
session,
|
||||
payment_record.payment_id,
|
||||
"failed_creation",
|
||||
)
|
||||
await session.commit()
|
||||
except Exception as e_status:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"SeverPay: failed to mark payment {payment_record.payment_id} as failed_creation: {e_status}", # noqa: E501
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(get_text("error_payment_gateway"))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await callback.answer(get_text("error_payment_gateway"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -1,148 +0,0 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import F, Router, types
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.stars_service import StarsService
|
||||
from config.settings import Settings
|
||||
|
||||
router = Router(name="user_subscription_payments_stars_router")
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("pay_stars:"))
|
||||
async def pay_stars_callback_handler(
|
||||
callback: types.CallbackQuery,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
session: AsyncSession,
|
||||
stars_service: StarsService,
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
if not i18n or not callback.message:
|
||||
try:
|
||||
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
if not settings.STARS_ENABLED:
|
||||
try:
|
||||
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
_, data_payload = callback.data.split(":", 1)
|
||||
parts = data_payload.split(":")
|
||||
months = float(parts[0])
|
||||
stars_price = int(float(parts[1]))
|
||||
sale_mode = parts[2] if len(parts) > 2 else "subscription"
|
||||
except (ValueError, IndexError):
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
user_id = callback.from_user.id
|
||||
human_value = str(int(months)) if float(months).is_integer() else f"{months:g}"
|
||||
sale_base = sale_mode.split("@", 1)[0].split("|", 1)[0]
|
||||
payment_description = (
|
||||
get_text("payment_description_traffic", traffic_gb=human_value)
|
||||
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
|
||||
else (
|
||||
get_text("payment_description_hwid_devices", count=int(months))
|
||||
if sale_base in {"hwid_device", "hwid_devices"}
|
||||
else get_text("payment_description_subscription", months=int(months))
|
||||
)
|
||||
)
|
||||
|
||||
payment_db_id = await stars_service.create_invoice(
|
||||
session=session,
|
||||
user_id=user_id,
|
||||
months=months,
|
||||
stars_price=stars_price,
|
||||
description=payment_description,
|
||||
sale_mode=sale_mode,
|
||||
)
|
||||
|
||||
if payment_db_id:
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
get_text(
|
||||
"payment_invoice_sent_message_traffic"
|
||||
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
|
||||
else "payment_invoice_sent_message",
|
||||
months=int(months),
|
||||
traffic_gb=human_value,
|
||||
),
|
||||
reply_markup=InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=get_text("back_to_payment_methods_button"),
|
||||
callback_data=f"subscribe_period:{human_value}",
|
||||
)
|
||||
]
|
||||
]
|
||||
),
|
||||
)
|
||||
except Exception as e_edit:
|
||||
logging.warning(f"Stars payment: failed to show invoice info message ({e_edit})")
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
await callback.answer(get_text("error_payment_gateway"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@router.pre_checkout_query()
|
||||
async def handle_pre_checkout_query(query: types.PreCheckoutQuery):
|
||||
try:
|
||||
await query.answer(ok=True)
|
||||
except Exception:
|
||||
# Nothing else to do here; Telegram will show an error if not answered
|
||||
pass
|
||||
|
||||
|
||||
@router.message(F.successful_payment)
|
||||
async def handle_successful_stars_payment(
|
||||
message: types.Message,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
session: AsyncSession,
|
||||
stars_service: StarsService,
|
||||
):
|
||||
payload = (
|
||||
message.successful_payment.invoice_payload if message and message.successful_payment else ""
|
||||
)
|
||||
try:
|
||||
parts = (payload or "").split(":")
|
||||
payment_db_id = int(parts[0])
|
||||
months = float(parts[1]) if len(parts) > 1 else 0
|
||||
sale_mode = parts[2] if len(parts) > 2 else "subscription"
|
||||
except Exception:
|
||||
return
|
||||
|
||||
stars_amount = int(message.successful_payment.total_amount) if message.successful_payment else 0
|
||||
await stars_service.process_successful_payment(
|
||||
session=session,
|
||||
message=message,
|
||||
payment_db_id=payment_db_id,
|
||||
months=months,
|
||||
stars_amount=stars_amount,
|
||||
i18n_data=i18n_data,
|
||||
sale_mode=sale_mode,
|
||||
)
|
||||
@@ -4,7 +4,11 @@ from typing import Optional
|
||||
from aiogram import F, Router, types
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot.keyboards.inline.user_keyboards import get_payment_method_keyboard
|
||||
from bot.keyboards.inline.user_keyboards import (
|
||||
get_payment_method_keyboard,
|
||||
sale_mode_with_callback_context,
|
||||
subscription_options_callback,
|
||||
)
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from config.settings import Settings
|
||||
|
||||
@@ -32,8 +36,10 @@ async def select_subscription_period_callback_handler(
|
||||
traffic_packages = getattr(settings, "traffic_packages", {}) or {}
|
||||
stars_traffic_packages = getattr(settings, "stars_traffic_packages", {}) or {}
|
||||
traffic_mode = bool(getattr(settings, "traffic_sale_mode", False) or stars_traffic_packages)
|
||||
parts = callback.data.split(":")
|
||||
callback_context = parts[2] if len(parts) > 2 else None
|
||||
try:
|
||||
months = float(callback.data.split(":")[-1])
|
||||
months = float(parts[1])
|
||||
except (ValueError, IndexError):
|
||||
logging.error(f"Invalid subscription period in callback_data: {callback.data}")
|
||||
try:
|
||||
@@ -53,14 +59,16 @@ async def select_subscription_period_callback_handler(
|
||||
|
||||
if price_rub is None:
|
||||
if traffic_mode and not price_source and stars_price is not None:
|
||||
from bot.payment_providers import iter_provider_specs
|
||||
|
||||
currency_methods_enabled = any(
|
||||
[
|
||||
settings.FREEKASSA_ENABLED,
|
||||
settings.PLATEGA_ENABLED,
|
||||
settings.SEVERPAY_ENABLED,
|
||||
settings.YOOKASSA_ENABLED,
|
||||
settings.CRYPTOPAY_ENABLED,
|
||||
]
|
||||
spec.price_source != "stars"
|
||||
and spec.is_available_to_user(
|
||||
settings,
|
||||
user_id=callback.from_user.id,
|
||||
require_configured=False,
|
||||
)
|
||||
for spec in iter_provider_specs()
|
||||
)
|
||||
if currency_methods_enabled:
|
||||
logging.error(
|
||||
@@ -97,7 +105,11 @@ async def select_subscription_period_callback_handler(
|
||||
current_lang,
|
||||
i18n,
|
||||
settings,
|
||||
sale_mode="traffic" if traffic_mode else "subscription",
|
||||
sale_mode=sale_mode_with_callback_context(
|
||||
"traffic" if traffic_mode else "subscription", callback_context
|
||||
),
|
||||
back_callback=subscription_options_callback(callback_context),
|
||||
user_id=callback.from_user.id,
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
@@ -1,842 +0,0 @@
|
||||
import logging
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from aiogram import F, Router, types
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot.keyboards.inline.user_keyboards import (
|
||||
get_back_to_main_menu_markup,
|
||||
get_payment_url_keyboard,
|
||||
get_yk_autopay_choice_keyboard,
|
||||
get_yk_saved_cards_keyboard,
|
||||
)
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.yookassa_service import YooKassaService
|
||||
from config.settings import Settings
|
||||
from db.dal import payment_dal, user_billing_dal
|
||||
|
||||
router = Router(name="user_subscription_payments_yookassa_router")
|
||||
|
||||
|
||||
def _format_value(val: float) -> str:
|
||||
return str(int(val)) if float(val).is_integer() else f"{val:g}"
|
||||
|
||||
|
||||
def _parse_offer_payload(payload: str) -> Optional[Tuple[float, float, str]]:
|
||||
try:
|
||||
parts = payload.split(":")
|
||||
value = float(parts[0])
|
||||
price = float(parts[1])
|
||||
sale_mode = parts[2] if len(parts) > 2 else "subscription"
|
||||
return value, price, sale_mode
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
|
||||
|
||||
def _sale_mode_base(sale_mode: str) -> str:
|
||||
return (sale_mode or "subscription").split("@", 1)[0].split("|", 1)[0]
|
||||
|
||||
|
||||
def _format_saved_payment_method_title(
|
||||
get_text, network: Optional[str], last4: Optional[str], is_default: bool
|
||||
) -> str:
|
||||
def _is_yoomoney_network(name: Optional[str]) -> bool:
|
||||
s = (name or "").lower()
|
||||
return "yoomoney" in s or "yoo money" in s or "yoo-money" in s
|
||||
|
||||
def _extract_last4(text: str) -> Optional[str]:
|
||||
digits = "".join(ch for ch in text if ch.isdigit())
|
||||
return digits[-4:] if len(digits) >= 4 else None
|
||||
|
||||
if _is_yoomoney_network(network):
|
||||
inferred_last4 = last4 or (_extract_last4(network or "") or "****")
|
||||
title = get_text("payment_method_wallet_title", last4=inferred_last4)
|
||||
elif last4:
|
||||
network_name = network or get_text("payment_network_card")
|
||||
title = get_text("payment_method_card_title", network=network_name, last4=last4)
|
||||
else:
|
||||
network_name = network or get_text("payment_network_generic")
|
||||
title = get_text("payment_method_generic_title", network=network_name)
|
||||
return f"⭐ {title}" if is_default else title
|
||||
|
||||
|
||||
async def _initiate_yk_payment(
|
||||
callback: types.CallbackQuery,
|
||||
*,
|
||||
settings: Settings,
|
||||
session: AsyncSession,
|
||||
yookassa_service: YooKassaService,
|
||||
i18n: Optional[JsonI18n],
|
||||
current_lang: str,
|
||||
get_text,
|
||||
user_id: int,
|
||||
months: int,
|
||||
price_rub: float,
|
||||
currency_code_for_yk: str,
|
||||
save_payment_method: bool,
|
||||
back_callback: str,
|
||||
payment_method_id: Optional[str] = None,
|
||||
selected_method_internal_id: Optional[int] = None,
|
||||
sale_mode: str = "subscription",
|
||||
) -> bool:
|
||||
"""Create payment record and initiate YooKassa payment (new card or saved card)."""
|
||||
if not callback.message:
|
||||
return False
|
||||
|
||||
sale_base = _sale_mode_base(sale_mode)
|
||||
payment_description = (
|
||||
get_text("payment_description_traffic", traffic_gb=_format_value(months))
|
||||
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
|
||||
else (
|
||||
get_text("payment_description_hwid_devices", count=int(months))
|
||||
if sale_base in {"hwid_device", "hwid_devices"}
|
||||
else get_text("payment_description_subscription", months=int(months))
|
||||
)
|
||||
)
|
||||
payment_record_data = {
|
||||
"user_id": user_id,
|
||||
"amount": price_rub,
|
||||
"currency": currency_code_for_yk,
|
||||
"status": "pending_yookassa",
|
||||
"description": payment_description,
|
||||
"subscription_duration_months": int(months) if sale_base == "subscription" else None,
|
||||
"sale_mode": sale_base,
|
||||
"tariff_key": sale_mode.split("@", 1)[1] if "@" in sale_mode else None,
|
||||
"purchased_gb": float(months)
|
||||
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
|
||||
else None,
|
||||
"purchased_hwid_devices": int(months)
|
||||
if sale_base in {"hwid_device", "hwid_devices"}
|
||||
else None,
|
||||
}
|
||||
|
||||
db_payment_record = None
|
||||
try:
|
||||
db_payment_record = await payment_dal.create_payment_record(session, payment_record_data)
|
||||
await session.commit()
|
||||
logging.info(
|
||||
f"Payment record {db_payment_record.payment_id} created for user {user_id} with status 'pending_yookassa'." # noqa: E501
|
||||
)
|
||||
except Exception as e_db_payment:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"Failed to create payment record in DB for user {user_id}: {e_db_payment}",
|
||||
exc_info=True,
|
||||
)
|
||||
try:
|
||||
await callback.message.edit_text(get_text("error_creating_payment_record"))
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
if not db_payment_record:
|
||||
try:
|
||||
await callback.message.edit_text(get_text("error_creating_payment_record"))
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
yookassa_metadata = {
|
||||
"user_id": str(user_id),
|
||||
"subscription_months": str(months),
|
||||
"payment_db_id": str(db_payment_record.payment_id),
|
||||
"sale_mode": sale_mode,
|
||||
}
|
||||
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}:
|
||||
yookassa_metadata["traffic_gb"] = str(months)
|
||||
if payment_method_id:
|
||||
yookassa_metadata["used_saved_payment_method_id"] = payment_method_id
|
||||
|
||||
receipt_email_for_yk = settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL
|
||||
|
||||
payment_response_yk = await yookassa_service.create_payment(
|
||||
amount=price_rub,
|
||||
currency=currency_code_for_yk,
|
||||
description=payment_description,
|
||||
metadata=yookassa_metadata,
|
||||
receipt_email=receipt_email_for_yk,
|
||||
save_payment_method=save_payment_method,
|
||||
payment_method_id=payment_method_id,
|
||||
)
|
||||
|
||||
if payment_response_yk and payment_response_yk.get("confirmation_url"):
|
||||
pm = payment_response_yk.get("payment_method")
|
||||
try:
|
||||
if pm and pm.get("id"):
|
||||
pm_type = pm.get("type")
|
||||
title = pm.get("title")
|
||||
card = pm.get("card") or {}
|
||||
account_number = pm.get("account_number") or pm.get("account")
|
||||
if isinstance(card, dict) and (pm_type or "").lower() in {
|
||||
"bank_card",
|
||||
"bank-card",
|
||||
"card",
|
||||
}:
|
||||
display_network = card.get("card_type") or title or "Card"
|
||||
display_last4 = card.get("last4")
|
||||
elif (pm_type or "").lower() in {"yoo_money", "yoomoney", "yoo-money", "wallet"}:
|
||||
display_network = "YooMoney"
|
||||
display_last4 = (
|
||||
account_number[-4:]
|
||||
if isinstance(account_number, str) and len(account_number) >= 4
|
||||
else None
|
||||
)
|
||||
else:
|
||||
display_network = title or (pm_type.upper() if pm_type else "Payment method")
|
||||
display_last4 = None
|
||||
await user_billing_dal.upsert_yk_payment_method(
|
||||
session,
|
||||
user_id=user_id,
|
||||
payment_method_id=pm["id"],
|
||||
card_last4=display_last4,
|
||||
card_network=display_network,
|
||||
)
|
||||
try:
|
||||
await user_billing_dal.upsert_user_payment_method(
|
||||
session,
|
||||
user_id=user_id,
|
||||
provider_payment_method_id=pm["id"],
|
||||
provider="yookassa",
|
||||
card_last4=display_last4,
|
||||
card_network=display_network,
|
||||
set_default=save_payment_method,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logging.exception("Failed to save YooKassa payment method preliminarily")
|
||||
try:
|
||||
await payment_dal.update_payment_status_by_db_id(
|
||||
session,
|
||||
payment_db_id=db_payment_record.payment_id,
|
||||
new_status=payment_response_yk.get("status", "pending"),
|
||||
yk_payment_id=payment_response_yk.get("id"),
|
||||
)
|
||||
if selected_method_internal_id is not None:
|
||||
try:
|
||||
await user_billing_dal.set_user_default_payment_method(
|
||||
session, user_id, selected_method_internal_id
|
||||
)
|
||||
except Exception:
|
||||
logging.exception(
|
||||
"Failed to set default payment method after initiating payment"
|
||||
)
|
||||
await session.commit()
|
||||
except Exception as e_db_update_ykid:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"Failed to update payment record {db_payment_record.payment_id} with YK ID: {e_db_update_ykid}", # noqa: E501
|
||||
exc_info=True,
|
||||
)
|
||||
try:
|
||||
await callback.message.edit_text(get_text("error_payment_gateway_link_failed"))
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
get_text(
|
||||
key="payment_link_message_traffic"
|
||||
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
|
||||
else "payment_link_message",
|
||||
months=int(months),
|
||||
traffic_gb=_format_value(months),
|
||||
),
|
||||
reply_markup=get_payment_url_keyboard(
|
||||
payment_response_yk["confirmation_url"],
|
||||
current_lang,
|
||||
i18n,
|
||||
back_callback=back_callback,
|
||||
back_text_key="back_to_payment_methods_button",
|
||||
),
|
||||
disable_web_page_preview=False,
|
||||
)
|
||||
except Exception as e_edit:
|
||||
logging.warning(f"Edit message for payment link failed: {e_edit}. Sending new one.")
|
||||
try:
|
||||
await callback.message.answer(
|
||||
get_text(
|
||||
key="payment_link_message_traffic"
|
||||
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
|
||||
else "payment_link_message",
|
||||
months=int(months),
|
||||
traffic_gb=_format_value(months),
|
||||
),
|
||||
reply_markup=get_payment_url_keyboard(
|
||||
payment_response_yk["confirmation_url"],
|
||||
current_lang,
|
||||
i18n,
|
||||
back_callback=back_callback,
|
||||
back_text_key="back_to_payment_methods_button",
|
||||
),
|
||||
disable_web_page_preview=False,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
|
||||
if payment_response_yk and payment_method_id:
|
||||
status_to_store = payment_response_yk.get("status", "pending")
|
||||
try:
|
||||
await payment_dal.update_payment_status_by_db_id(
|
||||
session,
|
||||
payment_db_id=db_payment_record.payment_id,
|
||||
new_status=status_to_store,
|
||||
yk_payment_id=payment_response_yk.get("id"),
|
||||
)
|
||||
if selected_method_internal_id is not None:
|
||||
try:
|
||||
await user_billing_dal.set_user_default_payment_method(
|
||||
session, user_id, selected_method_internal_id
|
||||
)
|
||||
except Exception:
|
||||
logging.exception(
|
||||
"Failed to set default payment method after saved-card payment start"
|
||||
)
|
||||
await session.commit()
|
||||
except Exception as e_db_update_saved:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"Failed to update saved-card payment record {db_payment_record.payment_id}: {e_db_update_saved}", # noqa: E501
|
||||
exc_info=True,
|
||||
)
|
||||
try:
|
||||
await callback.message.edit_text(get_text("error_payment_gateway"))
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
message_text = get_text("yookassa_autopay_charge_initiated")
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=get_back_to_main_menu_markup(current_lang, i18n),
|
||||
)
|
||||
except Exception as e_edit:
|
||||
logging.warning(f"Failed to notify about saved-card charge start: {e_edit}")
|
||||
try:
|
||||
await callback.message.answer(
|
||||
message_text,
|
||||
reply_markup=get_back_to_main_menu_markup(current_lang, i18n),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
|
||||
try:
|
||||
await payment_dal.update_payment_status_by_db_id(
|
||||
session, db_payment_record.payment_id, "failed_creation"
|
||||
)
|
||||
await session.commit()
|
||||
except Exception as e_db_fail_create:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"Additionally failed to update payment record to 'failed_creation': {e_db_fail_create}", # noqa: E501
|
||||
exc_info=True,
|
||||
)
|
||||
logging.error(
|
||||
f"Failed to create payment in YooKassa for user {user_id}, payment_db_id {db_payment_record.payment_id}. Response: {payment_response_yk}" # noqa: E501
|
||||
)
|
||||
try:
|
||||
await callback.message.edit_text(get_text("error_payment_gateway"))
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("pay_yk:"))
|
||||
async def pay_yk_callback_handler(
|
||||
callback: types.CallbackQuery,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
yookassa_service: YooKassaService,
|
||||
session: AsyncSession,
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
if not i18n or not callback.message:
|
||||
try:
|
||||
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
if not yookassa_service or not yookassa_service.configured:
|
||||
logging.error("YooKassa service is not configured or unavailable.")
|
||||
target_msg_edit = callback.message
|
||||
await target_msg_edit.edit_text(get_text("payment_service_unavailable"))
|
||||
try:
|
||||
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
_, data_payload = callback.data.split(":", 1)
|
||||
except ValueError:
|
||||
logging.error(f"Invalid pay_yk data in callback: {callback.data}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
parsed = _parse_offer_payload(data_payload)
|
||||
if not parsed:
|
||||
logging.error(f"Invalid pay_yk payload structure: {callback.data}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
months, price_rub, sale_mode = parsed
|
||||
user_id = callback.from_user.id
|
||||
currency_code_for_yk = "RUB"
|
||||
autopay_enabled = bool(
|
||||
settings.yookassa_autopayments_active
|
||||
and _sale_mode_base(sale_mode) == "subscription"
|
||||
and not settings.traffic_sale_mode
|
||||
)
|
||||
autopay_require_binding = bool(
|
||||
getattr(settings, "YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING", True)
|
||||
)
|
||||
saved_methods: List = []
|
||||
if autopay_enabled:
|
||||
try:
|
||||
saved_methods = await user_billing_dal.list_user_payment_methods(
|
||||
session, user_id, provider="yookassa"
|
||||
)
|
||||
except Exception as e_list:
|
||||
logging.exception(f"Failed to load saved payment methods for user {user_id}: {e_list}")
|
||||
saved_methods = []
|
||||
|
||||
if autopay_enabled and saved_methods:
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
get_text("yookassa_autopay_flow_prompt"),
|
||||
reply_markup=get_yk_autopay_choice_keyboard(
|
||||
months,
|
||||
price_rub,
|
||||
current_lang,
|
||||
i18n,
|
||||
has_saved_cards=True,
|
||||
sale_mode=sale_mode,
|
||||
),
|
||||
)
|
||||
except Exception as e_edit:
|
||||
logging.warning(f"Failed to show autopay choice: {e_edit}. Sending new message.")
|
||||
try:
|
||||
await callback.message.answer(
|
||||
get_text("yookassa_autopay_flow_prompt"),
|
||||
reply_markup=get_yk_autopay_choice_keyboard(
|
||||
months,
|
||||
price_rub,
|
||||
current_lang,
|
||||
i18n,
|
||||
has_saved_cards=True,
|
||||
sale_mode=sale_mode,
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
await _initiate_yk_payment(
|
||||
callback,
|
||||
settings=settings,
|
||||
session=session,
|
||||
yookassa_service=yookassa_service,
|
||||
i18n=i18n,
|
||||
current_lang=current_lang,
|
||||
get_text=get_text,
|
||||
user_id=user_id,
|
||||
months=months,
|
||||
price_rub=price_rub,
|
||||
currency_code_for_yk=currency_code_for_yk,
|
||||
save_payment_method=autopay_enabled and autopay_require_binding,
|
||||
back_callback=f"subscribe_period:{_format_value(months)}",
|
||||
sale_mode=sale_mode,
|
||||
)
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("pay_yk_new:"))
|
||||
async def pay_yk_new_card_handler(
|
||||
callback: types.CallbackQuery,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
yookassa_service: YooKassaService,
|
||||
session: AsyncSession,
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
if not i18n or not callback.message:
|
||||
try:
|
||||
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
if not yookassa_service or not yookassa_service.configured:
|
||||
logging.error("YooKassa service unavailable for pay_yk_new.")
|
||||
try:
|
||||
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await callback.message.edit_text(get_text("payment_service_unavailable"))
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
_, data_payload = callback.data.split(":", 1)
|
||||
except ValueError:
|
||||
logging.error(f"Invalid pay_yk_new data in callback: {callback.data}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
parsed = _parse_offer_payload(data_payload)
|
||||
if not parsed:
|
||||
logging.error(f"Invalid pay_yk_new payload structure: {callback.data}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
months, price_rub, sale_mode = parsed
|
||||
user_id = callback.from_user.id
|
||||
currency_code_for_yk = "RUB"
|
||||
autopay_enabled = bool(
|
||||
settings.yookassa_autopayments_active
|
||||
and _sale_mode_base(sale_mode) == "subscription"
|
||||
and not settings.traffic_sale_mode
|
||||
)
|
||||
autopay_require_binding = bool(
|
||||
getattr(settings, "YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING", True)
|
||||
)
|
||||
|
||||
await _initiate_yk_payment(
|
||||
callback,
|
||||
settings=settings,
|
||||
session=session,
|
||||
yookassa_service=yookassa_service,
|
||||
i18n=i18n,
|
||||
current_lang=current_lang,
|
||||
get_text=get_text,
|
||||
user_id=user_id,
|
||||
months=months,
|
||||
price_rub=price_rub,
|
||||
currency_code_for_yk=currency_code_for_yk,
|
||||
save_payment_method=autopay_enabled and autopay_require_binding,
|
||||
back_callback=f"subscribe_period:{_format_value(months)}",
|
||||
sale_mode=sale_mode,
|
||||
)
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("pay_yk_saved_list:"))
|
||||
async def pay_yk_saved_list_handler(
|
||||
callback: types.CallbackQuery,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
yookassa_service: YooKassaService,
|
||||
session: AsyncSession,
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
if not i18n or not callback.message:
|
||||
try:
|
||||
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
_, data_payload = callback.data.split(":", 1)
|
||||
except ValueError:
|
||||
logging.error(f"Invalid pay_yk_saved_list data: {callback.data}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
parts = data_payload.split(":")
|
||||
if len(parts) < 2:
|
||||
logging.error(f"pay_yk_saved_list payload missing components: {callback.data}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
months = float(parts[0])
|
||||
price_rub = float(parts[1])
|
||||
page = int(parts[2]) if len(parts) > 2 else 0
|
||||
sale_mode = parts[3] if len(parts) > 3 else "subscription"
|
||||
except (ValueError, IndexError):
|
||||
logging.error(f"pay_yk_saved_list payload parsing error: {callback.data}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
autopay_enabled = bool(
|
||||
settings.yookassa_autopayments_active
|
||||
and _sale_mode_base(sale_mode) == "subscription"
|
||||
and not settings.traffic_sale_mode
|
||||
)
|
||||
if not autopay_enabled:
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
user_id = callback.from_user.id
|
||||
try:
|
||||
saved_methods = await user_billing_dal.list_user_payment_methods(
|
||||
session, user_id, provider="yookassa"
|
||||
)
|
||||
except Exception as e_list:
|
||||
logging.exception(f"Failed to list saved payment methods for user {user_id}: {e_list}")
|
||||
saved_methods = []
|
||||
|
||||
if not saved_methods:
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
get_text("yookassa_autopay_no_saved_cards"),
|
||||
reply_markup=get_yk_autopay_choice_keyboard(
|
||||
months,
|
||||
price_rub,
|
||||
current_lang,
|
||||
i18n,
|
||||
has_saved_cards=False,
|
||||
sale_mode=sale_mode,
|
||||
),
|
||||
)
|
||||
except Exception as e_edit:
|
||||
logging.warning(f"Failed to display no-saved-card notice: {e_edit}")
|
||||
try:
|
||||
await callback.message.answer(
|
||||
get_text("yookassa_autopay_no_saved_cards"),
|
||||
reply_markup=get_yk_autopay_choice_keyboard(
|
||||
months,
|
||||
price_rub,
|
||||
current_lang,
|
||||
i18n,
|
||||
has_saved_cards=False,
|
||||
sale_mode=sale_mode,
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
cards: List[Tuple[str, str]] = []
|
||||
for method in saved_methods:
|
||||
title = _format_saved_payment_method_title(
|
||||
get_text, method.card_network, method.card_last4, method.is_default
|
||||
)
|
||||
cards.append((str(method.method_id), title))
|
||||
|
||||
per_page = 5
|
||||
max_page = max(0, (len(cards) - 1) // per_page)
|
||||
page = max(0, min(page, max_page))
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
get_text("yookassa_autopay_choose_saved_card"),
|
||||
reply_markup=get_yk_saved_cards_keyboard(
|
||||
cards,
|
||||
months,
|
||||
price_rub,
|
||||
current_lang,
|
||||
i18n,
|
||||
page=page,
|
||||
sale_mode=sale_mode,
|
||||
),
|
||||
)
|
||||
except Exception as e_edit:
|
||||
logging.warning(f"Failed to display saved card list: {e_edit}")
|
||||
try:
|
||||
await callback.message.answer(
|
||||
get_text("yookassa_autopay_choose_saved_card"),
|
||||
reply_markup=get_yk_saved_cards_keyboard(
|
||||
cards,
|
||||
months,
|
||||
price_rub,
|
||||
current_lang,
|
||||
i18n,
|
||||
page=page,
|
||||
sale_mode=sale_mode,
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("pay_yk_use_saved:"))
|
||||
async def pay_yk_use_saved_handler(
|
||||
callback: types.CallbackQuery,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
yookassa_service: YooKassaService,
|
||||
session: AsyncSession,
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
if not i18n or not callback.message:
|
||||
try:
|
||||
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
if not yookassa_service or not yookassa_service.configured:
|
||||
logging.error("YooKassa service unavailable for pay_yk_use_saved.")
|
||||
try:
|
||||
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await callback.message.edit_text(get_text("payment_service_unavailable"))
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
_, data_payload = callback.data.split(":", 1)
|
||||
except ValueError:
|
||||
logging.error(f"Invalid pay_yk_use_saved data: {callback.data}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
parts = data_payload.split(":")
|
||||
if len(parts) < 3:
|
||||
logging.error(f"pay_yk_use_saved payload missing components: {callback.data}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
months = float(parts[0])
|
||||
price_rub = float(parts[1])
|
||||
sale_mode = parts[3] if len(parts) > 3 else "subscription"
|
||||
except (ValueError, IndexError):
|
||||
logging.error(f"pay_yk_use_saved months/price parsing error: {callback.data}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
autopay_enabled = bool(
|
||||
settings.yookassa_autopayments_active
|
||||
and _sale_mode_base(sale_mode) == "subscription"
|
||||
and not settings.traffic_sale_mode
|
||||
)
|
||||
if not autopay_enabled:
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
method_identifier = parts[2]
|
||||
user_id = callback.from_user.id
|
||||
|
||||
try:
|
||||
saved_methods = await user_billing_dal.list_user_payment_methods(
|
||||
session, user_id, provider="yookassa"
|
||||
)
|
||||
except Exception as e_list:
|
||||
logging.exception(f"Failed to list saved payment methods for user {user_id}: {e_list}")
|
||||
saved_methods = []
|
||||
|
||||
selected_method = None
|
||||
for method in saved_methods:
|
||||
if method_identifier.isdigit():
|
||||
if method.method_id == int(method_identifier):
|
||||
selected_method = method
|
||||
break
|
||||
if method.provider_payment_method_id == method_identifier:
|
||||
selected_method = method
|
||||
break
|
||||
|
||||
if not selected_method:
|
||||
logging.warning(
|
||||
f"Selected payment method not found for user {user_id}: {method_identifier}"
|
||||
)
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
currency_code_for_yk = "RUB"
|
||||
|
||||
await _initiate_yk_payment(
|
||||
callback,
|
||||
settings=settings,
|
||||
session=session,
|
||||
yookassa_service=yookassa_service,
|
||||
i18n=i18n,
|
||||
current_lang=current_lang,
|
||||
get_text=get_text,
|
||||
user_id=user_id,
|
||||
months=months,
|
||||
price_rub=price_rub,
|
||||
currency_code_for_yk=currency_code_for_yk,
|
||||
save_payment_method=False,
|
||||
back_callback=f"pay_yk_saved_list:{_format_value(months)}:{price_rub}:{sale_mode}",
|
||||
payment_method_id=selected_method.provider_payment_method_id,
|
||||
selected_method_internal_id=selected_method.method_id,
|
||||
sale_mode=sale_mode,
|
||||
)
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
@@ -14,7 +14,12 @@ from bot.services.notification_service import NotificationService
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.utils.config_link import prepare_config_links
|
||||
from bot.utils.install_links import (
|
||||
append_install_share_link_text,
|
||||
ensure_user_install_guide_links,
|
||||
)
|
||||
from config.settings import Settings
|
||||
from db.dal import user_dal
|
||||
|
||||
from .start import send_main_menu
|
||||
|
||||
@@ -41,7 +46,7 @@ async def request_trial_confirmation_handler(
|
||||
return
|
||||
|
||||
if settings.TRIAL_ENABLED:
|
||||
if not await subscription_service.has_had_any_subscription(session, user_id):
|
||||
if not await subscription_service.has_trial_blocking_subscription(session, user_id):
|
||||
pass
|
||||
|
||||
if not settings.TRIAL_ENABLED:
|
||||
@@ -55,7 +60,7 @@ async def request_trial_confirmation_handler(
|
||||
pass
|
||||
return
|
||||
|
||||
if await subscription_service.has_had_any_subscription(session, user_id):
|
||||
if await subscription_service.has_trial_blocking_subscription(session, user_id):
|
||||
await callback.message.edit_text(
|
||||
_("trial_already_had_subscription_or_trial"),
|
||||
reply_markup=get_main_menu_inline_keyboard(current_lang, i18n, settings, False),
|
||||
@@ -74,6 +79,7 @@ async def request_trial_confirmation_handler(
|
||||
config_link_display_for_trial = None
|
||||
config_link_for_trial = None
|
||||
connect_button_url_for_trial = None
|
||||
install_share_url = None
|
||||
|
||||
if activation_result and activation_result.get("activated"):
|
||||
try:
|
||||
@@ -104,9 +110,23 @@ async def request_trial_confirmation_handler(
|
||||
traffic_gb=traffic_display,
|
||||
)
|
||||
|
||||
install_links = await ensure_user_install_guide_links(session, settings, user_id)
|
||||
install_share_url = install_links.public_share_url
|
||||
final_message_text_in_chat = append_install_share_link_text(
|
||||
final_message_text_in_chat,
|
||||
_,
|
||||
install_share_url,
|
||||
)
|
||||
|
||||
# Send notification to admin about new trial
|
||||
notification_service = NotificationService(callback.bot, settings, i18n)
|
||||
await notification_service.notify_trial_activation(user_id, end_date_obj)
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
await notification_service.notify_trial_activation(
|
||||
user_id,
|
||||
end_date_obj,
|
||||
username=db_user.username if db_user else callback.from_user.username,
|
||||
email=getattr(db_user, "email", None) if db_user else None,
|
||||
)
|
||||
# Mark ad attribution trial if exists
|
||||
try:
|
||||
from db.dal import ad_dal as _ad_dal
|
||||
@@ -127,8 +147,9 @@ async def request_trial_confirmation_handler(
|
||||
await callback.answer(final_message_text_in_chat, show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
if settings.TRIAL_ENABLED and not await subscription_service.has_had_any_subscription(
|
||||
session, user_id
|
||||
if (
|
||||
settings.TRIAL_ENABLED
|
||||
and not await subscription_service.has_trial_blocking_subscription(session, user_id)
|
||||
):
|
||||
show_trial_button_after_action = True
|
||||
|
||||
@@ -139,6 +160,7 @@ async def request_trial_confirmation_handler(
|
||||
settings,
|
||||
config_link_display_for_trial,
|
||||
connect_button_url=connect_button_url_for_trial,
|
||||
install_share_url=install_share_url,
|
||||
)
|
||||
if activation_result and activation_result.get("activated")
|
||||
else get_main_menu_inline_keyboard(
|
||||
@@ -197,7 +219,7 @@ async def confirm_activate_trial_handler(
|
||||
callback, settings, i18n_data, subscription_service, session, is_edit=True
|
||||
)
|
||||
return
|
||||
if await subscription_service.has_had_any_subscription(session, user_id):
|
||||
if await subscription_service.has_trial_blocking_subscription(session, user_id):
|
||||
try:
|
||||
await callback.answer(_("trial_already_had_subscription_or_trial"), show_alert=True)
|
||||
except Exception:
|
||||
@@ -214,6 +236,7 @@ async def confirm_activate_trial_handler(
|
||||
config_link_display_for_trial = None
|
||||
config_link_for_trial = None
|
||||
connect_button_url_for_trial = None
|
||||
install_share_url = None
|
||||
|
||||
if activation_result and activation_result.get("activated"):
|
||||
try:
|
||||
@@ -243,6 +266,13 @@ async def confirm_activate_trial_handler(
|
||||
config_link=config_link_for_trial,
|
||||
traffic_gb=traffic_display,
|
||||
)
|
||||
install_links = await ensure_user_install_guide_links(session, settings, user_id)
|
||||
install_share_url = install_links.public_share_url
|
||||
final_message_text_in_chat = append_install_share_link_text(
|
||||
final_message_text_in_chat,
|
||||
_,
|
||||
install_share_url,
|
||||
)
|
||||
else:
|
||||
message_key_from_service = (
|
||||
activation_result.get("message_key", "trial_activation_failed")
|
||||
@@ -254,8 +284,9 @@ async def confirm_activate_trial_handler(
|
||||
await callback.answer(final_message_text_in_chat, show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
if settings.TRIAL_ENABLED and not await subscription_service.has_had_any_subscription(
|
||||
session, user_id
|
||||
if (
|
||||
settings.TRIAL_ENABLED
|
||||
and not await subscription_service.has_trial_blocking_subscription(session, user_id)
|
||||
):
|
||||
show_trial_button_after_action = True
|
||||
|
||||
@@ -266,6 +297,7 @@ async def confirm_activate_trial_handler(
|
||||
settings,
|
||||
config_link_display_for_trial,
|
||||
connect_button_url=connect_button_url_for_trial,
|
||||
install_share_url=install_share_url,
|
||||
)
|
||||
if activation_result and activation_result.get("activated")
|
||||
else get_main_menu_inline_keyboard(
|
||||
@@ -293,7 +325,13 @@ async def confirm_activate_trial_handler(
|
||||
|
||||
if activation_result and activation_result.get("activated") and end_date_obj:
|
||||
notification_service = NotificationService(callback.bot, settings, i18n)
|
||||
await notification_service.notify_trial_activation(user_id, end_date_obj)
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
await notification_service.notify_trial_activation(
|
||||
user_id,
|
||||
end_date_obj,
|
||||
username=db_user.username if db_user else callback.from_user.username,
|
||||
email=getattr(db_user, "email", None) if db_user else None,
|
||||
)
|
||||
try:
|
||||
from db.dal import ad_dal as _ad_dal
|
||||
|
||||
|
||||
@@ -51,7 +51,11 @@ async def cache_get_json(settings: Settings, key: str) -> Any:
|
||||
redis = await get_redis(settings)
|
||||
if redis is None:
|
||||
return None
|
||||
raw = await redis.get(key)
|
||||
try:
|
||||
raw = await redis.get(key)
|
||||
except Exception as exc:
|
||||
logger.warning("Redis cache get failed for key %s: %s", key, exc)
|
||||
return None
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
@@ -65,14 +69,39 @@ async def cache_set_json(settings: Settings, key: str, value: Any, ttl_seconds:
|
||||
redis = await get_redis(settings)
|
||||
if redis is None:
|
||||
return
|
||||
await redis.set(key, json.dumps(value, ensure_ascii=False, default=str), ex=ttl_seconds)
|
||||
try:
|
||||
await redis.set(key, json.dumps(value, ensure_ascii=False, default=str), ex=ttl_seconds)
|
||||
except Exception as exc:
|
||||
logger.warning("Redis cache set failed for key %s: %s", key, exc)
|
||||
|
||||
|
||||
async def cache_delete(settings: Settings, *keys: str) -> None:
|
||||
redis = await get_redis(settings)
|
||||
if redis is None or not keys:
|
||||
return
|
||||
await redis.delete(*keys)
|
||||
try:
|
||||
await redis.delete(*keys)
|
||||
except Exception as exc:
|
||||
logger.warning("Redis cache delete failed for %s key(s): %s", len(keys), exc)
|
||||
|
||||
|
||||
async def cache_delete_pattern(settings: Settings, pattern: str) -> int:
|
||||
redis = await get_redis(settings)
|
||||
if redis is None or not pattern:
|
||||
return 0
|
||||
deleted = 0
|
||||
batch = []
|
||||
try:
|
||||
async for key in redis.scan_iter(match=pattern, count=100):
|
||||
batch.append(key)
|
||||
if len(batch) >= 100:
|
||||
deleted += int(await redis.delete(*batch))
|
||||
batch.clear()
|
||||
if batch:
|
||||
deleted += int(await redis.delete(*batch))
|
||||
except Exception as exc:
|
||||
logger.warning("Redis cache pattern delete failed for %s: %s", pattern, exc)
|
||||
return deleted
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
|
||||
@@ -24,28 +24,36 @@ async def enqueue_webhook_event(
|
||||
if redis is None:
|
||||
return False
|
||||
|
||||
dedupe_id = event_id or payload.get("id") or payload.get("event_id")
|
||||
if dedupe_id:
|
||||
dedupe_key = redis_key(settings, "webhook", "seen", provider, dedupe_id)
|
||||
if not await redis.set(dedupe_key, "1", nx=True, ex=24 * 60 * 60):
|
||||
logger.info("Skipping duplicate %s webhook event %s", provider, dedupe_id)
|
||||
return True
|
||||
try:
|
||||
dedupe_id = event_id or payload.get("id") or payload.get("event_id")
|
||||
if dedupe_id:
|
||||
dedupe_key = redis_key(settings, "webhook", "seen", provider, dedupe_id)
|
||||
if not await redis.set(dedupe_key, "1", nx=True, ex=24 * 60 * 60):
|
||||
logger.info("Skipping duplicate %s webhook event %s", provider, dedupe_id)
|
||||
return True
|
||||
|
||||
message = {
|
||||
"provider": provider,
|
||||
"event_id": dedupe_id,
|
||||
"payload": payload,
|
||||
"enqueued_at": time.time(),
|
||||
}
|
||||
await redis.lpush(webhook_queue_key(settings), json.dumps(message, ensure_ascii=False))
|
||||
return True
|
||||
message = {
|
||||
"provider": provider,
|
||||
"event_id": dedupe_id,
|
||||
"payload": payload,
|
||||
"enqueued_at": time.time(),
|
||||
}
|
||||
await redis.lpush(webhook_queue_key(settings), json.dumps(message, ensure_ascii=False))
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.warning("Redis webhook enqueue failed for %s: %s", provider, exc)
|
||||
return False
|
||||
|
||||
|
||||
async def pop_webhook_event(settings: Settings, timeout_seconds: int = 5) -> Optional[dict]:
|
||||
redis = await get_redis(settings)
|
||||
if redis is None:
|
||||
return None
|
||||
item = await redis.brpop(webhook_queue_key(settings), timeout=timeout_seconds)
|
||||
try:
|
||||
item = await redis.brpop(webhook_queue_key(settings), timeout=timeout_seconds)
|
||||
except Exception as exc:
|
||||
logger.warning("Redis webhook pop failed: %s", exc)
|
||||
return None
|
||||
if not item:
|
||||
return None
|
||||
_, raw = item
|
||||
@@ -60,4 +68,8 @@ async def webhook_queue_depth(settings: Settings) -> int:
|
||||
redis = await get_redis(settings)
|
||||
if redis is None:
|
||||
return 0
|
||||
return int(await redis.llen(webhook_queue_key(settings)))
|
||||
try:
|
||||
return int(await redis.llen(webhook_queue_key(settings)))
|
||||
except Exception as exc:
|
||||
logger.warning("Redis webhook queue depth failed: %s", exc)
|
||||
return 0
|
||||
|
||||
@@ -452,10 +452,11 @@ def get_broadcast_confirmation_keyboard(
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
# Row: target selection (all / active / inactive)
|
||||
# Row: target selection (all / active / inactive / expired)
|
||||
target_all_label = _(key="broadcast_target_all_button")
|
||||
target_active_label = _(key="broadcast_target_active_button")
|
||||
target_inactive_label = _(key="broadcast_target_inactive_button")
|
||||
target_expired_label = _(key="broadcast_target_expired_button")
|
||||
|
||||
# Highlight current selection with a prefix
|
||||
def mark_selected(label: str, is_selected: bool) -> str:
|
||||
@@ -473,7 +474,10 @@ def get_broadcast_confirmation_keyboard(
|
||||
text=mark_selected(target_inactive_label, target == "inactive"),
|
||||
callback_data="broadcast_target:inactive",
|
||||
)
|
||||
builder.adjust(3)
|
||||
builder.button(
|
||||
text=mark_selected(target_expired_label, target == "expired"),
|
||||
callback_data="broadcast_target:expired",
|
||||
)
|
||||
|
||||
# Row: confirmation
|
||||
builder.button(
|
||||
@@ -482,7 +486,7 @@ def get_broadcast_confirmation_keyboard(
|
||||
builder.button(
|
||||
text=_(key="cancel_broadcast_button"), callback_data="broadcast_final_action:cancel"
|
||||
)
|
||||
builder.adjust(2)
|
||||
builder.adjust(2, 2, 2)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,138 @@ from typing import Any, Dict, List, Optional, Tuple
|
||||
from aiogram.types import InlineKeyboardMarkup, WebAppInfo
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton
|
||||
|
||||
from bot.middlewares.i18n import locale_language_options
|
||||
from bot.utils.channel_subscription import normalize_required_channel_link
|
||||
from bot.utils.install_links import bot_install_guide_url
|
||||
from bot.utils.mini_app_url import subscription_mini_app_trial_url
|
||||
from config.settings import Settings
|
||||
from config.tariffs_config import (
|
||||
default_currency_key_for_settings,
|
||||
default_payment_currency_code_for_settings,
|
||||
)
|
||||
|
||||
BOT_MENU_CONTEXT = "bot"
|
||||
HWID_RENEWAL_TOKEN = "hwid_renewal"
|
||||
|
||||
|
||||
def sale_mode_tokens(sale_mode: Optional[str]) -> Tuple[str, ...]:
|
||||
if not sale_mode or "|" not in sale_mode:
|
||||
return ()
|
||||
return tuple(token.strip() for token in str(sale_mode).split("|")[1:] if token.strip())
|
||||
|
||||
|
||||
def callback_context_from_back_callback(back_callback: Optional[str]) -> Optional[str]:
|
||||
if back_callback == "main_action:bot_interface":
|
||||
return BOT_MENU_CONTEXT
|
||||
return None
|
||||
|
||||
|
||||
def sale_mode_with_callback_context(sale_mode: str, context: Optional[str]) -> str:
|
||||
sale_mode = sale_mode or "subscription"
|
||||
if not context or context in sale_mode_tokens(sale_mode):
|
||||
return sale_mode
|
||||
return f"{sale_mode}|{context}"
|
||||
|
||||
|
||||
def sale_mode_with_token(sale_mode: str, token: str) -> str:
|
||||
sale_mode = sale_mode or "subscription"
|
||||
token = str(token or "").strip()
|
||||
if not token or token in sale_mode_tokens(sale_mode):
|
||||
return sale_mode
|
||||
return f"{sale_mode}|{token}"
|
||||
|
||||
|
||||
def sale_mode_without_token(sale_mode: str, token: str) -> str:
|
||||
sale_mode = sale_mode or "subscription"
|
||||
token = str(token or "").strip()
|
||||
if not token or "|" not in sale_mode:
|
||||
return sale_mode
|
||||
base, *tokens = sale_mode.split("|")
|
||||
kept = [item for item in tokens if item.strip() and item.strip() != token]
|
||||
return "|".join([base, *kept])
|
||||
|
||||
|
||||
def sale_mode_has_token(sale_mode: Optional[str], token: str) -> bool:
|
||||
return str(token or "").strip() in sale_mode_tokens(sale_mode)
|
||||
|
||||
|
||||
def callback_context_from_sale_mode(sale_mode: Optional[str]) -> Optional[str]:
|
||||
tokens = sale_mode_tokens(sale_mode)
|
||||
return BOT_MENU_CONTEXT if BOT_MENU_CONTEXT in tokens else None
|
||||
|
||||
|
||||
def callback_suffix_for_context(context: Optional[str]) -> str:
|
||||
return f":{context}" if context else ""
|
||||
|
||||
|
||||
def subscription_options_callback(context: Optional[str]) -> str:
|
||||
return "main_action:bot_subscribe" if context == BOT_MENU_CONTEXT else "main_action:subscribe"
|
||||
|
||||
|
||||
def tariff_purchase_back_callback(context: Optional[str]) -> str:
|
||||
if context == BOT_MENU_CONTEXT:
|
||||
return "main_action:bot_interface"
|
||||
return subscription_options_callback(context)
|
||||
|
||||
|
||||
def payment_methods_back_callback(
|
||||
value: str, sale_mode: str = "subscription", price: Optional[float] = None
|
||||
) -> str:
|
||||
sale_mode = sale_mode or "subscription"
|
||||
context = callback_context_from_sale_mode(sale_mode)
|
||||
context_suffix = callback_suffix_for_context(context)
|
||||
sale_mode_main = sale_mode.split("|", 1)[0]
|
||||
sale_base = sale_mode_main.split("@", 1)[0]
|
||||
tariff_key = sale_mode_main.split("@", 1)[1] if "@" in sale_mode_main else None
|
||||
|
||||
if sale_base == "subscription" and tariff_key:
|
||||
return f"tariff:period:{tariff_key}:{value}{context_suffix}"
|
||||
if sale_base == "traffic_package" and tariff_key:
|
||||
return f"tariff:package:{tariff_key}:{value}{context_suffix}"
|
||||
if sale_base == "topup" and tariff_key:
|
||||
return f"tariff:package:{tariff_key}:{value}"
|
||||
if sale_base == "premium_topup" and tariff_key:
|
||||
return f"tariff:premium_package:{tariff_key}:{value}"
|
||||
if sale_base in {"hwid_device", "hwid_devices", "hwid_devices_renewal"} and tariff_key:
|
||||
action = "renewal_package" if sale_base == "hwid_devices_renewal" else "package"
|
||||
return f"hwid_devices:{action}:{tariff_key}:{value}"
|
||||
if sale_base == "tariff_upgrade" and tariff_key:
|
||||
amount = str(price) if price is not None else value
|
||||
return f"tariff_change:pay:{tariff_key}:{amount}"
|
||||
if sale_base in {"subscription", "traffic"}:
|
||||
return f"subscribe_period:{value}{context_suffix}"
|
||||
return subscription_options_callback(context)
|
||||
|
||||
|
||||
def payment_options_back_callback(sale_mode: str = "subscription") -> str:
|
||||
sale_mode = sale_mode or "subscription"
|
||||
context = callback_context_from_sale_mode(sale_mode)
|
||||
context_suffix = callback_suffix_for_context(context)
|
||||
sale_mode_main = sale_mode.split("|", 1)[0]
|
||||
sale_base = sale_mode_main.split("@", 1)[0]
|
||||
tariff_key = sale_mode_main.split("@", 1)[1] if "@" in sale_mode_main else None
|
||||
|
||||
if sale_base in {"subscription", "traffic_package"} and tariff_key:
|
||||
return f"tariff:select:{tariff_key}{context_suffix}"
|
||||
if sale_base in {"topup", "premium_topup"}:
|
||||
return "tariff_topup:list"
|
||||
if sale_base in {"hwid_device", "hwid_devices", "hwid_devices_renewal"}:
|
||||
return "hwid_devices:list"
|
||||
return subscription_options_callback(context)
|
||||
|
||||
|
||||
def _trial_activation_button(lang: str, i18n_instance, settings: Settings) -> InlineKeyboardButton:
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
if settings.SUBSCRIPTION_MINI_APP_URL:
|
||||
trial_url = subscription_mini_app_trial_url(settings) or settings.SUBSCRIPTION_MINI_APP_URL
|
||||
return InlineKeyboardButton(
|
||||
text=_(key="menu_activate_trial_button"),
|
||||
web_app=WebAppInfo(url=trial_url),
|
||||
)
|
||||
return InlineKeyboardButton(
|
||||
text=_(key="menu_activate_trial_button"),
|
||||
callback_data="main_action:request_trial",
|
||||
)
|
||||
|
||||
|
||||
def get_main_menu_inline_keyboard(
|
||||
@@ -12,6 +143,9 @@ def get_main_menu_inline_keyboard(
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
if show_trial_button and settings.TRIAL_ENABLED:
|
||||
builder.row(_trial_activation_button(lang, i18n_instance, settings))
|
||||
|
||||
if settings.SUBSCRIPTION_MINI_APP_URL:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
@@ -38,8 +172,7 @@ def get_main_menu_inline_keyboard(
|
||||
InlineKeyboardButton(text=_(key="menu_support_button"), url=settings.SUPPORT_LINK)
|
||||
)
|
||||
|
||||
user_agreement_url = settings.USER_AGREEMENT_URL or settings.TERMS_OF_SERVICE_URL
|
||||
if settings.PRIVACY_POLICY_URL or user_agreement_url:
|
||||
if settings.PRIVACY_POLICY_URL or settings.USER_AGREEMENT_URL:
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=_(key="menu_info_button"), callback_data="main_action:info")
|
||||
)
|
||||
@@ -54,11 +187,7 @@ def get_bot_interface_inline_keyboard(
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
if show_trial_button and settings.TRIAL_ENABLED:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_(key="menu_activate_trial_button"), callback_data="main_action:request_trial"
|
||||
)
|
||||
)
|
||||
builder.row(_trial_activation_button(lang, i18n_instance, settings))
|
||||
|
||||
if settings.SUBSCRIPTION_MINI_APP_URL:
|
||||
builder.row(
|
||||
@@ -110,8 +239,7 @@ def get_bot_interface_inline_keyboard(
|
||||
InlineKeyboardButton(text=_(key="menu_support_button"), url=settings.SUPPORT_LINK)
|
||||
)
|
||||
|
||||
user_agreement_url = settings.USER_AGREEMENT_URL or settings.TERMS_OF_SERVICE_URL
|
||||
if settings.PRIVACY_POLICY_URL or user_agreement_url:
|
||||
if settings.PRIVACY_POLICY_URL or settings.USER_AGREEMENT_URL:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_(key="menu_info_button"), callback_data="main_action:bot_info"
|
||||
@@ -156,14 +284,18 @@ def get_language_selection_keyboard(
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(current_lang, key, **kwargs)
|
||||
callback_suffix = ":bot" if back_callback == "main_action:bot_interface" else ""
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(
|
||||
text=f"🇬🇧 English {'✅' if current_lang == 'en' else ''}",
|
||||
callback_data=f"set_lang_en{callback_suffix}",
|
||||
)
|
||||
builder.button(
|
||||
text=f"🇷🇺 Русский {'✅' if current_lang == 'ru' else ''}",
|
||||
callback_data=f"set_lang_ru{callback_suffix}",
|
||||
)
|
||||
if hasattr(i18n_instance, "language_options"):
|
||||
languages = i18n_instance.language_options()
|
||||
else:
|
||||
locales_data = getattr(i18n_instance, "locales_data", {}) or {"ru": {}, "en": {}}
|
||||
languages = locale_language_options(locales_data.keys(), base_languages=locales_data.keys())
|
||||
for language in languages:
|
||||
lang_code = language["code"]
|
||||
checked = " ✅" if current_lang == lang_code else ""
|
||||
builder.button(
|
||||
text=f"{language['flag']} {language['label']}{checked}",
|
||||
callback_data=f"set_lang_{lang_code}{callback_suffix}",
|
||||
)
|
||||
builder.button(text=_(key="back_to_main_menu_button"), callback_data=back_callback)
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
@@ -187,9 +319,11 @@ def get_subscription_options_keyboard(
|
||||
i18n_instance,
|
||||
traffic_mode: bool = False,
|
||||
back_callback: str = "main_action:back_to_main",
|
||||
callback_context: Optional[str] = None,
|
||||
) -> InlineKeyboardMarkup:
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
callback_context = callback_context or callback_context_from_back_callback(back_callback)
|
||||
|
||||
def _format_gb(val: float) -> str:
|
||||
return str(int(val)) if float(val).is_integer() else f"{val:g}"
|
||||
@@ -204,7 +338,10 @@ def get_subscription_options_keyboard(
|
||||
price=price,
|
||||
currency_symbol=currency_symbol_val,
|
||||
)
|
||||
callback_data = f"subscribe_period:{_format_gb(months)}"
|
||||
callback_data = (
|
||||
f"subscribe_period:{_format_gb(months)}"
|
||||
f"{callback_suffix_for_context(callback_context)}"
|
||||
)
|
||||
else:
|
||||
button_text = _(
|
||||
"subscribe_for_months_button",
|
||||
@@ -212,7 +349,9 @@ def get_subscription_options_keyboard(
|
||||
price=price,
|
||||
currency_symbol=currency_symbol_val,
|
||||
)
|
||||
callback_data = f"subscribe_period:{months}"
|
||||
callback_data = (
|
||||
f"subscribe_period:{months}{callback_suffix_for_context(callback_context)}"
|
||||
)
|
||||
builder.button(text=button_text, callback_data=callback_data)
|
||||
builder.adjust(1)
|
||||
builder.row(
|
||||
@@ -222,36 +361,65 @@ def get_subscription_options_keyboard(
|
||||
|
||||
|
||||
def get_tariff_catalog_keyboard(
|
||||
tariffs: List[Any], lang: str, i18n_instance
|
||||
tariffs: List[Any],
|
||||
lang: str,
|
||||
i18n_instance,
|
||||
settings: Optional[Settings] = None,
|
||||
back_callback: str = "main_action:back_to_main",
|
||||
callback_context: Optional[str] = None,
|
||||
) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
callback_context = callback_context or callback_context_from_back_callback(back_callback)
|
||||
default_currency = default_currency_key_for_settings(settings) if settings else "rub"
|
||||
for tariff in tariffs:
|
||||
label = tariff.name(lang)
|
||||
if tariff.billing_model == "period":
|
||||
min_price = tariff.min_period_price_rub()
|
||||
if hasattr(tariff, "min_period_price"):
|
||||
min_price = tariff.min_period_price(default_currency)
|
||||
elif default_currency == "rub" and hasattr(tariff, "min_period_price_rub"):
|
||||
min_price = tariff.min_period_price_rub()
|
||||
else:
|
||||
min_price = None
|
||||
if min_price is not None:
|
||||
label = f"{label} от {min_price:g}"
|
||||
else:
|
||||
package = tariff.min_traffic_package_rub()
|
||||
if hasattr(tariff, "min_traffic_package"):
|
||||
package = tariff.min_traffic_package(default_currency)
|
||||
elif default_currency == "rub" and hasattr(tariff, "min_traffic_package_rub"):
|
||||
package = tariff.min_traffic_package_rub()
|
||||
else:
|
||||
package = None
|
||||
if package:
|
||||
label = f"{label} от {package.price:g} / {package.gb:g} GB"
|
||||
builder.row(InlineKeyboardButton(text=label, callback_data=f"tariff:select:{tariff.key}"))
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=label,
|
||||
callback_data=f"tariff:select:{tariff.key}"
|
||||
f"{callback_suffix_for_context(callback_context)}",
|
||||
)
|
||||
)
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_(key="back_to_main_menu_button"), callback_data="main_action:back_to_main"
|
||||
)
|
||||
InlineKeyboardButton(text=_(key="back_to_main_menu_button"), callback_data=back_callback)
|
||||
)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def get_tariff_periods_keyboard(
|
||||
tariff: Any, lang: str, i18n_instance, settings: Settings
|
||||
tariff: Any,
|
||||
lang: str,
|
||||
i18n_instance,
|
||||
settings: Settings,
|
||||
back_callback: str = "main_action:subscribe",
|
||||
callback_context: Optional[str] = None,
|
||||
) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
callback_context = callback_context or callback_context_from_back_callback(back_callback)
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
default_currency = default_currency_key_for_settings(settings)
|
||||
currency_code = default_payment_currency_code_for_settings(settings)
|
||||
for months in tariff.enabled_periods:
|
||||
rub_price = tariff.period_price(months, "rub")
|
||||
rub_price = tariff.period_price(months, default_currency)
|
||||
if rub_price and rub_price > 0:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
@@ -259,15 +427,14 @@ def get_tariff_periods_keyboard(
|
||||
"subscribe_for_months_button",
|
||||
months=months,
|
||||
price=rub_price,
|
||||
currency_symbol=settings.DEFAULT_CURRENCY_SYMBOL,
|
||||
currency_symbol=currency_code,
|
||||
),
|
||||
callback_data=f"tariff:period:{tariff.key}:{months}",
|
||||
callback_data=f"tariff:period:{tariff.key}:{months}"
|
||||
f"{callback_suffix_for_context(callback_context)}",
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_(key="back_to_main_menu_button"), callback_data="main_action:subscribe"
|
||||
)
|
||||
InlineKeyboardButton(text=_(key="back_to_main_menu_button"), callback_data=back_callback)
|
||||
)
|
||||
return builder.as_markup()
|
||||
|
||||
@@ -277,9 +444,12 @@ def get_tariff_packages_keyboard(
|
||||
packages: List[Any],
|
||||
lang: str,
|
||||
i18n_instance,
|
||||
currency_symbol: str = "RUB",
|
||||
back_callback: str = "main_action:subscribe",
|
||||
callback_context: Optional[str] = None,
|
||||
) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
callback_context = callback_context or callback_context_from_back_callback(back_callback)
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
for package in packages:
|
||||
builder.row(
|
||||
@@ -288,9 +458,10 @@ def get_tariff_packages_keyboard(
|
||||
"buy_traffic_package_button",
|
||||
traffic_gb=f"{package.gb:g}",
|
||||
price=package.price,
|
||||
currency_symbol="RUB",
|
||||
currency_symbol=currency_symbol,
|
||||
),
|
||||
callback_data=f"tariff:package:{tariff.key}:{package.gb:g}",
|
||||
callback_data=f"tariff:package:{tariff.key}:{package.gb:g}"
|
||||
f"{callback_suffix_for_context(callback_context)}",
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
@@ -306,9 +477,11 @@ def get_hwid_device_packages_keyboard(
|
||||
i18n_instance,
|
||||
settings: Settings,
|
||||
back_callback: str = "main_action:my_subscription",
|
||||
renewal: bool = False,
|
||||
) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
currency_code = default_payment_currency_code_for_settings(settings)
|
||||
for package in packages:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
@@ -316,9 +489,12 @@ def get_hwid_device_packages_keyboard(
|
||||
"buy_hwid_devices_button",
|
||||
count=package.count,
|
||||
price=package.price,
|
||||
currency_symbol=settings.DEFAULT_CURRENCY_SYMBOL,
|
||||
currency_symbol=currency_code,
|
||||
),
|
||||
callback_data=(
|
||||
f"hwid_devices:{'renewal_package' if renewal else 'package'}:"
|
||||
f"{tariff.key}:{package.count}"
|
||||
),
|
||||
callback_data=f"hwid_devices:package:{tariff.key}:{package.count}",
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
@@ -336,6 +512,12 @@ def get_payment_method_keyboard(
|
||||
i18n_instance,
|
||||
settings: Settings,
|
||||
sale_mode: str = "subscription",
|
||||
back_callback: Optional[str] = None,
|
||||
user_id: Optional[int] = None,
|
||||
is_admin: Optional[bool] = None,
|
||||
hwid_renewal_quote: Optional[Dict[str, Any]] = None,
|
||||
hwid_renewal_stars_quote: Optional[Dict[str, Any]] = None,
|
||||
hwid_renewal_selected: bool = True,
|
||||
) -> InlineKeyboardMarkup:
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
@@ -344,57 +526,71 @@ def get_payment_method_keyboard(
|
||||
return str(int(val)) if float(val).is_integer() else f"{val:g}"
|
||||
|
||||
value_str = _format_value(months)
|
||||
mode_suffix = f":{sale_mode}"
|
||||
import logging as _kbd_logging
|
||||
payment_sale_mode = sale_mode
|
||||
selected_hwid_quote = hwid_renewal_quote or hwid_renewal_stars_quote
|
||||
if selected_hwid_quote:
|
||||
tariff_key = None
|
||||
sale_mode_main = str(sale_mode or "").split("|", 1)[0]
|
||||
if "@" in sale_mode_main:
|
||||
tariff_key = sale_mode_main.split("@", 1)[1]
|
||||
context = callback_context_from_sale_mode(sale_mode)
|
||||
toggle_tokens = [f"tariff:period:{tariff_key}:{value_str}"]
|
||||
if context:
|
||||
toggle_tokens.append(context)
|
||||
toggle_tokens.append("no_hwid" if hwid_renewal_selected else "hwid")
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_(
|
||||
"payment_hwid_renewal_toggle_on"
|
||||
if hwid_renewal_selected
|
||||
else "payment_hwid_renewal_toggle_off",
|
||||
count=int(selected_hwid_quote.get("device_count") or 0),
|
||||
price=(
|
||||
hwid_renewal_quote.get("price")
|
||||
if hwid_renewal_quote
|
||||
else hwid_renewal_stars_quote.get("price")
|
||||
),
|
||||
currency_symbol=currency_symbol_val,
|
||||
),
|
||||
callback_data=":".join(toggle_tokens),
|
||||
)
|
||||
)
|
||||
if hwid_renewal_selected:
|
||||
payment_sale_mode = sale_mode_with_token(sale_mode, HWID_RENEWAL_TOKEN)
|
||||
else:
|
||||
payment_sale_mode = sale_mode_without_token(sale_mode, HWID_RENEWAL_TOKEN)
|
||||
from bot.payment_providers import get_provider_spec, provider_telegram_button_text
|
||||
|
||||
_kbd_logging.info(
|
||||
"payment_method_keyboard build: order=%s | platega_enabled=%s sbp=%s crypto=%s",
|
||||
settings.payment_methods_order,
|
||||
settings.PLATEGA_ENABLED,
|
||||
settings.PLATEGA_SBP_ENABLED,
|
||||
settings.PLATEGA_CRYPTO_ENABLED,
|
||||
)
|
||||
for method in settings.payment_methods_order:
|
||||
if method == "severpay" and getattr(settings, "SEVERPAY_ENABLED", False):
|
||||
builder.button(
|
||||
text=_("pay_with_severpay_button"),
|
||||
callback_data=f"pay_severpay:{value_str}:{price}{mode_suffix}",
|
||||
spec = get_provider_spec(method)
|
||||
if (
|
||||
not spec
|
||||
or not spec.callback_prefix
|
||||
or not spec.is_usable_for_payment(settings, currency_symbol_val, price)
|
||||
or not spec.is_available_to_user(
|
||||
settings,
|
||||
user_id=user_id,
|
||||
is_admin=is_admin,
|
||||
require_configured=False,
|
||||
)
|
||||
elif method == "freekassa" and settings.FREEKASSA_ENABLED:
|
||||
builder.button(
|
||||
text=_("pay_with_sbp_button"),
|
||||
callback_data=f"pay_fk:{value_str}:{price}{mode_suffix}",
|
||||
)
|
||||
elif method == "platega_sbp" and settings.PLATEGA_ENABLED and settings.PLATEGA_SBP_ENABLED:
|
||||
builder.button(
|
||||
text=_("pay_with_platega_sbp_button"),
|
||||
callback_data=f"pay_platega_sbp:{value_str}:{price}{mode_suffix}",
|
||||
)
|
||||
elif (
|
||||
method == "platega_crypto"
|
||||
and settings.PLATEGA_ENABLED
|
||||
and settings.PLATEGA_CRYPTO_ENABLED
|
||||
):
|
||||
builder.button(
|
||||
text=_("pay_with_platega_crypto_button"),
|
||||
callback_data=f"pay_platega_crypto:{value_str}:{price}{mode_suffix}",
|
||||
)
|
||||
elif method == "yookassa" and settings.YOOKASSA_ENABLED:
|
||||
builder.button(
|
||||
text=_("pay_with_yookassa_button"),
|
||||
callback_data=f"pay_yk:{value_str}:{price}{mode_suffix}",
|
||||
)
|
||||
elif method == "stars" and settings.STARS_ENABLED and stars_price is not None:
|
||||
builder.button(
|
||||
text=_("pay_with_stars_button"),
|
||||
callback_data=f"pay_stars:{value_str}:{stars_price}{mode_suffix}",
|
||||
)
|
||||
elif method == "cryptopay" and settings.CRYPTOPAY_ENABLED:
|
||||
builder.button(
|
||||
text=_("pay_with_cryptopay_button"),
|
||||
callback_data=f"pay_crypto:{value_str}:{price}{mode_suffix}",
|
||||
)
|
||||
builder.button(text=_(key="cancel_button"), callback_data="main_action:subscribe")
|
||||
continue
|
||||
callback_data = spec.callback_data(
|
||||
value=value_str,
|
||||
rub_price=price,
|
||||
stars_price=stars_price,
|
||||
sale_mode=payment_sale_mode,
|
||||
)
|
||||
if not callback_data:
|
||||
continue
|
||||
builder.button(
|
||||
text=provider_telegram_button_text(spec, settings, language=lang),
|
||||
callback_data=callback_data,
|
||||
)
|
||||
builder.button(
|
||||
text=_(key="cancel_button"),
|
||||
callback_data=back_callback or payment_options_back_callback(sale_mode),
|
||||
)
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
@@ -426,6 +622,7 @@ def get_yk_autopay_choice_keyboard(
|
||||
i18n_instance,
|
||||
has_saved_cards: bool = True,
|
||||
sale_mode: str = "subscription",
|
||||
back_callback: Optional[str] = None,
|
||||
) -> InlineKeyboardMarkup:
|
||||
"""Keyboard for choosing between saved card charge or new card payment when auto-renew is enabled.""" # noqa: E501
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
@@ -441,7 +638,7 @@ def get_yk_autopay_choice_keyboard(
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_(key="yookassa_autopay_pay_saved_card_button"),
|
||||
callback_data=f"pay_yk_saved_list:{value_str}:{price_str}{suffix}",
|
||||
callback_data=f"pay_yk_saved_list:{value_str}:{price_str}:0{suffix}",
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
@@ -453,7 +650,7 @@ def get_yk_autopay_choice_keyboard(
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_(key="back_to_payment_methods_button"),
|
||||
callback_data=f"subscribe_period:{value_str}",
|
||||
callback_data=back_callback or payment_methods_back_callback(value_str, sale_mode),
|
||||
)
|
||||
)
|
||||
return builder.as_markup()
|
||||
@@ -583,10 +780,11 @@ def get_channel_subscription_keyboard(
|
||||
|
||||
has_buttons = False
|
||||
|
||||
if channel_link:
|
||||
channel_url = normalize_required_channel_link(channel_link)
|
||||
if channel_url:
|
||||
builder.button(
|
||||
text=_(key="channel_subscription_join_button"),
|
||||
url=channel_link,
|
||||
url=channel_url,
|
||||
)
|
||||
has_buttons = True
|
||||
|
||||
@@ -611,13 +809,29 @@ def get_connect_and_main_keyboard(
|
||||
config_link: Optional[str],
|
||||
connect_button_url: Optional[str] = None,
|
||||
preserve_message: bool = False,
|
||||
install_share_url: Optional[str] = None,
|
||||
) -> InlineKeyboardMarkup:
|
||||
"""Keyboard with a connect button and a back to main menu button."""
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
install_url = bot_install_guide_url(settings)
|
||||
button_target = connect_button_url or config_link
|
||||
|
||||
if button_target:
|
||||
if install_url:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_("connect_button"),
|
||||
web_app=WebAppInfo(url=install_url),
|
||||
)
|
||||
)
|
||||
if install_share_url:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_("install_guide_share_button"),
|
||||
url=install_share_url,
|
||||
)
|
||||
)
|
||||
elif button_target:
|
||||
builder.row(InlineKeyboardButton(text=_("connect_button"), url=button_target))
|
||||
elif settings.SUBSCRIPTION_MINI_APP_URL:
|
||||
builder.row(
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Optional
|
||||
from typing import Awaitable, Callable, Optional
|
||||
|
||||
from aiogram import Bot, Dispatcher
|
||||
from aiogram.exceptions import TelegramNetworkError
|
||||
from aiogram.types import BotCommand, MenuButtonDefault, MenuButtonWebApp, WebAppInfo
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from bot.app.controllers.dispatcher_controller import build_dispatcher
|
||||
from bot.app.factories.build_services import build_core_services
|
||||
from bot.app.web.web_server import build_and_start_web_app
|
||||
from bot.handlers.admin.sync_admin import perform_sync
|
||||
from bot.infra.redis import close_redis
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.routers import build_root_router
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
from bot.services.settings_override_service import load_overrides_from_db
|
||||
from bot.services.locale_override_service import load_locale_overrides
|
||||
from bot.utils.message_queue import init_queue_manager
|
||||
from config.settings import Settings
|
||||
from db.database_setup import init_db_connection
|
||||
from db.database_setup import init_db, init_db_connection
|
||||
|
||||
TELEGRAM_STARTUP_RETRY_DELAY_SECONDS = 2.0
|
||||
|
||||
|
||||
def redact_token(value: str, token: Optional[str]) -> str:
|
||||
@@ -26,17 +26,74 @@ def redact_token(value: str, token: Optional[str]) -> str:
|
||||
return value.replace(token, "***")
|
||||
|
||||
|
||||
def _telegram_network_error_detail(exc: TelegramNetworkError) -> str:
|
||||
root_cause = exc.__cause__ or exc.__context__
|
||||
detail = str(exc)
|
||||
if root_cause:
|
||||
root_detail = f"{type(root_cause).__name__}: {root_cause}"
|
||||
if root_detail not in detail:
|
||||
detail = f"{detail} ({root_detail})"
|
||||
return detail
|
||||
|
||||
|
||||
async def _run_telegram_startup_step(
|
||||
action: str,
|
||||
step: Callable[[], Awaitable[object]],
|
||||
unexpected_log_message: str,
|
||||
*,
|
||||
attempts: Optional[int] = None,
|
||||
retry_delay_seconds: float = TELEGRAM_STARTUP_RETRY_DELAY_SECONDS,
|
||||
) -> bool:
|
||||
attempt = 1
|
||||
max_attempts = max(1, attempts) if attempts is not None else None
|
||||
while True:
|
||||
try:
|
||||
await step()
|
||||
if attempt > 1:
|
||||
logging.info(
|
||||
"STARTUP: Telegram step succeeded while %s on attempt %s%s.",
|
||||
action,
|
||||
attempt,
|
||||
f"/{max_attempts}" if max_attempts is not None else "",
|
||||
)
|
||||
return True
|
||||
except TelegramNetworkError as exc:
|
||||
detail = _telegram_network_error_detail(exc)
|
||||
attempt_label = (
|
||||
f"{attempt}/{max_attempts}" if max_attempts is not None else str(attempt)
|
||||
)
|
||||
if max_attempts is not None and attempt >= max_attempts:
|
||||
logging.warning(
|
||||
"STARTUP: Telegram network error while %s after %s attempts: %s.",
|
||||
action,
|
||||
max_attempts,
|
||||
detail,
|
||||
)
|
||||
return False
|
||||
logging.warning(
|
||||
"STARTUP: Telegram network error while %s on attempt %s: %s. "
|
||||
"Retrying in %.1fs and will keep trying until Telegram is reachable.",
|
||||
action,
|
||||
attempt_label,
|
||||
detail,
|
||||
retry_delay_seconds,
|
||||
)
|
||||
attempt += 1
|
||||
await asyncio.sleep(retry_delay_seconds)
|
||||
continue
|
||||
except Exception:
|
||||
logging.exception(unexpected_log_message)
|
||||
return False
|
||||
|
||||
|
||||
async def register_all_routers(dp: Dispatcher, settings: Settings):
|
||||
dp.include_router(build_root_router(settings))
|
||||
logging.info("All application routers registered.")
|
||||
|
||||
|
||||
async def on_startup_configured(dispatcher: Dispatcher):
|
||||
async def configure_telegram_webhook(dispatcher: Dispatcher) -> None:
|
||||
bot: Bot = dispatcher["bot_instance"]
|
||||
settings: Settings = dispatcher["settings"]
|
||||
i18n_instance: JsonI18n = dispatcher["i18n_instance"]
|
||||
|
||||
logging.info("STARTUP: on_startup_configured executing...")
|
||||
|
||||
telegram_webhook_url_to_set = settings.WEBHOOK_BASE_URL
|
||||
if telegram_webhook_url_to_set:
|
||||
@@ -49,7 +106,7 @@ async def on_startup_configured(dispatcher: Dispatcher):
|
||||
redact_token(full_telegram_webhook_url, settings.BOT_TOKEN),
|
||||
)
|
||||
|
||||
try:
|
||||
async def _configure_webhook() -> None:
|
||||
current_webhook_info = await bot.get_webhook_info()
|
||||
logging.info(
|
||||
f"STARTUP: Current Telegram webhook info BEFORE setting: {current_webhook_info.model_dump_json(exclude_none=True, indent=2)}" # noqa: E501
|
||||
@@ -81,16 +138,28 @@ async def on_startup_configured(dispatcher: Dispatcher):
|
||||
"STARTUP: CRITICAL - Telegram Webhook URL is EMPTY after set attempt. Check bot token and URL validity." # noqa: E501
|
||||
)
|
||||
|
||||
except Exception:
|
||||
logging.exception("STARTUP: EXCEPTION during set/get Telegram webhook.")
|
||||
await _run_telegram_startup_step(
|
||||
"configuring Telegram webhook",
|
||||
_configure_webhook,
|
||||
"STARTUP: EXCEPTION during set/get Telegram webhook.",
|
||||
)
|
||||
else:
|
||||
logging.error(
|
||||
"STARTUP: WEBHOOK_BASE_URL not set in environment. Webhook mode is required. Exiting."
|
||||
)
|
||||
raise SystemExit("WEBHOOK_BASE_URL is required. Polling mode is disabled.")
|
||||
|
||||
|
||||
async def on_startup_configured(dispatcher: Dispatcher):
|
||||
bot: Bot = dispatcher["bot_instance"]
|
||||
settings: Settings = dispatcher["settings"]
|
||||
i18n_instance: JsonI18n = dispatcher["i18n_instance"]
|
||||
|
||||
logging.info("STARTUP: on_startup_configured executing...")
|
||||
|
||||
if settings.SUBSCRIPTION_MINI_APP_URL:
|
||||
try:
|
||||
|
||||
async def _configure_mini_app_menu() -> None:
|
||||
menu_text = i18n_instance.gettext(
|
||||
settings.DEFAULT_LANGUAGE,
|
||||
"menu_personal_account_button",
|
||||
@@ -103,10 +172,14 @@ async def on_startup_configured(dispatcher: Dispatcher):
|
||||
)
|
||||
await bot.set_chat_menu_button(menu_button=MenuButtonDefault())
|
||||
logging.info("STARTUP: Mini app domain registered and default menu button restored.")
|
||||
except Exception:
|
||||
logging.exception("STARTUP: Failed to register mini app domain.")
|
||||
|
||||
try:
|
||||
await _run_telegram_startup_step(
|
||||
"registering mini app menu button",
|
||||
_configure_mini_app_menu,
|
||||
"STARTUP: Failed to register mini app domain.",
|
||||
)
|
||||
|
||||
async def _configure_bot_commands() -> None:
|
||||
bot_commands = [
|
||||
BotCommand(command="tg", description="Интерфейс в боте"),
|
||||
]
|
||||
@@ -117,8 +190,12 @@ async def on_startup_configured(dispatcher: Dispatcher):
|
||||
)
|
||||
await bot.set_my_commands(bot_commands)
|
||||
logging.info("STARTUP: bot command descriptions set.")
|
||||
except Exception:
|
||||
logging.exception("STARTUP: Failed to set bot commands.")
|
||||
|
||||
await _run_telegram_startup_step(
|
||||
"setting bot commands",
|
||||
_configure_bot_commands,
|
||||
"STARTUP: Failed to set bot commands.",
|
||||
)
|
||||
|
||||
# Initialize message queue manager
|
||||
try:
|
||||
@@ -128,42 +205,9 @@ async def on_startup_configured(dispatcher: Dispatcher):
|
||||
except Exception:
|
||||
logging.exception("STARTUP: Failed to initialize message queue manager.")
|
||||
|
||||
# Automatic sync on startup — runs in background so the dispatcher can
|
||||
# start serving Telegram webhooks immediately even if the panel is slow.
|
||||
# perform_sync is single-flight, so concurrent admin-triggered runs will
|
||||
# be skipped while this one is in progress.
|
||||
logging.info("STARTUP: Bot on_startup_configured completed.")
|
||||
|
||||
|
||||
async def _background_startup_sync(
|
||||
*,
|
||||
panel_service: PanelApiService,
|
||||
session_factory: sessionmaker,
|
||||
settings: Settings,
|
||||
i18n_instance: JsonI18n,
|
||||
) -> None:
|
||||
try:
|
||||
async with session_factory() as session:
|
||||
sync_result = await perform_sync(
|
||||
panel_service=panel_service,
|
||||
session=session,
|
||||
settings=settings,
|
||||
i18n_instance=i18n_instance,
|
||||
)
|
||||
status = sync_result.get("status")
|
||||
details = sync_result.get("details", "N/A")
|
||||
if status == "completed":
|
||||
logging.info(f"STARTUP: Background sync completed successfully. Details: {details}")
|
||||
elif status == "skipped":
|
||||
logging.info(f"STARTUP: Background sync skipped: {details}")
|
||||
else:
|
||||
logging.warning(
|
||||
f"STARTUP: Background sync finished with status '{status}'. Details: {details}"
|
||||
)
|
||||
except Exception:
|
||||
logging.exception("STARTUP: Background sync failed.")
|
||||
|
||||
|
||||
async def on_shutdown_configured(dispatcher: Dispatcher):
|
||||
logging.warning("SHUTDOWN: on_shutdown_configured executing...")
|
||||
|
||||
@@ -187,19 +231,19 @@ async def on_shutdown_configured(dispatcher: Dispatcher):
|
||||
except Exception as e:
|
||||
logging.warning(f"Failed to close session for {key}: {e}")
|
||||
|
||||
from bot.payment_providers import iter_service_keys
|
||||
|
||||
for service_key in (
|
||||
"panel_service",
|
||||
"cryptopay_service",
|
||||
"freekassa_service",
|
||||
"panel_webhook_service",
|
||||
"yookassa_service",
|
||||
"lknpd_service",
|
||||
"promo_code_service",
|
||||
"stars_service",
|
||||
"subscription_service",
|
||||
"referral_service",
|
||||
"platega_service",
|
||||
"severpay_service",
|
||||
"support_service",
|
||||
"notification_service",
|
||||
"email_auth_service",
|
||||
*iter_service_keys(),
|
||||
):
|
||||
await close_service(service_key)
|
||||
|
||||
@@ -227,13 +271,16 @@ async def run_bot(settings_param: Settings):
|
||||
if local_async_session_factory is None:
|
||||
logging.critical("Failed to initialize database connection and session factory. Exiting.")
|
||||
return
|
||||
await load_overrides_from_db(settings_param, local_async_session_factory)
|
||||
await init_db(settings_param, local_async_session_factory)
|
||||
dp, bot, extra = build_dispatcher(settings_param, local_async_session_factory)
|
||||
i18n_instance = extra["i18n_instance"]
|
||||
await load_locale_overrides(i18n_instance, local_async_session_factory)
|
||||
|
||||
# Get bot username for YooKassa default return URL if needed
|
||||
actual_bot_username = "your_bot_username"
|
||||
try:
|
||||
|
||||
async def _resolve_bot_username() -> None:
|
||||
nonlocal actual_bot_username
|
||||
bot_info = await bot.get_me()
|
||||
if bot_info.username:
|
||||
actual_bot_username = bot_info.username
|
||||
@@ -241,10 +288,14 @@ async def run_bot(settings_param: Settings):
|
||||
logging.info(f"Bot username resolved: @{actual_bot_username}")
|
||||
else:
|
||||
logging.warning("Bot username is empty; Telegram Login Widget will be unavailable.")
|
||||
except Exception as e:
|
||||
logging.error(
|
||||
f"Failed to get bot info (e.g., for YooKassa default URL): {e}. Using fallback: {actual_bot_username}" # noqa: E501
|
||||
)
|
||||
|
||||
bot_username_resolved = await _run_telegram_startup_step(
|
||||
"getting bot info from Telegram",
|
||||
_resolve_bot_username,
|
||||
f"Failed to get bot info (e.g., for YooKassa default URL). Using fallback: {actual_bot_username}", # noqa: E501
|
||||
)
|
||||
if not bot_username_resolved:
|
||||
logging.warning("Using fallback bot username: %s", actual_bot_username)
|
||||
|
||||
services = build_core_services(
|
||||
settings_param,
|
||||
@@ -275,14 +326,27 @@ async def run_bot(settings_param: Settings):
|
||||
await dp.emit_shutdown()
|
||||
raise SystemExit("WEBHOOK_BASE_URL is required. Polling mode is disabled.")
|
||||
|
||||
from bot.payment_providers import get_provider_spec
|
||||
|
||||
_yk_spec = get_provider_spec("yookassa")
|
||||
_yk_path = _yk_spec.webhook_path(settings_param) if _yk_spec and _yk_spec.webhook_path else "-"
|
||||
logging.info(
|
||||
"Starting AIOHTTP server: webhook_base=%s yookassa_path=%s",
|
||||
settings_param.WEBHOOK_BASE_URL,
|
||||
settings_param.yookassa_webhook_path,
|
||||
_yk_path,
|
||||
)
|
||||
|
||||
async def _after_webhooks_started() -> None:
|
||||
await configure_telegram_webhook(dp)
|
||||
|
||||
async def web_server_task():
|
||||
await build_and_start_web_app(dp, bot, settings_param, local_async_session_factory)
|
||||
await build_and_start_web_app(
|
||||
dp,
|
||||
bot,
|
||||
settings_param,
|
||||
local_async_session_factory,
|
||||
after_webhooks_started=_after_webhooks_started,
|
||||
)
|
||||
|
||||
main_tasks = [asyncio.create_task(web_server_task(), name="AIOHTTPServerTask")]
|
||||
|
||||
|
||||
@@ -24,6 +24,9 @@ class ActionLoggerMiddleware(BaseMiddleware):
|
||||
|
||||
result = await handler(event, data)
|
||||
|
||||
if data.get("skip_action_log") or data.get("antiflood_dropped"):
|
||||
return result
|
||||
|
||||
session: AsyncSession = data["session"]
|
||||
event_user: Optional[User] = data.get("event_from_user")
|
||||
|
||||
|
||||
@@ -11,6 +11,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot.keyboards.inline.user_keyboards import get_channel_subscription_keyboard
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.utils.channel_subscription import (
|
||||
normalize_required_channel_id,
|
||||
resolve_required_channel_link,
|
||||
)
|
||||
from config.settings import Settings
|
||||
from db.dal import user_dal
|
||||
|
||||
@@ -32,7 +36,7 @@ class ChannelSubscriptionMiddleware(BaseMiddleware):
|
||||
event: Update,
|
||||
data: Dict[str, Any],
|
||||
) -> Any:
|
||||
required_channel_id = self.settings.REQUIRED_CHANNEL_ID
|
||||
required_channel_id = normalize_required_channel_id(self.settings.REQUIRED_CHANNEL_ID)
|
||||
if not required_channel_id:
|
||||
return await handler(event, data)
|
||||
|
||||
@@ -85,10 +89,14 @@ class ChannelSubscriptionMiddleware(BaseMiddleware):
|
||||
return i18n_instance.gettext(current_lang, key)
|
||||
return key
|
||||
|
||||
bot_instance = data.get("bot") or data.get("bot_instance")
|
||||
channel_link = await resolve_required_channel_link(
|
||||
bot_instance,
|
||||
required_channel_id,
|
||||
self.settings.REQUIRED_CHANNEL_LINK,
|
||||
)
|
||||
keyboard = (
|
||||
get_channel_subscription_keyboard(
|
||||
current_lang, i18n_instance, self.settings.REQUIRED_CHANNEL_LINK
|
||||
)
|
||||
get_channel_subscription_keyboard(current_lang, i18n_instance, channel_link)
|
||||
if i18n_instance
|
||||
else None
|
||||
)
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Awaitable, Callable, Dict, Optional
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Awaitable, Callable, Dict, Iterable, List, Optional, Set, Tuple
|
||||
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.types import Update, User
|
||||
@@ -10,14 +13,307 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from config.settings import Settings
|
||||
from db.dal import user_dal
|
||||
|
||||
LocaleOverrides = Dict[str, Dict[str, str]]
|
||||
|
||||
_LOCALE_LANGUAGE_CODE_RE = re.compile(r"^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$")
|
||||
LANGUAGE_LABELS: Dict[str, str] = {
|
||||
"ru": "Русский",
|
||||
"en": "English",
|
||||
"de": "Deutsch",
|
||||
"es": "Español",
|
||||
"fr": "Français",
|
||||
"pt-br": "Português (BR)",
|
||||
"tr": "Türkçe",
|
||||
"uk": "Українська",
|
||||
}
|
||||
LANGUAGE_FLAGS: Dict[str, str] = {
|
||||
"ru": "🇷🇺",
|
||||
"en": "🇬🇧",
|
||||
"de": "🇩🇪",
|
||||
"es": "🇪🇸",
|
||||
"fr": "🇫🇷",
|
||||
"pt-br": "🇧🇷",
|
||||
"tr": "🇹🇷",
|
||||
"uk": "🇺🇦",
|
||||
}
|
||||
DEFAULT_LANGUAGE_ORDER = ("ru", "en")
|
||||
LOCALE_KEY_ALIASES: Dict[str, str] = {
|
||||
"admin_apply": "wa_apply",
|
||||
"admin_ads_col_status": "admin_status",
|
||||
"admin_ad_label_source": "admin_ads_col_source",
|
||||
"admin_back": "wa_back",
|
||||
"admin_btn_refresh": "admin_refresh",
|
||||
"admin_btn_save": "admin_save",
|
||||
"admin_btn_saving": "admin_saving",
|
||||
"admin_close": "wa_close",
|
||||
"admin_copied": "wa_copied",
|
||||
"admin_copy": "wa_copy",
|
||||
"admin_csv_amount": "admin_amount",
|
||||
"admin_csv_description": "admin_description",
|
||||
"admin_csv_payment_id": "admin_id",
|
||||
"admin_csv_status": "admin_status",
|
||||
"admin_link_copied": "wa_link_copied",
|
||||
"admin_next": "wa_next",
|
||||
"admin_payment_detail_copied": "wa_copied",
|
||||
"admin_payment_detail_provider": "admin_provider",
|
||||
"admin_payment_detail_provider_section": "admin_provider",
|
||||
"admin_payment_detail_user_section": "admin_user",
|
||||
"admin_payments_col_user_id": "admin_id",
|
||||
"admin_promo_col_code": "admin_promo_csv_code",
|
||||
"admin_promo_col_status": "admin_status",
|
||||
"admin_promo_csv_is_active": "admin_badge_active",
|
||||
"admin_promo_csv_status": "admin_status",
|
||||
"admin_promo_label_code": "admin_promo_csv_code",
|
||||
"admin_promo_unlimited_validity": "admin_promo_unlimited",
|
||||
"admin_stats_revenue_custom_range_apply": "wa_apply",
|
||||
"admin_stats_revenue_tooltip_amount": "admin_amount",
|
||||
"admin_stats_sync_status": "admin_status",
|
||||
"admin_status_active": "admin_badge_active",
|
||||
"admin_support_category": "wa_support_category",
|
||||
"admin_support_category_account": "wa_support_category_account",
|
||||
"admin_support_category_billing": "wa_support_category_billing",
|
||||
"admin_support_category_other": "wa_support_category_other",
|
||||
"admin_support_category_technical": "wa_support_category_technical",
|
||||
"admin_support_close_ticket": "wa_close",
|
||||
"admin_support_empty": "wa_support_empty",
|
||||
"admin_support_filter_active": "wa_support_filter_active",
|
||||
"admin_support_filter_all": "wa_support_filter_all",
|
||||
"admin_support_internal_note": "wa_support_internal_note",
|
||||
"admin_support_no_messages": "wa_support_no_messages",
|
||||
"admin_support_priority": "wa_support_priority",
|
||||
"admin_support_priority_high": "wa_support_priority_high",
|
||||
"admin_support_priority_low": "wa_support_priority_low",
|
||||
"admin_support_priority_normal": "wa_support_priority_normal",
|
||||
"admin_support_priority_urgent": "wa_support_priority_urgent",
|
||||
"admin_support_role_system": "wa_support_role_system",
|
||||
"admin_support_role_user": "admin_user",
|
||||
"admin_support_search": "admin_search",
|
||||
"admin_support_status": "admin_status",
|
||||
"admin_support_status_awaiting_admin": "wa_support_status_awaiting_admin",
|
||||
"admin_support_status_awaiting_user": "wa_support_status_awaiting_user",
|
||||
"admin_support_status_closed": "wa_support_status_closed",
|
||||
"admin_support_status_open": "wa_support_status_open",
|
||||
"admin_support_status_resolved": "wa_support_status_resolved",
|
||||
"admin_support_ticket_number": "wa_support_ticket_number",
|
||||
"admin_support_user_context": "admin_user",
|
||||
"admin_tariffs_legacy_traffic_packages": "admin_tariff_traffic_packages",
|
||||
"admin_tariffs_stat_enabled": "admin_enabled",
|
||||
"admin_user_btn_cancel": "wa_cancel",
|
||||
"admin_user_history_until": "wa_until_date",
|
||||
"admin_user_label_provider": "admin_provider",
|
||||
"admin_user_short": "admin_user",
|
||||
"admin_user_stats_total_label": "admin_total",
|
||||
"back_to_autopay_method_choice_button": "back_to_main_menu_button",
|
||||
"back_to_payment_methods_button": "back_to_main_menu_button",
|
||||
"cancel_broadcast_button": "cancel_button",
|
||||
"csv_no": "no_button",
|
||||
"csv_yes": "yes_button",
|
||||
"user_premium_override_status_unlimited": "user_regular_override_status_unlimited",
|
||||
"user_regular_override_save": "admin_save",
|
||||
"wa_devices_disconnect_title": "wa_devices_disconnect",
|
||||
"wa_install_link_copied": "wa_link_copied",
|
||||
"wa_link_email_modal_title": "wa_settings_link_email_action",
|
||||
}
|
||||
|
||||
|
||||
def resolve_locale_key(key: object) -> str:
|
||||
value = str(key or "").strip()
|
||||
seen: Set[str] = set()
|
||||
while value in LOCALE_KEY_ALIASES and value not in seen:
|
||||
seen.add(value)
|
||||
value = LOCALE_KEY_ALIASES[value]
|
||||
return value
|
||||
|
||||
|
||||
def is_valid_locale_language_code(value: str) -> bool:
|
||||
return 2 <= len(value) <= 16 and bool(_LOCALE_LANGUAGE_CODE_RE.fullmatch(value))
|
||||
|
||||
|
||||
def normalize_locale_language_code(
|
||||
raw: object,
|
||||
valid_languages: Optional[Set[str]] = None,
|
||||
*,
|
||||
prefer_known_base: bool = True,
|
||||
) -> str:
|
||||
value = str(raw or "").strip().lower().replace("_", "-")
|
||||
if not value:
|
||||
return ""
|
||||
if prefer_known_base and valid_languages and value not in valid_languages:
|
||||
base = value.split("-", 1)[0]
|
||||
if base in valid_languages:
|
||||
return base
|
||||
return value
|
||||
|
||||
|
||||
def _normalize_language_code(raw: object, valid_languages: Optional[Set[str]] = None) -> str:
|
||||
return normalize_locale_language_code(raw, valid_languages)
|
||||
|
||||
|
||||
def locale_language_label(code: object) -> str:
|
||||
value = normalize_locale_language_code(code, prefer_known_base=False)
|
||||
return LANGUAGE_LABELS.get(value, value.upper())
|
||||
|
||||
|
||||
def locale_language_flag(code: object) -> str:
|
||||
value = normalize_locale_language_code(code, prefer_known_base=False)
|
||||
return LANGUAGE_FLAGS.get(value, "🏳️")
|
||||
|
||||
|
||||
def sort_locale_language_codes(codes: Iterable[object]) -> List[str]:
|
||||
normalized = {normalize_locale_language_code(code, prefer_known_base=False) for code in codes}
|
||||
normalized = {code for code in normalized if code and is_valid_locale_language_code(code)}
|
||||
preferred = [code for code in DEFAULT_LANGUAGE_ORDER if code in normalized]
|
||||
rest = sorted(code for code in normalized if code not in DEFAULT_LANGUAGE_ORDER)
|
||||
return [*preferred, *rest]
|
||||
|
||||
|
||||
def locale_language_options(
|
||||
codes: Iterable[object],
|
||||
*,
|
||||
base_languages: Iterable[object] = (),
|
||||
) -> List[Dict[str, Any]]:
|
||||
base_set = set(sort_locale_language_codes(base_languages))
|
||||
return [
|
||||
{
|
||||
"code": code,
|
||||
"label": locale_language_label(code),
|
||||
"flag": locale_language_flag(code),
|
||||
"base": code in base_set,
|
||||
}
|
||||
for code in sort_locale_language_codes(codes)
|
||||
]
|
||||
|
||||
|
||||
def _valid_locale_keys_by_language(
|
||||
locales_data: Dict[str, Dict[str, str]],
|
||||
) -> Dict[str, Set[str]]:
|
||||
return {
|
||||
lang: {str(key) for key in messages.keys()}
|
||||
for lang, messages in locales_data.items()
|
||||
if isinstance(messages, dict)
|
||||
}
|
||||
|
||||
|
||||
def normalize_locale_overrides_payload(
|
||||
payload: object,
|
||||
*,
|
||||
valid_languages: Optional[Iterable[str]] = None,
|
||||
valid_keys_by_language: Optional[Dict[str, Set[str]]] = None,
|
||||
allow_extra_languages: bool = False,
|
||||
key_aliases: Optional[Dict[str, str]] = None,
|
||||
) -> Tuple[LocaleOverrides, Dict[str, str]]:
|
||||
"""Normalize a user/admin supplied locale override JSON payload.
|
||||
|
||||
The canonical shape is ``{"ru": {"welcome": "..."}, "en": {...}}``.
|
||||
For convenience, files may also wrap it as ``{"overrides": {...}}`` or
|
||||
``{"locales": {...}}``.
|
||||
"""
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
return {}, {"_payload": "invalid_payload"}
|
||||
|
||||
raw_payload = payload
|
||||
for wrapper_key in ("overrides", "locales"):
|
||||
wrapped = raw_payload.get(wrapper_key)
|
||||
if isinstance(wrapped, dict):
|
||||
raw_payload = wrapped
|
||||
break
|
||||
|
||||
valid_lang_set = {str(lang).lower() for lang in valid_languages or []}
|
||||
aliases = key_aliases or LOCALE_KEY_ALIASES
|
||||
|
||||
def resolve_payload_key(raw_key: str) -> str:
|
||||
value = raw_key
|
||||
seen: Set[str] = set()
|
||||
while value in aliases and value not in seen:
|
||||
seen.add(value)
|
||||
value = aliases[value]
|
||||
return value
|
||||
|
||||
all_valid_keys: Set[str] = set()
|
||||
if valid_keys_by_language:
|
||||
for keys in valid_keys_by_language.values():
|
||||
all_valid_keys.update(str(key) for key in keys)
|
||||
|
||||
overrides: LocaleOverrides = {}
|
||||
errors: Dict[str, str] = {}
|
||||
|
||||
for raw_lang, raw_messages in raw_payload.items():
|
||||
lang = normalize_locale_language_code(
|
||||
raw_lang,
|
||||
valid_lang_set or None,
|
||||
prefer_known_base=not allow_extra_languages,
|
||||
)
|
||||
error_key = str(raw_lang or "_language")
|
||||
if not lang:
|
||||
errors[error_key] = "invalid_language"
|
||||
continue
|
||||
if valid_lang_set and lang not in valid_lang_set:
|
||||
if not allow_extra_languages:
|
||||
errors[error_key] = "unknown_language"
|
||||
continue
|
||||
if not is_valid_locale_language_code(lang):
|
||||
errors[error_key] = "invalid_language"
|
||||
continue
|
||||
elif allow_extra_languages and not is_valid_locale_language_code(lang):
|
||||
errors[error_key] = "invalid_language"
|
||||
continue
|
||||
if not isinstance(raw_messages, dict):
|
||||
errors[lang] = "invalid_language_bucket"
|
||||
continue
|
||||
|
||||
lang_keys = valid_keys_by_language.get(lang, set()) if valid_keys_by_language else set()
|
||||
bucket: Dict[str, str] = {}
|
||||
for raw_key, raw_value in raw_messages.items():
|
||||
raw_key_text = str(raw_key or "").strip()
|
||||
key = resolve_payload_key(raw_key_text)
|
||||
item_error_key = f"{lang}.{raw_key_text or '_key'}"
|
||||
if not raw_key_text or not key:
|
||||
errors[item_error_key] = "invalid_key"
|
||||
continue
|
||||
if all_valid_keys and key not in all_valid_keys and key not in lang_keys:
|
||||
errors[item_error_key] = "unknown_key"
|
||||
continue
|
||||
if raw_value is None:
|
||||
continue
|
||||
if not isinstance(raw_value, str):
|
||||
errors[item_error_key] = "invalid_value"
|
||||
continue
|
||||
if len(raw_value) > 20000:
|
||||
errors[item_error_key] = "value_too_long"
|
||||
continue
|
||||
if raw_key_text in aliases and key in bucket:
|
||||
continue
|
||||
bucket[key] = raw_value
|
||||
if bucket:
|
||||
overrides[lang] = dict(sorted(bucket.items()))
|
||||
|
||||
return dict(sorted(overrides.items())), errors
|
||||
|
||||
|
||||
class JsonI18n:
|
||||
def __init__(self, path: str, default: str = "en", domain: str = "bot"):
|
||||
def __init__(
|
||||
self,
|
||||
path: str,
|
||||
default: str = "en",
|
||||
domain: str = "bot",
|
||||
overrides_path: Optional[str] = None,
|
||||
):
|
||||
self.domain = domain
|
||||
self.path = path
|
||||
self.default_lang = default
|
||||
self.base_locales_data: Dict[str, Dict[str, str]] = {}
|
||||
self.locale_overrides: LocaleOverrides = {}
|
||||
self.locales_data: Dict[str, Dict[str, str]] = {}
|
||||
self._overrides_path: Optional[Path] = None
|
||||
self._overrides_file_mtime_ns: Optional[int] = None
|
||||
self._overrides_file_content: Optional[str] = None
|
||||
self._overrides_file_next_check = 0.0
|
||||
self._overrides_file_check_interval_seconds = 1.0
|
||||
self._load_locales()
|
||||
if overrides_path:
|
||||
self.configure_overrides_file(overrides_path)
|
||||
self.reload_overrides_from_file(force=True)
|
||||
logging.info(
|
||||
f"JsonI18n initialized. Loaded languages: {list(self.locales_data.keys())}. Default: {self.default_lang}" # noqa: E501
|
||||
)
|
||||
@@ -26,13 +322,26 @@ class JsonI18n:
|
||||
if not os.path.isdir(self.path):
|
||||
logging.error(f"Locales path not found or not a directory: {self.path}")
|
||||
return
|
||||
loaded: Dict[str, Dict[str, str]] = {}
|
||||
for item in os.listdir(self.path):
|
||||
if item.endswith(".json"):
|
||||
lang_code = item.split(".")[0]
|
||||
file_path = os.path.join(self.path, item)
|
||||
try:
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
self.locales_data[lang_code] = json.load(f)
|
||||
data = json.load(f)
|
||||
if isinstance(data, dict):
|
||||
loaded[lang_code] = {
|
||||
str(key): str(value)
|
||||
for key, value in data.items()
|
||||
if isinstance(value, str)
|
||||
}
|
||||
else:
|
||||
logging.error(
|
||||
"Locale %s from %s is not a JSON object",
|
||||
lang_code,
|
||||
file_path,
|
||||
)
|
||||
except json.JSONDecodeError as e_json_load:
|
||||
logging.error(
|
||||
f"Error loading locale {lang_code} from {file_path} (JSON Decode Error): {e_json_load}" # noqa: E501
|
||||
@@ -42,24 +351,153 @@ class JsonI18n:
|
||||
f"Error loading locale {lang_code} from {file_path}: {e_load}",
|
||||
exc_info=True,
|
||||
)
|
||||
self.base_locales_data = loaded
|
||||
self._rebuild_effective_locales()
|
||||
|
||||
def _rebuild_effective_locales(self) -> None:
|
||||
effective: Dict[str, Dict[str, str]] = {}
|
||||
for lang, messages in self.base_locales_data.items():
|
||||
merged = dict(messages)
|
||||
merged.update(self.locale_overrides.get(lang, {}))
|
||||
effective[lang] = merged
|
||||
fallback_base = (
|
||||
self.base_locales_data.get(self.default_lang)
|
||||
or self.base_locales_data.get("en")
|
||||
or next(iter(self.base_locales_data.values()), {})
|
||||
)
|
||||
for lang, messages in self.locale_overrides.items():
|
||||
if lang in effective:
|
||||
continue
|
||||
merged = dict(fallback_base)
|
||||
merged.update(messages)
|
||||
effective[lang] = merged
|
||||
self.locales_data = effective
|
||||
|
||||
def _valid_keys_by_language(self) -> Dict[str, Set[str]]:
|
||||
return _valid_locale_keys_by_language(self.base_locales_data)
|
||||
|
||||
def language_options(self) -> List[Dict[str, Any]]:
|
||||
self.reload_overrides_from_file()
|
||||
return locale_language_options(
|
||||
self.locales_data.keys(),
|
||||
base_languages=self.base_locales_data.keys(),
|
||||
)
|
||||
|
||||
def set_locale_overrides(self, overrides: object) -> Dict[str, str]:
|
||||
normalized, errors = normalize_locale_overrides_payload(
|
||||
overrides,
|
||||
valid_languages=set(self.base_locales_data.keys()),
|
||||
valid_keys_by_language=self._valid_keys_by_language(),
|
||||
allow_extra_languages=True,
|
||||
)
|
||||
if errors:
|
||||
logging.warning("Some locale overrides were skipped: %s", errors)
|
||||
self.locale_overrides = normalized
|
||||
self._rebuild_effective_locales()
|
||||
return errors
|
||||
|
||||
def configure_overrides_file(self, path: str | Path) -> None:
|
||||
self._overrides_path = Path(path)
|
||||
try:
|
||||
self._overrides_file_mtime_ns = self._overrides_path.stat().st_mtime_ns
|
||||
except FileNotFoundError:
|
||||
self._overrides_file_mtime_ns = None
|
||||
except OSError as exc:
|
||||
logging.warning("Failed to stat locale overrides file %s: %s", path, exc)
|
||||
self._overrides_file_mtime_ns = None
|
||||
|
||||
def reload_overrides_from_file(self, *, force: bool = False) -> bool:
|
||||
if self._overrides_path is None:
|
||||
return False
|
||||
now = time.monotonic()
|
||||
if not force and now < self._overrides_file_next_check:
|
||||
return False
|
||||
self._overrides_file_next_check = now + self._overrides_file_check_interval_seconds
|
||||
|
||||
try:
|
||||
stat = self._overrides_path.stat()
|
||||
except FileNotFoundError:
|
||||
if self._overrides_file_mtime_ns is None:
|
||||
return False
|
||||
self._overrides_file_mtime_ns = None
|
||||
self._overrides_file_content = None
|
||||
logging.info(
|
||||
"Locale overrides file removed; keeping current in-memory overrides until "
|
||||
"the DB fallback is reloaded"
|
||||
)
|
||||
return False
|
||||
except OSError as exc:
|
||||
logging.warning(
|
||||
"Failed to stat locale overrides file %s: %s",
|
||||
self._overrides_path,
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
|
||||
try:
|
||||
content = self._overrides_path.read_text(encoding="utf-8")
|
||||
except OSError as exc:
|
||||
logging.warning(
|
||||
"Failed to read locale overrides file %s: %s",
|
||||
self._overrides_path,
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
|
||||
if (
|
||||
not force
|
||||
and stat.st_mtime_ns == self._overrides_file_mtime_ns
|
||||
and content == self._overrides_file_content
|
||||
):
|
||||
return False
|
||||
|
||||
try:
|
||||
payload = json.loads(content)
|
||||
except json.JSONDecodeError as exc:
|
||||
logging.warning(
|
||||
"Failed to parse locale overrides file %s: %s",
|
||||
self._overrides_path,
|
||||
exc,
|
||||
)
|
||||
self._overrides_file_mtime_ns = stat.st_mtime_ns
|
||||
self._overrides_file_content = content
|
||||
return False
|
||||
|
||||
self._overrides_file_mtime_ns = stat.st_mtime_ns
|
||||
self._overrides_file_content = content
|
||||
self.set_locale_overrides(payload)
|
||||
logging.info("Locale overrides reloaded from %s", self._overrides_path)
|
||||
return True
|
||||
|
||||
def gettext(self, lang_code: Optional[str], key: str, **kwargs) -> str:
|
||||
self.reload_overrides_from_file()
|
||||
lookup_key = resolve_locale_key(key)
|
||||
|
||||
requested_lang_code = normalize_locale_language_code(
|
||||
lang_code,
|
||||
set(self.locales_data.keys()),
|
||||
prefer_known_base=False,
|
||||
)
|
||||
requested_base_lang_code = requested_lang_code.split("-", 1)[0]
|
||||
|
||||
# Determine effective language with robust fallback
|
||||
if lang_code and lang_code in self.locales_data:
|
||||
effective_lang_code = lang_code
|
||||
if requested_lang_code and requested_lang_code in self.locales_data:
|
||||
effective_lang_code = requested_lang_code
|
||||
elif requested_base_lang_code and requested_base_lang_code in self.locales_data:
|
||||
effective_lang_code = requested_base_lang_code
|
||||
elif self.default_lang in self.locales_data:
|
||||
effective_lang_code = self.default_lang
|
||||
elif "en" in self.locales_data:
|
||||
effective_lang_code = "en"
|
||||
else:
|
||||
effective_lang_code = lang_code or self.default_lang
|
||||
effective_lang_code = requested_lang_code or self.default_lang
|
||||
|
||||
lang_data = self.locales_data.get(effective_lang_code)
|
||||
if lang_data is None:
|
||||
# Try explicit fallback to English if available
|
||||
fallback_data = self.locales_data.get("en")
|
||||
if fallback_data is not None:
|
||||
text = fallback_data.get(key)
|
||||
text = fallback_data.get(lookup_key)
|
||||
if text is not None:
|
||||
try:
|
||||
return text.format(**kwargs) if kwargs else text
|
||||
@@ -70,11 +508,11 @@ class JsonI18n:
|
||||
)
|
||||
return key.format(**kwargs) if kwargs else key
|
||||
|
||||
text = lang_data.get(key)
|
||||
text = lang_data.get(lookup_key)
|
||||
if text is None:
|
||||
if effective_lang_code != self.default_lang:
|
||||
default_lang_data = self.locales_data.get(self.default_lang, {})
|
||||
text = default_lang_data.get(key)
|
||||
text = default_lang_data.get(lookup_key)
|
||||
|
||||
if text is None:
|
||||
logging.warning(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Awaitable, Callable, Dict, Optional
|
||||
|
||||
from aiogram import BaseMiddleware
|
||||
@@ -6,9 +7,13 @@ from aiogram.types import Update
|
||||
from aiogram.types import User as TgUser
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot.utils.text_sanitizer import sanitize_display_name, sanitize_username, username_for_display
|
||||
from bot.infra.redis import cache_get_json, cache_set_json, redis_key
|
||||
from bot.utils.text_sanitizer import sanitize_display_name, sanitize_username
|
||||
from config.settings import Settings
|
||||
from db.dal import user_dal
|
||||
|
||||
_LOCAL_PROFILE_SYNC_CHECKS: Dict[int, float] = {}
|
||||
|
||||
|
||||
class ProfileSyncMiddleware(BaseMiddleware):
|
||||
async def __call__(
|
||||
@@ -19,8 +24,12 @@ class ProfileSyncMiddleware(BaseMiddleware):
|
||||
) -> Any:
|
||||
session: AsyncSession = data.get("session")
|
||||
tg_user: Optional[TgUser] = data.get("event_from_user")
|
||||
settings: Optional[Settings] = data.get("settings")
|
||||
|
||||
if session and tg_user:
|
||||
if settings and await _profile_sync_recently_checked(settings, int(tg_user.id)):
|
||||
return await handler(event, data)
|
||||
|
||||
try:
|
||||
db_user = await user_dal.get_user_by_telegram_id(session, tg_user.id)
|
||||
if not db_user:
|
||||
@@ -46,22 +55,13 @@ class ProfileSyncMiddleware(BaseMiddleware):
|
||||
f"ProfileSyncMiddleware: Updated user {tg_user.id} profile fields: {list(update_payload.keys())}" # noqa: E501
|
||||
)
|
||||
|
||||
# Also update description on panel if linked
|
||||
# Keep panel identity fields fresh, but do not rewrite
|
||||
# description from profile changes. Remnawave may return
|
||||
# description with lossy encoding in list views.
|
||||
try:
|
||||
panel_service = data.get("panel_service")
|
||||
if panel_service and db_user.panel_user_uuid:
|
||||
description_text = "\n".join(
|
||||
[
|
||||
db_user.email or "",
|
||||
username_for_display(tg_user.username, with_at=False)
|
||||
if sanitized_username is not None
|
||||
else "",
|
||||
sanitized_first_name or "",
|
||||
sanitized_last_name or "",
|
||||
]
|
||||
).strip()
|
||||
panel_payload = {
|
||||
"description": description_text,
|
||||
"telegramId": tg_user.id,
|
||||
}
|
||||
if db_user.email:
|
||||
@@ -72,12 +72,49 @@ class ProfileSyncMiddleware(BaseMiddleware):
|
||||
)
|
||||
except Exception as e_upd_desc:
|
||||
logging.warning(
|
||||
f"ProfileSyncMiddleware: Failed to update panel description for user {tg_user.id}: {e_upd_desc}" # noqa: E501
|
||||
f"ProfileSyncMiddleware: Failed to update panel identity for user {tg_user.id}: {e_upd_desc}" # noqa: E501
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(
|
||||
f"ProfileSyncMiddleware: Failed to sync profile for user {getattr(tg_user, 'id', 'N/A')}: {e}", # noqa: E501
|
||||
exc_info=True,
|
||||
)
|
||||
finally:
|
||||
if settings:
|
||||
await _mark_profile_sync_checked(settings, int(tg_user.id))
|
||||
|
||||
return await handler(event, data)
|
||||
|
||||
|
||||
async def _profile_sync_recently_checked(settings: Settings, telegram_id: int) -> bool:
|
||||
ttl_seconds = int(getattr(settings, "PROFILE_SYNC_CACHE_TTL_SECONDS", 900) or 0)
|
||||
if ttl_seconds <= 0:
|
||||
return False
|
||||
|
||||
now = time.monotonic()
|
||||
expires_at = _LOCAL_PROFILE_SYNC_CHECKS.get(telegram_id)
|
||||
if expires_at and expires_at > now:
|
||||
return True
|
||||
|
||||
key = redis_key(settings, "cache", "profile-sync", telegram_id)
|
||||
try:
|
||||
cached = await cache_get_json(settings, key)
|
||||
except Exception:
|
||||
cached = None
|
||||
if cached:
|
||||
_LOCAL_PROFILE_SYNC_CHECKS[telegram_id] = now + ttl_seconds
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def _mark_profile_sync_checked(settings: Settings, telegram_id: int) -> None:
|
||||
ttl_seconds = int(getattr(settings, "PROFILE_SYNC_CACHE_TTL_SECONDS", 900) or 0)
|
||||
if ttl_seconds <= 0:
|
||||
return
|
||||
|
||||
_LOCAL_PROFILE_SYNC_CHECKS[telegram_id] = time.monotonic() + ttl_seconds
|
||||
key = redis_key(settings, "cache", "profile-sync", telegram_id)
|
||||
try:
|
||||
await cache_set_json(settings, key, {"checked": True}, ttl_seconds)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
import time
|
||||
from collections import defaultdict, deque
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Awaitable, Callable, Deque, Dict, Optional
|
||||
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.types import Update
|
||||
|
||||
from bot.infra.redis import get_redis, redis_key
|
||||
from config.settings import Settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_WINDOW_SECONDS = 60
|
||||
DEFAULT_MAX_UPDATES_PER_WINDOW = 180
|
||||
DEFAULT_MESSAGE_MAX_PER_WINDOW = 120
|
||||
DEFAULT_CALLBACK_MAX_PER_WINDOW = 240
|
||||
DEFAULT_INLINE_MAX_PER_WINDOW = 60
|
||||
DEFAULT_START_MAX_PER_WINDOW = 30
|
||||
DEFAULT_EXPENSIVE_CALLBACK_MAX_PER_WINDOW = 60
|
||||
DEFAULT_PAYMENT_CALLBACK_COOLDOWN_SECONDS = 20
|
||||
DEFAULT_TRIAL_CALLBACK_COOLDOWN_SECONDS = 30
|
||||
|
||||
EXPENSIVE_CALLBACK_PREFIXES = (
|
||||
"pay_",
|
||||
"trial_action:confirm_activate",
|
||||
"main_action:request_trial",
|
||||
"main_action:apply_promo",
|
||||
"main_action:bot_apply_promo",
|
||||
"tariff_change:apply:",
|
||||
"tariff_change:confirm_pay:",
|
||||
"tariff_change:pay:",
|
||||
"autorenew:confirm:",
|
||||
"disconnect_device:",
|
||||
)
|
||||
|
||||
TRIAL_CALLBACK_PREFIXES = (
|
||||
"trial_action:confirm_activate",
|
||||
"main_action:request_trial",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RateLimitRule:
|
||||
window_seconds: int
|
||||
max_events: int
|
||||
|
||||
|
||||
class UpdateAntiFloodMiddleware(BaseMiddleware):
|
||||
"""Drop extreme update floods before DB-backed middleware runs."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
settings: Settings,
|
||||
*,
|
||||
default_rule: Optional[RateLimitRule] = None,
|
||||
action_rules: Optional[Dict[str, RateLimitRule]] = None,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.settings = settings
|
||||
self.default_rule = default_rule or RateLimitRule(
|
||||
window_seconds=int(
|
||||
getattr(settings, "TELEGRAM_ANTIFLOOD_WINDOW_SECONDS", DEFAULT_WINDOW_SECONDS)
|
||||
or DEFAULT_WINDOW_SECONDS
|
||||
),
|
||||
max_events=int(
|
||||
getattr(
|
||||
settings,
|
||||
"TELEGRAM_ANTIFLOOD_MAX_UPDATES_PER_WINDOW",
|
||||
DEFAULT_MAX_UPDATES_PER_WINDOW,
|
||||
)
|
||||
or DEFAULT_MAX_UPDATES_PER_WINDOW
|
||||
),
|
||||
)
|
||||
self.action_rules = action_rules or _default_action_rules(settings)
|
||||
self._local_buckets: Dict[str, Deque[float]] = defaultdict(deque)
|
||||
self._local_cooldowns: Dict[str, float] = {}
|
||||
self._local_lock = asyncio.Lock()
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
handler: Callable[[Update, Dict[str, Any]], Awaitable[Any]],
|
||||
event: Update,
|
||||
data: Dict[str, Any],
|
||||
) -> Any:
|
||||
if bool(getattr(self.settings, "TELEGRAM_DROP_NON_PRIVATE_UPDATES", True)):
|
||||
chat_type = _message_or_callback_chat_type(event)
|
||||
if chat_type is not None and chat_type != "private":
|
||||
logger.info(
|
||||
"Telegram update dropped outside private chat: chat_type=%s update_type=%s",
|
||||
chat_type,
|
||||
getattr(event, "event_type", "unknown"),
|
||||
)
|
||||
_mark_dropped(data)
|
||||
return None
|
||||
|
||||
if not bool(getattr(self.settings, "TELEGRAM_ANTIFLOOD_ENABLED", True)):
|
||||
return await handler(event, data)
|
||||
|
||||
actor_key = _update_actor_key(event)
|
||||
if not actor_key:
|
||||
return await handler(event, data)
|
||||
|
||||
action_key = _update_action_key(event)
|
||||
cooldown = _update_action_cooldown(event, self.settings)
|
||||
if cooldown and await self._is_cooldown_active(cooldown[0], cooldown[1]):
|
||||
logger.info(
|
||||
"Telegram callback dropped by action cooldown: actor=%s cooldown=%s",
|
||||
actor_key,
|
||||
cooldown[0],
|
||||
)
|
||||
_mark_dropped(data)
|
||||
await _quietly_answer_callback(event)
|
||||
return None
|
||||
|
||||
if await self._is_limited("updates", actor_key, self.default_rule) or (
|
||||
action_key
|
||||
and action_key in self.action_rules
|
||||
and await self._is_limited(action_key, actor_key, self.action_rules[action_key])
|
||||
):
|
||||
logger.warning(
|
||||
"Telegram update dropped by anti-flood: actor=%s update_type=%s",
|
||||
actor_key,
|
||||
action_key or getattr(event, "event_type", "unknown"),
|
||||
)
|
||||
_mark_dropped(data)
|
||||
return None
|
||||
|
||||
return await handler(event, data)
|
||||
|
||||
async def _is_limited(self, bucket_name: str, actor_key: str, rule: RateLimitRule) -> bool:
|
||||
if rule.window_seconds <= 0 or rule.max_events <= 0:
|
||||
return False
|
||||
|
||||
try:
|
||||
redis = await get_redis(self.settings)
|
||||
if redis is not None:
|
||||
key = redis_key(
|
||||
self.settings,
|
||||
"rate-limit",
|
||||
"telegram",
|
||||
bucket_name,
|
||||
actor_key,
|
||||
)
|
||||
current = int(await redis.incr(key))
|
||||
if current == 1:
|
||||
await redis.expire(key, rule.window_seconds)
|
||||
return current > rule.max_events
|
||||
except Exception as exc:
|
||||
logger.warning("Redis telegram anti-flood unavailable; using local fallback: %s", exc)
|
||||
|
||||
return await self._is_limited_local(f"{bucket_name}:{actor_key}", rule)
|
||||
|
||||
async def _is_cooldown_active(self, cooldown_key: str, ttl_seconds: int) -> bool:
|
||||
if ttl_seconds <= 0:
|
||||
return False
|
||||
|
||||
try:
|
||||
redis = await get_redis(self.settings)
|
||||
if redis is not None:
|
||||
key = redis_key(
|
||||
self.settings,
|
||||
"cooldown",
|
||||
"telegram",
|
||||
cooldown_key,
|
||||
)
|
||||
acquired = await redis.set(key, "1", nx=True, ex=ttl_seconds)
|
||||
return not bool(acquired)
|
||||
except Exception as exc:
|
||||
logger.warning("Redis telegram cooldown unavailable; using local fallback: %s", exc)
|
||||
|
||||
return await self._is_cooldown_active_local(cooldown_key, ttl_seconds)
|
||||
|
||||
async def _is_cooldown_active_local(self, cooldown_key: str, ttl_seconds: int) -> bool:
|
||||
now = time.monotonic()
|
||||
async with self._local_lock:
|
||||
expired = [
|
||||
key for key, expires_at in self._local_cooldowns.items() if expires_at <= now
|
||||
]
|
||||
for key in expired:
|
||||
self._local_cooldowns.pop(key, None)
|
||||
|
||||
expires_at = self._local_cooldowns.get(cooldown_key)
|
||||
if expires_at and expires_at > now:
|
||||
return True
|
||||
|
||||
self._local_cooldowns[cooldown_key] = now + ttl_seconds
|
||||
return False
|
||||
|
||||
async def _is_limited_local(self, actor_key: str, rule: RateLimitRule) -> bool:
|
||||
now = time.monotonic()
|
||||
cutoff = now - rule.window_seconds
|
||||
async with self._local_lock:
|
||||
bucket = self._local_buckets[actor_key]
|
||||
while bucket and bucket[0] <= cutoff:
|
||||
bucket.popleft()
|
||||
bucket.append(now)
|
||||
if len(bucket) > rule.max_events:
|
||||
return True
|
||||
if not bucket:
|
||||
self._local_buckets.pop(actor_key, None)
|
||||
return False
|
||||
|
||||
|
||||
def _update_actor_key(update: Update) -> Optional[str]:
|
||||
user_id = None
|
||||
chat_id = None
|
||||
|
||||
if update.message:
|
||||
user_id = update.message.from_user.id if update.message.from_user else None
|
||||
chat_id = update.message.chat.id if update.message.chat else None
|
||||
elif update.callback_query:
|
||||
user_id = update.callback_query.from_user.id if update.callback_query.from_user else None
|
||||
if update.callback_query.message and update.callback_query.message.chat:
|
||||
chat_id = update.callback_query.message.chat.id
|
||||
elif update.inline_query:
|
||||
user_id = update.inline_query.from_user.id if update.inline_query.from_user else None
|
||||
|
||||
if user_id is not None:
|
||||
return f"user:{int(user_id)}"
|
||||
if chat_id is not None:
|
||||
return f"chat:{int(chat_id)}"
|
||||
return None
|
||||
|
||||
|
||||
def _message_or_callback_chat_type(update: Update) -> Optional[str]:
|
||||
if update.message and update.message.chat:
|
||||
return str(update.message.chat.type)
|
||||
if (
|
||||
update.callback_query
|
||||
and update.callback_query.message
|
||||
and update.callback_query.message.chat
|
||||
):
|
||||
return str(update.callback_query.message.chat.type)
|
||||
return None
|
||||
|
||||
|
||||
def _update_action_key(update: Update) -> str:
|
||||
if update.message:
|
||||
text = update.message.text or ""
|
||||
if text.startswith("/start"):
|
||||
return "start"
|
||||
return "message"
|
||||
if update.callback_query:
|
||||
data = update.callback_query.data or ""
|
||||
if data.startswith(EXPENSIVE_CALLBACK_PREFIXES):
|
||||
return "expensive_callback"
|
||||
return "callback"
|
||||
if update.inline_query:
|
||||
return "inline"
|
||||
return "updates"
|
||||
|
||||
|
||||
def _update_action_cooldown(update: Update, settings: Settings) -> Optional[tuple[str, int]]:
|
||||
if not bool(getattr(settings, "TELEGRAM_ACTION_COOLDOWN_ENABLED", True)):
|
||||
return None
|
||||
if not update.callback_query or not update.callback_query.from_user:
|
||||
return None
|
||||
|
||||
callback_data = update.callback_query.data or ""
|
||||
if not callback_data:
|
||||
return None
|
||||
|
||||
user_id = int(update.callback_query.from_user.id)
|
||||
data_digest = hashlib.sha256(callback_data.encode("utf-8")).hexdigest()[:24]
|
||||
|
||||
if callback_data.startswith("pay_"):
|
||||
ttl = int(
|
||||
getattr(
|
||||
settings,
|
||||
"TELEGRAM_PAYMENT_CALLBACK_COOLDOWN_SECONDS",
|
||||
DEFAULT_PAYMENT_CALLBACK_COOLDOWN_SECONDS,
|
||||
)
|
||||
or DEFAULT_PAYMENT_CALLBACK_COOLDOWN_SECONDS
|
||||
)
|
||||
return f"payment:user:{user_id}:data:{data_digest}", ttl
|
||||
|
||||
if callback_data.startswith(TRIAL_CALLBACK_PREFIXES):
|
||||
ttl = int(
|
||||
getattr(
|
||||
settings,
|
||||
"TELEGRAM_TRIAL_CALLBACK_COOLDOWN_SECONDS",
|
||||
DEFAULT_TRIAL_CALLBACK_COOLDOWN_SECONDS,
|
||||
)
|
||||
or DEFAULT_TRIAL_CALLBACK_COOLDOWN_SECONDS
|
||||
)
|
||||
return f"trial:user:{user_id}:data:{data_digest}", ttl
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def _quietly_answer_callback(update: Update) -> None:
|
||||
callback = update.callback_query
|
||||
if not callback:
|
||||
return
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _mark_dropped(data: Dict[str, Any]) -> None:
|
||||
data["antiflood_dropped"] = True
|
||||
data["skip_action_log"] = True
|
||||
|
||||
|
||||
def _default_action_rules(settings: Settings) -> Dict[str, RateLimitRule]:
|
||||
window_seconds = int(
|
||||
getattr(settings, "TELEGRAM_ANTIFLOOD_WINDOW_SECONDS", DEFAULT_WINDOW_SECONDS)
|
||||
or DEFAULT_WINDOW_SECONDS
|
||||
)
|
||||
return {
|
||||
"message": RateLimitRule(
|
||||
window_seconds,
|
||||
int(
|
||||
getattr(
|
||||
settings,
|
||||
"TELEGRAM_ANTIFLOOD_MESSAGE_MAX_PER_WINDOW",
|
||||
DEFAULT_MESSAGE_MAX_PER_WINDOW,
|
||||
)
|
||||
or DEFAULT_MESSAGE_MAX_PER_WINDOW
|
||||
),
|
||||
),
|
||||
"callback": RateLimitRule(
|
||||
window_seconds,
|
||||
int(
|
||||
getattr(
|
||||
settings,
|
||||
"TELEGRAM_ANTIFLOOD_CALLBACK_MAX_PER_WINDOW",
|
||||
DEFAULT_CALLBACK_MAX_PER_WINDOW,
|
||||
)
|
||||
or DEFAULT_CALLBACK_MAX_PER_WINDOW
|
||||
),
|
||||
),
|
||||
"inline": RateLimitRule(
|
||||
window_seconds,
|
||||
int(
|
||||
getattr(
|
||||
settings,
|
||||
"TELEGRAM_ANTIFLOOD_INLINE_MAX_PER_WINDOW",
|
||||
DEFAULT_INLINE_MAX_PER_WINDOW,
|
||||
)
|
||||
or DEFAULT_INLINE_MAX_PER_WINDOW
|
||||
),
|
||||
),
|
||||
"start": RateLimitRule(
|
||||
window_seconds,
|
||||
int(
|
||||
getattr(
|
||||
settings,
|
||||
"TELEGRAM_ANTIFLOOD_START_MAX_PER_WINDOW",
|
||||
DEFAULT_START_MAX_PER_WINDOW,
|
||||
)
|
||||
or DEFAULT_START_MAX_PER_WINDOW
|
||||
),
|
||||
),
|
||||
"expensive_callback": RateLimitRule(
|
||||
window_seconds,
|
||||
int(
|
||||
getattr(
|
||||
settings,
|
||||
"TELEGRAM_ANTIFLOOD_EXPENSIVE_CALLBACK_MAX_PER_WINDOW",
|
||||
DEFAULT_EXPENSIVE_CALLBACK_MAX_PER_WINDOW,
|
||||
)
|
||||
or DEFAULT_EXPENSIVE_CALLBACK_MAX_PER_WINDOW
|
||||
),
|
||||
),
|
||||
}
|
||||