Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c121957aa | ||
|
|
5d00124ca0 | ||
|
|
b164fbd9bb | ||
|
|
f1620a784b | ||
|
|
e546ba3515 | ||
|
|
d39e781203 | ||
|
|
b5dfaf8188 | ||
|
|
0bd3d70e12 | ||
|
|
bce3d1a046 | ||
|
|
826b036724 | ||
|
|
799bd01a79 | ||
|
|
814663a528 | ||
|
|
ee9d1a3ad1 | ||
|
|
11a4d08b45 | ||
|
|
1eb2165b28 | ||
|
|
3f22c3a531 | ||
|
|
2c618c7a6a | ||
|
|
7622537961 | ||
|
|
5365df91f6 | ||
|
|
194295d34c | ||
|
|
5e9a08aeda | ||
|
|
f870e4d8b7 | ||
|
|
8ba0e465b1 | ||
|
|
0e470688a1 | ||
|
|
1e7152846b | ||
|
|
e29f723486 | ||
|
|
59d10184e8 | ||
|
|
da03510a92 | ||
|
|
199c7797f5 | ||
|
|
9a32cab718 | ||
|
|
4853a49112 | ||
|
|
0d637340f5 | ||
|
|
401469e276 | ||
|
|
31e59f6339 | ||
|
|
8dde585d49 | ||
|
|
3c84007f17 | ||
|
|
d11b0cabaa | ||
|
|
4b5908d6cc | ||
|
|
5bb8f2add0 | ||
|
|
484327a032 | ||
|
|
9ea9f571bb | ||
|
|
9b4a58f82f | ||
|
|
e6adbbece4 | ||
|
|
1e79b15351 | ||
|
|
6f3c123b3a | ||
|
|
4a0f763307 | ||
|
|
a121d38fbb | ||
|
|
1e8b97888a | ||
|
|
9ca2fe487c | ||
|
|
229ce7e1e0 | ||
|
|
39d5fd1856 | ||
|
|
fed1c1c960 | ||
|
|
e7df5e539c | ||
|
|
dcc7f9eb72 | ||
|
|
baaf5c457f | ||
|
|
62bf5c35a8 | ||
|
|
d619afff29 | ||
|
|
3bf9acf4d4 |
@@ -5,13 +5,6 @@
|
||||
.gitattributes
|
||||
LICENSE
|
||||
README.md
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
scratch_*.py
|
||||
*.local.*
|
||||
node_modules/
|
||||
.git/
|
||||
|
||||
|
||||
# CI
|
||||
@@ -24,11 +17,6 @@ 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.min.*.js
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
**/__pycache__/
|
||||
|
||||
+33
-61
@@ -3,71 +3,40 @@ BOT_TOKEN=your_bot_token_here #
|
||||
ADMIN_IDS=comma_separated_admin_ids # Your telegram ID
|
||||
|
||||
# PostgreSQL Database Connection Settings
|
||||
POSTGRES_USER= # Required: database user name
|
||||
POSTGRES_PASSWORD= # Required: database password
|
||||
POSTGRES_HOST=remnawave-minishop-db # Database container name
|
||||
POSTGRES_USER=postgres # Database user name
|
||||
POSTGRES_PASSWORD=postgres # Database password
|
||||
POSTGRES_HOST=remnawave-tg-shop-db # Database container name
|
||||
POSTGRES_PORT=5432 # Port
|
||||
POSTGRES_DB=postgres # Database name
|
||||
|
||||
# Localization and Display
|
||||
DEFAULT_LANGUAGE="ru" # or "en"
|
||||
DEFAULT_CURRENCY_SYMBOL="RUB" # e.g., RUB, USD, EUR
|
||||
|
||||
# External Links
|
||||
SUPPORT_LINK=https://t.me/your_support_link # Link to the support chat
|
||||
SERVER_STATUS_URL=https://status.yourdomain.tld/status/your_service # Link to the server status page
|
||||
TERMS_OF_SERVICE_URL=https://example.com/tos # Link to the terms of service
|
||||
PRIVACY_POLICY_URL=https://example.com/privacy # Link to the privacy policy
|
||||
USER_AGREEMENT_URL=https://example.com/user-agreement # Link to the user agreement
|
||||
SUBSCRIPTION_MINI_APP_URL= # Public URL of the subscription Mini App, e.g. https://app.yourdomain.tld/
|
||||
START_COMMAND_DESCRIPTION= # Description of the /start command
|
||||
DISABLE_WELCOME_MESSAGE= # Disable the welcome message
|
||||
SUBSCRIPTION_MINI_APP_URL="" # URL of the subscription mini-app
|
||||
START_COMMAND_DESCRIPTION="" # Description of the /start command
|
||||
DISABLE_WELCOME_MESSAGE=False # Disable the welcome message
|
||||
MY_DEVICES_SECTION_ENABLED=False # Enable the My Devices section in the subscription menu
|
||||
USER_HWID_DEVICE_LIMIT=0 # Default HWID/device limit for panel users (0 = unlimited)
|
||||
|
||||
# Required channel subscription
|
||||
REQUIRED_CHANNEL_ID= # Telegram channel ID (e.g. -1001234567890) the user must join
|
||||
REQUIRED_CHANNEL_SUBSCRIBE_TO_USE=False # Enable/disable the required channel gate
|
||||
REQUIRED_CHANNEL_ID="" # Telegram channel ID (e.g. -1001234567890) the user must join
|
||||
REQUIRED_CHANNEL_LINK=https://t.me/your_channel # Optional: public link/invite button text opens
|
||||
|
||||
# Webhook Base URL (used for Telegram and payment providers)
|
||||
WEBHOOK_BASE_URL=https://webhooks.yourdomain.tld
|
||||
TRUSTED_PROXIES=127.0.0.1,::1 # Reverse proxies trusted for X-Forwarded-For
|
||||
|
||||
# Subscription Mini App (same container, separate port)
|
||||
WEBAPP_ENABLED=True # Run Mini App HTTP server
|
||||
WEBAPP_SERVER_HOST=0.0.0.0 # Internal listen host
|
||||
WEBAPP_SERVER_PORT=8081 # Internal/published Mini App port
|
||||
WEBAPP_TITLE="Моя подписка" # Mini App title
|
||||
WEBAPP_PRIMARY_COLOR="#00fe7a" # Main UI color
|
||||
WEBAPP_LOGO_URL= # Optional logo URL; shown in the header and login screen, leave empty to hide
|
||||
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
|
||||
TELEGRAM_WEBHOOK_PATH=/webhook/telegram # Relative path for Telegram updates
|
||||
TELEGRAM_WEBHOOK_SECRET= # Secret token for Telegram webhook validation header
|
||||
|
||||
# Payment Method Toggles
|
||||
YOOKASSA_ENABLED=True # Turn on YOOKASSA
|
||||
FREEKASSA_ENABLED=True # Turn on FreeKassa
|
||||
STARS_ENABLED=True # Turn on STARS
|
||||
STARS_PROVIDER_TOKEN= # Leave empty for Telegram Stars (XTR)
|
||||
CRYPTOPAY_ENABLED=True # Turn on CRYPTOPAY
|
||||
PLATEGA_ENABLED=False # Turn on PLATEGA
|
||||
SEVERPAY_ENABLED=False # Turn on SeverPay
|
||||
@@ -95,7 +64,6 @@ FREEKASSA_API_KEY=your_api_key #
|
||||
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
|
||||
@@ -105,22 +73,19 @@ CRYPTOPAY_ASSET=RUB #
|
||||
|
||||
# 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)
|
||||
PLATEGA_MERCHANT_ID="" # Your MerchantId from Platega
|
||||
PLATEGA_SECRET="" # API secret from Platega
|
||||
PLATEGA_PAYMENT_METHOD=2 # Payment method ID (2=SBP QR, 10=RU cards, 12=International, 13=Crypto)
|
||||
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)
|
||||
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
|
||||
@@ -150,8 +115,8 @@ SUBSCRIPTION_NOTIFY_AFTER_EXPIRE=True #
|
||||
SUBSCRIPTION_NOTIFY_DAYS_BEFORE=3 # Days before expiration to notify
|
||||
|
||||
|
||||
REFERRAL_ENABLED=True # Enable/disable the referral system
|
||||
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
|
||||
@@ -168,7 +133,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_WEBHOOK_SECRET="" # secret used to verify panel webhook signatures
|
||||
|
||||
# User traffic limits (applied for all users)
|
||||
# 0 means unlimited
|
||||
@@ -178,7 +143,7 @@ USER_TRAFFIC_STRATEGY="NO_RESET" #
|
||||
# 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
|
||||
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
|
||||
@@ -188,7 +153,7 @@ TRIAL_TRAFFIC_STRATEGY="NO_RESET" #
|
||||
|
||||
# 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=
|
||||
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"
|
||||
@@ -197,16 +162,23 @@ WEB_SERVER_PORT=8080
|
||||
# Admin Panel Log Pagination
|
||||
LOGS_PAGE_SIZE=10 # Number of events in the log
|
||||
LOG_LEVEL=INFO # Global log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
||||
LOG_STORE_MESSAGE_CONTENT=False # Store message/callback content in DB logs
|
||||
LOG_STORE_RAW_UPDATES=False # Store raw update payload snippets in DB logs
|
||||
LOG_EXPORT_INCLUDE_SENSITIVE=False # Include content/raw update columns in admin CSV export
|
||||
LOG_ADMIN_HIDE=False # Hide admin actions from "All message logs" UI and CSV export
|
||||
|
||||
# Admin Logging Configuration
|
||||
LOG_CHAT_ID=-1001234567890 # Telegram chat/group ID for admin notifications
|
||||
LOG_THREAD_ID= # Optional: Thread ID for supergroup messages
|
||||
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
|
||||
|
||||
# Discount promo reservation timeout
|
||||
DISCOUNT_PROMO_PAYMENT_TIMEOUT_MINUTES=10 # Minutes to keep discount promo reservation before it expires
|
||||
|
||||
# Embedded mode thumbnails. Please don't touch this if you don't know what it is.
|
||||
INLINE_REFERRAL_THUMBNAIL_URL=https://cdn-icons-png.flaticon.com/512/1077/1077114.png
|
||||
INLINE_USER_STATS_THUMBNAIL_URL=https://cdn-icons-png.flaticon.com/512/681/681494.png
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
name: Codex PR Review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
- ready_for_review
|
||||
|
||||
jobs:
|
||||
codex:
|
||||
if: github.event.pull_request.draft == false
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
final_message: ${{ steps.run_codex.outputs.final-message }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
ref: refs/pull/${{ github.event.pull_request.number }}/merge
|
||||
|
||||
- name: Pre-fetch base and head refs for the PR
|
||||
run: |
|
||||
git fetch --no-tags origin \
|
||||
${{ github.event.pull_request.base.ref }} \
|
||||
+refs/pull/${{ github.event.pull_request.number }}/head
|
||||
|
||||
- name: Run Codex review
|
||||
id: run_codex
|
||||
uses: openai/codex-action@v1
|
||||
with:
|
||||
openai-api-key: ${{ secrets.CODEX_API_KEY }}
|
||||
responses-api-endpoint: ${{ secrets.CODEX_RESPONSES_API_ENDPOINT }}
|
||||
prompt: |
|
||||
This is PR #${{ github.event.pull_request.number }} for ${{ github.repository }}.
|
||||
|
||||
Review only changes from this PR using:
|
||||
git log --oneline ${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }}
|
||||
|
||||
Focus on:
|
||||
- bugs and regressions
|
||||
- security issues
|
||||
- risky edge cases
|
||||
- missing tests where critical
|
||||
|
||||
Keep feedback concise and actionable.
|
||||
|
||||
Pull request title and body:
|
||||
----
|
||||
${{ github.event.pull_request.title }}
|
||||
${{ github.event.pull_request.body }}
|
||||
|
||||
post-feedback:
|
||||
runs-on: ubuntu-latest
|
||||
needs: codex
|
||||
if: needs.codex.outputs.final_message != ''
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Post Codex feedback to PR
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
CODEX_FINAL_MESSAGE: ${{ needs.codex.outputs.final_message }}
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
script: |
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.payload.pull_request.number,
|
||||
body: process.env.CODEX_FINAL_MESSAGE,
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
name: Build and Push Dev Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
pull_request:
|
||||
branches:
|
||||
- dev
|
||||
|
||||
env:
|
||||
GHCR_REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
DOCKERHUB_IMAGE: docker.io/${{ secrets.DOCKERHUB_USERNAME }}/${{ github.event.repository.name }}
|
||||
|
||||
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 GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.GHCR_REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
${{ env.DOCKERHUB_IMAGE }}
|
||||
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 }}
|
||||
@@ -0,0 +1,56 @@
|
||||
name: Build and Publish multi-arch Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
paths-ignore:
|
||||
- 'README.md'
|
||||
|
||||
env:
|
||||
GHCR_IMAGE: ghcr.io/${{ github.repository }}
|
||||
DOCKERHUB_IMAGE: docker.io/${{ secrets.DOCKERHUB_USERNAME }}/${{ github.event.repository.name }}
|
||||
|
||||
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: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_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: |
|
||||
${{ env.GHCR_IMAGE }}:latest
|
||||
${{ env.GHCR_IMAGE }}:${{ github.ref_name }}
|
||||
${{ env.DOCKERHUB_IMAGE }}:latest
|
||||
${{ env.DOCKERHUB_IMAGE }}:${{ github.ref_name }}
|
||||
+2
-10
@@ -3,16 +3,6 @@ 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.min.*.js
|
||||
|
||||
# Игнорировать кэш Python
|
||||
__pycache__/
|
||||
@@ -27,3 +17,5 @@ __pycache__/
|
||||
locales/ru_backup.json
|
||||
locales/en_backup.json
|
||||
db/models_old.py
|
||||
.serena/
|
||||
AGENTS.md
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
# Replace the example domains below with your real webhook and Mini App hostnames.
|
||||
webhook.domain.com {
|
||||
encode zstd gzip
|
||||
reverse_proxy remnawave-minishop:{$WEB_SERVER_PORT:8080}
|
||||
}
|
||||
|
||||
app.domain.com {
|
||||
encode zstd gzip
|
||||
reverse_proxy remnawave-minishop:{$WEBAPP_SERVER_PORT:8081}
|
||||
}
|
||||
+3
-34
@@ -1,4 +1,4 @@
|
||||
FROM python:3.12-slim AS python-builder
|
||||
FROM python:3.12-slim AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -7,45 +7,14 @@ COPY requirements.txt .
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
|
||||
FROM node:22-slim AS webapp-builder
|
||||
|
||||
WORKDIR /webapp
|
||||
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
if [ -f package-lock.json ]; then npm ci; else npm install; fi
|
||||
|
||||
COPY bot/app/web/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
|
||||
|
||||
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 --from=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.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.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,21 +1,17 @@
|
||||
# Remnawave Minishop
|
||||
# Telegram-бот для продажи подписок 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 и многое другое. Возможна миграция.
|
||||
Этот Telegram-бот предназначен для автоматизации продажи и управления подписками для панели **Remnawave**. Он интегрируется с API Remnawave для управления пользователями и подписками, а также использует различные платежные системы для приема платежей.
|
||||
|
||||
## ✨ Ключевые возможности
|
||||
|
||||
### Для пользователей:
|
||||
- **Регистрация и выбор языка:** Поддержка русского и английского языков.
|
||||
- **Просмотр подписки:** Пользователи могут видеть статус своей подписки, дату окончания и ссылку на конфигурацию.
|
||||
- **Web App (Mini App):** отдельный веб-интерфейс для просмотра ссылки подключения, остатка времени и оплаты подписки.
|
||||
- **Вход по email:** вход и регистрация в Web App по коду из письма, а также привязка email и Telegram к одному аккаунту.
|
||||
- **Мои устройства:** Опциональный раздел для просмотра и отключения подключенных устройств (активируется через переменную `MY_DEVICES_SECTION_ENABLED`).
|
||||
- **Пробная подписка:** Система пробных подписок для новых пользователей (активируется вручную по кнопке).
|
||||
- **Промокоды:** Возможность применять промокоды для получения скидок или бонусных дней.
|
||||
- **Реферальная программа:** Пользователи могут приглашать друзей и получать за это бонусные дни подписки.
|
||||
- **Оплата:** Поддержка оплаты через YooKassa, FreeKassa (REST API), Platega, SeverPay, CryptoPay и Telegram Stars.
|
||||
- **Оплата:** Поддержка оплаты через YooKassa, FreeKassa (REST API), Platega, SeverPay, CryptoPay и Telegram Stars.
|
||||
|
||||
### Для администраторов:
|
||||
- **Защищенная админ-панель:** Доступ только для администраторов, указанных в `ADMIN_IDS`.
|
||||
@@ -32,6 +28,7 @@ Remnawave Minishop — это Telegram-бот **и** Web App (Mini App) для
|
||||
- **Aiogram 3.x:** Асинхронный фреймворк для Telegram ботов.
|
||||
- **aiohttp:** Для запуска веб-сервера (вебхуки).
|
||||
- **SQLAlchemy 2.x & asyncpg:** Асинхронная работа с базой данных PostgreSQL.
|
||||
- **Alembic:** Миграции схемы базы данных.
|
||||
- **YooKassa, FreeKassa API, Platega, SeverPay, aiocryptopay:** Интеграции с платежными системами.
|
||||
- **Pydantic:** Для управления настройками из `.env` файла.
|
||||
- **Docker & Docker Compose:** Для контейнеризации и развертывания.
|
||||
@@ -49,12 +46,12 @@ Remnawave Minishop — это Telegram-бот **и** Web App (Mini App) для
|
||||
|
||||
1. **Клонируйте репозиторий:**
|
||||
```bash
|
||||
git clone https://github.com/3252a8/remnawave-minishop
|
||||
cd remnawave-minishop
|
||||
git clone https://github.com/kavore/remnawave-tg-shop
|
||||
cd remnawave-tg-shop
|
||||
```
|
||||
|
||||
2. **Создайте и настройте файл `.env`:**
|
||||
Скопируйте `.env.example` в `.env` и заполните своими данными.
|
||||
Скопируйте `env.example` в `.env` и заполните своими данными.
|
||||
```bash
|
||||
cp .env.example .env
|
||||
nano .env
|
||||
@@ -70,31 +67,12 @@ Remnawave Minishop — это Telegram-бот **и** Web App (Mini App) для
|
||||
| `ADMIN_IDS` | **Обязательно.** ID администраторов в Telegram через запятую. | `12345678,98765432` |
|
||||
| `DEFAULT_LANGUAGE` | Язык по умолчанию для новых пользователей. | `ru` |
|
||||
| `SUPPORT_LINK` | (Опционально) Ссылка на поддержку. | `https://t.me/your_support` |
|
||||
| `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` |
|
||||
| `SUBSCRIPTION_MINI_APP_URL` | (Опционально) URL Mini App для показа подписки. | `https://t.me/your_bot/app` |
|
||||
| `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_SUBSCRIBE_TO_USE` | Включить/выключить обязательную проверку подписки на канал (`true`/`false`). | `false` |
|
||||
| `REQUIRED_CHANNEL_ID` | ID канала для проверки подписки. Используется, только если `REQUIRED_CHANNEL_SUBSCRIBE_TO_USE=true`. | `-1001234567890` |
|
||||
| `REQUIRED_CHANNEL_LINK` | (Опционально) Публичная ссылка или invite на канал для кнопки «Проверить подписку». | `https://t.me/your_channel` |
|
||||
| `REFERRAL_ENABLED` | Включить/выключить реферальную систему полностью (`true`/`false`). | `true` |
|
||||
</details>
|
||||
|
||||
<details>
|
||||
@@ -102,15 +80,15 @@ Remnawave Minishop — это Telegram-бот **и** Web App (Mini App) для
|
||||
|
||||
| Переменная | Описание |
|
||||
| --- | --- |
|
||||
| `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`). |
|
||||
| `WEBHOOK_BASE_URL`| **Обязательно.** Базовый URL для вебхуков, например `https://your.domain.com`. |
|
||||
| `TELEGRAM_WEBHOOK_PATH` | Относительный путь Telegram вебхука. По умолчанию `/webhook/telegram`. |
|
||||
| `TELEGRAM_WEBHOOK_SECRET` | (Рекомендуется) Секрет для проверки заголовка `X-Telegram-Bot-Api-Secret-Token`. |
|
||||
| `WEB_SERVER_HOST` | Хост для веб-сервера. По умолчанию `0.0.0.0`. | `0.0.0.0` |
|
||||
| `WEB_SERVER_PORT` | Порт для веб-сервера. | `8080` |
|
||||
| `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 (самозанятый). |
|
||||
@@ -125,12 +103,13 @@ Remnawave Minishop — это Telegram-бот **и** Web App (Mini App) для
|
||||
| `FREEKASSA_PAYMENT_IP` | Внешний IP вашего сервера, который будет передаваться в запрос оплаты. |
|
||||
| `FREEKASSA_PAYMENT_METHOD_ID` | ID метода оплаты через магазин FreeKassa. По умолчанию `44`. |
|
||||
| `STARS_ENABLED` | Включить/выключить Telegram Stars (`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`. |
|
||||
| `STARS_PROVIDER_TOKEN` | Токен провайдера Telegram invoice. Для Stars (XTR) оставить пустым. |
|
||||
| `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. |
|
||||
@@ -139,6 +118,18 @@ Remnawave Minishop — это Telegram-бот **и** Web App (Mini App) для
|
||||
| `SEVERPAY_LIFETIME_MINUTES` | (Опционально) Время жизни платежной ссылки в минутах (30–4320). |
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Настройки логирования</b></summary>
|
||||
|
||||
| Переменная | Описание | Пример |
|
||||
| --- | --- | --- |
|
||||
| `LOGS_PAGE_SIZE` | Количество записей на странице в разделе админ-логов. | `10` |
|
||||
| `LOG_STORE_MESSAGE_CONTENT` | Сохранять ли содержимое сообщений/колбэков в БД логов (`true`/`false`). | `false` |
|
||||
| `LOG_STORE_RAW_UPDATES` | Сохранять ли превью сырого Telegram update в БД логов (`true`/`false`). | `false` |
|
||||
| `LOG_EXPORT_INCLUDE_SENSITIVE` | Добавлять ли в CSV экспорт чувствительные поля (`content`, `raw_update_preview`). | `false` |
|
||||
| `LOG_ADMIN_HIDE` | Скрывать админские события (`ADMIN_IDS`) в интерфейсе «Все логи сообщений» и в CSV экспорте (`true`/`false`). Логи продолжают записываться в БД. | `true` |
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Настройки подписок</b></summary>
|
||||
|
||||
@@ -175,155 +166,50 @@ Remnawave Minishop — это Telegram-бот **и** Web App (Mini App) для
|
||||
| `TRIAL_TRAFFIC_LIMIT_GB`| Лимит трафика для пробного периода в ГБ. |
|
||||
</details>
|
||||
|
||||
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. **Запустите контейнеры:**
|
||||
3. **Запустите контейнеры:**
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
Эта команда соберёт образ из `Dockerfile` (Python + сборка Web App на Node) и запустит сервис в фоновом режиме. Если нужен запуск из готового образа GHCR — используйте `docker-compose-remote-server.yml`.
|
||||
Эта команда скачает образ и запустит сервис в фоновом режиме.
|
||||
|
||||
5. **Настройка вебхуков (Обязательно):**
|
||||
4. **Настройка вебхуков (Обязательно):**
|
||||
Вебхуки являются **обязательным** компонентом для работы бота, так как они используются для получения уведомлений от платежных систем (YooKassa, FreeKassa, CryptoPay, Platega, SeverPay) и панели Remnawave.
|
||||
|
||||
Вам понадобится обратный прокси (например, Nginx) для обработки HTTPS-трафика и перенаправления запросов на контейнер с ботом.
|
||||
|
||||
**Пути для перенаправления:**
|
||||
- `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>`.
|
||||
- `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/platega` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/platega`
|
||||
- `https://<ваш_домен>/webhook/severpay` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/severpay`
|
||||
- `https://<ваш_домен>/webhook/cryptopay` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/cryptopay`
|
||||
- `https://<ваш_домен>/webhook/panel` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/panel`
|
||||
- **Для Telegram:** Бот автоматически установит вебхук, если в `.env` указан `WEBHOOK_BASE_URL`. Путь берётся из `TELEGRAM_WEBHOOK_PATH` (по умолчанию `https://<ваш_домен>/webhook/telegram`).
|
||||
|
||||
Где `remnawave-minishop` — это имя сервиса из `docker-compose.yml`, а `<WEB_SERVER_PORT>` — порт, указанный в `.env`.
|
||||
Где `remnawave-tg-shop` — это имя сервиса из `docker-compose.yml`, а `<WEB_SERVER_PORT>` — порт, указанный в `.env`.
|
||||
|
||||
**Отдельный порт Web App:**
|
||||
- `https://<домен_web_app>/` → `http://remnawave-minishop:<WEBAPP_SERVER_PORT>/`
|
||||
|
||||
Web App не должен проксироваться на `WEB_SERVER_PORT`: этот порт оставьте для Telegram, платежных и Remnawave webhooks.
|
||||
|
||||
6. **Просмотр логов:**
|
||||
5. **Просмотр логов:**
|
||||
```bash
|
||||
docker compose logs -f remnawave-minishop
|
||||
docker compose logs -f remnawave-tg-shop
|
||||
```
|
||||
|
||||
> 💡 Если включена проверка подписки на канал (`REQUIRED_CHANNEL_ID`), добавьте бота администратором в этот канал. Пользователь увидит кнопку «Проверить подписку», и, после первого успешного подтверждения, дальнейшие действия блокироваться не будут.
|
||||
> 💡 Если включена проверка подписки (`REQUIRED_CHANNEL_SUBSCRIBE_TO_USE=true`), добавьте бота администратором в канал из `REQUIRED_CHANNEL_ID`. Пользователь увидит кнопку «Проверить подписку», и после успешного подтверждения доступ продолжится.
|
||||
|
||||
### Настройка Web App / Mini App
|
||||
### Миграции БД (Alembic)
|
||||
|
||||
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>`.
|
||||
|
||||
Шапка и модальные окна Web App учитывают Telegram safe area через `--tg-content-safe-area-inset-top` и `--tg-safe-area-inset-top`, а сверху добавлен повышенный дополнительный буфер, чтобы интерфейс не уезжал под панель Telegram при открытии из списка чатов.
|
||||
|
||||
Для настройки внешнего вида без запуска бота можно открыть файл `bot/app/web/templates/subscription_webapp.html` напрямую в браузере. Рядом с ним лежат `telegram-web-app.js`, `telegram-widget.js`, `subscription_webapp.css` и `subscription_webapp.js`, поэтому локальный предпросмотр работает без сервера и без внешнего CDN. При отдаче страницы через Web App сервер dev-mock автоматически вырезается и не попадает пользователям.
|
||||
|
||||
Локальная копия Telegram Web App SDK хранится в `bot/app/web/templates/telegram-web-app.js`, а локальная копия Telegram Login Widget - в `bot/app/web/templates/telegram-widget.js`. В контейнере обе копии автоматически обновляются при старте Web App и затем раз в 24 часа; если источник временно недоступен, используется уже сохраненная версия. У локального `telegram-widget.js` есть минимальная нормализация, чтобы при загрузке с вашего домена виджет все равно открывал iframe на Telegram-origin, а не на `/embed/...` вашего сайта. Для ручного обновления можно запустить команды:
|
||||
- При запуске `python main.py` миграции применяются автоматически до `head`.
|
||||
- Для ручного запуска используйте:
|
||||
|
||||
```bash
|
||||
python scripts/update_telegram_web_app_js.py
|
||||
python scripts/update_telegram_widget_js.py
|
||||
alembic upgrade head
|
||||
```
|
||||
|
||||
При необходимости оба скрипта принимают `--source-url` и `--target`, если нужно скачать файл в другое место или проверить альтернативный источник.
|
||||
|
||||
## Подробная инструкция для развертывания на сервере с панелью Remnawave
|
||||
|
||||
### 1. Клонирование репозитория
|
||||
|
||||
```bash
|
||||
git clone https://github.com/3252a8/remnawave-minishop && cd remnawave-minishop
|
||||
git clone https://github.com/kavore/remnawave-tg-shop && cd remnawave-tg-shop
|
||||
```
|
||||
|
||||
### 2. Настройка переменных окружения
|
||||
@@ -352,8 +238,8 @@ cd /opt/remnawave/nginx && nano nginx.conf
|
||||
Добавьте в `nginx.conf` следующую конфигурацию:
|
||||
|
||||
```nginx
|
||||
upstream remnawave-minishop {
|
||||
server remnawave-minishop:8080;
|
||||
upstream remnawave-tg-shop {
|
||||
server remnawave-tg-shop:8080;
|
||||
}
|
||||
|
||||
map $http_upgrade $connection_upgrade {
|
||||
@@ -385,7 +271,7 @@ server {
|
||||
error_page 400 404 500 502 @redirect;
|
||||
|
||||
location / {
|
||||
proxy_pass http://remnawave-minishop$request_uri;
|
||||
proxy_pass http://remnawave-tg-shop$request_uri;
|
||||
}
|
||||
|
||||
location @redirect {
|
||||
@@ -455,7 +341,7 @@ networks:
|
||||
Запустите бота:
|
||||
|
||||
```bash
|
||||
cd /root/remnawave-minishop && docker compose up -d && docker compose logs -f -t
|
||||
cd /root/remnawave-tg-shop && docker compose up -d && docker compose logs -f -t
|
||||
```
|
||||
|
||||
Перезапустите Nginx:
|
||||
@@ -466,96 +352,40 @@ cd /opt/remnawave/nginx && docker compose down && docker compose up -d && docker
|
||||
|
||||
## 🐳 Docker
|
||||
|
||||
Файлы `Dockerfile` и `docker-compose.yml` уже настроены для локальной сборки и запуска проекта.
|
||||
Файлы `Dockerfile` и `docker-compose.yml` уже настроены для сборки и запуска проекта. `docker-compose.yml` использует готовый образ с GitHub Container Registry, но вы можете раскомментировать `build: .` для локальной сборки.
|
||||
|
||||
Если нужен запуск из готового образа, используйте `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).
|
||||
Для автоматической публикации образов настроены GitHub Actions (`.github/workflows`). По умолчанию образы пушатся в GitHub Container Registry и Docker Hub. Добавьте в Secrets репозитория значения `DOCKERHUB_USERNAME` и `DOCKERHUB_TOKEN` (персональный access token или пароль для Docker Hub), чтобы загрузка в Docker Hub работала корректно.
|
||||
|
||||
## 📁 Структура проекта
|
||||
|
||||
```
|
||||
.
|
||||
├── bot/
|
||||
│ ├── 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 # Основная логика бота
|
||||
│ ├── filters/ # Пользовательские фильтры Aiogram
|
||||
│ ├── handlers/ # Обработчики сообщений и колбэков
|
||||
│ ├── keyboards/ # Клавиатуры
|
||||
│ ├── middlewares/ # Промежуточные слои (i18n, проверка бана)
|
||||
│ ├── services/ # Бизнес-логика (платежи, API панели)
|
||||
│ ├── states/ # Состояния FSM
|
||||
│ └── main_bot.py # Основная логика бота
|
||||
├── config/
|
||||
│ └── settings.py # Настройки Pydantic
|
||||
│ └── settings.py # Настройки Pydantic
|
||||
├── db/
|
||||
│ ├── 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 # Точка входа в приложение
|
||||
│ ├── dal/ # Слой доступа к данным (DAL)
|
||||
│ ├── database_setup.py # Настройка БД
|
||||
│ └── models.py # Модели SQLAlchemy
|
||||
├── locales/ # Файлы локализации (ru, en)
|
||||
├── .env.example # Пример файла с переменными окружения
|
||||
├── Dockerfile # Инструкции для сборки Docker-образа
|
||||
├── docker-compose.yml # Файл для оркестрации контейнеров
|
||||
├── requirements.txt # Зависимости Python
|
||||
└── main.py # Точка входа в приложение
|
||||
```
|
||||
|
||||
## 🔮 Планы на будущее
|
||||
|
||||
- Расширенные типы промокодов (например, скидки в процентах).
|
||||
|
||||
## ❤️ Поддержка
|
||||
- Crypto: `USDT/Other ERC-20 0xeD506D44aae634fEc0E01C8835744fBedb7B2a44 (Ethereum/Polygon/Gnosis)`
|
||||
- Карты РФ и зарубежные: [Tribute](https://t.me/tribute/app?startapp=dqdg)
|
||||
- Crypto: `USDT TRC-20 TT3SqBbfU4vYm6SUwUVNZsy278m2xbM4GE`
|
||||
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
[alembic]
|
||||
script_location = alembic
|
||||
prepend_sys_path = .
|
||||
sqlalchemy.url =
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
@@ -0,0 +1,94 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import pool
|
||||
from sqlalchemy.engine import Connection
|
||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||
|
||||
from db.models import Base
|
||||
|
||||
config = context.config
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def _get_database_url() -> str:
|
||||
configured_url = config.get_main_option("sqlalchemy.url")
|
||||
if configured_url:
|
||||
return configured_url
|
||||
|
||||
env_url = os.getenv("DATABASE_URL")
|
||||
if env_url:
|
||||
return env_url
|
||||
|
||||
user = os.getenv("POSTGRES_USER", "postgres")
|
||||
password = os.getenv("POSTGRES_PASSWORD", "postgres")
|
||||
host = os.getenv("POSTGRES_HOST", "localhost")
|
||||
port = os.getenv("POSTGRES_PORT", "5432")
|
||||
db_name = os.getenv("POSTGRES_DB", "postgres")
|
||||
return f"postgresql+asyncpg://{user}:{password}@{host}:{port}/{db_name}"
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Run migrations in 'offline' mode."""
|
||||
|
||||
context.configure(
|
||||
url=_get_database_url(),
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
compare_type=True,
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def do_run_migrations(connection: Connection) -> None:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
compare_type=True,
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_async_migrations() -> None:
|
||||
configuration = config.get_section(config.config_ini_section) or {}
|
||||
configuration["sqlalchemy.url"] = _get_database_url()
|
||||
|
||||
connectable = async_engine_from_config(
|
||||
configuration,
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
async with connectable.connect() as connection:
|
||||
await connection.run_sync(do_run_migrations)
|
||||
|
||||
await connectable.dispose()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Run migrations in 'online' mode."""
|
||||
|
||||
connectable = config.attributes.get("connection", None)
|
||||
if connectable is None:
|
||||
asyncio.run(run_async_migrations())
|
||||
else:
|
||||
do_run_migrations(connectable)
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -0,0 +1,26 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,280 @@
|
||||
"""initial schema
|
||||
|
||||
Revision ID: 0001_initial_schema
|
||||
Revises:
|
||||
Create Date: 2026-02-08 00:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "0001_initial_schema"
|
||||
down_revision: Union[str, Sequence[str], None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"users",
|
||||
sa.Column("user_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("username", sa.String(), nullable=True),
|
||||
sa.Column("first_name", sa.String(), nullable=True),
|
||||
sa.Column("last_name", sa.String(), nullable=True),
|
||||
sa.Column("language_code", sa.String(), nullable=True),
|
||||
sa.Column("registration_date", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=True),
|
||||
sa.Column("is_banned", sa.Boolean(), nullable=True),
|
||||
sa.Column("panel_user_uuid", sa.String(), nullable=True),
|
||||
sa.Column("referral_code", sa.String(length=16), nullable=True),
|
||||
sa.Column("referred_by_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("channel_subscription_verified", sa.Boolean(), nullable=True),
|
||||
sa.Column("channel_subscription_checked_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("channel_subscription_verified_for", sa.BigInteger(), nullable=True),
|
||||
sa.ForeignKeyConstraint(["referred_by_id"], ["users.user_id"]),
|
||||
sa.PrimaryKeyConstraint("user_id"),
|
||||
sa.UniqueConstraint("panel_user_uuid"),
|
||||
sa.UniqueConstraint("referral_code"),
|
||||
)
|
||||
op.create_index("ix_users_username", "users", ["username"], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"promo_codes",
|
||||
sa.Column("promo_code_id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("code", sa.String(), nullable=False),
|
||||
sa.Column("promo_type", sa.String(), nullable=False),
|
||||
sa.Column("bonus_days", sa.Integer(), nullable=True),
|
||||
sa.Column("discount_percentage", sa.Integer(), nullable=True),
|
||||
sa.Column("max_activations", sa.Integer(), nullable=False),
|
||||
sa.Column("current_activations", sa.Integer(), nullable=True),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=True),
|
||||
sa.Column("created_by_admin_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=True),
|
||||
sa.Column("valid_until", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint("promo_code_id"),
|
||||
sa.UniqueConstraint("code"),
|
||||
)
|
||||
op.create_index("idx_promo_codes_promo_type", "promo_codes", ["promo_type"], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"ad_campaigns",
|
||||
sa.Column("ad_campaign_id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("source", sa.String(), nullable=False),
|
||||
sa.Column("start_param", sa.String(), nullable=False),
|
||||
sa.Column("cost", sa.Float(), nullable=False),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=True),
|
||||
sa.PrimaryKeyConstraint("ad_campaign_id"),
|
||||
sa.UniqueConstraint("start_param"),
|
||||
)
|
||||
op.create_index("ix_ad_campaigns_source", "ad_campaigns", ["source"], unique=False)
|
||||
op.create_index("ix_ad_campaigns_is_active", "ad_campaigns", ["is_active"], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"subscriptions",
|
||||
sa.Column("subscription_id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("user_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("panel_user_uuid", sa.String(), nullable=False),
|
||||
sa.Column("panel_subscription_uuid", sa.String(), nullable=True),
|
||||
sa.Column("start_date", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("end_date", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("duration_months", sa.Integer(), nullable=True),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=True),
|
||||
sa.Column("status_from_panel", sa.String(), nullable=True),
|
||||
sa.Column("traffic_limit_bytes", sa.BigInteger(), nullable=True),
|
||||
sa.Column("traffic_used_bytes", sa.BigInteger(), nullable=True),
|
||||
sa.Column("last_notification_sent", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("provider", sa.String(), nullable=True),
|
||||
sa.Column("skip_notifications", sa.Boolean(), nullable=True),
|
||||
sa.Column("auto_renew_enabled", sa.Boolean(), nullable=True),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.user_id"]),
|
||||
sa.PrimaryKeyConstraint("subscription_id"),
|
||||
sa.UniqueConstraint("panel_subscription_uuid"),
|
||||
)
|
||||
op.create_index("ix_subscriptions_user_id", "subscriptions", ["user_id"], unique=False)
|
||||
op.create_index("ix_subscriptions_panel_user_uuid", "subscriptions", ["panel_user_uuid"], unique=False)
|
||||
op.create_index("ix_subscriptions_end_date", "subscriptions", ["end_date"], unique=False)
|
||||
op.create_index("ix_subscriptions_is_active", "subscriptions", ["is_active"], unique=False)
|
||||
op.create_index("ix_subscriptions_auto_renew_enabled", "subscriptions", ["auto_renew_enabled"], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"payments",
|
||||
sa.Column("payment_id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("user_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("yookassa_payment_id", sa.String(), nullable=True),
|
||||
sa.Column("provider_payment_id", sa.String(), nullable=True),
|
||||
sa.Column("provider", sa.String(), nullable=False),
|
||||
sa.Column("idempotence_key", sa.String(), nullable=True),
|
||||
sa.Column("amount", sa.Float(), nullable=False),
|
||||
sa.Column("original_amount", sa.Float(), nullable=True),
|
||||
sa.Column("discount_applied", sa.Float(), nullable=True),
|
||||
sa.Column("currency", sa.String(), nullable=False),
|
||||
sa.Column("status", sa.String(), nullable=False),
|
||||
sa.Column("description", sa.String(), nullable=True),
|
||||
sa.Column("subscription_duration_months", sa.Integer(), nullable=True),
|
||||
sa.Column("promo_code_id", sa.Integer(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=True),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(["promo_code_id"], ["promo_codes.promo_code_id"]),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.user_id"]),
|
||||
sa.PrimaryKeyConstraint("payment_id"),
|
||||
sa.UniqueConstraint("idempotence_key"),
|
||||
sa.UniqueConstraint("provider_payment_id"),
|
||||
sa.UniqueConstraint("yookassa_payment_id"),
|
||||
)
|
||||
op.create_index("ix_payments_user_id", "payments", ["user_id"], unique=False)
|
||||
op.create_index("ix_payments_provider", "payments", ["provider"], unique=False)
|
||||
op.create_index("ix_payments_status", "payments", ["status"], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"user_billing",
|
||||
sa.Column("user_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("yookassa_payment_method_id", sa.String(), nullable=True),
|
||||
sa.Column("card_last4", sa.String(), nullable=True),
|
||||
sa.Column("card_network", sa.String(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=True),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.user_id"]),
|
||||
sa.PrimaryKeyConstraint("user_id"),
|
||||
sa.UniqueConstraint("yookassa_payment_method_id"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"user_payment_methods",
|
||||
sa.Column("method_id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("user_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("provider", sa.String(), nullable=False),
|
||||
sa.Column("provider_payment_method_id", sa.String(), nullable=False),
|
||||
sa.Column("card_last4", sa.String(), nullable=True),
|
||||
sa.Column("card_network", sa.String(), nullable=True),
|
||||
sa.Column("is_default", sa.Boolean(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=True),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.user_id"]),
|
||||
sa.PrimaryKeyConstraint("method_id"),
|
||||
sa.UniqueConstraint("provider_payment_method_id"),
|
||||
sa.UniqueConstraint("user_id", "provider_payment_method_id", name="uq_user_provider_method"),
|
||||
)
|
||||
op.create_index("ix_user_payment_methods_user_id", "user_payment_methods", ["user_id"], unique=False)
|
||||
op.create_index("ix_user_payment_methods_provider", "user_payment_methods", ["provider"], unique=False)
|
||||
op.create_index("ix_user_payment_methods_is_default", "user_payment_methods", ["is_default"], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"promo_code_activations",
|
||||
sa.Column("activation_id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("promo_code_id", sa.Integer(), nullable=False),
|
||||
sa.Column("user_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("activated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=True),
|
||||
sa.Column("payment_id", sa.Integer(), nullable=True),
|
||||
sa.ForeignKeyConstraint(["payment_id"], ["payments.payment_id"]),
|
||||
sa.ForeignKeyConstraint(["promo_code_id"], ["promo_codes.promo_code_id"]),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.user_id"]),
|
||||
sa.PrimaryKeyConstraint("activation_id"),
|
||||
sa.UniqueConstraint("promo_code_id", "user_id", name="uq_promo_user_activation"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"active_discounts",
|
||||
sa.Column("user_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("promo_code_id", sa.Integer(), nullable=False),
|
||||
sa.Column("discount_percentage", sa.Integer(), nullable=False),
|
||||
sa.Column("activated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.ForeignKeyConstraint(["promo_code_id"], ["promo_codes.promo_code_id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.user_id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("user_id"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"message_logs",
|
||||
sa.Column("log_id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("user_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("telegram_username", sa.String(), nullable=True),
|
||||
sa.Column("telegram_first_name", sa.String(), nullable=True),
|
||||
sa.Column("event_type", sa.String(), nullable=False),
|
||||
sa.Column("content", sa.Text(), nullable=True),
|
||||
sa.Column("raw_update_preview", sa.Text(), nullable=True),
|
||||
sa.Column("timestamp", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=True),
|
||||
sa.Column("is_admin_event", sa.Boolean(), nullable=True),
|
||||
sa.Column("target_user_id", sa.BigInteger(), nullable=True),
|
||||
sa.ForeignKeyConstraint(["target_user_id"], ["users.user_id"]),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.user_id"]),
|
||||
sa.PrimaryKeyConstraint("log_id"),
|
||||
)
|
||||
op.create_index("ix_message_logs_user_id", "message_logs", ["user_id"], unique=False)
|
||||
op.create_index("ix_message_logs_event_type", "message_logs", ["event_type"], unique=False)
|
||||
op.create_index("ix_message_logs_timestamp", "message_logs", ["timestamp"], unique=False)
|
||||
op.create_index("ix_message_logs_target_user_id", "message_logs", ["target_user_id"], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"panel_sync_status",
|
||||
sa.Column("id", sa.Integer(), autoincrement=False, nullable=False),
|
||||
sa.Column("last_sync_time", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("status", sa.String(), nullable=True),
|
||||
sa.Column("details", sa.Text(), nullable=True),
|
||||
sa.Column("users_processed_from_panel", sa.Integer(), nullable=True),
|
||||
sa.Column("subscriptions_synced", sa.Integer(), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("id"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"ad_attributions",
|
||||
sa.Column("user_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("ad_campaign_id", sa.Integer(), nullable=False),
|
||||
sa.Column("first_start_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=True),
|
||||
sa.Column("trial_activated_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(["ad_campaign_id"], ["ad_campaigns.ad_campaign_id"]),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.user_id"]),
|
||||
sa.PrimaryKeyConstraint("user_id"),
|
||||
)
|
||||
op.create_index("ix_ad_attributions_ad_campaign_id", "ad_attributions", ["ad_campaign_id"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_ad_attributions_ad_campaign_id", table_name="ad_attributions")
|
||||
op.drop_table("ad_attributions")
|
||||
|
||||
op.drop_table("panel_sync_status")
|
||||
|
||||
op.drop_index("ix_message_logs_target_user_id", table_name="message_logs")
|
||||
op.drop_index("ix_message_logs_timestamp", table_name="message_logs")
|
||||
op.drop_index("ix_message_logs_event_type", table_name="message_logs")
|
||||
op.drop_index("ix_message_logs_user_id", table_name="message_logs")
|
||||
op.drop_table("message_logs")
|
||||
|
||||
op.drop_table("active_discounts")
|
||||
|
||||
op.drop_table("promo_code_activations")
|
||||
|
||||
op.drop_index("ix_user_payment_methods_is_default", table_name="user_payment_methods")
|
||||
op.drop_index("ix_user_payment_methods_provider", table_name="user_payment_methods")
|
||||
op.drop_index("ix_user_payment_methods_user_id", table_name="user_payment_methods")
|
||||
op.drop_table("user_payment_methods")
|
||||
|
||||
op.drop_table("user_billing")
|
||||
|
||||
op.drop_index("ix_payments_status", table_name="payments")
|
||||
op.drop_index("ix_payments_provider", table_name="payments")
|
||||
op.drop_index("ix_payments_user_id", table_name="payments")
|
||||
op.drop_table("payments")
|
||||
|
||||
op.drop_index("ix_subscriptions_auto_renew_enabled", table_name="subscriptions")
|
||||
op.drop_index("ix_subscriptions_is_active", table_name="subscriptions")
|
||||
op.drop_index("ix_subscriptions_end_date", table_name="subscriptions")
|
||||
op.drop_index("ix_subscriptions_panel_user_uuid", table_name="subscriptions")
|
||||
op.drop_index("ix_subscriptions_user_id", table_name="subscriptions")
|
||||
op.drop_table("subscriptions")
|
||||
|
||||
op.drop_index("ix_ad_campaigns_is_active", table_name="ad_campaigns")
|
||||
op.drop_index("ix_ad_campaigns_source", table_name="ad_campaigns")
|
||||
op.drop_table("ad_campaigns")
|
||||
|
||||
op.drop_index("idx_promo_codes_promo_type", table_name="promo_codes")
|
||||
op.drop_table("promo_codes")
|
||||
|
||||
op.drop_index("ix_users_username", table_name="users")
|
||||
op.drop_table("users")
|
||||
@@ -0,0 +1,86 @@
|
||||
"""add active discount expiration
|
||||
|
||||
Revision ID: 0002_active_discount_expires_at
|
||||
Revises: 0001_initial_schema
|
||||
Create Date: 2026-02-08 00:00:01.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op, context
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "0002_active_discount_expires_at"
|
||||
down_revision: Union[str, Sequence[str], None] = "0001_initial_schema"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
_INDEX_NAME = "idx_active_discounts_expires_at"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if context.is_offline_mode():
|
||||
op.add_column(
|
||||
"active_discounts",
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.execute(
|
||||
sa.text(
|
||||
"UPDATE active_discounts "
|
||||
"SET expires_at = COALESCE(activated_at, NOW()) + INTERVAL '10 minutes' "
|
||||
"WHERE expires_at IS NULL"
|
||||
)
|
||||
)
|
||||
op.alter_column("active_discounts", "expires_at", nullable=False)
|
||||
op.create_index(_INDEX_NAME, "active_discounts", ["expires_at"], unique=False)
|
||||
return
|
||||
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
if not inspector.has_table("active_discounts"):
|
||||
return
|
||||
|
||||
columns = {column["name"] for column in inspector.get_columns("active_discounts")}
|
||||
if "expires_at" not in columns:
|
||||
op.add_column(
|
||||
"active_discounts",
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.execute(
|
||||
sa.text(
|
||||
"UPDATE active_discounts "
|
||||
"SET expires_at = COALESCE(activated_at, NOW()) + INTERVAL '10 minutes' "
|
||||
"WHERE expires_at IS NULL"
|
||||
)
|
||||
)
|
||||
op.alter_column("active_discounts", "expires_at", nullable=False)
|
||||
|
||||
indexes = {index["name"] for index in inspector.get_indexes("active_discounts")}
|
||||
if _INDEX_NAME not in indexes:
|
||||
op.create_index(_INDEX_NAME, "active_discounts", ["expires_at"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if context.is_offline_mode():
|
||||
op.drop_index(_INDEX_NAME, table_name="active_discounts")
|
||||
op.drop_column("active_discounts", "expires_at")
|
||||
return
|
||||
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
if not inspector.has_table("active_discounts"):
|
||||
return
|
||||
|
||||
indexes = {index["name"] for index in inspector.get_indexes("active_discounts")}
|
||||
if _INDEX_NAME in indexes:
|
||||
op.drop_index(_INDEX_NAME, table_name="active_discounts")
|
||||
|
||||
columns = {column["name"] for column in inspector.get_columns("active_discounts")}
|
||||
if "expires_at" in columns:
|
||||
op.drop_column("active_discounts", "expires_at")
|
||||
@@ -0,0 +1,95 @@
|
||||
"""harden promo current activations
|
||||
|
||||
Revision ID: 0003_promo_current_activations_not_null
|
||||
Revises: 0002_active_discount_expires_at
|
||||
Create Date: 2026-02-11 00:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op, context
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "0003_promo_curr_act_not_null"
|
||||
down_revision: Union[str, Sequence[str],
|
||||
None] = "0002_active_discount_expires_at"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if context.is_offline_mode():
|
||||
op.execute(
|
||||
sa.text(
|
||||
"UPDATE promo_codes SET current_activations = 0 "
|
||||
"WHERE current_activations IS NULL"
|
||||
)
|
||||
)
|
||||
op.alter_column(
|
||||
"promo_codes",
|
||||
"current_activations",
|
||||
existing_type=sa.Integer(),
|
||||
nullable=False,
|
||||
server_default=sa.text("0"),
|
||||
)
|
||||
return
|
||||
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
if not inspector.has_table("promo_codes"):
|
||||
return
|
||||
|
||||
promo_columns = {column["name"]
|
||||
for column in inspector.get_columns("promo_codes")}
|
||||
if "current_activations" not in promo_columns:
|
||||
op.add_column(
|
||||
"promo_codes",
|
||||
sa.Column("current_activations", sa.Integer(),
|
||||
nullable=False, server_default=sa.text("0")),
|
||||
)
|
||||
return
|
||||
|
||||
op.execute(
|
||||
sa.text(
|
||||
"UPDATE promo_codes SET current_activations = 0 "
|
||||
"WHERE current_activations IS NULL"
|
||||
)
|
||||
)
|
||||
op.alter_column(
|
||||
"promo_codes",
|
||||
"current_activations",
|
||||
existing_type=sa.Integer(),
|
||||
nullable=False,
|
||||
server_default=sa.text("0"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if context.is_offline_mode():
|
||||
op.alter_column(
|
||||
"promo_codes",
|
||||
"current_activations",
|
||||
existing_type=sa.Integer(),
|
||||
nullable=True,
|
||||
server_default=None,
|
||||
)
|
||||
return
|
||||
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
if not inspector.has_table("promo_codes"):
|
||||
return
|
||||
|
||||
promo_columns = {column["name"]
|
||||
for column in inspector.get_columns("promo_codes")}
|
||||
if "current_activations" in promo_columns:
|
||||
op.alter_column(
|
||||
"promo_codes",
|
||||
"current_activations",
|
||||
existing_type=sa.Integer(),
|
||||
nullable=True,
|
||||
server_default=None,
|
||||
)
|
||||
@@ -1,3 +1,4 @@
|
||||
import logging
|
||||
from aiogram import Bot
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
@@ -81,12 +82,14 @@ def build_core_services(
|
||||
|
||||
# Wire services that depend on each other
|
||||
try:
|
||||
# Allow subscription service to consume promo codes
|
||||
setattr(subscription_service, "promo_code_service", promo_code_service)
|
||||
# Attach YooKassa to subscription service for auto-renew charges
|
||||
setattr(subscription_service, "yookassa_service", yookassa_service)
|
||||
# Allow panel webhook to trigger renewals through subscription service
|
||||
setattr(panel_webhook_service, "subscription_service", subscription_service)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/app/factories/build_services.py: %s", exc)
|
||||
|
||||
return {
|
||||
"panel_service": panel_service,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -1,296 +0,0 @@
|
||||
<!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="#05070a">
|
||||
<link id="app-favicon" rel="icon" href="data:," sizes="any">
|
||||
<title>Моя подписка</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=JetBrains+Mono:wght@400;500;600;700&family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
@font-face {
|
||||
font-family: 'Twemoji Country Flags';
|
||||
unicode-range: U+1F1E6-1F1FF, U+1F3F4, U+E0062-E0063, U+E0065, U+E0067, U+E006C, U+E006E, U+E0073-E0074, U+E0077, U+E007F;
|
||||
src: url('https://cdn.jsdelivr.net/npm/country-flag-emoji-polyfill@0.1/dist/TwemojiCountryFlags.woff2') format('woff2');
|
||||
font-display: swap;
|
||||
}
|
||||
</style>
|
||||
<script src="./telegram-web-app.js"></script>
|
||||
<link rel="stylesheet" href="./subscription_webapp.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="loader" class="app-loader" data-i18n="loading">Загрузка...</div>
|
||||
|
||||
<main id="app" class="app hidden">
|
||||
<header class="app-header">
|
||||
<div class="app-header-title">
|
||||
<div class="brand-logo-shell hidden" data-brand-logo-shell aria-hidden="true">
|
||||
<span class="brand-logo-spinner hidden" data-brand-logo-spinner aria-hidden="true"></span>
|
||||
<img id="brand-logo" class="brand-logo hidden" data-brand-logo alt="" aria-hidden="true">
|
||||
</div>
|
||||
<div id="brand-title" class="brand-title" data-brand-title>Моя подписка</div>
|
||||
</div>
|
||||
<div class="app-header-actions">
|
||||
<div class="lang-menu">
|
||||
<button id="lang-chip" class="lang-chip" type="button" aria-haspopup="true" aria-expanded="false" aria-controls="lang-dropdown" aria-label="Language" data-action="toggle-lang-menu">
|
||||
<span id="lang-chip-flag" class="lang-chip-flag" aria-hidden="true">🇷🇺</span>
|
||||
<span id="lang-chip-label" class="lang-chip-label">RU</span>
|
||||
<svg class="lang-chip-caret" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640" aria-hidden="true">
|
||||
<path d="M297.4 470.6C309.9 483.1 330.2 483.1 342.7 470.6L534.7 278.6C547.2 266.1 547.2 245.8 534.7 233.3C522.2 220.8 501.9 220.8 489.4 233.3L320 402.7L150.6 233.4C138.1 220.9 117.8 220.9 105.3 233.4C92.8 245.9 92.8 266.2 105.3 278.7L297.3 470.7z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div id="lang-dropdown" class="lang-dropdown hidden" role="menu"></div>
|
||||
</div>
|
||||
<div class="user-menu">
|
||||
<button id="user-chip" class="user-chip" type="button" aria-haspopup="true" aria-expanded="false" aria-controls="user-dropdown" data-action="toggle-user-menu">
|
||||
<img id="user-chip-avatar" class="user-chip-avatar" alt="" aria-hidden="true">
|
||||
<span id="user-chip-name" class="user-chip-name">...</span>
|
||||
<svg class="user-chip-caret" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640" aria-hidden="true">
|
||||
<path d="M297.4 470.6C309.9 483.1 330.2 483.1 342.7 470.6L534.7 278.6C547.2 266.1 547.2 245.8 534.7 233.3C522.2 220.8 501.9 220.8 489.4 233.3L320 402.7L150.6 233.4C138.1 220.9 117.8 220.9 105.3 233.4C92.8 245.9 92.8 266.2 105.3 278.7L297.3 470.7z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div id="user-dropdown" class="user-dropdown hidden" role="menu">
|
||||
<div class="user-dropdown-head">
|
||||
<img id="user-dropdown-avatar" class="user-dropdown-avatar" alt="" aria-hidden="true">
|
||||
<div class="user-dropdown-head-text">
|
||||
<div id="user-dropdown-name" class="user-dropdown-name">...</div>
|
||||
<div id="user-dropdown-sub" class="user-dropdown-sub"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="user-dropdown-rows">
|
||||
<div class="user-dropdown-row" role="menuitem">
|
||||
<span class="user-dropdown-row-label" data-i18n="email_label">Email</span>
|
||||
<span id="user-dropdown-email-status" class="user-dropdown-row-status">...</span>
|
||||
</div>
|
||||
<div class="user-dropdown-row" role="menuitem">
|
||||
<span class="user-dropdown-row-label" data-i18n="telegram_label">Telegram</span>
|
||||
<span id="user-dropdown-telegram-status" class="user-dropdown-row-status">...</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="user-dropdown-logout" type="button" role="menuitem" data-action="logout">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640" aria-hidden="true">
|
||||
<path d="M224 160C241.7 160 256 145.7 256 128C256 110.3 241.7 96 224 96L160 96C107 96 64 139 64 192L64 448C64 501 107 544 160 544L224 544C241.7 544 256 529.7 256 512C256 494.3 241.7 480 224 480L160 480C142.3 480 128 465.7 128 448L128 192C128 174.3 142.3 160 160 160L224 160zM566.6 342.6C579.1 330.1 579.1 309.8 566.6 297.3L438.6 169.3C426.1 156.8 405.8 156.8 393.3 169.3C380.8 181.8 380.8 202.1 393.3 214.6L466.7 288L256 288C238.3 288 224 302.3 224 320C224 337.7 238.3 352 256 352L466.7 352L393.3 425.4C380.8 437.9 380.8 458.2 393.3 470.7C405.8 483.2 426.1 483.2 438.6 470.7L566.6 342.7z"/>
|
||||
</svg>
|
||||
<span data-i18n="logout">Выйти</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="panel grid gap-[17px] p-[18px]">
|
||||
<div id="sub-status" class="sub-status">
|
||||
<span class="sub-status-dot" aria-hidden="true"></span>
|
||||
<span id="sub-status-text" class="sub-status-text">...</span>
|
||||
</div>
|
||||
|
||||
<div class="sub-countdown">
|
||||
<div class="sub-countdown-row">
|
||||
<span id="remaining" class="sub-countdown-value">...</span>
|
||||
<span id="end-date-caption" class="sub-countdown-caption"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="traffic-bar" class="traffic-bar" role="progressbar" aria-valuemin="0" aria-valuemax="100">
|
||||
<div id="traffic-bar-fill" class="traffic-bar-fill"></div>
|
||||
<span id="traffic" class="traffic-bar-value">...</span>
|
||||
</div>
|
||||
|
||||
<div id="connect-actions">
|
||||
<button id="connect-btn" class="btn-primary w-full" type="button" data-action="open-connect-link" data-i18n="connect">Подключиться</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-0.5 border-t border-[var(--border)] pt-3.5">
|
||||
<button id="extend-btn" class="btn-ghost w-full" type="button" data-action="toggle-payment-flow" data-i18n="extend_subscription">Продлить подписку/Добавить дни</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="link-panel" class="panel hidden grid gap-3.5 p-[17px]">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<div id="link-panel-title" class="section-title" data-i18n="link_panel_title">Привяжите способ входа</div>
|
||||
<div id="link-panel-caption" class="section-caption" data-i18n="link_panel_caption">Чтобы не терять доступ к кабинету</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="email-link-box" class="hidden grid gap-2">
|
||||
<div class="link-cta-head">
|
||||
<span class="link-cta-title" data-i18n="link_email_title">Привяжите email</span>
|
||||
<span class="link-cta-caption" data-i18n="link_email_caption">Получайте код для входа на почту</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-[minmax(0,1fr)_minmax(120px,auto)] gap-2">
|
||||
<input id="email-link-input" class="input w-full" type="email" autocomplete="email" inputmode="email" placeholder="mail@example.com" data-placeholder-i18n="email_placeholder">
|
||||
<button id="email-link-send-btn" class="btn" type="button" data-action="request-email-link-code" data-i18n="send_code">Отправить код</button>
|
||||
</div>
|
||||
<div id="email-link-code-row" class="hidden grid grid-cols-[minmax(0,1fr)_minmax(120px,auto)] gap-2">
|
||||
<input id="email-link-code-input" class="input input-code w-full" type="text" inputmode="numeric" autocomplete="one-time-code" maxlength="6" placeholder="000000" data-placeholder-i18n="code_placeholder">
|
||||
<button id="email-link-verify-btn" class="btn-primary" type="button" data-action="verify-email-link-code" data-i18n="confirm">Подтвердить</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="telegram-link-box" class="hidden grid gap-2">
|
||||
<div class="link-cta-head">
|
||||
<span class="link-cta-title" data-i18n="link_telegram_title">Привяжите Telegram</span>
|
||||
<span class="link-cta-caption" data-i18n="link_telegram_caption">Входите одним нажатием через Telegram</span>
|
||||
</div>
|
||||
<div id="telegram-link-widget" class="telegram-login-widget" aria-live="polite"></div>
|
||||
<div id="telegram-link-status" class="status-text hidden" aria-live="polite"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid min-w-0 grid-cols-2 gap-2">
|
||||
<button class="btn w-full" type="button" data-action="open-referral-modal" data-i18n="referral_title">Пригласить друга</button>
|
||||
<button class="btn w-full" type="button" data-action="open-promo-modal" data-i18n="promo_title">Промокод</button>
|
||||
</section>
|
||||
|
||||
|
||||
|
||||
|
||||
<a id="support-link" class="hidden mt-0.5 btn" href="#" target="_blank" rel="noopener" data-i18n="support">Поддержка</a>
|
||||
<div id="legal-links-app" data-legal-links class="legal-links hidden">
|
||||
<a class="legal-link max-[560px]:flex-[1_1_100%]" data-legal-key="privacyPolicyUrl" href="#" target="_blank" rel="noopener" data-i18n="privacy_policy">Политика конфиденциальности</a>
|
||||
<a class="legal-link max-[560px]:flex-[1_1_100%]" data-legal-key="userAgreementUrl" href="#" target="_blank" rel="noopener" data-i18n="user_agreement">Пользовательское соглашение</a>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div id="payment-modal" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="payment-title">
|
||||
<button class="modal-backdrop" type="button" data-title-i18n="close_payment" aria-label="Закрыть оплату" data-action="close-payment-flow"></button>
|
||||
<section id="payment-flow" class="panel-modal modal-card grid gap-4 p-4">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<div id="payment-title" class="section-title text-[var(--accent)]" data-i18n="payment_title">Оплата подписки</div>
|
||||
</div>
|
||||
<button class="icon-btn" type="button" data-title-i18n="close_payment" aria-label="Закрыть оплату" data-action="close-payment-flow">×</button>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2.5">
|
||||
<div class="section-label" data-i18n="select_period">Выберите период</div>
|
||||
<div id="plans" class="grid grid-cols-2 gap-2 sm:grid-cols-4"></div>
|
||||
</div>
|
||||
|
||||
<div class="notice grid gap-1.5">
|
||||
<div class="section-label" data-i18n="payment_amount_label">Стоимость выбранного периода</div>
|
||||
<div id="selected-plan-price" class="text-[20px] font-extrabold leading-tight text-[var(--text-primary)]">...</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2.5">
|
||||
<div class="section-label" data-i18n="choose_payment_method">Способ оплаты</div>
|
||||
<div id="payment-methods" class="grid gap-2"></div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div id="promo-modal" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="promo-title">
|
||||
<button class="modal-backdrop" type="button" data-title-i18n="close" aria-label="Закрыть" data-action="close-promo-modal"></button>
|
||||
<section id="promo-panel" class="panel-modal modal-card grid gap-3.5 p-[17px]">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<div id="promo-title" class="section-title text-[var(--accent)]" data-i18n="promo_title">Промокод</div>
|
||||
<div class="section-caption" data-i18n="promo_caption">Введите код, чтобы начислить бонусные дни.</div>
|
||||
</div>
|
||||
<button class="icon-btn" type="button" data-title-i18n="close" aria-label="Закрыть" data-action="close-promo-modal">×</button>
|
||||
</div>
|
||||
<div class="grid grid-cols-[minmax(0,1fr)_minmax(120px,auto)] gap-2">
|
||||
<input id="promo-code-input" class="input input-code w-full" type="text" autocomplete="off" inputmode="text" placeholder="PROMO2026" data-placeholder-i18n="promo_placeholder">
|
||||
<button id="promo-apply-btn" class="btn-primary" type="button" data-action="apply-promo-code" data-i18n="apply_promo">Применить</button>
|
||||
</div>
|
||||
<div id="promo-status" class="status-text hidden" aria-live="polite"></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div id="referral-modal" class="modal modal--page hidden" role="dialog" aria-modal="true" aria-labelledby="referral-title">
|
||||
<button class="modal-backdrop" type="button" data-title-i18n="close" aria-label="Закрыть" data-action="close-referral-modal"></button>
|
||||
<section id="referral-panel" class="panel-modal modal-card grid gap-3.5 p-[17px]"></section>
|
||||
</div>
|
||||
|
||||
<div id="account-merge-modal" class="modal modal--page hidden" role="dialog" aria-modal="true" aria-labelledby="account-merge-title" aria-describedby="account-merge-caption">
|
||||
<button class="modal-backdrop" type="button" data-title-i18n="close" aria-label="Закрыть" data-action="close-account-merge-modal"></button>
|
||||
<section id="account-merge-panel" class="panel-modal modal-card grid gap-3.5 p-[17px]"></section>
|
||||
</div>
|
||||
|
||||
<div id="email-code-modal" class="modal auth-code-modal hidden" role="dialog" aria-modal="true" aria-labelledby="email-code-title" aria-describedby="email-code-caption">
|
||||
<button class="modal-backdrop" type="button" data-title-i18n="close" aria-label="Закрыть" data-action="close-email-login-code-modal"></button>
|
||||
<div class="relative z-[1] grid w-[min(100%,420px)] justify-items-stretch gap-2.5">
|
||||
<section class="panel-modal modal-card grid gap-3.5 p-[18px]">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<div id="email-code-title" class="section-title" data-i18n="email_code_title">Подтвердите вход</div>
|
||||
<div id="email-code-caption" class="section-caption" data-i18n="email_code_caption">Введите 6-значный код из письма.</div>
|
||||
</div>
|
||||
<button class="icon-btn" type="button" data-title-i18n="close" aria-label="Закрыть" data-action="close-email-login-code-modal">×</button>
|
||||
</div>
|
||||
|
||||
<div class="metric rounded-[var(--radius-md)] grid-cols-[minmax(0,0.6fr)_minmax(0,1.4fr)]">
|
||||
<div class="metric-label" data-i18n="email_label">Email</div>
|
||||
<div id="email-code-address" class="metric-value">...</div>
|
||||
</div>
|
||||
|
||||
<label class="relative block min-h-14 cursor-text">
|
||||
<input id="email-login-code-input" class="otp-input" type="text" inputmode="numeric" autocomplete="one-time-code" maxlength="6" pattern="[0-9]*" data-aria-i18n="email_code_aria" aria-label="Код подтверждения">
|
||||
<span class="otp-slots" aria-hidden="true">
|
||||
<span id="email-code-slot-0"></span>
|
||||
<span id="email-code-slot-1"></span>
|
||||
<span id="email-code-slot-2"></span>
|
||||
<span id="email-code-slot-3"></span>
|
||||
<span id="email-code-slot-4"></span>
|
||||
<span id="email-code-slot-5"></span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div id="email-code-status" class="status-text hidden" aria-live="polite"></div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<button id="email-login-verify-btn" class="btn-primary w-full" type="button" data-action="verify-email-login-code" data-i18n="login">Войти</button>
|
||||
</div>
|
||||
</section>
|
||||
<button id="email-login-resend-btn" class="code-modal-resend" type="button" data-action="resend-email-login-code" data-i18n="resend_code">Отправить еще раз</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section id="login" class="login login-layout gap-5">
|
||||
<div class="grid justify-items-center gap-3 text-center">
|
||||
<div class="brand-logo-shell brand-logo-shell--lg hidden" data-brand-logo-shell aria-hidden="true">
|
||||
<span class="brand-logo-spinner hidden" data-brand-logo-spinner aria-hidden="true"></span>
|
||||
<img id="login-brand-logo" class="brand-logo hidden" data-brand-logo alt="" aria-hidden="true">
|
||||
</div>
|
||||
<div id="login-brand-title" class="max-w-[320px] break-words font-[family-name:var(--font-mono)] text-[28px] font-extrabold leading-[1.05] tracking-[0] text-[var(--accent)] max-[460px]:max-w-[240px] max-[460px]:text-[24px]" data-brand-title>Моя подписка</div>
|
||||
</div>
|
||||
<div class="panel-modal w-full grid gap-5 p-6 text-center">
|
||||
<h1 class="section-title text-[22px] leading-[1.1]" data-i18n="login_title">Войдите или зарегистрируйтесь</h1>
|
||||
<div class="grid gap-2.5">
|
||||
<div class="auth-tabs-wrapper" role="tablist">
|
||||
<button id="email-auth-tab" class="auth-tab active" type="button" data-action="set-auth-mode" data-mode="email" data-i18n="email_login_tab">Email</button>
|
||||
<button id="telegram-auth-tab" class="auth-tab" type="button" data-action="set-auth-mode" data-mode="telegram" data-i18n="telegram_login_tab">Telegram</button>
|
||||
</div>
|
||||
<div class="login-auth-body">
|
||||
<div id="email-login-pane" class="grid gap-2.5">
|
||||
<div class="grid gap-2.5">
|
||||
<input id="email-login-input" class="input w-full" type="email" autocomplete="email" inputmode="email" placeholder="mail@example.com" data-placeholder-i18n="email_placeholder">
|
||||
<button id="email-login-send-btn" class="btn-primary w-full" type="button" data-action="request-email-login-code" data-i18n="login_continue">Продолжить</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="telegram-login-pane" class="hidden grid gap-2.5">
|
||||
<div id="telegram-login-widget" class="telegram-login-widget" aria-live="polite"></div>
|
||||
</div>
|
||||
<div id="auth-status" class="status-text hidden" aria-live="polite"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="legal-links-login" data-legal-links class="legal-links hidden">
|
||||
<a class="legal-link max-[560px]:flex-[1_1_100%]" data-legal-key="privacyPolicyUrl" href="#" target="_blank" rel="noopener" data-i18n="privacy_policy">Политика конфиденциальности</a>
|
||||
<a class="legal-link max-[560px]:flex-[1_1_100%]" data-legal-key="userAgreementUrl" href="#" target="_blank" rel="noopener" data-i18n="user_agreement">Пользовательское соглашение</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="toast" class="toast hidden" role="status" aria-live="polite"></div>
|
||||
|
||||
<!-- WEBAPP_I18N_SCRIPT -->
|
||||
<!-- WEBAPP_CONFIG_SCRIPT -->
|
||||
<!-- WEBAPP_JS_SCRIPT -->
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,907 +0,0 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@source "./subscription_webapp.html";
|
||||
@source "./subscription_webapp.js";
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg-primary: #05070a;
|
||||
--bg-card: #0f1521;
|
||||
--bg-card-hover: #161e2e;
|
||||
--border: #1e2940;
|
||||
--text-primary: #e2e8f0;
|
||||
--text-secondary: #94a3b8;
|
||||
--text-muted: #64748b;
|
||||
--accent: #00fe7a;
|
||||
--accent-hover: #5aff9f;
|
||||
--success: #10b981;
|
||||
--danger: #ef4444;
|
||||
--font-sans: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
--font-mono: "JetBrains Mono", "Fira Code", monospace;
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 8px;
|
||||
--transition: 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--app-safe-top-extra: 10px;
|
||||
--app-safe-top: max(var(--tg-content-safe-area-inset-top, 0px), var(--tg-safe-area-inset-top, 0px), env(safe-area-inset-top));
|
||||
--app-safe-bottom: max(var(--tg-content-safe-area-inset-bottom, 0px), var(--tg-safe-area-inset-bottom, 0px), env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
@layer base {
|
||||
html,
|
||||
body {
|
||||
@apply m-0 min-h-full w-full overflow-x-hidden tracking-[0];
|
||||
}
|
||||
|
||||
body {
|
||||
@apply overflow-x-hidden bg-[var(--bg-primary)] font-[family-name:var(--font-sans)] text-[var(--text-primary)] antialiased;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
body.modal-open {
|
||||
@apply overflow-hidden;
|
||||
}
|
||||
|
||||
button,
|
||||
a {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
@apply cursor-pointer border-0;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
@apply cursor-progress opacity-[0.58];
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
|
||||
.btn-sm {
|
||||
@apply inline-flex min-h-[38px] min-w-0 cursor-pointer items-center justify-center gap-2 rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.03)] px-[11px] py-2 text-center font-extrabold text-[13px] leading-none text-[var(--text-secondary)] no-underline transition-[transform,border-color,background,color,box-shadow] duration-200 ease-[cubic-bezier(0.4,0,0.2,1)] hover:-translate-y-0.5 hover:border-[color-mix(in_srgb,var(--accent)_42%,var(--border))] hover:bg-[var(--bg-card-hover)];
|
||||
}
|
||||
.metrics {
|
||||
@apply grid gap-0 overflow-hidden rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.018)];
|
||||
}
|
||||
.metric {
|
||||
@apply grid min-h-12 grid-cols-[minmax(0,0.88fr)_minmax(0,1.12fr)] items-center gap-2.5 border border-[var(--border)] bg-[rgba(255,255,255,0.02)] px-3 py-2.5;
|
||||
}
|
||||
.auth-tab {
|
||||
@apply min-h-9 rounded-[var(--radius-sm)] bg-transparent font-[850] text-[var(--text-secondary)] transition-colors;
|
||||
}
|
||||
.login-layout {
|
||||
@apply hidden mx-auto min-h-screen w-[min(100vw,560px)] flex-col items-center justify-center gap-4 px-[14px];
|
||||
padding-top: calc(max(var(--app-safe-top), 14px) + var(--app-safe-top-extra));
|
||||
padding-bottom: max(var(--app-safe-bottom), 18px);
|
||||
}
|
||||
.section-label {
|
||||
@apply font-[family-name:var(--font-mono)] text-[11px] font-extrabold text-[var(--text-muted)];
|
||||
}
|
||||
.app-loader {
|
||||
@apply grid min-h-[78vh] place-items-center font-[family-name:var(--font-mono)] font-extrabold text-[var(--text-muted)];
|
||||
}
|
||||
.auth-tabs-wrapper {
|
||||
@apply grid min-h-[46px] grid-cols-2 gap-1 rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(5,8,13,0.38)] p-1;
|
||||
}
|
||||
|
||||
|
||||
.panel-head {
|
||||
@apply flex items-start justify-between gap-3;
|
||||
}
|
||||
.section-title {
|
||||
@apply text-lg font-extrabold leading-tight text-[var(--text-primary)];
|
||||
}
|
||||
.section-caption {
|
||||
@apply mt-[3px] text-[13px] text-[var(--text-secondary)];
|
||||
}
|
||||
.metric-label {
|
||||
@apply min-w-0 font-[family-name:var(--font-mono)] text-[11px] font-bold text-[var(--text-muted)];
|
||||
}
|
||||
.metric-value {
|
||||
@apply min-w-0 text-right text-sm font-bold text-[var(--text-primary)];
|
||||
}
|
||||
.referral-link-row {
|
||||
@apply grid min-h-[58px] grid-cols-[minmax(0,1fr)_48px] items-center gap-2.5 rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.02)] px-3 py-[11px];
|
||||
}
|
||||
.referral-link-value {
|
||||
@apply mt-1 font-[family-name:var(--font-mono)] text-xs font-bold leading-[1.35] text-[var(--text-primary)];
|
||||
}
|
||||
.bonus-row {
|
||||
@apply grid min-h-[58px] grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)] items-center gap-2.5 rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.02)] px-3 py-[11px];
|
||||
}
|
||||
.empty-state {
|
||||
@apply rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.02)] p-[13px] text-sm leading-[1.45] text-[var(--text-secondary)];
|
||||
}
|
||||
.plan-card {
|
||||
@apply inline-flex min-h-[44px] w-full min-w-0 items-center justify-center rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.02)] px-3 py-2 text-center font-extrabold text-[14px] leading-tight text-[var(--text-primary)] transition-[transform,border-color,background,box-shadow] hover:-translate-y-0.5 hover:border-[color-mix(in_srgb,var(--accent)_42%,var(--border))] hover:bg-[var(--bg-card-hover)];
|
||||
transition-duration: 200ms;
|
||||
}
|
||||
.plan-card-active {
|
||||
@apply border-[var(--accent)] bg-[color-mix(in_srgb,var(--accent)_8%,transparent)] text-[var(--accent)] ring-1 ring-[color-mix(in_srgb,var(--accent)_58%,transparent)] shadow-[0_12px_30px_rgba(0,0,0,0.2)];
|
||||
}
|
||||
.payment-method-card {
|
||||
@apply inline-flex min-h-[52px] w-full min-w-0 items-center justify-center rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.03)] px-4 py-[12px] text-center font-extrabold text-[15px] leading-tight text-[var(--text-primary)] transition-[transform,border-color,background,box-shadow] hover:-translate-y-0.5 hover:border-[color-mix(in_srgb,var(--accent)_42%,var(--border))] hover:bg-[var(--bg-card-hover)];
|
||||
transition-duration: 200ms;
|
||||
}
|
||||
.payment-method-card--platega {
|
||||
border-color: rgba(0, 254, 122, 0.36);
|
||||
background: rgba(0, 254, 122, 0.08);
|
||||
}
|
||||
.payment-method-card--cryptopay {
|
||||
border-color: rgba(34, 211, 238, 0.36);
|
||||
background: rgba(34, 211, 238, 0.08);
|
||||
}
|
||||
.plan-name {
|
||||
@apply block text-[15px] font-extrabold leading-tight;
|
||||
}
|
||||
.plan-meta {
|
||||
@apply mt-1 block font-[family-name:var(--font-mono)] text-[11px] font-bold leading-[1.3] text-[var(--text-muted)];
|
||||
}
|
||||
.plan-price {
|
||||
@apply block max-w-[48%] flex-none text-right font-[family-name:var(--font-mono)] text-[15px] font-extrabold leading-tight text-[var(--accent)];
|
||||
}
|
||||
.notice {
|
||||
@apply rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.02)] p-[13px] text-sm leading-[1.45] text-[var(--text-secondary)];
|
||||
}
|
||||
.step-num {
|
||||
@apply block font-[family-name:var(--font-mono)] text-[11px] font-extrabold leading-[1.1] text-current;
|
||||
}
|
||||
.step-name {
|
||||
@apply mt-1 block text-xs font-extrabold leading-[1.15] text-current;
|
||||
}
|
||||
.main-value {
|
||||
@apply m-0 text-[32px] font-extrabold leading-[1.08] text-[var(--text-primary)];
|
||||
}
|
||||
.main-caption {
|
||||
@apply mt-1.5 mb-0 text-sm leading-[1.45] text-[var(--text-secondary)];
|
||||
}
|
||||
.status-text {
|
||||
@apply m-0 min-h-5 text-sm leading-[1.45] text-[var(--text-secondary)];
|
||||
}
|
||||
.legal-links {
|
||||
@apply flex flex-wrap justify-center gap-x-4 gap-y-1.5 text-center;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
@apply flex min-h-12 items-center justify-between gap-3;
|
||||
}
|
||||
.app-header-title {
|
||||
@apply flex min-w-0 items-center gap-2.5;
|
||||
}
|
||||
.app-header-actions {
|
||||
@apply flex min-w-0 items-center gap-2;
|
||||
}
|
||||
.panel-head-sm {
|
||||
@apply flex min-w-0 items-center justify-between gap-2.5;
|
||||
}
|
||||
|
||||
.user-menu {
|
||||
@apply relative inline-flex;
|
||||
}
|
||||
|
||||
.lang-menu {
|
||||
@apply relative inline-flex;
|
||||
}
|
||||
|
||||
.lang-chip {
|
||||
@apply inline-flex min-h-[38px] cursor-pointer items-center gap-1.5 rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.03)] px-2 text-left font-extrabold text-[12px] text-[var(--text-primary)] no-underline transition-[transform,border-color,background,color,box-shadow] duration-200 ease-[cubic-bezier(0.4,0,0.2,1)] hover:border-[color-mix(in_srgb,var(--accent)_42%,var(--border))] hover:bg-[var(--bg-card-hover)];
|
||||
}
|
||||
|
||||
.lang-chip[aria-expanded="true"] {
|
||||
@apply border-[color-mix(in_srgb,var(--accent)_55%,var(--border))] bg-[var(--bg-card-hover)];
|
||||
}
|
||||
|
||||
.lang-chip-flag {
|
||||
@apply text-[14px] leading-none;
|
||||
font-family: 'Twemoji Country Flags', 'Apple Color Emoji', 'Segoe UI Emoji', 'Noto Color Emoji', sans-serif;
|
||||
}
|
||||
|
||||
.lang-chip-label {
|
||||
@apply font-[family-name:var(--font-mono)] text-[11px] tracking-wide;
|
||||
}
|
||||
|
||||
.lang-chip-caret {
|
||||
@apply h-3 w-3 flex-none fill-[var(--text-secondary)] transition-transform;
|
||||
}
|
||||
|
||||
.lang-chip[aria-expanded="true"] .lang-chip-caret {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.lang-dropdown {
|
||||
@apply absolute right-0 top-[calc(100%+8px)] z-30 grid w-[160px] origin-top-right gap-0 rounded-[var(--radius-md)] border border-[var(--border)] bg-[var(--bg-card)] opacity-0 shadow-[0_24px_48px_rgba(0,0,0,0.45)] transition-[opacity,transform] duration-200 ease-[cubic-bezier(0.2,0.8,0.2,1)] pointer-events-none -translate-y-2 scale-[0.96];
|
||||
backdrop-filter: blur(14px);
|
||||
will-change: opacity, transform;
|
||||
}
|
||||
|
||||
.lang-dropdown.show {
|
||||
@apply opacity-100 pointer-events-auto translate-y-0 scale-100;
|
||||
}
|
||||
|
||||
.lang-dropdown-item {
|
||||
@apply flex cursor-pointer items-center gap-2.5 border-0 bg-transparent px-3 py-2.5 text-left text-[13px] font-bold text-[var(--text-secondary)] transition-colors hover:bg-[var(--bg-card-hover)] hover:text-[var(--text-primary)];
|
||||
}
|
||||
|
||||
.lang-dropdown-item + .lang-dropdown-item {
|
||||
@apply border-t border-[var(--border)];
|
||||
}
|
||||
|
||||
.lang-dropdown-item.is-active {
|
||||
@apply bg-[var(--bg-card-hover)] text-[var(--text-primary)];
|
||||
}
|
||||
|
||||
.lang-dropdown-flag {
|
||||
@apply text-[16px] leading-none;
|
||||
font-family: 'Twemoji Country Flags', 'Apple Color Emoji', 'Segoe UI Emoji', 'Noto Color Emoji', sans-serif;
|
||||
}
|
||||
|
||||
.lang-dropdown-label {
|
||||
@apply flex-1 overflow-hidden text-ellipsis whitespace-nowrap;
|
||||
}
|
||||
|
||||
.user-chip {
|
||||
@apply inline-flex min-h-[38px] min-w-0 max-w-[200px] cursor-pointer items-center gap-2 rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.03)] py-1 pl-1 pr-2.5 text-left font-extrabold text-[13px] text-[var(--text-primary)] no-underline transition-[transform,border-color,background,color,box-shadow] duration-200 ease-[cubic-bezier(0.4,0,0.2,1)] hover:border-[color-mix(in_srgb,var(--accent)_42%,var(--border))] hover:bg-[var(--bg-card-hover)];
|
||||
}
|
||||
|
||||
.user-chip[aria-expanded="true"] {
|
||||
@apply border-[color-mix(in_srgb,var(--accent)_55%,var(--border))] bg-[var(--bg-card-hover)];
|
||||
}
|
||||
|
||||
.user-chip-avatar {
|
||||
@apply h-[28px] w-[28px] flex-none rounded-[var(--radius-sm)] object-cover;
|
||||
background: color-mix(in srgb, var(--accent) 10%, var(--bg-card));
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.user-chip-name {
|
||||
@apply min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap;
|
||||
}
|
||||
|
||||
.user-chip-caret {
|
||||
@apply h-3 w-3 flex-none fill-[var(--text-secondary)] transition-transform;
|
||||
}
|
||||
|
||||
.user-chip[aria-expanded="true"] .user-chip-caret {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.user-dropdown {
|
||||
@apply absolute right-0 top-[calc(100%+8px)] z-30 grid w-[min(calc(100vw-28px),280px)] origin-top-right gap-0 rounded-[var(--radius-md)] border border-[var(--border)] bg-[var(--bg-card)] opacity-0 shadow-[0_24px_48px_rgba(0,0,0,0.45)] transition-[opacity,transform] duration-200 ease-[cubic-bezier(0.2,0.8,0.2,1)] pointer-events-none -translate-y-2 scale-[0.96];
|
||||
backdrop-filter: blur(14px);
|
||||
will-change: opacity, transform;
|
||||
}
|
||||
|
||||
.user-dropdown.show {
|
||||
@apply opacity-100 pointer-events-auto translate-y-0 scale-100;
|
||||
}
|
||||
|
||||
.user-dropdown-head {
|
||||
@apply flex items-center gap-2.5 border-b border-[var(--border)] px-3 py-2.5;
|
||||
}
|
||||
|
||||
.user-dropdown-avatar {
|
||||
@apply h-[40px] w-[40px] flex-none rounded-[var(--radius-md)] object-cover;
|
||||
background: color-mix(in srgb, var(--accent) 10%, var(--bg-card));
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.user-dropdown-head-text {
|
||||
@apply grid min-w-0 gap-0.5;
|
||||
}
|
||||
|
||||
.user-dropdown-name {
|
||||
@apply overflow-hidden text-ellipsis whitespace-nowrap text-sm font-extrabold text-[var(--text-primary)];
|
||||
}
|
||||
|
||||
.user-dropdown-sub {
|
||||
@apply overflow-hidden text-ellipsis whitespace-nowrap font-[family-name:var(--font-mono)] text-[11px] text-[var(--text-muted)];
|
||||
}
|
||||
|
||||
.user-dropdown-rows {
|
||||
@apply grid;
|
||||
}
|
||||
|
||||
.user-dropdown-row {
|
||||
@apply flex items-center justify-between gap-3 px-3 py-2.5;
|
||||
}
|
||||
|
||||
.user-dropdown-row + .user-dropdown-row {
|
||||
@apply border-t border-[var(--border)];
|
||||
}
|
||||
|
||||
.user-dropdown-row-label {
|
||||
@apply font-[family-name:var(--font-mono)] text-[11px] font-bold text-[var(--text-muted)];
|
||||
}
|
||||
|
||||
.user-dropdown-row-status {
|
||||
@apply overflow-hidden text-ellipsis whitespace-nowrap text-right text-[13px] font-bold text-[var(--text-primary)];
|
||||
}
|
||||
|
||||
.user-dropdown-row-status.linked {
|
||||
@apply text-[#6ee7b7];
|
||||
}
|
||||
|
||||
.user-dropdown-row-status.unlinked {
|
||||
@apply text-[var(--text-muted)];
|
||||
}
|
||||
|
||||
.user-dropdown-logout {
|
||||
@apply flex min-h-[42px] w-full cursor-pointer items-center justify-center gap-2 rounded-none border-0 border-t border-[var(--border)] bg-transparent px-3 py-2.5 text-center text-[13px] font-extrabold text-[var(--text-secondary)] transition-colors hover:bg-[var(--bg-card-hover)] hover:text-[var(--text-primary)];
|
||||
}
|
||||
|
||||
.user-dropdown-logout svg {
|
||||
@apply h-4 w-4 fill-current;
|
||||
}
|
||||
|
||||
.sub-status {
|
||||
@apply flex min-h-[28px] items-center gap-2.5 text-[var(--danger)];
|
||||
}
|
||||
|
||||
.sub-status.active {
|
||||
@apply text-[var(--success)];
|
||||
}
|
||||
|
||||
.sub-status-dot {
|
||||
@apply relative inline-block h-[10px] w-[10px] flex-none rounded-full;
|
||||
background: currentColor;
|
||||
box-shadow: 0 0 12px currentColor;
|
||||
}
|
||||
|
||||
.sub-status-dot::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 9999px;
|
||||
background: currentColor;
|
||||
opacity: 0.55;
|
||||
animation: sub-status-pulse 1.8s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
.sub-status-text {
|
||||
@apply font-extrabold text-[15px] leading-tight;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
@keyframes brand-logo-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.sub-countdown {
|
||||
@apply grid gap-1;
|
||||
}
|
||||
|
||||
.sub-countdown-row {
|
||||
@apply flex flex-wrap items-baseline gap-x-2 gap-y-0.5;
|
||||
}
|
||||
|
||||
.sub-countdown-value {
|
||||
@apply font-extrabold text-[22px] leading-[1.1] text-[var(--text-primary)];
|
||||
}
|
||||
|
||||
.sub-countdown-caption {
|
||||
@apply font-[family-name:var(--font-mono)] text-[12px] leading-[1.3] text-[var(--text-muted)];
|
||||
}
|
||||
|
||||
.traffic-bar {
|
||||
@apply relative flex min-h-[24px] w-full items-center justify-center overflow-hidden rounded-full border border-[var(--border)];
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.traffic-bar-fill {
|
||||
@apply absolute inset-y-0 left-0;
|
||||
width: 0%;
|
||||
background: color-mix(in srgb, var(--accent) 28%, transparent);
|
||||
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1), background 0.2s ease;
|
||||
}
|
||||
|
||||
.traffic-bar.over .traffic-bar-fill {
|
||||
background: color-mix(in srgb, var(--danger) 32%, transparent);
|
||||
}
|
||||
|
||||
.traffic-bar.unlimited .traffic-bar-fill {
|
||||
width: 100%;
|
||||
background: color-mix(in srgb, var(--success) 12%, transparent);
|
||||
}
|
||||
|
||||
.traffic-bar-value {
|
||||
@apply relative z-[1] min-w-0 px-3 font-[family-name:var(--font-mono)] text-[12px] font-bold text-center whitespace-nowrap;
|
||||
color: #ffffff;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.traffic-bar.unlimited .traffic-bar-value {
|
||||
animation: traffic-infinity-pulse 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes traffic-infinity-pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
text-shadow: 0 0 0 color-mix(in srgb, var(--success) 0%, transparent);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
text-shadow: 0 0 10px color-mix(in srgb, var(--success) 70%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.link-cta-head {
|
||||
@apply grid gap-1;
|
||||
}
|
||||
|
||||
.link-cta-title {
|
||||
@apply font-extrabold text-[15px] leading-tight text-[var(--text-primary)];
|
||||
}
|
||||
|
||||
.link-cta-caption {
|
||||
@apply text-[13px] leading-[1.4] text-[var(--text-secondary)];
|
||||
}
|
||||
|
||||
@keyframes sub-status-pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 0.55;
|
||||
}
|
||||
70% {
|
||||
transform: scale(2.2);
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
transform: scale(2.2);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
.login-head {
|
||||
@apply flex min-w-0 items-center justify-center gap-3.5;
|
||||
}
|
||||
|
||||
.btn {
|
||||
@apply inline-flex min-h-[46px] min-w-0 cursor-pointer items-center justify-center gap-2 rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.03)] px-[13px] py-[11px] text-center font-extrabold text-[var(--text-primary)] no-underline transition-[transform,border-color,background,color,box-shadow] duration-200 ease-[cubic-bezier(0.4,0,0.2,1)] hover:-translate-y-0.5 hover:border-[color-mix(in_srgb,var(--accent)_42%,var(--border))] hover:bg-[var(--bg-card-hover)];
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply inline-flex min-h-[46px] min-w-0 cursor-pointer items-center justify-center gap-2 rounded-[var(--radius-md)] border border-[color-mix(in_srgb,var(--accent)_80%,var(--border))] bg-[var(--accent)] px-[13px] py-[11px] text-center font-extrabold text-[#04110a] no-underline shadow-[0_10px_26px_color-mix(in_srgb,var(--accent)_18%,transparent)] transition-[transform,border-color,background,color,box-shadow] duration-200 ease-[cubic-bezier(0.4,0,0.2,1)] hover:-translate-y-0.5 hover:bg-[var(--accent-hover)] hover:shadow-[0_14px_34px_color-mix(in_srgb,var(--accent)_24%,transparent)];
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
@apply inline-flex min-h-[46px] min-w-0 w-full cursor-pointer items-center justify-center gap-2 rounded-[var(--radius-md)] border border-[color-mix(in_srgb,var(--accent)_35%,var(--border))] bg-[color-mix(in_srgb,var(--accent)_5%,transparent)] px-[13px] py-[11px] text-center font-extrabold text-[var(--accent)] no-underline shadow-none transition-[transform,border-color,background,color,box-shadow] duration-200 ease-[cubic-bezier(0.4,0,0.2,1)] hover:-translate-y-0.5 hover:bg-[color-mix(in_srgb,var(--accent)_10%,transparent)] hover:shadow-[0_10px_24px_color-mix(in_srgb,var(--accent)_10%,transparent)];
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
@apply inline-flex h-[38px] w-[38px] flex-none cursor-pointer items-center justify-center gap-2 rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.03)] text-[var(--text-secondary)] transition-[transform,border-color,background,color,box-shadow] duration-200 ease-[cubic-bezier(0.4,0,0.2,1)] hover:-translate-y-0.5 hover:border-[color-mix(in_srgb,var(--accent)_42%,var(--border))] hover:bg-[var(--bg-card-hover)];
|
||||
}
|
||||
|
||||
.input {
|
||||
@apply min-h-[46px] w-full rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(5,8,13,0.42)] px-3 py-[11px] font-bold text-[var(--text-primary)] outline-none transition-[border-color,background,box-shadow] duration-200 ease-[cubic-bezier(0.4,0,0.2,1)] placeholder:text-[var(--text-muted)] focus:border-[color-mix(in_srgb,var(--accent)_55%,var(--border))] focus:bg-[rgba(7,12,19,0.68)] focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--accent)_12%,transparent),0_12px_28px_rgba(0,0,0,0.2)] aria-[invalid=true]:border-[color-mix(in_srgb,var(--danger)_72%,var(--border))] aria-[invalid=true]:focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--danger)_14%,transparent)];
|
||||
}
|
||||
|
||||
.input-code {
|
||||
@apply text-center font-[family-name:var(--font-mono)] tracking-[0];
|
||||
}
|
||||
|
||||
.panel {
|
||||
@apply relative overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border)] bg-[var(--bg-card)] shadow-[0_20px_40px_rgba(0,0,0,0.18)];
|
||||
}
|
||||
|
||||
.panel-modal {
|
||||
@apply relative overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border)] bg-[var(--bg-card)] shadow-[0_30px_60px_rgba(0,0,0,0.28)];
|
||||
}
|
||||
|
||||
.step-box {
|
||||
@apply min-h-[58px] rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.02)] p-[9px] text-[var(--text-muted)] shadow-[inset_0_1px_0_rgba(255,255,255,0.045)];
|
||||
}
|
||||
|
||||
.modal {
|
||||
@apply fixed inset-0 z-20 grid place-items-center opacity-0 transition-opacity pointer-events-none;
|
||||
padding: calc(max(var(--app-safe-top), 14px) + var(--app-safe-top-extra)) 14px max(var(--app-safe-bottom), 14px);
|
||||
}
|
||||
|
||||
.modal.show {
|
||||
@apply opacity-100 pointer-events-auto;
|
||||
}
|
||||
|
||||
.modal-backdrop,
|
||||
.tools-modal-backdrop {
|
||||
@apply absolute inset-0 border-0 p-0;
|
||||
background: rgba(3, 6, 10, 0.72);
|
||||
backdrop-filter: blur(7px);
|
||||
}
|
||||
|
||||
.tools-modal-backdrop {
|
||||
@apply fixed z-25;
|
||||
}
|
||||
|
||||
.modal-card {
|
||||
@apply relative max-h-[min(86vh,760px)] w-[min(100%,560px)] overflow-auto transition-transform duration-[0.22s] ease-[cubic-bezier(0.2,0.8,0.2,1)];
|
||||
transform: translateY(18px) scale(0.98);
|
||||
}
|
||||
|
||||
.modal.show .modal-card {
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
|
||||
|
||||
.app {
|
||||
@apply mx-auto flex w-[min(100vw,560px)] max-w-[100vw] flex-col gap-4;
|
||||
padding: calc(max(var(--app-safe-top), 14px) + var(--app-safe-top-extra)) 14px max(var(--app-safe-bottom), 22px);
|
||||
}
|
||||
|
||||
@media (min-width: 561px) {
|
||||
.app {
|
||||
@apply min-h-screen justify-center;
|
||||
}
|
||||
}
|
||||
|
||||
.telegram-login-widget {
|
||||
@apply flex min-w-0 items-center;
|
||||
}
|
||||
|
||||
.brand-logo-shell {
|
||||
@apply relative grid h-[38px] w-[38px] flex-none place-items-center overflow-hidden;
|
||||
}
|
||||
|
||||
.brand-logo-shell--lg {
|
||||
@apply h-[96px] w-[96px];
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
@apply absolute inset-0 h-full w-full object-contain;
|
||||
}
|
||||
|
||||
.brand-logo-spinner {
|
||||
@apply h-5 w-5 flex-none rounded-full border-2;
|
||||
border-color: color-mix(in srgb, var(--accent) 18%, var(--border));
|
||||
border-top-color: var(--accent);
|
||||
animation: brand-logo-spin 0.85s linear infinite;
|
||||
}
|
||||
|
||||
.brand-logo--lg {
|
||||
@apply h-[84px] w-[84px];
|
||||
}
|
||||
|
||||
.plans,
|
||||
.methods,
|
||||
.metrics,
|
||||
.referral-link-list,
|
||||
.bonus-list {
|
||||
@apply grid min-w-0;
|
||||
}
|
||||
|
||||
.actions,
|
||||
.otp-slots,
|
||||
.metric {
|
||||
@apply grid min-w-0 gap-2;
|
||||
}
|
||||
|
||||
.actions {
|
||||
grid-template-columns: minmax(0, 1fr) 48px;
|
||||
}
|
||||
|
||||
.metric {
|
||||
grid-template-columns: minmax(0, 0.88fr) minmax(0, 1.12fr);
|
||||
}
|
||||
|
||||
.metrics {
|
||||
@apply gap-0 overflow-hidden rounded-[var(--radius-md)] border border-[var(--border)];
|
||||
background: rgba(255, 255, 255, 0.018);
|
||||
}
|
||||
|
||||
.metric,
|
||||
.notice,
|
||||
.otp-slots span {
|
||||
@apply border border-[var(--border)];
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.metric {
|
||||
@apply min-h-[46px] items-center gap-2.5 px-3 py-2.5;
|
||||
}
|
||||
|
||||
.metric {
|
||||
@apply min-h-12;
|
||||
}
|
||||
|
||||
.metric + .metric {
|
||||
@apply border-t border-[var(--border)];
|
||||
}
|
||||
|
||||
.brand-title,
|
||||
.login-brand-title {
|
||||
@apply max-w-[260px] font-[family-name:var(--font-mono)] font-extrabold tracking-[0] text-[var(--accent)];
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
@apply overflow-hidden text-ellipsis whitespace-nowrap text-[17px] leading-[1.15];
|
||||
text-shadow: 0 0 20px color-mix(in srgb, var(--accent) 18%, transparent);
|
||||
}
|
||||
|
||||
.login-brand-title {
|
||||
@apply text-left text-2xl leading-[1.1];
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.login-auth-body {
|
||||
@apply grid w-full min-h-[140px] gap-2.5 content-start;
|
||||
}
|
||||
|
||||
.login-auth-body--telegram {
|
||||
@apply content-center;
|
||||
}
|
||||
|
||||
.login-text,
|
||||
.promo-status {
|
||||
@apply text-[var(--text-secondary)];
|
||||
}
|
||||
|
||||
.section-label {
|
||||
@apply min-w-0 font-[family-name:var(--font-mono)] text-[11px] font-bold text-[var(--text-muted)];
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
@apply font-extrabold;
|
||||
}
|
||||
|
||||
.mono-pill,
|
||||
.badge {
|
||||
@apply inline-flex min-h-7 items-center rounded-[var(--radius-md)] px-2.5 font-[family-name:var(--font-mono)] text-[11px] font-bold whitespace-nowrap;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.mono-pill {
|
||||
@apply text-[var(--accent)];
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--border));
|
||||
background: color-mix(in srgb, var(--accent) 9%, transparent);
|
||||
}
|
||||
|
||||
.badge {
|
||||
@apply min-w-0 overflow-hidden text-ellipsis text-[#6ee7b7];
|
||||
border: 1px solid color-mix(in srgb, var(--success) 34%, var(--border));
|
||||
background: rgba(16, 185, 129, 0.12);
|
||||
}
|
||||
|
||||
.badge.off,
|
||||
.promo-status.error,
|
||||
.login-status.error {
|
||||
@apply text-[#fca5a5];
|
||||
}
|
||||
|
||||
.badge.off {
|
||||
border-color: color-mix(in srgb, var(--danger) 34%, var(--border));
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
}
|
||||
|
||||
.promo-status.success {
|
||||
@apply text-[#6ee7b7];
|
||||
}
|
||||
|
||||
#legal-links-login {
|
||||
@apply w-[min(100vw,560px)];
|
||||
}
|
||||
|
||||
.legal-link,
|
||||
.code-modal-resend {
|
||||
@apply border-0 bg-transparent p-0 text-[13px] font-bold text-[var(--text-secondary)] underline underline-offset-[3px] transition-colors;
|
||||
text-decoration-color: color-mix(in srgb, var(--accent) 42%, currentColor);
|
||||
}
|
||||
|
||||
.legal-link {
|
||||
@apply inline flex-initial;
|
||||
}
|
||||
|
||||
|
||||
.otp-input {
|
||||
@apply absolute inset-0 z-2 h-full w-full border-0 bg-transparent text-transparent opacity-[0.01] outline-none;
|
||||
caret-color: transparent;
|
||||
}
|
||||
|
||||
.otp-slots {
|
||||
@apply grid-cols-6;
|
||||
}
|
||||
|
||||
.otp-slots span {
|
||||
@apply grid aspect-square min-w-0 place-items-center rounded-[var(--radius-md)] font-[family-name:var(--font-mono)] text-xl font-[850] leading-none text-[var(--text-primary)] transition-[border-color,background,box-shadow];
|
||||
background: rgba(5, 8, 13, 0.42);
|
||||
}
|
||||
|
||||
.otp-slots span.filled {
|
||||
border-color: color-mix(in srgb, var(--accent) 35%, var(--border));
|
||||
background: color-mix(in srgb, var(--accent) 7%, rgba(255, 255, 255, 0.035));
|
||||
}
|
||||
|
||||
.otp-slots span.active {
|
||||
border-color: color-mix(in srgb, var(--accent) 72%, var(--border));
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 12%, transparent);
|
||||
}
|
||||
|
||||
.step-num,
|
||||
.step-name {
|
||||
@apply block text-current;
|
||||
}
|
||||
|
||||
.step-num {
|
||||
@apply font-[family-name:var(--font-mono)] text-[11px] font-extrabold leading-[1.1];
|
||||
}
|
||||
|
||||
.step-name {
|
||||
@apply mt-1 text-xs font-extrabold leading-[1.15];
|
||||
}
|
||||
|
||||
.step.active,
|
||||
.auth-tab.active {
|
||||
@apply border-[var(--accent)] text-[var(--accent)];
|
||||
background: color-mix(in srgb, var(--accent) 9%, transparent);
|
||||
}
|
||||
|
||||
.step.done {
|
||||
@apply text-[var(--text-secondary)];
|
||||
border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
|
||||
}
|
||||
|
||||
.notice {
|
||||
@apply rounded-[var(--radius-md)] p-[13px] text-sm leading-[1.45] text-[var(--text-secondary)];
|
||||
}
|
||||
|
||||
.login-layout.show {
|
||||
@apply flex;
|
||||
}
|
||||
|
||||
.telegram-login-widget {
|
||||
@apply min-h-14 justify-center;
|
||||
}
|
||||
|
||||
.login-text,
|
||||
.promo-status {
|
||||
@apply m-0 text-sm leading-[1.45];
|
||||
}
|
||||
|
||||
.promo-status {
|
||||
@apply mt-0.5 min-h-[22px];
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.login-status {
|
||||
@apply m-0 min-h-5;
|
||||
}
|
||||
|
||||
.toast {
|
||||
@apply fixed left-1/2 z-40 w-max max-w-[calc(100vw-28px)] rounded-[var(--radius-md)] px-[13px] py-[11px] text-[13px] font-bold text-[var(--text-primary)] opacity-0 transition-[opacity,transform] duration-300 ease-[cubic-bezier(0.2,0.8,0.2,1)];
|
||||
bottom: max(var(--app-safe-bottom), 22px);
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--border));
|
||||
background: rgba(15, 21, 33, 0.82);
|
||||
backdrop-filter: blur(14px);
|
||||
box-shadow: 0 16px 38px rgba(0, 0, 0, 0.45), 0 0 15px color-mix(in srgb, var(--accent) 15%, transparent);
|
||||
pointer-events: none;
|
||||
transform: translate(-50%, 14px) scale(0.94);
|
||||
}
|
||||
|
||||
.toast.show {
|
||||
@apply opacity-100;
|
||||
transform: translate(-50%, 0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@supports not (color: color-mix(in srgb, #000 50%, #fff)) {
|
||||
.mono-pill,
|
||||
.step.active,
|
||||
.auth-tab.active {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.mono-pill,
|
||||
.step.active,
|
||||
.auth-tab.active {
|
||||
background: rgba(0, 254, 122, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.legal-link:hover:not(:disabled),
|
||||
.code-modal-resend:hover:not(:disabled) {
|
||||
@apply text-[var(--accent)];
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 460px) {
|
||||
.app {
|
||||
@apply px-2.5;
|
||||
}
|
||||
|
||||
.user-chip {
|
||||
@apply max-w-[150px];
|
||||
}
|
||||
|
||||
.user-chip-name {
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
@apply max-w-[164px];
|
||||
}
|
||||
|
||||
.metric {
|
||||
@apply grid-cols-1;
|
||||
}
|
||||
|
||||
.badge,
|
||||
.mono-pill {
|
||||
@apply max-w-full;
|
||||
}
|
||||
|
||||
.otp-slots {
|
||||
@apply gap-1.5;
|
||||
}
|
||||
|
||||
.otp-slots span {
|
||||
@apply text-lg;
|
||||
}
|
||||
|
||||
.brand-logo--lg {
|
||||
@apply h-[68px] w-[68px];
|
||||
}
|
||||
|
||||
.brand-logo-shell--lg {
|
||||
@apply h-[96px] w-[96px];
|
||||
}
|
||||
|
||||
.login-auth-body {
|
||||
@apply min-h-[128px];
|
||||
}
|
||||
|
||||
.login-brand-title {
|
||||
@apply max-w-[200px] text-[21px];
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 380px) {
|
||||
.brand-title {
|
||||
@apply max-w-[150px];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.step-box {
|
||||
@apply min-h-[54px] px-[7px] py-2;
|
||||
}
|
||||
|
||||
.modal {
|
||||
@apply place-items-center p-0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
padding: calc(max(var(--app-safe-top), 14px) + var(--app-safe-top-extra)) 14px max(var(--app-safe-bottom), 14px);
|
||||
}
|
||||
|
||||
.modal--page {
|
||||
@apply place-items-stretch p-0;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.modal--page .modal-backdrop {
|
||||
@apply bg-[var(--bg-primary)] opacity-100;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
|
||||
.modal-card {
|
||||
@apply h-auto max-h-[min(92vh,760px)] w-full rounded-[var(--radius-lg)];
|
||||
transform: translateY(12px) scale(0.98);
|
||||
}
|
||||
|
||||
.modal--page .modal-card {
|
||||
@apply h-full w-full max-h-none max-w-none rounded-none border-0 shadow-none;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
.modal.show .modal-card {
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,575 +0,0 @@
|
||||
(function(window) {
|
||||
(function(window){
|
||||
window.__parseFunction = function(__func, __attrs) {
|
||||
__attrs = __attrs || [];
|
||||
__func = '(function(' + __attrs.join(',') + '){' + __func + '})';
|
||||
return window.execScript ? window.execScript(__func) : eval(__func);
|
||||
}
|
||||
}(window));
|
||||
(function(window){
|
||||
|
||||
function addEvent(el, event, handler) {
|
||||
var events = event.split(/\s+/);
|
||||
for (var i = 0; i < events.length; i++) {
|
||||
if (el.addEventListener) {
|
||||
el.addEventListener(events[i], handler);
|
||||
} else {
|
||||
el.attachEvent('on' + events[i], handler);
|
||||
}
|
||||
}
|
||||
}
|
||||
function removeEvent(el, event, handler) {
|
||||
var events = event.split(/\s+/);
|
||||
for (var i = 0; i < events.length; i++) {
|
||||
if (el.removeEventListener) {
|
||||
el.removeEventListener(events[i], handler);
|
||||
} else {
|
||||
el.detachEvent('on' + events[i], handler);
|
||||
}
|
||||
}
|
||||
}
|
||||
function getCssProperty(el, prop) {
|
||||
if (window.getComputedStyle) {
|
||||
return window.getComputedStyle(el, '').getPropertyValue(prop) || null;
|
||||
} else if (el.currentStyle) {
|
||||
return el.currentStyle[prop] || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function geById(el_or_id) {
|
||||
if (typeof el_or_id == 'string' || el_or_id instanceof String) {
|
||||
return document.getElementById(el_or_id);
|
||||
} else if (el_or_id instanceof HTMLElement) {
|
||||
return el_or_id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
var getWidgetsOrigin = function(default_origin, dev_origin) {
|
||||
var link = document.createElement('A'), origin;
|
||||
link.href = document.currentScript && document.currentScript.src || default_origin;
|
||||
origin = link.origin || link.protocol + '//' + link.hostname;
|
||||
if (origin == 'https://telegram.org') {
|
||||
origin = default_origin;
|
||||
} else if (origin == 'https://telegram-js.azureedge.net' || origin == 'https://tg.dev') {
|
||||
origin = dev_origin;
|
||||
} else {
|
||||
origin = default_origin;
|
||||
}
|
||||
return origin;
|
||||
};
|
||||
|
||||
var getPageCanonical = function() {
|
||||
var a = document.createElement('A'), link, href;
|
||||
if (document.querySelector) {
|
||||
link = document.querySelector('link[rel="canonical"]');
|
||||
if (link && (href = link.getAttribute('href'))) {
|
||||
a.href = href;
|
||||
return a.href;
|
||||
}
|
||||
} else {
|
||||
var links = document.getElementsByTagName('LINK');
|
||||
for (var i = 0; i < links.length; i++) {
|
||||
if ((link = links[i]) &&
|
||||
(link.getAttribute('rel') == 'canonical') &&
|
||||
(href = link.getAttribute('href'))) {
|
||||
a.href = href;
|
||||
return a.href;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
function haveTgAuthResult() {
|
||||
var locationHash = '', re = /[#\?\&]tgAuthResult=([A-Za-z0-9\-_=]*)$/, match;
|
||||
try {
|
||||
locationHash = location.hash.toString();
|
||||
if (match = locationHash.match(re)) {
|
||||
location.hash = locationHash.replace(re, '');
|
||||
var data = match[1] || '';
|
||||
data = data.replace(/-/g, '+').replace(/_/g, '/');
|
||||
var pad = data.length % 4;
|
||||
if (pad > 1) {
|
||||
data += new Array(5 - pad).join('=');
|
||||
}
|
||||
return JSON.parse(window.atob(data));
|
||||
}
|
||||
} catch (e) {}
|
||||
return false;
|
||||
}
|
||||
|
||||
function getXHR() {
|
||||
if (navigator.appName == "Microsoft Internet Explorer"){
|
||||
return new ActiveXObject("Microsoft.XMLHTTP");
|
||||
} else {
|
||||
return new XMLHttpRequest();
|
||||
}
|
||||
}
|
||||
|
||||
if (!window.Telegram) {
|
||||
window.Telegram = {};
|
||||
}
|
||||
if (!window.Telegram.__WidgetUuid) {
|
||||
window.Telegram.__WidgetUuid = 0;
|
||||
}
|
||||
if (!window.Telegram.__WidgetLastId) {
|
||||
window.Telegram.__WidgetLastId = 0;
|
||||
}
|
||||
if (!window.Telegram.__WidgetCallbacks) {
|
||||
window.Telegram.__WidgetCallbacks = {};
|
||||
}
|
||||
|
||||
function postMessageToIframe(iframe, event, data, callback) {
|
||||
if (!iframe._ready) {
|
||||
if (!iframe._readyQueue) iframe._readyQueue = [];
|
||||
iframe._readyQueue.push([event, data, callback]);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
data = data || {};
|
||||
data.event = event;
|
||||
if (callback) {
|
||||
data._cb = ++window.Telegram.__WidgetLastId;
|
||||
window.Telegram.__WidgetCallbacks[data._cb] = {
|
||||
iframe: iframe,
|
||||
callback: callback
|
||||
};
|
||||
}
|
||||
iframe.contentWindow.postMessage(JSON.stringify(data), '*');
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
function initWidget(widgetEl) {
|
||||
var widgetId, widgetElId, widgetsOrigin, existsEl,
|
||||
src, styles = {}, allowedAttrs = [],
|
||||
defWidth, defHeight, scrollable = false, onInitAuthUser, onAuthUser, onUnauth;
|
||||
if (!widgetEl.tagName ||
|
||||
!(widgetEl.tagName.toUpperCase() == 'SCRIPT' ||
|
||||
widgetEl.tagName.toUpperCase() == 'BLOCKQUOTE' &&
|
||||
widgetEl.classList.contains('telegram-post'))) {
|
||||
return null;
|
||||
}
|
||||
if (widgetEl._iframe) {
|
||||
return widgetEl._iframe;
|
||||
}
|
||||
if (widgetId = widgetEl.getAttribute('data-telegram-post')) {
|
||||
var comment = widgetEl.getAttribute('data-comment') || '';
|
||||
widgetsOrigin = getWidgetsOrigin('https://t.me', 'https://post.tg.dev');
|
||||
widgetElId = 'telegram-post-' + widgetId.replace(/[^a-z0-9_]/ig, '-') + (comment ? '-comment' + comment : '');
|
||||
src = widgetsOrigin + '/' + widgetId + '?embed=1';
|
||||
allowedAttrs = ['comment', 'userpic', 'mode', 'single?', 'color', 'dark', 'dark_color'];
|
||||
defWidth = widgetEl.getAttribute('data-width') || '100%';
|
||||
defHeight = '';
|
||||
styles.minWidth = '320px';
|
||||
}
|
||||
else if (widgetId = widgetEl.getAttribute('data-telegram-discussion')) {
|
||||
widgetsOrigin = getWidgetsOrigin('https://t.me', 'https://post.tg.dev');
|
||||
widgetElId = 'telegram-discussion-' + widgetId.replace(/[^a-z0-9_]/ig, '-') + '-' + (++window.Telegram.__WidgetUuid);
|
||||
var websitePageUrl = widgetEl.getAttribute('data-page-url');
|
||||
if (!websitePageUrl) {
|
||||
websitePageUrl = getPageCanonical();
|
||||
}
|
||||
src = widgetsOrigin + '/' + widgetId + '?embed=1&discussion=1' + (websitePageUrl ? '&page_url=' + encodeURIComponent(websitePageUrl) : '');
|
||||
allowedAttrs = ['comments_limit', 'color', 'colorful', 'dark', 'dark_color', 'width', 'height'];
|
||||
defWidth = widgetEl.getAttribute('data-width') || '100%';
|
||||
defHeight = widgetEl.getAttribute('data-height') || 0;
|
||||
styles.minWidth = '320px';
|
||||
if (defHeight > 0) {
|
||||
scrollable = true;
|
||||
}
|
||||
}
|
||||
else if (widgetEl.hasAttribute('data-telegram-login')) {
|
||||
widgetId = widgetEl.getAttribute('data-telegram-login');
|
||||
widgetsOrigin = getWidgetsOrigin('https://oauth.telegram.org', 'https://oauth.tg.dev');
|
||||
widgetElId = 'telegram-login-' + widgetId.replace(/[^a-z0-9_]/ig, '-');
|
||||
src = widgetsOrigin + '/embed/' + widgetId + '?origin=' + encodeURIComponent(location.origin || location.protocol + '//' + location.hostname) + '&return_to=' + encodeURIComponent(location.href);
|
||||
allowedAttrs = ['size', 'userpic', 'init_auth', 'request_access', 'radius', 'min_width', 'max_width', 'lang'];
|
||||
defWidth = 186;
|
||||
defHeight = 28;
|
||||
if (widgetEl.hasAttribute('data-size')) {
|
||||
var size = widgetEl.getAttribute('data-size');
|
||||
if (size == 'small') defWidth = 148, defHeight = 20;
|
||||
else if (size == 'large') defWidth = 238, defHeight = 40;
|
||||
}
|
||||
if (widgetEl.hasAttribute('data-onauth')) {
|
||||
onInitAuthUser = onAuthUser = __parseFunction(widgetEl.getAttribute('data-onauth'), ['user']);
|
||||
}
|
||||
else if (widgetEl.hasAttribute('data-auth-url')) {
|
||||
var a = document.createElement('A');
|
||||
a.href = widgetEl.getAttribute('data-auth-url');
|
||||
onAuthUser = function(user) {
|
||||
var authUrl = a.href;
|
||||
authUrl += (authUrl.indexOf('?') >= 0) ? '&' : '?';
|
||||
var params = [];
|
||||
for (var key in user) {
|
||||
params.push(key + '=' + encodeURIComponent(user[key]));
|
||||
}
|
||||
authUrl += params.join('&');
|
||||
location.href = authUrl;
|
||||
};
|
||||
}
|
||||
if (widgetEl.hasAttribute('data-onunauth')) {
|
||||
onUnauth = __parseFunction(widgetEl.getAttribute('data-onunauth'));
|
||||
}
|
||||
var auth_result = haveTgAuthResult();
|
||||
if (auth_result && onAuthUser) {
|
||||
onAuthUser(auth_result);
|
||||
}
|
||||
}
|
||||
else if (widgetId = widgetEl.getAttribute('data-telegram-share-url')) {
|
||||
widgetsOrigin = getWidgetsOrigin('https://t.me', 'https://post.tg.dev');
|
||||
widgetElId = 'telegram-share-' + window.btoa(widgetId);
|
||||
src = widgetsOrigin + '/share/embed?origin=' + encodeURIComponent(location.origin || location.protocol + '//' + location.hostname);
|
||||
allowedAttrs = ['telegram-share-url', 'comment', 'size', 'text'];
|
||||
defWidth = 60;
|
||||
defHeight = 20;
|
||||
if (widgetEl.getAttribute('data-size') == 'large') {
|
||||
defWidth = 76;
|
||||
defHeight = 28;
|
||||
}
|
||||
}
|
||||
else {
|
||||
return null;
|
||||
}
|
||||
existsEl = document.getElementById(widgetElId);
|
||||
if (existsEl) {
|
||||
return existsEl;
|
||||
}
|
||||
for (var i = 0; i < allowedAttrs.length; i++) {
|
||||
var attr = allowedAttrs[i];
|
||||
var novalue = attr.substr(-1) == '?';
|
||||
if (novalue) {
|
||||
attr = attr.slice(0, -1);
|
||||
}
|
||||
var data_attr = 'data-' + attr.replace(/_/g, '-');
|
||||
if (widgetEl.hasAttribute(data_attr)) {
|
||||
var attr_value = novalue ? '1' : encodeURIComponent(widgetEl.getAttribute(data_attr));
|
||||
src += '&' + attr + '=' + attr_value;
|
||||
}
|
||||
}
|
||||
function getCurCoords(iframe) {
|
||||
var docEl = document.documentElement;
|
||||
var frect = iframe.getBoundingClientRect();
|
||||
return {
|
||||
frameTop: frect.top,
|
||||
frameBottom: frect.bottom,
|
||||
frameLeft: frect.left,
|
||||
frameRight: frect.right,
|
||||
frameWidth: frect.width,
|
||||
frameHeight: frect.height,
|
||||
scrollTop: window.pageYOffset,
|
||||
scrollLeft: window.pageXOffset,
|
||||
clientWidth: docEl.clientWidth,
|
||||
clientHeight: docEl.clientHeight
|
||||
};
|
||||
}
|
||||
function visibilityHandler() {
|
||||
if (isVisible(iframe, 50)) {
|
||||
postMessageToIframe(iframe, 'visible', {frame: widgetElId});
|
||||
}
|
||||
}
|
||||
function focusHandler() {
|
||||
postMessageToIframe(iframe, 'focus', {has_focus: document.hasFocus()});
|
||||
}
|
||||
function postMessageHandler(event) {
|
||||
if (event.source !== iframe.contentWindow ||
|
||||
event.origin != widgetsOrigin) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
var data = JSON.parse(event.data);
|
||||
} catch(e) {
|
||||
var data = {};
|
||||
}
|
||||
if (data.event == 'resize') {
|
||||
if (data.height) {
|
||||
iframe.style.height = data.height + 'px';
|
||||
}
|
||||
if (data.width) {
|
||||
iframe.style.width = data.width + 'px';
|
||||
}
|
||||
}
|
||||
else if (data.event == 'ready') {
|
||||
iframe._ready = true;
|
||||
focusHandler();
|
||||
for (var i = 0; i < iframe._readyQueue.length; i++) {
|
||||
var queue_item = iframe._readyQueue[i];
|
||||
postMessageToIframe(iframe, queue_item[0], queue_item[1], queue_item[2]);
|
||||
}
|
||||
iframe._readyQueue = [];
|
||||
}
|
||||
else if (data.event == 'visible_off') {
|
||||
removeEvent(window, 'scroll', visibilityHandler);
|
||||
removeEvent(window, 'resize', visibilityHandler);
|
||||
}
|
||||
else if (data.event == 'get_coords') {
|
||||
postMessageToIframe(iframe, 'callback', {
|
||||
_cb: data._cb,
|
||||
value: getCurCoords(iframe)
|
||||
});
|
||||
}
|
||||
else if (data.event == 'scroll_to') {
|
||||
try {
|
||||
window.scrollTo(data.x || 0, data.y || 0);
|
||||
} catch(e) {}
|
||||
}
|
||||
else if (data.event == 'auth_user') {
|
||||
if (data.init) {
|
||||
onInitAuthUser && onInitAuthUser(data.auth_data);
|
||||
} else {
|
||||
onAuthUser && onAuthUser(data.auth_data);
|
||||
}
|
||||
}
|
||||
else if (data.event == 'unauthorized') {
|
||||
onUnauth && onUnauth();
|
||||
}
|
||||
else if (data.event == 'callback') {
|
||||
var cb_data = null;
|
||||
if (cb_data = window.Telegram.__WidgetCallbacks[data._cb]) {
|
||||
if (cb_data.iframe === iframe) {
|
||||
cb_data.callback(data.value);
|
||||
delete window.Telegram.__WidgetCallbacks[data._cb];
|
||||
}
|
||||
} else {
|
||||
console.warn('Callback #' + data._cb + ' not found');
|
||||
}
|
||||
}
|
||||
}
|
||||
var iframe = document.createElement('iframe');
|
||||
iframe.id = widgetElId;
|
||||
iframe.src = src;
|
||||
iframe.width = defWidth;
|
||||
iframe.height = defHeight;
|
||||
iframe.setAttribute('frameborder', '0');
|
||||
if (!scrollable) {
|
||||
iframe.setAttribute('scrolling', 'no');
|
||||
iframe.style.overflow = 'hidden';
|
||||
}
|
||||
iframe.style.colorScheme = 'light dark';
|
||||
iframe.style.border = 'none';
|
||||
for (var prop in styles) {
|
||||
iframe.style[prop] = styles[prop];
|
||||
}
|
||||
if (widgetEl.parentNode) {
|
||||
widgetEl.parentNode.insertBefore(iframe, widgetEl);
|
||||
if (widgetEl.tagName.toUpperCase() == 'BLOCKQUOTE') {
|
||||
widgetEl.parentNode.removeChild(widgetEl);
|
||||
}
|
||||
}
|
||||
iframe._ready = false;
|
||||
iframe._readyQueue = [];
|
||||
widgetEl._iframe = iframe;
|
||||
addEvent(iframe, 'load', function() {
|
||||
removeEvent(iframe, 'load', visibilityHandler);
|
||||
addEvent(window, 'scroll', visibilityHandler);
|
||||
addEvent(window, 'resize', visibilityHandler);
|
||||
visibilityHandler();
|
||||
});
|
||||
addEvent(window, 'focus blur', focusHandler);
|
||||
addEvent(window, 'message', postMessageHandler);
|
||||
return iframe;
|
||||
}
|
||||
function isVisible(el, padding) {
|
||||
var node = el, val;
|
||||
var visibility = getCssProperty(node, 'visibility');
|
||||
if (visibility == 'hidden') return false;
|
||||
while (node) {
|
||||
if (node === document.documentElement) break;
|
||||
var display = getCssProperty(node, 'display');
|
||||
if (display == 'none') return false;
|
||||
var opacity = getCssProperty(node, 'opacity');
|
||||
if (opacity !== null && opacity < 0.1) return false;
|
||||
node = node.parentNode;
|
||||
}
|
||||
if (el.getBoundingClientRect) {
|
||||
padding = +padding || 0;
|
||||
var rect = el.getBoundingClientRect();
|
||||
var html = document.documentElement;
|
||||
if (rect.bottom < padding ||
|
||||
rect.right < padding ||
|
||||
rect.top > (window.innerHeight || html.clientHeight) - padding ||
|
||||
rect.left > (window.innerWidth || html.clientWidth) - padding) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function getAllWidgets() {
|
||||
var widgets = [];
|
||||
if (document.querySelectorAll) {
|
||||
widgets = document.querySelectorAll('script[data-telegram-post],blockquote.telegram-post,script[data-telegram-discussion],script[data-telegram-login],script[data-telegram-share-url]');
|
||||
} else {
|
||||
widgets = Array.prototype.slice.apply(document.getElementsByTagName('SCRIPT'));
|
||||
widgets = widgets.concat(Array.prototype.slice.apply(document.getElementsByTagName('BLOCKQUOTE')));
|
||||
}
|
||||
return widgets;
|
||||
}
|
||||
|
||||
function getWidgetInfo(el_or_id, callback) {
|
||||
var e = null, iframe = null;
|
||||
if (el = geById(el_or_id)) {
|
||||
if (el.tagName &&
|
||||
el.tagName.toUpperCase() == 'IFRAME') {
|
||||
iframe = el;
|
||||
} else if (el._iframe) {
|
||||
iframe = el._iframe;
|
||||
}
|
||||
if (iframe && callback) {
|
||||
postMessageToIframe(iframe, 'get_info', {}, callback);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setWidgetOptions(options, el_or_id) {
|
||||
var e = null, iframe = null;
|
||||
if (typeof el_or_id === 'undefined') {
|
||||
var widgets = getAllWidgets();
|
||||
for (var i = 0; i < widgets.length; i++) {
|
||||
if (iframe = widgets[i]._iframe) {
|
||||
postMessageToIframe(iframe, 'set_options', {options: options});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (el = geById(el_or_id)) {
|
||||
if (el.tagName &&
|
||||
el.tagName.toUpperCase() == 'IFRAME') {
|
||||
iframe = el;
|
||||
} else if (el._iframe) {
|
||||
iframe = el._iframe;
|
||||
}
|
||||
if (iframe) {
|
||||
postMessageToIframe(iframe, 'set_options', {options: options});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!document.currentScript ||
|
||||
!initWidget(document.currentScript)) {
|
||||
var widgets = getAllWidgets();
|
||||
for (var i = 0; i < widgets.length; i++) {
|
||||
initWidget(widgets[i]);
|
||||
}
|
||||
}
|
||||
|
||||
var TelegramLogin = {
|
||||
popups: {},
|
||||
options: null,
|
||||
auth_callback: null,
|
||||
_init: function(options, auth_callback) {
|
||||
TelegramLogin.options = options;
|
||||
TelegramLogin.auth_callback = auth_callback;
|
||||
var auth_result = haveTgAuthResult();
|
||||
if (auth_result && auth_callback) {
|
||||
auth_callback(auth_result);
|
||||
}
|
||||
},
|
||||
_open: function(callback) {
|
||||
TelegramLogin._auth(TelegramLogin.options, function(authData) {
|
||||
if (TelegramLogin.auth_callback) {
|
||||
TelegramLogin.auth_callback(authData);
|
||||
}
|
||||
if (callback) {
|
||||
callback(authData);
|
||||
}
|
||||
});
|
||||
},
|
||||
_auth: function(options, callback) {
|
||||
var bot_id = parseInt(options.bot_id);
|
||||
if (!bot_id) {
|
||||
throw new Error('Bot id required');
|
||||
}
|
||||
var width = 550;
|
||||
var height = 470;
|
||||
var left = Math.max(0, (screen.width - width) / 2) + (screen.availLeft | 0),
|
||||
top = Math.max(0, (screen.height - height) / 2) + (screen.availTop | 0);
|
||||
var onMessage = function (event) {
|
||||
try {
|
||||
var data = JSON.parse(event.data);
|
||||
} catch(e) {
|
||||
var data = {};
|
||||
}
|
||||
if (!TelegramLogin.popups[bot_id]) return;
|
||||
if (event.source !== TelegramLogin.popups[bot_id].window) return;
|
||||
if (data.event == 'auth_result') {
|
||||
onAuthDone(data.result);
|
||||
}
|
||||
};
|
||||
var onAuthDone = function (authData) {
|
||||
if (!TelegramLogin.popups[bot_id]) return;
|
||||
if (TelegramLogin.popups[bot_id].authFinished) return;
|
||||
callback && callback(authData);
|
||||
TelegramLogin.popups[bot_id].authFinished = true;
|
||||
removeEvent(window, 'message', onMessage);
|
||||
};
|
||||
var checkClose = function(bot_id) {
|
||||
if (!TelegramLogin.popups[bot_id]) return;
|
||||
if (!TelegramLogin.popups[bot_id].window ||
|
||||
TelegramLogin.popups[bot_id].window.closed) {
|
||||
return TelegramLogin.getAuthData(options, function(origin, authData) {
|
||||
onAuthDone(authData);
|
||||
});
|
||||
}
|
||||
setTimeout(checkClose, 100, bot_id);
|
||||
}
|
||||
var popup_url = Telegram.Login.widgetsOrigin + '/auth?bot_id=' + encodeURIComponent(options.bot_id) + '&origin=' + encodeURIComponent(location.origin || location.protocol + '//' + location.hostname) + (options.request_access ? '&request_access=' + encodeURIComponent(options.request_access) : '') + (options.lang ? '&lang=' + encodeURIComponent(options.lang) : '') + '&return_to=' + encodeURIComponent(location.href);
|
||||
var popup = window.open(popup_url, 'telegram_oauth_bot' + bot_id, 'width=' + width + ',height=' + height + ',left=' + left + ',top=' + top + ',status=0,location=0,menubar=0,toolbar=0');
|
||||
TelegramLogin.popups[bot_id] = {
|
||||
window: popup,
|
||||
authFinished: false
|
||||
};
|
||||
if (popup) {
|
||||
addEvent(window, 'message', onMessage);
|
||||
popup.focus();
|
||||
checkClose(bot_id);
|
||||
}
|
||||
},
|
||||
getAuthData: function(options, callback) {
|
||||
var bot_id = parseInt(options.bot_id);
|
||||
if (!bot_id) {
|
||||
throw new Error('Bot id required');
|
||||
}
|
||||
var xhr = getXHR();
|
||||
var url = Telegram.Login.widgetsOrigin + '/auth/get';
|
||||
xhr.open('POST', url);
|
||||
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded; charset=UTF-8');
|
||||
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
|
||||
xhr.onreadystatechange = function() {
|
||||
if (xhr.readyState == 4) {
|
||||
if (typeof xhr.responseBody == 'undefined' && xhr.responseText) {
|
||||
try {
|
||||
var result = JSON.parse(xhr.responseText);
|
||||
} catch(e) {
|
||||
var result = {};
|
||||
}
|
||||
if (result.user) {
|
||||
callback(result.origin, result.user);
|
||||
} else {
|
||||
callback(result.origin, false);
|
||||
}
|
||||
} else {
|
||||
callback('*', false);
|
||||
}
|
||||
}
|
||||
};
|
||||
xhr.onerror = function() {
|
||||
callback('*', false);
|
||||
};
|
||||
xhr.withCredentials = true;
|
||||
xhr.send('bot_id=' + encodeURIComponent(options.bot_id) + (options.lang ? '&lang=' + encodeURIComponent(options.lang) : ''));
|
||||
}
|
||||
};
|
||||
|
||||
window.Telegram.getWidgetInfo = getWidgetInfo;
|
||||
window.Telegram.setWidgetOptions = setWidgetOptions;
|
||||
window.Telegram.Login = {
|
||||
init: TelegramLogin._init,
|
||||
open: TelegramLogin._open,
|
||||
auth: TelegramLogin._auth,
|
||||
widgetsOrigin: getWidgetsOrigin('https://oauth.telegram.org', 'https://oauth.tg.dev')
|
||||
};
|
||||
|
||||
}(window));
|
||||
})(window);
|
||||
+23
-96
@@ -1,8 +1,5 @@
|
||||
import hmac
|
||||
import asyncio
|
||||
import logging
|
||||
from contextlib import suppress
|
||||
|
||||
from aiohttp import web
|
||||
from aiogram import Bot, Dispatcher
|
||||
from aiogram.webhook.aiohttp_server import SimpleRequestHandler, setup_application
|
||||
@@ -11,26 +8,18 @@ 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)
|
||||
|
||||
TELEGRAM_WEB_APP_SDK_REFRESH_INTERVAL_SECONDS = 24 * 60 * 60
|
||||
|
||||
|
||||
def _inject_shared_instances(
|
||||
app: web.Application,
|
||||
async def build_and_start_web_app(
|
||||
dp: Dispatcher,
|
||||
bot: Bot,
|
||||
settings: Settings,
|
||||
async_session_factory: sessionmaker,
|
||||
) -> None:
|
||||
):
|
||||
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",
|
||||
@@ -45,37 +34,29 @@ def _inject_shared_instances(
|
||||
"platega_service",
|
||||
"severpay_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
|
||||
|
||||
|
||||
async def build_and_start_web_app(
|
||||
dp: Dispatcher,
|
||||
bot: Bot,
|
||||
settings: Settings,
|
||||
async_session_factory: sessionmaker,
|
||||
):
|
||||
app = web.Application()
|
||||
_inject_shared_instances(app, dp, bot, settings, async_session_factory)
|
||||
|
||||
async def _healthcheck(request: web.Request) -> web.Response:
|
||||
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)
|
||||
telegram_webhook_secret = (settings.TELEGRAM_WEBHOOK_SECRET or "").strip() or None
|
||||
|
||||
if telegram_uses_webhook_mode:
|
||||
telegram_webhook_path = settings.telegram_webhook_path
|
||||
SecureSimpleRequestHandler(
|
||||
dispatcher=dp,
|
||||
bot=bot,
|
||||
secret_token=settings.WEBHOOK_SECRET_TOKEN,
|
||||
).register(app, path=telegram_webhook_path)
|
||||
app.router.add_post(
|
||||
telegram_webhook_path,
|
||||
SimpleRequestHandler(
|
||||
dispatcher=dp,
|
||||
bot=bot,
|
||||
secret_token=telegram_webhook_secret,
|
||||
),
|
||||
)
|
||||
logging.info(
|
||||
f"Telegram webhook route configured at: [POST] {telegram_webhook_path} (relative to base URL)"
|
||||
"Telegram webhook route configured at: [POST] %s (secret_token=%s)",
|
||||
telegram_webhook_path,
|
||||
"set" if telegram_webhook_secret else "not_set",
|
||||
)
|
||||
|
||||
from bot.handlers.user.payment import yookassa_webhook_route
|
||||
@@ -88,7 +69,7 @@ async def build_and_start_web_app(
|
||||
cp_path = settings.cryptopay_webhook_path
|
||||
if cp_path.startswith("/"):
|
||||
app.router.add_post(cp_path, cryptopay_webhook_route)
|
||||
logging.info(f"CryptoPay webhook route configured at: [POST] {cp_path}")
|
||||
logging.info("CryptoPay webhook route configured at: [POST] %s", cp_path)
|
||||
|
||||
fk_path = settings.freekassa_webhook_path
|
||||
if fk_path.startswith("/"):
|
||||
@@ -116,13 +97,10 @@ 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}")
|
||||
|
||||
runners = []
|
||||
|
||||
webhooks_runner = web.AppRunner(app)
|
||||
await webhooks_runner.setup()
|
||||
runners.append(webhooks_runner)
|
||||
web_app_runner = web.AppRunner(app)
|
||||
await web_app_runner.setup()
|
||||
site = web.TCPSite(
|
||||
webhooks_runner,
|
||||
web_app_runner,
|
||||
host=settings.WEB_SERVER_HOST,
|
||||
port=settings.WEB_SERVER_PORT,
|
||||
)
|
||||
@@ -132,56 +110,5 @@ async def build_and_start_web_app(
|
||||
f"AIOHTTP server started on http://{settings.WEB_SERVER_HOST}:{settings.WEB_SERVER_PORT}"
|
||||
)
|
||||
|
||||
telegram_web_app_sdk_refresh_task = None
|
||||
if settings.WEBAPP_ENABLED:
|
||||
from bot.app.web.subscription_webapp import (
|
||||
create_subscription_webapp_application,
|
||||
refresh_telegram_login_widget_sdk,
|
||||
refresh_telegram_web_app_sdk,
|
||||
)
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
async def _refresh_telegram_web_assets_forever() -> None:
|
||||
while True:
|
||||
await refresh_telegram_web_app_sdk()
|
||||
await refresh_telegram_login_widget_sdk()
|
||||
await asyncio.sleep(TELEGRAM_WEB_APP_SDK_REFRESH_INTERVAL_SECONDS)
|
||||
|
||||
telegram_web_app_sdk_refresh_task = asyncio.create_task(
|
||||
_refresh_telegram_web_assets_forever(),
|
||||
name="TelegramWebAssetsRefreshTask",
|
||||
)
|
||||
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
finally:
|
||||
if telegram_web_app_sdk_refresh_task is not None:
|
||||
telegram_web_app_sdk_refresh_task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await telegram_web_app_sdk_refresh_task
|
||||
|
||||
for runner in reversed(runners):
|
||||
try:
|
||||
await runner.cleanup()
|
||||
except Exception as cleanup_error:
|
||||
logging.warning("Failed to cleanup aiohttp runner: %s", cleanup_error)
|
||||
# Run until cancelled
|
||||
await asyncio.Event().wait()
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
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
|
||||
@@ -46,8 +46,8 @@ async def show_ads_menu(callback: types.CallbackQuery, settings: Settings, i18n_
|
||||
await callback.message.edit_text(text, reply_markup=reply_markup)
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/admin/ads.py: %s", exc)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("admin_ads:page:"))
|
||||
@@ -262,8 +262,8 @@ async def ads_create_start(callback: types.CallbackQuery, state: FSMContext, set
|
||||
await callback.message.edit_text(_("admin_ads_create_source_prompt"))
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/admin/ads.py: %s", exc)
|
||||
|
||||
|
||||
@router.message(
|
||||
|
||||
@@ -171,8 +171,8 @@ async def change_broadcast_target_handler(
|
||||
current_lang, i18n, target=new_target
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/admin/broadcast.py: %s", exc)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
|
||||
@@ -141,9 +141,6 @@ 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,7 +23,6 @@ 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,
|
||||
@@ -172,9 +171,17 @@ async def view_all_logs_handler(callback: types.CallbackQuery,
|
||||
await callback.answer("Error processing request.", show_alert=True)
|
||||
return
|
||||
|
||||
hide_admin_events = bool(settings.LOG_ADMIN_HIDE)
|
||||
logs_models = await message_log_dal.get_all_message_logs(
|
||||
session, settings.LOGS_PAGE_SIZE, page_idx * settings.LOGS_PAGE_SIZE)
|
||||
total_logs_count = await message_log_dal.count_all_message_logs(session)
|
||||
session,
|
||||
settings.LOGS_PAGE_SIZE,
|
||||
page_idx * settings.LOGS_PAGE_SIZE,
|
||||
hide_admin_events=hide_admin_events,
|
||||
)
|
||||
total_logs_count = await message_log_dal.count_all_message_logs(
|
||||
session,
|
||||
hide_admin_events=hide_admin_events,
|
||||
)
|
||||
|
||||
await _display_formatted_logs(
|
||||
target_message=callback.message,
|
||||
@@ -226,14 +233,12 @@ 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() or (input_text.startswith("-") and input_text[1:].isdigit()):
|
||||
if input_text.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:])
|
||||
@@ -248,7 +253,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 (user_model_for_logs.email or f"ID {target_user_id}"))
|
||||
if user_model_for_logs.username else f"ID {target_user_id}")
|
||||
|
||||
logs_models = await message_log_dal.get_user_message_logs(
|
||||
session, target_user_id, settings.LOGS_PAGE_SIZE, 0)
|
||||
@@ -295,7 +300,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 (user_model_for_logs.email or f"ID {target_user_id}"))
|
||||
if user_model_for_logs.username else f"ID {target_user_id}")
|
||||
|
||||
logs_models = await message_log_dal.get_user_message_logs(
|
||||
session, target_user_id, settings.LOGS_PAGE_SIZE,
|
||||
@@ -347,8 +352,12 @@ async def export_logs_csv_handler(callback: types.CallbackQuery,
|
||||
try:
|
||||
# Get all logs (limit to 10000 for performance)
|
||||
logs_models = await message_log_dal.get_all_message_logs(
|
||||
session, limit=10000, offset=0)
|
||||
|
||||
session,
|
||||
limit=10000,
|
||||
offset=0,
|
||||
hide_admin_events=bool(settings.LOG_ADMIN_HIDE),
|
||||
)
|
||||
|
||||
if not logs_models:
|
||||
await callback.message.answer(_(
|
||||
"admin_logs_csv_no_data"
|
||||
@@ -358,7 +367,7 @@ async def export_logs_csv_handler(callback: types.CallbackQuery,
|
||||
# Create CSV content
|
||||
csv_buffer = io.StringIO()
|
||||
csv_writer = csv.writer(csv_buffer, delimiter=',', quotechar='"', quoting=csv.QUOTE_MINIMAL)
|
||||
|
||||
|
||||
# Write header
|
||||
headers = [
|
||||
_("admin_csv_header_log_id"),
|
||||
@@ -367,22 +376,26 @@ async def export_logs_csv_handler(callback: types.CallbackQuery,
|
||||
_("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")
|
||||
]
|
||||
include_sensitive_fields = bool(settings.LOG_EXPORT_INCLUDE_SENSITIVE)
|
||||
if include_sensitive_fields:
|
||||
headers.extend([
|
||||
_("admin_csv_header_content"),
|
||||
_("admin_csv_header_raw_update_preview"),
|
||||
])
|
||||
csv_writer.writerow(headers)
|
||||
|
||||
|
||||
# Write data rows
|
||||
for log in logs_models:
|
||||
# Format timestamp
|
||||
timestamp_str = log.timestamp.strftime('%Y-%m-%d %H:%M:%S UTC') if log.timestamp else ''
|
||||
|
||||
|
||||
# Clean content and raw_update_preview (remove newlines and quotes for CSV)
|
||||
content_clean = (log.content or '').replace('\n', ' ').replace('\r', ' ').strip()
|
||||
raw_update_clean = (log.raw_update_preview or '').replace('\n', ' ').replace('\r', ' ').strip()
|
||||
|
||||
|
||||
row = [
|
||||
log.log_id or '',
|
||||
timestamp_str,
|
||||
@@ -390,27 +403,30 @@ async def export_logs_csv_handler(callback: types.CallbackQuery,
|
||||
log.telegram_username or '',
|
||||
log.telegram_first_name or '',
|
||||
log.event_type or '',
|
||||
content_clean,
|
||||
'Yes' if log.is_admin_event else 'No',
|
||||
log.target_user_id or '',
|
||||
raw_update_clean
|
||||
]
|
||||
if include_sensitive_fields:
|
||||
row.extend([
|
||||
content_clean,
|
||||
raw_update_clean,
|
||||
])
|
||||
csv_writer.writerow(row)
|
||||
|
||||
|
||||
# Create file
|
||||
csv_content = csv_buffer.getvalue()
|
||||
csv_buffer.close()
|
||||
|
||||
|
||||
# Generate filename with current timestamp
|
||||
now = datetime.now()
|
||||
filename = f"message_logs_{now.strftime('%Y%m%d_%H%M%S')}.csv"
|
||||
|
||||
|
||||
# Send as document
|
||||
csv_file = types.BufferedInputFile(
|
||||
csv_content.encode('utf-8-sig'), # BOM for Excel compatibility
|
||||
filename=filename
|
||||
)
|
||||
|
||||
|
||||
await callback.message.answer_document(
|
||||
csv_file,
|
||||
caption=_(
|
||||
@@ -419,7 +435,7 @@ async def export_logs_csv_handler(callback: types.CallbackQuery,
|
||||
date=now.strftime('%Y-%m-%d %H:%M:%S')
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error exporting logs to CSV: {e}", exc_info=True)
|
||||
await callback.message.answer(_(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import logging
|
||||
import random
|
||||
import secrets
|
||||
import string
|
||||
import csv
|
||||
import io
|
||||
@@ -56,7 +56,7 @@ async def create_bulk_promo_prompt_handler(callback: types.CallbackQuery,
|
||||
def generate_unique_promo_code(length: int = 8) -> str:
|
||||
"""Generate a unique random promo code"""
|
||||
characters = string.ascii_uppercase + string.digits
|
||||
return ''.join(random.choice(characters) for _ in range(length))
|
||||
return ''.join(secrets.choice(characters) for _ in range(length))
|
||||
|
||||
|
||||
# Step 1: Process quantity
|
||||
|
||||
@@ -28,25 +28,82 @@ async def create_promo_prompt_handler(callback: types.CallbackQuery,
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
# Step 1: Ask for promo code
|
||||
# Step 0: Ask for promo type (bonus_days or discount)
|
||||
prompt_text = _(
|
||||
"admin_promo_step1_code"
|
||||
"admin_promo_step0_type"
|
||||
)
|
||||
|
||||
# Create keyboard for type selection
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_("admin_promo_type_bonus_days"),
|
||||
callback_data="promo_type_select:bonus_days"
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_("admin_promo_type_discount"),
|
||||
callback_data="promo_type_select:discount"
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_("admin_back_to_panel"),
|
||||
callback_data="admin_action:main"
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
prompt_text,
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
|
||||
reply_markup=builder.as_markup(),
|
||||
parse_mode="HTML")
|
||||
except Exception as e:
|
||||
logging.warning(
|
||||
f"Could not edit message for promo prompt: {e}. Sending new.")
|
||||
f"Could not edit message for promo type prompt: {e}. Sending new.")
|
||||
await callback.message.answer(
|
||||
prompt_text,
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
|
||||
reply_markup=builder.as_markup(),
|
||||
parse_mode="HTML")
|
||||
await callback.answer()
|
||||
await state.set_state(AdminStates.waiting_for_promo_code)
|
||||
await state.set_state(AdminStates.waiting_for_promo_type_selection)
|
||||
|
||||
|
||||
# Step 0: Process type selection
|
||||
@router.callback_query(F.data.startswith("promo_type_select:"), StateFilter(AdminStates.waiting_for_promo_type_selection))
|
||||
async def process_promo_type_selection(callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
i18n_data: dict,
|
||||
settings: Settings):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n or not callback.message:
|
||||
await callback.answer("Error processing type selection.", show_alert=True)
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
try:
|
||||
promo_type = callback.data.split(":")[-1] # "bonus_days" or "discount"
|
||||
await state.update_data(promo_type=promo_type)
|
||||
|
||||
# Step 1: Ask for promo code
|
||||
prompt_text = _(
|
||||
"admin_promo_step1_code"
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
prompt_text,
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
await state.set_state(AdminStates.waiting_for_promo_code)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error processing promo type selection: {e}")
|
||||
await callback.message.answer(_("error_occurred_try_again"))
|
||||
await callback.answer()
|
||||
|
||||
|
||||
# Step 1: Process promo code
|
||||
@@ -80,19 +137,31 @@ async def process_promo_code_handler(message: types.Message,
|
||||
return
|
||||
|
||||
await state.update_data(promo_code=code_str)
|
||||
|
||||
# Step 2: Ask for bonus days
|
||||
prompt_text = _(
|
||||
"admin_promo_step2_bonus_days",
|
||||
code=code_str
|
||||
)
|
||||
|
||||
|
||||
# Get promo type from state
|
||||
data = await state.get_data()
|
||||
promo_type = data.get("promo_type", "bonus_days")
|
||||
|
||||
# Step 2: Ask for bonus days OR discount percentage based on type
|
||||
if promo_type == "discount":
|
||||
prompt_text = _(
|
||||
"admin_promo_step2_discount_percentage",
|
||||
code=code_str
|
||||
)
|
||||
next_state = AdminStates.waiting_for_promo_discount_percentage
|
||||
else:
|
||||
prompt_text = _(
|
||||
"admin_promo_step2_bonus_days",
|
||||
code=code_str
|
||||
)
|
||||
next_state = AdminStates.waiting_for_promo_bonus_days
|
||||
|
||||
await message.answer(
|
||||
prompt_text,
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await state.set_state(AdminStates.waiting_for_promo_bonus_days)
|
||||
await state.set_state(next_state)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error processing promo code: {e}")
|
||||
@@ -119,9 +188,9 @@ async def process_promo_bonus_days_handler(message: types.Message,
|
||||
"admin_promo_invalid_bonus_days"
|
||||
))
|
||||
return
|
||||
|
||||
|
||||
await state.update_data(bonus_days=bonus_days)
|
||||
|
||||
|
||||
# Step 3: Ask for max activations
|
||||
data = await state.get_data()
|
||||
prompt_text = _(
|
||||
@@ -129,14 +198,14 @@ async def process_promo_bonus_days_handler(message: types.Message,
|
||||
code=data.get("promo_code"),
|
||||
bonus_days=bonus_days
|
||||
)
|
||||
|
||||
|
||||
await message.answer(
|
||||
prompt_text,
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await state.set_state(AdminStates.waiting_for_promo_max_activations)
|
||||
|
||||
|
||||
except ValueError:
|
||||
await message.answer(_(
|
||||
"admin_promo_invalid_number"
|
||||
@@ -146,6 +215,53 @@ async def process_promo_bonus_days_handler(message: types.Message,
|
||||
await message.answer(_("error_occurred_try_again"))
|
||||
|
||||
|
||||
# Step 2: Process discount percentage
|
||||
@router.message(AdminStates.waiting_for_promo_discount_percentage, F.text)
|
||||
async def process_promo_discount_percentage_handler(message: types.Message,
|
||||
state: FSMContext,
|
||||
i18n_data: dict,
|
||||
settings: Settings):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n:
|
||||
await message.reply("Language service error.")
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
try:
|
||||
discount_percentage = int(message.text.strip())
|
||||
if not (1 <= discount_percentage <= 100):
|
||||
await message.answer(
|
||||
"❌ Discount percentage must be between 1 and 100."
|
||||
)
|
||||
return
|
||||
|
||||
await state.update_data(discount_percentage=discount_percentage)
|
||||
|
||||
# Step 3: Ask for max activations
|
||||
data = await state.get_data()
|
||||
prompt_text = _(
|
||||
"admin_promo_step3_max_activations_discount",
|
||||
code=data.get("promo_code"),
|
||||
discount_percentage=discount_percentage
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
prompt_text,
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await state.set_state(AdminStates.waiting_for_promo_max_activations)
|
||||
|
||||
except ValueError:
|
||||
await message.answer(_(
|
||||
"admin_promo_invalid_number"
|
||||
))
|
||||
except Exception as e:
|
||||
logging.error(f"Error processing discount percentage: {e}")
|
||||
await message.answer(_("error_occurred_try_again"))
|
||||
|
||||
|
||||
# Step 3: Process max activations
|
||||
@router.message(AdminStates.waiting_for_promo_max_activations, F.text)
|
||||
async def process_promo_max_activations_handler(message: types.Message,
|
||||
@@ -168,15 +284,25 @@ async def process_promo_max_activations_handler(message: types.Message,
|
||||
return
|
||||
|
||||
await state.update_data(max_activations=max_activations)
|
||||
|
||||
|
||||
# Step 4: Ask for validity
|
||||
data = await state.get_data()
|
||||
prompt_text = _(
|
||||
"admin_promo_step4_validity",
|
||||
code=data.get("promo_code"),
|
||||
bonus_days=data.get("bonus_days"),
|
||||
max_activations=max_activations
|
||||
)
|
||||
promo_type = data.get("promo_type", "bonus_days")
|
||||
|
||||
if promo_type == "discount":
|
||||
prompt_text = _(
|
||||
"admin_promo_step4_validity_discount",
|
||||
code=data.get("promo_code"),
|
||||
discount_percentage=data.get("discount_percentage"),
|
||||
max_activations=max_activations
|
||||
)
|
||||
else:
|
||||
prompt_text = _(
|
||||
"admin_promo_step4_validity",
|
||||
code=data.get("promo_code"),
|
||||
bonus_days=data.get("bonus_days"),
|
||||
max_activations=max_activations
|
||||
)
|
||||
|
||||
# Create keyboard for validity options
|
||||
builder = InlineKeyboardBuilder()
|
||||
@@ -240,12 +366,15 @@ async def process_promo_set_validity(callback: types.CallbackQuery,
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
data = await state.get_data()
|
||||
prompt_text = _(
|
||||
"admin_promo_enter_validity_days",
|
||||
code=data.get("promo_code"),
|
||||
bonus_days=data.get("bonus_days"),
|
||||
max_activations=data.get("max_activations")
|
||||
)
|
||||
promo_type = data.get("promo_type", "bonus_days")
|
||||
|
||||
# Display the correct text based on promo type
|
||||
if promo_type == "discount":
|
||||
value_info = f"{data.get('discount_percentage')}%"
|
||||
else:
|
||||
value_info = f"{data.get('bonus_days')} дней"
|
||||
|
||||
prompt_text = f"⏰ Введите количество дней действия промокода (1-365):\n\nКод: <b>{data.get('promo_code')}</b>\n{'Скидка' if promo_type == 'discount' else 'Бонус'}: <b>{value_info}</b>\nМакс. активаций: <b>{data.get('max_activations')}</b>"
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
@@ -310,40 +439,60 @@ async def create_promo_code_final(callback_or_message,
|
||||
|
||||
try:
|
||||
data = await state.get_data()
|
||||
|
||||
promo_type = data.get("promo_type", "bonus_days")
|
||||
|
||||
# Prepare promo code data
|
||||
promo_data = {
|
||||
"code": data["promo_code"],
|
||||
"bonus_days": data["bonus_days"],
|
||||
"promo_type": promo_type,
|
||||
"max_activations": data["max_activations"],
|
||||
"current_activations": 0,
|
||||
"is_active": True,
|
||||
"created_by_admin_id": callback_or_message.from_user.id,
|
||||
"created_at": datetime.now(timezone.utc)
|
||||
}
|
||||
|
||||
|
||||
# Set type-specific fields
|
||||
if promo_type == "discount":
|
||||
promo_data["discount_percentage"] = data["discount_percentage"]
|
||||
promo_data["bonus_days"] = None
|
||||
else:
|
||||
promo_data["bonus_days"] = data["bonus_days"]
|
||||
promo_data["discount_percentage"] = None
|
||||
|
||||
# Set validity
|
||||
if data.get("validity_days"):
|
||||
promo_data["valid_until"] = datetime.now(timezone.utc) + timedelta(days=data["validity_days"])
|
||||
else:
|
||||
promo_data["valid_until"] = None
|
||||
|
||||
|
||||
# Create promo code
|
||||
created_promo = await promo_code_dal.create_promo_code(session, promo_data)
|
||||
await session.commit()
|
||||
|
||||
|
||||
# Log successful creation
|
||||
logging.info(f"Promo code '{data['promo_code']}' created with ID {created_promo.promo_code_id}")
|
||||
|
||||
logging.info(f"Promo code '{data['promo_code']}' ({promo_type}) created with ID {created_promo.promo_code_id}")
|
||||
|
||||
# Success message
|
||||
valid_until_str = _("admin_promo_unlimited") if not data.get("validity_days") else f"{data['validity_days']} дней"
|
||||
success_text = _(
|
||||
"admin_promo_created_success",
|
||||
code=data["promo_code"],
|
||||
bonus_days=data["bonus_days"],
|
||||
max_activations=data["max_activations"],
|
||||
valid_until_str=valid_until_str
|
||||
)
|
||||
|
||||
# Format success message based on type
|
||||
if promo_type == "discount":
|
||||
success_text = _(
|
||||
"admin_promo_created_success_discount",
|
||||
code=data["promo_code"],
|
||||
discount_percentage=data['discount_percentage'],
|
||||
max_activations=data["max_activations"],
|
||||
valid_until_str=valid_until_str
|
||||
)
|
||||
else:
|
||||
success_text = _(
|
||||
"admin_promo_created_success",
|
||||
code=data["promo_code"],
|
||||
bonus_days=data['bonus_days'],
|
||||
max_activations=data["max_activations"],
|
||||
valid_until_str=valid_until_str
|
||||
)
|
||||
|
||||
if hasattr(callback_or_message, 'message'): # CallbackQuery
|
||||
try:
|
||||
@@ -385,8 +534,10 @@ async def create_promo_code_final(callback_or_message,
|
||||
@router.callback_query(
|
||||
F.data == "admin_action:main",
|
||||
StateFilter(
|
||||
AdminStates.waiting_for_promo_type_selection,
|
||||
AdminStates.waiting_for_promo_code,
|
||||
AdminStates.waiting_for_promo_bonus_days,
|
||||
AdminStates.waiting_for_promo_discount_percentage,
|
||||
AdminStates.waiting_for_promo_max_activations,
|
||||
AdminStates.waiting_for_promo_validity_days,
|
||||
),
|
||||
|
||||
@@ -47,9 +47,19 @@ async def get_promo_detail_text_and_keyboard(promo_id: int, session: AsyncSessio
|
||||
|
||||
created = promo.created_at.strftime("%d.%m.%Y %H:%M") if promo.created_at else "N/A"
|
||||
|
||||
# Determine promo type and value to display
|
||||
promo_type = getattr(promo, "promo_type", "bonus_days")
|
||||
if promo_type == "discount":
|
||||
type_name = _("admin_promo_type_discount")
|
||||
value_line = _("admin_promo_card_discount_percentage", percentage=promo.discount_percentage)
|
||||
else:
|
||||
type_name = _("admin_promo_type_bonus_days")
|
||||
value_line = _("admin_promo_card_bonus_days", days=promo.bonus_days)
|
||||
|
||||
text = "\n".join([
|
||||
_("admin_promo_card_title", code=promo.code),
|
||||
_("admin_promo_card_bonus_days", days=promo.bonus_days),
|
||||
_("admin_promo_card_type", type=type_name),
|
||||
value_line,
|
||||
_("admin_promo_card_activations", current=promo.current_activations, max=promo.max_activations),
|
||||
_("admin_promo_card_validity", validity=validity),
|
||||
_("admin_promo_card_status", status=status),
|
||||
@@ -76,12 +86,22 @@ async def view_promo_codes_handler(callback: types.CallbackQuery, i18n_data: dic
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
promo_models = await promo_code_dal.get_all_active_promo_codes(session, limit=20, offset=0)
|
||||
text = f"{_('admin_active_promos_list_header')}\n\n{_('admin_no_active_promos')}" if not promo_models else "\n".join(
|
||||
[_("admin_active_promos_list_header"), ""] + [
|
||||
f"{get_promo_status_emoji_and_text(p, i18n, current_lang)[0]} <code>{p.code}</code> | 🎁 {p.bonus_days}д | 📊 {p.current_activations}/{p.max_activations} | ⏰ {p.valid_until.strftime('%d.%m.%Y') if p.valid_until else _('admin_promo_valid_indefinitely')}"
|
||||
for p in promo_models
|
||||
]
|
||||
)
|
||||
if not promo_models:
|
||||
text = f"{_('admin_active_promos_list_header')}\n\n{_('admin_no_active_promos')}"
|
||||
else:
|
||||
promo_lines = [_("admin_active_promos_list_header"), ""]
|
||||
for p in promo_models:
|
||||
status_emoji = get_promo_status_emoji_and_text(p, i18n, current_lang)[0]
|
||||
promo_type = getattr(p, "promo_type", "bonus_days")
|
||||
if promo_type == "discount":
|
||||
value_display = f"💰 {p.discount_percentage}%"
|
||||
else:
|
||||
value_display = f"🎁 {p.bonus_days}д"
|
||||
validity_display = p.valid_until.strftime('%d.%m.%Y') if p.valid_until else _('admin_promo_valid_indefinitely')
|
||||
promo_lines.append(
|
||||
f"{status_emoji} <code>{p.code}</code> | {value_display} | 📊 {p.current_activations}/{p.max_activations} | ⏰ {validity_display}"
|
||||
)
|
||||
text = "\n".join(promo_lines)
|
||||
|
||||
await callback.message.edit_text(text, reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), parse_mode="HTML")
|
||||
await callback.answer()
|
||||
@@ -300,7 +320,9 @@ async def promo_export_all_handler(callback: types.CallbackQuery, i18n_data: dic
|
||||
# CSV headers (forced to English)
|
||||
writer.writerow([
|
||||
i18n.gettext(export_lang, "admin_promo_csv_code"),
|
||||
"Type",
|
||||
i18n.gettext(export_lang, "admin_promo_csv_bonus_days"),
|
||||
"Discount %",
|
||||
i18n.gettext(export_lang, "admin_promo_csv_max_activations"),
|
||||
i18n.gettext(export_lang, "admin_promo_csv_current_activations"),
|
||||
i18n.gettext(export_lang, "admin_promo_csv_status"),
|
||||
@@ -309,15 +331,22 @@ async def promo_export_all_handler(callback: types.CallbackQuery, i18n_data: dic
|
||||
i18n.gettext(export_lang, "admin_promo_csv_created_at"),
|
||||
i18n.gettext(export_lang, "admin_promo_csv_created_by_admin_id"),
|
||||
])
|
||||
|
||||
|
||||
for promo in all_promos:
|
||||
# Определяем статус
|
||||
status_emoji, status_text = get_promo_status_emoji_and_text(promo, i18n, export_lang)
|
||||
|
||||
|
||||
# Determine promo type and values
|
||||
promo_type = getattr(promo, "promo_type", "bonus_days")
|
||||
bonus_days_val = promo.bonus_days if promo_type == "bonus_days" else "N/A"
|
||||
discount_val = promo.discount_percentage if promo_type == "discount" else "N/A"
|
||||
|
||||
# Формируем данные для CSV
|
||||
row = [
|
||||
promo.code,
|
||||
promo.bonus_days,
|
||||
promo_type,
|
||||
bonus_days_val,
|
||||
discount_val,
|
||||
promo.max_activations,
|
||||
promo.current_activations,
|
||||
status_text,
|
||||
@@ -374,13 +403,26 @@ async def promo_edit_select_handler(callback: types.CallbackQuery, i18n_data: di
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
promo_id = int(callback.data.split(":")[1])
|
||||
|
||||
|
||||
# Get promo to check type
|
||||
promo = await promo_code_dal.get_promo_code_by_id(session, promo_id)
|
||||
if not promo:
|
||||
await callback.answer(_("admin_promo_not_found"), show_alert=True)
|
||||
return
|
||||
|
||||
promo_type = getattr(promo, "promo_type", "bonus_days")
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=_("admin_promo_edit_bonus_days"), callback_data=f"promo_edit_field:bonus_days:{promo_id}"))
|
||||
# Show appropriate edit option based on type
|
||||
if promo_type == "discount":
|
||||
builder.row(InlineKeyboardButton(text=_("admin_promo_edit_discount_percentage"), callback_data=f"promo_edit_field:discount_percentage:{promo_id}"))
|
||||
else:
|
||||
builder.row(InlineKeyboardButton(text=_("admin_promo_edit_bonus_days"), callback_data=f"promo_edit_field:bonus_days:{promo_id}"))
|
||||
|
||||
builder.row(InlineKeyboardButton(text=_("admin_promo_edit_max_activations"), callback_data=f"promo_edit_field:max_activations:{promo_id}"))
|
||||
builder.row(InlineKeyboardButton(text=_("admin_promo_edit_validity"), callback_data=f"promo_edit_field:valid_until:{promo_id}"))
|
||||
builder.row(InlineKeyboardButton(text=_("admin_promo_back_to_detail_button"), callback_data=f"promo_detail:{promo_id}"))
|
||||
|
||||
|
||||
await callback.message.edit_text(_("admin_promo_edit_select_field"), reply_markup=builder.as_markup())
|
||||
await callback.answer()
|
||||
|
||||
@@ -394,14 +436,19 @@ async def promo_edit_field_handler(callback: types.CallbackQuery, state: FSMCont
|
||||
|
||||
action, field, promo_id_str = callback.data.split(":")
|
||||
await state.update_data(promo_id=int(promo_id_str), field_to_edit=field)
|
||||
|
||||
|
||||
prompts = {
|
||||
"bonus_days": "admin_promo_prompt_bonus_days",
|
||||
"discount_percentage": "admin_promo_prompt_discount_percentage",
|
||||
"max_activations": "admin_promo_prompt_max_activations",
|
||||
"valid_until": "admin_promo_prompt_validity_days"
|
||||
}
|
||||
|
||||
prompt_key = prompts.get(field, "error_occurred_try_again")
|
||||
prompt_text = _(prompt_key)
|
||||
|
||||
await state.set_state(AdminStates.waiting_for_promo_edit_details)
|
||||
await callback.message.edit_text(_(prompts.get(field, "error_occurred_try_again")))
|
||||
await callback.message.edit_text(prompt_text)
|
||||
await callback.answer()
|
||||
|
||||
@router.message(StateFilter(AdminStates.waiting_for_promo_edit_details))
|
||||
@@ -418,9 +465,15 @@ async def process_promo_edit_details(message: types.Message, state: FSMContext,
|
||||
try:
|
||||
value = message.text
|
||||
update_data = {}
|
||||
|
||||
|
||||
if field == "bonus_days":
|
||||
update_data["bonus_days"] = int(value)
|
||||
elif field == "discount_percentage":
|
||||
discount_pct = int(value)
|
||||
if not (1 <= discount_pct <= 100):
|
||||
await message.answer("❌ Discount percentage must be between 1 and 100.")
|
||||
return
|
||||
update_data["discount_percentage"] = discount_pct
|
||||
elif field == "max_activations":
|
||||
update_data["max_activations"] = int(value)
|
||||
elif field == "valid_until":
|
||||
@@ -433,7 +486,7 @@ async def process_promo_edit_details(message: types.Message, state: FSMContext,
|
||||
if await promo_code_dal.update_promo_code(session, promo_id, update_data):
|
||||
await session.commit()
|
||||
await message.answer(_("admin_promo_edit_success"))
|
||||
|
||||
|
||||
# Reset state and show updated details
|
||||
await state.clear()
|
||||
text, keyboard = await get_promo_detail_text_and_keyboard(promo_id, session, i18n, current_lang)
|
||||
|
||||
@@ -2,7 +2,6 @@ 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
|
||||
@@ -11,44 +10,12 @@ 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,
|
||||
get_back_to_user_management_keyboard,
|
||||
)
|
||||
from bot.keyboards.inline.admin_keyboards import get_back_to_admin_panel_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):
|
||||
@@ -288,104 +255,3 @@ 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",
|
||||
)
|
||||
|
||||
@@ -18,108 +18,6 @@ from bot.middlewares.i18n import JsonI18n
|
||||
router = Router(name="admin_sync_router")
|
||||
|
||||
|
||||
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,
|
||||
@@ -178,7 +76,6 @@ async def perform_sync(
|
||||
"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}")
|
||||
@@ -196,20 +93,15 @@ async def perform_sync(
|
||||
|
||||
# First, try to find by telegram ID if available
|
||||
if telegram_id_from_panel:
|
||||
existing_user = await user_dal.get_user_by_telegram_id(
|
||||
existing_user = await user_dal.get_user_by_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.
|
||||
# The panel UUID is the strongest local link for subscription sync.
|
||||
# If not found by telegram ID, try to find by panel UUID
|
||||
if not existing_user:
|
||||
existing_user = await user_dal.get_user_by_panel_uuid(
|
||||
session, panel_uuid
|
||||
@@ -227,15 +119,6 @@ async def perform_sync(
|
||||
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:
|
||||
@@ -243,13 +126,6 @@ async def perform_sync(
|
||||
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
|
||||
@@ -278,28 +154,6 @@ async def perform_sync(
|
||||
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"
|
||||
@@ -321,39 +175,16 @@ async def perform_sync(
|
||||
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(
|
||||
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 = (
|
||||
@@ -365,11 +196,7 @@ async def perform_sync(
|
||||
and desired_description != current_panel_description
|
||||
):
|
||||
await panel_service.update_user_details_on_panel(
|
||||
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 {}),
|
||||
}
|
||||
panel_uuid, {"description": description_text}
|
||||
)
|
||||
except Exception as e_desc:
|
||||
logging.warning(
|
||||
|
||||
@@ -31,45 +31,6 @@ from bot.utils.telegram_markup import (
|
||||
|
||||
router = Router(name="admin_user_management_router")
|
||||
USERNAME_REGEX = re.compile(r"^[a-zA-Z0-9_]{5,32}$")
|
||||
EMAIL_REGEX = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
||||
|
||||
|
||||
def _format_traffic_period(strategy: Optional[str], get_text: Callable[..., 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
|
||||
|
||||
|
||||
def _format_used_with_period(get_text: Callable[..., str], 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)
|
||||
|
||||
|
||||
async def _find_user_by_admin_input(
|
||||
session: AsyncSession,
|
||||
input_text: str,
|
||||
) -> Optional[User]:
|
||||
if input_text.isdigit() or (input_text.startswith("-") and input_text[1:].isdigit()):
|
||||
try:
|
||||
return await user_dal.get_user_by_id(session, int(input_text))
|
||||
except ValueError:
|
||||
return None
|
||||
if EMAIL_REGEX.match(input_text):
|
||||
return await user_dal.get_user_by_email(session, input_text)
|
||||
if input_text.startswith("@") and USERNAME_REGEX.match(input_text[1:]):
|
||||
return await user_dal.get_user_by_username(session, input_text[1:])
|
||||
if USERNAME_REGEX.match(input_text):
|
||||
return await user_dal.get_user_by_username(session, input_text)
|
||||
return None
|
||||
|
||||
|
||||
async def users_list_handler(callback: types.CallbackQuery,
|
||||
@@ -269,10 +230,6 @@ async def format_user_card(user: User, session: AsyncSession,
|
||||
card_parts.append(f"{_('admin_user_id_label')} {hcode(str(user.user_id))}")
|
||||
card_parts.append(f"{_('admin_user_name_label')} {hcode(user_name)}")
|
||||
card_parts.append(f"{_('admin_user_username_label')} {hcode(username_display)}")
|
||||
if user.email:
|
||||
card_parts.append(f"{_('admin_user_email_label')} {hcode(user.email)}")
|
||||
if user.telegram_id and int(user.telegram_id) != int(user.user_id):
|
||||
card_parts.append(f"{_('admin_user_telegram_id_label')} {hcode(str(user.telegram_id))}")
|
||||
card_parts.append(f"{_('admin_user_language_label')} {hcode(user.language_code or na_value)}")
|
||||
card_parts.append(f"{_('admin_user_registration_label')} {hcode(registration_date)}")
|
||||
|
||||
@@ -306,22 +263,10 @@ async def format_user_card(user: User, session: AsyncSession,
|
||||
|
||||
traffic_limit = subscription_details.get('traffic_limit_bytes')
|
||||
traffic_used = subscription_details.get('traffic_used_bytes')
|
||||
traffic_strategy = subscription_details.get('traffic_limit_strategy')
|
||||
period_label = _format_traffic_period(traffic_strategy, _)
|
||||
if traffic_used is not None or traffic_limit is not None:
|
||||
used_display = _("traffic_na")
|
||||
if traffic_used is not None:
|
||||
traffic_used_gb = traffic_used / (1024**3)
|
||||
used_display = f"{traffic_used_gb:.2f}GB"
|
||||
used_display = _format_used_with_period(_, used_display, period_label)
|
||||
|
||||
if traffic_limit:
|
||||
traffic_limit_gb = traffic_limit / (1024**3)
|
||||
limit_display = f"{traffic_limit_gb:.2f}GB"
|
||||
else:
|
||||
limit_display = _("traffic_unlimited")
|
||||
|
||||
card_parts.append(f"{_('admin_user_traffic_label')} {hcode(f'{used_display} / {limit_display}')}")
|
||||
if traffic_limit and traffic_used is not None:
|
||||
traffic_limit_gb = traffic_limit / (1024**3)
|
||||
traffic_used_gb = traffic_used / (1024**3)
|
||||
card_parts.append(f"{_('admin_user_traffic_label')} {hcode(f'{traffic_used_gb:.2f}GB / {traffic_limit_gb:.2f}GB')}")
|
||||
else:
|
||||
card_parts.append(f"{_('admin_user_subscription_label')} {hcode(_('admin_user_subscription_none'))}")
|
||||
except Exception as e:
|
||||
@@ -384,7 +329,18 @@ async def process_user_search_handler(message: types.Message, state: FSMContext,
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
input_text = message.text.strip() if message.text else ""
|
||||
user_model = await _find_user_by_admin_input(session, input_text)
|
||||
user_model: Optional[User] = None
|
||||
|
||||
# Try to find user by ID or username
|
||||
if input_text.isdigit():
|
||||
try:
|
||||
user_model = await user_dal.get_user_by_id(session, int(input_text))
|
||||
except ValueError:
|
||||
pass
|
||||
elif input_text.startswith("@") and USERNAME_REGEX.match(input_text[1:]):
|
||||
user_model = await user_dal.get_user_by_username(session, input_text[1:])
|
||||
elif USERNAME_REGEX.match(input_text):
|
||||
user_model = await user_dal.get_user_by_username(session, input_text)
|
||||
|
||||
if not user_model:
|
||||
await message.answer(_(
|
||||
@@ -1189,7 +1145,18 @@ async def process_ban_user_handler(message: types.Message, state: FSMContext,
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
input_text = message.text.strip() if message.text else ""
|
||||
user_model = await _find_user_by_admin_input(session, input_text)
|
||||
user_model: Optional[User] = None
|
||||
|
||||
# Try to find user by ID or username
|
||||
if input_text.isdigit():
|
||||
try:
|
||||
user_model = await user_dal.get_user_by_id(session, int(input_text))
|
||||
except ValueError:
|
||||
pass
|
||||
elif input_text.startswith("@") and USERNAME_REGEX.match(input_text[1:]):
|
||||
user_model = await user_dal.get_user_by_username(session, input_text[1:])
|
||||
elif USERNAME_REGEX.match(input_text):
|
||||
user_model = await user_dal.get_user_by_username(session, input_text)
|
||||
|
||||
if not user_model:
|
||||
await message.answer(_(
|
||||
@@ -1245,7 +1212,18 @@ async def process_unban_user_handler(message: types.Message, state: FSMContext,
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
input_text = message.text.strip() if message.text else ""
|
||||
user_model = await _find_user_by_admin_input(session, input_text)
|
||||
user_model: Optional[User] = None
|
||||
|
||||
# Try to find user by ID or username
|
||||
if input_text.isdigit():
|
||||
try:
|
||||
user_model = await user_dal.get_user_by_id(session, int(input_text))
|
||||
except ValueError:
|
||||
pass
|
||||
elif input_text.startswith("@") and USERNAME_REGEX.match(input_text[1:]):
|
||||
user_model = await user_dal.get_user_by_username(session, input_text[1:])
|
||||
elif USERNAME_REGEX.match(input_text):
|
||||
user_model = await user_dal.get_user_by_username(session, input_text)
|
||||
|
||||
if not user_model:
|
||||
await message.answer(_(
|
||||
|
||||
+427
-200
@@ -1,6 +1,5 @@
|
||||
import logging
|
||||
import json
|
||||
import asyncio
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
@@ -25,22 +24,10 @@ 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,
|
||||
@@ -49,7 +36,8 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
||||
panel_service: PanelApiService,
|
||||
subscription_service: SubscriptionService,
|
||||
referral_service: ReferralService,
|
||||
lknpd_service: Optional[LknpdService] = None):
|
||||
yookassa_service: Optional[YooKassaService] = None,
|
||||
lknpd_service: Optional[LknpdService] = None) -> bool:
|
||||
metadata = payment_info_from_webhook.get("metadata", {})
|
||||
user_id_str = metadata.get("user_id")
|
||||
subscription_months_str = metadata.get("subscription_months")
|
||||
@@ -60,80 +48,135 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
||||
auto_renew_subscription_id_str = metadata.get(
|
||||
"auto_renew_for_subscription_id")
|
||||
|
||||
# For auto-renew payments, payment_db_id may be absent. In that case,
|
||||
# we will create/ensure a payment record idempotently using provider payment id.
|
||||
if (
|
||||
not user_id_str
|
||||
or (not subscription_months_str and not traffic_gb_str)
|
||||
or (not payment_db_id_str and not auto_renew_subscription_id_str)
|
||||
or not payment_db_id_str
|
||||
):
|
||||
logging.error(
|
||||
f"Missing crucial metadata for payment: {payment_info_from_webhook.get('id')}, metadata: {metadata}"
|
||||
)
|
||||
return
|
||||
return False
|
||||
|
||||
db_user = None
|
||||
payment_before_update = None
|
||||
try:
|
||||
user_id = int(user_id_str)
|
||||
subscription_months = float(subscription_months_str or 0)
|
||||
traffic_amount_gb = float(traffic_gb_str) if traffic_gb_str else subscription_months
|
||||
payment_db_id = int(
|
||||
payment_db_id_str) if payment_db_id_str and payment_db_id_str.isdigit() else None
|
||||
is_auto_renew = bool(auto_renew_subscription_id_str and not payment_db_id and sale_mode != "traffic")
|
||||
if not payment_db_id_str.isdigit():
|
||||
logging.error(
|
||||
"Invalid payment_db_id metadata for payment %s: %s",
|
||||
payment_info_from_webhook.get("id"),
|
||||
payment_db_id_str,
|
||||
)
|
||||
return False
|
||||
payment_db_id = int(payment_db_id_str)
|
||||
is_auto_renew = bool(auto_renew_subscription_id_str 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:
|
||||
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 False
|
||||
if payment_record.user_id != user_id:
|
||||
logging.error(
|
||||
"Payment ownership mismatch for payment %s: metadata user_id=%s, db user_id=%s",
|
||||
payment_db_id,
|
||||
user_id,
|
||||
payment_record.user_id,
|
||||
)
|
||||
return False
|
||||
|
||||
# Provider-backed verification (defense-in-depth): verify actual YooKassa payment state
|
||||
if yk_payment_id_from_hook and yookassa_service and yookassa_service.configured:
|
||||
provider_payment_info = await yookassa_service.get_payment_info(yk_payment_id_from_hook)
|
||||
if not provider_payment_info:
|
||||
logging.error(
|
||||
"YooKassa webhook verification failed: payment %s not found via provider API",
|
||||
yk_payment_id_from_hook,
|
||||
)
|
||||
return False
|
||||
|
||||
provider_status = str(provider_payment_info.get("status") or "")
|
||||
provider_paid = bool(provider_payment_info.get("paid"))
|
||||
if provider_status != "succeeded" or not provider_paid:
|
||||
logging.error(
|
||||
"YooKassa webhook verification failed: payment %s status/paid mismatch (status=%s, paid=%s)",
|
||||
yk_payment_id_from_hook,
|
||||
provider_status,
|
||||
provider_paid,
|
||||
)
|
||||
return False
|
||||
|
||||
provider_metadata_raw = provider_payment_info.get("metadata") or {}
|
||||
provider_metadata = provider_metadata_raw if isinstance(provider_metadata_raw, dict) else {}
|
||||
if str(provider_metadata.get("user_id") or "") != str(user_id):
|
||||
logging.error(
|
||||
"YooKassa webhook verification failed: user_id mismatch for payment %s (provider=%s, expected=%s)",
|
||||
yk_payment_id_from_hook,
|
||||
provider_metadata.get("user_id"),
|
||||
user_id,
|
||||
)
|
||||
return False
|
||||
provider_payment_db_id = str(provider_metadata.get("payment_db_id") or "").strip()
|
||||
if provider_payment_db_id != str(payment_db_id):
|
||||
logging.error(
|
||||
"YooKassa webhook verification failed: payment_db_id mismatch for payment %s (provider=%s, expected=%s)",
|
||||
yk_payment_id_from_hook,
|
||||
provider_metadata.get("payment_db_id"),
|
||||
payment_db_id,
|
||||
)
|
||||
return False
|
||||
|
||||
try:
|
||||
if not yk_payment_id_from_hook:
|
||||
provider_amount = float(provider_payment_info.get("amount_value") or 0.0)
|
||||
if round(provider_amount, 2) != round(payment_value, 2):
|
||||
logging.error(
|
||||
"Auto-renew webhook missing YooKassa payment id; cannot ensure payment record."
|
||||
"YooKassa webhook verification failed: amount mismatch for payment %s (payload %.2f vs provider %.2f)",
|
||||
yk_payment_id_from_hook,
|
||||
payment_value,
|
||||
provider_amount,
|
||||
)
|
||||
return
|
||||
from db.dal import payment_dal as _payment_dal
|
||||
payment_record = await _payment_dal.get_payment_by_provider_payment_id(
|
||||
session, yk_payment_id_from_hook
|
||||
)
|
||||
if not payment_record:
|
||||
payment_record = await _payment_dal.ensure_payment_with_provider_id(
|
||||
session,
|
||||
user_id=user_id,
|
||||
amount=payment_value,
|
||||
currency=amount_data.get("currency", settings.DEFAULT_CURRENCY_SYMBOL),
|
||||
months=months_for_record or 1,
|
||||
description=payment_info_from_webhook.get(
|
||||
"description") or f"Auto-renewal for {months_for_record or subscription_months} months",
|
||||
provider="yookassa",
|
||||
provider_payment_id=yk_payment_id_from_hook,
|
||||
return False
|
||||
if payment_record and round(float(payment_record.amount), 2) != round(provider_amount, 2):
|
||||
logging.error(
|
||||
"YooKassa webhook verification failed: DB amount mismatch for payment %s (db %.2f vs provider %.2f)",
|
||||
payment_record.payment_id,
|
||||
float(payment_record.amount),
|
||||
provider_amount,
|
||||
)
|
||||
payment_db_id = payment_record.payment_id
|
||||
except Exception as e_ensure:
|
||||
return False
|
||||
provider_currency = str(provider_payment_info.get("amount_currency") or "").upper()
|
||||
if payment_record and provider_currency and str(payment_record.currency or "").upper() != provider_currency:
|
||||
logging.error(
|
||||
"YooKassa webhook verification failed: currency mismatch for payment %s (db=%s, provider=%s)",
|
||||
payment_record.payment_id,
|
||||
payment_record.currency,
|
||||
provider_currency,
|
||||
)
|
||||
return False
|
||||
except Exception as e_amount_verify:
|
||||
logging.error(
|
||||
f"Failed to ensure payment record for auto-renew webhook (YK {payment_info_from_webhook.get('id')}): {e_ensure}",
|
||||
exc_info=True,
|
||||
"YooKassa webhook verification failed for payment %s: cannot validate amount (%s)",
|
||||
yk_payment_id_from_hook,
|
||||
e_amount_verify,
|
||||
)
|
||||
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
|
||||
return False
|
||||
|
||||
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
|
||||
return True
|
||||
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
if not db_user:
|
||||
@@ -145,7 +188,7 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
||||
session, payment_db_id, "failed_user_not_found",
|
||||
payment_info_from_webhook.get("id"))
|
||||
|
||||
return
|
||||
return False
|
||||
|
||||
except (TypeError, ValueError) as e:
|
||||
logging.error(
|
||||
@@ -161,16 +204,62 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
||||
logging.error(
|
||||
f"Failed to update payment status after metadata error: {e_upd}"
|
||||
)
|
||||
return
|
||||
return False
|
||||
|
||||
try:
|
||||
yk_payment_id_from_hook = payment_info_from_webhook.get("id")
|
||||
payment_before_update = None
|
||||
provider_payment_id = str(yk_payment_id_from_hook or "").strip()
|
||||
if not provider_payment_id:
|
||||
raise ValueError(
|
||||
f"Missing provider payment id in successful YooKassa webhook for payment {payment_db_id}"
|
||||
)
|
||||
|
||||
if payment_db_id is not None:
|
||||
payment_before_update = await payment_dal.get_payment_by_db_id(
|
||||
session,
|
||||
payment_db_id,
|
||||
)
|
||||
if payment_before_update and payment_before_update.status == "succeeded":
|
||||
logging.info(
|
||||
"YooKassa webhook ignored: payment %s already succeeded (db_id=%s)",
|
||||
yk_payment_id_from_hook,
|
||||
payment_db_id,
|
||||
)
|
||||
return True
|
||||
|
||||
claimed_for_processing = await payment_dal.mark_provider_payment_processing_once(
|
||||
session,
|
||||
payment_db_id,
|
||||
provider_payment_id,
|
||||
expected_status_prefix="pending",
|
||||
)
|
||||
if not claimed_for_processing:
|
||||
payment_after_claim = await payment_dal.get_payment_by_db_id(
|
||||
session,
|
||||
payment_db_id,
|
||||
)
|
||||
if payment_after_claim and payment_after_claim.status == "succeeded":
|
||||
logging.info(
|
||||
"YooKassa webhook ignored: payment %s already succeeded after claim attempt",
|
||||
payment_db_id,
|
||||
)
|
||||
return True
|
||||
|
||||
# Another transaction is processing this payment now.
|
||||
if payment_after_claim and payment_after_claim.status == "processing":
|
||||
logging.info(
|
||||
"YooKassa webhook: payment %s is already being processed by another worker",
|
||||
payment_db_id,
|
||||
)
|
||||
return False
|
||||
|
||||
logging.warning(
|
||||
"YooKassa webhook: payment %s cannot be claimed for processing (status=%s)",
|
||||
payment_db_id,
|
||||
payment_after_claim.status if payment_after_claim else None,
|
||||
)
|
||||
return False
|
||||
|
||||
should_send_lknpd_receipt = bool(
|
||||
lknpd_service
|
||||
and lknpd_service.configured
|
||||
@@ -202,7 +291,7 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
||||
else:
|
||||
display_last4 = None
|
||||
else:
|
||||
# Wallets, SBP, etc. — use provided title/type; no last4
|
||||
# Wallets, SBP, etc. - use provided title/type; no last4
|
||||
display_network = title or (pm_type.upper() if pm_type else "Payment method")
|
||||
display_last4 = None
|
||||
|
||||
@@ -227,37 +316,58 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
||||
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,
|
||||
)
|
||||
try:
|
||||
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,
|
||||
)
|
||||
except Exception:
|
||||
previous_status = payment_before_update.status if payment_before_update else "pending_yookassa"
|
||||
await payment_dal.rollback_provider_payment_processing(
|
||||
session,
|
||||
payment_db_id,
|
||||
rollback_status=previous_status,
|
||||
provider_payment_id=provider_payment_id,
|
||||
)
|
||||
logging.exception(
|
||||
"Failed to activate subscription for payment %s; rolled back payment status for retry",
|
||||
payment_db_id,
|
||||
)
|
||||
return False
|
||||
|
||||
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,
|
||||
new_status=payment_info_from_webhook.get("status", "succeeded"),
|
||||
yk_payment_id=yk_payment_id_from_hook)
|
||||
if not updated_payment_record:
|
||||
logging.error(
|
||||
f"Failed to update payment record {payment_db_id} for yk_id {yk_payment_id_from_hook}"
|
||||
previous_status = payment_before_update.status if payment_before_update else "pending_yookassa"
|
||||
await payment_dal.rollback_provider_payment_processing(
|
||||
session,
|
||||
payment_db_id,
|
||||
rollback_status=previous_status,
|
||||
provider_payment_id=provider_payment_id,
|
||||
)
|
||||
raise Exception(
|
||||
f"DB Error: Could not update payment record {payment_db_id}")
|
||||
return False
|
||||
|
||||
marked = await payment_dal.mark_provider_payment_succeeded_once(
|
||||
session,
|
||||
payment_db_id,
|
||||
provider_payment_id,
|
||||
)
|
||||
if not marked:
|
||||
logging.warning(
|
||||
"YooKassa webhook: payment %s could not be atomically marked succeeded after activation",
|
||||
payment_db_id,
|
||||
)
|
||||
return False
|
||||
|
||||
base_subscription_end_date = activation_details['end_date']
|
||||
final_end_date_for_user = base_subscription_end_date
|
||||
@@ -323,7 +433,7 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
||||
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 "—",
|
||||
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(
|
||||
@@ -405,7 +515,7 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
||||
await notification_service.notify_payment_received(
|
||||
user_id=user_id,
|
||||
amount=payment_value,
|
||||
currency=settings.DEFAULT_CURRENCY_SYMBOL,
|
||||
currency="RUB",
|
||||
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,
|
||||
@@ -414,6 +524,8 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to send payment notification: {e}")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e_process:
|
||||
logging.error(
|
||||
f"Error during process_successful_payment main try block for user {user_id}: {e_process}",
|
||||
@@ -480,24 +592,21 @@ async def yookassa_webhook_route(request: web.Request):
|
||||
i18n_instance: JsonI18n = request.app['i18n']
|
||||
settings: Settings = request.app['settings']
|
||||
panel_service: PanelApiService = request.app['panel_service']
|
||||
yookassa_service: Optional[YooKassaService] = request.app.get('yookassa_service')
|
||||
subscription_service: SubscriptionService = request.app[
|
||||
'subscription_service']
|
||||
referral_service: ReferralService = request.app['referral_service']
|
||||
lknpd_service: Optional[LknpdService] = request.app.get('lknpd_service')
|
||||
async_session_factory: sessionmaker = request.app[
|
||||
'async_session_factory']
|
||||
except KeyError:
|
||||
logging.exception(
|
||||
"KeyError accessing app context in yookassa_webhook_route.")
|
||||
except KeyError as e_app_ctx:
|
||||
logging.error(
|
||||
f"KeyError accessing app context in yookassa_webhook_route: {e_app_ctx}.",
|
||||
exc_info=True)
|
||||
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()
|
||||
|
||||
@@ -515,7 +624,7 @@ async def yookassa_webhook_route(request: web.Request):
|
||||
logging.error(
|
||||
f"YooKassa webhook payment {payment_data_from_notification.id} lacks metadata. Cannot process."
|
||||
)
|
||||
return web.Response(status=200, text="ok_error_no_metadata")
|
||||
return web.Response(status=200, text="yookassa_missing_metadata")
|
||||
|
||||
# Safely extract payment_method details (SDK objects may not have to_dict)
|
||||
pm_obj = getattr(payment_data_from_notification, 'payment_method', None)
|
||||
@@ -570,122 +679,240 @@ async def yookassa_webhook_route(request: web.Request):
|
||||
"payment_method": pm_dict,
|
||||
}
|
||||
|
||||
async with payment_processing_lock:
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
if notification_object.event == YOOKASSA_EVENT_PAYMENT_SUCCEEDED:
|
||||
if payment_dict_for_processing.get(
|
||||
"paid") and payment_dict_for_processing.get(
|
||||
"status") == "succeeded":
|
||||
await process_successful_payment(
|
||||
session, bot, payment_dict_for_processing,
|
||||
i18n_instance, settings, panel_service,
|
||||
subscription_service, referral_service,
|
||||
lknpd_service)
|
||||
await session.commit()
|
||||
else:
|
||||
logging.warning(
|
||||
f"Payment Succeeded event for {payment_dict_for_processing.get('id')} "
|
||||
f"but data not as expected: status='{payment_dict_for_processing.get('status')}', "
|
||||
f"paid='{payment_dict_for_processing.get('paid')}'"
|
||||
)
|
||||
elif notification_object.event == YOOKASSA_EVENT_PAYMENT_CANCELED:
|
||||
await process_cancelled_payment(
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
# Defense-in-depth: for state mutations beyond succeeded, verify against provider API.
|
||||
if notification_object.event in {
|
||||
YOOKASSA_EVENT_PAYMENT_CANCELED,
|
||||
YOOKASSA_EVENT_PAYMENT_WAITING_FOR_CAPTURE,
|
||||
}:
|
||||
if not yookassa_service or not yookassa_service.configured:
|
||||
logging.critical(
|
||||
"YooKassa webhook rejected: verification service is not configured for event %s (payment_id=%s)",
|
||||
notification_object.event,
|
||||
payment_dict_for_processing.get("id"),
|
||||
)
|
||||
return web.Response(status=503, text="yookassa_verification_required")
|
||||
|
||||
provider_payment_info = await yookassa_service.get_payment_info(
|
||||
payment_dict_for_processing.get("id")
|
||||
)
|
||||
if not provider_payment_info:
|
||||
logging.error(
|
||||
"YooKassa webhook verification failed: payment %s not found via provider API",
|
||||
payment_dict_for_processing.get("id"),
|
||||
)
|
||||
return web.Response(status=503, text="yookassa_verification_failed")
|
||||
|
||||
provider_status = str(provider_payment_info.get("status") or "")
|
||||
provider_paid = bool(provider_payment_info.get("paid"))
|
||||
provider_metadata_raw = provider_payment_info.get("metadata") or {}
|
||||
provider_metadata = provider_metadata_raw if isinstance(provider_metadata_raw, dict) else {}
|
||||
|
||||
payment_dict_for_processing["status"] = provider_status or payment_dict_for_processing.get("status")
|
||||
payment_dict_for_processing["paid"] = provider_paid
|
||||
payment_dict_for_processing["metadata"] = dict(provider_metadata)
|
||||
|
||||
provider_amount_value = provider_payment_info.get("amount_value")
|
||||
provider_amount_currency = provider_payment_info.get("amount_currency")
|
||||
if provider_amount_value is not None and provider_amount_currency:
|
||||
payment_dict_for_processing["amount"] = {
|
||||
"value": str(provider_amount_value),
|
||||
"currency": str(provider_amount_currency),
|
||||
}
|
||||
|
||||
provider_pm = provider_payment_info.get("payment_method")
|
||||
if isinstance(provider_pm, dict) and provider_pm.get("id"):
|
||||
# Use provider payment_method as authoritative.
|
||||
payment_dict_for_processing["payment_method"] = provider_pm
|
||||
|
||||
if notification_object.event == YOOKASSA_EVENT_PAYMENT_SUCCEEDED:
|
||||
if not yookassa_service or not yookassa_service.configured:
|
||||
logging.critical(
|
||||
"YooKassa webhook rejected: verification service is not configured for succeeded event (payment_id=%s)",
|
||||
payment_dict_for_processing.get("id"),
|
||||
)
|
||||
return web.Response(status=503, text="yookassa_verification_required")
|
||||
|
||||
if payment_dict_for_processing.get(
|
||||
"paid") and payment_dict_for_processing.get(
|
||||
"status") == "succeeded":
|
||||
processed = await process_successful_payment(
|
||||
session, bot, payment_dict_for_processing,
|
||||
i18n_instance, settings)
|
||||
i18n_instance, settings, panel_service,
|
||||
subscription_service, referral_service,
|
||||
yookassa_service,
|
||||
lknpd_service)
|
||||
if not processed:
|
||||
metadata_for_result = payment_dict_for_processing.get("metadata") or {}
|
||||
payment_db_id_raw = metadata_for_result.get("payment_db_id")
|
||||
payment_db_id_for_check = None
|
||||
if isinstance(payment_db_id_raw, int):
|
||||
payment_db_id_for_check = payment_db_id_raw
|
||||
elif isinstance(payment_db_id_raw, str) and payment_db_id_raw.isdigit():
|
||||
payment_db_id_for_check = int(payment_db_id_raw)
|
||||
|
||||
terminal_failure_recorded = False
|
||||
if payment_db_id_for_check is not None:
|
||||
payment_after_processing = await payment_dal.get_payment_by_db_id(
|
||||
session,
|
||||
payment_db_id_for_check,
|
||||
)
|
||||
terminal_failure_recorded = bool(
|
||||
payment_after_processing
|
||||
and isinstance(payment_after_processing.status, str)
|
||||
and payment_after_processing.status.startswith("failed")
|
||||
)
|
||||
|
||||
if terminal_failure_recorded:
|
||||
try:
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logging.exception(
|
||||
"Failed to commit failure status for YooKassa payment %s",
|
||||
payment_dict_for_processing.get("id"),
|
||||
)
|
||||
return web.Response(status=503, text="yookassa_processing_failed_retry")
|
||||
return web.Response(status=200, text="ok")
|
||||
|
||||
await session.rollback()
|
||||
logging.warning(
|
||||
"YooKassa payment %s processing returned non-terminal failure; responding 503 for retry",
|
||||
payment_dict_for_processing.get("id"),
|
||||
)
|
||||
return web.Response(status=503, text="yookassa_processing_failed_retry")
|
||||
await session.commit()
|
||||
elif notification_object.event == YOOKASSA_EVENT_PAYMENT_WAITING_FOR_CAPTURE:
|
||||
# Bind-only flow: save method and cancel auth if metadata has bind_only
|
||||
metadata = payment_dict_for_processing.get("metadata", {}) or {}
|
||||
if settings.yookassa_autopayments_active and metadata.get("bind_only") == "1":
|
||||
try:
|
||||
user_id_str = metadata.get("user_id")
|
||||
if user_id_str and user_id_str.isdigit():
|
||||
user_id = int(user_id_str)
|
||||
payment_method = payment_dict_for_processing.get("payment_method")
|
||||
if isinstance(payment_method, dict) and payment_method.get("id"):
|
||||
pm_type = payment_method.get("type")
|
||||
title = payment_method.get("title")
|
||||
card = payment_method.get("card") or {}
|
||||
account_number = payment_method.get("account_number") or payment_method.get("account")
|
||||
display_network = None
|
||||
display_last4 = None
|
||||
if (pm_type or "").lower() in {"bank_card", "bank-card", "card"}:
|
||||
display_network = card.get("card_type") or title or "Card"
|
||||
display_last4 = card.get("last4")
|
||||
elif (pm_type or "").lower() in {"yoo_money", "yoomoney", "yoo-money", "wallet"}:
|
||||
# Normalize wallet display name to avoid leaking full account from title
|
||||
display_network = "YooMoney"
|
||||
if isinstance(account_number, str) and len(account_number) >= 4:
|
||||
display_last4 = account_number[-4:]
|
||||
else:
|
||||
display_last4 = None
|
||||
else:
|
||||
display_network = title or (pm_type.upper() if pm_type else "Payment method")
|
||||
display_last4 = None
|
||||
await user_billing_dal.upsert_yk_payment_method(
|
||||
else:
|
||||
logging.warning(
|
||||
f"Payment Succeeded event for {payment_dict_for_processing.get('id')} "
|
||||
f"but data not as expected: status='{payment_dict_for_processing.get('status')}', "
|
||||
f"paid='{payment_dict_for_processing.get('paid')}'"
|
||||
)
|
||||
await session.rollback()
|
||||
return web.Response(status=503, text="yookassa_invalid_succeeded_payload")
|
||||
elif notification_object.event == YOOKASSA_EVENT_PAYMENT_CANCELED:
|
||||
if payment_dict_for_processing.get("status") not in {"canceled", "cancelled"}:
|
||||
logging.error(
|
||||
"YooKassa webhook rejected: canceled event status mismatch for payment %s (status=%s)",
|
||||
payment_dict_for_processing.get("id"),
|
||||
payment_dict_for_processing.get("status"),
|
||||
)
|
||||
return web.Response(status=503, text="yookassa_invalid_canceled_payload")
|
||||
await process_cancelled_payment(
|
||||
session, bot, payment_dict_for_processing,
|
||||
i18n_instance, settings)
|
||||
await session.commit()
|
||||
elif notification_object.event == YOOKASSA_EVENT_PAYMENT_WAITING_FOR_CAPTURE:
|
||||
# Bind-only flow: save method and cancel auth if metadata has bind_only
|
||||
metadata = payment_dict_for_processing.get("metadata", {}) or {}
|
||||
if settings.yookassa_autopayments_active and metadata.get("bind_only") == "1":
|
||||
if payment_dict_for_processing.get("status") != "waiting_for_capture":
|
||||
logging.error(
|
||||
"YooKassa webhook rejected: waiting_for_capture event status mismatch for payment %s (status=%s)",
|
||||
payment_dict_for_processing.get("id"),
|
||||
payment_dict_for_processing.get("status"),
|
||||
)
|
||||
return web.Response(status=503, text="yookassa_invalid_waiting_payload")
|
||||
try:
|
||||
user_id_str = metadata.get("user_id")
|
||||
if user_id_str and user_id_str.isdigit():
|
||||
user_id = int(user_id_str)
|
||||
payment_method = payment_dict_for_processing.get("payment_method")
|
||||
if isinstance(payment_method, dict) and payment_method.get("id"):
|
||||
pm_type = payment_method.get("type")
|
||||
title = payment_method.get("title")
|
||||
|
||||
# Support both webhook shape (nested card) and provider shape (card_last4)
|
||||
last4_val = None
|
||||
card = payment_method.get("card") or {}
|
||||
if isinstance(card, dict) and card.get("last4"):
|
||||
last4_val = card.get("last4")
|
||||
if not last4_val:
|
||||
last4_val = payment_method.get("card_last4")
|
||||
|
||||
display_network = None
|
||||
display_last4 = None
|
||||
if (pm_type or "").lower() in {"bank_card", "bank-card", "card"}:
|
||||
display_network = title or "Card"
|
||||
display_last4 = last4_val
|
||||
elif (pm_type or "").lower() in {"yoo_money", "yoomoney", "yoo-money", "wallet"}:
|
||||
display_network = "YooMoney"
|
||||
display_last4 = last4_val
|
||||
else:
|
||||
display_network = title or (pm_type.upper() if pm_type else "Payment method")
|
||||
display_last4 = last4_val
|
||||
|
||||
await user_billing_dal.upsert_yk_payment_method(
|
||||
session,
|
||||
user_id=user_id,
|
||||
payment_method_id=payment_method.get("id"),
|
||||
card_last4=display_last4,
|
||||
card_network=display_network,
|
||||
)
|
||||
await session.commit()
|
||||
# Save multi-card entry and mark default if first
|
||||
try:
|
||||
from db.dal import user_billing_dal as ub
|
||||
await ub.upsert_user_payment_method(
|
||||
session,
|
||||
user_id=user_id,
|
||||
payment_method_id=payment_method.get("id"),
|
||||
provider_payment_method_id=payment_method.get("id"),
|
||||
provider="yookassa",
|
||||
card_last4=display_last4,
|
||||
card_network=display_network,
|
||||
set_default=True,
|
||||
)
|
||||
await session.commit()
|
||||
# Save multi-card entry and mark default if first
|
||||
try:
|
||||
from db.dal import user_billing_dal as ub
|
||||
await ub.upsert_user_payment_method(
|
||||
session,
|
||||
user_id=user_id,
|
||||
provider_payment_method_id=payment_method.get("id"),
|
||||
provider="yookassa",
|
||||
card_last4=display_last4,
|
||||
card_network=display_network,
|
||||
set_default=True,
|
||||
)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
# Notify user about successful binding with Back button
|
||||
try:
|
||||
# Use user's DB language for bind success notification
|
||||
i18n_lang = settings.DEFAULT_LANGUAGE
|
||||
from db.dal import user_dal
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
if db_user and db_user.language_code:
|
||||
i18n_lang = db_user.language_code
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(i18n_lang, key, **kwargs)
|
||||
from bot.keyboards.inline.user_keyboards import get_back_to_payment_methods_keyboard
|
||||
await bot.send_message(
|
||||
chat_id=user_id,
|
||||
text=_("payment_method_bound_success"),
|
||||
reply_markup=get_back_to_payment_methods_keyboard(i18n_lang, i18n_instance)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
# Attempt to cancel the authorization to avoid charge hold
|
||||
try:
|
||||
yk: YooKassaService = request.app.get('yookassa_service')
|
||||
if yk:
|
||||
await yk.cancel_payment(payment_dict_for_processing.get("id"))
|
||||
except Exception:
|
||||
logging.exception("Failed to cancel bind-only payment auth")
|
||||
except Exception:
|
||||
logging.exception("Failed to handle bind-only waiting_for_capture webhook")
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logging.exception(
|
||||
"Error processing YooKassa webhook event '%s' for YK Payment ID %s in DB transaction.",
|
||||
notification_object.event,
|
||||
payment_dict_for_processing.get('id'))
|
||||
return web.Response(
|
||||
status=500, text="internal_processing_error")
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
# Notify user about successful binding with Back button
|
||||
try:
|
||||
# Use user's DB language for bind success notification
|
||||
i18n_lang = settings.DEFAULT_LANGUAGE
|
||||
from db.dal import user_dal
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
if db_user and db_user.language_code:
|
||||
i18n_lang = db_user.language_code
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(i18n_lang, key, **kwargs)
|
||||
from bot.keyboards.inline.user_keyboards import get_back_to_payment_methods_keyboard
|
||||
await bot.send_message(
|
||||
chat_id=user_id,
|
||||
text=_("payment_method_bound_success"),
|
||||
reply_markup=get_back_to_payment_methods_keyboard(i18n_lang, i18n_instance)
|
||||
)
|
||||
except Exception as exc:
|
||||
logging.debug(
|
||||
"Failed to notify user %s about payment method binding: %s",
|
||||
user_id,
|
||||
exc,
|
||||
)
|
||||
# Attempt to cancel the authorization to avoid charge hold
|
||||
try:
|
||||
yk: YooKassaService = request.app.get('yookassa_service')
|
||||
if yk:
|
||||
await yk.cancel_payment(payment_dict_for_processing.get("id"))
|
||||
except Exception:
|
||||
logging.exception("Failed to cancel bind-only payment auth")
|
||||
except Exception:
|
||||
logging.exception("Failed to handle bind-only waiting_for_capture webhook")
|
||||
except Exception as e_webhook_db_processing:
|
||||
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)
|
||||
return web.Response(
|
||||
status=503, text="yookassa_processing_error_retry")
|
||||
|
||||
return web.Response(status=200, text="ok")
|
||||
|
||||
except json.JSONDecodeError:
|
||||
logging.error("YooKassa Webhook: Invalid JSON received.")
|
||||
return web.Response(status=400, text="bad_request_invalid_json")
|
||||
except Exception:
|
||||
logging.exception("YooKassa Webhook general processing error.")
|
||||
return web.Response(status=500, text="internal_error")
|
||||
except Exception as e_general_webhook:
|
||||
logging.error(
|
||||
f"YooKassa Webhook general processing error: {e_general_webhook}",
|
||||
exc_info=True)
|
||||
return web.Response(status=503,
|
||||
text="yookassa_general_error_retry")
|
||||
|
||||
@@ -4,6 +4,7 @@ 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
|
||||
@@ -15,7 +16,6 @@ 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,18 +35,15 @@ 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 safe_answer_callback(callback, "Language service error.", show_alert=True)
|
||||
await callback.answer("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 safe_answer_callback(
|
||||
callback,
|
||||
_("error_occurred_processing_request"),
|
||||
show_alert=True,
|
||||
)
|
||||
await callback.answer(_("error_occurred_processing_request"),
|
||||
show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
@@ -61,7 +58,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 safe_answer_callback(callback)
|
||||
await callback.answer()
|
||||
await state.set_state(UserPromoStates.waiting_for_promo_code)
|
||||
logging.info(
|
||||
f"User {callback.from_user.id} entered state UserPromoStates.waiting_for_promo_code. "
|
||||
@@ -122,41 +119,93 @@ 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}")
|
||||
|
||||
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,
|
||||
)
|
||||
response_to_user_text = _("promo_code_not_found",
|
||||
code=hcode(code_input.upper()))
|
||||
reply_markup = get_back_to_main_menu_markup(current_lang, i18n)
|
||||
else:
|
||||
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
|
||||
)
|
||||
# Try as BONUS code first (existing behavior)
|
||||
success, result = await promo_code_service.apply_promo_code(
|
||||
session, user.id, code_input, current_lang)
|
||||
|
||||
if success:
|
||||
# Bonus code success
|
||||
await session.commit()
|
||||
logging.info(
|
||||
f"Bonus 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:
|
||||
# Bonus code failed, try as DISCOUNT code
|
||||
success_discount, result_discount = await promo_code_service.apply_discount_promo_code(
|
||||
session, user.id, code_input, current_lang
|
||||
)
|
||||
|
||||
if success_discount:
|
||||
# Discount code success
|
||||
await session.commit()
|
||||
logging.info(
|
||||
f"Discount promo code '{code_input}' successfully applied for user {user.id}."
|
||||
)
|
||||
discount_pct = result_discount # Returns percentage
|
||||
|
||||
# Send notification about discount promo activation
|
||||
if settings.LOG_PROMO_ACTIVATIONS:
|
||||
try:
|
||||
from bot.services.notification_service import NotificationService
|
||||
notification_service = NotificationService(bot, settings, i18n)
|
||||
await notification_service.notify_discount_promo_activation(
|
||||
user_id=user.id,
|
||||
promo_code=code_input.upper(),
|
||||
discount_percentage=discount_pct,
|
||||
username=user.username
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to send discount promo activation notification: {e}")
|
||||
|
||||
response_to_user_text = _(
|
||||
"discount_promo_code_applied_success",
|
||||
code=hcode(code_input.upper()),
|
||||
discount=discount_pct
|
||||
)
|
||||
reply_markup = get_back_to_main_menu_markup(current_lang, i18n)
|
||||
else:
|
||||
# Both failed
|
||||
await session.rollback()
|
||||
logging.info(
|
||||
f"Promo code '{code_input}' application failed for user {user.id}. "
|
||||
f"Bonus reason: {result}. Discount reason: {result_discount}"
|
||||
)
|
||||
bonus_not_found_text = _(
|
||||
"promo_code_not_found", code=code_input.upper()
|
||||
)
|
||||
discount_not_found_text = _(
|
||||
"promo_code_not_found_or_not_discount", code=code_input.upper()
|
||||
)
|
||||
if result != bonus_not_found_text and result_discount == discount_not_found_text:
|
||||
response_to_user_text = result
|
||||
else:
|
||||
response_to_user_text = result_discount # Prefer the discount attempt error
|
||||
reply_markup = get_back_to_main_menu_markup(
|
||||
current_lang, i18n
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
response_to_user_text,
|
||||
@@ -179,7 +228,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 safe_answer_callback(callback, "Language error", show_alert=True)
|
||||
await callback.answer("Language error", show_alert=True)
|
||||
return
|
||||
|
||||
logging.info(
|
||||
@@ -198,8 +247,5 @@ async def cancel_promo_input_via_button(
|
||||
else:
|
||||
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
await safe_answer_callback(
|
||||
callback,
|
||||
_("promo_input_cancelled_short"),
|
||||
show_alert=False,
|
||||
)
|
||||
await callback.answer(_("promo_input_cancelled_short"),
|
||||
show_alert=False)
|
||||
|
||||
@@ -2,11 +2,9 @@ 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
|
||||
@@ -45,6 +43,15 @@ async def referral_command_handler(event: Union[types.Message,
|
||||
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
if not settings.REFERRAL_ENABLED:
|
||||
await target_message_obj.answer(
|
||||
_("referral_no_bonuses_configured"),
|
||||
reply_markup=get_back_to_main_menu_markup(current_lang, i18n),
|
||||
)
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
await event.answer()
|
||||
return
|
||||
|
||||
try:
|
||||
bot_info = await bot.get_me()
|
||||
bot_username = bot_info.username
|
||||
@@ -105,16 +112,6 @@ 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)
|
||||
@@ -148,6 +145,9 @@ async def referral_action_handler(callback: types.CallbackQuery, settings: Setti
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
if action == "share_message":
|
||||
if not settings.REFERRAL_ENABLED:
|
||||
await callback.answer(_("referral_no_bonuses_configured"), show_alert=True)
|
||||
return
|
||||
try:
|
||||
bot_info = await bot.get_me()
|
||||
bot_username = bot_info.username
|
||||
@@ -179,20 +179,3 @@ 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,
|
||||
)
|
||||
)
|
||||
|
||||
+144
-352
@@ -14,10 +14,8 @@ 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
|
||||
@@ -26,29 +24,10 @@ 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,
|
||||
@@ -69,20 +48,30 @@ async def send_main_menu(target_event: Union[types.Message,
|
||||
if isinstance(target_event, types.CallbackQuery):
|
||||
try:
|
||||
await target_event.answer(err_msg_fallback, show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/start.py: %s", exc)
|
||||
elif isinstance(target_event, types.Message):
|
||||
try:
|
||||
await target_event.answer(err_msg_fallback)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/start.py: %s", exc)
|
||||
return
|
||||
|
||||
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
show_trial_button_in_menu = await should_show_trial_button(
|
||||
settings, subscription_service, session, user_id)
|
||||
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!"
|
||||
)
|
||||
|
||||
text = _(key="main_menu_greeting", user_name=user_full_name)
|
||||
reply_markup = get_main_menu_inline_keyboard(current_lang, i18n, settings,
|
||||
@@ -100,11 +89,8 @@ 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 safe_answer_callback(
|
||||
target_event,
|
||||
_("error_displaying_menu"),
|
||||
show_alert=True,
|
||||
)
|
||||
await target_event.answer(_("error_displaying_menu"),
|
||||
show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
@@ -114,7 +100,10 @@ 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):
|
||||
await safe_answer_callback(target_event)
|
||||
try:
|
||||
await target_event.answer()
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/start.py: %s", exc)
|
||||
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}."
|
||||
@@ -127,72 +116,11 @@ 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_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,
|
||||
)
|
||||
await target_event.answer(
|
||||
_("error_occurred_try_again") if is_edit else None)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/start.py: %s", exc)
|
||||
|
||||
|
||||
async def ensure_required_channel_subscription(
|
||||
@@ -206,8 +134,15 @@ async def ensure_required_channel_subscription(
|
||||
Verify that the user is a member of the required channel (if configured).
|
||||
Returns True when access can proceed, False when user must subscribe first.
|
||||
"""
|
||||
if not settings.REQUIRED_CHANNEL_SUBSCRIBE_TO_USE:
|
||||
return True
|
||||
|
||||
required_channel_id = settings.REQUIRED_CHANNEL_ID
|
||||
if not required_channel_id:
|
||||
logging.warning(
|
||||
"REQUIRED_CHANNEL_SUBSCRIBE_TO_USE is enabled but REQUIRED_CHANNEL_ID is not set. "
|
||||
"Channel gate is skipped."
|
||||
)
|
||||
return True
|
||||
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
@@ -285,13 +220,13 @@ async def ensure_required_channel_subscription(
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
try:
|
||||
await event.answer(error_text, show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/start.py: %s", exc)
|
||||
if message_obj:
|
||||
try:
|
||||
await message_obj.answer(error_text)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/start.py: %s", exc)
|
||||
else:
|
||||
await event.answer(error_text)
|
||||
return False
|
||||
@@ -306,13 +241,13 @@ async def ensure_required_channel_subscription(
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
try:
|
||||
await event.answer(error_text, show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/start.py: %s", exc)
|
||||
if message_obj:
|
||||
try:
|
||||
await message_obj.answer(error_text)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/start.py: %s", exc)
|
||||
else:
|
||||
await event.answer(error_text)
|
||||
return False
|
||||
@@ -361,12 +296,12 @@ async def ensure_required_channel_subscription(
|
||||
if keyboard is None and message_obj:
|
||||
try:
|
||||
await message_obj.answer(prompt_text)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/start.py: %s", exc)
|
||||
try:
|
||||
await event.answer(prompt_text, show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/start.py: %s", exc)
|
||||
else:
|
||||
await event.answer(prompt_text, reply_markup=keyboard)
|
||||
|
||||
@@ -376,21 +311,16 @@ async def ensure_required_channel_subscription(
|
||||
@router.message(CommandStart())
|
||||
@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"^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")))
|
||||
@router.message(CommandStart(magic=F.args.regexp(r"^(?!ref_|promo_)([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,
|
||||
page_ref_match: Optional[re.Match] = None,
|
||||
ad_param_match: Optional[re.Match] = None,
|
||||
admin_user_match: Optional[re.Match] = None):
|
||||
ad_param_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")
|
||||
@@ -400,64 +330,11 @@ 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:
|
||||
if ref_match and settings.REFERRAL_ENABLED:
|
||||
raw_ref_value = ref_match.group(1)
|
||||
if raw_ref_value.isdigit():
|
||||
if settings.LEGACY_REFS:
|
||||
@@ -475,12 +352,14 @@ async def start_command_handler(message: types.Message,
|
||||
session, normalized_code)
|
||||
if ref_user and ref_user.user_id != user_id:
|
||||
referred_by_user_id = ref_user.user_id
|
||||
elif ref_match and not settings.REFERRAL_ENABLED:
|
||||
logging.info(
|
||||
"User %s started with referral parameter while referral system is disabled.",
|
||||
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}")
|
||||
@@ -490,7 +369,6 @@ 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,
|
||||
@@ -520,50 +398,6 @@ async def start_command_handler(message: types.Message,
|
||||
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
|
||||
@@ -628,33 +462,28 @@ async def start_command_handler(message: types.Message,
|
||||
logging.error(f"Failed to attribute user {user_id} to ad '{ad_start_param}': {e_attr}")
|
||||
try:
|
||||
await session.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/start.py: %s", exc)
|
||||
|
||||
if not await ensure_required_channel_subscription(message, settings, i18n,
|
||||
current_lang, session,
|
||||
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 and not open_referral_page_for_existing_user:
|
||||
if not settings.DISABLE_WELCOME_MESSAGE:
|
||||
await message.answer(_(key="welcome", user_name=hd.quote(user.full_name)))
|
||||
|
||||
# Auto-apply promo code if provided via start parameter
|
||||
if promo_code_to_apply:
|
||||
try:
|
||||
from bot.services.promo_code_service import PromoCodeService
|
||||
promo_code_service = PromoCodeService(settings, subscription_service, message.bot, i18n)
|
||||
|
||||
success, result = await promo_code_service.apply_promo_code(
|
||||
success_bonus, bonus_result = await promo_code_service.apply_promo_code(
|
||||
session, user_id, promo_code_to_apply, current_lang
|
||||
)
|
||||
|
||||
if success:
|
||||
if success_bonus:
|
||||
await session.commit()
|
||||
logging.info(f"Auto-applied promo code '{promo_code_to_apply}' for user {user_id}")
|
||||
|
||||
@@ -664,7 +493,7 @@ async def start_command_handler(message: types.Message,
|
||||
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
|
||||
new_end_date = bonus_result if isinstance(bonus_result, datetime) else None
|
||||
|
||||
promo_success_text = _(
|
||||
"promo_code_applied_success_full",
|
||||
@@ -687,23 +516,54 @@ async def start_command_handler(message: types.Message,
|
||||
|
||||
# Don't show main menu if promo was successfully applied
|
||||
return
|
||||
else:
|
||||
|
||||
success_discount, discount_result = await promo_code_service.apply_discount_promo_code(
|
||||
session, user_id, promo_code_to_apply, current_lang
|
||||
)
|
||||
|
||||
if success_discount:
|
||||
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
|
||||
discount_pct = discount_result if isinstance(discount_result, int) else 0
|
||||
logging.info(
|
||||
f"Auto-applied discount promo code '{promo_code_to_apply}' for user {user_id}: {discount_pct}%"
|
||||
)
|
||||
|
||||
if settings.LOG_PROMO_ACTIVATIONS:
|
||||
try:
|
||||
from bot.services.notification_service import NotificationService
|
||||
notification_service = NotificationService(message.bot, settings, i18n)
|
||||
await notification_service.notify_discount_promo_activation(
|
||||
user_id=user_id,
|
||||
promo_code=promo_code_to_apply.upper(),
|
||||
discount_percentage=discount_pct,
|
||||
username=user.username,
|
||||
)
|
||||
except Exception as notify_error:
|
||||
logging.error(f"Failed to send discount promo activation notification: {notify_error}")
|
||||
|
||||
from bot.keyboards.inline.user_keyboards import get_back_to_main_menu_markup
|
||||
await message.answer(
|
||||
_(
|
||||
"discount_promo_code_applied_success",
|
||||
code=hd.quote(promo_code_to_apply.upper()),
|
||||
discount=discount_pct,
|
||||
),
|
||||
reply_markup=get_back_to_main_menu_markup(current_lang, i18n),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
return
|
||||
|
||||
await session.rollback()
|
||||
logging.warning(
|
||||
f"Failed to auto-apply promo code '{promo_code_to_apply}' for user {user_id}. "
|
||||
f"Bonus reason: {bonus_result}. Discount reason: {discount_result}"
|
||||
)
|
||||
# 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,
|
||||
@@ -712,31 +572,6 @@ 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,
|
||||
@@ -767,7 +602,15 @@ async def verify_channel_subscription_callback(
|
||||
welcome_text = _(key="welcome",
|
||||
user_name=hd.quote(callback.from_user.full_name))
|
||||
if callback.message:
|
||||
await callback.message.answer(welcome_text)
|
||||
try:
|
||||
await callback.message.edit_text(welcome_text)
|
||||
except Exception as welcome_edit_error:
|
||||
logging.debug(
|
||||
"Failed to edit subscription prompt to welcome for user %s: %s",
|
||||
callback.from_user.id,
|
||||
welcome_edit_error,
|
||||
)
|
||||
await callback.message.answer(welcome_text)
|
||||
else:
|
||||
fallback_bot: Optional[Bot] = getattr(callback, "bot", None)
|
||||
if fallback_bot:
|
||||
@@ -775,20 +618,24 @@ async def verify_channel_subscription_callback(
|
||||
welcome_text)
|
||||
|
||||
try:
|
||||
await safe_answer_callback(
|
||||
callback,
|
||||
_(key="channel_subscription_verified_success"),
|
||||
show_alert=True,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
await callback.answer(_(key="channel_subscription_verified_success"),
|
||||
show_alert=True)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/start.py: %s", exc)
|
||||
|
||||
await send_main_menu(callback,
|
||||
menu_target_event: Union[types.Message, types.CallbackQuery] = callback
|
||||
should_edit_menu_message = bool(callback.message)
|
||||
|
||||
if not settings.DISABLE_WELCOME_MESSAGE and callback.message:
|
||||
menu_target_event = callback.message
|
||||
should_edit_menu_message = False
|
||||
|
||||
await send_main_menu(menu_target_event,
|
||||
settings,
|
||||
i18n_data,
|
||||
subscription_service,
|
||||
session,
|
||||
is_edit=bool(callback.message))
|
||||
is_edit=should_edit_menu_message)
|
||||
|
||||
|
||||
@router.message(Command("language"))
|
||||
@@ -810,11 +657,7 @@ async def language_command_handler(
|
||||
event, types.CallbackQuery) else event
|
||||
if not target_message_obj:
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
await safe_answer_callback(
|
||||
event,
|
||||
_("error_occurred_try_again"),
|
||||
show_alert=True,
|
||||
)
|
||||
await event.answer(_("error_occurred_try_again"), show_alert=True)
|
||||
return
|
||||
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
@@ -825,7 +668,7 @@ async def language_command_handler(
|
||||
except Exception:
|
||||
await target_message_obj.answer(text_to_send,
|
||||
reply_markup=reply_markup)
|
||||
await safe_answer_callback(event)
|
||||
await event.answer()
|
||||
else:
|
||||
await target_message_obj.answer(text_to_send,
|
||||
reply_markup=reply_markup)
|
||||
@@ -837,21 +680,15 @@ 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 safe_answer_callback(
|
||||
callback,
|
||||
"Service error or message context lost.",
|
||||
show_alert=True,
|
||||
)
|
||||
await callback.answer("Service error or message context lost.",
|
||||
show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
lang_code = callback.data.split("_")[2]
|
||||
except IndexError:
|
||||
await safe_answer_callback(
|
||||
callback,
|
||||
"Error processing language selection.",
|
||||
show_alert=True,
|
||||
)
|
||||
await callback.answer("Error processing language selection.",
|
||||
show_alert=True)
|
||||
return
|
||||
|
||||
user_id = callback.from_user.id
|
||||
@@ -862,22 +699,18 @@ async def select_language_callback_handler(
|
||||
|
||||
i18n_data["current_language"] = lang_code
|
||||
_ = lambda key, **kwargs: i18n.gettext(lang_code, key, **kwargs)
|
||||
await safe_answer_callback(callback, _(key="language_set_alert"))
|
||||
await callback.answer(_(key="language_set_alert"))
|
||||
logging.info(
|
||||
f"User {user_id} language updated to {lang_code} in session.")
|
||||
else:
|
||||
await safe_answer_callback(
|
||||
callback,
|
||||
"Could not set language.",
|
||||
show_alert=True,
|
||||
)
|
||||
await callback.answer("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 safe_answer_callback(callback, "Error setting language.", show_alert=True)
|
||||
await callback.answer("Error setting language.", show_alert=True)
|
||||
return
|
||||
await send_main_menu(callback,
|
||||
settings,
|
||||
@@ -895,6 +728,10 @@ async def main_action_callback_handler(
|
||||
promo_code_service: PromoCodeService, session: AsyncSession):
|
||||
action = callback.data.split(":")[1]
|
||||
user_id = callback.from_user.id
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs
|
||||
) if i18n else key
|
||||
|
||||
from . import subscription as user_subscription_handlers
|
||||
from . import referral as user_referral_handlers
|
||||
@@ -902,16 +739,12 @@ async def main_action_callback_handler(
|
||||
from . import trial_handler as user_trial_handlers
|
||||
|
||||
if not callback.message:
|
||||
await safe_answer_callback(
|
||||
callback,
|
||||
"Error: message context lost.",
|
||||
show_alert=True,
|
||||
)
|
||||
await callback.answer("Error: message context lost.", show_alert=True)
|
||||
return
|
||||
|
||||
if action == "subscribe":
|
||||
await user_subscription_handlers.display_subscription_options(
|
||||
callback, i18n_data, settings, session)
|
||||
callback, i18n_data, settings, session, promo_code_service=promo_code_service)
|
||||
elif action == "my_subscription":
|
||||
await user_subscription_handlers.my_subscription_command_handler(
|
||||
callback, i18n_data, settings, panel_service, subscription_service,
|
||||
@@ -921,6 +754,10 @@ async def main_action_callback_handler(
|
||||
callback, i18n_data, settings, panel_service, subscription_service,
|
||||
session, bot)
|
||||
elif action == "referral":
|
||||
if not settings.REFERRAL_ENABLED:
|
||||
await callback.answer(_("referral_no_bonuses_configured"),
|
||||
show_alert=True)
|
||||
return
|
||||
await user_referral_handlers.referral_command_handler(
|
||||
callback, settings, i18n_data, referral_service, bot, session)
|
||||
elif action == "apply_promo":
|
||||
@@ -932,44 +769,6 @@ 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,
|
||||
@@ -985,11 +784,4 @@ async def main_action_callback_handler(
|
||||
session,
|
||||
is_edit=False)
|
||||
else:
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
_ = lambda key, **kwargs: i18n.gettext(
|
||||
i18n_data.get("current_language"), key, **kwargs) if i18n else key
|
||||
await safe_answer_callback(
|
||||
callback,
|
||||
_("main_menu_unknown_action"),
|
||||
show_alert=True,
|
||||
)
|
||||
await callback.answer(_("main_menu_unknown_action"), show_alert=True)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import hashlib
|
||||
import logging
|
||||
import math
|
||||
from aiogram import Router, F, types, Bot
|
||||
from aiogram.filters import Command
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, WebAppInfo
|
||||
@@ -39,7 +40,13 @@ def _hwid_callback_token(hwid: Optional[str]) -> str:
|
||||
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):
|
||||
async def display_subscription_options(
|
||||
event: Union[types.Message, types.CallbackQuery],
|
||||
i18n_data: dict,
|
||||
settings: Settings,
|
||||
session: AsyncSession,
|
||||
promo_code_service=None,
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
|
||||
@@ -50,32 +57,57 @@ async def display_subscription_options(event: Union[types.Message, types.Callbac
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
try:
|
||||
await event.answer(err_msg, show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
|
||||
elif isinstance(event, types.Message):
|
||||
await event.answer(err_msg)
|
||||
return
|
||||
|
||||
currency_symbol_val = settings.DEFAULT_CURRENCY_SYMBOL
|
||||
currency_symbol_val = "RUB"
|
||||
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)
|
||||
|
||||
options_are_stars = False
|
||||
if traffic_mode:
|
||||
if traffic_packages:
|
||||
options = traffic_packages
|
||||
elif stars_traffic_packages:
|
||||
options = stars_traffic_packages
|
||||
currency_symbol_val = "⭐"
|
||||
options_are_stars = True
|
||||
else:
|
||||
options = {}
|
||||
else:
|
||||
options = settings.subscription_options
|
||||
|
||||
if options:
|
||||
display_options = options
|
||||
if options and promo_code_service:
|
||||
try:
|
||||
active_discount_info = await promo_code_service.get_user_active_discount(
|
||||
session, event.from_user.id
|
||||
)
|
||||
except Exception:
|
||||
active_discount_info = None
|
||||
if active_discount_info:
|
||||
discount_pct, _promo_code = active_discount_info
|
||||
discounted_options = {}
|
||||
for period, price in options.items():
|
||||
if price is None:
|
||||
discounted_options[period] = price
|
||||
else:
|
||||
discounted_price, _ = promo_code_service.calculate_discounted_price(
|
||||
price, discount_pct
|
||||
)
|
||||
if options_are_stars:
|
||||
discounted_price = math.ceil(discounted_price)
|
||||
discounted_options[period] = discounted_price
|
||||
display_options = discounted_options
|
||||
|
||||
if display_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
|
||||
display_options, currency_symbol_val, current_lang, i18n, traffic_mode=traffic_mode
|
||||
)
|
||||
else:
|
||||
text_content = get_text("no_subscription_options_available")
|
||||
@@ -86,8 +118,8 @@ async def display_subscription_options(event: Union[types.Message, types.Callbac
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
try:
|
||||
await event.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
|
||||
return
|
||||
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
@@ -97,15 +129,23 @@ async def display_subscription_options(event: Union[types.Message, types.Callbac
|
||||
await target_message_obj.answer(text_content, reply_markup=reply_markup)
|
||||
try:
|
||||
await event.answer()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
|
||||
else:
|
||||
await target_message_obj.answer(text_content, reply_markup=reply_markup)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "main_action:subscribe")
|
||||
async def reshow_subscription_options_callback(callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession):
|
||||
await display_subscription_options(callback, i18n_data, settings, session)
|
||||
async def reshow_subscription_options_callback(
|
||||
callback: types.CallbackQuery,
|
||||
i18n_data: dict,
|
||||
settings: Settings,
|
||||
session: AsyncSession,
|
||||
promo_code_service=None,
|
||||
):
|
||||
await display_subscription_options(
|
||||
callback, i18n_data, settings, session, promo_code_service=promo_code_service
|
||||
)
|
||||
|
||||
|
||||
async def my_subscription_command_handler(
|
||||
@@ -146,8 +186,8 @@ async def my_subscription_command_handler(
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
try:
|
||||
await event.answer()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
|
||||
try:
|
||||
await event.message.edit_text(text, reply_markup=kb)
|
||||
except Exception:
|
||||
@@ -169,41 +209,21 @@ async def my_subscription_command_handler(
|
||||
if isinstance(val, (int, float)):
|
||||
val_gb = float(val) / (2**30)
|
||||
return f"{val_gb:.2f} GB"
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
|
||||
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
|
||||
|
||||
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)
|
||||
used_display = _fmt_gb(active.get("traffic_used_bytes"))
|
||||
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
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
|
||||
text = get_text(
|
||||
"my_traffic_details",
|
||||
status=active.get("status_from_panel", get_text("status_active")).capitalize(),
|
||||
@@ -211,7 +231,6 @@ async def my_subscription_command_handler(
|
||||
traffic_limit=limit_display,
|
||||
traffic_used=used_display,
|
||||
traffic_left=remaining_display,
|
||||
traffic_period=period_label,
|
||||
config_link=config_link_value,
|
||||
)
|
||||
else:
|
||||
@@ -223,12 +242,8 @@ async def my_subscription_command_handler(
|
||||
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,
|
||||
)
|
||||
f"{active['traffic_used_bytes'] / 2**30:.2f} GB" if active.get("traffic_used_bytes") is not None else get_text("traffic_na")
|
||||
),
|
||||
traffic_period=period_label,
|
||||
)
|
||||
|
||||
base_markup = get_back_to_main_menu_markup(current_lang, i18n)
|
||||
@@ -238,22 +253,23 @@ async def my_subscription_command_handler(
|
||||
# Build rows to prepend above the base "back" markup
|
||||
prepend_rows = []
|
||||
|
||||
# 1) Connect button: prefer the actual subscription URL; fall back to mini-app
|
||||
cfg_link_val = connect_button_url or config_link_display
|
||||
if cfg_link_val:
|
||||
prepend_rows.append([
|
||||
InlineKeyboardButton(
|
||||
text=get_text("connect_button"),
|
||||
url=cfg_link_val,
|
||||
)
|
||||
])
|
||||
elif settings.SUBSCRIPTION_MINI_APP_URL:
|
||||
# 1) Mini-app connect button on top if enabled, otherwise fall back to config link URL
|
||||
if 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 = connect_button_url or config_link_display
|
||||
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")
|
||||
@@ -326,15 +342,15 @@ async def my_subscription_command_handler(
|
||||
|
||||
if prepend_rows:
|
||||
kb = prepend_rows + kb
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
|
||||
markup = InlineKeyboardMarkup(inline_keyboard=kb)
|
||||
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
try:
|
||||
await event.answer()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
|
||||
try:
|
||||
await event.message.edit_text(text, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True)
|
||||
except Exception:
|
||||
@@ -373,8 +389,8 @@ async def my_devices_command_handler(
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
try:
|
||||
await event.answer(get_text("my_devices_feature_disabled"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
|
||||
else:
|
||||
await target.answer(get_text("my_devices_feature_disabled"))
|
||||
return
|
||||
@@ -386,8 +402,8 @@ async def my_devices_command_handler(
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
try:
|
||||
await event.answer(message, show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
|
||||
else:
|
||||
await target.answer(message)
|
||||
return
|
||||
@@ -397,8 +413,8 @@ async def my_devices_command_handler(
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
try:
|
||||
await event.answer(get_text("no_devices_found"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
|
||||
else:
|
||||
await target.answer(get_text("no_devices_found"))
|
||||
return
|
||||
@@ -459,8 +475,8 @@ async def my_devices_command_handler(
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
try:
|
||||
await event.answer()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
|
||||
try:
|
||||
await event.message.edit_text(text, reply_markup=markup)
|
||||
except Exception:
|
||||
@@ -486,8 +502,8 @@ async def disconnect_device_handler(
|
||||
if not settings.MY_DEVICES_SECTION_ENABLED:
|
||||
try:
|
||||
await callback.answer(get_text("my_devices_feature_disabled"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
|
||||
return
|
||||
|
||||
try:
|
||||
@@ -495,8 +511,8 @@ async def disconnect_device_handler(
|
||||
except Exception:
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
|
||||
return
|
||||
|
||||
active = await subscription_service.get_active_subscription_details(session, callback.from_user.id)
|
||||
@@ -533,8 +549,8 @@ async def disconnect_device_handler(
|
||||
await session.commit()
|
||||
try:
|
||||
await callback.answer(get_text("device_disconnected"))
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
|
||||
await my_devices_command_handler(callback, i18n_data, settings, panel_service, subscription_service, session, bot)
|
||||
|
||||
|
||||
@@ -560,8 +576,8 @@ async def toggle_autorenew_handler(
|
||||
except Exception:
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
|
||||
return
|
||||
|
||||
sub = await session.get(Subscription, sub_id)
|
||||
@@ -576,8 +592,8 @@ async def toggle_autorenew_handler(
|
||||
if not has_saved_card:
|
||||
try:
|
||||
await callback.answer(get_text("autorenew_enable_requires_card"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
|
||||
return
|
||||
|
||||
# Show confirmation popup and inline buttons
|
||||
@@ -588,12 +604,12 @@ async def toggle_autorenew_handler(
|
||||
except Exception:
|
||||
try:
|
||||
await callback.message.answer(confirm_text, reply_markup=kb)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
|
||||
return
|
||||
|
||||
|
||||
@@ -618,8 +634,8 @@ async def confirm_autorenew_handler(
|
||||
except Exception:
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
|
||||
return
|
||||
|
||||
sub = await session.get(Subscription, sub_id)
|
||||
@@ -634,20 +650,20 @@ async def confirm_autorenew_handler(
|
||||
if not has_saved_card:
|
||||
try:
|
||||
await callback.answer(get_text("autorenew_enable_requires_card"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
|
||||
try:
|
||||
await my_subscription_command_handler(callback, i18n_data, settings, panel_service, subscription_service, session, bot)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
|
||||
return
|
||||
|
||||
await subscription_dal.update_subscription(session, sub.subscription_id, {"auto_renew_enabled": enable})
|
||||
await session.commit()
|
||||
try:
|
||||
await callback.answer(get_text("subscription_autorenew_updated"))
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
|
||||
await my_subscription_command_handler(callback, i18n_data, settings, panel_service, subscription_service, session, bot)
|
||||
|
||||
|
||||
@@ -671,21 +687,21 @@ async def autorenew_cancel_from_webhook_button(
|
||||
if not sub:
|
||||
try:
|
||||
await callback.answer(get_text("subscription_not_active"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
|
||||
return
|
||||
if sub.provider != "yookassa":
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
|
||||
return
|
||||
await subscription_dal.update_subscription(session, sub.subscription_id, {"auto_renew_enabled": False})
|
||||
await session.commit()
|
||||
try:
|
||||
await callback.answer(get_text("subscription_autorenew_updated"))
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
|
||||
await my_subscription_command_handler(callback, i18n_data, settings, panel_service, subscription_service, session, bot)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"""
|
||||
Helper функция для применения скидок к платежам
|
||||
Используется всеми платежными обработчиками
|
||||
"""
|
||||
import logging
|
||||
from typing import Optional, Tuple
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from db.dal import active_discount_dal
|
||||
|
||||
|
||||
async def apply_discount_to_payment(
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
original_price: float,
|
||||
promo_code_service=None
|
||||
) -> Tuple[float, Optional[float], Optional[int]]:
|
||||
"""
|
||||
Apply active discount to payment if exists.
|
||||
|
||||
Returns:
|
||||
(final_price, discount_amount, promo_code_id)
|
||||
"""
|
||||
if not promo_code_service:
|
||||
return original_price, None, None
|
||||
|
||||
active_discount = await active_discount_dal.get_active_discount(session, user_id)
|
||||
if not active_discount:
|
||||
return original_price, None, None
|
||||
|
||||
# Calculate discounted price
|
||||
final_price, discount_amount = promo_code_service.calculate_discounted_price(
|
||||
original_price, active_discount.discount_percentage
|
||||
)
|
||||
|
||||
logging.info(
|
||||
f"Applying {active_discount.discount_percentage}% discount to payment for user {user_id}: "
|
||||
f"{original_price} -> {final_price}"
|
||||
)
|
||||
|
||||
return final_price, discount_amount, active_discount.promo_code_id
|
||||
@@ -1,3 +1,4 @@
|
||||
import logging
|
||||
from aiogram import Router, F, types
|
||||
from typing import Optional, List
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -26,8 +27,8 @@ async def payment_methods_manage(callback: types.CallbackQuery, settings: Settin
|
||||
try:
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
await callback.answer(_("error_service_unavailable"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payment_methods.py: %s", exc)
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
@@ -67,8 +68,8 @@ async def payment_methods_manage(callback: types.CallbackQuery, settings: Settin
|
||||
await callback.message.edit_text(text, reply_markup=get_payment_methods_list_keyboard(cards, 0, current_lang, i18n))
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payment_methods.py: %s", exc)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "pm:bind")
|
||||
@@ -79,8 +80,8 @@ async def payment_method_bind(callback: types.CallbackQuery, settings: Settings,
|
||||
try:
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
await callback.answer(_("error_service_unavailable"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payment_methods.py: %s", exc)
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
@@ -101,8 +102,8 @@ async def payment_method_bind(callback: types.CallbackQuery, settings: Settings,
|
||||
await callback.message.edit_text(_("payment_methods_title"), reply_markup=get_bind_url_keyboard(resp["confirmation_url"], current_lang, i18n))
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payment_methods.py: %s", exc)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("pm:delete_confirm"))
|
||||
@@ -113,8 +114,8 @@ async def payment_method_delete_confirm(callback: types.CallbackQuery, settings:
|
||||
try:
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
await callback.answer(_("error_service_unavailable"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payment_methods.py: %s", exc)
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
parts = callback.data.split(":", 2)
|
||||
@@ -122,8 +123,8 @@ async def payment_method_delete_confirm(callback: types.CallbackQuery, settings:
|
||||
await callback.message.edit_text(_("payment_method_delete_confirm"), reply_markup=get_payment_method_delete_confirm_keyboard(pm_id, current_lang, i18n))
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payment_methods.py: %s", exc)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("pm:delete"))
|
||||
@@ -134,8 +135,8 @@ async def payment_method_delete(callback: types.CallbackQuery, settings: Setting
|
||||
try:
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
await callback.answer(_("error_service_unavailable"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payment_methods.py: %s", exc)
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
parts = callback.data.split(":", 2)
|
||||
@@ -156,8 +157,8 @@ async def payment_method_delete(callback: types.CallbackQuery, settings: Setting
|
||||
try:
|
||||
legacy_deleted = await user_billing_dal.delete_yk_payment_method(session, callback.from_user.id)
|
||||
deleted = deleted or legacy_deleted
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payment_methods.py: %s", exc)
|
||||
await session.commit()
|
||||
|
||||
methods = await list_user_payment_methods(session, callback.from_user.id)
|
||||
@@ -189,15 +190,15 @@ async def payment_method_delete(callback: types.CallbackQuery, settings: Setting
|
||||
await callback.message.edit_text(f"{msg}\n\n{text}", reply_markup=get_payment_methods_list_keyboard(cards, 0, current_lang, i18n))
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payment_methods.py: %s", exc)
|
||||
return
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
try:
|
||||
await callback.answer(_("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payment_methods.py: %s", exc)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("pm:view"))
|
||||
@@ -208,8 +209,8 @@ async def payment_method_view(callback: types.CallbackQuery, settings: Settings,
|
||||
try:
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
await callback.answer(_("error_service_unavailable"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payment_methods.py: %s", exc)
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
@@ -262,14 +263,14 @@ async def payment_method_view(callback: types.CallbackQuery, settings: Settings,
|
||||
lp = result.scalar_one_or_none()
|
||||
if lp and lp.created_at:
|
||||
last_tx = lp.created_at.strftime('%Y-%m-%d')
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payment_methods.py: %s", exc)
|
||||
details = f"{title}\n{_('payment_method_added_at', date=added_at)}\n{_('payment_method_last_tx', date=last_tx)}"
|
||||
await callback.message.edit_text(details, reply_markup=get_payment_method_details_keyboard(str(sel.method_id), current_lang, i18n))
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payment_methods.py: %s", exc)
|
||||
return
|
||||
|
||||
added_at = billing.created_at.strftime('%Y-%m-%d') if getattr(billing, 'created_at', None) else "—"
|
||||
@@ -289,8 +290,8 @@ async def payment_method_view(callback: types.CallbackQuery, settings: Settings,
|
||||
last_payment = result.scalar_one_or_none()
|
||||
if last_payment and last_payment.created_at:
|
||||
last_tx = last_payment.created_at.strftime('%Y-%m-%d')
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payment_methods.py: %s", exc)
|
||||
|
||||
def _is_yoomoney_network(network: Optional[str]) -> bool:
|
||||
s = (network or "").lower()
|
||||
@@ -317,8 +318,8 @@ async def payment_method_view(callback: types.CallbackQuery, settings: Settings,
|
||||
await callback.message.edit_text(details, reply_markup=get_payment_method_details_keyboard(billing.yookassa_payment_method_id, current_lang, i18n))
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payment_methods.py: %s", exc)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("pm:history"))
|
||||
@@ -329,8 +330,8 @@ async def payment_method_history(callback: types.CallbackQuery, settings: Settin
|
||||
try:
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
await callback.answer(_("error_service_unavailable"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payment_methods.py: %s", exc)
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
@@ -371,8 +372,8 @@ async def payment_method_history(callback: types.CallbackQuery, settings: Settin
|
||||
if pm.get("id") == selected_pm_provider_id:
|
||||
filtered.append(p)
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payment_methods.py: %s", exc)
|
||||
user_payments = filtered
|
||||
|
||||
if not user_payments:
|
||||
@@ -459,6 +460,6 @@ async def payment_methods_list(callback: types.CallbackQuery, settings: Settings
|
||||
await callback.message.edit_text(text, reply_markup=get_payment_methods_list_keyboard(cards, page, current_lang, i18n))
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payment_methods.py: %s", exc)
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import F, Router, types
|
||||
@@ -11,6 +12,8 @@ from config.settings import Settings
|
||||
router = Router(name="user_subscription_payments_crypto_router")
|
||||
|
||||
|
||||
from bot.handlers.user.subscription.payments_subscription import resolve_fiat_offer_price_for_user
|
||||
|
||||
@router.callback_query(F.data.startswith("pay_crypto:"))
|
||||
async def pay_crypto_callback_handler(
|
||||
callback: types.CallbackQuery,
|
||||
@@ -18,6 +21,7 @@ async def pay_crypto_callback_handler(
|
||||
i18n_data: dict,
|
||||
session: AsyncSession,
|
||||
cryptopay_service: CryptoPayService,
|
||||
promo_code_service=None,
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
@@ -26,31 +30,68 @@ async def pay_crypto_callback_handler(
|
||||
if not i18n or not callback.message:
|
||||
try:
|
||||
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_crypto.py: %s", exc)
|
||||
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
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_crypto.py: %s", exc)
|
||||
return
|
||||
|
||||
try:
|
||||
_, data_payload = callback.data.split(":", 1)
|
||||
parts = data_payload.split(":")
|
||||
months = float(parts[0])
|
||||
price_amount = float(parts[1])
|
||||
callback_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
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_crypto.py: %s", exc)
|
||||
return
|
||||
|
||||
user_id = callback.from_user.id
|
||||
resolved_price_amount = await resolve_fiat_offer_price_for_user(
|
||||
session=session,
|
||||
settings=settings,
|
||||
user_id=user_id,
|
||||
months=months,
|
||||
sale_mode=sale_mode,
|
||||
promo_code_service=promo_code_service,
|
||||
)
|
||||
if resolved_price_amount is None:
|
||||
logging.warning(
|
||||
"CryptoPay: no server-side price for user %s, value=%s, mode=%s",
|
||||
user_id,
|
||||
months,
|
||||
sale_mode,
|
||||
)
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_crypto.py: %s", exc)
|
||||
return
|
||||
|
||||
if abs(resolved_price_amount - callback_price_amount) > 0.01:
|
||||
logging.warning(
|
||||
"CryptoPay: callback price mismatch for user %s, value=%s, mode=%s, callback=%.2f, resolved=%.2f",
|
||||
user_id,
|
||||
months,
|
||||
sale_mode,
|
||||
callback_price_amount,
|
||||
resolved_price_amount,
|
||||
)
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_crypto.py: %s", exc)
|
||||
return
|
||||
|
||||
price_amount = resolved_price_amount
|
||||
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)
|
||||
@@ -65,6 +106,7 @@ async def pay_crypto_callback_handler(
|
||||
amount=price_amount,
|
||||
description=payment_description,
|
||||
sale_mode=sale_mode,
|
||||
promo_code_service=promo_code_service,
|
||||
)
|
||||
|
||||
if invoice_url:
|
||||
@@ -101,15 +143,15 @@ async def pay_crypto_callback_handler(
|
||||
),
|
||||
disable_web_page_preview=False,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_crypto.py: %s", exc)
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_crypto.py: %s", exc)
|
||||
return
|
||||
|
||||
try:
|
||||
await callback.answer(get_text("error_payment_gateway"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_crypto.py: %s", exc)
|
||||
|
||||
@@ -14,6 +14,8 @@ from db.dal import payment_dal
|
||||
router = Router(name="user_subscription_payments_freekassa_router")
|
||||
|
||||
|
||||
from bot.handlers.user.subscription.payments_subscription import resolve_fiat_offer_price_for_user
|
||||
|
||||
@router.callback_query(F.data.startswith("pay_fk:"))
|
||||
async def pay_fk_callback_handler(
|
||||
callback: types.CallbackQuery,
|
||||
@@ -21,6 +23,7 @@ async def pay_fk_callback_handler(
|
||||
i18n_data: dict,
|
||||
freekassa_service: FreeKassaService,
|
||||
session: AsyncSession,
|
||||
promo_code_service=None,
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
@@ -29,53 +32,93 @@ async def pay_fk_callback_handler(
|
||||
if not i18n or not callback.message:
|
||||
try:
|
||||
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_freekassa.py: %s", exc)
|
||||
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
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_freekassa.py: %s", exc)
|
||||
try:
|
||||
await callback.message.edit_text(get_text("payment_service_unavailable"))
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_freekassa.py: %s", exc)
|
||||
return
|
||||
|
||||
try:
|
||||
_, data_payload = callback.data.split(":", 1)
|
||||
parts = data_payload.split(":")
|
||||
months = float(parts[0])
|
||||
price_rub = float(parts[1])
|
||||
callback_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
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_freekassa.py: %s", exc)
|
||||
return
|
||||
|
||||
user_id = callback.from_user.id
|
||||
resolved_price_rub = await resolve_fiat_offer_price_for_user(
|
||||
session=session,
|
||||
settings=settings,
|
||||
user_id=user_id,
|
||||
months=months,
|
||||
sale_mode=sale_mode,
|
||||
promo_code_service=promo_code_service,
|
||||
)
|
||||
if resolved_price_rub is None:
|
||||
logging.warning(
|
||||
"FreeKassa: no server-side price for user %s, value=%s, mode=%s",
|
||||
user_id,
|
||||
months,
|
||||
sale_mode,
|
||||
)
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_freekassa.py: %s", exc)
|
||||
return
|
||||
|
||||
if abs(resolved_price_rub - callback_price_rub) > 0.01:
|
||||
logging.warning(
|
||||
"FreeKassa: callback price mismatch for user %s, value=%s, mode=%s, callback=%.2f, resolved=%.2f",
|
||||
user_id,
|
||||
months,
|
||||
sale_mode,
|
||||
callback_price_rub,
|
||||
resolved_price_rub,
|
||||
)
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_freekassa.py: %s", exc)
|
||||
return
|
||||
|
||||
price_rub = resolved_price_rub
|
||||
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"
|
||||
currency_code = getattr(freekassa_service, "default_currency", None) or "RUB"
|
||||
|
||||
payment_record_payload = {
|
||||
"user_id": user_id,
|
||||
"amount": price_rub,
|
||||
"original_amount": None,
|
||||
"discount_applied": None,
|
||||
"currency": currency_code,
|
||||
"status": "pending_freekassa",
|
||||
"description": payment_description,
|
||||
"subscription_duration_months": int(months),
|
||||
"provider": "freekassa",
|
||||
"promo_code_id": None,
|
||||
}
|
||||
|
||||
try:
|
||||
@@ -89,12 +132,12 @@ async def pay_fk_callback_handler(
|
||||
)
|
||||
try:
|
||||
await callback.message.edit_text(get_text("error_creating_payment_record"))
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_freekassa.py: %s", exc)
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_freekassa.py: %s", exc)
|
||||
return
|
||||
|
||||
success, response_data = await freekassa_service.create_order(
|
||||
@@ -108,6 +151,8 @@ async def pay_fk_callback_handler(
|
||||
extra_params={
|
||||
"us_method": freekassa_service.payment_method_id,
|
||||
},
|
||||
promo_code_service=promo_code_service,
|
||||
session=session,
|
||||
)
|
||||
|
||||
if success:
|
||||
@@ -173,12 +218,12 @@ async def pay_fk_callback_handler(
|
||||
),
|
||||
disable_web_page_preview=False,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_freekassa.py: %s", exc)
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_freekassa.py: %s", exc)
|
||||
return
|
||||
|
||||
logging.error(
|
||||
@@ -206,9 +251,9 @@ async def pay_fk_callback_handler(
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(get_text("error_payment_gateway"))
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_freekassa.py: %s", exc)
|
||||
try:
|
||||
await callback.answer(get_text("error_payment_gateway"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_freekassa.py: %s", exc)
|
||||
|
||||
@@ -14,41 +14,17 @@ 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:")
|
||||
)
|
||||
from bot.handlers.user.subscription.payments_subscription import resolve_fiat_offer_price_for_user
|
||||
|
||||
@router.callback_query(F.data.startswith("pay_platega:"))
|
||||
async def pay_platega_callback_handler(
|
||||
callback: types.CallbackQuery,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
platega_service: PlategaService,
|
||||
session: AsyncSession,
|
||||
promo_code_service=None,
|
||||
):
|
||||
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
|
||||
@@ -56,53 +32,95 @@ async def pay_platega_callback_handler(
|
||||
if not i18n or not callback.message:
|
||||
try:
|
||||
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_platega.py: %s", exc)
|
||||
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
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_platega.py: %s", exc)
|
||||
try:
|
||||
await callback.message.edit_text(get_text("payment_service_unavailable"))
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_platega.py: %s", exc)
|
||||
return
|
||||
|
||||
try:
|
||||
_, data_payload = callback.data.split(":", 1)
|
||||
parts = data_payload.split(":")
|
||||
months = float(parts[0])
|
||||
price_rub = float(parts[1])
|
||||
callback_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
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_platega.py: %s", exc)
|
||||
return
|
||||
|
||||
user_id = callback.from_user.id
|
||||
resolved_price_rub = await resolve_fiat_offer_price_for_user(
|
||||
session=session,
|
||||
settings=settings,
|
||||
user_id=user_id,
|
||||
months=months,
|
||||
sale_mode=sale_mode,
|
||||
promo_code_service=promo_code_service,
|
||||
)
|
||||
if resolved_price_rub is None:
|
||||
logging.warning(
|
||||
"Platega: no server-side price for user %s, value=%s, mode=%s",
|
||||
user_id,
|
||||
months,
|
||||
sale_mode,
|
||||
)
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_platega.py: %s", exc)
|
||||
return
|
||||
|
||||
if abs(resolved_price_rub - callback_price_rub) > 0.01:
|
||||
logging.warning(
|
||||
"Platega: callback price mismatch for user %s, value=%s, mode=%s, callback=%.2f, resolved=%.2f",
|
||||
user_id,
|
||||
months,
|
||||
sale_mode,
|
||||
callback_price_rub,
|
||||
resolved_price_rub,
|
||||
)
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_platega.py: %s", exc)
|
||||
return
|
||||
|
||||
price_rub = resolved_price_rub
|
||||
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"
|
||||
currency_code = "RUB"
|
||||
|
||||
# Price is already discounted at payments_subscription.py stage
|
||||
# Service will handle discount metadata if needed
|
||||
payment_record_payload = {
|
||||
"user_id": user_id,
|
||||
"amount": price_rub,
|
||||
"original_amount": None,
|
||||
"discount_applied": None,
|
||||
"currency": currency_code,
|
||||
"status": "pending_platega",
|
||||
"description": payment_description,
|
||||
"subscription_duration_months": int(months),
|
||||
"provider": "platega",
|
||||
"promo_code_id": None,
|
||||
}
|
||||
|
||||
try:
|
||||
@@ -116,12 +134,12 @@ async def pay_platega_callback_handler(
|
||||
)
|
||||
try:
|
||||
await callback.message.edit_text(get_text("error_creating_payment_record"))
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_platega.py: %s", exc)
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_platega.py: %s", exc)
|
||||
return
|
||||
|
||||
payload_meta = json.dumps(
|
||||
@@ -130,7 +148,6 @@ async def pay_platega_callback_handler(
|
||||
"user_id": user_id,
|
||||
"months": months,
|
||||
"sale_mode": sale_mode,
|
||||
"platega_variant": platega_variant,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -142,7 +159,8 @@ async def pay_platega_callback_handler(
|
||||
currency=currency_code,
|
||||
description=payment_description,
|
||||
payload=payload_meta,
|
||||
payment_method=platega_method_id,
|
||||
promo_code_service=promo_code_service,
|
||||
session=session,
|
||||
)
|
||||
|
||||
if success:
|
||||
@@ -204,12 +222,12 @@ async def pay_platega_callback_handler(
|
||||
),
|
||||
disable_web_page_preview=False,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_platega.py: %s", exc)
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_platega.py: %s", exc)
|
||||
return
|
||||
|
||||
logging.error(
|
||||
@@ -231,9 +249,9 @@ async def pay_platega_callback_handler(
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(get_text("error_payment_gateway"))
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_platega.py: %s", exc)
|
||||
try:
|
||||
await callback.answer(get_text("error_payment_gateway"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_platega.py: %s", exc)
|
||||
|
||||
@@ -13,6 +13,8 @@ from db.dal import payment_dal
|
||||
router = Router(name="user_subscription_payments_severpay_router")
|
||||
|
||||
|
||||
from bot.handlers.user.subscription.payments_subscription import resolve_fiat_offer_price_for_user
|
||||
|
||||
@router.callback_query(F.data.startswith("pay_severpay:"))
|
||||
async def pay_severpay_callback_handler(
|
||||
callback: types.CallbackQuery,
|
||||
@@ -20,6 +22,7 @@ async def pay_severpay_callback_handler(
|
||||
i18n_data: dict,
|
||||
severpay_service: SeverPayService,
|
||||
session: AsyncSession,
|
||||
promo_code_service=None,
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
@@ -28,53 +31,95 @@ async def pay_severpay_callback_handler(
|
||||
if not i18n or not callback.message:
|
||||
try:
|
||||
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_severpay.py: %s", exc)
|
||||
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
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_severpay.py: %s", exc)
|
||||
try:
|
||||
await callback.message.edit_text(get_text("payment_service_unavailable"))
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_severpay.py: %s", exc)
|
||||
return
|
||||
|
||||
try:
|
||||
_, data_payload = callback.data.split(":", 1)
|
||||
parts = data_payload.split(":")
|
||||
months = float(parts[0])
|
||||
price_rub = float(parts[1])
|
||||
callback_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
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_severpay.py: %s", exc)
|
||||
return
|
||||
|
||||
user_id = callback.from_user.id
|
||||
resolved_price_rub = await resolve_fiat_offer_price_for_user(
|
||||
session=session,
|
||||
settings=settings,
|
||||
user_id=user_id,
|
||||
months=months,
|
||||
sale_mode=sale_mode,
|
||||
promo_code_service=promo_code_service,
|
||||
)
|
||||
if resolved_price_rub is None:
|
||||
logging.warning(
|
||||
"SeverPay: no server-side price for user %s, value=%s, mode=%s",
|
||||
user_id,
|
||||
months,
|
||||
sale_mode,
|
||||
)
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_severpay.py: %s", exc)
|
||||
return
|
||||
|
||||
if abs(resolved_price_rub - callback_price_rub) > 0.01:
|
||||
logging.warning(
|
||||
"SeverPay: callback price mismatch for user %s, value=%s, mode=%s, callback=%.2f, resolved=%.2f",
|
||||
user_id,
|
||||
months,
|
||||
sale_mode,
|
||||
callback_price_rub,
|
||||
resolved_price_rub,
|
||||
)
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_severpay.py: %s", exc)
|
||||
return
|
||||
|
||||
price_rub = resolved_price_rub
|
||||
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"
|
||||
currency_code = "RUB"
|
||||
|
||||
# Price is already discounted at payments_subscription.py stage
|
||||
# Service will handle discount metadata if needed
|
||||
payment_record_payload = {
|
||||
"user_id": user_id,
|
||||
"amount": price_rub,
|
||||
"original_amount": None,
|
||||
"discount_applied": None,
|
||||
"currency": currency_code,
|
||||
"status": "pending_severpay",
|
||||
"description": payment_description,
|
||||
"subscription_duration_months": int(months),
|
||||
"provider": "severpay",
|
||||
"promo_code_id": None,
|
||||
}
|
||||
|
||||
try:
|
||||
@@ -88,12 +133,12 @@ async def pay_severpay_callback_handler(
|
||||
)
|
||||
try:
|
||||
await callback.message.edit_text(get_text("error_creating_payment_record"))
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_severpay.py: %s", exc)
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_severpay.py: %s", exc)
|
||||
return
|
||||
|
||||
success, response_data = await severpay_service.create_payment(
|
||||
@@ -103,6 +148,8 @@ async def pay_severpay_callback_handler(
|
||||
amount=price_rub,
|
||||
currency=currency_code,
|
||||
description=payment_description,
|
||||
promo_code_service=promo_code_service,
|
||||
session=session,
|
||||
)
|
||||
|
||||
if success:
|
||||
@@ -164,12 +211,12 @@ async def pay_severpay_callback_handler(
|
||||
),
|
||||
disable_web_page_preview=False,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_severpay.py: %s", exc)
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_severpay.py: %s", exc)
|
||||
return
|
||||
|
||||
logging.error(
|
||||
@@ -191,9 +238,9 @@ async def pay_severpay_callback_handler(
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(get_text("error_payment_gateway"))
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_severpay.py: %s", exc)
|
||||
try:
|
||||
await callback.answer(get_text("error_payment_gateway"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_severpay.py: %s", exc)
|
||||
|
||||
@@ -19,6 +19,7 @@ async def pay_stars_callback_handler(
|
||||
i18n_data: dict,
|
||||
session: AsyncSession,
|
||||
stars_service: StarsService,
|
||||
promo_code_service=None,
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
@@ -27,15 +28,15 @@ async def pay_stars_callback_handler(
|
||||
if not i18n or not callback.message:
|
||||
try:
|
||||
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_stars.py: %s", exc)
|
||||
return
|
||||
|
||||
if not settings.STARS_ENABLED:
|
||||
try:
|
||||
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_stars.py: %s", exc)
|
||||
return
|
||||
|
||||
try:
|
||||
@@ -47,8 +48,8 @@ async def pay_stars_callback_handler(
|
||||
except (ValueError, IndexError):
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_stars.py: %s", exc)
|
||||
return
|
||||
|
||||
user_id = callback.from_user.id
|
||||
@@ -66,6 +67,7 @@ async def pay_stars_callback_handler(
|
||||
stars_price=stars_price,
|
||||
description=payment_description,
|
||||
sale_mode=sale_mode,
|
||||
promo_code_service=promo_code_service,
|
||||
)
|
||||
|
||||
if payment_db_id:
|
||||
@@ -76,34 +78,44 @@ async def pay_stars_callback_handler(
|
||||
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}",
|
||||
)]
|
||||
]),
|
||||
reply_markup=InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=get_text("back_to_payment_methods_button"),
|
||||
callback_data=f"subscribe_period:{human_value}",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=get_text("cancel_button"),
|
||||
callback_data="main_action:subscribe",
|
||||
)
|
||||
],
|
||||
]
|
||||
),
|
||||
)
|
||||
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
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_stars.py: %s", exc)
|
||||
return
|
||||
|
||||
try:
|
||||
await callback.answer(get_text("error_payment_gateway"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_stars.py: %s", exc)
|
||||
|
||||
|
||||
@router.pre_checkout_query()
|
||||
async def handle_pre_checkout_query(query: types.PreCheckoutQuery):
|
||||
try:
|
||||
await query.answer(ok=True)
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
# Nothing else to do here; Telegram will show an error if not answered
|
||||
pass
|
||||
logging.debug("Failed to answer pre_checkout_query in payments_stars: %s", exc)
|
||||
|
||||
|
||||
@router.message(F.successful_payment)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
import math
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import F, Router, types
|
||||
@@ -11,12 +12,43 @@ from config.settings import Settings
|
||||
router = Router(name="user_subscription_payments_selection_router")
|
||||
|
||||
|
||||
async def resolve_fiat_offer_price_for_user(
|
||||
session: AsyncSession,
|
||||
settings: Settings,
|
||||
user_id: int,
|
||||
months: float,
|
||||
sale_mode: str,
|
||||
promo_code_service=None,
|
||||
) -> Optional[float]:
|
||||
"""Resolve offer price server-side to prevent callback payload tampering."""
|
||||
price_source = (
|
||||
getattr(settings, "traffic_packages", {}) or {}
|
||||
if sale_mode == "traffic"
|
||||
else (settings.subscription_options or {})
|
||||
)
|
||||
base_price = price_source.get(months)
|
||||
if base_price is None:
|
||||
return None
|
||||
|
||||
resolved_price = float(base_price)
|
||||
if promo_code_service:
|
||||
active_discount_info = await promo_code_service.get_user_active_discount(session, user_id)
|
||||
if active_discount_info:
|
||||
discount_pct, _ = active_discount_info
|
||||
resolved_price, _ = promo_code_service.calculate_discounted_price(
|
||||
resolved_price,
|
||||
discount_pct,
|
||||
)
|
||||
return resolved_price
|
||||
|
||||
|
||||
@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,
|
||||
promo_code_service=None, # Injected from dispatcher
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
@@ -25,8 +57,8 @@ async def select_subscription_period_callback_handler(
|
||||
if not i18n or not callback.message:
|
||||
try:
|
||||
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_subscription.py: %s", exc)
|
||||
return
|
||||
|
||||
traffic_packages = getattr(settings, "traffic_packages", {}) or {}
|
||||
@@ -38,8 +70,8 @@ async def select_subscription_period_callback_handler(
|
||||
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
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_subscription.py: %s", exc)
|
||||
return
|
||||
|
||||
price_source = traffic_packages if traffic_mode else settings.subscription_options
|
||||
@@ -47,7 +79,49 @@ async def select_subscription_period_callback_handler(
|
||||
|
||||
price_rub = price_source.get(months)
|
||||
stars_price = stars_price_source.get(months)
|
||||
currency_symbol_val = settings.DEFAULT_CURRENCY_SYMBOL
|
||||
currency_symbol_val = "RUB"
|
||||
|
||||
# Check for active discount and apply if exists
|
||||
discount_text = ""
|
||||
if promo_code_service and (price_rub is not None or stars_price is not None):
|
||||
active_discount_info = await promo_code_service.get_user_active_discount(
|
||||
session, callback.from_user.id
|
||||
)
|
||||
|
||||
if active_discount_info:
|
||||
discount_pct, promo_code = active_discount_info
|
||||
if price_rub is not None:
|
||||
original_price_rub = price_rub
|
||||
price_rub, discount_amt = promo_code_service.calculate_discounted_price(
|
||||
price_rub, discount_pct
|
||||
)
|
||||
discount_text = get_text(
|
||||
"active_discount_notice",
|
||||
code=promo_code,
|
||||
discount_pct=discount_pct,
|
||||
original_price=original_price_rub,
|
||||
discounted_price=price_rub,
|
||||
discount_amount=discount_amt,
|
||||
currency_symbol=currency_symbol_val,
|
||||
)
|
||||
if stars_price is not None:
|
||||
original_stars_price = stars_price
|
||||
discounted_stars_price, _ = promo_code_service.calculate_discounted_price(
|
||||
float(stars_price), discount_pct
|
||||
)
|
||||
discounted_stars_price = math.ceil(discounted_stars_price)
|
||||
stars_price = discounted_stars_price
|
||||
if not discount_text:
|
||||
discount_amt = original_stars_price - discounted_stars_price
|
||||
discount_text = get_text(
|
||||
"active_discount_notice",
|
||||
code=promo_code,
|
||||
discount_pct=discount_pct,
|
||||
original_price=original_stars_price,
|
||||
discounted_price=discounted_stars_price,
|
||||
discount_amount=discount_amt,
|
||||
currency_symbol="⭐",
|
||||
)
|
||||
|
||||
if price_rub is None:
|
||||
if traffic_mode and not price_source and stars_price is not None:
|
||||
@@ -67,8 +141,8 @@ async def select_subscription_period_callback_handler(
|
||||
)
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_subscription.py: %s", exc)
|
||||
return
|
||||
price_rub = 0.0
|
||||
currency_symbol_val = "⭐"
|
||||
@@ -78,11 +152,14 @@ async def select_subscription_period_callback_handler(
|
||||
)
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_subscription.py: %s", exc)
|
||||
return
|
||||
|
||||
text_content = get_text("choose_payment_method_traffic") if traffic_mode else get_text("choose_payment_method")
|
||||
if discount_text:
|
||||
text_content = f"{discount_text}\n\n{text_content}"
|
||||
|
||||
reply_markup = get_payment_method_keyboard(
|
||||
months,
|
||||
price_rub,
|
||||
@@ -103,5 +180,5 @@ async def select_subscription_period_callback_handler(
|
||||
await callback.message.answer(text_content, reply_markup=reply_markup)
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_subscription.py: %s", exc)
|
||||
|
||||
@@ -13,11 +13,13 @@ from bot.keyboards.inline.user_keyboards import (
|
||||
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
|
||||
from db.dal import payment_dal, user_billing_dal, active_discount_dal
|
||||
|
||||
router = Router(name="user_subscription_payments_yookassa_router")
|
||||
|
||||
|
||||
from bot.handlers.user.subscription.payments_subscription import resolve_fiat_offer_price_for_user
|
||||
|
||||
def _format_value(val: float) -> str:
|
||||
return str(int(val)) if float(val).is_integer() else f"{val:g}"
|
||||
|
||||
@@ -60,6 +62,7 @@ async def _initiate_yk_payment(
|
||||
settings: Settings,
|
||||
session: AsyncSession,
|
||||
yookassa_service: YooKassaService,
|
||||
promo_code_service, # NEW: Added promo_code_service
|
||||
i18n: Optional[JsonI18n],
|
||||
current_lang: str,
|
||||
get_text,
|
||||
@@ -77,6 +80,46 @@ async def _initiate_yk_payment(
|
||||
if not callback.message:
|
||||
return False
|
||||
|
||||
# Check for active discount to save metadata (price already discounted from previous step)
|
||||
original_price = None
|
||||
discount_amount = None
|
||||
active_promo_code_id = None
|
||||
|
||||
if promo_code_service:
|
||||
active_discount = await active_discount_dal.get_active_discount(session, user_id)
|
||||
if active_discount:
|
||||
# Price is already discounted, calculate original price backwards
|
||||
discount_pct = active_discount.discount_percentage
|
||||
active_promo_code_id = active_discount.promo_code_id
|
||||
denominator = 1 - discount_pct / 100
|
||||
if denominator <= 0:
|
||||
price_source = (
|
||||
getattr(settings, "traffic_packages", {}) or {}
|
||||
if sale_mode == "traffic"
|
||||
else (settings.subscription_options or {})
|
||||
)
|
||||
fallback_original = price_source.get(months)
|
||||
if fallback_original is not None:
|
||||
original_price = fallback_original
|
||||
discount_amount = original_price - price_rub
|
||||
logging.info(
|
||||
f"Recording {discount_pct}% discount for YooKassa payment: "
|
||||
f"original {original_price:.2f} -> final {price_rub}"
|
||||
)
|
||||
else:
|
||||
logging.warning(
|
||||
"YooKassa discount %s%% has invalid denominator and no fallback price for months=%s.",
|
||||
discount_pct,
|
||||
months,
|
||||
)
|
||||
else:
|
||||
original_price = price_rub / denominator
|
||||
discount_amount = original_price - price_rub
|
||||
logging.info(
|
||||
f"Recording {discount_pct}% discount for YooKassa payment: "
|
||||
f"original {original_price:.2f} -> final {price_rub}"
|
||||
)
|
||||
|
||||
payment_description = (
|
||||
get_text("payment_description_traffic", traffic_gb=_format_value(months))
|
||||
if sale_mode == "traffic"
|
||||
@@ -84,11 +127,14 @@ async def _initiate_yk_payment(
|
||||
)
|
||||
payment_record_data = {
|
||||
"user_id": user_id,
|
||||
"amount": price_rub,
|
||||
"amount": price_rub, # Discounted amount
|
||||
"original_amount": original_price if discount_amount else None, # NEW
|
||||
"discount_applied": discount_amount, # NEW
|
||||
"currency": currency_code_for_yk,
|
||||
"status": "pending_yookassa",
|
||||
"description": payment_description,
|
||||
"subscription_duration_months": int(months),
|
||||
"promo_code_id": active_promo_code_id, # NEW: Link to promo code
|
||||
}
|
||||
|
||||
db_payment_record = None
|
||||
@@ -106,15 +152,15 @@ async def _initiate_yk_payment(
|
||||
)
|
||||
try:
|
||||
await callback.message.edit_text(get_text("error_creating_payment_record"))
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
return False
|
||||
|
||||
if not db_payment_record:
|
||||
try:
|
||||
await callback.message.edit_text(get_text("error_creating_payment_record"))
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
return False
|
||||
|
||||
yookassa_metadata = {
|
||||
@@ -178,8 +224,8 @@ async def _initiate_yk_payment(
|
||||
card_network=display_network,
|
||||
set_default=save_payment_method,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
@@ -207,8 +253,8 @@ async def _initiate_yk_payment(
|
||||
)
|
||||
try:
|
||||
await callback.message.edit_text(get_text("error_payment_gateway_link_failed"))
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
return False
|
||||
|
||||
try:
|
||||
@@ -247,8 +293,8 @@ async def _initiate_yk_payment(
|
||||
),
|
||||
disable_web_page_preview=False,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
return True
|
||||
|
||||
if payment_response_yk and payment_method_id:
|
||||
@@ -276,8 +322,8 @@ async def _initiate_yk_payment(
|
||||
)
|
||||
try:
|
||||
await callback.message.edit_text(get_text("error_payment_gateway"))
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
return False
|
||||
|
||||
message_text = get_text("yookassa_autopay_charge_initiated")
|
||||
@@ -293,8 +339,8 @@ async def _initiate_yk_payment(
|
||||
message_text,
|
||||
reply_markup=get_back_to_main_menu_markup(current_lang, i18n),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
return True
|
||||
|
||||
try:
|
||||
@@ -313,13 +359,13 @@ async def _initiate_yk_payment(
|
||||
)
|
||||
try:
|
||||
await callback.message.edit_text(get_text("error_payment_gateway"))
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
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):
|
||||
async def pay_yk_callback_handler(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, yookassa_service: YooKassaService, session: AsyncSession, promo_code_service=None):
|
||||
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
|
||||
@@ -327,8 +373,8 @@ async def pay_yk_callback_handler(callback: types.CallbackQuery, settings: Setti
|
||||
if not i18n or not callback.message:
|
||||
try:
|
||||
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
return
|
||||
|
||||
if not yookassa_service or not yookassa_service.configured:
|
||||
@@ -337,8 +383,8 @@ async def pay_yk_callback_handler(callback: types.CallbackQuery, settings: Setti
|
||||
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
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
return
|
||||
|
||||
try:
|
||||
@@ -347,8 +393,8 @@ async def pay_yk_callback_handler(callback: types.CallbackQuery, settings: Setti
|
||||
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
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
return
|
||||
|
||||
parsed = _parse_offer_payload(data_payload)
|
||||
@@ -356,12 +402,50 @@ async def pay_yk_callback_handler(callback: types.CallbackQuery, settings: Setti
|
||||
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
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
return
|
||||
|
||||
months, price_rub, sale_mode = parsed
|
||||
months, callback_price_rub, sale_mode = parsed
|
||||
user_id = callback.from_user.id
|
||||
|
||||
resolved_price_rub = await resolve_fiat_offer_price_for_user(
|
||||
session=session,
|
||||
settings=settings,
|
||||
user_id=user_id,
|
||||
months=months,
|
||||
sale_mode=sale_mode,
|
||||
promo_code_service=promo_code_service,
|
||||
)
|
||||
if resolved_price_rub is None:
|
||||
logging.warning(
|
||||
"YooKassa: no server-side price for user %s, value=%s, mode=%s",
|
||||
user_id,
|
||||
months,
|
||||
sale_mode,
|
||||
)
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
return
|
||||
|
||||
if abs(resolved_price_rub - callback_price_rub) > 0.01:
|
||||
logging.warning(
|
||||
"YooKassa: callback price mismatch for user %s, value=%s, mode=%s, callback=%.2f, resolved=%.2f",
|
||||
user_id,
|
||||
months,
|
||||
sale_mode,
|
||||
callback_price_rub,
|
||||
resolved_price_rub,
|
||||
)
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
return
|
||||
|
||||
price_rub = resolved_price_rub
|
||||
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(
|
||||
@@ -404,12 +488,12 @@ async def pay_yk_callback_handler(callback: types.CallbackQuery, settings: Setti
|
||||
sale_mode=sale_mode,
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
return
|
||||
|
||||
await _initiate_yk_payment(
|
||||
@@ -417,6 +501,7 @@ async def pay_yk_callback_handler(callback: types.CallbackQuery, settings: Setti
|
||||
settings=settings,
|
||||
session=session,
|
||||
yookassa_service=yookassa_service,
|
||||
promo_code_service=promo_code_service,
|
||||
i18n=i18n,
|
||||
current_lang=current_lang,
|
||||
get_text=get_text,
|
||||
@@ -430,12 +515,12 @@ async def pay_yk_callback_handler(callback: types.CallbackQuery, settings: Setti
|
||||
)
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
|
||||
|
||||
@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):
|
||||
async def pay_yk_new_card_handler(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, yookassa_service: YooKassaService, session: AsyncSession, promo_code_service=None):
|
||||
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
|
||||
@@ -443,20 +528,20 @@ async def pay_yk_new_card_handler(callback: types.CallbackQuery, settings: Setti
|
||||
if not i18n or not callback.message:
|
||||
try:
|
||||
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
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
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
try:
|
||||
await callback.message.edit_text(get_text("payment_service_unavailable"))
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
return
|
||||
|
||||
try:
|
||||
@@ -465,8 +550,8 @@ async def pay_yk_new_card_handler(callback: types.CallbackQuery, settings: Setti
|
||||
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
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
return
|
||||
|
||||
parsed = _parse_offer_payload(data_payload)
|
||||
@@ -474,12 +559,49 @@ async def pay_yk_new_card_handler(callback: types.CallbackQuery, settings: Setti
|
||||
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
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
return
|
||||
|
||||
months, price_rub, sale_mode = parsed
|
||||
months, callback_price_rub, sale_mode = parsed
|
||||
user_id = callback.from_user.id
|
||||
resolved_price_rub = await resolve_fiat_offer_price_for_user(
|
||||
session=session,
|
||||
settings=settings,
|
||||
user_id=user_id,
|
||||
months=months,
|
||||
sale_mode=sale_mode,
|
||||
promo_code_service=promo_code_service,
|
||||
)
|
||||
if resolved_price_rub is None:
|
||||
logging.warning(
|
||||
"YooKassa: no server-side price for new-card flow, user=%s, value=%s, mode=%s",
|
||||
user_id,
|
||||
months,
|
||||
sale_mode,
|
||||
)
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
return
|
||||
|
||||
if abs(resolved_price_rub - callback_price_rub) > 0.01:
|
||||
logging.warning(
|
||||
"YooKassa: callback price mismatch in new-card flow, user=%s, value=%s, mode=%s, callback=%.2f, resolved=%.2f",
|
||||
user_id,
|
||||
months,
|
||||
sale_mode,
|
||||
callback_price_rub,
|
||||
resolved_price_rub,
|
||||
)
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
return
|
||||
|
||||
price_rub = resolved_price_rub
|
||||
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(
|
||||
@@ -491,6 +613,7 @@ async def pay_yk_new_card_handler(callback: types.CallbackQuery, settings: Setti
|
||||
settings=settings,
|
||||
session=session,
|
||||
yookassa_service=yookassa_service,
|
||||
promo_code_service=promo_code_service,
|
||||
i18n=i18n,
|
||||
current_lang=current_lang,
|
||||
get_text=get_text,
|
||||
@@ -504,12 +627,12 @@ async def pay_yk_new_card_handler(callback: types.CallbackQuery, settings: Setti
|
||||
)
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
|
||||
|
||||
@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):
|
||||
async def pay_yk_saved_list_handler(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, yookassa_service: YooKassaService, session: AsyncSession, promo_code_service=None):
|
||||
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
|
||||
@@ -517,8 +640,8 @@ async def pay_yk_saved_list_handler(callback: types.CallbackQuery, settings: Set
|
||||
if not i18n or not callback.message:
|
||||
try:
|
||||
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
return
|
||||
|
||||
try:
|
||||
@@ -527,8 +650,8 @@ async def pay_yk_saved_list_handler(callback: types.CallbackQuery, settings: Set
|
||||
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
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
return
|
||||
|
||||
parts = data_payload.split(":")
|
||||
@@ -536,32 +659,69 @@ async def pay_yk_saved_list_handler(callback: types.CallbackQuery, settings: Set
|
||||
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
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
return
|
||||
|
||||
try:
|
||||
months = float(parts[0])
|
||||
price_rub = float(parts[1])
|
||||
callback_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
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
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
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
return
|
||||
|
||||
user_id = callback.from_user.id
|
||||
resolved_price_rub = await resolve_fiat_offer_price_for_user(
|
||||
session=session,
|
||||
settings=settings,
|
||||
user_id=user_id,
|
||||
months=months,
|
||||
sale_mode=sale_mode,
|
||||
promo_code_service=promo_code_service,
|
||||
)
|
||||
if resolved_price_rub is None:
|
||||
logging.warning(
|
||||
"YooKassa: no server-side price for saved-list flow, user=%s, value=%s, mode=%s",
|
||||
user_id,
|
||||
months,
|
||||
sale_mode,
|
||||
)
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
return
|
||||
|
||||
if abs(resolved_price_rub - callback_price_rub) > 0.01:
|
||||
logging.warning(
|
||||
"YooKassa: callback price mismatch in saved-list flow, user=%s, value=%s, mode=%s, callback=%.2f, resolved=%.2f",
|
||||
user_id,
|
||||
months,
|
||||
sale_mode,
|
||||
callback_price_rub,
|
||||
resolved_price_rub,
|
||||
)
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
return
|
||||
|
||||
price_rub = resolved_price_rub
|
||||
try:
|
||||
saved_methods = await user_billing_dal.list_user_payment_methods(
|
||||
session, user_id, provider="yookassa"
|
||||
@@ -597,12 +757,12 @@ async def pay_yk_saved_list_handler(callback: types.CallbackQuery, settings: Set
|
||||
sale_mode=sale_mode,
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
return
|
||||
|
||||
cards: List[Tuple[str, str]] = []
|
||||
@@ -644,16 +804,16 @@ async def pay_yk_saved_list_handler(callback: types.CallbackQuery, settings: Set
|
||||
sale_mode=sale_mode,
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
|
||||
|
||||
@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):
|
||||
async def pay_yk_use_saved_handler(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, yookassa_service: YooKassaService, session: AsyncSession, promo_code_service=None):
|
||||
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
|
||||
@@ -661,20 +821,20 @@ async def pay_yk_use_saved_handler(callback: types.CallbackQuery, settings: Sett
|
||||
if not i18n or not callback.message:
|
||||
try:
|
||||
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
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
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
try:
|
||||
await callback.message.edit_text(get_text("payment_service_unavailable"))
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
return
|
||||
|
||||
try:
|
||||
@@ -683,8 +843,8 @@ async def pay_yk_use_saved_handler(callback: types.CallbackQuery, settings: Sett
|
||||
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
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
return
|
||||
|
||||
parts = data_payload.split(":")
|
||||
@@ -692,32 +852,67 @@ async def pay_yk_use_saved_handler(callback: types.CallbackQuery, settings: Sett
|
||||
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
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
return
|
||||
|
||||
try:
|
||||
months = float(parts[0])
|
||||
price_rub = float(parts[1])
|
||||
callback_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
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
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
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
return
|
||||
|
||||
method_identifier = parts[2]
|
||||
user_id = callback.from_user.id
|
||||
resolved_price_rub = await resolve_fiat_offer_price_for_user(
|
||||
session=session,
|
||||
settings=settings,
|
||||
user_id=user_id,
|
||||
months=months,
|
||||
sale_mode=sale_mode,
|
||||
promo_code_service=promo_code_service,
|
||||
)
|
||||
if resolved_price_rub is None:
|
||||
logging.warning(
|
||||
"YooKassa: no server-side price for use-saved flow, user=%s, value=%s, mode=%s",
|
||||
user_id,
|
||||
months,
|
||||
sale_mode,
|
||||
)
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
return
|
||||
|
||||
if abs(resolved_price_rub - callback_price_rub) > 0.01:
|
||||
logging.warning(
|
||||
"YooKassa: callback price mismatch in use-saved flow, user=%s, value=%s, mode=%s, callback=%.2f, resolved=%.2f",
|
||||
user_id,
|
||||
months,
|
||||
sale_mode,
|
||||
callback_price_rub,
|
||||
resolved_price_rub,
|
||||
)
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
return
|
||||
|
||||
try:
|
||||
saved_methods = await user_billing_dal.list_user_payment_methods(
|
||||
@@ -741,10 +936,11 @@ async def pay_yk_use_saved_handler(callback: types.CallbackQuery, settings: Sett
|
||||
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
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
return
|
||||
|
||||
price_rub = resolved_price_rub
|
||||
currency_code_for_yk = "RUB"
|
||||
|
||||
await _initiate_yk_payment(
|
||||
@@ -752,6 +948,7 @@ async def pay_yk_use_saved_handler(callback: types.CallbackQuery, settings: Sett
|
||||
settings=settings,
|
||||
session=session,
|
||||
yookassa_service=yookassa_service,
|
||||
promo_code_service=promo_code_service,
|
||||
i18n=i18n,
|
||||
current_lang=current_lang,
|
||||
get_text=get_text,
|
||||
@@ -767,5 +964,5 @@ async def pay_yk_use_saved_handler(callback: types.CallbackQuery, settings: Sett
|
||||
)
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
|
||||
@@ -35,8 +35,8 @@ async def request_trial_confirmation_handler(
|
||||
if not i18n or not callback.message:
|
||||
try:
|
||||
await callback.answer(_("error_occurred_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/trial_handler.py: %s", exc)
|
||||
return
|
||||
|
||||
show_trial_btn_in_menu_if_fail = False
|
||||
@@ -53,8 +53,8 @@ async def request_trial_confirmation_handler(
|
||||
)
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/trial_handler.py: %s", exc)
|
||||
return
|
||||
|
||||
if await subscription_service.has_had_any_subscription(session, user_id):
|
||||
@@ -66,8 +66,8 @@ async def request_trial_confirmation_handler(
|
||||
)
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/trial_handler.py: %s", exc)
|
||||
return
|
||||
|
||||
# Directly activate trial without confirmation
|
||||
@@ -84,8 +84,8 @@ async def request_trial_confirmation_handler(
|
||||
if activation_result and activation_result.get("activated"):
|
||||
try:
|
||||
await callback.answer(_("trial_activated_alert"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/trial_handler.py: %s", exc)
|
||||
|
||||
end_date_obj = activation_result.get("end_date")
|
||||
config_link_display_for_trial, connect_button_url_for_trial = await prepare_config_links(
|
||||
@@ -134,8 +134,8 @@ async def request_trial_confirmation_handler(
|
||||
final_message_text_in_chat = _(message_key_from_service)
|
||||
try:
|
||||
await callback.answer(final_message_text_in_chat, show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/trial_handler.py: %s", exc)
|
||||
if (
|
||||
settings.TRIAL_ENABLED
|
||||
and not await subscription_service.has_had_any_subscription(
|
||||
@@ -197,15 +197,15 @@ async def confirm_activate_trial_handler(
|
||||
if not i18n or not callback.message:
|
||||
try:
|
||||
await callback.answer(_("error_occurred_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/trial_handler.py: %s", exc)
|
||||
return
|
||||
|
||||
if not settings.TRIAL_ENABLED:
|
||||
try:
|
||||
await callback.answer(_("trial_feature_disabled"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/trial_handler.py: %s", exc)
|
||||
|
||||
await send_main_menu(
|
||||
callback, settings, i18n_data, subscription_service, session, is_edit=True
|
||||
@@ -216,8 +216,8 @@ async def confirm_activate_trial_handler(
|
||||
await callback.answer(
|
||||
_("trial_already_had_subscription_or_trial"), show_alert=True
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/trial_handler.py: %s", exc)
|
||||
await send_main_menu(
|
||||
callback, settings, i18n_data, subscription_service, session, is_edit=True
|
||||
)
|
||||
@@ -236,8 +236,8 @@ async def confirm_activate_trial_handler(
|
||||
if activation_result and activation_result.get("activated"):
|
||||
try:
|
||||
await callback.answer(_("trial_activated_alert"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/trial_handler.py: %s", exc)
|
||||
|
||||
end_date_obj = activation_result.get("end_date")
|
||||
config_link_display_for_trial, connect_button_url_for_trial = await prepare_config_links(
|
||||
@@ -274,8 +274,8 @@ async def confirm_activate_trial_handler(
|
||||
final_message_text_in_chat = _(message_key_from_service)
|
||||
try:
|
||||
await callback.answer(final_message_text_in_chat, show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/trial_handler.py: %s", exc)
|
||||
if (
|
||||
settings.TRIAL_ENABLED
|
||||
and not await subscription_service.has_had_any_subscription(
|
||||
|
||||
@@ -64,12 +64,10 @@ def get_user_management_keyboard(i18n_instance, lang: str) -> InlineKeyboardMark
|
||||
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, 2, 1)
|
||||
builder.adjust(2, 1, 1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
@@ -268,8 +266,6 @@ 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}")
|
||||
|
||||
@@ -323,8 +319,6 @@ def get_users_list_keyboard(users: List[User], current_page: int,
|
||||
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}")
|
||||
@@ -466,21 +460,3 @@ 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,50 +13,11 @@ 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"))
|
||||
@@ -67,14 +28,16 @@ def get_bot_interface_inline_keyboard(
|
||||
)
|
||||
)
|
||||
|
||||
referral_button = InlineKeyboardButton(
|
||||
text=_(key="menu_referral_inline"),
|
||||
callback_data="main_action:referral")
|
||||
promo_button = InlineKeyboardButton(
|
||||
text=_(key="menu_apply_promo_button"),
|
||||
callback_data="main_action:apply_promo")
|
||||
builder.row(referral_button)
|
||||
builder.row(promo_button)
|
||||
if settings.REFERRAL_ENABLED:
|
||||
referral_button = InlineKeyboardButton(
|
||||
text=_(key="menu_referral_inline"),
|
||||
callback_data="main_action:referral")
|
||||
builder.row(referral_button, promo_button)
|
||||
else:
|
||||
builder.row(promo_button)
|
||||
|
||||
language_button = InlineKeyboardButton(
|
||||
text=_(key="menu_language_settings_inline"),
|
||||
@@ -95,36 +58,14 @@ def get_bot_interface_inline_keyboard(
|
||||
InlineKeyboardButton(text=_(key="menu_support_button"),
|
||||
url=settings.SUPPORT_LINK))
|
||||
|
||||
user_agreement_url = settings.USER_AGREEMENT_URL or settings.TERMS_OF_SERVICE_URL
|
||||
if settings.PRIVACY_POLICY_URL or user_agreement_url:
|
||||
if settings.TERMS_OF_SERVICE_URL:
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=_(key="menu_info_button"),
|
||||
callback_data="main_action:info"))
|
||||
InlineKeyboardButton(text=_(key="menu_terms_button"),
|
||||
url=settings.TERMS_OF_SERVICE_URL))
|
||||
|
||||
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
|
||||
@@ -195,14 +136,6 @@ def get_payment_method_keyboard(months: int, price: float,
|
||||
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(
|
||||
@@ -214,15 +147,10 @@ def get_payment_method_keyboard(months: int, price: float,
|
||||
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:
|
||||
elif method == "platega" and settings.PLATEGA_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}",
|
||||
text=_("pay_with_platega_button"),
|
||||
callback_data=f"pay_platega:{value_str}:{price}{mode_suffix}",
|
||||
)
|
||||
elif method == "yookassa" and settings.YOOKASSA_ENABLED:
|
||||
builder.button(
|
||||
@@ -457,17 +385,17 @@ def get_connect_and_main_keyboard(
|
||||
builder = InlineKeyboardBuilder()
|
||||
button_target = connect_button_url or config_link
|
||||
|
||||
if button_target:
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=_("connect_button"), url=button_target)
|
||||
)
|
||||
elif settings.SUBSCRIPTION_MINI_APP_URL:
|
||||
if settings.SUBSCRIPTION_MINI_APP_URL:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_("connect_button"),
|
||||
web_app=WebAppInfo(url=settings.SUBSCRIPTION_MINI_APP_URL),
|
||||
)
|
||||
)
|
||||
elif button_target:
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=_("connect_button"), url=button_target)
|
||||
)
|
||||
else:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
|
||||
+53
-57
@@ -40,12 +40,6 @@ 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.")
|
||||
@@ -64,49 +58,49 @@ 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.telegram_webhook_path}"
|
||||
)
|
||||
full_telegram_webhook_url = settings.telegram_full_webhook_url
|
||||
if not full_telegram_webhook_url:
|
||||
logging.error(
|
||||
"STARTUP: Telegram webhook URL could not be built (WEBHOOK_BASE_URL missing)."
|
||||
)
|
||||
raise SystemExit("WEBHOOK_BASE_URL is required. Polling mode is disabled.")
|
||||
|
||||
logging.info(
|
||||
"STARTUP: Attempting to set Telegram webhook to: %s",
|
||||
redact_token(full_telegram_webhook_url, settings.BOT_TOKEN),
|
||||
"STARTUP: Attempting to set Telegram webhook (path=%s)",
|
||||
settings.telegram_webhook_path,
|
||||
)
|
||||
|
||||
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)}"
|
||||
)
|
||||
if current_webhook_info.url:
|
||||
logging.info("STARTUP: Telegram webhook already set (non-empty URL).")
|
||||
else:
|
||||
logging.info("STARTUP: Telegram webhook currently empty (will set).")
|
||||
|
||||
telegram_webhook_secret = (settings.TELEGRAM_WEBHOOK_SECRET or "").strip() or None
|
||||
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(),
|
||||
secret_token=telegram_webhook_secret,
|
||||
)
|
||||
if set_success:
|
||||
logging.info(
|
||||
"STARTUP: bot.set_webhook to %s returned SUCCESS (True).",
|
||||
redact_token(full_telegram_webhook_url, settings.BOT_TOKEN),
|
||||
)
|
||||
logging.info("STARTUP: bot.set_webhook returned SUCCESS (True).")
|
||||
else:
|
||||
logging.error(
|
||||
"STARTUP: bot.set_webhook to %s returned FAILURE (False).",
|
||||
redact_token(full_telegram_webhook_url, settings.BOT_TOKEN),
|
||||
)
|
||||
logging.error("STARTUP: bot.set_webhook 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:
|
||||
logging.exception("STARTUP: EXCEPTION during set/get Telegram webhook.")
|
||||
except Exception as e_setwebhook:
|
||||
logging.error(
|
||||
"STARTUP: EXCEPTION during set/get Telegram webhook: %s",
|
||||
e_setwebhook,
|
||||
exc_info=True,
|
||||
)
|
||||
else:
|
||||
logging.error(
|
||||
"STARTUP: WEBHOOK_BASE_URL not set in environment. Webhook mode is required. Exiting."
|
||||
@@ -117,7 +111,7 @@ async def on_startup_configured(dispatcher: Dispatcher):
|
||||
try:
|
||||
menu_text = i18n_instance.gettext(
|
||||
settings.DEFAULT_LANGUAGE,
|
||||
"menu_personal_account_button",
|
||||
"menu_my_subscription_inline",
|
||||
)
|
||||
await bot.set_chat_menu_button(
|
||||
menu_button=MenuButtonWebApp(
|
||||
@@ -129,31 +123,39 @@ async def on_startup_configured(dispatcher: Dispatcher):
|
||||
logging.info(
|
||||
"STARTUP: Mini app domain registered and default menu button restored."
|
||||
)
|
||||
except Exception:
|
||||
logging.exception("STARTUP: Failed to register mini app domain.")
|
||||
|
||||
try:
|
||||
bot_commands = [
|
||||
BotCommand(command="tg", description="Интерфейс в боте"),
|
||||
]
|
||||
if settings.START_COMMAND_DESCRIPTION:
|
||||
bot_commands.insert(
|
||||
0,
|
||||
BotCommand(command="start",
|
||||
description=settings.START_COMMAND_DESCRIPTION),
|
||||
except Exception as e:
|
||||
logging.error(
|
||||
f"STARTUP: Failed to register mini app domain: {e}", exc_info=True
|
||||
)
|
||||
await bot.set_my_commands(bot_commands)
|
||||
logging.info("STARTUP: bot command descriptions set.")
|
||||
except Exception:
|
||||
logging.exception("STARTUP: Failed to set bot commands.")
|
||||
|
||||
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)
|
||||
|
||||
# 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:
|
||||
logging.exception("STARTUP: Failed to initialize message queue manager.")
|
||||
except Exception as e:
|
||||
logging.error(f"STARTUP: Failed to initialize message queue manager: {e}", exc_info=True)
|
||||
|
||||
# Initialize promo discount expiration worker
|
||||
try:
|
||||
promo_code_service: Optional[PromoCodeService] = dispatcher.get("promo_code_service")
|
||||
if promo_code_service:
|
||||
await promo_code_service.setup_discount_expiration_worker(async_session_factory)
|
||||
logging.info("STARTUP: Promo discount expiration worker initialized")
|
||||
except Exception as e:
|
||||
logging.error(
|
||||
f"STARTUP: Failed to initialize promo discount expiration worker: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# Automatic sync on startup
|
||||
try:
|
||||
@@ -172,8 +174,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:
|
||||
logging.exception("STARTUP: Failed to run automatic sync.")
|
||||
except Exception as e:
|
||||
logging.error(f"STARTUP: Failed to run automatic sync: {e}", exc_info=True)
|
||||
|
||||
logging.info("STARTUP: Bot on_startup_configured completed.")
|
||||
|
||||
@@ -249,14 +251,8 @@ async def run_bot(settings_param: Settings):
|
||||
actual_bot_username = "your_bot_username"
|
||||
try:
|
||||
bot_info = await bot.get_me()
|
||||
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."
|
||||
)
|
||||
actual_bot_username = bot_info.username
|
||||
logging.info(f"Bot username resolved: @{actual_bot_username}")
|
||||
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,13 +40,14 @@ class ActionLoggerMiddleware(BaseMiddleware):
|
||||
is_admin_event_flag = True
|
||||
|
||||
raw_update_snippet = None
|
||||
try:
|
||||
raw_update_snippet = event.model_dump_json(exclude_none=True,
|
||||
indent=None)[:1000]
|
||||
except AttributeError:
|
||||
raw_update_snippet = str(event)[:1000]
|
||||
except Exception:
|
||||
raw_update_snippet = str(event)[:1000]
|
||||
if self.settings.LOG_STORE_RAW_UPDATES:
|
||||
try:
|
||||
raw_update_snippet = event.model_dump_json(exclude_none=True,
|
||||
indent=None)[:1000]
|
||||
except AttributeError:
|
||||
raw_update_snippet = str(event)[:1000]
|
||||
except Exception:
|
||||
raw_update_snippet = str(event)[:1000]
|
||||
|
||||
current_event_type = event.event_type
|
||||
|
||||
@@ -83,7 +84,7 @@ class ActionLoggerMiddleware(BaseMiddleware):
|
||||
"telegram_username": telegram_username,
|
||||
"telegram_first_name": telegram_first_name,
|
||||
"event_type": current_event_type,
|
||||
"content": content[:1000] if content else "N/A",
|
||||
"content": (content[:1000] if content else "N/A") if self.settings.LOG_STORE_MESSAGE_CONTENT else None,
|
||||
"raw_update_preview": raw_update_snippet,
|
||||
"is_admin_event": is_admin_event_flag,
|
||||
"target_user_id": target_user_id_for_log,
|
||||
|
||||
@@ -32,6 +32,9 @@ class ChannelSubscriptionMiddleware(BaseMiddleware):
|
||||
event: Update,
|
||||
data: Dict[str, Any],
|
||||
) -> Any:
|
||||
if not self.settings.REQUIRED_CHANNEL_SUBSCRIBE_TO_USE:
|
||||
return await handler(event, data)
|
||||
|
||||
required_channel_id = self.settings.REQUIRED_CHANNEL_ID
|
||||
if not required_channel_id:
|
||||
return await handler(event, data)
|
||||
@@ -124,8 +127,8 @@ class ChannelSubscriptionMiddleware(BaseMiddleware):
|
||||
) -> None:
|
||||
try:
|
||||
await callback.answer(prompt_text, show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Failed to answer callback for channel gate prompt: %s", exc)
|
||||
|
||||
if callback.message:
|
||||
try:
|
||||
|
||||
@@ -22,17 +22,13 @@ class ProfileSyncMiddleware(BaseMiddleware):
|
||||
|
||||
if session and tg_user:
|
||||
try:
|
||||
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)
|
||||
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:
|
||||
@@ -41,7 +37,7 @@ class ProfileSyncMiddleware(BaseMiddleware):
|
||||
update_payload["last_name"] = sanitized_last_name
|
||||
|
||||
if update_payload:
|
||||
await user_dal.update_user(session, db_user.user_id, update_payload)
|
||||
await user_dal.update_user(session, tg_user.id, update_payload)
|
||||
logging.info(
|
||||
f"ProfileSyncMiddleware: Updated user {tg_user.id} profile fields: {list(update_payload.keys())}"
|
||||
)
|
||||
@@ -51,20 +47,13 @@ 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,
|
||||
panel_payload,
|
||||
{"description": description_text},
|
||||
)
|
||||
except Exception as e_upd_desc:
|
||||
logging.warning(
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import hashlib
|
||||
import logging
|
||||
import json
|
||||
import hmac
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import Bot
|
||||
@@ -21,8 +19,6 @@ 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:
|
||||
def __init__(
|
||||
@@ -42,7 +38,6 @@ 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)
|
||||
@@ -70,24 +65,68 @@ class CryptoPayService:
|
||||
amount: float,
|
||||
description: str,
|
||||
sale_mode: str = "subscription",
|
||||
url_kind: str = "bot",
|
||||
promo_code_service=None,
|
||||
) -> Optional[str]:
|
||||
if not self.configured or not self.client:
|
||||
logging.error("CryptoPayService not configured")
|
||||
return None
|
||||
|
||||
# Check for active discount to save metadata (price already discounted from previous step)
|
||||
original_amount = None
|
||||
discount_amount = None
|
||||
promo_code_id = None
|
||||
|
||||
if promo_code_service:
|
||||
from db.dal import active_discount_dal
|
||||
active_discount = await active_discount_dal.get_active_discount(session, user_id)
|
||||
if active_discount:
|
||||
# Price is already discounted, calculate original price backwards
|
||||
discount_pct = active_discount.discount_percentage
|
||||
promo_code_id = active_discount.promo_code_id
|
||||
denominator = 1 - discount_pct / 100
|
||||
if denominator <= 0:
|
||||
price_source = (
|
||||
getattr(self.settings, "traffic_packages", {}) or {}
|
||||
if sale_mode == "traffic"
|
||||
else (self.settings.subscription_options or {})
|
||||
)
|
||||
fallback_original = price_source.get(months)
|
||||
if fallback_original is not None:
|
||||
original_amount = fallback_original
|
||||
discount_amount = original_amount - amount
|
||||
logging.info(
|
||||
f"Recording {discount_pct}% discount for CryptoPay payment: "
|
||||
f"original {original_amount:.2f} -> final {amount}"
|
||||
)
|
||||
else:
|
||||
logging.warning(
|
||||
"CryptoPay discount %s%% has invalid denominator and no fallback price for months=%s.",
|
||||
discount_pct,
|
||||
months,
|
||||
)
|
||||
else:
|
||||
original_amount = amount / denominator
|
||||
discount_amount = original_amount - amount
|
||||
logging.info(
|
||||
f"Recording {discount_pct}% discount for CryptoPay payment: "
|
||||
f"original {original_amount:.2f} -> final {amount}"
|
||||
)
|
||||
|
||||
# Create pending payment in DB and commit to persist
|
||||
try:
|
||||
payment_record = await payment_dal.create_payment_record(
|
||||
session,
|
||||
{
|
||||
"user_id": user_id,
|
||||
"amount": float(amount),
|
||||
"amount": amount,
|
||||
"original_amount": original_amount,
|
||||
"discount_applied": discount_amount,
|
||||
"currency": self.settings.CRYPTOPAY_ASSET,
|
||||
"status": "pending_cryptopay",
|
||||
"description": description,
|
||||
"subscription_duration_months": int(months),
|
||||
"provider": "cryptopay",
|
||||
"promo_code_id": promo_code_id,
|
||||
},
|
||||
)
|
||||
await session.commit()
|
||||
@@ -122,22 +161,16 @@ class CryptoPayService:
|
||||
str(invoice.status),
|
||||
)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
except Exception as e_db_update:
|
||||
await session.rollback()
|
||||
logging.exception(
|
||||
"Failed to update cryptopay payment record %s.",
|
||||
payment_record.payment_id,
|
||||
logging.error(
|
||||
f"Failed to update cryptopay payment record {payment_record.payment_id}: {e_db_update}",
|
||||
exc_info=True,
|
||||
)
|
||||
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:
|
||||
logging.exception("CryptoPay invoice creation failed.")
|
||||
except Exception as e:
|
||||
logging.error(f"CryptoPay invoice creation failed: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
async def _invoice_paid_handler(self, update: Update, app: web.Application):
|
||||
@@ -152,8 +185,8 @@ class CryptoPayService:
|
||||
payment_db_id = int(meta["payment_db_id"])
|
||||
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.")
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to parse CryptoPay payload: {e}")
|
||||
return
|
||||
|
||||
async_session_factory: sessionmaker = app["async_session_factory"]
|
||||
@@ -165,22 +198,92 @@ class CryptoPayService:
|
||||
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
await payment_dal.update_provider_payment_and_status(
|
||||
# Fetch payment record to get promo_code_id
|
||||
payment_record = await payment_dal.get_payment_by_db_id(session, payment_db_id)
|
||||
if not payment_record:
|
||||
logging.error(f"CryptoPay: Payment record {payment_db_id} not found")
|
||||
return
|
||||
|
||||
if payment_record.user_id != user_id:
|
||||
logging.error(
|
||||
"CryptoPay webhook: user mismatch for payment %s (db=%s, payload=%s)",
|
||||
payment_db_id,
|
||||
payment_record.user_id,
|
||||
user_id,
|
||||
)
|
||||
return
|
||||
|
||||
provider_currency = None
|
||||
for candidate in (
|
||||
getattr(invoice, "fiat", None),
|
||||
getattr(invoice, "asset", None),
|
||||
getattr(invoice, "paid_asset", None),
|
||||
settings.CRYPTOPAY_ASSET,
|
||||
):
|
||||
if candidate:
|
||||
provider_currency = str(candidate).upper()
|
||||
break
|
||||
expected_currency = str(payment_record.currency or "").upper()
|
||||
if expected_currency and provider_currency and expected_currency != provider_currency:
|
||||
logging.error(
|
||||
"CryptoPay webhook: currency mismatch for payment %s (expected %s, got %s)",
|
||||
payment_db_id,
|
||||
expected_currency,
|
||||
provider_currency,
|
||||
)
|
||||
return
|
||||
|
||||
if payment_record.status == "succeeded":
|
||||
logging.info("CryptoPay webhook: payment %s already succeeded", payment_db_id)
|
||||
return
|
||||
|
||||
try:
|
||||
expected_amount = float(payment_record.amount)
|
||||
incoming_amount = float(invoice.amount)
|
||||
if round(incoming_amount, 2) != round(expected_amount, 2):
|
||||
logging.error(
|
||||
"CryptoPay webhook: amount mismatch for payment %s (expected %.2f, got %.2f)",
|
||||
payment_db_id,
|
||||
expected_amount,
|
||||
incoming_amount,
|
||||
)
|
||||
return
|
||||
except Exception as amount_exc:
|
||||
logging.error(
|
||||
"CryptoPay webhook: failed to compare amount for payment %s: %s",
|
||||
payment_db_id,
|
||||
amount_exc,
|
||||
)
|
||||
return
|
||||
|
||||
marked = await payment_dal.mark_provider_payment_succeeded_once(
|
||||
session,
|
||||
payment_db_id,
|
||||
str(invoice.invoice_id),
|
||||
"succeeded",
|
||||
)
|
||||
if not marked:
|
||||
logging.info(
|
||||
"CryptoPay webhook: payment %s already processed atomically",
|
||||
payment_db_id,
|
||||
)
|
||||
return
|
||||
|
||||
activation = await subscription_service.activate_subscription(
|
||||
session,
|
||||
user_id,
|
||||
int(months) if sale_mode != "traffic" else 0,
|
||||
float(invoice.amount),
|
||||
payment_db_id,
|
||||
promo_code_id_from_payment=payment_record.promo_code_id,
|
||||
provider="cryptopay",
|
||||
sale_mode=sale_mode,
|
||||
traffic_gb=traffic_gb if sale_mode == "traffic" else None,
|
||||
)
|
||||
if not activation or not activation.get("end_date"):
|
||||
raise RuntimeError(
|
||||
f"CryptoPay webhook: activation failed for payment {payment_db_id}"
|
||||
)
|
||||
|
||||
referral_bonus = None
|
||||
if sale_mode != "traffic":
|
||||
referral_bonus = await referral_service.apply_referral_bonuses_for_payment(
|
||||
@@ -191,9 +294,9 @@ class CryptoPayService:
|
||||
skip_if_active_before_payment=False,
|
||||
)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
logging.exception("Failed to process CryptoPay invoice.")
|
||||
logging.error(f"Failed to process CryptoPay invoice: {e}", exc_info=True)
|
||||
return
|
||||
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
@@ -254,8 +357,8 @@ class CryptoPayService:
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
except Exception:
|
||||
logging.exception("Failed to send CryptoPay success message.")
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to send CryptoPay success message: {e}")
|
||||
|
||||
# Send notification about payment
|
||||
try:
|
||||
@@ -264,36 +367,18 @@ class CryptoPayService:
|
||||
await notification_service.notify_payment_received(
|
||||
user_id=user_id,
|
||||
amount=float(invoice.amount),
|
||||
currency=invoice.asset or settings.DEFAULT_CURRENCY_SYMBOL,
|
||||
currency=invoice.asset or "RUB",
|
||||
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:
|
||||
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
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to send crypto_pay payment notification: {e}")
|
||||
|
||||
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)
|
||||
|
||||
|
||||
|
||||
@@ -1,497 +0,0 @@
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
|
||||
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()
|
||||
|
||||
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}"
|
||||
code_model = EmailVerificationCode(
|
||||
email=normalized_email,
|
||||
code_hash=self._hash_code(normalized_email, purpose, code),
|
||||
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,
|
||||
)
|
||||
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 _send_code_email(
|
||||
self,
|
||||
*,
|
||||
email: str,
|
||||
code: str,
|
||||
language_code: str,
|
||||
) -> None:
|
||||
await asyncio.to_thread(
|
||||
self._send_code_email_sync,
|
||||
email=email,
|
||||
code=code,
|
||||
language_code=language_code,
|
||||
)
|
||||
|
||||
async def send_custom_email(
|
||||
self,
|
||||
*,
|
||||
email: str,
|
||||
subject: str,
|
||||
body: str,
|
||||
) -> None:
|
||||
await asyncio.to_thread(
|
||||
self._send_custom_email_sync,
|
||||
email=email,
|
||||
subject=subject,
|
||||
body=body,
|
||||
)
|
||||
|
||||
def _send_code_email_sync(
|
||||
self,
|
||||
*,
|
||||
email: str,
|
||||
code: str,
|
||||
language_code: str,
|
||||
) -> None:
|
||||
lang = (language_code or self.settings.DEFAULT_LANGUAGE or "ru").split("-")[0]
|
||||
if lang == "en":
|
||||
subject = "Your login code"
|
||||
body = (
|
||||
f"Your verification code: {code}\n\n"
|
||||
f"The code expires in {max(1, int(self.settings.EMAIL_CODE_TTL_SECONDS) // 60)} minutes."
|
||||
)
|
||||
else:
|
||||
subject = "Код подтверждения"
|
||||
body = (
|
||||
f"Ваш код подтверждения: {code}\n\n"
|
||||
f"Код действует {max(1, int(self.settings.EMAIL_CODE_TTL_SECONDS) // 60)} мин."
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
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,
|
||||
) -> 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)
|
||||
|
||||
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)
|
||||
@@ -7,7 +7,6 @@ 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
|
||||
@@ -22,7 +21,6 @@ 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:
|
||||
@@ -46,7 +44,7 @@ class FreeKassaService:
|
||||
self.shop_id: Optional[str] = settings.FREEKASSA_MERCHANT_ID
|
||||
self.api_key: Optional[str] = settings.FREEKASSA_API_KEY
|
||||
self.second_secret: Optional[str] = settings.FREEKASSA_SECOND_SECRET
|
||||
self.default_currency: str = (settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
|
||||
self.default_currency: str = "RUB"
|
||||
self.server_ip: Optional[str] = settings.FREEKASSA_PAYMENT_IP
|
||||
self.payment_method_id: Optional[int] = settings.FREEKASSA_PAYMENT_METHOD_ID
|
||||
|
||||
@@ -80,11 +78,70 @@ class FreeKassaService:
|
||||
ip_address: Optional[str] = None,
|
||||
payment_method_id: Optional[int] = None,
|
||||
extra_params: Optional[Dict[str, Any]] = None,
|
||||
promo_code_service=None,
|
||||
session=None,
|
||||
) -> Tuple[bool, Dict[str, Any]]:
|
||||
if not self.configured:
|
||||
logging.error("FreeKassaService is not configured. Cannot create order.")
|
||||
return False, {"message": "service_not_configured"}
|
||||
|
||||
# Check for active discount to save metadata (price already discounted from previous step)
|
||||
original_amount = None
|
||||
discount_amount = None
|
||||
promo_code_id = None
|
||||
|
||||
if promo_code_service and session:
|
||||
from db.dal import active_discount_dal
|
||||
active_discount = await active_discount_dal.get_active_discount(session, user_id)
|
||||
if active_discount:
|
||||
# Price is already discounted, calculate original price backwards
|
||||
discount_pct = active_discount.discount_percentage
|
||||
promo_code_id = active_discount.promo_code_id
|
||||
denominator = 1 - discount_pct / 100
|
||||
if denominator <= 0:
|
||||
traffic_mode = bool(getattr(self.settings, "traffic_sale_mode", False))
|
||||
price_source = (
|
||||
getattr(self.settings, "traffic_packages", {}) or {}
|
||||
if traffic_mode
|
||||
else (self.settings.subscription_options or {})
|
||||
)
|
||||
fallback_original = price_source.get(months)
|
||||
if fallback_original is not None:
|
||||
original_amount = fallback_original
|
||||
discount_amount = original_amount - amount
|
||||
logging.info(
|
||||
f"Recording {discount_pct}% discount for FreeKassa payment: "
|
||||
f"original {original_amount:.2f} -> final {amount}"
|
||||
)
|
||||
else:
|
||||
logging.warning(
|
||||
"FreeKassa discount %s%% has invalid denominator and no fallback price for months=%s.",
|
||||
discount_pct,
|
||||
months,
|
||||
)
|
||||
else:
|
||||
original_amount = amount / denominator
|
||||
discount_amount = original_amount - amount
|
||||
logging.info(
|
||||
f"Recording {discount_pct}% discount for FreeKassa payment: "
|
||||
f"original {original_amount:.2f} -> final {amount}"
|
||||
)
|
||||
|
||||
# Update payment record with discount metadata
|
||||
try:
|
||||
await payment_dal.update_payment_discount_info(
|
||||
session,
|
||||
payment_db_id,
|
||||
original_amount,
|
||||
discount_amount,
|
||||
promo_code_id,
|
||||
)
|
||||
await session.commit()
|
||||
except Exception as e_update:
|
||||
logging.warning(
|
||||
f"FreeKassa: failed to update discount metadata for payment {payment_db_id}: {e_update}"
|
||||
)
|
||||
|
||||
ip_address = ip_address or self.server_ip
|
||||
if not ip_address:
|
||||
logging.error("FreeKassaService: payment IP is required but not configured.")
|
||||
@@ -137,7 +194,7 @@ class FreeKassaService:
|
||||
|
||||
return True, response_data
|
||||
except Exception as exc:
|
||||
logging.exception("FreeKassa create_order: request failed.")
|
||||
logging.error("FreeKassa create_order: request failed: %s", exc, exc_info=True)
|
||||
return False, {"message": str(exc)}
|
||||
|
||||
async def _get_session(self) -> ClientSession:
|
||||
@@ -171,59 +228,72 @@ class FreeKassaService:
|
||||
|
||||
def _validate_signature(
|
||||
self,
|
||||
raw_body: bytes,
|
||||
merchant_order_id: str,
|
||||
amount: str,
|
||||
provided_signature: str,
|
||||
payload: Optional[Dict[str, Any]] = None,
|
||||
) -> bool:
|
||||
if not provided_signature:
|
||||
return False
|
||||
if not self.second_secret:
|
||||
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)
|
||||
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"),
|
||||
usedforsecurity=False,
|
||||
).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
|
||||
|
||||
async def webhook_route(self, request: web.Request) -> web.Response:
|
||||
if not self.configured:
|
||||
return web.Response(status=503, text="freekassa_disabled")
|
||||
|
||||
try:
|
||||
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.")
|
||||
data = await request.post()
|
||||
except Exception as e:
|
||||
logging.error(f"FreeKassa webhook: failed to read POST data: {e}")
|
||||
return web.Response(status=400, text="bad_request")
|
||||
|
||||
payload_dict: Dict[str, Any] = {}
|
||||
if raw_body:
|
||||
payload_dict: Dict[str, Any]
|
||||
if data:
|
||||
payload_dict = {str(k): v for k, v in data.items()}
|
||||
else:
|
||||
try:
|
||||
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)
|
||||
}
|
||||
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
|
||||
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:
|
||||
return web.Response(status=403)
|
||||
logging.error(f"FreeKassa webhook: merchant mismatch (got {merchant_id})")
|
||||
return web.Response(status=403, text="merchant_mismatch")
|
||||
|
||||
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")
|
||||
@@ -231,9 +301,11 @@ 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(raw_body, signature):
|
||||
if not self._validate_signature(order_id_str, amount_str, signature, payload_dict):
|
||||
logging.error("FreeKassa webhook: invalid signature")
|
||||
return web.Response(status=403, text="invalid_signature")
|
||||
|
||||
try:
|
||||
@@ -248,6 +320,16 @@ class FreeKassaService:
|
||||
logging.error(f"FreeKassa webhook: payment {payment_db_id} not found")
|
||||
return web.Response(status=404, text="payment_not_found")
|
||||
|
||||
if payment.currency and str(payment.currency).upper() != str(self.default_currency or payment.currency).upper():
|
||||
# FreeKassa sends amount without currency; ensure DB currency matches configured service currency
|
||||
logging.error(
|
||||
"FreeKassa webhook: currency mismatch for payment %s (db=%s, expected=%s)",
|
||||
payment_db_id,
|
||||
payment.currency,
|
||||
self.default_currency,
|
||||
)
|
||||
return web.Response(status=400, text="currency_mismatch")
|
||||
|
||||
if payment.status == "succeeded":
|
||||
logging.info(f"FreeKassa webhook: payment {payment_db_id} already succeeded")
|
||||
return web.Response(text="YES")
|
||||
@@ -257,22 +339,30 @@ class FreeKassaService:
|
||||
amount_decimal = Decimal(amount_str)
|
||||
expected_amount = Decimal(str(payment.amount)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
if amount_decimal.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) != expected_amount:
|
||||
logging.warning(
|
||||
logging.error(
|
||||
f"FreeKassa webhook: amount mismatch for payment {payment_db_id} "
|
||||
f"(expected {expected_amount}, got {amount_decimal})"
|
||||
)
|
||||
return web.Response(status=400, text="amount_mismatch")
|
||||
except Exception as e:
|
||||
logging.warning(f"FreeKassa webhook: failed to compare amount for payment {payment_db_id}: {e}")
|
||||
logging.error(f"FreeKassa webhook: failed to compare amount for payment {payment_db_id}: {e}")
|
||||
return web.Response(status=400, text="amount_validation_error")
|
||||
|
||||
activation = None
|
||||
referral_bonus = None
|
||||
try:
|
||||
await payment_dal.update_provider_payment_and_status(
|
||||
provider_id = str(provider_payment_id or f"freekassa:{order_id_str}")
|
||||
marked = await payment_dal.mark_provider_payment_succeeded_once(
|
||||
session=session,
|
||||
payment_db_id=payment.payment_id,
|
||||
provider_payment_id=str(provider_payment_id or f"freekassa:{order_id_str}"),
|
||||
new_status="succeeded",
|
||||
provider_payment_id=provider_id,
|
||||
)
|
||||
if not marked:
|
||||
logging.info(
|
||||
"FreeKassa webhook: payment %s already processed atomically",
|
||||
payment.payment_id,
|
||||
)
|
||||
return web.Response(text="YES")
|
||||
|
||||
months = payment.subscription_duration_months or 1
|
||||
sale_mode = "traffic" if self.settings.traffic_sale_mode else "subscription"
|
||||
@@ -283,10 +373,15 @@ class FreeKassaService:
|
||||
int(months) if sale_mode != "traffic" else 0,
|
||||
float(payment.amount),
|
||||
payment.payment_id,
|
||||
promo_code_id_from_payment=payment.promo_code_id,
|
||||
provider="freekassa",
|
||||
sale_mode=sale_mode,
|
||||
traffic_gb=months if sale_mode == "traffic" else None,
|
||||
)
|
||||
if not activation or not activation.get("end_date"):
|
||||
raise RuntimeError(
|
||||
f"FreeKassa webhook: activation failed for payment {payment.payment_id}"
|
||||
)
|
||||
|
||||
referral_bonus = None
|
||||
if sale_mode != "traffic":
|
||||
@@ -299,9 +394,9 @@ class FreeKassaService:
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
logging.exception("FreeKassa webhook: failed to process payment %s.", payment_db_id)
|
||||
logging.error(f"FreeKassa webhook: failed to process payment {payment_db_id}: {e}", exc_info=True)
|
||||
return web.Response(status=500, text="processing_error")
|
||||
|
||||
db_user = payment.user or await user_dal.get_user_by_id(session, payment.user_id)
|
||||
@@ -385,8 +480,8 @@ class FreeKassaService:
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
except Exception:
|
||||
logging.exception("FreeKassa notification: failed to send message to user %s.", payment.user_id)
|
||||
except Exception as e:
|
||||
logging.error(f"FreeKassa notification: failed to send message to user {payment.user_id}: {e}")
|
||||
|
||||
try:
|
||||
notification_service = NotificationService(self.bot, self.settings, self.i18n)
|
||||
@@ -399,8 +494,8 @@ class FreeKassaService:
|
||||
payment_provider="freekassa",
|
||||
username=db_user.username if db_user else None,
|
||||
)
|
||||
except Exception:
|
||||
logging.exception("FreeKassa notification: failed to notify admins.")
|
||||
except Exception as e:
|
||||
logging.error(f"FreeKassa notification: failed to notify admins: {e}")
|
||||
|
||||
return web.Response(text="YES")
|
||||
|
||||
|
||||
@@ -120,8 +120,8 @@ class NotificationService:
|
||||
logging.error(
|
||||
f"Failed to send notification to log channel {self.settings.LOG_CHAT_ID}: {exc}"
|
||||
)
|
||||
except Exception:
|
||||
logging.exception("Failed to send notification to log channel %s.", self.settings.LOG_CHAT_ID)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to send notification to log channel {self.settings.LOG_CHAT_ID}: {e}")
|
||||
return
|
||||
|
||||
try:
|
||||
@@ -143,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:
|
||||
logging.exception("Failed to queue notification to log channel %s.", self.settings.LOG_CHAT_ID)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to queue notification to log channel {self.settings.LOG_CHAT_ID}: {e}")
|
||||
|
||||
async def _send_to_admins(self, message: str):
|
||||
"""Send message to all admin users using message queue"""
|
||||
@@ -162,8 +162,8 @@ class NotificationService:
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True
|
||||
)
|
||||
except Exception:
|
||||
logging.exception("Failed to send notification to admin %s.", admin_id)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to send notification to admin {admin_id}: {e}")
|
||||
return
|
||||
|
||||
for admin_id in self.settings.ADMIN_IDS:
|
||||
@@ -174,8 +174,8 @@ class NotificationService:
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True
|
||||
)
|
||||
except Exception:
|
||||
logging.exception("Failed to queue notification to admin %s.", admin_id)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to queue notification to admin {admin_id}: {e}")
|
||||
|
||||
async def notify_new_user_registration(self, user_id: int, username: Optional[str] = None,
|
||||
first_name: Optional[str] = None,
|
||||
@@ -271,15 +271,15 @@ class NotificationService:
|
||||
"""Send notification about promo code activation"""
|
||||
if not self.settings.LOG_PROMO_ACTIVATIONS:
|
||||
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=user_id,
|
||||
username=username,
|
||||
)
|
||||
|
||||
|
||||
message = _(
|
||||
"log_promo_activation",
|
||||
user_display=user_display,
|
||||
@@ -287,7 +287,33 @@ class NotificationService:
|
||||
bonus_days=bonus_days,
|
||||
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
)
|
||||
|
||||
|
||||
# Send to log channel
|
||||
profile_keyboard = self._build_profile_keyboard(_, user_id)
|
||||
await self._send_to_log_channel(message, reply_markup=profile_keyboard)
|
||||
|
||||
async def notify_discount_promo_activation(self, user_id: int, promo_code: str, discount_percentage: int,
|
||||
username: Optional[str] = None):
|
||||
"""Send notification about discount promo code activation"""
|
||||
if not self.settings.LOG_PROMO_ACTIVATIONS:
|
||||
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=user_id,
|
||||
username=username,
|
||||
)
|
||||
|
||||
message = _(
|
||||
"log_promo_discount_activation",
|
||||
user_display=user_display,
|
||||
promo_code=promo_code,
|
||||
discount_percentage=discount_percentage,
|
||||
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
)
|
||||
|
||||
# Send to log channel
|
||||
profile_keyboard = self._build_profile_keyboard(_, user_id)
|
||||
await self._send_to_log_channel(message, reply_markup=profile_keyboard)
|
||||
|
||||
@@ -59,6 +59,29 @@ class PanelApiService:
|
||||
headers["Authorization"] = f"Bearer {self.api_key}"
|
||||
return headers
|
||||
|
||||
@staticmethod
|
||||
def _sanitize_payload_for_log(payload: Any) -> Any:
|
||||
if isinstance(payload, dict):
|
||||
redacted: Dict[str, Any] = {}
|
||||
for key, value in payload.items():
|
||||
lowered = str(key).lower()
|
||||
if any(mask_key in lowered for mask_key in (
|
||||
"token",
|
||||
"secret",
|
||||
"password",
|
||||
"authorization",
|
||||
"api_key",
|
||||
"apikey",
|
||||
"key",
|
||||
)):
|
||||
redacted[key] = "***"
|
||||
else:
|
||||
redacted[key] = PanelApiService._sanitize_payload_for_log(value)
|
||||
return redacted
|
||||
if isinstance(payload, list):
|
||||
return [PanelApiService._sanitize_payload_for_log(item) for item in payload]
|
||||
return payload
|
||||
|
||||
async def _request(self,
|
||||
method: str,
|
||||
endpoint: str,
|
||||
@@ -83,8 +106,8 @@ class PanelApiService:
|
||||
if current_params:
|
||||
try:
|
||||
url_with_params_for_log += "?" + urlencode(current_params)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Failed to encode params for panel API log URL: %s", exc)
|
||||
|
||||
json_payload_for_log = kwargs.get('json') if method.upper() in [
|
||||
"POST", "PATCH", "PUT"
|
||||
@@ -92,10 +115,11 @@ class PanelApiService:
|
||||
log_prefix = f"Panel API Req: {method.upper()} {url_with_params_for_log}"
|
||||
if json_payload_for_log:
|
||||
try:
|
||||
payload_str = json.dumps(json_payload_for_log)
|
||||
sanitized_payload = self._sanitize_payload_for_log(json_payload_for_log)
|
||||
payload_str = json.dumps(sanitized_payload)
|
||||
log_prefix += f" | Payload: {payload_str[:300]}{'...' if len(payload_str) > 300 else ''}"
|
||||
except Exception:
|
||||
log_prefix += f" | Payload: {str(json_payload_for_log)[:300]}..."
|
||||
log_prefix += " | Payload: <unavailable>"
|
||||
try:
|
||||
async with aiohttp_session.request(method.upper(),
|
||||
url_for_request,
|
||||
@@ -106,7 +130,8 @@ class PanelApiService:
|
||||
|
||||
log_suffix = f"| Status: {response_status}"
|
||||
|
||||
if log_full_response or not (200 <= response_status < 300):
|
||||
should_log_full_body = bool(log_full_response and self.settings.LOG_LEVEL == "DEBUG")
|
||||
if should_log_full_body or not (200 <= response_status < 300):
|
||||
try:
|
||||
parsed_json_for_log = json.loads(response_text)
|
||||
pretty_response_text = json.dumps(parsed_json_for_log,
|
||||
@@ -174,7 +199,7 @@ class PanelApiService:
|
||||
"message": f"Connection error: {str(e)}"
|
||||
}
|
||||
except aiohttp.ClientError as e:
|
||||
logging.exception("Panel API ClientError to %s.", url_for_request)
|
||||
logging.error(f"Panel API ClientError to {url_for_request}: {e}")
|
||||
return {
|
||||
"error": True,
|
||||
"status_code": -2,
|
||||
@@ -404,7 +429,10 @@ class PanelApiService:
|
||||
return response
|
||||
|
||||
logging.error(
|
||||
f"Failed to create panel user '{username_on_panel}'. Payload: {payload}, Response: {response if not log_response else '(full response logged above)'}"
|
||||
"Failed to create panel user '%s'. Payload: %s, Response: %s",
|
||||
username_on_panel,
|
||||
self._sanitize_payload_for_log(payload),
|
||||
response if not log_response else "(full response logged above)",
|
||||
)
|
||||
return response
|
||||
|
||||
@@ -426,7 +454,10 @@ class PanelApiService:
|
||||
return full_response.get("response")
|
||||
|
||||
logging.error(
|
||||
f"Failed to update user {user_uuid} details on panel. Payload: {update_payload}, Response: {full_response if not log_response else '(logged above)'}"
|
||||
"Failed to update user %s details on panel. Payload: %s, Response: %s",
|
||||
user_uuid,
|
||||
self._sanitize_payload_for_log(update_payload),
|
||||
full_response if not log_response else "(logged above)",
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
@@ -26,10 +26,6 @@ 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,
|
||||
@@ -44,8 +40,8 @@ class PanelWebhookService:
|
||||
await self.bot.send_message(
|
||||
user_id, _(message_key, **kwargs), reply_markup=reply_markup
|
||||
)
|
||||
except Exception:
|
||||
logging.exception("Failed to send notification to %s", user_id)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to send notification to {user_id}: {e}")
|
||||
|
||||
async def handle_event(self, event_name: str, user_payload: dict):
|
||||
telegram_id = user_payload.get("telegramId")
|
||||
@@ -58,10 +54,7 @@ class PanelWebhookService:
|
||||
return
|
||||
|
||||
async with self.async_session_factory() as session:
|
||||
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
|
||||
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}"
|
||||
|
||||
@@ -76,7 +69,7 @@ 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, internal_user_id)
|
||||
sub = await subscription_dal.get_active_subscription_by_user_id(session, user_id)
|
||||
if sub and sub.auto_renew_enabled and sub.provider == 'yookassa':
|
||||
try:
|
||||
ok = await subscription_service.charge_subscription_renewal(session, sub)
|
||||
@@ -96,7 +89,7 @@ class PanelWebhookService:
|
||||
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, internal_user_id)
|
||||
sub = await subscription_dal.get_active_subscription_by_user_id(session, user_id)
|
||||
logging.info(
|
||||
"48h webhook check: user_id=%s sub_found=%s auto_renew=%s provider=%s",
|
||||
user_id,
|
||||
@@ -144,18 +137,18 @@ class PanelWebhookService:
|
||||
|
||||
async def handle_webhook(self, raw_body: bytes, signature_header: Optional[str]) -> web.Response:
|
||||
if not self.settings.PANEL_WEBHOOK_SECRET:
|
||||
return web.Response(status=401, text="unauthorized")
|
||||
logging.critical("Panel webhook rejected: PANEL_WEBHOOK_SECRET is not configured")
|
||||
return web.Response(status=503, text="panel_webhook_secret_required")
|
||||
|
||||
if not signature_header:
|
||||
return web.Response(status=401, text="unauthorized")
|
||||
|
||||
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=401, text="unauthorized")
|
||||
return web.Response(status=403, text="invalid_signature")
|
||||
|
||||
try:
|
||||
payload = json.loads(raw_body.decode())
|
||||
|
||||
+107
-46
@@ -1,4 +1,3 @@
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
@@ -42,8 +41,6 @@ class PlategaService:
|
||||
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
|
||||
|
||||
@@ -59,14 +56,6 @@ class PlategaService:
|
||||
)
|
||||
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:
|
||||
@@ -87,19 +76,76 @@ class PlategaService:
|
||||
currency: Optional[str],
|
||||
description: str,
|
||||
payload: Optional[str] = None,
|
||||
payment_method: Optional[int] = None,
|
||||
promo_code_service=None,
|
||||
session=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()
|
||||
# Check for active discount to save metadata (price already discounted from previous step)
|
||||
original_amount = None
|
||||
discount_amount = None
|
||||
promo_code_id = None
|
||||
|
||||
if promo_code_service and session:
|
||||
from db.dal import active_discount_dal
|
||||
active_discount = await active_discount_dal.get_active_discount(session, user_id)
|
||||
if active_discount:
|
||||
# Price is already discounted, calculate original price backwards
|
||||
discount_pct = active_discount.discount_percentage
|
||||
promo_code_id = active_discount.promo_code_id
|
||||
denominator = 1 - discount_pct / 100
|
||||
if denominator <= 0:
|
||||
traffic_mode = bool(getattr(self.settings, "traffic_sale_mode", False))
|
||||
price_source = (
|
||||
getattr(self.settings, "traffic_packages", {}) or {}
|
||||
if traffic_mode
|
||||
else (self.settings.subscription_options or {})
|
||||
)
|
||||
fallback_original = price_source.get(months)
|
||||
if fallback_original is not None:
|
||||
original_amount = fallback_original
|
||||
discount_amount = original_amount - amount
|
||||
logging.info(
|
||||
f"Recording {discount_pct}% discount for Platega payment: "
|
||||
f"original {original_amount:.2f} -> final {amount}"
|
||||
)
|
||||
else:
|
||||
logging.warning(
|
||||
"Platega discount %s%% has invalid denominator and no fallback price for months=%s.",
|
||||
discount_pct,
|
||||
months,
|
||||
)
|
||||
else:
|
||||
original_amount = amount / denominator
|
||||
discount_amount = original_amount - amount
|
||||
logging.info(
|
||||
f"Recording {discount_pct}% discount for Platega payment: "
|
||||
f"original {original_amount:.2f} -> final {amount}"
|
||||
)
|
||||
|
||||
# Update payment record with discount metadata
|
||||
try:
|
||||
await payment_dal.update_payment_discount_info(
|
||||
session,
|
||||
payment_db_id,
|
||||
original_amount,
|
||||
discount_amount,
|
||||
promo_code_id,
|
||||
)
|
||||
await session.commit()
|
||||
except Exception as e_update:
|
||||
logging.warning(
|
||||
f"Platega: failed to update discount metadata for payment {payment_db_id}: {e_update}"
|
||||
)
|
||||
|
||||
http_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)
|
||||
currency_code = (currency or "RUB").upper()
|
||||
|
||||
body: Dict[str, Any] = {
|
||||
"paymentMethod": method_id,
|
||||
"paymentMethod": int(self.payment_method),
|
||||
"paymentDetails": {"amount": float(amount), "currency": currency_code},
|
||||
"description": description,
|
||||
"return": self.return_url,
|
||||
@@ -109,15 +155,9 @@ class PlategaService:
|
||||
|
||||
# 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:
|
||||
async with http_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 {}
|
||||
@@ -139,7 +179,7 @@ class PlategaService:
|
||||
|
||||
return True, response_data
|
||||
except Exception as exc:
|
||||
logging.exception("Platega create_transaction: request failed.")
|
||||
logging.error("Platega create_transaction: request failed: %s", exc, exc_info=True)
|
||||
return False, {"message": str(exc)}
|
||||
|
||||
async def webhook_route(self, request: web.Request) -> web.Response:
|
||||
@@ -148,23 +188,20 @@ class PlategaService:
|
||||
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
logging.exception("Platega webhook: failed to parse JSON.")
|
||||
except Exception as exc:
|
||||
logging.error("Platega webhook: failed to parse JSON: %s", exc)
|
||||
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 ""))
|
||||
):
|
||||
if header_merchant != self.merchant_id or header_secret != self.secret:
|
||||
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"
|
||||
currency = data.get("currency") or "RUB"
|
||||
|
||||
if not transaction_id or not status:
|
||||
logging.error("Platega webhook: missing transaction id or status in payload: %s", data)
|
||||
@@ -183,27 +220,46 @@ class PlategaService:
|
||||
sale_mode = "traffic" if self.settings.traffic_sale_mode else "subscription"
|
||||
|
||||
if status == "CONFIRMED":
|
||||
if currency:
|
||||
provider_currency = str(currency).upper()
|
||||
expected_currency = str(payment.currency or "").upper()
|
||||
if expected_currency and expected_currency != provider_currency:
|
||||
logging.error(
|
||||
"Platega webhook: currency mismatch for payment %s (expected %s, got %s)",
|
||||
payment.payment_id,
|
||||
expected_currency,
|
||||
provider_currency,
|
||||
)
|
||||
return web.Response(status=400, text="currency_mismatch")
|
||||
|
||||
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(
|
||||
logging.error(
|
||||
"Platega webhook: amount mismatch for payment %s (expected %s, got %s)",
|
||||
payment.payment_id,
|
||||
expected_amount,
|
||||
incoming_amount,
|
||||
)
|
||||
return web.Response(status=400, text="amount_mismatch")
|
||||
except Exception as exc:
|
||||
logging.warning("Platega webhook: failed to compare amounts for %s: %s", payment.payment_id, exc)
|
||||
logging.error("Platega webhook: failed to compare amounts for %s: %s", payment.payment_id, exc)
|
||||
return web.Response(status=400, text="amount_validation_error")
|
||||
|
||||
try:
|
||||
await payment_dal.update_provider_payment_and_status(
|
||||
marked = await payment_dal.mark_provider_payment_succeeded_once(
|
||||
session,
|
||||
payment.payment_id,
|
||||
transaction_id,
|
||||
"succeeded",
|
||||
)
|
||||
if not marked:
|
||||
logging.info(
|
||||
"Platega webhook: payment %s already processed atomically",
|
||||
payment.payment_id,
|
||||
)
|
||||
return web.Response(text="ok")
|
||||
|
||||
activation = await self.subscription_service.activate_subscription(
|
||||
session,
|
||||
@@ -211,10 +267,15 @@ class PlategaService:
|
||||
int(payment_months) if sale_mode != "traffic" else 0,
|
||||
float(payment.amount),
|
||||
payment.payment_id,
|
||||
promo_code_id_from_payment=payment.promo_code_id,
|
||||
provider="platega",
|
||||
sale_mode=sale_mode,
|
||||
traffic_gb=payment_months if sale_mode == "traffic" else None,
|
||||
)
|
||||
if not activation or not activation.get("end_date"):
|
||||
raise RuntimeError(
|
||||
f"Platega webhook: activation failed for payment {payment.payment_id}"
|
||||
)
|
||||
|
||||
referral_bonus = None
|
||||
if sale_mode != "traffic":
|
||||
@@ -227,9 +288,9 @@ class PlategaService:
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
await session.rollback()
|
||||
logging.exception("Platega webhook: failed to process payment %s.", transaction_id)
|
||||
logging.error("Platega webhook: failed to process payment %s: %s", transaction_id, exc, exc_info=True)
|
||||
return web.Response(status=500, text="processing_error")
|
||||
|
||||
db_user = await user_dal.get_user_by_id(session, payment.user_id)
|
||||
@@ -308,8 +369,8 @@ class PlategaService:
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
except Exception:
|
||||
logging.exception("Platega webhook: failed to notify user %s.", payment.user_id)
|
||||
except Exception as exc:
|
||||
logging.error("Platega webhook: failed to notify user %s: %s", payment.user_id, exc)
|
||||
|
||||
try:
|
||||
notification_service = NotificationService(self.bot, self.settings, self.i18n)
|
||||
@@ -322,12 +383,12 @@ class PlategaService:
|
||||
payment_provider="platega",
|
||||
username=db_user.username if db_user else None,
|
||||
)
|
||||
except Exception:
|
||||
logging.exception("Platega webhook: failed to notify admins.")
|
||||
except Exception as exc:
|
||||
logging.error("Platega webhook: failed to notify admins: %s", exc)
|
||||
|
||||
return web.Response(text="ok")
|
||||
|
||||
if status in {"CANCELED", "CANCELLED", "CHARGEBACKED"}:
|
||||
if status in {"CANCELED", "CANCELLED", "CHARGEBACK", "CHARGEBACKED"}:
|
||||
try:
|
||||
await payment_dal.update_provider_payment_and_status(
|
||||
session,
|
||||
@@ -336,9 +397,9 @@ class PlategaService:
|
||||
"canceled",
|
||||
)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
await session.rollback()
|
||||
logging.exception("Platega webhook: failed to cancel payment %s.", transaction_id)
|
||||
logging.error("Platega webhook: failed to cancel payment %s: %s", transaction_id, exc)
|
||||
return web.Response(status=500, text="processing_error")
|
||||
|
||||
db_user = await user_dal.get_user_by_id(session, payment.user_id)
|
||||
@@ -346,8 +407,8 @@ class PlategaService:
|
||||
_ = 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
|
||||
except Exception as exc:
|
||||
logging.debug("Platega webhook: failed to send cancellation message to user %s: %s", payment.user_id, exc)
|
||||
return web.Response(text="ok_canceled")
|
||||
|
||||
logging.warning("Platega webhook: unhandled status '%s' for transaction %s", status, transaction_id)
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import logging
|
||||
from html import escape as html_escape
|
||||
from datetime import datetime
|
||||
import asyncio
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from typing import Optional, Tuple, Dict
|
||||
from typing import Optional, Tuple
|
||||
from aiogram import Bot
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
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
|
||||
from db.dal import promo_code_dal, user_dal, active_discount_dal, payment_dal
|
||||
|
||||
from .subscription_service import SubscriptionService
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
@@ -25,9 +24,122 @@ class PromoCodeService:
|
||||
self.subscription_service = subscription_service
|
||||
self.bot = bot
|
||||
self.i18n = i18n
|
||||
self.discount_payment_timeout_minutes = max(
|
||||
1,
|
||||
int(getattr(settings, "DISCOUNT_PROMO_PAYMENT_TIMEOUT_MINUTES", 10) or 10),
|
||||
)
|
||||
self._discount_expiration_task: Optional[asyncio.Task] = None
|
||||
self._async_session_factory: Optional[sessionmaker] = None
|
||||
|
||||
def _throttle_identifier(self, user_id: int) -> str:
|
||||
return f"user:{int(user_id)}"
|
||||
async def setup_discount_expiration_worker(
|
||||
self,
|
||||
async_session_factory: sessionmaker,
|
||||
) -> None:
|
||||
"""Attach DB session factory and start background cleanup loop."""
|
||||
self._async_session_factory = async_session_factory
|
||||
if self._discount_expiration_task and not self._discount_expiration_task.done():
|
||||
return
|
||||
self._discount_expiration_task = asyncio.create_task(
|
||||
self._discount_expiration_loop(),
|
||||
name="PromoDiscountExpirationLoop",
|
||||
)
|
||||
logging.info("PromoCodeService: started discount expiration background worker.")
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Gracefully stop background workers."""
|
||||
if not self._discount_expiration_task:
|
||||
return
|
||||
self._discount_expiration_task.cancel()
|
||||
try:
|
||||
await self._discount_expiration_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception:
|
||||
logging.exception("PromoCodeService: failed while stopping expiration worker")
|
||||
finally:
|
||||
self._discount_expiration_task = None
|
||||
|
||||
async def _discount_expiration_loop(self) -> None:
|
||||
"""Periodically clears expired discount reservations and notifies users."""
|
||||
while True:
|
||||
try:
|
||||
if not self._async_session_factory:
|
||||
await asyncio.sleep(30)
|
||||
continue
|
||||
|
||||
await self._process_expired_discounts_once()
|
||||
except asyncio.CancelledError:
|
||||
logging.info("PromoCodeService: discount expiration loop cancelled.")
|
||||
raise
|
||||
except Exception:
|
||||
logging.exception("PromoCodeService: unhandled error in discount expiration loop")
|
||||
|
||||
await asyncio.sleep(30)
|
||||
|
||||
async def _process_expired_discounts_once(self) -> None:
|
||||
if not self._async_session_factory:
|
||||
return
|
||||
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
notifications_to_send: list[tuple[int, str]] = []
|
||||
|
||||
async with self._async_session_factory() as session:
|
||||
expired_discounts = await active_discount_dal.get_expired_active_discounts(
|
||||
session,
|
||||
now=now_utc,
|
||||
limit=100,
|
||||
)
|
||||
if not expired_discounts:
|
||||
return
|
||||
|
||||
for expired in expired_discounts:
|
||||
cleared = await active_discount_dal.clear_active_discount_if_matches(
|
||||
session,
|
||||
user_id=expired.user_id,
|
||||
promo_code_id=expired.promo_code_id,
|
||||
expires_at_lte=now_utc,
|
||||
)
|
||||
if not cleared:
|
||||
continue
|
||||
|
||||
await promo_code_dal.decrement_promo_code_usage(session, expired.promo_code_id)
|
||||
|
||||
db_user = await user_dal.get_user_by_id(session, expired.user_id)
|
||||
user_lang = (
|
||||
db_user.language_code
|
||||
if db_user and db_user.language_code
|
||||
else self.settings.DEFAULT_LANGUAGE
|
||||
)
|
||||
promo = await promo_code_dal.get_promo_code_by_id(session, expired.promo_code_id)
|
||||
promo_code = promo.code if promo else ""
|
||||
message_text = self.i18n.gettext(
|
||||
user_lang,
|
||||
"discount_promo_expired_need_reactivate",
|
||||
code_part=(f" (<code>{promo_code}</code>)" if promo_code else ""),
|
||||
)
|
||||
|
||||
notifications_to_send.append((expired.user_id, message_text))
|
||||
|
||||
logging.info(
|
||||
"Expired discount reservation removed: user=%s, promo=%s",
|
||||
expired.user_id,
|
||||
expired.promo_code_id,
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
|
||||
for user_id, message_text in notifications_to_send:
|
||||
try:
|
||||
await self.bot.send_message(
|
||||
chat_id=user_id,
|
||||
text=message_text,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
except Exception:
|
||||
logging.exception(
|
||||
"Failed to send discount expiration message to user %s",
|
||||
user_id,
|
||||
)
|
||||
|
||||
async def apply_promo_code(
|
||||
self,
|
||||
@@ -37,45 +149,19 @@ class PromoCodeService:
|
||||
user_lang: str,
|
||||
) -> Tuple[bool, datetime | str]:
|
||||
_ = lambda k, **kw: self.i18n.gettext(user_lang, k, **kw)
|
||||
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)
|
||||
code_input_upper = code_input.strip().upper()
|
||||
|
||||
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(
|
||||
promo_data = await promo_code_dal.get_active_bonus_promo_code_by_code_str(
|
||||
session, code_input_upper)
|
||||
|
||||
if not promo_data:
|
||||
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)
|
||||
return False, _("promo_code_not_found", code=code_input_upper)
|
||||
|
||||
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_display)
|
||||
code=code_input_upper)
|
||||
|
||||
bonus_days = promo_data.bonus_days
|
||||
|
||||
@@ -92,11 +178,6 @@ 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)
|
||||
@@ -119,3 +200,285 @@ class PromoCodeService:
|
||||
return False, _("error_applying_promo_bonus")
|
||||
else:
|
||||
return False, _("error_applying_promo_bonus")
|
||||
|
||||
async def apply_discount_promo_code(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
code_input: str,
|
||||
user_lang: str,
|
||||
) -> Tuple[bool, int | str]:
|
||||
"""
|
||||
Apply a discount promo code (sets active discount for user).
|
||||
Returns: (success: bool, discount_percentage or error_message)
|
||||
"""
|
||||
_ = lambda k, **kw: self.i18n.gettext(user_lang, k, **kw)
|
||||
code_input_upper = code_input.strip().upper()
|
||||
|
||||
# Check if user already has an active discount
|
||||
existing_discount = await active_discount_dal.get_active_discount(
|
||||
session,
|
||||
user_id,
|
||||
include_expired=True,
|
||||
)
|
||||
if existing_discount:
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
if existing_discount.expires_at <= now_utc:
|
||||
cleared = await active_discount_dal.clear_active_discount_if_expired(
|
||||
session,
|
||||
user_id,
|
||||
now=now_utc,
|
||||
)
|
||||
if cleared:
|
||||
await promo_code_dal.decrement_promo_code_usage(
|
||||
session,
|
||||
existing_discount.promo_code_id,
|
||||
)
|
||||
existing_discount = None
|
||||
|
||||
if existing_discount:
|
||||
# Get the promo code for the existing discount
|
||||
existing_promo = await promo_code_dal.get_promo_code_by_id(
|
||||
session, existing_discount.promo_code_id
|
||||
)
|
||||
if existing_promo:
|
||||
return False, _("discount_promo_already_active",
|
||||
code=existing_promo.code,
|
||||
discount_pct=existing_discount.discount_percentage)
|
||||
else:
|
||||
# Existing discount but promo not found - clear it and continue
|
||||
await active_discount_dal.clear_active_discount(session, user_id)
|
||||
|
||||
# Get discount promo code
|
||||
promo_data = await promo_code_dal.get_active_discount_promo_code_by_code_str(
|
||||
session, code_input_upper
|
||||
)
|
||||
|
||||
if not promo_data:
|
||||
return False, _("promo_code_not_found_or_not_discount", code=code_input_upper)
|
||||
|
||||
# Check if user already used this code
|
||||
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)
|
||||
|
||||
# Reserve discount for limited time and count activation immediately
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(
|
||||
minutes=self.discount_payment_timeout_minutes,
|
||||
)
|
||||
active_discount = await active_discount_dal.set_active_discount(
|
||||
session,
|
||||
user_id=user_id,
|
||||
promo_code_id=promo_data.promo_code_id,
|
||||
discount_percentage=promo_data.discount_percentage,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
|
||||
if not active_discount:
|
||||
# This shouldn't happen since we checked above, but just in case
|
||||
return False, _("error_applying_promo_discount")
|
||||
|
||||
promo_incremented = await promo_code_dal.increment_promo_code_usage(
|
||||
session,
|
||||
promo_data.promo_code_id,
|
||||
)
|
||||
if not promo_incremented:
|
||||
await active_discount_dal.clear_active_discount_if_matches(
|
||||
session,
|
||||
user_id=user_id,
|
||||
promo_code_id=promo_data.promo_code_id,
|
||||
)
|
||||
return False, _("promo_code_not_found_or_not_discount", code=code_input_upper)
|
||||
|
||||
logging.info(
|
||||
f"Discount promo code {code_input_upper} activated for user {user_id}: "
|
||||
f"{promo_data.discount_percentage}% off until {expires_at.isoformat()}"
|
||||
)
|
||||
return True, promo_data.discount_percentage
|
||||
|
||||
async def get_user_active_discount(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
user_id: int
|
||||
) -> Optional[Tuple[int, str]]:
|
||||
"""
|
||||
Get user's active discount if any.
|
||||
Returns: (discount_percentage, promo_code) or None
|
||||
"""
|
||||
active_discount = await active_discount_dal.get_active_discount(
|
||||
session,
|
||||
user_id,
|
||||
include_expired=True,
|
||||
)
|
||||
if not active_discount:
|
||||
return None
|
||||
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
if active_discount.expires_at <= now_utc:
|
||||
cleared = await active_discount_dal.clear_active_discount_if_expired(
|
||||
session,
|
||||
user_id,
|
||||
now=now_utc,
|
||||
)
|
||||
if cleared:
|
||||
await promo_code_dal.decrement_promo_code_usage(
|
||||
session,
|
||||
active_discount.promo_code_id,
|
||||
)
|
||||
return None
|
||||
|
||||
# Fetch promo code for code string
|
||||
promo = await promo_code_dal.get_promo_code_by_id(
|
||||
session, active_discount.promo_code_id
|
||||
)
|
||||
if not promo:
|
||||
# Discount exists but promo not found - clear it
|
||||
await active_discount_dal.clear_active_discount(session, user_id)
|
||||
return None
|
||||
|
||||
# Check if promo code has expired
|
||||
if promo.valid_until and promo.valid_until <= datetime.now(timezone.utc):
|
||||
# Promo code expired - clear the discount
|
||||
logging.info(
|
||||
f"Promo code {promo.code} expired (valid_until: {promo.valid_until}). "
|
||||
f"Clearing active discount for user {user_id}"
|
||||
)
|
||||
cleared = await active_discount_dal.clear_active_discount(session, user_id)
|
||||
if cleared:
|
||||
await promo_code_dal.decrement_promo_code_usage(session, promo.promo_code_id)
|
||||
return None
|
||||
|
||||
return (active_discount.discount_percentage, promo.code)
|
||||
|
||||
def calculate_discounted_price(
|
||||
self,
|
||||
original_price: float,
|
||||
discount_percentage: int
|
||||
) -> Tuple[float, float]:
|
||||
"""
|
||||
Calculate discounted price and discount amount.
|
||||
Returns: (final_price, discount_amount)
|
||||
"""
|
||||
discount_amount = round(original_price * (discount_percentage / 100), 2)
|
||||
final_price = round(original_price - discount_amount, 2)
|
||||
|
||||
# Ensure price doesn't go negative
|
||||
if final_price < 0:
|
||||
final_price = 0
|
||||
discount_amount = original_price
|
||||
|
||||
return final_price, discount_amount
|
||||
|
||||
async def consume_discount(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
payment_id: int
|
||||
) -> bool:
|
||||
"""
|
||||
Consume discount after successful payment.
|
||||
|
||||
The payment record is the source of truth. Even if the active reservation was
|
||||
concurrently expired/cleared, we still record promo activation and reconcile
|
||||
current_activations so successful discounted payments are always accounted for.
|
||||
"""
|
||||
payment_record = await payment_dal.get_payment_by_db_id(session, payment_id)
|
||||
if not payment_record:
|
||||
logging.warning(
|
||||
"Payment %s not found for discount consumption (user %s).",
|
||||
payment_id,
|
||||
user_id,
|
||||
)
|
||||
return False
|
||||
|
||||
if not payment_record.discount_applied:
|
||||
return False
|
||||
|
||||
promo_code_id = payment_record.promo_code_id
|
||||
if not promo_code_id:
|
||||
logging.warning(
|
||||
"Payment %s for user %s has discount_applied but no promo_code_id.",
|
||||
payment_id,
|
||||
user_id,
|
||||
)
|
||||
return False
|
||||
|
||||
existing_activation = await promo_code_dal.get_user_activation_for_promo(
|
||||
session, promo_code_id, user_id
|
||||
)
|
||||
|
||||
activation_created = False
|
||||
if existing_activation:
|
||||
if existing_activation.payment_id is None:
|
||||
updated_payment = await promo_code_dal.set_activation_payment_id(
|
||||
session, promo_code_id, user_id, payment_id
|
||||
)
|
||||
if updated_payment:
|
||||
logging.info(
|
||||
"Linked discount promo %s activation to payment %s for user %s.",
|
||||
promo_code_id,
|
||||
payment_id,
|
||||
user_id,
|
||||
)
|
||||
else:
|
||||
activation_recorded = await promo_code_dal.record_promo_activation(
|
||||
session,
|
||||
promo_code_id,
|
||||
user_id,
|
||||
payment_id=payment_id,
|
||||
)
|
||||
if not activation_recorded:
|
||||
logging.error(
|
||||
"Failed to record discount activation for user %s, promo %s.",
|
||||
user_id,
|
||||
promo_code_id,
|
||||
)
|
||||
return False
|
||||
activation_created = True
|
||||
|
||||
active_discount = await active_discount_dal.get_active_discount(
|
||||
session,
|
||||
user_id,
|
||||
include_expired=True,
|
||||
)
|
||||
|
||||
# Reservation is best-effort cleanup at this point; payment success already happened.
|
||||
if active_discount and active_discount.promo_code_id == promo_code_id:
|
||||
await active_discount_dal.clear_active_discount_if_matches(
|
||||
session,
|
||||
user_id=user_id,
|
||||
promo_code_id=promo_code_id,
|
||||
)
|
||||
elif active_discount and active_discount.promo_code_id != promo_code_id:
|
||||
logging.info(
|
||||
"Active discount promo %s differs from payment promo %s during consumption.",
|
||||
active_discount.promo_code_id,
|
||||
promo_code_id,
|
||||
)
|
||||
else:
|
||||
logging.info(
|
||||
"Discount reservation already absent at consumption time (user=%s, promo=%s, payment=%s)",
|
||||
user_id,
|
||||
promo_code_id,
|
||||
payment_id,
|
||||
)
|
||||
|
||||
# If reservation was already expired/removed and we had to create activation now,
|
||||
# restore current_activations to match the successful payment.
|
||||
if activation_created:
|
||||
await promo_code_dal.increment_promo_code_usage(
|
||||
session,
|
||||
promo_code_id,
|
||||
allow_overflow=True,
|
||||
)
|
||||
|
||||
await session.flush()
|
||||
logging.info(
|
||||
"Discount consumed for user %s, promo %s, payment %s",
|
||||
user_id,
|
||||
promo_code_id,
|
||||
payment_id,
|
||||
)
|
||||
return True
|
||||
|
||||
@@ -32,6 +32,13 @@ class ReferralService:
|
||||
current_payment_db_id: Optional[int] = None,
|
||||
skip_if_active_before_payment: bool = True) -> Dict[str, Any]:
|
||||
|
||||
if not getattr(self.settings, "REFERRAL_ENABLED", True):
|
||||
return {
|
||||
"referee_bonus_applied_days": None,
|
||||
"referee_new_end_date": None,
|
||||
"inviter_bonus_applied_flag": False,
|
||||
}
|
||||
|
||||
referee_final_end_date: Optional[datetime] = None
|
||||
referee_bonus_applied_days: Optional[int] = None
|
||||
inviter_bonus_successfully_applied = False
|
||||
@@ -260,6 +267,9 @@ class ReferralService:
|
||||
async def generate_referral_link(self, session: AsyncSession,
|
||||
bot_username: str,
|
||||
inviter_user_id: int) -> Optional[str]:
|
||||
if not getattr(self.settings, "REFERRAL_ENABLED", True):
|
||||
return None
|
||||
|
||||
try:
|
||||
user = await user_dal.get_user_by_id(session, inviter_user_id)
|
||||
if not user:
|
||||
|
||||
@@ -99,14 +99,73 @@ class SeverPayService:
|
||||
amount: float,
|
||||
currency: Optional[str],
|
||||
description: str,
|
||||
promo_code_service=None,
|
||||
session=None,
|
||||
) -> 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()
|
||||
# Check for active discount to save metadata (price already discounted from previous step)
|
||||
original_amount = None
|
||||
discount_amount = None
|
||||
promo_code_id = None
|
||||
|
||||
if promo_code_service and session:
|
||||
from db.dal import active_discount_dal
|
||||
active_discount = await active_discount_dal.get_active_discount(session, user_id)
|
||||
if active_discount:
|
||||
# Price is already discounted, calculate original price backwards
|
||||
discount_pct = active_discount.discount_percentage
|
||||
promo_code_id = active_discount.promo_code_id
|
||||
denominator = 1 - discount_pct / 100
|
||||
if denominator <= 0:
|
||||
traffic_mode = bool(getattr(self.settings, "traffic_sale_mode", False))
|
||||
price_source = (
|
||||
getattr(self.settings, "traffic_packages", {}) or {}
|
||||
if traffic_mode
|
||||
else (self.settings.subscription_options or {})
|
||||
)
|
||||
fallback_original = price_source.get(months)
|
||||
if fallback_original is not None:
|
||||
original_amount = fallback_original
|
||||
discount_amount = original_amount - amount
|
||||
logging.info(
|
||||
f"Recording {discount_pct}% discount for SeverPay payment: "
|
||||
f"original {original_amount:.2f} -> final {amount}"
|
||||
)
|
||||
else:
|
||||
logging.warning(
|
||||
"SeverPay discount %s%% has invalid denominator and no fallback price for months=%s.",
|
||||
discount_pct,
|
||||
months,
|
||||
)
|
||||
else:
|
||||
original_amount = amount / denominator
|
||||
discount_amount = original_amount - amount
|
||||
logging.info(
|
||||
f"Recording {discount_pct}% discount for SeverPay payment: "
|
||||
f"original {original_amount:.2f} -> final {amount}"
|
||||
)
|
||||
|
||||
# Update payment record with discount metadata
|
||||
try:
|
||||
await payment_dal.update_payment_discount_info(
|
||||
session,
|
||||
payment_db_id,
|
||||
original_amount,
|
||||
discount_amount,
|
||||
promo_code_id,
|
||||
)
|
||||
await session.commit()
|
||||
except Exception as e_update:
|
||||
logging.warning(
|
||||
f"SeverPay: failed to update discount metadata for payment {payment_db_id}: {e_update}"
|
||||
)
|
||||
|
||||
http_session = await self._get_session()
|
||||
url = f"{self.base_url}/payin/create"
|
||||
currency_code = (currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
|
||||
currency_code = (currency or "RUB").upper()
|
||||
amount_str = self._format_amount(amount)
|
||||
|
||||
body = {
|
||||
@@ -124,7 +183,7 @@ class SeverPayService:
|
||||
signed_body = self._build_signed_body(body)
|
||||
|
||||
try:
|
||||
async with session.post(url, json=signed_body) as response:
|
||||
async with http_session.post(url, json=signed_body) as response:
|
||||
response_text = await response.text()
|
||||
try:
|
||||
response_data = json.loads(response_text) if response_text else {}
|
||||
@@ -142,7 +201,7 @@ class SeverPayService:
|
||||
|
||||
return True, response_data.get("data") or response_data
|
||||
except Exception as exc:
|
||||
logging.exception("SeverPay create_payment: request failed.")
|
||||
logging.error("SeverPay create_payment: request failed: %s", exc, exc_info=True)
|
||||
return False, {"message": str(exc)}
|
||||
|
||||
async def webhook_route(self, request: web.Request) -> web.Response:
|
||||
@@ -151,8 +210,8 @@ class SeverPayService:
|
||||
|
||||
try:
|
||||
payload = await request.json()
|
||||
except Exception:
|
||||
logging.exception("SeverPay webhook: failed to parse JSON.")
|
||||
except Exception as exc:
|
||||
logging.error("SeverPay webhook: failed to parse JSON: %s", exc)
|
||||
return web.json_response({"status": False, "msg": "bad_request"}, status=400)
|
||||
|
||||
if not isinstance(payload, dict) or not self._validate_signature(payload):
|
||||
@@ -169,6 +228,8 @@ class SeverPayService:
|
||||
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()
|
||||
amount_raw = data.get("amount")
|
||||
currency_raw = data.get("currency")
|
||||
|
||||
payment_db_id: Optional[int] = None
|
||||
try:
|
||||
@@ -190,16 +251,58 @@ class SeverPayService:
|
||||
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)
|
||||
|
||||
if payment.status == "succeeded" and status == "success":
|
||||
logging.info("SeverPay webhook: payment %s already succeeded", payment.payment_id)
|
||||
return web.json_response({"status": True})
|
||||
|
||||
if status == "success" and 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.error(
|
||||
"SeverPay webhook: amount mismatch for payment %s (expected %s, got %s)",
|
||||
payment.payment_id,
|
||||
expected_amount,
|
||||
incoming_amount,
|
||||
)
|
||||
return web.json_response({"status": False, "msg": "amount_mismatch"}, status=400)
|
||||
except Exception as exc:
|
||||
logging.error(
|
||||
"SeverPay webhook: failed to compare amounts for payment %s: %s",
|
||||
payment.payment_id,
|
||||
exc,
|
||||
)
|
||||
return web.json_response({"status": False, "msg": "amount_validation_error"}, status=400)
|
||||
|
||||
if currency_raw:
|
||||
provider_currency = str(currency_raw).upper()
|
||||
expected_currency = str(payment.currency or "").upper()
|
||||
if expected_currency and provider_currency != expected_currency:
|
||||
logging.error(
|
||||
"SeverPay webhook: currency mismatch for payment %s (expected %s, got %s)",
|
||||
payment.payment_id,
|
||||
expected_currency,
|
||||
provider_currency,
|
||||
)
|
||||
return web.json_response({"status": False, "msg": "currency_mismatch"}, status=400)
|
||||
|
||||
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(
|
||||
provider_id = provider_payment_id or str(payment.payment_id)
|
||||
marked = await payment_dal.mark_provider_payment_succeeded_once(
|
||||
session,
|
||||
payment.payment_id,
|
||||
provider_payment_id or str(payment.payment_id),
|
||||
"succeeded",
|
||||
provider_id,
|
||||
)
|
||||
if not marked:
|
||||
logging.info(
|
||||
"SeverPay webhook: payment %s already processed atomically",
|
||||
payment.payment_id,
|
||||
)
|
||||
return web.json_response({"status": True})
|
||||
|
||||
activation = await self.subscription_service.activate_subscription(
|
||||
session,
|
||||
@@ -207,6 +310,7 @@ class SeverPayService:
|
||||
int(payment_months) if sale_mode != "traffic" else 0,
|
||||
float(payment.amount),
|
||||
payment.payment_id,
|
||||
promo_code_id_from_payment=payment.promo_code_id,
|
||||
provider="severpay",
|
||||
sale_mode=sale_mode,
|
||||
traffic_gb=payment_months if sale_mode == "traffic" else None,
|
||||
@@ -223,9 +327,9 @@ class SeverPayService:
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
await session.rollback()
|
||||
logging.exception("SeverPay webhook: failed to process payment %s.", provider_payment_id)
|
||||
logging.error("SeverPay webhook: failed to process payment %s: %s", provider_payment_id, exc, exc_info=True)
|
||||
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)
|
||||
@@ -304,8 +408,8 @@ class SeverPayService:
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
except Exception:
|
||||
logging.exception("SeverPay webhook: failed to notify user %s.", payment.user_id)
|
||||
except Exception as exc:
|
||||
logging.error("SeverPay webhook: failed to notify user %s: %s", payment.user_id, exc)
|
||||
|
||||
try:
|
||||
notification_service = NotificationService(self.bot, self.settings, self.i18n)
|
||||
@@ -318,8 +422,8 @@ class SeverPayService:
|
||||
payment_provider="severpay",
|
||||
username=db_user.username if db_user else None,
|
||||
)
|
||||
except Exception:
|
||||
logging.exception("SeverPay webhook: failed to notify admins.")
|
||||
except Exception as exc:
|
||||
logging.error("SeverPay webhook: failed to notify admins: %s", exc)
|
||||
|
||||
return web.json_response({"status": True})
|
||||
|
||||
@@ -332,9 +436,9 @@ class SeverPayService:
|
||||
"failed",
|
||||
)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
await session.rollback()
|
||||
logging.exception("SeverPay webhook: failed to mark payment %s as failed.", provider_payment_id)
|
||||
logging.error("SeverPay webhook: failed to mark payment %s as failed: %s", provider_payment_id, exc)
|
||||
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)
|
||||
@@ -342,8 +446,8 @@ class SeverPayService:
|
||||
_ = 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
|
||||
except Exception as exc:
|
||||
logging.debug("SeverPay webhook: failed to send cancellation message to user %s: %s", payment.user_id, exc)
|
||||
return web.json_response({"status": True})
|
||||
|
||||
if status in {"process", "new"}:
|
||||
@@ -355,9 +459,9 @@ class SeverPayService:
|
||||
"pending_severpay",
|
||||
)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
await session.rollback()
|
||||
logging.exception("SeverPay webhook: failed to update pending status for %s.", provider_payment_id)
|
||||
logging.error("SeverPay webhook: failed to update pending status for %s: %s", provider_payment_id, exc)
|
||||
return web.json_response({"status": True})
|
||||
|
||||
logging.warning("SeverPay webhook: unhandled status '%s' for payment %s", status, provider_payment_id)
|
||||
|
||||
+129
-34
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
import math
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import Bot, types
|
||||
@@ -26,16 +27,94 @@ class StarsService:
|
||||
self.subscription_service = subscription_service
|
||||
self.referral_service = referral_service
|
||||
|
||||
async def create_invoice(self, session: AsyncSession, user_id: int, months: int,
|
||||
stars_price: int, description: str, sale_mode: str = "subscription") -> Optional[int]:
|
||||
def _resolve_base_stars_price(self, months: float, sale_mode: str) -> Optional[int]:
|
||||
stars_price_source = (
|
||||
self.settings.stars_traffic_packages
|
||||
if sale_mode == "traffic"
|
||||
else self.settings.stars_subscription_options
|
||||
)
|
||||
|
||||
if sale_mode != "traffic":
|
||||
months_key = int(months) if float(months).is_integer() else months
|
||||
base_price = stars_price_source.get(months_key)
|
||||
if base_price is not None:
|
||||
return base_price
|
||||
|
||||
if float(months).is_integer():
|
||||
return stars_price_source.get(float(months_key))
|
||||
|
||||
return None
|
||||
|
||||
base_price = stars_price_source.get(months)
|
||||
if base_price is not None:
|
||||
return base_price
|
||||
|
||||
for package_size, package_price in stars_price_source.items():
|
||||
if math.isclose(float(package_size), float(months), rel_tol=0.0, abs_tol=1e-9):
|
||||
return package_price
|
||||
|
||||
return None
|
||||
|
||||
async def create_invoice(self, session: AsyncSession, user_id: int, months: float,
|
||||
stars_price: int, description: str, sale_mode: str = "subscription",
|
||||
promo_code_service=None) -> Optional[int]:
|
||||
# Always resolve base price server-side and reject unknown packages.
|
||||
resolved_base_price = self._resolve_base_stars_price(months, sale_mode)
|
||||
if resolved_base_price is None:
|
||||
logging.warning(
|
||||
"Stars invoice rejected: base price not found for sale_mode=%s months=%s.",
|
||||
sale_mode,
|
||||
months,
|
||||
)
|
||||
return None
|
||||
|
||||
original_stars_price = int(resolved_base_price)
|
||||
|
||||
# Detect callback tampering (or stale callback payload) and prefer server-side price.
|
||||
if int(stars_price) != original_stars_price:
|
||||
logging.warning(
|
||||
"Stars callback price mismatch for user %s: callback=%s, resolved=%s, sale_mode=%s, months=%s",
|
||||
user_id,
|
||||
stars_price,
|
||||
original_stars_price,
|
||||
sale_mode,
|
||||
months,
|
||||
)
|
||||
|
||||
# Invoice amount starts from the base price and discount is applied once.
|
||||
stars_price = original_stars_price
|
||||
discount_amount_stars = None
|
||||
promo_code_id = None
|
||||
|
||||
if promo_code_service:
|
||||
# Import here to avoid circular import
|
||||
from bot.handlers.user.subscription.payment_discount_helper import apply_discount_to_payment
|
||||
|
||||
# Apply discount and round up using ceiling
|
||||
final_price_float, discount_float, promo_code_id = await apply_discount_to_payment(
|
||||
session, user_id, float(original_stars_price), promo_code_service
|
||||
)
|
||||
if discount_float:
|
||||
stars_price = math.ceil(final_price_float)
|
||||
discount_amount_stars = original_stars_price - stars_price
|
||||
logging.info(
|
||||
"Stars discount applied: %s -> %.2f -> %s (ceiling)",
|
||||
original_stars_price,
|
||||
final_price_float,
|
||||
stars_price,
|
||||
)
|
||||
|
||||
payment_record_data = {
|
||||
"user_id": user_id,
|
||||
"amount": float(stars_price),
|
||||
"original_amount": float(original_stars_price) if discount_amount_stars else None,
|
||||
"discount_applied": float(discount_amount_stars) if discount_amount_stars else None,
|
||||
"currency": "XTR",
|
||||
"status": "pending_stars",
|
||||
"description": description,
|
||||
"subscription_duration_months": int(months),
|
||||
"provider": "telegram_stars",
|
||||
"promo_code_id": promo_code_id,
|
||||
}
|
||||
try:
|
||||
db_payment_record = await payment_dal.create_payment_record(
|
||||
@@ -55,7 +134,7 @@ class StarsService:
|
||||
title=description,
|
||||
description=description,
|
||||
payload=payload,
|
||||
provider_token="", # Required to be empty for Telegram Stars (XTR) per Telegram Bot API.
|
||||
provider_token=self.settings.STARS_PROVIDER_TOKEN or "",
|
||||
currency="XTR",
|
||||
prices=prices,
|
||||
)
|
||||
@@ -72,45 +151,61 @@ class StarsService:
|
||||
stars_amount: int,
|
||||
i18n_data: dict,
|
||||
sale_mode: str = "subscription") -> None:
|
||||
# Fetch payment record to get promo_code_id
|
||||
payment_record = await payment_dal.get_payment_by_db_id(session, payment_db_id)
|
||||
promo_code_id_from_payment = payment_record.promo_code_id if payment_record else None
|
||||
|
||||
activation_details = None
|
||||
referral_bonus = None
|
||||
try:
|
||||
await payment_dal.update_provider_payment_and_status(
|
||||
session, payment_db_id,
|
||||
message.successful_payment.provider_payment_charge_id,
|
||||
"succeeded")
|
||||
provider_payment_id = str(
|
||||
message.successful_payment.provider_payment_charge_id
|
||||
or f"stars:{payment_db_id}"
|
||||
)
|
||||
marked = await payment_dal.mark_provider_payment_succeeded_once(
|
||||
session,
|
||||
payment_db_id,
|
||||
provider_payment_id,
|
||||
)
|
||||
if not marked:
|
||||
logging.info(
|
||||
"Stars payment %s already processed atomically",
|
||||
payment_db_id,
|
||||
)
|
||||
return
|
||||
|
||||
activation_details = await self.subscription_service.activate_subscription(
|
||||
session,
|
||||
message.from_user.id,
|
||||
int(months) if sale_mode != "traffic" else 0,
|
||||
float(stars_amount),
|
||||
payment_db_id,
|
||||
promo_code_id_from_payment=promo_code_id_from_payment,
|
||||
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"):
|
||||
raise RuntimeError(
|
||||
f"Failed to activate subscription after stars payment {payment_db_id}"
|
||||
)
|
||||
|
||||
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()
|
||||
except Exception as e_upd:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"Failed to update stars payment record {payment_db_id}: {e_upd}",
|
||||
f"Failed to process stars payment record {payment_db_id}: {e_upd}",
|
||||
exc_info=True)
|
||||
return
|
||||
|
||||
activation_details = await self.subscription_service.activate_subscription(
|
||||
session,
|
||||
message.from_user.id,
|
||||
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 = 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
|
||||
final_end = referral_bonus.get("referee_new_end_date") if referral_bonus else None
|
||||
if not final_end:
|
||||
|
||||
@@ -5,7 +5,7 @@ from typing import Optional, Dict, Any, List, Tuple
|
||||
from aiogram import Bot
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
|
||||
from db.dal import user_dal, subscription_dal, promo_code_dal, payment_dal, user_billing_dal
|
||||
from db.dal import user_dal, subscription_dal, promo_code_dal, user_billing_dal, payment_dal
|
||||
from bot.utils.date_utils import add_months
|
||||
from bot.utils.config_link import prepare_config_links
|
||||
from db.models import User, Subscription
|
||||
@@ -55,33 +55,6 @@ 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
|
||||
@@ -96,42 +69,6 @@ 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]:
|
||||
@@ -145,45 +82,25 @@ class SubscriptionService:
|
||||
return None, None, None, False
|
||||
|
||||
current_local_panel_uuid = db_user.panel_user_uuid
|
||||
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_username_on_panel_standard = f"tg_{user_id}"
|
||||
|
||||
panel_user_obj_from_api = None
|
||||
panel_user_created_or_linked_now = False
|
||||
|
||||
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
|
||||
)
|
||||
panel_users_by_tg_id_list = await self.panel_service.get_users_by_filter(
|
||||
telegram_id=user_id
|
||||
)
|
||||
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 {telegram_id_for_panel}: UUID {panel_user_obj_from_api.get('uuid')}, Username: {panel_user_obj_from_api.get('username')}"
|
||||
f"Found panel user by telegramId {user_id}: 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 {telegram_id_for_panel}. Manual intervention needed."
|
||||
f"CRITICAL: Multiple panel users found for telegramId {user_id}. 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:
|
||||
|
||||
@@ -202,9 +119,12 @@ class SubscriptionService:
|
||||
)
|
||||
creation_response = await self.panel_service.create_panel_user(
|
||||
username_on_panel=panel_username_on_panel_standard,
|
||||
telegram_id=telegram_id_for_panel,
|
||||
email=db_user.email,
|
||||
description=self._panel_description_for_user(db_user),
|
||||
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 "",
|
||||
]),
|
||||
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,
|
||||
@@ -228,9 +148,12 @@ class SubscriptionService:
|
||||
)
|
||||
creation_response = await self.panel_service.create_panel_user(
|
||||
username_on_panel=panel_username_on_panel_standard,
|
||||
telegram_id=telegram_id_for_panel,
|
||||
email=db_user.email,
|
||||
description=self._panel_description_for_user(db_user),
|
||||
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 "",
|
||||
]),
|
||||
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,
|
||||
@@ -276,6 +199,7 @@ 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:
|
||||
@@ -342,15 +266,24 @@ class SubscriptionService:
|
||||
if (
|
||||
panel_user_obj_from_api
|
||||
and current_local_panel_uuid
|
||||
and telegram_id_for_panel
|
||||
and panel_telegram_id_int != telegram_id_for_panel
|
||||
and panel_telegram_id_int != user_id
|
||||
):
|
||||
logging.info(
|
||||
f"Panel user {current_local_panel_uuid} has telegramId '{panel_telegram_id_from_api}'. Updating on panel to '{telegram_id_for_panel}'."
|
||||
f"Panel user {current_local_panel_uuid} has telegramId '{panel_telegram_id_from_api}'. Updating on panel to '{user_id}'."
|
||||
)
|
||||
# Also set readable description with Telegram fields
|
||||
await self.panel_service.update_user_details_on_panel(
|
||||
current_local_panel_uuid,
|
||||
self._panel_identity_payload_for_user(db_user),
|
||||
{
|
||||
"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 "",
|
||||
]
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
panel_sub_link_id = panel_user_obj_from_api.get(
|
||||
@@ -448,7 +381,14 @@ class SubscriptionService:
|
||||
traffic_limit_bytes=self.settings.trial_traffic_limit_bytes,
|
||||
)
|
||||
|
||||
panel_update_payload.update(self._panel_identity_payload_for_user(db_user))
|
||||
# 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 "",
|
||||
]
|
||||
)
|
||||
|
||||
updated_panel_user = await self.panel_service.update_user_details_on_panel(
|
||||
panel_user_uuid, panel_update_payload
|
||||
@@ -504,7 +444,9 @@ class SubscriptionService:
|
||||
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)
|
||||
traffic_info = panel_user_data.get("userTraffic") or {}
|
||||
current_limit = panel_user_data.get("trafficLimitBytes")
|
||||
current_used = traffic_info.get("usedTrafficBytes")
|
||||
|
||||
active_sub = await subscription_dal.get_active_subscription_by_user_id(
|
||||
session, user_id, panel_user_uuid
|
||||
@@ -558,7 +500,13 @@ class SubscriptionService:
|
||||
traffic_limit_strategy="NO_RESET",
|
||||
)
|
||||
|
||||
panel_update_payload.update(self._panel_identity_payload_for_user(db_user))
|
||||
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 "",
|
||||
]
|
||||
)
|
||||
|
||||
updated_panel_user = await self.panel_service.update_user_details_on_panel(
|
||||
panel_user_uuid, panel_update_payload
|
||||
@@ -656,7 +604,7 @@ class SubscriptionService:
|
||||
and promo_model.is_active
|
||||
and promo_model.current_activations < promo_model.max_activations
|
||||
):
|
||||
applied_promo_bonus_days = promo_model.bonus_days
|
||||
applied_promo_bonus_days = promo_model.bonus_days or 0
|
||||
duration_days_total += applied_promo_bonus_days
|
||||
|
||||
activation = await promo_code_dal.record_promo_activation(
|
||||
@@ -667,7 +615,7 @@ class SubscriptionService:
|
||||
)
|
||||
if activation:
|
||||
await promo_code_dal.increment_promo_code_usage(
|
||||
session, promo_code_id_from_payment
|
||||
session, promo_code_id_from_payment, allow_overflow=True
|
||||
)
|
||||
else:
|
||||
logging.warning(
|
||||
@@ -722,7 +670,14 @@ class SubscriptionService:
|
||||
traffic_limit_bytes=self.settings.user_traffic_limit_bytes,
|
||||
)
|
||||
|
||||
panel_update_payload.update(self._panel_identity_payload_for_user(db_user))
|
||||
# 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 "",
|
||||
]
|
||||
)
|
||||
|
||||
updated_panel_user = await self.panel_service.update_user_details_on_panel(
|
||||
panel_user_uuid, panel_update_payload
|
||||
@@ -736,6 +691,22 @@ class SubscriptionService:
|
||||
final_subscription_url = updated_panel_user.get("subscriptionUrl")
|
||||
final_panel_short_uuid = updated_panel_user.get("shortUuid", panel_short_uuid)
|
||||
|
||||
# Consume discount promo code if payment had one
|
||||
try:
|
||||
promo_code_service = getattr(self, "promo_code_service", None)
|
||||
if not promo_code_service:
|
||||
from .promo_code_service import PromoCodeService
|
||||
|
||||
promo_code_service = PromoCodeService(
|
||||
self.settings, self, self.bot, self.i18n
|
||||
)
|
||||
await promo_code_service.consume_discount(session, user_id, payment_db_id)
|
||||
except Exception as e:
|
||||
logging.error(
|
||||
f"Failed to consume discount for user {user_id}, payment {payment_db_id}: {e}"
|
||||
)
|
||||
# Don't fail the subscription activation if discount consumption fails
|
||||
|
||||
return {
|
||||
"subscription_id": new_or_updated_sub.subscription_id,
|
||||
"end_date": final_end_date,
|
||||
@@ -887,22 +858,13 @@ 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_traffic_limit, _ = self._extract_panel_traffic_details(panel_user_data)
|
||||
traffic_stats = panel_user_data.get("userTraffic") or {}
|
||||
panel_traffic_used = traffic_stats.get("usedTrafficBytes")
|
||||
panel_traffic_limit = panel_user_data.get("trafficLimitBytes")
|
||||
panel_sub_uuid_from_panel = panel_user_data.get(
|
||||
"subscriptionUuid"
|
||||
) or panel_user_data.get("shortUuid")
|
||||
@@ -955,7 +917,6 @@ 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")
|
||||
@@ -968,9 +929,8 @@ class SubscriptionService:
|
||||
"status_from_panel": panel_user_data.get("status", "UNKNOWN").upper(),
|
||||
"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,
|
||||
"traffic_limit_bytes": panel_user_data.get("trafficLimitBytes"),
|
||||
"traffic_used_bytes": (panel_user_data.get("userTraffic") or {}).get("usedTrafficBytes"),
|
||||
"user_bot_username": db_user.username,
|
||||
"is_panel_data": True,
|
||||
"max_devices": hwid_limit,
|
||||
@@ -1047,15 +1007,30 @@ class SubscriptionService:
|
||||
logging.error(f"Auto-renew price missing for {months} months")
|
||||
return False
|
||||
|
||||
payment_description = f"Auto-renewal for {months} months"
|
||||
payment_record = await payment_dal.create_payment_record(
|
||||
session,
|
||||
{
|
||||
"user_id": sub.user_id,
|
||||
"amount": float(amount),
|
||||
"currency": "RUB",
|
||||
"status": "pending_yookassa",
|
||||
"description": payment_description,
|
||||
"subscription_duration_months": int(months),
|
||||
"provider": "yookassa",
|
||||
},
|
||||
)
|
||||
|
||||
metadata = {
|
||||
"user_id": str(sub.user_id),
|
||||
"auto_renew_for_subscription_id": str(sub.subscription_id),
|
||||
"subscription_months": str(months),
|
||||
"payment_db_id": str(payment_record.payment_id),
|
||||
}
|
||||
resp = await yk.create_payment(
|
||||
amount=float(amount),
|
||||
currency="RUB",
|
||||
description=f"Auto-renewal for {months} months",
|
||||
description=payment_description,
|
||||
metadata=metadata,
|
||||
payment_method_id=default_pm.provider_payment_method_id,
|
||||
save_payment_method=False,
|
||||
@@ -1064,6 +1039,14 @@ class SubscriptionService:
|
||||
if not resp or resp.get("status") not in {"pending", "waiting_for_capture", "succeeded"}:
|
||||
logging.error(f"Auto-renew create_payment failed: {resp}")
|
||||
return False
|
||||
provider_payment_id = resp.get("id")
|
||||
if provider_payment_id:
|
||||
await payment_dal.update_provider_payment_and_status(
|
||||
session,
|
||||
payment_db_id=payment_record.payment_id,
|
||||
provider_payment_id=provider_payment_id,
|
||||
new_status="pending_yookassa",
|
||||
)
|
||||
logging.info(f"Auto-renew initiated for user {sub.user_id} payment_id={resp.get('id')}")
|
||||
return True
|
||||
|
||||
|
||||
@@ -35,8 +35,9 @@ class YooKassaService:
|
||||
self.configured = True
|
||||
logging.info(
|
||||
f"YooKassa SDK configured for shop_id: {shop_id[:5]}...")
|
||||
except Exception:
|
||||
logging.exception("Failed to configure YooKassa SDK.")
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to configure YooKassa SDK: {e}",
|
||||
exc_info=True)
|
||||
self.configured = False
|
||||
|
||||
if configured_return_url:
|
||||
@@ -160,11 +161,10 @@ class YooKassaService:
|
||||
f"Amount: {amount} {currency}. Metadata: {metadata}. Receipt: {receipt_data_dict}"
|
||||
)
|
||||
|
||||
response = await asyncio.to_thread(
|
||||
YooKassaPayment.create,
|
||||
payment_request,
|
||||
idempotence_key,
|
||||
)
|
||||
loop = asyncio.get_running_loop()
|
||||
response = await loop.run_in_executor(
|
||||
None, lambda: YooKassaPayment.create(payment_request,
|
||||
idempotence_key))
|
||||
|
||||
logging.info(
|
||||
f"YooKassa Payment.create response: ID={response.id}, Status={response.status}, Paid={response.paid}"
|
||||
@@ -200,8 +200,9 @@ class YooKassaService:
|
||||
response.test if hasattr(response, 'test') else None,
|
||||
"payment_method": getattr(response, 'payment_method', None),
|
||||
}
|
||||
except Exception:
|
||||
logging.exception("YooKassa payment creation failed.")
|
||||
except Exception as e:
|
||||
logging.error(f"YooKassa payment creation failed: {e}",
|
||||
exc_info=True)
|
||||
return None
|
||||
|
||||
async def get_payment_info(
|
||||
@@ -215,10 +216,9 @@ class YooKassaService:
|
||||
f"Fetching payment info from YooKassa for ID: {payment_id_in_yookassa}"
|
||||
)
|
||||
|
||||
payment_info_yk = await asyncio.to_thread(
|
||||
YooKassaPayment.find_one,
|
||||
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))
|
||||
|
||||
if payment_info_yk:
|
||||
logging.info(
|
||||
@@ -264,9 +264,10 @@ class YooKassaService:
|
||||
f"No payment info found in YooKassa for ID: {payment_id_in_yookassa}"
|
||||
)
|
||||
return None
|
||||
except Exception:
|
||||
logging.exception(
|
||||
"YooKassa get payment info for %s failed.", payment_id_in_yookassa)
|
||||
except Exception as e:
|
||||
logging.error(
|
||||
f"YooKassa get payment info for {payment_id_in_yookassa} failed: {e}",
|
||||
exc_info=True)
|
||||
return None
|
||||
|
||||
async def cancel_payment(self, payment_id_in_yookassa: str) -> bool:
|
||||
@@ -274,9 +275,10 @@ class YooKassaService:
|
||||
logging.error("YooKassa is not configured. Cannot cancel payment.")
|
||||
return False
|
||||
try:
|
||||
await asyncio.to_thread(YooKassaPayment.cancel, payment_id_in_yookassa)
|
||||
loop = asyncio.get_running_loop()
|
||||
await loop.run_in_executor(None, lambda: YooKassaPayment.cancel(payment_id_in_yookassa))
|
||||
logging.info(f"Cancelled YooKassa payment {payment_id_in_yookassa}")
|
||||
return True
|
||||
except Exception:
|
||||
logging.exception("Failed to cancel YooKassa payment %s.", payment_id_in_yookassa)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to cancel YooKassa payment {payment_id_in_yookassa}: {e}")
|
||||
return False
|
||||
|
||||
@@ -6,8 +6,10 @@ class AdminStates(StatesGroup):
|
||||
waiting_for_broadcast_message = State()
|
||||
confirming_broadcast = State()
|
||||
waiting_for_promo_details = State()
|
||||
waiting_for_promo_type_selection = State()
|
||||
waiting_for_promo_code = State()
|
||||
waiting_for_promo_bonus_days = State()
|
||||
waiting_for_promo_discount_percentage = State()
|
||||
waiting_for_promo_max_activations = State()
|
||||
waiting_for_promo_validity_days = State()
|
||||
waiting_for_promo_edit_details = State()
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
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
|
||||
@@ -82,9 +82,9 @@ class MessageQueue:
|
||||
self.total_failed += 1
|
||||
logging.error(f"Failed to send queued message to {message.chat_id}: {exc}")
|
||||
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
self.total_failed += 1
|
||||
logging.exception("Failed to send queued message to %s.", message.chat_id)
|
||||
logging.error(f"Failed to send queued message to {message.chat_id}: {e}")
|
||||
|
||||
finally:
|
||||
self.is_processing = False
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
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)
|
||||
+104
-366
@@ -1,100 +1,7 @@
|
||||
import logging
|
||||
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]
|
||||
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]
|
||||
from pydantic import Field, ValidationError, computed_field, field_validator, model_validator
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
@@ -104,20 +11,21 @@ class Settings(BaseSettings):
|
||||
alias="ADMIN_IDS",
|
||||
description="Comma-separated list of admin Telegram User IDs")
|
||||
|
||||
POSTGRES_USER: str = Field(...)
|
||||
POSTGRES_PASSWORD: str = Field(...)
|
||||
POSTGRES_USER: str = Field(default="user")
|
||||
POSTGRES_PASSWORD: str = Field(default="password")
|
||||
POSTGRES_HOST: str = Field(default="localhost")
|
||||
POSTGRES_PORT: int = Field(default=5432)
|
||||
POSTGRES_DB: str = Field(default="vpn_shop_db")
|
||||
|
||||
DEFAULT_LANGUAGE: str = Field(default="ru")
|
||||
DEFAULT_CURRENCY_SYMBOL: str = Field(default="RUB")
|
||||
|
||||
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_SUBSCRIBE_TO_USE: bool = Field(
|
||||
default=False,
|
||||
description="Require users to subscribe to REQUIRED_CHANNEL_ID before using the bot",
|
||||
)
|
||||
REQUIRED_CHANNEL_ID: Optional[int] = Field(
|
||||
default=None,
|
||||
description="Telegram channel ID the user must join to access the bot")
|
||||
@@ -168,9 +76,13 @@ class Settings(BaseSettings):
|
||||
)
|
||||
|
||||
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.",
|
||||
TELEGRAM_WEBHOOK_PATH: str = Field(
|
||||
default="/webhook/telegram",
|
||||
description="Relative path for Telegram webhook endpoint",
|
||||
)
|
||||
TELEGRAM_WEBHOOK_SECRET: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Secret token for Telegram webhook header validation",
|
||||
)
|
||||
|
||||
CRYPTOPAY_TOKEN: Optional[str] = None
|
||||
@@ -183,24 +95,7 @@ class Settings(BaseSettings):
|
||||
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).",
|
||||
default=2, description="Platega payment method ID (e.g., 2 for SBP QR)"
|
||||
)
|
||||
PLATEGA_RETURN_URL: Optional[str] = Field(default=None)
|
||||
PLATEGA_FAILED_URL: Optional[str] = Field(default=None)
|
||||
@@ -213,10 +108,6 @@ class Settings(BaseSettings):
|
||||
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
|
||||
@@ -230,6 +121,10 @@ class Settings(BaseSettings):
|
||||
|
||||
YOOKASSA_ENABLED: bool = Field(default=True)
|
||||
STARS_ENABLED: bool = Field(default=True)
|
||||
STARS_PROVIDER_TOKEN: Optional[str] = Field(
|
||||
default="",
|
||||
description="Provider token for Telegram invoices. For Stars (XTR) should stay empty.",
|
||||
)
|
||||
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)",
|
||||
@@ -288,9 +183,9 @@ class Settings(BaseSettings):
|
||||
default=True,
|
||||
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.",
|
||||
REFERRAL_ENABLED: bool = Field(
|
||||
default=True,
|
||||
description="Enable referral links, referral menu and referral bonuses",
|
||||
)
|
||||
LEGACY_REFS: bool = Field(
|
||||
default=True,
|
||||
@@ -319,48 +214,6 @@ class Settings(BaseSettings):
|
||||
|
||||
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_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)
|
||||
@@ -388,102 +241,6 @@ 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,
|
||||
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]:
|
||||
@@ -541,20 +298,21 @@ class Settings(BaseSettings):
|
||||
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"
|
||||
path = (self.TELEGRAM_WEBHOOK_PATH or "").strip() or "/webhook/telegram"
|
||||
if not path.startswith("/"):
|
||||
path = f"/{path}"
|
||||
return path
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def telegram_full_webhook_url(self) -> Optional[str]:
|
||||
base = self.WEBHOOK_BASE_URL
|
||||
if base:
|
||||
return f"{base.rstrip('/')}{self.telegram_webhook_path}"
|
||||
return None
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
@@ -775,8 +533,7 @@ class Settings(BaseSettings):
|
||||
"""
|
||||
default_order = [
|
||||
"freekassa",
|
||||
"platega_sbp",
|
||||
"platega_crypto",
|
||||
"platega",
|
||||
"severpay",
|
||||
"yookassa",
|
||||
"stars",
|
||||
@@ -784,57 +541,12 @@ class Settings(BaseSettings):
|
||||
]
|
||||
if not self.PAYMENT_METHODS_ORDER:
|
||||
return default_order
|
||||
methods: List[str] = []
|
||||
methods = []
|
||||
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)
|
||||
if slug:
|
||||
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(
|
||||
@@ -843,7 +555,24 @@ class Settings(BaseSettings):
|
||||
)
|
||||
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)")
|
||||
LOG_STORE_MESSAGE_CONTENT: bool = Field(
|
||||
default=False,
|
||||
description="Store message/callback content in message logs",
|
||||
)
|
||||
LOG_STORE_RAW_UPDATES: bool = Field(
|
||||
default=False,
|
||||
description="Store raw update previews in message logs",
|
||||
)
|
||||
LOG_EXPORT_INCLUDE_SENSITIVE: bool = Field(
|
||||
default=False,
|
||||
description="Include content/raw update fields in admin CSV export",
|
||||
)
|
||||
|
||||
LOG_ADMIN_HIDE: bool = Field(
|
||||
default=False,
|
||||
description="Hide admin-generated events from admin logs UI and CSV export",
|
||||
)
|
||||
|
||||
@field_validator('LOG_LEVEL', mode='before')
|
||||
@classmethod
|
||||
def normalize_log_level(cls, v):
|
||||
@@ -853,33 +582,39 @@ class Settings(BaseSettings):
|
||||
return "INFO"
|
||||
return v
|
||||
|
||||
@field_validator('POSTGRES_USER', 'POSTGRES_PASSWORD', mode='before')
|
||||
@model_validator(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
|
||||
def drop_comment_placeholder_values(cls, values: Any):
|
||||
"""
|
||||
dotenv parses lines like `KEY= # comment` as `"# comment"`.
|
||||
Treat such values as unset so defaults/optionals work as expected.
|
||||
"""
|
||||
if not isinstance(values, dict):
|
||||
return values
|
||||
|
||||
@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)
|
||||
sanitized: Dict[str, Any] = {}
|
||||
for key, value in values.items():
|
||||
if isinstance(value, str):
|
||||
trimmed = value.strip()
|
||||
if trimmed == "#" or trimmed.startswith("# "):
|
||||
continue
|
||||
sanitized[key] = value
|
||||
return sanitized
|
||||
|
||||
@field_validator('LOG_CHAT_ID', 'LOG_THREAD_ID', mode='before')
|
||||
@field_validator(
|
||||
'TELEGRAM_WEBHOOK_PATH',
|
||||
mode='before',
|
||||
)
|
||||
@classmethod
|
||||
def validate_optional_int_fields(cls, v):
|
||||
"""Convert empty strings to None for optional integer fields"""
|
||||
if isinstance(v, str) and v.strip() == '':
|
||||
return None
|
||||
return v
|
||||
def normalize_webhook_path(cls, v):
|
||||
if not isinstance(v, str):
|
||||
return "/webhook/telegram"
|
||||
cleaned = v.strip()
|
||||
if not cleaned:
|
||||
return "/webhook/telegram"
|
||||
if not cleaned.startswith("/"):
|
||||
cleaned = f"/{cleaned}"
|
||||
return cleaned
|
||||
|
||||
@field_validator(
|
||||
'REQUIRED_CHANNEL_LINK',
|
||||
@@ -887,15 +622,8 @@ class Settings(BaseSettings):
|
||||
'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',
|
||||
'TELEGRAM_WEBHOOK_SECRET',
|
||||
'PANEL_WEBHOOK_SECRET',
|
||||
mode='before',
|
||||
)
|
||||
@classmethod
|
||||
@@ -904,7 +632,16 @@ class Settings(BaseSettings):
|
||||
return None
|
||||
return v
|
||||
|
||||
@field_validator('USER_HWID_DEVICE_LIMIT', 'SEVERPAY_MID', 'SEVERPAY_LIFETIME_MINUTES', mode='before')
|
||||
@field_validator(
|
||||
'REQUIRED_CHANNEL_ID',
|
||||
'FREEKASSA_PAYMENT_METHOD_ID',
|
||||
'USER_HWID_DEVICE_LIMIT',
|
||||
'SEVERPAY_MID',
|
||||
'SEVERPAY_LIFETIME_MINUTES',
|
||||
'LOG_CHAT_ID',
|
||||
'LOG_THREAD_ID',
|
||||
mode='before'
|
||||
)
|
||||
@classmethod
|
||||
def validate_optional_int(cls, v):
|
||||
if isinstance(v, str):
|
||||
@@ -919,6 +656,10 @@ class Settings(BaseSettings):
|
||||
LOG_PROMO_ACTIVATIONS: bool = Field(default=True, description="Send notifications for promo code activations")
|
||||
LOG_TRIAL_ACTIVATIONS: bool = Field(default=True, description="Send notifications for trial activations")
|
||||
LOG_SUSPICIOUS_ACTIVITY: bool = Field(default=True, description="Send notifications for suspicious promo attempts")
|
||||
DISCOUNT_PROMO_PAYMENT_TIMEOUT_MINUTES: int = Field(
|
||||
default=10,
|
||||
description="How long a discount promo reservation is kept before user payment",
|
||||
)
|
||||
|
||||
model_config = SettingsConfigDict(env_file='.env',
|
||||
env_file_encoding='utf-8',
|
||||
@@ -943,13 +684,10 @@ 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"):
|
||||
if _settings_instance.WEBHOOK_BASE_URL and not _settings_instance.TELEGRAM_WEBHOOK_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."
|
||||
"WARNING: TELEGRAM_WEBHOOK_SECRET is empty while webhook mode is enabled. "
|
||||
"Set TELEGRAM_WEBHOOK_SECRET to validate X-Telegram-Bot-Api-Secret-Token header."
|
||||
)
|
||||
if not _settings_instance.YOOKASSA_SHOP_ID or not _settings_instance.YOOKASSA_SECRET_KEY:
|
||||
logging.warning(
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Tuple
|
||||
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from sqlalchemy import inspect, text
|
||||
from sqlalchemy.engine import Connection
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||
|
||||
from config.settings import Settings
|
||||
|
||||
|
||||
import os
|
||||
_BASELINE_REVISION = "0001_initial_schema"
|
||||
|
||||
|
||||
def _build_alembic_config(settings: Settings) -> Config:
|
||||
project_root = Path(__file__).resolve().parents[1]
|
||||
config = Config(str(project_root / "alembic.ini"))
|
||||
config.set_main_option("script_location", str(project_root / "alembic"))
|
||||
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
|
||||
return config
|
||||
|
||||
|
||||
def _inspect_database_state(connection: Connection) -> Tuple[bool, bool, bool]:
|
||||
db_inspector = inspect(connection)
|
||||
has_alembic_version = db_inspector.has_table("alembic_version")
|
||||
has_users_table = db_inspector.has_table("users")
|
||||
has_legacy_migrator_table = db_inspector.has_table("schema_migrations")
|
||||
return has_alembic_version, has_users_table, has_legacy_migrator_table
|
||||
|
||||
|
||||
def _run_legacy_migrator_compatibility(connection: Connection) -> None:
|
||||
db_inspector = inspect(connection)
|
||||
if not db_inspector.has_table("users"):
|
||||
return
|
||||
|
||||
users_columns = {
|
||||
column["name"]
|
||||
for column in db_inspector.get_columns("users")
|
||||
}
|
||||
user_alter_statements = []
|
||||
|
||||
if "channel_subscription_verified" not in users_columns:
|
||||
user_alter_statements.append(
|
||||
"ALTER TABLE users ADD COLUMN channel_subscription_verified BOOLEAN"
|
||||
)
|
||||
if "channel_subscription_checked_at" not in users_columns:
|
||||
user_alter_statements.append(
|
||||
"ALTER TABLE users ADD COLUMN channel_subscription_checked_at TIMESTAMPTZ"
|
||||
)
|
||||
if "channel_subscription_verified_for" not in users_columns:
|
||||
user_alter_statements.append(
|
||||
"ALTER TABLE users ADD COLUMN channel_subscription_verified_for BIGINT"
|
||||
)
|
||||
if "referral_code" not in users_columns:
|
||||
user_alter_statements.append(
|
||||
"ALTER TABLE users ADD COLUMN referral_code VARCHAR(16)"
|
||||
)
|
||||
|
||||
for statement in user_alter_statements:
|
||||
connection.execute(text(statement))
|
||||
|
||||
users_columns = {
|
||||
column["name"]
|
||||
for column in inspect(connection).get_columns("users")
|
||||
}
|
||||
if "referral_code" in users_columns:
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE users
|
||||
SET referral_code = NULLIF(UPPER(BTRIM(referral_code)), '')
|
||||
WHERE referral_code IS NOT NULL
|
||||
"""
|
||||
)
|
||||
)
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
WITH duplicate_codes AS (
|
||||
SELECT
|
||||
user_id,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY referral_code
|
||||
ORDER BY user_id
|
||||
) AS rn
|
||||
FROM users
|
||||
WHERE referral_code IS NOT NULL
|
||||
)
|
||||
UPDATE users AS u
|
||||
SET referral_code = UPPER(
|
||||
SUBSTRING(
|
||||
md5(
|
||||
u.user_id::text
|
||||
|| clock_timestamp()::text
|
||||
|| random()::text
|
||||
)
|
||||
FROM 1 FOR 9
|
||||
)
|
||||
)
|
||||
FROM duplicate_codes AS d
|
||||
WHERE u.user_id = d.user_id
|
||||
AND d.rn > 1
|
||||
"""
|
||||
)
|
||||
)
|
||||
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
|
||||
)
|
||||
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
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
db_inspector = inspect(connection)
|
||||
if db_inspector.has_table("payments"):
|
||||
payments_columns = {
|
||||
column["name"]
|
||||
for column in db_inspector.get_columns("payments")
|
||||
}
|
||||
if "original_amount" not in payments_columns:
|
||||
connection.execute(text("ALTER TABLE payments ADD COLUMN original_amount FLOAT"))
|
||||
if "discount_applied" not in payments_columns:
|
||||
connection.execute(text("ALTER TABLE payments ADD COLUMN discount_applied FLOAT"))
|
||||
|
||||
db_inspector = inspect(connection)
|
||||
has_promo_codes = db_inspector.has_table("promo_codes")
|
||||
if has_promo_codes:
|
||||
promo_columns = {
|
||||
column["name"]
|
||||
for column in db_inspector.get_columns("promo_codes")
|
||||
}
|
||||
if "promo_type" not in promo_columns:
|
||||
connection.execute(
|
||||
text(
|
||||
"ALTER TABLE promo_codes ADD COLUMN promo_type VARCHAR NOT NULL DEFAULT 'bonus_days'"
|
||||
)
|
||||
)
|
||||
if "discount_percentage" not in promo_columns:
|
||||
connection.execute(
|
||||
text("ALTER TABLE promo_codes ADD COLUMN discount_percentage INTEGER")
|
||||
)
|
||||
if "current_activations" not in promo_columns:
|
||||
connection.execute(
|
||||
text(
|
||||
"ALTER TABLE promo_codes ADD COLUMN current_activations INTEGER NOT NULL DEFAULT 0"
|
||||
)
|
||||
)
|
||||
else:
|
||||
connection.execute(
|
||||
text(
|
||||
"UPDATE promo_codes SET current_activations = 0 "
|
||||
"WHERE current_activations IS NULL"
|
||||
)
|
||||
)
|
||||
connection.execute(
|
||||
text("ALTER TABLE promo_codes ALTER COLUMN current_activations SET DEFAULT 0")
|
||||
)
|
||||
connection.execute(
|
||||
text("ALTER TABLE promo_codes ALTER COLUMN current_activations SET NOT NULL")
|
||||
)
|
||||
if "bonus_days" in promo_columns:
|
||||
connection.execute(
|
||||
text("ALTER TABLE promo_codes ALTER COLUMN bonus_days DROP NOT NULL")
|
||||
)
|
||||
connection.execute(
|
||||
text(
|
||||
"CREATE INDEX IF NOT EXISTS idx_promo_codes_promo_type ON promo_codes (promo_type)"
|
||||
)
|
||||
)
|
||||
|
||||
db_inspector = inspect(connection)
|
||||
has_active_discounts = db_inspector.has_table("active_discounts")
|
||||
if not has_active_discounts and has_promo_codes:
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS active_discounts (
|
||||
user_id BIGINT PRIMARY KEY,
|
||||
promo_code_id INTEGER NOT NULL,
|
||||
discount_percentage INTEGER NOT NULL,
|
||||
activated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT fk_active_discounts_user
|
||||
FOREIGN KEY (user_id) REFERENCES users (user_id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_active_discounts_promo_code
|
||||
FOREIGN KEY (promo_code_id) REFERENCES promo_codes (promo_code_id) ON DELETE CASCADE
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
has_active_discounts = True
|
||||
|
||||
if has_active_discounts and has_promo_codes:
|
||||
connection.execute(
|
||||
text(
|
||||
"DELETE FROM active_discounts ad "
|
||||
"WHERE NOT EXISTS (SELECT 1 FROM users u WHERE u.user_id = ad.user_id) "
|
||||
"OR NOT EXISTS (SELECT 1 FROM promo_codes p WHERE p.promo_code_id = ad.promo_code_id)"
|
||||
)
|
||||
)
|
||||
connection.execute(
|
||||
text(
|
||||
"ALTER TABLE active_discounts "
|
||||
"DROP CONSTRAINT IF EXISTS active_discounts_user_id_fkey"
|
||||
)
|
||||
)
|
||||
connection.execute(
|
||||
text(
|
||||
"ALTER TABLE active_discounts "
|
||||
"DROP CONSTRAINT IF EXISTS fk_active_discounts_user"
|
||||
)
|
||||
)
|
||||
connection.execute(
|
||||
text(
|
||||
"ALTER TABLE active_discounts "
|
||||
"DROP CONSTRAINT IF EXISTS active_discounts_promo_code_id_fkey"
|
||||
)
|
||||
)
|
||||
connection.execute(
|
||||
text(
|
||||
"ALTER TABLE active_discounts "
|
||||
"DROP CONSTRAINT IF EXISTS fk_active_discounts_promo_code"
|
||||
)
|
||||
)
|
||||
connection.execute(
|
||||
text(
|
||||
"ALTER TABLE active_discounts "
|
||||
"ADD CONSTRAINT fk_active_discounts_user "
|
||||
"FOREIGN KEY (user_id) REFERENCES users (user_id) ON DELETE CASCADE"
|
||||
)
|
||||
)
|
||||
connection.execute(
|
||||
text(
|
||||
"ALTER TABLE active_discounts "
|
||||
"ADD CONSTRAINT fk_active_discounts_promo_code "
|
||||
"FOREIGN KEY (promo_code_id) REFERENCES promo_codes (promo_code_id) ON DELETE CASCADE"
|
||||
)
|
||||
)
|
||||
elif has_active_discounts and not has_promo_codes:
|
||||
logging.warning(
|
||||
"Alembic legacy compatibility: skipped active_discounts FK repair "
|
||||
"because promo_codes table is missing."
|
||||
)
|
||||
|
||||
|
||||
def _run_stamp(connection: Connection, alembic_config: Config, revision: str) -> None:
|
||||
alembic_config.attributes["connection"] = connection
|
||||
command.stamp(alembic_config, revision)
|
||||
|
||||
|
||||
def _run_upgrade(connection: Connection, alembic_config: Config) -> None:
|
||||
alembic_config.attributes["connection"] = connection
|
||||
command.upgrade(alembic_config, "head")
|
||||
|
||||
|
||||
async def run_alembic_migrations(settings: Settings, async_engine: AsyncEngine) -> None:
|
||||
"""Apply Alembic migrations with bootstrap for existing installations."""
|
||||
|
||||
alembic_config = _build_alembic_config(settings)
|
||||
allow_bootstrap_without_legacy = os.getenv("ALEMBIC_ALLOW_STAMP_WITHOUT_LEGACY", "").lower() in {
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
"on",
|
||||
}
|
||||
|
||||
async with async_engine.begin() as async_connection:
|
||||
(
|
||||
has_alembic_version,
|
||||
has_users_table,
|
||||
has_legacy_migrator_table,
|
||||
) = await async_connection.run_sync(
|
||||
_inspect_database_state
|
||||
)
|
||||
|
||||
if not has_alembic_version and has_users_table:
|
||||
if not has_legacy_migrator_table and not allow_bootstrap_without_legacy:
|
||||
raise RuntimeError(
|
||||
"Alembic bootstrap refused: found existing users table without "
|
||||
"alembic_version and without legacy schema_migrations marker. "
|
||||
"Set ALEMBIC_ALLOW_STAMP_WITHOUT_LEGACY=true to explicitly allow "
|
||||
f"stamping baseline {_BASELINE_REVISION} after manual verification."
|
||||
)
|
||||
|
||||
if has_legacy_migrator_table:
|
||||
logging.info(
|
||||
"Alembic: applying legacy migrator compatibility fixes before stamp."
|
||||
)
|
||||
await async_connection.run_sync(_run_legacy_migrator_compatibility)
|
||||
else:
|
||||
logging.warning(
|
||||
"Alembic: existing users table without legacy schema_migrations; "
|
||||
"proceeding with explicit bootstrap override flag."
|
||||
)
|
||||
|
||||
logging.info(
|
||||
"Alembic: existing schema detected without alembic_version; stamping %s.",
|
||||
_BASELINE_REVISION,
|
||||
)
|
||||
await async_connection.run_sync(
|
||||
_run_stamp,
|
||||
alembic_config,
|
||||
_BASELINE_REVISION,
|
||||
)
|
||||
|
||||
logging.info("Alembic: running upgrade to head...")
|
||||
await async_connection.run_sync(_run_upgrade, alembic_config)
|
||||
|
||||
logging.info("Alembic: migrations applied successfully.")
|
||||
@@ -6,7 +6,6 @@ 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",
|
||||
@@ -17,7 +16,6 @@ __all__ = (
|
||||
"message_log_dal",
|
||||
"user_billing_dal",
|
||||
"ad_dal",
|
||||
"security_dal",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import logging
|
||||
from typing import Optional, List
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.future import select
|
||||
from sqlalchemy import delete
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from db.models import ActiveDiscount
|
||||
|
||||
|
||||
async def set_active_discount(
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
promo_code_id: int,
|
||||
discount_percentage: int,
|
||||
expires_at: datetime,
|
||||
) -> Optional[ActiveDiscount]:
|
||||
"""
|
||||
Set active discount for user.
|
||||
Returns None if user already has an active discount (enforce one-at-a-time rule).
|
||||
"""
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
|
||||
existing = await get_active_discount(session, user_id, include_expired=True)
|
||||
if existing and existing.expires_at > now_utc:
|
||||
logging.warning(
|
||||
f"User {user_id} already has active discount (promo_code_id: {existing.promo_code_id}). "
|
||||
f"Cannot activate new discount {promo_code_id}."
|
||||
)
|
||||
return None
|
||||
|
||||
if existing and existing.expires_at <= now_utc:
|
||||
await clear_active_discount_if_expired(session, user_id, now=now_utc)
|
||||
|
||||
# Create new active discount
|
||||
new_discount = ActiveDiscount(
|
||||
user_id=user_id,
|
||||
promo_code_id=promo_code_id,
|
||||
discount_percentage=discount_percentage,
|
||||
activated_at=now_utc,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
session.add(new_discount)
|
||||
await session.flush()
|
||||
await session.refresh(new_discount)
|
||||
logging.info(
|
||||
f"Active discount set for user {user_id}: promo_code_id={promo_code_id}, "
|
||||
f"discount={discount_percentage}%"
|
||||
)
|
||||
return new_discount
|
||||
|
||||
|
||||
async def get_active_discount(
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
include_expired: bool = False,
|
||||
) -> Optional[ActiveDiscount]:
|
||||
"""Get active discount for user if exists."""
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
stmt = select(ActiveDiscount).where(ActiveDiscount.user_id == user_id)
|
||||
if not include_expired:
|
||||
stmt = stmt.where(ActiveDiscount.expires_at > now_utc)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def clear_active_discount(
|
||||
session: AsyncSession,
|
||||
user_id: int
|
||||
) -> bool:
|
||||
"""
|
||||
Clear active discount for user.
|
||||
Returns True if discount was cleared, False if no discount was found.
|
||||
"""
|
||||
stmt = delete(ActiveDiscount).where(ActiveDiscount.user_id == user_id)
|
||||
result = await session.execute(stmt)
|
||||
await session.flush()
|
||||
cleared = result.rowcount > 0
|
||||
if cleared:
|
||||
logging.info(f"Active discount cleared for user {user_id}")
|
||||
return cleared
|
||||
|
||||
|
||||
async def clear_active_discount_if_expired(
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
now: Optional[datetime] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Clear active discount for user only when it has already expired.
|
||||
"""
|
||||
now_utc = now or datetime.now(timezone.utc)
|
||||
stmt = delete(ActiveDiscount).where(
|
||||
ActiveDiscount.user_id == user_id,
|
||||
ActiveDiscount.expires_at <= now_utc,
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
await session.flush()
|
||||
cleared = result.rowcount > 0
|
||||
if cleared:
|
||||
logging.info("Expired active discount cleared for user %s", user_id)
|
||||
return cleared
|
||||
|
||||
|
||||
async def clear_active_discount_if_matches(
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
promo_code_id: Optional[int] = None,
|
||||
expires_at_lte: Optional[datetime] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Clear active discount for user only when additional constraints match.
|
||||
"""
|
||||
conditions = [ActiveDiscount.user_id == user_id]
|
||||
if promo_code_id is not None:
|
||||
conditions.append(ActiveDiscount.promo_code_id == promo_code_id)
|
||||
if expires_at_lte is not None:
|
||||
conditions.append(ActiveDiscount.expires_at <= expires_at_lte)
|
||||
|
||||
stmt = delete(ActiveDiscount).where(*conditions)
|
||||
result = await session.execute(stmt)
|
||||
await session.flush()
|
||||
cleared = result.rowcount > 0
|
||||
if cleared:
|
||||
logging.info(
|
||||
"Active discount cleared for user %s by constrained cleanup.",
|
||||
user_id,
|
||||
)
|
||||
return cleared
|
||||
|
||||
|
||||
async def get_expired_active_discounts(
|
||||
session: AsyncSession,
|
||||
now: Optional[datetime] = None,
|
||||
limit: int = 100,
|
||||
) -> List[ActiveDiscount]:
|
||||
"""Get expired active discount reservations for cleanup/notifications."""
|
||||
now_utc = now or datetime.now(timezone.utc)
|
||||
stmt = (
|
||||
select(ActiveDiscount)
|
||||
.where(ActiveDiscount.expires_at <= now_utc)
|
||||
.order_by(ActiveDiscount.expires_at.asc())
|
||||
.limit(limit)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def clear_active_discounts_by_promo_code(
|
||||
session: AsyncSession,
|
||||
promo_code_id: int
|
||||
) -> int:
|
||||
"""
|
||||
Clear all active discounts associated with a specific promo code.
|
||||
Returns the number of discounts cleared.
|
||||
"""
|
||||
stmt = delete(ActiveDiscount).where(ActiveDiscount.promo_code_id == promo_code_id)
|
||||
result = await session.execute(stmt)
|
||||
await session.flush()
|
||||
count = result.rowcount
|
||||
if count > 0:
|
||||
logging.info(f"Cleared {count} active discount(s) for promo_code_id={promo_code_id}")
|
||||
return count
|
||||
@@ -22,16 +22,23 @@ async def create_message_log(session: AsyncSession,
|
||||
return None
|
||||
|
||||
|
||||
async def get_all_message_logs(session: AsyncSession, limit: int,
|
||||
offset: int) -> List[MessageLog]:
|
||||
stmt = select(MessageLog).order_by(
|
||||
MessageLog.timestamp.desc()).limit(limit).offset(offset)
|
||||
async def get_all_message_logs(session: AsyncSession,
|
||||
limit: int,
|
||||
offset: int,
|
||||
hide_admin_events: bool = False) -> List[MessageLog]:
|
||||
stmt = select(MessageLog)
|
||||
if hide_admin_events:
|
||||
stmt = stmt.where(MessageLog.is_admin_event.is_(False))
|
||||
stmt = stmt.order_by(MessageLog.timestamp.desc()).limit(limit).offset(offset)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def count_all_message_logs(session: AsyncSession) -> int:
|
||||
async def count_all_message_logs(session: AsyncSession,
|
||||
hide_admin_events: bool = False) -> int:
|
||||
stmt = select(func.count()).select_from(MessageLog)
|
||||
if hide_admin_events:
|
||||
stmt = stmt.where(MessageLog.is_admin_event.is_(False))
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
+141
-2
@@ -5,7 +5,7 @@ from sqlalchemy.future import select
|
||||
from sqlalchemy import update, func, and_
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from db.models import Payment, User
|
||||
from db.models import Payment
|
||||
|
||||
|
||||
async def create_payment_record(session: AsyncSession,
|
||||
@@ -176,10 +176,149 @@ async def update_provider_payment_and_status(
|
||||
return payment
|
||||
|
||||
|
||||
async def mark_provider_payment_succeeded_once(
|
||||
session: AsyncSession,
|
||||
payment_db_id: int,
|
||||
provider_payment_id: str) -> bool:
|
||||
"""Atomically mark payment as succeeded only once.
|
||||
|
||||
Returns True only for the first successful transition to "succeeded".
|
||||
Returns False when payment is missing or already succeeded.
|
||||
"""
|
||||
stmt = (
|
||||
update(Payment)
|
||||
.where(
|
||||
Payment.payment_id == payment_db_id,
|
||||
Payment.status != "succeeded",
|
||||
)
|
||||
.values(
|
||||
status="succeeded",
|
||||
provider_payment_id=provider_payment_id,
|
||||
updated_at=func.now(),
|
||||
)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
updated = (result.rowcount or 0) > 0
|
||||
if updated:
|
||||
logging.info(
|
||||
"Payment record %s atomically marked as succeeded (provider id %s).",
|
||||
payment_db_id,
|
||||
provider_payment_id,
|
||||
)
|
||||
return updated
|
||||
|
||||
|
||||
async def mark_provider_payment_processing_once(
|
||||
session: AsyncSession,
|
||||
payment_db_id: int,
|
||||
provider_payment_id: str,
|
||||
expected_status_prefix: Optional[str] = None) -> bool:
|
||||
"""Atomically claim payment for processing exactly once.
|
||||
|
||||
Returns True only when status is changed from a non-terminal state to
|
||||
"processing". This prevents duplicate activation when concurrent
|
||||
webhooks arrive for the same payment.
|
||||
"""
|
||||
conditions = [
|
||||
Payment.payment_id == payment_db_id,
|
||||
Payment.status != "succeeded",
|
||||
Payment.status != "processing",
|
||||
]
|
||||
if expected_status_prefix:
|
||||
if expected_status_prefix == "pending":
|
||||
# YooKassa may keep authorized payments in waiting_for_capture
|
||||
# before reporting a final successful capture webhook.
|
||||
conditions.append(
|
||||
Payment.status.in_(("waiting_for_capture", "pending", "pending_yookassa"))
|
||||
)
|
||||
else:
|
||||
conditions.append(Payment.status.like(f"{expected_status_prefix}%"))
|
||||
|
||||
stmt = (
|
||||
update(Payment)
|
||||
.where(*conditions)
|
||||
.values(
|
||||
status="processing",
|
||||
provider_payment_id=provider_payment_id,
|
||||
updated_at=func.now(),
|
||||
)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
updated = (result.rowcount or 0) > 0
|
||||
if updated:
|
||||
logging.info(
|
||||
"Payment record %s atomically marked as processing (provider id %s).",
|
||||
payment_db_id,
|
||||
provider_payment_id,
|
||||
)
|
||||
return updated
|
||||
|
||||
|
||||
async def rollback_provider_payment_processing(
|
||||
session: AsyncSession,
|
||||
payment_db_id: int,
|
||||
rollback_status: str,
|
||||
provider_payment_id: Optional[str] = None) -> bool:
|
||||
"""Atomically rollback temporary processing status.
|
||||
|
||||
Returns True only if payment is currently in "processing" state.
|
||||
"""
|
||||
values = {
|
||||
"status": rollback_status,
|
||||
"updated_at": func.now(),
|
||||
}
|
||||
if provider_payment_id:
|
||||
values["provider_payment_id"] = provider_payment_id
|
||||
|
||||
stmt = (
|
||||
update(Payment)
|
||||
.where(
|
||||
Payment.payment_id == payment_db_id,
|
||||
Payment.status == "processing",
|
||||
)
|
||||
.values(**values)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
updated = (result.rowcount or 0) > 0
|
||||
if updated:
|
||||
logging.info(
|
||||
"Payment record %s rolled back from processing to %s.",
|
||||
payment_db_id,
|
||||
rollback_status,
|
||||
)
|
||||
return updated
|
||||
|
||||
|
||||
async def update_payment_discount_info(
|
||||
session: AsyncSession,
|
||||
payment_db_id: int,
|
||||
original_amount: Optional[float],
|
||||
discount_applied: Optional[float],
|
||||
promo_code_id: Optional[int]) -> Optional[Payment]:
|
||||
"""Update payment record with discount metadata."""
|
||||
payment = await get_payment_by_db_id(session, payment_db_id)
|
||||
if payment:
|
||||
payment.original_amount = original_amount
|
||||
payment.discount_applied = discount_applied
|
||||
payment.promo_code_id = promo_code_id
|
||||
payment.updated_at = func.now()
|
||||
await session.flush()
|
||||
await session.refresh(payment)
|
||||
logging.info(
|
||||
f"Payment record {payment.payment_id} updated with discount info: "
|
||||
f"original {original_amount}, discount {discount_applied}, promo {promo_code_id}"
|
||||
)
|
||||
else:
|
||||
logging.warning(
|
||||
f"Payment record with DB ID {payment_db_id} not found for discount info update."
|
||||
)
|
||||
return payment
|
||||
|
||||
|
||||
async def get_financial_statistics(session: AsyncSession) -> Dict[str, Any]:
|
||||
"""Get comprehensive financial statistics."""
|
||||
from datetime import datetime, timedelta
|
||||
from sqlalchemy import and_, text
|
||||
from sqlalchemy import and_
|
||||
|
||||
now = datetime.utcnow()
|
||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
@@ -43,6 +43,34 @@ async def get_active_promo_code_by_code_str(
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_active_bonus_promo_code_by_code_str(
|
||||
session: AsyncSession, code_str: str) -> Optional[PromoCode]:
|
||||
"""Get active bonus_days-type promo code by code string"""
|
||||
stmt = select(PromoCode).where(
|
||||
PromoCode.code == code_str.upper(),
|
||||
PromoCode.promo_type == "bonus_days",
|
||||
PromoCode.is_active == True,
|
||||
PromoCode.current_activations < PromoCode.max_activations,
|
||||
or_(PromoCode.valid_until == None, PromoCode.valid_until
|
||||
> datetime.now(timezone.utc)))
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_active_discount_promo_code_by_code_str(
|
||||
session: AsyncSession, code_str: str) -> Optional[PromoCode]:
|
||||
"""Get active discount-type promo code by code string"""
|
||||
stmt = select(PromoCode).where(
|
||||
PromoCode.code == code_str.upper(),
|
||||
PromoCode.promo_type == "discount",
|
||||
PromoCode.is_active == True,
|
||||
PromoCode.current_activations < PromoCode.max_activations,
|
||||
or_(PromoCode.valid_until == None, PromoCode.valid_until
|
||||
> datetime.now(timezone.utc)))
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_all_active_promo_codes(session: AsyncSession,
|
||||
limit: int = 20,
|
||||
offset: int = 0) -> List[PromoCode]:
|
||||
@@ -104,36 +132,78 @@ async def update_promo_code(session: AsyncSession, promo_id: int,
|
||||
|
||||
|
||||
async def delete_promo_code(session: AsyncSession, promo_id: int) -> Optional[PromoCode]:
|
||||
from db.dal import active_discount_dal
|
||||
|
||||
promo = await get_promo_code_by_id(session, promo_id)
|
||||
if not promo:
|
||||
return None
|
||||
# First, delete related activations due to foreign key constraint
|
||||
|
||||
# 1. Clear all active discounts referencing this promo code
|
||||
await active_discount_dal.clear_active_discounts_by_promo_code(session, promo_id)
|
||||
|
||||
# 2. Set promo_code_id to NULL in payments table to avoid FK violation
|
||||
stmt = update(Payment).where(Payment.promo_code_id == promo_id).values(promo_code_id=None)
|
||||
await session.execute(stmt)
|
||||
|
||||
# 3. Delete related activations
|
||||
activations = await get_promo_activations_by_code_id(session, promo_id)
|
||||
for activation in activations:
|
||||
await session.delete(activation)
|
||||
|
||||
|
||||
# 4. Delete the promo code itself
|
||||
await session.delete(promo)
|
||||
await session.flush()
|
||||
|
||||
logging.info(f"Promo code '{promo.code}' (ID: {promo_id}) deleted successfully")
|
||||
return promo
|
||||
|
||||
|
||||
async def increment_promo_code_usage(
|
||||
session: AsyncSession, promo_code_id: int) -> Optional[PromoCode]:
|
||||
session: AsyncSession,
|
||||
promo_code_id: int,
|
||||
allow_overflow: bool = False) -> Optional[PromoCode]:
|
||||
conditions = [PromoCode.promo_code_id == promo_code_id]
|
||||
if not allow_overflow:
|
||||
conditions.append(PromoCode.current_activations < PromoCode.max_activations)
|
||||
|
||||
stmt = (
|
||||
update(PromoCode)
|
||||
.where(*conditions)
|
||||
.values(current_activations=PromoCode.current_activations + 1)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
if result.rowcount and result.rowcount > 0:
|
||||
await session.flush()
|
||||
return await get_promo_code_by_id(session, promo_code_id)
|
||||
|
||||
promo = await get_promo_code_by_id(session, promo_code_id)
|
||||
if promo:
|
||||
if promo.current_activations < promo.max_activations:
|
||||
promo.current_activations += 1
|
||||
await session.flush()
|
||||
await session.refresh(promo)
|
||||
return promo
|
||||
if allow_overflow:
|
||||
logging.warning(
|
||||
f"Failed to increment promo usage for promo {promo.code} (ID: {promo_code_id})."
|
||||
)
|
||||
else:
|
||||
logging.warning(
|
||||
f"Promo code {promo.code} (ID: {promo_code_id}) already reached max activations."
|
||||
)
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
async def decrement_promo_code_usage(
|
||||
session: AsyncSession, promo_code_id: int) -> bool:
|
||||
stmt = (
|
||||
update(PromoCode)
|
||||
.where(
|
||||
PromoCode.promo_code_id == promo_code_id,
|
||||
PromoCode.current_activations > 0,
|
||||
)
|
||||
.values(current_activations=PromoCode.current_activations - 1)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
await session.flush()
|
||||
return bool(result.rowcount and result.rowcount > 0)
|
||||
|
||||
|
||||
async def get_user_activation_for_promo(
|
||||
session: AsyncSession, promo_code_id: int,
|
||||
user_id: int) -> Optional[PromoCodeActivation]:
|
||||
@@ -189,3 +259,22 @@ async def record_promo_activation(
|
||||
f"Promo code {promo_code_id} activated by user {user_id}. Activation ID: {new_activation.activation_id}"
|
||||
)
|
||||
return new_activation
|
||||
|
||||
|
||||
async def set_activation_payment_id(
|
||||
session: AsyncSession,
|
||||
promo_code_id: int,
|
||||
user_id: int,
|
||||
payment_id: int) -> bool:
|
||||
stmt = (
|
||||
update(PromoCodeActivation)
|
||||
.where(
|
||||
PromoCodeActivation.promo_code_id == promo_code_id,
|
||||
PromoCodeActivation.user_id == user_id,
|
||||
PromoCodeActivation.payment_id == None,
|
||||
)
|
||||
.values(payment_id=payment_id)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
await session.flush()
|
||||
return bool(result.rowcount and result.rowcount > 0)
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
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]) -> None:
|
||||
current_panel_subscription_uuid: Optional[str]):
|
||||
stmt = (update(Subscription).where(
|
||||
Subscription.panel_user_uuid == panel_user_uuid,
|
||||
Subscription.is_active == True,
|
||||
|
||||
+27
-462
@@ -5,9 +5,8 @@ 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_, or_, desc
|
||||
from sqlalchemy.orm import aliased
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from sqlalchemy import update, delete, func, and_, or_
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
|
||||
from ..models import (
|
||||
@@ -24,11 +23,6 @@ from ..models import (
|
||||
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:
|
||||
@@ -55,14 +49,6 @@ async def generate_unique_referral_code(session: AsyncSession) -> str:
|
||||
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.
|
||||
@@ -95,23 +81,6 @@ 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]:
|
||||
@@ -165,304 +134,6 @@ 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:
|
||||
@@ -629,22 +300,23 @@ async def get_user_ids_without_active_subscription(session: AsyncSession) -> Lis
|
||||
from datetime import datetime, timezone
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
active_subs = aliased(Subscription)
|
||||
# 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()
|
||||
|
||||
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,
|
||||
active_subs.user_id.is_(None),
|
||||
~User.user_id.in_(active_subs_subq),
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -672,13 +344,25 @@ async def delete_user_and_relations(session: AsyncSession, user_id: int) -> bool
|
||||
or_(MessageLog.user_id == user_id, MessageLog.target_user_id == user_id)
|
||||
)
|
||||
)
|
||||
await session.execute(delete(Payment).where(Payment.user_id == user_id))
|
||||
|
||||
user_payment_ids_subquery = select(Payment.payment_id).where(Payment.user_id == user_id)
|
||||
|
||||
# Keep activations of other users intact if they reference this user's payments
|
||||
await session.execute(
|
||||
delete(Subscription).where(Subscription.user_id == user_id)
|
||||
update(PromoCodeActivation)
|
||||
.where(
|
||||
PromoCodeActivation.payment_id.in_(user_payment_ids_subquery),
|
||||
PromoCodeActivation.user_id != user_id,
|
||||
)
|
||||
.values(payment_id=None)
|
||||
)
|
||||
await session.execute(
|
||||
delete(PromoCodeActivation).where(PromoCodeActivation.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(UserPaymentMethod).where(UserPaymentMethod.user_id == user_id)
|
||||
)
|
||||
@@ -688,122 +372,3 @@ async def delete_user_and_relations(session: AsyncSession, user_id: int) -> bool
|
||||
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]
|
||||
|
||||
+21
-12
@@ -1,27 +1,40 @@
|
||||
import logging
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from config.settings import Settings
|
||||
from .models import Base
|
||||
from .migrator import run_database_migrations
|
||||
from .alembic_runner import run_alembic_migrations
|
||||
|
||||
async_engine = None
|
||||
|
||||
|
||||
def _mask_db_url(url: str) -> str:
|
||||
try:
|
||||
parsed = urlsplit(url)
|
||||
if parsed.username is None:
|
||||
return url
|
||||
username = parsed.username
|
||||
host = parsed.hostname or ""
|
||||
port = f":{parsed.port}" if parsed.port else ""
|
||||
netloc = f"{username}:***@{host}{port}"
|
||||
return urlunsplit((parsed.scheme, netloc, parsed.path, parsed.query, parsed.fragment))
|
||||
except Exception:
|
||||
return "<masked>"
|
||||
|
||||
|
||||
def init_db_connection(settings: Settings) -> sessionmaker:
|
||||
global async_engine
|
||||
|
||||
if async_engine is None:
|
||||
masked_url = _mask_db_url(settings.DATABASE_URL)
|
||||
logging.info(
|
||||
f"Attempting to create SQLAlchemy engine with URL: {settings.DATABASE_URL}"
|
||||
f"Attempting to create SQLAlchemy engine with URL: {masked_url}"
|
||||
)
|
||||
async_engine = create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
echo=False,
|
||||
pool_pre_ping=True,
|
||||
pool_size=20,
|
||||
max_overflow=10,
|
||||
)
|
||||
|
||||
local_async_session_factory = async_sessionmaker(
|
||||
@@ -32,7 +45,7 @@ def init_db_connection(settings: Settings) -> sessionmaker:
|
||||
autoflush=False,
|
||||
)
|
||||
logging.info(
|
||||
f"SQLAlchemy Async Engine and SessionFactory configured for PostgreSQL."
|
||||
"SQLAlchemy Async Engine and SessionFactory configured for PostgreSQL."
|
||||
)
|
||||
return local_async_session_factory
|
||||
|
||||
@@ -63,12 +76,8 @@ async def init_db(settings: Settings, session_factory: sessionmaker):
|
||||
"async_engine is not initialized. Call init_db_connection and get session_factory first."
|
||||
)
|
||||
|
||||
async with async_engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
await conn.run_sync(run_database_migrations)
|
||||
logging.info(
|
||||
"PostgreSQL database initialized/checked successfully using SQLAlchemy."
|
||||
)
|
||||
await run_alembic_migrations(settings, async_engine)
|
||||
logging.info("PostgreSQL database migrations checked/applied via Alembic.")
|
||||
|
||||
async with session_factory() as session:
|
||||
from .dal.panel_sync_dal import get_panel_sync_status, update_panel_sync_status
|
||||
|
||||
-399
@@ -1,399 +0,0 @@
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, List, Set
|
||||
|
||||
from sqlalchemy import inspect, text
|
||||
from sqlalchemy.engine import Connection
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Migration:
|
||||
id: str
|
||||
description: str
|
||||
upgrade: Callable[[Connection], None]
|
||||
|
||||
|
||||
def _ensure_migrations_table(connection: Connection) -> None:
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
id VARCHAR(255) PRIMARY KEY,
|
||||
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _migration_0001_add_channel_subscription_fields(connection: Connection) -> None:
|
||||
inspector = inspect(connection)
|
||||
columns: Set[str] = {col["name"] for col in inspector.get_columns("users")}
|
||||
statements: List[str] = []
|
||||
|
||||
if "channel_subscription_verified" not in columns:
|
||||
statements.append(
|
||||
"ALTER TABLE users ADD COLUMN channel_subscription_verified BOOLEAN"
|
||||
)
|
||||
if "channel_subscription_checked_at" not in columns:
|
||||
statements.append(
|
||||
"ALTER TABLE users ADD COLUMN channel_subscription_checked_at TIMESTAMPTZ"
|
||||
)
|
||||
if "channel_subscription_verified_for" not in columns:
|
||||
statements.append(
|
||||
"ALTER TABLE users ADD COLUMN channel_subscription_verified_for BIGINT"
|
||||
)
|
||||
|
||||
for stmt in statements:
|
||||
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_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,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def run_database_migrations(connection: Connection) -> None:
|
||||
"""
|
||||
Apply pending migrations sequentially. Already applied revisions are skipped.
|
||||
"""
|
||||
_ensure_migrations_table(connection)
|
||||
|
||||
applied_revisions: Set[str] = {
|
||||
row[0]
|
||||
for row in connection.execute(
|
||||
text("SELECT id FROM schema_migrations")
|
||||
)
|
||||
}
|
||||
|
||||
for migration in MIGRATIONS:
|
||||
if migration.id in applied_revisions:
|
||||
continue
|
||||
|
||||
logging.info(
|
||||
"Migrator: applying %s – %s", migration.id, migration.description
|
||||
)
|
||||
try:
|
||||
with connection.begin_nested():
|
||||
migration.upgrade(connection)
|
||||
connection.execute(
|
||||
text(
|
||||
"INSERT INTO schema_migrations (id) VALUES (:revision)"
|
||||
),
|
||||
{"revision": migration.id},
|
||||
)
|
||||
except Exception as exc:
|
||||
logging.error(
|
||||
"Migrator: failed to apply %s (%s)",
|
||||
migration.id,
|
||||
migration.description,
|
||||
exc_info=True,
|
||||
)
|
||||
raise exc
|
||||
else:
|
||||
logging.info("Migrator: migration %s applied successfully", migration.id)
|
||||
+40
-55
@@ -1,4 +1,4 @@
|
||||
from sqlalchemy import create_engine, Column, Integer, String, Boolean, DateTime, Float, ForeignKey, UniqueConstraint, Text, BigInteger, Index
|
||||
from sqlalchemy import create_engine, Column, Integer, String, Boolean, DateTime, Float, ForeignKey, UniqueConstraint, Text, BigInteger
|
||||
from sqlalchemy.orm import relationship, DeclarativeBase
|
||||
from sqlalchemy.ext.asyncio import AsyncAttrs
|
||||
from sqlalchemy.sql import func
|
||||
@@ -14,10 +14,6 @@ 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")
|
||||
@@ -29,7 +25,6 @@ class User(Base):
|
||||
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)
|
||||
@@ -61,10 +56,6 @@ 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,
|
||||
@@ -94,51 +85,8 @@ 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)
|
||||
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,
|
||||
@@ -152,7 +100,12 @@ class Payment(Base):
|
||||
provider_payment_id = Column(String, unique=True, nullable=True)
|
||||
provider = Column(String, nullable=False, default="yookassa", index=True)
|
||||
idempotence_key = Column(String, unique=True, nullable=True)
|
||||
amount = Column(Float, nullable=False)
|
||||
amount = Column(Float, nullable=False) # Final amount paid (after discount if any)
|
||||
|
||||
# Discount tracking fields
|
||||
original_amount = Column(Float, nullable=True) # Amount before discount
|
||||
discount_applied = Column(Float, nullable=True) # Discount amount (not percentage)
|
||||
|
||||
currency = Column(String, nullable=False)
|
||||
status = Column(String, nullable=False, index=True)
|
||||
description = Column(String, nullable=True)
|
||||
@@ -206,7 +159,17 @@ class PromoCode(Base):
|
||||
|
||||
promo_code_id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
code = Column(String, unique=True, nullable=False, index=True)
|
||||
bonus_days = Column(Integer, nullable=False)
|
||||
|
||||
# Type field to distinguish promo code types
|
||||
promo_type = Column(String, nullable=False, default="bonus_days", index=True)
|
||||
# Values: "bonus_days" or "discount"
|
||||
|
||||
# For bonus_days type: number of days to add to subscription
|
||||
bonus_days = Column(Integer, nullable=True)
|
||||
|
||||
# For discount type: percentage discount (1-100)
|
||||
discount_percentage = Column(Integer, nullable=True)
|
||||
|
||||
max_activations = Column(Integer, nullable=False)
|
||||
current_activations = Column(Integer, default=0)
|
||||
is_active = Column(Boolean, default=True)
|
||||
@@ -243,6 +206,28 @@ class PromoCodeActivation(Base):
|
||||
name='uq_promo_user_activation'), )
|
||||
|
||||
|
||||
class ActiveDiscount(Base):
|
||||
"""Tracks pending discount promo code reservations awaiting payment."""
|
||||
__tablename__ = "active_discounts"
|
||||
|
||||
user_id = Column(
|
||||
BigInteger,
|
||||
ForeignKey("users.user_id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
)
|
||||
promo_code_id = Column(
|
||||
Integer,
|
||||
ForeignKey("promo_codes.promo_code_id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
discount_percentage = Column(Integer, nullable=False)
|
||||
activated_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
expires_at = Column(DateTime(timezone=True), nullable=False)
|
||||
|
||||
promo_code = relationship("PromoCode")
|
||||
user = relationship("User")
|
||||
|
||||
|
||||
class MessageLog(Base):
|
||||
__tablename__ = "message_logs"
|
||||
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
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
|
||||
@@ -1,13 +1,12 @@
|
||||
services:
|
||||
remnawave-minishop:
|
||||
image: ghcr.io/3252a8/remnawave-minishop:${IMAGE_TAG:-latest}
|
||||
container_name: remnawave-minishop
|
||||
hostname: remnawave-minishop
|
||||
remnawave-tg-shop:
|
||||
image: kavore/remnawave-tg-shop:latest
|
||||
container_name: remnawave-tg-shop
|
||||
hostname: remnawave-tg-shop
|
||||
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:
|
||||
@@ -16,17 +15,17 @@ services:
|
||||
- ./locales:/app/locales
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- remnawave-minishop-db
|
||||
remnawave-minishop-db:
|
||||
- remnawave-tg-shop-db
|
||||
remnawave-tg-shop-db:
|
||||
image: postgres:17
|
||||
container_name: remnawave-minishop-db
|
||||
hostname: remnawave-minishop-db
|
||||
container_name: remnawave-tg-shop-db
|
||||
hostname: remnawave-tg-shop-db
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- TZ=UTC
|
||||
volumes:
|
||||
- remnawave-minishop-db-data:/var/lib/postgresql/data
|
||||
- remnawave-tg-shop-db-data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- remnawave-network
|
||||
restart: unless-stopped
|
||||
@@ -38,5 +37,5 @@ services:
|
||||
networks:
|
||||
remnawave-network: null
|
||||
volumes:
|
||||
remnawave-minishop-db-data:
|
||||
name: remnawave-minishop-db-data
|
||||
remnawave-tg-shop-db-data:
|
||||
name: remnawave-tg-shop-db-data
|
||||
|
||||
+14
-20
@@ -1,34 +1,28 @@
|
||||
services:
|
||||
remnawave-minishop:
|
||||
image: ghcr.io/3252a8/remnawave-minishop:${IMAGE_TAG:-latest}
|
||||
container_name: remnawave-minishop
|
||||
hostname: remnawave-minishop
|
||||
remnawave-tg-shop:
|
||||
image: kavore/remnawave-tg-shop:latest
|
||||
# build: .
|
||||
container_name: remnawave-tg-shop
|
||||
hostname: remnawave-tg-shop
|
||||
env_file:
|
||||
- .env
|
||||
networks:
|
||||
- remnawave-network
|
||||
volumes:
|
||||
- ./locales:/app/locales
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
remnawave-minishop-db:
|
||||
remnawave-tg-shop-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-minishop-db:
|
||||
remnawave-tg-shop-db:
|
||||
image: postgres:17
|
||||
container_name: remnawave-minishop-db
|
||||
hostname: remnawave-minishop-db
|
||||
container_name: remnawave-tg-shop-db
|
||||
hostname: remnawave-tg-shop-db
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- remnawave-minishop-db-data:/var/lib/postgresql/data
|
||||
- remnawave-tg-shop-db-data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- remnawave-network
|
||||
restart: unless-stopped
|
||||
@@ -43,5 +37,5 @@ networks:
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
remnawave-minishop-db-data:
|
||||
name: remnawave-minishop-db-data
|
||||
remnawave-tg-shop-db-data:
|
||||
name: remnawave-tg-shop-db-data
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
# Миграция с `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
|
||||
```
|
||||
+46
-46
@@ -5,26 +5,18 @@
|
||||
"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.",
|
||||
"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.",
|
||||
"main_menu_greeting": "Hi, {user_name}! 👋\nWhat would you like to do?",
|
||||
"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": "🎁 Invite friend",
|
||||
"menu_referral_inline": "🎁 Referrals",
|
||||
"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!",
|
||||
@@ -49,8 +41,6 @@
|
||||
"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",
|
||||
@@ -82,16 +72,15 @@
|
||||
"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 👇",
|
||||
"discount_promo_code_applied_success": "✅ Promo code <code>{code}</code> activated!\n\n💰 A {discount}% discount will be applied to your next purchase.\n\nSelect a plan for payment.",
|
||||
"discount_promo_already_active": "❌ You already have an active discount promo code (<code>{code}</code>, -{discount_pct}%). Use it first or wait until the payment is complete.",
|
||||
"discount_promo_expired_need_reactivate": "⏰ The discount hold period has ended{code_part}.\n\nPlease enter the promo code again to apply the discount.",
|
||||
"promo_code_not_found_or_not_discount": "❌ Promo code <code>{code}</code> not found or is invalid.",
|
||||
"active_discount_notice": "🎁 Active discount: <code>{code}</code> (-{discount_pct}%)\n💵 Price: <s>{original_price}{currency_symbol}</s> ➔ <b>{discounted_price}{currency_symbol}</b>\n💰 Savings: {discount_amount}{currency_symbol}",
|
||||
"error_applying_promo_bonus": "Failed to apply promo bonus. Please try again later or contact support.",
|
||||
"promo_input_cancelled_short": "Promo code entry cancelled.",
|
||||
"trial_feature_disabled": "Free trial is currently unavailable.",
|
||||
@@ -99,14 +88,13 @@
|
||||
"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 service - 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 VPN - 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}.",
|
||||
@@ -140,18 +128,6 @@
|
||||
"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",
|
||||
@@ -198,6 +174,7 @@
|
||||
"admin_promo_invalid_max_activations": "Max activations must be a positive number.",
|
||||
"admin_promo_invalid_validity_days": "Validity period (in days) must be a positive number.",
|
||||
"admin_promo_created_success": "✅ Promo code <code>{code}</code> created successfully!\nBonus: {bonus_days} days\nMax uses: {max_activations}\nValid until: {valid_until_str}.",
|
||||
"admin_promo_created_success_discount": "✅ Promo code <code>{code}</code> created successfully!\nDiscount: {discount_percentage}%\nMax uses: {max_activations}\nValid until: {valid_until_str}.",
|
||||
"admin_promo_set_validity_days": "⏰ Set validity (days)",
|
||||
"admin_back_to_panel": "⬅️ Back to panel",
|
||||
"admin_promo_unlimited": "♾️ Unlimited",
|
||||
@@ -232,16 +209,18 @@
|
||||
"csv_no": "No",
|
||||
"admin_promo_edit_select_field": "Select a field to edit:",
|
||||
"admin_promo_prompt_bonus_days": "Enter the new number of bonus days:",
|
||||
"admin_promo_prompt_discount_percentage": "Enter the new discount percentage (1-100):",
|
||||
"admin_promo_prompt_max_activations": "Enter the new maximum number of activations:",
|
||||
"admin_promo_prompt_validity_days": "Enter the new validity period in days (0 for indefinite):",
|
||||
"admin_promo_edit_success": "Promo code updated successfully.",
|
||||
"admin_promo_invalid_input": "Invalid input, please try again.",
|
||||
"admin_promo_edit_bonus_days": "🎁 Bonus Days",
|
||||
"admin_promo_edit_discount_percentage": "💰 Discount %",
|
||||
"admin_promo_edit_max_activations": "🔢 Max Activations",
|
||||
"admin_promo_edit_validity": "⏰ Validity",
|
||||
"admin_ban_user_prompt": "Enter user ID, @username, or email to ban:",
|
||||
"admin_ban_user_prompt": "Enter user ID or @username to ban:",
|
||||
"admin_user_already_banned": "User {user_id_or_username} is already banned.",
|
||||
"admin_unban_user_prompt": "Enter user ID, @username, or email to unban:",
|
||||
"admin_unban_user_prompt": "Enter user ID or @username to unban:",
|
||||
"admin_user_not_banned": "⚠️ User is not banned",
|
||||
"admin_banned_user_button_text": "{user_display} (ID: {user_id})",
|
||||
"prev_page_button": "⬅️ Prev.",
|
||||
@@ -258,7 +237,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, @username, or email to view logs:",
|
||||
"admin_prompt_for_user_id_or_username_logs": "Enter user ID or @username 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",
|
||||
@@ -269,16 +248,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 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.",
|
||||
"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.",
|
||||
"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.",
|
||||
"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, @username, or email to search:",
|
||||
"admin_user_management_prompt": "👤 User Management\n\nEnter user ID or @username to search:",
|
||||
"admin_user_subscription_info": "Subscription Information:",
|
||||
"admin_user_reset_trial_button": "🔄 Reset Trial",
|
||||
"admin_user_add_subscription_button": "➕ Add Days",
|
||||
@@ -337,6 +316,7 @@
|
||||
"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_promo_discount_activation": "💰 <b>Discount Promo Code Activated</b>\n\n👤 User: {user_display}\n🏷 Code: <code>{promo_code}</code>\n💵 Discount: <b>{discount_percentage}%</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}",
|
||||
"log_suspicious_promo": "⚠️ <b>Suspicious Promo Code Attempt</b>\n\n👤 User: {user_display}\n🆔 ID: <code>{user_id}</code>\n📝 Input: <pre>{suspicious_input}</pre>\n🕐 Time: {timestamp}",
|
||||
@@ -364,18 +344,22 @@
|
||||
"admin_bulk_promo_invalid_quantity": "❌ Quantity must be between 1 and 1000",
|
||||
"admin_bulk_promo_enter_validity_days": "⏰ Enter the number of validity days for promo codes (1-365):",
|
||||
"admin_bulk_promo_creating": "⏳ Creating {quantity} promo codes...",
|
||||
"admin_promo_step0_type": "Choose promo code type:",
|
||||
"admin_promo_type_bonus_days": "🎁 Bonus Days (subscription extension)",
|
||||
"admin_promo_type_discount": "💰 Purchase Discount (%)",
|
||||
"admin_promo_step1_code": "🎟 <b>Create Promo Code</b>\n\n<b>Step 1 of 4:</b> Promo Code\n\nEnter promo code (3-30 characters, letters and numbers only):",
|
||||
"admin_promo_step2_bonus_days": "🎟 <b>Create Promo Code</b>\n\n<b>Step 2 of 4:</b> Bonus Days\n\nCode: <b>{code}</b>\n\nEnter the number of bonus days (1-365):",
|
||||
"admin_promo_step2_discount_percentage": "🎟 <b>Create Promo Code</b>\n\n<b>Step 2 of 4:</b> Discount Percentage\n\nCode: <b>{code}</b>\n\nEnter the discount percentage for the promo code (1-100):",
|
||||
"admin_promo_step3_max_activations": "🎟 <b>Create Promo Code</b>\n\n<b>Step 3 of 4:</b> Max Activations\n\nCode: <b>{code}</b>\nBonus days: <b>{bonus_days}</b>\n\nEnter the maximum number of activations (1-10000):",
|
||||
"admin_promo_step3_max_activations_discount": "🎟 <b>Create Promo Code</b>\n\n<b>Step 3 of 4:</b> Max Activations\n\nCode: <b>{code}</b>\nDiscount: <b>{discount_percentage}%</b>\n\nEnter the maximum number of activations (1-10000):",
|
||||
"admin_promo_step4_validity": "🎟 <b>Create Promo Code</b>\n\n<b>Step 4 of 4:</b> Validity Period\n\nCode: <b>{code}</b>\nBonus days: <b>{bonus_days}</b>\nMax activations: <b>{max_activations}</b>\n\nChoose the validity period for the promo code:",
|
||||
"admin_promo_step4_validity_discount": "🎟 <b>Create Promo Code</b>\n\n<b>Step 4 of 4:</b> Validity Period\n\nCode: <b>{code}</b>\nDiscount: <b>{discount_percentage}%</b>\nMax activations: <b>{max_activations}</b>\n\nChoose the validity period for the promo code:",
|
||||
"admin_promo_code_already_exists": "❌ A promo code with this code already exists",
|
||||
"admin_promo_unlimited_validity": "♾️ Unlimited",
|
||||
"admin_promo_enter_validity_days": "⏰ Enter the number of validity days for the promo code (1-365):",
|
||||
"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>",
|
||||
@@ -404,7 +388,9 @@
|
||||
"admin_promo_management_title": "🎟 <b>Promo Code Management</b>\n\nSelect a promo code for detailed view:",
|
||||
"admin_promo_management_empty": "📭 No promo codes available",
|
||||
"admin_promo_card_title": "🎟 <b>Promo Code: {code}</b>",
|
||||
"admin_promo_card_type": "📌 Type: {type}",
|
||||
"admin_promo_card_bonus_days": "🎁 Bonus days: <b>{days}</b>",
|
||||
"admin_promo_card_discount_percentage": "💰 Discount: <b>{percentage}%</b>",
|
||||
"admin_promo_card_activations": "🔢 Activations: <b>{current}/{max}</b>",
|
||||
"admin_promo_card_validity": "⏰ Valid until: <b>{validity}</b>",
|
||||
"admin_promo_card_status": "📊 Status: <b>{status}</b>",
|
||||
@@ -459,10 +445,9 @@
|
||||
"admin_payments_pagination_info": "📊 Showing {shown} of {total} payments (page {current_page}/{total_pages})",
|
||||
"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>",
|
||||
"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>",
|
||||
"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:\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.",
|
||||
@@ -518,5 +503,20 @@
|
||||
"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.",
|
||||
"admin_bulk_promo_creation_cancelled": "Bulk promo creation cancelled.",
|
||||
"admin_direct_empty_message": "Cannot send an empty message.",
|
||||
"admin_logs_csv_export_failed": "❌ CSV export failed: {error}",
|
||||
"admin_logs_csv_no_data": "No data to export.",
|
||||
"admin_promo_creation_cancelled": "Promo creation cancelled.",
|
||||
"admin_promo_invalid_number": "❌ Invalid number. Enter a number greater than 0.",
|
||||
"admin_sync_errors": "\n🚫 Synchronization errors: {count}",
|
||||
"error_applying_promo_discount": "❌ Error applying promo discount. Please try again later.",
|
||||
"error_generating_referral_link": "Error generating referral link. Please try again later.",
|
||||
"error_occurred_processing_request": "An error occurred while processing your request. Please try again.",
|
||||
"inline_panel_stats_error": "❌ Failed to load panel statistics.",
|
||||
"inline_system_error": "Error loading statistics",
|
||||
"no_bonus_placeholder": "none",
|
||||
"payment_successful_with_promo_full": "✅ Payment successful!\nYour {months}-month subscription has been extended by {bonus_days} bonus days via promo code and is active until {end_date}.\n\nConnection key:\n<code>{config_link}</code>\n\nTo connect, open the link and follow the instructions 👇",
|
||||
"system_or_unknown_user": "System/unknown user",
|
||||
"free_kassa_order_full": "Order #{order_id} from {date}\n\n"
|
||||
}
|
||||
|
||||
+47
-46
@@ -5,26 +5,18 @@
|
||||
"channel_subscription_verify_button": "Проверить подписку",
|
||||
"channel_subscription_check_failed": "Не удалось проверить подписку. Попробуйте позже или обратитесь в поддержку.",
|
||||
"channel_subscription_verified_success": "✅ Подписка подтверждена! Можно продолжать.",
|
||||
"webapp_auth_success": "✅ Вход подтвержден. Вернитесь в Web App.",
|
||||
"webapp_auth_expired": "Ссылка авторизации устарела. Вернитесь в Web App и попробуйте еще раз.",
|
||||
"main_menu_greeting": "Привет, {user_name}! 👋\nПерейдите в \"Личный кабинет\", чтобы настроить подключение, посмотреть подписку или продлить доступ.",
|
||||
"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": "🎁 Пригласить друга",
|
||||
"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": "Язык изменен!",
|
||||
@@ -49,8 +41,6 @@
|
||||
"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",
|
||||
@@ -82,31 +72,30 @@
|
||||
"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Чтобы подключиться, перейдите по ссылке и следуйте инструкции 👇",
|
||||
"discount_promo_code_applied_success": "✅ Промокод <code>{code}</code> активирован!\n\n💰 Скидка {discount}% будет применена к вашей следующей покупке.\n\nВыберите тариф для оплаты.",
|
||||
"discount_promo_already_active": "❌ У вас уже есть активированный промокод на скидку (<code>{code}</code>, -{discount_pct}%). Используйте его сначала или дождитесь окончания платежа.",
|
||||
"discount_promo_expired_need_reactivate": "⏰ Время действия скидки истекло{code_part}.\n\nВведите промокод снова, чтобы применить скидку.",
|
||||
"promo_code_not_found_or_not_discount": "❌ Промокод <code>{code}</code> не найден или недействителен.",
|
||||
"active_discount_notice": "🎁 Активна скидка: <code>{code}</code> (-{discount_pct}%)\n💵 Цена: <s>{original_price}{currency_symbol}</s> ➔ <b>{discounted_price}{currency_symbol}</b>\n💰 Экономия: {discount_amount}{currency_symbol}",
|
||||
"error_applying_promo_bonus": "Не удалось применить бонус по промокоду. Пожалуйста, попробуйте позже или свяжитесь с поддержкой.",
|
||||
"error_applying_promo_discount": "❌ Ошибка при активации промокода. Попробуйте позже.",
|
||||
"promo_input_cancelled_short": "Ввод промокода отменен.",
|
||||
"trial_feature_disabled": "Пробный период в данный момент недоступен.",
|
||||
"trial_already_had_subscription_or_trial": "Вы уже использовали пробный период или у вас была платная подписка. Пробный период доступен только один раз для новых пользователей.",
|
||||
"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": "🚀 Привет! Попробуй этот сервис - быстрый, надёжный и доступный!\n\n🎁 По моей ссылке тебе дадут бонусные дни к подписке!\n\n{referral_link}",
|
||||
"referral_friend_message": "🚀 Привет! Попробуй этот VPN - быстрый, надёжный и доступный!\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}.",
|
||||
@@ -140,18 +129,6 @@
|
||||
"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",
|
||||
@@ -198,11 +175,12 @@
|
||||
"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⏳ Ваша подписка на сервис истекает через 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Продлите её по кнопке ниже.",
|
||||
"admin_promo_created_success_discount": "✅ Промокод <code>{code}</code> успешно создан!\nСкидка: {discount_percentage}%\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Продлите её по кнопке ниже.",
|
||||
"autorenew_48h_charge_tomorrow_notice": "🔔 Напоминание\n\nЗавтра будет автоматическое списание за продление подписки. Если вы не хотите автопродление — отключите его кнопкой ниже.",
|
||||
"autorenew_confirm_enable": "🔄 Включить автопродление? Перед окончанием подписки будет выполняться автосписание.",
|
||||
"autorenew_confirm_disable": "🛑 Отключить автопродление? Автосписаний больше не будет.",
|
||||
@@ -241,16 +219,18 @@
|
||||
"csv_no": "Нет",
|
||||
"admin_promo_edit_select_field": "Выберите поле для редактирования:",
|
||||
"admin_promo_prompt_bonus_days": "Введите новое количество бонусных дней:",
|
||||
"admin_promo_prompt_discount_percentage": "Введите новый процент скидки (от 1 до 100):",
|
||||
"admin_promo_prompt_max_activations": "Введите новое максимальное количество активаций:",
|
||||
"admin_promo_prompt_validity_days": "Введите новый срок действия в днях (0 для бессрочного):",
|
||||
"admin_promo_edit_success": "Промокод успешно обновлен.",
|
||||
"admin_promo_invalid_input": "Неверный ввод, попробуйте еще раз.",
|
||||
"admin_promo_edit_bonus_days": "🎁 Бонусные дни",
|
||||
"admin_promo_edit_discount_percentage": "💰 Процент скидки",
|
||||
"admin_promo_edit_max_activations": "🔢 Макс. активации",
|
||||
"admin_promo_edit_validity": "⏰ Срок действия",
|
||||
"admin_ban_user_prompt": "Введите ID, @username или email пользователя для блокировки:",
|
||||
"admin_ban_user_prompt": "Введите ID или @username пользователя для блокировки:",
|
||||
"admin_user_already_banned": "Пользователь {user_id_or_username} уже заблокирован.",
|
||||
"admin_unban_user_prompt": "Введите ID, @username или email пользователя для разблокировки:",
|
||||
"admin_unban_user_prompt": "Введите ID или @username пользователя для разблокировки:",
|
||||
"admin_user_not_banned": "⚠️ Пользователь не заблокирован",
|
||||
"admin_banned_user_button_text": "{user_display} (ID: {user_id})",
|
||||
"prev_page_button": "⬅️ Пред.",
|
||||
@@ -267,7 +247,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 или email пользователя для просмотра его логов:",
|
||||
"admin_prompt_for_user_id_or_username_logs": "Введите ID или @username пользователя для просмотра его логов:",
|
||||
"admin_log_user_not_found": "Пользователь по запросу \"{input}\" не найден в базе данных бота.",
|
||||
"sync_started_simple": "🔄 Начинаю синхронизацию...",
|
||||
"sync_success_simple": "✅ Синхронизация успешно завершена",
|
||||
@@ -278,7 +258,7 @@
|
||||
"admin_broadcast_invalid_html": "❌ Некорректный HTML в сообщении. Пожалуйста, отправьте корректный HTML (поддерживаются теги Telegram) или уберите теги.",
|
||||
"error_displaying_logs_too_long": "Ошибка: логи слишком длинные для отображения одним сообщением. Попробуйте найти логи по конкретному пользователю.",
|
||||
"error_displaying_statistics": "Ошибка отображения статистики.",
|
||||
"admin_user_management_prompt": "👤 Управление пользователями\n\nВведите ID пользователя, @username или email для поиска:",
|
||||
"admin_user_management_prompt": "👤 Управление пользователями\n\nВведите ID пользователя или @username для поиска:",
|
||||
"admin_user_subscription_info": "Информация о подписке:",
|
||||
"admin_user_reset_trial_button": "🔄 Сбросить триал",
|
||||
"admin_user_add_subscription_button": "➕ Добавить дни",
|
||||
@@ -337,6 +317,7 @@
|
||||
"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_promo_discount_activation": "💰 <b>Активирован промокод на скидку</b>\n\n👤 Пользователь: {user_display}\n🏷 Код: <code>{promo_code}</code>\n💵 Скидка: <b>{discount_percentage}%</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}",
|
||||
"log_suspicious_promo": "⚠️ <b>Подозрительная попытка ввода промокода</b>\n\n👤 Пользователь: {user_display}\n🆔 ID: <code>{user_id}</code>\n📝 Ввод: <pre>{suspicious_input}</pre>\n🕐 Время: {timestamp}",
|
||||
@@ -364,18 +345,23 @@
|
||||
"admin_bulk_promo_invalid_quantity": "❌ Количество должно быть от 1 до 1000",
|
||||
"admin_bulk_promo_enter_validity_days": "⏰ Введите количество дней действия промокодов (1-365):",
|
||||
"admin_bulk_promo_creating": "⏳ Создаю {quantity} промокодов...",
|
||||
"admin_promo_step0_type": "Выберите тип промокода:",
|
||||
"admin_promo_type_bonus_days": "🎁 Бонусные дни (продление подписки)",
|
||||
"admin_promo_type_discount": "💰 Скидка на покупку (%)",
|
||||
"admin_promo_step1_code": "🎟 <b>Создание промокода</b>\n\n<b>Шаг 1 из 4:</b> Код промокода\n\nВведите код промокода (3-30 символов, только буквы и цифры):",
|
||||
"admin_promo_step2_bonus_days": "🎟 <b>Создание промокода</b>\n\n<b>Шаг 2 из 4:</b> Бонусные дни\n\nКод: <b>{code}</b>\n\nВведите количество бонусных дней (1-365):",
|
||||
"admin_promo_step2_discount_percentage": "🎟 <b>Создание промокода</b>\n\n<b>Шаг 2 из 4:</b> Процент скидки\n\nКод: <b>{code}</b>\n\nВведите процент скидки для промокода <code>{code}</code> (от 1 до 100):",
|
||||
"admin_promo_invalid_discount_percentage": "❌ Процент скидки должен быть от 1 до 100.",
|
||||
"admin_promo_step3_max_activations": "🎟 <b>Создание промокода</b>\n\n<b>Шаг 3 из 4:</b> Максимальные активации\n\nКод: <b>{code}</b>\nБонусные дни: <b>{bonus_days}</b>\n\nВведите максимальное количество активаций (1-10000):",
|
||||
"admin_promo_step3_max_activations_discount": "🎟 <b>Создание промокода</b>\n\n<b>Шаг 3 из 4:</b> Максимальные активации\n\nКод: <b>{code}</b>\nСкидка: <b>{discount_percentage}%</b>\n\nВведите максимальное количество активаций (1-10000):",
|
||||
"admin_promo_step4_validity": "🎟 <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Выберите срок действия промокода:",
|
||||
"admin_promo_step4_validity_discount": "🎟 <b>Создание промокода</b>\n\n<b>Шаг 4 из 4:</b> Срок действия\n\nКод: <b>{code}</b>\nСкидка: <b>{discount_percentage}%</b>\nМакс. активации: <b>{max_activations}</b>\n\nВыберите срок действия промокода:",
|
||||
"admin_promo_code_already_exists": "❌ Промокод с таким кодом уже существует",
|
||||
"admin_promo_unlimited_validity": "♾️ Неограниченно",
|
||||
"admin_promo_enter_validity_days": "⏰ Введите количество дней действия промокода (1-365):",
|
||||
"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>",
|
||||
@@ -404,7 +390,9 @@
|
||||
"admin_promo_management_title": "🎟 <b>Управление промокодами</b>\n\nВыберите промокод для детального просмотра:",
|
||||
"admin_promo_management_empty": "📭 Промокоды отсутствуют",
|
||||
"admin_promo_card_title": "🎟 <b>Промокод: {code}</b>",
|
||||
"admin_promo_card_type": "📌 Тип: {type}",
|
||||
"admin_promo_card_bonus_days": "🎁 Бонусные дни: <b>{days}</b>",
|
||||
"admin_promo_card_discount_percentage": "💰 Скидка: <b>{percentage}%</b>",
|
||||
"admin_promo_card_activations": "🔢 Активации: <b>{current}/{max}</b>",
|
||||
"admin_promo_card_validity": "⏰ Действует до: <b>{validity}</b>",
|
||||
"admin_promo_card_status": "📊 Статус: <b>{status}</b>",
|
||||
@@ -459,10 +447,9 @@
|
||||
"admin_payments_pagination_info": "📊 Показано {shown} из {total} платежей (стр. {current_page}/{total_pages})",
|
||||
"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>",
|
||||
"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>",
|
||||
"my_traffic_details": "🔐 <b>Мой трафик</b>\n\n⏰ Статус: <b>{status}</b>\n📅 Действует до: <b>{end_date}</b>\n\n🔗 Ссылка на конфигурацию:\n<code>{config_link}</code>\n\n📊 Трафик:\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": "Настройки автопродления обновлены.",
|
||||
@@ -518,5 +505,19 @@
|
||||
"admin_ads_delete_confirm": "Вы уверены, что хотите удалить кампанию #{id}? Это действие необратимо.",
|
||||
"admin_ads_deleted_success": "Кампания удалена.",
|
||||
"admin_ads_not_found": "Кампания не найдена.",
|
||||
"admin_bulk_promo_creation_cancelled": "Массовое создание промокодов отменено.",
|
||||
"admin_direct_empty_message": "Нельзя отправить пустое сообщение.",
|
||||
"admin_logs_csv_export_failed": "❌ Ошибка экспорта CSV: {error}",
|
||||
"admin_logs_csv_no_data": "Нет данных для экспорта.",
|
||||
"admin_promo_creation_cancelled": "Создание промокода отменено.",
|
||||
"admin_promo_invalid_number": "❌ Некорректное число. Введите число больше 0.",
|
||||
"admin_sync_errors": "\n🚫 Ошибок синхронизации: {count}",
|
||||
"error_generating_referral_link": "Ошибка генерации реферальной ссылки. Попробуйте позже.",
|
||||
"error_occurred_processing_request": "Произошла ошибка при обработке запроса. Попробуйте снова.",
|
||||
"inline_panel_stats_error": "❌ Не удалось получить статистику панели.",
|
||||
"inline_system_error": "Ошибка загрузки статистики",
|
||||
"no_bonus_placeholder": "нет",
|
||||
"payment_successful_with_promo_full": "✅ Оплата прошла успешно!\nВаша подписка на {months} мес. продлена на {bonus_days} бонусных дней по промокоду и активна до {end_date}.\n\nКлюч подключения:\n<code>{config_link}</code>\n\nЧтобы подключиться, перейдите по ссылке и следуйте инструкции 👇",
|
||||
"system_or_unknown_user": "Система/неизвестный пользователь",
|
||||
"free_kassa_order_full": "Заказ №{order_id} от {date}\n\n"
|
||||
}
|
||||
|
||||
@@ -11,10 +11,22 @@ from db.database_setup import init_db, init_db_connection
|
||||
|
||||
|
||||
def _resolve_log_level(value: str) -> int:
|
||||
return getattr(logging, value.upper(), logging.INFO)
|
||||
if not value:
|
||||
return logging.INFO
|
||||
if isinstance(value, str):
|
||||
normalized = value.strip()
|
||||
if not normalized:
|
||||
return logging.INFO
|
||||
if normalized.isdigit():
|
||||
return int(normalized)
|
||||
level = getattr(logging, normalized.upper(), None)
|
||||
if isinstance(level, int):
|
||||
return level
|
||||
return logging.INFO
|
||||
|
||||
|
||||
async def main():
|
||||
load_dotenv()
|
||||
settings = get_settings()
|
||||
|
||||
session_factory = init_db_connection(settings)
|
||||
|
||||
Generated
-1578
File diff suppressed because it is too large
Load Diff
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build:webapp:css": "tailwindcss -i ./bot/app/web/templates/subscription_webapp.tailwind.css -o ./bot/app/web/templates/subscription_webapp.css --minify",
|
||||
"build:webapp:js": "node ./scripts/build_subscription_webapp_js.mjs",
|
||||
"build:webapp": "npm run build:webapp:css && npm run build:webapp:js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/cli": "4.2.4",
|
||||
"esbuild": "^0.28.0",
|
||||
"tailwindcss": "4.2.4"
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -5,7 +5,7 @@ pydantic==2.12.5
|
||||
yookassa==3.9.0
|
||||
httpx>=0.27.0
|
||||
pydantic_settings==2.12.0
|
||||
email-validator==2.3.0
|
||||
sqlalchemy[asyncio]==2.0.45
|
||||
asyncpg==0.31.0
|
||||
alembic==1.18.3
|
||||
aiocryptopay==0.4.8
|
||||
|
||||
-159
@@ -1,159 +0,0 @@
|
||||
import re
|
||||
|
||||
html_path = "bot/app/web/templates/subscription_webapp.html"
|
||||
|
||||
with open(html_path, 'r', encoding='utf-8') as f:
|
||||
html = f.read()
|
||||
|
||||
replacements = {
|
||||
r'class="flex items-start justify-between gap-3"': 'class="panel-head"',
|
||||
r'class="text-lg font-extrabold leading-tight text-\[var\(--text-primary\)\]"': 'class="section-title"',
|
||||
r'class="mt-\[3px\] text-\[13px\] text-\[var\(--text-secondary\)\]"': 'class="section-caption"',
|
||||
r'class="min-w-0 font-\[family-name:var\(--font-mono\)\] text-\[11px\] font-bold text-\[var\(--text-muted\)\]"': 'class="metric-label"',
|
||||
r'class="min-w-0 text-right text-sm font-bold text-\[var\(--text-primary\)\]"': 'class="metric-value"',
|
||||
r'class="m-0 text-\[32px\] font-extrabold leading-\[1\.08\] text-\[var\(--text-primary\)\]"': 'class="main-value"',
|
||||
r'class="mt-1\.5 mb-0 text-sm leading-\[1\.45\] text-\[var\(--text-secondary\)\]"': 'class="main-caption"',
|
||||
r'class="login-text login-status hidden m-0 min-h-5 text-sm leading-\[1\.45\] text-\[var\(--text-secondary\)\]"': 'class="status-text hidden"',
|
||||
r'class="login-text login-status hidden"': 'class="status-text hidden"',
|
||||
r'class="actions grid min-w-0 grid-cols-\[minmax\(0,1fr\)_48px\] gap-2"': 'class="actions-row"',
|
||||
r'class="hidden flex flex-wrap justify-center gap-x-4 gap-y-1\.5"': 'class="legal-links hidden"',
|
||||
r'class="flex min-h-12 items-center justify-between gap-3"': 'class="app-header"',
|
||||
r'class="flex min-w-0 items-center gap-2\.5"': 'class="app-header-title"',
|
||||
r'class="flex min-w-0 items-center gap-2"': 'class="app-header-actions"',
|
||||
r'class="flex min-w-0 items-center justify-between gap-2\.5"': 'class="panel-head-sm"',
|
||||
r'class="flex min-w-0 items-center justify-center gap-3\.5"': 'class="login-head"',
|
||||
}
|
||||
|
||||
for pattern, repl in replacements.items():
|
||||
html = re.sub(pattern, repl, html)
|
||||
|
||||
with open(html_path, 'w', encoding='utf-8') as f:
|
||||
f.write(html)
|
||||
|
||||
js_path = "bot/app/web/templates/subscription_webapp.js"
|
||||
with open(js_path, 'r', encoding='utf-8') as f:
|
||||
js = f.read()
|
||||
|
||||
js_replacements = {
|
||||
r"panelHead: 'flex items-start justify-between gap-3',": "panelHead: 'panel-head',",
|
||||
r"flowCaption: 'mt-\[3px\] text-\[13px\] text-\[var\(--text-secondary\)\]',": "flowCaption: 'section-caption',",
|
||||
r"sectionTitle: 'text-lg font-extrabold leading-tight text-\[var\(--text-primary\)\]',": "sectionTitle: 'section-title',",
|
||||
r"metricLabel: 'min-w-0 font-\[family-name:var\(--font-mono\)\] text-\[11px\] font-bold text-\[var\(--text-muted\)\]',": "metricLabel: 'metric-label',",
|
||||
r"metricValue: 'min-w-0 text-right text-sm font-bold text-\[var\(--text-primary\)\]',": "metricValue: 'metric-value',",
|
||||
r"referralLinkRow: 'grid min-h-\[58px\] grid-cols-\[minmax\(0,1fr\)_48px\] items-center gap-2\.5 rounded-\[var\(--radius-md\)\] border border-\[var\(--border\)\] bg-\[rgba\(255,255,255,0\.02\)\] px-3 py-\[11px\]',": "referralLinkRow: 'referral-link-row',",
|
||||
r"referralLinkValue: 'mt-1 font-\[family-name:var\(--font-mono\)\] text-xs font-bold leading-\[1\.35\] text-\[var\(--text-primary\)\]',": "referralLinkValue: 'referral-link-value',",
|
||||
r"bonusRow: 'grid min-h-\[58px\] grid-cols-\[minmax\(0,0\.8fr\)_minmax\(0,1\.2fr\)\] items-center gap-2\.5 rounded-\[var\(--radius-md\)\] border border-\[var\(--border\)\] bg-\[rgba\(255,255,255,0\.02\)\] px-3 py-\[11px\]',": "bonusRow: 'bonus-row',",
|
||||
r"empty: 'rounded-\[var\(--radius-md\)\] border border-\[var\(--border\)\] bg-\[rgba\(255,255,255,0\.02\)\] p-\[13px\] text-sm leading-\[1\.45\] text-\[var\(--text-secondary\)\]',": "empty: 'empty-state',",
|
||||
r"planCard: 'flex min-h-16 w-full min-w-0 items-center justify-between gap-3 rounded-\[var\(--radius-md\)\] border border-\[var\(--border\)\] bg-\[rgba\(255,255,255,0\.02\)\] p-\[13px\] text-left text-\[var\(--text-primary\)\] transition-\[transform,border-color,background,box-shadow\] hover:-translate-y-0\.5 hover:border-\[color-mix\(in_srgb,var\(--accent\)_42%,var\(--border\)\)\] hover:bg-\[var\(--bg-card-hover\)\]',": "planCard: 'plan-card',",
|
||||
r"planCardActive: 'border-\[var\(--accent\)\] bg-\[color-mix\(in_srgb,var\(--accent\)_8%,transparent\)\] ring-1 ring-\[color-mix\(in_srgb,var\(--accent\)_58%,transparent\)\] shadow-\[0_12px_30px_rgba\(0,0,0,0\.2\)\]',": "planCardActive: 'plan-card-active',",
|
||||
r"planName: 'block text-\[15px\] font-extrabold leading-tight',": "planName: 'plan-name',",
|
||||
r"planMeta: 'mt-1 block font-\[family-name:var\(--font-mono\)\] text-\[11px\] font-bold leading-\[1\.3\] text-\[var\(--text-muted\)\]',": "planMeta: 'plan-meta',",
|
||||
r"planPrice: 'block max-w-\[48%\] flex-none text-right font-\[family-name:var\(--font-mono\)\] text-\[15px\] font-extrabold leading-tight text-\[var\(--accent\)\]',": "planPrice: 'plan-price',",
|
||||
r"notice: 'rounded-\[var\(--radius-md\)\] border border-\[var\(--border\)\] bg-\[rgba\(255,255,255,0\.02\)\] p-\[13px\] text-sm leading-\[1\.45\] text-\[var\(--text-secondary\)\]',": "notice: 'notice',",
|
||||
r"stepNum: 'block font-\[family-name:var\(--font-mono\)\] text-\[11px\] font-extrabold leading-\[1\.1\] text-current',": "stepNum: 'step-num',",
|
||||
r"stepName: 'mt-1 block text-xs font-extrabold leading-\[1\.15\] text-current'": "stepName: 'step-name'"
|
||||
}
|
||||
|
||||
for pattern, repl in js_replacements.items():
|
||||
js = re.sub(pattern, repl, js)
|
||||
|
||||
with open(js_path, 'w', encoding='utf-8') as f:
|
||||
f.write(js)
|
||||
|
||||
# CSS additions
|
||||
css_path = "bot/app/web/templates/subscription_webapp.tailwind.css"
|
||||
with open(css_path, 'r', encoding='utf-8') as f:
|
||||
css = f.read()
|
||||
|
||||
new_components = """
|
||||
.panel-head {
|
||||
@apply flex items-start justify-between gap-3;
|
||||
}
|
||||
.section-title {
|
||||
@apply text-lg font-extrabold leading-tight text-[var(--text-primary)];
|
||||
}
|
||||
.section-caption {
|
||||
@apply mt-[3px] text-[13px] text-[var(--text-secondary)];
|
||||
}
|
||||
.metric-label {
|
||||
@apply min-w-0 font-[family-name:var(--font-mono)] text-[11px] font-bold text-[var(--text-muted)];
|
||||
}
|
||||
.metric-value {
|
||||
@apply min-w-0 text-right text-sm font-bold text-[var(--text-primary)];
|
||||
}
|
||||
.referral-link-row {
|
||||
@apply grid min-h-[58px] grid-cols-[minmax(0,1fr)_48px] items-center gap-2.5 rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.02)] px-3 py-[11px];
|
||||
}
|
||||
.referral-link-value {
|
||||
@apply mt-1 font-[family-name:var(--font-mono)] text-xs font-bold leading-[1.35] text-[var(--text-primary)];
|
||||
}
|
||||
.bonus-row {
|
||||
@apply grid min-h-[58px] grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)] items-center gap-2.5 rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.02)] px-3 py-[11px];
|
||||
}
|
||||
.empty-state {
|
||||
@apply rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.02)] p-[13px] text-sm leading-[1.45] text-[var(--text-secondary)];
|
||||
}
|
||||
.plan-card {
|
||||
@apply flex min-h-16 w-full min-w-0 items-center justify-between gap-3 rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.02)] p-[13px] text-left text-[var(--text-primary)] transition-[transform,border-color,background,box-shadow] hover:-translate-y-0.5 hover:border-[color-mix(in_srgb,var(--accent)_42%,var(--border))] hover:bg-[var(--bg-card-hover)];
|
||||
transition-duration: 200ms;
|
||||
}
|
||||
.plan-card-active {
|
||||
@apply border-[var(--accent)] bg-[color-mix(in_srgb,var(--accent)_8%,transparent)] ring-1 ring-[color-mix(in_srgb,var(--accent)_58%,transparent)] shadow-[0_12px_30px_rgba(0,0,0,0.2)];
|
||||
}
|
||||
.plan-name {
|
||||
@apply block text-[15px] font-extrabold leading-tight;
|
||||
}
|
||||
.plan-meta {
|
||||
@apply mt-1 block font-[family-name:var(--font-mono)] text-[11px] font-bold leading-[1.3] text-[var(--text-muted)];
|
||||
}
|
||||
.plan-price {
|
||||
@apply block max-w-[48%] flex-none text-right font-[family-name:var(--font-mono)] text-[15px] font-extrabold leading-tight text-[var(--accent)];
|
||||
}
|
||||
.notice {
|
||||
@apply rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.02)] p-[13px] text-sm leading-[1.45] text-[var(--text-secondary)];
|
||||
}
|
||||
.step-num {
|
||||
@apply block font-[family-name:var(--font-mono)] text-[11px] font-extrabold leading-[1.1] text-current;
|
||||
}
|
||||
.step-name {
|
||||
@apply mt-1 block text-xs font-extrabold leading-[1.15] text-current;
|
||||
}
|
||||
.main-value {
|
||||
@apply m-0 text-[32px] font-extrabold leading-[1.08] text-[var(--text-primary)];
|
||||
}
|
||||
.main-caption {
|
||||
@apply mt-1.5 mb-0 text-sm leading-[1.45] text-[var(--text-secondary)];
|
||||
}
|
||||
.status-text {
|
||||
@apply m-0 min-h-5 text-sm leading-[1.45] text-[var(--text-secondary)];
|
||||
}
|
||||
.legal-links {
|
||||
@apply flex flex-wrap justify-center gap-x-4 gap-y-1.5;
|
||||
}
|
||||
.actions-row {
|
||||
@apply grid min-w-0 grid-cols-[minmax(0,1fr)_48px] gap-2;
|
||||
}
|
||||
.app-header {
|
||||
@apply flex min-h-12 items-center justify-between gap-3;
|
||||
}
|
||||
.app-header-title {
|
||||
@apply flex min-w-0 items-center gap-2.5;
|
||||
}
|
||||
.app-header-actions {
|
||||
@apply flex min-w-0 items-center gap-2;
|
||||
}
|
||||
.panel-head-sm {
|
||||
@apply flex min-w-0 items-center justify-between gap-2.5;
|
||||
}
|
||||
.login-head {
|
||||
@apply flex min-w-0 items-center justify-center gap-3.5;
|
||||
}
|
||||
"""
|
||||
|
||||
css_split = css.split('.btn {')
|
||||
new_css = css_split[0] + new_components + '\n .btn {' + css_split[1]
|
||||
|
||||
with open(css_path, 'w', encoding='utf-8') as f:
|
||||
f.write(new_css)
|
||||
|
||||
print("Done")
|
||||
@@ -1,8 +0,0 @@
|
||||
import re
|
||||
|
||||
with open('bot/app/web/templates/subscription_webapp.html', 'r', encoding='utf-8') as f:
|
||||
html = f.read()
|
||||
|
||||
long_classes = set(re.findall(r'class="([^"]{40,})"', html))
|
||||
for cls in long_classes:
|
||||
print(f"--- LONG CLASS ({len(cls)} chars) ---\n{cls}\n")
|
||||
@@ -1,90 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile, readdir, unlink, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { transform } from "esbuild";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(__dirname, "..");
|
||||
const sourcePath = path.join(
|
||||
repoRoot,
|
||||
"bot",
|
||||
"app",
|
||||
"web",
|
||||
"templates",
|
||||
"subscription_webapp.js",
|
||||
);
|
||||
|
||||
function normalizeLineEndings(value) {
|
||||
return value.replace(/\r\n/g, "\n");
|
||||
}
|
||||
|
||||
function stripMarkedBlock(source, startMarker, endMarker) {
|
||||
const start = source.indexOf(startMarker);
|
||||
if (start === -1) {
|
||||
return source;
|
||||
}
|
||||
const end = source.indexOf(endMarker, start);
|
||||
if (end === -1) {
|
||||
return source.slice(0, start);
|
||||
}
|
||||
return source.slice(0, start) + source.slice(end + endMarker.length);
|
||||
}
|
||||
|
||||
function stripFallbackI18n(source) {
|
||||
const fallbackStart = source.indexOf(" const FALLBACK_I18N = {");
|
||||
const i18nLine = " const I18N = readJsonScript('i18n') || (MOCK && MOCK.i18n) || FALLBACK_I18N;";
|
||||
const i18nLineIndex = source.indexOf(i18nLine);
|
||||
if (fallbackStart === -1 || i18nLineIndex === -1 || i18nLineIndex < fallbackStart) {
|
||||
return source;
|
||||
}
|
||||
|
||||
return (
|
||||
source.slice(0, fallbackStart)
|
||||
+ " const I18N = readJsonScript('i18n') || (MOCK && MOCK.i18n) || {};\n"
|
||||
+ source.slice(i18nLineIndex + i18nLine.length)
|
||||
);
|
||||
}
|
||||
|
||||
async function removeOldMinifiedAssets(assetDir, keepName) {
|
||||
const entries = await readdir(assetDir, { withFileTypes: true });
|
||||
await Promise.all(
|
||||
entries
|
||||
.filter(
|
||||
(entry) => entry.isFile() && /^subscription_webapp\.min\.[0-9a-f]{8}\.js$/.test(entry.name) && entry.name !== keepName,
|
||||
)
|
||||
.map((entry) => unlink(path.join(assetDir, entry.name))),
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const rawSource = await readFile(sourcePath, "utf8");
|
||||
const withoutMocks = stripMarkedBlock(
|
||||
normalizeLineEndings(rawSource),
|
||||
"/* WEBAPP_DEV_MOCK_START */",
|
||||
"/* WEBAPP_DEV_MOCK_END */",
|
||||
);
|
||||
const strippedSource = stripFallbackI18n(withoutMocks);
|
||||
const result = await transform(strippedSource, {
|
||||
charset: "utf8",
|
||||
legalComments: "none",
|
||||
loader: "js",
|
||||
minify: true,
|
||||
target: "es2018",
|
||||
});
|
||||
|
||||
const code = `${result.code.replace(/[ \t]+$/gm, "").trimEnd()}\n`;
|
||||
const hash = createHash("sha256").update(code, "utf8").digest("hex").slice(0, 8);
|
||||
const outputPath = path.join(
|
||||
path.dirname(sourcePath),
|
||||
`subscription_webapp.min.${hash}.js`,
|
||||
);
|
||||
|
||||
await removeOldMinifiedAssets(path.dirname(sourcePath), path.basename(outputPath));
|
||||
await writeFile(outputPath, code, "utf8");
|
||||
console.log(`Wrote ${path.relative(repoRoot, outputPath)} (${Buffer.byteLength(code, "utf8")} bytes)`);
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -1,303 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT=""
|
||||
|
||||
OLD_PREFIX="remnawave-tg-shop"
|
||||
NEW_PREFIX="remnawave-minishop"
|
||||
OLD_DB_VOLUME="${OLD_PREFIX}-db-data"
|
||||
NEW_DB_VOLUME="${NEW_PREFIX}-db-data"
|
||||
OLD_CADDY_VOLUMES=("${OLD_PREFIX}-caddy-data" "${OLD_PREFIX}-caddy-config")
|
||||
NEW_CADDY_VOLUMES=("${NEW_PREFIX}-caddy-data" "${NEW_PREFIX}-caddy-config")
|
||||
KNOWN_CONTAINERS=(
|
||||
"${OLD_PREFIX}"
|
||||
"${OLD_PREFIX}-db"
|
||||
"${OLD_PREFIX}-caddy"
|
||||
"${NEW_PREFIX}"
|
||||
"${NEW_PREFIX}-db"
|
||||
"${NEW_PREFIX}-caddy"
|
||||
)
|
||||
|
||||
log() {
|
||||
printf '%s\n' "$*"
|
||||
}
|
||||
|
||||
die() {
|
||||
printf 'Ошибка: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
require_cmd() {
|
||||
command -v "$1" >/dev/null 2>&1 || die "Не найдено обязательное средство \`$1\` в PATH."
|
||||
}
|
||||
|
||||
resolve_root() {
|
||||
if [[ -n "${PROJECT_ROOT:-}" ]]; then
|
||||
[[ -d "$PROJECT_ROOT" ]] || die "PROJECT_ROOT не существует: $PROJECT_ROOT"
|
||||
(cd -- "$PROJECT_ROOT" >/dev/null && pwd -P)
|
||||
return
|
||||
fi
|
||||
|
||||
local git_root
|
||||
if git_root="$(git rev-parse --show-toplevel 2>/dev/null)"; then
|
||||
printf '%s\n' "$git_root"
|
||||
return
|
||||
fi
|
||||
|
||||
pwd -P
|
||||
}
|
||||
|
||||
run() {
|
||||
log "+ $*"
|
||||
"$@"
|
||||
}
|
||||
|
||||
container_exists() {
|
||||
docker inspect "$1" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
container_running() {
|
||||
[[ "$(docker inspect -f '{{.State.Running}}' "$1" 2>/dev/null || true)" == "true" ]]
|
||||
}
|
||||
|
||||
stop_container() {
|
||||
local name="$1"
|
||||
|
||||
if ! container_exists "$name"; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
if container_running "$name"; then
|
||||
docker stop "$name" >/dev/null
|
||||
fi
|
||||
docker rm "$name" >/dev/null
|
||||
}
|
||||
|
||||
volume_exists() {
|
||||
docker volume inspect "$1" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
volume_is_empty() {
|
||||
docker run --rm -v "$1:/data" alpine sh -c 'test -z "$(find /data -mindepth 1 -print -quit)"' >/dev/null 2>&1
|
||||
}
|
||||
|
||||
copy_volume() {
|
||||
local source="$1"
|
||||
local target="$2"
|
||||
|
||||
if ! volume_exists "$source"; then
|
||||
log " - Пропускаю том \`$source\`: исходный том не найден."
|
||||
return 1
|
||||
fi
|
||||
|
||||
if volume_exists "$target" && ! volume_is_empty "$target"; then
|
||||
log " - Пропускаю том \`$target\`: он уже не пустой."
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! volume_exists "$target"; then
|
||||
die "Целевой том \`$target\` не создан Compose. Сначала нужно подготовить новый стек в режиме \`--no-start\`."
|
||||
fi
|
||||
|
||||
docker run --rm -v "$source:/from:ro" -v "$target:/to" alpine sh -c 'cd /from && cp -a . /to/'
|
||||
}
|
||||
|
||||
is_old_postgres_host() {
|
||||
grep -Eq "^[[:space:]]*POSTGRES_HOST[[:space:]]*=[[:space:]]*${OLD_PREFIX}-db[[:space:]]*(#.*)?$" "$ROOT/.env"
|
||||
}
|
||||
|
||||
is_new_postgres_host() {
|
||||
grep -Eq "^[[:space:]]*POSTGRES_HOST[[:space:]]*=[[:space:]]*${NEW_PREFIX}-db[[:space:]]*(#.*)?$" "$ROOT/.env"
|
||||
}
|
||||
|
||||
update_postgres_host() {
|
||||
if is_old_postgres_host; then
|
||||
sed -i.bak -E "s|^([[:space:]]*POSTGRES_HOST[[:space:]]*=[[:space:]]*)${OLD_PREFIX}-db([[:space:]]*(#.*)?)$|\\1${NEW_PREFIX}-db\\2|" "$ROOT/.env"
|
||||
log " - \`.env\` обновлён, резервная копия сохранена в \`.env.bak\`."
|
||||
elif is_new_postgres_host; then
|
||||
log " - \`POSTGRES_HOST\` уже указывает на новый контейнер, ничего менять не нужно."
|
||||
else
|
||||
log " - \`POSTGRES_HOST\` не похож на старую схему, пропускаю изменение."
|
||||
fi
|
||||
}
|
||||
|
||||
main() {
|
||||
require_cmd git
|
||||
require_cmd docker
|
||||
docker info >/dev/null
|
||||
|
||||
ROOT="$(resolve_root)"
|
||||
|
||||
local compose_file="${COMPOSE_FILE:-docker-compose.yml}"
|
||||
local target_branch="${TARGET_BRANCH:-main}"
|
||||
local git_remote="${GIT_REMOTE:-origin}"
|
||||
local new_origin_url="${NEW_ORIGIN_URL:-}"
|
||||
local assume_yes="${ASSUME_YES:-0}"
|
||||
local current_origin
|
||||
local current_branch
|
||||
local remote_ref
|
||||
local head_commit
|
||||
local compose_has_build=0
|
||||
local compose_has_caddy=0
|
||||
local -a compose_cmd
|
||||
local -a running_containers=()
|
||||
local -a summary=()
|
||||
local -a up_args
|
||||
local name
|
||||
local source
|
||||
local target
|
||||
local answer
|
||||
|
||||
if [[ $compose_file != /* ]]; then
|
||||
compose_file="$ROOT/$compose_file"
|
||||
fi
|
||||
[[ -f "$compose_file" ]] || die "Compose-файл не найден: $compose_file"
|
||||
[[ -e "$ROOT/.git" ]] || die "Скрипт нужно запускать из корня git-репозитория."
|
||||
[[ -f "$ROOT/.env" ]] || die "Не найден \`.env\` в корне репозитория."
|
||||
|
||||
if docker compose version >/dev/null 2>&1; then
|
||||
compose_cmd=(docker compose)
|
||||
elif command -v docker-compose >/dev/null 2>&1; then
|
||||
compose_cmd=(docker-compose)
|
||||
else
|
||||
die "Не найден ни \`docker compose\`, ни \`docker-compose\`."
|
||||
fi
|
||||
|
||||
if [[ -n "$(git -C "$ROOT" status --porcelain=v1)" ]]; then
|
||||
die "В рабочем дереве есть незакоммиченные изменения. Сначала сохраните их, чтобы миграция не затёрла чужие правки."
|
||||
fi
|
||||
|
||||
if grep -Eq '^[[:space:]]*build:[[:space:]]*' "$compose_file"; then
|
||||
compose_has_build=1
|
||||
fi
|
||||
if grep -Eq '^[[:space:]]*caddy:[[:space:]]*$' "$compose_file"; then
|
||||
compose_has_caddy=1
|
||||
fi
|
||||
|
||||
for name in "${KNOWN_CONTAINERS[@]}"; do
|
||||
if container_exists "$name"; then
|
||||
running_containers+=("$name")
|
||||
fi
|
||||
done
|
||||
|
||||
current_origin="$(git -C "$ROOT" remote get-url "$git_remote")"
|
||||
if [[ -n "$new_origin_url" && "$current_origin" != "$new_origin_url" ]]; then
|
||||
summary+=( "обновить $git_remote с \`$current_origin\` на \`$new_origin_url\`" )
|
||||
fi
|
||||
summary+=( "скачать ветку \`$target_branch\` из \`$git_remote\`" )
|
||||
if volume_exists "$OLD_DB_VOLUME"; then
|
||||
summary+=( "проверить том БД \`$OLD_DB_VOLUME\` и перенести в \`$NEW_DB_VOLUME\` при необходимости" )
|
||||
fi
|
||||
if ((compose_has_caddy)); then
|
||||
for i in 0 1; do
|
||||
source="${OLD_CADDY_VOLUMES[$i]}"
|
||||
target="${NEW_CADDY_VOLUMES[$i]}"
|
||||
if volume_exists "$source"; then
|
||||
summary+=( "проверить том \`$source\` и перенести в \`$target\` при необходимости" )
|
||||
fi
|
||||
done
|
||||
fi
|
||||
if is_old_postgres_host; then
|
||||
summary+=( "обновить \`POSTGRES_HOST\` в \`.env\`" )
|
||||
fi
|
||||
summary+=( "подготовить новый стек через Compose в режиме \`--no-start\`" )
|
||||
summary+=( "запустить compose-файл \`$(basename "$compose_file")\`" )
|
||||
|
||||
if [[ "$assume_yes" != "1" ]]; then
|
||||
if [[ ! -t 0 ]]; then
|
||||
die "Скрипт ожидает интерактивное подтверждение. Запустите с \`ASSUME_YES=1\` для неинтерактивного режима."
|
||||
fi
|
||||
log "План миграции:"
|
||||
for name in "${summary[@]}"; do
|
||||
log " - $name"
|
||||
done
|
||||
read -r -p "Продолжить? [y/N]: " answer
|
||||
case "$answer" in
|
||||
y|Y|yes|YES|Yes)
|
||||
;;
|
||||
*)
|
||||
die "Миграция отменена пользователем."
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
log "1. Останавливаю старый стек"
|
||||
if ((${#running_containers[@]})); then
|
||||
for name in "${running_containers[@]}"; do
|
||||
stop_container "$name"
|
||||
log " - контейнер \`$name\` остановлен/удалён"
|
||||
done
|
||||
else
|
||||
log " - запущенных контейнеров старой схемы не найдено"
|
||||
fi
|
||||
|
||||
if [[ -n "$new_origin_url" && "$current_origin" != "$new_origin_url" ]]; then
|
||||
log "2. Обновляю origin"
|
||||
run git -C "$ROOT" remote set-url "$git_remote" "$new_origin_url"
|
||||
else
|
||||
log "2. Origin уже актуален, пропускаю"
|
||||
fi
|
||||
|
||||
log "3. Обновляю git до ветки \`$target_branch\`"
|
||||
run git -C "$ROOT" fetch "$git_remote" "$target_branch"
|
||||
|
||||
current_branch="$(git -C "$ROOT" branch --show-current || true)"
|
||||
if [[ -z "$current_branch" ]]; then
|
||||
current_branch="$(git -C "$ROOT" rev-parse --abbrev-ref HEAD)"
|
||||
fi
|
||||
|
||||
if [[ "$current_branch" != "$target_branch" ]]; then
|
||||
if git -C "$ROOT" show-ref --verify --quiet "refs/heads/$target_branch"; then
|
||||
run git -C "$ROOT" switch "$target_branch"
|
||||
else
|
||||
run git -C "$ROOT" switch -c "$target_branch" --track "$git_remote/$target_branch"
|
||||
fi
|
||||
else
|
||||
log " - уже на ветке \`$target_branch\`"
|
||||
fi
|
||||
|
||||
remote_ref="$(git -C "$ROOT" rev-parse "$git_remote/$target_branch")"
|
||||
head_commit="$(git -C "$ROOT" rev-parse HEAD)"
|
||||
if [[ "$head_commit" != "$remote_ref" ]]; then
|
||||
run git -C "$ROOT" pull --ff-only "$git_remote" "$target_branch"
|
||||
else
|
||||
log " - локальная ветка уже совпадает с удалённой, \`git pull\` не нужен"
|
||||
fi
|
||||
|
||||
log "4. Обновляю \`.env\`"
|
||||
update_postgres_host
|
||||
|
||||
log "5. Подготавливаю новый стек через Compose"
|
||||
if ((compose_has_build)); then
|
||||
run "${compose_cmd[@]}" -f "$compose_file" up --no-start --build
|
||||
else
|
||||
run "${compose_cmd[@]}" -f "$compose_file" up --no-start
|
||||
fi
|
||||
|
||||
log "6. Переношу тома"
|
||||
if copy_volume "$OLD_DB_VOLUME" "$NEW_DB_VOLUME"; then
|
||||
log " - БД перенесена в \`$NEW_DB_VOLUME\`"
|
||||
fi
|
||||
if ((compose_has_caddy)); then
|
||||
for i in 0 1; do
|
||||
source="${OLD_CADDY_VOLUMES[$i]}"
|
||||
target="${NEW_CADDY_VOLUMES[$i]}"
|
||||
if copy_volume "$source" "$target"; then
|
||||
log " - \`$source\` перенесён в \`$target\`"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
log "7. Запускаю новый стек"
|
||||
if ((compose_has_build)); then
|
||||
up_args=(up -d --build --remove-orphans)
|
||||
else
|
||||
up_args=(up -d --remove-orphans)
|
||||
fi
|
||||
run "${compose_cmd[@]}" -f "$compose_file" "${up_args[@]}"
|
||||
run "${compose_cmd[@]}" -f "$compose_file" ps
|
||||
|
||||
log "Готово."
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user