Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11429e887e | ||
|
|
820548cb2d | ||
|
|
e2038085ca | ||
|
|
3676bf1ae7 | ||
|
|
57f766dda2 | ||
|
|
da8f2d08df | ||
|
|
9579f019d4 | ||
|
|
7c27337e0a | ||
|
|
2dffce4244 | ||
|
|
27a9f0aff8 | ||
|
|
67920040ea | ||
|
|
fd1b910236 | ||
|
|
09ba53d185 | ||
|
|
91e79388d1 | ||
|
|
cb5d59571e | ||
|
|
365c6c7858 | ||
|
|
6886a90ee4 | ||
|
|
7d1c9ee373 | ||
|
|
dc05595b9b | ||
|
|
d2581b1c52 | ||
|
|
eb37ed4c74 | ||
|
|
6800734136 | ||
|
|
d4b7da3a54 | ||
|
|
8382fa9232 | ||
|
|
071b9f2b25 | ||
|
|
1e8ffa15d0 | ||
|
|
fc7f97c136 | ||
|
|
99732211b7 | ||
|
|
aaa8e957f6 | ||
|
|
30fb774d93 | ||
|
|
e0b5218037 | ||
|
|
387bdb6abc | ||
|
|
3298dc3e77 | ||
|
|
55dce99d34 | ||
|
|
87f9e23bae | ||
|
|
1e97dd9fe5 | ||
|
|
a05c54fc66 | ||
|
|
aeb51b9ccc | ||
|
|
b72a23f1e1 | ||
|
|
4d96a3e646 | ||
|
|
fc8e573243 | ||
|
|
f80e336e1f | ||
|
|
2a1ff8e45e | ||
|
|
e9ad259957 | ||
|
|
86ddceb646 | ||
|
|
a1725a6872 | ||
|
|
af7cee5014 | ||
|
|
b6ee5e8790 | ||
|
|
46520fb37c | ||
|
|
18ba00c98a | ||
|
|
e065c85a3e | ||
|
|
7401649841 | ||
|
|
a96007d48a | ||
|
|
2169fd8b50 | ||
|
|
2b7d1f0be8 | ||
|
|
eec5635541 | ||
|
|
e21a0758c3 | ||
|
|
defbac43d7 | ||
|
|
94612e11f2 | ||
|
|
11187487b4 | ||
|
|
f31540afdb | ||
|
|
6afbb72a34 | ||
|
|
af66103111 | ||
|
|
92277b2e27 | ||
|
|
475a1953d1 | ||
|
|
8af59750c6 | ||
|
|
00a0792c35 | ||
|
|
b9c0dfb9a1 | ||
|
|
9679190709 | ||
|
|
c8d4b3565c | ||
|
|
fe976022ec | ||
|
|
6a367c1dec | ||
|
|
9f338c3416 | ||
|
|
a575cb057b | ||
|
|
116b0a5c08 | ||
|
|
ed6b214b29 | ||
|
|
7d8bbbc904 | ||
|
|
763c4ad1bb | ||
|
|
5243cc4284 | ||
|
|
5a015fe8b4 | ||
|
|
b759ddb0f5 | ||
|
|
57821ba2a9 | ||
|
|
410cb89f3e | ||
|
|
e01f56bc34 | ||
|
|
ff59eda3d7 | ||
|
|
b7e497e241 | ||
|
|
7a81a2f20b | ||
|
|
990eba1e1e | ||
|
|
6f9a87cf98 | ||
|
|
0a9d929181 | ||
|
|
7dc4806be7 | ||
|
|
8783129c16 | ||
|
|
50d78c8746 | ||
|
|
63784bc036 | ||
|
|
aa5a9496ed | ||
|
|
00f8e5fb20 | ||
|
|
ca8df10eab | ||
|
|
b461838c0d | ||
|
|
50857d5283 | ||
|
|
449bc0c780 | ||
|
|
e1223f8c57 | ||
|
|
7822ec1b09 | ||
|
|
81f5168726 | ||
|
|
77f2420ae3 | ||
|
|
4de730bf5a | ||
|
|
77da0d97c0 | ||
|
|
e8b395cf2f | ||
|
|
d15b1be3b9 | ||
|
|
8a6b2704d4 | ||
|
|
ce610b4804 | ||
|
|
4d0241a0ea | ||
|
|
2c1e86863d | ||
|
|
16b11327ee | ||
|
|
3d5998b8fb | ||
|
|
2df4d2d770 | ||
|
|
4ce78f277e | ||
|
|
d7d5c2b1ef | ||
|
|
4958e1fdfd | ||
|
|
258b2f4dec | ||
|
|
338008bbd0 | ||
|
|
829758dc44 | ||
|
|
9deb98bbe7 | ||
|
|
26af92b5ac | ||
|
|
87b2443a06 | ||
|
|
aabc0e312d | ||
|
|
d15d58df36 | ||
|
|
21f2ac4534 | ||
|
|
dd0e6e75af | ||
|
|
0251f939d7 | ||
|
|
a00bc0f345 | ||
|
|
ae4ce43e1c | ||
|
|
19ed8916f4 | ||
|
|
f7de08bfd7 | ||
|
|
6e42f67931 | ||
|
|
b87b6cd378 | ||
|
|
b39fb73c30 | ||
|
|
3120884ed5 | ||
|
|
c927244b1a | ||
|
|
e049f38c7c | ||
|
|
ddc6ee0024 | ||
|
|
fa5f1460ab | ||
|
|
9f556f3e04 | ||
|
|
70b618a94b | ||
|
|
0610156f80 | ||
|
|
587b39de1d | ||
|
|
e35885b54b | ||
|
|
c1b7ace876 | ||
|
|
f416b0aed1 | ||
|
|
c8192e4427 | ||
|
|
dae1a6889b | ||
|
|
4740666d63 | ||
|
|
d6b703debb | ||
|
|
f1113eb80a | ||
|
|
604f0d9656 | ||
|
|
8ce5a92625 | ||
|
|
72b6e93c94 | ||
|
|
d60952718e | ||
|
|
53dcc59770 | ||
|
|
5ec179b6d6 | ||
|
|
250df445f0 | ||
|
|
12376e5070 | ||
|
|
c2afc6107a | ||
|
|
77370eb963 | ||
|
|
94b0787cad | ||
|
|
9c499fe3c2 | ||
|
|
7e26f9da9b | ||
|
|
778615a97f | ||
|
|
651572f15f | ||
|
|
5ccb8ddabe | ||
|
|
86f944e544 | ||
|
|
bc29f5ebd6 | ||
|
|
9a84be85c6 | ||
|
|
c08ac854b2 | ||
|
|
807a8933b9 | ||
|
|
b9cb1fec06 | ||
|
|
eab803652b | ||
|
|
259d0646bc | ||
|
|
4f1b7d0832 | ||
|
|
1aa529ab23 | ||
|
|
a46502380c | ||
|
|
85c276bcf5 | ||
|
|
55f8db11ce | ||
|
|
79af61a48e | ||
|
|
69d2e2a899 | ||
|
|
069ad967b5 | ||
|
|
18a6b3e18d | ||
|
|
3b9043332c | ||
|
|
d94b57bd0b | ||
|
|
5247092ca2 | ||
|
|
049789c9c5 | ||
|
|
6b74ae4a8e | ||
|
|
c13c01ea43 | ||
|
|
48e7d3569f | ||
|
|
4f3b45cbd8 | ||
|
|
a7ec55d741 | ||
|
|
0701af0f35 | ||
|
|
c0851c5339 | ||
|
|
785b6c2d41 | ||
|
|
cce3fd4f58 | ||
|
|
0dd6beebb2 | ||
|
|
8fb2a76698 | ||
|
|
3d6713caa9 | ||
|
|
8d8fdce519 | ||
|
|
c385a1466c | ||
|
|
8e1fdb33d2 | ||
|
|
29222be1fa | ||
|
|
08aca3b28d | ||
|
|
658139d607 | ||
|
|
1d4abf7977 | ||
|
|
90ab186a7c | ||
|
|
62e8bc08d5 | ||
|
|
dc725ccd68 | ||
|
|
d88aee03bb | ||
|
|
475a30caf4 | ||
|
|
c1593d7868 | ||
|
|
0dbcfe3770 | ||
|
|
e3ffb87cfc | ||
|
|
b4218ec6a7 | ||
|
|
48f8656ef8 | ||
|
|
cee8491b3d | ||
|
|
4bc3f3b4b3 | ||
|
|
adb1105621 | ||
|
|
c8fd79e4ba | ||
|
|
52bc9f71cc | ||
|
|
a1d3db0e16 | ||
|
|
d0e34126e1 | ||
|
|
c96ebea077 | ||
|
|
f00a72ad15 | ||
|
|
22171eb66f | ||
|
|
061fdeb72b | ||
|
|
8eba23574b | ||
|
|
7b650a74a6 | ||
|
|
805bd4cf60 | ||
|
|
56a32d493f | ||
|
|
b24a685066 | ||
|
|
fb06fbd0e1 | ||
|
|
719369057f | ||
|
|
cea9d98155 | ||
|
|
6cf15e0698 | ||
|
|
2971b9ad2e | ||
|
|
3e86fc76b6 | ||
|
|
829a18715a | ||
|
|
844c8e12a7 | ||
|
|
ef75f905f4 | ||
|
|
cd816cbe5c | ||
|
|
2b0fa3a314 | ||
|
|
ead990e61c | ||
|
|
eb6e343e4c | ||
|
|
caf5b88eef | ||
|
|
183bef070d | ||
|
|
643bf5fabb | ||
|
|
7d446d5f64 | ||
|
|
73ce689d5e | ||
|
|
5791f2e58c | ||
|
|
69cc2cbe29 | ||
|
|
7df2e127a4 | ||
|
|
d503cf7f7f | ||
|
|
c0d70030c3 | ||
|
|
4e7c36dbf7 | ||
|
|
3b0b88e9a0 | ||
|
|
207e7751cd | ||
|
|
c65b608f0b | ||
|
|
cff8368b7c | ||
|
|
2f367bbd6b | ||
|
|
c3d6324ec6 | ||
|
|
4800a2da80 | ||
|
|
70a0cd44a1 | ||
|
|
545c9c7d07 | ||
|
|
cd60f496fa | ||
|
|
f883065bb9 | ||
|
|
b71bd71d8d | ||
|
|
0524d24b13 | ||
|
|
cdfd94c814 | ||
|
|
6513681125 | ||
|
|
f321fd04cf | ||
|
|
baede17adf | ||
|
|
17bf8720a3 | ||
|
|
280aced20e |
@@ -1,10 +1,21 @@
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
.github
|
||||
.gitattributes
|
||||
LICENSE
|
||||
README.md
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
scratch_*.py
|
||||
scratch/
|
||||
.claude/
|
||||
*.local.*
|
||||
node_modules/
|
||||
frontend/node_modules/
|
||||
deploy/compose/docker-compose-dev.yml
|
||||
data/*
|
||||
!data/tariffs.example.json
|
||||
|
||||
|
||||
# CI
|
||||
@@ -14,9 +25,14 @@ README.md
|
||||
|
||||
# Docker
|
||||
docker-compose.yml
|
||||
Dockerfile
|
||||
.docker
|
||||
deploy/compose/*.yml
|
||||
.dockerignore
|
||||
tmp/
|
||||
|
||||
# WebApp build artifacts (regenerated inside Docker)
|
||||
backend/bot/app/web/templates/subscription_webapp.css
|
||||
backend/bot/app/web/templates/subscription_webapp.js
|
||||
backend/bot/app/web/templates/subscription_webapp.min.*.js
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
**/__pycache__/
|
||||
|
||||
@@ -3,11 +3,26 @@ BOT_TOKEN=your_bot_token_here #
|
||||
ADMIN_IDS=comma_separated_admin_ids # Your telegram ID
|
||||
|
||||
# PostgreSQL Database Connection Settings
|
||||
POSTGRES_USER=postgres # Database user name
|
||||
POSTGRES_PASSWORD=postgres # Database password
|
||||
POSTGRES_HOST=remnawave-tg-shop-db # Database container name
|
||||
POSTGRES_USER= # Required: database user name
|
||||
POSTGRES_PASSWORD= # Required: database password
|
||||
POSTGRES_HOST=remnawave-minishop-db # Database container name
|
||||
POSTGRES_PORT=5432 # Port
|
||||
POSTGRES_DB=postgres # Database name
|
||||
DB_POOL_SIZE=20 # SQLAlchemy async pool size per backend/worker process
|
||||
DB_MAX_OVERFLOW=10 # Extra transient DB connections above pool size
|
||||
DB_POOL_TIMEOUT_SECONDS=30 # Seconds to wait for a DB pool connection
|
||||
DB_POOL_RECYCLE_SECONDS=1800 # Recycle DB connections to avoid stale sockets
|
||||
|
||||
REDIS_URL=redis://redis:6379/0 # Shared Redis for FSM, rate limits, cache, locks and queues
|
||||
REDIS_KEY_PREFIX=remnawave-tg-shop # Prefix for Redis keys
|
||||
WEBAPP_ME_CACHE_TTL_SECONDS=15 # Short TTL for /api/me payload cache
|
||||
WEBAPP_RATE_LIMIT_TTL_SECONDS=60 # Redis rate-limit window
|
||||
WEBAPP_RATE_LIMIT_MAX_REQUESTS=30 # Requests per window/action/user/IP
|
||||
WEBHOOK_QUEUE_NAME=webhook-events # Redis queue for heavy webhook processing
|
||||
WEBHOOK_QUEUE_CONCURRENCY=4 # Worker webhook consumers
|
||||
WORKER_PANEL_SYNC_INTERVAL_SECONDS=900 # Worker panel sync interval
|
||||
TARIFF_WORKER_LOCK_TTL_SECONDS=240 # Redis lock TTL for tariff tick
|
||||
TARIFF_WORKER_TICK_SECONDS=300 # Tariff worker tick interval
|
||||
|
||||
# Localization and Display
|
||||
DEFAULT_LANGUAGE="ru" # or "en"
|
||||
@@ -17,7 +32,9 @@ DEFAULT_CURRENCY_SYMBOL="RUB" #
|
||||
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
|
||||
SUBSCRIPTION_MINI_APP_URL= # URL of the subscription mini-app
|
||||
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
|
||||
@@ -29,13 +46,51 @@ REQUIRED_CHANNEL_LINK=https://t.me/your_channel #
|
||||
|
||||
# Webhook Base URL (used for Telegram and payment providers)
|
||||
WEBHOOK_BASE_URL=https://webhooks.yourdomain.tld
|
||||
TRUSTED_PROXIES=127.0.0.1,::1 # Reverse proxies trusted for X-Forwarded-For
|
||||
|
||||
# Subscription Mini App (same container, separate port)
|
||||
WEBAPP_ENABLED=True # Run Mini App HTTP server
|
||||
WEBAPP_SERVER_HOST=0.0.0.0 # Internal listen host
|
||||
WEBAPP_SERVER_PORT=8081 # Internal/published Mini App port
|
||||
WEBAPP_TITLE="/minishop" # Mini App title
|
||||
WEBAPP_THEMES_DIR=data/themes # Folder with theme subfolders: <key>/theme.json and optional CSS/assets
|
||||
WEBAPP_DEFAULT_THEME= # Optional: override descriptor default theme key (e.g. light)
|
||||
WEBAPP_SESSION_SECRET= # Optional: HMAC secret for webapp sessions; generated if empty
|
||||
WEBHOOK_SECRET_TOKEN= # Optional: Telegram webhook secret token; generated if empty
|
||||
WEBAPP_SESSION_TTL_SECONDS=86400 # Web App session lifetime (24h)
|
||||
WEBAPP_AUTH_MAX_AGE_SECONDS=86400 # Max Telegram initData age
|
||||
WEBAPP_LOGIN_TOKEN_TTL_SECONDS=600 # External browser login link lifetime
|
||||
TELEGRAM_OAUTH_CLIENT_ID= # Telegram Web Login Client ID from BotFather; defaults to bot ID from BOT_TOKEN
|
||||
TELEGRAM_OAUTH_CLIENT_SECRET= # Optional Telegram Web Login Client Secret; reserved for full OIDC code flow
|
||||
TELEGRAM_OAUTH_REQUEST_ACCESS=write # Optional comma-separated permissions: write,phone; empty = OpenID profile only
|
||||
|
||||
# Email login and account linking via SMTP (Brevo SMTP relay defaults)
|
||||
SMTP_HOST=smtp-relay.brevo.com # SMTP server
|
||||
SMTP_PORT=587 # Brevo recommends 587 with STARTTLS
|
||||
SMTP_FALLBACK_PORTS=2525,465 # Tried after SMTP_PORT; 465 uses SSL automatically
|
||||
SMTP_TIMEOUT_SECONDS=30 # Per SMTP connection/send attempt timeout
|
||||
SMTP_USERNAME= # Brevo SMTP login
|
||||
SMTP_PASSWORD= # Brevo SMTP key/password
|
||||
SMTP_FROM_EMAIL= # Verified sender email
|
||||
SMTP_FROM_NAME= # Optional sender name
|
||||
SMTP_STARTTLS=True # Use STARTTLS on SMTP_PORT
|
||||
SMTP_USE_SSL=False # Use SSL wrapper, usually only for port 465
|
||||
EMAIL_CODE_TTL_SECONDS=600 # Email verification code lifetime
|
||||
EMAIL_CODE_RESEND_SECONDS=60 # Minimum delay between code sends
|
||||
EMAIL_CODE_MAX_ATTEMPTS=5 # Max attempts per code
|
||||
BRUTE_FORCE_MAX_FAILURES=5 # Max failed code attempts in the throttle window
|
||||
BRUTE_FORCE_WINDOW_SECONDS=900 # Rolling window used to count failures
|
||||
BRUTE_FORCE_LOCK_SECONDS=1800 # Temporary lockout duration after too many failures
|
||||
|
||||
# Payment Method Toggles
|
||||
YOOKASSA_ENABLED=True # Turn on YOOKASSA
|
||||
FREEKASSA_ENABLED=True # Turn on FreeKassa
|
||||
STARS_ENABLED=True # Turn on STARS
|
||||
TRIBUTE_ENABLED=True # Turn on TRIBUTE
|
||||
CRYPTOPAY_ENABLED=True # Turn on CRYPTOPAY
|
||||
PLATEGA_ENABLED=False # Turn on PLATEGA
|
||||
SEVERPAY_ENABLED=False # Turn on SeverPay
|
||||
# Order of payment methods (top to bottom). Supported: severpay, freekassa, platega, yookassa, stars, cryptopay
|
||||
PAYMENT_METHODS_ORDER=severpay,yookassa,cryptopay,freekassa,platega,stars
|
||||
|
||||
# YooKassa Payment Gateway Configuration
|
||||
YOOKASSA_SHOP_ID=your_shop_id # Your store ID in YooKassa
|
||||
@@ -44,12 +99,21 @@ YOOKASSA_RETURN_URL=https://t.me/your_bot #
|
||||
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)
|
||||
|
||||
# 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)
|
||||
|
||||
# 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
|
||||
|
||||
# CryptoBot Payment Gateway Configuration
|
||||
CRYPTOPAY_TOKEN= # API token for CryptoPay
|
||||
@@ -57,40 +121,58 @@ CRYPTOPAY_NETWORK=mainnet #
|
||||
CRYPTOPAY_CURRENCY_TYPE=fiat # Currency type (fiat or crypto)
|
||||
CRYPTOPAY_ASSET=RUB # Asset, e.g., RUB, BTC, USDT
|
||||
|
||||
# Tribute Payment Gateway Configuration
|
||||
TRIBUTE_API_KEY= # API key for verifying Tribute webhook signatures
|
||||
TRIBUTE_SKIP_NOTIFICATIONS=True # Skip renewal notifications for Tribute payments
|
||||
TRIBUTE_SKIP_CANCELLATION_NOTIFICATIONS=False # Skip cancellation notifications for Tribute payments
|
||||
# Platega Payment Gateway Configuration
|
||||
PLATEGA_BASE_URL=https://app.platega.io # Base API URL
|
||||
PLATEGA_MERCHANT_ID= # Your MerchantId from Platega
|
||||
PLATEGA_SECRET= # API secret from Platega
|
||||
PLATEGA_PAYMENT_METHOD=2 # Legacy method ID; fallback for the SBP button when PLATEGA_SBP_METHOD stays default
|
||||
PLATEGA_SBP_ENABLED=False # Show a separate "Pay via SBP" Platega button
|
||||
PLATEGA_CRYPTO_ENABLED=False # Show a separate "Pay with crypto" Platega button
|
||||
PLATEGA_SBP_METHOD=2 # Platega method ID for SBP QR (default 2)
|
||||
PLATEGA_CRYPTO_METHOD=13 # Platega method ID for crypto (default 13)
|
||||
PLATEGA_RETURN_URL= # Optional: redirect after successful payment (defaults to bot link)
|
||||
PLATEGA_FAILED_URL= # Optional: redirect after failed/cancelled payment (defaults to return URL)
|
||||
|
||||
# SeverPay Payment Gateway Configuration
|
||||
SEVERPAY_BASE_URL=https://severpay.io/api/merchant # Base API URL
|
||||
SEVERPAY_MID= # Your MID from SeverPay
|
||||
SEVERPAY_TOKEN= # API token/secret for signing requests
|
||||
SEVERPAY_RETURN_URL= # Optional: redirect URL after payment (defaults to bot link)
|
||||
SEVERPAY_LIFETIME_MINUTES= # Optional: payment link lifetime in minutes (30-4320, leave empty for default)
|
||||
|
||||
# Subscription Options. Specify cost parameters or payment links here.
|
||||
1_MONTH_ENABLED=True
|
||||
RUB_PRICE_1_MONTH=150
|
||||
STARS_PRICE_1_MONTH=0
|
||||
TRIBUTE_LINK_1_MONTH=
|
||||
|
||||
3_MONTHS_ENABLED=True
|
||||
RUB_PRICE_3_MONTHS=300
|
||||
STARS_PRICE_3_MONTHS=0
|
||||
TRIBUTE_LINK_3_MONTHS=
|
||||
|
||||
6_MONTHS_ENABLED=True
|
||||
RUB_PRICE_6_MONTHS=500
|
||||
STARS_PRICE_6_MONTHS=0
|
||||
TRIBUTE_LINK_6_MONTHS=
|
||||
|
||||
12_MONTHS_ENABLED=True
|
||||
RUB_PRICE_12_MONTHS=900
|
||||
STARS_PRICE_12_MONTHS=0
|
||||
TRIBUTE_LINK_12_MONTHS=
|
||||
|
||||
# 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
|
||||
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_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
|
||||
@@ -104,39 +186,47 @@ 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
|
||||
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)
|
||||
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)
|
||||
TRIAL_ENABLED=True # Enable the trial period
|
||||
TRIAL_DURATION_DAYS=5 # Duration of the trial period in days
|
||||
TRIAL_TRAFFIC_LIMIT_GB=0 # Traffic limit for the trial period (0 = unlimited)
|
||||
TRIAL_TRAFFIC_STRATEGY="NO_RESET" # Traffic reset strategy for the trial period (NO_RESET, WEEK, MONTH)
|
||||
|
||||
# Connection link handling (happ crypt4)
|
||||
CRYPT4_ENABLED=False # Enable happ crypt4 encryption for subscription URLs
|
||||
CRYPT4_REDIRECT_URL= # Base redirect to wrap the connect button, e.g. https://redir.example.com?url=
|
||||
|
||||
# Web Server Settings (for handling webhooks)
|
||||
WEB_SERVER_HOST="0.0.0.0"
|
||||
WEB_SERVER_PORT=8080
|
||||
|
||||
# Admin Panel Log Pagination
|
||||
LOGS_PAGE_SIZE=10 # Number of events in the log
|
||||
LOGS_PAGE_SIZE=10 # Number of events in the log
|
||||
LOG_LEVEL=INFO # Global log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
||||
|
||||
# Admin Logging Configuration
|
||||
LOG_CHAT_ID=-1001234567890 # Telegram chat/group ID for admin notifications
|
||||
LOG_THREAD_ID= # Optional: Thread ID for supergroup messages
|
||||
LOG_NEW_USERS=True # Log new user registrations
|
||||
LOG_PAYMENTS=True # Log payments
|
||||
LOG_PROMO_ACTIVATIONS=True # Log promo code activations
|
||||
LOG_TRIAL_ACTIVATIONS=True # Log trial activations
|
||||
LOG_SUSPICIOUS_ACTIVITY=True # Log suspicious activity
|
||||
LOG_CHAT_ID=-1001234567890 # Telegram chat/group ID for admin notifications
|
||||
LOG_THREAD_ID= # Optional: Thread ID for supergroup messages
|
||||
LOG_NEW_USERS=True # Log new user registrations
|
||||
LOG_PAYMENTS=True # Log payments
|
||||
LOG_PROMO_ACTIVATIONS=True # Log promo code activations
|
||||
LOG_TRIAL_ACTIVATIONS=True # Log trial activations
|
||||
LOG_SUSPICIOUS_ACTIVITY=True # Log suspicious activity
|
||||
LOG_ADMIN_ACTIONS=True # Log actions from users listed in ADMIN_IDS
|
||||
|
||||
# Embedded mode thumbnails. Please don't touch this if you don't know what it is.
|
||||
INLINE_REFERRAL_THUMBNAIL_URL=https://cdn-icons-png.flaticon.com/512/1077/1077114.png
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
*.sh text eol=lf
|
||||
deploy/docker/frontend/*.sh text eol=lf
|
||||
@@ -1,59 +0,0 @@
|
||||
name: Build and Push Dev Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
pull_request:
|
||||
branches:
|
||||
- dev
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=sha,prefix={{branch}}-
|
||||
flavor: |
|
||||
latest=false
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Image digest
|
||||
run: echo ${{ steps.meta.outputs.digest }}
|
||||
@@ -1,44 +0,0 @@
|
||||
name: Build and Publish multi-arch Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
paths-ignore:
|
||||
- 'README.md'
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up QEMU (для эмуляции arm64 на x86)
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build and push multi-arch Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: |
|
||||
ghcr.io/${{ github.repository }}:latest
|
||||
ghcr.io/${{ github.repository }}:${{ github.ref_name }}
|
||||
@@ -3,6 +3,23 @@ bot_database.sqlite3
|
||||
|
||||
# Игнорировать файлы окружения
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
docker-compose-dev.yml
|
||||
scratch_*.py
|
||||
*.local.*
|
||||
node_modules/
|
||||
.git/
|
||||
|
||||
# WebApp build artifacts (regenerated by `npm run build:webapp` / Docker build)
|
||||
bot/app/web/templates/subscription_webapp.css
|
||||
bot/app/web/templates/subscription_webapp.js
|
||||
bot/app/web/templates/subscription_webapp.min.*.js
|
||||
backend/bot/app/web/templates/subscription_webapp.css
|
||||
backend/bot/app/web/templates/subscription_webapp.js
|
||||
backend/bot/app/web/templates/subscription_webapp.min.*.js
|
||||
tmp
|
||||
.claude
|
||||
|
||||
# Игнорировать кэш Python
|
||||
__pycache__/
|
||||
@@ -17,3 +34,5 @@ __pycache__/
|
||||
locales/ru_backup.json
|
||||
locales/en_backup.json
|
||||
db/models_old.py
|
||||
data/*
|
||||
!data/tariffs.example.json
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
FROM python:3.11-slim AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN rm -rf /root/.cache
|
||||
|
||||
CMD ["python", "main.py"]
|
||||
@@ -1,198 +1,118 @@
|
||||
# Telegram-бот для продажи подписок Remnawave
|
||||
# Remnawave Minishop
|
||||
|
||||
Этот Telegram-бот предназначен для автоматизации продажи и управления подписками для панели **Remnawave**. Он интегрируется с API Remnawave для управления пользователями и подписками, а также использует различные платежные системы для приема платежей.
|
||||

|
||||
|
||||
## ✨ Ключевые возможности
|
||||
Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи и управления подписками Remnawave. Бот обрабатывает регистрацию, оплату, продление, пробный период, промокоды, рефералов и поддержку в чате. Web App показывает ссылку подключения, срок действия, трафик, оплату, устройства и вход по Telegram Mini Apps `initData`, Telegram OAuth / OpenID Connect и одноразовому email-коду.
|
||||
|
||||
### Для пользователей:
|
||||
- **Регистрация и выбор языка:** Поддержка русского и английского языков.
|
||||
- **Просмотр подписки:** Пользователи могут видеть статус своей подписки, дату окончания и ссылку на конфигурацию.
|
||||
- **Мои устройства:** Опциональный раздел для просмотра и отключения подключенных устройств (активируется через переменную `MY_DEVICES_SECTION_ENABLED`).
|
||||
- **Пробная подписка:** Система пробных подписок для новых пользователей (активируется вручную по кнопке).
|
||||
- **Промокоды:** Возможность применять промокоды для получения скидок или бонусных дней.
|
||||
- **Реферальная программа:** Пользователи могут приглашать друзей и получать за это бонусные дни подписки.
|
||||
- **Оплата:** Поддержка оплаты через YooKassa, FreeKassa (REST API), CryptoPay, Telegram Stars и Tribute.
|
||||
Проект является переработанным форком [kavore/remnawave-tg-shop](https://github.com/kavore/remnawave-tg-shop). Для переноса данных из прежнего стека используйте [инструкцию по миграции](docs/migration-to-minishop.md).
|
||||
|
||||
### Для администраторов:
|
||||
- **Защищенная админ-панель:** Доступ только для администраторов, указанных в `ADMIN_IDS`.
|
||||
- **Статистика:** Просмотр статистики использования бота (общее количество пользователей, забаненные, активные подписки), недавние платежи и статус синхронизации с панелью.
|
||||
- **Управление пользователями:** Блокировка/разблокировка пользователей, просмотр списка забаненных и детальной информации о пользователе.
|
||||
- **Рассылка:** Отправка сообщений всем пользователям, пользователям с активной или истекшей подпиской.
|
||||
- **Управление промокодами:** Создание и просмотр промокодов.
|
||||
- **Синхронизация с панелью:** Ручной запуск синхронизации пользователей и подписок с панелью Remnawave.
|
||||
- **Логи действий:** Просмотр логов всех действий пользователей.
|
||||
## Возможности
|
||||
|
||||
## 🚀 Технологии
|
||||
Для пользователей:
|
||||
|
||||
- **Python 3.11**
|
||||
- **Aiogram 3.x:** Асинхронный фреймворк для Telegram ботов.
|
||||
- **aiohttp:** Для запуска веб-сервера (вебхуки).
|
||||
- **SQLAlchemy 2.x & asyncpg:** Асинхронная работа с базой данных PostgreSQL.
|
||||
- **YooKassa, FreeKassa API, aiocryptopay:** Интеграции с платежными системами.
|
||||
- **Pydantic:** Для управления настройками из `.env` файла.
|
||||
- **Docker & Docker Compose:** Для контейнеризации и развертывания.
|
||||
- регистрация с выбором русского или английского языка;
|
||||
- просмотр статуса подписки, даты окончания, ссылки подключения и трафика;
|
||||
- покупка подписок, пакетов трафика, обычная и premium-докупка трафика, докупка устройств по настроенному каталогу тарифов;
|
||||
- Web App / Mini App с входом через Telegram или email;
|
||||
- пробный период, промокоды и реферальная программа;
|
||||
- оплата через YooKassa, FreeKassa, Platega, SeverPay, CryptoPay и Telegram Stars;
|
||||
- раздел "Мои устройства" при включенном `MY_DEVICES_SECTION_ENABLED`.
|
||||
|
||||
## ⚙️ Установка и запуск
|
||||
Для администраторов:
|
||||
|
||||
### Предварительные требования
|
||||
- админ-панель для пользователей из `ADMIN_IDS` (только при входе через Telegram, не для аккаунтов только с email);
|
||||
- статистика пользователей, подписок, платежей и синхронизации с Remnawave;
|
||||
- список пользователей с поиском, фильтрами и колонкой premium-трафика;
|
||||
- блокировка пользователей, рассылки, промокоды, логи действий и настройка разрешенных параметров приложения поверх `.env`;
|
||||
- редактор JSON-каталога тарифов с period/traffic-моделями, Internal Squads, premium-сквадами и HWID-пакетами;
|
||||
- ручная синхронизация пользователей и подписок с панелью.
|
||||
|
||||
- Установленные Docker и Docker Compose.
|
||||
- Рабочая панель Remnawave.
|
||||
- Токен Telegram-бота.
|
||||
- Данные для подключения к платежным системам (YooKassa, CryptoPay и т.д.).
|
||||
## Документация
|
||||
|
||||
### Шаги установки
|
||||
- [Настройка окружения](docs/configuration.md) - основные переменные `.env`, платежи, Remnawave, пробный период, SMTP для email-входа и секреты.
|
||||
- [Тарифы](docs/tariffs.md) - каталог тарифов, period- и traffic-модели, обычные и premium-докупки, premium-сквады, смена тарифа, HWID-лимиты и обработка трафика.
|
||||
- [Админ-панель](docs/admin.md) - права доступа, настройки, редактор тарифов, premium-сквады и сохранение JSON-каталога.
|
||||
- [Web App / Mini App](docs/webapp.md) - отдельный порт, домен, Telegram OAuth, email-вход и реферальные ссылки.
|
||||
- [Темы Web App](docs/webapp-themes.md) - кастомные темы, настройка внешнего вида, логотипы, CSS/ассеты и пайплайн создания новой темы.
|
||||
- [Развертывание](docs/deployment.md) - Docker Compose, reverse proxy, Nginx, Caddy, вебхуки, запуск из образа и обновление версии (`IMAGE_TAG`).
|
||||
- [Миграция с remnawave-tg-shop](docs/migration-to-minishop.md) - перенос данных из прежнего стека.
|
||||
|
||||
1. **Клонируйте репозиторий:**
|
||||
```bash
|
||||
git clone https://github.com/machka-pasla/remnawave-tg-shop
|
||||
cd remnawave-tg-shop
|
||||
```
|
||||
## Совместимость
|
||||
|
||||
2. **Создайте и настройте файл `.env`:**
|
||||
Скопируйте `env.example` в `.env` и заполните своими данными.
|
||||
```bash
|
||||
cp .env.example .env
|
||||
nano .env
|
||||
```
|
||||
Ниже перечислены ключевые переменные.
|
||||
Интеграция с API панели Remnawave (вебхуки, пользователи, подписки, статистика в админке и т.д.) **протестирована** на панели Remnawave версии **`> 2.7.0`**. Более старые версии могут работать частично или не работать из‑за изменений в API.
|
||||
|
||||
<details>
|
||||
<summary><b>Основные настройки</b></summary>
|
||||
## Стек
|
||||
|
||||
| Переменная | Описание | Пример |
|
||||
| --- | --- | --- |
|
||||
| `BOT_TOKEN` | **Обязательно.** Токен вашего Telegram-бота. | `1234567890:ABC-DEF1234ghIkl-zyx57W2v1u123ew11` |
|
||||
| `ADMIN_IDS` | **Обязательно.** ID администраторов в Telegram через запятую. | `12345678,98765432` |
|
||||
| `DEFAULT_LANGUAGE` | Язык по умолчанию для новых пользователей. | `ru` |
|
||||
| `SUPPORT_LINK` | (Опционально) Ссылка на поддержку. | `https://t.me/your_support` |
|
||||
| `SUBSCRIPTION_MINI_APP_URL` | (Опционально) URL Mini App для показа подписки. | `https://t.me/your_bot/app` |
|
||||
| `MY_DEVICES_SECTION_ENABLED` | Включить раздел «Мои устройства» в меню подписки (`true`/`false`). | `false` |
|
||||
| `REQUIRED_CHANNEL_ID` | (Опционально) ID канала, на который пользователь должен подписаться перед использованием. Оставьте пустым, если проверка не нужна. | `-1001234567890` |
|
||||
| `REQUIRED_CHANNEL_LINK` | (Опционально) Публичная ссылка или invite на канал для кнопки «Проверить подписку». | `https://t.me/your_channel` |
|
||||
</details>
|
||||
Сборка и runtime задаются **deploy/docker/Dockerfile** и **docker-compose.yml**; точные версии пакетов — в **backend/requirements.txt** и **frontend/package.json**.
|
||||
|
||||
<details>
|
||||
<summary><b>Настройки платежей и вебхуков</b></summary>
|
||||
| Слой | Технологии |
|
||||
| --- | --- |
|
||||
| Backend | Python **3.12**, [aiogram](https://docs.aiogram.dev/) 3.x (Telegram), **aiohttp** (HTTP и Web App), **SQLAlchemy** 2 async, **asyncpg**, **Pydantic** / pydantic-settings, **httpx**, платёжные SDK (в т.ч. YooKassa, aiocryptopay), **PyJWT** |
|
||||
| Данные | **PostgreSQL** **17** (сервис `postgres` в Compose) и **Redis** **7** (сервис `redis`) |
|
||||
| Сборка Web App | **Node.js** **22**, **Svelte** **5**, **Vite**, **Tailwind CSS** 4; артефакты попадают в шаблоны `backend/bot/app/web/templates/` |
|
||||
|
||||
| Переменная | Описание |
|
||||
| --- | --- |
|
||||
| `WEBHOOK_BASE_URL`| **Обязательно.** Базовый URL для вебхуков, например `https://your.domain.com`. |
|
||||
| `WEB_SERVER_HOST` | Хост для веб-сервера. | `0.0.0.0` |
|
||||
| `WEB_SERVER_PORT` | Порт для веб-сервера. | `8080` |
|
||||
| `YOOKASSA_ENABLED` | Включить/выключить YooKassa (`true`/`false`). |
|
||||
| `YOOKASSA_SHOP_ID` | ID вашего магазина в YooKassa. |
|
||||
| `YOOKASSA_SECRET_KEY`| Секретный ключ магазина YooKassa. |
|
||||
| `CRYPTOPAY_ENABLED` | Включить/выключить CryptoPay (`true`/`false`). |
|
||||
| `CRYPTOPAY_TOKEN` | Токен из вашего CryptoPay App. |
|
||||
| `FREEKASSA_ENABLED` | Включить/выключить FreeKassa (`true`/`false`). |
|
||||
| `FREEKASSA_MERCHANT_ID` | ID вашего магазина в FreeKassa. |
|
||||
| `FREEKASSA_API_KEY` | API-ключ для запросов к FreeKassa REST API. |
|
||||
| `FREEKASSA_SECOND_SECRET` | Секретное слово №2 — используется для проверки уведомлений от FreeKassa. |
|
||||
| `FREEKASSA_PAYMENT_URL` | (Опционально, legacy SCI) Базовый URL платёжной формы FreeKassa. По умолчанию `https://pay.freekassa.ru/`. |
|
||||
| `FREEKASSA_PAYMENT_IP` | Внешний IP вашего сервера, который будет передаваться в запрос оплаты. |
|
||||
| `STARS_ENABLED` | Включить/выключить Telegram Stars (`true`/`false`). |
|
||||
| `TRIBUTE_ENABLED`| Включить/выключить Tribute (`true`/`false`). |
|
||||
</details>
|
||||
Локальная разработка без Docker возможна при установленных Python 3.12, PostgreSQL и (для пересборки фронта) Node 22; типичный сценарий — всё через Compose.
|
||||
|
||||
<details>
|
||||
<summary><b>Настройки подписок</b></summary>
|
||||
## Быстрый старт
|
||||
|
||||
Для каждого периода (1, 3, 6, 12 месяцев) можно настроить доступность и цены:
|
||||
- `1_MONTH_ENABLED`: `true` или `false`
|
||||
- `RUB_PRICE_1_MONTH`: Цена в рублях
|
||||
- `STARS_PRICE_1_MONTH`: Цена в Telegram Stars
|
||||
- `TRIBUTE_LINK_1_MONTH`: Ссылка для оплаты через Tribute
|
||||
Аналогичные переменные есть для `3_MONTHS`, `6_MONTHS`, `12_MONTHS`.
|
||||
</details>
|
||||
Требования:
|
||||
|
||||
<details>
|
||||
<summary><b>Настройки панели Remnawave</b></summary>
|
||||
|
||||
| Переменная | Описание |
|
||||
| --- | --- |
|
||||
| `PANEL_API_URL` | URL API вашей панели Remnawave. |
|
||||
| `PANEL_API_KEY` | API ключ для доступа к панели. |
|
||||
| `PANEL_WEBHOOK_SECRET`| Секретный ключ для проверки вебхуков от панели. |
|
||||
| `USER_SQUAD_UUIDS` | ID отрядов для новых пользователей. |
|
||||
| `USER_TRAFFIC_LIMIT_GB`| Лимит трафика в ГБ (0 - безлимит). |
|
||||
| `USER_HWID_DEVICE_LIMIT`| Лимит устройств (HWID) для новых пользователей (0 - безлимит). |
|
||||
- Docker и Docker Compose;
|
||||
- рабочая панель Remnawave версии **`> 2.7.0`** (см. раздел «Совместимость»);
|
||||
- токен Telegram-бота;
|
||||
- параметры хотя бы одного платежного провайдера.
|
||||
|
||||
> Раздел "Мои устройства" становится доступен пользователям только при включении `MY_DEVICES_SECTION_ENABLED`. Значение лимита устройств при создании записей в панели берётся из `USER_HWID_DEVICE_LIMIT`.
|
||||
</gidetails>
|
||||
|
||||
<details>
|
||||
<summary><b>Настройки пробного периода</b></summary>
|
||||
|
||||
| Переменная | Описание |
|
||||
| --- | --- |
|
||||
| `TRIAL_ENABLED` | Включить/выключить пробный период (`true`/`false`). |
|
||||
| `TRIAL_DURATION_DAYS`| Длительность пробного периода в днях. |
|
||||
| `TRIAL_TRAFFIC_LIMIT_GB`| Лимит трафика для пробного периода в ГБ. |
|
||||
</details>
|
||||
|
||||
3. **Запустите контейнеры:**
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
Эта команда скачает образ и запустит сервис в фоновом режиме.
|
||||
|
||||
4. **Настройка вебхуков (Обязательно):**
|
||||
Вебхуки являются **обязательным** компонентом для работы бота, так как они используются для получения уведомлений от платежных систем (YooKassa, FreeKassa, CryptoPay, Tribute) и панели Remnawave.
|
||||
|
||||
Вам понадобится обратный прокси (например, Nginx) для обработки HTTPS-трафика и перенаправления запросов на контейнер с ботом.
|
||||
|
||||
**Пути для перенаправления:**
|
||||
- `https://<ваш_домен>/webhook/yookassa` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/yookassa`
|
||||
- `https://<ваш_домен>/webhook/freekassa` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/freekassa`
|
||||
- `https://<ваш_домен>/webhook/cryptopay` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/cryptopay`
|
||||
- `https://<ваш_домен>/webhook/tribute` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/tribute`
|
||||
- `https://<ваш_домен>/webhook/panel` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/panel`
|
||||
- **Для Telegram:** Бот автоматически установит вебхук, если в `.env` указан `WEBHOOK_BASE_URL`. Путь будет `https://<ваш_домен>/<BOT_TOKEN>`.
|
||||
|
||||
Где `remnawave-tg-shop` — это имя сервиса из `docker-compose.yml`, а `<WEB_SERVER_PORT>` — порт, указанный в `.env`.
|
||||
|
||||
5. **Просмотр логов:**
|
||||
```bash
|
||||
docker compose logs -f remnawave-tg-shop
|
||||
```
|
||||
|
||||
> 💡 Если включена проверка подписки на канал (`REQUIRED_CHANNEL_ID`), добавьте бота администратором в этот канал. Пользователь увидит кнопку «Проверить подписку», и, после первого успешного подтверждения, дальнейшие действия блокироваться не будут.
|
||||
|
||||
## 🐳 Docker
|
||||
|
||||
Файлы `Dockerfile` и `docker-compose.yml` уже настроены для сборки и запуска проекта. `docker-compose.yml` использует готовый образ с GitHub Container Registry, но вы можете раскомментировать `build: .` для локальной сборки.
|
||||
|
||||
## 📁 Структура проекта
|
||||
|
||||
```
|
||||
.
|
||||
├── bot/
|
||||
│ ├── filters/ # Пользовательские фильтры Aiogram
|
||||
│ ├── handlers/ # Обработчики сообщений и колбэков
|
||||
│ ├── keyboards/ # Клавиатуры
|
||||
│ ├── middlewares/ # Промежуточные слои (i18n, проверка бана)
|
||||
│ ├── services/ # Бизнес-логика (платежи, API панели)
|
||||
│ ├── states/ # Состояния FSM
|
||||
│ └── main_bot.py # Основная логика бота
|
||||
├── config/
|
||||
│ └── settings.py # Настройки Pydantic
|
||||
├── db/
|
||||
│ ├── dal/ # Слой доступа к данным (DAL)
|
||||
│ ├── database_setup.py # Настройка БД
|
||||
│ └── models.py # Модели SQLAlchemy
|
||||
├── locales/ # Файлы локализации (ru, en)
|
||||
├── .env.example # Пример файла с переменными окружения
|
||||
├── Dockerfile # Инструкции для сборки Docker-образа
|
||||
├── docker-compose.yml # Файл для оркестрации контейнеров
|
||||
├── requirements.txt # Зависимости Python
|
||||
└── main.py # Точка входа в приложение
|
||||
```bash
|
||||
git clone https://github.com/3252a8/remnawave-minishop
|
||||
cd remnawave-minishop
|
||||
cp .env.example .env
|
||||
nano .env
|
||||
docker compose up -d --build
|
||||
docker compose logs -f backend worker frontend
|
||||
```
|
||||
|
||||
## 🔮 Планы на будущее
|
||||
Минимально заполните в `.env`:
|
||||
|
||||
- Расширенные типы промокодов (например, скидки в процентах).
|
||||
- `BOT_TOKEN` - токен Telegram-бота;
|
||||
- `ADMIN_IDS` - Telegram ID администраторов через запятую;
|
||||
- `WEBHOOK_BASE_URL` - публичный URL вебхуков;
|
||||
- `PANEL_API_URL`, `PANEL_API_KEY`, `PANEL_WEBHOOK_SECRET` - доступ к Remnawave;
|
||||
- `USER_SQUAD_UUIDS` - Internal Squads для пользователей;
|
||||
- настройки платежного провайдера;
|
||||
- `SUBSCRIPTION_MINI_APP_URL`, если используется Web App.
|
||||
|
||||
## ❤️ Поддержка
|
||||
- Карты РФ и зарубежные: [Tribute](https://t.me/tribute/app?startapp=dqdg)
|
||||
- Crypto: `USDT TRC-20 TT3SqBbfU4vYm6SUwUVNZsy278m2xbM4GE`
|
||||
Для каталога тарифов используется `TARIFFS_CONFIG_PATH` со значением по умолчанию `data/tariffs.json`. Пример формата лежит в [data/tariffs.example.json](data/tariffs.example.json), подробности - в [docs/tariffs.md](docs/tariffs.md).
|
||||
|
||||
Если в Docker Compose включаете bind mount `./data:/app/data`, заранее создайте каталог и отдайте его пользователю контейнера. Это нужно для сохранения `data/tariffs.json`, каталога тем `data/themes`, кеша логотипа Web App и animated emoji:
|
||||
|
||||
```bash
|
||||
mkdir -p data/themes data/webapp-logo data/webapp-emoji
|
||||
chown -R 10001:10001 data
|
||||
chmod -R u+rwX data
|
||||
```
|
||||
|
||||
## Полезные команды
|
||||
|
||||
```bash
|
||||
# Локальная сборка и запуск
|
||||
docker compose up -d --build
|
||||
|
||||
# Логи приложения
|
||||
docker compose logs -f backend worker frontend
|
||||
|
||||
# Запуск с Caddy
|
||||
docker compose -f deploy/compose/docker-compose-caddy.yml up -d
|
||||
|
||||
# Запуск из готового образа
|
||||
IMAGE_TAG=3.1.0 docker compose -f deploy/compose/docker-compose-remote-server.yml up -d
|
||||
```
|
||||
|
||||
GHCR image names for releases:
|
||||
|
||||
- `ghcr.io/3252a8/remnawave-minishop-backend`
|
||||
- `ghcr.io/3252a8/remnawave-minishop-worker`
|
||||
- `ghcr.io/3252a8/remnawave-minishop-frontend`
|
||||
|
||||
## Поддержка
|
||||
|
||||
- Crypto: `USDT/Other ERC-20 0xeD506D44aae634fEc0E01C8835744fBedb7B2a44 (Ethereum/Polygon/Gnosis)`
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def configure_logging() -> None:
|
||||
level = getattr(logging, os.getenv("LOG_LEVEL", "INFO").upper(), logging.INFO)
|
||||
logging.basicConfig(
|
||||
level=level,
|
||||
stream=sys.stdout,
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
)
|
||||
@@ -1,23 +1,33 @@
|
||||
import logging
|
||||
from typing import Dict
|
||||
|
||||
from aiogram import Bot, Dispatcher
|
||||
from aiogram.enums import ParseMode
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
from aiogram.fsm.storage.memory import MemoryStorage
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from config.settings import Settings
|
||||
from bot.middlewares.db_session import DBSessionMiddleware
|
||||
from bot.middlewares.i18n import I18nMiddleware, get_i18n_instance, JsonI18n
|
||||
from bot.middlewares.ban_check_middleware import BanCheckMiddleware
|
||||
try:
|
||||
from aiogram.fsm.storage.redis import RedisStorage
|
||||
except ModuleNotFoundError: # pragma: no cover - dependency is installed in Docker image
|
||||
RedisStorage = None # type: ignore[assignment]
|
||||
|
||||
from bot.middlewares.action_logger_middleware import ActionLoggerMiddleware
|
||||
from bot.middlewares.profile_sync import ProfileSyncMiddleware
|
||||
from bot.middlewares.ban_check_middleware import BanCheckMiddleware
|
||||
from bot.middlewares.channel_subscription import ChannelSubscriptionMiddleware
|
||||
from bot.middlewares.db_session import DBSessionMiddleware
|
||||
from bot.middlewares.i18n import I18nMiddleware, get_i18n_instance
|
||||
from bot.middlewares.profile_sync import ProfileSyncMiddleware
|
||||
from config.settings import Settings
|
||||
|
||||
|
||||
def build_dispatcher(settings: Settings, async_session_factory: sessionmaker) -> tuple[Dispatcher, Bot, Dict]:
|
||||
storage = MemoryStorage()
|
||||
def build_dispatcher(
|
||||
settings: Settings, async_session_factory: sessionmaker
|
||||
) -> tuple[Dispatcher, Bot, Dict]:
|
||||
storage = (
|
||||
RedisStorage.from_url(settings.REDIS_URL)
|
||||
if settings.REDIS_URL and RedisStorage is not None
|
||||
else MemoryStorage()
|
||||
)
|
||||
default_props = DefaultBotProperties(parse_mode=ParseMode.HTML)
|
||||
bot = Bot(token=settings.BOT_TOKEN, default=default_props)
|
||||
|
||||
@@ -32,8 +42,9 @@ def build_dispatcher(settings: Settings, async_session_factory: sessionmaker) ->
|
||||
dp.update.outer_middleware(I18nMiddleware(i18n=i18n_instance, settings=settings))
|
||||
dp.update.outer_middleware(ProfileSyncMiddleware())
|
||||
dp.update.outer_middleware(BanCheckMiddleware(settings=settings, i18n_instance=i18n_instance))
|
||||
dp.update.outer_middleware(ChannelSubscriptionMiddleware(settings=settings, i18n_instance=i18n_instance))
|
||||
dp.update.outer_middleware(
|
||||
ChannelSubscriptionMiddleware(settings=settings, i18n_instance=i18n_instance)
|
||||
)
|
||||
dp.update.outer_middleware(ActionLoggerMiddleware(settings=settings))
|
||||
|
||||
return dp, bot, {"i18n_instance": i18n_instance}
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
from aiogram import Bot
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from config.settings import Settings
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.yookassa_service import YooKassaService
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.services.referral_service import ReferralService
|
||||
from bot.services.promo_code_service import PromoCodeService
|
||||
from bot.services.stars_service import StarsService
|
||||
from bot.services.tribute_service import TributeService
|
||||
from bot.services.crypto_pay_service import CryptoPayService
|
||||
from bot.services.panel_webhook_service import PanelWebhookService
|
||||
from bot.services.freekassa_service import FreeKassaService
|
||||
from bot.services.lknpd_service import LknpdService
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
from bot.services.panel_webhook_service import PanelWebhookService
|
||||
from bot.services.platega_service import PlategaService
|
||||
from bot.services.promo_code_service import PromoCodeService
|
||||
from bot.services.referral_service import ReferralService
|
||||
from bot.services.severpay_service import SeverPayService
|
||||
from bot.services.stars_service import StarsService
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.services.yookassa_service import YooKassaService
|
||||
from config.settings import Settings
|
||||
|
||||
|
||||
def build_core_services(
|
||||
@@ -45,16 +47,27 @@ def build_core_services(
|
||||
subscription_service=subscription_service,
|
||||
referral_service=referral_service,
|
||||
)
|
||||
tribute_service = TributeService(
|
||||
bot,
|
||||
settings,
|
||||
i18n,
|
||||
async_session_factory,
|
||||
panel_service,
|
||||
subscription_service,
|
||||
referral_service,
|
||||
platega_service = PlategaService(
|
||||
bot=bot,
|
||||
settings=settings,
|
||||
i18n=i18n,
|
||||
async_session_factory=async_session_factory,
|
||||
subscription_service=subscription_service,
|
||||
referral_service=referral_service,
|
||||
default_return_url=bot_username_for_default_return,
|
||||
)
|
||||
severpay_service = SeverPayService(
|
||||
bot=bot,
|
||||
settings=settings,
|
||||
i18n=i18n,
|
||||
async_session_factory=async_session_factory,
|
||||
subscription_service=subscription_service,
|
||||
referral_service=referral_service,
|
||||
default_return_url=bot_username_for_default_return,
|
||||
)
|
||||
panel_webhook_service = PanelWebhookService(
|
||||
bot, settings, i18n, async_session_factory, panel_service
|
||||
)
|
||||
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,
|
||||
@@ -62,15 +75,18 @@ def build_core_services(
|
||||
bot_username_for_default_return=bot_username_for_default_return,
|
||||
settings_obj=settings,
|
||||
)
|
||||
lknpd_service = LknpdService(
|
||||
settings.LKNPD_INN,
|
||||
settings.LKNPD_PASSWORD,
|
||||
api_url=settings.LKNPD_API_URL,
|
||||
)
|
||||
|
||||
# Wire services that depend on each other
|
||||
try:
|
||||
# Attach YooKassa to subscription service for auto-renew charges
|
||||
setattr(subscription_service, "yookassa_service", yookassa_service)
|
||||
# Allow panel webhook to trigger renewals through subscription service
|
||||
setattr(panel_webhook_service, "subscription_service", subscription_service)
|
||||
except Exception:
|
||||
pass
|
||||
# Wire services that depend on each other. These attachments are critical
|
||||
# for auto-renew (subscription_service.yookassa_service) and for the panel
|
||||
# webhook handler's 24h pre-expiry renewal trigger; do NOT swallow errors —
|
||||
# silent wiring failures previously caused auto-renew to disappear.
|
||||
subscription_service.yookassa_service = yookassa_service
|
||||
panel_webhook_service.subscription_service = subscription_service
|
||||
|
||||
return {
|
||||
"panel_service": panel_service,
|
||||
@@ -80,8 +96,9 @@ def build_core_services(
|
||||
"stars_service": stars_service,
|
||||
"cryptopay_service": cryptopay_service,
|
||||
"freekassa_service": freekassa_service,
|
||||
"tribute_service": tribute_service,
|
||||
"panel_webhook_service": panel_webhook_service,
|
||||
"yookassa_service": yookassa_service,
|
||||
"lknpd_service": lknpd_service,
|
||||
"platega_service": platega_service,
|
||||
"severpay_service": severpay_service,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Compatibility facade for the admin Mini App API."""
|
||||
|
||||
# ruff: noqa: I001
|
||||
|
||||
from bot.app.web.admin_api_impl import (
|
||||
_runtime as _runtime,
|
||||
ads as _ads,
|
||||
auth as _auth,
|
||||
broadcast as _broadcast,
|
||||
common as _common,
|
||||
logs as _logs,
|
||||
panel as _panel,
|
||||
payments as _payments,
|
||||
promos as _promos,
|
||||
routes as _routes,
|
||||
settings as _settings,
|
||||
stats as _stats,
|
||||
sync as _sync,
|
||||
tariffs as _tariffs,
|
||||
themes as _themes,
|
||||
users as _users,
|
||||
)
|
||||
|
||||
_MODULES = (
|
||||
_runtime,
|
||||
_auth,
|
||||
_common,
|
||||
_stats,
|
||||
_users,
|
||||
_payments,
|
||||
_promos,
|
||||
_logs,
|
||||
_broadcast,
|
||||
_sync,
|
||||
_ads,
|
||||
_settings,
|
||||
_tariffs,
|
||||
_themes,
|
||||
_panel,
|
||||
_routes,
|
||||
)
|
||||
|
||||
_NAMESPACE = {}
|
||||
for _module in _MODULES:
|
||||
_NAMESPACE.update(
|
||||
{
|
||||
_name: _value
|
||||
for _name, _value in vars(_module).items()
|
||||
if not _name.startswith("__") and _name != "annotations"
|
||||
}
|
||||
)
|
||||
|
||||
for _module in _MODULES:
|
||||
vars(_module).update(_NAMESPACE)
|
||||
|
||||
globals().update(_NAMESPACE)
|
||||
|
||||
__all__ = sorted(_name for _name in _NAMESPACE if not _name.startswith("__"))
|
||||
@@ -0,0 +1 @@
|
||||
"""Domain modules for the admin Mini App API."""
|
||||
@@ -0,0 +1,66 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
"""HTTP API powering the admin section of the subscription Mini App.
|
||||
|
||||
All routes require an authenticated webapp session (cookie or Bearer
|
||||
token) AND the resolved Telegram user id must appear in
|
||||
``settings.ADMIN_IDS``. Authorization is enforced via the
|
||||
``_require_admin_user_id`` helper, never trusted from the client.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from urllib.parse import parse_qsl, urlsplit, urlunsplit
|
||||
|
||||
from aiohttp import web
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy import Float, and_, case, cast, or_, select
|
||||
from sqlalchemy import func as sa_func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from bot.app.web.admin_settings_manifest import (
|
||||
manifest_payload,
|
||||
)
|
||||
from bot.infra.webhook_queue import enqueue_webhook_event
|
||||
from bot.services.referral_service import ReferralService
|
||||
from bot.services.settings_override_service import (
|
||||
current_value,
|
||||
update_overrides,
|
||||
)
|
||||
from bot.utils import MessageContent, send_message_via_queue
|
||||
from bot.utils.message_queue import get_queue_manager
|
||||
from config.settings import Settings
|
||||
from config.tariffs_config import TariffsConfig
|
||||
from db.dal import (
|
||||
ad_dal,
|
||||
app_settings_dal,
|
||||
message_log_dal,
|
||||
panel_sync_dal,
|
||||
payment_dal,
|
||||
promo_code_dal,
|
||||
subscription_dal,
|
||||
user_dal,
|
||||
)
|
||||
from db.models import (
|
||||
AdCampaign,
|
||||
MessageLog,
|
||||
Payment,
|
||||
PromoCode,
|
||||
Subscription,
|
||||
User,
|
||||
UserTelegramAvatar,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ─── Auth ──────────────────────────────────────────────────────────
|
||||
|
||||
__all__ = [name for name in globals() if not name.startswith("__")]
|
||||
@@ -0,0 +1,69 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
|
||||
async def admin_ads_list_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
campaigns = await ad_dal.list_campaigns(session)
|
||||
totals = await ad_dal.get_totals(session)
|
||||
results = []
|
||||
for campaign in campaigns:
|
||||
try:
|
||||
stats = await ad_dal.get_campaign_stats(session, campaign.ad_campaign_id)
|
||||
except Exception:
|
||||
stats = {}
|
||||
results.append(_serialize_ad(campaign, stats))
|
||||
return _ok({"campaigns": results, "totals": totals})
|
||||
|
||||
|
||||
async def admin_ad_create_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
payload = await _read_json(request)
|
||||
source = str(payload.get("source") or "").strip()
|
||||
start_param = str(payload.get("start_param") or "").strip()
|
||||
cost = float(payload.get("cost") or 0.0)
|
||||
if not source or not start_param:
|
||||
return _error(400, "invalid_payload")
|
||||
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
existing = await ad_dal.get_campaign_by_start_param(session, start_param)
|
||||
if existing:
|
||||
return _error(409, "duplicate_start_param")
|
||||
campaign = await ad_dal.create_campaign(
|
||||
session,
|
||||
source=source,
|
||||
start_param=start_param,
|
||||
cost=cost,
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(campaign)
|
||||
return _ok({"campaign": _serialize_ad(campaign)})
|
||||
|
||||
|
||||
async def admin_ad_toggle_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
campaign_id = int(request.match_info["campaign_id"])
|
||||
payload = await _read_json(request)
|
||||
is_active = bool(payload.get("is_active", True))
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
ok = await ad_dal.toggle_campaign_active(session, campaign_id, is_active)
|
||||
if not ok:
|
||||
return _error(404, "not_found")
|
||||
await session.commit()
|
||||
return _ok({})
|
||||
|
||||
|
||||
async def admin_ad_delete_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
campaign_id = int(request.match_info["campaign_id"])
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
ok = await ad_dal.delete_campaign(session, campaign_id)
|
||||
if not ok:
|
||||
return _error(404, "not_found")
|
||||
await session.commit()
|
||||
return _ok({})
|
||||
@@ -0,0 +1,58 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
|
||||
def _require_admin_user_id(request: web.Request) -> int:
|
||||
"""Return the authenticated user id, or raise 401/403 for non-admins."""
|
||||
|
||||
from bot.app.web.session import extract_authenticated_user_id
|
||||
|
||||
settings: Settings = request.app["settings"]
|
||||
user_id = extract_authenticated_user_id(request)
|
||||
if not user_id:
|
||||
raise web.HTTPUnauthorized(
|
||||
text=json.dumps({"ok": False, "error": "unauthorized"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
admin_ids = settings.ADMIN_IDS or []
|
||||
db_user_telegram_id = request.get("admin_telegram_id")
|
||||
if db_user_telegram_id is None:
|
||||
raise web.HTTPForbidden(
|
||||
text=json.dumps({"ok": False, "error": "forbidden"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
if int(db_user_telegram_id) not in {int(x) for x in admin_ids}:
|
||||
raise web.HTTPForbidden(
|
||||
text=json.dumps({"ok": False, "error": "forbidden"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
return int(user_id)
|
||||
|
||||
|
||||
@web.middleware
|
||||
async def admin_auth_middleware(request: web.Request, handler):
|
||||
"""Resolve the Telegram id of the current user and stash it on the request.
|
||||
|
||||
Doing this once per request lets every admin route call
|
||||
``_require_admin_user_id`` without re-querying the DB.
|
||||
"""
|
||||
|
||||
if not request.path.startswith("/api/admin"):
|
||||
return await handler(request)
|
||||
|
||||
from bot.app.web.session import extract_authenticated_user_id
|
||||
|
||||
user_id = extract_authenticated_user_id(request)
|
||||
if user_id:
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
if db_user and db_user.telegram_id:
|
||||
request["admin_telegram_id"] = int(db_user.telegram_id)
|
||||
elif db_user:
|
||||
# No telegram_id yet (email-only user) — can't be an admin
|
||||
request["admin_telegram_id"] = None
|
||||
|
||||
return await handler(request)
|
||||
@@ -0,0 +1,54 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
|
||||
async def admin_broadcast_route(request: web.Request) -> web.Response:
|
||||
actor_id = _require_admin_user_id(request)
|
||||
payload = await _read_json(request)
|
||||
text = str(payload.get("text") or "").strip()
|
||||
target = str(payload.get("target") or "all").strip().lower()
|
||||
if not text:
|
||||
return _error(400, "empty_text")
|
||||
if target not in {"all", "active", "inactive"}:
|
||||
target = "all"
|
||||
|
||||
queue_manager = get_queue_manager()
|
||||
if not queue_manager:
|
||||
return _error(503, "queue_unavailable")
|
||||
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
if target == "active":
|
||||
user_ids = await user_dal.get_user_ids_with_active_subscription(session)
|
||||
elif target == "inactive":
|
||||
user_ids = await user_dal.get_user_ids_without_active_subscription(session)
|
||||
else:
|
||||
user_ids = await user_dal.get_all_active_user_ids_for_broadcast(session)
|
||||
|
||||
sent = 0
|
||||
failed = 0
|
||||
for uid in user_ids:
|
||||
try:
|
||||
await send_message_via_queue(
|
||||
queue_manager,
|
||||
int(uid),
|
||||
MessageContent(content_type="text", text=text),
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
sent += 1
|
||||
except Exception as exc:
|
||||
failed += 1
|
||||
logger.debug("Broadcast queue failed for %s: %s", uid, exc)
|
||||
|
||||
await message_log_dal.create_message_log(
|
||||
session,
|
||||
{
|
||||
"user_id": actor_id,
|
||||
"event_type": "admin_broadcast_webapp",
|
||||
"content": f"target={target} sent={sent} failed={failed} text={text[:120]}",
|
||||
"is_admin_event": True,
|
||||
},
|
||||
)
|
||||
|
||||
return _ok({"queued": sent, "failed": failed, "target": target})
|
||||
@@ -0,0 +1,371 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
|
||||
def _ok(payload: Dict[str, Any], **extra) -> web.Response:
|
||||
body = {"ok": True, **payload, **extra}
|
||||
return web.json_response(body)
|
||||
|
||||
|
||||
def _error(status: int, code: str, message: str = "") -> web.Response:
|
||||
return web.json_response(
|
||||
{"ok": False, "error": code, "message": message or code},
|
||||
status=status,
|
||||
)
|
||||
|
||||
|
||||
async def _read_json(request: web.Request) -> Dict[str, Any]:
|
||||
try:
|
||||
data = await request.json()
|
||||
return data if isinstance(data, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _serialize_user(user: User) -> Dict[str, Any]:
|
||||
return {
|
||||
"user_id": int(user.user_id),
|
||||
"telegram_id": int(user.telegram_id) if user.telegram_id else None,
|
||||
"telegram_photo_url": user.telegram_photo_url,
|
||||
"username": user.username,
|
||||
"first_name": user.first_name,
|
||||
"last_name": user.last_name,
|
||||
"email": user.email,
|
||||
"language_code": user.language_code,
|
||||
"is_banned": bool(user.is_banned),
|
||||
"registration_date": user.registration_date.isoformat() if user.registration_date else None,
|
||||
"panel_user_uuid": user.panel_user_uuid,
|
||||
"referral_code": user.referral_code,
|
||||
"referred_by_id": int(user.referred_by_id) if user.referred_by_id else None,
|
||||
}
|
||||
|
||||
|
||||
def _premium_limit_bytes_from_subscription(sub: Subscription) -> int:
|
||||
premium_bonus_bytes = int(getattr(sub, "premium_bonus_bytes", 0) or 0)
|
||||
return (
|
||||
int(sub.premium_baseline_bytes or 0)
|
||||
+ int(sub.premium_topup_balance_bytes or 0)
|
||||
+ int(getattr(sub, "premium_topup_used_bytes", 0) or 0)
|
||||
+ premium_bonus_bytes
|
||||
)
|
||||
|
||||
|
||||
def _premium_traffic_list_payload(sub: Optional[Subscription]) -> Dict[str, Any]:
|
||||
"""Premium traffic column when subscription has a finite premium quota (bytes > 0).
|
||||
|
||||
Note: ``Subscription.premium_is_limited`` in the DB means *quota exhausted* for panel
|
||||
routing, not 'tariff includes premium traffic' — do not use it here.
|
||||
"""
|
||||
|
||||
if sub is None:
|
||||
return {"state": "none"}
|
||||
if bool(getattr(sub, "premium_unlimited_override", False)):
|
||||
return {
|
||||
"state": "unlimited",
|
||||
"unlimited": True,
|
||||
"used_bytes": int(sub.premium_used_bytes or 0),
|
||||
"limit_bytes": None,
|
||||
"percent": None,
|
||||
}
|
||||
limit_bytes = _premium_limit_bytes_from_subscription(sub)
|
||||
if limit_bytes <= 0:
|
||||
return {"state": "none"}
|
||||
used_bytes = int(sub.premium_used_bytes or 0)
|
||||
ratio = float(used_bytes) / float(limit_bytes) if limit_bytes else 0.0
|
||||
pct = int(max(0, min(100, round(ratio * 100))))
|
||||
if ratio >= 1.0:
|
||||
state = "critical"
|
||||
elif ratio >= 0.85:
|
||||
state = "warn"
|
||||
else:
|
||||
state = "good"
|
||||
return {
|
||||
"state": state,
|
||||
"unlimited": False,
|
||||
"used_bytes": used_bytes,
|
||||
"limit_bytes": limit_bytes,
|
||||
"percent": pct,
|
||||
}
|
||||
|
||||
|
||||
def _serialize_subscription(sub: Subscription) -> Dict[str, Any]:
|
||||
premium_bonus_bytes = int(getattr(sub, "premium_bonus_bytes", 0) or 0)
|
||||
regular_bonus_bytes = int(getattr(sub, "regular_bonus_bytes", 0) or 0)
|
||||
regular_unlimited_override = bool(getattr(sub, "regular_unlimited_override", False))
|
||||
premium_unlimited_override = bool(getattr(sub, "premium_unlimited_override", False))
|
||||
premium_limit_bytes = _premium_limit_bytes_from_subscription(sub)
|
||||
return {
|
||||
"subscription_id": int(sub.subscription_id),
|
||||
"panel_user_uuid": sub.panel_user_uuid,
|
||||
"panel_subscription_uuid": sub.panel_subscription_uuid,
|
||||
"start_date": sub.start_date.isoformat() if sub.start_date else None,
|
||||
"end_date": sub.end_date.isoformat() if sub.end_date else None,
|
||||
"duration_months": sub.duration_months,
|
||||
"is_active": bool(sub.is_active),
|
||||
"status_from_panel": sub.status_from_panel,
|
||||
"traffic_limit_bytes": sub.traffic_limit_bytes,
|
||||
"traffic_used_bytes": sub.traffic_used_bytes,
|
||||
"tier_baseline_bytes": sub.tier_baseline_bytes,
|
||||
"topup_balance_bytes": sub.topup_balance_bytes,
|
||||
"premium_used_bytes": sub.premium_used_bytes,
|
||||
"premium_limit_bytes": premium_limit_bytes,
|
||||
"premium_baseline_bytes": sub.premium_baseline_bytes,
|
||||
"premium_topup_balance_bytes": sub.premium_topup_balance_bytes,
|
||||
"premium_topup_used_bytes": getattr(sub, "premium_topup_used_bytes", 0),
|
||||
"premium_bonus_bytes": premium_bonus_bytes,
|
||||
"regular_bonus_bytes": regular_bonus_bytes,
|
||||
"regular_unlimited_override": regular_unlimited_override,
|
||||
"premium_unlimited_override": premium_unlimited_override,
|
||||
"premium_is_limited": bool(sub.premium_is_limited),
|
||||
"tariff_key": sub.tariff_key,
|
||||
"auto_renew_enabled": bool(sub.auto_renew_enabled),
|
||||
"provider": sub.provider,
|
||||
"is_throttled": bool(sub.is_throttled),
|
||||
}
|
||||
|
||||
|
||||
def _payment_traffic_gb_split(payment: Payment) -> Tuple[Optional[float], Optional[float]]:
|
||||
"""For traffic purchases: ``(regular_gb, premium_gb)``. Other payments → (None, None)."""
|
||||
if payment.purchased_gb is None:
|
||||
return None, None
|
||||
try:
|
||||
gb = float(payment.purchased_gb)
|
||||
except (TypeError, ValueError):
|
||||
return None, None
|
||||
sm = (payment.sale_mode or "").strip()
|
||||
if not sm:
|
||||
return None, None
|
||||
base = sm.split("@", 1)[0].split("|", 1)[0].lower()
|
||||
if base == "premium_topup":
|
||||
return None, gb
|
||||
if base in {"traffic", "traffic_package", "topup"}:
|
||||
return gb, None
|
||||
return None, None
|
||||
|
||||
|
||||
def _payment_user_display_label(loaded_user: Any, payment_user_id: int) -> str:
|
||||
"""Human-facing name for payments tables: TG profile name, else email, else user id."""
|
||||
if loaded_user is None:
|
||||
return str(payment_user_id)
|
||||
tid = getattr(loaded_user, "telegram_id", None)
|
||||
if tid is not None:
|
||||
fn = (getattr(loaded_user, "first_name", None) or "").strip()
|
||||
ln = (getattr(loaded_user, "last_name", None) or "").strip()
|
||||
full = f"{fn} {ln}".strip()
|
||||
if full:
|
||||
return full
|
||||
un = (getattr(loaded_user, "username", None) or "").strip()
|
||||
if un:
|
||||
return un if un.startswith("@") else f"@{un}"
|
||||
return str(payment_user_id)
|
||||
email = (getattr(loaded_user, "email", None) or "").strip()
|
||||
if email:
|
||||
return email
|
||||
return str(payment_user_id)
|
||||
|
||||
|
||||
def _serialize_payment(payment: Payment) -> Dict[str, Any]:
|
||||
# Avoid lazy-loading `payment.user` outside an active SQLAlchemy session.
|
||||
# Some admin routes serialize payments after the session scope is closed.
|
||||
telegram_id = None
|
||||
loaded_user = payment.__dict__.get("user")
|
||||
user_label = _payment_user_display_label(loaded_user, int(payment.user_id))
|
||||
if loaded_user is not None:
|
||||
tid = getattr(loaded_user, "telegram_id", None)
|
||||
if tid is not None:
|
||||
try:
|
||||
telegram_id = int(tid)
|
||||
except (TypeError, ValueError):
|
||||
telegram_id = None
|
||||
reg_gb, prem_gb = _payment_traffic_gb_split(payment)
|
||||
return {
|
||||
"payment_id": int(payment.payment_id),
|
||||
"user_id": int(payment.user_id),
|
||||
"user_label": user_label,
|
||||
"telegram_id": telegram_id,
|
||||
"traffic_regular_gb": reg_gb,
|
||||
"traffic_premium_gb": prem_gb,
|
||||
"provider": payment.provider,
|
||||
"provider_payment_id": payment.provider_payment_id,
|
||||
"amount": float(payment.amount),
|
||||
"currency": payment.currency,
|
||||
"status": payment.status,
|
||||
"description": payment.description,
|
||||
"subscription_duration_months": payment.subscription_duration_months,
|
||||
"sale_mode": payment.sale_mode,
|
||||
"tariff_key": payment.tariff_key,
|
||||
"purchased_gb": payment.purchased_gb,
|
||||
"purchased_hwid_devices": payment.purchased_hwid_devices,
|
||||
"created_at": payment.created_at.isoformat() if payment.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _serialize_promo(promo: PromoCode) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": int(promo.promo_code_id),
|
||||
"code": promo.code,
|
||||
"bonus_days": int(promo.bonus_days),
|
||||
"max_activations": int(promo.max_activations),
|
||||
"current_activations": int(promo.current_activations or 0),
|
||||
"is_active": bool(promo.is_active),
|
||||
"valid_until": promo.valid_until.isoformat() if promo.valid_until else None,
|
||||
"created_at": promo.created_at.isoformat() if promo.created_at else None,
|
||||
"created_by_admin_id": int(promo.created_by_admin_id)
|
||||
if promo.created_by_admin_id
|
||||
else None,
|
||||
}
|
||||
|
||||
|
||||
def _serialize_ad(campaign: AdCampaign, totals: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": int(campaign.ad_campaign_id),
|
||||
"source": campaign.source,
|
||||
"start_param": campaign.start_param,
|
||||
"cost": float(campaign.cost or 0),
|
||||
"is_active": bool(campaign.is_active),
|
||||
"created_at": campaign.created_at.isoformat() if campaign.created_at else None,
|
||||
"stats": totals or {},
|
||||
}
|
||||
|
||||
|
||||
def _serialize_log(entry: MessageLog) -> Dict[str, Any]:
|
||||
return {
|
||||
"log_id": int(entry.log_id),
|
||||
"user_id": int(entry.user_id) if entry.user_id else None,
|
||||
"telegram_username": entry.telegram_username,
|
||||
"telegram_first_name": entry.telegram_first_name,
|
||||
"event_type": entry.event_type,
|
||||
"content": entry.content,
|
||||
"is_admin_event": bool(entry.is_admin_event),
|
||||
"target_user_id": int(entry.target_user_id) if entry.target_user_id else None,
|
||||
"timestamp": entry.timestamp.isoformat() if entry.timestamp else None,
|
||||
}
|
||||
|
||||
|
||||
def _tariffs_config_path(settings: Settings) -> Path:
|
||||
return Path(settings.TARIFFS_CONFIG_PATH).expanduser()
|
||||
|
||||
|
||||
def _tariffs_config_payload(config: TariffsConfig) -> Dict[str, Any]:
|
||||
return config.model_dump(mode="json", exclude_none=True)
|
||||
|
||||
|
||||
def _write_tariffs_config_file(path: Path, config: TariffsConfig) -> None:
|
||||
data = _tariffs_config_payload(config)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp_path = path.with_suffix(f"{path.suffix}.tmp")
|
||||
payload = json.dumps(data, ensure_ascii=False, indent=2) + "\n"
|
||||
try:
|
||||
tmp_path.write_text(payload, encoding="utf-8")
|
||||
tmp_path.replace(path)
|
||||
except PermissionError:
|
||||
# A docker-compose single-file bind mount can make /app/config
|
||||
# unwritable while the mounted tariffs.json itself is writable.
|
||||
# Fall back to updating the existing file in-place.
|
||||
if tmp_path.exists():
|
||||
try:
|
||||
tmp_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
path.write_text(payload, encoding="utf-8")
|
||||
|
||||
|
||||
def _webapp_themes_catalog_payload(config: Any) -> Dict[str, Any]:
|
||||
return config.model_dump(mode="json", exclude_none=True)
|
||||
|
||||
|
||||
def _panel_node_uuid_key(node: Dict[str, Any]) -> str:
|
||||
uid = node.get("nodeUuid") or node.get("node_uuid") or node.get("uuid") or node.get("id")
|
||||
return str(uid).strip().lower() if uid else ""
|
||||
|
||||
|
||||
def _panel_node_users_online(node: Dict[str, Any]) -> Optional[int]:
|
||||
uo = node.get("usersOnline")
|
||||
if uo is None:
|
||||
uo = node.get("users_online")
|
||||
if uo is None:
|
||||
uo = node.get("onlineUsers") or node.get("online_users")
|
||||
if uo is None:
|
||||
mg = node.get("metricGroups")
|
||||
if isinstance(mg, dict):
|
||||
uo = mg.get("onlineUsers") or mg.get("online_users")
|
||||
if uo is None:
|
||||
return None
|
||||
try:
|
||||
return int(uo)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _panel_nodes_online_by_uuid(nodes_payload: Any) -> Dict[str, int]:
|
||||
"""Build node_uuid(lower) -> usersOnline from GET /system/stats/nodes payload."""
|
||||
out: Dict[str, int] = {}
|
||||
raw_list: Optional[List[Any]] = None
|
||||
if isinstance(nodes_payload, list):
|
||||
raw_list = nodes_payload
|
||||
elif isinstance(nodes_payload, dict):
|
||||
raw_list = nodes_payload.get("nodes")
|
||||
if raw_list is None:
|
||||
raw_list = nodes_payload.get("items") or nodes_payload.get("data")
|
||||
if not isinstance(raw_list, list):
|
||||
return out
|
||||
for n in raw_list:
|
||||
if not isinstance(n, dict):
|
||||
continue
|
||||
key = _panel_node_uuid_key(n)
|
||||
if not key:
|
||||
continue
|
||||
online = _panel_node_users_online(n)
|
||||
if online is not None:
|
||||
out[key] = online
|
||||
return out
|
||||
|
||||
|
||||
def _enrich_bandwidth_nodes_with_online(
|
||||
bw: Any,
|
||||
online_by_uuid: Dict[str, int],
|
||||
online_by_name: Optional[Dict[str, int]] = None,
|
||||
) -> None:
|
||||
"""Attach usersOnline to topNodes/series (UUID and optional node name)."""
|
||||
if not isinstance(bw, dict):
|
||||
return
|
||||
if not online_by_uuid and not online_by_name:
|
||||
return
|
||||
for key in ("topNodes", "series"):
|
||||
arr = bw.get(key)
|
||||
if not isinstance(arr, list):
|
||||
continue
|
||||
for item in arr:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
if item.get("usersOnline") is not None:
|
||||
continue
|
||||
uid = item.get("uuid") or item.get("nodeUuid") or item.get("node_uuid")
|
||||
if uid and online_by_uuid:
|
||||
hit = online_by_uuid.get(str(uid).strip().lower())
|
||||
if hit is not None:
|
||||
item["usersOnline"] = hit
|
||||
continue
|
||||
if online_by_name:
|
||||
nm = item.get("name")
|
||||
if nm and isinstance(nm, str):
|
||||
hitn = online_by_name.get(nm.strip().lower())
|
||||
if hitn is not None:
|
||||
item["usersOnline"] = hitn
|
||||
|
||||
|
||||
def _build_admin_webapp_referral_link(
|
||||
base_url: Optional[str], referral_code: Optional[str]
|
||||
) -> Optional[str]:
|
||||
"""Mirror of ``subscription_webapp._build_webapp_referral_link``.
|
||||
|
||||
Kept local to avoid a cross-module import cycle (subscription_webapp
|
||||
imports admin_api).
|
||||
"""
|
||||
if not base_url or not referral_code:
|
||||
return None
|
||||
parts = urlsplit(base_url)
|
||||
query = dict(parse_qsl(parts.query, keep_blank_values=True))
|
||||
query["ref"] = f"u{referral_code}"
|
||||
new_query = "&".join(f"{k}={v}" for k, v in query.items())
|
||||
return urlunsplit((parts.scheme, parts.netloc, parts.path, new_query, parts.fragment))
|
||||
@@ -0,0 +1,36 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
|
||||
async def admin_logs_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
|
||||
page = max(0, int(request.query.get("page", 0) or 0))
|
||||
page_size = min(200, max(1, int(request.query.get("page_size", 50) or 50)))
|
||||
user_filter = request.query.get("user_id")
|
||||
|
||||
async with async_session_factory() as session:
|
||||
if user_filter:
|
||||
try:
|
||||
user_id = int(user_filter)
|
||||
except (TypeError, ValueError):
|
||||
return _error(400, "invalid_user_id")
|
||||
entries = await message_log_dal.get_user_message_logs(
|
||||
session, user_id, page_size, page * page_size
|
||||
)
|
||||
total = await message_log_dal.count_user_message_logs(session, user_id)
|
||||
else:
|
||||
entries = await message_log_dal.get_all_message_logs(
|
||||
session, page_size, page * page_size
|
||||
)
|
||||
total = await message_log_dal.count_all_message_logs(session)
|
||||
|
||||
return _ok(
|
||||
{
|
||||
"logs": [_serialize_log(entry) for entry in entries],
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total": int(total or 0),
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,35 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
|
||||
async def admin_panel_internal_squads_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
panel_service = request.app.get("panel_service")
|
||||
if panel_service is None:
|
||||
return _error(503, "panel_unavailable", "Panel service unavailable")
|
||||
try:
|
||||
squads = await panel_service.get_internal_squads()
|
||||
except Exception as exc:
|
||||
logger.exception("Failed to load internal squads from panel")
|
||||
return _error(502, "panel_request_failed", str(exc))
|
||||
if squads is None:
|
||||
return _error(502, "panel_request_failed", "Unable to load internal squads")
|
||||
items = []
|
||||
for squad in squads:
|
||||
if not isinstance(squad, dict):
|
||||
continue
|
||||
uuid = squad.get("uuid") or squad.get("id")
|
||||
if not uuid:
|
||||
continue
|
||||
items.append(
|
||||
{
|
||||
"uuid": str(uuid),
|
||||
"name": squad.get("name") or squad.get("title") or str(uuid),
|
||||
"members_count": squad.get("membersCount")
|
||||
or squad.get("usersCount")
|
||||
or squad.get("members_count"),
|
||||
"active_inbounds_count": squad.get("activeInboundsCount")
|
||||
or squad.get("active_inbounds_count"),
|
||||
}
|
||||
)
|
||||
return _ok({"squads": items})
|
||||
@@ -0,0 +1,95 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
|
||||
async def admin_payments_list_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
|
||||
page = max(0, int(request.query.get("page", 0) or 0))
|
||||
page_size = min(100, max(1, int(request.query.get("page_size", 25) or 25)))
|
||||
|
||||
async with async_session_factory() as session:
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
stmt = (
|
||||
select(Payment)
|
||||
.options(selectinload(Payment.user))
|
||||
.order_by(Payment.created_at.desc())
|
||||
.offset(page * page_size)
|
||||
.limit(page_size)
|
||||
)
|
||||
rows = (await session.execute(stmt)).scalars().all()
|
||||
total = await payment_dal.get_payments_count(session)
|
||||
|
||||
return _ok(
|
||||
{
|
||||
"payments": [_serialize_payment(p) for p in rows],
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total": int(total or 0),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def admin_payments_export_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
|
||||
async with async_session_factory() as session:
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
stmt = (
|
||||
select(Payment)
|
||||
.options(selectinload(Payment.user))
|
||||
.order_by(Payment.created_at.desc())
|
||||
.limit(10000)
|
||||
)
|
||||
rows = (await session.execute(stmt)).scalars().all()
|
||||
|
||||
buffer = io.StringIO()
|
||||
writer = csv.writer(buffer)
|
||||
writer.writerow(
|
||||
[
|
||||
"payment_id",
|
||||
"user_id",
|
||||
"user_label",
|
||||
"provider",
|
||||
"provider_payment_id",
|
||||
"amount",
|
||||
"currency",
|
||||
"status",
|
||||
"description",
|
||||
"duration_months",
|
||||
"sale_mode",
|
||||
"tariff_key",
|
||||
"created_at",
|
||||
]
|
||||
)
|
||||
for p in rows:
|
||||
label = _payment_user_display_label(p.user, int(p.user_id)) if p.user else str(p.user_id)
|
||||
writer.writerow(
|
||||
[
|
||||
p.payment_id,
|
||||
p.user_id,
|
||||
label,
|
||||
p.provider,
|
||||
p.provider_payment_id or "",
|
||||
p.amount,
|
||||
p.currency,
|
||||
p.status,
|
||||
p.description or "",
|
||||
p.subscription_duration_months or "",
|
||||
p.sale_mode or "",
|
||||
p.tariff_key or "",
|
||||
p.created_at.isoformat() if p.created_at else "",
|
||||
]
|
||||
)
|
||||
|
||||
response = web.Response(
|
||||
body=buffer.getvalue().encode("utf-8-sig"),
|
||||
content_type="text/csv",
|
||||
charset="utf-8",
|
||||
)
|
||||
response.headers["Content-Disposition"] = 'attachment; filename="payments.csv"'
|
||||
return response
|
||||
@@ -0,0 +1,96 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
|
||||
async def admin_promos_list_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
page = max(0, int(request.query.get("page", 0) or 0))
|
||||
page_size = min(100, max(1, int(request.query.get("page_size", 25) or 25)))
|
||||
async with async_session_factory() as session:
|
||||
promos = await promo_code_dal.get_all_promo_codes_with_details(
|
||||
session, limit=page_size, offset=page * page_size
|
||||
)
|
||||
total = await promo_code_dal.get_promo_codes_count(session)
|
||||
return _ok(
|
||||
{
|
||||
"promos": [_serialize_promo(p) for p in promos],
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total": int(total or 0),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def admin_promo_create_route(request: web.Request) -> web.Response:
|
||||
actor_id = _require_admin_user_id(request)
|
||||
payload = await _read_json(request)
|
||||
code = str(payload.get("code") or "").strip().upper()
|
||||
bonus_days = int(payload.get("bonus_days") or 0)
|
||||
max_activations = int(payload.get("max_activations") or 0)
|
||||
valid_days = payload.get("valid_days")
|
||||
if not code or bonus_days <= 0 or max_activations <= 0:
|
||||
return _error(400, "invalid_payload")
|
||||
|
||||
valid_until = None
|
||||
if valid_days:
|
||||
try:
|
||||
valid_until = datetime.now(timezone.utc) + timedelta(days=int(valid_days))
|
||||
except (TypeError, ValueError):
|
||||
return _error(400, "invalid_valid_days")
|
||||
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
existing = await promo_code_dal.get_promo_code_by_code(session, code)
|
||||
if existing:
|
||||
return _error(409, "duplicate_code")
|
||||
promo = await promo_code_dal.create_promo_code(
|
||||
session,
|
||||
{
|
||||
"code": code,
|
||||
"bonus_days": bonus_days,
|
||||
"max_activations": max_activations,
|
||||
"valid_until": valid_until,
|
||||
"created_by_admin_id": actor_id,
|
||||
"is_active": True,
|
||||
},
|
||||
)
|
||||
await session.commit()
|
||||
return _ok({"promo": _serialize_promo(promo)})
|
||||
|
||||
|
||||
async def admin_promo_update_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
promo_id = int(request.match_info["promo_id"])
|
||||
payload = await _read_json(request)
|
||||
update_data: Dict[str, Any] = {}
|
||||
if "is_active" in payload:
|
||||
update_data["is_active"] = bool(payload["is_active"])
|
||||
if "bonus_days" in payload and payload["bonus_days"] is not None:
|
||||
update_data["bonus_days"] = int(payload["bonus_days"])
|
||||
if "max_activations" in payload and payload["max_activations"] is not None:
|
||||
update_data["max_activations"] = int(payload["max_activations"])
|
||||
|
||||
if not update_data:
|
||||
return _error(400, "no_changes")
|
||||
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
promo = await promo_code_dal.update_promo_code(session, promo_id, update_data)
|
||||
if not promo:
|
||||
return _error(404, "not_found")
|
||||
await session.commit()
|
||||
await session.refresh(promo)
|
||||
return _ok({"promo": _serialize_promo(promo)})
|
||||
|
||||
|
||||
async def admin_promo_delete_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
promo_id = int(request.match_info["promo_id"])
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
promo = await promo_code_dal.delete_promo_code(session, promo_id)
|
||||
if not promo:
|
||||
return _error(404, "not_found")
|
||||
await session.commit()
|
||||
return _ok({})
|
||||
@@ -0,0 +1,61 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
|
||||
def setup_admin_routes(app: web.Application) -> None:
|
||||
router = app.router
|
||||
router.add_get("/api/admin/me", admin_me_route)
|
||||
router.add_get("/api/admin/stats", admin_stats_route)
|
||||
|
||||
router.add_get("/api/admin/users", admin_users_list_route)
|
||||
router.add_get("/api/admin/users/{user_id:-?\\d+}", admin_user_detail_route)
|
||||
router.add_get("/api/admin/users/{user_id:-?\\d+}/avatar", admin_user_avatar_route)
|
||||
router.add_post("/api/admin/users/{user_id:-?\\d+}/ban", admin_user_ban_route)
|
||||
router.add_post("/api/admin/users/{user_id:-?\\d+}/message", admin_user_message_route)
|
||||
router.add_post(
|
||||
"/api/admin/users/{user_id:-?\\d+}/message/preview", admin_user_message_preview_route
|
||||
)
|
||||
router.add_post("/api/admin/users/{user_id:-?\\d+}/reset-trial", admin_user_reset_trial_route)
|
||||
router.add_post("/api/admin/users/{user_id:-?\\d+}/extend", admin_user_extend_route)
|
||||
router.add_post(
|
||||
"/api/admin/users/{user_id:-?\\d+}/premium-override",
|
||||
admin_user_premium_override_route,
|
||||
)
|
||||
router.add_post(
|
||||
"/api/admin/users/{user_id:-?\\d+}/regular-traffic-override",
|
||||
admin_user_regular_traffic_override_route,
|
||||
)
|
||||
router.add_post(
|
||||
"/api/admin/users/{user_id:-?\\d+}/traffic-grant",
|
||||
admin_user_traffic_grant_route,
|
||||
)
|
||||
router.add_delete("/api/admin/users/{user_id:-?\\d+}", admin_user_delete_route)
|
||||
|
||||
router.add_get("/api/admin/payments", admin_payments_list_route)
|
||||
router.add_get("/api/admin/payments/export.csv", admin_payments_export_route)
|
||||
|
||||
router.add_get("/api/admin/promos", admin_promos_list_route)
|
||||
router.add_post("/api/admin/promos", admin_promo_create_route)
|
||||
router.add_patch("/api/admin/promos/{promo_id:\\d+}", admin_promo_update_route)
|
||||
router.add_delete("/api/admin/promos/{promo_id:\\d+}", admin_promo_delete_route)
|
||||
|
||||
router.add_get("/api/admin/logs", admin_logs_route)
|
||||
|
||||
router.add_post("/api/admin/broadcast", admin_broadcast_route)
|
||||
router.add_post("/api/admin/sync", admin_sync_route)
|
||||
|
||||
router.add_get("/api/admin/ads", admin_ads_list_route)
|
||||
router.add_post("/api/admin/ads", admin_ad_create_route)
|
||||
router.add_post("/api/admin/ads/{campaign_id:\\d+}/toggle", admin_ad_toggle_route)
|
||||
router.add_delete("/api/admin/ads/{campaign_id:\\d+}", admin_ad_delete_route)
|
||||
|
||||
router.add_get("/api/admin/settings", admin_settings_get_route)
|
||||
router.add_patch("/api/admin/settings", admin_settings_patch_route)
|
||||
|
||||
router.add_get("/api/admin/tariffs", admin_tariffs_get_route)
|
||||
router.add_put("/api/admin/tariffs", admin_tariffs_save_route)
|
||||
router.add_get("/api/admin/themes", admin_themes_get_route)
|
||||
router.add_put("/api/admin/themes", admin_themes_save_route)
|
||||
router.add_post("/api/admin/appearance/logo", admin_appearance_logo_upload_route)
|
||||
router.add_post("/api/admin/appearance/favicon", admin_appearance_favicon_upload_route)
|
||||
router.add_get("/api/admin/panel/internal-squads", admin_panel_internal_squads_route)
|
||||
@@ -0,0 +1,90 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
|
||||
async def admin_settings_get_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
settings: Settings = request.app["settings"]
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
|
||||
async with async_session_factory() as session:
|
||||
overrides = await app_settings_dal.get_overrides_with_meta(session)
|
||||
|
||||
overrides_by_key = {entry["key"]: entry for entry in overrides}
|
||||
|
||||
fields = manifest_payload()
|
||||
sections: Dict[str, Dict[str, Any]] = {}
|
||||
for field in fields:
|
||||
key = field["key"]
|
||||
section_id = field["section"]
|
||||
if section_id not in sections:
|
||||
sections[section_id] = {
|
||||
"id": section_id,
|
||||
"order": field["section_order"],
|
||||
"fields": [],
|
||||
}
|
||||
override = overrides_by_key.get(key)
|
||||
value = current_value(settings, key)
|
||||
is_secret = bool(field.get("secret"))
|
||||
response_field = {
|
||||
**field,
|
||||
"value": "" if is_secret else value,
|
||||
"overridden": bool(override),
|
||||
"updated_at": override.get("updated_at") if override else None,
|
||||
}
|
||||
if is_secret:
|
||||
response_field["has_value"] = bool(value)
|
||||
sections[section_id]["fields"].append(response_field)
|
||||
|
||||
ordered_sections = sorted(sections.values(), key=lambda s: s["order"])
|
||||
return _ok({"sections": ordered_sections})
|
||||
|
||||
|
||||
async def admin_settings_patch_route(request: web.Request) -> web.Response:
|
||||
actor_id = _require_admin_user_id(request)
|
||||
settings: Settings = request.app["settings"]
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
payload = await _read_json(request)
|
||||
updates = payload.get("updates") or {}
|
||||
deletes = payload.get("deletes") or []
|
||||
if not isinstance(updates, dict):
|
||||
return _error(400, "invalid_updates")
|
||||
if not isinstance(deletes, list):
|
||||
return _error(400, "invalid_deletes")
|
||||
|
||||
result = await update_overrides(
|
||||
settings,
|
||||
async_session_factory,
|
||||
updates=updates,
|
||||
deletes=deletes,
|
||||
actor_id=actor_id,
|
||||
)
|
||||
if not result.get("ok"):
|
||||
return web.json_response(
|
||||
{"ok": False, "error": "validation_failed", "errors": result.get("errors", {})},
|
||||
status=400,
|
||||
)
|
||||
|
||||
# Bust the public webapp settings cache so users see new values immediately.
|
||||
cache = request.app.get("webapp_settings_cache")
|
||||
if isinstance(cache, dict):
|
||||
cache["ts"] = 0.0
|
||||
cache["data"] = {}
|
||||
if (
|
||||
"WEBAPP_LOGO_URL" in updates
|
||||
or "WEBAPP_LOGO_URL" in deletes
|
||||
or "WEBAPP_LOGO_USE_EMOJI" in updates
|
||||
or "WEBAPP_LOGO_USE_EMOJI" in deletes
|
||||
or "WEBAPP_FAVICON_URL" in updates
|
||||
or "WEBAPP_FAVICON_URL" in deletes
|
||||
or "WEBAPP_FAVICON_USE_CUSTOM" in updates
|
||||
or "WEBAPP_FAVICON_USE_CUSTOM" in deletes
|
||||
or "WEBAPP_LOGO_FAVICON_URL" in updates
|
||||
or "WEBAPP_LOGO_FAVICON_URL" in deletes
|
||||
):
|
||||
request.app["webapp_logo_cache"] = None
|
||||
from bot.app.web.admin_api_impl.themes import prune_unused_appearance_assets
|
||||
|
||||
prune_unused_appearance_assets(settings)
|
||||
|
||||
return _ok({"applied": result.get("applied", 0), "reverted": result.get("reverted", 0)})
|
||||
@@ -0,0 +1,89 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
|
||||
async def admin_me_route(request: web.Request) -> web.Response:
|
||||
user_id = _require_admin_user_id(request)
|
||||
settings: Settings = request.app["settings"]
|
||||
return _ok({}, user_id=user_id, admin_ids=list(settings.ADMIN_IDS or []))
|
||||
|
||||
|
||||
async def admin_stats_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
settings: Settings = request.app["settings"]
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
|
||||
async with async_session_factory() as session:
|
||||
user_stats = await user_dal.get_enhanced_user_statistics(session)
|
||||
financial_stats = await payment_dal.get_financial_statistics(session)
|
||||
sync_status = await panel_sync_dal.get_panel_sync_status(session)
|
||||
recent_payments = await payment_dal.get_recent_payment_logs_with_user(session, limit=10)
|
||||
|
||||
payload = {
|
||||
"users": user_stats,
|
||||
"financial": financial_stats,
|
||||
"panel_sync": {
|
||||
"status": sync_status.status if sync_status else "never_run",
|
||||
"last_sync_time": sync_status.last_sync_time.isoformat()
|
||||
if sync_status and sync_status.last_sync_time
|
||||
else None,
|
||||
"details": sync_status.details if sync_status else None,
|
||||
"users_processed": sync_status.users_processed_from_panel if sync_status else 0,
|
||||
"subscriptions_synced": sync_status.subscriptions_synced if sync_status else 0,
|
||||
},
|
||||
"recent_payments": [_serialize_payment(p) for p in recent_payments],
|
||||
}
|
||||
|
||||
panel_service = request.app.get("panel_service")
|
||||
if panel_service is not None:
|
||||
try:
|
||||
system = await panel_service.get_system_stats()
|
||||
bandwidth = await panel_service.get_bandwidth_stats()
|
||||
panel_body: Dict[str, Any] = {
|
||||
"system": system or {},
|
||||
"bandwidth": bandwidth or {},
|
||||
}
|
||||
try:
|
||||
nodes = await panel_service.get_nodes_statistics()
|
||||
panel_body["nodes"] = nodes or {}
|
||||
except Exception as exc_nodes: # pragma: no cover - optional endpoint
|
||||
logger.debug("Panel nodes stats unavailable: %s", exc_nodes)
|
||||
panel_body["nodes"] = {}
|
||||
try:
|
||||
today = datetime.now(timezone.utc).date()
|
||||
start_d = today - timedelta(days=7)
|
||||
nodes_bw = await panel_service.get_nodes_bandwidth_usage(
|
||||
start=start_d.isoformat(),
|
||||
end=today.isoformat(),
|
||||
top_nodes_limit=64,
|
||||
)
|
||||
panel_body["nodes_bandwidth"] = nodes_bw or {}
|
||||
except Exception as exc_nb: # pragma: no cover - optional endpoint
|
||||
logger.debug("Panel nodes bandwidth range unavailable: %s", exc_nb)
|
||||
panel_body["nodes_bandwidth"] = {}
|
||||
try:
|
||||
online_map = _panel_nodes_online_by_uuid(panel_body.get("nodes"))
|
||||
lookups = await panel_service.get_nodes_online_lookups()
|
||||
for k, v in lookups.get("byUuid", {}).items():
|
||||
online_map[k] = v
|
||||
_enrich_bandwidth_nodes_with_online(
|
||||
panel_body.get("nodes_bandwidth"),
|
||||
online_map,
|
||||
lookups.get("byName") or {},
|
||||
)
|
||||
except Exception as exc_merge: # pragma: no cover
|
||||
logger.debug("Panel nodes online merge skipped: %s", exc_merge)
|
||||
payload["panel"] = panel_body
|
||||
except Exception as exc:
|
||||
logger.debug("Panel stats unavailable: %s", exc)
|
||||
payload["panel"] = {"error": "unavailable"}
|
||||
|
||||
queue_manager = get_queue_manager()
|
||||
if queue_manager:
|
||||
try:
|
||||
payload["queue"] = queue_manager.get_queue_stats()
|
||||
except Exception: # pragma: no cover - defensive
|
||||
payload["queue"] = None
|
||||
|
||||
payload["currency_symbol"] = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
|
||||
return _ok(payload)
|
||||
@@ -0,0 +1,16 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
|
||||
async def admin_sync_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
settings: Settings = request.app["settings"]
|
||||
queued = await enqueue_webhook_event(
|
||||
settings,
|
||||
"panel_sync",
|
||||
{"requested_by": _require_admin_user_id(request)},
|
||||
event_id=None,
|
||||
)
|
||||
if queued:
|
||||
return _ok({"result": {"status": "queued"}})
|
||||
return _error(503, "queue_unavailable")
|
||||
@@ -0,0 +1,63 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
|
||||
async def admin_tariffs_get_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
settings: Settings = request.app["settings"]
|
||||
path = _tariffs_config_path(settings)
|
||||
|
||||
try:
|
||||
config = settings.tariffs_config
|
||||
except Exception as exc:
|
||||
logger.warning("Invalid tariffs config requested from admin UI: %s", exc)
|
||||
return _error(400, "invalid_tariffs_config", str(exc))
|
||||
|
||||
if config is None:
|
||||
return _ok(
|
||||
{
|
||||
"exists": path.exists(),
|
||||
"path": str(path),
|
||||
"catalog": {
|
||||
"default_tariff": "",
|
||||
"topup_packages_default": {"rub": [], "stars": []},
|
||||
"tariffs": [],
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
return _ok(
|
||||
{
|
||||
"exists": True,
|
||||
"path": str(path),
|
||||
"catalog": _tariffs_config_payload(config),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def admin_tariffs_save_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
settings: Settings = request.app["settings"]
|
||||
payload = await _read_json(request)
|
||||
catalog = payload.get("catalog") if "catalog" in payload else payload
|
||||
if not isinstance(catalog, dict):
|
||||
return _error(400, "invalid_payload", "catalog must be an object")
|
||||
|
||||
try:
|
||||
config = TariffsConfig.model_validate(catalog)
|
||||
except (ValidationError, ValueError) as exc:
|
||||
return _error(400, "invalid_tariffs_config", str(exc))
|
||||
|
||||
path = _tariffs_config_path(settings)
|
||||
try:
|
||||
_write_tariffs_config_file(path, config)
|
||||
except OSError as exc:
|
||||
logger.exception("Failed to write tariffs config to %s", path)
|
||||
return _error(500, "write_failed", str(exc))
|
||||
|
||||
cache = request.app.get("webapp_settings_cache")
|
||||
if isinstance(cache, dict):
|
||||
cache["ts"] = 0.0
|
||||
cache["data"] = {}
|
||||
|
||||
return _ok({"exists": True, "path": str(path), "catalog": _tariffs_config_payload(config)})
|
||||
@@ -0,0 +1,452 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import ipaddress
|
||||
import shutil
|
||||
import re
|
||||
import socket
|
||||
|
||||
from aiohttp import ClientSession, ClientTimeout
|
||||
from PIL import Image, ImageOps, UnidentifiedImageError
|
||||
|
||||
from config.webapp_themes_config import (
|
||||
WebappThemesConfig,
|
||||
ensure_webapp_core_themes,
|
||||
resolved_webapp_themes_catalog,
|
||||
write_webapp_theme_dir,
|
||||
)
|
||||
|
||||
|
||||
WEBAPP_LOGO_MAX_BYTES = 2 * 1024 * 1024
|
||||
WEBAPP_UPLOADED_LOGO_DIR = Path(__file__).resolve().parents[5] / "data" / "webapp-logo" / "uploads"
|
||||
WEBAPP_UPLOADED_LOGO_PATH = "/webapp-uploaded-logo"
|
||||
WEBAPP_FAVICON_DIR = Path(__file__).resolve().parents[5] / "data" / "webapp-logo" / "favicons"
|
||||
WEBAPP_FAVICON_PATH = "/webapp-favicon"
|
||||
WEBAPP_EMOJI_CACHE_DIR = Path(__file__).resolve().parents[5] / "data" / "webapp-emoji"
|
||||
WEBAPP_FAVICON_SIZES = (16, 32, 48, 180, 192, 512)
|
||||
WEBAPP_LOGO_UPLOAD_CONTENT_TYPES = {
|
||||
".gif": "image/gif",
|
||||
".ico": "image/x-icon",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".svg": "image/svg+xml",
|
||||
".webp": "image/webp",
|
||||
}
|
||||
|
||||
|
||||
def _detect_logo_extension(
|
||||
body: bytes, content_type: str = "", filename: str = ""
|
||||
) -> Optional[str]:
|
||||
content_type = (content_type or "").split(";", 1)[0].strip().lower()
|
||||
suffix = Path(filename or "").suffix.lower()
|
||||
if content_type == "image/png" or body.startswith(b"\x89PNG\r\n\x1a\n"):
|
||||
return ".png"
|
||||
if content_type == "image/jpeg" or body.startswith(b"\xff\xd8\xff"):
|
||||
return ".jpg"
|
||||
if content_type == "image/gif" or body.startswith((b"GIF87a", b"GIF89a")):
|
||||
return ".gif"
|
||||
if content_type == "image/webp" or (
|
||||
len(body) > 12 and body[:4] == b"RIFF" and body[8:12] == b"WEBP"
|
||||
):
|
||||
return ".webp"
|
||||
if content_type in {"image/svg+xml", "image/svg"} or suffix == ".svg":
|
||||
head = body[:512].lstrip().lower()
|
||||
if head.startswith(b"<svg") or b"<svg" in head:
|
||||
return ".svg"
|
||||
if content_type == "image/x-icon" or suffix == ".ico":
|
||||
if body.startswith(b"\x00\x00\x01\x00"):
|
||||
return ".ico"
|
||||
return suffix if suffix in WEBAPP_LOGO_UPLOAD_CONTENT_TYPES else None
|
||||
|
||||
|
||||
def _write_uploaded_logo(body: bytes, content_type: str = "", filename: str = "") -> str:
|
||||
if not body or len(body) > WEBAPP_LOGO_MAX_BYTES:
|
||||
raise ValueError("logo must be a non-empty image up to 2 MiB")
|
||||
ext = _detect_logo_extension(body, content_type, filename)
|
||||
if ext not in WEBAPP_LOGO_UPLOAD_CONTENT_TYPES:
|
||||
raise ValueError("unsupported image type")
|
||||
digest = hashlib.sha256(body).hexdigest()[:16]
|
||||
safe_name = f"logo-{digest}{ext}"
|
||||
WEBAPP_UPLOADED_LOGO_DIR.mkdir(parents=True, exist_ok=True)
|
||||
(WEBAPP_UPLOADED_LOGO_DIR / safe_name).write_bytes(body)
|
||||
return f"{WEBAPP_UPLOADED_LOGO_PATH}/{safe_name}"
|
||||
|
||||
|
||||
def _uploaded_logo_filename(url: str) -> Optional[str]:
|
||||
parsed = urlsplit(str(url or ""))
|
||||
path = parsed.path if parsed.scheme or parsed.netloc else str(url or "")
|
||||
prefix = f"{WEBAPP_UPLOADED_LOGO_PATH}/"
|
||||
if not path.startswith(prefix):
|
||||
return None
|
||||
filename = path.removeprefix(prefix)
|
||||
if re.fullmatch(r"logo-[0-9a-f]{16}\.(?:gif|ico|jpe?g|png|svg|webp)", filename):
|
||||
return filename
|
||||
return None
|
||||
|
||||
|
||||
def _favicon_digest(url: str) -> Optional[str]:
|
||||
parsed = urlsplit(str(url or ""))
|
||||
path = parsed.path if parsed.scheme or parsed.netloc else str(url or "")
|
||||
match = re.fullmatch(
|
||||
rf"{re.escape(WEBAPP_FAVICON_PATH)}/([0-9a-f]{{16}})/(?:[A-Za-z0-9_.-]+)",
|
||||
path,
|
||||
)
|
||||
return match.group(1) if match else None
|
||||
|
||||
|
||||
def _emoji_to_codepoints(value: str) -> str:
|
||||
return "_".join(f"{ord(char):x}" for char in str(value or "").strip())
|
||||
|
||||
|
||||
def prune_unused_appearance_assets(settings: Settings) -> None:
|
||||
keep_logos = {
|
||||
filename
|
||||
for filename in [
|
||||
_uploaded_logo_filename(getattr(settings, "WEBAPP_LOGO_URL", "")),
|
||||
]
|
||||
if filename
|
||||
}
|
||||
keep_favicons = {
|
||||
digest
|
||||
for digest in [
|
||||
_favicon_digest(getattr(settings, "WEBAPP_FAVICON_URL", "")),
|
||||
_favicon_digest(getattr(settings, "WEBAPP_LOGO_FAVICON_URL", "")),
|
||||
]
|
||||
if digest
|
||||
}
|
||||
keep_emoji_prefixes = set()
|
||||
if (
|
||||
getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False)
|
||||
and str(getattr(settings, "WEBAPP_LOGO_EMOJI_FONT", "") or "").strip()
|
||||
== "noto-color-animated"
|
||||
):
|
||||
codepoints = _emoji_to_codepoints(getattr(settings, "WEBAPP_LOGO_EMOJI", ""))
|
||||
if codepoints:
|
||||
keep_emoji_prefixes.add(f"{codepoints}.512.")
|
||||
|
||||
for path in WEBAPP_UPLOADED_LOGO_DIR.glob("logo-*"):
|
||||
if path.is_file() and path.name not in keep_logos:
|
||||
try:
|
||||
path.unlink()
|
||||
except OSError:
|
||||
logger.warning("Failed to remove unused webapp logo %s", path, exc_info=True)
|
||||
|
||||
for path in WEBAPP_FAVICON_DIR.glob("*"):
|
||||
if (
|
||||
path.is_dir()
|
||||
and re.fullmatch(r"[0-9a-f]{16}", path.name)
|
||||
and path.name not in keep_favicons
|
||||
):
|
||||
try:
|
||||
shutil.rmtree(path)
|
||||
except OSError:
|
||||
logger.warning("Failed to remove unused webapp favicon set %s", path, exc_info=True)
|
||||
|
||||
for path in WEBAPP_EMOJI_CACHE_DIR.glob("*.512.*"):
|
||||
if path.is_file() and not any(
|
||||
path.name.startswith(prefix) for prefix in keep_emoji_prefixes
|
||||
):
|
||||
try:
|
||||
path.unlink()
|
||||
except OSError:
|
||||
logger.warning("Failed to remove unused webapp emoji asset %s", path, exc_info=True)
|
||||
|
||||
|
||||
async def _persist_appearance_upload(
|
||||
request: web.Request,
|
||||
updates: Dict[str, Any],
|
||||
actor_id: int,
|
||||
) -> bool:
|
||||
settings: Settings = request.app["settings"]
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
result = await update_overrides(
|
||||
settings,
|
||||
async_session_factory,
|
||||
updates=updates,
|
||||
deletes=[],
|
||||
actor_id=actor_id,
|
||||
)
|
||||
if not result.get("ok"):
|
||||
logger.warning("Failed to persist uploaded appearance asset settings: %s", result)
|
||||
return False
|
||||
|
||||
cache = request.app.get("webapp_settings_cache")
|
||||
if isinstance(cache, dict):
|
||||
cache["ts"] = 0.0
|
||||
cache["data"] = {}
|
||||
request.app["webapp_logo_cache"] = None
|
||||
prune_unused_appearance_assets(settings)
|
||||
return True
|
||||
|
||||
|
||||
def _image_to_square_icon(source: Image.Image, size: int) -> Image.Image:
|
||||
fitted = source.copy()
|
||||
fitted.thumbnail((size, size), Image.Resampling.LANCZOS)
|
||||
canvas = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||||
left = (size - fitted.width) // 2
|
||||
top = (size - fitted.height) // 2
|
||||
canvas.alpha_composite(fitted, (left, top))
|
||||
return canvas
|
||||
|
||||
|
||||
def _write_favicon_set(body: bytes, content_type: str = "", filename: str = "") -> Dict[str, Any]:
|
||||
if not body or len(body) > WEBAPP_LOGO_MAX_BYTES:
|
||||
raise ValueError("favicon source must be a non-empty image up to 2 MiB")
|
||||
|
||||
ext = _detect_logo_extension(body, content_type, filename)
|
||||
digest = hashlib.sha256(body).hexdigest()[:16]
|
||||
target_dir = WEBAPP_FAVICON_DIR / digest
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if ext == ".svg":
|
||||
safe_name = "favicon.svg"
|
||||
(target_dir / safe_name).write_bytes(body)
|
||||
return {
|
||||
"favicon_url": f"{WEBAPP_FAVICON_PATH}/{digest}/{safe_name}",
|
||||
"variants": {"svg": f"{WEBAPP_FAVICON_PATH}/{digest}/{safe_name}"},
|
||||
}
|
||||
|
||||
try:
|
||||
with Image.open(io.BytesIO(body)) as image:
|
||||
image.seek(0)
|
||||
source = ImageOps.exif_transpose(image).convert("RGBA")
|
||||
except (OSError, UnidentifiedImageError, ValueError) as exc:
|
||||
raise ValueError("favicon source must be a raster image") from exc
|
||||
|
||||
if source.width < 1 or source.height < 1 or source.width > 8192 or source.height > 8192:
|
||||
raise ValueError("favicon source dimensions are not supported")
|
||||
|
||||
variants: Dict[str, str] = {}
|
||||
png_icons: Dict[int, Image.Image] = {}
|
||||
for size in WEBAPP_FAVICON_SIZES:
|
||||
icon = _image_to_square_icon(source, size)
|
||||
png_icons[size] = icon
|
||||
filename = f"icon-{size}.png"
|
||||
icon.save(target_dir / filename, format="PNG", optimize=True)
|
||||
variants[f"{size}"] = f"{WEBAPP_FAVICON_PATH}/{digest}/{filename}"
|
||||
|
||||
png_icons[180].save(target_dir / "apple-touch-icon.png", format="PNG", optimize=True)
|
||||
variants["apple_touch"] = f"{WEBAPP_FAVICON_PATH}/{digest}/apple-touch-icon.png"
|
||||
png_icons[32].save(
|
||||
target_dir / "favicon.ico",
|
||||
format="ICO",
|
||||
sizes=[(16, 16), (32, 32), (48, 48)],
|
||||
)
|
||||
variants["ico"] = f"{WEBAPP_FAVICON_PATH}/{digest}/favicon.ico"
|
||||
return {
|
||||
"favicon_url": variants["180"],
|
||||
"variants": variants,
|
||||
}
|
||||
|
||||
|
||||
async def _read_uploaded_logo_file(request: web.Request) -> tuple[bytes, str, str]:
|
||||
reader = await request.multipart()
|
||||
async for part in reader:
|
||||
if part.name != "file":
|
||||
continue
|
||||
body = bytearray()
|
||||
while True:
|
||||
chunk = await part.read_chunk(size=64 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
body.extend(chunk)
|
||||
if len(body) > WEBAPP_LOGO_MAX_BYTES:
|
||||
raise ValueError("logo must be up to 2 MiB")
|
||||
return bytes(body), part.headers.get("Content-Type", ""), part.filename or ""
|
||||
raise ValueError("file field is required")
|
||||
|
||||
|
||||
async def _hostname_resolves_to_public_address(hostname: str) -> bool:
|
||||
if not hostname:
|
||||
return False
|
||||
try:
|
||||
ip_obj = ipaddress.ip_address(hostname)
|
||||
return not (
|
||||
ip_obj.is_private
|
||||
or ip_obj.is_loopback
|
||||
or ip_obj.is_link_local
|
||||
or ip_obj.is_unspecified
|
||||
or ip_obj.is_reserved
|
||||
)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
try:
|
||||
resolved = await loop.getaddrinfo(hostname, None, type=socket.SOCK_STREAM)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
found_public_ip = False
|
||||
for entry in resolved:
|
||||
sockaddr = entry[4]
|
||||
candidate = sockaddr[0] if sockaddr else ""
|
||||
try:
|
||||
ip_obj = ipaddress.ip_address(candidate)
|
||||
except ValueError:
|
||||
continue
|
||||
if (
|
||||
ip_obj.is_private
|
||||
or ip_obj.is_loopback
|
||||
or ip_obj.is_link_local
|
||||
or ip_obj.is_unspecified
|
||||
or ip_obj.is_reserved
|
||||
):
|
||||
return False
|
||||
found_public_ip = True
|
||||
return found_public_ip
|
||||
|
||||
|
||||
async def _fetch_logo_from_url(url: str) -> tuple[bytes, str, str]:
|
||||
parsed = urlsplit(url)
|
||||
if parsed.scheme != "https" or not parsed.hostname:
|
||||
raise ValueError("only https image URLs are supported")
|
||||
if not await _hostname_resolves_to_public_address(parsed.hostname):
|
||||
raise ValueError("logo URL must resolve to a public address")
|
||||
|
||||
timeout = ClientTimeout(total=5)
|
||||
async with ClientSession(timeout=timeout, headers={"User-Agent": "Mozilla/5.0"}) as session:
|
||||
async with session.get(
|
||||
url,
|
||||
allow_redirects=False,
|
||||
headers={"Accept": "image/avif,image/webp,image/svg+xml,image/png,image/*,*/*;q=0.8"},
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
raise ValueError(f"logo URL returned HTTP {response.status}")
|
||||
content_type = (
|
||||
(response.headers.get("Content-Type") or "").split(";", 1)[0].strip().lower()
|
||||
)
|
||||
if content_type and not content_type.startswith("image/"):
|
||||
raise ValueError("logo URL returned non-image content")
|
||||
body = bytearray()
|
||||
async for chunk in response.content.iter_chunked(64 * 1024):
|
||||
body.extend(chunk)
|
||||
if len(body) > WEBAPP_LOGO_MAX_BYTES:
|
||||
raise ValueError("logo must be up to 2 MiB")
|
||||
return bytes(body), content_type, Path(parsed.path).name
|
||||
|
||||
|
||||
async def admin_appearance_logo_upload_route(request: web.Request) -> web.Response:
|
||||
actor_id = _require_admin_user_id(request)
|
||||
content_type = (request.headers.get("Content-Type") or "").lower()
|
||||
try:
|
||||
if content_type.startswith("multipart/form-data"):
|
||||
body, detected_content_type, filename = await _read_uploaded_logo_file(request)
|
||||
else:
|
||||
payload = await _read_json(request)
|
||||
source_url = str(payload.get("url") or "").strip()
|
||||
if not source_url:
|
||||
return _error(400, "invalid_payload", "url or file is required")
|
||||
body, detected_content_type, filename = await _fetch_logo_from_url(source_url)
|
||||
logo_url = _write_uploaded_logo(body, detected_content_type, filename)
|
||||
try:
|
||||
favicon_payload = _write_favicon_set(body, detected_content_type, filename)
|
||||
except ValueError:
|
||||
favicon_payload = {}
|
||||
except ValueError as exc:
|
||||
return _error(400, "invalid_logo", str(exc))
|
||||
except OSError as exc:
|
||||
logger.exception("Failed to save uploaded webapp logo")
|
||||
return _error(500, "write_failed", str(exc))
|
||||
persisted = await _persist_appearance_upload(
|
||||
request,
|
||||
{
|
||||
"WEBAPP_LOGO_URL": logo_url,
|
||||
"WEBAPP_LOGO_USE_EMOJI": False,
|
||||
**(
|
||||
{"WEBAPP_LOGO_FAVICON_URL": favicon_payload["favicon_url"]}
|
||||
if favicon_payload.get("favicon_url")
|
||||
else {}
|
||||
),
|
||||
},
|
||||
actor_id,
|
||||
)
|
||||
|
||||
return _ok({"logo_url": logo_url, "persisted": persisted, **favicon_payload})
|
||||
|
||||
|
||||
async def admin_appearance_favicon_upload_route(request: web.Request) -> web.Response:
|
||||
actor_id = _require_admin_user_id(request)
|
||||
content_type = (request.headers.get("Content-Type") or "").lower()
|
||||
try:
|
||||
if content_type.startswith("multipart/form-data"):
|
||||
body, detected_content_type, filename = await _read_uploaded_logo_file(request)
|
||||
else:
|
||||
payload = await _read_json(request)
|
||||
source_url = str(payload.get("url") or "").strip()
|
||||
if not source_url:
|
||||
return _error(400, "invalid_payload", "url or file is required")
|
||||
body, detected_content_type, filename = await _fetch_logo_from_url(source_url)
|
||||
favicon_payload = _write_favicon_set(body, detected_content_type, filename)
|
||||
except ValueError as exc:
|
||||
return _error(400, "invalid_favicon", str(exc))
|
||||
except OSError as exc:
|
||||
logger.exception("Failed to save uploaded webapp favicon")
|
||||
return _error(500, "write_failed", str(exc))
|
||||
persisted = await _persist_appearance_upload(
|
||||
request,
|
||||
{
|
||||
"WEBAPP_FAVICON_URL": favicon_payload["favicon_url"],
|
||||
"WEBAPP_FAVICON_USE_CUSTOM": True,
|
||||
},
|
||||
actor_id,
|
||||
)
|
||||
|
||||
return _ok({"persisted": persisted, **favicon_payload})
|
||||
|
||||
|
||||
async def admin_themes_get_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
settings: Settings = request.app["settings"]
|
||||
primary = settings.WEBAPP_PRIMARY_COLOR or "#00fe7a"
|
||||
catalog = resolved_webapp_themes_catalog(
|
||||
primary_accent=primary,
|
||||
env_default_theme=settings.WEBAPP_DEFAULT_THEME,
|
||||
theme_dir=settings.WEBAPP_THEMES_DIR,
|
||||
)
|
||||
|
||||
return _ok(
|
||||
{
|
||||
"exists": Path(settings.WEBAPP_THEMES_DIR).expanduser().exists(),
|
||||
"themes_dir": str(Path(settings.WEBAPP_THEMES_DIR).expanduser()),
|
||||
"catalog": _webapp_themes_catalog_payload(catalog),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def admin_themes_save_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
settings: Settings = request.app["settings"]
|
||||
payload = await _read_json(request)
|
||||
catalog = payload.get("catalog") if "catalog" in payload else payload
|
||||
if not isinstance(catalog, dict):
|
||||
return _error(400, "invalid_payload", "catalog must be an object")
|
||||
|
||||
try:
|
||||
config = WebappThemesConfig.model_validate(catalog)
|
||||
except (ValidationError, ValueError) as exc:
|
||||
return _error(400, "invalid_webapp_themes_config", str(exc))
|
||||
|
||||
config, _changed = ensure_webapp_core_themes(config, settings.WEBAPP_PRIMARY_COLOR or "#00fe7a")
|
||||
|
||||
try:
|
||||
write_webapp_theme_dir(settings.WEBAPP_THEMES_DIR, config, delete_missing=True)
|
||||
except OSError as exc:
|
||||
logger.exception("Failed to write webapp themes to %s", settings.WEBAPP_THEMES_DIR)
|
||||
return _error(500, "write_failed", str(exc))
|
||||
|
||||
cache = request.app.get("webapp_settings_cache")
|
||||
if isinstance(cache, dict):
|
||||
cache["ts"] = 0.0
|
||||
cache["data"] = {}
|
||||
|
||||
return _ok(
|
||||
{
|
||||
"exists": True,
|
||||
"themes_dir": str(Path(settings.WEBAPP_THEMES_DIR).expanduser()),
|
||||
"catalog": _webapp_themes_catalog_payload(config),
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,925 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
|
||||
async def admin_users_list_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
|
||||
page = max(0, int(request.query.get("page", 0) or 0))
|
||||
page_size = min(100, max(1, int(request.query.get("page_size", 25) or 25)))
|
||||
query = (request.query.get("q") or "").strip()
|
||||
filter_value = (request.query.get("filter") or "all").lower()
|
||||
panel_status = (request.query.get("panel_status") or "all").lower()
|
||||
premium_traffic = (request.query.get("premium_traffic") or "all").lower()
|
||||
sort_value = (request.query.get("sort") or "registered_desc").lower()
|
||||
|
||||
async with async_session_factory() as session:
|
||||
users, total = await _filter_and_sort_users(
|
||||
session,
|
||||
query=query,
|
||||
filter_value=filter_value,
|
||||
panel_status=panel_status,
|
||||
premium_traffic=premium_traffic,
|
||||
sort_value=sort_value,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
statuses = await _bulk_user_statuses(session, [u.user_id for u in users])
|
||||
cached_avatar_ids = await _bulk_user_avatar_keys(session, [u.user_id for u in users])
|
||||
active_subs = await _bulk_active_subscriptions_for_users(
|
||||
session, [u.user_id for u in users]
|
||||
)
|
||||
|
||||
serialized = []
|
||||
for user in users:
|
||||
payload = _serialize_user(user)
|
||||
status_payload = statuses.get(user.user_id) or {"status": "bot_only", "end_date": None}
|
||||
payload["panel_status"] = status_payload.get("status")
|
||||
if status_payload.get("status") == "expired" and status_payload.get("end_date"):
|
||||
payload["panel_status_expired_at"] = status_payload["end_date"]
|
||||
payload["avatar_url"] = (
|
||||
f"/api/admin/users/{user.user_id}/avatar?v={cached_avatar_ids[user.user_id]}"
|
||||
if user.user_id in cached_avatar_ids
|
||||
else None
|
||||
)
|
||||
payload["premium_traffic"] = _premium_traffic_list_payload(active_subs.get(user.user_id))
|
||||
serialized.append(payload)
|
||||
|
||||
return _ok(
|
||||
{
|
||||
"users": serialized,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total": total,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def _bulk_user_statuses(
|
||||
session: AsyncSession, user_ids: List[int]
|
||||
) -> Dict[int, Dict[str, Optional[str]]]:
|
||||
"""Return active subscription status for a batch of users.
|
||||
|
||||
Returns the panel status (active/expired/limited/disabled) when an active
|
||||
subscription exists, otherwise ``"bot_only"`` when the user is in the bot
|
||||
but has no panel subscription.
|
||||
"""
|
||||
|
||||
if not user_ids:
|
||||
return {}
|
||||
stmt = (
|
||||
select(
|
||||
Subscription.user_id,
|
||||
Subscription.status_from_panel,
|
||||
Subscription.is_active,
|
||||
Subscription.end_date,
|
||||
)
|
||||
.where(Subscription.user_id.in_(user_ids))
|
||||
.order_by(Subscription.is_active.desc(), Subscription.end_date.desc().nullslast())
|
||||
)
|
||||
rows = (await session.execute(stmt)).all()
|
||||
out: Dict[int, Dict[str, Optional[str]]] = {}
|
||||
for uid, panel_status, is_active, end_date in rows:
|
||||
if uid in out:
|
||||
continue
|
||||
if is_active:
|
||||
status = (panel_status or "active").lower()
|
||||
else:
|
||||
status = (panel_status or "expired").lower()
|
||||
out[uid] = {
|
||||
"status": status,
|
||||
"end_date": end_date.isoformat() if end_date else None,
|
||||
}
|
||||
for uid in user_ids:
|
||||
if uid not in out:
|
||||
out[uid] = {"status": "bot_only", "end_date": None}
|
||||
return out
|
||||
|
||||
|
||||
async def _bulk_user_avatar_keys(session: AsyncSession, user_ids: List[int]) -> Dict[int, str]:
|
||||
"""Return ``{user_id: cache_key}`` for users with a cached Telegram avatar.
|
||||
|
||||
The cache key is the row's ``updated_at`` timestamp — used as a
|
||||
cache-buster query param so the browser refetches when the avatar
|
||||
changes.
|
||||
"""
|
||||
|
||||
if not user_ids:
|
||||
return {}
|
||||
stmt = select(UserTelegramAvatar.user_id, UserTelegramAvatar.updated_at).where(
|
||||
UserTelegramAvatar.user_id.in_(user_ids)
|
||||
)
|
||||
rows = (await session.execute(stmt)).all()
|
||||
return {int(uid): (updated_at.isoformat() if updated_at else "") for uid, updated_at in rows}
|
||||
|
||||
|
||||
async def admin_user_avatar_route(request: web.Request) -> web.Response:
|
||||
"""Serve the cached Telegram avatar for any user (admin-only).
|
||||
|
||||
Mirrors ``/api/account/avatar`` but takes a ``user_id`` from the URL
|
||||
and uses admin auth. Only the cached blob from
|
||||
``user_telegram_avatars`` is served — refreshing from Telegram is the
|
||||
job of the user-facing endpoint, so the admin list never blocks on a
|
||||
Telegram round-trip.
|
||||
"""
|
||||
|
||||
_require_admin_user_id(request)
|
||||
target_id = int(request.match_info["user_id"])
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
avatar = await session.get(UserTelegramAvatar, target_id)
|
||||
|
||||
if not avatar:
|
||||
raise web.HTTPNotFound(text="avatar_not_cached")
|
||||
|
||||
etag = (
|
||||
f'W/"avatar-{target_id}-{int(avatar.updated_at.timestamp())}"'
|
||||
if avatar.updated_at
|
||||
else None
|
||||
)
|
||||
if etag and request.headers.get("If-None-Match") == etag:
|
||||
return web.Response(status=304, headers={"ETag": etag})
|
||||
|
||||
response = web.Response(
|
||||
body=bytes(avatar.image_bytes),
|
||||
content_type=avatar.content_type or "image/jpeg",
|
||||
)
|
||||
response.headers["Cache-Control"] = "private, max-age=3600"
|
||||
if etag:
|
||||
response.headers["ETag"] = etag
|
||||
return response
|
||||
|
||||
|
||||
def _ranked_active_subscriptions_sq(now: datetime):
|
||||
"""Latest active subscription per user (same ordering as subscription_dal)."""
|
||||
|
||||
rn = sa_func.row_number().over(
|
||||
partition_by=Subscription.user_id,
|
||||
order_by=(
|
||||
Subscription.end_date.desc(),
|
||||
Subscription.subscription_id.desc(),
|
||||
),
|
||||
)
|
||||
inner = (
|
||||
select(
|
||||
Subscription.user_id,
|
||||
Subscription.subscription_id,
|
||||
Subscription.premium_used_bytes,
|
||||
Subscription.premium_baseline_bytes,
|
||||
Subscription.premium_topup_balance_bytes,
|
||||
Subscription.premium_topup_used_bytes,
|
||||
Subscription.premium_bonus_bytes,
|
||||
Subscription.premium_unlimited_override,
|
||||
rn.label("rn"),
|
||||
)
|
||||
.where(
|
||||
Subscription.is_active.is_(True),
|
||||
Subscription.end_date > now,
|
||||
)
|
||||
.subquery()
|
||||
)
|
||||
return select(inner).where(inner.c.rn == 1).subquery(name="ranked_active_sub")
|
||||
|
||||
|
||||
async def _bulk_active_subscriptions_for_users(
|
||||
session: AsyncSession, user_ids: List[int]
|
||||
) -> Dict[int, Subscription]:
|
||||
"""Active subscription row per user (for admin list premium traffic column)."""
|
||||
|
||||
if not user_ids:
|
||||
return {}
|
||||
now = datetime.now(timezone.utc)
|
||||
stmt = (
|
||||
select(Subscription)
|
||||
.where(
|
||||
Subscription.user_id.in_(user_ids),
|
||||
Subscription.is_active.is_(True),
|
||||
Subscription.end_date > now,
|
||||
)
|
||||
.order_by(
|
||||
Subscription.user_id.asc(),
|
||||
Subscription.end_date.desc(),
|
||||
Subscription.subscription_id.desc(),
|
||||
)
|
||||
)
|
||||
rows = (await session.execute(stmt)).scalars().all()
|
||||
out: Dict[int, Subscription] = {}
|
||||
for sub in rows:
|
||||
uid = int(sub.user_id)
|
||||
if uid not in out:
|
||||
out[uid] = sub
|
||||
return out
|
||||
|
||||
|
||||
async def _filter_and_sort_users(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
query: str = "",
|
||||
filter_value: str,
|
||||
panel_status: str = "all",
|
||||
premium_traffic: str = "all",
|
||||
sort_value: str,
|
||||
page: int,
|
||||
page_size: int,
|
||||
) -> tuple[List[User], int]:
|
||||
"""Return paginated users with optional search, filter and sort applied."""
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
sort_key = (sort_value or "registered_desc").lower()
|
||||
pt_filter = (premium_traffic or "all").lower()
|
||||
needs_premium_sq = pt_filter != "all" or sort_key in {
|
||||
"premium_ratio_asc",
|
||||
"premium_ratio_desc",
|
||||
}
|
||||
|
||||
stmt = select(User)
|
||||
count_stmt = select(sa_func.count(User.user_id))
|
||||
|
||||
sq = None
|
||||
ratio_expr = None
|
||||
plim_expr = None
|
||||
pu_expr = None
|
||||
|
||||
if needs_premium_sq:
|
||||
sq = _ranked_active_subscriptions_sq(now)
|
||||
stmt = stmt.outerjoin(sq, User.user_id == sq.c.user_id)
|
||||
count_stmt = count_stmt.outerjoin(sq, User.user_id == sq.c.user_id)
|
||||
pb = sa_func.coalesce(sq.c.premium_bonus_bytes, 0)
|
||||
plim_expr = (
|
||||
sa_func.coalesce(sq.c.premium_baseline_bytes, 0)
|
||||
+ sa_func.coalesce(sq.c.premium_topup_balance_bytes, 0)
|
||||
+ sa_func.coalesce(sq.c.premium_topup_used_bytes, 0)
|
||||
+ pb
|
||||
)
|
||||
pu_expr = sa_func.coalesce(sq.c.premium_used_bytes, 0)
|
||||
ratio_expr = case(
|
||||
(sq.c.user_id.is_(None), None),
|
||||
(sq.c.premium_unlimited_override.is_(True), None),
|
||||
(plim_expr <= 0, None),
|
||||
else_=cast(pu_expr, Float) / cast(plim_expr, Float),
|
||||
)
|
||||
|
||||
search_cond = _user_search_condition(query)
|
||||
if search_cond is not None:
|
||||
stmt = stmt.where(search_cond)
|
||||
count_stmt = count_stmt.where(search_cond)
|
||||
|
||||
f = (filter_value or "all").lower()
|
||||
if f == "banned":
|
||||
cond = User.is_banned.is_(True)
|
||||
elif f == "active":
|
||||
cond = User.is_banned.is_(False)
|
||||
elif f == "tg_linked":
|
||||
cond = User.telegram_id.is_not(None)
|
||||
elif f == "no_tg":
|
||||
cond = User.telegram_id.is_(None)
|
||||
elif f == "email_linked":
|
||||
cond = User.email.is_not(None)
|
||||
elif f == "no_email":
|
||||
cond = User.email.is_(None)
|
||||
elif f == "panel_linked":
|
||||
cond = User.panel_user_uuid.is_not(None)
|
||||
else:
|
||||
cond = None
|
||||
|
||||
if cond is not None:
|
||||
stmt = stmt.where(cond)
|
||||
count_stmt = count_stmt.where(cond)
|
||||
|
||||
panel_cond = _user_panel_status_condition(panel_status)
|
||||
if panel_cond is not None:
|
||||
stmt = stmt.where(panel_cond)
|
||||
count_stmt = count_stmt.where(panel_cond)
|
||||
|
||||
if needs_premium_sq and sq is not None and plim_expr is not None and pu_expr is not None:
|
||||
if pt_filter == "none":
|
||||
premium_cond = or_(
|
||||
sq.c.user_id.is_(None),
|
||||
and_(
|
||||
sq.c.premium_unlimited_override.is_(False),
|
||||
plim_expr <= 0,
|
||||
),
|
||||
)
|
||||
stmt = stmt.where(premium_cond)
|
||||
count_stmt = count_stmt.where(premium_cond)
|
||||
elif pt_filter == "unlimited":
|
||||
premium_cond = and_(
|
||||
sq.c.user_id.isnot(None),
|
||||
sq.c.premium_unlimited_override.is_(True),
|
||||
)
|
||||
stmt = stmt.where(premium_cond)
|
||||
count_stmt = count_stmt.where(premium_cond)
|
||||
elif pt_filter == "good":
|
||||
premium_cond = and_(
|
||||
sq.c.user_id.isnot(None),
|
||||
sq.c.premium_unlimited_override.is_(False),
|
||||
plim_expr > 0,
|
||||
(100 * pu_expr) < (85 * plim_expr),
|
||||
)
|
||||
stmt = stmt.where(premium_cond)
|
||||
count_stmt = count_stmt.where(premium_cond)
|
||||
elif pt_filter == "warn":
|
||||
premium_cond = and_(
|
||||
sq.c.user_id.isnot(None),
|
||||
sq.c.premium_unlimited_override.is_(False),
|
||||
plim_expr > 0,
|
||||
(100 * pu_expr) >= (85 * plim_expr),
|
||||
pu_expr < plim_expr,
|
||||
)
|
||||
stmt = stmt.where(premium_cond)
|
||||
count_stmt = count_stmt.where(premium_cond)
|
||||
elif pt_filter == "critical":
|
||||
premium_cond = and_(
|
||||
sq.c.user_id.isnot(None),
|
||||
sq.c.premium_unlimited_override.is_(False),
|
||||
plim_expr > 0,
|
||||
pu_expr >= plim_expr,
|
||||
)
|
||||
stmt = stmt.where(premium_cond)
|
||||
count_stmt = count_stmt.where(premium_cond)
|
||||
|
||||
sort_map = {
|
||||
"registered_desc": User.registration_date.desc().nullslast(),
|
||||
"registered_asc": User.registration_date.asc().nullslast(),
|
||||
"name_asc": (
|
||||
sa_func.coalesce(User.first_name, User.username, User.email).asc(),
|
||||
User.user_id.asc(),
|
||||
),
|
||||
"name_desc": (
|
||||
sa_func.coalesce(User.first_name, User.username, User.email).desc(),
|
||||
User.user_id.desc(),
|
||||
),
|
||||
"id_asc": User.user_id.asc(),
|
||||
"id_desc": User.user_id.desc(),
|
||||
}
|
||||
|
||||
if needs_premium_sq and ratio_expr is not None and sort_key == "premium_ratio_asc":
|
||||
stmt = stmt.order_by(ratio_expr.asc().nullslast(), User.user_id.asc())
|
||||
elif needs_premium_sq and ratio_expr is not None and sort_key == "premium_ratio_desc":
|
||||
stmt = stmt.order_by(ratio_expr.desc().nullslast(), User.user_id.desc())
|
||||
else:
|
||||
order = sort_map.get(sort_key, sort_map["registered_desc"])
|
||||
if isinstance(order, tuple):
|
||||
stmt = stmt.order_by(*order)
|
||||
else:
|
||||
stmt = stmt.order_by(order)
|
||||
|
||||
stmt = stmt.offset(max(page, 0) * max(page_size, 1)).limit(max(page_size, 1))
|
||||
|
||||
users = (await session.execute(stmt)).scalars().all()
|
||||
total = (await session.execute(count_stmt)).scalar_one()
|
||||
return users, int(total)
|
||||
|
||||
|
||||
def _user_panel_status_condition(panel_status: str):
|
||||
status = (panel_status or "all").lower()
|
||||
if status not in {"active", "expired", "limited"}:
|
||||
return None
|
||||
|
||||
normalized_status = sa_func.lower(sa_func.coalesce(Subscription.status_from_panel, ""))
|
||||
blank_status = or_(
|
||||
Subscription.status_from_panel.is_(None), Subscription.status_from_panel == ""
|
||||
)
|
||||
if status == "active":
|
||||
status_cond = or_(
|
||||
normalized_status == "active", blank_status & Subscription.is_active.is_(True)
|
||||
)
|
||||
elif status == "expired":
|
||||
status_cond = or_(
|
||||
normalized_status == "expired", blank_status & Subscription.is_active.is_(False)
|
||||
)
|
||||
else:
|
||||
status_cond = normalized_status == "limited"
|
||||
|
||||
return (
|
||||
select(Subscription.subscription_id)
|
||||
.where(Subscription.user_id == User.user_id, status_cond)
|
||||
.exists()
|
||||
)
|
||||
|
||||
|
||||
def _user_search_condition(query: str):
|
||||
raw = (query or "").strip().lstrip("@")
|
||||
if not raw:
|
||||
return None
|
||||
|
||||
like = f"%{raw}%"
|
||||
conditions = [
|
||||
User.username.ilike(like),
|
||||
User.first_name.ilike(like),
|
||||
User.last_name.ilike(like),
|
||||
User.email.ilike(like),
|
||||
]
|
||||
if raw.isdigit():
|
||||
numeric = int(raw)
|
||||
conditions.extend([User.user_id == numeric, User.telegram_id == numeric])
|
||||
|
||||
return or_(*conditions)
|
||||
|
||||
|
||||
async def admin_user_detail_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
target_id = int(request.match_info["user_id"])
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
settings: Settings = request.app["settings"]
|
||||
|
||||
async with async_session_factory() as session:
|
||||
user = await user_dal.get_user_by_id(session, target_id)
|
||||
if not user:
|
||||
return _error(404, "not_found", "User not found")
|
||||
|
||||
active_sub = await subscription_dal.get_active_subscription_by_user_id(session, target_id)
|
||||
latest_subs_stmt = (
|
||||
select(Subscription)
|
||||
.where(Subscription.user_id == target_id)
|
||||
.order_by(Subscription.start_date.desc().nullslast())
|
||||
.limit(20)
|
||||
)
|
||||
latest_subs = (await session.execute(latest_subs_stmt)).scalars().all()
|
||||
total_paid = await payment_dal.get_user_total_paid(session, target_id)
|
||||
recent_payments_stmt = (
|
||||
select(Payment)
|
||||
.where(Payment.user_id == target_id)
|
||||
.order_by(Payment.created_at.desc())
|
||||
.limit(20)
|
||||
)
|
||||
recent_payments = (await session.execute(recent_payments_stmt)).scalars().all()
|
||||
log_count = await message_log_dal.count_user_message_logs(session, target_id)
|
||||
avatar_keys = await _bulk_user_avatar_keys(session, [target_id])
|
||||
|
||||
# Referral links — both the bot deep-link and the webapp deep-link.
|
||||
referral_code: Optional[str] = None
|
||||
try:
|
||||
referral_code = await user_dal.ensure_referral_code(session, user)
|
||||
await session.commit()
|
||||
except Exception as exc_ref: # pragma: no cover — defensive
|
||||
logger.warning("Failed to ensure referral code for user %s: %s", target_id, exc_ref)
|
||||
await session.rollback()
|
||||
|
||||
referral_service: Optional[ReferralService] = request.app.get("referral_service")
|
||||
bot_username = request.app.get("bot_username") or ""
|
||||
referral_bot_link: Optional[str] = None
|
||||
if referral_service and bot_username and referral_code:
|
||||
try:
|
||||
async with async_session_factory() as session:
|
||||
referral_bot_link = await referral_service.generate_referral_link(
|
||||
session, bot_username, target_id
|
||||
)
|
||||
except Exception as exc_link: # pragma: no cover
|
||||
logger.warning("Failed to build bot referral link for %s: %s", target_id, exc_link)
|
||||
referral_webapp_link = _build_admin_webapp_referral_link(
|
||||
getattr(settings, "SUBSCRIPTION_MINI_APP_URL", None),
|
||||
referral_code,
|
||||
)
|
||||
|
||||
# Subscription page URL — the raw panel `subscriptionUrl` that the user
|
||||
# imports into their VPN client. May be missing if the user has never
|
||||
# been provisioned on the panel.
|
||||
subscription_url: Optional[str] = None
|
||||
panel_uuid = getattr(user, "panel_user_uuid", None)
|
||||
if panel_uuid:
|
||||
subscription_service = request.app.get("subscription_service")
|
||||
panel_service = getattr(subscription_service, "panel_service", None)
|
||||
if panel_service is not None:
|
||||
try:
|
||||
panel_data = await panel_service.get_user_by_uuid(panel_uuid)
|
||||
if panel_data:
|
||||
subscription_url = panel_data.get("subscriptionUrl") or None
|
||||
except Exception as exc_panel: # pragma: no cover
|
||||
logger.warning(
|
||||
"Failed to fetch subscriptionUrl for user %s (uuid=%s): %s",
|
||||
target_id,
|
||||
panel_uuid,
|
||||
exc_panel,
|
||||
)
|
||||
|
||||
serialized_user = _serialize_user(user)
|
||||
serialized_user["avatar_url"] = (
|
||||
f"/api/admin/users/{target_id}/avatar?v={avatar_keys[target_id]}"
|
||||
if target_id in avatar_keys
|
||||
else None
|
||||
)
|
||||
|
||||
return _ok(
|
||||
{
|
||||
"user": serialized_user,
|
||||
"active_subscription": _serialize_subscription(active_sub) if active_sub else None,
|
||||
"subscriptions": [_serialize_subscription(s) for s in (latest_subs or [])],
|
||||
"total_paid": float(total_paid),
|
||||
"recent_payments": [_serialize_payment(p) for p in recent_payments],
|
||||
"log_count": int(log_count or 0),
|
||||
"subscription_url": subscription_url,
|
||||
"referral": {
|
||||
"code": referral_code,
|
||||
"bot_link": referral_bot_link,
|
||||
"webapp_link": referral_webapp_link,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def admin_user_ban_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
target_id = int(request.match_info["user_id"])
|
||||
payload = await _read_json(request)
|
||||
desired = bool(payload.get("banned"))
|
||||
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
user = await user_dal.get_user_by_id(session, target_id)
|
||||
if not user:
|
||||
return _error(404, "not_found")
|
||||
user.is_banned = bool(desired)
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
return _ok({"user": _serialize_user(user)})
|
||||
|
||||
|
||||
async def admin_user_message_route(request: web.Request) -> web.Response:
|
||||
actor_id = _require_admin_user_id(request)
|
||||
target_id = int(request.match_info["user_id"])
|
||||
payload = await _read_json(request)
|
||||
text = str(payload.get("text") or "").strip()
|
||||
if not text:
|
||||
return _error(400, "empty_text")
|
||||
|
||||
queue_manager = get_queue_manager()
|
||||
if not queue_manager:
|
||||
return _error(503, "queue_unavailable")
|
||||
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
target_user = await user_dal.get_user_by_id(session, target_id)
|
||||
if not target_user or not target_user.telegram_id:
|
||||
return _error(404, "no_telegram_account")
|
||||
|
||||
try:
|
||||
await send_message_via_queue(
|
||||
queue_manager,
|
||||
int(target_user.telegram_id),
|
||||
MessageContent(content_type="text", text=text),
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Admin direct message failed: %s", exc)
|
||||
return _error(502, "send_failed", str(exc))
|
||||
|
||||
await message_log_dal.create_message_log(
|
||||
session,
|
||||
{
|
||||
"user_id": actor_id,
|
||||
"event_type": "admin_direct_message_webapp",
|
||||
"content": text[:4000],
|
||||
"is_admin_event": True,
|
||||
"target_user_id": target_id,
|
||||
},
|
||||
)
|
||||
|
||||
return _ok({})
|
||||
|
||||
|
||||
async def admin_user_message_preview_route(request: web.Request) -> web.Response:
|
||||
actor_id = _require_admin_user_id(request)
|
||||
admin_telegram_id = request.get("admin_telegram_id")
|
||||
target_id = int(request.match_info["user_id"])
|
||||
payload = await _read_json(request)
|
||||
text = str(payload.get("text") or "").strip()
|
||||
if not text:
|
||||
return _error(400, "empty_text")
|
||||
if not admin_telegram_id:
|
||||
return _error(403, "admin_telegram_unavailable")
|
||||
|
||||
queue_manager = get_queue_manager()
|
||||
if not queue_manager:
|
||||
return _error(503, "queue_unavailable")
|
||||
|
||||
try:
|
||||
await send_message_via_queue(
|
||||
queue_manager,
|
||||
int(admin_telegram_id),
|
||||
MessageContent(content_type="text", text=text),
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Admin direct message preview failed: %s", exc)
|
||||
return _error(502, "preview_failed", str(exc))
|
||||
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
await message_log_dal.create_message_log(
|
||||
session,
|
||||
{
|
||||
"user_id": actor_id,
|
||||
"event_type": "admin_direct_message_preview_webapp",
|
||||
"content": text[:4000],
|
||||
"is_admin_event": True,
|
||||
"target_user_id": target_id,
|
||||
},
|
||||
)
|
||||
|
||||
return _ok({})
|
||||
|
||||
|
||||
async def admin_user_delete_route(request: web.Request) -> web.Response:
|
||||
actor_id = _require_admin_user_id(request)
|
||||
target_id = int(request.match_info["user_id"])
|
||||
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
ok = await user_dal.delete_user_and_relations(session, target_id)
|
||||
if not ok:
|
||||
await session.rollback()
|
||||
return _error(404, "not_found")
|
||||
await message_log_dal.create_message_log(
|
||||
session,
|
||||
{
|
||||
"user_id": actor_id,
|
||||
"event_type": "admin_delete_user_webapp",
|
||||
"content": f"Deleted user_id={target_id}",
|
||||
"is_admin_event": True,
|
||||
},
|
||||
)
|
||||
await session.commit()
|
||||
return _ok({})
|
||||
|
||||
|
||||
async def admin_user_reset_trial_route(request: web.Request) -> web.Response:
|
||||
actor_id = _require_admin_user_id(request)
|
||||
target_id = int(request.match_info["user_id"])
|
||||
panel_service = request.app.get("panel_service")
|
||||
subscription_service = request.app.get("subscription_service")
|
||||
if panel_service is None or subscription_service is None:
|
||||
return _error(503, "service_unavailable")
|
||||
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
user = await user_dal.get_user_by_id(session, target_id)
|
||||
if not user:
|
||||
return _error(404, "not_found")
|
||||
|
||||
active = await subscription_dal.get_active_subscription_by_user_id(session, target_id)
|
||||
if active:
|
||||
await session.delete(active)
|
||||
|
||||
await message_log_dal.create_message_log(
|
||||
session,
|
||||
{
|
||||
"user_id": actor_id,
|
||||
"event_type": "admin_reset_trial_webapp",
|
||||
"content": f"Reset trial for user_id={target_id}",
|
||||
"is_admin_event": True,
|
||||
"target_user_id": target_id,
|
||||
},
|
||||
)
|
||||
await session.commit()
|
||||
return _ok({})
|
||||
|
||||
|
||||
async def admin_user_premium_override_route(request: web.Request) -> web.Response:
|
||||
"""Premium-squad traffic overrides only (unlimited toggle + bonus GB)."""
|
||||
actor_id = _require_admin_user_id(request)
|
||||
target_id = int(request.match_info["user_id"])
|
||||
payload = await _read_json(request)
|
||||
subscription_service = request.app.get("subscription_service")
|
||||
|
||||
unlimited = bool(payload.get("unlimited"))
|
||||
bonus_bytes_raw = payload.get("bonus_bytes")
|
||||
bonus_gb_raw = payload.get("bonus_gb")
|
||||
if bonus_bytes_raw is None and bonus_gb_raw is None:
|
||||
bonus_bytes = 0
|
||||
elif bonus_bytes_raw is not None:
|
||||
try:
|
||||
bonus_bytes = int(bonus_bytes_raw)
|
||||
except (TypeError, ValueError):
|
||||
return _error(400, "invalid_bonus", "bonus_bytes must be an integer")
|
||||
else:
|
||||
try:
|
||||
bonus_bytes = int(round(float(bonus_gb_raw) * (1024**3)))
|
||||
except (TypeError, ValueError):
|
||||
return _error(400, "invalid_bonus", "bonus_gb must be a number")
|
||||
|
||||
if bonus_bytes < 0:
|
||||
return _error(400, "invalid_bonus", "bonus must be non-negative")
|
||||
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
active = await subscription_dal.get_active_subscription_by_user_id(session, target_id)
|
||||
if not active:
|
||||
return _error(404, "no_active_subscription")
|
||||
|
||||
active.premium_unlimited_override = bool(unlimited)
|
||||
active.premium_bonus_bytes = int(bonus_bytes)
|
||||
if active.premium_unlimited_override:
|
||||
active.premium_is_limited = False
|
||||
|
||||
await message_log_dal.create_message_log(
|
||||
session,
|
||||
{
|
||||
"user_id": actor_id,
|
||||
"event_type": "admin_premium_override_webapp",
|
||||
"content": (f"unlimited={bool(unlimited)} bonus_bytes={int(bonus_bytes)}"),
|
||||
"is_admin_event": True,
|
||||
"target_user_id": target_id,
|
||||
},
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(active)
|
||||
|
||||
if subscription_service is not None:
|
||||
await subscription_service.sync_premium_squad_access_to_panel(session, target_id)
|
||||
await session.commit()
|
||||
await session.refresh(active)
|
||||
|
||||
return _ok({"subscription": _serialize_subscription(active)})
|
||||
|
||||
|
||||
async def admin_user_regular_traffic_override_route(request: web.Request) -> web.Response:
|
||||
"""Main (regular) traffic: unlimited-style ceiling + admin bonus GB."""
|
||||
actor_id = _require_admin_user_id(request)
|
||||
target_id = int(request.match_info["user_id"])
|
||||
payload = await _read_json(request)
|
||||
|
||||
unlimited = bool(payload.get("unlimited"))
|
||||
regular_bonus_bytes_raw = payload.get("regular_bonus_bytes")
|
||||
regular_bonus_gb_raw = payload.get("regular_bonus_gb")
|
||||
if regular_bonus_bytes_raw is None and regular_bonus_gb_raw is None:
|
||||
regular_bonus_bytes = 0
|
||||
elif regular_bonus_bytes_raw is not None:
|
||||
try:
|
||||
regular_bonus_bytes = int(regular_bonus_bytes_raw)
|
||||
except (TypeError, ValueError):
|
||||
return _error(400, "invalid_regular_bonus", "regular_bonus_bytes must be an integer")
|
||||
else:
|
||||
try:
|
||||
regular_bonus_bytes = int(round(float(regular_bonus_gb_raw) * (1024**3)))
|
||||
except (TypeError, ValueError):
|
||||
return _error(400, "invalid_regular_bonus", "regular_bonus_gb must be a number")
|
||||
|
||||
if regular_bonus_bytes < 0:
|
||||
return _error(400, "invalid_regular_bonus", "regular bonus must be non-negative")
|
||||
|
||||
subscription_service = request.app.get("subscription_service")
|
||||
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
active = await subscription_dal.get_active_subscription_by_user_id(session, target_id)
|
||||
if not active:
|
||||
return _error(404, "no_active_subscription")
|
||||
|
||||
active.regular_unlimited_override = bool(unlimited)
|
||||
active.regular_bonus_bytes = int(regular_bonus_bytes)
|
||||
|
||||
if subscription_service is not None:
|
||||
await subscription_service.sync_main_traffic_limit_to_panel(session, target_id)
|
||||
|
||||
await message_log_dal.create_message_log(
|
||||
session,
|
||||
{
|
||||
"user_id": actor_id,
|
||||
"event_type": "admin_regular_traffic_override_webapp",
|
||||
"content": (
|
||||
f"unlimited={bool(unlimited)} regular_bonus_bytes={int(regular_bonus_bytes)}"
|
||||
),
|
||||
"is_admin_event": True,
|
||||
"target_user_id": target_id,
|
||||
},
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(active)
|
||||
|
||||
return _ok({"subscription": _serialize_subscription(active)})
|
||||
|
||||
|
||||
async def admin_user_traffic_grant_route(request: web.Request) -> web.Response:
|
||||
"""Credit regular or premium traffic to a user without a payment.
|
||||
|
||||
Body: ``{"kind": "regular" | "premium", "gb": float}`` (alternatively
|
||||
``"bytes": int``). Mirrors the same effect as a user-purchased top-up:
|
||||
the chosen balance grows, panel limit/squads are refreshed, and an entry
|
||||
is added to ``traffic_topups`` with ``kind="admin_topup"`` or
|
||||
``kind="admin_premium_topup"`` and ``payment_id=NULL``.
|
||||
"""
|
||||
actor_id = _require_admin_user_id(request)
|
||||
target_id = int(request.match_info["user_id"])
|
||||
payload = await _read_json(request)
|
||||
|
||||
kind = str(payload.get("kind") or "regular").strip().lower()
|
||||
if kind not in {"regular", "premium"}:
|
||||
return _error(400, "invalid_kind", "kind must be 'regular' or 'premium'")
|
||||
|
||||
bytes_raw = payload.get("bytes")
|
||||
gb_raw = payload.get("gb")
|
||||
if bytes_raw is None and gb_raw is None:
|
||||
return _error(400, "missing_amount", "either 'gb' or 'bytes' is required")
|
||||
try:
|
||||
if bytes_raw is not None:
|
||||
grant_bytes = int(bytes_raw)
|
||||
gb_value = grant_bytes / (1024**3)
|
||||
else:
|
||||
gb_value = float(gb_raw)
|
||||
grant_bytes = int(round(gb_value * (1024**3)))
|
||||
except (TypeError, ValueError):
|
||||
return _error(400, "invalid_amount", "amount must be a positive number")
|
||||
if gb_value <= 0 or grant_bytes <= 0:
|
||||
return _error(400, "invalid_amount", "amount must be positive")
|
||||
|
||||
subscription_service = request.app.get("subscription_service")
|
||||
if subscription_service is None:
|
||||
return _error(503, "subscription_service_unavailable")
|
||||
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
active = await subscription_dal.get_active_subscription_by_user_id(session, target_id)
|
||||
if not active:
|
||||
return _error(404, "no_active_subscription")
|
||||
|
||||
if kind == "regular":
|
||||
result = await subscription_service.admin_grant_topup(session, target_id, gb_value)
|
||||
else:
|
||||
result = await subscription_service.admin_grant_premium_topup(
|
||||
session, target_id, gb_value
|
||||
)
|
||||
if not result:
|
||||
await session.rollback()
|
||||
return _error(
|
||||
422,
|
||||
"grant_failed",
|
||||
"Unable to credit traffic (missing tariff/squads or panel error)",
|
||||
)
|
||||
|
||||
await message_log_dal.create_message_log(
|
||||
session,
|
||||
{
|
||||
"user_id": actor_id,
|
||||
"event_type": "admin_traffic_grant_webapp",
|
||||
"content": f"kind={kind} bytes={grant_bytes}",
|
||||
"is_admin_event": True,
|
||||
"target_user_id": target_id,
|
||||
},
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
refreshed = await subscription_dal.get_active_subscription_by_user_id(session, target_id)
|
||||
|
||||
return _ok(
|
||||
{
|
||||
"subscription": _serialize_subscription(refreshed) if refreshed else None,
|
||||
"grant": {
|
||||
"kind": kind,
|
||||
"granted_bytes": grant_bytes,
|
||||
"granted_gb": gb_value,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def admin_user_extend_route(request: web.Request) -> web.Response:
|
||||
actor_id = _require_admin_user_id(request)
|
||||
target_id = int(request.match_info["user_id"])
|
||||
payload = await _read_json(request)
|
||||
try:
|
||||
days = int(payload.get("days") or 0)
|
||||
except (TypeError, ValueError):
|
||||
return _error(400, "invalid_days")
|
||||
if days <= 0:
|
||||
return _error(400, "invalid_days")
|
||||
|
||||
subscription_service = request.app.get("subscription_service")
|
||||
if subscription_service is None:
|
||||
return _error(503, "subscription_service_unavailable")
|
||||
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
new_end = await subscription_service.extend_active_subscription_days(
|
||||
session,
|
||||
target_id,
|
||||
days,
|
||||
"admin_extend_subscription_webapp",
|
||||
)
|
||||
if not new_end:
|
||||
await session.rollback()
|
||||
return _error(500, "extend_failed")
|
||||
|
||||
await message_log_dal.create_message_log(
|
||||
session,
|
||||
{
|
||||
"user_id": actor_id,
|
||||
"event_type": "admin_extend_subscription_webapp",
|
||||
"content": f"+{days}d -> {new_end.isoformat()}",
|
||||
"is_admin_event": True,
|
||||
"target_user_id": target_id,
|
||||
},
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
refreshed = await subscription_dal.get_active_subscription_by_user_id(session, target_id)
|
||||
|
||||
return _ok(
|
||||
{
|
||||
"subscription": _serialize_subscription(refreshed) if refreshed else None,
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,536 @@
|
||||
"""Manifest of settings editable from the admin web app.
|
||||
|
||||
Each entry describes a single overridable attribute on the global
|
||||
``Settings`` instance. The manifest is the only contract between the
|
||||
admin UI and the backend: keys not listed here cannot be changed via
|
||||
the API, even by an admin.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, List, Optional, Tuple
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SettingField:
|
||||
key: str
|
||||
type: str # "string" | "int" | "float" | "bool" | "text" | "url" | "color" | "secret"
|
||||
section: str
|
||||
label: str
|
||||
description: str = ""
|
||||
placeholder: str = ""
|
||||
optional: bool = True
|
||||
secret: bool = False
|
||||
min: Optional[float] = None
|
||||
max: Optional[float] = None
|
||||
choices: Optional[Tuple[Tuple[str, str], ...]] = None
|
||||
subsection: Optional[str] = None # group label inside a section
|
||||
i18n_label_key: Optional[str] = None
|
||||
i18n_description_key: Optional[str] = None
|
||||
|
||||
|
||||
SETTINGS_MANIFEST: List[SettingField] = [
|
||||
# ─── General ────────────────────────────────────────────────────
|
||||
SettingField(
|
||||
"DEFAULT_LANGUAGE",
|
||||
"string",
|
||||
"general",
|
||||
"Язык по умолчанию",
|
||||
"Используется для приветственных сообщений и публичных страниц.",
|
||||
),
|
||||
SettingField(
|
||||
"DEFAULT_CURRENCY_SYMBOL",
|
||||
"string",
|
||||
"general",
|
||||
"Валюта",
|
||||
"Например, RUB, USD, EUR.",
|
||||
placeholder="RUB",
|
||||
),
|
||||
SettingField(
|
||||
"SUPPORT_LINK", "url", "general", "Ссылка поддержки", "Куда вести пользователей за помощью."
|
||||
),
|
||||
SettingField("SERVER_STATUS_URL", "url", "general", "Ссылка на статус серверов"),
|
||||
SettingField("TERMS_OF_SERVICE_URL", "url", "general", "Условия использования"),
|
||||
SettingField("PRIVACY_POLICY_URL", "url", "general", "Политика конфиденциальности"),
|
||||
SettingField("USER_AGREEMENT_URL", "url", "general", "Пользовательское соглашение"),
|
||||
SettingField("DISABLE_WELCOME_MESSAGE", "bool", "general", "Скрыть приветствие /start"),
|
||||
SettingField(
|
||||
"START_COMMAND_DESCRIPTION", "string", "general", "Описание /start", placeholder=""
|
||||
),
|
||||
SettingField(
|
||||
"REQUIRED_CHANNEL_ID",
|
||||
"int",
|
||||
"general",
|
||||
"ID обязательного канала",
|
||||
"Telegram ID канала, в котором нужно состоять.",
|
||||
),
|
||||
SettingField(
|
||||
"REQUIRED_CHANNEL_LINK",
|
||||
"string",
|
||||
"general",
|
||||
"Ссылка на канал",
|
||||
"Имя пользователя или invite-link.",
|
||||
),
|
||||
# ─── Web app appearance ────────────────────────────────────────
|
||||
SettingField(
|
||||
"WEBAPP_TITLE", "string", "appearance", "Название Web App", placeholder="Моя подписка"
|
||||
),
|
||||
SettingField(
|
||||
"WEBAPP_PRIMARY_COLOR", "color", "appearance", "Основной цвет", placeholder="#00fe7a"
|
||||
),
|
||||
SettingField("WEBAPP_LOGO_USE_EMOJI", "bool", "appearance", "Использовать эмоджи-логотип"),
|
||||
SettingField("WEBAPP_LOGO_URL", "url", "appearance", "URL логотипа"),
|
||||
SettingField("WEBAPP_LOGO_EMOJI", "string", "appearance", "Эмоджи-логотип", placeholder="🫥"),
|
||||
SettingField(
|
||||
"WEBAPP_LOGO_EMOJI_FONT",
|
||||
"string",
|
||||
"appearance",
|
||||
"Шрифт эмоджи-логотипа",
|
||||
"Выберите шрифт для отображения эмодзи-логотипа",
|
||||
choices=(
|
||||
("system", "Системный (по умолчанию)"),
|
||||
("noto-color", "Noto Color Emoji"),
|
||||
("noto-color-animated", "Noto Color Emoji Animated"),
|
||||
("noto-emoji", "Noto Emoji"),
|
||||
("twemoji", "Twitter Emoji"),
|
||||
("openmoji", "OpenMoji"),
|
||||
("apple", "Apple Color Emoji (local)"),
|
||||
("segoe", "Segoe UI Emoji (local)"),
|
||||
("noto-local", "Noto Emoji (local)"),
|
||||
),
|
||||
),
|
||||
SettingField(
|
||||
"WEBAPP_FAVICON_USE_CUSTOM",
|
||||
"bool",
|
||||
"appearance",
|
||||
"Использовать отдельную favicon",
|
||||
),
|
||||
SettingField("WEBAPP_FAVICON_URL", "url", "appearance", "URL отдельной favicon"),
|
||||
SettingField("WEBAPP_LOGO_FAVICON_URL", "url", "appearance", "Favicon из логотипа"),
|
||||
SettingField("WEBAPP_ENABLED", "bool", "appearance", "Web App включён"),
|
||||
# ─── Subscription periods & pricing ────────────────────────────
|
||||
SettingField("MONTH_1_ENABLED", "bool", "pricing", "Тариф 1 месяц"),
|
||||
SettingField("MONTH_3_ENABLED", "bool", "pricing", "Тариф 3 месяца"),
|
||||
SettingField("MONTH_6_ENABLED", "bool", "pricing", "Тариф 6 месяцев"),
|
||||
SettingField("MONTH_12_ENABLED", "bool", "pricing", "Тариф 12 месяцев"),
|
||||
SettingField("RUB_PRICE_1_MONTH", "int", "pricing", "Цена 1 мес. (RUB)"),
|
||||
SettingField("RUB_PRICE_3_MONTHS", "int", "pricing", "Цена 3 мес. (RUB)"),
|
||||
SettingField("RUB_PRICE_6_MONTHS", "int", "pricing", "Цена 6 мес. (RUB)"),
|
||||
SettingField("RUB_PRICE_12_MONTHS", "int", "pricing", "Цена 12 мес. (RUB)"),
|
||||
SettingField("STARS_PRICE_1_MONTH", "int", "pricing", "Цена 1 мес. (Stars)"),
|
||||
SettingField("STARS_PRICE_3_MONTHS", "int", "pricing", "Цена 3 мес. (Stars)"),
|
||||
SettingField("STARS_PRICE_6_MONTHS", "int", "pricing", "Цена 6 мес. (Stars)"),
|
||||
SettingField("STARS_PRICE_12_MONTHS", "int", "pricing", "Цена 12 мес. (Stars)"),
|
||||
SettingField(
|
||||
"TRAFFIC_PACKAGES", "string", "pricing", "Пакеты трафика", "Формат: 10:199,50:799 (ГБ:цена)"
|
||||
),
|
||||
SettingField("STARS_TRAFFIC_PACKAGES", "string", "pricing", "Пакеты трафика (Stars)"),
|
||||
SettingField(
|
||||
"PAYMENT_METHODS_ORDER",
|
||||
"string",
|
||||
"pricing",
|
||||
"Порядок методов оплаты",
|
||||
"Через запятую, например: severpay,freekassa,yookassa",
|
||||
),
|
||||
# ─── Payment providers (toggles) ───────────────────────────────
|
||||
# Common
|
||||
SettingField("STARS_ENABLED", "bool", "payments", "Telegram Stars", subsection="Общие"),
|
||||
SettingField(
|
||||
"PAYMENT_METHODS_ORDER",
|
||||
"string",
|
||||
"payments",
|
||||
"Порядок методов оплаты",
|
||||
"Через запятую: severpay,freekassa,yookassa,platega,stars,cryptopay",
|
||||
subsection="Общие",
|
||||
),
|
||||
# YooKassa
|
||||
SettingField("YOOKASSA_ENABLED", "bool", "payments", "Включена", subsection="YooKassa"),
|
||||
SettingField("YOOKASSA_SHOP_ID", "string", "payments", "Shop ID", subsection="YooKassa"),
|
||||
SettingField(
|
||||
"YOOKASSA_SECRET_KEY",
|
||||
"string",
|
||||
"payments",
|
||||
"Secret key",
|
||||
subsection="YooKassa",
|
||||
secret=True,
|
||||
),
|
||||
SettingField("YOOKASSA_RETURN_URL", "url", "payments", "Return URL", subsection="YooKassa"),
|
||||
SettingField(
|
||||
"YOOKASSA_DEFAULT_RECEIPT_EMAIL",
|
||||
"string",
|
||||
"payments",
|
||||
"Email для чека по умолчанию",
|
||||
subsection="YooKassa",
|
||||
),
|
||||
SettingField(
|
||||
"YOOKASSA_VAT_CODE",
|
||||
"int",
|
||||
"payments",
|
||||
"VAT code",
|
||||
"1..6 в зависимости от системы налогообложения",
|
||||
subsection="YooKassa",
|
||||
min=1,
|
||||
max=6,
|
||||
),
|
||||
SettingField(
|
||||
"YOOKASSA_AUTOPAYMENTS_ENABLED",
|
||||
"bool",
|
||||
"payments",
|
||||
"Автоплатежи (recurring)",
|
||||
subsection="YooKassa",
|
||||
),
|
||||
SettingField(
|
||||
"YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING",
|
||||
"bool",
|
||||
"payments",
|
||||
"Принудительная привязка карты",
|
||||
subsection="YooKassa",
|
||||
),
|
||||
# FreeKassa
|
||||
SettingField("FREEKASSA_ENABLED", "bool", "payments", "Включена", subsection="FreeKassa"),
|
||||
SettingField(
|
||||
"FREEKASSA_MERCHANT_ID", "string", "payments", "Merchant ID", subsection="FreeKassa"
|
||||
),
|
||||
SettingField(
|
||||
"FREEKASSA_FIRST_SECRET",
|
||||
"string",
|
||||
"payments",
|
||||
"First secret",
|
||||
subsection="FreeKassa",
|
||||
secret=True,
|
||||
),
|
||||
SettingField(
|
||||
"FREEKASSA_SECOND_SECRET",
|
||||
"string",
|
||||
"payments",
|
||||
"Second secret",
|
||||
"Используется для проверки подписи входящих уведомлений",
|
||||
subsection="FreeKassa",
|
||||
secret=True,
|
||||
),
|
||||
SettingField(
|
||||
"FREEKASSA_API_KEY", "string", "payments", "API key", subsection="FreeKassa", secret=True
|
||||
),
|
||||
SettingField(
|
||||
"FREEKASSA_PAYMENT_URL",
|
||||
"url",
|
||||
"payments",
|
||||
"Payment URL",
|
||||
placeholder="https://pay.freekassa.ru/",
|
||||
subsection="FreeKassa",
|
||||
),
|
||||
SettingField(
|
||||
"FREEKASSA_PAYMENT_METHOD_ID",
|
||||
"int",
|
||||
"payments",
|
||||
"Метод оплаты по умолчанию",
|
||||
subsection="FreeKassa",
|
||||
),
|
||||
SettingField(
|
||||
"FREEKASSA_PAYMENT_IP",
|
||||
"string",
|
||||
"payments",
|
||||
"IP сервера",
|
||||
"Передаётся в подпись запроса при создании платежа",
|
||||
subsection="FreeKassa",
|
||||
),
|
||||
SettingField(
|
||||
"FREEKASSA_TRUSTED_IPS",
|
||||
"string",
|
||||
"payments",
|
||||
"Доверенные IP",
|
||||
"Через запятую — IP-адреса, с которых принимаются нотификации",
|
||||
subsection="FreeKassa",
|
||||
),
|
||||
# Platega
|
||||
SettingField("PLATEGA_ENABLED", "bool", "payments", "Включена", subsection="Platega"),
|
||||
SettingField(
|
||||
"PLATEGA_BASE_URL",
|
||||
"url",
|
||||
"payments",
|
||||
"Base URL",
|
||||
placeholder="https://app.platega.io",
|
||||
subsection="Platega",
|
||||
),
|
||||
SettingField("PLATEGA_MERCHANT_ID", "string", "payments", "Merchant ID", subsection="Platega"),
|
||||
SettingField(
|
||||
"PLATEGA_SECRET", "string", "payments", "Secret", subsection="Platega", secret=True
|
||||
),
|
||||
SettingField(
|
||||
"PLATEGA_PAYMENT_METHOD", "int", "payments", "Метод оплаты (legacy)", subsection="Platega"
|
||||
),
|
||||
SettingField("PLATEGA_SBP_ENABLED", "bool", "payments", "SBP-кнопка", subsection="Platega"),
|
||||
SettingField("PLATEGA_SBP_METHOD", "int", "payments", "SBP method ID", subsection="Platega"),
|
||||
SettingField(
|
||||
"PLATEGA_CRYPTO_ENABLED", "bool", "payments", "Crypto-кнопка", subsection="Platega"
|
||||
),
|
||||
SettingField(
|
||||
"PLATEGA_CRYPTO_METHOD", "int", "payments", "Crypto method ID", subsection="Platega"
|
||||
),
|
||||
SettingField("PLATEGA_RETURN_URL", "url", "payments", "Return URL", subsection="Platega"),
|
||||
SettingField("PLATEGA_FAILED_URL", "url", "payments", "Failed URL", subsection="Platega"),
|
||||
# SeverPay
|
||||
SettingField("SEVERPAY_ENABLED", "bool", "payments", "Включена", subsection="SeverPay"),
|
||||
SettingField("SEVERPAY_MID", "int", "payments", "MID", subsection="SeverPay"),
|
||||
SettingField(
|
||||
"SEVERPAY_TOKEN", "string", "payments", "Token", subsection="SeverPay", secret=True
|
||||
),
|
||||
SettingField(
|
||||
"SEVERPAY_BASE_URL",
|
||||
"url",
|
||||
"payments",
|
||||
"Base URL",
|
||||
placeholder="https://severpay.io/api/merchant",
|
||||
subsection="SeverPay",
|
||||
),
|
||||
SettingField("SEVERPAY_RETURN_URL", "url", "payments", "Return URL", subsection="SeverPay"),
|
||||
SettingField(
|
||||
"SEVERPAY_LIFETIME_MINUTES",
|
||||
"int",
|
||||
"payments",
|
||||
"Срок жизни ссылки (мин)",
|
||||
"30..4320; пусто — значение провайдера",
|
||||
subsection="SeverPay",
|
||||
min=30,
|
||||
max=4320,
|
||||
),
|
||||
# CryptoPay
|
||||
SettingField("CRYPTOPAY_ENABLED", "bool", "payments", "Включена", subsection="CryptoPay"),
|
||||
SettingField(
|
||||
"CRYPTOPAY_TOKEN", "string", "payments", "Token", subsection="CryptoPay", secret=True
|
||||
),
|
||||
SettingField(
|
||||
"CRYPTOPAY_NETWORK",
|
||||
"string",
|
||||
"payments",
|
||||
"Network",
|
||||
"mainnet или testnet",
|
||||
subsection="CryptoPay",
|
||||
),
|
||||
SettingField(
|
||||
"CRYPTOPAY_CURRENCY_TYPE",
|
||||
"string",
|
||||
"payments",
|
||||
"Currency type",
|
||||
"fiat или crypto",
|
||||
subsection="CryptoPay",
|
||||
),
|
||||
SettingField(
|
||||
"CRYPTOPAY_ASSET", "string", "payments", "Asset", placeholder="RUB", subsection="CryptoPay"
|
||||
),
|
||||
# ─── Trial ─────────────────────────────────────────────────────
|
||||
SettingField("TRIAL_ENABLED", "bool", "trial", "Триал включён"),
|
||||
SettingField("TRIAL_DURATION_DAYS", "int", "trial", "Длительность триала (дней)", min=0),
|
||||
SettingField("TRIAL_TRAFFIC_LIMIT_GB", "float", "trial", "Лимит трафика триала (ГБ)", min=0),
|
||||
SettingField("TRIAL_TRAFFIC_STRATEGY", "string", "trial", "Стратегия сброса трафика триала"),
|
||||
# ─── Referral program ──────────────────────────────────────────
|
||||
SettingField(
|
||||
"REFERRAL_ONE_BONUS_PER_REFEREE", "bool", "referral", "Один бонус на приглашённого"
|
||||
),
|
||||
SettingField(
|
||||
"REFERRAL_WELCOME_BONUS_DAYS", "int", "referral", "Приветственный бонус (дней)", min=0
|
||||
),
|
||||
SettingField("LEGACY_REFS", "bool", "referral", "Поддержка старых ref-ссылок"),
|
||||
SettingField(
|
||||
"REFERRAL_BONUS_DAYS_INVITER_1_MONTH",
|
||||
"int",
|
||||
"referral",
|
||||
"Бонус приглашающему: 1 мес.",
|
||||
min=0,
|
||||
),
|
||||
SettingField(
|
||||
"REFERRAL_BONUS_DAYS_INVITER_3_MONTHS",
|
||||
"int",
|
||||
"referral",
|
||||
"Бонус приглашающему: 3 мес.",
|
||||
min=0,
|
||||
),
|
||||
SettingField(
|
||||
"REFERRAL_BONUS_DAYS_INVITER_6_MONTHS",
|
||||
"int",
|
||||
"referral",
|
||||
"Бонус приглашающему: 6 мес.",
|
||||
min=0,
|
||||
),
|
||||
SettingField(
|
||||
"REFERRAL_BONUS_DAYS_INVITER_12_MONTHS",
|
||||
"int",
|
||||
"referral",
|
||||
"Бонус приглашающему: 12 мес.",
|
||||
min=0,
|
||||
),
|
||||
SettingField(
|
||||
"REFERRAL_BONUS_DAYS_REFEREE_1_MONTH",
|
||||
"int",
|
||||
"referral",
|
||||
"Бонус приглашённому: 1 мес.",
|
||||
min=0,
|
||||
),
|
||||
SettingField(
|
||||
"REFERRAL_BONUS_DAYS_REFEREE_3_MONTHS",
|
||||
"int",
|
||||
"referral",
|
||||
"Бонус приглашённому: 3 мес.",
|
||||
min=0,
|
||||
),
|
||||
SettingField(
|
||||
"REFERRAL_BONUS_DAYS_REFEREE_6_MONTHS",
|
||||
"int",
|
||||
"referral",
|
||||
"Бонус приглашённому: 6 мес.",
|
||||
min=0,
|
||||
),
|
||||
SettingField(
|
||||
"REFERRAL_BONUS_DAYS_REFEREE_12_MONTHS",
|
||||
"int",
|
||||
"referral",
|
||||
"Бонус приглашённому: 12 мес.",
|
||||
min=0,
|
||||
),
|
||||
# ─── Notifications ─────────────────────────────────────────────
|
||||
SettingField(
|
||||
"SUBSCRIPTION_NOTIFICATIONS_ENABLED",
|
||||
"bool",
|
||||
"notifications",
|
||||
"Включены уведомления о подписке",
|
||||
),
|
||||
SettingField(
|
||||
"SUBSCRIPTION_NOTIFY_ON_EXPIRE", "bool", "notifications", "Уведомлять об истечении"
|
||||
),
|
||||
SettingField(
|
||||
"SUBSCRIPTION_NOTIFY_AFTER_EXPIRE", "bool", "notifications", "Уведомлять после истечения"
|
||||
),
|
||||
SettingField(
|
||||
"SUBSCRIPTION_NOTIFY_DAYS_BEFORE",
|
||||
"int",
|
||||
"notifications",
|
||||
"За сколько дней предупреждать",
|
||||
min=0,
|
||||
),
|
||||
SettingField("LOG_NEW_USERS", "bool", "notifications", "Логировать новых пользователей"),
|
||||
SettingField("LOG_PAYMENTS", "bool", "notifications", "Логировать платежи"),
|
||||
SettingField(
|
||||
"LOG_PROMO_ACTIVATIONS", "bool", "notifications", "Логировать активации промокодов"
|
||||
),
|
||||
SettingField("LOG_TRIAL_ACTIVATIONS", "bool", "notifications", "Логировать активации триала"),
|
||||
SettingField(
|
||||
"LOG_SUSPICIOUS_ACTIVITY", "bool", "notifications", "Логировать подозрительные действия"
|
||||
),
|
||||
SettingField(
|
||||
"LOG_ADMIN_ACTIONS",
|
||||
"bool",
|
||||
"notifications",
|
||||
"Логировать действия администраторов",
|
||||
"Если выключено, события от пользователей из ADMIN_IDS не записываются в message logs.",
|
||||
i18n_label_key="settings_field_log_admin_actions_label",
|
||||
i18n_description_key="settings_field_log_admin_actions_description",
|
||||
),
|
||||
SettingField(
|
||||
"LOG_LEVEL",
|
||||
"string",
|
||||
"notifications",
|
||||
"Глобальный уровень логов",
|
||||
"DEBUG / INFO / WARNING / ERROR",
|
||||
),
|
||||
SettingField("LOG_CHAT_ID", "int", "notifications", "ID чата для логов"),
|
||||
SettingField("LOG_THREAD_ID", "int", "notifications", "ID треда (для супергрупп)"),
|
||||
# ─── Devices ───────────────────────────────────────────────────
|
||||
SettingField("MY_DEVICES_SECTION_ENABLED", "bool", "devices", "Раздел «Мои устройства»"),
|
||||
SettingField(
|
||||
"USER_HWID_DEVICE_LIMIT", "int", "devices", "Лимит устройств по умолчанию (0 = ∞)", min=0
|
||||
),
|
||||
SettingField("USER_TRAFFIC_LIMIT_GB", "float", "devices", "Лимит трафика пользователя (ГБ)"),
|
||||
SettingField("USER_TRAFFIC_STRATEGY", "string", "devices", "Стратегия сброса трафика"),
|
||||
]
|
||||
|
||||
|
||||
def get_field_by_key(key: str) -> Optional[SettingField]:
|
||||
for field in SETTINGS_MANIFEST:
|
||||
if field.key == key:
|
||||
return field
|
||||
return None
|
||||
|
||||
|
||||
def manifest_keys() -> List[str]:
|
||||
return [f.key for f in SETTINGS_MANIFEST]
|
||||
|
||||
|
||||
def coerce_value(field: SettingField, raw: Any) -> Any:
|
||||
"""Coerce a value coming from JSON to the type declared by the field."""
|
||||
|
||||
if raw is None or (isinstance(raw, str) and raw.strip() == ""):
|
||||
return None
|
||||
|
||||
if field.type == "bool":
|
||||
if isinstance(raw, bool):
|
||||
return raw
|
||||
if isinstance(raw, (int, float)):
|
||||
return bool(raw)
|
||||
if isinstance(raw, str):
|
||||
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
||||
return bool(raw)
|
||||
|
||||
if field.type == "int":
|
||||
try:
|
||||
value = int(str(raw).strip())
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError(f"{field.key}: integer expected") from exc
|
||||
if field.min is not None and value < field.min:
|
||||
raise ValueError(f"{field.key}: must be >= {field.min:g}")
|
||||
if field.max is not None and value > field.max:
|
||||
raise ValueError(f"{field.key}: must be <= {field.max:g}")
|
||||
return value
|
||||
|
||||
if field.type == "float":
|
||||
try:
|
||||
value = float(str(raw).strip())
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError(f"{field.key}: number expected") from exc
|
||||
if field.min is not None and value < field.min:
|
||||
raise ValueError(f"{field.key}: must be >= {field.min:g}")
|
||||
if field.max is not None and value > field.max:
|
||||
raise ValueError(f"{field.key}: must be <= {field.max:g}")
|
||||
return value
|
||||
|
||||
if isinstance(raw, str):
|
||||
return raw.strip()
|
||||
return str(raw)
|
||||
|
||||
|
||||
def manifest_payload() -> List[dict]:
|
||||
"""Serialize the manifest for the admin UI."""
|
||||
|
||||
sections_order = {
|
||||
"general": 1,
|
||||
"appearance": 2,
|
||||
"pricing": 3,
|
||||
"payments": 4,
|
||||
"trial": 5,
|
||||
"referral": 6,
|
||||
"notifications": 7,
|
||||
"devices": 8,
|
||||
}
|
||||
items: List[dict] = []
|
||||
for field in SETTINGS_MANIFEST:
|
||||
auto_label_i18n_key = f"settings_field_{field.key.lower()}_label"
|
||||
auto_description_i18n_key = f"settings_field_{field.key.lower()}_description"
|
||||
item = {
|
||||
"key": field.key,
|
||||
"type": field.type,
|
||||
"section": field.section,
|
||||
"section_order": sections_order.get(field.section, 99),
|
||||
"subsection": field.subsection,
|
||||
"label": field.label,
|
||||
"description": field.description,
|
||||
"i18n_label_key": field.i18n_label_key or auto_label_i18n_key,
|
||||
"i18n_description_key": field.i18n_description_key
|
||||
or (auto_description_i18n_key if field.description else None),
|
||||
"placeholder": field.placeholder,
|
||||
"optional": field.optional,
|
||||
"secret": field.secret,
|
||||
}
|
||||
if field.choices:
|
||||
item["choices"] = [{"value": v, "label": lbl} for v, lbl in field.choices]
|
||||
items.append(item)
|
||||
return items
|
||||
@@ -0,0 +1,29 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from bot.app.web.webapp_auth import verify_webapp_session_token
|
||||
from config.settings import Settings
|
||||
|
||||
WEBAPP_SESSION_COOKIE_NAME = "rw_webapp_session"
|
||||
|
||||
|
||||
def extract_authenticated_user_id(request: web.Request) -> Optional[int]:
|
||||
settings: Settings = request.app["settings"]
|
||||
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
if auth_header.startswith("Bearer "):
|
||||
user_id = verify_webapp_session_token(
|
||||
settings,
|
||||
auth_header.removeprefix("Bearer ").strip(),
|
||||
)
|
||||
if user_id:
|
||||
return user_id
|
||||
|
||||
session_cookie = request.cookies.get(WEBAPP_SESSION_COOKIE_NAME)
|
||||
if session_cookie:
|
||||
return verify_webapp_session_token(settings, session_cookie)
|
||||
|
||||
return None
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Compatibility facade for the subscription Mini App backend."""
|
||||
|
||||
# ruff: noqa: I001
|
||||
|
||||
from bot.app.web.webapp import (
|
||||
_runtime as _runtime,
|
||||
account as _account,
|
||||
application as _application,
|
||||
assets as _assets,
|
||||
auth as _auth,
|
||||
billing as _billing,
|
||||
common as _common,
|
||||
devices as _devices,
|
||||
payloads as _payloads,
|
||||
routes as _routes,
|
||||
serializers as _serializers,
|
||||
)
|
||||
|
||||
_MODULES = (
|
||||
_runtime,
|
||||
_payloads,
|
||||
_common,
|
||||
_assets,
|
||||
_auth,
|
||||
_account,
|
||||
_serializers,
|
||||
_billing,
|
||||
_devices,
|
||||
_routes,
|
||||
_application,
|
||||
)
|
||||
|
||||
_NAMESPACE = {}
|
||||
for _module in _MODULES:
|
||||
_NAMESPACE.update(
|
||||
{
|
||||
_name: _value
|
||||
for _name, _value in vars(_module).items()
|
||||
if not _name.startswith("__") and _name != "annotations"
|
||||
}
|
||||
)
|
||||
|
||||
for _module in _MODULES:
|
||||
vars(_module).update(_NAMESPACE)
|
||||
|
||||
globals().update(_NAMESPACE)
|
||||
|
||||
__all__ = sorted(_name for _name in _NAMESPACE if not _name.startswith("__"))
|
||||
@@ -0,0 +1,25 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<meta name="theme-color" content="#03070b">
|
||||
<link id="app-favicon" rel="icon" href="data:," sizes="any">
|
||||
<title>/minishop</title>
|
||||
<link rel="stylesheet" href="/subscription_webapp.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<main id="app"></main>
|
||||
|
||||
<!-- WEBAPP_I18N_SCRIPT -->
|
||||
<!-- WEBAPP_CONFIG_SCRIPT -->
|
||||
<!-- WEBAPP_JS_SCRIPT -->
|
||||
<!-- WEBAPP_DEV_MOCK_START -->
|
||||
<script src="/subscription_webapp.js" defer></script>
|
||||
<!-- WEBAPP_DEV_MOCK_END -->
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,953 @@
|
||||
/*
|
||||
* ASCII / console theme.
|
||||
* Pure black background, white foreground, monospace everything,
|
||||
* 1px white borders, animated ASCII spinners and block-progress bars.
|
||||
*/
|
||||
|
||||
.theme-key-ascii {
|
||||
color-scheme: dark;
|
||||
--accent: #ffffff;
|
||||
--accent-contrast: #000000;
|
||||
--bg: #000000;
|
||||
--panel: #000000;
|
||||
--panel-2: #050505;
|
||||
--panel-3: #0c0c0c;
|
||||
--border: #ffffff;
|
||||
--border-strong: #ffffff;
|
||||
--text: #ffffff;
|
||||
--muted: #b0b0b0;
|
||||
--dim: #6a6a6a;
|
||||
--danger: #ff5555;
|
||||
--blue: #ffffff;
|
||||
--radius: 0px;
|
||||
--font-sans: "JetBrains Mono", "Cascadia Code", "Fira Code", "Consolas",
|
||||
"Source Code Pro", "Courier New", ui-monospace, monospace;
|
||||
--font-logo: "JetBrains Mono", "Cascadia Code", "Consolas", "Courier New",
|
||||
ui-monospace, monospace;
|
||||
--font-mono: "JetBrains Mono", "Cascadia Code", "Consolas", "Courier New",
|
||||
ui-monospace, monospace;
|
||||
--surface-sheen: transparent;
|
||||
--surface-sheen-soft: transparent;
|
||||
--surface-hover: rgba(255, 255, 255, 0.08);
|
||||
--surface-muted: #0a0a0a;
|
||||
--surface-subtle-border: #ffffff;
|
||||
--overlay-scrim: rgba(0, 0, 0, 0.85);
|
||||
--nav-bg: #000000;
|
||||
--rail-bg: #000000;
|
||||
--shadow-soft: none;
|
||||
--shadow-strong: none;
|
||||
--shadow-popover: 0 0 0 1px #ffffff;
|
||||
--inset-highlight: transparent;
|
||||
--admin-bg: #000000;
|
||||
--admin-surface: #000000;
|
||||
--admin-surface-2: #050505;
|
||||
--admin-elev: #0c0c0c;
|
||||
--admin-border: #ffffff;
|
||||
--admin-border-strong: #ffffff;
|
||||
--admin-text: #ffffff;
|
||||
--admin-muted: #b0b0b0;
|
||||
--admin-dim: #6a6a6a;
|
||||
}
|
||||
|
||||
/* ---------- Base typography ---------- */
|
||||
|
||||
.theme-key-ascii,
|
||||
.theme-key-ascii body,
|
||||
.theme-key-ascii button,
|
||||
.theme-key-ascii input,
|
||||
.theme-key-ascii textarea,
|
||||
.theme-key-ascii select {
|
||||
font-family: var(--font-sans);
|
||||
letter-spacing: 0;
|
||||
font-synthesis: none;
|
||||
-webkit-font-smoothing: none;
|
||||
font-smooth: never;
|
||||
font-variant-ligatures: none;
|
||||
}
|
||||
|
||||
.theme-key-ascii.app-shell {
|
||||
background: var(--bg) !important;
|
||||
background-image:
|
||||
repeating-linear-gradient(
|
||||
0deg,
|
||||
rgba(255, 255, 255, 0.025) 0,
|
||||
rgba(255, 255, 255, 0.025) 1px,
|
||||
transparent 1px,
|
||||
transparent 3px
|
||||
) !important;
|
||||
}
|
||||
|
||||
/* Slight CRT-like flicker on the shell. */
|
||||
@keyframes ascii-flicker {
|
||||
0%, 96%, 100% { opacity: 1; }
|
||||
97% { opacity: 0.96; }
|
||||
98% { opacity: 1; }
|
||||
99% { opacity: 0.94; }
|
||||
}
|
||||
|
||||
.theme-key-ascii.app-shell::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 9998;
|
||||
background: repeating-linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 255, 0.02) 0,
|
||||
rgba(255, 255, 255, 0.02) 1px,
|
||||
transparent 1px,
|
||||
transparent 2px
|
||||
);
|
||||
animation: ascii-flicker 5s infinite;
|
||||
}
|
||||
|
||||
/* ---------- Panels / cards ---------- */
|
||||
|
||||
.theme-key-ascii .card,
|
||||
.theme-key-ascii .period-card,
|
||||
.theme-key-ascii .method-card,
|
||||
.theme-key-ascii .settings-row,
|
||||
.theme-key-ascii .option-row,
|
||||
.theme-key-ascii .tariff-selected-card,
|
||||
.theme-key-ascii .tariff-action-card,
|
||||
.theme-key-ascii .tariff-warning-card,
|
||||
.theme-key-ascii .topup-carryover-note,
|
||||
.theme-key-ascii .input,
|
||||
.theme-key-ascii .dialog-card,
|
||||
.theme-key-ascii .language-select-content,
|
||||
.theme-key-ascii .bottom-nav,
|
||||
.theme-key-ascii .toast,
|
||||
.theme-key-ascii .admin-sidebar,
|
||||
.theme-key-ascii .admin-header,
|
||||
.theme-key-ascii .admin-card,
|
||||
.theme-key-ascii .admin-stat-card,
|
||||
.theme-key-ascii .admin-revenue-panel,
|
||||
.theme-key-ascii .admin-empty,
|
||||
.theme-key-ascii .admin-tariff-card,
|
||||
.theme-key-ascii .admin-toolbar-card,
|
||||
.theme-key-ascii .admin-table-card,
|
||||
.theme-key-ascii .admin-panel-dash-card,
|
||||
.theme-key-ascii .admin-select-trigger,
|
||||
.theme-key-ascii .admin-select-content,
|
||||
.theme-key-ascii .admin-cn-card[data-slot="card"],
|
||||
.theme-key-ascii .admin-dialog .dialog-card,
|
||||
.theme-key-ascii .admin-theme-editor-section {
|
||||
border: 1px solid #ffffff;
|
||||
border-radius: 0;
|
||||
background: var(--panel);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* No ribbon/corner overlays: those caused dialog overflow scrollbars.
|
||||
* The console feel comes from the crisp 1px borders, monospace text,
|
||||
* and the animated marquees / glitches applied to interactive elements. */
|
||||
|
||||
/* ---------- Buttons ---------- */
|
||||
|
||||
.theme-key-ascii .btn,
|
||||
.theme-key-ascii .language-select-trigger,
|
||||
.theme-key-ascii .bottom-nav button,
|
||||
.theme-key-ascii .admin-btn,
|
||||
.theme-key-ascii .admin-chip,
|
||||
.theme-key-ascii .admin-tabs-trigger,
|
||||
.theme-key-ascii .admin-revenue-period-btn,
|
||||
.theme-key-ascii .admin-mobile-toggle,
|
||||
.theme-key-ascii .admin-nav-item {
|
||||
border: 1px solid #ffffff;
|
||||
border-radius: 0;
|
||||
background: #000000;
|
||||
color: #ffffff;
|
||||
box-shadow: none;
|
||||
text-transform: none;
|
||||
font-family: var(--font-sans);
|
||||
transform: none;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.theme-key-ascii .btn:hover:not(:disabled),
|
||||
.theme-key-ascii .admin-btn:hover:not(:disabled),
|
||||
.theme-key-ascii .admin-nav-item:hover,
|
||||
.theme-key-ascii .admin-tabs-trigger:hover,
|
||||
.theme-key-ascii .admin-revenue-period-btn:hover,
|
||||
.theme-key-ascii .bottom-nav button:hover {
|
||||
background: #ffffff;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.theme-key-ascii .btn:active:not(:disabled),
|
||||
.theme-key-ascii .bottom-nav button:active,
|
||||
.theme-key-ascii .admin-btn:active:not(:disabled) {
|
||||
background: #ffffff;
|
||||
color: #000000;
|
||||
transform: translate(1px, 1px);
|
||||
}
|
||||
|
||||
.theme-key-ascii .btn-primary,
|
||||
.theme-key-ascii .admin-btn-primary,
|
||||
.theme-key-ascii .bottom-nav button.active,
|
||||
.theme-key-ascii .period-card.active,
|
||||
.theme-key-ascii .method-card.active,
|
||||
.theme-key-ascii .option-row.active,
|
||||
.theme-key-ascii .admin-nav-item.active,
|
||||
.theme-key-ascii .admin-tabs-trigger[data-state="active"],
|
||||
.theme-key-ascii .admin-revenue-period-btn.is-active {
|
||||
background: #ffffff;
|
||||
color: #000000;
|
||||
border-color: #ffffff;
|
||||
}
|
||||
|
||||
.theme-key-ascii .btn-primary:hover:not(:disabled),
|
||||
.theme-key-ascii .admin-btn-primary:hover:not(:disabled) {
|
||||
background: #000000;
|
||||
color: #ffffff;
|
||||
outline: 1px solid #ffffff;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* Blinking caret-style focus ring. */
|
||||
@keyframes ascii-caret {
|
||||
0%, 49% { outline-color: #ffffff; }
|
||||
50%, 100% { outline-color: transparent; }
|
||||
}
|
||||
|
||||
.theme-key-ascii .btn:focus-visible,
|
||||
.theme-key-ascii .admin-btn:focus-visible,
|
||||
.theme-key-ascii .admin-nav-item:focus-visible,
|
||||
.theme-key-ascii .admin-tabs-trigger:focus-visible,
|
||||
.theme-key-ascii .admin-revenue-period-btn:focus-visible,
|
||||
.theme-key-ascii .admin-mobile-toggle:focus-visible,
|
||||
.theme-key-ascii .language-select-trigger:focus-visible,
|
||||
.theme-key-ascii .bottom-nav button:focus-visible {
|
||||
outline: 2px solid #ffffff;
|
||||
outline-offset: 1px;
|
||||
animation: ascii-caret 1s steps(1) infinite;
|
||||
}
|
||||
|
||||
/* ---------- Inputs ---------- */
|
||||
|
||||
.theme-key-ascii .input,
|
||||
.theme-key-ascii .admin-input,
|
||||
.theme-key-ascii .admin-textarea,
|
||||
.theme-key-ascii .admin-screen-wrap textarea,
|
||||
.theme-key-ascii .admin-dialog textarea {
|
||||
border: 1px solid #ffffff;
|
||||
border-radius: 0;
|
||||
background: #000000;
|
||||
color: #ffffff;
|
||||
box-shadow: none;
|
||||
font-family: var(--font-mono);
|
||||
caret-color: #ffffff;
|
||||
}
|
||||
|
||||
.theme-key-ascii .input::placeholder,
|
||||
.theme-key-ascii .admin-input::placeholder,
|
||||
.theme-key-ascii .admin-textarea::placeholder,
|
||||
.theme-key-ascii .admin-screen-wrap textarea::placeholder,
|
||||
.theme-key-ascii .admin-dialog textarea::placeholder {
|
||||
color: var(--dim);
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.theme-key-ascii .input:focus,
|
||||
.theme-key-ascii .admin-input:focus,
|
||||
.theme-key-ascii .admin-textarea:focus,
|
||||
.theme-key-ascii .admin-screen-wrap textarea:focus,
|
||||
.theme-key-ascii .admin-dialog textarea:focus {
|
||||
outline: none;
|
||||
border-color: #ffffff;
|
||||
box-shadow: inset 0 0 0 1px #ffffff;
|
||||
}
|
||||
|
||||
/* ---------- Bottom nav (desktop rail) ---------- */
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.theme-key-ascii .bottom-nav {
|
||||
border-right: 1px solid #ffffff !important;
|
||||
background: var(--rail-bg) !important;
|
||||
backdrop-filter: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.theme-key-ascii .bottom-nav button {
|
||||
border: 1px solid #ffffff !important;
|
||||
border-radius: 0 !important;
|
||||
background: #000000 !important;
|
||||
color: #ffffff !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.theme-key-ascii .bottom-nav button.active {
|
||||
background: #ffffff !important;
|
||||
color: #000000 !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- ASCII progress bar ---------- *
|
||||
* Empty track: ░░░░░░░░░░░ (low-contrast dotted fill).
|
||||
* Filled span: ██████ (solid white blocks).
|
||||
*/
|
||||
|
||||
.theme-key-ascii .progress {
|
||||
height: 14px;
|
||||
border: 1px solid #ffffff;
|
||||
border-radius: 0 !important;
|
||||
background-color: #000000;
|
||||
background-image: repeating-linear-gradient(
|
||||
90deg,
|
||||
rgba(255, 255, 255, 0.22) 0,
|
||||
rgba(255, 255, 255, 0.22) 1px,
|
||||
transparent 1px,
|
||||
transparent 4px
|
||||
);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.theme-key-ascii .progress span {
|
||||
border-radius: 0 !important;
|
||||
background: #ffffff !important;
|
||||
background-image: repeating-linear-gradient(
|
||||
90deg,
|
||||
rgba(0, 0, 0, 0.0) 0,
|
||||
rgba(0, 0, 0, 0.0) 5px,
|
||||
rgba(0, 0, 0, 0.35) 5px,
|
||||
rgba(0, 0, 0, 0.35) 6px
|
||||
) !important;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Indeterminate scanning effect for any progress lacking a width-set span. */
|
||||
@keyframes ascii-scan {
|
||||
0% { background-position: 0 0; }
|
||||
100% { background-position: 12px 0; }
|
||||
}
|
||||
|
||||
/* ---------- ASCII spinner replacement ---------- */
|
||||
|
||||
.theme-key-ascii .ui-spinner,
|
||||
.theme-key-ascii .telegram-button-spinner,
|
||||
.theme-key-ascii .brand-mark-spinner {
|
||||
border: none !important;
|
||||
border-radius: 0 !important;
|
||||
width: 1ch !important;
|
||||
height: 1em !important;
|
||||
background: transparent !important;
|
||||
position: relative;
|
||||
animation: none !important;
|
||||
color: currentColor;
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.theme-key-ascii .ui-spinner::before,
|
||||
.theme-key-ascii .telegram-button-spinner::before,
|
||||
.theme-key-ascii .brand-mark-spinner::before {
|
||||
content: "|";
|
||||
display: inline-block;
|
||||
animation: ascii-spin 0.8s steps(1) infinite;
|
||||
font-family: var(--font-mono);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
@keyframes ascii-spin {
|
||||
0% { content: "|"; }
|
||||
25% { content: "/"; }
|
||||
50% { content: "-"; }
|
||||
75% { content: "\\"; }
|
||||
100% { content: "|"; }
|
||||
}
|
||||
|
||||
/* Some browsers don't animate content; fallback rotation of a glyph. */
|
||||
@supports not (animation-name: ascii-spin) {
|
||||
.theme-key-ascii .ui-spinner::before,
|
||||
.theme-key-ascii .telegram-button-spinner::before,
|
||||
.theme-key-ascii .brand-mark-spinner::before {
|
||||
content: "+";
|
||||
animation: ascii-spin-rotate 0.8s steps(4) infinite;
|
||||
}
|
||||
@keyframes ascii-spin-rotate {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
}
|
||||
|
||||
/* Blinking cursor appended to brand text. */
|
||||
.theme-key-ascii .login-brand h1::after,
|
||||
.theme-key-ascii .admin-sidebar-brand strong::after,
|
||||
.theme-key-ascii .brand-row strong::after {
|
||||
content: "_";
|
||||
display: inline-block;
|
||||
margin-left: 0.2ch;
|
||||
color: #ffffff;
|
||||
animation: ascii-blink 1s steps(1) infinite;
|
||||
}
|
||||
|
||||
@keyframes ascii-blink {
|
||||
0%, 49% { opacity: 1; }
|
||||
50%, 100% { opacity: 0; }
|
||||
}
|
||||
|
||||
/* Section heading prompt prefix. */
|
||||
.theme-key-ascii .admin-card-head h2::before,
|
||||
.theme-key-ascii .admin-card-head h3::before,
|
||||
.theme-key-ascii .card > h2:first-child::before,
|
||||
.theme-key-ascii .card > h3:first-child::before {
|
||||
content: "> ";
|
||||
color: #ffffff;
|
||||
opacity: 0.85;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* ---------- Tables ---------- */
|
||||
|
||||
.theme-key-ascii .admin-table thead th {
|
||||
background: #000000;
|
||||
color: #ffffff;
|
||||
border-bottom: 1px solid #ffffff;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.theme-key-ascii .admin-table tbody tr {
|
||||
border-bottom: 1px dashed #ffffff;
|
||||
}
|
||||
|
||||
.theme-key-ascii .admin-table tbody tr:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
/* ---------- Badges / chips ---------- */
|
||||
|
||||
.theme-key-ascii .admin-badge,
|
||||
.theme-key-ascii .admin-cn-badge {
|
||||
border: 1px solid #ffffff;
|
||||
border-radius: 0;
|
||||
background: #000000;
|
||||
color: #ffffff;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.theme-key-ascii .admin-badge::before,
|
||||
.theme-key-ascii .admin-cn-badge::before {
|
||||
content: "[";
|
||||
}
|
||||
|
||||
.theme-key-ascii .admin-badge::after,
|
||||
.theme-key-ascii .admin-cn-badge::after {
|
||||
content: "]";
|
||||
}
|
||||
|
||||
/* ---------- Links ---------- */
|
||||
|
||||
.theme-key-ascii a:not(.btn):not(.bottom-nav button):not([class*="-trigger"]),
|
||||
.theme-key-ascii .admin-screen-wrap a:not(.admin-btn):not(.admin-nav-item) {
|
||||
color: #ffffff;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.theme-key-ascii a:not(.btn):not(.bottom-nav button):not([class*="-trigger"]):hover,
|
||||
.theme-key-ascii .admin-screen-wrap a:not(.admin-btn):not(.admin-nav-item):hover {
|
||||
background: #ffffff;
|
||||
color: #000000;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* ---------- Selection ---------- */
|
||||
|
||||
.theme-key-ascii ::selection {
|
||||
background: #ffffff;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
/* ---------- Scrollbars ---------- */
|
||||
|
||||
.theme-key-ascii ::-webkit-scrollbar {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.theme-key-ascii ::-webkit-scrollbar-track {
|
||||
background-color: #000000;
|
||||
background-image: repeating-linear-gradient(
|
||||
0deg,
|
||||
#ffffff 0,
|
||||
#ffffff 1px,
|
||||
transparent 1px,
|
||||
transparent 4px
|
||||
);
|
||||
}
|
||||
|
||||
.theme-key-ascii ::-webkit-scrollbar-thumb {
|
||||
background: #ffffff;
|
||||
border: 1px solid #000000;
|
||||
}
|
||||
|
||||
.theme-key-ascii ::-webkit-scrollbar-thumb:active {
|
||||
background: #b0b0b0;
|
||||
}
|
||||
|
||||
.theme-key-ascii ::-webkit-scrollbar-corner {
|
||||
background: #000000;
|
||||
}
|
||||
|
||||
/* ---------- Lucide icons: render as crisp white outlines ---------- */
|
||||
|
||||
.theme-key-ascii svg.lucide,
|
||||
.theme-key-ascii svg[class*="lucide-"] {
|
||||
color: #ffffff !important;
|
||||
stroke: #ffffff !important;
|
||||
fill: none !important;
|
||||
stroke-width: 1.75;
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.theme-key-ascii .btn-primary svg.lucide,
|
||||
.theme-key-ascii .bottom-nav button.active svg.lucide,
|
||||
.theme-key-ascii .period-card.active svg.lucide,
|
||||
.theme-key-ascii .method-card.active svg.lucide,
|
||||
.theme-key-ascii .option-row.active svg.lucide,
|
||||
.theme-key-ascii .admin-btn-primary svg.lucide,
|
||||
.theme-key-ascii .admin-nav-item.active svg.lucide,
|
||||
.theme-key-ascii .admin-tabs-trigger[data-state="active"] svg.lucide,
|
||||
.theme-key-ascii .admin-revenue-period-btn.is-active svg.lucide {
|
||||
color: #000000 !important;
|
||||
stroke: #000000 !important;
|
||||
}
|
||||
|
||||
/* ---------- Toast / language select polish ---------- */
|
||||
|
||||
.theme-key-ascii .toast {
|
||||
background: #000000;
|
||||
border: 1px solid #ffffff;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.theme-key-ascii .language-select-item {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.theme-key-ascii .language-select-item[data-highlighted],
|
||||
.theme-key-ascii .language-select-item[data-selected] {
|
||||
background: #ffffff;
|
||||
color: #000000 !important;
|
||||
}
|
||||
|
||||
/* ---------- Headings: stronger console feel ---------- */
|
||||
|
||||
.theme-key-ascii h1,
|
||||
.theme-key-ascii h2,
|
||||
.theme-key-ascii h3,
|
||||
.theme-key-ascii h4 {
|
||||
font-family: var(--font-mono);
|
||||
letter-spacing: 0;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.theme-key-ascii .admin-header {
|
||||
background: #000000;
|
||||
border-bottom: 1px solid #ffffff;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.theme-key-ascii .admin-header-title h2,
|
||||
.theme-key-ascii .admin-header-title small {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* Make any element with role progressbar but no inner span show animated stripes. */
|
||||
.theme-key-ascii [role="progressbar"]:not(.progress) {
|
||||
background:
|
||||
repeating-linear-gradient(
|
||||
90deg,
|
||||
#ffffff 0,
|
||||
#ffffff 6px,
|
||||
#000000 6px,
|
||||
#000000 8px
|
||||
);
|
||||
animation: ascii-scan 0.6s linear infinite;
|
||||
border: 1px solid #ffffff;
|
||||
border-radius: 0;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* Console-themed extras
|
||||
* ============================================================ */
|
||||
|
||||
/* ---------- ASCII skeletons ---------- *
|
||||
* Subtle dark shimmer with a single bright scan line moving across.
|
||||
*/
|
||||
|
||||
@keyframes ascii-skeleton-scan {
|
||||
0% { background-position: -120% 0; }
|
||||
100% { background-position: 220% 0; }
|
||||
}
|
||||
|
||||
.theme-key-ascii .ui-skeleton,
|
||||
.theme-key-ascii .admin-skeleton,
|
||||
.theme-key-ascii .skeleton-line,
|
||||
.theme-key-ascii .skeleton-dot,
|
||||
.theme-key-ascii .skeleton-pay-button,
|
||||
.theme-key-ascii .ui-skeleton-line,
|
||||
.theme-key-ascii .admin-skeleton-line,
|
||||
.theme-key-ascii .admin-skeleton-line-strong,
|
||||
.theme-key-ascii .admin-skeleton-line-soft,
|
||||
.theme-key-ascii .admin-skeleton-line-short,
|
||||
.theme-key-ascii .admin-skeleton-line-tiny,
|
||||
.theme-key-ascii .ui-skeleton-title,
|
||||
.theme-key-ascii .ui-skeleton-short,
|
||||
.theme-key-ascii .ui-skeleton-tiny,
|
||||
.theme-key-ascii .ui-skeleton-badge,
|
||||
.theme-key-ascii .admin-skeleton-badge,
|
||||
.theme-key-ascii .admin-skeleton-avatar,
|
||||
.theme-key-ascii .admin-stat-skeleton-card,
|
||||
.theme-key-ascii .admin-stat-skeleton-wide,
|
||||
.theme-key-ascii .admin-cn-card-skeleton--tall {
|
||||
border-radius: 0 !important;
|
||||
border: 1px solid #ffffff !important;
|
||||
background-color: #050505 !important;
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
transparent 40%,
|
||||
rgba(255, 255, 255, 0.18) 50%,
|
||||
transparent 60%,
|
||||
transparent 100%
|
||||
) !important;
|
||||
background-size: 200% 100% !important;
|
||||
background-repeat: no-repeat !important;
|
||||
color: #ffffff !important;
|
||||
animation: ascii-skeleton-scan 1.6s linear infinite !important;
|
||||
}
|
||||
|
||||
.theme-key-ascii .admin-skeleton-avatar {
|
||||
width: 32px !important;
|
||||
height: 32px !important;
|
||||
}
|
||||
|
||||
/* ---------- Empty / loading state console message ---------- */
|
||||
.theme-key-ascii .admin-empty {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.theme-key-ascii .admin-empty::before {
|
||||
content: "$ tail -f /var/log/empty.log";
|
||||
display: block;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--muted);
|
||||
margin-bottom: 8px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
/* ---------- Buttons: glitch on hover ---------- */
|
||||
|
||||
@keyframes ascii-glitch {
|
||||
0%, 100% { transform: translate(0, 0); clip-path: inset(0 0 0 0); }
|
||||
20% { transform: translate(-1px, 0); clip-path: inset(20% 0 50% 0); }
|
||||
40% { transform: translate(1px, 0); clip-path: inset(40% 0 30% 0); }
|
||||
60% { transform: translate(-1px, 0); clip-path: inset(10% 0 70% 0); }
|
||||
80% { transform: translate(1px, 0); clip-path: inset(60% 0 10% 0); }
|
||||
}
|
||||
|
||||
.theme-key-ascii .btn:hover:not(:disabled)::after,
|
||||
.theme-key-ascii .admin-btn:hover:not(:disabled)::after {
|
||||
content: attr(data-label, "");
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Disable glitch text duplication if the button has no data-label.
|
||||
* Apply a subtle scanline overlay instead, which is content-agnostic. */
|
||||
.theme-key-ascii .btn,
|
||||
.theme-key-ascii .admin-btn,
|
||||
.theme-key-ascii .admin-nav-item,
|
||||
.theme-key-ascii .bottom-nav button {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.theme-key-ascii .btn:hover:not(:disabled)::before,
|
||||
.theme-key-ascii .admin-btn:hover:not(:disabled)::before,
|
||||
.theme-key-ascii .admin-nav-item:hover::before,
|
||||
.theme-key-ascii .bottom-nav button:hover::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background: repeating-linear-gradient(
|
||||
0deg,
|
||||
rgba(0, 0, 0, 0.4) 0,
|
||||
rgba(0, 0, 0, 0.4) 1px,
|
||||
transparent 1px,
|
||||
transparent 3px
|
||||
);
|
||||
animation: ascii-glitch 0.6s steps(1) infinite;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* ---------- Bottom nav active markers "> item <" ---------- */
|
||||
|
||||
.theme-key-ascii .bottom-nav button.active::before,
|
||||
.theme-key-ascii .admin-nav-item.active::before {
|
||||
content: ">";
|
||||
position: absolute;
|
||||
left: 6px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-family: var(--font-mono);
|
||||
color: #000000;
|
||||
animation: ascii-blink 1s steps(1) infinite;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.theme-key-ascii .bottom-nav button.active::after,
|
||||
.theme-key-ascii .admin-nav-item.active::after {
|
||||
content: "<";
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-family: var(--font-mono);
|
||||
color: #000000;
|
||||
animation: ascii-blink 1s steps(1) infinite;
|
||||
animation-delay: 0.5s;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.theme-key-ascii .bottom-nav button.active,
|
||||
.theme-key-ascii .admin-nav-item.active {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* On the mobile bottom-bar (compact) hide the markers to avoid overlap. */
|
||||
@media (max-width: 1023px) {
|
||||
.theme-key-ascii .bottom-nav button.active::before,
|
||||
.theme-key-ascii .bottom-nav button.active::after {
|
||||
content: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- ASCII block progress fill ---------- *
|
||||
* The actual fill renders as alternating █ blocks via the existing span
|
||||
* gradient. We also overlay a slow scanning highlight to make it feel
|
||||
* "live", and add a soft typed counter to the right edge.
|
||||
*/
|
||||
|
||||
/* (duplicate progress fill rules removed — see definition above) */
|
||||
|
||||
/* ---------- Headings: subtle CRT glitch on hover ---------- */
|
||||
|
||||
@keyframes ascii-heading-jitter {
|
||||
0%, 92%, 100% { transform: translate(0, 0); }
|
||||
93% { transform: translate(-1px, 0); }
|
||||
94% { transform: translate(1px, 0); }
|
||||
95% { transform: translate(0, -1px); }
|
||||
96% { transform: translate(0, 1px); }
|
||||
}
|
||||
|
||||
.theme-key-ascii h1,
|
||||
.theme-key-ascii h2,
|
||||
.theme-key-ascii h3,
|
||||
.theme-key-ascii .login-brand h1,
|
||||
.theme-key-ascii .admin-sidebar-brand strong,
|
||||
.theme-key-ascii .admin-card-head h2,
|
||||
.theme-key-ascii .admin-card-head h3 {
|
||||
display: inline-block;
|
||||
animation: ascii-heading-jitter 7s steps(1) infinite;
|
||||
}
|
||||
|
||||
/* ---------- App-shell boot banner ---------- *
|
||||
* A non-blocking strip at the very top of the viewport that displays a
|
||||
* typed "booting…" line, then settles. Pure CSS so it cannot interfere
|
||||
* with any DOM. The animation runs once on mount.
|
||||
*/
|
||||
|
||||
@keyframes ascii-boot-type {
|
||||
0% { width: 0; }
|
||||
85% { width: 28ch; }
|
||||
100% { width: 28ch; }
|
||||
}
|
||||
|
||||
@keyframes ascii-boot-fade {
|
||||
0%, 70% { opacity: 1; }
|
||||
100% { opacity: 0; visibility: hidden; }
|
||||
}
|
||||
|
||||
.theme-key-ascii.app-shell::after {
|
||||
content: "$ remnawave --start --tty=0";
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 9999;
|
||||
display: block;
|
||||
padding: 2px 8px;
|
||||
max-width: 28ch;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
background: #000000;
|
||||
color: #ffffff;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
border-right: 1px solid #ffffff;
|
||||
border-bottom: 1px solid #ffffff;
|
||||
pointer-events: none;
|
||||
animation:
|
||||
ascii-boot-type 1.6s steps(28) 1 both,
|
||||
ascii-boot-fade 3s linear 1.6s 1 forwards;
|
||||
}
|
||||
|
||||
/* ---------- Toggle / checkbox squareification (best-effort) ---------- */
|
||||
|
||||
.theme-key-ascii input[type="checkbox"],
|
||||
.theme-key-ascii input[type="radio"] {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
border: 1px solid #ffffff;
|
||||
background: #000000;
|
||||
border-radius: 0 !important;
|
||||
position: relative;
|
||||
vertical-align: middle;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.theme-key-ascii input[type="checkbox"]:checked::after,
|
||||
.theme-key-ascii input[type="radio"]:checked::after {
|
||||
content: "x";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* ---------- Code-like "$ " prefix on toast messages ---------- */
|
||||
|
||||
.theme-key-ascii .toast::before {
|
||||
content: "$ ";
|
||||
color: #ffffff;
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* ---------- Disabled state — strikethrough hatching ---------- */
|
||||
|
||||
.theme-key-ascii .btn:disabled,
|
||||
.theme-key-ascii .admin-btn:disabled,
|
||||
.theme-key-ascii button:disabled {
|
||||
background-image: repeating-linear-gradient(
|
||||
-45deg,
|
||||
transparent 0,
|
||||
transparent 4px,
|
||||
rgba(255, 255, 255, 0.18) 4px,
|
||||
rgba(255, 255, 255, 0.18) 5px
|
||||
);
|
||||
color: var(--dim) !important;
|
||||
border-color: var(--dim) !important;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* Square everything: drop all rounded corners on touched surfaces.
|
||||
* ============================================================ */
|
||||
|
||||
.theme-key-ascii :is(
|
||||
.card, .dialog-card, .toast,
|
||||
.btn, .input,
|
||||
.period-card, .method-card, .settings-row, .option-row,
|
||||
.tariff-selected-card, .tariff-action-card, .tariff-warning-card,
|
||||
.topup-carryover-note, .language-select-content, .language-select-item,
|
||||
.language-select-trigger, .bottom-nav, .bottom-nav button,
|
||||
.field-error-tooltip,
|
||||
.admin-card, .admin-card-head, .admin-card-body,
|
||||
.admin-stat-card, .admin-stat-skeleton-card, .admin-stat-skeleton-wide,
|
||||
.admin-revenue-panel, .admin-empty,
|
||||
.admin-tariff-card, .admin-toolbar-card, .admin-table-card,
|
||||
.admin-panel-dash-card,
|
||||
.admin-select-trigger, .admin-select-content, .admin-select-item,
|
||||
.admin-cn-card, .admin-cn-badge, .admin-badge,
|
||||
.admin-cn-card-skeleton--tall,
|
||||
.admin-input, .admin-textarea, .admin-btn, .admin-chip,
|
||||
.admin-tabs-trigger, .admin-tabs-list,
|
||||
.admin-nav-item, .admin-revenue-period-btn, .admin-mobile-toggle,
|
||||
.admin-header, .admin-sidebar, .admin-sidebar-brand,
|
||||
.admin-dialog,
|
||||
.admin-theme-editor-section,
|
||||
[data-slot="card"], [data-slot="card-header"],
|
||||
[data-slot="card-content"], [data-slot="card-footer"]
|
||||
),
|
||||
.theme-key-ascii :is(
|
||||
.card, .dialog-card, .toast,
|
||||
.btn, .input,
|
||||
.admin-card, .admin-card-head, .admin-card-body,
|
||||
.admin-stat-card, .admin-revenue-panel, .admin-empty,
|
||||
.admin-tariff-card, .admin-toolbar-card, .admin-table-card,
|
||||
.admin-panel-dash-card,
|
||||
.admin-select-trigger, .admin-select-content,
|
||||
.admin-cn-card,
|
||||
.admin-input, .admin-textarea, .admin-btn,
|
||||
.admin-nav-item, .admin-tabs-trigger
|
||||
) * {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
.theme-key-ascii img,
|
||||
.theme-key-ascii .admin-avatar,
|
||||
.theme-key-ascii .admin-skeleton-avatar {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* Console-style tables: cell borders, header underline,
|
||||
* row separator using dashed line.
|
||||
* ============================================================ */
|
||||
|
||||
.theme-key-ascii .admin-table,
|
||||
.theme-key-ascii table {
|
||||
border-collapse: collapse;
|
||||
border: 1px solid #ffffff;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.theme-key-ascii .admin-table th,
|
||||
.theme-key-ascii .admin-table td,
|
||||
.theme-key-ascii table th,
|
||||
.theme-key-ascii table td {
|
||||
border: 1px solid #ffffff;
|
||||
border-radius: 0 !important;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.theme-key-ascii .admin-table thead th,
|
||||
.theme-key-ascii table thead th {
|
||||
background: #000000;
|
||||
border-bottom: 2px solid #ffffff;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.theme-key-ascii .admin-table tbody tr,
|
||||
.theme-key-ascii table tbody tr {
|
||||
border-bottom: 1px solid #ffffff;
|
||||
}
|
||||
|
||||
.theme-key-ascii .admin-table tbody tr:hover,
|
||||
.theme-key-ascii table tbody tr:hover {
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
}
|
||||
|
||||
.theme-key-ascii .admin-table tbody tr:hover td,
|
||||
.theme-key-ascii table tbody tr:hover td {
|
||||
color: #ffffff;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"key": "ascii",
|
||||
"names": {
|
||||
"ru": "ASCII",
|
||||
"en": "ASCII"
|
||||
},
|
||||
"enabled": true,
|
||||
"default": false,
|
||||
"use_primary_accent": false,
|
||||
"use_in_admin": true,
|
||||
"css_file": "style.css",
|
||||
"assets_version": 1,
|
||||
"tokens": {
|
||||
"color_scheme": "dark",
|
||||
"style_preset": "ascii"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"key": "dark",
|
||||
"names": {
|
||||
"ru": "Темная",
|
||||
"en": "Dark"
|
||||
},
|
||||
"enabled": true,
|
||||
"default": true,
|
||||
"use_primary_accent": true,
|
||||
"use_in_admin": true,
|
||||
"assets_version": 1,
|
||||
"tokens": {
|
||||
"color_scheme": "dark",
|
||||
"bg": "#03070b",
|
||||
"panel": "#111820",
|
||||
"panel_2": "#0b1118",
|
||||
"panel_3": "#17212b",
|
||||
"border": "rgba(255, 255, 255, 0.12)",
|
||||
"border_strong": "rgba(255, 255, 255, 0.2)",
|
||||
"text": "#f2f7f4",
|
||||
"muted": "#a9b4b0",
|
||||
"dim": "#68736f",
|
||||
"danger": "#ff6b6b",
|
||||
"blue": "#2d9cff",
|
||||
"radius": "8px"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
.theme-key-light {
|
||||
color-scheme: light;
|
||||
--accent: #047857;
|
||||
--bg: #f7f8fb;
|
||||
--panel: #ffffff;
|
||||
--panel-2: #f1f5f9;
|
||||
--panel-3: #e8edf3;
|
||||
--border: rgba(15, 23, 42, 0.11);
|
||||
--border-strong: rgba(15, 23, 42, 0.2);
|
||||
--text: #0f172a;
|
||||
--muted: #475569;
|
||||
--dim: #64748b;
|
||||
--danger: #dc2626;
|
||||
--danger-text: #b91c1c;
|
||||
--danger-soft: color-mix(in srgb, var(--danger) 9%, var(--panel));
|
||||
--danger-border: color-mix(in srgb, var(--danger) 34%, var(--border));
|
||||
--success: #16a34a;
|
||||
--success-text: #166534;
|
||||
--success-soft: color-mix(in srgb, var(--success) 10%, var(--panel));
|
||||
--success-border: color-mix(in srgb, var(--success) 34%, var(--border));
|
||||
--warning: #d97706;
|
||||
--warning-text: #92400e;
|
||||
--warning-soft: color-mix(in srgb, var(--warning) 11%, var(--panel));
|
||||
--warning-border: color-mix(in srgb, var(--warning) 34%, var(--border));
|
||||
--info: #2563eb;
|
||||
--info-text: #1d4ed8;
|
||||
--info-soft: color-mix(in srgb, var(--info) 9%, var(--panel));
|
||||
--info-border: color-mix(in srgb, var(--info) 30%, var(--border));
|
||||
--blue: #2563eb;
|
||||
--radius: 8px;
|
||||
--accent-contrast: #ffffff;
|
||||
--surface-sheen: rgba(15, 23, 42, 0.035);
|
||||
--surface-sheen-soft: rgba(15, 23, 42, 0.012);
|
||||
--surface-hover: rgba(15, 23, 42, 0.045);
|
||||
--surface-muted: rgba(15, 23, 42, 0.035);
|
||||
--surface-subtle-border: rgba(15, 23, 42, 0.1);
|
||||
--overlay-scrim: rgba(15, 23, 42, 0.34);
|
||||
--nav-bg: rgba(255, 255, 255, 0.88);
|
||||
--rail-bg: rgba(255, 255, 255, 0.72);
|
||||
--shadow-soft: 0 6px 18px rgba(15, 23, 42, 0.06);
|
||||
--shadow-strong: 0 18px 44px rgba(15, 23, 42, 0.12);
|
||||
--shadow-popover: 0 14px 28px rgba(15, 23, 42, 0.12);
|
||||
--inset-highlight: rgba(255, 255, 255, 0.75);
|
||||
--admin-bg: #f7f8fb;
|
||||
--admin-surface: #ffffff;
|
||||
--admin-surface-2: #f1f5f9;
|
||||
--admin-elev: #e8edf3;
|
||||
--admin-border: rgba(15, 23, 42, 0.1);
|
||||
--admin-border-strong: rgba(15, 23, 42, 0.18);
|
||||
--admin-text: #0f172a;
|
||||
--admin-muted: #64748b;
|
||||
--admin-dim: #64748b;
|
||||
--admin-chart-stroke: #065f46;
|
||||
--admin-chart-fill: rgba(6, 95, 70, 0.22);
|
||||
}
|
||||
|
||||
.theme-key-light .ui-spinner,
|
||||
.theme-key-light .brand-mark-spinner {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.theme-key-light .telegram-button-spinner {
|
||||
border-color: rgba(255, 255, 255, 0.35);
|
||||
border-top-color: #ffffff;
|
||||
}
|
||||
|
||||
.theme-key-light .btn-primary,
|
||||
.theme-key-light .admin-btn.admin-btn-primary,
|
||||
.theme-key-light .admin-extend-control .admin-btn.admin-btn-primary {
|
||||
background: color-mix(in srgb, var(--accent) 50%, #000000);
|
||||
border-color: color-mix(in srgb, var(--accent) 42%, #000000);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.theme-key-light .btn-primary:hover:not(:disabled),
|
||||
.theme-key-light .admin-btn.admin-btn-primary:hover:not(:disabled),
|
||||
.theme-key-light .admin-extend-control .admin-btn.admin-btn-primary:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--accent) 52%, #000000);
|
||||
}
|
||||
|
||||
.theme-key-light.app-shell {
|
||||
background: var(--bg) !important;
|
||||
}
|
||||
|
||||
.theme-key-light .phone-screen {
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
/* Flatten Settings rows: no gradient sheen, no inset highlight that reads as a 3D bevel */
|
||||
.theme-key-light .settings-row {
|
||||
background: var(--panel);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.theme-key-light .settings-row-linked {
|
||||
background: var(--success-soft);
|
||||
}
|
||||
|
||||
/* Avatar/profile card: bigger lift, but rows below have an opaque background and
|
||||
stack above, so the shadow stays visually under them instead of bleeding through. */
|
||||
.theme-key-light .settings-profile {
|
||||
box-shadow:
|
||||
0 10px 24px rgba(15, 23, 42, 0.10),
|
||||
inset 0 1px 0 var(--inset-highlight);
|
||||
}
|
||||
|
||||
.theme-key-light .settings-links-block {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Slightly stronger axis/grid contrast for the revenue chart on a light surface */
|
||||
.theme-key-light .admin-revenue-svg-frame {
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
/* Bonus section: drop accent color from body strongs; only the bonus-system heading
|
||||
and explicitly-accent card headings stay tinted — and they use the same darkened
|
||||
accent technique as .btn-primary on light, so they remain readable on white. */
|
||||
.theme-key-light .bonus-card strong {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.theme-key-light .bonus-card-head strong,
|
||||
.theme-key-light .card-heading-accent {
|
||||
color: color-mix(in srgb, var(--accent) 50%, #000000);
|
||||
}
|
||||
|
||||
.theme-key-light .bonus-card-head > svg {
|
||||
color: color-mix(in srgb, var(--accent) 50%, #000000);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"key": "light",
|
||||
"names": {
|
||||
"ru": "Светлая",
|
||||
"en": "Light"
|
||||
},
|
||||
"enabled": true,
|
||||
"default": false,
|
||||
"use_primary_accent": true,
|
||||
"use_in_admin": true,
|
||||
"css_file": "style.css",
|
||||
"assets_version": 2,
|
||||
"tokens": {
|
||||
"color_scheme": "light"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 340 B |
|
After Width: | Height: | Size: 375 B |
|
After Width: | Height: | Size: 424 B |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 356 B |
|
After Width: | Height: | Size: 419 B |
|
After Width: | Height: | Size: 372 B |
|
After Width: | Height: | Size: 388 B |
|
After Width: | Height: | Size: 378 B |
|
After Width: | Height: | Size: 424 B |
|
After Width: | Height: | Size: 636 B |
|
After Width: | Height: | Size: 364 B |
|
After Width: | Height: | Size: 390 B |
|
After Width: | Height: | Size: 385 B |
|
After Width: | Height: | Size: 415 B |
|
After Width: | Height: | Size: 356 B |
|
After Width: | Height: | Size: 393 B |
|
After Width: | Height: | Size: 474 B |
|
After Width: | Height: | Size: 395 B |
|
After Width: | Height: | Size: 461 B |
|
After Width: | Height: | Size: 327 B |
|
After Width: | Height: | Size: 411 B |
|
After Width: | Height: | Size: 395 B |
|
After Width: | Height: | Size: 415 B |
|
After Width: | Height: | Size: 393 B |
|
After Width: | Height: | Size: 422 B |
|
After Width: | Height: | Size: 478 B |
|
After Width: | Height: | Size: 500 B |
|
After Width: | Height: | Size: 589 B |
|
After Width: | Height: | Size: 392 B |
|
After Width: | Height: | Size: 392 B |
|
After Width: | Height: | Size: 384 B |
|
After Width: | Height: | Size: 419 B |
|
After Width: | Height: | Size: 403 B |
|
After Width: | Height: | Size: 371 B |
|
After Width: | Height: | Size: 344 B |
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"key": "windows95",
|
||||
"names": {
|
||||
"ru": "Windows 95",
|
||||
"en": "Windows 95"
|
||||
},
|
||||
"enabled": true,
|
||||
"default": false,
|
||||
"use_primary_accent": false,
|
||||
"use_in_admin": true,
|
||||
"css_file": "style.css",
|
||||
"assets_version": 6,
|
||||
"tokens": {
|
||||
"color_scheme": "light",
|
||||
"style_preset": "win95"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import asyncio
|
||||
import hmac
|
||||
import logging
|
||||
|
||||
from aiogram import Bot, Dispatcher
|
||||
from aiogram.webhook.aiohttp_server import SimpleRequestHandler, setup_application
|
||||
from aiohttp import web
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from config.settings import Settings
|
||||
|
||||
|
||||
class SecureSimpleRequestHandler(SimpleRequestHandler):
|
||||
def verify_secret(self, telegram_secret_token: str, bot: Bot) -> bool:
|
||||
if not self.secret_token:
|
||||
return False
|
||||
return hmac.compare_digest(telegram_secret_token, self.secret_token)
|
||||
|
||||
|
||||
def _inject_shared_instances(
|
||||
app: web.Application,
|
||||
dp: Dispatcher,
|
||||
bot: Bot,
|
||||
settings: Settings,
|
||||
async_session_factory: sessionmaker,
|
||||
) -> None:
|
||||
app["bot"] = bot
|
||||
app["dp"] = dp
|
||||
app["settings"] = settings
|
||||
app["async_session_factory"] = async_session_factory
|
||||
app["i18n"] = dp.get("i18n_instance")
|
||||
for key in (
|
||||
"yookassa_service",
|
||||
"lknpd_service",
|
||||
"subscription_service",
|
||||
"referral_service",
|
||||
"panel_service",
|
||||
"stars_service",
|
||||
"freekassa_service",
|
||||
"cryptopay_service",
|
||||
"panel_webhook_service",
|
||||
"platega_service",
|
||||
"severpay_service",
|
||||
):
|
||||
if hasattr(dp, "workflow_data") and key in dp.workflow_data: # type: ignore
|
||||
app[key] = dp.workflow_data[key] # type: ignore
|
||||
|
||||
|
||||
async def build_and_start_web_app(
|
||||
dp: Dispatcher,
|
||||
bot: Bot,
|
||||
settings: Settings,
|
||||
async_session_factory: sessionmaker,
|
||||
):
|
||||
app = web.Application()
|
||||
_inject_shared_instances(app, dp, bot, settings, async_session_factory)
|
||||
|
||||
async def _healthcheck(request: web.Request) -> web.Response:
|
||||
payload = {"status": "ok"}
|
||||
try:
|
||||
from db.database_setup import async_engine
|
||||
|
||||
pool = async_engine.pool if async_engine is not None else None
|
||||
if pool is not None:
|
||||
payload["db_pool"] = {
|
||||
"checked_in": pool.checkedin(),
|
||||
"checked_out": pool.checkedout(),
|
||||
"size": pool.size(),
|
||||
"overflow": pool.overflow(),
|
||||
}
|
||||
except Exception:
|
||||
logging.exception("Failed to collect DB pool health metrics")
|
||||
return web.json_response(payload)
|
||||
|
||||
app.router.add_get("/healthz", _healthcheck)
|
||||
app.router.add_get("/health", _healthcheck)
|
||||
|
||||
setup_application(app, dp, bot=bot)
|
||||
|
||||
telegram_uses_webhook_mode = bool(settings.WEBHOOK_BASE_URL)
|
||||
|
||||
if telegram_uses_webhook_mode:
|
||||
telegram_webhook_path = settings.telegram_webhook_path
|
||||
SecureSimpleRequestHandler(
|
||||
dispatcher=dp,
|
||||
bot=bot,
|
||||
secret_token=settings.WEBHOOK_SECRET_TOKEN,
|
||||
).register(app, path=telegram_webhook_path)
|
||||
logging.info(
|
||||
f"Telegram webhook route configured at: [POST] {telegram_webhook_path} (relative to base URL)" # noqa: E501
|
||||
)
|
||||
|
||||
from bot.handlers.user.payment import yookassa_webhook_route
|
||||
from bot.services.crypto_pay_service import cryptopay_webhook_route
|
||||
from bot.services.freekassa_service import freekassa_webhook_route
|
||||
from bot.services.panel_webhook_service import panel_webhook_route
|
||||
from bot.services.platega_service import platega_webhook_route
|
||||
from bot.services.severpay_service import severpay_webhook_route
|
||||
|
||||
cp_path = settings.cryptopay_webhook_path
|
||||
if cp_path.startswith("/"):
|
||||
app.router.add_post(cp_path, cryptopay_webhook_route)
|
||||
logging.info(f"CryptoPay webhook route configured at: [POST] {cp_path}")
|
||||
|
||||
fk_path = settings.freekassa_webhook_path
|
||||
if fk_path.startswith("/"):
|
||||
app.router.add_post(fk_path, freekassa_webhook_route)
|
||||
logging.info(f"FreeKassa webhook route configured at: [POST] {fk_path}")
|
||||
|
||||
pg_path = settings.platega_webhook_path
|
||||
if pg_path.startswith("/"):
|
||||
app.router.add_post(pg_path, platega_webhook_route)
|
||||
logging.info(f"Platega webhook route configured at: [POST] {pg_path}")
|
||||
|
||||
sp_path = settings.severpay_webhook_path
|
||||
if sp_path.startswith("/"):
|
||||
app.router.add_post(sp_path, severpay_webhook_route)
|
||||
logging.info(f"SeverPay webhook route configured at: [POST] {sp_path}")
|
||||
|
||||
# YooKassa webhook (register only when base URL present and path configured)
|
||||
yk_path = settings.yookassa_webhook_path
|
||||
if settings.WEBHOOK_BASE_URL and yk_path and yk_path.startswith("/"):
|
||||
app.router.add_post(yk_path, yookassa_webhook_route)
|
||||
logging.info(f"YooKassa webhook route configured at: [POST] {yk_path}")
|
||||
|
||||
panel_path = settings.panel_webhook_path
|
||||
if panel_path.startswith("/"):
|
||||
app.router.add_post(panel_path, panel_webhook_route)
|
||||
logging.info(f"Panel webhook route configured at: [POST] {panel_path}")
|
||||
|
||||
runners = []
|
||||
|
||||
webhooks_runner = web.AppRunner(app)
|
||||
await webhooks_runner.setup()
|
||||
runners.append(webhooks_runner)
|
||||
site = web.TCPSite(
|
||||
webhooks_runner,
|
||||
host=settings.WEB_SERVER_HOST,
|
||||
port=settings.WEB_SERVER_PORT,
|
||||
)
|
||||
|
||||
await site.start()
|
||||
logging.info(
|
||||
f"AIOHTTP server started on http://{settings.WEB_SERVER_HOST}:{settings.WEB_SERVER_PORT}"
|
||||
)
|
||||
|
||||
if settings.WEBAPP_ENABLED:
|
||||
from bot.app.web.subscription_webapp import create_subscription_webapp_application
|
||||
|
||||
subscription_app = create_subscription_webapp_application(
|
||||
dp,
|
||||
bot,
|
||||
settings,
|
||||
async_session_factory,
|
||||
)
|
||||
subscription_runner = web.AppRunner(subscription_app)
|
||||
await subscription_runner.setup()
|
||||
runners.append(subscription_runner)
|
||||
subscription_site = web.TCPSite(
|
||||
subscription_runner,
|
||||
host=settings.WEBAPP_SERVER_HOST,
|
||||
port=settings.WEBAPP_SERVER_PORT,
|
||||
)
|
||||
await subscription_site.start()
|
||||
logging.info(
|
||||
"Subscription WebApp server started on http://%s:%s",
|
||||
settings.WEBAPP_SERVER_HOST,
|
||||
settings.WEBAPP_SERVER_PORT,
|
||||
)
|
||||
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
finally:
|
||||
for runner in reversed(runners):
|
||||
try:
|
||||
await runner.cleanup()
|
||||
except Exception as cleanup_error:
|
||||
logging.warning("Failed to cleanup aiohttp runner: %s", cleanup_error)
|
||||
@@ -0,0 +1 @@
|
||||
"""Domain modules for the subscription Mini App backend."""
|
||||
@@ -0,0 +1,118 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import html
|
||||
import hmac
|
||||
import io
|
||||
import ipaddress
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import socket
|
||||
import subprocess
|
||||
import time
|
||||
from collections import deque
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from urllib.parse import parse_qsl, quote, urlencode, urlsplit, urlunsplit
|
||||
|
||||
from aiogram import Bot, Dispatcher
|
||||
from aiogram.types import LabeledPrice
|
||||
from aiohttp import ClientSession, ClientTimeout, web
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr, ValidationError, constr, field_validator
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from bot.app.web.admin_api import (
|
||||
admin_auth_middleware,
|
||||
setup_admin_routes,
|
||||
)
|
||||
from bot.app.web.webapp_auth import (
|
||||
create_signed_telegram_oauth_state,
|
||||
create_telegram_oauth_nonce,
|
||||
create_webapp_session_token,
|
||||
validate_telegram_login_widget_data,
|
||||
validate_telegram_oauth_id_token,
|
||||
validate_telegram_webapp_init_data,
|
||||
verify_signed_telegram_oauth_state,
|
||||
verify_telegram_oauth_nonce,
|
||||
verify_webapp_session_token,
|
||||
)
|
||||
from bot.infra.redis import cache_get_json, cache_set_json, get_redis, redis_key
|
||||
from bot.services.crypto_pay_service import CryptoPayService
|
||||
from bot.services.email_auth_service import EmailAuthService, normalize_email
|
||||
from bot.services.email_templates import render_account_merged
|
||||
from bot.services.freekassa_service import FreeKassaService
|
||||
from bot.services.platega_service import PlategaService
|
||||
from bot.services.promo_code_service import PromoCodeService
|
||||
from bot.services.referral_service import ReferralService
|
||||
from bot.services.severpay_service import SeverPayService
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.services.yookassa_service import YooKassaService
|
||||
from bot.utils.config_link import prepare_config_links
|
||||
from bot.utils.request_security import parse_ip_entries, request_client_ip
|
||||
from bot.utils.text_sanitizer import sanitize_display_name, sanitize_username
|
||||
from config.settings import Settings
|
||||
from db.dal import payment_dal, subscription_dal, user_dal
|
||||
from db.dal.user_dal import UserMergeConflictError
|
||||
from db.models import Payment, User, UserTelegramAvatar
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TEMPLATE_PATH = Path(__file__).resolve().parents[1] / "templates" / "subscription_webapp.html"
|
||||
ASSET_DIR = TEMPLATE_PATH.parent
|
||||
APP_ROOT = Path(__file__).resolve().parents[5]
|
||||
WEBAPP_LOGO_PROXY_PATH = "/webapp-logo"
|
||||
WEBAPP_LOGO_CACHE_DIR = APP_ROOT / "data" / "webapp-logo"
|
||||
WEBAPP_UPLOADED_LOGO_DIR = WEBAPP_LOGO_CACHE_DIR / "uploads"
|
||||
WEBAPP_UPLOADED_LOGO_PATH = "/webapp-uploaded-logo"
|
||||
WEBAPP_FAVICON_DIR = WEBAPP_LOGO_CACHE_DIR / "favicons"
|
||||
WEBAPP_FAVICON_PATH = "/webapp-favicon"
|
||||
WEBAPP_EMOJI_CACHE_DIR = APP_ROOT / "data" / "webapp-emoji"
|
||||
WEBAPP_CONFIG_PLACEHOLDER = "<!-- WEBAPP_CONFIG_SCRIPT -->"
|
||||
WEBAPP_I18N_PLACEHOLDER = "<!-- WEBAPP_I18N_SCRIPT -->"
|
||||
WEBAPP_JS_PLACEHOLDER = "<!-- WEBAPP_JS_SCRIPT -->"
|
||||
APP_REPOSITORY_URL = "https://github.com/3252a8/remnawave-minishop"
|
||||
DEV_MOCK_START_MARKER = "<!-- WEBAPP_DEV_MOCK_START -->"
|
||||
DEV_MOCK_END_MARKER = "<!-- WEBAPP_DEV_MOCK_END -->"
|
||||
WEBAPP_RATE_LIMIT_WINDOW_SECONDS = 60
|
||||
WEBAPP_RATE_LIMIT_MAX_REQUESTS = 30
|
||||
WEBAPP_LOGO_MAX_BYTES = 2 * 1024 * 1024
|
||||
WEBAPP_EMOJI_MAX_BYTES = 4 * 1024 * 1024
|
||||
WEBAPP_THEME_CSS_MAX_BYTES = 512 * 1024
|
||||
WEBAPP_THEME_ASSET_MAX_BYTES = 1024 * 1024
|
||||
WEBAPP_THEME_ASSET_CONTENT_TYPES = {
|
||||
".gif": "image/gif",
|
||||
".ico": "image/x-icon",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".svg": "image/svg+xml",
|
||||
".webp": "image/webp",
|
||||
}
|
||||
WEBAPP_TELEGRAM_AVATAR_MAX_BYTES = 128 * 1024
|
||||
WEBAPP_TELEGRAM_AVATAR_REFRESH_SECONDS = 24 * 60 * 60
|
||||
WEBAPP_TELEGRAM_AVATAR_FETCH_TIMEOUT_SECONDS = 4
|
||||
WEBAPP_SESSION_COOKIE_NAME = "rw_webapp_session"
|
||||
WEBAPP_CSRF_COOKIE_NAME = "rw_webapp_csrf"
|
||||
WEBAPP_TELEGRAM_OAUTH_STATE_COOKIE_NAME = "rw_tg_oauth_state"
|
||||
WEBAPP_CSRF_HEADER_NAME = "X-CSRF-Token"
|
||||
WEBAPP_STATE_CHANGING_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
|
||||
_APP_VERSION_CACHE: Optional[str] = None
|
||||
WEBAPP_CSRF_EXEMPT_PATHS = {
|
||||
"/api/auth/telegram/nonce",
|
||||
"/api/auth/token",
|
||||
"/api/auth/email/request",
|
||||
"/api/auth/email/verify",
|
||||
"/api/auth/email/magic",
|
||||
"/api/auth/logout",
|
||||
}
|
||||
|
||||
_SHARED_HTTP_SESSION: Optional[ClientSession] = None
|
||||
_SHARED_HTTP_SESSION_LOCK = asyncio.Lock()
|
||||
|
||||
__all__ = [name for name in globals() if not name.startswith("__")]
|
||||
@@ -0,0 +1,540 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
|
||||
async def account_email_request_route(request: web.Request) -> web.Response:
|
||||
user_id = _require_user_id(request)
|
||||
settings: Settings = request.app["settings"]
|
||||
payload = await _read_json(request)
|
||||
email_payload, validation_error = _validate_model_payload(WebAppEmailPayload, payload)
|
||||
if validation_error:
|
||||
return validation_error
|
||||
email = email_payload.email
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
|
||||
async with async_session_factory() as session:
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
if not db_user or db_user.is_banned:
|
||||
return _json_error(403, "access_denied", "Access denied")
|
||||
if db_user.email == email and db_user.email_verified_at:
|
||||
return web.json_response({"ok": True, "already_linked": True})
|
||||
lang = _normalize_language(db_user.language_code or settings.DEFAULT_LANGUAGE)
|
||||
|
||||
return await _request_email_code(
|
||||
request,
|
||||
email=email,
|
||||
purpose="link_email",
|
||||
language_code=lang,
|
||||
target_user_id=user_id,
|
||||
)
|
||||
|
||||
|
||||
async def account_email_verify_route(request: web.Request) -> web.Response:
|
||||
user_id = _require_user_id(request)
|
||||
rate_limit_response = await _enforce_webapp_rate_limit(
|
||||
request,
|
||||
user_id=user_id,
|
||||
action="account_email_verify",
|
||||
)
|
||||
if rate_limit_response:
|
||||
return rate_limit_response
|
||||
|
||||
payload = await _read_json(request)
|
||||
email_payload, validation_error = _validate_model_payload(WebAppEmailCodePayload, payload)
|
||||
if validation_error:
|
||||
return validation_error
|
||||
email = email_payload.email
|
||||
code = str(email_payload.code or "")
|
||||
email_service: EmailAuthService = request.app["email_auth_service"]
|
||||
settings: Settings = request.app["settings"]
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
merge_notice: Optional[Dict[str, Any]] = None
|
||||
source_panel_uuid: Optional[str] = None
|
||||
final_user_id = user_id
|
||||
final_email = email
|
||||
final_telegram_id: Optional[int] = None
|
||||
final_username: Optional[str] = None
|
||||
final_first_name: Optional[str] = None
|
||||
final_panel_uuid: Optional[str] = None
|
||||
should_notify_email_linked = False
|
||||
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
verify_result = await email_service.verify_code(
|
||||
session,
|
||||
email=email,
|
||||
purpose="link_email",
|
||||
code=code,
|
||||
target_user_id=user_id,
|
||||
)
|
||||
if not verify_result.ok:
|
||||
await session.commit()
|
||||
status = 429 if verify_result.error == "rate_limited" else 400
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": False,
|
||||
"error": verify_result.error or "invalid_code",
|
||||
"retry_after": verify_result.retry_after,
|
||||
"message": "Invalid code",
|
||||
},
|
||||
status=status,
|
||||
)
|
||||
|
||||
current_user = await user_dal.get_user_by_id(session, user_id)
|
||||
if not current_user or current_user.is_banned:
|
||||
await session.rollback()
|
||||
return _json_error(403, "access_denied", "Access denied")
|
||||
should_notify_email_linked = (
|
||||
bool(_telegram_id_for_user(current_user)) and not current_user.email
|
||||
)
|
||||
|
||||
existing_email_user = await user_dal.get_user_by_email(session, email)
|
||||
if existing_email_user and existing_email_user.user_id != current_user.user_id:
|
||||
source_panel_uuid = existing_email_user.panel_user_uuid
|
||||
current_user = await user_dal.merge_users(
|
||||
session,
|
||||
source_user_id=existing_email_user.user_id,
|
||||
target_user_id=current_user.user_id,
|
||||
)
|
||||
merge_notice = await _build_account_merge_notice(
|
||||
session,
|
||||
merged_user=current_user,
|
||||
source_user_id=existing_email_user.user_id,
|
||||
source_panel_uuid=source_panel_uuid,
|
||||
settings=settings,
|
||||
)
|
||||
current_user.email = email
|
||||
current_user.email_verified_at = datetime.now(timezone.utc)
|
||||
await _sync_panel_identity_for_user(request, current_user)
|
||||
await session.commit()
|
||||
final_user_id = int(current_user.user_id)
|
||||
final_telegram_id = _telegram_id_for_user(current_user)
|
||||
final_username = current_user.username
|
||||
final_first_name = current_user.first_name
|
||||
final_panel_uuid = current_user.panel_user_uuid
|
||||
|
||||
if merge_notice:
|
||||
merge_end_date_raw = merge_notice.get("final_end_date")
|
||||
merge_end_date = (
|
||||
datetime.fromisoformat(merge_end_date_raw) if merge_end_date_raw else None
|
||||
)
|
||||
await _sync_panel_identity_for_user(
|
||||
request,
|
||||
current_user,
|
||||
expire_at=merge_end_date,
|
||||
)
|
||||
# Best-effort cleanup of the removed panel account after the DB merge.
|
||||
if source_panel_uuid and final_panel_uuid and source_panel_uuid != final_panel_uuid:
|
||||
subscription_service: SubscriptionService = request.app.get(
|
||||
"subscription_service"
|
||||
)
|
||||
if subscription_service and subscription_service.panel_service:
|
||||
try:
|
||||
await subscription_service.panel_service.delete_user_from_panel(
|
||||
source_panel_uuid,
|
||||
log_response=False,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to delete merged source panel user %s: %s",
|
||||
source_panel_uuid,
|
||||
exc,
|
||||
)
|
||||
|
||||
email_service: EmailAuthService = request.app.get("email_auth_service")
|
||||
if email_service and final_email:
|
||||
email_content = render_account_merged(
|
||||
settings,
|
||||
language_code=merge_notice.get("language") or settings.DEFAULT_LANGUAGE,
|
||||
primary_user_id=merge_notice.get("primary_user_id"),
|
||||
removed_user_id=merge_notice.get("removed_user_id"),
|
||||
final_end_date_text=str(
|
||||
merge_notice.get("final_end_date_text")
|
||||
or merge_notice.get("final_end_date")
|
||||
or ""
|
||||
),
|
||||
)
|
||||
try:
|
||||
await email_service.send_rendered_email(
|
||||
email=final_email,
|
||||
content=email_content,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to send account merge email to %s: %s",
|
||||
final_email,
|
||||
exc,
|
||||
)
|
||||
except UserMergeConflictError as exc:
|
||||
await session.rollback()
|
||||
return _json_error(409, "account_merge_conflict", str(exc))
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logger.exception("Email account link failed")
|
||||
return _json_error(500, "link_failed", "Link failed")
|
||||
|
||||
if should_notify_email_linked:
|
||||
try:
|
||||
from bot.services.notification_service import NotificationService
|
||||
|
||||
bot: Bot = request.app["bot"]
|
||||
notification_service = NotificationService(
|
||||
bot,
|
||||
settings,
|
||||
request.app.get("i18n"),
|
||||
)
|
||||
await notification_service.notify_account_email_linked(
|
||||
user_id=int(final_user_id),
|
||||
email=final_email,
|
||||
telegram_id=final_telegram_id,
|
||||
username=final_username,
|
||||
first_name=final_first_name,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to send account email linked notification")
|
||||
|
||||
token = create_webapp_session_token(settings, int(final_user_id))
|
||||
response_payload: Dict[str, Any] = {"ok": True}
|
||||
if merge_notice:
|
||||
response_payload["account_merge"] = merge_notice
|
||||
response_payload["user_id"] = final_user_id
|
||||
return _build_webapp_auth_response(settings, response_payload, token=token)
|
||||
|
||||
|
||||
async def account_telegram_link_route(request: web.Request) -> web.Response:
|
||||
user_id = _require_user_id(request)
|
||||
settings: Settings = request.app["settings"]
|
||||
payload = await _read_json(request)
|
||||
telegram_user = await _validate_telegram_auth_payload(request, payload)
|
||||
if not telegram_user:
|
||||
return _json_error(401, "invalid_auth", "Invalid Telegram auth data")
|
||||
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
merge_notice: Optional[Dict[str, Any]] = None
|
||||
source_panel_uuid: Optional[str] = None
|
||||
final_user_id = user_id
|
||||
final_telegram_id: Optional[int] = None
|
||||
final_email: Optional[str] = None
|
||||
final_username: Optional[str] = None
|
||||
final_first_name: Optional[str] = None
|
||||
final_panel_uuid: Optional[str] = None
|
||||
should_notify_telegram_linked = False
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
current_user_before_link = await user_dal.get_user_by_id(session, user_id)
|
||||
if not current_user_before_link or current_user_before_link.is_banned:
|
||||
await session.rollback()
|
||||
return _json_error(403, "access_denied", "Access denied")
|
||||
should_notify_telegram_linked = bool(
|
||||
current_user_before_link.email
|
||||
) and not _telegram_id_for_user(current_user_before_link)
|
||||
source_panel_uuid = current_user_before_link.panel_user_uuid
|
||||
|
||||
db_user = await _link_telegram_to_user(
|
||||
request,
|
||||
session,
|
||||
current_user_id=user_id,
|
||||
telegram_user=telegram_user,
|
||||
settings=settings,
|
||||
)
|
||||
if db_user.is_banned:
|
||||
await session.rollback()
|
||||
return _json_error(403, "banned", "Access denied")
|
||||
|
||||
final_user_id = int(db_user.user_id)
|
||||
final_telegram_id = _telegram_id_for_user(db_user)
|
||||
final_email = db_user.email
|
||||
final_username = db_user.username
|
||||
final_first_name = db_user.first_name
|
||||
final_panel_uuid = db_user.panel_user_uuid
|
||||
if final_user_id != user_id:
|
||||
merge_notice = await _build_account_merge_notice(
|
||||
session,
|
||||
merged_user=db_user,
|
||||
source_user_id=user_id,
|
||||
source_panel_uuid=source_panel_uuid,
|
||||
settings=settings,
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
if merge_notice:
|
||||
merge_end_date_raw = merge_notice.get("final_end_date")
|
||||
merge_end_date = (
|
||||
datetime.fromisoformat(merge_end_date_raw) if merge_end_date_raw else None
|
||||
)
|
||||
await _sync_panel_identity_for_user(
|
||||
request,
|
||||
db_user,
|
||||
expire_at=merge_end_date,
|
||||
)
|
||||
# Best-effort cleanup of the removed panel account after the DB merge.
|
||||
if source_panel_uuid and final_panel_uuid and source_panel_uuid != final_panel_uuid:
|
||||
subscription_service: SubscriptionService = request.app.get(
|
||||
"subscription_service"
|
||||
)
|
||||
if subscription_service and subscription_service.panel_service:
|
||||
try:
|
||||
await subscription_service.panel_service.delete_user_from_panel(
|
||||
source_panel_uuid,
|
||||
log_response=False,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to delete merged source panel user %s: %s",
|
||||
source_panel_uuid,
|
||||
exc,
|
||||
)
|
||||
|
||||
email_service: EmailAuthService = request.app.get("email_auth_service")
|
||||
if email_service and final_email:
|
||||
email_content = render_account_merged(
|
||||
settings,
|
||||
language_code=merge_notice.get("language") or settings.DEFAULT_LANGUAGE,
|
||||
primary_user_id=merge_notice.get("primary_user_id"),
|
||||
removed_user_id=merge_notice.get("removed_user_id"),
|
||||
final_end_date_text=str(
|
||||
merge_notice.get("final_end_date_text")
|
||||
or merge_notice.get("final_end_date")
|
||||
or ""
|
||||
),
|
||||
)
|
||||
try:
|
||||
await email_service.send_rendered_email(
|
||||
email=final_email,
|
||||
content=email_content,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to send account merge email to %s: %s",
|
||||
final_email,
|
||||
exc,
|
||||
)
|
||||
except UserMergeConflictError as exc:
|
||||
await session.rollback()
|
||||
return _json_error(409, "account_merge_conflict", str(exc))
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logger.exception("Telegram account link failed")
|
||||
return _json_error(500, "link_failed", "Link failed")
|
||||
|
||||
if should_notify_telegram_linked and final_telegram_id:
|
||||
try:
|
||||
from bot.services.notification_service import NotificationService
|
||||
|
||||
bot: Bot = request.app["bot"]
|
||||
notification_service = NotificationService(
|
||||
bot,
|
||||
settings,
|
||||
request.app.get("i18n"),
|
||||
)
|
||||
await notification_service.notify_account_telegram_linked(
|
||||
user_id=int(final_user_id),
|
||||
email=final_email,
|
||||
telegram_id=int(final_telegram_id),
|
||||
username=final_username,
|
||||
first_name=final_first_name,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to send account Telegram linked notification")
|
||||
|
||||
token = create_webapp_session_token(settings, int(final_user_id))
|
||||
response_payload: Dict[str, Any] = {
|
||||
"ok": True,
|
||||
"user_id": int(final_user_id),
|
||||
"telegram_id": final_telegram_id,
|
||||
}
|
||||
if merge_notice:
|
||||
response_payload["account_merge"] = merge_notice
|
||||
return _build_webapp_auth_response(settings, response_payload, token=token)
|
||||
|
||||
|
||||
async def me_route(request: web.Request) -> web.Response:
|
||||
user_id = _require_user_id(request)
|
||||
settings: Settings = request.app["settings"]
|
||||
cache_key = redis_key(settings, "cache", "webapp", "me", user_id)
|
||||
cached = await cache_get_json(settings, cache_key)
|
||||
if cached:
|
||||
return web.json_response({"ok": True, **cached})
|
||||
data = await _build_user_payload(request, user_id)
|
||||
await cache_set_json(settings, cache_key, data, settings.WEBAPP_ME_CACHE_TTL_SECONDS)
|
||||
return web.json_response({"ok": True, **data})
|
||||
|
||||
|
||||
async def account_avatar_route(request: web.Request) -> web.Response:
|
||||
user_id = _require_user_id(request)
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
if not db_user or db_user.is_banned:
|
||||
await session.rollback()
|
||||
return _json_error(403, "access_denied", "Access denied")
|
||||
|
||||
avatar = await _ensure_cached_telegram_avatar(request, session, db_user)
|
||||
await session.commit()
|
||||
|
||||
if not avatar:
|
||||
raise web.HTTPNotFound(text="avatar_not_cached")
|
||||
|
||||
etag = _telegram_avatar_etag(avatar)
|
||||
if etag and request.headers.get("If-None-Match") == etag:
|
||||
return web.Response(status=304, headers={"ETag": etag})
|
||||
|
||||
response = web.Response(
|
||||
body=bytes(avatar.image_bytes),
|
||||
content_type=avatar.content_type or "image/jpeg",
|
||||
)
|
||||
response.headers["Cache-Control"] = "private, max-age=3600"
|
||||
if etag:
|
||||
response.headers["ETag"] = etag
|
||||
return response
|
||||
|
||||
|
||||
async def account_language_route(request: web.Request) -> web.Response:
|
||||
user_id = _require_user_id(request)
|
||||
payload = await _read_json(request)
|
||||
language_payload, validation_error = _validate_model_payload(WebAppLanguagePayload, payload)
|
||||
if validation_error:
|
||||
return validation_error
|
||||
|
||||
language = _normalize_language(str(language_payload.language or ""))
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
if not db_user or db_user.is_banned:
|
||||
await session.rollback()
|
||||
return _json_error(403, "access_denied", "Access denied")
|
||||
|
||||
if _normalize_language(db_user.language_code or "") != language:
|
||||
db_user.language_code = language
|
||||
await session.flush()
|
||||
await session.commit()
|
||||
|
||||
return web.json_response({"ok": True, "language": language})
|
||||
|
||||
|
||||
def _format_webapp_datetime(value: Optional[datetime]) -> Optional[str]:
|
||||
if not value:
|
||||
return None
|
||||
normalized = value if value.tzinfo else value.replace(tzinfo=timezone.utc)
|
||||
return normalized.strftime("%d.%m.%Y %H:%M")
|
||||
|
||||
|
||||
def _telegram_photo_url_value(telegram_user: Dict[str, Any]) -> Optional[str]:
|
||||
raw_value = telegram_user.get("photo_url")
|
||||
if not raw_value:
|
||||
return None
|
||||
value = str(raw_value).strip()
|
||||
return value or None
|
||||
|
||||
|
||||
def _telegram_avatar_is_stale(avatar: Optional[UserTelegramAvatar]) -> bool:
|
||||
if not avatar or not avatar.updated_at:
|
||||
return True
|
||||
updated_at = avatar.updated_at
|
||||
if updated_at.tzinfo is None:
|
||||
updated_at = updated_at.replace(tzinfo=timezone.utc)
|
||||
return (
|
||||
datetime.now(timezone.utc) - updated_at
|
||||
).total_seconds() >= WEBAPP_TELEGRAM_AVATAR_REFRESH_SECONDS
|
||||
|
||||
|
||||
def _telegram_avatar_etag(avatar: UserTelegramAvatar) -> str:
|
||||
digest = hashlib.sha256(bytes(avatar.image_bytes)).hexdigest()[:16]
|
||||
return f'"tg-avatar-{int(avatar.user_id)}-{digest}"'
|
||||
|
||||
|
||||
def _telegram_avatar_url(avatar: Optional[UserTelegramAvatar]) -> str:
|
||||
if not avatar:
|
||||
return ""
|
||||
updated_at = avatar.updated_at
|
||||
if updated_at and updated_at.tzinfo is None:
|
||||
updated_at = updated_at.replace(tzinfo=timezone.utc)
|
||||
version = (
|
||||
int(updated_at.timestamp())
|
||||
if updated_at
|
||||
else hashlib.sha256(bytes(avatar.image_bytes)).hexdigest()[:8]
|
||||
)
|
||||
return f"/api/account/avatar?v={version}"
|
||||
|
||||
|
||||
def _select_compact_telegram_photo_size(sizes: List[Any]) -> Optional[Any]:
|
||||
if not sizes:
|
||||
return None
|
||||
suitable = [size for size in sizes if int(getattr(size, "width", 0) or 0) >= 160]
|
||||
candidates = suitable or sizes
|
||||
return min(
|
||||
candidates,
|
||||
key=lambda size: (
|
||||
int(getattr(size, "file_size", 0) or 0)
|
||||
or int(getattr(size, "width", 0) or 0) * int(getattr(size, "height", 0) or 0),
|
||||
int(getattr(size, "width", 0) or 0),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _telegram_file_content_type(file_path: Optional[str]) -> str:
|
||||
path = str(file_path or "").lower()
|
||||
if path.endswith(".png"):
|
||||
return "image/png"
|
||||
if path.endswith(".webp"):
|
||||
return "image/webp"
|
||||
return "image/jpeg"
|
||||
|
||||
|
||||
async def _fetch_compact_telegram_avatar(
|
||||
bot: Bot, telegram_id: int
|
||||
) -> Optional[Tuple[bytes, str, Optional[str]]]:
|
||||
photos = await bot.get_user_profile_photos(user_id=telegram_id, limit=1)
|
||||
if not photos or not photos.photos:
|
||||
return None
|
||||
|
||||
photo_size = _select_compact_telegram_photo_size(list(photos.photos[0] or []))
|
||||
if not photo_size:
|
||||
return None
|
||||
|
||||
file_info = await bot.get_file(photo_size.file_id)
|
||||
destination = io.BytesIO()
|
||||
await bot.download_file(file_info.file_path, destination=destination)
|
||||
body = destination.getvalue()
|
||||
if not body or len(body) > WEBAPP_TELEGRAM_AVATAR_MAX_BYTES:
|
||||
return None
|
||||
return (
|
||||
body,
|
||||
_telegram_file_content_type(file_info.file_path),
|
||||
getattr(photo_size, "file_unique_id", None),
|
||||
)
|
||||
|
||||
|
||||
async def _ensure_cached_telegram_avatar(
|
||||
request: web.Request,
|
||||
session: AsyncSession,
|
||||
user: User,
|
||||
) -> Optional[UserTelegramAvatar]:
|
||||
avatar = await user_dal.get_user_telegram_avatar(session, int(user.user_id))
|
||||
telegram_id = _telegram_id_for_user(user)
|
||||
if not telegram_id:
|
||||
return avatar
|
||||
if avatar and not _telegram_avatar_is_stale(avatar):
|
||||
return avatar
|
||||
|
||||
bot: Bot = request.app["bot"]
|
||||
try:
|
||||
fetched = await asyncio.wait_for(
|
||||
_fetch_compact_telegram_avatar(bot, int(telegram_id)),
|
||||
timeout=WEBAPP_TELEGRAM_AVATAR_FETCH_TIMEOUT_SECONDS,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.info("Failed to refresh Telegram avatar for user %s: %s", user.user_id, exc)
|
||||
return avatar
|
||||
|
||||
if not fetched:
|
||||
return avatar
|
||||
|
||||
body, content_type, file_unique_id = fetched
|
||||
return await user_dal.upsert_user_telegram_avatar(
|
||||
session,
|
||||
user_id=int(user.user_id),
|
||||
file_unique_id=file_unique_id,
|
||||
content_type=content_type,
|
||||
image_bytes=body,
|
||||
)
|
||||
@@ -0,0 +1,59 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
|
||||
def create_subscription_webapp_application(
|
||||
dp: Dispatcher,
|
||||
bot: Bot,
|
||||
settings: Settings,
|
||||
async_session_factory: sessionmaker,
|
||||
) -> web.Application:
|
||||
app = web.Application(
|
||||
middlewares=[
|
||||
_security_headers_middleware,
|
||||
_csrf_protection_middleware,
|
||||
admin_auth_middleware,
|
||||
]
|
||||
)
|
||||
app["bot"] = bot
|
||||
app["dp"] = dp
|
||||
app["settings"] = settings
|
||||
app["async_session_factory"] = async_session_factory
|
||||
app["i18n"] = dp.get("i18n_instance")
|
||||
app["email_auth_service"] = EmailAuthService(settings)
|
||||
app["webapp_logo_cache"] = None
|
||||
app["webapp_logo_cache_lock"] = asyncio.Lock()
|
||||
app["webapp_settings_cache"] = {"ts": 0.0, "data": {}}
|
||||
app["webapp_rate_limit_buckets"] = {}
|
||||
app["webapp_rate_limit_lock"] = asyncio.Lock()
|
||||
|
||||
async def _startup(app_obj: web.Application) -> None:
|
||||
await _ensure_shared_http_session()
|
||||
await _warm_webapp_logo_cache(app_obj)
|
||||
await _warm_webapp_animated_emoji_cache(app_obj)
|
||||
|
||||
async def _shutdown(app_obj: web.Application) -> None:
|
||||
await _close_shared_http_session()
|
||||
|
||||
app.on_startup.append(_startup)
|
||||
app.on_shutdown.append(_shutdown)
|
||||
|
||||
for key in (
|
||||
"subscription_service",
|
||||
"yookassa_service",
|
||||
"freekassa_service",
|
||||
"cryptopay_service",
|
||||
"platega_service",
|
||||
"severpay_service",
|
||||
"promo_code_service",
|
||||
"referral_service",
|
||||
"panel_service",
|
||||
):
|
||||
if hasattr(dp, "workflow_data") and key in dp.workflow_data: # type: ignore[attr-defined]
|
||||
app[key] = dp.workflow_data[key] # type: ignore[index]
|
||||
|
||||
if hasattr(dp, "workflow_data") and "bot_username" in dp.workflow_data: # type: ignore[attr-defined]
|
||||
app["bot_username"] = dp.workflow_data["bot_username"] # type: ignore[index]
|
||||
|
||||
setup_subscription_webapp_routes(app)
|
||||
return app
|
||||
@@ -0,0 +1,156 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
|
||||
async def _read_json(request: web.Request) -> Dict[str, Any]:
|
||||
try:
|
||||
data = await request.json()
|
||||
return data if isinstance(data, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _json_error(status: int, code: str, message: str) -> web.Response:
|
||||
return web.json_response(
|
||||
{"ok": False, "error": code, "message": message},
|
||||
status=status,
|
||||
)
|
||||
|
||||
|
||||
def _validation_error_response(exc: ValidationError) -> web.Response:
|
||||
for error in exc.errors():
|
||||
loc = error.get("loc") or ()
|
||||
field = str(loc[0]) if loc else ""
|
||||
error_type = str(error.get("type") or "")
|
||||
message = str(error.get("msg") or "")
|
||||
message_lower = message.lower()
|
||||
|
||||
if field == "email":
|
||||
if (
|
||||
"too_long" in message_lower
|
||||
or "too long" in message_lower
|
||||
or error_type == "string_too_long"
|
||||
):
|
||||
return _json_error(400, "email_too_long", "Email is too long")
|
||||
return _json_error(400, "invalid_email", "Invalid email")
|
||||
|
||||
if field in {"description", "comment", "note"} and error_type == "string_too_long":
|
||||
return _json_error(400, f"{field}_too_long", f"{field.capitalize()} is too long")
|
||||
|
||||
if error_type == "string_too_long":
|
||||
return _json_error(400, "text_too_long", "Text is too long")
|
||||
|
||||
return _json_error(400, "invalid_request", "Invalid request")
|
||||
|
||||
|
||||
def _validate_model_payload(
|
||||
model_cls: type[BaseModel],
|
||||
payload: Dict[str, Any],
|
||||
) -> tuple[Optional[BaseModel], Optional[web.Response]]:
|
||||
try:
|
||||
return model_cls.model_validate(payload), None
|
||||
except ValidationError as exc:
|
||||
return None, _validation_error_response(exc)
|
||||
|
||||
|
||||
def _normalize_language(lang: Optional[str]) -> str:
|
||||
value = (lang or "ru").split("-")[0].lower()
|
||||
return value if value in {"ru", "en"} else "ru"
|
||||
|
||||
|
||||
def _format_remaining(seconds: int, lang: str) -> str:
|
||||
if seconds <= 0:
|
||||
if lang == "en":
|
||||
return "Subscription inactive"
|
||||
return "Подписка не активна"
|
||||
days, rem = divmod(seconds, 86400)
|
||||
hours, rem = divmod(rem, 3600)
|
||||
minutes = rem // 60
|
||||
if lang == "en":
|
||||
if days > 0:
|
||||
return f"{days} d. {hours} h."
|
||||
if hours > 0:
|
||||
return f"{hours} h. {minutes} min."
|
||||
return f"{max(1, minutes)} min."
|
||||
if days > 0:
|
||||
return f"{days} д. {hours} ч."
|
||||
if hours > 0:
|
||||
return f"{hours} ч. {minutes} мин."
|
||||
return f"{max(1, minutes)} мин."
|
||||
|
||||
|
||||
def _coerce_int_or_none(value: Optional[Any]) -> Optional[int]:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _format_bytes(value: Optional[Any], *, zero_as_unlimited: bool = False) -> str:
|
||||
if value is None:
|
||||
return "N/A"
|
||||
try:
|
||||
size = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return str(value)
|
||||
if size <= 0 and zero_as_unlimited:
|
||||
return "∞"
|
||||
if size <= 0:
|
||||
size = 0
|
||||
units = ["B", "KB", "MB", "GB", "TB"]
|
||||
index = 0
|
||||
while size >= 1024 and index < len(units) - 1:
|
||||
size /= 1024
|
||||
index += 1
|
||||
return f"{size:.2f} {units[index]}"
|
||||
|
||||
|
||||
def _format_months_title(months: int, lang: str) -> str:
|
||||
if lang == "en":
|
||||
if months == 1:
|
||||
return "1 month"
|
||||
return f"{months} months"
|
||||
if months == 1:
|
||||
return "1 месяц"
|
||||
if 2 <= months <= 4:
|
||||
return f"{months} месяца"
|
||||
return f"{months} месяцев"
|
||||
|
||||
|
||||
def _format_number_for_payload(value: Any) -> str:
|
||||
numeric = float(value or 0)
|
||||
return str(int(numeric)) if numeric.is_integer() else f"{numeric:g}"
|
||||
|
||||
|
||||
def _format_traffic_title(traffic_gb: float, lang: str) -> str:
|
||||
return f"{_format_number_for_payload(traffic_gb)} GB"
|
||||
|
||||
|
||||
def _traffic_payment_description(traffic_gb: float, lang: str) -> str:
|
||||
if lang == "en":
|
||||
return f"Traffic package {_format_traffic_title(traffic_gb, lang)}"
|
||||
return f"Пакет трафика {_format_traffic_title(traffic_gb, lang)}"
|
||||
|
||||
|
||||
def _hwid_devices_payment_description(device_count: int, lang: str) -> str:
|
||||
if lang == "en":
|
||||
return f"HWID device package +{device_count}"
|
||||
return f"Докупка устройств HWID +{device_count}"
|
||||
|
||||
|
||||
def _resolve_numeric_option_key(options: Dict[Any, Any], target: float) -> Optional[Any]:
|
||||
for key in options:
|
||||
try:
|
||||
if abs(float(key) - float(target)) < 0.000001:
|
||||
return key
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _payment_description(months: int, lang: str) -> str:
|
||||
if lang == "en":
|
||||
return f"Subscription for {_format_months_title(months, lang)}"
|
||||
return f"Подписка на {_format_months_title(months, lang)}"
|
||||
@@ -0,0 +1,169 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
|
||||
async def devices_route(request: web.Request) -> web.Response:
|
||||
user_id = _require_user_id(request)
|
||||
settings: Settings = request.app["settings"]
|
||||
if not settings.MY_DEVICES_SECTION_ENABLED:
|
||||
return _json_error(404, "devices_disabled", "Devices section is disabled")
|
||||
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
subscription_service: SubscriptionService = request.app["subscription_service"]
|
||||
async with async_session_factory() as session:
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
if not db_user or db_user.is_banned:
|
||||
return _json_error(403, "access_denied", "Access denied")
|
||||
|
||||
active = await subscription_service.get_active_subscription_details(session, user_id)
|
||||
panel_user_uuid = active.get("user_id") if active else None
|
||||
if not panel_user_uuid:
|
||||
return _json_error(400, "subscription_not_active", "Subscription is not active")
|
||||
|
||||
panel_service = getattr(subscription_service, "panel_service", None)
|
||||
if not panel_service:
|
||||
return _json_error(503, "panel_unavailable", "Panel service unavailable")
|
||||
|
||||
try:
|
||||
devices_response = await panel_service.get_user_devices(panel_user_uuid)
|
||||
except Exception:
|
||||
logger.exception("Failed to load WebApp devices for user %s", user_id)
|
||||
return _json_error(502, "devices_load_failed", "Failed to load devices")
|
||||
|
||||
devices = _normalize_devices_response(devices_response)
|
||||
max_devices = _coerce_int_or_none(active.get("max_devices")) if active else None
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": True,
|
||||
"enabled": True,
|
||||
"current_devices": len(devices),
|
||||
"max_devices": max_devices,
|
||||
"max_devices_label": _format_devices_limit(max_devices),
|
||||
"devices": [
|
||||
_serialize_device(device, index) for index, device in enumerate(devices, start=1)
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def disconnect_device_route(request: web.Request) -> web.Response:
|
||||
user_id = _require_user_id(request)
|
||||
rate_limit_response = await _enforce_webapp_rate_limit(
|
||||
request,
|
||||
user_id=user_id,
|
||||
action="devices_disconnect",
|
||||
)
|
||||
if rate_limit_response:
|
||||
return rate_limit_response
|
||||
|
||||
settings: Settings = request.app["settings"]
|
||||
if not settings.MY_DEVICES_SECTION_ENABLED:
|
||||
return _json_error(404, "devices_disabled", "Devices section is disabled")
|
||||
|
||||
payload = await _read_json(request)
|
||||
disconnect_payload, validation_error = _validate_model_payload(
|
||||
WebAppDeviceDisconnectPayload, payload
|
||||
)
|
||||
if validation_error:
|
||||
return validation_error
|
||||
token = str(disconnect_payload.token or "").strip()
|
||||
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
subscription_service: SubscriptionService = request.app["subscription_service"]
|
||||
async with async_session_factory() as session:
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
if not db_user or db_user.is_banned:
|
||||
return _json_error(403, "access_denied", "Access denied")
|
||||
|
||||
active = await subscription_service.get_active_subscription_details(session, user_id)
|
||||
panel_user_uuid = active.get("user_id") if active else None
|
||||
if not panel_user_uuid:
|
||||
return _json_error(400, "subscription_not_active", "Subscription is not active")
|
||||
|
||||
panel_service = getattr(subscription_service, "panel_service", None)
|
||||
if not panel_service:
|
||||
return _json_error(503, "panel_unavailable", "Panel service unavailable")
|
||||
|
||||
try:
|
||||
devices_response = await panel_service.get_user_devices(panel_user_uuid)
|
||||
except Exception:
|
||||
logger.exception("Failed to load WebApp devices before disconnect for user %s", user_id)
|
||||
return _json_error(502, "devices_load_failed", "Failed to load devices")
|
||||
|
||||
target_hwid = None
|
||||
for device in _normalize_devices_response(devices_response):
|
||||
hwid = str(device.get("hwid") or "").strip()
|
||||
if hwid and hmac.compare_digest(_device_hwid_token(hwid), token):
|
||||
target_hwid = hwid
|
||||
break
|
||||
|
||||
if not target_hwid:
|
||||
return _json_error(404, "device_not_found", "Device not found")
|
||||
|
||||
success = await panel_service.disconnect_device(panel_user_uuid, target_hwid)
|
||||
if not success:
|
||||
return _json_error(502, "device_disconnect_failed", "Failed to disconnect device")
|
||||
await session.commit()
|
||||
|
||||
return web.json_response({"ok": True})
|
||||
|
||||
|
||||
def _device_hwid_token(hwid: str) -> str:
|
||||
return hashlib.sha256(str(hwid or "").encode()).hexdigest()[:32]
|
||||
|
||||
|
||||
def _shorten_hwid_for_display(hwid: Optional[str], max_length: int = 24) -> str:
|
||||
value = str(hwid or "").strip()
|
||||
if len(value) <= max_length:
|
||||
return value
|
||||
return f"{value[:8]}...{value[-6:]}"
|
||||
|
||||
|
||||
def _normalize_devices_response(devices_response: Any) -> List[Dict[str, Any]]:
|
||||
if isinstance(devices_response, dict):
|
||||
devices = devices_response.get("devices") or []
|
||||
else:
|
||||
devices = devices_response or []
|
||||
if not isinstance(devices, list):
|
||||
return []
|
||||
return [device for device in devices if isinstance(device, dict)]
|
||||
|
||||
|
||||
def _format_devices_limit(max_devices: Optional[int]) -> str:
|
||||
if max_devices in (None, 0):
|
||||
return "Unlimited"
|
||||
return str(max_devices)
|
||||
|
||||
|
||||
def _format_device_datetime(value: Any) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
text = str(value)
|
||||
try:
|
||||
normalized = datetime.fromisoformat(text.replace("Z", "+00:00"))
|
||||
return normalized.strftime("%d.%m.%Y %H:%M")
|
||||
except Exception:
|
||||
return text
|
||||
|
||||
|
||||
def _serialize_device(device: Dict[str, Any], index: int) -> Dict[str, Any]:
|
||||
hwid = str(device.get("hwid") or "").strip()
|
||||
model = str(device.get("deviceModel") or "").strip()
|
||||
platform = str(device.get("platform") or "").strip()
|
||||
os_version = str(device.get("osVersion") or "").strip()
|
||||
user_agent = str(device.get("userAgent") or "").strip()
|
||||
display_name = model or platform or f"Device {index}"
|
||||
platform_label = " ".join(part for part in (platform, os_version) if part).strip()
|
||||
return {
|
||||
"index": index,
|
||||
"display_name": display_name,
|
||||
"platform": platform,
|
||||
"os_version": os_version,
|
||||
"platform_label": platform_label,
|
||||
"user_agent": user_agent,
|
||||
"created_at": device.get("createdAt"),
|
||||
"created_at_text": _format_device_datetime(device.get("createdAt")),
|
||||
"hwid_short": _shorten_hwid_for_display(hwid),
|
||||
"token": _device_hwid_token(hwid) if hwid else "",
|
||||
"can_disconnect": bool(hwid),
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
|
||||
class WebAppEmailPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
email: EmailStr
|
||||
|
||||
@field_validator("email")
|
||||
@classmethod
|
||||
def _normalize_and_limit_email(cls, value: EmailStr) -> str:
|
||||
normalized = normalize_email(str(value))
|
||||
if len(normalized) > 254:
|
||||
raise ValueError("email_too_long")
|
||||
return normalized
|
||||
|
||||
|
||||
class WebAppEmailCodePayload(WebAppEmailPayload):
|
||||
code: str = ""
|
||||
|
||||
|
||||
class WebAppEmailMagicPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
token: constr(min_length=8, max_length=512)
|
||||
|
||||
|
||||
class WebAppPaymentCreatePayload(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
method: str = ""
|
||||
months: Any = None
|
||||
traffic_gb: Any = None
|
||||
device_count: Any = None
|
||||
tariff_key: Optional[constr(max_length=128)] = None
|
||||
sale_mode: Optional[constr(max_length=64)] = None
|
||||
description: Optional[constr(max_length=4096)] = None
|
||||
comment: Optional[constr(max_length=4096)] = None
|
||||
note: Optional[constr(max_length=4096)] = None
|
||||
|
||||
|
||||
class WebAppTariffChangePayload(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
tariff_key: constr(min_length=1, max_length=128)
|
||||
mode: constr(min_length=1, max_length=64)
|
||||
|
||||
|
||||
class WebAppLanguagePayload(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
language: constr(min_length=2, max_length=16)
|
||||
|
||||
|
||||
class WebAppDeviceDisconnectPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
token: constr(min_length=8, max_length=128)
|
||||
@@ -0,0 +1,65 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
|
||||
def setup_subscription_webapp_routes(app: web.Application) -> None:
|
||||
app.router.add_get("/", index_route)
|
||||
app.router.add_get("/home", index_route)
|
||||
app.router.add_get("/invite", index_route)
|
||||
app.router.add_get("/devices", index_route)
|
||||
app.router.add_get("/settings", index_route)
|
||||
app.router.add_get("/admin", index_route)
|
||||
app.router.add_get(
|
||||
(
|
||||
"/admin/{section:stats|users|payments|promos|ads|broadcast|logs|tariffs|"
|
||||
"appearance|settings}"
|
||||
),
|
||||
index_route,
|
||||
)
|
||||
app.router.add_get("/admin/users/{user_id:-?[0-9]+}", index_route)
|
||||
app.router.add_get("/auth/telegram/start", telegram_oauth_start_route)
|
||||
app.router.add_get("/auth/telegram/callback", telegram_oauth_callback_route)
|
||||
app.router.add_get("/health", health_route)
|
||||
app.router.add_get(WEBAPP_LOGO_PROXY_PATH, webapp_logo_route)
|
||||
app.router.add_get(
|
||||
rf"{WEBAPP_UPLOADED_LOGO_PATH}/{{filename:[A-Za-z0-9_.-]+}}",
|
||||
webapp_uploaded_logo_route,
|
||||
)
|
||||
app.router.add_get(
|
||||
rf"{WEBAPP_FAVICON_PATH}/{{digest:[0-9a-f]{{16}}}}/{{filename:[A-Za-z0-9_.-]+}}",
|
||||
webapp_favicon_route,
|
||||
)
|
||||
app.router.add_get(
|
||||
r"/webapp-emoji/{codepoints:[0-9a-f_]+}/512.{ext:gif|webp}",
|
||||
webapp_animated_emoji_route,
|
||||
)
|
||||
app.router.add_get("/subscription_webapp.css", css_asset_route)
|
||||
app.router.add_get(r"/webapp-theme-css/{path:.+}", theme_css_asset_route)
|
||||
app.router.add_get(r"/webapp-theme-assets/{path:.+}", theme_asset_route)
|
||||
app.router.add_get("/subscription_webapp.min.{asset_hash}.js", js_asset_route)
|
||||
app.router.add_get("/subscription_webapp.js", js_asset_route)
|
||||
app.router.add_post("/api/auth/telegram/nonce", telegram_oauth_nonce_route)
|
||||
app.router.add_post("/api/auth/token", auth_token_route)
|
||||
app.router.add_post("/api/auth/email/request", email_auth_request_route)
|
||||
app.router.add_post("/api/auth/email/verify", email_auth_verify_route)
|
||||
app.router.add_post("/api/auth/email/magic", email_auth_magic_route)
|
||||
app.router.add_post("/api/auth/logout", logout_route)
|
||||
app.router.add_get("/api/bootstrap", bootstrap_route)
|
||||
app.router.add_get("/api/me", me_route)
|
||||
app.router.add_get("/api/account/avatar", account_avatar_route)
|
||||
app.router.add_post("/api/account/language", account_language_route)
|
||||
app.router.add_post("/api/account/email/request", account_email_request_route)
|
||||
app.router.add_post("/api/account/email/verify", account_email_verify_route)
|
||||
app.router.add_post("/api/account/telegram/link", account_telegram_link_route)
|
||||
app.router.add_post("/api/promo/apply", apply_promo_route)
|
||||
app.router.add_post("/api/trial/activate", activate_trial_route)
|
||||
app.router.add_get("/api/devices", devices_route)
|
||||
app.router.add_post("/api/devices/disconnect", disconnect_device_route)
|
||||
app.router.add_get("/api/devices/topup-options", device_topup_options_route)
|
||||
app.router.add_get("/api/tariffs/topup-options", tariff_topup_options_route)
|
||||
app.router.add_get("/api/tariffs/change-options", tariff_change_options_route)
|
||||
app.router.add_post("/api/tariffs/change", tariff_change_route)
|
||||
app.router.add_post("/api/tariffs/change-payment", tariff_change_payment_route)
|
||||
app.router.add_post("/api/payments", create_payment_route)
|
||||
app.router.add_get("/api/payments/{payment_id}", payment_status_route)
|
||||
setup_admin_routes(app)
|
||||
@@ -0,0 +1,627 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
from config.webapp_themes_config import public_themes_catalog_payload
|
||||
|
||||
|
||||
async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, Any]:
|
||||
settings: Settings = request.app["settings"]
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
subscription_service: SubscriptionService = request.app["subscription_service"]
|
||||
cached = _get_cached_webapp_settings(request)
|
||||
|
||||
async with async_session_factory() as session:
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
if not db_user or db_user.is_banned:
|
||||
raise web.HTTPForbidden(
|
||||
text=json.dumps({"ok": False, "error": "access_denied"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
active = await subscription_service.get_active_subscription_details(session, user_id)
|
||||
referral_code = await user_dal.ensure_referral_code(session, db_user)
|
||||
referral_service: Optional[ReferralService] = request.app.get("referral_service")
|
||||
bot_username = request.app.get("bot_username") or ""
|
||||
referral_link = None
|
||||
if referral_service and bot_username:
|
||||
referral_link = await referral_service.generate_referral_link(
|
||||
session,
|
||||
bot_username,
|
||||
user_id,
|
||||
)
|
||||
webapp_referral_link = _build_webapp_referral_link(
|
||||
request.app["settings"].SUBSCRIPTION_MINI_APP_URL,
|
||||
referral_code,
|
||||
)
|
||||
referral_stats = (
|
||||
await referral_service.get_referral_stats(session, user_id)
|
||||
if referral_service
|
||||
else {"invited_count": 0, "purchased_count": 0}
|
||||
)
|
||||
local_sub = (
|
||||
await subscription_dal.get_active_subscription_by_user_id(
|
||||
session,
|
||||
user_id,
|
||||
db_user.panel_user_uuid,
|
||||
)
|
||||
if db_user.panel_user_uuid
|
||||
else None
|
||||
)
|
||||
trial_available = bool(
|
||||
settings.TRIAL_ENABLED
|
||||
and settings.TRIAL_DURATION_DAYS > 0
|
||||
and not await subscription_service.has_had_any_subscription(session, user_id)
|
||||
)
|
||||
avatar = await _ensure_cached_telegram_avatar(request, session, db_user)
|
||||
try:
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
|
||||
lang = _normalize_language(db_user.language_code or settings.DEFAULT_LANGUAGE)
|
||||
admin_ids = {int(x) for x in (settings.ADMIN_IDS or [])}
|
||||
is_admin = bool(db_user.telegram_id and int(db_user.telegram_id) in admin_ids)
|
||||
return {
|
||||
"user": {
|
||||
"id": user_id,
|
||||
"username": db_user.username,
|
||||
"email": db_user.email,
|
||||
"email_verified": bool(db_user.email_verified_at),
|
||||
"telegram_id": db_user.telegram_id,
|
||||
"telegram_linked": bool(_telegram_id_for_user(db_user)),
|
||||
"telegram_photo_url": _telegram_avatar_url(avatar),
|
||||
"first_name": db_user.first_name,
|
||||
"language_code": lang,
|
||||
"is_admin": is_admin,
|
||||
},
|
||||
"subscription": _serialize_subscription(settings, active, local_sub, lang),
|
||||
"referral": {
|
||||
"code": referral_code,
|
||||
"bot_link": referral_link,
|
||||
"webapp_link": webapp_referral_link,
|
||||
"invited_count": referral_stats.get("invited_count", 0),
|
||||
"purchased_count": referral_stats.get("purchased_count", 0),
|
||||
"welcome_bonus_days": max(
|
||||
0, int(getattr(settings, "REFERRAL_WELCOME_BONUS_DAYS", 0) or 0)
|
||||
),
|
||||
"one_bonus_per_referee": bool(
|
||||
getattr(settings, "REFERRAL_ONE_BONUS_PER_REFEREE", False)
|
||||
),
|
||||
"bonus_details": _serialize_referral_bonus_details(settings, lang),
|
||||
},
|
||||
"plans": _serialize_plans(
|
||||
settings,
|
||||
lang,
|
||||
subscription_options=cached["subscription_options"],
|
||||
stars_subscription_options=cached["stars_subscription_options"],
|
||||
traffic_packages=cached["traffic_packages"],
|
||||
stars_traffic_packages=cached["stars_traffic_packages"],
|
||||
),
|
||||
"payment_methods": _serialize_payment_methods(settings, request.app),
|
||||
"themes_catalog": public_themes_catalog_payload(
|
||||
settings.webapp_themes_catalog,
|
||||
settings.WEBAPP_PRIMARY_COLOR or "#00fe7a",
|
||||
enabled_only=True,
|
||||
),
|
||||
"settings": {
|
||||
"support_url": settings.SUPPORT_LINK,
|
||||
"traffic_mode": bool(settings.traffic_sale_mode),
|
||||
"my_devices_enabled": bool(settings.MY_DEVICES_SECTION_ENABLED),
|
||||
"user_hwid_device_limit": (
|
||||
int(settings.USER_HWID_DEVICE_LIMIT)
|
||||
if settings.USER_HWID_DEVICE_LIMIT is not None
|
||||
else None
|
||||
),
|
||||
"trial_enabled": bool(settings.TRIAL_ENABLED),
|
||||
"trial_available": trial_available,
|
||||
"trial_duration_days": int(settings.TRIAL_DURATION_DAYS or 0),
|
||||
"trial_traffic_limit_gb": float(settings.TRIAL_TRAFFIC_LIMIT_GB or 0),
|
||||
"trial_traffic_strategy": getattr(settings, "TRIAL_TRAFFIC_STRATEGY", "NO_RESET"),
|
||||
"email_auth_enabled": settings.email_auth_configured,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _serialize_referral_bonus_details(settings: Settings, lang: str) -> List[Dict[str, Any]]:
|
||||
if getattr(settings, "traffic_sale_mode", False):
|
||||
return []
|
||||
|
||||
details: List[Dict[str, Any]] = []
|
||||
for months, _price in sorted(settings.subscription_options.items()):
|
||||
inviter_days = settings.referral_bonus_inviter.get(months)
|
||||
friend_days = settings.referral_bonus_referee.get(months)
|
||||
if inviter_days is None and friend_days is None:
|
||||
continue
|
||||
details.append(
|
||||
{
|
||||
"months": int(months),
|
||||
"title": _format_months_title(int(months), lang),
|
||||
"inviter_days": int(inviter_days or 0),
|
||||
"friend_days": int(friend_days or 0),
|
||||
}
|
||||
)
|
||||
return details
|
||||
|
||||
|
||||
def _build_webapp_referral_link(
|
||||
base_url: Optional[str],
|
||||
referral_code: Optional[str],
|
||||
) -> Optional[str]:
|
||||
if not base_url or not referral_code:
|
||||
return None
|
||||
parts = urlsplit(base_url)
|
||||
query = dict(parse_qsl(parts.query, keep_blank_values=True))
|
||||
query["ref"] = f"u{referral_code}"
|
||||
return urlunsplit(
|
||||
(
|
||||
parts.scheme,
|
||||
parts.netloc,
|
||||
parts.path or "/",
|
||||
urlencode(query),
|
||||
parts.fragment,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _serialize_subscription(
|
||||
settings: Settings,
|
||||
active: Optional[Dict[str, Any]],
|
||||
local_sub: Optional[Any],
|
||||
lang: str,
|
||||
) -> Dict[str, Any]:
|
||||
if not active:
|
||||
return {
|
||||
"active": False,
|
||||
"status": "INACTIVE",
|
||||
"remaining_text": _format_remaining(0, lang),
|
||||
"days_left": 0,
|
||||
"config_link": None,
|
||||
"connect_url": None,
|
||||
}
|
||||
|
||||
end_date = active.get("end_date")
|
||||
if end_date and end_date.tzinfo is None:
|
||||
end_date = end_date.replace(tzinfo=timezone.utc)
|
||||
|
||||
seconds_left = 0
|
||||
if end_date:
|
||||
seconds_left = max(
|
||||
0,
|
||||
int((end_date - datetime.now(timezone.utc)).total_seconds()),
|
||||
)
|
||||
|
||||
can_topup_regular_traffic = False
|
||||
can_topup_premium_traffic = False
|
||||
can_topup_traffic = False
|
||||
can_topup_devices = False
|
||||
if settings.tariffs_config and active.get("tariff_key"):
|
||||
try:
|
||||
tariff = settings.tariffs_config.require(str(active.get("tariff_key")))
|
||||
packages = settings.tariffs_config.topup_packages_for(tariff)
|
||||
can_topup_regular_traffic = bool(packages and packages.has_any())
|
||||
can_topup_premium_traffic = bool(
|
||||
tariff.premium_squad_uuids
|
||||
and tariff.premium_topup_packages
|
||||
and tariff.premium_topup_packages.has_any()
|
||||
)
|
||||
can_topup_traffic = bool(can_topup_regular_traffic or can_topup_premium_traffic)
|
||||
# max_devices == 0 means unlimited — top-up is pointless in that case.
|
||||
can_topup_devices = bool(
|
||||
tariff.has_hwid_device_packages()
|
||||
and _coerce_int_or_none(active.get("max_devices")) != 0
|
||||
)
|
||||
except Exception:
|
||||
can_topup_regular_traffic = False
|
||||
can_topup_premium_traffic = False
|
||||
can_topup_traffic = False
|
||||
can_topup_devices = False
|
||||
|
||||
return {
|
||||
"active": seconds_left > 0,
|
||||
"status": active.get("status_from_panel") or "UNKNOWN",
|
||||
"end_date": end_date.isoformat() if end_date else None,
|
||||
"end_date_text": end_date.strftime("%d.%m.%Y %H:%M") if end_date else "N/A",
|
||||
"days_left": seconds_left // 86400,
|
||||
"remaining_text": _format_remaining(seconds_left, lang),
|
||||
"config_link": active.get("config_link"),
|
||||
"connect_url": active.get("connect_button_url") or active.get("config_link"),
|
||||
"traffic_limit": _format_bytes(active.get("traffic_limit_bytes"), zero_as_unlimited=True),
|
||||
"traffic_used": _format_bytes(active.get("traffic_used_bytes")),
|
||||
"traffic_limit_bytes": _coerce_int_or_none(active.get("traffic_limit_bytes")),
|
||||
"traffic_used_bytes": _coerce_int_or_none(active.get("traffic_used_bytes")),
|
||||
"tariff_key": active.get("tariff_key"),
|
||||
"tariff_name": active.get("tariff_name"),
|
||||
"tariff_description": active.get("tariff_description"),
|
||||
"premium_title": active.get("premium_title"),
|
||||
"billing_model": active.get("billing_model"),
|
||||
"traffic_limit_strategy": str(active.get("traffic_limit_strategy") or ""),
|
||||
"tier_baseline_bytes": _coerce_int_or_none(active.get("tier_baseline_bytes")),
|
||||
"topup_balance_bytes": _coerce_int_or_none(active.get("topup_balance_bytes")),
|
||||
"premium_limit": _format_bytes(active.get("premium_limit_bytes"), zero_as_unlimited=True),
|
||||
"premium_used": _format_bytes(active.get("premium_used_bytes")),
|
||||
"premium_limit_bytes": _coerce_int_or_none(active.get("premium_limit_bytes")),
|
||||
"premium_used_bytes": _coerce_int_or_none(active.get("premium_used_bytes")),
|
||||
"premium_baseline_bytes": _coerce_int_or_none(active.get("premium_baseline_bytes")),
|
||||
"premium_topup_balance_bytes": _coerce_int_or_none(
|
||||
active.get("premium_topup_balance_bytes")
|
||||
),
|
||||
"premium_topup_used_bytes": _coerce_int_or_none(active.get("premium_topup_used_bytes")),
|
||||
"premium_bonus_bytes": _coerce_int_or_none(active.get("premium_bonus_bytes")) or 0,
|
||||
"regular_bonus_bytes": _coerce_int_or_none(active.get("regular_bonus_bytes")) or 0,
|
||||
"regular_unlimited_override": bool(active.get("regular_unlimited_override")),
|
||||
"premium_unlimited_override": bool(active.get("premium_unlimited_override")),
|
||||
"premium_is_limited": bool(active.get("premium_is_limited")),
|
||||
"premium_squad_labels": list(active.get("premium_squad_labels") or []),
|
||||
"premium_node_labels": list(active.get("premium_node_labels") or []),
|
||||
"can_topup_traffic": can_topup_traffic,
|
||||
"can_topup_regular_traffic": can_topup_regular_traffic,
|
||||
"can_topup_premium_traffic": can_topup_premium_traffic,
|
||||
"can_topup_devices": can_topup_devices,
|
||||
"period_start_at": active.get("period_start_at").isoformat()
|
||||
if active.get("period_start_at")
|
||||
else None,
|
||||
"is_throttled": bool(active.get("is_throttled")),
|
||||
"max_devices": _coerce_int_or_none(active.get("max_devices")),
|
||||
"base_hwid_device_limit": _coerce_int_or_none(active.get("base_hwid_device_limit")),
|
||||
"extra_hwid_devices": _coerce_int_or_none(active.get("extra_hwid_devices")) or 0,
|
||||
"auto_renew_enabled": bool(getattr(local_sub, "auto_renew_enabled", False)),
|
||||
"provider": getattr(local_sub, "provider", None),
|
||||
}
|
||||
|
||||
|
||||
def _serialize_plans(
|
||||
settings: Settings,
|
||||
lang: str,
|
||||
*,
|
||||
subscription_options: Optional[Dict[int, float]] = None,
|
||||
stars_subscription_options: Optional[Dict[int, int]] = None,
|
||||
traffic_packages: Optional[Dict[float, float]] = None,
|
||||
stars_traffic_packages: Optional[Dict[float, int]] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
tariffs_config = settings.tariffs_config
|
||||
if tariffs_config:
|
||||
plans: List[Dict[str, Any]] = []
|
||||
for tariff in tariffs_config.enabled_tariffs:
|
||||
common = {
|
||||
"tariff_key": tariff.key,
|
||||
"tariff_name": tariff.name(lang),
|
||||
"billing_model": tariff.billing_model,
|
||||
"description": tariff.description(lang),
|
||||
"squad_uuids": tariff.squad_uuids,
|
||||
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
|
||||
"hwid_device_limit": tariff.hwid_device_limit,
|
||||
"hwid_device_packages": _serialize_hwid_device_packages(
|
||||
settings,
|
||||
tariff,
|
||||
tariff.hwid_device_packages,
|
||||
lang,
|
||||
),
|
||||
}
|
||||
if tariff.billing_model == "period":
|
||||
for months in sorted(tariff.enabled_periods):
|
||||
price = tariff.period_price(int(months), "rub")
|
||||
stars_price = tariff.period_price(int(months), "stars")
|
||||
if price is None and (stars_price is None or int(stars_price) <= 0):
|
||||
continue
|
||||
plan = {
|
||||
**common,
|
||||
"id": f"{tariff.key}:period:{int(months)}",
|
||||
"sale_mode": "subscription",
|
||||
"months": int(months),
|
||||
"price": float(price or 0),
|
||||
"title": tariff.name(lang),
|
||||
"subtitle": _format_months_title(int(months), lang),
|
||||
"monthly_gb": tariff.monthly_gb,
|
||||
}
|
||||
if stars_price is not None and int(stars_price) > 0:
|
||||
plan["stars_price"] = int(stars_price)
|
||||
plans.append(plan)
|
||||
else:
|
||||
rub_packages = {
|
||||
float(package.gb): float(package.price)
|
||||
for package in (tariff.traffic_packages.rub if tariff.traffic_packages else [])
|
||||
}
|
||||
stars_packages = {
|
||||
float(package.gb): int(float(package.price))
|
||||
for package in (
|
||||
tariff.traffic_packages.stars if tariff.traffic_packages else []
|
||||
)
|
||||
}
|
||||
for traffic_gb in sorted(set(rub_packages) | set(stars_packages)):
|
||||
price = rub_packages.get(traffic_gb)
|
||||
stars_price = stars_packages.get(traffic_gb)
|
||||
if price is None and (stars_price is None or int(stars_price) <= 0):
|
||||
continue
|
||||
traffic_value = float(traffic_gb)
|
||||
plan = {
|
||||
**common,
|
||||
"id": f"{tariff.key}:traffic:{_format_number_for_payload(traffic_value)}",
|
||||
"sale_mode": "traffic_package",
|
||||
"months": int(traffic_value)
|
||||
if traffic_value.is_integer()
|
||||
else traffic_value,
|
||||
"traffic_gb": traffic_value,
|
||||
"price": float(price or 0),
|
||||
"title": tariff.name(lang),
|
||||
"subtitle": _format_traffic_title(traffic_value, lang),
|
||||
}
|
||||
if stars_price is not None and int(stars_price) > 0:
|
||||
plan["stars_price"] = int(stars_price)
|
||||
plans.append(plan)
|
||||
return plans
|
||||
|
||||
if getattr(settings, "traffic_sale_mode", False):
|
||||
active_traffic_packages = traffic_packages or settings.traffic_packages
|
||||
active_stars_traffic_packages = stars_traffic_packages or settings.stars_traffic_packages
|
||||
traffic_units = sorted(set(active_traffic_packages) | set(active_stars_traffic_packages))
|
||||
plans: List[Dict[str, Any]] = []
|
||||
for traffic_gb in traffic_units:
|
||||
price = active_traffic_packages.get(traffic_gb)
|
||||
stars_price = active_stars_traffic_packages.get(traffic_gb)
|
||||
if price is None and (stars_price is None or int(stars_price) <= 0):
|
||||
continue
|
||||
traffic_value = float(traffic_gb)
|
||||
plan = {
|
||||
"months": int(traffic_value) if traffic_value.is_integer() else traffic_value,
|
||||
"traffic_gb": traffic_value,
|
||||
"price": float(price or 0),
|
||||
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
|
||||
"title": _format_traffic_title(traffic_value, lang),
|
||||
"sale_mode": "traffic",
|
||||
}
|
||||
if stars_price is not None and int(stars_price) > 0:
|
||||
plan["stars_price"] = int(stars_price)
|
||||
plans.append(plan)
|
||||
return plans
|
||||
|
||||
active_subscription_options = subscription_options or settings.subscription_options
|
||||
active_stars_subscription_options = (
|
||||
stars_subscription_options or settings.stars_subscription_options
|
||||
)
|
||||
plans: List[Dict[str, Any]] = []
|
||||
for months in sorted(set(active_subscription_options) | set(active_stars_subscription_options)):
|
||||
price = active_subscription_options.get(months)
|
||||
stars_price = active_stars_subscription_options.get(months)
|
||||
if price is None and (stars_price is None or int(stars_price) <= 0):
|
||||
continue
|
||||
plan = {
|
||||
"months": int(months),
|
||||
"price": float(price or 0),
|
||||
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
|
||||
"title": _format_months_title(int(months), lang),
|
||||
"sale_mode": "subscription",
|
||||
}
|
||||
if stars_price is not None and int(stars_price) > 0:
|
||||
plan["stars_price"] = int(stars_price)
|
||||
plans.append(plan)
|
||||
return plans
|
||||
|
||||
|
||||
def _traffic_percent(used: Optional[int], limit: Optional[int]) -> int:
|
||||
used_val = int(used or 0)
|
||||
limit_val = int(limit or 0)
|
||||
if limit_val <= 0:
|
||||
return 0
|
||||
return max(0, min(100, round((used_val / limit_val) * 100)))
|
||||
|
||||
|
||||
def _serialize_topup_packages(
|
||||
settings: Settings,
|
||||
tariff: Any,
|
||||
packages: Optional[Any],
|
||||
lang: str,
|
||||
*,
|
||||
sale_mode: str = "topup",
|
||||
title_prefix: str = "",
|
||||
) -> List[Dict[str, Any]]:
|
||||
rub_packages = {
|
||||
float(package.gb): float(package.price) for package in (packages.rub if packages else [])
|
||||
}
|
||||
stars_packages = {
|
||||
float(package.gb): int(float(package.price))
|
||||
for package in (packages.stars if packages else [])
|
||||
}
|
||||
plans: List[Dict[str, Any]] = []
|
||||
for traffic_gb in sorted(set(rub_packages) | set(stars_packages)):
|
||||
price = rub_packages.get(traffic_gb)
|
||||
stars_price = stars_packages.get(traffic_gb)
|
||||
if price is None and (stars_price is None or int(stars_price) <= 0):
|
||||
continue
|
||||
traffic_value = float(traffic_gb)
|
||||
plan: Dict[str, Any] = {
|
||||
"id": f"{tariff.key}:{sale_mode}:{_format_number_for_payload(traffic_value)}",
|
||||
"tariff_key": tariff.key,
|
||||
"tariff_name": tariff.name(lang),
|
||||
"billing_model": tariff.billing_model,
|
||||
"sale_mode": sale_mode,
|
||||
"months": int(traffic_value) if traffic_value.is_integer() else traffic_value,
|
||||
"traffic_gb": traffic_value,
|
||||
"price": float(price or 0),
|
||||
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
|
||||
"title": f"{title_prefix}{_format_traffic_title(traffic_value, lang)}",
|
||||
"subtitle": tariff.premium_name(lang)
|
||||
if sale_mode == "premium_topup"
|
||||
else tariff.name(lang),
|
||||
}
|
||||
if stars_price is not None and int(stars_price) > 0:
|
||||
plan["stars_price"] = int(stars_price)
|
||||
plans.append(plan)
|
||||
return plans
|
||||
|
||||
|
||||
def _serialize_hwid_device_packages(
|
||||
settings: Settings,
|
||||
tariff: Any,
|
||||
packages: Optional[Any],
|
||||
lang: str,
|
||||
) -> List[Dict[str, Any]]:
|
||||
rub_packages = {
|
||||
int(package.count): float(package.price) for package in (packages.rub if packages else [])
|
||||
}
|
||||
stars_packages = {
|
||||
int(package.count): int(float(package.price))
|
||||
for package in (packages.stars if packages else [])
|
||||
}
|
||||
plans: List[Dict[str, Any]] = []
|
||||
for count in sorted(set(rub_packages) | set(stars_packages)):
|
||||
price = rub_packages.get(count)
|
||||
stars_price = stars_packages.get(count)
|
||||
if price is None and (stars_price is None or int(stars_price) <= 0):
|
||||
continue
|
||||
plan: Dict[str, Any] = {
|
||||
"id": f"{tariff.key}:hwid:{count}",
|
||||
"tariff_key": tariff.key,
|
||||
"tariff_name": tariff.name(lang),
|
||||
"billing_model": tariff.billing_model,
|
||||
"sale_mode": "hwid_devices",
|
||||
"months": int(count),
|
||||
"device_count": int(count),
|
||||
"price": float(price or 0),
|
||||
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
|
||||
"title": f"+{count}",
|
||||
"subtitle": tariff.name(lang),
|
||||
}
|
||||
if stars_price is not None and int(stars_price) > 0:
|
||||
plan["stars_price"] = int(stars_price)
|
||||
plans.append(plan)
|
||||
return plans
|
||||
|
||||
|
||||
def _serialize_tariff_change_target(
|
||||
settings: Settings,
|
||||
config: Any,
|
||||
tariff: Any,
|
||||
options: Dict[str, Any],
|
||||
lang: str,
|
||||
) -> Dict[str, Any]:
|
||||
actions: List[Dict[str, Any]] = []
|
||||
mode = str(options.get("mode") or "")
|
||||
if mode == "period_to_period":
|
||||
actions.append(
|
||||
{
|
||||
"mode": "recalc_days",
|
||||
"kind": "free",
|
||||
"title": "recalc_days",
|
||||
"days_after": int(options.get("recalc_days") or 0),
|
||||
"remaining_days": int(options.get("remaining_days") or 0),
|
||||
}
|
||||
)
|
||||
paid_diff = float(options.get("paid_diff_rub") or 0)
|
||||
if paid_diff > 0:
|
||||
actions.append(
|
||||
{
|
||||
"mode": "paid_diff",
|
||||
"kind": "payment",
|
||||
"title": "paid_diff",
|
||||
"price": paid_diff,
|
||||
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
|
||||
}
|
||||
)
|
||||
elif mode == "period_to_traffic":
|
||||
actions.append(
|
||||
{
|
||||
"mode": "convert_days_to_gb",
|
||||
"kind": "free",
|
||||
"title": "convert_days_to_gb",
|
||||
"converted_gb": float(options.get("converted_gb") or 0),
|
||||
"remaining_days": int(options.get("remaining_days") or 0),
|
||||
}
|
||||
)
|
||||
actions.extend(
|
||||
{
|
||||
"mode": "buy_package",
|
||||
"kind": "payment",
|
||||
"title": f"+{package.gb:g} GB",
|
||||
"traffic_gb": float(package.gb),
|
||||
"price": float(package.price),
|
||||
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
|
||||
}
|
||||
for package in (tariff.traffic_packages.rub if tariff.traffic_packages else [])
|
||||
)
|
||||
else:
|
||||
for months in tariff.enabled_periods:
|
||||
price = tariff.period_price(int(months), "rub")
|
||||
if price:
|
||||
actions.append(
|
||||
{
|
||||
"mode": "buy_period",
|
||||
"kind": "payment",
|
||||
"months": int(months),
|
||||
"title": _format_months_title(int(months), lang),
|
||||
"price": float(price),
|
||||
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
|
||||
}
|
||||
)
|
||||
return {
|
||||
"tariff_key": tariff.key,
|
||||
"title": tariff.name(lang),
|
||||
"description": tariff.description(lang),
|
||||
"billing_model": tariff.billing_model,
|
||||
"monthly_gb": tariff.monthly_gb,
|
||||
"options": options,
|
||||
"actions": actions,
|
||||
}
|
||||
|
||||
|
||||
def _serialize_payment_methods(
|
||||
settings: Settings,
|
||||
app: web.Application,
|
||||
) -> List[Dict[str, Any]]:
|
||||
labels = {
|
||||
"severpay": "SeverPay",
|
||||
"freekassa": "FreeKassa / СБП",
|
||||
"platega_sbp": "Platega · СБП",
|
||||
"platega_crypto": "Platega · Crypto",
|
||||
"yookassa": "Банковская карта",
|
||||
"stars": "Telegram Stars",
|
||||
"cryptopay": "CryptoPay",
|
||||
}
|
||||
methods: List[Dict[str, Any]] = []
|
||||
for method in settings.payment_methods_order:
|
||||
method = method.lower()
|
||||
if (
|
||||
method == "severpay"
|
||||
and settings.SEVERPAY_ENABLED
|
||||
and _service_configured(app, "severpay_service")
|
||||
):
|
||||
methods.append({"id": method, "name": labels[method]})
|
||||
elif (
|
||||
method == "freekassa"
|
||||
and settings.FREEKASSA_ENABLED
|
||||
and _service_configured(app, "freekassa_service")
|
||||
):
|
||||
methods.append({"id": method, "name": labels[method]})
|
||||
elif (
|
||||
method == "platega_sbp"
|
||||
and settings.PLATEGA_ENABLED
|
||||
and settings.PLATEGA_SBP_ENABLED
|
||||
and _service_configured(app, "platega_service")
|
||||
):
|
||||
methods.append({"id": method, "name": labels[method]})
|
||||
elif (
|
||||
method == "platega_crypto"
|
||||
and settings.PLATEGA_ENABLED
|
||||
and settings.PLATEGA_CRYPTO_ENABLED
|
||||
and _service_configured(app, "platega_service")
|
||||
):
|
||||
methods.append({"id": method, "name": labels[method]})
|
||||
elif (
|
||||
method == "yookassa"
|
||||
and settings.YOOKASSA_ENABLED
|
||||
and _service_configured(app, "yookassa_service")
|
||||
):
|
||||
methods.append({"id": method, "name": labels[method]})
|
||||
elif method == "stars" and settings.STARS_ENABLED:
|
||||
methods.append({"id": method, "name": labels[method]})
|
||||
elif (
|
||||
method == "cryptopay"
|
||||
and settings.CRYPTOPAY_ENABLED
|
||||
and _service_configured(app, "cryptopay_service")
|
||||
):
|
||||
methods.append({"id": method, "name": labels[method]})
|
||||
return methods
|
||||
|
||||
|
||||
def _service_configured(app: web.Application, key: str) -> bool:
|
||||
service = app.get(key)
|
||||
return bool(service and getattr(service, "configured", False))
|
||||
@@ -0,0 +1,362 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
import time
|
||||
from typing import Any, Dict, Optional
|
||||
from urllib.parse import parse_qsl
|
||||
|
||||
from config.settings import Settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 5 minutes clock skew tolerance for Telegram clients
|
||||
TELEGRAM_CLOCK_SKEW_SECONDS = 300
|
||||
TELEGRAM_OAUTH_ISSUER = "https://oauth.telegram.org"
|
||||
TELEGRAM_OAUTH_JWKS_URL = "https://oauth.telegram.org/.well-known/jwks.json"
|
||||
TELEGRAM_OAUTH_ALGORITHMS = ["RS256", "ES256", "EdDSA"]
|
||||
|
||||
|
||||
def _urlsafe_b64encode(raw: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
|
||||
|
||||
|
||||
def _urlsafe_b64decode(raw: str) -> bytes:
|
||||
padded = raw + ("=" * (-len(raw) % 4))
|
||||
return base64.urlsafe_b64decode(padded.encode("ascii"))
|
||||
|
||||
|
||||
def _session_secret(settings: Settings) -> bytes:
|
||||
return hmac.new(
|
||||
settings.WEBAPP_SESSION_SECRET.encode("utf-8"),
|
||||
b"remnawave-tg-shop-webapp-session",
|
||||
hashlib.sha256,
|
||||
).digest()
|
||||
|
||||
|
||||
def create_webapp_session_token(settings: Settings, user_id: int) -> str:
|
||||
now = int(time.time())
|
||||
payload = {
|
||||
"sub": int(user_id),
|
||||
"iat": now,
|
||||
"exp": now + max(60, int(settings.WEBAPP_SESSION_TTL_SECONDS)),
|
||||
}
|
||||
payload_part = _urlsafe_b64encode(json.dumps(payload, separators=(",", ":")).encode("utf-8"))
|
||||
signature = hmac.new(
|
||||
_session_secret(settings),
|
||||
payload_part.encode("ascii"),
|
||||
hashlib.sha256,
|
||||
).digest()
|
||||
return f"{payload_part}.{_urlsafe_b64encode(signature)}"
|
||||
|
||||
|
||||
def verify_webapp_session_token(settings: Settings, token: str) -> Optional[int]:
|
||||
if not token or "." not in token:
|
||||
return None
|
||||
|
||||
try:
|
||||
payload_part, signature_part = token.split(".", 1)
|
||||
expected_signature = hmac.new(
|
||||
_session_secret(settings),
|
||||
payload_part.encode("ascii"),
|
||||
hashlib.sha256,
|
||||
).digest()
|
||||
received_signature = _urlsafe_b64decode(signature_part)
|
||||
if not hmac.compare_digest(expected_signature, received_signature):
|
||||
return None
|
||||
|
||||
payload = json.loads(_urlsafe_b64decode(payload_part).decode("utf-8"))
|
||||
if int(payload.get("exp", 0)) < int(time.time()):
|
||||
return None
|
||||
return int(payload["sub"])
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to verify webapp session token: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def create_telegram_oauth_nonce(settings: Settings, *, ttl_seconds: int = 600) -> str:
|
||||
now = int(time.time())
|
||||
payload = {
|
||||
"n": secrets.token_urlsafe(24),
|
||||
"iat": now,
|
||||
"exp": now + max(60, int(ttl_seconds)),
|
||||
}
|
||||
payload_part = _urlsafe_b64encode(json.dumps(payload, separators=(",", ":")).encode("utf-8"))
|
||||
signature = hmac.new(
|
||||
_session_secret(settings),
|
||||
f"telegram-oauth-nonce.{payload_part}".encode("ascii"),
|
||||
hashlib.sha256,
|
||||
).digest()
|
||||
return f"{payload_part}.{_urlsafe_b64encode(signature)}"
|
||||
|
||||
|
||||
def verify_telegram_oauth_nonce(settings: Settings, nonce: str) -> bool:
|
||||
if not nonce or "." not in nonce:
|
||||
return False
|
||||
|
||||
try:
|
||||
payload_part, signature_part = nonce.split(".", 1)
|
||||
expected_signature = hmac.new(
|
||||
_session_secret(settings),
|
||||
f"telegram-oauth-nonce.{payload_part}".encode("ascii"),
|
||||
hashlib.sha256,
|
||||
).digest()
|
||||
received_signature = _urlsafe_b64decode(signature_part)
|
||||
if not hmac.compare_digest(expected_signature, received_signature):
|
||||
return False
|
||||
|
||||
payload = json.loads(_urlsafe_b64decode(payload_part).decode("utf-8"))
|
||||
now = int(time.time())
|
||||
if int(payload.get("exp", 0)) < now:
|
||||
return False
|
||||
if int(payload.get("iat", 0)) > now + TELEGRAM_CLOCK_SKEW_SECONDS:
|
||||
return False
|
||||
return bool(payload.get("n"))
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to verify Telegram OAuth nonce: %s", exc)
|
||||
return False
|
||||
|
||||
|
||||
def create_signed_telegram_oauth_state(
|
||||
settings: Settings,
|
||||
payload: Dict[str, Any],
|
||||
*,
|
||||
ttl_seconds: int = 600,
|
||||
) -> str:
|
||||
now = int(time.time())
|
||||
state_payload = {
|
||||
**payload,
|
||||
"iat": now,
|
||||
"exp": now + max(60, int(ttl_seconds)),
|
||||
}
|
||||
payload_part = _urlsafe_b64encode(
|
||||
json.dumps(state_payload, separators=(",", ":")).encode("utf-8")
|
||||
)
|
||||
signature = hmac.new(
|
||||
_session_secret(settings),
|
||||
f"telegram-oauth-state.{payload_part}".encode("ascii"),
|
||||
hashlib.sha256,
|
||||
).digest()
|
||||
return f"{payload_part}.{_urlsafe_b64encode(signature)}"
|
||||
|
||||
|
||||
def verify_signed_telegram_oauth_state(
|
||||
settings: Settings,
|
||||
state: str,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
if not state or "." not in state:
|
||||
return None
|
||||
|
||||
try:
|
||||
payload_part, signature_part = state.split(".", 1)
|
||||
expected_signature = hmac.new(
|
||||
_session_secret(settings),
|
||||
f"telegram-oauth-state.{payload_part}".encode("ascii"),
|
||||
hashlib.sha256,
|
||||
).digest()
|
||||
received_signature = _urlsafe_b64decode(signature_part)
|
||||
if not hmac.compare_digest(expected_signature, received_signature):
|
||||
return None
|
||||
|
||||
payload = json.loads(_urlsafe_b64decode(payload_part).decode("utf-8"))
|
||||
now = int(time.time())
|
||||
if int(payload.get("exp", 0)) < now:
|
||||
return None
|
||||
if int(payload.get("iat", 0)) > now + TELEGRAM_CLOCK_SKEW_SECONDS:
|
||||
return None
|
||||
return payload
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to verify Telegram OAuth state: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
async def validate_telegram_oauth_id_token(
|
||||
id_token: str,
|
||||
*,
|
||||
client_id: int,
|
||||
expected_nonce: str,
|
||||
max_age_seconds: int,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Validate Telegram OIDC ID token and return a Telegram-like user payload."""
|
||||
|
||||
if not id_token or not client_id or not expected_nonce:
|
||||
return None
|
||||
|
||||
try:
|
||||
import jwt
|
||||
from jwt import PyJWKClient
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"PyJWT is not installed; Telegram OAuth ID token validation is unavailable: %s",
|
||||
exc,
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
jwks_client = PyJWKClient(TELEGRAM_OAUTH_JWKS_URL)
|
||||
signing_key = await asyncio.to_thread(
|
||||
jwks_client.get_signing_key_from_jwt,
|
||||
id_token,
|
||||
)
|
||||
claims = await asyncio.to_thread(
|
||||
jwt.decode,
|
||||
id_token,
|
||||
signing_key.key,
|
||||
algorithms=TELEGRAM_OAUTH_ALGORITHMS,
|
||||
audience=str(client_id),
|
||||
issuer=TELEGRAM_OAUTH_ISSUER,
|
||||
leeway=TELEGRAM_CLOCK_SKEW_SECONDS,
|
||||
options={"require": ["exp", "iat", "iss", "aud"]},
|
||||
)
|
||||
|
||||
if not hmac.compare_digest(str(claims.get("nonce") or ""), expected_nonce):
|
||||
logger.warning("Telegram OAuth nonce mismatch.")
|
||||
return None
|
||||
|
||||
now = int(time.time())
|
||||
issued_at = int(claims.get("iat") or 0)
|
||||
max_age = max(60, int(max_age_seconds))
|
||||
if issued_at > now + TELEGRAM_CLOCK_SKEW_SECONDS or now - issued_at > max_age:
|
||||
logger.warning("Telegram OAuth ID token is stale.")
|
||||
return None
|
||||
|
||||
telegram_id_raw = claims.get("id")
|
||||
if not telegram_id_raw:
|
||||
return None
|
||||
telegram_id = int(telegram_id_raw)
|
||||
|
||||
full_name = str(claims.get("name") or "").strip()
|
||||
first_name = str(claims.get("given_name") or "").strip()
|
||||
last_name = str(claims.get("family_name") or "").strip()
|
||||
if full_name and not first_name:
|
||||
name_parts = full_name.split(None, 1)
|
||||
first_name = name_parts[0]
|
||||
if len(name_parts) > 1 and not last_name:
|
||||
last_name = name_parts[1]
|
||||
|
||||
return {
|
||||
"id": telegram_id,
|
||||
"username": claims.get("preferred_username") or claims.get("username"),
|
||||
"first_name": first_name or full_name or "Telegram",
|
||||
"last_name": last_name,
|
||||
"photo_url": claims.get("picture"),
|
||||
"language_code": claims.get("locale"),
|
||||
}
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to validate Telegram OAuth ID token: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def validate_telegram_webapp_init_data(
|
||||
init_data: str,
|
||||
bot_token: str,
|
||||
*,
|
||||
max_age_seconds: int,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Validate Telegram Mini App initData and return the trusted user payload."""
|
||||
|
||||
try:
|
||||
parsed_data = dict(parse_qsl(init_data or "", keep_blank_values=True))
|
||||
received_hash = parsed_data.pop("hash", None)
|
||||
if not received_hash:
|
||||
return None
|
||||
|
||||
data_check_string = "\n".join(
|
||||
f"{key}={value}" for key, value in sorted(parsed_data.items())
|
||||
)
|
||||
secret_key = hmac.new(
|
||||
b"WebAppData",
|
||||
bot_token.encode("utf-8"),
|
||||
hashlib.sha256,
|
||||
).digest()
|
||||
calculated_hash = hmac.new(
|
||||
secret_key,
|
||||
data_check_string.encode("utf-8"),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
if not hmac.compare_digest(calculated_hash, received_hash):
|
||||
logger.warning("Telegram WebApp initData hash mismatch.")
|
||||
return None
|
||||
|
||||
auth_date_raw = parsed_data.get("auth_date")
|
||||
if auth_date_raw:
|
||||
auth_date = int(auth_date_raw)
|
||||
now = int(time.time())
|
||||
max_age = max(60, int(max_age_seconds))
|
||||
if auth_date > now + TELEGRAM_CLOCK_SKEW_SECONDS or now - auth_date > max_age:
|
||||
logger.warning("Telegram WebApp initData auth_date is stale.")
|
||||
return None
|
||||
|
||||
user_json = parsed_data.get("user")
|
||||
if not user_json:
|
||||
return None
|
||||
user_data = json.loads(user_json)
|
||||
if not user_data.get("id"):
|
||||
return None
|
||||
if parsed_data.get("start_param"):
|
||||
user_data["start_param"] = parsed_data.get("start_param")
|
||||
return user_data
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to validate Telegram WebApp initData: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def validate_telegram_login_widget_data(
|
||||
auth_data: Any,
|
||||
bot_token: str,
|
||||
*,
|
||||
max_age_seconds: int,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Validate Telegram Login Widget data and return the trusted user payload."""
|
||||
|
||||
try:
|
||||
if isinstance(auth_data, str):
|
||||
parsed_data = dict(parse_qsl(auth_data or "", keep_blank_values=True))
|
||||
elif isinstance(auth_data, dict):
|
||||
parsed_data = {
|
||||
str(key): str(value) for key, value in auth_data.items() if value is not None
|
||||
}
|
||||
else:
|
||||
return None
|
||||
|
||||
received_hash = str(parsed_data.pop("hash", "") or "")
|
||||
if not received_hash:
|
||||
return None
|
||||
|
||||
data_check_string = "\n".join(
|
||||
f"{key}={value}" for key, value in sorted(parsed_data.items())
|
||||
)
|
||||
secret_key = hashlib.sha256(bot_token.encode("utf-8")).digest()
|
||||
calculated_hash = hmac.new(
|
||||
secret_key,
|
||||
data_check_string.encode("utf-8"),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
if not hmac.compare_digest(calculated_hash, received_hash):
|
||||
logger.warning("Telegram Login Widget hash mismatch.")
|
||||
return None
|
||||
|
||||
auth_date_raw = parsed_data.get("auth_date")
|
||||
if auth_date_raw:
|
||||
auth_date = int(auth_date_raw)
|
||||
now = int(time.time())
|
||||
max_age = max(60, int(max_age_seconds))
|
||||
if auth_date > now + TELEGRAM_CLOCK_SKEW_SECONDS or now - auth_date > max_age:
|
||||
logger.warning("Telegram Login Widget auth_date is stale.")
|
||||
return None
|
||||
|
||||
user_id_raw = parsed_data.get("id")
|
||||
if not user_id_raw:
|
||||
return None
|
||||
int(user_id_raw)
|
||||
|
||||
if not parsed_data.get("first_name"):
|
||||
return None
|
||||
|
||||
return parsed_data
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to validate Telegram Login Widget data: %s", exc)
|
||||
return None
|
||||
@@ -1,15 +1,14 @@
|
||||
from typing import List, Union
|
||||
|
||||
from aiogram.filters import Filter
|
||||
from aiogram.types import Message, CallbackQuery, User
|
||||
from aiogram.types import CallbackQuery, Message, User
|
||||
|
||||
|
||||
class AdminFilter(Filter):
|
||||
|
||||
def __init__(self, admin_ids: List[int]):
|
||||
self.admin_ids = admin_ids
|
||||
|
||||
async def __call__(self, event: Union[Message, CallbackQuery],
|
||||
event_from_user: User) -> bool:
|
||||
async def __call__(self, event: Union[Message, CallbackQuery], event_from_user: User) -> bool:
|
||||
if not event_from_user:
|
||||
return False
|
||||
if not self.admin_ids:
|
||||
@@ -1,14 +1,7 @@
|
||||
from aiogram import Router
|
||||
|
||||
from . import common
|
||||
from . import broadcast
|
||||
from . import ads, broadcast, common, logs_admin, payments, statistics, sync_admin, user_management
|
||||
from .promo import promo_router_aggregate
|
||||
from . import user_management
|
||||
from . import statistics
|
||||
from . import sync_admin
|
||||
from . import logs_admin
|
||||
from . import payments
|
||||
from . import ads
|
||||
|
||||
admin_router_aggregate = Router(name="admin_features_router")
|
||||
|
||||
@@ -22,4 +15,4 @@ admin_router_aggregate.include_router(logs_admin.router)
|
||||
admin_router_aggregate.include_router(payments.router)
|
||||
admin_router_aggregate.include_router(ads.router)
|
||||
|
||||
__all__ = ("admin_router_aggregate", )
|
||||
__all__ = ("admin_router_aggregate",)
|
||||
@@ -1,14 +1,15 @@
|
||||
import logging
|
||||
from aiogram import Router, F, types
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import F, Router, types
|
||||
from aiogram.filters import StateFilter
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from typing import Optional
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config.settings import Settings
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from db.dal import ad_dal
|
||||
from bot.states.admin_states import AdminStates
|
||||
from config.settings import Settings
|
||||
from db.dal import ad_dal
|
||||
|
||||
router = Router(name="admin_ads_router")
|
||||
|
||||
@@ -17,7 +18,9 @@ PAGE_SIZE = 5
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_action:ads")
|
||||
async def show_ads_menu(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession):
|
||||
async def show_ads_menu(
|
||||
callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
@@ -35,14 +38,20 @@ async def show_ads_menu(callback: types.CallbackQuery, settings: Settings, i18n_
|
||||
if total_count == 0:
|
||||
text = overview + "\n\n" + _("admin_ads_empty")
|
||||
from bot.keyboards.inline.admin_keyboards import get_ads_menu_keyboard
|
||||
|
||||
reply_markup = get_ads_menu_keyboard(i18n, current_lang)
|
||||
else:
|
||||
current_page = 0
|
||||
total_pages = max(1, (total_count + PAGE_SIZE - 1) // PAGE_SIZE)
|
||||
campaigns = await ad_dal.list_campaigns_paged(session, page=current_page, page_size=PAGE_SIZE)
|
||||
campaigns = await ad_dal.list_campaigns_paged(
|
||||
session, page=current_page, page_size=PAGE_SIZE
|
||||
)
|
||||
text = overview + "\n\n" + _("admin_ads_header")
|
||||
from bot.keyboards.inline.admin_keyboards import get_ads_list_keyboard
|
||||
reply_markup = get_ads_list_keyboard(i18n, current_lang, campaigns, current_page, total_pages)
|
||||
|
||||
reply_markup = get_ads_list_keyboard(
|
||||
i18n, current_lang, campaigns, current_page, total_pages
|
||||
)
|
||||
await callback.message.edit_text(text, reply_markup=reply_markup)
|
||||
try:
|
||||
await callback.answer()
|
||||
@@ -51,7 +60,9 @@ async def show_ads_menu(callback: types.CallbackQuery, settings: Settings, i18n_
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("admin_ads:page:"))
|
||||
async def ads_list_pagination(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession):
|
||||
async def ads_list_pagination(
|
||||
callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
@@ -65,7 +76,11 @@ async def ads_list_pagination(callback: types.CallbackQuery, settings: Settings,
|
||||
page = 0
|
||||
|
||||
totals = await ad_dal.get_totals(session)
|
||||
overview = _("admin_ads_overview", revenue=f"{totals.get('revenue', 0.0):.2f}", cost=f"{totals.get('cost', 0.0):.2f}")
|
||||
overview = _(
|
||||
"admin_ads_overview",
|
||||
revenue=f"{totals.get('revenue', 0.0):.2f}",
|
||||
cost=f"{totals.get('cost', 0.0):.2f}",
|
||||
)
|
||||
total_count = await ad_dal.count_campaigns(session)
|
||||
total_pages = max(1, (total_count + PAGE_SIZE - 1) // PAGE_SIZE)
|
||||
page = max(0, min(page, total_pages - 1))
|
||||
@@ -73,6 +88,7 @@ async def ads_list_pagination(callback: types.CallbackQuery, settings: Settings,
|
||||
campaigns = await ad_dal.list_campaigns_paged(session, page=page, page_size=PAGE_SIZE)
|
||||
text = overview + "\n\n" + _("admin_ads_header")
|
||||
from bot.keyboards.inline.admin_keyboards import get_ads_list_keyboard
|
||||
|
||||
reply_markup = get_ads_list_keyboard(i18n, current_lang, campaigns, page, total_pages)
|
||||
try:
|
||||
await callback.message.edit_text(text, reply_markup=reply_markup)
|
||||
@@ -83,7 +99,9 @@ async def ads_list_pagination(callback: types.CallbackQuery, settings: Settings,
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("admin_ads:card:"))
|
||||
async def show_ad_card(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession):
|
||||
async def show_ad_card(
|
||||
callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
@@ -118,6 +136,7 @@ async def show_ad_card(callback: types.CallbackQuery, settings: Settings, i18n_d
|
||||
)
|
||||
|
||||
from bot.keyboards.inline.admin_keyboards import get_ad_card_keyboard
|
||||
|
||||
reply_markup = get_ad_card_keyboard(i18n, current_lang, camp.ad_campaign_id, back_page)
|
||||
try:
|
||||
await callback.message.edit_text(text, reply_markup=reply_markup, parse_mode="HTML")
|
||||
@@ -144,6 +163,7 @@ async def ads_delete_prompt(callback: types.CallbackQuery, settings: Settings, i
|
||||
return
|
||||
|
||||
from bot.keyboards.inline.admin_keyboards import get_confirmation_keyboard
|
||||
|
||||
confirm_text = i18n.gettext(current_lang, "admin_ads_delete_confirm", id=camp_id)
|
||||
kb = get_confirmation_keyboard(
|
||||
yes_callback_data=f"admin_ads:delete_confirm:{camp_id}:{back_page}",
|
||||
@@ -159,7 +179,9 @@ async def ads_delete_prompt(callback: types.CallbackQuery, settings: Settings, i
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("admin_ads:delete_cancel:"))
|
||||
async def ads_delete_cancel(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession):
|
||||
async def ads_delete_cancel(
|
||||
callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession
|
||||
):
|
||||
# Return to the ad card view
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
@@ -178,7 +200,7 @@ async def ads_delete_cancel(callback: types.CallbackQuery, settings: Settings, i
|
||||
|
||||
camp = await ad_dal.get_campaign_by_id(session, camp_id)
|
||||
if not camp:
|
||||
await callback.answer(_("admin_ads_not_found", default="Кампания не найдена."), show_alert=True)
|
||||
await callback.answer(_("admin_ads_not_found"), show_alert=True)
|
||||
return
|
||||
try:
|
||||
stats = await ad_dal.get_campaign_stats(session, camp_id)
|
||||
@@ -197,6 +219,7 @@ async def ads_delete_cancel(callback: types.CallbackQuery, settings: Settings, i
|
||||
revenue=f"{stats['revenue']:.2f}",
|
||||
)
|
||||
from bot.keyboards.inline.admin_keyboards import get_ad_card_keyboard
|
||||
|
||||
reply_markup = get_ad_card_keyboard(i18n, current_lang, camp.ad_campaign_id, back_page)
|
||||
try:
|
||||
await callback.message.edit_text(text, reply_markup=reply_markup, parse_mode="HTML")
|
||||
@@ -206,7 +229,9 @@ async def ads_delete_cancel(callback: types.CallbackQuery, settings: Settings, i
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("admin_ads:delete_confirm:"))
|
||||
async def ads_delete_confirm(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession):
|
||||
async def ads_delete_confirm(
|
||||
callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
@@ -224,7 +249,7 @@ async def ads_delete_confirm(callback: types.CallbackQuery, settings: Settings,
|
||||
|
||||
existed = await ad_dal.delete_campaign(session, camp_id)
|
||||
if not existed:
|
||||
await callback.answer(_("admin_ads_not_found", default="Кампания не найдена."), show_alert=True)
|
||||
await callback.answer(_("admin_ads_not_found"), show_alert=True)
|
||||
return
|
||||
await session.commit()
|
||||
|
||||
@@ -241,15 +266,21 @@ async def ads_delete_confirm(callback: types.CallbackQuery, settings: Settings,
|
||||
campaigns = await ad_dal.list_campaigns_paged(session, page=page, page_size=PAGE_SIZE)
|
||||
text = overview + "\n\n" + _("admin_ads_header")
|
||||
from bot.keyboards.inline.admin_keyboards import get_ads_list_keyboard
|
||||
|
||||
reply_markup = get_ads_list_keyboard(i18n, current_lang, campaigns, page, total_pages)
|
||||
try:
|
||||
await callback.message.edit_text(text, reply_markup=reply_markup)
|
||||
await callback.answer(_("admin_ads_deleted_success"), show_alert=True)
|
||||
except Exception:
|
||||
await callback.answer(_("admin_ads_deleted_success"), show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_action:ads_create")
|
||||
async def ads_create_start(callback: types.CallbackQuery, state: FSMContext, settings: Settings, i18n_data: dict):
|
||||
async def ads_create_start(
|
||||
callback: types.CallbackQuery, state: FSMContext, settings: Settings, i18n_data: dict
|
||||
):
|
||||
from bot.states.admin_states import AdminStates
|
||||
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
@@ -274,7 +305,13 @@ async def ads_create_start(callback: types.CallbackQuery, state: FSMContext, set
|
||||
),
|
||||
F.text,
|
||||
)
|
||||
async def ads_create_flow(message: types.Message, state: FSMContext, settings: Settings, i18n_data: dict, session: AsyncSession):
|
||||
async def ads_create_flow(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
session: AsyncSession,
|
||||
):
|
||||
current_state = await state.get_state()
|
||||
if current_state not in (
|
||||
AdminStates.waiting_for_ad_source.state,
|
||||
@@ -301,6 +338,7 @@ async def ads_create_flow(message: types.Message, state: FSMContext, settings: S
|
||||
start_param = message.text.strip()
|
||||
# Allow alnum underscore dash only
|
||||
import re as _re
|
||||
|
||||
if not _re.match(r"^[A-Za-z0-9_\-]{2,64}$", start_param):
|
||||
await message.answer(_("admin_ads_invalid_start_param"))
|
||||
return
|
||||
@@ -354,6 +392,7 @@ async def ads_create_flow(message: types.Message, state: FSMContext, settings: S
|
||||
)
|
||||
# Offer back to ads menu
|
||||
from bot.keyboards.inline.admin_keyboards import get_ads_menu_keyboard
|
||||
await message.answer(_("admin_ads_back_to_menu_hint"), reply_markup=get_ads_menu_keyboard(i18n, current_lang))
|
||||
|
||||
|
||||
await message.answer(
|
||||
_("admin_ads_back_to_menu_hint"), reply_markup=get_ads_menu_keyboard(i18n, current_lang)
|
||||
)
|
||||
@@ -1,25 +1,28 @@
|
||||
import logging
|
||||
import asyncio
|
||||
from aiogram import Router, F, types, Bot
|
||||
from aiogram.exceptions import TelegramRetryAfter, TelegramBadRequest
|
||||
|
||||
from aiogram.fsm.context import FSMContext
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import Bot, F, Router, types
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config.settings import Settings
|
||||
|
||||
from db.dal import user_dal, message_log_dal
|
||||
|
||||
from bot.states.admin_states import AdminStates
|
||||
from bot.keyboards.inline.admin_keyboards import (
|
||||
get_broadcast_confirmation_keyboard,
|
||||
get_back_to_admin_panel_keyboard,
|
||||
get_admin_panel_keyboard,
|
||||
get_back_to_admin_panel_keyboard,
|
||||
get_broadcast_confirmation_keyboard,
|
||||
)
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.states.admin_states import AdminStates
|
||||
from bot.utils import (
|
||||
MessageContent,
|
||||
get_message_content,
|
||||
send_message_by_type,
|
||||
send_message_via_queue,
|
||||
)
|
||||
from bot.utils.message_queue import get_queue_manager
|
||||
from bot.utils import get_message_content, send_message_by_type, send_message_via_queue, MessageContent
|
||||
from config.settings import Settings
|
||||
from db.dal import message_log_dal, user_dal
|
||||
|
||||
router = Router(name="admin_broadcast_router")
|
||||
|
||||
@@ -48,9 +51,7 @@ async def broadcast_message_prompt_handler(
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
|
||||
)
|
||||
except Exception as e:
|
||||
logging.warning(
|
||||
f"Could not edit message for broadcast prompt: {e}. Sending new."
|
||||
)
|
||||
logging.warning(f"Could not edit message for broadcast prompt: {e}. Sending new.")
|
||||
await callback.message.answer(
|
||||
prompt_text,
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
|
||||
@@ -100,8 +101,8 @@ async def process_broadcast_message_handler(
|
||||
# Для медиа-сообщений используем caption_entities, для текста - entities
|
||||
if content.content_type == "text":
|
||||
await send_message_by_type(
|
||||
bot,
|
||||
chat_id=message.chat.id,
|
||||
bot,
|
||||
chat_id=message.chat.id,
|
||||
content=content,
|
||||
parse_mode="HTML",
|
||||
entities=entities,
|
||||
@@ -110,8 +111,8 @@ async def process_broadcast_message_handler(
|
||||
)
|
||||
else:
|
||||
await send_message_by_type(
|
||||
bot,
|
||||
chat_id=message.chat.id,
|
||||
bot,
|
||||
chat_id=message.chat.id,
|
||||
content=content,
|
||||
parse_mode="HTML",
|
||||
caption_entities=entities,
|
||||
@@ -122,7 +123,6 @@ async def process_broadcast_message_handler(
|
||||
await message.answer(
|
||||
_(
|
||||
"admin_broadcast_invalid_html",
|
||||
default="❌ Некорректный HTML в сообщении. Пожалуйста, отправьте корректный HTML (поддерживаются теги Telegram) или уберите теги.\nОшибка: {error}",
|
||||
error=str(e),
|
||||
)
|
||||
)
|
||||
@@ -160,26 +160,20 @@ async def change_broadcast_target_handler(
|
||||
return
|
||||
|
||||
await state.update_data(broadcast_target=new_target)
|
||||
user_fsm_data = await state.get_data()
|
||||
await state.get_data()
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
confirmation_prompt = _(
|
||||
"admin_broadcast_confirm_prompt_short"
|
||||
)
|
||||
confirmation_prompt = _("admin_broadcast_confirm_prompt_short")
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
confirmation_prompt,
|
||||
reply_markup=get_broadcast_confirmation_keyboard(
|
||||
current_lang, i18n, target=new_target
|
||||
),
|
||||
reply_markup=get_broadcast_confirmation_keyboard(current_lang, i18n, target=new_target),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
F.data == "admin_action:main", AdminStates.waiting_for_broadcast_message
|
||||
)
|
||||
@router.callback_query(F.data == "admin_action:main", AdminStates.waiting_for_broadcast_message)
|
||||
async def cancel_broadcast_at_prompt_stage(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
@@ -195,9 +189,7 @@ async def cancel_broadcast_at_prompt_stage(
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
_("admin_broadcast_cancelled_nav_back"), reply_markup=None
|
||||
)
|
||||
await callback.message.edit_text(_("admin_broadcast_cancelled_nav_back"), reply_markup=None)
|
||||
except Exception:
|
||||
await callback.message.answer(_("admin_broadcast_cancelled_nav_back"))
|
||||
|
||||
@@ -237,16 +229,14 @@ async def confirm_broadcast_callback_handler(
|
||||
content = MessageContent(
|
||||
content_type=user_fsm_data.get("broadcast_content_type", "text"),
|
||||
file_id=user_fsm_data.get("broadcast_file_id"),
|
||||
text=user_fsm_data.get("broadcast_text")
|
||||
text=user_fsm_data.get("broadcast_text"),
|
||||
)
|
||||
entities = user_fsm_data.get("broadcast_entities", [])
|
||||
|
||||
|
||||
if not content.text and content.content_type == "text":
|
||||
await callback.message.edit_text(_("admin_broadcast_error_no_message"))
|
||||
await state.clear()
|
||||
await callback.answer(
|
||||
_("admin_broadcast_error_no_message_alert"), show_alert=True
|
||||
)
|
||||
await callback.answer(_("admin_broadcast_error_no_message_alert"), show_alert=True)
|
||||
return
|
||||
|
||||
await callback.message.edit_text(_("admin_broadcast_sending_started"), reply_markup=None)
|
||||
@@ -264,13 +254,15 @@ async def confirm_broadcast_callback_handler(
|
||||
failed_count = 0
|
||||
admin_user = callback.from_user
|
||||
logging.info(
|
||||
f"Admin {admin_user.id} broadcasting '{(content.text or '')[:50]}...' to {len(user_ids)} users."
|
||||
f"Admin {admin_user.id} broadcasting '{(content.text or '')[:50]}...' to {len(user_ids)} users." # noqa: E501
|
||||
)
|
||||
|
||||
# Get message queue manager
|
||||
queue_manager = get_queue_manager()
|
||||
if not queue_manager:
|
||||
await callback.message.edit_text("❌ Ошибка: система очередей не инициализирована", reply_markup=None)
|
||||
await callback.message.edit_text(
|
||||
"❌ Ошибка: система очередей не инициализирована", reply_markup=None
|
||||
)
|
||||
return
|
||||
|
||||
# Queue all messages for sending
|
||||
@@ -279,8 +271,8 @@ async def confirm_broadcast_callback_handler(
|
||||
# Для медиа-сообщений используем caption_entities, для текста - entities
|
||||
if content.content_type == "text":
|
||||
await send_message_via_queue(
|
||||
queue_manager,
|
||||
uid,
|
||||
queue_manager,
|
||||
uid,
|
||||
content,
|
||||
parse_mode="HTML",
|
||||
entities=entities,
|
||||
@@ -288,15 +280,15 @@ async def confirm_broadcast_callback_handler(
|
||||
)
|
||||
else:
|
||||
await send_message_via_queue(
|
||||
queue_manager,
|
||||
uid,
|
||||
queue_manager,
|
||||
uid,
|
||||
content,
|
||||
parse_mode="HTML",
|
||||
caption_entities=entities,
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
sent_count += 1
|
||||
|
||||
|
||||
# Log successful queuing
|
||||
await message_log_dal.create_message_log(
|
||||
session,
|
||||
@@ -305,16 +297,14 @@ async def confirm_broadcast_callback_handler(
|
||||
"telegram_username": admin_user.username,
|
||||
"telegram_first_name": admin_user.first_name,
|
||||
"event_type": "admin_broadcast_queued",
|
||||
"content": f"To user {uid}: [{content.content_type}] {(content.text or '')[:70]}...",
|
||||
"content": f"To user {uid}: [{content.content_type}] {(content.text or '')[:70]}...", # noqa: E501
|
||||
"is_admin_event": True,
|
||||
"target_user_id": uid,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
failed_count += 1
|
||||
logging.warning(
|
||||
f"Failed to queue broadcast to {uid}: {type(e).__name__} – {e}"
|
||||
)
|
||||
logging.warning(f"Failed to queue broadcast to {uid}: {type(e).__name__} – {e}")
|
||||
await message_log_dal.create_message_log(
|
||||
session,
|
||||
{
|
||||
@@ -347,15 +337,6 @@ async def confirm_broadcast_callback_handler(
|
||||
total_failed = failed_count + dynamic_failed
|
||||
return _(
|
||||
"broadcast_queue_result",
|
||||
default=(
|
||||
"🚀 Рассылка поставлена в очередь!\n"
|
||||
"📤 В очередь добавлено: {sent_count}\n"
|
||||
"❌ Ошибок: {failed_count}\n\n"
|
||||
"📊 Статус очередей:\n"
|
||||
"👥 Очередь пользователей: {user_queue_size} сообщений\n"
|
||||
"📢 Очередь групп: {group_queue_size} сообщений\n\n"
|
||||
"ℹ️ Сообщения будут отправлены автоматически с соблюдением лимитов Telegram."
|
||||
),
|
||||
sent_count=sent_count,
|
||||
failed_count=total_failed,
|
||||
user_queue_size=stats["user_queue_size"],
|
||||
@@ -397,14 +378,10 @@ async def confirm_broadcast_callback_handler(
|
||||
if "message is not modified" in str(e):
|
||||
last_text = new_text
|
||||
else:
|
||||
logging.debug(
|
||||
"Broadcast queue auto-update stopped: %s", e
|
||||
)
|
||||
logging.debug("Broadcast queue auto-update stopped: %s", e)
|
||||
break
|
||||
except Exception as e:
|
||||
logging.debug(
|
||||
"Broadcast queue auto-update unexpected error: %s", e
|
||||
)
|
||||
logging.debug("Broadcast queue auto-update unexpected error: %s", e)
|
||||
break
|
||||
|
||||
if queues_drained:
|
||||