Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c1e86863d | ||
|
|
16b11327ee | ||
|
|
3d5998b8fb | ||
|
|
2df4d2d770 | ||
|
|
4ce78f277e | ||
|
|
d7d5c2b1ef | ||
|
|
aabc0e312d | ||
|
|
d15d58df36 | ||
|
|
21f2ac4534 | ||
|
|
dd0e6e75af | ||
|
|
0251f939d7 | ||
|
|
a00bc0f345 | ||
|
|
ae4ce43e1c | ||
|
|
19ed8916f4 | ||
|
|
f7de08bfd7 | ||
|
|
6e42f67931 | ||
|
|
b87b6cd378 | ||
|
|
b39fb73c30 | ||
|
|
3120884ed5 | ||
|
|
c927244b1a | ||
|
|
e049f38c7c | ||
|
|
ddc6ee0024 | ||
|
|
fa5f1460ab | ||
|
|
9f556f3e04 | ||
|
|
70b618a94b | ||
|
|
0610156f80 | ||
|
|
587b39de1d | ||
|
|
e35885b54b | ||
|
|
c1b7ace876 | ||
|
|
f416b0aed1 | ||
|
|
c8192e4427 | ||
|
|
dae1a6889b | ||
|
|
4740666d63 | ||
|
|
d6b703debb | ||
|
|
f1113eb80a | ||
|
|
604f0d9656 | ||
|
|
8ce5a92625 | ||
|
|
72b6e93c94 | ||
|
|
d60952718e | ||
|
|
53dcc59770 | ||
|
|
5ec179b6d6 | ||
|
|
250df445f0 | ||
|
|
12376e5070 | ||
|
|
c2afc6107a | ||
|
|
77370eb963 | ||
|
|
94b0787cad | ||
|
|
9c499fe3c2 | ||
|
|
7e26f9da9b | ||
|
|
778615a97f | ||
|
|
651572f15f | ||
|
|
5ccb8ddabe | ||
|
|
86f944e544 | ||
|
|
bc29f5ebd6 | ||
|
|
9a84be85c6 | ||
|
|
c08ac854b2 | ||
|
|
807a8933b9 | ||
|
|
b9cb1fec06 | ||
|
|
eab803652b | ||
|
|
259d0646bc | ||
|
|
4f1b7d0832 | ||
|
|
1aa529ab23 | ||
|
|
a46502380c | ||
|
|
85c276bcf5 | ||
|
|
55f8db11ce | ||
|
|
79af61a48e | ||
|
|
69d2e2a899 | ||
|
|
069ad967b5 | ||
|
|
18a6b3e18d | ||
|
|
3b9043332c | ||
|
|
d94b57bd0b | ||
|
|
5247092ca2 | ||
|
|
049789c9c5 | ||
|
|
6b74ae4a8e | ||
|
|
c13c01ea43 | ||
|
|
48e7d3569f | ||
|
|
4f3b45cbd8 | ||
|
|
a7ec55d741 | ||
|
|
0701af0f35 | ||
|
|
c0851c5339 | ||
|
|
785b6c2d41 | ||
|
|
cce3fd4f58 | ||
|
|
0dd6beebb2 | ||
|
|
8fb2a76698 | ||
|
|
3d6713caa9 | ||
|
|
8d8fdce519 | ||
|
|
c385a1466c | ||
|
|
8e1fdb33d2 | ||
|
|
29222be1fa | ||
|
|
08aca3b28d | ||
|
|
658139d607 | ||
|
|
1d4abf7977 | ||
|
|
90ab186a7c | ||
|
|
62e8bc08d5 | ||
|
|
dc725ccd68 | ||
|
|
d88aee03bb | ||
|
|
475a30caf4 | ||
|
|
c1593d7868 | ||
|
|
0dbcfe3770 | ||
|
|
e3ffb87cfc | ||
|
|
b4218ec6a7 | ||
|
|
48f8656ef8 | ||
|
|
cee8491b3d | ||
|
|
4bc3f3b4b3 | ||
|
|
adb1105621 | ||
|
|
c8fd79e4ba | ||
|
|
52bc9f71cc | ||
|
|
a1d3db0e16 | ||
|
|
d0e34126e1 | ||
|
|
c96ebea077 | ||
|
|
f00a72ad15 | ||
|
|
22171eb66f | ||
|
|
061fdeb72b | ||
|
|
8eba23574b | ||
|
|
7b650a74a6 | ||
|
|
805bd4cf60 | ||
|
|
56a32d493f | ||
|
|
b24a685066 | ||
|
|
fb06fbd0e1 | ||
|
|
719369057f | ||
|
|
cea9d98155 | ||
|
|
6cf15e0698 | ||
|
|
2971b9ad2e | ||
|
|
3e86fc76b6 | ||
|
|
829a18715a | ||
|
|
844c8e12a7 | ||
|
|
ef75f905f4 | ||
|
|
cd816cbe5c | ||
|
|
2b0fa3a314 | ||
|
|
ead990e61c | ||
|
|
eb6e343e4c | ||
|
|
caf5b88eef | ||
|
|
183bef070d | ||
|
|
643bf5fabb | ||
|
|
7d446d5f64 | ||
|
|
73ce689d5e | ||
|
|
5791f2e58c | ||
|
|
69cc2cbe29 | ||
|
|
7df2e127a4 | ||
|
|
d503cf7f7f | ||
|
|
c0d70030c3 | ||
|
|
4e7c36dbf7 | ||
|
|
3b0b88e9a0 | ||
|
|
207e7751cd | ||
|
|
c65b608f0b | ||
|
|
cff8368b7c | ||
|
|
2f367bbd6b | ||
|
|
c3d6324ec6 | ||
|
|
4800a2da80 | ||
|
|
70a0cd44a1 | ||
|
|
545c9c7d07 | ||
|
|
cd60f496fa | ||
|
|
f883065bb9 | ||
|
|
b71bd71d8d | ||
|
|
0524d24b13 |
@@ -5,6 +5,13 @@
|
||||
.gitattributes
|
||||
LICENSE
|
||||
README.md
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
scratch_*.py
|
||||
*.local.*
|
||||
node_modules/
|
||||
.git/
|
||||
|
||||
|
||||
# CI
|
||||
@@ -17,6 +24,12 @@ docker-compose.yml
|
||||
Dockerfile
|
||||
.docker
|
||||
.dockerignore
|
||||
tmp/
|
||||
|
||||
# WebApp build artifacts (regenerated inside Docker)
|
||||
bot/app/web/templates/subscription_webapp.css
|
||||
bot/app/web/templates/subscription_webapp.js
|
||||
bot/app/web/templates/subscription_webapp.min.*.js
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
**/__pycache__/
|
||||
|
||||
+105
-35
@@ -3,9 +3,9 @@ BOT_TOKEN=your_bot_token_here #
|
||||
ADMIN_IDS=comma_separated_admin_ids # Your telegram ID
|
||||
|
||||
# PostgreSQL Database Connection Settings
|
||||
POSTGRES_USER=postgres # Database user name
|
||||
POSTGRES_PASSWORD=postgres # Database password
|
||||
POSTGRES_HOST=remnawave-tg-shop-db # Database container name
|
||||
POSTGRES_USER= # Required: database user name
|
||||
POSTGRES_PASSWORD= # Required: database password
|
||||
POSTGRES_HOST=remnawave-minishop-db # Database container name
|
||||
POSTGRES_PORT=5432 # Port
|
||||
POSTGRES_DB=postgres # Database name
|
||||
|
||||
@@ -17,7 +17,9 @@ DEFAULT_CURRENCY_SYMBOL="RUB" #
|
||||
SUPPORT_LINK=https://t.me/your_support_link # Link to the support chat
|
||||
SERVER_STATUS_URL=https://status.yourdomain.tld/status/your_service # Link to the server status page
|
||||
TERMS_OF_SERVICE_URL=https://example.com/tos # Link to the terms of service
|
||||
SUBSCRIPTION_MINI_APP_URL= # URL of the subscription mini-app
|
||||
PRIVACY_POLICY_URL=https://example.com/privacy # Link to the privacy policy
|
||||
USER_AGREEMENT_URL=https://example.com/user-agreement # Link to the user agreement
|
||||
SUBSCRIPTION_MINI_APP_URL= # Public URL of the subscription Mini App, e.g. https://app.yourdomain.tld/
|
||||
START_COMMAND_DESCRIPTION= # Description of the /start command
|
||||
DISABLE_WELCOME_MESSAGE= # Disable the welcome message
|
||||
MY_DEVICES_SECTION_ENABLED=False # Enable the My Devices section in the subscription menu
|
||||
@@ -29,13 +31,49 @@ REQUIRED_CHANNEL_LINK=https://t.me/your_channel #
|
||||
|
||||
# Webhook Base URL (used for Telegram and payment providers)
|
||||
WEBHOOK_BASE_URL=https://webhooks.yourdomain.tld
|
||||
TRUSTED_PROXIES=127.0.0.1,::1 # Reverse proxies trusted for X-Forwarded-For
|
||||
|
||||
# Subscription Mini App (same container, separate port)
|
||||
WEBAPP_ENABLED=True # Run Mini App HTTP server
|
||||
WEBAPP_SERVER_HOST=0.0.0.0 # Internal listen host
|
||||
WEBAPP_SERVER_PORT=8081 # Internal/published Mini App port
|
||||
WEBAPP_TITLE="/minishop" # Mini App title
|
||||
WEBAPP_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
|
||||
|
||||
# Email login and account linking via SMTP (Brevo SMTP relay defaults)
|
||||
SMTP_HOST=smtp-relay.brevo.com # SMTP server
|
||||
SMTP_PORT=587 # Brevo recommends 587 with STARTTLS
|
||||
SMTP_FALLBACK_PORTS=2525,465 # Tried after SMTP_PORT; 465 uses SSL automatically
|
||||
SMTP_TIMEOUT_SECONDS=30 # Per SMTP connection/send attempt timeout
|
||||
SMTP_USERNAME= # Brevo SMTP login
|
||||
SMTP_PASSWORD= # Brevo SMTP key/password
|
||||
SMTP_FROM_EMAIL= # Verified sender email
|
||||
SMTP_FROM_NAME= # Optional sender name
|
||||
SMTP_STARTTLS=True # Use STARTTLS on SMTP_PORT
|
||||
SMTP_USE_SSL=False # Use SSL wrapper, usually only for port 465
|
||||
EMAIL_CODE_TTL_SECONDS=600 # Email verification code lifetime
|
||||
EMAIL_CODE_RESEND_SECONDS=60 # Minimum delay between code sends
|
||||
EMAIL_CODE_MAX_ATTEMPTS=5 # Max attempts per code
|
||||
BRUTE_FORCE_MAX_FAILURES=5 # Max failed code attempts in the throttle window
|
||||
BRUTE_FORCE_WINDOW_SECONDS=900 # Rolling window used to count failures
|
||||
BRUTE_FORCE_LOCK_SECONDS=1800 # Temporary lockout duration after too many failures
|
||||
|
||||
# Payment Method Toggles
|
||||
YOOKASSA_ENABLED=True # Turn on YOOKASSA
|
||||
FREEKASSA_ENABLED=True # Turn on FreeKassa
|
||||
STARS_ENABLED=True # Turn on STARS
|
||||
TRIBUTE_ENABLED=True # Turn on TRIBUTE
|
||||
CRYPTOPAY_ENABLED=True # Turn on CRYPTOPAY
|
||||
PLATEGA_ENABLED=False # Turn on PLATEGA
|
||||
SEVERPAY_ENABLED=False # Turn on SeverPay
|
||||
# Order of payment methods (top to bottom). Supported: severpay, freekassa, platega, yookassa, stars, cryptopay
|
||||
PAYMENT_METHODS_ORDER=severpay,yookassa,cryptopay,freekassa,platega,stars
|
||||
|
||||
# YooKassa Payment Gateway Configuration
|
||||
YOOKASSA_SHOP_ID=your_shop_id # Your store ID in YooKassa
|
||||
@@ -44,12 +82,21 @@ YOOKASSA_RETURN_URL=https://t.me/your_bot #
|
||||
YOOKASSA_DEFAULT_RECEIPT_EMAIL=your_email@example.com # Default email for sending receipts
|
||||
YOOKASSA_VAT_CODE=1 # VAT code
|
||||
YOOKASSA_AUTOPAYMENTS_ENABLED=False # Auto-renew toggle
|
||||
YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING=True # Force automatic card binding when autopay is enabled (set to False to show the save-card checkbox)
|
||||
|
||||
# Nalogo (self-employed receipts)
|
||||
NALOGO_INN=your_inn # INN for nalog.ru
|
||||
NALOGO_PASSWORD=your_nalogo_password # Password for nalog.ru
|
||||
NALOGO_RECEIPT_NAME_SUBSCRIPTION=subscription {months} months # Receipt name for time-based subscriptions ({months} = duration)
|
||||
NALOGO_RECEIPT_NAME_TRAFFIC=traffic package {gb} GB # Receipt name for traffic packages ({gb} = traffic amount)
|
||||
|
||||
# FreeKassa Payment Gateway Configuration
|
||||
FREEKASSA_MERCHANT_ID=your_shop_id # Your shop ID in FreeKassa
|
||||
FREEKASSA_API_KEY=your_api_key # API key for REST requests
|
||||
FREEKASSA_SECOND_SECRET=your_second_secret # Secret word #2 (used to verify notifications)
|
||||
FREEKASSA_PAYMENT_IP= # Public IP address reported to FreeKassa
|
||||
FREEKASSA_PAYMENT_METHOD_ID=44 # Payment method ID, you can get it from https://merchant.freekassa.net/settings/currencies
|
||||
FREEKASSA_TRUSTED_IPS=168.119.157.136,168.119.60.227,178.154.197.79,51.250.54.238 # FreeKassa webhook source IP allowlist
|
||||
|
||||
# CryptoBot Payment Gateway Configuration
|
||||
CRYPTOPAY_TOKEN= # API token for CryptoPay
|
||||
@@ -57,40 +104,56 @@ CRYPTOPAY_NETWORK=mainnet #
|
||||
CRYPTOPAY_CURRENCY_TYPE=fiat # Currency type (fiat or crypto)
|
||||
CRYPTOPAY_ASSET=RUB # Asset, e.g., RUB, BTC, USDT
|
||||
|
||||
# Tribute Payment Gateway Configuration
|
||||
TRIBUTE_API_KEY= # API key for verifying Tribute webhook signatures
|
||||
TRIBUTE_SKIP_NOTIFICATIONS=True # Skip renewal notifications for Tribute payments
|
||||
TRIBUTE_SKIP_CANCELLATION_NOTIFICATIONS=False # Skip cancellation notifications for Tribute payments
|
||||
# Platega Payment Gateway Configuration
|
||||
PLATEGA_BASE_URL=https://app.platega.io # Base API URL
|
||||
PLATEGA_MERCHANT_ID= # Your MerchantId from Platega
|
||||
PLATEGA_SECRET= # API secret from Platega
|
||||
PLATEGA_PAYMENT_METHOD=2 # Legacy method ID; fallback for the SBP button when PLATEGA_SBP_METHOD stays default
|
||||
PLATEGA_SBP_ENABLED=False # Show a separate "Pay via SBP" Platega button
|
||||
PLATEGA_CRYPTO_ENABLED=False # Show a separate "Pay with crypto" Platega button
|
||||
PLATEGA_SBP_METHOD=2 # Platega method ID for SBP QR (default 2)
|
||||
PLATEGA_CRYPTO_METHOD=13 # Platega method ID for crypto (default 13)
|
||||
PLATEGA_RETURN_URL= # Optional: redirect after successful payment (defaults to bot link)
|
||||
PLATEGA_FAILED_URL= # Optional: redirect after failed/cancelled payment (defaults to return URL)
|
||||
|
||||
# SeverPay Payment Gateway Configuration
|
||||
SEVERPAY_BASE_URL=https://severpay.io/api/merchant # Base API URL
|
||||
SEVERPAY_MID= # Your MID from SeverPay
|
||||
SEVERPAY_TOKEN= # API token/secret for signing requests
|
||||
SEVERPAY_RETURN_URL= # Optional: redirect URL after payment (defaults to bot link)
|
||||
SEVERPAY_LIFETIME_MINUTES= # Optional: payment link lifetime in minutes (30-4320, leave empty for default)
|
||||
|
||||
# Subscription Options. Specify cost parameters or payment links here.
|
||||
1_MONTH_ENABLED=True
|
||||
RUB_PRICE_1_MONTH=150
|
||||
STARS_PRICE_1_MONTH=0
|
||||
TRIBUTE_LINK_1_MONTH=
|
||||
|
||||
3_MONTHS_ENABLED=True
|
||||
RUB_PRICE_3_MONTHS=300
|
||||
STARS_PRICE_3_MONTHS=0
|
||||
TRIBUTE_LINK_3_MONTHS=
|
||||
|
||||
6_MONTHS_ENABLED=True
|
||||
RUB_PRICE_6_MONTHS=500
|
||||
STARS_PRICE_6_MONTHS=0
|
||||
TRIBUTE_LINK_6_MONTHS=
|
||||
|
||||
12_MONTHS_ENABLED=True
|
||||
RUB_PRICE_12_MONTHS=900
|
||||
STARS_PRICE_12_MONTHS=0
|
||||
TRIBUTE_LINK_12_MONTHS=
|
||||
|
||||
# Traffic Packages (enables traffic sale mode when set)
|
||||
TRAFFIC_PACKAGES=10:199,50:799 # Format: "<GB>:<price>", comma-separated
|
||||
STARS_TRAFFIC_PACKAGES=10:2500 # Optional: traffic packages priced in Stars
|
||||
|
||||
# Subscription Notifications
|
||||
SUBSCRIPTION_NOTIFICATIONS_ENABLED=True # Enable subscription
|
||||
SUBSCRIPTION_NOTIFY_ON_EXPIRE=True # Notify on subscription
|
||||
SUBSCRIPTION_NOTIFY_AFTER_EXPIRE=True # Notify after
|
||||
SUBSCRIPTION_NOTIFY_DAYS_BEFORE=3 # Days before expiration to notify
|
||||
SUBSCRIPTION_NOTIFICATIONS_ENABLED=True # Enable subscription
|
||||
SUBSCRIPTION_NOTIFY_ON_EXPIRE=True # Notify on subscription
|
||||
SUBSCRIPTION_NOTIFY_AFTER_EXPIRE=True # Notify after
|
||||
SUBSCRIPTION_NOTIFY_DAYS_BEFORE=3 # Days before expiration to notify
|
||||
|
||||
|
||||
REFERRAL_ONE_BONUS_PER_REFEREE=False # Give a bonus only once per referee
|
||||
REFERRAL_ONE_BONUS_PER_REFEREE=False # Give a bonus only once per referee
|
||||
REFERRAL_WELCOME_BONUS_DAYS=3 # Welcome bonus for newly registered user from referral link
|
||||
LEGACY_REFS=true # Allow ref_<tg_id> links. Leave unset/true unless you want to disable old links
|
||||
# Referral Bonus Days
|
||||
# Bonus for the inviting user
|
||||
REFERRAL_BONUS_DAYS_1_MONTH=3
|
||||
@@ -104,39 +167,46 @@ REFEREE_BONUS_DAYS_6_MONTHS=7
|
||||
REFEREE_BONUS_DAYS_12_MONTHS=15
|
||||
|
||||
# Panel API Configuration
|
||||
PANEL_API_URL=http://your_panel_api_url/api # URL of the panel API
|
||||
PANEL_API_KEY=your_panel_api_key # Panel API key
|
||||
PANEL_WEBHOOK_SECRET= # secret used to verify panel webhook signatures
|
||||
PANEL_API_URL=http://your_panel_api_url/api # URL of the panel API
|
||||
PANEL_API_KEY=your_panel_api_key # Panel API key
|
||||
PANEL_WEBHOOK_SECRET= # secret used to verify panel webhook signatures
|
||||
|
||||
# User traffic limits (applied for all users)
|
||||
# 0 means unlimited
|
||||
USER_TRAFFIC_LIMIT_GB=0 # Traffic limit for users (0 unlimited)
|
||||
USER_TRAFFIC_STRATEGY="NO_RESET" # Traffic reset strategy (NO_RESET, WEEK, MONTH)
|
||||
USER_TRAFFIC_LIMIT_GB=0 # Traffic limit for users (0 unlimited)
|
||||
USER_TRAFFIC_STRATEGY="NO_RESET" # Traffic reset strategy (NO_RESET, WEEK, MONTH)
|
||||
|
||||
# Default Internal Squads for Users (Optional, comma-separated UUIDs)
|
||||
USER_SQUAD_UUIDS=uuid1,uuid2,uuid3
|
||||
# Default External Squad for Users (Optional, single UUID)
|
||||
USER_EXTERNAL_SQUAD_UUID= # Optional: UUID from Remnawave External Squads to auto-link new panel users
|
||||
|
||||
# Trial Settings
|
||||
TRIAL_ENABLED=True # Enable the trial period
|
||||
TRIAL_DURATION_DAYS=5 # Duration of the trial period in days
|
||||
TRIAL_TRAFFIC_LIMIT_GB=0 # Traffic limit for the trial period (0 = unlimited)
|
||||
TRIAL_TRAFFIC_STRATEGY="NO_RESET" # Traffic reset strategy for the trial period (NO_RESET, WEEK, MONTH)
|
||||
TRIAL_ENABLED=True # Enable the trial period
|
||||
TRIAL_DURATION_DAYS=5 # Duration of the trial period in days
|
||||
TRIAL_TRAFFIC_LIMIT_GB=0 # Traffic limit for the trial period (0 = unlimited)
|
||||
TRIAL_TRAFFIC_STRATEGY="NO_RESET" # Traffic reset strategy for the trial period (NO_RESET, WEEK, MONTH)
|
||||
|
||||
# Connection link handling (happ crypt4)
|
||||
CRYPT4_ENABLED=False # Enable happ crypt4 encryption for subscription URLs
|
||||
CRYPT4_REDIRECT_URL= # Base redirect to wrap the connect button, e.g. https://redir.example.com?url=
|
||||
|
||||
# Web Server Settings (for handling webhooks)
|
||||
WEB_SERVER_HOST="0.0.0.0"
|
||||
WEB_SERVER_PORT=8080
|
||||
|
||||
# Admin Panel Log Pagination
|
||||
LOGS_PAGE_SIZE=10 # Number of events in the log
|
||||
LOGS_PAGE_SIZE=10 # Number of events in the log
|
||||
LOG_LEVEL=INFO # Global log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
||||
|
||||
# Admin Logging Configuration
|
||||
LOG_CHAT_ID=-1001234567890 # Telegram chat/group ID for admin notifications
|
||||
LOG_THREAD_ID= # Optional: Thread ID for supergroup messages
|
||||
LOG_NEW_USERS=True # Log new user registrations
|
||||
LOG_PAYMENTS=True # Log payments
|
||||
LOG_PROMO_ACTIVATIONS=True # Log promo code activations
|
||||
LOG_TRIAL_ACTIVATIONS=True # Log trial activations
|
||||
LOG_SUSPICIOUS_ACTIVITY=True # Log suspicious activity
|
||||
LOG_CHAT_ID=-1001234567890 # Telegram chat/group ID for admin notifications
|
||||
LOG_THREAD_ID= # Optional: Thread ID for supergroup messages
|
||||
LOG_NEW_USERS=True # Log new user registrations
|
||||
LOG_PAYMENTS=True # Log payments
|
||||
LOG_PROMO_ACTIVATIONS=True # Log promo code activations
|
||||
LOG_TRIAL_ACTIVATIONS=True # Log trial activations
|
||||
LOG_SUSPICIOUS_ACTIVITY=True # Log suspicious activity
|
||||
|
||||
# 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
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
name: Build and Push Dev Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
pull_request:
|
||||
branches:
|
||||
- dev
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=sha,prefix={{branch}}-
|
||||
flavor: |
|
||||
latest=false
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Image digest
|
||||
run: echo ${{ steps.meta.outputs.digest }}
|
||||
@@ -1,44 +0,0 @@
|
||||
name: Build and Publish multi-arch Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
paths-ignore:
|
||||
- 'README.md'
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up QEMU (для эмуляции arm64 на x86)
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build and push multi-arch Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: |
|
||||
ghcr.io/${{ github.repository }}:latest
|
||||
ghcr.io/${{ github.repository }}:${{ github.ref_name }}
|
||||
+15
@@ -3,6 +3,19 @@ bot_database.sqlite3
|
||||
|
||||
# Игнорировать файлы окружения
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
scratch_*.py
|
||||
*.local.*
|
||||
node_modules/
|
||||
.git/
|
||||
|
||||
# WebApp build artifacts (regenerated by `npm run build:webapp` / Docker build)
|
||||
bot/app/web/templates/subscription_webapp.css
|
||||
bot/app/web/templates/subscription_webapp.js
|
||||
bot/app/web/templates/subscription_webapp.min.*.js
|
||||
tmp
|
||||
.claude
|
||||
|
||||
# Игнорировать кэш Python
|
||||
__pycache__/
|
||||
@@ -17,3 +30,5 @@ __pycache__/
|
||||
locales/ru_backup.json
|
||||
locales/en_backup.json
|
||||
db/models_old.py
|
||||
config/tariffs.json
|
||||
docker-compose-dev.yml
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
# 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}
|
||||
}
|
||||
+39
-4
@@ -1,4 +1,4 @@
|
||||
FROM python:3.11-slim AS builder
|
||||
FROM python:3.12-slim AS python-builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -7,14 +7,49 @@ COPY requirements.txt .
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
FROM python:3.11-slim
|
||||
|
||||
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
|
||||
|
||||
COPY --from=builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
|
||||
|
||||
LABEL org.opencontainers.image.source="https://github.com/3252a8/remnawave-minishop"
|
||||
|
||||
RUN useradd -u 10001 -m appuser
|
||||
|
||||
COPY --from=python-builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
|
||||
|
||||
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 rm -rf /root/.cache
|
||||
|
||||
RUN mkdir -p /app/logs /app/data && chown -R appuser:appuser /app/logs /app/data
|
||||
|
||||
USER appuser
|
||||
|
||||
CMD ["python", "main.py"]
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
# Telegram-бот для продажи подписок Remnawave
|
||||
# Remnawave Minishop
|
||||
|
||||
Этот Telegram-бот предназначен для автоматизации продажи и управления подписками для панели **Remnawave**. Он интегрируется с API Remnawave для управления пользователями и подписками, а также использует различные платежные системы для приема платежей.
|
||||
Remnawave Minishop — это Telegram-бот **и** Web App (Mini App) для автоматизации продажи и управления подписками панели **Remnawave**. Бот закрывает сценарий покупки, продления и работы с поддержкой прямо в чате, а Web App в едином интерфейсе показывает ссылку подключения, остаток времени, трафик, оплату и устройства, поддерживая вход через Telegram Mini Apps `initData`, Telegram Login Widget и одноразовый код по email. Под капотом — интеграция с API Remnawave для управления пользователями и подписками и набор платёжных шлюзов для приёма платежей.
|
||||
|
||||
> 🍴 **Это глубоко переработанный форк [kavore/remnawave-tg-shop](https://github.com/kavore/remnawave-tg-shop).** Здесь добавлены полноценный Web App / Mini App, вход по email и многое другое. Возможна миграция.
|
||||
|
||||
## ✨ Ключевые возможности
|
||||
|
||||
### Для пользователей:
|
||||
- **Регистрация и выбор языка:** Поддержка русского и английского языков.
|
||||
- **Просмотр подписки:** Пользователи могут видеть статус своей подписки, дату окончания и ссылку на конфигурацию.
|
||||
- **Web App (Mini App):** отдельный веб-интерфейс для просмотра ссылки подключения, остатка времени и оплаты подписки.
|
||||
- **Вход по email:** вход и регистрация в Web App по коду из письма, а также привязка email и Telegram к одному аккаунту.
|
||||
- **Мои устройства:** Опциональный раздел для просмотра и отключения подключенных устройств (активируется через переменную `MY_DEVICES_SECTION_ENABLED`).
|
||||
- **Пробная подписка:** Система пробных подписок для новых пользователей (активируется вручную по кнопке).
|
||||
- **Промокоды:** Возможность применять промокоды для получения скидок или бонусных дней.
|
||||
- **Реферальная программа:** Пользователи могут приглашать друзей и получать за это бонусные дни подписки.
|
||||
- **Оплата:** Поддержка оплаты через YooKassa, FreeKassa (REST API), CryptoPay, Telegram Stars и Tribute.
|
||||
- **Оплата:** Поддержка оплаты через YooKassa, FreeKassa (REST API), Platega, SeverPay, CryptoPay и Telegram Stars.
|
||||
|
||||
### Для администраторов:
|
||||
- **Защищенная админ-панель:** Доступ только для администраторов, указанных в `ADMIN_IDS`.
|
||||
@@ -24,11 +28,11 @@
|
||||
|
||||
## 🚀 Технологии
|
||||
|
||||
- **Python 3.11**
|
||||
- **Python 3.12**
|
||||
- **Aiogram 3.x:** Асинхронный фреймворк для Telegram ботов.
|
||||
- **aiohttp:** Для запуска веб-сервера (вебхуки).
|
||||
- **SQLAlchemy 2.x & asyncpg:** Асинхронная работа с базой данных PostgreSQL.
|
||||
- **YooKassa, FreeKassa API, aiocryptopay:** Интеграции с платежными системами.
|
||||
- **YooKassa, FreeKassa API, Platega, SeverPay, aiocryptopay:** Интеграции с платежными системами.
|
||||
- **Pydantic:** Для управления настройками из `.env` файла.
|
||||
- **Docker & Docker Compose:** Для контейнеризации и развертывания.
|
||||
|
||||
@@ -45,12 +49,12 @@
|
||||
|
||||
1. **Клонируйте репозиторий:**
|
||||
```bash
|
||||
git clone https://github.com/machka-pasla/remnawave-tg-shop
|
||||
cd remnawave-tg-shop
|
||||
git clone https://github.com/3252a8/remnawave-minishop
|
||||
cd remnawave-minishop
|
||||
```
|
||||
|
||||
2. **Создайте и настройте файл `.env`:**
|
||||
Скопируйте `env.example` в `.env` и заполните своими данными.
|
||||
Скопируйте `.env.example` в `.env` и заполните своими данными.
|
||||
```bash
|
||||
cp .env.example .env
|
||||
nano .env
|
||||
@@ -66,8 +70,29 @@
|
||||
| `ADMIN_IDS` | **Обязательно.** ID администраторов в Telegram через запятую. | `12345678,98765432` |
|
||||
| `DEFAULT_LANGUAGE` | Язык по умолчанию для новых пользователей. | `ru` |
|
||||
| `SUPPORT_LINK` | (Опционально) Ссылка на поддержку. | `https://t.me/your_support` |
|
||||
| `SUBSCRIPTION_MINI_APP_URL` | (Опционально) URL Mini App для показа подписки. | `https://t.me/your_bot/app` |
|
||||
| `PRIVACY_POLICY_URL` | (Опционально) Ссылка на политику конфиденциальности, показывается внизу Web App. | `https://example.com/privacy` |
|
||||
| `USER_AGREEMENT_URL` | (Опционально) Ссылка на пользовательское соглашение, показывается внизу Web App. | `https://example.com/agreement` |
|
||||
| `SUBSCRIPTION_MINI_APP_URL` | (Опционально) Публичный URL Mini App для показа подписки. Если задан, кнопка «Моя подписка» откроет Web App. | `https://app.domain.com/` |
|
||||
| `WEBAPP_ENABLED` | Включить Web App в том же контейнере, но на отдельном порту. | `true` |
|
||||
| `WEBAPP_SERVER_PORT` | Внутренний порт Web App. | `8081` |
|
||||
| `WEBAPP_TITLE` | Заголовок Web App. | `Моя подписка` |
|
||||
| `WEBAPP_PRIMARY_COLOR` | Основной цвет Web App. | `#00fe7a` |
|
||||
| `WEBAPP_LOGO_URL` | (Опционально) URL логотипа Web App. Если значение пустое, логотип не показывается вообще; если задано, он отображается в шапке и на экране логина. | `https://domain.com/logo.png` |
|
||||
| `SMTP_HOST` | SMTP-сервер для кодов входа по email. Для Brevo: `smtp-relay.brevo.com`. | `smtp-relay.brevo.com` |
|
||||
| `SMTP_PORT` | SMTP-порт. Для Brevo обычно используется 587 с STARTTLS. | `587` |
|
||||
| `SMTP_FALLBACK_PORTS` | Дополнительные SMTP-порты через запятую. Пробуются после `SMTP_PORT`; порт `465` автоматически используется через SSL. Для Brevo удобно оставить `2525,465`. | `2525,465` |
|
||||
| `SMTP_TIMEOUT_SECONDS` | Timeout для каждой SMTP-попытки подключения и отправки. | `30` |
|
||||
| `SMTP_USERNAME` / `SMTP_PASSWORD` | Логин и SMTP key/password из Brevo. Если не заданы вместе с `SMTP_FROM_EMAIL`, вход по email скрывается. | `user@smtp-brevo.com` |
|
||||
| `SMTP_FROM_EMAIL` / `SMTP_FROM_NAME` | Подтвержденный отправитель и отображаемое имя отправителя для писем с кодом. | `no-reply@example.com` |
|
||||
| `EMAIL_CODE_TTL_SECONDS` | Срок действия кода подтверждения email. | `600` |
|
||||
| `EMAIL_CODE_RESEND_SECONDS` | Минимальная пауза между отправками кода на один email. | `60` |
|
||||
| `EMAIL_CODE_MAX_ATTEMPTS` | Максимум попыток на один конкретный код. | `5` |
|
||||
| `BRUTE_FORCE_MAX_FAILURES` | Максимум неудачных попыток в окне защиты от перебора. | `5` |
|
||||
| `BRUTE_FORCE_WINDOW_SECONDS` | Длительность окна, в котором считаются неудачные попытки. | `900` |
|
||||
| `BRUTE_FORCE_LOCK_SECONDS` | Время временной блокировки после превышения лимита. | `1800` |
|
||||
| `MY_DEVICES_SECTION_ENABLED` | Включить раздел «Мои устройства» в меню подписки (`true`/`false`). | `false` |
|
||||
| `WEBAPP_SESSION_SECRET` | (Опционально) HMAC-секрет для подписи сессий Web App. Если пусто — генерируется при старте, но тогда сессии станут невалидными после перезапуска контейнера. Для прода задайте явно. | `см. раздел «Генерация секретов»` |
|
||||
| `WEBHOOK_SECRET_TOKEN` | (Опционально) Secret token для проверки подлинности вебхуков Telegram. Если пусто — генерируется при старте. Для прода задайте явно, чтобы значение пережило рестарт. | `см. раздел «Генерация секретов»` |
|
||||
| `REQUIRED_CHANNEL_ID` | (Опционально) ID канала, на который пользователь должен подписаться перед использованием. Оставьте пустым, если проверка не нужна. | `-1001234567890` |
|
||||
| `REQUIRED_CHANNEL_LINK` | (Опционально) Публичная ссылка или invite на канал для кнопки «Проверить подписку». | `https://t.me/your_channel` |
|
||||
</details>
|
||||
@@ -77,12 +102,19 @@
|
||||
|
||||
| Переменная | Описание |
|
||||
| --- | --- |
|
||||
| `WEBHOOK_BASE_URL`| **Обязательно.** Базовый URL для вебхуков, например `https://your.domain.com`. |
|
||||
| `WEB_SERVER_HOST` | Хост для веб-сервера. | `0.0.0.0` |
|
||||
| `WEB_SERVER_PORT` | Порт для веб-сервера. | `8080` |
|
||||
| `WEBHOOK_BASE_URL` | **Обязательно.** Базовый URL для вебхуков, например `https://your.domain.com`. |
|
||||
| `WEB_SERVER_HOST` | Хост для веб-сервера (по умолчанию `0.0.0.0`). |
|
||||
| `WEB_SERVER_PORT` | Порт для веб-сервера (по умолчанию `8080`). |
|
||||
| `WEBAPP_SERVER_HOST` | Хост отдельного веб-сервера Mini App (по умолчанию `0.0.0.0`). |
|
||||
| `WEBAPP_SERVER_PORT` | Порт отдельного веб-сервера Mini App (по умолчанию `8081`). |
|
||||
| `PAYMENT_METHODS_ORDER` | (Опционально) Порядок отображения кнопок оплаты через запятую. Поддерживаемые ключи: `severpay`, `freekassa`, `platega`, `yookassa`, `stars`, `cryptopay`. Первый будет сверху. |
|
||||
| `YOOKASSA_ENABLED` | Включить/выключить YooKassa (`true`/`false`). |
|
||||
| `YOOKASSA_SHOP_ID` | ID вашего магазина в YooKassa. |
|
||||
| `YOOKASSA_SECRET_KEY`| Секретный ключ магазина YooKassa. |
|
||||
| `YOOKASSA_SECRET_KEY` | Секретный ключ магазина YooKassa. |
|
||||
| `YOOKASSA_AUTOPAYMENTS_ENABLED` | Включить автопродление (сохранение карт, автосписания, управление способами оплаты). |
|
||||
| `YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING` | Требовать обязательную привязку карты при оплате с автосписанием. Установите `false`, чтобы пользователю показывался чекбокс «Сохранить карту». |
|
||||
| `NALOGO_INN` | ИНН для авторизации в nalog.ru (самозанятый). |
|
||||
| `NALOGO_PASSWORD` | Пароль для авторизации в nalog.ru (самозанятый). |
|
||||
| `CRYPTOPAY_ENABLED` | Включить/выключить CryptoPay (`true`/`false`). |
|
||||
| `CRYPTOPAY_TOKEN` | Токен из вашего CryptoPay App. |
|
||||
| `FREEKASSA_ENABLED` | Включить/выключить FreeKassa (`true`/`false`). |
|
||||
@@ -91,19 +123,28 @@
|
||||
| `FREEKASSA_SECOND_SECRET` | Секретное слово №2 — используется для проверки уведомлений от FreeKassa. |
|
||||
| `FREEKASSA_PAYMENT_URL` | (Опционально, legacy SCI) Базовый URL платёжной формы FreeKassa. По умолчанию `https://pay.freekassa.ru/`. |
|
||||
| `FREEKASSA_PAYMENT_IP` | Внешний IP вашего сервера, который будет передаваться в запрос оплаты. |
|
||||
| `FREEKASSA_PAYMENT_METHOD_ID` | ID метода оплаты через магазин FreeKassa. По умолчанию `44`. |
|
||||
| `STARS_ENABLED` | Включить/выключить Telegram Stars (`true`/`false`). |
|
||||
| `TRIBUTE_ENABLED`| Включить/выключить Tribute (`true`/`false`). |
|
||||
| `PLATEGA_ENABLED` | Включить/выключить Platega (`true`/`false`). |
|
||||
| `PLATEGA_MERCHANT_ID` | MerchantId из личного кабинета Platega. |
|
||||
| `PLATEGA_SECRET` | API секрет для запросов Platega. |
|
||||
| `PLATEGA_PAYMENT_METHOD` | ID способа оплаты (2 — SBP QR, 10 — РФ карты, 12 — международные карты, 13 — crypto). |
|
||||
| `PLATEGA_RETURN_URL` | (Опционально) URL редиректа после успешной оплаты. По умолчанию ссылка на бота. |
|
||||
| `PLATEGA_FAILED_URL` | (Опционально) URL редиректа при ошибке/отмене. По умолчанию как `PLATEGA_RETURN_URL`. |
|
||||
| `SEVERPAY_ENABLED` | Включить/выключить SeverPay (`true`/`false`). |
|
||||
| `SEVERPAY_MID` | MID магазина в SeverPay. |
|
||||
| `SEVERPAY_TOKEN` | Секрет/токен для подписи запросов SeverPay. |
|
||||
| `SEVERPAY_BASE_URL` | (Опционально) Базовый URL API SeverPay. По умолчанию `https://severpay.io/api/merchant`. |
|
||||
| `SEVERPAY_RETURN_URL` | (Опционально) URL редиректа после оплаты (по умолчанию ссылка на бота). |
|
||||
| `SEVERPAY_LIFETIME_MINUTES` | (Опционально) Время жизни платежной ссылки в минутах (30–4320). |
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Настройки подписок</b></summary>
|
||||
<summary><b>Настройки тарифов</b></summary>
|
||||
|
||||
Для каждого периода (1, 3, 6, 12 месяцев) можно настроить доступность и цены:
|
||||
- `1_MONTH_ENABLED`: `true` или `false`
|
||||
- `RUB_PRICE_1_MONTH`: Цена в рублях
|
||||
- `STARS_PRICE_1_MONTH`: Цена в Telegram Stars
|
||||
- `TRIBUTE_LINK_1_MONTH`: Ссылка для оплаты через Tribute
|
||||
Аналогичные переменные есть для `3_MONTHS`, `6_MONTHS`, `12_MONTHS`.
|
||||
Бот умеет продавать **подписку на срок** (1/3/6/12 мес.) или **пакеты трафика** (`TRAFFIC_PACKAGES=10:199,50:799`). Эти режимы взаимоисключающие — наличие непустой `TRAFFIC_PACKAGES` (или `STARS_TRAFFIC_PACKAGES`) автоматически переключает бот в режим продажи трафика.
|
||||
|
||||
Полное описание обоих режимов, переменных, что происходит при покупке, как ведут себя автопродление, реф-бонусы и триал — вынесено в [docs/tariffs.md](docs/tariffs.md).
|
||||
</details>
|
||||
|
||||
<details>
|
||||
@@ -115,11 +156,12 @@
|
||||
| `PANEL_API_KEY` | API ключ для доступа к панели. |
|
||||
| `PANEL_WEBHOOK_SECRET`| Секретный ключ для проверки вебхуков от панели. |
|
||||
| `USER_SQUAD_UUIDS` | ID отрядов для новых пользователей. |
|
||||
| `USER_EXTERNAL_SQUAD_UUID` | Опционально. UUID внешнего отряда (External Squad) из [документации Remnawave](https://docs.rw/api), куда автоматически добавляются новые пользователи. |
|
||||
| `USER_TRAFFIC_LIMIT_GB`| Лимит трафика в ГБ (0 - безлимит). |
|
||||
| `USER_HWID_DEVICE_LIMIT`| Лимит устройств (HWID) для новых пользователей (0 - безлимит). |
|
||||
|
||||
> Раздел "Мои устройства" становится доступен пользователям только при включении `MY_DEVICES_SECTION_ENABLED`. Значение лимита устройств при создании записей в панели берётся из `USER_HWID_DEVICE_LIMIT`.
|
||||
</gidetails>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Настройки пробного периода</b></summary>
|
||||
@@ -131,68 +173,374 @@
|
||||
| `TRIAL_TRAFFIC_LIMIT_GB`| Лимит трафика для пробного периода в ГБ. |
|
||||
</details>
|
||||
|
||||
3. **Запустите контейнеры:**
|
||||
3. **Сгенерируйте секреты (рекомендуется):**
|
||||
|
||||
Переменные `WEBAPP_SESSION_SECRET` и `WEBHOOK_SECRET_TOKEN` могут быть пустыми — тогда они автоматически сгенерируются при каждом старте контейнера. Однако в проде это означает, что после рестарта все сессии Web App станут невалидными, а Telegram придётся перерегистрировать webhook. Поэтому для боевого окружения задайте оба значения вручную.
|
||||
|
||||
Сгенерировать криптостойкие значения можно одной из команд:
|
||||
|
||||
```bash
|
||||
# вариант 1 — Python (есть в любом окружении с Python 3)
|
||||
python -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||
|
||||
# вариант 2 — openssl
|
||||
openssl rand -base64 32 | tr -d '=+/' | cut -c1-43
|
||||
|
||||
# вариант 3 — /dev/urandom (Linux/macOS)
|
||||
head -c 32 /dev/urandom | base64 | tr -d '=+/' | cut -c1-43
|
||||
```
|
||||
|
||||
Запустите команду дважды и подставьте полученные значения в `.env`:
|
||||
|
||||
```env
|
||||
WEBAPP_SESSION_SECRET=<первое_значение>
|
||||
WEBHOOK_SECRET_TOKEN=<второе_значение>
|
||||
```
|
||||
|
||||
> ⚠️ Не используйте одно и то же значение для обеих переменных и не коммитьте `.env` в git.
|
||||
|
||||
4. **Запустите контейнеры:**
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
Эта команда скачает образ и запустит сервис в фоновом режиме.
|
||||
Эта команда соберёт образ из `Dockerfile` (Python + сборка Web App на Node) и запустит сервис в фоновом режиме. Если нужен запуск из готового образа GHCR — используйте `docker-compose-remote-server.yml`.
|
||||
|
||||
4. **Настройка вебхуков (Обязательно):**
|
||||
Вебхуки являются **обязательным** компонентом для работы бота, так как они используются для получения уведомлений от платежных систем (YooKassa, FreeKassa, CryptoPay, Tribute) и панели Remnawave.
|
||||
5. **Настройка вебхуков (Обязательно):**
|
||||
Вебхуки являются **обязательным** компонентом для работы бота, так как они используются для получения уведомлений от платежных систем (YooKassa, FreeKassa, CryptoPay, Platega, SeverPay) и панели Remnawave.
|
||||
|
||||
Вам понадобится обратный прокси (например, Nginx) для обработки HTTPS-трафика и перенаправления запросов на контейнер с ботом.
|
||||
|
||||
**Пути для перенаправления:**
|
||||
- `https://<ваш_домен>/webhook/yookassa` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/yookassa`
|
||||
- `https://<ваш_домен>/webhook/freekassa` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/freekassa`
|
||||
- `https://<ваш_домен>/webhook/cryptopay` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/cryptopay`
|
||||
- `https://<ваш_домен>/webhook/tribute` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/tribute`
|
||||
- `https://<ваш_домен>/webhook/panel` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/panel`
|
||||
- `https://<ваш_домен>/webhook/yookassa` → `http://remnawave-minishop:<WEB_SERVER_PORT>/webhook/yookassa`
|
||||
- `https://<ваш_домен>/webhook/freekassa` → `http://remnawave-minishop:<WEB_SERVER_PORT>/webhook/freekassa`
|
||||
- `https://<ваш_домен>/webhook/platega` → `http://remnawave-minishop:<WEB_SERVER_PORT>/webhook/platega`
|
||||
- `https://<ваш_домен>/webhook/severpay` → `http://remnawave-minishop:<WEB_SERVER_PORT>/webhook/severpay`
|
||||
- `https://<ваш_домен>/webhook/cryptopay` → `http://remnawave-minishop:<WEB_SERVER_PORT>/webhook/cryptopay`
|
||||
- `https://<ваш_домен>/webhook/panel` → `http://remnawave-minishop:<WEB_SERVER_PORT>/webhook/panel`
|
||||
- **Для Telegram:** Бот автоматически установит вебхук, если в `.env` указан `WEBHOOK_BASE_URL`. Путь будет `https://<ваш_домен>/<BOT_TOKEN>`.
|
||||
|
||||
Где `remnawave-tg-shop` — это имя сервиса из `docker-compose.yml`, а `<WEB_SERVER_PORT>` — порт, указанный в `.env`.
|
||||
Где `remnawave-minishop` — это имя сервиса из `docker-compose.yml`, а `<WEB_SERVER_PORT>` — порт, указанный в `.env`.
|
||||
|
||||
5. **Просмотр логов:**
|
||||
**Отдельный порт Web App:**
|
||||
- `https://<домен_web_app>/` → `http://remnawave-minishop:<WEBAPP_SERVER_PORT>/`
|
||||
|
||||
Web App не должен проксироваться на `WEB_SERVER_PORT`: этот порт оставьте для Telegram, платежных и Remnawave webhooks.
|
||||
|
||||
6. **Просмотр логов:**
|
||||
```bash
|
||||
docker compose logs -f remnawave-tg-shop
|
||||
docker compose logs -f remnawave-minishop
|
||||
```
|
||||
|
||||
> 💡 Если включена проверка подписки на канал (`REQUIRED_CHANNEL_ID`), добавьте бота администратором в этот канал. Пользователь увидит кнопку «Проверить подписку», и, после первого успешного подтверждения, дальнейшие действия блокироваться не будут.
|
||||
|
||||
### Настройка Web App / Mini App
|
||||
|
||||
Web App запускается в том же контейнере, что и бот, но слушает отдельный порт `WEBAPP_SERVER_PORT` (по умолчанию `8081`). Внутри Web App пользователь авторизуется через Telegram Mini Apps `initData`; если страницу открыть вне Telegram, показывается официальный Telegram Login Widget. Также доступен вход по email через одноразовый код из письма, если настроен SMTP: после отправки письма код вводится в отдельном модальном окне подтверждения. После успешного входа страница обновляет данные сразу, без сообщений боту.
|
||||
|
||||
1. Укажите в `.env` публичный URL Web App и порт:
|
||||
|
||||
```env
|
||||
WEBAPP_ENABLED=True
|
||||
WEBAPP_SERVER_HOST=0.0.0.0
|
||||
WEBAPP_SERVER_PORT=8081
|
||||
SUBSCRIPTION_MINI_APP_URL=https://app.domain.com/
|
||||
WEBAPP_TITLE="Моя подписка"
|
||||
WEBAPP_PRIMARY_COLOR="#00fe7a"
|
||||
WEBAPP_LOGO_URL=
|
||||
SMTP_HOST=smtp-relay.brevo.com
|
||||
SMTP_PORT=587
|
||||
SMTP_FALLBACK_PORTS=2525,465
|
||||
SMTP_USERNAME=<brevo-smtp-login>
|
||||
SMTP_PASSWORD=<brevo-smtp-key>
|
||||
SMTP_FROM_EMAIL=no-reply@domain.com
|
||||
```
|
||||
|
||||
Если основной порт не отвечает, отправка письма автоматически пробует fallback-порты из `SMTP_FALLBACK_PORTS`. Для Brevo типичная схема: `587` с STARTTLS, затем `2525`, затем `465` через SSL.
|
||||
|
||||
2. Убедитесь, что `docker-compose.yml` публикует порт Web App:
|
||||
|
||||
```yaml
|
||||
ports:
|
||||
- 127.0.0.1:8080:8080
|
||||
- 127.0.0.1:${WEBAPP_SERVER_PORT:-8081}:${WEBAPP_SERVER_PORT:-8081}
|
||||
```
|
||||
|
||||
3. Проксируйте отдельный домен или location на порт Web App:
|
||||
|
||||
```nginx
|
||||
upstream remnawave-minishop-webapp {
|
||||
server remnawave-minishop:8081;
|
||||
}
|
||||
|
||||
server {
|
||||
server_name app.domain.com;
|
||||
listen 443 ssl;
|
||||
http2 on;
|
||||
|
||||
ssl_certificate "/etc/nginx/ssl/app_fullchain.pem";
|
||||
ssl_certificate_key "/etc/nginx/ssl/app_privkey.key";
|
||||
|
||||
location / {
|
||||
proxy_pass http://remnawave-minishop-webapp;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
4. В BotFather настройте домен Mini App для бота (`/setdomain`) и укажите домен из `SUBSCRIPTION_MINI_APP_URL`. Этот же домен используется и Telegram Login Widget.
|
||||
|
||||
5. Перезапустите контейнер:
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
После этого кнопка «Моя подписка» в меню бота откроет Web App. Web App показывает текущую ссылку подключения, остаток времени, трафик, оплату и блок аккаунта. Пользователь может привязать email к Telegram-аккаунту через код из письма или привязать Telegram к email-аккаунту через Login Widget. После привязки вход работает обоими способами.
|
||||
|
||||
Для email-регистраций пользователь в панели Remnawave создается с анонимным username вида `em_<referral_code>`; email добавляется в описание пользователя панели и, если API панели принимает поле email, передается отдельным полем. Для Telegram-регистраций сохраняется существующая схема `tg_<telegram_id>`.
|
||||
|
||||
## Подробная инструкция для развертывания на сервере с панелью Remnawave
|
||||
|
||||
### 1. Клонирование репозитория
|
||||
|
||||
```bash
|
||||
git clone https://github.com/3252a8/remnawave-minishop && cd remnawave-minishop
|
||||
```
|
||||
|
||||
### 2. Настройка переменных окружения
|
||||
|
||||
```bash
|
||||
cp .env.example .env && nano .env
|
||||
```
|
||||
|
||||
**Обязательные поля для заполнения:**
|
||||
- `BOT_TOKEN` - токен телеграмм бота, например, `234567890:ABC-DEF1234ghIkl-zyx57W2v1u123ew11`
|
||||
- `ADMIN_IDS` - TG ID администраторов, например, `12345678,98765432` и т.д. (через запятую без пробелов)
|
||||
- `WEBHOOK_BASE_URL` - Обязательно. Базовый URL для вебхуков, например `https://webhook.domain.com`
|
||||
- `PANEL_API_URL` - URL API вашей панели Remnawave (например, `http://remnawave:3000/api` или `https://panel.domain.com/api`)
|
||||
- `PANEL_API_KEY` - API ключ для доступа к панели (генерируется из UI-интерфейса панели)
|
||||
- `PANEL_WEBHOOK_SECRET` - Секретный ключ для проверки вебхуков от панели (берётся из `.env` самой панели)
|
||||
- `USER_SQUAD_UUIDS` - ID отрядов для новых пользователей
|
||||
|
||||
### 3. Настройка Reverse Proxy (Nginx)
|
||||
|
||||
Перейдите в директорию конфигурации Nginx панели Remnawave:
|
||||
|
||||
```bash
|
||||
cd /opt/remnawave/nginx && nano nginx.conf
|
||||
```
|
||||
|
||||
Добавьте в `nginx.conf` следующую конфигурацию:
|
||||
|
||||
```nginx
|
||||
upstream remnawave-minishop {
|
||||
server remnawave-minishop:8080;
|
||||
}
|
||||
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
"" close;
|
||||
}
|
||||
|
||||
server {
|
||||
server_name webhook.domain.com; # Домен для отправки Webhook'ов
|
||||
listen 443 ssl;
|
||||
http2 on;
|
||||
|
||||
ssl_certificate "/etc/nginx/ssl/webhook_fullchain.pem";
|
||||
ssl_certificate_key "/etc/nginx/ssl/webhook_privkey.key";
|
||||
ssl_trusted_certificate "/etc/nginx/ssl/webhook_fullchain.pem";
|
||||
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
proxy_intercept_errors on;
|
||||
error_page 400 404 500 502 @redirect;
|
||||
|
||||
location / {
|
||||
proxy_pass http://remnawave-minishop$request_uri;
|
||||
}
|
||||
|
||||
location @redirect {
|
||||
return 404;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Выпуск SSL-сертификата для домена webhook
|
||||
|
||||
Убедитесь, что установлены необходимые компоненты, а также откройте 80 порт:
|
||||
|
||||
```bash
|
||||
sudo apt-get install cron socat
|
||||
curl https://get.acme.sh | sh -s email=EMAIL && source ~/.bashrc
|
||||
ufw allow 80/tcp && ufw reload
|
||||
```
|
||||
|
||||
Выпустите сертификат:
|
||||
|
||||
```bash
|
||||
acme.sh --set-default-ca --server letsencrypt
|
||||
acme.sh --issue --standalone -d 'webhook.domain.com' \
|
||||
--key-file /opt/remnawave/nginx/webhook_privkey.key \
|
||||
--fullchain-file /opt/remnawave/nginx/webhook_fullchain.pem
|
||||
```
|
||||
|
||||
### 5. Добавление сертификатов в Docker Compose Nginx
|
||||
|
||||
Отредактируйте `docker-compose.yml` панели Nginx:
|
||||
|
||||
```bash
|
||||
cd /opt/remnawave/nginx && nano docker-compose.yml
|
||||
```
|
||||
|
||||
Добавьте две строки в секцию `volumes`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
remnawave-nginx:
|
||||
image: nginx:1.26
|
||||
container_name: remnawave-nginx
|
||||
hostname: remnawave-nginx
|
||||
volumes:
|
||||
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
- ./fullchain.pem:/etc/nginx/ssl/fullchain.pem:ro
|
||||
- ./privkey.key:/etc/nginx/ssl/privkey.key:ro
|
||||
- ./subdomain_fullchain.pem:/etc/nginx/ssl/subdomain_fullchain.pem:ro
|
||||
- ./subdomain_privkey.key:/etc/nginx/ssl/subdomain_privkey.key:ro
|
||||
- ./webhook_fullchain.pem:/etc/nginx/ssl/webhook_fullchain.pem:ro # Добавьте эту строку
|
||||
- ./webhook_privkey.key:/etc/nginx/ssl/webhook_privkey.key:ro # Добавьте эту строку
|
||||
restart: always
|
||||
ports:
|
||||
- '0.0.0.0:443:443'
|
||||
networks:
|
||||
- remnawave-network
|
||||
|
||||
networks:
|
||||
remnawave-network:
|
||||
name: remnawave-network
|
||||
driver: bridge
|
||||
external: true
|
||||
```
|
||||
|
||||
### 6. Запуск бота и перезапуск Nginx
|
||||
|
||||
Запустите бота:
|
||||
|
||||
```bash
|
||||
cd /root/remnawave-minishop && docker compose up -d && docker compose logs -f -t
|
||||
```
|
||||
|
||||
Перезапустите Nginx:
|
||||
|
||||
```bash
|
||||
cd /opt/remnawave/nginx && docker compose down && docker compose up -d && docker compose logs -f -t
|
||||
```
|
||||
|
||||
## 🐳 Docker
|
||||
|
||||
Файлы `Dockerfile` и `docker-compose.yml` уже настроены для сборки и запуска проекта. `docker-compose.yml` использует готовый образ с GitHub Container Registry, но вы можете раскомментировать `build: .` для локальной сборки.
|
||||
Файлы `Dockerfile` и `docker-compose.yml` уже настроены для локальной сборки и запуска проекта.
|
||||
|
||||
Если нужен запуск из готового образа, используйте `docker-compose-remote-server.yml` как шаблон и укажите свой `image:` вместо локальной сборки. По умолчанию он тянет `ghcr.io/3252a8/remnawave-minishop:latest`, а для закрепления версии можно задать `IMAGE_TAG=3.1.0`.
|
||||
|
||||
В GHCR доступны теги `3.1.0` и `latest`.
|
||||
|
||||
Чтобы использовать сохранённый образ, можно запустить:
|
||||
```bash
|
||||
IMAGE_TAG=3.1.0 docker compose -f docker-compose-remote-server.yml up -d
|
||||
```
|
||||
|
||||
### Вариант с Caddy
|
||||
|
||||
Если нужен reverse proxy на Caddy, используйте `docker-compose-caddy.yml` вместе с `Caddyfile`. Это удобный вариант, когда хочется, чтобы Caddy сам выпускал TLS-сертификаты и проксировал и webhook'и, и Mini App без ручной настройки Nginx.
|
||||
|
||||
В этой схеме:
|
||||
- Caddy публикует наружу `80` и `443`.
|
||||
- Бот остается доступным только внутри docker-сети.
|
||||
- `WEBHOOK_BASE_URL` должен указывать на домен вебхуков, а `SUBSCRIPTION_MINI_APP_URL` - на домен Mini App.
|
||||
|
||||
Пример `Caddyfile`:
|
||||
|
||||
```caddyfile
|
||||
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}
|
||||
}
|
||||
```
|
||||
|
||||
Что нужно поменять под себя:
|
||||
- заменить `webhook.domain.com` и `app.domain.com` на свои домены;
|
||||
- убедиться, что в `.env` заданы `WEBHOOK_BASE_URL=https://webhook.domain.com` и `SUBSCRIPTION_MINI_APP_URL=https://app.domain.com/`;
|
||||
- при необходимости скорректировать `WEB_SERVER_PORT` и `WEBAPP_SERVER_PORT`, если они отличаются от стандартных `8080` и `8081`.
|
||||
- в BotFather укажите домен Mini App через `/setdomain`, чтобы он совпадал с `SUBSCRIPTION_MINI_APP_URL`.
|
||||
|
||||
Запуск:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose-caddy.yml up -d --build
|
||||
```
|
||||
|
||||
После этого Caddy сам выпустит сертификаты и будет проксировать webhook'и на порт `8080`, а Mini App - на `8081`.
|
||||
|
||||
## 🔄 Миграция с `remnawave-tg-shop` на `remnawave-minishop`
|
||||
|
||||
Короткая инструкция, автоматический запуск helper'а из `raw` и ручной вариант переноса вынесены в отдельный документ: [docs/migration-to-minishop.md](docs/migration-to-minishop.md).
|
||||
|
||||
## 📁 Структура проекта
|
||||
|
||||
```
|
||||
.
|
||||
├── bot/
|
||||
│ ├── filters/ # Пользовательские фильтры Aiogram
|
||||
│ ├── handlers/ # Обработчики сообщений и колбэков
|
||||
│ ├── keyboards/ # Клавиатуры
|
||||
│ ├── middlewares/ # Промежуточные слои (i18n, проверка бана)
|
||||
│ ├── services/ # Бизнес-логика (платежи, API панели)
|
||||
│ ├── states/ # Состояния FSM
|
||||
│ └── main_bot.py # Основная логика бота
|
||||
│ ├── app/ # Сборка приложения (фабрики, контроллеры, Web App)
|
||||
│ │ ├── controllers/ # Запуск Aiogram dispatcher
|
||||
│ │ ├── factories/ # Фабрики сервисов (платежи, панель и т.д.)
|
||||
│ │ └── web/ # Web App / Mini App (сервер, аутентификация, шаблоны)
|
||||
│ ├── filters/ # Пользовательские фильтры Aiogram
|
||||
│ ├── handlers/ # Обработчики сообщений и колбэков (admin/, user/)
|
||||
│ ├── keyboards/ # Клавиатуры
|
||||
│ ├── middlewares/ # Промежуточные слои (i18n, проверка бана и т.д.)
|
||||
│ ├── services/ # Бизнес-логика (платёжные шлюзы, API панели, email и т.д.)
|
||||
│ ├── states/ # Состояния FSM (admin/user)
|
||||
│ ├── utils/ # Вспомогательные утилиты
|
||||
│ ├── routers.py # Регистрация всех роутеров Aiogram
|
||||
│ └── main_bot.py # Основная логика бота
|
||||
├── config/
|
||||
│ └── settings.py # Настройки Pydantic
|
||||
│ └── settings.py # Настройки Pydantic
|
||||
├── db/
|
||||
│ ├── dal/ # Слой доступа к данным (DAL)
|
||||
│ ├── database_setup.py # Настройка БД
|
||||
│ └── models.py # Модели SQLAlchemy
|
||||
├── locales/ # Файлы локализации (ru, en)
|
||||
├── .env.example # Пример файла с переменными окружения
|
||||
├── Dockerfile # Инструкции для сборки Docker-образа
|
||||
├── docker-compose.yml # Файл для оркестрации контейнеров
|
||||
├── requirements.txt # Зависимости Python
|
||||
└── main.py # Точка входа в приложение
|
||||
│ ├── dal/ # Слой доступа к данным (DAL)
|
||||
│ ├── database_setup.py # Настройка БД и подключения
|
||||
│ ├── migrator.py # Миграции схемы при старте
|
||||
│ └── models.py # Модели SQLAlchemy
|
||||
├── locales/ # Файлы локализации (ru.json, en.json)
|
||||
├── scripts/ # Сборка JS Web App, обновление копий Telegram JS и миграционный helper
|
||||
├── tests/ # Pytest-тесты
|
||||
├── .env.example # Пример файла с переменными окружения
|
||||
├── Caddyfile # Пример конфигурации Caddy
|
||||
├── Dockerfile # Multi-stage сборка (Python + Node для Web App)
|
||||
├── docker-compose.yml # Локальная сборка и запуск
|
||||
├── docker-compose-caddy.yml # Запуск с Caddy в качестве reverse proxy
|
||||
├── docker-compose-remote-server.yml # Запуск из готового образа GHCR
|
||||
├── package.json # Frontend-зависимости (Tailwind, esbuild) и сборка Web App
|
||||
├── requirements.txt # Зависимости Python
|
||||
└── main.py # Точка входа в приложение
|
||||
```
|
||||
|
||||
## 🔮 Планы на будущее
|
||||
|
||||
- Расширенные типы промокодов (например, скидки в процентах).
|
||||
|
||||
## ❤️ Поддержка
|
||||
- Карты РФ и зарубежные: [Tribute](https://t.me/tribute/app?startapp=dqdg)
|
||||
- Crypto: `USDT TRC-20 TT3SqBbfU4vYm6SUwUVNZsy278m2xbM4GE`
|
||||
- Crypto: `USDT/Other ERC-20 0xeD506D44aae634fEc0E01C8835744fBedb7B2a44 (Ethereum/Polygon/Gnosis)`
|
||||
|
||||
@@ -9,10 +9,12 @@ from bot.services.subscription_service import SubscriptionService
|
||||
from bot.services.referral_service import ReferralService
|
||||
from bot.services.promo_code_service import PromoCodeService
|
||||
from bot.services.stars_service import StarsService
|
||||
from bot.services.tribute_service import TributeService
|
||||
from bot.services.crypto_pay_service import CryptoPayService
|
||||
from bot.services.panel_webhook_service import PanelWebhookService
|
||||
from bot.services.freekassa_service import FreeKassaService
|
||||
from bot.services.platega_service import PlategaService
|
||||
from bot.services.severpay_service import SeverPayService
|
||||
from bot.services.lknpd_service import LknpdService
|
||||
|
||||
|
||||
def build_core_services(
|
||||
@@ -45,14 +47,23 @@ def build_core_services(
|
||||
subscription_service=subscription_service,
|
||||
referral_service=referral_service,
|
||||
)
|
||||
tribute_service = TributeService(
|
||||
bot,
|
||||
settings,
|
||||
i18n,
|
||||
async_session_factory,
|
||||
panel_service,
|
||||
subscription_service,
|
||||
referral_service,
|
||||
platega_service = PlategaService(
|
||||
bot=bot,
|
||||
settings=settings,
|
||||
i18n=i18n,
|
||||
async_session_factory=async_session_factory,
|
||||
subscription_service=subscription_service,
|
||||
referral_service=referral_service,
|
||||
default_return_url=bot_username_for_default_return,
|
||||
)
|
||||
severpay_service = SeverPayService(
|
||||
bot=bot,
|
||||
settings=settings,
|
||||
i18n=i18n,
|
||||
async_session_factory=async_session_factory,
|
||||
subscription_service=subscription_service,
|
||||
referral_service=referral_service,
|
||||
default_return_url=bot_username_for_default_return,
|
||||
)
|
||||
panel_webhook_service = PanelWebhookService(bot, settings, i18n, async_session_factory, panel_service)
|
||||
yookassa_service = YooKassaService(
|
||||
@@ -62,6 +73,11 @@ def build_core_services(
|
||||
bot_username_for_default_return=bot_username_for_default_return,
|
||||
settings_obj=settings,
|
||||
)
|
||||
lknpd_service = LknpdService(
|
||||
settings.LKNPD_INN,
|
||||
settings.LKNPD_PASSWORD,
|
||||
api_url=settings.LKNPD_API_URL,
|
||||
)
|
||||
|
||||
# Wire services that depend on each other
|
||||
try:
|
||||
@@ -80,8 +96,9 @@ def build_core_services(
|
||||
"stars_service": stars_service,
|
||||
"cryptopay_service": cryptopay_service,
|
||||
"freekassa_service": freekassa_service,
|
||||
"tribute_service": tribute_service,
|
||||
"panel_webhook_service": panel_webhook_service,
|
||||
"yookassa_service": yookassa_service,
|
||||
"lknpd_service": lknpd_service,
|
||||
"platega_service": platega_service,
|
||||
"severpay_service": severpay_service,
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,217 @@
|
||||
<script>
|
||||
import {
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
Copy,
|
||||
Crown,
|
||||
Database,
|
||||
Download,
|
||||
Gift,
|
||||
Globe2,
|
||||
LockKeyhole,
|
||||
Mail,
|
||||
RefreshCw,
|
||||
Repeat2,
|
||||
Send,
|
||||
Ticket,
|
||||
UserRound,
|
||||
Zap,
|
||||
} from "lucide-svelte";
|
||||
|
||||
import Button from "./lib/components/ui/button.svelte";
|
||||
import Card from "./lib/components/ui/card.svelte";
|
||||
import BackTitle from "./preview/BackTitle.svelte";
|
||||
import PhoneFrame from "./preview/PhoneFrame.svelte";
|
||||
import PreviewMethods from "./preview/PreviewMethods.svelte";
|
||||
import PreviewNav from "./preview/PreviewNav.svelte";
|
||||
|
||||
export let config = {};
|
||||
export let mockData = {};
|
||||
|
||||
const title = config.title || "/minishop";
|
||||
const logoEmoji = config.logoEmoji || "🫥";
|
||||
const plans = mockData.plans || [];
|
||||
const sub = mockData.subscription || {};
|
||||
const methods = mockData.payment_methods || [];
|
||||
const user = mockData.user || {};
|
||||
const tariffs = [
|
||||
["subscription", "Подписка", "Безлимитный трафик", "Идеально для постоянного использования", Zap],
|
||||
["traffic", "Трафик", "Пакеты гигабайт", "Платите только за нужный объем", Database],
|
||||
["premium", "Премиум", "Максимальная скорость", "Приоритетные серверы и поддержка", Crown],
|
||||
];
|
||||
const traffic = [
|
||||
[20, 290],
|
||||
[50, 590],
|
||||
[100, 990],
|
||||
[300, 2190],
|
||||
];
|
||||
const settingsRows = [
|
||||
[Globe2, "Язык интерфейса", "Русский"],
|
||||
[Send, "Привязка Telegram", user.telegram_linked ? `@${user.username || "username"}` : "Не привязан"],
|
||||
[Mail, "Привязка почты", user.email || "Не привязана"],
|
||||
[UserRound, "Выйти", "Завершить сессию"],
|
||||
];
|
||||
const previewTelegramName = user.first_name || (user.username ? `@${user.username}` : "Telegram не привязан");
|
||||
const previewEmail = user.email || "Почта не привязана";
|
||||
const previewTelegramId = user.telegram_id ? `TG ID ${user.telegram_id}` : "TG ID не привязан";
|
||||
const previewAvatar = user.telegram_photo_url || "";
|
||||
|
||||
function money(value) {
|
||||
return `${value} ₽`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="preview-board" style={`--accent: ${config.primaryColor || "#00fe7a"};`}>
|
||||
<PhoneFrame number="1" label="Главный экран">
|
||||
<main class="home-layout">
|
||||
<div class="login-brand home-brand">
|
||||
<div class="brand-mark brand-mark-xl"><span>{logoEmoji}</span></div>
|
||||
<h1>{title}</h1>
|
||||
</div>
|
||||
<div class="home-bottom">
|
||||
<Card class="status-card">
|
||||
<div class="sub-status">
|
||||
<CheckCircle2 size={23} />
|
||||
<div><h2>Подписка активна</h2><p>до {sub.end_date_text}</p></div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div class="traffic-top"><span>Использовано трафика</span><strong>{sub.traffic_used} из {sub.traffic_limit}</strong></div>
|
||||
<div class="progress"><span style="width: 18%"></span></div>
|
||||
<div class="traffic-percent">18%</div>
|
||||
</Card>
|
||||
<div class="action-stack">
|
||||
<Button class="wide"><Download size={17} />Установить и настроить</Button>
|
||||
<Button variant="secondary" class="wide"><RefreshCw size={17} />Продлить</Button>
|
||||
<Button variant="secondary" class="wide"><Repeat2 size={17} />Сменить тариф</Button>
|
||||
</div>
|
||||
</div>
|
||||
<PreviewNav active="home" />
|
||||
</main>
|
||||
</PhoneFrame>
|
||||
|
||||
<PhoneFrame number="2" label="Выбор тарифа">
|
||||
<div class="preview-header"><div class="brand-row"><div class="brand-mark"><span>{logoEmoji}</span></div><strong>{title}</strong></div></div>
|
||||
<div class="tariff-list">
|
||||
{#each tariffs as tariff, index}
|
||||
<div class:active={index === 0} class="select-card">
|
||||
<span class="select-icon"><svelte:component this={tariff[4]} size={24} /></span>
|
||||
<span><strong>{tariff[1]}</strong><small>{tariff[2]}</small><em>{tariff[3]}</em></span>
|
||||
{#if index === 0}<CheckCircle2 size={21} />{:else}<Circle size={21} />{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<Button class="wide bottom-action">Далее <ArrowRight size={17} /></Button>
|
||||
</PhoneFrame>
|
||||
|
||||
<PhoneFrame number="3" label="Оплата тарифа — подписка" wide>
|
||||
<BackTitle title="Подписка" subtitle="Выберите срок подписки" />
|
||||
<div class="period-grid">
|
||||
{#each plans as plan, index}
|
||||
<div class:active={index === 1} class="period-card">
|
||||
<strong>{plan.title}</strong><span>{money(plan.price)}</span><small>{money(Math.round(plan.price / plan.months))}/мес</small>
|
||||
{#if index === 1}<CheckCircle2 size={18} />{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<Card class="total-card"><span>Итого<br /><small>К оплате</small></span><strong>790 ₽</strong></Card>
|
||||
<PreviewMethods {methods} />
|
||||
<Button class="wide bottom-action">Оплатить 790 ₽ <LockKeyhole size={16} /></Button>
|
||||
</PhoneFrame>
|
||||
|
||||
<PhoneFrame number="4" label="Оплата тарифа — трафик" wide>
|
||||
<BackTitle title="Трафик" subtitle="Выберите пакет трафика" />
|
||||
<div class="period-grid">
|
||||
{#each traffic as pack, index}
|
||||
<div class:active={index === 2} class="period-card">
|
||||
<strong>{pack[0]} ГБ</strong><span>{money(pack[1])}</span><small>{money(Math.round(pack[1] / pack[0]))}/ГБ</small>
|
||||
{#if index === 2}<CheckCircle2 size={18} />{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<Card class="total-card"><span>Итого<br /><small>К оплате</small></span><strong>990 ₽</strong></Card>
|
||||
<PreviewMethods {methods} />
|
||||
<Button class="wide bottom-action">Оплатить 990 ₽ <LockKeyhole size={16} /></Button>
|
||||
</PhoneFrame>
|
||||
|
||||
<PhoneFrame number="5" label="Смена тарифа">
|
||||
<BackTitle title="Смена тарифа" subtitle="Остаток 12 дней будет пересчитан" />
|
||||
<div class="tariff-list compact">
|
||||
<div class="select-card"><span><strong>Подписка</strong><small>Безлимитный трафик</small></span><em>Доплата 190 ₽</em><Circle size={20} /></div>
|
||||
<div class="select-card active"><span><strong>Трафик</strong><small>Пакеты гигабайт</small></span><em>Доплата не требуется</em><CheckCircle2 size={20} /></div>
|
||||
<div class="select-card"><span><strong>Премиум</strong><small>Максимальная скорость</small></span><em>Доплата 390 ₽</em><Circle size={20} /></div>
|
||||
</div>
|
||||
<Button class="wide bottom-action">Далее <ArrowRight size={17} /></Button>
|
||||
<div class="preview-modal">
|
||||
<Repeat2 size={30} />
|
||||
<strong>Сменить тариф без доплаты?</strong>
|
||||
<p>Остаток 12 дней будет пересчитан по новому тарифу.</p>
|
||||
<Button>Да, сменить</Button>
|
||||
<Button variant="secondary">Отмена</Button>
|
||||
</div>
|
||||
</PhoneFrame>
|
||||
|
||||
<PhoneFrame number="6" label="Пригласить друга">
|
||||
<div class="preview-header"><div class="brand-row"><div class="brand-mark"><span>{logoEmoji}</span></div><strong>{title}</strong></div></div>
|
||||
<Card>
|
||||
<div class="card-label">Ваша реферальная ссылка</div>
|
||||
<div class="copy-row"><code>https://minishop.app/ref/ABCD1234</code><Button>Копировать <Copy size={16} /></Button></div>
|
||||
</Card>
|
||||
<Card class="bonus-card">
|
||||
<Gift size={42} /><div><span>Ваш бонус</span><strong>+7 дней за каждого друга</strong><p>Друг получит +3 дня к подписке.</p></div>
|
||||
</Card>
|
||||
<Button variant="outline" class="wide"><Ticket size={18} />Активировать промокод</Button>
|
||||
</PhoneFrame>
|
||||
|
||||
<PhoneFrame number="7" label="Настройки">
|
||||
<div class="preview-header"><div class="brand-row"><div class="brand-mark"><span>{logoEmoji}</span></div><strong>{title}</strong></div></div>
|
||||
<Card class="settings-profile">
|
||||
<div class="settings-avatar">
|
||||
{#if previewAvatar}
|
||||
<img src={previewAvatar} alt="Аватар пользователя" />
|
||||
{:else}
|
||||
<UserRound size={27} />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="settings-profile-meta">
|
||||
<strong>{previewTelegramName}</strong>
|
||||
<small>{previewEmail}</small>
|
||||
<small>{previewTelegramId}</small>
|
||||
</div>
|
||||
</Card> <div class="settings-list">
|
||||
{#each settingsRows as row}
|
||||
<div class="settings-row">
|
||||
<svelte:component this={row[0]} size={20} />
|
||||
<span><strong>{row[1]}</strong><small>{row[2]}</small></span>
|
||||
<ArrowRight size={16} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</PhoneFrame>
|
||||
|
||||
<PhoneFrame number="8" label="Логин" wide>
|
||||
<div class="login-brand small">
|
||||
<div class="brand-mark brand-mark-xl"><span>{logoEmoji}</span></div>
|
||||
<h1>{title}</h1><p>Войдите в свой аккаунт</p>
|
||||
</div>
|
||||
<Card class="auth-card">
|
||||
<div class="field-label">Вход по email</div>
|
||||
<div class="auth-email-stack"><div class="input muted">Email</div><Button class="wide"><Mail size={17} />Войти по почте</Button></div>
|
||||
<div class="or-line"><span></span>или<span></span></div>
|
||||
<Button variant="telegram" class="wide telegram-login-button">
|
||||
<span class="telegram-login-text"><Send size={17} />Войти через телеграм</span>
|
||||
</Button>
|
||||
</Card>
|
||||
</PhoneFrame>
|
||||
|
||||
<PhoneFrame number="9" label="Подтверждение по коду" wide>
|
||||
<BackTitle title="Подтверждение по email" subtitle="Мы отправили код на user@example.com" />
|
||||
<div class="otp-slots static">{#each [1, 2, 3, 4, 5, 6] as digit}<span>{digit}</span>{/each}</div>
|
||||
<Button class="wide bottom-action">Подтвердить</Button>
|
||||
<button class="link-button"><RefreshCw size={15} />Отправить код повторно (00:45)</button>
|
||||
</PhoneFrame>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<script>
|
||||
import { cn } from "../../utils.js";
|
||||
|
||||
export let type = "button";
|
||||
export let variant = "default";
|
||||
export let size = "default";
|
||||
export let disabled = false;
|
||||
export let href = "";
|
||||
export let onclick = undefined;
|
||||
let className = "";
|
||||
export { className as class };
|
||||
|
||||
const variants = {
|
||||
default: "btn btn-primary",
|
||||
secondary: "btn btn-secondary",
|
||||
outline: "btn btn-outline",
|
||||
ghost: "btn btn-ghost",
|
||||
telegram: "btn btn-telegram",
|
||||
icon: "btn btn-icon",
|
||||
};
|
||||
|
||||
const sizes = {
|
||||
default: "",
|
||||
sm: "btn-sm",
|
||||
lg: "btn-lg",
|
||||
icon: "btn-square",
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if href}
|
||||
<a class={cn(variants[variant], sizes[size], className)} {href} on:click={onclick} {...$$restProps}>
|
||||
<slot />
|
||||
</a>
|
||||
{:else}
|
||||
<button class={cn(variants[variant], sizes[size], className)} {type} {disabled} on:click={onclick} {...$$restProps}>
|
||||
<slot />
|
||||
</button>
|
||||
{/if}
|
||||
@@ -0,0 +1,12 @@
|
||||
<script>
|
||||
import { cn } from "../../utils.js";
|
||||
|
||||
export let active = false;
|
||||
export let compact = false;
|
||||
let className = "";
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<section class={cn("card", active && "card-active", compact && "card-compact", className)}>
|
||||
<slot />
|
||||
</section>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script>
|
||||
import { X } from "lucide-svelte";
|
||||
import { cn } from "../../utils.js";
|
||||
import Button from "./button.svelte";
|
||||
|
||||
export let open = false;
|
||||
export let title = "";
|
||||
export let description = "";
|
||||
export let closeLabel = "Close";
|
||||
export let onclose = () => {};
|
||||
let className = "";
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<div class="dialog" role="dialog" aria-modal="true" aria-label={title}>
|
||||
<button class="dialog-backdrop" type="button" aria-label={closeLabel} on:click={onclose}></button>
|
||||
<section class={cn("dialog-card", className)}>
|
||||
<div class="dialog-head">
|
||||
<div>
|
||||
{#if title}<h2>{title}</h2>{/if}
|
||||
{#if description}<p>{description}</p>{/if}
|
||||
</div>
|
||||
<Button variant="icon" size="icon" onclick={onclose} aria-label={closeLabel}>
|
||||
<X size={18} />
|
||||
</Button>
|
||||
</div>
|
||||
<slot />
|
||||
</section>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,29 @@
|
||||
<script>
|
||||
import { cn } from "../../utils.js";
|
||||
|
||||
export let value = "";
|
||||
export let type = "text";
|
||||
export let placeholder = "";
|
||||
export let inputmode = undefined;
|
||||
export let maxlength = undefined;
|
||||
export let autocomplete = undefined;
|
||||
export let disabled = false;
|
||||
let className = "";
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<input
|
||||
bind:value
|
||||
class={cn("input", className)}
|
||||
on:keydown
|
||||
on:input
|
||||
on:focus
|
||||
on:blur
|
||||
{type}
|
||||
{placeholder}
|
||||
{inputmode}
|
||||
{maxlength}
|
||||
{autocomplete}
|
||||
{disabled}
|
||||
{...$$restProps}
|
||||
/>
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { mount } from "svelte";
|
||||
|
||||
import App from "./App.svelte";
|
||||
import "./styles.css";
|
||||
|
||||
const target = document.getElementById("app");
|
||||
|
||||
if (target) {
|
||||
mount(App, { target });
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<script>
|
||||
import { ArrowLeft } from "lucide-svelte";
|
||||
|
||||
export let title = "";
|
||||
export let subtitle = "";
|
||||
</script>
|
||||
|
||||
<header class="screen-head">
|
||||
<button class="btn btn-icon btn-square" type="button" aria-label="Назад">
|
||||
<ArrowLeft size={18} />
|
||||
</button>
|
||||
<div class="center-copy">
|
||||
<h1>{title}</h1>
|
||||
<p>{subtitle}</p>
|
||||
</div>
|
||||
<span></span>
|
||||
</header>
|
||||
@@ -0,0 +1,12 @@
|
||||
<script>
|
||||
export let number = "";
|
||||
export let label = "";
|
||||
export let wide = false;
|
||||
</script>
|
||||
|
||||
<section class:wide class="preview-phone-wrap">
|
||||
<h2><span>{number}.</span> {label}</h2>
|
||||
<div class="preview-phone">
|
||||
<slot />
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script>
|
||||
import { CreditCard, Send, WalletCards } from "lucide-svelte";
|
||||
|
||||
export let methods = [];
|
||||
|
||||
const icons = [CreditCard, Send, WalletCards, WalletCards];
|
||||
|
||||
function note(index) {
|
||||
if (index === 0) return "Visa, Mastercard";
|
||||
if (index === 1) return "Быстро и удобно";
|
||||
if (index === 2) return "USDT, BTC, ETH";
|
||||
return "ЮMoney, СБП и др.";
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="method-grid">
|
||||
{#each methods as method, index}
|
||||
<div class:active={index === 1} class="method-card">
|
||||
<svelte:component this={icons[index] || WalletCards} size={18} />
|
||||
<span>
|
||||
<strong>{method.name}</strong>
|
||||
<small>{note(index)}</small>
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -0,0 +1,11 @@
|
||||
<script>
|
||||
import { Gift, Home, Settings } from "lucide-svelte";
|
||||
|
||||
export let active = "home";
|
||||
</script>
|
||||
|
||||
<nav class="bottom-nav static">
|
||||
<button class:active={active === "home"} type="button"><Home size={20} /><span>Главная</span></button>
|
||||
<button class:active={active === "invite"} type="button"><Gift size={20} /><span>Пригласить</span></button>
|
||||
<button class:active={active === "settings"} type="button"><Settings size={20} /><span>Настройки</span></button>
|
||||
</nav>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,36 @@
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { svelte } from "@sveltejs/vite-plugin-svelte";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const templateDir = path.resolve(__dirname, "../templates");
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [svelte()],
|
||||
build: {
|
||||
outDir: templateDir,
|
||||
emptyOutDir: false,
|
||||
minify: false,
|
||||
sourcemap: false,
|
||||
cssCodeSplit: false,
|
||||
lib: {
|
||||
entry: path.resolve(__dirname, "src/main.js"),
|
||||
name: "SubscriptionWebApp",
|
||||
formats: ["iife"],
|
||||
fileName: () => "subscription_webapp.js",
|
||||
cssFileName: "subscription_webapp",
|
||||
},
|
||||
rollupOptions: {
|
||||
output: {
|
||||
assetFileNames: (assetInfo) => {
|
||||
if (assetInfo.name && assetInfo.name.endsWith(".css")) {
|
||||
return "subscription_webapp.css";
|
||||
}
|
||||
return "subscription_webapp.[name][extname]";
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
<!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="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
|
||||
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
||||
<link rel="stylesheet" href="/subscription_webapp.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<main id="app"></main>
|
||||
|
||||
<!-- WEBAPP_I18N_SCRIPT -->
|
||||
<!-- WEBAPP_CONFIG_SCRIPT -->
|
||||
<!-- WEBAPP_JS_SCRIPT -->
|
||||
<!-- WEBAPP_DEV_MOCK_START -->
|
||||
<script src="/subscription_webapp.js" defer></script>
|
||||
<!-- WEBAPP_DEV_MOCK_END -->
|
||||
</body>
|
||||
|
||||
</html>
|
||||
+99
-33
@@ -1,5 +1,7 @@
|
||||
import hmac
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from aiohttp import web
|
||||
from aiogram import Bot, Dispatcher
|
||||
from aiogram.webhook.aiohttp_server import SimpleRequestHandler, setup_application
|
||||
@@ -8,6 +10,42 @@ from sqlalchemy.orm import sessionmaker
|
||||
from config.settings import Settings
|
||||
|
||||
|
||||
class SecureSimpleRequestHandler(SimpleRequestHandler):
|
||||
def verify_secret(self, telegram_secret_token: str, bot: Bot) -> bool:
|
||||
if not self.secret_token:
|
||||
return False
|
||||
return hmac.compare_digest(telegram_secret_token, self.secret_token)
|
||||
|
||||
|
||||
def _inject_shared_instances(
|
||||
app: web.Application,
|
||||
dp: Dispatcher,
|
||||
bot: Bot,
|
||||
settings: Settings,
|
||||
async_session_factory: sessionmaker,
|
||||
) -> None:
|
||||
app["bot"] = bot
|
||||
app["dp"] = dp
|
||||
app["settings"] = settings
|
||||
app["async_session_factory"] = async_session_factory
|
||||
app["i18n"] = dp.get("i18n_instance")
|
||||
for key in (
|
||||
"yookassa_service",
|
||||
"lknpd_service",
|
||||
"subscription_service",
|
||||
"referral_service",
|
||||
"panel_service",
|
||||
"stars_service",
|
||||
"freekassa_service",
|
||||
"cryptopay_service",
|
||||
"panel_webhook_service",
|
||||
"platega_service",
|
||||
"severpay_service",
|
||||
):
|
||||
if hasattr(dp, "workflow_data") and key in dp.workflow_data: # type: ignore
|
||||
app[key] = dp.workflow_data[key] # type: ignore
|
||||
|
||||
|
||||
async def build_and_start_web_app(
|
||||
dp: Dispatcher,
|
||||
bot: Bot,
|
||||
@@ -15,48 +53,34 @@ async def build_and_start_web_app(
|
||||
async_session_factory: sessionmaker,
|
||||
):
|
||||
app = web.Application()
|
||||
app["bot"] = bot
|
||||
app["dp"] = dp
|
||||
app["settings"] = settings
|
||||
app["async_session_factory"] = async_session_factory
|
||||
# Inject shared instances used by webhook handlers
|
||||
app["i18n"] = dp.get("i18n_instance")
|
||||
for key in (
|
||||
"yookassa_service",
|
||||
"subscription_service",
|
||||
"referral_service",
|
||||
"panel_service",
|
||||
"stars_service",
|
||||
"freekassa_service",
|
||||
"cryptopay_service",
|
||||
"tribute_service",
|
||||
"panel_webhook_service",
|
||||
):
|
||||
# Access dispatcher workflow_data directly to avoid sequence protocol issues
|
||||
if hasattr(dp, "workflow_data") and key in dp.workflow_data: # type: ignore
|
||||
app[key] = dp.workflow_data[key] # type: ignore
|
||||
_inject_shared_instances(app, dp, bot, settings, async_session_factory)
|
||||
|
||||
async def _healthcheck(request: web.Request) -> web.Response:
|
||||
return web.json_response({"status": "ok"})
|
||||
|
||||
app.router.add_get("/healthz", _healthcheck)
|
||||
|
||||
setup_application(app, dp, bot=bot)
|
||||
|
||||
telegram_uses_webhook_mode = bool(settings.WEBHOOK_BASE_URL)
|
||||
|
||||
if telegram_uses_webhook_mode:
|
||||
telegram_webhook_path = f"/{settings.BOT_TOKEN}"
|
||||
app.router.add_post(telegram_webhook_path, SimpleRequestHandler(dispatcher=dp, bot=bot))
|
||||
telegram_webhook_path = settings.telegram_webhook_path
|
||||
SecureSimpleRequestHandler(
|
||||
dispatcher=dp,
|
||||
bot=bot,
|
||||
secret_token=settings.WEBHOOK_SECRET_TOKEN,
|
||||
).register(app, path=telegram_webhook_path)
|
||||
logging.info(
|
||||
f"Telegram webhook route configured at: [POST] {telegram_webhook_path} (relative to base URL)"
|
||||
)
|
||||
|
||||
from bot.handlers.user.payment import yookassa_webhook_route
|
||||
from bot.services.tribute_service import tribute_webhook_route
|
||||
from bot.services.crypto_pay_service import cryptopay_webhook_route
|
||||
from bot.services.panel_webhook_service import panel_webhook_route
|
||||
from bot.services.freekassa_service import freekassa_webhook_route
|
||||
|
||||
tribute_path = settings.tribute_webhook_path
|
||||
if tribute_path.startswith("/"):
|
||||
app.router.add_post(tribute_path, tribute_webhook_route)
|
||||
logging.info(f"Tribute webhook route configured at: [POST] {tribute_path}")
|
||||
from bot.services.platega_service import platega_webhook_route
|
||||
from bot.services.severpay_service import severpay_webhook_route
|
||||
|
||||
cp_path = settings.cryptopay_webhook_path
|
||||
if cp_path.startswith("/"):
|
||||
@@ -68,6 +92,16 @@ async def build_and_start_web_app(
|
||||
app.router.add_post(fk_path, freekassa_webhook_route)
|
||||
logging.info(f"FreeKassa webhook route configured at: [POST] {fk_path}")
|
||||
|
||||
pg_path = settings.platega_webhook_path
|
||||
if pg_path.startswith("/"):
|
||||
app.router.add_post(pg_path, platega_webhook_route)
|
||||
logging.info(f"Platega webhook route configured at: [POST] {pg_path}")
|
||||
|
||||
sp_path = settings.severpay_webhook_path
|
||||
if sp_path.startswith("/"):
|
||||
app.router.add_post(sp_path, severpay_webhook_route)
|
||||
logging.info(f"SeverPay webhook route configured at: [POST] {sp_path}")
|
||||
|
||||
# YooKassa webhook (register only when base URL present and path configured)
|
||||
yk_path = settings.yookassa_webhook_path
|
||||
if settings.WEBHOOK_BASE_URL and yk_path and yk_path.startswith("/"):
|
||||
@@ -79,10 +113,13 @@ async def build_and_start_web_app(
|
||||
app.router.add_post(panel_path, panel_webhook_route)
|
||||
logging.info(f"Panel webhook route configured at: [POST] {panel_path}")
|
||||
|
||||
web_app_runner = web.AppRunner(app)
|
||||
await web_app_runner.setup()
|
||||
runners = []
|
||||
|
||||
webhooks_runner = web.AppRunner(app)
|
||||
await webhooks_runner.setup()
|
||||
runners.append(webhooks_runner)
|
||||
site = web.TCPSite(
|
||||
web_app_runner,
|
||||
webhooks_runner,
|
||||
host=settings.WEB_SERVER_HOST,
|
||||
port=settings.WEB_SERVER_PORT,
|
||||
)
|
||||
@@ -92,6 +129,35 @@ async def build_and_start_web_app(
|
||||
f"AIOHTTP server started on http://{settings.WEB_SERVER_HOST}:{settings.WEB_SERVER_PORT}"
|
||||
)
|
||||
|
||||
# Run until cancelled
|
||||
await asyncio.Event().wait()
|
||||
if settings.WEBAPP_ENABLED:
|
||||
from bot.app.web.subscription_webapp import create_subscription_webapp_application
|
||||
|
||||
subscription_app = create_subscription_webapp_application(
|
||||
dp,
|
||||
bot,
|
||||
settings,
|
||||
async_session_factory,
|
||||
)
|
||||
subscription_runner = web.AppRunner(subscription_app)
|
||||
await subscription_runner.setup()
|
||||
runners.append(subscription_runner)
|
||||
subscription_site = web.TCPSite(
|
||||
subscription_runner,
|
||||
host=settings.WEBAPP_SERVER_HOST,
|
||||
port=settings.WEBAPP_SERVER_PORT,
|
||||
)
|
||||
await subscription_site.start()
|
||||
logging.info(
|
||||
"Subscription WebApp server started on http://%s:%s",
|
||||
settings.WEBAPP_SERVER_HOST,
|
||||
settings.WEBAPP_SERVER_PORT,
|
||||
)
|
||||
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
finally:
|
||||
for runner in reversed(runners):
|
||||
try:
|
||||
await runner.cleanup()
|
||||
except Exception as cleanup_error:
|
||||
logging.warning("Failed to cleanup aiohttp runner: %s", cleanup_error)
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Dict, Optional
|
||||
from urllib.parse import parse_qsl
|
||||
|
||||
from config.settings import Settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 5 minutes clock skew tolerance for Telegram clients
|
||||
TELEGRAM_CLOCK_SKEW_SECONDS = 300
|
||||
|
||||
|
||||
def _urlsafe_b64encode(raw: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
|
||||
|
||||
|
||||
def _urlsafe_b64decode(raw: str) -> bytes:
|
||||
padded = raw + ("=" * (-len(raw) % 4))
|
||||
return base64.urlsafe_b64decode(padded.encode("ascii"))
|
||||
|
||||
|
||||
def _session_secret(settings: Settings) -> bytes:
|
||||
return hmac.new(
|
||||
settings.WEBAPP_SESSION_SECRET.encode("utf-8"),
|
||||
b"remnawave-tg-shop-webapp-session",
|
||||
hashlib.sha256,
|
||||
).digest()
|
||||
|
||||
|
||||
def create_webapp_session_token(settings: Settings, user_id: int) -> str:
|
||||
now = int(time.time())
|
||||
payload = {
|
||||
"sub": int(user_id),
|
||||
"iat": now,
|
||||
"exp": now + max(60, int(settings.WEBAPP_SESSION_TTL_SECONDS)),
|
||||
}
|
||||
payload_part = _urlsafe_b64encode(
|
||||
json.dumps(payload, separators=(",", ":")).encode("utf-8")
|
||||
)
|
||||
signature = hmac.new(
|
||||
_session_secret(settings),
|
||||
payload_part.encode("ascii"),
|
||||
hashlib.sha256,
|
||||
).digest()
|
||||
return f"{payload_part}.{_urlsafe_b64encode(signature)}"
|
||||
|
||||
|
||||
def verify_webapp_session_token(settings: Settings, token: str) -> Optional[int]:
|
||||
if not token or "." not in token:
|
||||
return None
|
||||
|
||||
try:
|
||||
payload_part, signature_part = token.split(".", 1)
|
||||
expected_signature = hmac.new(
|
||||
_session_secret(settings),
|
||||
payload_part.encode("ascii"),
|
||||
hashlib.sha256,
|
||||
).digest()
|
||||
received_signature = _urlsafe_b64decode(signature_part)
|
||||
if not hmac.compare_digest(expected_signature, received_signature):
|
||||
return None
|
||||
|
||||
payload = json.loads(_urlsafe_b64decode(payload_part).decode("utf-8"))
|
||||
if int(payload.get("exp", 0)) < int(time.time()):
|
||||
return None
|
||||
return int(payload["sub"])
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to verify webapp session token: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def validate_telegram_webapp_init_data(
|
||||
init_data: str,
|
||||
bot_token: str,
|
||||
*,
|
||||
max_age_seconds: int,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Validate Telegram Mini App initData and return the trusted user payload."""
|
||||
|
||||
try:
|
||||
parsed_data = dict(parse_qsl(init_data or "", keep_blank_values=True))
|
||||
received_hash = parsed_data.pop("hash", None)
|
||||
if not received_hash:
|
||||
return None
|
||||
|
||||
data_check_string = "\n".join(
|
||||
f"{key}={value}" for key, value in sorted(parsed_data.items())
|
||||
)
|
||||
secret_key = hmac.new(
|
||||
b"WebAppData",
|
||||
bot_token.encode("utf-8"),
|
||||
hashlib.sha256,
|
||||
).digest()
|
||||
calculated_hash = hmac.new(
|
||||
secret_key,
|
||||
data_check_string.encode("utf-8"),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
if not hmac.compare_digest(calculated_hash, received_hash):
|
||||
logger.warning("Telegram WebApp initData hash mismatch.")
|
||||
return None
|
||||
|
||||
auth_date_raw = parsed_data.get("auth_date")
|
||||
if auth_date_raw:
|
||||
auth_date = int(auth_date_raw)
|
||||
now = int(time.time())
|
||||
max_age = max(60, int(max_age_seconds))
|
||||
if auth_date > now + TELEGRAM_CLOCK_SKEW_SECONDS or now - auth_date > max_age:
|
||||
logger.warning("Telegram WebApp initData auth_date is stale.")
|
||||
return None
|
||||
|
||||
user_json = parsed_data.get("user")
|
||||
if not user_json:
|
||||
return None
|
||||
user_data = json.loads(user_json)
|
||||
if not user_data.get("id"):
|
||||
return None
|
||||
if parsed_data.get("start_param"):
|
||||
user_data["start_param"] = parsed_data.get("start_param")
|
||||
return user_data
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to validate Telegram WebApp initData: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def validate_telegram_login_widget_data(
|
||||
auth_data: Any,
|
||||
bot_token: str,
|
||||
*,
|
||||
max_age_seconds: int,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Validate Telegram Login Widget data and return the trusted user payload."""
|
||||
|
||||
try:
|
||||
if isinstance(auth_data, str):
|
||||
parsed_data = dict(parse_qsl(auth_data or "", keep_blank_values=True))
|
||||
elif isinstance(auth_data, dict):
|
||||
parsed_data = {
|
||||
str(key): str(value)
|
||||
for key, value in auth_data.items()
|
||||
if value is not None
|
||||
}
|
||||
else:
|
||||
return None
|
||||
|
||||
received_hash = str(parsed_data.pop("hash", "") or "")
|
||||
if not received_hash:
|
||||
return None
|
||||
|
||||
data_check_string = "\n".join(
|
||||
f"{key}={value}" for key, value in sorted(parsed_data.items())
|
||||
)
|
||||
secret_key = hashlib.sha256(bot_token.encode("utf-8")).digest()
|
||||
calculated_hash = hmac.new(
|
||||
secret_key,
|
||||
data_check_string.encode("utf-8"),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
if not hmac.compare_digest(calculated_hash, received_hash):
|
||||
logger.warning("Telegram Login Widget hash mismatch.")
|
||||
return None
|
||||
|
||||
auth_date_raw = parsed_data.get("auth_date")
|
||||
if auth_date_raw:
|
||||
auth_date = int(auth_date_raw)
|
||||
now = int(time.time())
|
||||
max_age = max(60, int(max_age_seconds))
|
||||
if auth_date > now + TELEGRAM_CLOCK_SKEW_SECONDS or now - auth_date > max_age:
|
||||
logger.warning("Telegram Login Widget auth_date is stale.")
|
||||
return None
|
||||
|
||||
user_id_raw = parsed_data.get("id")
|
||||
if not user_id_raw:
|
||||
return None
|
||||
int(user_id_raw)
|
||||
|
||||
if not parsed_data.get("first_name"):
|
||||
return None
|
||||
|
||||
return parsed_data
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to validate Telegram Login Widget data: %s", exc)
|
||||
return None
|
||||
@@ -178,7 +178,7 @@ async def ads_delete_cancel(callback: types.CallbackQuery, settings: Settings, i
|
||||
|
||||
camp = await ad_dal.get_campaign_by_id(session, camp_id)
|
||||
if not camp:
|
||||
await callback.answer(_("admin_ads_not_found", default="Кампания не найдена."), show_alert=True)
|
||||
await callback.answer(_("admin_ads_not_found"), show_alert=True)
|
||||
return
|
||||
try:
|
||||
stats = await ad_dal.get_campaign_stats(session, camp_id)
|
||||
@@ -224,7 +224,7 @@ async def ads_delete_confirm(callback: types.CallbackQuery, settings: Settings,
|
||||
|
||||
existed = await ad_dal.delete_campaign(session, camp_id)
|
||||
if not existed:
|
||||
await callback.answer(_("admin_ads_not_found", default="Кампания не найдена."), show_alert=True)
|
||||
await callback.answer(_("admin_ads_not_found"), show_alert=True)
|
||||
return
|
||||
await session.commit()
|
||||
|
||||
|
||||
@@ -122,7 +122,6 @@ async def process_broadcast_message_handler(
|
||||
await message.answer(
|
||||
_(
|
||||
"admin_broadcast_invalid_html",
|
||||
default="❌ Некорректный HTML в сообщении. Пожалуйста, отправьте корректный HTML (поддерживаются теги Telegram) или уберите теги.\nОшибка: {error}",
|
||||
error=str(e),
|
||||
)
|
||||
)
|
||||
@@ -347,15 +346,6 @@ async def confirm_broadcast_callback_handler(
|
||||
total_failed = failed_count + dynamic_failed
|
||||
return _(
|
||||
"broadcast_queue_result",
|
||||
default=(
|
||||
"🚀 Рассылка поставлена в очередь!\n"
|
||||
"📤 В очередь добавлено: {sent_count}\n"
|
||||
"❌ Ошибок: {failed_count}\n\n"
|
||||
"📊 Статус очередей:\n"
|
||||
"👥 Очередь пользователей: {user_queue_size} сообщений\n"
|
||||
"📢 Очередь групп: {group_queue_size} сообщений\n\n"
|
||||
"ℹ️ Сообщения будут отправлены автоматически с соблюдением лимитов Telegram."
|
||||
),
|
||||
sent_count=sent_count,
|
||||
failed_count=total_failed,
|
||||
user_queue_size=stats["user_queue_size"],
|
||||
|
||||
@@ -98,8 +98,22 @@ async def admin_panel_actions_callback_handler(
|
||||
await admin_user_mgmnt_handlers.unban_user_prompt_handler(
|
||||
callback, state, i18n_data, settings, session)
|
||||
elif action == "users_management":
|
||||
# This is deprecated, kept for compatibility
|
||||
from . import user_management as admin_user_management_handlers
|
||||
await admin_user_management_handlers.user_management_menu_handler(
|
||||
await admin_user_management_handlers.user_search_prompt_handler(
|
||||
callback, state, i18n_data, settings, session)
|
||||
elif action == "users_list" and len(action_parts) > 2:
|
||||
# Route to users list handler with page number
|
||||
from . import user_management as admin_user_management_handlers
|
||||
try:
|
||||
page = int(action_parts[2])
|
||||
await admin_user_management_handlers.users_list_handler(
|
||||
callback, i18n_data, settings, session, page)
|
||||
except (IndexError, ValueError):
|
||||
await callback.answer("Invalid page number", show_alert=True)
|
||||
elif action == "users_search_prompt":
|
||||
from . import user_management as admin_user_management_handlers
|
||||
await admin_user_management_handlers.user_search_prompt_handler(
|
||||
callback, state, i18n_data, settings, session)
|
||||
elif action == "view_banned":
|
||||
|
||||
@@ -127,6 +141,9 @@ async def admin_panel_actions_callback_handler(
|
||||
from . import payments as admin_payments_handlers
|
||||
await admin_payments_handlers.view_payments_handler(
|
||||
callback, i18n_data, settings, session)
|
||||
elif action == "user_ratings":
|
||||
await admin_stats_handlers.show_user_ratings_handler(
|
||||
callback, i18n_data, settings, session)
|
||||
elif action == "ads":
|
||||
from . import ads as admin_ads_handlers
|
||||
await admin_ads_handlers.show_ads_menu(callback, settings, i18n_data, session)
|
||||
|
||||
@@ -23,6 +23,7 @@ from bot.middlewares.i18n import JsonI18n
|
||||
|
||||
router = Router(name="admin_logs_router")
|
||||
USERNAME_REGEX = re.compile(r"^[a-zA-Z0-9_]{5,32}$")
|
||||
EMAIL_REGEX = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
||||
|
||||
|
||||
async def display_logs_menu(callback: types.CallbackQuery, i18n_data: dict,
|
||||
@@ -225,12 +226,14 @@ async def process_user_id_for_logs_handler(message: types.Message,
|
||||
input_text = message.text.strip() if message.text else ""
|
||||
user_model_for_logs: Optional[User] = None
|
||||
|
||||
if input_text.isdigit():
|
||||
if input_text.isdigit() or (input_text.startswith("-") and input_text[1:].isdigit()):
|
||||
try:
|
||||
user_model_for_logs = await user_dal.get_user_by_id(
|
||||
session, int(input_text))
|
||||
except ValueError:
|
||||
pass
|
||||
elif EMAIL_REGEX.match(input_text):
|
||||
user_model_for_logs = await user_dal.get_user_by_email(session, input_text)
|
||||
elif input_text.startswith("@") and USERNAME_REGEX.match(input_text[1:]):
|
||||
user_model_for_logs = await user_dal.get_user_by_username(
|
||||
session, input_text[1:])
|
||||
@@ -245,7 +248,7 @@ async def process_user_id_for_logs_handler(message: types.Message,
|
||||
target_user_id = user_model_for_logs.user_id
|
||||
user_display_name = user_model_for_logs.first_name or (
|
||||
f"@{user_model_for_logs.username}"
|
||||
if user_model_for_logs.username else f"ID {target_user_id}")
|
||||
if user_model_for_logs.username else (user_model_for_logs.email or f"ID {target_user_id}"))
|
||||
|
||||
logs_models = await message_log_dal.get_user_message_logs(
|
||||
session, target_user_id, settings.LOGS_PAGE_SIZE, 0)
|
||||
@@ -292,7 +295,7 @@ async def view_user_logs_paginated_handler(callback: types.CallbackQuery,
|
||||
|
||||
user_display_name = user_model_for_logs.first_name or (
|
||||
f"@{user_model_for_logs.username}"
|
||||
if user_model_for_logs.username else f"ID {target_user_id}")
|
||||
if user_model_for_logs.username else (user_model_for_logs.email or f"ID {target_user_id}"))
|
||||
|
||||
logs_models = await message_log_dal.get_user_message_logs(
|
||||
session, target_user_id, settings.LOGS_PAGE_SIZE,
|
||||
@@ -338,8 +341,7 @@ async def export_logs_csv_handler(callback: types.CallbackQuery,
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
await callback.answer(_(
|
||||
"admin_logs_csv_export_started",
|
||||
default="🔄 Начинаю экспорт логов в CSV..."
|
||||
"admin_logs_csv_export_started"
|
||||
))
|
||||
|
||||
try:
|
||||
@@ -349,8 +351,7 @@ async def export_logs_csv_handler(callback: types.CallbackQuery,
|
||||
|
||||
if not logs_models:
|
||||
await callback.message.answer(_(
|
||||
"admin_logs_csv_no_data",
|
||||
default="❌ Нет данных для экспорта"
|
||||
"admin_logs_csv_no_data"
|
||||
))
|
||||
return
|
||||
|
||||
@@ -360,16 +361,16 @@ async def export_logs_csv_handler(callback: types.CallbackQuery,
|
||||
|
||||
# Write header
|
||||
headers = [
|
||||
_("admin_csv_header_log_id", default="Log ID"),
|
||||
_("admin_csv_header_timestamp", default="Timestamp"),
|
||||
_("admin_csv_header_user_id", default="User ID"),
|
||||
_("admin_csv_header_telegram_username", default="Telegram Username"),
|
||||
_("admin_csv_header_telegram_first_name", default="Telegram First Name"),
|
||||
_("admin_csv_header_event_type", default="Event Type"),
|
||||
_("admin_csv_header_content", default="Content"),
|
||||
_("admin_csv_header_is_admin_event", default="Is Admin Event"),
|
||||
_("admin_csv_header_target_user_id", default="Target User ID"),
|
||||
_("admin_csv_header_raw_update_preview", default="Raw Update Preview")
|
||||
_("admin_csv_header_log_id"),
|
||||
_("admin_csv_header_timestamp"),
|
||||
_("admin_csv_header_user_id"),
|
||||
_("admin_csv_header_telegram_username"),
|
||||
_("admin_csv_header_telegram_first_name"),
|
||||
_("admin_csv_header_event_type"),
|
||||
_("admin_csv_header_content"),
|
||||
_("admin_csv_header_is_admin_event"),
|
||||
_("admin_csv_header_target_user_id"),
|
||||
_("admin_csv_header_raw_update_preview")
|
||||
]
|
||||
csv_writer.writerow(headers)
|
||||
|
||||
@@ -414,7 +415,6 @@ async def export_logs_csv_handler(callback: types.CallbackQuery,
|
||||
csv_file,
|
||||
caption=_(
|
||||
"admin_logs_csv_export_success",
|
||||
default="✅ Экспорт логов завершен!\n\n📊 Записей: {count}\n📅 Дата экспорта: {date}",
|
||||
count=len(logs_models),
|
||||
date=now.strftime('%Y-%m-%d %H:%M:%S')
|
||||
)
|
||||
@@ -424,6 +424,5 @@ async def export_logs_csv_handler(callback: types.CallbackQuery,
|
||||
logging.error(f"Error exporting logs to CSV: {e}", exc_info=True)
|
||||
await callback.message.answer(_(
|
||||
"admin_logs_csv_export_failed",
|
||||
default="❌ Ошибка при экспорте логов: {error}",
|
||||
error=str(e)
|
||||
))
|
||||
|
||||
@@ -34,12 +34,20 @@ async def get_payments_with_pagination(session: AsyncSession, page: int = 0,
|
||||
return payments, total_count
|
||||
|
||||
|
||||
def format_payment_text(payment: Payment, i18n: JsonI18n, lang: str) -> str:
|
||||
def format_payment_text(payment: Payment, i18n: JsonI18n, lang: str, settings: Settings) -> str:
|
||||
"""Format single payment info as text."""
|
||||
_ = lambda key, **kwargs: i18n.gettext(lang, key, **kwargs)
|
||||
|
||||
pending_statuses = [
|
||||
'pending',
|
||||
'pending_yookassa',
|
||||
'pending_freekassa',
|
||||
'pending_platega',
|
||||
'pending_severpay',
|
||||
'pending_cryptopay',
|
||||
]
|
||||
status_emoji = "✅" if payment.status == 'succeeded' else (
|
||||
"⏳" if payment.status in ['pending', 'pending_yookassa', 'pending_freekassa'] else "❌"
|
||||
"⏳" if payment.status in pending_statuses else "❌"
|
||||
)
|
||||
|
||||
user_info = f"User {payment.user_id}"
|
||||
@@ -52,17 +60,27 @@ def format_payment_text(payment: Payment, i18n: JsonI18n, lang: str) -> str:
|
||||
|
||||
provider_text = {
|
||||
'yookassa': 'YooKassa',
|
||||
'tribute': 'Tribute',
|
||||
'telegram_stars': 'Telegram Stars',
|
||||
'cryptopay': 'CryptoPay',
|
||||
'freekassa': 'FreeKassa',
|
||||
'severpay': 'SeverPay',
|
||||
'platega': 'Platega',
|
||||
}.get(payment.provider, payment.provider or 'Unknown')
|
||||
|
||||
traffic_mode = getattr(settings, "traffic_sale_mode", False)
|
||||
if traffic_mode:
|
||||
traffic_val = payment.subscription_duration_months or 0
|
||||
traffic_display = str(int(traffic_val)) if float(traffic_val).is_integer() else f"{traffic_val:g}"
|
||||
period_line = _("admin_payment_traffic_label", traffic_gb=traffic_display)
|
||||
else:
|
||||
period_line = _("admin_payment_months_label", months=payment.subscription_duration_months or 0)
|
||||
|
||||
return (
|
||||
f"{status_emoji} <b>{payment.amount} {payment.currency}</b>\n"
|
||||
f"👤 {user_info}\n"
|
||||
f"💳 {provider_text}\n"
|
||||
f"📅 {payment_date}\n"
|
||||
f"{period_line}\n"
|
||||
f"📋 {payment.status}\n"
|
||||
f"📝 {payment.description or 'N/A'}"
|
||||
)
|
||||
@@ -84,7 +102,7 @@ async def view_payments_handler(callback: types.CallbackQuery, i18n_data: dict,
|
||||
|
||||
if not payments and page == 0:
|
||||
await callback.message.edit_text(
|
||||
_("admin_no_payments_found", default="Платежи не найдены."),
|
||||
_("admin_no_payments_found"),
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
@@ -92,7 +110,7 @@ async def view_payments_handler(callback: types.CallbackQuery, i18n_data: dict,
|
||||
return
|
||||
|
||||
# Format payments text
|
||||
text_parts = [_("admin_payments_header", default="💰 <b>Все платежи</b>")]
|
||||
text_parts = [_("admin_payments_header")]
|
||||
text_parts.append(_("admin_payments_pagination_info",
|
||||
shown=len(payments),
|
||||
total=total_count,
|
||||
@@ -100,7 +118,7 @@ async def view_payments_handler(callback: types.CallbackQuery, i18n_data: dict,
|
||||
total_pages=total_pages) + "\n")
|
||||
|
||||
for i, payment in enumerate(payments, 1):
|
||||
text_parts.append(f"<b>{page * page_size + i}.</b> {format_payment_text(payment, i18n, current_lang)}")
|
||||
text_parts.append(f"<b>{page * page_size + i}.</b> {format_payment_text(payment, i18n, current_lang, settings)}")
|
||||
text_parts.append("") # Empty line between payments
|
||||
|
||||
# Build keyboard with pagination and export
|
||||
@@ -122,11 +140,11 @@ async def view_payments_handler(callback: types.CallbackQuery, i18n_data: dict,
|
||||
# Export and refresh buttons
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_("admin_export_payments_csv", default="📊 Экспорт CSV"),
|
||||
text=_("admin_export_payments_csv"),
|
||||
callback_data="payments_export_csv"
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text=_("admin_refresh_payments", default="🔄 Обновить"),
|
||||
text=_("admin_refresh_payments"),
|
||||
callback_data=f"payments_page:{page}"
|
||||
)
|
||||
)
|
||||
@@ -173,7 +191,7 @@ async def export_payments_csv_handler(callback: types.CallbackQuery, i18n_data:
|
||||
|
||||
if not all_payments:
|
||||
await callback.answer(
|
||||
_("admin_no_payments_to_export", default="Нет платежей для экспорта."),
|
||||
_("admin_no_payments_to_export"),
|
||||
show_alert=True
|
||||
)
|
||||
return
|
||||
@@ -184,22 +202,30 @@ async def export_payments_csv_handler(callback: types.CallbackQuery, i18n_data:
|
||||
|
||||
# Write header
|
||||
writer.writerow([
|
||||
_("admin_csv_payment_id", default="ID"),
|
||||
_("admin_csv_user_id", default="User ID"),
|
||||
_("admin_csv_username", default="Username"),
|
||||
_("admin_csv_first_name", default="First Name"),
|
||||
_("admin_csv_amount", default="Amount"),
|
||||
_("admin_csv_currency", default="Currency"),
|
||||
_("admin_csv_provider", default="Provider"),
|
||||
_("admin_csv_status", default="Status"),
|
||||
_("admin_csv_description", default="Description"),
|
||||
_("admin_csv_months", default="Months"),
|
||||
_("admin_csv_created_at", default="Created At"),
|
||||
_("admin_csv_provider_payment_id", default="Provider Payment ID")
|
||||
_("admin_csv_payment_id"),
|
||||
_("admin_csv_user_id"),
|
||||
_("admin_csv_username"),
|
||||
_("admin_csv_first_name"),
|
||||
_("admin_csv_amount"),
|
||||
_("admin_csv_currency"),
|
||||
_("admin_csv_provider"),
|
||||
_("admin_csv_status"),
|
||||
_("admin_csv_description"),
|
||||
_("admin_csv_units"),
|
||||
_("admin_csv_created_at"),
|
||||
_("admin_csv_provider_payment_id")
|
||||
])
|
||||
|
||||
traffic_mode = getattr(settings, "traffic_sale_mode", False)
|
||||
|
||||
# Write payment data
|
||||
for payment in all_payments:
|
||||
units_val = payment.subscription_duration_months or ""
|
||||
if traffic_mode and units_val not in ("", None):
|
||||
try:
|
||||
units_val = str(int(units_val)) if float(units_val).is_integer() else f"{units_val:g}"
|
||||
except Exception:
|
||||
units_val = payment.subscription_duration_months or ""
|
||||
writer.writerow([
|
||||
payment.payment_id,
|
||||
payment.user_id,
|
||||
@@ -210,7 +236,7 @@ async def export_payments_csv_handler(callback: types.CallbackQuery, i18n_data:
|
||||
payment.provider or "",
|
||||
payment.status,
|
||||
payment.description or "",
|
||||
payment.subscription_duration_months or "",
|
||||
units_val,
|
||||
payment.created_at.strftime('%Y-%m-%d %H:%M:%S') if payment.created_at else "",
|
||||
payment.provider_payment_id or ""
|
||||
])
|
||||
@@ -229,13 +255,12 @@ async def export_payments_csv_handler(callback: types.CallbackQuery, i18n_data:
|
||||
|
||||
await callback.message.reply_document(
|
||||
document=file,
|
||||
caption=_("admin_payments_export_success",
|
||||
default="📊 Payments export completed!\nTotal records: {count}",
|
||||
caption=_("admin_payments_export_success",
|
||||
count=len(all_payments))
|
||||
)
|
||||
|
||||
await callback.answer(
|
||||
_("admin_export_sent", default="File sent!"),
|
||||
_("admin_export_sent"),
|
||||
show_alert=False
|
||||
)
|
||||
|
||||
|
||||
@@ -34,8 +34,7 @@ async def create_bulk_promo_prompt_handler(callback: types.CallbackQuery,
|
||||
|
||||
# Step 1: Ask for quantity
|
||||
prompt_text = _(
|
||||
"admin_bulk_promo_step1_quantity",
|
||||
default="🎟 <b>Массовое создание промокодов</b>\n\n<b>Шаг 1 из 4:</b> Количество\n\nВведите количество промокодов для создания (1-100):"
|
||||
"admin_bulk_promo_step1_quantity"
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -77,8 +76,7 @@ async def process_bulk_promo_quantity_handler(message: types.Message,
|
||||
quantity = int(message.text.strip())
|
||||
if not (1 <= quantity <= 100):
|
||||
await message.answer(_(
|
||||
"admin_bulk_promo_invalid_quantity",
|
||||
default="❌ Количество промокодов должно быть от 1 до 100"
|
||||
"admin_bulk_promo_invalid_quantity"
|
||||
))
|
||||
return
|
||||
|
||||
@@ -87,7 +85,6 @@ async def process_bulk_promo_quantity_handler(message: types.Message,
|
||||
# Step 2: Ask for bonus days
|
||||
prompt_text = _(
|
||||
"admin_bulk_promo_step2_bonus_days",
|
||||
default="🎟 <b>Массовое создание промокодов</b>\n\n<b>Шаг 2 из 4:</b> Бонусные дни\n\nКоличество: <b>{quantity}</b>\n\nВведите количество бонусных дней для каждого промокода (1-365):",
|
||||
quantity=quantity
|
||||
)
|
||||
|
||||
@@ -100,8 +97,7 @@ async def process_bulk_promo_quantity_handler(message: types.Message,
|
||||
|
||||
except ValueError:
|
||||
await message.answer(_(
|
||||
"admin_promo_invalid_number",
|
||||
default="❌ Введите корректное число"
|
||||
"admin_promo_invalid_number"
|
||||
))
|
||||
except Exception as e:
|
||||
logging.error(f"Error processing bulk promo quantity: {e}")
|
||||
@@ -125,8 +121,7 @@ async def process_bulk_promo_bonus_days_handler(message: types.Message,
|
||||
bonus_days = int(message.text.strip())
|
||||
if not (1 <= bonus_days <= 365):
|
||||
await message.answer(_(
|
||||
"admin_promo_invalid_bonus_days",
|
||||
default="❌ Количество бонусных дней должно быть от 1 до 365"
|
||||
"admin_promo_invalid_bonus_days"
|
||||
))
|
||||
return
|
||||
|
||||
@@ -136,7 +131,6 @@ async def process_bulk_promo_bonus_days_handler(message: types.Message,
|
||||
data = await state.get_data()
|
||||
prompt_text = _(
|
||||
"admin_bulk_promo_step3_max_activations",
|
||||
default="🎟 <b>Массовое создание промокодов</b>\n\n<b>Шаг 3 из 4:</b> Лимит активаций\n\nКоличество: <b>{quantity}</b>\nБонусные дни: <b>{bonus_days}</b>\n\nВведите максимальное количество активаций для каждого промокода (1-10000):",
|
||||
quantity=data.get("quantity"),
|
||||
bonus_days=bonus_days
|
||||
)
|
||||
@@ -150,8 +144,7 @@ async def process_bulk_promo_bonus_days_handler(message: types.Message,
|
||||
|
||||
except ValueError:
|
||||
await message.answer(_(
|
||||
"admin_promo_invalid_number",
|
||||
default="❌ Введите корректное число"
|
||||
"admin_promo_invalid_number"
|
||||
))
|
||||
except Exception as e:
|
||||
logging.error(f"Error processing bulk promo bonus days: {e}")
|
||||
@@ -175,8 +168,7 @@ async def process_bulk_promo_max_activations_handler(message: types.Message,
|
||||
max_activations = int(message.text.strip())
|
||||
if not (1 <= max_activations <= 10000):
|
||||
await message.answer(_(
|
||||
"admin_promo_invalid_max_activations",
|
||||
default="❌ Максимальное количество активаций должно быть от 1 до 10000"
|
||||
"admin_promo_invalid_max_activations"
|
||||
))
|
||||
return
|
||||
|
||||
@@ -186,7 +178,6 @@ async def process_bulk_promo_max_activations_handler(message: types.Message,
|
||||
data = await state.get_data()
|
||||
prompt_text = _(
|
||||
"admin_bulk_promo_step4_validity",
|
||||
default="🎟 <b>Массовое создание промокодов</b>\n\n<b>Шаг 4 из 4:</b> Срок действия\n\nКоличество: <b>{quantity}</b>\nБонусные дни: <b>{bonus_days}</b>\nМакс. активаций: <b>{max_activations}</b>\n\nВыберите срок действия промокодов:",
|
||||
quantity=data.get("quantity"),
|
||||
bonus_days=data.get("bonus_days"),
|
||||
max_activations=max_activations
|
||||
@@ -196,19 +187,19 @@ async def process_bulk_promo_max_activations_handler(message: types.Message,
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_("admin_promo_unlimited_validity", default="🔄 Без ограничений"),
|
||||
text=_("admin_promo_unlimited_validity"),
|
||||
callback_data="bulk_promo_unlimited_validity"
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_("admin_promo_set_validity_days", default="📅 Указать дни"),
|
||||
text=_("admin_promo_set_validity_days"),
|
||||
callback_data="bulk_promo_set_validity"
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_("admin_back_to_panel", default="🔙 В админ панель"),
|
||||
text=_("admin_back_to_panel"),
|
||||
callback_data="admin_action:main"
|
||||
)
|
||||
)
|
||||
@@ -222,8 +213,7 @@ async def process_bulk_promo_max_activations_handler(message: types.Message,
|
||||
|
||||
except ValueError:
|
||||
await message.answer(_(
|
||||
"admin_promo_invalid_number",
|
||||
default="❌ Введите корректное число"
|
||||
"admin_promo_invalid_number"
|
||||
))
|
||||
except Exception as e:
|
||||
logging.error(f"Error processing bulk promo max activations: {e}")
|
||||
@@ -257,7 +247,6 @@ async def process_bulk_promo_set_validity(callback: types.CallbackQuery,
|
||||
data = await state.get_data()
|
||||
prompt_text = _(
|
||||
"admin_bulk_promo_enter_validity_days",
|
||||
default="🎟 <b>Массовое создание промокодов</b>\n\n<b>Шаг 4 из 4:</b> Срок действия\n\nКоличество: <b>{quantity}</b>\nБонусные дни: <b>{bonus_days}</b>\nМакс. активаций: <b>{max_activations}</b>\n\nВведите количество дней действия промокодов (1-365):",
|
||||
quantity=data.get("quantity"),
|
||||
bonus_days=data.get("bonus_days"),
|
||||
max_activations=data.get("max_activations")
|
||||
@@ -296,8 +285,7 @@ async def process_bulk_promo_validity_days_handler(message: types.Message,
|
||||
validity_days = int(message.text.strip())
|
||||
if not (1 <= validity_days <= 365):
|
||||
await message.answer(_(
|
||||
"admin_promo_invalid_validity_days",
|
||||
default="❌ Срок действия должен быть от 1 до 365 дней"
|
||||
"admin_promo_invalid_validity_days"
|
||||
))
|
||||
return
|
||||
|
||||
@@ -306,8 +294,7 @@ async def process_bulk_promo_validity_days_handler(message: types.Message,
|
||||
|
||||
except ValueError:
|
||||
await message.answer(_(
|
||||
"admin_promo_invalid_number",
|
||||
default="❌ Введите корректное число"
|
||||
"admin_promo_invalid_number"
|
||||
))
|
||||
except Exception as e:
|
||||
logging.error(f"Error processing bulk promo validity days: {e}")
|
||||
@@ -333,7 +320,6 @@ async def create_bulk_promo_codes_final(callback_or_message,
|
||||
# Show progress message
|
||||
progress_text = _(
|
||||
"admin_bulk_promo_creating",
|
||||
default="🔄 Создание {quantity} промокодов...",
|
||||
quantity=quantity
|
||||
)
|
||||
|
||||
@@ -395,12 +381,10 @@ async def create_bulk_promo_codes_final(callback_or_message,
|
||||
# Success message
|
||||
success_lines = [
|
||||
_(
|
||||
"admin_bulk_promo_created_title",
|
||||
default="✅ <b>Массовое создание завершено!</b>\n"
|
||||
"admin_bulk_promo_created_title"
|
||||
),
|
||||
_(
|
||||
"admin_bulk_promo_created_stats",
|
||||
default="📊 Создано: <b>{created}</b> из <b>{total}</b>",
|
||||
created=len(created_codes),
|
||||
total=quantity
|
||||
)
|
||||
@@ -409,14 +393,11 @@ async def create_bulk_promo_codes_final(callback_or_message,
|
||||
if data.get("validity_days"):
|
||||
validity_text = f"{data['validity_days']} дней"
|
||||
else:
|
||||
validity_text = _("admin_promo_unlimited", default="Без ограничений")
|
||||
validity_text = _("admin_promo_unlimited")
|
||||
|
||||
success_lines.append(
|
||||
_(
|
||||
"admin_bulk_promo_settings",
|
||||
default="🎁 Бонусные дни: <b>{bonus_days}</b>\n"
|
||||
"📊 Макс. активаций: <b>{max_activations}</b>\n"
|
||||
"⏰ Срок действия: <b>{validity}</b>",
|
||||
bonus_days=data["bonus_days"],
|
||||
max_activations=data["max_activations"],
|
||||
validity=validity_text
|
||||
@@ -521,7 +502,7 @@ async def create_bulk_promo_codes_final(callback_or_message,
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error creating bulk promo codes: {e}")
|
||||
error_text = _("error_occurred_try_again", default="❌ Произошла ошибка. Попробуйте снова.")
|
||||
error_text = _("error_occurred_try_again")
|
||||
|
||||
if hasattr(callback_or_message, 'message'): # CallbackQuery
|
||||
await callback_or_message.message.answer(error_text)
|
||||
@@ -564,5 +545,5 @@ async def cancel_bulk_promo_creation_state_to_menu(callback: types.CallbackQuery
|
||||
reply_markup=get_admin_panel_keyboard(i18n, current_lang, settings)
|
||||
)
|
||||
|
||||
await callback.answer(_("admin_bulk_promo_creation_cancelled", default="Массовое создание промокодов отменено"))
|
||||
await state.clear()
|
||||
await callback.answer(_("admin_bulk_promo_creation_cancelled"))
|
||||
await state.clear()
|
||||
|
||||
@@ -30,8 +30,7 @@ async def create_promo_prompt_handler(callback: types.CallbackQuery,
|
||||
|
||||
# Step 1: Ask for promo code
|
||||
prompt_text = _(
|
||||
"admin_promo_step1_code",
|
||||
default="🎟 <b>Создание промокода</b>\n\n<b>Шаг 1 из 4:</b> Код промокода\n\nВведите код промокода (3-30 символов, только буквы и цифры):"
|
||||
"admin_promo_step1_code"
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -68,8 +67,7 @@ async def process_promo_code_handler(message: types.Message,
|
||||
code_str = message.text.strip().upper()
|
||||
if not (3 <= len(code_str) <= 30 and code_str.isalnum()):
|
||||
await message.answer(_(
|
||||
"admin_promo_invalid_code_format",
|
||||
default="❌ Код промокода должен содержать 3-30 символов (только буквы и цифры)"
|
||||
"admin_promo_invalid_code_format"
|
||||
))
|
||||
return
|
||||
|
||||
@@ -77,8 +75,7 @@ async def process_promo_code_handler(message: types.Message,
|
||||
existing_promo = await promo_code_dal.get_promo_code_by_code(session, code_str)
|
||||
if existing_promo:
|
||||
await message.answer(_(
|
||||
"admin_promo_code_already_exists",
|
||||
default="❌ Промокод с таким кодом уже существует"
|
||||
"admin_promo_code_already_exists"
|
||||
))
|
||||
return
|
||||
|
||||
@@ -87,7 +84,6 @@ async def process_promo_code_handler(message: types.Message,
|
||||
# Step 2: Ask for bonus days
|
||||
prompt_text = _(
|
||||
"admin_promo_step2_bonus_days",
|
||||
default="🎟 <b>Создание промокода</b>\n\n<b>Шаг 2 из 4:</b> Бонусные дни\n\nКод: <b>{code}</b>\n\nВведите количество бонусных дней (1-365):",
|
||||
code=code_str
|
||||
)
|
||||
|
||||
@@ -120,8 +116,7 @@ async def process_promo_bonus_days_handler(message: types.Message,
|
||||
bonus_days = int(message.text.strip())
|
||||
if not (1 <= bonus_days <= 365):
|
||||
await message.answer(_(
|
||||
"admin_promo_invalid_bonus_days",
|
||||
default="❌ Количество бонусных дней должно быть от 1 до 365"
|
||||
"admin_promo_invalid_bonus_days"
|
||||
))
|
||||
return
|
||||
|
||||
@@ -131,7 +126,6 @@ async def process_promo_bonus_days_handler(message: types.Message,
|
||||
data = await state.get_data()
|
||||
prompt_text = _(
|
||||
"admin_promo_step3_max_activations",
|
||||
default="🎟 <b>Создание промокода</b>\n\n<b>Шаг 3 из 4:</b> Лимит активаций\n\nКод: <b>{code}</b>\nБонусные дни: <b>{bonus_days}</b>\n\nВведите максимальное количество активаций (1-10000):",
|
||||
code=data.get("promo_code"),
|
||||
bonus_days=bonus_days
|
||||
)
|
||||
@@ -145,8 +139,7 @@ async def process_promo_bonus_days_handler(message: types.Message,
|
||||
|
||||
except ValueError:
|
||||
await message.answer(_(
|
||||
"admin_promo_invalid_number",
|
||||
default="❌ Введите корректное число"
|
||||
"admin_promo_invalid_number"
|
||||
))
|
||||
except Exception as e:
|
||||
logging.error(f"Error processing promo bonus days: {e}")
|
||||
@@ -170,8 +163,7 @@ async def process_promo_max_activations_handler(message: types.Message,
|
||||
max_activations = int(message.text.strip())
|
||||
if not (1 <= max_activations <= 10000):
|
||||
await message.answer(_(
|
||||
"admin_promo_invalid_max_activations",
|
||||
default="❌ Максимальное количество активаций должно быть от 1 до 10000"
|
||||
"admin_promo_invalid_max_activations"
|
||||
))
|
||||
return
|
||||
|
||||
@@ -181,7 +173,6 @@ async def process_promo_max_activations_handler(message: types.Message,
|
||||
data = await state.get_data()
|
||||
prompt_text = _(
|
||||
"admin_promo_step4_validity",
|
||||
default="🎟 <b>Создание промокода</b>\n\n<b>Шаг 4 из 4:</b> Срок действия\n\nКод: <b>{code}</b>\nБонусные дни: <b>{bonus_days}</b>\nМакс. активаций: <b>{max_activations}</b>\n\nВыберите срок действия промокода:",
|
||||
code=data.get("promo_code"),
|
||||
bonus_days=data.get("bonus_days"),
|
||||
max_activations=max_activations
|
||||
@@ -191,19 +182,19 @@ async def process_promo_max_activations_handler(message: types.Message,
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_("admin_promo_unlimited_validity", default="🔄 Без ограничений"),
|
||||
text=_("admin_promo_unlimited_validity"),
|
||||
callback_data="promo_unlimited_validity"
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_("admin_promo_set_validity_days", default="📅 Указать дни"),
|
||||
text=_("admin_promo_set_validity_days"),
|
||||
callback_data="promo_set_validity"
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_("admin_back_to_panel", default="🔙 В админ панель"),
|
||||
text=_("admin_back_to_panel"),
|
||||
callback_data="admin_action:main"
|
||||
)
|
||||
)
|
||||
@@ -217,8 +208,7 @@ async def process_promo_max_activations_handler(message: types.Message,
|
||||
|
||||
except ValueError:
|
||||
await message.answer(_(
|
||||
"admin_promo_invalid_number",
|
||||
default="❌ Введите корректное число"
|
||||
"admin_promo_invalid_number"
|
||||
))
|
||||
except Exception as e:
|
||||
logging.error(f"Error processing promo max activations: {e}")
|
||||
@@ -252,7 +242,6 @@ async def process_promo_set_validity(callback: types.CallbackQuery,
|
||||
data = await state.get_data()
|
||||
prompt_text = _(
|
||||
"admin_promo_enter_validity_days",
|
||||
default="🎟 <b>Создание промокода</b>\n\n<b>Шаг 4 из 4:</b> Срок действия\n\nКод: <b>{code}</b>\nБонусные дни: <b>{bonus_days}</b>\nМакс. активаций: <b>{max_activations}</b>\n\nВведите количество дней действия промокода (1-365):",
|
||||
code=data.get("promo_code"),
|
||||
bonus_days=data.get("bonus_days"),
|
||||
max_activations=data.get("max_activations")
|
||||
@@ -291,8 +280,7 @@ async def process_promo_validity_days_handler(message: types.Message,
|
||||
validity_days = int(message.text.strip())
|
||||
if not (1 <= validity_days <= 365):
|
||||
await message.answer(_(
|
||||
"admin_promo_invalid_validity_days",
|
||||
default="❌ Срок действия должен быть от 1 до 365 дней"
|
||||
"admin_promo_invalid_validity_days"
|
||||
))
|
||||
return
|
||||
|
||||
@@ -301,8 +289,7 @@ async def process_promo_validity_days_handler(message: types.Message,
|
||||
|
||||
except ValueError:
|
||||
await message.answer(_(
|
||||
"admin_promo_invalid_number",
|
||||
default="❌ Введите корректное число"
|
||||
"admin_promo_invalid_number"
|
||||
))
|
||||
except Exception as e:
|
||||
logging.error(f"Error processing promo validity days: {e}")
|
||||
@@ -349,14 +336,9 @@ async def create_promo_code_final(callback_or_message,
|
||||
logging.info(f"Promo code '{data['promo_code']}' created with ID {created_promo.promo_code_id}")
|
||||
|
||||
# Success message
|
||||
valid_until_str = _("admin_promo_unlimited", default="Без ограничений") if not data.get("validity_days") else f"{data['validity_days']} дней"
|
||||
valid_until_str = _("admin_promo_unlimited") if not data.get("validity_days") else f"{data['validity_days']} дней"
|
||||
success_text = _(
|
||||
"admin_promo_created_success",
|
||||
default="✅ <b>Промокод успешно создан!</b>\n\n"
|
||||
"🎟 Код: <code>{code}</code>\n"
|
||||
"🎁 Бонусные дни: <b>{bonus_days}</b>\n"
|
||||
"📊 Макс. активаций: <b>{max_activations}</b>\n"
|
||||
"⏰ Срок действия: <b>{valid_until_str}</b>",
|
||||
code=data["promo_code"],
|
||||
bonus_days=data["bonus_days"],
|
||||
max_activations=data["max_activations"],
|
||||
@@ -388,7 +370,7 @@ async def create_promo_code_final(callback_or_message,
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error creating promo code: {e}")
|
||||
error_text = _("error_occurred_try_again", default="❌ Произошла ошибка. Попробуйте снова.")
|
||||
error_text = _("error_occurred_try_again")
|
||||
|
||||
if hasattr(callback_or_message, 'message'): # CallbackQuery
|
||||
await callback_or_message.message.answer(error_text)
|
||||
@@ -432,5 +414,5 @@ async def cancel_promo_creation_state_to_menu(callback: types.CallbackQuery,
|
||||
reply_markup=get_admin_panel_keyboard(i18n, current_lang, settings)
|
||||
)
|
||||
|
||||
await callback.answer(_("admin_promo_creation_cancelled", default="Создание промокода отменено"))
|
||||
await state.clear()
|
||||
await callback.answer(_("admin_promo_creation_cancelled"))
|
||||
await state.clear()
|
||||
|
||||
@@ -2,6 +2,7 @@ import logging
|
||||
from aiogram import Router, F, types
|
||||
from typing import Optional, Dict, List
|
||||
from datetime import datetime
|
||||
import html
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config.settings import Settings
|
||||
@@ -10,12 +11,44 @@ from db.dal import user_dal, payment_dal, panel_sync_dal
|
||||
from db.models import Payment, PanelSyncStatus
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
|
||||
from bot.keyboards.inline.admin_keyboards import get_back_to_admin_panel_keyboard
|
||||
from bot.keyboards.inline.admin_keyboards import (
|
||||
get_back_to_admin_panel_keyboard,
|
||||
get_back_to_user_management_keyboard,
|
||||
)
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
|
||||
router = Router(name="admin_statistics_router")
|
||||
|
||||
|
||||
def _format_rating_user_label(user_row: Dict[str, object],
|
||||
bot_username: Optional[str] = None) -> str:
|
||||
user_id = int(user_row.get("user_id", 0) or 0)
|
||||
username = user_row.get("username")
|
||||
first_name = user_row.get("first_name")
|
||||
user_id_text = str(user_id)
|
||||
user_id_html = html.escape(user_id_text)
|
||||
|
||||
if bot_username:
|
||||
safe_bot_username = html.escape(bot_username)
|
||||
user_id_html = (
|
||||
f'<a href="https://t.me/{safe_bot_username}?start=admin_user_{user_id_text}">'
|
||||
f"{user_id_html}</a>"
|
||||
)
|
||||
|
||||
parts: List[str] = []
|
||||
if username:
|
||||
parts.append(f"@{html.escape(str(username))}")
|
||||
elif first_name:
|
||||
parts.append(html.escape(str(first_name)))
|
||||
|
||||
if not parts:
|
||||
parts.append(f"ID {user_id_html}")
|
||||
else:
|
||||
parts.append(f"(ID {user_id_html})")
|
||||
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
async def show_statistics_handler(callback: types.CallbackQuery,
|
||||
i18n_data: dict, settings: Settings,
|
||||
session: AsyncSession):
|
||||
@@ -34,30 +67,30 @@ async def show_statistics_handler(callback: types.CallbackQuery,
|
||||
user_stats = await user_dal.get_enhanced_user_statistics(session)
|
||||
|
||||
stats_text_parts.append(
|
||||
f"\n<b>👥 {_('admin_enhanced_users_stats_header', default='Пользователи')}</b>"
|
||||
f"\n<b>👥 {_('admin_enhanced_users_stats_header')}</b>"
|
||||
)
|
||||
stats_text_parts.append(
|
||||
f"📊 {_('admin_user_stats_total_label', default='Всего')}: <b>{user_stats['total_users']}</b>"
|
||||
f"📊 {_('admin_user_stats_total_label')}: <b>{user_stats['total_users']}</b>"
|
||||
)
|
||||
# Removed: Active today moved to panel stats
|
||||
stats_text_parts.append(
|
||||
f"💳 {_('admin_user_stats_paid_subs_label', default='С платной подпиской')}: <b>{user_stats['paid_subscriptions']}</b>"
|
||||
f"💳 {_('admin_user_stats_paid_subs_label')}: <b>{user_stats['paid_subscriptions']}</b>"
|
||||
)
|
||||
stats_text_parts.append(
|
||||
f"🆓 {_('admin_user_stats_trial_label', default='На пробном периоде')}: <b>{user_stats['trial_users']}</b>"
|
||||
f"🆓 {_('admin_user_stats_trial_label')}: <b>{user_stats['trial_users']}</b>"
|
||||
)
|
||||
stats_text_parts.append(
|
||||
f"😴 {_('admin_user_stats_inactive_label', default='Неактивных')}: <b>{user_stats['inactive_users']}</b>"
|
||||
f"😴 {_('admin_user_stats_inactive_label')}: <b>{user_stats['inactive_users']}</b>"
|
||||
)
|
||||
stats_text_parts.append(
|
||||
f"🚫 {_('admin_user_stats_banned_label', default='Заблокированных')}: <b>{user_stats['banned_users']}</b>"
|
||||
f"🚫 {_('admin_user_stats_banned_label')}: <b>{user_stats['banned_users']}</b>"
|
||||
)
|
||||
stats_text_parts.append(
|
||||
f"🎁 {_('admin_user_stats_referral_label', default='Привлечено по реферальной программе')}: <b>{user_stats['referral_users']}</b>"
|
||||
f"🎁 {_('admin_user_stats_referral_label')}: <b>{user_stats['referral_users']}</b>"
|
||||
)
|
||||
|
||||
# Panel Statistics - moved above financial
|
||||
stats_text_parts.append(f"\n<b>🖥 {_('admin_panel_stats_header', default='Статистика панели')}</b>")
|
||||
stats_text_parts.append(f"\n<b>🖥 {_('admin_panel_stats_header')}</b>")
|
||||
|
||||
try:
|
||||
async with PanelApiService(settings) as panel_service:
|
||||
@@ -80,12 +113,12 @@ async def show_statistics_handler(callback: types.CallbackQuery,
|
||||
total_users = users.get('totalUsers', 0)
|
||||
online_now = online_stats.get('onlineNow', 0)
|
||||
|
||||
stats_text_parts.append(f"🟢 {_('admin_panel_online_label', default='Онлайн')}: <b>{online_now}</b>")
|
||||
stats_text_parts.append(f"📊 {_('admin_panel_active_label', default='Активных')}: <b>{active_users}</b>")
|
||||
stats_text_parts.append(f"🔴 {_('admin_panel_disabled_label', default='Отключенных')}: <b>{disabled_users}</b>")
|
||||
stats_text_parts.append(f"⏰ {_('admin_panel_expired_label', default='Истекшие')}: <b>{expired_users}</b>")
|
||||
stats_text_parts.append(f"⚠️ {_('admin_panel_limited_label', default='Ограниченные')}: <b>{limited_users}</b>")
|
||||
stats_text_parts.append(f"👥 {_('admin_panel_total_users_label', default='Всего пользователей')}: <b>{total_users}</b>")
|
||||
stats_text_parts.append(f"🟢 {_('admin_panel_online_label')}: <b>{online_now}</b>")
|
||||
stats_text_parts.append(f"📊 {_('admin_panel_active_label')}: <b>{active_users}</b>")
|
||||
stats_text_parts.append(f"🔴 {_('admin_panel_disabled_label')}: <b>{disabled_users}</b>")
|
||||
stats_text_parts.append(f"⏰ {_('admin_panel_expired_label')}: <b>{expired_users}</b>")
|
||||
stats_text_parts.append(f"⚠️ {_('admin_panel_limited_label')}: <b>{limited_users}</b>")
|
||||
stats_text_parts.append(f"👥 {_('admin_panel_total_users_label')}: <b>{total_users}</b>")
|
||||
|
||||
# System resources
|
||||
memory = system_stats.get('memory', {})
|
||||
@@ -93,9 +126,9 @@ async def show_statistics_handler(callback: types.CallbackQuery,
|
||||
memory_total = memory.get('total', 1)
|
||||
memory_used = memory.get('used', 0)
|
||||
memory_usage = (memory_used / memory_total) * 100 if memory_total > 0 else 0
|
||||
stats_text_parts.append(f"💾 {_('admin_panel_memory_usage_label', default='Использование RAM')}: <b>{memory_usage:.1f}%</b>")
|
||||
stats_text_parts.append(f"💾 {_('admin_panel_memory_usage_label')}: <b>{memory_usage:.1f}%</b>")
|
||||
else:
|
||||
stats_text_parts.append(f"⚠️ {_('admin_panel_system_stats_error', default='Ошибка получения системной статистики')}")
|
||||
stats_text_parts.append(f"⚠️ {_('admin_panel_system_stats_error')}")
|
||||
|
||||
# Bandwidth stats
|
||||
if bandwidth_stats:
|
||||
@@ -107,13 +140,13 @@ async def show_statistics_handler(callback: types.CallbackQuery,
|
||||
|
||||
if week_traffic:
|
||||
week_total = week_traffic.get('current', '0 B')
|
||||
stats_text_parts.append(f"📊 {_('admin_panel_traffic_week_label', default='Трафик за неделю')}: <b>{week_total}</b>")
|
||||
stats_text_parts.append(f"📊 {_('admin_panel_traffic_week_label')}: <b>{week_total}</b>")
|
||||
|
||||
if month_traffic:
|
||||
month_total = month_traffic.get('current', '0 B')
|
||||
stats_text_parts.append(f"📊 {_('admin_panel_traffic_month_label', default='Трафик за месяц')}: <b>{month_total}</b>")
|
||||
stats_text_parts.append(f"📊 {_('admin_panel_traffic_month_label')}: <b>{month_total}</b>")
|
||||
else:
|
||||
stats_text_parts.append(f"⚠️ {_('admin_panel_bandwidth_stats_error', default='Ошибка получения статистики трафика')}")
|
||||
stats_text_parts.append(f"⚠️ {_('admin_panel_bandwidth_stats_error')}")
|
||||
|
||||
# Nodes stats
|
||||
if nodes_stats and 'lastSevenDays' in nodes_stats:
|
||||
@@ -124,35 +157,35 @@ async def show_statistics_handler(callback: types.CallbackQuery,
|
||||
unique_nodes.add(node_data.get('nodeName', ''))
|
||||
total_nodes_count = len(unique_nodes)
|
||||
# Assume all nodes are active since we don't have status info
|
||||
stats_text_parts.append(f"🔗 {_('admin_panel_nodes_label', default='Активных нод')}: <b>{total_nodes_count}/{total_nodes_count}</b>")
|
||||
stats_text_parts.append(f"🔗 {_('admin_panel_nodes_label')}: <b>{total_nodes_count}/{total_nodes_count}</b>")
|
||||
else:
|
||||
# Use nodes total from system stats as fallback
|
||||
nodes_info = system_stats.get('nodes', {}) if system_stats else {}
|
||||
total_online = nodes_info.get('totalOnline', 0)
|
||||
stats_text_parts.append(f"🔗 {_('admin_panel_nodes_label', default='Активных нод')}: <b>{total_online}</b>")
|
||||
stats_text_parts.append(f"🔗 {_('admin_panel_nodes_label')}: <b>{total_online}</b>")
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to fetch panel statistics: {e}", exc_info=True)
|
||||
stats_text_parts.append(f"❌ {_('admin_panel_stats_fetch_error', default='Ошибка получения данных с панели')}")
|
||||
stats_text_parts.append(f"⚠️ {_('admin_panel_stats_error_details', default='Детали')}: {str(e)}")
|
||||
stats_text_parts.append(f"❌ {_('admin_panel_stats_fetch_error')}")
|
||||
stats_text_parts.append(f"⚠️ {_('admin_panel_stats_error_details')}: {str(e)}")
|
||||
|
||||
# Financial statistics
|
||||
financial_stats = await payment_dal.get_financial_statistics(session)
|
||||
|
||||
stats_text_parts.append(
|
||||
f"\n<b>💰 {_('admin_financial_stats_header', default='Финансовая статистика')}</b>"
|
||||
f"\n<b>💰 {_('admin_financial_stats_header')}</b>"
|
||||
)
|
||||
stats_text_parts.append(
|
||||
f"📅 {_('admin_financial_today_label', default='За сегодня')}: <b>{financial_stats['today_revenue']:.2f} RUB</b> ({financial_stats['today_payments_count']} {_('admin_financial_payments_label', default='платежей')})"
|
||||
f"📅 {_('admin_financial_today_label')}: <b>{financial_stats['today_revenue']:.2f} RUB</b> ({financial_stats['today_payments_count']} {_('admin_financial_payments_label')})"
|
||||
)
|
||||
stats_text_parts.append(
|
||||
f"📅 {_('admin_financial_week_label', default='За неделю')}: <b>{financial_stats['week_revenue']:.2f} RUB</b>"
|
||||
f"📅 {_('admin_financial_week_label')}: <b>{financial_stats['week_revenue']:.2f} RUB</b>"
|
||||
)
|
||||
stats_text_parts.append(
|
||||
f"📅 {_('admin_financial_month_label', default='За месяц')}: <b>{financial_stats['month_revenue']:.2f} RUB</b>"
|
||||
f"📅 {_('admin_financial_month_label')}: <b>{financial_stats['month_revenue']:.2f} RUB</b>"
|
||||
)
|
||||
stats_text_parts.append(
|
||||
f"🏆 {_('admin_financial_all_time_label', default='За все время')}: <b>{financial_stats['all_time_revenue']:.2f} RUB</b>"
|
||||
f"🏆 {_('admin_financial_all_time_label')}: <b>{financial_stats['all_time_revenue']:.2f} RUB</b>"
|
||||
)
|
||||
|
||||
last_payments_models: List[
|
||||
@@ -162,8 +195,21 @@ async def show_statistics_handler(callback: types.CallbackQuery,
|
||||
stats_text_parts.append(
|
||||
f"\n<b>{_('admin_stats_recent_payments_header')}</b>")
|
||||
for payment in last_payments_models:
|
||||
status_emoji = "✅" if payment.status == 'succeeded' else (
|
||||
"⏳" if payment.status in ['pending', 'pending_yookassa', 'pending_freekassa'] else "❌")
|
||||
pending_statuses = [
|
||||
"pending",
|
||||
"pending_yookassa",
|
||||
"pending_freekassa",
|
||||
"pending_platega",
|
||||
"pending_severpay",
|
||||
"pending_cryptopay",
|
||||
]
|
||||
status_emoji = (
|
||||
"✅"
|
||||
if payment.status == "succeeded"
|
||||
else "⏳"
|
||||
if payment.status in pending_statuses
|
||||
else "❌"
|
||||
)
|
||||
|
||||
user_info = f"User {payment.user_id}"
|
||||
if payment.user and payment.user.username:
|
||||
@@ -242,3 +288,104 @@ async def show_statistics_handler(callback: types.CallbackQuery,
|
||||
reply_markup=get_back_to_admin_panel_keyboard(
|
||||
current_lang, i18n))
|
||||
break
|
||||
|
||||
|
||||
async def show_user_ratings_handler(
|
||||
callback: types.CallbackQuery,
|
||||
i18n_data: dict,
|
||||
settings: Settings,
|
||||
session: AsyncSession,
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n or not callback.message:
|
||||
await callback.answer("Error displaying ratings.", show_alert=True)
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
top_limit = 10
|
||||
bot_username: Optional[str] = None
|
||||
try:
|
||||
me = await callback.bot.get_me()
|
||||
bot_username = me.username
|
||||
except Exception as e_get_me:
|
||||
logging.warning("Failed to resolve bot username for ratings links: %s",
|
||||
e_get_me)
|
||||
|
||||
traffic_top = await user_dal.get_top_users_by_traffic_used(session, limit=top_limit)
|
||||
lifetime_traffic_top = await user_dal.get_top_users_by_lifetime_traffic_used(
|
||||
session, limit=top_limit
|
||||
)
|
||||
invited_top = await user_dal.get_top_users_by_referrals_count(session, limit=top_limit)
|
||||
revenue_top = await user_dal.get_top_users_by_referral_revenue(session, limit=top_limit)
|
||||
|
||||
text_parts: List[str] = [
|
||||
_("admin_user_ratings_header", top_limit=top_limit),
|
||||
"",
|
||||
f"<b>{_('admin_user_ratings_traffic_month_title')}</b>",
|
||||
]
|
||||
|
||||
if traffic_top:
|
||||
for idx, row in enumerate(traffic_top, start=1):
|
||||
traffic_gb = float(row.get("traffic_used_bytes") or 0) / (1024**3)
|
||||
text_parts.append(
|
||||
_(
|
||||
"admin_user_ratings_traffic_item",
|
||||
rank=idx,
|
||||
user=_format_rating_user_label(row, bot_username),
|
||||
traffic_gb=f"{traffic_gb:.2f}",
|
||||
)
|
||||
)
|
||||
else:
|
||||
text_parts.append(_("admin_user_ratings_empty"))
|
||||
|
||||
text_parts.extend(["", f"<b>{_('admin_user_ratings_traffic_lifetime_title')}</b>"])
|
||||
if lifetime_traffic_top:
|
||||
for idx, row in enumerate(lifetime_traffic_top, start=1):
|
||||
traffic_gb = float(row.get("lifetime_used_traffic_bytes") or 0) / (1024**3)
|
||||
text_parts.append(
|
||||
_(
|
||||
"admin_user_ratings_traffic_item",
|
||||
rank=idx,
|
||||
user=_format_rating_user_label(row, bot_username),
|
||||
traffic_gb=f"{traffic_gb:.2f}",
|
||||
)
|
||||
)
|
||||
else:
|
||||
text_parts.append(_("admin_user_ratings_empty"))
|
||||
|
||||
text_parts.extend(["", f"<b>{_('admin_user_ratings_invited_title')}</b>"])
|
||||
if invited_top:
|
||||
for idx, row in enumerate(invited_top, start=1):
|
||||
text_parts.append(
|
||||
_(
|
||||
"admin_user_ratings_invited_item",
|
||||
rank=idx,
|
||||
user=_format_rating_user_label(row, bot_username),
|
||||
invited_count=int(row.get("invited_count") or 0),
|
||||
)
|
||||
)
|
||||
else:
|
||||
text_parts.append(_("admin_user_ratings_empty"))
|
||||
|
||||
text_parts.extend(["", f"<b>{_('admin_user_ratings_revenue_title')}</b>"])
|
||||
if revenue_top:
|
||||
for idx, row in enumerate(revenue_top, start=1):
|
||||
text_parts.append(
|
||||
_(
|
||||
"admin_user_ratings_revenue_item",
|
||||
rank=idx,
|
||||
user=_format_rating_user_label(row, bot_username),
|
||||
revenue=f"{float(row.get('referral_revenue') or 0):.2f}",
|
||||
)
|
||||
)
|
||||
else:
|
||||
text_parts.append(_("admin_user_ratings_empty"))
|
||||
|
||||
await callback.message.edit_text(
|
||||
"\n".join(text_parts),
|
||||
reply_markup=get_back_to_user_management_keyboard(current_lang, i18n),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@ from aiogram import Router, types, Bot
|
||||
from aiogram.filters import Command
|
||||
from typing import Optional, Union
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import update, or_
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from config.settings import Settings
|
||||
@@ -10,14 +11,121 @@ from bot.services.panel_api_service import PanelApiService
|
||||
from bot.services.notification_service import NotificationService
|
||||
|
||||
from db.dal import user_dal, subscription_dal, panel_sync_dal
|
||||
from db.models import Subscription
|
||||
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
|
||||
router = Router(name="admin_sync_router")
|
||||
|
||||
|
||||
async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
||||
settings: Settings, i18n_instance: JsonI18n) -> dict:
|
||||
def _normalize_panel_email(value: Optional[str]) -> Optional[str]:
|
||||
email = (value or "").strip().lower()
|
||||
return email or None
|
||||
|
||||
|
||||
def _extract_lifetime_used_traffic_bytes(panel_user_data: dict) -> Optional[int]:
|
||||
user_traffic = panel_user_data.get("userTraffic") or {}
|
||||
raw_value = (
|
||||
user_traffic.get("lifetimeUsedTrafficBytes")
|
||||
if isinstance(user_traffic, dict)
|
||||
else None
|
||||
)
|
||||
if raw_value is None:
|
||||
raw_value = panel_user_data.get("lifetimeUsedTrafficBytes")
|
||||
|
||||
try:
|
||||
if raw_value is None:
|
||||
return None
|
||||
return int(raw_value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
async def _bind_panel_email_to_user(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
existing_user,
|
||||
email_from_panel: Optional[str],
|
||||
panel_uuid: str,
|
||||
) -> tuple[object, bool]:
|
||||
"""Bind panel email to a local user without violating the unique email index.
|
||||
|
||||
Panel email is treated as verified because it comes from the operator-managed
|
||||
panel. If the same email already belongs to an email-only local account for
|
||||
this panel user, merge that account into the Telegram/local user.
|
||||
"""
|
||||
if not email_from_panel:
|
||||
return existing_user, False
|
||||
|
||||
if existing_user.email == email_from_panel:
|
||||
if not existing_user.email_verified_at:
|
||||
existing_user.email_verified_at = datetime.now(timezone.utc)
|
||||
return existing_user, True
|
||||
return existing_user, False
|
||||
|
||||
user_with_email = await user_dal.get_user_by_email(session, email_from_panel)
|
||||
if user_with_email and user_with_email.user_id != existing_user.user_id:
|
||||
can_merge_email_identity = (
|
||||
not user_with_email.telegram_id
|
||||
and user_with_email.panel_user_uuid in (None, panel_uuid)
|
||||
and (not existing_user.email or existing_user.email == email_from_panel)
|
||||
)
|
||||
if can_merge_email_identity:
|
||||
try:
|
||||
merged_user = await user_dal.merge_users(
|
||||
session,
|
||||
source_user_id=user_with_email.user_id,
|
||||
target_user_id=existing_user.user_id,
|
||||
)
|
||||
if not merged_user.email:
|
||||
merged_user.email = email_from_panel
|
||||
if not merged_user.email_verified_at:
|
||||
merged_user.email_verified_at = datetime.now(timezone.utc)
|
||||
logging.info(
|
||||
"Merged email-only user %s into user %s while binding panel email %s for panel UUID %s.",
|
||||
user_with_email.user_id,
|
||||
merged_user.user_id,
|
||||
email_from_panel,
|
||||
panel_uuid,
|
||||
)
|
||||
return merged_user, True
|
||||
except Exception as merge_error:
|
||||
logging.warning(
|
||||
"Could not merge email-only user %s into user %s for panel email %s: %s",
|
||||
user_with_email.user_id,
|
||||
existing_user.user_id,
|
||||
email_from_panel,
|
||||
merge_error,
|
||||
)
|
||||
return existing_user, False
|
||||
|
||||
logging.warning(
|
||||
"Panel email %s for panel UUID %s is already linked to local user %s; "
|
||||
"skipping email binding for user %s.",
|
||||
email_from_panel,
|
||||
panel_uuid,
|
||||
user_with_email.user_id,
|
||||
existing_user.user_id,
|
||||
)
|
||||
return existing_user, False
|
||||
|
||||
existing_user.email = email_from_panel
|
||||
existing_user.email_verified_at = datetime.now(timezone.utc)
|
||||
logging.info(
|
||||
"Bound panel email %s to local user %s for panel UUID %s.",
|
||||
email_from_panel,
|
||||
existing_user.user_id,
|
||||
panel_uuid,
|
||||
)
|
||||
return existing_user, True
|
||||
|
||||
|
||||
async def perform_sync(
|
||||
panel_service: PanelApiService,
|
||||
session: AsyncSession,
|
||||
settings: Settings,
|
||||
i18n_instance: JsonI18n,
|
||||
) -> dict:
|
||||
"""
|
||||
Perform panel synchronization and return results
|
||||
Returns dict with status, details, and sync statistics
|
||||
@@ -27,7 +135,7 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
||||
users_updated = 0
|
||||
subscriptions_synced_count = 0
|
||||
sync_errors = []
|
||||
|
||||
|
||||
# Additional counters for detailed logging
|
||||
users_without_telegram_id = 0
|
||||
users_not_found_in_db = 0
|
||||
@@ -52,7 +160,12 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
||||
session, "success", status_msg, 0, 0
|
||||
)
|
||||
await session.commit()
|
||||
return {"status": "success", "details": status_msg, "users_synced": 0, "subs_synced": 0}
|
||||
return {
|
||||
"status": "success",
|
||||
"details": status_msg,
|
||||
"users_synced": 0,
|
||||
"subs_synced": 0,
|
||||
}
|
||||
|
||||
total_panel_users = len(panel_users_data)
|
||||
logging.info(f"Starting sync for {total_panel_users} panel users.")
|
||||
@@ -61,12 +174,17 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
||||
try:
|
||||
panel_records_checked += 1
|
||||
panel_uuid = panel_user_dict.get("uuid")
|
||||
panel_subscription_uuid = panel_user_dict.get("subscriptionUuid") or panel_user_dict.get("shortUuid")
|
||||
panel_subscription_uuid = panel_user_dict.get("subscriptionUuid") or panel_user_dict.get(
|
||||
"shortUuid"
|
||||
)
|
||||
telegram_id_from_panel = panel_user_dict.get("telegramId")
|
||||
email_from_panel = _normalize_panel_email(panel_user_dict.get("email"))
|
||||
|
||||
if not panel_uuid:
|
||||
sync_errors.append(f"Panel user missing UUID: {panel_user_dict}")
|
||||
logging.warning(f"Skipping panel user without UUID: {panel_user_dict}")
|
||||
logging.warning(
|
||||
f"Skipping panel user without UUID: {panel_user_dict}"
|
||||
)
|
||||
continue
|
||||
|
||||
# Track users without telegram ID
|
||||
@@ -75,22 +193,49 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
||||
|
||||
# Try to find existing user in local DB
|
||||
existing_user = None
|
||||
|
||||
|
||||
# First, try to find by telegram ID if available
|
||||
if telegram_id_from_panel:
|
||||
existing_user = await user_dal.get_user_by_id(session, telegram_id_from_panel)
|
||||
existing_user = await user_dal.get_user_by_telegram_id(
|
||||
session, telegram_id_from_panel
|
||||
)
|
||||
if not existing_user:
|
||||
existing_user = await user_dal.get_user_by_id(
|
||||
session, telegram_id_from_panel
|
||||
)
|
||||
if existing_user:
|
||||
logging.debug(f"Found user by telegramId {telegram_id_from_panel}")
|
||||
|
||||
# If not found by telegram ID, try to find by panel UUID
|
||||
logging.debug(
|
||||
f"Found user by telegramId {telegram_id_from_panel}"
|
||||
)
|
||||
|
||||
# If not found by telegram ID, try to find by panel UUID.
|
||||
# The panel UUID is the strongest local link for subscription sync.
|
||||
if not existing_user:
|
||||
existing_user = await user_dal.get_user_by_panel_uuid(session, panel_uuid)
|
||||
existing_user = await user_dal.get_user_by_panel_uuid(
|
||||
session, panel_uuid
|
||||
)
|
||||
if existing_user:
|
||||
logging.info(f"Found user by panel UUID {panel_uuid}, telegramId: {existing_user.user_id}")
|
||||
logging.info(
|
||||
f"Found user by panel UUID {panel_uuid}, telegramId: {existing_user.user_id}"
|
||||
)
|
||||
# Update telegram ID if it was missing in panel data but we have local user
|
||||
if telegram_id_from_panel and existing_user.user_id != telegram_id_from_panel:
|
||||
logging.warning(f"TelegramId mismatch: panel={telegram_id_from_panel}, local={existing_user.user_id}")
|
||||
|
||||
if (
|
||||
telegram_id_from_panel
|
||||
and existing_user.user_id != telegram_id_from_panel
|
||||
):
|
||||
logging.warning(
|
||||
f"TelegramId mismatch: panel={telegram_id_from_panel}, local={existing_user.user_id}"
|
||||
)
|
||||
|
||||
# Finally, fall back to email. This mainly catches panel users that
|
||||
# were first imported as email-only identities.
|
||||
if not existing_user and email_from_panel:
|
||||
existing_user = await user_dal.get_user_by_email(
|
||||
session, email_from_panel
|
||||
)
|
||||
if existing_user:
|
||||
logging.debug(f"Found user by email {email_from_panel}")
|
||||
|
||||
if not existing_user:
|
||||
users_not_found_in_db += 1
|
||||
if telegram_id_from_panel:
|
||||
@@ -98,28 +243,67 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
||||
try:
|
||||
user_data = {
|
||||
"user_id": telegram_id_from_panel,
|
||||
"telegram_id": telegram_id_from_panel,
|
||||
"email": email_from_panel,
|
||||
"email_verified_at": (
|
||||
datetime.now(timezone.utc)
|
||||
if email_from_panel
|
||||
else None
|
||||
),
|
||||
"username": None, # Username will be updated when user interacts with bot
|
||||
"first_name": None, # Panel doesn't provide this info
|
||||
"last_name": None, # Panel doesn't provide this info
|
||||
"last_name": None, # Panel doesn't provide this info
|
||||
"language_code": "ru", # Default language
|
||||
"panel_user_uuid": panel_uuid,
|
||||
"is_banned": False,
|
||||
"referred_by_id": None
|
||||
"referred_by_id": None,
|
||||
}
|
||||
|
||||
new_user, was_created = await user_dal.create_user(session, user_data)
|
||||
|
||||
new_user, was_created = await user_dal.create_user(
|
||||
session, user_data
|
||||
)
|
||||
if was_created:
|
||||
users_created += 1
|
||||
logging.info(f"Created new user {telegram_id_from_panel} from panel sync with UUID {panel_uuid}")
|
||||
|
||||
logging.info(
|
||||
f"Created new user {telegram_id_from_panel} from panel sync with UUID {panel_uuid}"
|
||||
)
|
||||
|
||||
existing_user = new_user
|
||||
|
||||
|
||||
except Exception as e_create:
|
||||
sync_errors.append(f"Error creating user {telegram_id_from_panel}: {str(e_create)}")
|
||||
logging.error(f"Error creating user {telegram_id_from_panel}: {e_create}")
|
||||
sync_errors.append(
|
||||
f"Error creating user {telegram_id_from_panel}: {str(e_create)}"
|
||||
)
|
||||
logging.error(
|
||||
f"Error creating user {telegram_id_from_panel}: {e_create}"
|
||||
)
|
||||
continue
|
||||
elif email_from_panel:
|
||||
try:
|
||||
new_user, was_created = await user_dal.create_email_user(
|
||||
session,
|
||||
email=email_from_panel,
|
||||
language_code="ru",
|
||||
)
|
||||
new_user.panel_user_uuid = panel_uuid
|
||||
if was_created:
|
||||
users_created += 1
|
||||
logging.info(
|
||||
f"Created new email user {new_user.user_id} from panel sync with UUID {panel_uuid}"
|
||||
)
|
||||
existing_user = new_user
|
||||
except Exception as e_create_email:
|
||||
sync_errors.append(
|
||||
f"Error creating email user {email_from_panel}: {str(e_create_email)}"
|
||||
)
|
||||
logging.error(
|
||||
f"Error creating email user {email_from_panel}: {e_create_email}"
|
||||
)
|
||||
continue
|
||||
else:
|
||||
logging.debug(f"Panel user with UUID {panel_uuid} (no telegramId) not found in local DB - skipping")
|
||||
logging.debug(
|
||||
f"Panel user with UUID {panel_uuid} (no telegramId) not found in local DB - skipping"
|
||||
)
|
||||
continue
|
||||
|
||||
# User found in local DB
|
||||
@@ -134,22 +318,58 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
||||
existing_user.panel_user_uuid = panel_uuid
|
||||
user_was_updated = True
|
||||
users_uuid_updated += 1
|
||||
logging.info(f"Updated panel UUID for user {actual_user_id}: {panel_uuid}")
|
||||
logging.info(
|
||||
f"Updated panel UUID for user {actual_user_id}: {panel_uuid}"
|
||||
)
|
||||
existing_user, email_was_bound = await _bind_panel_email_to_user(
|
||||
session,
|
||||
existing_user=existing_user,
|
||||
email_from_panel=email_from_panel,
|
||||
panel_uuid=panel_uuid,
|
||||
)
|
||||
if email_was_bound:
|
||||
user_was_updated = True
|
||||
if (
|
||||
telegram_id_from_panel
|
||||
and existing_user.telegram_id != telegram_id_from_panel
|
||||
):
|
||||
existing_user.telegram_id = telegram_id_from_panel
|
||||
user_was_updated = True
|
||||
|
||||
lifetime_used = _extract_lifetime_used_traffic_bytes(panel_user_dict)
|
||||
if (
|
||||
lifetime_used is not None
|
||||
and existing_user.lifetime_used_traffic_bytes != lifetime_used
|
||||
):
|
||||
existing_user.lifetime_used_traffic_bytes = lifetime_used
|
||||
user_was_updated = True
|
||||
|
||||
# Ensure panel description contains Telegram fields
|
||||
try:
|
||||
if panel_uuid and existing_user:
|
||||
description_text = "\n".join([
|
||||
existing_user.username or "",
|
||||
existing_user.first_name or "",
|
||||
existing_user.last_name or "",
|
||||
])
|
||||
description_text = "\n".join(
|
||||
line for line in [
|
||||
existing_user.email or "",
|
||||
existing_user.username or "",
|
||||
existing_user.first_name or "",
|
||||
existing_user.last_name or "",
|
||||
] if line
|
||||
)
|
||||
# Update description only when it differs from the current one on panel
|
||||
current_panel_description = (panel_user_dict.get("description") or "").strip()
|
||||
current_panel_description = (
|
||||
panel_user_dict.get("description") or ""
|
||||
).strip()
|
||||
desired_description = description_text.strip()
|
||||
if desired_description and desired_description != current_panel_description:
|
||||
if (
|
||||
desired_description
|
||||
and desired_description != current_panel_description
|
||||
):
|
||||
await panel_service.update_user_details_on_panel(
|
||||
panel_uuid, {"description": description_text}
|
||||
panel_uuid, {
|
||||
"description": description_text,
|
||||
**({"email": existing_user.email} if existing_user.email else {}),
|
||||
**({"telegramId": existing_user.telegram_id} if existing_user.telegram_id else {}),
|
||||
}
|
||||
)
|
||||
except Exception as e_desc:
|
||||
logging.warning(
|
||||
@@ -159,13 +379,13 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
||||
# Sync subscription data
|
||||
panel_expire_at_iso = panel_user_dict.get("expireAt")
|
||||
panel_status = panel_user_dict.get("status", "UNKNOWN")
|
||||
|
||||
|
||||
if panel_expire_at_iso:
|
||||
try:
|
||||
panel_expire_at = datetime.fromisoformat(
|
||||
panel_expire_at_iso.replace("Z", "+00:00")
|
||||
)
|
||||
|
||||
|
||||
# Prefer syncing by concrete subscription UUID (shortUuid/subscriptionUuid)
|
||||
subscription_uuid_from_panel = (
|
||||
panel_user_dict.get("subscriptionUuid")
|
||||
@@ -173,6 +393,27 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
||||
)
|
||||
|
||||
if subscription_uuid_from_panel:
|
||||
# Если панель говорит, что подписка ACTIVE — сначала деактивируем все другие активные
|
||||
if panel_status == "ACTIVE":
|
||||
await session.execute(
|
||||
update(Subscription)
|
||||
.where(
|
||||
Subscription.panel_user_uuid == panel_uuid,
|
||||
Subscription.is_active.is_(True),
|
||||
or_(
|
||||
Subscription.panel_subscription_uuid
|
||||
!= subscription_uuid_from_panel,
|
||||
Subscription.panel_subscription_uuid.is_(
|
||||
None
|
||||
),
|
||||
),
|
||||
)
|
||||
.values(
|
||||
is_active=False,
|
||||
status_from_panel="INACTIVE",
|
||||
)
|
||||
)
|
||||
|
||||
# Try to find subscription by its panel_subscription_uuid first (idempotent)
|
||||
existing_sub_by_uuid = (
|
||||
await subscription_dal.get_subscription_by_panel_subscription_uuid(
|
||||
@@ -197,7 +438,8 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
||||
subscriptions_updated += 1
|
||||
user_was_updated = True
|
||||
logging.info(
|
||||
f"Synced existing subscription {existing_sub_by_uuid.subscription_id} for user {actual_user_id}: expires {panel_expire_at}, status {panel_status}"
|
||||
f"Synced existing subscription {existing_sub_by_uuid.subscription_id} "
|
||||
f"for user {actual_user_id}: expires {panel_expire_at}, status {panel_status}"
|
||||
)
|
||||
else:
|
||||
# Create a new subscription only when we have a concrete subscription UUID
|
||||
@@ -212,6 +454,7 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
||||
"is_active": panel_status == "ACTIVE",
|
||||
"status_from_panel": panel_status,
|
||||
"traffic_limit_bytes": settings.user_traffic_limit_bytes,
|
||||
"auto_renew_enabled": False,
|
||||
}
|
||||
created_sub = await subscription_dal.upsert_subscription(
|
||||
session, sub_payload
|
||||
@@ -220,12 +463,15 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
||||
subscriptions_created += 1
|
||||
user_was_updated = True
|
||||
logging.info(
|
||||
f"Created subscription {created_sub.subscription_id} for user {actual_user_id} by panel_sub_uuid {subscription_uuid_from_panel}"
|
||||
f"Created subscription {created_sub.subscription_id} "
|
||||
f"for user {actual_user_id} by panel_sub_uuid {subscription_uuid_from_panel}"
|
||||
)
|
||||
else:
|
||||
# No subscription UUID from panel: only update an already active subscription for this user/panel UUID
|
||||
active_sub = await subscription_dal.get_active_subscription_by_user_id(
|
||||
session, actual_user_id, panel_uuid
|
||||
active_sub = (
|
||||
await subscription_dal.get_active_subscription_by_user_id(
|
||||
session, actual_user_id, panel_uuid
|
||||
)
|
||||
)
|
||||
if active_sub:
|
||||
await subscription_dal.update_subscription(
|
||||
@@ -241,23 +487,30 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
||||
subscriptions_updated += 1
|
||||
user_was_updated = True
|
||||
logging.info(
|
||||
f"Updated active subscription {active_sub.subscription_id} for user {actual_user_id}: expires {panel_expire_at}, status {panel_status}"
|
||||
f"Updated active subscription {active_sub.subscription_id} "
|
||||
f"for user {actual_user_id}: expires {panel_expire_at}, status {panel_status}"
|
||||
)
|
||||
else:
|
||||
# Without a concrete subscription UUID we avoid creating new records to keep sync idempotent
|
||||
logging.debug(
|
||||
f"No subscriptionUuid for panel user {panel_uuid}; skipped creation for user {actual_user_id}"
|
||||
)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
sync_errors.append(f"Error syncing subscription for user {actual_user_id}: {str(e)}")
|
||||
logging.error(f"Error syncing subscription for user {actual_user_id}: {e}")
|
||||
sync_errors.append(
|
||||
f"Error syncing subscription for user {actual_user_id}: {str(e)}"
|
||||
)
|
||||
logging.error(
|
||||
f"Error syncing subscription for user {actual_user_id}: {e}"
|
||||
)
|
||||
|
||||
if user_was_updated:
|
||||
users_updated += 1
|
||||
|
||||
|
||||
except Exception as e_user:
|
||||
sync_errors.append(f"Error processing panel user {panel_user_dict.get('uuid', 'unknown')}: {str(e_user)}")
|
||||
sync_errors.append(
|
||||
f"Error processing panel user {panel_user_dict.get('uuid', 'unknown')}: {str(e_user)}"
|
||||
)
|
||||
logging.error(f"Error syncing user: {e_user}")
|
||||
|
||||
# Update sync status
|
||||
@@ -266,14 +519,26 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
||||
default_lang = settings.DEFAULT_LANGUAGE
|
||||
additional_stats = ""
|
||||
if users_without_telegram_id > 0:
|
||||
additional_stats += i18n_instance.gettext(default_lang, "admin_sync_no_telegram_id", count=users_without_telegram_id)
|
||||
additional_stats += i18n_instance.gettext(
|
||||
default_lang,
|
||||
"admin_sync_no_telegram_id",
|
||||
count=users_without_telegram_id,
|
||||
)
|
||||
if users_not_found_in_db > 0:
|
||||
additional_stats += i18n_instance.gettext(default_lang, "admin_sync_not_found_in_db", count=users_not_found_in_db)
|
||||
additional_stats += i18n_instance.gettext(
|
||||
default_lang,
|
||||
"admin_sync_not_found_in_db",
|
||||
count=users_not_found_in_db,
|
||||
)
|
||||
if sync_errors:
|
||||
additional_stats += i18n_instance.gettext(default_lang, "admin_sync_errors", count=len(sync_errors))
|
||||
additional_stats += i18n_instance.gettext(
|
||||
default_lang, "admin_sync_errors", count=len(sync_errors)
|
||||
)
|
||||
|
||||
# Build full details using localization
|
||||
details = i18n_instance.gettext(default_lang, "admin_sync_details",
|
||||
details = i18n_instance.gettext(
|
||||
default_lang,
|
||||
"admin_sync_details",
|
||||
panel_records_checked=panel_records_checked,
|
||||
users_found_in_db=users_found_in_db,
|
||||
users_created=users_created,
|
||||
@@ -281,11 +546,15 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
||||
subscriptions_synced_count=subscriptions_synced_count,
|
||||
subscriptions_created=subscriptions_created,
|
||||
subscriptions_updated=subscriptions_updated,
|
||||
additional_stats=additional_stats
|
||||
additional_stats=additional_stats,
|
||||
)
|
||||
|
||||
await panel_sync_dal.update_panel_sync_status(
|
||||
session, status, details, panel_records_checked, subscriptions_synced_count
|
||||
session,
|
||||
status,
|
||||
details,
|
||||
panel_records_checked,
|
||||
subscriptions_synced_count,
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
@@ -310,19 +579,27 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
||||
"users_synced": users_found_in_db,
|
||||
"users_created": users_created,
|
||||
"subs_synced": subscriptions_synced_count,
|
||||
"errors": sync_errors
|
||||
"errors": sync_errors,
|
||||
}
|
||||
|
||||
except Exception as e_sync_global:
|
||||
await session.rollback()
|
||||
logging.error(f"Global error during sync: {e_sync_global}", exc_info=True)
|
||||
error_detail = f"Unexpected error during sync: {str(e_sync_global)}"
|
||||
|
||||
|
||||
await panel_sync_dal.update_panel_sync_status(
|
||||
session, "failed", error_detail, panel_records_checked, subscriptions_synced_count
|
||||
session,
|
||||
"failed",
|
||||
error_detail,
|
||||
panel_records_checked,
|
||||
subscriptions_synced_count,
|
||||
)
|
||||
|
||||
return {"status": "failed", "details": error_detail, "errors": [str(e_sync_global)]}
|
||||
|
||||
return {
|
||||
"status": "failed",
|
||||
"details": error_detail,
|
||||
"errors": [str(e_sync_global)],
|
||||
}
|
||||
|
||||
|
||||
@router.message(Command("sync"))
|
||||
@@ -365,34 +642,40 @@ async def sync_command_handler(
|
||||
# Use the extracted perform_sync function
|
||||
try:
|
||||
sync_result = await perform_sync(panel_service, session, settings, i18n)
|
||||
|
||||
|
||||
status = sync_result.get("status")
|
||||
details = sync_result.get("details", "No details available")
|
||||
errors = sync_result.get("errors", [])
|
||||
|
||||
|
||||
# Simple confirmation message to admin
|
||||
if status == "failed":
|
||||
await bot.send_message(target_chat_id, _("sync_failed_simple"))
|
||||
elif status == "completed_with_errors":
|
||||
await bot.send_message(target_chat_id, _("sync_errors_simple", errors_count=len(errors)))
|
||||
await bot.send_message(
|
||||
target_chat_id,
|
||||
_("sync_errors_simple", errors_count=len(errors)),
|
||||
)
|
||||
else:
|
||||
await bot.send_message(target_chat_id, _("sync_success_simple"))
|
||||
|
||||
|
||||
# Send notification to log channel with proper thread handling
|
||||
try:
|
||||
notification_service = NotificationService(bot, settings, i18n)
|
||||
await notification_service.notify_panel_sync(
|
||||
status, details,
|
||||
status,
|
||||
details,
|
||||
sync_result.get("users_processed", 0),
|
||||
sync_result.get("subs_synced", 0)
|
||||
sync_result.get("subs_synced", 0),
|
||||
)
|
||||
except Exception as e_notification:
|
||||
logging.error(f"Failed to send sync notification: {e_notification}")
|
||||
|
||||
|
||||
except Exception as e_sync_global:
|
||||
logging.error(f"Global error during /sync command: {e_sync_global}", exc_info=True)
|
||||
logging.error(
|
||||
f"Global error during /sync command: {e_sync_global}", exc_info=True
|
||||
)
|
||||
await bot.send_message(target_chat_id, _("sync_critical_error"))
|
||||
|
||||
|
||||
# Send notification to log channel about failure
|
||||
try:
|
||||
notification_service = NotificationService(bot, settings, i18n)
|
||||
@@ -400,7 +683,9 @@ async def sync_command_handler(
|
||||
"failed", str(e_sync_global), 0, 0
|
||||
)
|
||||
except Exception as e_notification:
|
||||
logging.error(f"Failed to send sync failure notification: {e_notification}")
|
||||
logging.error(
|
||||
f"Failed to send sync failure notification: {e_notification}"
|
||||
)
|
||||
|
||||
|
||||
@router.message(Command("syncstatus"))
|
||||
@@ -419,7 +704,9 @@ async def sync_status_command_handler(
|
||||
if status_record_model:
|
||||
last_time_val = status_record_model.last_sync_time
|
||||
last_time_str = (
|
||||
last_time_val.strftime("%Y-%m-%d %H:%M:%S UTC") if last_time_val else "N/A"
|
||||
last_time_val.strftime("%Y-%m-%d %H:%M:%S UTC")
|
||||
if last_time_val
|
||||
else "N/A"
|
||||
)
|
||||
|
||||
details_val = status_record_model.details
|
||||
@@ -436,4 +723,4 @@ async def sync_status_command_handler(
|
||||
else:
|
||||
response_text = _("admin_sync_status_never_run")
|
||||
|
||||
await message.answer(response_text, parse_mode="HTML")
|
||||
await message.answer(response_text, parse_mode="HTML")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+32
-51
@@ -38,7 +38,13 @@ async def inline_query_handler(inline_query: InlineQuery,
|
||||
# For all users: referral functionality
|
||||
if not query or "реф" in query or "ref" in query or "друг" in query or "friend" in query:
|
||||
referral_result = await create_referral_result(
|
||||
inline_query, bot, referral_service, i18n, current_lang, settings
|
||||
inline_query,
|
||||
bot,
|
||||
referral_service,
|
||||
i18n,
|
||||
current_lang,
|
||||
settings,
|
||||
session,
|
||||
)
|
||||
if referral_result:
|
||||
results.append(referral_result)
|
||||
@@ -67,9 +73,15 @@ async def inline_query_handler(inline_query: InlineQuery,
|
||||
await inline_query.answer(results=[], cache_time=10)
|
||||
|
||||
|
||||
async def create_referral_result(inline_query: InlineQuery, bot: Bot,
|
||||
referral_service: ReferralService,
|
||||
i18n_instance, lang: str, settings: Settings) -> Optional[InlineQueryResultArticle]:
|
||||
async def create_referral_result(
|
||||
inline_query: InlineQuery,
|
||||
bot: Bot,
|
||||
referral_service: ReferralService,
|
||||
i18n_instance,
|
||||
lang: str,
|
||||
settings: Settings,
|
||||
session: AsyncSession,
|
||||
) -> Optional[InlineQueryResultArticle]:
|
||||
"""Create referral link result for inline query"""
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
|
||||
@@ -80,25 +92,27 @@ async def create_referral_result(inline_query: InlineQuery, bot: Bot,
|
||||
return None
|
||||
|
||||
user_id = inline_query.from_user.id
|
||||
referral_link = referral_service.generate_referral_link(bot_username, user_id)
|
||||
referral_link = await referral_service.generate_referral_link(
|
||||
session, bot_username, user_id
|
||||
)
|
||||
|
||||
if not referral_link:
|
||||
logging.warning("Could not produce referral link for inline user %s", user_id)
|
||||
return None
|
||||
|
||||
# Create message content (use same text as friend message)
|
||||
message_text = _(
|
||||
"referral_friend_message",
|
||||
default="🚀 Привет! Попробуй этот VPN - быстрый, надёжный и доступный!\n\n"
|
||||
"🎁 По моей ссылке тебе дадут бонусные дни к подписке!\n\n{referral_link}",
|
||||
referral_link=referral_link
|
||||
)
|
||||
|
||||
return InlineQueryResultArticle(
|
||||
id="referral_link",
|
||||
title=_(
|
||||
"inline_referral_title",
|
||||
default="🎁 Пригласить друга"
|
||||
"inline_referral_title"
|
||||
),
|
||||
description=_(
|
||||
"inline_referral_description",
|
||||
default="Поделиться реферальной ссылкой для получения бонусов"
|
||||
"inline_referral_description"
|
||||
),
|
||||
input_message_content=InputTextMessageContent(
|
||||
message_text=message_text,
|
||||
@@ -149,13 +163,6 @@ async def create_user_stats_result(session: AsyncSession, i18n_instance, lang: s
|
||||
|
||||
stats_text = _(
|
||||
"inline_user_stats_message",
|
||||
default="📊 <b>Статистика Бота</b>\n👥 Пользователи\n\n"
|
||||
"📊 Всего: <b>{total}</b>\n"
|
||||
"💳 С платной подпиской: <b>{paid}</b>\n"
|
||||
"🆓 На пробном периоде: <b>{trial}</b>\n"
|
||||
"😴 Неактивных: <b>{inactive}</b>\n"
|
||||
"🚫 Заблокированных: <b>{banned}</b>\n"
|
||||
"🎁 Привлечено по реферальной программе: <b>{referral}</b>",
|
||||
total=user_stats['total_users'],
|
||||
active_today=user_stats['active_today'],
|
||||
paid=user_stats['paid_subscriptions'],
|
||||
@@ -168,12 +175,10 @@ async def create_user_stats_result(session: AsyncSession, i18n_instance, lang: s
|
||||
return InlineQueryResultArticle(
|
||||
id="admin_user_stats",
|
||||
title=_(
|
||||
"inline_admin_user_stats_title",
|
||||
default="📊 Статистика пользователей"
|
||||
"inline_admin_user_stats_title"
|
||||
),
|
||||
description=_(
|
||||
"inline_user_stats_description",
|
||||
default="Всего: {total}, Платных: {active}",
|
||||
total=user_stats['total_users'],
|
||||
active=user_stats['paid_subscriptions']
|
||||
),
|
||||
@@ -199,12 +204,6 @@ async def create_financial_stats_result(session: AsyncSession, i18n_instance, la
|
||||
|
||||
stats_text = _(
|
||||
"inline_financial_stats_message",
|
||||
default="💰 <b>Финансовая статистика</b>\n\n"
|
||||
"📅 За сегодня: <b>{today:.2f} RUB</b>\n"
|
||||
" ({today_count} платежей)\n"
|
||||
"📅 За неделю: <b>{week:.2f} RUB</b>\n"
|
||||
"📅 За месяц: <b>{month:.2f} RUB</b>\n"
|
||||
"🏆 За все время: <b>{all_time:.2f} RUB</b>",
|
||||
today=financial_stats['today_revenue'],
|
||||
today_count=financial_stats['today_payments_count'],
|
||||
week=financial_stats['week_revenue'],
|
||||
@@ -215,12 +214,10 @@ async def create_financial_stats_result(session: AsyncSession, i18n_instance, la
|
||||
return InlineQueryResultArticle(
|
||||
id="admin_financial_stats",
|
||||
title=_(
|
||||
"inline_admin_financial_stats_title",
|
||||
default="💰 Финансовая статистика"
|
||||
"inline_admin_financial_stats_title"
|
||||
),
|
||||
description=_(
|
||||
"inline_financial_description",
|
||||
default="Сегодня: {today} RUB",
|
||||
today=f"{financial_stats['today_revenue']:.2f}"
|
||||
),
|
||||
input_message_content=InputTextMessageContent(
|
||||
@@ -293,17 +290,6 @@ async def create_system_stats_result(session: AsyncSession, i18n_instance, lang:
|
||||
|
||||
stats_text = _(
|
||||
"inline_system_stats_message",
|
||||
default="🖥 <b>Статистика панели</b>\n\n"
|
||||
"🟢 Онлайн: <b>{online}</b>\n"
|
||||
"📊 Активных: <b>{active}</b>\n"
|
||||
"🔴 Отключенных: <b>{disabled}</b>\n"
|
||||
"⏰ Истекшие: <b>{expired}</b>\n"
|
||||
"⚠️ Ограниченные: <b>{limited}</b>\n"
|
||||
"👥 Всего пользователей: <b>{total}</b>\n"
|
||||
"💾 Использование RAM: <b>{memory:.1f}%</b>\n"
|
||||
"📊 Трафик за неделю: <b>{week_traffic}</b>\n"
|
||||
"📊 Трафик за месяц: <b>{month_traffic}</b>\n"
|
||||
"🔗 Активных нод: <b>{active_nodes}/{total_nodes}</b>",
|
||||
online=online_now,
|
||||
active=active_users,
|
||||
disabled=disabled_users,
|
||||
@@ -317,17 +303,15 @@ async def create_system_stats_result(session: AsyncSession, i18n_instance, lang:
|
||||
total_nodes=total_nodes
|
||||
)
|
||||
else:
|
||||
stats_text = _("inline_panel_stats_error", default="❌ Ошибка получения данных с панели")
|
||||
stats_text = _("inline_panel_stats_error")
|
||||
|
||||
return InlineQueryResultArticle(
|
||||
id="admin_system_stats",
|
||||
title=_(
|
||||
"inline_admin_system_stats_title",
|
||||
default="🖥 Системная статистика"
|
||||
"inline_admin_system_stats_title"
|
||||
),
|
||||
description=_(
|
||||
"inline_system_description",
|
||||
default="🟢 Онлайн: {online}, 📊 Активных: {active}",
|
||||
online=online_now,
|
||||
active=active_users
|
||||
),
|
||||
@@ -341,15 +325,14 @@ async def create_system_stats_result(session: AsyncSession, i18n_instance, lang:
|
||||
except Exception as e:
|
||||
logging.error(f"Error creating system stats result: {e}")
|
||||
# Fallback error message
|
||||
error_text = _("inline_panel_stats_error", default="❌ Ошибка получения данных с панели")
|
||||
error_text = _("inline_panel_stats_error")
|
||||
|
||||
return InlineQueryResultArticle(
|
||||
id="admin_system_stats",
|
||||
title=_(
|
||||
"inline_admin_system_stats_title",
|
||||
default="🖥 Системная статистика"
|
||||
"inline_admin_system_stats_title"
|
||||
),
|
||||
description=_("inline_system_error", default="Ошибка получения данных"),
|
||||
description=_("inline_system_error"),
|
||||
input_message_content=InputTextMessageContent(
|
||||
message_text=error_text,
|
||||
parse_mode="HTML"
|
||||
@@ -357,5 +340,3 @@ async def create_system_stats_result(session: AsyncSession, i18n_instance, lang:
|
||||
thumbnail_url=settings.INLINE_SYSTEM_STATS_THUMBNAIL_URL
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
|
||||
+193
-86
@@ -18,17 +18,29 @@ from bot.services.subscription_service import SubscriptionService
|
||||
from bot.services.referral_service import ReferralService
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
from bot.services.yookassa_service import YooKassaService
|
||||
from bot.services.lknpd_service import LknpdService
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from config.settings import Settings
|
||||
from bot.services.notification_service import NotificationService
|
||||
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
|
||||
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
|
||||
from bot.utils.config_link import prepare_config_links
|
||||
from bot.utils.request_security import ip_in_allowlist, request_client_ip
|
||||
|
||||
payment_processing_lock = asyncio.Lock()
|
||||
|
||||
YOOKASSA_EVENT_PAYMENT_SUCCEEDED = 'payment.succeeded'
|
||||
YOOKASSA_EVENT_PAYMENT_CANCELED = 'payment.canceled'
|
||||
YOOKASSA_EVENT_PAYMENT_WAITING_FOR_CAPTURE = 'payment.waiting_for_capture'
|
||||
YOOKASSA_WEBHOOK_ALLOWED_IPS = [
|
||||
"185.71.76.0/27",
|
||||
"185.71.77.0/27",
|
||||
"77.75.153.0/25",
|
||||
"77.75.156.11",
|
||||
"77.75.156.35",
|
||||
"77.75.154.128/25",
|
||||
"2a02:5180::/32",
|
||||
]
|
||||
|
||||
|
||||
async def process_successful_payment(session: AsyncSession, bot: Bot,
|
||||
@@ -36,10 +48,13 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
||||
i18n: JsonI18n, settings: Settings,
|
||||
panel_service: PanelApiService,
|
||||
subscription_service: SubscriptionService,
|
||||
referral_service: ReferralService):
|
||||
referral_service: ReferralService,
|
||||
lknpd_service: Optional[LknpdService] = None):
|
||||
metadata = payment_info_from_webhook.get("metadata", {})
|
||||
user_id_str = metadata.get("user_id")
|
||||
subscription_months_str = metadata.get("subscription_months")
|
||||
traffic_gb_str = metadata.get("traffic_gb")
|
||||
sale_mode = metadata.get("sale_mode") or ("traffic" if settings.traffic_sale_mode else "subscription")
|
||||
promo_code_id_str = metadata.get("promo_code_id")
|
||||
payment_db_id_str = metadata.get("payment_db_id")
|
||||
auto_renew_subscription_id_str = metadata.get(
|
||||
@@ -47,8 +62,11 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
||||
|
||||
# For auto-renew payments, payment_db_id may be absent. In that case,
|
||||
# we will create/ensure a payment record idempotently using provider payment id.
|
||||
if (not user_id_str or not subscription_months_str
|
||||
or (not payment_db_id_str and not auto_renew_subscription_id_str)):
|
||||
if (
|
||||
not user_id_str
|
||||
or (not subscription_months_str and not traffic_gb_str)
|
||||
or (not payment_db_id_str and not auto_renew_subscription_id_str)
|
||||
):
|
||||
logging.error(
|
||||
f"Missing crucial metadata for payment: {payment_info_from_webhook.get('id')}, metadata: {metadata}"
|
||||
)
|
||||
@@ -57,54 +75,65 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
||||
db_user = None
|
||||
try:
|
||||
user_id = int(user_id_str)
|
||||
subscription_months = int(subscription_months_str)
|
||||
subscription_months = float(subscription_months_str or 0)
|
||||
traffic_amount_gb = float(traffic_gb_str) if traffic_gb_str else subscription_months
|
||||
payment_db_id = int(
|
||||
payment_db_id_str) if payment_db_id_str and payment_db_id_str.isdigit() else None
|
||||
is_auto_renew = bool(auto_renew_subscription_id_str and not payment_db_id)
|
||||
is_auto_renew = bool(auto_renew_subscription_id_str and not payment_db_id and sale_mode != "traffic")
|
||||
promo_code_id = int(
|
||||
promo_code_id_str
|
||||
) if promo_code_id_str and promo_code_id_str.isdigit() else None
|
||||
|
||||
amount_data = payment_info_from_webhook.get("amount", {})
|
||||
months_for_record = int(subscription_months) if sale_mode != "traffic" else 0
|
||||
payment_value = float(amount_data.get("value", 0.0))
|
||||
yk_payment_id_from_hook = payment_info_from_webhook.get("id")
|
||||
|
||||
payment_record = None
|
||||
# If this is an auto-renewal (no payment_db_id in metadata), ensure a payment record exists
|
||||
if payment_db_id is None and auto_renew_subscription_id_str:
|
||||
try:
|
||||
# Create/ensure provider payment by YooKassa payment id for idempotency
|
||||
yk_payment_id_from_hook = payment_info_from_webhook.get("id")
|
||||
if not yk_payment_id_from_hook:
|
||||
logging.error(
|
||||
"Auto-renew webhook missing YooKassa payment id; cannot ensure payment record."
|
||||
)
|
||||
return
|
||||
from db.dal import payment_dal as _payment_dal
|
||||
ensured_payment = await _payment_dal.ensure_payment_with_provider_id(
|
||||
session,
|
||||
user_id=user_id,
|
||||
amount=payment_value,
|
||||
currency=amount_data.get("currency", settings.DEFAULT_CURRENCY_SYMBOL),
|
||||
months=subscription_months,
|
||||
description=payment_info_from_webhook.get(
|
||||
"description") or f"Auto-renewal for {subscription_months} months",
|
||||
provider="yookassa",
|
||||
provider_payment_id=yk_payment_id_from_hook,
|
||||
payment_record = await _payment_dal.get_payment_by_provider_payment_id(
|
||||
session, yk_payment_id_from_hook
|
||||
)
|
||||
payment_db_id = ensured_payment.payment_id
|
||||
# Also persist yookassa_payment_id field if not set yet
|
||||
try:
|
||||
await _payment_dal.update_payment_status_by_db_id(
|
||||
if not payment_record:
|
||||
payment_record = await _payment_dal.ensure_payment_with_provider_id(
|
||||
session,
|
||||
payment_db_id,
|
||||
payment_info_from_webhook.get("status", "succeeded"),
|
||||
yk_payment_id_from_hook,
|
||||
)
|
||||
except Exception:
|
||||
# Non-fatal; continue processing
|
||||
logging.exception(
|
||||
"Failed to backfill yookassa_payment_id for ensured auto-renew payment"
|
||||
user_id=user_id,
|
||||
amount=payment_value,
|
||||
currency=amount_data.get("currency", settings.DEFAULT_CURRENCY_SYMBOL),
|
||||
months=months_for_record or 1,
|
||||
description=payment_info_from_webhook.get(
|
||||
"description") or f"Auto-renewal for {months_for_record or subscription_months} months",
|
||||
provider="yookassa",
|
||||
provider_payment_id=yk_payment_id_from_hook,
|
||||
)
|
||||
payment_db_id = payment_record.payment_id
|
||||
except Exception as e_ensure:
|
||||
logging.error(
|
||||
f"Failed to ensure payment record for auto-renew webhook (YK {payment_info_from_webhook.get('id')}): {e_ensure}",
|
||||
exc_info=True,
|
||||
)
|
||||
return
|
||||
elif payment_db_id is not None:
|
||||
payment_record = await payment_dal.get_payment_by_db_id(session, payment_db_id)
|
||||
if not payment_record:
|
||||
logging.error(
|
||||
f"Payment record {payment_db_id} not found for YK ID {yk_payment_id_from_hook}."
|
||||
)
|
||||
return
|
||||
|
||||
if payment_record and payment_record.status == "succeeded":
|
||||
logging.info(
|
||||
f"Skipping duplicate YooKassa webhook for payment {payment_db_id} (YK: {yk_payment_id_from_hook})."
|
||||
)
|
||||
return
|
||||
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
if not db_user:
|
||||
@@ -136,10 +165,24 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
||||
|
||||
try:
|
||||
yk_payment_id_from_hook = payment_info_from_webhook.get("id")
|
||||
payment_before_update = None
|
||||
if payment_db_id is not None:
|
||||
payment_before_update = await payment_dal.get_payment_by_db_id(
|
||||
session,
|
||||
payment_db_id,
|
||||
)
|
||||
should_send_lknpd_receipt = bool(
|
||||
lknpd_service
|
||||
and lknpd_service.configured
|
||||
and payment_info_from_webhook.get("paid") is True
|
||||
and payment_info_from_webhook.get("status") == "succeeded"
|
||||
and payment_before_update
|
||||
and payment_before_update.status != "succeeded"
|
||||
)
|
||||
# Try to capture and save payment method for future charges if available
|
||||
try:
|
||||
payment_method = payment_info_from_webhook.get("payment_method")
|
||||
if getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False) and isinstance(payment_method, dict) and payment_method.get("saved", False):
|
||||
if settings.yookassa_autopayments_active and isinstance(payment_method, dict) and payment_method.get("saved", False):
|
||||
pm_id = payment_method.get("id")
|
||||
pm_type = payment_method.get("type")
|
||||
title = payment_method.get("title")
|
||||
@@ -170,8 +213,40 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
||||
card_last4=display_last4,
|
||||
card_network=display_network,
|
||||
)
|
||||
try:
|
||||
await user_billing_dal.upsert_user_payment_method(
|
||||
session,
|
||||
user_id=user_id,
|
||||
provider_payment_method_id=pm_id,
|
||||
provider="yookassa",
|
||||
card_last4=display_last4,
|
||||
card_network=display_network,
|
||||
set_default=True,
|
||||
)
|
||||
except Exception:
|
||||
logging.exception("Failed to persist multi-card YooKassa method from webhook")
|
||||
except Exception:
|
||||
logging.exception("Failed to persist YooKassa payment method from webhook")
|
||||
months_for_activation = int(subscription_months) if sale_mode != "traffic" else 0
|
||||
activation_details = await subscription_service.activate_subscription(
|
||||
session,
|
||||
user_id,
|
||||
months_for_activation,
|
||||
payment_value,
|
||||
payment_db_id,
|
||||
promo_code_id_from_payment=promo_code_id,
|
||||
provider="yookassa",
|
||||
sale_mode=sale_mode,
|
||||
traffic_gb=traffic_amount_gb if sale_mode == "traffic" else None,
|
||||
)
|
||||
|
||||
if not activation_details or not activation_details.get('end_date'):
|
||||
logging.error(
|
||||
f"Failed to activate subscription for user {user_id} after payment {yk_payment_id_from_hook}"
|
||||
)
|
||||
raise Exception(
|
||||
f"Subscription Error: Failed to activate for user {user_id}")
|
||||
|
||||
updated_payment_record = await payment_dal.update_payment_status_by_db_id(
|
||||
session,
|
||||
payment_db_id=payment_db_id,
|
||||
@@ -184,34 +259,20 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
||||
raise Exception(
|
||||
f"DB Error: Could not update payment record {payment_db_id}")
|
||||
|
||||
activation_details = await subscription_service.activate_subscription(
|
||||
session,
|
||||
user_id,
|
||||
subscription_months,
|
||||
payment_value,
|
||||
payment_db_id,
|
||||
promo_code_id_from_payment=promo_code_id,
|
||||
provider="yookassa")
|
||||
|
||||
if not activation_details or not activation_details.get('end_date'):
|
||||
logging.error(
|
||||
f"Failed to activate subscription for user {user_id} after payment {yk_payment_id_from_hook}"
|
||||
)
|
||||
raise Exception(
|
||||
f"Subscription Error: Failed to activate for user {user_id}")
|
||||
|
||||
base_subscription_end_date = activation_details['end_date']
|
||||
final_end_date_for_user = base_subscription_end_date
|
||||
applied_promo_bonus_days = activation_details.get(
|
||||
"applied_promo_bonus_days", 0)
|
||||
|
||||
referral_bonus_info = await referral_service.apply_referral_bonuses_for_payment(
|
||||
session,
|
||||
user_id,
|
||||
subscription_months,
|
||||
current_payment_db_id=payment_db_id,
|
||||
skip_if_active_before_payment=False,
|
||||
)
|
||||
referral_bonus_info = None
|
||||
if sale_mode != "traffic":
|
||||
referral_bonus_info = await referral_service.apply_referral_bonuses_for_payment(
|
||||
session,
|
||||
user_id,
|
||||
months_for_activation or int(subscription_months) or 1,
|
||||
current_payment_db_id=payment_db_id,
|
||||
skip_if_active_before_payment=False,
|
||||
)
|
||||
applied_referee_bonus_days_from_referral: Optional[int] = None
|
||||
if referral_bonus_info and referral_bonus_info.get(
|
||||
"referee_new_end_date"):
|
||||
@@ -224,19 +285,56 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
||||
user_lang = db_user.language_code if db_user and db_user.language_code else settings.DEFAULT_LANGUAGE
|
||||
_ = lambda key, **kwargs: i18n.gettext(user_lang, key, **kwargs)
|
||||
|
||||
traffic_label = (
|
||||
str(int(traffic_amount_gb)) if float(traffic_amount_gb).is_integer() else f"{traffic_amount_gb:g}"
|
||||
)
|
||||
if should_send_lknpd_receipt:
|
||||
receipt_item_name = payment_info_from_webhook.get("description")
|
||||
if not receipt_item_name:
|
||||
if sale_mode == "traffic":
|
||||
receipt_item_name = settings.LKNPD_RECEIPT_NAME_TRAFFIC.format(gb=traffic_label)
|
||||
else:
|
||||
receipt_item_name = settings.LKNPD_RECEIPT_NAME_SUBSCRIPTION.format(months=int(subscription_months))
|
||||
try:
|
||||
await lknpd_service.create_income_receipt(
|
||||
item_name=receipt_item_name,
|
||||
amount=payment_value,
|
||||
quantity=1.0,
|
||||
operation_time=datetime.now(timezone.utc),
|
||||
)
|
||||
except Exception:
|
||||
logging.exception(
|
||||
"Failed to send LKNPD receipt for payment %s",
|
||||
yk_payment_id_from_hook,
|
||||
)
|
||||
config_link_display, connect_button_url = await prepare_config_links(
|
||||
settings, activation_details.get("subscription_url") if activation_details else None
|
||||
)
|
||||
config_link_text = config_link_display or _("config_link_not_available")
|
||||
# For auto-renew charges, avoid re-sending config link; send concise message
|
||||
if is_auto_renew and final_end_date_for_user:
|
||||
if sale_mode != "traffic" and is_auto_renew and final_end_date_for_user:
|
||||
details_message = _(
|
||||
"yookassa_auto_renewal",
|
||||
months=subscription_months,
|
||||
months=int(subscription_months),
|
||||
end_date=final_end_date_for_user.strftime('%Y-%m-%d'),
|
||||
)
|
||||
details_markup = None
|
||||
else:
|
||||
config_link = activation_details.get("subscription_url") or _(
|
||||
"config_link_not_available"
|
||||
elif sale_mode == "traffic":
|
||||
details_message = _(
|
||||
"payment_successful_traffic_full",
|
||||
traffic_gb=traffic_label,
|
||||
end_date=final_end_date_for_user.strftime('%Y-%m-%d') if final_end_date_for_user else "—",
|
||||
config_link=config_link_text,
|
||||
)
|
||||
|
||||
details_markup = get_connect_and_main_keyboard(
|
||||
user_lang,
|
||||
i18n,
|
||||
settings,
|
||||
config_link_display,
|
||||
connect_button_url=connect_button_url,
|
||||
preserve_message=True,
|
||||
)
|
||||
else:
|
||||
if applied_referee_bonus_days_from_referral and final_end_date_for_user:
|
||||
inviter_name_display = _("friend_placeholder")
|
||||
if db_user and db_user.referred_by_id:
|
||||
@@ -251,27 +349,27 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
||||
|
||||
details_message = _(
|
||||
"payment_successful_with_referral_bonus_full",
|
||||
months=subscription_months,
|
||||
months=int(subscription_months),
|
||||
base_end_date=base_subscription_end_date.strftime('%Y-%m-%d'),
|
||||
bonus_days=applied_referee_bonus_days_from_referral,
|
||||
final_end_date=final_end_date_for_user.strftime('%Y-%m-%d'),
|
||||
inviter_name=inviter_name_display,
|
||||
config_link=config_link,
|
||||
config_link=config_link_text,
|
||||
)
|
||||
elif applied_promo_bonus_days > 0 and final_end_date_for_user:
|
||||
details_message = _(
|
||||
"payment_successful_with_promo_full",
|
||||
months=subscription_months,
|
||||
months=int(subscription_months),
|
||||
bonus_days=applied_promo_bonus_days,
|
||||
end_date=final_end_date_for_user.strftime('%Y-%m-%d'),
|
||||
config_link=config_link,
|
||||
config_link=config_link_text,
|
||||
)
|
||||
elif final_end_date_for_user:
|
||||
details_message = _(
|
||||
"payment_successful_full",
|
||||
months=subscription_months,
|
||||
months=int(subscription_months),
|
||||
end_date=final_end_date_for_user.strftime('%Y-%m-%d'),
|
||||
config_link=config_link,
|
||||
config_link=config_link_text,
|
||||
)
|
||||
else:
|
||||
logging.error(
|
||||
@@ -280,7 +378,12 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
||||
details_message = _("payment_successful_error_details")
|
||||
|
||||
details_markup = get_connect_and_main_keyboard(
|
||||
user_lang, i18n, settings, config_link, preserve_message=True
|
||||
user_lang,
|
||||
i18n,
|
||||
settings,
|
||||
config_link_display,
|
||||
connect_button_url=connect_button_url,
|
||||
preserve_message=True,
|
||||
)
|
||||
try:
|
||||
await bot.send_message(
|
||||
@@ -303,9 +406,10 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
||||
user_id=user_id,
|
||||
amount=payment_value,
|
||||
currency=settings.DEFAULT_CURRENCY_SYMBOL,
|
||||
months=subscription_months,
|
||||
months=int(subscription_months) if sale_mode != "traffic" else 0,
|
||||
payment_provider="yookassa", # This is specifically for YooKassa webhook
|
||||
username=user.username if user else None
|
||||
username=user.username if user else None,
|
||||
traffic_gb=traffic_amount_gb if sale_mode == "traffic" else None,
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to send payment notification: {e}")
|
||||
@@ -379,16 +483,21 @@ async def yookassa_webhook_route(request: web.Request):
|
||||
subscription_service: SubscriptionService = request.app[
|
||||
'subscription_service']
|
||||
referral_service: ReferralService = request.app['referral_service']
|
||||
lknpd_service: Optional[LknpdService] = request.app.get('lknpd_service')
|
||||
async_session_factory: sessionmaker = request.app[
|
||||
'async_session_factory']
|
||||
except KeyError as e_app_ctx:
|
||||
logging.error(
|
||||
f"KeyError accessing app context in yookassa_webhook_route: {e_app_ctx}.",
|
||||
exc_info=True)
|
||||
except KeyError:
|
||||
logging.exception(
|
||||
"KeyError accessing app context in yookassa_webhook_route.")
|
||||
return web.Response(
|
||||
status=500,
|
||||
text="Internal Server Error: Missing app context component")
|
||||
|
||||
client_ip = request_client_ip(request, trusted_proxies=settings.trusted_proxies)
|
||||
if not ip_in_allowlist(client_ip, YOOKASSA_WEBHOOK_ALLOWED_IPS):
|
||||
logging.warning("YooKassa webhook denied from unauthorized IP source.")
|
||||
return web.Response(status=403)
|
||||
|
||||
try:
|
||||
event_json = await request.json()
|
||||
|
||||
@@ -471,7 +580,8 @@ async def yookassa_webhook_route(request: web.Request):
|
||||
await process_successful_payment(
|
||||
session, bot, payment_dict_for_processing,
|
||||
i18n_instance, settings, panel_service,
|
||||
subscription_service, referral_service)
|
||||
subscription_service, referral_service,
|
||||
lknpd_service)
|
||||
await session.commit()
|
||||
else:
|
||||
logging.warning(
|
||||
@@ -487,7 +597,7 @@ async def yookassa_webhook_route(request: web.Request):
|
||||
elif notification_object.event == YOOKASSA_EVENT_PAYMENT_WAITING_FOR_CAPTURE:
|
||||
# Bind-only flow: save method and cancel auth if metadata has bind_only
|
||||
metadata = payment_dict_for_processing.get("metadata", {}) or {}
|
||||
if getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False) and metadata.get("bind_only") == "1":
|
||||
if settings.yookassa_autopayments_active and metadata.get("bind_only") == "1":
|
||||
try:
|
||||
user_id_str = metadata.get("user_id")
|
||||
if user_id_str and user_id_str.isdigit():
|
||||
@@ -562,23 +672,20 @@ async def yookassa_webhook_route(request: web.Request):
|
||||
logging.exception("Failed to cancel bind-only payment auth")
|
||||
except Exception:
|
||||
logging.exception("Failed to handle bind-only waiting_for_capture webhook")
|
||||
except Exception as e_webhook_db_processing:
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"Error processing YooKassa webhook event '{notification_object.event}' "
|
||||
f"for YK Payment ID {payment_dict_for_processing.get('id')} in DB transaction: {e_webhook_db_processing}",
|
||||
exc_info=True)
|
||||
logging.exception(
|
||||
"Error processing YooKassa webhook event '%s' for YK Payment ID %s in DB transaction.",
|
||||
notification_object.event,
|
||||
payment_dict_for_processing.get('id'))
|
||||
return web.Response(
|
||||
status=200, text="ok_internal_processing_error_logged")
|
||||
status=500, text="internal_processing_error")
|
||||
|
||||
return web.Response(status=200, text="ok")
|
||||
|
||||
except json.JSONDecodeError:
|
||||
logging.error("YooKassa Webhook: Invalid JSON received.")
|
||||
return web.Response(status=400, text="bad_request_invalid_json")
|
||||
except Exception as e_general_webhook:
|
||||
logging.error(
|
||||
f"YooKassa Webhook general processing error: {e_general_webhook}",
|
||||
exc_info=True)
|
||||
return web.Response(status=200,
|
||||
text="ok_general_internal_error_logged")
|
||||
except Exception:
|
||||
logging.exception("YooKassa Webhook general processing error.")
|
||||
return web.Response(status=500, text="internal_error")
|
||||
|
||||
@@ -4,7 +4,6 @@ from aiogram import Router, F, types, Bot
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from typing import Optional
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from aiogram.utils.markdown import hcode
|
||||
|
||||
from config.settings import Settings
|
||||
from bot.states.user_states import UserPromoStates
|
||||
@@ -16,6 +15,7 @@ from bot.keyboards.inline.user_keyboards import (
|
||||
)
|
||||
from datetime import datetime
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.utils.callback_answer import safe_answer_callback
|
||||
|
||||
from .start import send_main_menu
|
||||
|
||||
@@ -35,15 +35,18 @@ async def prompt_promo_code_input(callback: types.CallbackQuery,
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n:
|
||||
await callback.answer("Language service error.", show_alert=True)
|
||||
await safe_answer_callback(callback, "Language service error.", show_alert=True)
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
if not callback.message:
|
||||
logging.error(
|
||||
"CallbackQuery has no message in prompt_promo_code_input")
|
||||
await callback.answer(_("error_occurred_processing_request"),
|
||||
show_alert=True)
|
||||
await safe_answer_callback(
|
||||
callback,
|
||||
_("error_occurred_processing_request"),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
@@ -58,7 +61,7 @@ async def prompt_promo_code_input(callback: types.CallbackQuery,
|
||||
text=_(key="promo_code_prompt"),
|
||||
reply_markup=get_back_to_main_menu_markup(current_lang, i18n))
|
||||
|
||||
await callback.answer()
|
||||
await safe_answer_callback(callback)
|
||||
await state.set_state(UserPromoStates.waiting_for_promo_code)
|
||||
logging.info(
|
||||
f"User {callback.from_user.id} entered state UserPromoStates.waiting_for_promo_code. "
|
||||
@@ -119,41 +122,41 @@ async def process_promo_code_input(message: types.Message, state: FSMContext,
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to send suspicious promo notification: {e}")
|
||||
|
||||
response_to_user_text = _("promo_code_not_found",
|
||||
code=hcode(code_input.upper()))
|
||||
reply_markup = get_back_to_main_menu_markup(current_lang, i18n)
|
||||
success, result = await promo_code_service.apply_promo_code(
|
||||
session, user.id, code_input, current_lang)
|
||||
if success:
|
||||
await session.commit()
|
||||
logging.info(
|
||||
f"Promo code '{code_input}' successfully applied for user {user.id}."
|
||||
)
|
||||
|
||||
new_end_date = result if isinstance(result, datetime) else None
|
||||
active = await subscription_service.get_active_subscription_details(session, user.id)
|
||||
config_link_display = active.get("config_link") if active else None
|
||||
connect_button_url = active.get("connect_button_url") if active else None
|
||||
config_link_text = config_link_display or _("config_link_not_available")
|
||||
|
||||
response_to_user_text = _(
|
||||
"promo_code_applied_success_full",
|
||||
end_date=(new_end_date.strftime("%d.%m.%Y %H:%M:%S") if new_end_date else "N/A"),
|
||||
config_link=config_link_text,
|
||||
)
|
||||
reply_markup = get_connect_and_main_keyboard(
|
||||
current_lang,
|
||||
i18n,
|
||||
settings,
|
||||
config_link_display,
|
||||
connect_button_url=connect_button_url,
|
||||
)
|
||||
else:
|
||||
|
||||
success, result = await promo_code_service.apply_promo_code(
|
||||
session, user.id, code_input, current_lang)
|
||||
if success:
|
||||
await session.commit()
|
||||
logging.info(
|
||||
f"Promo code '{code_input}' successfully applied for user {user.id}."
|
||||
)
|
||||
|
||||
new_end_date = result if isinstance(result, datetime) else None
|
||||
active = await subscription_service.get_active_subscription_details(session, user.id)
|
||||
config_link = active.get("config_link") if active else None
|
||||
config_link = config_link or _("config_link_not_available")
|
||||
|
||||
response_to_user_text = _(
|
||||
"promo_code_applied_success_full",
|
||||
end_date=(new_end_date.strftime("%d.%m.%Y %H:%M:%S") if new_end_date else "N/A"),
|
||||
config_link=config_link,
|
||||
)
|
||||
reply_markup = get_connect_and_main_keyboard(
|
||||
current_lang, i18n, settings, config_link
|
||||
)
|
||||
else:
|
||||
await session.rollback()
|
||||
logging.info(
|
||||
f"Promo code '{code_input}' application failed for user {user.id}. Reason: {result}"
|
||||
)
|
||||
response_to_user_text = result
|
||||
reply_markup = get_back_to_main_menu_markup(
|
||||
current_lang, i18n
|
||||
)
|
||||
await session.commit()
|
||||
logging.info(
|
||||
f"Promo code '{code_input}' application failed for user {user.id}. Reason: {result}"
|
||||
)
|
||||
response_to_user_text = result
|
||||
reply_markup = get_back_to_main_menu_markup(
|
||||
current_lang, i18n
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
response_to_user_text,
|
||||
@@ -176,7 +179,7 @@ async def cancel_promo_input_via_button(
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n:
|
||||
logging.error("i18n missing in cancel_promo_input_via_button")
|
||||
await callback.answer("Language error", show_alert=True)
|
||||
await safe_answer_callback(callback, "Language error", show_alert=True)
|
||||
return
|
||||
|
||||
logging.info(
|
||||
@@ -195,5 +198,8 @@ async def cancel_promo_input_via_button(
|
||||
else:
|
||||
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
await callback.answer(_("promo_input_cancelled_short"),
|
||||
show_alert=False)
|
||||
await safe_answer_callback(
|
||||
callback,
|
||||
_("promo_input_cancelled_short"),
|
||||
show_alert=False,
|
||||
)
|
||||
|
||||
@@ -2,9 +2,11 @@ import logging
|
||||
from aiogram import Router, F, types, Bot
|
||||
from aiogram.filters import Command
|
||||
from typing import Optional, Union
|
||||
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config.settings import Settings
|
||||
from db.dal import user_dal
|
||||
from bot.services.referral_service import ReferralService
|
||||
|
||||
from bot.keyboards.inline.user_keyboards import get_back_to_main_menu_markup
|
||||
@@ -60,28 +62,40 @@ async def referral_command_handler(event: Union[types.Message,
|
||||
return
|
||||
|
||||
inviter_user_id = event.from_user.id
|
||||
referral_link = referral_service.generate_referral_link(
|
||||
bot_username, inviter_user_id)
|
||||
referral_link = await referral_service.generate_referral_link(
|
||||
session, bot_username, inviter_user_id)
|
||||
|
||||
if not referral_link:
|
||||
logging.error(
|
||||
"Failed to generate referral link for user %s (probably missing DB record).",
|
||||
inviter_user_id,
|
||||
)
|
||||
await target_message_obj.answer(_("error_generating_referral_link"))
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
await event.answer()
|
||||
return
|
||||
|
||||
bonus_info_parts = []
|
||||
if settings.subscription_options:
|
||||
if getattr(settings, "traffic_sale_mode", False):
|
||||
bonus_details_str = _("referral_not_available_for_traffic")
|
||||
else:
|
||||
if settings.subscription_options:
|
||||
for months_period_key, _price in sorted(
|
||||
settings.subscription_options.items()):
|
||||
|
||||
for months_period_key, _price in sorted(
|
||||
settings.subscription_options.items()):
|
||||
inv_bonus = settings.referral_bonus_inviter.get(months_period_key)
|
||||
ref_bonus = settings.referral_bonus_referee.get(months_period_key)
|
||||
if inv_bonus is not None or ref_bonus is not None:
|
||||
bonus_info_parts.append(
|
||||
_("referral_bonus_per_period",
|
||||
months=months_period_key,
|
||||
inviter_bonus_days=inv_bonus
|
||||
if inv_bonus is not None else _("no_bonus_placeholder"),
|
||||
referee_bonus_days=ref_bonus
|
||||
if ref_bonus is not None else _("no_bonus_placeholder")))
|
||||
|
||||
inv_bonus = settings.referral_bonus_inviter.get(months_period_key)
|
||||
ref_bonus = settings.referral_bonus_referee.get(months_period_key)
|
||||
if inv_bonus is not None or ref_bonus is not None:
|
||||
bonus_info_parts.append(
|
||||
_("referral_bonus_per_period",
|
||||
months=months_period_key,
|
||||
inviter_bonus_days=inv_bonus
|
||||
if inv_bonus is not None else _("no_bonus_placeholder"),
|
||||
referee_bonus_days=ref_bonus
|
||||
if ref_bonus is not None else _("no_bonus_placeholder")))
|
||||
|
||||
bonus_details_str = "\n".join(bonus_info_parts) if bonus_info_parts else _(
|
||||
"referral_no_bonuses_configured")
|
||||
bonus_details_str = "\n".join(bonus_info_parts) if bonus_info_parts else _(
|
||||
"referral_no_bonuses_configured")
|
||||
|
||||
# Get referral statistics
|
||||
referral_stats = await referral_service.get_referral_stats(session, inviter_user_id)
|
||||
@@ -91,6 +105,16 @@ async def referral_command_handler(event: Union[types.Message,
|
||||
bonus_details=bonus_details_str,
|
||||
invited_count=referral_stats["invited_count"],
|
||||
purchased_count=referral_stats["purchased_count"])
|
||||
if settings.SUBSCRIPTION_MINI_APP_URL:
|
||||
db_user = await user_dal.get_user_by_id(session, inviter_user_id)
|
||||
referral_code = await user_dal.ensure_referral_code(session, db_user) if db_user else None
|
||||
webapp_referral_link = _build_webapp_referral_link(
|
||||
settings.SUBSCRIPTION_MINI_APP_URL,
|
||||
referral_code,
|
||||
)
|
||||
if webapp_referral_link:
|
||||
webapp_label = "Web App ссылка" if current_lang == "ru" else "Web App link"
|
||||
text += f"\n\n🔗 {webapp_label}:\n<code>{webapp_referral_link}</code>"
|
||||
|
||||
from bot.keyboards.inline.user_keyboards import get_referral_link_keyboard
|
||||
reply_markup_val = get_referral_link_keyboard(current_lang, i18n)
|
||||
@@ -132,7 +156,16 @@ async def referral_action_handler(callback: types.CallbackQuery, settings: Setti
|
||||
return
|
||||
|
||||
inviter_user_id = callback.from_user.id
|
||||
referral_link = referral_service.generate_referral_link(bot_username, inviter_user_id)
|
||||
referral_link = await referral_service.generate_referral_link(
|
||||
session, bot_username, inviter_user_id)
|
||||
|
||||
if not referral_link:
|
||||
logging.error(
|
||||
"Failed to generate referral link for user %s via inline button.",
|
||||
inviter_user_id,
|
||||
)
|
||||
await callback.answer(_("error_generating_referral_link"), show_alert=True)
|
||||
return
|
||||
|
||||
friend_message = _("referral_friend_message", referral_link=referral_link)
|
||||
|
||||
@@ -146,3 +179,20 @@ async def referral_action_handler(callback: types.CallbackQuery, settings: Setti
|
||||
await callback.answer("Произошла ошибка", show_alert=True)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
|
||||
def _build_webapp_referral_link(base_url: Optional[str], referral_code: Optional[str]) -> Optional[str]:
|
||||
if not base_url or not referral_code:
|
||||
return None
|
||||
parts = urlsplit(base_url)
|
||||
query = dict(parse_qsl(parts.query, keep_blank_values=True))
|
||||
query["ref"] = f"u{referral_code}"
|
||||
return urlunsplit(
|
||||
(
|
||||
parts.scheme,
|
||||
parts.netloc,
|
||||
parts.path or "/",
|
||||
urlencode(query),
|
||||
parts.fragment,
|
||||
)
|
||||
)
|
||||
|
||||
+359
-49
@@ -14,8 +14,10 @@ from db.models import User
|
||||
|
||||
from bot.keyboards.inline.user_keyboards import (
|
||||
get_main_menu_inline_keyboard,
|
||||
get_bot_interface_inline_keyboard,
|
||||
get_language_selection_keyboard,
|
||||
get_channel_subscription_keyboard,
|
||||
get_information_links_keyboard,
|
||||
)
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
@@ -24,10 +26,29 @@ from bot.services.promo_code_service import PromoCodeService
|
||||
from config.settings import Settings
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.utils.text_sanitizer import sanitize_username, sanitize_display_name
|
||||
|
||||
from bot.utils.callback_answer import safe_answer_callback
|
||||
router = Router(name="user_start_router")
|
||||
|
||||
|
||||
async def should_show_trial_button(
|
||||
settings: Settings,
|
||||
subscription_service: SubscriptionService,
|
||||
session: AsyncSession,
|
||||
user_id: int) -> bool:
|
||||
if not settings.TRIAL_ENABLED:
|
||||
return False
|
||||
|
||||
if hasattr(subscription_service, 'has_had_any_subscription') and callable(
|
||||
getattr(subscription_service, 'has_had_any_subscription')):
|
||||
return not await subscription_service.has_had_any_subscription(
|
||||
session, user_id)
|
||||
|
||||
logging.error(
|
||||
"Method has_had_any_subscription is missing in SubscriptionService!"
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
async def send_main_menu(target_event: Union[types.Message,
|
||||
types.CallbackQuery],
|
||||
settings: Settings,
|
||||
@@ -60,18 +81,8 @@ async def send_main_menu(target_event: Union[types.Message,
|
||||
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
show_trial_button_in_menu = False
|
||||
if settings.TRIAL_ENABLED:
|
||||
if hasattr(
|
||||
subscription_service, 'has_had_any_subscription') and callable(
|
||||
getattr(subscription_service, 'has_had_any_subscription')):
|
||||
if not await subscription_service.has_had_any_subscription(
|
||||
session, user_id):
|
||||
show_trial_button_in_menu = True
|
||||
else:
|
||||
logging.error(
|
||||
"Method has_had_any_subscription is missing in SubscriptionService for send_main_menu!"
|
||||
)
|
||||
show_trial_button_in_menu = await should_show_trial_button(
|
||||
settings, subscription_service, session, user_id)
|
||||
|
||||
text = _(key="main_menu_greeting", user_name=user_full_name)
|
||||
reply_markup = get_main_menu_inline_keyboard(current_lang, i18n, settings,
|
||||
@@ -89,8 +100,11 @@ async def send_main_menu(target_event: Union[types.Message,
|
||||
f"send_main_menu: target_message_obj is None for event from user {user_id}."
|
||||
)
|
||||
if isinstance(target_event, types.CallbackQuery):
|
||||
await target_event.answer(_("error_displaying_menu"),
|
||||
show_alert=True)
|
||||
await safe_answer_callback(
|
||||
target_event,
|
||||
_("error_displaying_menu"),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
@@ -100,10 +114,7 @@ async def send_main_menu(target_event: Union[types.Message,
|
||||
await target_message_obj.answer(text, reply_markup=reply_markup)
|
||||
|
||||
if isinstance(target_event, types.CallbackQuery):
|
||||
try:
|
||||
await target_event.answer()
|
||||
except Exception:
|
||||
pass
|
||||
await safe_answer_callback(target_event)
|
||||
except Exception as e_send_edit:
|
||||
logging.warning(
|
||||
f"Failed to send/edit main menu (user: {user_id}, is_edit: {is_edit}): {type(e_send_edit).__name__} - {e_send_edit}."
|
||||
@@ -116,11 +127,72 @@ async def send_main_menu(target_event: Union[types.Message,
|
||||
f"Also failed to send new main menu message for user {user_id}: {e_send_new}"
|
||||
)
|
||||
if isinstance(target_event, types.CallbackQuery):
|
||||
await safe_answer_callback(
|
||||
target_event,
|
||||
_("error_occurred_try_again") if is_edit else None,
|
||||
)
|
||||
|
||||
|
||||
async def send_bot_interface_menu(
|
||||
target_event: Union[types.Message, types.CallbackQuery],
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
subscription_service: SubscriptionService,
|
||||
session: AsyncSession,
|
||||
is_edit: bool = False):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
|
||||
if not i18n:
|
||||
logging.error("i18n_instance missing in send_bot_interface_menu.")
|
||||
return
|
||||
|
||||
user_id = target_event.from_user.id
|
||||
show_trial_button_in_menu = await should_show_trial_button(
|
||||
settings, subscription_service, session, user_id)
|
||||
|
||||
text = i18n.gettext(current_lang, "bot_interface_menu_title")
|
||||
reply_markup = get_bot_interface_inline_keyboard(
|
||||
current_lang, i18n, settings, show_trial_button_in_menu)
|
||||
|
||||
target_message_obj: Optional[types.Message] = None
|
||||
if isinstance(target_event, types.Message):
|
||||
target_message_obj = target_event
|
||||
elif isinstance(target_event, types.CallbackQuery) and target_event.message:
|
||||
target_message_obj = target_event.message
|
||||
|
||||
if not target_message_obj:
|
||||
logging.error(
|
||||
"send_bot_interface_menu: target_message_obj is None for user %s.",
|
||||
user_id,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
if is_edit:
|
||||
await target_message_obj.edit_text(text, reply_markup=reply_markup)
|
||||
else:
|
||||
await target_message_obj.answer(text, reply_markup=reply_markup)
|
||||
|
||||
if isinstance(target_event, types.CallbackQuery):
|
||||
await safe_answer_callback(target_event)
|
||||
except Exception as e_send_edit:
|
||||
logging.warning(
|
||||
"Failed to send/edit bot interface menu (user: %s, is_edit: %s): %s - %s.",
|
||||
user_id,
|
||||
is_edit,
|
||||
type(e_send_edit).__name__,
|
||||
e_send_edit,
|
||||
)
|
||||
if is_edit:
|
||||
try:
|
||||
await target_event.answer(
|
||||
_("error_occurred_try_again") if is_edit else None)
|
||||
except Exception:
|
||||
pass
|
||||
await target_message_obj.answer(text, reply_markup=reply_markup)
|
||||
except Exception as e_send_new:
|
||||
logging.error(
|
||||
"Also failed to send new bot interface menu for user %s: %s",
|
||||
user_id,
|
||||
e_send_new,
|
||||
)
|
||||
|
||||
|
||||
async def ensure_required_channel_subscription(
|
||||
@@ -302,18 +374,23 @@ async def ensure_required_channel_subscription(
|
||||
|
||||
|
||||
@router.message(CommandStart())
|
||||
@router.message(CommandStart(magic=F.args.regexp(r"^ref_(\d+)$").as_("ref_match")))
|
||||
@router.message(CommandStart(magic=F.args.regexp(r"^ref_((?:[uU][A-Za-z0-9]{9})|(?:[A-Za-z0-9]{9})|\d+)$").as_("ref_match")))
|
||||
@router.message(CommandStart(magic=F.args.regexp(r"^promo_(\w+)$").as_("promo_match")))
|
||||
@router.message(CommandStart(magic=F.args.regexp(r"^(?!ref_|promo_)([A-Za-z0-9_\-]{2,64})$").as_("ad_param_match")))
|
||||
@router.message(CommandStart(magic=F.args.regexp(r"^admin_user_(\d+)$").as_("admin_user_match")))
|
||||
@router.message(CommandStart(magic=F.args.regexp(r"^page_ref$").as_("page_ref_match")))
|
||||
@router.message(CommandStart(magic=F.args.regexp(r"^(?!ref_|promo_|admin_user_|page_ref$|webapp_auth_)([A-Za-z0-9_\-]{2,64})$").as_("ad_param_match")))
|
||||
async def start_command_handler(message: types.Message,
|
||||
state: FSMContext,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
subscription_service: SubscriptionService,
|
||||
referral_service: ReferralService,
|
||||
session: AsyncSession,
|
||||
ref_match: Optional[re.Match] = None,
|
||||
promo_match: Optional[re.Match] = None,
|
||||
ad_param_match: Optional[re.Match] = None):
|
||||
page_ref_match: Optional[re.Match] = None,
|
||||
ad_param_match: Optional[re.Match] = None,
|
||||
admin_user_match: Optional[re.Match] = None):
|
||||
await state.clear()
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
@@ -323,17 +400,87 @@ async def start_command_handler(message: types.Message,
|
||||
user = message.from_user
|
||||
user_id = user.id
|
||||
|
||||
if admin_user_match and user_id in settings.ADMIN_IDS:
|
||||
target_user_id = int(admin_user_match.group(1))
|
||||
target_user = await user_dal.get_user_by_id(session, target_user_id)
|
||||
if not target_user:
|
||||
await message.answer(
|
||||
_("admin_user_not_found", input=hd.quote(str(target_user_id)))
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
from bot.handlers.admin.user_management import (
|
||||
format_user_card,
|
||||
get_user_card_keyboard,
|
||||
_send_with_profile_link_fallback,
|
||||
)
|
||||
|
||||
referral_service = ReferralService(
|
||||
settings, subscription_service, message.bot, i18n
|
||||
)
|
||||
user_card_text = await format_user_card(
|
||||
target_user,
|
||||
session,
|
||||
subscription_service,
|
||||
i18n,
|
||||
current_lang,
|
||||
referral_service,
|
||||
)
|
||||
keyboard = get_user_card_keyboard(
|
||||
target_user.user_id,
|
||||
i18n,
|
||||
current_lang,
|
||||
target_user.referred_by_id,
|
||||
)
|
||||
|
||||
await _send_with_profile_link_fallback(
|
||||
message.answer,
|
||||
text=user_card_text,
|
||||
markup=keyboard.as_markup(),
|
||||
user_id=target_user.user_id,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
return
|
||||
except Exception as e_admin_card:
|
||||
logging.error(
|
||||
"Failed to open admin user card via deep-link for %s: %s",
|
||||
target_user_id,
|
||||
e_admin_card,
|
||||
exc_info=True,
|
||||
)
|
||||
await message.answer(_("admin_user_card_error"))
|
||||
return
|
||||
|
||||
referred_by_user_id: Optional[int] = None
|
||||
promo_code_to_apply: Optional[str] = None
|
||||
should_open_referral_from_start = False
|
||||
ad_start_param: Optional[str] = None
|
||||
|
||||
if ref_match:
|
||||
potential_referrer_id = int(ref_match.group(1))
|
||||
if await user_dal.get_user_by_id(session, potential_referrer_id):
|
||||
referred_by_user_id = potential_referrer_id
|
||||
raw_ref_value = ref_match.group(1)
|
||||
if raw_ref_value.isdigit():
|
||||
if settings.LEGACY_REFS:
|
||||
potential_referrer_id = int(raw_ref_value)
|
||||
if potential_referrer_id != user_id and await user_dal.get_user_by_id(
|
||||
session, potential_referrer_id):
|
||||
referred_by_user_id = potential_referrer_id
|
||||
else:
|
||||
normalized_code = raw_ref_value.strip()
|
||||
if normalized_code and normalized_code[0].lower() == "u":
|
||||
normalized_code = normalized_code[1:]
|
||||
ref_user = None
|
||||
if normalized_code:
|
||||
ref_user = await user_dal.get_user_by_referral_code(
|
||||
session, normalized_code)
|
||||
if ref_user and ref_user.user_id != user_id:
|
||||
referred_by_user_id = ref_user.user_id
|
||||
elif promo_match:
|
||||
promo_code_to_apply = promo_match.group(1)
|
||||
logging.info(f"User {user_id} started with promo code: {promo_code_to_apply}")
|
||||
elif page_ref_match:
|
||||
should_open_referral_from_start = True
|
||||
logging.info(f"User {user_id} started with page_ref deep-link.")
|
||||
elif ad_param_match:
|
||||
ad_start_param = ad_param_match.group(1)
|
||||
logging.info(f"User {user_id} started with ad start param: {ad_start_param}")
|
||||
@@ -343,6 +490,7 @@ async def start_command_handler(message: types.Message,
|
||||
sanitized_last_name = sanitize_display_name(user.last_name)
|
||||
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
is_existing_user = db_user is not None
|
||||
if not db_user:
|
||||
user_data_to_create = {
|
||||
"user_id": user_id,
|
||||
@@ -357,10 +505,65 @@ async def start_command_handler(message: types.Message,
|
||||
db_user, created = await user_dal.create_user(session, user_data_to_create)
|
||||
|
||||
if created:
|
||||
try:
|
||||
await session.commit()
|
||||
except Exception as commit_error:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"Failed to commit new user {user_id}: {commit_error}",
|
||||
exc_info=True,
|
||||
)
|
||||
await message.answer(_("error_occurred_processing_request"))
|
||||
return
|
||||
|
||||
logging.info(
|
||||
f"New user {user_id} added to session. Referred by: {referred_by_user_id or 'N/A'}."
|
||||
)
|
||||
|
||||
# Auto-grant referral welcome bonus to newly registered referred users.
|
||||
referral_welcome_days = max(
|
||||
0, int(getattr(settings, "REFERRAL_WELCOME_BONUS_DAYS", 0) or 0)
|
||||
)
|
||||
if referred_by_user_id and referral_welcome_days > 0:
|
||||
try:
|
||||
referral_bonus_end_date = await subscription_service.extend_active_subscription_days(
|
||||
session,
|
||||
user_id,
|
||||
referral_welcome_days,
|
||||
reason="referral_welcome_bonus",
|
||||
)
|
||||
if referral_bonus_end_date:
|
||||
await session.commit()
|
||||
logging.info(
|
||||
"Referral welcome bonus applied: user %s got %s days, new end date %s.",
|
||||
user_id,
|
||||
referral_welcome_days,
|
||||
referral_bonus_end_date.isoformat(),
|
||||
)
|
||||
await message.answer(
|
||||
_(
|
||||
"referral_welcome_bonus_applied",
|
||||
days=referral_welcome_days,
|
||||
end_date=referral_bonus_end_date.strftime("%d.%m.%Y %H:%M:%S"),
|
||||
),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
else:
|
||||
await session.rollback()
|
||||
logging.warning(
|
||||
"Referral welcome bonus was not applied for user %s (referred by %s).",
|
||||
user_id,
|
||||
referred_by_user_id,
|
||||
)
|
||||
except Exception as referral_bonus_error:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
"Failed to apply referral welcome bonus for user %s: %s",
|
||||
user_id,
|
||||
referral_bonus_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# Send notification about new user registration
|
||||
try:
|
||||
from bot.services.notification_service import NotificationService
|
||||
@@ -433,8 +636,12 @@ async def start_command_handler(message: types.Message,
|
||||
db_user):
|
||||
return
|
||||
|
||||
open_referral_page_for_existing_user = (
|
||||
should_open_referral_from_start and is_existing_user
|
||||
)
|
||||
|
||||
# Send welcome message if not disabled
|
||||
if not settings.DISABLE_WELCOME_MESSAGE:
|
||||
if not settings.DISABLE_WELCOME_MESSAGE and not open_referral_page_for_existing_user:
|
||||
await message.answer(_(key="welcome", user_name=hd.quote(user.full_name)))
|
||||
|
||||
# Auto-apply promo code if provided via start parameter
|
||||
@@ -453,35 +660,50 @@ async def start_command_handler(message: types.Message,
|
||||
|
||||
# Get updated subscription details
|
||||
active = await subscription_service.get_active_subscription_details(session, user_id)
|
||||
config_link = active.get("config_link") if active else None
|
||||
config_link = config_link or _("config_link_not_available")
|
||||
config_link_display = active.get("config_link") if active else None
|
||||
connect_button_url = active.get("connect_button_url") if active else None
|
||||
config_link_text = config_link_display or _("config_link_not_available")
|
||||
|
||||
new_end_date = result if isinstance(result, datetime) else None
|
||||
|
||||
promo_success_text = _(
|
||||
"promo_code_applied_success_full",
|
||||
end_date=(new_end_date.strftime("%d.%m.%Y %H:%M:%S") if new_end_date else "N/A"),
|
||||
config_link=config_link,
|
||||
config_link=config_link_text,
|
||||
)
|
||||
|
||||
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
|
||||
await message.answer(
|
||||
promo_success_text,
|
||||
reply_markup=get_connect_and_main_keyboard(current_lang, i18n, settings, config_link),
|
||||
reply_markup=get_connect_and_main_keyboard(
|
||||
current_lang,
|
||||
i18n,
|
||||
settings,
|
||||
config_link_display,
|
||||
connect_button_url=connect_button_url,
|
||||
),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
# Don't show main menu if promo was successfully applied
|
||||
return
|
||||
else:
|
||||
await session.rollback()
|
||||
await session.commit()
|
||||
logging.warning(f"Failed to auto-apply promo code '{promo_code_to_apply}' for user {user_id}: {result}")
|
||||
await message.answer(str(result), parse_mode="HTML")
|
||||
# Continue to show main menu if promo failed
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error auto-applying promo code '{promo_code_to_apply}' for user {user_id}: {e}")
|
||||
await session.rollback()
|
||||
|
||||
if open_referral_page_for_existing_user:
|
||||
from . import referral as user_referral_handlers
|
||||
await user_referral_handlers.referral_command_handler(
|
||||
message, settings, i18n_data, referral_service, message.bot, session
|
||||
)
|
||||
return
|
||||
|
||||
await send_main_menu(message,
|
||||
settings,
|
||||
i18n_data,
|
||||
@@ -490,6 +712,31 @@ async def start_command_handler(message: types.Message,
|
||||
is_edit=False)
|
||||
|
||||
|
||||
@router.message(Command("tg"))
|
||||
async def tg_interface_command_handler(message: types.Message,
|
||||
state: FSMContext,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
subscription_service: SubscriptionService,
|
||||
session: AsyncSession):
|
||||
await state.clear()
|
||||
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
db_user = await user_dal.get_user_by_id(session, message.from_user.id)
|
||||
if not await ensure_required_channel_subscription(message, settings, i18n,
|
||||
current_lang, session,
|
||||
db_user):
|
||||
return
|
||||
|
||||
await send_bot_interface_menu(message,
|
||||
settings,
|
||||
i18n_data,
|
||||
subscription_service,
|
||||
session,
|
||||
is_edit=False)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "channel_subscription:verify")
|
||||
async def verify_channel_subscription_callback(
|
||||
callback: types.CallbackQuery,
|
||||
@@ -528,8 +775,11 @@ async def verify_channel_subscription_callback(
|
||||
welcome_text)
|
||||
|
||||
try:
|
||||
await callback.answer(_(key="channel_subscription_verified_success"),
|
||||
show_alert=True)
|
||||
await safe_answer_callback(
|
||||
callback,
|
||||
_(key="channel_subscription_verified_success"),
|
||||
show_alert=True,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -560,7 +810,11 @@ async def language_command_handler(
|
||||
event, types.CallbackQuery) else event
|
||||
if not target_message_obj:
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
await event.answer(_("error_occurred_try_again"), show_alert=True)
|
||||
await safe_answer_callback(
|
||||
event,
|
||||
_("error_occurred_try_again"),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
@@ -571,7 +825,7 @@ async def language_command_handler(
|
||||
except Exception:
|
||||
await target_message_obj.answer(text_to_send,
|
||||
reply_markup=reply_markup)
|
||||
await event.answer()
|
||||
await safe_answer_callback(event)
|
||||
else:
|
||||
await target_message_obj.answer(text_to_send,
|
||||
reply_markup=reply_markup)
|
||||
@@ -583,15 +837,21 @@ async def select_language_callback_handler(
|
||||
subscription_service: SubscriptionService, session: AsyncSession):
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n or not callback.message:
|
||||
await callback.answer("Service error or message context lost.",
|
||||
show_alert=True)
|
||||
await safe_answer_callback(
|
||||
callback,
|
||||
"Service error or message context lost.",
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
lang_code = callback.data.split("_")[2]
|
||||
except IndexError:
|
||||
await callback.answer("Error processing language selection.",
|
||||
show_alert=True)
|
||||
await safe_answer_callback(
|
||||
callback,
|
||||
"Error processing language selection.",
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
user_id = callback.from_user.id
|
||||
@@ -602,18 +862,22 @@ async def select_language_callback_handler(
|
||||
|
||||
i18n_data["current_language"] = lang_code
|
||||
_ = lambda key, **kwargs: i18n.gettext(lang_code, key, **kwargs)
|
||||
await callback.answer(_(key="language_set_alert"))
|
||||
await safe_answer_callback(callback, _(key="language_set_alert"))
|
||||
logging.info(
|
||||
f"User {user_id} language updated to {lang_code} in session.")
|
||||
else:
|
||||
await callback.answer("Could not set language.", show_alert=True)
|
||||
await safe_answer_callback(
|
||||
callback,
|
||||
"Could not set language.",
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
except Exception as e_lang_update:
|
||||
|
||||
logging.error(
|
||||
f"Error updating lang for user {user_id}: {e_lang_update}",
|
||||
exc_info=True)
|
||||
await callback.answer("Error setting language.", show_alert=True)
|
||||
await safe_answer_callback(callback, "Error setting language.", show_alert=True)
|
||||
return
|
||||
await send_main_menu(callback,
|
||||
settings,
|
||||
@@ -638,7 +902,11 @@ async def main_action_callback_handler(
|
||||
from . import trial_handler as user_trial_handlers
|
||||
|
||||
if not callback.message:
|
||||
await callback.answer("Error: message context lost.", show_alert=True)
|
||||
await safe_answer_callback(
|
||||
callback,
|
||||
"Error: message context lost.",
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
if action == "subscribe":
|
||||
@@ -664,6 +932,44 @@ async def main_action_callback_handler(
|
||||
elif action == "language":
|
||||
|
||||
await language_command_handler(callback, i18n_data, settings)
|
||||
elif action == "info":
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
current_lang = i18n_data.get("current_language",
|
||||
settings.DEFAULT_LANGUAGE)
|
||||
if not i18n:
|
||||
await safe_answer_callback(
|
||||
callback,
|
||||
"Language service error.",
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(
|
||||
current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
privacy_url = settings.PRIVACY_POLICY_URL
|
||||
user_agreement_url = settings.USER_AGREEMENT_URL or settings.TERMS_OF_SERVICE_URL
|
||||
|
||||
if not privacy_url and not user_agreement_url:
|
||||
await safe_answer_callback(
|
||||
callback,
|
||||
_("error_occurred_try_again"),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
reply_markup = get_information_links_keyboard(
|
||||
current_lang,
|
||||
i18n,
|
||||
privacy_url,
|
||||
user_agreement_url,
|
||||
)
|
||||
try:
|
||||
await callback.message.edit_text(_(key="info_links_message"),
|
||||
reply_markup=reply_markup)
|
||||
except Exception:
|
||||
await callback.message.answer(_(key="info_links_message"),
|
||||
reply_markup=reply_markup)
|
||||
await safe_answer_callback(callback)
|
||||
elif action == "back_to_main":
|
||||
await send_main_menu(callback,
|
||||
settings,
|
||||
@@ -681,5 +987,9 @@ async def main_action_callback_handler(
|
||||
else:
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
_ = lambda key, **kwargs: i18n.gettext(
|
||||
i18n_data.get("current_language"), key, **kw) if i18n else key
|
||||
await callback.answer(_("main_menu_unknown_action"), show_alert=True)
|
||||
i18n_data.get("current_language"), key, **kwargs) if i18n else key
|
||||
await safe_answer_callback(
|
||||
callback,
|
||||
_("main_menu_unknown_action"),
|
||||
show_alert=True,
|
||||
)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import hashlib
|
||||
import logging
|
||||
from aiogram import Router, F, types, Bot
|
||||
from aiogram.filters import Command
|
||||
@@ -16,12 +17,28 @@ from bot.keyboards.inline.user_keyboards import (
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from db.dal import subscription_dal
|
||||
from db.dal import subscription_dal, user_billing_dal
|
||||
from db.models import Subscription
|
||||
|
||||
router = Router(name="user_subscription_core_router")
|
||||
|
||||
|
||||
def _shorten_hwid_for_display(hwid: Optional[str], max_length: int = 24) -> str:
|
||||
"""Trim HWID for button text to keep within Telegram limits."""
|
||||
if not hwid:
|
||||
return "-"
|
||||
hwid_str = str(hwid)
|
||||
if len(hwid_str) <= max_length:
|
||||
return hwid_str
|
||||
return f"{hwid_str[:8]}...{hwid_str[-6:]}"
|
||||
|
||||
|
||||
def _hwid_callback_token(hwid: Optional[str]) -> str:
|
||||
"""Stable short token for callback_data; avoids 64b limit with raw HWID."""
|
||||
hwid_str = str(hwid or "")
|
||||
return hashlib.sha256(hwid_str.encode()).hexdigest()[:32]
|
||||
|
||||
|
||||
async def display_subscription_options(event: Union[types.Message, types.CallbackQuery], i18n_data: dict, settings: Settings, session: AsyncSession):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
@@ -40,13 +57,29 @@ async def display_subscription_options(event: Union[types.Message, types.Callbac
|
||||
return
|
||||
|
||||
currency_symbol_val = settings.DEFAULT_CURRENCY_SYMBOL
|
||||
text_content = get_text("select_subscription_period") if settings.subscription_options else get_text("no_subscription_options_available")
|
||||
traffic_packages = getattr(settings, "traffic_packages", {}) or {}
|
||||
stars_traffic_packages = getattr(settings, "stars_traffic_packages", {}) or {}
|
||||
traffic_mode = bool(getattr(settings, "traffic_sale_mode", False) or stars_traffic_packages)
|
||||
|
||||
reply_markup = (
|
||||
get_subscription_options_keyboard(settings.subscription_options, currency_symbol_val, current_lang, i18n)
|
||||
if settings.subscription_options
|
||||
else get_back_to_main_menu_markup(current_lang, i18n)
|
||||
)
|
||||
if traffic_mode:
|
||||
if traffic_packages:
|
||||
options = traffic_packages
|
||||
elif stars_traffic_packages:
|
||||
options = stars_traffic_packages
|
||||
currency_symbol_val = "⭐"
|
||||
else:
|
||||
options = {}
|
||||
else:
|
||||
options = settings.subscription_options
|
||||
|
||||
if options:
|
||||
text_content = get_text("select_traffic_package") if traffic_mode else get_text("select_subscription_period")
|
||||
reply_markup = get_subscription_options_keyboard(
|
||||
options, currency_symbol_val, current_lang, i18n, traffic_mode=traffic_mode
|
||||
)
|
||||
else:
|
||||
text_content = get_text("no_subscription_options_available")
|
||||
reply_markup = get_back_to_main_menu_markup(current_lang, i18n)
|
||||
|
||||
target_message_obj = event.message if isinstance(event, types.CallbackQuery) else event
|
||||
if not target_message_obj:
|
||||
@@ -104,7 +137,7 @@ async def my_subscription_command_handler(
|
||||
text = get_text("subscription_not_active")
|
||||
|
||||
buy_button = InlineKeyboardButton(
|
||||
text=get_text("menu_subscribe_inline", default="Купить"), callback_data="main_action:subscribe"
|
||||
text=get_text("menu_subscribe_inline"), callback_data="main_action:subscribe"
|
||||
)
|
||||
back_markup = get_back_to_main_menu_markup(current_lang, i18n)
|
||||
|
||||
@@ -125,28 +158,78 @@ async def my_subscription_command_handler(
|
||||
|
||||
end_date = active.get("end_date")
|
||||
days_left = (end_date.date() - datetime.now().date()).days if end_date else 0
|
||||
tribute_hint = ""
|
||||
if active.get("status_from_panel", "").lower() == "active":
|
||||
local_sub = await subscription_dal.get_active_subscription_by_user_id(session, event.from_user.id)
|
||||
if local_sub:
|
||||
if local_sub.provider == "tribute":
|
||||
link = None
|
||||
link = settings.tribute_payment_links.get(local_sub.duration_months or 1) if hasattr(settings, "tribute_payment_links") else None
|
||||
tribute_hint = "\n\n" + (
|
||||
get_text("subscription_tribute_notice_with_link", link=link) if link else get_text("subscription_tribute_notice")
|
||||
)
|
||||
traffic_mode = bool(getattr(settings, "traffic_sale_mode", False))
|
||||
config_link_display = active.get("config_link")
|
||||
connect_button_url = active.get("connect_button_url")
|
||||
config_link_value = config_link_display or get_text("config_link_not_available")
|
||||
def _fmt_gb(val: Optional[float]) -> str:
|
||||
if val is None:
|
||||
return get_text("traffic_na")
|
||||
try:
|
||||
if isinstance(val, (int, float)):
|
||||
val_gb = float(val) / (2**30)
|
||||
return f"{val_gb:.2f} GB"
|
||||
except Exception:
|
||||
pass
|
||||
return str(val)
|
||||
def _format_traffic_period(strategy: Optional[str]) -> Optional[str]:
|
||||
if not strategy:
|
||||
return None
|
||||
strategy_upper = str(strategy).upper()
|
||||
key_map = {
|
||||
"MONTH": "traffic_period_month",
|
||||
"WEEK": "traffic_period_week",
|
||||
"DAY": "traffic_period_day",
|
||||
"NO_RESET": "traffic_period_no_reset",
|
||||
}
|
||||
label_key = key_map.get(strategy_upper)
|
||||
return get_text(label_key) if label_key else strategy_upper
|
||||
|
||||
text = get_text(
|
||||
"my_subscription_details",
|
||||
end_date=end_date.strftime("%Y-%m-%d") if end_date else "N/A",
|
||||
days_left=max(0, days_left),
|
||||
status=active.get("status_from_panel", get_text("status_active")).capitalize(),
|
||||
config_link=active.get("config_link") or get_text("config_link_not_available"),
|
||||
traffic_limit=(f"{active['traffic_limit_bytes'] / 2**30:.2f} GB" if active.get("traffic_limit_bytes") else get_text("traffic_unlimited")),
|
||||
traffic_used=(
|
||||
f"{active['traffic_used_bytes'] / 2**30:.2f} GB" if active.get("traffic_used_bytes") is not None else get_text("traffic_na")
|
||||
),
|
||||
)
|
||||
def _format_used_with_period(used_display: str, period_label: Optional[str]) -> str:
|
||||
if not period_label:
|
||||
return used_display
|
||||
return get_text("traffic_used_with_period", traffic_used=used_display, traffic_period=period_label)
|
||||
|
||||
period_label = _format_traffic_period(active.get("traffic_limit_strategy"))
|
||||
period_label = period_label or get_text("traffic_period_unknown")
|
||||
|
||||
if traffic_mode:
|
||||
limit_display = _fmt_gb(active.get("traffic_limit_bytes"))
|
||||
used_display = _format_used_with_period(_fmt_gb(active.get("traffic_used_bytes")), period_label)
|
||||
remaining_display = get_text("traffic_na")
|
||||
try:
|
||||
limit_val = active.get("traffic_limit_bytes") or 0
|
||||
used_val = active.get("traffic_used_bytes") or 0
|
||||
remaining_val = max(0, float(limit_val) - float(used_val))
|
||||
remaining_display = _fmt_gb(remaining_val)
|
||||
except Exception:
|
||||
pass
|
||||
text = get_text(
|
||||
"my_traffic_details",
|
||||
status=active.get("status_from_panel", get_text("status_active")).capitalize(),
|
||||
end_date=end_date.strftime("%Y-%m-%d") if end_date else get_text("traffic_no_expiry"),
|
||||
traffic_limit=limit_display,
|
||||
traffic_used=used_display,
|
||||
traffic_left=remaining_display,
|
||||
traffic_period=period_label,
|
||||
config_link=config_link_value,
|
||||
)
|
||||
else:
|
||||
text = get_text(
|
||||
"my_subscription_details",
|
||||
end_date=end_date.strftime("%Y-%m-%d") if end_date else "N/A",
|
||||
days_left=max(0, days_left),
|
||||
status=active.get("status_from_panel", get_text("status_active")).capitalize(),
|
||||
config_link=config_link_value,
|
||||
traffic_limit=(f"{active['traffic_limit_bytes'] / 2**30:.2f} GB" if active.get("traffic_limit_bytes") else get_text("traffic_unlimited")),
|
||||
traffic_used=(
|
||||
_format_used_with_period(
|
||||
f"{active['traffic_used_bytes'] / 2**30:.2f} GB" if active.get("traffic_used_bytes") is not None else get_text("traffic_na"),
|
||||
period_label,
|
||||
)
|
||||
),
|
||||
traffic_period=period_label,
|
||||
)
|
||||
|
||||
base_markup = get_back_to_main_menu_markup(current_lang, i18n)
|
||||
kb = base_markup.inline_keyboard
|
||||
@@ -155,23 +238,22 @@ async def my_subscription_command_handler(
|
||||
# Build rows to prepend above the base "back" markup
|
||||
prepend_rows = []
|
||||
|
||||
# 1) Mini-app connect button on top if enabled, otherwise fall back to config link URL
|
||||
if settings.SUBSCRIPTION_MINI_APP_URL:
|
||||
# 1) Connect button: prefer the actual subscription URL; fall back to mini-app
|
||||
cfg_link_val = connect_button_url or config_link_display
|
||||
if cfg_link_val:
|
||||
prepend_rows.append([
|
||||
InlineKeyboardButton(
|
||||
text=get_text("connect_button"),
|
||||
url=cfg_link_val,
|
||||
)
|
||||
])
|
||||
elif settings.SUBSCRIPTION_MINI_APP_URL:
|
||||
prepend_rows.append([
|
||||
InlineKeyboardButton(
|
||||
text=get_text("connect_button"),
|
||||
web_app=WebAppInfo(url=settings.SUBSCRIPTION_MINI_APP_URL),
|
||||
)
|
||||
])
|
||||
else:
|
||||
cfg_link_val = (active or {}).get("config_link")
|
||||
if cfg_link_val:
|
||||
prepend_rows.append([
|
||||
InlineKeyboardButton(
|
||||
text=get_text("connect_button"),
|
||||
url=cfg_link_val,
|
||||
)
|
||||
])
|
||||
|
||||
if settings.MY_DEVICES_SECTION_ENABLED:
|
||||
max_devices_value = active.get("max_devices")
|
||||
@@ -224,8 +306,8 @@ async def my_subscription_command_handler(
|
||||
)
|
||||
])
|
||||
|
||||
# 2) Auto-renew toggle (if supported and not tribute)
|
||||
if local_sub and local_sub.provider != "tribute" and getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
|
||||
# 2) Auto-renew toggle (YooKassa only)
|
||||
if not traffic_mode and local_sub and local_sub.provider == "yookassa" and settings.yookassa_autopayments_active:
|
||||
toggle_text = (
|
||||
get_text("autorenew_disable_button") if local_sub.auto_renew_enabled else get_text("autorenew_enable_button")
|
||||
)
|
||||
@@ -237,7 +319,7 @@ async def my_subscription_command_handler(
|
||||
])
|
||||
|
||||
# 3) Payment methods management (when autopayments enabled)
|
||||
if getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
|
||||
if not traffic_mode and settings.yookassa_autopayments_active:
|
||||
prepend_rows.append([
|
||||
InlineKeyboardButton(text=get_text("payment_methods_manage_button"), callback_data="pm:manage")
|
||||
])
|
||||
@@ -254,17 +336,17 @@ async def my_subscription_command_handler(
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await event.message.edit_text(text + tribute_hint, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True)
|
||||
await event.message.edit_text(text, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True)
|
||||
except Exception:
|
||||
await bot.send_message(
|
||||
chat_id=target.chat.id,
|
||||
text=text + tribute_hint,
|
||||
text=text,
|
||||
reply_markup=markup,
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
else:
|
||||
await target.answer(text + tribute_hint, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True)
|
||||
await target.answer(text, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "main_action:my_devices")
|
||||
@@ -321,6 +403,12 @@ async def my_devices_command_handler(
|
||||
await target.answer(get_text("no_devices_found"))
|
||||
return
|
||||
|
||||
devices_list_raw = []
|
||||
if isinstance(devices, dict):
|
||||
devices_list_raw = devices.get("devices") or []
|
||||
elif isinstance(devices, list):
|
||||
devices_list_raw = devices
|
||||
|
||||
max_devices_value = active.get("max_devices")
|
||||
max_devices_display = get_text("devices_unlimited_label")
|
||||
if max_devices_value not in (None, 0):
|
||||
@@ -331,19 +419,22 @@ async def my_devices_command_handler(
|
||||
except (TypeError, ValueError):
|
||||
max_devices_display = str(max_devices_value)
|
||||
|
||||
if not devices or not devices.get('devices') or len(devices.get('devices')) == 0:
|
||||
if not devices_list_raw:
|
||||
text = get_text("no_devices_details_found_message", max_devices=max_devices_display)
|
||||
else:
|
||||
devices_list = []
|
||||
current_devices = len(devices.get('devices') or [])
|
||||
for index, device in enumerate(devices.get('devices') or [], start=1):
|
||||
current_devices = len(devices_list_raw)
|
||||
for index, device in enumerate(devices_list_raw, start=1):
|
||||
device_model = device.get('deviceModel') or None
|
||||
platform = device.get('platform') or None
|
||||
user_agent = device.get('userAgent') or None
|
||||
os_version = device.get('osVersion') or None
|
||||
created_at = device.get('createdAt')
|
||||
hwid = device.get('hwid')
|
||||
created_at_str = datetime.fromisoformat(created_at).strftime("%d.%m.%Y %H:%M")
|
||||
try:
|
||||
created_at_str = datetime.fromisoformat(created_at).strftime("%d.%m.%Y %H:%M") if created_at else "-"
|
||||
except Exception:
|
||||
created_at_str = str(created_at)
|
||||
|
||||
device_details = get_text("device_details", index=index, device_model=device_model, platform=platform, os_version=os_version, created_at_str=created_at_str, user_agent=user_agent, hwid=hwid)
|
||||
devices_list.append(device_details)
|
||||
@@ -354,11 +445,14 @@ async def my_devices_command_handler(
|
||||
kb = base_markup.inline_keyboard
|
||||
|
||||
devices_kb = []
|
||||
for index, device in enumerate(devices.get('devices') or [], start=1):
|
||||
for index, device in enumerate(devices_list_raw, start=1):
|
||||
hwid = device.get('hwid')
|
||||
device_button_text = get_text("disconnect_device_button", hwid=hwid, index=index)
|
||||
if not hwid:
|
||||
continue
|
||||
device_button_text = get_text("disconnect_device_button", hwid=_shorten_hwid_for_display(hwid), index=index)
|
||||
hwid_token = _hwid_callback_token(hwid)
|
||||
|
||||
devices_kb.append([InlineKeyboardButton(text=device_button_text, callback_data=f"disconnect_device:{hwid}")])
|
||||
devices_kb.append([InlineKeyboardButton(text=device_button_text, callback_data=f"disconnect_device:{hwid_token}")])
|
||||
kb = devices_kb + kb
|
||||
markup = InlineKeyboardMarkup(inline_keyboard=kb)
|
||||
|
||||
@@ -397,7 +491,7 @@ async def disconnect_device_handler(
|
||||
return
|
||||
|
||||
try:
|
||||
_, hwid = callback.data.split(":", 1)
|
||||
_, hwid_token = callback.data.split(":", 1)
|
||||
except Exception:
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
@@ -406,10 +500,32 @@ async def disconnect_device_handler(
|
||||
return
|
||||
|
||||
active = await subscription_service.get_active_subscription_details(session, callback.from_user.id)
|
||||
if not active:
|
||||
if not active or not active.get("user_id"):
|
||||
await callback.answer(get_text("subscription_not_active"), show_alert=True)
|
||||
return
|
||||
|
||||
devices = await panel_service.get_user_devices(active.get("user_id"))
|
||||
if not devices:
|
||||
await callback.answer(get_text("no_devices_found"), show_alert=True)
|
||||
return
|
||||
|
||||
devices_list_raw = []
|
||||
if isinstance(devices, dict):
|
||||
devices_list_raw = devices.get("devices") or []
|
||||
elif isinstance(devices, list):
|
||||
devices_list_raw = devices
|
||||
|
||||
hwid = None
|
||||
for device in devices_list_raw:
|
||||
hwid_candidate = device.get("hwid")
|
||||
if hwid_candidate and _hwid_callback_token(hwid_candidate) == hwid_token:
|
||||
hwid = hwid_candidate
|
||||
break
|
||||
|
||||
if not hwid:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
return
|
||||
|
||||
success = await panel_service.disconnect_device(active.get("user_id"), hwid)
|
||||
if not success:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
@@ -452,9 +568,17 @@ async def toggle_autorenew_handler(
|
||||
if not sub or sub.user_id != callback.from_user.id:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
return
|
||||
if sub.provider == "tribute":
|
||||
await callback.answer(get_text("subscription_autorenew_not_supported_for_tribute"), show_alert=True)
|
||||
if sub.provider != "yookassa":
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
return
|
||||
if enable:
|
||||
has_saved_card = await user_billing_dal.user_has_saved_payment_method(session, callback.from_user.id)
|
||||
if not has_saved_card:
|
||||
try:
|
||||
await callback.answer(get_text("autorenew_enable_requires_card"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
# Show confirmation popup and inline buttons
|
||||
confirm_text = get_text("autorenew_confirm_enable") if enable else get_text("autorenew_confirm_disable")
|
||||
@@ -502,9 +626,21 @@ async def confirm_autorenew_handler(
|
||||
if not sub or sub.user_id != callback.from_user.id:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
return
|
||||
if sub.provider == "tribute":
|
||||
await callback.answer(get_text("subscription_autorenew_not_supported_for_tribute"), show_alert=True)
|
||||
if sub.provider != "yookassa":
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
return
|
||||
if enable:
|
||||
has_saved_card = await user_billing_dal.user_has_saved_payment_method(session, callback.from_user.id)
|
||||
if not has_saved_card:
|
||||
try:
|
||||
await callback.answer(get_text("autorenew_enable_requires_card"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await my_subscription_command_handler(callback, i18n_data, settings, panel_service, subscription_service, session, bot)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
await subscription_dal.update_subscription(session, sub.subscription_id, {"auto_renew_enabled": enable})
|
||||
await session.commit()
|
||||
@@ -529,7 +665,7 @@ async def autorenew_cancel_from_webhook_button(
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
# Disable auto-renew on the active subscription (non-tribute)
|
||||
# Disable auto-renew on the active subscription
|
||||
from db.dal import subscription_dal
|
||||
sub = await subscription_dal.get_active_subscription_by_user_id(session, callback.from_user.id)
|
||||
if not sub:
|
||||
@@ -538,9 +674,9 @@ async def autorenew_cancel_from_webhook_button(
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
if sub.provider == "tribute":
|
||||
if sub.provider != "yookassa":
|
||||
try:
|
||||
await callback.answer(get_text("subscription_autorenew_not_supported_for_tribute"), show_alert=True)
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
@@ -22,7 +22,7 @@ router = Router(name="user_subscription_payment_methods_router")
|
||||
async def payment_methods_manage(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
|
||||
if not settings.yookassa_autopayments_active:
|
||||
try:
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
await callback.answer(_("error_service_unavailable"), show_alert=True)
|
||||
@@ -51,9 +51,9 @@ async def payment_methods_manage(callback: types.CallbackQuery, settings: Settin
|
||||
return get_text("payment_method_wallet_title", last4=l4)
|
||||
return get_text("payment_method_wallet_title", last4="****")
|
||||
if last4:
|
||||
network_name = network or get_text("payment_network_card", default="Card")
|
||||
network_name = network or get_text("payment_network_card")
|
||||
return get_text("payment_method_card_title", network=network_name, last4=last4)
|
||||
network_name = network or get_text("payment_network_generic", default="Payment method")
|
||||
network_name = network or get_text("payment_network_generic")
|
||||
return get_text("payment_method_generic_title", network=network_name)
|
||||
|
||||
for m in methods:
|
||||
@@ -75,7 +75,7 @@ async def payment_methods_manage(callback: types.CallbackQuery, settings: Settin
|
||||
async def payment_method_bind(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession, yookassa_service: YooKassaService):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
|
||||
if not settings.yookassa_autopayments_active:
|
||||
try:
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
await callback.answer(_("error_service_unavailable"), show_alert=True)
|
||||
@@ -109,7 +109,7 @@ async def payment_method_bind(callback: types.CallbackQuery, settings: Settings,
|
||||
async def payment_method_delete_confirm(callback: types.CallbackQuery, settings: Settings, i18n_data: dict):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
|
||||
if not settings.yookassa_autopayments_active:
|
||||
try:
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
await callback.answer(_("error_service_unavailable"), show_alert=True)
|
||||
@@ -130,7 +130,7 @@ async def payment_method_delete_confirm(callback: types.CallbackQuery, settings:
|
||||
async def payment_method_delete(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
|
||||
if not settings.yookassa_autopayments_active:
|
||||
try:
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
await callback.answer(_("error_service_unavailable"), show_alert=True)
|
||||
@@ -177,9 +177,9 @@ async def payment_method_delete(callback: types.CallbackQuery, settings: Setting
|
||||
return _("payment_method_wallet_title", last4=l4)
|
||||
return _("payment_method_wallet_title", last4="****")
|
||||
if last4:
|
||||
network_name = network or _("payment_network_card", default="Card")
|
||||
network_name = network or _("payment_network_card")
|
||||
return _("payment_method_card_title", network=network_name, last4=last4)
|
||||
network_name = network or _("payment_network_generic", default="Payment method")
|
||||
network_name = network or _("payment_network_generic")
|
||||
return _("payment_method_generic_title", network=network_name)
|
||||
title = _format_pm_title(m.card_network, m.card_last4)
|
||||
cards.append((str(m.method_id), title if not m.is_default else f"⭐ {title}"))
|
||||
@@ -204,7 +204,7 @@ async def payment_method_delete(callback: types.CallbackQuery, settings: Setting
|
||||
async def payment_method_view(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
|
||||
if not settings.yookassa_autopayments_active:
|
||||
try:
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
await callback.answer(_("error_service_unavailable"), show_alert=True)
|
||||
@@ -239,9 +239,9 @@ async def payment_method_view(callback: types.CallbackQuery, settings: Settings,
|
||||
return _("payment_method_wallet_title", last4=l4)
|
||||
return _("payment_method_wallet_title", last4="****")
|
||||
if last4:
|
||||
network_name = network or _("payment_network_card", default="Card")
|
||||
network_name = network or _("payment_network_card")
|
||||
return _("payment_method_card_title", network=network_name, last4=last4)
|
||||
network_name = network or _("payment_network_generic", default="Payment method")
|
||||
network_name = network or _("payment_network_generic")
|
||||
return _("payment_method_generic_title", network=network_name)
|
||||
|
||||
title = _format_pm_title(sel.card_network, sel.card_last4)
|
||||
@@ -307,9 +307,9 @@ async def payment_method_view(callback: types.CallbackQuery, settings: Settings,
|
||||
return _("payment_method_wallet_title", last4=l4)
|
||||
return _("payment_method_wallet_title", last4="****")
|
||||
if last4:
|
||||
network_name = network or _("payment_network_card", default="Card")
|
||||
network_name = network or _("payment_network_card")
|
||||
return _("payment_method_card_title", network=network_name, last4=last4)
|
||||
network_name = network or _("payment_network_generic", default="Payment method")
|
||||
network_name = network or _("payment_network_generic")
|
||||
return _("payment_method_generic_title", network=network_name)
|
||||
|
||||
title = _format_pm_title(billing.card_network, billing.card_last4)
|
||||
@@ -325,7 +325,7 @@ async def payment_method_view(callback: types.CallbackQuery, settings: Settings,
|
||||
async def payment_method_history(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession, yookassa_service: YooKassaService):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
|
||||
if not settings.yookassa_autopayments_active:
|
||||
try:
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
await callback.answer(_("error_service_unavailable"), show_alert=True)
|
||||
@@ -390,8 +390,15 @@ async def payment_method_history(callback: types.CallbackQuery, settings: Settin
|
||||
await callback.message.edit_text(_("payment_method_no_history"), reply_markup=back_markup)
|
||||
return
|
||||
|
||||
traffic_mode = getattr(settings, "traffic_sale_mode", False)
|
||||
|
||||
def _format_item(p: Payment) -> str:
|
||||
title = p.description or _("subscription_purchase_title", months=p.subscription_duration_months or 1)
|
||||
if traffic_mode:
|
||||
units_val = p.subscription_duration_months or 0
|
||||
units_display = str(int(units_val)) if float(units_val).is_integer() else f"{units_val:g}"
|
||||
title = p.description or _("traffic_purchase_title", traffic_gb=units_display)
|
||||
else:
|
||||
title = p.description or _("subscription_purchase_title", months=p.subscription_duration_months or 1)
|
||||
date_str = p.created_at.strftime('%Y-%m-%d') if p.created_at else "N/A"
|
||||
return f"{date_str} — {title} — {p.amount:.2f} {p.currency}"
|
||||
|
||||
@@ -433,9 +440,9 @@ async def payment_methods_list(callback: types.CallbackQuery, settings: Settings
|
||||
return get_text("payment_method_wallet_title", last4=l4)
|
||||
return get_text("payment_method_wallet_title", last4="****")
|
||||
if last4:
|
||||
network_name = network or get_text("payment_network_card", default="Card")
|
||||
network_name = network or get_text("payment_network_card")
|
||||
return get_text("payment_method_card_title", network=network_name, last4=last4)
|
||||
network_name = network or get_text("payment_network_generic", default="Payment method")
|
||||
network_name = network or get_text("payment_network_generic")
|
||||
return get_text("payment_method_generic_title", network=network_name)
|
||||
title = _format_pm_title(m.card_network, m.card_last4)
|
||||
cards.append((str(m.method_id), title if not m.is_default else f"⭐ {title}"))
|
||||
@@ -455,4 +462,3 @@ async def payment_methods_list(callback: types.CallbackQuery, settings: Settings
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,115 @@
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import F, Router, types
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot.keyboards.inline.user_keyboards import get_payment_url_keyboard
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.crypto_pay_service import CryptoPayService
|
||||
from config.settings import Settings
|
||||
|
||||
router = Router(name="user_subscription_payments_crypto_router")
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("pay_crypto:"))
|
||||
async def pay_crypto_callback_handler(
|
||||
callback: types.CallbackQuery,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
session: AsyncSession,
|
||||
cryptopay_service: CryptoPayService,
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
get_text = (lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key)
|
||||
|
||||
if not i18n or not callback.message:
|
||||
try:
|
||||
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
if not cryptopay_service or not getattr(cryptopay_service, "configured", False):
|
||||
try:
|
||||
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
_, data_payload = callback.data.split(":", 1)
|
||||
parts = data_payload.split(":")
|
||||
months = float(parts[0])
|
||||
price_amount = float(parts[1])
|
||||
sale_mode = parts[2] if len(parts) > 2 else "subscription"
|
||||
except (ValueError, IndexError):
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
user_id = callback.from_user.id
|
||||
human_value = str(int(months)) if float(months).is_integer() else f"{months:g}"
|
||||
payment_description = (
|
||||
get_text("payment_description_traffic", traffic_gb=human_value)
|
||||
if sale_mode == "traffic"
|
||||
else get_text("payment_description_subscription", months=int(months))
|
||||
)
|
||||
|
||||
invoice_url = await cryptopay_service.create_invoice(
|
||||
session=session,
|
||||
user_id=user_id,
|
||||
months=months,
|
||||
amount=price_amount,
|
||||
description=payment_description,
|
||||
sale_mode=sale_mode,
|
||||
)
|
||||
|
||||
if invoice_url:
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
get_text(
|
||||
key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message",
|
||||
months=int(months),
|
||||
traffic_gb=human_value,
|
||||
),
|
||||
reply_markup=get_payment_url_keyboard(
|
||||
invoice_url,
|
||||
current_lang,
|
||||
i18n,
|
||||
back_callback=f"subscribe_period:{human_value}",
|
||||
back_text_key="back_to_payment_methods_button",
|
||||
),
|
||||
disable_web_page_preview=False,
|
||||
)
|
||||
except Exception:
|
||||
try:
|
||||
await callback.message.answer(
|
||||
get_text(
|
||||
key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message",
|
||||
months=int(months),
|
||||
traffic_gb=human_value,
|
||||
),
|
||||
reply_markup=get_payment_url_keyboard(
|
||||
invoice_url,
|
||||
current_lang,
|
||||
i18n,
|
||||
back_callback=f"subscribe_period:{human_value}",
|
||||
back_text_key="back_to_payment_methods_button",
|
||||
),
|
||||
disable_web_page_preview=False,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
await callback.answer(get_text("error_payment_gateway"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,214 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import F, Router, types
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot.keyboards.inline.user_keyboards import get_payment_url_keyboard
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.freekassa_service import FreeKassaService
|
||||
from config.settings import Settings
|
||||
from db.dal import payment_dal
|
||||
|
||||
router = Router(name="user_subscription_payments_freekassa_router")
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("pay_fk:"))
|
||||
async def pay_fk_callback_handler(
|
||||
callback: types.CallbackQuery,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
freekassa_service: FreeKassaService,
|
||||
session: AsyncSession,
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
if not i18n or not callback.message:
|
||||
try:
|
||||
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
if not freekassa_service or not freekassa_service.configured:
|
||||
logging.error("FreeKassa service is not configured or unavailable.")
|
||||
try:
|
||||
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await callback.message.edit_text(get_text("payment_service_unavailable"))
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
_, data_payload = callback.data.split(":", 1)
|
||||
parts = data_payload.split(":")
|
||||
months = float(parts[0])
|
||||
price_rub = float(parts[1])
|
||||
sale_mode = parts[2] if len(parts) > 2 else "subscription"
|
||||
except (ValueError, IndexError):
|
||||
logging.error(f"Invalid pay_fk data in callback: {callback.data}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
user_id = callback.from_user.id
|
||||
human_value = str(int(months)) if float(months).is_integer() else f"{months:g}"
|
||||
payment_description = (
|
||||
get_text("payment_description_traffic", traffic_gb=human_value)
|
||||
if sale_mode == "traffic"
|
||||
else get_text("payment_description_subscription", months=int(months))
|
||||
)
|
||||
currency_code = getattr(freekassa_service, "default_currency", None) or settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
|
||||
|
||||
payment_record_payload = {
|
||||
"user_id": user_id,
|
||||
"amount": price_rub,
|
||||
"currency": currency_code,
|
||||
"status": "pending_freekassa",
|
||||
"description": payment_description,
|
||||
"subscription_duration_months": int(months),
|
||||
"provider": "freekassa",
|
||||
}
|
||||
|
||||
try:
|
||||
payment_record = await payment_dal.create_payment_record(session, payment_record_payload)
|
||||
await session.commit()
|
||||
except Exception as e_db_create:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"FreeKassa: failed to create payment record for user {user_id}: {e_db_create}",
|
||||
exc_info=True,
|
||||
)
|
||||
try:
|
||||
await callback.message.edit_text(get_text("error_creating_payment_record"))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
success, response_data = await freekassa_service.create_order(
|
||||
payment_db_id=payment_record.payment_id,
|
||||
user_id=payment_record.user_id,
|
||||
months=months,
|
||||
amount=price_rub,
|
||||
currency=freekassa_service.default_currency,
|
||||
payment_method_id=freekassa_service.payment_method_id,
|
||||
ip_address=freekassa_service.server_ip,
|
||||
extra_params={
|
||||
"us_method": freekassa_service.payment_method_id,
|
||||
},
|
||||
)
|
||||
|
||||
if success:
|
||||
location = response_data.get("location")
|
||||
order_hash = response_data.get("orderHash")
|
||||
order_id_api = response_data.get("orderId")
|
||||
provider_identifier = order_hash or order_id_api
|
||||
|
||||
if provider_identifier:
|
||||
try:
|
||||
await payment_dal.update_provider_payment_and_status(
|
||||
session,
|
||||
payment_record.payment_id,
|
||||
str(provider_identifier),
|
||||
payment_record.status,
|
||||
)
|
||||
await session.commit()
|
||||
except Exception as e_status:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"FreeKassa: failed to store provider order id for payment {payment_record.payment_id}: {e_status}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if location:
|
||||
order_identifier_display = str(order_id_api or provider_identifier or payment_record.payment_id)
|
||||
order_info_text = get_text(
|
||||
"free_kassa_order_info",
|
||||
order_id=order_identifier_display,
|
||||
date=datetime.now().strftime("%Y-%m-%d"),
|
||||
)
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
f"{order_info_text}\n\n" + get_text(
|
||||
key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message",
|
||||
months=int(months),
|
||||
traffic_gb=human_value,
|
||||
),
|
||||
reply_markup=get_payment_url_keyboard(
|
||||
location,
|
||||
current_lang,
|
||||
i18n,
|
||||
back_callback=f"subscribe_period:{human_value}",
|
||||
back_text_key="back_to_payment_methods_button",
|
||||
),
|
||||
disable_web_page_preview=False,
|
||||
)
|
||||
except Exception as e_edit:
|
||||
logging.warning(f"FreeKassa: failed to display payment link ({e_edit}), sending new message.")
|
||||
try:
|
||||
await callback.message.answer(
|
||||
f"{order_info_text}\n\n" + get_text(
|
||||
key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message",
|
||||
months=int(months),
|
||||
traffic_gb=human_value,
|
||||
),
|
||||
reply_markup=get_payment_url_keyboard(
|
||||
location,
|
||||
current_lang,
|
||||
i18n,
|
||||
back_callback=f"subscribe_period:{human_value}",
|
||||
back_text_key="back_to_payment_methods_button",
|
||||
),
|
||||
disable_web_page_preview=False,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
logging.error(
|
||||
"FreeKassa: create_order succeeded but no payment link returned for payment %s. Response: %s",
|
||||
payment_record.payment_id,
|
||||
response_data,
|
||||
)
|
||||
else:
|
||||
logging.error(
|
||||
"FreeKassa: create_order failed for payment %s with response %s",
|
||||
payment_record.payment_id,
|
||||
response_data,
|
||||
)
|
||||
|
||||
try:
|
||||
await payment_dal.update_payment_status_by_db_id(
|
||||
session,
|
||||
payment_record.payment_id,
|
||||
"failed_creation",
|
||||
)
|
||||
await session.commit()
|
||||
except Exception as e_status:
|
||||
await session.rollback()
|
||||
logging.error(f"FreeKassa: failed to mark payment {payment_record.payment_id} as failed_creation: {e_status}", exc_info=True)
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(get_text("error_payment_gateway"))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await callback.answer(get_text("error_payment_gateway"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,239 @@
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import F, Router, types
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot.keyboards.inline.user_keyboards import get_payment_url_keyboard
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.platega_service import PlategaService
|
||||
from config.settings import Settings
|
||||
from db.dal import payment_dal
|
||||
|
||||
router = Router(name="user_subscription_payments_platega_router")
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
F.data.startswith("pay_platega_sbp:")
|
||||
| F.data.startswith("pay_platega_crypto:")
|
||||
| F.data.startswith("pay_platega:")
|
||||
)
|
||||
async def pay_platega_callback_handler(
|
||||
callback: types.CallbackQuery,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
platega_service: PlategaService,
|
||||
session: AsyncSession,
|
||||
):
|
||||
callback_prefix, _, _ = (callback.data or "").partition(":")
|
||||
if callback_prefix == "pay_platega_crypto":
|
||||
platega_method_id = settings.PLATEGA_CRYPTO_METHOD
|
||||
platega_variant = "crypto"
|
||||
if not settings.PLATEGA_CRYPTO_ENABLED:
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
elif callback_prefix == "pay_platega_sbp":
|
||||
platega_method_id = settings.platega_sbp_method_resolved
|
||||
platega_variant = "sbp"
|
||||
if not settings.PLATEGA_SBP_ENABLED:
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
else:
|
||||
# Legacy callback (pre-split): keep working as SBP
|
||||
platega_method_id = settings.platega_sbp_method_resolved
|
||||
platega_variant = "sbp"
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
if not i18n or not callback.message:
|
||||
try:
|
||||
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
if not platega_service or not platega_service.configured:
|
||||
logging.error("Platega service is not configured or unavailable.")
|
||||
try:
|
||||
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await callback.message.edit_text(get_text("payment_service_unavailable"))
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
_, data_payload = callback.data.split(":", 1)
|
||||
parts = data_payload.split(":")
|
||||
months = float(parts[0])
|
||||
price_rub = float(parts[1])
|
||||
sale_mode = parts[2] if len(parts) > 2 else "subscription"
|
||||
except (ValueError, IndexError):
|
||||
logging.error(f"Invalid pay_platega data in callback: {callback.data}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
user_id = callback.from_user.id
|
||||
human_value = str(int(months)) if float(months).is_integer() else f"{months:g}"
|
||||
payment_description = (
|
||||
get_text("payment_description_traffic", traffic_gb=human_value)
|
||||
if sale_mode == "traffic"
|
||||
else get_text("payment_description_subscription", months=int(months))
|
||||
)
|
||||
currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
|
||||
|
||||
payment_record_payload = {
|
||||
"user_id": user_id,
|
||||
"amount": price_rub,
|
||||
"currency": currency_code,
|
||||
"status": "pending_platega",
|
||||
"description": payment_description,
|
||||
"subscription_duration_months": int(months),
|
||||
"provider": "platega",
|
||||
}
|
||||
|
||||
try:
|
||||
payment_record = await payment_dal.create_payment_record(session, payment_record_payload)
|
||||
await session.commit()
|
||||
except Exception as e_db_create:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"Platega: failed to create payment record for user {user_id}: {e_db_create}",
|
||||
exc_info=True,
|
||||
)
|
||||
try:
|
||||
await callback.message.edit_text(get_text("error_creating_payment_record"))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
payload_meta = json.dumps(
|
||||
{
|
||||
"payment_db_id": payment_record.payment_id,
|
||||
"user_id": user_id,
|
||||
"months": months,
|
||||
"sale_mode": sale_mode,
|
||||
"platega_variant": platega_variant,
|
||||
}
|
||||
)
|
||||
|
||||
success, response_data = await platega_service.create_transaction(
|
||||
payment_db_id=payment_record.payment_id,
|
||||
user_id=user_id,
|
||||
months=months,
|
||||
amount=price_rub,
|
||||
currency=currency_code,
|
||||
description=payment_description,
|
||||
payload=payload_meta,
|
||||
payment_method=platega_method_id,
|
||||
)
|
||||
|
||||
if success:
|
||||
transaction_id = response_data.get("transactionId") or response_data.get("id")
|
||||
redirect_url = (
|
||||
response_data.get("redirect")
|
||||
or response_data.get("url")
|
||||
or response_data.get("paymentUrl")
|
||||
)
|
||||
provider_status = response_data.get("status", payment_record.status)
|
||||
|
||||
if transaction_id and redirect_url:
|
||||
try:
|
||||
await payment_dal.update_provider_payment_and_status(
|
||||
session,
|
||||
payment_record.payment_id,
|
||||
str(transaction_id),
|
||||
str(provider_status),
|
||||
)
|
||||
await session.commit()
|
||||
except Exception as e_status:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"Platega: failed to store transaction id for payment {payment_record.payment_id}: {e_status}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
get_text(
|
||||
key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message",
|
||||
months=int(months),
|
||||
traffic_gb=human_value,
|
||||
),
|
||||
reply_markup=get_payment_url_keyboard(
|
||||
redirect_url,
|
||||
current_lang,
|
||||
i18n,
|
||||
back_callback=f"subscribe_period:{human_value}",
|
||||
back_text_key="back_to_payment_methods_button",
|
||||
),
|
||||
disable_web_page_preview=False,
|
||||
)
|
||||
except Exception as e_edit:
|
||||
logging.warning(f"Platega: failed to display payment link ({e_edit}), sending new message.")
|
||||
try:
|
||||
await callback.message.answer(
|
||||
get_text(
|
||||
key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message",
|
||||
months=int(months),
|
||||
traffic_gb=human_value,
|
||||
),
|
||||
reply_markup=get_payment_url_keyboard(
|
||||
redirect_url,
|
||||
current_lang,
|
||||
i18n,
|
||||
back_callback=f"subscribe_period:{human_value}",
|
||||
back_text_key="back_to_payment_methods_button",
|
||||
),
|
||||
disable_web_page_preview=False,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
logging.error(
|
||||
"Platega: transaction created but missing transaction id or payment link for payment %s. Response: %s",
|
||||
payment_record.payment_id,
|
||||
response_data,
|
||||
)
|
||||
|
||||
try:
|
||||
await payment_dal.update_payment_status_by_db_id(
|
||||
session,
|
||||
payment_record.payment_id,
|
||||
"failed_creation",
|
||||
)
|
||||
await session.commit()
|
||||
except Exception as e_status:
|
||||
await session.rollback()
|
||||
logging.error(f"Platega: failed to mark payment {payment_record.payment_id} as failed_creation: {e_status}", exc_info=True)
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(get_text("error_payment_gateway"))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await callback.answer(get_text("error_payment_gateway"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,199 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import F, Router, types
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot.keyboards.inline.user_keyboards import get_payment_url_keyboard
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.severpay_service import SeverPayService
|
||||
from config.settings import Settings
|
||||
from db.dal import payment_dal
|
||||
|
||||
router = Router(name="user_subscription_payments_severpay_router")
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("pay_severpay:"))
|
||||
async def pay_severpay_callback_handler(
|
||||
callback: types.CallbackQuery,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
severpay_service: SeverPayService,
|
||||
session: AsyncSession,
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
if not i18n or not callback.message:
|
||||
try:
|
||||
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
if not severpay_service or not severpay_service.configured:
|
||||
logging.error("SeverPay service is not configured or unavailable.")
|
||||
try:
|
||||
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await callback.message.edit_text(get_text("payment_service_unavailable"))
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
_, data_payload = callback.data.split(":", 1)
|
||||
parts = data_payload.split(":")
|
||||
months = float(parts[0])
|
||||
price_rub = float(parts[1])
|
||||
sale_mode = parts[2] if len(parts) > 2 else "subscription"
|
||||
except (ValueError, IndexError):
|
||||
logging.error(f"Invalid pay_severpay data in callback: {callback.data}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
user_id = callback.from_user.id
|
||||
human_value = str(int(months)) if float(months).is_integer() else f"{months:g}"
|
||||
payment_description = (
|
||||
get_text("payment_description_traffic", traffic_gb=human_value)
|
||||
if sale_mode == "traffic"
|
||||
else get_text("payment_description_subscription", months=int(months))
|
||||
)
|
||||
currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
|
||||
|
||||
payment_record_payload = {
|
||||
"user_id": user_id,
|
||||
"amount": price_rub,
|
||||
"currency": currency_code,
|
||||
"status": "pending_severpay",
|
||||
"description": payment_description,
|
||||
"subscription_duration_months": int(months),
|
||||
"provider": "severpay",
|
||||
}
|
||||
|
||||
try:
|
||||
payment_record = await payment_dal.create_payment_record(session, payment_record_payload)
|
||||
await session.commit()
|
||||
except Exception as e_db_create:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"SeverPay: failed to create payment record for user {user_id}: {e_db_create}",
|
||||
exc_info=True,
|
||||
)
|
||||
try:
|
||||
await callback.message.edit_text(get_text("error_creating_payment_record"))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
success, response_data = await severpay_service.create_payment(
|
||||
payment_db_id=payment_record.payment_id,
|
||||
user_id=user_id,
|
||||
months=months,
|
||||
amount=price_rub,
|
||||
currency=currency_code,
|
||||
description=payment_description,
|
||||
)
|
||||
|
||||
if success:
|
||||
payment_link = (
|
||||
response_data.get("url")
|
||||
or response_data.get("payment_url")
|
||||
or response_data.get("paymentUrl")
|
||||
)
|
||||
provider_identifier = response_data.get("id") or response_data.get("uid")
|
||||
|
||||
if provider_identifier:
|
||||
try:
|
||||
await payment_dal.update_provider_payment_and_status(
|
||||
session,
|
||||
payment_record.payment_id,
|
||||
str(provider_identifier),
|
||||
payment_record.status,
|
||||
)
|
||||
await session.commit()
|
||||
except Exception as e_status:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"SeverPay: failed to store provider payment id for payment {payment_record.payment_id}: {e_status}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if payment_link:
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
get_text(
|
||||
key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message",
|
||||
months=int(months),
|
||||
traffic_gb=human_value,
|
||||
),
|
||||
reply_markup=get_payment_url_keyboard(
|
||||
payment_link,
|
||||
current_lang,
|
||||
i18n,
|
||||
back_callback=f"subscribe_period:{human_value}",
|
||||
back_text_key="back_to_payment_methods_button",
|
||||
),
|
||||
disable_web_page_preview=False,
|
||||
)
|
||||
except Exception as e_edit:
|
||||
logging.warning(f"SeverPay: failed to display payment link ({e_edit}), sending new message.")
|
||||
try:
|
||||
await callback.message.answer(
|
||||
get_text(
|
||||
key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message",
|
||||
months=int(months),
|
||||
traffic_gb=human_value,
|
||||
),
|
||||
reply_markup=get_payment_url_keyboard(
|
||||
payment_link,
|
||||
current_lang,
|
||||
i18n,
|
||||
back_callback=f"subscribe_period:{human_value}",
|
||||
back_text_key="back_to_payment_methods_button",
|
||||
),
|
||||
disable_web_page_preview=False,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
logging.error(
|
||||
"SeverPay: payment created but missing payment link for payment %s. Response: %s",
|
||||
payment_record.payment_id,
|
||||
response_data,
|
||||
)
|
||||
|
||||
try:
|
||||
await payment_dal.update_payment_status_by_db_id(
|
||||
session,
|
||||
payment_record.payment_id,
|
||||
"failed_creation",
|
||||
)
|
||||
await session.commit()
|
||||
except Exception as e_status:
|
||||
await session.rollback()
|
||||
logging.error(f"SeverPay: failed to mark payment {payment_record.payment_id} as failed_creation: {e_status}", exc_info=True)
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(get_text("error_payment_gateway"))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await callback.answer(get_text("error_payment_gateway"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,136 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import F, Router, types
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.stars_service import StarsService
|
||||
from config.settings import Settings
|
||||
|
||||
router = Router(name="user_subscription_payments_stars_router")
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("pay_stars:"))
|
||||
async def pay_stars_callback_handler(
|
||||
callback: types.CallbackQuery,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
session: AsyncSession,
|
||||
stars_service: StarsService,
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
get_text = (lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key)
|
||||
|
||||
if not i18n or not callback.message:
|
||||
try:
|
||||
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
if not settings.STARS_ENABLED:
|
||||
try:
|
||||
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
_, data_payload = callback.data.split(":", 1)
|
||||
parts = data_payload.split(":")
|
||||
months = float(parts[0])
|
||||
stars_price = int(float(parts[1]))
|
||||
sale_mode = parts[2] if len(parts) > 2 else "subscription"
|
||||
except (ValueError, IndexError):
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
user_id = callback.from_user.id
|
||||
human_value = str(int(months)) if float(months).is_integer() else f"{months:g}"
|
||||
payment_description = (
|
||||
get_text("payment_description_traffic", traffic_gb=human_value)
|
||||
if sale_mode == "traffic"
|
||||
else get_text("payment_description_subscription", months=int(months))
|
||||
)
|
||||
|
||||
payment_db_id = await stars_service.create_invoice(
|
||||
session=session,
|
||||
user_id=user_id,
|
||||
months=months,
|
||||
stars_price=stars_price,
|
||||
description=payment_description,
|
||||
sale_mode=sale_mode,
|
||||
)
|
||||
|
||||
if payment_db_id:
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
get_text(
|
||||
"payment_invoice_sent_message_traffic" if sale_mode == "traffic" else "payment_invoice_sent_message",
|
||||
months=int(months),
|
||||
traffic_gb=human_value,
|
||||
),
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(
|
||||
text=get_text("back_to_payment_methods_button"),
|
||||
callback_data=f"subscribe_period:{human_value}",
|
||||
)]
|
||||
]),
|
||||
)
|
||||
except Exception as e_edit:
|
||||
logging.warning(f"Stars payment: failed to show invoice info message ({e_edit})")
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
await callback.answer(get_text("error_payment_gateway"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@router.pre_checkout_query()
|
||||
async def handle_pre_checkout_query(query: types.PreCheckoutQuery):
|
||||
try:
|
||||
await query.answer(ok=True)
|
||||
except Exception:
|
||||
# Nothing else to do here; Telegram will show an error if not answered
|
||||
pass
|
||||
|
||||
|
||||
@router.message(F.successful_payment)
|
||||
async def handle_successful_stars_payment(
|
||||
message: types.Message,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
session: AsyncSession,
|
||||
stars_service: StarsService,
|
||||
):
|
||||
payload = (message.successful_payment.invoice_payload
|
||||
if message and message.successful_payment else "")
|
||||
try:
|
||||
parts = (payload or "").split(":")
|
||||
payment_db_id = int(parts[0])
|
||||
months = float(parts[1]) if len(parts) > 1 else 0
|
||||
sale_mode = parts[2] if len(parts) > 2 else "subscription"
|
||||
except Exception:
|
||||
return
|
||||
|
||||
stars_amount = int(message.successful_payment.total_amount) if message.successful_payment else 0
|
||||
await stars_service.process_successful_payment(
|
||||
session=session,
|
||||
message=message,
|
||||
payment_db_id=payment_db_id,
|
||||
months=months,
|
||||
stars_amount=stars_amount,
|
||||
i18n_data=i18n_data,
|
||||
sale_mode=sale_mode,
|
||||
)
|
||||
@@ -0,0 +1,107 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import F, Router, types
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot.keyboards.inline.user_keyboards import get_payment_method_keyboard
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from config.settings import Settings
|
||||
|
||||
router = Router(name="user_subscription_payments_selection_router")
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("subscribe_period:"))
|
||||
async def select_subscription_period_callback_handler(
|
||||
callback: types.CallbackQuery,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
session: AsyncSession,
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
if not i18n or not callback.message:
|
||||
try:
|
||||
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
traffic_packages = getattr(settings, "traffic_packages", {}) or {}
|
||||
stars_traffic_packages = getattr(settings, "stars_traffic_packages", {}) or {}
|
||||
traffic_mode = bool(getattr(settings, "traffic_sale_mode", False) or stars_traffic_packages)
|
||||
try:
|
||||
months = float(callback.data.split(":")[-1])
|
||||
except (ValueError, IndexError):
|
||||
logging.error(f"Invalid subscription period in callback_data: {callback.data}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
price_source = traffic_packages if traffic_mode else settings.subscription_options
|
||||
stars_price_source = stars_traffic_packages if traffic_mode else settings.stars_subscription_options
|
||||
|
||||
price_rub = price_source.get(months)
|
||||
stars_price = stars_price_source.get(months)
|
||||
currency_symbol_val = settings.DEFAULT_CURRENCY_SYMBOL
|
||||
|
||||
if price_rub is None:
|
||||
if traffic_mode and not price_source and stars_price is not None:
|
||||
currency_methods_enabled = any(
|
||||
[
|
||||
settings.FREEKASSA_ENABLED,
|
||||
settings.PLATEGA_ENABLED,
|
||||
settings.SEVERPAY_ENABLED,
|
||||
settings.YOOKASSA_ENABLED,
|
||||
settings.CRYPTOPAY_ENABLED,
|
||||
]
|
||||
)
|
||||
if currency_methods_enabled:
|
||||
logging.error(
|
||||
"Currency price missing for traffic option %s while fiat providers are enabled.",
|
||||
months,
|
||||
)
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
price_rub = 0.0
|
||||
currency_symbol_val = "⭐"
|
||||
else:
|
||||
logging.error(
|
||||
f"Price not found for option {months} using {'traffic_packages' if traffic_mode else 'subscription_options'}."
|
||||
)
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
text_content = get_text("choose_payment_method_traffic") if traffic_mode else get_text("choose_payment_method")
|
||||
reply_markup = get_payment_method_keyboard(
|
||||
months,
|
||||
price_rub,
|
||||
stars_price,
|
||||
currency_symbol_val,
|
||||
current_lang,
|
||||
i18n,
|
||||
settings,
|
||||
sale_mode="traffic" if traffic_mode else "subscription",
|
||||
)
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(text_content, reply_markup=reply_markup)
|
||||
except Exception as e_edit:
|
||||
logging.warning(
|
||||
f"Edit message for payment method selection failed: {e_edit}. Sending new one."
|
||||
)
|
||||
await callback.message.answer(text_content, reply_markup=reply_markup)
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,771 @@
|
||||
import logging
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from aiogram import F, Router, types
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot.keyboards.inline.user_keyboards import (
|
||||
get_back_to_main_menu_markup,
|
||||
get_payment_url_keyboard,
|
||||
get_yk_autopay_choice_keyboard,
|
||||
get_yk_saved_cards_keyboard,
|
||||
)
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.yookassa_service import YooKassaService
|
||||
from config.settings import Settings
|
||||
from db.dal import payment_dal, user_billing_dal
|
||||
|
||||
router = Router(name="user_subscription_payments_yookassa_router")
|
||||
|
||||
|
||||
def _format_value(val: float) -> str:
|
||||
return str(int(val)) if float(val).is_integer() else f"{val:g}"
|
||||
|
||||
|
||||
def _parse_offer_payload(payload: str) -> Optional[Tuple[float, float, str]]:
|
||||
try:
|
||||
parts = payload.split(":")
|
||||
value = float(parts[0])
|
||||
price = float(parts[1])
|
||||
sale_mode = parts[2] if len(parts) > 2 else "subscription"
|
||||
return value, price, sale_mode
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
|
||||
|
||||
def _format_saved_payment_method_title(get_text, network: Optional[str], last4: Optional[str], is_default: bool) -> str:
|
||||
def _is_yoomoney_network(name: Optional[str]) -> bool:
|
||||
s = (name or "").lower()
|
||||
return "yoomoney" in s or "yoo money" in s or "yoo-money" in s
|
||||
|
||||
def _extract_last4(text: str) -> Optional[str]:
|
||||
digits = "".join(ch for ch in text if ch.isdigit())
|
||||
return digits[-4:] if len(digits) >= 4 else None
|
||||
|
||||
if _is_yoomoney_network(network):
|
||||
inferred_last4 = last4 or (_extract_last4(network or "") or "****")
|
||||
title = get_text("payment_method_wallet_title", last4=inferred_last4)
|
||||
elif last4:
|
||||
network_name = network or get_text("payment_network_card")
|
||||
title = get_text("payment_method_card_title", network=network_name, last4=last4)
|
||||
else:
|
||||
network_name = network or get_text("payment_network_generic")
|
||||
title = get_text("payment_method_generic_title", network=network_name)
|
||||
return f"⭐ {title}" if is_default else title
|
||||
|
||||
|
||||
async def _initiate_yk_payment(
|
||||
callback: types.CallbackQuery,
|
||||
*,
|
||||
settings: Settings,
|
||||
session: AsyncSession,
|
||||
yookassa_service: YooKassaService,
|
||||
i18n: Optional[JsonI18n],
|
||||
current_lang: str,
|
||||
get_text,
|
||||
user_id: int,
|
||||
months: int,
|
||||
price_rub: float,
|
||||
currency_code_for_yk: str,
|
||||
save_payment_method: bool,
|
||||
back_callback: str,
|
||||
payment_method_id: Optional[str] = None,
|
||||
selected_method_internal_id: Optional[int] = None,
|
||||
sale_mode: str = "subscription",
|
||||
) -> bool:
|
||||
"""Create payment record and initiate YooKassa payment (new card or saved card)."""
|
||||
if not callback.message:
|
||||
return False
|
||||
|
||||
payment_description = (
|
||||
get_text("payment_description_traffic", traffic_gb=_format_value(months))
|
||||
if sale_mode == "traffic"
|
||||
else get_text("payment_description_subscription", months=int(months))
|
||||
)
|
||||
payment_record_data = {
|
||||
"user_id": user_id,
|
||||
"amount": price_rub,
|
||||
"currency": currency_code_for_yk,
|
||||
"status": "pending_yookassa",
|
||||
"description": payment_description,
|
||||
"subscription_duration_months": int(months),
|
||||
}
|
||||
|
||||
db_payment_record = None
|
||||
try:
|
||||
db_payment_record = await payment_dal.create_payment_record(session, payment_record_data)
|
||||
await session.commit()
|
||||
logging.info(
|
||||
f"Payment record {db_payment_record.payment_id} created for user {user_id} with status 'pending_yookassa'."
|
||||
)
|
||||
except Exception as e_db_payment:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"Failed to create payment record in DB for user {user_id}: {e_db_payment}",
|
||||
exc_info=True,
|
||||
)
|
||||
try:
|
||||
await callback.message.edit_text(get_text("error_creating_payment_record"))
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
if not db_payment_record:
|
||||
try:
|
||||
await callback.message.edit_text(get_text("error_creating_payment_record"))
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
yookassa_metadata = {
|
||||
"user_id": str(user_id),
|
||||
"subscription_months": str(months),
|
||||
"payment_db_id": str(db_payment_record.payment_id),
|
||||
"sale_mode": sale_mode,
|
||||
}
|
||||
if sale_mode == "traffic":
|
||||
yookassa_metadata["traffic_gb"] = str(months)
|
||||
if payment_method_id:
|
||||
yookassa_metadata["used_saved_payment_method_id"] = payment_method_id
|
||||
|
||||
receipt_email_for_yk = settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL
|
||||
|
||||
payment_response_yk = await yookassa_service.create_payment(
|
||||
amount=price_rub,
|
||||
currency=currency_code_for_yk,
|
||||
description=payment_description,
|
||||
metadata=yookassa_metadata,
|
||||
receipt_email=receipt_email_for_yk,
|
||||
save_payment_method=save_payment_method,
|
||||
payment_method_id=payment_method_id,
|
||||
)
|
||||
|
||||
if payment_response_yk and payment_response_yk.get("confirmation_url"):
|
||||
pm = payment_response_yk.get("payment_method")
|
||||
try:
|
||||
if pm and pm.get("id"):
|
||||
pm_type = pm.get("type")
|
||||
title = pm.get("title")
|
||||
card = pm.get("card") or {}
|
||||
account_number = pm.get("account_number") or pm.get("account")
|
||||
if isinstance(card, dict) and (pm_type or "").lower() in {"bank_card", "bank-card", "card"}:
|
||||
display_network = card.get("card_type") or title or "Card"
|
||||
display_last4 = card.get("last4")
|
||||
elif (pm_type or "").lower() in {"yoo_money", "yoomoney", "yoo-money", "wallet"}:
|
||||
display_network = "YooMoney"
|
||||
display_last4 = (
|
||||
account_number[-4:]
|
||||
if isinstance(account_number, str) and len(account_number) >= 4
|
||||
else None
|
||||
)
|
||||
else:
|
||||
display_network = title or (pm_type.upper() if pm_type else "Payment method")
|
||||
display_last4 = None
|
||||
await user_billing_dal.upsert_yk_payment_method(
|
||||
session,
|
||||
user_id=user_id,
|
||||
payment_method_id=pm["id"],
|
||||
card_last4=display_last4,
|
||||
card_network=display_network,
|
||||
)
|
||||
try:
|
||||
await user_billing_dal.upsert_user_payment_method(
|
||||
session,
|
||||
user_id=user_id,
|
||||
provider_payment_method_id=pm["id"],
|
||||
provider="yookassa",
|
||||
card_last4=display_last4,
|
||||
card_network=display_network,
|
||||
set_default=save_payment_method,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logging.exception("Failed to save YooKassa payment method preliminarily")
|
||||
try:
|
||||
await payment_dal.update_payment_status_by_db_id(
|
||||
session,
|
||||
payment_db_id=db_payment_record.payment_id,
|
||||
new_status=payment_response_yk.get("status", "pending"),
|
||||
yk_payment_id=payment_response_yk.get("id"),
|
||||
)
|
||||
if selected_method_internal_id is not None:
|
||||
try:
|
||||
await user_billing_dal.set_user_default_payment_method(
|
||||
session, user_id, selected_method_internal_id
|
||||
)
|
||||
except Exception:
|
||||
logging.exception("Failed to set default payment method after initiating payment")
|
||||
await session.commit()
|
||||
except Exception as e_db_update_ykid:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"Failed to update payment record {db_payment_record.payment_id} with YK ID: {e_db_update_ykid}",
|
||||
exc_info=True,
|
||||
)
|
||||
try:
|
||||
await callback.message.edit_text(get_text("error_payment_gateway_link_failed"))
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
get_text(
|
||||
key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message",
|
||||
months=int(months),
|
||||
traffic_gb=_format_value(months),
|
||||
),
|
||||
reply_markup=get_payment_url_keyboard(
|
||||
payment_response_yk["confirmation_url"],
|
||||
current_lang,
|
||||
i18n,
|
||||
back_callback=back_callback,
|
||||
back_text_key="back_to_payment_methods_button",
|
||||
),
|
||||
disable_web_page_preview=False,
|
||||
)
|
||||
except Exception as e_edit:
|
||||
logging.warning(
|
||||
f"Edit message for payment link failed: {e_edit}. Sending new one."
|
||||
)
|
||||
try:
|
||||
await callback.message.answer(
|
||||
get_text(
|
||||
key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message",
|
||||
months=int(months),
|
||||
traffic_gb=_format_value(months),
|
||||
),
|
||||
reply_markup=get_payment_url_keyboard(
|
||||
payment_response_yk["confirmation_url"],
|
||||
current_lang,
|
||||
i18n,
|
||||
back_callback=back_callback,
|
||||
back_text_key="back_to_payment_methods_button",
|
||||
),
|
||||
disable_web_page_preview=False,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
|
||||
if payment_response_yk and payment_method_id:
|
||||
status_to_store = payment_response_yk.get("status", "pending")
|
||||
try:
|
||||
await payment_dal.update_payment_status_by_db_id(
|
||||
session,
|
||||
payment_db_id=db_payment_record.payment_id,
|
||||
new_status=status_to_store,
|
||||
yk_payment_id=payment_response_yk.get("id"),
|
||||
)
|
||||
if selected_method_internal_id is not None:
|
||||
try:
|
||||
await user_billing_dal.set_user_default_payment_method(
|
||||
session, user_id, selected_method_internal_id
|
||||
)
|
||||
except Exception:
|
||||
logging.exception("Failed to set default payment method after saved-card payment start")
|
||||
await session.commit()
|
||||
except Exception as e_db_update_saved:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"Failed to update saved-card payment record {db_payment_record.payment_id}: {e_db_update_saved}",
|
||||
exc_info=True,
|
||||
)
|
||||
try:
|
||||
await callback.message.edit_text(get_text("error_payment_gateway"))
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
message_text = get_text("yookassa_autopay_charge_initiated")
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=get_back_to_main_menu_markup(current_lang, i18n),
|
||||
)
|
||||
except Exception as e_edit:
|
||||
logging.warning(f"Failed to notify about saved-card charge start: {e_edit}")
|
||||
try:
|
||||
await callback.message.answer(
|
||||
message_text,
|
||||
reply_markup=get_back_to_main_menu_markup(current_lang, i18n),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
|
||||
try:
|
||||
await payment_dal.update_payment_status_by_db_id(
|
||||
session, db_payment_record.payment_id, "failed_creation"
|
||||
)
|
||||
await session.commit()
|
||||
except Exception as e_db_fail_create:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"Additionally failed to update payment record to 'failed_creation': {e_db_fail_create}",
|
||||
exc_info=True,
|
||||
)
|
||||
logging.error(
|
||||
f"Failed to create payment in YooKassa for user {user_id}, payment_db_id {db_payment_record.payment_id}. Response: {payment_response_yk}"
|
||||
)
|
||||
try:
|
||||
await callback.message.edit_text(get_text("error_payment_gateway"))
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("pay_yk:"))
|
||||
async def pay_yk_callback_handler(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, yookassa_service: YooKassaService, session: AsyncSession):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
if not i18n or not callback.message:
|
||||
try:
|
||||
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
if not yookassa_service or not yookassa_service.configured:
|
||||
logging.error("YooKassa service is not configured or unavailable.")
|
||||
target_msg_edit = callback.message
|
||||
await target_msg_edit.edit_text(get_text("payment_service_unavailable"))
|
||||
try:
|
||||
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
_, data_payload = callback.data.split(":", 1)
|
||||
except ValueError:
|
||||
logging.error(f"Invalid pay_yk data in callback: {callback.data}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
parsed = _parse_offer_payload(data_payload)
|
||||
if not parsed:
|
||||
logging.error(f"Invalid pay_yk payload structure: {callback.data}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
months, price_rub, sale_mode = parsed
|
||||
user_id = callback.from_user.id
|
||||
currency_code_for_yk = "RUB"
|
||||
autopay_enabled = bool(settings.yookassa_autopayments_active and sale_mode != "traffic" and not settings.traffic_sale_mode)
|
||||
autopay_require_binding = bool(
|
||||
getattr(settings, 'YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING', True)
|
||||
)
|
||||
saved_methods: List = []
|
||||
if autopay_enabled:
|
||||
try:
|
||||
saved_methods = await user_billing_dal.list_user_payment_methods(
|
||||
session, user_id, provider="yookassa"
|
||||
)
|
||||
except Exception as e_list:
|
||||
logging.exception(f"Failed to load saved payment methods for user {user_id}: {e_list}")
|
||||
saved_methods = []
|
||||
|
||||
if autopay_enabled and saved_methods:
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
get_text("yookassa_autopay_flow_prompt"),
|
||||
reply_markup=get_yk_autopay_choice_keyboard(
|
||||
months,
|
||||
price_rub,
|
||||
current_lang,
|
||||
i18n,
|
||||
has_saved_cards=True,
|
||||
sale_mode=sale_mode,
|
||||
),
|
||||
)
|
||||
except Exception as e_edit:
|
||||
logging.warning(f"Failed to show autopay choice: {e_edit}. Sending new message.")
|
||||
try:
|
||||
await callback.message.answer(
|
||||
get_text("yookassa_autopay_flow_prompt"),
|
||||
reply_markup=get_yk_autopay_choice_keyboard(
|
||||
months,
|
||||
price_rub,
|
||||
current_lang,
|
||||
i18n,
|
||||
has_saved_cards=True,
|
||||
sale_mode=sale_mode,
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
await _initiate_yk_payment(
|
||||
callback,
|
||||
settings=settings,
|
||||
session=session,
|
||||
yookassa_service=yookassa_service,
|
||||
i18n=i18n,
|
||||
current_lang=current_lang,
|
||||
get_text=get_text,
|
||||
user_id=user_id,
|
||||
months=months,
|
||||
price_rub=price_rub,
|
||||
currency_code_for_yk=currency_code_for_yk,
|
||||
save_payment_method=autopay_enabled and autopay_require_binding,
|
||||
back_callback=f"subscribe_period:{_format_value(months)}",
|
||||
sale_mode=sale_mode,
|
||||
)
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("pay_yk_new:"))
|
||||
async def pay_yk_new_card_handler(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, yookassa_service: YooKassaService, session: AsyncSession):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
if not i18n or not callback.message:
|
||||
try:
|
||||
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
if not yookassa_service or not yookassa_service.configured:
|
||||
logging.error("YooKassa service unavailable for pay_yk_new.")
|
||||
try:
|
||||
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await callback.message.edit_text(get_text("payment_service_unavailable"))
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
_, data_payload = callback.data.split(":", 1)
|
||||
except ValueError:
|
||||
logging.error(f"Invalid pay_yk_new data in callback: {callback.data}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
parsed = _parse_offer_payload(data_payload)
|
||||
if not parsed:
|
||||
logging.error(f"Invalid pay_yk_new payload structure: {callback.data}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
months, price_rub, sale_mode = parsed
|
||||
user_id = callback.from_user.id
|
||||
currency_code_for_yk = "RUB"
|
||||
autopay_enabled = bool(settings.yookassa_autopayments_active and sale_mode != "traffic" and not settings.traffic_sale_mode)
|
||||
autopay_require_binding = bool(
|
||||
getattr(settings, 'YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING', True)
|
||||
)
|
||||
|
||||
await _initiate_yk_payment(
|
||||
callback,
|
||||
settings=settings,
|
||||
session=session,
|
||||
yookassa_service=yookassa_service,
|
||||
i18n=i18n,
|
||||
current_lang=current_lang,
|
||||
get_text=get_text,
|
||||
user_id=user_id,
|
||||
months=months,
|
||||
price_rub=price_rub,
|
||||
currency_code_for_yk=currency_code_for_yk,
|
||||
save_payment_method=autopay_enabled and autopay_require_binding,
|
||||
back_callback=f"subscribe_period:{_format_value(months)}",
|
||||
sale_mode=sale_mode,
|
||||
)
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("pay_yk_saved_list:"))
|
||||
async def pay_yk_saved_list_handler(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, yookassa_service: YooKassaService, session: AsyncSession):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
if not i18n or not callback.message:
|
||||
try:
|
||||
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
_, data_payload = callback.data.split(":", 1)
|
||||
except ValueError:
|
||||
logging.error(f"Invalid pay_yk_saved_list data: {callback.data}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
parts = data_payload.split(":")
|
||||
if len(parts) < 2:
|
||||
logging.error(f"pay_yk_saved_list payload missing components: {callback.data}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
months = float(parts[0])
|
||||
price_rub = float(parts[1])
|
||||
page = int(parts[2]) if len(parts) > 2 else 0
|
||||
sale_mode = parts[3] if len(parts) > 3 else "subscription"
|
||||
except (ValueError, IndexError):
|
||||
logging.error(f"pay_yk_saved_list payload parsing error: {callback.data}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
autopay_enabled = bool(settings.yookassa_autopayments_active and sale_mode != "traffic" and not settings.traffic_sale_mode)
|
||||
if not autopay_enabled:
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
user_id = callback.from_user.id
|
||||
try:
|
||||
saved_methods = await user_billing_dal.list_user_payment_methods(
|
||||
session, user_id, provider="yookassa"
|
||||
)
|
||||
except Exception as e_list:
|
||||
logging.exception(f"Failed to list saved payment methods for user {user_id}: {e_list}")
|
||||
saved_methods = []
|
||||
|
||||
if not saved_methods:
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
get_text("yookassa_autopay_no_saved_cards"),
|
||||
reply_markup=get_yk_autopay_choice_keyboard(
|
||||
months,
|
||||
price_rub,
|
||||
current_lang,
|
||||
i18n,
|
||||
has_saved_cards=False,
|
||||
sale_mode=sale_mode,
|
||||
),
|
||||
)
|
||||
except Exception as e_edit:
|
||||
logging.warning(f"Failed to display no-saved-card notice: {e_edit}")
|
||||
try:
|
||||
await callback.message.answer(
|
||||
get_text("yookassa_autopay_no_saved_cards"),
|
||||
reply_markup=get_yk_autopay_choice_keyboard(
|
||||
months,
|
||||
price_rub,
|
||||
current_lang,
|
||||
i18n,
|
||||
has_saved_cards=False,
|
||||
sale_mode=sale_mode,
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
cards: List[Tuple[str, str]] = []
|
||||
for method in saved_methods:
|
||||
title = _format_saved_payment_method_title(
|
||||
get_text, method.card_network, method.card_last4, method.is_default
|
||||
)
|
||||
cards.append((str(method.method_id), title))
|
||||
|
||||
per_page = 5
|
||||
max_page = max(0, (len(cards) - 1) // per_page)
|
||||
page = max(0, min(page, max_page))
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
get_text("yookassa_autopay_choose_saved_card"),
|
||||
reply_markup=get_yk_saved_cards_keyboard(
|
||||
cards,
|
||||
months,
|
||||
price_rub,
|
||||
current_lang,
|
||||
i18n,
|
||||
page=page,
|
||||
sale_mode=sale_mode,
|
||||
),
|
||||
)
|
||||
except Exception as e_edit:
|
||||
logging.warning(f"Failed to display saved card list: {e_edit}")
|
||||
try:
|
||||
await callback.message.answer(
|
||||
get_text("yookassa_autopay_choose_saved_card"),
|
||||
reply_markup=get_yk_saved_cards_keyboard(
|
||||
cards,
|
||||
months,
|
||||
price_rub,
|
||||
current_lang,
|
||||
i18n,
|
||||
page=page,
|
||||
sale_mode=sale_mode,
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("pay_yk_use_saved:"))
|
||||
async def pay_yk_use_saved_handler(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, yookassa_service: YooKassaService, session: AsyncSession):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
if not i18n or not callback.message:
|
||||
try:
|
||||
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
if not yookassa_service or not yookassa_service.configured:
|
||||
logging.error("YooKassa service unavailable for pay_yk_use_saved.")
|
||||
try:
|
||||
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await callback.message.edit_text(get_text("payment_service_unavailable"))
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
_, data_payload = callback.data.split(":", 1)
|
||||
except ValueError:
|
||||
logging.error(f"Invalid pay_yk_use_saved data: {callback.data}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
parts = data_payload.split(":")
|
||||
if len(parts) < 3:
|
||||
logging.error(f"pay_yk_use_saved payload missing components: {callback.data}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
months = float(parts[0])
|
||||
price_rub = float(parts[1])
|
||||
sale_mode = parts[3] if len(parts) > 3 else "subscription"
|
||||
except (ValueError, IndexError):
|
||||
logging.error(f"pay_yk_use_saved months/price parsing error: {callback.data}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
autopay_enabled = bool(settings.yookassa_autopayments_active and sale_mode != "traffic" and not settings.traffic_sale_mode)
|
||||
if not autopay_enabled:
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
method_identifier = parts[2]
|
||||
user_id = callback.from_user.id
|
||||
|
||||
try:
|
||||
saved_methods = await user_billing_dal.list_user_payment_methods(
|
||||
session, user_id, provider="yookassa"
|
||||
)
|
||||
except Exception as e_list:
|
||||
logging.exception(f"Failed to list saved payment methods for user {user_id}: {e_list}")
|
||||
saved_methods = []
|
||||
|
||||
selected_method = None
|
||||
for method in saved_methods:
|
||||
if method_identifier.isdigit():
|
||||
if method.method_id == int(method_identifier):
|
||||
selected_method = method
|
||||
break
|
||||
if method.provider_payment_method_id == method_identifier:
|
||||
selected_method = method
|
||||
break
|
||||
|
||||
if not selected_method:
|
||||
logging.warning(f"Selected payment method not found for user {user_id}: {method_identifier}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
currency_code_for_yk = "RUB"
|
||||
|
||||
await _initiate_yk_payment(
|
||||
callback,
|
||||
settings=settings,
|
||||
session=session,
|
||||
yookassa_service=yookassa_service,
|
||||
i18n=i18n,
|
||||
current_lang=current_lang,
|
||||
get_text=get_text,
|
||||
user_id=user_id,
|
||||
months=months,
|
||||
price_rub=price_rub,
|
||||
currency_code_for_yk=currency_code_for_yk,
|
||||
save_payment_method=False,
|
||||
back_callback=f"pay_yk_saved_list:{_format_value(months)}:{price_rub}:{sale_mode}",
|
||||
payment_method_id=selected_method.provider_payment_method_id,
|
||||
selected_method_internal_id=selected_method.method_id,
|
||||
sale_mode=sale_mode,
|
||||
)
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
@@ -13,6 +13,7 @@ from bot.keyboards.inline.user_keyboards import (
|
||||
get_main_menu_inline_keyboard,
|
||||
get_connect_and_main_keyboard,
|
||||
)
|
||||
from bot.utils.config_link import prepare_config_links
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from .start import send_main_menu
|
||||
|
||||
@@ -76,7 +77,9 @@ async def request_trial_confirmation_handler(
|
||||
|
||||
final_message_text_in_chat = ""
|
||||
show_trial_button_after_action = False
|
||||
config_link_display_for_trial = None
|
||||
config_link_for_trial = None
|
||||
connect_button_url_for_trial = None
|
||||
|
||||
if activation_result and activation_result.get("activated"):
|
||||
try:
|
||||
@@ -85,9 +88,10 @@ async def request_trial_confirmation_handler(
|
||||
pass
|
||||
|
||||
end_date_obj = activation_result.get("end_date")
|
||||
config_link_for_trial = activation_result.get("subscription_url") or _(
|
||||
"config_link_not_available"
|
||||
config_link_display_for_trial, connect_button_url_for_trial = await prepare_config_links(
|
||||
settings, activation_result.get("subscription_url")
|
||||
)
|
||||
config_link_for_trial = config_link_display_for_trial or _("config_link_not_available")
|
||||
|
||||
traffic_gb_val = activation_result.get(
|
||||
"traffic_gb", settings.TRIAL_TRAFFIC_LIMIT_GB
|
||||
@@ -142,7 +146,11 @@ async def request_trial_confirmation_handler(
|
||||
|
||||
reply_markup = (
|
||||
get_connect_and_main_keyboard(
|
||||
current_lang, i18n, settings, config_link_for_trial
|
||||
current_lang,
|
||||
i18n,
|
||||
settings,
|
||||
config_link_display_for_trial,
|
||||
connect_button_url=connect_button_url_for_trial,
|
||||
)
|
||||
if activation_result and activation_result.get("activated")
|
||||
else get_main_menu_inline_keyboard(
|
||||
@@ -221,7 +229,9 @@ async def confirm_activate_trial_handler(
|
||||
|
||||
final_message_text_in_chat = ""
|
||||
show_trial_button_after_action = False
|
||||
config_link_display_for_trial = None
|
||||
config_link_for_trial = None
|
||||
connect_button_url_for_trial = None
|
||||
|
||||
if activation_result and activation_result.get("activated"):
|
||||
try:
|
||||
@@ -230,9 +240,10 @@ async def confirm_activate_trial_handler(
|
||||
pass
|
||||
|
||||
end_date_obj = activation_result.get("end_date")
|
||||
config_link_for_trial = activation_result.get("subscription_url") or _(
|
||||
"config_link_not_available"
|
||||
config_link_display_for_trial, connect_button_url_for_trial = await prepare_config_links(
|
||||
settings, activation_result.get("subscription_url")
|
||||
)
|
||||
config_link_for_trial = config_link_display_for_trial or _("config_link_not_available")
|
||||
|
||||
traffic_gb_val = activation_result.get(
|
||||
"traffic_gb", settings.TRIAL_TRAFFIC_LIMIT_GB
|
||||
@@ -275,7 +286,11 @@ async def confirm_activate_trial_handler(
|
||||
|
||||
reply_markup = (
|
||||
get_connect_and_main_keyboard(
|
||||
current_lang, i18n, settings, config_link_for_trial
|
||||
current_lang,
|
||||
i18n,
|
||||
settings,
|
||||
config_link_display_for_trial,
|
||||
connect_button_url=connect_button_url_for_trial,
|
||||
)
|
||||
if activation_result and activation_result.get("activated")
|
||||
else get_main_menu_inline_keyboard(
|
||||
|
||||
@@ -26,7 +26,7 @@ def get_admin_panel_keyboard(i18n_instance, lang: str,
|
||||
callback_data="admin_section:promo_marketing")
|
||||
|
||||
# Реклама
|
||||
builder.button(text=_(key="admin_ads_section", default="📈 Реклама"),
|
||||
builder.button(text=_(key="admin_ads_section"),
|
||||
callback_data="admin_action:ads")
|
||||
|
||||
# Системные функции
|
||||
@@ -43,7 +43,7 @@ def get_stats_monitoring_keyboard(i18n_instance, lang: str) -> InlineKeyboardMar
|
||||
|
||||
builder.button(text=_(key="admin_stats_button"),
|
||||
callback_data="admin_action:stats")
|
||||
builder.button(text=_(key="admin_view_payments_button", default="💰 Платежи"),
|
||||
builder.button(text=_(key="admin_view_payments_button"),
|
||||
callback_data="admin_action:view_payments")
|
||||
builder.button(text=_(key="admin_view_logs_menu_button"),
|
||||
callback_data="admin_action:view_logs_menu")
|
||||
@@ -59,13 +59,17 @@ def get_user_management_keyboard(i18n_instance, lang: str) -> InlineKeyboardMark
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
builder.button(text=_(key="admin_users_management_button"),
|
||||
callback_data="admin_action:users_management")
|
||||
callback_data="admin_action:users_list:0")
|
||||
builder.button(text=_(key="admin_users_search_button"),
|
||||
callback_data="admin_action:users_search_prompt")
|
||||
builder.button(text=_(key="admin_ban_management_section"),
|
||||
callback_data="admin_section:ban_management")
|
||||
builder.button(text=_(key="admin_user_ratings_button"),
|
||||
callback_data="admin_action:user_ratings")
|
||||
|
||||
builder.button(text=_(key="back_to_admin_panel_button"),
|
||||
callback_data="admin_action:main")
|
||||
builder.adjust(2, 1)
|
||||
builder.adjust(2, 2, 1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
@@ -123,7 +127,7 @@ def get_system_functions_keyboard(i18n_instance, lang: str) -> InlineKeyboardMar
|
||||
def get_ads_menu_keyboard(i18n_instance, lang: str) -> InlineKeyboardMarkup:
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text=_(key="admin_ads_create_button", default="➕ Создать кампанию"),
|
||||
builder.button(text=_(key="admin_ads_create_button"),
|
||||
callback_data="admin_action:ads_create")
|
||||
builder.button(text=_(key="back_to_admin_panel_button"),
|
||||
callback_data="admin_action:main")
|
||||
@@ -154,7 +158,7 @@ def get_ads_list_keyboard(
|
||||
if current_page > 0:
|
||||
row.append(
|
||||
InlineKeyboardButton(
|
||||
text="⬅️ " + _("prev_page_button", default="Prev"),
|
||||
text="⬅️ " + _("prev_page_button"),
|
||||
callback_data=f"admin_ads:page:{current_page - 1}",
|
||||
)
|
||||
)
|
||||
@@ -167,14 +171,14 @@ def get_ads_list_keyboard(
|
||||
if current_page < total_pages - 1:
|
||||
row.append(
|
||||
InlineKeyboardButton(
|
||||
text=_("next_page_button", default="Next") + " ➡️",
|
||||
text=_("next_page_button") + " ➡️",
|
||||
callback_data=f"admin_ads:page:{current_page + 1}",
|
||||
)
|
||||
)
|
||||
if row:
|
||||
builder.row(*row)
|
||||
|
||||
builder.button(text=_(key="admin_ads_create_button", default="➕ Создать кампанию"),
|
||||
builder.button(text=_(key="admin_ads_create_button"),
|
||||
callback_data="admin_action:ads_create")
|
||||
builder.button(text=_(key="back_to_admin_panel_button"),
|
||||
callback_data="admin_action:main")
|
||||
@@ -186,9 +190,9 @@ def get_ad_card_keyboard(i18n_instance, lang: str, campaign_id: int, back_page:
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
# Dangerous action: Delete campaign
|
||||
builder.button(text=_(key="admin_ads_delete_button", default="🗑 Удалить кампанию"),
|
||||
builder.button(text=_(key="admin_ads_delete_button"),
|
||||
callback_data=f"admin_ads:delete:{campaign_id}:{back_page}")
|
||||
builder.button(text=_(key="back_to_ads_list_button", default="⬅️ К списку"),
|
||||
builder.button(text=_(key="back_to_ads_list_button"),
|
||||
callback_data=f"admin_ads:page:{back_page}")
|
||||
builder.button(text=_(key="back_to_admin_panel_button"),
|
||||
callback_data="admin_action:main")
|
||||
@@ -225,12 +229,12 @@ def get_logs_pagination_keyboard(
|
||||
if current_page > 0:
|
||||
row_buttons.append(
|
||||
InlineKeyboardButton(
|
||||
text="⬅️ " + _("prev_page_button", default="Prev"),
|
||||
text="⬅️ " + _("prev_page_button"),
|
||||
callback_data=f"{base_callback_data}:{current_page - 1}"))
|
||||
if current_page < total_pages - 1:
|
||||
row_buttons.append(
|
||||
InlineKeyboardButton(
|
||||
text=_("next_page_button", default="Next") + " ➡️",
|
||||
text=_("next_page_button") + " ➡️",
|
||||
callback_data=f"{base_callback_data}:{current_page + 1}"))
|
||||
|
||||
if row_buttons: builder.row(*row_buttons)
|
||||
@@ -264,6 +268,8 @@ def get_banned_users_keyboard(banned_users: List[User], current_page: int,
|
||||
user_display_parts.append(user_row.first_name)
|
||||
if user_row.username:
|
||||
user_display_parts.append(f"(@{user_row.username})")
|
||||
elif user_row.email:
|
||||
user_display_parts.append(f"({user_row.email})")
|
||||
if not user_display_parts:
|
||||
user_display_parts.append(f"ID: {user_row.user_id}")
|
||||
|
||||
@@ -305,6 +311,70 @@ def get_banned_users_keyboard(banned_users: List[User], current_page: int,
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def get_users_list_keyboard(users: List[User], current_page: int,
|
||||
total_users: int, i18n_instance, lang: str,
|
||||
page_size: int = 15) -> InlineKeyboardMarkup:
|
||||
"""Generate keyboard for paginated user list"""
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
# Add user buttons
|
||||
for user in users:
|
||||
user_display_parts = []
|
||||
if user.username:
|
||||
user_display_parts.append(f"@{user.username}")
|
||||
elif user.email:
|
||||
user_display_parts.append(user.email)
|
||||
user_display_parts.append(f"ID: {user.user_id}")
|
||||
if user.first_name:
|
||||
user_display_parts.append(f"- {user.first_name}")
|
||||
|
||||
button_text = " ".join(user_display_parts)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=button_text,
|
||||
callback_data=f"admin_user_card_from_list:{user.user_id}:{current_page}"
|
||||
)
|
||||
)
|
||||
|
||||
# Pagination buttons
|
||||
if total_users > page_size:
|
||||
total_pages = math.ceil(total_users / page_size)
|
||||
pagination_buttons = []
|
||||
if current_page > 0:
|
||||
pagination_buttons.append(
|
||||
InlineKeyboardButton(
|
||||
text=_("prev_page_button"),
|
||||
callback_data=f"admin_action:users_list:{current_page - 1}"
|
||||
)
|
||||
)
|
||||
pagination_buttons.append(
|
||||
InlineKeyboardButton(
|
||||
text=f"{current_page + 1}/{total_pages}",
|
||||
callback_data="stub_page_display"
|
||||
)
|
||||
)
|
||||
if current_page < total_pages - 1:
|
||||
pagination_buttons.append(
|
||||
InlineKeyboardButton(
|
||||
text=_("next_page_button"),
|
||||
callback_data=f"admin_action:users_list:{current_page + 1}"
|
||||
)
|
||||
)
|
||||
if pagination_buttons:
|
||||
builder.row(*pagination_buttons)
|
||||
|
||||
# Back button
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_("back_to_user_management_button"),
|
||||
callback_data="admin_section:user_management"
|
||||
)
|
||||
)
|
||||
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def get_user_card_keyboard(user_id: int,
|
||||
is_banned: bool,
|
||||
i18n_instance,
|
||||
@@ -320,6 +390,12 @@ def get_user_card_keyboard(user_id: int,
|
||||
builder.button(
|
||||
text=_(key="user_card_ban_button"),
|
||||
callback_data=f"admin_ban_confirm:{user_id}:{banned_list_page}")
|
||||
builder.button(
|
||||
text=_(
|
||||
key="user_card_open_profile_button"
|
||||
),
|
||||
url=f"tg://user?id={user_id}"
|
||||
)
|
||||
builder.button(
|
||||
text=_(key="user_card_back_to_banned_list_button"),
|
||||
callback_data=f"admin_action:view_banned:{banned_list_page}")
|
||||
@@ -347,16 +423,13 @@ def get_broadcast_confirmation_keyboard(lang: str,
|
||||
|
||||
# Row: target selection (all / active / inactive)
|
||||
target_all_label = _(
|
||||
key="broadcast_target_all_button",
|
||||
default="👥 Все"
|
||||
key="broadcast_target_all_button"
|
||||
)
|
||||
target_active_label = _(
|
||||
key="broadcast_target_active_button",
|
||||
default="✅ Активные"
|
||||
key="broadcast_target_active_button"
|
||||
)
|
||||
target_inactive_label = _(
|
||||
key="broadcast_target_inactive_button",
|
||||
default="⌛ Неактивные"
|
||||
key="broadcast_target_inactive_button"
|
||||
)
|
||||
|
||||
# Highlight current selection with a prefix
|
||||
@@ -378,9 +451,9 @@ def get_broadcast_confirmation_keyboard(lang: str,
|
||||
builder.adjust(3)
|
||||
|
||||
# Row: confirmation
|
||||
builder.button(text=_(key="confirm_broadcast_send_button", default="🚀 Отправить"),
|
||||
builder.button(text=_(key="confirm_broadcast_send_button"),
|
||||
callback_data="broadcast_final_action:send")
|
||||
builder.button(text=_(key="cancel_broadcast_button", default="❌ Отмена"),
|
||||
builder.button(text=_(key="cancel_broadcast_button"),
|
||||
callback_data="broadcast_final_action:cancel")
|
||||
builder.adjust(2)
|
||||
return builder.as_markup()
|
||||
@@ -393,3 +466,21 @@ def get_back_to_admin_panel_keyboard(lang: str,
|
||||
builder.button(text=_(key="back_to_admin_panel_button"),
|
||||
callback_data="admin_action:main")
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def get_back_to_stats_monitoring_keyboard(lang: str,
|
||||
i18n_instance) -> InlineKeyboardMarkup:
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text=_(key="back_to_stats_monitoring_button"),
|
||||
callback_data="admin_section:stats_monitoring")
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def get_back_to_user_management_keyboard(lang: str,
|
||||
i18n_instance) -> InlineKeyboardMarkup:
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text=_(key="back_to_user_management_button"),
|
||||
callback_data="admin_section:user_management")
|
||||
return builder.as_markup()
|
||||
|
||||
@@ -13,11 +13,50 @@ def get_main_menu_inline_keyboard(
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
if settings.SUBSCRIPTION_MINI_APP_URL:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_(key="menu_personal_account_button"),
|
||||
web_app=WebAppInfo(url=settings.SUBSCRIPTION_MINI_APP_URL),
|
||||
)
|
||||
)
|
||||
else:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_(key="menu_personal_account_button"),
|
||||
callback_data="main_action:my_subscription",
|
||||
)
|
||||
)
|
||||
|
||||
if settings.SUPPORT_LINK:
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=_(key="menu_support_button"),
|
||||
url=settings.SUPPORT_LINK))
|
||||
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def get_bot_interface_inline_keyboard(
|
||||
lang: str,
|
||||
i18n_instance,
|
||||
settings: Settings,
|
||||
show_trial_button: bool = False) -> InlineKeyboardMarkup:
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
if show_trial_button and settings.TRIAL_ENABLED:
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=_(key="menu_activate_trial_button"),
|
||||
callback_data="main_action:request_trial"))
|
||||
|
||||
if settings.SUBSCRIPTION_MINI_APP_URL:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_(key="menu_personal_account_button"),
|
||||
web_app=WebAppInfo(url=settings.SUBSCRIPTION_MINI_APP_URL),
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=_(key="menu_subscribe_inline"),
|
||||
callback_data="main_action:subscribe"))
|
||||
@@ -34,7 +73,8 @@ def get_main_menu_inline_keyboard(
|
||||
promo_button = InlineKeyboardButton(
|
||||
text=_(key="menu_apply_promo_button"),
|
||||
callback_data="main_action:apply_promo")
|
||||
builder.row(referral_button, promo_button)
|
||||
builder.row(referral_button)
|
||||
builder.row(promo_button)
|
||||
|
||||
language_button = InlineKeyboardButton(
|
||||
text=_(key="menu_language_settings_inline"),
|
||||
@@ -55,14 +95,36 @@ def get_main_menu_inline_keyboard(
|
||||
InlineKeyboardButton(text=_(key="menu_support_button"),
|
||||
url=settings.SUPPORT_LINK))
|
||||
|
||||
if settings.TERMS_OF_SERVICE_URL:
|
||||
user_agreement_url = settings.USER_AGREEMENT_URL or settings.TERMS_OF_SERVICE_URL
|
||||
if settings.PRIVACY_POLICY_URL or user_agreement_url:
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=_(key="menu_terms_button"),
|
||||
url=settings.TERMS_OF_SERVICE_URL))
|
||||
InlineKeyboardButton(text=_(key="menu_info_button"),
|
||||
callback_data="main_action:info"))
|
||||
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def get_information_links_keyboard(
|
||||
lang: str,
|
||||
i18n_instance,
|
||||
privacy_policy_url: Optional[str],
|
||||
user_agreement_url: Optional[str]) -> InlineKeyboardMarkup:
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
if privacy_policy_url:
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=_(key="privacy_policy_button"),
|
||||
url=privacy_policy_url))
|
||||
if user_agreement_url:
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=_(key="user_agreement_button"),
|
||||
url=user_agreement_url))
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=_(key="back_to_main_menu_button"),
|
||||
callback_data="main_action:back_to_main"))
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def get_language_selection_keyboard(i18n_instance,
|
||||
current_lang: str) -> InlineKeyboardMarkup:
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(current_lang, key, **kwargs
|
||||
@@ -91,19 +153,31 @@ def get_trial_confirmation_keyboard(lang: str,
|
||||
|
||||
|
||||
def get_subscription_options_keyboard(subscription_options: Dict[
|
||||
int, Optional[int]], currency_symbol_val: str, lang: str,
|
||||
i18n_instance) -> InlineKeyboardMarkup:
|
||||
float, Optional[float]], currency_symbol_val: str, lang: str,
|
||||
i18n_instance, traffic_mode: bool = False) -> InlineKeyboardMarkup:
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
def _format_gb(val: float) -> str:
|
||||
return str(int(val)) if float(val).is_integer() else f"{val:g}"
|
||||
if subscription_options:
|
||||
for months, price in subscription_options.items():
|
||||
if price is not None:
|
||||
button_text = _("subscribe_for_months_button",
|
||||
months=months,
|
||||
price=price,
|
||||
currency_symbol=currency_symbol_val)
|
||||
if traffic_mode:
|
||||
button_text = _(
|
||||
"buy_traffic_package_button",
|
||||
traffic_gb=_format_gb(months),
|
||||
price=price,
|
||||
currency_symbol=currency_symbol_val,
|
||||
)
|
||||
callback_data = f"subscribe_period:{_format_gb(months)}"
|
||||
else:
|
||||
button_text = _("subscribe_for_months_button",
|
||||
months=months,
|
||||
price=price,
|
||||
currency_symbol=currency_symbol_val)
|
||||
callback_data = f"subscribe_period:{months}"
|
||||
builder.button(text=button_text,
|
||||
callback_data=f"subscribe_period:{months}")
|
||||
callback_data=callback_data)
|
||||
builder.adjust(1)
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=_(key="back_to_main_menu_button"),
|
||||
@@ -112,26 +186,59 @@ def get_subscription_options_keyboard(subscription_options: Dict[
|
||||
|
||||
|
||||
def get_payment_method_keyboard(months: int, price: float,
|
||||
tribute_url: Optional[str],
|
||||
stars_price: Optional[int],
|
||||
currency_symbol_val: str, lang: str,
|
||||
i18n_instance, settings: Settings) -> InlineKeyboardMarkup:
|
||||
i18n_instance, settings: Settings, sale_mode: str = "subscription") -> InlineKeyboardMarkup:
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
if settings.FREEKASSA_ENABLED:
|
||||
builder.button(text=_("pay_with_sbp_button"),
|
||||
callback_data=f"pay_fk:{months}:{price}")
|
||||
if settings.YOOKASSA_ENABLED:
|
||||
builder.button(text=_("pay_with_yookassa_button"),
|
||||
callback_data=f"pay_yk:{months}:{price}")
|
||||
if settings.TRIBUTE_ENABLED and tribute_url:
|
||||
builder.button(text=_("pay_with_tribute_button"), url=tribute_url)
|
||||
if settings.STARS_ENABLED and stars_price is not None:
|
||||
builder.button(text=_("pay_with_stars_button"),
|
||||
callback_data=f"pay_stars:{months}:{stars_price}")
|
||||
if settings.CRYPTOPAY_ENABLED:
|
||||
builder.button(text=_("pay_with_cryptopay_button"),
|
||||
callback_data=f"pay_crypto:{months}:{price}")
|
||||
def _format_value(val: float) -> str:
|
||||
return str(int(val)) if float(val).is_integer() else f"{val:g}"
|
||||
value_str = _format_value(months)
|
||||
mode_suffix = f":{sale_mode}"
|
||||
import logging as _kbd_logging
|
||||
_kbd_logging.info(
|
||||
"payment_method_keyboard build: order=%s | platega_enabled=%s sbp=%s crypto=%s",
|
||||
settings.payment_methods_order,
|
||||
settings.PLATEGA_ENABLED,
|
||||
settings.PLATEGA_SBP_ENABLED,
|
||||
settings.PLATEGA_CRYPTO_ENABLED,
|
||||
)
|
||||
for method in settings.payment_methods_order:
|
||||
if method == "severpay" and getattr(settings, "SEVERPAY_ENABLED", False):
|
||||
builder.button(
|
||||
text=_("pay_with_severpay_button"),
|
||||
callback_data=f"pay_severpay:{value_str}:{price}{mode_suffix}",
|
||||
)
|
||||
elif method == "freekassa" and settings.FREEKASSA_ENABLED:
|
||||
builder.button(
|
||||
text=_("pay_with_sbp_button"),
|
||||
callback_data=f"pay_fk:{value_str}:{price}{mode_suffix}",
|
||||
)
|
||||
elif method == "platega_sbp" and settings.PLATEGA_ENABLED and settings.PLATEGA_SBP_ENABLED:
|
||||
builder.button(
|
||||
text=_("pay_with_platega_sbp_button"),
|
||||
callback_data=f"pay_platega_sbp:{value_str}:{price}{mode_suffix}",
|
||||
)
|
||||
elif method == "platega_crypto" and settings.PLATEGA_ENABLED and settings.PLATEGA_CRYPTO_ENABLED:
|
||||
builder.button(
|
||||
text=_("pay_with_platega_crypto_button"),
|
||||
callback_data=f"pay_platega_crypto:{value_str}:{price}{mode_suffix}",
|
||||
)
|
||||
elif method == "yookassa" and settings.YOOKASSA_ENABLED:
|
||||
builder.button(
|
||||
text=_("pay_with_yookassa_button"),
|
||||
callback_data=f"pay_yk:{value_str}:{price}{mode_suffix}",
|
||||
)
|
||||
elif method == "stars" and settings.STARS_ENABLED and stars_price is not None:
|
||||
builder.button(
|
||||
text=_("pay_with_stars_button"),
|
||||
callback_data=f"pay_stars:{value_str}:{stars_price}{mode_suffix}",
|
||||
)
|
||||
elif method == "cryptopay" and settings.CRYPTOPAY_ENABLED:
|
||||
builder.button(
|
||||
text=_("pay_with_cryptopay_button"),
|
||||
callback_data=f"pay_crypto:{value_str}:{price}{mode_suffix}",
|
||||
)
|
||||
builder.button(text=_(key="cancel_button"),
|
||||
callback_data="main_action:subscribe")
|
||||
builder.adjust(1)
|
||||
@@ -162,28 +269,33 @@ def get_yk_autopay_choice_keyboard(
|
||||
lang: str,
|
||||
i18n_instance,
|
||||
has_saved_cards: bool = True,
|
||||
sale_mode: str = "subscription",
|
||||
) -> InlineKeyboardMarkup:
|
||||
"""Keyboard for choosing between saved card charge or new card payment when auto-renew is enabled."""
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
price_str = str(price)
|
||||
def _format_value(val: float) -> str:
|
||||
return str(int(val)) if float(val).is_integer() else f"{val:g}"
|
||||
value_str = _format_value(months)
|
||||
suffix = f":{sale_mode}"
|
||||
if has_saved_cards:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_(key="yookassa_autopay_pay_saved_card_button"),
|
||||
callback_data=f"pay_yk_saved_list:{months}:{price_str}",
|
||||
callback_data=f"pay_yk_saved_list:{value_str}:{price_str}{suffix}",
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_(key="yookassa_autopay_pay_new_card_button"),
|
||||
callback_data=f"pay_yk_new:{months}:{price_str}",
|
||||
callback_data=f"pay_yk_new:{value_str}:{price_str}{suffix}",
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_(key="back_to_payment_methods_button"),
|
||||
callback_data=f"subscribe_period:{months}",
|
||||
callback_data=f"subscribe_period:{value_str}",
|
||||
)
|
||||
)
|
||||
return builder.as_markup()
|
||||
@@ -196,6 +308,7 @@ def get_yk_saved_cards_keyboard(
|
||||
lang: str,
|
||||
i18n_instance,
|
||||
page: int = 0,
|
||||
sale_mode: str = "subscription",
|
||||
) -> InlineKeyboardMarkup:
|
||||
"""Paginated keyboard for selecting a saved YooKassa card."""
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
@@ -205,12 +318,16 @@ def get_yk_saved_cards_keyboard(
|
||||
start = page * per_page
|
||||
end = min(total, start + per_page)
|
||||
price_str = str(price)
|
||||
def _format_value(val: float) -> str:
|
||||
return str(int(val)) if float(val).is_integer() else f"{val:g}"
|
||||
value_str = _format_value(months)
|
||||
suffix = f":{sale_mode}"
|
||||
|
||||
for method_id, title in cards[start:end]:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=title,
|
||||
callback_data=f"pay_yk_use_saved:{months}:{price_str}:{method_id}",
|
||||
callback_data=f"pay_yk_use_saved:{value_str}:{price_str}:{method_id}{suffix}",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -219,14 +336,14 @@ def get_yk_saved_cards_keyboard(
|
||||
nav_buttons.append(
|
||||
InlineKeyboardButton(
|
||||
text="⬅️",
|
||||
callback_data=f"pay_yk_saved_list:{months}:{price_str}:{page-1}",
|
||||
callback_data=f"pay_yk_saved_list:{value_str}:{price_str}:{page-1}{suffix}",
|
||||
)
|
||||
)
|
||||
if end < total:
|
||||
nav_buttons.append(
|
||||
InlineKeyboardButton(
|
||||
text="➡️",
|
||||
callback_data=f"pay_yk_saved_list:{months}:{price_str}:{page+1}",
|
||||
callback_data=f"pay_yk_saved_list:{value_str}:{price_str}:{page+1}{suffix}",
|
||||
)
|
||||
)
|
||||
if nav_buttons:
|
||||
@@ -235,13 +352,13 @@ def get_yk_saved_cards_keyboard(
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_(key="yookassa_autopay_pay_new_card_button"),
|
||||
callback_data=f"pay_yk_new:{months}:{price_str}",
|
||||
callback_data=f"pay_yk_new:{value_str}:{price_str}{suffix}",
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_(key="back_to_autopay_method_choice_button"),
|
||||
callback_data=f"pay_yk:{months}:{price_str}",
|
||||
callback_data=f"pay_yk:{value_str}:{price_str}{suffix}",
|
||||
)
|
||||
)
|
||||
return builder.as_markup()
|
||||
@@ -333,22 +450,24 @@ def get_connect_and_main_keyboard(
|
||||
i18n_instance,
|
||||
settings: Settings,
|
||||
config_link: Optional[str],
|
||||
connect_button_url: Optional[str] = None,
|
||||
preserve_message: bool = False) -> InlineKeyboardMarkup:
|
||||
"""Keyboard with a connect button and a back to main menu button."""
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
button_target = connect_button_url or config_link
|
||||
|
||||
if settings.SUBSCRIPTION_MINI_APP_URL:
|
||||
if button_target:
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=_("connect_button"), url=button_target)
|
||||
)
|
||||
elif settings.SUBSCRIPTION_MINI_APP_URL:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_("connect_button"),
|
||||
web_app=WebAppInfo(url=settings.SUBSCRIPTION_MINI_APP_URL),
|
||||
)
|
||||
)
|
||||
elif config_link:
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=_("connect_button"), url=config_link)
|
||||
)
|
||||
else:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
|
||||
+74
-61
@@ -33,7 +33,6 @@ from bot.services.subscription_service import SubscriptionService
|
||||
from bot.services.referral_service import ReferralService
|
||||
from bot.services.promo_code_service import PromoCodeService
|
||||
from bot.services.stars_service import StarsService
|
||||
from bot.services.tribute_service import TributeService, tribute_webhook_route
|
||||
from bot.services.crypto_pay_service import CryptoPayService, cryptopay_webhook_route
|
||||
|
||||
from bot.handlers.user import payment as user_payment_webhook_module
|
||||
@@ -41,6 +40,12 @@ from bot.handlers.admin.sync_admin import perform_sync
|
||||
from bot.utils.message_queue import init_queue_manager
|
||||
|
||||
|
||||
def redact_token(value: str, token: Optional[str]) -> str:
|
||||
if not value or not token:
|
||||
return value
|
||||
return value.replace(token, "***")
|
||||
|
||||
|
||||
async def register_all_routers(dp: Dispatcher, settings: Settings):
|
||||
dp.include_router(build_root_router(settings))
|
||||
logging.info("All application routers registered.")
|
||||
@@ -60,52 +65,48 @@ async def on_startup_configured(dispatcher: Dispatcher):
|
||||
telegram_webhook_url_to_set = settings.WEBHOOK_BASE_URL
|
||||
if telegram_webhook_url_to_set:
|
||||
full_telegram_webhook_url = (
|
||||
f"{str(telegram_webhook_url_to_set).rstrip('/')}/{settings.BOT_TOKEN}"
|
||||
f"{str(telegram_webhook_url_to_set).rstrip('/')}{settings.telegram_webhook_path}"
|
||||
)
|
||||
|
||||
logging.info(
|
||||
f"STARTUP: Attempting to set Telegram webhook to: {full_telegram_webhook_url if full_telegram_webhook_url != 'ERROR_URL_TOKEN_DETECTED' else 'HIDDEN DUE TO TOKEN'}"
|
||||
"STARTUP: Attempting to set Telegram webhook to: %s",
|
||||
redact_token(full_telegram_webhook_url, settings.BOT_TOKEN),
|
||||
)
|
||||
|
||||
if full_telegram_webhook_url != "ERROR_URL_TOKEN_DETECTED":
|
||||
try:
|
||||
current_webhook_info = await bot.get_webhook_info()
|
||||
logging.info(
|
||||
f"STARTUP: Current Telegram webhook info BEFORE setting: {current_webhook_info.model_dump_json(exclude_none=True, indent=2)}"
|
||||
)
|
||||
|
||||
set_success = await bot.set_webhook(
|
||||
url=full_telegram_webhook_url,
|
||||
drop_pending_updates=True,
|
||||
allowed_updates=dispatcher.resolve_used_update_types(),
|
||||
)
|
||||
if set_success:
|
||||
logging.info(
|
||||
f"STARTUP: bot.set_webhook to {full_telegram_webhook_url} returned SUCCESS (True)."
|
||||
)
|
||||
else:
|
||||
logging.error(
|
||||
f"STARTUP: bot.set_webhook to {full_telegram_webhook_url} returned FAILURE (False)."
|
||||
)
|
||||
|
||||
new_webhook_info = await bot.get_webhook_info()
|
||||
logging.info(
|
||||
f"STARTUP: Telegram Webhook info AFTER setting: {new_webhook_info.model_dump_json(exclude_none=True, indent=2)}"
|
||||
)
|
||||
if not new_webhook_info.url:
|
||||
logging.error(
|
||||
"STARTUP: CRITICAL - Telegram Webhook URL is EMPTY after set attempt. Check bot token and URL validity."
|
||||
)
|
||||
|
||||
except Exception as e_setwebhook:
|
||||
logging.error(
|
||||
f"STARTUP: EXCEPTION during set/get Telegram webhook: {e_setwebhook}",
|
||||
exc_info=True,
|
||||
)
|
||||
else:
|
||||
logging.error(
|
||||
"STARTUP: Skipped setting Telegram webhook due to security or configuration error."
|
||||
try:
|
||||
current_webhook_info = await bot.get_webhook_info()
|
||||
logging.info(
|
||||
f"STARTUP: Current Telegram webhook info BEFORE setting: {current_webhook_info.model_dump_json(exclude_none=True, indent=2)}"
|
||||
)
|
||||
|
||||
set_success = await bot.set_webhook(
|
||||
url=full_telegram_webhook_url,
|
||||
secret_token=settings.WEBHOOK_SECRET_TOKEN,
|
||||
drop_pending_updates=True,
|
||||
allowed_updates=dispatcher.resolve_used_update_types(),
|
||||
)
|
||||
if set_success:
|
||||
logging.info(
|
||||
"STARTUP: bot.set_webhook to %s returned SUCCESS (True).",
|
||||
redact_token(full_telegram_webhook_url, settings.BOT_TOKEN),
|
||||
)
|
||||
else:
|
||||
logging.error(
|
||||
"STARTUP: bot.set_webhook to %s returned FAILURE (False).",
|
||||
redact_token(full_telegram_webhook_url, settings.BOT_TOKEN),
|
||||
)
|
||||
|
||||
new_webhook_info = await bot.get_webhook_info()
|
||||
logging.info(
|
||||
f"STARTUP: Telegram Webhook info AFTER setting: {new_webhook_info.model_dump_json(exclude_none=True, indent=2)}"
|
||||
)
|
||||
if not new_webhook_info.url:
|
||||
logging.error(
|
||||
"STARTUP: CRITICAL - Telegram Webhook URL is EMPTY after set attempt. Check bot token and URL validity."
|
||||
)
|
||||
|
||||
except Exception:
|
||||
logging.exception("STARTUP: EXCEPTION during set/get Telegram webhook.")
|
||||
else:
|
||||
logging.error(
|
||||
"STARTUP: WEBHOOK_BASE_URL not set in environment. Webhook mode is required. Exiting."
|
||||
@@ -116,7 +117,7 @@ async def on_startup_configured(dispatcher: Dispatcher):
|
||||
try:
|
||||
menu_text = i18n_instance.gettext(
|
||||
settings.DEFAULT_LANGUAGE,
|
||||
"menu_my_subscription_inline",
|
||||
"menu_personal_account_button",
|
||||
)
|
||||
await bot.set_chat_menu_button(
|
||||
menu_button=MenuButtonWebApp(
|
||||
@@ -128,27 +129,31 @@ async def on_startup_configured(dispatcher: Dispatcher):
|
||||
logging.info(
|
||||
"STARTUP: Mini app domain registered and default menu button restored."
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(
|
||||
f"STARTUP: Failed to register mini app domain: {e}", exc_info=True
|
||||
)
|
||||
except Exception:
|
||||
logging.exception("STARTUP: Failed to register mini app domain.")
|
||||
|
||||
if settings.START_COMMAND_DESCRIPTION:
|
||||
try:
|
||||
await bot.set_my_commands([
|
||||
BotCommand(command="start", description=settings.START_COMMAND_DESCRIPTION)
|
||||
])
|
||||
logging.info("STARTUP: /start command description set.")
|
||||
except Exception as e:
|
||||
logging.error(f"STARTUP: Failed to set bot commands: {e}", exc_info=True)
|
||||
try:
|
||||
bot_commands = [
|
||||
BotCommand(command="tg", description="Интерфейс в боте"),
|
||||
]
|
||||
if settings.START_COMMAND_DESCRIPTION:
|
||||
bot_commands.insert(
|
||||
0,
|
||||
BotCommand(command="start",
|
||||
description=settings.START_COMMAND_DESCRIPTION),
|
||||
)
|
||||
await bot.set_my_commands(bot_commands)
|
||||
logging.info("STARTUP: bot command descriptions set.")
|
||||
except Exception:
|
||||
logging.exception("STARTUP: Failed to set bot commands.")
|
||||
|
||||
# Initialize message queue manager
|
||||
try:
|
||||
queue_manager = init_queue_manager(bot)
|
||||
dispatcher["queue_manager"] = queue_manager
|
||||
logging.info("STARTUP: Message queue manager initialized")
|
||||
except Exception as e:
|
||||
logging.error(f"STARTUP: Failed to initialize message queue manager: {e}", exc_info=True)
|
||||
except Exception:
|
||||
logging.exception("STARTUP: Failed to initialize message queue manager.")
|
||||
|
||||
# Automatic sync on startup
|
||||
try:
|
||||
@@ -167,8 +172,8 @@ async def on_startup_configured(dispatcher: Dispatcher):
|
||||
else:
|
||||
logging.warning(f"STARTUP: Automatic sync completed with issues. Status: {sync_result.get('status', 'unknown')}")
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"STARTUP: Failed to run automatic sync: {e}", exc_info=True)
|
||||
except Exception:
|
||||
logging.exception("STARTUP: Failed to run automatic sync.")
|
||||
|
||||
logging.info("STARTUP: Bot on_startup_configured completed.")
|
||||
|
||||
@@ -200,13 +205,15 @@ async def on_shutdown_configured(dispatcher: Dispatcher):
|
||||
"panel_service",
|
||||
"cryptopay_service",
|
||||
"freekassa_service",
|
||||
"tribute_service",
|
||||
"panel_webhook_service",
|
||||
"yookassa_service",
|
||||
"lknpd_service",
|
||||
"promo_code_service",
|
||||
"stars_service",
|
||||
"subscription_service",
|
||||
"referral_service",
|
||||
"platega_service",
|
||||
"severpay_service",
|
||||
):
|
||||
await close_service(service_key)
|
||||
|
||||
@@ -242,8 +249,14 @@ async def run_bot(settings_param: Settings):
|
||||
actual_bot_username = "your_bot_username"
|
||||
try:
|
||||
bot_info = await bot.get_me()
|
||||
actual_bot_username = bot_info.username
|
||||
logging.info(f"Bot username resolved: @{actual_bot_username}")
|
||||
if bot_info.username:
|
||||
actual_bot_username = bot_info.username
|
||||
dp["bot_username"] = actual_bot_username
|
||||
logging.info(f"Bot username resolved: @{actual_bot_username}")
|
||||
else:
|
||||
logging.warning(
|
||||
"Bot username is empty; Telegram Login Widget will be unavailable."
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(
|
||||
f"Failed to get bot info (e.g., for YooKassa default URL): {e}. Using fallback: {actual_bot_username}"
|
||||
|
||||
@@ -40,6 +40,14 @@ class ChannelSubscriptionMiddleware(BaseMiddleware):
|
||||
if not event_user or event_user.id in self.settings.ADMIN_IDS:
|
||||
return await handler(event, data)
|
||||
|
||||
callback_query = event.callback_query
|
||||
if (
|
||||
callback_query
|
||||
and callback_query.data
|
||||
and callback_query.data == "channel_subscription:verify"
|
||||
):
|
||||
return await handler(event, data)
|
||||
|
||||
# Allow /start to reach the handler so the check can be re-run.
|
||||
message_object: Optional[Message] = event.message
|
||||
if (
|
||||
|
||||
@@ -22,13 +22,17 @@ class ProfileSyncMiddleware(BaseMiddleware):
|
||||
|
||||
if session and tg_user:
|
||||
try:
|
||||
db_user = await user_dal.get_user_by_id(session, tg_user.id)
|
||||
db_user = await user_dal.get_user_by_telegram_id(session, tg_user.id)
|
||||
if not db_user:
|
||||
db_user = await user_dal.get_user_by_id(session, tg_user.id)
|
||||
if db_user:
|
||||
update_payload: Dict[str, Any] = {}
|
||||
sanitized_username = sanitize_username(tg_user.username)
|
||||
sanitized_first_name = sanitize_display_name(tg_user.first_name)
|
||||
sanitized_last_name = sanitize_display_name(tg_user.last_name)
|
||||
|
||||
if db_user.telegram_id != tg_user.id:
|
||||
update_payload["telegram_id"] = tg_user.id
|
||||
if db_user.username != sanitized_username:
|
||||
update_payload["username"] = sanitized_username
|
||||
if db_user.first_name != sanitized_first_name:
|
||||
@@ -37,7 +41,7 @@ class ProfileSyncMiddleware(BaseMiddleware):
|
||||
update_payload["last_name"] = sanitized_last_name
|
||||
|
||||
if update_payload:
|
||||
await user_dal.update_user(session, tg_user.id, update_payload)
|
||||
await user_dal.update_user(session, db_user.user_id, update_payload)
|
||||
logging.info(
|
||||
f"ProfileSyncMiddleware: Updated user {tg_user.id} profile fields: {list(update_payload.keys())}"
|
||||
)
|
||||
@@ -47,13 +51,20 @@ class ProfileSyncMiddleware(BaseMiddleware):
|
||||
panel_service = data.get("panel_service")
|
||||
if panel_service and db_user.panel_user_uuid:
|
||||
description_text = "\n".join([
|
||||
db_user.email or "",
|
||||
username_for_display(tg_user.username, with_at=False) if sanitized_username is not None else "",
|
||||
sanitized_first_name or "",
|
||||
sanitized_last_name or "",
|
||||
]).strip()
|
||||
panel_payload = {
|
||||
"description": description_text,
|
||||
"telegramId": tg_user.id,
|
||||
}
|
||||
if db_user.email:
|
||||
panel_payload["email"] = db_user.email
|
||||
await panel_service.update_user_details_on_panel(
|
||||
db_user.panel_user_uuid,
|
||||
{"description": description_text},
|
||||
panel_payload,
|
||||
)
|
||||
except Exception as e_upd_desc:
|
||||
logging.warning(
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import hashlib
|
||||
import logging
|
||||
import json
|
||||
import hmac
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import Bot
|
||||
@@ -17,6 +19,9 @@ from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
|
||||
from bot.services.notification_service import NotificationService
|
||||
from db.dal import payment_dal, user_dal
|
||||
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
|
||||
from bot.utils.config_link import prepare_config_links
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CryptoPayService:
|
||||
@@ -37,6 +42,7 @@ class CryptoPayService:
|
||||
self.async_session_factory = async_session_factory
|
||||
self.subscription_service = subscription_service
|
||||
self.referral_service = referral_service
|
||||
self.token = token
|
||||
if token:
|
||||
net = Networks.TEST_NET if str(network).lower() == "testnet" else Networks.MAIN_NET
|
||||
self.client = AioCryptoPay(token=token, network=net)
|
||||
@@ -63,6 +69,8 @@ class CryptoPayService:
|
||||
months: int,
|
||||
amount: float,
|
||||
description: str,
|
||||
sale_mode: str = "subscription",
|
||||
url_kind: str = "bot",
|
||||
) -> Optional[str]:
|
||||
if not self.configured or not self.client:
|
||||
logging.error("CryptoPayService not configured")
|
||||
@@ -78,7 +86,7 @@ class CryptoPayService:
|
||||
"currency": self.settings.CRYPTOPAY_ASSET,
|
||||
"status": "pending_cryptopay",
|
||||
"description": description,
|
||||
"subscription_duration_months": months,
|
||||
"subscription_duration_months": int(months),
|
||||
"provider": "cryptopay",
|
||||
},
|
||||
)
|
||||
@@ -94,6 +102,8 @@ class CryptoPayService:
|
||||
"user_id": str(user_id),
|
||||
"subscription_months": str(months),
|
||||
"payment_db_id": str(payment_record.payment_id),
|
||||
"sale_mode": sale_mode,
|
||||
"traffic_gb": str(months) if sale_mode == "traffic" else None,
|
||||
})
|
||||
try:
|
||||
invoice = await self.client.create_invoice(
|
||||
@@ -112,16 +122,22 @@ class CryptoPayService:
|
||||
str(invoice.status),
|
||||
)
|
||||
await session.commit()
|
||||
except Exception as e_db_update:
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"Failed to update cryptopay payment record {payment_record.payment_id}: {e_db_update}",
|
||||
exc_info=True,
|
||||
logging.exception(
|
||||
"Failed to update cryptopay payment record %s.",
|
||||
payment_record.payment_id,
|
||||
)
|
||||
return None
|
||||
if url_kind == "web":
|
||||
return (
|
||||
getattr(invoice, "web_app_invoice_url", None)
|
||||
or getattr(invoice, "mini_app_invoice_url", None)
|
||||
or invoice.bot_invoice_url
|
||||
)
|
||||
return invoice.bot_invoice_url
|
||||
except Exception as e:
|
||||
logging.error(f"CryptoPay invoice creation failed: {e}", exc_info=True)
|
||||
except Exception:
|
||||
logging.exception("CryptoPay invoice creation failed.")
|
||||
return None
|
||||
|
||||
async def _invoice_paid_handler(self, update: Update, app: web.Application):
|
||||
@@ -132,10 +148,12 @@ class CryptoPayService:
|
||||
try:
|
||||
meta = json.loads(invoice.payload)
|
||||
user_id = int(meta["user_id"])
|
||||
months = int(meta["subscription_months"])
|
||||
months = float(meta.get("subscription_months") or 0)
|
||||
payment_db_id = int(meta["payment_db_id"])
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to parse CryptoPay payload: {e}")
|
||||
sale_mode = meta.get("sale_mode") or ("traffic" if self.settings.traffic_sale_mode else "subscription")
|
||||
traffic_gb = float(meta.get("traffic_gb")) if meta.get("traffic_gb") else months
|
||||
except Exception:
|
||||
logging.exception("Failed to parse CryptoPay payload.")
|
||||
return
|
||||
|
||||
async_session_factory: sessionmaker = app["async_session_factory"]
|
||||
@@ -156,22 +174,26 @@ class CryptoPayService:
|
||||
activation = await subscription_service.activate_subscription(
|
||||
session,
|
||||
user_id,
|
||||
months,
|
||||
int(months) if sale_mode != "traffic" else 0,
|
||||
float(invoice.amount),
|
||||
payment_db_id,
|
||||
provider="cryptopay",
|
||||
sale_mode=sale_mode,
|
||||
traffic_gb=traffic_gb if sale_mode == "traffic" else None,
|
||||
)
|
||||
referral_bonus = await referral_service.apply_referral_bonuses_for_payment(
|
||||
session,
|
||||
user_id,
|
||||
months,
|
||||
current_payment_db_id=payment_db_id,
|
||||
skip_if_active_before_payment=False,
|
||||
)
|
||||
referral_bonus = None
|
||||
if sale_mode != "traffic":
|
||||
referral_bonus = await referral_service.apply_referral_bonuses_for_payment(
|
||||
session,
|
||||
user_id,
|
||||
int(months) or 1,
|
||||
current_payment_db_id=payment_db_id,
|
||||
skip_if_active_before_payment=False,
|
||||
)
|
||||
await session.commit()
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logging.error(f"Failed to process CryptoPay invoice: {e}", exc_info=True)
|
||||
logging.exception("Failed to process CryptoPay invoice.")
|
||||
return
|
||||
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
@@ -179,14 +201,21 @@ class CryptoPayService:
|
||||
lang = db_user.language_code if db_user and db_user.language_code else settings.DEFAULT_LANGUAGE
|
||||
_ = lambda k, **kw: i18n.gettext(lang, k, **kw)
|
||||
|
||||
config_link = activation.get("subscription_url") or _("config_link_not_available")
|
||||
raw_config_link = activation.get("subscription_url") if activation else None
|
||||
display_link, button_link = await prepare_config_links(settings, raw_config_link)
|
||||
config_link_text = display_link or _("config_link_not_available")
|
||||
final_end = activation.get("end_date")
|
||||
applied_days = 0
|
||||
if referral_bonus and referral_bonus.get("referee_new_end_date"):
|
||||
final_end = referral_bonus["referee_new_end_date"]
|
||||
applied_days = referral_bonus.get("referee_bonus_applied_days", 0)
|
||||
|
||||
if applied_days:
|
||||
if sale_mode == "traffic":
|
||||
text = _("payment_successful_traffic_full",
|
||||
traffic_gb=str(int(traffic_gb)) if float(traffic_gb).is_integer() else f"{traffic_gb:g}",
|
||||
end_date=final_end.strftime('%Y-%m-%d') if final_end else "—",
|
||||
config_link=config_link_text)
|
||||
elif applied_days:
|
||||
inviter_name_display = _("friend_placeholder")
|
||||
if db_user and db_user.referred_by_id:
|
||||
inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id)
|
||||
@@ -197,20 +226,25 @@ class CryptoPayService:
|
||||
elif inviter.username:
|
||||
inviter_name_display = username_for_display(inviter.username, with_at=False)
|
||||
text = _("payment_successful_with_referral_bonus_full",
|
||||
months=months,
|
||||
months=int(months),
|
||||
base_end_date=activation["end_date"].strftime('%Y-%m-%d'),
|
||||
bonus_days=applied_days,
|
||||
final_end_date=final_end.strftime('%Y-%m-%d'),
|
||||
inviter_name=inviter_name_display,
|
||||
config_link=config_link)
|
||||
config_link=config_link_text)
|
||||
else:
|
||||
text = _("payment_successful_full",
|
||||
months=months,
|
||||
end_date=final_end.strftime('%Y-%m-%d'),
|
||||
config_link=config_link)
|
||||
months=int(months),
|
||||
end_date=final_end.strftime('%Y-%m-%d') if final_end else "—",
|
||||
config_link=config_link_text)
|
||||
|
||||
markup = get_connect_and_main_keyboard(
|
||||
lang, i18n, settings, config_link, preserve_message=True
|
||||
lang,
|
||||
i18n,
|
||||
settings,
|
||||
display_link,
|
||||
connect_button_url=button_link,
|
||||
preserve_message=True,
|
||||
)
|
||||
try:
|
||||
await bot.send_message(
|
||||
@@ -220,8 +254,8 @@ class CryptoPayService:
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to send CryptoPay success message: {e}")
|
||||
except Exception:
|
||||
logging.exception("Failed to send CryptoPay success message.")
|
||||
|
||||
# Send notification about payment
|
||||
try:
|
||||
@@ -231,16 +265,35 @@ class CryptoPayService:
|
||||
user_id=user_id,
|
||||
amount=float(invoice.amount),
|
||||
currency=invoice.asset or settings.DEFAULT_CURRENCY_SYMBOL,
|
||||
months=months,
|
||||
months=int(months) if sale_mode != "traffic" else 0,
|
||||
traffic_gb=traffic_gb if sale_mode == "traffic" else None,
|
||||
payment_provider="crypto_pay",
|
||||
username=user.username if user else None
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to send crypto_pay payment notification: {e}")
|
||||
except Exception:
|
||||
logging.exception("Failed to send crypto_pay payment notification.")
|
||||
|
||||
def _validate_webhook_signature(self, raw_body: bytes, signature: str) -> bool:
|
||||
if not self.token:
|
||||
return False
|
||||
|
||||
expected_signature = hmac.new(
|
||||
hashlib.sha256(self.token.encode("utf-8")).digest(),
|
||||
raw_body,
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
if not hmac.compare_digest(expected_signature, signature or ""):
|
||||
logger.error("CryptoPay signature mismatch")
|
||||
return False
|
||||
return True
|
||||
|
||||
async def webhook_route(self, request: web.Request) -> web.Response:
|
||||
if not self.configured or not self.client:
|
||||
return web.Response(status=503, text="cryptopay_disabled")
|
||||
raw_body = await request.read()
|
||||
signature = request.headers.get("crypto-pay-api-signature", "")
|
||||
if not self._validate_webhook_signature(raw_body, signature):
|
||||
return web.Response(status=401)
|
||||
return await self.client.get_updates(request)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,605 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
import re
|
||||
import secrets
|
||||
import smtplib
|
||||
import ssl
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from email.message import EmailMessage
|
||||
from email.utils import formataddr
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config.settings import Settings
|
||||
from db.dal import security_dal
|
||||
from db.models import EmailVerificationCode
|
||||
from bot.services.email_templates import EmailContent, render_login_code
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SmtpAttempt:
|
||||
port: int
|
||||
use_ssl: bool
|
||||
starttls: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EmailCodeRequestResult:
|
||||
ok: bool
|
||||
error: Optional[str] = None
|
||||
retry_after: Optional[int] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EmailCodeVerifyResult:
|
||||
ok: bool
|
||||
error: Optional[str] = None
|
||||
retry_after: Optional[int] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EmailMagicVerifyResult:
|
||||
ok: bool
|
||||
error: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
purpose: Optional[str] = None
|
||||
target_user_id: Optional[int] = None
|
||||
|
||||
|
||||
def normalize_email(value: str) -> str:
|
||||
return (value or "").strip().lower()
|
||||
|
||||
|
||||
def is_valid_email(value: str) -> bool:
|
||||
email = normalize_email(value)
|
||||
return bool(email and len(email) <= 254 and EMAIL_RE.match(email))
|
||||
|
||||
|
||||
def _email_throttle_identifier(email: str, purpose: str, target_user_id: Optional[int]) -> str:
|
||||
target_part = "none" if target_user_id is None else str(target_user_id)
|
||||
return f"{purpose}:{target_part}:{email}"
|
||||
|
||||
|
||||
class EmailAuthService:
|
||||
def __init__(self, settings: Settings):
|
||||
self.settings = settings
|
||||
|
||||
def _smtp_attempts(self) -> list[SmtpAttempt]:
|
||||
attempts: list[SmtpAttempt] = []
|
||||
primary_port = int(self.settings.SMTP_PORT)
|
||||
|
||||
for port in self.settings.smtp_ports_to_try:
|
||||
if port == primary_port:
|
||||
use_ssl = bool(self.settings.SMTP_USE_SSL or port == 465)
|
||||
starttls = bool(self.settings.SMTP_STARTTLS and not use_ssl)
|
||||
else:
|
||||
use_ssl = port == 465
|
||||
starttls = bool(self.settings.SMTP_STARTTLS and not use_ssl)
|
||||
attempts.append(SmtpAttempt(port=port, use_ssl=use_ssl, starttls=starttls))
|
||||
|
||||
return attempts or [
|
||||
SmtpAttempt(
|
||||
port=primary_port,
|
||||
use_ssl=bool(self.settings.SMTP_USE_SSL or primary_port == 465),
|
||||
starttls=bool(
|
||||
self.settings.SMTP_STARTTLS
|
||||
and not self.settings.SMTP_USE_SSL
|
||||
and primary_port != 465
|
||||
),
|
||||
)
|
||||
]
|
||||
|
||||
def _hash_code(self, email: str, purpose: str, code: str) -> str:
|
||||
secret = hmac.new(
|
||||
self.settings.BOT_TOKEN.encode("utf-8"),
|
||||
b"remnawave-tg-shop-email-code",
|
||||
hashlib.sha256,
|
||||
).digest()
|
||||
payload = f"{purpose}:{email}:{code}".encode("utf-8")
|
||||
return hmac.new(secret, payload, hashlib.sha256).hexdigest()
|
||||
|
||||
def _hash_magic_token(self, token: str) -> str:
|
||||
secret = hmac.new(
|
||||
self.settings.BOT_TOKEN.encode("utf-8"),
|
||||
b"remnawave-tg-shop-email-magic",
|
||||
hashlib.sha256,
|
||||
).digest()
|
||||
return hmac.new(secret, token.encode("utf-8"), hashlib.sha256).hexdigest()
|
||||
|
||||
def _build_magic_link(self, *, token: str, purpose: str) -> Optional[str]:
|
||||
base_url = (self.settings.SUBSCRIPTION_MINI_APP_URL or "").strip()
|
||||
if not base_url:
|
||||
return None
|
||||
from urllib.parse import urlencode, urlsplit, urlunsplit
|
||||
|
||||
parsed = urlsplit(base_url)
|
||||
if parsed.scheme not in ("http", "https") or not parsed.netloc:
|
||||
return None
|
||||
params = {"login_token": token}
|
||||
if purpose and purpose != "login":
|
||||
params["login_purpose"] = purpose
|
||||
existing_query = parsed.query
|
||||
new_query = urlencode(params)
|
||||
merged_query = f"{existing_query}&{new_query}" if existing_query else new_query
|
||||
return urlunsplit((parsed.scheme, parsed.netloc, parsed.path, merged_query, parsed.fragment))
|
||||
|
||||
async def request_code(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
email: str,
|
||||
purpose: str,
|
||||
language_code: str,
|
||||
target_user_id: Optional[int] = None,
|
||||
) -> EmailCodeRequestResult:
|
||||
normalized_email = normalize_email(email)
|
||||
if not self.settings.email_auth_configured:
|
||||
return EmailCodeRequestResult(ok=False, error="email_auth_not_configured")
|
||||
if not is_valid_email(normalized_email):
|
||||
return EmailCodeRequestResult(ok=False, error="invalid_email")
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
throttle = await security_dal.check_throttle(
|
||||
session,
|
||||
scope=security_dal.EMAIL_CODE_VERIFY_SCOPE,
|
||||
identifier=_email_throttle_identifier(normalized_email, purpose, target_user_id),
|
||||
now=now,
|
||||
)
|
||||
if throttle.locked:
|
||||
return EmailCodeRequestResult(
|
||||
ok=False,
|
||||
error="rate_limited",
|
||||
retry_after=throttle.retry_after,
|
||||
)
|
||||
|
||||
latest_code = await self._get_latest_code(
|
||||
session,
|
||||
email=normalized_email,
|
||||
purpose=purpose,
|
||||
target_user_id=target_user_id,
|
||||
)
|
||||
if latest_code and latest_code.created_at:
|
||||
created_at = latest_code.created_at
|
||||
if created_at.tzinfo is None:
|
||||
created_at = created_at.replace(tzinfo=timezone.utc)
|
||||
resend_after = max(1, int(self.settings.EMAIL_CODE_RESEND_SECONDS))
|
||||
elapsed = int((now - created_at).total_seconds())
|
||||
if elapsed < resend_after and latest_code.consumed_at is None:
|
||||
return EmailCodeRequestResult(
|
||||
ok=False,
|
||||
error="rate_limited",
|
||||
retry_after=resend_after - elapsed,
|
||||
)
|
||||
|
||||
await session.execute(
|
||||
update(EmailVerificationCode)
|
||||
.where(
|
||||
EmailVerificationCode.email == normalized_email,
|
||||
EmailVerificationCode.purpose == purpose,
|
||||
EmailVerificationCode.target_user_id == target_user_id,
|
||||
EmailVerificationCode.status == "active",
|
||||
EmailVerificationCode.consumed_at.is_(None),
|
||||
)
|
||||
.values(status="superseded")
|
||||
)
|
||||
|
||||
code = f"{secrets.randbelow(1_000_000):06d}"
|
||||
magic_token = secrets.token_urlsafe(32)
|
||||
magic_link = self._build_magic_link(token=magic_token, purpose=purpose)
|
||||
code_model = EmailVerificationCode(
|
||||
email=normalized_email,
|
||||
code_hash=self._hash_code(normalized_email, purpose, code),
|
||||
magic_token_hash=self._hash_magic_token(magic_token) if magic_link else None,
|
||||
purpose=purpose,
|
||||
target_user_id=target_user_id,
|
||||
expires_at=now + timedelta(seconds=max(60, int(self.settings.EMAIL_CODE_TTL_SECONDS))),
|
||||
status="active",
|
||||
)
|
||||
session.add(code_model)
|
||||
await session.flush()
|
||||
|
||||
await self._send_code_email(
|
||||
email=normalized_email,
|
||||
code=code,
|
||||
language_code=language_code,
|
||||
magic_link=magic_link,
|
||||
)
|
||||
return EmailCodeRequestResult(ok=True)
|
||||
|
||||
async def verify_code(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
email: str,
|
||||
purpose: str,
|
||||
code: str,
|
||||
target_user_id: Optional[int] = None,
|
||||
) -> EmailCodeVerifyResult:
|
||||
normalized_email = normalize_email(email)
|
||||
normalized_code = re.sub(r"\D", "", code or "")
|
||||
if not is_valid_email(normalized_email):
|
||||
return EmailCodeVerifyResult(ok=False, error="invalid_code")
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
throttle_identifier = _email_throttle_identifier(
|
||||
normalized_email,
|
||||
purpose,
|
||||
target_user_id,
|
||||
)
|
||||
throttle = await security_dal.check_throttle(
|
||||
session,
|
||||
scope=security_dal.EMAIL_CODE_VERIFY_SCOPE,
|
||||
identifier=throttle_identifier,
|
||||
now=now,
|
||||
)
|
||||
if throttle.locked:
|
||||
return EmailCodeVerifyResult(
|
||||
ok=False,
|
||||
error="rate_limited",
|
||||
retry_after=throttle.retry_after,
|
||||
)
|
||||
|
||||
latest_code = await self._get_latest_code(
|
||||
session,
|
||||
email=normalized_email,
|
||||
purpose=purpose,
|
||||
target_user_id=target_user_id,
|
||||
)
|
||||
if not latest_code or latest_code.consumed_at is not None:
|
||||
return EmailCodeVerifyResult(ok=False, error="invalid_code")
|
||||
|
||||
expires_at = latest_code.expires_at
|
||||
if expires_at.tzinfo is None:
|
||||
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
||||
if expires_at < now:
|
||||
return EmailCodeVerifyResult(ok=False, error="expired_code")
|
||||
|
||||
max_attempts = max(1, int(self.settings.EMAIL_CODE_MAX_ATTEMPTS))
|
||||
if int(latest_code.attempts or 0) >= max_attempts:
|
||||
return EmailCodeVerifyResult(ok=False, error="too_many_attempts")
|
||||
|
||||
if len(normalized_code) != 6:
|
||||
latest_code.attempts = int(latest_code.attempts or 0) + 1
|
||||
throttle_result = await security_dal.record_throttle_failure(
|
||||
session,
|
||||
scope=security_dal.EMAIL_CODE_VERIFY_SCOPE,
|
||||
identifier=throttle_identifier,
|
||||
max_failures=self.settings.BRUTE_FORCE_MAX_FAILURES,
|
||||
window_seconds=self.settings.BRUTE_FORCE_WINDOW_SECONDS,
|
||||
lock_seconds=self.settings.BRUTE_FORCE_LOCK_SECONDS,
|
||||
now=now,
|
||||
)
|
||||
await session.flush()
|
||||
if throttle_result.locked:
|
||||
return EmailCodeVerifyResult(
|
||||
ok=False,
|
||||
error="rate_limited",
|
||||
retry_after=throttle_result.retry_after,
|
||||
)
|
||||
if int(latest_code.attempts or 0) >= max_attempts:
|
||||
return EmailCodeVerifyResult(ok=False, error="too_many_attempts")
|
||||
return EmailCodeVerifyResult(ok=False, error="invalid_code")
|
||||
|
||||
expected_hash = self._hash_code(normalized_email, purpose, normalized_code)
|
||||
if not hmac.compare_digest(expected_hash, latest_code.code_hash):
|
||||
latest_code.attempts = int(latest_code.attempts or 0) + 1
|
||||
throttle_result = await security_dal.record_throttle_failure(
|
||||
session,
|
||||
scope=security_dal.EMAIL_CODE_VERIFY_SCOPE,
|
||||
identifier=throttle_identifier,
|
||||
max_failures=self.settings.BRUTE_FORCE_MAX_FAILURES,
|
||||
window_seconds=self.settings.BRUTE_FORCE_WINDOW_SECONDS,
|
||||
lock_seconds=self.settings.BRUTE_FORCE_LOCK_SECONDS,
|
||||
now=now,
|
||||
)
|
||||
await session.flush()
|
||||
if throttle_result.locked:
|
||||
return EmailCodeVerifyResult(
|
||||
ok=False,
|
||||
error="rate_limited",
|
||||
retry_after=throttle_result.retry_after,
|
||||
)
|
||||
return EmailCodeVerifyResult(ok=False, error="invalid_code")
|
||||
|
||||
latest_code.consumed_at = now
|
||||
await security_dal.clear_throttle_state(
|
||||
session,
|
||||
scope=security_dal.EMAIL_CODE_VERIFY_SCOPE,
|
||||
identifier=throttle_identifier,
|
||||
)
|
||||
await session.flush()
|
||||
return EmailCodeVerifyResult(ok=True)
|
||||
|
||||
async def _get_latest_code(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
email: str,
|
||||
purpose: str,
|
||||
target_user_id: Optional[int],
|
||||
) -> Optional[EmailVerificationCode]:
|
||||
stmt = (
|
||||
select(EmailVerificationCode)
|
||||
.where(
|
||||
EmailVerificationCode.email == email,
|
||||
EmailVerificationCode.purpose == purpose,
|
||||
EmailVerificationCode.target_user_id == target_user_id,
|
||||
EmailVerificationCode.status == "active",
|
||||
EmailVerificationCode.consumed_at.is_(None),
|
||||
)
|
||||
.order_by(EmailVerificationCode.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def verify_magic_token(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
token: str,
|
||||
purpose: str,
|
||||
target_user_id: Optional[int] = None,
|
||||
) -> EmailMagicVerifyResult:
|
||||
if not token:
|
||||
return EmailMagicVerifyResult(ok=False, error="invalid_token")
|
||||
|
||||
token_hash = self._hash_magic_token(token)
|
||||
now = datetime.now(timezone.utc)
|
||||
stmt = (
|
||||
select(EmailVerificationCode)
|
||||
.where(
|
||||
EmailVerificationCode.magic_token_hash == token_hash,
|
||||
EmailVerificationCode.purpose == purpose,
|
||||
EmailVerificationCode.target_user_id == target_user_id,
|
||||
EmailVerificationCode.status == "active",
|
||||
EmailVerificationCode.consumed_at.is_(None),
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
record = result.scalar_one_or_none()
|
||||
if not record:
|
||||
return EmailMagicVerifyResult(ok=False, error="invalid_token")
|
||||
|
||||
expires_at = record.expires_at
|
||||
if expires_at.tzinfo is None:
|
||||
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
||||
if expires_at < now:
|
||||
return EmailMagicVerifyResult(ok=False, error="expired_token")
|
||||
|
||||
record.consumed_at = now
|
||||
throttle_identifier = _email_throttle_identifier(
|
||||
record.email,
|
||||
purpose,
|
||||
target_user_id,
|
||||
)
|
||||
await security_dal.clear_throttle_state(
|
||||
session,
|
||||
scope=security_dal.EMAIL_CODE_VERIFY_SCOPE,
|
||||
identifier=throttle_identifier,
|
||||
)
|
||||
await session.flush()
|
||||
return EmailMagicVerifyResult(
|
||||
ok=True,
|
||||
email=record.email,
|
||||
purpose=record.purpose,
|
||||
target_user_id=record.target_user_id,
|
||||
)
|
||||
|
||||
async def _send_code_email(
|
||||
self,
|
||||
*,
|
||||
email: str,
|
||||
code: str,
|
||||
language_code: str,
|
||||
magic_link: Optional[str] = None,
|
||||
) -> None:
|
||||
await asyncio.to_thread(
|
||||
self._send_code_email_sync,
|
||||
email=email,
|
||||
code=code,
|
||||
language_code=language_code,
|
||||
magic_link=magic_link,
|
||||
)
|
||||
|
||||
async def send_custom_email(
|
||||
self,
|
||||
*,
|
||||
email: str,
|
||||
subject: str,
|
||||
body: str,
|
||||
html_body: Optional[str] = None,
|
||||
) -> None:
|
||||
await asyncio.to_thread(
|
||||
self._send_custom_email_sync,
|
||||
email=email,
|
||||
subject=subject,
|
||||
body=body,
|
||||
html_body=html_body,
|
||||
)
|
||||
|
||||
async def send_rendered_email(
|
||||
self,
|
||||
*,
|
||||
email: str,
|
||||
content: EmailContent,
|
||||
) -> None:
|
||||
await self.send_custom_email(
|
||||
email=email,
|
||||
subject=content.subject,
|
||||
body=content.text,
|
||||
html_body=content.html,
|
||||
)
|
||||
|
||||
def _send_code_email_sync(
|
||||
self,
|
||||
*,
|
||||
email: str,
|
||||
code: str,
|
||||
language_code: str,
|
||||
magic_link: Optional[str] = None,
|
||||
) -> None:
|
||||
content = render_login_code(
|
||||
self.settings,
|
||||
code=code,
|
||||
language_code=language_code,
|
||||
magic_link=magic_link,
|
||||
)
|
||||
|
||||
message = EmailMessage()
|
||||
message["Subject"] = content.subject
|
||||
message["From"] = formataddr(
|
||||
(
|
||||
self.settings.SMTP_FROM_NAME or self.settings.WEBAPP_TITLE,
|
||||
self.settings.SMTP_FROM_EMAIL or "",
|
||||
)
|
||||
)
|
||||
message["To"] = email
|
||||
message.set_content(content.text)
|
||||
message.add_alternative(content.html, subtype="html")
|
||||
|
||||
context = ssl.create_default_context()
|
||||
smtp_host = self.settings.SMTP_HOST
|
||||
timeout = max(5, int(self.settings.SMTP_TIMEOUT_SECONDS))
|
||||
attempts = self._smtp_attempts()
|
||||
last_error: Optional[BaseException] = None
|
||||
|
||||
for attempt_number, attempt in enumerate(attempts, start=1):
|
||||
try:
|
||||
self._send_message_via_smtp(
|
||||
message=message,
|
||||
smtp_host=smtp_host,
|
||||
smtp_port=attempt.port,
|
||||
timeout=timeout,
|
||||
context=context,
|
||||
use_ssl=attempt.use_ssl,
|
||||
starttls=attempt.starttls,
|
||||
)
|
||||
logger.info(
|
||||
"Email verification code sent to %s via %s:%s",
|
||||
email,
|
||||
smtp_host,
|
||||
attempt.port,
|
||||
)
|
||||
return
|
||||
except (OSError, smtplib.SMTPException, TimeoutError) as exc:
|
||||
last_error = exc
|
||||
log_level = logging.WARNING if attempt_number < len(attempts) else logging.ERROR
|
||||
logger.log(
|
||||
log_level,
|
||||
"SMTP send attempt %s/%s failed via %s:%s (ssl=%s, starttls=%s): %s",
|
||||
attempt_number,
|
||||
len(attempts),
|
||||
smtp_host,
|
||||
attempt.port,
|
||||
attempt.use_ssl,
|
||||
attempt.starttls,
|
||||
exc,
|
||||
)
|
||||
|
||||
if last_error:
|
||||
raise last_error
|
||||
|
||||
def _send_custom_email_sync(
|
||||
self,
|
||||
*,
|
||||
email: str,
|
||||
subject: str,
|
||||
body: str,
|
||||
html_body: Optional[str] = None,
|
||||
) -> None:
|
||||
message = EmailMessage()
|
||||
message["Subject"] = subject
|
||||
message["From"] = formataddr(
|
||||
(
|
||||
self.settings.SMTP_FROM_NAME or self.settings.WEBAPP_TITLE,
|
||||
self.settings.SMTP_FROM_EMAIL or "",
|
||||
)
|
||||
)
|
||||
message["To"] = email
|
||||
message.set_content(body)
|
||||
if html_body:
|
||||
message.add_alternative(html_body, subtype="html")
|
||||
|
||||
context = ssl.create_default_context()
|
||||
smtp_host = self.settings.SMTP_HOST
|
||||
timeout = max(5, int(self.settings.SMTP_TIMEOUT_SECONDS))
|
||||
attempts = self._smtp_attempts()
|
||||
last_error: Optional[BaseException] = None
|
||||
|
||||
for attempt_number, attempt in enumerate(attempts, start=1):
|
||||
try:
|
||||
self._send_message_via_smtp(
|
||||
message=message,
|
||||
smtp_host=smtp_host,
|
||||
smtp_port=attempt.port,
|
||||
timeout=timeout,
|
||||
context=context,
|
||||
use_ssl=attempt.use_ssl,
|
||||
starttls=attempt.starttls,
|
||||
)
|
||||
logger.info(
|
||||
"Custom email sent to %s via %s:%s",
|
||||
email,
|
||||
smtp_host,
|
||||
attempt.port,
|
||||
)
|
||||
return
|
||||
except (OSError, smtplib.SMTPException, TimeoutError) as exc:
|
||||
last_error = exc
|
||||
log_level = logging.WARNING if attempt_number < len(attempts) else logging.ERROR
|
||||
logger.log(
|
||||
log_level,
|
||||
"SMTP send attempt %s/%s failed for custom email via %s:%s (ssl=%s, starttls=%s): %s",
|
||||
attempt_number,
|
||||
len(attempts),
|
||||
smtp_host,
|
||||
attempt.port,
|
||||
attempt.use_ssl,
|
||||
attempt.starttls,
|
||||
exc,
|
||||
)
|
||||
|
||||
if last_error:
|
||||
raise last_error
|
||||
|
||||
def _send_message_via_smtp(
|
||||
self,
|
||||
*,
|
||||
message: EmailMessage,
|
||||
smtp_host: str,
|
||||
smtp_port: int,
|
||||
timeout: int,
|
||||
context: ssl.SSLContext,
|
||||
use_ssl: bool,
|
||||
starttls: bool,
|
||||
) -> None:
|
||||
if use_ssl:
|
||||
with smtplib.SMTP_SSL(
|
||||
smtp_host,
|
||||
smtp_port,
|
||||
context=context,
|
||||
timeout=timeout,
|
||||
) as smtp:
|
||||
smtp.ehlo()
|
||||
smtp.login(self.settings.SMTP_USERNAME, self.settings.SMTP_PASSWORD)
|
||||
smtp.send_message(message)
|
||||
return
|
||||
|
||||
with smtplib.SMTP(smtp_host, smtp_port, timeout=timeout) as smtp:
|
||||
smtp.ehlo()
|
||||
if starttls:
|
||||
smtp.starttls(context=context)
|
||||
smtp.ehlo()
|
||||
smtp.login(self.settings.SMTP_USERNAME, self.settings.SMTP_PASSWORD)
|
||||
smtp.send_message(message)
|
||||
@@ -0,0 +1,484 @@
|
||||
"""Branded HTML email templates that mirror the subscription Mini App look.
|
||||
|
||||
The web app uses a dark theme with a configurable accent colour
|
||||
(`WEBAPP_PRIMARY_COLOR`) and an optional logo (`WEBAPP_LOGO_URL`). The same
|
||||
accent + logo are reused here so emails feel like part of the product. All
|
||||
copy goes through the shared `JsonI18n` instance so translations live in
|
||||
``locales/<lang>.json`` next to the rest of the bot strings.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Sequence, Tuple
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from bot.middlewares.i18n import JsonI18n, get_i18n_instance
|
||||
from config.settings import Settings
|
||||
|
||||
|
||||
_BG = "#05070a"
|
||||
_CARD_BG = "#0e1116"
|
||||
_BORDER = "#1a1f27"
|
||||
_TEXT = "#e6e9ef"
|
||||
_TEXT_MUTED = "#9aa3b2"
|
||||
_TEXT_DIM = "#5d6573"
|
||||
_DEFAULT_ACCENT = "#00fe7a"
|
||||
_HEX_RE = re.compile(r"^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EmailContent:
|
||||
subject: str
|
||||
text: str
|
||||
html: str
|
||||
|
||||
|
||||
def _safe_color(value: Optional[str]) -> str:
|
||||
if not value:
|
||||
return _DEFAULT_ACCENT
|
||||
candidate = value.strip()
|
||||
if _HEX_RE.match(candidate):
|
||||
return candidate
|
||||
return _DEFAULT_ACCENT
|
||||
|
||||
|
||||
def _public_logo_url(settings: Settings) -> Optional[str]:
|
||||
"""Email recipients can't reach the in-app /webapp-logo proxy, so the
|
||||
raw https URL from the env is used directly. Anything else is dropped."""
|
||||
raw = (settings.WEBAPP_LOGO_URL or "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
parsed = urlsplit(raw)
|
||||
if parsed.scheme != "https" or not parsed.hostname:
|
||||
return None
|
||||
return raw
|
||||
|
||||
|
||||
def _brand_title(settings: Settings) -> str:
|
||||
title = (settings.WEBAPP_TITLE or "").strip()
|
||||
return title or "Subscription"
|
||||
|
||||
|
||||
def _normalize_lang(language_code: Optional[str], settings: Settings) -> str:
|
||||
return (language_code or settings.DEFAULT_LANGUAGE or "ru").split("-")[0]
|
||||
|
||||
|
||||
def _resolve_i18n(i18n: Optional[JsonI18n]) -> JsonI18n:
|
||||
return i18n or get_i18n_instance()
|
||||
|
||||
|
||||
def _t_html(i18n: JsonI18n, lang: str, key: str, **kwargs) -> str:
|
||||
"""Translate for HTML context: format args are HTML-escaped, the
|
||||
translated template itself is treated as already-safe HTML (locale files
|
||||
are author-controlled and may include simple inline tags like <strong>)."""
|
||||
safe_kwargs = {k: html.escape(str(v)) for k, v in kwargs.items()}
|
||||
return i18n.gettext(lang, key, **safe_kwargs)
|
||||
|
||||
|
||||
def _t_text(i18n: JsonI18n, lang: str, key: str, **kwargs) -> str:
|
||||
return i18n.gettext(lang, key, **kwargs)
|
||||
|
||||
|
||||
def _layout(
|
||||
*,
|
||||
settings: Settings,
|
||||
preheader: str,
|
||||
heading: str,
|
||||
intro_html: str,
|
||||
body_html: str,
|
||||
footer_html: str,
|
||||
) -> str:
|
||||
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
|
||||
brand_title = html.escape(_brand_title(settings))
|
||||
logo_url = _public_logo_url(settings)
|
||||
logo_block = ""
|
||||
if logo_url:
|
||||
logo_block = (
|
||||
f'<img src="{html.escape(logo_url, quote=True)}" width="64" height="64" '
|
||||
f'alt="" style="display:block;border:0;outline:none;text-decoration:none;'
|
||||
f'border-radius:16px;background:{_CARD_BG};">'
|
||||
)
|
||||
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<meta name="supported-color-schemes" content="dark">
|
||||
<title>{html.escape(heading)}</title>
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background:{_BG};font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:{_TEXT};">
|
||||
<div style="display:none;max-height:0;overflow:hidden;opacity:0;color:transparent;mso-hide:all;">{html.escape(preheader)}</div>
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:{_BG};">
|
||||
<tr>
|
||||
<td align="center" style="padding:32px 16px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="max-width:480px;">
|
||||
<tr>
|
||||
<td align="center" style="padding-bottom:24px;">
|
||||
{logo_block}
|
||||
<div style="margin-top:14px;font-family:'JetBrains Mono','SFMono-Regular',Menlo,Consolas,monospace;font-weight:800;font-size:22px;line-height:1.05;color:{accent};letter-spacing:0;">{brand_title}</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="background:{_CARD_BG};border:1px solid {_BORDER};border-radius:18px;padding:28px;">
|
||||
<h1 style="margin:0 0 10px 0;font-size:20px;line-height:1.25;font-weight:700;color:#ffffff;">{html.escape(heading)}</h1>
|
||||
<div style="margin:0 0 20px 0;font-size:14px;line-height:1.55;color:{_TEXT_MUTED};">{intro_html}</div>
|
||||
{body_html}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="padding-top:20px;">
|
||||
<div style="font-size:11px;line-height:1.55;color:{_TEXT_DIM};">{footer_html}</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
def _info_rows_html(rows: Sequence[Tuple[str, str]]) -> str:
|
||||
if not rows:
|
||||
return ""
|
||||
last = len(rows) - 1
|
||||
cells = []
|
||||
for index, (label, value) in enumerate(rows):
|
||||
border = "" if index == last else f"border-bottom:1px solid {_BORDER};"
|
||||
cells.append(
|
||||
f'<tr>'
|
||||
f'<td style="padding:11px 0;{border}font-size:12px;color:{_TEXT_DIM};text-transform:uppercase;letter-spacing:0.04em;">{html.escape(label)}</td>'
|
||||
f'<td align="right" style="padding:11px 0;{border}font-family:\'JetBrains Mono\',\'SFMono-Regular\',Menlo,Consolas,monospace;font-size:14px;font-weight:600;color:{_TEXT};">{html.escape(value)}</td>'
|
||||
f'</tr>'
|
||||
)
|
||||
return (
|
||||
f'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" '
|
||||
f'style="margin:0 0 16px 0;background:{_BG};border:1px solid {_BORDER};border-radius:14px;padding:6px 16px;">'
|
||||
+ "".join(cells)
|
||||
+ "</table>"
|
||||
)
|
||||
|
||||
|
||||
def _cta_button_html(*, label: str, url: str, accent: str) -> str:
|
||||
safe_label = html.escape(label)
|
||||
safe_url = html.escape(url, quote=True)
|
||||
# Accent green is light, so contrast text is dark; works for the default and similar light accents.
|
||||
return (
|
||||
f'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" '
|
||||
f'style="width:100%;margin:22px 0 18px 0;">'
|
||||
f'<tr><td align="center" bgcolor="{accent}" style="background:{accent};border-radius:12px;">'
|
||||
f'<a href="{safe_url}" target="_blank" rel="noopener" '
|
||||
f'style="display:block;width:100%;box-sizing:border-box;padding:15px 22px;'
|
||||
f'font-family:-apple-system,BlinkMacSystemFont,\'Segoe UI\',Roboto,Helvetica,Arial,sans-serif;'
|
||||
f'font-size:15px;font-weight:700;color:#05070a;text-decoration:none;letter-spacing:0.02em;text-align:center;">{safe_label}</a>'
|
||||
f'</td></tr></table>'
|
||||
)
|
||||
|
||||
|
||||
def _format_amount(amount: float, currency: str) -> str:
|
||||
rounded = round(float(amount), 2)
|
||||
if rounded.is_integer():
|
||||
body = f"{int(rounded)}"
|
||||
else:
|
||||
body = f"{rounded:.2f}"
|
||||
suffix = (currency or "").strip()
|
||||
return f"{body} {suffix}".strip()
|
||||
|
||||
|
||||
def _format_traffic(traffic_gb: Optional[float]) -> str:
|
||||
if traffic_gb is None:
|
||||
return "—"
|
||||
value = float(traffic_gb)
|
||||
return str(int(value)) if value.is_integer() else f"{value:g}"
|
||||
|
||||
|
||||
def _format_minutes(seconds: int) -> int:
|
||||
return max(1, int(seconds) // 60)
|
||||
|
||||
|
||||
def render_login_code(
|
||||
settings: Settings,
|
||||
*,
|
||||
code: str,
|
||||
language_code: Optional[str],
|
||||
magic_link: Optional[str] = None,
|
||||
i18n: Optional[JsonI18n] = None,
|
||||
) -> EmailContent:
|
||||
i18n = _resolve_i18n(i18n)
|
||||
lang = _normalize_lang(language_code, settings)
|
||||
minutes = _format_minutes(settings.EMAIL_CODE_TTL_SECONDS)
|
||||
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
|
||||
brand = _brand_title(settings)
|
||||
safe_magic_link = (magic_link or "").strip()
|
||||
|
||||
subject = _t_text(i18n, lang, "email_login_code_subject", code=code)
|
||||
preheader = _t_text(i18n, lang, "email_login_code_preheader", minutes=minutes)
|
||||
heading = _t_text(i18n, lang, "email_login_code_heading")
|
||||
intro = _t_text(i18n, lang, "email_login_code_intro")
|
||||
expiry_html = _t_html(i18n, lang, "email_login_code_expiry_html", minutes=minutes)
|
||||
security = _t_text(i18n, lang, "email_login_code_security")
|
||||
footer = _t_html(i18n, lang, "email_footer_auto", brand=brand)
|
||||
text_lines = [_t_text(i18n, lang, "email_login_code_text", code=code, minutes=minutes)]
|
||||
if safe_magic_link:
|
||||
text_lines.append(
|
||||
_t_text(i18n, lang, "email_login_code_text_magic", url=safe_magic_link)
|
||||
)
|
||||
|
||||
code_block = (
|
||||
f'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:0 0 18px 0;">'
|
||||
f'<tr><td align="center" style="background:{_BG};border:1px solid {_BORDER};border-radius:14px;padding:22px 16px;">'
|
||||
f'<div style="font-family:\'JetBrains Mono\',\'SFMono-Regular\',Menlo,Consolas,monospace;font-size:36px;line-height:1;font-weight:700;letter-spacing:10px;color:{accent};">'
|
||||
f'{html.escape(code)}'
|
||||
f'</div></td></tr></table>'
|
||||
)
|
||||
|
||||
magic_block = ""
|
||||
if safe_magic_link:
|
||||
cta_label = _t_text(i18n, lang, "email_login_code_magic_cta")
|
||||
divider_label = _t_text(i18n, lang, "email_login_code_magic_or")
|
||||
magic_intro = _t_text(i18n, lang, "email_login_code_magic_intro")
|
||||
magic_hint = _t_text(i18n, lang, "email_login_code_magic_hint")
|
||||
divider_html = (
|
||||
f'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:4px 0 14px 0;">'
|
||||
f'<tr>'
|
||||
f'<td width="40%" style="border-bottom:1px solid {_BORDER};font-size:0;line-height:0;"> </td>'
|
||||
f'<td align="center" style="padding:0 10px;font-size:11px;letter-spacing:0.08em;text-transform:uppercase;color:{_TEXT_DIM};white-space:nowrap;">{html.escape(divider_label)}</td>'
|
||||
f'<td width="40%" style="border-bottom:1px solid {_BORDER};font-size:0;line-height:0;"> </td>'
|
||||
f'</tr></table>'
|
||||
)
|
||||
magic_block = (
|
||||
divider_html
|
||||
+ f'<p style="margin:0 0 4px 0;font-size:13px;line-height:1.55;color:{_TEXT_MUTED};text-align:center;">{html.escape(magic_intro)}</p>'
|
||||
+ _cta_button_html(label=cta_label, url=safe_magic_link, accent=accent)
|
||||
+ f'<p style="margin:0 0 6px 0;font-size:12px;line-height:1.55;color:{_TEXT_DIM};text-align:center;">{html.escape(magic_hint)}</p>'
|
||||
)
|
||||
|
||||
body_html = (
|
||||
code_block
|
||||
+ f'<p style="margin:0 0 8px 0;font-size:13px;line-height:1.55;color:{_TEXT_MUTED};">{expiry_html}</p>'
|
||||
+ f'<p style="margin:0 0 4px 0;font-size:12px;line-height:1.55;color:{_TEXT_DIM};">{html.escape(security)}</p>'
|
||||
+ magic_block
|
||||
)
|
||||
|
||||
rendered = _layout(
|
||||
settings=settings,
|
||||
preheader=preheader,
|
||||
heading=heading,
|
||||
intro_html=html.escape(intro),
|
||||
body_html=body_html,
|
||||
footer_html=footer,
|
||||
)
|
||||
return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered)
|
||||
|
||||
|
||||
def render_account_merged(
|
||||
settings: Settings,
|
||||
*,
|
||||
language_code: Optional[str],
|
||||
primary_user_id: Optional[int],
|
||||
removed_user_id: Optional[int],
|
||||
final_end_date_text: str,
|
||||
i18n: Optional[JsonI18n] = None,
|
||||
) -> EmailContent:
|
||||
i18n = _resolve_i18n(i18n)
|
||||
lang = _normalize_lang(language_code, settings)
|
||||
brand = _brand_title(settings)
|
||||
primary = "—" if primary_user_id is None else f"#{primary_user_id}"
|
||||
removed = "—" if removed_user_id is None else f"#{removed_user_id}"
|
||||
end_date = final_end_date_text or "—"
|
||||
|
||||
subject = _t_text(i18n, lang, "email_account_merged_subject")
|
||||
preheader = _t_text(i18n, lang, "email_account_merged_preheader")
|
||||
heading = _t_text(i18n, lang, "email_account_merged_heading")
|
||||
intro = _t_text(i18n, lang, "email_account_merged_intro")
|
||||
note = _t_text(i18n, lang, "email_account_merged_note")
|
||||
footer = _t_html(i18n, lang, "email_footer_auto", brand=brand)
|
||||
text = _t_text(
|
||||
i18n,
|
||||
lang,
|
||||
"email_account_merged_text",
|
||||
primary=primary,
|
||||
removed=removed,
|
||||
end_date=end_date,
|
||||
)
|
||||
|
||||
rows = [
|
||||
(_t_text(i18n, lang, "email_account_merged_row_kept"), primary),
|
||||
(_t_text(i18n, lang, "email_account_merged_row_removed"), removed),
|
||||
(_t_text(i18n, lang, "email_account_merged_row_end_date"), end_date),
|
||||
]
|
||||
body_html = (
|
||||
_info_rows_html(rows)
|
||||
+ f'<p style="margin:0;font-size:12px;line-height:1.55;color:{_TEXT_DIM};">{html.escape(note)}</p>'
|
||||
)
|
||||
|
||||
rendered = _layout(
|
||||
settings=settings,
|
||||
preheader=preheader,
|
||||
heading=heading,
|
||||
intro_html=html.escape(intro),
|
||||
body_html=body_html,
|
||||
footer_html=footer,
|
||||
)
|
||||
return EmailContent(subject=subject, text=text, html=rendered)
|
||||
|
||||
|
||||
def render_payment_success(
|
||||
settings: Settings,
|
||||
*,
|
||||
language_code: Optional[str],
|
||||
sale_mode: str,
|
||||
months: int,
|
||||
traffic_gb: Optional[float],
|
||||
amount: float,
|
||||
currency: str,
|
||||
end_date_text: str,
|
||||
dashboard_url: Optional[str],
|
||||
provider_label: Optional[str] = None,
|
||||
i18n: Optional[JsonI18n] = None,
|
||||
) -> EmailContent:
|
||||
i18n = _resolve_i18n(i18n)
|
||||
lang = _normalize_lang(language_code, settings)
|
||||
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
|
||||
brand = _brand_title(settings)
|
||||
is_traffic = sale_mode == "traffic"
|
||||
amount_text = _format_amount(amount, currency)
|
||||
safe_dashboard_url = (dashboard_url or "").strip()
|
||||
end_date = end_date_text or "—"
|
||||
traffic_label = _format_traffic(traffic_gb)
|
||||
|
||||
subject = _t_text(i18n, lang, "email_payment_success_subject")
|
||||
preheader = _t_text(i18n, lang, "email_payment_success_preheader")
|
||||
heading = _t_text(i18n, lang, "email_payment_success_heading")
|
||||
footer_note = _t_text(i18n, lang, "email_payment_success_footer_note")
|
||||
footer = _t_html(i18n, lang, "email_footer_auto", brand=brand)
|
||||
cta_label = _t_text(i18n, lang, "email_payment_success_cta")
|
||||
|
||||
if is_traffic:
|
||||
intro = _t_text(i18n, lang, "email_payment_success_intro_traffic", traffic_gb=traffic_label)
|
||||
period_label = _t_text(i18n, lang, "email_payment_success_row_traffic")
|
||||
period_value = _t_text(i18n, lang, "email_payment_success_traffic_value", traffic_gb=traffic_label)
|
||||
text = _t_text(
|
||||
i18n,
|
||||
lang,
|
||||
"email_payment_success_text_traffic",
|
||||
amount=amount_text,
|
||||
traffic_gb=traffic_label,
|
||||
end_date=end_date,
|
||||
)
|
||||
else:
|
||||
months_int = int(months or 0)
|
||||
intro = _t_text(i18n, lang, "email_payment_success_intro_subscription", months=months_int)
|
||||
period_label = _t_text(i18n, lang, "email_payment_success_row_period")
|
||||
period_value = _t_text(
|
||||
i18n,
|
||||
lang,
|
||||
"email_payment_success_period_value",
|
||||
months=months_int,
|
||||
)
|
||||
text = _t_text(
|
||||
i18n,
|
||||
lang,
|
||||
"email_payment_success_text_subscription",
|
||||
amount=amount_text,
|
||||
months=months_int,
|
||||
end_date=end_date,
|
||||
)
|
||||
|
||||
rows: list[Tuple[str, str]] = [
|
||||
(period_label, period_value),
|
||||
(_t_text(i18n, lang, "email_payment_success_row_amount"), amount_text),
|
||||
(_t_text(i18n, lang, "email_payment_success_row_end_date"), end_date),
|
||||
]
|
||||
if provider_label:
|
||||
rows.append((_t_text(i18n, lang, "email_payment_success_row_method"), provider_label))
|
||||
|
||||
text_lines = [text]
|
||||
if safe_dashboard_url:
|
||||
text_lines.append(_t_text(i18n, lang, "email_payment_success_text_dashboard", url=safe_dashboard_url))
|
||||
|
||||
body_parts = [_info_rows_html(rows)]
|
||||
if safe_dashboard_url:
|
||||
body_parts.append(_cta_button_html(label=cta_label, url=safe_dashboard_url, accent=accent))
|
||||
body_parts.append(
|
||||
f'<p style="margin:6px 0 0 0;font-size:12px;line-height:1.55;color:{_TEXT_DIM};">{html.escape(footer_note)}</p>'
|
||||
)
|
||||
|
||||
rendered = _layout(
|
||||
settings=settings,
|
||||
preheader=preheader,
|
||||
heading=heading,
|
||||
intro_html=html.escape(intro),
|
||||
body_html="".join(body_parts),
|
||||
footer_html=footer,
|
||||
)
|
||||
return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered)
|
||||
|
||||
|
||||
def render_subscription_expiring(
|
||||
settings: Settings,
|
||||
*,
|
||||
language_code: Optional[str],
|
||||
days_left: int,
|
||||
end_date_text: str,
|
||||
dashboard_url: Optional[str],
|
||||
i18n: Optional[JsonI18n] = None,
|
||||
) -> EmailContent:
|
||||
i18n = _resolve_i18n(i18n)
|
||||
lang = _normalize_lang(language_code, settings)
|
||||
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
|
||||
brand = _brand_title(settings)
|
||||
safe_dashboard_url = (dashboard_url or "").strip()
|
||||
days = max(0, int(days_left))
|
||||
end_date = end_date_text or "—"
|
||||
|
||||
if days == 0:
|
||||
suffix = "today"
|
||||
elif days == 1:
|
||||
suffix = "tomorrow"
|
||||
else:
|
||||
suffix = "days"
|
||||
|
||||
subject = _t_text(i18n, lang, f"email_subscription_expiring_subject_{suffix}", days=days)
|
||||
heading = _t_text(i18n, lang, f"email_subscription_expiring_heading_{suffix}", days=days)
|
||||
preheader = _t_text(i18n, lang, f"email_subscription_expiring_preheader_{suffix}", days=days)
|
||||
intro = _t_text(i18n, lang, f"email_subscription_expiring_intro_{suffix}", days=days)
|
||||
note = _t_text(i18n, lang, "email_subscription_expiring_note")
|
||||
footer = _t_html(i18n, lang, "email_footer_auto", brand=brand)
|
||||
cta_label = _t_text(i18n, lang, "email_subscription_expiring_cta")
|
||||
|
||||
rows = [
|
||||
(_t_text(i18n, lang, "email_subscription_expiring_row_days_left"), str(days)),
|
||||
(_t_text(i18n, lang, "email_subscription_expiring_row_end_date"), end_date),
|
||||
]
|
||||
|
||||
text_lines = [
|
||||
_t_text(i18n, lang, "email_subscription_expiring_text", heading=heading, end_date=end_date),
|
||||
]
|
||||
if safe_dashboard_url:
|
||||
text_lines.append(
|
||||
_t_text(i18n, lang, "email_subscription_expiring_text_renew", url=safe_dashboard_url)
|
||||
)
|
||||
|
||||
body_parts = [_info_rows_html(rows)]
|
||||
if safe_dashboard_url:
|
||||
body_parts.append(_cta_button_html(label=cta_label, url=safe_dashboard_url, accent=accent))
|
||||
body_parts.append(
|
||||
f'<p style="margin:6px 0 0 0;font-size:12px;line-height:1.55;color:{_TEXT_DIM};">{html.escape(note)}</p>'
|
||||
)
|
||||
|
||||
rendered = _layout(
|
||||
settings=settings,
|
||||
preheader=preheader,
|
||||
heading=heading,
|
||||
intro_html=html.escape(intro),
|
||||
body_html="".join(body_parts),
|
||||
footer_html=footer,
|
||||
)
|
||||
return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered)
|
||||
@@ -7,6 +7,7 @@ import logging
|
||||
import time
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
from typing import Optional, Dict, Any, Tuple
|
||||
from urllib.parse import parse_qsl
|
||||
|
||||
from aiohttp import ClientSession, ClientTimeout, web
|
||||
from aiogram import Bot
|
||||
@@ -20,6 +21,8 @@ from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
|
||||
from bot.services.notification_service import NotificationService
|
||||
from db.dal import payment_dal, user_dal
|
||||
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
|
||||
from bot.utils.config_link import prepare_config_links
|
||||
from bot.utils.request_security import ip_in_allowlist, request_client_ip
|
||||
|
||||
|
||||
class FreeKassaService:
|
||||
@@ -45,6 +48,7 @@ class FreeKassaService:
|
||||
self.second_secret: Optional[str] = settings.FREEKASSA_SECOND_SECRET
|
||||
self.default_currency: str = (settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
|
||||
self.server_ip: Optional[str] = settings.FREEKASSA_PAYMENT_IP
|
||||
self.payment_method_id: Optional[int] = settings.FREEKASSA_PAYMENT_METHOD_ID
|
||||
|
||||
self.api_base_url: str = "https://api.fk.life/v1"
|
||||
self._timeout = ClientTimeout(total=15)
|
||||
@@ -72,9 +76,9 @@ class FreeKassaService:
|
||||
months: int,
|
||||
amount: float,
|
||||
currency: Optional[str],
|
||||
method_code: int,
|
||||
email: Optional[str] = None,
|
||||
ip_address: Optional[str] = None,
|
||||
payment_method_id: Optional[int] = None,
|
||||
extra_params: Optional[Dict[str, Any]] = None,
|
||||
) -> Tuple[bool, Dict[str, Any]]:
|
||||
if not self.configured:
|
||||
@@ -94,7 +98,7 @@ class FreeKassaService:
|
||||
"shopId": int(self.shop_id),
|
||||
"nonce": await self._generate_nonce(),
|
||||
"paymentId": str(payment_db_id),
|
||||
"i": int(method_code),
|
||||
"i": int(payment_method_id),
|
||||
"amount": amount_str,
|
||||
"currency": currency_code,
|
||||
"email": email,
|
||||
@@ -133,7 +137,7 @@ class FreeKassaService:
|
||||
|
||||
return True, response_data
|
||||
except Exception as exc:
|
||||
logging.error("FreeKassa create_order: request failed: %s", exc, exc_info=True)
|
||||
logging.exception("FreeKassa create_order: request failed.")
|
||||
return False, {"message": str(exc)}
|
||||
|
||||
async def _get_session(self) -> ClientSession:
|
||||
@@ -167,69 +171,59 @@ class FreeKassaService:
|
||||
|
||||
def _validate_signature(
|
||||
self,
|
||||
merchant_order_id: str,
|
||||
amount: str,
|
||||
raw_body: bytes,
|
||||
provided_signature: str,
|
||||
payload: Optional[Dict[str, Any]] = None,
|
||||
) -> bool:
|
||||
if not provided_signature:
|
||||
return False
|
||||
if not self.second_secret:
|
||||
return False
|
||||
|
||||
if self.shop_id and self.second_secret:
|
||||
signature_source = f"{self.shop_id}:{amount}:{self.second_secret}:{merchant_order_id}"
|
||||
expected_signature = hashlib.md5(signature_source.encode("utf-8")).hexdigest()
|
||||
if expected_signature.lower() == provided_signature.lower():
|
||||
return True
|
||||
|
||||
if self.api_key and payload:
|
||||
items = [
|
||||
(key, value)
|
||||
for key, value in payload.items()
|
||||
if key not in {"signature", "SIGN"} and value is not None
|
||||
]
|
||||
items.sort(key=lambda pair: pair[0])
|
||||
message = "|".join(str(value) for _, value in items)
|
||||
alt_signature = hmac.new(self.api_key.encode("utf-8"), message.encode("utf-8"), hashlib.sha256).hexdigest()
|
||||
if alt_signature.lower() == provided_signature.lower():
|
||||
return True
|
||||
|
||||
return False
|
||||
expected_signature = hmac.new(
|
||||
self.second_secret.encode("utf-8"),
|
||||
raw_body,
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
return hmac.compare_digest(expected_signature, provided_signature)
|
||||
|
||||
async def webhook_route(self, request: web.Request) -> web.Response:
|
||||
if not self.configured:
|
||||
return web.Response(status=503, text="freekassa_disabled")
|
||||
|
||||
try:
|
||||
data = await request.post()
|
||||
except Exception as e:
|
||||
logging.error(f"FreeKassa webhook: failed to read POST data: {e}")
|
||||
client_ip = request_client_ip(request, trusted_proxies=self.settings.trusted_proxies)
|
||||
if not ip_in_allowlist(client_ip, self.settings.freekassa_trusted_ips):
|
||||
return web.Response(status=403)
|
||||
|
||||
raw_body = await request.read()
|
||||
except Exception:
|
||||
logging.exception("FreeKassa webhook: failed to read request body.")
|
||||
return web.Response(status=400, text="bad_request")
|
||||
|
||||
payload_dict: Dict[str, Any]
|
||||
if data:
|
||||
payload_dict = {str(k): v for k, v in data.items()}
|
||||
else:
|
||||
payload_dict: Dict[str, Any] = {}
|
||||
if raw_body:
|
||||
try:
|
||||
json_payload = await request.json()
|
||||
payload_dict = {str(k): v for k, v in json_payload.items()} if isinstance(json_payload, dict) else {}
|
||||
data = json_payload
|
||||
if request.content_type.startswith("application/json"):
|
||||
decoded_json = json.loads(raw_body.decode("utf-8"))
|
||||
if isinstance(decoded_json, dict):
|
||||
payload_dict = {str(k): v for k, v in decoded_json.items()}
|
||||
else:
|
||||
payload_dict = {
|
||||
str(key): value
|
||||
for key, value in parse_qsl(raw_body.decode("utf-8"), keep_blank_values=True)
|
||||
}
|
||||
except Exception:
|
||||
payload_dict = {}
|
||||
data = {}
|
||||
|
||||
def _get(key: str, default: Optional[str] = None) -> Optional[str]:
|
||||
if isinstance(data, dict):
|
||||
return data.get(key) or data.get(key.lower()) or default
|
||||
return payload_dict.get(key) or payload_dict.get(key.lower()) or default
|
||||
|
||||
merchant_id = _get("MERCHANT_ID")
|
||||
if merchant_id != self.shop_id:
|
||||
logging.error(f"FreeKassa webhook: merchant mismatch (got {merchant_id})")
|
||||
return web.Response(status=403, text="merchant_mismatch")
|
||||
return web.Response(status=403)
|
||||
|
||||
signature = _get("SIGN") or _get("signature")
|
||||
if not signature:
|
||||
logging.error("FreeKassa webhook: missing signature")
|
||||
return web.Response(status=400, text="missing_signature")
|
||||
|
||||
order_id_str = _get("MERCHANT_ORDER_ID") or _get("ORDER_ID") or _get("o")
|
||||
@@ -237,11 +231,9 @@ class FreeKassaService:
|
||||
provider_payment_id = _get("intid") or _get("payment_id") or _get("transaction_id")
|
||||
|
||||
if not order_id_str or not amount_str:
|
||||
logging.error("FreeKassa webhook: missing order_id or amount")
|
||||
return web.Response(status=400, text="missing_data")
|
||||
|
||||
if not self._validate_signature(order_id_str, amount_str, signature, payload_dict):
|
||||
logging.error("FreeKassa webhook: invalid signature")
|
||||
if not self._validate_signature(raw_body, signature):
|
||||
return web.Response(status=403, text="invalid_signature")
|
||||
|
||||
try:
|
||||
@@ -283,40 +275,45 @@ class FreeKassaService:
|
||||
)
|
||||
|
||||
months = payment.subscription_duration_months or 1
|
||||
sale_mode = "traffic" if self.settings.traffic_sale_mode else "subscription"
|
||||
|
||||
activation = await self.subscription_service.activate_subscription(
|
||||
session,
|
||||
payment.user_id,
|
||||
months,
|
||||
int(months) if sale_mode != "traffic" else 0,
|
||||
float(payment.amount),
|
||||
payment.payment_id,
|
||||
provider="freekassa",
|
||||
sale_mode=sale_mode,
|
||||
traffic_gb=months if sale_mode == "traffic" else None,
|
||||
)
|
||||
|
||||
referral_bonus = await self.referral_service.apply_referral_bonuses_for_payment(
|
||||
session,
|
||||
payment.user_id,
|
||||
months,
|
||||
current_payment_db_id=payment.payment_id,
|
||||
skip_if_active_before_payment=False,
|
||||
)
|
||||
referral_bonus = None
|
||||
if sale_mode != "traffic":
|
||||
referral_bonus = await self.referral_service.apply_referral_bonuses_for_payment(
|
||||
session,
|
||||
payment.user_id,
|
||||
int(months),
|
||||
current_payment_db_id=payment.payment_id,
|
||||
skip_if_active_before_payment=False,
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logging.error(f"FreeKassa webhook: failed to process payment {payment_db_id}: {e}", exc_info=True)
|
||||
logging.exception("FreeKassa webhook: failed to process payment %s.", payment_db_id)
|
||||
return web.Response(status=500, text="processing_error")
|
||||
|
||||
db_user = payment.user or await user_dal.get_user_by_id(session, payment.user_id)
|
||||
lang = db_user.language_code if db_user and db_user.language_code else self.settings.DEFAULT_LANGUAGE
|
||||
_ = lambda k, **kw: self.i18n.gettext(lang, k, **kw) if self.i18n else k
|
||||
|
||||
config_link = None
|
||||
final_end = None
|
||||
raw_config_link = activation.get("subscription_url") if activation else None
|
||||
config_link_display, connect_button_url = await prepare_config_links(self.settings, raw_config_link)
|
||||
config_link_text = config_link_display or _("config_link_not_available")
|
||||
final_end = activation.get("end_date") if activation else None
|
||||
months = payment.subscription_duration_months or 1
|
||||
if activation:
|
||||
config_link = activation.get("subscription_url")
|
||||
final_end = activation.get("end_date")
|
||||
sale_mode = "traffic" if self.settings.traffic_sale_mode else "subscription"
|
||||
|
||||
applied_days = 0
|
||||
if referral_bonus and referral_bonus.get("referee_new_end_date"):
|
||||
@@ -326,14 +323,19 @@ class FreeKassaService:
|
||||
if not final_end and activation and activation.get("end_date"):
|
||||
final_end = activation["end_date"]
|
||||
|
||||
if not config_link:
|
||||
config_link = _("config_link_not_available")
|
||||
if final_end:
|
||||
end_date_str = final_end.strftime("%Y-%m-%d")
|
||||
else:
|
||||
end_date_str = _("config_link_not_available")
|
||||
|
||||
if applied_days:
|
||||
traffic_label = str(int(months)) if float(months).is_integer() else f"{months:g}"
|
||||
|
||||
if sale_mode == "traffic":
|
||||
text = _("payment_successful_traffic_full",
|
||||
traffic_gb=traffic_label,
|
||||
end_date=end_date_str if final_end else "",
|
||||
config_link=config_link_text)
|
||||
elif applied_days:
|
||||
inviter_name_display = _("friend_placeholder")
|
||||
if db_user and db_user.referred_by_id:
|
||||
inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id)
|
||||
@@ -350,14 +352,14 @@ class FreeKassaService:
|
||||
bonus_days=applied_days,
|
||||
final_end_date=end_date_str,
|
||||
inviter_name=inviter_name_display,
|
||||
config_link=config_link,
|
||||
config_link=config_link_text,
|
||||
)
|
||||
else:
|
||||
text = _(
|
||||
"payment_successful_full",
|
||||
months=months,
|
||||
end_date=end_date_str,
|
||||
config_link=config_link,
|
||||
config_link=config_link_text,
|
||||
)
|
||||
if provider_payment_id:
|
||||
order_info_text = _(
|
||||
@@ -371,7 +373,8 @@ class FreeKassaService:
|
||||
lang,
|
||||
self.i18n,
|
||||
self.settings,
|
||||
config_link,
|
||||
config_link_display,
|
||||
connect_button_url=connect_button_url,
|
||||
preserve_message=True,
|
||||
)
|
||||
try:
|
||||
@@ -382,8 +385,8 @@ class FreeKassaService:
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"FreeKassa notification: failed to send message to user {payment.user_id}: {e}")
|
||||
except Exception:
|
||||
logging.exception("FreeKassa notification: failed to send message to user %s.", payment.user_id)
|
||||
|
||||
try:
|
||||
notification_service = NotificationService(self.bot, self.settings, self.i18n)
|
||||
@@ -391,12 +394,13 @@ class FreeKassaService:
|
||||
user_id=payment.user_id,
|
||||
amount=float(payment.amount),
|
||||
currency=self.default_currency,
|
||||
months=months,
|
||||
months=int(months) if sale_mode != "traffic" else 0,
|
||||
traffic_gb=months if sale_mode == "traffic" else None,
|
||||
payment_provider="freekassa",
|
||||
username=db_user.username if db_user else None,
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"FreeKassa notification: failed to notify admins: {e}")
|
||||
except Exception:
|
||||
logging.exception("FreeKassa notification: failed to notify admins.")
|
||||
|
||||
return web.Response(text="YES")
|
||||
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
"""
|
||||
LKNPD API client for self-employed (NPD) tax receipts.
|
||||
Custom implementation for lknpd.nalog.ru API.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from decimal import Decimal
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PaymentType(str, Enum):
|
||||
"""Payment type for income registration."""
|
||||
CASH = "CASH"
|
||||
WIRE = "WIRE"
|
||||
|
||||
|
||||
class IncomeType(str, Enum):
|
||||
"""Income source type."""
|
||||
FROM_INDIVIDUAL = "FROM_INDIVIDUAL"
|
||||
FROM_LEGAL_ENTITY = "FROM_LEGAL_ENTITY"
|
||||
FROM_FOREIGN_AGENCY = "FROM_FOREIGN_AGENCY"
|
||||
|
||||
|
||||
class LknpdApiError(Exception):
|
||||
"""Base exception for LKNPD API errors."""
|
||||
def __init__(self, message: str, status_code: int | None = None):
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
class LknpdAuthError(LknpdApiError):
|
||||
"""Authentication error (401)."""
|
||||
pass
|
||||
|
||||
|
||||
class LknpdValidationError(LknpdApiError):
|
||||
"""Validation error (400)."""
|
||||
pass
|
||||
|
||||
|
||||
def _generate_device_id() -> str:
|
||||
"""Generate device ID for API requests."""
|
||||
return str(uuid.uuid4()).replace("-", "")[:21].lower()
|
||||
|
||||
|
||||
def _format_datetime(dt: datetime) -> str:
|
||||
"""Format datetime to ISO/ATOM format with Z suffix."""
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=UTC)
|
||||
elif dt.tzinfo != UTC:
|
||||
dt = dt.astimezone(UTC)
|
||||
return dt.isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
class LknpdClient:
|
||||
"""
|
||||
Async client for LKNPD (lknpd.nalog.ru) self-employed API.
|
||||
|
||||
Supports:
|
||||
- INN + password authentication
|
||||
- Token refresh
|
||||
- Income registration with proper payment types (CASH/WIRE)
|
||||
"""
|
||||
|
||||
DEFAULT_HEADERS = {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
"Accept-Language": "ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7",
|
||||
"Referrer": "https://lknpd.nalog.ru/auth/login",
|
||||
}
|
||||
|
||||
DEVICE_INFO_TEMPLATE = {
|
||||
"sourceType": "WEB",
|
||||
"appVersion": "1.0.0",
|
||||
"metaDetails": {
|
||||
"userAgent": (
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 11_2_2) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/88.0.4324.192 Safari/537.36"
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str = "https://lknpd.nalog.ru/api",
|
||||
timeout: float = 10.0,
|
||||
):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.timeout = timeout
|
||||
self.device_id = _generate_device_id()
|
||||
self._token_data: dict[str, Any] | None = None
|
||||
self._refresh_lock = asyncio.Lock()
|
||||
|
||||
def _get_device_info(self) -> dict[str, Any]:
|
||||
"""Get device info with current device ID."""
|
||||
info = self.DEVICE_INFO_TEMPLATE.copy()
|
||||
info["sourceDeviceId"] = self.device_id
|
||||
return info
|
||||
|
||||
async def authenticate(self, inn: str, password: str) -> bool:
|
||||
"""
|
||||
Authenticate with INN and password.
|
||||
|
||||
Returns True if authentication was successful.
|
||||
"""
|
||||
request_data = {
|
||||
"username": inn,
|
||||
"password": password,
|
||||
"deviceInfo": self._get_device_info(),
|
||||
}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url}/v1/auth/lkfl",
|
||||
json=request_data,
|
||||
headers=self.DEFAULT_HEADERS,
|
||||
)
|
||||
|
||||
if response.status_code == 401:
|
||||
raise LknpdAuthError("Invalid credentials", 401)
|
||||
|
||||
if response.status_code >= 400:
|
||||
raise LknpdApiError(
|
||||
f"Authentication failed: {response.text}",
|
||||
response.status_code,
|
||||
)
|
||||
|
||||
self._token_data = response.json()
|
||||
logger.info("LKNPD authentication successful")
|
||||
return True
|
||||
|
||||
except httpx.RequestError as e:
|
||||
logger.exception("Network error during authentication")
|
||||
raise LknpdApiError(f"Network error: {e}")
|
||||
|
||||
async def _refresh_token(self) -> bool:
|
||||
"""Refresh access token using refresh token."""
|
||||
async with self._refresh_lock:
|
||||
if not self._token_data or "refreshToken" not in self._token_data:
|
||||
return False
|
||||
|
||||
request_data = {
|
||||
"deviceInfo": self._get_device_info(),
|
||||
"refreshToken": self._token_data["refreshToken"],
|
||||
}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url}/v1/auth/token",
|
||||
json=request_data,
|
||||
headers=self.DEFAULT_HEADERS,
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
return False
|
||||
|
||||
self._token_data = response.json()
|
||||
logger.info("LKNPD token refreshed")
|
||||
return True
|
||||
|
||||
except Exception:
|
||||
logger.exception("Token refresh failed")
|
||||
return False
|
||||
|
||||
def _get_auth_headers(self) -> dict[str, str]:
|
||||
"""Get authorization headers from current token."""
|
||||
if not self._token_data or "token" not in self._token_data:
|
||||
return {}
|
||||
return {"Authorization": f"Bearer {self._token_data['token']}"}
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
json_data: dict[str, Any] | None = None,
|
||||
retry_on_401: bool = True,
|
||||
) -> httpx.Response:
|
||||
"""Make authenticated API request with auto-retry on 401."""
|
||||
headers = {**self.DEFAULT_HEADERS, **self._get_auth_headers()}
|
||||
url = f"{self.base_url}/v1{path}"
|
||||
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.request(
|
||||
method,
|
||||
url,
|
||||
json=json_data,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
# Handle 401 with token refresh
|
||||
if response.status_code == 401 and retry_on_401:
|
||||
if await self._refresh_token():
|
||||
headers = {**self.DEFAULT_HEADERS, **self._get_auth_headers()}
|
||||
response = await client.request(
|
||||
method,
|
||||
url,
|
||||
json=json_data,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
@property
|
||||
def is_authenticated(self) -> bool:
|
||||
"""Check if client has valid token data."""
|
||||
return self._token_data is not None and "token" in self._token_data
|
||||
|
||||
async def create_income(
|
||||
self,
|
||||
*,
|
||||
name: str,
|
||||
amount: Decimal | float,
|
||||
quantity: Decimal | float | int = 1,
|
||||
payment_type: PaymentType = PaymentType.WIRE,
|
||||
income_type: IncomeType = IncomeType.FROM_INDIVIDUAL,
|
||||
client_inn: str | None = None,
|
||||
client_name: str | None = None,
|
||||
client_phone: str | None = None,
|
||||
operation_time: datetime | None = None,
|
||||
) -> str | None:
|
||||
"""
|
||||
Register income and create receipt.
|
||||
|
||||
Args:
|
||||
name: Service/item description
|
||||
amount: Price per unit
|
||||
quantity: Number of units
|
||||
payment_type: CASH or WIRE (for card/bank payments)
|
||||
income_type: Source type (individual, legal entity, foreign)
|
||||
client_inn: Client's INN (required for legal entities)
|
||||
client_name: Client's display name
|
||||
client_phone: Client's phone number
|
||||
operation_time: Time of operation (defaults to now)
|
||||
|
||||
Returns:
|
||||
Receipt UUID if successful, None otherwise
|
||||
"""
|
||||
if not self.is_authenticated:
|
||||
raise LknpdAuthError("Not authenticated")
|
||||
|
||||
# Prepare times
|
||||
now = datetime.now(UTC)
|
||||
op_time = operation_time or now
|
||||
|
||||
# Calculate total
|
||||
amount_decimal = Decimal(str(amount))
|
||||
qty_decimal = Decimal(str(quantity))
|
||||
total = amount_decimal * qty_decimal
|
||||
|
||||
# API expects quantity as integer when it's a whole number
|
||||
qty_value: int | str
|
||||
if qty_decimal == qty_decimal.to_integral_value():
|
||||
qty_value = int(qty_decimal)
|
||||
else:
|
||||
qty_value = str(qty_decimal)
|
||||
|
||||
# Build request
|
||||
request_data = {
|
||||
"operationTime": _format_datetime(op_time),
|
||||
"requestTime": _format_datetime(now),
|
||||
"services": [
|
||||
{
|
||||
"name": name,
|
||||
"amount": str(amount_decimal),
|
||||
"quantity": qty_value,
|
||||
}
|
||||
],
|
||||
"totalAmount": str(total),
|
||||
"client": {
|
||||
"contactPhone": client_phone,
|
||||
"displayName": client_name,
|
||||
"incomeType": income_type.value,
|
||||
"inn": client_inn,
|
||||
},
|
||||
"paymentType": payment_type.value,
|
||||
"ignoreMaxTotalIncomeRestriction": False,
|
||||
}
|
||||
|
||||
try:
|
||||
response = await self._request("POST", "/income", json_data=request_data)
|
||||
|
||||
if response.status_code == 400:
|
||||
logger.error("LKNPD validation error: %s", response.text)
|
||||
raise LknpdValidationError(response.text, 400)
|
||||
|
||||
if response.status_code == 401:
|
||||
raise LknpdAuthError("Authentication expired", 401)
|
||||
|
||||
if response.status_code >= 400:
|
||||
logger.error(
|
||||
"LKNPD API error: status=%d body=%s",
|
||||
response.status_code,
|
||||
response.text,
|
||||
)
|
||||
raise LknpdApiError(response.text, response.status_code)
|
||||
|
||||
payload = response.json()
|
||||
receipt_uuid = (
|
||||
payload.get("approvedReceiptUuid")
|
||||
or payload.get("receiptUuid")
|
||||
or payload.get("receipt_uuid")
|
||||
)
|
||||
|
||||
if receipt_uuid:
|
||||
logger.info("LKNPD receipt created: %s", receipt_uuid)
|
||||
|
||||
return receipt_uuid
|
||||
|
||||
except httpx.RequestError as e:
|
||||
logger.exception("Network error creating income")
|
||||
raise LknpdApiError(f"Network error: {e}")
|
||||
@@ -0,0 +1,69 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from .lknpd_client import LknpdClient, PaymentType, LknpdApiError
|
||||
|
||||
|
||||
class LknpdService:
|
||||
def __init__(
|
||||
self,
|
||||
inn: Optional[str],
|
||||
password: Optional[str],
|
||||
api_url: str = "https://lknpd.nalog.ru/api",
|
||||
) -> None:
|
||||
self.inn = inn.strip() if inn else None
|
||||
self.password = password
|
||||
self.configured = bool(self.inn and self.password)
|
||||
self._client = LknpdClient(base_url=api_url) if self.configured else None
|
||||
self._auth_lock = asyncio.Lock()
|
||||
|
||||
if not self.configured:
|
||||
logging.warning("LKNPD credentials are missing. Receipt sending disabled.")
|
||||
|
||||
async def _ensure_authenticated(self) -> bool:
|
||||
if not self._client:
|
||||
return False
|
||||
|
||||
async with self._auth_lock:
|
||||
if self._client.is_authenticated:
|
||||
return True
|
||||
|
||||
try:
|
||||
await self._client.authenticate(self.inn, self.password)
|
||||
return True
|
||||
except LknpdApiError:
|
||||
logging.exception("LKNPD authentication failed.")
|
||||
return False
|
||||
|
||||
async def create_income_receipt(
|
||||
self,
|
||||
*,
|
||||
item_name: str,
|
||||
amount: float,
|
||||
quantity: float = 1.0,
|
||||
operation_time: Optional[datetime] = None,
|
||||
) -> Optional[str]:
|
||||
if not self.configured:
|
||||
return None
|
||||
if not await self._ensure_authenticated():
|
||||
return None
|
||||
|
||||
try:
|
||||
receipt_uuid = await self._client.create_income(
|
||||
name=item_name,
|
||||
amount=amount,
|
||||
quantity=quantity,
|
||||
payment_type=PaymentType.WIRE,
|
||||
operation_time=operation_time,
|
||||
)
|
||||
if not receipt_uuid:
|
||||
logging.info("LKNPD receipt created without a UUID in response.")
|
||||
return receipt_uuid
|
||||
except LknpdApiError:
|
||||
logging.exception("Failed to create LKNPD receipt.")
|
||||
return None
|
||||
|
||||
async def close(self) -> None:
|
||||
return None
|
||||
@@ -1,10 +1,11 @@
|
||||
import logging
|
||||
import asyncio
|
||||
from aiogram import Bot
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from aiogram.utils.text_decorations import html_decoration as hd
|
||||
from aiogram.exceptions import TelegramRetryAfter
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, Union, Dict, Any
|
||||
from typing import Optional, Union, Dict, Any, Callable
|
||||
|
||||
from config.settings import Settings
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
@@ -14,6 +15,10 @@ from bot.utils.text_sanitizer import (
|
||||
display_name_or_fallback,
|
||||
username_for_display,
|
||||
)
|
||||
from bot.utils.telegram_markup import (
|
||||
is_profile_link_error,
|
||||
remove_profile_link_buttons,
|
||||
)
|
||||
|
||||
|
||||
class NotificationService:
|
||||
@@ -34,8 +39,43 @@ class NotificationService:
|
||||
if username:
|
||||
base_display = f"{base_display} ({username_for_display(username)})"
|
||||
return base_display
|
||||
|
||||
@staticmethod
|
||||
def _build_profile_keyboard(
|
||||
translate: Callable[..., str],
|
||||
user_id: int,
|
||||
referrer_id: Optional[int] = None,
|
||||
) -> InlineKeyboardMarkup:
|
||||
"""Create inline keyboard with links to user (and referrer) profiles."""
|
||||
buttons = [
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=translate(
|
||||
"log_open_profile_link",
|
||||
),
|
||||
url=f"tg://user?id={user_id}",
|
||||
)
|
||||
]
|
||||
]
|
||||
|
||||
if referrer_id:
|
||||
buttons.append([
|
||||
InlineKeyboardButton(
|
||||
text=translate(
|
||||
"log_open_referrer_profile_button",
|
||||
),
|
||||
url=f"tg://user?id={referrer_id}",
|
||||
)
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
async def _send_to_log_channel(self, message: str, thread_id: Optional[int] = None):
|
||||
async def _send_to_log_channel(
|
||||
self,
|
||||
message: str,
|
||||
thread_id: Optional[int] = None,
|
||||
reply_markup: Optional[InlineKeyboardMarkup] = None,
|
||||
):
|
||||
"""Send message to configured log channel/group using message queue"""
|
||||
if not self.settings.LOG_CHAT_ID:
|
||||
return
|
||||
@@ -43,16 +83,45 @@ class NotificationService:
|
||||
queue_manager = get_queue_manager()
|
||||
if not queue_manager:
|
||||
logging.warning("Message queue manager not available, falling back to direct send")
|
||||
final_thread_id = thread_id or self.settings.LOG_THREAD_ID
|
||||
|
||||
def _build_kwargs(markup: Optional[InlineKeyboardMarkup]) -> Dict[str, Any]:
|
||||
kwargs: Dict[str, Any] = {
|
||||
"chat_id": self.settings.LOG_CHAT_ID,
|
||||
"text": message,
|
||||
"parse_mode": "HTML",
|
||||
"disable_web_page_preview": True,
|
||||
}
|
||||
if markup:
|
||||
kwargs["reply_markup"] = markup
|
||||
if final_thread_id:
|
||||
kwargs["message_thread_id"] = final_thread_id
|
||||
return kwargs
|
||||
|
||||
try:
|
||||
await self.bot.send_message(
|
||||
chat_id=self.settings.LOG_CHAT_ID,
|
||||
text=message,
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True,
|
||||
message_thread_id=thread_id or self.settings.LOG_THREAD_ID
|
||||
await self.bot.send_message(**_build_kwargs(reply_markup))
|
||||
except TelegramBadRequest as exc:
|
||||
if is_profile_link_error(exc):
|
||||
fallback_markup = remove_profile_link_buttons(reply_markup)
|
||||
logging.warning(
|
||||
"Telegram rejected profile buttons for log chat %s: %s. "
|
||||
"Retrying without tg:// links.",
|
||||
self.settings.LOG_CHAT_ID,
|
||||
getattr(exc, "message", "") or str(exc),
|
||||
)
|
||||
try:
|
||||
await self.bot.send_message(**_build_kwargs(fallback_markup))
|
||||
except Exception as retry_exc:
|
||||
logging.error(
|
||||
"Failed to send notification without profile buttons to log "
|
||||
f"channel {self.settings.LOG_CHAT_ID}: {retry_exc}"
|
||||
)
|
||||
return
|
||||
logging.error(
|
||||
f"Failed to send notification to log channel {self.settings.LOG_CHAT_ID}: {exc}"
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to send notification to log channel {self.settings.LOG_CHAT_ID}: {e}")
|
||||
except Exception:
|
||||
logging.exception("Failed to send notification to log channel %s.", self.settings.LOG_CHAT_ID)
|
||||
return
|
||||
|
||||
try:
|
||||
@@ -64,6 +133,8 @@ class NotificationService:
|
||||
"parse_mode": "HTML",
|
||||
"disable_web_page_preview": True
|
||||
}
|
||||
if reply_markup:
|
||||
kwargs["reply_markup"] = reply_markup
|
||||
|
||||
# Add thread ID for supergroups if specified
|
||||
if final_thread_id:
|
||||
@@ -72,8 +143,8 @@ class NotificationService:
|
||||
# Queue message for sending (groups are rate limited to 15/minute)
|
||||
await queue_manager.send_message(self.settings.LOG_CHAT_ID, **kwargs)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to queue notification to log channel {self.settings.LOG_CHAT_ID}: {e}")
|
||||
except Exception:
|
||||
logging.exception("Failed to queue notification to log channel %s.", self.settings.LOG_CHAT_ID)
|
||||
|
||||
async def _send_to_admins(self, message: str):
|
||||
"""Send message to all admin users using message queue"""
|
||||
@@ -91,8 +162,8 @@ class NotificationService:
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to send notification to admin {admin_id}: {e}")
|
||||
except Exception:
|
||||
logging.exception("Failed to send notification to admin %s.", admin_id)
|
||||
return
|
||||
|
||||
for admin_id in self.settings.ADMIN_IDS:
|
||||
@@ -103,8 +174,8 @@ class NotificationService:
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to queue notification to admin {admin_id}: {e}")
|
||||
except Exception:
|
||||
logging.exception("Failed to queue notification to admin %s.", admin_id)
|
||||
|
||||
async def notify_new_user_registration(self, user_id: int, username: Optional[str] = None,
|
||||
first_name: Optional[str] = None,
|
||||
@@ -124,26 +195,139 @@ class NotificationService:
|
||||
|
||||
referral_text = ""
|
||||
if referred_by_id:
|
||||
referral_text = _("log_referral_suffix", default=" (реферал от {referrer_id})", referrer_id=referred_by_id)
|
||||
referrer_link = hd.link(str(referred_by_id), f"tg://user?id={referred_by_id}")
|
||||
referral_text = _(
|
||||
"log_referral_suffix",
|
||||
referrer_link=referrer_link,
|
||||
)
|
||||
|
||||
message = _(
|
||||
"log_new_user_registration",
|
||||
default="👤 <b>Новый пользователь</b>\n\n"
|
||||
"🆔 ID: <code>{user_id}</code>\n"
|
||||
"👤 Имя: {user_display}{referral_text}\n"
|
||||
"📅 Время: {timestamp}",
|
||||
user_id=user_id,
|
||||
user_display=user_display,
|
||||
referral_text=referral_text,
|
||||
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
)
|
||||
|
||||
|
||||
# Send to log channel
|
||||
await self._send_to_log_channel(message)
|
||||
|
||||
profile_keyboard = self._build_profile_keyboard(_, user_id, referred_by_id)
|
||||
await self._send_to_log_channel(message, reply_markup=profile_keyboard)
|
||||
|
||||
async def notify_new_email_user_registration(
|
||||
self,
|
||||
user_id: int,
|
||||
email: str,
|
||||
referred_by_id: Optional[int] = None,
|
||||
):
|
||||
"""Send notification about new user registration via email (Web App)."""
|
||||
if not self.settings.LOG_NEW_USERS:
|
||||
return
|
||||
|
||||
admin_lang = self.settings.DEFAULT_LANGUAGE
|
||||
_ = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw) if self.i18n else k
|
||||
|
||||
referral_text = ""
|
||||
if referred_by_id:
|
||||
referrer_link = hd.link(str(referred_by_id), f"tg://user?id={referred_by_id}")
|
||||
referral_text = _(
|
||||
"log_referral_suffix",
|
||||
referrer_link=referrer_link,
|
||||
)
|
||||
|
||||
message = _(
|
||||
"log_new_email_user_registration",
|
||||
user_id=user_id,
|
||||
email=hd.quote(email),
|
||||
referral_text=referral_text,
|
||||
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
)
|
||||
|
||||
# Email users have a synthetic (negative) user_id with no Telegram profile,
|
||||
# so we only attach the referrer button when a real referrer is present.
|
||||
reply_markup: Optional[InlineKeyboardMarkup] = None
|
||||
if referred_by_id and referred_by_id > 0:
|
||||
reply_markup = InlineKeyboardMarkup(inline_keyboard=[[
|
||||
InlineKeyboardButton(
|
||||
text=_("log_open_referrer_profile_button"),
|
||||
url=f"tg://user?id={referred_by_id}",
|
||||
)
|
||||
]])
|
||||
|
||||
await self._send_to_log_channel(message, reply_markup=reply_markup)
|
||||
|
||||
async def notify_account_email_linked(
|
||||
self,
|
||||
user_id: int,
|
||||
email: str,
|
||||
telegram_id: Optional[int] = None,
|
||||
username: Optional[str] = None,
|
||||
first_name: Optional[str] = None,
|
||||
):
|
||||
"""Send notification when an email is linked to a Telegram-created account."""
|
||||
if not self.settings.LOG_NEW_USERS:
|
||||
return
|
||||
|
||||
admin_lang = self.settings.DEFAULT_LANGUAGE
|
||||
_ = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw) if self.i18n else k
|
||||
|
||||
user_display = self._format_user_display(
|
||||
user_id=telegram_id or user_id,
|
||||
username=username,
|
||||
first_name=first_name,
|
||||
)
|
||||
|
||||
message = _(
|
||||
"log_account_email_linked",
|
||||
user_id=user_id,
|
||||
telegram_id=telegram_id or user_id,
|
||||
user_display=user_display,
|
||||
email=hd.quote(email),
|
||||
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
)
|
||||
|
||||
reply_markup: Optional[InlineKeyboardMarkup] = None
|
||||
if telegram_id and telegram_id > 0:
|
||||
reply_markup = self._build_profile_keyboard(_, telegram_id)
|
||||
|
||||
await self._send_to_log_channel(message, reply_markup=reply_markup)
|
||||
|
||||
async def notify_account_telegram_linked(
|
||||
self,
|
||||
user_id: int,
|
||||
email: Optional[str],
|
||||
telegram_id: int,
|
||||
username: Optional[str] = None,
|
||||
first_name: Optional[str] = None,
|
||||
):
|
||||
"""Send notification when Telegram is linked to an email-created account."""
|
||||
if not self.settings.LOG_NEW_USERS:
|
||||
return
|
||||
|
||||
admin_lang = self.settings.DEFAULT_LANGUAGE
|
||||
_ = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw) if self.i18n else k
|
||||
|
||||
user_display = self._format_user_display(
|
||||
user_id=telegram_id,
|
||||
username=username,
|
||||
first_name=first_name,
|
||||
)
|
||||
|
||||
message = _(
|
||||
"log_account_telegram_linked",
|
||||
user_id=user_id,
|
||||
telegram_id=telegram_id,
|
||||
user_display=user_display,
|
||||
email=hd.quote(email or ""),
|
||||
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
)
|
||||
|
||||
profile_keyboard = self._build_profile_keyboard(_, telegram_id)
|
||||
await self._send_to_log_channel(message, reply_markup=profile_keyboard)
|
||||
|
||||
async def notify_payment_received(self, user_id: int, amount: float, currency: str,
|
||||
months: int, payment_provider: str,
|
||||
username: Optional[str] = None):
|
||||
username: Optional[str] = None,
|
||||
traffic_gb: Optional[float] = None):
|
||||
"""Send notification about successful payment"""
|
||||
if not self.settings.LOG_PAYMENTS:
|
||||
return
|
||||
@@ -161,28 +345,37 @@ class NotificationService:
|
||||
"freekassa": "💳",
|
||||
"cryptopay": "₿",
|
||||
"stars": "⭐",
|
||||
"tribute": "💎"
|
||||
"platega": "💳",
|
||||
"severpay": "💳",
|
||||
}.get(payment_provider.lower(), "💰")
|
||||
|
||||
message = _(
|
||||
"log_payment_received",
|
||||
default="{provider_emoji} <b>Получен платеж</b>\n\n"
|
||||
"👤 Пользователь: {user_display}\n"
|
||||
"💰 Сумма: <b>{amount} {currency}</b>\n"
|
||||
"📅 Период: <b>{months} мес.</b>\n"
|
||||
"🏦 Провайдер: {payment_provider}\n"
|
||||
"🕐 Время: {timestamp}",
|
||||
provider_emoji=provider_emoji,
|
||||
user_display=user_display,
|
||||
amount=amount,
|
||||
currency=currency,
|
||||
months=months,
|
||||
payment_provider=payment_provider,
|
||||
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
)
|
||||
|
||||
if traffic_gb is not None:
|
||||
traffic_label = str(int(traffic_gb)) if float(traffic_gb).is_integer() else f"{traffic_gb:g}"
|
||||
message = _(
|
||||
"log_payment_received_traffic",
|
||||
provider_emoji=provider_emoji,
|
||||
user_display=user_display,
|
||||
amount=amount,
|
||||
currency=currency,
|
||||
traffic_gb=traffic_label,
|
||||
payment_provider=payment_provider,
|
||||
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
)
|
||||
else:
|
||||
message = _(
|
||||
"log_payment_received",
|
||||
provider_emoji=provider_emoji,
|
||||
user_display=user_display,
|
||||
amount=amount,
|
||||
currency=currency,
|
||||
months=months,
|
||||
payment_provider=payment_provider,
|
||||
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
)
|
||||
|
||||
# Send to log channel
|
||||
await self._send_to_log_channel(message)
|
||||
profile_keyboard = self._build_profile_keyboard(_, user_id)
|
||||
await self._send_to_log_channel(message, reply_markup=profile_keyboard)
|
||||
|
||||
async def notify_promo_activation(self, user_id: int, promo_code: str, bonus_days: int,
|
||||
username: Optional[str] = None):
|
||||
@@ -200,11 +393,6 @@ class NotificationService:
|
||||
|
||||
message = _(
|
||||
"log_promo_activation",
|
||||
default="🎁 <b>Активирован промокод</b>\n\n"
|
||||
"👤 Пользователь: {user_display}\n"
|
||||
"🏷 Код: <code>{promo_code}</code>\n"
|
||||
"🎯 Бонус: <b>+{bonus_days} дн.</b>\n"
|
||||
"🕐 Время: {timestamp}",
|
||||
user_display=user_display,
|
||||
promo_code=promo_code,
|
||||
bonus_days=bonus_days,
|
||||
@@ -212,7 +400,8 @@ class NotificationService:
|
||||
)
|
||||
|
||||
# Send to log channel
|
||||
await self._send_to_log_channel(message)
|
||||
profile_keyboard = self._build_profile_keyboard(_, user_id)
|
||||
await self._send_to_log_channel(message, reply_markup=profile_keyboard)
|
||||
|
||||
async def notify_trial_activation(self, user_id: int, end_date: datetime,
|
||||
username: Optional[str] = None):
|
||||
@@ -230,17 +419,14 @@ class NotificationService:
|
||||
|
||||
message = _(
|
||||
"log_trial_activation",
|
||||
default="🆓 <b>Активирован триал</b>\n\n"
|
||||
"👤 Пользователь: {user_display}\n"
|
||||
"⏰ Действует до: <b>{end_date}</b>\n"
|
||||
"🕐 Время: {timestamp}",
|
||||
user_display=user_display,
|
||||
end_date=end_date.strftime("%Y-%m-%d %H:%M"),
|
||||
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
)
|
||||
|
||||
# Send to log channel
|
||||
await self._send_to_log_channel(message)
|
||||
profile_keyboard = self._build_profile_keyboard(_, user_id)
|
||||
await self._send_to_log_channel(message, reply_markup=profile_keyboard)
|
||||
|
||||
async def notify_panel_sync(self, status: str, details: str,
|
||||
users_processed: int, subs_synced: int,
|
||||
@@ -261,12 +447,6 @@ class NotificationService:
|
||||
|
||||
message = _(
|
||||
"log_panel_sync",
|
||||
default="{status_emoji} <b>Синхронизация с панелью</b>\n\n"
|
||||
"📊 Статус: <b>{status}</b>\n"
|
||||
"👥 Обработано пользователей: <b>{users_processed}</b>\n"
|
||||
"📋 Синхронизировано подписок: <b>{subs_synced}</b>\n"
|
||||
"🕐 Время: {timestamp}\n\n"
|
||||
"📝 Детали:\n{details}",
|
||||
status_emoji=status_emoji,
|
||||
status=status,
|
||||
users_processed=users_processed,
|
||||
@@ -275,7 +455,7 @@ class NotificationService:
|
||||
details=details
|
||||
)
|
||||
|
||||
# Send to log channel
|
||||
# Send to log channel
|
||||
await self._send_to_log_channel(message)
|
||||
|
||||
async def notify_suspicious_promo_attempt(
|
||||
@@ -297,18 +477,14 @@ class NotificationService:
|
||||
|
||||
message = _(
|
||||
"log_suspicious_promo",
|
||||
default="⚠️ <b>Подозрительная попытка ввода промокода</b>\n\n"
|
||||
"👤 Пользователь: {user_display}\n"
|
||||
"🆔 ID: <code>{user_id}</code>\n"
|
||||
"📝 Ввод: <pre>{suspicious_input}</pre>\n"
|
||||
"🕐 Время: {timestamp}",
|
||||
user_display=hd.quote(user_display),
|
||||
user_id=user_id,
|
||||
suspicious_input=hd.quote(suspicious_input),
|
||||
timestamp=datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S %Z"))
|
||||
|
||||
# Send to log channel
|
||||
await self._send_to_log_channel(message)
|
||||
profile_keyboard = self._build_profile_keyboard(_, user_id)
|
||||
await self._send_to_log_channel(message, reply_markup=profile_keyboard)
|
||||
|
||||
async def send_custom_notification(self, message: str, to_admins: bool = False,
|
||||
to_log_channel: bool = True, thread_id: Optional[int] = None):
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import aiohttp
|
||||
import logging
|
||||
import json
|
||||
import re
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import asyncio
|
||||
@@ -173,7 +174,7 @@ class PanelApiService:
|
||||
"message": f"Connection error: {str(e)}"
|
||||
}
|
||||
except aiohttp.ClientError as e:
|
||||
logging.error(f"Panel API ClientError to {url_for_request}: {e}")
|
||||
logging.exception("Panel API ClientError to %s.", url_for_request)
|
||||
return {
|
||||
"error": True,
|
||||
"status_code": -2,
|
||||
@@ -339,23 +340,25 @@ class PanelApiService:
|
||||
default_traffic_limit_strategy: str = "NO_RESET",
|
||||
hwid_device_limit: Optional[int] = None,
|
||||
specific_squad_uuids: Optional[List[str]] = None,
|
||||
external_squad_uuid: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
tag: Optional[str] = None,
|
||||
status: str = "ACTIVE",
|
||||
log_response: bool = True) -> Optional[Dict[str, Any]]:
|
||||
|
||||
if not (6 <= len(username_on_panel) <= 34 and
|
||||
username_on_panel.replace('_', '').replace('-', '').isalnum()):
|
||||
if not (username_on_panel.startswith("tg_")
|
||||
and username_on_panel.split("tg_")[-1].isdigit()):
|
||||
msg = f"Panel username '{username_on_panel}' does not meet panel requirements."
|
||||
logging.error(msg)
|
||||
return {
|
||||
"error": True,
|
||||
"status_code": 400,
|
||||
"message": msg,
|
||||
"errorCode": "VALIDATION_ERROR_USERNAME"
|
||||
}
|
||||
username_is_valid = (
|
||||
3 <= len(username_on_panel) <= 36
|
||||
and re.match(r"^[A-Za-z0-9_-]+$", username_on_panel) is not None
|
||||
)
|
||||
if not username_is_valid:
|
||||
msg = f"Panel username '{username_on_panel}' does not meet panel requirements."
|
||||
logging.error(msg)
|
||||
return {
|
||||
"error": True,
|
||||
"status_code": 400,
|
||||
"message": msg,
|
||||
"errorCode": "VALIDATION_ERROR_USERNAME"
|
||||
}
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
expire_at_dt = now + timedelta(days=default_expire_days)
|
||||
@@ -383,6 +386,8 @@ class PanelApiService:
|
||||
)
|
||||
if specific_squad_uuids:
|
||||
payload["activeInternalSquads"] = specific_squad_uuids
|
||||
if external_squad_uuid:
|
||||
payload["externalSquadUuid"] = external_squad_uuid
|
||||
if telegram_id is not None: payload["telegramId"] = telegram_id
|
||||
if email: payload["email"] = email
|
||||
if description: payload["description"] = description
|
||||
@@ -455,6 +460,37 @@ class PanelApiService:
|
||||
)
|
||||
return False
|
||||
|
||||
async def delete_user_from_panel(self,
|
||||
user_uuid: str,
|
||||
log_response: bool = True) -> bool:
|
||||
"""Delete a user from the panel. Treat not-found as already deleted."""
|
||||
endpoint = f"/users/{user_uuid}"
|
||||
response_data = await self._request(
|
||||
"DELETE", endpoint, log_full_response=log_response
|
||||
)
|
||||
|
||||
if not response_data:
|
||||
logging.error(
|
||||
f"Panel API delete_user_from_panel returned no data for user {user_uuid}."
|
||||
)
|
||||
return False
|
||||
|
||||
if response_data.get("error"):
|
||||
details = response_data.get("details") or {}
|
||||
error_code = details.get("errorCode") or response_data.get("errorCode")
|
||||
if error_code in {"A062", "A040"}:
|
||||
logging.info(
|
||||
f"Panel user {user_uuid} already absent (errorCode {error_code}). Treating as deleted."
|
||||
)
|
||||
return True
|
||||
logging.error(
|
||||
f"Failed to delete user {user_uuid} on panel. Response: {response_data}"
|
||||
)
|
||||
return False
|
||||
|
||||
logging.info(f"Panel user {user_uuid} deleted successfully.")
|
||||
return True
|
||||
|
||||
async def get_subscription_link(
|
||||
self,
|
||||
short_uuid_or_sub_uuid: str,
|
||||
@@ -526,3 +562,20 @@ class PanelApiService:
|
||||
if response_data and not response_data.get("error") and "response" in response_data:
|
||||
return response_data.get("response")
|
||||
return None
|
||||
|
||||
async def encrypt_happ_link(self, link_to_encrypt: str) -> Optional[str]:
|
||||
"""Encrypt a subscription link using the panel's happ crypt4 API.
|
||||
|
||||
Returns the encrypted link string or None if encryption failed.
|
||||
"""
|
||||
payload = {"linkToEncrypt": link_to_encrypt}
|
||||
response_data = await self._request(
|
||||
"POST",
|
||||
"/system/tools/happ/encrypt",
|
||||
json=payload,
|
||||
log_full_response=False
|
||||
)
|
||||
if response_data and not response_data.get("error") and "response" in response_data:
|
||||
return response_data.get("response", {}).get("encryptedLink")
|
||||
logging.error(f"Failed to encrypt happ link. Response: {response_data}")
|
||||
return None
|
||||
|
||||
@@ -9,10 +9,11 @@ from sqlalchemy.orm import sessionmaker
|
||||
from typing import Optional
|
||||
from config.settings import Settings
|
||||
from .panel_api_service import PanelApiService
|
||||
from .email_auth_service import EmailAuthService
|
||||
from .email_templates import render_subscription_expiring
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.keyboards.inline.user_keyboards import get_subscribe_only_markup, get_autorenew_cancel_keyboard
|
||||
from db.dal import user_dal
|
||||
from bot.utils.date_utils import add_months
|
||||
|
||||
EVENT_MAP = {
|
||||
"user.expires_in_72_hours": (3, "subscription_72h_notification"),
|
||||
@@ -27,6 +28,10 @@ class PanelWebhookService:
|
||||
self.i18n = i18n
|
||||
self.async_session_factory = async_session_factory
|
||||
self.panel_service = panel_service
|
||||
if not self.settings.PANEL_WEBHOOK_SECRET:
|
||||
logging.error(
|
||||
"PANEL_WEBHOOK_SECRET is not configured. Panel webhooks will be rejected."
|
||||
)
|
||||
|
||||
async def _send_message(
|
||||
self,
|
||||
@@ -41,130 +46,8 @@ class PanelWebhookService:
|
||||
await self.bot.send_message(
|
||||
user_id, _(message_key, **kwargs), reply_markup=reply_markup
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to send notification to {user_id}: {e}")
|
||||
|
||||
async def _handle_expired_subscription(self, session, user_id: int, user_payload: dict,
|
||||
lang: str, markup, first_name: str) -> bool:
|
||||
"""Handle expired subscription - auto-renew tribute users if no cancellation was received.
|
||||
|
||||
Returns True if an auto-renewal was performed (and renewal message sent), False otherwise.
|
||||
"""
|
||||
from db.dal import subscription_dal, payment_dal
|
||||
from datetime import datetime, timezone
|
||||
|
||||
try:
|
||||
auto_renewed = False
|
||||
# Check if user has tribute subscriptions that weren't cancelled
|
||||
user_subs = await subscription_dal.get_active_subscriptions_for_user(session, user_id)
|
||||
|
||||
for sub in user_subs:
|
||||
# Check if this subscription was marked as cancelled (from tribute cancellation webhook)
|
||||
if sub.status_from_panel == 'CANCELLED':
|
||||
logging.info(f"Subscription {sub.subscription_id} for user {user_id} was cancelled, skipping auto-renewal")
|
||||
continue
|
||||
|
||||
# Check if this user has tribute payments
|
||||
last_tribute_duration = await payment_dal.get_last_tribute_payment_duration(session, user_id)
|
||||
|
||||
if last_tribute_duration is not None:
|
||||
# This user has tribute payments, auto-renew for the same duration
|
||||
logging.info(f"Auto-renewing tribute subscription for user {user_id} for {last_tribute_duration} months")
|
||||
|
||||
# Extend subscription by the last payment duration (calendar months)
|
||||
new_end_date = add_months(datetime.now(timezone.utc), last_tribute_duration)
|
||||
|
||||
# Update local DB subscription
|
||||
await subscription_dal.update_subscription(
|
||||
session,
|
||||
sub.subscription_id,
|
||||
{
|
||||
'end_date': new_end_date,
|
||||
'status_from_panel': 'ACTIVE',
|
||||
'is_active': True
|
||||
}
|
||||
)
|
||||
# Update panel expiry to ensure actual service access is extended
|
||||
try:
|
||||
panel_payload = {
|
||||
"uuid": sub.panel_user_uuid,
|
||||
"expireAt": new_end_date.isoformat(timespec='milliseconds').replace('+00:00', 'Z'),
|
||||
"status": "ACTIVE",
|
||||
}
|
||||
panel_update_resp = await self.panel_service.update_user_details_on_panel(
|
||||
sub.panel_user_uuid,
|
||||
panel_payload,
|
||||
log_response=True,
|
||||
)
|
||||
if panel_update_resp:
|
||||
logging.info(
|
||||
f"Panel expiry updated for user {user_id} (panel_uuid {sub.panel_user_uuid}) to {new_end_date}"
|
||||
)
|
||||
except Exception as e_panel:
|
||||
logging.error(
|
||||
f"Failed to update panel expiry for user {user_id} (panel_uuid {sub.panel_user_uuid}): {e_panel}")
|
||||
|
||||
# Create a succeeded payment record in DB with the same amount/currency as last tribute payment
|
||||
try:
|
||||
last_payment = await payment_dal.get_last_tribute_payment(session, user_id)
|
||||
if last_payment and last_payment.amount and last_payment.currency:
|
||||
provider_payment_id = (
|
||||
f"tribute_auto_{user_id}_{sub.subscription_id}_"
|
||||
f"{new_end_date.strftime('%Y%m%d')}"
|
||||
)
|
||||
created_payment = await payment_dal.ensure_payment_with_provider_id(
|
||||
session,
|
||||
user_id=user_id,
|
||||
amount=float(last_payment.amount),
|
||||
currency=last_payment.currency,
|
||||
months=last_tribute_duration,
|
||||
description="Auto-renewal (panel webhook)",
|
||||
provider="tribute",
|
||||
provider_payment_id=provider_payment_id,
|
||||
)
|
||||
if created_payment:
|
||||
logging.info(
|
||||
f"Auto-renew payment recorded (id={created_payment.payment_id}) for user {user_id} amount={created_payment.amount} {created_payment.currency} months={last_tribute_duration}"
|
||||
)
|
||||
else:
|
||||
logging.warning(
|
||||
f"Could not create auto-renew payment for user {user_id}: previous tribute payment not found or missing amount/currency")
|
||||
except Exception as e_pay:
|
||||
logging.error(
|
||||
f"Failed to create auto-renew payment record for user {user_id}: {e_pay}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# Send auto-renewal notification
|
||||
_ = lambda k, **kw: self.i18n.gettext(lang, k, **kw) if self.i18n else k
|
||||
auto_renewal_msg = _(
|
||||
"tribute_auto_renewal",
|
||||
default="🔄 <b>Подписка автоматически продлена</b>\n\n"
|
||||
"Ваша подписка Tribute была автоматически продлена на {months} мес.\n"
|
||||
"Новая дата окончания: {end_date}",
|
||||
user_name=first_name,
|
||||
months=last_tribute_duration,
|
||||
end_date=new_end_date.strftime('%Y-%m-%d')
|
||||
)
|
||||
|
||||
try:
|
||||
await self.bot.send_message(
|
||||
user_id,
|
||||
auto_renewal_msg,
|
||||
reply_markup=markup,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
auto_renewed = True
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to send auto-renewal notification to user {user_id}: {e}")
|
||||
|
||||
await session.commit()
|
||||
return auto_renewed
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error handling expired subscription for user {user_id}: {e}")
|
||||
await session.rollback()
|
||||
return False
|
||||
except Exception:
|
||||
logging.exception("Failed to send notification to %s", user_id)
|
||||
|
||||
async def handle_event(self, event_name: str, user_payload: dict):
|
||||
telegram_id = user_payload.get("telegramId")
|
||||
@@ -177,9 +60,13 @@ class PanelWebhookService:
|
||||
return
|
||||
|
||||
async with self.async_session_factory() as session:
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
db_user = await user_dal.get_user_by_telegram_id(session, user_id)
|
||||
if not db_user:
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
internal_user_id = db_user.user_id if db_user else user_id
|
||||
lang = db_user.language_code if db_user and db_user.language_code else self.settings.DEFAULT_LANGUAGE
|
||||
first_name = db_user.first_name or f"User {user_id}" if db_user else f"User {user_id}"
|
||||
user_email = (db_user.email or "").strip() if db_user else ""
|
||||
|
||||
markup = get_subscribe_only_markup(lang, self.i18n)
|
||||
|
||||
@@ -192,8 +79,8 @@ class PanelWebhookService:
|
||||
if subscription_service:
|
||||
async with self.async_session_factory() as session:
|
||||
from db.dal import subscription_dal
|
||||
sub = await subscription_dal.get_active_subscription_by_user_id(session, user_id)
|
||||
if sub and sub.auto_renew_enabled and sub.provider != 'tribute':
|
||||
sub = await subscription_dal.get_active_subscription_by_user_id(session, internal_user_id)
|
||||
if sub and sub.auto_renew_enabled and sub.provider == 'yookassa':
|
||||
try:
|
||||
ok = await subscription_service.charge_subscription_renewal(session, sub)
|
||||
# If initiation succeeded, suppress the 24h reminder by returning early
|
||||
@@ -208,11 +95,11 @@ class PanelWebhookService:
|
||||
except Exception:
|
||||
logging.exception("Auto-renew trigger (24h) failed pre-check")
|
||||
if days_left <= self.settings.SUBSCRIPTION_NOTIFY_DAYS_BEFORE:
|
||||
# For 48h event, if auto-renew is enabled and not tribute, show special notice with cancel button
|
||||
# For 48h event, if auto-renew is enabled, show special notice with cancel button
|
||||
if days_left == 2:
|
||||
async with self.async_session_factory() as session:
|
||||
from db.dal import subscription_dal
|
||||
sub = await subscription_dal.get_active_subscription_by_user_id(session, user_id)
|
||||
sub = await subscription_dal.get_active_subscription_by_user_id(session, internal_user_id)
|
||||
logging.info(
|
||||
"48h webhook check: user_id=%s sub_found=%s auto_renew=%s provider=%s",
|
||||
user_id,
|
||||
@@ -220,7 +107,7 @@ class PanelWebhookService:
|
||||
getattr(sub, 'auto_renew_enabled', None) if sub else None,
|
||||
getattr(sub, 'provider', None) if sub else None,
|
||||
)
|
||||
if sub and sub.auto_renew_enabled and sub.provider != 'tribute':
|
||||
if sub and sub.auto_renew_enabled and sub.provider == 'yookassa':
|
||||
cancel_kb = get_autorenew_cancel_keyboard(lang, self.i18n)
|
||||
await self._send_message(
|
||||
user_id,
|
||||
@@ -238,12 +125,15 @@ class PanelWebhookService:
|
||||
user_name=first_name,
|
||||
end_date=user_payload.get("expireAt", "")[:10],
|
||||
)
|
||||
if days_left == 3 and user_email:
|
||||
await self._send_subscription_expiring_email(
|
||||
recipient=user_email,
|
||||
lang=lang,
|
||||
days_left=days_left,
|
||||
end_date_text=user_payload.get("expireAt", "")[:10],
|
||||
)
|
||||
elif event_name == "user.expired":
|
||||
# Check if this is a tribute user that should be auto-renewed (regardless of notification settings)
|
||||
auto_renewed = await self._handle_expired_subscription(session, user_id, user_payload, lang, markup, first_name)
|
||||
|
||||
# If auto-renewed via Tribute, suppress expiration notification. Otherwise, send it if enabled.
|
||||
if not auto_renewed and self.settings.SUBSCRIPTION_NOTIFY_ON_EXPIRE:
|
||||
if self.settings.SUBSCRIPTION_NOTIFY_ON_EXPIRE:
|
||||
await self._send_message(
|
||||
user_id,
|
||||
lang,
|
||||
@@ -262,17 +152,44 @@ class PanelWebhookService:
|
||||
end_date=user_payload.get("expireAt", "")[:10],
|
||||
)
|
||||
|
||||
async def _send_subscription_expiring_email(
|
||||
self,
|
||||
*,
|
||||
recipient: str,
|
||||
lang: str,
|
||||
days_left: int,
|
||||
end_date_text: str,
|
||||
) -> None:
|
||||
"""Best-effort branded reminder; silently no-ops without SMTP config."""
|
||||
if not self.settings.email_auth_configured:
|
||||
return
|
||||
try:
|
||||
content = render_subscription_expiring(
|
||||
self.settings,
|
||||
language_code=lang,
|
||||
days_left=days_left,
|
||||
end_date_text=end_date_text,
|
||||
dashboard_url=(self.settings.SUBSCRIPTION_MINI_APP_URL or "").strip() or None,
|
||||
)
|
||||
email_service = EmailAuthService(self.settings)
|
||||
await email_service.send_rendered_email(email=recipient, content=content)
|
||||
except Exception:
|
||||
logging.exception("Failed to send subscription-expiring email to %s", recipient)
|
||||
|
||||
async def handle_webhook(self, raw_body: bytes, signature_header: Optional[str]) -> web.Response:
|
||||
if self.settings.PANEL_WEBHOOK_SECRET:
|
||||
if not signature_header:
|
||||
return web.Response(status=403, text="no_signature")
|
||||
expected_sig = hmac.new(
|
||||
self.settings.PANEL_WEBHOOK_SECRET.encode(),
|
||||
raw_body,
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
if not hmac.compare_digest(expected_sig, signature_header):
|
||||
return web.Response(status=403, text="invalid_signature")
|
||||
if not self.settings.PANEL_WEBHOOK_SECRET:
|
||||
return web.Response(status=401, text="unauthorized")
|
||||
|
||||
if not signature_header:
|
||||
return web.Response(status=401, text="unauthorized")
|
||||
|
||||
expected_sig = hmac.new(
|
||||
self.settings.PANEL_WEBHOOK_SECRET.encode(),
|
||||
raw_body,
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
if not hmac.compare_digest(expected_sig, signature_header):
|
||||
return web.Response(status=401, text="unauthorized")
|
||||
|
||||
try:
|
||||
payload = json.loads(raw_body.decode())
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
from typing import Optional, Dict, Any, Tuple
|
||||
|
||||
from aiohttp import ClientSession, ClientTimeout, web
|
||||
from aiogram import Bot
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from config.settings import Settings
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.services.referral_service import ReferralService
|
||||
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
|
||||
from bot.services.notification_service import NotificationService
|
||||
from db.dal import payment_dal, user_dal
|
||||
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
|
||||
from bot.utils.config_link import prepare_config_links
|
||||
|
||||
|
||||
class PlategaService:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
bot: Bot,
|
||||
settings: Settings,
|
||||
i18n: JsonI18n,
|
||||
async_session_factory: sessionmaker,
|
||||
subscription_service: SubscriptionService,
|
||||
referral_service: ReferralService,
|
||||
default_return_url: str,
|
||||
):
|
||||
self.bot = bot
|
||||
self.settings = settings
|
||||
self.i18n = i18n
|
||||
self.async_session_factory = async_session_factory
|
||||
self.subscription_service = subscription_service
|
||||
self.referral_service = referral_service
|
||||
|
||||
self.base_url = (settings.PLATEGA_BASE_URL or "https://app.platega.io").rstrip("/")
|
||||
self.merchant_id = settings.PLATEGA_MERCHANT_ID
|
||||
self.secret = settings.PLATEGA_SECRET
|
||||
self.payment_method = settings.PLATEGA_PAYMENT_METHOD
|
||||
self.sbp_method = settings.platega_sbp_method_resolved
|
||||
self.crypto_method = settings.PLATEGA_CRYPTO_METHOD
|
||||
self.return_url = settings.PLATEGA_RETURN_URL or f"https://t.me/{default_return_url}"
|
||||
self.failed_url = settings.PLATEGA_FAILED_URL or self.return_url
|
||||
|
||||
self._timeout = ClientTimeout(total=20)
|
||||
self._session: Optional[ClientSession] = None
|
||||
self._auth_headers = {
|
||||
"X-MerchantId": self.merchant_id or "",
|
||||
"X-Secret": self.secret or "",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
self.configured: bool = bool(
|
||||
settings.PLATEGA_ENABLED and self.merchant_id and self.secret
|
||||
)
|
||||
if not self.configured:
|
||||
logging.warning("PlategaService initialized but not fully configured. Payments disabled.")
|
||||
else:
|
||||
logging.info(
|
||||
"PlategaService configured. SBP button: %s (method=%s), Crypto button: %s (method=%s)",
|
||||
"ON" if settings.PLATEGA_SBP_ENABLED else "OFF",
|
||||
self.sbp_method,
|
||||
"ON" if settings.PLATEGA_CRYPTO_ENABLED else "OFF",
|
||||
self.crypto_method,
|
||||
)
|
||||
|
||||
async def _get_session(self) -> ClientSession:
|
||||
if self._session is None or self._session.closed:
|
||||
self._session = ClientSession(timeout=self._timeout)
|
||||
return self._session
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._session and not self._session.closed:
|
||||
await self._session.close()
|
||||
|
||||
async def create_transaction(
|
||||
self,
|
||||
*,
|
||||
payment_db_id: int,
|
||||
user_id: int,
|
||||
months: int,
|
||||
amount: float,
|
||||
currency: Optional[str],
|
||||
description: str,
|
||||
payload: Optional[str] = None,
|
||||
payment_method: Optional[int] = None,
|
||||
) -> Tuple[bool, Dict[str, Any]]:
|
||||
if not self.configured:
|
||||
logging.error("PlategaService is not configured. Cannot create transaction.")
|
||||
return False, {"message": "service_not_configured"}
|
||||
|
||||
session = await self._get_session()
|
||||
url = f"{self.base_url}/transaction/process"
|
||||
currency_code = (currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
|
||||
method_id = int(payment_method if payment_method is not None else self.payment_method)
|
||||
|
||||
body: Dict[str, Any] = {
|
||||
"paymentMethod": method_id,
|
||||
"paymentDetails": {"amount": float(amount), "currency": currency_code},
|
||||
"description": description,
|
||||
"return": self.return_url,
|
||||
"failedUrl": self.failed_url,
|
||||
"payload": payload,
|
||||
}
|
||||
|
||||
# Remove optional keys with falsy values to avoid validation errors
|
||||
clean_body = {k: v for k, v in body.items() if v not in (None, "")}
|
||||
safe_headers = {
|
||||
"X-MerchantId": self._auth_headers.get("X-MerchantId"),
|
||||
"X-Secret": "***" if self._auth_headers.get("X-Secret") else "",
|
||||
"Content-Type": self._auth_headers.get("Content-Type"),
|
||||
}
|
||||
logging.info("Platega create_transaction request: url=%s headers=%s body=%s", url, safe_headers, clean_body)
|
||||
|
||||
try:
|
||||
async with session.post(url, json=clean_body, headers=self._auth_headers) as response:
|
||||
response_text = await response.text()
|
||||
try:
|
||||
response_data = json.loads(response_text) if response_text else {}
|
||||
except json.JSONDecodeError:
|
||||
logging.error("Platega create_transaction: invalid JSON response: %s", response_text)
|
||||
return False, {
|
||||
"status": response.status,
|
||||
"message": "invalid_json",
|
||||
"raw": response_text,
|
||||
}
|
||||
|
||||
if response.status != 200:
|
||||
logging.error(
|
||||
"Platega create_transaction: API returned error (status=%s, body=%s)",
|
||||
response.status,
|
||||
response_data,
|
||||
)
|
||||
return False, {"status": response.status, "message": response_data}
|
||||
|
||||
return True, response_data
|
||||
except Exception as exc:
|
||||
logging.exception("Platega create_transaction: request failed.")
|
||||
return False, {"message": str(exc)}
|
||||
|
||||
async def webhook_route(self, request: web.Request) -> web.Response:
|
||||
if not self.configured:
|
||||
return web.Response(status=503, text="platega_disabled")
|
||||
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
logging.exception("Platega webhook: failed to parse JSON.")
|
||||
return web.Response(status=400, text="bad_request")
|
||||
|
||||
header_merchant = request.headers.get("X-MerchantId")
|
||||
header_secret = request.headers.get("X-Secret")
|
||||
if not (
|
||||
hmac.compare_digest(str(header_merchant or ""), str(self.merchant_id or ""))
|
||||
and hmac.compare_digest(str(header_secret or ""), str(self.secret or ""))
|
||||
):
|
||||
logging.error("Platega webhook: invalid auth headers")
|
||||
return web.Response(status=403, text="forbidden")
|
||||
|
||||
transaction_id = str(data.get("id") or data.get("transactionId") or "").strip()
|
||||
status = str(data.get("status") or "").upper()
|
||||
amount_raw = data.get("amount")
|
||||
currency = data.get("currency") or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
|
||||
|
||||
if not transaction_id or not status:
|
||||
logging.error("Platega webhook: missing transaction id or status in payload: %s", data)
|
||||
return web.Response(status=400, text="missing_fields")
|
||||
|
||||
async with self.async_session_factory() as session:
|
||||
payment = await payment_dal.get_payment_by_provider_payment_id(session, transaction_id)
|
||||
if not payment:
|
||||
logging.error("Platega webhook: payment not found for transaction %s", transaction_id)
|
||||
return web.Response(status=404, text="payment_not_found")
|
||||
|
||||
if payment.status == "succeeded" and status == "CONFIRMED":
|
||||
return web.Response(text="ok")
|
||||
|
||||
payment_months = payment.subscription_duration_months or 1
|
||||
sale_mode = "traffic" if self.settings.traffic_sale_mode else "subscription"
|
||||
|
||||
if status == "CONFIRMED":
|
||||
if amount_raw is not None:
|
||||
try:
|
||||
incoming_amount = Decimal(str(amount_raw)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
expected_amount = Decimal(str(payment.amount)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
if incoming_amount != expected_amount:
|
||||
logging.warning(
|
||||
"Platega webhook: amount mismatch for payment %s (expected %s, got %s)",
|
||||
payment.payment_id,
|
||||
expected_amount,
|
||||
incoming_amount,
|
||||
)
|
||||
except Exception as exc:
|
||||
logging.warning("Platega webhook: failed to compare amounts for %s: %s", payment.payment_id, exc)
|
||||
|
||||
try:
|
||||
await payment_dal.update_provider_payment_and_status(
|
||||
session,
|
||||
payment.payment_id,
|
||||
transaction_id,
|
||||
"succeeded",
|
||||
)
|
||||
|
||||
activation = await self.subscription_service.activate_subscription(
|
||||
session,
|
||||
payment.user_id,
|
||||
int(payment_months) if sale_mode != "traffic" else 0,
|
||||
float(payment.amount),
|
||||
payment.payment_id,
|
||||
provider="platega",
|
||||
sale_mode=sale_mode,
|
||||
traffic_gb=payment_months if sale_mode == "traffic" else None,
|
||||
)
|
||||
|
||||
referral_bonus = None
|
||||
if sale_mode != "traffic":
|
||||
referral_bonus = await self.referral_service.apply_referral_bonuses_for_payment(
|
||||
session,
|
||||
payment.user_id,
|
||||
int(payment_months),
|
||||
current_payment_db_id=payment.payment_id,
|
||||
skip_if_active_before_payment=False,
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logging.exception("Platega webhook: failed to process payment %s.", transaction_id)
|
||||
return web.Response(status=500, text="processing_error")
|
||||
|
||||
db_user = await user_dal.get_user_by_id(session, payment.user_id)
|
||||
lang = db_user.language_code if db_user and db_user.language_code else self.settings.DEFAULT_LANGUAGE
|
||||
_ = lambda k, **kw: self.i18n.gettext(lang, k, **kw) if self.i18n else k
|
||||
|
||||
raw_config_link = activation.get("subscription_url") if activation else None
|
||||
config_link_display, connect_button_url = await prepare_config_links(self.settings, raw_config_link)
|
||||
config_link_text = config_link_display or _("config_link_not_available")
|
||||
final_end = activation.get("end_date") if activation else None
|
||||
applied_days = 0
|
||||
applied_promo_days = activation.get("applied_promo_bonus_days", 0) if activation else 0
|
||||
|
||||
if referral_bonus and referral_bonus.get("referee_new_end_date"):
|
||||
final_end = referral_bonus["referee_new_end_date"]
|
||||
applied_days = referral_bonus.get("referee_bonus_applied_days", 0)
|
||||
|
||||
traffic_label = str(int(payment_months)) if float(payment_months).is_integer() else f"{payment_months:g}"
|
||||
|
||||
if sale_mode == "traffic":
|
||||
text = _(
|
||||
"payment_successful_traffic_full",
|
||||
traffic_gb=traffic_label,
|
||||
end_date=final_end.strftime("%Y-%m-%d") if final_end else "",
|
||||
config_link=config_link_text,
|
||||
)
|
||||
elif applied_days:
|
||||
inviter_name_display = _("friend_placeholder")
|
||||
if db_user and db_user.referred_by_id:
|
||||
inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id)
|
||||
if inviter:
|
||||
safe_name = sanitize_display_name(inviter.first_name) if inviter.first_name else None
|
||||
if safe_name:
|
||||
inviter_name_display = safe_name
|
||||
elif inviter.username:
|
||||
inviter_name_display = username_for_display(inviter.username, with_at=False)
|
||||
|
||||
text = _(
|
||||
"payment_successful_with_referral_bonus_full",
|
||||
months=payment_months,
|
||||
base_end_date=activation["end_date"].strftime("%Y-%m-%d") if activation and activation.get("end_date") else final_end.strftime("%Y-%m-%d") if final_end else "",
|
||||
bonus_days=applied_days,
|
||||
final_end_date=final_end.strftime("%Y-%m-%d") if final_end else "",
|
||||
inviter_name=inviter_name_display,
|
||||
config_link=config_link_text,
|
||||
)
|
||||
elif applied_promo_days and final_end:
|
||||
text = _(
|
||||
"payment_successful_with_promo_full",
|
||||
months=payment_months,
|
||||
bonus_days=applied_promo_days,
|
||||
end_date=final_end.strftime("%Y-%m-%d"),
|
||||
config_link=config_link_text,
|
||||
)
|
||||
else:
|
||||
text = _(
|
||||
"payment_successful_full",
|
||||
months=payment_months,
|
||||
end_date=final_end.strftime("%Y-%m-%d") if final_end else "",
|
||||
config_link=config_link_text,
|
||||
)
|
||||
|
||||
markup = get_connect_and_main_keyboard(
|
||||
lang,
|
||||
self.i18n,
|
||||
self.settings,
|
||||
config_link_display,
|
||||
connect_button_url=connect_button_url,
|
||||
preserve_message=True,
|
||||
)
|
||||
try:
|
||||
await self.bot.send_message(
|
||||
payment.user_id,
|
||||
text,
|
||||
reply_markup=markup,
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
except Exception:
|
||||
logging.exception("Platega webhook: failed to notify user %s.", payment.user_id)
|
||||
|
||||
try:
|
||||
notification_service = NotificationService(self.bot, self.settings, self.i18n)
|
||||
await notification_service.notify_payment_received(
|
||||
user_id=payment.user_id,
|
||||
amount=float(payment.amount),
|
||||
currency=currency,
|
||||
months=int(payment_months) if sale_mode != "traffic" else 0,
|
||||
traffic_gb=payment_months if sale_mode == "traffic" else None,
|
||||
payment_provider="platega",
|
||||
username=db_user.username if db_user else None,
|
||||
)
|
||||
except Exception:
|
||||
logging.exception("Platega webhook: failed to notify admins.")
|
||||
|
||||
return web.Response(text="ok")
|
||||
|
||||
if status in {"CANCELED", "CANCELLED", "CHARGEBACKED"}:
|
||||
try:
|
||||
await payment_dal.update_provider_payment_and_status(
|
||||
session,
|
||||
payment.payment_id,
|
||||
transaction_id,
|
||||
"canceled",
|
||||
)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logging.exception("Platega webhook: failed to cancel payment %s.", transaction_id)
|
||||
return web.Response(status=500, text="processing_error")
|
||||
|
||||
db_user = await user_dal.get_user_by_id(session, payment.user_id)
|
||||
lang = db_user.language_code if db_user and db_user.language_code else self.settings.DEFAULT_LANGUAGE
|
||||
_ = lambda k, **kw: self.i18n.gettext(lang, k, **kw) if self.i18n else k
|
||||
try:
|
||||
await self.bot.send_message(payment.user_id, _("payment_failed"))
|
||||
except Exception:
|
||||
pass
|
||||
return web.Response(text="ok_canceled")
|
||||
|
||||
logging.warning("Platega webhook: unhandled status '%s' for transaction %s", status, transaction_id)
|
||||
return web.Response(status=202, text="status_ignored")
|
||||
|
||||
|
||||
async def platega_webhook_route(request: web.Request) -> web.Response:
|
||||
service: PlategaService = request.app["platega_service"]
|
||||
return await service.webhook_route(request)
|
||||
@@ -1,10 +1,12 @@
|
||||
import logging
|
||||
from html import escape as html_escape
|
||||
from datetime import datetime
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from typing import Optional, Tuple, Dict
|
||||
from aiogram import Bot
|
||||
|
||||
from config.settings import Settings
|
||||
from db.dal import security_dal
|
||||
|
||||
from db.dal import promo_code_dal, user_dal
|
||||
from db.models import PromoCode, User
|
||||
@@ -24,6 +26,9 @@ class PromoCodeService:
|
||||
self.bot = bot
|
||||
self.i18n = i18n
|
||||
|
||||
def _throttle_identifier(self, user_id: int) -> str:
|
||||
return f"user:{int(user_id)}"
|
||||
|
||||
async def apply_promo_code(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
@@ -32,19 +37,45 @@ class PromoCodeService:
|
||||
user_lang: str,
|
||||
) -> Tuple[bool, datetime | str]:
|
||||
_ = lambda k, **kw: self.i18n.gettext(user_lang, k, **kw)
|
||||
code_input_upper = code_input.strip().upper()
|
||||
code_input_upper = (code_input or "").strip().upper()[:100]
|
||||
code_display = html_escape(code_input_upper[:100], quote=False)
|
||||
throttle_identifier = self._throttle_identifier(user_id)
|
||||
|
||||
throttle = await security_dal.check_throttle(
|
||||
session,
|
||||
scope=security_dal.PROMO_CODE_APPLY_SCOPE,
|
||||
identifier=throttle_identifier,
|
||||
)
|
||||
if throttle.locked:
|
||||
return False, _(
|
||||
"promo_code_too_many_attempts",
|
||||
seconds=throttle.retry_after or max(1, int(self.settings.BRUTE_FORCE_LOCK_SECONDS)),
|
||||
)
|
||||
|
||||
promo_data = await promo_code_dal.get_active_promo_code_by_code_str(
|
||||
session, code_input_upper)
|
||||
|
||||
if not promo_data:
|
||||
return False, _("promo_code_not_found", code=code_input_upper)
|
||||
throttle_result = await security_dal.record_throttle_failure(
|
||||
session,
|
||||
scope=security_dal.PROMO_CODE_APPLY_SCOPE,
|
||||
identifier=throttle_identifier,
|
||||
max_failures=self.settings.BRUTE_FORCE_MAX_FAILURES,
|
||||
window_seconds=self.settings.BRUTE_FORCE_WINDOW_SECONDS,
|
||||
lock_seconds=self.settings.BRUTE_FORCE_LOCK_SECONDS,
|
||||
)
|
||||
if throttle_result.locked:
|
||||
return False, _(
|
||||
"promo_code_too_many_attempts",
|
||||
seconds=throttle_result.retry_after or max(1, int(self.settings.BRUTE_FORCE_LOCK_SECONDS)),
|
||||
)
|
||||
return False, _("promo_code_not_found", code=code_display)
|
||||
|
||||
existing_activation = await promo_code_dal.get_user_activation_for_promo(
|
||||
session, promo_data.promo_code_id, user_id)
|
||||
if existing_activation:
|
||||
return False, _("promo_code_already_used_by_user",
|
||||
code=code_input_upper)
|
||||
code=code_display)
|
||||
|
||||
bonus_days = promo_data.bonus_days
|
||||
|
||||
@@ -61,6 +92,11 @@ class PromoCodeService:
|
||||
session, promo_data.promo_code_id)
|
||||
|
||||
if activation_recorded and promo_incremented:
|
||||
await security_dal.clear_throttle_state(
|
||||
session,
|
||||
scope=security_dal.PROMO_CODE_APPLY_SCOPE,
|
||||
identifier=throttle_identifier,
|
||||
)
|
||||
# Send notification about promo activation
|
||||
try:
|
||||
notification_service = NotificationService(self.bot, self.settings, self.i18n)
|
||||
|
||||
@@ -177,6 +177,8 @@ class ReferralService:
|
||||
"ACTIVE_BONUS",
|
||||
"traffic_limit_bytes":
|
||||
self.settings.user_traffic_limit_bytes,
|
||||
"auto_renew_enabled":
|
||||
False,
|
||||
}
|
||||
try:
|
||||
await subscription_dal.deactivate_other_active_subscriptions(
|
||||
@@ -255,9 +257,35 @@ class ReferralService:
|
||||
|
||||
raise
|
||||
|
||||
def generate_referral_link(self, bot_username: str,
|
||||
inviter_user_id: int) -> str:
|
||||
return f"https://t.me/{bot_username}?start=ref_{inviter_user_id}"
|
||||
async def generate_referral_link(self, session: AsyncSession,
|
||||
bot_username: str,
|
||||
inviter_user_id: int) -> Optional[str]:
|
||||
try:
|
||||
user = await user_dal.get_user_by_id(session, inviter_user_id)
|
||||
if not user:
|
||||
logging.warning(
|
||||
"Unable to generate referral link: user %s not found.",
|
||||
inviter_user_id,
|
||||
)
|
||||
return None
|
||||
|
||||
referral_code = await user_dal.ensure_referral_code(session, user)
|
||||
if not referral_code:
|
||||
logging.warning(
|
||||
"User %s has no referral code even after regeneration attempt.",
|
||||
inviter_user_id,
|
||||
)
|
||||
return None
|
||||
|
||||
return f"https://t.me/{bot_username}?start=ref_u{referral_code}"
|
||||
except Exception as exc:
|
||||
logging.error(
|
||||
"Failed to generate referral link for user %s: %s",
|
||||
inviter_user_id,
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
return None
|
||||
|
||||
async def get_referral_stats(self, session: AsyncSession, user_id: int) -> dict:
|
||||
"""Get referral statistics for a user"""
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
import hmac
|
||||
import hashlib
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
from typing import Optional, Dict, Any, Tuple
|
||||
|
||||
from aiohttp import ClientSession, ClientTimeout, web
|
||||
from aiogram import Bot
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from config.settings import Settings
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.services.referral_service import ReferralService
|
||||
from bot.services.notification_service import NotificationService
|
||||
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
|
||||
from db.dal import payment_dal, user_dal
|
||||
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
|
||||
from bot.utils.config_link import prepare_config_links
|
||||
|
||||
|
||||
class SeverPayService:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
bot: Bot,
|
||||
settings: Settings,
|
||||
i18n: JsonI18n,
|
||||
async_session_factory: sessionmaker,
|
||||
subscription_service: SubscriptionService,
|
||||
referral_service: ReferralService,
|
||||
default_return_url: str,
|
||||
):
|
||||
self.bot = bot
|
||||
self.settings = settings
|
||||
self.i18n = i18n
|
||||
self.async_session_factory = async_session_factory
|
||||
self.subscription_service = subscription_service
|
||||
self.referral_service = referral_service
|
||||
|
||||
self.base_url = (settings.SEVERPAY_BASE_URL or "https://severpay.io/api/merchant").rstrip("/")
|
||||
self.mid = settings.SEVERPAY_MID
|
||||
self.token = settings.SEVERPAY_TOKEN or ""
|
||||
self.return_url = settings.SEVERPAY_RETURN_URL or f"https://t.me/{default_return_url}"
|
||||
self.lifetime_minutes = settings.SEVERPAY_LIFETIME_MINUTES
|
||||
|
||||
self._timeout = ClientTimeout(total=15)
|
||||
self._session: Optional[ClientSession] = None
|
||||
|
||||
self.configured: bool = bool(settings.SEVERPAY_ENABLED and self.mid and self.token)
|
||||
if not self.configured:
|
||||
logging.warning("SeverPayService initialized but not fully configured. Payments disabled.")
|
||||
|
||||
async def _get_session(self) -> ClientSession:
|
||||
if self._session is None or self._session.closed:
|
||||
self._session = ClientSession(timeout=self._timeout)
|
||||
return self._session
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._session and not self._session.closed:
|
||||
await self._session.close()
|
||||
|
||||
@staticmethod
|
||||
def _format_amount(amount: float) -> str:
|
||||
quantized = Decimal(str(amount)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
return f"{quantized:.2f}"
|
||||
|
||||
def _sign_payload(self, payload: Dict[str, Any]) -> str:
|
||||
message = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
||||
return hmac.new(self.token.encode("utf-8"), message.encode("utf-8"), hashlib.sha256).hexdigest()
|
||||
|
||||
def _build_signed_body(self, extra: Dict[str, Any]) -> Dict[str, Any]:
|
||||
body: Dict[str, Any] = {
|
||||
"mid": self.mid,
|
||||
"salt": secrets.token_hex(8),
|
||||
}
|
||||
body.update(extra)
|
||||
sorted_body = dict(sorted(body.items()))
|
||||
sorted_body["sign"] = self._sign_payload(sorted_body)
|
||||
return sorted_body
|
||||
|
||||
def _validate_signature(self, payload: Dict[str, Any]) -> bool:
|
||||
provided_sign = str(payload.get("sign") or "")
|
||||
if not provided_sign or not self.token:
|
||||
return False
|
||||
# Webhook signatures are calculated on the original payload order (without sorting).
|
||||
data = {k: v for k, v in payload.items() if k != "sign"}
|
||||
expected_sign = self._sign_payload(data)
|
||||
return hmac.compare_digest(provided_sign, expected_sign)
|
||||
|
||||
async def create_payment(
|
||||
self,
|
||||
*,
|
||||
payment_db_id: int,
|
||||
user_id: int,
|
||||
months: int,
|
||||
amount: float,
|
||||
currency: Optional[str],
|
||||
description: str,
|
||||
) -> Tuple[bool, Dict[str, Any]]:
|
||||
if not self.configured:
|
||||
logging.error("SeverPayService is not configured. Cannot create payment.")
|
||||
return False, {"message": "service_not_configured"}
|
||||
|
||||
session = await self._get_session()
|
||||
url = f"{self.base_url}/payin/create"
|
||||
currency_code = (currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
|
||||
amount_str = self._format_amount(amount)
|
||||
|
||||
body = {
|
||||
"order_id": str(payment_db_id),
|
||||
"amount": amount_str,
|
||||
"currency": currency_code,
|
||||
"client_email": f"{user_id}@telegram.org",
|
||||
"client_id": str(user_id),
|
||||
"url_return": self.return_url,
|
||||
}
|
||||
|
||||
if self.lifetime_minutes:
|
||||
body["lifetime"] = int(self.lifetime_minutes)
|
||||
|
||||
signed_body = self._build_signed_body(body)
|
||||
|
||||
try:
|
||||
async with session.post(url, json=signed_body) as response:
|
||||
response_text = await response.text()
|
||||
try:
|
||||
response_data = json.loads(response_text) if response_text else {}
|
||||
except json.JSONDecodeError:
|
||||
logging.error("SeverPay create_payment: invalid JSON response: %s", response_text)
|
||||
return False, {"status": response.status, "message": "invalid_json", "raw": response_text}
|
||||
|
||||
if response.status != 200 or not response_data.get("status"):
|
||||
logging.error(
|
||||
"SeverPay create_payment: API returned error (status=%s, body=%s)",
|
||||
response.status,
|
||||
response_data,
|
||||
)
|
||||
return False, {"status": response.status, "message": response_data}
|
||||
|
||||
return True, response_data.get("data") or response_data
|
||||
except Exception as exc:
|
||||
logging.exception("SeverPay create_payment: request failed.")
|
||||
return False, {"message": str(exc)}
|
||||
|
||||
async def webhook_route(self, request: web.Request) -> web.Response:
|
||||
if not self.configured:
|
||||
return web.json_response({"status": False, "msg": "severpay_disabled"}, status=503)
|
||||
|
||||
try:
|
||||
payload = await request.json()
|
||||
except Exception:
|
||||
logging.exception("SeverPay webhook: failed to parse JSON.")
|
||||
return web.json_response({"status": False, "msg": "bad_request"}, status=400)
|
||||
|
||||
if not isinstance(payload, dict) or not self._validate_signature(payload):
|
||||
logging.error("SeverPay webhook: invalid signature or payload.")
|
||||
return web.json_response({"status": False, "msg": "invalid_signature"}, status=403)
|
||||
|
||||
event_type = str(payload.get("type") or "").lower()
|
||||
data = payload.get("data") or {}
|
||||
|
||||
if event_type != "payin" or not isinstance(data, dict):
|
||||
logging.warning("SeverPay webhook: unsupported event type '%s'", event_type)
|
||||
return web.json_response({"status": True})
|
||||
|
||||
provider_payment_id = str(data.get("id") or data.get("uid") or "")
|
||||
order_id_raw = data.get("order_id")
|
||||
status = str(data.get("status") or "").lower()
|
||||
|
||||
payment_db_id: Optional[int] = None
|
||||
try:
|
||||
if isinstance(order_id_raw, int):
|
||||
payment_db_id = order_id_raw
|
||||
elif isinstance(order_id_raw, str) and order_id_raw.isdigit():
|
||||
payment_db_id = int(order_id_raw)
|
||||
except Exception:
|
||||
payment_db_id = None
|
||||
|
||||
async with self.async_session_factory() as session:
|
||||
payment = None
|
||||
if payment_db_id is not None:
|
||||
payment = await payment_dal.get_payment_by_db_id(session, payment_db_id)
|
||||
if not payment and provider_payment_id:
|
||||
payment = await payment_dal.get_payment_by_provider_payment_id(session, provider_payment_id)
|
||||
|
||||
if not payment:
|
||||
logging.error("SeverPay webhook: payment not found (order_id=%s, provider_id=%s)", order_id_raw, provider_payment_id)
|
||||
return web.json_response({"status": False, "msg": "payment_not_found"}, status=404)
|
||||
|
||||
payment_months = payment.subscription_duration_months or 1
|
||||
sale_mode = "traffic" if self.settings.traffic_sale_mode else "subscription"
|
||||
if status == "success":
|
||||
try:
|
||||
await payment_dal.update_provider_payment_and_status(
|
||||
session,
|
||||
payment.payment_id,
|
||||
provider_payment_id or str(payment.payment_id),
|
||||
"succeeded",
|
||||
)
|
||||
|
||||
activation = await self.subscription_service.activate_subscription(
|
||||
session,
|
||||
payment.user_id,
|
||||
int(payment_months) if sale_mode != "traffic" else 0,
|
||||
float(payment.amount),
|
||||
payment.payment_id,
|
||||
provider="severpay",
|
||||
sale_mode=sale_mode,
|
||||
traffic_gb=payment_months if sale_mode == "traffic" else None,
|
||||
)
|
||||
|
||||
referral_bonus = None
|
||||
if sale_mode != "traffic":
|
||||
referral_bonus = await self.referral_service.apply_referral_bonuses_for_payment(
|
||||
session,
|
||||
payment.user_id,
|
||||
int(payment_months),
|
||||
current_payment_db_id=payment.payment_id,
|
||||
skip_if_active_before_payment=False,
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logging.exception("SeverPay webhook: failed to process payment %s.", provider_payment_id)
|
||||
return web.json_response({"status": False, "msg": "processing_error"}, status=500)
|
||||
|
||||
db_user = payment.user or await user_dal.get_user_by_id(session, payment.user_id)
|
||||
lang = db_user.language_code if db_user and db_user.language_code else self.settings.DEFAULT_LANGUAGE
|
||||
_ = lambda k, **kw: self.i18n.gettext(lang, k, **kw) if self.i18n else k
|
||||
|
||||
raw_config_link = activation.get("subscription_url") if activation else None
|
||||
config_link_display, connect_button_url = await prepare_config_links(self.settings, raw_config_link)
|
||||
config_link_text = config_link_display or _("config_link_not_available")
|
||||
final_end = activation.get("end_date") if activation else None
|
||||
applied_days = 0
|
||||
applied_promo_days = activation.get("applied_promo_bonus_days", 0) if activation else 0
|
||||
|
||||
if referral_bonus and referral_bonus.get("referee_new_end_date"):
|
||||
final_end = referral_bonus["referee_new_end_date"]
|
||||
applied_days = referral_bonus.get("referee_bonus_applied_days", 0)
|
||||
|
||||
traffic_label = str(int(payment_months)) if float(payment_months).is_integer() else f"{payment_months:g}"
|
||||
|
||||
if sale_mode == "traffic":
|
||||
text = _(
|
||||
"payment_successful_traffic_full",
|
||||
traffic_gb=traffic_label,
|
||||
end_date=final_end.strftime("%Y-%m-%d") if final_end else "",
|
||||
config_link=config_link_text,
|
||||
)
|
||||
elif applied_days:
|
||||
inviter_name_display = _("friend_placeholder")
|
||||
if db_user and db_user.referred_by_id:
|
||||
inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id)
|
||||
if inviter:
|
||||
safe_name = sanitize_display_name(inviter.first_name) if inviter.first_name else None
|
||||
if safe_name:
|
||||
inviter_name_display = safe_name
|
||||
elif inviter.username:
|
||||
inviter_name_display = username_for_display(inviter.username, with_at=False)
|
||||
|
||||
text = _(
|
||||
"payment_successful_with_referral_bonus_full",
|
||||
months=payment_months,
|
||||
base_end_date=activation["end_date"].strftime("%Y-%m-%d") if activation and activation.get("end_date") else final_end.strftime("%Y-%m-%d") if final_end else "",
|
||||
bonus_days=applied_days,
|
||||
final_end_date=final_end.strftime("%Y-%m-%d") if final_end else "",
|
||||
inviter_name=inviter_name_display,
|
||||
config_link=config_link_text,
|
||||
)
|
||||
elif applied_promo_days and final_end:
|
||||
text = _(
|
||||
"payment_successful_with_promo_full",
|
||||
months=payment_months,
|
||||
bonus_days=applied_promo_days,
|
||||
end_date=final_end.strftime("%Y-%m-%d"),
|
||||
config_link=config_link_text,
|
||||
)
|
||||
else:
|
||||
text = _(
|
||||
"payment_successful_full",
|
||||
months=payment_months,
|
||||
end_date=final_end.strftime("%Y-%m-%d") if final_end else "",
|
||||
config_link=config_link_text,
|
||||
)
|
||||
|
||||
markup = get_connect_and_main_keyboard(
|
||||
lang,
|
||||
self.i18n,
|
||||
self.settings,
|
||||
config_link_display,
|
||||
connect_button_url=connect_button_url,
|
||||
preserve_message=True,
|
||||
)
|
||||
try:
|
||||
await self.bot.send_message(
|
||||
payment.user_id,
|
||||
text,
|
||||
reply_markup=markup,
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
except Exception:
|
||||
logging.exception("SeverPay webhook: failed to notify user %s.", payment.user_id)
|
||||
|
||||
try:
|
||||
notification_service = NotificationService(self.bot, self.settings, self.i18n)
|
||||
await notification_service.notify_payment_received(
|
||||
user_id=payment.user_id,
|
||||
amount=float(payment.amount),
|
||||
currency=payment.currency,
|
||||
months=int(payment_months) if sale_mode != "traffic" else 0,
|
||||
traffic_gb=payment_months if sale_mode == "traffic" else None,
|
||||
payment_provider="severpay",
|
||||
username=db_user.username if db_user else None,
|
||||
)
|
||||
except Exception:
|
||||
logging.exception("SeverPay webhook: failed to notify admins.")
|
||||
|
||||
return web.json_response({"status": True})
|
||||
|
||||
if status in {"fail", "decline"}:
|
||||
try:
|
||||
await payment_dal.update_provider_payment_and_status(
|
||||
session,
|
||||
payment.payment_id,
|
||||
provider_payment_id or str(payment.payment_id),
|
||||
"failed",
|
||||
)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logging.exception("SeverPay webhook: failed to mark payment %s as failed.", provider_payment_id)
|
||||
return web.json_response({"status": False, "msg": "processing_error"}, status=500)
|
||||
|
||||
db_user = payment.user or await user_dal.get_user_by_id(session, payment.user_id)
|
||||
lang = db_user.language_code if db_user and db_user.language_code else self.settings.DEFAULT_LANGUAGE
|
||||
_ = lambda k, **kw: self.i18n.gettext(lang, k, **kw) if self.i18n else k
|
||||
try:
|
||||
await self.bot.send_message(payment.user_id, _("payment_failed"))
|
||||
except Exception:
|
||||
pass
|
||||
return web.json_response({"status": True})
|
||||
|
||||
if status in {"process", "new"}:
|
||||
try:
|
||||
await payment_dal.update_provider_payment_and_status(
|
||||
session,
|
||||
payment.payment_id,
|
||||
provider_payment_id or str(payment.payment_id),
|
||||
"pending_severpay",
|
||||
)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logging.exception("SeverPay webhook: failed to update pending status for %s.", provider_payment_id)
|
||||
return web.json_response({"status": True})
|
||||
|
||||
logging.warning("SeverPay webhook: unhandled status '%s' for payment %s", status, provider_payment_id)
|
||||
return web.json_response({"status": True})
|
||||
|
||||
|
||||
async def severpay_webhook_route(request: web.Request) -> web.Response:
|
||||
service: SeverPayService = request.app["severpay_service"]
|
||||
return await service.webhook_route(request)
|
||||
@@ -13,6 +13,7 @@ from bot.middlewares.i18n import JsonI18n
|
||||
from .notification_service import NotificationService
|
||||
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
|
||||
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
|
||||
from bot.utils.config_link import prepare_config_links
|
||||
|
||||
|
||||
class StarsService:
|
||||
@@ -26,14 +27,14 @@ class StarsService:
|
||||
self.referral_service = referral_service
|
||||
|
||||
async def create_invoice(self, session: AsyncSession, user_id: int, months: int,
|
||||
stars_price: int, description: str) -> Optional[int]:
|
||||
stars_price: int, description: str, sale_mode: str = "subscription") -> Optional[int]:
|
||||
payment_record_data = {
|
||||
"user_id": user_id,
|
||||
"amount": float(stars_price),
|
||||
"currency": "XTR",
|
||||
"status": "pending_stars",
|
||||
"description": description,
|
||||
"subscription_duration_months": months,
|
||||
"subscription_duration_months": int(months),
|
||||
"provider": "telegram_stars",
|
||||
}
|
||||
try:
|
||||
@@ -46,7 +47,7 @@ class StarsService:
|
||||
exc_info=True)
|
||||
return None
|
||||
|
||||
payload = f"{db_payment_record.payment_id}:{months}"
|
||||
payload = f"{db_payment_record.payment_id}:{months}:{sale_mode}"
|
||||
prices = [LabeledPrice(label=description, amount=stars_price)]
|
||||
try:
|
||||
await self.bot.send_invoice(
|
||||
@@ -54,7 +55,7 @@ class StarsService:
|
||||
title=description,
|
||||
description=description,
|
||||
payload=payload,
|
||||
provider_token="",
|
||||
provider_token="", # Required to be empty for Telegram Stars (XTR) per Telegram Bot API.
|
||||
currency="XTR",
|
||||
prices=prices,
|
||||
)
|
||||
@@ -69,7 +70,8 @@ class StarsService:
|
||||
payment_db_id: int,
|
||||
months: int,
|
||||
stars_amount: int,
|
||||
i18n_data: dict) -> None:
|
||||
i18n_data: dict,
|
||||
sale_mode: str = "subscription") -> None:
|
||||
try:
|
||||
await payment_dal.update_provider_payment_and_status(
|
||||
session, payment_db_id,
|
||||
@@ -86,23 +88,27 @@ class StarsService:
|
||||
activation_details = await self.subscription_service.activate_subscription(
|
||||
session,
|
||||
message.from_user.id,
|
||||
months,
|
||||
int(months) if sale_mode != "traffic" else 0,
|
||||
float(stars_amount),
|
||||
payment_db_id,
|
||||
provider="telegram_stars",
|
||||
sale_mode=sale_mode,
|
||||
traffic_gb=months if sale_mode == "traffic" else None,
|
||||
)
|
||||
if not activation_details or not activation_details.get("end_date"):
|
||||
logging.error(
|
||||
f"Failed to activate subscription after stars payment for user {message.from_user.id}")
|
||||
return
|
||||
|
||||
referral_bonus = await self.referral_service.apply_referral_bonuses_for_payment(
|
||||
session,
|
||||
message.from_user.id,
|
||||
months,
|
||||
current_payment_db_id=payment_db_id,
|
||||
skip_if_active_before_payment=False,
|
||||
)
|
||||
referral_bonus = None
|
||||
if sale_mode != "traffic":
|
||||
referral_bonus = await self.referral_service.apply_referral_bonuses_for_payment(
|
||||
session,
|
||||
message.from_user.id,
|
||||
int(months) or 1,
|
||||
current_payment_db_id=payment_db_id,
|
||||
skip_if_active_before_payment=False,
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
applied_days = referral_bonus.get("referee_bonus_applied_days") if referral_bonus else None
|
||||
@@ -116,11 +122,18 @@ class StarsService:
|
||||
i18n: JsonI18n = i18n_data.get("i18n_instance")
|
||||
_ = lambda k, **kw: i18n.gettext(current_lang, k, **kw) if i18n else k
|
||||
|
||||
config_link = activation_details.get("subscription_url") or _(
|
||||
"config_link_not_available"
|
||||
)
|
||||
raw_config_link = activation_details.get("subscription_url") if activation_details else None
|
||||
config_link_display, connect_button_url = await prepare_config_links(self.settings, raw_config_link)
|
||||
config_link_text = config_link_display or _("config_link_not_available")
|
||||
|
||||
if applied_days:
|
||||
if sale_mode == "traffic":
|
||||
success_msg = _(
|
||||
"payment_successful_traffic_full",
|
||||
traffic_gb=str(int(months)) if float(months).is_integer() else f"{months:g}",
|
||||
end_date=final_end.strftime('%Y-%m-%d'),
|
||||
config_link=config_link_text,
|
||||
)
|
||||
elif applied_days:
|
||||
inviter_name_display = _("friend_placeholder")
|
||||
db_user = await user_dal.get_user_by_id(session, message.from_user.id)
|
||||
if db_user and db_user.referred_by_id:
|
||||
@@ -138,17 +151,22 @@ class StarsService:
|
||||
bonus_days=applied_days,
|
||||
final_end_date=final_end.strftime('%Y-%m-%d'),
|
||||
inviter_name=inviter_name_display,
|
||||
config_link=config_link,
|
||||
config_link=config_link_text,
|
||||
)
|
||||
else:
|
||||
success_msg = _(
|
||||
"payment_successful_full",
|
||||
months=months,
|
||||
end_date=final_end.strftime('%Y-%m-%d'),
|
||||
config_link=config_link,
|
||||
config_link=config_link_text,
|
||||
)
|
||||
markup = get_connect_and_main_keyboard(
|
||||
current_lang, i18n, self.settings, config_link, preserve_message=True
|
||||
current_lang,
|
||||
i18n,
|
||||
self.settings,
|
||||
config_link_display,
|
||||
connect_button_url=connect_button_url,
|
||||
preserve_message=True,
|
||||
)
|
||||
try:
|
||||
await self.bot.send_message(
|
||||
@@ -170,9 +188,10 @@ class StarsService:
|
||||
user_id=message.from_user.id,
|
||||
amount=float(stars_amount),
|
||||
currency="XTR",
|
||||
months=months,
|
||||
months=int(months) if sale_mode != "traffic" else 0,
|
||||
payment_provider="stars",
|
||||
username=user.username if user else None
|
||||
username=user.username if user else None,
|
||||
traffic_gb=months if sale_mode == "traffic" else None,
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to send stars payment notification: {e}")
|
||||
|
||||
@@ -7,10 +7,13 @@ from bot.middlewares.i18n import JsonI18n
|
||||
|
||||
from db.dal import user_dal, subscription_dal, promo_code_dal, payment_dal, user_billing_dal
|
||||
from bot.utils.date_utils import add_months
|
||||
from bot.utils.config_link import prepare_config_links
|
||||
from db.models import User, Subscription
|
||||
|
||||
from config.settings import Settings
|
||||
from .panel_api_service import PanelApiService
|
||||
from .email_auth_service import EmailAuthService
|
||||
from .email_templates import render_payment_success
|
||||
|
||||
|
||||
class SubscriptionService:
|
||||
@@ -54,6 +57,33 @@ class SubscriptionService:
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _extract_panel_traffic_details(
|
||||
self, panel_user_data: Dict[str, Any]
|
||||
) -> Tuple[Optional[int], Optional[int], Optional[str]]:
|
||||
traffic_stats = panel_user_data.get("userTraffic") or {}
|
||||
used = traffic_stats.get("usedTrafficBytes")
|
||||
if used is None:
|
||||
used = panel_user_data.get("usedTrafficBytes")
|
||||
limit = panel_user_data.get("trafficLimitBytes")
|
||||
strategy = panel_user_data.get("trafficLimitStrategy")
|
||||
if strategy is None:
|
||||
strategy = traffic_stats.get("trafficLimitStrategy")
|
||||
return used, limit, strategy
|
||||
|
||||
def _extract_lifetime_used_traffic(
|
||||
self, panel_user_data: Dict[str, Any]
|
||||
) -> Optional[int]:
|
||||
traffic_stats = panel_user_data.get("userTraffic") or {}
|
||||
lifetime = traffic_stats.get("lifetimeUsedTrafficBytes")
|
||||
if lifetime is None:
|
||||
lifetime = panel_user_data.get("lifetimeUsedTrafficBytes")
|
||||
try:
|
||||
if lifetime is None:
|
||||
return None
|
||||
return int(lifetime)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
async def _notify_admin_panel_user_creation_failed(self, user_id: int):
|
||||
if not self.bot or not self.i18n or not self.settings.ADMIN_IDS:
|
||||
return
|
||||
@@ -68,6 +98,42 @@ class SubscriptionService:
|
||||
f"Failed to notify admin {admin_id} about panel user creation failure: {e}"
|
||||
)
|
||||
|
||||
def _telegram_id_for_panel(self, db_user: User) -> Optional[int]:
|
||||
if db_user.telegram_id:
|
||||
return int(db_user.telegram_id)
|
||||
if db_user.user_id and int(db_user.user_id) > 0:
|
||||
return int(db_user.user_id)
|
||||
return None
|
||||
|
||||
async def _panel_username_for_user(
|
||||
self, session: AsyncSession, db_user: User
|
||||
) -> str:
|
||||
telegram_id = self._telegram_id_for_panel(db_user)
|
||||
if telegram_id and int(db_user.user_id) == telegram_id:
|
||||
return f"tg_{telegram_id}"
|
||||
referral_code = await user_dal.ensure_referral_code(session, db_user)
|
||||
return f"em_{referral_code}"
|
||||
|
||||
def _panel_description_for_user(self, db_user: User) -> str:
|
||||
lines = [
|
||||
db_user.email or "",
|
||||
db_user.username or "",
|
||||
db_user.first_name or "",
|
||||
db_user.last_name or "",
|
||||
]
|
||||
return "\n".join(line for line in lines if line).strip()
|
||||
|
||||
def _panel_identity_payload_for_user(self, db_user: User) -> Dict[str, Any]:
|
||||
payload: Dict[str, Any] = {
|
||||
"description": self._panel_description_for_user(db_user),
|
||||
}
|
||||
telegram_id = self._telegram_id_for_panel(db_user)
|
||||
if telegram_id:
|
||||
payload["telegramId"] = telegram_id
|
||||
if db_user.email:
|
||||
payload["email"] = db_user.email
|
||||
return payload
|
||||
|
||||
async def _get_or_create_panel_user_link_details(
|
||||
self, session: AsyncSession, user_id: int, db_user: Optional[User] = None
|
||||
) -> Tuple[Optional[str], Optional[str], Optional[str], bool]:
|
||||
@@ -81,25 +147,45 @@ class SubscriptionService:
|
||||
return None, None, None, False
|
||||
|
||||
current_local_panel_uuid = db_user.panel_user_uuid
|
||||
panel_username_on_panel_standard = f"tg_{user_id}"
|
||||
panel_username_on_panel_standard = await self._panel_username_for_user(
|
||||
session, db_user
|
||||
)
|
||||
telegram_id_for_panel = self._telegram_id_for_panel(db_user)
|
||||
|
||||
panel_user_obj_from_api = None
|
||||
panel_user_created_or_linked_now = False
|
||||
|
||||
panel_users_by_tg_id_list = await self.panel_service.get_users_by_filter(
|
||||
telegram_id=user_id
|
||||
)
|
||||
panel_users_by_tg_id_list = None
|
||||
if telegram_id_for_panel:
|
||||
panel_users_by_tg_id_list = await self.panel_service.get_users_by_filter(
|
||||
telegram_id=telegram_id_for_panel
|
||||
)
|
||||
if panel_users_by_tg_id_list and len(panel_users_by_tg_id_list) == 1:
|
||||
panel_user_obj_from_api = panel_users_by_tg_id_list[0]
|
||||
logging.info(
|
||||
f"Found panel user by telegramId {user_id}: UUID {panel_user_obj_from_api.get('uuid')}, Username: {panel_user_obj_from_api.get('username')}"
|
||||
f"Found panel user by telegramId {telegram_id_for_panel}: UUID {panel_user_obj_from_api.get('uuid')}, Username: {panel_user_obj_from_api.get('username')}"
|
||||
)
|
||||
elif panel_users_by_tg_id_list and len(panel_users_by_tg_id_list) > 1:
|
||||
logging.error(
|
||||
f"CRITICAL: Multiple panel users found for telegramId {user_id}. Manual intervention needed."
|
||||
f"CRITICAL: Multiple panel users found for telegramId {telegram_id_for_panel}. Manual intervention needed."
|
||||
)
|
||||
return None, None, None, False
|
||||
|
||||
if not panel_user_obj_from_api and db_user.email:
|
||||
panel_users_by_email_list = await self.panel_service.get_users_by_filter(
|
||||
email=db_user.email
|
||||
)
|
||||
if panel_users_by_email_list and len(panel_users_by_email_list) == 1:
|
||||
panel_user_obj_from_api = panel_users_by_email_list[0]
|
||||
logging.info(
|
||||
f"Found panel user by email {db_user.email}: UUID {panel_user_obj_from_api.get('uuid')}, Username: {panel_user_obj_from_api.get('username')}"
|
||||
)
|
||||
elif panel_users_by_email_list and len(panel_users_by_email_list) > 1:
|
||||
logging.error(
|
||||
f"CRITICAL: Multiple panel users found for email {db_user.email}. Manual intervention needed."
|
||||
)
|
||||
return None, None, None, False
|
||||
|
||||
if not panel_user_obj_from_api:
|
||||
if current_local_panel_uuid:
|
||||
|
||||
@@ -118,13 +204,11 @@ class SubscriptionService:
|
||||
)
|
||||
creation_response = await self.panel_service.create_panel_user(
|
||||
username_on_panel=panel_username_on_panel_standard,
|
||||
telegram_id=user_id,
|
||||
description="\n".join([
|
||||
(db_user.username or "") if db_user else "",
|
||||
(db_user.first_name or "") if db_user else "",
|
||||
(db_user.last_name or "") if db_user else "",
|
||||
]),
|
||||
telegram_id=telegram_id_for_panel,
|
||||
email=db_user.email,
|
||||
description=self._panel_description_for_user(db_user),
|
||||
specific_squad_uuids=self.settings.parsed_user_squad_uuids,
|
||||
external_squad_uuid=self.settings.parsed_user_external_squad_uuid,
|
||||
default_traffic_limit_bytes=self.settings.user_traffic_limit_bytes,
|
||||
default_traffic_limit_strategy=self.settings.USER_TRAFFIC_STRATEGY,
|
||||
)
|
||||
@@ -146,13 +230,11 @@ class SubscriptionService:
|
||||
)
|
||||
creation_response = await self.panel_service.create_panel_user(
|
||||
username_on_panel=panel_username_on_panel_standard,
|
||||
telegram_id=user_id,
|
||||
description="\n".join([
|
||||
(db_user.username or "") if db_user else "",
|
||||
(db_user.first_name or "") if db_user else "",
|
||||
(db_user.last_name or "") if db_user else "",
|
||||
]),
|
||||
telegram_id=telegram_id_for_panel,
|
||||
email=db_user.email,
|
||||
description=self._panel_description_for_user(db_user),
|
||||
specific_squad_uuids=self.settings.parsed_user_squad_uuids,
|
||||
external_squad_uuid=self.settings.parsed_user_external_squad_uuid,
|
||||
default_traffic_limit_bytes=self.settings.user_traffic_limit_bytes,
|
||||
default_traffic_limit_strategy=self.settings.USER_TRAFFIC_STRATEGY,
|
||||
)
|
||||
@@ -196,7 +278,6 @@ class SubscriptionService:
|
||||
)
|
||||
|
||||
actual_panel_uuid_from_api = panel_user_obj_from_api.get("uuid")
|
||||
actual_panel_username_from_api = panel_user_obj_from_api.get("username")
|
||||
panel_telegram_id_from_api = panel_user_obj_from_api.get("telegramId")
|
||||
|
||||
if not actual_panel_uuid_from_api:
|
||||
@@ -263,24 +344,15 @@ class SubscriptionService:
|
||||
if (
|
||||
panel_user_obj_from_api
|
||||
and current_local_panel_uuid
|
||||
and panel_telegram_id_int != user_id
|
||||
and telegram_id_for_panel
|
||||
and panel_telegram_id_int != telegram_id_for_panel
|
||||
):
|
||||
logging.info(
|
||||
f"Panel user {current_local_panel_uuid} has telegramId '{panel_telegram_id_from_api}'. Updating on panel to '{user_id}'."
|
||||
f"Panel user {current_local_panel_uuid} has telegramId '{panel_telegram_id_from_api}'. Updating on panel to '{telegram_id_for_panel}'."
|
||||
)
|
||||
# Also set readable description with Telegram fields
|
||||
await self.panel_service.update_user_details_on_panel(
|
||||
current_local_panel_uuid,
|
||||
{
|
||||
"telegramId": user_id,
|
||||
"description": "\n".join(
|
||||
[
|
||||
(db_user.username or "") if db_user else "",
|
||||
(db_user.first_name or "") if db_user else "",
|
||||
(db_user.last_name or "") if db_user else "",
|
||||
]
|
||||
),
|
||||
},
|
||||
self._panel_identity_payload_for_user(db_user),
|
||||
)
|
||||
|
||||
panel_sub_link_id = panel_user_obj_from_api.get(
|
||||
@@ -355,6 +427,7 @@ class SubscriptionService:
|
||||
"is_active": True,
|
||||
"status_from_panel": "TRIAL",
|
||||
"traffic_limit_bytes": self.settings.trial_traffic_limit_bytes,
|
||||
"traffic_limit_strategy": self.settings.TRIAL_TRAFFIC_STRATEGY,
|
||||
"auto_renew_enabled": False,
|
||||
}
|
||||
try:
|
||||
@@ -376,16 +449,10 @@ class SubscriptionService:
|
||||
expire_at=end_date,
|
||||
status="ACTIVE",
|
||||
traffic_limit_bytes=self.settings.trial_traffic_limit_bytes,
|
||||
traffic_limit_strategy=self.settings.TRIAL_TRAFFIC_STRATEGY,
|
||||
)
|
||||
|
||||
# Add user description based on Telegram profile
|
||||
panel_update_payload["description"] = "\n".join(
|
||||
[
|
||||
(db_user.username or "") if db_user else "",
|
||||
(db_user.first_name or "") if db_user else "",
|
||||
(db_user.last_name or "") if db_user else "",
|
||||
]
|
||||
)
|
||||
panel_update_payload.update(self._panel_identity_payload_for_user(db_user))
|
||||
|
||||
updated_panel_user = await self.panel_service.update_user_details_on_panel(
|
||||
panel_user_uuid, panel_update_payload
|
||||
@@ -417,6 +484,121 @@ class SubscriptionService:
|
||||
"subscription_url": final_subscription_url,
|
||||
}
|
||||
|
||||
async def _activate_traffic_package(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
traffic_gb: float,
|
||||
payment_amount: float,
|
||||
payment_db_id: int,
|
||||
provider: str = "yookassa",
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Activate or extend a traffic-based package instead of a time-based subscription."""
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
if not db_user:
|
||||
logging.error("User %s not found for traffic package activation", user_id)
|
||||
return None
|
||||
|
||||
panel_user_uuid, panel_sub_link_id, panel_short_uuid, _ = (
|
||||
await self._get_or_create_panel_user_link_details(session, user_id, db_user)
|
||||
)
|
||||
|
||||
if not panel_user_uuid or not panel_sub_link_id:
|
||||
logging.error("Failed to ensure panel linkage for user %s during traffic activation", user_id)
|
||||
return None
|
||||
|
||||
panel_user_data = await self.panel_service.get_user_by_uuid(panel_user_uuid) or {}
|
||||
current_used, current_limit, _ = self._extract_panel_traffic_details(panel_user_data)
|
||||
|
||||
active_sub = await subscription_dal.get_active_subscription_by_user_id(
|
||||
session, user_id, panel_user_uuid
|
||||
)
|
||||
if current_limit is None and active_sub:
|
||||
current_limit = active_sub.traffic_limit_bytes
|
||||
if current_used is None and active_sub:
|
||||
current_used = active_sub.traffic_used_bytes
|
||||
|
||||
purchase_bytes = int(float(traffic_gb) * (1024**3))
|
||||
new_limit = (current_limit or 0) + purchase_bytes
|
||||
|
||||
start_date = datetime.now(timezone.utc)
|
||||
# Set a far-future expiry to satisfy panel requirements; keep the latest known expiry if it's further.
|
||||
far_future = datetime(2099, 1, 1, tzinfo=timezone.utc)
|
||||
final_end_date = far_future
|
||||
if active_sub and active_sub.end_date and active_sub.end_date > final_end_date:
|
||||
final_end_date = active_sub.end_date
|
||||
|
||||
await subscription_dal.deactivate_other_active_subscriptions(
|
||||
session, panel_user_uuid, panel_sub_link_id
|
||||
)
|
||||
|
||||
sub_payload = {
|
||||
"user_id": user_id,
|
||||
"panel_user_uuid": panel_user_uuid,
|
||||
"panel_subscription_uuid": panel_sub_link_id,
|
||||
"start_date": start_date,
|
||||
"end_date": final_end_date,
|
||||
"duration_months": 0,
|
||||
"is_active": True,
|
||||
"status_from_panel": "ACTIVE",
|
||||
"traffic_limit_bytes": new_limit,
|
||||
"traffic_used_bytes": current_used,
|
||||
"provider": provider,
|
||||
"skip_notifications": True,
|
||||
"auto_renew_enabled": False,
|
||||
}
|
||||
|
||||
try:
|
||||
new_or_updated_sub = await subscription_dal.upsert_subscription(session, sub_payload)
|
||||
except Exception as exc:
|
||||
logging.error("Failed to upsert traffic subscription for user %s: %s", user_id, exc, exc_info=True)
|
||||
return None
|
||||
|
||||
panel_update_payload = self._build_panel_update_payload(
|
||||
panel_user_uuid=panel_user_uuid,
|
||||
expire_at=final_end_date,
|
||||
status="ACTIVE",
|
||||
traffic_limit_bytes=new_limit,
|
||||
traffic_limit_strategy="NO_RESET",
|
||||
)
|
||||
|
||||
panel_update_payload.update(self._panel_identity_payload_for_user(db_user))
|
||||
|
||||
updated_panel_user = await self.panel_service.update_user_details_on_panel(
|
||||
panel_user_uuid, panel_update_payload
|
||||
)
|
||||
if not updated_panel_user or updated_panel_user.get("error"):
|
||||
logging.warning(
|
||||
"Panel user details update FAILED for traffic package user %s. Response: %s",
|
||||
panel_user_uuid,
|
||||
updated_panel_user,
|
||||
)
|
||||
return None
|
||||
|
||||
final_subscription_url = updated_panel_user.get("subscriptionUrl")
|
||||
final_panel_short_uuid = updated_panel_user.get("shortUuid", panel_short_uuid)
|
||||
|
||||
await self._send_payment_success_email(
|
||||
db_user=db_user,
|
||||
sale_mode="traffic",
|
||||
months=0,
|
||||
traffic_gb=float(traffic_gb),
|
||||
payment_amount=payment_amount,
|
||||
end_date=None,
|
||||
provider=provider,
|
||||
)
|
||||
|
||||
return {
|
||||
"subscription_id": new_or_updated_sub.subscription_id,
|
||||
"end_date": final_end_date,
|
||||
"is_active": True,
|
||||
"panel_user_uuid": panel_user_uuid,
|
||||
"panel_short_uuid": final_panel_short_uuid,
|
||||
"subscription_url": final_subscription_url,
|
||||
"applied_promo_bonus_days": 0,
|
||||
"traffic_limit_bytes": new_limit,
|
||||
}
|
||||
|
||||
async def activate_subscription(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
@@ -426,8 +608,21 @@ class SubscriptionService:
|
||||
payment_db_id: int,
|
||||
promo_code_id_from_payment: Optional[int] = None,
|
||||
provider: str = "yookassa",
|
||||
sale_mode: str = "subscription",
|
||||
traffic_gb: Optional[float] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
|
||||
if sale_mode == "traffic" or getattr(self.settings, "traffic_sale_mode", False):
|
||||
target_gb = traffic_gb if traffic_gb is not None else float(months)
|
||||
return await self._activate_traffic_package(
|
||||
session=session,
|
||||
user_id=user_id,
|
||||
traffic_gb=target_gb,
|
||||
payment_amount=payment_amount,
|
||||
payment_db_id=payment_db_id,
|
||||
provider=provider,
|
||||
)
|
||||
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
if not db_user:
|
||||
logging.error(
|
||||
@@ -445,6 +640,11 @@ class SubscriptionService:
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
months_int = int(months)
|
||||
except Exception:
|
||||
months_int = 1
|
||||
|
||||
current_active_sub = await subscription_dal.get_active_subscription_by_user_id(
|
||||
session, user_id, panel_user_uuid
|
||||
)
|
||||
@@ -457,7 +657,7 @@ class SubscriptionService:
|
||||
start_date = current_active_sub.end_date
|
||||
|
||||
# base duration by months
|
||||
end_after_months = add_months(start_date, months)
|
||||
end_after_months = add_months(start_date, months_int)
|
||||
duration_days_total = (end_after_months - start_date).days
|
||||
applied_promo_bonus_days = 0
|
||||
|
||||
@@ -498,19 +698,25 @@ class SubscriptionService:
|
||||
session, panel_user_uuid, panel_sub_link_id
|
||||
)
|
||||
|
||||
auto_renew_should_enable = False
|
||||
if provider == "yookassa" and self.settings.yookassa_autopayments_active:
|
||||
auto_renew_should_enable = await user_billing_dal.user_has_saved_payment_method(
|
||||
session, user_id
|
||||
)
|
||||
|
||||
sub_payload = {
|
||||
"user_id": user_id,
|
||||
"panel_user_uuid": panel_user_uuid,
|
||||
"panel_subscription_uuid": panel_sub_link_id,
|
||||
"start_date": start_date,
|
||||
"end_date": final_end_date,
|
||||
"duration_months": months,
|
||||
"duration_months": months_int,
|
||||
"is_active": True,
|
||||
"status_from_panel": "ACTIVE",
|
||||
"traffic_limit_bytes": self.settings.user_traffic_limit_bytes,
|
||||
"provider": provider,
|
||||
"skip_notifications": provider == "tribute" and self.settings.TRIBUTE_SKIP_NOTIFICATIONS,
|
||||
"auto_renew_enabled": True,
|
||||
"skip_notifications": False,
|
||||
"auto_renew_enabled": auto_renew_should_enable,
|
||||
}
|
||||
try:
|
||||
new_or_updated_sub = await subscription_dal.upsert_subscription(
|
||||
@@ -530,14 +736,7 @@ class SubscriptionService:
|
||||
traffic_limit_bytes=self.settings.user_traffic_limit_bytes,
|
||||
)
|
||||
|
||||
# Add user description based on Telegram profile
|
||||
panel_update_payload["description"] = "\n".join(
|
||||
[
|
||||
(db_user.username or "") if db_user else "",
|
||||
(db_user.first_name or "") if db_user else "",
|
||||
(db_user.last_name or "") if db_user else "",
|
||||
]
|
||||
)
|
||||
panel_update_payload.update(self._panel_identity_payload_for_user(db_user))
|
||||
|
||||
updated_panel_user = await self.panel_service.update_user_details_on_panel(
|
||||
panel_user_uuid, panel_update_payload
|
||||
@@ -551,6 +750,16 @@ class SubscriptionService:
|
||||
final_subscription_url = updated_panel_user.get("subscriptionUrl")
|
||||
final_panel_short_uuid = updated_panel_user.get("shortUuid", panel_short_uuid)
|
||||
|
||||
await self._send_payment_success_email(
|
||||
db_user=db_user,
|
||||
sale_mode="subscription",
|
||||
months=months_int,
|
||||
traffic_gb=None,
|
||||
payment_amount=payment_amount,
|
||||
end_date=final_end_date,
|
||||
provider=provider,
|
||||
)
|
||||
|
||||
return {
|
||||
"subscription_id": new_or_updated_sub.subscription_id,
|
||||
"end_date": final_end_date,
|
||||
@@ -616,6 +825,7 @@ class SubscriptionService:
|
||||
"is_active": True,
|
||||
"status_from_panel": "ACTIVE_BONUS",
|
||||
"traffic_limit_bytes": traffic_limit,
|
||||
"auto_renew_enabled": False,
|
||||
}
|
||||
await subscription_dal.deactivate_other_active_subscriptions(
|
||||
session, panel_uuid, panel_sub_uuid
|
||||
@@ -701,12 +911,22 @@ class SubscriptionService:
|
||||
await user_dal.update_user(session, user_id, {"panel_user_uuid": None})
|
||||
return None
|
||||
|
||||
panel_lifetime_used = self._extract_lifetime_used_traffic(panel_user_data)
|
||||
if (
|
||||
panel_lifetime_used is not None
|
||||
and db_user.lifetime_used_traffic_bytes != panel_lifetime_used
|
||||
):
|
||||
await user_dal.update_user(
|
||||
session,
|
||||
user_id,
|
||||
{"lifetime_used_traffic_bytes": panel_lifetime_used},
|
||||
)
|
||||
|
||||
if local_active_sub:
|
||||
update_payload_local = {}
|
||||
panel_status = panel_user_data.get("status", "UNKNOWN").upper()
|
||||
panel_expire_at_str = panel_user_data.get("expireAt")
|
||||
panel_traffic_used = panel_user_data.get("usedTrafficBytes")
|
||||
panel_traffic_limit = panel_user_data.get("trafficLimitBytes")
|
||||
panel_traffic_used, panel_traffic_limit, _ = self._extract_panel_traffic_details(panel_user_data)
|
||||
panel_sub_uuid_from_panel = panel_user_data.get(
|
||||
"subscriptionUuid"
|
||||
) or panel_user_data.get("shortUuid")
|
||||
@@ -759,6 +979,9 @@ class SubscriptionService:
|
||||
if panel_user_data.get("expireAt")
|
||||
else None
|
||||
)
|
||||
panel_traffic_used, panel_traffic_limit, panel_traffic_strategy = self._extract_panel_traffic_details(panel_user_data)
|
||||
config_link_raw = panel_user_data.get("subscriptionUrl")
|
||||
display_link, connect_button_url = await prepare_config_links(self.settings, config_link_raw)
|
||||
hwid_limit = panel_user_data.get("hwidDeviceLimit")
|
||||
if hwid_limit is None:
|
||||
hwid_limit = self.settings.USER_HWID_DEVICE_LIMIT
|
||||
@@ -767,9 +990,11 @@ class SubscriptionService:
|
||||
"user_id": panel_user_data.get("uuid"),
|
||||
"end_date": panel_end_date,
|
||||
"status_from_panel": panel_user_data.get("status", "UNKNOWN").upper(),
|
||||
"config_link": panel_user_data.get("subscriptionUrl"),
|
||||
"traffic_limit_bytes": panel_user_data.get("trafficLimitBytes"),
|
||||
"traffic_used_bytes": panel_user_data.get("usedTrafficBytes"),
|
||||
"config_link": display_link,
|
||||
"connect_button_url": connect_button_url,
|
||||
"traffic_limit_bytes": panel_traffic_limit,
|
||||
"traffic_used_bytes": panel_traffic_used,
|
||||
"traffic_limit_strategy": panel_traffic_strategy,
|
||||
"user_bot_username": db_user.username,
|
||||
"is_panel_data": True,
|
||||
"max_devices": hwid_limit,
|
||||
@@ -813,13 +1038,16 @@ class SubscriptionService:
|
||||
sub: Subscription,
|
||||
) -> bool:
|
||||
"""Attempt to charge user using saved payment method. Return True on initiated/handled, False on failure."""
|
||||
if getattr(self.settings, "traffic_sale_mode", False):
|
||||
logging.info("Auto-renew skipped: traffic sale mode enabled")
|
||||
return True
|
||||
if not sub.auto_renew_enabled:
|
||||
return True
|
||||
# If autopayments are disabled globally, skip charging attempts
|
||||
if not getattr(self.settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
|
||||
if not self.settings.yookassa_autopayments_active:
|
||||
return True
|
||||
if sub.provider == "tribute":
|
||||
# Tribute is paid externally; we do not auto-charge here
|
||||
if sub.provider != "yookassa":
|
||||
logging.info("Auto-renew skipped: provider %s does not support auto-renew", sub.provider)
|
||||
return True
|
||||
|
||||
from db.dal.user_billing_dal import get_user_default_payment_method
|
||||
@@ -863,6 +1091,61 @@ class SubscriptionService:
|
||||
logging.info(f"Auto-renew initiated for user {sub.user_id} payment_id={resp.get('id')}")
|
||||
return True
|
||||
|
||||
_PROVIDER_LABELS = {
|
||||
"yookassa": "YooKassa",
|
||||
"freekassa": "FreeKassa",
|
||||
"platega": "Platega",
|
||||
"severpay": "SeverPay",
|
||||
"cryptopay": "Crypto Pay",
|
||||
"crypto_pay": "Crypto Pay",
|
||||
"stars": "Telegram Stars",
|
||||
"tribute": "Tribute",
|
||||
}
|
||||
|
||||
async def _send_payment_success_email(
|
||||
self,
|
||||
*,
|
||||
db_user: User,
|
||||
sale_mode: str,
|
||||
months: int,
|
||||
traffic_gb: Optional[float],
|
||||
payment_amount: float,
|
||||
end_date: Optional[datetime],
|
||||
provider: str,
|
||||
) -> None:
|
||||
"""Best-effort branded email confirming the payment. No-op if SMTP or
|
||||
the user's email aren't set. Failures are logged and swallowed so the
|
||||
payment flow is never blocked by mail delivery."""
|
||||
if not self.settings.email_auth_configured:
|
||||
return
|
||||
recipient = (db_user.email or "").strip() if db_user else ""
|
||||
if not recipient:
|
||||
return
|
||||
|
||||
end_date_text = end_date.strftime("%Y-%m-%d") if end_date else ""
|
||||
provider_label = self._PROVIDER_LABELS.get((provider or "").lower())
|
||||
dashboard_url = (self.settings.SUBSCRIPTION_MINI_APP_URL or "").strip() or None
|
||||
|
||||
try:
|
||||
content = render_payment_success(
|
||||
self.settings,
|
||||
language_code=db_user.language_code or self.settings.DEFAULT_LANGUAGE,
|
||||
sale_mode=sale_mode,
|
||||
months=int(months or 0),
|
||||
traffic_gb=traffic_gb,
|
||||
amount=float(payment_amount or 0),
|
||||
currency=self.settings.DEFAULT_CURRENCY_SYMBOL,
|
||||
end_date_text=end_date_text,
|
||||
dashboard_url=dashboard_url,
|
||||
provider_label=provider_label,
|
||||
)
|
||||
email_service = EmailAuthService(self.settings)
|
||||
await email_service.send_rendered_email(email=recipient, content=content)
|
||||
except Exception:
|
||||
logging.exception(
|
||||
"Failed to send payment success email to user %s", db_user.user_id
|
||||
)
|
||||
|
||||
async def update_last_notification_sent(
|
||||
self, session: AsyncSession, user_id: int, subscription_end_date: datetime
|
||||
):
|
||||
@@ -892,6 +1175,7 @@ class SubscriptionService:
|
||||
status: Optional[str] = None,
|
||||
traffic_limit_bytes: Optional[int] = None,
|
||||
include_uuid: bool = True,
|
||||
traffic_limit_strategy: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
payload: Dict[str, Any] = {}
|
||||
if include_uuid and panel_user_uuid:
|
||||
@@ -902,7 +1186,9 @@ class SubscriptionService:
|
||||
payload["status"] = status
|
||||
if traffic_limit_bytes is not None:
|
||||
payload["trafficLimitBytes"] = traffic_limit_bytes
|
||||
payload["trafficLimitStrategy"] = self.settings.USER_TRAFFIC_STRATEGY
|
||||
payload["trafficLimitStrategy"] = traffic_limit_strategy or self.settings.USER_TRAFFIC_STRATEGY
|
||||
if self.settings.parsed_user_squad_uuids:
|
||||
payload["activeInternalSquads"] = self.settings.parsed_user_squad_uuids
|
||||
if self.settings.parsed_user_external_squad_uuid:
|
||||
payload["externalSquadUuid"] = self.settings.parsed_user_external_squad_uuid
|
||||
return payload
|
||||
|
||||
@@ -1,335 +0,0 @@
|
||||
import logging
|
||||
import hmac
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Optional
|
||||
|
||||
from aiohttp import web
|
||||
from aiogram import Bot
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from config.settings import Settings
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
from bot.services.referral_service import ReferralService
|
||||
from .notification_service import NotificationService
|
||||
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
|
||||
from db.dal import payment_dal, user_dal, subscription_dal
|
||||
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
|
||||
|
||||
|
||||
def convert_period_to_months(period: Optional[str]) -> int:
|
||||
"""Map Tribute subscription period strings to months."""
|
||||
if not period:
|
||||
return 1
|
||||
|
||||
mapping = {
|
||||
"monthly": 1,
|
||||
"quarterly": 3,
|
||||
"3-month": 3,
|
||||
"3months": 3,
|
||||
"3-months": 3,
|
||||
"q": 3,
|
||||
"halfyearly": 6,
|
||||
"yearly": 12,
|
||||
"annual": 12,
|
||||
"y": 12,
|
||||
}
|
||||
return mapping.get(period.lower(), 1)
|
||||
|
||||
|
||||
class TributeService:
|
||||
def __init__(
|
||||
self,
|
||||
bot: Bot,
|
||||
settings: Settings,
|
||||
i18n: JsonI18n,
|
||||
async_session_factory: sessionmaker,
|
||||
panel_service: PanelApiService,
|
||||
subscription_service: SubscriptionService,
|
||||
referral_service: ReferralService,
|
||||
):
|
||||
self.bot = bot
|
||||
self.settings = settings
|
||||
self.i18n = i18n
|
||||
self.async_session_factory = async_session_factory
|
||||
self.panel_service = panel_service
|
||||
self.subscription_service = subscription_service
|
||||
self.referral_service = referral_service
|
||||
|
||||
async def handle_webhook(self, raw_body: bytes, signature_header: Optional[str]) -> web.Response:
|
||||
settings = self.settings
|
||||
bot = self.bot
|
||||
i18n = self.i18n
|
||||
async_session_factory = self.async_session_factory
|
||||
subscription_service = self.subscription_service
|
||||
referral_service = self.referral_service
|
||||
|
||||
def ok(data: Optional[dict] = None) -> web.Response:
|
||||
payload = {"status": "ok"}
|
||||
if data:
|
||||
payload.update(data)
|
||||
return web.json_response(payload, status=200)
|
||||
|
||||
def ignored(reason: str) -> web.Response:
|
||||
return web.json_response({"status": "ignored", "reason": reason}, status=200)
|
||||
|
||||
def bad_request(reason: str) -> web.Response:
|
||||
return web.json_response({"status": "error", "reason": reason}, status=400)
|
||||
|
||||
if settings.TRIBUTE_API_KEY:
|
||||
if not signature_header:
|
||||
return web.json_response({"status": "error", "reason": "no_signature"}, status=403)
|
||||
expected_sig = hmac.new(settings.TRIBUTE_API_KEY.encode(), raw_body,
|
||||
hashlib.sha256).hexdigest()
|
||||
if not hmac.compare_digest(expected_sig, signature_header):
|
||||
return web.json_response({"status": "error", "reason": "invalid_signature"}, status=403)
|
||||
|
||||
try:
|
||||
payload = json.loads(raw_body.decode())
|
||||
except Exception:
|
||||
return bad_request("invalid_json")
|
||||
|
||||
logging.info(
|
||||
"Tribute webhook data: %s",
|
||||
json.dumps(payload, ensure_ascii=False),
|
||||
)
|
||||
|
||||
# Tribute webhook spec: only two events are sent
|
||||
# name: new_subscription | cancelled_subscription
|
||||
event_name = payload.get("name")
|
||||
data = payload.get("payload", {})
|
||||
|
||||
# Mandatory routing fields
|
||||
user_id = data.get("telegram_user_id")
|
||||
if not user_id:
|
||||
# Permanent format issue — acknowledge to avoid retries
|
||||
return ignored("missing_telegram_user_id")
|
||||
|
||||
period_val = data.get("period")
|
||||
months = convert_period_to_months(period_val)
|
||||
|
||||
# Tribute sends amount in minor units (kopecks/cents). Convert to major units before persisting.
|
||||
amount_value = data.get("amount") or data.get("price")
|
||||
currency = (data.get("currency") or settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
|
||||
if amount_value is not None:
|
||||
try:
|
||||
amount_minor_units = float(amount_value)
|
||||
except (TypeError, ValueError):
|
||||
amount_minor_units = 0.0
|
||||
amount_float = round(amount_minor_units / 100.0, 2)
|
||||
else:
|
||||
amount_float = 0.0
|
||||
|
||||
async with async_session_factory() as session:
|
||||
if event_name == "new_subscription":
|
||||
# Use a unique, idempotent provider payment id per webhook event
|
||||
# Prefer explicit event/payment identifiers if present; otherwise fall back to payload hash suffix
|
||||
candidate_event_id = (
|
||||
str(data.get("event_id") or data.get("payment_id") or data.get("purchase_id") or data.get("invoice_id") or "")
|
||||
)
|
||||
if candidate_event_id:
|
||||
provider_payment_id = candidate_event_id
|
||||
else:
|
||||
# Combine subscription_id (if any) with a stable hash of the raw payload to ensure uniqueness per event
|
||||
sub_id_part = str(data.get("subscription_id") or "sub")
|
||||
payload_hash = hashlib.sha256(raw_body).hexdigest()[:16]
|
||||
provider_payment_id = f"{sub_id_part}:{payload_hash}"
|
||||
|
||||
# Idempotent ensure payment
|
||||
payment_record = await payment_dal.ensure_payment_with_provider_id(
|
||||
session,
|
||||
user_id=int(user_id),
|
||||
amount=amount_float,
|
||||
currency=currency,
|
||||
months=months,
|
||||
description="Tribute subscription",
|
||||
provider="tribute",
|
||||
provider_payment_id=provider_payment_id,
|
||||
)
|
||||
|
||||
activation_details = await subscription_service.activate_subscription(
|
||||
session,
|
||||
int(user_id),
|
||||
months,
|
||||
float(amount_float),
|
||||
payment_record.payment_id,
|
||||
provider="tribute",
|
||||
)
|
||||
referral_bonus = await referral_service.apply_referral_bonuses_for_payment(
|
||||
session,
|
||||
int(user_id),
|
||||
months,
|
||||
current_payment_db_id=payment_record.payment_id,
|
||||
skip_if_active_before_payment=False,
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
db_user = await user_dal.get_user_by_id(session, int(user_id))
|
||||
lang = db_user.language_code if db_user and db_user.language_code else settings.DEFAULT_LANGUAGE
|
||||
_ = lambda k, **kw: i18n.gettext(lang, k, **kw)
|
||||
|
||||
applied_ref_days = referral_bonus.get('referee_bonus_applied_days') if referral_bonus else None
|
||||
final_end = (referral_bonus.get('referee_new_end_date')
|
||||
if referral_bonus else None)
|
||||
if not final_end:
|
||||
final_end = activation_details.get('end_date')
|
||||
|
||||
if final_end:
|
||||
config_link = activation_details.get("subscription_url") or _(
|
||||
"config_link_not_available"
|
||||
)
|
||||
|
||||
if applied_ref_days:
|
||||
inviter_name_display = _('friend_placeholder')
|
||||
if db_user and db_user.referred_by_id:
|
||||
inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id)
|
||||
if inviter:
|
||||
safe_name = sanitize_display_name(inviter.first_name) if inviter.first_name else None
|
||||
if safe_name:
|
||||
inviter_name_display = safe_name
|
||||
elif inviter.username:
|
||||
inviter_name_display = username_for_display(inviter.username, with_at=False)
|
||||
success_msg = _(
|
||||
"payment_successful_with_referral_bonus_full",
|
||||
months=months,
|
||||
base_end_date=activation_details["end_date"].strftime('%Y-%m-%d'),
|
||||
bonus_days=applied_ref_days,
|
||||
final_end_date=final_end.strftime('%Y-%m-%d'),
|
||||
inviter_name=inviter_name_display,
|
||||
config_link=config_link,
|
||||
)
|
||||
else:
|
||||
success_msg = _(
|
||||
"payment_successful_full",
|
||||
months=months,
|
||||
end_date=final_end.strftime('%Y-%m-%d'),
|
||||
config_link=config_link,
|
||||
)
|
||||
markup = get_connect_and_main_keyboard(
|
||||
lang,
|
||||
i18n,
|
||||
settings,
|
||||
config_link,
|
||||
preserve_message=True,
|
||||
)
|
||||
|
||||
try:
|
||||
# Use user's DB language in success messages prepared above
|
||||
await bot.send_message(
|
||||
int(user_id),
|
||||
success_msg,
|
||||
reply_markup=markup,
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(
|
||||
f"Failed to send Tribute payment success message to user {user_id}: {e}")
|
||||
|
||||
# Send notification about payment
|
||||
try:
|
||||
notification_service = NotificationService(bot, settings, i18n)
|
||||
user = await user_dal.get_user_by_id(session, int(user_id))
|
||||
await notification_service.notify_payment_received(
|
||||
user_id=int(user_id),
|
||||
amount=float(amount_float),
|
||||
currency=currency,
|
||||
months=months,
|
||||
payment_provider="tribute",
|
||||
username=user.username if user else None
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to send tribute payment notification: {e}")
|
||||
elif event_name == "cancelled_subscription":
|
||||
await self._handle_tribute_cancellation(session, int(user_id), bot, i18n)
|
||||
|
||||
else:
|
||||
await session.commit()
|
||||
# Acknowledge to Tribute that webhook was received and processed/accepted
|
||||
return ok({"event": event_name or "unknown"})
|
||||
|
||||
async def _handle_tribute_cancellation(self, session, user_id: int, bot: Bot, i18n: JsonI18n):
|
||||
"""Handle tribute subscription cancellation - set subscription to 1 day grace period"""
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from db.dal import subscription_dal, user_dal
|
||||
from bot.keyboards.inline.user_keyboards import get_subscribe_only_markup
|
||||
|
||||
try:
|
||||
grace_days = 1
|
||||
grace_end = datetime.now(timezone.utc) + timedelta(days=grace_days)
|
||||
|
||||
active_subscriptions = await subscription_dal.get_active_subscriptions_for_user(session, user_id)
|
||||
|
||||
panel_users_updated: set[str] = set()
|
||||
for sub in active_subscriptions:
|
||||
updated_sub = await subscription_dal.update_subscription(
|
||||
session,
|
||||
sub.subscription_id,
|
||||
{
|
||||
"end_date": grace_end,
|
||||
"status_from_panel": "CANCELLED",
|
||||
"skip_notifications": True,
|
||||
},
|
||||
)
|
||||
|
||||
panel_uuid = updated_sub.panel_user_uuid if updated_sub else None
|
||||
if panel_uuid and panel_uuid not in panel_users_updated:
|
||||
panel_users_updated.add(panel_uuid)
|
||||
panel_payload = {
|
||||
"expireAt": grace_end.isoformat(timespec="milliseconds").replace("+00:00", "Z"),
|
||||
}
|
||||
try:
|
||||
await self.panel_service.update_user_details_on_panel(
|
||||
panel_uuid,
|
||||
panel_payload,
|
||||
log_response=False,
|
||||
)
|
||||
except Exception as panel_err:
|
||||
logging.error(
|
||||
f"Failed to update panel expiry for user {user_id} (panel_uuid {panel_uuid}) during Tribute cancellation: {panel_err}")
|
||||
|
||||
await session.commit()
|
||||
|
||||
# Send notification about cancellation if enabled
|
||||
if not self.settings.TRIBUTE_SKIP_CANCELLATION_NOTIFICATIONS:
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
lang = db_user.language_code if db_user and db_user.language_code else self.settings.DEFAULT_LANGUAGE
|
||||
first_name = db_user.first_name or f"User {user_id}" if db_user else f"User {user_id}"
|
||||
|
||||
_ = lambda k, **kw: i18n.gettext(lang, k, **kw) if i18n else k
|
||||
markup = get_subscribe_only_markup(lang, i18n)
|
||||
|
||||
cancellation_msg = _(
|
||||
"tribute_subscription_cancelled",
|
||||
default="🚨 <b>Подписка отменена</b>\n\n"
|
||||
"Ваша подписка Tribute была отменена. У вас есть 24 часа для восстановления доступа, "
|
||||
"после чего подписка будет заблокирована.\n\n"
|
||||
"Для продления подписки нажмите кнопку ниже.",
|
||||
user_name=first_name
|
||||
)
|
||||
|
||||
try:
|
||||
await bot.send_message(
|
||||
int(user_id),
|
||||
cancellation_msg,
|
||||
reply_markup=markup,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to send tribute cancellation notification to user {user_id}: {e}")
|
||||
|
||||
logging.info(f"Tribute subscription cancelled for user {user_id}, grace period set to 1 day")
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error handling tribute cancellation for user {user_id}: {e}")
|
||||
await session.rollback()
|
||||
|
||||
|
||||
async def tribute_webhook_route(request: web.Request):
|
||||
"""AIOHTTP route handler for Tribute webhook calls."""
|
||||
tribute_service: TributeService = request.app['tribute_service']
|
||||
raw_body = await request.read()
|
||||
signature_header = request.headers.get('trbt-signature')
|
||||
return await tribute_service.handle_webhook(raw_body, signature_header)
|
||||
@@ -21,7 +21,10 @@ class YooKassaService:
|
||||
|
||||
self.settings = settings_obj
|
||||
|
||||
if not shop_id or not secret_key:
|
||||
if self.settings and not self.settings.YOOKASSA_ENABLED:
|
||||
logging.warning("YooKassa is disabled via YOOKASSA_ENABLED flag. Payment functionality will be DISABLED.")
|
||||
self.configured = False
|
||||
elif not shop_id or not secret_key:
|
||||
logging.warning(
|
||||
"YooKassa SHOP_ID or SECRET_KEY not configured in settings. "
|
||||
"Payment functionality will be DISABLED.")
|
||||
@@ -32,9 +35,8 @@ class YooKassaService:
|
||||
self.configured = True
|
||||
logging.info(
|
||||
f"YooKassa SDK configured for shop_id: {shop_id[:5]}...")
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to configure YooKassa SDK: {e}",
|
||||
exc_info=True)
|
||||
except Exception:
|
||||
logging.exception("Failed to configure YooKassa SDK.")
|
||||
self.configured = False
|
||||
|
||||
if configured_return_url:
|
||||
@@ -111,10 +113,12 @@ class YooKassaService:
|
||||
capture = False
|
||||
amount = max(amount, 1.00)
|
||||
builder.set_capture(capture)
|
||||
builder.set_confirmation({
|
||||
"type": ConfirmationType.REDIRECT,
|
||||
"return_url": self.return_url
|
||||
})
|
||||
if not payment_method_id:
|
||||
# Saved payment_method_id charges must omit confirmation per YooKassa API
|
||||
builder.set_confirmation({
|
||||
"type": ConfirmationType.REDIRECT,
|
||||
"return_url": self.return_url
|
||||
})
|
||||
builder.set_description(description)
|
||||
builder.set_metadata(metadata)
|
||||
if save_payment_method:
|
||||
@@ -156,10 +160,11 @@ class YooKassaService:
|
||||
f"Amount: {amount} {currency}. Metadata: {metadata}. Receipt: {receipt_data_dict}"
|
||||
)
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
response = await loop.run_in_executor(
|
||||
None, lambda: YooKassaPayment.create(payment_request,
|
||||
idempotence_key))
|
||||
response = await asyncio.to_thread(
|
||||
YooKassaPayment.create,
|
||||
payment_request,
|
||||
idempotence_key,
|
||||
)
|
||||
|
||||
logging.info(
|
||||
f"YooKassa Payment.create response: ID={response.id}, Status={response.status}, Paid={response.paid}"
|
||||
@@ -195,9 +200,8 @@ class YooKassaService:
|
||||
response.test if hasattr(response, 'test') else None,
|
||||
"payment_method": getattr(response, 'payment_method', None),
|
||||
}
|
||||
except Exception as e:
|
||||
logging.error(f"YooKassa payment creation failed: {e}",
|
||||
exc_info=True)
|
||||
except Exception:
|
||||
logging.exception("YooKassa payment creation failed.")
|
||||
return None
|
||||
|
||||
async def get_payment_info(
|
||||
@@ -211,9 +215,10 @@ class YooKassaService:
|
||||
f"Fetching payment info from YooKassa for ID: {payment_id_in_yookassa}"
|
||||
)
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
payment_info_yk = await loop.run_in_executor(
|
||||
None, lambda: YooKassaPayment.find_one(payment_id_in_yookassa))
|
||||
payment_info_yk = await asyncio.to_thread(
|
||||
YooKassaPayment.find_one,
|
||||
payment_id_in_yookassa,
|
||||
)
|
||||
|
||||
if payment_info_yk:
|
||||
logging.info(
|
||||
@@ -259,10 +264,9 @@ class YooKassaService:
|
||||
f"No payment info found in YooKassa for ID: {payment_id_in_yookassa}"
|
||||
)
|
||||
return None
|
||||
except Exception as e:
|
||||
logging.error(
|
||||
f"YooKassa get payment info for {payment_id_in_yookassa} failed: {e}",
|
||||
exc_info=True)
|
||||
except Exception:
|
||||
logging.exception(
|
||||
"YooKassa get payment info for %s failed.", payment_id_in_yookassa)
|
||||
return None
|
||||
|
||||
async def cancel_payment(self, payment_id_in_yookassa: str) -> bool:
|
||||
@@ -270,10 +274,9 @@ class YooKassaService:
|
||||
logging.error("YooKassa is not configured. Cannot cancel payment.")
|
||||
return False
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
await loop.run_in_executor(None, lambda: YooKassaPayment.cancel(payment_id_in_yookassa))
|
||||
await asyncio.to_thread(YooKassaPayment.cancel, payment_id_in_yookassa)
|
||||
logging.info(f"Cancelled YooKassa payment {payment_id_in_yookassa}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to cancel YooKassa payment {payment_id_in_yookassa}: {e}")
|
||||
except Exception:
|
||||
logging.exception("Failed to cancel YooKassa payment %s.", payment_id_in_yookassa)
|
||||
return False
|
||||
|
||||
@@ -28,6 +28,7 @@ class AdminStates(StatesGroup):
|
||||
waiting_for_user_search = State()
|
||||
waiting_for_subscription_days_to_add = State()
|
||||
waiting_for_direct_message_to_user = State()
|
||||
waiting_for_user_delete_confirmation = State()
|
||||
|
||||
# Ads campaigns
|
||||
waiting_for_ad_source = State()
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from aiogram.exceptions import TelegramAPIError, TelegramBadRequest
|
||||
from aiogram.types import CallbackQuery
|
||||
|
||||
|
||||
_EXPIRED_CALLBACK_MARKERS = (
|
||||
"query is too old",
|
||||
"response timeout expired",
|
||||
"query id is invalid",
|
||||
)
|
||||
|
||||
|
||||
def is_expired_callback_answer_error(error: BaseException) -> bool:
|
||||
if not isinstance(error, TelegramBadRequest):
|
||||
return False
|
||||
message = str(error).lower()
|
||||
return any(marker in message for marker in _EXPIRED_CALLBACK_MARKERS)
|
||||
|
||||
|
||||
async def safe_answer_callback(
|
||||
callback: CallbackQuery,
|
||||
*args: Any,
|
||||
**kwargs: Any,
|
||||
) -> bool:
|
||||
try:
|
||||
await callback.answer(*args, **kwargs)
|
||||
return True
|
||||
except TelegramBadRequest as error:
|
||||
user_id = getattr(getattr(callback, "from_user", None), "id", "unknown")
|
||||
if is_expired_callback_answer_error(error):
|
||||
logging.info(
|
||||
"Ignored expired callback answer for user %s: %s",
|
||||
user_id,
|
||||
error,
|
||||
)
|
||||
return False
|
||||
logging.warning(
|
||||
"Failed to answer callback query for user %s: %s",
|
||||
user_id,
|
||||
error,
|
||||
)
|
||||
return False
|
||||
except TelegramAPIError as error:
|
||||
user_id = getattr(getattr(callback, "from_user", None), "id", "unknown")
|
||||
logging.warning(
|
||||
"Telegram API error while answering callback query for user %s: %s",
|
||||
user_id,
|
||||
error,
|
||||
)
|
||||
return False
|
||||
@@ -0,0 +1,47 @@
|
||||
import logging
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from config.settings import Settings
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
|
||||
|
||||
async def _encrypt_raw_link(settings: Settings, raw_link: str) -> Optional[str]:
|
||||
"""Encrypt the raw subscription URL using the panel's happ crypt4 API."""
|
||||
async with PanelApiService(settings) as panel_service:
|
||||
encrypted_link = await panel_service.encrypt_happ_link(raw_link)
|
||||
if encrypted_link:
|
||||
return encrypted_link
|
||||
return None
|
||||
|
||||
|
||||
async def prepare_config_links(settings: Settings, raw_link: Optional[str]) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""
|
||||
Build the user-facing connection key and the URL for the connect button.
|
||||
|
||||
Returns (display_link, button_link). When CRYPT4 is enabled the display link
|
||||
is encrypted and prefixed with happ://crypt4/ by panel API, and the button link is wrapped
|
||||
with CRYPT4_REDIRECT_URL if provided.
|
||||
"""
|
||||
if not raw_link:
|
||||
return None, None
|
||||
|
||||
cleaned = raw_link.strip()
|
||||
if not cleaned:
|
||||
return None, None
|
||||
|
||||
display_link = cleaned
|
||||
button_link = cleaned
|
||||
|
||||
if settings.CRYPT4_ENABLED:
|
||||
encrypted_payload = await _encrypt_raw_link(settings, cleaned)
|
||||
if encrypted_payload:
|
||||
display_link = encrypted_payload
|
||||
button_link = display_link
|
||||
else:
|
||||
logging.error("CRYPT4_ENABLED is set but encryption failed; using raw link as fallback.")
|
||||
|
||||
redirect_base = (settings.CRYPT4_REDIRECT_URL or "").strip()
|
||||
if redirect_base and settings.CRYPT4_ENABLED and display_link:
|
||||
button_link = f"{redirect_base}{display_link}"
|
||||
|
||||
return display_link, button_link
|
||||
@@ -5,6 +5,12 @@ from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from collections import deque
|
||||
from aiogram import Bot
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
|
||||
from bot.utils.telegram_markup import (
|
||||
is_profile_link_error,
|
||||
remove_profile_link_buttons,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -51,17 +57,34 @@ class MessageQueue:
|
||||
message = self.queue.popleft()
|
||||
try:
|
||||
await self._send_message(message)
|
||||
self.last_send_times.append(datetime.now())
|
||||
self.total_sent += 1
|
||||
self._record_send_time()
|
||||
|
||||
except TelegramBadRequest as exc:
|
||||
fallback_message = self._build_profile_link_fallback(message, exc)
|
||||
if fallback_message:
|
||||
logging.warning(
|
||||
"Telegram rejected profile buttons for chat %s: %s. "
|
||||
"Retrying without tg:// links.",
|
||||
message.chat_id,
|
||||
getattr(exc, "message", "") or str(exc),
|
||||
)
|
||||
try:
|
||||
await self._send_message(fallback_message)
|
||||
self._record_send_time()
|
||||
continue
|
||||
except Exception as retry_exc:
|
||||
self.total_failed += 1
|
||||
logging.error(
|
||||
f"Failed to send fallback message to {message.chat_id}: {retry_exc}"
|
||||
)
|
||||
continue
|
||||
|
||||
# Keep only recent send times (last minute)
|
||||
cutoff_time = datetime.now() - timedelta(seconds=60)
|
||||
while self.last_send_times and self.last_send_times[0] < cutoff_time:
|
||||
self.last_send_times.popleft()
|
||||
|
||||
except Exception as e:
|
||||
self.total_failed += 1
|
||||
logging.error(f"Failed to send queued message to {message.chat_id}: {e}")
|
||||
logging.error(f"Failed to send queued message to {message.chat_id}: {exc}")
|
||||
|
||||
except Exception:
|
||||
self.total_failed += 1
|
||||
logging.exception("Failed to send queued message to %s.", message.chat_id)
|
||||
|
||||
finally:
|
||||
self.is_processing = False
|
||||
@@ -77,6 +100,38 @@ class MessageQueue:
|
||||
if time_since_last < self.delay_between_messages:
|
||||
wait_time = self.delay_between_messages - time_since_last
|
||||
await asyncio.sleep(wait_time)
|
||||
|
||||
def _record_send_time(self) -> None:
|
||||
"""Track sent message timestamps and purge old entries for rate limiting."""
|
||||
now = datetime.now()
|
||||
self.last_send_times.append(now)
|
||||
self.total_sent += 1
|
||||
|
||||
cutoff_time = now - timedelta(seconds=60)
|
||||
while self.last_send_times and self.last_send_times[0] < cutoff_time:
|
||||
self.last_send_times.popleft()
|
||||
|
||||
def _build_profile_link_fallback(
|
||||
self, message: QueuedMessage, exc: Exception
|
||||
) -> Optional[QueuedMessage]:
|
||||
"""Create a fallback message without tg://user buttons when Telegram rejects them."""
|
||||
if not is_profile_link_error(exc):
|
||||
return None
|
||||
|
||||
markup = message.kwargs.get("reply_markup")
|
||||
if markup is None:
|
||||
return None
|
||||
|
||||
safe_markup = remove_profile_link_buttons(markup)
|
||||
fallback_kwargs = dict(message.kwargs)
|
||||
fallback_kwargs["reply_markup"] = safe_markup
|
||||
|
||||
return QueuedMessage(
|
||||
chat_id=message.chat_id,
|
||||
method_name=message.method_name,
|
||||
kwargs=fallback_kwargs,
|
||||
callback=message.callback,
|
||||
)
|
||||
|
||||
async def _send_message(self, message: QueuedMessage) -> Any:
|
||||
"""Send a single message - to be implemented by subclass"""
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
from typing import Optional, Sequence
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
|
||||
def parse_ip_entries(raw_values: Optional[Sequence[str] | str]) -> list[ipaddress._BaseNetwork]:
|
||||
if raw_values is None:
|
||||
return []
|
||||
if isinstance(raw_values, str):
|
||||
values = [item.strip() for item in raw_values.split(",")]
|
||||
else:
|
||||
values = [str(item).strip() for item in raw_values]
|
||||
|
||||
parsed: list[ipaddress._BaseNetwork] = []
|
||||
for value in values:
|
||||
if not value:
|
||||
continue
|
||||
try:
|
||||
parsed.append(ipaddress.ip_network(value, strict=False))
|
||||
except ValueError:
|
||||
continue
|
||||
return parsed
|
||||
|
||||
|
||||
def _parse_ip(value: Optional[str]) -> Optional[ipaddress._BaseAddress]:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return ipaddress.ip_address(value.strip())
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _last_forwarded_ip(header_value: str) -> Optional[str]:
|
||||
candidates = [item.strip() for item in header_value.split(",") if item.strip()]
|
||||
if not candidates:
|
||||
return None
|
||||
candidate = candidates[-1]
|
||||
return candidate if _parse_ip(candidate) is not None else None
|
||||
|
||||
|
||||
def request_client_ip(
|
||||
request: web.Request,
|
||||
*,
|
||||
trusted_proxies: Optional[Sequence[str] | str] = None,
|
||||
) -> Optional[str]:
|
||||
remote_ip = _parse_ip(request.remote or "")
|
||||
forwarded_for = request.headers.get("X-Forwarded-For", "")
|
||||
|
||||
if remote_ip and forwarded_for:
|
||||
trusted_networks = parse_ip_entries(trusted_proxies)
|
||||
if any(remote_ip in network for network in trusted_networks):
|
||||
forwarded_ip = _last_forwarded_ip(forwarded_for)
|
||||
if forwarded_ip:
|
||||
return forwarded_ip
|
||||
|
||||
if remote_ip:
|
||||
return str(remote_ip)
|
||||
|
||||
forwarded_ip = _last_forwarded_ip(forwarded_for)
|
||||
return forwarded_ip
|
||||
|
||||
|
||||
def ip_in_allowlist(ip_value: Optional[str], allowed_entries: Optional[Sequence[str] | str]) -> bool:
|
||||
parsed_ip = _parse_ip(ip_value)
|
||||
if parsed_ip is None:
|
||||
return False
|
||||
|
||||
allowed_networks = parse_ip_entries(allowed_entries)
|
||||
return any(parsed_ip in network for network in allowed_networks)
|
||||
@@ -0,0 +1,39 @@
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import types
|
||||
|
||||
PROFILE_BUTTON_ERROR_CODES = ("BUTTON_USER_INVALID", "BUTTON_USER_PRIVACY_RESTRICTED")
|
||||
TG_USER_LINK_PREFIX = "tg://user?id="
|
||||
|
||||
|
||||
def remove_profile_link_buttons(
|
||||
markup: Optional[types.InlineKeyboardMarkup],
|
||||
) -> Optional[types.InlineKeyboardMarkup]:
|
||||
"""Remove buttons that point to tg://user links to avoid privacy-related errors."""
|
||||
inline_keyboard = getattr(markup, "inline_keyboard", None)
|
||||
if not markup or not inline_keyboard:
|
||||
return None
|
||||
|
||||
cleaned_rows = []
|
||||
for row in inline_keyboard:
|
||||
filtered_row = [
|
||||
button
|
||||
for button in row
|
||||
if not (
|
||||
getattr(button, "url", None)
|
||||
and str(button.url).startswith(TG_USER_LINK_PREFIX)
|
||||
)
|
||||
]
|
||||
if filtered_row:
|
||||
cleaned_rows.append(filtered_row)
|
||||
|
||||
if not cleaned_rows:
|
||||
return None
|
||||
|
||||
return types.InlineKeyboardMarkup(inline_keyboard=cleaned_rows)
|
||||
|
||||
|
||||
def is_profile_link_error(exc: BaseException) -> bool:
|
||||
"""Return True if Telegram rejected markup because of profile link buttons."""
|
||||
message = getattr(exc, "message", "") or str(exc)
|
||||
return any(code in message for code in PROFILE_BUTTON_ERROR_CODES)
|
||||
+606
-41
@@ -1,8 +1,102 @@
|
||||
import logging
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
from pydantic import Field, ValidationError, computed_field, field_validator
|
||||
import os
|
||||
import secrets
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
from pydantic import BaseModel, Field, ValidationError, computed_field, field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
def _split_csv(value: Optional[str]) -> List[str]:
|
||||
if not value:
|
||||
return []
|
||||
return [item.strip() for item in value.split(",") if item.strip()]
|
||||
|
||||
|
||||
class DBSettings(BaseModel):
|
||||
user: str
|
||||
password: str
|
||||
host: str
|
||||
port: int
|
||||
database: str
|
||||
|
||||
|
||||
class PaymentSettings(BaseModel):
|
||||
yookassa_enabled: bool
|
||||
yookassa_shop_id: Optional[str]
|
||||
yookassa_secret_key: Optional[str]
|
||||
yookassa_return_url: Optional[str]
|
||||
yookassa_default_receipt_email: Optional[str]
|
||||
yookassa_vat_code: int
|
||||
yookassa_payment_mode: str
|
||||
yookassa_payment_subject: str
|
||||
yookassa_autopayments_enabled: bool
|
||||
yookassa_autopayments_require_card_binding: bool
|
||||
freekassa_enabled: bool
|
||||
freekassa_merchant_id: Optional[str]
|
||||
freekassa_second_secret: Optional[str]
|
||||
freekassa_api_key: Optional[str]
|
||||
freekassa_payment_ip: Optional[str]
|
||||
freekassa_payment_method_id: Optional[int]
|
||||
freekassa_trusted_ips: List[str]
|
||||
platega_enabled: bool
|
||||
platega_base_url: str
|
||||
platega_merchant_id: Optional[str]
|
||||
platega_secret: Optional[str]
|
||||
platega_payment_method: int
|
||||
platega_sbp_enabled: bool
|
||||
platega_crypto_enabled: bool
|
||||
platega_sbp_method: int
|
||||
platega_crypto_method: int
|
||||
platega_return_url: Optional[str]
|
||||
platega_failed_url: Optional[str]
|
||||
severpay_enabled: bool
|
||||
severpay_mid: Optional[int]
|
||||
severpay_token: Optional[str]
|
||||
severpay_return_url: Optional[str]
|
||||
severpay_base_url: str
|
||||
severpay_lifetime_minutes: Optional[int]
|
||||
cryptopay_enabled: bool
|
||||
cryptopay_token: Optional[str]
|
||||
cryptopay_network: str
|
||||
cryptopay_currency_type: str
|
||||
cryptopay_asset: str
|
||||
|
||||
|
||||
class EmailSettings(BaseModel):
|
||||
smtp_host: str
|
||||
smtp_port: int
|
||||
smtp_fallback_ports: Optional[str]
|
||||
smtp_timeout_seconds: int
|
||||
smtp_username: Optional[str]
|
||||
smtp_password: Optional[str]
|
||||
smtp_from_email: Optional[str]
|
||||
smtp_from_name: Optional[str]
|
||||
smtp_starttls: bool
|
||||
smtp_use_ssl: bool
|
||||
email_code_ttl_seconds: int
|
||||
email_code_resend_seconds: int
|
||||
email_code_max_attempts: int
|
||||
brute_force_max_failures: int
|
||||
brute_force_window_seconds: int
|
||||
brute_force_lock_seconds: int
|
||||
|
||||
|
||||
class WebAppSettings(BaseModel):
|
||||
title: str
|
||||
primary_color: str
|
||||
logo_url: Optional[str]
|
||||
logo_emoji: str
|
||||
session_ttl_seconds: int
|
||||
session_secret: str
|
||||
webhook_secret_token: str
|
||||
auth_max_age_seconds: int
|
||||
login_token_ttl_seconds: int
|
||||
server_host: str
|
||||
server_port: int
|
||||
enabled: bool
|
||||
trusted_proxies: List[str]
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
BOT_TOKEN: str
|
||||
@@ -11,8 +105,8 @@ class Settings(BaseSettings):
|
||||
alias="ADMIN_IDS",
|
||||
description="Comma-separated list of admin Telegram User IDs")
|
||||
|
||||
POSTGRES_USER: str = Field(default="user")
|
||||
POSTGRES_PASSWORD: str = Field(default="password")
|
||||
POSTGRES_USER: str = Field(...)
|
||||
POSTGRES_PASSWORD: str = Field(...)
|
||||
POSTGRES_HOST: str = Field(default="localhost")
|
||||
POSTGRES_PORT: int = Field(default=5432)
|
||||
POSTGRES_DB: str = Field(default="vpn_shop_db")
|
||||
@@ -23,6 +117,8 @@ class Settings(BaseSettings):
|
||||
SUPPORT_LINK: Optional[str] = Field(default=None)
|
||||
SERVER_STATUS_URL: Optional[str] = Field(default=None)
|
||||
TERMS_OF_SERVICE_URL: Optional[str] = Field(default=None)
|
||||
PRIVACY_POLICY_URL: Optional[str] = Field(default=None)
|
||||
USER_AGREEMENT_URL: Optional[str] = Field(default=None)
|
||||
REQUIRED_CHANNEL_ID: Optional[int] = Field(
|
||||
default=None,
|
||||
description="Telegram channel ID the user must join to access the bot")
|
||||
@@ -41,14 +137,74 @@ class Settings(BaseSettings):
|
||||
YOOKASSA_PAYMENT_SUBJECT: str = Field(default="service")
|
||||
# Single toggle to enable recurring payments (saving cards, managing payment methods, auto-renew)
|
||||
YOOKASSA_AUTOPAYMENTS_ENABLED: bool = Field(default=False)
|
||||
YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING: bool = Field(
|
||||
default=True,
|
||||
description="When true, new YooKassa payments in autopay mode force card binding without a user checkbox."
|
||||
)
|
||||
|
||||
LKNPD_INN: Optional[str] = Field(
|
||||
default=None,
|
||||
alias="NALOGO_INN",
|
||||
description="INN for lknpd.nalog.ru (self-employed) authentication"
|
||||
)
|
||||
LKNPD_PASSWORD: Optional[str] = Field(
|
||||
default=None,
|
||||
alias="NALOGO_PASSWORD",
|
||||
description="Password for lknpd.nalog.ru (self-employed) authentication"
|
||||
)
|
||||
LKNPD_API_URL: str = Field(
|
||||
default="https://lknpd.nalog.ru/api",
|
||||
alias="NALOGO_API_URL",
|
||||
description="Base URL for LKNPD API (can be overridden for proxies)"
|
||||
)
|
||||
LKNPD_RECEIPT_NAME_SUBSCRIPTION: str = Field(
|
||||
default="subscription {months} months",
|
||||
alias="NALOGO_RECEIPT_NAME_SUBSCRIPTION",
|
||||
description="Receipt item name for time-based subscriptions. Use {months} placeholder for duration."
|
||||
)
|
||||
LKNPD_RECEIPT_NAME_TRAFFIC: str = Field(
|
||||
default="traffic package {gb} GB",
|
||||
alias="NALOGO_RECEIPT_NAME_TRAFFIC",
|
||||
description="Receipt item name for traffic packages. Use {gb} placeholder for traffic amount."
|
||||
)
|
||||
|
||||
WEBHOOK_BASE_URL: Optional[str] = None
|
||||
TRUSTED_PROXIES: Optional[str] = Field(
|
||||
default="127.0.0.1,::1",
|
||||
description="Comma-separated list of reverse proxy IPs or CIDRs trusted to forward X-Forwarded-For.",
|
||||
)
|
||||
|
||||
CRYPTOPAY_TOKEN: Optional[str] = None
|
||||
CRYPTOPAY_NETWORK: str = Field(default="mainnet")
|
||||
CRYPTOPAY_CURRENCY_TYPE: str = Field(default="fiat")
|
||||
CRYPTOPAY_ASSET: str = Field(default="RUB")
|
||||
CRYPTOPAY_ENABLED: bool = Field(default=True)
|
||||
PLATEGA_ENABLED: bool = Field(default=False)
|
||||
PLATEGA_BASE_URL: str = Field(default="https://app.platega.io")
|
||||
PLATEGA_MERCHANT_ID: Optional[str] = None
|
||||
PLATEGA_SECRET: Optional[str] = None
|
||||
PLATEGA_PAYMENT_METHOD: int = Field(
|
||||
default=2,
|
||||
description="Legacy Platega payment method ID. Used as fallback for PLATEGA_SBP_METHOD when the new field is unset.",
|
||||
)
|
||||
PLATEGA_SBP_ENABLED: bool = Field(
|
||||
default=False,
|
||||
description="Show a separate Platega SBP payment button.",
|
||||
)
|
||||
PLATEGA_CRYPTO_ENABLED: bool = Field(
|
||||
default=False,
|
||||
description="Show a separate Platega crypto payment button.",
|
||||
)
|
||||
PLATEGA_SBP_METHOD: int = Field(
|
||||
default=2,
|
||||
description="Platega method ID for SBP QR (default 2).",
|
||||
)
|
||||
PLATEGA_CRYPTO_METHOD: int = Field(
|
||||
default=13,
|
||||
description="Platega method ID for crypto (default 13).",
|
||||
)
|
||||
PLATEGA_RETURN_URL: Optional[str] = Field(default=None)
|
||||
PLATEGA_FAILED_URL: Optional[str] = Field(default=None)
|
||||
|
||||
FREEKASSA_ENABLED: bool = Field(default=False)
|
||||
FREEKASSA_MERCHANT_ID: Optional[str] = None
|
||||
@@ -57,10 +213,28 @@ class Settings(BaseSettings):
|
||||
FREEKASSA_PAYMENT_URL: str = Field(default="https://pay.freekassa.ru/")
|
||||
FREEKASSA_API_KEY: Optional[str] = None
|
||||
FREEKASSA_PAYMENT_IP: Optional[str] = None
|
||||
FREEKASSA_PAYMENT_METHOD_ID: Optional[int] = None
|
||||
FREEKASSA_TRUSTED_IPS: str = Field(
|
||||
default="168.119.157.136,168.119.60.227,178.154.197.79,51.250.54.238",
|
||||
description="Comma-separated FreeKassa webhook IP allowlist.",
|
||||
)
|
||||
|
||||
SEVERPAY_ENABLED: bool = Field(default=False)
|
||||
SEVERPAY_MID: Optional[int] = None
|
||||
SEVERPAY_TOKEN: Optional[str] = None
|
||||
SEVERPAY_RETURN_URL: Optional[str] = None
|
||||
SEVERPAY_BASE_URL: str = Field(default="https://severpay.io/api/merchant")
|
||||
SEVERPAY_LIFETIME_MINUTES: Optional[int] = Field(
|
||||
default=None,
|
||||
description="Lifetime of the payment link in minutes (30-4320, defaults to provider value)",
|
||||
)
|
||||
|
||||
YOOKASSA_ENABLED: bool = Field(default=True)
|
||||
STARS_ENABLED: bool = Field(default=True)
|
||||
TRIBUTE_ENABLED: bool = Field(default=True)
|
||||
PAYMENT_METHODS_ORDER: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Comma-separated list of payment methods to show (e.g., severpay,freekassa,yookassa,platega,stars,cryptopay)",
|
||||
)
|
||||
|
||||
MONTH_1_ENABLED: bool = Field(default=True, alias="1_MONTH_ENABLED")
|
||||
MONTH_3_ENABLED: bool = Field(default=True, alias="3_MONTHS_ENABLED")
|
||||
@@ -76,17 +250,17 @@ class Settings(BaseSettings):
|
||||
STARS_PRICE_3_MONTHS: Optional[int] = Field(default=None)
|
||||
STARS_PRICE_6_MONTHS: Optional[int] = Field(default=None)
|
||||
STARS_PRICE_12_MONTHS: Optional[int] = Field(default=None)
|
||||
|
||||
|
||||
TRIBUTE_LINK_1_MONTH: Optional[str] = Field(default=None)
|
||||
TRIBUTE_LINK_3_MONTHS: Optional[str] = Field(default=None)
|
||||
TRIBUTE_LINK_6_MONTHS: Optional[str] = Field(default=None)
|
||||
TRIBUTE_LINK_12_MONTHS: Optional[str] = Field(default=None)
|
||||
TRIBUTE_API_KEY: Optional[str] = Field(default=None)
|
||||
TRIBUTE_SKIP_NOTIFICATIONS: bool = Field(default=True, description="Skip renewal notifications for Tribute payments")
|
||||
TRIBUTE_SKIP_CANCELLATION_NOTIFICATIONS: bool = Field(default=False, description="Skip cancellation notifications for Tribute payments")
|
||||
PANEL_WEBHOOK_SECRET: Optional[str] = Field(default=None)
|
||||
|
||||
TRAFFIC_PACKAGES: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Comma-separated list of traffic packages in the format '<GB>:<price>', e.g. '10:199,50:799'",
|
||||
)
|
||||
STARS_TRAFFIC_PACKAGES: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Comma-separated list of traffic packages priced in Stars, e.g. '5:500,20:1500'",
|
||||
)
|
||||
|
||||
SUBSCRIPTION_NOTIFICATIONS_ENABLED: bool = Field(default=True)
|
||||
SUBSCRIPTION_NOTIFY_ON_EXPIRE: bool = Field(default=True)
|
||||
SUBSCRIPTION_NOTIFY_AFTER_EXPIRE: bool = Field(default=True)
|
||||
@@ -113,7 +287,15 @@ class Settings(BaseSettings):
|
||||
# Referral program configuration
|
||||
REFERRAL_ONE_BONUS_PER_REFEREE: bool = Field(
|
||||
default=True,
|
||||
description="When true, referral bonuses (for inviter and referee) are applied only once per invited user – on their first successful payment."
|
||||
description="When true, referral bonuses (for inviter and referee) are applied only once per invited user - on their first successful payment."
|
||||
)
|
||||
REFERRAL_WELCOME_BONUS_DAYS: int = Field(
|
||||
default=3,
|
||||
description="Welcome bonus days granted to a newly registered user who joined via referral link.",
|
||||
)
|
||||
LEGACY_REFS: bool = Field(
|
||||
default=True,
|
||||
description="Allow legacy referral links like ref_<telegram_id> to continue working. Defaults to True when unset."
|
||||
)
|
||||
|
||||
PANEL_API_URL: Optional[str] = None
|
||||
@@ -124,13 +306,64 @@ class Settings(BaseSettings):
|
||||
default=None,
|
||||
description=
|
||||
"Comma-separated UUIDs of internal squads to assign to new panel users")
|
||||
USER_EXTERNAL_SQUAD_UUID: Optional[str] = Field(
|
||||
default=None,
|
||||
description=
|
||||
"UUID of the external squad to assign to new panel users (optional)")
|
||||
|
||||
TRIAL_ENABLED: bool = Field(default=True)
|
||||
TRIAL_DURATION_DAYS: int = Field(default=3)
|
||||
TRIAL_TRAFFIC_LIMIT_GB: Optional[float] = Field(default=5.0)
|
||||
TRIAL_TRAFFIC_STRATEGY: str = Field(default="NO_RESET")
|
||||
|
||||
CRYPT4_ENABLED: bool = Field(default=False, description="Enable happ crypt4 encryption for subscription URLs")
|
||||
CRYPT4_REDIRECT_URL: Optional[str] = Field(default=None, description="Base redirect URL used for the connect button when crypt4 is enabled")
|
||||
|
||||
WEB_SERVER_HOST: str = Field(default="0.0.0.0")
|
||||
WEB_SERVER_PORT: int = Field(default=8080)
|
||||
|
||||
WEBAPP_ENABLED: bool = Field(
|
||||
default=True,
|
||||
description="Run the subscription Mini App in the same container on a separate port.",
|
||||
)
|
||||
WEBAPP_SERVER_HOST: str = Field(default="0.0.0.0")
|
||||
WEBAPP_SERVER_PORT: int = Field(default=8081)
|
||||
WEBAPP_TITLE: str = Field(default="Моя подписка")
|
||||
WEBAPP_PRIMARY_COLOR: str = Field(default="#00fe7a")
|
||||
WEBAPP_LOGO_URL: Optional[str] = Field(default=None)
|
||||
WEBAPP_LOGO_EMOJI: str = Field(default="🫥")
|
||||
WEBAPP_SESSION_SECRET: str = Field(default_factory=lambda: secrets.token_urlsafe(32))
|
||||
WEBHOOK_SECRET_TOKEN: str = Field(default_factory=lambda: secrets.token_urlsafe(32))
|
||||
WEBAPP_SESSION_TTL_SECONDS: int = Field(default=24 * 60 * 60)
|
||||
WEBAPP_AUTH_MAX_AGE_SECONDS: int = Field(default=24 * 60 * 60)
|
||||
WEBAPP_LOGIN_TOKEN_TTL_SECONDS: int = Field(default=10 * 60)
|
||||
|
||||
SMTP_HOST: str = Field(default="smtp-relay.brevo.com")
|
||||
SMTP_PORT: int = Field(default=587)
|
||||
SMTP_FALLBACK_PORTS: Optional[str] = Field(default="2525,465")
|
||||
SMTP_TIMEOUT_SECONDS: int = Field(default=30)
|
||||
SMTP_USERNAME: Optional[str] = Field(default=None)
|
||||
SMTP_PASSWORD: Optional[str] = Field(default=None)
|
||||
SMTP_FROM_EMAIL: Optional[str] = Field(default=None)
|
||||
SMTP_FROM_NAME: Optional[str] = Field(default=None)
|
||||
SMTP_STARTTLS: bool = Field(default=True)
|
||||
SMTP_USE_SSL: bool = Field(default=False)
|
||||
EMAIL_CODE_TTL_SECONDS: int = Field(default=10 * 60)
|
||||
EMAIL_CODE_RESEND_SECONDS: int = Field(default=60)
|
||||
EMAIL_CODE_MAX_ATTEMPTS: int = Field(default=5)
|
||||
BRUTE_FORCE_MAX_FAILURES: int = Field(
|
||||
default=5,
|
||||
description="Maximum failed code attempts allowed within the throttle window before a temporary lockout is applied.",
|
||||
)
|
||||
BRUTE_FORCE_WINDOW_SECONDS: int = Field(
|
||||
default=15 * 60,
|
||||
description="Rolling window used to count failed email and promo code attempts.",
|
||||
)
|
||||
BRUTE_FORCE_LOCK_SECONDS: int = Field(
|
||||
default=30 * 60,
|
||||
description="Temporary lockout duration applied after too many failed code attempts.",
|
||||
)
|
||||
|
||||
LOGS_PAGE_SIZE: int = Field(default=10)
|
||||
|
||||
SUBSCRIPTION_MINI_APP_URL: Optional[str] = Field(default=None)
|
||||
@@ -158,6 +391,103 @@ class Settings(BaseSettings):
|
||||
def DATABASE_URL(self) -> str:
|
||||
return f"postgresql+asyncpg://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}"
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def db_settings(self) -> DBSettings:
|
||||
return DBSettings(
|
||||
user=self.POSTGRES_USER,
|
||||
password=self.POSTGRES_PASSWORD,
|
||||
host=self.POSTGRES_HOST,
|
||||
port=self.POSTGRES_PORT,
|
||||
database=self.POSTGRES_DB,
|
||||
)
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def payment_settings(self) -> PaymentSettings:
|
||||
return PaymentSettings(
|
||||
yookassa_enabled=self.YOOKASSA_ENABLED,
|
||||
yookassa_shop_id=self.YOOKASSA_SHOP_ID,
|
||||
yookassa_secret_key=self.YOOKASSA_SECRET_KEY,
|
||||
yookassa_return_url=self.YOOKASSA_RETURN_URL,
|
||||
yookassa_default_receipt_email=self.YOOKASSA_DEFAULT_RECEIPT_EMAIL,
|
||||
yookassa_vat_code=self.YOOKASSA_VAT_CODE,
|
||||
yookassa_payment_mode=self.YOOKASSA_PAYMENT_MODE,
|
||||
yookassa_payment_subject=self.YOOKASSA_PAYMENT_SUBJECT,
|
||||
yookassa_autopayments_enabled=self.YOOKASSA_AUTOPAYMENTS_ENABLED,
|
||||
yookassa_autopayments_require_card_binding=self.YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING,
|
||||
freekassa_enabled=self.FREEKASSA_ENABLED,
|
||||
freekassa_merchant_id=self.FREEKASSA_MERCHANT_ID,
|
||||
freekassa_second_secret=self.FREEKASSA_SECOND_SECRET,
|
||||
freekassa_api_key=self.FREEKASSA_API_KEY,
|
||||
freekassa_payment_ip=self.FREEKASSA_PAYMENT_IP,
|
||||
freekassa_payment_method_id=self.FREEKASSA_PAYMENT_METHOD_ID,
|
||||
freekassa_trusted_ips=self.freekassa_trusted_ips,
|
||||
platega_enabled=self.PLATEGA_ENABLED,
|
||||
platega_base_url=self.PLATEGA_BASE_URL,
|
||||
platega_merchant_id=self.PLATEGA_MERCHANT_ID,
|
||||
platega_secret=self.PLATEGA_SECRET,
|
||||
platega_payment_method=self.PLATEGA_PAYMENT_METHOD,
|
||||
platega_sbp_enabled=self.PLATEGA_SBP_ENABLED,
|
||||
platega_crypto_enabled=self.PLATEGA_CRYPTO_ENABLED,
|
||||
platega_sbp_method=self.platega_sbp_method_resolved,
|
||||
platega_crypto_method=self.PLATEGA_CRYPTO_METHOD,
|
||||
platega_return_url=self.PLATEGA_RETURN_URL,
|
||||
platega_failed_url=self.PLATEGA_FAILED_URL,
|
||||
severpay_enabled=self.SEVERPAY_ENABLED,
|
||||
severpay_mid=self.SEVERPAY_MID,
|
||||
severpay_token=self.SEVERPAY_TOKEN,
|
||||
severpay_return_url=self.SEVERPAY_RETURN_URL,
|
||||
severpay_base_url=self.SEVERPAY_BASE_URL,
|
||||
severpay_lifetime_minutes=self.SEVERPAY_LIFETIME_MINUTES,
|
||||
cryptopay_enabled=self.CRYPTOPAY_ENABLED,
|
||||
cryptopay_token=self.CRYPTOPAY_TOKEN,
|
||||
cryptopay_network=self.CRYPTOPAY_NETWORK,
|
||||
cryptopay_currency_type=self.CRYPTOPAY_CURRENCY_TYPE,
|
||||
cryptopay_asset=self.CRYPTOPAY_ASSET,
|
||||
)
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def email_settings(self) -> EmailSettings:
|
||||
return EmailSettings(
|
||||
smtp_host=self.SMTP_HOST,
|
||||
smtp_port=self.SMTP_PORT,
|
||||
smtp_fallback_ports=self.SMTP_FALLBACK_PORTS,
|
||||
smtp_timeout_seconds=self.SMTP_TIMEOUT_SECONDS,
|
||||
smtp_username=self.SMTP_USERNAME,
|
||||
smtp_password=self.SMTP_PASSWORD,
|
||||
smtp_from_email=self.SMTP_FROM_EMAIL,
|
||||
smtp_from_name=self.SMTP_FROM_NAME,
|
||||
smtp_starttls=self.SMTP_STARTTLS,
|
||||
smtp_use_ssl=self.SMTP_USE_SSL,
|
||||
email_code_ttl_seconds=self.EMAIL_CODE_TTL_SECONDS,
|
||||
email_code_resend_seconds=self.EMAIL_CODE_RESEND_SECONDS,
|
||||
email_code_max_attempts=self.EMAIL_CODE_MAX_ATTEMPTS,
|
||||
brute_force_max_failures=self.BRUTE_FORCE_MAX_FAILURES,
|
||||
brute_force_window_seconds=self.BRUTE_FORCE_WINDOW_SECONDS,
|
||||
brute_force_lock_seconds=self.BRUTE_FORCE_LOCK_SECONDS,
|
||||
)
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def webapp_settings(self) -> WebAppSettings:
|
||||
return WebAppSettings(
|
||||
title=self.WEBAPP_TITLE,
|
||||
primary_color=self.WEBAPP_PRIMARY_COLOR,
|
||||
logo_url=self.WEBAPP_LOGO_URL,
|
||||
logo_emoji=self.WEBAPP_LOGO_EMOJI,
|
||||
session_ttl_seconds=self.WEBAPP_SESSION_TTL_SECONDS,
|
||||
session_secret=self.WEBAPP_SESSION_SECRET,
|
||||
webhook_secret_token=self.WEBHOOK_SECRET_TOKEN,
|
||||
auth_max_age_seconds=self.WEBAPP_AUTH_MAX_AGE_SECONDS,
|
||||
login_token_ttl_seconds=self.WEBAPP_LOGIN_TOKEN_TTL_SECONDS,
|
||||
server_host=self.WEBAPP_SERVER_HOST,
|
||||
server_port=self.WEBAPP_SERVER_PORT,
|
||||
enabled=self.WEBAPP_ENABLED,
|
||||
trusted_proxies=self.trusted_proxies,
|
||||
)
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def ADMIN_IDS(self) -> List[int]:
|
||||
@@ -206,6 +536,30 @@ class Settings(BaseSettings):
|
||||
]
|
||||
return None
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def parsed_user_external_squad_uuid(self) -> Optional[str]:
|
||||
if self.USER_EXTERNAL_SQUAD_UUID:
|
||||
cleaned = self.USER_EXTERNAL_SQUAD_UUID.strip()
|
||||
if cleaned:
|
||||
return cleaned
|
||||
return None
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def trusted_proxies(self) -> List[str]:
|
||||
return _split_csv(self.TRUSTED_PROXIES)
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def freekassa_trusted_ips(self) -> List[str]:
|
||||
return _split_csv(self.FREEKASSA_TRUSTED_IPS)
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def telegram_webhook_path(self) -> str:
|
||||
return "/tg/webhook"
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def yookassa_webhook_path(self) -> str:
|
||||
@@ -220,19 +574,6 @@ class Settings(BaseSettings):
|
||||
return f"{base.rstrip('/')}{self.yookassa_webhook_path}"
|
||||
return None
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def tribute_webhook_path(self) -> str:
|
||||
return "/webhook/tribute"
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def tribute_full_webhook_url(self) -> Optional[str]:
|
||||
base = self.WEBHOOK_BASE_URL
|
||||
if base:
|
||||
return f"{base.rstrip('/')}{self.tribute_webhook_path}"
|
||||
return None
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def panel_webhook_path(self) -> str:
|
||||
@@ -272,6 +613,32 @@ class Settings(BaseSettings):
|
||||
return f"{base.rstrip('/')}{self.freekassa_webhook_path}"
|
||||
return None
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def severpay_webhook_path(self) -> str:
|
||||
return "/webhook/severpay"
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def severpay_full_webhook_url(self) -> Optional[str]:
|
||||
base = self.WEBHOOK_BASE_URL
|
||||
if base:
|
||||
return f"{base.rstrip('/')}{self.severpay_webhook_path}"
|
||||
return None
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def platega_webhook_path(self) -> str:
|
||||
return "/webhook/platega"
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def platega_full_webhook_url(self) -> Optional[str]:
|
||||
base = self.WEBHOOK_BASE_URL
|
||||
if base:
|
||||
return f"{base.rstrip('/')}{self.platega_webhook_path}"
|
||||
return None
|
||||
|
||||
# Computed YooKassa receipt fields based on recurring toggle
|
||||
@computed_field
|
||||
@property
|
||||
@@ -316,17 +683,59 @@ class Settings(BaseSettings):
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def tribute_payment_links(self) -> Dict[int, str]:
|
||||
links: Dict[int, str] = {}
|
||||
if self.TRIBUTE_ENABLED and self.MONTH_1_ENABLED and self.TRIBUTE_LINK_1_MONTH:
|
||||
links[1] = self.TRIBUTE_LINK_1_MONTH
|
||||
if self.TRIBUTE_ENABLED and self.MONTH_3_ENABLED and self.TRIBUTE_LINK_3_MONTHS:
|
||||
links[3] = self.TRIBUTE_LINK_3_MONTHS
|
||||
if self.TRIBUTE_ENABLED and self.MONTH_6_ENABLED and self.TRIBUTE_LINK_6_MONTHS:
|
||||
links[6] = self.TRIBUTE_LINK_6_MONTHS
|
||||
if self.TRIBUTE_ENABLED and self.MONTH_12_ENABLED and self.TRIBUTE_LINK_12_MONTHS:
|
||||
links[12] = self.TRIBUTE_LINK_12_MONTHS
|
||||
return links
|
||||
def traffic_packages(self) -> Dict[float, float]:
|
||||
"""
|
||||
Mapping of traffic size in GB to price in the default currency.
|
||||
"""
|
||||
packages: Dict[float, float] = {}
|
||||
raw = (self.TRAFFIC_PACKAGES or "").strip()
|
||||
if not raw:
|
||||
return packages
|
||||
for part in raw.split(","):
|
||||
chunk = part.strip()
|
||||
if not chunk or ":" not in chunk:
|
||||
continue
|
||||
size_str, price_str = chunk.split(":", 1)
|
||||
try:
|
||||
size_gb = float(size_str.strip())
|
||||
price_val = float(price_str.strip())
|
||||
if size_gb > 0 and price_val >= 0:
|
||||
packages[size_gb] = price_val
|
||||
except ValueError:
|
||||
logging.warning("Invalid TRAFFIC_PACKAGES entry skipped: %s", chunk)
|
||||
continue
|
||||
return packages
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def stars_traffic_packages(self) -> Dict[float, int]:
|
||||
"""
|
||||
Mapping of traffic size in GB to price in Telegram Stars.
|
||||
"""
|
||||
packages: Dict[float, int] = {}
|
||||
raw = (self.STARS_TRAFFIC_PACKAGES or "").strip()
|
||||
if not raw:
|
||||
return packages
|
||||
for part in raw.split(","):
|
||||
chunk = part.strip()
|
||||
if not chunk or ":" not in chunk:
|
||||
continue
|
||||
size_str, price_str = chunk.split(":", 1)
|
||||
try:
|
||||
size_gb = float(size_str.strip())
|
||||
price_val = int(float(price_str.strip()))
|
||||
if size_gb > 0 and price_val >= 0:
|
||||
packages[size_gb] = price_val
|
||||
except ValueError:
|
||||
logging.warning("Invalid STARS_TRAFFIC_PACKAGES entry skipped: %s", chunk)
|
||||
continue
|
||||
return packages
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def traffic_sale_mode(self) -> bool:
|
||||
"""When true, the bot sells traffic packages instead of time-based subscriptions."""
|
||||
return bool(self.traffic_packages or self.stars_traffic_packages)
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
@@ -355,11 +764,119 @@ class Settings(BaseSettings):
|
||||
if self.REFERRAL_BONUS_DAYS_REFEREE_12_MONTHS is not None:
|
||||
bonuses[12] = self.REFERRAL_BONUS_DAYS_REFEREE_12_MONTHS
|
||||
return bonuses
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def yookassa_autopayments_active(self) -> bool:
|
||||
"""Autopay features are available only when YooKassa itself is enabled."""
|
||||
return bool(self.YOOKASSA_ENABLED and self.YOOKASSA_AUTOPAYMENTS_ENABLED)
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def payment_methods_order(self) -> List[str]:
|
||||
"""
|
||||
Ordered list of payment providers to show in the subscription payment keyboard.
|
||||
"""
|
||||
default_order = [
|
||||
"freekassa",
|
||||
"platega_sbp",
|
||||
"platega_crypto",
|
||||
"severpay",
|
||||
"yookassa",
|
||||
"stars",
|
||||
"cryptopay",
|
||||
]
|
||||
if not self.PAYMENT_METHODS_ORDER:
|
||||
return default_order
|
||||
methods: List[str] = []
|
||||
for item in self.PAYMENT_METHODS_ORDER.split(","):
|
||||
slug = item.strip().lower()
|
||||
if not slug:
|
||||
continue
|
||||
if slug == "platega":
|
||||
# Legacy slug — expand to the new sub-methods preserving order
|
||||
if "platega_sbp" not in methods:
|
||||
methods.append("platega_sbp")
|
||||
if "platega_crypto" not in methods:
|
||||
methods.append("platega_crypto")
|
||||
continue
|
||||
methods.append(slug)
|
||||
return methods or default_order
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def platega_sbp_method_resolved(self) -> int:
|
||||
"""SBP method ID, falling back to legacy PLATEGA_PAYMENT_METHOD when SBP-specific value is the default."""
|
||||
if self.PLATEGA_SBP_METHOD != 2:
|
||||
return self.PLATEGA_SBP_METHOD
|
||||
return self.PLATEGA_PAYMENT_METHOD or 2
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def email_auth_configured(self) -> bool:
|
||||
return bool(
|
||||
self.SMTP_HOST
|
||||
and self.SMTP_PORT
|
||||
and self.SMTP_USERNAME
|
||||
and self.SMTP_PASSWORD
|
||||
and self.SMTP_FROM_EMAIL
|
||||
)
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def smtp_ports_to_try(self) -> List[int]:
|
||||
ports: List[int] = []
|
||||
|
||||
def add_port(value: Any) -> None:
|
||||
try:
|
||||
port = int(str(value).strip())
|
||||
except (TypeError, ValueError):
|
||||
return
|
||||
if 0 < port <= 65535 and port not in ports:
|
||||
ports.append(port)
|
||||
|
||||
add_port(self.SMTP_PORT)
|
||||
for item in (self.SMTP_FALLBACK_PORTS or "").split(","):
|
||||
add_port(item)
|
||||
return ports
|
||||
|
||||
# Logging Configuration
|
||||
LOG_LEVEL: str = Field(
|
||||
default="INFO",
|
||||
description="Global log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)",
|
||||
)
|
||||
LOG_CHAT_ID: Optional[int] = Field(default=None, description="Telegram chat/group ID for sending notifications")
|
||||
LOG_THREAD_ID: Optional[int] = Field(default=None, description="Thread ID for supergroup messages (optional)")
|
||||
|
||||
@field_validator('LOG_LEVEL', mode='before')
|
||||
@classmethod
|
||||
def normalize_log_level(cls, v):
|
||||
if isinstance(v, str):
|
||||
v = v.strip().upper()
|
||||
if not v:
|
||||
return "INFO"
|
||||
return v
|
||||
|
||||
@field_validator('POSTGRES_USER', 'POSTGRES_PASSWORD', mode='before')
|
||||
@classmethod
|
||||
def validate_required_db_credentials(cls, v):
|
||||
if isinstance(v, str):
|
||||
v = v.strip()
|
||||
if not v:
|
||||
raise ValueError("must not be empty")
|
||||
return v
|
||||
|
||||
@field_validator('WEBAPP_SESSION_SECRET', 'WEBHOOK_SECRET_TOKEN', mode='before')
|
||||
@classmethod
|
||||
def normalize_webapp_secrets(cls, v):
|
||||
if isinstance(v, str):
|
||||
v = v.strip()
|
||||
if v:
|
||||
return v
|
||||
if v:
|
||||
return v
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
@field_validator('LOG_CHAT_ID', 'LOG_THREAD_ID', mode='before')
|
||||
@classmethod
|
||||
def validate_optional_int_fields(cls, v):
|
||||
@@ -368,14 +885,30 @@ class Settings(BaseSettings):
|
||||
return None
|
||||
return v
|
||||
|
||||
@field_validator('REQUIRED_CHANNEL_LINK', mode='before')
|
||||
@field_validator(
|
||||
'REQUIRED_CHANNEL_LINK',
|
||||
'PLATEGA_RETURN_URL',
|
||||
'PLATEGA_FAILED_URL',
|
||||
'SEVERPAY_RETURN_URL',
|
||||
'CRYPT4_REDIRECT_URL',
|
||||
'PRIVACY_POLICY_URL',
|
||||
'USER_AGREEMENT_URL',
|
||||
'SUBSCRIPTION_MINI_APP_URL',
|
||||
'WEBAPP_LOGO_URL',
|
||||
'SMTP_USERNAME',
|
||||
'SMTP_PASSWORD',
|
||||
'SMTP_FROM_EMAIL',
|
||||
'SMTP_FROM_NAME',
|
||||
'SMTP_FALLBACK_PORTS',
|
||||
mode='before',
|
||||
)
|
||||
@classmethod
|
||||
def sanitize_optional_link(cls, v):
|
||||
if isinstance(v, str) and not v.strip():
|
||||
return None
|
||||
return v
|
||||
|
||||
@field_validator('USER_HWID_DEVICE_LIMIT', mode='before')
|
||||
@field_validator('USER_HWID_DEVICE_LIMIT', 'SEVERPAY_MID', 'SEVERPAY_LIFETIME_MINUTES', mode='before')
|
||||
@classmethod
|
||||
def validate_optional_int(cls, v):
|
||||
if isinstance(v, str):
|
||||
@@ -414,10 +947,28 @@ def get_settings() -> Settings:
|
||||
logging.warning(
|
||||
"CRITICAL: PANEL_API_URL is not set. Panel integration will not work."
|
||||
)
|
||||
if not os.getenv("WEBAPP_SESSION_SECRET"):
|
||||
logging.warning(
|
||||
"WEBAPP_SESSION_SECRET is not set. A generated secret will be used for this process only."
|
||||
)
|
||||
if not os.getenv("WEBHOOK_SECRET_TOKEN"):
|
||||
logging.warning(
|
||||
"WEBHOOK_SECRET_TOKEN is not set. A generated secret will be used for this process only."
|
||||
)
|
||||
if not _settings_instance.YOOKASSA_SHOP_ID or not _settings_instance.YOOKASSA_SECRET_KEY:
|
||||
logging.warning(
|
||||
"CRITICAL: YooKassa credentials (SHOP_ID or SECRET_KEY) are not set. Payments will not work."
|
||||
)
|
||||
if (
|
||||
_settings_instance.LKNPD_INN
|
||||
or _settings_instance.LKNPD_PASSWORD
|
||||
) and not (
|
||||
_settings_instance.LKNPD_INN
|
||||
and _settings_instance.LKNPD_PASSWORD
|
||||
):
|
||||
logging.warning(
|
||||
"WARNING: LKNPD credentials are incomplete. Receipt sending will be disabled."
|
||||
)
|
||||
if _settings_instance.FREEKASSA_ENABLED:
|
||||
if (
|
||||
not _settings_instance.FREEKASSA_MERCHANT_ID
|
||||
@@ -435,6 +986,20 @@ def get_settings() -> Settings:
|
||||
"CRITICAL: FreeKassa is enabled but no subscription prices are configured (RUB_PRICE_*). Users will not see payment buttons."
|
||||
)
|
||||
|
||||
if _settings_instance.PLATEGA_ENABLED:
|
||||
if (
|
||||
not _settings_instance.PLATEGA_MERCHANT_ID
|
||||
or not _settings_instance.PLATEGA_SECRET
|
||||
):
|
||||
logging.warning(
|
||||
"CRITICAL: Platega is enabled but merchant credentials (PLATEGA_MERCHANT_ID/PLATEGA_SECRET) are missing. Platega payments will not work."
|
||||
)
|
||||
if _settings_instance.SEVERPAY_ENABLED:
|
||||
if not _settings_instance.SEVERPAY_MID or not _settings_instance.SEVERPAY_TOKEN:
|
||||
logging.warning(
|
||||
"CRITICAL: SeverPay is enabled but MID or TOKEN is missing. SeverPay payments will not work."
|
||||
)
|
||||
|
||||
except ValidationError as e:
|
||||
logging.critical(
|
||||
f"Pydantic validation error while loading settings: {e}")
|
||||
|
||||
@@ -6,6 +6,7 @@ from . import panel_sync_dal
|
||||
from . import message_log_dal
|
||||
from . import user_billing_dal
|
||||
from . import ad_dal
|
||||
from . import security_dal
|
||||
|
||||
__all__ = (
|
||||
"user_dal",
|
||||
@@ -16,6 +17,7 @@ __all__ = (
|
||||
"message_log_dal",
|
||||
"user_billing_dal",
|
||||
"ad_dal",
|
||||
"security_dal",
|
||||
)
|
||||
|
||||
|
||||
|
||||
+29
-19
@@ -60,17 +60,18 @@ async def ensure_payment_with_provider_id(
|
||||
"""Idempotently create a payment record for a provider event.
|
||||
|
||||
If a payment with the same provider_payment_id already exists, returns it.
|
||||
Otherwise creates a new succeeded payment with provided data.
|
||||
Otherwise creates a new pending payment with provided data.
|
||||
"""
|
||||
existing = await get_payment_by_provider_payment_id(session, provider_payment_id)
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
pending_status = f"pending_{provider}" if provider else "pending"
|
||||
payment_payload: Dict[str, Any] = {
|
||||
"user_id": user_id,
|
||||
"amount": float(amount),
|
||||
"currency": currency,
|
||||
"status": "succeeded",
|
||||
"status": pending_status,
|
||||
"description": description,
|
||||
"subscription_duration_months": months,
|
||||
"provider_payment_id": provider_payment_id,
|
||||
@@ -239,26 +240,35 @@ async def get_financial_statistics(session: AsyncSession) -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
async def get_last_tribute_payment_duration(session: AsyncSession, user_id: int) -> Optional[int]:
|
||||
"""Get duration in months from the last successful tribute payment for a user."""
|
||||
stmt = select(Payment.subscription_duration_months).where(
|
||||
async def get_user_total_paid(session: AsyncSession, user_id: int) -> float:
|
||||
"""Get total amount paid by a specific user (sum of all succeeded payments)."""
|
||||
stmt = select(func.sum(Payment.amount)).where(
|
||||
and_(
|
||||
Payment.user_id == user_id,
|
||||
Payment.provider == 'tribute',
|
||||
Payment.status == 'succeeded'
|
||||
)
|
||||
).order_by(Payment.created_at.desc()).limit(1)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
total = result.scalar()
|
||||
return float(total or 0)
|
||||
|
||||
|
||||
async def get_referral_revenue(session: AsyncSession, referrer_id: int) -> float:
|
||||
"""Get total revenue generated from referred users' payments.
|
||||
|
||||
This calculates the sum of all succeeded payments made by users
|
||||
where referred_by_id equals the referrer_id.
|
||||
"""
|
||||
from db.models import User
|
||||
|
||||
stmt = select(func.sum(Payment.amount)).join(
|
||||
User, Payment.user_id == User.user_id
|
||||
).where(
|
||||
and_(
|
||||
User.referred_by_id == referrer_id,
|
||||
Payment.status == 'succeeded'
|
||||
)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_last_tribute_payment(
|
||||
session: AsyncSession, user_id: int) -> Optional[Payment]:
|
||||
"""Return the most recent succeeded Tribute payment for the user."""
|
||||
stmt = (select(Payment).where(
|
||||
and_(Payment.user_id == user_id, Payment.provider == 'tribute',
|
||||
Payment.status == 'succeeded')).order_by(
|
||||
Payment.created_at.desc()).limit(1))
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
total = result.scalar()
|
||||
return float(total or 0)
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import case, delete, or_, select
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..models import SecurityThrottle
|
||||
|
||||
EMAIL_CODE_VERIFY_SCOPE = "email_code_verify"
|
||||
PROMO_CODE_APPLY_SCOPE = "promo_code_apply"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ThrottleDecision:
|
||||
locked: bool
|
||||
retry_after: Optional[int] = None
|
||||
|
||||
|
||||
def _utc_now(value: Optional[datetime] = None) -> datetime:
|
||||
if value is None:
|
||||
value = datetime.now(timezone.utc)
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=timezone.utc)
|
||||
return value.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _retry_after_seconds(locked_until: Optional[datetime], now: datetime) -> Optional[int]:
|
||||
if not locked_until:
|
||||
return None
|
||||
locked_until = _utc_now(locked_until)
|
||||
remaining = int((locked_until - now).total_seconds())
|
||||
return max(1, remaining) if remaining > 0 else None
|
||||
|
||||
|
||||
async def get_throttle_state(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
scope: str,
|
||||
identifier: str,
|
||||
) -> Optional[SecurityThrottle]:
|
||||
stmt = (
|
||||
select(SecurityThrottle)
|
||||
.where(
|
||||
SecurityThrottle.scope == scope,
|
||||
SecurityThrottle.identifier == identifier,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def check_throttle(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
scope: str,
|
||||
identifier: str,
|
||||
now: Optional[datetime] = None,
|
||||
) -> ThrottleDecision:
|
||||
now = _utc_now(now)
|
||||
row = await get_throttle_state(session, scope=scope, identifier=identifier)
|
||||
if not row or not row.locked_until:
|
||||
return ThrottleDecision(locked=False)
|
||||
|
||||
locked_until = _utc_now(row.locked_until)
|
||||
if locked_until <= now:
|
||||
return ThrottleDecision(locked=False)
|
||||
|
||||
return ThrottleDecision(
|
||||
locked=True,
|
||||
retry_after=_retry_after_seconds(locked_until, now),
|
||||
)
|
||||
|
||||
|
||||
async def record_throttle_failure(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
scope: str,
|
||||
identifier: str,
|
||||
max_failures: int,
|
||||
window_seconds: int,
|
||||
lock_seconds: int,
|
||||
now: Optional[datetime] = None,
|
||||
) -> ThrottleDecision:
|
||||
now = _utc_now(now)
|
||||
max_failures = max(1, int(max_failures))
|
||||
window_seconds = max(1, int(window_seconds))
|
||||
lock_seconds = max(1, int(lock_seconds))
|
||||
window_cutoff = now - timedelta(seconds=window_seconds)
|
||||
lock_until = now + timedelta(seconds=lock_seconds)
|
||||
|
||||
failure_count_expr = case(
|
||||
(
|
||||
or_(
|
||||
SecurityThrottle.window_started_at.is_(None),
|
||||
SecurityThrottle.window_started_at <= window_cutoff,
|
||||
),
|
||||
1,
|
||||
),
|
||||
else_=SecurityThrottle.failures + 1,
|
||||
)
|
||||
|
||||
stmt = (
|
||||
pg_insert(SecurityThrottle)
|
||||
.values(
|
||||
scope=scope,
|
||||
identifier=identifier,
|
||||
failures=1,
|
||||
window_started_at=now,
|
||||
last_attempt_at=now,
|
||||
locked_until=lock_until if max_failures <= 1 else None,
|
||||
)
|
||||
.on_conflict_do_update(
|
||||
index_elements=[SecurityThrottle.scope, SecurityThrottle.identifier],
|
||||
set_={
|
||||
"failures": failure_count_expr,
|
||||
"window_started_at": case(
|
||||
(
|
||||
or_(
|
||||
SecurityThrottle.window_started_at.is_(None),
|
||||
SecurityThrottle.window_started_at <= window_cutoff,
|
||||
),
|
||||
now,
|
||||
),
|
||||
else_=SecurityThrottle.window_started_at,
|
||||
),
|
||||
"last_attempt_at": now,
|
||||
"locked_until": case(
|
||||
(failure_count_expr >= max_failures, lock_until),
|
||||
else_=None,
|
||||
),
|
||||
},
|
||||
)
|
||||
.returning(SecurityThrottle.locked_until)
|
||||
)
|
||||
|
||||
result = await session.execute(stmt)
|
||||
locked_until = result.scalar_one_or_none()
|
||||
locked_until = _utc_now(locked_until) if locked_until else None
|
||||
if locked_until and locked_until > now:
|
||||
return ThrottleDecision(
|
||||
locked=True,
|
||||
retry_after=_retry_after_seconds(locked_until, now),
|
||||
)
|
||||
return ThrottleDecision(locked=False)
|
||||
|
||||
|
||||
async def clear_throttle_state(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
scope: str,
|
||||
identifier: str,
|
||||
) -> None:
|
||||
stmt = delete(SecurityThrottle).where(
|
||||
SecurityThrottle.scope == scope,
|
||||
SecurityThrottle.identifier == identifier,
|
||||
)
|
||||
await session.execute(stmt)
|
||||
@@ -130,7 +130,7 @@ async def upsert_subscription(session: AsyncSession,
|
||||
|
||||
async def deactivate_other_active_subscriptions(
|
||||
session: AsyncSession, panel_user_uuid: str,
|
||||
current_panel_subscription_uuid: Optional[str]):
|
||||
current_panel_subscription_uuid: Optional[str]) -> None:
|
||||
stmt = (update(Subscription).where(
|
||||
Subscription.panel_user_uuid == panel_user_uuid,
|
||||
Subscription.is_active == True,
|
||||
|
||||
@@ -162,3 +162,19 @@ async def delete_user_payment_method_by_provider_id(
|
||||
await session.delete(method)
|
||||
await session.flush()
|
||||
return True
|
||||
|
||||
|
||||
async def user_has_saved_payment_method(
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
provider: str = "yookassa",
|
||||
) -> bool:
|
||||
"""Return True if the user has at least one saved payment method."""
|
||||
try:
|
||||
methods = await list_user_payment_methods(session, user_id, provider)
|
||||
if methods:
|
||||
return True
|
||||
billing = await get_user_billing(session, user_id)
|
||||
return bool(billing and billing.yookassa_payment_method_id)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
+594
-14
@@ -1,13 +1,85 @@
|
||||
import logging
|
||||
import secrets
|
||||
import string
|
||||
from typing import Optional, List, Dict, Any, Tuple
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.future import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlalchemy import update, delete, func, and_
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import update, delete, func, and_, or_, desc
|
||||
from sqlalchemy.orm import aliased
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
|
||||
from ..models import User, Subscription
|
||||
from ..models import (
|
||||
User,
|
||||
Subscription,
|
||||
Payment,
|
||||
PromoCodeActivation,
|
||||
MessageLog,
|
||||
UserBilling,
|
||||
UserPaymentMethod,
|
||||
AdAttribution,
|
||||
)
|
||||
|
||||
REFERRAL_CODE_ALPHABET = string.ascii_uppercase + string.digits
|
||||
REFERRAL_CODE_LENGTH = 9
|
||||
MAX_REFERRAL_CODE_ATTEMPTS = 25
|
||||
MAX_EMAIL_USER_ID_ATTEMPTS = 25
|
||||
|
||||
|
||||
class UserMergeConflictError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _generate_referral_code_candidate() -> str:
|
||||
return "".join(
|
||||
secrets.choice(REFERRAL_CODE_ALPHABET) for _ in range(REFERRAL_CODE_LENGTH)
|
||||
)
|
||||
|
||||
|
||||
async def _referral_code_exists(session: AsyncSession, code: str) -> bool:
|
||||
stmt = select(User.user_id).where(User.referral_code == code)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
|
||||
async def generate_unique_referral_code(session: AsyncSession) -> str:
|
||||
"""
|
||||
Generate a unique referral code consisting of uppercase alphanumeric characters.
|
||||
Retries until a free code is found or raises RuntimeError after exceeding attempts.
|
||||
"""
|
||||
for _ in range(MAX_REFERRAL_CODE_ATTEMPTS):
|
||||
candidate = _generate_referral_code_candidate()
|
||||
if not await _referral_code_exists(session, candidate):
|
||||
return candidate
|
||||
raise RuntimeError("Failed to generate a unique referral code after several attempts.")
|
||||
|
||||
|
||||
async def generate_unique_email_user_id(session: AsyncSession) -> int:
|
||||
for _ in range(MAX_EMAIL_USER_ID_ATTEMPTS):
|
||||
candidate = -(secrets.randbelow(9_000_000_000_000_000) + 1)
|
||||
if not await get_user_by_id(session, candidate):
|
||||
return candidate
|
||||
raise RuntimeError("Failed to generate a unique email user id after several attempts.")
|
||||
|
||||
|
||||
async def ensure_referral_code(session: AsyncSession, user: User) -> str:
|
||||
"""
|
||||
Ensure the provided user has a referral code, generating and persisting it if missing.
|
||||
Returns the existing or newly generated code.
|
||||
"""
|
||||
if user.referral_code:
|
||||
normalized = user.referral_code.strip().upper()
|
||||
if normalized != user.referral_code:
|
||||
user.referral_code = normalized
|
||||
await session.flush()
|
||||
await session.refresh(user)
|
||||
return user.referral_code
|
||||
|
||||
user.referral_code = await generate_unique_referral_code(session)
|
||||
await session.flush()
|
||||
await session.refresh(user)
|
||||
return user.referral_code
|
||||
|
||||
|
||||
async def get_user_by_id(session: AsyncSession, user_id: int) -> Optional[User]:
|
||||
@@ -23,6 +95,23 @@ async def get_user_by_username(session: AsyncSession, username: str) -> Optional
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_user_by_email(session: AsyncSession, email: str) -> Optional[User]:
|
||||
clean_email = (email or "").strip().lower()
|
||||
if not clean_email:
|
||||
return None
|
||||
stmt = select(User).where(func.lower(User.email) == clean_email)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_user_by_telegram_id(
|
||||
session: AsyncSession, telegram_id: int
|
||||
) -> Optional[User]:
|
||||
stmt = select(User).where(User.telegram_id == telegram_id)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_user_by_panel_uuid(
|
||||
session: AsyncSession, panel_uuid: str
|
||||
) -> Optional[User]:
|
||||
@@ -43,6 +132,11 @@ async def create_user(session: AsyncSession, user_data: Dict[str, Any]) -> Tuple
|
||||
if "registration_date" not in user_data:
|
||||
user_data["registration_date"] = datetime.now(timezone.utc)
|
||||
|
||||
if not user_data.get("referral_code"):
|
||||
user_data["referral_code"] = await generate_unique_referral_code(session)
|
||||
else:
|
||||
user_data["referral_code"] = user_data["referral_code"].strip().upper()
|
||||
|
||||
# Use PostgreSQL upsert to avoid IntegrityError on concurrent inserts
|
||||
stmt = (
|
||||
pg_insert(User)
|
||||
@@ -71,6 +165,313 @@ async def create_user(session: AsyncSession, user_data: Dict[str, Any]) -> Tuple
|
||||
return user, created
|
||||
|
||||
|
||||
async def create_email_user(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
email: str,
|
||||
language_code: str,
|
||||
email_verified_at: Optional[datetime] = None,
|
||||
referred_by_id: Optional[int] = None,
|
||||
) -> Tuple[User, bool]:
|
||||
normalized_email = (email or "").strip().lower()
|
||||
user_id = await generate_unique_email_user_id(session)
|
||||
return await create_user(
|
||||
session,
|
||||
{
|
||||
"user_id": user_id,
|
||||
"email": normalized_email,
|
||||
"email_verified_at": email_verified_at or datetime.now(timezone.utc),
|
||||
"language_code": language_code,
|
||||
"referred_by_id": referred_by_id,
|
||||
"registration_date": datetime.now(timezone.utc),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def _has_active_panel_subscription(
|
||||
session: AsyncSession, user_id: int, panel_user_uuid: str
|
||||
) -> bool:
|
||||
stmt = (
|
||||
select(Subscription.subscription_id)
|
||||
.where(
|
||||
Subscription.user_id == user_id,
|
||||
Subscription.panel_user_uuid == panel_user_uuid,
|
||||
Subscription.is_active == True,
|
||||
Subscription.end_date > datetime.now(timezone.utc),
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
|
||||
async def _get_latest_subscription_for_user(
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
panel_user_uuid: Optional[str] = None,
|
||||
*,
|
||||
active_only: bool = False,
|
||||
) -> Optional[Subscription]:
|
||||
stmt = select(Subscription).where(Subscription.user_id == user_id)
|
||||
if panel_user_uuid is not None:
|
||||
stmt = stmt.where(Subscription.panel_user_uuid == panel_user_uuid)
|
||||
if active_only:
|
||||
stmt = stmt.where(
|
||||
Subscription.is_active == True,
|
||||
Subscription.end_date > datetime.now(timezone.utc),
|
||||
)
|
||||
stmt = stmt.order_by(Subscription.end_date.desc(), Subscription.subscription_id.desc()).limit(1)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def _get_active_subscription_for_user(
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
panel_user_uuid: Optional[str] = None,
|
||||
) -> Optional[Subscription]:
|
||||
return await _get_latest_subscription_for_user(
|
||||
session,
|
||||
user_id,
|
||||
panel_user_uuid,
|
||||
active_only=True,
|
||||
)
|
||||
|
||||
|
||||
async def merge_users(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
source_user_id: int,
|
||||
target_user_id: int,
|
||||
) -> User:
|
||||
"""Merge source user data into target user and remove the source row."""
|
||||
|
||||
if source_user_id == target_user_id:
|
||||
target = await get_user_by_id(session, target_user_id)
|
||||
if not target:
|
||||
raise ValueError("Target user not found.")
|
||||
return target
|
||||
|
||||
source = await get_user_by_id(session, source_user_id)
|
||||
target = await get_user_by_id(session, target_user_id)
|
||||
if not source or not target:
|
||||
raise ValueError("Both source and target users are required for merge.")
|
||||
|
||||
if source.email and target.email and source.email != target.email:
|
||||
raise UserMergeConflictError("Both accounts already have different emails.")
|
||||
if (
|
||||
source.telegram_id
|
||||
and target.telegram_id
|
||||
and int(source.telegram_id) != int(target.telegram_id)
|
||||
):
|
||||
raise UserMergeConflictError("Both accounts already have different Telegram IDs.")
|
||||
|
||||
source_panel_uuid = source.panel_user_uuid
|
||||
target_panel_uuid = target.panel_user_uuid
|
||||
panel_uuid_to_keep = target_panel_uuid or source_panel_uuid
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
source_active_sub = await _get_active_subscription_for_user(
|
||||
session, source_user_id, source_panel_uuid
|
||||
)
|
||||
target_active_sub = await _get_active_subscription_for_user(
|
||||
session, target_user_id, target_panel_uuid
|
||||
)
|
||||
target_anchor_sub = target_active_sub
|
||||
if not target_anchor_sub and target_panel_uuid:
|
||||
target_anchor_sub = await _get_latest_subscription_for_user(
|
||||
session, target_user_id, target_panel_uuid
|
||||
)
|
||||
if not target_anchor_sub and not target_panel_uuid:
|
||||
target_anchor_sub = await _get_latest_subscription_for_user(session, target_user_id)
|
||||
|
||||
if (
|
||||
source_active_sub
|
||||
and target_anchor_sub
|
||||
and source_panel_uuid
|
||||
and target_panel_uuid
|
||||
and source_panel_uuid != target_panel_uuid
|
||||
):
|
||||
source_end = source_active_sub.end_date
|
||||
if source_end.tzinfo is None:
|
||||
source_end = source_end.replace(tzinfo=timezone.utc)
|
||||
|
||||
target_end = target_anchor_sub.end_date
|
||||
if target_end.tzinfo is None:
|
||||
target_end = target_end.replace(tzinfo=timezone.utc)
|
||||
|
||||
source_remaining = max(timedelta(0), source_end - now)
|
||||
if source_remaining > timedelta(0):
|
||||
base_end = target_end if target_end > now else now
|
||||
target_anchor_sub.end_date = base_end + source_remaining
|
||||
target_anchor_sub.last_notification_sent = None
|
||||
target_anchor_sub.is_active = True
|
||||
target_anchor_sub.status_from_panel = "ACTIVE_EXTENDED_BY_MERGE"
|
||||
|
||||
source_active_sub.is_active = False
|
||||
source_active_sub.skip_notifications = True
|
||||
source_active_sub.last_notification_sent = None
|
||||
source_active_sub.status_from_panel = "MERGED_INTO_ACCOUNT"
|
||||
elif (
|
||||
source_active_sub
|
||||
and target_panel_uuid
|
||||
and source_panel_uuid
|
||||
and source_panel_uuid != target_panel_uuid
|
||||
and not target_anchor_sub
|
||||
):
|
||||
source_active_sub.panel_user_uuid = target_panel_uuid
|
||||
source_active_sub.last_notification_sent = None
|
||||
source_active_sub.status_from_panel = "ACTIVE_EXTENDED_BY_MERGE"
|
||||
|
||||
email_to_move = source.email if source.email and not target.email else None
|
||||
email_verified_at_to_move = (
|
||||
source.email_verified_at
|
||||
if source.email and (not target.email_verified_at or email_to_move)
|
||||
else None
|
||||
)
|
||||
telegram_id_to_move = (
|
||||
source.telegram_id if source.telegram_id and not target.telegram_id else None
|
||||
)
|
||||
referral_code_to_move = (
|
||||
source.referral_code if source.referral_code and not target.referral_code else None
|
||||
)
|
||||
|
||||
if email_to_move:
|
||||
source.email = None
|
||||
if telegram_id_to_move:
|
||||
source.telegram_id = None
|
||||
if referral_code_to_move:
|
||||
source.referral_code = None
|
||||
if email_to_move or source_panel_uuid or telegram_id_to_move or referral_code_to_move:
|
||||
await session.flush()
|
||||
|
||||
if email_to_move:
|
||||
target.email = email_to_move
|
||||
if email_verified_at_to_move and not target.email_verified_at:
|
||||
target.email_verified_at = email_verified_at_to_move
|
||||
if telegram_id_to_move:
|
||||
target.telegram_id = telegram_id_to_move
|
||||
if panel_uuid_to_keep and not target.panel_user_uuid:
|
||||
target.panel_user_uuid = panel_uuid_to_keep
|
||||
if referral_code_to_move:
|
||||
target.referral_code = referral_code_to_move
|
||||
|
||||
for attr in ("username", "first_name", "last_name", "language_code", "telegram_photo_url"):
|
||||
if not getattr(target, attr) and getattr(source, attr):
|
||||
setattr(target, attr, getattr(source, attr))
|
||||
if not target.channel_subscription_verified and source.channel_subscription_verified is not None:
|
||||
target.channel_subscription_verified = source.channel_subscription_verified
|
||||
if not target.channel_subscription_checked_at and source.channel_subscription_checked_at:
|
||||
target.channel_subscription_checked_at = source.channel_subscription_checked_at
|
||||
if not target.channel_subscription_verified_for and source.channel_subscription_verified_for:
|
||||
target.channel_subscription_verified_for = source.channel_subscription_verified_for
|
||||
if source.lifetime_used_traffic_bytes is not None:
|
||||
target.lifetime_used_traffic_bytes = (
|
||||
(target.lifetime_used_traffic_bytes or 0) + source.lifetime_used_traffic_bytes
|
||||
)
|
||||
if not target.referred_by_id and source.referred_by_id != target_user_id:
|
||||
target.referred_by_id = source.referred_by_id
|
||||
if target.referred_by_id == source_user_id:
|
||||
target.referred_by_id = source.referred_by_id
|
||||
if target.referred_by_id == target_user_id:
|
||||
target.referred_by_id = None
|
||||
|
||||
target_method_ids = select(UserPaymentMethod.provider_payment_method_id).where(
|
||||
UserPaymentMethod.user_id == target_user_id
|
||||
)
|
||||
await session.execute(
|
||||
delete(UserPaymentMethod).where(
|
||||
UserPaymentMethod.user_id == source_user_id,
|
||||
UserPaymentMethod.provider_payment_method_id.in_(target_method_ids),
|
||||
)
|
||||
)
|
||||
|
||||
target_promo_ids = select(PromoCodeActivation.promo_code_id).where(
|
||||
PromoCodeActivation.user_id == target_user_id
|
||||
)
|
||||
await session.execute(
|
||||
delete(PromoCodeActivation).where(
|
||||
PromoCodeActivation.user_id == source_user_id,
|
||||
PromoCodeActivation.promo_code_id.in_(target_promo_ids),
|
||||
)
|
||||
)
|
||||
|
||||
target_has_billing = (
|
||||
await session.execute(
|
||||
select(UserBilling.user_id).where(UserBilling.user_id == target_user_id)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if target_has_billing:
|
||||
await session.execute(delete(UserBilling).where(UserBilling.user_id == source_user_id))
|
||||
else:
|
||||
await session.execute(
|
||||
update(UserBilling)
|
||||
.where(UserBilling.user_id == source_user_id)
|
||||
.values(user_id=target_user_id)
|
||||
)
|
||||
|
||||
target_has_attribution = (
|
||||
await session.execute(
|
||||
select(AdAttribution.user_id).where(AdAttribution.user_id == target_user_id)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if target_has_attribution:
|
||||
await session.execute(
|
||||
delete(AdAttribution).where(AdAttribution.user_id == source_user_id)
|
||||
)
|
||||
else:
|
||||
await session.execute(
|
||||
update(AdAttribution)
|
||||
.where(AdAttribution.user_id == source_user_id)
|
||||
.values(user_id=target_user_id)
|
||||
)
|
||||
|
||||
subscription_update_values: Dict[str, Any] = {"user_id": target_user_id}
|
||||
if panel_uuid_to_keep:
|
||||
subscription_update_values["panel_user_uuid"] = panel_uuid_to_keep
|
||||
await session.execute(
|
||||
update(Subscription)
|
||||
.where(Subscription.user_id == source_user_id)
|
||||
.values(**subscription_update_values)
|
||||
)
|
||||
for model in (Payment, PromoCodeActivation, UserPaymentMethod):
|
||||
await session.execute(
|
||||
update(model)
|
||||
.where(model.user_id == source_user_id)
|
||||
.values(user_id=target_user_id)
|
||||
)
|
||||
|
||||
await session.execute(
|
||||
update(MessageLog)
|
||||
.where(MessageLog.user_id == source_user_id)
|
||||
.values(user_id=target_user_id)
|
||||
)
|
||||
await session.execute(
|
||||
update(MessageLog)
|
||||
.where(MessageLog.target_user_id == source_user_id)
|
||||
.values(target_user_id=target_user_id)
|
||||
)
|
||||
await session.execute(
|
||||
update(User)
|
||||
.where(User.referred_by_id == source_user_id)
|
||||
.values(referred_by_id=target_user_id)
|
||||
)
|
||||
|
||||
await session.delete(source)
|
||||
await session.flush()
|
||||
await session.refresh(target)
|
||||
return target
|
||||
|
||||
|
||||
async def get_user_by_referral_code(session: AsyncSession, referral_code: str) -> Optional[User]:
|
||||
normalized = referral_code.strip().upper()
|
||||
if not normalized:
|
||||
return None
|
||||
stmt = select(User).where(User.referral_code == normalized)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_user(
|
||||
session: AsyncSession, user_id: int, update_data: Dict[str, Any]
|
||||
) -> Optional[User]:
|
||||
@@ -102,6 +503,29 @@ async def get_banned_users(session: AsyncSession) -> List[User]:
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_all_users_paginated(
|
||||
session: AsyncSession, *, page: int = 0, page_size: int = 15
|
||||
) -> List[User]:
|
||||
"""Return a slice of users ordered by newest registration first."""
|
||||
safe_page = max(page, 0)
|
||||
safe_page_size = max(page_size, 1)
|
||||
|
||||
stmt = (
|
||||
select(User)
|
||||
.order_by(User.registration_date.desc())
|
||||
.offset(safe_page * safe_page_size)
|
||||
.limit(safe_page_size)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def count_all_users(session: AsyncSession) -> int:
|
||||
"""Count total number of users."""
|
||||
result = await session.execute(select(func.count(User.user_id)))
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
async def get_all_active_user_ids_for_broadcast(session: AsyncSession) -> List[int]:
|
||||
stmt = select(User.user_id).where(User.is_banned == False)
|
||||
result = await session.execute(stmt)
|
||||
@@ -205,25 +629,181 @@ async def get_user_ids_without_active_subscription(session: AsyncSession) -> Lis
|
||||
from datetime import datetime, timezone
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# Subquery for users with active subscription
|
||||
active_subs_subq = (
|
||||
select(Subscription.user_id)
|
||||
.where(
|
||||
and_(
|
||||
Subscription.is_active == True,
|
||||
Subscription.end_date > now,
|
||||
)
|
||||
)
|
||||
).scalar_subquery()
|
||||
active_subs = aliased(Subscription)
|
||||
|
||||
stmt = (
|
||||
select(User.user_id)
|
||||
.outerjoin(
|
||||
active_subs,
|
||||
and_(
|
||||
active_subs.user_id == User.user_id,
|
||||
active_subs.is_active == True,
|
||||
active_subs.end_date > now,
|
||||
),
|
||||
)
|
||||
.where(
|
||||
and_(
|
||||
User.is_banned == False,
|
||||
~User.user_id.in_(active_subs_subq),
|
||||
active_subs.user_id.is_(None),
|
||||
)
|
||||
)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def delete_user_and_relations(session: AsyncSession, user_id: int) -> bool:
|
||||
"""Completely remove a user and all dependent records from the database.
|
||||
|
||||
This helper ensures we do not leave dangling foreign keys or orphaned data.
|
||||
"""
|
||||
user = await get_user_by_id(session, user_id)
|
||||
if not user:
|
||||
return False
|
||||
|
||||
# Ensure referral pointers do not block deletion
|
||||
await session.execute(
|
||||
update(User).where(User.referred_by_id == user_id).values(referred_by_id=None)
|
||||
)
|
||||
|
||||
# Clean up dependent tables that do not cascade automatically
|
||||
await session.execute(
|
||||
delete(MessageLog).where(
|
||||
or_(MessageLog.user_id == user_id, MessageLog.target_user_id == user_id)
|
||||
)
|
||||
)
|
||||
await session.execute(delete(Payment).where(Payment.user_id == user_id))
|
||||
await session.execute(
|
||||
delete(Subscription).where(Subscription.user_id == user_id)
|
||||
)
|
||||
await session.execute(
|
||||
delete(PromoCodeActivation).where(PromoCodeActivation.user_id == user_id)
|
||||
)
|
||||
await session.execute(
|
||||
delete(UserPaymentMethod).where(UserPaymentMethod.user_id == user_id)
|
||||
)
|
||||
await session.execute(delete(UserBilling).where(UserBilling.user_id == user_id))
|
||||
await session.execute(delete(AdAttribution).where(AdAttribution.user_id == user_id))
|
||||
|
||||
await session.delete(user)
|
||||
await session.flush()
|
||||
return True
|
||||
|
||||
|
||||
async def get_top_users_by_traffic_used(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
limit: int = 10,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Return top users by total used traffic across all subscriptions."""
|
||||
safe_limit = max(1, limit)
|
||||
|
||||
total_traffic_used = func.coalesce(func.sum(Subscription.traffic_used_bytes), 0)
|
||||
|
||||
stmt = (
|
||||
select(
|
||||
User.user_id,
|
||||
User.username,
|
||||
User.first_name,
|
||||
total_traffic_used.label("traffic_used_bytes"),
|
||||
)
|
||||
.join(Subscription, Subscription.user_id == User.user_id, isouter=True)
|
||||
.group_by(User.user_id, User.username, User.first_name)
|
||||
.having(total_traffic_used > 0)
|
||||
.order_by(desc("traffic_used_bytes"), User.user_id.asc())
|
||||
.limit(safe_limit)
|
||||
)
|
||||
|
||||
result = await session.execute(stmt)
|
||||
return [dict(row._mapping) for row in result]
|
||||
|
||||
|
||||
async def get_top_users_by_lifetime_traffic_used(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
limit: int = 10,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Return top users by lifetime used traffic from panel data."""
|
||||
safe_limit = max(1, limit)
|
||||
lifetime_used = func.coalesce(User.lifetime_used_traffic_bytes, 0)
|
||||
|
||||
stmt = (
|
||||
select(
|
||||
User.user_id,
|
||||
User.username,
|
||||
User.first_name,
|
||||
lifetime_used.label("lifetime_used_traffic_bytes"),
|
||||
)
|
||||
.where(lifetime_used > 0)
|
||||
.order_by(desc("lifetime_used_traffic_bytes"), User.user_id.asc())
|
||||
.limit(safe_limit)
|
||||
)
|
||||
|
||||
result = await session.execute(stmt)
|
||||
return [dict(row._mapping) for row in result]
|
||||
|
||||
|
||||
async def get_top_users_by_referrals_count(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
limit: int = 10,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Return top users by number of invited users."""
|
||||
safe_limit = max(1, limit)
|
||||
referred_user = aliased(User)
|
||||
|
||||
invited_count = func.count(referred_user.user_id)
|
||||
|
||||
stmt = (
|
||||
select(
|
||||
User.user_id,
|
||||
User.username,
|
||||
User.first_name,
|
||||
invited_count.label("invited_count"),
|
||||
)
|
||||
.join(referred_user, referred_user.referred_by_id == User.user_id, isouter=True)
|
||||
.group_by(User.user_id, User.username, User.first_name)
|
||||
.having(invited_count > 0)
|
||||
.order_by(desc("invited_count"), User.user_id.asc())
|
||||
.limit(safe_limit)
|
||||
)
|
||||
|
||||
result = await session.execute(stmt)
|
||||
return [dict(row._mapping) for row in result]
|
||||
|
||||
|
||||
async def get_top_users_by_referral_revenue(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
limit: int = 10,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Return top users by total revenue brought by all invited users."""
|
||||
safe_limit = max(1, limit)
|
||||
referred_user = aliased(User)
|
||||
|
||||
referral_revenue = func.coalesce(func.sum(Payment.amount), 0.0)
|
||||
|
||||
stmt = (
|
||||
select(
|
||||
User.user_id,
|
||||
User.username,
|
||||
User.first_name,
|
||||
referral_revenue.label("referral_revenue"),
|
||||
)
|
||||
.join(referred_user, referred_user.referred_by_id == User.user_id, isouter=True)
|
||||
.join(
|
||||
Payment,
|
||||
and_(
|
||||
Payment.user_id == referred_user.user_id,
|
||||
Payment.status == "succeeded",
|
||||
),
|
||||
isouter=True,
|
||||
)
|
||||
.group_by(User.user_id, User.username, User.first_name)
|
||||
.having(referral_revenue > 0)
|
||||
.order_by(desc("referral_revenue"), User.user_id.asc())
|
||||
.limit(safe_limit)
|
||||
)
|
||||
|
||||
result = await session.execute(stmt)
|
||||
return [dict(row._mapping) for row in result]
|
||||
|
||||
@@ -20,6 +20,8 @@ def init_db_connection(settings: Settings) -> sessionmaker:
|
||||
settings.DATABASE_URL,
|
||||
echo=False,
|
||||
pool_pre_ping=True,
|
||||
pool_size=20,
|
||||
max_overflow=10,
|
||||
)
|
||||
|
||||
local_async_session_factory = async_sessionmaker(
|
||||
|
||||
+327
@@ -48,12 +48,339 @@ def _migration_0001_add_channel_subscription_fields(connection: Connection) -> N
|
||||
connection.execute(text(stmt))
|
||||
|
||||
|
||||
def _migration_0002_add_referral_code(connection: Connection) -> None:
|
||||
inspector = inspect(connection)
|
||||
columns: Set[str] = {col["name"] for col in inspector.get_columns("users")}
|
||||
|
||||
if "referral_code" not in columns:
|
||||
connection.execute(
|
||||
text("ALTER TABLE users ADD COLUMN referral_code VARCHAR(16)")
|
||||
)
|
||||
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
WITH generated_codes AS (
|
||||
SELECT
|
||||
user_id,
|
||||
UPPER(
|
||||
SUBSTRING(
|
||||
md5(
|
||||
user_id::text
|
||||
|| clock_timestamp()::text
|
||||
|| random()::text
|
||||
)
|
||||
FROM 1 FOR 9
|
||||
)
|
||||
) AS referral_code
|
||||
FROM users
|
||||
WHERE referral_code IS NULL OR referral_code = ''
|
||||
)
|
||||
UPDATE users AS u
|
||||
SET referral_code = g.referral_code
|
||||
FROM generated_codes AS g
|
||||
WHERE u.user_id = g.user_id
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_users_referral_code
|
||||
ON users (referral_code)
|
||||
WHERE referral_code IS NOT NULL
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _migration_0003_normalize_referral_codes(connection: Connection) -> None:
|
||||
inspector = inspect(connection)
|
||||
columns: Set[str] = {col["name"] for col in inspector.get_columns("users")}
|
||||
if "referral_code" not in columns:
|
||||
return
|
||||
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE users
|
||||
SET referral_code = UPPER(referral_code)
|
||||
WHERE referral_code IS NOT NULL
|
||||
AND referral_code <> UPPER(referral_code)
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _migration_0004_add_lifetime_used_traffic(connection: Connection) -> None:
|
||||
inspector = inspect(connection)
|
||||
columns: Set[str] = {col["name"] for col in inspector.get_columns("users")}
|
||||
if "lifetime_used_traffic_bytes" in columns:
|
||||
return
|
||||
|
||||
connection.execute(
|
||||
text(
|
||||
"ALTER TABLE users ADD COLUMN lifetime_used_traffic_bytes BIGINT"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _migration_0005_add_email_auth_fields(connection: Connection) -> None:
|
||||
inspector = inspect(connection)
|
||||
columns: Set[str] = {col["name"] for col in inspector.get_columns("users")}
|
||||
|
||||
if "email" not in columns:
|
||||
connection.execute(text("ALTER TABLE users ADD COLUMN email VARCHAR"))
|
||||
if "email_verified_at" not in columns:
|
||||
connection.execute(
|
||||
text("ALTER TABLE users ADD COLUMN email_verified_at TIMESTAMPTZ")
|
||||
)
|
||||
if "telegram_id" not in columns:
|
||||
connection.execute(text("ALTER TABLE users ADD COLUMN telegram_id BIGINT"))
|
||||
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE users
|
||||
SET telegram_id = user_id
|
||||
WHERE telegram_id IS NULL
|
||||
AND user_id > 0
|
||||
"""
|
||||
)
|
||||
)
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_users_email
|
||||
ON users (email)
|
||||
WHERE email IS NOT NULL
|
||||
"""
|
||||
)
|
||||
)
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_users_telegram_id
|
||||
ON users (telegram_id)
|
||||
WHERE telegram_id IS NOT NULL
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS email_verification_codes (
|
||||
code_id SERIAL PRIMARY KEY,
|
||||
email VARCHAR NOT NULL,
|
||||
code_hash VARCHAR NOT NULL,
|
||||
purpose VARCHAR NOT NULL,
|
||||
target_user_id BIGINT NULL REFERENCES users(user_id),
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
consumed_at TIMESTAMPTZ NULL,
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS ix_email_verification_codes_lookup
|
||||
ON email_verification_codes (email, purpose, target_user_id, created_at DESC)
|
||||
"""
|
||||
)
|
||||
)
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS ix_email_verification_codes_expires_at
|
||||
ON email_verification_codes (expires_at)
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _migration_0006_add_security_throttles(connection: Connection) -> None:
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS security_throttles (
|
||||
throttle_id SERIAL PRIMARY KEY,
|
||||
scope VARCHAR(64) NOT NULL,
|
||||
identifier VARCHAR(512) NOT NULL,
|
||||
failures INTEGER NOT NULL DEFAULT 0,
|
||||
window_started_at TIMESTAMPTZ NULL,
|
||||
locked_until TIMESTAMPTZ NULL,
|
||||
last_attempt_at TIMESTAMPTZ NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NULL,
|
||||
CONSTRAINT uq_security_throttles_scope_identifier UNIQUE (scope, identifier)
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS ix_security_throttles_scope
|
||||
ON security_throttles (scope)
|
||||
"""
|
||||
)
|
||||
)
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS ix_security_throttles_locked_until
|
||||
ON security_throttles (locked_until)
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _migration_0007_add_telegram_photo_url(connection: Connection) -> None:
|
||||
inspector = inspect(connection)
|
||||
columns: Set[str] = {col["name"] for col in inspector.get_columns("users")}
|
||||
if "telegram_photo_url" in columns:
|
||||
return
|
||||
|
||||
connection.execute(
|
||||
text("ALTER TABLE users ADD COLUMN telegram_photo_url TEXT")
|
||||
)
|
||||
|
||||
|
||||
def _migration_0008_add_email_verification_code_status(connection: Connection) -> None:
|
||||
inspector = inspect(connection)
|
||||
columns: Set[str] = {col["name"] for col in inspector.get_columns("email_verification_codes")}
|
||||
|
||||
if "status" not in columns:
|
||||
connection.execute(
|
||||
text(
|
||||
"ALTER TABLE email_verification_codes ADD COLUMN status VARCHAR NOT NULL DEFAULT 'active'"
|
||||
)
|
||||
)
|
||||
else:
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE email_verification_codes
|
||||
SET status = 'active'
|
||||
WHERE status IS NULL OR status = ''
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS ix_email_verification_codes_status
|
||||
ON email_verification_codes (status)
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _migration_0010_add_email_magic_token_hash(connection: Connection) -> None:
|
||||
inspector = inspect(connection)
|
||||
columns: Set[str] = {col["name"] for col in inspector.get_columns("email_verification_codes")}
|
||||
|
||||
if "magic_token_hash" not in columns:
|
||||
connection.execute(
|
||||
text(
|
||||
"ALTER TABLE email_verification_codes ADD COLUMN magic_token_hash VARCHAR"
|
||||
)
|
||||
)
|
||||
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS ix_email_verification_codes_magic_token_hash
|
||||
ON email_verification_codes (magic_token_hash)
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _migration_0009_add_composite_indexes(connection: Connection) -> None:
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS ix_subscriptions_is_active_end_date
|
||||
ON subscriptions (is_active, end_date)
|
||||
"""
|
||||
)
|
||||
)
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS ix_subscriptions_user_id_is_active
|
||||
ON subscriptions (user_id, is_active)
|
||||
"""
|
||||
)
|
||||
)
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS ix_payments_user_id_status
|
||||
ON payments (user_id, status)
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
MIGRATIONS: List[Migration] = [
|
||||
Migration(
|
||||
id="0001_add_channel_subscription_fields",
|
||||
description="Add columns to track required channel subscription verification",
|
||||
upgrade=_migration_0001_add_channel_subscription_fields,
|
||||
),
|
||||
Migration(
|
||||
id="0002_add_referral_code",
|
||||
description="Store short referral codes for users and backfill existing rows",
|
||||
upgrade=_migration_0002_add_referral_code,
|
||||
),
|
||||
Migration(
|
||||
id="0003_normalize_referral_codes",
|
||||
description="Normalize referral codes to uppercase for consistent lookups",
|
||||
upgrade=_migration_0003_normalize_referral_codes,
|
||||
),
|
||||
Migration(
|
||||
id="0004_add_lifetime_used_traffic",
|
||||
description="Store lifetime traffic usage for users",
|
||||
upgrade=_migration_0004_add_lifetime_used_traffic,
|
||||
),
|
||||
Migration(
|
||||
id="0005_add_email_auth_fields",
|
||||
description="Add email login identities and verification codes",
|
||||
upgrade=_migration_0005_add_email_auth_fields,
|
||||
),
|
||||
Migration(
|
||||
id="0006_add_security_throttles",
|
||||
description="Add generic lockout tracking for brute-force protection",
|
||||
upgrade=_migration_0006_add_security_throttles,
|
||||
),
|
||||
Migration(
|
||||
id="0007_add_telegram_photo_url",
|
||||
description="Store Telegram profile photo URLs for linked users",
|
||||
upgrade=_migration_0007_add_telegram_photo_url,
|
||||
),
|
||||
Migration(
|
||||
id="0008_add_email_verification_code_status",
|
||||
description="Track superseded email verification codes explicitly",
|
||||
upgrade=_migration_0008_add_email_verification_code_status,
|
||||
),
|
||||
Migration(
|
||||
id="0009_add_composite_indexes",
|
||||
description="Add composite indexes for subscription and payment lookups",
|
||||
upgrade=_migration_0009_add_composite_indexes,
|
||||
),
|
||||
Migration(
|
||||
id="0010_add_email_magic_token_hash",
|
||||
description="Store hashed magic-link tokens for email login deeplinks",
|
||||
upgrade=_migration_0010_add_email_magic_token_hash,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
|
||||
+55
-1
@@ -1,4 +1,4 @@
|
||||
from sqlalchemy import create_engine, Column, Integer, String, Boolean, DateTime, Float, ForeignKey, UniqueConstraint, Text, BigInteger
|
||||
from sqlalchemy import create_engine, Column, Integer, String, Boolean, DateTime, Float, ForeignKey, UniqueConstraint, Text, BigInteger, Index
|
||||
from sqlalchemy.orm import relationship, DeclarativeBase
|
||||
from sqlalchemy.ext.asyncio import AsyncAttrs
|
||||
from sqlalchemy.sql import func
|
||||
@@ -14,6 +14,10 @@ class User(Base):
|
||||
|
||||
user_id = Column(BigInteger, primary_key=True, index=True)
|
||||
username = Column(String, nullable=True, index=True)
|
||||
email = Column(String, nullable=True, unique=True, index=True)
|
||||
email_verified_at = Column(DateTime(timezone=True), nullable=True)
|
||||
telegram_id = Column(BigInteger, nullable=True, unique=True, index=True)
|
||||
telegram_photo_url = Column(Text, nullable=True)
|
||||
first_name = Column(String, nullable=True)
|
||||
last_name = Column(String, nullable=True)
|
||||
language_code = Column(String, default="ru")
|
||||
@@ -21,9 +25,11 @@ class User(Base):
|
||||
server_default=func.now())
|
||||
is_banned = Column(Boolean, default=False)
|
||||
panel_user_uuid = Column(String, nullable=True, unique=True, index=True)
|
||||
referral_code = Column(String(16), nullable=True, unique=True, index=True)
|
||||
referred_by_id = Column(BigInteger,
|
||||
ForeignKey("users.user_id"),
|
||||
nullable=True)
|
||||
lifetime_used_traffic_bytes = Column(BigInteger, nullable=True)
|
||||
channel_subscription_verified = Column(Boolean, nullable=True)
|
||||
channel_subscription_checked_at = Column(DateTime(timezone=True),
|
||||
nullable=True)
|
||||
@@ -55,6 +61,10 @@ class User(Base):
|
||||
|
||||
class Subscription(Base):
|
||||
__tablename__ = "subscriptions"
|
||||
__table_args__ = (
|
||||
Index("ix_subscriptions_is_active_end_date", "is_active", "end_date"),
|
||||
Index("ix_subscriptions_user_id_is_active", "user_id", "is_active"),
|
||||
)
|
||||
|
||||
subscription_id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id = Column(BigInteger,
|
||||
@@ -84,8 +94,52 @@ class Subscription(Base):
|
||||
return f"<Subscription(id={self.subscription_id}, user_id={self.user_id}, panel_uuid='{self.panel_user_uuid}', ends='{self.end_date}')>"
|
||||
|
||||
|
||||
class EmailVerificationCode(Base):
|
||||
__tablename__ = "email_verification_codes"
|
||||
|
||||
code_id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
email = Column(String, nullable=False, index=True)
|
||||
code_hash = Column(String, nullable=False)
|
||||
magic_token_hash = Column(String, nullable=True, index=True)
|
||||
purpose = Column(String, nullable=False, index=True)
|
||||
target_user_id = Column(
|
||||
BigInteger,
|
||||
ForeignKey("users.user_id"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
expires_at = Column(DateTime(timezone=True), nullable=False, index=True)
|
||||
consumed_at = Column(DateTime(timezone=True), nullable=True)
|
||||
status = Column(String, nullable=False, default="active", index=True)
|
||||
attempts = Column(Integer, nullable=False, default=0)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
target_user = relationship("User")
|
||||
|
||||
|
||||
class SecurityThrottle(Base):
|
||||
__tablename__ = "security_throttles"
|
||||
|
||||
throttle_id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
scope = Column(String(64), nullable=False, index=True)
|
||||
identifier = Column(String(512), nullable=False, index=True)
|
||||
failures = Column(Integer, nullable=False, default=0)
|
||||
window_started_at = Column(DateTime(timezone=True), nullable=True)
|
||||
locked_until = Column(DateTime(timezone=True), nullable=True, index=True)
|
||||
last_attempt_at = Column(DateTime(timezone=True), nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now(), nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("scope", "identifier", name="uq_security_throttles_scope_identifier"),
|
||||
)
|
||||
|
||||
|
||||
class Payment(Base):
|
||||
__tablename__ = "payments"
|
||||
__table_args__ = (
|
||||
Index("ix_payments_user_id_status", "user_id", "status"),
|
||||
)
|
||||
|
||||
payment_id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id = Column(BigInteger,
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
services:
|
||||
remnawave-minishop:
|
||||
image: ghcr.io/3252a8/remnawave-minishop:${IMAGE_TAG:-latest}
|
||||
container_name: remnawave-minishop
|
||||
hostname: remnawave-minishop
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- TZ=UTC
|
||||
volumes:
|
||||
- ./locales:/app/locales
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
remnawave-minishop-db:
|
||||
condition: service_healthy
|
||||
|
||||
remnawave-minishop-db:
|
||||
image: postgres:17
|
||||
container_name: remnawave-minishop-db
|
||||
hostname: remnawave-minishop-db
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- TZ=UTC
|
||||
volumes:
|
||||
- remnawave-minishop-db-data:/var/lib/postgresql/data
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
|
||||
caddy:
|
||||
image: caddy:2-alpine
|
||||
container_name: remnawave-minishop-caddy
|
||||
hostname: remnawave-minishop-caddy
|
||||
environment:
|
||||
- TZ=UTC
|
||||
- WEB_SERVER_PORT=${WEB_SERVER_PORT:-8080}
|
||||
- WEBAPP_SERVER_PORT=${WEBAPP_SERVER_PORT:-8081}
|
||||
depends_on:
|
||||
- remnawave-minishop
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- caddy_data:/data
|
||||
- caddy_config:/config
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
remnawave-minishop-db-data:
|
||||
name: remnawave-minishop-db-data
|
||||
caddy_data:
|
||||
name: remnawave-minishop-caddy-data
|
||||
caddy_config:
|
||||
name: remnawave-minishop-caddy-config
|
||||
@@ -0,0 +1,42 @@
|
||||
services:
|
||||
remnawave-minishop:
|
||||
image: ghcr.io/3252a8/remnawave-minishop:${IMAGE_TAG:-latest}
|
||||
container_name: remnawave-minishop
|
||||
hostname: remnawave-minishop
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- '127.0.0.1:8080:8080'
|
||||
- '127.0.0.1:${WEBAPP_SERVER_PORT:-8081}:${WEBAPP_SERVER_PORT:-8081}'
|
||||
networks:
|
||||
- remnawave-network
|
||||
environment:
|
||||
- TZ=UTC
|
||||
volumes:
|
||||
- ./locales:/app/locales
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- remnawave-minishop-db
|
||||
remnawave-minishop-db:
|
||||
image: postgres:17
|
||||
container_name: remnawave-minishop-db
|
||||
hostname: remnawave-minishop-db
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- TZ=UTC
|
||||
volumes:
|
||||
- remnawave-minishop-db-data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- remnawave-network
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
networks:
|
||||
remnawave-network: null
|
||||
volumes:
|
||||
remnawave-minishop-db-data:
|
||||
name: remnawave-minishop-db-data
|
||||
+20
-14
@@ -1,28 +1,34 @@
|
||||
services:
|
||||
remnawave-tg-shop:
|
||||
image: ghcr.io/machka-pasla/remnawave-tg-shop:latest
|
||||
# build: .
|
||||
container_name: remnawave-tg-shop
|
||||
hostname: remnawave-tg-shop
|
||||
remnawave-minishop:
|
||||
image: ghcr.io/3252a8/remnawave-minishop:${IMAGE_TAG:-latest}
|
||||
container_name: remnawave-minishop
|
||||
hostname: remnawave-minishop
|
||||
env_file:
|
||||
- .env
|
||||
networks:
|
||||
- remnawave-network
|
||||
volumes:
|
||||
- ./locales:/app/locales
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
remnawave-tg-shop-db:
|
||||
remnawave-minishop-db:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- 127.0.0.1:8080:8080
|
||||
- 127.0.0.1:${WEBAPP_SERVER_PORT:-8081}:${WEBAPP_SERVER_PORT:-8081}
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/healthz >/dev/null || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
|
||||
remnawave-tg-shop-db:
|
||||
remnawave-minishop-db:
|
||||
image: postgres:17
|
||||
container_name: remnawave-tg-shop-db
|
||||
hostname: remnawave-tg-shop-db
|
||||
container_name: remnawave-minishop-db
|
||||
hostname: remnawave-minishop-db
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- remnawave-tg-shop-db-data:/var/lib/postgresql/data
|
||||
- remnawave-minishop-db-data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- remnawave-network
|
||||
restart: unless-stopped
|
||||
@@ -37,5 +43,5 @@ networks:
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
remnawave-tg-shop-db-data:
|
||||
name: remnawave-tg-shop-db-data
|
||||
remnawave-minishop-db-data:
|
||||
name: remnawave-minishop-db-data
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
# Миграция с `remnawave-tg-shop` на `remnawave-minishop`
|
||||
|
||||
Начиная с этой версии контейнеры и тома названы `remnawave-minishop*` вместо `remnawave-tg-shop*`. Старый и новый стеки используют **разные имена томов**, поэтому простой `docker compose up -d` после `git pull` создаст пустую БД. Эта инструкция описывает, как перенести данные.
|
||||
|
||||
Есть два пути:
|
||||
|
||||
- [Автоматический](#автоматический-способ-через-скрипт) — один скрипт, идемпотентный, проверяет состояние на каждом шаге.
|
||||
- [Ручной](#ручной-способ) — команды, которые делает скрипт, если хочется понимать происходящее или выполнить выборочно.
|
||||
|
||||
В обоих случаях:
|
||||
- старые тома **не удаляются** автоматически — это безопасный бэкап на случай отката;
|
||||
- сертификаты Caddy (если используется `docker-compose-caddy.yml`) тоже переносятся, чтобы Let's Encrypt не выписывал их заново и не упереться в rate limit.
|
||||
|
||||
## Автоматический способ (через скрипт)
|
||||
|
||||
Если helper ещё не лежит у вас локально, запускайте его прямо из `raw` из корня старого репозитория:
|
||||
|
||||
```bash
|
||||
bash <(curl -fsSL https://raw.githubusercontent.com/3252a8/remnawave-minishop/main/scripts/migrate_to_minishop.sh)
|
||||
```
|
||||
|
||||
> Команда выше рассчитана на `bash` / Git Bash / WSL. Если вы запускаете из PowerShell, удобнее сначала открыть Git Bash.
|
||||
|
||||
Если вы уже подтянули новую версию и файл есть локально, можно запускать и так:
|
||||
|
||||
```bash
|
||||
bash scripts/migrate_to_minishop.sh
|
||||
```
|
||||
|
||||
По умолчанию скрипт работает с `docker-compose.yml` и переключается на ветку `main`. Можно переопределить через переменные окружения:
|
||||
|
||||
| Переменная | Назначение | По умолчанию |
|
||||
| ----------------- | ----------------------------------------------------------------------- | ---------------------- |
|
||||
| `PROJECT_ROOT` | Явный путь к корню старого репозитория, если запуск не из него | текущая директория |
|
||||
| `COMPOSE_FILE` | Какой compose-файл стартовать в конце | `docker-compose.yml` |
|
||||
| `TARGET_BRANCH` | На какую ветку переключаться и подтягивать обновления | `main` |
|
||||
| `GIT_REMOTE` | Какой remote использовать для `fetch`/`pull` | `origin` |
|
||||
| `NEW_ORIGIN_URL` | Если задано и не совпадает с URL выбранного remote — он будет обновлён | (не меняется) |
|
||||
| `ASSUME_YES` | `1` — не задавать интерактивных вопросов | `0` |
|
||||
|
||||
Примеры:
|
||||
|
||||
```bash
|
||||
# Caddy-вариант из raw
|
||||
COMPOSE_FILE=docker-compose-caddy.yml \
|
||||
bash <(curl -fsSL https://raw.githubusercontent.com/3252a8/remnawave-minishop/main/scripts/migrate_to_minishop.sh)
|
||||
|
||||
# С переключением origin на форк 3252a8
|
||||
NEW_ORIGIN_URL=https://github.com/3252a8/remnawave-minishop.git \
|
||||
bash <(curl -fsSL https://raw.githubusercontent.com/3252a8/remnawave-minishop/main/scripts/migrate_to_minishop.sh)
|
||||
|
||||
# Без интерактива
|
||||
ASSUME_YES=1 \
|
||||
bash <(curl -fsSL https://raw.githubusercontent.com/3252a8/remnawave-minishop/main/scripts/migrate_to_minishop.sh)
|
||||
```
|
||||
|
||||
Что делает скрипт:
|
||||
|
||||
1. **Останавливает текущий стек**: проверяет известные контейнеры старой и новой схемы и останавливает их, если они запущены.
|
||||
2. **Переключает `origin`**, если задана переменная `NEW_ORIGIN_URL`, иначе оставляет как есть.
|
||||
3. **Подтягивает целевую ветку** (`git fetch` + `git switch` + `git pull --ff-only`). Прерывается, если в рабочем дереве есть незакоммиченные изменения.
|
||||
4. **Обновляет `.env`** и правит `POSTGRES_HOST`, если он ещё указывает на старый контейнер.
|
||||
5. **Подготавливает новый стек в режиме `--no-start`**, чтобы Compose сам создал тома и не ругался на уже существующий volume.
|
||||
6. **Переносит тома** `remnawave-tg-shop-*` → `remnawave-minishop-*` через одноразовый `alpine`-контейнер. Если новый том уже непустой, копирование пропускается.
|
||||
7. **Стартует новый стек** (`docker compose -f $COMPOSE_FILE up -d --remove-orphans`, а для локальной сборки ещё и `--build`) и печатает `docker compose ps`.
|
||||
|
||||
Скрипт идемпотентен: повторный запуск ничего не сломает, просто пропустит уже выполненные шаги.
|
||||
|
||||
После того как убедитесь, что бот работает и данные на месте, удалите старые тома:
|
||||
|
||||
```bash
|
||||
docker volume rm remnawave-tg-shop-db-data
|
||||
docker volume rm remnawave-tg-shop-caddy-data remnawave-tg-shop-caddy-config 2>/dev/null || true
|
||||
```
|
||||
|
||||
> ⚠️ Если у вас есть внешний reverse proxy (Nginx и т.п.), не забудьте поправить в его конфиге `upstream`/`proxy_pass`: имя хоста контейнера изменилось с `remnawave-tg-shop` на `remnawave-minishop`. Скрипт не трогает внешние конфиги.
|
||||
|
||||
## Ручной способ
|
||||
|
||||
1. **Остановите старый стек и обновите код:**
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
git fetch origin
|
||||
git checkout main
|
||||
git pull --ff-only origin main
|
||||
```
|
||||
|
||||
2. **Обновите `.env`:**
|
||||
|
||||
```bash
|
||||
sed -i.bak 's/^POSTGRES_HOST=remnawave-tg-shop-db$/POSTGRES_HOST=remnawave-minishop-db/' .env
|
||||
```
|
||||
|
||||
3. **Подготовьте новый стек без запуска:**
|
||||
|
||||
```bash
|
||||
# Локальная сборка
|
||||
docker compose up --no-start --build
|
||||
|
||||
# Или Caddy-вариант
|
||||
docker compose -f docker-compose-caddy.yml up --no-start --build
|
||||
|
||||
# Или готовый образ
|
||||
docker compose -f docker-compose-remote-server.yml up --no-start
|
||||
```
|
||||
|
||||
4. **Перенесите том БД в новое имя:**
|
||||
|
||||
```bash
|
||||
docker run --rm \
|
||||
-v remnawave-tg-shop-db-data:/from:ro \
|
||||
-v remnawave-minishop-db-data:/to \
|
||||
alpine sh -c "cd /from && cp -a . /to"
|
||||
```
|
||||
|
||||
5. **(Только для Caddy)** перенесите тома Caddy с TLS-сертификатами и состоянием ACME:
|
||||
|
||||
```bash
|
||||
for v in caddy-data caddy-config; do
|
||||
docker run --rm \
|
||||
-v "remnawave-tg-shop-$v":/from:ro \
|
||||
-v "remnawave-minishop-$v":/to \
|
||||
alpine sh -c "cd /from && cp -a . /to"
|
||||
done
|
||||
```
|
||||
|
||||
6. **Запустите новый стек:**
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
# или
|
||||
docker compose -f docker-compose-caddy.yml up -d --build
|
||||
# или
|
||||
docker compose -f docker-compose-remote-server.yml up -d
|
||||
```
|
||||
|
||||
7. **Проверьте:**
|
||||
|
||||
```bash
|
||||
docker compose ps
|
||||
docker compose logs -f remnawave-minishop
|
||||
```
|
||||
|
||||
8. **(Опционально) удалите старые тома**, когда убедитесь, что новый стек стабилен:
|
||||
|
||||
```bash
|
||||
docker volume rm remnawave-tg-shop-db-data
|
||||
docker volume rm remnawave-tg-shop-caddy-data remnawave-tg-shop-caddy-config 2>/dev/null || true
|
||||
```
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
# Тарифы
|
||||
|
||||
В Minishop два режима тарификации, и они **взаимоисключающие**: бот в каждый момент времени продаёт либо подписку на срок (1 / 3 / 6 / 12 месяцев), либо пакеты трафика. Ниже — что именно настраивается, что происходит при покупке и как переключаться.
|
||||
|
||||
## Краткое сравнение
|
||||
|
||||
| | Подписка по времени | Пакеты трафика |
|
||||
| --- | --- | --- |
|
||||
| Что покупает пользователь | Период доступа (1/3/6/12 мес.) | Объём трафика в ГБ |
|
||||
| Срок действия в панели | До конца купленного периода | До 01.01.2099 (фактически бессрочно) |
|
||||
| Лимит трафика | Из `USER_TRAFFIC_LIMIT_GB`, сбрасывается по `USER_TRAFFIC_STRATEGY` | Кумулятивно: каждый платёж **прибавляет** ГБ к лимиту |
|
||||
| Стратегия сброса | `NO_RESET` / `WEEK` / `MONTH` / `DAY` | Принудительно `NO_RESET` |
|
||||
| Автопродление YooKassa | Поддерживается | Выключено |
|
||||
| Реферальные бонусы | Доступны (бонусные дни) | Не выдаются |
|
||||
| Триал | Доступен (`TRIAL_*`) | Доступен (включается отдельно) |
|
||||
|
||||
Бот определяет режим по наличию переменных `TRAFFIC_PACKAGES` или `STARS_TRAFFIC_PACKAGES`: если хотя бы одна из них непустая — включается режим продажи трафика и переменные `*_MONTHS_ENABLED` / `RUB_PRICE_*` игнорируются.
|
||||
|
||||
## Режим «Подписка по времени» (по умолчанию)
|
||||
|
||||
Используется, когда `TRAFFIC_PACKAGES` и `STARS_TRAFFIC_PACKAGES` пусты.
|
||||
|
||||
### Цены и периоды
|
||||
|
||||
Для каждого периода настраивается доступность и две цены — в основной валюте (`DEFAULT_CURRENCY_SYMBOL`, по умолчанию RUB) и в Telegram Stars:
|
||||
|
||||
| Переменная | Описание |
|
||||
| --- | --- |
|
||||
| `1_MONTH_ENABLED` / `3_MONTHS_ENABLED` / `6_MONTHS_ENABLED` / `12_MONTHS_ENABLED` | `true`/`false` — показывать ли период в выборе |
|
||||
| `RUB_PRICE_1_MONTH` / `RUB_PRICE_3_MONTHS` / `RUB_PRICE_6_MONTHS` / `RUB_PRICE_12_MONTHS` | Цена в рублях. Если не задана — кнопка не появится |
|
||||
| `STARS_PRICE_1_MONTH` / `STARS_PRICE_3_MONTHS` / `STARS_PRICE_6_MONTHS` / `STARS_PRICE_12_MONTHS` | Цена в Stars. Используется, когда оплата идёт через `STARS_ENABLED` |
|
||||
|
||||
Если `RUB_PRICE_*` равно `0` — соответствующий период просто скрывается.
|
||||
|
||||
### Трафик пользователя
|
||||
|
||||
Лимит трафика и стратегия его сброса настраиваются глобально и применяются ко всем платным пользователям (в том числе при продлении):
|
||||
|
||||
| Переменная | Описание |
|
||||
| --- | --- |
|
||||
| `USER_TRAFFIC_LIMIT_GB` | Лимит трафика в ГБ. `0` — безлимит |
|
||||
| `USER_TRAFFIC_STRATEGY` | Когда сбрасывается счётчик: `NO_RESET`, `DAY`, `WEEK`, `MONTH` |
|
||||
|
||||
### Что происходит при оплате
|
||||
|
||||
1. Если у пользователя ещё нет активной подписки — стартовая дата = «сейчас». Если есть — новая длительность прибавляется к её `end_date` (продление, а не перезапись).
|
||||
2. К итогу могут добавиться бонусные дни промокода (`promo_codes`) и реферальной программы (`REFERRAL_BONUS_DAYS_*`, `REFEREE_BONUS_DAYS_*`).
|
||||
3. В панели Remnawave у пользователя обновляются `expireAt`, `trafficLimitBytes` и `trafficLimitStrategy` под текущие настройки.
|
||||
|
||||
### Автопродление (только YooKassa)
|
||||
|
||||
Включается через `YOOKASSA_AUTOPAYMENTS_ENABLED=true`. Если включено и пользователь сохранил карту, то за `SUBSCRIPTION_NOTIFY_DAYS_BEFORE` дней до окончания бот пытается списать сумму, равную `RUB_PRICE_*` для длительности из последнего платежа. См. `YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING` для управления чекбоксом «сохранить карту».
|
||||
|
||||
В режиме трафика автопродление принудительно выключается, даже если YooKassa-настройки разрешают его.
|
||||
|
||||
### Уведомления
|
||||
|
||||
Управляются `SUBSCRIPTION_NOTIFICATIONS_ENABLED`, `SUBSCRIPTION_NOTIFY_ON_EXPIRE`, `SUBSCRIPTION_NOTIFY_AFTER_EXPIRE`, `SUBSCRIPTION_NOTIFY_DAYS_BEFORE`. Работают по `end_date` подписки — поэтому в режиме трафика, где `end_date` всегда «2099», они эффективно не отправляются.
|
||||
|
||||
## Режим «Пакеты трафика»
|
||||
|
||||
Включается заданием переменной `TRAFFIC_PACKAGES` (и/или `STARS_TRAFFIC_PACKAGES`) в `.env`.
|
||||
|
||||
### Формат
|
||||
|
||||
```env
|
||||
# Пакеты в основной валюте (RUB), формат "<GB>:<цена>", через запятую
|
||||
TRAFFIC_PACKAGES=10:199,50:799,200:1999
|
||||
|
||||
# Пакеты в Telegram Stars (опционально, если включён STARS_ENABLED)
|
||||
STARS_TRAFFIC_PACKAGES=10:2500,50:9000
|
||||
```
|
||||
|
||||
Для дробных размеров допускается `0.5:99`, `1.5:299` и т. п. Цены допускают `0` (например, для теста).
|
||||
|
||||
Если задана только `STARS_TRAFFIC_PACKAGES`, при выборе пакета бот покажет цену в звёздах и автоматически подменит символ валюты на `⭐`. Если фиатные провайдеры (`YOOKASSA_ENABLED`, `FREEKASSA_ENABLED`, `PLATEGA_ENABLED`, `SEVERPAY_ENABLED`, `CRYPTOPAY_ENABLED`) включены, но `TRAFFIC_PACKAGES` не задана — бот сочтёт это ошибкой конфигурации и не пропустит покупку.
|
||||
|
||||
### Что происходит при оплате
|
||||
|
||||
1. К текущему лимиту трафика пользователя в панели **прибавляется** купленный объём (`new_limit = current_limit + purchase_bytes`). Это работает и для существующих подписчиков.
|
||||
2. Стратегия сброса принудительно ставится в `NO_RESET` — иначе докупленные ГБ обнулялись бы по расписанию.
|
||||
3. `end_date` ставится в `2099-01-01 UTC` (или сохраняется более поздняя, если по какой-то причине уже была). Это нужно, чтобы Remnawave не помечал пользователя как просроченного.
|
||||
4. Реферальные бонусы и автопродление при таких платежах отключены: даже если в `.env` они настроены, в этом режиме бот их не применяет.
|
||||
5. Поле `subscription_duration_months` в БД и в CSV-выгрузке платежей в этом режиме интерпретируется как «количество ГБ», а не как месяцы. В UI и админке это учитывается, но имейте в виду при ручных запросах к БД.
|
||||
|
||||
### Триал в режиме трафика
|
||||
|
||||
Триал работает по той же логике, что и в режиме подписок: задаётся `TRIAL_ENABLED`, `TRIAL_DURATION_DAYS`, `TRIAL_TRAFFIC_LIMIT_GB`, `TRIAL_TRAFFIC_STRATEGY`. То есть пробный период всё ещё ограничен по времени, даже если основная продажа — это пакеты трафика.
|
||||
|
||||
## Как переключиться между режимами
|
||||
|
||||
1. В `.env` либо заполните `TRAFFIC_PACKAGES` (включится режим трафика), либо очистите её (включится режим подписок).
|
||||
2. Перезапустите контейнер: `docker compose up -d`.
|
||||
3. Существующие подписчики не теряют доступ:
|
||||
- При переключении в режим трафика их `end_date` остаётся прежним до следующей покупки. Покупка пакета установит `end_date` = «2099» и переведёт стратегию в `NO_RESET`.
|
||||
- При обратном переключении следующий платёж задаст обычный `end_date` от «сейчас» (или продлит существующий).
|
||||
|
||||
> ⚠️ Одновременно совмещать продажу подписок и трафика бот **не умеет**. Проверка идёт по одному булевому флагу `traffic_sale_mode`.
|
||||
|
||||
## Чеки для самозанятых (nalog.ru)
|
||||
|
||||
Для каждого режима используется отдельный шаблон названия в чеке:
|
||||
|
||||
| Переменная | Назначение |
|
||||
| --- | --- |
|
||||
| `NALOGO_RECEIPT_NAME_SUBSCRIPTION` | Шаблон для подписки. Поддерживает `{months}` |
|
||||
| `NALOGO_RECEIPT_NAME_TRAFFIC` | Шаблон для пакета трафика. Поддерживает `{gb}` |
|
||||
|
||||
Пример: `NALOGO_RECEIPT_NAME_TRAFFIC=traffic package {gb} GB`.
|
||||
|
||||
## Связанные переменные
|
||||
|
||||
| Переменная | Описание |
|
||||
| --- | --- |
|
||||
| `DEFAULT_CURRENCY_SYMBOL` | Что показывается рядом с фиатной ценой (`RUB`, `USD`, `EUR`, …) |
|
||||
| `PAYMENT_METHODS_ORDER` | Порядок кнопок оплаты — общий для обоих режимов |
|
||||
| `STARS_ENABLED` | Без него Stars-цены не предложатся, даже если заданы |
|
||||
| `USER_HWID_DEVICE_LIMIT` | Лимит устройств — общий для обоих режимов, к тарификации не относится |
|
||||
|
||||
## Где это всё в коде
|
||||
|
||||
- Чтение и парсинг переменных: [config/settings.py](../config/settings.py) — `subscription_options`, `traffic_packages`, `stars_traffic_packages`, `traffic_sale_mode`.
|
||||
- Активация платежа: [bot/services/subscription_service.py](../bot/services/subscription_service.py) — `activate_subscription` (ветка по `sale_mode`) и `_activate_traffic_package`.
|
||||
- Выбор тарифа в боте: [bot/handlers/user/subscription/core.py](../bot/handlers/user/subscription/core.py) и [bot/handlers/user/subscription/payments_subscription.py](../bot/handlers/user/subscription/payments_subscription.py).
|
||||
- Web App: [bot/app/web/subscription_webapp.py](../bot/app/web/subscription_webapp.py) — поле `traffic_mode` в payload и логика отображения трафика.
|
||||
+287
-24
@@ -5,18 +5,26 @@
|
||||
"channel_subscription_verify_button": "Check subscription",
|
||||
"channel_subscription_check_failed": "Couldn't verify the subscription. Please try again later or contact support.",
|
||||
"channel_subscription_verified_success": "✅ Subscription confirmed! You're good to go.",
|
||||
"main_menu_greeting": "Hi, {user_name}! 👋\nWhat would you like to do?",
|
||||
"webapp_auth_success": "✅ Login confirmed. Return to the Web App.",
|
||||
"webapp_auth_expired": "The login link has expired. Return to the Web App and try again.",
|
||||
"main_menu_greeting": "Hi, {user_name}! 👋\nOpen \"Personal Account\" to set up the connection, view your subscription, or renew access.",
|
||||
"menu_activate_trial_button": "🆓 Free Trial",
|
||||
"menu_subscribe_inline": "🚀 Purchase",
|
||||
"menu_personal_account_button": "🔑 Personal Account",
|
||||
"menu_my_subscription_inline": "🔐 My Subscription",
|
||||
"no_subscription_options_available": "Subscription issuance is not configured by the bot administrator",
|
||||
"menu_referral_inline": "🎁 Referrals",
|
||||
"menu_referral_inline": "🎁 Invite friend",
|
||||
"referral_no_bonuses_configured": "Sorry, the referral program is currently disabled",
|
||||
"menu_apply_promo_button": "🎟 Promo Code",
|
||||
"menu_language_settings_inline": "🌐 Language",
|
||||
"menu_server_status_button": "📊 Status",
|
||||
"menu_support_button": "💬 Support",
|
||||
"menu_terms_button": "📄 Terms of Service",
|
||||
"menu_info_button": "ℹ️ Information",
|
||||
"bot_interface_menu_title": "Bot interface",
|
||||
"info_links_message": "Choose a document:",
|
||||
"privacy_policy_button": "🔒 Privacy Policy",
|
||||
"user_agreement_button": "📄 User Agreement",
|
||||
"back_to_main_menu_button": "⬅️ Back",
|
||||
"choose_language": "Choose language:",
|
||||
"language_set_alert": "Language changed!",
|
||||
@@ -25,8 +33,11 @@
|
||||
"error_displaying_menu": "Error displaying menu.",
|
||||
"main_menu_unknown_action": "Unknown action.",
|
||||
"select_subscription_period": "Select subscription period:",
|
||||
"select_traffic_package": "Select a traffic package:",
|
||||
"subscribe_for_months_button": "{months} mo. - {price} {currency_symbol}",
|
||||
"buy_traffic_package_button": "{traffic_gb} GB - {price} {currency_symbol}",
|
||||
"choose_payment_method": "Choose payment method:",
|
||||
"choose_payment_method_traffic": "Choose how to pay for the traffic package:",
|
||||
"pay_button": "💳 Pay",
|
||||
"pay_with_yookassa_button": "💳 YooKassa",
|
||||
"yookassa_autopay_flow_prompt": "Auto-renew is enabled. Choose how you'd like to pay:",
|
||||
@@ -37,13 +48,15 @@
|
||||
"back_to_autopay_method_choice_button": "⬅️ Back",
|
||||
"yookassa_autopay_charge_initiated": "Charge request sent to the selected card. We'll notify you once the payment completes.",
|
||||
"pay_with_sbp_button": "📱 SBP",
|
||||
"pay_with_platega_button": "💳 Platega (SBP/Cards)",
|
||||
"pay_with_platega_sbp_button": "🏦 Pay via SBP",
|
||||
"pay_with_platega_crypto_button": "🪙 Pay with crypto",
|
||||
"pay_with_severpay_button": "💳 SeverPay",
|
||||
"back_to_payment_methods_button": "⬅️ Back",
|
||||
"pay_with_cryptopay_button": "💎 CryptoBot",
|
||||
"pay_with_tribute_button": "❤️ Tribute",
|
||||
"pay_with_stars_button": "🌟 Telegram Stars",
|
||||
"connect_button": "🔗 Connect",
|
||||
"cancel_button": "❌ Cancel",
|
||||
|
||||
"devices_button": "📱 My Devices ({current_devices}/{max_devices})",
|
||||
"my_devices_details": "📱 <b>My Devices ({current_devices}/{max_devices})</b>\n\n{devices}\n\nYou can disconnect a device by selecting it from the list below.\n<blockquote><i>Note: If you disconnect a device, it will be automatically connected again when you use it next. Before deleting, make sure you have deleted the subscription from the application.</i></blockquote>",
|
||||
"no_devices_details_found_message": "📱 <b>My Devices</b>\n\nYou don't have any devices yet.\nYou have {max_devices} devices available. You can connect them by clicking the \"🔗 Connect\" button in the subscription menu.",
|
||||
@@ -53,19 +66,28 @@
|
||||
"no_devices_found": "Error loading device list.",
|
||||
"devices_unlimited_label": "Unlimited",
|
||||
"my_devices_feature_disabled": "The My Devices section is currently unavailable.",
|
||||
|
||||
"payment_description_subscription": "Subscription payment for {months} mo.",
|
||||
"payment_description_traffic": "Traffic package {traffic_gb} GB",
|
||||
"payment_link_message": "To pay for {months} mo. subscription, click the button below:",
|
||||
"payment_link_message_traffic": "To pay for a {traffic_gb} GB package, tap the button below:",
|
||||
"free_kassa_order_info": "Order #{order_id} from {date}",
|
||||
"payment_invoice_sent_message": "Telegram has sent the invoice above. Complete the payment or pick another method below.",
|
||||
"payment_invoice_sent_message_traffic": "Invoice for {traffic_gb} GB sent above. Complete the payment or pick another method below.",
|
||||
"payment_successful_error_details": "✅ Payment succeeded, but an error occurred displaying details. Your subscription is active. Contact support if anything is wrong.",
|
||||
"payment_successful_full": "✅ Payment successful!\nYour {months}-month subscription is active until {end_date}.\n\nConnection key:\n<code>{config_link}</code>\n\nTo connect, open the link and follow the instructions 👇",
|
||||
"payment_successful_traffic_full": "✅ Payment successful!\nYour {traffic_gb} GB package is active.\nValidity: {end_date}\n\nConnection key:\n<code>{config_link}</code>\n\nTo connect, open the link and follow the instructions 👇",
|
||||
"payment_successful_with_referral_bonus_full": "✅ Payment successful!\nYour {months}-month subscription (base end date: {base_end_date}) has been extended by {bonus_days} bonus days for referral from {inviter_name} and is now active until {final_end_date}.\n\nConnection key:\n<code>{config_link}</code>\n\nTo connect, open the link and follow the instructions 👇",
|
||||
"payment_failed": "❌ Payment failed or was cancelled. Please try again or contact support.",
|
||||
"config_link_not_available": "not available, contact support",
|
||||
"traffic_unlimited": "Unlimited",
|
||||
"traffic_period_day": "per day",
|
||||
"traffic_period_week": "per week",
|
||||
"traffic_period_month": "per month",
|
||||
"traffic_period_no_reset": "no reset",
|
||||
"traffic_used_with_period": "{traffic_used} ({traffic_period})",
|
||||
"promo_code_prompt": "Please enter your promo code:",
|
||||
"promo_code_not_found": "Promo code <code>{code}</code> not found, expired, or already used the maximum number of times.",
|
||||
"promo_code_too_many_attempts": "Too many failed promo code attempts. Please try again in {seconds} sec.",
|
||||
"promo_code_already_used_by_user": "You have already used promo code <code>{code}</code>.",
|
||||
"promo_code_applied_success_full": "✅ Promo code applied successfully!\nSubscription active until {end_date}.\n\nConnection key:\n<code>{config_link}</code>\n\nTo connect, open the link and follow the instructions 👇",
|
||||
"error_applying_promo_bonus": "Failed to apply promo bonus. Please try again later or contact support.",
|
||||
@@ -75,12 +97,14 @@
|
||||
"trial_confirm_activate_button": "✅ Activate!",
|
||||
"trial_activated_alert": "Trial activated!",
|
||||
"trial_activated_details_message": "✅ Trial activated!\nYour {days}-day trial is active until {end_date}.\n\nConnection key:\n<code>{config_link}</code>\n\nTo connect, open the link and follow the instructions 👇",
|
||||
"referral_welcome_bonus_applied": "🎁 You have received {days} bonus day(s) for registering via a referral link!\nYour subscription is active until {end_date}.",
|
||||
"yes_button": "Yes",
|
||||
"no_button": "No",
|
||||
"referral_program_info_new": "🎁 <b>Referral Program</b>\n\n📊 <b>Your stats:</b>\n👥 Friends invited: <b>{invited_count}</b>\n💳 Purchased subscription: <b>{purchased_count}</b>\n\n🔗 Your link:\n<code>{referral_link}</code>\n\n💰 <b>Invitation bonuses:</b>\n{bonus_details}\n\n📢 Share the link with friends and get bonuses!",
|
||||
"referral_bonus_per_period": "\n\n🎁 For a friend's {months}-month subscription:\n ➢ You: <b>{inviter_bonus_days} days</b>\n ➢ Friend: <b>{referee_bonus_days} days</b>",
|
||||
"referral_not_available_for_traffic": "Referral bonuses are not available for traffic packages.",
|
||||
"referral_share_message_button": "📩 Message for friend",
|
||||
"referral_friend_message": "🚀 Hey! Try this VPN - it's fast, reliable and affordable!\n\n🎁 Use my link to get bonus days with your subscription!\n\n{referral_link}",
|
||||
"referral_friend_message": "🚀 Hey! Try this service - it's fast, reliable and affordable!\n\n🎁 Use my link to get bonus days with your subscription!\n\n{referral_link}",
|
||||
"friend_placeholder": "friend",
|
||||
"referral_bonus_inviter_notification_extended": "🎉 Congrats! Your friend {referee_name} paid for a subscription. You received {days} bonus days! Your subscription is now active until {new_end_date}.",
|
||||
"referral_bonus_inviter_notification_new_sub": "🎉 Congrats! Your friend {referee_name} paid for a subscription. You received a {days}-day bonus subscription! It is active until {new_end_date}.",
|
||||
@@ -101,7 +125,10 @@
|
||||
"admin_promo_marketing_section": "🎁 Promos",
|
||||
"admin_system_functions_section": "⚙️ System",
|
||||
"admin_ban_management_section": "🚫 Bans",
|
||||
"admin_users_management_button": "👤 Management",
|
||||
"admin_users_search_button": "🔍 Search",
|
||||
"admin_users_management_button": "👥 User List",
|
||||
"admin_users_list_header": "👥 <b>User List</b>\n\nPage {current}/{total} ({total_users} users)",
|
||||
"admin_user_back_to_list_button": "⬅️ Back to List",
|
||||
"back_to_user_management_button": "⬅️ To Users",
|
||||
"back_to_admin_panel_button": "⬅️ To Admin",
|
||||
"admin_stats_header": "📊 Bot Statistics",
|
||||
@@ -111,6 +138,18 @@
|
||||
"admin_stats_payment_item": "{status_emoji} {amount} {currency} from {user_info} ({p_status}) [{p_date}]",
|
||||
"admin_stats_no_payments_found": "No payments found yet.",
|
||||
"admin_view_payments_button": "💰 Payments",
|
||||
"admin_user_ratings_button": "🏆 User Ratings",
|
||||
"back_to_stats_monitoring_button": "⬅️ To Statistics",
|
||||
"admin_user_ratings_header": "🏆 <b>User Ratings (TOP {top_limit})</b>",
|
||||
"admin_user_ratings_traffic_title": "📊 By used traffic",
|
||||
"admin_user_ratings_traffic_month_title": "📊 By used traffic (month)",
|
||||
"admin_user_ratings_traffic_lifetime_title": "📊 By used traffic (lifetime)",
|
||||
"admin_user_ratings_invited_title": "👥 By invited users",
|
||||
"admin_user_ratings_revenue_title": "💸 By referral revenue",
|
||||
"admin_user_ratings_traffic_item": "{rank}. {user} — <b>{traffic_gb} GB</b>",
|
||||
"admin_user_ratings_invited_item": "{rank}. {user} — <b>{invited_count}</b>",
|
||||
"admin_user_ratings_revenue_item": "{rank}. {user} — <b>{revenue} RUB</b>",
|
||||
"admin_user_ratings_empty": "No data yet.",
|
||||
"admin_payments_header": "💰 <b>All Payments</b>",
|
||||
"admin_no_payments_found": "No payments found.",
|
||||
"admin_export_payments_csv": "📊 Export CSV",
|
||||
@@ -127,6 +166,7 @@
|
||||
"admin_csv_provider": "Provider",
|
||||
"admin_csv_status": "Status",
|
||||
"admin_csv_description": "Description",
|
||||
"admin_csv_units": "Months/GB",
|
||||
"admin_csv_months": "Months",
|
||||
"admin_csv_created_at": "Created At",
|
||||
"admin_csv_provider_payment_id": "Provider Payment ID",
|
||||
@@ -197,9 +237,9 @@
|
||||
"admin_promo_edit_bonus_days": "🎁 Bonus Days",
|
||||
"admin_promo_edit_max_activations": "🔢 Max Activations",
|
||||
"admin_promo_edit_validity": "⏰ Validity",
|
||||
"admin_ban_user_prompt": "Enter user ID or @username to ban:",
|
||||
"admin_ban_user_prompt": "Enter user ID, @username, or email to ban:",
|
||||
"admin_user_already_banned": "User {user_id_or_username} is already banned.",
|
||||
"admin_unban_user_prompt": "Enter user ID or @username to unban:",
|
||||
"admin_unban_user_prompt": "Enter user ID, @username, or email to unban:",
|
||||
"admin_user_not_banned": "⚠️ User is not banned",
|
||||
"admin_banned_user_button_text": "{user_display} (ID: {user_id})",
|
||||
"prev_page_button": "⬅️ Prev.",
|
||||
@@ -207,6 +247,8 @@
|
||||
"admin_user_card_title": "User Card",
|
||||
"user_card_ban_button": "🚫 Ban",
|
||||
"user_card_unban_button": "✅ Unban",
|
||||
"user_card_open_profile_button": "👤 Open profile",
|
||||
"user_card_open_referrer_profile_button": "👤 Referrer profile",
|
||||
"user_card_back_to_banned_list_button": "⬅️ Back to Ban List",
|
||||
"admin_logs_menu_title": "Logs Menu:",
|
||||
"admin_view_all_logs_button": "📜 All Message Logs",
|
||||
@@ -214,7 +256,7 @@
|
||||
"admin_export_logs_csv_button": "📄 Export to CSV",
|
||||
"admin_no_logs_found": "No logs found.",
|
||||
"admin_log_entry_format": "<code>{timestamp_str}</code> - <b>{user_display}</b> (ID: {user_id})\n <i>{event_type}</i>: {content_preview}",
|
||||
"admin_prompt_for_user_id_or_username_logs": "Enter user ID or @username to view logs:",
|
||||
"admin_prompt_for_user_id_or_username_logs": "Enter user ID, @username, or email to view logs:",
|
||||
"admin_log_user_not_found": "User \"{input}\" not found in bot database.",
|
||||
"sync_started_simple": "🔄 Starting synchronization...",
|
||||
"sync_success_simple": "✅ Synchronization completed successfully",
|
||||
@@ -225,18 +267,16 @@
|
||||
"admin_broadcast_invalid_html": "❌ Invalid HTML in message. Please send valid HTML (Telegram-supported tags) or remove tags.",
|
||||
"error_displaying_logs_too_long": "Error: logs too long to display in one message. Try viewing logs for a specific user.",
|
||||
"error_displaying_statistics": "Error displaying statistics.",
|
||||
"subscription_72h_notification": "👋 Hi, {user_name}!\n\n⏳ Your VPN subscription expires in 3 days — {end_date}.\n\nPlease renew it using the button below.",
|
||||
"subscription_48h_notification": "👋 Hi, {user_name}!\n\n⏳ Your VPN subscription expires in 2 days — {end_date}.\n\nPlease renew it using the button below.",
|
||||
"subscription_24h_notification": "👋 Hi, {user_name}!\n\n⏳ Your VPN subscription expires in 1 day — {end_date}.\n\nPlease renew it using the button below.",
|
||||
"subscription_expired_notification": "👋 Hi, {user_name}!\n\n⛔ Your VPN subscription expired on {end_date}.\n\nPlease renew it using the button below.",
|
||||
"subscription_expired_yesterday_notification": "👋 Hi, {user_name}!\n\n⏳ Your VPN subscription expired yesterday ({end_date}).\n\nPlease renew it using the button below.",
|
||||
"subscription_72h_notification": "👋 Hi, {user_name}!\n\n⏳ Your service subscription expires in 3 days — {end_date}.\n\nPlease renew it using the button below.",
|
||||
"subscription_48h_notification": "👋 Hi, {user_name}!\n\n⏳ Your service subscription expires in 2 days — {end_date}.\n\nPlease renew it using the button below.",
|
||||
"subscription_24h_notification": "👋 Hi, {user_name}!\n\n⏳ Your service subscription expires in 1 day — {end_date}.\n\nPlease renew it using the button below.",
|
||||
"subscription_expired_notification": "👋 Hi, {user_name}!\n\n⛔ Your service subscription expired on {end_date}.\n\nPlease renew it using the button below.",
|
||||
"subscription_expired_yesterday_notification": "👋 Hi, {user_name}!\n\n⏳ Your service subscription expired yesterday ({end_date}).\n\nPlease renew it using the button below.",
|
||||
"autorenew_48h_charge_tomorrow_notice": "🔔 Reminder\n\nTomorrow an automatic charge will occur to renew your subscription. If you don't want auto-renew, disable it using the button below.",
|
||||
"autorenew_confirm_enable": "🔄 Enable auto-renew? An automatic charge will be attempted before your subscription ends.",
|
||||
"autorenew_confirm_disable": "🛑 Disable auto-renew? No further automatic charges will occur.",
|
||||
"tribute_subscription_cancelled": "🚨 <b>Subscription Cancelled</b>\n\nYour Tribute subscription has been cancelled. You have 24 hours to restore access, after which the subscription will be blocked.\n\nTo renew your subscription, press the button below.",
|
||||
"tribute_auto_renewal": "🔄 <b>Subscription Auto-Renewed</b>\n\nYour Tribute subscription has been automatically renewed for {months} months.\nNew expiration date: {end_date}",
|
||||
"yookassa_auto_renewal": "🔄 <b>Subscription Auto-Renewed</b>\n\nYour subscription was automatically renewed for {months} month(s).\nNew expiration date: {end_date}",
|
||||
"admin_user_management_prompt": "👤 User Management\n\nEnter user ID or @username to search:",
|
||||
"admin_user_management_prompt": "👤 User Management\n\nEnter user ID, @username, or email to search:",
|
||||
"admin_user_subscription_info": "Subscription Information:",
|
||||
"admin_user_reset_trial_button": "🔄 Reset Trial",
|
||||
"admin_user_add_subscription_button": "➕ Add Days",
|
||||
@@ -244,6 +284,16 @@
|
||||
"admin_user_send_message_button": "✉️ Send Message",
|
||||
"admin_user_view_logs_button": "📜 User Actions",
|
||||
"admin_user_refresh_button": "🔄 Refresh",
|
||||
"admin_user_delete_button": "❌ Delete User",
|
||||
"admin_user_delete_not_allowed": "❌ You are not permitted to delete users.",
|
||||
"admin_user_delete_confirmation_prompt": "⚠️ You are about to delete user {user_id} completely.\n\nSend the exact Telegram ID of this user to confirm.\nAny other reply will cancel the operation.",
|
||||
"admin_user_delete_state_missing": "⚠️ No active delete operation. Start again.",
|
||||
"admin_user_delete_cancelled": "Deletion cancelled.",
|
||||
"admin_user_delete_mismatch": "⚠️ ID mismatch. Deletion aborted.",
|
||||
"admin_user_delete_already_removed": "ℹ️ The user is already removed.",
|
||||
"admin_user_delete_panel_error": "❌ Failed to remove the user from the panel. Operation aborted.",
|
||||
"admin_user_delete_success": "✅ User {user_id} was removed from the bot and the panel.",
|
||||
"admin_user_delete_error": "❌ Unable to delete the user. Please try again later.",
|
||||
"admin_user_search_new_button": "🔍 Find Another",
|
||||
"admin_user_view_all_logs_button": "📋 All Actions",
|
||||
"admin_user_back_to_card_button": "🔙 Back to Card",
|
||||
@@ -278,9 +328,15 @@
|
||||
"inline_admin_financial_stats_title": "💰 Financial Statistics",
|
||||
"inline_system_stats_message": "🖥 <b>Panel Statistics</b>\n\n🟢 Online: <b>{online}</b>\n📊 Active: <b>{active}</b>\n🔴 Disabled: <b>{disabled}</b>\n⏰ Expired: <b>{expired}</b>\n⚠️ Limited: <b>{limited}</b>\n👥 Total users: <b>{total}</b>\n💾 RAM Usage: <b>{memory:.1f}%</b>\n📊 Week traffic: <b>{week_traffic}</b>\n📊 Month traffic: <b>{month_traffic}</b>\n🔗 Active nodes: <b>{active_nodes}/{total_nodes}</b>",
|
||||
"inline_admin_system_stats_title": "🖥 System Statistics",
|
||||
"log_referral_suffix": " (referral from {referrer_id})",
|
||||
"log_referral_suffix": " (referral from {referrer_link})",
|
||||
"log_open_profile_link": "👤 Open profile",
|
||||
"log_open_referrer_profile_button": "👤 Referrer profile",
|
||||
"log_new_user_registration": "👤 <b>New User</b>\n\n🆔 ID: <code>{user_id}</code>\n👤 Name: {user_display}{referral_text}\n📅 Time: {timestamp}",
|
||||
"log_new_email_user_registration": "📧 <b>New User (email)</b>\n\n🆔 ID: <code>{user_id}</code>\n📧 Email: <code>{email}</code>{referral_text}\n📅 Time: {timestamp}",
|
||||
"log_account_email_linked": "📧 <b>Email linked</b>\n\n🆔 User ID: <code>{user_id}</code>\n📨 Telegram ID: <code>{telegram_id}</code>\n👤 User: {user_display}\n📧 Email: <code>{email}</code>\n🕐 Time: {timestamp}",
|
||||
"log_account_telegram_linked": "📨 <b>Telegram linked</b>\n\n🆔 User ID: <code>{user_id}</code>\n📨 Telegram ID: <code>{telegram_id}</code>\n👤 User: {user_display}\n📧 Email: <code>{email}</code>\n🕐 Time: {timestamp}",
|
||||
"log_payment_received": "{provider_emoji} <b>Payment Received</b>\n\n👤 User: {user_display}\n💰 Amount: <b>{amount} {currency}</b>\n📅 Period: <b>{months} mo.</b>\n🏦 Provider: {payment_provider}\n🕐 Time: {timestamp}",
|
||||
"log_payment_received_traffic": "{provider_emoji} <b>Payment Received</b>\n\n👤 User: {user_display}\n💰 Amount: <b>{amount} {currency}</b>\n🗂 Traffic: <b>{traffic_gb} GB</b>\n🏦 Provider: {payment_provider}\n🕐 Time: {timestamp}",
|
||||
"log_promo_activation": "🎁 <b>Promo Code Activated</b>\n\n👤 User: {user_display}\n🏷 Code: <code>{promo_code}</code>\n🎯 Bonus: <b>+{bonus_days}d</b>\n🕐 Time: {timestamp}",
|
||||
"log_trial_activation": "🆓 <b>Trial Activated</b>\n\n👤 User: {user_display}\n⏰ Valid until: <b>{end_date}</b>\n🕐 Time: {timestamp}",
|
||||
"log_panel_sync": "{status_emoji} <b>Panel Synchronization</b>\n\n📊 Status: <b>{status}</b>\n👥 Users processed: <b>{users_processed}</b>\n📋 Subscriptions synced: <b>{subs_synced}</b>\n🕐 Time: {timestamp}\n\n📝 Details:\n{details}",
|
||||
@@ -319,6 +375,8 @@
|
||||
"admin_user_id_label": "🆔 <b>ID:</b>",
|
||||
"admin_user_name_label": "👤 <b>Name:</b>",
|
||||
"admin_user_username_label": "📱 <b>Username:</b>",
|
||||
"admin_user_email_label": "✉️ <b>Email:</b>",
|
||||
"admin_user_telegram_id_label": "📨 <b>Telegram ID:</b>",
|
||||
"admin_user_language_label": "🌍 <b>Language:</b>",
|
||||
"admin_user_registration_label": "📅 <b>Registration:</b>",
|
||||
"admin_user_status_label": "🛡 <b>Status:</b>",
|
||||
@@ -337,6 +395,8 @@
|
||||
"admin_user_na_value": "N/A",
|
||||
"admin_user_subscription_none": "No active subscription",
|
||||
"admin_user_actions_count_label": "📜 <b>Total actions:</b>",
|
||||
"admin_user_total_paid_label": "💰 <b>Total Paid:</b>",
|
||||
"admin_user_referral_revenue_label": "💸 <b>Referral Revenue:</b>",
|
||||
"admin_user_invited_friends_label": "👥 <b>Friends invited:</b>",
|
||||
"admin_user_ref_purchased_label": "💳 <b>Purchased subscription:</b>",
|
||||
"admin_user_subscription_active_until": "⏰ <b>Active until:</b>",
|
||||
@@ -398,7 +458,12 @@
|
||||
"admin_sync_no_telegram_id": "\n⚠️ Records without telegramId: {count}",
|
||||
"admin_sync_not_found_in_db": "\n❌ Not found in DB: {count}",
|
||||
"admin_payments_pagination_info": "📊 Showing {shown} of {total} payments (page {current_page}/{total_pages})",
|
||||
"my_subscription_details": "🔐 <b>My Subscription</b>\n\n⏰ Status: <b>{status}</b>\n📅 Active until: <b>{end_date}</b>\n📆 Days left: <b>{days_left}</b>\n\n🔗 Configuration link:\n<code>{config_link}</code>\n\n📊 Traffic:\nLimit: <b>{traffic_limit}</b>\nUsed: <b>{traffic_used}</b>",
|
||||
"admin_payment_traffic_label": "🗂 Traffic: <b>{traffic_gb} GB</b>",
|
||||
"admin_payment_months_label": "📅 Period: <b>{months} mo.</b>",
|
||||
"my_subscription_details": "🔐 <b>My Subscription</b>\n\n⏰ Status: <b>{status}</b>\n📅 Active until: <b>{end_date}</b>\n📆 Days left: <b>{days_left}</b>\n\n🔗 Configuration link:\n<code>{config_link}</code>\n\n📊 Traffic ({traffic_period}):\nLimit: <b>{traffic_limit}</b>\nUsed: <b>{traffic_used}</b>",
|
||||
"my_traffic_details": "🔐 <b>My Traffic</b>\n\n⏰ Status: <b>{status}</b>\n📅 Valid until: <b>{end_date}</b>\n\n🔗 Configuration link:\n<code>{config_link}</code>\n\n📊 Traffic ({traffic_period}):\nLimit: <b>{traffic_limit}</b>\nUsed: <b>{traffic_used}</b>\nLeft: <b>{traffic_left}</b>",
|
||||
"traffic_no_expiry": "no limit",
|
||||
"traffic_period_unknown": "unknown",
|
||||
"autorenew_enable_button": "🔄 Enable auto-renew",
|
||||
"autorenew_disable_button": "🛑 Disable auto-renew",
|
||||
"subscription_autorenew_updated": "Auto-renew settings updated.",
|
||||
@@ -421,9 +486,8 @@
|
||||
"payment_method_tx_history_title": "📜 Transactions history",
|
||||
"payment_method_no_history": "No transactions history.",
|
||||
"subscription_purchase_title": "Subscription purchase for {months} mo.",
|
||||
"subscription_tribute_notice": "Paid via Tribute. Renew using your Tribute link.",
|
||||
"subscription_tribute_notice_with_link": "Paid via Tribute. Renew: {link}",
|
||||
"subscription_autorenew_not_supported_for_tribute": "Auto-renew is handled by Tribute. Manage renewal in the Tribute app/link.",
|
||||
"traffic_purchase_title": "Traffic purchase {traffic_gb} GB",
|
||||
"autorenew_enable_requires_card": "Link a payment card in Payment Methods before enabling auto-renew.",
|
||||
"subscription_not_active": "You don't have an active subscription.",
|
||||
"error_service_unavailable": "Service unavailable. Please try again later.",
|
||||
"error_payment_gateway": "Payment service error. Please try again later.",
|
||||
@@ -455,5 +519,204 @@
|
||||
"admin_ads_delete_confirm": "Are you sure you want to delete campaign #{id}? This action is irreversible.",
|
||||
"admin_ads_deleted_success": "Campaign deleted.",
|
||||
"admin_ads_not_found": "Campaign not found.",
|
||||
"free_kassa_order_full": "Order #{order_id} from {date}\n\n"
|
||||
"free_kassa_order_full": "Order #{order_id} from {date}\n\n",
|
||||
"email_footer_auto": "Sent automatically by {brand}. Please don't reply.",
|
||||
"email_login_code_subject": "{code} — your sign-in code",
|
||||
"email_login_code_preheader": "Your one-time code expires in {minutes} min.",
|
||||
"email_login_code_heading": "Confirm your sign-in",
|
||||
"email_login_code_intro": "Use this 6-digit code to finish signing in to your subscription dashboard.",
|
||||
"email_login_code_expiry_html": "The code is valid for <strong style=\"color:#e6e9ef;\">{minutes} min</strong>.",
|
||||
"email_login_code_security": "If you didn't request this code, you can ignore this email — your account stays safe.",
|
||||
"email_login_code_magic_cta": "Sign in with one tap",
|
||||
"email_login_code_magic_intro": "Don't want to type the code? Sign in with this link.",
|
||||
"email_login_code_magic_hint": "The link is single-use and expires together with the code.",
|
||||
"email_login_code_magic_or": "or",
|
||||
"email_login_code_text": "Your verification code: {code}\n\nThe code is valid for {minutes} min.\nIf you didn't request this code, ignore this message.",
|
||||
"email_login_code_text_magic": "Or sign in with this single-use link: {url}",
|
||||
"email_account_merged_subject": "Accounts merged",
|
||||
"email_account_merged_preheader": "Your accounts were combined into one profile.",
|
||||
"email_account_merged_heading": "Accounts merged",
|
||||
"email_account_merged_intro": "We merged your accounts into a single profile so you keep one subscription and one history of payments.",
|
||||
"email_account_merged_row_kept": "Kept account",
|
||||
"email_account_merged_row_removed": "Removed account",
|
||||
"email_account_merged_row_end_date": "New end date",
|
||||
"email_account_merged_note": "Your subscription link is unchanged. The duplicate panel user was removed automatically.",
|
||||
"email_account_merged_text": "We merged your accounts into one profile.\n\nKept account: {primary}\nRemoved account: {removed}\nPaid periods were combined. New subscription end date: {end_date}.\nYour subscription link stayed the same, and the later account was removed from Remnawave automatically.",
|
||||
"email_payment_success_subject": "Payment received",
|
||||
"email_payment_success_preheader": "Thanks — your subscription has been activated.",
|
||||
"email_payment_success_heading": "Payment received",
|
||||
"email_payment_success_intro_subscription": "Thanks! Your subscription has been extended by {months} month(s).",
|
||||
"email_payment_success_intro_traffic": "Thanks! We added {traffic_gb} GB of traffic to your account.",
|
||||
"email_payment_success_row_period": "Period",
|
||||
"email_payment_success_row_traffic": "Traffic added",
|
||||
"email_payment_success_row_amount": "Amount",
|
||||
"email_payment_success_row_end_date": "Active until",
|
||||
"email_payment_success_row_method": "Method",
|
||||
"email_payment_success_period_value": "{months} month(s)",
|
||||
"email_payment_success_traffic_value": "{traffic_gb} GB",
|
||||
"email_payment_success_cta": "Open dashboard",
|
||||
"email_payment_success_footer_note": "We'll remind you a few days before the subscription ends.",
|
||||
"email_payment_success_text_subscription": "Payment received: {amount}.\nSubscription extended by {months} month(s).\nActive until: {end_date}.",
|
||||
"email_payment_success_text_traffic": "Payment received: {amount}.\nTraffic added: {traffic_gb} GB.\nActive until: {end_date}.",
|
||||
"email_payment_success_text_dashboard": "Dashboard: {url}",
|
||||
"email_subscription_expiring_subject_today": "Your subscription ends today",
|
||||
"email_subscription_expiring_subject_tomorrow": "1 day left on your subscription",
|
||||
"email_subscription_expiring_subject_days": "{days} days left on your subscription",
|
||||
"email_subscription_expiring_heading_today": "Subscription ends today",
|
||||
"email_subscription_expiring_heading_tomorrow": "1 day left on your subscription",
|
||||
"email_subscription_expiring_heading_days": "{days} days left on your subscription",
|
||||
"email_subscription_expiring_preheader_today": "Renew today to avoid disconnection.",
|
||||
"email_subscription_expiring_preheader_tomorrow": "Your subscription expires tomorrow.",
|
||||
"email_subscription_expiring_preheader_days": "Your subscription expires in {days} days.",
|
||||
"email_subscription_expiring_intro_today": "Your subscription is about to expire. Renew now to keep your connection active without interruption.",
|
||||
"email_subscription_expiring_intro_tomorrow": "Your subscription expires tomorrow. Renew now to avoid losing access.",
|
||||
"email_subscription_expiring_intro_days": "A heads-up so you can renew in advance and avoid any interruption.",
|
||||
"email_subscription_expiring_row_days_left": "Days left",
|
||||
"email_subscription_expiring_row_end_date": "Active until",
|
||||
"email_subscription_expiring_cta": "Renew subscription",
|
||||
"email_subscription_expiring_note": "If you've already renewed or use auto-renewal, you can ignore this email.",
|
||||
"email_subscription_expiring_text": "{heading}.\nActive until: {end_date}.",
|
||||
"email_subscription_expiring_text_renew": "Renew: {url}",
|
||||
"wa_loading": "Loading...",
|
||||
"wa_back": "Back",
|
||||
"wa_close": "Close",
|
||||
"wa_auth_checking_login": "Checking sign-in...",
|
||||
"wa_auth_login_confirm_failed": "Could not confirm sign-in",
|
||||
"wa_auth_checking_telegram": "Checking Telegram...",
|
||||
"wa_auth_access_denied": "Access denied",
|
||||
"wa_auth_telegram_not_confirmed": "Telegram sign-in not confirmed",
|
||||
"wa_auth_telegram_unavailable": "Telegram sign-in is currently unavailable",
|
||||
"wa_auth_telegram_not_configured": "Telegram sign-in is not configured",
|
||||
"wa_auth_telegram_cancelled": "Telegram sign-in was cancelled",
|
||||
"wa_auth_invalid_email": "Enter a valid email",
|
||||
"wa_auth_sending_code": "Sending code...",
|
||||
"wa_auth_send_code_failed": "Failed to send code",
|
||||
"wa_auth_enter_code_6digits": "Enter the 6-digit code from the email",
|
||||
"wa_auth_checking_code": "Checking code...",
|
||||
"wa_auth_invalid_code": "Invalid code",
|
||||
"wa_auth_code_expired": "Code expired",
|
||||
"wa_auth_resend_wait": "You can resend in {seconds} sec.",
|
||||
"wa_email_verification_title": "Email confirmation",
|
||||
"wa_email_sent_to": "We sent a code to {email}",
|
||||
"wa_email_code_aria": "Verification code",
|
||||
"wa_email_placeholder": "Email",
|
||||
"wa_confirm": "Confirm",
|
||||
"wa_resend_code": "Resend code",
|
||||
"wa_send_code_email": "Send code to email",
|
||||
"wa_or": "or",
|
||||
"wa_login_telegram_button": "Sign in with Telegram",
|
||||
"wa_auth_legal_intro": "By creating an account, you agree to the",
|
||||
"wa_auth_legal_privacy": "privacy policy",
|
||||
"wa_auth_legal_and": "and",
|
||||
"wa_auth_legal_agreement": "user agreement",
|
||||
"wa_home_subscription_active": "Subscription active",
|
||||
"wa_home_access_active": "Access active",
|
||||
"wa_sub_term_forever": "Forever",
|
||||
"wa_sub_term_value_unit": "{value} {unit}",
|
||||
"wa_sub_term_day_one": "day",
|
||||
"wa_sub_term_day_few": "days",
|
||||
"wa_sub_term_day_many": "days",
|
||||
"wa_sub_term_month_one": "month",
|
||||
"wa_sub_term_month_few": "months",
|
||||
"wa_sub_term_month_many": "months",
|
||||
"wa_sub_term_year_one": "year",
|
||||
"wa_sub_term_year_few": "years",
|
||||
"wa_sub_term_year_many": "years",
|
||||
"wa_home_subscription_inactive": "Subscription inactive",
|
||||
"wa_until_date": "until {date}",
|
||||
"wa_home_traffic_used": "Traffic used",
|
||||
"wa_renew": "Renew",
|
||||
"wa_pay_subscription": "Pay subscription",
|
||||
"wa_subscription_title": "Subscription",
|
||||
"wa_subscription_choose_period": "Choose subscription period",
|
||||
"wa_per_month_short": "/mo",
|
||||
"wa_traffic_packages_title": "Traffic",
|
||||
"wa_traffic_packages_choose": "Choose traffic package",
|
||||
"wa_per_gb_short": "/GB",
|
||||
"wa_buy_traffic": "Buy traffic",
|
||||
"wa_activate_trial": "Activate trial",
|
||||
"wa_trial_title": "Trial period",
|
||||
"wa_trial_details": "{days} days, {traffic}",
|
||||
"wa_trial_activated": "Trial activated",
|
||||
"wa_trial_activation_failed": "Failed to activate trial",
|
||||
"wa_payment_methods_not_configured": "Payment methods are not configured yet",
|
||||
"wa_pay": "Pay",
|
||||
"wa_referral_link_title": "Your referral link",
|
||||
"wa_link_unavailable": "Link is not available yet",
|
||||
"wa_link_copied": "Link copied",
|
||||
"wa_copy": "Copy",
|
||||
"wa_referral_bonus_overview_title": "Bonus system",
|
||||
"wa_referral_bonus_once_note": "Bonus is granted only once per invited user after their first payment.",
|
||||
"wa_referral_bonus_registration_title": "For registration via referral link",
|
||||
"wa_referral_bonus_paid_intro": "If a friend pays for a subscription:",
|
||||
"wa_referral_bonus_you_days": "You: +{days} days",
|
||||
"wa_referral_bonus_friend_days": "Friend: +{days} days",
|
||||
"wa_referral_bonus_not_configured": "Referral bonuses are not configured",
|
||||
"wa_promo_enter": "Enter promo code",
|
||||
"wa_promo_activated_until": "Promo code activated. Subscription until {date}",
|
||||
"wa_promo_activated": "Promo code activated",
|
||||
"wa_promo_activation_failed": "Failed to activate promo code",
|
||||
"wa_activate": "Activate",
|
||||
"wa_settings_avatar_alt": "User avatar",
|
||||
"wa_settings_language": "Language",
|
||||
"wa_settings_link_telegram": "Telegram linked",
|
||||
"wa_settings_not_linked": "Not linked",
|
||||
"wa_settings_link_email": "Email linked",
|
||||
"wa_settings_email_not_linked": "Not linked",
|
||||
"wa_settings_linked": "Linked",
|
||||
"wa_settings_language_update_failed": "Failed to update language",
|
||||
"wa_settings_user_agreement": "User agreement",
|
||||
"wa_settings_privacy_policy": "Privacy policy",
|
||||
"wa_logout": "Log out",
|
||||
"wa_end_session": "End session",
|
||||
"wa_navigation": "Navigation",
|
||||
"wa_nav_home": "Home",
|
||||
"wa_nav_settings": "Settings",
|
||||
"wa_nav_devices": "Devices",
|
||||
"wa_devices_title": "My devices",
|
||||
"wa_devices_count": "{current} of {max}",
|
||||
"wa_devices_unlimited": "Unlimited",
|
||||
"wa_devices_refresh": "Refresh devices",
|
||||
"wa_devices_loading": "Loading devices...",
|
||||
"wa_devices_load_failed": "Failed to load devices",
|
||||
"wa_devices_empty": "No devices yet",
|
||||
"wa_devices_empty_hint": "You can connect up to {max} devices from your subscription app.",
|
||||
"wa_device_fallback_name": "Device {index}",
|
||||
"wa_devices_platform_unknown": "Platform unknown",
|
||||
"wa_devices_connected_at": "Connected",
|
||||
"wa_devices_disconnect": "Disconnect device",
|
||||
"wa_devices_disconnect_title": "Disconnect device",
|
||||
"wa_devices_disconnect_desc": "{device} will be removed from your device list. It may connect again next time you use the subscription on that device.",
|
||||
"wa_devices_disconnect_confirm": "Disconnect",
|
||||
"wa_device_disconnected": "Device disconnected",
|
||||
"wa_device_disconnect_failed": "Failed to disconnect device",
|
||||
"wa_cancel": "Cancel",
|
||||
"wa_payment_created": "Payment created",
|
||||
"wa_payment_create_failed": "Failed to create payment",
|
||||
"wa_connect_link_unavailable": "Connection link is not available yet",
|
||||
"wa_copied": "Copied",
|
||||
"wa_unavailable": "Not available yet",
|
||||
"wa_method_other_title": "Other methods",
|
||||
"wa_unlimited_traffic": "Unlimited traffic",
|
||||
"wa_traffic_of": "{used} of {limit}",
|
||||
"wa_language_default": "English",
|
||||
"wa_telegram_not_linked": "Telegram not linked",
|
||||
"wa_tg_id_not_linked": "TG ID not linked",
|
||||
"wa_nav_bonuses": "Bonuses",
|
||||
"wa_method_platega_sbp_card": "SBP/Card",
|
||||
"wa_method_platega_crypto": "Crypto",
|
||||
"wa_settings_link_telegram_action": "Link Telegram",
|
||||
"wa_settings_telegram_linked_title": "Telegram linked",
|
||||
"wa_settings_link_email_action": "Link email",
|
||||
"wa_settings_email_linked_title": "Email linked",
|
||||
"wa_activate_promo_title": "Activate promo code",
|
||||
"wa_link_email_modal_title": "Link email",
|
||||
"wa_traffic_reset_none": "No reset",
|
||||
"wa_traffic_reset_monthly": "Monthly reset",
|
||||
"wa_traffic_reset_weekly": "Weekly reset",
|
||||
"wa_traffic_reset_daily": "Daily reset",
|
||||
"wa_traffic_reset_yearly": "Yearly reset",
|
||||
"wa_traffic_reset_policy": "Traffic reset policy",
|
||||
"wa_plan_one_year": "1 year",
|
||||
"wa_link_email_modal_desc": "Enter your email and get a verification code"
|
||||
}
|
||||
|
||||
+288
-25
@@ -5,18 +5,26 @@
|
||||
"channel_subscription_verify_button": "Проверить подписку",
|
||||
"channel_subscription_check_failed": "Не удалось проверить подписку. Попробуйте позже или обратитесь в поддержку.",
|
||||
"channel_subscription_verified_success": "✅ Подписка подтверждена! Можно продолжать.",
|
||||
"main_menu_greeting": "Привет, {user_name}! 👋\nЧто бы вы хотели сделать?",
|
||||
"webapp_auth_success": "✅ Вход подтвержден. Вернитесь в Web App.",
|
||||
"webapp_auth_expired": "Ссылка авторизации устарела. Вернитесь в Web App и попробуйте еще раз.",
|
||||
"main_menu_greeting": "Привет, {user_name}! 👋\nПерейдите в \"Личный кабинет\", чтобы настроить подключение, посмотреть подписку или продлить доступ.",
|
||||
"menu_activate_trial_button": "🆓 Пробный период",
|
||||
"menu_subscribe_inline": "🚀 Купить",
|
||||
"menu_personal_account_button": "🔑 Личный кабинет",
|
||||
"menu_my_subscription_inline": "🔐 Моя подписка",
|
||||
"no_subscription_options_available": "Выдача подписки не натроена администратором бота",
|
||||
"menu_referral_inline": "🎁 Рефералы",
|
||||
"no_subscription_options_available": "Выдача подписки не настроена администратором бота",
|
||||
"menu_referral_inline": "🎁 Пригласить друга",
|
||||
"referral_no_bonuses_configured": "Извините, реферальная программа в данный момент отключена",
|
||||
"menu_apply_promo_button": "🎟 Промокод",
|
||||
"menu_language_settings_inline": "🌐 Язык",
|
||||
"menu_server_status_button": "📊 Статус",
|
||||
"menu_support_button": "💬 Поддержка",
|
||||
"menu_terms_button": "📄 Условия сервиса",
|
||||
"menu_info_button": "ℹ️ Информация",
|
||||
"bot_interface_menu_title": "Интерфейс в боте",
|
||||
"info_links_message": "Выберите документ:",
|
||||
"privacy_policy_button": "🔒 Политика конфиденциальности",
|
||||
"user_agreement_button": "📄 Пользовательское соглашение",
|
||||
"back_to_main_menu_button": "⬅️ Назад",
|
||||
"choose_language": "Выберите язык / Select language:",
|
||||
"language_set_alert": "Язык изменен!",
|
||||
@@ -25,8 +33,11 @@
|
||||
"error_displaying_menu": "Ошибка отображения меню.",
|
||||
"main_menu_unknown_action": "Неизвестное действие.",
|
||||
"select_subscription_period": "Выберите срок подписки:",
|
||||
"select_traffic_package": "Выберите пакет трафика:",
|
||||
"subscribe_for_months_button": "{months} мес. - {price} {currency_symbol}",
|
||||
"buy_traffic_package_button": "{traffic_gb} ГБ - {price} {currency_symbol}",
|
||||
"choose_payment_method": "Выберите способ оплаты:",
|
||||
"choose_payment_method_traffic": "Выберите способ оплаты пакета трафика:",
|
||||
"pay_button": "💳 Оплатить",
|
||||
"pay_with_yookassa_button": "💳 ЮKassa",
|
||||
"yookassa_autopay_flow_prompt": "Автопродление включено. Выберите, как оплатить подписку:",
|
||||
@@ -37,12 +48,14 @@
|
||||
"back_to_autopay_method_choice_button": "⬅️ Назад",
|
||||
"yookassa_autopay_charge_initiated": "Запрос на списание с выбранной карты отправлен. Сообщим, как только платёж завершится.",
|
||||
"pay_with_sbp_button": "📱 СБП",
|
||||
"pay_with_platega_button": "💳 Platega (СБП/карты)",
|
||||
"pay_with_platega_sbp_button": "🏦 Оплата через СБП",
|
||||
"pay_with_platega_crypto_button": "🪙 Оплата криптой",
|
||||
"pay_with_severpay_button": "💳 SeverPay",
|
||||
"back_to_payment_methods_button": "⬅️ Назад",
|
||||
"pay_with_cryptopay_button": "💎 CryptoBot",
|
||||
"pay_with_tribute_button": "❤️ Tribute",
|
||||
"pay_with_stars_button": "🌟 Звезды Telegram",
|
||||
"connect_button": "🔗 Подключиться",
|
||||
|
||||
"devices_button": "📱 Мои устройства ({current_devices}/{max_devices})",
|
||||
"my_devices_details": "📱 <b>Список ваших устройств ({current_devices}/{max_devices})</b>\n\n{devices}\n\nВы можете отключить устройство, выбрав его в списке ниже.\n<blockquote><i>Примечание: Если вы отключили устройство, оно будет автоматически подключено заново при следующем использовании. Перед удалением убедитесь, что вы удалили подписку из приложения.</i></blockquote>",
|
||||
"no_devices_details_found_message": "📱 <b>Список ваших устройств</b>\n\nУ вас пока нет устройств.\nВам доступно {max_devices} устройств. Подключить их можно через кнопку \"🔗 Подключиться\" в меню подписки.",
|
||||
@@ -52,20 +65,29 @@
|
||||
"no_devices_found": "Ошибка загрузки списка устройств.",
|
||||
"devices_unlimited_label": "Без ограничений",
|
||||
"my_devices_feature_disabled": "Раздел \"Мои устройства\" сейчас недоступен.",
|
||||
|
||||
"cancel_button": "❌ Отмена",
|
||||
"payment_description_subscription": "Оплата подписки на {months} мес.",
|
||||
"payment_description_traffic": "Пакет трафика {traffic_gb} ГБ",
|
||||
"payment_link_message": "Для оплаты подписки на {months} мес., нажмите кнопку ниже:",
|
||||
"payment_link_message_traffic": "Для оплаты пакета {traffic_gb} ГБ нажмите кнопку ниже:",
|
||||
"free_kassa_order_info": "Заказ №{order_id} от {date}",
|
||||
"payment_invoice_sent_message": "Счёт Telegram Stars отправлен выше. Нажмите «Оплатить» или вернитесь к выбору способа ниже.",
|
||||
"payment_invoice_sent_message_traffic": "Счет на пакет {traffic_gb} ГБ отправлен выше. Нажмите «Оплатить» или вернитесь к выбору способа ниже.",
|
||||
"payment_successful_error_details": "✅ Оплата прошла успешно, но возникла ошибка при отображении деталей. Ваша подписка активна. Свяжитесь с поддержкой, если что-то не так.",
|
||||
"payment_successful_full": "✅ Оплата прошла успешно!\nВаша подписка на {months} мес. активна до {end_date}.\n\nКлюч подключения:\n<code>{config_link}</code>\n\nЧтобы подключиться, перейдите по ссылке и следуйте инструкции 👇",
|
||||
"payment_successful_traffic_full": "✅ Оплата прошла успешно!\nВаш пакет {traffic_gb} ГБ активирован.\nДата действия: {end_date}\n\nКлюч подключения:\n<code>{config_link}</code>\n\nЧтобы подключиться, перейдите по ссылке и следуйте инструкции 👇",
|
||||
"payment_successful_with_referral_bonus_full": "✅ Оплата прошла успешно!\nВаша подписка на {months} мес. (базовая дата окончания: {base_end_date}) продлена на {bonus_days} бонусных дней за приглашение от {inviter_name} и теперь активна до {final_end_date}.\n\nКлюч подключения:\n<code>{config_link}</code>\n\nЧтобы подключиться, перейдите по ссылке и следуйте инструкции 👇",
|
||||
"payment_failed": "❌ Оплата не удалась или была отменена. Пожалуйста, попробуйте еще раз или свяжитесь с поддержкой.",
|
||||
"config_link_not_available": "недоступна, обратитесь в поддержку",
|
||||
"traffic_unlimited": "Безлимитный",
|
||||
"traffic_period_day": "за день",
|
||||
"traffic_period_week": "за неделю",
|
||||
"traffic_period_month": "за месяц",
|
||||
"traffic_period_no_reset": "без сброса",
|
||||
"traffic_used_with_period": "{traffic_used} ({traffic_period})",
|
||||
"promo_code_prompt": "Пожалуйста, введите ваш промокод:",
|
||||
"promo_code_not_found": "Промокод <code>{code}</code> не найден, истек или уже использован максимальное количество раз.",
|
||||
"promo_code_too_many_attempts": "Слишком много неудачных попыток ввода промокода. Повторите через {seconds} сек.",
|
||||
"promo_code_already_used_by_user": "Вы уже активировали промокод <code>{code}</code>.",
|
||||
"promo_code_applied_success_full": "✅ Промокод успешно применен!\nПодписка активна до {end_date}.\n\nКлюч подключения:\n<code>{config_link}</code>\n\nЧтобы подключиться, перейдите по ссылке и следуйте инструкции 👇",
|
||||
"error_applying_promo_bonus": "Не удалось применить бонус по промокоду. Пожалуйста, попробуйте позже или свяжитесь с поддержкой.",
|
||||
@@ -75,12 +97,14 @@
|
||||
"trial_confirm_activate_button": "✅ Активировать!",
|
||||
"trial_activated_alert": "Пробный период активирован!",
|
||||
"trial_activated_details_message": "✅ Пробный доступ активирован!\nВаш триал на {days} дн. действует до {end_date}.\n\nКлюч подключения:\n<code>{config_link}</code>\n\nЧтобы подключиться, перейдите по ссылке и следуйте инструкции 👇",
|
||||
"referral_welcome_bonus_applied": "🎁 Вам начислено {days} бонусных дн. за регистрацию по реферальной ссылке!\nПодписка активна до {end_date}.",
|
||||
"yes_button": "Да",
|
||||
"no_button": "Нет",
|
||||
"referral_program_info_new": "🎁 <b>Реферальная программа</b>\n\n📊 <b>Твоя статистика:</b>\n👥 Приглашено друзей: <b>{invited_count}</b>\n💳 Купили подписку: <b>{purchased_count}</b>\n\n🔗 Твоя ссылка:\n<code>{referral_link}</code>\n\n💰 <b>Бонусы за приглашения:</b>\n{bonus_details}\n\n📢 Поделись ссылкой с друзьями и получай бонусы!",
|
||||
"referral_bonus_per_period": "\n\n🎁 За {months}-мес. подписку друга:\n ➢ Вы: <b>{inviter_bonus_days} дн.</b>\n ➢ Друг: <b>{referee_bonus_days} дн.</b>",
|
||||
"referral_not_available_for_traffic": "Для пакетов трафика реферальные бонусы не начисляются.",
|
||||
"referral_share_message_button": "📩 Сообщение для друга",
|
||||
"referral_friend_message": "🚀 Привет! Попробуй этот VPN - быстрый, надёжный и доступный!\n\n🎁 По моей ссылке тебе дадут бонусные дни к подписке!\n\n{referral_link}",
|
||||
"referral_friend_message": "🚀 Привет! Попробуй этот сервис - быстрый, надёжный и доступный!\n\n🎁 По моей ссылке тебе дадут бонусные дни к подписке!\n\n{referral_link}",
|
||||
"friend_placeholder": "друг",
|
||||
"referral_bonus_inviter_notification_extended": "🎉 Поздравляем! Ваш друг {referee_name} оплатил подписку. Вам начислено {days} бонусных дней! Ваша подписка теперь активна до {new_end_date}.",
|
||||
"referral_bonus_inviter_notification_new_sub": "🎉 Поздравляем! Ваш друг {referee_name} оплатил подписку. Вам начислена бонусная подписка на {days} дней! Она активна до {new_end_date}.",
|
||||
@@ -101,7 +125,10 @@
|
||||
"admin_promo_marketing_section": "🎁 Промокоды",
|
||||
"admin_system_functions_section": "⚙️ Система",
|
||||
"admin_ban_management_section": "🚫 Блокировки",
|
||||
"admin_users_management_button": "👤 Управление",
|
||||
"admin_users_search_button": "🔍 Поиск",
|
||||
"admin_users_management_button": "👥 Список пользователей",
|
||||
"admin_users_list_header": "👥 <b>Список пользователей</b>\n\nСтраница {current}/{total} ({total_users} пользователей)",
|
||||
"admin_user_back_to_list_button": "⬅️ К списку",
|
||||
"back_to_user_management_button": "⬅️ К пользователям",
|
||||
"back_to_admin_panel_button": "⬅️ В админку",
|
||||
"admin_stats_header": "📊 Статистика Бота",
|
||||
@@ -111,6 +138,18 @@
|
||||
"admin_stats_payment_item": "{status_emoji} {amount} {currency} от {user_info} ({p_status}) [{p_date}]",
|
||||
"admin_stats_no_payments_found": "Платежей пока нет.",
|
||||
"admin_view_payments_button": "💰 Платежи",
|
||||
"admin_user_ratings_button": "🏆 Рейтинг пользователей",
|
||||
"back_to_stats_monitoring_button": "⬅️ К статистике",
|
||||
"admin_user_ratings_header": "🏆 <b>Рейтинг пользователей (ТОП {top_limit})</b>",
|
||||
"admin_user_ratings_traffic_title": "📊 По использованному трафику",
|
||||
"admin_user_ratings_traffic_month_title": "📊 По использованному трафику за месяц",
|
||||
"admin_user_ratings_traffic_lifetime_title": "📊 По использованному трафику за всё время",
|
||||
"admin_user_ratings_invited_title": "👥 По количеству приглашенных",
|
||||
"admin_user_ratings_revenue_title": "💸 По доходу с приглашенных",
|
||||
"admin_user_ratings_traffic_item": "{rank}. {user} — <b>{traffic_gb} ГБ</b>",
|
||||
"admin_user_ratings_invited_item": "{rank}. {user} — <b>{invited_count}</b>",
|
||||
"admin_user_ratings_revenue_item": "{rank}. {user} — <b>{revenue} RUB</b>",
|
||||
"admin_user_ratings_empty": "Пока нет данных.",
|
||||
"admin_payments_header": "💰 <b>Все платежи</b>",
|
||||
"admin_no_payments_found": "Платежи не найдены.",
|
||||
"admin_export_payments_csv": "📊 Экспорт CSV",
|
||||
@@ -127,6 +166,7 @@
|
||||
"admin_csv_provider": "Платежная система",
|
||||
"admin_csv_status": "Статус",
|
||||
"admin_csv_description": "Описание",
|
||||
"admin_csv_units": "Месяцы/ГБ",
|
||||
"admin_csv_months": "Месяцев",
|
||||
"admin_csv_created_at": "Дата создания",
|
||||
"admin_csv_provider_payment_id": "ID платежа в системе",
|
||||
@@ -156,15 +196,14 @@
|
||||
"admin_promo_invalid_max_activations": "Максимальное количество активаций должно быть положительным числом.",
|
||||
"admin_promo_invalid_validity_days": "Срок действия промокода (в днях) должен быть положительным числом.",
|
||||
"admin_promo_created_success": "✅ Промокод <code>{code}</code> успешно создан!\nБонус: {bonus_days} дней\nМакс. активаций: {max_activations}\nДействителен: {valid_until_str}",
|
||||
"subscription_72h_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на VPN истекает через 3 дня — {end_date}.\n\nПродлите её по кнопке ниже.",
|
||||
"subscription_48h_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на VPN истекает через 2 дня — {end_date}.\n\nПродлите её по кнопке ниже.",
|
||||
"subscription_24h_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на VPN истекает через 1 день — {end_date}.\n\nПродлите её по кнопке ниже.",
|
||||
"subscription_expired_notification": "👋 Привет, {user_name}!\n\n⛔ Срок вашей подписки на VPN истек ({end_date}).\n\nПродлите её по кнопке ниже.",
|
||||
"subscription_expired_yesterday_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на VPN истекла сутки назад ({end_date}).\n\nПродлите её по кнопке ниже.",
|
||||
"subscription_72h_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на сервис истекает через 3 дня — {end_date}.\n\nПродлите её по кнопке ниже.",
|
||||
"subscription_48h_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на сервис истекает через 2 дня — {end_date}.\n\nПродлите её по кнопке ниже.",
|
||||
"subscription_24h_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на сервис истекает через 1 день — {end_date}.\n\nПродлите её по кнопке ниже.",
|
||||
"subscription_expired_notification": "👋 Привет, {user_name}!\n\n⛔ Срок вашей подписки на сервис истек ({end_date}).\n\nПродлите её по кнопке ниже.",
|
||||
"subscription_expired_yesterday_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на сервис истекла сутки назад ({end_date}).\n\nПродлите её по кнопке ниже.",
|
||||
"autorenew_48h_charge_tomorrow_notice": "🔔 Напоминание\n\nЗавтра будет автоматическое списание за продление подписки. Если вы не хотите автопродление — отключите его кнопкой ниже.",
|
||||
"autorenew_confirm_enable": "🔄 Включить автопродление? Перед окончанием подписки будет выполняться автосписание.",
|
||||
"autorenew_confirm_disable": "🛑 Отключить автопродление? Автосписаний больше не будет.",
|
||||
"tribute_subscription_cancelled": "🚨 <b>Подписка отменена</b>\n\nВаша подписка Tribute была отменена. У вас есть 24 часа для восстановления доступа, после чего подписка будет заблокирована.\n\nДля продления подписки нажмите кнопку ниже.",
|
||||
"yookassa_auto_renewal": "🔄 <b>Подписка автоматически продлена</b>\n\nВаша подписка была автоматически продлена на {months} мес.\nНовая дата окончания: {end_date}",
|
||||
"admin_promo_set_validity_days": "⏰ Установить срок (дни)",
|
||||
"admin_back_to_panel": "⬅️ В панель",
|
||||
@@ -207,9 +246,9 @@
|
||||
"admin_promo_edit_bonus_days": "🎁 Бонусные дни",
|
||||
"admin_promo_edit_max_activations": "🔢 Макс. активации",
|
||||
"admin_promo_edit_validity": "⏰ Срок действия",
|
||||
"admin_ban_user_prompt": "Введите ID или @username пользователя для блокировки:",
|
||||
"admin_ban_user_prompt": "Введите ID, @username или email пользователя для блокировки:",
|
||||
"admin_user_already_banned": "Пользователь {user_id_or_username} уже заблокирован.",
|
||||
"admin_unban_user_prompt": "Введите ID или @username пользователя для разблокировки:",
|
||||
"admin_unban_user_prompt": "Введите ID, @username или email пользователя для разблокировки:",
|
||||
"admin_user_not_banned": "⚠️ Пользователь не заблокирован",
|
||||
"admin_banned_user_button_text": "{user_display} (ID: {user_id})",
|
||||
"prev_page_button": "⬅️ Пред.",
|
||||
@@ -217,6 +256,8 @@
|
||||
"admin_user_card_title": "Карточка пользователя",
|
||||
"user_card_ban_button": "🚫 Заблокировать",
|
||||
"user_card_unban_button": "✅ Разблокировать",
|
||||
"user_card_open_profile_button": "👤 Открыть профиль",
|
||||
"user_card_open_referrer_profile_button": "👤 Профиль пригласившего",
|
||||
"user_card_back_to_banned_list_button": "⬅️ К списку забаненных",
|
||||
"admin_logs_menu_title": "Меню логов:",
|
||||
"admin_view_all_logs_button": "📜 Все логи сообщений",
|
||||
@@ -224,7 +265,7 @@
|
||||
"admin_export_logs_csv_button": "📄 Экспорт в CSV",
|
||||
"admin_no_logs_found": "Логи не найдены.",
|
||||
"admin_log_entry_format": "<code>{timestamp_str}</code> - <b>{user_display}</b> (ID: {user_id})\n <i>{event_type}</i>: {content_preview}",
|
||||
"admin_prompt_for_user_id_or_username_logs": "Введите ID или @username пользователя для просмотра его логов:",
|
||||
"admin_prompt_for_user_id_or_username_logs": "Введите ID, @username или email пользователя для просмотра его логов:",
|
||||
"admin_log_user_not_found": "Пользователь по запросу \"{input}\" не найден в базе данных бота.",
|
||||
"sync_started_simple": "🔄 Начинаю синхронизацию...",
|
||||
"sync_success_simple": "✅ Синхронизация успешно завершена",
|
||||
@@ -235,8 +276,7 @@
|
||||
"admin_broadcast_invalid_html": "❌ Некорректный HTML в сообщении. Пожалуйста, отправьте корректный HTML (поддерживаются теги Telegram) или уберите теги.",
|
||||
"error_displaying_logs_too_long": "Ошибка: логи слишком длинные для отображения одним сообщением. Попробуйте найти логи по конкретному пользователю.",
|
||||
"error_displaying_statistics": "Ошибка отображения статистики.",
|
||||
"tribute_auto_renewal": "🔄 <b>Подписка автоматически продлена</b>\n\nВаша подписка Tribute была автоматически продлена на {months} мес.\nНовая дата окончания: {end_date}",
|
||||
"admin_user_management_prompt": "👤 Управление пользователями\n\nВведите ID пользователя или @username для поиска:",
|
||||
"admin_user_management_prompt": "👤 Управление пользователями\n\nВведите ID пользователя, @username или email для поиска:",
|
||||
"admin_user_subscription_info": "Информация о подписке:",
|
||||
"admin_user_reset_trial_button": "🔄 Сбросить триал",
|
||||
"admin_user_add_subscription_button": "➕ Добавить дни",
|
||||
@@ -244,6 +284,16 @@
|
||||
"admin_user_send_message_button": "✉️ Сообщение",
|
||||
"admin_user_view_logs_button": "📜 Логи",
|
||||
"admin_user_refresh_button": "🔄 Обновить",
|
||||
"admin_user_delete_button": "❌ Удалить пользователя",
|
||||
"admin_user_delete_not_allowed": "❌ У вас нет прав для удаления пользователей.",
|
||||
"admin_user_delete_confirmation_prompt": "⚠️ Вы хотите полностью удалить пользователя {user_id}.\n\nОтправьте точный Telegram ID этого пользователя, чтобы подтвердить удаление.\nЛюбой другой ответ отменит операцию.",
|
||||
"admin_user_delete_state_missing": "⚠️ Нет активной операции удаления. Начните заново.",
|
||||
"admin_user_delete_cancelled": "Удаление отменено.",
|
||||
"admin_user_delete_mismatch": "⚠️ ID не совпадает. Удаление отменено.",
|
||||
"admin_user_delete_already_removed": "ℹ️ Пользователь уже удалён.",
|
||||
"admin_user_delete_panel_error": "❌ Не удалось удалить пользователя на панели. Операция прервана.",
|
||||
"admin_user_delete_success": "✅ Пользователь {user_id} удалён из бота и панели.",
|
||||
"admin_user_delete_error": "❌ Не удалось удалить пользователя. Попробуйте позже.",
|
||||
"admin_user_search_new_button": "🔍 Новый поиск",
|
||||
"admin_user_view_all_logs_button": "📋 Все логи",
|
||||
"admin_user_back_to_card_button": "🔙 К карточке",
|
||||
@@ -278,9 +328,15 @@
|
||||
"inline_admin_financial_stats_title": "💰 Финансовая статистика",
|
||||
"inline_system_stats_message": "🖥 <b>Статистика панели</b>\n\n🟢 Онлайн: <b>{online}</b>\n📊 Активных: <b>{active}</b>\n🔴 Отключенных: <b>{disabled}</b>\n⏰ Истекшие: <b>{expired}</b>\n⚠️ Ограниченные: <b>{limited}</b>\n👥 Всего пользователей: <b>{total}</b>\n💾 Использование RAM: <b>{memory:.1f}%</b>\n📊 Трафик за неделю: <b>{week_traffic}</b>\n📊 Трафик за месяц: <b>{month_traffic}</b>\n🔗 Активных нод: <b>{active_nodes}/{total_nodes}</b>",
|
||||
"inline_admin_system_stats_title": "🖥 Системная статистика",
|
||||
"log_referral_suffix": " (реферал от {referrer_id})",
|
||||
"log_referral_suffix": " (реферал от {referrer_link})",
|
||||
"log_open_profile_link": "👤 Открыть профиль",
|
||||
"log_open_referrer_profile_button": "👤 Профиль пригласившего",
|
||||
"log_new_user_registration": "👤 <b>Новый пользователь</b>\n\n🆔 ID: <code>{user_id}</code>\n👤 Имя: {user_display}{referral_text}\n📅 Время: {timestamp}",
|
||||
"log_new_email_user_registration": "📧 <b>Новый пользователь (email)</b>\n\n🆔 ID: <code>{user_id}</code>\n📧 Email: <code>{email}</code>{referral_text}\n📅 Время: {timestamp}",
|
||||
"log_account_email_linked": "📧 <b>Привязана почта</b>\n\n🆔 ID пользователя: <code>{user_id}</code>\n📨 Telegram ID: <code>{telegram_id}</code>\n👤 Пользователь: {user_display}\n📧 Email: <code>{email}</code>\n🕐 Время: {timestamp}",
|
||||
"log_account_telegram_linked": "📨 <b>Привязан Telegram</b>\n\n🆔 ID пользователя: <code>{user_id}</code>\n📨 Telegram ID: <code>{telegram_id}</code>\n👤 Пользователь: {user_display}\n📧 Email: <code>{email}</code>\n🕐 Время: {timestamp}",
|
||||
"log_payment_received": "{provider_emoji} <b>Получен платеж</b>\n\n👤 Пользователь: {user_display}\n💰 Сумма: <b>{amount} {currency}</b>\n📅 Период: <b>{months} мес.</b>\n🏦 Провайдер: {payment_provider}\n🕐 Время: {timestamp}",
|
||||
"log_payment_received_traffic": "{provider_emoji} <b>Получен платеж</b>\n\n👤 Пользователь: {user_display}\n💰 Сумма: <b>{amount} {currency}</b>\n🗂 Трафик: <b>{traffic_gb} ГБ</b>\n🏦 Провайдер: {payment_provider}\n🕐 Время: {timestamp}",
|
||||
"log_promo_activation": "🎁 <b>Активирован промокод</b>\n\n👤 Пользователь: {user_display}\n🏷 Код: <code>{promo_code}</code>\n🎯 Бонус: <b>+{bonus_days} дн.</b>\n🕐 Время: {timestamp}",
|
||||
"log_trial_activation": "🆓 <b>Активирован триал</b>\n\n👤 Пользователь: {user_display}\n⏰ Действует до: <b>{end_date}</b>\n🕐 Время: {timestamp}",
|
||||
"log_panel_sync": "{status_emoji} <b>Синхронизация с панелью</b>\n\n📊 Статус: <b>{status}</b>\n👥 Обработано пользователей: <b>{users_processed}</b>\n📋 Синхронизировано подписок: <b>{subs_synced}</b>\n🕐 Время: {timestamp}\n\n📝 Детали:\n{details}",
|
||||
@@ -319,6 +375,8 @@
|
||||
"admin_user_id_label": "🆔 <b>ID:</b>",
|
||||
"admin_user_name_label": "👤 <b>Имя:</b>",
|
||||
"admin_user_username_label": "📱 <b>Username:</b>",
|
||||
"admin_user_email_label": "✉️ <b>Email:</b>",
|
||||
"admin_user_telegram_id_label": "📨 <b>Telegram ID:</b>",
|
||||
"admin_user_language_label": "🌍 <b>Язык:</b>",
|
||||
"admin_user_registration_label": "📅 <b>Регистрация:</b>",
|
||||
"admin_user_status_label": "🛡 <b>Статус:</b>",
|
||||
@@ -337,6 +395,8 @@
|
||||
"admin_user_na_value": "N/A",
|
||||
"admin_user_subscription_none": "Нет активной подписки",
|
||||
"admin_user_actions_count_label": "📜 <b>Всего действий:</b>",
|
||||
"admin_user_total_paid_label": "💰 <b>Всего оплачено:</b>",
|
||||
"admin_user_referral_revenue_label": "💸 <b>Доход по рефералам:</b>",
|
||||
"admin_user_invited_friends_label": "👥 <b>Приглашено друзей:</b>",
|
||||
"admin_user_ref_purchased_label": "💳 <b>Купили подписку:</b>",
|
||||
"admin_user_subscription_active_until": "⏰ <b>Действует до:</b>",
|
||||
@@ -398,7 +458,12 @@
|
||||
"admin_sync_no_telegram_id": "\n⚠️ Записей без telegramId: {count}",
|
||||
"admin_sync_not_found_in_db": "\n❌ Не найдено в БД: {count}",
|
||||
"admin_payments_pagination_info": "📊 Показано {shown} из {total} платежей (стр. {current_page}/{total_pages})",
|
||||
"my_subscription_details": "🔐 <b>Моя подписка</b>\n\n⏰ Статус: <b>{status}</b>\n📅 Действует до: <b>{end_date}</b>\n📆 Осталось дней: <b>{days_left}</b>\n\n🔗 Ссылка на конфигурацию:\n<code>{config_link}</code>\n\n📊 Трафик:\nЛимит: <b>{traffic_limit}</b>\nИспользовано: <b>{traffic_used}</b>",
|
||||
"admin_payment_traffic_label": "🗂 Трафик: <b>{traffic_gb} ГБ</b>",
|
||||
"admin_payment_months_label": "📅 Период: <b>{months} мес.</b>",
|
||||
"my_subscription_details": "🔐 <b>Моя подписка</b>\n\n⏰ Статус: <b>{status}</b>\n📅 Действует до: <b>{end_date}</b>\n📆 Осталось дней: <b>{days_left}</b>\n\n🔗 Ссылка на конфигурацию:\n<code>{config_link}</code>\n\n📊 Трафик ({traffic_period}):\nЛимит: <b>{traffic_limit}</b>\nИспользовано: <b>{traffic_used}</b>",
|
||||
"my_traffic_details": "🔐 <b>Мой трафик</b>\n\n⏰ Статус: <b>{status}</b>\n📅 Действует до: <b>{end_date}</b>\n\n🔗 Ссылка на конфигурацию:\n<code>{config_link}</code>\n\n📊 Трафик ({traffic_period}):\nЛимит: <b>{traffic_limit}</b>\nИспользовано: <b>{traffic_used}</b>\nОсталось: <b>{traffic_left}</b>",
|
||||
"traffic_no_expiry": "без ограничения",
|
||||
"traffic_period_unknown": "неизвестно",
|
||||
"autorenew_enable_button": "🔄 Включить автопродление",
|
||||
"autorenew_disable_button": "🛑 Отключить автопродление",
|
||||
"subscription_autorenew_updated": "Настройки автопродления обновлены.",
|
||||
@@ -421,9 +486,8 @@
|
||||
"payment_method_tx_history_title": "📜 История операций",
|
||||
"payment_method_no_history": "История операций отсутствует.",
|
||||
"subscription_purchase_title": "Покупка подписки на {months} мес.",
|
||||
"subscription_tribute_notice": "Оплачено через Tribute. Продление делайте по ссылке Tribute.",
|
||||
"subscription_tribute_notice_with_link": "Оплачено через Tribute. Продлить: {link}",
|
||||
"subscription_autorenew_not_supported_for_tribute": "Автопродление управляется Tribute. Управляйте продлением в приложении/ссылке Tribute.",
|
||||
"traffic_purchase_title": "Покупка {traffic_gb} ГБ",
|
||||
"autorenew_enable_requires_card": "Прежде чем включать автоплатёж, привяжите карту в разделе «Способы оплаты».",
|
||||
"subscription_not_active": "У вас нет активной подписки.",
|
||||
"error_service_unavailable": "Сервис недоступен. Попробуйте позже.",
|
||||
"error_payment_gateway": "Ошибка платежного сервиса. Попробуйте позже.",
|
||||
@@ -455,5 +519,204 @@
|
||||
"admin_ads_delete_confirm": "Вы уверены, что хотите удалить кампанию #{id}? Это действие необратимо.",
|
||||
"admin_ads_deleted_success": "Кампания удалена.",
|
||||
"admin_ads_not_found": "Кампания не найдена.",
|
||||
"free_kassa_order_full": "Заказ №{order_id} от {date}\n\n"
|
||||
"free_kassa_order_full": "Заказ №{order_id} от {date}\n\n",
|
||||
"email_footer_auto": "Это автоматическое письмо от {brand}. Отвечать на него не нужно.",
|
||||
"email_login_code_subject": "{code} — код для входа",
|
||||
"email_login_code_preheader": "Одноразовый код действует {minutes} мин.",
|
||||
"email_login_code_heading": "Подтвердите вход",
|
||||
"email_login_code_intro": "Введите этот 6-значный код, чтобы завершить вход в личный кабинет подписки.",
|
||||
"email_login_code_expiry_html": "Код действует <strong style=\"color:#e6e9ef;\">{minutes} мин</strong>.",
|
||||
"email_login_code_security": "Если вы не запрашивали код, просто проигнорируйте это письмо — ваш аккаунт в безопасности.",
|
||||
"email_login_code_magic_cta": "Войти одним нажатием",
|
||||
"email_login_code_magic_intro": "Не хотите вводить код? Войдите по ссылке.",
|
||||
"email_login_code_magic_hint": "Ссылка одноразовая и действует столько же, сколько код.",
|
||||
"email_login_code_magic_or": "или",
|
||||
"email_login_code_text": "Ваш код подтверждения: {code}\n\nКод действует {minutes} мин.\nЕсли вы не запрашивали код, проигнорируйте это письмо.",
|
||||
"email_login_code_text_magic": "Или войдите по ссылке (одноразовая): {url}",
|
||||
"email_account_merged_subject": "Аккаунты объединены",
|
||||
"email_account_merged_preheader": "Мы объединили ваши аккаунты в один профиль.",
|
||||
"email_account_merged_heading": "Аккаунты объединены",
|
||||
"email_account_merged_intro": "Мы объединили ваши аккаунты в один профиль — теперь у вас одна подписка и общая история оплат.",
|
||||
"email_account_merged_row_kept": "Оставлен аккаунт",
|
||||
"email_account_merged_row_removed": "Удалён аккаунт",
|
||||
"email_account_merged_row_end_date": "Новая дата окончания",
|
||||
"email_account_merged_note": "Ссылка на подписку осталась прежней. Дублирующий пользователь панели был удалён автоматически.",
|
||||
"email_account_merged_text": "Мы объединили ваши аккаунты в один профиль.\n\nОставлен аккаунт: {primary}\nУдалён аккаунт: {removed}\nОплаченные периоды сложились. Новая дата окончания подписки: {end_date}.\nСсылка на подписку осталась прежней, а более поздний аккаунт был удалён из Remnawave автоматически.",
|
||||
"email_payment_success_subject": "Платёж получен",
|
||||
"email_payment_success_preheader": "Спасибо — подписка активирована.",
|
||||
"email_payment_success_heading": "Платёж получен",
|
||||
"email_payment_success_intro_subscription": "Спасибо! Ваша подписка продлена на {months} мес.",
|
||||
"email_payment_success_intro_traffic": "Спасибо! Мы зачислили {traffic_gb} ГБ трафика на ваш аккаунт.",
|
||||
"email_payment_success_row_period": "Период",
|
||||
"email_payment_success_row_traffic": "Трафик",
|
||||
"email_payment_success_row_amount": "Сумма",
|
||||
"email_payment_success_row_end_date": "Действует до",
|
||||
"email_payment_success_row_method": "Способ оплаты",
|
||||
"email_payment_success_period_value": "{months} мес.",
|
||||
"email_payment_success_traffic_value": "{traffic_gb} ГБ",
|
||||
"email_payment_success_cta": "Открыть кабинет",
|
||||
"email_payment_success_footer_note": "За несколько дней до окончания мы напомним о продлении.",
|
||||
"email_payment_success_text_subscription": "Платёж получен: {amount}.\nПодписка продлена на {months} мес.\nДействует до: {end_date}.",
|
||||
"email_payment_success_text_traffic": "Платёж получен: {amount}.\nЗачислено трафика: {traffic_gb} ГБ.\nДействует до: {end_date}.",
|
||||
"email_payment_success_text_dashboard": "Кабинет: {url}",
|
||||
"email_subscription_expiring_subject_today": "Подписка заканчивается сегодня",
|
||||
"email_subscription_expiring_subject_tomorrow": "Подписка заканчивается завтра",
|
||||
"email_subscription_expiring_subject_days": "До конца подписки осталось {days} дн.",
|
||||
"email_subscription_expiring_heading_today": "Подписка заканчивается сегодня",
|
||||
"email_subscription_expiring_heading_tomorrow": "Подписка заканчивается завтра",
|
||||
"email_subscription_expiring_heading_days": "До конца подписки {days} дн.",
|
||||
"email_subscription_expiring_preheader_today": "Продлите сегодня, чтобы не потерять доступ.",
|
||||
"email_subscription_expiring_preheader_tomorrow": "Подписка истекает завтра.",
|
||||
"email_subscription_expiring_preheader_days": "Подписка заканчивается через {days} дн.",
|
||||
"email_subscription_expiring_intro_today": "Ваша подписка вот-вот истечёт. Продлите её, чтобы соединение не прерывалось.",
|
||||
"email_subscription_expiring_intro_tomorrow": "Ваша подписка истекает завтра. Продлите её, чтобы не потерять доступ.",
|
||||
"email_subscription_expiring_intro_days": "Напоминаем заранее, чтобы вы успели продлить подписку без перерыва в работе.",
|
||||
"email_subscription_expiring_row_days_left": "Осталось дней",
|
||||
"email_subscription_expiring_row_end_date": "Действует до",
|
||||
"email_subscription_expiring_cta": "Продлить подписку",
|
||||
"email_subscription_expiring_note": "Если вы уже продлили или включили автопродление — просто проигнорируйте это письмо.",
|
||||
"email_subscription_expiring_text": "{heading}.\nДействует до: {end_date}.",
|
||||
"email_subscription_expiring_text_renew": "Продлить: {url}",
|
||||
"wa_loading": "Загрузка...",
|
||||
"wa_back": "Назад",
|
||||
"wa_close": "Закрыть",
|
||||
"wa_auth_checking_login": "Проверяем вход...",
|
||||
"wa_auth_login_confirm_failed": "Не удалось подтвердить вход",
|
||||
"wa_auth_checking_telegram": "Проверяем Telegram...",
|
||||
"wa_auth_access_denied": "Доступ запрещен",
|
||||
"wa_auth_telegram_not_confirmed": "Telegram-вход не подтвержден",
|
||||
"wa_auth_telegram_unavailable": "Telegram-вход сейчас недоступен",
|
||||
"wa_auth_telegram_not_configured": "Telegram-вход не настроен",
|
||||
"wa_auth_telegram_cancelled": "Вход через Telegram отменён",
|
||||
"wa_auth_invalid_email": "Введите корректный email",
|
||||
"wa_auth_sending_code": "Отправляем код...",
|
||||
"wa_auth_send_code_failed": "Не удалось отправить код",
|
||||
"wa_auth_enter_code_6digits": "Введите 6 цифр из письма",
|
||||
"wa_auth_checking_code": "Проверяем код...",
|
||||
"wa_auth_invalid_code": "Неверный код",
|
||||
"wa_auth_code_expired": "Код устарел",
|
||||
"wa_auth_resend_wait": "Повторная отправка через {seconds} сек.",
|
||||
"wa_email_verification_title": "Подтверждение по email",
|
||||
"wa_email_sent_to": "Мы отправили код на {email}",
|
||||
"wa_email_code_aria": "Код подтверждения",
|
||||
"wa_email_placeholder": "Email",
|
||||
"wa_confirm": "Подтвердить",
|
||||
"wa_resend_code": "Отправить код повторно",
|
||||
"wa_send_code_email": "Отправить код на почту",
|
||||
"wa_or": "или",
|
||||
"wa_login_telegram_button": "Войти через телеграм",
|
||||
"wa_auth_legal_intro": "Создавая аккаунт, вы соглашаетесь с",
|
||||
"wa_auth_legal_privacy": "политикой конфиденциальности",
|
||||
"wa_auth_legal_and": "и",
|
||||
"wa_auth_legal_agreement": "пользовательским соглашением",
|
||||
"wa_home_subscription_active": "Подписка активна",
|
||||
"wa_home_access_active": "Доступ активен",
|
||||
"wa_sub_term_forever": "Навсегда",
|
||||
"wa_sub_term_value_unit": "{value} {unit}",
|
||||
"wa_sub_term_day_one": "день",
|
||||
"wa_sub_term_day_few": "дня",
|
||||
"wa_sub_term_day_many": "дней",
|
||||
"wa_sub_term_month_one": "месяц",
|
||||
"wa_sub_term_month_few": "месяца",
|
||||
"wa_sub_term_month_many": "месяцев",
|
||||
"wa_sub_term_year_one": "год",
|
||||
"wa_sub_term_year_few": "года",
|
||||
"wa_sub_term_year_many": "лет",
|
||||
"wa_home_subscription_inactive": "Подписка не активна",
|
||||
"wa_until_date": "до {date}",
|
||||
"wa_home_traffic_used": "Использовано трафика",
|
||||
"wa_renew": "Продлить",
|
||||
"wa_pay_subscription": "Оплатить подписку",
|
||||
"wa_subscription_title": "Подписка",
|
||||
"wa_subscription_choose_period": "Выберите срок подписки",
|
||||
"wa_per_month_short": "/мес",
|
||||
"wa_traffic_packages_title": "Трафик",
|
||||
"wa_traffic_packages_choose": "Выберите пакет трафика",
|
||||
"wa_per_gb_short": "/ГБ",
|
||||
"wa_buy_traffic": "Купить трафик",
|
||||
"wa_activate_trial": "Активировать триал",
|
||||
"wa_trial_title": "Пробный период",
|
||||
"wa_trial_details": "{days} дн., {traffic}",
|
||||
"wa_trial_activated": "Пробный период активирован",
|
||||
"wa_trial_activation_failed": "Не удалось активировать пробный период",
|
||||
"wa_payment_methods_not_configured": "Способы оплаты пока не настроены",
|
||||
"wa_pay": "Оплатить",
|
||||
"wa_referral_link_title": "Ваша реферальная ссылка",
|
||||
"wa_link_unavailable": "Ссылка пока недоступна",
|
||||
"wa_link_copied": "Ссылка скопирована",
|
||||
"wa_copy": "Копировать",
|
||||
"wa_referral_bonus_overview_title": "Система бонусов",
|
||||
"wa_referral_bonus_once_note": "Бонус за приглашённого начисляется только один раз, после его первой оплаты.",
|
||||
"wa_referral_bonus_registration_title": "За регистрацию по реферальной ссылке",
|
||||
"wa_referral_bonus_paid_intro": "Если друг оплатит подписку:",
|
||||
"wa_referral_bonus_you_days": "Вам: +{days} дней",
|
||||
"wa_referral_bonus_friend_days": "Другу: +{days} дней",
|
||||
"wa_referral_bonus_not_configured": "Реферальные бонусы не настроены",
|
||||
"wa_promo_enter": "Введите промокод",
|
||||
"wa_promo_activated_until": "Промокод активирован. Подписка до {date}",
|
||||
"wa_promo_activated": "Промокод активирован",
|
||||
"wa_promo_activation_failed": "Не удалось активировать промокод",
|
||||
"wa_activate": "Активировать",
|
||||
"wa_settings_avatar_alt": "Аватар пользователя",
|
||||
"wa_settings_language": "Выбор языка",
|
||||
"wa_settings_link_telegram": "Привязка Telegram",
|
||||
"wa_settings_not_linked": "Не привязан",
|
||||
"wa_settings_link_email": "Привязка почты",
|
||||
"wa_settings_email_not_linked": "Не привязана",
|
||||
"wa_settings_linked": "Привязано",
|
||||
"wa_settings_language_update_failed": "Не удалось обновить язык",
|
||||
"wa_settings_user_agreement": "Пользовательское соглашение",
|
||||
"wa_settings_privacy_policy": "Политика конфиденциальности",
|
||||
"wa_logout": "Выйти",
|
||||
"wa_end_session": "Завершить сессию",
|
||||
"wa_navigation": "Навигация",
|
||||
"wa_nav_home": "Главная",
|
||||
"wa_nav_settings": "Настройки",
|
||||
"wa_nav_devices": "Устройства",
|
||||
"wa_devices_title": "Мои устройства",
|
||||
"wa_devices_count": "{current} из {max}",
|
||||
"wa_devices_unlimited": "Без ограничений",
|
||||
"wa_devices_refresh": "Обновить устройства",
|
||||
"wa_devices_loading": "Загружаем устройства...",
|
||||
"wa_devices_load_failed": "Не удалось загрузить устройства",
|
||||
"wa_devices_empty": "Устройств пока нет",
|
||||
"wa_devices_empty_hint": "Можно подключить до {max} устройств через приложение с подпиской.",
|
||||
"wa_device_fallback_name": "Устройство {index}",
|
||||
"wa_devices_platform_unknown": "Платформа неизвестна",
|
||||
"wa_devices_connected_at": "Подключено",
|
||||
"wa_devices_disconnect": "Отключить устройство",
|
||||
"wa_devices_disconnect_title": "Отключить устройство",
|
||||
"wa_devices_disconnect_desc": "{device} будет удалено из списка устройств. Оно может подключиться снова при следующем использовании подписки на этом устройстве.",
|
||||
"wa_devices_disconnect_confirm": "Отключить",
|
||||
"wa_device_disconnected": "Устройство отключено",
|
||||
"wa_device_disconnect_failed": "Не удалось отключить устройство",
|
||||
"wa_cancel": "Отмена",
|
||||
"wa_payment_created": "Платеж создан",
|
||||
"wa_payment_create_failed": "Не удалось создать платеж",
|
||||
"wa_connect_link_unavailable": "Ссылка для подключения пока недоступна",
|
||||
"wa_copied": "Скопировано",
|
||||
"wa_unavailable": "Пока недоступно",
|
||||
"wa_method_other_title": "Другие способы",
|
||||
"wa_unlimited_traffic": "Безлимитный трафик",
|
||||
"wa_traffic_of": "{used} из {limit}",
|
||||
"wa_language_default": "Русский",
|
||||
"wa_telegram_not_linked": "Telegram не привязан",
|
||||
"wa_tg_id_not_linked": "TG ID не привязан",
|
||||
"wa_nav_bonuses": "Бонусы",
|
||||
"wa_method_platega_sbp_card": "СБП/Карта",
|
||||
"wa_method_platega_crypto": "Крипта",
|
||||
"wa_settings_link_telegram_action": "Привязать телеграм",
|
||||
"wa_settings_telegram_linked_title": "Телеграм привязан",
|
||||
"wa_settings_link_email_action": "Привязать почту",
|
||||
"wa_settings_email_linked_title": "Почта привязана",
|
||||
"wa_activate_promo_title": "Активировать промокод",
|
||||
"wa_link_email_modal_title": "Привязать почту",
|
||||
"wa_traffic_reset_none": "Без сброса",
|
||||
"wa_traffic_reset_monthly": "Сброс ежемесячно",
|
||||
"wa_traffic_reset_weekly": "Сброс еженедельно",
|
||||
"wa_traffic_reset_daily": "Сброс ежедневно",
|
||||
"wa_traffic_reset_yearly": "Сброс ежегодно",
|
||||
"wa_traffic_reset_policy": "Стратегия сброса трафика",
|
||||
"wa_plan_one_year": "1 год",
|
||||
"wa_link_email_modal_desc": "Введите email и получите код подтверждения"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
from dotenv import load_dotenv
|
||||
@@ -9,8 +10,11 @@ from config.settings import get_settings, Settings
|
||||
from db.database_setup import init_db, init_db_connection
|
||||
|
||||
|
||||
def _resolve_log_level(value: str) -> int:
|
||||
return getattr(logging, value.upper(), logging.INFO)
|
||||
|
||||
|
||||
async def main():
|
||||
load_dotenv()
|
||||
settings = get_settings()
|
||||
|
||||
session_factory = init_db_connection(settings)
|
||||
@@ -25,8 +29,9 @@ async def main():
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
load_dotenv()
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
level=_resolve_log_level(os.getenv("LOG_LEVEL", "INFO")),
|
||||
stream=sys.stdout,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||
try:
|
||||
|
||||
Generated
+2570
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user