Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b7c58a6c9 | ||
|
|
19aa2ec9c9 | ||
|
|
c4c5b8e3a0 | ||
|
|
33a7dbc0e6 | ||
|
|
72921b9a8f | ||
|
|
835436fa1a | ||
|
|
648f4ba4bc | ||
|
|
f5023dc46b | ||
|
|
4c0a798050 | ||
|
|
cfe7c4ec5f | ||
|
|
c82779e15b | ||
|
|
9e56715e77 | ||
|
|
2254b9ad19 | ||
|
|
81707d9c7c | ||
|
|
8a38524774 | ||
|
|
87d3f1b410 | ||
|
|
1094a65852 | ||
|
|
0ab4b11f97 | ||
|
|
6d4fb5888f | ||
|
|
e7301e2c48 | ||
|
|
f69f6546f0 | ||
|
|
7fe53993aa | ||
|
|
028f0680c6 | ||
|
|
b3f67a5398 | ||
|
|
db3611487e | ||
|
|
f2c722bdfc | ||
|
|
ccb125ab9d | ||
|
|
a300a4a9c0 | ||
|
|
044cb4de7e | ||
|
|
3405d12696 | ||
|
|
11cd35373e | ||
|
|
5001185bf8 | ||
|
|
a7f298743d | ||
|
|
8192eaf55b | ||
|
|
5a0e0033ec | ||
|
|
7521f89ffd | ||
|
|
d8a1da1f13 | ||
|
|
3b846f0d44 | ||
|
|
f5006af6c0 | ||
|
|
3152631911 | ||
|
|
1d9f069f45 | ||
|
|
707f569f62 | ||
|
|
e3643ee9a0 | ||
|
|
49cf5ebad8 | ||
|
|
b79f5f4d7d | ||
|
|
d870915dc0 | ||
|
|
fcd494f815 | ||
|
|
a7728f80d0 | ||
|
|
9eb6387973 | ||
|
|
7be5510208 | ||
|
|
e0269bbf86 | ||
|
|
e76558d68b | ||
|
|
cceba2e98e | ||
|
|
1e93d25a40 | ||
|
|
74ef9d31b4 | ||
|
|
15b3f9c084 | ||
|
|
87efa6c77b | ||
|
|
07b35036e7 | ||
|
|
c9c12b9ed6 | ||
|
|
960754c54e | ||
|
|
51c9c8b4f0 | ||
|
|
fff7e90e14 | ||
|
|
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 |
@@ -12,7 +12,8 @@ scratch/
|
|||||||
.claude/
|
.claude/
|
||||||
*.local.*
|
*.local.*
|
||||||
node_modules/
|
node_modules/
|
||||||
docker-compose-dev.yml
|
frontend/node_modules/
|
||||||
|
deploy/compose/docker-compose-dev.yml
|
||||||
data/*
|
data/*
|
||||||
!data/tariffs.example.json
|
!data/tariffs.example.json
|
||||||
|
|
||||||
@@ -24,15 +25,15 @@ data/*
|
|||||||
|
|
||||||
# Docker
|
# Docker
|
||||||
docker-compose.yml
|
docker-compose.yml
|
||||||
Dockerfile
|
deploy/compose/*.yml
|
||||||
.docker
|
|
||||||
.dockerignore
|
.dockerignore
|
||||||
tmp/
|
tmp/
|
||||||
|
|
||||||
# WebApp build artifacts (regenerated inside Docker)
|
# WebApp build artifacts (regenerated inside Docker)
|
||||||
bot/app/web/templates/subscription_webapp.css
|
backend/bot/app/web/templates/subscription_webapp.css
|
||||||
bot/app/web/templates/subscription_webapp.js
|
backend/bot/app/web/templates/subscription_webapp.js
|
||||||
bot/app/web/templates/subscription_webapp.min.*.js
|
backend/bot/app/web/templates/subscription_webapp.min.*.js
|
||||||
|
backend/bot/app/web/templates/subscription_webapp.*.css
|
||||||
|
|
||||||
# Byte-compiled / optimized / DLL files
|
# Byte-compiled / optimized / DLL files
|
||||||
**/__pycache__/
|
**/__pycache__/
|
||||||
|
|||||||
@@ -1,223 +1,72 @@
|
|||||||
# Telegram Bot Token and Admin IDs
|
# Minimal bootstrap env.
|
||||||
BOT_TOKEN=your_bot_token_here # Telegram bot token
|
# Most product settings are configured later in Web App admin:
|
||||||
ADMIN_IDS=comma_separated_admin_ids # Your telegram ID
|
# Admin -> System -> Settings, Admin -> System -> Tariffs, Admin -> Appearance.
|
||||||
|
# Full reference: docs/env-vars.md
|
||||||
|
|
||||||
# PostgreSQL Database Connection Settings
|
# Telegram bot token from @BotFather.
|
||||||
POSTGRES_USER= # Required: database user name
|
# Example: 1234567890:AA...
|
||||||
POSTGRES_PASSWORD= # Required: database password
|
BOT_TOKEN=your_bot_token_here
|
||||||
POSTGRES_HOST=remnawave-minishop-db # Database container name
|
|
||||||
POSTGRES_PORT=5432 # Port
|
|
||||||
POSTGRES_DB=postgres # Database name
|
|
||||||
|
|
||||||
# Localization and Display
|
# Telegram numeric user IDs allowed to open the admin panel.
|
||||||
DEFAULT_LANGUAGE="ru" # or "en"
|
# Use commas for several admins, for example: 123456789,987654321
|
||||||
DEFAULT_CURRENCY_SYMBOL="RUB" # e.g., RUB, USD, EUR
|
ADMIN_IDS=123456789
|
||||||
|
|
||||||
# External Links
|
# Public HTTPS base URL of the backend webhook server.
|
||||||
SUPPORT_LINK=https://t.me/your_support_link # Link to the support chat
|
# Telegram, payment providers and Remnawave call webhook endpoints under this domain.
|
||||||
SERVER_STATUS_URL=https://status.yourdomain.tld/status/your_service # Link to the server status page
|
# This is usually the backend/API domain, not the Mini App frontend domain.
|
||||||
TERMS_OF_SERVICE_URL=https://example.com/tos # Link to the terms of service
|
# Example: https://bot.yourdomain.tld
|
||||||
PRIVACY_POLICY_URL=https://example.com/privacy # Link to the privacy policy
|
|
||||||
USER_AGREEMENT_URL=https://example.com/user-agreement # Link to the user agreement
|
|
||||||
SUBSCRIPTION_MINI_APP_URL= # Public URL of the subscription Mini App, e.g. https://app.yourdomain.tld/
|
|
||||||
START_COMMAND_DESCRIPTION= # Description of the /start command
|
|
||||||
DISABLE_WELCOME_MESSAGE= # Disable the welcome message
|
|
||||||
MY_DEVICES_SECTION_ENABLED=False # Enable the My Devices section in the subscription menu
|
|
||||||
USER_HWID_DEVICE_LIMIT=0 # Default HWID/device limit for panel users (0 = unlimited)
|
|
||||||
|
|
||||||
# Required channel subscription
|
|
||||||
REQUIRED_CHANNEL_ID= # Telegram channel ID (e.g. -1001234567890) the user must join
|
|
||||||
REQUIRED_CHANNEL_LINK=https://t.me/your_channel # Optional: public link/invite button text opens
|
|
||||||
|
|
||||||
# Webhook Base URL (used for Telegram and payment providers)
|
|
||||||
WEBHOOK_BASE_URL=https://webhooks.yourdomain.tld
|
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)
|
# PostgreSQL user created by Docker Compose and used by the backend.
|
||||||
WEBAPP_ENABLED=True # Run Mini App HTTP server
|
POSTGRES_USER=remnawave_minishop
|
||||||
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_PRIMARY_COLOR="#00fe7a" # Main UI color
|
|
||||||
WEBAPP_LOGO_URL= # Optional logo URL; if empty the emoji below is used
|
|
||||||
WEBAPP_LOGO_EMOJI="🫥" # Emoji logo fallback shown in the header and login screen
|
|
||||||
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)
|
# PostgreSQL password. Change it before production deploy.
|
||||||
SMTP_HOST=smtp-relay.brevo.com # SMTP server
|
POSTGRES_PASSWORD=change_me
|
||||||
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
|
# PostgreSQL database name created by Docker Compose.
|
||||||
YOOKASSA_ENABLED=True # Turn on YOOKASSA
|
POSTGRES_DB=remnawave_minishop
|
||||||
FREEKASSA_ENABLED=True # Turn on FreeKassa
|
|
||||||
STARS_ENABLED=True # Turn on STARS
|
|
||||||
CRYPTOPAY_ENABLED=True # Turn on CRYPTOPAY
|
|
||||||
PLATEGA_ENABLED=False # Turn on PLATEGA
|
|
||||||
SEVERPAY_ENABLED=False # Turn on SeverPay
|
|
||||||
# Order of payment methods (top to bottom). Supported: severpay, freekassa, platega, yookassa, stars, cryptopay
|
|
||||||
PAYMENT_METHODS_ORDER=severpay,yookassa,cryptopay,freekassa,platega,stars
|
|
||||||
|
|
||||||
# YooKassa Payment Gateway Configuration
|
# Enables the Web App and the Web App admin panel.
|
||||||
YOOKASSA_SHOP_ID=your_shop_id # Your store ID in YooKassa
|
# Keep True for the first setup. If set to False, the admin UI is unavailable
|
||||||
YOOKASSA_SECRET_KEY=your_secret_key # Your secret key for YooKassa
|
# until you change it back to True in .env and restart the app.
|
||||||
YOOKASSA_RETURN_URL=https://t.me/your_bot # URL to which the user will be returned after payment
|
WEBAPP_ENABLED=True
|
||||||
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)
|
# Stable secret for Web App sessions.
|
||||||
NALOGO_INN=your_inn # INN for nalog.ru
|
# Generate with: openssl rand -hex 32
|
||||||
NALOGO_PASSWORD=your_nalogo_password # Password for nalog.ru
|
# If empty, sessions are invalidated on every restart.
|
||||||
NALOGO_RECEIPT_NAME_SUBSCRIPTION=subscription {months} months # Receipt name for time-based subscriptions ({months} = duration)
|
WEBAPP_SESSION_SECRET=
|
||||||
NALOGO_RECEIPT_NAME_TRAFFIC=traffic package {gb} GB # Receipt name for traffic packages ({gb} = traffic amount)
|
|
||||||
|
|
||||||
# FreeKassa Payment Gateway Configuration
|
# Stable Telegram webhook secret_token.
|
||||||
FREEKASSA_MERCHANT_ID=your_shop_id # Your shop ID in FreeKassa
|
# Generate with: openssl rand -hex 32
|
||||||
FREEKASSA_API_KEY=your_api_key # API key for REST requests
|
# If empty, a new token can be generated on process start.
|
||||||
FREEKASSA_SECOND_SECRET=your_second_secret # Secret word #2 (used to verify notifications)
|
WEBHOOK_SECRET_TOKEN=
|
||||||
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
|
# Public HTTPS URL of the Mini App frontend, with trailing slash.
|
||||||
CRYPTOPAY_TOKEN= # API token for CryptoPay
|
# This URL is opened by Telegram buttons and BotFather Mini App settings.
|
||||||
CRYPTOPAY_NETWORK=mainnet # Network (mainnet or testnet)
|
# Do not put /api or webhook paths here.
|
||||||
CRYPTOPAY_CURRENCY_TYPE=fiat # Currency type (fiat or crypto)
|
# Example: https://app.yourdomain.tld/
|
||||||
CRYPTOPAY_ASSET=RUB # Asset, e.g., RUB, BTC, USDT
|
SUBSCRIPTION_MINI_APP_URL=https://app.yourdomain.tld/
|
||||||
|
|
||||||
# Platega Payment Gateway Configuration
|
# Remnawave panel API URL. Usually the panel domain plus /api.
|
||||||
PLATEGA_BASE_URL=https://app.platega.io # Base API URL
|
# Example: https://panel.yourdomain.tld/api
|
||||||
PLATEGA_MERCHANT_ID= # Your MerchantId from Platega
|
PANEL_API_URL=https://panel.yourdomain.tld/api
|
||||||
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
|
# Remnawave API key with permissions to manage users, subscriptions and squads.
|
||||||
SEVERPAY_BASE_URL=https://severpay.io/api/merchant # Base API URL
|
# Keep this secret. It can be overridden later in the admin panel if needed.
|
||||||
SEVERPAY_MID= # Your MID from SeverPay
|
PANEL_API_KEY=
|
||||||
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.
|
# Shared secret for validating incoming Remnawave webhooks.
|
||||||
1_MONTH_ENABLED=True
|
# Use the same value when configuring the webhook in Remnawave panel.
|
||||||
RUB_PRICE_1_MONTH=150
|
PANEL_WEBHOOK_SECRET=
|
||||||
STARS_PRICE_1_MONTH=0
|
|
||||||
|
|
||||||
3_MONTHS_ENABLED=True
|
# Host port that publishes the backend webhook server from Docker Compose.
|
||||||
RUB_PRICE_3_MONTHS=300
|
# Your reverse proxy should route WEBHOOK_BASE_URL traffic to this port.
|
||||||
STARS_PRICE_3_MONTHS=0
|
|
||||||
|
|
||||||
6_MONTHS_ENABLED=True
|
|
||||||
RUB_PRICE_6_MONTHS=500
|
|
||||||
STARS_PRICE_6_MONTHS=0
|
|
||||||
|
|
||||||
12_MONTHS_ENABLED=True
|
|
||||||
RUB_PRICE_12_MONTHS=900
|
|
||||||
STARS_PRICE_12_MONTHS=0
|
|
||||||
|
|
||||||
# Traffic Packages (enables traffic sale mode when set)
|
|
||||||
TRAFFIC_PACKAGES=10:199,50:799 # Format: "<GB>:<price>", comma-separated
|
|
||||||
STARS_TRAFFIC_PACKAGES=10:2500 # Optional: traffic packages priced in Stars
|
|
||||||
TARIFFS_CONFIG_PATH=data/tariffs.json # Optional Tariffs 2.0 JSON config. If missing, legacy .env pricing is used.
|
|
||||||
NEWT_ID= # Optional: local docker-compose-dev.yml Newt tunnel id
|
|
||||||
NEWT_SECRET= # Optional: local docker-compose-dev.yml Newt tunnel secret
|
|
||||||
TARIFF_TRAFFIC_WARNING_LEVELS=85,90,95 # Tariffs 2.0 traffic warning levels, percent used
|
|
||||||
|
|
||||||
# Subscription Notifications
|
|
||||||
SUBSCRIPTION_NOTIFICATIONS_ENABLED=True # Enable subscription
|
|
||||||
SUBSCRIPTION_NOTIFY_ON_EXPIRE=True # Notify on subscription
|
|
||||||
SUBSCRIPTION_NOTIFY_AFTER_EXPIRE=True # Notify after
|
|
||||||
SUBSCRIPTION_NOTIFY_DAYS_BEFORE=3 # Days before expiration to notify
|
|
||||||
|
|
||||||
|
|
||||||
REFERRAL_ONE_BONUS_PER_REFEREE=False # Give a bonus only once per referee
|
|
||||||
REFERRAL_WELCOME_BONUS_DAYS=3 # Welcome bonus for newly registered user from referral link
|
|
||||||
LEGACY_REFS=true # Allow ref_<tg_id> links. Leave unset/true unless you want to disable old links
|
|
||||||
# Referral Bonus Days
|
|
||||||
# Bonus for the inviting user
|
|
||||||
REFERRAL_BONUS_DAYS_1_MONTH=3
|
|
||||||
REFERRAL_BONUS_DAYS_3_MONTHS=7
|
|
||||||
REFERRAL_BONUS_DAYS_6_MONTHS=15
|
|
||||||
REFERRAL_BONUS_DAYS_12_MONTHS=30
|
|
||||||
# Invited User Bonus
|
|
||||||
REFEREE_BONUS_DAYS_1_MONTH=1
|
|
||||||
REFEREE_BONUS_DAYS_3_MONTHS=3
|
|
||||||
REFEREE_BONUS_DAYS_6_MONTHS=7
|
|
||||||
REFEREE_BONUS_DAYS_12_MONTHS=15
|
|
||||||
|
|
||||||
# Panel API Configuration
|
|
||||||
PANEL_API_URL=http://your_panel_api_url/api # URL of the panel API
|
|
||||||
PANEL_API_KEY=your_panel_api_key # Panel API key
|
|
||||||
PANEL_WEBHOOK_SECRET= # secret used to verify panel webhook signatures
|
|
||||||
|
|
||||||
# User traffic limits (applied for all users)
|
|
||||||
# 0 means unlimited
|
|
||||||
USER_TRAFFIC_LIMIT_GB=0 # Traffic limit for users (0 unlimited)
|
|
||||||
USER_TRAFFIC_STRATEGY="NO_RESET" # Traffic reset strategy (NO_RESET, WEEK, MONTH)
|
|
||||||
|
|
||||||
# Default Internal Squads for Users (Optional, comma-separated UUIDs)
|
|
||||||
USER_SQUAD_UUIDS=uuid1,uuid2,uuid3
|
|
||||||
# Default External Squad for Users (Optional, single UUID)
|
|
||||||
USER_EXTERNAL_SQUAD_UUID= # Optional: UUID from Remnawave External Squads to auto-link new panel users
|
|
||||||
|
|
||||||
# Trial Settings
|
|
||||||
TRIAL_ENABLED=True # Enable the trial period
|
|
||||||
TRIAL_DURATION_DAYS=5 # Duration of the trial period in days
|
|
||||||
TRIAL_TRAFFIC_LIMIT_GB=0 # Traffic limit for the trial period (0 = unlimited)
|
|
||||||
TRIAL_TRAFFIC_STRATEGY="NO_RESET" # Traffic reset strategy for the trial period (NO_RESET, WEEK, MONTH)
|
|
||||||
|
|
||||||
# Connection link handling (happ crypt4)
|
|
||||||
CRYPT4_ENABLED=False # Enable happ crypt4 encryption for subscription URLs
|
|
||||||
CRYPT4_REDIRECT_URL= # Base redirect to wrap the connect button, e.g. https://redir.example.com?url=
|
|
||||||
|
|
||||||
# Web Server Settings (for handling webhooks)
|
|
||||||
WEB_SERVER_HOST="0.0.0.0"
|
|
||||||
WEB_SERVER_PORT=8080
|
WEB_SERVER_PORT=8080
|
||||||
|
|
||||||
# Admin Panel Log Pagination
|
# Host port that publishes the frontend nginx from Docker Compose.
|
||||||
LOGS_PAGE_SIZE=10 # Number of events in the log
|
# Your reverse proxy should route SUBSCRIPTION_MINI_APP_URL traffic to this port.
|
||||||
LOG_LEVEL=INFO # Global log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
FRONTEND_PORT=8082
|
||||||
|
|
||||||
# Admin Logging Configuration
|
# Reverse proxy IPs/CIDRs trusted for X-Forwarded-For.
|
||||||
LOG_CHAT_ID=-1001234567890 # Telegram chat/group ID for admin notifications
|
# Keep loopback for local proxy; add your proxy network if needed.
|
||||||
LOG_THREAD_ID= # Optional: Thread ID for supergroup messages
|
TRUSTED_PROXIES=127.0.0.1,::1
|
||||||
LOG_NEW_USERS=True # Log new user registrations
|
|
||||||
LOG_PAYMENTS=True # Log payments
|
|
||||||
LOG_PROMO_ACTIVATIONS=True # Log promo code activations
|
|
||||||
LOG_TRIAL_ACTIVATIONS=True # Log trial activations
|
|
||||||
LOG_SUSPICIOUS_ACTIVITY=True # Log suspicious activity
|
|
||||||
LOG_ADMIN_ACTIONS=True # Log actions from users listed in ADMIN_IDS
|
|
||||||
|
|
||||||
# Embedded mode thumbnails. Please don't touch this if you don't know what it is.
|
|
||||||
INLINE_REFERRAL_THUMBNAIL_URL=https://cdn-icons-png.flaticon.com/512/1077/1077114.png
|
|
||||||
INLINE_USER_STATS_THUMBNAIL_URL=https://cdn-icons-png.flaticon.com/512/681/681494.png
|
|
||||||
INLINE_FINANCIAL_STATS_THUMBNAIL_URL=https://cdn-icons-png.flaticon.com/512/2769/2769339.png
|
|
||||||
INLINE_SYSTEM_STATS_THUMBNAIL_URL=https://cdn-icons-png.flaticon.com/512/2920/2920277.png
|
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
*.sh text eol=lf
|
||||||
|
deploy/docker/frontend/*.sh text eol=lf
|
||||||
@@ -5,7 +5,9 @@ bot_database.sqlite3
|
|||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
docker-compose-dev.yml
|
||||||
scratch_*.py
|
scratch_*.py
|
||||||
|
scratch/
|
||||||
*.local.*
|
*.local.*
|
||||||
node_modules/
|
node_modules/
|
||||||
.git/
|
.git/
|
||||||
@@ -14,6 +16,35 @@ node_modules/
|
|||||||
bot/app/web/templates/subscription_webapp.css
|
bot/app/web/templates/subscription_webapp.css
|
||||||
bot/app/web/templates/subscription_webapp.js
|
bot/app/web/templates/subscription_webapp.js
|
||||||
bot/app/web/templates/subscription_webapp.min.*.js
|
bot/app/web/templates/subscription_webapp.min.*.js
|
||||||
|
bot/app/web/templates/subscription_webapp.*.css
|
||||||
|
bot/app/web/templates/subscription_webapp.min.*.js.br
|
||||||
|
bot/app/web/templates/subscription_webapp.min.*.js.gz
|
||||||
|
bot/app/web/templates/subscription_webapp.*.css.br
|
||||||
|
bot/app/web/templates/subscription_webapp.*.css.gz
|
||||||
|
bot/app/web/templates/subscription_webapp_admin.css
|
||||||
|
bot/app/web/templates/subscription_webapp_admin.js
|
||||||
|
bot/app/web/templates/subscription_webapp_admin.min.*.js
|
||||||
|
bot/app/web/templates/subscription_webapp_admin.*.css
|
||||||
|
bot/app/web/templates/subscription_webapp_admin.min.*.js.br
|
||||||
|
bot/app/web/templates/subscription_webapp_admin.min.*.js.gz
|
||||||
|
bot/app/web/templates/subscription_webapp_admin.*.css.br
|
||||||
|
bot/app/web/templates/subscription_webapp_admin.*.css.gz
|
||||||
|
backend/bot/app/web/templates/subscription_webapp.css
|
||||||
|
backend/bot/app/web/templates/subscription_webapp.js
|
||||||
|
backend/bot/app/web/templates/subscription_webapp.min.*.js
|
||||||
|
backend/bot/app/web/templates/subscription_webapp.*.css
|
||||||
|
backend/bot/app/web/templates/subscription_webapp.min.*.js.br
|
||||||
|
backend/bot/app/web/templates/subscription_webapp.min.*.js.gz
|
||||||
|
backend/bot/app/web/templates/subscription_webapp.*.css.br
|
||||||
|
backend/bot/app/web/templates/subscription_webapp.*.css.gz
|
||||||
|
backend/bot/app/web/templates/subscription_webapp_admin.css
|
||||||
|
backend/bot/app/web/templates/subscription_webapp_admin.js
|
||||||
|
backend/bot/app/web/templates/subscription_webapp_admin.min.*.js
|
||||||
|
backend/bot/app/web/templates/subscription_webapp_admin.*.css
|
||||||
|
backend/bot/app/web/templates/subscription_webapp_admin.min.*.js.br
|
||||||
|
backend/bot/app/web/templates/subscription_webapp_admin.min.*.js.gz
|
||||||
|
backend/bot/app/web/templates/subscription_webapp_admin.*.css.br
|
||||||
|
backend/bot/app/web/templates/subscription_webapp_admin.*.css.gz
|
||||||
tmp
|
tmp
|
||||||
.claude
|
.claude
|
||||||
|
|
||||||
@@ -32,4 +63,3 @@ locales/en_backup.json
|
|||||||
db/models_old.py
|
db/models_old.py
|
||||||
data/*
|
data/*
|
||||||
!data/tariffs.example.json
|
!data/tariffs.example.json
|
||||||
docker-compose-dev.yml
|
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
# Replace the example domains below with your real webhook and Mini App hostnames.
|
|
||||||
webhook.domain.com {
|
|
||||||
encode zstd gzip
|
|
||||||
reverse_proxy remnawave-minishop:{$WEB_SERVER_PORT:8080}
|
|
||||||
}
|
|
||||||
|
|
||||||
app.domain.com {
|
|
||||||
encode zstd gzip
|
|
||||||
reverse_proxy remnawave-minishop:{$WEBAPP_SERVER_PORT:8081}
|
|
||||||
}
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
FROM python:3.12-slim AS python-builder
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
COPY requirements.txt .
|
|
||||||
|
|
||||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
|
||||||
pip install --no-cache-dir -r requirements.txt
|
|
||||||
|
|
||||||
|
|
||||||
FROM node:22-slim AS webapp-builder
|
|
||||||
|
|
||||||
WORKDIR /webapp
|
|
||||||
|
|
||||||
COPY package.json package-lock.json* ./
|
|
||||||
RUN --mount=type=cache,target=/root/.npm \
|
|
||||||
if [ -f package-lock.json ]; then npm ci; else npm install; fi
|
|
||||||
|
|
||||||
COPY bot/app/web/frontend ./bot/app/web/frontend
|
|
||||||
COPY bot/app/web/templates ./bot/app/web/templates
|
|
||||||
COPY scripts/build_subscription_webapp_js.mjs ./scripts/build_subscription_webapp_js.mjs
|
|
||||||
|
|
||||||
RUN npm run build:webapp
|
|
||||||
|
|
||||||
|
|
||||||
FROM python:3.12-slim
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
ARG APP_VERSION=""
|
|
||||||
ARG APP_REVISION=""
|
|
||||||
|
|
||||||
LABEL org.opencontainers.image.source="https://github.com/3252a8/remnawave-minishop" \
|
|
||||||
org.opencontainers.image.version="${APP_VERSION}" \
|
|
||||||
org.opencontainers.image.revision="${APP_REVISION}"
|
|
||||||
|
|
||||||
RUN useradd -u 10001 -m appuser
|
|
||||||
|
|
||||||
COPY --from=python-builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
|
|
||||||
|
|
||||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
|
||||||
--mount=type=cache,target=/var/lib/apt,sharing=locked \
|
|
||||||
apt-get update && \
|
|
||||||
apt-get install -y --no-install-recommends git
|
|
||||||
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# Replace template assets with freshly built ones
|
|
||||||
RUN rm -f bot/app/web/templates/subscription_webapp.css \
|
|
||||||
bot/app/web/templates/subscription_webapp.js \
|
|
||||||
bot/app/web/templates/subscription_webapp.min.*.js
|
|
||||||
COPY --from=webapp-builder /webapp/bot/app/web/templates/subscription_webapp.css \
|
|
||||||
bot/app/web/templates/subscription_webapp.css
|
|
||||||
COPY --from=webapp-builder /webapp/bot/app/web/templates/subscription_webapp.js \
|
|
||||||
bot/app/web/templates/subscription_webapp.js
|
|
||||||
COPY --from=webapp-builder /webapp/bot/app/web/templates/subscription_webapp.min.*.js \
|
|
||||||
bot/app/web/templates/
|
|
||||||
|
|
||||||
RUN set -eux; \
|
|
||||||
if [ -n "$APP_VERSION" ]; then \
|
|
||||||
printf '%s\n' "$APP_VERSION" > .build-version; \
|
|
||||||
elif [ -d .git ]; then \
|
|
||||||
tag="$(git describe --tags --abbrev=0 2>/dev/null || true)"; \
|
|
||||||
sha="$(git rev-parse --short HEAD 2>/dev/null || true)"; \
|
|
||||||
dirty=""; \
|
|
||||||
if ! git diff --quiet --ignore-submodules HEAD 2>/dev/null; then dirty="-dirty"; fi; \
|
|
||||||
if [ -n "$tag" ] && [ -n "$sha" ]; then \
|
|
||||||
count="$(git rev-list "${tag}..HEAD" --count 2>/dev/null || true)"; \
|
|
||||||
if [ -n "$count" ] && [ "$count" != "0" ]; then \
|
|
||||||
printf '%s+%s.g%s%s\n' "$tag" "$count" "$sha" "$dirty" > .build-version; \
|
|
||||||
else \
|
|
||||||
printf '%s%s\n' "$tag" "$dirty" > .build-version; \
|
|
||||||
fi; \
|
|
||||||
elif [ -n "$sha" ]; then \
|
|
||||||
printf 'dev+g%s%s\n' "$sha" "$dirty" > .build-version; \
|
|
||||||
else \
|
|
||||||
printf 'dev+container\n' > .build-version; \
|
|
||||||
fi; \
|
|
||||||
else \
|
|
||||||
printf 'dev+container\n' > .build-version; \
|
|
||||||
fi; \
|
|
||||||
if [ -n "$APP_REVISION" ]; then \
|
|
||||||
printf '%s\n' "$APP_REVISION" > .build-revision; \
|
|
||||||
elif [ -d .git ]; then \
|
|
||||||
git rev-parse HEAD > .build-revision 2>/dev/null || printf 'unknown\n' > .build-revision; \
|
|
||||||
else \
|
|
||||||
printf 'unknown\n' > .build-revision; \
|
|
||||||
fi; \
|
|
||||||
apt-get purge -y --auto-remove git; \
|
|
||||||
rm -rf .git /root/.cache
|
|
||||||
|
|
||||||
RUN mkdir -p /app/logs /app/data && chown -R appuser:appuser /app/logs /app/data
|
|
||||||
|
|
||||||
USER appuser
|
|
||||||
|
|
||||||
CMD ["python", "main.py"]
|
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
# Remnawave Minishop
|
# Remnawave Minishop
|
||||||
|
|
||||||
Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи и управления подписками Remnawave. Бот обрабатывает регистрацию, оплату, продление, пробный период, промокоды, рефералов и поддержку в чате. Web App показывает ссылку подключения, срок действия, трафик, оплату, устройства и вход по Telegram Mini Apps `initData`, Telegram OAuth / OpenID Connect и одноразовому email-коду.
|

|
||||||
|
|
||||||
|
Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи и управления подписками панели [Remnawave](https://docs.rw/). Бот обрабатывает регистрацию, оплату, продление, пробный период, промокоды, рефералов и поддержку в чате. Web App показывает ссылку подключения, срок действия, трафик, оплату, устройства и вход по Telegram Mini Apps `initData`, Telegram OAuth / OpenID Connect и одноразовому email-коду.
|
||||||
|
|
||||||
Проект является переработанным форком [kavore/remnawave-tg-shop](https://github.com/kavore/remnawave-tg-shop). Для переноса данных из прежнего стека используйте [инструкцию по миграции](docs/migration-to-minishop.md).
|
Проект является переработанным форком [kavore/remnawave-tg-shop](https://github.com/kavore/remnawave-tg-shop). Для переноса данных из прежнего стека используйте [инструкцию по миграции](docs/migration-to-minishop.md).
|
||||||
|
|
||||||
@@ -13,7 +15,8 @@ Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи
|
|||||||
- покупка подписок, пакетов трафика, обычная и premium-докупка трафика, докупка устройств по настроенному каталогу тарифов;
|
- покупка подписок, пакетов трафика, обычная и premium-докупка трафика, докупка устройств по настроенному каталогу тарифов;
|
||||||
- Web App / Mini App с входом через Telegram или email;
|
- Web App / Mini App с входом через Telegram или email;
|
||||||
- пробный период, промокоды и реферальная программа;
|
- пробный период, промокоды и реферальная программа;
|
||||||
- оплата через YooKassa, FreeKassa, Platega, SeverPay, CryptoPay и Telegram Stars;
|
- оплата через YooKassa, FreeKassa, Platega, SeverPay, Wata, CryptoPay, Heleket и Telegram Stars;
|
||||||
|
- тикеты поддержки в Web App и внешняя ссылка на поддержку;
|
||||||
- раздел "Мои устройства" при включенном `MY_DEVICES_SECTION_ENABLED`.
|
- раздел "Мои устройства" при включенном `MY_DEVICES_SECTION_ENABLED`.
|
||||||
|
|
||||||
Для администраторов:
|
Для администраторов:
|
||||||
@@ -21,16 +24,19 @@ Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи
|
|||||||
- админ-панель для пользователей из `ADMIN_IDS` (только при входе через Telegram, не для аккаунтов только с email);
|
- админ-панель для пользователей из `ADMIN_IDS` (только при входе через Telegram, не для аккаунтов только с email);
|
||||||
- статистика пользователей, подписок, платежей и синхронизации с Remnawave;
|
- статистика пользователей, подписок, платежей и синхронизации с Remnawave;
|
||||||
- список пользователей с поиском, фильтрами и колонкой premium-трафика;
|
- список пользователей с поиском, фильтрами и колонкой premium-трафика;
|
||||||
- блокировка пользователей, рассылки, промокоды, логи действий и настройка разрешенных параметров приложения поверх `.env`;
|
- блокировка пользователей, поддержка через тикеты, рассылки, промокоды, логи действий и настройка разрешенных параметров приложения поверх `.env`;
|
||||||
- редактор JSON-каталога тарифов с period/traffic-моделями, Internal Squads, premium-сквадами и HWID-пакетами;
|
- редактор JSON-каталога тарифов с period/traffic-моделями, Internal Squads, premium-сквадами и HWID-пакетами;
|
||||||
- ручная синхронизация пользователей и подписок с панелью.
|
- ручная синхронизация пользователей и подписок с панелью.
|
||||||
|
|
||||||
## Документация
|
## Документация
|
||||||
|
|
||||||
- [Настройка окружения](docs/configuration.md) - основные переменные `.env`, платежи, Remnawave, пробный период, SMTP для email-входа и секреты.
|
- [Настройка окружения](docs/configuration.md) - bootstrap `.env` и рекомендуемая настройка через Web App админку.
|
||||||
|
- [Переменные `.env`](docs/env-vars.md) - полный справочник всех env-ключей по разделам.
|
||||||
- [Тарифы](docs/tariffs.md) - каталог тарифов, period- и traffic-модели, обычные и premium-докупки, premium-сквады, смена тарифа, HWID-лимиты и обработка трафика.
|
- [Тарифы](docs/tariffs.md) - каталог тарифов, period- и traffic-модели, обычные и premium-докупки, premium-сквады, смена тарифа, HWID-лимиты и обработка трафика.
|
||||||
- [Админ-панель](docs/admin.md) - права доступа, настройки, редактор тарифов, premium-сквады и сохранение JSON-каталога.
|
- [Админ-панель](docs/admin.md) - права доступа, настройки, редактор тарифов, premium-сквады и сохранение JSON-каталога.
|
||||||
- [Web App / Mini App](docs/webapp.md) - отдельный порт, домен, Telegram OAuth, email-вход и реферальные ссылки.
|
- [Web App / Mini App](docs/webapp.md) - отдельный порт, домен, Telegram OAuth, email-вход и реферальные ссылки.
|
||||||
|
- [Поддержка](docs/support.md) - тикеты в Mini App, входящий список админки, уведомления, лимиты и внешняя ссылка поддержки.
|
||||||
|
- [Темы Web App](docs/webapp-themes.md) - кастомные темы, настройка внешнего вида, логотипы, CSS/ассеты и пайплайн создания новой темы.
|
||||||
- [Развертывание](docs/deployment.md) - Docker Compose, reverse proxy, Nginx, Caddy, вебхуки, запуск из образа и обновление версии (`IMAGE_TAG`).
|
- [Развертывание](docs/deployment.md) - Docker Compose, reverse proxy, Nginx, Caddy, вебхуки, запуск из образа и обновление версии (`IMAGE_TAG`).
|
||||||
- [Миграция с remnawave-tg-shop](docs/migration-to-minishop.md) - перенос данных из прежнего стека.
|
- [Миграция с remnawave-tg-shop](docs/migration-to-minishop.md) - перенос данных из прежнего стека.
|
||||||
|
|
||||||
@@ -40,13 +46,13 @@ Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи
|
|||||||
|
|
||||||
## Стек
|
## Стек
|
||||||
|
|
||||||
Сборка и runtime задаются **Dockerfile** и **docker-compose.yml**; точные версии пакетов — в **requirements.txt** и **package.json**.
|
Сборка и runtime задаются **deploy/docker/Dockerfile** и **docker-compose.yml**; точные версии пакетов — в **backend/requirements.txt** и **frontend/package.json**.
|
||||||
|
|
||||||
| Слой | Технологии |
|
| Слой | Технологии |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 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** |
|
| 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** (сервис `remnawave-minishop-db` в Compose) |
|
| Данные | **PostgreSQL** **17** (сервис `postgres` в Compose) и **Redis** **7** (сервис `redis`) |
|
||||||
| Сборка Web App | **Node.js** **22**, **Svelte** **5**, **Vite**, **Tailwind CSS** 4; артефакты попадают в шаблоны `bot/app/web/templates/` |
|
| Сборка Web App | **Node.js** **22**, **Svelte** **5**, **Vite**, **Tailwind CSS** 4; артефакты попадают в шаблоны `backend/bot/app/web/templates/` |
|
||||||
|
|
||||||
Локальная разработка без Docker возможна при установленных Python 3.12, PostgreSQL и (для пересборки фронта) Node 22; типичный сценарий — всё через Compose.
|
Локальная разработка без Docker возможна при установленных Python 3.12, PostgreSQL и (для пересборки фронта) Node 22; типичный сценарий — всё через Compose.
|
||||||
|
|
||||||
@@ -57,7 +63,7 @@ Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи
|
|||||||
- Docker и Docker Compose;
|
- Docker и Docker Compose;
|
||||||
- рабочая панель Remnawave версии **`> 2.7.0`** (см. раздел «Совместимость»);
|
- рабочая панель Remnawave версии **`> 2.7.0`** (см. раздел «Совместимость»);
|
||||||
- токен Telegram-бота;
|
- токен Telegram-бота;
|
||||||
- параметры хотя бы одного платежного провайдера.
|
- публичные домены для webhook и Mini App.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/3252a8/remnawave-minishop
|
git clone https://github.com/3252a8/remnawave-minishop
|
||||||
@@ -65,7 +71,7 @@ cd remnawave-minishop
|
|||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
nano .env
|
nano .env
|
||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
docker compose logs -f remnawave-minishop
|
docker compose logs -f backend worker frontend
|
||||||
```
|
```
|
||||||
|
|
||||||
Минимально заполните в `.env`:
|
Минимально заполните в `.env`:
|
||||||
@@ -73,17 +79,21 @@ docker compose logs -f remnawave-minishop
|
|||||||
- `BOT_TOKEN` - токен Telegram-бота;
|
- `BOT_TOKEN` - токен Telegram-бота;
|
||||||
- `ADMIN_IDS` - Telegram ID администраторов через запятую;
|
- `ADMIN_IDS` - Telegram ID администраторов через запятую;
|
||||||
- `WEBHOOK_BASE_URL` - публичный URL вебхуков;
|
- `WEBHOOK_BASE_URL` - публичный URL вебхуков;
|
||||||
|
- `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB` - доступы PostgreSQL;
|
||||||
|
- `WEBAPP_ENABLED=True` - включает Web App и админку для первого входа;
|
||||||
|
- `WEBAPP_SESSION_SECRET`, `WEBHOOK_SECRET_TOKEN` - стабильные секреты;
|
||||||
|
- `SUBSCRIPTION_MINI_APP_URL` - публичный HTTPS URL Mini App/frontend, например `https://app.domain.com/`;
|
||||||
- `PANEL_API_URL`, `PANEL_API_KEY`, `PANEL_WEBHOOK_SECRET` - доступ к Remnawave;
|
- `PANEL_API_URL`, `PANEL_API_KEY`, `PANEL_WEBHOOK_SECRET` - доступ к Remnawave;
|
||||||
- `USER_SQUAD_UUIDS` - Internal Squads для пользователей;
|
- остальные настройки удобнее задать в Web App админке.
|
||||||
- настройки платежного провайдера;
|
|
||||||
- `SUBSCRIPTION_MINI_APP_URL`, если используется Web App.
|
После первого входа в админку настройте тарифы, платежные провайдеры, внешний вид, поддержку и уведомления через UI. Полный справочник env-переменных: [docs/env-vars.md](docs/env-vars.md).
|
||||||
|
|
||||||
Для каталога тарифов используется `TARIFFS_CONFIG_PATH` со значением по умолчанию `data/tariffs.json`. Пример формата лежит в [data/tariffs.example.json](data/tariffs.example.json), подробности - в [docs/tariffs.md](docs/tariffs.md).
|
Для каталога тарифов используется `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`, кеша логотипа Web App и animated emoji:
|
Если в Docker Compose включаете bind mount `./data:/app/data`, заранее создайте каталог и отдайте его пользователю контейнера. Это нужно для сохранения `data/tariffs.json`, каталога тем `data/themes`, кеша логотипа Web App и animated emoji:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir -p data/webapp-logo data/webapp-emoji
|
mkdir -p data/themes data/webapp-logo data/webapp-emoji
|
||||||
chown -R 10001:10001 data
|
chown -R 10001:10001 data
|
||||||
chmod -R u+rwX data
|
chmod -R u+rwX data
|
||||||
```
|
```
|
||||||
@@ -95,15 +105,21 @@ chmod -R u+rwX data
|
|||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
|
|
||||||
# Логи приложения
|
# Логи приложения
|
||||||
docker compose logs -f remnawave-minishop
|
docker compose logs -f backend worker frontend
|
||||||
|
|
||||||
# Запуск с Caddy
|
# Запуск с Caddy
|
||||||
docker compose -f docker-compose-caddy.yml up -d --build
|
docker compose -f deploy/compose/docker-compose-caddy.yml up -d
|
||||||
|
|
||||||
# Запуск из готового образа
|
# Запуск из готового образа
|
||||||
IMAGE_TAG=3.1.0 docker compose -f docker-compose-remote-server.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)`
|
- 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",
|
||||||
|
)
|
||||||
@@ -6,6 +6,11 @@ from aiogram.enums import ParseMode
|
|||||||
from aiogram.fsm.storage.memory import MemoryStorage
|
from aiogram.fsm.storage.memory import MemoryStorage
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
try:
|
||||||
|
from aiogram.fsm.storage.redis import RedisStorage
|
||||||
|
except ModuleNotFoundError: # pragma: no cover - dependency is installed in Docker image
|
||||||
|
RedisStorage = None # type: ignore[assignment]
|
||||||
|
|
||||||
from bot.middlewares.action_logger_middleware import ActionLoggerMiddleware
|
from bot.middlewares.action_logger_middleware import ActionLoggerMiddleware
|
||||||
from bot.middlewares.ban_check_middleware import BanCheckMiddleware
|
from bot.middlewares.ban_check_middleware import BanCheckMiddleware
|
||||||
from bot.middlewares.channel_subscription import ChannelSubscriptionMiddleware
|
from bot.middlewares.channel_subscription import ChannelSubscriptionMiddleware
|
||||||
@@ -18,7 +23,11 @@ from config.settings import Settings
|
|||||||
def build_dispatcher(
|
def build_dispatcher(
|
||||||
settings: Settings, async_session_factory: sessionmaker
|
settings: Settings, async_session_factory: sessionmaker
|
||||||
) -> tuple[Dispatcher, Bot, Dict]:
|
) -> tuple[Dispatcher, Bot, Dict]:
|
||||||
storage = MemoryStorage()
|
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)
|
default_props = DefaultBotProperties(parse_mode=ParseMode.HTML)
|
||||||
bot = Bot(token=settings.BOT_TOKEN, default=default_props)
|
bot = Bot(token=settings.BOT_TOKEN, default=default_props)
|
||||||
|
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
from aiogram import Bot
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from bot.middlewares.i18n import JsonI18n
|
||||||
|
from bot.payment_providers import (
|
||||||
|
ServiceFactoryContext,
|
||||||
|
build_provider_configs,
|
||||||
|
build_provider_services,
|
||||||
|
)
|
||||||
|
from bot.services.email_auth_service import EmailAuthService
|
||||||
|
from bot.services.lknpd_service import LknpdService
|
||||||
|
from bot.services.notification_service import NotificationService
|
||||||
|
from bot.services.panel_api_service import PanelApiService
|
||||||
|
from bot.services.panel_webhook_service import PanelWebhookService
|
||||||
|
from bot.services.promo_code_service import PromoCodeService
|
||||||
|
from bot.services.referral_service import ReferralService
|
||||||
|
from bot.services.subscription_service import SubscriptionService
|
||||||
|
from bot.services.support_service import SupportService
|
||||||
|
from config.settings import Settings
|
||||||
|
|
||||||
|
|
||||||
|
def build_core_services(
|
||||||
|
settings: Settings,
|
||||||
|
bot: Bot,
|
||||||
|
async_session_factory: sessionmaker,
|
||||||
|
i18n: JsonI18n,
|
||||||
|
bot_username_for_default_return: str,
|
||||||
|
):
|
||||||
|
panel_service = PanelApiService(settings)
|
||||||
|
subscription_service = SubscriptionService(settings, panel_service, bot, i18n)
|
||||||
|
referral_service = ReferralService(settings, subscription_service, bot, i18n)
|
||||||
|
promo_code_service = PromoCodeService(settings, subscription_service, bot, i18n)
|
||||||
|
email_auth_service = EmailAuthService(settings)
|
||||||
|
notification_service = NotificationService(
|
||||||
|
bot,
|
||||||
|
settings,
|
||||||
|
i18n,
|
||||||
|
session_factory=async_session_factory,
|
||||||
|
email_auth_service=email_auth_service,
|
||||||
|
bot_username=bot_username_for_default_return,
|
||||||
|
)
|
||||||
|
support_service = SupportService(
|
||||||
|
async_session_factory,
|
||||||
|
settings,
|
||||||
|
bot,
|
||||||
|
i18n,
|
||||||
|
notification_service,
|
||||||
|
email_auth_service,
|
||||||
|
)
|
||||||
|
panel_webhook_service = PanelWebhookService(
|
||||||
|
bot, settings, i18n, async_session_factory, panel_service
|
||||||
|
)
|
||||||
|
provider_configs = build_provider_configs()
|
||||||
|
payment_services = build_provider_services(
|
||||||
|
ServiceFactoryContext(
|
||||||
|
settings=settings,
|
||||||
|
bot=bot,
|
||||||
|
async_session_factory=async_session_factory,
|
||||||
|
i18n=i18n,
|
||||||
|
bot_username_for_default_return=bot_username_for_default_return,
|
||||||
|
subscription_service=subscription_service,
|
||||||
|
referral_service=referral_service,
|
||||||
|
provider_configs=provider_configs,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
lknpd_service = LknpdService(
|
||||||
|
settings.LKNPD_INN,
|
||||||
|
settings.LKNPD_PASSWORD,
|
||||||
|
api_url=settings.LKNPD_API_URL,
|
||||||
|
)
|
||||||
|
|
||||||
|
# These attachments are critical for auto-renew and panel pre-expiry hooks.
|
||||||
|
subscription_service.yookassa_service = payment_services.get("yookassa_service")
|
||||||
|
panel_webhook_service.subscription_service = subscription_service
|
||||||
|
|
||||||
|
services = {
|
||||||
|
"panel_service": panel_service,
|
||||||
|
"subscription_service": subscription_service,
|
||||||
|
"referral_service": referral_service,
|
||||||
|
"promo_code_service": promo_code_service,
|
||||||
|
"notification_service": notification_service,
|
||||||
|
"email_auth_service": email_auth_service,
|
||||||
|
"support_service": support_service,
|
||||||
|
"panel_webhook_service": panel_webhook_service,
|
||||||
|
"lknpd_service": lknpd_service,
|
||||||
|
}
|
||||||
|
services.update(payment_services)
|
||||||
|
return services
|
||||||
@@ -15,8 +15,10 @@ from bot.app.web.admin_api_impl import (
|
|||||||
routes as _routes,
|
routes as _routes,
|
||||||
settings as _settings,
|
settings as _settings,
|
||||||
stats as _stats,
|
stats as _stats,
|
||||||
|
support as _support,
|
||||||
sync as _sync,
|
sync as _sync,
|
||||||
tariffs as _tariffs,
|
tariffs as _tariffs,
|
||||||
|
themes as _themes,
|
||||||
users as _users,
|
users as _users,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -29,11 +31,13 @@ _MODULES = (
|
|||||||
_payments,
|
_payments,
|
||||||
_promos,
|
_promos,
|
||||||
_logs,
|
_logs,
|
||||||
|
_support,
|
||||||
_broadcast,
|
_broadcast,
|
||||||
_sync,
|
_sync,
|
||||||
_ads,
|
_ads,
|
||||||
_settings,
|
_settings,
|
||||||
_tariffs,
|
_tariffs,
|
||||||
|
_themes,
|
||||||
_panel,
|
_panel,
|
||||||
_routes,
|
_routes,
|
||||||
)
|
)
|
||||||
@@ -28,6 +28,7 @@ from sqlalchemy.orm import sessionmaker
|
|||||||
from bot.app.web.admin_settings_manifest import (
|
from bot.app.web.admin_settings_manifest import (
|
||||||
manifest_payload,
|
manifest_payload,
|
||||||
)
|
)
|
||||||
|
from bot.infra.webhook_queue import enqueue_webhook_event
|
||||||
from bot.services.referral_service import ReferralService
|
from bot.services.referral_service import ReferralService
|
||||||
from bot.services.settings_override_service import (
|
from bot.services.settings_override_service import (
|
||||||
current_value,
|
current_value,
|
||||||
@@ -270,6 +270,10 @@ def _write_tariffs_config_file(path: Path, config: TariffsConfig) -> None:
|
|||||||
path.write_text(payload, encoding="utf-8")
|
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:
|
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")
|
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 ""
|
return str(uid).strip().lower() if uid else ""
|
||||||
@@ -15,6 +15,10 @@ def setup_admin_routes(app: web.Application) -> None:
|
|||||||
router.add_post(
|
router.add_post(
|
||||||
"/api/admin/users/{user_id:-?\\d+}/message/preview", admin_user_message_preview_route
|
"/api/admin/users/{user_id:-?\\d+}/message/preview", admin_user_message_preview_route
|
||||||
)
|
)
|
||||||
|
router.add_post(
|
||||||
|
"/api/admin/users/{user_id:-?\\d+}/telegram-profile-link",
|
||||||
|
admin_user_telegram_profile_link_route,
|
||||||
|
)
|
||||||
router.add_post("/api/admin/users/{user_id:-?\\d+}/reset-trial", admin_user_reset_trial_route)
|
router.add_post("/api/admin/users/{user_id:-?\\d+}/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+}/extend", admin_user_extend_route)
|
||||||
router.add_post(
|
router.add_post(
|
||||||
@@ -41,6 +45,16 @@ def setup_admin_routes(app: web.Application) -> None:
|
|||||||
|
|
||||||
router.add_get("/api/admin/logs", admin_logs_route)
|
router.add_get("/api/admin/logs", admin_logs_route)
|
||||||
|
|
||||||
|
router.add_get("/api/admin/support/tickets", admin_support_tickets_route)
|
||||||
|
router.add_get("/api/admin/support/tickets/{id:\\d+}", admin_support_ticket_detail_route)
|
||||||
|
router.add_post(
|
||||||
|
"/api/admin/support/tickets/{id:\\d+}/messages",
|
||||||
|
admin_support_ticket_reply_route,
|
||||||
|
)
|
||||||
|
router.add_patch("/api/admin/support/tickets/{id:\\d+}", admin_support_ticket_patch_route)
|
||||||
|
router.add_post("/api/admin/support/tickets/{id:\\d+}/read", admin_support_ticket_read_route)
|
||||||
|
router.add_get("/api/admin/support/stats", admin_support_stats_route)
|
||||||
|
|
||||||
router.add_post("/api/admin/broadcast", admin_broadcast_route)
|
router.add_post("/api/admin/broadcast", admin_broadcast_route)
|
||||||
router.add_post("/api/admin/sync", admin_sync_route)
|
router.add_post("/api/admin/sync", admin_sync_route)
|
||||||
|
|
||||||
@@ -54,4 +68,8 @@ def setup_admin_routes(app: web.Application) -> None:
|
|||||||
|
|
||||||
router.add_get("/api/admin/tariffs", admin_tariffs_get_route)
|
router.add_get("/api/admin/tariffs", admin_tariffs_get_route)
|
||||||
router.add_put("/api/admin/tariffs", admin_tariffs_save_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)
|
router.add_get("/api/admin/panel/internal-squads", admin_panel_internal_squads_route)
|
||||||
@@ -13,6 +13,7 @@ async def admin_settings_get_route(request: web.Request) -> web.Response:
|
|||||||
overrides_by_key = {entry["key"]: entry for entry in overrides}
|
overrides_by_key = {entry["key"]: entry for entry in overrides}
|
||||||
|
|
||||||
fields = manifest_payload()
|
fields = manifest_payload()
|
||||||
|
webhook_base_url = str(settings.WEBHOOK_BASE_URL or "").strip().rstrip("/")
|
||||||
sections: Dict[str, Dict[str, Any]] = {}
|
sections: Dict[str, Dict[str, Any]] = {}
|
||||||
for field in fields:
|
for field in fields:
|
||||||
key = field["key"]
|
key = field["key"]
|
||||||
@@ -34,6 +35,14 @@ async def admin_settings_get_route(request: web.Request) -> web.Response:
|
|||||||
}
|
}
|
||||||
if is_secret:
|
if is_secret:
|
||||||
response_field["has_value"] = bool(value)
|
response_field["has_value"] = bool(value)
|
||||||
|
webhook_path = str(response_field.get("webhook_path") or "").strip()
|
||||||
|
if webhook_path:
|
||||||
|
if not webhook_path.startswith("/"):
|
||||||
|
webhook_path = f"/{webhook_path}"
|
||||||
|
response_field["webhook_path"] = webhook_path
|
||||||
|
response_field["webhook_base_url_configured"] = bool(webhook_base_url)
|
||||||
|
if webhook_base_url:
|
||||||
|
response_field["webhook_url"] = f"{webhook_base_url}{webhook_path}"
|
||||||
sections[section_id]["fields"].append(response_field)
|
sections[section_id]["fields"].append(response_field)
|
||||||
|
|
||||||
ordered_sections = sorted(sections.values(), key=lambda s: s["order"])
|
ordered_sections = sorted(sections.values(), key=lambda s: s["order"])
|
||||||
@@ -70,5 +79,27 @@ async def admin_settings_patch_route(request: web.Request) -> web.Response:
|
|||||||
if isinstance(cache, dict):
|
if isinstance(cache, dict):
|
||||||
cache["ts"] = 0.0
|
cache["ts"] = 0.0
|
||||||
cache["data"] = {}
|
cache["data"] = {}
|
||||||
|
try:
|
||||||
|
from bot.app.web.webapp.cache_helpers import invalidate_all_webapp_user_caches
|
||||||
|
|
||||||
|
await invalidate_all_webapp_user_caches(settings, include_devices=True)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to invalidate WebApp user payload caches after settings update")
|
||||||
|
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)})
|
return _ok({"applied": result.get("applied", 0), "reverted": result.get("reverted", 0)})
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
# ruff: noqa: F401,F403,F405,I001
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from ._runtime import * # noqa: F403,F405
|
||||||
|
from .auth import _require_admin_user_id
|
||||||
|
from .common import _ok, _serialize_payment
|
||||||
|
from bot.utils.ttl_cache import AsyncTTLCache
|
||||||
|
|
||||||
|
_ADMIN_PANEL_STATS_CACHES: Dict[tuple[int, int], AsyncTTLCache] = {}
|
||||||
|
_ADMIN_DB_STATS_CACHES: Dict[tuple[int, int], AsyncTTLCache] = {}
|
||||||
|
|
||||||
|
|
||||||
|
async def admin_me_route(request: web.Request) -> web.Response:
|
||||||
|
user_id = _require_admin_user_id(request)
|
||||||
|
settings: Settings = request.app["settings"]
|
||||||
|
return _ok({}, user_id=user_id, admin_ids=list(settings.ADMIN_IDS or []))
|
||||||
|
|
||||||
|
|
||||||
|
async def admin_stats_route(request: web.Request) -> web.Response:
|
||||||
|
_require_admin_user_id(request)
|
||||||
|
settings: Settings = request.app["settings"]
|
||||||
|
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||||
|
|
||||||
|
payload = dict(await _load_admin_db_stats(settings, async_session_factory))
|
||||||
|
|
||||||
|
panel_service = request.app.get("panel_service")
|
||||||
|
if panel_service is not None:
|
||||||
|
payload["panel"] = await _load_admin_panel_stats(request, settings, panel_service)
|
||||||
|
|
||||||
|
queue_manager = get_queue_manager()
|
||||||
|
if queue_manager:
|
||||||
|
try:
|
||||||
|
payload["queue"] = queue_manager.get_queue_stats()
|
||||||
|
except Exception: # pragma: no cover - defensive
|
||||||
|
payload["queue"] = None
|
||||||
|
|
||||||
|
payload["currency_symbol"] = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
|
||||||
|
return _ok(payload)
|
||||||
|
|
||||||
|
|
||||||
|
async def _load_admin_db_stats(
|
||||||
|
settings: Settings,
|
||||||
|
async_session_factory: sessionmaker,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
cache = _admin_db_stats_cache(settings)
|
||||||
|
if cache is None:
|
||||||
|
return await _load_admin_db_stats_uncached(async_session_factory)
|
||||||
|
return await cache.get_or_load(
|
||||||
|
"db",
|
||||||
|
lambda: _load_admin_db_stats_uncached(async_session_factory),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _load_admin_db_stats_uncached(async_session_factory: sessionmaker) -> Dict[str, Any]:
|
||||||
|
async with async_session_factory() as session:
|
||||||
|
user_stats = await user_dal.get_enhanced_user_statistics(session)
|
||||||
|
financial_stats = await payment_dal.get_financial_statistics(session)
|
||||||
|
sync_status = await panel_sync_dal.get_panel_sync_status(session)
|
||||||
|
recent_payments = await payment_dal.get_recent_payment_logs_with_user(session, limit=10)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"users": user_stats,
|
||||||
|
"financial": financial_stats,
|
||||||
|
"panel_sync": {
|
||||||
|
"status": sync_status.status if sync_status else "never_run",
|
||||||
|
"last_sync_time": sync_status.last_sync_time.isoformat()
|
||||||
|
if sync_status and sync_status.last_sync_time
|
||||||
|
else None,
|
||||||
|
"details": sync_status.details if sync_status else None,
|
||||||
|
"users_processed": sync_status.users_processed_from_panel if sync_status else 0,
|
||||||
|
"subscriptions_synced": sync_status.subscriptions_synced if sync_status else 0,
|
||||||
|
},
|
||||||
|
"recent_payments": [_serialize_payment(p) for p in recent_payments],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _admin_db_stats_cache(settings: Settings) -> Optional[AsyncTTLCache]:
|
||||||
|
ttl_seconds = int(getattr(settings, "ADMIN_DB_STATS_CACHE_TTL_SECONDS", 5) or 0)
|
||||||
|
if ttl_seconds <= 0:
|
||||||
|
return None
|
||||||
|
cache_key = (id(settings), ttl_seconds)
|
||||||
|
cache = _ADMIN_DB_STATS_CACHES.get(cache_key)
|
||||||
|
if cache is None:
|
||||||
|
cache = AsyncTTLCache(
|
||||||
|
ttl_seconds=ttl_seconds,
|
||||||
|
settings=settings,
|
||||||
|
namespace="admin:db_stats",
|
||||||
|
)
|
||||||
|
_ADMIN_DB_STATS_CACHES[cache_key] = cache
|
||||||
|
return cache
|
||||||
|
|
||||||
|
|
||||||
|
async def _load_admin_panel_stats(
|
||||||
|
request: web.Request,
|
||||||
|
settings: Settings,
|
||||||
|
panel_service,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
cache = _admin_panel_stats_cache(settings)
|
||||||
|
if cache is None:
|
||||||
|
return await _load_admin_panel_stats_uncached(panel_service)
|
||||||
|
return await cache.get_or_load("panel", lambda: _load_admin_panel_stats_uncached(panel_service))
|
||||||
|
|
||||||
|
|
||||||
|
def _admin_panel_stats_cache(settings: Settings) -> Optional[AsyncTTLCache]:
|
||||||
|
ttl_seconds = int(getattr(settings, "ADMIN_PANEL_STATS_CACHE_TTL_SECONDS", 15) or 0)
|
||||||
|
if ttl_seconds <= 0:
|
||||||
|
return None
|
||||||
|
cache_key = (id(settings), ttl_seconds)
|
||||||
|
cache = _ADMIN_PANEL_STATS_CACHES.get(cache_key)
|
||||||
|
if cache is None:
|
||||||
|
cache = AsyncTTLCache(
|
||||||
|
ttl_seconds=ttl_seconds,
|
||||||
|
settings=settings,
|
||||||
|
namespace="admin:panel_stats",
|
||||||
|
)
|
||||||
|
_ADMIN_PANEL_STATS_CACHES[cache_key] = cache
|
||||||
|
return cache
|
||||||
|
|
||||||
|
|
||||||
|
async def _load_admin_panel_stats_uncached(panel_service) -> Dict[str, Any]:
|
||||||
|
try:
|
||||||
|
today = datetime.now(timezone.utc).date()
|
||||||
|
start_d = today - timedelta(days=7)
|
||||||
|
system, bandwidth, nodes, nodes_bw, lookups = await asyncio.gather(
|
||||||
|
_safe_panel_call(panel_service.get_system_stats(), "system stats"),
|
||||||
|
_safe_panel_call(panel_service.get_bandwidth_stats(), "bandwidth stats"),
|
||||||
|
_safe_panel_call(panel_service.get_nodes_statistics(), "nodes stats"),
|
||||||
|
_safe_panel_call(
|
||||||
|
panel_service.get_nodes_bandwidth_usage(
|
||||||
|
start=start_d.isoformat(),
|
||||||
|
end=today.isoformat(),
|
||||||
|
top_nodes_limit=64,
|
||||||
|
),
|
||||||
|
"nodes bandwidth range",
|
||||||
|
),
|
||||||
|
_safe_panel_call(panel_service.get_nodes_online_lookups(), "nodes online lookups"),
|
||||||
|
)
|
||||||
|
|
||||||
|
panel_body: Dict[str, Any] = {
|
||||||
|
"system": system or {},
|
||||||
|
"bandwidth": bandwidth or {},
|
||||||
|
"nodes": nodes or {},
|
||||||
|
"nodes_bandwidth": nodes_bw or {},
|
||||||
|
}
|
||||||
|
if isinstance(lookups, dict):
|
||||||
|
try:
|
||||||
|
online_map = _panel_nodes_online_by_uuid(panel_body.get("nodes"))
|
||||||
|
for k, v in lookups.get("byUuid", {}).items():
|
||||||
|
online_map[k] = v
|
||||||
|
_enrich_bandwidth_nodes_with_online(
|
||||||
|
panel_body.get("nodes_bandwidth"),
|
||||||
|
online_map,
|
||||||
|
lookups.get("byName") or {},
|
||||||
|
)
|
||||||
|
except Exception as exc_merge: # pragma: no cover
|
||||||
|
logger.debug("Panel nodes online merge skipped: %s", exc_merge)
|
||||||
|
return panel_body
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("Panel stats unavailable: %s", exc)
|
||||||
|
return {"error": "unavailable"}
|
||||||
|
|
||||||
|
|
||||||
|
async def _safe_panel_call(awaitable, label: str) -> Any:
|
||||||
|
try:
|
||||||
|
return await awaitable
|
||||||
|
except Exception as exc: # pragma: no cover - optional panel endpoints
|
||||||
|
logger.debug("Panel %s unavailable: %s", label, exc)
|
||||||
|
return None
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
# ruff: noqa: F401,F403,F405,I001
|
||||||
|
from ._runtime import * # noqa: F403,F405
|
||||||
|
|
||||||
|
from typing import Literal, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, constr, field_validator
|
||||||
|
|
||||||
|
from bot.services.support_service import TicketNotFound
|
||||||
|
from db.dal import support_dal, user_dal
|
||||||
|
from db.models import SupportTicket, SupportTicketMessage
|
||||||
|
|
||||||
|
|
||||||
|
class AdminTicketReplyPayload(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="ignore")
|
||||||
|
|
||||||
|
body: constr(min_length=1, max_length=4000)
|
||||||
|
is_internal_note: bool = False
|
||||||
|
|
||||||
|
@field_validator("body")
|
||||||
|
@classmethod
|
||||||
|
def _strip_body(cls, value: str) -> str:
|
||||||
|
stripped = value.strip()
|
||||||
|
if not stripped:
|
||||||
|
raise ValueError("empty_text")
|
||||||
|
return stripped
|
||||||
|
|
||||||
|
|
||||||
|
class AdminTicketPatchPayload(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="ignore")
|
||||||
|
|
||||||
|
status: Optional[Literal["open", "awaiting_user", "awaiting_admin", "resolved", "closed"]] = (
|
||||||
|
None
|
||||||
|
)
|
||||||
|
priority: Optional[Literal["low", "normal", "high", "urgent"]] = None
|
||||||
|
category: Optional[Literal["billing", "technical", "account", "other"]] = None
|
||||||
|
assigned_admin_id: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_model_payload(model_cls, payload: Dict[str, Any]):
|
||||||
|
try:
|
||||||
|
return model_cls.model_validate(payload), None
|
||||||
|
except ValidationError:
|
||||||
|
return None, _error(400, "invalid_request", "Invalid request")
|
||||||
|
|
||||||
|
|
||||||
|
def _support_ticket_payload(ticket: SupportTicket) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"ticket_id": ticket.ticket_id,
|
||||||
|
"user_id": ticket.user_id,
|
||||||
|
"subject": ticket.subject,
|
||||||
|
"category": ticket.category,
|
||||||
|
"priority": ticket.priority,
|
||||||
|
"status": ticket.status,
|
||||||
|
"assigned_admin_id": ticket.assigned_admin_id,
|
||||||
|
"last_message_at": ticket.last_message_at.isoformat() if ticket.last_message_at else None,
|
||||||
|
"last_message_role": ticket.last_message_role,
|
||||||
|
"unread_user_count": int(ticket.unread_user_count or 0),
|
||||||
|
"unread_admin_count": int(ticket.unread_admin_count or 0),
|
||||||
|
"created_at": ticket.created_at.isoformat() if ticket.created_at else None,
|
||||||
|
"updated_at": ticket.updated_at.isoformat() if ticket.updated_at else None,
|
||||||
|
"closed_at": ticket.closed_at.isoformat() if ticket.closed_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _user_display_name(user) -> Optional[str]:
|
||||||
|
if not user:
|
||||||
|
return None
|
||||||
|
name = " ".join(
|
||||||
|
part.strip() for part in [user.first_name, user.last_name] if part and part.strip()
|
||||||
|
).strip()
|
||||||
|
return name or user.username or user.email or str(user.user_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _support_message_payload(
|
||||||
|
message: SupportTicketMessage,
|
||||||
|
*,
|
||||||
|
authors: Optional[Dict[int, Any]] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
author = authors.get(message.author_user_id) if authors and message.author_user_id else None
|
||||||
|
return {
|
||||||
|
"message_id": message.message_id,
|
||||||
|
"ticket_id": message.ticket_id,
|
||||||
|
"author_role": message.author_role,
|
||||||
|
"author_user_id": message.author_user_id,
|
||||||
|
"author_name": _user_display_name(author),
|
||||||
|
"body": message.body,
|
||||||
|
"is_internal_note": bool(message.is_internal_note),
|
||||||
|
"created_at": message.created_at.isoformat() if message.created_at else None,
|
||||||
|
"read_by_user_at": message.read_by_user_at.isoformat() if message.read_by_user_at else None,
|
||||||
|
"read_by_admin_at": message.read_by_admin_at.isoformat()
|
||||||
|
if message.read_by_admin_at
|
||||||
|
else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _admin_support_user_payload(user) -> Dict[str, Any]:
|
||||||
|
if not user:
|
||||||
|
return {}
|
||||||
|
return {
|
||||||
|
"user_id": user.user_id,
|
||||||
|
"telegram_id": user.telegram_id,
|
||||||
|
"username": user.username,
|
||||||
|
"first_name": user.first_name,
|
||||||
|
"last_name": user.last_name,
|
||||||
|
"email": user.email,
|
||||||
|
"telegram_photo_url": user.telegram_photo_url,
|
||||||
|
"is_banned": bool(user.is_banned),
|
||||||
|
"registration_date": user.registration_date.isoformat() if user.registration_date else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _support_limit_offset(request: web.Request) -> tuple[int, int]:
|
||||||
|
limit = max(1, min(100, int(request.query.get("limit", 25) or 25)))
|
||||||
|
offset = max(0, int(request.query.get("offset", 0) or 0))
|
||||||
|
return limit, offset
|
||||||
|
|
||||||
|
|
||||||
|
async def admin_support_tickets_route(request: web.Request) -> web.Response:
|
||||||
|
_require_admin_user_id(request)
|
||||||
|
limit, offset = _support_limit_offset(request)
|
||||||
|
assigned_raw = request.query.get("assigned")
|
||||||
|
assigned_admin_id = None
|
||||||
|
if assigned_raw and assigned_raw not in {"all", "any"}:
|
||||||
|
assigned_admin_id = int(assigned_raw)
|
||||||
|
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||||
|
async with async_session_factory() as session:
|
||||||
|
tickets = await support_dal.list_admin_tickets(
|
||||||
|
session,
|
||||||
|
status=request.query.get("status") or None,
|
||||||
|
priority=request.query.get("priority") or None,
|
||||||
|
category=request.query.get("category") or None,
|
||||||
|
assigned_admin_id=assigned_admin_id,
|
||||||
|
search=request.query.get("search") or None,
|
||||||
|
sort=request.query.get("sort") or "updated_desc",
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
)
|
||||||
|
return web.json_response(
|
||||||
|
{
|
||||||
|
"ok": True,
|
||||||
|
"tickets": [
|
||||||
|
{
|
||||||
|
**_support_ticket_payload(ticket),
|
||||||
|
"user": _admin_support_user_payload(getattr(ticket, "user", None)),
|
||||||
|
}
|
||||||
|
for ticket in tickets
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def admin_support_ticket_detail_route(request: web.Request) -> web.Response:
|
||||||
|
_require_admin_user_id(request)
|
||||||
|
ticket_id = int(request.match_info["id"])
|
||||||
|
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||||
|
service = request.app["support_service"]
|
||||||
|
async with async_session_factory() as session:
|
||||||
|
ticket, messages = await support_dal.get_ticket(session, ticket_id, include_internal=True)
|
||||||
|
if not ticket:
|
||||||
|
return _error(404, "not_found", "Ticket not found")
|
||||||
|
user = await user_dal.get_user_by_id(session, ticket.user_id)
|
||||||
|
snapshot = await service.build_user_snapshot(user, session=session) if user else {}
|
||||||
|
author_ids = {m.author_user_id for m in messages if m.author_user_id is not None}
|
||||||
|
authors = {}
|
||||||
|
for author_id in author_ids:
|
||||||
|
author = await user_dal.get_user_by_id(session, author_id)
|
||||||
|
if author:
|
||||||
|
authors[author_id] = author
|
||||||
|
return web.json_response(
|
||||||
|
{
|
||||||
|
"ok": True,
|
||||||
|
"ticket": {
|
||||||
|
**_support_ticket_payload(ticket),
|
||||||
|
"user": _admin_support_user_payload(user),
|
||||||
|
},
|
||||||
|
"messages": [_support_message_payload(m, authors=authors) for m in messages],
|
||||||
|
"user_snapshot": snapshot,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def admin_support_ticket_reply_route(request: web.Request) -> web.Response:
|
||||||
|
admin_id = _require_admin_user_id(request)
|
||||||
|
ticket_id = int(request.match_info["id"])
|
||||||
|
payload, error = _validate_model_payload(AdminTicketReplyPayload, await _read_json(request))
|
||||||
|
if error:
|
||||||
|
return error
|
||||||
|
try:
|
||||||
|
ticket, message = await request.app["support_service"].reply_as_admin(
|
||||||
|
admin_id,
|
||||||
|
ticket_id,
|
||||||
|
payload.body,
|
||||||
|
is_internal_note=payload.is_internal_note,
|
||||||
|
)
|
||||||
|
except TicketNotFound:
|
||||||
|
return _error(404, "not_found", "Ticket not found")
|
||||||
|
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||||
|
async with async_session_factory() as session:
|
||||||
|
admin = await user_dal.get_user_by_id(session, admin_id)
|
||||||
|
return web.json_response(
|
||||||
|
{
|
||||||
|
"ok": True,
|
||||||
|
"ticket": _support_ticket_payload(ticket),
|
||||||
|
"message": _support_message_payload(
|
||||||
|
message, authors={admin_id: admin} if admin else {}
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def admin_support_ticket_patch_route(request: web.Request) -> web.Response:
|
||||||
|
admin_id = _require_admin_user_id(request)
|
||||||
|
ticket_id = int(request.match_info["id"])
|
||||||
|
payload, error = _validate_model_payload(AdminTicketPatchPayload, await _read_json(request))
|
||||||
|
if error:
|
||||||
|
return error
|
||||||
|
updates = payload.model_dump(exclude_unset=True)
|
||||||
|
try:
|
||||||
|
if updates.get("status") == "closed":
|
||||||
|
ticket = await request.app["support_service"].close_ticket(admin_id, ticket_id)
|
||||||
|
updates.pop("status", None)
|
||||||
|
if updates:
|
||||||
|
ticket = await request.app["support_service"]._update_and_audit(
|
||||||
|
admin_id,
|
||||||
|
ticket_id,
|
||||||
|
**updates,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
ticket = await request.app["support_service"]._update_and_audit(
|
||||||
|
admin_id,
|
||||||
|
ticket_id,
|
||||||
|
**updates,
|
||||||
|
)
|
||||||
|
except TicketNotFound:
|
||||||
|
return _error(404, "not_found", "Ticket not found")
|
||||||
|
return web.json_response({"ok": True, "ticket": _support_ticket_payload(ticket)})
|
||||||
|
|
||||||
|
|
||||||
|
async def admin_support_ticket_read_route(request: web.Request) -> web.Response:
|
||||||
|
_require_admin_user_id(request)
|
||||||
|
ticket_id = int(request.match_info["id"])
|
||||||
|
await request.app["support_service"].mark_read_as_admin(ticket_id)
|
||||||
|
return web.json_response({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
|
async def admin_support_stats_route(request: web.Request) -> web.Response:
|
||||||
|
_require_admin_user_id(request)
|
||||||
|
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||||
|
async with async_session_factory() as session:
|
||||||
|
stats = await support_dal.admin_stats(session)
|
||||||
|
return web.json_response({"ok": True, "stats": stats})
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# ruff: noqa: F401,F403,F405,I001
|
||||||
|
from ._runtime import * # noqa: F403,F405
|
||||||
|
|
||||||
|
|
||||||
|
async def admin_sync_route(request: web.Request) -> web.Response:
|
||||||
|
_require_admin_user_id(request)
|
||||||
|
settings: Settings = request.app["settings"]
|
||||||
|
queued = await enqueue_webhook_event(
|
||||||
|
settings,
|
||||||
|
"panel_sync",
|
||||||
|
{"requested_by": _require_admin_user_id(request)},
|
||||||
|
event_id=None,
|
||||||
|
)
|
||||||
|
if queued:
|
||||||
|
return _ok({"result": {"status": "queued"}})
|
||||||
|
return _error(503, "queue_unavailable")
|
||||||
@@ -0,0 +1,498 @@
|
|||||||
|
# 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 _theme_payload_for_version_compare(theme: Any) -> Dict[str, Any]:
|
||||||
|
if hasattr(theme, "model_dump"):
|
||||||
|
data = theme.model_dump(mode="json", exclude_none=True)
|
||||||
|
elif isinstance(theme, dict):
|
||||||
|
data = dict(theme)
|
||||||
|
else:
|
||||||
|
data = {}
|
||||||
|
data.pop("assets_version", None)
|
||||||
|
data.pop("default", None)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _bump_theme_asset_versions(
|
||||||
|
config: WebappThemesConfig,
|
||||||
|
previous: WebappThemesConfig,
|
||||||
|
) -> WebappThemesConfig:
|
||||||
|
previous_by_key = {theme.key: theme for theme in previous.themes}
|
||||||
|
default_changed = config.default_theme != previous.default_theme
|
||||||
|
data = config.model_dump(mode="json", exclude_none=True)
|
||||||
|
for theme in data.get("themes", []):
|
||||||
|
if not isinstance(theme, dict):
|
||||||
|
continue
|
||||||
|
if not str(theme.get("css_file") or "").strip():
|
||||||
|
continue
|
||||||
|
key = str(theme.get("key") or "")
|
||||||
|
previous_theme = previous_by_key.get(key)
|
||||||
|
previous_version = int(getattr(previous_theme, "assets_version", 0) or 0)
|
||||||
|
current_version = int(theme.get("assets_version") or 1)
|
||||||
|
theme_changed = (
|
||||||
|
previous_theme is None
|
||||||
|
or _theme_payload_for_version_compare(theme)
|
||||||
|
!= _theme_payload_for_version_compare(previous_theme)
|
||||||
|
)
|
||||||
|
if theme_changed or (default_changed and key == config.default_theme):
|
||||||
|
theme["assets_version"] = max(previous_version + 1, current_version, 1)
|
||||||
|
elif previous_version > current_version:
|
||||||
|
theme["assets_version"] = previous_version
|
||||||
|
return WebappThemesConfig.model_validate(data)
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_logo_extension(
|
||||||
|
body: bytes, content_type: str = "", filename: str = ""
|
||||||
|
) -> Optional[str]:
|
||||||
|
content_type = (content_type or "").split(";", 1)[0].strip().lower()
|
||||||
|
suffix = Path(filename or "").suffix.lower()
|
||||||
|
if content_type == "image/png" or body.startswith(b"\x89PNG\r\n\x1a\n"):
|
||||||
|
return ".png"
|
||||||
|
if content_type == "image/jpeg" or body.startswith(b"\xff\xd8\xff"):
|
||||||
|
return ".jpg"
|
||||||
|
if content_type == "image/gif" or body.startswith((b"GIF87a", b"GIF89a")):
|
||||||
|
return ".gif"
|
||||||
|
if content_type == "image/webp" or (
|
||||||
|
len(body) > 12 and body[:4] == b"RIFF" and body[8:12] == b"WEBP"
|
||||||
|
):
|
||||||
|
return ".webp"
|
||||||
|
if content_type in {"image/svg+xml", "image/svg"} or suffix == ".svg":
|
||||||
|
head = body[:512].lstrip().lower()
|
||||||
|
if head.startswith(b"<svg") or b"<svg" in head:
|
||||||
|
return ".svg"
|
||||||
|
if content_type == "image/x-icon" or suffix == ".ico":
|
||||||
|
if body.startswith(b"\x00\x00\x01\x00"):
|
||||||
|
return ".ico"
|
||||||
|
return suffix if suffix in WEBAPP_LOGO_UPLOAD_CONTENT_TYPES else None
|
||||||
|
|
||||||
|
|
||||||
|
def _write_uploaded_logo(body: bytes, content_type: str = "", filename: str = "") -> str:
|
||||||
|
if not body or len(body) > WEBAPP_LOGO_MAX_BYTES:
|
||||||
|
raise ValueError("logo must be a non-empty image up to 2 MiB")
|
||||||
|
ext = _detect_logo_extension(body, content_type, filename)
|
||||||
|
if ext not in WEBAPP_LOGO_UPLOAD_CONTENT_TYPES:
|
||||||
|
raise ValueError("unsupported image type")
|
||||||
|
digest = hashlib.sha256(body).hexdigest()[:16]
|
||||||
|
safe_name = f"logo-{digest}{ext}"
|
||||||
|
WEBAPP_UPLOADED_LOGO_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
(WEBAPP_UPLOADED_LOGO_DIR / safe_name).write_bytes(body)
|
||||||
|
return f"{WEBAPP_UPLOADED_LOGO_PATH}/{safe_name}"
|
||||||
|
|
||||||
|
|
||||||
|
def _uploaded_logo_filename(url: str) -> Optional[str]:
|
||||||
|
parsed = urlsplit(str(url or ""))
|
||||||
|
path = parsed.path if parsed.scheme or parsed.netloc else str(url or "")
|
||||||
|
prefix = f"{WEBAPP_UPLOADED_LOGO_PATH}/"
|
||||||
|
if not path.startswith(prefix):
|
||||||
|
return None
|
||||||
|
filename = path.removeprefix(prefix)
|
||||||
|
if re.fullmatch(r"logo-[0-9a-f]{16}\.(?:gif|ico|jpe?g|png|svg|webp)", filename):
|
||||||
|
return filename
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _favicon_digest(url: str) -> Optional[str]:
|
||||||
|
parsed = urlsplit(str(url or ""))
|
||||||
|
path = parsed.path if parsed.scheme or parsed.netloc else str(url or "")
|
||||||
|
match = re.fullmatch(
|
||||||
|
rf"{re.escape(WEBAPP_FAVICON_PATH)}/([0-9a-f]{{16}})/(?:[A-Za-z0-9_.-]+)",
|
||||||
|
path,
|
||||||
|
)
|
||||||
|
return match.group(1) if match else None
|
||||||
|
|
||||||
|
|
||||||
|
def _emoji_to_codepoints(value: str) -> str:
|
||||||
|
return "_".join(f"{ord(char):x}" for char in str(value or "").strip())
|
||||||
|
|
||||||
|
|
||||||
|
def prune_unused_appearance_assets(settings: Settings) -> None:
|
||||||
|
keep_logos = {
|
||||||
|
filename
|
||||||
|
for filename in [
|
||||||
|
_uploaded_logo_filename(getattr(settings, "WEBAPP_LOGO_URL", "")),
|
||||||
|
]
|
||||||
|
if filename
|
||||||
|
}
|
||||||
|
keep_favicons = {
|
||||||
|
digest
|
||||||
|
for digest in [
|
||||||
|
_favicon_digest(getattr(settings, "WEBAPP_FAVICON_URL", "")),
|
||||||
|
_favicon_digest(getattr(settings, "WEBAPP_LOGO_FAVICON_URL", "")),
|
||||||
|
]
|
||||||
|
if digest
|
||||||
|
}
|
||||||
|
keep_emoji_prefixes = set()
|
||||||
|
if (
|
||||||
|
getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False)
|
||||||
|
and str(getattr(settings, "WEBAPP_LOGO_EMOJI_FONT", "") or "").strip()
|
||||||
|
== "noto-color-animated"
|
||||||
|
):
|
||||||
|
codepoints = _emoji_to_codepoints(getattr(settings, "WEBAPP_LOGO_EMOJI", ""))
|
||||||
|
if codepoints:
|
||||||
|
keep_emoji_prefixes.add(f"{codepoints}.512.")
|
||||||
|
|
||||||
|
for path in WEBAPP_UPLOADED_LOGO_DIR.glob("logo-*"):
|
||||||
|
if path.is_file() and path.name not in keep_logos:
|
||||||
|
try:
|
||||||
|
path.unlink()
|
||||||
|
except OSError:
|
||||||
|
logger.warning("Failed to remove unused webapp logo %s", path, exc_info=True)
|
||||||
|
|
||||||
|
for path in WEBAPP_FAVICON_DIR.glob("*"):
|
||||||
|
if (
|
||||||
|
path.is_dir()
|
||||||
|
and re.fullmatch(r"[0-9a-f]{16}", path.name)
|
||||||
|
and path.name not in keep_favicons
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
shutil.rmtree(path)
|
||||||
|
except OSError:
|
||||||
|
logger.warning("Failed to remove unused webapp favicon set %s", path, exc_info=True)
|
||||||
|
|
||||||
|
for path in WEBAPP_EMOJI_CACHE_DIR.glob("*.512.*"):
|
||||||
|
if path.is_file() and not any(
|
||||||
|
path.name.startswith(prefix) for prefix in keep_emoji_prefixes
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
path.unlink()
|
||||||
|
except OSError:
|
||||||
|
logger.warning("Failed to remove unused webapp emoji asset %s", path, exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def _persist_appearance_upload(
|
||||||
|
request: web.Request,
|
||||||
|
updates: Dict[str, Any],
|
||||||
|
actor_id: int,
|
||||||
|
) -> bool:
|
||||||
|
settings: Settings = request.app["settings"]
|
||||||
|
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||||
|
result = await update_overrides(
|
||||||
|
settings,
|
||||||
|
async_session_factory,
|
||||||
|
updates=updates,
|
||||||
|
deletes=[],
|
||||||
|
actor_id=actor_id,
|
||||||
|
)
|
||||||
|
if not result.get("ok"):
|
||||||
|
logger.warning("Failed to persist uploaded appearance asset settings: %s", result)
|
||||||
|
return False
|
||||||
|
|
||||||
|
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"]
|
||||||
|
previous_config = resolved_webapp_themes_catalog(
|
||||||
|
primary_accent=settings.WEBAPP_PRIMARY_COLOR or "#00fe7a",
|
||||||
|
env_default_theme=settings.WEBAPP_DEFAULT_THEME,
|
||||||
|
theme_dir=settings.WEBAPP_THEMES_DIR,
|
||||||
|
)
|
||||||
|
payload = await _read_json(request)
|
||||||
|
catalog = payload.get("catalog") if "catalog" in payload else payload
|
||||||
|
if not isinstance(catalog, dict):
|
||||||
|
return _error(400, "invalid_payload", "catalog must be an object")
|
||||||
|
|
||||||
|
try:
|
||||||
|
config = WebappThemesConfig.model_validate(catalog)
|
||||||
|
except (ValidationError, ValueError) as exc:
|
||||||
|
return _error(400, "invalid_webapp_themes_config", str(exc))
|
||||||
|
|
||||||
|
config, _changed = ensure_webapp_core_themes(config, settings.WEBAPP_PRIMARY_COLOR or "#00fe7a")
|
||||||
|
config = _bump_theme_asset_versions(config, previous_config)
|
||||||
|
|
||||||
|
try:
|
||||||
|
write_webapp_theme_dir(settings.WEBAPP_THEMES_DIR, config, delete_missing=True)
|
||||||
|
except OSError as exc:
|
||||||
|
logger.exception("Failed to write webapp themes to %s", settings.WEBAPP_THEMES_DIR)
|
||||||
|
return _error(500, "write_failed", str(exc))
|
||||||
|
|
||||||
|
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),
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -1,9 +1,21 @@
|
|||||||
# ruff: noqa: F401,F403,F405,I001
|
# ruff: noqa: F401,F403,F405,I001
|
||||||
from ._runtime import * # noqa: F403,F405
|
from ._runtime import * # noqa: F403,F405
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
from html import escape as html_escape
|
||||||
|
|
||||||
|
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||||
|
|
||||||
|
from bot.app.web.webapp.cache_helpers import invalidate_webapp_user_caches
|
||||||
|
from bot.infra.redis import cache_delete_pattern, redis_key
|
||||||
|
from bot.utils.ttl_cache import AsyncTTLCache
|
||||||
|
|
||||||
|
_ADMIN_USERS_LIST_CACHES: Dict[tuple[int, int], AsyncTTLCache] = {}
|
||||||
|
|
||||||
|
|
||||||
async def admin_users_list_route(request: web.Request) -> web.Response:
|
async def admin_users_list_route(request: web.Request) -> web.Response:
|
||||||
_require_admin_user_id(request)
|
_require_admin_user_id(request)
|
||||||
|
settings: Settings = request.app["settings"]
|
||||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||||
|
|
||||||
page = max(0, int(request.query.get("page", 0) or 0))
|
page = max(0, int(request.query.get("page", 0) or 0))
|
||||||
@@ -14,6 +26,79 @@ async def admin_users_list_route(request: web.Request) -> web.Response:
|
|||||||
premium_traffic = (request.query.get("premium_traffic") or "all").lower()
|
premium_traffic = (request.query.get("premium_traffic") or "all").lower()
|
||||||
sort_value = (request.query.get("sort") or "registered_desc").lower()
|
sort_value = (request.query.get("sort") or "registered_desc").lower()
|
||||||
|
|
||||||
|
payload = await _load_admin_users_list_payload(
|
||||||
|
settings,
|
||||||
|
async_session_factory,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
query=query,
|
||||||
|
filter_value=filter_value,
|
||||||
|
panel_status=panel_status,
|
||||||
|
premium_traffic=premium_traffic,
|
||||||
|
sort_value=sort_value,
|
||||||
|
)
|
||||||
|
return _ok(payload)
|
||||||
|
|
||||||
|
|
||||||
|
async def _load_admin_users_list_payload(
|
||||||
|
settings: Settings,
|
||||||
|
async_session_factory: sessionmaker,
|
||||||
|
*,
|
||||||
|
page: int,
|
||||||
|
page_size: int,
|
||||||
|
query: str,
|
||||||
|
filter_value: str,
|
||||||
|
panel_status: str,
|
||||||
|
premium_traffic: str,
|
||||||
|
sort_value: str,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
cache = _admin_users_list_cache(settings)
|
||||||
|
cache_key = _admin_users_list_cache_key(
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
query=query,
|
||||||
|
filter_value=filter_value,
|
||||||
|
panel_status=panel_status,
|
||||||
|
premium_traffic=premium_traffic,
|
||||||
|
sort_value=sort_value,
|
||||||
|
)
|
||||||
|
if cache is None:
|
||||||
|
return await _load_admin_users_list_payload_uncached(
|
||||||
|
async_session_factory,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
query=query,
|
||||||
|
filter_value=filter_value,
|
||||||
|
panel_status=panel_status,
|
||||||
|
premium_traffic=premium_traffic,
|
||||||
|
sort_value=sort_value,
|
||||||
|
)
|
||||||
|
return await cache.get_or_load(
|
||||||
|
cache_key,
|
||||||
|
lambda: _load_admin_users_list_payload_uncached(
|
||||||
|
async_session_factory,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
query=query,
|
||||||
|
filter_value=filter_value,
|
||||||
|
panel_status=panel_status,
|
||||||
|
premium_traffic=premium_traffic,
|
||||||
|
sort_value=sort_value,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _load_admin_users_list_payload_uncached(
|
||||||
|
async_session_factory: sessionmaker,
|
||||||
|
*,
|
||||||
|
page: int,
|
||||||
|
page_size: int,
|
||||||
|
query: str,
|
||||||
|
filter_value: str,
|
||||||
|
panel_status: str,
|
||||||
|
premium_traffic: str,
|
||||||
|
sort_value: str,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
async with async_session_factory() as session:
|
async with async_session_factory() as session:
|
||||||
users, total = await _filter_and_sort_users(
|
users, total = await _filter_and_sort_users(
|
||||||
session,
|
session,
|
||||||
@@ -47,14 +132,58 @@ async def admin_users_list_route(request: web.Request) -> web.Response:
|
|||||||
payload["premium_traffic"] = _premium_traffic_list_payload(active_subs.get(user.user_id))
|
payload["premium_traffic"] = _premium_traffic_list_payload(active_subs.get(user.user_id))
|
||||||
serialized.append(payload)
|
serialized.append(payload)
|
||||||
|
|
||||||
return _ok(
|
return {
|
||||||
{
|
"users": serialized,
|
||||||
"users": serialized,
|
"page": page,
|
||||||
"page": page,
|
"page_size": page_size,
|
||||||
"page_size": page_size,
|
"total": total,
|
||||||
"total": total,
|
}
|
||||||
}
|
|
||||||
)
|
|
||||||
|
def _admin_users_list_cache(settings: Settings) -> Optional[AsyncTTLCache]:
|
||||||
|
ttl_seconds = int(getattr(settings, "ADMIN_USERS_LIST_CACHE_TTL_SECONDS", 3) or 0)
|
||||||
|
if ttl_seconds <= 0:
|
||||||
|
return None
|
||||||
|
cache_key = (id(settings), ttl_seconds)
|
||||||
|
cache = _ADMIN_USERS_LIST_CACHES.get(cache_key)
|
||||||
|
if cache is None:
|
||||||
|
cache = AsyncTTLCache(
|
||||||
|
ttl_seconds=ttl_seconds,
|
||||||
|
settings=settings,
|
||||||
|
namespace="admin:users_list",
|
||||||
|
)
|
||||||
|
_ADMIN_USERS_LIST_CACHES[cache_key] = cache
|
||||||
|
return cache
|
||||||
|
|
||||||
|
|
||||||
|
def _admin_users_list_cache_key(**params: Any) -> str:
|
||||||
|
raw = json.dumps(params, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||||
|
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
async def _invalidate_admin_users_list_cache(settings: Settings) -> None:
|
||||||
|
for settings_id, _ttl in tuple(_ADMIN_USERS_LIST_CACHES):
|
||||||
|
if settings_id == id(settings):
|
||||||
|
_ADMIN_USERS_LIST_CACHES[(settings_id, _ttl)].invalidate()
|
||||||
|
try:
|
||||||
|
await cache_delete_pattern(settings, redis_key(settings, "cache", "admin:users_list", "*"))
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
async def _invalidate_after_admin_user_mutation(
|
||||||
|
settings: Settings,
|
||||||
|
user_id: Optional[int] = None,
|
||||||
|
*,
|
||||||
|
include_devices: bool = True,
|
||||||
|
) -> None:
|
||||||
|
await _invalidate_admin_users_list_cache(settings)
|
||||||
|
if user_id is not None:
|
||||||
|
await invalidate_webapp_user_caches(
|
||||||
|
settings,
|
||||||
|
user_id,
|
||||||
|
include_devices=include_devices,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _bulk_user_statuses(
|
async def _bulk_user_statuses(
|
||||||
@@ -526,6 +655,7 @@ async def admin_user_ban_route(request: web.Request) -> web.Response:
|
|||||||
payload = await _read_json(request)
|
payload = await _read_json(request)
|
||||||
desired = bool(payload.get("banned"))
|
desired = bool(payload.get("banned"))
|
||||||
|
|
||||||
|
settings: Settings = request.app["settings"]
|
||||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||||
async with async_session_factory() as session:
|
async with async_session_factory() as session:
|
||||||
user = await user_dal.get_user_by_id(session, target_id)
|
user = await user_dal.get_user_by_id(session, target_id)
|
||||||
@@ -534,6 +664,7 @@ async def admin_user_ban_route(request: web.Request) -> web.Response:
|
|||||||
user.is_banned = bool(desired)
|
user.is_banned = bool(desired)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(user)
|
await session.refresh(user)
|
||||||
|
await _invalidate_after_admin_user_mutation(settings, target_id)
|
||||||
return _ok({"user": _serialize_user(user)})
|
return _ok({"user": _serialize_user(user)})
|
||||||
|
|
||||||
|
|
||||||
@@ -624,10 +755,112 @@ async def admin_user_message_preview_route(request: web.Request) -> web.Response
|
|||||||
return _ok({})
|
return _ok({})
|
||||||
|
|
||||||
|
|
||||||
|
def _admin_user_display_name_for_message(user: User) -> str:
|
||||||
|
full = " ".join(
|
||||||
|
part
|
||||||
|
for part in [getattr(user, "first_name", None), getattr(user, "last_name", None)]
|
||||||
|
if part
|
||||||
|
).strip()
|
||||||
|
return (
|
||||||
|
full
|
||||||
|
or (f"@{user.username}" if getattr(user, "username", None) else None)
|
||||||
|
or getattr(user, "email", None)
|
||||||
|
or f"User #{user.user_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def admin_user_telegram_profile_link_route(request: web.Request) -> web.Response:
|
||||||
|
actor_id = _require_admin_user_id(request)
|
||||||
|
admin_telegram_id = request.get("admin_telegram_id")
|
||||||
|
if not admin_telegram_id:
|
||||||
|
return _error(403, "admin_telegram_unavailable")
|
||||||
|
|
||||||
|
queue_manager = get_queue_manager()
|
||||||
|
if not queue_manager:
|
||||||
|
return _error(503, "queue_unavailable")
|
||||||
|
|
||||||
|
target_id = int(request.match_info["user_id"])
|
||||||
|
settings: Settings = request.app["settings"]
|
||||||
|
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||||
|
|
||||||
|
async with async_session_factory() as session:
|
||||||
|
target_user = await user_dal.get_user_by_id(session, target_id)
|
||||||
|
if not target_user:
|
||||||
|
return _error(404, "not_found")
|
||||||
|
if not target_user.telegram_id:
|
||||||
|
return _error(404, "no_telegram_account")
|
||||||
|
|
||||||
|
admin_user = await user_dal.get_user_by_id(session, actor_id)
|
||||||
|
lang = (
|
||||||
|
getattr(admin_user, "language_code", None)
|
||||||
|
or getattr(settings, "DEFAULT_LANGUAGE", None)
|
||||||
|
or "ru"
|
||||||
|
)
|
||||||
|
|
||||||
|
await message_log_dal.create_message_log(
|
||||||
|
session,
|
||||||
|
{
|
||||||
|
"user_id": actor_id,
|
||||||
|
"event_type": "admin_profile_link_webapp",
|
||||||
|
"content": f"Requested Telegram profile link for user_id={target_id}",
|
||||||
|
"is_admin_event": True,
|
||||||
|
"target_user_id": target_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
i18n_instance = request.app.get("i18n")
|
||||||
|
translate = (
|
||||||
|
(lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs))
|
||||||
|
if i18n_instance is not None
|
||||||
|
else (lambda key, **kwargs: key.format(**kwargs) if kwargs else key)
|
||||||
|
)
|
||||||
|
target_name = _admin_user_display_name_for_message(target_user)
|
||||||
|
telegram_id = int(target_user.telegram_id)
|
||||||
|
profile_url = f"tg://user?id={telegram_id}"
|
||||||
|
message_text = translate(
|
||||||
|
"admin_user_profile_link_message",
|
||||||
|
name=html_escape(target_name),
|
||||||
|
user_id=target_user.user_id,
|
||||||
|
telegram_id=telegram_id,
|
||||||
|
)
|
||||||
|
if message_text == "admin_user_profile_link_message":
|
||||||
|
message_text = (
|
||||||
|
f"Профиль пользователя: <b>{html_escape(target_name)}</b>\n"
|
||||||
|
f"User ID: <code>{target_user.user_id}</code>\n"
|
||||||
|
f"Telegram ID: <code>{telegram_id}</code>\n\n"
|
||||||
|
"Нажмите кнопку ниже, чтобы открыть профиль в Telegram."
|
||||||
|
)
|
||||||
|
|
||||||
|
button_text = translate("user_card_open_profile_button")
|
||||||
|
if button_text == "user_card_open_profile_button":
|
||||||
|
button_text = "👤 Открыть профиль"
|
||||||
|
|
||||||
|
markup = InlineKeyboardMarkup(
|
||||||
|
inline_keyboard=[[InlineKeyboardButton(text=button_text, url=profile_url)]]
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await send_message_via_queue(
|
||||||
|
queue_manager,
|
||||||
|
int(admin_telegram_id),
|
||||||
|
MessageContent(content_type="text", text=message_text),
|
||||||
|
parse_mode="HTML",
|
||||||
|
disable_web_page_preview=True,
|
||||||
|
reply_markup=markup,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Admin profile link message enqueue failed: %s", exc)
|
||||||
|
return _error(502, "send_failed", str(exc))
|
||||||
|
|
||||||
|
return _ok({"queued": True})
|
||||||
|
|
||||||
|
|
||||||
async def admin_user_delete_route(request: web.Request) -> web.Response:
|
async def admin_user_delete_route(request: web.Request) -> web.Response:
|
||||||
actor_id = _require_admin_user_id(request)
|
actor_id = _require_admin_user_id(request)
|
||||||
target_id = int(request.match_info["user_id"])
|
target_id = int(request.match_info["user_id"])
|
||||||
|
|
||||||
|
settings: Settings = request.app["settings"]
|
||||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||||
async with async_session_factory() as session:
|
async with async_session_factory() as session:
|
||||||
ok = await user_dal.delete_user_and_relations(session, target_id)
|
ok = await user_dal.delete_user_and_relations(session, target_id)
|
||||||
@@ -644,12 +877,14 @@ async def admin_user_delete_route(request: web.Request) -> web.Response:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
await _invalidate_after_admin_user_mutation(settings, target_id)
|
||||||
return _ok({})
|
return _ok({})
|
||||||
|
|
||||||
|
|
||||||
async def admin_user_reset_trial_route(request: web.Request) -> web.Response:
|
async def admin_user_reset_trial_route(request: web.Request) -> web.Response:
|
||||||
actor_id = _require_admin_user_id(request)
|
actor_id = _require_admin_user_id(request)
|
||||||
target_id = int(request.match_info["user_id"])
|
target_id = int(request.match_info["user_id"])
|
||||||
|
settings: Settings = request.app["settings"]
|
||||||
panel_service = request.app.get("panel_service")
|
panel_service = request.app.get("panel_service")
|
||||||
subscription_service = request.app.get("subscription_service")
|
subscription_service = request.app.get("subscription_service")
|
||||||
if panel_service is None or subscription_service is None:
|
if panel_service is None or subscription_service is None:
|
||||||
@@ -676,6 +911,7 @@ async def admin_user_reset_trial_route(request: web.Request) -> web.Response:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
await _invalidate_after_admin_user_mutation(settings, target_id)
|
||||||
return _ok({})
|
return _ok({})
|
||||||
|
|
||||||
|
|
||||||
@@ -683,6 +919,7 @@ async def admin_user_premium_override_route(request: web.Request) -> web.Respons
|
|||||||
"""Premium-squad traffic overrides only (unlimited toggle + bonus GB)."""
|
"""Premium-squad traffic overrides only (unlimited toggle + bonus GB)."""
|
||||||
actor_id = _require_admin_user_id(request)
|
actor_id = _require_admin_user_id(request)
|
||||||
target_id = int(request.match_info["user_id"])
|
target_id = int(request.match_info["user_id"])
|
||||||
|
settings: Settings = request.app["settings"]
|
||||||
payload = await _read_json(request)
|
payload = await _read_json(request)
|
||||||
subscription_service = request.app.get("subscription_service")
|
subscription_service = request.app.get("subscription_service")
|
||||||
|
|
||||||
@@ -734,6 +971,7 @@ async def admin_user_premium_override_route(request: web.Request) -> web.Respons
|
|||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(active)
|
await session.refresh(active)
|
||||||
|
|
||||||
|
await _invalidate_after_admin_user_mutation(settings, target_id)
|
||||||
return _ok({"subscription": _serialize_subscription(active)})
|
return _ok({"subscription": _serialize_subscription(active)})
|
||||||
|
|
||||||
|
|
||||||
@@ -741,6 +979,7 @@ async def admin_user_regular_traffic_override_route(request: web.Request) -> web
|
|||||||
"""Main (regular) traffic: unlimited-style ceiling + admin bonus GB."""
|
"""Main (regular) traffic: unlimited-style ceiling + admin bonus GB."""
|
||||||
actor_id = _require_admin_user_id(request)
|
actor_id = _require_admin_user_id(request)
|
||||||
target_id = int(request.match_info["user_id"])
|
target_id = int(request.match_info["user_id"])
|
||||||
|
settings: Settings = request.app["settings"]
|
||||||
payload = await _read_json(request)
|
payload = await _read_json(request)
|
||||||
|
|
||||||
unlimited = bool(payload.get("unlimited"))
|
unlimited = bool(payload.get("unlimited"))
|
||||||
@@ -791,6 +1030,7 @@ async def admin_user_regular_traffic_override_route(request: web.Request) -> web
|
|||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(active)
|
await session.refresh(active)
|
||||||
|
|
||||||
|
await _invalidate_after_admin_user_mutation(settings, target_id)
|
||||||
return _ok({"subscription": _serialize_subscription(active)})
|
return _ok({"subscription": _serialize_subscription(active)})
|
||||||
|
|
||||||
|
|
||||||
@@ -805,6 +1045,7 @@ async def admin_user_traffic_grant_route(request: web.Request) -> web.Response:
|
|||||||
"""
|
"""
|
||||||
actor_id = _require_admin_user_id(request)
|
actor_id = _require_admin_user_id(request)
|
||||||
target_id = int(request.match_info["user_id"])
|
target_id = int(request.match_info["user_id"])
|
||||||
|
settings: Settings = request.app["settings"]
|
||||||
payload = await _read_json(request)
|
payload = await _read_json(request)
|
||||||
|
|
||||||
kind = str(payload.get("kind") or "regular").strip().lower()
|
kind = str(payload.get("kind") or "regular").strip().lower()
|
||||||
@@ -865,6 +1106,7 @@ async def admin_user_traffic_grant_route(request: web.Request) -> web.Response:
|
|||||||
|
|
||||||
refreshed = await subscription_dal.get_active_subscription_by_user_id(session, target_id)
|
refreshed = await subscription_dal.get_active_subscription_by_user_id(session, target_id)
|
||||||
|
|
||||||
|
await _invalidate_after_admin_user_mutation(settings, target_id)
|
||||||
return _ok(
|
return _ok(
|
||||||
{
|
{
|
||||||
"subscription": _serialize_subscription(refreshed) if refreshed else None,
|
"subscription": _serialize_subscription(refreshed) if refreshed else None,
|
||||||
@@ -880,6 +1122,7 @@ async def admin_user_traffic_grant_route(request: web.Request) -> web.Response:
|
|||||||
async def admin_user_extend_route(request: web.Request) -> web.Response:
|
async def admin_user_extend_route(request: web.Request) -> web.Response:
|
||||||
actor_id = _require_admin_user_id(request)
|
actor_id = _require_admin_user_id(request)
|
||||||
target_id = int(request.match_info["user_id"])
|
target_id = int(request.match_info["user_id"])
|
||||||
|
settings: Settings = request.app["settings"]
|
||||||
payload = await _read_json(request)
|
payload = await _read_json(request)
|
||||||
try:
|
try:
|
||||||
days = int(payload.get("days") or 0)
|
days = int(payload.get("days") or 0)
|
||||||
@@ -918,6 +1161,7 @@ async def admin_user_extend_route(request: web.Request) -> web.Response:
|
|||||||
|
|
||||||
refreshed = await subscription_dal.get_active_subscription_by_user_id(session, target_id)
|
refreshed = await subscription_dal.get_active_subscription_by_user_id(session, target_id)
|
||||||
|
|
||||||
|
await _invalidate_after_admin_user_mutation(settings, target_id)
|
||||||
return _ok(
|
return _ok(
|
||||||
{
|
{
|
||||||
"subscription": _serialize_subscription(refreshed) if refreshed else None,
|
"subscription": _serialize_subscription(refreshed) if refreshed else None,
|
||||||
@@ -8,6 +8,7 @@ the API, even by an admin.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, List, Optional, Tuple
|
from typing import Any, List, Optional, Tuple
|
||||||
|
|
||||||
@@ -15,7 +16,7 @@ from typing import Any, List, Optional, Tuple
|
|||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class SettingField:
|
class SettingField:
|
||||||
key: str
|
key: str
|
||||||
type: str # "string" | "int" | "float" | "bool" | "text" | "url" | "color" | "secret"
|
type: str # "string" | "int" | "float" | "bool" | "text" | "url" | "color" | "icon"
|
||||||
section: str
|
section: str
|
||||||
label: str
|
label: str
|
||||||
description: str = ""
|
description: str = ""
|
||||||
@@ -28,6 +29,7 @@ class SettingField:
|
|||||||
subsection: Optional[str] = None # group label inside a section
|
subsection: Optional[str] = None # group label inside a section
|
||||||
i18n_label_key: Optional[str] = None
|
i18n_label_key: Optional[str] = None
|
||||||
i18n_description_key: Optional[str] = None
|
i18n_description_key: Optional[str] = None
|
||||||
|
i18n_subsection_key: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
SETTINGS_MANIFEST: List[SettingField] = [
|
SETTINGS_MANIFEST: List[SettingField] = [
|
||||||
@@ -72,13 +74,63 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
|||||||
"Ссылка на канал",
|
"Ссылка на канал",
|
||||||
"Имя пользователя или invite-link.",
|
"Имя пользователя или invite-link.",
|
||||||
),
|
),
|
||||||
|
SettingField(
|
||||||
|
"PANEL_API_URL",
|
||||||
|
"url",
|
||||||
|
"general",
|
||||||
|
"URL API Remnawave",
|
||||||
|
"Например, https://panel.example.com/api.",
|
||||||
|
subsection="Remnawave",
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"PANEL_API_KEY",
|
||||||
|
"string",
|
||||||
|
"general",
|
||||||
|
"API-ключ Remnawave",
|
||||||
|
"Секретный ключ API панели.",
|
||||||
|
secret=True,
|
||||||
|
subsection="Remnawave",
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"PANEL_WEBHOOK_SECRET",
|
||||||
|
"string",
|
||||||
|
"general",
|
||||||
|
"Секрет вебхуков Remnawave",
|
||||||
|
"Используется для проверки входящих вебхуков панели.",
|
||||||
|
secret=True,
|
||||||
|
subsection="Remnawave",
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"USER_SQUAD_UUIDS",
|
||||||
|
"string",
|
||||||
|
"general",
|
||||||
|
"Internal Squads по умолчанию",
|
||||||
|
"UUID через запятую для legacy-режима без JSON-каталога тарифов.",
|
||||||
|
subsection="Remnawave",
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"USER_EXTERNAL_SQUAD_UUID",
|
||||||
|
"string",
|
||||||
|
"general",
|
||||||
|
"External Squad по умолчанию",
|
||||||
|
"Необязательный UUID External Squad для новых пользователей.",
|
||||||
|
subsection="Remnawave",
|
||||||
|
),
|
||||||
# ─── Web app appearance ────────────────────────────────────────
|
# ─── Web app appearance ────────────────────────────────────────
|
||||||
SettingField(
|
SettingField(
|
||||||
"WEBAPP_TITLE", "string", "appearance", "Название Web App", placeholder="Моя подписка"
|
"WEBAPP_TITLE", "string", "appearance", "Название Web App", placeholder="Моя подписка"
|
||||||
),
|
),
|
||||||
|
SettingField(
|
||||||
|
"SUBSCRIPTION_MINI_APP_URL",
|
||||||
|
"url",
|
||||||
|
"appearance",
|
||||||
|
"Публичный URL Mini App",
|
||||||
|
"Например, https://app.example.com/.",
|
||||||
|
),
|
||||||
SettingField(
|
SettingField(
|
||||||
"WEBAPP_PRIMARY_COLOR", "color", "appearance", "Основной цвет", placeholder="#00fe7a"
|
"WEBAPP_PRIMARY_COLOR", "color", "appearance", "Основной цвет", placeholder="#00fe7a"
|
||||||
),
|
),
|
||||||
|
SettingField("WEBAPP_LOGO_USE_EMOJI", "bool", "appearance", "Использовать эмоджи-логотип"),
|
||||||
SettingField("WEBAPP_LOGO_URL", "url", "appearance", "URL логотипа"),
|
SettingField("WEBAPP_LOGO_URL", "url", "appearance", "URL логотипа"),
|
||||||
SettingField("WEBAPP_LOGO_EMOJI", "string", "appearance", "Эмоджи-логотип", placeholder="🫥"),
|
SettingField("WEBAPP_LOGO_EMOJI", "string", "appearance", "Эмоджи-логотип", placeholder="🫥"),
|
||||||
SettingField(
|
SettingField(
|
||||||
@@ -99,6 +151,14 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
|||||||
("noto-local", "Noto Emoji (local)"),
|
("noto-local", "Noto Emoji (local)"),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
SettingField(
|
||||||
|
"WEBAPP_FAVICON_USE_CUSTOM",
|
||||||
|
"bool",
|
||||||
|
"appearance",
|
||||||
|
"Использовать отдельную favicon",
|
||||||
|
),
|
||||||
|
SettingField("WEBAPP_FAVICON_URL", "url", "appearance", "URL отдельной favicon"),
|
||||||
|
SettingField("WEBAPP_LOGO_FAVICON_URL", "url", "appearance", "Favicon из логотипа"),
|
||||||
SettingField("WEBAPP_ENABLED", "bool", "appearance", "Web App включён"),
|
SettingField("WEBAPP_ENABLED", "bool", "appearance", "Web App включён"),
|
||||||
# ─── Subscription periods & pricing ────────────────────────────
|
# ─── Subscription periods & pricing ────────────────────────────
|
||||||
SettingField("MONTH_1_ENABLED", "bool", "pricing", "Тариф 1 месяц"),
|
SettingField("MONTH_1_ENABLED", "bool", "pricing", "Тариф 1 месяц"),
|
||||||
@@ -122,193 +182,39 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
|||||||
"string",
|
"string",
|
||||||
"pricing",
|
"pricing",
|
||||||
"Порядок методов оплаты",
|
"Порядок методов оплаты",
|
||||||
"Через запятую, например: severpay,freekassa,yookassa",
|
"Через запятую, например: severpay,freekassa,yookassa,heleket",
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"SUBSCRIPTION_PURCHASE_DESCRIPTION_ENABLED",
|
||||||
|
"bool",
|
||||||
|
"pricing",
|
||||||
|
"Показывать описание подписки",
|
||||||
|
"Текст появится перед выбором срока покупки или продления.",
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"SUBSCRIPTION_PURCHASE_DESCRIPTION_RU",
|
||||||
|
"text",
|
||||||
|
"pricing",
|
||||||
|
"Описание подписки (RU)",
|
||||||
|
"Русская версия текста на этапе оплаты.",
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"SUBSCRIPTION_PURCHASE_DESCRIPTION_EN",
|
||||||
|
"text",
|
||||||
|
"pricing",
|
||||||
|
"Описание подписки (EN)",
|
||||||
|
"Английская версия текста на этапе оплаты.",
|
||||||
),
|
),
|
||||||
# ─── Payment providers (toggles) ───────────────────────────────
|
# ─── Payment providers (toggles) ───────────────────────────────
|
||||||
# Common
|
# Common
|
||||||
SettingField("STARS_ENABLED", "bool", "payments", "Telegram Stars", subsection="Общие"),
|
SettingField("STARS_ENABLED", "bool", "payments", "Telegram Stars", subsection="common"),
|
||||||
SettingField(
|
SettingField(
|
||||||
"PAYMENT_METHODS_ORDER",
|
"PAYMENT_METHODS_ORDER",
|
||||||
"string",
|
"string",
|
||||||
"payments",
|
"payments",
|
||||||
"Порядок методов оплаты",
|
"Порядок методов оплаты",
|
||||||
"Через запятую: severpay,freekassa,yookassa,platega,stars,cryptopay",
|
"Через запятую: severpay,freekassa,yookassa,platega,stars,cryptopay,heleket",
|
||||||
subsection="Общие",
|
subsection="common",
|
||||||
),
|
|
||||||
# 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 ─────────────────────────────────────────────────────
|
# ─── Trial ─────────────────────────────────────────────────────
|
||||||
SettingField("TRIAL_ENABLED", "bool", "trial", "Триал включён"),
|
SettingField("TRIAL_ENABLED", "bool", "trial", "Триал включён"),
|
||||||
@@ -401,6 +307,7 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
|||||||
),
|
),
|
||||||
SettingField("LOG_NEW_USERS", "bool", "notifications", "Логировать новых пользователей"),
|
SettingField("LOG_NEW_USERS", "bool", "notifications", "Логировать новых пользователей"),
|
||||||
SettingField("LOG_PAYMENTS", "bool", "notifications", "Логировать платежи"),
|
SettingField("LOG_PAYMENTS", "bool", "notifications", "Логировать платежи"),
|
||||||
|
SettingField("LOG_SUPPORT", "bool", "notifications", "Логировать тикеты поддержки"),
|
||||||
SettingField(
|
SettingField(
|
||||||
"LOG_PROMO_ACTIVATIONS", "bool", "notifications", "Логировать активации промокодов"
|
"LOG_PROMO_ACTIVATIONS", "bool", "notifications", "Логировать активации промокодов"
|
||||||
),
|
),
|
||||||
@@ -414,8 +321,8 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
|||||||
"notifications",
|
"notifications",
|
||||||
"Логировать действия администраторов",
|
"Логировать действия администраторов",
|
||||||
"Если выключено, события от пользователей из ADMIN_IDS не записываются в message logs.",
|
"Если выключено, события от пользователей из ADMIN_IDS не записываются в message logs.",
|
||||||
i18n_label_key="settings_field_log_admin_actions_label",
|
i18n_label_key="admin_settings_field_log_admin_actions_label",
|
||||||
i18n_description_key="settings_field_log_admin_actions_description",
|
i18n_description_key="admin_settings_field_log_admin_actions_description",
|
||||||
),
|
),
|
||||||
SettingField(
|
SettingField(
|
||||||
"LOG_LEVEL",
|
"LOG_LEVEL",
|
||||||
@@ -426,6 +333,73 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
|||||||
),
|
),
|
||||||
SettingField("LOG_CHAT_ID", "int", "notifications", "ID чата для логов"),
|
SettingField("LOG_CHAT_ID", "int", "notifications", "ID чата для логов"),
|
||||||
SettingField("LOG_THREAD_ID", "int", "notifications", "ID треда (для супергрупп)"),
|
SettingField("LOG_THREAD_ID", "int", "notifications", "ID треда (для супергрупп)"),
|
||||||
|
SettingField(
|
||||||
|
"LOG_SUPPORT_THREAD_ID",
|
||||||
|
"int",
|
||||||
|
"notifications",
|
||||||
|
"ID треда поддержки",
|
||||||
|
"Тред лог-чата для уведомлений о тикетах поддержки.",
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"SUPPORT_TICKETS_ENABLED",
|
||||||
|
"bool",
|
||||||
|
"support",
|
||||||
|
"Тикеты поддержки включены",
|
||||||
|
"Показывает раздел поддержки в ЛК и включает создание тикетов.",
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"SUPPORT_ADMIN_EMAIL_NOTIFICATIONS_ENABLED",
|
||||||
|
"bool",
|
||||||
|
"support",
|
||||||
|
"Email-уведомления админам",
|
||||||
|
(
|
||||||
|
"Если выключено, новые тикеты и ответы пользователей останутся "
|
||||||
|
"только в Telegram и лог-чате."
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"SUPPORT_ADMIN_NOTIFICATION_COOLDOWN_SECONDS",
|
||||||
|
"int",
|
||||||
|
"support",
|
||||||
|
"Пауза Telegram-уведомлений",
|
||||||
|
(
|
||||||
|
"Минимум секунд между повторными Telegram/log уведомлениями "
|
||||||
|
"по одному непрочитанному тикету."
|
||||||
|
),
|
||||||
|
min=0,
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"SUPPORT_ADMIN_EMAIL_COOLDOWN_SECONDS",
|
||||||
|
"int",
|
||||||
|
"support",
|
||||||
|
"Пауза email-уведомлений",
|
||||||
|
"Минимум секунд между повторными email-уведомлениями по одному непрочитанному тикету.",
|
||||||
|
min=0,
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"SUPPORT_TICKET_MAX_BODY_LENGTH",
|
||||||
|
"int",
|
||||||
|
"support",
|
||||||
|
"Макс. длина сообщения",
|
||||||
|
"Максимальное количество символов в сообщении тикета.",
|
||||||
|
min=1,
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"SUPPORT_TICKET_MAX_SUBJECT_LENGTH",
|
||||||
|
"int",
|
||||||
|
"support",
|
||||||
|
"Макс. длина темы",
|
||||||
|
"Максимальное количество символов в теме тикета.",
|
||||||
|
min=1,
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"SUPPORT_TICKET_RATE_LIMIT_PER_HOUR",
|
||||||
|
"int",
|
||||||
|
"support",
|
||||||
|
"Лимит тикетов в час",
|
||||||
|
"Сколько новых тикетов пользователь может создать за час. 0 — без лимита.",
|
||||||
|
min=0,
|
||||||
|
),
|
||||||
# ─── Devices ───────────────────────────────────────────────────
|
# ─── Devices ───────────────────────────────────────────────────
|
||||||
SettingField("MY_DEVICES_SECTION_ENABLED", "bool", "devices", "Раздел «Мои устройства»"),
|
SettingField("MY_DEVICES_SECTION_ENABLED", "bool", "devices", "Раздел «Мои устройства»"),
|
||||||
SettingField(
|
SettingField(
|
||||||
@@ -436,15 +410,45 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _provider_field_to_setting_field(spec: Any, manifest_field: Any) -> SettingField:
|
||||||
|
return SettingField(
|
||||||
|
key=manifest_field.key,
|
||||||
|
type=manifest_field.type,
|
||||||
|
section="payments",
|
||||||
|
label=manifest_field.label,
|
||||||
|
description=manifest_field.description,
|
||||||
|
placeholder=manifest_field.placeholder,
|
||||||
|
optional=manifest_field.optional,
|
||||||
|
secret=manifest_field.secret,
|
||||||
|
min=manifest_field.min,
|
||||||
|
max=manifest_field.max,
|
||||||
|
choices=tuple(manifest_field.choices) if manifest_field.choices else None,
|
||||||
|
subsection=manifest_field.subsection,
|
||||||
|
i18n_label_key=getattr(manifest_field, "i18n_label_key", None),
|
||||||
|
i18n_description_key=getattr(manifest_field, "i18n_description_key", None),
|
||||||
|
i18n_subsection_key=getattr(manifest_field, "i18n_subsection_key", None),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def aggregated_manifest() -> List[SettingField]:
|
||||||
|
"""SETTINGS_MANIFEST + per-provider fragments declared in provider SPECs."""
|
||||||
|
from bot.payment_providers import iter_provider_manifest_fields # local to avoid cycle
|
||||||
|
|
||||||
|
fields: List[SettingField] = list(SETTINGS_MANIFEST)
|
||||||
|
for spec, manifest_field in iter_provider_manifest_fields():
|
||||||
|
fields.append(_provider_field_to_setting_field(spec, manifest_field))
|
||||||
|
return fields
|
||||||
|
|
||||||
|
|
||||||
def get_field_by_key(key: str) -> Optional[SettingField]:
|
def get_field_by_key(key: str) -> Optional[SettingField]:
|
||||||
for field in SETTINGS_MANIFEST:
|
for field in aggregated_manifest():
|
||||||
if field.key == key:
|
if field.key == key:
|
||||||
return field
|
return field
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def manifest_keys() -> List[str]:
|
def manifest_keys() -> List[str]:
|
||||||
return [f.key for f in SETTINGS_MANIFEST]
|
return [f.key for f in aggregated_manifest()]
|
||||||
|
|
||||||
|
|
||||||
def coerce_value(field: SettingField, raw: Any) -> Any:
|
def coerce_value(field: SettingField, raw: Any) -> Any:
|
||||||
@@ -489,8 +493,25 @@ def coerce_value(field: SettingField, raw: Any) -> Any:
|
|||||||
return str(raw)
|
return str(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def _i18n_slug(value: str) -> str:
|
||||||
|
slug = re.sub(r"[^a-z0-9]+", "_", value.strip().lower()).strip("_")
|
||||||
|
return slug or "default"
|
||||||
|
|
||||||
|
|
||||||
def manifest_payload() -> List[dict]:
|
def manifest_payload() -> List[dict]:
|
||||||
"""Serialize the manifest for the admin UI."""
|
"""Serialize the manifest for the admin UI.
|
||||||
|
|
||||||
|
For provider presentation fields we resolve the SPEC-declared default
|
||||||
|
(e.g. the button text the bot would use if the admin leaves the override
|
||||||
|
blank) and expose it as ``default``; ``placeholder`` falls back to the
|
||||||
|
same value so existing UIs that only read ``placeholder`` also show the
|
||||||
|
hint inside the empty input.
|
||||||
|
"""
|
||||||
|
from bot.payment_providers import (
|
||||||
|
find_manifest_owner,
|
||||||
|
manifest_field_default,
|
||||||
|
provider_webhook_metadata,
|
||||||
|
)
|
||||||
|
|
||||||
sections_order = {
|
sections_order = {
|
||||||
"general": 1,
|
"general": 1,
|
||||||
@@ -500,12 +521,31 @@ def manifest_payload() -> List[dict]:
|
|||||||
"trial": 5,
|
"trial": 5,
|
||||||
"referral": 6,
|
"referral": 6,
|
||||||
"notifications": 7,
|
"notifications": 7,
|
||||||
"devices": 8,
|
"support": 8,
|
||||||
|
"devices": 9,
|
||||||
}
|
}
|
||||||
items: List[dict] = []
|
items: List[dict] = []
|
||||||
for field in SETTINGS_MANIFEST:
|
for field in aggregated_manifest():
|
||||||
auto_label_i18n_key = f"settings_field_{field.key.lower()}_label"
|
auto_label_i18n_key = f"admin_settings_field_{field.key.lower()}_label"
|
||||||
auto_description_i18n_key = f"settings_field_{field.key.lower()}_description"
|
auto_description_i18n_key = f"admin_settings_field_{field.key.lower()}_description"
|
||||||
|
auto_subsection_i18n_key = (
|
||||||
|
f"admin_settings_subsection_{_i18n_slug(field.subsection)}"
|
||||||
|
if field.subsection
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
default_value: Optional[str] = None
|
||||||
|
webhook_metadata: Optional[dict] = None
|
||||||
|
owner = find_manifest_owner(field.key)
|
||||||
|
if owner is not None:
|
||||||
|
spec, manifest_field = owner
|
||||||
|
default_value = manifest_field_default(spec, manifest_field)
|
||||||
|
webhook_metadata = provider_webhook_metadata(spec)
|
||||||
|
|
||||||
|
placeholder = field.placeholder
|
||||||
|
if not placeholder and default_value:
|
||||||
|
placeholder = default_value
|
||||||
|
|
||||||
item = {
|
item = {
|
||||||
"key": field.key,
|
"key": field.key,
|
||||||
"type": field.type,
|
"type": field.type,
|
||||||
@@ -517,11 +557,28 @@ def manifest_payload() -> List[dict]:
|
|||||||
"i18n_label_key": field.i18n_label_key or auto_label_i18n_key,
|
"i18n_label_key": field.i18n_label_key or auto_label_i18n_key,
|
||||||
"i18n_description_key": field.i18n_description_key
|
"i18n_description_key": field.i18n_description_key
|
||||||
or (auto_description_i18n_key if field.description else None),
|
or (auto_description_i18n_key if field.description else None),
|
||||||
"placeholder": field.placeholder,
|
"i18n_subsection_key": field.i18n_subsection_key or auto_subsection_i18n_key,
|
||||||
|
"i18n_placeholder_key": (
|
||||||
|
f"admin_settings_field_{field.key.lower()}_placeholder" if placeholder else None
|
||||||
|
),
|
||||||
|
"placeholder": placeholder,
|
||||||
"optional": field.optional,
|
"optional": field.optional,
|
||||||
"secret": field.secret,
|
"secret": field.secret,
|
||||||
}
|
}
|
||||||
|
if default_value is not None:
|
||||||
|
item["default"] = default_value
|
||||||
|
if webhook_metadata:
|
||||||
|
item.update(webhook_metadata)
|
||||||
if field.choices:
|
if field.choices:
|
||||||
item["choices"] = [{"value": v, "label": lbl} for v, lbl in field.choices]
|
item["choices"] = [
|
||||||
|
{
|
||||||
|
"value": v,
|
||||||
|
"label": lbl,
|
||||||
|
"i18n_label_key": (
|
||||||
|
f"admin_settings_field_{field.key.lower()}_choice_{_i18n_slug(str(v))}"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
for v, lbl in field.choices
|
||||||
|
]
|
||||||
items.append(item)
|
items.append(item)
|
||||||
return items
|
return items
|
||||||
@@ -14,6 +14,7 @@ from bot.app.web.webapp import (
|
|||||||
payloads as _payloads,
|
payloads as _payloads,
|
||||||
routes as _routes,
|
routes as _routes,
|
||||||
serializers as _serializers,
|
serializers as _serializers,
|
||||||
|
support as _support,
|
||||||
)
|
)
|
||||||
|
|
||||||
_MODULES = (
|
_MODULES = (
|
||||||
@@ -26,6 +27,7 @@ _MODULES = (
|
|||||||
_serializers,
|
_serializers,
|
||||||
_billing,
|
_billing,
|
||||||
_devices,
|
_devices,
|
||||||
|
_support,
|
||||||
_routes,
|
_routes,
|
||||||
_application,
|
_application,
|
||||||
)
|
)
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<!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" />
|
||||||
|
<style>
|
||||||
|
.app-boot-fallback {
|
||||||
|
min-height: 100dvh;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 24px;
|
||||||
|
background: #03070b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-boot-fallback__spinner {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border: 2px solid rgba(242, 247, 244, 0.18);
|
||||||
|
border-top-color: #00fe7a;
|
||||||
|
border-radius: 999px;
|
||||||
|
animation: appBootSpin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.app-boot-fallback__spinner {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes appBootSpin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<main id="app">
|
||||||
|
<div class="app-boot-fallback" role="status" aria-label="Загрузка">
|
||||||
|
<div class="app-boot-fallback__spinner" aria-hidden="true"></div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- WEBAPP_I18N_SCRIPT -->
|
||||||
|
<!-- WEBAPP_CONFIG_SCRIPT -->
|
||||||
|
<!-- WEBAPP_JS_SCRIPT -->
|
||||||
|
<!-- WEBAPP_DEV_MOCK_START -->
|
||||||
|
<script src="/subscription_webapp.js" defer></script>
|
||||||
|
<!-- WEBAPP_DEV_MOCK_END -->
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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": 3,
|
||||||
|
"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": 9,
|
||||||
|
"tokens": {
|
||||||
|
"color_scheme": "light",
|
||||||
|
"style_preset": "win95"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ from aiogram.webhook.aiohttp_server import SimpleRequestHandler, setup_applicati
|
|||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from bot.payment_providers import iter_provider_specs, iter_service_keys
|
||||||
from config.settings import Settings
|
from config.settings import Settings
|
||||||
|
|
||||||
|
|
||||||
@@ -29,19 +30,15 @@ def _inject_shared_instances(
|
|||||||
app["settings"] = settings
|
app["settings"] = settings
|
||||||
app["async_session_factory"] = async_session_factory
|
app["async_session_factory"] = async_session_factory
|
||||||
app["i18n"] = dp.get("i18n_instance")
|
app["i18n"] = dp.get("i18n_instance")
|
||||||
for key in (
|
shared_keys = [
|
||||||
"yookassa_service",
|
|
||||||
"lknpd_service",
|
|
||||||
"subscription_service",
|
"subscription_service",
|
||||||
"referral_service",
|
"referral_service",
|
||||||
"panel_service",
|
"panel_service",
|
||||||
"stars_service",
|
|
||||||
"freekassa_service",
|
|
||||||
"cryptopay_service",
|
|
||||||
"panel_webhook_service",
|
"panel_webhook_service",
|
||||||
"platega_service",
|
"lknpd_service",
|
||||||
"severpay_service",
|
*iter_service_keys(),
|
||||||
):
|
]
|
||||||
|
for key in shared_keys:
|
||||||
if hasattr(dp, "workflow_data") and key in dp.workflow_data: # type: ignore
|
if hasattr(dp, "workflow_data") and key in dp.workflow_data: # type: ignore
|
||||||
app[key] = dp.workflow_data[key] # type: ignore
|
app[key] = dp.workflow_data[key] # type: ignore
|
||||||
|
|
||||||
@@ -56,9 +53,24 @@ async def build_and_start_web_app(
|
|||||||
_inject_shared_instances(app, dp, bot, settings, async_session_factory)
|
_inject_shared_instances(app, dp, bot, settings, async_session_factory)
|
||||||
|
|
||||||
async def _healthcheck(request: web.Request) -> web.Response:
|
async def _healthcheck(request: web.Request) -> web.Response:
|
||||||
return web.json_response({"status": "ok"})
|
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("/healthz", _healthcheck)
|
||||||
|
app.router.add_get("/health", _healthcheck)
|
||||||
|
|
||||||
setup_application(app, dp, bot=bot)
|
setup_application(app, dp, bot=bot)
|
||||||
|
|
||||||
@@ -75,38 +87,21 @@ async def build_and_start_web_app(
|
|||||||
f"Telegram webhook route configured at: [POST] {telegram_webhook_path} (relative to base URL)" # noqa: E501
|
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.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
|
registered_webhook_paths: set[str] = set()
|
||||||
if cp_path.startswith("/"):
|
for spec in iter_provider_specs():
|
||||||
app.router.add_post(cp_path, cryptopay_webhook_route)
|
webhook_route = spec.load_webhook_route()
|
||||||
logging.info(f"CryptoPay webhook route configured at: [POST] {cp_path}")
|
if not spec.webhook_path or not webhook_route:
|
||||||
|
continue
|
||||||
fk_path = settings.freekassa_webhook_path
|
if spec.webhook_requires_base_url and not settings.WEBHOOK_BASE_URL:
|
||||||
if fk_path.startswith("/"):
|
continue
|
||||||
app.router.add_post(fk_path, freekassa_webhook_route)
|
path = spec.webhook_path(settings)
|
||||||
logging.info(f"FreeKassa webhook route configured at: [POST] {fk_path}")
|
if not path or not path.startswith("/") or path in registered_webhook_paths:
|
||||||
|
continue
|
||||||
pg_path = settings.platega_webhook_path
|
registered_webhook_paths.add(path)
|
||||||
if pg_path.startswith("/"):
|
app.router.add_post(path, webhook_route)
|
||||||
app.router.add_post(pg_path, platega_webhook_route)
|
logging.info("%s webhook route configured at: [POST] %s", spec.label, path)
|
||||||
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
|
panel_path = settings.panel_webhook_path
|
||||||
if panel_path.startswith("/"):
|
if panel_path.startswith("/"):
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import html
|
||||||
import hmac
|
import hmac
|
||||||
import io
|
import io
|
||||||
import ipaddress
|
import ipaddress
|
||||||
@@ -17,10 +18,9 @@ from collections import deque
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
from urllib.parse import parse_qsl, quote, urlencode, urlsplit, urlunsplit
|
||||||
|
|
||||||
from aiogram import Bot, Dispatcher
|
from aiogram import Bot, Dispatcher
|
||||||
from aiogram.types import LabeledPrice
|
|
||||||
from aiohttp import ClientSession, ClientTimeout, web
|
from aiohttp import ClientSession, ClientTimeout, web
|
||||||
from pydantic import BaseModel, ConfigDict, EmailStr, ValidationError, constr, field_validator
|
from pydantic import BaseModel, ConfigDict, EmailStr, ValidationError, constr, field_validator
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
@@ -41,21 +41,17 @@ from bot.app.web.webapp_auth import (
|
|||||||
verify_telegram_oauth_nonce,
|
verify_telegram_oauth_nonce,
|
||||||
verify_webapp_session_token,
|
verify_webapp_session_token,
|
||||||
)
|
)
|
||||||
from bot.services.crypto_pay_service import CryptoPayService
|
from bot.infra.redis import cache_delete, cache_get_json, cache_set_json, get_redis, redis_key
|
||||||
from bot.services.email_auth_service import EmailAuthService, normalize_email
|
from bot.services.email_auth_service import EmailAuthService, normalize_email
|
||||||
from bot.services.email_templates import render_account_merged
|
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.promo_code_service import PromoCodeService
|
||||||
from bot.services.referral_service import ReferralService
|
from bot.services.referral_service import ReferralService
|
||||||
from bot.services.severpay_service import SeverPayService
|
|
||||||
from bot.services.subscription_service import SubscriptionService
|
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.config_link import prepare_config_links
|
||||||
from bot.utils.request_security import parse_ip_entries, request_client_ip
|
from bot.utils.request_security import parse_ip_entries, request_client_ip
|
||||||
from bot.utils.text_sanitizer import sanitize_display_name, sanitize_username
|
from bot.utils.text_sanitizer import sanitize_display_name, sanitize_username
|
||||||
from config.settings import Settings
|
from config.settings import Settings
|
||||||
from db.dal import payment_dal, subscription_dal, user_dal
|
from db.dal import payment_dal, security_dal, subscription_dal, support_dal, user_dal
|
||||||
from db.dal.user_dal import UserMergeConflictError
|
from db.dal.user_dal import UserMergeConflictError
|
||||||
from db.models import Payment, User, UserTelegramAvatar
|
from db.models import Payment, User, UserTelegramAvatar
|
||||||
|
|
||||||
@@ -63,9 +59,14 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
TEMPLATE_PATH = Path(__file__).resolve().parents[1] / "templates" / "subscription_webapp.html"
|
TEMPLATE_PATH = Path(__file__).resolve().parents[1] / "templates" / "subscription_webapp.html"
|
||||||
ASSET_DIR = TEMPLATE_PATH.parent
|
ASSET_DIR = TEMPLATE_PATH.parent
|
||||||
|
APP_ROOT = Path(__file__).resolve().parents[5]
|
||||||
WEBAPP_LOGO_PROXY_PATH = "/webapp-logo"
|
WEBAPP_LOGO_PROXY_PATH = "/webapp-logo"
|
||||||
WEBAPP_LOGO_CACHE_DIR = Path(__file__).resolve().parents[4] / "data" / "webapp-logo"
|
WEBAPP_LOGO_CACHE_DIR = APP_ROOT / "data" / "webapp-logo"
|
||||||
WEBAPP_EMOJI_CACHE_DIR = Path(__file__).resolve().parents[4] / "data" / "webapp-emoji"
|
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_CONFIG_PLACEHOLDER = "<!-- WEBAPP_CONFIG_SCRIPT -->"
|
||||||
WEBAPP_I18N_PLACEHOLDER = "<!-- WEBAPP_I18N_SCRIPT -->"
|
WEBAPP_I18N_PLACEHOLDER = "<!-- WEBAPP_I18N_SCRIPT -->"
|
||||||
WEBAPP_JS_PLACEHOLDER = "<!-- WEBAPP_JS_SCRIPT -->"
|
WEBAPP_JS_PLACEHOLDER = "<!-- WEBAPP_JS_SCRIPT -->"
|
||||||
@@ -76,6 +77,17 @@ WEBAPP_RATE_LIMIT_WINDOW_SECONDS = 60
|
|||||||
WEBAPP_RATE_LIMIT_MAX_REQUESTS = 30
|
WEBAPP_RATE_LIMIT_MAX_REQUESTS = 30
|
||||||
WEBAPP_LOGO_MAX_BYTES = 2 * 1024 * 1024
|
WEBAPP_LOGO_MAX_BYTES = 2 * 1024 * 1024
|
||||||
WEBAPP_EMOJI_MAX_BYTES = 4 * 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_MAX_BYTES = 128 * 1024
|
||||||
WEBAPP_TELEGRAM_AVATAR_REFRESH_SECONDS = 24 * 60 * 60
|
WEBAPP_TELEGRAM_AVATAR_REFRESH_SECONDS = 24 * 60 * 60
|
||||||
WEBAPP_TELEGRAM_AVATAR_FETCH_TIMEOUT_SECONDS = 4
|
WEBAPP_TELEGRAM_AVATAR_FETCH_TIMEOUT_SECONDS = 4
|
||||||
@@ -91,6 +103,7 @@ WEBAPP_CSRF_EXEMPT_PATHS = {
|
|||||||
"/api/auth/email/request",
|
"/api/auth/email/request",
|
||||||
"/api/auth/email/verify",
|
"/api/auth/email/verify",
|
||||||
"/api/auth/email/magic",
|
"/api/auth/email/magic",
|
||||||
|
"/api/auth/email/password",
|
||||||
"/api/auth/logout",
|
"/api/auth/logout",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,6 +1,14 @@
|
|||||||
# ruff: noqa: F401,F403,F405,I001
|
# ruff: noqa: F401,F403,F405,I001
|
||||||
from ._runtime import * # noqa: F403,F405
|
from ._runtime import * # noqa: F403,F405
|
||||||
|
|
||||||
|
from bot.app.web.webapp.cache_helpers import webapp_cached_user_payload
|
||||||
|
from .auth import (
|
||||||
|
_hash_email_password,
|
||||||
|
_notify_account_merged,
|
||||||
|
_sync_merged_panel_identity_for_user,
|
||||||
|
)
|
||||||
|
from .common import _invalidate_webapp_user_caches
|
||||||
|
|
||||||
|
|
||||||
async def account_email_request_route(request: web.Request) -> web.Response:
|
async def account_email_request_route(request: web.Request) -> web.Response:
|
||||||
user_id = _require_user_id(request)
|
user_id = _require_user_id(request)
|
||||||
@@ -105,7 +113,8 @@ async def account_email_verify_route(request: web.Request) -> web.Response:
|
|||||||
)
|
)
|
||||||
current_user.email = email
|
current_user.email = email
|
||||||
current_user.email_verified_at = datetime.now(timezone.utc)
|
current_user.email_verified_at = datetime.now(timezone.utc)
|
||||||
await _sync_panel_identity_for_user(request, current_user)
|
if not merge_notice:
|
||||||
|
await _sync_panel_identity_for_user(request, current_user)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
final_user_id = int(current_user.user_id)
|
final_user_id = int(current_user.user_id)
|
||||||
final_telegram_id = _telegram_id_for_user(current_user)
|
final_telegram_id = _telegram_id_for_user(current_user)
|
||||||
@@ -118,28 +127,13 @@ async def account_email_verify_route(request: web.Request) -> web.Response:
|
|||||||
merge_end_date = (
|
merge_end_date = (
|
||||||
datetime.fromisoformat(merge_end_date_raw) if merge_end_date_raw else None
|
datetime.fromisoformat(merge_end_date_raw) if merge_end_date_raw else None
|
||||||
)
|
)
|
||||||
await _sync_panel_identity_for_user(
|
await _sync_merged_panel_identity_for_user(
|
||||||
request,
|
request,
|
||||||
current_user,
|
current_user,
|
||||||
|
source_panel_uuid=source_panel_uuid,
|
||||||
|
final_panel_uuid=final_panel_uuid,
|
||||||
expire_at=merge_end_date,
|
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")
|
email_service: EmailAuthService = request.app.get("email_auth_service")
|
||||||
if email_service and final_email:
|
if email_service and final_email:
|
||||||
@@ -173,6 +167,17 @@ async def account_email_verify_route(request: web.Request) -> web.Response:
|
|||||||
logger.exception("Email account link failed")
|
logger.exception("Email account link failed")
|
||||||
return _json_error(500, "link_failed", "Link failed")
|
return _json_error(500, "link_failed", "Link failed")
|
||||||
|
|
||||||
|
await _invalidate_webapp_user_caches(settings, user_id, final_user_id, include_devices=True)
|
||||||
|
if merge_notice:
|
||||||
|
await _notify_account_merged(
|
||||||
|
request,
|
||||||
|
settings,
|
||||||
|
merge_notice=merge_notice,
|
||||||
|
email=final_email,
|
||||||
|
telegram_id=final_telegram_id,
|
||||||
|
username=final_username,
|
||||||
|
first_name=final_first_name,
|
||||||
|
)
|
||||||
if should_notify_email_linked:
|
if should_notify_email_linked:
|
||||||
try:
|
try:
|
||||||
from bot.services.notification_service import NotificationService
|
from bot.services.notification_service import NotificationService
|
||||||
@@ -201,6 +206,84 @@ async def account_email_verify_route(request: web.Request) -> web.Response:
|
|||||||
return _build_webapp_auth_response(settings, response_payload, token=token)
|
return _build_webapp_auth_response(settings, response_payload, token=token)
|
||||||
|
|
||||||
|
|
||||||
|
async def account_password_request_route(request: web.Request) -> web.Response:
|
||||||
|
user_id = _require_user_id(request)
|
||||||
|
settings: Settings = request.app["settings"]
|
||||||
|
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||||
|
|
||||||
|
async with async_session_factory() as session:
|
||||||
|
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||||
|
if not db_user or db_user.is_banned:
|
||||||
|
return _json_error(403, "access_denied", "Access denied")
|
||||||
|
if not db_user.email or not db_user.email_verified_at:
|
||||||
|
return _json_error(400, "email_not_linked", "Email is not linked")
|
||||||
|
email = db_user.email
|
||||||
|
lang = _normalize_language(db_user.language_code or settings.DEFAULT_LANGUAGE)
|
||||||
|
|
||||||
|
return await _request_email_code(
|
||||||
|
request,
|
||||||
|
email=email,
|
||||||
|
purpose="set_password",
|
||||||
|
language_code=lang,
|
||||||
|
target_user_id=user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def account_password_confirm_route(request: web.Request) -> web.Response:
|
||||||
|
user_id = _require_user_id(request)
|
||||||
|
payload = await _read_json(request)
|
||||||
|
password_payload, validation_error = _validate_model_payload(WebAppSetPasswordPayload, payload)
|
||||||
|
if validation_error:
|
||||||
|
return validation_error
|
||||||
|
if password_payload.password != password_payload.password_confirm:
|
||||||
|
return _json_error(400, "password_mismatch", "Passwords do not match")
|
||||||
|
|
||||||
|
settings: Settings = request.app["settings"]
|
||||||
|
email_service: EmailAuthService = request.app["email_auth_service"]
|
||||||
|
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||||
|
async with async_session_factory() as session:
|
||||||
|
try:
|
||||||
|
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||||
|
if not db_user or db_user.is_banned:
|
||||||
|
await session.rollback()
|
||||||
|
return _json_error(403, "access_denied", "Access denied")
|
||||||
|
if not db_user.email or not db_user.email_verified_at:
|
||||||
|
await session.rollback()
|
||||||
|
return _json_error(400, "email_not_linked", "Email is not linked")
|
||||||
|
|
||||||
|
verify_result = await email_service.verify_code(
|
||||||
|
session,
|
||||||
|
email=db_user.email,
|
||||||
|
purpose="set_password",
|
||||||
|
code=str(password_payload.code or ""),
|
||||||
|
target_user_id=user_id,
|
||||||
|
)
|
||||||
|
if not verify_result.ok:
|
||||||
|
await session.commit()
|
||||||
|
status = 429 if verify_result.error == "rate_limited" else 400
|
||||||
|
return web.json_response(
|
||||||
|
{
|
||||||
|
"ok": False,
|
||||||
|
"error": verify_result.error or "invalid_code",
|
||||||
|
"retry_after": verify_result.retry_after,
|
||||||
|
"message": "Invalid code",
|
||||||
|
},
|
||||||
|
status=status,
|
||||||
|
)
|
||||||
|
|
||||||
|
db_user.password_hash = _hash_email_password(str(password_payload.password))
|
||||||
|
db_user.password_set_at = datetime.now(timezone.utc)
|
||||||
|
await session.flush()
|
||||||
|
await session.commit()
|
||||||
|
except Exception:
|
||||||
|
await session.rollback()
|
||||||
|
logger.exception("Email password setup failed")
|
||||||
|
return _json_error(500, "password_setup_failed", "Password setup failed")
|
||||||
|
|
||||||
|
await _invalidate_webapp_user_caches(settings, user_id)
|
||||||
|
return web.json_response({"ok": True, "password_auth_enabled": True})
|
||||||
|
|
||||||
|
|
||||||
async def account_telegram_link_route(request: web.Request) -> web.Response:
|
async def account_telegram_link_route(request: web.Request) -> web.Response:
|
||||||
user_id = _require_user_id(request)
|
user_id = _require_user_id(request)
|
||||||
settings: Settings = request.app["settings"]
|
settings: Settings = request.app["settings"]
|
||||||
@@ -262,28 +345,13 @@ async def account_telegram_link_route(request: web.Request) -> web.Response:
|
|||||||
merge_end_date = (
|
merge_end_date = (
|
||||||
datetime.fromisoformat(merge_end_date_raw) if merge_end_date_raw else None
|
datetime.fromisoformat(merge_end_date_raw) if merge_end_date_raw else None
|
||||||
)
|
)
|
||||||
await _sync_panel_identity_for_user(
|
await _sync_merged_panel_identity_for_user(
|
||||||
request,
|
request,
|
||||||
db_user,
|
db_user,
|
||||||
|
source_panel_uuid=source_panel_uuid,
|
||||||
|
final_panel_uuid=final_panel_uuid,
|
||||||
expire_at=merge_end_date,
|
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")
|
email_service: EmailAuthService = request.app.get("email_auth_service")
|
||||||
if email_service and final_email:
|
if email_service and final_email:
|
||||||
@@ -317,6 +385,17 @@ async def account_telegram_link_route(request: web.Request) -> web.Response:
|
|||||||
logger.exception("Telegram account link failed")
|
logger.exception("Telegram account link failed")
|
||||||
return _json_error(500, "link_failed", "Link failed")
|
return _json_error(500, "link_failed", "Link failed")
|
||||||
|
|
||||||
|
await _invalidate_webapp_user_caches(settings, user_id, final_user_id, include_devices=True)
|
||||||
|
if merge_notice:
|
||||||
|
await _notify_account_merged(
|
||||||
|
request,
|
||||||
|
settings,
|
||||||
|
merge_notice=merge_notice,
|
||||||
|
email=final_email,
|
||||||
|
telegram_id=final_telegram_id,
|
||||||
|
username=final_username,
|
||||||
|
first_name=final_first_name,
|
||||||
|
)
|
||||||
if should_notify_telegram_linked and final_telegram_id:
|
if should_notify_telegram_linked and final_telegram_id:
|
||||||
try:
|
try:
|
||||||
from bot.services.notification_service import NotificationService
|
from bot.services.notification_service import NotificationService
|
||||||
@@ -350,7 +429,14 @@ async def account_telegram_link_route(request: web.Request) -> web.Response:
|
|||||||
|
|
||||||
async def me_route(request: web.Request) -> web.Response:
|
async def me_route(request: web.Request) -> web.Response:
|
||||||
user_id = _require_user_id(request)
|
user_id = _require_user_id(request)
|
||||||
data = await _build_user_payload(request, user_id)
|
settings: Settings = request.app["settings"]
|
||||||
|
data = await webapp_cached_user_payload(
|
||||||
|
settings,
|
||||||
|
"me",
|
||||||
|
user_id,
|
||||||
|
int(getattr(settings, "WEBAPP_ME_CACHE_TTL_SECONDS", 15) or 0),
|
||||||
|
lambda: _build_user_payload(request, user_id),
|
||||||
|
)
|
||||||
return web.json_response({"ok": True, **data})
|
return web.json_response({"ok": True, **data})
|
||||||
|
|
||||||
|
|
||||||
@@ -403,6 +489,7 @@ async def account_language_route(request: web.Request) -> web.Response:
|
|||||||
await session.flush()
|
await session.flush()
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
|
await _invalidate_webapp_user_caches(settings, user_id)
|
||||||
return web.json_response({"ok": True, "language": language})
|
return web.json_response({"ok": True, "language": language})
|
||||||
|
|
||||||
|
|
||||||
@@ -38,22 +38,22 @@ def create_subscription_webapp_application(
|
|||||||
app.on_startup.append(_startup)
|
app.on_startup.append(_startup)
|
||||||
app.on_shutdown.append(_shutdown)
|
app.on_shutdown.append(_shutdown)
|
||||||
|
|
||||||
|
from bot.payment_providers import iter_service_keys
|
||||||
|
|
||||||
for key in (
|
for key in (
|
||||||
"subscription_service",
|
"subscription_service",
|
||||||
"yookassa_service",
|
|
||||||
"freekassa_service",
|
|
||||||
"cryptopay_service",
|
|
||||||
"platega_service",
|
|
||||||
"severpay_service",
|
|
||||||
"promo_code_service",
|
"promo_code_service",
|
||||||
"referral_service",
|
"referral_service",
|
||||||
|
"support_service",
|
||||||
|
"notification_service",
|
||||||
|
"email_auth_service",
|
||||||
"panel_service",
|
"panel_service",
|
||||||
|
*iter_service_keys(),
|
||||||
):
|
):
|
||||||
if hasattr(dp, "workflow_data") and key in dp.workflow_data: # type: ignore[attr-defined]
|
if hasattr(dp, "workflow_data") and key in dp.workflow_data: # type: ignore[attr-defined]
|
||||||
app[key] = dp.workflow_data[key] # type: ignore[index]
|
app[key] = dp.workflow_data[key] # type: ignore[index]
|
||||||
|
|
||||||
# type: ignore[attr-defined]
|
if hasattr(dp, "workflow_data") and "bot_username" in dp.workflow_data: # type: ignore[attr-defined]
|
||||||
if hasattr(dp, "workflow_data") and "bot_username" in dp.workflow_data:
|
|
||||||
app["bot_username"] = dp.workflow_data["bot_username"] # type: ignore[index]
|
app["bot_username"] = dp.workflow_data["bot_username"] # type: ignore[index]
|
||||||
|
|
||||||
setup_subscription_webapp_routes(app)
|
setup_subscription_webapp_routes(app)
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
# ruff: noqa: F401,F403,F405,I001
|
# ruff: noqa: F401,F403,F405,I001
|
||||||
from ._runtime import * # noqa: F403,F405
|
from ._runtime import * # noqa: F403,F405
|
||||||
|
from .common import _invalidate_webapp_user_caches
|
||||||
|
|
||||||
|
|
||||||
def _resolve_telegram_bot_id(bot_token: str) -> Optional[int]:
|
def _resolve_telegram_bot_id(bot_token: str) -> Optional[int]:
|
||||||
@@ -132,6 +133,58 @@ def _urlsafe_sha256(value: str) -> str:
|
|||||||
return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
|
return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
|
||||||
|
|
||||||
|
|
||||||
|
PASSWORD_HASH_ALGORITHM = "pbkdf2_sha256"
|
||||||
|
PASSWORD_HASH_ITERATIONS = 260_000
|
||||||
|
|
||||||
|
|
||||||
|
def _password_hash_b64(value: bytes) -> str:
|
||||||
|
return base64.urlsafe_b64encode(value).decode("ascii").rstrip("=")
|
||||||
|
|
||||||
|
|
||||||
|
def _password_hash_unb64(value: str) -> bytes:
|
||||||
|
padding = "=" * (-len(value) % 4)
|
||||||
|
return base64.urlsafe_b64decode((value + padding).encode("ascii"))
|
||||||
|
|
||||||
|
|
||||||
|
def _hash_email_password(password: str) -> str:
|
||||||
|
salt = secrets.token_bytes(18)
|
||||||
|
digest = hashlib.pbkdf2_hmac(
|
||||||
|
"sha256",
|
||||||
|
password.encode("utf-8"),
|
||||||
|
salt,
|
||||||
|
PASSWORD_HASH_ITERATIONS,
|
||||||
|
)
|
||||||
|
return "$".join(
|
||||||
|
[
|
||||||
|
PASSWORD_HASH_ALGORITHM,
|
||||||
|
str(PASSWORD_HASH_ITERATIONS),
|
||||||
|
_password_hash_b64(salt),
|
||||||
|
_password_hash_b64(digest),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_email_password(password: str, stored_hash: Optional[str]) -> bool:
|
||||||
|
if not stored_hash:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
algorithm, iterations_raw, salt_raw, digest_raw = stored_hash.split("$", 3)
|
||||||
|
if algorithm != PASSWORD_HASH_ALGORITHM:
|
||||||
|
return False
|
||||||
|
iterations = int(iterations_raw)
|
||||||
|
salt = _password_hash_unb64(salt_raw)
|
||||||
|
expected_digest = _password_hash_unb64(digest_raw)
|
||||||
|
actual_digest = hashlib.pbkdf2_hmac(
|
||||||
|
"sha256",
|
||||||
|
password.encode("utf-8"),
|
||||||
|
salt,
|
||||||
|
iterations,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
return hmac.compare_digest(actual_digest, expected_digest)
|
||||||
|
|
||||||
|
|
||||||
async def _exchange_telegram_oauth_code(
|
async def _exchange_telegram_oauth_code(
|
||||||
request: web.Request,
|
request: web.Request,
|
||||||
*,
|
*,
|
||||||
@@ -286,10 +339,20 @@ async def telegram_oauth_callback_route(request: web.Request) -> web.Response:
|
|||||||
redirect_path = "/settings" if purpose == "link" else "/"
|
redirect_path = "/settings" if purpose == "link" else "/"
|
||||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||||
final_user_id: Optional[int] = None
|
final_user_id: Optional[int] = None
|
||||||
|
source_user_id_for_cache: Optional[int] = None
|
||||||
|
linked_user_for_panel: Optional[User] = None
|
||||||
|
link_source_panel_uuid: Optional[str] = None
|
||||||
|
link_final_panel_uuid: Optional[str] = None
|
||||||
|
link_merge_notice: Optional[Dict[str, Any]] = None
|
||||||
async with async_session_factory() as session:
|
async with async_session_factory() as session:
|
||||||
try:
|
try:
|
||||||
if purpose == "link":
|
if purpose == "link":
|
||||||
current_user_id = int(state.get("user_id") or 0)
|
current_user_id = int(state.get("user_id") or 0)
|
||||||
|
source_user_id_for_cache = current_user_id
|
||||||
|
current_user_before_link = await user_dal.get_user_by_id(session, current_user_id)
|
||||||
|
link_source_panel_uuid = (
|
||||||
|
current_user_before_link.panel_user_uuid if current_user_before_link else None
|
||||||
|
)
|
||||||
db_user = await _link_telegram_to_user(
|
db_user = await _link_telegram_to_user(
|
||||||
request,
|
request,
|
||||||
session,
|
session,
|
||||||
@@ -297,6 +360,16 @@ async def telegram_oauth_callback_route(request: web.Request) -> web.Response:
|
|||||||
telegram_user=telegram_user,
|
telegram_user=telegram_user,
|
||||||
settings=settings,
|
settings=settings,
|
||||||
)
|
)
|
||||||
|
if int(db_user.user_id) != current_user_id:
|
||||||
|
link_final_panel_uuid = db_user.panel_user_uuid
|
||||||
|
link_merge_notice = await _build_account_merge_notice(
|
||||||
|
session,
|
||||||
|
merged_user=db_user,
|
||||||
|
source_user_id=current_user_id,
|
||||||
|
source_panel_uuid=link_source_panel_uuid,
|
||||||
|
settings=settings,
|
||||||
|
)
|
||||||
|
linked_user_for_panel = db_user
|
||||||
else:
|
else:
|
||||||
db_user = await _ensure_user_from_telegram(
|
db_user = await _ensure_user_from_telegram(
|
||||||
session,
|
session,
|
||||||
@@ -334,6 +407,35 @@ async def telegram_oauth_callback_route(request: web.Request) -> web.Response:
|
|||||||
logger.exception("Telegram OAuth callback failed")
|
logger.exception("Telegram OAuth callback failed")
|
||||||
raise redirect(redirect_path, "failed")
|
raise redirect(redirect_path, "failed")
|
||||||
|
|
||||||
|
await _invalidate_webapp_user_caches(settings, final_user_id, include_devices=True)
|
||||||
|
if source_user_id_for_cache and source_user_id_for_cache != final_user_id:
|
||||||
|
await _invalidate_webapp_user_caches(
|
||||||
|
settings,
|
||||||
|
source_user_id_for_cache,
|
||||||
|
final_user_id,
|
||||||
|
include_devices=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if purpose == "link" and link_merge_notice and linked_user_for_panel:
|
||||||
|
merge_end_date_raw = link_merge_notice.get("final_end_date")
|
||||||
|
merge_end_date = datetime.fromisoformat(merge_end_date_raw) if merge_end_date_raw else None
|
||||||
|
await _sync_merged_panel_identity_for_user(
|
||||||
|
request,
|
||||||
|
linked_user_for_panel,
|
||||||
|
source_panel_uuid=link_source_panel_uuid,
|
||||||
|
final_panel_uuid=link_final_panel_uuid,
|
||||||
|
expire_at=merge_end_date,
|
||||||
|
)
|
||||||
|
await _notify_account_merged(
|
||||||
|
request,
|
||||||
|
settings,
|
||||||
|
merge_notice=link_merge_notice,
|
||||||
|
email=linked_user_for_panel.email,
|
||||||
|
telegram_id=_telegram_id_for_user(linked_user_for_panel),
|
||||||
|
username=linked_user_for_panel.username,
|
||||||
|
first_name=linked_user_for_panel.first_name,
|
||||||
|
)
|
||||||
|
|
||||||
token = create_webapp_session_token(settings, int(final_user_id))
|
token = create_webapp_session_token(settings, int(final_user_id))
|
||||||
response = web.HTTPFound(_telegram_oauth_redirect_url(redirect_path, status="success"))
|
response = web.HTTPFound(_telegram_oauth_redirect_url(redirect_path, status="success"))
|
||||||
_clear_telegram_oauth_state_cookie(response)
|
_clear_telegram_oauth_state_cookie(response)
|
||||||
@@ -428,6 +530,7 @@ async def auth_token_route(request: web.Request) -> web.Response:
|
|||||||
logger.exception("WebApp auth failed")
|
logger.exception("WebApp auth failed")
|
||||||
return _json_error(500, "auth_failed", "Auth failed")
|
return _json_error(500, "auth_failed", "Auth failed")
|
||||||
|
|
||||||
|
await _invalidate_webapp_user_caches(settings, authenticated_user_id, include_devices=True)
|
||||||
token = create_webapp_session_token(settings, int(authenticated_user_id))
|
token = create_webapp_session_token(settings, int(authenticated_user_id))
|
||||||
return _build_webapp_auth_response(settings, {"ok": True}, token=token)
|
return _build_webapp_auth_response(settings, {"ok": True}, token=token)
|
||||||
|
|
||||||
@@ -438,6 +541,114 @@ async def logout_route(request: web.Request) -> web.Response:
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def _password_login_failure_response(
|
||||||
|
*,
|
||||||
|
status: int = 401,
|
||||||
|
retry_after: Optional[int] = None,
|
||||||
|
) -> web.Response:
|
||||||
|
payload: Dict[str, Any] = {
|
||||||
|
"ok": False,
|
||||||
|
"error": "password_login_failed",
|
||||||
|
"fallback": "email_code",
|
||||||
|
"message": "Password login failed",
|
||||||
|
}
|
||||||
|
if retry_after is not None:
|
||||||
|
payload["retry_after"] = retry_after
|
||||||
|
return web.json_response(payload, status=status)
|
||||||
|
|
||||||
|
|
||||||
|
async def email_password_auth_route(request: web.Request) -> web.Response:
|
||||||
|
settings: Settings = request.app["settings"]
|
||||||
|
if not settings.email_auth_configured:
|
||||||
|
return _json_error(503, "email_auth_not_configured", "Email auth is not configured")
|
||||||
|
|
||||||
|
payload = await _read_json(request)
|
||||||
|
password_payload, validation_error = _validate_model_payload(
|
||||||
|
WebAppEmailPasswordPayload,
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
if validation_error:
|
||||||
|
return validation_error
|
||||||
|
|
||||||
|
email = password_payload.email
|
||||||
|
password = str(password_payload.password or "")
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||||
|
authenticated_user_id: Optional[int] = None
|
||||||
|
authenticated_telegram_id: Optional[int] = None
|
||||||
|
async with async_session_factory() as session:
|
||||||
|
try:
|
||||||
|
throttle = await security_dal.check_throttle(
|
||||||
|
session,
|
||||||
|
scope=security_dal.EMAIL_PASSWORD_LOGIN_SCOPE,
|
||||||
|
identifier=email,
|
||||||
|
now=now,
|
||||||
|
)
|
||||||
|
if throttle.locked:
|
||||||
|
await session.commit()
|
||||||
|
return _json_error(
|
||||||
|
429,
|
||||||
|
"rate_limited",
|
||||||
|
"Too many password attempts",
|
||||||
|
)
|
||||||
|
|
||||||
|
db_user = await user_dal.get_user_by_email(session, email)
|
||||||
|
password_ok = bool(
|
||||||
|
db_user
|
||||||
|
and db_user.email_verified_at
|
||||||
|
and db_user.password_hash
|
||||||
|
and _verify_email_password(password, db_user.password_hash)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not password_ok:
|
||||||
|
throttle_result = await security_dal.record_throttle_failure(
|
||||||
|
session,
|
||||||
|
scope=security_dal.EMAIL_PASSWORD_LOGIN_SCOPE,
|
||||||
|
identifier=email,
|
||||||
|
max_failures=settings.BRUTE_FORCE_MAX_FAILURES,
|
||||||
|
window_seconds=settings.BRUTE_FORCE_WINDOW_SECONDS,
|
||||||
|
lock_seconds=settings.BRUTE_FORCE_LOCK_SECONDS,
|
||||||
|
now=now,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
if throttle_result.locked:
|
||||||
|
return _json_error(
|
||||||
|
429,
|
||||||
|
"rate_limited",
|
||||||
|
"Too many password attempts",
|
||||||
|
)
|
||||||
|
return _password_login_failure_response()
|
||||||
|
|
||||||
|
if db_user.is_banned:
|
||||||
|
await session.rollback()
|
||||||
|
return _json_error(403, "banned", "Access denied")
|
||||||
|
|
||||||
|
await security_dal.clear_throttle_state(
|
||||||
|
session,
|
||||||
|
scope=security_dal.EMAIL_PASSWORD_LOGIN_SCOPE,
|
||||||
|
identifier=email,
|
||||||
|
)
|
||||||
|
authenticated_user_id = int(db_user.user_id)
|
||||||
|
authenticated_telegram_id = _telegram_id_for_user(db_user)
|
||||||
|
await session.commit()
|
||||||
|
except Exception:
|
||||||
|
await session.rollback()
|
||||||
|
logger.exception("Email password auth failed")
|
||||||
|
return _json_error(500, "auth_failed", "Auth failed")
|
||||||
|
|
||||||
|
token = create_webapp_session_token(settings, int(authenticated_user_id))
|
||||||
|
return _build_webapp_auth_response(
|
||||||
|
settings,
|
||||||
|
{
|
||||||
|
"ok": True,
|
||||||
|
"user_id": int(authenticated_user_id),
|
||||||
|
"telegram_id": authenticated_telegram_id,
|
||||||
|
},
|
||||||
|
token=token,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def email_auth_request_route(request: web.Request) -> web.Response:
|
async def email_auth_request_route(request: web.Request) -> web.Response:
|
||||||
settings: Settings = request.app["settings"]
|
settings: Settings = request.app["settings"]
|
||||||
payload = await _read_json(request)
|
payload = await _read_json(request)
|
||||||
@@ -534,6 +745,7 @@ async def email_auth_verify_route(request: web.Request) -> web.Response:
|
|||||||
logger.exception("Email WebApp auth failed")
|
logger.exception("Email WebApp auth failed")
|
||||||
return _json_error(500, "auth_failed", "Auth failed")
|
return _json_error(500, "auth_failed", "Auth failed")
|
||||||
|
|
||||||
|
await _invalidate_webapp_user_caches(settings, int(db_user.user_id), include_devices=True)
|
||||||
if created_user:
|
if created_user:
|
||||||
try:
|
try:
|
||||||
from bot.services.notification_service import NotificationService
|
from bot.services.notification_service import NotificationService
|
||||||
@@ -641,6 +853,7 @@ async def email_auth_magic_route(request: web.Request) -> web.Response:
|
|||||||
logger.exception("Email magic-link auth failed")
|
logger.exception("Email magic-link auth failed")
|
||||||
return _json_error(500, "auth_failed", "Auth failed")
|
return _json_error(500, "auth_failed", "Auth failed")
|
||||||
|
|
||||||
|
await _invalidate_webapp_user_caches(settings, int(db_user.user_id), include_devices=True)
|
||||||
if created_user and verified_email:
|
if created_user and verified_email:
|
||||||
try:
|
try:
|
||||||
from bot.services.notification_service import NotificationService
|
from bot.services.notification_service import NotificationService
|
||||||
@@ -809,6 +1022,14 @@ def _panel_description_for_user(user: User) -> str:
|
|||||||
return "\n".join(line for line in lines if line).strip()
|
return "\n".join(line for line in lines if line).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _telegram_photo_url_value(telegram_user: Dict[str, Any]) -> Optional[str]:
|
||||||
|
raw_value = telegram_user.get("photo_url")
|
||||||
|
if not raw_value:
|
||||||
|
return None
|
||||||
|
value = str(raw_value).strip()
|
||||||
|
return value or None
|
||||||
|
|
||||||
|
|
||||||
async def _sync_panel_identity_for_user(
|
async def _sync_panel_identity_for_user(
|
||||||
request: web.Request,
|
request: web.Request,
|
||||||
user: User,
|
user: User,
|
||||||
@@ -830,7 +1051,11 @@ async def _sync_panel_identity_for_user(
|
|||||||
if user.email:
|
if user.email:
|
||||||
payload["email"] = user.email
|
payload["email"] = user.email
|
||||||
if expire_at is not None:
|
if expire_at is not None:
|
||||||
|
if expire_at.tzinfo is None:
|
||||||
|
expire_at = expire_at.replace(tzinfo=timezone.utc)
|
||||||
payload["expireAt"] = expire_at.isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
payload["expireAt"] = expire_at.isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
||||||
|
if expire_at > datetime.now(timezone.utc):
|
||||||
|
payload["status"] = "ACTIVE"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await subscription_service.panel_service.update_user_details_on_panel(
|
await subscription_service.panel_service.update_user_details_on_panel(
|
||||||
@@ -848,6 +1073,53 @@ async def _sync_panel_identity_for_user(
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def _delete_merged_source_panel_user(
|
||||||
|
request: web.Request,
|
||||||
|
*,
|
||||||
|
source_panel_uuid: Optional[str],
|
||||||
|
final_panel_uuid: Optional[str],
|
||||||
|
) -> bool:
|
||||||
|
if not source_panel_uuid or not final_panel_uuid or source_panel_uuid == final_panel_uuid:
|
||||||
|
return True
|
||||||
|
|
||||||
|
subscription_service: SubscriptionService = request.app.get("subscription_service")
|
||||||
|
if not subscription_service or not subscription_service.panel_service:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
return bool(
|
||||||
|
await subscription_service.panel_service.delete_user_from_panel(
|
||||||
|
source_panel_uuid,
|
||||||
|
log_response=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to delete merged source panel user %s: %s",
|
||||||
|
source_panel_uuid,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def _sync_merged_panel_identity_for_user(
|
||||||
|
request: web.Request,
|
||||||
|
user: User,
|
||||||
|
*,
|
||||||
|
source_panel_uuid: Optional[str],
|
||||||
|
final_panel_uuid: Optional[str],
|
||||||
|
expire_at: Optional[datetime] = None,
|
||||||
|
) -> bool:
|
||||||
|
# Remnawave keeps email/telegramId unique. Remove the losing panel identity
|
||||||
|
# before patching the surviving one so merged accounts can accept both IDs.
|
||||||
|
await _delete_merged_source_panel_user(
|
||||||
|
request,
|
||||||
|
source_panel_uuid=source_panel_uuid,
|
||||||
|
final_panel_uuid=final_panel_uuid or user.panel_user_uuid,
|
||||||
|
)
|
||||||
|
return await _sync_panel_identity_for_user(request, user, expire_at=expire_at)
|
||||||
|
|
||||||
|
|
||||||
async def _build_account_merge_notice(
|
async def _build_account_merge_notice(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
*,
|
*,
|
||||||
@@ -885,6 +1157,42 @@ async def _build_account_merge_notice(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _notify_account_merged(
|
||||||
|
request: web.Request,
|
||||||
|
settings: Settings,
|
||||||
|
*,
|
||||||
|
merge_notice: Optional[Dict[str, Any]],
|
||||||
|
email: Optional[str],
|
||||||
|
telegram_id: Optional[int],
|
||||||
|
username: Optional[str],
|
||||||
|
first_name: Optional[str],
|
||||||
|
) -> None:
|
||||||
|
if not merge_notice:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
from bot.services.notification_service import NotificationService
|
||||||
|
|
||||||
|
bot: Bot = request.app["bot"]
|
||||||
|
notification_service = NotificationService(
|
||||||
|
bot,
|
||||||
|
settings,
|
||||||
|
request.app.get("i18n"),
|
||||||
|
)
|
||||||
|
await notification_service.notify_account_merged(
|
||||||
|
primary_user_id=int(merge_notice.get("primary_user_id") or 0),
|
||||||
|
removed_user_id=int(merge_notice.get("removed_user_id") or 0),
|
||||||
|
email=email,
|
||||||
|
telegram_id=telegram_id,
|
||||||
|
username=username,
|
||||||
|
first_name=first_name,
|
||||||
|
final_end_date_text=str(merge_notice.get("final_end_date_text") or ""),
|
||||||
|
primary_panel_user_uuid=merge_notice.get("primary_panel_user_uuid"),
|
||||||
|
removed_panel_user_uuid=merge_notice.get("removed_panel_user_uuid"),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to send account merged notification")
|
||||||
|
|
||||||
|
|
||||||
def _apply_telegram_profile_to_user(
|
def _apply_telegram_profile_to_user(
|
||||||
user: User,
|
user: User,
|
||||||
telegram_user: Dict[str, Any],
|
telegram_user: Dict[str, Any],
|
||||||
@@ -937,7 +1245,6 @@ async def _link_telegram_to_user(
|
|||||||
)
|
)
|
||||||
_apply_telegram_profile_to_user(merged_user, telegram_user, settings)
|
_apply_telegram_profile_to_user(merged_user, telegram_user, settings)
|
||||||
await session.flush()
|
await session.flush()
|
||||||
await _sync_panel_identity_for_user(request, merged_user)
|
|
||||||
return merged_user
|
return merged_user
|
||||||
|
|
||||||
if not existing_telegram_user and int(current_user.user_id) < 0:
|
if not existing_telegram_user and int(current_user.user_id) < 0:
|
||||||
@@ -969,7 +1276,6 @@ async def _link_telegram_to_user(
|
|||||||
)
|
)
|
||||||
_apply_telegram_profile_to_user(merged_user, telegram_user, settings)
|
_apply_telegram_profile_to_user(merged_user, telegram_user, settings)
|
||||||
await session.flush()
|
await session.flush()
|
||||||
await _sync_panel_identity_for_user(request, merged_user)
|
|
||||||
return merged_user
|
return merged_user
|
||||||
|
|
||||||
if current_user.telegram_id and int(current_user.telegram_id) != telegram_id:
|
if current_user.telegram_id and int(current_user.telegram_id) != telegram_id:
|
||||||
@@ -651,541 +651,31 @@ async def _create_subscription_payment(
|
|||||||
else _payment_description(int(months), lang)
|
else _payment_description(int(months), lang)
|
||||||
)
|
)
|
||||||
|
|
||||||
if method == "yookassa":
|
from bot.payment_providers import WebAppPaymentContext, get_provider_spec
|
||||||
if not settings.YOOKASSA_ENABLED:
|
|
||||||
|
provider_spec = get_provider_spec(method)
|
||||||
|
if provider_spec and provider_spec.create_webapp_payment:
|
||||||
|
if not provider_spec.is_visible(settings, request.app):
|
||||||
|
logger.warning(
|
||||||
|
"WebApp payment method unavailable: method=%s enabled=%s configured=%s",
|
||||||
|
method,
|
||||||
|
provider_spec.is_enabled(settings),
|
||||||
|
provider_spec.is_service_configured(request.app),
|
||||||
|
)
|
||||||
return _json_error(400, "payment_unavailable", "Payment method unavailable")
|
return _json_error(400, "payment_unavailable", "Payment method unavailable")
|
||||||
return await _create_yookassa_payment(
|
return await provider_spec.create_webapp_payment(
|
||||||
request,
|
WebAppPaymentContext(
|
||||||
session,
|
request=request,
|
||||||
user_id,
|
session=session,
|
||||||
months,
|
user_id=user_id,
|
||||||
price,
|
method=method,
|
||||||
description,
|
months=months,
|
||||||
sale_mode=sale_mode,
|
price=price,
|
||||||
traffic_gb=traffic_gb,
|
stars_price=stars_price,
|
||||||
)
|
description=description,
|
||||||
if method == "freekassa":
|
sale_mode=sale_mode,
|
||||||
if not settings.FREEKASSA_ENABLED:
|
traffic_gb=traffic_gb,
|
||||||
return _json_error(400, "payment_unavailable", "Payment method unavailable")
|
)
|
||||||
return await _create_freekassa_payment(
|
|
||||||
request,
|
|
||||||
session,
|
|
||||||
user_id,
|
|
||||||
months,
|
|
||||||
price,
|
|
||||||
description,
|
|
||||||
sale_mode=sale_mode,
|
|
||||||
traffic_gb=traffic_gb,
|
|
||||||
)
|
|
||||||
if method in ("platega", "platega_sbp", "platega_crypto"):
|
|
||||||
if not settings.PLATEGA_ENABLED:
|
|
||||||
return _json_error(400, "payment_unavailable", "Payment method unavailable")
|
|
||||||
if method == "platega_sbp" and not settings.PLATEGA_SBP_ENABLED:
|
|
||||||
return _json_error(400, "payment_unavailable", "Payment method unavailable")
|
|
||||||
if method == "platega_crypto" and not settings.PLATEGA_CRYPTO_ENABLED:
|
|
||||||
return _json_error(400, "payment_unavailable", "Payment method unavailable")
|
|
||||||
return await _create_platega_payment(
|
|
||||||
request,
|
|
||||||
session,
|
|
||||||
user_id,
|
|
||||||
months,
|
|
||||||
price,
|
|
||||||
description,
|
|
||||||
variant=method,
|
|
||||||
sale_mode=sale_mode,
|
|
||||||
traffic_gb=traffic_gb,
|
|
||||||
)
|
|
||||||
if method == "severpay":
|
|
||||||
if not settings.SEVERPAY_ENABLED:
|
|
||||||
return _json_error(400, "payment_unavailable", "Payment method unavailable")
|
|
||||||
return await _create_severpay_payment(
|
|
||||||
request,
|
|
||||||
session,
|
|
||||||
user_id,
|
|
||||||
months,
|
|
||||||
price,
|
|
||||||
description,
|
|
||||||
sale_mode=sale_mode,
|
|
||||||
traffic_gb=traffic_gb,
|
|
||||||
)
|
|
||||||
if method == "cryptopay":
|
|
||||||
service: CryptoPayService = request.app["cryptopay_service"]
|
|
||||||
if not settings.CRYPTOPAY_ENABLED or not service or not service.configured:
|
|
||||||
return _json_error(400, "payment_unavailable", "Payment method unavailable")
|
|
||||||
url = await service.create_invoice(
|
|
||||||
session=session,
|
|
||||||
user_id=user_id,
|
|
||||||
months=months,
|
|
||||||
amount=price,
|
|
||||||
description=description,
|
|
||||||
sale_mode=sale_mode,
|
|
||||||
url_kind="web",
|
|
||||||
)
|
|
||||||
if not url:
|
|
||||||
return _json_error(502, "payment_failed", "Failed to create payment")
|
|
||||||
return web.json_response(
|
|
||||||
{"ok": True, "action": "open_link", "payment_url": url, "payment_id": None}
|
|
||||||
)
|
|
||||||
if method == "stars":
|
|
||||||
if not settings.STARS_ENABLED or stars_price is None:
|
|
||||||
return _json_error(400, "payment_unavailable", "Payment method unavailable")
|
|
||||||
return await _create_stars_payment(
|
|
||||||
request,
|
|
||||||
session,
|
|
||||||
user_id,
|
|
||||||
months,
|
|
||||||
int(stars_price),
|
|
||||||
description,
|
|
||||||
sale_mode=sale_mode,
|
|
||||||
traffic_gb=traffic_gb,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return _json_error(400, "payment_unavailable", "Payment method unavailable")
|
return _json_error(400, "payment_unavailable", "Payment method unavailable")
|
||||||
|
|
||||||
|
|
||||||
async def _create_base_payment_record(
|
|
||||||
session: AsyncSession,
|
|
||||||
*,
|
|
||||||
user_id: int,
|
|
||||||
amount: float,
|
|
||||||
currency: str,
|
|
||||||
status: str,
|
|
||||||
description: str,
|
|
||||||
months: int,
|
|
||||||
provider: str,
|
|
||||||
sale_mode: Optional[str] = None,
|
|
||||||
tariff_key: Optional[str] = None,
|
|
||||||
purchased_gb: Optional[float] = None,
|
|
||||||
purchased_hwid_devices: Optional[int] = None,
|
|
||||||
) -> Payment:
|
|
||||||
payment = await payment_dal.create_payment_record(
|
|
||||||
session,
|
|
||||||
{
|
|
||||||
"user_id": user_id,
|
|
||||||
"amount": amount,
|
|
||||||
"currency": currency,
|
|
||||||
"status": status,
|
|
||||||
"description": description,
|
|
||||||
"subscription_duration_months": months,
|
|
||||||
"provider": provider,
|
|
||||||
"sale_mode": sale_mode,
|
|
||||||
"tariff_key": tariff_key,
|
|
||||||
"purchased_gb": purchased_gb,
|
|
||||||
"purchased_hwid_devices": purchased_hwid_devices,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
await session.commit()
|
|
||||||
return payment
|
|
||||||
|
|
||||||
|
|
||||||
async def _create_yookassa_payment(
|
|
||||||
request: web.Request,
|
|
||||||
session: AsyncSession,
|
|
||||||
user_id: int,
|
|
||||||
months: Any,
|
|
||||||
price: float,
|
|
||||||
description: str,
|
|
||||||
*,
|
|
||||||
sale_mode: str = "subscription",
|
|
||||||
traffic_gb: Optional[float] = None,
|
|
||||||
) -> web.Response:
|
|
||||||
settings: Settings = request.app["settings"]
|
|
||||||
service: YooKassaService = request.app["yookassa_service"]
|
|
||||||
if not service or not service.configured:
|
|
||||||
return _json_error(400, "payment_unavailable", "Payment method unavailable")
|
|
||||||
|
|
||||||
try:
|
|
||||||
traffic_sale = _sale_mode_is_traffic(sale_mode)
|
|
||||||
hwid_devices_sale = _sale_mode_is_hwid_devices(sale_mode)
|
|
||||||
payment = await _create_base_payment_record(
|
|
||||||
session,
|
|
||||||
user_id=user_id,
|
|
||||||
amount=price,
|
|
||||||
currency="RUB",
|
|
||||||
status="pending_yookassa",
|
|
||||||
description=description,
|
|
||||||
months=int(float(months)) if not traffic_sale else int(float(traffic_gb or months)),
|
|
||||||
provider="yookassa",
|
|
||||||
sale_mode=sale_mode,
|
|
||||||
tariff_key=_sale_mode_tariff_key(sale_mode),
|
|
||||||
purchased_gb=float(traffic_gb or months) if traffic_sale else None,
|
|
||||||
purchased_hwid_devices=int(float(months)) if hwid_devices_sale else None,
|
|
||||||
)
|
|
||||||
metadata = {
|
|
||||||
"user_id": str(user_id),
|
|
||||||
"subscription_months": str(
|
|
||||||
int(float(months)) if not traffic_sale and not hwid_devices_sale else 0
|
|
||||||
),
|
|
||||||
"payment_db_id": str(payment.payment_id),
|
|
||||||
"sale_mode": sale_mode,
|
|
||||||
"source": "webapp",
|
|
||||||
}
|
|
||||||
if traffic_sale:
|
|
||||||
metadata["traffic_gb"] = _format_number_for_payload(traffic_gb or months)
|
|
||||||
if hwid_devices_sale:
|
|
||||||
metadata["hwid_devices"] = str(int(float(months)))
|
|
||||||
if _sale_mode_tariff_key(sale_mode):
|
|
||||||
metadata["tariff_key"] = _sale_mode_tariff_key(sale_mode)
|
|
||||||
response = await service.create_payment(
|
|
||||||
amount=price,
|
|
||||||
currency="RUB",
|
|
||||||
description=description,
|
|
||||||
metadata=metadata,
|
|
||||||
receipt_email=settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL,
|
|
||||||
save_payment_method=bool(
|
|
||||||
settings.yookassa_autopayments_active
|
|
||||||
and settings.YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING
|
|
||||||
),
|
|
||||||
)
|
|
||||||
payment_url = response.get("confirmation_url") if response else None
|
|
||||||
if not payment_url:
|
|
||||||
await payment_dal.update_payment_status_by_db_id(
|
|
||||||
session, payment.payment_id, "failed_creation"
|
|
||||||
)
|
|
||||||
await session.commit()
|
|
||||||
return _json_error(502, "payment_failed", "Failed to create payment")
|
|
||||||
|
|
||||||
await payment_dal.update_payment_status_by_db_id(
|
|
||||||
session,
|
|
||||||
payment.payment_id,
|
|
||||||
response.get("status", "pending"),
|
|
||||||
yk_payment_id=response.get("id"),
|
|
||||||
)
|
|
||||||
await session.commit()
|
|
||||||
return web.json_response(
|
|
||||||
{
|
|
||||||
"ok": True,
|
|
||||||
"action": "open_link",
|
|
||||||
"payment_url": payment_url,
|
|
||||||
"payment_id": payment.payment_id,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
await session.rollback()
|
|
||||||
logger.exception("YooKassa WebApp payment failed")
|
|
||||||
return _json_error(502, "payment_failed", "Failed to create payment")
|
|
||||||
|
|
||||||
|
|
||||||
async def _create_freekassa_payment(
|
|
||||||
request: web.Request,
|
|
||||||
session: AsyncSession,
|
|
||||||
user_id: int,
|
|
||||||
months: Any,
|
|
||||||
price: float,
|
|
||||||
description: str,
|
|
||||||
*,
|
|
||||||
sale_mode: str = "subscription",
|
|
||||||
traffic_gb: Optional[float] = None,
|
|
||||||
) -> web.Response:
|
|
||||||
request.app["settings"]
|
|
||||||
service: FreeKassaService = request.app["freekassa_service"]
|
|
||||||
if not service or not service.configured or not service.payment_method_id:
|
|
||||||
return _json_error(400, "payment_unavailable", "Payment method unavailable")
|
|
||||||
|
|
||||||
try:
|
|
||||||
traffic_sale = _sale_mode_is_traffic(sale_mode)
|
|
||||||
hwid_devices_sale = _sale_mode_is_hwid_devices(sale_mode)
|
|
||||||
payment = await _create_base_payment_record(
|
|
||||||
session,
|
|
||||||
user_id=user_id,
|
|
||||||
amount=price,
|
|
||||||
currency=service.default_currency,
|
|
||||||
status="pending_freekassa",
|
|
||||||
description=description,
|
|
||||||
months=int(float(months)) if not traffic_sale else int(float(traffic_gb or months)),
|
|
||||||
provider="freekassa",
|
|
||||||
sale_mode=sale_mode,
|
|
||||||
tariff_key=_sale_mode_tariff_key(sale_mode),
|
|
||||||
purchased_gb=float(traffic_gb or months) if traffic_sale else None,
|
|
||||||
purchased_hwid_devices=int(float(months)) if hwid_devices_sale else None,
|
|
||||||
)
|
|
||||||
success, response_data = await service.create_order(
|
|
||||||
payment_db_id=payment.payment_id,
|
|
||||||
user_id=user_id,
|
|
||||||
months=months,
|
|
||||||
amount=price,
|
|
||||||
currency=service.default_currency,
|
|
||||||
payment_method_id=service.payment_method_id,
|
|
||||||
ip_address=service.server_ip,
|
|
||||||
extra_params={"us_method": service.payment_method_id},
|
|
||||||
)
|
|
||||||
payment_url = response_data.get("location") if success else None
|
|
||||||
provider_id = response_data.get("orderHash") or response_data.get("orderId")
|
|
||||||
if provider_id:
|
|
||||||
await payment_dal.update_provider_payment_and_status(
|
|
||||||
session, payment.payment_id, str(provider_id), payment.status
|
|
||||||
)
|
|
||||||
await session.commit()
|
|
||||||
if not payment_url:
|
|
||||||
await payment_dal.update_payment_status_by_db_id(
|
|
||||||
session, payment.payment_id, "failed_creation"
|
|
||||||
)
|
|
||||||
await session.commit()
|
|
||||||
return _json_error(502, "payment_failed", "Failed to create payment")
|
|
||||||
return web.json_response(
|
|
||||||
{
|
|
||||||
"ok": True,
|
|
||||||
"action": "open_link",
|
|
||||||
"payment_url": payment_url,
|
|
||||||
"payment_id": payment.payment_id,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
await session.rollback()
|
|
||||||
logger.exception("FreeKassa WebApp payment failed")
|
|
||||||
return _json_error(502, "payment_failed", "Failed to create payment")
|
|
||||||
|
|
||||||
|
|
||||||
async def _create_platega_payment(
|
|
||||||
request: web.Request,
|
|
||||||
session: AsyncSession,
|
|
||||||
user_id: int,
|
|
||||||
months: Any,
|
|
||||||
price: float,
|
|
||||||
description: str,
|
|
||||||
variant: str = "platega_sbp",
|
|
||||||
sale_mode: str = "subscription",
|
|
||||||
traffic_gb: Optional[float] = None,
|
|
||||||
) -> web.Response:
|
|
||||||
settings: Settings = request.app["settings"]
|
|
||||||
service: PlategaService = request.app["platega_service"]
|
|
||||||
if not service or not service.configured:
|
|
||||||
return _json_error(400, "payment_unavailable", "Payment method unavailable")
|
|
||||||
if variant == "platega_crypto":
|
|
||||||
if not settings.PLATEGA_CRYPTO_ENABLED:
|
|
||||||
return _json_error(400, "payment_unavailable", "Payment method unavailable")
|
|
||||||
platega_method_id = settings.PLATEGA_CRYPTO_METHOD
|
|
||||||
else:
|
|
||||||
if variant == "platega_sbp" and not settings.PLATEGA_SBP_ENABLED:
|
|
||||||
return _json_error(400, "payment_unavailable", "Payment method unavailable")
|
|
||||||
platega_method_id = settings.platega_sbp_method_resolved
|
|
||||||
|
|
||||||
try:
|
|
||||||
traffic_sale = _sale_mode_is_traffic(sale_mode)
|
|
||||||
hwid_devices_sale = _sale_mode_is_hwid_devices(sale_mode)
|
|
||||||
payment = await _create_base_payment_record(
|
|
||||||
session,
|
|
||||||
user_id=user_id,
|
|
||||||
amount=price,
|
|
||||||
currency=settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
|
|
||||||
status="pending_platega",
|
|
||||||
description=description,
|
|
||||||
months=int(float(months)) if not traffic_sale else int(float(traffic_gb or months)),
|
|
||||||
provider="platega",
|
|
||||||
sale_mode=sale_mode,
|
|
||||||
tariff_key=_sale_mode_tariff_key(sale_mode),
|
|
||||||
purchased_gb=float(traffic_gb or months) if traffic_sale else None,
|
|
||||||
purchased_hwid_devices=int(float(months)) if hwid_devices_sale else None,
|
|
||||||
)
|
|
||||||
months_for_provider = (
|
|
||||||
int(float(months)) if not traffic_sale else int(float(traffic_gb or months))
|
|
||||||
)
|
|
||||||
payload = json.dumps(
|
|
||||||
{
|
|
||||||
"payment_db_id": payment.payment_id,
|
|
||||||
"user_id": user_id,
|
|
||||||
"months": months_for_provider if not traffic_sale else 0,
|
|
||||||
"sale_mode": sale_mode,
|
|
||||||
"traffic_gb": _format_number_for_payload(traffic_gb or months)
|
|
||||||
if traffic_sale
|
|
||||||
else None,
|
|
||||||
"hwid_devices": int(float(months)) if hwid_devices_sale else None,
|
|
||||||
"source": "webapp",
|
|
||||||
"platega_variant": "crypto" if variant == "platega_crypto" else "sbp",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
success, response_data = await service.create_transaction(
|
|
||||||
payment_db_id=payment.payment_id,
|
|
||||||
user_id=user_id,
|
|
||||||
months=months_for_provider,
|
|
||||||
amount=price,
|
|
||||||
currency=settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
|
|
||||||
description=description,
|
|
||||||
payload=payload,
|
|
||||||
payment_method=platega_method_id,
|
|
||||||
)
|
|
||||||
payment_url = (
|
|
||||||
(
|
|
||||||
response_data.get("redirect")
|
|
||||||
or response_data.get("url")
|
|
||||||
or response_data.get("paymentUrl")
|
|
||||||
)
|
|
||||||
if success
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
provider_id = response_data.get("transactionId") or response_data.get("id")
|
|
||||||
if provider_id:
|
|
||||||
await payment_dal.update_provider_payment_and_status(
|
|
||||||
session,
|
|
||||||
payment.payment_id,
|
|
||||||
str(provider_id),
|
|
||||||
str(response_data.get("status", payment.status)),
|
|
||||||
)
|
|
||||||
await session.commit()
|
|
||||||
if not payment_url:
|
|
||||||
await payment_dal.update_payment_status_by_db_id(
|
|
||||||
session, payment.payment_id, "failed_creation"
|
|
||||||
)
|
|
||||||
await session.commit()
|
|
||||||
return _json_error(502, "payment_failed", "Failed to create payment")
|
|
||||||
return web.json_response(
|
|
||||||
{
|
|
||||||
"ok": True,
|
|
||||||
"action": "open_link",
|
|
||||||
"payment_url": payment_url,
|
|
||||||
"payment_id": payment.payment_id,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
await session.rollback()
|
|
||||||
logger.exception("Platega WebApp payment failed")
|
|
||||||
return _json_error(502, "payment_failed", "Failed to create payment")
|
|
||||||
|
|
||||||
|
|
||||||
async def _create_severpay_payment(
|
|
||||||
request: web.Request,
|
|
||||||
session: AsyncSession,
|
|
||||||
user_id: int,
|
|
||||||
months: Any,
|
|
||||||
price: float,
|
|
||||||
description: str,
|
|
||||||
*,
|
|
||||||
sale_mode: str = "subscription",
|
|
||||||
traffic_gb: Optional[float] = None,
|
|
||||||
) -> web.Response:
|
|
||||||
settings: Settings = request.app["settings"]
|
|
||||||
service: SeverPayService = request.app["severpay_service"]
|
|
||||||
if not service or not service.configured:
|
|
||||||
return _json_error(400, "payment_unavailable", "Payment method unavailable")
|
|
||||||
|
|
||||||
try:
|
|
||||||
traffic_sale = _sale_mode_is_traffic(sale_mode)
|
|
||||||
hwid_devices_sale = _sale_mode_is_hwid_devices(sale_mode)
|
|
||||||
payment = await _create_base_payment_record(
|
|
||||||
session,
|
|
||||||
user_id=user_id,
|
|
||||||
amount=price,
|
|
||||||
currency=settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
|
|
||||||
status="pending_severpay",
|
|
||||||
description=description,
|
|
||||||
months=int(float(months)) if not traffic_sale else int(float(traffic_gb or months)),
|
|
||||||
provider="severpay",
|
|
||||||
sale_mode=sale_mode,
|
|
||||||
tariff_key=_sale_mode_tariff_key(sale_mode),
|
|
||||||
purchased_gb=float(traffic_gb or months) if traffic_sale else None,
|
|
||||||
purchased_hwid_devices=int(float(months)) if hwid_devices_sale else None,
|
|
||||||
)
|
|
||||||
success, response_data = await service.create_payment(
|
|
||||||
payment_db_id=payment.payment_id,
|
|
||||||
user_id=user_id,
|
|
||||||
months=months,
|
|
||||||
amount=price,
|
|
||||||
currency=settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
|
|
||||||
description=description,
|
|
||||||
)
|
|
||||||
payment_url = (
|
|
||||||
(
|
|
||||||
response_data.get("url")
|
|
||||||
or response_data.get("payment_url")
|
|
||||||
or response_data.get("paymentUrl")
|
|
||||||
)
|
|
||||||
if success
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
provider_id = response_data.get("id") or response_data.get("uid")
|
|
||||||
if provider_id:
|
|
||||||
await payment_dal.update_provider_payment_and_status(
|
|
||||||
session, payment.payment_id, str(provider_id), payment.status
|
|
||||||
)
|
|
||||||
await session.commit()
|
|
||||||
if not payment_url:
|
|
||||||
await payment_dal.update_payment_status_by_db_id(
|
|
||||||
session, payment.payment_id, "failed_creation"
|
|
||||||
)
|
|
||||||
await session.commit()
|
|
||||||
return _json_error(502, "payment_failed", "Failed to create payment")
|
|
||||||
return web.json_response(
|
|
||||||
{
|
|
||||||
"ok": True,
|
|
||||||
"action": "open_link",
|
|
||||||
"payment_url": payment_url,
|
|
||||||
"payment_id": payment.payment_id,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
await session.rollback()
|
|
||||||
logger.exception("SeverPay WebApp payment failed")
|
|
||||||
return _json_error(502, "payment_failed", "Failed to create payment")
|
|
||||||
|
|
||||||
|
|
||||||
async def _create_stars_payment(
|
|
||||||
request: web.Request,
|
|
||||||
session: AsyncSession,
|
|
||||||
user_id: int,
|
|
||||||
months: Any,
|
|
||||||
stars_price: int,
|
|
||||||
description: str,
|
|
||||||
sale_mode: str = "subscription",
|
|
||||||
traffic_gb: Optional[float] = None,
|
|
||||||
) -> web.Response:
|
|
||||||
bot: Bot = request.app["bot"]
|
|
||||||
try:
|
|
||||||
traffic_sale = _sale_mode_is_traffic(sale_mode)
|
|
||||||
hwid_devices_sale = _sale_mode_is_hwid_devices(sale_mode)
|
|
||||||
payment = await _create_base_payment_record(
|
|
||||||
session,
|
|
||||||
user_id=user_id,
|
|
||||||
amount=float(stars_price),
|
|
||||||
currency="XTR",
|
|
||||||
status="pending_stars",
|
|
||||||
description=description,
|
|
||||||
months=int(float(months)) if not traffic_sale else int(float(traffic_gb or months)),
|
|
||||||
provider="telegram_stars",
|
|
||||||
sale_mode=sale_mode,
|
|
||||||
tariff_key=_sale_mode_tariff_key(sale_mode),
|
|
||||||
purchased_gb=float(traffic_gb or months) if traffic_sale else None,
|
|
||||||
purchased_hwid_devices=int(float(months)) if hwid_devices_sale else None,
|
|
||||||
)
|
|
||||||
payload_units = traffic_gb if traffic_sale and traffic_gb is not None else months
|
|
||||||
payload = f"{payment.payment_id}:{_format_number_for_payload(payload_units)}:{sale_mode}"
|
|
||||||
prices = [LabeledPrice(label=description, amount=stars_price)]
|
|
||||||
create_invoice_link = getattr(bot, "create_invoice_link", None)
|
|
||||||
if callable(create_invoice_link):
|
|
||||||
invoice_url = await create_invoice_link(
|
|
||||||
title=description,
|
|
||||||
description=description,
|
|
||||||
payload=payload,
|
|
||||||
# Required to be empty for Telegram Stars (XTR) per Telegram Bot API.
|
|
||||||
provider_token="",
|
|
||||||
currency="XTR",
|
|
||||||
prices=prices,
|
|
||||||
)
|
|
||||||
return web.json_response(
|
|
||||||
{
|
|
||||||
"ok": True,
|
|
||||||
"action": "open_invoice",
|
|
||||||
"payment_url": invoice_url,
|
|
||||||
"payment_id": payment.payment_id,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
await bot.send_invoice(
|
|
||||||
chat_id=user_id,
|
|
||||||
title=description,
|
|
||||||
description=description,
|
|
||||||
payload=payload,
|
|
||||||
provider_token="",
|
|
||||||
currency="XTR",
|
|
||||||
prices=prices,
|
|
||||||
)
|
|
||||||
return web.json_response(
|
|
||||||
{
|
|
||||||
"ok": True,
|
|
||||||
"action": "invoice_sent",
|
|
||||||
"payment_id": payment.payment_id,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
await session.rollback()
|
|
||||||
logger.exception("Stars WebApp payment failed")
|
|
||||||
return _json_error(502, "payment_failed", "Failed to create invoice")
|
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Awaitable, Callable, Optional
|
||||||
|
|
||||||
|
from bot.infra.redis import cache_delete, cache_delete_pattern, redis_key
|
||||||
|
from bot.utils.ttl_cache import AsyncTTLCache
|
||||||
|
from config.settings import Settings
|
||||||
|
|
||||||
|
_WEBAPP_USER_PAYLOAD_CACHES: dict[tuple[int, str, int], AsyncTTLCache] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _webapp_user_payload_cache(
|
||||||
|
settings: Settings,
|
||||||
|
namespace: str,
|
||||||
|
ttl_seconds: int,
|
||||||
|
) -> Optional[AsyncTTLCache]:
|
||||||
|
ttl = max(0, int(ttl_seconds or 0))
|
||||||
|
if ttl <= 0:
|
||||||
|
return None
|
||||||
|
cache_key = (id(settings), namespace, ttl)
|
||||||
|
cache = _WEBAPP_USER_PAYLOAD_CACHES.get(cache_key)
|
||||||
|
if cache is None:
|
||||||
|
cache = AsyncTTLCache(
|
||||||
|
ttl_seconds=ttl,
|
||||||
|
settings=settings,
|
||||||
|
namespace=f"webapp:{namespace}",
|
||||||
|
)
|
||||||
|
_WEBAPP_USER_PAYLOAD_CACHES[cache_key] = cache
|
||||||
|
return cache
|
||||||
|
|
||||||
|
|
||||||
|
async def webapp_cached_user_payload(
|
||||||
|
settings: Settings,
|
||||||
|
namespace: str,
|
||||||
|
user_id: int,
|
||||||
|
ttl_seconds: int,
|
||||||
|
loader: Callable[[], Awaitable[Any]],
|
||||||
|
) -> Any:
|
||||||
|
cache = _webapp_user_payload_cache(settings, namespace, ttl_seconds)
|
||||||
|
if cache is None:
|
||||||
|
return await loader()
|
||||||
|
return await cache.get_or_load(str(int(user_id)), loader)
|
||||||
|
|
||||||
|
|
||||||
|
def invalidate_local_webapp_user_payload(
|
||||||
|
settings: Settings,
|
||||||
|
namespace: str,
|
||||||
|
user_id: int,
|
||||||
|
) -> None:
|
||||||
|
key = str(int(user_id))
|
||||||
|
for (settings_id, cache_namespace, _ttl), cache in tuple(
|
||||||
|
_WEBAPP_USER_PAYLOAD_CACHES.items()
|
||||||
|
):
|
||||||
|
if settings_id == id(settings) and cache_namespace == namespace:
|
||||||
|
cache.invalidate(key)
|
||||||
|
|
||||||
|
|
||||||
|
def invalidate_all_local_webapp_user_payloads(
|
||||||
|
settings: Settings,
|
||||||
|
namespace: Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
|
for (settings_id, cache_namespace, _ttl), cache in tuple(
|
||||||
|
_WEBAPP_USER_PAYLOAD_CACHES.items()
|
||||||
|
):
|
||||||
|
if settings_id != id(settings):
|
||||||
|
continue
|
||||||
|
if namespace is not None and cache_namespace != namespace:
|
||||||
|
continue
|
||||||
|
cache.invalidate()
|
||||||
|
|
||||||
|
|
||||||
|
async def invalidate_webapp_user_caches(
|
||||||
|
settings: Settings,
|
||||||
|
*user_ids: Optional[int],
|
||||||
|
include_devices: bool = False,
|
||||||
|
) -> None:
|
||||||
|
keys: list[str] = []
|
||||||
|
seen: set[int] = set()
|
||||||
|
for raw_user_id in user_ids:
|
||||||
|
if raw_user_id is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
user_id = int(raw_user_id)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if user_id in seen:
|
||||||
|
continue
|
||||||
|
seen.add(user_id)
|
||||||
|
keys.append(redis_key(settings, "cache", "webapp", "me", user_id))
|
||||||
|
invalidate_local_webapp_user_payload(settings, "me", user_id)
|
||||||
|
if include_devices:
|
||||||
|
keys.append(redis_key(settings, "cache", "webapp", "devices", user_id))
|
||||||
|
invalidate_local_webapp_user_payload(settings, "devices", user_id)
|
||||||
|
if keys:
|
||||||
|
await cache_delete(settings, *keys)
|
||||||
|
|
||||||
|
|
||||||
|
async def invalidate_all_webapp_user_caches(
|
||||||
|
settings: Settings,
|
||||||
|
*,
|
||||||
|
include_devices: bool = False,
|
||||||
|
) -> None:
|
||||||
|
namespaces = ["me"]
|
||||||
|
if include_devices:
|
||||||
|
namespaces.append("devices")
|
||||||
|
|
||||||
|
for namespace in namespaces:
|
||||||
|
invalidate_all_local_webapp_user_payloads(settings, namespace)
|
||||||
|
try:
|
||||||
|
await cache_delete_pattern(
|
||||||
|
settings,
|
||||||
|
redis_key(settings, "cache", "webapp", namespace, "*"),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
# ruff: noqa: F401,F403,F405,I001
|
# ruff: noqa: F401,F403,F405,I001
|
||||||
from ._runtime import * # noqa: F403,F405
|
from ._runtime import * # noqa: F403,F405
|
||||||
|
|
||||||
|
from bot.app.web.webapp.cache_helpers import (
|
||||||
|
invalidate_local_webapp_user_payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _read_json(request: web.Request) -> Dict[str, Any]:
|
async def _read_json(request: web.Request) -> Dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
@@ -17,6 +21,32 @@ def _json_error(status: int, code: str, message: str) -> web.Response:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _invalidate_webapp_user_caches(
|
||||||
|
settings: Settings,
|
||||||
|
*user_ids: Optional[int],
|
||||||
|
include_devices: bool = False,
|
||||||
|
) -> None:
|
||||||
|
keys: List[str] = []
|
||||||
|
seen: set[int] = set()
|
||||||
|
for raw_user_id in user_ids:
|
||||||
|
if raw_user_id is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
user_id = int(raw_user_id)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if user_id in seen:
|
||||||
|
continue
|
||||||
|
seen.add(user_id)
|
||||||
|
keys.append(redis_key(settings, "cache", "webapp", "me", user_id))
|
||||||
|
invalidate_local_webapp_user_payload(settings, "me", user_id)
|
||||||
|
if include_devices:
|
||||||
|
keys.append(redis_key(settings, "cache", "webapp", "devices", user_id))
|
||||||
|
invalidate_local_webapp_user_payload(settings, "devices", user_id)
|
||||||
|
if keys:
|
||||||
|
await cache_delete(settings, *keys)
|
||||||
|
|
||||||
|
|
||||||
def _validation_error_response(exc: ValidationError) -> web.Response:
|
def _validation_error_response(exc: ValidationError) -> web.Response:
|
||||||
for error in exc.errors():
|
for error in exc.errors():
|
||||||
loc = error.get("loc") or ()
|
loc = error.get("loc") or ()
|
||||||
@@ -37,6 +67,13 @@ def _validation_error_response(exc: ValidationError) -> web.Response:
|
|||||||
if field in {"description", "comment", "note"} and error_type == "string_too_long":
|
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")
|
return _json_error(400, f"{field}_too_long", f"{field.capitalize()} is too long")
|
||||||
|
|
||||||
|
if field in {"password", "password_confirm"}:
|
||||||
|
if error_type == "string_too_short":
|
||||||
|
return _json_error(400, "password_too_short", "Password is too short")
|
||||||
|
if error_type == "string_too_long":
|
||||||
|
return _json_error(400, "password_too_long", "Password is too long")
|
||||||
|
return _json_error(400, "invalid_password", "Invalid password")
|
||||||
|
|
||||||
if error_type == "string_too_long":
|
if error_type == "string_too_long":
|
||||||
return _json_error(400, "text_too_long", "Text is too long")
|
return _json_error(400, "text_too_long", "Text is too long")
|
||||||
|
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
# ruff: noqa: F401,F403,F405,I001
|
# ruff: noqa: F401,F403,F405,I001
|
||||||
from ._runtime import * # noqa: F403,F405
|
from ._runtime import * # noqa: F403,F405
|
||||||
|
|
||||||
|
from bot.app.web.webapp.cache_helpers import webapp_cached_user_payload
|
||||||
|
|
||||||
|
|
||||||
async def devices_route(request: web.Request) -> web.Response:
|
async def devices_route(request: web.Request) -> web.Response:
|
||||||
user_id = _require_user_id(request)
|
user_id = _require_user_id(request)
|
||||||
@@ -15,35 +17,80 @@ async def devices_route(request: web.Request) -> web.Response:
|
|||||||
if not db_user or db_user.is_banned:
|
if not db_user or db_user.is_banned:
|
||||||
return _json_error(403, "access_denied", "Access denied")
|
return _json_error(403, "access_denied", "Access denied")
|
||||||
|
|
||||||
active = await subscription_service.get_active_subscription_details(session, user_id)
|
result = await webapp_cached_user_payload(
|
||||||
panel_user_uuid = active.get("user_id") if active else None
|
settings,
|
||||||
if not panel_user_uuid:
|
"devices",
|
||||||
return _json_error(400, "subscription_not_active", "Subscription is not active")
|
user_id,
|
||||||
|
int(getattr(settings, "WEBAPP_DEVICES_CACHE_TTL_SECONDS", 5) or 0),
|
||||||
|
lambda: _load_devices_payload(subscription_service, session, user_id),
|
||||||
|
)
|
||||||
|
if isinstance(result, dict) and result.get("ok") is True:
|
||||||
|
return web.json_response({"ok": True, **(result.get("payload") or {})})
|
||||||
|
if isinstance(result, dict) and not result.get("error"):
|
||||||
|
# Backward-compatible with payloads written by older versions under
|
||||||
|
# the same Redis cache key.
|
||||||
|
return web.json_response({"ok": True, **result})
|
||||||
|
if not isinstance(result, dict):
|
||||||
|
result = {}
|
||||||
|
if not result.get("ok"):
|
||||||
|
return _json_error(
|
||||||
|
int(result.get("status") or 500),
|
||||||
|
str(result.get("error") or "devices_load_failed"),
|
||||||
|
str(result.get("message") or "Failed to load devices"),
|
||||||
|
)
|
||||||
|
return web.json_response({"ok": True, **(result.get("payload") or {})})
|
||||||
|
|
||||||
panel_service = getattr(subscription_service, "panel_service", None)
|
|
||||||
if not panel_service:
|
|
||||||
return _json_error(503, "panel_unavailable", "Panel service unavailable")
|
|
||||||
|
|
||||||
try:
|
async def _load_devices_payload(
|
||||||
devices_response = await panel_service.get_user_devices(panel_user_uuid)
|
subscription_service: SubscriptionService,
|
||||||
except Exception:
|
session: AsyncSession,
|
||||||
logger.exception("Failed to load WebApp devices for user %s", user_id)
|
user_id: int,
|
||||||
return _json_error(502, "devices_load_failed", "Failed to load devices")
|
) -> Dict[str, Any]:
|
||||||
|
active = await subscription_service.get_active_subscription_details(session, user_id)
|
||||||
|
panel_user_uuid = active.get("user_id") if active else None
|
||||||
|
if not panel_user_uuid:
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"status": 400,
|
||||||
|
"error": "subscription_not_active",
|
||||||
|
"message": "Subscription is not active",
|
||||||
|
}
|
||||||
|
|
||||||
|
panel_service = getattr(subscription_service, "panel_service", None)
|
||||||
|
if not panel_service:
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"status": 503,
|
||||||
|
"error": "panel_unavailable",
|
||||||
|
"message": "Panel service unavailable",
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
devices_response = await panel_service.get_user_devices(panel_user_uuid)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to load WebApp devices for user %s", user_id)
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"status": 502,
|
||||||
|
"error": "devices_load_failed",
|
||||||
|
"message": "Failed to load devices",
|
||||||
|
}
|
||||||
|
|
||||||
devices = _normalize_devices_response(devices_response)
|
devices = _normalize_devices_response(devices_response)
|
||||||
max_devices = _coerce_int_or_none(active.get("max_devices")) if active else None
|
max_devices = _coerce_int_or_none(active.get("max_devices")) if active else None
|
||||||
return web.json_response(
|
return {
|
||||||
{
|
"ok": True,
|
||||||
"ok": True,
|
"payload": {
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"current_devices": len(devices),
|
"current_devices": len(devices),
|
||||||
"max_devices": max_devices,
|
"max_devices": max_devices,
|
||||||
"max_devices_label": _format_devices_limit(max_devices),
|
"max_devices_label": _format_devices_limit(max_devices),
|
||||||
"devices": [
|
"devices": [
|
||||||
_serialize_device(device, index) for index, device in enumerate(devices, start=1)
|
_serialize_device(device, index)
|
||||||
|
for index, device in enumerate(devices, start=1)
|
||||||
],
|
],
|
||||||
}
|
},
|
||||||
)
|
}
|
||||||
|
|
||||||
|
|
||||||
async def disconnect_device_route(request: web.Request) -> web.Response:
|
async def disconnect_device_route(request: web.Request) -> web.Response:
|
||||||
@@ -103,6 +150,7 @@ async def disconnect_device_route(request: web.Request) -> web.Response:
|
|||||||
success = await panel_service.disconnect_device(panel_user_uuid, target_hwid)
|
success = await panel_service.disconnect_device(panel_user_uuid, target_hwid)
|
||||||
if not success:
|
if not success:
|
||||||
return _json_error(502, "device_disconnect_failed", "Failed to disconnect device")
|
return _json_error(502, "device_disconnect_failed", "Failed to disconnect device")
|
||||||
|
await cache_delete(settings, redis_key(settings, "cache", "webapp", "devices", user_id))
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
return web.json_response({"ok": True})
|
return web.json_response({"ok": True})
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
# ruff: noqa: F401,F403,F405,I001
|
||||||
|
from ._runtime import * # noqa: F403,F405
|
||||||
|
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
|
||||||
|
class WebAppEmailPayload(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="ignore")
|
||||||
|
|
||||||
|
email: EmailStr
|
||||||
|
|
||||||
|
@field_validator("email")
|
||||||
|
@classmethod
|
||||||
|
def _normalize_and_limit_email(cls, value: EmailStr) -> str:
|
||||||
|
normalized = normalize_email(str(value))
|
||||||
|
if len(normalized) > 254:
|
||||||
|
raise ValueError("email_too_long")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
class WebAppEmailCodePayload(WebAppEmailPayload):
|
||||||
|
code: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class WebAppEmailPasswordPayload(WebAppEmailPayload):
|
||||||
|
password: constr(min_length=1, max_length=128)
|
||||||
|
|
||||||
|
|
||||||
|
class WebAppSetPasswordPayload(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="ignore")
|
||||||
|
|
||||||
|
password: constr(min_length=8, max_length=128)
|
||||||
|
password_confirm: constr(min_length=8, max_length=128)
|
||||||
|
code: constr(min_length=1, max_length=32)
|
||||||
|
|
||||||
|
|
||||||
|
class WebAppEmailMagicPayload(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="ignore")
|
||||||
|
|
||||||
|
token: constr(min_length=8, max_length=512)
|
||||||
|
|
||||||
|
|
||||||
|
class WebAppPaymentCreatePayload(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="ignore")
|
||||||
|
|
||||||
|
method: str = ""
|
||||||
|
months: Any = None
|
||||||
|
traffic_gb: Any = None
|
||||||
|
device_count: Any = None
|
||||||
|
tariff_key: Optional[constr(max_length=128)] = None
|
||||||
|
sale_mode: Optional[constr(max_length=64)] = None
|
||||||
|
description: Optional[constr(max_length=4096)] = None
|
||||||
|
comment: Optional[constr(max_length=4096)] = None
|
||||||
|
note: Optional[constr(max_length=4096)] = None
|
||||||
|
|
||||||
|
|
||||||
|
class WebAppTariffChangePayload(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="ignore")
|
||||||
|
|
||||||
|
tariff_key: constr(min_length=1, max_length=128)
|
||||||
|
mode: constr(min_length=1, max_length=64)
|
||||||
|
|
||||||
|
|
||||||
|
class WebAppLanguagePayload(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="ignore")
|
||||||
|
|
||||||
|
language: constr(min_length=2, max_length=16)
|
||||||
|
|
||||||
|
|
||||||
|
class WebAppDeviceDisconnectPayload(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="ignore")
|
||||||
|
|
||||||
|
token: constr(min_length=8, max_length=128)
|
||||||
|
|
||||||
|
|
||||||
|
SupportCategory = Literal["billing", "technical", "account", "other"]
|
||||||
|
SupportPriority = Literal["low", "normal", "high", "urgent"]
|
||||||
|
SupportStatus = Literal["open", "awaiting_user", "awaiting_admin", "resolved", "closed"]
|
||||||
|
|
||||||
|
|
||||||
|
class CreateTicketPayload(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="ignore")
|
||||||
|
|
||||||
|
subject: constr(min_length=1, max_length=160)
|
||||||
|
category: SupportCategory = "other"
|
||||||
|
priority: Literal["normal", "high"] = "normal"
|
||||||
|
body: constr(min_length=1, max_length=4000)
|
||||||
|
|
||||||
|
@field_validator("subject", "body")
|
||||||
|
@classmethod
|
||||||
|
def _strip_required_text(cls, value: str) -> str:
|
||||||
|
stripped = value.strip()
|
||||||
|
if not stripped:
|
||||||
|
raise ValueError("empty_text")
|
||||||
|
return stripped
|
||||||
|
|
||||||
|
|
||||||
|
class TicketReplyPayload(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="ignore")
|
||||||
|
|
||||||
|
body: constr(min_length=1, max_length=4000)
|
||||||
|
|
||||||
|
@field_validator("body")
|
||||||
|
@classmethod
|
||||||
|
def _strip_body(cls, value: str) -> str:
|
||||||
|
stripped = value.strip()
|
||||||
|
if not stripped:
|
||||||
|
raise ValueError("empty_text")
|
||||||
|
return stripped
|
||||||
|
|
||||||
|
|
||||||
|
class AdminTicketReplyPayload(TicketReplyPayload):
|
||||||
|
is_internal_note: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class AdminTicketPatchPayload(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="ignore")
|
||||||
|
|
||||||
|
status: Optional[SupportStatus] = None
|
||||||
|
priority: Optional[SupportPriority] = None
|
||||||
|
category: Optional[SupportCategory] = None
|
||||||
|
assigned_admin_id: Optional[int] = None
|
||||||
@@ -4,41 +4,80 @@ from ._runtime import * # noqa: F403,F405
|
|||||||
|
|
||||||
def setup_subscription_webapp_routes(app: web.Application) -> None:
|
def setup_subscription_webapp_routes(app: web.Application) -> None:
|
||||||
app.router.add_get("/", index_route)
|
app.router.add_get("/", index_route)
|
||||||
|
app.router.add_get("/login/password", index_route)
|
||||||
app.router.add_get("/home", index_route)
|
app.router.add_get("/home", index_route)
|
||||||
app.router.add_get("/invite", index_route)
|
app.router.add_get("/invite", index_route)
|
||||||
app.router.add_get("/devices", index_route)
|
app.router.add_get("/devices", index_route)
|
||||||
app.router.add_get("/settings", index_route)
|
app.router.add_get("/settings", index_route)
|
||||||
|
app.router.add_get("/support", index_route)
|
||||||
|
app.router.add_get("/support/{ticket_id:\\d+}", index_route)
|
||||||
app.router.add_get("/admin", index_route)
|
app.router.add_get("/admin", index_route)
|
||||||
app.router.add_get("/admin/{section:[a-z][a-z0-9_-]*}", index_route)
|
app.router.add_get(
|
||||||
|
(
|
||||||
|
"/admin/{section:stats|users|payments|promos|ads|broadcast|logs|tariffs|"
|
||||||
|
"appearance|settings|support}"
|
||||||
|
),
|
||||||
|
index_route,
|
||||||
|
)
|
||||||
app.router.add_get("/admin/users/{user_id:-?[0-9]+}", index_route)
|
app.router.add_get("/admin/users/{user_id:-?[0-9]+}", index_route)
|
||||||
|
app.router.add_get("/admin/support/{ticket_id:\\d+}", index_route)
|
||||||
app.router.add_get("/auth/telegram/start", telegram_oauth_start_route)
|
app.router.add_get("/auth/telegram/start", telegram_oauth_start_route)
|
||||||
app.router.add_get("/auth/telegram/callback", telegram_oauth_callback_route)
|
app.router.add_get("/auth/telegram/callback", telegram_oauth_callback_route)
|
||||||
app.router.add_get("/health", health_route)
|
app.router.add_get("/health", health_route)
|
||||||
app.router.add_get(WEBAPP_LOGO_PROXY_PATH, webapp_logo_route)
|
app.router.add_get(WEBAPP_LOGO_PROXY_PATH, webapp_logo_route)
|
||||||
|
app.router.add_get(
|
||||||
|
rf"{WEBAPP_UPLOADED_LOGO_PATH}/{{filename:[A-Za-z0-9_.-]+}}",
|
||||||
|
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(
|
app.router.add_get(
|
||||||
r"/webapp-emoji/{codepoints:[0-9a-f_]+}/512.{ext:gif|webp}",
|
r"/webapp-emoji/{codepoints:[0-9a-f_]+}/512.{ext:gif|webp}",
|
||||||
webapp_animated_emoji_route,
|
webapp_animated_emoji_route,
|
||||||
)
|
)
|
||||||
|
app.router.add_get("/subscription_webapp.{asset_hash:[0-9a-f]{8}}.css", css_asset_route)
|
||||||
app.router.add_get("/subscription_webapp.css", css_asset_route)
|
app.router.add_get("/subscription_webapp.css", css_asset_route)
|
||||||
|
app.router.add_get(
|
||||||
|
"/subscription_webapp_admin.{asset_hash:[0-9a-f]{8}}.css",
|
||||||
|
admin_css_asset_route,
|
||||||
|
)
|
||||||
|
app.router.add_get("/subscription_webapp_admin.css", admin_css_asset_route)
|
||||||
|
app.router.add_get(r"/webapp-theme-css/{path:.+}", theme_css_asset_route)
|
||||||
|
app.router.add_get(r"/webapp-theme-assets/{path:.+}", theme_asset_route)
|
||||||
app.router.add_get("/subscription_webapp.min.{asset_hash}.js", js_asset_route)
|
app.router.add_get("/subscription_webapp.min.{asset_hash}.js", js_asset_route)
|
||||||
app.router.add_get("/subscription_webapp.js", js_asset_route)
|
app.router.add_get("/subscription_webapp.js", js_asset_route)
|
||||||
|
app.router.add_get("/subscription_webapp_admin.min.{asset_hash}.js", admin_js_asset_route)
|
||||||
|
app.router.add_get("/subscription_webapp_admin.js", admin_js_asset_route)
|
||||||
app.router.add_post("/api/auth/telegram/nonce", telegram_oauth_nonce_route)
|
app.router.add_post("/api/auth/telegram/nonce", telegram_oauth_nonce_route)
|
||||||
app.router.add_post("/api/auth/token", auth_token_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/request", email_auth_request_route)
|
||||||
app.router.add_post("/api/auth/email/verify", email_auth_verify_route)
|
app.router.add_post("/api/auth/email/verify", email_auth_verify_route)
|
||||||
app.router.add_post("/api/auth/email/magic", email_auth_magic_route)
|
app.router.add_post("/api/auth/email/magic", email_auth_magic_route)
|
||||||
|
app.router.add_post("/api/auth/email/password", email_password_auth_route)
|
||||||
app.router.add_post("/api/auth/logout", logout_route)
|
app.router.add_post("/api/auth/logout", logout_route)
|
||||||
|
app.router.add_get("/api/bootstrap", bootstrap_route)
|
||||||
|
app.router.add_get("/api/i18n", i18n_route)
|
||||||
app.router.add_get("/api/me", me_route)
|
app.router.add_get("/api/me", me_route)
|
||||||
app.router.add_get("/api/account/avatar", account_avatar_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/language", account_language_route)
|
||||||
app.router.add_post("/api/account/email/request", account_email_request_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/email/verify", account_email_verify_route)
|
||||||
|
app.router.add_post("/api/account/password/request", account_password_request_route)
|
||||||
|
app.router.add_post("/api/account/password/confirm", account_password_confirm_route)
|
||||||
app.router.add_post("/api/account/telegram/link", account_telegram_link_route)
|
app.router.add_post("/api/account/telegram/link", account_telegram_link_route)
|
||||||
app.router.add_post("/api/promo/apply", apply_promo_route)
|
app.router.add_post("/api/promo/apply", apply_promo_route)
|
||||||
app.router.add_post("/api/trial/activate", activate_trial_route)
|
app.router.add_post("/api/trial/activate", activate_trial_route)
|
||||||
app.router.add_get("/api/devices", devices_route)
|
app.router.add_get("/api/devices", devices_route)
|
||||||
app.router.add_post("/api/devices/disconnect", disconnect_device_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/devices/topup-options", device_topup_options_route)
|
||||||
|
app.router.add_get("/api/support/tickets", support_tickets_route)
|
||||||
|
app.router.add_post("/api/support/tickets", support_create_ticket_route)
|
||||||
|
app.router.add_get("/api/support/tickets/{id:\\d+}", support_ticket_detail_route)
|
||||||
|
app.router.add_post("/api/support/tickets/{id:\\d+}/messages", support_ticket_reply_route)
|
||||||
|
app.router.add_post("/api/support/tickets/{id:\\d+}/read", support_ticket_read_route)
|
||||||
|
app.router.add_get("/api/support/unread", support_unread_route)
|
||||||
app.router.add_get("/api/tariffs/topup-options", tariff_topup_options_route)
|
app.router.add_get("/api/tariffs/topup-options", tariff_topup_options_route)
|
||||||
app.router.add_get("/api/tariffs/change-options", tariff_change_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", tariff_change_route)
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
# ruff: noqa: F401,F403,F405,I001
|
# ruff: noqa: F401,F403,F405,I001
|
||||||
from ._runtime import * # noqa: F403,F405
|
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]:
|
async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, Any]:
|
||||||
settings: Settings = request.app["settings"]
|
settings: Settings = request.app["settings"]
|
||||||
@@ -36,6 +38,11 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
|
|||||||
if referral_service
|
if referral_service
|
||||||
else {"invited_count": 0, "purchased_count": 0}
|
else {"invited_count": 0, "purchased_count": 0}
|
||||||
)
|
)
|
||||||
|
support_unread_count = (
|
||||||
|
await support_dal.count_user_unread(session, user_id)
|
||||||
|
if settings.SUPPORT_TICKETS_ENABLED
|
||||||
|
else 0
|
||||||
|
)
|
||||||
local_sub = (
|
local_sub = (
|
||||||
await subscription_dal.get_active_subscription_by_user_id(
|
await subscription_dal.get_active_subscription_by_user_id(
|
||||||
session,
|
session,
|
||||||
@@ -65,6 +72,9 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
|
|||||||
"username": db_user.username,
|
"username": db_user.username,
|
||||||
"email": db_user.email,
|
"email": db_user.email,
|
||||||
"email_verified": bool(db_user.email_verified_at),
|
"email_verified": bool(db_user.email_verified_at),
|
||||||
|
"password_auth_enabled": bool(
|
||||||
|
db_user.email and db_user.email_verified_at and db_user.password_hash
|
||||||
|
),
|
||||||
"telegram_id": db_user.telegram_id,
|
"telegram_id": db_user.telegram_id,
|
||||||
"telegram_linked": bool(_telegram_id_for_user(db_user)),
|
"telegram_linked": bool(_telegram_id_for_user(db_user)),
|
||||||
"telegram_photo_url": _telegram_avatar_url(avatar),
|
"telegram_photo_url": _telegram_avatar_url(avatar),
|
||||||
@@ -95,9 +105,20 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
|
|||||||
traffic_packages=cached["traffic_packages"],
|
traffic_packages=cached["traffic_packages"],
|
||||||
stars_traffic_packages=cached["stars_traffic_packages"],
|
stars_traffic_packages=cached["stars_traffic_packages"],
|
||||||
),
|
),
|
||||||
"payment_methods": _serialize_payment_methods(settings, request.app),
|
"payment_methods": _serialize_payment_methods(settings, request.app, lang),
|
||||||
|
"themes_catalog": public_themes_catalog_payload(
|
||||||
|
settings.webapp_themes_catalog,
|
||||||
|
settings.WEBAPP_PRIMARY_COLOR or "#00fe7a",
|
||||||
|
enabled_only=True,
|
||||||
|
),
|
||||||
|
"support_unread_count": int(support_unread_count or 0),
|
||||||
"settings": {
|
"settings": {
|
||||||
"support_url": settings.SUPPORT_LINK,
|
"support_url": settings.SUPPORT_LINK,
|
||||||
|
"support_tickets_enabled": bool(settings.SUPPORT_TICKETS_ENABLED),
|
||||||
|
"support_ticket_max_body_length": int(settings.SUPPORT_TICKET_MAX_BODY_LENGTH or 4000),
|
||||||
|
"support_ticket_max_subject_length": int(
|
||||||
|
settings.SUPPORT_TICKET_MAX_SUBJECT_LENGTH or 160
|
||||||
|
),
|
||||||
"traffic_mode": bool(settings.traffic_sale_mode),
|
"traffic_mode": bool(settings.traffic_sale_mode),
|
||||||
"my_devices_enabled": bool(settings.MY_DEVICES_SECTION_ENABLED),
|
"my_devices_enabled": bool(settings.MY_DEVICES_SECTION_ENABLED),
|
||||||
"user_hwid_device_limit": (
|
"user_hwid_device_limit": (
|
||||||
@@ -110,6 +131,7 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
|
|||||||
"trial_duration_days": int(settings.TRIAL_DURATION_DAYS or 0),
|
"trial_duration_days": int(settings.TRIAL_DURATION_DAYS or 0),
|
||||||
"trial_traffic_limit_gb": float(settings.TRIAL_TRAFFIC_LIMIT_GB or 0),
|
"trial_traffic_limit_gb": float(settings.TRIAL_TRAFFIC_LIMIT_GB or 0),
|
||||||
"trial_traffic_strategy": getattr(settings, "TRIAL_TRAFFIC_STRATEGY", "NO_RESET"),
|
"trial_traffic_strategy": getattr(settings, "TRIAL_TRAFFIC_STRATEGY", "NO_RESET"),
|
||||||
|
"subscription_purchase_description": settings.subscription_purchase_description(lang),
|
||||||
"email_auth_enabled": settings.email_auth_configured,
|
"email_auth_enabled": settings.email_auth_configured,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -186,6 +208,7 @@ def _serialize_subscription(
|
|||||||
can_topup_regular_traffic = False
|
can_topup_regular_traffic = False
|
||||||
can_topup_premium_traffic = False
|
can_topup_premium_traffic = False
|
||||||
can_topup_traffic = False
|
can_topup_traffic = False
|
||||||
|
can_topup_devices = False
|
||||||
if settings.tariffs_config and active.get("tariff_key"):
|
if settings.tariffs_config and active.get("tariff_key"):
|
||||||
try:
|
try:
|
||||||
tariff = settings.tariffs_config.require(str(active.get("tariff_key")))
|
tariff = settings.tariffs_config.require(str(active.get("tariff_key")))
|
||||||
@@ -197,10 +220,16 @@ def _serialize_subscription(
|
|||||||
and tariff.premium_topup_packages.has_any()
|
and tariff.premium_topup_packages.has_any()
|
||||||
)
|
)
|
||||||
can_topup_traffic = bool(can_topup_regular_traffic or can_topup_premium_traffic)
|
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:
|
except Exception:
|
||||||
can_topup_regular_traffic = False
|
can_topup_regular_traffic = False
|
||||||
can_topup_premium_traffic = False
|
can_topup_premium_traffic = False
|
||||||
can_topup_traffic = False
|
can_topup_traffic = False
|
||||||
|
can_topup_devices = False
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"active": seconds_left > 0,
|
"active": seconds_left > 0,
|
||||||
@@ -242,6 +271,7 @@ def _serialize_subscription(
|
|||||||
"can_topup_traffic": can_topup_traffic,
|
"can_topup_traffic": can_topup_traffic,
|
||||||
"can_topup_regular_traffic": can_topup_regular_traffic,
|
"can_topup_regular_traffic": can_topup_regular_traffic,
|
||||||
"can_topup_premium_traffic": can_topup_premium_traffic,
|
"can_topup_premium_traffic": can_topup_premium_traffic,
|
||||||
|
"can_topup_devices": can_topup_devices,
|
||||||
"period_start_at": active.get("period_start_at").isoformat()
|
"period_start_at": active.get("period_start_at").isoformat()
|
||||||
if active.get("period_start_at")
|
if active.get("period_start_at")
|
||||||
else None,
|
else None,
|
||||||
@@ -551,59 +581,23 @@ def _serialize_tariff_change_target(
|
|||||||
def _serialize_payment_methods(
|
def _serialize_payment_methods(
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
app: web.Application,
|
app: web.Application,
|
||||||
|
lang: str = "ru",
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
labels = {
|
from bot.payment_providers import get_provider_spec, resolve_provider_presentation
|
||||||
"severpay": "SeverPay",
|
|
||||||
"freekassa": "FreeKassa / СБП",
|
|
||||||
"platega_sbp": "Platega · СБП",
|
|
||||||
"platega_crypto": "Platega · Crypto",
|
|
||||||
"yookassa": "Банковская карта",
|
|
||||||
"stars": "Telegram Stars",
|
|
||||||
"cryptopay": "CryptoPay",
|
|
||||||
}
|
|
||||||
methods: List[Dict[str, Any]] = []
|
methods: List[Dict[str, Any]] = []
|
||||||
for method in settings.payment_methods_order:
|
for method in settings.payment_methods_order:
|
||||||
method = method.lower()
|
method = method.lower()
|
||||||
if (
|
spec = get_provider_spec(method)
|
||||||
method == "severpay"
|
if spec and spec.is_visible(settings, app):
|
||||||
and settings.SEVERPAY_ENABLED
|
presentation = resolve_provider_presentation(spec, settings, language=lang)
|
||||||
and _service_configured(app, "severpay_service")
|
methods.append(
|
||||||
):
|
{
|
||||||
methods.append({"id": method, "name": labels[method]})
|
"id": method,
|
||||||
elif (
|
"name": presentation.webapp_label,
|
||||||
method == "freekassa"
|
"icon": presentation.webapp_icon,
|
||||||
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
|
return methods
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
# ruff: noqa: F401,F403,F405,I001
|
||||||
|
from ._runtime import * # noqa: F403,F405
|
||||||
|
|
||||||
|
from bot.services.support_service import TicketForbidden, TicketNotFound, TicketRateLimited
|
||||||
|
from db.dal import support_dal, user_dal
|
||||||
|
from db.models import SupportTicket, SupportTicketMessage
|
||||||
|
|
||||||
|
|
||||||
|
def _support_ticket_payload(ticket: SupportTicket) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"ticket_id": ticket.ticket_id,
|
||||||
|
"user_id": ticket.user_id,
|
||||||
|
"subject": ticket.subject,
|
||||||
|
"category": ticket.category,
|
||||||
|
"priority": ticket.priority,
|
||||||
|
"status": ticket.status,
|
||||||
|
"assigned_admin_id": ticket.assigned_admin_id,
|
||||||
|
"last_message_at": ticket.last_message_at.isoformat() if ticket.last_message_at else None,
|
||||||
|
"last_message_role": ticket.last_message_role,
|
||||||
|
"unread_user_count": int(ticket.unread_user_count or 0),
|
||||||
|
"unread_admin_count": int(ticket.unread_admin_count or 0),
|
||||||
|
"created_at": ticket.created_at.isoformat() if ticket.created_at else None,
|
||||||
|
"updated_at": ticket.updated_at.isoformat() if ticket.updated_at else None,
|
||||||
|
"closed_at": ticket.closed_at.isoformat() if ticket.closed_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _support_message_payload(message: SupportTicketMessage) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"message_id": message.message_id,
|
||||||
|
"ticket_id": message.ticket_id,
|
||||||
|
"author_role": message.author_role,
|
||||||
|
"author_user_id": message.author_user_id,
|
||||||
|
"body": message.body,
|
||||||
|
"is_internal_note": bool(message.is_internal_note),
|
||||||
|
"created_at": message.created_at.isoformat() if message.created_at else None,
|
||||||
|
"read_by_user_at": message.read_by_user_at.isoformat() if message.read_by_user_at else None,
|
||||||
|
"read_by_admin_at": message.read_by_admin_at.isoformat()
|
||||||
|
if message.read_by_admin_at
|
||||||
|
else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _support_limit_offset(request: web.Request) -> tuple[int, int]:
|
||||||
|
limit = max(1, min(100, int(request.query.get("limit", 25) or 25)))
|
||||||
|
offset = max(0, int(request.query.get("offset", 0) or 0))
|
||||||
|
return limit, offset
|
||||||
|
|
||||||
|
|
||||||
|
async def support_tickets_route(request: web.Request) -> web.Response:
|
||||||
|
user_id = _require_user_id(request)
|
||||||
|
limit, offset = _support_limit_offset(request)
|
||||||
|
status_filter = request.query.get("status") or None
|
||||||
|
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||||
|
async with async_session_factory() as session:
|
||||||
|
tickets = await support_dal.list_user_tickets(
|
||||||
|
session,
|
||||||
|
user_id,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
status_filter=status_filter,
|
||||||
|
)
|
||||||
|
counts = await support_dal.user_ticket_counts(session, user_id)
|
||||||
|
return web.json_response(
|
||||||
|
{
|
||||||
|
"ok": True,
|
||||||
|
"tickets": [_support_ticket_payload(t) for t in tickets],
|
||||||
|
"counts": counts,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def support_create_ticket_route(request: web.Request) -> web.Response:
|
||||||
|
user_id = _require_user_id(request)
|
||||||
|
payload, error = _validate_model_payload(CreateTicketPayload, await _read_json(request))
|
||||||
|
if error:
|
||||||
|
return error
|
||||||
|
service = request.app["support_service"]
|
||||||
|
try:
|
||||||
|
ticket = await service.create_ticket(
|
||||||
|
user_id,
|
||||||
|
payload.subject,
|
||||||
|
payload.category,
|
||||||
|
payload.priority,
|
||||||
|
payload.body,
|
||||||
|
)
|
||||||
|
except TicketForbidden:
|
||||||
|
return _json_error(403, "ticket_forbidden", "Support ticket action is forbidden")
|
||||||
|
except TicketRateLimited:
|
||||||
|
return _json_error(429, "ticket_rate_limited", "Too many support tickets")
|
||||||
|
return web.json_response({"ok": True, "ticket": _support_ticket_payload(ticket)})
|
||||||
|
|
||||||
|
|
||||||
|
async def support_ticket_detail_route(request: web.Request) -> web.Response:
|
||||||
|
user_id = _require_user_id(request)
|
||||||
|
ticket_id = int(request.match_info["id"])
|
||||||
|
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||||
|
async with async_session_factory() as session:
|
||||||
|
ticket, messages = await support_dal.get_ticket(session, ticket_id, include_internal=False)
|
||||||
|
if not ticket or ticket.user_id != user_id:
|
||||||
|
return _json_error(404, "not_found", "Ticket not found")
|
||||||
|
return web.json_response(
|
||||||
|
{
|
||||||
|
"ok": True,
|
||||||
|
"ticket": _support_ticket_payload(ticket),
|
||||||
|
"messages": [_support_message_payload(m) for m in messages],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def support_ticket_reply_route(request: web.Request) -> web.Response:
|
||||||
|
user_id = _require_user_id(request)
|
||||||
|
ticket_id = int(request.match_info["id"])
|
||||||
|
payload, error = _validate_model_payload(TicketReplyPayload, await _read_json(request))
|
||||||
|
if error:
|
||||||
|
return error
|
||||||
|
service = request.app["support_service"]
|
||||||
|
try:
|
||||||
|
ticket, message = await service.reply_as_user(user_id, ticket_id, payload.body)
|
||||||
|
except TicketForbidden:
|
||||||
|
return _json_error(403, "ticket_forbidden", "Support ticket action is forbidden")
|
||||||
|
except TicketNotFound:
|
||||||
|
return _json_error(404, "not_found", "Ticket not found")
|
||||||
|
return web.json_response(
|
||||||
|
{
|
||||||
|
"ok": True,
|
||||||
|
"ticket": _support_ticket_payload(ticket),
|
||||||
|
"message": _support_message_payload(message),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def support_ticket_read_route(request: web.Request) -> web.Response:
|
||||||
|
user_id = _require_user_id(request)
|
||||||
|
ticket_id = int(request.match_info["id"])
|
||||||
|
service = request.app["support_service"]
|
||||||
|
try:
|
||||||
|
await service.mark_read_as_user(user_id, ticket_id)
|
||||||
|
except TicketNotFound:
|
||||||
|
return _json_error(404, "not_found", "Ticket not found")
|
||||||
|
return web.json_response({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
|
async def support_unread_route(request: web.Request) -> web.Response:
|
||||||
|
user_id = _require_user_id(request)
|
||||||
|
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||||
|
async with async_session_factory() as session:
|
||||||
|
user = await user_dal.get_user_by_id(session, user_id)
|
||||||
|
if user and user.is_banned:
|
||||||
|
return _json_error(403, "ticket_forbidden", "Support ticket action is forbidden")
|
||||||
|
unread = await support_dal.count_user_unread(session, user_id)
|
||||||
|
return web.json_response({"ok": True, "unread": unread})
|
||||||