Compare commits

..
58 Commits
Author SHA1 Message Date
kavoreandGitHub 1c121957aa Merge pull request #163 from kavore/dev
chore(release): migrate DB to alembic and harden payments/webhooks
2026-02-20 12:33:49 +03:00
kavore 5d00124ca0 fix(users): update referral code handling to ensure uniqueness and proper formatting
Implement logic to clean and standardize referral codes in the users table. This includes setting referral codes to NULL if they are empty or consist only of whitespace, and generating unique referral codes for users with duplicates. The changes enhance data integrity and ensure consistent formatting of referral codes across the database.
2026-02-12 16:44:14 +03:00
kavore b164fbd9bb refactor(payments): enhance payment processing logic and error handling
Refactor the `process_successful_payment` function to improve the handling of payment statuses during processing. Introduce a new method to atomically claim payments for processing and rollback payment statuses in case of activation failures. Enhance logging for various scenarios, including payment status checks and rollback actions, to provide clearer insights into payment processing flows. This improves the robustness and reliability of the payment handling logic.
2026-02-12 15:58:16 +03:00
kavore f1620a784b refactor(promo): streamline discount consumption logic
Simplify the discount consumption process in the PromoCodeService by removing unnecessary checks for active discounts and improving logging for various scenarios. Ensure that successful payments are always accounted for, even if the associated reservation has expired. This enhances clarity and reliability in the discount application workflow.
2026-02-12 15:47:52 +03:00
kavore e546ba3515 fix(payments): improve YooKassa webhook processing for terminal failures
Refactor the YooKassa webhook handling to better manage terminal payment failures. Introduce checks for payment status based on metadata, ensuring that only terminal failures are committed to the database. Enhance error handling and logging for non-terminal failures, providing clearer responses for retry scenarios. This improves the robustness of payment processing and reduces unnecessary retries.
2026-02-12 15:23:12 +03:00
kavore d39e781203 feat(payments): implement server-side price resolution for subscription payments
Add a new function `resolve_fiat_offer_price_for_user` to determine the correct offer price for users based on their subscription choices and any applicable discounts. Update payment handlers for CryptoPay, FreeKassa, Platega, SeverPay, and YooKassa to utilize this function, ensuring callback price validation and improving error handling for price mismatches. This enhances security by preventing callback payload tampering and ensures accurate pricing for users.
2026-02-12 15:09:04 +03:00
kavoreandGitHub b5dfaf8188 Merge pull request #165 from Snowy-Fluffy/fix-alembic
alembic migration fix
2026-02-12 10:50:28 +03:00
kavore 0bd3d70e12 feat(logging): add LOG_ADMIN_HIDE setting and update logging functionality
Introduce a new configuration option `LOG_ADMIN_HIDE` to control the visibility of admin-generated events in the logs. Update the logging retrieval functions to respect this setting, ensuring that admin actions can be hidden from the "All message logs" UI and CSV exports. Additionally, enhance the README with detailed logging configuration options for better clarity.
2026-02-12 10:48:48 +03:00
Snowy-Fluffy bce3d1a046 alembic migration fix 2026-02-12 10:17:17 +03:00
kavore 826b036724 fix(payments): enhance payment processing return values
Refactor the `process_successful_payment` function to ensure consistent return values, replacing `return` statements with `return False` for error cases. This improves error handling and clarity in payment processing logic. Additionally, update the promo code consumption logic to handle missing discounts more gracefully, ensuring proper logging and flow control.
2026-02-12 09:27:44 +03:00
kavore 799bd01a79 fix(payments): enhance YooKassa webhook processing and notification handling
Improve error handling for YooKassa payment events by adding verification for the payment service configuration. Refactor notification sending for expired discounts to batch messages before sending, ensuring better performance and error logging.
2026-02-11 23:49:58 +03:00
kavore 814663a528 fix(payments): improve payment activation error handling
Add validation for provider payment IDs in YooKassa webhook processing and ensure activation details are checked for null values in CryptoPay, FreeKassa, Platega, and Stars services. This prevents potential runtime errors during payment processing and enhances logging for better debugging.
2026-02-11 23:20:40 +03:00
kavore ee9d1a3ad1 fix(promo): auto-apply discount code from start link 2026-02-11 19:00:48 +03:00
kavore 11a4d08b45 fix(promo): avoid double stars discount application 2026-02-11 01:24:20 +03:00
kavore 1eb2165b28 fix(users): avoid promo activation FK conflict on user deletion 2026-02-10 22:14:13 +03:00
kavore 3f22c3a531 fix(payments): enforce strict YooKassa webhook binding
Require payment_db_id for successful webhook processing and validate ownership. Precreate payment records for auto-renew and include payment_db_id in provider metadata. Ignore replayed succeeded webhooks. Also normalize empty Telegram webhook secret before set_webhook and request handler usage.
2026-02-09 10:17:37 +03:00
kavore 2c618c7a6a fix(payments): hardcode RUB in user payment handlers 2026-02-09 00:19:59 +03:00
kavore 7622537961 fix(payments): enforce RUB as default service currency 2026-02-09 00:19:59 +03:00
kavore 5365df91f6 fix(env): remove unused default currency symbol setting 2026-02-09 00:19:59 +03:00
kavore 194295d34c fix(payments): add cancel button for Telegram Stars flow 2026-02-09 00:04:05 +03:00
kavore 5e9a08aeda feat(promo): add discount promo reservation timeout 2026-02-08 23:27:19 +03:00
kavore f870e4d8b7 feat(env): migrate database setup to alembic 2026-02-08 23:27:04 +03:00
kavore 8ba0e465b1 fix(start): keep welcome before menu after channel verify 2026-02-08 22:33:00 +03:00
kavore 0e470688a1 feat(ci): add codex PR review workflow 2026-02-08 22:33:00 +03:00
kavore 1e7152846b fix(i18n): add missing ru/en keys used in code 2026-02-08 22:33:00 +03:00
kavore e29f723486 docs(env): align .env.example and README with security changes 2026-02-08 22:33:00 +03:00
kavore 59d10184e8 chore(git): ignore .serena and AGENTS.md 2026-02-08 21:30:39 +03:00
kavore da03510a92 chore(security): replace silent exception swallowing across handlers 2026-02-08 21:30:39 +03:00
kavore 199c7797f5 fix(logs): redact sensitive payloads in storage and export 2026-02-08 21:30:39 +03:00
kavore 9a32cab718 fix(payments): enforce idempotent and verified webhook processing 2026-02-08 21:30:39 +03:00
kavore 4853a49112 fix(webhooks): harden webhook config and secret handling 2026-02-08 21:30:39 +03:00
kavore 0d637340f5 fix(env): stabilize .env parsing and add feature toggles
- Handle dotenv placeholders like 'KEY=  # comment' without validation crashes

- Add REQUIRED_CHANNEL_SUBSCRIBE_TO_USE to explicitly enable channel gate

- Add REFERRAL_ENABLED to fully disable referral flow and bonuses

- Hide referral UI/actions when disabled and ignore ref start params

- Update .env.example defaults and README docs for new flags
2026-02-06 23:49:46 +03:00
kavore 401469e276 Fix None promo bonus days in subscription activation 2026-02-06 11:16:28 +03:00
kavore 31e59f6339 Fix None promo bonus days in subscription activation 2026-02-06 11:16:23 +03:00
kavoreandGitHub 8dde585d49 Merge pull request #159 from kavore/dev
bugfix
2026-02-03 12:18:37 +03:00
kavore 3c84007f17 fix(promo): count activations on payment
Allow promo usage increments to overflow the max limit when a payment completes so counters reflect paid activations only.
2026-02-03 12:05:16 +03:00
kavore d11b0cabaa fix(users): handle active discounts on delete
Ensure active_discounts cascades on user/promo removal, clean orphan rows during migration, and rely on DB cascade for user deletion.
2026-02-03 11:25:53 +03:00
kavore 4b5908d6cc fix(promo): Improve error handling for promo code application
- Enhanced the logic for determining the response to users when a promo code application fails, ensuring clearer messaging based on the type of error encountered.
- Introduced specific error messages for cases where the promo code is not found or not applicable as a discount, improving user experience and clarity.
2026-02-02 23:55:04 +03:00
kavore 5bb8f2add0 feat(promo): Integrate promo code service into subscription options
- Enhanced the display_subscription_options and reshow_subscription_options_callback functions to accept a promo_code_service parameter for improved discount handling.
- Updated the logic to calculate and display discounted prices based on active promo codes, including support for both regular and star-based pricing.
- Modified relevant locales to include currency symbols in discount notices for better user clarity.
2026-02-02 00:35:31 +03:00
kavore 484327a032 feat(promo): Enhance promo code functionality and discount consumption logic
- Integrated promo code service into subscription service to streamline discount consumption during payment processing.
- Improved error handling and logging for promo code activation and usage increment.
- Updated payment services to handle discount calculations more robustly, including fallback mechanisms for invalid discount scenarios.
- Added new methods for managing promo code activations and usage in the database layer.
2026-02-01 19:29:31 +03:00
kavoreandGitHub 9ea9f571bb Merge pull request #155 from kavore/dev 2026-01-31 07:13:59 +03:00
kavoreandGitHub 9b4a58f82f Merge pull request #154 from VAQYBIN/dev
Исправление бага при удалении промокода на скидку
2026-01-28 16:18:23 +03:00
VAQYBIN e6adbbece4 Merge branch 'dev' of https://github.com/VAQYBIN/remnawave-tg-shop into dev 2026-01-25 20:43:20 +05:00
VAQYBIN 1e79b15351 fix(promo): Исправлена ошибка с удалением промокода на скидку
Исправлен баг, когда промокод на скидку не мог быть удалён, если пользователь применил скидку, но не воспользовался ей.
Добавлено истечение времени действия промокода, даже если промокод на скидку уже был введён
2026-01-25 20:43:01 +05:00
kavoreandGitHub 6f3c123b3a Merge pull request #153 from VAQYBIN/dev
Промокоды на скидку
2026-01-24 22:15:06 +03:00
VAQYBINandGitHub 4a0f763307 Delete CLAUDE.md 2026-01-23 12:42:40 +05:00
VAQYBIN a121d38fbb fix(payments): Исправлена ошибка с дублированием скидки для всех платёжных систем 2026-01-23 00:00:16 +05:00
VAQYBIN 1e8b97888a fix(promo): Исправлен баг с применением двойной скидки при оплате через CryptoBot 2026-01-21 01:26:10 +05:00
VAQYBIN 9ca2fe487c fix(promo): Исправлена ошибка с зацыкленностью импортов для оплат звёздами и криптой 2026-01-21 01:13:26 +05:00
VAQYBIN 229ce7e1e0 feat(promo): Добавлена поддержка промокодов на скидку для всех платёжных систем 2026-01-21 01:09:49 +05:00
VAQYBIN 39d5fd1856 chore(promo): Добавлена локализация кнопки изменения процента скидки 2026-01-20 01:44:22 +05:00
VAQYBIN fed1c1c960 feat(promo): Добавлена локализация при изменении процента скидки и логи при активации промокода на скидку 2026-01-20 01:26:14 +05:00
VAQYBIN e7df5e539c chore(promo): Добавлены локализации при создании промокодов на скидку 2026-01-20 01:06:18 +05:00
VAQYBIN dcc7f9eb72 fix(promo): Исправлена ошибка с дублированием скидки при использование промокода 2026-01-20 00:51:05 +05:00
VAQYBIN baaf5c457f fix(promo): Исправлена ошибка при применении промокода на скидку 2026-01-20 00:34:17 +05:00
VAQYBIN 62bf5c35a8 fix(db): Добавлены новые таблицы для хранения информации о новых и старых ценах при использовании промокодов на скидку 2026-01-20 00:16:24 +05:00
VAQYBIN d619afff29 test: fix ci/cd 2026-01-19 04:00:10 +05:00
VAQYBIN 3bf9acf4d4 feat(promo): Добавлены промокоды на скидку в процентах 2026-01-19 03:50:33 +05:00
115 changed files with 5322 additions and 17900 deletions
-13
View File
@@ -5,13 +5,6 @@
.gitattributes
LICENSE
README.md
.env
.env.*
!.env.example
scratch_*.py
*.local.*
node_modules/
.git/
# CI
@@ -24,12 +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.js
bot/app/web/templates/subscription_webapp.min.*.js
# Byte-compiled / optimized / DLL files
**/__pycache__/
+33 -62
View File
@@ -3,72 +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="/minishop" # Mini App title
WEBAPP_PRIMARY_COLOR="#00fe7a" # Main UI color
WEBAPP_LOGO_URL= # Optional logo URL; if empty the emoji below is used
WEBAPP_LOGO_EMOJI="🫥" # Emoji logo fallback shown in the header and login screen
WEBAPP_SESSION_SECRET= # Optional: HMAC secret for webapp sessions; generated if empty
WEBHOOK_SECRET_TOKEN= # Optional: Telegram webhook secret token; generated if empty
WEBAPP_SESSION_TTL_SECONDS=86400 # Web App session lifetime (24h)
WEBAPP_AUTH_MAX_AGE_SECONDS=86400 # Max Telegram initData age
WEBAPP_LOGIN_TOKEN_TTL_SECONDS=600 # External browser login link lifetime
# Email login and account linking via SMTP (Brevo SMTP relay defaults)
SMTP_HOST=smtp-relay.brevo.com # SMTP server
SMTP_PORT=587 # Brevo recommends 587 with STARTTLS
SMTP_FALLBACK_PORTS=2525,465 # Tried after SMTP_PORT; 465 uses SSL automatically
SMTP_TIMEOUT_SECONDS=30 # Per SMTP connection/send attempt timeout
SMTP_USERNAME= # Brevo SMTP login
SMTP_PASSWORD= # Brevo SMTP key/password
SMTP_FROM_EMAIL= # Verified sender email
SMTP_FROM_NAME= # Optional sender name
SMTP_STARTTLS=True # Use STARTTLS on SMTP_PORT
SMTP_USE_SSL=False # Use SSL wrapper, usually only for port 465
EMAIL_CODE_TTL_SECONDS=600 # Email verification code lifetime
EMAIL_CODE_RESEND_SECONDS=60 # Minimum delay between code sends
EMAIL_CODE_MAX_ATTEMPTS=5 # Max attempts per code
BRUTE_FORCE_MAX_FAILURES=5 # Max failed code attempts in the throttle window
BRUTE_FORCE_WINDOW_SECONDS=900 # Rolling window used to count failures
BRUTE_FORCE_LOCK_SECONDS=1800 # Temporary lockout duration after too many failures
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
@@ -96,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
@@ -106,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
@@ -151,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
@@ -169,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
@@ -179,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
@@ -189,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"
@@ -198,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
+75
View File
@@ -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,
});
+68
View File
@@ -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 }}
+56
View File
@@ -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 -15
View File
@@ -3,19 +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.js
bot/app/web/templates/subscription_webapp.min.*.js
tmp
.claude
# Игнорировать кэш Python
__pycache__/
@@ -30,5 +17,5 @@ __pycache__/
locales/ru_backup.json
locales/en_backup.json
db/models_old.py
config/tariffs.json
docker-compose-dev.yml
.serena/
AGENTS.md
-10
View File
@@ -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 -38
View File
@@ -1,4 +1,4 @@
FROM python:3.12-slim AS python-builder
FROM python:3.12-slim AS builder
WORKDIR /app
@@ -7,49 +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/frontend ./bot/app/web/frontend
COPY bot/app/web/templates ./bot/app/web/templates
COPY scripts/build_subscription_webapp_js.mjs ./scripts/build_subscription_webapp_js.mjs
RUN npm run build:webapp
FROM python:3.12-slim
WORKDIR /app
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.js \
bot/app/web/templates/subscription_webapp.min.*.js
COPY --from=webapp-builder /webapp/bot/app/web/templates/subscription_webapp.css \
bot/app/web/templates/subscription_webapp.css
COPY --from=webapp-builder /webapp/bot/app/web/templates/subscription_webapp.js \
bot/app/web/templates/subscription_webapp.js
COPY --from=webapp-builder /webapp/bot/app/web/templates/subscription_webapp.min.*.js \
bot/app/web/templates/
RUN rm -rf /root/.cache
RUN mkdir -p /app/logs /app/data && chown -R appuser:appuser /app/logs /app/data
USER appuser
CMD ["python", "main.py"]
+91 -246
View File
@@ -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. |
@@ -140,11 +119,25 @@ Remnawave Minishop — это Telegram-бот **и** Web App (Mini App) для
</details>
<details>
<summary><b>Настройки тарифов</b></summary>
<summary><b>Настройки логирования</b></summary>
Бот умеет продавать **подписку на срок** (1/3/6/12 мес.) или **пакеты трафика** (`TRAFFIC_PACKAGES=10:199,50:799`). Эти режимы взаимоисключающие — наличие непустой `TRAFFIC_PACKAGES` (или `STARS_TRAFFIC_PACKAGES`) автоматически переключает бот в режим продажи трафика.
| Переменная | Описание | Пример |
| --- | --- | --- |
| `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>
Полное описание обоих режимов, переменных, что происходит при покупке, как ведут себя автопродление, реф-бонусы и триал — вынесено в [docs/tariffs.md](docs/tariffs.md).
<details>
<summary><b>Настройки подписок</b></summary>
Для каждого периода (1, 3, 6, 12 месяцев) можно настроить доступность и цены:
- `1_MONTH_ENABLED`: `true` или `false`
- `RUB_PRICE_1_MONTH`: Цена в рублях
- `STARS_PRICE_1_MONTH`: Цена в Telegram Stars
Аналогичные переменные есть для `3_MONTHS`, `6_MONTHS`, `12_MONTHS`.
</details>
<details>
@@ -173,142 +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: после отправки письма код вводится в отдельном модальном окне подтверждения. После успешного входа страница обновляет данные сразу, без сообщений боту.
- При запуске `python main.py` миграции применяются автоматически до `head`.
- Для ручного запуска используйте:
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>`.
```bash
alembic upgrade head
```
## Подробная инструкция для развертывания на сервере с панелью 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. Настройка переменных окружения
@@ -337,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 {
@@ -370,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 {
@@ -440,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:
@@ -451,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
View File
@@ -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
+94
View File
@@ -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()
+26
View File
@@ -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"}
+280
View File
@@ -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,
)
+5 -2
View File
@@ -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
@@ -1,217 +0,0 @@
<script>
import {
ArrowRight,
CheckCircle2,
Circle,
Copy,
Crown,
Database,
Download,
Gift,
Globe2,
LockKeyhole,
Mail,
RefreshCw,
Repeat2,
Send,
Ticket,
UserRound,
Zap,
} from "lucide-svelte";
import Button from "./lib/components/ui/button.svelte";
import Card from "./lib/components/ui/card.svelte";
import BackTitle from "./preview/BackTitle.svelte";
import PhoneFrame from "./preview/PhoneFrame.svelte";
import PreviewMethods from "./preview/PreviewMethods.svelte";
import PreviewNav from "./preview/PreviewNav.svelte";
export let config = {};
export let mockData = {};
const title = config.title || "/minishop";
const logoEmoji = config.logoEmoji || "🫥";
const plans = mockData.plans || [];
const sub = mockData.subscription || {};
const methods = mockData.payment_methods || [];
const user = mockData.user || {};
const tariffs = [
["subscription", "Подписка", "Безлимитный трафик", "Идеально для постоянного использования", Zap],
["traffic", "Трафик", "Пакеты гигабайт", "Платите только за нужный объем", Database],
["premium", "Премиум", "Максимальная скорость", "Приоритетные серверы и поддержка", Crown],
];
const traffic = [
[20, 290],
[50, 590],
[100, 990],
[300, 2190],
];
const settingsRows = [
[Globe2, "Язык интерфейса", "Русский"],
[Send, "Привязка Telegram", user.telegram_linked ? `@${user.username || "username"}` : "Не привязан"],
[Mail, "Привязка почты", user.email || "Не привязана"],
[UserRound, "Выйти", "Завершить сессию"],
];
const previewTelegramName = user.first_name || (user.username ? `@${user.username}` : "Telegram не привязан");
const previewEmail = user.email || "Почта не привязана";
const previewTelegramId = user.telegram_id ? `TG ID ${user.telegram_id}` : "TG ID не привязан";
const previewAvatar = user.telegram_photo_url || "";
function money(value) {
return `${value} ₽`;
}
</script>
<div class="preview-board" style={`--accent: ${config.primaryColor || "#00fe7a"};`}>
<PhoneFrame number="1" label="Главный экран">
<main class="home-layout">
<div class="login-brand home-brand">
<div class="brand-mark brand-mark-xl"><span>{logoEmoji}</span></div>
<h1>{title}</h1>
</div>
<div class="home-bottom">
<Card class="status-card">
<div class="sub-status">
<CheckCircle2 size={23} />
<div><h2>Подписка активна</h2><p>до {sub.end_date_text}</p></div>
</div>
</Card>
<Card>
<div class="traffic-top"><span>Использовано трафика</span><strong>{sub.traffic_used} из {sub.traffic_limit}</strong></div>
<div class="progress"><span style="width: 18%"></span></div>
<div class="traffic-percent">18%</div>
</Card>
<div class="action-stack">
<Button class="wide"><Download size={17} />Установить и настроить</Button>
<Button variant="secondary" class="wide"><RefreshCw size={17} />Продлить</Button>
<Button variant="secondary" class="wide"><Repeat2 size={17} />Сменить тариф</Button>
</div>
</div>
<PreviewNav active="home" />
</main>
</PhoneFrame>
<PhoneFrame number="2" label="Выбор тарифа">
<div class="preview-header"><div class="brand-row"><div class="brand-mark"><span>{logoEmoji}</span></div><strong>{title}</strong></div></div>
<div class="tariff-list">
{#each tariffs as tariff, index}
<div class:active={index === 0} class="select-card">
<span class="select-icon"><svelte:component this={tariff[4]} size={24} /></span>
<span><strong>{tariff[1]}</strong><small>{tariff[2]}</small><em>{tariff[3]}</em></span>
{#if index === 0}<CheckCircle2 size={21} />{:else}<Circle size={21} />{/if}
</div>
{/each}
</div>
<Button class="wide bottom-action">Далее <ArrowRight size={17} /></Button>
</PhoneFrame>
<PhoneFrame number="3" label="Оплата тарифа — подписка" wide>
<BackTitle title="Подписка" subtitle="Выберите срок подписки" />
<div class="period-grid">
{#each plans as plan, index}
<div class:active={index === 1} class="period-card">
<strong>{plan.title}</strong><span>{money(plan.price)}</span><small>{money(Math.round(plan.price / plan.months))}/мес</small>
{#if index === 1}<CheckCircle2 size={18} />{/if}
</div>
{/each}
</div>
<Card class="total-card"><span>Итого<br /><small>К оплате</small></span><strong>790 ₽</strong></Card>
<PreviewMethods {methods} />
<Button class="wide bottom-action">Оплатить 790 ₽ <LockKeyhole size={16} /></Button>
</PhoneFrame>
<PhoneFrame number="4" label="Оплата тарифа — трафик" wide>
<BackTitle title="Трафик" subtitle="Выберите пакет трафика" />
<div class="period-grid">
{#each traffic as pack, index}
<div class:active={index === 2} class="period-card">
<strong>{pack[0]} ГБ</strong><span>{money(pack[1])}</span><small>{money(Math.round(pack[1] / pack[0]))}/ГБ</small>
{#if index === 2}<CheckCircle2 size={18} />{/if}
</div>
{/each}
</div>
<Card class="total-card"><span>Итого<br /><small>К оплате</small></span><strong>990 ₽</strong></Card>
<PreviewMethods {methods} />
<Button class="wide bottom-action">Оплатить 990 ₽ <LockKeyhole size={16} /></Button>
</PhoneFrame>
<PhoneFrame number="5" label="Смена тарифа">
<BackTitle title="Смена тарифа" subtitle="Остаток 12 дней будет пересчитан" />
<div class="tariff-list compact">
<div class="select-card"><span><strong>Подписка</strong><small>Безлимитный трафик</small></span><em>Доплата 190 ₽</em><Circle size={20} /></div>
<div class="select-card active"><span><strong>Трафик</strong><small>Пакеты гигабайт</small></span><em>Доплата не требуется</em><CheckCircle2 size={20} /></div>
<div class="select-card"><span><strong>Премиум</strong><small>Максимальная скорость</small></span><em>Доплата 390 ₽</em><Circle size={20} /></div>
</div>
<Button class="wide bottom-action">Далее <ArrowRight size={17} /></Button>
<div class="preview-modal">
<Repeat2 size={30} />
<strong>Сменить тариф без доплаты?</strong>
<p>Остаток 12 дней будет пересчитан по новому тарифу.</p>
<Button>Да, сменить</Button>
<Button variant="secondary">Отмена</Button>
</div>
</PhoneFrame>
<PhoneFrame number="6" label="Пригласить друга">
<div class="preview-header"><div class="brand-row"><div class="brand-mark"><span>{logoEmoji}</span></div><strong>{title}</strong></div></div>
<Card>
<div class="card-label">Ваша реферальная ссылка</div>
<div class="copy-row"><code>https://minishop.app/ref/ABCD1234</code><Button>Копировать <Copy size={16} /></Button></div>
</Card>
<Card class="bonus-card">
<Gift size={42} /><div><span>Ваш бонус</span><strong>+7 дней за каждого друга</strong><p>Друг получит +3 дня к подписке.</p></div>
</Card>
<Button variant="outline" class="wide"><Ticket size={18} />Активировать промокод</Button>
</PhoneFrame>
<PhoneFrame number="7" label="Настройки">
<div class="preview-header"><div class="brand-row"><div class="brand-mark"><span>{logoEmoji}</span></div><strong>{title}</strong></div></div>
<Card class="settings-profile">
<div class="settings-avatar">
{#if previewAvatar}
<img src={previewAvatar} alt="Аватар пользователя" />
{:else}
<UserRound size={27} />
{/if}
</div>
<div class="settings-profile-meta">
<strong>{previewTelegramName}</strong>
<small>{previewEmail}</small>
<small>{previewTelegramId}</small>
</div>
</Card> <div class="settings-list">
{#each settingsRows as row}
<div class="settings-row">
<svelte:component this={row[0]} size={20} />
<span><strong>{row[1]}</strong><small>{row[2]}</small></span>
<ArrowRight size={16} />
</div>
{/each}
</div>
</PhoneFrame>
<PhoneFrame number="8" label="Логин" wide>
<div class="login-brand small">
<div class="brand-mark brand-mark-xl"><span>{logoEmoji}</span></div>
<h1>{title}</h1><p>Войдите в свой аккаунт</p>
</div>
<Card class="auth-card">
<div class="field-label">Вход по email</div>
<div class="auth-email-stack"><div class="input muted">Email</div><Button class="wide"><Mail size={17} />Войти по почте</Button></div>
<div class="or-line"><span></span>или<span></span></div>
<Button variant="telegram" class="wide telegram-login-button">
<span class="telegram-login-text"><Send size={17} />Войти через телеграм</span>
</Button>
</Card>
</PhoneFrame>
<PhoneFrame number="9" label="Подтверждение по коду" wide>
<BackTitle title="Подтверждение по email" subtitle="Мы отправили код на user@example.com" />
<div class="otp-slots static">{#each [1, 2, 3, 4, 5, 6] as digit}<span>{digit}</span>{/each}</div>
<Button class="wide bottom-action">Подтвердить</Button>
<button class="link-button"><RefreshCw size={15} />Отправить код повторно (00:45)</button>
</PhoneFrame>
</div>
@@ -1,38 +0,0 @@
<script>
import { cn } from "../../utils.js";
export let type = "button";
export let variant = "default";
export let size = "default";
export let disabled = false;
export let href = "";
export let onclick = undefined;
let className = "";
export { className as class };
const variants = {
default: "btn btn-primary",
secondary: "btn btn-secondary",
outline: "btn btn-outline",
ghost: "btn btn-ghost",
telegram: "btn btn-telegram",
icon: "btn btn-icon",
};
const sizes = {
default: "",
sm: "btn-sm",
lg: "btn-lg",
icon: "btn-square",
};
</script>
{#if href}
<a class={cn(variants[variant], sizes[size], className)} {href} on:click={onclick} {...$$restProps}>
<slot />
</a>
{:else}
<button class={cn(variants[variant], sizes[size], className)} {type} {disabled} on:click={onclick} {...$$restProps}>
<slot />
</button>
{/if}
@@ -1,12 +0,0 @@
<script>
import { cn } from "../../utils.js";
export let active = false;
export let compact = false;
let className = "";
export { className as class };
</script>
<section class={cn("card", active && "card-active", compact && "card-compact", className)}>
<slot />
</section>
@@ -1,31 +0,0 @@
<script>
import { X } from "lucide-svelte";
import { cn } from "../../utils.js";
import Button from "./button.svelte";
export let open = false;
export let title = "";
export let description = "";
export let closeLabel = "Close";
export let onclose = () => {};
let className = "";
export { className as class };
</script>
{#if open}
<div class="dialog" role="dialog" aria-modal="true" aria-label={title}>
<button class="dialog-backdrop" type="button" aria-label={closeLabel} on:click={onclose}></button>
<section class={cn("dialog-card", className)}>
<div class="dialog-head">
<div>
{#if title}<h2>{title}</h2>{/if}
{#if description}<p>{description}</p>{/if}
</div>
<Button variant="icon" size="icon" onclick={onclose} aria-label={closeLabel}>
<X size={18} />
</Button>
</div>
<slot />
</section>
</div>
{/if}
@@ -1,29 +0,0 @@
<script>
import { cn } from "../../utils.js";
export let value = "";
export let type = "text";
export let placeholder = "";
export let inputmode = undefined;
export let maxlength = undefined;
export let autocomplete = undefined;
export let disabled = false;
let className = "";
export { className as class };
</script>
<input
bind:value
class={cn("input", className)}
on:keydown
on:input
on:focus
on:blur
{type}
{placeholder}
{inputmode}
{maxlength}
{autocomplete}
{disabled}
{...$$restProps}
/>
-6
View File
@@ -1,6 +0,0 @@
import { clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs) {
return twMerge(clsx(inputs));
}
-10
View File
@@ -1,10 +0,0 @@
import { mount } from "svelte";
import App from "./App.svelte";
import "./styles.css";
const target = document.getElementById("app");
if (target) {
mount(App, { target });
}
@@ -1,17 +0,0 @@
<script>
import { ArrowLeft } from "lucide-svelte";
export let title = "";
export let subtitle = "";
</script>
<header class="screen-head">
<button class="btn btn-icon btn-square" type="button" aria-label="Назад">
<ArrowLeft size={18} />
</button>
<div class="center-copy">
<h1>{title}</h1>
<p>{subtitle}</p>
</div>
<span></span>
</header>
@@ -1,12 +0,0 @@
<script>
export let number = "";
export let label = "";
export let wide = false;
</script>
<section class:wide class="preview-phone-wrap">
<h2><span>{number}.</span> {label}</h2>
<div class="preview-phone">
<slot />
</div>
</section>
@@ -1,26 +0,0 @@
<script>
import { CreditCard, Send, WalletCards } from "lucide-svelte";
export let methods = [];
const icons = [CreditCard, Send, WalletCards, WalletCards];
function note(index) {
if (index === 0) return "Visa, Mastercard";
if (index === 1) return "Быстро и удобно";
if (index === 2) return "USDT, BTC, ETH";
return "ЮMoney, СБП и др.";
}
</script>
<div class="method-grid">
{#each methods as method, index}
<div class:active={index === 1} class="method-card">
<svelte:component this={icons[index] || WalletCards} size={18} />
<span>
<strong>{method.name}</strong>
<small>{note(index)}</small>
</span>
</div>
{/each}
</div>
@@ -1,11 +0,0 @@
<script>
import { Gift, Home, Settings } from "lucide-svelte";
export let active = "home";
</script>
<nav class="bottom-nav static">
<button class:active={active === "home"} type="button"><Home size={20} /><span>Главная</span></button>
<button class:active={active === "invite"} type="button"><Gift size={20} /><span>Пригласить</span></button>
<button class:active={active === "settings"} type="button"><Settings size={20} /><span>Настройки</span></button>
</nav>
File diff suppressed because it is too large Load Diff
-36
View File
@@ -1,36 +0,0 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { svelte } from "@sveltejs/vite-plugin-svelte";
import { defineConfig } from "vite";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const templateDir = path.resolve(__dirname, "../templates");
export default defineConfig({
plugins: [svelte()],
build: {
outDir: templateDir,
emptyOutDir: false,
minify: false,
sourcemap: false,
cssCodeSplit: false,
lib: {
entry: path.resolve(__dirname, "src/main.js"),
name: "SubscriptionWebApp",
formats: ["iife"],
fileName: () => "subscription_webapp.js",
cssFileName: "subscription_webapp",
},
rollupOptions: {
output: {
assetFileNames: (assetInfo) => {
if (assetInfo.name && assetInfo.name.endsWith(".css")) {
return "subscription_webapp.css";
}
return "subscription_webapp.[name][extname]";
},
},
},
},
});
File diff suppressed because it is too large Load Diff
@@ -1,29 +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="#03070b">
<link id="app-favicon" rel="icon" href="data:," sizes="any">
<title>/minishop</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
<script src="https://telegram.org/js/telegram-web-app.js"></script>
<link rel="stylesheet" href="/subscription_webapp.css">
</head>
<body>
<main id="app"></main>
<!-- WEBAPP_I18N_SCRIPT -->
<!-- WEBAPP_CONFIG_SCRIPT -->
<!-- WEBAPP_JS_SCRIPT -->
<!-- WEBAPP_DEV_MOCK_START -->
<script src="/subscription_webapp.js" defer></script>
<!-- WEBAPP_DEV_MOCK_END -->
</body>
</html>
+23 -72
View File
@@ -1,7 +1,5 @@
import hmac
import asyncio
import logging
from aiohttp import web
from aiogram import Bot, Dispatcher
from aiogram.webhook.aiohttp_server import SimpleRequestHandler, setup_application
@@ -10,24 +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)
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",
@@ -42,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
@@ -85,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("/"):
@@ -113,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,
)
@@ -129,35 +110,5 @@ async def build_and_start_web_app(
f"AIOHTTP server started on http://{settings.WEB_SERVER_HOST}:{settings.WEB_SERVER_PORT}"
)
if settings.WEBAPP_ENABLED:
from bot.app.web.subscription_webapp import create_subscription_webapp_application
subscription_app = create_subscription_webapp_application(
dp,
bot,
settings,
async_session_factory,
)
subscription_runner = web.AppRunner(subscription_app)
await subscription_runner.setup()
runners.append(subscription_runner)
subscription_site = web.TCPSite(
subscription_runner,
host=settings.WEBAPP_SERVER_HOST,
port=settings.WEBAPP_SERVER_PORT,
)
await subscription_site.start()
logging.info(
"Subscription WebApp server started on http://%s:%s",
settings.WEBAPP_SERVER_HOST,
settings.WEBAPP_SERVER_PORT,
)
try:
await asyncio.Event().wait()
finally:
for runner in reversed(runners):
try:
await runner.cleanup()
except Exception as cleanup_error:
logging.warning("Failed to cleanup aiohttp runner: %s", cleanup_error)
# Run until cancelled
await asyncio.Event().wait()
-188
View File
@@ -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
+4 -4
View File
@@ -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(
+2 -2
View File
@@ -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()
-3
View File
@@ -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)
+39 -23
View File
@@ -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(_(
+2 -2
View File
@@ -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
+196 -45
View File
@@ -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,
),
+70 -17
View File
@@ -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)
+1 -135
View File
@@ -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",
)
+5 -178
View File
@@ -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(
+40 -62
View File
@@ -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
View File
@@ -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")
+94 -48
View File
@@ -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)
+12 -29
View File
@@ -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
View File
@@ -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)
+111 -95
View File
@@ -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)
+20 -20
View File
@@ -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(
+1 -25
View File
@@ -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()
+18 -90
View File
@@ -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
View File
@@ -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}"
+9 -8
View File
@@ -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,
+5 -2
View File
@@ -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:
+3 -14
View File
@@ -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(
+133 -48
View File
@@ -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)
-605
View File
@@ -1,605 +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
from bot.services.email_templates import EmailContent, render_login_code
logger = logging.getLogger(__name__)
EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
@dataclass(frozen=True)
class SmtpAttempt:
port: int
use_ssl: bool
starttls: bool
@dataclass(frozen=True)
class EmailCodeRequestResult:
ok: bool
error: Optional[str] = None
retry_after: Optional[int] = None
@dataclass(frozen=True)
class EmailCodeVerifyResult:
ok: bool
error: Optional[str] = None
retry_after: Optional[int] = None
@dataclass(frozen=True)
class EmailMagicVerifyResult:
ok: bool
error: Optional[str] = None
email: Optional[str] = None
purpose: Optional[str] = None
target_user_id: Optional[int] = None
def normalize_email(value: str) -> str:
return (value or "").strip().lower()
def is_valid_email(value: str) -> bool:
email = normalize_email(value)
return bool(email and len(email) <= 254 and EMAIL_RE.match(email))
def _email_throttle_identifier(email: str, purpose: str, target_user_id: Optional[int]) -> str:
target_part = "none" if target_user_id is None else str(target_user_id)
return f"{purpose}:{target_part}:{email}"
class EmailAuthService:
def __init__(self, settings: Settings):
self.settings = settings
def _smtp_attempts(self) -> list[SmtpAttempt]:
attempts: list[SmtpAttempt] = []
primary_port = int(self.settings.SMTP_PORT)
for port in self.settings.smtp_ports_to_try:
if port == primary_port:
use_ssl = bool(self.settings.SMTP_USE_SSL or port == 465)
starttls = bool(self.settings.SMTP_STARTTLS and not use_ssl)
else:
use_ssl = port == 465
starttls = bool(self.settings.SMTP_STARTTLS and not use_ssl)
attempts.append(SmtpAttempt(port=port, use_ssl=use_ssl, starttls=starttls))
return attempts or [
SmtpAttempt(
port=primary_port,
use_ssl=bool(self.settings.SMTP_USE_SSL or primary_port == 465),
starttls=bool(
self.settings.SMTP_STARTTLS
and not self.settings.SMTP_USE_SSL
and primary_port != 465
),
)
]
def _hash_code(self, email: str, purpose: str, code: str) -> str:
secret = hmac.new(
self.settings.BOT_TOKEN.encode("utf-8"),
b"remnawave-tg-shop-email-code",
hashlib.sha256,
).digest()
payload = f"{purpose}:{email}:{code}".encode("utf-8")
return hmac.new(secret, payload, hashlib.sha256).hexdigest()
def _hash_magic_token(self, token: str) -> str:
secret = hmac.new(
self.settings.BOT_TOKEN.encode("utf-8"),
b"remnawave-tg-shop-email-magic",
hashlib.sha256,
).digest()
return hmac.new(secret, token.encode("utf-8"), hashlib.sha256).hexdigest()
def _build_magic_link(self, *, token: str, purpose: str) -> Optional[str]:
base_url = (self.settings.SUBSCRIPTION_MINI_APP_URL or "").strip()
if not base_url:
return None
from urllib.parse import urlencode, urlsplit, urlunsplit
parsed = urlsplit(base_url)
if parsed.scheme not in ("http", "https") or not parsed.netloc:
return None
params = {"login_token": token}
if purpose and purpose != "login":
params["login_purpose"] = purpose
existing_query = parsed.query
new_query = urlencode(params)
merged_query = f"{existing_query}&{new_query}" if existing_query else new_query
return urlunsplit((parsed.scheme, parsed.netloc, parsed.path, merged_query, parsed.fragment))
async def request_code(
self,
session: AsyncSession,
*,
email: str,
purpose: str,
language_code: str,
target_user_id: Optional[int] = None,
) -> EmailCodeRequestResult:
normalized_email = normalize_email(email)
if not self.settings.email_auth_configured:
return EmailCodeRequestResult(ok=False, error="email_auth_not_configured")
if not is_valid_email(normalized_email):
return EmailCodeRequestResult(ok=False, error="invalid_email")
now = datetime.now(timezone.utc)
throttle = await security_dal.check_throttle(
session,
scope=security_dal.EMAIL_CODE_VERIFY_SCOPE,
identifier=_email_throttle_identifier(normalized_email, purpose, target_user_id),
now=now,
)
if throttle.locked:
return EmailCodeRequestResult(
ok=False,
error="rate_limited",
retry_after=throttle.retry_after,
)
latest_code = await self._get_latest_code(
session,
email=normalized_email,
purpose=purpose,
target_user_id=target_user_id,
)
if latest_code and latest_code.created_at:
created_at = latest_code.created_at
if created_at.tzinfo is None:
created_at = created_at.replace(tzinfo=timezone.utc)
resend_after = max(1, int(self.settings.EMAIL_CODE_RESEND_SECONDS))
elapsed = int((now - created_at).total_seconds())
if elapsed < resend_after and latest_code.consumed_at is None:
return EmailCodeRequestResult(
ok=False,
error="rate_limited",
retry_after=resend_after - elapsed,
)
await session.execute(
update(EmailVerificationCode)
.where(
EmailVerificationCode.email == normalized_email,
EmailVerificationCode.purpose == purpose,
EmailVerificationCode.target_user_id == target_user_id,
EmailVerificationCode.status == "active",
EmailVerificationCode.consumed_at.is_(None),
)
.values(status="superseded")
)
code = f"{secrets.randbelow(1_000_000):06d}"
magic_token = secrets.token_urlsafe(32)
magic_link = self._build_magic_link(token=magic_token, purpose=purpose)
code_model = EmailVerificationCode(
email=normalized_email,
code_hash=self._hash_code(normalized_email, purpose, code),
magic_token_hash=self._hash_magic_token(magic_token) if magic_link else None,
purpose=purpose,
target_user_id=target_user_id,
expires_at=now + timedelta(seconds=max(60, int(self.settings.EMAIL_CODE_TTL_SECONDS))),
status="active",
)
session.add(code_model)
await session.flush()
await self._send_code_email(
email=normalized_email,
code=code,
language_code=language_code,
magic_link=magic_link,
)
return EmailCodeRequestResult(ok=True)
async def verify_code(
self,
session: AsyncSession,
*,
email: str,
purpose: str,
code: str,
target_user_id: Optional[int] = None,
) -> EmailCodeVerifyResult:
normalized_email = normalize_email(email)
normalized_code = re.sub(r"\D", "", code or "")
if not is_valid_email(normalized_email):
return EmailCodeVerifyResult(ok=False, error="invalid_code")
now = datetime.now(timezone.utc)
throttle_identifier = _email_throttle_identifier(
normalized_email,
purpose,
target_user_id,
)
throttle = await security_dal.check_throttle(
session,
scope=security_dal.EMAIL_CODE_VERIFY_SCOPE,
identifier=throttle_identifier,
now=now,
)
if throttle.locked:
return EmailCodeVerifyResult(
ok=False,
error="rate_limited",
retry_after=throttle.retry_after,
)
latest_code = await self._get_latest_code(
session,
email=normalized_email,
purpose=purpose,
target_user_id=target_user_id,
)
if not latest_code or latest_code.consumed_at is not None:
return EmailCodeVerifyResult(ok=False, error="invalid_code")
expires_at = latest_code.expires_at
if expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=timezone.utc)
if expires_at < now:
return EmailCodeVerifyResult(ok=False, error="expired_code")
max_attempts = max(1, int(self.settings.EMAIL_CODE_MAX_ATTEMPTS))
if int(latest_code.attempts or 0) >= max_attempts:
return EmailCodeVerifyResult(ok=False, error="too_many_attempts")
if len(normalized_code) != 6:
latest_code.attempts = int(latest_code.attempts or 0) + 1
throttle_result = await security_dal.record_throttle_failure(
session,
scope=security_dal.EMAIL_CODE_VERIFY_SCOPE,
identifier=throttle_identifier,
max_failures=self.settings.BRUTE_FORCE_MAX_FAILURES,
window_seconds=self.settings.BRUTE_FORCE_WINDOW_SECONDS,
lock_seconds=self.settings.BRUTE_FORCE_LOCK_SECONDS,
now=now,
)
await session.flush()
if throttle_result.locked:
return EmailCodeVerifyResult(
ok=False,
error="rate_limited",
retry_after=throttle_result.retry_after,
)
if int(latest_code.attempts or 0) >= max_attempts:
return EmailCodeVerifyResult(ok=False, error="too_many_attempts")
return EmailCodeVerifyResult(ok=False, error="invalid_code")
expected_hash = self._hash_code(normalized_email, purpose, normalized_code)
if not hmac.compare_digest(expected_hash, latest_code.code_hash):
latest_code.attempts = int(latest_code.attempts or 0) + 1
throttle_result = await security_dal.record_throttle_failure(
session,
scope=security_dal.EMAIL_CODE_VERIFY_SCOPE,
identifier=throttle_identifier,
max_failures=self.settings.BRUTE_FORCE_MAX_FAILURES,
window_seconds=self.settings.BRUTE_FORCE_WINDOW_SECONDS,
lock_seconds=self.settings.BRUTE_FORCE_LOCK_SECONDS,
now=now,
)
await session.flush()
if throttle_result.locked:
return EmailCodeVerifyResult(
ok=False,
error="rate_limited",
retry_after=throttle_result.retry_after,
)
return EmailCodeVerifyResult(ok=False, error="invalid_code")
latest_code.consumed_at = now
await security_dal.clear_throttle_state(
session,
scope=security_dal.EMAIL_CODE_VERIFY_SCOPE,
identifier=throttle_identifier,
)
await session.flush()
return EmailCodeVerifyResult(ok=True)
async def _get_latest_code(
self,
session: AsyncSession,
*,
email: str,
purpose: str,
target_user_id: Optional[int],
) -> Optional[EmailVerificationCode]:
stmt = (
select(EmailVerificationCode)
.where(
EmailVerificationCode.email == email,
EmailVerificationCode.purpose == purpose,
EmailVerificationCode.target_user_id == target_user_id,
EmailVerificationCode.status == "active",
EmailVerificationCode.consumed_at.is_(None),
)
.order_by(EmailVerificationCode.created_at.desc())
.limit(1)
)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def verify_magic_token(
self,
session: AsyncSession,
*,
token: str,
purpose: str,
target_user_id: Optional[int] = None,
) -> EmailMagicVerifyResult:
if not token:
return EmailMagicVerifyResult(ok=False, error="invalid_token")
token_hash = self._hash_magic_token(token)
now = datetime.now(timezone.utc)
stmt = (
select(EmailVerificationCode)
.where(
EmailVerificationCode.magic_token_hash == token_hash,
EmailVerificationCode.purpose == purpose,
EmailVerificationCode.target_user_id == target_user_id,
EmailVerificationCode.status == "active",
EmailVerificationCode.consumed_at.is_(None),
)
.limit(1)
)
result = await session.execute(stmt)
record = result.scalar_one_or_none()
if not record:
return EmailMagicVerifyResult(ok=False, error="invalid_token")
expires_at = record.expires_at
if expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=timezone.utc)
if expires_at < now:
return EmailMagicVerifyResult(ok=False, error="expired_token")
record.consumed_at = now
throttle_identifier = _email_throttle_identifier(
record.email,
purpose,
target_user_id,
)
await security_dal.clear_throttle_state(
session,
scope=security_dal.EMAIL_CODE_VERIFY_SCOPE,
identifier=throttle_identifier,
)
await session.flush()
return EmailMagicVerifyResult(
ok=True,
email=record.email,
purpose=record.purpose,
target_user_id=record.target_user_id,
)
async def _send_code_email(
self,
*,
email: str,
code: str,
language_code: str,
magic_link: Optional[str] = None,
) -> None:
await asyncio.to_thread(
self._send_code_email_sync,
email=email,
code=code,
language_code=language_code,
magic_link=magic_link,
)
async def send_custom_email(
self,
*,
email: str,
subject: str,
body: str,
html_body: Optional[str] = None,
) -> None:
await asyncio.to_thread(
self._send_custom_email_sync,
email=email,
subject=subject,
body=body,
html_body=html_body,
)
async def send_rendered_email(
self,
*,
email: str,
content: EmailContent,
) -> None:
await self.send_custom_email(
email=email,
subject=content.subject,
body=content.text,
html_body=content.html,
)
def _send_code_email_sync(
self,
*,
email: str,
code: str,
language_code: str,
magic_link: Optional[str] = None,
) -> None:
content = render_login_code(
self.settings,
code=code,
language_code=language_code,
magic_link=magic_link,
)
message = EmailMessage()
message["Subject"] = content.subject
message["From"] = formataddr(
(
self.settings.SMTP_FROM_NAME or self.settings.WEBAPP_TITLE,
self.settings.SMTP_FROM_EMAIL or "",
)
)
message["To"] = email
message.set_content(content.text)
message.add_alternative(content.html, subtype="html")
context = ssl.create_default_context()
smtp_host = self.settings.SMTP_HOST
timeout = max(5, int(self.settings.SMTP_TIMEOUT_SECONDS))
attempts = self._smtp_attempts()
last_error: Optional[BaseException] = None
for attempt_number, attempt in enumerate(attempts, start=1):
try:
self._send_message_via_smtp(
message=message,
smtp_host=smtp_host,
smtp_port=attempt.port,
timeout=timeout,
context=context,
use_ssl=attempt.use_ssl,
starttls=attempt.starttls,
)
logger.info(
"Email verification code sent to %s via %s:%s",
email,
smtp_host,
attempt.port,
)
return
except (OSError, smtplib.SMTPException, TimeoutError) as exc:
last_error = exc
log_level = logging.WARNING if attempt_number < len(attempts) else logging.ERROR
logger.log(
log_level,
"SMTP send attempt %s/%s failed via %s:%s (ssl=%s, starttls=%s): %s",
attempt_number,
len(attempts),
smtp_host,
attempt.port,
attempt.use_ssl,
attempt.starttls,
exc,
)
if last_error:
raise last_error
def _send_custom_email_sync(
self,
*,
email: str,
subject: str,
body: str,
html_body: Optional[str] = None,
) -> None:
message = EmailMessage()
message["Subject"] = subject
message["From"] = formataddr(
(
self.settings.SMTP_FROM_NAME or self.settings.WEBAPP_TITLE,
self.settings.SMTP_FROM_EMAIL or "",
)
)
message["To"] = email
message.set_content(body)
if html_body:
message.add_alternative(html_body, subtype="html")
context = ssl.create_default_context()
smtp_host = self.settings.SMTP_HOST
timeout = max(5, int(self.settings.SMTP_TIMEOUT_SECONDS))
attempts = self._smtp_attempts()
last_error: Optional[BaseException] = None
for attempt_number, attempt in enumerate(attempts, start=1):
try:
self._send_message_via_smtp(
message=message,
smtp_host=smtp_host,
smtp_port=attempt.port,
timeout=timeout,
context=context,
use_ssl=attempt.use_ssl,
starttls=attempt.starttls,
)
logger.info(
"Custom email sent to %s via %s:%s",
email,
smtp_host,
attempt.port,
)
return
except (OSError, smtplib.SMTPException, TimeoutError) as exc:
last_error = exc
log_level = logging.WARNING if attempt_number < len(attempts) else logging.ERROR
logger.log(
log_level,
"SMTP send attempt %s/%s failed for custom email via %s:%s (ssl=%s, starttls=%s): %s",
attempt_number,
len(attempts),
smtp_host,
attempt.port,
attempt.use_ssl,
attempt.starttls,
exc,
)
if last_error:
raise last_error
def _send_message_via_smtp(
self,
*,
message: EmailMessage,
smtp_host: str,
smtp_port: int,
timeout: int,
context: ssl.SSLContext,
use_ssl: bool,
starttls: bool,
) -> None:
if use_ssl:
with smtplib.SMTP_SSL(
smtp_host,
smtp_port,
context=context,
timeout=timeout,
) as smtp:
smtp.ehlo()
smtp.login(self.settings.SMTP_USERNAME, self.settings.SMTP_PASSWORD)
smtp.send_message(message)
return
with smtplib.SMTP(smtp_host, smtp_port, timeout=timeout) as smtp:
smtp.ehlo()
if starttls:
smtp.starttls(context=context)
smtp.ehlo()
smtp.login(self.settings.SMTP_USERNAME, self.settings.SMTP_PASSWORD)
smtp.send_message(message)
-484
View File
@@ -1,484 +0,0 @@
"""Branded HTML email templates that mirror the subscription Mini App look.
The web app uses a dark theme with a configurable accent colour
(`WEBAPP_PRIMARY_COLOR`) and an optional logo (`WEBAPP_LOGO_URL`). The same
accent + logo are reused here so emails feel like part of the product. All
copy goes through the shared `JsonI18n` instance so translations live in
``locales/<lang>.json`` next to the rest of the bot strings.
"""
from __future__ import annotations
import html
import re
from dataclasses import dataclass
from typing import Optional, Sequence, Tuple
from urllib.parse import urlsplit
from bot.middlewares.i18n import JsonI18n, get_i18n_instance
from config.settings import Settings
_BG = "#05070a"
_CARD_BG = "#0e1116"
_BORDER = "#1a1f27"
_TEXT = "#e6e9ef"
_TEXT_MUTED = "#9aa3b2"
_TEXT_DIM = "#5d6573"
_DEFAULT_ACCENT = "#00fe7a"
_HEX_RE = re.compile(r"^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$")
@dataclass(frozen=True)
class EmailContent:
subject: str
text: str
html: str
def _safe_color(value: Optional[str]) -> str:
if not value:
return _DEFAULT_ACCENT
candidate = value.strip()
if _HEX_RE.match(candidate):
return candidate
return _DEFAULT_ACCENT
def _public_logo_url(settings: Settings) -> Optional[str]:
"""Email recipients can't reach the in-app /webapp-logo proxy, so the
raw https URL from the env is used directly. Anything else is dropped."""
raw = (settings.WEBAPP_LOGO_URL or "").strip()
if not raw:
return None
parsed = urlsplit(raw)
if parsed.scheme != "https" or not parsed.hostname:
return None
return raw
def _brand_title(settings: Settings) -> str:
title = (settings.WEBAPP_TITLE or "").strip()
return title or "Subscription"
def _normalize_lang(language_code: Optional[str], settings: Settings) -> str:
return (language_code or settings.DEFAULT_LANGUAGE or "ru").split("-")[0]
def _resolve_i18n(i18n: Optional[JsonI18n]) -> JsonI18n:
return i18n or get_i18n_instance()
def _t_html(i18n: JsonI18n, lang: str, key: str, **kwargs) -> str:
"""Translate for HTML context: format args are HTML-escaped, the
translated template itself is treated as already-safe HTML (locale files
are author-controlled and may include simple inline tags like <strong>)."""
safe_kwargs = {k: html.escape(str(v)) for k, v in kwargs.items()}
return i18n.gettext(lang, key, **safe_kwargs)
def _t_text(i18n: JsonI18n, lang: str, key: str, **kwargs) -> str:
return i18n.gettext(lang, key, **kwargs)
def _layout(
*,
settings: Settings,
preheader: str,
heading: str,
intro_html: str,
body_html: str,
footer_html: str,
) -> str:
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
brand_title = html.escape(_brand_title(settings))
logo_url = _public_logo_url(settings)
logo_block = ""
if logo_url:
logo_block = (
f'<img src="{html.escape(logo_url, quote=True)}" width="64" height="64" '
f'alt="" style="display:block;border:0;outline:none;text-decoration:none;'
f'border-radius:16px;background:{_CARD_BG};">'
)
return f"""<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="dark">
<meta name="supported-color-schemes" content="dark">
<title>{html.escape(heading)}</title>
</head>
<body style="margin:0;padding:0;background:{_BG};font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:{_TEXT};">
<div style="display:none;max-height:0;overflow:hidden;opacity:0;color:transparent;mso-hide:all;">{html.escape(preheader)}</div>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:{_BG};">
<tr>
<td align="center" style="padding:32px 16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="max-width:480px;">
<tr>
<td align="center" style="padding-bottom:24px;">
{logo_block}
<div style="margin-top:14px;font-family:'JetBrains Mono','SFMono-Regular',Menlo,Consolas,monospace;font-weight:800;font-size:22px;line-height:1.05;color:{accent};letter-spacing:0;">{brand_title}</div>
</td>
</tr>
<tr>
<td style="background:{_CARD_BG};border:1px solid {_BORDER};border-radius:18px;padding:28px;">
<h1 style="margin:0 0 10px 0;font-size:20px;line-height:1.25;font-weight:700;color:#ffffff;">{html.escape(heading)}</h1>
<div style="margin:0 0 20px 0;font-size:14px;line-height:1.55;color:{_TEXT_MUTED};">{intro_html}</div>
{body_html}
</td>
</tr>
<tr>
<td align="center" style="padding-top:20px;">
<div style="font-size:11px;line-height:1.55;color:{_TEXT_DIM};">{footer_html}</div>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
"""
def _info_rows_html(rows: Sequence[Tuple[str, str]]) -> str:
if not rows:
return ""
last = len(rows) - 1
cells = []
for index, (label, value) in enumerate(rows):
border = "" if index == last else f"border-bottom:1px solid {_BORDER};"
cells.append(
f'<tr>'
f'<td style="padding:11px 0;{border}font-size:12px;color:{_TEXT_DIM};text-transform:uppercase;letter-spacing:0.04em;">{html.escape(label)}</td>'
f'<td align="right" style="padding:11px 0;{border}font-family:\'JetBrains Mono\',\'SFMono-Regular\',Menlo,Consolas,monospace;font-size:14px;font-weight:600;color:{_TEXT};">{html.escape(value)}</td>'
f'</tr>'
)
return (
f'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" '
f'style="margin:0 0 16px 0;background:{_BG};border:1px solid {_BORDER};border-radius:14px;padding:6px 16px;">'
+ "".join(cells)
+ "</table>"
)
def _cta_button_html(*, label: str, url: str, accent: str) -> str:
safe_label = html.escape(label)
safe_url = html.escape(url, quote=True)
# Accent green is light, so contrast text is dark; works for the default and similar light accents.
return (
f'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" '
f'style="width:100%;margin:22px 0 18px 0;">'
f'<tr><td align="center" bgcolor="{accent}" style="background:{accent};border-radius:12px;">'
f'<a href="{safe_url}" target="_blank" rel="noopener" '
f'style="display:block;width:100%;box-sizing:border-box;padding:15px 22px;'
f'font-family:-apple-system,BlinkMacSystemFont,\'Segoe UI\',Roboto,Helvetica,Arial,sans-serif;'
f'font-size:15px;font-weight:700;color:#05070a;text-decoration:none;letter-spacing:0.02em;text-align:center;">{safe_label}</a>'
f'</td></tr></table>'
)
def _format_amount(amount: float, currency: str) -> str:
rounded = round(float(amount), 2)
if rounded.is_integer():
body = f"{int(rounded)}"
else:
body = f"{rounded:.2f}"
suffix = (currency or "").strip()
return f"{body} {suffix}".strip()
def _format_traffic(traffic_gb: Optional[float]) -> str:
if traffic_gb is None:
return ""
value = float(traffic_gb)
return str(int(value)) if value.is_integer() else f"{value:g}"
def _format_minutes(seconds: int) -> int:
return max(1, int(seconds) // 60)
def render_login_code(
settings: Settings,
*,
code: str,
language_code: Optional[str],
magic_link: Optional[str] = None,
i18n: Optional[JsonI18n] = None,
) -> EmailContent:
i18n = _resolve_i18n(i18n)
lang = _normalize_lang(language_code, settings)
minutes = _format_minutes(settings.EMAIL_CODE_TTL_SECONDS)
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
brand = _brand_title(settings)
safe_magic_link = (magic_link or "").strip()
subject = _t_text(i18n, lang, "email_login_code_subject", code=code)
preheader = _t_text(i18n, lang, "email_login_code_preheader", minutes=minutes)
heading = _t_text(i18n, lang, "email_login_code_heading")
intro = _t_text(i18n, lang, "email_login_code_intro")
expiry_html = _t_html(i18n, lang, "email_login_code_expiry_html", minutes=minutes)
security = _t_text(i18n, lang, "email_login_code_security")
footer = _t_html(i18n, lang, "email_footer_auto", brand=brand)
text_lines = [_t_text(i18n, lang, "email_login_code_text", code=code, minutes=minutes)]
if safe_magic_link:
text_lines.append(
_t_text(i18n, lang, "email_login_code_text_magic", url=safe_magic_link)
)
code_block = (
f'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:0 0 18px 0;">'
f'<tr><td align="center" style="background:{_BG};border:1px solid {_BORDER};border-radius:14px;padding:22px 16px;">'
f'<div style="font-family:\'JetBrains Mono\',\'SFMono-Regular\',Menlo,Consolas,monospace;font-size:36px;line-height:1;font-weight:700;letter-spacing:10px;color:{accent};">'
f'{html.escape(code)}'
f'</div></td></tr></table>'
)
magic_block = ""
if safe_magic_link:
cta_label = _t_text(i18n, lang, "email_login_code_magic_cta")
divider_label = _t_text(i18n, lang, "email_login_code_magic_or")
magic_intro = _t_text(i18n, lang, "email_login_code_magic_intro")
magic_hint = _t_text(i18n, lang, "email_login_code_magic_hint")
divider_html = (
f'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:4px 0 14px 0;">'
f'<tr>'
f'<td width="40%" style="border-bottom:1px solid {_BORDER};font-size:0;line-height:0;">&nbsp;</td>'
f'<td align="center" style="padding:0 10px;font-size:11px;letter-spacing:0.08em;text-transform:uppercase;color:{_TEXT_DIM};white-space:nowrap;">{html.escape(divider_label)}</td>'
f'<td width="40%" style="border-bottom:1px solid {_BORDER};font-size:0;line-height:0;">&nbsp;</td>'
f'</tr></table>'
)
magic_block = (
divider_html
+ f'<p style="margin:0 0 4px 0;font-size:13px;line-height:1.55;color:{_TEXT_MUTED};text-align:center;">{html.escape(magic_intro)}</p>'
+ _cta_button_html(label=cta_label, url=safe_magic_link, accent=accent)
+ f'<p style="margin:0 0 6px 0;font-size:12px;line-height:1.55;color:{_TEXT_DIM};text-align:center;">{html.escape(magic_hint)}</p>'
)
body_html = (
code_block
+ f'<p style="margin:0 0 8px 0;font-size:13px;line-height:1.55;color:{_TEXT_MUTED};">{expiry_html}</p>'
+ f'<p style="margin:0 0 4px 0;font-size:12px;line-height:1.55;color:{_TEXT_DIM};">{html.escape(security)}</p>'
+ magic_block
)
rendered = _layout(
settings=settings,
preheader=preheader,
heading=heading,
intro_html=html.escape(intro),
body_html=body_html,
footer_html=footer,
)
return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered)
def render_account_merged(
settings: Settings,
*,
language_code: Optional[str],
primary_user_id: Optional[int],
removed_user_id: Optional[int],
final_end_date_text: str,
i18n: Optional[JsonI18n] = None,
) -> EmailContent:
i18n = _resolve_i18n(i18n)
lang = _normalize_lang(language_code, settings)
brand = _brand_title(settings)
primary = "" if primary_user_id is None else f"#{primary_user_id}"
removed = "" if removed_user_id is None else f"#{removed_user_id}"
end_date = final_end_date_text or ""
subject = _t_text(i18n, lang, "email_account_merged_subject")
preheader = _t_text(i18n, lang, "email_account_merged_preheader")
heading = _t_text(i18n, lang, "email_account_merged_heading")
intro = _t_text(i18n, lang, "email_account_merged_intro")
note = _t_text(i18n, lang, "email_account_merged_note")
footer = _t_html(i18n, lang, "email_footer_auto", brand=brand)
text = _t_text(
i18n,
lang,
"email_account_merged_text",
primary=primary,
removed=removed,
end_date=end_date,
)
rows = [
(_t_text(i18n, lang, "email_account_merged_row_kept"), primary),
(_t_text(i18n, lang, "email_account_merged_row_removed"), removed),
(_t_text(i18n, lang, "email_account_merged_row_end_date"), end_date),
]
body_html = (
_info_rows_html(rows)
+ f'<p style="margin:0;font-size:12px;line-height:1.55;color:{_TEXT_DIM};">{html.escape(note)}</p>'
)
rendered = _layout(
settings=settings,
preheader=preheader,
heading=heading,
intro_html=html.escape(intro),
body_html=body_html,
footer_html=footer,
)
return EmailContent(subject=subject, text=text, html=rendered)
def render_payment_success(
settings: Settings,
*,
language_code: Optional[str],
sale_mode: str,
months: int,
traffic_gb: Optional[float],
amount: float,
currency: str,
end_date_text: str,
dashboard_url: Optional[str],
provider_label: Optional[str] = None,
i18n: Optional[JsonI18n] = None,
) -> EmailContent:
i18n = _resolve_i18n(i18n)
lang = _normalize_lang(language_code, settings)
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
brand = _brand_title(settings)
is_traffic = sale_mode == "traffic"
amount_text = _format_amount(amount, currency)
safe_dashboard_url = (dashboard_url or "").strip()
end_date = end_date_text or ""
traffic_label = _format_traffic(traffic_gb)
subject = _t_text(i18n, lang, "email_payment_success_subject")
preheader = _t_text(i18n, lang, "email_payment_success_preheader")
heading = _t_text(i18n, lang, "email_payment_success_heading")
footer_note = _t_text(i18n, lang, "email_payment_success_footer_note")
footer = _t_html(i18n, lang, "email_footer_auto", brand=brand)
cta_label = _t_text(i18n, lang, "email_payment_success_cta")
if is_traffic:
intro = _t_text(i18n, lang, "email_payment_success_intro_traffic", traffic_gb=traffic_label)
period_label = _t_text(i18n, lang, "email_payment_success_row_traffic")
period_value = _t_text(i18n, lang, "email_payment_success_traffic_value", traffic_gb=traffic_label)
text = _t_text(
i18n,
lang,
"email_payment_success_text_traffic",
amount=amount_text,
traffic_gb=traffic_label,
end_date=end_date,
)
else:
months_int = int(months or 0)
intro = _t_text(i18n, lang, "email_payment_success_intro_subscription", months=months_int)
period_label = _t_text(i18n, lang, "email_payment_success_row_period")
period_value = _t_text(
i18n,
lang,
"email_payment_success_period_value",
months=months_int,
)
text = _t_text(
i18n,
lang,
"email_payment_success_text_subscription",
amount=amount_text,
months=months_int,
end_date=end_date,
)
rows: list[Tuple[str, str]] = [
(period_label, period_value),
(_t_text(i18n, lang, "email_payment_success_row_amount"), amount_text),
(_t_text(i18n, lang, "email_payment_success_row_end_date"), end_date),
]
if provider_label:
rows.append((_t_text(i18n, lang, "email_payment_success_row_method"), provider_label))
text_lines = [text]
if safe_dashboard_url:
text_lines.append(_t_text(i18n, lang, "email_payment_success_text_dashboard", url=safe_dashboard_url))
body_parts = [_info_rows_html(rows)]
if safe_dashboard_url:
body_parts.append(_cta_button_html(label=cta_label, url=safe_dashboard_url, accent=accent))
body_parts.append(
f'<p style="margin:6px 0 0 0;font-size:12px;line-height:1.55;color:{_TEXT_DIM};">{html.escape(footer_note)}</p>'
)
rendered = _layout(
settings=settings,
preheader=preheader,
heading=heading,
intro_html=html.escape(intro),
body_html="".join(body_parts),
footer_html=footer,
)
return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered)
def render_subscription_expiring(
settings: Settings,
*,
language_code: Optional[str],
days_left: int,
end_date_text: str,
dashboard_url: Optional[str],
i18n: Optional[JsonI18n] = None,
) -> EmailContent:
i18n = _resolve_i18n(i18n)
lang = _normalize_lang(language_code, settings)
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
brand = _brand_title(settings)
safe_dashboard_url = (dashboard_url or "").strip()
days = max(0, int(days_left))
end_date = end_date_text or ""
if days == 0:
suffix = "today"
elif days == 1:
suffix = "tomorrow"
else:
suffix = "days"
subject = _t_text(i18n, lang, f"email_subscription_expiring_subject_{suffix}", days=days)
heading = _t_text(i18n, lang, f"email_subscription_expiring_heading_{suffix}", days=days)
preheader = _t_text(i18n, lang, f"email_subscription_expiring_preheader_{suffix}", days=days)
intro = _t_text(i18n, lang, f"email_subscription_expiring_intro_{suffix}", days=days)
note = _t_text(i18n, lang, "email_subscription_expiring_note")
footer = _t_html(i18n, lang, "email_footer_auto", brand=brand)
cta_label = _t_text(i18n, lang, "email_subscription_expiring_cta")
rows = [
(_t_text(i18n, lang, "email_subscription_expiring_row_days_left"), str(days)),
(_t_text(i18n, lang, "email_subscription_expiring_row_end_date"), end_date),
]
text_lines = [
_t_text(i18n, lang, "email_subscription_expiring_text", heading=heading, end_date=end_date),
]
if safe_dashboard_url:
text_lines.append(
_t_text(i18n, lang, "email_subscription_expiring_text_renew", url=safe_dashboard_url)
)
body_parts = [_info_rows_html(rows)]
if safe_dashboard_url:
body_parts.append(_cta_button_html(label=cta_label, url=safe_dashboard_url, accent=accent))
body_parts.append(
f'<p style="margin:6px 0 0 0;font-size:12px;line-height:1.55;color:{_TEXT_DIM};">{html.escape(note)}</p>'
)
rendered = _layout(
settings=settings,
preheader=preheader,
heading=heading,
intro_html=html.escape(intro),
body_html="".join(body_parts),
footer_html=footer,
)
return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered)
+139 -44
View File
@@ -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")
+39 -124
View File
@@ -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,
@@ -212,118 +212,7 @@ class NotificationService:
# Send to log channel
profile_keyboard = self._build_profile_keyboard(_, user_id, referred_by_id)
await self._send_to_log_channel(message, reply_markup=profile_keyboard)
async def notify_new_email_user_registration(
self,
user_id: int,
email: str,
referred_by_id: Optional[int] = None,
):
"""Send notification about new user registration via email (Web App)."""
if not self.settings.LOG_NEW_USERS:
return
admin_lang = self.settings.DEFAULT_LANGUAGE
_ = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw) if self.i18n else k
referral_text = ""
if referred_by_id:
referrer_link = hd.link(str(referred_by_id), f"tg://user?id={referred_by_id}")
referral_text = _(
"log_referral_suffix",
referrer_link=referrer_link,
)
message = _(
"log_new_email_user_registration",
user_id=user_id,
email=hd.quote(email),
referral_text=referral_text,
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
)
# Email users have a synthetic (negative) user_id with no Telegram profile,
# so we only attach the referrer button when a real referrer is present.
reply_markup: Optional[InlineKeyboardMarkup] = None
if referred_by_id and referred_by_id > 0:
reply_markup = InlineKeyboardMarkup(inline_keyboard=[[
InlineKeyboardButton(
text=_("log_open_referrer_profile_button"),
url=f"tg://user?id={referred_by_id}",
)
]])
await self._send_to_log_channel(message, reply_markup=reply_markup)
async def notify_account_email_linked(
self,
user_id: int,
email: str,
telegram_id: Optional[int] = None,
username: Optional[str] = None,
first_name: Optional[str] = None,
):
"""Send notification when an email is linked to a Telegram-created account."""
if not self.settings.LOG_NEW_USERS:
return
admin_lang = self.settings.DEFAULT_LANGUAGE
_ = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw) if self.i18n else k
user_display = self._format_user_display(
user_id=telegram_id or user_id,
username=username,
first_name=first_name,
)
message = _(
"log_account_email_linked",
user_id=user_id,
telegram_id=telegram_id or user_id,
user_display=user_display,
email=hd.quote(email),
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
)
reply_markup: Optional[InlineKeyboardMarkup] = None
if telegram_id and telegram_id > 0:
reply_markup = self._build_profile_keyboard(_, telegram_id)
await self._send_to_log_channel(message, reply_markup=reply_markup)
async def notify_account_telegram_linked(
self,
user_id: int,
email: Optional[str],
telegram_id: int,
username: Optional[str] = None,
first_name: Optional[str] = None,
):
"""Send notification when Telegram is linked to an email-created account."""
if not self.settings.LOG_NEW_USERS:
return
admin_lang = self.settings.DEFAULT_LANGUAGE
_ = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw) if self.i18n else k
user_display = self._format_user_display(
user_id=telegram_id,
username=username,
first_name=first_name,
)
message = _(
"log_account_telegram_linked",
user_id=user_id,
telegram_id=telegram_id,
user_display=user_display,
email=hd.quote(email or ""),
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
)
profile_keyboard = self._build_profile_keyboard(_, telegram_id)
await self._send_to_log_channel(message, reply_markup=profile_keyboard)
async def notify_payment_received(self, user_id: int, amount: float, currency: str,
months: int, payment_provider: str,
username: Optional[str] = None,
@@ -382,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,
@@ -398,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)
+39 -8
View File
@@ -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
+9 -50
View File
@@ -9,8 +9,6 @@ from sqlalchemy.orm import sessionmaker
from typing import Optional
from config.settings import Settings
from .panel_api_service import PanelApiService
from .email_auth_service import EmailAuthService
from .email_templates import render_subscription_expiring
from bot.middlewares.i18n import JsonI18n
from bot.keyboards.inline.user_keyboards import get_subscribe_only_markup, get_autorenew_cancel_keyboard
from db.dal import user_dal
@@ -28,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,
@@ -46,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")
@@ -60,13 +54,9 @@ 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}"
user_email = (db_user.email or "").strip() if db_user else ""
markup = get_subscribe_only_markup(lang, self.i18n)
@@ -79,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)
@@ -99,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,
@@ -125,13 +115,6 @@ class PanelWebhookService:
user_name=first_name,
end_date=user_payload.get("expireAt", "")[:10],
)
if days_left == 3 and user_email:
await self._send_subscription_expiring_email(
recipient=user_email,
lang=lang,
days_left=days_left,
end_date_text=user_payload.get("expireAt", "")[:10],
)
elif event_name == "user.expired":
if self.settings.SUBSCRIPTION_NOTIFY_ON_EXPIRE:
await self._send_message(
@@ -152,44 +135,20 @@ class PanelWebhookService:
end_date=user_payload.get("expireAt", "")[:10],
)
async def _send_subscription_expiring_email(
self,
*,
recipient: str,
lang: str,
days_left: int,
end_date_text: str,
) -> None:
"""Best-effort branded reminder; silently no-ops without SMTP config."""
if not self.settings.email_auth_configured:
return
try:
content = render_subscription_expiring(
self.settings,
language_code=lang,
days_left=days_left,
end_date_text=end_date_text,
dashboard_url=(self.settings.SUBSCRIPTION_MINI_APP_URL or "").strip() or None,
)
email_service = EmailAuthService(self.settings)
await email_service.send_rendered_email(email=recipient, content=content)
except Exception:
logging.exception("Failed to send subscription-expiring email to %s", recipient)
async def handle_webhook(self, raw_body: bytes, signature_header: Optional[str]) -> web.Response:
if 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
View File
@@ -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)
+406 -43
View File
@@ -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
+10
View File
@@ -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:
+125 -21
View File
@@ -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
View File
@@ -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:
+105 -201
View File
@@ -5,15 +5,13 @@ 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
from config.settings import Settings
from .panel_api_service import PanelApiService
from .email_auth_service import EmailAuthService
from .email_templates import render_payment_success
class SubscriptionService:
@@ -57,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
@@ -98,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]:
@@ -147,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:
@@ -204,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,
@@ -230,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,
@@ -278,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:
@@ -344,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(
@@ -427,7 +358,6 @@ class SubscriptionService:
"is_active": True,
"status_from_panel": "TRIAL",
"traffic_limit_bytes": self.settings.trial_traffic_limit_bytes,
"traffic_limit_strategy": self.settings.TRIAL_TRAFFIC_STRATEGY,
"auto_renew_enabled": False,
}
try:
@@ -449,10 +379,16 @@ class SubscriptionService:
expire_at=end_date,
status="ACTIVE",
traffic_limit_bytes=self.settings.trial_traffic_limit_bytes,
traffic_limit_strategy=self.settings.TRIAL_TRAFFIC_STRATEGY,
)
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
@@ -508,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
@@ -562,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
@@ -578,16 +522,6 @@ class SubscriptionService:
final_subscription_url = updated_panel_user.get("subscriptionUrl")
final_panel_short_uuid = updated_panel_user.get("shortUuid", panel_short_uuid)
await self._send_payment_success_email(
db_user=db_user,
sale_mode="traffic",
months=0,
traffic_gb=float(traffic_gb),
payment_amount=payment_amount,
end_date=None,
provider=provider,
)
return {
"subscription_id": new_or_updated_sub.subscription_id,
"end_date": final_end_date,
@@ -670,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(
@@ -681,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(
@@ -736,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
@@ -750,15 +691,21 @@ class SubscriptionService:
final_subscription_url = updated_panel_user.get("subscriptionUrl")
final_panel_short_uuid = updated_panel_user.get("shortUuid", panel_short_uuid)
await self._send_payment_success_email(
db_user=db_user,
sale_mode="subscription",
months=months_int,
traffic_gb=None,
payment_amount=payment_amount,
end_date=final_end_date,
provider=provider,
)
# 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,
@@ -911,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")
@@ -979,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")
@@ -992,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,
@@ -1071,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,
@@ -1088,64 +1039,17 @@ 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
_PROVIDER_LABELS = {
"yookassa": "YooKassa",
"freekassa": "FreeKassa",
"platega": "Platega",
"severpay": "SeverPay",
"cryptopay": "Crypto Pay",
"crypto_pay": "Crypto Pay",
"stars": "Telegram Stars",
"tribute": "Tribute",
}
async def _send_payment_success_email(
self,
*,
db_user: User,
sale_mode: str,
months: int,
traffic_gb: Optional[float],
payment_amount: float,
end_date: Optional[datetime],
provider: str,
) -> None:
"""Best-effort branded email confirming the payment. No-op if SMTP or
the user's email aren't set. Failures are logged and swallowed so the
payment flow is never blocked by mail delivery."""
if not self.settings.email_auth_configured:
return
recipient = (db_user.email or "").strip() if db_user else ""
if not recipient:
return
end_date_text = end_date.strftime("%Y-%m-%d") if end_date else ""
provider_label = self._PROVIDER_LABELS.get((provider or "").lower())
dashboard_url = (self.settings.SUBSCRIPTION_MINI_APP_URL or "").strip() or None
try:
content = render_payment_success(
self.settings,
language_code=db_user.language_code or self.settings.DEFAULT_LANGUAGE,
sale_mode=sale_mode,
months=int(months or 0),
traffic_gb=traffic_gb,
amount=float(payment_amount or 0),
currency=self.settings.DEFAULT_CURRENCY_SYMBOL,
end_date_text=end_date_text,
dashboard_url=dashboard_url,
provider_label=provider_label,
)
email_service = EmailAuthService(self.settings)
await email_service.send_rendered_email(email=recipient, content=content)
except Exception:
logging.exception(
"Failed to send payment success email to user %s", db_user.user_id
)
async def update_last_notification_sent(
self, session: AsyncSession, user_id: int, subscription_end_date: datetime
):
+21 -19
View File
@@ -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
+2
View File
@@ -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()
-52
View File
@@ -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
+2 -2
View File
@@ -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
-73
View File
@@ -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 -370
View File
@@ -1,101 +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]
logo_emoji: str
session_ttl_seconds: int
session_secret: str
webhook_secret_token: str
auth_max_age_seconds: int
login_token_ttl_seconds: int
server_host: str
server_port: int
enabled: bool
trusted_proxies: List[str]
from pydantic import Field, ValidationError, computed_field, field_validator, model_validator
from typing import Optional, List, Dict, Any
class Settings(BaseSettings):
@@ -105,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")
@@ -169,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
@@ -184,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)
@@ -214,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
@@ -231,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)",
@@ -289,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,
@@ -314,56 +208,12 @@ class Settings(BaseSettings):
TRIAL_ENABLED: bool = Field(default=True)
TRIAL_DURATION_DAYS: int = Field(default=3)
TRIAL_TRAFFIC_LIMIT_GB: Optional[float] = Field(default=5.0)
TRIAL_TRAFFIC_STRATEGY: str = Field(default="NO_RESET")
CRYPT4_ENABLED: bool = Field(default=False, description="Enable happ crypt4 encryption for subscription URLs")
CRYPT4_REDIRECT_URL: Optional[str] = Field(default=None, description="Base redirect URL used for the connect button when crypt4 is enabled")
WEB_SERVER_HOST: str = Field(default="0.0.0.0")
WEB_SERVER_PORT: int = Field(default=8080)
WEBAPP_ENABLED: bool = Field(
default=True,
description="Run the subscription Mini App in the same container on a separate port.",
)
WEBAPP_SERVER_HOST: str = Field(default="0.0.0.0")
WEBAPP_SERVER_PORT: int = Field(default=8081)
WEBAPP_TITLE: str = Field(default="Моя подписка")
WEBAPP_PRIMARY_COLOR: str = Field(default="#00fe7a")
WEBAPP_LOGO_URL: Optional[str] = Field(default=None)
WEBAPP_LOGO_EMOJI: str = Field(default="🫥")
WEBAPP_SESSION_SECRET: str = Field(default_factory=lambda: secrets.token_urlsafe(32))
WEBHOOK_SECRET_TOKEN: str = Field(default_factory=lambda: secrets.token_urlsafe(32))
WEBAPP_SESSION_TTL_SECONDS: int = Field(default=24 * 60 * 60)
WEBAPP_AUTH_MAX_AGE_SECONDS: int = Field(default=24 * 60 * 60)
WEBAPP_LOGIN_TOKEN_TTL_SECONDS: int = Field(default=10 * 60)
SMTP_HOST: str = Field(default="smtp-relay.brevo.com")
SMTP_PORT: int = Field(default=587)
SMTP_FALLBACK_PORTS: Optional[str] = Field(default="2525,465")
SMTP_TIMEOUT_SECONDS: int = Field(default=30)
SMTP_USERNAME: Optional[str] = Field(default=None)
SMTP_PASSWORD: Optional[str] = Field(default=None)
SMTP_FROM_EMAIL: Optional[str] = Field(default=None)
SMTP_FROM_NAME: Optional[str] = Field(default=None)
SMTP_STARTTLS: bool = Field(default=True)
SMTP_USE_SSL: bool = Field(default=False)
EMAIL_CODE_TTL_SECONDS: int = Field(default=10 * 60)
EMAIL_CODE_RESEND_SECONDS: int = Field(default=60)
EMAIL_CODE_MAX_ATTEMPTS: int = Field(default=5)
BRUTE_FORCE_MAX_FAILURES: int = Field(
default=5,
description="Maximum failed code attempts allowed within the throttle window before a temporary lockout is applied.",
)
BRUTE_FORCE_WINDOW_SECONDS: int = Field(
default=15 * 60,
description="Rolling window used to count failed email and promo code attempts.",
)
BRUTE_FORCE_LOCK_SECONDS: int = Field(
default=30 * 60,
description="Temporary lockout duration applied after too many failed code attempts.",
)
LOGS_PAGE_SIZE: int = Field(default=10)
SUBSCRIPTION_MINI_APP_URL: Optional[str] = Field(default=None)
@@ -391,103 +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,
logo_emoji=self.WEBAPP_LOGO_EMOJI,
session_ttl_seconds=self.WEBAPP_SESSION_TTL_SECONDS,
session_secret=self.WEBAPP_SESSION_SECRET,
webhook_secret_token=self.WEBHOOK_SECRET_TOKEN,
auth_max_age_seconds=self.WEBAPP_AUTH_MAX_AGE_SECONDS,
login_token_ttl_seconds=self.WEBAPP_LOGIN_TOKEN_TTL_SECONDS,
server_host=self.WEBAPP_SERVER_HOST,
server_port=self.WEBAPP_SERVER_PORT,
enabled=self.WEBAPP_ENABLED,
trusted_proxies=self.trusted_proxies,
)
@computed_field
@property
def ADMIN_IDS(self) -> List[int]:
@@ -545,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
@@ -779,8 +533,7 @@ class Settings(BaseSettings):
"""
default_order = [
"freekassa",
"platega_sbp",
"platega_crypto",
"platega",
"severpay",
"yookassa",
"stars",
@@ -788,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(
@@ -847,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):
@@ -857,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',
@@ -891,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
@@ -908,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):
@@ -923,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',
@@ -947,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(
+339
View File
@@ -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.")
-2
View File
@@ -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",
)
+163
View File
@@ -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
+12 -5
View File
@@ -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
View File
@@ -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)
+98 -9
View File
@@ -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)
-160
View File
@@ -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)
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
-425
View File
@@ -1,425 +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_0010_add_email_magic_token_hash(connection: Connection) -> None:
inspector = inspect(connection)
columns: Set[str] = {col["name"] for col in inspector.get_columns("email_verification_codes")}
if "magic_token_hash" not in columns:
connection.execute(
text(
"ALTER TABLE email_verification_codes ADD COLUMN magic_token_hash VARCHAR"
)
)
connection.execute(
text(
"""
CREATE INDEX IF NOT EXISTS ix_email_verification_codes_magic_token_hash
ON email_verification_codes (magic_token_hash)
"""
)
)
def _migration_0009_add_composite_indexes(connection: Connection) -> None:
connection.execute(
text(
"""
CREATE INDEX IF NOT EXISTS ix_subscriptions_is_active_end_date
ON subscriptions (is_active, end_date)
"""
)
)
connection.execute(
text(
"""
CREATE INDEX IF NOT EXISTS ix_subscriptions_user_id_is_active
ON subscriptions (user_id, is_active)
"""
)
)
connection.execute(
text(
"""
CREATE INDEX IF NOT EXISTS ix_payments_user_id_status
ON payments (user_id, status)
"""
)
)
MIGRATIONS: List[Migration] = [
Migration(
id="0001_add_channel_subscription_fields",
description="Add columns to track required channel subscription verification",
upgrade=_migration_0001_add_channel_subscription_fields,
),
Migration(
id="0002_add_referral_code",
description="Store short referral codes for users and backfill existing rows",
upgrade=_migration_0002_add_referral_code,
),
Migration(
id="0003_normalize_referral_codes",
description="Normalize referral codes to uppercase for consistent lookups",
upgrade=_migration_0003_normalize_referral_codes,
),
Migration(
id="0004_add_lifetime_used_traffic",
description="Store lifetime traffic usage for users",
upgrade=_migration_0004_add_lifetime_used_traffic,
),
Migration(
id="0005_add_email_auth_fields",
description="Add email login identities and verification codes",
upgrade=_migration_0005_add_email_auth_fields,
),
Migration(
id="0006_add_security_throttles",
description="Add generic lockout tracking for brute-force protection",
upgrade=_migration_0006_add_security_throttles,
),
Migration(
id="0007_add_telegram_photo_url",
description="Store Telegram profile photo URLs for linked users",
upgrade=_migration_0007_add_telegram_photo_url,
),
Migration(
id="0008_add_email_verification_code_status",
description="Track superseded email verification codes explicitly",
upgrade=_migration_0008_add_email_verification_code_status,
),
Migration(
id="0009_add_composite_indexes",
description="Add composite indexes for subscription and payment lookups",
upgrade=_migration_0009_add_composite_indexes,
),
Migration(
id="0010_add_email_magic_token_hash",
description="Store hashed magic-link tokens for email login deeplinks",
upgrade=_migration_0010_add_email_magic_token_hash,
),
]
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 -56
View File
@@ -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,52 +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)
magic_token_hash = Column(String, nullable=True, index=True)
purpose = Column(String, nullable=False, index=True)
target_user_id = Column(
BigInteger,
ForeignKey("users.user_id"),
nullable=True,
index=True,
)
expires_at = Column(DateTime(timezone=True), nullable=False, index=True)
consumed_at = Column(DateTime(timezone=True), nullable=True)
status = Column(String, nullable=False, default="active", index=True)
attempts = Column(Integer, nullable=False, default=0)
created_at = Column(DateTime(timezone=True), server_default=func.now())
target_user = relationship("User")
class SecurityThrottle(Base):
__tablename__ = "security_throttles"
throttle_id = Column(Integer, primary_key=True, autoincrement=True)
scope = Column(String(64), nullable=False, index=True)
identifier = Column(String(512), nullable=False, index=True)
failures = Column(Integer, nullable=False, default=0)
window_started_at = Column(DateTime(timezone=True), nullable=True)
locked_until = Column(DateTime(timezone=True), nullable=True, index=True)
last_attempt_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now(), nullable=True)
__table_args__ = (
UniqueConstraint("scope", "identifier", name="uq_security_throttles_scope_identifier"),
)
class Payment(Base):
__tablename__ = "payments"
__table_args__ = (
Index("ix_payments_user_id_status", "user_id", "status"),
)
payment_id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(BigInteger,
@@ -153,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)
@@ -207,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)
@@ -244,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"
-59
View File
@@ -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
+11 -12
View File
@@ -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
View File
@@ -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
-150
View File
@@ -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
```

Some files were not shown because too many files have changed in this diff Show More