Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d57c9be21 | ||
|
|
fde0f1b135 | ||
|
|
67df164498 | ||
|
|
48e666f605 | ||
|
|
3cb15fa254 | ||
|
|
2100949470 | ||
|
|
c2a475b062 | ||
|
|
585cbe8fb6 | ||
|
|
a243ddcac8 | ||
|
|
357d7caf26 | ||
|
|
2713b3bae5 | ||
|
|
2215ee8ad2 | ||
|
|
1b6acff6d4 | ||
|
|
25d9cc7446 | ||
|
|
e1e73ec736 | ||
|
|
cc21089f18 | ||
|
|
46120210f1 | ||
|
|
df43283971 | ||
|
|
a51b20ee54 | ||
|
|
0915022b52 | ||
|
|
11f5ec4a5b | ||
|
|
d187a79be6 | ||
|
|
7b05ad4cb1 | ||
|
|
79ac5cb797 | ||
|
|
c208541525 | ||
|
|
2b592e8abe | ||
|
|
43de4cc08b | ||
|
|
9a6ff6f636 | ||
|
|
8113908a98 | ||
|
|
81155c9151 | ||
|
|
7fa670c1e8 | ||
|
|
d94430c602 | ||
|
|
c6292a1c8e | ||
|
|
d7d8409919 | ||
|
|
8f603ad51c | ||
|
|
6f3b727cc7 | ||
|
|
b7a723a088 | ||
|
|
807e3dadd3 | ||
|
|
f11ec6a676 | ||
|
|
d83f64c5fe | ||
|
|
72eaed991a | ||
|
|
868c48ccb9 | ||
|
|
2b855ed11b | ||
|
|
2bc7bea29d | ||
|
|
b6a2bf2329 | ||
|
|
ba8be7c3f9 | ||
|
|
cb464059d9 | ||
|
|
a5aecb0f86 | ||
|
|
cf7f0eae51 | ||
|
|
850f135bf8 | ||
|
|
793412c472 | ||
|
|
a510ab1021 | ||
|
|
50cea15c92 | ||
|
|
6b5828ea51 | ||
|
|
257597ccb7 | ||
|
|
dba82cfb97 | ||
|
|
e0e2cde9a7 | ||
|
|
5b98e1a40c | ||
|
|
0f69823192 | ||
|
|
38f3b1ad74 | ||
|
|
9b4dab84ad | ||
|
|
c607b6ef85 | ||
|
|
09f0de78c6 | ||
|
|
6a3b4a6947 | ||
|
|
d6f707d386 | ||
|
|
dace4c2938 | ||
|
|
b69c2ab18d | ||
|
|
56fc88c10e | ||
|
|
e74f801aec | ||
|
|
acf5f060a6 | ||
|
|
fb2672732d | ||
|
|
f4730c3ceb | ||
|
|
e6b3a7c66f | ||
|
|
24cf193f3c | ||
|
|
85bf0fb6fd | ||
|
|
b576d04501 | ||
|
|
97268b9906 | ||
|
|
1091b7b846 | ||
|
|
f191c1813e | ||
|
|
e78f9bf291 | ||
|
|
5c74a08ed8 | ||
|
|
14950fd559 | ||
|
|
711b9a2487 | ||
|
|
6e7eb6acfd | ||
|
|
b42fae8772 | ||
|
|
f707662125 | ||
|
|
60c6e0e961 | ||
|
|
b23b75b72e | ||
|
|
c69f02f7c0 | ||
|
|
f22e359684 | ||
|
|
d2402fea77 | ||
|
|
9b8ddb39da | ||
|
|
459b655ae3 | ||
|
|
d81ab4137d | ||
|
|
cb6ae1e052 | ||
|
|
f4e2ae5fbd | ||
|
|
157c3a7c61 | ||
|
|
ef9ebc1918 | ||
|
|
87664a7735 | ||
|
|
3d58f60a4d | ||
|
|
a75a1f483c | ||
|
|
70d472e71c | ||
|
|
13a9e58e27 | ||
|
|
7219a6ac30 | ||
|
|
dbb27ee9ca | ||
|
|
4e58fda4a5 | ||
|
|
8eb5daada5 | ||
|
|
4c28d3868c | ||
|
|
08810eb2e0 | ||
|
|
b5d3b7b9c7 | ||
|
|
6330d57c60 | ||
|
|
149ff057a9 | ||
|
|
bb7641bb74 | ||
|
|
18f65ea493 | ||
|
|
5853b9da63 | ||
|
|
91cfe0baf3 | ||
|
|
194f1b9e49 | ||
|
|
df15cfd25e | ||
|
|
57e693fa37 | ||
|
|
60ea6fff0d | ||
|
|
3cee4b243a | ||
|
|
a42f80160b | ||
|
|
74548527f1 | ||
|
|
f8e3bee52a | ||
|
|
649528e165 | ||
|
|
859263dc2d | ||
|
|
a126c05365 | ||
|
|
d0c09f9e06 | ||
|
|
9f7171a5c3 | ||
|
|
990b08cfdc | ||
|
|
359a3c46a4 | ||
|
|
00ffec13d8 | ||
|
|
5c8099168d | ||
|
|
4675141974 | ||
|
|
67f31d62e4 | ||
|
|
c969d52ce1 | ||
|
|
3bd29511e1 | ||
|
|
0c31256fd1 | ||
|
|
04e45096be | ||
|
|
e537203209 | ||
|
|
283fc6bad4 | ||
|
|
a46e5a43d6 | ||
|
|
9cf5a93625 | ||
|
|
c5f41a261b | ||
|
|
74719669fb | ||
|
|
c8831718ed | ||
|
|
ad8f05e20b | ||
|
|
b53e6a2cc3 | ||
|
|
bcd95fb56c | ||
|
|
ce041758f1 | ||
|
|
b13883c039 | ||
|
|
fc48cd7a09 | ||
|
|
9688b0f773 | ||
|
|
52c4f83dd1 | ||
|
|
23aa3934e4 | ||
|
|
ebca408227 | ||
|
|
a5b408d204 | ||
|
|
aa97618165 | ||
|
|
973e97c54e | ||
|
|
4f6822f8e4 | ||
|
|
0f26c9bdba | ||
|
|
39ba57951c | ||
|
|
ffa61f673d | ||
|
|
f0bbe90f80 | ||
|
|
35d798a429 | ||
|
|
b34eb733b9 | ||
|
|
fcd456dce9 | ||
|
|
8246e9a590 | ||
|
|
14f244ab79 | ||
|
|
956ec5ea71 | ||
|
|
2ef420ff47 | ||
|
|
7de1cdf9d6 | ||
|
|
860c2298bd | ||
|
|
2eff9300d3 | ||
|
|
1bd09be005 | ||
|
|
ce571f6566 | ||
|
|
856129fce2 | ||
|
|
b67f659746 | ||
|
|
a102903814 | ||
|
|
7f6cca2f59 | ||
|
|
3e6ef9de32 | ||
|
|
9cf395c103 | ||
|
|
69429dce81 | ||
|
|
d3bfe774c7 | ||
|
|
e2df8a01c8 | ||
|
|
dbeee31081 | ||
|
|
aa5a68c32c | ||
|
|
a7b4863889 | ||
|
|
16a00efcc9 | ||
|
|
2779e62e43 | ||
|
|
d7fdcc25cd | ||
|
|
24c4bbc27e | ||
|
|
5b08ad5449 | ||
|
|
fa321426b8 | ||
|
|
a4839ffc63 | ||
|
|
0aa38d3806 | ||
|
|
c7b124ad4c | ||
|
|
b5b740611c | ||
|
|
68459a7075 | ||
|
|
0e84a480fc | ||
|
|
1ac368354d | ||
|
|
831e61cd99 | ||
|
|
981d63c53e |
@@ -0,0 +1,92 @@
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
.github
|
||||
.gitattributes
|
||||
LICENSE
|
||||
README.md
|
||||
|
||||
|
||||
# CI
|
||||
.codeclimate.yml
|
||||
.travis.yml
|
||||
.taskcluster.yml
|
||||
|
||||
# Docker
|
||||
docker-compose.yml
|
||||
Dockerfile
|
||||
.docker
|
||||
.dockerignore
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
**/__pycache__/
|
||||
**/*.py[cod]
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
env/
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.coverage
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
target/
|
||||
|
||||
# Virtual environment
|
||||
.env*
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
# PyCharm
|
||||
.idea
|
||||
|
||||
# Python mode for VIM
|
||||
.ropeproject
|
||||
**/.ropeproject
|
||||
|
||||
# Vim swap files
|
||||
**/*.swp
|
||||
|
||||
# VS Code
|
||||
.vscode/
|
||||
+77
-47
@@ -1,44 +1,55 @@
|
||||
# Telegram Bot Token and Admin IDs
|
||||
BOT_TOKEN=your_bot_token_here
|
||||
ADMIN_IDS=comma_separated_admin_ids
|
||||
BOT_TOKEN=your_bot_token_here # Telegram bot token
|
||||
ADMIN_IDS=comma_separated_admin_ids # Your telegram ID
|
||||
|
||||
POSTGRES_USER=postgres
|
||||
POSTGRES_PASSWORD=postgres
|
||||
POSTGRES_HOST=remnawave-tg-shop-db
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_DB=postgres
|
||||
# PostgreSQL Database Connection Settings
|
||||
POSTGRES_USER=postgres # Database user name
|
||||
POSTGRES_PASSWORD=postgres # Database password
|
||||
POSTGRES_HOST=remnawave-tg-shop-db # Database container name
|
||||
POSTGRES_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
|
||||
DEFAULT_LANGUAGE="ru" # or "en"
|
||||
DEFAULT_CURRENCY_SYMBOL="RUB" # e.g., RUB, USD, EUR
|
||||
|
||||
# External Links
|
||||
SUPPORT_LINK=https://t.me/your_support_link
|
||||
SERVER_STATUS_URL=https://status.yourdomain.tld/status/your_service
|
||||
TERMS_OF_SERVICE_URL=https://example.com/tos
|
||||
SUBSCRIPTION_MINI_APP_URL=
|
||||
START_COMMAND_DESCRIPTION=
|
||||
SUPPORT_LINK=https://t.me/your_support_link # Link to the support chat
|
||||
SERVER_STATUS_URL=https://status.yourdomain.tld/status/your_service # Link to the server status page
|
||||
TERMS_OF_SERVICE_URL=https://example.com/tos # Link to the terms of service
|
||||
SUBSCRIPTION_MINI_APP_URL= # URL of the subscription mini-app
|
||||
START_COMMAND_DESCRIPTION= # Description of the /start command
|
||||
DISABLE_WELCOME_MESSAGE= # Disable the welcome message
|
||||
|
||||
# Webhook Base URL (used for Telegram and payment providers)
|
||||
WEBHOOK_BASE_URL=https://webhooks.yourdomain.tld
|
||||
|
||||
# YooKassa Payment Gateway Configuration
|
||||
YOOKASSA_SHOP_ID=your_shop_id
|
||||
YOOKASSA_SECRET_KEY=your_secret_key
|
||||
YOOKASSA_WEBHOOK_BASE_URL=https://webhooks.yourdomain.tld
|
||||
YOOKASSA_RETURN_URL=https://t.me/your_bot
|
||||
YOOKASSA_DEFAULT_RECEIPT_EMAIL=your_email@example.com
|
||||
YOOKASSA_VAT_CODE=1
|
||||
YOOKASSA_PAYMENT_MODE=full_prepayment
|
||||
YOOKASSA_PAYMENT_SUBJECT=payment
|
||||
YOOKASSA_SHOP_ID=your_shop_id # Your store ID in YooKassa
|
||||
YOOKASSA_SECRET_KEY=your_secret_key # Your secret key for YooKassa
|
||||
YOOKASSA_RETURN_URL=https://t.me/your_bot # URL to which the user will be returned after payment
|
||||
YOOKASSA_DEFAULT_RECEIPT_EMAIL=your_email@example.com # Default email for sending receipts
|
||||
YOOKASSA_VAT_CODE=1 # VAT code
|
||||
YOOKASSA_AUTOPAYMENTS_ENABLED=False # Auto-renew toggle
|
||||
|
||||
# Telegram Webhook Configuration (Optional)
|
||||
# If unset, bot will use polling while YooKassa uses webhooks.
|
||||
TELEGRAM_WEBHOOK_BASE_URL=https://webhooks.yourdomain.tld
|
||||
# CryptoBot Payment Gateway Configuration
|
||||
CRYPTOPAY_TOKEN= # API token for CryptoPay
|
||||
CRYPTOPAY_NETWORK=mainnet # Network (mainnet or testnet)
|
||||
CRYPTOPAY_CURRENCY_TYPE=fiat # Currency type (fiat or crypto)
|
||||
CRYPTOPAY_ASSET=RUB # Asset, e.g., RUB, BTC, USDT
|
||||
|
||||
# Payment Methods
|
||||
YOOKASSA_ENABLED=True
|
||||
STARS_ENABLED=True
|
||||
TRIBUTE_ENABLED=True
|
||||
# Tribute Payment Gateway Configuration
|
||||
TRIBUTE_API_KEY= # API key for verifying Tribute webhook signatures
|
||||
TRIBUTE_SKIP_NOTIFICATIONS=True # Skip renewal notifications for Tribute payments
|
||||
TRIBUTE_SKIP_CANCELLATION_NOTIFICATIONS=False # Skip cancellation notifications for Tribute payments
|
||||
|
||||
# Subscription Options
|
||||
# Payment Method Toggles
|
||||
YOOKASSA_ENABLED=True # Turn on YOOKASSA
|
||||
STARS_ENABLED=True # Turn on STARS
|
||||
TRIBUTE_ENABLED=True # Turn on TRIBUTE
|
||||
CRYPTOPAY_ENABLED=True # Turn on CRYPTOPAY
|
||||
|
||||
# Subscription Options. Specify cost parameters or payment links here.
|
||||
1_MONTH_ENABLED=True
|
||||
RUB_PRICE_1_MONTH=150
|
||||
STARS_PRICE_1_MONTH=0
|
||||
@@ -58,45 +69,64 @@ TRIBUTE_LINK_6_MONTHS=
|
||||
RUB_PRICE_12_MONTHS=900
|
||||
STARS_PRICE_12_MONTHS=0
|
||||
TRIBUTE_LINK_12_MONTHS=
|
||||
# API key for verifying Tribute webhook signatures
|
||||
TRIBUTE_API_KEY=
|
||||
|
||||
# Subscription Expiration Notifications
|
||||
SUBSCRIPTION_EXPIRATION_NOTIFICATION_DAYS=7
|
||||
SUBSCRIPTION_NOTIFICATION_HOUR_UTC=13
|
||||
SUBSCRIPTION_NOTIFICATION_MINUTE_UTC=0
|
||||
# Subscription Notifications
|
||||
SUBSCRIPTION_NOTIFICATIONS_ENABLED=True # Enable subscription
|
||||
SUBSCRIPTION_NOTIFY_ON_EXPIRE=True # Notify on subscription
|
||||
SUBSCRIPTION_NOTIFY_AFTER_EXPIRE=True # Notify after
|
||||
SUBSCRIPTION_NOTIFY_DAYS_BEFORE=3 # Days before expiration to notify
|
||||
|
||||
|
||||
REFERRAL_ONE_BONUS_PER_REFEREE=False # Give a bonus only once per referee
|
||||
# Referral Bonus Days
|
||||
# Bonus for the inviting user
|
||||
REFERRAL_BONUS_DAYS_1_MONTH=3
|
||||
REFERRAL_BONUS_DAYS_3_MONTHS=7
|
||||
REFERRAL_BONUS_DAYS_6_MONTHS=15
|
||||
REFERRAL_BONUS_DAYS_12_MONTHS=30
|
||||
# Invited User Bonus
|
||||
REFEREE_BONUS_DAYS_1_MONTH=1
|
||||
REFEREE_BONUS_DAYS_3_MONTHS=3
|
||||
REFEREE_BONUS_DAYS_6_MONTHS=7
|
||||
REFEREE_BONUS_DAYS_12_MONTHS=15
|
||||
|
||||
# Panel API Configuration
|
||||
PANEL_API_URL=http://your_panel_api_url/api
|
||||
PANEL_API_KEY=your_panel_api_key
|
||||
PANEL_API_URL=http://your_panel_api_url/api # URL of the panel API
|
||||
PANEL_API_KEY=your_panel_api_key # Panel API key
|
||||
PANEL_WEBHOOK_SECRET= # secret used to verify panel webhook signatures
|
||||
|
||||
# User traffic limits (applied for all users)
|
||||
# 0 means unlimited
|
||||
USER_TRAFFIC_LIMIT_GB=0
|
||||
USER_TRAFFIC_STRATEGY="NO_RESET"
|
||||
USER_TRAFFIC_LIMIT_GB=0 # Traffic limit for users (0 unlimited)
|
||||
USER_TRAFFIC_STRATEGY="NO_RESET" # Traffic reset strategy (NO_RESET, WEEK, MONTH)
|
||||
|
||||
# Default Inbounds for Users (Optional, comma-separated UUIDs)
|
||||
USER_INBOUND_UUIDS=uuid1,uuid2,uuid3
|
||||
# Default Internal Squads for Users (Optional, comma-separated UUIDs)
|
||||
USER_SQUAD_UUIDS=uuid1,uuid2,uuid3
|
||||
|
||||
# Trial Settings
|
||||
TRIAL_ENABLED=True
|
||||
TRIAL_DURATION_DAYS=5
|
||||
TRIAL_TRAFFIC_LIMIT_GB=0 # 0 for unlimited
|
||||
TRIAL_TRAFFIC_STRATEGY="NO_RESET"
|
||||
TRIAL_ENABLED=True # Enable the trial period
|
||||
TRIAL_DURATION_DAYS=5 # Duration of the trial period in days
|
||||
TRIAL_TRAFFIC_LIMIT_GB=0 # Traffic limit for the trial period (0 = unlimited)
|
||||
TRIAL_TRAFFIC_STRATEGY="NO_RESET" # Traffic reset strategy for the trial period (NO_RESET, WEEK, MONTH)
|
||||
|
||||
# Web Server Settings (for handling webhooks)
|
||||
WEB_SERVER_HOST="0.0.0.0"
|
||||
WEB_SERVER_PORT=8080
|
||||
|
||||
# Admin Panel Log Pagination
|
||||
LOGS_PAGE_SIZE=10
|
||||
LOGS_PAGE_SIZE=10 # Number of events in the log
|
||||
|
||||
# Admin Logging Configuration
|
||||
LOG_CHAT_ID=-1001234567890 # Telegram chat/group ID for admin notifications
|
||||
LOG_THREAD_ID= # Optional: Thread ID for supergroup messages
|
||||
LOG_NEW_USERS=True # Log new user registrations
|
||||
LOG_PAYMENTS=True # Log payments
|
||||
LOG_PROMO_ACTIVATIONS=True # Log promo code activations
|
||||
LOG_TRIAL_ACTIVATIONS=True # Log trial activations
|
||||
LOG_SUSPICIOUS_ACTIVITY=True # Log suspicious activity
|
||||
|
||||
# Embedded mode thumbnails. Please don't touch this if you don't know what it is.
|
||||
INLINE_REFERRAL_THUMBNAIL_URL=https://cdn-icons-png.flaticon.com/512/1077/1077114.png
|
||||
INLINE_USER_STATS_THUMBNAIL_URL=https://cdn-icons-png.flaticon.com/512/681/681494.png
|
||||
INLINE_FINANCIAL_STATS_THUMBNAIL_URL=https://cdn-icons-png.flaticon.com/512/2769/2769339.png
|
||||
INLINE_SYSTEM_STATS_THUMBNAIL_URL=https://cdn-icons-png.flaticon.com/512/2920/2920277.png
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
name: Build and Push Dev Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
pull_request:
|
||||
branches:
|
||||
- dev
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=sha,prefix={{branch}}-
|
||||
flavor: |
|
||||
latest=false
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Image digest
|
||||
run: echo ${{ steps.meta.outputs.digest }}
|
||||
@@ -14,3 +14,6 @@ __pycache__/
|
||||
# Игнорировать Docker артефакты (если вдруг)
|
||||
*.log
|
||||
*.pid
|
||||
locales/ru_backup.json
|
||||
locales/en_backup.json
|
||||
db/models_old.py
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
Copyright 2025 machka-pasla
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
@@ -1,241 +1,182 @@
|
||||
# Telegram-бот для продажи подписок Remnawave
|
||||
|
||||
# Remnawave Subscription Sales Telegram Bot
|
||||
Этот Telegram-бот предназначен для автоматизации продажи и управления подписками для панели **Remnawave**. Он интегрируется с API Remnawave для управления пользователями и подписками, а также использует различные платежные системы для приема платежей.
|
||||
|
||||
This Telegram bot is designed to automate the sale and management of subscriptions for a **Remnawave panel**. It integrates with the Remnawave API for user and subscription management and uses YooKassa for processing payments.
|
||||
## ✨ Ключевые возможности
|
||||
|
||||
## ✨ Features
|
||||
### Для пользователей:
|
||||
- **Регистрация и выбор языка:** Поддержка русского и английского языков.
|
||||
- **Просмотр подписки:** Пользователи могут видеть статус своей подписки, дату окончания и ссылку на конфигурацию.
|
||||
- **Пробная подписка:** Система пробных подписок для новых пользователей (активируется вручную по кнопке).
|
||||
- **Промокоды:** Возможность применять промокоды для получения скидок или бонусных дней.
|
||||
- **Реферальная программа:** Пользователи могут приглашать друзей и получать за это бонусные дни подписки.
|
||||
- **Оплата:** Поддержка оплаты через YooKassa, CryptoPay, Telegram Stars и Tribute.
|
||||
|
||||
* **User Interaction:**
|
||||
* User registration with language selection (English/Russian).
|
||||
* Display of main menu with available actions via inline keyboards.
|
||||
* Ability for users to view their current subscription status, expiration date, and configuration link.
|
||||
* Trial subscription system for new users (configurable, manual activation via button).
|
||||
* Promo code system for users to apply discounts or bonuses.
|
||||
* Referral program for users to earn bonus subscription days.
|
||||
* **Subscription Management:**
|
||||
* Handles subscription purchases for various periods (1, 3, 6, 12 months).
|
||||
* Integrates with **YooKassa** for payment processing, including fiscal receipt data.
|
||||
* Automatic subscription activation/extension upon successful payment.
|
||||
* Link and syncs users with a **Remnawave panel** account, primarily matching by Telegram ID.
|
||||
* Updates user status, expiration dates, traffic limits, and inbounds on the Remnawave panel.
|
||||
* **Admin Panel:**
|
||||
* Protected by `ADMIN_IDS` (supports multiple administrators).
|
||||
* **Statistics:** View bot usage (total users, banned, active subscriptions), recent payments, and panel sync status.
|
||||
* **User Management:**
|
||||
* Ban/Unban users by Telegram ID or @username (updates local DB and panel).
|
||||
* View a paginated list of banned users.
|
||||
* View a "user card" with detailed information and unban option.
|
||||
* **Broadcast:** Send messages to all users, users with active subscriptions, or users with expired subscriptions.
|
||||
* **Promo Codes:** Create and view promo codes (bonus days, activation limits, validity).
|
||||
* **Panel Sync:** Manually trigger synchronization of users and subscriptions from the Remnawave panel to the bot's database, matching by Telegram ID.
|
||||
* **Activity Logs:** View a paginated list of all user actions (messages, commands, callbacks) or logs for a specific user.
|
||||
* **Notifications:**
|
||||
* Automated daily notifications to users about expiring subscriptions (via APScheduler).
|
||||
* Notifications to users and inviters upon successful referral bonus application.
|
||||
* Notifications to admin(s) about suspicious promo code input attempts.
|
||||
* **Security & Technical:**
|
||||
* Uses parameterized queries to prevent SQL injection.
|
||||
* Proactive check for suspicious input in promo code field (notifies admin).
|
||||
* Middleware for checking banned users on every interaction.
|
||||
* Middleware for logging user actions.
|
||||
* Webhook support for Telegram and YooKassa for efficient updates.
|
||||
* Configurable via `.env` file.
|
||||
* Dockerized for easy deployment.
|
||||
### Для администраторов:
|
||||
- **Защищенная админ-панель:** Доступ только для администраторов, указанных в `ADMIN_IDS`.
|
||||
- **Статистика:** Просмотр статистики использования бота (общее количество пользователей, забаненные, активные подписки), недавние платежи и статус синхронизации с панелью.
|
||||
- **Управление пользователями:** Блокировка/разблокировка пользователей, просмотр списка забаненных и детальной информации о пользователе.
|
||||
- **Рассылка:** Отправка сообщений всем пользователям, пользователям с активной или истекшей подпиской.
|
||||
- **Управление промокодами:** Создание и просмотр промокодов.
|
||||
- **Синхронизация с панелью:** Ручной запуск синхронизации пользователей и подписок с панелью Remnawave.
|
||||
- **Логи действий:** Просмотр логов всех действий пользователей.
|
||||
|
||||
## 🚀 Technologies Used
|
||||
## 🚀 Технологии
|
||||
|
||||
* **Python 3.11**
|
||||
* **Aiogram 3.x:** Asynchronous Telegram Bot Framework
|
||||
* **aiohttp:** For running the webhook server
|
||||
* **sqlalchemy:** Asynchronous PostgreSQL database interaction
|
||||
* **YooKassa SDK:** For payment processing
|
||||
* **APScheduler:** For scheduled tasks (e.g., notifications)
|
||||
* **Pydantic:** For settings management (loading from `.env`)
|
||||
* **Docker & Docker Compose:** For containerization and deployment
|
||||
- **Python 3.11**
|
||||
- **Aiogram 3.x:** Асинхронный фреймворк для Telegram ботов.
|
||||
- **aiohttp:** Для запуска веб-сервера (вебхуки).
|
||||
- **SQLAlchemy 2.x & asyncpg:** Асинхронная работа с базой данных PostgreSQL.
|
||||
- **YooKassa, aiocryptopay:** SDK для интеграции с платежными системами.
|
||||
- **Pydantic:** Для управления настройками из `.env` файла.
|
||||
- **Docker & Docker Compose:** Для контейнеризации и развертывания.
|
||||
|
||||
## ⚙️ Setup and Configuration
|
||||
## ⚙️ Установка и запуск
|
||||
|
||||
### Prerequisites
|
||||
### Предварительные требования
|
||||
|
||||
* Docker and Docker Compose installed.
|
||||
* A running instance of a Remnawave panel.
|
||||
* A Telegram Bot Token.
|
||||
* A YooKassa Shop ID and Secret Key.
|
||||
- Установленные Docker и Docker Compose.
|
||||
- Рабочая панель Remnawave.
|
||||
- Токен Telegram-бота.
|
||||
- Данные для подключения к платежным системам (YooKassa, CryptoPay и т.д.).
|
||||
|
||||
### Configuration Steps
|
||||
### Шаги установки
|
||||
|
||||
1. **Clone the Repository:**
|
||||
1. **Клонируйте репозиторий:**
|
||||
```bash
|
||||
git clone https://github.com/machka-pasla/remnawave-tg-shop
|
||||
cd remnawave-tg-shop
|
||||
```
|
||||
|
||||
2. **Create an `.env` File:**
|
||||
Copy the `env.example` file to `.env` and fill in your specific values:
|
||||
2. **Создайте и настройте файл `.env`:**
|
||||
Скопируйте `env.example` в `.env` и заполните своими данными.
|
||||
```bash
|
||||
cp .env.example .env
|
||||
nano .env
|
||||
```
|
||||
Key variables to configure in `.env`:
|
||||
* `BOT_TOKEN`: Your Telegram Bot Token from BotFather.
|
||||
* `ADMIN_IDS`: Comma-separated list of your Telegram User IDs for admin access (e.g., `12345678,98765432`). **Crucial for bot management.**
|
||||
* `DEFAULT_LANGUAGE`: Default language for new users (e.g., `ru` or `en`).
|
||||
* `DEFAULT_CURRENCY_SYMBOL`: e.g., `RUB`, `USD`.
|
||||
* `SUPPORT_LINK`: (Optional) URL for a support chat/contact (e.g., `https://t.me/your_support`).
|
||||
* `SERVER_STATUS_URL`: (Optional) URL to a server status page (e.g., Uptime Kuma).
|
||||
* `SUBSCRIPTION_MINI_APP_URL`: (Optional) URL of the Telegram mini app for viewing subscription details. If set, the "My Subscription" button will open this mini app and the bot will register it automatically via API.
|
||||
* `START_COMMAND_DESCRIPTION`: (Optional) Description for the `/start` command shown in the bot's menu.
|
||||
* **YooKassa Settings:**
|
||||
* `YOOKASSA_SHOP_ID`: Your shop ID from YooKassa.
|
||||
* `YOOKASSA_SECRET_KEY`: Your secret key from YooKassa.
|
||||
* `YOOKASSA_WEBHOOK_BASE_URL`: Your publicly accessible HTTPS base URL where the bot will listen for YooKassa webhooks (e.g., `https://your.domain.com`). The full path will be `{YOOKASSA_WEBHOOK_BASE_URL}/webhook/yookassa`.
|
||||
* `YOOKASSA_RETURN_URL`: (Optional) URL user is redirected to after payment, often `https://t.me/your_bot_username`.
|
||||
* `YOOKASSA_DEFAULT_RECEIPT_EMAIL`: **Important for 54-FZ (Russian fiscalization).** A default email for sending fiscal receipts.
|
||||
* `YOOKASSA_VAT_CODE`: VAT code for items in receipt (e.g., `1` for "No VAT". Consult YooKassa documentation and tax advisor).
|
||||
* `YOOKASSA_PAYMENT_MODE`: e.g., `full_prepayment`.
|
||||
* `YOOKASSA_PAYMENT_SUBJECT`: e.g., `service`.
|
||||
* `TELEGRAM_WEBHOOK_BASE_URL`: (Optional) If you want Telegram updates via webhook. Can be the same as `YOOKASSA_WEBHOOK_BASE_URL`. If not set, the bot will use polling for Telegram updates.
|
||||
* **Payment Method Toggles:** `YOOKASSA_ENABLED`, `STARS_ENABLED`, `TRIBUTE_ENABLED`.
|
||||
* **Subscription Options:** For each duration you can use variables like
|
||||
`1_MONTH_ENABLED`, `RUB_PRICE_1_MONTH`, `STARS_PRICE_1_MONTH`, `TRIBUTE_LINK_1_MONTH`
|
||||
(and corresponding variables for `3_MONTHS`, `6_MONTHS`, `12_MONTHS`).
|
||||
* **Panel API Settings:**
|
||||
* `PANEL_API_URL`: Full URL to your Remnawave panel's API (e.g., `http://remnawave:3000/api` or `https://panel.yourdomain.com/api`).
|
||||
* `PANEL_API_KEY`: API Key for authenticating with the Remnawave panel.
|
||||
* `USER_INBOUND_UUIDS`: (Optional) Comma-separated list of inbound UUIDs from your panel to assign to users. If empty, `activateAllInbounds: true` (panel default) is used for new users.
|
||||
* `USER_TRAFFIC_LIMIT_GB` and `USER_TRAFFIC_STRATEGY`: Default traffic limit in gigabytes (0 for unlimited) and the reset strategy applied when updating users on the panel.
|
||||
* `TRIAL_ENABLED`, `TRIAL_DURATION_DAYS`, `TRIAL_TRAFFIC_LIMIT_GB`: Settings for the trial period.
|
||||
* `WEB_SERVER_HOST`, `WEB_SERVER_PORT`: Host and port for the bot's internal webhook server.
|
||||
* `LOGS_PAGE_SIZE`: For admin panel log pagination.
|
||||
Ниже перечислены ключевые переменные.
|
||||
|
||||
3. **Locales:**
|
||||
* Translation files are in the `locales/` directory (`en.json`, `ru.json`). Ensure they are present and correctly formatted. `locales` mounting is optional.
|
||||
<details>
|
||||
<summary><b>Основные настройки</b></summary>
|
||||
|
||||
4. **Run with Docker Compose:**
|
||||
| Переменная | Описание | Пример |
|
||||
| --- | --- | --- |
|
||||
| `BOT_TOKEN` | **Обязательно.** Токен вашего Telegram-бота. | `1234567890:ABC-DEF1234ghIkl-zyx57W2v1u123ew11` |
|
||||
| `ADMIN_IDS` | **Обязательно.** ID администраторов в Telegram через запятую. | `12345678,98765432` |
|
||||
| `DEFAULT_LANGUAGE` | Язык по умолчанию для новых пользователей. | `ru` |
|
||||
| `SUPPORT_LINK` | (Опционально) Ссылка на поддержку. | `https://t.me/your_support` |
|
||||
| `SUBSCRIPTION_MINI_APP_URL` | (Опционально) URL Mini App для показа подписки. | `https://t.me/your_bot/app` |
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Настройки платежей и вебхуков</b></summary>
|
||||
|
||||
| Переменная | Описание |
|
||||
| --- | --- |
|
||||
| `WEBHOOK_BASE_URL`| **Обязательно.** Базовый URL для вебхуков, например `https://your.domain.com`. |
|
||||
| `WEB_SERVER_HOST` | Хост для веб-сервера. | `0.0.0.0` |
|
||||
| `WEB_SERVER_PORT` | Порт для веб-сервера. | `8080` |
|
||||
| `YOOKASSA_ENABLED` | Включить/выключить YooKassa (`true`/`false`). |
|
||||
| `YOOKASSA_SHOP_ID` | ID вашего магазина в YooKassa. |
|
||||
| `YOOKASSA_SECRET_KEY`| Секретный ключ магазина YooKassa. |
|
||||
| `CRYPTOPAY_ENABLED` | Включить/выключить CryptoPay (`true`/`false`). |
|
||||
| `CRYPTOPAY_TOKEN` | Токен из вашего CryptoPay App. |
|
||||
| `STARS_ENABLED` | Включить/выключить Telegram Stars (`true`/`false`). |
|
||||
| `TRIBUTE_ENABLED`| Включить/выключить Tribute (`true`/`false`). |
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Настройки подписок</b></summary>
|
||||
|
||||
Для каждого периода (1, 3, 6, 12 месяцев) можно настроить доступность и цены:
|
||||
- `1_MONTH_ENABLED`: `true` или `false`
|
||||
- `RUB_PRICE_1_MONTH`: Цена в рублях
|
||||
- `STARS_PRICE_1_MONTH`: Цена в Telegram Stars
|
||||
- `TRIBUTE_LINK_1_MONTH`: Ссылка для оплаты через Tribute
|
||||
Аналогичные переменные есть для `3_MONTHS`, `6_MONTHS`, `12_MONTHS`.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Настройки панели Remnawave</b></summary>
|
||||
|
||||
| Переменная | Описание |
|
||||
| --- | --- |
|
||||
| `PANEL_API_URL` | URL API вашей панели Remnawave. |
|
||||
| `PANEL_API_KEY` | API ключ для доступа к панели. |
|
||||
| `PANEL_WEBHOOK_SECRET`| Секретный ключ для проверки вебхуков от панели. |
|
||||
| `USER_SQUAD_UUIDS` | ID отрядов для новых пользователей. |
|
||||
| `USER_TRAFFIC_LIMIT_GB`| Лимит трафика в ГБ (0 - безлимит). |
|
||||
</gidetails>
|
||||
|
||||
<details>
|
||||
<summary><b>Настройки пробного периода</b></summary>
|
||||
|
||||
| Переменная | Описание |
|
||||
| --- | --- |
|
||||
| `TRIAL_ENABLED` | Включить/выключить пробный период (`true`/`false`). |
|
||||
| `TRIAL_DURATION_DAYS`| Длительность пробного периода в днях. |
|
||||
| `TRIAL_TRAFFIC_LIMIT_GB`| Лимит трафика для пробного периода в ГБ. |
|
||||
</details>
|
||||
|
||||
3. **Запустите контейнеры:**
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
This command will pull the Docker image (if it doesn't exist or if `Dockerfile` changed) and start the `remnawave-tg-shop` service in detached mode.
|
||||
Эта команда скачает образ и запустит сервис в фоновом режиме.
|
||||
|
||||
5. **Webhook Setup (Important if using webhooks):**
|
||||
* **Reverse Proxy (Nginx, Caddy, etc.):** You need a reverse proxy to handle incoming HTTPS traffic, manage SSL certificates (e.g., from Let's Encrypt), and forward requests to your bot's container.
|
||||
* Forward requests for `https://{YOOKASSA_WEBHOOK_BASE_URL_domain}/webhook/yookassa` to `http://remnawave-tg-shop:{WEB_SERVER_PORT}/webhook/yookassa` (where `remnawave-tg-shop` is the service name in `docker-compose.yml`).
|
||||
* If using Telegram webhooks, forward requests for `https://{TELEGRAM_WEBHOOK_BASE_URL_domain}/<YOUR_BOT_TOKEN>` to `http://remnawave-tg-shop:{WEB_SERVER_PORT}/<YOUR_BOT_TOKEN>`.
|
||||
* **Telegram Webhook Registration:** The bot attempts to set its Telegram webhook URL on startup if `TELEGRAM_WEBHOOK_BASE_URL` is configured in `.env`. Check the bot logs to confirm if this was successful. You can also manually check using the Telegram Bot API method `getWebhookInfo`.
|
||||
4. **Настройка вебхуков (Обязательно):**
|
||||
Вебхуки являются **обязательным** компонентом для работы бота, так как они используются для получения уведомлений от платежных систем (YooKassa, CryptoPay, Tribute) и панели Remnawave.
|
||||
|
||||
6. **Database:**
|
||||
* A PostgreSQL database will be created in the docker container. The schema is initialized automatically on the first run if the database doesn't exist.
|
||||
Вам понадобится обратный прокси (например, Nginx) для обработки HTTPS-трафика и перенаправления запросов на контейнер с ботом.
|
||||
|
||||
7. **Viewing Logs:**
|
||||
**Пути для перенаправления:**
|
||||
- `https://<ваш_домен>/webhook/yookassa` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/yookassa`
|
||||
- `https://<ваш_домен>/webhook/cryptopay` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/cryptopay`
|
||||
- `https://<ваш_домен>/webhook/tribute` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/tribute`
|
||||
- `https://<ваш_домен>/webhook/panel` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/panel`
|
||||
- **Для Telegram:** Бот автоматически установит вебхук, если в `.env` указан `WEBHOOK_BASE_URL`. Путь будет `https://<ваш_домен>/<BOT_TOKEN>`.
|
||||
|
||||
Где `remnawave-tg-shop` — это имя сервиса из `docker-compose.yml`, а `<WEB_SERVER_PORT>` — порт, указанный в `.env`.
|
||||
|
||||
5. **Просмотр логов:**
|
||||
```bash
|
||||
docker compose logs -f remnawave-tg-shop
|
||||
```
|
||||
|
||||
## 🐳 Docker Setup
|
||||
## 🐳 Docker
|
||||
|
||||
### `Dockerfile`
|
||||
Файлы `Dockerfile` и `docker-compose.yml` уже настроены для сборки и запуска проекта. `docker-compose.yml` использует готовый образ с GitHub Container Registry, но вы можете раскомментировать `build: .` для локальной сборки.
|
||||
|
||||
```dockerfile
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
|
||||
# Consider adding build arguments for proxy if needed in your environment
|
||||
# ARG HTTP_PROXY
|
||||
# ARG HTTPS_PROXY
|
||||
# ENV http_proxy=$HTTP_PROXY
|
||||
# ENV https_proxy=$HTTPS_PROXY
|
||||
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
# Ensure main.py is executable if needed, though python command handles it
|
||||
# RUN chmod +x main.py
|
||||
|
||||
CMD ["python", "main.py"]
|
||||
|
||||
```
|
||||
|
||||
### `docker-compose.yml`
|
||||
```
|
||||
services:
|
||||
remnawave-tg-shop:
|
||||
image: ghcr.io/machka-pasla/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
|
||||
|
||||
postgres:
|
||||
image: postgres:17
|
||||
container_name: remnawave-tg-shop-db
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- remnawave-tg-shop-db-data:/var/lib/postgresql/data
|
||||
# networks:
|
||||
# - remnawave-network
|
||||
restart: unless-stopped
|
||||
|
||||
# networks:
|
||||
# remnawave-network:
|
||||
# external: true
|
||||
|
||||
volumes:
|
||||
remnawave-tg-shop-db-data:
|
||||
name: remnawave-tg-shop-db-data
|
||||
```
|
||||
|
||||
**Note on `remnawave-network`:** The `docker-compose.yml` assumes an external network named `remnawave-network`. If this network doesn't exist or you want the bot on a different network (e.g., a default bridge or a new one defined in this compose file), you'll need to adjust the `networks` section. If the Remnawave panel is also running in Docker on the same host, putting them on the same user-defined network allows them to communicate using service names.
|
||||
|
||||
## 🛠️ Project Structure (Overview)
|
||||
## 📁 Структура проекта
|
||||
|
||||
```
|
||||
.
|
||||
├── bot/
|
||||
│ ├── filters/ # Custom Aiogram filters (e.g., AdminFilter)
|
||||
│ ├── handlers/ # Message and callback query handlers (admin and user)
|
||||
│ ├── keyboards/ # Inline and reply keyboard generators
|
||||
│ ├── middlewares/ # Custom Aiogram middlewares (i18n, ban check, logger)
|
||||
│ ├── services/ # Business logic (payments, subscriptions, panel API interaction)
|
||||
│ ├── states/ # FSM states
|
||||
│ └── main_bot.py # Core bot logic, dispatcher setup, startup/shutdown
|
||||
│ ├── filters/ # Пользовательские фильтры Aiogram
|
||||
│ ├── handlers/ # Обработчики сообщений и колбэков
|
||||
│ ├── keyboards/ # Клавиатуры
|
||||
│ ├── middlewares/ # Промежуточные слои (i18n, проверка бана)
|
||||
│ ├── services/ # Бизнес-логика (платежи, API панели)
|
||||
│ ├── states/ # Состояния FSM
|
||||
│ └── main_bot.py # Основная логика бота
|
||||
├── config/
|
||||
│ └── settings.py # Pydantic settings and config parser
|
||||
│ └── settings.py # Настройки Pydantic
|
||||
├── db/
|
||||
│ ├── dal/ # Data Access Layer (queries, transactions)
|
||||
│ ├── database_setup.py # DB connection/init setup
|
||||
│ └── models.py # ORM models (e.g., SQLAlchemy)
|
||||
├── locales/ # Localization files
|
||||
│ ├── en.json # English locale
|
||||
│ └── ru.json # Russian locale
|
||||
├── .env.example # Example environment variables for local setup
|
||||
├── .env # Actual environment variables (ignored by Git)
|
||||
├── Dockerfile # Docker image build instructions
|
||||
├── docker-compose.yml # Docker Compose orchestration config
|
||||
├── requirements.txt # List of Python dependencies
|
||||
├── README.md # Project documentation
|
||||
└── main.py # Entry point to launch the bot
|
||||
│ ├── dal/ # Слой доступа к данным (DAL)
|
||||
│ ├── database_setup.py # Настройка БД
|
||||
│ └── models.py # Модели SQLAlchemy
|
||||
├── locales/ # Файлы локализации (ru, en)
|
||||
├── .env.example # Пример файла с переменными окружения
|
||||
├── Dockerfile # Инструкции для сборки Docker-образа
|
||||
├── docker-compose.yml # Файл для оркестрации контейнеров
|
||||
├── requirements.txt # Зависимости Python
|
||||
└── main.py # Точка входа в приложение
|
||||
```
|
||||
|
||||
## 🤝 Contributing
|
||||
## 🔮 Планы на будущее
|
||||
|
||||
Contributions are welcome!
|
||||
- Расширенные типы промокодов (например, скидки в процентах).
|
||||
|
||||
## 🔮 Future Enhancements
|
||||
|
||||
- More detailed analytics for admin.
|
||||
- Support for different payment methods.
|
||||
- Advanced promo code types (e.g., percentage discounts).
|
||||
|
||||
## Donations (pls)
|
||||
- Russian and international cards [LINK](https://t.me/tribute/app?startapp=dqdg)
|
||||
## ❤️ Поддержка
|
||||
- Карты РФ и зарубежные: [Tribute](https://t.me/tribute/app?startapp=dqdg)
|
||||
- Crypto: `USDT TRC-20 TT3SqBbfU4vYm6SUwUVNZsy278m2xbM4GE`
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import logging
|
||||
from typing import Dict
|
||||
|
||||
from aiogram import Bot, Dispatcher
|
||||
from aiogram.enums import ParseMode
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.fsm.storage.memory import MemoryStorage
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from config.settings import Settings
|
||||
from bot.middlewares.db_session import DBSessionMiddleware
|
||||
from bot.middlewares.i18n import I18nMiddleware, get_i18n_instance, JsonI18n
|
||||
from bot.middlewares.ban_check_middleware import BanCheckMiddleware
|
||||
from bot.middlewares.action_logger_middleware import ActionLoggerMiddleware
|
||||
from bot.middlewares.profile_sync import ProfileSyncMiddleware
|
||||
|
||||
|
||||
def build_dispatcher(settings: Settings, async_session_factory: sessionmaker) -> tuple[Dispatcher, Bot, Dict]:
|
||||
storage = MemoryStorage()
|
||||
default_props = DefaultBotProperties(parse_mode=ParseMode.HTML)
|
||||
bot = Bot(token=settings.BOT_TOKEN, default=default_props)
|
||||
|
||||
dp = Dispatcher(storage=storage, settings=settings, bot_instance=bot)
|
||||
|
||||
i18n_instance = get_i18n_instance(path="locales", default=settings.DEFAULT_LANGUAGE)
|
||||
|
||||
dp["i18n_instance"] = i18n_instance
|
||||
dp["async_session_factory"] = async_session_factory
|
||||
|
||||
dp.update.outer_middleware(DBSessionMiddleware(async_session_factory))
|
||||
dp.update.outer_middleware(I18nMiddleware(i18n=i18n_instance, settings=settings))
|
||||
dp.update.outer_middleware(ProfileSyncMiddleware())
|
||||
dp.update.outer_middleware(BanCheckMiddleware(settings=settings, i18n_instance=i18n_instance))
|
||||
dp.update.outer_middleware(ActionLoggerMiddleware(settings=settings))
|
||||
|
||||
return dp, bot, {"i18n_instance": i18n_instance}
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
from aiogram import Bot
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from config.settings import Settings
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.yookassa_service import YooKassaService
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.services.referral_service import ReferralService
|
||||
from bot.services.promo_code_service import PromoCodeService
|
||||
from bot.services.stars_service import StarsService
|
||||
from bot.services.tribute_service import TributeService
|
||||
from bot.services.crypto_pay_service import CryptoPayService
|
||||
from bot.services.panel_webhook_service import PanelWebhookService
|
||||
|
||||
|
||||
def build_core_services(
|
||||
settings: Settings,
|
||||
bot: Bot,
|
||||
async_session_factory: sessionmaker,
|
||||
i18n: JsonI18n,
|
||||
bot_username_for_default_return: str,
|
||||
):
|
||||
panel_service = PanelApiService(settings)
|
||||
subscription_service = SubscriptionService(settings, panel_service, bot, i18n)
|
||||
referral_service = ReferralService(settings, subscription_service, bot, i18n)
|
||||
promo_code_service = PromoCodeService(settings, subscription_service, bot, i18n)
|
||||
stars_service = StarsService(bot, settings, i18n, subscription_service, referral_service)
|
||||
cryptopay_service = CryptoPayService(
|
||||
settings.CRYPTOPAY_TOKEN,
|
||||
settings.CRYPTOPAY_NETWORK,
|
||||
bot,
|
||||
settings,
|
||||
i18n,
|
||||
async_session_factory,
|
||||
subscription_service,
|
||||
referral_service,
|
||||
)
|
||||
tribute_service = TributeService(
|
||||
bot,
|
||||
settings,
|
||||
i18n,
|
||||
async_session_factory,
|
||||
panel_service,
|
||||
subscription_service,
|
||||
referral_service,
|
||||
)
|
||||
panel_webhook_service = PanelWebhookService(bot, settings, i18n, async_session_factory, panel_service)
|
||||
yookassa_service = YooKassaService(
|
||||
shop_id=settings.YOOKASSA_SHOP_ID,
|
||||
secret_key=settings.YOOKASSA_SECRET_KEY,
|
||||
configured_return_url=settings.YOOKASSA_RETURN_URL,
|
||||
bot_username_for_default_return=bot_username_for_default_return,
|
||||
settings_obj=settings,
|
||||
)
|
||||
|
||||
# Wire services that depend on each other
|
||||
try:
|
||||
# Attach YooKassa to subscription service for auto-renew charges
|
||||
setattr(subscription_service, "yookassa_service", yookassa_service)
|
||||
# Allow panel webhook to trigger renewals through subscription service
|
||||
setattr(panel_webhook_service, "subscription_service", subscription_service)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"panel_service": panel_service,
|
||||
"subscription_service": subscription_service,
|
||||
"referral_service": referral_service,
|
||||
"promo_code_service": promo_code_service,
|
||||
"stars_service": stars_service,
|
||||
"cryptopay_service": cryptopay_service,
|
||||
"tribute_service": tribute_service,
|
||||
"panel_webhook_service": panel_webhook_service,
|
||||
"yookassa_service": yookassa_service,
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from aiohttp import web
|
||||
from aiogram import Bot, Dispatcher
|
||||
from aiogram.webhook.aiohttp_server import SimpleRequestHandler, setup_application
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from config.settings import Settings
|
||||
|
||||
|
||||
async def build_and_start_web_app(
|
||||
dp: Dispatcher,
|
||||
bot: Bot,
|
||||
settings: Settings,
|
||||
async_session_factory: sessionmaker,
|
||||
):
|
||||
app = web.Application()
|
||||
app["bot"] = bot
|
||||
app["dp"] = dp
|
||||
app["settings"] = settings
|
||||
app["async_session_factory"] = async_session_factory
|
||||
# Inject shared instances used by webhook handlers
|
||||
app["i18n"] = dp.get("i18n_instance")
|
||||
for key in (
|
||||
"yookassa_service",
|
||||
"subscription_service",
|
||||
"referral_service",
|
||||
"panel_service",
|
||||
"stars_service",
|
||||
"cryptopay_service",
|
||||
"tribute_service",
|
||||
"panel_webhook_service",
|
||||
):
|
||||
# Access dispatcher workflow_data directly to avoid sequence protocol issues
|
||||
if hasattr(dp, "workflow_data") and key in dp.workflow_data: # type: ignore
|
||||
app[key] = dp.workflow_data[key] # type: ignore
|
||||
|
||||
setup_application(app, dp, bot=bot)
|
||||
|
||||
telegram_uses_webhook_mode = bool(settings.WEBHOOK_BASE_URL)
|
||||
|
||||
if telegram_uses_webhook_mode:
|
||||
telegram_webhook_path = f"/{settings.BOT_TOKEN}"
|
||||
app.router.add_post(telegram_webhook_path, SimpleRequestHandler(dispatcher=dp, bot=bot))
|
||||
logging.info(
|
||||
f"Telegram webhook route configured at: [POST] {telegram_webhook_path} (relative to base URL)"
|
||||
)
|
||||
|
||||
from bot.handlers.user.payment import yookassa_webhook_route
|
||||
from bot.services.tribute_service import tribute_webhook_route
|
||||
from bot.services.crypto_pay_service import cryptopay_webhook_route
|
||||
from bot.services.panel_webhook_service import panel_webhook_route
|
||||
|
||||
tribute_path = settings.tribute_webhook_path
|
||||
if tribute_path.startswith("/"):
|
||||
app.router.add_post(tribute_path, tribute_webhook_route)
|
||||
logging.info(f"Tribute webhook route configured at: [POST] {tribute_path}")
|
||||
|
||||
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}")
|
||||
|
||||
# YooKassa webhook (register only when base URL present and path configured)
|
||||
yk_path = settings.yookassa_webhook_path
|
||||
if settings.WEBHOOK_BASE_URL and yk_path and yk_path.startswith("/"):
|
||||
app.router.add_post(yk_path, yookassa_webhook_route)
|
||||
logging.info(f"YooKassa webhook route configured at: [POST] {yk_path}")
|
||||
|
||||
panel_path = settings.panel_webhook_path
|
||||
if panel_path.startswith("/"):
|
||||
app.router.add_post(panel_path, panel_webhook_route)
|
||||
logging.info(f"Panel webhook route configured at: [POST] {panel_path}")
|
||||
|
||||
web_app_runner = web.AppRunner(app)
|
||||
await web_app_runner.setup()
|
||||
site = web.TCPSite(
|
||||
web_app_runner,
|
||||
host=settings.WEB_SERVER_HOST,
|
||||
port=settings.WEB_SERVER_PORT,
|
||||
)
|
||||
|
||||
await site.start()
|
||||
logging.info(
|
||||
f"AIOHTTP server started on http://{settings.WEB_SERVER_HOST}:{settings.WEB_SERVER_PORT}"
|
||||
)
|
||||
|
||||
# Run until cancelled
|
||||
await asyncio.Event().wait()
|
||||
|
||||
|
||||
@@ -2,20 +2,24 @@ from aiogram import Router
|
||||
|
||||
from . import common
|
||||
from . import broadcast
|
||||
from . import promo_codes
|
||||
from .promo import promo_router_aggregate
|
||||
from . import user_management
|
||||
from . import statistics
|
||||
from . import sync_admin
|
||||
from . import logs_admin
|
||||
from . import payments
|
||||
from . import ads
|
||||
|
||||
admin_router_aggregate = Router(name="admin_features_router")
|
||||
|
||||
admin_router_aggregate.include_router(common.router)
|
||||
admin_router_aggregate.include_router(broadcast.router)
|
||||
admin_router_aggregate.include_router(promo_codes.router)
|
||||
admin_router_aggregate.include_router(promo_router_aggregate)
|
||||
admin_router_aggregate.include_router(user_management.router)
|
||||
admin_router_aggregate.include_router(statistics.router)
|
||||
admin_router_aggregate.include_router(sync_admin.router)
|
||||
admin_router_aggregate.include_router(logs_admin.router)
|
||||
admin_router_aggregate.include_router(payments.router)
|
||||
admin_router_aggregate.include_router(ads.router)
|
||||
|
||||
__all__ = ("admin_router_aggregate", )
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
import logging
|
||||
from aiogram import Router, F, types
|
||||
from aiogram.filters import StateFilter
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from typing import Optional
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config.settings import Settings
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from db.dal import ad_dal
|
||||
from bot.states.admin_states import AdminStates
|
||||
|
||||
router = Router(name="admin_ads_router")
|
||||
|
||||
|
||||
PAGE_SIZE = 5
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_action:ads")
|
||||
async def show_ads_menu(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession):
|
||||
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
|
||||
|
||||
if not i18n or not callback.message:
|
||||
await callback.answer("Language error.", show_alert=True)
|
||||
return
|
||||
|
||||
totals = await ad_dal.get_totals(session)
|
||||
total_cost = totals.get("cost", 0.0)
|
||||
total_revenue = totals.get("revenue", 0.0)
|
||||
overview = _("admin_ads_overview", revenue=f"{total_revenue:.2f}", cost=f"{total_cost:.2f}")
|
||||
|
||||
total_count = await ad_dal.count_campaigns(session)
|
||||
if total_count == 0:
|
||||
text = overview + "\n\n" + _("admin_ads_empty")
|
||||
from bot.keyboards.inline.admin_keyboards import get_ads_menu_keyboard
|
||||
reply_markup = get_ads_menu_keyboard(i18n, current_lang)
|
||||
else:
|
||||
current_page = 0
|
||||
total_pages = max(1, (total_count + PAGE_SIZE - 1) // PAGE_SIZE)
|
||||
campaigns = await ad_dal.list_campaigns_paged(session, page=current_page, page_size=PAGE_SIZE)
|
||||
text = overview + "\n\n" + _("admin_ads_header")
|
||||
from bot.keyboards.inline.admin_keyboards import get_ads_list_keyboard
|
||||
reply_markup = get_ads_list_keyboard(i18n, current_lang, campaigns, current_page, total_pages)
|
||||
await callback.message.edit_text(text, reply_markup=reply_markup)
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("admin_ads:page:"))
|
||||
async def ads_list_pagination(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
if not i18n or not callback.message:
|
||||
await callback.answer("Language error.", show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
page = int(callback.data.split(":")[2])
|
||||
except Exception:
|
||||
page = 0
|
||||
|
||||
totals = await ad_dal.get_totals(session)
|
||||
overview = _("admin_ads_overview", revenue=f"{totals.get('revenue', 0.0):.2f}", cost=f"{totals.get('cost', 0.0):.2f}")
|
||||
total_count = await ad_dal.count_campaigns(session)
|
||||
total_pages = max(1, (total_count + PAGE_SIZE - 1) // PAGE_SIZE)
|
||||
page = max(0, min(page, total_pages - 1))
|
||||
|
||||
campaigns = await ad_dal.list_campaigns_paged(session, page=page, page_size=PAGE_SIZE)
|
||||
text = overview + "\n\n" + _("admin_ads_header")
|
||||
from bot.keyboards.inline.admin_keyboards import get_ads_list_keyboard
|
||||
reply_markup = get_ads_list_keyboard(i18n, current_lang, campaigns, page, total_pages)
|
||||
try:
|
||||
await callback.message.edit_text(text, reply_markup=reply_markup)
|
||||
await callback.answer()
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to paginate ads list: {e}")
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("admin_ads:card:"))
|
||||
async def show_ad_card(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
if not i18n or not callback.message:
|
||||
await callback.answer("Language error.", show_alert=True)
|
||||
return
|
||||
|
||||
parts = callback.data.split(":")
|
||||
camp_id = int(parts[2])
|
||||
back_page = int(parts[3]) if len(parts) > 3 else 0
|
||||
|
||||
camp = await ad_dal.get_campaign_by_id(session, camp_id)
|
||||
if not camp:
|
||||
await callback.answer(_("admin_promo_not_found"), show_alert=True)
|
||||
return
|
||||
try:
|
||||
stats = await ad_dal.get_campaign_stats(session, camp_id)
|
||||
except Exception:
|
||||
stats = {"starts": 0, "trials": 0, "payers": 0, "revenue": 0.0}
|
||||
|
||||
text = _(
|
||||
"admin_ads_card",
|
||||
id=camp.ad_campaign_id,
|
||||
source=camp.source,
|
||||
start_param=camp.start_param,
|
||||
cost=f"{camp.cost:.2f}",
|
||||
active=_("csv_yes") if camp.is_active else _("csv_no"),
|
||||
starts=stats["starts"],
|
||||
trials=stats["trials"],
|
||||
payers=stats["payers"],
|
||||
revenue=f"{stats['revenue']:.2f}",
|
||||
)
|
||||
|
||||
from bot.keyboards.inline.admin_keyboards import get_ad_card_keyboard
|
||||
reply_markup = get_ad_card_keyboard(i18n, current_lang, camp.ad_campaign_id, back_page)
|
||||
try:
|
||||
await callback.message.edit_text(text, reply_markup=reply_markup, parse_mode="HTML")
|
||||
await callback.answer()
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to show ad card: {e}")
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("admin_ads:delete:"))
|
||||
async def ads_delete_prompt(callback: types.CallbackQuery, settings: Settings, i18n_data: dict):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n or not callback.message:
|
||||
await callback.answer("Language error.", show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
_, _, camp_id_str, back_page_str = callback.data.split(":", 3)
|
||||
camp_id = int(camp_id_str)
|
||||
back_page = int(back_page_str)
|
||||
except Exception:
|
||||
await callback.answer(i18n.gettext(current_lang, "error_try_again"), show_alert=True)
|
||||
return
|
||||
|
||||
from bot.keyboards.inline.admin_keyboards import get_confirmation_keyboard
|
||||
confirm_text = i18n.gettext(current_lang, "admin_ads_delete_confirm", id=camp_id)
|
||||
kb = get_confirmation_keyboard(
|
||||
yes_callback_data=f"admin_ads:delete_confirm:{camp_id}:{back_page}",
|
||||
no_callback_data=f"admin_ads:delete_cancel:{camp_id}:{back_page}",
|
||||
i18n_instance=i18n,
|
||||
lang=current_lang,
|
||||
)
|
||||
try:
|
||||
await callback.message.edit_text(confirm_text, reply_markup=kb)
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("admin_ads:delete_cancel:"))
|
||||
async def ads_delete_cancel(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession):
|
||||
# Return to the ad card view
|
||||
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
|
||||
if not i18n or not callback.message:
|
||||
await callback.answer("Language error.", show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
parts = callback.data.split(":", 3)
|
||||
camp_id = int(parts[2])
|
||||
back_page = int(parts[3])
|
||||
except Exception:
|
||||
await callback.answer(_("error_try_again"), show_alert=True)
|
||||
return
|
||||
|
||||
camp = await ad_dal.get_campaign_by_id(session, camp_id)
|
||||
if not camp:
|
||||
await callback.answer(_("admin_ads_not_found", default="Кампания не найдена."), show_alert=True)
|
||||
return
|
||||
try:
|
||||
stats = await ad_dal.get_campaign_stats(session, camp_id)
|
||||
except Exception:
|
||||
stats = {"starts": 0, "trials": 0, "payers": 0, "revenue": 0.0}
|
||||
text = _(
|
||||
"admin_ads_card",
|
||||
id=camp.ad_campaign_id,
|
||||
source=camp.source,
|
||||
start_param=camp.start_param,
|
||||
cost=f"{camp.cost:.2f}",
|
||||
active=_("csv_yes") if camp.is_active else _("csv_no"),
|
||||
starts=stats["starts"],
|
||||
trials=stats["trials"],
|
||||
payers=stats["payers"],
|
||||
revenue=f"{stats['revenue']:.2f}",
|
||||
)
|
||||
from bot.keyboards.inline.admin_keyboards import get_ad_card_keyboard
|
||||
reply_markup = get_ad_card_keyboard(i18n, current_lang, camp.ad_campaign_id, back_page)
|
||||
try:
|
||||
await callback.message.edit_text(text, reply_markup=reply_markup, parse_mode="HTML")
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("admin_ads:delete_confirm:"))
|
||||
async def ads_delete_confirm(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession):
|
||||
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
|
||||
if not i18n or not callback.message:
|
||||
await callback.answer("Language error.", show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
parts = callback.data.split(":", 3)
|
||||
camp_id = int(parts[2])
|
||||
back_page = int(parts[3])
|
||||
except Exception:
|
||||
await callback.answer(_("error_try_again"), show_alert=True)
|
||||
return
|
||||
|
||||
existed = await ad_dal.delete_campaign(session, camp_id)
|
||||
if not existed:
|
||||
await callback.answer(_("admin_ads_not_found", default="Кампания не найдена."), show_alert=True)
|
||||
return
|
||||
await session.commit()
|
||||
|
||||
# After delete, show list page (may shift due to fewer items)
|
||||
totals = await ad_dal.get_totals(session)
|
||||
overview = _(
|
||||
"admin_ads_overview",
|
||||
revenue=f"{totals.get('revenue', 0.0):.2f}",
|
||||
cost=f"{totals.get('cost', 0.0):.2f}",
|
||||
)
|
||||
total_count = await ad_dal.count_campaigns(session)
|
||||
total_pages = max(1, (total_count + PAGE_SIZE - 1) // PAGE_SIZE)
|
||||
page = max(0, min(back_page, total_pages - 1))
|
||||
campaigns = await ad_dal.list_campaigns_paged(session, page=page, page_size=PAGE_SIZE)
|
||||
text = overview + "\n\n" + _("admin_ads_header")
|
||||
from bot.keyboards.inline.admin_keyboards import get_ads_list_keyboard
|
||||
reply_markup = get_ads_list_keyboard(i18n, current_lang, campaigns, page, total_pages)
|
||||
try:
|
||||
await callback.message.edit_text(text, reply_markup=reply_markup)
|
||||
await callback.answer(_("admin_ads_deleted_success"), show_alert=True)
|
||||
except Exception:
|
||||
await callback.answer(_("admin_ads_deleted_success"), show_alert=True)
|
||||
@router.callback_query(F.data == "admin_action:ads_create")
|
||||
async def ads_create_start(callback: types.CallbackQuery, state: FSMContext, settings: Settings, i18n_data: dict):
|
||||
from bot.states.admin_states import AdminStates
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
if not i18n or not callback.message:
|
||||
await callback.answer("Language error.", show_alert=True)
|
||||
return
|
||||
|
||||
await state.set_state(AdminStates.waiting_for_ad_source)
|
||||
await callback.message.edit_text(_("admin_ads_create_source_prompt"))
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@router.message(
|
||||
StateFilter(
|
||||
AdminStates.waiting_for_ad_source,
|
||||
AdminStates.waiting_for_ad_start_param,
|
||||
AdminStates.waiting_for_ad_cost,
|
||||
),
|
||||
F.text,
|
||||
)
|
||||
async def ads_create_flow(message: types.Message, state: FSMContext, settings: Settings, i18n_data: dict, session: AsyncSession):
|
||||
current_state = await state.get_state()
|
||||
if current_state not in (
|
||||
AdminStates.waiting_for_ad_source.state,
|
||||
AdminStates.waiting_for_ad_start_param.state,
|
||||
AdminStates.waiting_for_ad_cost.state,
|
||||
):
|
||||
return
|
||||
|
||||
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
|
||||
|
||||
if current_state == AdminStates.waiting_for_ad_source.state:
|
||||
source = message.text.strip()
|
||||
if not source or len(source) > 64:
|
||||
await message.answer(_("admin_ads_invalid_source"))
|
||||
return
|
||||
await state.update_data(ad_source=source)
|
||||
await state.set_state(AdminStates.waiting_for_ad_start_param)
|
||||
await message.answer(_("admin_ads_create_start_param_prompt"))
|
||||
return
|
||||
|
||||
if current_state == AdminStates.waiting_for_ad_start_param.state:
|
||||
start_param = message.text.strip()
|
||||
# Allow alnum underscore dash only
|
||||
import re as _re
|
||||
if not _re.match(r"^[A-Za-z0-9_\-]{2,64}$", start_param):
|
||||
await message.answer(_("admin_ads_invalid_start_param"))
|
||||
return
|
||||
await state.update_data(ad_start_param=start_param)
|
||||
await state.set_state(AdminStates.waiting_for_ad_cost)
|
||||
await message.answer(_("admin_ads_create_cost_prompt"))
|
||||
return
|
||||
|
||||
if current_state == AdminStates.waiting_for_ad_cost.state:
|
||||
text = message.text.replace(",", ".").strip()
|
||||
try:
|
||||
cost = float(text)
|
||||
if cost < 0 or cost > 1e8:
|
||||
raise ValueError()
|
||||
except Exception:
|
||||
await message.answer(_("admin_ads_invalid_cost"))
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
try:
|
||||
campaign = await ad_dal.create_campaign(
|
||||
session,
|
||||
source=data.get("ad_source", "unknown"),
|
||||
start_param=data.get("ad_start_param", "NA"),
|
||||
cost=cost,
|
||||
)
|
||||
await session.commit()
|
||||
except ValueError as ve:
|
||||
await session.rollback()
|
||||
if str(ve) == "ad_campaign_start_param_exists":
|
||||
await message.answer(_("admin_ads_start_param_exists"))
|
||||
else:
|
||||
await message.answer(_("error_occurred_try_again"))
|
||||
return
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
logging.error(f"Failed to create ad campaign: {e}", exc_info=True)
|
||||
await message.answer(_("error_occurred_try_again"))
|
||||
return
|
||||
|
||||
await state.clear()
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
await message.answer(
|
||||
_(
|
||||
"admin_ads_created_success",
|
||||
id=campaign.ad_campaign_id,
|
||||
source=campaign.source,
|
||||
start_param=campaign.start_param,
|
||||
cost=f"{campaign.cost:.2f}",
|
||||
)
|
||||
)
|
||||
# Offer back to ads menu
|
||||
from bot.keyboards.inline.admin_keyboards import get_ads_menu_keyboard
|
||||
await message.answer(_("admin_ads_back_to_menu_hint"), reply_markup=get_ads_menu_keyboard(i18n, current_lang))
|
||||
|
||||
|
||||
+160
-23
@@ -1,6 +1,7 @@
|
||||
import logging
|
||||
import asyncio
|
||||
from aiogram import Router, F, types, Bot
|
||||
from aiogram.exceptions import TelegramRetryAfter, TelegramBadRequest
|
||||
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from typing import Optional
|
||||
@@ -17,6 +18,8 @@ from bot.keyboards.inline.admin_keyboards import (
|
||||
get_admin_panel_keyboard,
|
||||
)
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.utils.message_queue import get_queue_manager
|
||||
from bot.utils import get_message_content, send_message_by_type, send_message_via_queue, MessageContent
|
||||
|
||||
router = Router(name="admin_broadcast_router")
|
||||
|
||||
@@ -56,13 +59,14 @@ async def broadcast_message_prompt_handler(
|
||||
await state.set_state(AdminStates.waiting_for_broadcast_message)
|
||||
|
||||
|
||||
@router.message(AdminStates.waiting_for_broadcast_message, F.text)
|
||||
@router.message(AdminStates.waiting_for_broadcast_message)
|
||||
async def process_broadcast_message_handler(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
i18n_data: dict,
|
||||
settings: Settings,
|
||||
session: AsyncSession,
|
||||
bot: Bot,
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
@@ -73,25 +77,106 @@ async def process_broadcast_message_handler(
|
||||
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
# Сохраняем в state исходный текст и entities
|
||||
text = message.text or message.caption or ""
|
||||
# Определяем тип содержимого и сохраняем данные в state
|
||||
entities = message.entities or message.caption_entities or []
|
||||
content = get_message_content(message)
|
||||
|
||||
# Если нет ни текста, ни медиа — ошибка
|
||||
if not content.text and not content.file_id:
|
||||
await message.answer(_("admin_broadcast_error_no_message"))
|
||||
return
|
||||
|
||||
# Сохраняем данные для рассылки
|
||||
await state.update_data(
|
||||
broadcast_text=text,
|
||||
broadcast_text=content.text,
|
||||
broadcast_entities=entities,
|
||||
broadcast_content_type=content.content_type,
|
||||
broadcast_file_id=content.file_id,
|
||||
broadcast_target="all",
|
||||
)
|
||||
|
||||
preview_snippet = (text[:200] + "...") if len(text) > 200 else text
|
||||
confirmation_prompt = _("admin_broadcast_confirm_prompt", message_preview=preview_snippet)
|
||||
# Отправляем превью-копию того, что будет разослано
|
||||
try:
|
||||
# Для медиа-сообщений используем caption_entities, для текста - entities
|
||||
if content.content_type == "text":
|
||||
await send_message_by_type(
|
||||
bot,
|
||||
chat_id=message.chat.id,
|
||||
content=content,
|
||||
parse_mode="HTML",
|
||||
entities=entities,
|
||||
disable_web_page_preview=True,
|
||||
disable_notification=True,
|
||||
)
|
||||
else:
|
||||
await send_message_by_type(
|
||||
bot,
|
||||
chat_id=message.chat.id,
|
||||
content=content,
|
||||
parse_mode="HTML",
|
||||
caption_entities=entities,
|
||||
disable_web_page_preview=True,
|
||||
disable_notification=True,
|
||||
)
|
||||
except TelegramBadRequest as e:
|
||||
await message.answer(
|
||||
_(
|
||||
"admin_broadcast_invalid_html",
|
||||
default="❌ Некорректный HTML в сообщении. Пожалуйста, отправьте корректный HTML (поддерживаются теги Telegram) или уберите теги.\nОшибка: {error}",
|
||||
error=str(e),
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
# Показываем короткое подтверждение без дублирования текста — сообщение выше служит превью
|
||||
confirmation_prompt = _("admin_broadcast_confirm_prompt_short")
|
||||
|
||||
await message.answer(
|
||||
confirmation_prompt,
|
||||
reply_markup=get_broadcast_confirmation_keyboard(current_lang, i18n),
|
||||
reply_markup=get_broadcast_confirmation_keyboard(current_lang, i18n, target="all"),
|
||||
)
|
||||
await state.set_state(AdminStates.confirming_broadcast)
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
F.data.startswith("broadcast_target:"),
|
||||
AdminStates.confirming_broadcast,
|
||||
)
|
||||
async def change_broadcast_target_handler(
|
||||
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 updating selection.", show_alert=True)
|
||||
return
|
||||
|
||||
new_target = callback.data.split(":")[1]
|
||||
if new_target not in {"all", "active", "inactive"}:
|
||||
await callback.answer("Unknown target.", show_alert=True)
|
||||
return
|
||||
|
||||
await state.update_data(broadcast_target=new_target)
|
||||
user_fsm_data = await state.get_data()
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
confirmation_prompt = _(
|
||||
"admin_broadcast_confirm_prompt_short"
|
||||
)
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
confirmation_prompt,
|
||||
reply_markup=get_broadcast_confirmation_keyboard(
|
||||
current_lang, i18n, target=new_target
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
F.data == "admin_action:main", AdminStates.waiting_for_broadcast_message
|
||||
)
|
||||
@@ -148,10 +233,15 @@ async def confirm_broadcast_callback_handler(
|
||||
user_fsm_data = await state.get_data()
|
||||
|
||||
if action == "send":
|
||||
text = user_fsm_data.get("broadcast_text")
|
||||
# Создаем объект контента из сохраненных данных
|
||||
content = MessageContent(
|
||||
content_type=user_fsm_data.get("broadcast_content_type", "text"),
|
||||
file_id=user_fsm_data.get("broadcast_file_id"),
|
||||
text=user_fsm_data.get("broadcast_text")
|
||||
)
|
||||
entities = user_fsm_data.get("broadcast_entities", [])
|
||||
|
||||
if not text:
|
||||
|
||||
if not content.text and content.content_type == "text":
|
||||
await callback.message.edit_text(_("admin_broadcast_error_no_message"))
|
||||
await state.clear()
|
||||
await callback.answer(
|
||||
@@ -162,32 +252,60 @@ async def confirm_broadcast_callback_handler(
|
||||
await callback.message.edit_text(_("admin_broadcast_sending_started"), reply_markup=None)
|
||||
await callback.answer()
|
||||
|
||||
user_ids = await user_dal.get_all_active_user_ids_for_broadcast(session)
|
||||
target = user_fsm_data.get("broadcast_target", "all")
|
||||
if target == "active":
|
||||
user_ids = await user_dal.get_user_ids_with_active_subscription(session)
|
||||
elif target == "inactive":
|
||||
user_ids = await user_dal.get_user_ids_without_active_subscription(session)
|
||||
else:
|
||||
user_ids = await user_dal.get_all_active_user_ids_for_broadcast(session)
|
||||
|
||||
sent_count = 0
|
||||
failed_count = 0
|
||||
admin_user = callback.from_user
|
||||
logging.info(
|
||||
f"Admin {admin_user.id} broadcasting '{text[:50]}...' to {len(user_ids)} users."
|
||||
f"Admin {admin_user.id} broadcasting '{(content.text or '')[:50]}...' to {len(user_ids)} users."
|
||||
)
|
||||
|
||||
# Get message queue manager
|
||||
queue_manager = get_queue_manager()
|
||||
if not queue_manager:
|
||||
await callback.message.edit_text("❌ Ошибка: система очередей не инициализирована", reply_markup=None)
|
||||
return
|
||||
|
||||
# Queue all messages for sending
|
||||
for uid in user_ids:
|
||||
try:
|
||||
await bot.send_message(
|
||||
chat_id=uid,
|
||||
text=text,
|
||||
entities=entities,
|
||||
)
|
||||
# Для медиа-сообщений используем caption_entities, для текста - entities
|
||||
if content.content_type == "text":
|
||||
await send_message_via_queue(
|
||||
queue_manager,
|
||||
uid,
|
||||
content,
|
||||
parse_mode="HTML",
|
||||
entities=entities,
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
else:
|
||||
await send_message_via_queue(
|
||||
queue_manager,
|
||||
uid,
|
||||
content,
|
||||
parse_mode="HTML",
|
||||
caption_entities=entities,
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
sent_count += 1
|
||||
|
||||
|
||||
# Log successful queuing
|
||||
await message_log_dal.create_message_log(
|
||||
session,
|
||||
{
|
||||
"user_id": admin_user.id,
|
||||
"telegram_username": admin_user.username,
|
||||
"telegram_first_name": admin_user.first_name,
|
||||
"event_type": "admin_broadcast_sent",
|
||||
"content": f"To user {uid}: {text[:70]}...",
|
||||
"event_type": "admin_broadcast_queued",
|
||||
"content": f"To user {uid}: [{content.content_type}] {(content.text or '')[:70]}...",
|
||||
"is_admin_event": True,
|
||||
"target_user_id": uid,
|
||||
},
|
||||
@@ -195,7 +313,7 @@ async def confirm_broadcast_callback_handler(
|
||||
except Exception as e:
|
||||
failed_count += 1
|
||||
logging.warning(
|
||||
f"Failed to send broadcast to {uid}: {type(e).__name__} – {e}"
|
||||
f"Failed to queue broadcast to {uid}: {type(e).__name__} – {e}"
|
||||
)
|
||||
await message_log_dal.create_message_log(
|
||||
session,
|
||||
@@ -209,7 +327,6 @@ async def confirm_broadcast_callback_handler(
|
||||
"target_user_id": uid,
|
||||
},
|
||||
)
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
try:
|
||||
await session.commit()
|
||||
@@ -217,7 +334,27 @@ async def confirm_broadcast_callback_handler(
|
||||
await session.rollback()
|
||||
logging.error(f"Error committing broadcast logs: {e_commit}")
|
||||
|
||||
result_message = _("admin_broadcast_finished_stats", sent_count=sent_count, failed_count=failed_count)
|
||||
# Get queue stats for detailed report
|
||||
queue_stats = queue_manager.get_queue_stats()
|
||||
|
||||
result_message = (
|
||||
_(
|
||||
"broadcast_queue_result",
|
||||
default=(
|
||||
"🚀 Рассылка поставлена в очередь!\n"
|
||||
"📤 В очередь добавлено: {sent_count}\n"
|
||||
"❌ Ошибок: {failed_count}\n\n"
|
||||
"📊 Статус очередей:\n"
|
||||
"👥 Очередь пользователей: {user_queue_size} сообщений\n"
|
||||
"📢 Очередь групп: {group_queue_size} сообщений\n\n"
|
||||
"ℹ️ Сообщения будут отправлены автоматически с соблюдением лимитов Telegram."
|
||||
),
|
||||
sent_count=sent_count,
|
||||
failed_count=failed_count,
|
||||
user_queue_size=queue_stats["user_queue_size"],
|
||||
group_queue_size=queue_stats["group_queue_size"],
|
||||
)
|
||||
)
|
||||
await callback.message.answer(
|
||||
result_message,
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
|
||||
|
||||
@@ -6,13 +6,20 @@ from typing import Optional
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config.settings import Settings
|
||||
from bot.keyboards.inline.admin_keyboards import get_admin_panel_keyboard
|
||||
from bot.keyboards.inline.admin_keyboards import (
|
||||
get_admin_panel_keyboard, get_stats_monitoring_keyboard,
|
||||
get_user_management_keyboard, get_ban_management_keyboard,
|
||||
get_promo_marketing_keyboard, get_system_functions_keyboard
|
||||
)
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.utils.message_queue import get_queue_manager
|
||||
|
||||
from . import broadcast as admin_broadcast_handlers
|
||||
from . import promo_codes as admin_promo_handlers
|
||||
from .promo import create as admin_promo_create_handlers
|
||||
from .promo import manage as admin_promo_manage_handlers
|
||||
from .promo import bulk as admin_promo_bulk_handlers
|
||||
from . import user_management as admin_user_mgmnt_handlers
|
||||
from . import statistics as admin_stats_handlers
|
||||
from . import sync_admin as admin_sync_handlers
|
||||
@@ -73,13 +80,16 @@ async def admin_panel_actions_callback_handler(
|
||||
await admin_broadcast_handlers.broadcast_message_prompt_handler(
|
||||
callback, state, i18n_data, settings, session)
|
||||
elif action == "create_promo":
|
||||
await admin_promo_handlers.create_promo_prompt_handler(
|
||||
await admin_promo_create_handlers.create_promo_prompt_handler(
|
||||
callback, state, i18n_data, settings, session)
|
||||
elif action == "create_bulk_promo":
|
||||
await admin_promo_bulk_handlers.create_bulk_promo_prompt_handler(
|
||||
callback, state, i18n_data, settings, session)
|
||||
elif action == "manage_promos":
|
||||
await admin_promo_handlers.manage_promo_codes_handler(
|
||||
await admin_promo_manage_handlers.manage_promo_codes_handler(
|
||||
callback, i18n_data, settings, session)
|
||||
elif action == "view_promos":
|
||||
await admin_promo_handlers.view_promo_codes_handler(
|
||||
await admin_promo_manage_handlers.view_promo_codes_handler(
|
||||
callback, i18n_data, settings, session)
|
||||
elif action == "ban_user_prompt":
|
||||
await admin_user_mgmnt_handlers.ban_user_prompt_handler(
|
||||
@@ -87,6 +97,10 @@ async def admin_panel_actions_callback_handler(
|
||||
elif action == "unban_user_prompt":
|
||||
await admin_user_mgmnt_handlers.unban_user_prompt_handler(
|
||||
callback, state, i18n_data, settings, session)
|
||||
elif action == "users_management":
|
||||
from . import user_management as admin_user_management_handlers
|
||||
await admin_user_management_handlers.user_management_menu_handler(
|
||||
callback, state, i18n_data, settings, session)
|
||||
elif action == "view_banned":
|
||||
|
||||
await admin_user_mgmnt_handlers.view_banned_users_handler(
|
||||
@@ -94,6 +108,9 @@ async def admin_panel_actions_callback_handler(
|
||||
elif action == "view_logs_menu":
|
||||
await admin_logs_handlers.display_logs_menu(callback, i18n_data,
|
||||
settings, session)
|
||||
elif action == "promo_management":
|
||||
await admin_promo_manage_handlers.promo_management_handler(
|
||||
callback, i18n_data, settings, session)
|
||||
elif action == "sync_panel":
|
||||
|
||||
await admin_sync_handlers.sync_command_handler(
|
||||
@@ -104,6 +121,18 @@ async def admin_panel_actions_callback_handler(
|
||||
panel_service=panel_service,
|
||||
session=session)
|
||||
await callback.answer(_("admin_sync_initiated_from_panel"))
|
||||
elif action == "queue_status":
|
||||
await show_queue_status_handler(callback, i18n_data)
|
||||
elif action == "view_payments":
|
||||
from . import payments as admin_payments_handlers
|
||||
await admin_payments_handlers.view_payments_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)
|
||||
elif action == "ads_create":
|
||||
from . import ads as admin_ads_handlers
|
||||
await admin_ads_handlers.ads_create_start(callback, state, settings, i18n_data)
|
||||
elif action == "main":
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
@@ -121,3 +150,107 @@ async def admin_panel_actions_callback_handler(
|
||||
f"Unknown admin_action received: {action} from callback {callback.data}"
|
||||
)
|
||||
await callback.answer(_("admin_unknown_action"), show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("admin_section:"))
|
||||
async def admin_section_handler(callback: types.CallbackQuery, state: FSMContext,
|
||||
settings: Settings, i18n_data: dict, session: AsyncSession):
|
||||
section = callback.data.split(":")[1]
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n:
|
||||
await callback.answer("Language error.", show_alert=True)
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
if not callback.message:
|
||||
await callback.answer("Error: message context lost.", show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
if section == "stats_monitoring":
|
||||
await callback.message.edit_text(
|
||||
_("admin_stats_and_monitoring_section"),
|
||||
reply_markup=get_stats_monitoring_keyboard(i18n, current_lang)
|
||||
)
|
||||
elif section == "user_management":
|
||||
await callback.message.edit_text(
|
||||
_("admin_user_management_section"),
|
||||
reply_markup=get_user_management_keyboard(i18n, current_lang)
|
||||
)
|
||||
elif section == "ban_management":
|
||||
await callback.message.edit_text(
|
||||
_("admin_ban_management_section"),
|
||||
reply_markup=get_ban_management_keyboard(i18n, current_lang)
|
||||
)
|
||||
elif section == "promo_marketing":
|
||||
await callback.message.edit_text(
|
||||
_("admin_promo_marketing_section"),
|
||||
reply_markup=get_promo_marketing_keyboard(i18n, current_lang)
|
||||
)
|
||||
elif section == "system_functions":
|
||||
await callback.message.edit_text(
|
||||
_("admin_system_functions_section"),
|
||||
reply_markup=get_system_functions_keyboard(i18n, current_lang)
|
||||
)
|
||||
else:
|
||||
await callback.answer(_("admin_unknown_action"), show_alert=True)
|
||||
return
|
||||
|
||||
await callback.answer()
|
||||
except Exception as e:
|
||||
logging.error(f"Error handling admin section {section}: {e}")
|
||||
await callback.message.answer(
|
||||
_("error_occurred_try_again"),
|
||||
reply_markup=get_admin_panel_keyboard(i18n, current_lang, settings)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def show_queue_status_handler(callback: types.CallbackQuery, i18n_data: dict):
|
||||
"""Show message queue status to admin"""
|
||||
current_lang = i18n_data.get("current_language", "ru")
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n or not callback.message:
|
||||
await callback.answer("Error processing request.", show_alert=True)
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
queue_manager = get_queue_manager()
|
||||
if not queue_manager:
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
await callback.message.edit_text(
|
||||
"❌ Система очередей не инициализирована",
|
||||
reply_markup=InlineKeyboardBuilder().button(
|
||||
text=_("back_to_admin_panel_button"),
|
||||
callback_data="admin_action:main"
|
||||
).as_markup()
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
try:
|
||||
stats = queue_manager.get_queue_stats()
|
||||
|
||||
message_text = _(
|
||||
"admin_queue_status_info",
|
||||
user_queue_size=stats['user_queue_size'],
|
||||
user_processing="✅ Да" if stats['user_queue_processing'] else "❌ Нет",
|
||||
user_recent=stats['user_recent_sends'],
|
||||
group_queue_size=stats['group_queue_size'],
|
||||
group_processing="✅ Да" if stats['group_queue_processing'] else "❌ Нет",
|
||||
group_recent=stats['group_recent_sends']
|
||||
)
|
||||
|
||||
from bot.keyboards.inline.admin_keyboards import get_back_to_admin_panel_keyboard
|
||||
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error getting queue status: {e}")
|
||||
await callback.answer("❌ Ошибка получения статуса очередей", show_alert=True)
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
import csv
|
||||
import io
|
||||
from datetime import datetime
|
||||
from aiogram import Router, F, types, Bot
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from typing import Optional, List, Dict, Any
|
||||
@@ -321,3 +324,106 @@ async def cancel_log_user_input_state_to_menu(callback: types.CallbackQuery,
|
||||
await state.clear()
|
||||
|
||||
await display_logs_menu(callback, i18n_data, settings, session)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_logs:export_csv")
|
||||
async def export_logs_csv_handler(callback: types.CallbackQuery,
|
||||
settings: Settings, i18n_data: dict,
|
||||
session: AsyncSession):
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
if not i18n or not callback.message:
|
||||
await callback.answer("Error processing CSV export.", show_alert=True)
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
await callback.answer(_(
|
||||
"admin_logs_csv_export_started",
|
||||
default="🔄 Начинаю экспорт логов в CSV..."
|
||||
))
|
||||
|
||||
try:
|
||||
# Get all logs (limit to 10000 for performance)
|
||||
logs_models = await message_log_dal.get_all_message_logs(
|
||||
session, limit=10000, offset=0)
|
||||
|
||||
if not logs_models:
|
||||
await callback.message.answer(_(
|
||||
"admin_logs_csv_no_data",
|
||||
default="❌ Нет данных для экспорта"
|
||||
))
|
||||
return
|
||||
|
||||
# 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", default="Log ID"),
|
||||
_("admin_csv_header_timestamp", default="Timestamp"),
|
||||
_("admin_csv_header_user_id", default="User ID"),
|
||||
_("admin_csv_header_telegram_username", default="Telegram Username"),
|
||||
_("admin_csv_header_telegram_first_name", default="Telegram First Name"),
|
||||
_("admin_csv_header_event_type", default="Event Type"),
|
||||
_("admin_csv_header_content", default="Content"),
|
||||
_("admin_csv_header_is_admin_event", default="Is Admin Event"),
|
||||
_("admin_csv_header_target_user_id", default="Target User ID"),
|
||||
_("admin_csv_header_raw_update_preview", default="Raw Update Preview")
|
||||
]
|
||||
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,
|
||||
log.user_id or '',
|
||||
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
|
||||
]
|
||||
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=_(
|
||||
"admin_logs_csv_export_success",
|
||||
default="✅ Экспорт логов завершен!\n\n📊 Записей: {count}\n📅 Дата экспорта: {date}",
|
||||
count=len(logs_models),
|
||||
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(_(
|
||||
"admin_logs_csv_export_failed",
|
||||
default="❌ Ошибка при экспорте логов: {error}",
|
||||
error=str(e)
|
||||
))
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
import logging
|
||||
import csv
|
||||
import io
|
||||
from aiogram import Router, F, types
|
||||
from aiogram.filters import StateFilter
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional, List
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config.settings import Settings
|
||||
from db.dal import payment_dal
|
||||
from db.models import Payment
|
||||
from bot.keyboards.inline.admin_keyboards import get_back_to_admin_panel_keyboard
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
|
||||
router = Router(name="admin_payments_router")
|
||||
|
||||
|
||||
async def get_payments_with_pagination(session: AsyncSession, page: int = 0,
|
||||
page_size: int = 10) -> tuple[List[Payment], int]:
|
||||
"""Get payments with pagination and total count."""
|
||||
offset = page * page_size
|
||||
|
||||
# Get total count
|
||||
total_count = await payment_dal.get_payments_count(session)
|
||||
|
||||
# Get payments for current page
|
||||
payments = await payment_dal.get_recent_payment_logs_with_user(
|
||||
session, limit=page_size, offset=offset
|
||||
)
|
||||
|
||||
return payments, total_count
|
||||
|
||||
|
||||
def format_payment_text(payment: Payment, i18n: JsonI18n, lang: str) -> str:
|
||||
"""Format single payment info as text."""
|
||||
_ = lambda key, **kwargs: i18n.gettext(lang, key, **kwargs)
|
||||
|
||||
status_emoji = "✅" if payment.status == 'succeeded' else (
|
||||
"⏳" if payment.status in ['pending', 'pending_yookassa'] else "❌"
|
||||
)
|
||||
|
||||
user_info = f"User {payment.user_id}"
|
||||
if payment.user and payment.user.username:
|
||||
user_info += f" (@{payment.user.username})"
|
||||
elif payment.user and payment.user.first_name:
|
||||
user_info += f" ({payment.user.first_name})"
|
||||
|
||||
payment_date = payment.created_at.strftime('%Y-%m-%d %H:%M') if payment.created_at else "N/A"
|
||||
|
||||
provider_text = {
|
||||
'yookassa': 'YooKassa',
|
||||
'tribute': 'Tribute',
|
||||
'telegram_stars': 'Telegram Stars',
|
||||
'cryptopay': 'CryptoPay'
|
||||
}.get(payment.provider, payment.provider or 'Unknown')
|
||||
|
||||
return (
|
||||
f"{status_emoji} <b>{payment.amount} {payment.currency}</b>\n"
|
||||
f"👤 {user_info}\n"
|
||||
f"💳 {provider_text}\n"
|
||||
f"📅 {payment_date}\n"
|
||||
f"📋 {payment.status}\n"
|
||||
f"📝 {payment.description or 'N/A'}"
|
||||
)
|
||||
|
||||
|
||||
async def view_payments_handler(callback: types.CallbackQuery, i18n_data: dict,
|
||||
settings: Settings, session: AsyncSession, page: int = 0):
|
||||
"""Display paginated list of all payments."""
|
||||
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 request.", show_alert=True)
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
page_size = 5 # Show 5 payments per page
|
||||
payments, total_count = await get_payments_with_pagination(session, page, page_size)
|
||||
total_pages = (total_count + page_size - 1) // page_size if total_count > 0 else 1
|
||||
|
||||
if not payments and page == 0:
|
||||
await callback.message.edit_text(
|
||||
_("admin_no_payments_found", default="Платежи не найдены."),
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
# Format payments text
|
||||
text_parts = [_("admin_payments_header", default="💰 <b>Все платежи</b>")]
|
||||
text_parts.append(_("admin_payments_pagination_info",
|
||||
shown=len(payments),
|
||||
total=total_count,
|
||||
current_page=page + 1,
|
||||
total_pages=total_pages) + "\n")
|
||||
|
||||
for i, payment in enumerate(payments, 1):
|
||||
text_parts.append(f"<b>{page * page_size + i}.</b> {format_payment_text(payment, i18n, current_lang)}")
|
||||
text_parts.append("") # Empty line between payments
|
||||
|
||||
# Build keyboard with pagination and export
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
# Pagination buttons
|
||||
nav_buttons = []
|
||||
if page > 0:
|
||||
nav_buttons.append(InlineKeyboardButton(text="⬅️", callback_data=f"payments_page:{page-1}"))
|
||||
|
||||
nav_buttons.append(InlineKeyboardButton(text=f"{page + 1}/{total_pages}", callback_data="noop"))
|
||||
|
||||
if page < total_pages - 1:
|
||||
nav_buttons.append(InlineKeyboardButton(text="➡️", callback_data=f"payments_page:{page+1}"))
|
||||
|
||||
if nav_buttons:
|
||||
builder.row(*nav_buttons)
|
||||
|
||||
# Export and refresh buttons
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_("admin_export_payments_csv", default="📊 Экспорт CSV"),
|
||||
callback_data="payments_export_csv"
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text=_("admin_refresh_payments", default="🔄 Обновить"),
|
||||
callback_data=f"payments_page:{page}"
|
||||
)
|
||||
)
|
||||
|
||||
# Back button
|
||||
builder.row(InlineKeyboardButton(
|
||||
text=_("back_to_admin_panel_button"),
|
||||
callback_data="admin_section:stats_monitoring"
|
||||
))
|
||||
|
||||
await callback.message.edit_text(
|
||||
"\n".join(text_parts),
|
||||
reply_markup=builder.as_markup(),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("payments_page:"))
|
||||
async def payments_pagination_handler(callback: types.CallbackQuery, i18n_data: dict,
|
||||
settings: Settings, session: AsyncSession):
|
||||
"""Handle pagination for payments list."""
|
||||
try:
|
||||
page = int(callback.data.split(":")[1])
|
||||
await view_payments_handler(callback, i18n_data, settings, session, page)
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer("Error processing pagination.", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "payments_export_csv")
|
||||
async def export_payments_csv_handler(callback: types.CallbackQuery, i18n_data: dict,
|
||||
settings: Settings, session: AsyncSession):
|
||||
"""Export all successful payments to CSV file."""
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n:
|
||||
await callback.answer("Language service error.", show_alert=True)
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
try:
|
||||
# Get all successful payments
|
||||
all_payments = await payment_dal.get_all_succeeded_payments_with_user(session)
|
||||
|
||||
if not all_payments:
|
||||
await callback.answer(
|
||||
_("admin_no_payments_to_export", default="Нет платежей для экспорта."),
|
||||
show_alert=True
|
||||
)
|
||||
return
|
||||
|
||||
# Create CSV in memory
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
|
||||
# Write header
|
||||
writer.writerow([
|
||||
_("admin_csv_payment_id", default="ID"),
|
||||
_("admin_csv_user_id", default="User ID"),
|
||||
_("admin_csv_username", default="Username"),
|
||||
_("admin_csv_first_name", default="First Name"),
|
||||
_("admin_csv_amount", default="Amount"),
|
||||
_("admin_csv_currency", default="Currency"),
|
||||
_("admin_csv_provider", default="Provider"),
|
||||
_("admin_csv_status", default="Status"),
|
||||
_("admin_csv_description", default="Description"),
|
||||
_("admin_csv_months", default="Months"),
|
||||
_("admin_csv_created_at", default="Created At"),
|
||||
_("admin_csv_provider_payment_id", default="Provider Payment ID")
|
||||
])
|
||||
|
||||
# Write payment data
|
||||
for payment in all_payments:
|
||||
writer.writerow([
|
||||
payment.payment_id,
|
||||
payment.user_id,
|
||||
payment.user.username if payment.user and payment.user.username else "",
|
||||
payment.user.first_name if payment.user and payment.user.first_name else "",
|
||||
payment.amount,
|
||||
payment.currency,
|
||||
payment.provider or "",
|
||||
payment.status,
|
||||
payment.description or "",
|
||||
payment.subscription_duration_months or "",
|
||||
payment.created_at.strftime('%Y-%m-%d %H:%M:%S') if payment.created_at else "",
|
||||
payment.provider_payment_id or ""
|
||||
])
|
||||
|
||||
# Prepare file
|
||||
csv_content = output.getvalue().encode('utf-8-sig') # UTF-8 with BOM for Excel
|
||||
output.close()
|
||||
|
||||
# Generate filename with current date
|
||||
current_time = datetime.now().strftime('%Y-%m-%d_%H-%M')
|
||||
filename = f"payments_export_{current_time}.csv"
|
||||
|
||||
# Send file
|
||||
from aiogram.types import BufferedInputFile
|
||||
file = BufferedInputFile(csv_content, filename=filename)
|
||||
|
||||
await callback.message.reply_document(
|
||||
document=file,
|
||||
caption=_("admin_payments_export_success",
|
||||
default="📊 Payments export completed!\nTotal records: {count}",
|
||||
count=len(all_payments))
|
||||
)
|
||||
|
||||
await callback.answer(
|
||||
_("admin_export_sent", default="File sent!"),
|
||||
show_alert=False
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to export payments CSV: {e}", exc_info=True)
|
||||
await callback.answer(f"❌ Ошибка экспорта: {str(e)}", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "noop")
|
||||
async def noop_handler(callback: types.CallbackQuery):
|
||||
"""Handle no-op callback (for pagination display)."""
|
||||
await callback.answer()
|
||||
@@ -0,0 +1,13 @@
|
||||
from aiogram import Router
|
||||
|
||||
from . import create
|
||||
from . import manage
|
||||
from . import bulk
|
||||
|
||||
promo_router_aggregate = Router(name="promo_features_router")
|
||||
|
||||
promo_router_aggregate.include_router(create.router)
|
||||
promo_router_aggregate.include_router(manage.router)
|
||||
promo_router_aggregate.include_router(bulk.router)
|
||||
|
||||
__all__ = ("promo_router_aggregate",)
|
||||
@@ -0,0 +1,568 @@
|
||||
import logging
|
||||
import random
|
||||
import string
|
||||
import csv
|
||||
import io
|
||||
from aiogram import Router, F, types
|
||||
from aiogram.filters import StateFilter
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config.settings import Settings
|
||||
from db.dal import promo_code_dal
|
||||
from bot.states.admin_states import AdminStates
|
||||
from bot.keyboards.inline.admin_keyboards import get_back_to_admin_panel_keyboard, get_admin_panel_keyboard
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
|
||||
router = Router(name="promo_bulk_router")
|
||||
|
||||
|
||||
async def create_bulk_promo_prompt_handler(callback: types.CallbackQuery,
|
||||
state: FSMContext, 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 preparing bulk promo creation.",
|
||||
show_alert=True)
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
# Step 1: Ask for quantity
|
||||
prompt_text = _(
|
||||
"admin_bulk_promo_step1_quantity",
|
||||
default="🎟 <b>Массовое создание промокодов</b>\n\n<b>Шаг 1 из 4:</b> Количество\n\nВведите количество промокодов для создания (1-100):"
|
||||
)
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
prompt_text,
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
|
||||
parse_mode="HTML")
|
||||
except Exception as e:
|
||||
logging.warning(
|
||||
f"Could not edit message for bulk promo prompt: {e}. Sending new.")
|
||||
await callback.message.answer(
|
||||
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_bulk_promo_quantity)
|
||||
|
||||
|
||||
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))
|
||||
|
||||
|
||||
# Step 1: Process quantity
|
||||
@router.message(AdminStates.waiting_for_bulk_promo_quantity, F.text)
|
||||
async def process_bulk_promo_quantity_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:
|
||||
quantity = int(message.text.strip())
|
||||
if not (1 <= quantity <= 100):
|
||||
await message.answer(_(
|
||||
"admin_bulk_promo_invalid_quantity",
|
||||
default="❌ Количество промокодов должно быть от 1 до 100"
|
||||
))
|
||||
return
|
||||
|
||||
await state.update_data(quantity=quantity)
|
||||
|
||||
# Step 2: Ask for bonus days
|
||||
prompt_text = _(
|
||||
"admin_bulk_promo_step2_bonus_days",
|
||||
default="🎟 <b>Массовое создание промокодов</b>\n\n<b>Шаг 2 из 4:</b> Бонусные дни\n\nКоличество: <b>{quantity}</b>\n\nВведите количество бонусных дней для каждого промокода (1-365):",
|
||||
quantity=quantity
|
||||
)
|
||||
|
||||
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_bulk_promo_bonus_days)
|
||||
|
||||
except ValueError:
|
||||
await message.answer(_(
|
||||
"admin_promo_invalid_number",
|
||||
default="❌ Введите корректное число"
|
||||
))
|
||||
except Exception as e:
|
||||
logging.error(f"Error processing bulk promo quantity: {e}")
|
||||
await message.answer(_("error_occurred_try_again"))
|
||||
|
||||
|
||||
# Step 2: Process bonus days
|
||||
@router.message(AdminStates.waiting_for_bulk_promo_bonus_days, F.text)
|
||||
async def process_bulk_promo_bonus_days_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:
|
||||
bonus_days = int(message.text.strip())
|
||||
if not (1 <= bonus_days <= 365):
|
||||
await message.answer(_(
|
||||
"admin_promo_invalid_bonus_days",
|
||||
default="❌ Количество бонусных дней должно быть от 1 до 365"
|
||||
))
|
||||
return
|
||||
|
||||
await state.update_data(bonus_days=bonus_days)
|
||||
|
||||
# Step 3: Ask for max activations
|
||||
data = await state.get_data()
|
||||
prompt_text = _(
|
||||
"admin_bulk_promo_step3_max_activations",
|
||||
default="🎟 <b>Массовое создание промокодов</b>\n\n<b>Шаг 3 из 4:</b> Лимит активаций\n\nКоличество: <b>{quantity}</b>\nБонусные дни: <b>{bonus_days}</b>\n\nВведите максимальное количество активаций для каждого промокода (1-10000):",
|
||||
quantity=data.get("quantity"),
|
||||
bonus_days=bonus_days
|
||||
)
|
||||
|
||||
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_bulk_promo_max_activations)
|
||||
|
||||
except ValueError:
|
||||
await message.answer(_(
|
||||
"admin_promo_invalid_number",
|
||||
default="❌ Введите корректное число"
|
||||
))
|
||||
except Exception as e:
|
||||
logging.error(f"Error processing bulk promo bonus days: {e}")
|
||||
await message.answer(_("error_occurred_try_again"))
|
||||
|
||||
|
||||
# Step 3: Process max activations
|
||||
@router.message(AdminStates.waiting_for_bulk_promo_max_activations, F.text)
|
||||
async def process_bulk_promo_max_activations_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:
|
||||
max_activations = int(message.text.strip())
|
||||
if not (1 <= max_activations <= 10000):
|
||||
await message.answer(_(
|
||||
"admin_promo_invalid_max_activations",
|
||||
default="❌ Максимальное количество активаций должно быть от 1 до 10000"
|
||||
))
|
||||
return
|
||||
|
||||
await state.update_data(max_activations=max_activations)
|
||||
|
||||
# Step 4: Ask for validity
|
||||
data = await state.get_data()
|
||||
prompt_text = _(
|
||||
"admin_bulk_promo_step4_validity",
|
||||
default="🎟 <b>Массовое создание промокодов</b>\n\n<b>Шаг 4 из 4:</b> Срок действия\n\nКоличество: <b>{quantity}</b>\nБонусные дни: <b>{bonus_days}</b>\nМакс. активаций: <b>{max_activations}</b>\n\nВыберите срок действия промокодов:",
|
||||
quantity=data.get("quantity"),
|
||||
bonus_days=data.get("bonus_days"),
|
||||
max_activations=max_activations
|
||||
)
|
||||
|
||||
# Create keyboard for validity options
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_("admin_promo_unlimited_validity", default="🔄 Без ограничений"),
|
||||
callback_data="bulk_promo_unlimited_validity"
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_("admin_promo_set_validity_days", default="📅 Указать дни"),
|
||||
callback_data="bulk_promo_set_validity"
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_("admin_back_to_panel", default="🔙 В админ панель"),
|
||||
callback_data="admin_action:main"
|
||||
)
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
prompt_text,
|
||||
reply_markup=builder.as_markup(),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await state.set_state(AdminStates.waiting_for_bulk_promo_validity_days)
|
||||
|
||||
except ValueError:
|
||||
await message.answer(_(
|
||||
"admin_promo_invalid_number",
|
||||
default="❌ Введите корректное число"
|
||||
))
|
||||
except Exception as e:
|
||||
logging.error(f"Error processing bulk promo max activations: {e}")
|
||||
await message.answer(_("error_occurred_try_again"))
|
||||
|
||||
|
||||
# Step 4: Handle unlimited validity
|
||||
@router.callback_query(F.data == "bulk_promo_unlimited_validity", StateFilter(AdminStates.waiting_for_bulk_promo_validity_days))
|
||||
async def process_bulk_promo_unlimited_validity(callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
i18n_data: dict,
|
||||
settings: Settings,
|
||||
session: AsyncSession):
|
||||
await state.update_data(validity_days=None)
|
||||
await create_bulk_promo_codes_final(callback, state, i18n_data, settings, session)
|
||||
|
||||
|
||||
# Step 4: Handle set validity
|
||||
@router.callback_query(F.data == "bulk_promo_set_validity", StateFilter(AdminStates.waiting_for_bulk_promo_validity_days))
|
||||
async def process_bulk_promo_set_validity(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 validity.", show_alert=True)
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
data = await state.get_data()
|
||||
prompt_text = _(
|
||||
"admin_bulk_promo_enter_validity_days",
|
||||
default="🎟 <b>Массовое создание промокодов</b>\n\n<b>Шаг 4 из 4:</b> Срок действия\n\nКоличество: <b>{quantity}</b>\nБонусные дни: <b>{bonus_days}</b>\nМакс. активаций: <b>{max_activations}</b>\n\nВведите количество дней действия промокодов (1-365):",
|
||||
quantity=data.get("quantity"),
|
||||
bonus_days=data.get("bonus_days"),
|
||||
max_activations=data.get("max_activations")
|
||||
)
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
prompt_text,
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
except Exception:
|
||||
await callback.message.answer(
|
||||
prompt_text,
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
# Step 4: Process validity days
|
||||
@router.message(AdminStates.waiting_for_bulk_promo_validity_days, F.text)
|
||||
async def process_bulk_promo_validity_days_handler(message: types.Message,
|
||||
state: FSMContext,
|
||||
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:
|
||||
await message.reply("Language service error.")
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
try:
|
||||
validity_days = int(message.text.strip())
|
||||
if not (1 <= validity_days <= 365):
|
||||
await message.answer(_(
|
||||
"admin_promo_invalid_validity_days",
|
||||
default="❌ Срок действия должен быть от 1 до 365 дней"
|
||||
))
|
||||
return
|
||||
|
||||
await state.update_data(validity_days=validity_days)
|
||||
await create_bulk_promo_codes_final(message, state, i18n_data, settings, session)
|
||||
|
||||
except ValueError:
|
||||
await message.answer(_(
|
||||
"admin_promo_invalid_number",
|
||||
default="❌ Введите корректное число"
|
||||
))
|
||||
except Exception as e:
|
||||
logging.error(f"Error processing bulk promo validity days: {e}")
|
||||
await message.answer(_("error_occurred_try_again"))
|
||||
|
||||
|
||||
async def create_bulk_promo_codes_final(callback_or_message,
|
||||
state: FSMContext,
|
||||
i18n_data: dict,
|
||||
settings: Settings,
|
||||
session: AsyncSession):
|
||||
"""Final step - create multiple promo codes in database"""
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n:
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
try:
|
||||
data = await state.get_data()
|
||||
quantity = data["quantity"]
|
||||
|
||||
# Show progress message
|
||||
progress_text = _(
|
||||
"admin_bulk_promo_creating",
|
||||
default="🔄 Создание {quantity} промокодов...",
|
||||
quantity=quantity
|
||||
)
|
||||
|
||||
if hasattr(callback_or_message, 'message'): # CallbackQuery
|
||||
try:
|
||||
await callback_or_message.message.edit_text(progress_text, parse_mode="HTML")
|
||||
except Exception:
|
||||
await callback_or_message.message.answer(progress_text, parse_mode="HTML")
|
||||
await callback_or_message.answer()
|
||||
else: # Message
|
||||
await callback_or_message.answer(progress_text, parse_mode="HTML")
|
||||
|
||||
# Generate and create promo codes
|
||||
created_codes = []
|
||||
failed_codes = []
|
||||
|
||||
for i in range(quantity):
|
||||
try:
|
||||
# Generate unique code
|
||||
attempts = 0
|
||||
while attempts < 10: # Max 10 attempts to generate unique code
|
||||
promo_code = generate_unique_promo_code()
|
||||
existing_promo = await promo_code_dal.get_promo_code_by_code(session, promo_code)
|
||||
if not existing_promo:
|
||||
break
|
||||
attempts += 1
|
||||
|
||||
if attempts >= 10:
|
||||
failed_codes.append(f"Код #{i+1} (не удалось сгенерировать уникальный)")
|
||||
continue
|
||||
|
||||
# Prepare promo code data
|
||||
promo_data = {
|
||||
"code": promo_code,
|
||||
"bonus_days": data["bonus_days"],
|
||||
"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 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)
|
||||
created_codes.append(created_promo.code)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error creating bulk promo code #{i+1}: {e}")
|
||||
failed_codes.append(f"Код #{i+1} ({str(e)[:50]})")
|
||||
|
||||
await session.commit()
|
||||
|
||||
# Success message
|
||||
success_lines = [
|
||||
_(
|
||||
"admin_bulk_promo_created_title",
|
||||
default="✅ <b>Массовое создание завершено!</b>\n"
|
||||
),
|
||||
_(
|
||||
"admin_bulk_promo_created_stats",
|
||||
default="📊 Создано: <b>{created}</b> из <b>{total}</b>",
|
||||
created=len(created_codes),
|
||||
total=quantity
|
||||
)
|
||||
]
|
||||
|
||||
if data.get("validity_days"):
|
||||
validity_text = f"{data['validity_days']} дней"
|
||||
else:
|
||||
validity_text = _("admin_promo_unlimited", default="Без ограничений")
|
||||
|
||||
success_lines.append(
|
||||
_(
|
||||
"admin_bulk_promo_settings",
|
||||
default="🎁 Бонусные дни: <b>{bonus_days}</b>\n"
|
||||
"📊 Макс. активаций: <b>{max_activations}</b>\n"
|
||||
"⏰ Срок действия: <b>{validity}</b>",
|
||||
bonus_days=data["bonus_days"],
|
||||
max_activations=data["max_activations"],
|
||||
validity=validity_text
|
||||
)
|
||||
)
|
||||
|
||||
# Create CSV file with promo codes if any were created
|
||||
csv_file = None
|
||||
if created_codes:
|
||||
success_lines.append(f"\n🎟 <b>Создано {len(created_codes)} промокодов</b>")
|
||||
success_lines.append("📄 CSV файл с промокодами отправлен отдельным сообщением")
|
||||
|
||||
# Create CSV file
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
|
||||
# CSV headers
|
||||
writer.writerow([
|
||||
"Промокод", "Бонусные дни", "Макс. активации", "Действителен до",
|
||||
"Команда для старта", "Ссылка для активации"
|
||||
])
|
||||
|
||||
# Get real bot username
|
||||
bot_username = 'your_bot' # fallback
|
||||
try:
|
||||
if hasattr(callback_or_message, 'message'):
|
||||
bot = callback_or_message.message.bot
|
||||
else:
|
||||
bot = callback_or_message.bot
|
||||
|
||||
bot_info = await bot.get_me()
|
||||
bot_username = bot_info.username or 'your_bot'
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to get bot username for CSV links: {e}")
|
||||
bot_username = 'your_bot'
|
||||
|
||||
for code in created_codes:
|
||||
# Determine validity info
|
||||
if data.get("validity_days"):
|
||||
valid_until = (datetime.now(timezone.utc) + timedelta(days=data["validity_days"])).strftime("%Y-%m-%d %H:%M:%S")
|
||||
else:
|
||||
valid_until = "Без ограничений"
|
||||
|
||||
start_command = f"/start promo_{code}"
|
||||
telegram_link = f"https://t.me/{bot_username}?start=promo_{code}"
|
||||
|
||||
writer.writerow([
|
||||
code,
|
||||
data["bonus_days"],
|
||||
data["max_activations"],
|
||||
valid_until,
|
||||
start_command,
|
||||
telegram_link
|
||||
])
|
||||
|
||||
output.seek(0)
|
||||
|
||||
# Create file for sending
|
||||
filename = f"bulk_promo_codes_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
|
||||
csv_file = types.BufferedInputFile(
|
||||
output.getvalue().encode('utf-8-sig'), # BOM for correct Excel display
|
||||
filename=filename
|
||||
)
|
||||
|
||||
if failed_codes:
|
||||
success_lines.append(f"\n❌ <b>Ошибки ({len(failed_codes)}):</b>")
|
||||
for error in failed_codes[:5]: # Show first 5 errors
|
||||
success_lines.append(error)
|
||||
if len(failed_codes) > 5:
|
||||
success_lines.append(f"... и еще {len(failed_codes) - 5} ошибок")
|
||||
|
||||
success_text = "\n".join(success_lines)
|
||||
|
||||
if hasattr(callback_or_message, 'message'): # CallbackQuery
|
||||
try:
|
||||
await callback_or_message.message.edit_text(
|
||||
success_text,
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
message_obj = callback_or_message.message
|
||||
except Exception:
|
||||
message_obj = await callback_or_message.message.answer(
|
||||
success_text,
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback_or_message.answer()
|
||||
else: # Message
|
||||
message_obj = await callback_or_message.answer(
|
||||
success_text,
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
# Send CSV file if created
|
||||
if csv_file:
|
||||
csv_caption = f"📄 Промокоды для массового создания\n💫 Всего: {len(created_codes)} промокодов\n🎁 Бонус: {data['bonus_days']} дней каждый"
|
||||
await message_obj.answer_document(csv_file, caption=csv_caption)
|
||||
|
||||
await state.clear()
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error creating bulk promo codes: {e}")
|
||||
error_text = _("error_occurred_try_again", default="❌ Произошла ошибка. Попробуйте снова.")
|
||||
|
||||
if hasattr(callback_or_message, 'message'): # CallbackQuery
|
||||
await callback_or_message.message.answer(error_text)
|
||||
else: # Message
|
||||
await callback_or_message.answer(error_text)
|
||||
|
||||
await state.clear()
|
||||
|
||||
|
||||
# Cancel bulk promo creation
|
||||
@router.callback_query(
|
||||
F.data == "admin_action:main",
|
||||
StateFilter(
|
||||
AdminStates.waiting_for_bulk_promo_quantity,
|
||||
AdminStates.waiting_for_bulk_promo_bonus_days,
|
||||
AdminStates.waiting_for_bulk_promo_max_activations,
|
||||
AdminStates.waiting_for_bulk_promo_validity_days,
|
||||
),
|
||||
)
|
||||
async def cancel_bulk_promo_creation_state_to_menu(callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
session: AsyncSession):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n or not callback.message:
|
||||
await callback.answer("Error cancelling.", show_alert=True)
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
_(key="admin_panel_title"),
|
||||
reply_markup=get_admin_panel_keyboard(i18n, current_lang, settings)
|
||||
)
|
||||
except Exception:
|
||||
await callback.message.answer(
|
||||
_(key="admin_panel_title"),
|
||||
reply_markup=get_admin_panel_keyboard(i18n, current_lang, settings)
|
||||
)
|
||||
|
||||
await callback.answer(_("admin_bulk_promo_creation_cancelled", default="Массовое создание промокодов отменено"))
|
||||
await state.clear()
|
||||
@@ -0,0 +1,436 @@
|
||||
import logging
|
||||
from aiogram import Router, F, types
|
||||
from aiogram.filters import StateFilter
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config.settings import Settings
|
||||
from db.dal import promo_code_dal
|
||||
from bot.states.admin_states import AdminStates
|
||||
from bot.keyboards.inline.admin_keyboards import get_back_to_admin_panel_keyboard, get_admin_panel_keyboard
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
|
||||
router = Router(name="promo_create_router")
|
||||
|
||||
|
||||
async def create_promo_prompt_handler(callback: types.CallbackQuery,
|
||||
state: FSMContext, 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 preparing promo creation.",
|
||||
show_alert=True)
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
# Step 1: Ask for promo code
|
||||
prompt_text = _(
|
||||
"admin_promo_step1_code",
|
||||
default="🎟 <b>Создание промокода</b>\n\n<b>Шаг 1 из 4:</b> Код промокода\n\nВведите код промокода (3-30 символов, только буквы и цифры):"
|
||||
)
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
prompt_text,
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
|
||||
parse_mode="HTML")
|
||||
except Exception as e:
|
||||
logging.warning(
|
||||
f"Could not edit message for promo prompt: {e}. Sending new.")
|
||||
await callback.message.answer(
|
||||
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)
|
||||
|
||||
|
||||
# Step 1: Process promo code
|
||||
@router.message(AdminStates.waiting_for_promo_code, F.text)
|
||||
async def process_promo_code_handler(message: types.Message,
|
||||
state: FSMContext,
|
||||
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:
|
||||
await message.reply("Language service error.")
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
try:
|
||||
code_str = message.text.strip().upper()
|
||||
if not (3 <= len(code_str) <= 30 and code_str.isalnum()):
|
||||
await message.answer(_(
|
||||
"admin_promo_invalid_code_format",
|
||||
default="❌ Код промокода должен содержать 3-30 символов (только буквы и цифры)"
|
||||
))
|
||||
return
|
||||
|
||||
# Check if code already exists
|
||||
existing_promo = await promo_code_dal.get_promo_code_by_code(session, code_str)
|
||||
if existing_promo:
|
||||
await message.answer(_(
|
||||
"admin_promo_code_already_exists",
|
||||
default="❌ Промокод с таким кодом уже существует"
|
||||
))
|
||||
return
|
||||
|
||||
await state.update_data(promo_code=code_str)
|
||||
|
||||
# Step 2: Ask for bonus days
|
||||
prompt_text = _(
|
||||
"admin_promo_step2_bonus_days",
|
||||
default="🎟 <b>Создание промокода</b>\n\n<b>Шаг 2 из 4:</b> Бонусные дни\n\nКод: <b>{code}</b>\n\nВведите количество бонусных дней (1-365):",
|
||||
code=code_str
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error processing promo code: {e}")
|
||||
await message.answer(_("error_occurred_try_again"))
|
||||
|
||||
|
||||
# Step 2: Process bonus days
|
||||
@router.message(AdminStates.waiting_for_promo_bonus_days, F.text)
|
||||
async def process_promo_bonus_days_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:
|
||||
bonus_days = int(message.text.strip())
|
||||
if not (1 <= bonus_days <= 365):
|
||||
await message.answer(_(
|
||||
"admin_promo_invalid_bonus_days",
|
||||
default="❌ Количество бонусных дней должно быть от 1 до 365"
|
||||
))
|
||||
return
|
||||
|
||||
await state.update_data(bonus_days=bonus_days)
|
||||
|
||||
# Step 3: Ask for max activations
|
||||
data = await state.get_data()
|
||||
prompt_text = _(
|
||||
"admin_promo_step3_max_activations",
|
||||
default="🎟 <b>Создание промокода</b>\n\n<b>Шаг 3 из 4:</b> Лимит активаций\n\nКод: <b>{code}</b>\nБонусные дни: <b>{bonus_days}</b>\n\nВведите максимальное количество активаций (1-10000):",
|
||||
code=data.get("promo_code"),
|
||||
bonus_days=bonus_days
|
||||
)
|
||||
|
||||
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",
|
||||
default="❌ Введите корректное число"
|
||||
))
|
||||
except Exception as e:
|
||||
logging.error(f"Error processing promo bonus days: {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,
|
||||
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:
|
||||
max_activations = int(message.text.strip())
|
||||
if not (1 <= max_activations <= 10000):
|
||||
await message.answer(_(
|
||||
"admin_promo_invalid_max_activations",
|
||||
default="❌ Максимальное количество активаций должно быть от 1 до 10000"
|
||||
))
|
||||
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",
|
||||
default="🎟 <b>Создание промокода</b>\n\n<b>Шаг 4 из 4:</b> Срок действия\n\nКод: <b>{code}</b>\nБонусные дни: <b>{bonus_days}</b>\nМакс. активаций: <b>{max_activations}</b>\n\nВыберите срок действия промокода:",
|
||||
code=data.get("promo_code"),
|
||||
bonus_days=data.get("bonus_days"),
|
||||
max_activations=max_activations
|
||||
)
|
||||
|
||||
# Create keyboard for validity options
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_("admin_promo_unlimited_validity", default="🔄 Без ограничений"),
|
||||
callback_data="promo_unlimited_validity"
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_("admin_promo_set_validity_days", default="📅 Указать дни"),
|
||||
callback_data="promo_set_validity"
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_("admin_back_to_panel", default="🔙 В админ панель"),
|
||||
callback_data="admin_action:main"
|
||||
)
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
prompt_text,
|
||||
reply_markup=builder.as_markup(),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await state.set_state(AdminStates.waiting_for_promo_validity_days)
|
||||
|
||||
except ValueError:
|
||||
await message.answer(_(
|
||||
"admin_promo_invalid_number",
|
||||
default="❌ Введите корректное число"
|
||||
))
|
||||
except Exception as e:
|
||||
logging.error(f"Error processing promo max activations: {e}")
|
||||
await message.answer(_("error_occurred_try_again"))
|
||||
|
||||
|
||||
# Step 4: Handle unlimited validity
|
||||
@router.callback_query(F.data == "promo_unlimited_validity", StateFilter(AdminStates.waiting_for_promo_validity_days))
|
||||
async def process_promo_unlimited_validity(callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
i18n_data: dict,
|
||||
settings: Settings,
|
||||
session: AsyncSession):
|
||||
await state.update_data(validity_days=None)
|
||||
await create_promo_code_final(callback, state, i18n_data, settings, session)
|
||||
|
||||
|
||||
# Step 4: Handle set validity
|
||||
@router.callback_query(F.data == "promo_set_validity", StateFilter(AdminStates.waiting_for_promo_validity_days))
|
||||
async def process_promo_set_validity(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 validity.", show_alert=True)
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
data = await state.get_data()
|
||||
prompt_text = _(
|
||||
"admin_promo_enter_validity_days",
|
||||
default="🎟 <b>Создание промокода</b>\n\n<b>Шаг 4 из 4:</b> Срок действия\n\nКод: <b>{code}</b>\nБонусные дни: <b>{bonus_days}</b>\nМакс. активаций: <b>{max_activations}</b>\n\nВведите количество дней действия промокода (1-365):",
|
||||
code=data.get("promo_code"),
|
||||
bonus_days=data.get("bonus_days"),
|
||||
max_activations=data.get("max_activations")
|
||||
)
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
prompt_text,
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
except Exception:
|
||||
await callback.message.answer(
|
||||
prompt_text,
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
# Step 4: Process validity days
|
||||
@router.message(AdminStates.waiting_for_promo_validity_days, F.text)
|
||||
async def process_promo_validity_days_handler(message: types.Message,
|
||||
state: FSMContext,
|
||||
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:
|
||||
await message.reply("Language service error.")
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
try:
|
||||
validity_days = int(message.text.strip())
|
||||
if not (1 <= validity_days <= 365):
|
||||
await message.answer(_(
|
||||
"admin_promo_invalid_validity_days",
|
||||
default="❌ Срок действия должен быть от 1 до 365 дней"
|
||||
))
|
||||
return
|
||||
|
||||
await state.update_data(validity_days=validity_days)
|
||||
await create_promo_code_final(message, state, i18n_data, settings, session)
|
||||
|
||||
except ValueError:
|
||||
await message.answer(_(
|
||||
"admin_promo_invalid_number",
|
||||
default="❌ Введите корректное число"
|
||||
))
|
||||
except Exception as e:
|
||||
logging.error(f"Error processing promo validity days: {e}")
|
||||
await message.answer(_("error_occurred_try_again"))
|
||||
|
||||
|
||||
async def create_promo_code_final(callback_or_message,
|
||||
state: FSMContext,
|
||||
i18n_data: dict,
|
||||
settings: Settings,
|
||||
session: AsyncSession):
|
||||
"""Final step - create the promo code in database"""
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n:
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
try:
|
||||
data = await state.get_data()
|
||||
|
||||
# Prepare promo code data
|
||||
promo_data = {
|
||||
"code": data["promo_code"],
|
||||
"bonus_days": data["bonus_days"],
|
||||
"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 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}")
|
||||
|
||||
# Success message
|
||||
valid_until_str = _("admin_promo_unlimited", default="Без ограничений") if not data.get("validity_days") else f"{data['validity_days']} дней"
|
||||
success_text = _(
|
||||
"admin_promo_created_success",
|
||||
default="✅ <b>Промокод успешно создан!</b>\n\n"
|
||||
"🎟 Код: <code>{code}</code>\n"
|
||||
"🎁 Бонусные дни: <b>{bonus_days}</b>\n"
|
||||
"📊 Макс. активаций: <b>{max_activations}</b>\n"
|
||||
"⏰ Срок действия: <b>{valid_until_str}</b>",
|
||||
code=data["promo_code"],
|
||||
bonus_days=data["bonus_days"],
|
||||
max_activations=data["max_activations"],
|
||||
valid_until_str=valid_until_str
|
||||
)
|
||||
|
||||
if hasattr(callback_or_message, 'message'): # CallbackQuery
|
||||
try:
|
||||
await callback_or_message.message.edit_text(
|
||||
success_text,
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
except Exception:
|
||||
await callback_or_message.message.answer(
|
||||
success_text,
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback_or_message.answer()
|
||||
else: # Message
|
||||
await callback_or_message.answer(
|
||||
success_text,
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error creating promo code: {e}")
|
||||
error_text = _("error_occurred_try_again", default="❌ Произошла ошибка. Попробуйте снова.")
|
||||
|
||||
if hasattr(callback_or_message, 'message'): # CallbackQuery
|
||||
await callback_or_message.message.answer(error_text)
|
||||
await callback_or_message.answer()
|
||||
else: # Message
|
||||
await callback_or_message.answer(error_text)
|
||||
|
||||
await state.clear()
|
||||
|
||||
|
||||
# Cancel promo creation
|
||||
@router.callback_query(
|
||||
F.data == "admin_action:main",
|
||||
StateFilter(
|
||||
AdminStates.waiting_for_promo_code,
|
||||
AdminStates.waiting_for_promo_bonus_days,
|
||||
AdminStates.waiting_for_promo_max_activations,
|
||||
AdminStates.waiting_for_promo_validity_days,
|
||||
),
|
||||
)
|
||||
async def cancel_promo_creation_state_to_menu(callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
session: AsyncSession):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n or not callback.message:
|
||||
await callback.answer("Error cancelling.", show_alert=True)
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
_(key="admin_panel_title"),
|
||||
reply_markup=get_admin_panel_keyboard(i18n, current_lang, settings)
|
||||
)
|
||||
except Exception:
|
||||
await callback.message.answer(
|
||||
_(key="admin_panel_title"),
|
||||
reply_markup=get_admin_panel_keyboard(i18n, current_lang, settings)
|
||||
)
|
||||
|
||||
await callback.answer(_("admin_promo_creation_cancelled", default="Создание промокода отменено"))
|
||||
await state.clear()
|
||||
@@ -0,0 +1,452 @@
|
||||
import logging
|
||||
import csv
|
||||
import io
|
||||
from aiogram import Router, F, types
|
||||
from aiogram.filters import StateFilter
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional, List
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config.settings import Settings, get_settings
|
||||
from db.dal import promo_code_dal
|
||||
from db.models import PromoCode, PromoCodeActivation
|
||||
from bot.states.admin_states import AdminStates
|
||||
from bot.keyboards.inline.admin_keyboards import get_back_to_admin_panel_keyboard, get_admin_panel_keyboard
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
|
||||
router = Router(name="promo_manage_router")
|
||||
|
||||
|
||||
def get_promo_status_emoji_and_text(promo: PromoCode, i18n: JsonI18n, current_lang: str):
|
||||
"""Determine promo code status and return emoji + text"""
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
if promo.valid_until and promo.valid_until < datetime.now(timezone.utc):
|
||||
return "⏰", _("admin_promo_status_expired")
|
||||
elif promo.current_activations >= promo.max_activations:
|
||||
return "🔄", _("admin_promo_status_used_up")
|
||||
elif promo.is_active:
|
||||
return "✅", _("admin_promo_status_active")
|
||||
else:
|
||||
return "🚫", _("admin_promo_status_inactive")
|
||||
|
||||
|
||||
async def get_promo_detail_text_and_keyboard(promo_id: int, session: AsyncSession, i18n: JsonI18n, current_lang: str):
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
promo = await promo_code_dal.get_promo_code_by_id(session, promo_id)
|
||||
if not promo:
|
||||
return None, None
|
||||
|
||||
status_emoji, status = get_promo_status_emoji_and_text(promo, i18n, current_lang)
|
||||
|
||||
validity = _("admin_promo_valid_indefinitely")
|
||||
if promo.valid_until:
|
||||
validity = promo.valid_until.strftime("%d.%m.%Y %H:%M")
|
||||
|
||||
created = promo.created_at.strftime("%d.%m.%Y %H:%M") if promo.created_at else "N/A"
|
||||
|
||||
text = "\n".join([
|
||||
_("admin_promo_card_title", code=promo.code),
|
||||
_("admin_promo_card_bonus_days", days=promo.bonus_days),
|
||||
_("admin_promo_card_activations", current=promo.current_activations, max=promo.max_activations),
|
||||
_("admin_promo_card_validity", validity=validity),
|
||||
_("admin_promo_card_status", status=status),
|
||||
_("admin_promo_card_created", created=created),
|
||||
_("admin_promo_card_created_by", creator=promo.created_by_admin_id)
|
||||
])
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=_("admin_promo_edit_button"), callback_data=f"promo_edit_select:{promo_id}"))
|
||||
builder.row(InlineKeyboardButton(text=_("admin_promo_toggle_status_button"), callback_data=f"promo_toggle:{promo_id}"))
|
||||
builder.row(InlineKeyboardButton(text=_("admin_promo_view_activations_button"), callback_data=f"promo_activations:{promo_id}:0"))
|
||||
builder.row(InlineKeyboardButton(text=_("admin_promo_delete_button"), callback_data=f"promo_delete:{promo_id}"))
|
||||
builder.row(InlineKeyboardButton(text=_("admin_promo_back_to_list_button"), callback_data="admin_action:promo_management"))
|
||||
|
||||
return text, builder.as_markup()
|
||||
|
||||
|
||||
async def view_promo_codes_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 processing request.", show_alert=True)
|
||||
return
|
||||
_ = 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
|
||||
]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(text, reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), parse_mode="HTML")
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def promo_management_handler(callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession, page: int = 0):
|
||||
current_lang = i18n_data.get("current_language", "ru")
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n or not callback.message:
|
||||
await callback.answer("Error processing request.", show_alert=True)
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
page_size = 10 # Количество промокодов на странице
|
||||
offset = page * page_size
|
||||
|
||||
# Получаем общее количество промокодов
|
||||
total_count = await promo_code_dal.get_promo_codes_count(session)
|
||||
total_pages = (total_count + page_size - 1) // page_size if total_count > 0 else 1
|
||||
|
||||
promo_models = await promo_code_dal.get_all_promo_codes_with_details(session, limit=page_size, offset=offset)
|
||||
if not promo_models and page == 0:
|
||||
await callback.message.edit_text(_("admin_promo_management_empty"), reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), parse_mode="HTML")
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
for promo in promo_models:
|
||||
status_emoji, status_text = get_promo_status_emoji_and_text(promo, i18n, current_lang)
|
||||
button_text = f"{status_emoji} {promo.code} ({promo.current_activations}/{promo.max_activations})"
|
||||
builder.row(InlineKeyboardButton(text=button_text, callback_data=f"promo_detail:{promo.promo_code_id}"))
|
||||
|
||||
# Добавляем кнопки пагинации если есть больше одной страницы
|
||||
if total_pages > 1:
|
||||
pagination_buttons = []
|
||||
if page > 0:
|
||||
pagination_buttons.append(InlineKeyboardButton(text=_("prev_page_button"), callback_data=f"promo_management:{page-1}"))
|
||||
if page < total_pages - 1:
|
||||
pagination_buttons.append(InlineKeyboardButton(text=_("next_page_button"), callback_data=f"promo_management:{page+1}"))
|
||||
|
||||
if pagination_buttons:
|
||||
builder.row(*pagination_buttons)
|
||||
|
||||
# Добавляем кнопки экспорта и возврата
|
||||
builder.row(InlineKeyboardButton(text=_("admin_promo_export_csv_button"), callback_data="promo_export_all"))
|
||||
builder.row(InlineKeyboardButton(text=_("back_to_admin_panel_button"), callback_data="admin_action:main"))
|
||||
|
||||
# Формируем заголовок с информацией о страницах
|
||||
title = _("admin_promo_management_title")
|
||||
if total_pages > 1:
|
||||
title += f"\n{_('admin_promo_list_page_info', current=page+1, total=total_pages, count=total_count)}"
|
||||
|
||||
await callback.message.edit_text(title, reply_markup=builder.as_markup(), parse_mode="HTML")
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("promo_management:"))
|
||||
async def promo_management_pagination_handler(callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession):
|
||||
try:
|
||||
page = int(callback.data.split(":")[1])
|
||||
await promo_management_handler(callback, i18n_data, settings, session, page)
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer("Error processing pagination.", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("promo_detail:"))
|
||||
async def promo_detail_handler(callback: types.CallbackQuery, i18n_data: dict, session: AsyncSession):
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
current_lang = i18n_data.get("current_language")
|
||||
if not i18n or not callback.message or not current_lang:
|
||||
await callback.answer("Error processing request.", show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
promo_id = int(callback.data.split(":")[1])
|
||||
text, keyboard = await get_promo_detail_text_and_keyboard(promo_id, session, i18n, current_lang)
|
||||
if text:
|
||||
await callback.message.edit_text(text, reply_markup=keyboard, parse_mode="HTML")
|
||||
else:
|
||||
await callback.answer(i18n.gettext(current_lang, "admin_promo_not_found"), show_alert=True)
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer(i18n.gettext(current_lang, "admin_promo_not_found"), show_alert=True)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("promo_toggle:"))
|
||||
async def promo_toggle_handler(callback: types.CallbackQuery, i18n_data: dict, session: AsyncSession):
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
current_lang = i18n_data.get("current_language")
|
||||
if not i18n or not callback.message or not current_lang:
|
||||
return await callback.answer("Language service error.", show_alert=True)
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
try:
|
||||
promo_id = int(callback.data.split(":")[1])
|
||||
promo = await promo_code_dal.get_promo_code_by_id(session, promo_id)
|
||||
if not promo:
|
||||
return await callback.answer(_("admin_promo_not_found"), show_alert=True)
|
||||
|
||||
new_status = not promo.is_active
|
||||
if await promo_code_dal.update_promo_code(session, promo_id, {"is_active": new_status}):
|
||||
await session.commit()
|
||||
status_text = _("admin_promo_status_activated") if new_status else _("admin_promo_status_deactivated")
|
||||
await callback.answer(_("admin_promo_toggle_success", code=promo.code, status=status_text))
|
||||
|
||||
text, keyboard = await get_promo_detail_text_and_keyboard(promo_id, session, i18n, current_lang)
|
||||
if text:
|
||||
await callback.message.edit_text(text, reply_markup=keyboard, parse_mode="HTML")
|
||||
else:
|
||||
await callback.answer(_("error_occurred_try_again"), show_alert=True)
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer(_("admin_promo_not_found"), show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("promo_activations:"))
|
||||
async def promo_activations_handler(callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession):
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
current_lang = i18n_data.get("current_language")
|
||||
if not i18n or not callback.message or not current_lang:
|
||||
return await callback.answer("Error processing request.", show_alert=True)
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
try:
|
||||
parts = callback.data.split(":")
|
||||
promo_id = int(parts[1])
|
||||
page = int(parts[2])
|
||||
page_size = settings.LOGS_PAGE_SIZE
|
||||
|
||||
promo = await promo_code_dal.get_promo_code_by_id(session, promo_id)
|
||||
if not promo:
|
||||
return await callback.answer(_("admin_promo_not_found"), show_alert=True)
|
||||
|
||||
total_activations = await promo_code_dal.count_promo_activations_by_code_id(session, promo_id)
|
||||
activations = await promo_code_dal.get_promo_activations_by_code_id(session, promo_id, limit=page_size, offset=page * page_size)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
if not activations:
|
||||
text = _("admin_promo_no_activations", code=promo.code)
|
||||
else:
|
||||
text = _("admin_promo_activations_header", code=promo.code) + "\n\n"
|
||||
text += "\n".join([_("admin_promo_activation_item", user_id=a.user_id, date=a.activated_at.strftime("%d.%m.%Y %H:%M")) for a in activations])
|
||||
|
||||
nav_buttons = []
|
||||
if page > 0:
|
||||
nav_buttons.append(InlineKeyboardButton(text="⬅️", callback_data=f"promo_activations:{promo_id}:{page-1}"))
|
||||
if (page + 1) * page_size < total_activations:
|
||||
nav_buttons.append(InlineKeyboardButton(text="➡️", callback_data=f"promo_activations:{promo_id}:{page+1}"))
|
||||
if nav_buttons:
|
||||
builder.row(*nav_buttons)
|
||||
|
||||
builder.row(InlineKeyboardButton(text=_("admin_promo_export_csv_button"), callback_data=f"promo_export:{promo_id}"))
|
||||
builder.row(InlineKeyboardButton(text=_("admin_promo_back_to_detail_button"), callback_data=f"promo_detail:{promo_id}"))
|
||||
|
||||
await callback.message.edit_text(text, reply_markup=builder.as_markup(), parse_mode="HTML")
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer(_("admin_promo_not_found"), show_alert=True)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("promo_export:"))
|
||||
async def promo_export_activations_handler(callback: types.CallbackQuery, i18n_data: dict, session: AsyncSession):
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
current_lang = i18n_data.get("current_language")
|
||||
if not i18n or not callback.message or not current_lang:
|
||||
return await callback.answer("Error processing request.", show_alert=True)
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
export_lang = "en"
|
||||
|
||||
try:
|
||||
promo_id = int(callback.data.split(":")[1])
|
||||
promo = await promo_code_dal.get_promo_code_by_id(session, promo_id)
|
||||
if not promo:
|
||||
return await callback.answer(_("admin_promo_not_found"), show_alert=True)
|
||||
|
||||
activations = await promo_code_dal.get_promo_activations_by_code_id(session, promo_id)
|
||||
if not activations:
|
||||
return await callback.answer(_("admin_promo_no_activations", code=promo.code), show_alert=True)
|
||||
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(["User ID", "Activation Date"])
|
||||
for act in activations:
|
||||
writer.writerow([act.user_id, act.activated_at.strftime("%Y-%m-%d %H:%M:%S")])
|
||||
|
||||
output.seek(0)
|
||||
file = types.BufferedInputFile(output.getvalue().encode('utf-8'), filename=f"promo_{promo.code}_activations.csv")
|
||||
# Force English caption for exports
|
||||
await callback.message.answer_document(
|
||||
file,
|
||||
caption=i18n.gettext(export_lang, "admin_promo_export_caption", code=promo.code)
|
||||
)
|
||||
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer(_("admin_promo_not_found"), show_alert=True)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data == "promo_export_all")
|
||||
async def promo_export_all_handler(callback: types.CallbackQuery, i18n_data: dict, session: AsyncSession):
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
current_lang = i18n_data.get("current_language")
|
||||
if not i18n or not callback.message or not current_lang:
|
||||
return await callback.answer("Error processing request.", show_alert=True)
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
export_lang = "en"
|
||||
|
||||
try:
|
||||
await callback.answer(i18n.gettext(export_lang, "admin_promo_export_all_generating"), show_alert=True)
|
||||
|
||||
# Получаем все промокоды
|
||||
all_promos = await promo_code_dal.get_all_promo_codes_with_details(session, limit=10000, offset=0)
|
||||
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
|
||||
# CSV headers (forced to English)
|
||||
writer.writerow([
|
||||
i18n.gettext(export_lang, "admin_promo_csv_code"),
|
||||
i18n.gettext(export_lang, "admin_promo_csv_bonus_days"),
|
||||
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"),
|
||||
i18n.gettext(export_lang, "admin_promo_csv_is_active"),
|
||||
i18n.gettext(export_lang, "admin_promo_csv_valid_until"),
|
||||
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)
|
||||
|
||||
# Формируем данные для CSV
|
||||
row = [
|
||||
promo.code,
|
||||
promo.bonus_days,
|
||||
promo.max_activations,
|
||||
promo.current_activations,
|
||||
status_text,
|
||||
i18n.gettext(export_lang, "csv_yes") if promo.is_active else i18n.gettext(export_lang, "csv_no"),
|
||||
promo.valid_until.strftime("%Y-%m-%d %H:%M:%S") if promo.valid_until else i18n.gettext(export_lang, "admin_promo_valid_indefinitely"),
|
||||
promo.created_at.strftime("%Y-%m-%d %H:%M:%S") if promo.created_at else "N/A",
|
||||
promo.created_by_admin_id or "N/A"
|
||||
]
|
||||
writer.writerow(row)
|
||||
|
||||
output.seek(0)
|
||||
|
||||
# Создаем файл для отправки
|
||||
filename = f"promo_codes_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
|
||||
file = types.BufferedInputFile(
|
||||
output.getvalue().encode('utf-8-sig'), # BOM для корректного отображения в Excel
|
||||
filename=filename
|
||||
)
|
||||
|
||||
caption = i18n.gettext(export_lang, "admin_promo_export_all_caption", count=len(all_promos))
|
||||
await callback.message.answer_document(file, caption=caption)
|
||||
|
||||
except Exception as e:
|
||||
await callback.answer(f"❌ Export error: {str(e)}", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("promo_delete:"))
|
||||
async def promo_delete_handler(callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession):
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
current_lang = i18n_data.get("current_language")
|
||||
if not i18n or not callback.message or not current_lang:
|
||||
return await callback.answer("Language service error.", show_alert=True)
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
try:
|
||||
promo_id = int(callback.data.split(":")[1])
|
||||
promo = await promo_code_dal.delete_promo_code(session, promo_id)
|
||||
if promo:
|
||||
await session.commit()
|
||||
await callback.answer(_("admin_promo_deleted_success", code=promo.code), show_alert=True)
|
||||
await promo_management_handler(callback, i18n_data, settings, session, 0)
|
||||
else:
|
||||
await callback.answer(_("admin_promo_not_found"), show_alert=True)
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer(_("admin_promo_not_found"), show_alert=True)
|
||||
|
||||
|
||||
# --- Promo Edit Handlers ---
|
||||
@router.callback_query(F.data.startswith("promo_edit_select:"))
|
||||
async def promo_edit_select_handler(callback: types.CallbackQuery, i18n_data: dict, session: AsyncSession):
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
current_lang = i18n_data.get("current_language")
|
||||
if not i18n or not callback.message or not current_lang:
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
promo_id = int(callback.data.split(":")[1])
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
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()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("promo_edit_field:"))
|
||||
async def promo_edit_field_handler(callback: types.CallbackQuery, state: FSMContext, i18n_data: dict, session: AsyncSession):
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
current_lang = i18n_data.get("current_language")
|
||||
if not i18n or not callback.message or not current_lang: return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
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",
|
||||
"max_activations": "admin_promo_prompt_max_activations",
|
||||
"valid_until": "admin_promo_prompt_validity_days"
|
||||
}
|
||||
await state.set_state(AdminStates.waiting_for_promo_edit_details)
|
||||
await callback.message.edit_text(_(prompts.get(field, "error_occurred_try_again")))
|
||||
await callback.answer()
|
||||
|
||||
@router.message(StateFilter(AdminStates.waiting_for_promo_edit_details))
|
||||
async def process_promo_edit_details(message: types.Message, state: FSMContext, session: AsyncSession, i18n_data: dict):
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
current_lang = i18n_data.get("current_language")
|
||||
if not i18n or not message or not current_lang: return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
data = await state.get_data()
|
||||
promo_id = data.get("promo_id")
|
||||
field = data.get("field_to_edit")
|
||||
|
||||
try:
|
||||
value = message.text
|
||||
update_data = {}
|
||||
|
||||
if field == "bonus_days":
|
||||
update_data["bonus_days"] = int(value)
|
||||
elif field == "max_activations":
|
||||
update_data["max_activations"] = int(value)
|
||||
elif field == "valid_until":
|
||||
if value.lower() in ['0', 'вечно', 'бессрочно', 'indefinite']:
|
||||
update_data["valid_until"] = None
|
||||
else:
|
||||
days = int(value)
|
||||
update_data["valid_until"] = datetime.now(timezone.utc) + timedelta(days=days)
|
||||
|
||||
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)
|
||||
if text:
|
||||
await message.answer(text, reply_markup=keyboard, parse_mode="HTML")
|
||||
else:
|
||||
await message.answer(_("error_occurred_try_again"))
|
||||
await state.clear()
|
||||
|
||||
except (ValueError, TypeError):
|
||||
await message.answer(_("admin_promo_invalid_input"))
|
||||
# Don't clear state, let them try again
|
||||
|
||||
|
||||
async def manage_promo_codes_handler(callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession):
|
||||
await promo_management_handler(callback, i18n_data, settings, session)
|
||||
@@ -1,367 +0,0 @@
|
||||
import logging
|
||||
from aiogram import Router, F, types, Bot
|
||||
from aiogram.filters import StateFilter
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config.settings import Settings
|
||||
|
||||
from db.dal import promo_code_dal
|
||||
|
||||
from bot.states.admin_states import AdminStates
|
||||
from bot.keyboards.inline.admin_keyboards import get_back_to_admin_panel_keyboard, get_admin_panel_keyboard
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
|
||||
router = Router(name="admin_promo_codes_router")
|
||||
|
||||
|
||||
async def create_promo_prompt_handler(callback: types.CallbackQuery,
|
||||
state: FSMContext, 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 preparing promo creation.",
|
||||
show_alert=True)
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
prompt_text = _("admin_promo_create_prompt",
|
||||
example_format="MYPROMO20 7 100 30")
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
prompt_text,
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n))
|
||||
except Exception as e:
|
||||
logging.warning(
|
||||
f"Could not edit message for promo prompt: {e}. Sending new.")
|
||||
await callback.message.answer(
|
||||
prompt_text,
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n))
|
||||
await callback.answer()
|
||||
await state.set_state(AdminStates.waiting_for_promo_details)
|
||||
|
||||
|
||||
@router.message(AdminStates.waiting_for_promo_details, F.text)
|
||||
async def process_promo_code_details_handler(message: types.Message,
|
||||
state: FSMContext,
|
||||
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:
|
||||
await message.reply("Language service error.")
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
if not message.text:
|
||||
await message.answer(_("admin_promo_invalid_format"))
|
||||
return
|
||||
|
||||
parts = message.text.strip().split()
|
||||
if not (3 <= len(parts) <= 4):
|
||||
await message.answer(_("admin_promo_invalid_format"))
|
||||
return
|
||||
|
||||
try:
|
||||
code_str = parts[0].upper()
|
||||
if not (3 <= len(code_str) <= 30 and code_str.isalnum()):
|
||||
raise ValueError(_("admin_promo_invalid_code_format"))
|
||||
|
||||
bonus_days = int(parts[1])
|
||||
max_activations = int(parts[2])
|
||||
|
||||
valid_until_date: Optional[datetime] = None
|
||||
valid_until_str_display = _("admin_promo_valid_indefinitely")
|
||||
|
||||
if len(parts) == 4:
|
||||
valid_days_from_now = int(parts[3])
|
||||
if valid_days_from_now <= 0:
|
||||
raise ValueError(_("admin_promo_invalid_validity_days"))
|
||||
valid_until_date = datetime.now(
|
||||
timezone.utc) + timedelta(days=valid_days_from_now)
|
||||
valid_until_str_display = _(
|
||||
"admin_promo_valid_until_display",
|
||||
date=valid_until_date.strftime('%Y-%m-%d'))
|
||||
|
||||
if bonus_days <= 0 or max_activations <= 0:
|
||||
raise ValueError(_("admin_promo_invalid_bonus_or_activations"))
|
||||
|
||||
except ValueError as e:
|
||||
await message.answer(_("admin_promo_invalid_values", error=str(e)))
|
||||
return
|
||||
except Exception as e_parse:
|
||||
logging.error(
|
||||
f"Error parsing promo details '{message.text}': {e_parse}")
|
||||
await message.answer(_("admin_promo_invalid_format_general"))
|
||||
return
|
||||
|
||||
admin_id = message.from_user.id if message.from_user else 0
|
||||
|
||||
promo_data_to_create = {
|
||||
"code": code_str,
|
||||
"bonus_days": bonus_days,
|
||||
"max_activations": max_activations,
|
||||
"created_by_admin_id": admin_id,
|
||||
"valid_until": valid_until_date,
|
||||
"is_active": True,
|
||||
"current_activations": 0
|
||||
}
|
||||
|
||||
try:
|
||||
created_promo = await promo_code_dal.create_promo_code(
|
||||
session, promo_data_to_create)
|
||||
await session.commit()
|
||||
|
||||
if created_promo:
|
||||
success_text = _("admin_promo_created_success",
|
||||
code=created_promo.code,
|
||||
bonus_days=created_promo.bonus_days,
|
||||
max_activations=created_promo.max_activations,
|
||||
valid_until_str=valid_until_str_display)
|
||||
await message.answer(success_text,
|
||||
reply_markup=get_back_to_admin_panel_keyboard(
|
||||
current_lang, i18n))
|
||||
else:
|
||||
await message.answer(
|
||||
_("admin_promo_creation_failed_duplicate", code=code_str))
|
||||
|
||||
except Exception as e_db_create:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"Failed to create promo code '{code_str}' in DB: {e_db_create}",
|
||||
exc_info=True)
|
||||
await message.answer(_("admin_promo_creation_failed"))
|
||||
|
||||
await state.clear()
|
||||
|
||||
|
||||
async def view_promo_codes_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 promo codes.", show_alert=True)
|
||||
return
|
||||
_ = 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)
|
||||
|
||||
if not promo_models:
|
||||
await callback.message.edit_text(
|
||||
_("admin_no_active_promos"),
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n))
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
response_text_parts = [f"<b>{_('admin_active_promos_list_header')}</b>\n"]
|
||||
for promo in promo_models:
|
||||
valid_until_display_text = _("admin_promo_valid_indefinitely")
|
||||
if promo.valid_until:
|
||||
|
||||
valid_until_display_text = promo.valid_until.strftime('%Y-%m-%d')
|
||||
|
||||
response_text_parts.append(
|
||||
_("admin_promo_list_item",
|
||||
code=promo.code,
|
||||
bonus=promo.bonus_days,
|
||||
current=promo.current_activations,
|
||||
max=promo.max_activations,
|
||||
valid_until=valid_until_display_text))
|
||||
|
||||
final_text = "\n".join(response_text_parts)
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
final_text,
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
|
||||
parse_mode="HTML")
|
||||
except Exception as e:
|
||||
logging.warning(
|
||||
f"Failed to edit message for promo list: {e}. Sending new.")
|
||||
if callback.message:
|
||||
await callback.message.answer(
|
||||
final_text,
|
||||
reply_markup=get_back_to_admin_panel_keyboard(
|
||||
current_lang, i18n),
|
||||
parse_mode="HTML")
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_action:manage_promos")
|
||||
async def manage_promo_codes_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 promo codes.", show_alert=True)
|
||||
return
|
||||
_ = 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)
|
||||
if not promo_models:
|
||||
await callback.message.edit_text(
|
||||
_("admin_no_active_promos"),
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n))
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
kb = InlineKeyboardBuilder()
|
||||
for promo in promo_models:
|
||||
kb.row(
|
||||
InlineKeyboardButton(
|
||||
text=promo.code,
|
||||
callback_data=f"promo_edit:{promo.promo_code_id}"),
|
||||
InlineKeyboardButton(
|
||||
text=_("admin_promo_delete_button"),
|
||||
callback_data=f"promo_delete:{promo.promo_code_id}"),
|
||||
)
|
||||
kb.row(
|
||||
InlineKeyboardButton(text=_("back_to_admin_panel_button"),
|
||||
callback_data="admin_action:main"))
|
||||
|
||||
await callback.message.edit_text(
|
||||
_("admin_manage_promos_title"),
|
||||
reply_markup=kb.as_markup())
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("promo_edit:"))
|
||||
async def promo_edit_select_handler(callback: types.CallbackQuery, state: FSMContext,
|
||||
i18n_data: dict, settings: Settings,
|
||||
session: AsyncSession):
|
||||
promo_id = int(callback.data.split(":")[1])
|
||||
promo = await promo_code_dal.get_promo_code_by_id(session, promo_id)
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n or not promo or not callback.message:
|
||||
await callback.answer("Error", show_alert=True)
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
await state.update_data(edit_promo_id=promo_id)
|
||||
await state.set_state(AdminStates.waiting_for_promo_edit_details)
|
||||
await callback.message.edit_text(
|
||||
_("admin_promo_edit_prompt", code=promo.code),
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
|
||||
parse_mode="HTML")
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.message(AdminStates.waiting_for_promo_edit_details, F.text)
|
||||
async def process_promo_edit_details(message: types.Message, state: FSMContext,
|
||||
i18n_data: dict, settings: Settings,
|
||||
session: AsyncSession):
|
||||
data = await state.get_data()
|
||||
promo_id = data.get("edit_promo_id")
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n or not promo_id:
|
||||
await message.answer("Error")
|
||||
await state.clear()
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
parts = message.text.strip().split()
|
||||
if not (3 <= len(parts) <= 4):
|
||||
await message.answer(_("admin_promo_invalid_format"))
|
||||
return
|
||||
try:
|
||||
code_str = parts[0].upper()
|
||||
bonus = int(parts[1])
|
||||
max_act = int(parts[2])
|
||||
valid_until = None
|
||||
if len(parts) == 4:
|
||||
days = int(parts[3])
|
||||
valid_until = datetime.now(timezone.utc) + timedelta(days=days)
|
||||
except Exception:
|
||||
await message.answer(_("admin_promo_invalid_format_general"))
|
||||
return
|
||||
|
||||
update_data = {
|
||||
"code": code_str,
|
||||
"bonus_days": bonus,
|
||||
"max_activations": max_act,
|
||||
"valid_until": valid_until,
|
||||
}
|
||||
updated = await promo_code_dal.update_promo_code(session, promo_id, update_data)
|
||||
if updated:
|
||||
await session.commit()
|
||||
await message.answer(
|
||||
_("admin_promo_updated_success", code=code_str),
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
else:
|
||||
await session.rollback()
|
||||
await message.answer(_("admin_promo_not_found"))
|
||||
await state.clear()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("promo_delete:"))
|
||||
async def promo_delete_handler(callback: types.CallbackQuery, i18n_data: dict,
|
||||
settings: Settings, session: AsyncSession):
|
||||
promo_id = int(callback.data.split(":")[1])
|
||||
promo = await promo_code_dal.delete_promo_code(session, promo_id)
|
||||
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", show_alert=True)
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
if promo:
|
||||
await session.commit()
|
||||
await callback.message.edit_text(
|
||||
_("admin_promo_deleted_success", code=promo.code),
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
|
||||
parse_mode="HTML")
|
||||
else:
|
||||
await session.rollback()
|
||||
await callback.message.edit_text(
|
||||
_("admin_promo_not_found"),
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n))
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
F.data == "admin_action:main",
|
||||
StateFilter(
|
||||
AdminStates.waiting_for_promo_details,
|
||||
AdminStates.waiting_for_promo_edit_details,
|
||||
),
|
||||
)
|
||||
async def cancel_promo_creation_state_to_menu(callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
session: AsyncSession):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n or not callback.message:
|
||||
await callback.answer("Error cancelling.", show_alert=True)
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(_("admin_action_cancelled_default"),
|
||||
reply_markup=get_admin_panel_keyboard(
|
||||
i18n, current_lang, settings))
|
||||
except Exception:
|
||||
await callback.message.answer(_("admin_action_cancelled_default"),
|
||||
reply_markup=get_admin_panel_keyboard(
|
||||
i18n, current_lang, settings))
|
||||
|
||||
await callback.answer(_("admin_action_cancelled_default_alert"))
|
||||
await state.clear()
|
||||
@@ -8,6 +8,7 @@ from config.settings import Settings
|
||||
|
||||
from db.dal import user_dal, payment_dal, panel_sync_dal
|
||||
from db.models import Payment, PanelSyncStatus
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
|
||||
from bot.keyboards.inline.admin_keyboards import get_back_to_admin_panel_keyboard
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
@@ -29,13 +30,130 @@ async def show_statistics_handler(callback: types.CallbackQuery,
|
||||
|
||||
stats_text_parts = [f"<b>{_('admin_stats_header')}</b>"]
|
||||
|
||||
user_stats_dict = await user_dal.get_user_count_stats_dal(session)
|
||||
# Enhanced user statistics
|
||||
user_stats = await user_dal.get_enhanced_user_statistics(session)
|
||||
|
||||
stats_text_parts.append(
|
||||
_("admin_stats_users",
|
||||
total_users=user_stats_dict.get("total_users", 0),
|
||||
banned_users=user_stats_dict.get("banned_users", 0),
|
||||
active_subs=user_stats_dict.get("users_with_active_subscriptions",
|
||||
0)))
|
||||
f"\n<b>👥 {_('admin_enhanced_users_stats_header', default='Пользователи')}</b>"
|
||||
)
|
||||
stats_text_parts.append(
|
||||
f"📊 {_('admin_user_stats_total_label', default='Всего')}: <b>{user_stats['total_users']}</b>"
|
||||
)
|
||||
# Removed: Active today moved to panel stats
|
||||
stats_text_parts.append(
|
||||
f"💳 {_('admin_user_stats_paid_subs_label', default='С платной подпиской')}: <b>{user_stats['paid_subscriptions']}</b>"
|
||||
)
|
||||
stats_text_parts.append(
|
||||
f"🆓 {_('admin_user_stats_trial_label', default='На пробном периоде')}: <b>{user_stats['trial_users']}</b>"
|
||||
)
|
||||
stats_text_parts.append(
|
||||
f"😴 {_('admin_user_stats_inactive_label', default='Неактивных')}: <b>{user_stats['inactive_users']}</b>"
|
||||
)
|
||||
stats_text_parts.append(
|
||||
f"🚫 {_('admin_user_stats_banned_label', default='Заблокированных')}: <b>{user_stats['banned_users']}</b>"
|
||||
)
|
||||
stats_text_parts.append(
|
||||
f"🎁 {_('admin_user_stats_referral_label', default='Привлечено по реферальной программе')}: <b>{user_stats['referral_users']}</b>"
|
||||
)
|
||||
|
||||
# Panel Statistics - moved above financial
|
||||
stats_text_parts.append(f"\n<b>🖥 {_('admin_panel_stats_header', default='Статистика панели')}</b>")
|
||||
|
||||
try:
|
||||
async with PanelApiService(settings) as panel_service:
|
||||
# Get system stats
|
||||
system_stats = await panel_service.get_system_stats()
|
||||
bandwidth_stats = await panel_service.get_bandwidth_stats()
|
||||
nodes_stats = await panel_service.get_nodes_statistics()
|
||||
|
||||
logging.info(f"Panel stats response: system={system_stats}, bandwidth={bandwidth_stats}, nodes={nodes_stats}")
|
||||
|
||||
if system_stats:
|
||||
users = system_stats.get('users', {})
|
||||
status_counts = users.get('statusCounts', {})
|
||||
online_stats = system_stats.get('onlineStats', {})
|
||||
|
||||
active_users = status_counts.get('ACTIVE', 0)
|
||||
disabled_users = status_counts.get('DISABLED', 0)
|
||||
expired_users = status_counts.get('EXPIRED', 0)
|
||||
limited_users = status_counts.get('LIMITED', 0)
|
||||
total_users = users.get('totalUsers', 0)
|
||||
online_now = online_stats.get('onlineNow', 0)
|
||||
|
||||
stats_text_parts.append(f"🟢 {_('admin_panel_online_label', default='Онлайн')}: <b>{online_now}</b>")
|
||||
stats_text_parts.append(f"📊 {_('admin_panel_active_label', default='Активных')}: <b>{active_users}</b>")
|
||||
stats_text_parts.append(f"🔴 {_('admin_panel_disabled_label', default='Отключенных')}: <b>{disabled_users}</b>")
|
||||
stats_text_parts.append(f"⏰ {_('admin_panel_expired_label', default='Истекшие')}: <b>{expired_users}</b>")
|
||||
stats_text_parts.append(f"⚠️ {_('admin_panel_limited_label', default='Ограниченные')}: <b>{limited_users}</b>")
|
||||
stats_text_parts.append(f"👥 {_('admin_panel_total_users_label', default='Всего пользователей')}: <b>{total_users}</b>")
|
||||
|
||||
# System resources
|
||||
memory = system_stats.get('memory', {})
|
||||
if memory:
|
||||
memory_total = memory.get('total', 1)
|
||||
memory_used = memory.get('used', 0)
|
||||
memory_usage = (memory_used / memory_total) * 100 if memory_total > 0 else 0
|
||||
stats_text_parts.append(f"💾 {_('admin_panel_memory_usage_label', default='Использование RAM')}: <b>{memory_usage:.1f}%</b>")
|
||||
else:
|
||||
stats_text_parts.append(f"⚠️ {_('admin_panel_system_stats_error', default='Ошибка получения системной статистики')}")
|
||||
|
||||
# Bandwidth stats
|
||||
if bandwidth_stats:
|
||||
week_traffic = bandwidth_stats.get('bandwidthLastSevenDays', {})
|
||||
month_traffic = bandwidth_stats.get('bandwidthLast30Days', {})
|
||||
# Fallback to the actual key name from API if the above doesn't exist
|
||||
if not month_traffic:
|
||||
month_traffic = bandwidth_stats.get('bandwidthLastThirtyDays', {})
|
||||
|
||||
if week_traffic:
|
||||
week_total = week_traffic.get('current', '0 B')
|
||||
stats_text_parts.append(f"📊 {_('admin_panel_traffic_week_label', default='Трафик за неделю')}: <b>{week_total}</b>")
|
||||
|
||||
if month_traffic:
|
||||
month_total = month_traffic.get('current', '0 B')
|
||||
stats_text_parts.append(f"📊 {_('admin_panel_traffic_month_label', default='Трафик за месяц')}: <b>{month_total}</b>")
|
||||
else:
|
||||
stats_text_parts.append(f"⚠️ {_('admin_panel_bandwidth_stats_error', default='Ошибка получения статистики трафика')}")
|
||||
|
||||
# Nodes stats
|
||||
if nodes_stats and 'lastSevenDays' in nodes_stats:
|
||||
last_seven_days = nodes_stats.get('lastSevenDays', [])
|
||||
# Get unique node names from the data
|
||||
unique_nodes = set()
|
||||
for node_data in last_seven_days:
|
||||
unique_nodes.add(node_data.get('nodeName', ''))
|
||||
total_nodes_count = len(unique_nodes)
|
||||
# Assume all nodes are active since we don't have status info
|
||||
stats_text_parts.append(f"🔗 {_('admin_panel_nodes_label', default='Активных нод')}: <b>{total_nodes_count}/{total_nodes_count}</b>")
|
||||
else:
|
||||
# Use nodes total from system stats as fallback
|
||||
nodes_info = system_stats.get('nodes', {}) if system_stats else {}
|
||||
total_online = nodes_info.get('totalOnline', 0)
|
||||
stats_text_parts.append(f"🔗 {_('admin_panel_nodes_label', default='Активных нод')}: <b>{total_online}</b>")
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to fetch panel statistics: {e}", exc_info=True)
|
||||
stats_text_parts.append(f"❌ {_('admin_panel_stats_fetch_error', default='Ошибка получения данных с панели')}")
|
||||
stats_text_parts.append(f"⚠️ {_('admin_panel_stats_error_details', default='Детали')}: {str(e)}")
|
||||
|
||||
# Financial statistics
|
||||
financial_stats = await payment_dal.get_financial_statistics(session)
|
||||
|
||||
stats_text_parts.append(
|
||||
f"\n<b>💰 {_('admin_financial_stats_header', default='Финансовая статистика')}</b>"
|
||||
)
|
||||
stats_text_parts.append(
|
||||
f"📅 {_('admin_financial_today_label', default='За сегодня')}: <b>{financial_stats['today_revenue']:.2f} RUB</b> ({financial_stats['today_payments_count']} {_('admin_financial_payments_label', default='платежей')})"
|
||||
)
|
||||
stats_text_parts.append(
|
||||
f"📅 {_('admin_financial_week_label', default='За неделю')}: <b>{financial_stats['week_revenue']:.2f} RUB</b>"
|
||||
)
|
||||
stats_text_parts.append(
|
||||
f"📅 {_('admin_financial_month_label', default='За месяц')}: <b>{financial_stats['month_revenue']:.2f} RUB</b>"
|
||||
)
|
||||
stats_text_parts.append(
|
||||
f"🏆 {_('admin_financial_all_time_label', default='За все время')}: <b>{financial_stats['all_time_revenue']:.2f} RUB</b>"
|
||||
)
|
||||
|
||||
last_payments_models: List[
|
||||
Payment] = await payment_dal.get_recent_payment_logs_with_user(session,
|
||||
@@ -79,9 +197,7 @@ async def show_statistics_handler(callback: types.CallbackQuery,
|
||||
'%Y-%m-%d %H:%M:%S UTC') if sync_time_val else "N/A"
|
||||
|
||||
details_val = sync_status_model.details
|
||||
details_str = (details_val[:100] +
|
||||
"...") if details_val and len(details_val) > 100 else (
|
||||
details_val or "N/A")
|
||||
details_str = details_val or "N/A"
|
||||
|
||||
stats_text_parts.append(
|
||||
f" {_('admin_stats_sync_time')}: {sync_time_str}")
|
||||
|
||||
+349
-263
@@ -7,6 +7,7 @@ from datetime import datetime, timezone
|
||||
|
||||
from config.settings import Settings
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
from bot.services.notification_service import NotificationService
|
||||
|
||||
from db.dal import user_dal, subscription_dal, panel_sync_dal
|
||||
|
||||
@@ -15,6 +16,315 @@ from bot.middlewares.i18n import JsonI18n
|
||||
router = Router(name="admin_sync_router")
|
||||
|
||||
|
||||
async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
||||
settings: Settings, i18n_instance: JsonI18n) -> dict:
|
||||
"""
|
||||
Perform panel synchronization and return results
|
||||
Returns dict with status, details, and sync statistics
|
||||
"""
|
||||
panel_records_checked = 0
|
||||
users_found_in_db = 0
|
||||
users_updated = 0
|
||||
subscriptions_synced_count = 0
|
||||
sync_errors = []
|
||||
|
||||
# Additional counters for detailed logging
|
||||
users_without_telegram_id = 0
|
||||
users_not_found_in_db = 0
|
||||
users_created = 0
|
||||
users_uuid_updated = 0
|
||||
subscriptions_created = 0
|
||||
subscriptions_updated = 0
|
||||
|
||||
try:
|
||||
panel_users_data = await panel_service.get_all_panel_users()
|
||||
|
||||
if panel_users_data is None:
|
||||
error_msg = "Failed to fetch users from panel or panel API issue."
|
||||
sync_errors.append(error_msg)
|
||||
await panel_sync_dal.update_panel_sync_status(session, "failed", error_msg)
|
||||
await session.commit()
|
||||
return {"status": "failed", "details": error_msg, "errors": sync_errors}
|
||||
|
||||
if not panel_users_data:
|
||||
status_msg = "No users found in the panel to sync."
|
||||
await panel_sync_dal.update_panel_sync_status(
|
||||
session, "success", status_msg, 0, 0
|
||||
)
|
||||
await session.commit()
|
||||
return {"status": "success", "details": status_msg, "users_synced": 0, "subs_synced": 0}
|
||||
|
||||
total_panel_users = len(panel_users_data)
|
||||
logging.info(f"Starting sync for {total_panel_users} panel users.")
|
||||
|
||||
for panel_user_dict in panel_users_data:
|
||||
try:
|
||||
panel_records_checked += 1
|
||||
panel_uuid = panel_user_dict.get("uuid")
|
||||
panel_subscription_uuid = panel_user_dict.get("subscriptionUuid") or panel_user_dict.get("shortUuid")
|
||||
telegram_id_from_panel = panel_user_dict.get("telegramId")
|
||||
|
||||
if not panel_uuid:
|
||||
sync_errors.append(f"Panel user missing UUID: {panel_user_dict}")
|
||||
logging.warning(f"Skipping panel user without UUID: {panel_user_dict}")
|
||||
continue
|
||||
|
||||
# Track users without telegram ID
|
||||
if not telegram_id_from_panel:
|
||||
users_without_telegram_id += 1
|
||||
|
||||
# Try to find existing user in local DB
|
||||
existing_user = None
|
||||
|
||||
# First, try to find by telegram ID if available
|
||||
if telegram_id_from_panel:
|
||||
existing_user = await user_dal.get_user_by_id(session, telegram_id_from_panel)
|
||||
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
|
||||
if not existing_user:
|
||||
existing_user = await user_dal.get_user_by_panel_uuid(session, panel_uuid)
|
||||
if existing_user:
|
||||
logging.info(f"Found user by panel UUID {panel_uuid}, telegramId: {existing_user.user_id}")
|
||||
# Update telegram ID if it was missing in panel data but we have local user
|
||||
if telegram_id_from_panel and existing_user.user_id != telegram_id_from_panel:
|
||||
logging.warning(f"TelegramId mismatch: panel={telegram_id_from_panel}, local={existing_user.user_id}")
|
||||
|
||||
if not existing_user:
|
||||
users_not_found_in_db += 1
|
||||
if telegram_id_from_panel:
|
||||
# Create new user if they have telegram_id
|
||||
try:
|
||||
user_data = {
|
||||
"user_id": telegram_id_from_panel,
|
||||
"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
|
||||
"language_code": "ru", # Default language
|
||||
"panel_user_uuid": panel_uuid,
|
||||
"is_banned": False,
|
||||
"referred_by_id": None
|
||||
}
|
||||
|
||||
new_user, was_created = await user_dal.create_user(session, user_data)
|
||||
if was_created:
|
||||
users_created += 1
|
||||
logging.info(f"Created new user {telegram_id_from_panel} from panel sync with UUID {panel_uuid}")
|
||||
|
||||
existing_user = new_user
|
||||
|
||||
except Exception as e_create:
|
||||
sync_errors.append(f"Error creating user {telegram_id_from_panel}: {str(e_create)}")
|
||||
logging.error(f"Error creating user {telegram_id_from_panel}: {e_create}")
|
||||
continue
|
||||
else:
|
||||
logging.debug(f"Panel user with UUID {panel_uuid} (no telegramId) not found in local DB - skipping")
|
||||
continue
|
||||
|
||||
# User found in local DB
|
||||
users_found_in_db += 1
|
||||
user_was_updated = False
|
||||
|
||||
# Get the actual user_id for subscription operations
|
||||
actual_user_id = existing_user.user_id
|
||||
|
||||
# Update panel UUID if different
|
||||
if existing_user.panel_user_uuid != panel_uuid:
|
||||
existing_user.panel_user_uuid = panel_uuid
|
||||
user_was_updated = True
|
||||
users_uuid_updated += 1
|
||||
logging.info(f"Updated panel UUID for user {actual_user_id}: {panel_uuid}")
|
||||
|
||||
# Ensure panel description contains Telegram fields
|
||||
try:
|
||||
if panel_uuid and existing_user:
|
||||
description_text = "\n".join([
|
||||
existing_user.username or "",
|
||||
existing_user.first_name or "",
|
||||
existing_user.last_name or "",
|
||||
])
|
||||
# Update description only when it differs from the current one on panel
|
||||
current_panel_description = (panel_user_dict.get("description") or "").strip()
|
||||
desired_description = description_text.strip()
|
||||
if desired_description and desired_description != current_panel_description:
|
||||
await panel_service.update_user_details_on_panel(
|
||||
panel_uuid, {"description": description_text}
|
||||
)
|
||||
except Exception as e_desc:
|
||||
logging.warning(
|
||||
f"Sync: Failed to update description for panel user {panel_uuid} (tg {actual_user_id}): {e_desc}"
|
||||
)
|
||||
|
||||
# Sync subscription data
|
||||
panel_expire_at_iso = panel_user_dict.get("expireAt")
|
||||
panel_status = panel_user_dict.get("status", "UNKNOWN")
|
||||
|
||||
if panel_expire_at_iso:
|
||||
try:
|
||||
panel_expire_at = datetime.fromisoformat(
|
||||
panel_expire_at_iso.replace("Z", "+00:00")
|
||||
)
|
||||
|
||||
# Prefer syncing by concrete subscription UUID (shortUuid/subscriptionUuid)
|
||||
subscription_uuid_from_panel = (
|
||||
panel_user_dict.get("subscriptionUuid")
|
||||
or panel_user_dict.get("shortUuid")
|
||||
)
|
||||
|
||||
if subscription_uuid_from_panel:
|
||||
# Try to find subscription by its panel_subscription_uuid first (idempotent)
|
||||
existing_sub_by_uuid = (
|
||||
await subscription_dal.get_subscription_by_panel_subscription_uuid(
|
||||
session, subscription_uuid_from_panel
|
||||
)
|
||||
)
|
||||
|
||||
if existing_sub_by_uuid:
|
||||
# Atomic update of all relevant fields
|
||||
await subscription_dal.update_subscription(
|
||||
session,
|
||||
existing_sub_by_uuid.subscription_id,
|
||||
{
|
||||
"user_id": actual_user_id,
|
||||
"panel_user_uuid": panel_uuid,
|
||||
"end_date": panel_expire_at,
|
||||
"is_active": panel_status == "ACTIVE",
|
||||
"status_from_panel": panel_status,
|
||||
},
|
||||
)
|
||||
subscriptions_synced_count += 1
|
||||
subscriptions_updated += 1
|
||||
user_was_updated = True
|
||||
logging.info(
|
||||
f"Synced existing subscription {existing_sub_by_uuid.subscription_id} for user {actual_user_id}: expires {panel_expire_at}, status {panel_status}"
|
||||
)
|
||||
else:
|
||||
# Create a new subscription only when we have a concrete subscription UUID
|
||||
sub_payload = {
|
||||
"user_id": actual_user_id,
|
||||
"panel_user_uuid": panel_uuid,
|
||||
"panel_subscription_uuid": subscription_uuid_from_panel,
|
||||
# Do not guess precise start_date from panel; keep nullable
|
||||
"start_date": None,
|
||||
"end_date": panel_expire_at,
|
||||
"duration_months": None,
|
||||
"is_active": panel_status == "ACTIVE",
|
||||
"status_from_panel": panel_status,
|
||||
"traffic_limit_bytes": settings.user_traffic_limit_bytes,
|
||||
}
|
||||
created_sub = await subscription_dal.upsert_subscription(
|
||||
session, sub_payload
|
||||
)
|
||||
subscriptions_synced_count += 1
|
||||
subscriptions_created += 1
|
||||
user_was_updated = True
|
||||
logging.info(
|
||||
f"Created subscription {created_sub.subscription_id} for user {actual_user_id} by panel_sub_uuid {subscription_uuid_from_panel}"
|
||||
)
|
||||
else:
|
||||
# No subscription UUID from panel: only update an already active subscription for this user/panel UUID
|
||||
active_sub = await subscription_dal.get_active_subscription_by_user_id(
|
||||
session, actual_user_id, panel_uuid
|
||||
)
|
||||
if active_sub:
|
||||
await subscription_dal.update_subscription(
|
||||
session,
|
||||
active_sub.subscription_id,
|
||||
{
|
||||
"end_date": panel_expire_at,
|
||||
"is_active": panel_status == "ACTIVE",
|
||||
"status_from_panel": panel_status,
|
||||
},
|
||||
)
|
||||
subscriptions_synced_count += 1
|
||||
subscriptions_updated += 1
|
||||
user_was_updated = True
|
||||
logging.info(
|
||||
f"Updated active subscription {active_sub.subscription_id} for user {actual_user_id}: expires {panel_expire_at}, status {panel_status}"
|
||||
)
|
||||
else:
|
||||
# Without a concrete subscription UUID we avoid creating new records to keep sync idempotent
|
||||
logging.debug(
|
||||
f"No subscriptionUuid for panel user {panel_uuid}; skipped creation for user {actual_user_id}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
sync_errors.append(f"Error syncing subscription for user {actual_user_id}: {str(e)}")
|
||||
logging.error(f"Error syncing subscription for user {actual_user_id}: {e}")
|
||||
|
||||
if user_was_updated:
|
||||
users_updated += 1
|
||||
|
||||
except Exception as e_user:
|
||||
sync_errors.append(f"Error processing panel user {panel_user_dict.get('uuid', 'unknown')}: {str(e_user)}")
|
||||
logging.error(f"Error syncing user: {e_user}")
|
||||
|
||||
# Update sync status
|
||||
status = "completed_with_errors" if sync_errors else "completed"
|
||||
# Build additional stats
|
||||
default_lang = settings.DEFAULT_LANGUAGE
|
||||
additional_stats = ""
|
||||
if users_without_telegram_id > 0:
|
||||
additional_stats += i18n_instance.gettext(default_lang, "admin_sync_no_telegram_id", count=users_without_telegram_id)
|
||||
if users_not_found_in_db > 0:
|
||||
additional_stats += i18n_instance.gettext(default_lang, "admin_sync_not_found_in_db", count=users_not_found_in_db)
|
||||
if sync_errors:
|
||||
additional_stats += i18n_instance.gettext(default_lang, "admin_sync_errors", count=len(sync_errors))
|
||||
|
||||
# Build full details using localization
|
||||
details = i18n_instance.gettext(default_lang, "admin_sync_details",
|
||||
panel_records_checked=panel_records_checked,
|
||||
users_found_in_db=users_found_in_db,
|
||||
users_created=users_created,
|
||||
users_updated=users_updated,
|
||||
subscriptions_synced_count=subscriptions_synced_count,
|
||||
subscriptions_created=subscriptions_created,
|
||||
subscriptions_updated=subscriptions_updated,
|
||||
additional_stats=additional_stats
|
||||
)
|
||||
|
||||
await panel_sync_dal.update_panel_sync_status(
|
||||
session, status, details, panel_records_checked, subscriptions_synced_count
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
# Detailed logging summary
|
||||
logging.info(f"Sync completed - Summary:")
|
||||
logging.info(f" Panel records checked: {panel_records_checked}")
|
||||
logging.info(f" Users without telegramId: {users_without_telegram_id}")
|
||||
logging.info(f" Users not found in local DB: {users_not_found_in_db}")
|
||||
logging.info(f" Users found in local DB: {users_found_in_db}")
|
||||
logging.info(f" Users created: {users_created}")
|
||||
logging.info(f" Users with UUID updated: {users_uuid_updated}")
|
||||
logging.info(f" Users updated overall: {users_updated}")
|
||||
logging.info(f" Subscriptions total synced: {subscriptions_synced_count}")
|
||||
logging.info(f" Subscriptions created: {subscriptions_created}")
|
||||
logging.info(f" Subscriptions updated: {subscriptions_updated}")
|
||||
logging.info(f" Sync errors: {len(sync_errors)}")
|
||||
|
||||
return {
|
||||
"status": status,
|
||||
"details": details,
|
||||
"users_processed": panel_records_checked,
|
||||
"users_synced": users_found_in_db,
|
||||
"users_created": users_created,
|
||||
"subs_synced": subscriptions_synced_count,
|
||||
"errors": sync_errors
|
||||
}
|
||||
|
||||
except Exception as e_sync_global:
|
||||
await session.rollback()
|
||||
logging.error(f"Global error during sync: {e_sync_global}", exc_info=True)
|
||||
error_detail = f"Unexpected error during sync: {str(e_sync_global)}"
|
||||
|
||||
await panel_sync_dal.update_panel_sync_status(
|
||||
session, "failed", error_detail, panel_records_checked, subscriptions_synced_count
|
||||
)
|
||||
|
||||
return {"status": "failed", "details": error_detail, "errors": [str(e_sync_global)]}
|
||||
|
||||
|
||||
@router.message(Command("sync"))
|
||||
async def sync_command_handler(
|
||||
message_event: Union[types.Message, types.CallbackQuery],
|
||||
@@ -48,269 +358,49 @@ async def sync_command_handler(
|
||||
return
|
||||
|
||||
if isinstance(message_event, types.Message):
|
||||
await message_event.answer(_("sync_started"))
|
||||
await message_event.answer(_("sync_started_simple"))
|
||||
|
||||
logging.info(f"Admin ({message_event.from_user.id}) triggered panel sync.")
|
||||
|
||||
users_processed_count = 0
|
||||
users_synced_successfully = 0
|
||||
subscriptions_synced_count = 0
|
||||
sync_errors = []
|
||||
|
||||
# Use the extracted perform_sync function
|
||||
try:
|
||||
panel_users_data = await panel_service.get_all_panel_users()
|
||||
|
||||
if panel_users_data is None:
|
||||
error_msg = "Failed to fetch users from panel or panel API issue."
|
||||
sync_errors.append(error_msg)
|
||||
await panel_sync_dal.update_panel_sync_status(session, "failed", error_msg)
|
||||
await session.commit()
|
||||
await bot.send_message(target_chat_id, _("sync_failed", details=error_msg))
|
||||
return
|
||||
|
||||
if not panel_users_data:
|
||||
status_msg = "No users found in the panel to sync."
|
||||
await panel_sync_dal.update_panel_sync_status(
|
||||
session, "success", status_msg, 0, 0
|
||||
)
|
||||
await session.commit()
|
||||
await bot.send_message(
|
||||
target_chat_id,
|
||||
_("sync_completed", status="Success", details=status_msg),
|
||||
)
|
||||
return
|
||||
|
||||
total_panel_users = len(panel_users_data)
|
||||
logging.info(f"Starting sync for {total_panel_users} panel users.")
|
||||
|
||||
for panel_user_dict in panel_users_data:
|
||||
users_processed_count += 1
|
||||
panel_uuid = panel_user_dict.get("uuid")
|
||||
telegram_id_from_panel_str = panel_user_dict.get("telegramId")
|
||||
panel_username = panel_user_dict.get("username")
|
||||
|
||||
if not panel_uuid:
|
||||
logging.warning(
|
||||
f"Sync: Panel user data missing 'uuid'. Data: {str(panel_user_dict)[:200]}. Skipping."
|
||||
)
|
||||
sync_errors.append(
|
||||
f"Panel user data (username: {panel_username or 'N/A'}) missing UUID."
|
||||
)
|
||||
continue
|
||||
|
||||
telegram_id_from_panel: Optional[int] = None
|
||||
if telegram_id_from_panel_str:
|
||||
try:
|
||||
telegram_id_from_panel = int(telegram_id_from_panel_str)
|
||||
except ValueError:
|
||||
logging.warning(
|
||||
f"Sync: Panel user {panel_uuid} (username: {panel_username}) has invalid 'telegramId': {telegram_id_from_panel_str}. Skipping TG ID based sync."
|
||||
)
|
||||
|
||||
if not telegram_id_from_panel:
|
||||
|
||||
logging.info(
|
||||
f"Sync: Panel user {panel_uuid} (username: {panel_username}) has no valid 'telegramId'. Skipping full sync for this user."
|
||||
)
|
||||
|
||||
continue
|
||||
|
||||
bot_user = await user_dal.get_user_by_id(session, telegram_id_from_panel)
|
||||
if not bot_user:
|
||||
user_data_to_create = {
|
||||
"user_id": telegram_id_from_panel,
|
||||
"username": panel_username,
|
||||
"panel_user_uuid": panel_uuid,
|
||||
"language_code": settings.DEFAULT_LANGUAGE,
|
||||
"registration_date": (
|
||||
datetime.fromisoformat(
|
||||
panel_user_dict["createdAt"].replace("Z", "+00:00")
|
||||
)
|
||||
if panel_user_dict.get("createdAt")
|
||||
else datetime.now(timezone.utc)
|
||||
),
|
||||
}
|
||||
bot_user = await user_dal.create_user(session, user_data_to_create)
|
||||
logging.info(
|
||||
f"Sync: Created new local user {telegram_id_from_panel} from panel data {panel_uuid}."
|
||||
)
|
||||
else:
|
||||
if bot_user.panel_user_uuid != panel_uuid:
|
||||
if bot_user.panel_user_uuid is not None:
|
||||
logging.warning(
|
||||
f"Sync: Local user {telegram_id_from_panel} was linked to {bot_user.panel_user_uuid}, panel now gives {panel_uuid}. Updating."
|
||||
)
|
||||
|
||||
conflicting_user = await user_dal.get_user_by_panel_uuid(
|
||||
session, panel_uuid
|
||||
)
|
||||
if (
|
||||
conflicting_user
|
||||
and conflicting_user.user_id != telegram_id_from_panel
|
||||
):
|
||||
sync_errors.append(
|
||||
f"Panel UUID {panel_uuid} for TG {telegram_id_from_panel} already linked to another TG user {conflicting_user.user_id}."
|
||||
)
|
||||
logging.error(sync_errors[-1])
|
||||
continue
|
||||
|
||||
await user_dal.update_user(
|
||||
session,
|
||||
telegram_id_from_panel,
|
||||
{"panel_user_uuid": panel_uuid, "username": panel_username},
|
||||
)
|
||||
logging.info(
|
||||
f"Sync: Updated panel_uuid for local user {telegram_id_from_panel} to {panel_uuid}."
|
||||
)
|
||||
|
||||
panel_sub_link_id = panel_user_dict.get(
|
||||
"subscriptionUuid"
|
||||
) or panel_user_dict.get("shortUuid")
|
||||
if panel_sub_link_id:
|
||||
end_date_str = panel_user_dict.get("expireAt")
|
||||
start_date_str = panel_user_dict.get("createdAt")
|
||||
|
||||
if end_date_str:
|
||||
try:
|
||||
end_date_obj = datetime.fromisoformat(
|
||||
end_date_str.replace("Z", "+00:00")
|
||||
)
|
||||
start_date_obj = (
|
||||
datetime.fromisoformat(
|
||||
start_date_str.replace("Z", "+00:00")
|
||||
)
|
||||
if start_date_str
|
||||
else datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
status_from_panel = panel_user_dict.get(
|
||||
"status", "UNKNOWN"
|
||||
).upper()
|
||||
is_active_flag = (
|
||||
1
|
||||
if status_from_panel == "ACTIVE"
|
||||
and end_date_obj > datetime.now(timezone.utc)
|
||||
else 0
|
||||
)
|
||||
|
||||
sub_payload = {
|
||||
"user_id": telegram_id_from_panel,
|
||||
"panel_user_uuid": panel_uuid,
|
||||
"panel_subscription_uuid": panel_sub_link_id,
|
||||
"start_date": start_date_obj,
|
||||
"end_date": end_date_obj,
|
||||
"is_active": is_active_flag,
|
||||
"status_from_panel": status_from_panel,
|
||||
"traffic_limit_bytes": panel_user_dict.get(
|
||||
"trafficLimitBytes"
|
||||
),
|
||||
"traffic_used_bytes": panel_user_dict.get(
|
||||
"usedTrafficBytes"
|
||||
),
|
||||
}
|
||||
|
||||
await subscription_dal.deactivate_other_active_subscriptions(
|
||||
session, panel_uuid, panel_sub_link_id
|
||||
)
|
||||
await subscription_dal.upsert_subscription(session, sub_payload)
|
||||
subscriptions_synced_count += 1
|
||||
users_synced_successfully += 1
|
||||
except ValueError as e_date:
|
||||
logging.warning(
|
||||
f"Sync: Bad date format for panel user {panel_uuid} (TG ID: {telegram_id_from_panel}). Sub data: {str(panel_user_dict)[:100]}. Error: {e_date}"
|
||||
)
|
||||
sync_errors.append(
|
||||
f"Bad date for panel user {panel_uuid} (TG ID: {telegram_id_from_panel})."
|
||||
)
|
||||
except Exception as e_sub_sync:
|
||||
logging.error(
|
||||
f"Sync: Error syncing subscription for panel user {panel_uuid} (TG ID: {telegram_id_from_panel}): {e_sub_sync}",
|
||||
exc_info=True,
|
||||
)
|
||||
sync_errors.append(
|
||||
f"Sub sync error for panel user {panel_uuid} (TG ID: {telegram_id_from_panel})."
|
||||
)
|
||||
else:
|
||||
logging.warning(
|
||||
f"Sync: Panel user {panel_uuid} (TG ID: {telegram_id_from_panel}) has sub link but no expireAt date. Skipping subscription sync."
|
||||
)
|
||||
else:
|
||||
|
||||
await subscription_dal.deactivate_other_active_subscriptions(
|
||||
session, panel_uuid, None
|
||||
)
|
||||
logging.info(
|
||||
f"Sync: Panel user {panel_uuid} (TG ID: {telegram_id_from_panel}) has no subscription link on panel. Deactivated local subs if any."
|
||||
)
|
||||
users_synced_successfully += 1
|
||||
|
||||
if users_processed_count % 20 == 0:
|
||||
logging.info(
|
||||
f"Sync progress: {users_processed_count}/{total_panel_users} users processed from panel."
|
||||
)
|
||||
|
||||
panel_uuid_set = {u.get("uuid") for u in panel_users_data if u.get("uuid")}
|
||||
local_users_with_uuid = await user_dal.get_all_users_with_panel_uuid(session)
|
||||
for local_user in local_users_with_uuid:
|
||||
if local_user.panel_user_uuid not in panel_uuid_set:
|
||||
await subscription_dal.deactivate_other_active_subscriptions(
|
||||
session, local_user.panel_user_uuid, None
|
||||
)
|
||||
logging.info(
|
||||
f"Sync: Local user {local_user.user_id} with panel UUID {local_user.panel_user_uuid} not found on panel. Deactivated local subs."
|
||||
)
|
||||
|
||||
status_msg_key = "sync_completed_details"
|
||||
final_status_type = "success"
|
||||
|
||||
if sync_errors:
|
||||
final_status_type = "partial_success"
|
||||
status_msg_key = "sync_completed_with_errors_details"
|
||||
error_preview = "\n".join(sync_errors[:3])
|
||||
details_for_db = f"Users processed: {users_processed_count}. Subs synced: {subscriptions_synced_count}. Errors: {len(sync_errors)}. First few: {error_preview}"
|
||||
sync_result = await perform_sync(panel_service, session, settings, i18n)
|
||||
|
||||
status = sync_result.get("status")
|
||||
details = sync_result.get("details", "No details available")
|
||||
errors = sync_result.get("errors", [])
|
||||
|
||||
# Simple confirmation message to admin
|
||||
if status == "failed":
|
||||
await bot.send_message(target_chat_id, _("sync_failed_simple"))
|
||||
elif status == "completed_with_errors":
|
||||
await bot.send_message(target_chat_id, _("sync_errors_simple", errors_count=len(errors)))
|
||||
else:
|
||||
details_for_db = f"Successfully processed {users_processed_count} users. Synced {subscriptions_synced_count} subscriptions."
|
||||
|
||||
await panel_sync_dal.update_panel_sync_status(
|
||||
session,
|
||||
final_status_type,
|
||||
details_for_db,
|
||||
users_processed_count,
|
||||
subscriptions_synced_count,
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
final_user_message = _(
|
||||
status_msg_key,
|
||||
total_checked=total_panel_users,
|
||||
users_synced=users_synced_successfully,
|
||||
subs_synced=subscriptions_synced_count,
|
||||
errors_count=len(sync_errors),
|
||||
error_details_preview=(
|
||||
error_preview if sync_errors else _("no_errors_placeholder")
|
||||
),
|
||||
)
|
||||
await bot.send_message(target_chat_id, final_user_message)
|
||||
|
||||
await bot.send_message(target_chat_id, _("sync_success_simple"))
|
||||
|
||||
# Send notification to log channel with proper thread handling
|
||||
try:
|
||||
notification_service = NotificationService(bot, settings, i18n)
|
||||
await notification_service.notify_panel_sync(
|
||||
status, details,
|
||||
sync_result.get("users_processed", 0),
|
||||
sync_result.get("subs_synced", 0)
|
||||
)
|
||||
except Exception as e_notification:
|
||||
logging.error(f"Failed to send sync notification: {e_notification}")
|
||||
|
||||
except Exception as e_sync_global:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"Global error during /sync command: {e_sync_global}", exc_info=True
|
||||
)
|
||||
error_detail_for_db = (
|
||||
f"An unexpected error occurred during sync: {str(e_sync_global)[:200]}"
|
||||
)
|
||||
await panel_sync_dal.update_panel_sync_status(
|
||||
session,
|
||||
"failed",
|
||||
error_detail_for_db,
|
||||
users_processed_count,
|
||||
subscriptions_synced_count,
|
||||
)
|
||||
|
||||
await bot.send_message(
|
||||
target_chat_id, _("sync_failed", details=error_detail_for_db)
|
||||
)
|
||||
logging.error(f"Global error during /sync command: {e_sync_global}", exc_info=True)
|
||||
await bot.send_message(target_chat_id, _("sync_critical_error"))
|
||||
|
||||
# Send notification to log channel about failure
|
||||
try:
|
||||
notification_service = NotificationService(bot, settings, i18n)
|
||||
await notification_service.notify_panel_sync(
|
||||
"failed", str(e_sync_global), 0, 0
|
||||
)
|
||||
except Exception as e_notification:
|
||||
logging.error(f"Failed to send sync failure notification: {e_notification}")
|
||||
|
||||
|
||||
@router.message(Command("syncstatus"))
|
||||
@@ -333,11 +423,7 @@ async def sync_status_command_handler(
|
||||
)
|
||||
|
||||
details_val = status_record_model.details
|
||||
details_str = (
|
||||
(details_val[:200] + "...")
|
||||
if details_val and len(details_val) > 200
|
||||
else (details_val or "N/A")
|
||||
)
|
||||
details_str = details_val or "N/A"
|
||||
|
||||
response_text = (
|
||||
f"<b>{_('admin_stats_last_sync_header')}</b>\n"
|
||||
@@ -350,4 +436,4 @@ async def sync_status_command_handler(
|
||||
else:
|
||||
response_text = _("admin_sync_status_never_run")
|
||||
|
||||
await message.answer(response_text, parse_mode="HTML")
|
||||
await message.answer(response_text, parse_mode="HTML")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,361 @@
|
||||
import logging
|
||||
from aiogram import Router, types, Bot
|
||||
from aiogram.types import InlineQuery, InlineQueryResultArticle, InputTextMessageContent
|
||||
from typing import List, Optional
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config.settings import Settings
|
||||
from db.dal import user_dal, payment_dal
|
||||
from bot.services.referral_service import ReferralService
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
|
||||
router = Router(name="inline_mode_router")
|
||||
|
||||
|
||||
@router.inline_query()
|
||||
async def inline_query_handler(inline_query: InlineQuery,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
referral_service: ReferralService,
|
||||
bot: Bot,
|
||||
session: AsyncSession):
|
||||
"""Handle inline queries for referral links and admin statistics"""
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n:
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
user_id = inline_query.from_user.id
|
||||
query = inline_query.query.lower().strip()
|
||||
|
||||
results: List[InlineQueryResultArticle] = []
|
||||
|
||||
# Check if user is admin
|
||||
is_admin = user_id in settings.ADMIN_IDS if settings.ADMIN_IDS else False
|
||||
|
||||
try:
|
||||
# For all users: referral functionality
|
||||
if not query or "реф" in query or "ref" in query or "друг" in query or "friend" in query:
|
||||
referral_result = await create_referral_result(
|
||||
inline_query, bot, referral_service, i18n, current_lang, settings
|
||||
)
|
||||
if referral_result:
|
||||
results.append(referral_result)
|
||||
|
||||
# For admins: statistics
|
||||
if is_admin and (not query or "стат" in query or "stat" in query or "админ" in query or "admin" in query):
|
||||
stats_results = await create_admin_stats_results(
|
||||
session, i18n, current_lang, settings
|
||||
)
|
||||
results.extend(stats_results)
|
||||
|
||||
|
||||
|
||||
# Limit results to 50 (Telegram limit)
|
||||
results = results[:50]
|
||||
|
||||
await inline_query.answer(
|
||||
results=results,
|
||||
cache_time=30, # Cache for 30 seconds
|
||||
is_personal=True # Results are personalized
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error handling inline query from user {user_id}: {e}")
|
||||
# Send empty results in case of error
|
||||
await inline_query.answer(results=[], cache_time=10)
|
||||
|
||||
|
||||
async def create_referral_result(inline_query: InlineQuery, bot: Bot,
|
||||
referral_service: ReferralService,
|
||||
i18n_instance, lang: str, settings: Settings) -> Optional[InlineQueryResultArticle]:
|
||||
"""Create referral link result for inline query"""
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
|
||||
try:
|
||||
bot_info = await bot.get_me()
|
||||
bot_username = bot_info.username
|
||||
if not bot_username:
|
||||
return None
|
||||
|
||||
user_id = inline_query.from_user.id
|
||||
referral_link = referral_service.generate_referral_link(bot_username, user_id)
|
||||
|
||||
# Create message content (use same text as friend message)
|
||||
message_text = _(
|
||||
"referral_friend_message",
|
||||
default="🚀 Привет! Попробуй этот VPN - быстрый, надёжный и доступный!\n\n"
|
||||
"🎁 По моей ссылке тебе дадут бонусные дни к подписке!\n\n{referral_link}",
|
||||
referral_link=referral_link
|
||||
)
|
||||
|
||||
return InlineQueryResultArticle(
|
||||
id="referral_link",
|
||||
title=_(
|
||||
"inline_referral_title",
|
||||
default="🎁 Пригласить друга"
|
||||
),
|
||||
description=_(
|
||||
"inline_referral_description",
|
||||
default="Поделиться реферальной ссылкой для получения бонусов"
|
||||
),
|
||||
input_message_content=InputTextMessageContent(
|
||||
message_text=message_text,
|
||||
disable_web_page_preview=True
|
||||
),
|
||||
thumbnail_url=settings.INLINE_REFERRAL_THUMBNAIL_URL
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error creating referral result: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def create_admin_stats_results(session: AsyncSession, i18n_instance, lang: str, settings: Settings) -> List[InlineQueryResultArticle]:
|
||||
"""Create admin statistics results for inline query"""
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
results = []
|
||||
|
||||
try:
|
||||
# Quick user stats
|
||||
user_stats_result = await create_user_stats_result(session, i18n_instance, lang, settings)
|
||||
if user_stats_result:
|
||||
results.append(user_stats_result)
|
||||
|
||||
# Quick financial stats
|
||||
financial_stats_result = await create_financial_stats_result(session, i18n_instance, lang, settings)
|
||||
if financial_stats_result:
|
||||
results.append(financial_stats_result)
|
||||
|
||||
# Quick system stats
|
||||
system_stats_result = await create_system_stats_result(session, i18n_instance, lang, settings)
|
||||
if system_stats_result:
|
||||
results.append(system_stats_result)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error creating admin stats results: {e}")
|
||||
|
||||
return results
|
||||
|
||||
|
||||
async def create_user_stats_result(session: AsyncSession, i18n_instance, lang: str, settings: Settings) -> Optional[InlineQueryResultArticle]:
|
||||
"""Create user statistics result"""
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
|
||||
try:
|
||||
from db.dal.user_dal import get_enhanced_user_statistics
|
||||
user_stats = await get_enhanced_user_statistics(session)
|
||||
|
||||
stats_text = _(
|
||||
"inline_user_stats_message",
|
||||
default="📊 <b>Статистика Бота</b>\n👥 Пользователи\n\n"
|
||||
"📊 Всего: <b>{total}</b>\n"
|
||||
"💳 С платной подпиской: <b>{paid}</b>\n"
|
||||
"🆓 На пробном периоде: <b>{trial}</b>\n"
|
||||
"😴 Неактивных: <b>{inactive}</b>\n"
|
||||
"🚫 Заблокированных: <b>{banned}</b>\n"
|
||||
"🎁 Привлечено по реферальной программе: <b>{referral}</b>",
|
||||
total=user_stats['total_users'],
|
||||
active_today=user_stats['active_today'],
|
||||
paid=user_stats['paid_subscriptions'],
|
||||
trial=user_stats['trial_users'],
|
||||
inactive=user_stats['inactive_users'],
|
||||
banned=user_stats['banned_users'],
|
||||
referral=user_stats['referral_users']
|
||||
)
|
||||
|
||||
return InlineQueryResultArticle(
|
||||
id="admin_user_stats",
|
||||
title=_(
|
||||
"inline_admin_user_stats_title",
|
||||
default="📊 Статистика пользователей"
|
||||
),
|
||||
description=_(
|
||||
"inline_user_stats_description",
|
||||
default="Всего: {total}, Платных: {active}",
|
||||
total=user_stats['total_users'],
|
||||
active=user_stats['paid_subscriptions']
|
||||
),
|
||||
input_message_content=InputTextMessageContent(
|
||||
message_text=stats_text,
|
||||
parse_mode="HTML"
|
||||
),
|
||||
thumbnail_url=settings.INLINE_USER_STATS_THUMBNAIL_URL
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error creating user stats result: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def create_financial_stats_result(session: AsyncSession, i18n_instance, lang: str, settings: Settings) -> Optional[InlineQueryResultArticle]:
|
||||
"""Create financial statistics result"""
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
|
||||
try:
|
||||
from db.dal.payment_dal import get_financial_statistics
|
||||
financial_stats = await get_financial_statistics(session)
|
||||
|
||||
stats_text = _(
|
||||
"inline_financial_stats_message",
|
||||
default="💰 <b>Финансовая статистика</b>\n\n"
|
||||
"📅 За сегодня: <b>{today:.2f} RUB</b>\n"
|
||||
" ({today_count} платежей)\n"
|
||||
"📅 За неделю: <b>{week:.2f} RUB</b>\n"
|
||||
"📅 За месяц: <b>{month:.2f} RUB</b>\n"
|
||||
"🏆 За все время: <b>{all_time:.2f} RUB</b>",
|
||||
today=financial_stats['today_revenue'],
|
||||
today_count=financial_stats['today_payments_count'],
|
||||
week=financial_stats['week_revenue'],
|
||||
month=financial_stats['month_revenue'],
|
||||
all_time=financial_stats['all_time_revenue']
|
||||
)
|
||||
|
||||
return InlineQueryResultArticle(
|
||||
id="admin_financial_stats",
|
||||
title=_(
|
||||
"inline_admin_financial_stats_title",
|
||||
default="💰 Финансовая статистика"
|
||||
),
|
||||
description=_(
|
||||
"inline_financial_description",
|
||||
default="Сегодня: {today} RUB",
|
||||
today=f"{financial_stats['today_revenue']:.2f}"
|
||||
),
|
||||
input_message_content=InputTextMessageContent(
|
||||
message_text=stats_text,
|
||||
parse_mode="HTML"
|
||||
),
|
||||
thumbnail_url=settings.INLINE_FINANCIAL_STATS_THUMBNAIL_URL
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error creating financial stats result: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def create_system_stats_result(session: AsyncSession, i18n_instance, lang: str, settings: Settings) -> Optional[InlineQueryResultArticle]:
|
||||
"""Create panel statistics result with system/nodes/bandwidth info"""
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
|
||||
try:
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
|
||||
# Get panel stats similar to main statistics
|
||||
async with PanelApiService(settings) as panel_service:
|
||||
system_stats = await panel_service.get_system_stats()
|
||||
bandwidth_stats = await panel_service.get_bandwidth_stats()
|
||||
nodes_stats = await panel_service.get_nodes_statistics()
|
||||
|
||||
if system_stats:
|
||||
users = system_stats.get('users', {})
|
||||
status_counts = users.get('statusCounts', {})
|
||||
online_stats = system_stats.get('onlineStats', {})
|
||||
|
||||
active_users = status_counts.get('ACTIVE', 0)
|
||||
disabled_users = status_counts.get('DISABLED', 0)
|
||||
expired_users = status_counts.get('EXPIRED', 0)
|
||||
limited_users = status_counts.get('LIMITED', 0)
|
||||
total_users = users.get('totalUsers', 0)
|
||||
online_now = online_stats.get('onlineNow', 0)
|
||||
|
||||
# Memory usage
|
||||
memory = system_stats.get('memory', {})
|
||||
memory_usage = 0
|
||||
if memory:
|
||||
memory_total = memory.get('total', 1)
|
||||
memory_used = memory.get('used', 0)
|
||||
memory_usage = (memory_used / memory_total) * 100 if memory_total > 0 else 0
|
||||
|
||||
# Bandwidth
|
||||
week_traffic = "N/A"
|
||||
month_traffic = "N/A"
|
||||
if bandwidth_stats:
|
||||
week_data = bandwidth_stats.get('bandwidthLastSevenDays', {})
|
||||
month_data = bandwidth_stats.get('bandwidthLast30Days', {}) or bandwidth_stats.get('bandwidthLastThirtyDays', {})
|
||||
|
||||
week_traffic = week_data.get('current', 'N/A') if week_data else 'N/A'
|
||||
month_traffic = month_data.get('current', 'N/A') if month_data else 'N/A'
|
||||
|
||||
# Nodes
|
||||
active_nodes = 0
|
||||
total_nodes = 0
|
||||
if nodes_stats and 'lastSevenDays' in nodes_stats:
|
||||
unique_nodes = set()
|
||||
for node_data in nodes_stats.get('lastSevenDays', []):
|
||||
unique_nodes.add(node_data.get('nodeName', ''))
|
||||
total_nodes = len(unique_nodes)
|
||||
active_nodes = total_nodes # Assume all are active
|
||||
elif system_stats and 'nodes' in system_stats:
|
||||
active_nodes = system_stats.get('nodes', {}).get('totalOnline', 0)
|
||||
total_nodes = active_nodes
|
||||
|
||||
stats_text = _(
|
||||
"inline_system_stats_message",
|
||||
default="🖥 <b>Статистика панели</b>\n\n"
|
||||
"🟢 Онлайн: <b>{online}</b>\n"
|
||||
"📊 Активных: <b>{active}</b>\n"
|
||||
"🔴 Отключенных: <b>{disabled}</b>\n"
|
||||
"⏰ Истекшие: <b>{expired}</b>\n"
|
||||
"⚠️ Ограниченные: <b>{limited}</b>\n"
|
||||
"👥 Всего пользователей: <b>{total}</b>\n"
|
||||
"💾 Использование RAM: <b>{memory:.1f}%</b>\n"
|
||||
"📊 Трафик за неделю: <b>{week_traffic}</b>\n"
|
||||
"📊 Трафик за месяц: <b>{month_traffic}</b>\n"
|
||||
"🔗 Активных нод: <b>{active_nodes}/{total_nodes}</b>",
|
||||
online=online_now,
|
||||
active=active_users,
|
||||
disabled=disabled_users,
|
||||
expired=expired_users,
|
||||
limited=limited_users,
|
||||
total=total_users,
|
||||
memory=memory_usage,
|
||||
week_traffic=week_traffic,
|
||||
month_traffic=month_traffic,
|
||||
active_nodes=active_nodes,
|
||||
total_nodes=total_nodes
|
||||
)
|
||||
else:
|
||||
stats_text = _("inline_panel_stats_error", default="❌ Ошибка получения данных с панели")
|
||||
|
||||
return InlineQueryResultArticle(
|
||||
id="admin_system_stats",
|
||||
title=_(
|
||||
"inline_admin_system_stats_title",
|
||||
default="🖥 Системная статистика"
|
||||
),
|
||||
description=_(
|
||||
"inline_system_description",
|
||||
default="🟢 Онлайн: {online}, 📊 Активных: {active}",
|
||||
online=online_now,
|
||||
active=active_users
|
||||
),
|
||||
input_message_content=InputTextMessageContent(
|
||||
message_text=stats_text,
|
||||
parse_mode="HTML"
|
||||
),
|
||||
thumbnail_url=settings.INLINE_SYSTEM_STATS_THUMBNAIL_URL
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error creating system stats result: {e}")
|
||||
# Fallback error message
|
||||
error_text = _("inline_panel_stats_error", default="❌ Ошибка получения данных с панели")
|
||||
|
||||
return InlineQueryResultArticle(
|
||||
id="admin_system_stats",
|
||||
title=_(
|
||||
"inline_admin_system_stats_title",
|
||||
default="🖥 Системная статистика"
|
||||
),
|
||||
description=_("inline_system_error", default="Ошибка получения данных"),
|
||||
input_message_content=InputTextMessageContent(
|
||||
message_text=error_text,
|
||||
parse_mode="HTML"
|
||||
),
|
||||
thumbnail_url=settings.INLINE_SYSTEM_STATS_THUMBNAIL_URL
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from aiogram import Router
|
||||
|
||||
from . import start
|
||||
from . import subscription
|
||||
# TODO: after splitting subscription into a package, replace this import
|
||||
from .subscription import router as subscription_router
|
||||
from . import referral
|
||||
from . import promo_user
|
||||
from . import trial_handler
|
||||
@@ -11,5 +12,5 @@ user_router_aggregate = Router(name="user_router_aggregate")
|
||||
user_router_aggregate.include_router(promo_user.router)
|
||||
user_router_aggregate.include_router(trial_handler.router)
|
||||
user_router_aggregate.include_router(start.router)
|
||||
user_router_aggregate.include_router(subscription.router)
|
||||
user_router_aggregate.include_router(subscription_router)
|
||||
user_router_aggregate.include_router(referral.router)
|
||||
|
||||
+284
-46
@@ -12,7 +12,7 @@ from sqlalchemy.orm import sessionmaker
|
||||
from yookassa.domain.notification import WebhookNotification
|
||||
from yookassa.domain.models.amount import Amount as YooKassaAmount
|
||||
|
||||
from db.dal import payment_dal, user_dal
|
||||
from db.dal import payment_dal, user_dal, user_billing_dal
|
||||
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.services.referral_service import ReferralService
|
||||
@@ -20,12 +20,15 @@ from bot.services.panel_api_service import PanelApiService
|
||||
from bot.services.yookassa_service import YooKassaService
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from config.settings import Settings
|
||||
from bot.services.notification_service import notify_admin_new_payment
|
||||
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
|
||||
|
||||
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'
|
||||
|
||||
|
||||
async def process_successful_payment(session: AsyncSession, bot: Bot,
|
||||
@@ -39,8 +42,13 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
||||
subscription_months_str = metadata.get("subscription_months")
|
||||
promo_code_id_str = metadata.get("promo_code_id")
|
||||
payment_db_id_str = metadata.get("payment_db_id")
|
||||
auto_renew_subscription_id_str = metadata.get(
|
||||
"auto_renew_for_subscription_id")
|
||||
|
||||
if not user_id_str or not subscription_months_str or not payment_db_id_str:
|
||||
# For auto-renew payments, payment_db_id may be absent. In that case,
|
||||
# we will create/ensure a payment record idempotently using provider payment id.
|
||||
if (not user_id_str or not subscription_months_str
|
||||
or (not payment_db_id_str and not auto_renew_subscription_id_str)):
|
||||
logging.error(
|
||||
f"Missing crucial metadata for payment: {payment_info_from_webhook.get('id')}, metadata: {metadata}"
|
||||
)
|
||||
@@ -50,7 +58,9 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
||||
try:
|
||||
user_id = int(user_id_str)
|
||||
subscription_months = int(subscription_months_str)
|
||||
payment_db_id = int(payment_db_id_str)
|
||||
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)
|
||||
promo_code_id = int(
|
||||
promo_code_id_str
|
||||
) if promo_code_id_str and promo_code_id_str.isdigit() else None
|
||||
@@ -58,6 +68,44 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
||||
amount_data = payment_info_from_webhook.get("amount", {})
|
||||
payment_value = float(amount_data.get("value", 0.0))
|
||||
|
||||
# If this is an auto-renewal (no payment_db_id in metadata), ensure a payment record exists
|
||||
if payment_db_id is None and auto_renew_subscription_id_str:
|
||||
try:
|
||||
# Create/ensure provider payment by YooKassa payment id for idempotency
|
||||
yk_payment_id_from_hook = payment_info_from_webhook.get("id")
|
||||
from db.dal import payment_dal as _payment_dal
|
||||
ensured_payment = await _payment_dal.ensure_payment_with_provider_id(
|
||||
session,
|
||||
user_id=user_id,
|
||||
amount=payment_value,
|
||||
currency=amount_data.get("currency", settings.DEFAULT_CURRENCY_SYMBOL),
|
||||
months=subscription_months,
|
||||
description=payment_info_from_webhook.get(
|
||||
"description") or f"Auto-renewal for {subscription_months} months",
|
||||
provider="yookassa",
|
||||
provider_payment_id=yk_payment_id_from_hook,
|
||||
)
|
||||
payment_db_id = ensured_payment.payment_id
|
||||
# Also persist yookassa_payment_id field if not set yet
|
||||
try:
|
||||
await _payment_dal.update_payment_status_by_db_id(
|
||||
session,
|
||||
payment_db_id,
|
||||
payment_info_from_webhook.get("status", "succeeded"),
|
||||
yk_payment_id_from_hook,
|
||||
)
|
||||
except Exception:
|
||||
# Non-fatal; continue processing
|
||||
logging.exception(
|
||||
"Failed to backfill yookassa_payment_id for ensured auto-renew payment"
|
||||
)
|
||||
except Exception as e_ensure:
|
||||
logging.error(
|
||||
f"Failed to ensure payment record for auto-renew webhook (YK {payment_info_from_webhook.get('id')}): {e_ensure}",
|
||||
exc_info=True,
|
||||
)
|
||||
return
|
||||
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
if not db_user:
|
||||
logging.error(
|
||||
@@ -88,6 +136,42 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
||||
|
||||
try:
|
||||
yk_payment_id_from_hook = payment_info_from_webhook.get("id")
|
||||
# Try to capture and save payment method for future charges if available
|
||||
try:
|
||||
payment_method = payment_info_from_webhook.get("payment_method")
|
||||
if getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False) and isinstance(payment_method, dict) and payment_method.get("saved", False):
|
||||
pm_id = 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
|
||||
# Build generic display for various instrument types
|
||||
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:
|
||||
# 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
|
||||
|
||||
await user_billing_dal.upsert_yk_payment_method(
|
||||
session,
|
||||
user_id=user_id,
|
||||
payment_method_id=pm_id,
|
||||
card_last4=display_last4,
|
||||
card_network=display_network,
|
||||
)
|
||||
except Exception:
|
||||
logging.exception("Failed to persist YooKassa payment method from webhook")
|
||||
updated_payment_record = await payment_dal.update_payment_status_by_db_id(
|
||||
session,
|
||||
payment_db_id=payment_db_id,
|
||||
@@ -122,7 +206,12 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
||||
"applied_promo_bonus_days", 0)
|
||||
|
||||
referral_bonus_info = await referral_service.apply_referral_bonuses_for_payment(
|
||||
session, user_id, subscription_months)
|
||||
session,
|
||||
user_id,
|
||||
subscription_months,
|
||||
current_payment_db_id=payment_db_id,
|
||||
skip_if_active_before_payment=False,
|
||||
)
|
||||
applied_referee_bonus_days_from_referral: Optional[int] = None
|
||||
if referral_bonus_info and referral_bonus_info.get(
|
||||
"referee_new_end_date"):
|
||||
@@ -131,59 +220,95 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
||||
applied_referee_bonus_days_from_referral = referral_bonus_info.get(
|
||||
"referee_bonus_applied_days")
|
||||
|
||||
# Use user's DB language for all user-facing messages
|
||||
user_lang = db_user.language_code if db_user and db_user.language_code else settings.DEFAULT_LANGUAGE
|
||||
_ = lambda key, **kwargs: i18n.gettext(user_lang, key, **kwargs)
|
||||
|
||||
success_message = ""
|
||||
if applied_referee_bonus_days_from_referral and final_end_date_for_user:
|
||||
inviter_name_display = _("friend_placeholder")
|
||||
if db_user and db_user.referred_by_id:
|
||||
inviter = await user_dal.get_user_by_id(
|
||||
session, db_user.referred_by_id)
|
||||
if inviter and inviter.first_name:
|
||||
inviter_name_display = inviter.first_name
|
||||
elif inviter and inviter.username:
|
||||
inviter_name_display = f"@{inviter.username}"
|
||||
|
||||
success_message = _(
|
||||
"payment_successful_with_referral_bonus",
|
||||
# For auto-renew charges, avoid re-sending config link; send concise message
|
||||
if is_auto_renew and final_end_date_for_user:
|
||||
details_message = _(
|
||||
"yookassa_auto_renewal",
|
||||
months=subscription_months,
|
||||
base_end_date=base_subscription_end_date.strftime('%Y-%m-%d'),
|
||||
bonus_days=applied_referee_bonus_days_from_referral,
|
||||
final_end_date=final_end_date_for_user.strftime('%Y-%m-%d'),
|
||||
inviter_name=inviter_name_display)
|
||||
elif applied_promo_bonus_days > 0 and final_end_date_for_user:
|
||||
success_message = _(
|
||||
"payment_successful_with_promo",
|
||||
months=subscription_months,
|
||||
bonus_days=applied_promo_bonus_days,
|
||||
end_date=final_end_date_for_user.strftime('%Y-%m-%d'))
|
||||
elif final_end_date_for_user:
|
||||
success_message = _(
|
||||
"payment_successful",
|
||||
months=subscription_months,
|
||||
end_date=final_end_date_for_user.strftime('%Y-%m-%d'))
|
||||
end_date=final_end_date_for_user.strftime('%Y-%m-%d'),
|
||||
)
|
||||
details_markup = None
|
||||
else:
|
||||
logging.error(
|
||||
f"Critical error: final_end_date_for_user is None for user {user_id} after successful payment logic."
|
||||
config_link = activation_details.get("subscription_url") or _(
|
||||
"config_link_not_available"
|
||||
)
|
||||
success_message = _("payment_successful_error_details")
|
||||
|
||||
if applied_referee_bonus_days_from_referral and final_end_date_for_user:
|
||||
inviter_name_display = _("friend_placeholder")
|
||||
if db_user and db_user.referred_by_id:
|
||||
inviter = await user_dal.get_user_by_id(
|
||||
session, db_user.referred_by_id)
|
||||
if inviter:
|
||||
safe_name = sanitize_display_name(inviter.first_name) if inviter.first_name else None
|
||||
if safe_name:
|
||||
inviter_name_display = safe_name
|
||||
elif inviter.username:
|
||||
inviter_name_display = username_for_display(inviter.username, with_at=False)
|
||||
|
||||
details_message = _(
|
||||
"payment_successful_with_referral_bonus_full",
|
||||
months=subscription_months,
|
||||
base_end_date=base_subscription_end_date.strftime('%Y-%m-%d'),
|
||||
bonus_days=applied_referee_bonus_days_from_referral,
|
||||
final_end_date=final_end_date_for_user.strftime('%Y-%m-%d'),
|
||||
inviter_name=inviter_name_display,
|
||||
config_link=config_link,
|
||||
)
|
||||
elif applied_promo_bonus_days > 0 and final_end_date_for_user:
|
||||
details_message = _(
|
||||
"payment_successful_with_promo_full",
|
||||
months=subscription_months,
|
||||
bonus_days=applied_promo_bonus_days,
|
||||
end_date=final_end_date_for_user.strftime('%Y-%m-%d'),
|
||||
config_link=config_link,
|
||||
)
|
||||
elif final_end_date_for_user:
|
||||
details_message = _(
|
||||
"payment_successful_full",
|
||||
months=subscription_months,
|
||||
end_date=final_end_date_for_user.strftime('%Y-%m-%d'),
|
||||
config_link=config_link,
|
||||
)
|
||||
else:
|
||||
logging.error(
|
||||
f"Critical error: final_end_date_for_user is None for user {user_id} after successful payment logic."
|
||||
)
|
||||
details_message = _("payment_successful_error_details")
|
||||
|
||||
details_markup = get_connect_and_main_keyboard(
|
||||
user_lang, i18n, settings, config_link
|
||||
)
|
||||
try:
|
||||
await bot.send_message(user_id, success_message)
|
||||
await bot.send_message(
|
||||
user_id,
|
||||
details_message,
|
||||
reply_markup=details_markup,
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
except Exception as e_notify:
|
||||
logging.error(
|
||||
f"Failed to send final payment success message to user {user_id}: {e_notify}"
|
||||
f"Failed to send payment details message to user {user_id}: {e_notify}"
|
||||
)
|
||||
|
||||
await notify_admin_new_payment(
|
||||
bot,
|
||||
settings,
|
||||
i18n,
|
||||
user_id,
|
||||
subscription_months,
|
||||
payment_value,
|
||||
)
|
||||
# Send notification about payment
|
||||
try:
|
||||
notification_service = NotificationService(bot, settings, i18n)
|
||||
user = await user_dal.get_user_by_id(session, user_id)
|
||||
await notification_service.notify_payment_received(
|
||||
user_id=user_id,
|
||||
amount=payment_value,
|
||||
currency=settings.DEFAULT_CURRENCY_SYMBOL,
|
||||
months=subscription_months,
|
||||
payment_provider="yookassa", # This is specifically for YooKassa webhook
|
||||
username=user.username if user else None
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to send payment notification: {e}")
|
||||
|
||||
except Exception as e_process:
|
||||
logging.error(
|
||||
@@ -283,6 +408,40 @@ async def yookassa_webhook_route(request: web.Request):
|
||||
)
|
||||
return web.Response(status=200, text="ok_error_no_metadata")
|
||||
|
||||
# Safely extract payment_method details (SDK objects may not have to_dict)
|
||||
pm_obj = getattr(payment_data_from_notification, 'payment_method', None)
|
||||
pm_dict = None
|
||||
if pm_obj is not None:
|
||||
try:
|
||||
card_obj = getattr(pm_obj, 'card', None)
|
||||
pm_dict = {
|
||||
"id": getattr(pm_obj, 'id', None),
|
||||
"type": getattr(pm_obj, 'type', None),
|
||||
"saved": bool(getattr(pm_obj, 'saved', False)),
|
||||
"title": getattr(pm_obj, 'title', None),
|
||||
"account_number": (
|
||||
getattr(pm_obj, 'account_number', None)
|
||||
if hasattr(pm_obj, 'account_number') else (
|
||||
getattr(pm_obj, 'account', None)
|
||||
if hasattr(pm_obj, 'account') else None
|
||||
)
|
||||
),
|
||||
"card": (
|
||||
{
|
||||
"first6": getattr(card_obj, 'first6', None),
|
||||
"last4": getattr(card_obj, 'last4', None),
|
||||
"expiry_month": getattr(card_obj, 'expiry_month', None),
|
||||
"expiry_year": getattr(card_obj, 'expiry_year', None),
|
||||
"card_type": getattr(card_obj, 'card_type', None),
|
||||
}
|
||||
if card_obj is not None
|
||||
else None
|
||||
),
|
||||
}
|
||||
except Exception:
|
||||
logging.exception("Failed to serialize YooKassa payment_method from webhook")
|
||||
pm_dict = None
|
||||
|
||||
payment_dict_for_processing = {
|
||||
"id":
|
||||
str(payment_data_from_notification.id),
|
||||
@@ -299,6 +458,7 @@ async def yookassa_webhook_route(request: web.Request):
|
||||
"description":
|
||||
str(payment_data_from_notification.description)
|
||||
if payment_data_from_notification.description else None,
|
||||
"payment_method": pm_dict,
|
||||
}
|
||||
|
||||
async with payment_processing_lock:
|
||||
@@ -324,6 +484,84 @@ async def yookassa_webhook_route(request: web.Request):
|
||||
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 getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False) 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(
|
||||
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,
|
||||
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 as e_webhook_db_processing:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
|
||||
@@ -10,7 +10,11 @@ from config.settings import Settings
|
||||
from bot.states.user_states import UserPromoStates
|
||||
from bot.services.promo_code_service import PromoCodeService
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.keyboards.inline.user_keyboards import get_back_to_main_menu_markup
|
||||
from bot.keyboards.inline.user_keyboards import (
|
||||
get_back_to_main_menu_markup,
|
||||
get_connect_and_main_keyboard,
|
||||
)
|
||||
from datetime import datetime
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
|
||||
from .start import send_main_menu
|
||||
@@ -65,6 +69,7 @@ async def prompt_promo_code_input(callback: types.CallbackQuery,
|
||||
async def process_promo_code_input(message: types.Message, state: FSMContext,
|
||||
settings: Settings, i18n_data: dict,
|
||||
promo_code_service: PromoCodeService,
|
||||
subscription_service: SubscriptionService,
|
||||
bot: Bot, session: AsyncSession):
|
||||
logging.info(
|
||||
f"Processing promo code input from user {message.from_user.id} in state {await state.get_state()}: '{message.text}'"
|
||||
@@ -100,50 +105,61 @@ async def process_promo_code_input(message: types.Message, state: FSMContext,
|
||||
|
||||
response_to_user_text = ""
|
||||
if is_suspicious:
|
||||
|
||||
if settings.ADMIN_IDS:
|
||||
admin_notify_key = "admin_suspicious_promo_attempt_notification" if user.username else "admin_suspicious_promo_attempt_notification_no_username"
|
||||
|
||||
admin_lang = settings.DEFAULT_LANGUAGE
|
||||
_admin = lambda k, **kw: i18n.gettext(admin_lang, k, **kw)
|
||||
admin_notification_text = _admin(
|
||||
admin_notify_key,
|
||||
user_id=user.id,
|
||||
user_username=user.username or "N/A",
|
||||
user_first_name=user.first_name or "N/A",
|
||||
promo_code_input=hcode(code_input))
|
||||
for admin_id in settings.ADMIN_IDS:
|
||||
try:
|
||||
await bot.send_message(admin_id,
|
||||
admin_notification_text,
|
||||
parse_mode="HTML")
|
||||
except Exception as e_admin_notify:
|
||||
logging.error(
|
||||
f"Failed to send suspicious promo notification to admin {admin_id}: {e_admin_notify}"
|
||||
)
|
||||
# Send notification through NotificationService if enabled
|
||||
if settings.LOG_SUSPICIOUS_ACTIVITY:
|
||||
try:
|
||||
from bot.services.notification_service import NotificationService
|
||||
notification_service = NotificationService(bot, settings, i18n)
|
||||
await notification_service.notify_suspicious_promo_attempt(
|
||||
user_id=user.id,
|
||||
username=user.username,
|
||||
first_name=user.first_name,
|
||||
suspicious_input=code_input
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to send suspicious promo notification: {e}")
|
||||
|
||||
response_to_user_text = _("promo_code_not_found",
|
||||
code=hcode(code_input.upper()))
|
||||
reply_markup = get_back_to_main_menu_markup(current_lang, i18n)
|
||||
else:
|
||||
|
||||
success, response_text_from_service = await promo_code_service.apply_promo_code(
|
||||
success, result = await promo_code_service.apply_promo_code(
|
||||
session, user.id, code_input, current_lang)
|
||||
response_to_user_text = response_text_from_service
|
||||
if success:
|
||||
await session.commit()
|
||||
logging.info(
|
||||
f"Promo code '{code_input}' successfully applied for user {user.id}."
|
||||
)
|
||||
|
||||
new_end_date = result if isinstance(result, datetime) else None
|
||||
active = await subscription_service.get_active_subscription_details(session, user.id)
|
||||
config_link = active.get("config_link") if active else None
|
||||
config_link = config_link or _("config_link_not_available")
|
||||
|
||||
response_to_user_text = _(
|
||||
"promo_code_applied_success_full",
|
||||
end_date=(new_end_date.strftime("%d.%m.%Y %H:%M:%S") if new_end_date else "N/A"),
|
||||
config_link=config_link,
|
||||
)
|
||||
reply_markup = get_connect_and_main_keyboard(
|
||||
current_lang, i18n, settings, config_link
|
||||
)
|
||||
else:
|
||||
await session.rollback()
|
||||
logging.info(
|
||||
f"Promo code '{code_input}' application failed for user {user.id}. Reason: {response_text_from_service}"
|
||||
f"Promo code '{code_input}' application failed for user {user.id}. Reason: {result}"
|
||||
)
|
||||
response_to_user_text = result
|
||||
reply_markup = get_back_to_main_menu_markup(
|
||||
current_lang, i18n
|
||||
)
|
||||
|
||||
await message.answer(response_to_user_text,
|
||||
reply_markup=get_back_to_main_menu_markup(
|
||||
current_lang, i18n),
|
||||
parse_mode="HTML")
|
||||
await message.answer(
|
||||
response_to_user_text,
|
||||
reply_markup=reply_markup,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await state.clear()
|
||||
logging.info(
|
||||
f"Promo code input '{code_input}' processing finished for user {message.from_user.id}. State cleared."
|
||||
|
||||
@@ -83,11 +83,17 @@ async def referral_command_handler(event: Union[types.Message,
|
||||
bonus_details_str = "\n".join(bonus_info_parts) if bonus_info_parts else _(
|
||||
"referral_no_bonuses_configured")
|
||||
|
||||
# Get referral statistics
|
||||
referral_stats = await referral_service.get_referral_stats(session, inviter_user_id)
|
||||
|
||||
text = _("referral_program_info_new",
|
||||
referral_link=referral_link,
|
||||
bonus_details=bonus_details_str)
|
||||
bonus_details=bonus_details_str,
|
||||
invited_count=referral_stats["invited_count"],
|
||||
purchased_count=referral_stats["purchased_count"])
|
||||
|
||||
reply_markup_val = get_back_to_main_menu_markup(current_lang, i18n)
|
||||
from bot.keyboards.inline.user_keyboards import get_referral_link_keyboard
|
||||
reply_markup_val = get_referral_link_keyboard(current_lang, i18n)
|
||||
|
||||
if isinstance(event, types.Message):
|
||||
await event.answer(text,
|
||||
@@ -106,3 +112,37 @@ async def referral_command_handler(event: Union[types.Message,
|
||||
reply_markup=reply_markup_val,
|
||||
disable_web_page_preview=True)
|
||||
await event.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("referral_action:"))
|
||||
async def referral_action_handler(callback: types.CallbackQuery, settings: Settings,
|
||||
i18n_data: dict, referral_service: ReferralService,
|
||||
bot: Bot, session: AsyncSession):
|
||||
action = callback.data.split(":")[1]
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n = i18n_data.get("i18n_instance")
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
if action == "share_message":
|
||||
try:
|
||||
bot_info = await bot.get_me()
|
||||
bot_username = bot_info.username
|
||||
if not bot_username:
|
||||
await callback.answer("Ошибка получения имени бота", show_alert=True)
|
||||
return
|
||||
|
||||
inviter_user_id = callback.from_user.id
|
||||
referral_link = referral_service.generate_referral_link(bot_username, inviter_user_id)
|
||||
|
||||
friend_message = _("referral_friend_message", referral_link=referral_link)
|
||||
|
||||
await callback.message.answer(
|
||||
friend_message,
|
||||
disable_web_page_preview=True
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error in referral share message: {e}")
|
||||
await callback.answer("Произошла ошибка", show_alert=True)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
+139
-40
@@ -1,5 +1,7 @@
|
||||
import logging
|
||||
import re
|
||||
from aiogram import Router, F, types, Bot
|
||||
from aiogram.utils.text_decorations import html_decoration as hd
|
||||
from aiogram.filters import CommandStart, Command
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from typing import Optional, Union
|
||||
@@ -15,6 +17,7 @@ from bot.services.referral_service import ReferralService
|
||||
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
|
||||
|
||||
router = Router(name="user_start_router")
|
||||
|
||||
@@ -30,7 +33,7 @@ async def send_main_menu(target_event: Union[types.Message,
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
|
||||
user_id = target_event.from_user.id
|
||||
user_full_name = target_event.from_user.full_name
|
||||
user_full_name = hd.quote(target_event.from_user.full_name)
|
||||
|
||||
if not i18n:
|
||||
logging.error(
|
||||
@@ -41,10 +44,9 @@ async def send_main_menu(target_event: Union[types.Message,
|
||||
await target_event.answer(err_msg_fallback, show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
elif isinstance(target_event, types.Message) and hasattr(
|
||||
target_event, 'chat') and target_event.chat:
|
||||
elif isinstance(target_event, types.Message):
|
||||
try:
|
||||
await target_event.chat.send_message(err_msg_fallback)
|
||||
await target_event.answer(err_msg_fallback)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
@@ -91,33 +93,42 @@ 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 target_event.answer()
|
||||
try:
|
||||
await target_event.answer()
|
||||
except Exception:
|
||||
pass
|
||||
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}."
|
||||
)
|
||||
if is_edit and target_message_obj and hasattr(
|
||||
target_message_obj, 'chat') and target_message_obj.chat:
|
||||
if is_edit and target_message_obj:
|
||||
try:
|
||||
await target_message_obj.chat.send_message(
|
||||
text, reply_markup=reply_markup)
|
||||
await target_message_obj.answer(text, reply_markup=reply_markup)
|
||||
except Exception as e_send_new:
|
||||
logging.error(
|
||||
f"Also failed to send new main menu message for user {user_id}: {e_send_new}"
|
||||
)
|
||||
if isinstance(target_event, types.CallbackQuery):
|
||||
await target_event.answer(
|
||||
_("error_occurred_try_again") if is_edit else None)
|
||||
try:
|
||||
await target_event.answer(
|
||||
_("error_occurred_try_again") if is_edit else None)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@router.message(CommandStart())
|
||||
@router.message(CommandStart(magic=F.args.regexp(r"^ref_(\d+)$").as_("ref_match")))
|
||||
@router.message(CommandStart(magic=F.args.regexp(r"^promo_(\w+)$").as_("promo_match")))
|
||||
@router.message(CommandStart(magic=F.args.regexp(r"^(?!ref_|promo_)([A-Za-z0-9_\-]{2,64})$").as_("ad_param_match")))
|
||||
async def start_command_handler(message: types.Message,
|
||||
state: FSMContext,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
subscription_service: SubscriptionService,
|
||||
session: AsyncSession,
|
||||
command: Optional[CommandStart] = None):
|
||||
ref_match: Optional[re.Match] = None,
|
||||
promo_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")
|
||||
@@ -128,37 +139,55 @@ async def start_command_handler(message: types.Message,
|
||||
user_id = user.id
|
||||
|
||||
referred_by_user_id: Optional[int] = None
|
||||
if command and command.args:
|
||||
arg_payload = command.args
|
||||
if arg_payload.startswith("ref_"):
|
||||
try:
|
||||
potential_referrer_id_str = arg_payload.split("_")[1]
|
||||
if potential_referrer_id_str.isdigit():
|
||||
potential_referrer_id = int(potential_referrer_id_str)
|
||||
if potential_referrer_id != user_id:
|
||||
referred_by_user_id = potential_referrer_id
|
||||
except (IndexError, ValueError) as e:
|
||||
logging.warning(
|
||||
f"Could not parse referral from /start args '{arg_payload}': {e}"
|
||||
)
|
||||
promo_code_to_apply: Optional[str] = None
|
||||
ad_start_param: Optional[str] = None
|
||||
|
||||
if ref_match:
|
||||
potential_referrer_id = int(ref_match.group(1))
|
||||
if await user_dal.get_user_by_id(session, potential_referrer_id):
|
||||
referred_by_user_id = potential_referrer_id
|
||||
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 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}")
|
||||
|
||||
sanitized_username = sanitize_username(user.username)
|
||||
sanitized_first_name = sanitize_display_name(user.first_name)
|
||||
sanitized_last_name = sanitize_display_name(user.last_name)
|
||||
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
if not db_user:
|
||||
user_data_to_create = {
|
||||
"user_id": user_id,
|
||||
"username": user.username,
|
||||
"first_name": user.first_name,
|
||||
"last_name": user.last_name,
|
||||
"username": sanitized_username,
|
||||
"first_name": sanitized_first_name,
|
||||
"last_name": sanitized_last_name,
|
||||
"language_code": current_lang,
|
||||
"referred_by_id": referred_by_user_id,
|
||||
"registration_date": datetime.now(timezone.utc)
|
||||
}
|
||||
try:
|
||||
db_user = await user_dal.create_user(session, user_data_to_create)
|
||||
db_user, created = await user_dal.create_user(session, user_data_to_create)
|
||||
|
||||
logging.info(
|
||||
f"New user {user_id} added to session. Referred by: {referred_by_user_id or 'N/A'}."
|
||||
)
|
||||
if created:
|
||||
logging.info(
|
||||
f"New user {user_id} added to session. Referred by: {referred_by_user_id or 'N/A'}."
|
||||
)
|
||||
|
||||
# Send notification about new user registration
|
||||
try:
|
||||
from bot.services.notification_service import NotificationService
|
||||
notification_service = NotificationService(message.bot, settings, i18n)
|
||||
await notification_service.notify_new_user_registration(
|
||||
user_id=user_id,
|
||||
username=sanitized_username,
|
||||
first_name=sanitized_first_name,
|
||||
referred_by_id=referred_by_user_id
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to send new user notification: {e}")
|
||||
except Exception as e_create:
|
||||
|
||||
logging.error(
|
||||
@@ -170,14 +199,21 @@ async def start_command_handler(message: types.Message,
|
||||
update_payload = {}
|
||||
if db_user.language_code != current_lang:
|
||||
update_payload["language_code"] = current_lang
|
||||
# Set referral only if not already set AND user is not currently active.
|
||||
# This allows previously subscribed but currently inactive users to be attributed.
|
||||
if referred_by_user_id and db_user.referred_by_id is None:
|
||||
update_payload["referred_by_id"] = referred_by_user_id
|
||||
if user.username != db_user.username:
|
||||
update_payload["username"] = user.username
|
||||
if user.first_name != db_user.first_name:
|
||||
update_payload["first_name"] = user.first_name
|
||||
if user.last_name != db_user.last_name:
|
||||
update_payload["last_name"] = user.last_name
|
||||
try:
|
||||
is_active_now = await subscription_service.has_active_subscription(session, user_id)
|
||||
except Exception:
|
||||
is_active_now = False
|
||||
if not is_active_now:
|
||||
update_payload["referred_by_id"] = referred_by_user_id
|
||||
if sanitized_username != db_user.username:
|
||||
update_payload["username"] = sanitized_username
|
||||
if sanitized_first_name != db_user.first_name:
|
||||
update_payload["first_name"] = sanitized_first_name
|
||||
if sanitized_last_name != db_user.last_name:
|
||||
update_payload["last_name"] = sanitized_last_name
|
||||
|
||||
if update_payload:
|
||||
try:
|
||||
@@ -192,7 +228,70 @@ async def start_command_handler(message: types.Message,
|
||||
f"Failed to update existing user {user_id} in session: {e_update}",
|
||||
exc_info=True)
|
||||
|
||||
await message.answer(_(key="welcome", user_name=user.full_name))
|
||||
# Attribute user to ad campaign if start param provided
|
||||
if ad_start_param:
|
||||
try:
|
||||
from db.dal import ad_dal as _ad_dal
|
||||
campaign = await _ad_dal.get_campaign_by_start_param(session, ad_start_param)
|
||||
if campaign and campaign.is_active:
|
||||
await _ad_dal.ensure_attribution(session, user_id=user_id, campaign_id=campaign.ad_campaign_id)
|
||||
await session.commit()
|
||||
except Exception as e_attr:
|
||||
logging.error(f"Failed to attribute user {user_id} to ad '{ad_start_param}': {e_attr}")
|
||||
try:
|
||||
await session.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Send welcome message if not disabled
|
||||
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(
|
||||
session, user_id, promo_code_to_apply, current_lang
|
||||
)
|
||||
|
||||
if success:
|
||||
await session.commit()
|
||||
logging.info(f"Auto-applied promo code '{promo_code_to_apply}' for user {user_id}")
|
||||
|
||||
# Get updated subscription details
|
||||
active = await subscription_service.get_active_subscription_details(session, user_id)
|
||||
config_link = active.get("config_link") if active else None
|
||||
config_link = config_link or _("config_link_not_available")
|
||||
|
||||
new_end_date = result if isinstance(result, datetime) else None
|
||||
|
||||
promo_success_text = _(
|
||||
"promo_code_applied_success_full",
|
||||
end_date=(new_end_date.strftime("%d.%m.%Y %H:%M:%S") if new_end_date else "N/A"),
|
||||
config_link=config_link,
|
||||
)
|
||||
|
||||
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
|
||||
await message.answer(
|
||||
promo_success_text,
|
||||
reply_markup=get_connect_and_main_keyboard(current_lang, i18n, settings, config_link),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
# Don't show main menu if promo was successfully applied
|
||||
return
|
||||
else:
|
||||
await session.rollback()
|
||||
logging.warning(f"Failed to auto-apply promo code '{promo_code_to_apply}' for user {user_id}: {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()
|
||||
|
||||
await send_main_menu(message,
|
||||
settings,
|
||||
i18n_data,
|
||||
|
||||
@@ -1,425 +0,0 @@
|
||||
import logging
|
||||
from aiogram import Router, F, types, Bot
|
||||
from aiogram.filters import Command
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, LabeledPrice
|
||||
from typing import Optional, Dict, Any, Union
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config.settings import Settings
|
||||
from db.dal import payment_dal
|
||||
from bot.keyboards.inline.user_keyboards import (
|
||||
get_subscription_options_keyboard, get_payment_method_keyboard,
|
||||
get_payment_url_keyboard, get_back_to_main_menu_markup)
|
||||
from bot.services.yookassa_service import YooKassaService
|
||||
from bot.services.stars_service import StarsService
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
from bot.services.referral_service import ReferralService
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
|
||||
router = Router(name="user_subscription_router")
|
||||
|
||||
|
||||
async def display_subscription_options(event: Union[types.Message,
|
||||
types.CallbackQuery],
|
||||
i18n_data: dict, settings: Settings,
|
||||
session: AsyncSession):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
|
||||
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs
|
||||
) if i18n else key
|
||||
|
||||
if not i18n:
|
||||
err_msg = "Language service error."
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
await event.answer(err_msg, show_alert=True)
|
||||
elif isinstance(event, types.Message):
|
||||
await event.answer(err_msg)
|
||||
return
|
||||
|
||||
currency_symbol_val = settings.DEFAULT_CURRENCY_SYMBOL
|
||||
text_content = get_text("select_subscription_period"
|
||||
) if settings.subscription_options else get_text(
|
||||
"no_subscription_options_available")
|
||||
|
||||
reply_markup = get_subscription_options_keyboard(
|
||||
settings.subscription_options, currency_symbol_val, current_lang, i18n
|
||||
) if settings.subscription_options else get_back_to_main_menu_markup(
|
||||
current_lang, i18n)
|
||||
|
||||
target_message_obj = event.message if isinstance(
|
||||
event, types.CallbackQuery) else event
|
||||
if not target_message_obj:
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
await event.answer(get_text("error_occurred_try_again"),
|
||||
show_alert=True)
|
||||
return
|
||||
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
try:
|
||||
await target_message_obj.edit_text(text_content,
|
||||
reply_markup=reply_markup)
|
||||
except Exception:
|
||||
await target_message_obj.answer(text_content,
|
||||
reply_markup=reply_markup)
|
||||
await event.answer()
|
||||
else:
|
||||
await target_message_obj.answer(text_content,
|
||||
reply_markup=reply_markup)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("subscribe_period:"))
|
||||
async def select_subscription_period_callback_handler(
|
||||
callback: types.CallbackQuery, settings: Settings, i18n_data: dict,
|
||||
session: AsyncSession):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs
|
||||
) if i18n else key
|
||||
|
||||
if not i18n or not callback.message:
|
||||
await callback.answer(get_text("error_occurred_try_again"),
|
||||
show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
months = int(callback.data.split(":")[-1])
|
||||
except (ValueError, IndexError):
|
||||
logging.error(
|
||||
f"Invalid subscription period in callback_data: {callback.data}")
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
return
|
||||
|
||||
price_rub = settings.subscription_options.get(months)
|
||||
if price_rub is None:
|
||||
logging.error(
|
||||
f"Price not found for {months} months subscription period in settings.subscription_options."
|
||||
)
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
return
|
||||
|
||||
currency_symbol_val = settings.DEFAULT_CURRENCY_SYMBOL
|
||||
text_content = get_text("choose_payment_method")
|
||||
tribute_url = settings.tribute_payment_links.get(months)
|
||||
stars_price = settings.stars_subscription_options.get(months)
|
||||
reply_markup = get_payment_method_keyboard(
|
||||
months,
|
||||
price_rub,
|
||||
tribute_url,
|
||||
stars_price,
|
||||
currency_symbol_val,
|
||||
current_lang,
|
||||
i18n,
|
||||
settings,
|
||||
)
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(text_content,
|
||||
reply_markup=reply_markup)
|
||||
except Exception as e_edit:
|
||||
logging.warning(
|
||||
f"Edit message for payment method selection failed: {e_edit}. Sending new one."
|
||||
)
|
||||
await callback.message.answer(text_content,
|
||||
reply_markup=reply_markup)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("pay_stars:"))
|
||||
async def pay_stars_callback_handler(
|
||||
callback: types.CallbackQuery, settings: Settings, i18n_data: dict,
|
||||
session: AsyncSession, bot: Bot, stars_service: StarsService):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
|
||||
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
if not i18n or not callback.message:
|
||||
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
_, data_payload = callback.data.split(":", 1)
|
||||
months_str, price_str = data_payload.split(":")
|
||||
months = int(months_str)
|
||||
stars_price = int(price_str)
|
||||
except (ValueError, IndexError):
|
||||
logging.error(f"Invalid pay_stars data in callback: {callback.data}")
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
return
|
||||
|
||||
user_id = callback.from_user.id
|
||||
payment_description = get_text("payment_description_subscription", months=months)
|
||||
|
||||
payment_id = await stars_service.create_invoice(
|
||||
session, user_id, months, stars_price, payment_description)
|
||||
if payment_id is None:
|
||||
await callback.message.edit_text(get_text("error_payment_gateway"))
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
return
|
||||
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("pay_yk:"))
|
||||
async def pay_yk_callback_handler(
|
||||
callback: types.CallbackQuery, settings: Settings, i18n_data: dict,
|
||||
yookassa_service: YooKassaService, session: AsyncSession):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
|
||||
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs
|
||||
) if i18n else key
|
||||
|
||||
if not i18n or not callback.message:
|
||||
|
||||
await callback.answer(get_text("error_occurred_try_again"),
|
||||
show_alert=True)
|
||||
return
|
||||
|
||||
if not yookassa_service or not yookassa_service.configured:
|
||||
logging.error("YooKassa service is not configured or unavailable.")
|
||||
target_msg_edit = callback.message
|
||||
await target_msg_edit.edit_text(get_text("payment_service_unavailable")
|
||||
)
|
||||
await callback.answer(get_text("payment_service_unavailable_alert"),
|
||||
show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
_, data_payload = callback.data.split(":", 1)
|
||||
months_str, price_str = data_payload.split(":")
|
||||
months = int(months_str)
|
||||
price_rub = float(price_str)
|
||||
except (ValueError, IndexError):
|
||||
logging.error(
|
||||
f"Invalid pay_yk data in callback: {callback.data}")
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
return
|
||||
|
||||
user_id = callback.from_user.id
|
||||
|
||||
payment_description = get_text("payment_description_subscription",
|
||||
months=months)
|
||||
currency_code_for_yk = "RUB"
|
||||
|
||||
payment_record_data = {
|
||||
"user_id": user_id,
|
||||
"amount": price_rub,
|
||||
"currency": currency_code_for_yk,
|
||||
"status": "pending_yookassa",
|
||||
"description": payment_description,
|
||||
"subscription_duration_months": months,
|
||||
}
|
||||
db_payment_record = None
|
||||
try:
|
||||
db_payment_record = await payment_dal.create_payment_record(
|
||||
session, payment_record_data)
|
||||
await session.commit()
|
||||
logging.info(
|
||||
f"Payment record {db_payment_record.payment_id} created for user {user_id} with status 'pending_yookassa'."
|
||||
)
|
||||
except Exception as e_db_payment:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"Failed to create payment record in DB for user {user_id}: {e_db_payment}",
|
||||
exc_info=True)
|
||||
await callback.message.edit_text(
|
||||
get_text("error_creating_payment_record"))
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
return
|
||||
|
||||
if not db_payment_record:
|
||||
await callback.message.edit_text(
|
||||
get_text("error_creating_payment_record"))
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
return
|
||||
|
||||
yookassa_metadata = {
|
||||
"user_id": str(user_id),
|
||||
"subscription_months": str(months),
|
||||
"payment_db_id": str(db_payment_record.payment_id),
|
||||
}
|
||||
receipt_email_for_yk = settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL
|
||||
|
||||
payment_response_yk = await yookassa_service.create_payment(
|
||||
amount=price_rub,
|
||||
currency=currency_code_for_yk,
|
||||
description=payment_description,
|
||||
metadata=yookassa_metadata,
|
||||
receipt_email=receipt_email_for_yk)
|
||||
|
||||
if payment_response_yk and payment_response_yk.get("confirmation_url"):
|
||||
try:
|
||||
await payment_dal.update_payment_status_by_db_id(
|
||||
session,
|
||||
payment_db_id=db_payment_record.payment_id,
|
||||
new_status=payment_response_yk.get("status", "pending"),
|
||||
yk_payment_id=payment_response_yk.get("id"))
|
||||
await session.commit()
|
||||
except Exception as e_db_update_ykid:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"Failed to update payment record {db_payment_record.payment_id} with YK ID: {e_db_update_ykid}",
|
||||
exc_info=True)
|
||||
await callback.message.edit_text(
|
||||
get_text("error_payment_gateway_link_failed"))
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
return
|
||||
|
||||
await callback.message.edit_text(
|
||||
get_text(key="payment_link_message", months=months),
|
||||
reply_markup=get_payment_url_keyboard(
|
||||
payment_response_yk["confirmation_url"], current_lang, i18n),
|
||||
disable_web_page_preview=False)
|
||||
else:
|
||||
try:
|
||||
await payment_dal.update_payment_status_by_db_id(
|
||||
session, db_payment_record.payment_id, "failed_creation")
|
||||
await session.commit()
|
||||
except Exception as e_db_fail_create:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"Additionally failed to update payment record to 'failed_creation': {e_db_fail_create}",
|
||||
exc_info=True)
|
||||
|
||||
logging.error(
|
||||
f"Failed to create payment in YooKassa for user {user_id}, payment_db_id {db_payment_record.payment_id}. Response: {payment_response_yk}"
|
||||
)
|
||||
await callback.message.edit_text(get_text("error_payment_gateway"))
|
||||
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@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 my_subscription_command_handler(
|
||||
event: Union[types.Message, types.CallbackQuery],
|
||||
i18n_data: dict,
|
||||
settings: Settings,
|
||||
panel_service: PanelApiService,
|
||||
subscription_service: SubscriptionService,
|
||||
session: AsyncSession,
|
||||
bot: Bot
|
||||
):
|
||||
target = event.message if isinstance(event, types.CallbackQuery) else event
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: JsonI18n = i18n_data.get("i18n_instance")
|
||||
get_text = lambda key, **kw: i18n.gettext(current_lang, key, **kw)
|
||||
|
||||
if not i18n or not target:
|
||||
if isinstance(event, types.Message):
|
||||
await event.answer(get_text("error_occurred_try_again"))
|
||||
return
|
||||
|
||||
if not panel_service or not subscription_service:
|
||||
await target.answer(get_text("error_service_unavailable"))
|
||||
return
|
||||
|
||||
active = await subscription_service.get_active_subscription_details(session, event.from_user.id)
|
||||
|
||||
if not active:
|
||||
text = get_text("subscription_not_active")
|
||||
|
||||
buy_button = InlineKeyboardButton(
|
||||
text=get_text("menu_subscribe_inline", default="Купить"),
|
||||
callback_data="main_action:subscribe"
|
||||
)
|
||||
back_markup = get_back_to_main_menu_markup(current_lang, i18n)
|
||||
|
||||
kb = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[buy_button],
|
||||
*back_markup.inline_keyboard
|
||||
]
|
||||
)
|
||||
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
await event.answer()
|
||||
try:
|
||||
await event.message.edit_text(text, reply_markup=kb)
|
||||
except:
|
||||
await event.message.answer(text, reply_markup=kb)
|
||||
else:
|
||||
await event.answer(text, reply_markup=kb)
|
||||
return
|
||||
|
||||
end_date = active.get("end_date")
|
||||
days_left = (
|
||||
(end_date.date() - datetime.now().date()).days
|
||||
if end_date else 0
|
||||
)
|
||||
text = get_text(
|
||||
"my_subscription_details",
|
||||
end_date=end_date.strftime("%Y-%m-%d") if end_date else "N/A",
|
||||
days_left=max(0, days_left),
|
||||
status=active.get("status_from_panel", get_text("status_active")).capitalize(),
|
||||
config_link=active.get("config_link") or get_text("config_link_not_available"),
|
||||
traffic_limit=(
|
||||
f"{active['traffic_limit_bytes'] / 2**30:.2f} GB"
|
||||
if active.get("traffic_limit_bytes")
|
||||
else get_text("traffic_unlimited")
|
||||
),
|
||||
traffic_used=(
|
||||
f"{active['traffic_used_bytes'] / 2**30:.2f} GB"
|
||||
if active.get("traffic_used_bytes") is not None
|
||||
else get_text("traffic_na")
|
||||
)
|
||||
)
|
||||
markup = get_back_to_main_menu_markup(current_lang, i18n)
|
||||
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
await event.answer()
|
||||
try:
|
||||
await event.message.edit_text(text, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True)
|
||||
except:
|
||||
await bot.send_message(chat_id=target.chat.id, text=text, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True)
|
||||
else:
|
||||
await target.answer(text, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True)
|
||||
|
||||
|
||||
@router.pre_checkout_query()
|
||||
async def stars_pre_checkout_handler(pre_checkout_query: types.PreCheckoutQuery):
|
||||
await pre_checkout_query.answer(ok=True)
|
||||
|
||||
|
||||
@router.message(F.successful_payment)
|
||||
async def stars_successful_payment_handler(
|
||||
message: types.Message, settings: Settings, i18n_data: dict,
|
||||
session: AsyncSession, stars_service: StarsService):
|
||||
sp = message.successful_payment
|
||||
if not sp or sp.currency != "XTR":
|
||||
return
|
||||
|
||||
payload = sp.invoice_payload or ""
|
||||
try:
|
||||
payment_id_str, months_str = payload.split(":")
|
||||
payment_db_id = int(payment_id_str)
|
||||
months = int(months_str)
|
||||
except (ValueError, IndexError):
|
||||
logging.error(f"Invalid invoice payload for stars payment: {payload}")
|
||||
return
|
||||
|
||||
stars_amount = sp.total_amount
|
||||
await stars_service.process_successful_payment(
|
||||
session, message, payment_db_id, months, stars_amount, i18n_data)
|
||||
|
||||
|
||||
@router.message(Command("connect"))
|
||||
async def connect_command_handler(message: types.Message, i18n_data: dict,
|
||||
settings: Settings,
|
||||
panel_service: PanelApiService,
|
||||
subscription_service: SubscriptionService,
|
||||
session: AsyncSession, bot: Bot):
|
||||
logging.info(f"User {message.from_user.id} used /connect command.")
|
||||
await my_subscription_command_handler(message, i18n_data, settings,
|
||||
panel_service, subscription_service,
|
||||
session, bot)
|
||||
@@ -0,0 +1,17 @@
|
||||
from aiogram import Router
|
||||
|
||||
from . import core
|
||||
from . import payments
|
||||
from . import payment_methods
|
||||
|
||||
router = Router(name="user_subscription_router")
|
||||
|
||||
# Include sub-routers
|
||||
router.include_router(core.router)
|
||||
router.include_router(payments.router)
|
||||
router.include_router(payment_methods.router)
|
||||
|
||||
# Re-export commonly used entrypoints for backward compatibility
|
||||
from .core import display_subscription_options, my_subscription_command_handler # noqa: E402,F401
|
||||
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
import logging
|
||||
from aiogram import Router, F, types, Bot
|
||||
from aiogram.filters import Command
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, WebAppInfo
|
||||
from typing import Optional, Union
|
||||
from datetime import datetime
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.future import select
|
||||
|
||||
from config.settings import Settings
|
||||
from bot.keyboards.inline.user_keyboards import (
|
||||
get_subscription_options_keyboard,
|
||||
get_back_to_main_menu_markup,
|
||||
get_autorenew_confirm_keyboard,
|
||||
)
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from db.dal import subscription_dal
|
||||
from db.models import Subscription
|
||||
|
||||
router = Router(name="user_subscription_core_router")
|
||||
|
||||
|
||||
async def display_subscription_options(event: Union[types.Message, types.CallbackQuery], i18n_data: dict, settings: Settings, session: AsyncSession):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
|
||||
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
if not i18n:
|
||||
err_msg = "Language service error."
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
try:
|
||||
await event.answer(err_msg, show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
elif isinstance(event, types.Message):
|
||||
await event.answer(err_msg)
|
||||
return
|
||||
|
||||
currency_symbol_val = settings.DEFAULT_CURRENCY_SYMBOL
|
||||
text_content = get_text("select_subscription_period") if settings.subscription_options else get_text("no_subscription_options_available")
|
||||
|
||||
reply_markup = (
|
||||
get_subscription_options_keyboard(settings.subscription_options, currency_symbol_val, current_lang, i18n)
|
||||
if settings.subscription_options
|
||||
else get_back_to_main_menu_markup(current_lang, i18n)
|
||||
)
|
||||
|
||||
target_message_obj = event.message if isinstance(event, types.CallbackQuery) else event
|
||||
if not target_message_obj:
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
try:
|
||||
await event.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
try:
|
||||
await target_message_obj.edit_text(text_content, reply_markup=reply_markup)
|
||||
except Exception:
|
||||
await target_message_obj.answer(text_content, reply_markup=reply_markup)
|
||||
try:
|
||||
await event.answer()
|
||||
except Exception:
|
||||
pass
|
||||
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 my_subscription_command_handler(
|
||||
event: Union[types.Message, types.CallbackQuery],
|
||||
i18n_data: dict,
|
||||
settings: Settings,
|
||||
panel_service: PanelApiService,
|
||||
subscription_service: SubscriptionService,
|
||||
session: AsyncSession,
|
||||
bot: Bot,
|
||||
):
|
||||
target = event.message if isinstance(event, types.CallbackQuery) else event
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: JsonI18n = i18n_data.get("i18n_instance")
|
||||
get_text = lambda key, **kw: i18n.gettext(current_lang, key, **kw)
|
||||
|
||||
if not i18n or not target:
|
||||
if isinstance(event, types.Message):
|
||||
await event.answer(get_text("error_occurred_try_again"))
|
||||
return
|
||||
|
||||
if not panel_service or not subscription_service:
|
||||
await target.answer(get_text("error_service_unavailable"))
|
||||
return
|
||||
|
||||
active = await subscription_service.get_active_subscription_details(session, event.from_user.id)
|
||||
|
||||
if not active:
|
||||
text = get_text("subscription_not_active")
|
||||
|
||||
buy_button = InlineKeyboardButton(
|
||||
text=get_text("menu_subscribe_inline", default="Купить"), callback_data="main_action:subscribe"
|
||||
)
|
||||
back_markup = get_back_to_main_menu_markup(current_lang, i18n)
|
||||
|
||||
kb = InlineKeyboardMarkup(inline_keyboard=[[buy_button], *back_markup.inline_keyboard])
|
||||
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
try:
|
||||
await event.answer()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await event.message.edit_text(text, reply_markup=kb)
|
||||
except Exception:
|
||||
await event.message.answer(text, reply_markup=kb)
|
||||
else:
|
||||
await event.answer(text, reply_markup=kb)
|
||||
return
|
||||
|
||||
end_date = active.get("end_date")
|
||||
days_left = (end_date.date() - datetime.now().date()).days if end_date else 0
|
||||
tribute_hint = ""
|
||||
if active.get("status_from_panel", "").lower() == "active":
|
||||
local_sub = await subscription_dal.get_active_subscription_by_user_id(session, event.from_user.id)
|
||||
if local_sub:
|
||||
if local_sub.provider == "tribute":
|
||||
link = None
|
||||
link = settings.tribute_payment_links.get(local_sub.duration_months or 1) if hasattr(settings, "tribute_payment_links") else None
|
||||
tribute_hint = "\n\n" + (
|
||||
get_text("subscription_tribute_notice_with_link", link=link) if link else get_text("subscription_tribute_notice")
|
||||
)
|
||||
|
||||
text = get_text(
|
||||
"my_subscription_details",
|
||||
end_date=end_date.strftime("%Y-%m-%d") if end_date else "N/A",
|
||||
days_left=max(0, days_left),
|
||||
status=active.get("status_from_panel", get_text("status_active")).capitalize(),
|
||||
config_link=active.get("config_link") or get_text("config_link_not_available"),
|
||||
traffic_limit=(f"{active['traffic_limit_bytes'] / 2**30:.2f} GB" if active.get("traffic_limit_bytes") else get_text("traffic_unlimited")),
|
||||
traffic_used=(
|
||||
f"{active['traffic_used_bytes'] / 2**30:.2f} GB" if active.get("traffic_used_bytes") is not None else get_text("traffic_na")
|
||||
),
|
||||
)
|
||||
|
||||
base_markup = get_back_to_main_menu_markup(current_lang, i18n)
|
||||
kb = base_markup.inline_keyboard
|
||||
try:
|
||||
local_sub = await subscription_dal.get_active_subscription_by_user_id(session, event.from_user.id)
|
||||
# Build rows to prepend above the base "back" markup
|
||||
prepend_rows = []
|
||||
|
||||
# 1) Mini-app connect button on top if enabled, otherwise fall back to config link URL
|
||||
if settings.SUBSCRIPTION_MINI_APP_URL:
|
||||
prepend_rows.append([
|
||||
InlineKeyboardButton(
|
||||
text=get_text("connect_button"),
|
||||
web_app=WebAppInfo(url=settings.SUBSCRIPTION_MINI_APP_URL),
|
||||
)
|
||||
])
|
||||
else:
|
||||
cfg_link_val = (active or {}).get("config_link")
|
||||
if cfg_link_val:
|
||||
prepend_rows.append([
|
||||
InlineKeyboardButton(
|
||||
text=get_text("connect_button"),
|
||||
url=cfg_link_val,
|
||||
)
|
||||
])
|
||||
|
||||
# 2) Auto-renew toggle (if supported and not tribute)
|
||||
if local_sub and local_sub.provider != "tribute" and getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
|
||||
toggle_text = (
|
||||
get_text("autorenew_disable_button") if local_sub.auto_renew_enabled else get_text("autorenew_enable_button")
|
||||
)
|
||||
prepend_rows.append([
|
||||
InlineKeyboardButton(
|
||||
text=toggle_text,
|
||||
callback_data=f"toggle_autorenew:{local_sub.subscription_id}:{1 if not local_sub.auto_renew_enabled else 0}",
|
||||
)
|
||||
])
|
||||
|
||||
# 3) Payment methods management (when autopayments enabled)
|
||||
if getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
|
||||
prepend_rows.append([
|
||||
InlineKeyboardButton(text=get_text("payment_methods_manage_button"), callback_data="pm:manage")
|
||||
])
|
||||
|
||||
if prepend_rows:
|
||||
kb = prepend_rows + kb
|
||||
except Exception:
|
||||
pass
|
||||
markup = InlineKeyboardMarkup(inline_keyboard=kb)
|
||||
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
try:
|
||||
await event.answer()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await event.message.edit_text(text + tribute_hint, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True)
|
||||
except Exception:
|
||||
await bot.send_message(
|
||||
chat_id=target.chat.id,
|
||||
text=text + tribute_hint,
|
||||
reply_markup=markup,
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
else:
|
||||
await target.answer(text + tribute_hint, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("toggle_autorenew:"))
|
||||
async def toggle_autorenew_handler(
|
||||
callback: types.CallbackQuery,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
session: AsyncSession,
|
||||
subscription_service: SubscriptionService,
|
||||
panel_service: PanelApiService,
|
||||
bot: Bot,
|
||||
):
|
||||
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
|
||||
|
||||
try:
|
||||
_, payload = callback.data.split(":", 1)
|
||||
sub_id_str, enable_str = payload.split(":")
|
||||
sub_id = int(sub_id_str)
|
||||
enable = bool(int(enable_str))
|
||||
except Exception:
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
sub = await session.get(Subscription, sub_id)
|
||||
if not sub or sub.user_id != callback.from_user.id:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
return
|
||||
if sub.provider == "tribute":
|
||||
await callback.answer(get_text("subscription_autorenew_not_supported_for_tribute"), show_alert=True)
|
||||
return
|
||||
|
||||
# Show confirmation popup and inline buttons
|
||||
confirm_text = get_text("autorenew_confirm_enable") if enable else get_text("autorenew_confirm_disable")
|
||||
kb = get_autorenew_confirm_keyboard(enable, sub.subscription_id, current_lang, i18n)
|
||||
try:
|
||||
await callback.message.edit_text(confirm_text, reply_markup=kb)
|
||||
except Exception:
|
||||
try:
|
||||
await callback.message.answer(confirm_text, reply_markup=kb)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("autorenew:confirm:"))
|
||||
async def confirm_autorenew_handler(
|
||||
callback: types.CallbackQuery,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
session: AsyncSession,
|
||||
subscription_service: SubscriptionService,
|
||||
panel_service: PanelApiService,
|
||||
bot: Bot,
|
||||
):
|
||||
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
|
||||
|
||||
try:
|
||||
_, _, sub_id_str, enable_str = callback.data.split(":", 3)
|
||||
sub_id = int(sub_id_str)
|
||||
enable = bool(int(enable_str))
|
||||
except Exception:
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
sub = await session.get(Subscription, sub_id)
|
||||
if not sub or sub.user_id != callback.from_user.id:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
return
|
||||
if sub.provider == "tribute":
|
||||
await callback.answer(get_text("subscription_autorenew_not_supported_for_tribute"), show_alert=True)
|
||||
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
|
||||
await my_subscription_command_handler(callback, i18n_data, settings, panel_service, subscription_service, session, bot)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "autorenew:cancel")
|
||||
async def autorenew_cancel_from_webhook_button(
|
||||
callback: types.CallbackQuery,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
session: AsyncSession,
|
||||
subscription_service: SubscriptionService,
|
||||
panel_service: PanelApiService,
|
||||
bot: Bot,
|
||||
):
|
||||
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
|
||||
|
||||
# Disable auto-renew on the active subscription (non-tribute)
|
||||
from db.dal import subscription_dal
|
||||
sub = await subscription_dal.get_active_subscription_by_user_id(session, callback.from_user.id)
|
||||
if not sub:
|
||||
try:
|
||||
await callback.answer(get_text("subscription_not_active"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
if sub.provider == "tribute":
|
||||
try:
|
||||
await callback.answer(get_text("subscription_autorenew_not_supported_for_tribute"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
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
|
||||
await my_subscription_command_handler(callback, i18n_data, settings, panel_service, subscription_service, session, bot)
|
||||
|
||||
|
||||
@router.message(Command("connect"))
|
||||
async def connect_command_handler(
|
||||
message: types.Message,
|
||||
i18n_data: dict,
|
||||
settings: Settings,
|
||||
panel_service: PanelApiService,
|
||||
subscription_service: SubscriptionService,
|
||||
session: AsyncSession,
|
||||
bot: Bot,
|
||||
):
|
||||
logging.info(f"User {message.from_user.id} used /connect command.")
|
||||
await my_subscription_command_handler(message, i18n_data, settings, panel_service, subscription_service, session, bot)
|
||||
|
||||
|
||||
@@ -0,0 +1,458 @@
|
||||
from aiogram import Router, F, types
|
||||
from typing import Optional, List
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config.settings import Settings
|
||||
from bot.keyboards.inline.user_keyboards import (
|
||||
get_payment_methods_list_keyboard,
|
||||
get_payment_method_delete_confirm_keyboard,
|
||||
get_payment_method_details_keyboard,
|
||||
get_bind_url_keyboard,
|
||||
)
|
||||
from bot.services.yookassa_service import YooKassaService
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from db.dal import user_billing_dal
|
||||
from db.models import Payment
|
||||
from sqlalchemy.future import select
|
||||
|
||||
router = Router(name="user_subscription_payment_methods_router")
|
||||
|
||||
|
||||
@router.callback_query(F.data == "pm:manage")
|
||||
async def payment_methods_manage(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
|
||||
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
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
from db.dal.user_billing_dal import list_user_payment_methods
|
||||
get_text = _
|
||||
methods = await list_user_payment_methods(session, callback.from_user.id)
|
||||
cards: List[tuple] = []
|
||||
|
||||
def _is_yoomoney_network(network: Optional[str]) -> bool:
|
||||
s = (network or "").lower()
|
||||
return "yoomoney" in s or "yoo money" in s or "yoo-money" in s
|
||||
|
||||
def _extract_last4(text: str) -> Optional[str]:
|
||||
digits = "".join(ch for ch in text if ch.isdigit())
|
||||
return digits[-4:] if len(digits) >= 4 else None
|
||||
|
||||
def _format_pm_title(network: Optional[str], last4: Optional[str]) -> str:
|
||||
if _is_yoomoney_network(network):
|
||||
l4 = last4 or _extract_last4(network or "")
|
||||
if l4:
|
||||
return get_text("payment_method_wallet_title", last4=l4)
|
||||
return get_text("payment_method_wallet_title", last4="****")
|
||||
if last4:
|
||||
network_name = network or get_text("payment_network_card", default="Card")
|
||||
return get_text("payment_method_card_title", network=network_name, last4=last4)
|
||||
network_name = network or get_text("payment_network_generic", default="Payment method")
|
||||
return get_text("payment_method_generic_title", network=network_name)
|
||||
|
||||
for m in methods:
|
||||
title = _format_pm_title(m.card_network, m.card_last4)
|
||||
cards.append((str(m.method_id), title if not m.is_default else f"⭐ {title}"))
|
||||
|
||||
text = get_text("payment_methods_title")
|
||||
if not cards:
|
||||
text += "\n\n" + get_text("payment_method_none")
|
||||
|
||||
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
|
||||
|
||||
|
||||
@router.callback_query(F.data == "pm:bind")
|
||||
async def payment_method_bind(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession, yookassa_service: YooKassaService):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
|
||||
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
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
metadata = {"user_id": str(callback.from_user.id), "bind_only": "1"}
|
||||
resp = await yookassa_service.create_payment(
|
||||
amount=1.00,
|
||||
currency="RUB",
|
||||
description="Bind card",
|
||||
metadata=metadata,
|
||||
receipt_email=settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL,
|
||||
save_payment_method=True,
|
||||
capture=False,
|
||||
bind_only=True,
|
||||
)
|
||||
if not resp or not resp.get("confirmation_url"):
|
||||
await callback.answer(_("error_payment_gateway"), show_alert=True)
|
||||
return
|
||||
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
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("pm:delete_confirm"))
|
||||
async def payment_method_delete_confirm(callback: types.CallbackQuery, settings: Settings, i18n_data: dict):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
|
||||
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
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
parts = callback.data.split(":", 2)
|
||||
pm_id = parts[2] if len(parts) >= 3 else ""
|
||||
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
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("pm:delete"))
|
||||
async def payment_method_delete(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
|
||||
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
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
parts = callback.data.split(":", 2)
|
||||
pm_id_raw = parts[2] if len(parts) >= 3 else ""
|
||||
deleted = False
|
||||
|
||||
try:
|
||||
from db.dal.user_billing_dal import (
|
||||
delete_user_payment_method,
|
||||
delete_user_payment_method_by_provider_id,
|
||||
list_user_payment_methods,
|
||||
)
|
||||
if pm_id_raw:
|
||||
if pm_id_raw.isdigit():
|
||||
deleted = await delete_user_payment_method(session, callback.from_user.id, int(pm_id_raw))
|
||||
else:
|
||||
deleted = await delete_user_payment_method_by_provider_id(session, callback.from_user.id, pm_id_raw)
|
||||
try:
|
||||
legacy_deleted = await user_billing_dal.delete_yk_payment_method(session, callback.from_user.id)
|
||||
deleted = deleted or legacy_deleted
|
||||
except Exception:
|
||||
pass
|
||||
await session.commit()
|
||||
|
||||
methods = await list_user_payment_methods(session, callback.from_user.id)
|
||||
text = _("payment_methods_title")
|
||||
cards = []
|
||||
for m in methods:
|
||||
def _is_yoomoney_network(network: Optional[str]) -> bool:
|
||||
s = (network or "").lower()
|
||||
return "yoomoney" in s or "yoo money" in s or "yoo-money" in s
|
||||
def _extract_last4(text: str) -> Optional[str]:
|
||||
digits = "".join(ch for ch in text if ch.isdigit())
|
||||
return digits[-4:] if len(digits) >= 4 else None
|
||||
def _format_pm_title(network: Optional[str], last4: Optional[str]) -> str:
|
||||
if _is_yoomoney_network(network):
|
||||
l4 = last4 or _extract_last4(network or "")
|
||||
if l4:
|
||||
return _("payment_method_wallet_title", last4=l4)
|
||||
return _("payment_method_wallet_title", last4="****")
|
||||
if last4:
|
||||
network_name = network or _("payment_network_card", default="Card")
|
||||
return _("payment_method_card_title", network=network_name, last4=last4)
|
||||
network_name = network or _("payment_network_generic", default="Payment method")
|
||||
return _("payment_method_generic_title", network=network_name)
|
||||
title = _format_pm_title(m.card_network, m.card_last4)
|
||||
cards.append((str(m.method_id), title if not m.is_default else f"⭐ {title}"))
|
||||
if not cards:
|
||||
text += "\n\n" + _("payment_method_none")
|
||||
msg = _("payment_method_deleted_success") if deleted else _("error_try_again")
|
||||
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
|
||||
return
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
try:
|
||||
await callback.answer(_("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("pm:view"))
|
||||
async def payment_method_view(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
|
||||
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
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
billing = await user_billing_dal.get_user_billing(session, callback.from_user.id)
|
||||
if not billing or not billing.yookassa_payment_method_id:
|
||||
from db.dal.user_billing_dal import list_user_payment_methods
|
||||
methods = await list_user_payment_methods(session, callback.from_user.id)
|
||||
if not methods:
|
||||
await callback.answer(_("payment_method_none"), show_alert=True)
|
||||
return
|
||||
parts = callback.data.split(":", 2)
|
||||
pm_id = parts[2] if len(parts) >= 3 else str(methods[0].method_id)
|
||||
sel = next((m for m in methods if str(m.method_id) == pm_id or m.provider_payment_method_id == pm_id), methods[0])
|
||||
|
||||
def _is_yoomoney_network(network: Optional[str]) -> bool:
|
||||
s = (network or "").lower()
|
||||
return "yoomoney" in s or "yoo money" in s or "yoo-money" in s
|
||||
|
||||
def _extract_last4(text: str) -> Optional[str]:
|
||||
digits = "".join(ch for ch in text if ch.isdigit())
|
||||
return digits[-4:] if len(digits) >= 4 else None
|
||||
|
||||
def _format_pm_title(network: Optional[str], last4: Optional[str]) -> str:
|
||||
if _is_yoomoney_network(network):
|
||||
l4 = last4 or _extract_last4(network or "")
|
||||
if l4:
|
||||
return _("payment_method_wallet_title", last4=l4)
|
||||
return _("payment_method_wallet_title", last4="****")
|
||||
if last4:
|
||||
network_name = network or _("payment_network_card", default="Card")
|
||||
return _("payment_method_card_title", network=network_name, last4=last4)
|
||||
network_name = network or _("payment_network_generic", default="Payment method")
|
||||
return _("payment_method_generic_title", network=network_name)
|
||||
|
||||
title = _format_pm_title(sel.card_network, sel.card_last4)
|
||||
added_at = sel.created_at.strftime('%Y-%m-%d') if getattr(sel, 'created_at', None) else "—"
|
||||
last_tx = "—"
|
||||
try:
|
||||
stmt = (
|
||||
select(Payment)
|
||||
.where(
|
||||
Payment.user_id == callback.from_user.id,
|
||||
Payment.status == 'succeeded',
|
||||
Payment.provider == 'yookassa',
|
||||
)
|
||||
.order_by(Payment.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
lp = result.scalar_one_or_none()
|
||||
if lp and lp.created_at:
|
||||
last_tx = lp.created_at.strftime('%Y-%m-%d')
|
||||
except Exception:
|
||||
pass
|
||||
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
|
||||
return
|
||||
|
||||
added_at = billing.created_at.strftime('%Y-%m-%d') if getattr(billing, 'created_at', None) else "—"
|
||||
last_tx = "—"
|
||||
try:
|
||||
stmt = (
|
||||
select(Payment)
|
||||
.where(
|
||||
Payment.user_id == callback.from_user.id,
|
||||
Payment.status == 'succeeded',
|
||||
Payment.provider == 'yookassa',
|
||||
)
|
||||
.order_by(Payment.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
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
|
||||
|
||||
def _is_yoomoney_network(network: Optional[str]) -> bool:
|
||||
s = (network or "").lower()
|
||||
return "yoomoney" in s or "yoo money" in s or "yoo-money" in s
|
||||
|
||||
def _extract_last4(text: str) -> Optional[str]:
|
||||
digits = "".join(ch for ch in text if ch.isdigit())
|
||||
return digits[-4:] if len(digits) >= 4 else None
|
||||
|
||||
def _format_pm_title(network: Optional[str], last4: Optional[str]) -> str:
|
||||
if _is_yoomoney_network(network):
|
||||
l4 = last4 or _extract_last4(network or "")
|
||||
if l4:
|
||||
return _("payment_method_wallet_title", last4=l4)
|
||||
return _("payment_method_wallet_title", last4="****")
|
||||
if last4:
|
||||
network_name = network or _("payment_network_card", default="Card")
|
||||
return _("payment_method_card_title", network=network_name, last4=last4)
|
||||
network_name = network or _("payment_network_generic", default="Payment method")
|
||||
return _("payment_method_generic_title", network=network_name)
|
||||
|
||||
title = _format_pm_title(billing.card_network, billing.card_last4)
|
||||
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(billing.yookassa_payment_method_id, current_lang, i18n))
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("pm:history"))
|
||||
async def payment_method_history(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession, yookassa_service: YooKassaService):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
|
||||
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
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
from db.dal import payment_dal
|
||||
payments = await payment_dal.get_recent_payment_logs_with_user(session, limit=30, offset=0)
|
||||
user_payments = [p for p in payments if p.user_id == callback.from_user.id]
|
||||
|
||||
selected_pm_provider_id: Optional[str] = None
|
||||
pm_filter_requested: bool = False
|
||||
try:
|
||||
split_a, split_b, split_pm_id = callback.data.split(":", 2)
|
||||
if split_pm_id:
|
||||
pm_filter_requested = True
|
||||
if split_pm_id.isdigit():
|
||||
from db.dal.user_billing_dal import list_user_payment_methods
|
||||
methods = await list_user_payment_methods(session, callback.from_user.id)
|
||||
sel = next((m for m in methods if str(m.method_id) == split_pm_id), None)
|
||||
if sel and sel.provider_payment_method_id:
|
||||
selected_pm_provider_id = sel.provider_payment_method_id
|
||||
else:
|
||||
selected_pm_provider_id = split_pm_id
|
||||
except Exception:
|
||||
selected_pm_provider_id = None
|
||||
pm_filter_requested = False
|
||||
|
||||
if pm_filter_requested and not selected_pm_provider_id:
|
||||
user_payments = []
|
||||
|
||||
if selected_pm_provider_id:
|
||||
filtered: List[Payment] = []
|
||||
for p in user_payments:
|
||||
if p.provider != 'yookassa':
|
||||
continue
|
||||
if p.yookassa_payment_id and yookassa_service:
|
||||
try:
|
||||
info = await yookassa_service.get_payment_info(p.yookassa_payment_id)
|
||||
pm = (info or {}).get("payment_method") or {}
|
||||
if pm.get("id") == selected_pm_provider_id:
|
||||
filtered.append(p)
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
user_payments = filtered
|
||||
|
||||
if not user_payments:
|
||||
from bot.keyboards.inline.user_keyboards import get_back_to_payment_method_details_keyboard, get_payment_methods_manage_keyboard
|
||||
back_pm_id = ""
|
||||
try:
|
||||
split_a, split_b, back_pm_id = callback.data.split(":", 2)
|
||||
except Exception:
|
||||
back_pm_id = ""
|
||||
back_markup = (
|
||||
get_back_to_payment_method_details_keyboard(back_pm_id, current_lang, i18n)
|
||||
if back_pm_id
|
||||
else get_payment_methods_manage_keyboard(current_lang, i18n, has_card=True)
|
||||
)
|
||||
await callback.message.edit_text(_("payment_method_no_history"), reply_markup=back_markup)
|
||||
return
|
||||
|
||||
def _format_item(p: Payment) -> str:
|
||||
title = p.description or _("subscription_purchase_title", months=p.subscription_duration_months or 1)
|
||||
date_str = p.created_at.strftime('%Y-%m-%d') if p.created_at else "N/A"
|
||||
return f"{date_str} — {title} — {p.amount:.2f} {p.currency}"
|
||||
|
||||
lines = [_format_item(p) for p in user_payments]
|
||||
text = _("payment_method_tx_history_title") + "\n\n" + "\n".join(lines)
|
||||
try:
|
||||
split_a, split_b, split_pm_id_for_back = callback.data.split(":", 2)
|
||||
except Exception:
|
||||
split_pm_id_for_back = ""
|
||||
from bot.keyboards.inline.user_keyboards import get_back_to_payment_method_details_keyboard, get_payment_methods_manage_keyboard
|
||||
back_markup = (
|
||||
get_back_to_payment_method_details_keyboard(split_pm_id_for_back, current_lang, i18n)
|
||||
if split_pm_id_for_back
|
||||
else get_payment_methods_manage_keyboard(current_lang, i18n, has_card=True)
|
||||
)
|
||||
await callback.message.edit_text(text, reply_markup=back_markup)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("pm:list:"))
|
||||
async def payment_methods_list(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
from db.dal.user_billing_dal import list_user_payment_methods
|
||||
cards: List[tuple] = []
|
||||
methods = await list_user_payment_methods(session, callback.from_user.id)
|
||||
for m in methods:
|
||||
def _is_yoomoney_network(network: Optional[str]) -> bool:
|
||||
s = (network or "").lower()
|
||||
return "yoomoney" in s or "yoo money" in s or "yoo-money" in s
|
||||
def _extract_last4(text: str) -> Optional[str]:
|
||||
digits = "".join(ch for ch in text if ch.isdigit())
|
||||
return digits[-4:] if len(digits) >= 4 else None
|
||||
def _format_pm_title(network: Optional[str], last4: Optional[str]) -> str:
|
||||
if _is_yoomoney_network(network):
|
||||
l4 = last4 or _extract_last4(network or "")
|
||||
if l4:
|
||||
return get_text("payment_method_wallet_title", last4=l4)
|
||||
return get_text("payment_method_wallet_title", last4="****")
|
||||
if last4:
|
||||
network_name = network or get_text("payment_network_card", default="Card")
|
||||
return get_text("payment_method_card_title", network=network_name, last4=last4)
|
||||
network_name = network or get_text("payment_network_generic", default="Payment method")
|
||||
return get_text("payment_method_generic_title", network=network_name)
|
||||
title = _format_pm_title(m.card_network, m.card_last4)
|
||||
cards.append((str(m.method_id), title if not m.is_default else f"⭐ {title}"))
|
||||
|
||||
try:
|
||||
_, _, page_str = callback.data.split(":", 2)
|
||||
page = int(page_str)
|
||||
except Exception:
|
||||
page = 0
|
||||
|
||||
text = get_text("payment_methods_title")
|
||||
if not cards:
|
||||
text += "\n\n" + get_text("payment_method_none")
|
||||
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
|
||||
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
import logging
|
||||
from aiogram import Router, F, types
|
||||
from typing import Optional
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config.settings import Settings
|
||||
from bot.keyboards.inline.user_keyboards import get_payment_method_keyboard, get_payment_url_keyboard
|
||||
from bot.services.yookassa_service import YooKassaService
|
||||
from bot.services.crypto_pay_service import CryptoPayService
|
||||
from bot.services.stars_service import StarsService
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from db.dal import payment_dal, user_billing_dal
|
||||
|
||||
router = Router(name="user_subscription_payments_router")
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("subscribe_period:"))
|
||||
async def select_subscription_period_callback_handler(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
if not i18n or not callback.message:
|
||||
try:
|
||||
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
months = int(callback.data.split(":")[-1])
|
||||
except (ValueError, IndexError):
|
||||
logging.error(f"Invalid subscription period in callback_data: {callback.data}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
price_rub = settings.subscription_options.get(months)
|
||||
if price_rub is None:
|
||||
logging.error(
|
||||
f"Price not found for {months} months subscription period in settings.subscription_options."
|
||||
)
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
currency_symbol_val = settings.DEFAULT_CURRENCY_SYMBOL
|
||||
text_content = get_text("choose_payment_method")
|
||||
tribute_url = settings.tribute_payment_links.get(months)
|
||||
stars_price = settings.stars_subscription_options.get(months)
|
||||
reply_markup = get_payment_method_keyboard(
|
||||
months,
|
||||
price_rub,
|
||||
tribute_url,
|
||||
stars_price,
|
||||
currency_symbol_val,
|
||||
current_lang,
|
||||
i18n,
|
||||
settings,
|
||||
)
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(text_content, reply_markup=reply_markup)
|
||||
except Exception as e_edit:
|
||||
logging.warning(
|
||||
f"Edit message for payment method selection failed: {e_edit}. Sending new one."
|
||||
)
|
||||
await callback.message.answer(text_content, reply_markup=reply_markup)
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("pay_yk:"))
|
||||
async def pay_yk_callback_handler(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, yookassa_service: YooKassaService, session: AsyncSession):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
if not i18n or not callback.message:
|
||||
try:
|
||||
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
if not yookassa_service or not yookassa_service.configured:
|
||||
logging.error("YooKassa service is not configured or unavailable.")
|
||||
target_msg_edit = callback.message
|
||||
await target_msg_edit.edit_text(get_text("payment_service_unavailable"))
|
||||
try:
|
||||
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
_, data_payload = callback.data.split(":", 1)
|
||||
months_str, price_str = data_payload.split(":")
|
||||
months = int(months_str)
|
||||
price_rub = float(price_str)
|
||||
except (ValueError, IndexError):
|
||||
logging.error(f"Invalid pay_yk data in callback: {callback.data}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
user_id = callback.from_user.id
|
||||
payment_description = get_text("payment_description_subscription", months=months)
|
||||
currency_code_for_yk = "RUB"
|
||||
|
||||
payment_record_data = {
|
||||
"user_id": user_id,
|
||||
"amount": price_rub,
|
||||
"currency": currency_code_for_yk,
|
||||
"status": "pending_yookassa",
|
||||
"description": payment_description,
|
||||
"subscription_duration_months": months,
|
||||
}
|
||||
|
||||
db_payment_record = None
|
||||
try:
|
||||
db_payment_record = await payment_dal.create_payment_record(session, payment_record_data)
|
||||
await session.commit()
|
||||
logging.info(
|
||||
f"Payment record {db_payment_record.payment_id} created for user {user_id} with status 'pending_yookassa'."
|
||||
)
|
||||
except Exception as e_db_payment:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"Failed to create payment record in DB for user {user_id}: {e_db_payment}",
|
||||
exc_info=True,
|
||||
)
|
||||
await callback.message.edit_text(get_text("error_creating_payment_record"))
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
if not db_payment_record:
|
||||
await callback.message.edit_text(get_text("error_creating_payment_record"))
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
yookassa_metadata = {
|
||||
"user_id": str(user_id),
|
||||
"subscription_months": str(months),
|
||||
"payment_db_id": str(db_payment_record.payment_id),
|
||||
}
|
||||
receipt_email_for_yk = settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL
|
||||
|
||||
payment_response_yk = await yookassa_service.create_payment(
|
||||
amount=price_rub,
|
||||
currency=currency_code_for_yk,
|
||||
description=payment_description,
|
||||
metadata=yookassa_metadata,
|
||||
receipt_email=receipt_email_for_yk,
|
||||
# Save method only when autopayments are enabled
|
||||
save_payment_method=bool(getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False)),
|
||||
)
|
||||
|
||||
if payment_response_yk and payment_response_yk.get("confirmation_url"):
|
||||
pm = payment_response_yk.get("payment_method")
|
||||
try:
|
||||
if pm and pm.get("id"):
|
||||
pm_type = pm.get("type")
|
||||
title = pm.get("title")
|
||||
card = pm.get("card") or {}
|
||||
account_number = pm.get("account_number") or pm.get("account")
|
||||
if isinstance(card, dict) and (pm_type or "").lower() in {"bank_card", "bank-card", "card"}:
|
||||
display_network = card.get("card_type") or title or "Card"
|
||||
display_last4 = card.get("last4")
|
||||
elif (pm_type or "").lower() in {"yoo_money", "yoomoney", "yoo-money", "wallet"}:
|
||||
display_network = "YooMoney"
|
||||
display_last4 = (
|
||||
account_number[-4:]
|
||||
if isinstance(account_number, str) and len(account_number) >= 4
|
||||
else None
|
||||
)
|
||||
else:
|
||||
display_network = title or (pm_type.upper() if pm_type else "Payment method")
|
||||
display_last4 = None
|
||||
await user_billing_dal.upsert_yk_payment_method(
|
||||
session,
|
||||
user_id=user_id,
|
||||
payment_method_id=pm["id"],
|
||||
card_last4=display_last4,
|
||||
card_network=display_network,
|
||||
)
|
||||
try:
|
||||
await user_billing_dal.upsert_user_payment_method(
|
||||
session,
|
||||
user_id=user_id,
|
||||
provider_payment_method_id=pm["id"],
|
||||
provider="yookassa",
|
||||
card_last4=display_last4,
|
||||
card_network=display_network,
|
||||
set_default=True,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logging.exception("Failed to save YooKassa payment method preliminarily")
|
||||
try:
|
||||
await payment_dal.update_payment_status_by_db_id(
|
||||
session,
|
||||
payment_db_id=db_payment_record.payment_id,
|
||||
new_status=payment_response_yk.get("status", "pending"),
|
||||
yk_payment_id=payment_response_yk.get("id"),
|
||||
)
|
||||
await session.commit()
|
||||
except Exception as e_db_update_ykid:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"Failed to update payment record {db_payment_record.payment_id} with YK ID: {e_db_update_ykid}",
|
||||
exc_info=True,
|
||||
)
|
||||
await callback.message.edit_text(get_text("error_payment_gateway_link_failed"))
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
await callback.message.edit_text(
|
||||
get_text(key="payment_link_message", months=months),
|
||||
reply_markup=get_payment_url_keyboard(payment_response_yk["confirmation_url"], current_lang, i18n),
|
||||
disable_web_page_preview=False,
|
||||
)
|
||||
else:
|
||||
try:
|
||||
await payment_dal.update_payment_status_by_db_id(session, db_payment_record.payment_id, "failed_creation")
|
||||
await session.commit()
|
||||
except Exception as e_db_fail_create:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"Additionally failed to update payment record to 'failed_creation': {e_db_fail_create}",
|
||||
exc_info=True,
|
||||
)
|
||||
logging.error(
|
||||
f"Failed to create payment in YooKassa for user {user_id}, payment_db_id {db_payment_record.payment_id}. Response: {payment_response_yk}"
|
||||
)
|
||||
await callback.message.edit_text(get_text("error_payment_gateway"))
|
||||
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("pay_crypto:"))
|
||||
async def pay_crypto_callback_handler(
|
||||
callback: types.CallbackQuery,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
session: AsyncSession,
|
||||
cryptopay_service: CryptoPayService,
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
get_text = (lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key)
|
||||
|
||||
if not i18n or not callback.message:
|
||||
try:
|
||||
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
if not cryptopay_service or not getattr(cryptopay_service, "configured", False):
|
||||
try:
|
||||
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
_, data_payload = callback.data.split(":", 1)
|
||||
months_str, price_str = data_payload.split(":")
|
||||
months = int(months_str)
|
||||
price_amount = float(price_str)
|
||||
except (ValueError, IndexError):
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
user_id = callback.from_user.id
|
||||
payment_description = get_text("payment_description_subscription", months=months)
|
||||
|
||||
invoice_url = await cryptopay_service.create_invoice(
|
||||
session=session,
|
||||
user_id=user_id,
|
||||
months=months,
|
||||
amount=price_amount,
|
||||
description=payment_description,
|
||||
)
|
||||
|
||||
if invoice_url:
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
get_text(key="payment_link_message", months=months),
|
||||
reply_markup=get_payment_url_keyboard(invoice_url, current_lang, i18n),
|
||||
disable_web_page_preview=False,
|
||||
)
|
||||
except Exception:
|
||||
try:
|
||||
await callback.message.answer(
|
||||
get_text(key="payment_link_message", months=months),
|
||||
reply_markup=get_payment_url_keyboard(invoice_url, current_lang, i18n),
|
||||
disable_web_page_preview=False,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
await callback.answer(get_text("error_payment_gateway"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("pay_stars:"))
|
||||
async def pay_stars_callback_handler(
|
||||
callback: types.CallbackQuery,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
session: AsyncSession,
|
||||
stars_service: StarsService,
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
get_text = (lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key)
|
||||
|
||||
if not i18n or not callback.message:
|
||||
try:
|
||||
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
if not settings.STARS_ENABLED:
|
||||
try:
|
||||
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
_, data_payload = callback.data.split(":", 1)
|
||||
months_str, stars_price_str = data_payload.split(":")
|
||||
months = int(months_str)
|
||||
stars_price = int(stars_price_str)
|
||||
except (ValueError, IndexError):
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
user_id = callback.from_user.id
|
||||
payment_description = get_text("payment_description_subscription", months=months)
|
||||
|
||||
payment_db_id = await stars_service.create_invoice(
|
||||
session=session,
|
||||
user_id=user_id,
|
||||
months=months,
|
||||
stars_price=stars_price,
|
||||
description=payment_description,
|
||||
)
|
||||
|
||||
if payment_db_id:
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
await callback.answer(get_text("error_payment_gateway"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@router.pre_checkout_query()
|
||||
async def handle_pre_checkout_query(query: types.PreCheckoutQuery):
|
||||
try:
|
||||
await query.answer(ok=True)
|
||||
except Exception:
|
||||
# Nothing else to do here; Telegram will show an error if not answered
|
||||
pass
|
||||
|
||||
|
||||
@router.message(F.successful_payment)
|
||||
async def handle_successful_stars_payment(
|
||||
message: types.Message,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
session: AsyncSession,
|
||||
stars_service: StarsService,
|
||||
):
|
||||
payload = (message.successful_payment.invoice_payload
|
||||
if message and message.successful_payment else "")
|
||||
try:
|
||||
payment_db_id_str, months_str = (payload or "").split(":", 1)
|
||||
payment_db_id = int(payment_db_id_str)
|
||||
months = int(months_str)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
stars_amount = int(message.successful_payment.total_amount) if message.successful_payment else 0
|
||||
await stars_service.process_successful_payment(
|
||||
session=session,
|
||||
message=message,
|
||||
payment_db_id=payment_db_id,
|
||||
months=months,
|
||||
stars_amount=stars_amount,
|
||||
i18n_data=i18n_data,
|
||||
)
|
||||
|
||||
@@ -7,10 +7,11 @@ from datetime import datetime
|
||||
from config.settings import Settings
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
from bot.services.notification_service import notify_admin_new_trial
|
||||
from bot.services.notification_service import NotificationService
|
||||
from bot.keyboards.inline.user_keyboards import (
|
||||
get_trial_confirmation_keyboard,
|
||||
get_main_menu_inline_keyboard,
|
||||
get_connect_and_main_keyboard,
|
||||
)
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from .start import send_main_menu
|
||||
@@ -31,7 +32,10 @@ async def request_trial_confirmation_handler(
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
if not i18n or not callback.message:
|
||||
await callback.answer(_("error_occurred_try_again"), show_alert=True)
|
||||
try:
|
||||
await callback.answer(_("error_occurred_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
show_trial_btn_in_menu_if_fail = False
|
||||
@@ -46,7 +50,10 @@ async def request_trial_confirmation_handler(
|
||||
current_lang, i18n, settings, False
|
||||
),
|
||||
)
|
||||
await callback.answer()
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
if await subscription_service.has_had_any_subscription(session, user_id):
|
||||
@@ -56,24 +63,112 @@ async def request_trial_confirmation_handler(
|
||||
current_lang, i18n, settings, False
|
||||
),
|
||||
)
|
||||
await callback.answer()
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
traffic_gb_display = (
|
||||
str(settings.TRIAL_TRAFFIC_LIMIT_GB)
|
||||
if settings.TRIAL_TRAFFIC_LIMIT_GB and settings.TRIAL_TRAFFIC_LIMIT_GB > 0
|
||||
else _("traffic_unlimited")
|
||||
# Directly activate trial without confirmation
|
||||
activation_result = await subscription_service.activate_trial_subscription(
|
||||
session, user_id
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
text=_(
|
||||
"trial_confirm_prompt",
|
||||
days=settings.TRIAL_DURATION_DAYS,
|
||||
traffic_gb=traffic_gb_display,
|
||||
),
|
||||
reply_markup=get_trial_confirmation_keyboard(current_lang, i18n),
|
||||
final_message_text_in_chat = ""
|
||||
show_trial_button_after_action = False
|
||||
config_link_for_trial = None
|
||||
|
||||
if activation_result and activation_result.get("activated"):
|
||||
try:
|
||||
await callback.answer(_("trial_activated_alert"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
end_date_obj = activation_result.get("end_date")
|
||||
config_link_for_trial = activation_result.get("subscription_url") or _(
|
||||
"config_link_not_available"
|
||||
)
|
||||
|
||||
traffic_gb_val = activation_result.get(
|
||||
"traffic_gb", settings.TRIAL_TRAFFIC_LIMIT_GB
|
||||
)
|
||||
traffic_display = (
|
||||
f"{traffic_gb_val} GB"
|
||||
if traffic_gb_val and traffic_gb_val > 0
|
||||
else _("traffic_unlimited")
|
||||
)
|
||||
|
||||
final_message_text_in_chat = _(
|
||||
"trial_activated_details_message",
|
||||
days=activation_result.get("days", settings.TRIAL_DURATION_DAYS),
|
||||
end_date=(
|
||||
end_date_obj.strftime("%Y-%m-%d")
|
||||
if isinstance(end_date_obj, datetime)
|
||||
else "N/A"
|
||||
),
|
||||
config_link=config_link_for_trial,
|
||||
traffic_gb=traffic_display,
|
||||
)
|
||||
|
||||
# Send notification to admin about new trial
|
||||
notification_service = NotificationService(callback.bot, settings, i18n)
|
||||
await notification_service.notify_trial_activation(user_id, end_date_obj)
|
||||
# Mark ad attribution trial if exists
|
||||
try:
|
||||
from db.dal import ad_dal as _ad_dal
|
||||
await _ad_dal.mark_trial_activated(session, user_id)
|
||||
await session.commit()
|
||||
except Exception as e_mark:
|
||||
await session.rollback()
|
||||
logging.error(f"Failed to mark trial for ad attribution for user {user_id}: {e_mark}")
|
||||
else:
|
||||
message_key_from_service = (
|
||||
activation_result.get("message_key", "trial_activation_failed")
|
||||
if activation_result
|
||||
else "trial_activation_failed"
|
||||
)
|
||||
final_message_text_in_chat = _(message_key_from_service)
|
||||
try:
|
||||
await callback.answer(final_message_text_in_chat, show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
if (
|
||||
settings.TRIAL_ENABLED
|
||||
and not await subscription_service.has_had_any_subscription(
|
||||
session, user_id
|
||||
)
|
||||
):
|
||||
show_trial_button_after_action = True
|
||||
|
||||
reply_markup = (
|
||||
get_connect_and_main_keyboard(
|
||||
current_lang, i18n, settings, config_link_for_trial
|
||||
)
|
||||
if activation_result and activation_result.get("activated")
|
||||
else get_main_menu_inline_keyboard(
|
||||
current_lang, i18n, settings, show_trial_button_after_action
|
||||
)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
final_message_text_in_chat,
|
||||
parse_mode="HTML",
|
||||
reply_markup=reply_markup,
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
except Exception as e_edit:
|
||||
logging.warning(
|
||||
f"Could not edit trial result message: {e_edit}. Sending new one."
|
||||
)
|
||||
|
||||
if callback.message:
|
||||
await callback.message.answer(
|
||||
final_message_text_in_chat,
|
||||
parse_mode="HTML",
|
||||
reply_markup=reply_markup,
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "trial_action:confirm_activate")
|
||||
@@ -92,20 +187,29 @@ async def confirm_activate_trial_handler(
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
if not i18n or not callback.message:
|
||||
await callback.answer(_("error_occurred_try_again"), show_alert=True)
|
||||
try:
|
||||
await callback.answer(_("error_occurred_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
if not settings.TRIAL_ENABLED:
|
||||
await callback.answer(_("trial_feature_disabled"), show_alert=True)
|
||||
try:
|
||||
await callback.answer(_("trial_feature_disabled"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await send_main_menu(
|
||||
callback, settings, i18n_data, subscription_service, session, is_edit=True
|
||||
)
|
||||
return
|
||||
if await subscription_service.has_had_any_subscription(session, user_id):
|
||||
await callback.answer(
|
||||
_("trial_already_had_subscription_or_trial"), show_alert=True
|
||||
)
|
||||
try:
|
||||
await callback.answer(
|
||||
_("trial_already_had_subscription_or_trial"), show_alert=True
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
await send_main_menu(
|
||||
callback, settings, i18n_data, subscription_service, session, is_edit=True
|
||||
)
|
||||
@@ -117,9 +221,13 @@ async def confirm_activate_trial_handler(
|
||||
|
||||
final_message_text_in_chat = ""
|
||||
show_trial_button_after_action = False
|
||||
config_link_for_trial = None
|
||||
|
||||
if activation_result and activation_result.get("activated"):
|
||||
await callback.answer(_("trial_activated_alert"), show_alert=True)
|
||||
try:
|
||||
await callback.answer(_("trial_activated_alert"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
end_date_obj = activation_result.get("end_date")
|
||||
config_link_for_trial = activation_result.get("subscription_url") or _(
|
||||
@@ -153,7 +261,10 @@ async def confirm_activate_trial_handler(
|
||||
else "trial_activation_failed"
|
||||
)
|
||||
final_message_text_in_chat = _(message_key_from_service)
|
||||
await callback.answer(final_message_text_in_chat, show_alert=True)
|
||||
try:
|
||||
await callback.answer(final_message_text_in_chat, show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
if (
|
||||
settings.TRIAL_ENABLED
|
||||
and not await subscription_service.has_had_any_subscription(
|
||||
@@ -162,13 +273,21 @@ async def confirm_activate_trial_handler(
|
||||
):
|
||||
show_trial_button_after_action = True
|
||||
|
||||
reply_markup = (
|
||||
get_connect_and_main_keyboard(
|
||||
current_lang, i18n, settings, config_link_for_trial
|
||||
)
|
||||
if activation_result and activation_result.get("activated")
|
||||
else get_main_menu_inline_keyboard(
|
||||
current_lang, i18n, settings, show_trial_button_after_action
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
final_message_text_in_chat,
|
||||
parse_mode="HTML",
|
||||
reply_markup=get_main_menu_inline_keyboard(
|
||||
current_lang, i18n, settings, show_trial_button_after_action
|
||||
),
|
||||
reply_markup=reply_markup,
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
except Exception as e_edit:
|
||||
@@ -176,28 +295,24 @@ async def confirm_activate_trial_handler(
|
||||
f"Could not edit trial result message: {e_edit}. Sending new one."
|
||||
)
|
||||
|
||||
if (
|
||||
callback.message
|
||||
and hasattr(callback.message, "chat")
|
||||
and callback.message.chat
|
||||
):
|
||||
await callback.message.chat.send_message(
|
||||
if callback.message:
|
||||
await callback.message.answer(
|
||||
final_message_text_in_chat,
|
||||
parse_mode="HTML",
|
||||
reply_markup=get_main_menu_inline_keyboard(
|
||||
current_lang, i18n, settings, show_trial_button_after_action
|
||||
),
|
||||
reply_markup=reply_markup,
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
|
||||
if activation_result and activation_result.get("activated") and end_date_obj:
|
||||
await notify_admin_new_trial(
|
||||
callback.bot,
|
||||
settings,
|
||||
i18n,
|
||||
user_id,
|
||||
end_date_obj,
|
||||
)
|
||||
notification_service = NotificationService(callback.bot, settings, i18n)
|
||||
await notification_service.notify_trial_activation(user_id, end_date_obj)
|
||||
try:
|
||||
from db.dal import ad_dal as _ad_dal
|
||||
await _ad_dal.mark_trial_activated(session, user_id)
|
||||
await session.commit()
|
||||
except Exception as e_mark:
|
||||
await session.rollback()
|
||||
logging.error(f"Failed to mark trial for ad attribution for user {user_id}: {e_mark}")
|
||||
|
||||
|
||||
@router.callback_query(F.data == "main_action:cancel_trial")
|
||||
|
||||
@@ -12,27 +12,187 @@ def get_admin_panel_keyboard(i18n_instance, lang: str,
|
||||
settings: Settings) -> InlineKeyboardMarkup:
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
# Статистика и мониторинг
|
||||
builder.button(text=_(key="admin_stats_and_monitoring_section"),
|
||||
callback_data="admin_section:stats_monitoring")
|
||||
|
||||
# Управление пользователями
|
||||
builder.button(text=_(key="admin_user_management_section"),
|
||||
callback_data="admin_section:user_management")
|
||||
|
||||
# Промокоды и маркетинг
|
||||
builder.button(text=_(key="admin_promo_marketing_section"),
|
||||
callback_data="admin_section:promo_marketing")
|
||||
|
||||
# Реклама
|
||||
builder.button(text=_(key="admin_ads_section", default="📈 Реклама"),
|
||||
callback_data="admin_action:ads")
|
||||
|
||||
# Системные функции
|
||||
builder.button(text=_(key="admin_system_functions_section"),
|
||||
callback_data="admin_section:system_functions")
|
||||
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def get_stats_monitoring_keyboard(i18n_instance, lang: str) -> InlineKeyboardMarkup:
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
builder.button(text=_(key="admin_stats_button"),
|
||||
callback_data="admin_action:stats")
|
||||
builder.button(text=_(key="admin_broadcast_button"),
|
||||
callback_data="admin_action:broadcast")
|
||||
builder.button(text=_(key="admin_create_promo_button"),
|
||||
callback_data="admin_action:create_promo")
|
||||
builder.button(text=_(key="admin_manage_promos_button"),
|
||||
callback_data="admin_action:manage_promos")
|
||||
builder.button(text=_(key="admin_view_promos_button"),
|
||||
callback_data="admin_action:view_promos")
|
||||
builder.button(text=_(key="admin_view_payments_button", default="💰 Платежи"),
|
||||
callback_data="admin_action:view_payments")
|
||||
builder.button(text=_(key="admin_view_logs_menu_button"),
|
||||
callback_data="admin_action:view_logs_menu")
|
||||
|
||||
builder.button(text=_(key="back_to_admin_panel_button"),
|
||||
callback_data="admin_action:main")
|
||||
builder.adjust(2, 1, 1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def get_user_management_keyboard(i18n_instance, lang: str) -> InlineKeyboardMarkup:
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
builder.button(text=_(key="admin_users_management_button"),
|
||||
callback_data="admin_action:users_management")
|
||||
builder.button(text=_(key="admin_ban_management_section"),
|
||||
callback_data="admin_section:ban_management")
|
||||
|
||||
builder.button(text=_(key="back_to_admin_panel_button"),
|
||||
callback_data="admin_action:main")
|
||||
builder.adjust(2, 1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def get_ban_management_keyboard(i18n_instance, lang: str) -> InlineKeyboardMarkup:
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
builder.button(text=_(key="admin_ban_user_button"),
|
||||
callback_data="admin_action:ban_user_prompt")
|
||||
builder.button(text=_(key="admin_unban_user_button"),
|
||||
callback_data="admin_action:unban_user_prompt")
|
||||
builder.button(text=_(key="admin_view_banned_users_button"),
|
||||
callback_data="admin_action:view_banned:0")
|
||||
builder.button(text=_(key="admin_view_logs_menu_button"),
|
||||
callback_data="admin_action:view_logs_menu")
|
||||
|
||||
builder.button(text=_(key="back_to_user_management_button"),
|
||||
callback_data="admin_section:user_management")
|
||||
builder.adjust(2, 1, 1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def get_promo_marketing_keyboard(i18n_instance, lang: str) -> InlineKeyboardMarkup:
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
builder.button(text=_(key="admin_create_promo_button"),
|
||||
callback_data="admin_action:create_promo")
|
||||
builder.button(text=_(key="admin_create_bulk_promo_button"),
|
||||
callback_data="admin_action:create_bulk_promo")
|
||||
builder.button(text=_(key="admin_promo_management_button"),
|
||||
callback_data="admin_action:promo_management")
|
||||
|
||||
builder.button(text=_(key="back_to_admin_panel_button"),
|
||||
callback_data="admin_action:main")
|
||||
builder.adjust(2, 1, 1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def get_system_functions_keyboard(i18n_instance, lang: str) -> InlineKeyboardMarkup:
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
builder.button(text=_(key="admin_broadcast_button"),
|
||||
callback_data="admin_action:broadcast")
|
||||
builder.button(text=_(key="admin_sync_panel_button"),
|
||||
callback_data="admin_action:sync_panel")
|
||||
builder.adjust(2, 2, 2, 2, 1)
|
||||
builder.button(text=_(key="admin_queue_status_button"),
|
||||
callback_data="admin_action:queue_status")
|
||||
|
||||
builder.button(text=_(key="back_to_admin_panel_button"),
|
||||
callback_data="admin_action:main")
|
||||
builder.adjust(2, 1, 1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def get_ads_menu_keyboard(i18n_instance, lang: str) -> InlineKeyboardMarkup:
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text=_(key="admin_ads_create_button", default="➕ Создать кампанию"),
|
||||
callback_data="admin_action:ads_create")
|
||||
builder.button(text=_(key="back_to_admin_panel_button"),
|
||||
callback_data="admin_action:main")
|
||||
builder.adjust(1, 1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def get_ads_list_keyboard(
|
||||
i18n_instance,
|
||||
lang: str,
|
||||
campaigns: list,
|
||||
current_page: int,
|
||||
total_pages: int,
|
||||
) -> InlineKeyboardMarkup:
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
for c in campaigns:
|
||||
title = f"{c.source}"
|
||||
builder.button(
|
||||
text=title,
|
||||
callback_data=f"admin_ads:card:{c.ad_campaign_id}:{current_page}",
|
||||
)
|
||||
|
||||
# Pagination row (only when needed)
|
||||
if total_pages > 1:
|
||||
row = []
|
||||
if current_page > 0:
|
||||
row.append(
|
||||
InlineKeyboardButton(
|
||||
text="⬅️ " + _("prev_page_button", default="Prev"),
|
||||
callback_data=f"admin_ads:page:{current_page - 1}",
|
||||
)
|
||||
)
|
||||
row.append(
|
||||
InlineKeyboardButton(
|
||||
text=f"{current_page + 1}/{total_pages}",
|
||||
callback_data="ads_page_display",
|
||||
)
|
||||
)
|
||||
if current_page < total_pages - 1:
|
||||
row.append(
|
||||
InlineKeyboardButton(
|
||||
text=_("next_page_button", default="Next") + " ➡️",
|
||||
callback_data=f"admin_ads:page:{current_page + 1}",
|
||||
)
|
||||
)
|
||||
if row:
|
||||
builder.row(*row)
|
||||
|
||||
builder.button(text=_(key="admin_ads_create_button", default="➕ Создать кампанию"),
|
||||
callback_data="admin_action:ads_create")
|
||||
builder.button(text=_(key="back_to_admin_panel_button"),
|
||||
callback_data="admin_action:main")
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def get_ad_card_keyboard(i18n_instance, lang: str, campaign_id: int, back_page: int) -> InlineKeyboardMarkup:
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
# Dangerous action: Delete campaign
|
||||
builder.button(text=_(key="admin_ads_delete_button", default="🗑 Удалить кампанию"),
|
||||
callback_data=f"admin_ads:delete:{campaign_id}:{back_page}")
|
||||
builder.button(text=_(key="back_to_ads_list_button", default="⬅️ К списку"),
|
||||
callback_data=f"admin_ads:page:{back_page}")
|
||||
builder.button(text=_(key="back_to_admin_panel_button"),
|
||||
callback_data="admin_action:main")
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
@@ -43,9 +203,12 @@ def get_logs_menu_keyboard(i18n_instance, lang: str) -> InlineKeyboardMarkup:
|
||||
callback_data="admin_logs:view_all:0")
|
||||
builder.button(text=_(key="admin_view_user_logs_prompt_button"),
|
||||
callback_data="admin_logs:prompt_user")
|
||||
builder.button(text=_(key="admin_export_logs_csv_button"),
|
||||
callback_data="admin_logs:export_csv")
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=_(key="back_to_admin_panel_button"),
|
||||
callback_data="admin_action:main"))
|
||||
builder.adjust(2, 1, 1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
@@ -177,12 +340,47 @@ def get_confirmation_keyboard(yes_callback_data: str, no_callback_data: str,
|
||||
|
||||
|
||||
def get_broadcast_confirmation_keyboard(lang: str,
|
||||
i18n_instance) -> InlineKeyboardMarkup:
|
||||
i18n_instance,
|
||||
target: str = "all") -> InlineKeyboardMarkup:
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text=_(key="confirm_broadcast_send_button"),
|
||||
|
||||
# Row: target selection (all / active / inactive)
|
||||
target_all_label = _(
|
||||
key="broadcast_target_all_button",
|
||||
default="👥 Все"
|
||||
)
|
||||
target_active_label = _(
|
||||
key="broadcast_target_active_button",
|
||||
default="✅ Активные"
|
||||
)
|
||||
target_inactive_label = _(
|
||||
key="broadcast_target_inactive_button",
|
||||
default="⌛ Неактивные"
|
||||
)
|
||||
|
||||
# Highlight current selection with a prefix
|
||||
def mark_selected(label: str, is_selected: bool) -> str:
|
||||
return ("• " + label) if is_selected else label
|
||||
|
||||
builder.button(
|
||||
text=mark_selected(target_all_label, target == "all"),
|
||||
callback_data="broadcast_target:all",
|
||||
)
|
||||
builder.button(
|
||||
text=mark_selected(target_active_label, target == "active"),
|
||||
callback_data="broadcast_target:active",
|
||||
)
|
||||
builder.button(
|
||||
text=mark_selected(target_inactive_label, target == "inactive"),
|
||||
callback_data="broadcast_target:inactive",
|
||||
)
|
||||
builder.adjust(3)
|
||||
|
||||
# Row: confirmation
|
||||
builder.button(text=_(key="confirm_broadcast_send_button", default="🚀 Отправить"),
|
||||
callback_data="broadcast_final_action:send")
|
||||
builder.button(text=_(key="cancel_broadcast_button"),
|
||||
builder.button(text=_(key="cancel_broadcast_button", default="❌ Отмена"),
|
||||
callback_data="broadcast_final_action:cancel")
|
||||
builder.adjust(2)
|
||||
return builder.as_markup()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton
|
||||
from aiogram.types import InlineKeyboardMarkup, WebAppInfo
|
||||
from typing import Dict, Optional, List
|
||||
from typing import Dict, Optional, List, Tuple
|
||||
|
||||
from config.settings import Settings
|
||||
|
||||
@@ -21,20 +21,12 @@ def get_main_menu_inline_keyboard(
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=_(key="menu_subscribe_inline"),
|
||||
callback_data="main_action:subscribe"))
|
||||
if settings.SUBSCRIPTION_MINI_APP_URL:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_(key="menu_my_subscription_inline"),
|
||||
web_app=WebAppInfo(url=settings.SUBSCRIPTION_MINI_APP_URL),
|
||||
)
|
||||
)
|
||||
else:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_(key="menu_my_subscription_inline"),
|
||||
callback_data="main_action:my_subscription",
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_(key="menu_my_subscription_inline"),
|
||||
callback_data="main_action:my_subscription",
|
||||
)
|
||||
)
|
||||
|
||||
referral_button = InlineKeyboardButton(
|
||||
text=_(key="menu_referral_inline"),
|
||||
@@ -134,6 +126,9 @@ def get_payment_method_keyboard(months: int, price: float,
|
||||
if settings.YOOKASSA_ENABLED:
|
||||
builder.button(text=_("pay_with_yookassa_button"),
|
||||
callback_data=f"pay_yk:{months}:{price}")
|
||||
if settings.CRYPTOPAY_ENABLED:
|
||||
builder.button(text=_("pay_with_cryptopay_button"),
|
||||
callback_data=f"pay_crypto:{months}:{price}")
|
||||
builder.button(text=_(key="cancel_button"),
|
||||
callback_data="main_action:subscribe")
|
||||
builder.adjust(1)
|
||||
@@ -155,8 +150,11 @@ def get_referral_link_keyboard(lang: str,
|
||||
i18n_instance) -> InlineKeyboardMarkup:
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text=_(key="referral_share_message_button"),
|
||||
callback_data="referral_action:share_message")
|
||||
builder.button(text=_(key="back_to_main_menu_button"),
|
||||
callback_data="main_action:back_to_main")
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
@@ -169,6 +167,14 @@ def get_back_to_main_menu_markup(lang: str,
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def get_subscribe_only_markup(lang: str, i18n_instance) -> InlineKeyboardMarkup:
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text=_(key="menu_subscribe_inline"),
|
||||
callback_data="main_action:subscribe")
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def get_user_banned_keyboard(support_link: Optional[str], lang: str,
|
||||
i18n_instance) -> Optional[InlineKeyboardMarkup]:
|
||||
if not support_link:
|
||||
@@ -177,3 +183,162 @@ def get_user_banned_keyboard(support_link: Optional[str], lang: str,
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text=_(key="menu_support_button"), url=support_link)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def get_connect_and_main_keyboard(
|
||||
lang: str,
|
||||
i18n_instance,
|
||||
settings: Settings,
|
||||
config_link: Optional[str]) -> InlineKeyboardMarkup:
|
||||
"""Keyboard with a connect button and a back to main menu button."""
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
if settings.SUBSCRIPTION_MINI_APP_URL:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_("connect_button"),
|
||||
web_app=WebAppInfo(url=settings.SUBSCRIPTION_MINI_APP_URL),
|
||||
)
|
||||
)
|
||||
elif config_link:
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=_("connect_button"), url=config_link)
|
||||
)
|
||||
else:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_("connect_button"),
|
||||
callback_data="main_action:my_subscription",
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_("back_to_main_menu_button"),
|
||||
callback_data="main_action:back_to_main",
|
||||
)
|
||||
)
|
||||
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def get_payment_methods_manage_keyboard(lang: str, i18n_instance, has_card: bool) -> InlineKeyboardMarkup:
|
||||
"""Deprecated in favor of get_payment_methods_list_keyboard. Kept for backward compatibility."""
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=_(key="payment_method_bind_button"), callback_data="pm:bind")
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=_(key="back_to_main_menu_button"), callback_data="main_action:back_to_main")
|
||||
)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def get_payment_methods_list_keyboard(
|
||||
cards: List[Tuple[str, str]],
|
||||
page: int,
|
||||
lang: str,
|
||||
i18n_instance,
|
||||
) -> InlineKeyboardMarkup:
|
||||
"""
|
||||
Build a paginated list of saved payment methods.
|
||||
cards: list of tuples (payment_method_id, display_title)
|
||||
page: 0-based page index
|
||||
"""
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
per_page = 5
|
||||
total = len(cards)
|
||||
start = page * per_page
|
||||
end = start + per_page
|
||||
for pm_id, title in cards[start:end]:
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=title, callback_data=f"pm:view:{pm_id}")
|
||||
)
|
||||
|
||||
# Pagination controls if needed
|
||||
nav_buttons: List[InlineKeyboardButton] = []
|
||||
if start > 0:
|
||||
nav_buttons.append(InlineKeyboardButton(text="⬅️", callback_data=f"pm:list:{page-1}"))
|
||||
if end < total:
|
||||
nav_buttons.append(InlineKeyboardButton(text="➡️", callback_data=f"pm:list:{page+1}"))
|
||||
if nav_buttons:
|
||||
builder.row(*nav_buttons)
|
||||
|
||||
# Bind new card and back
|
||||
builder.row(InlineKeyboardButton(text=_(key="payment_method_bind_button"), callback_data="pm:bind"))
|
||||
builder.row(InlineKeyboardButton(text=_(key="back_to_main_menu_button"), callback_data="main_action:back_to_main"))
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def get_payment_method_delete_confirm_keyboard(pm_id: str, lang: str, i18n_instance) -> InlineKeyboardMarkup:
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=_(key="yes_button"), callback_data=f"pm:delete:{pm_id}"),
|
||||
InlineKeyboardButton(text=_(key="cancel_button"), callback_data=f"pm:view:{pm_id}"),
|
||||
)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def get_payment_method_details_keyboard(pm_id: str, lang: str, i18n_instance) -> InlineKeyboardMarkup:
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=_(key="payment_method_tx_history_title"), callback_data=f"pm:history:{pm_id}")
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=_(key="payment_method_delete_button"), callback_data=f"pm:delete_confirm:{pm_id}")
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=_(key="back_to_main_menu_button"), callback_data="pm:list:0")
|
||||
)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def get_bind_url_keyboard(bind_url: str, lang: str, i18n_instance) -> InlineKeyboardMarkup:
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text=_(key="payment_method_bind_button"), url=bind_url)
|
||||
builder.button(text=_(key="back_to_main_menu_button"), callback_data="pm:manage")
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def get_back_to_payment_methods_keyboard(lang: str, i18n_instance) -> InlineKeyboardMarkup:
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=_(key="back_to_main_menu_button"), callback_data="pm:list:0"))
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def get_back_to_payment_method_details_keyboard(pm_id: str, lang: str, i18n_instance) -> InlineKeyboardMarkup:
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
# Back one step: return to specific payment method details
|
||||
builder.row(InlineKeyboardButton(text=_(key="back_to_main_menu_button"), callback_data=f"pm:view:{pm_id}"))
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def get_autorenew_cancel_keyboard(lang: str, i18n_instance) -> InlineKeyboardMarkup:
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=_(key="autorenew_disable_button"), callback_data="autorenew:cancel")
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=_(key="menu_my_subscription_inline"), callback_data="main_action:my_subscription")
|
||||
)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def get_autorenew_confirm_keyboard(enable: bool, sub_id: int, lang: str, i18n_instance) -> InlineKeyboardMarkup:
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=_(key="yes_button"), callback_data=f"autorenew:confirm:{sub_id}:{1 if enable else 0}"),
|
||||
InlineKeyboardButton(text=_(key="no_button"), callback_data="main_action:my_subscription"),
|
||||
)
|
||||
return builder.as_markup()
|
||||
|
||||
+110
-257
@@ -1,22 +1,15 @@
|
||||
import logging
|
||||
import asyncio
|
||||
from typing import Callable, Dict, Any, Awaitable, Optional
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
from aiogram import Bot, Dispatcher, BaseMiddleware, Router, F
|
||||
from aiogram.types import (
|
||||
Update,
|
||||
MenuButtonDefault,
|
||||
MenuButtonWebApp,
|
||||
WebAppInfo,
|
||||
BotCommand,
|
||||
)
|
||||
from aiogram import Bot, Dispatcher
|
||||
from aiogram.types import (MenuButtonDefault, MenuButtonWebApp, WebAppInfo, BotCommand)
|
||||
from aiogram.enums import ParseMode
|
||||
from aiogram.filters import CommandStart, Command
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.webhook.aiohttp_server import SimpleRequestHandler, setup_application
|
||||
from aiogram.fsm.storage.memory import MemoryStorage
|
||||
from aiohttp import web
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from bot.services.panel_webhook_service import PanelWebhookService, panel_webhook_route
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from config.settings import Settings
|
||||
@@ -24,14 +17,16 @@ from config.settings import Settings
|
||||
from db.database_setup import init_db_connection
|
||||
|
||||
from bot.middlewares.i18n import I18nMiddleware, get_i18n_instance, JsonI18n
|
||||
from bot.middlewares.db_session import DBSessionMiddleware
|
||||
from bot.middlewares.ban_check_middleware import BanCheckMiddleware
|
||||
from bot.middlewares.action_logger_middleware import ActionLoggerMiddleware
|
||||
from bot.middlewares.profile_sync import ProfileSyncMiddleware
|
||||
from bot.app.controllers.dispatcher_controller import build_dispatcher
|
||||
from bot.app.factories.build_services import build_core_services
|
||||
from bot.app.web.web_server import build_and_start_web_app
|
||||
|
||||
from bot.handlers.user import user_router_aggregate
|
||||
from bot.handlers.admin import admin_router_aggregate
|
||||
from bot.filters.admin_filter import AdminFilter
|
||||
from bot.routers import build_root_router
|
||||
|
||||
from bot.services.notification_service import schedule_subscription_notifications
|
||||
from bot.services.yookassa_service import YooKassaService
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
@@ -39,55 +34,15 @@ from bot.services.referral_service import ReferralService
|
||||
from bot.services.promo_code_service import PromoCodeService
|
||||
from bot.services.stars_service import StarsService
|
||||
from bot.services.tribute_service import TributeService, tribute_webhook_route
|
||||
from bot.services.crypto_pay_service import CryptoPayService, cryptopay_webhook_route
|
||||
|
||||
from bot.handlers.user import payment as user_payment_webhook_module
|
||||
|
||||
|
||||
class DBSessionMiddleware(BaseMiddleware):
|
||||
|
||||
def __init__(self, async_session_factory: sessionmaker):
|
||||
super().__init__()
|
||||
self.async_session_factory = async_session_factory
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
handler: Callable[[Update, Dict[str, Any]], Awaitable[Any]],
|
||||
event: Update,
|
||||
data: Dict[str, Any],
|
||||
) -> Any:
|
||||
if self.async_session_factory is None:
|
||||
logging.critical("DBSessionMiddleware: async_session_factory is None!")
|
||||
raise RuntimeError(
|
||||
"async_session_factory not provided to DBSessionMiddleware"
|
||||
)
|
||||
|
||||
async with self.async_session_factory() as session:
|
||||
data["session"] = session
|
||||
try:
|
||||
result = await handler(event, data)
|
||||
|
||||
await session.commit()
|
||||
return result
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
"DBSessionMiddleware: Exception caused rollback.", exc_info=True
|
||||
)
|
||||
raise
|
||||
from bot.handlers.admin.sync_admin import perform_sync
|
||||
from bot.utils.message_queue import init_queue_manager
|
||||
|
||||
|
||||
async def register_all_routers(dp: Dispatcher, settings: Settings):
|
||||
dp.include_router(user_router_aggregate)
|
||||
|
||||
admin_main_router = Router(name="admin_main_filtered_router")
|
||||
admin_filter_instance = AdminFilter(admin_ids=settings.ADMIN_IDS)
|
||||
|
||||
admin_main_router.message.filter(admin_filter_instance)
|
||||
admin_main_router.callback_query.filter(admin_filter_instance)
|
||||
|
||||
admin_main_router.include_router(admin_router_aggregate)
|
||||
|
||||
dp.include_router(admin_main_router)
|
||||
dp.include_router(build_root_router(settings))
|
||||
logging.info("All application routers registered.")
|
||||
|
||||
|
||||
@@ -101,28 +56,8 @@ async def on_startup_configured(dispatcher: Dispatcher):
|
||||
|
||||
logging.info("STARTUP: on_startup_configured executing...")
|
||||
|
||||
existing_scheduler: Optional[AsyncIOScheduler] = dispatcher.get("scheduler")
|
||||
|
||||
if existing_scheduler and existing_scheduler.running:
|
||||
logging.warning("STARTUP: Scheduler already running, skipping initialization.")
|
||||
else:
|
||||
scheduler = AsyncIOScheduler(timezone="UTC")
|
||||
try:
|
||||
await schedule_subscription_notifications(
|
||||
bot,
|
||||
settings,
|
||||
i18n_instance,
|
||||
scheduler,
|
||||
panel_service,
|
||||
async_session_factory,
|
||||
)
|
||||
scheduler.start()
|
||||
dispatcher["scheduler"] = scheduler
|
||||
logging.info("STARTUP: APScheduler started.")
|
||||
except Exception as e:
|
||||
logging.error(f"STARTUP: Failed to start APScheduler: {e}", exc_info=True)
|
||||
|
||||
telegram_webhook_url_to_set = getattr(settings, "TELEGRAM_WEBHOOK_BASE_URL", None)
|
||||
telegram_webhook_url_to_set = settings.WEBHOOK_BASE_URL
|
||||
if telegram_webhook_url_to_set:
|
||||
full_telegram_webhook_url = (
|
||||
f"{str(telegram_webhook_url_to_set).rstrip('/')}/{settings.BOT_TOKEN}"
|
||||
@@ -172,10 +107,10 @@ async def on_startup_configured(dispatcher: Dispatcher):
|
||||
"STARTUP: Skipped setting Telegram webhook due to security or configuration error."
|
||||
)
|
||||
else:
|
||||
logging.info(
|
||||
"STARTUP: TELEGRAM_WEBHOOK_BASE_URL not set in environment. Attempting to delete any existing webhook (running in polling mode)."
|
||||
logging.error(
|
||||
"STARTUP: WEBHOOK_BASE_URL not set in environment. Webhook mode is required. Exiting."
|
||||
)
|
||||
await bot.delete_webhook(drop_pending_updates=True)
|
||||
raise SystemExit("WEBHOOK_BASE_URL is required. Polling mode is disabled.")
|
||||
|
||||
if settings.SUBSCRIPTION_MINI_APP_URL:
|
||||
try:
|
||||
@@ -207,26 +142,72 @@ async def on_startup_configured(dispatcher: Dispatcher):
|
||||
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 as e:
|
||||
logging.error(f"STARTUP: Failed to initialize message queue manager: {e}", exc_info=True)
|
||||
|
||||
# Automatic sync on startup
|
||||
try:
|
||||
logging.info("STARTUP: Running automatic panel sync...")
|
||||
|
||||
async with async_session_factory() as session:
|
||||
sync_result = await perform_sync(
|
||||
panel_service=panel_service,
|
||||
session=session,
|
||||
settings=settings,
|
||||
i18n_instance=i18n_instance
|
||||
)
|
||||
|
||||
if sync_result.get("status") == "completed":
|
||||
logging.info(f"STARTUP: Automatic sync completed successfully. Details: {sync_result.get('details', 'N/A')}")
|
||||
else:
|
||||
logging.warning(f"STARTUP: Automatic sync completed with issues. Status: {sync_result.get('status', 'unknown')}")
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"STARTUP: Failed to run automatic sync: {e}", exc_info=True)
|
||||
|
||||
logging.info("STARTUP: Bot on_startup_configured completed.")
|
||||
|
||||
|
||||
async def on_shutdown_configured(dispatcher: Dispatcher):
|
||||
logging.warning("SHUTDOWN: on_shutdown_configured executing...")
|
||||
|
||||
scheduler: Optional[AsyncIOScheduler] = dispatcher.get("scheduler")
|
||||
if scheduler and scheduler.running:
|
||||
try:
|
||||
scheduler.shutdown(wait=False)
|
||||
logging.info("SHUTDOWN: APScheduler shut down.")
|
||||
except Exception as e:
|
||||
logging.error(
|
||||
f"SHUTDOWN: Error shutting down APScheduler: {e}", exc_info=True
|
||||
)
|
||||
async def close_service(key: str) -> None:
|
||||
service = dispatcher.get(key)
|
||||
if not service:
|
||||
return
|
||||
close_coro = getattr(service, "close", None)
|
||||
if callable(close_coro):
|
||||
try:
|
||||
await close_coro()
|
||||
logging.info(f"{key} closed on shutdown.")
|
||||
except Exception as e:
|
||||
logging.warning(f"Failed to close {key}: {e}")
|
||||
else:
|
||||
close_session = getattr(service, "close_session", None)
|
||||
if callable(close_session):
|
||||
try:
|
||||
await close_session()
|
||||
logging.info(f"{key} session closed on shutdown.")
|
||||
except Exception as e:
|
||||
logging.warning(f"Failed to close session for {key}: {e}")
|
||||
|
||||
panel_service: Optional[PanelApiService] = dispatcher.get("panel_service")
|
||||
if panel_service and hasattr(panel_service, "close_session"):
|
||||
await panel_service.close_session()
|
||||
logging.info("Panel API service session closed on shutdown.")
|
||||
for service_key in (
|
||||
"panel_service",
|
||||
"cryptopay_service",
|
||||
"tribute_service",
|
||||
"panel_webhook_service",
|
||||
"yookassa_service",
|
||||
"promo_code_service",
|
||||
"stars_service",
|
||||
"subscription_service",
|
||||
"referral_service",
|
||||
):
|
||||
await close_service(service_key)
|
||||
|
||||
bot: Bot = dispatcher["bot_instance"]
|
||||
if bot and bot.session:
|
||||
@@ -247,19 +228,16 @@ async def on_shutdown_configured(dispatcher: Dispatcher):
|
||||
|
||||
|
||||
async def run_bot(settings_param: Settings):
|
||||
storage = MemoryStorage()
|
||||
default_props = DefaultBotProperties(parse_mode=ParseMode.HTML)
|
||||
bot = Bot(token=settings_param.BOT_TOKEN, default=default_props)
|
||||
|
||||
local_async_session_factory = init_db_connection(settings_param)
|
||||
if local_async_session_factory is None:
|
||||
logging.critical(
|
||||
"Failed to initialize database connection and session factory. Exiting."
|
||||
)
|
||||
return
|
||||
dp, bot, extra = build_dispatcher(settings_param, local_async_session_factory)
|
||||
i18n_instance = extra["i18n_instance"]
|
||||
|
||||
dp = Dispatcher(storage=storage, settings=settings_param, bot_instance=bot)
|
||||
|
||||
# Get bot username for YooKassa default return URL if needed
|
||||
actual_bot_username = "your_bot_username"
|
||||
try:
|
||||
bot_info = await bot.get_me()
|
||||
@@ -270,180 +248,55 @@ async def run_bot(settings_param: Settings):
|
||||
f"Failed to get bot info (e.g., for YooKassa default URL): {e}. Using fallback: {actual_bot_username}"
|
||||
)
|
||||
|
||||
i18n_instance = get_i18n_instance(
|
||||
path="locales", default=settings_param.DEFAULT_LANGUAGE
|
||||
)
|
||||
|
||||
yookassa_service = YooKassaService(
|
||||
shop_id=settings_param.YOOKASSA_SHOP_ID,
|
||||
secret_key=settings_param.YOOKASSA_SECRET_KEY,
|
||||
configured_return_url=settings_param.YOOKASSA_RETURN_URL,
|
||||
bot_username_for_default_return=actual_bot_username,
|
||||
settings_obj=settings_param,
|
||||
)
|
||||
panel_service = PanelApiService(settings_param)
|
||||
|
||||
subscription_service = SubscriptionService(
|
||||
settings_param, panel_service, bot, i18n_instance
|
||||
)
|
||||
referral_service = ReferralService(
|
||||
settings_param, subscription_service, bot, i18n_instance
|
||||
)
|
||||
promo_code_service = PromoCodeService(
|
||||
settings_param, subscription_service, bot, i18n_instance
|
||||
)
|
||||
stars_service = StarsService(
|
||||
bot, settings_param, i18n_instance, subscription_service, referral_service
|
||||
)
|
||||
tribute_service = TributeService(
|
||||
bot,
|
||||
services = build_core_services(
|
||||
settings_param,
|
||||
i18n_instance,
|
||||
bot,
|
||||
local_async_session_factory,
|
||||
panel_service,
|
||||
subscription_service,
|
||||
referral_service,
|
||||
i18n_instance,
|
||||
actual_bot_username,
|
||||
)
|
||||
|
||||
dp["i18n_instance"] = i18n_instance
|
||||
dp["yookassa_service"] = yookassa_service
|
||||
dp["panel_service"] = panel_service
|
||||
dp["subscription_service"] = subscription_service
|
||||
dp["referral_service"] = referral_service
|
||||
dp["promo_code_service"] = promo_code_service
|
||||
dp["stars_service"] = stars_service
|
||||
dp["tribute_service"] = tribute_service
|
||||
for key, service in services.items():
|
||||
dp[key] = service
|
||||
dp["panel_service"] = services["panel_service"]
|
||||
dp["async_session_factory"] = local_async_session_factory
|
||||
|
||||
dp.update.outer_middleware(DBSessionMiddleware(local_async_session_factory))
|
||||
dp.update.outer_middleware(
|
||||
I18nMiddleware(i18n=i18n_instance, settings=settings_param)
|
||||
)
|
||||
dp.update.outer_middleware(
|
||||
BanCheckMiddleware(settings=settings_param, i18n_instance=i18n_instance)
|
||||
)
|
||||
dp.update.outer_middleware(ActionLoggerMiddleware(settings=settings_param))
|
||||
|
||||
dp.startup.register(on_startup_configured)
|
||||
dp.shutdown.register(lambda: on_shutdown_configured(dp))
|
||||
# Wrap startup/shutdown handlers to satisfy aiogram event signature (no args passed)
|
||||
async def _on_startup_wrapper():
|
||||
await on_startup_configured(dp)
|
||||
async def _on_shutdown_wrapper():
|
||||
await on_shutdown_configured(dp)
|
||||
dp.startup.register(_on_startup_wrapper)
|
||||
dp.shutdown.register(_on_shutdown_wrapper)
|
||||
|
||||
await register_all_routers(dp, settings_param)
|
||||
|
||||
tg_webhook_base = getattr(settings_param, "TELEGRAM_WEBHOOK_BASE_URL", None)
|
||||
yk_webhook_base = getattr(settings_param, "YOOKASSA_WEBHOOK_BASE_URL", None)
|
||||
tg_webhook_base = settings_param.WEBHOOK_BASE_URL
|
||||
|
||||
should_run_aiohttp_server = bool(tg_webhook_base) or (
|
||||
bool(yk_webhook_base) and bool(settings_param.yookassa_webhook_path)
|
||||
)
|
||||
|
||||
telegram_uses_webhook_mode = bool(tg_webhook_base)
|
||||
run_telegram_polling = not telegram_uses_webhook_mode
|
||||
# Webhook mode is now required - exit if not configured
|
||||
if not tg_webhook_base:
|
||||
logging.error("WEBHOOK_BASE_URL is required. Polling mode is disabled. Exiting.")
|
||||
await dp.emit_shutdown()
|
||||
raise SystemExit("WEBHOOK_BASE_URL is required. Polling mode is disabled.")
|
||||
|
||||
logging.info(f"--- Bot Run Mode Decision ---")
|
||||
logging.info(
|
||||
f"Configured TELEGRAM_WEBHOOK_BASE_URL: '{tg_webhook_base}' -> Telegram Webhook Mode: {telegram_uses_webhook_mode}"
|
||||
)
|
||||
logging.info(
|
||||
f"Configured YOOKASSA_WEBHOOK_BASE_URL: '{yk_webhook_base}' & Path: '{settings_param.yookassa_webhook_path}'"
|
||||
)
|
||||
logging.info(f"Decision: Run AIOHTTP server: {should_run_aiohttp_server}")
|
||||
logging.info(f"Decision: Run Telegram Polling: {run_telegram_polling}")
|
||||
logging.info(f"Configured WEBHOOK_BASE_URL: '{tg_webhook_base}' -> Webhook Mode: ENABLED")
|
||||
logging.info(f"YooKassa webhook path: '{settings_param.yookassa_webhook_path}'")
|
||||
logging.info(f"Decision: Run AIOHTTP server: ENABLED (required for webhooks)")
|
||||
logging.info(f"--- End Bot Run Mode Decision ---")
|
||||
|
||||
web_app_runner = None
|
||||
main_tasks = []
|
||||
|
||||
if should_run_aiohttp_server:
|
||||
app = web.Application()
|
||||
app["bot"] = bot
|
||||
app["dp"] = dp
|
||||
app["settings"] = settings_param
|
||||
app["i18n"] = i18n_instance
|
||||
app["async_session_factory"] = local_async_session_factory
|
||||
# Only run AIOHTTP server for webhook mode
|
||||
async def web_server_task():
|
||||
await build_and_start_web_app(dp, bot, settings_param, local_async_session_factory)
|
||||
|
||||
app["yookassa_service"] = yookassa_service
|
||||
app["subscription_service"] = subscription_service
|
||||
app["referral_service"] = referral_service
|
||||
app["panel_service"] = panel_service
|
||||
app["stars_service"] = stars_service
|
||||
app["tribute_service"] = tribute_service
|
||||
main_tasks.append(asyncio.create_task(web_server_task(), name="AIOHTTPServerTask"))
|
||||
|
||||
setup_application(app, dp, bot=bot)
|
||||
# Recurring billing moved to panel webhook (24h before expiry). No periodic task needed here.
|
||||
|
||||
if telegram_uses_webhook_mode:
|
||||
telegram_webhook_path = f"/{settings_param.BOT_TOKEN}"
|
||||
if not telegram_webhook_path.startswith("/"):
|
||||
telegram_webhook_path = "/" + telegram_webhook_path
|
||||
app.router.add_post(
|
||||
telegram_webhook_path, SimpleRequestHandler(dispatcher=dp, bot=bot)
|
||||
)
|
||||
logging.info(
|
||||
f"Telegram webhook route configured at: [POST] {telegram_webhook_path} (relative to base URL)"
|
||||
)
|
||||
|
||||
if yk_webhook_base and settings_param.yookassa_webhook_path:
|
||||
yk_path = settings_param.yookassa_webhook_path
|
||||
if not yk_path or not isinstance(yk_path, str):
|
||||
logging.error(
|
||||
f"YooKassa webhook path is invalid or not configured in settings: {yk_path}. Skipping YooKassa webhook setup."
|
||||
)
|
||||
elif not yk_path.startswith("/"):
|
||||
logging.error(
|
||||
f"CRITICAL: YooKassa webhook path '{yk_path}' from settings does not start with '/'. Correct settings.py or .env. Skipping YooKassa webhook."
|
||||
)
|
||||
else:
|
||||
app.router.add_post(
|
||||
yk_path, user_payment_webhook_module.yookassa_webhook_route
|
||||
)
|
||||
logging.info(f"YooKassa webhook route configured at: [POST] {yk_path}")
|
||||
|
||||
tribute_path = settings_param.tribute_webhook_path
|
||||
if tribute_path.startswith("/"):
|
||||
app.router.add_post(tribute_path, tribute_webhook_route)
|
||||
logging.info(f"Tribute webhook route configured at: [POST] {tribute_path}")
|
||||
|
||||
web_app_runner = web.AppRunner(app)
|
||||
await web_app_runner.setup()
|
||||
site = web.TCPSite(
|
||||
web_app_runner,
|
||||
host=settings_param.WEB_SERVER_HOST,
|
||||
port=settings_param.WEB_SERVER_PORT,
|
||||
)
|
||||
|
||||
async def web_server_task():
|
||||
await site.start()
|
||||
logging.info(
|
||||
f"AIOHTTP server started on http://{settings_param.WEB_SERVER_HOST}:{settings_param.WEB_SERVER_PORT}"
|
||||
)
|
||||
(
|
||||
await asyncio.Event().wait()
|
||||
if not run_telegram_polling
|
||||
else await asyncio.sleep(31536000)
|
||||
)
|
||||
|
||||
main_tasks.append(
|
||||
asyncio.create_task(web_server_task(), name="AIOHTTPServerTask")
|
||||
)
|
||||
|
||||
if run_telegram_polling:
|
||||
logging.info("Starting bot in Telegram Polling mode...")
|
||||
main_tasks.append(
|
||||
asyncio.create_task(
|
||||
dp.start_polling(bot, allowed_updates=dp.resolve_used_update_types()),
|
||||
name="TelegramPollingTask",
|
||||
)
|
||||
)
|
||||
|
||||
if not main_tasks:
|
||||
logging.error(
|
||||
"Bot is not configured for any run mode (neither Webhook nor Polling). Exiting."
|
||||
)
|
||||
await dp.emit_shutdown()
|
||||
return
|
||||
|
||||
logging.info(
|
||||
f"Starting bot with main tasks: {[task.get_name() for task in main_tasks]}"
|
||||
)
|
||||
logging.info("Starting bot in Webhook mode with AIOHTTP server...")
|
||||
logging.info(f"Starting bot with main tasks: {[task.get_name() for task in main_tasks]}")
|
||||
|
||||
try:
|
||||
await asyncio.gather(*main_tasks)
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import logging
|
||||
from typing import Callable, Dict, Any, Awaitable
|
||||
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.types import Update
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
|
||||
class DBSessionMiddleware(BaseMiddleware):
|
||||
|
||||
def __init__(self, async_session_factory: sessionmaker):
|
||||
super().__init__()
|
||||
self.async_session_factory = async_session_factory
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
handler: Callable[[Update, Dict[str, Any]], Awaitable[Any]],
|
||||
event: Update,
|
||||
data: Dict[str, Any],
|
||||
) -> Any:
|
||||
if self.async_session_factory is None:
|
||||
logging.critical("DBSessionMiddleware: async_session_factory is None!")
|
||||
raise RuntimeError(
|
||||
"async_session_factory not provided to DBSessionMiddleware"
|
||||
)
|
||||
|
||||
async with self.async_session_factory() as session:
|
||||
data["session"] = session
|
||||
try:
|
||||
result = await handler(event, data)
|
||||
|
||||
await session.commit()
|
||||
return result
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
"DBSessionMiddleware: Exception caused rollback.", exc_info=True
|
||||
)
|
||||
raise
|
||||
|
||||
+18
-1
@@ -45,10 +45,27 @@ class JsonI18n:
|
||||
exc_info=True)
|
||||
|
||||
def gettext(self, lang_code: Optional[str], key: str, **kwargs) -> str:
|
||||
effective_lang_code = lang_code if lang_code and lang_code in self.locales_data else self.default_lang
|
||||
# Determine effective language with robust fallback
|
||||
if lang_code and lang_code in self.locales_data:
|
||||
effective_lang_code = lang_code
|
||||
elif self.default_lang in self.locales_data:
|
||||
effective_lang_code = self.default_lang
|
||||
elif 'en' in self.locales_data:
|
||||
effective_lang_code = 'en'
|
||||
else:
|
||||
effective_lang_code = lang_code or self.default_lang
|
||||
|
||||
lang_data = self.locales_data.get(effective_lang_code)
|
||||
if lang_data is None:
|
||||
# Try explicit fallback to English if available
|
||||
fallback_data = self.locales_data.get('en')
|
||||
if fallback_data is not None:
|
||||
text = fallback_data.get(key)
|
||||
if text is not None:
|
||||
try:
|
||||
return text.format(**kwargs) if kwargs else text
|
||||
except Exception:
|
||||
return text
|
||||
logging.warning(
|
||||
f"No language data for '{effective_lang_code}' (default '{self.default_lang}' also missing). Key '{key}' will be returned as is."
|
||||
)
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import logging
|
||||
from typing import Callable, Dict, Any, Awaitable, Optional
|
||||
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.types import Update, User as TgUser
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from db.dal import user_dal
|
||||
from bot.utils.text_sanitizer import sanitize_username, sanitize_display_name, username_for_display
|
||||
|
||||
|
||||
class ProfileSyncMiddleware(BaseMiddleware):
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
handler: Callable[[Update, Dict[str, Any]], Awaitable[Any]],
|
||||
event: Update,
|
||||
data: Dict[str, Any],
|
||||
) -> Any:
|
||||
session: AsyncSession = data.get("session")
|
||||
tg_user: Optional[TgUser] = data.get("event_from_user")
|
||||
|
||||
if session and tg_user:
|
||||
try:
|
||||
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.username != sanitized_username:
|
||||
update_payload["username"] = sanitized_username
|
||||
if db_user.first_name != sanitized_first_name:
|
||||
update_payload["first_name"] = sanitized_first_name
|
||||
if db_user.last_name != sanitized_last_name:
|
||||
update_payload["last_name"] = sanitized_last_name
|
||||
|
||||
if 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())}"
|
||||
)
|
||||
|
||||
# Also update description on panel if linked
|
||||
try:
|
||||
panel_service = data.get("panel_service")
|
||||
if panel_service and db_user.panel_user_uuid:
|
||||
description_text = "\n".join([
|
||||
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()
|
||||
await panel_service.update_user_details_on_panel(
|
||||
db_user.panel_user_uuid,
|
||||
{"description": description_text},
|
||||
)
|
||||
except Exception as e_upd_desc:
|
||||
logging.warning(
|
||||
f"ProfileSyncMiddleware: Failed to update panel description for user {tg_user.id}: {e_upd_desc}"
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(
|
||||
f"ProfileSyncMiddleware: Failed to sync profile for user {getattr(tg_user, 'id', 'N/A')}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
return await handler(event, data)
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
from aiogram import Router, F
|
||||
|
||||
from bot.handlers.user import user_router_aggregate
|
||||
from bot.handlers import inline_mode
|
||||
from bot.handlers.admin import admin_router_aggregate
|
||||
from bot.filters.admin_filter import AdminFilter
|
||||
from config.settings import Settings
|
||||
|
||||
|
||||
def build_root_router(settings: Settings) -> Router:
|
||||
root = Router(name="root")
|
||||
|
||||
# Allow all updates only in private chats (messages, callback queries, etc.)
|
||||
root.message.filter(F.chat.type == "private")
|
||||
root.callback_query.filter(F.message.chat.type == "private")
|
||||
|
||||
# Public routers
|
||||
root.include_router(user_router_aggregate)
|
||||
root.include_router(inline_mode.router)
|
||||
|
||||
# Admin routers behind filter
|
||||
admin_main_router = Router(name="admin_main_filtered_router")
|
||||
admin_filter_instance = AdminFilter(admin_ids=settings.ADMIN_IDS)
|
||||
admin_main_router.message.filter(admin_filter_instance)
|
||||
admin_main_router.callback_query.filter(admin_filter_instance)
|
||||
admin_main_router.include_router(admin_router_aggregate)
|
||||
root.include_router(admin_main_router)
|
||||
|
||||
return root
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
import logging
|
||||
import json
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import Bot
|
||||
from aiohttp import web
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from aiocryptopay import AioCryptoPay, Networks
|
||||
from aiocryptopay.models.update import Update
|
||||
|
||||
from config.settings import Settings
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.services.referral_service import ReferralService
|
||||
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
|
||||
from bot.services.notification_service import NotificationService
|
||||
from db.dal import payment_dal, user_dal
|
||||
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
|
||||
|
||||
|
||||
class CryptoPayService:
|
||||
def __init__(
|
||||
self,
|
||||
token: Optional[str],
|
||||
network: str,
|
||||
bot: Bot,
|
||||
settings: Settings,
|
||||
i18n: JsonI18n,
|
||||
async_session_factory: sessionmaker,
|
||||
subscription_service: SubscriptionService,
|
||||
referral_service: ReferralService,
|
||||
):
|
||||
self.bot = bot
|
||||
self.settings = settings
|
||||
self.i18n = i18n
|
||||
self.async_session_factory = async_session_factory
|
||||
self.subscription_service = subscription_service
|
||||
self.referral_service = referral_service
|
||||
if token:
|
||||
net = Networks.TEST_NET if str(network).lower() == "testnet" else Networks.MAIN_NET
|
||||
self.client = AioCryptoPay(token=token, network=net)
|
||||
self.client.register_pay_handler(self._invoice_paid_handler)
|
||||
self.configured = True
|
||||
else:
|
||||
logging.warning("CryptoPay token not provided. CryptoPay disabled")
|
||||
self.client = None
|
||||
self.configured = False
|
||||
|
||||
async def close(self):
|
||||
"""Close underlying AioCryptoPay session if initialized."""
|
||||
if self.client:
|
||||
try:
|
||||
await self.client.close()
|
||||
logging.info("CryptoPay client session closed.")
|
||||
except Exception as e:
|
||||
logging.warning(f"Failed to close CryptoPay client: {e}")
|
||||
|
||||
async def create_invoice(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
months: int,
|
||||
amount: float,
|
||||
description: str,
|
||||
) -> Optional[str]:
|
||||
if not self.configured or not self.client:
|
||||
logging.error("CryptoPayService not configured")
|
||||
return None
|
||||
|
||||
# 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),
|
||||
"currency": self.settings.CRYPTOPAY_ASSET,
|
||||
"status": "pending_cryptopay",
|
||||
"description": description,
|
||||
"subscription_duration_months": months,
|
||||
"provider": "cryptopay",
|
||||
},
|
||||
)
|
||||
await session.commit()
|
||||
except Exception as e_db_create:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"Failed to create cryptopay payment record for user {user_id}: {e_db_create}",
|
||||
exc_info=True,
|
||||
)
|
||||
return None
|
||||
payload = json.dumps({
|
||||
"user_id": str(user_id),
|
||||
"subscription_months": str(months),
|
||||
"payment_db_id": str(payment_record.payment_id),
|
||||
})
|
||||
try:
|
||||
invoice = await self.client.create_invoice(
|
||||
amount=amount,
|
||||
currency_type=self.settings.CRYPTOPAY_CURRENCY_TYPE,
|
||||
fiat=self.settings.CRYPTOPAY_ASSET if self.settings.CRYPTOPAY_CURRENCY_TYPE == "fiat" else None,
|
||||
asset=self.settings.CRYPTOPAY_ASSET if self.settings.CRYPTOPAY_CURRENCY_TYPE == "crypto" else None,
|
||||
description=description,
|
||||
payload=payload,
|
||||
)
|
||||
try:
|
||||
await payment_dal.update_provider_payment_and_status(
|
||||
session,
|
||||
payment_record.payment_id,
|
||||
str(invoice.invoice_id),
|
||||
str(invoice.status),
|
||||
)
|
||||
await session.commit()
|
||||
except Exception as e_db_update:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"Failed to update cryptopay payment record {payment_record.payment_id}: {e_db_update}",
|
||||
exc_info=True,
|
||||
)
|
||||
return None
|
||||
return invoice.bot_invoice_url
|
||||
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):
|
||||
invoice = update.payload
|
||||
if not invoice.payload:
|
||||
logging.warning("CryptoPay webhook without payload")
|
||||
return
|
||||
try:
|
||||
meta = json.loads(invoice.payload)
|
||||
user_id = int(meta["user_id"])
|
||||
months = int(meta["subscription_months"])
|
||||
payment_db_id = int(meta["payment_db_id"])
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to parse CryptoPay payload: {e}")
|
||||
return
|
||||
|
||||
async_session_factory: sessionmaker = app["async_session_factory"]
|
||||
bot: Bot = app["bot"]
|
||||
settings: Settings = app["settings"]
|
||||
i18n: JsonI18n = app["i18n"]
|
||||
subscription_service: SubscriptionService = app["subscription_service"]
|
||||
referral_service: ReferralService = app["referral_service"]
|
||||
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
await payment_dal.update_provider_payment_and_status(
|
||||
session,
|
||||
payment_db_id,
|
||||
str(invoice.invoice_id),
|
||||
"succeeded",
|
||||
)
|
||||
activation = await subscription_service.activate_subscription(
|
||||
session,
|
||||
user_id,
|
||||
months,
|
||||
float(invoice.amount),
|
||||
payment_db_id,
|
||||
provider="cryptopay",
|
||||
)
|
||||
referral_bonus = await referral_service.apply_referral_bonuses_for_payment(
|
||||
session,
|
||||
user_id,
|
||||
months,
|
||||
current_payment_db_id=payment_db_id,
|
||||
skip_if_active_before_payment=False,
|
||||
)
|
||||
await session.commit()
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
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)
|
||||
# Use DB language for user-facing messages
|
||||
lang = db_user.language_code if db_user and db_user.language_code else settings.DEFAULT_LANGUAGE
|
||||
_ = lambda k, **kw: i18n.gettext(lang, k, **kw)
|
||||
|
||||
config_link = activation.get("subscription_url") or _("config_link_not_available")
|
||||
final_end = activation.get("end_date")
|
||||
applied_days = 0
|
||||
if referral_bonus and referral_bonus.get("referee_new_end_date"):
|
||||
final_end = referral_bonus["referee_new_end_date"]
|
||||
applied_days = referral_bonus.get("referee_bonus_applied_days", 0)
|
||||
|
||||
if applied_days:
|
||||
inviter_name_display = _("friend_placeholder")
|
||||
if db_user and db_user.referred_by_id:
|
||||
inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id)
|
||||
if inviter:
|
||||
safe_name = sanitize_display_name(inviter.first_name) if inviter.first_name else None
|
||||
if safe_name:
|
||||
inviter_name_display = safe_name
|
||||
elif inviter.username:
|
||||
inviter_name_display = username_for_display(inviter.username, with_at=False)
|
||||
text = _("payment_successful_with_referral_bonus_full",
|
||||
months=months,
|
||||
base_end_date=activation["end_date"].strftime('%Y-%m-%d'),
|
||||
bonus_days=applied_days,
|
||||
final_end_date=final_end.strftime('%Y-%m-%d'),
|
||||
inviter_name=inviter_name_display,
|
||||
config_link=config_link)
|
||||
else:
|
||||
text = _("payment_successful_full",
|
||||
months=months,
|
||||
end_date=final_end.strftime('%Y-%m-%d'),
|
||||
config_link=config_link)
|
||||
|
||||
markup = get_connect_and_main_keyboard(lang, i18n, settings, config_link)
|
||||
try:
|
||||
await bot.send_message(
|
||||
user_id,
|
||||
text,
|
||||
reply_markup=markup,
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to send CryptoPay success message: {e}")
|
||||
|
||||
# Send notification about payment
|
||||
try:
|
||||
notification_service = NotificationService(bot, settings, i18n)
|
||||
user = await user_dal.get_user_by_id(session, user_id)
|
||||
await notification_service.notify_payment_received(
|
||||
user_id=user_id,
|
||||
amount=float(invoice.amount),
|
||||
currency=invoice.asset or settings.DEFAULT_CURRENCY_SYMBOL,
|
||||
months=months,
|
||||
payment_provider="crypto_pay",
|
||||
username=user.username if user else None
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to send crypto_pay payment notification: {e}")
|
||||
|
||||
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")
|
||||
return await self.client.get_updates(request)
|
||||
|
||||
|
||||
async def cryptopay_webhook_route(request: web.Request) -> web.Response:
|
||||
service: CryptoPayService = request.app["cryptopay_service"]
|
||||
return await service.webhook_route(request)
|
||||
@@ -1,178 +1,320 @@
|
||||
import logging
|
||||
import asyncio
|
||||
from aiogram import Bot
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from aiogram.utils.text_decorations import html_decoration as hd
|
||||
from aiogram.exceptions import TelegramRetryAfter
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, Union, Dict, Any
|
||||
|
||||
from config.settings import Settings
|
||||
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.utils.message_queue import get_queue_manager
|
||||
from bot.utils.text_sanitizer import (
|
||||
display_name_or_fallback,
|
||||
username_for_display,
|
||||
)
|
||||
|
||||
|
||||
async def send_expiration_warnings(bot: Bot, settings: Settings,
|
||||
i18n: JsonI18n,
|
||||
panel_service: PanelApiService,
|
||||
async_session_factory: sessionmaker):
|
||||
class NotificationService:
|
||||
"""Enhanced notification service for sending messages to admins and log channels"""
|
||||
|
||||
def __init__(self, bot: Bot, settings: Settings, i18n: Optional[JsonI18n] = None):
|
||||
self.bot = bot
|
||||
self.settings = settings
|
||||
self.i18n = i18n
|
||||
|
||||
logging.info(
|
||||
f"Scheduler job 'send_expiration_warnings' started at {datetime.now(timezone.utc)} UTC."
|
||||
)
|
||||
|
||||
if async_session_factory is None:
|
||||
logging.error(
|
||||
"NotificationService: AsyncSessionFactory not provided to send_expiration_warnings!"
|
||||
)
|
||||
return
|
||||
|
||||
async with async_session_factory() as session:
|
||||
@staticmethod
|
||||
def _format_user_display(
|
||||
user_id: int,
|
||||
username: Optional[str] = None,
|
||||
first_name: Optional[str] = None,
|
||||
) -> str:
|
||||
base_display = display_name_or_fallback(first_name, f"ID {user_id}")
|
||||
if username:
|
||||
base_display = f"{base_display} ({username_for_display(username)})"
|
||||
return base_display
|
||||
|
||||
async def _send_to_log_channel(self, message: str, thread_id: Optional[int] = None):
|
||||
"""Send message to configured log channel/group using message queue"""
|
||||
if not self.settings.LOG_CHAT_ID:
|
||||
return
|
||||
|
||||
queue_manager = get_queue_manager()
|
||||
if not queue_manager:
|
||||
logging.warning("Message queue manager not available, falling back to direct send")
|
||||
try:
|
||||
await self.bot.send_message(
|
||||
chat_id=self.settings.LOG_CHAT_ID,
|
||||
text=message,
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True,
|
||||
message_thread_id=thread_id or self.settings.LOG_THREAD_ID
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to send notification to log channel {self.settings.LOG_CHAT_ID}: {e}")
|
||||
return
|
||||
|
||||
try:
|
||||
|
||||
sub_service = SubscriptionService(settings, panel_service)
|
||||
|
||||
expiring_subs_details_list = await sub_service.get_subscriptions_ending_soon(
|
||||
session, settings.SUBSCRIPTION_EXPIRATION_NOTIFICATION_DAYS)
|
||||
|
||||
if not expiring_subs_details_list:
|
||||
logging.info(
|
||||
"No subscriptions found ending soon for notification.")
|
||||
return
|
||||
|
||||
logging.info(
|
||||
f"Found {len(expiring_subs_details_list)} subscriptions for expiration warning."
|
||||
)
|
||||
|
||||
for sub_details in expiring_subs_details_list:
|
||||
user_id = sub_details['user_id']
|
||||
user_lang = sub_details.get('language_code',
|
||||
settings.DEFAULT_LANGUAGE)
|
||||
first_name = sub_details.get('first_name', f"User {user_id}")
|
||||
end_date_str_for_msg = sub_details.get('end_date_str', "N/A")
|
||||
days_left_display = sub_details.get('days_left', "N/A")
|
||||
|
||||
subscription_actual_end_date_obj: Optional[
|
||||
datetime] = sub_details.get(
|
||||
'subscription_end_date_iso_for_update')
|
||||
|
||||
_ = lambda key, **kwargs: i18n.gettext(user_lang, key, **kwargs
|
||||
)
|
||||
message_text = _("subscription_ending_soon_notification",
|
||||
user_name=first_name,
|
||||
end_date=end_date_str_for_msg,
|
||||
days_left=days_left_display)
|
||||
# Use thread_id if provided, otherwise use from settings
|
||||
final_thread_id = thread_id or self.settings.LOG_THREAD_ID
|
||||
|
||||
kwargs = {
|
||||
"text": message,
|
||||
"parse_mode": "HTML",
|
||||
"disable_web_page_preview": True
|
||||
}
|
||||
|
||||
# Add thread ID for supergroups if specified
|
||||
if final_thread_id:
|
||||
kwargs["message_thread_id"] = final_thread_id
|
||||
|
||||
# Queue message for sending (groups are rate limited to 15/minute)
|
||||
await queue_manager.send_message(self.settings.LOG_CHAT_ID, **kwargs)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to queue notification to log channel {self.settings.LOG_CHAT_ID}: {e}")
|
||||
|
||||
async def _send_to_admins(self, message: str):
|
||||
"""Send message to all admin users using message queue"""
|
||||
if not self.settings.ADMIN_IDS:
|
||||
return
|
||||
|
||||
queue_manager = get_queue_manager()
|
||||
if not queue_manager:
|
||||
logging.warning("Message queue manager not available, falling back to direct send")
|
||||
for admin_id in self.settings.ADMIN_IDS:
|
||||
try:
|
||||
await bot.send_message(user_id, message_text)
|
||||
logging.info(
|
||||
f"Sent expiration warning to user {user_id} for subscription ending {end_date_str_for_msg}."
|
||||
await self.bot.send_message(
|
||||
chat_id=admin_id,
|
||||
text=message,
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True
|
||||
)
|
||||
|
||||
if subscription_actual_end_date_obj:
|
||||
await sub_service.update_last_notification_sent(
|
||||
session, user_id, subscription_actual_end_date_obj)
|
||||
else:
|
||||
logging.warning(
|
||||
f"Could not find exact subscription end_date_obj for user {user_id} to update notification time."
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(
|
||||
f"Failed to send expiration warning or update notification status for user {user_id}: {e}",
|
||||
exc_info=True)
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
await session.commit()
|
||||
logging.info(
|
||||
"Finished processing expiration warnings. Session committed.")
|
||||
|
||||
except Exception as e_session:
|
||||
logging.error(
|
||||
f"Error during send_expiration_warnings session: {e_session}",
|
||||
exc_info=True)
|
||||
await session.rollback()
|
||||
logging.info(
|
||||
"Session rolled back due to error in send_expiration_warnings."
|
||||
)
|
||||
|
||||
|
||||
async def schedule_subscription_notifications(
|
||||
bot: Bot, settings: Settings, i18n: JsonI18n,
|
||||
scheduler: AsyncIOScheduler, panel_service: PanelApiService,
|
||||
async_session_factory: sessionmaker):
|
||||
|
||||
async def job_wrapper():
|
||||
|
||||
try:
|
||||
await send_expiration_warnings(bot, settings, i18n, panel_service,
|
||||
async_session_factory)
|
||||
except Exception as e:
|
||||
logging.error(
|
||||
f"Unhandled error in scheduled job 'send_expiration_warnings' (job_wrapper): {e}",
|
||||
exc_info=True)
|
||||
|
||||
try:
|
||||
notification_hour = int(settings.SUBSCRIPTION_NOTIFICATION_HOUR_UTC)
|
||||
notification_minute = int(
|
||||
settings.SUBSCRIPTION_NOTIFICATION_MINUTE_UTC)
|
||||
except (ValueError, TypeError):
|
||||
logging.warning(
|
||||
"SUBSCRIPTION_NOTIFICATION_HOUR_UTC or MINUTE_UTC is invalid in settings. Defaulting to 9:00 UTC."
|
||||
logging.error(f"Failed to send notification to admin {admin_id}: {e}")
|
||||
return
|
||||
|
||||
for admin_id in self.settings.ADMIN_IDS:
|
||||
try:
|
||||
await queue_manager.send_message(
|
||||
chat_id=admin_id,
|
||||
text=message,
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True
|
||||
)
|
||||
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,
|
||||
referred_by_id: Optional[int] = None):
|
||||
"""Send notification about new user registration"""
|
||||
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=user_id,
|
||||
username=username,
|
||||
first_name=first_name,
|
||||
)
|
||||
notification_hour = 9
|
||||
notification_minute = 0
|
||||
|
||||
referral_text = ""
|
||||
if referred_by_id:
|
||||
referral_text = _("log_referral_suffix", default=" (реферал от {referrer_id})", referrer_id=referred_by_id)
|
||||
|
||||
message = _(
|
||||
"log_new_user_registration",
|
||||
default="👤 <b>Новый пользователь</b>\n\n"
|
||||
"🆔 ID: <code>{user_id}</code>\n"
|
||||
"👤 Имя: {user_display}{referral_text}\n"
|
||||
"📅 Время: {timestamp}",
|
||||
user_id=user_id,
|
||||
user_display=user_display,
|
||||
referral_text=referral_text,
|
||||
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
)
|
||||
|
||||
# Send to log channel
|
||||
await self._send_to_log_channel(message)
|
||||
|
||||
async def notify_payment_received(self, user_id: int, amount: float, currency: str,
|
||||
months: int, payment_provider: str,
|
||||
username: Optional[str] = None):
|
||||
"""Send notification about successful payment"""
|
||||
if not self.settings.LOG_PAYMENTS:
|
||||
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,
|
||||
)
|
||||
|
||||
provider_emoji = {
|
||||
"yookassa": "💳",
|
||||
"cryptopay": "₿",
|
||||
"stars": "⭐",
|
||||
"tribute": "💎"
|
||||
}.get(payment_provider.lower(), "💰")
|
||||
|
||||
message = _(
|
||||
"log_payment_received",
|
||||
default="{provider_emoji} <b>Получен платеж</b>\n\n"
|
||||
"👤 Пользователь: {user_display}\n"
|
||||
"💰 Сумма: <b>{amount} {currency}</b>\n"
|
||||
"📅 Период: <b>{months} мес.</b>\n"
|
||||
"🏦 Провайдер: {payment_provider}\n"
|
||||
"🕐 Время: {timestamp}",
|
||||
provider_emoji=provider_emoji,
|
||||
user_display=user_display,
|
||||
amount=amount,
|
||||
currency=currency,
|
||||
months=months,
|
||||
payment_provider=payment_provider,
|
||||
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
)
|
||||
|
||||
# Send to log channel
|
||||
await self._send_to_log_channel(message)
|
||||
|
||||
async def notify_promo_activation(self, user_id: int, promo_code: str, bonus_days: int,
|
||||
username: Optional[str] = None):
|
||||
"""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",
|
||||
default="🎁 <b>Активирован промокод</b>\n\n"
|
||||
"👤 Пользователь: {user_display}\n"
|
||||
"🏷 Код: <code>{promo_code}</code>\n"
|
||||
"🎯 Бонус: <b>+{bonus_days} дн.</b>\n"
|
||||
"🕐 Время: {timestamp}",
|
||||
user_display=user_display,
|
||||
promo_code=promo_code,
|
||||
bonus_days=bonus_days,
|
||||
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
)
|
||||
|
||||
# Send to log channel
|
||||
await self._send_to_log_channel(message)
|
||||
|
||||
async def notify_trial_activation(self, user_id: int, end_date: datetime,
|
||||
username: Optional[str] = None):
|
||||
"""Send notification about trial activation"""
|
||||
if not self.settings.LOG_TRIAL_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_trial_activation",
|
||||
default="🆓 <b>Активирован триал</b>\n\n"
|
||||
"👤 Пользователь: {user_display}\n"
|
||||
"⏰ Действует до: <b>{end_date}</b>\n"
|
||||
"🕐 Время: {timestamp}",
|
||||
user_display=user_display,
|
||||
end_date=end_date.strftime("%Y-%m-%d %H:%M"),
|
||||
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
)
|
||||
|
||||
# Send to log channel
|
||||
await self._send_to_log_channel(message)
|
||||
|
||||
scheduler.add_job(job_wrapper,
|
||||
'cron',
|
||||
hour=notification_hour,
|
||||
minute=notification_minute,
|
||||
name="daily_subscription_expiration_warnings_v2",
|
||||
misfire_grace_time=60 * 15,
|
||||
replace_existing=True)
|
||||
logging.info(
|
||||
f"Subscription expiration warning job scheduled daily at {notification_hour:02d}:{notification_minute:02d} UTC."
|
||||
)
|
||||
async def notify_panel_sync(self, status: str, details: str,
|
||||
users_processed: int, subs_synced: int,
|
||||
username: Optional[str] = None):
|
||||
"""Send notification about panel synchronization"""
|
||||
if not getattr(self.settings, 'LOG_PANEL_SYNC', True):
|
||||
return
|
||||
|
||||
admin_lang = self.settings.DEFAULT_LANGUAGE
|
||||
_ = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw) if self.i18n else k
|
||||
|
||||
# Status emoji based on sync result
|
||||
status_emoji = {
|
||||
"completed": "✅",
|
||||
"completed_with_errors": "⚠️",
|
||||
"failed": "❌"
|
||||
}.get(status, "🔄")
|
||||
|
||||
message = _(
|
||||
"log_panel_sync",
|
||||
default="{status_emoji} <b>Синхронизация с панелью</b>\n\n"
|
||||
"📊 Статус: <b>{status}</b>\n"
|
||||
"👥 Обработано пользователей: <b>{users_processed}</b>\n"
|
||||
"📋 Синхронизировано подписок: <b>{subs_synced}</b>\n"
|
||||
"🕐 Время: {timestamp}\n\n"
|
||||
"📝 Детали:\n{details}",
|
||||
status_emoji=status_emoji,
|
||||
status=status,
|
||||
users_processed=users_processed,
|
||||
subs_synced=subs_synced,
|
||||
timestamp=datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S %Z"),
|
||||
details=details
|
||||
)
|
||||
|
||||
# Send to log channel
|
||||
await self._send_to_log_channel(message)
|
||||
|
||||
async def notify_suspicious_promo_attempt(
|
||||
self, user_id: int, suspicious_input: str,
|
||||
username: Optional[str] = None, first_name: Optional[str] = None):
|
||||
"""Send notification about a suspicious promo code attempt."""
|
||||
if not self.settings.LOG_SUSPICIOUS_ACTIVITY:
|
||||
return
|
||||
|
||||
async def notify_admins(bot: Bot, settings: Settings, i18n: JsonI18n,
|
||||
message_key: str, parse_mode: str | None = None,
|
||||
**kwargs) -> None:
|
||||
if not settings.ADMIN_IDS:
|
||||
return
|
||||
admin_lang = settings.DEFAULT_LANGUAGE
|
||||
msg = i18n.gettext(admin_lang, message_key, **kwargs)
|
||||
for admin_id in settings.ADMIN_IDS:
|
||||
try:
|
||||
await bot.send_message(admin_id, msg, parse_mode=parse_mode)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to send admin notification to {admin_id}: {e}")
|
||||
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,
|
||||
first_name=first_name,
|
||||
)
|
||||
|
||||
async def notify_admin_new_trial(bot: Bot, settings: Settings, i18n: JsonI18n,
|
||||
user_id: int, end_date: datetime) -> None:
|
||||
end_date_str = end_date.strftime('%Y-%m-%d') if isinstance(end_date, datetime) else str(end_date)
|
||||
await notify_admins(
|
||||
bot,
|
||||
settings,
|
||||
i18n,
|
||||
"admin_new_trial_notification",
|
||||
user_id=user_id,
|
||||
end_date=end_date_str,
|
||||
)
|
||||
message = _(
|
||||
"log_suspicious_promo",
|
||||
default="⚠️ <b>Подозрительная попытка ввода промокода</b>\n\n"
|
||||
"👤 Пользователь: {user_display}\n"
|
||||
"🆔 ID: <code>{user_id}</code>\n"
|
||||
"📝 Ввод: <pre>{suspicious_input}</pre>\n"
|
||||
"🕐 Время: {timestamp}",
|
||||
user_display=hd.quote(user_display),
|
||||
user_id=user_id,
|
||||
suspicious_input=hd.quote(suspicious_input),
|
||||
timestamp=datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S %Z"))
|
||||
|
||||
# Send to log channel
|
||||
await self._send_to_log_channel(message)
|
||||
|
||||
async def send_custom_notification(self, message: str, to_admins: bool = False,
|
||||
to_log_channel: bool = True, thread_id: Optional[int] = None):
|
||||
"""Send custom notification message"""
|
||||
if to_log_channel:
|
||||
await self._send_to_log_channel(message, thread_id)
|
||||
if to_admins:
|
||||
await self._send_to_admins(message)
|
||||
|
||||
async def notify_admin_new_payment(bot: Bot, settings: Settings, i18n: JsonI18n,
|
||||
user_id: int, months: int, amount: float,
|
||||
currency: str | None = None) -> None:
|
||||
currency_symbol = currency or settings.DEFAULT_CURRENCY_SYMBOL
|
||||
await notify_admins(
|
||||
bot,
|
||||
settings,
|
||||
i18n,
|
||||
"admin_new_payment_notification",
|
||||
user_id=user_id,
|
||||
months=months,
|
||||
amount=f"{amount:.2f}",
|
||||
currency=currency_symbol,
|
||||
)
|
||||
# Removed legacy helper functions that duplicated NotificationService API
|
||||
|
||||
@@ -21,6 +21,14 @@ class PanelApiService:
|
||||
self.api_key = settings.PANEL_API_KEY
|
||||
self._session: Optional[aiohttp.ClientSession] = None
|
||||
self.default_client_ip = "127.0.0.1"
|
||||
|
||||
async def __aenter__(self):
|
||||
"""Context manager entry"""
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
"""Context manager exit - automatically close session"""
|
||||
await self.close_session()
|
||||
|
||||
async def _get_session(self) -> aiohttp.ClientSession:
|
||||
if self._session is None or self._session.closed:
|
||||
@@ -32,7 +40,11 @@ class PanelApiService:
|
||||
if self._session and not self._session.closed:
|
||||
await self._session.close()
|
||||
self._session = None
|
||||
logging.info("Panel API service HTTP session closed.")
|
||||
logging.debug("Panel API service HTTP session closed.")
|
||||
|
||||
async def close(self):
|
||||
"""Alias for close_session for API consistency."""
|
||||
await self.close_session()
|
||||
|
||||
async def _prepare_headers(self) -> Dict[str, str]:
|
||||
headers = {
|
||||
@@ -325,8 +337,7 @@ class PanelApiService:
|
||||
default_expire_days: int = 1,
|
||||
default_traffic_limit_bytes: int = 0,
|
||||
default_traffic_limit_strategy: str = "NO_RESET",
|
||||
specific_inbound_uuids: Optional[List[str]] = None,
|
||||
activate_all_inbounds_default_flag: bool = True,
|
||||
specific_squad_uuids: Optional[List[str]] = None,
|
||||
description: Optional[str] = None,
|
||||
tag: Optional[str] = None,
|
||||
status: str = "ACTIVE",
|
||||
@@ -357,11 +368,8 @@ class PanelApiService:
|
||||
"trafficLimitStrategy": default_traffic_limit_strategy.upper(),
|
||||
"trafficLimitBytes": default_traffic_limit_bytes,
|
||||
}
|
||||
if specific_inbound_uuids:
|
||||
payload["activeUserInbounds"] = specific_inbound_uuids
|
||||
payload["activateAllInbounds"] = False
|
||||
else:
|
||||
payload["activateAllInbounds"] = activate_all_inbounds_default_flag
|
||||
if specific_squad_uuids:
|
||||
payload["activeInternalSquads"] = specific_squad_uuids
|
||||
if telegram_id is not None: payload["telegramId"] = telegram_id
|
||||
if email: payload["email"] = email
|
||||
if description: payload["description"] = description
|
||||
@@ -460,3 +468,25 @@ class PanelApiService:
|
||||
async def get_bot_db_last_sync_status(
|
||||
self, session: AsyncSession) -> Optional[PanelSyncStatus]:
|
||||
return await panel_sync_dal.get_panel_sync_status(session)
|
||||
|
||||
|
||||
async def get_system_stats(self) -> Optional[Dict[str, Any]]:
|
||||
"""Get system statistics (CPU, memory, users counts)"""
|
||||
response_data = await self._request("GET", "/system/stats", log_full_response=False)
|
||||
if response_data and not response_data.get("error") and "response" in response_data:
|
||||
return response_data.get("response")
|
||||
return None
|
||||
|
||||
async def get_bandwidth_stats(self) -> Optional[Dict[str, Any]]:
|
||||
"""Get bandwidth statistics"""
|
||||
response_data = await self._request("GET", "/system/stats/bandwidth", log_full_response=False)
|
||||
if response_data and not response_data.get("error") and "response" in response_data:
|
||||
return response_data.get("response")
|
||||
return None
|
||||
|
||||
async def get_nodes_statistics(self) -> Optional[Dict[str, Any]]:
|
||||
"""Get nodes statistics"""
|
||||
response_data = await self._request("GET", "/system/stats/nodes", log_full_response=False)
|
||||
if response_data and not response_data.get("error") and "response" in response_data:
|
||||
return response_data.get("response")
|
||||
return None
|
||||
@@ -0,0 +1,305 @@
|
||||
import json
|
||||
import logging
|
||||
import hmac
|
||||
import hashlib
|
||||
from aiohttp import web
|
||||
from aiogram import Bot
|
||||
from aiogram.types import InlineKeyboardMarkup
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from typing import Optional
|
||||
from config.settings import Settings
|
||||
from .panel_api_service import PanelApiService
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.keyboards.inline.user_keyboards import get_subscribe_only_markup, get_autorenew_cancel_keyboard
|
||||
from db.dal import user_dal
|
||||
from bot.utils.date_utils import add_months
|
||||
|
||||
EVENT_MAP = {
|
||||
"user.expires_in_72_hours": (3, "subscription_72h_notification"),
|
||||
"user.expires_in_48_hours": (2, "subscription_48h_notification"),
|
||||
"user.expires_in_24_hours": (1, "subscription_24h_notification"),
|
||||
}
|
||||
|
||||
class PanelWebhookService:
|
||||
def __init__(self, bot: Bot, settings: Settings, i18n: JsonI18n, async_session_factory: sessionmaker, panel_service: PanelApiService):
|
||||
self.bot = bot
|
||||
self.settings = settings
|
||||
self.i18n = i18n
|
||||
self.async_session_factory = async_session_factory
|
||||
self.panel_service = panel_service
|
||||
|
||||
async def _send_message(
|
||||
self,
|
||||
user_id: int,
|
||||
lang: str,
|
||||
message_key: str,
|
||||
reply_markup: InlineKeyboardMarkup | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
_ = lambda k, **kw: self.i18n.gettext(lang, k, **kw)
|
||||
try:
|
||||
await self.bot.send_message(
|
||||
user_id, _(message_key, **kwargs), reply_markup=reply_markup
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to send notification to {user_id}: {e}")
|
||||
|
||||
async def _handle_expired_subscription(self, session, user_id: int, user_payload: dict,
|
||||
lang: str, markup, first_name: str) -> bool:
|
||||
"""Handle expired subscription - auto-renew tribute users if no cancellation was received.
|
||||
|
||||
Returns True if an auto-renewal was performed (and renewal message sent), False otherwise.
|
||||
"""
|
||||
from db.dal import subscription_dal, payment_dal
|
||||
from datetime import datetime, timezone
|
||||
|
||||
try:
|
||||
auto_renewed = False
|
||||
# Check if user has tribute subscriptions that weren't cancelled
|
||||
user_subs = await subscription_dal.get_active_subscriptions_for_user(session, user_id)
|
||||
|
||||
for sub in user_subs:
|
||||
# Check if this subscription was marked as cancelled (from tribute cancellation webhook)
|
||||
if sub.status_from_panel == 'CANCELLED':
|
||||
logging.info(f"Subscription {sub.subscription_id} for user {user_id} was cancelled, skipping auto-renewal")
|
||||
continue
|
||||
|
||||
# Check if this user has tribute payments
|
||||
last_tribute_duration = await payment_dal.get_last_tribute_payment_duration(session, user_id)
|
||||
|
||||
if last_tribute_duration is not None:
|
||||
# This user has tribute payments, auto-renew for the same duration
|
||||
logging.info(f"Auto-renewing tribute subscription for user {user_id} for {last_tribute_duration} months")
|
||||
|
||||
# Extend subscription by the last payment duration (calendar months)
|
||||
new_end_date = add_months(datetime.now(timezone.utc), last_tribute_duration)
|
||||
|
||||
# Update local DB subscription
|
||||
await subscription_dal.update_subscription(
|
||||
session,
|
||||
sub.subscription_id,
|
||||
{
|
||||
'end_date': new_end_date,
|
||||
'status_from_panel': 'ACTIVE',
|
||||
'is_active': True
|
||||
}
|
||||
)
|
||||
# Update panel expiry to ensure actual service access is extended
|
||||
try:
|
||||
panel_payload = {
|
||||
"uuid": sub.panel_user_uuid,
|
||||
"expireAt": new_end_date.isoformat(timespec='milliseconds').replace('+00:00', 'Z'),
|
||||
"status": "ACTIVE",
|
||||
}
|
||||
panel_update_resp = await self.panel_service.update_user_details_on_panel(
|
||||
sub.panel_user_uuid,
|
||||
panel_payload,
|
||||
log_response=True,
|
||||
)
|
||||
if panel_update_resp:
|
||||
logging.info(
|
||||
f"Panel expiry updated for user {user_id} (panel_uuid {sub.panel_user_uuid}) to {new_end_date}"
|
||||
)
|
||||
except Exception as e_panel:
|
||||
logging.error(
|
||||
f"Failed to update panel expiry for user {user_id} (panel_uuid {sub.panel_user_uuid}): {e_panel}")
|
||||
|
||||
# Create a succeeded payment record in DB with the same amount/currency as last tribute payment
|
||||
try:
|
||||
last_payment = await payment_dal.get_last_tribute_payment(session, user_id)
|
||||
if last_payment and last_payment.amount and last_payment.currency:
|
||||
provider_payment_id = (
|
||||
f"tribute_auto_{user_id}_{sub.subscription_id}_"
|
||||
f"{new_end_date.strftime('%Y%m%d')}"
|
||||
)
|
||||
created_payment = await payment_dal.ensure_payment_with_provider_id(
|
||||
session,
|
||||
user_id=user_id,
|
||||
amount=float(last_payment.amount),
|
||||
currency=last_payment.currency,
|
||||
months=last_tribute_duration,
|
||||
description="Auto-renewal (panel webhook)",
|
||||
provider="tribute",
|
||||
provider_payment_id=provider_payment_id,
|
||||
)
|
||||
if created_payment:
|
||||
logging.info(
|
||||
f"Auto-renew payment recorded (id={created_payment.payment_id}) for user {user_id} amount={created_payment.amount} {created_payment.currency} months={last_tribute_duration}"
|
||||
)
|
||||
else:
|
||||
logging.warning(
|
||||
f"Could not create auto-renew payment for user {user_id}: previous tribute payment not found or missing amount/currency")
|
||||
except Exception as e_pay:
|
||||
logging.error(
|
||||
f"Failed to create auto-renew payment record for user {user_id}: {e_pay}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# Send auto-renewal notification
|
||||
_ = lambda k, **kw: self.i18n.gettext(lang, k, **kw) if self.i18n else k
|
||||
auto_renewal_msg = _(
|
||||
"tribute_auto_renewal",
|
||||
default="🔄 <b>Подписка автоматически продлена</b>\n\n"
|
||||
"Ваша подписка Tribute была автоматически продлена на {months} мес.\n"
|
||||
"Новая дата окончания: {end_date}",
|
||||
user_name=first_name,
|
||||
months=last_tribute_duration,
|
||||
end_date=new_end_date.strftime('%Y-%m-%d')
|
||||
)
|
||||
|
||||
try:
|
||||
await self.bot.send_message(
|
||||
user_id,
|
||||
auto_renewal_msg,
|
||||
reply_markup=markup,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
auto_renewed = True
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to send auto-renewal notification to user {user_id}: {e}")
|
||||
|
||||
await session.commit()
|
||||
return auto_renewed
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error handling expired subscription for user {user_id}: {e}")
|
||||
await session.rollback()
|
||||
return False
|
||||
|
||||
async def handle_event(self, event_name: str, user_payload: dict):
|
||||
telegram_id = user_payload.get("telegramId")
|
||||
if not telegram_id:
|
||||
logging.warning("Panel webhook without telegramId received")
|
||||
return
|
||||
user_id = int(telegram_id)
|
||||
|
||||
if not self.settings.SUBSCRIPTION_NOTIFICATIONS_ENABLED:
|
||||
return
|
||||
|
||||
async with self.async_session_factory() as session:
|
||||
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}"
|
||||
|
||||
markup = get_subscribe_only_markup(lang, self.i18n)
|
||||
|
||||
if event_name in EVENT_MAP:
|
||||
days_left, msg_key = EVENT_MAP[event_name]
|
||||
if days_left == 1:
|
||||
# Trigger auto-renew via SubscriptionService (wired in at factory)
|
||||
try:
|
||||
subscription_service = getattr(self, "subscription_service", None)
|
||||
if subscription_service:
|
||||
async with self.async_session_factory() as session:
|
||||
from db.dal import subscription_dal
|
||||
sub = await subscription_dal.get_active_subscription_by_user_id(session, user_id)
|
||||
if sub and sub.auto_renew_enabled and sub.provider != 'tribute':
|
||||
try:
|
||||
ok = await subscription_service.charge_subscription_renewal(session, sub)
|
||||
# If initiation succeeded, suppress the 24h reminder by returning early
|
||||
if ok:
|
||||
await session.commit()
|
||||
return
|
||||
else:
|
||||
await session.rollback()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logging.exception("Auto-renew attempt (24h) failed")
|
||||
except Exception:
|
||||
logging.exception("Auto-renew trigger (24h) failed pre-check")
|
||||
if days_left <= self.settings.SUBSCRIPTION_NOTIFY_DAYS_BEFORE:
|
||||
# For 48h event, if auto-renew is enabled and not tribute, show special notice with cancel button
|
||||
if days_left == 2:
|
||||
async with self.async_session_factory() as session:
|
||||
from db.dal import subscription_dal
|
||||
sub = await subscription_dal.get_active_subscription_by_user_id(session, user_id)
|
||||
logging.info(
|
||||
"48h webhook check: user_id=%s sub_found=%s auto_renew=%s provider=%s",
|
||||
user_id,
|
||||
bool(sub),
|
||||
getattr(sub, 'auto_renew_enabled', None) if sub else None,
|
||||
getattr(sub, 'provider', None) if sub else None,
|
||||
)
|
||||
if sub and sub.auto_renew_enabled and sub.provider != 'tribute':
|
||||
cancel_kb = get_autorenew_cancel_keyboard(lang, self.i18n)
|
||||
await self._send_message(
|
||||
user_id,
|
||||
lang,
|
||||
"autorenew_48h_charge_tomorrow_notice",
|
||||
reply_markup=cancel_kb,
|
||||
user_name=first_name,
|
||||
)
|
||||
return
|
||||
await self._send_message(
|
||||
user_id,
|
||||
lang,
|
||||
msg_key,
|
||||
reply_markup=markup,
|
||||
user_name=first_name,
|
||||
end_date=user_payload.get("expireAt", "")[:10],
|
||||
)
|
||||
elif event_name == "user.expired":
|
||||
# Check if this is a tribute user that should be auto-renewed (regardless of notification settings)
|
||||
auto_renewed = await self._handle_expired_subscription(session, user_id, user_payload, lang, markup, first_name)
|
||||
|
||||
# If auto-renewed via Tribute, suppress expiration notification. Otherwise, send it if enabled.
|
||||
if not auto_renewed and self.settings.SUBSCRIPTION_NOTIFY_ON_EXPIRE:
|
||||
await self._send_message(
|
||||
user_id,
|
||||
lang,
|
||||
"subscription_expired_notification",
|
||||
reply_markup=markup,
|
||||
user_name=first_name,
|
||||
end_date=user_payload.get("expireAt", "")[:10],
|
||||
)
|
||||
elif event_name == "user.expired_24_hours_ago" and self.settings.SUBSCRIPTION_NOTIFY_AFTER_EXPIRE:
|
||||
await self._send_message(
|
||||
user_id,
|
||||
lang,
|
||||
"subscription_expired_yesterday_notification",
|
||||
reply_markup=markup,
|
||||
user_name=first_name,
|
||||
end_date=user_payload.get("expireAt", "")[:10],
|
||||
)
|
||||
|
||||
async def handle_webhook(self, raw_body: bytes, signature_header: Optional[str]) -> web.Response:
|
||||
if self.settings.PANEL_WEBHOOK_SECRET:
|
||||
if not signature_header:
|
||||
return web.Response(status=403, text="no_signature")
|
||||
expected_sig = hmac.new(
|
||||
self.settings.PANEL_WEBHOOK_SECRET.encode(),
|
||||
raw_body,
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
if not hmac.compare_digest(expected_sig, signature_header):
|
||||
return web.Response(status=403, text="invalid_signature")
|
||||
|
||||
try:
|
||||
payload = json.loads(raw_body.decode())
|
||||
except Exception:
|
||||
return web.Response(status=400, text="bad_request")
|
||||
|
||||
event_name = payload.get("name") or payload.get("event")
|
||||
user_data = payload.get("payload") or payload.get("data", {})
|
||||
if isinstance(user_data, dict) and "user" in user_data:
|
||||
user_data = user_data.get("user") or user_data
|
||||
|
||||
telegram_id = user_data.get("telegramId") if isinstance(user_data, dict) else None
|
||||
|
||||
if not event_name:
|
||||
return web.Response(status=200, text="ok_no_event")
|
||||
|
||||
logging.info(
|
||||
"Panel webhook event received: %s; telegramId=%s",
|
||||
event_name,
|
||||
telegram_id if telegram_id is not None else "N/A",
|
||||
)
|
||||
|
||||
await self.handle_event(event_name, user_data)
|
||||
return web.Response(status=200, text="ok")
|
||||
|
||||
async def panel_webhook_route(request: web.Request):
|
||||
service: PanelWebhookService = request.app["panel_webhook_service"]
|
||||
raw = await request.read()
|
||||
signature_header = request.headers.get("X-Remnawave-Signature")
|
||||
return await service.handle_webhook(raw, signature_header)
|
||||
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from typing import Optional, Tuple, Dict
|
||||
from aiogram import Bot
|
||||
@@ -10,6 +11,7 @@ from db.models import PromoCode, User
|
||||
|
||||
from .subscription_service import SubscriptionService
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from .notification_service import NotificationService
|
||||
|
||||
|
||||
class PromoCodeService:
|
||||
@@ -22,9 +24,13 @@ class PromoCodeService:
|
||||
self.bot = bot
|
||||
self.i18n = i18n
|
||||
|
||||
async def apply_promo_code(self, session: AsyncSession, user_id: int,
|
||||
code_input: str,
|
||||
user_lang: str) -> Tuple[bool, str]:
|
||||
async def apply_promo_code(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
code_input: str,
|
||||
user_lang: str,
|
||||
) -> Tuple[bool, datetime | str]:
|
||||
_ = lambda k, **kw: self.i18n.gettext(user_lang, k, **kw)
|
||||
code_input_upper = code_input.strip().upper()
|
||||
|
||||
@@ -49,18 +55,26 @@ class PromoCodeService:
|
||||
reason=f"promo code {code_input_upper}")
|
||||
|
||||
if new_end_date:
|
||||
|
||||
activation_recorded = await promo_code_dal.record_promo_activation(
|
||||
session, promo_data.promo_code_id, user_id, payment_id=None)
|
||||
promo_incremented = await promo_code_dal.increment_promo_code_usage(
|
||||
session, promo_data.promo_code_id)
|
||||
|
||||
if activation_recorded and promo_incremented:
|
||||
|
||||
return True, _("promo_code_applied_success",
|
||||
code=code_input_upper,
|
||||
bonus_days=bonus_days,
|
||||
new_end_date=new_end_date.strftime('%Y-%m-%d'))
|
||||
# Send notification about promo activation
|
||||
try:
|
||||
notification_service = NotificationService(self.bot, self.settings, self.i18n)
|
||||
user = await user_dal.get_user_by_id(session, user_id)
|
||||
await notification_service.notify_promo_activation(
|
||||
user_id=user_id,
|
||||
promo_code=code_input_upper,
|
||||
bonus_days=bonus_days,
|
||||
username=user.username if user else None
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to send promo activation notification: {e}")
|
||||
|
||||
return True, new_end_date
|
||||
else:
|
||||
|
||||
logging.error(
|
||||
@@ -68,5 +82,4 @@ class PromoCodeService:
|
||||
)
|
||||
return False, _("error_applying_promo_bonus")
|
||||
else:
|
||||
|
||||
return False, _("error_applying_promo_bonus")
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import logging
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import text
|
||||
from typing import Optional, Dict, Any
|
||||
from aiogram import Bot
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
from config.settings import Settings
|
||||
from db.dal import user_dal
|
||||
from db.dal import payment_dal
|
||||
from db.models import User
|
||||
from db.dal import subscription_dal
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
@@ -23,8 +25,12 @@ class ReferralService:
|
||||
self.i18n = i18n
|
||||
|
||||
async def apply_referral_bonuses_for_payment(
|
||||
self, session: AsyncSession, referee_user_id: int,
|
||||
purchased_subscription_months: int) -> Dict[str, Any]:
|
||||
self,
|
||||
session: AsyncSession,
|
||||
referee_user_id: int,
|
||||
purchased_subscription_months: int,
|
||||
current_payment_db_id: Optional[int] = None,
|
||||
skip_if_active_before_payment: bool = True) -> Dict[str, Any]:
|
||||
|
||||
referee_final_end_date: Optional[datetime] = None
|
||||
referee_bonus_applied_days: Optional[int] = None
|
||||
@@ -42,6 +48,39 @@ class ReferralService:
|
||||
"referee_new_end_date": None
|
||||
}
|
||||
|
||||
# If configured to apply referral bonuses only once per invited user,
|
||||
# check if the referee already has succeeded payments.
|
||||
# Use getattr with a safe default (True) to avoid AttributeError if
|
||||
# running with an older settings schema.
|
||||
if getattr(self.settings, "REFERRAL_ONE_BONUS_PER_REFEREE", True):
|
||||
try:
|
||||
succeeded_count = await payment_dal.count_user_succeeded_payments(
|
||||
session, referee_user_id, exclude_payment_id=current_payment_db_id
|
||||
)
|
||||
if succeeded_count and succeeded_count > 0:
|
||||
logging.info(
|
||||
f"Referral bonuses skipped for user {referee_user_id}: already has {succeeded_count} succeeded payments.")
|
||||
return {
|
||||
"referee_bonus_applied_days": None,
|
||||
"referee_new_end_date": None
|
||||
}
|
||||
except Exception as e_cnt:
|
||||
logging.error(f"Failed counting succeeded payments for user {referee_user_id}: {e_cnt}")
|
||||
|
||||
# Additionally, do not award referral bonuses if the user was active at payment time
|
||||
# (has an active subscription now). This avoids giving bonuses to already active users.
|
||||
if skip_if_active_before_payment:
|
||||
try:
|
||||
if await self.subscription_service.has_active_subscription(session, referee_user_id):
|
||||
logging.info(
|
||||
f"Referral bonuses skipped for user {referee_user_id}: user currently has an active subscription.")
|
||||
return {
|
||||
"referee_bonus_applied_days": None,
|
||||
"referee_new_end_date": None
|
||||
}
|
||||
except Exception as e_sub:
|
||||
logging.error(f"Failed to check active subscription for {referee_user_id}: {e_sub}")
|
||||
|
||||
inviter_user_id = referee_user_model.referred_by_id
|
||||
inviter_user_model = await user_dal.get_user_by_id(
|
||||
session, inviter_user_id)
|
||||
@@ -219,3 +258,39 @@ class ReferralService:
|
||||
def generate_referral_link(self, bot_username: str,
|
||||
inviter_user_id: int) -> str:
|
||||
return f"https://t.me/{bot_username}?start=ref_{inviter_user_id}"
|
||||
|
||||
async def get_referral_stats(self, session: AsyncSession, user_id: int) -> dict:
|
||||
"""Get referral statistics for a user"""
|
||||
from db.dal import user_dal, payment_dal
|
||||
|
||||
try:
|
||||
# Count total invited users (referrals)
|
||||
invited_count_result = await session.execute(
|
||||
text("SELECT COUNT(*) FROM users WHERE referred_by_id = :user_id"),
|
||||
{"user_id": user_id}
|
||||
)
|
||||
invited_count = invited_count_result.scalar() or 0
|
||||
|
||||
# Count users who made successful payments (purchased subscription)
|
||||
purchased_count_result = await session.execute(
|
||||
text("""
|
||||
SELECT COUNT(DISTINCT u.user_id)
|
||||
FROM users u
|
||||
JOIN payments p ON u.user_id = p.user_id
|
||||
WHERE u.referred_by_id = :user_id
|
||||
AND p.status = 'succeeded'
|
||||
"""),
|
||||
{"user_id": user_id}
|
||||
)
|
||||
purchased_count = purchased_count_result.scalar() or 0
|
||||
|
||||
return {
|
||||
"invited_count": invited_count,
|
||||
"purchased_count": purchased_count
|
||||
}
|
||||
except Exception as e:
|
||||
logging.error(f"Error getting referral stats for user {user_id}: {e}")
|
||||
return {
|
||||
"invited_count": 0,
|
||||
"purchased_count": 0
|
||||
}
|
||||
|
||||
@@ -10,6 +10,9 @@ from db.dal import payment_dal, user_dal
|
||||
from .subscription_service import SubscriptionService
|
||||
from .referral_service import ReferralService
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from .notification_service import NotificationService
|
||||
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
|
||||
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
|
||||
|
||||
|
||||
class StarsService:
|
||||
@@ -94,7 +97,12 @@ class StarsService:
|
||||
return
|
||||
|
||||
referral_bonus = await self.referral_service.apply_referral_bonuses_for_payment(
|
||||
session, message.from_user.id, months)
|
||||
session,
|
||||
message.from_user.id,
|
||||
months,
|
||||
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
|
||||
@@ -102,34 +110,69 @@ class StarsService:
|
||||
if not final_end:
|
||||
final_end = activation_details["end_date"]
|
||||
|
||||
current_lang = i18n_data.get("current_language",
|
||||
self.settings.DEFAULT_LANGUAGE)
|
||||
# Always use user's language from DB for user-facing messages
|
||||
db_user = await user_dal.get_user_by_id(session, message.from_user.id)
|
||||
current_lang = db_user.language_code if db_user and db_user.language_code else self.settings.DEFAULT_LANGUAGE
|
||||
i18n: JsonI18n = i18n_data.get("i18n_instance")
|
||||
_ = lambda k, **kw: i18n.gettext(current_lang, k, **kw) if i18n else k
|
||||
|
||||
config_link = activation_details.get("subscription_url") or _(
|
||||
"config_link_not_available"
|
||||
)
|
||||
|
||||
if applied_days:
|
||||
inviter_name_display = _("friend_placeholder")
|
||||
db_user = await user_dal.get_user_by_id(session, message.from_user.id)
|
||||
if db_user and db_user.referred_by_id:
|
||||
inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id)
|
||||
if inviter and inviter.first_name:
|
||||
inviter_name_display = inviter.first_name
|
||||
elif inviter and inviter.username:
|
||||
inviter_name_display = f"@{inviter.username}"
|
||||
if inviter:
|
||||
safe_name = sanitize_display_name(inviter.first_name) if inviter.first_name else None
|
||||
if safe_name:
|
||||
inviter_name_display = safe_name
|
||||
elif inviter.username:
|
||||
inviter_name_display = username_for_display(inviter.username, with_at=False)
|
||||
success_msg = _(
|
||||
"payment_successful_with_referral_bonus",
|
||||
"payment_successful_with_referral_bonus_full",
|
||||
months=months,
|
||||
base_end_date=activation_details["end_date"].strftime('%Y-%m-%d'),
|
||||
bonus_days=applied_days,
|
||||
final_end_date=final_end.strftime('%Y-%m-%d'),
|
||||
inviter_name=inviter_name_display,
|
||||
config_link=config_link,
|
||||
)
|
||||
else:
|
||||
success_msg = _("payment_successful", months=months,
|
||||
end_date=final_end.strftime('%Y-%m-%d'))
|
||||
success_msg = _(
|
||||
"payment_successful_full",
|
||||
months=months,
|
||||
end_date=final_end.strftime('%Y-%m-%d'),
|
||||
config_link=config_link,
|
||||
)
|
||||
markup = get_connect_and_main_keyboard(
|
||||
current_lang, i18n, self.settings, config_link
|
||||
)
|
||||
try:
|
||||
await self.bot.send_message(message.from_user.id, success_msg)
|
||||
await self.bot.send_message(
|
||||
message.from_user.id,
|
||||
success_msg,
|
||||
reply_markup=markup,
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
except Exception as e_send:
|
||||
logging.error(
|
||||
f"Failed to send stars payment success message: {e_send}")
|
||||
|
||||
# Send notification about payment
|
||||
try:
|
||||
notification_service = NotificationService(self.bot, self.settings, self.i18n)
|
||||
user = await user_dal.get_user_by_id(session, message.from_user.id)
|
||||
await notification_service.notify_payment_received(
|
||||
user_id=message.from_user.id,
|
||||
amount=float(stars_amount),
|
||||
currency="XTR",
|
||||
months=months,
|
||||
payment_provider="stars",
|
||||
username=user.username if user else None
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to send stars payment notification: {e}")
|
||||
|
||||
@@ -5,7 +5,8 @@ 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
|
||||
from db.dal import user_dal, subscription_dal, promo_code_dal, payment_dal, user_billing_dal
|
||||
from bot.utils.date_utils import add_months
|
||||
from db.models import User, Subscription
|
||||
|
||||
from config.settings import Settings
|
||||
@@ -34,12 +35,25 @@ class SubscriptionService:
|
||||
else self.settings.DEFAULT_LANGUAGE
|
||||
)
|
||||
|
||||
async def has_had_any_subscription(
|
||||
self, session: AsyncSession, user_id: int
|
||||
) -> bool:
|
||||
|
||||
async def has_had_any_subscription(self, session: AsyncSession, user_id: int) -> bool:
|
||||
return await subscription_dal.has_any_subscription_for_user(session, user_id)
|
||||
|
||||
async def has_active_subscription(self, session: AsyncSession, user_id: int) -> bool:
|
||||
"""Return True if user currently has an active subscription (end_date in future)."""
|
||||
try:
|
||||
user_record = await user_dal.get_user_by_id(session, user_id)
|
||||
if not user_record or not user_record.panel_user_uuid:
|
||||
return False
|
||||
active_sub = await subscription_dal.get_active_subscription_by_user_id(
|
||||
session, user_id, user_record.panel_user_uuid
|
||||
)
|
||||
if not active_sub or not active_sub.end_date:
|
||||
return False
|
||||
from datetime import datetime, timezone
|
||||
return active_sub.is_active and active_sub.end_date > datetime.now(timezone.utc)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
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
|
||||
@@ -105,7 +119,12 @@ class SubscriptionService:
|
||||
creation_response = await self.panel_service.create_panel_user(
|
||||
username_on_panel=panel_username_on_panel_standard,
|
||||
telegram_id=user_id,
|
||||
specific_inbound_uuids=self.settings.parsed_user_inbound_uuids,
|
||||
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,
|
||||
default_traffic_limit_bytes=self.settings.user_traffic_limit_bytes,
|
||||
default_traffic_limit_strategy=self.settings.USER_TRAFFIC_STRATEGY,
|
||||
)
|
||||
@@ -128,7 +147,12 @@ class SubscriptionService:
|
||||
creation_response = await self.panel_service.create_panel_user(
|
||||
username_on_panel=panel_username_on_panel_standard,
|
||||
telegram_id=user_id,
|
||||
specific_inbound_uuids=self.settings.parsed_user_inbound_uuids,
|
||||
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,
|
||||
default_traffic_limit_bytes=self.settings.user_traffic_limit_bytes,
|
||||
default_traffic_limit_strategy=self.settings.USER_TRAFFIC_STRATEGY,
|
||||
)
|
||||
@@ -219,23 +243,10 @@ class SubscriptionService:
|
||||
"panel_user_uuid": actual_panel_uuid_from_api
|
||||
}
|
||||
|
||||
if (
|
||||
actual_panel_username_from_api
|
||||
and actual_panel_username_from_api
|
||||
!= panel_username_on_panel_standard
|
||||
and (
|
||||
db_user.username is None
|
||||
or db_user.username != actual_panel_username_from_api
|
||||
)
|
||||
):
|
||||
update_data_for_local_user["username"] = (
|
||||
actual_panel_username_from_api
|
||||
)
|
||||
|
||||
# Do not overwrite Telegram username with panel username.
|
||||
# Only update the local linkage to panel UUID here.
|
||||
await user_dal.update_user(session, user_id, update_data_for_local_user)
|
||||
db_user.panel_user_uuid = actual_panel_uuid_from_api
|
||||
if "username" in update_data_for_local_user:
|
||||
db_user.username = update_data_for_local_user["username"]
|
||||
panel_user_created_or_linked_now = True
|
||||
current_local_panel_uuid = actual_panel_uuid_from_api
|
||||
else:
|
||||
@@ -257,8 +268,19 @@ class SubscriptionService:
|
||||
logging.info(
|
||||
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, {"telegramId": user_id}
|
||||
current_local_panel_uuid,
|
||||
{
|
||||
"telegramId": user_id,
|
||||
"description": "\n".join(
|
||||
[
|
||||
(db_user.username or "") if db_user else "",
|
||||
(db_user.first_name or "") if db_user else "",
|
||||
(db_user.last_name or "") if db_user else "",
|
||||
]
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
panel_sub_link_id = panel_user_obj_from_api.get(
|
||||
@@ -348,21 +370,21 @@ class SubscriptionService:
|
||||
"message_key": "trial_activation_failed_db",
|
||||
}
|
||||
|
||||
panel_update_payload: Dict[str, Any] = {
|
||||
"uuid": panel_user_uuid,
|
||||
"expireAt": end_date.isoformat(timespec="milliseconds").replace(
|
||||
"+00:00", "Z"
|
||||
),
|
||||
"status": "ACTIVE",
|
||||
"trafficLimitBytes": self.settings.trial_traffic_limit_bytes,
|
||||
"trafficLimitStrategy": self.settings.USER_TRAFFIC_STRATEGY,
|
||||
}
|
||||
if self.settings.parsed_user_inbound_uuids:
|
||||
panel_update_payload["activeUserInbounds"] = (
|
||||
self.settings.parsed_user_inbound_uuids
|
||||
)
|
||||
elif panel_user_created_now:
|
||||
panel_update_payload["activateAllInbounds"] = True
|
||||
panel_update_payload = self._build_panel_update_payload(
|
||||
panel_user_uuid=panel_user_uuid,
|
||||
expire_at=end_date,
|
||||
status="ACTIVE",
|
||||
traffic_limit_bytes=self.settings.trial_traffic_limit_bytes,
|
||||
)
|
||||
|
||||
# Add user description based on Telegram profile
|
||||
panel_update_payload["description"] = "\n".join(
|
||||
[
|
||||
(db_user.username or "") if db_user else "",
|
||||
(db_user.first_name or "") if db_user else "",
|
||||
(db_user.last_name or "") if db_user else "",
|
||||
]
|
||||
)
|
||||
|
||||
updated_panel_user = await self.panel_service.update_user_details_on_panel(
|
||||
panel_user_uuid, panel_update_payload
|
||||
@@ -433,7 +455,9 @@ class SubscriptionService:
|
||||
):
|
||||
start_date = current_active_sub.end_date
|
||||
|
||||
duration_days_total = months * 30
|
||||
# base duration by months
|
||||
end_after_months = add_months(start_date, months)
|
||||
duration_days_total = (end_after_months - start_date).days
|
||||
applied_promo_bonus_days = 0
|
||||
|
||||
if promo_code_id_from_payment:
|
||||
@@ -484,7 +508,8 @@ class SubscriptionService:
|
||||
"status_from_panel": "ACTIVE",
|
||||
"traffic_limit_bytes": self.settings.user_traffic_limit_bytes,
|
||||
"provider": provider,
|
||||
"skip_notifications": provider == "tribute",
|
||||
"skip_notifications": provider == "tribute" and self.settings.TRIBUTE_SKIP_NOTIFICATIONS,
|
||||
"auto_renew_enabled": True,
|
||||
}
|
||||
try:
|
||||
new_or_updated_sub = await subscription_dal.upsert_subscription(
|
||||
@@ -497,21 +522,21 @@ class SubscriptionService:
|
||||
)
|
||||
return None
|
||||
|
||||
panel_update_payload = {
|
||||
"uuid": panel_user_uuid,
|
||||
"expireAt": final_end_date.isoformat(timespec="milliseconds").replace(
|
||||
"+00:00", "Z"
|
||||
),
|
||||
"status": "ACTIVE",
|
||||
"trafficLimitBytes": self.settings.user_traffic_limit_bytes,
|
||||
"trafficLimitStrategy": self.settings.USER_TRAFFIC_STRATEGY,
|
||||
}
|
||||
if self.settings.parsed_user_inbound_uuids:
|
||||
panel_update_payload["activeUserInbounds"] = (
|
||||
self.settings.parsed_user_inbound_uuids
|
||||
)
|
||||
elif panel_user_created_now:
|
||||
panel_update_payload["activateAllInbounds"] = True
|
||||
panel_update_payload = self._build_panel_update_payload(
|
||||
panel_user_uuid=panel_user_uuid,
|
||||
expire_at=final_end_date,
|
||||
status="ACTIVE",
|
||||
traffic_limit_bytes=self.settings.user_traffic_limit_bytes,
|
||||
)
|
||||
|
||||
# Add user description based on Telegram profile
|
||||
panel_update_payload["description"] = "\n".join(
|
||||
[
|
||||
(db_user.username or "") if db_user else "",
|
||||
(db_user.first_name or "") if db_user else "",
|
||||
(db_user.last_name or "") if db_user else "",
|
||||
]
|
||||
)
|
||||
|
||||
updated_panel_user = await self.panel_service.update_user_details_on_panel(
|
||||
panel_user_uuid, panel_update_payload
|
||||
@@ -567,6 +592,10 @@ class SubscriptionService:
|
||||
)
|
||||
start_date = datetime.now(timezone.utc)
|
||||
new_end_date_obj = start_date + timedelta(days=bonus_days)
|
||||
|
||||
# For promo code activations, use the configured user traffic limit
|
||||
traffic_limit = self.settings.user_traffic_limit_bytes if "promo code" in reason.lower() else self.settings.trial_traffic_limit_bytes
|
||||
|
||||
bonus_sub_payload = {
|
||||
"user_id": user_id,
|
||||
"panel_user_uuid": panel_uuid,
|
||||
@@ -576,7 +605,7 @@ class SubscriptionService:
|
||||
"duration_months": 0,
|
||||
"is_active": True,
|
||||
"status_from_panel": "ACTIVE_BONUS",
|
||||
"traffic_limit_bytes": self.settings.user_traffic_limit_bytes,
|
||||
"traffic_limit_bytes": traffic_limit,
|
||||
}
|
||||
await subscription_dal.deactivate_other_active_subscriptions(
|
||||
session, panel_uuid, panel_sub_uuid
|
||||
@@ -597,14 +626,19 @@ class SubscriptionService:
|
||||
)
|
||||
|
||||
if updated_sub_model:
|
||||
# Prepare panel update payload
|
||||
panel_update_payload = self._build_panel_update_payload(
|
||||
expire_at=new_end_date_obj,
|
||||
traffic_limit_bytes=(
|
||||
self.settings.user_traffic_limit_bytes if "promo code" in reason.lower() else None
|
||||
),
|
||||
include_uuid=False,
|
||||
)
|
||||
|
||||
panel_update_success = (
|
||||
await self.panel_service.update_user_details_on_panel(
|
||||
panel_uuid,
|
||||
{
|
||||
"expireAt": new_end_date_obj.isoformat(
|
||||
timespec="milliseconds"
|
||||
).replace("+00:00", "Z")
|
||||
},
|
||||
panel_update_payload,
|
||||
)
|
||||
)
|
||||
if not panel_update_success:
|
||||
@@ -747,6 +781,62 @@ class SubscriptionService:
|
||||
)
|
||||
return results
|
||||
|
||||
async def charge_subscription_renewal(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
sub: Subscription,
|
||||
) -> bool:
|
||||
"""Attempt to charge user using saved payment method. Return True on initiated/handled, False on failure."""
|
||||
if not sub.auto_renew_enabled:
|
||||
return True
|
||||
# If autopayments are disabled globally, skip charging attempts
|
||||
if not getattr(self.settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
|
||||
return True
|
||||
if sub.provider == "tribute":
|
||||
# Tribute is paid externally; we do not auto-charge here
|
||||
return True
|
||||
|
||||
from db.dal.user_billing_dal import get_user_default_payment_method
|
||||
default_pm = await get_user_default_payment_method(session, sub.user_id)
|
||||
if not default_pm:
|
||||
logging.info(f"Auto-renew skipped: no saved payment method for user {sub.user_id}")
|
||||
return False
|
||||
|
||||
try:
|
||||
from .yookassa_service import YooKassaService # local import to avoid cycles
|
||||
yk: YooKassaService = self.yookassa_service # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
yk = None # type: ignore
|
||||
if not yk or not getattr(yk, 'configured', False):
|
||||
logging.warning("YooKassa unavailable for auto-renew")
|
||||
return False
|
||||
|
||||
months = sub.duration_months or 1
|
||||
amount = self.settings.subscription_options.get(months)
|
||||
if not amount:
|
||||
logging.error(f"Auto-renew price missing for {months} months")
|
||||
return False
|
||||
|
||||
metadata = {
|
||||
"user_id": str(sub.user_id),
|
||||
"auto_renew_for_subscription_id": str(sub.subscription_id),
|
||||
"subscription_months": str(months),
|
||||
}
|
||||
resp = await yk.create_payment(
|
||||
amount=float(amount),
|
||||
currency="RUB",
|
||||
description=f"Auto-renewal for {months} months",
|
||||
metadata=metadata,
|
||||
payment_method_id=default_pm.provider_payment_method_id,
|
||||
save_payment_method=False,
|
||||
capture=True,
|
||||
)
|
||||
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
|
||||
logging.info(f"Auto-renew initiated for user {sub.user_id} payment_id={resp.get('id')}")
|
||||
return True
|
||||
|
||||
async def update_last_notification_sent(
|
||||
self, session: AsyncSession, user_id: int, subscription_end_date: datetime
|
||||
):
|
||||
@@ -766,3 +856,27 @@ class SubscriptionService:
|
||||
logging.warning(
|
||||
f"Could not find subscription for user {user_id} ending at {subscription_end_date.isoformat()} to update notification time."
|
||||
)
|
||||
|
||||
# Helpers
|
||||
def _build_panel_update_payload(
|
||||
self,
|
||||
*,
|
||||
panel_user_uuid: Optional[str] = None,
|
||||
expire_at: Optional[datetime] = None,
|
||||
status: Optional[str] = None,
|
||||
traffic_limit_bytes: Optional[int] = None,
|
||||
include_uuid: bool = True,
|
||||
) -> Dict[str, Any]:
|
||||
payload: Dict[str, Any] = {}
|
||||
if include_uuid and panel_user_uuid:
|
||||
payload["uuid"] = panel_user_uuid
|
||||
if expire_at is not None:
|
||||
payload["expireAt"] = expire_at.isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
||||
if status is not None:
|
||||
payload["status"] = status
|
||||
if traffic_limit_bytes is not None:
|
||||
payload["trafficLimitBytes"] = traffic_limit_bytes
|
||||
payload["trafficLimitStrategy"] = self.settings.USER_TRAFFIC_STRATEGY
|
||||
if self.settings.parsed_user_squad_uuids:
|
||||
payload["activeInternalSquads"] = self.settings.parsed_user_squad_uuids
|
||||
return payload
|
||||
|
||||
+236
-71
@@ -13,15 +13,43 @@ from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
from bot.services.referral_service import ReferralService
|
||||
from .notification_service import NotificationService
|
||||
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
|
||||
from db.dal import payment_dal, user_dal, subscription_dal
|
||||
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
|
||||
|
||||
|
||||
def convert_period_to_months(period: Optional[str]) -> int:
|
||||
"""Map Tribute subscription period strings to months."""
|
||||
if not period:
|
||||
return 1
|
||||
|
||||
mapping = {
|
||||
"monthly": 1,
|
||||
"quarterly": 3,
|
||||
"3-month": 3,
|
||||
"3months": 3,
|
||||
"3-months": 3,
|
||||
"q": 3,
|
||||
"halfyearly": 6,
|
||||
"yearly": 12,
|
||||
"annual": 12,
|
||||
"y": 12,
|
||||
}
|
||||
return mapping.get(period.lower(), 1)
|
||||
|
||||
|
||||
class TributeService:
|
||||
def __init__(self, bot: Bot, settings: Settings, i18n: JsonI18n,
|
||||
async_session_factory: sessionmaker,
|
||||
panel_service: PanelApiService,
|
||||
subscription_service: SubscriptionService,
|
||||
referral_service: ReferralService):
|
||||
def __init__(
|
||||
self,
|
||||
bot: Bot,
|
||||
settings: Settings,
|
||||
i18n: JsonI18n,
|
||||
async_session_factory: sessionmaker,
|
||||
panel_service: PanelApiService,
|
||||
subscription_service: SubscriptionService,
|
||||
referral_service: ReferralService,
|
||||
):
|
||||
self.bot = bot
|
||||
self.settings = settings
|
||||
self.i18n = i18n
|
||||
@@ -30,8 +58,7 @@ class TributeService:
|
||||
self.subscription_service = subscription_service
|
||||
self.referral_service = referral_service
|
||||
|
||||
async def handle_webhook(self, raw_body: bytes,
|
||||
signature_header: Optional[str]) -> web.Response:
|
||||
async def handle_webhook(self, raw_body: bytes, signature_header: Optional[str]) -> web.Response:
|
||||
settings = self.settings
|
||||
bot = self.bot
|
||||
i18n = self.i18n
|
||||
@@ -39,73 +66,107 @@ class TributeService:
|
||||
subscription_service = self.subscription_service
|
||||
referral_service = self.referral_service
|
||||
|
||||
def ok(data: Optional[dict] = None) -> web.Response:
|
||||
payload = {"status": "ok"}
|
||||
if data:
|
||||
payload.update(data)
|
||||
return web.json_response(payload, status=200)
|
||||
|
||||
def ignored(reason: str) -> web.Response:
|
||||
return web.json_response({"status": "ignored", "reason": reason}, status=200)
|
||||
|
||||
def bad_request(reason: str) -> web.Response:
|
||||
return web.json_response({"status": "error", "reason": reason}, status=400)
|
||||
|
||||
if settings.TRIBUTE_API_KEY:
|
||||
if not signature_header:
|
||||
return web.Response(status=403, text="no_signature")
|
||||
return web.json_response({"status": "error", "reason": "no_signature"}, status=403)
|
||||
expected_sig = hmac.new(settings.TRIBUTE_API_KEY.encode(), raw_body,
|
||||
hashlib.sha256).hexdigest()
|
||||
if not hmac.compare_digest(expected_sig, signature_header):
|
||||
return web.Response(status=403, text="invalid_signature")
|
||||
return web.json_response({"status": "error", "reason": "invalid_signature"}, status=403)
|
||||
|
||||
try:
|
||||
payload = json.loads(raw_body.decode())
|
||||
except Exception:
|
||||
return web.Response(status=400, text="bad_request")
|
||||
return bad_request("invalid_json")
|
||||
|
||||
event_name = payload.get('name')
|
||||
data = payload.get('payload', {})
|
||||
user_id = data.get('telegram_user_id')
|
||||
price_val = data.get('price')
|
||||
logging.info(
|
||||
"Tribute webhook data: %s",
|
||||
json.dumps(payload, ensure_ascii=False),
|
||||
)
|
||||
|
||||
if not user_id or price_val is None:
|
||||
return web.Response(status=200, text="ok_missing_fields")
|
||||
# Tribute webhook spec: only two events are sent
|
||||
# name: new_subscription | cancelled_subscription
|
||||
event_name = payload.get("name")
|
||||
data = payload.get("payload", {})
|
||||
|
||||
months_map = {int(v): m for m, v in settings.subscription_options.items()}
|
||||
price_rub = price_val / 100
|
||||
months = months_map.get(int(price_rub))
|
||||
if not months:
|
||||
logging.warning(
|
||||
f"Tribute webhook: price {price_val} not mapped to months")
|
||||
return web.Response(status=200, text="ok_price_unmapped")
|
||||
# Mandatory routing fields
|
||||
user_id = data.get("telegram_user_id")
|
||||
if not user_id:
|
||||
# Permanent format issue — acknowledge to avoid retries
|
||||
return ignored("missing_telegram_user_id")
|
||||
|
||||
period_val = data.get("period")
|
||||
months = convert_period_to_months(period_val)
|
||||
|
||||
# Tribute sends amount in minor units (kopecks/cents). Convert to major units before persisting.
|
||||
amount_value = data.get("amount") or data.get("price")
|
||||
currency = (data.get("currency") or settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
|
||||
if amount_value is not None:
|
||||
try:
|
||||
amount_minor_units = float(amount_value)
|
||||
except (TypeError, ValueError):
|
||||
amount_minor_units = 0.0
|
||||
amount_float = round(amount_minor_units / 100.0, 2)
|
||||
else:
|
||||
amount_float = 0.0
|
||||
|
||||
async with async_session_factory() as session:
|
||||
if event_name == 'new_subscription':
|
||||
provider_payment_id = str(data.get('subscription_id'))
|
||||
existing_payment = await payment_dal.get_payment_by_provider_payment_id(
|
||||
session, provider_payment_id)
|
||||
if existing_payment:
|
||||
logging.info(
|
||||
"Duplicate Tribute payment webhook ignored for provider_payment_id %s",
|
||||
provider_payment_id,
|
||||
)
|
||||
payment_record = existing_payment
|
||||
if event_name == "new_subscription":
|
||||
# Use a unique, idempotent provider payment id per webhook event
|
||||
# Prefer explicit event/payment identifiers if present; otherwise fall back to payload hash suffix
|
||||
candidate_event_id = (
|
||||
str(data.get("event_id") or data.get("payment_id") or data.get("purchase_id") or data.get("invoice_id") or "")
|
||||
)
|
||||
if candidate_event_id:
|
||||
provider_payment_id = candidate_event_id
|
||||
else:
|
||||
payment_record = await payment_dal.create_payment_record(
|
||||
session,
|
||||
{
|
||||
'user_id': user_id,
|
||||
'amount': float(price_rub),
|
||||
'currency': 'RUB',
|
||||
'status': 'succeeded',
|
||||
'description': 'Tribute subscription',
|
||||
'subscription_duration_months': months,
|
||||
'provider_payment_id': provider_payment_id,
|
||||
'provider': 'tribute',
|
||||
},
|
||||
)
|
||||
# Combine subscription_id (if any) with a stable hash of the raw payload to ensure uniqueness per event
|
||||
sub_id_part = str(data.get("subscription_id") or "sub")
|
||||
payload_hash = hashlib.sha256(raw_body).hexdigest()[:16]
|
||||
provider_payment_id = f"{sub_id_part}:{payload_hash}"
|
||||
|
||||
# Idempotent ensure payment
|
||||
payment_record = await payment_dal.ensure_payment_with_provider_id(
|
||||
session,
|
||||
user_id=int(user_id),
|
||||
amount=amount_float,
|
||||
currency=currency,
|
||||
months=months,
|
||||
description="Tribute subscription",
|
||||
provider="tribute",
|
||||
provider_payment_id=provider_payment_id,
|
||||
)
|
||||
|
||||
activation_details = await subscription_service.activate_subscription(
|
||||
session,
|
||||
user_id,
|
||||
int(user_id),
|
||||
months,
|
||||
float(price_rub),
|
||||
float(amount_float),
|
||||
payment_record.payment_id,
|
||||
provider='tribute',
|
||||
provider="tribute",
|
||||
)
|
||||
referral_bonus = await referral_service.apply_referral_bonuses_for_payment(
|
||||
session, user_id, months)
|
||||
session,
|
||||
int(user_id),
|
||||
months,
|
||||
current_payment_db_id=payment_record.payment_id,
|
||||
skip_if_active_before_payment=False,
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
db_user = await user_dal.get_user_by_id(session, int(user_id))
|
||||
lang = db_user.language_code if db_user and db_user.language_code else settings.DEFAULT_LANGUAGE
|
||||
_ = lambda k, **kw: i18n.gettext(lang, k, **kw)
|
||||
|
||||
@@ -116,46 +177,150 @@ class TributeService:
|
||||
final_end = activation_details.get('end_date')
|
||||
|
||||
if final_end:
|
||||
config_link = activation_details.get("subscription_url") or _(
|
||||
"config_link_not_available"
|
||||
)
|
||||
|
||||
if applied_ref_days:
|
||||
inviter_name_display = _('friend_placeholder')
|
||||
if db_user and db_user.referred_by_id:
|
||||
inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id)
|
||||
if inviter and inviter.first_name:
|
||||
inviter_name_display = inviter.first_name
|
||||
elif inviter and inviter.username:
|
||||
inviter_name_display = f"@{inviter.username}"
|
||||
if inviter:
|
||||
safe_name = sanitize_display_name(inviter.first_name) if inviter.first_name else None
|
||||
if safe_name:
|
||||
inviter_name_display = safe_name
|
||||
elif inviter.username:
|
||||
inviter_name_display = username_for_display(inviter.username, with_at=False)
|
||||
success_msg = _(
|
||||
"payment_successful_with_referral_bonus",
|
||||
"payment_successful_with_referral_bonus_full",
|
||||
months=months,
|
||||
base_end_date=activation_details["end_date"].strftime('%Y-%m-%d'),
|
||||
bonus_days=applied_ref_days,
|
||||
final_end_date=final_end.strftime('%Y-%m-%d'),
|
||||
inviter_name=inviter_name_display)
|
||||
inviter_name=inviter_name_display,
|
||||
config_link=config_link,
|
||||
)
|
||||
else:
|
||||
success_msg = _(
|
||||
"payment_successful", months=months,
|
||||
end_date=final_end.strftime('%Y-%m-%d'))
|
||||
"payment_successful_full",
|
||||
months=months,
|
||||
end_date=final_end.strftime('%Y-%m-%d'),
|
||||
config_link=config_link,
|
||||
)
|
||||
markup = get_connect_and_main_keyboard(
|
||||
lang, i18n, settings, config_link
|
||||
)
|
||||
|
||||
try:
|
||||
await bot.send_message(user_id, success_msg)
|
||||
# Use user's DB language in success messages prepared above
|
||||
await bot.send_message(
|
||||
int(user_id),
|
||||
success_msg,
|
||||
reply_markup=markup,
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(
|
||||
f"Failed to send Tribute payment success message to user {user_id}: {e}")
|
||||
elif event_name == 'cancelled_subscription':
|
||||
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 settings.DEFAULT_LANGUAGE
|
||||
_ = lambda k, **kw: i18n.gettext(lang, k, **kw)
|
||||
|
||||
# Send notification about payment
|
||||
try:
|
||||
await bot.send_message(user_id, _("subscription_cancelled_notification"))
|
||||
notification_service = NotificationService(bot, settings, i18n)
|
||||
user = await user_dal.get_user_by_id(session, int(user_id))
|
||||
await notification_service.notify_payment_received(
|
||||
user_id=int(user_id),
|
||||
amount=float(amount_float),
|
||||
currency=currency,
|
||||
months=months,
|
||||
payment_provider="tribute",
|
||||
username=user.username if user else None
|
||||
)
|
||||
except Exception as e:
|
||||
logging.warning(
|
||||
f"Failed to notify user {user_id} about cancellation: {e}")
|
||||
await subscription_dal.set_skip_notifications_for_provider(
|
||||
session, user_id, 'tribute', False)
|
||||
await session.commit()
|
||||
logging.error(f"Failed to send tribute payment notification: {e}")
|
||||
elif event_name == "cancelled_subscription":
|
||||
await self._handle_tribute_cancellation(session, int(user_id), bot, i18n)
|
||||
|
||||
else:
|
||||
await session.commit()
|
||||
return web.Response(status=200, text="ok")
|
||||
# Acknowledge to Tribute that webhook was received and processed/accepted
|
||||
return ok({"event": event_name or "unknown"})
|
||||
|
||||
async def _handle_tribute_cancellation(self, session, user_id: int, bot: Bot, i18n: JsonI18n):
|
||||
"""Handle tribute subscription cancellation - set subscription to 1 day grace period"""
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from db.dal import subscription_dal, user_dal
|
||||
from bot.keyboards.inline.user_keyboards import get_subscribe_only_markup
|
||||
|
||||
try:
|
||||
grace_days = 1
|
||||
grace_end = datetime.now(timezone.utc) + timedelta(days=grace_days)
|
||||
|
||||
active_subscriptions = await subscription_dal.get_active_subscriptions_for_user(session, user_id)
|
||||
|
||||
panel_users_updated: set[str] = set()
|
||||
for sub in active_subscriptions:
|
||||
updated_sub = await subscription_dal.update_subscription(
|
||||
session,
|
||||
sub.subscription_id,
|
||||
{
|
||||
"end_date": grace_end,
|
||||
"status_from_panel": "CANCELLED",
|
||||
"skip_notifications": True,
|
||||
},
|
||||
)
|
||||
|
||||
panel_uuid = updated_sub.panel_user_uuid if updated_sub else None
|
||||
if panel_uuid and panel_uuid not in panel_users_updated:
|
||||
panel_users_updated.add(panel_uuid)
|
||||
panel_payload = {
|
||||
"expireAt": grace_end.isoformat(timespec="milliseconds").replace("+00:00", "Z"),
|
||||
}
|
||||
try:
|
||||
await self.panel_service.update_user_details_on_panel(
|
||||
panel_uuid,
|
||||
panel_payload,
|
||||
log_response=False,
|
||||
)
|
||||
except Exception as panel_err:
|
||||
logging.error(
|
||||
f"Failed to update panel expiry for user {user_id} (panel_uuid {panel_uuid}) during Tribute cancellation: {panel_err}")
|
||||
|
||||
await session.commit()
|
||||
|
||||
# Send notification about cancellation if enabled
|
||||
if not self.settings.TRIBUTE_SKIP_CANCELLATION_NOTIFICATIONS:
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
lang = db_user.language_code if db_user and db_user.language_code else self.settings.DEFAULT_LANGUAGE
|
||||
first_name = db_user.first_name or f"User {user_id}" if db_user else f"User {user_id}"
|
||||
|
||||
_ = lambda k, **kw: i18n.gettext(lang, k, **kw) if i18n else k
|
||||
markup = get_subscribe_only_markup(lang, i18n)
|
||||
|
||||
cancellation_msg = _(
|
||||
"tribute_subscription_cancelled",
|
||||
default="🚨 <b>Подписка отменена</b>\n\n"
|
||||
"Ваша подписка Tribute была отменена. У вас есть 24 часа для восстановления доступа, "
|
||||
"после чего подписка будет заблокирована.\n\n"
|
||||
"Для продления подписки нажмите кнопку ниже.",
|
||||
user_name=first_name
|
||||
)
|
||||
|
||||
try:
|
||||
await bot.send_message(
|
||||
int(user_id),
|
||||
cancellation_msg,
|
||||
reply_markup=markup,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to send tribute cancellation notification to user {user_id}: {e}")
|
||||
|
||||
logging.info(f"Tribute subscription cancelled for user {user_id}, grace period set to 1 day")
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error handling tribute cancellation for user {user_id}: {e}")
|
||||
await session.rollback()
|
||||
|
||||
|
||||
async def tribute_webhook_route(request: web.Request):
|
||||
|
||||
@@ -61,7 +61,11 @@ class YooKassaService:
|
||||
description: str,
|
||||
metadata: Dict[str, Any],
|
||||
receipt_email: Optional[str] = None,
|
||||
receipt_phone: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
||||
receipt_phone: Optional[str] = None,
|
||||
save_payment_method: bool = False,
|
||||
payment_method_id: Optional[str] = None,
|
||||
capture: bool = True,
|
||||
bind_only: bool = False) -> Optional[Dict[str, Any]]:
|
||||
if not self.configured:
|
||||
logging.error("YooKassa is not configured. Cannot create payment.")
|
||||
return None
|
||||
@@ -102,13 +106,23 @@ class YooKassaService:
|
||||
"value": str(round(amount, 2)),
|
||||
"currency": currency.upper()
|
||||
})
|
||||
builder.set_capture(True)
|
||||
# For binding cards only, do not capture and set minimal amount
|
||||
if bind_only:
|
||||
capture = False
|
||||
amount = max(amount, 1.00)
|
||||
builder.set_capture(capture)
|
||||
builder.set_confirmation({
|
||||
"type": ConfirmationType.REDIRECT,
|
||||
"return_url": self.return_url
|
||||
})
|
||||
builder.set_description(description)
|
||||
builder.set_metadata(metadata)
|
||||
if save_payment_method:
|
||||
# Ask YooKassa to save method for off-session charges
|
||||
builder.set_save_payment_method(True)
|
||||
if payment_method_id:
|
||||
# Use a previously saved payment method for merchant-initiated payments
|
||||
builder.set_payment_method_id(payment_method_id)
|
||||
|
||||
receipt_items_list: List[Dict[str, Any]] = [{
|
||||
"description":
|
||||
@@ -122,9 +136,9 @@ class YooKassaService:
|
||||
"vat_code":
|
||||
str(self.settings.YOOKASSA_VAT_CODE),
|
||||
"payment_mode":
|
||||
self.settings.YOOKASSA_PAYMENT_MODE,
|
||||
getattr(self.settings, 'yk_receipt_payment_mode', self.settings.YOOKASSA_PAYMENT_MODE),
|
||||
"payment_subject":
|
||||
self.settings.YOOKASSA_PAYMENT_SUBJECT
|
||||
getattr(self.settings, 'yk_receipt_payment_subject', self.settings.YOOKASSA_PAYMENT_SUBJECT)
|
||||
}]
|
||||
|
||||
receipt_data_dict: Dict[str, Any] = {
|
||||
@@ -142,7 +156,7 @@ class YooKassaService:
|
||||
f"Amount: {amount} {currency}. Metadata: {metadata}. Receipt: {receipt_data_dict}"
|
||||
)
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
response = await loop.run_in_executor(
|
||||
None, lambda: YooKassaPayment.create(payment_request,
|
||||
idempotence_key))
|
||||
@@ -178,7 +192,8 @@ class YooKassaService:
|
||||
"description_from_yk":
|
||||
response.description,
|
||||
"test_mode":
|
||||
response.test if hasattr(response, 'test') else None
|
||||
response.test if hasattr(response, 'test') else None,
|
||||
"payment_method": getattr(response, 'payment_method', None),
|
||||
}
|
||||
except Exception as e:
|
||||
logging.error(f"YooKassa payment creation failed: {e}",
|
||||
@@ -196,7 +211,7 @@ class YooKassaService:
|
||||
f"Fetching payment info from YooKassa for ID: {payment_id_in_yookassa}"
|
||||
)
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
payment_info_yk = await loop.run_in_executor(
|
||||
None, lambda: YooKassaPayment.find_one(payment_id_in_yookassa))
|
||||
|
||||
@@ -204,37 +219,40 @@ class YooKassaService:
|
||||
logging.info(
|
||||
f"YooKassa payment info for {payment_id_in_yookassa}: Status={payment_info_yk.status}, Paid={payment_info_yk.paid}"
|
||||
)
|
||||
pm = getattr(payment_info_yk, 'payment_method', None)
|
||||
pm_payload: Dict[str, Any] = {}
|
||||
if pm:
|
||||
# Collect common fields, including id and hints for last4
|
||||
pm_id = getattr(pm, 'id', None)
|
||||
pm_type = getattr(pm, 'type', None)
|
||||
pm_title = getattr(pm, 'title', None)
|
||||
account_number = getattr(pm, 'account_number', None) or getattr(pm, 'account', None)
|
||||
card_obj = getattr(pm, 'card', None)
|
||||
last4_val = None
|
||||
if card_obj and hasattr(card_obj, 'last4'):
|
||||
last4_val = getattr(card_obj, 'last4')
|
||||
elif isinstance(account_number, str) and len(account_number) >= 4:
|
||||
last4_val = account_number[-4:]
|
||||
pm_payload = {
|
||||
"id": pm_id,
|
||||
"type": pm_type,
|
||||
"title": pm_title,
|
||||
"card_last4": last4_val,
|
||||
}
|
||||
return {
|
||||
"id":
|
||||
payment_info_yk.id,
|
||||
"status":
|
||||
payment_info_yk.status,
|
||||
"paid":
|
||||
payment_info_yk.paid,
|
||||
"amount_value":
|
||||
float(payment_info_yk.amount.value),
|
||||
"amount_currency":
|
||||
payment_info_yk.amount.currency,
|
||||
"metadata":
|
||||
payment_info_yk.metadata,
|
||||
"description":
|
||||
payment_info_yk.description,
|
||||
"refundable":
|
||||
payment_info_yk.refundable,
|
||||
"created_at":
|
||||
payment_info_yk.created_at.isoformat() if hasattr(
|
||||
payment_info_yk.created_at, 'isoformat') else str(
|
||||
payment_info_yk.created_at),
|
||||
"captured_at":
|
||||
payment_info_yk.captured_at.isoformat()
|
||||
if payment_info_yk.captured_at and hasattr(
|
||||
payment_info_yk.captured_at, 'isoformat') else None,
|
||||
"payment_method_type":
|
||||
payment_info_yk.payment_method.type
|
||||
if payment_info_yk.payment_method else None,
|
||||
"test_mode":
|
||||
payment_info_yk.test
|
||||
if hasattr(payment_info_yk, 'test') else None
|
||||
"id": payment_info_yk.id,
|
||||
"status": payment_info_yk.status,
|
||||
"paid": payment_info_yk.paid,
|
||||
"amount_value": float(payment_info_yk.amount.value),
|
||||
"amount_currency": payment_info_yk.amount.currency,
|
||||
"metadata": payment_info_yk.metadata,
|
||||
"description": payment_info_yk.description,
|
||||
"refundable": payment_info_yk.refundable,
|
||||
"created_at": payment_info_yk.created_at.isoformat() if hasattr(
|
||||
payment_info_yk.created_at, 'isoformat') else str(payment_info_yk.created_at),
|
||||
"captured_at": payment_info_yk.captured_at.isoformat() if getattr(payment_info_yk, 'captured_at', None) and hasattr(payment_info_yk.captured_at, 'isoformat') else None,
|
||||
"payment_method": pm_payload,
|
||||
"test_mode": getattr(payment_info_yk, 'test', None),
|
||||
}
|
||||
else:
|
||||
logging.warning(
|
||||
@@ -246,3 +264,16 @@ class YooKassaService:
|
||||
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:
|
||||
if not self.configured:
|
||||
logging.error("YooKassa is not configured. Cannot cancel payment.")
|
||||
return False
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
await loop.run_in_executor(None, lambda: YooKassaPayment.cancel(payment_id_in_yookassa))
|
||||
logging.info(f"Cancelled YooKassa payment {payment_id_in_yookassa}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to cancel YooKassa payment {payment_id_in_yookassa}: {e}")
|
||||
return False
|
||||
|
||||
@@ -6,8 +6,30 @@ class AdminStates(StatesGroup):
|
||||
waiting_for_broadcast_message = State()
|
||||
confirming_broadcast = State()
|
||||
waiting_for_promo_details = State()
|
||||
waiting_for_promo_code = State()
|
||||
waiting_for_promo_bonus_days = State()
|
||||
waiting_for_promo_max_activations = State()
|
||||
waiting_for_promo_validity_days = State()
|
||||
waiting_for_promo_edit_details = State()
|
||||
waiting_for_promo_edit_code = State()
|
||||
waiting_for_promo_edit_bonus_days = State()
|
||||
waiting_for_promo_edit_max_activations = State()
|
||||
waiting_for_promo_edit_validity_days = State()
|
||||
waiting_for_bulk_promo_quantity = State()
|
||||
waiting_for_bulk_promo_bonus_days = State()
|
||||
waiting_for_bulk_promo_max_activations = State()
|
||||
waiting_for_bulk_promo_validity_days = State()
|
||||
waiting_for_user_id_to_ban = State()
|
||||
waiting_for_user_id_to_unban = State()
|
||||
|
||||
waiting_for_user_id_for_logs = State()
|
||||
|
||||
# User management states
|
||||
waiting_for_user_search = State()
|
||||
waiting_for_subscription_days_to_add = State()
|
||||
waiting_for_direct_message_to_user = State()
|
||||
|
||||
# Ads campaigns
|
||||
waiting_for_ad_source = State()
|
||||
waiting_for_ad_start_param = State()
|
||||
waiting_for_ad_cost = State()
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
# Bot utilities package
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Dict, Any
|
||||
from aiogram import types
|
||||
|
||||
|
||||
@dataclass
|
||||
class MessageContent:
|
||||
"""Класс для хранения информации о контенте сообщения"""
|
||||
content_type: str
|
||||
file_id: Optional[str] = None
|
||||
text: Optional[str] = None
|
||||
|
||||
|
||||
# Словари поддерживаемых параметров для каждого типа сообщения
|
||||
SUPPORTED_PARAMS = {
|
||||
"text": {"parse_mode", "entities", "disable_web_page_preview", "disable_notification", "protect_content", "reply_markup", "reply_to_message_id", "allow_sending_without_reply", "message_thread_id"},
|
||||
"photo": {"caption", "parse_mode", "caption_entities", "disable_notification", "protect_content", "reply_markup", "reply_to_message_id", "allow_sending_without_reply", "message_thread_id", "has_spoiler"},
|
||||
"video": {"duration", "width", "height", "thumbnail", "caption", "parse_mode", "caption_entities", "supports_streaming", "disable_notification", "protect_content", "reply_markup", "reply_to_message_id", "allow_sending_without_reply", "message_thread_id", "has_spoiler"},
|
||||
"animation": {"duration", "width", "height", "thumbnail", "caption", "parse_mode", "caption_entities", "disable_notification", "protect_content", "reply_markup", "reply_to_message_id", "allow_sending_without_reply", "message_thread_id", "has_spoiler"},
|
||||
"document": {"thumbnail", "caption", "parse_mode", "caption_entities", "disable_content_type_detection", "disable_notification", "protect_content", "reply_markup", "reply_to_message_id", "allow_sending_without_reply", "message_thread_id"},
|
||||
"audio": {"caption", "parse_mode", "caption_entities", "duration", "performer", "title", "thumbnail", "disable_notification", "protect_content", "reply_markup", "reply_to_message_id", "allow_sending_without_reply", "message_thread_id"},
|
||||
"voice": {"caption", "parse_mode", "caption_entities", "duration", "disable_notification", "protect_content", "reply_markup", "reply_to_message_id", "allow_sending_without_reply", "message_thread_id"},
|
||||
"sticker": {"disable_notification", "protect_content", "reply_markup", "reply_to_message_id", "allow_sending_without_reply", "message_thread_id"},
|
||||
"video_note": {"duration", "length", "thumbnail", "disable_notification", "protect_content", "reply_markup", "reply_to_message_id", "allow_sending_without_reply", "message_thread_id"},
|
||||
}
|
||||
|
||||
|
||||
def filter_kwargs(content_type: str, kwargs: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Фильтрует kwargs, оставляя только поддерживаемые параметры для данного типа сообщения"""
|
||||
supported = SUPPORTED_PARAMS.get(content_type, set())
|
||||
return {k: v for k, v in kwargs.items() if k in supported}
|
||||
|
||||
|
||||
def get_message_content(message: types.Message) -> MessageContent:
|
||||
"""
|
||||
Определяет тип контента сообщения и возвращает его данные.
|
||||
Использует match/case вместо длинных if-elif цепочек.
|
||||
"""
|
||||
text = (message.text or message.caption or "").strip()
|
||||
|
||||
# Проверяем наличие медиа-контента
|
||||
media_content = None
|
||||
if message.photo:
|
||||
media_content = ("photo", message.photo[-1].file_id)
|
||||
elif message.video:
|
||||
media_content = ("video", message.video.file_id)
|
||||
elif message.animation:
|
||||
media_content = ("animation", message.animation.file_id)
|
||||
elif message.document:
|
||||
media_content = ("document", message.document.file_id)
|
||||
elif message.audio:
|
||||
media_content = ("audio", message.audio.file_id)
|
||||
elif message.voice:
|
||||
media_content = ("voice", message.voice.file_id)
|
||||
elif message.sticker:
|
||||
media_content = ("sticker", message.sticker.file_id)
|
||||
elif message.video_note:
|
||||
media_content = ("video_note", message.video_note.file_id)
|
||||
|
||||
# Используем match/case для определения типа контента
|
||||
match media_content:
|
||||
case (content_type, file_id):
|
||||
return MessageContent(content_type=content_type, file_id=file_id, text=text)
|
||||
case None:
|
||||
return MessageContent(content_type="text", text=text)
|
||||
case _:
|
||||
return MessageContent(content_type="text", text=text)
|
||||
|
||||
|
||||
async def send_message_by_type(bot, chat_id: int, content: MessageContent, **kwargs) -> None:
|
||||
"""
|
||||
Отправляет сообщение указанного типа.
|
||||
Использует match/case вместо длинных if-elif цепочек.
|
||||
Автоматически фильтрует неподдерживаемые параметры.
|
||||
"""
|
||||
# Фильтруем kwargs для данного типа сообщения
|
||||
filtered_kwargs = filter_kwargs(content.content_type, kwargs)
|
||||
|
||||
match content.content_type:
|
||||
case "text":
|
||||
await bot.send_message(
|
||||
chat_id=chat_id,
|
||||
text=content.text,
|
||||
**filtered_kwargs
|
||||
)
|
||||
case "photo":
|
||||
await bot.send_photo(
|
||||
chat_id=chat_id,
|
||||
photo=content.file_id,
|
||||
caption=content.text or None,
|
||||
**filtered_kwargs
|
||||
)
|
||||
case "video":
|
||||
await bot.send_video(
|
||||
chat_id=chat_id,
|
||||
video=content.file_id,
|
||||
caption=content.text or None,
|
||||
**filtered_kwargs
|
||||
)
|
||||
case "animation":
|
||||
await bot.send_animation(
|
||||
chat_id=chat_id,
|
||||
animation=content.file_id,
|
||||
caption=content.text or None,
|
||||
**filtered_kwargs
|
||||
)
|
||||
case "document":
|
||||
await bot.send_document(
|
||||
chat_id=chat_id,
|
||||
document=content.file_id,
|
||||
caption=content.text or None,
|
||||
**filtered_kwargs
|
||||
)
|
||||
case "audio":
|
||||
await bot.send_audio(
|
||||
chat_id=chat_id,
|
||||
audio=content.file_id,
|
||||
caption=content.text or None,
|
||||
**filtered_kwargs
|
||||
)
|
||||
case "voice":
|
||||
await bot.send_voice(
|
||||
chat_id=chat_id,
|
||||
voice=content.file_id,
|
||||
caption=content.text or None,
|
||||
**filtered_kwargs
|
||||
)
|
||||
case "sticker":
|
||||
await bot.send_sticker(
|
||||
chat_id=chat_id,
|
||||
sticker=content.file_id,
|
||||
**filtered_kwargs
|
||||
)
|
||||
case "video_note":
|
||||
await bot.send_video_note(
|
||||
chat_id=chat_id,
|
||||
video_note=content.file_id,
|
||||
**filtered_kwargs
|
||||
)
|
||||
case _:
|
||||
# Fallback для неизвестных типов - отправляем как текст
|
||||
text_kwargs = filter_kwargs("text", kwargs)
|
||||
await bot.send_message(
|
||||
chat_id=chat_id,
|
||||
text=content.text or "Unknown content type",
|
||||
**text_kwargs
|
||||
)
|
||||
|
||||
|
||||
async def send_message_via_queue(queue_manager, uid: int, content: MessageContent, **kwargs) -> None:
|
||||
"""
|
||||
Отправляет сообщение через очередь в зависимости от типа контента.
|
||||
Использует match/case вместо длинных if-elif цепочек.
|
||||
Автоматически фильтрует неподдерживаемые параметры.
|
||||
"""
|
||||
# Фильтруем kwargs для данного типа сообщения
|
||||
filtered_kwargs = filter_kwargs(content.content_type, kwargs)
|
||||
|
||||
match content.content_type:
|
||||
case "text":
|
||||
await queue_manager.send_message(
|
||||
chat_id=uid, text=content.text, **filtered_kwargs
|
||||
)
|
||||
case "photo":
|
||||
await queue_manager.send_photo(
|
||||
chat_id=uid, photo=content.file_id, caption=content.text or None, **filtered_kwargs
|
||||
)
|
||||
case "video":
|
||||
await queue_manager.send_video(
|
||||
chat_id=uid, video=content.file_id, caption=content.text or None, **filtered_kwargs
|
||||
)
|
||||
case "animation":
|
||||
await queue_manager.send_animation(
|
||||
chat_id=uid, animation=content.file_id, caption=content.text or None, **filtered_kwargs
|
||||
)
|
||||
case "document":
|
||||
await queue_manager.send_document(
|
||||
chat_id=uid, document=content.file_id, caption=content.text or None, **filtered_kwargs
|
||||
)
|
||||
case "audio":
|
||||
await queue_manager.send_audio(
|
||||
chat_id=uid, audio=content.file_id, caption=content.text or None, **filtered_kwargs
|
||||
)
|
||||
case "voice":
|
||||
await queue_manager.send_voice(
|
||||
chat_id=uid, voice=content.file_id, caption=content.text or None, **filtered_kwargs
|
||||
)
|
||||
case "sticker":
|
||||
await queue_manager.send_sticker(
|
||||
chat_id=uid, sticker=content.file_id, **filtered_kwargs
|
||||
)
|
||||
case "video_note":
|
||||
await queue_manager.send_video_note(
|
||||
chat_id=uid, video_note=content.file_id, **filtered_kwargs
|
||||
)
|
||||
case _:
|
||||
# Fallback для неизвестных типов - отправляем как текст
|
||||
text_kwargs = filter_kwargs("text", kwargs)
|
||||
await queue_manager.send_message(
|
||||
chat_id=uid, text=content.text or "Unknown content type", **text_kwargs
|
||||
)
|
||||
|
||||
|
||||
async def send_direct_message(bot, chat_id: int, content: MessageContent, extra_text: str = "", **kwargs) -> None:
|
||||
"""
|
||||
Отправляет прямое сообщение с дополнительной обработкой для sticker и video_note.
|
||||
Для этих типов медиа отправляется отдельное текстовое сообщение, т.к. они не поддерживают caption.
|
||||
Автоматически фильтрует неподдерживаемые параметры.
|
||||
"""
|
||||
match content.content_type:
|
||||
case "sticker":
|
||||
# Отправляем стикер с отфильтрованными параметрами
|
||||
sticker_kwargs = filter_kwargs("sticker", kwargs)
|
||||
await bot.send_sticker(
|
||||
chat_id=chat_id,
|
||||
sticker=content.file_id,
|
||||
**sticker_kwargs
|
||||
)
|
||||
# Если есть текст с подписью, отправляем отдельно
|
||||
if content.text or extra_text:
|
||||
text_to_send = (content.text + extra_text) if content.text else extra_text
|
||||
text_kwargs = filter_kwargs("text", kwargs)
|
||||
await bot.send_message(
|
||||
chat_id,
|
||||
text_to_send,
|
||||
**text_kwargs
|
||||
)
|
||||
case "video_note":
|
||||
# Отправляем видео-заметку с отфильтрованными параметрами
|
||||
video_note_kwargs = filter_kwargs("video_note", kwargs)
|
||||
await bot.send_video_note(
|
||||
chat_id=chat_id,
|
||||
video_note=content.file_id,
|
||||
**video_note_kwargs
|
||||
)
|
||||
# Если есть текст с подписью, отправляем отдельно
|
||||
if content.text or extra_text:
|
||||
text_to_send = (content.text + extra_text) if content.text else extra_text
|
||||
text_kwargs = filter_kwargs("text", kwargs)
|
||||
await bot.send_message(
|
||||
chat_id,
|
||||
text_to_send,
|
||||
**text_kwargs
|
||||
)
|
||||
case "text":
|
||||
# Для текста объединяем с extra_text
|
||||
final_text = (content.text + extra_text) if content.text else extra_text
|
||||
text_kwargs = filter_kwargs("text", kwargs)
|
||||
await bot.send_message(
|
||||
chat_id=chat_id,
|
||||
text=final_text,
|
||||
**text_kwargs
|
||||
)
|
||||
case _:
|
||||
# Для остальных типов медиа используем caption
|
||||
final_caption = (content.text + extra_text) if content.text else None
|
||||
await send_message_by_type(
|
||||
bot, chat_id,
|
||||
MessageContent(content.content_type, content.file_id, final_caption),
|
||||
**kwargs
|
||||
)
|
||||
@@ -0,0 +1,27 @@
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
|
||||
def add_months(base_dt: datetime, months_to_add: int) -> datetime:
|
||||
"""Add calendar months to a datetime, clamping the day to the month's length.
|
||||
|
||||
Preserves tzinfo from base_dt.
|
||||
"""
|
||||
year = base_dt.year
|
||||
month = base_dt.month + months_to_add
|
||||
day = base_dt.day
|
||||
|
||||
# Normalize year and month
|
||||
year += (month - 1) // 12
|
||||
month = ((month - 1) % 12) + 1
|
||||
|
||||
# Determine last day of target month by rolling to next month's first day and subtracting 1 day
|
||||
if month == 12:
|
||||
next_month_first = datetime(year + 1, 1, 1, tzinfo=base_dt.tzinfo)
|
||||
else:
|
||||
next_month_first = datetime(year, month + 1, 1, tzinfo=base_dt.tzinfo)
|
||||
last_day = (next_month_first - timedelta(days=1)).day
|
||||
|
||||
clamped_day = min(day, last_day)
|
||||
return base_dt.replace(year=year, month=month, day=clamped_day)
|
||||
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Dict, Any, Callable, Awaitable, Optional
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from collections import deque
|
||||
from aiogram import Bot
|
||||
|
||||
|
||||
@dataclass
|
||||
class QueuedMessage:
|
||||
"""Represents a queued message with all necessary parameters"""
|
||||
chat_id: int
|
||||
method_name: str # 'send_message', 'edit_message_text', etc.
|
||||
kwargs: Dict[str, Any]
|
||||
callback: Optional[Callable[[Any], Awaitable[None]]] = None # Optional callback for result
|
||||
|
||||
|
||||
class MessageQueue:
|
||||
"""Message queue with rate limiting for Telegram API"""
|
||||
|
||||
def __init__(self, messages_per_second: float, burst_size: int = 5):
|
||||
self.messages_per_second = messages_per_second
|
||||
self.burst_size = burst_size
|
||||
self.queue: deque[QueuedMessage] = deque()
|
||||
self.last_send_times: deque[datetime] = deque()
|
||||
self.is_processing = False
|
||||
self.delay_between_messages = 1.0 / messages_per_second
|
||||
|
||||
async def add_message(self, message: QueuedMessage) -> None:
|
||||
"""Add message to queue"""
|
||||
self.queue.append(message)
|
||||
if not self.is_processing:
|
||||
asyncio.create_task(self._process_queue())
|
||||
|
||||
async def _process_queue(self) -> None:
|
||||
"""Process messages from queue with rate limiting"""
|
||||
if self.is_processing:
|
||||
return
|
||||
|
||||
self.is_processing = True
|
||||
|
||||
try:
|
||||
while self.queue:
|
||||
# Check if we need to wait
|
||||
await self._wait_if_needed()
|
||||
|
||||
# Get and process next message
|
||||
message = self.queue.popleft()
|
||||
try:
|
||||
await self._send_message(message)
|
||||
self.last_send_times.append(datetime.now())
|
||||
|
||||
# Keep only recent send times (last minute)
|
||||
cutoff_time = datetime.now() - timedelta(seconds=60)
|
||||
while self.last_send_times and self.last_send_times[0] < cutoff_time:
|
||||
self.last_send_times.popleft()
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to send queued message to {message.chat_id}: {e}")
|
||||
|
||||
finally:
|
||||
self.is_processing = False
|
||||
|
||||
async def _wait_if_needed(self) -> None:
|
||||
"""Wait if we need to respect rate limits"""
|
||||
if not self.last_send_times:
|
||||
return
|
||||
|
||||
# Calculate time since last message
|
||||
time_since_last = (datetime.now() - self.last_send_times[-1]).total_seconds()
|
||||
|
||||
if time_since_last < self.delay_between_messages:
|
||||
wait_time = self.delay_between_messages - time_since_last
|
||||
await asyncio.sleep(wait_time)
|
||||
|
||||
async def _send_message(self, message: QueuedMessage) -> Any:
|
||||
"""Send a single message - to be implemented by subclass"""
|
||||
raise NotImplementedError("Subclass must implement _send_message")
|
||||
|
||||
|
||||
class TelegramMessageQueue(MessageQueue):
|
||||
"""Telegram-specific message queue"""
|
||||
|
||||
def __init__(self, bot: Bot, messages_per_second: float, burst_size: int = 5):
|
||||
super().__init__(messages_per_second, burst_size)
|
||||
self.bot = bot
|
||||
|
||||
async def _send_message(self, message: QueuedMessage) -> Any:
|
||||
"""Send message using bot method"""
|
||||
method = getattr(self.bot, message.method_name)
|
||||
result = await method(chat_id=message.chat_id, **message.kwargs)
|
||||
|
||||
# Call callback if provided
|
||||
if message.callback:
|
||||
await message.callback(result)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class MessageQueueManager:
|
||||
"""Manager for different types of message queues"""
|
||||
|
||||
def __init__(self, bot: Bot):
|
||||
self.bot = bot
|
||||
|
||||
# Different queues for different types of chats
|
||||
self.group_queue = TelegramMessageQueue(
|
||||
bot=bot,
|
||||
messages_per_second=15/60, # 15 messages per minute for groups
|
||||
burst_size=3
|
||||
)
|
||||
|
||||
self.user_queue = TelegramMessageQueue(
|
||||
bot=bot,
|
||||
messages_per_second=25, # 25 messages per second for users
|
||||
burst_size=10
|
||||
)
|
||||
|
||||
def _is_group_chat(self, chat_id: int) -> bool:
|
||||
"""Check if chat_id belongs to a group or channel"""
|
||||
return str(chat_id).startswith('-100')
|
||||
|
||||
async def send_message(self, chat_id: int, **kwargs) -> None:
|
||||
"""Queue a send_message call"""
|
||||
queue = self.group_queue if self._is_group_chat(chat_id) else self.user_queue
|
||||
message = QueuedMessage(
|
||||
chat_id=chat_id,
|
||||
method_name='send_message',
|
||||
kwargs=kwargs
|
||||
)
|
||||
await queue.add_message(message)
|
||||
|
||||
async def edit_message_text(self, chat_id: int, **kwargs) -> None:
|
||||
"""Queue an edit_message_text call"""
|
||||
queue = self.group_queue if self._is_group_chat(chat_id) else self.user_queue
|
||||
message = QueuedMessage(
|
||||
chat_id=chat_id,
|
||||
method_name='edit_message_text',
|
||||
kwargs=kwargs
|
||||
)
|
||||
await queue.add_message(message)
|
||||
|
||||
async def send_document(self, chat_id: int, **kwargs) -> None:
|
||||
"""Queue a send_document call"""
|
||||
queue = self.group_queue if self._is_group_chat(chat_id) else self.user_queue
|
||||
message = QueuedMessage(
|
||||
chat_id=chat_id,
|
||||
method_name='send_document',
|
||||
kwargs=kwargs
|
||||
)
|
||||
await queue.add_message(message)
|
||||
|
||||
async def send_photo(self, chat_id: int, **kwargs) -> None:
|
||||
"""Queue a send_photo call"""
|
||||
queue = self.group_queue if self._is_group_chat(chat_id) else self.user_queue
|
||||
message = QueuedMessage(
|
||||
chat_id=chat_id,
|
||||
method_name='send_photo',
|
||||
kwargs=kwargs
|
||||
)
|
||||
await queue.add_message(message)
|
||||
|
||||
async def send_video(self, chat_id: int, **kwargs) -> None:
|
||||
"""Queue a send_video call"""
|
||||
queue = self.group_queue if self._is_group_chat(chat_id) else self.user_queue
|
||||
message = QueuedMessage(
|
||||
chat_id=chat_id,
|
||||
method_name='send_video',
|
||||
kwargs=kwargs
|
||||
)
|
||||
await queue.add_message(message)
|
||||
|
||||
async def send_animation(self, chat_id: int, **kwargs) -> None:
|
||||
"""Queue a send_animation (GIF) call"""
|
||||
queue = self.group_queue if self._is_group_chat(chat_id) else self.user_queue
|
||||
message = QueuedMessage(
|
||||
chat_id=chat_id,
|
||||
method_name='send_animation',
|
||||
kwargs=kwargs
|
||||
)
|
||||
await queue.add_message(message)
|
||||
|
||||
async def send_audio(self, chat_id: int, **kwargs) -> None:
|
||||
"""Queue a send_audio call"""
|
||||
queue = self.group_queue if self._is_group_chat(chat_id) else self.user_queue
|
||||
message = QueuedMessage(
|
||||
chat_id=chat_id,
|
||||
method_name='send_audio',
|
||||
kwargs=kwargs
|
||||
)
|
||||
await queue.add_message(message)
|
||||
|
||||
async def send_voice(self, chat_id: int, **kwargs) -> None:
|
||||
"""Queue a send_voice call"""
|
||||
queue = self.group_queue if self._is_group_chat(chat_id) else self.user_queue
|
||||
message = QueuedMessage(
|
||||
chat_id=chat_id,
|
||||
method_name='send_voice',
|
||||
kwargs=kwargs
|
||||
)
|
||||
await queue.add_message(message)
|
||||
|
||||
async def send_sticker(self, chat_id: int, **kwargs) -> None:
|
||||
"""Queue a send_sticker call"""
|
||||
queue = self.group_queue if self._is_group_chat(chat_id) else self.user_queue
|
||||
message = QueuedMessage(
|
||||
chat_id=chat_id,
|
||||
method_name='send_sticker',
|
||||
kwargs=kwargs
|
||||
)
|
||||
await queue.add_message(message)
|
||||
|
||||
async def send_video_note(self, chat_id: int, **kwargs) -> None:
|
||||
"""Queue a send_video_note call"""
|
||||
queue = self.group_queue if self._is_group_chat(chat_id) else self.user_queue
|
||||
message = QueuedMessage(
|
||||
chat_id=chat_id,
|
||||
method_name='send_video_note',
|
||||
kwargs=kwargs
|
||||
)
|
||||
await queue.add_message(message)
|
||||
|
||||
async def answer_callback_query(self, callback_query_id: str, **kwargs) -> None:
|
||||
"""Send callback query answer immediately (not rate limited)"""
|
||||
await self.bot.answer_callback_query(callback_query_id, **kwargs)
|
||||
|
||||
def get_queue_stats(self) -> Dict[str, Any]:
|
||||
"""Get statistics about queues"""
|
||||
return {
|
||||
"group_queue_size": len(self.group_queue.queue),
|
||||
"user_queue_size": len(self.user_queue.queue),
|
||||
"group_queue_processing": self.group_queue.is_processing,
|
||||
"user_queue_processing": self.user_queue.is_processing,
|
||||
"group_recent_sends": len(self.group_queue.last_send_times),
|
||||
"user_recent_sends": len(self.user_queue.last_send_times)
|
||||
}
|
||||
|
||||
|
||||
# Global queue manager instance
|
||||
_queue_manager: Optional[MessageQueueManager] = None
|
||||
|
||||
|
||||
def init_queue_manager(bot: Bot) -> MessageQueueManager:
|
||||
"""Initialize global queue manager"""
|
||||
global _queue_manager
|
||||
_queue_manager = MessageQueueManager(bot)
|
||||
return _queue_manager
|
||||
|
||||
|
||||
def get_queue_manager() -> Optional[MessageQueueManager]:
|
||||
"""Get global queue manager instance"""
|
||||
return _queue_manager
|
||||
@@ -0,0 +1,226 @@
|
||||
import re
|
||||
import unicodedata
|
||||
from typing import Optional
|
||||
|
||||
_OBFUSCATION_CHARS = " .\\-/\\\\•﹒٫_․·∙‧ꞏ‒–—﹘﹣⁻−"
|
||||
|
||||
_URL_PATTERNS = [
|
||||
re.compile(r"(?i)https?://\S+"),
|
||||
re.compile(r"(?i)www\.\S+"),
|
||||
re.compile(r"(?i)tg://\S+"),
|
||||
re.compile(r"(?i)telegram\.me\S*"),
|
||||
re.compile(r"(?i)t\.me/\+\S*"),
|
||||
re.compile(r"(?i)joinchat\S*"),
|
||||
]
|
||||
|
||||
_OBFUSCATED_DOMAIN_PATTERNS = [
|
||||
re.compile(
|
||||
r"(?i)[tт][\s{}\u2022]*[\.{}\u2022]*[\s{}\u2022]*[mм][eе]".format(
|
||||
re.escape(_OBFUSCATION_CHARS),
|
||||
re.escape(_OBFUSCATION_CHARS),
|
||||
re.escape(_OBFUSCATION_CHARS),
|
||||
)
|
||||
),
|
||||
re.compile(
|
||||
r"(?i)[tт][{}\s]*[eе][{}\s]*[lłl1i|][{}\s]*[eе]"
|
||||
r"[{}\s]*[gɢgqг][{}\s]*[rр][{}\s]*[aа]"
|
||||
r"[{}\s]*(?:[mм]|rn)".format(
|
||||
re.escape(_OBFUSCATION_CHARS),
|
||||
re.escape(_OBFUSCATION_CHARS),
|
||||
re.escape(_OBFUSCATION_CHARS),
|
||||
re.escape(_OBFUSCATION_CHARS),
|
||||
re.escape(_OBFUSCATION_CHARS),
|
||||
re.escape(_OBFUSCATION_CHARS),
|
||||
re.escape(_OBFUSCATION_CHARS),
|
||||
)
|
||||
),
|
||||
re.compile(r"(?i)t\.me\S*"),
|
||||
]
|
||||
|
||||
_ENGLISH_SERVICE_PATTERNS = [
|
||||
re.compile(r"(?i)telegram"),
|
||||
re.compile(r"(?i)teleqram"),
|
||||
re.compile(r"(?i)teiegram"),
|
||||
re.compile(r"(?i)teieqram"),
|
||||
re.compile(r"(?i)telegrarn"),
|
||||
re.compile(r"(?i)service"),
|
||||
re.compile(r"(?i)notif(?:ication)?"),
|
||||
re.compile(r"(?i)system"),
|
||||
re.compile(r"(?i)security"),
|
||||
re.compile(r"(?i)safety"),
|
||||
re.compile(r"(?i)support"),
|
||||
re.compile(r"(?i)moderation"),
|
||||
re.compile(r"(?i)review"),
|
||||
re.compile(r"(?i)compliance"),
|
||||
re.compile(r"(?i)abuse"),
|
||||
re.compile(r"(?i)spam"),
|
||||
re.compile(r"(?i)report"),
|
||||
]
|
||||
|
||||
_RUSSIAN_SERVICE_PATTERNS = [
|
||||
re.compile(r"(?i)телеграм\w*"),
|
||||
re.compile(r"(?i)служебн\w*"),
|
||||
re.compile(r"(?i)уведомлен\w*"),
|
||||
re.compile(r"(?i)поддержк\w*"),
|
||||
re.compile(r"(?i)безопасн\w*"),
|
||||
re.compile(r"(?i)модерац\w*"),
|
||||
re.compile(r"(?i)жалоб\w*"),
|
||||
re.compile(r"(?i)абуз\w*"),
|
||||
]
|
||||
|
||||
_PRE_LOWER_TRANSLATION = str.maketrans(
|
||||
{
|
||||
"I": "l",
|
||||
"İ": "l",
|
||||
"Q": "g",
|
||||
"@": " ",
|
||||
}
|
||||
)
|
||||
|
||||
_POST_LOWER_TRANSLATION = str.maketrans(
|
||||
{
|
||||
"а": "a",
|
||||
"б": "b",
|
||||
"в": "v",
|
||||
"г": "g",
|
||||
"д": "d",
|
||||
"е": "e",
|
||||
"ё": "e",
|
||||
"ж": "zh",
|
||||
"з": "z",
|
||||
"и": "i",
|
||||
"і": "i",
|
||||
"й": "i",
|
||||
"к": "k",
|
||||
"л": "l",
|
||||
"м": "m",
|
||||
"н": "n",
|
||||
"о": "o",
|
||||
"п": "p",
|
||||
"р": "r",
|
||||
"с": "s",
|
||||
"т": "t",
|
||||
"у": "u",
|
||||
"ф": "f",
|
||||
"х": "h",
|
||||
"ц": "c",
|
||||
"ч": "ch",
|
||||
"ш": "sh",
|
||||
"щ": "sh",
|
||||
"ъ": "",
|
||||
"ы": "y",
|
||||
"ь": "",
|
||||
"э": "e",
|
||||
"ю": "yu",
|
||||
"я": "ya",
|
||||
"_": "_",
|
||||
}
|
||||
)
|
||||
|
||||
_NORMALIZED_BANNED_TOKENS = {
|
||||
"tme",
|
||||
"telegram",
|
||||
"teleqram",
|
||||
"teiegram",
|
||||
"teieqram",
|
||||
"telegrarn",
|
||||
"joinchat",
|
||||
"http",
|
||||
"https",
|
||||
"www",
|
||||
"tg",
|
||||
"service",
|
||||
"notification",
|
||||
"system",
|
||||
"security",
|
||||
"safety",
|
||||
"support",
|
||||
"moderation",
|
||||
"review",
|
||||
"compliance",
|
||||
"abuse",
|
||||
"spam",
|
||||
"report",
|
||||
}
|
||||
|
||||
_USERNAME_PLACEHOLDER = "клиент"
|
||||
|
||||
|
||||
def _normalize_for_detection(value: str) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
|
||||
normalized = unicodedata.normalize("NFKD", value)
|
||||
normalized = normalized.translate(_PRE_LOWER_TRANSLATION)
|
||||
normalized = normalized.lower()
|
||||
normalized = "".join(
|
||||
ch for ch in normalized if unicodedata.category(ch) != "Mn"
|
||||
)
|
||||
normalized = normalized.translate(_POST_LOWER_TRANSLATION)
|
||||
normalized = normalized.replace("rn", "m")
|
||||
|
||||
pattern = rf"[{re.escape(_OBFUSCATION_CHARS)}\s]+"
|
||||
normalized = re.sub(pattern, "", normalized)
|
||||
normalized = re.sub(r"[^a-z0-9]+", "", normalized)
|
||||
return normalized
|
||||
|
||||
|
||||
def _remove_patterns(value: str) -> str:
|
||||
updated = value
|
||||
for pattern in (
|
||||
_URL_PATTERNS
|
||||
+ _OBFUSCATED_DOMAIN_PATTERNS
|
||||
+ _ENGLISH_SERVICE_PATTERNS
|
||||
+ _RUSSIAN_SERVICE_PATTERNS
|
||||
):
|
||||
updated = pattern.sub(" ", updated)
|
||||
return updated
|
||||
|
||||
|
||||
def _finalize(value: str) -> Optional[str]:
|
||||
compacted = re.sub(r"\s+", " ", value)
|
||||
compacted = compacted.strip(" \t\r\n-_.,/\\")
|
||||
compacted = compacted.strip()
|
||||
if not compacted:
|
||||
return None
|
||||
|
||||
normalized = _normalize_for_detection(compacted)
|
||||
if any(token in normalized for token in _NORMALIZED_BANNED_TOKENS):
|
||||
return None
|
||||
return compacted
|
||||
|
||||
|
||||
def sanitize_display_name(value: Optional[str]) -> Optional[str]:
|
||||
if value is None:
|
||||
return None
|
||||
clean = value.replace("@", " ")
|
||||
clean = _remove_patterns(clean)
|
||||
return _finalize(clean)
|
||||
|
||||
|
||||
def sanitize_username(value: Optional[str]) -> Optional[str]:
|
||||
if value is None:
|
||||
return None
|
||||
clean = value.strip()
|
||||
clean = clean.lstrip("@")
|
||||
clean = _remove_patterns(clean)
|
||||
return _finalize(clean)
|
||||
|
||||
|
||||
def username_for_display(username: Optional[str], with_at: bool = False) -> str:
|
||||
sanitized = sanitize_username(username)
|
||||
if not sanitized:
|
||||
return _USERNAME_PLACEHOLDER
|
||||
return f"@{sanitized}" if with_at else sanitized
|
||||
|
||||
|
||||
def display_name_or_fallback(
|
||||
first_name: Optional[str],
|
||||
fallback: Optional[str] = None,
|
||||
) -> str:
|
||||
sanitized = sanitize_display_name(first_name)
|
||||
if sanitized:
|
||||
return sanitized
|
||||
if fallback is not None:
|
||||
return fallback
|
||||
return _USERNAME_PLACEHOLDER
|
||||
+100
-16
@@ -1,6 +1,6 @@
|
||||
import logging
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
from pydantic import Field, ValidationError, computed_field
|
||||
from pydantic import Field, ValidationError, computed_field, field_validator
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
|
||||
@@ -26,15 +26,23 @@ class Settings(BaseSettings):
|
||||
|
||||
YOOKASSA_SHOP_ID: Optional[str] = None
|
||||
YOOKASSA_SECRET_KEY: Optional[str] = None
|
||||
YOOKASSA_WEBHOOK_BASE_URL: Optional[str] = None
|
||||
YOOKASSA_RETURN_URL: Optional[str] = None
|
||||
|
||||
YOOKASSA_DEFAULT_RECEIPT_EMAIL: Optional[str] = Field(default=None)
|
||||
YOOKASSA_VAT_CODE: int = Field(default=1)
|
||||
# Deprecated: explicit receipt fields are now derived from YOOKASSA_AUTOPAYMENTS_ENABLED
|
||||
YOOKASSA_PAYMENT_MODE: str = Field(default="full_prepayment")
|
||||
YOOKASSA_PAYMENT_SUBJECT: str = Field(default="service")
|
||||
# Single toggle to enable recurring payments (saving cards, managing payment methods, auto-renew)
|
||||
YOOKASSA_AUTOPAYMENTS_ENABLED: bool = Field(default=False)
|
||||
|
||||
TELEGRAM_WEBHOOK_BASE_URL: Optional[str] = None
|
||||
WEBHOOK_BASE_URL: Optional[str] = None
|
||||
|
||||
CRYPTOPAY_TOKEN: Optional[str] = None
|
||||
CRYPTOPAY_NETWORK: str = Field(default="mainnet")
|
||||
CRYPTOPAY_CURRENCY_TYPE: str = Field(default="fiat")
|
||||
CRYPTOPAY_ASSET: str = Field(default="RUB")
|
||||
CRYPTOPAY_ENABLED: bool = Field(default=True)
|
||||
|
||||
YOOKASSA_ENABLED: bool = Field(default=True)
|
||||
STARS_ENABLED: bool = Field(default=True)
|
||||
@@ -61,10 +69,14 @@ class Settings(BaseSettings):
|
||||
TRIBUTE_LINK_6_MONTHS: Optional[str] = Field(default=None)
|
||||
TRIBUTE_LINK_12_MONTHS: Optional[str] = Field(default=None)
|
||||
TRIBUTE_API_KEY: Optional[str] = Field(default=None)
|
||||
TRIBUTE_SKIP_NOTIFICATIONS: bool = Field(default=True, description="Skip renewal notifications for Tribute payments")
|
||||
TRIBUTE_SKIP_CANCELLATION_NOTIFICATIONS: bool = Field(default=False, description="Skip cancellation notifications for Tribute payments")
|
||||
PANEL_WEBHOOK_SECRET: Optional[str] = Field(default=None)
|
||||
|
||||
SUBSCRIPTION_EXPIRATION_NOTIFICATION_DAYS: int = Field(default=7)
|
||||
SUBSCRIPTION_NOTIFICATION_HOUR_UTC: int = Field(default=9)
|
||||
SUBSCRIPTION_NOTIFICATION_MINUTE_UTC: int = Field(default=0)
|
||||
SUBSCRIPTION_NOTIFICATIONS_ENABLED: bool = Field(default=True)
|
||||
SUBSCRIPTION_NOTIFY_ON_EXPIRE: bool = Field(default=True)
|
||||
SUBSCRIPTION_NOTIFY_AFTER_EXPIRE: bool = Field(default=True)
|
||||
SUBSCRIPTION_NOTIFY_DAYS_BEFORE: int = Field(default=3)
|
||||
|
||||
REFERRAL_BONUS_DAYS_INVITER_1_MONTH: Optional[int] = Field(
|
||||
default=3, alias="REFERRAL_BONUS_DAYS_1_MONTH")
|
||||
@@ -84,14 +96,20 @@ class Settings(BaseSettings):
|
||||
REFERRAL_BONUS_DAYS_REFEREE_12_MONTHS: Optional[int] = Field(
|
||||
default=15, alias="REFEREE_BONUS_DAYS_12_MONTHS")
|
||||
|
||||
# Referral program configuration
|
||||
REFERRAL_ONE_BONUS_PER_REFEREE: bool = Field(
|
||||
default=True,
|
||||
description="When true, referral bonuses (for inviter and referee) are applied only once per invited user – on their first successful payment."
|
||||
)
|
||||
|
||||
PANEL_API_URL: Optional[str] = None
|
||||
PANEL_API_KEY: Optional[str] = None
|
||||
USER_TRAFFIC_LIMIT_GB: Optional[float] = Field(default=0.0)
|
||||
USER_TRAFFIC_STRATEGY: str = Field(default="NO_RESET")
|
||||
USER_INBOUND_UUIDS: Optional[str] = Field(
|
||||
USER_SQUAD_UUIDS: Optional[str] = Field(
|
||||
default=None,
|
||||
description=
|
||||
"Comma-separated UUIDs of inbounds to activate for new panel users")
|
||||
"Comma-separated UUIDs of internal squads to assign to new panel users")
|
||||
|
||||
TRIAL_ENABLED: bool = Field(default=True)
|
||||
TRIAL_DURATION_DAYS: int = Field(default=3)
|
||||
@@ -104,6 +122,13 @@ class Settings(BaseSettings):
|
||||
SUBSCRIPTION_MINI_APP_URL: Optional[str] = Field(default=None)
|
||||
|
||||
START_COMMAND_DESCRIPTION: Optional[str] = Field(default=None)
|
||||
DISABLE_WELCOME_MESSAGE: bool = Field(default=False, description="Disable welcome message on /start command")
|
||||
|
||||
# Inline mode thumbnail URLs
|
||||
INLINE_REFERRAL_THUMBNAIL_URL: str = Field(default="https://cdn-icons-png.flaticon.com/512/1077/1077114.png")
|
||||
INLINE_USER_STATS_THUMBNAIL_URL: str = Field(default="https://cdn-icons-png.flaticon.com/512/681/681494.png")
|
||||
INLINE_FINANCIAL_STATS_THUMBNAIL_URL: str = Field(default="https://cdn-icons-png.flaticon.com/512/2769/2769339.png")
|
||||
INLINE_SYSTEM_STATS_THUMBNAIL_URL: str = Field(default="https://cdn-icons-png.flaticon.com/512/2920/2920277.png")
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
@@ -149,11 +174,11 @@ class Settings(BaseSettings):
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def parsed_user_inbound_uuids(self) -> Optional[List[str]]:
|
||||
if self.USER_INBOUND_UUIDS:
|
||||
def parsed_user_squad_uuids(self) -> Optional[List[str]]:
|
||||
if self.USER_SQUAD_UUIDS:
|
||||
return [
|
||||
uuid.strip()
|
||||
for uuid in self.USER_INBOUND_UUIDS.split(',')
|
||||
for uuid in self.USER_SQUAD_UUIDS.split(',')
|
||||
if uuid.strip()
|
||||
]
|
||||
return None
|
||||
@@ -167,9 +192,9 @@ class Settings(BaseSettings):
|
||||
@computed_field
|
||||
@property
|
||||
def yookassa_full_webhook_url(self) -> Optional[str]:
|
||||
if self.YOOKASSA_WEBHOOK_BASE_URL:
|
||||
|
||||
return f"{self.YOOKASSA_WEBHOOK_BASE_URL.rstrip('/')}{self.yookassa_webhook_path}"
|
||||
base = self.WEBHOOK_BASE_URL
|
||||
if base:
|
||||
return f"{base.rstrip('/')}{self.yookassa_webhook_path}"
|
||||
return None
|
||||
|
||||
@computed_field
|
||||
@@ -180,10 +205,50 @@ class Settings(BaseSettings):
|
||||
@computed_field
|
||||
@property
|
||||
def tribute_full_webhook_url(self) -> Optional[str]:
|
||||
if self.YOOKASSA_WEBHOOK_BASE_URL:
|
||||
return f"{self.YOOKASSA_WEBHOOK_BASE_URL.rstrip('/')}{self.tribute_webhook_path}"
|
||||
base = self.WEBHOOK_BASE_URL
|
||||
if base:
|
||||
return f"{base.rstrip('/')}{self.tribute_webhook_path}"
|
||||
return None
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def panel_webhook_path(self) -> str:
|
||||
return "/webhook/panel"
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def panel_full_webhook_url(self) -> Optional[str]:
|
||||
base = self.WEBHOOK_BASE_URL
|
||||
if base:
|
||||
return f"{base.rstrip('/')}{self.panel_webhook_path}"
|
||||
return None
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def cryptopay_webhook_path(self) -> str:
|
||||
return "/webhook/cryptopay"
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def cryptopay_full_webhook_url(self) -> Optional[str]:
|
||||
base = self.WEBHOOK_BASE_URL
|
||||
if base:
|
||||
return f"{base.rstrip('/')}{self.cryptopay_webhook_path}"
|
||||
return None
|
||||
|
||||
# Computed YooKassa receipt fields based on recurring toggle
|
||||
@computed_field
|
||||
@property
|
||||
def yk_receipt_payment_mode(self) -> str:
|
||||
# If autopayments are enabled, use service; otherwise full prepayment
|
||||
return "service" if self.YOOKASSA_AUTOPAYMENTS_ENABLED else "full_prepayment"
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def yk_receipt_payment_subject(self) -> str:
|
||||
# If autopayments are enabled, use full_payment; otherwise payment
|
||||
return "full_payment" if self.YOOKASSA_AUTOPAYMENTS_ENABLED else "payment"
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def subscription_options(self) -> Dict[int, float]:
|
||||
@@ -254,6 +319,25 @@ class Settings(BaseSettings):
|
||||
if self.REFERRAL_BONUS_DAYS_REFEREE_12_MONTHS is not None:
|
||||
bonuses[12] = self.REFERRAL_BONUS_DAYS_REFEREE_12_MONTHS
|
||||
return bonuses
|
||||
|
||||
# Logging Configuration
|
||||
LOG_CHAT_ID: Optional[int] = Field(default=None, description="Telegram chat/group ID for sending notifications")
|
||||
LOG_THREAD_ID: Optional[int] = Field(default=None, description="Thread ID for supergroup messages (optional)")
|
||||
|
||||
@field_validator('LOG_CHAT_ID', 'LOG_THREAD_ID', 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
|
||||
|
||||
# Notification types
|
||||
LOG_NEW_USERS: bool = Field(default=True, description="Send notifications for new user registrations")
|
||||
LOG_PAYMENTS: bool = Field(default=True, description="Send notifications for successful payments")
|
||||
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")
|
||||
|
||||
model_config = SettingsConfigDict(env_file='.env',
|
||||
env_file_encoding='utf-8',
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
from . import user_dal
|
||||
from . import payment_dal
|
||||
from . import subscription_dal
|
||||
from . import promo_code_dal
|
||||
from . import panel_sync_dal
|
||||
from . import message_log_dal
|
||||
from . import user_billing_dal
|
||||
from . import ad_dal
|
||||
|
||||
__all__ = (
|
||||
"user_dal",
|
||||
"payment_dal",
|
||||
"subscription_dal",
|
||||
"promo_code_dal",
|
||||
"panel_sync_dal",
|
||||
"message_log_dal",
|
||||
"user_billing_dal",
|
||||
"ad_dal",
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import logging
|
||||
from typing import Optional, List, Dict, Any, Tuple
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.future import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlalchemy import update, delete, func, and_
|
||||
|
||||
from ..models import AdCampaign, AdAttribution, Payment
|
||||
|
||||
|
||||
async def create_campaign(
|
||||
session: AsyncSession, *, source: str, start_param: str, cost: float
|
||||
) -> AdCampaign:
|
||||
existing = await get_campaign_by_start_param(session, start_param)
|
||||
if existing:
|
||||
raise ValueError("ad_campaign_start_param_exists")
|
||||
|
||||
campaign = AdCampaign(source=source, start_param=start_param, cost=float(cost))
|
||||
session.add(campaign)
|
||||
await session.flush()
|
||||
await session.refresh(campaign)
|
||||
logging.info(
|
||||
f"AdCampaign created id={campaign.ad_campaign_id}, source={source}, start={start_param}, cost={cost}"
|
||||
)
|
||||
return campaign
|
||||
|
||||
|
||||
async def get_campaign_by_id(session: AsyncSession, campaign_id: int) -> Optional[AdCampaign]:
|
||||
stmt = select(AdCampaign).where(AdCampaign.ad_campaign_id == campaign_id)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_campaign_by_start_param(session: AsyncSession, start_param: str) -> Optional[AdCampaign]:
|
||||
clean = start_param.strip()
|
||||
stmt = select(AdCampaign).where(AdCampaign.start_param == clean)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def list_campaigns(session: AsyncSession, *, only_active: bool = False) -> List[AdCampaign]:
|
||||
stmt = select(AdCampaign).order_by(AdCampaign.created_at.desc())
|
||||
if only_active:
|
||||
stmt = stmt.where(AdCampaign.is_active == True)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def toggle_campaign_active(session: AsyncSession, campaign_id: int, is_active: bool) -> bool:
|
||||
stmt = (
|
||||
update(AdCampaign)
|
||||
.where(AdCampaign.ad_campaign_id == campaign_id)
|
||||
.values(is_active=is_active)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
return result.rowcount > 0
|
||||
|
||||
|
||||
async def ensure_attribution(session: AsyncSession, *, user_id: int, campaign_id: int) -> AdAttribution:
|
||||
existing = await get_attribution_for_user(session, user_id)
|
||||
if existing:
|
||||
return existing
|
||||
attrib = AdAttribution(user_id=user_id, ad_campaign_id=campaign_id)
|
||||
session.add(attrib)
|
||||
await session.flush()
|
||||
await session.refresh(attrib)
|
||||
logging.info(f"AdAttribution created for user {user_id} -> campaign {campaign_id}")
|
||||
return attrib
|
||||
|
||||
|
||||
async def get_attribution_for_user(session: AsyncSession, user_id: int) -> Optional[AdAttribution]:
|
||||
stmt = select(AdAttribution).where(AdAttribution.user_id == user_id)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def mark_trial_activated(session: AsyncSession, user_id: int) -> bool:
|
||||
stmt = (
|
||||
update(AdAttribution)
|
||||
.where(and_(AdAttribution.user_id == user_id, AdAttribution.trial_activated_at.is_(None)))
|
||||
.values(trial_activated_at=func.now())
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
return result.rowcount > 0
|
||||
|
||||
|
||||
async def get_campaign_stats(session: AsyncSession, campaign_id: int) -> Dict[str, Any]:
|
||||
# Starts (attributed users)
|
||||
starts_stmt = select(func.count(AdAttribution.user_id)).where(
|
||||
AdAttribution.ad_campaign_id == campaign_id
|
||||
)
|
||||
starts = (await session.execute(starts_stmt)).scalar() or 0
|
||||
|
||||
# Trials
|
||||
trials_stmt = select(func.count(AdAttribution.user_id)).where(
|
||||
and_(AdAttribution.ad_campaign_id == campaign_id, AdAttribution.trial_activated_at.is_not(None))
|
||||
)
|
||||
trials = (await session.execute(trials_stmt)).scalar() or 0
|
||||
|
||||
# Payers (unique users with succeeded payments)
|
||||
payers_stmt = select(func.count(func.distinct(Payment.user_id))).select_from(Payment).where(
|
||||
and_(
|
||||
Payment.status == "succeeded",
|
||||
Payment.user_id.in_(
|
||||
select(AdAttribution.user_id).where(AdAttribution.ad_campaign_id == campaign_id)
|
||||
),
|
||||
)
|
||||
)
|
||||
payers = (await session.execute(payers_stmt)).scalar() or 0
|
||||
|
||||
# Revenue sum
|
||||
revenue_stmt = select(func.coalesce(func.sum(Payment.amount), 0.0)).select_from(Payment).where(
|
||||
and_(
|
||||
Payment.status == "succeeded",
|
||||
Payment.user_id.in_(
|
||||
select(AdAttribution.user_id).where(AdAttribution.ad_campaign_id == campaign_id)
|
||||
),
|
||||
)
|
||||
)
|
||||
revenue = float((await session.execute(revenue_stmt)).scalar() or 0.0)
|
||||
|
||||
return {
|
||||
"starts": int(starts),
|
||||
"trials": int(trials),
|
||||
"payers": int(payers),
|
||||
"revenue": revenue,
|
||||
}
|
||||
|
||||
|
||||
async def count_campaigns(session: AsyncSession, *, only_active: bool = False) -> int:
|
||||
stmt = select(func.count(AdCampaign.ad_campaign_id))
|
||||
if only_active:
|
||||
stmt = stmt.where(AdCampaign.is_active == True)
|
||||
return int((await session.execute(stmt)).scalar() or 0)
|
||||
|
||||
|
||||
async def list_campaigns_paged(
|
||||
session: AsyncSession, *, page: int, page_size: int, only_active: bool = False
|
||||
) -> List[AdCampaign]:
|
||||
offset = max(0, page) * max(1, page_size)
|
||||
stmt = select(AdCampaign).order_by(AdCampaign.created_at.desc()).offset(offset).limit(page_size)
|
||||
if only_active:
|
||||
stmt = stmt.where(AdCampaign.is_active == True)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_totals(session: AsyncSession) -> Dict[str, float]:
|
||||
# Total cost across all campaigns
|
||||
total_cost_stmt = select(func.coalesce(func.sum(AdCampaign.cost), 0.0))
|
||||
total_cost = float((await session.execute(total_cost_stmt)).scalar() or 0.0)
|
||||
|
||||
# Total revenue from all attributed users (unique users counted across all campaigns)
|
||||
revenue_stmt = select(func.coalesce(func.sum(Payment.amount), 0.0)).select_from(Payment).where(
|
||||
and_(
|
||||
Payment.status == "succeeded",
|
||||
Payment.user_id.in_(select(AdAttribution.user_id)),
|
||||
)
|
||||
)
|
||||
total_revenue = float((await session.execute(revenue_stmt)).scalar() or 0.0)
|
||||
|
||||
return {"cost": total_cost, "revenue": total_revenue}
|
||||
|
||||
|
||||
async def delete_campaign(session: AsyncSession, campaign_id: int) -> bool:
|
||||
"""Delete ad campaign by id along with related attributions.
|
||||
|
||||
Returns True if campaign existed and was deleted, False otherwise.
|
||||
"""
|
||||
try:
|
||||
campaign = await session.get(AdCampaign, campaign_id)
|
||||
if not campaign:
|
||||
return False
|
||||
await session.delete(campaign)
|
||||
await session.flush()
|
||||
logging.info(f"AdCampaign deleted id={campaign_id}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to delete AdCampaign id={campaign_id}: {e}", exc_info=True)
|
||||
raise
|
||||
|
||||
|
||||
+159
-52
@@ -2,7 +2,7 @@ import logging
|
||||
from typing import Optional, List, Dict, Any
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.future import select
|
||||
from sqlalchemy import update, func
|
||||
from sqlalchemy import update, func, and_
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from db.models import Payment, User
|
||||
@@ -38,14 +38,6 @@ async def create_payment_record(session: AsyncSession,
|
||||
return new_payment
|
||||
|
||||
|
||||
async def get_payment_by_yookassa_id(
|
||||
session: AsyncSession, yookassa_payment_id: str) -> Optional[Payment]:
|
||||
stmt = select(Payment).where(
|
||||
Payment.yookassa_payment_id == yookassa_payment_id)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_payment_by_provider_payment_id(
|
||||
session: AsyncSession, provider_payment_id: str) -> Optional[Payment]:
|
||||
"""Fetch a payment by provider-specific identifier."""
|
||||
@@ -55,6 +47,38 @@ async def get_payment_by_provider_payment_id(
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def ensure_payment_with_provider_id(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
amount: float,
|
||||
currency: str,
|
||||
months: int,
|
||||
description: str,
|
||||
provider: str,
|
||||
provider_payment_id: str) -> Payment:
|
||||
"""Idempotently create a payment record for a provider event.
|
||||
|
||||
If a payment with the same provider_payment_id already exists, returns it.
|
||||
Otherwise creates a new succeeded payment with provided data.
|
||||
"""
|
||||
existing = await get_payment_by_provider_payment_id(session, provider_payment_id)
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
payment_payload: Dict[str, Any] = {
|
||||
"user_id": user_id,
|
||||
"amount": float(amount),
|
||||
"currency": currency,
|
||||
"status": "succeeded",
|
||||
"description": description,
|
||||
"subscription_duration_months": months,
|
||||
"provider_payment_id": provider_payment_id,
|
||||
"provider": provider,
|
||||
}
|
||||
return await create_payment_record(session, payment_payload)
|
||||
|
||||
|
||||
async def get_payment_by_db_id(session: AsyncSession,
|
||||
payment_db_id: int) -> Optional[Payment]:
|
||||
|
||||
@@ -64,15 +88,6 @@ async def get_payment_by_db_id(session: AsyncSession,
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_payment_by_db_id_with_promo(
|
||||
session: AsyncSession, payment_db_id: int) -> Optional[Payment]:
|
||||
|
||||
stmt = select(Payment).where(Payment.payment_id == payment_db_id).options(
|
||||
selectinload(Payment.promo_code_used))
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_payment_status_by_db_id(
|
||||
session: AsyncSession,
|
||||
payment_db_id: int,
|
||||
@@ -96,47 +111,50 @@ async def update_payment_status_by_db_id(
|
||||
return payment
|
||||
|
||||
|
||||
async def user_has_successful_payment_for_provider(
|
||||
session: AsyncSession, user_id: int, provider: str) -> bool:
|
||||
"""Check if a user has at least one successful payment for the provider."""
|
||||
|
||||
stmt = (select(Payment.payment_id)
|
||||
.where(Payment.user_id == user_id,
|
||||
Payment.provider == provider,
|
||||
Payment.status == 'succeeded')
|
||||
.limit(1))
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
|
||||
async def update_payment_status_by_yk_id(session: AsyncSession,
|
||||
yookassa_payment_id: str,
|
||||
new_status: str) -> Optional[Payment]:
|
||||
payment = await get_payment_by_yookassa_id(session, yookassa_payment_id)
|
||||
if payment:
|
||||
payment.status = new_status
|
||||
payment.updated_at = func.now()
|
||||
await session.flush()
|
||||
await session.refresh(payment)
|
||||
logging.info(
|
||||
f"Payment record with YK ID {yookassa_payment_id} status updated to {new_status}."
|
||||
)
|
||||
else:
|
||||
logging.warning(
|
||||
f"Payment record with YK ID {yookassa_payment_id} not found for status update."
|
||||
)
|
||||
return payment
|
||||
|
||||
|
||||
async def get_recent_payment_logs_with_user(session: AsyncSession,
|
||||
limit: int = 20,
|
||||
offset: int = 0) -> List[Payment]:
|
||||
stmt = (select(Payment).options(selectinload(Payment.user)).order_by(
|
||||
Payment.created_at.desc()).limit(limit).offset(offset))
|
||||
stmt = (select(Payment).options(selectinload(Payment.user))
|
||||
.where(Payment.status == 'succeeded')
|
||||
.order_by(Payment.created_at.desc())
|
||||
.limit(limit).offset(offset))
|
||||
result = await session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_payments_count(session: AsyncSession) -> int:
|
||||
"""Get total count of successful payments."""
|
||||
stmt = select(func.count(Payment.payment_id)).where(Payment.status == 'succeeded')
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar() or 0
|
||||
|
||||
|
||||
async def get_all_succeeded_payments_with_user(session: AsyncSession) -> List[Payment]:
|
||||
"""Get all successful payments with user data for export."""
|
||||
stmt = (select(Payment).options(selectinload(Payment.user))
|
||||
.where(Payment.status == 'succeeded')
|
||||
.order_by(Payment.created_at.desc()))
|
||||
result = await session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def count_user_succeeded_payments(
|
||||
session: AsyncSession, user_id: int, exclude_payment_id: Optional[int] = None
|
||||
) -> int:
|
||||
"""Count succeeded payments for a specific user.
|
||||
|
||||
If exclude_payment_id is provided, that specific payment will be excluded
|
||||
from the count. Useful to check "prior" payments while processing the
|
||||
current payment in the same transaction.
|
||||
"""
|
||||
conditions = [Payment.user_id == user_id, Payment.status == 'succeeded']
|
||||
if exclude_payment_id is not None:
|
||||
conditions.append(Payment.payment_id != exclude_payment_id)
|
||||
stmt = select(func.count(Payment.payment_id)).where(and_(*conditions))
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar() or 0
|
||||
|
||||
|
||||
async def update_provider_payment_and_status(
|
||||
session: AsyncSession, payment_db_id: int,
|
||||
provider_payment_id: str, new_status: str) -> Optional[Payment]:
|
||||
@@ -155,3 +173,92 @@ async def update_provider_payment_and_status(
|
||||
f"Payment record with DB ID {payment_db_id} not found for provider 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
|
||||
|
||||
now = datetime.utcnow()
|
||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
week_start = today_start - timedelta(days=7)
|
||||
month_start = today_start - timedelta(days=30)
|
||||
|
||||
# Today's revenue
|
||||
stmt_today = select(func.sum(Payment.amount)).where(
|
||||
and_(
|
||||
Payment.status == 'succeeded',
|
||||
Payment.created_at >= today_start
|
||||
)
|
||||
)
|
||||
today_revenue = await session.execute(stmt_today)
|
||||
today_amount = today_revenue.scalar() or 0
|
||||
|
||||
# Week revenue
|
||||
stmt_week = select(func.sum(Payment.amount)).where(
|
||||
and_(
|
||||
Payment.status == 'succeeded',
|
||||
Payment.created_at >= week_start
|
||||
)
|
||||
)
|
||||
week_revenue = await session.execute(stmt_week)
|
||||
week_amount = week_revenue.scalar() or 0
|
||||
|
||||
# Month revenue
|
||||
stmt_month = select(func.sum(Payment.amount)).where(
|
||||
and_(
|
||||
Payment.status == 'succeeded',
|
||||
Payment.created_at >= month_start
|
||||
)
|
||||
)
|
||||
month_revenue = await session.execute(stmt_month)
|
||||
month_amount = month_revenue.scalar() or 0
|
||||
|
||||
# All time revenue
|
||||
stmt_all = select(func.sum(Payment.amount)).where(Payment.status == 'succeeded')
|
||||
all_revenue = await session.execute(stmt_all)
|
||||
all_amount = all_revenue.scalar() or 0
|
||||
|
||||
# Count of successful payments today
|
||||
stmt_count_today = select(func.count(Payment.payment_id)).where(
|
||||
and_(
|
||||
Payment.status == 'succeeded',
|
||||
Payment.created_at >= today_start
|
||||
)
|
||||
)
|
||||
today_count = await session.execute(stmt_count_today)
|
||||
today_payments_count = today_count.scalar() or 0
|
||||
|
||||
return {
|
||||
"today_revenue": float(today_amount),
|
||||
"week_revenue": float(week_amount),
|
||||
"month_revenue": float(month_amount),
|
||||
"all_time_revenue": float(all_amount),
|
||||
"today_payments_count": today_payments_count
|
||||
}
|
||||
|
||||
|
||||
async def get_last_tribute_payment_duration(session: AsyncSession, user_id: int) -> Optional[int]:
|
||||
"""Get duration in months from the last successful tribute payment for a user."""
|
||||
stmt = select(Payment.subscription_duration_months).where(
|
||||
and_(
|
||||
Payment.user_id == user_id,
|
||||
Payment.provider == 'tribute',
|
||||
Payment.status == 'succeeded'
|
||||
)
|
||||
).order_by(Payment.created_at.desc()).limit(1)
|
||||
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_last_tribute_payment(
|
||||
session: AsyncSession, user_id: int) -> Optional[Payment]:
|
||||
"""Return the most recent succeeded Tribute payment for the user."""
|
||||
stmt = (select(Payment).where(
|
||||
and_(Payment.user_id == user_id, Payment.provider == 'tribute',
|
||||
Payment.status == 'succeeded')).order_by(
|
||||
Payment.created_at.desc()).limit(1))
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
@@ -10,7 +10,6 @@ from db.models import PromoCode, PromoCodeActivation, User, Payment
|
||||
|
||||
async def create_promo_code(session: AsyncSession,
|
||||
promo_data: Dict[str, Any]) -> PromoCode:
|
||||
|
||||
new_promo = PromoCode(**promo_data)
|
||||
session.add(new_promo)
|
||||
await session.flush()
|
||||
@@ -26,6 +25,13 @@ async def get_promo_code_by_id(session: AsyncSession,
|
||||
return await session.get(PromoCode, promo_code_id)
|
||||
|
||||
|
||||
async def get_promo_code_by_code(session: AsyncSession, code_str: str) -> Optional[PromoCode]:
|
||||
"""Get promo code by code string (regardless of active status)"""
|
||||
stmt = select(PromoCode).where(PromoCode.code == code_str.upper())
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_active_promo_code_by_code_str(
|
||||
session: AsyncSession, code_str: str) -> Optional[PromoCode]:
|
||||
stmt = select(PromoCode).where(
|
||||
@@ -49,6 +55,42 @@ async def get_all_active_promo_codes(session: AsyncSession,
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_all_promo_codes_with_details(session: AsyncSession, limit: int = 50,
|
||||
offset: int = 0) -> List[PromoCode]:
|
||||
"""Get all promo codes (active and inactive) with pagination for management"""
|
||||
stmt = (select(PromoCode).order_by(
|
||||
PromoCode.created_at.desc()).limit(limit).offset(offset))
|
||||
result = await session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_promo_codes_count(session: AsyncSession) -> int:
|
||||
"""Get total count of all promo codes"""
|
||||
from sqlalchemy import func
|
||||
stmt = select(func.count(PromoCode.promo_code_id))
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
async def get_promo_activations_by_code_id(session: AsyncSession, promo_code_id: int, limit: Optional[int] = None, offset: int = 0) -> List[PromoCodeActivation]:
|
||||
"""Get activation history for a specific promo code with optional pagination."""
|
||||
stmt = (select(PromoCodeActivation)
|
||||
.where(PromoCodeActivation.promo_code_id == promo_code_id)
|
||||
.order_by(PromoCodeActivation.activated_at.desc())
|
||||
.offset(offset))
|
||||
if limit is not None:
|
||||
stmt = stmt.limit(limit)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def count_promo_activations_by_code_id(session: AsyncSession, promo_code_id: int) -> int:
|
||||
"""Count total activations for a specific promo code."""
|
||||
stmt = select(func.count()).select_from(PromoCodeActivation).where(PromoCodeActivation.promo_code_id == promo_code_id)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
async def update_promo_code(session: AsyncSession, promo_id: int,
|
||||
update_data: Dict[str, Any]) -> Optional[PromoCode]:
|
||||
promo = await get_promo_code_by_id(session, promo_id)
|
||||
@@ -65,6 +107,11 @@ async def delete_promo_code(session: AsyncSession, promo_id: int) -> Optional[Pr
|
||||
promo = await get_promo_code_by_id(session, promo_id)
|
||||
if not promo:
|
||||
return None
|
||||
# First, delete related activations due to foreign key constraint
|
||||
activations = await get_promo_activations_by_code_id(session, promo_id)
|
||||
for activation in activations:
|
||||
await session.delete(activation)
|
||||
|
||||
await session.delete(promo)
|
||||
await session.flush()
|
||||
return promo
|
||||
@@ -90,7 +137,6 @@ async def increment_promo_code_usage(
|
||||
async def get_user_activation_for_promo(
|
||||
session: AsyncSession, promo_code_id: int,
|
||||
user_id: int) -> Optional[PromoCodeActivation]:
|
||||
|
||||
stmt = select(PromoCodeActivation).where(
|
||||
PromoCodeActivation.promo_code_id == promo_code_id,
|
||||
PromoCodeActivation.user_id == user_id).limit(1)
|
||||
@@ -103,7 +149,6 @@ async def record_promo_activation(
|
||||
promo_code_id: int,
|
||||
user_id: int,
|
||||
payment_id: Optional[int] = None) -> Optional[PromoCodeActivation]:
|
||||
|
||||
existing_activation = await get_user_activation_for_promo(
|
||||
session, promo_code_id, user_id)
|
||||
if existing_activation:
|
||||
@@ -128,7 +173,6 @@ async def record_promo_activation(
|
||||
logging.error(
|
||||
f"Cannot record promo activation: Payment {payment_id} not found."
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
activation_data = {
|
||||
|
||||
+54
-66
@@ -14,13 +14,15 @@ async def get_active_subscription_by_user_id(
|
||||
user_id: int,
|
||||
panel_user_uuid: Optional[str] = None) -> Optional[Subscription]:
|
||||
stmt = select(Subscription).where(
|
||||
Subscription.user_id == user_id, Subscription.is_active == True,
|
||||
Subscription.end_date > datetime.now(timezone.utc))
|
||||
Subscription.user_id == user_id,
|
||||
Subscription.is_active == True,
|
||||
Subscription.end_date > datetime.now(timezone.utc),
|
||||
)
|
||||
if panel_user_uuid:
|
||||
stmt = stmt.where(Subscription.panel_user_uuid == panel_user_uuid)
|
||||
stmt = stmt.order_by(Subscription.end_date.desc())
|
||||
stmt = stmt.order_by(Subscription.end_date.desc()).limit(1)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
return result.scalars().first()
|
||||
|
||||
|
||||
async def get_subscription_by_panel_subscription_uuid(
|
||||
@@ -31,27 +33,14 @@ async def get_subscription_by_panel_subscription_uuid(
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def create_subscription(session: AsyncSession,
|
||||
sub_data: Dict[str, Any]) -> Subscription:
|
||||
from .user_dal import get_user_by_id
|
||||
|
||||
if "user_id" not in sub_data or sub_data["user_id"] is None:
|
||||
raise ValueError(
|
||||
"user_id is required to create a subscription directly.")
|
||||
user = await get_user_by_id(session, sub_data["user_id"])
|
||||
if not user:
|
||||
raise ValueError(
|
||||
f"User with id {sub_data['user_id']} not found for creating subscription."
|
||||
)
|
||||
|
||||
new_sub = Subscription(**sub_data)
|
||||
session.add(new_sub)
|
||||
await session.flush()
|
||||
await session.refresh(new_sub)
|
||||
logging.info(
|
||||
f"Subscription {new_sub.subscription_id} created for user {new_sub.user_id}"
|
||||
)
|
||||
return new_sub
|
||||
async def get_active_subscriptions_for_user(session: AsyncSession, user_id: int) -> List[Subscription]:
|
||||
"""Get all active subscriptions for a user."""
|
||||
stmt = select(Subscription).where(
|
||||
Subscription.user_id == user_id,
|
||||
Subscription.is_active == True
|
||||
).order_by(Subscription.end_date.desc())
|
||||
result = await session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def update_subscription(
|
||||
@@ -66,6 +55,34 @@ async def update_subscription(
|
||||
return sub
|
||||
|
||||
|
||||
async def set_auto_renew(session: AsyncSession, subscription_id: int, enabled: bool) -> Optional[Subscription]:
|
||||
"""Toggle auto_renew_enabled for a subscription."""
|
||||
return await update_subscription(session, subscription_id, {"auto_renew_enabled": enabled})
|
||||
|
||||
|
||||
async def set_user_subscriptions_cancelled_with_grace(
|
||||
session: AsyncSession, user_id: int, grace_days: int = 1) -> int:
|
||||
"""Mark all active user subscriptions as cancelled with a short grace period.
|
||||
|
||||
Sets end_date to now + grace_days, status_from_panel to 'CANCELLED', and
|
||||
skip future notifications to reduce noise after cancellation.
|
||||
Returns number of updated rows.
|
||||
"""
|
||||
from datetime import datetime, timezone, timedelta
|
||||
grace_end = datetime.now(timezone.utc) + timedelta(days=grace_days)
|
||||
stmt = (
|
||||
update(Subscription)
|
||||
.where(Subscription.user_id == user_id, Subscription.is_active == True)
|
||||
.values(
|
||||
end_date=grace_end,
|
||||
status_from_panel="CANCELLED",
|
||||
skip_notifications=True,
|
||||
)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
return result.rowcount or 0
|
||||
|
||||
|
||||
async def upsert_subscription(session: AsyncSession,
|
||||
sub_payload: Dict[str, Any]) -> Subscription:
|
||||
panel_sub_uuid = sub_payload.get("panel_subscription_uuid")
|
||||
@@ -144,6 +161,18 @@ async def deactivate_all_user_subscriptions(
|
||||
return result.rowcount
|
||||
|
||||
|
||||
async def delete_all_user_subscriptions(
|
||||
session: AsyncSession, user_id: int) -> int:
|
||||
"""Completely delete all user subscriptions (for trial reset)"""
|
||||
stmt = delete(Subscription).where(Subscription.user_id == user_id)
|
||||
result = await session.execute(stmt)
|
||||
if result.rowcount > 0:
|
||||
logging.info(
|
||||
f"Deleted {result.rowcount} subscription records for user {user_id} for trial reset."
|
||||
)
|
||||
return result.rowcount
|
||||
|
||||
|
||||
async def update_subscription_end_date(
|
||||
session: AsyncSession, subscription_id: int,
|
||||
new_end_date: datetime) -> Optional[Subscription]:
|
||||
@@ -193,17 +222,6 @@ async def update_subscription_notification_time(
|
||||
{"last_notification_sent": notification_time})
|
||||
|
||||
|
||||
async def get_user_active_subscription_end_date_str(
|
||||
session: AsyncSession, user_id: int) -> Optional[str]:
|
||||
stmt = (select(Subscription.end_date).where(
|
||||
Subscription.user_id == user_id, Subscription.is_active == True,
|
||||
Subscription.end_date > datetime.now(timezone.utc)).order_by(
|
||||
Subscription.end_date.desc()).limit(1))
|
||||
result = await session.execute(stmt)
|
||||
end_date_obj = result.scalar_one_or_none()
|
||||
return end_date_obj.strftime('%Y-%m-%d') if end_date_obj else None
|
||||
|
||||
|
||||
async def find_subscription_for_notification_update(
|
||||
session: AsyncSession, user_id: int,
|
||||
subscription_end_date_to_match: datetime) -> Optional[Subscription]:
|
||||
@@ -222,34 +240,4 @@ async def find_subscription_for_notification_update(
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def set_skip_notifications_for_provider(
|
||||
session: AsyncSession, user_id: int, provider: str,
|
||||
skip: bool) -> int:
|
||||
stmt = (update(Subscription).where(
|
||||
Subscription.user_id == user_id,
|
||||
Subscription.is_active == True,
|
||||
Subscription.provider == provider).values(skip_notifications=skip))
|
||||
result = await session.execute(stmt)
|
||||
return result.rowcount
|
||||
|
||||
|
||||
async def get_active_subscriptions_for_autorenew(
|
||||
session: AsyncSession, provider: str,
|
||||
days_threshold: int = 1,
|
||||
require_skip_flag: bool = True) -> List[Subscription]:
|
||||
"""Fetch active subscriptions nearing expiration for auto-renew logic."""
|
||||
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
threshold_date = now_utc + timedelta(days=days_threshold)
|
||||
|
||||
conditions = [
|
||||
Subscription.provider == provider,
|
||||
Subscription.is_active == True,
|
||||
Subscription.end_date <= threshold_date,
|
||||
]
|
||||
if require_skip_flag:
|
||||
conditions.append(Subscription.skip_notifications == True)
|
||||
|
||||
stmt = select(Subscription).where(*conditions)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
from typing import Optional, Dict, Any, List
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from db.models import UserBilling, UserPaymentMethod
|
||||
|
||||
|
||||
async def get_user_billing(session: AsyncSession, user_id: int) -> Optional[UserBilling]:
|
||||
stmt = select(UserBilling).where(UserBilling.user_id == user_id)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def upsert_yk_payment_method(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
payment_method_id: str,
|
||||
card_last4: Optional[str] = None,
|
||||
card_network: Optional[str] = None,
|
||||
) -> UserBilling:
|
||||
existing = await get_user_billing(session, user_id)
|
||||
if existing:
|
||||
existing.yookassa_payment_method_id = payment_method_id
|
||||
existing.card_last4 = card_last4
|
||||
existing.card_network = card_network
|
||||
existing.updated_at = func.now()
|
||||
await session.flush()
|
||||
await session.refresh(existing)
|
||||
return existing
|
||||
record = UserBilling(
|
||||
user_id=user_id,
|
||||
yookassa_payment_method_id=payment_method_id,
|
||||
card_last4=card_last4,
|
||||
card_network=card_network,
|
||||
)
|
||||
session.add(record)
|
||||
await session.flush()
|
||||
await session.refresh(record)
|
||||
return record
|
||||
|
||||
|
||||
async def delete_yk_payment_method(session: AsyncSession, user_id: int) -> bool:
|
||||
existing = await get_user_billing(session, user_id)
|
||||
if not existing:
|
||||
return False
|
||||
existing.yookassa_payment_method_id = None
|
||||
existing.card_last4 = None
|
||||
existing.card_network = None
|
||||
existing.updated_at = func.now()
|
||||
await session.flush()
|
||||
await session.refresh(existing)
|
||||
return True
|
||||
|
||||
|
||||
# Multi-card support API
|
||||
async def upsert_user_payment_method(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
provider_payment_method_id: str,
|
||||
provider: str = "yookassa",
|
||||
card_last4: Optional[str] = None,
|
||||
card_network: Optional[str] = None,
|
||||
set_default: bool = False,
|
||||
) -> UserPaymentMethod:
|
||||
existing_stmt = select(UserPaymentMethod).where(UserPaymentMethod.provider_payment_method_id == provider_payment_method_id)
|
||||
result = await session.execute(existing_stmt)
|
||||
existing: Optional[UserPaymentMethod] = result.scalar_one_or_none()
|
||||
if existing:
|
||||
existing.card_last4 = card_last4
|
||||
existing.card_network = card_network
|
||||
if set_default:
|
||||
# unset previous defaults
|
||||
await session.execute(
|
||||
update(UserPaymentMethod)
|
||||
.where(UserPaymentMethod.user_id == user_id)
|
||||
.values(is_default=False)
|
||||
)
|
||||
existing.is_default = True
|
||||
existing.updated_at = func.now()
|
||||
await session.flush()
|
||||
await session.refresh(existing)
|
||||
return existing
|
||||
if set_default:
|
||||
await session.execute(
|
||||
update(UserPaymentMethod)
|
||||
.where(UserPaymentMethod.user_id == user_id)
|
||||
.values(is_default=False)
|
||||
)
|
||||
record = UserPaymentMethod(
|
||||
user_id=user_id,
|
||||
provider=provider,
|
||||
provider_payment_method_id=provider_payment_method_id,
|
||||
card_last4=card_last4,
|
||||
card_network=card_network,
|
||||
is_default=set_default,
|
||||
)
|
||||
session.add(record)
|
||||
await session.flush()
|
||||
await session.refresh(record)
|
||||
return record
|
||||
|
||||
|
||||
async def list_user_payment_methods(session: AsyncSession, user_id: int, provider: Optional[str] = None) -> List[UserPaymentMethod]:
|
||||
stmt = select(UserPaymentMethod).where(UserPaymentMethod.user_id == user_id)
|
||||
if provider:
|
||||
stmt = stmt.where(UserPaymentMethod.provider == provider)
|
||||
stmt = stmt.order_by(UserPaymentMethod.is_default.desc(), UserPaymentMethod.created_at.desc())
|
||||
result = await session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_user_default_payment_method(session: AsyncSession, user_id: int, provider: str = "yookassa") -> Optional[UserPaymentMethod]:
|
||||
stmt = select(UserPaymentMethod).where(
|
||||
UserPaymentMethod.user_id == user_id,
|
||||
UserPaymentMethod.provider == provider,
|
||||
UserPaymentMethod.is_default == True,
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def set_user_default_payment_method(session: AsyncSession, user_id: int, method_id: int) -> bool:
|
||||
methods = await list_user_payment_methods(session, user_id)
|
||||
if not any(m.method_id == method_id for m in methods):
|
||||
return False
|
||||
await session.execute(update(UserPaymentMethod).where(UserPaymentMethod.user_id == user_id).values(is_default=False))
|
||||
await session.execute(update(UserPaymentMethod).where(UserPaymentMethod.method_id == method_id).values(is_default=True))
|
||||
return True
|
||||
|
||||
|
||||
async def delete_user_payment_method(session: AsyncSession, user_id: int, method_id: int) -> bool:
|
||||
stmt = select(UserPaymentMethod).where(UserPaymentMethod.method_id == method_id, UserPaymentMethod.user_id == user_id)
|
||||
result = await session.execute(stmt)
|
||||
method = result.scalar_one_or_none()
|
||||
if not method:
|
||||
return False
|
||||
await session.delete(method)
|
||||
await session.flush()
|
||||
return True
|
||||
|
||||
|
||||
async def delete_user_payment_method_by_provider_id(
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
provider_payment_method_id: str,
|
||||
) -> bool:
|
||||
"""Delete a saved payment method by its provider payment_method.id for a specific user.
|
||||
|
||||
Useful when callbacks pass the provider id (e.g., YooKassa pm_...) instead of our internal method_id.
|
||||
"""
|
||||
stmt = select(UserPaymentMethod).where(
|
||||
UserPaymentMethod.user_id == user_id,
|
||||
UserPaymentMethod.provider_payment_method_id == provider_payment_method_id,
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
method: Optional[UserPaymentMethod] = result.scalar_one_or_none()
|
||||
if not method:
|
||||
return False
|
||||
await session.delete(method)
|
||||
await session.flush()
|
||||
return True
|
||||
+154
-76
@@ -4,7 +4,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.future import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlalchemy import update, delete, func, and_
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
|
||||
from ..models import User, Subscription
|
||||
|
||||
@@ -30,35 +31,44 @@ async def get_user_by_panel_uuid(
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_user(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
user_id: Optional[int] = None,
|
||||
username: Optional[str] = None,
|
||||
panel_uuid: Optional[str] = None,
|
||||
) -> Optional[User]:
|
||||
if user_id is not None:
|
||||
return await get_user_by_id(session, user_id)
|
||||
if username is not None:
|
||||
return await get_user_by_username(session, username)
|
||||
if panel_uuid is not None:
|
||||
return await get_user_by_panel_uuid(session, panel_uuid)
|
||||
return None
|
||||
## Removed unused generic get_user helper to keep DAL explicit and simple
|
||||
|
||||
|
||||
async def create_user(session: AsyncSession, user_data: Dict[str, Any]) -> User:
|
||||
async def create_user(session: AsyncSession, user_data: Dict[str, Any]) -> Tuple[User, bool]:
|
||||
"""Create a user if not exists in a race-safe way.
|
||||
|
||||
Returns a tuple of (user, created_flag).
|
||||
"""
|
||||
|
||||
if "registration_date" not in user_data:
|
||||
user_data["registration_date"] = datetime.now()
|
||||
user_data["registration_date"] = datetime.now(timezone.utc)
|
||||
|
||||
new_user = User(**user_data)
|
||||
session.add(new_user)
|
||||
await session.flush()
|
||||
await session.refresh(new_user)
|
||||
logging.info(
|
||||
f"New user {new_user.user_id} created in DAL. Referred by: {new_user.referred_by_id or 'N/A'}."
|
||||
# Use PostgreSQL upsert to avoid IntegrityError on concurrent inserts
|
||||
stmt = (
|
||||
pg_insert(User)
|
||||
.values(**user_data)
|
||||
.on_conflict_do_nothing(index_elements=[User.user_id])
|
||||
.returning(User.user_id)
|
||||
)
|
||||
return new_user
|
||||
|
||||
result = await session.execute(stmt)
|
||||
inserted_row = result.first()
|
||||
created = inserted_row is not None
|
||||
|
||||
# Fetch the user (inserted just now or pre-existing)
|
||||
user_id: int = user_data["user_id"]
|
||||
user = await get_user_by_id(session, user_id)
|
||||
|
||||
if created and user is not None:
|
||||
logging.info(
|
||||
f"New user {user.user_id} created in DAL. Referred by: {user.referred_by_id or 'N/A'}."
|
||||
)
|
||||
elif user is not None:
|
||||
logging.info(
|
||||
f"User {user.user_id} already exists in DAL. Proceeding without creation."
|
||||
)
|
||||
|
||||
return user, created
|
||||
|
||||
|
||||
async def update_user(
|
||||
@@ -81,36 +91,15 @@ async def update_user_language(
|
||||
return result.rowcount > 0
|
||||
|
||||
|
||||
async def set_user_ban_status(
|
||||
session: AsyncSession, user_id: int, is_banned: bool
|
||||
) -> bool:
|
||||
user = await get_user_by_id(session, user_id)
|
||||
if user:
|
||||
user.is_banned = is_banned
|
||||
await session.flush()
|
||||
await session.refresh(user)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def get_banned_users_paginated(
|
||||
session: AsyncSession, limit: int, offset: int
|
||||
) -> Tuple[List[User], int]:
|
||||
stmt_users = (
|
||||
async def get_banned_users(session: AsyncSession) -> List[User]:
|
||||
"""Get all banned users"""
|
||||
stmt = (
|
||||
select(User)
|
||||
.where(User.is_banned == True)
|
||||
.order_by(User.registration_date.desc())
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
result_users = await session.execute(stmt_users)
|
||||
users_list = result_users.scalars().all()
|
||||
|
||||
stmt_count = select(func.count()).select_from(User).where(User.is_banned == True)
|
||||
result_count = await session.execute(stmt_count)
|
||||
total_banned = result_count.scalar_one()
|
||||
|
||||
return users_list, total_banned
|
||||
result = await session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_all_active_user_ids_for_broadcast(session: AsyncSession) -> List[int]:
|
||||
@@ -119,33 +108,122 @@ async def get_all_active_user_ids_for_broadcast(session: AsyncSession) -> List[i
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_user_count_stats_dal(session: AsyncSession) -> Dict[str, int]:
|
||||
total_users_stmt = select(func.count(User.user_id)).select_from(User)
|
||||
banned_users_stmt = (
|
||||
select(func.count(User.user_id)).select_from(User).where(User.is_banned == True)
|
||||
)
|
||||
|
||||
active_subs_stmt = (
|
||||
select(func.count(func.distinct(Subscription.user_id)))
|
||||
.join(User, Subscription.user_id == User.user_id)
|
||||
.where(Subscription.is_active == True)
|
||||
.where(Subscription.end_date > datetime.now())
|
||||
)
|
||||
|
||||
total_users = (await session.execute(total_users_stmt)).scalar_one_or_none() or 0
|
||||
banned_users = (await session.execute(banned_users_stmt)).scalar_one_or_none() or 0
|
||||
active_subs_users = (
|
||||
await session.execute(active_subs_stmt)
|
||||
).scalar_one_or_none() or 0
|
||||
|
||||
return {
|
||||
"total_users": total_users,
|
||||
"banned_users": banned_users,
|
||||
"users_with_active_subscriptions": active_subs_users,
|
||||
}
|
||||
|
||||
|
||||
async def get_all_users_with_panel_uuid(session: AsyncSession) -> List[User]:
|
||||
stmt = select(User).where(User.panel_user_uuid.is_not(None))
|
||||
result = await session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_enhanced_user_statistics(session: AsyncSession) -> Dict[str, Any]:
|
||||
"""Get comprehensive user statistics including active users, trial users, etc."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# Use timezone-aware UTC to avoid naive/aware comparison issues in SQL queries
|
||||
now = datetime.now(timezone.utc)
|
||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
# Total users
|
||||
total_users_stmt = select(func.count(User.user_id))
|
||||
total_users = (await session.execute(total_users_stmt)).scalar() or 0
|
||||
|
||||
# Banned users
|
||||
banned_users_stmt = select(func.count(User.user_id)).where(User.is_banned == True)
|
||||
banned_users = (await session.execute(banned_users_stmt)).scalar() or 0
|
||||
|
||||
# Active users today (proxy: registered today)
|
||||
active_today_stmt = select(func.count(User.user_id)).where(User.registration_date >= today_start)
|
||||
active_today = (await session.execute(active_today_stmt)).scalar() or 0
|
||||
|
||||
# Users with active paid subscriptions (non-trial providers only)
|
||||
paid_subs_stmt = (
|
||||
select(func.count(func.distinct(Subscription.user_id)))
|
||||
.join(User, Subscription.user_id == User.user_id)
|
||||
.where(
|
||||
and_(
|
||||
Subscription.is_active == True,
|
||||
Subscription.end_date > now,
|
||||
Subscription.provider.is_not(None) # Not trial
|
||||
)
|
||||
)
|
||||
)
|
||||
paid_subs_users = (await session.execute(paid_subs_stmt)).scalar() or 0
|
||||
|
||||
# Users on trial period
|
||||
trial_subs_stmt = (
|
||||
select(func.count(func.distinct(Subscription.user_id)))
|
||||
.join(User, Subscription.user_id == User.user_id)
|
||||
.where(
|
||||
and_(
|
||||
Subscription.is_active == True,
|
||||
Subscription.end_date > now,
|
||||
Subscription.provider.is_(None) # Trial subscriptions
|
||||
)
|
||||
)
|
||||
)
|
||||
trial_users = (await session.execute(trial_subs_stmt)).scalar() or 0
|
||||
|
||||
# Inactive users (no active subscription)
|
||||
inactive_users = total_users - paid_subs_users - trial_users - banned_users
|
||||
|
||||
# Users attracted via referral
|
||||
referral_users_stmt = select(func.count(User.user_id)).where(User.referred_by_id.is_not(None))
|
||||
referral_users = (await session.execute(referral_users_stmt)).scalar() or 0
|
||||
|
||||
return {
|
||||
"total_users": total_users,
|
||||
"banned_users": banned_users,
|
||||
"active_today": active_today,
|
||||
"paid_subscriptions": paid_subs_users,
|
||||
"trial_users": trial_users,
|
||||
"inactive_users": max(0, inactive_users),
|
||||
"referral_users": referral_users
|
||||
}
|
||||
|
||||
|
||||
async def get_user_ids_with_active_subscription(session: AsyncSession) -> List[int]:
|
||||
"""Return non-banned user IDs who have an active subscription (paid or trial)."""
|
||||
from datetime import datetime, timezone
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
stmt = (
|
||||
select(func.distinct(Subscription.user_id))
|
||||
.join(User, Subscription.user_id == User.user_id)
|
||||
.where(
|
||||
and_(
|
||||
User.is_banned == False,
|
||||
Subscription.is_active == True,
|
||||
Subscription.end_date > now,
|
||||
)
|
||||
)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_user_ids_without_active_subscription(session: AsyncSession) -> List[int]:
|
||||
"""Return non-banned user IDs who do NOT have any active subscription."""
|
||||
from datetime import datetime, timezone
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# Subquery for users with active subscription
|
||||
active_subs_subq = (
|
||||
select(Subscription.user_id)
|
||||
.where(
|
||||
and_(
|
||||
Subscription.is_active == True,
|
||||
Subscription.end_date > now,
|
||||
)
|
||||
)
|
||||
).scalar_subquery()
|
||||
|
||||
stmt = (
|
||||
select(User.user_id)
|
||||
.where(
|
||||
and_(
|
||||
User.is_banned == False,
|
||||
~User.user_id.in_(active_subs_subq),
|
||||
)
|
||||
)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
@@ -4,6 +4,7 @@ from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from config.settings import Settings
|
||||
from .models import Base
|
||||
from .migrator import run_simple_migrations
|
||||
|
||||
async_engine = None
|
||||
|
||||
@@ -62,6 +63,8 @@ async def init_db(settings: Settings, session_factory: sessionmaker):
|
||||
|
||||
async with async_engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
# Run lightweight, idempotent migrations to add any missing columns
|
||||
await conn.run_sync(run_simple_migrations)
|
||||
logging.info(
|
||||
"PostgreSQL database initialized/checked successfully using SQLAlchemy."
|
||||
)
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import logging
|
||||
from typing import Set
|
||||
|
||||
from sqlalchemy import inspect, text
|
||||
from sqlalchemy.engine import Connection
|
||||
|
||||
from .models import Base
|
||||
|
||||
|
||||
def _add_missing_columns(connection: Connection) -> None:
|
||||
inspector = inspect(connection)
|
||||
metadata = Base.metadata
|
||||
|
||||
existing_tables: Set[str] = set(inspector.get_table_names())
|
||||
|
||||
for table in metadata.tables.values():
|
||||
table_name = table.name
|
||||
if table_name not in existing_tables:
|
||||
# Tables are created elsewhere via create_all; skip here.
|
||||
continue
|
||||
|
||||
existing_columns = {col_info["name"] for col_info in inspector.get_columns(table_name)}
|
||||
|
||||
for desired_column in table.columns:
|
||||
if desired_column.name in existing_columns:
|
||||
continue
|
||||
|
||||
# Build ADD COLUMN DDL
|
||||
preparer = connection.dialect.identifier_preparer
|
||||
table_quoted = preparer.format_table(table)
|
||||
column_name_quoted = preparer.quote(desired_column.name)
|
||||
column_type_sql = desired_column.type.compile(dialect=connection.dialect)
|
||||
|
||||
default_clause = ""
|
||||
server_default = getattr(desired_column, "server_default", None)
|
||||
if server_default is not None and getattr(server_default, "arg", None) is not None:
|
||||
try:
|
||||
compiled_default = str(
|
||||
server_default.arg.compile(dialect=connection.dialect)
|
||||
)
|
||||
default_clause = f" DEFAULT {compiled_default}"
|
||||
except Exception: # best-effort
|
||||
pass
|
||||
|
||||
# For safety, add new columns as NULLable to avoid failures on existing rows
|
||||
# If strict NOT NULL is needed, it can be enforced manually later.
|
||||
ddl = f"ALTER TABLE {table_quoted} ADD COLUMN {column_name_quoted} {column_type_sql}{default_clause}"
|
||||
|
||||
logging.info(
|
||||
f"Migrator: adding missing column {desired_column.name} to table {table_name}"
|
||||
)
|
||||
connection.execute(text(ddl))
|
||||
|
||||
|
||||
def run_simple_migrations(connection: Connection) -> None:
|
||||
"""
|
||||
Run lightweight, idempotent migrations:
|
||||
- Ensure missing columns are added to existing tables to match models in db/models.py
|
||||
Note: Table creation is handled separately via Base.metadata.create_all.
|
||||
"""
|
||||
try:
|
||||
_add_missing_columns(connection)
|
||||
logging.info("Migrator: schema synchronized (columns added as needed).")
|
||||
except Exception as e:
|
||||
logging.error(f"Migrator: failed to run simple migrations: {e}", exc_info=True)
|
||||
raise
|
||||
@@ -72,6 +72,7 @@ class Subscription(Base):
|
||||
last_notification_sent = Column(DateTime(timezone=True), nullable=True)
|
||||
provider = Column(String, nullable=True)
|
||||
skip_notifications = Column(Boolean, default=False)
|
||||
auto_renew_enabled = Column(Boolean, default=True, index=True)
|
||||
|
||||
user = relationship("User", back_populates="subscriptions")
|
||||
|
||||
@@ -112,6 +113,37 @@ class Payment(Base):
|
||||
back_populates="payments_where_used")
|
||||
|
||||
|
||||
class UserBilling(Base):
|
||||
__tablename__ = "user_billing"
|
||||
|
||||
user_id = Column(BigInteger, ForeignKey("users.user_id"), primary_key=True)
|
||||
# Saved payment method for off-session recurring charges (YooKassa)
|
||||
yookassa_payment_method_id = Column(String, nullable=True, unique=True)
|
||||
card_last4 = Column(String, nullable=True)
|
||||
card_network = Column(String, nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now(), nullable=True)
|
||||
|
||||
user = relationship("User")
|
||||
|
||||
class UserPaymentMethod(Base):
|
||||
__tablename__ = "user_payment_methods"
|
||||
|
||||
method_id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id = Column(BigInteger, ForeignKey("users.user_id"), nullable=False, index=True)
|
||||
provider = Column(String, nullable=False, default="yookassa", index=True)
|
||||
provider_payment_method_id = Column(String, nullable=False, unique=True, index=True)
|
||||
card_last4 = Column(String, nullable=True)
|
||||
card_network = Column(String, nullable=True)
|
||||
is_default = Column(Boolean, default=False, index=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now(), nullable=True)
|
||||
|
||||
user = relationship("User")
|
||||
__table_args__ = (
|
||||
UniqueConstraint('user_id', 'provider_payment_method_id', name='uq_user_provider_method'),
|
||||
)
|
||||
|
||||
class PromoCode(Base):
|
||||
__tablename__ = "promo_codes"
|
||||
|
||||
@@ -195,3 +227,35 @@ class PanelSyncStatus(Base):
|
||||
subscriptions_synced = Column(Integer, default=0)
|
||||
|
||||
__table_args__ = (UniqueConstraint('id'), )
|
||||
|
||||
|
||||
class AdCampaign(Base):
|
||||
__tablename__ = "ad_campaigns"
|
||||
|
||||
ad_campaign_id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
source = Column(String, nullable=False, index=True)
|
||||
start_param = Column(String, nullable=False, unique=True, index=True)
|
||||
cost = Column(Float, nullable=False, default=0.0)
|
||||
is_active = Column(Boolean, default=True, index=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
attributions = relationship(
|
||||
"AdAttribution",
|
||||
back_populates="campaign",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<AdCampaign(id={self.ad_campaign_id}, source='{self.source}', start_param='{self.start_param}', cost={self.cost})>"
|
||||
|
||||
|
||||
class AdAttribution(Base):
|
||||
__tablename__ = "ad_attributions"
|
||||
|
||||
user_id = Column(BigInteger, ForeignKey("users.user_id"), primary_key=True, index=True)
|
||||
ad_campaign_id = Column(Integer, ForeignKey("ad_campaigns.ad_campaign_id"), nullable=False, index=True)
|
||||
first_start_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
trial_activated_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
user = relationship("User")
|
||||
campaign = relationship("AdCampaign", back_populates="attributions")
|
||||
|
||||
@@ -11,6 +11,9 @@ services:
|
||||
volumes:
|
||||
- ./locales:/app/locales
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
remnawave-tg-shop-db:
|
||||
condition: service_healthy
|
||||
|
||||
remnawave-tg-shop-db:
|
||||
image: postgres:17
|
||||
@@ -23,6 +26,11 @@ services:
|
||||
networks:
|
||||
- remnawave-network
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
|
||||
networks:
|
||||
remnawave-network:
|
||||
|
||||
+329
-111
@@ -5,108 +5,112 @@
|
||||
"menu_activate_trial_button": "🆓 Free Trial",
|
||||
"menu_subscribe_inline": "🚀 Purchase",
|
||||
"menu_my_subscription_inline": "🔐 My Subscription",
|
||||
"no_subscription options available": "Subscription issuance is not configured by the bot administrator",
|
||||
"menu_referral_inline": "🎁 Referrals",
|
||||
"referral_no_bonuses_configured": "Sorry, the referral program is currently disabled",
|
||||
"menu_apply_promo_button": "🎟 Promo Code",
|
||||
"menu_language_settings_inline": "🌐 Language",
|
||||
"menu_server_status_button": "📊 Status",
|
||||
"menu_support_button": "💬 Support",
|
||||
"menu_terms_button": "📄 Terms of Service",
|
||||
|
||||
"back_to_main_menu_button": "⬅️ Back",
|
||||
|
||||
"choose_language": "Choose language / Выберите язык:",
|
||||
"choose_language": "Choose language:",
|
||||
"language_set_alert": "Language changed!",
|
||||
|
||||
"error_occurred_try_again": "An error occurred, please try again.",
|
||||
"error_try_again": "Please try again.",
|
||||
"error_displaying_menu": "Error displaying menu.",
|
||||
"main_menu_unknown_action": "Unknown action.",
|
||||
|
||||
"select_subscription_period": "Select subscription period:",
|
||||
"no_subscription_options_available": "No subscription options available at the moment.",
|
||||
"subscribe_for_months_button": "{months} mo. - {price} {currency_symbol}",
|
||||
|
||||
"choose_payment_method": "Choose payment method:",
|
||||
"pay_button": "💳 Pay",
|
||||
"pay_with_yookassa_button": "💳 YooKassa",
|
||||
"pay_with_cryptopay_button": "💎 CryptoBot",
|
||||
"pay_with_tribute_button": "❤️ Tribute",
|
||||
"pay_with_stars_button": "🌟 Telegram Stars",
|
||||
"connect_button": "🔗 Connect",
|
||||
"cancel_button": "❌ Cancel",
|
||||
"payment_description_subscription": "Subscription payment for {months} mo.",
|
||||
"payment_service_unavailable": "Payment service temporarily unavailable. Please try again later.",
|
||||
"payment_service_unavailable_alert": "Payment service unavailable",
|
||||
"error_creating_payment_record": "Error creating payment record. Please try again later.",
|
||||
"error_payment_gateway_link_failed": "Failed to get payment link. Please contact support.",
|
||||
"payment_link_message": "To pay for {months} mo. subscription, click the button below:",
|
||||
"error_payment_gateway": "Payment gateway error. Please try again later or contact support.",
|
||||
"payment_successful": "✅ Payment successful!\nYour {months}-month subscription is active until {end_date}.",
|
||||
"payment_successful_with_promo": "✅ Payment successful!\nYour {months}-month subscription (with promo bonus +{bonus_days} days) is active until {end_date}.",
|
||||
"payment_successful_with_referral_bonus": "✅ Payment successful!\nYour {months}-month subscription (base end date: {base_end_date}) has been extended by {bonus_days} bonus days for referral from {inviter_name} and is now active until {final_end_date}.",
|
||||
"payment_successful_error_details": "✅ Payment succeeded, but an error occurred displaying details. Your subscription is active. Contact support if anything is wrong.",
|
||||
"payment_successful_full": "✅ Payment successful!\nYour {months}-month subscription is active until {end_date}.\n\nConnection key:\n<code>{config_link}</code>\n\nTo connect, open the link and follow the instructions 👇",
|
||||
"payment_successful_with_referral_bonus_full": "✅ Payment successful!\nYour {months}-month subscription (base end date: {base_end_date}) has been extended by {bonus_days} bonus days for referral from {inviter_name} and is now active until {final_end_date}.\n\nConnection key:\n<code>{config_link}</code>\n\nTo connect, open the link and follow the instructions 👇",
|
||||
"payment_failed": "❌ Payment failed or was cancelled. Please try again or contact support.",
|
||||
"error_processing_your_payment": "An error occurred processing your payment after success. Please contact support with the details.",
|
||||
|
||||
"my_subscription_details": "<b>🔐 My Subscription:</b>\n\nStatus: <b>{status}</b>\nActive until: <b>{end_date}</b> (days left: {days_left})\n\nTraffic: <b>{traffic_used}</b> of <b>{traffic_limit}</b>\n\nConfiguration link:\n<code>{config_link}</code>",
|
||||
"config_link_not_available": "not available, contact support",
|
||||
"subscription_not_active": "You have no active subscription.\nWould you like to purchase one?",
|
||||
"status_active": "Active",
|
||||
"status_expired": "Expired",
|
||||
"status_disabled": "Disabled",
|
||||
"traffic_unlimited": "Unlimited",
|
||||
"traffic_na": "n/a",
|
||||
"error_service_unavailable": "Service temporarily unavailable. Please try again later.",
|
||||
|
||||
"promo_code_prompt": "Please enter your promo code:",
|
||||
"promo_code_not_found": "Promo code <code>{code}</code> not found, expired, or already used the maximum number of times.",
|
||||
"promo_code_already_used_by_user": "You have already used promo code <code>{code}</code>.",
|
||||
"promo_code_no_active_subscription": "You must have an active subscription to apply this promo code.",
|
||||
"promo_code_applied_success": "✅ Promo code <code>{code}</code> applied successfully!\nYour subscription is extended by {bonus_days} days and is now active until {new_end_date}.",
|
||||
"promo_code_applied_success_full": "✅ Promo code applied successfully!\nSubscription active until {end_date}.\n\nConnection key:\n<code>{config_link}</code>\n\nTo connect, open the link and follow the instructions 👇",
|
||||
"error_applying_promo_bonus": "Failed to apply promo bonus. Please try again later or contact support.",
|
||||
"promo_input_cancelled_short": "Promo code entry cancelled.",
|
||||
|
||||
"trial_feature_disabled": "Free trial is currently unavailable.",
|
||||
"trial_already_had_subscription_or_trial": "You have already used a free trial or had a paid subscription. Free trial is available only once for new users.",
|
||||
"trial_confirm_prompt": "Would you like to activate a free trial for {days} days with {traffic_gb} GB traffic limit?",
|
||||
"trial_confirm_activate_button": "✅ Activate!",
|
||||
"trial_activated_alert": "Trial activated!",
|
||||
"trial_activated_details_message": "🚀 Your {days}-day free trial is activated!\n\nValid until: <b>{end_date}</b>\nTraffic: <b>{traffic_gb} GB</b>\n\nYour configuration link:\n<code>{config_link}</code>",
|
||||
"trial_activation_failed": "Failed to activate free trial. Please try again later.",
|
||||
"trial_activation_failed_panel_link": "Failed to link your account to the server for trial activation. Please try again later.",
|
||||
"trial_activation_failed_db": "Database error during trial activation. Please try again later.",
|
||||
"trial_activation_failed_panel_update": "Failed to update trial details on the server. Please try again later.",
|
||||
"user_not_found_for_trial": "Your account was not found in the system. Please run /start first.",
|
||||
"trial_activated_details_message": "✅ Trial activated!\nYour {days}-day trial is active until {end_date}.\n\nConnection key:\n<code>{config_link}</code>\n\nTo connect, open the link and follow the instructions 👇",
|
||||
"yes_button": "Yes",
|
||||
"no_button": "No",
|
||||
"trial_cancelled_short": "Activation cancelled.",
|
||||
|
||||
"referral_program_info_new": "Invite friends and earn bonuses!\n\nYour referral link:\n<code>{referral_link}</code>\n\n<b>Bonuses:</b>{bonus_details}",
|
||||
"referral_program_info_new": "🎁 <b>Referral Program</b>\n\n📊 <b>Your stats:</b>\n👥 Friends invited: <b>{invited_count}</b>\n💳 Purchased subscription: <b>{purchased_count}</b>\n\n🔗 Your link:\n<code>{referral_link}</code>\n\n💰 <b>Invitation bonuses:</b>\n{bonus_details}\n\n📢 Share the link with friends and get bonuses!",
|
||||
"referral_bonus_per_period": "\n\n🎁 For a friend's {months}-month subscription:\n ➢ You: <b>{inviter_bonus_days} days</b>\n ➢ Friend: <b>{referee_bonus_days} days</b>",
|
||||
"no_bonus_days": "0",
|
||||
"referral_no_bonuses_configured": "\nNo referral bonuses configured.",
|
||||
"referral_link_for_copying_reminder": "The link is above. Press and hold to copy.",
|
||||
"referral_share_message_button": "📩 Message for friend",
|
||||
"referral_friend_message": "🚀 Hey! Try this VPN - it's fast, reliable and affordable!\n\n🎁 Use my link to get bonus days with your subscription!\n\n{referral_link}",
|
||||
"friend_placeholder": "friend",
|
||||
"referral_bonus_inviter_notification_extended": "🎉 Congrats! Your friend {referee_name} paid for a subscription. You received {days} bonus days! Your subscription is now active until {new_end_date}.",
|
||||
"referral_bonus_inviter_notification_new_sub": "🎉 Congrats! Your friend {referee_name} paid for a subscription. You received a {days}-day bonus subscription! It is active until {new_end_date}.",
|
||||
|
||||
"user_is_banned": "🚫 Your account is banned. Please contact support.",
|
||||
|
||||
"admin_panel_title": "Admin Panel",
|
||||
"admin_stats_button": "📊 Statistics",
|
||||
"admin_broadcast_button": "📢 Broadcast",
|
||||
"admin_create_promo_button": "🎁 Create Promo",
|
||||
"admin_manage_promos_button": "🛠 Manage Promos",
|
||||
"admin_view_promos_button": "👀 Promo List",
|
||||
"admin_ban_user_button": "🚫 Ban User",
|
||||
"admin_unban_user_button": "✅ Unban User",
|
||||
"admin_create_bulk_promo_button": "📦 Bulk Create",
|
||||
"admin_ban_user_button": "🚫 Ban",
|
||||
"admin_unban_user_button": "✅ Unban",
|
||||
"admin_view_banned_users_button": "📜 Ban List",
|
||||
"admin_view_logs_menu_button": "📄 Logs",
|
||||
"admin_sync_panel_button": "🔄 Sync",
|
||||
"admin_unknown_action": "Unknown admin action.",
|
||||
"admin_action_cancelled_default": "Action cancelled. Returning to menu.",
|
||||
"admin_action_cancelled_default_alert": "Action cancelled",
|
||||
"back_to_admin_panel_button": "⬅️ Back to Admin",
|
||||
|
||||
"admin_stats_and_monitoring_section": "📊 Statistics",
|
||||
"admin_user_management_section": "👥 Users",
|
||||
"admin_promo_marketing_section": "🎁 Promos",
|
||||
"admin_system_functions_section": "⚙️ System",
|
||||
"admin_ban_management_section": "🚫 Bans",
|
||||
"admin_users_management_button": "👤 Management",
|
||||
"back_to_user_management_button": "⬅️ To Users",
|
||||
"back_to_admin_panel_button": "⬅️ To Admin",
|
||||
"admin_stats_header": "📊 Bot Statistics",
|
||||
"admin_stats_users": "👥 Users: Total - {total_users}, Banned - {banned_users}, Active Subs - {active_subs}",
|
||||
"admin_enhanced_users_stats_header": "Users",
|
||||
"admin_financial_stats_header": "Financial Statistics",
|
||||
"admin_stats_recent_payments_header": "Recent Payments:",
|
||||
"admin_stats_payment_item": "{status_emoji} {amount} {currency} from {user_info} ({p_status}) [{p_date}]",
|
||||
"admin_stats_no_payments_found": "No payments found yet.",
|
||||
"admin_view_payments_button": "💰 Payments",
|
||||
"admin_payments_header": "💰 <b>All Payments</b>",
|
||||
"admin_no_payments_found": "No payments found.",
|
||||
"admin_export_payments_csv": "📊 Export CSV",
|
||||
"admin_refresh_payments": "🔄 Refresh",
|
||||
"admin_no_payments_to_export": "No payments to export.",
|
||||
"admin_payments_export_success": "📊 Payments export completed!\nTotal records: {count}",
|
||||
"admin_export_sent": "File sent!",
|
||||
"admin_csv_payment_id": "ID",
|
||||
"admin_csv_user_id": "User ID",
|
||||
"admin_csv_username": "Username",
|
||||
"admin_csv_first_name": "First Name",
|
||||
"admin_csv_amount": "Amount",
|
||||
"admin_csv_currency": "Currency",
|
||||
"admin_csv_provider": "Provider",
|
||||
"admin_csv_status": "Status",
|
||||
"admin_csv_description": "Description",
|
||||
"admin_csv_months": "Months",
|
||||
"admin_csv_created_at": "Created At",
|
||||
"admin_csv_provider_payment_id": "Provider Payment ID",
|
||||
"admin_stats_last_sync_header": "Last Panel Sync:",
|
||||
"admin_stats_sync_time": "Time",
|
||||
"admin_stats_sync_status": "Status",
|
||||
@@ -114,108 +118,322 @@
|
||||
"admin_stats_sync_subs_synced": "Subscriptions Synced",
|
||||
"admin_stats_sync_details_label": "Details",
|
||||
"admin_sync_status_never_run": "Panel sync never run.",
|
||||
|
||||
"admin_broadcast_enter_message": "Enter the broadcast message (HTML supported):",
|
||||
"admin_broadcast_confirm_prompt": "You are about to send the following message (first 200 characters):\n\n{message_preview}\n\nConfirm sending?",
|
||||
"admin_broadcast_confirm_prompt_short": "The message above will be sent. Confirm?",
|
||||
"broadcast_target_all_button": "👥 All",
|
||||
"broadcast_target_active_button": "✅ Active",
|
||||
"broadcast_target_inactive_button": "⌛ Inactive",
|
||||
"confirm_broadcast_send_button": "✅ Send",
|
||||
"cancel_broadcast_button": "❌ Cancel",
|
||||
"admin_broadcast_sending_started": "Starting broadcast...",
|
||||
"admin_broadcast_error_no_message": "Error: no message to broadcast.",
|
||||
"admin_broadcast_error_no_message_alert": "Broadcast message is empty!",
|
||||
"admin_broadcast_finished_stats": "🏁 Broadcast finished!\nSent: {sent_count}\nFailed: {failed_count}",
|
||||
"admin_broadcast_cancelled": "Broadcast cancelled.",
|
||||
"admin_broadcast_cancelled_alert": "Broadcast cancelled!",
|
||||
"admin_broadcast_cancelled_nav_back": "Broadcast cancelled. You are returned to the admin panel.",
|
||||
|
||||
"admin_promo_create_prompt": "Enter promo details in the format: CODE BONUS_DAYS MAX_USES [VALIDITY_DAYS]\nExample: <code>{example_format}</code>\n(Validity is optional; default is indefinite)",
|
||||
"admin_promo_invalid_format": "Invalid format. Please use: CODE BONUS_DAYS MAX_USES [VALIDITY_DAYS]",
|
||||
"broadcast_queue_result": "🚀 Broadcast queued!\n📤 Enqueued: {sent_count}\n❌ Errors: {failed_count}\n\n📊 Queue Status:\n👥 User queue: {user_queue_size} messages\n📢 Group queue: {group_queue_size} messages\n\nℹ️ Messages will be sent automatically within Telegram limits.",
|
||||
"admin_promo_invalid_code_format": "Code must be 3–30 alphanumeric characters.",
|
||||
"admin_promo_invalid_bonus_or_activations": "Bonus days and max uses must be positive numbers.",
|
||||
"admin_promo_invalid_bonus_days": "Bonus days must be a positive number.",
|
||||
"admin_promo_invalid_max_activations": "Max activations must be a positive number.",
|
||||
"admin_promo_invalid_validity_days": "Validity period (in days) must be a positive number.",
|
||||
"admin_promo_invalid_values": "Invalid values. {error}",
|
||||
"admin_promo_invalid_format_general": "Promo parsing error. Check the format.",
|
||||
"admin_promo_created_success": "✅ Promo code <code>{code}</code> created successfully!\nBonus: {bonus_days} days\nMax uses: {max_activations}\nValid until: {valid_until_str}.",
|
||||
"admin_promo_creation_failed_duplicate": "❌ Error: Promo code <code>{code}</code> already exists.",
|
||||
"admin_promo_creation_failed": "❌ Failed to create promo code. Please try again later.",
|
||||
"admin_promo_set_validity_days": "⏰ Set validity (days)",
|
||||
"admin_back_to_panel": "⬅️ Back to panel",
|
||||
"admin_promo_unlimited": "♾️ Unlimited",
|
||||
"admin_bulk_promo_created_title": "📦 Bulk creation completed",
|
||||
"admin_bulk_promo_created_stats": "📊 Created: <b>{created}</b> of <b>{total}</b>",
|
||||
"admin_bulk_promo_settings": "🎁 Bonus days: <b>{bonus_days}</b>\n📊 Max activations: <b>{max_activations}</b>\n⏰ Validity: <b>{validity}</b>",
|
||||
"admin_promo_list_page_info": "Page {current}/{total} ({count} promo codes)",
|
||||
"admin_queue_status_button": "📊 Queue Status",
|
||||
"admin_queue_status_title": "📊 Message Queue Status",
|
||||
"admin_queue_status_info": "📤 <b>Message Queues:</b>\n\n👥 <b>Users (25 msg/sec):</b>\n 📋 In queue: {user_queue_size}\n 🔄 Processing: {user_processing}\n 📈 Sent per minute: {user_recent}\n\n📢 <b>Groups/channels (15 msg/min):</b>\n 📋 In queue: {group_queue_size}\n 🔄 Processing: {group_processing}\n 📈 Sent per minute: {group_recent}",
|
||||
"admin_active_promos_list_header": "Active Promo Codes:",
|
||||
"admin_no_active_promos": "No active promo codes.",
|
||||
"admin_promo_list_item": "<code>{code}</code>: +{bonus}d, {current}/{max} used, valid until {valid_until}",
|
||||
"admin_promo_valid_indefinitely": "indefinite",
|
||||
"admin_promo_valid_until_display": "until {date}",
|
||||
"admin_manage_promos_title": "Manage Promo Codes",
|
||||
"admin_promo_edit_button": "✏️ Edit",
|
||||
"admin_promo_delete_button": "🗑 Delete",
|
||||
"admin_promo_edit_prompt": "Send new details for <code>{code}</code> in format: CODE BONUS_DAYS MAX_USES [VALIDITY_DAYS]",
|
||||
"admin_promo_updated_success": "Promo <code>{code}</code> updated.",
|
||||
"admin_promo_deleted_success": "Promo <code>{code}</code> deleted.",
|
||||
"admin_promo_deleted_success": "Promo {code} deleted.",
|
||||
"admin_promo_not_found": "Promo not found.",
|
||||
|
||||
"admin_promo_export_csv_button": "📄 Export to CSV",
|
||||
"admin_promo_export_caption": "📄 Activations for promo code {code}",
|
||||
"admin_promo_export_all_generating": "📄 Generating CSV...",
|
||||
"admin_promo_export_all_caption": "📄 Export of all promo codes\n📊 Total: {count} promo codes",
|
||||
"admin_promo_csv_code": "Code",
|
||||
"admin_promo_csv_bonus_days": "Bonus Days",
|
||||
"admin_promo_csv_max_activations": "Max Activations",
|
||||
"admin_promo_csv_current_activations": "Current Activations",
|
||||
"admin_promo_csv_status": "Status",
|
||||
"admin_promo_csv_is_active": "Active",
|
||||
"admin_promo_csv_valid_until": "Valid Until",
|
||||
"admin_promo_csv_created_at": "Created",
|
||||
"admin_promo_csv_created_by_admin_id": "Created By (Admin ID)",
|
||||
"csv_yes": "Yes",
|
||||
"csv_no": "No",
|
||||
"admin_promo_edit_select_field": "Select a field to edit:",
|
||||
"admin_promo_prompt_bonus_days": "Enter the new number of bonus days:",
|
||||
"admin_promo_prompt_max_activations": "Enter the new maximum number of activations:",
|
||||
"admin_promo_prompt_validity_days": "Enter the new validity period in days (0 for indefinite):",
|
||||
"admin_promo_edit_success": "Promo code updated successfully.",
|
||||
"admin_promo_invalid_input": "Invalid input, please try again.",
|
||||
"admin_promo_edit_bonus_days": "🎁 Bonus Days",
|
||||
"admin_promo_edit_max_activations": "🔢 Max Activations",
|
||||
"admin_promo_edit_validity": "⏰ Validity",
|
||||
"admin_ban_user_prompt": "Enter user ID or @username to ban:",
|
||||
"admin_user_not_found_in_bot_db": "User <code>{user_id}</code> not found in bot database.",
|
||||
"admin_cannot_ban_self_or_admin": "You cannot ban yourself or another admin.",
|
||||
"admin_user_already_banned": "User {user_id_or_username} is already banned.",
|
||||
"admin_user_banned_success_combined": "✅ User {user_id_or_username} banned in bot {panel_status_part}.",
|
||||
"admin_panel_ban_success_part": "and on panel",
|
||||
"admin_panel_ban_fail_part": ", but failed to ban on panel",
|
||||
"admin_user_ban_failed_local_db_error": "❌ Failed to ban user in local database.",
|
||||
"admin_unban_user_prompt": "Enter user ID or @username to unban:",
|
||||
"admin_user_not_banned": "User {user_id_or_username} is not banned.",
|
||||
"admin_user_unbanned_success_combined": "✅ User {user_id_or_username} unbanned in bot {panel_status_part}.",
|
||||
"admin_panel_unban_success_part": "and on panel",
|
||||
"admin_panel_unban_fail_part": ", but failed to unban on panel",
|
||||
"admin_user_unban_failed_local_db_error": "❌ Failed to unban user in local database.",
|
||||
"admin_no_banned_users": "There are no banned users at the moment.",
|
||||
"admin_banned_list_title": "Banned Users (page {current_page}/{total_pages}):",
|
||||
"admin_user_not_banned": "⚠️ User is not banned",
|
||||
"admin_banned_user_button_text": "{user_display} (ID: {user_id})",
|
||||
"prev_page_button": "⬅️ Prev.",
|
||||
"next_page_button": "Next ➡️",
|
||||
"admin_user_card_title": "User Profile: {user_display}",
|
||||
"user_card_info": "ID: <code>{user_id}</code>\nUsername: @{username}\nName: {first_name} {last_name}\nLanguage: {language_code}\nPanel UUID: <code>{panel_user_uuid}</code>\nStatus: <b>{ban_status}</b>\nRegistered: {reg_date}\nSubscription until: <b>{sub_end_date}</b>",
|
||||
"user_card_banned": "BANNED",
|
||||
"user_card_active": "Active",
|
||||
"user_card_sub_na": "n/a",
|
||||
"admin_user_card_title": "User Card",
|
||||
"user_card_ban_button": "🚫 Ban",
|
||||
"user_card_unban_button": "✅ Unban",
|
||||
"user_card_back_to_banned_list_button": "⬅️ Back to Ban List",
|
||||
"admin_confirm_action_title": "Confirm: {action_text}",
|
||||
"ban_verb_l": "ban",
|
||||
"unban_verb_l": "unban",
|
||||
"admin_confirm_ban_prompt": "Are you sure you want to ban {user_display} (ID: {user_id})?",
|
||||
"admin_confirm_unban_prompt": "Are you sure you want to unban {user_display} (ID: {user_id})?",
|
||||
"admin_user_banned_from_card_alert": "User {user_display} (ID: {user_id}) has been banned.",
|
||||
"admin_user_unbanned_from_card_alert": "User {user_display} (ID: {user_id}) has been unbanned.",
|
||||
"admin_user_ban_failed_db_error": "Error banning user in database.",
|
||||
"admin_user_unban_failed_db_error": "Error unbanning user in database.",
|
||||
"admin_panel_status_update_fail_part": ", but panel status not updated",
|
||||
|
||||
"admin_logs_menu_title": "Logs Menu:",
|
||||
"admin_view_all_logs_button": "📜 All Message Logs",
|
||||
"admin_view_user_logs_prompt_button": "👤 User Logs",
|
||||
"admin_all_logs_title": "All Logs (page {current_page}/{total_pages}):",
|
||||
"admin_export_logs_csv_button": "📄 Export to CSV",
|
||||
"admin_no_logs_found": "No logs found.",
|
||||
"admin_log_entry_format": "<code>{timestamp_str}</code> - <b>{user_display}</b> (ID: {user_id})\n <i>{event_type}</i>: {content_preview}",
|
||||
"system_or_unknown_user": "System/Unknown",
|
||||
"admin_prompt_for_user_id_or_username_logs": "Enter user ID or @username to view logs:",
|
||||
"admin_log_user_not_found": "User \"{input}\" not found in bot database.",
|
||||
"admin_user_logs_title": "Logs for {user_display} (page {current_page}/{total_pages}):",
|
||||
|
||||
"sync_started": "🔄 Starting data sync with panel...",
|
||||
"sync_failed": "❌ Sync with panel failed. Details: {details}",
|
||||
"sync_completed": "✅ Sync with panel completed. Status: {status}. Details: {details}",
|
||||
"sync_completed_details": "Checked: {total_checked} entries.\nUsers synced/updated: {users_synced}.\nSubscriptions synced/updated: {subs_synced}.",
|
||||
"sync_completed_with_errors_details": "Checked: {total_checked} entries.\nUsers synced/updated: {users_synced}.\nSubscriptions synced/updated: {subs_synced}.\nErrors: {errors_count}.\n\nFirst errors:\n{error_details_preview}",
|
||||
"no_errors_placeholder": "none",
|
||||
"sync_started_simple": "🔄 Starting synchronization...",
|
||||
"sync_success_simple": "✅ Synchronization completed successfully",
|
||||
"sync_failed_simple": "❌ Synchronization failed",
|
||||
"sync_errors_simple": "⚠️ Synchronization completed with errors ({errors_count} errors)",
|
||||
"sync_critical_error": "❌ Critical synchronization error",
|
||||
"admin_sync_initiated_from_panel": "Sync initiated...",
|
||||
"admin_panel_user_creation_failed": "❌ Failed to create panel user for TG ID {user_id}. Panel unreachable?",
|
||||
"admin_broadcast_invalid_html": "❌ Invalid HTML in message. Please send valid HTML (Telegram-supported tags) or remove tags.",
|
||||
"error_displaying_logs_too_long": "Error: logs too long to display in one message. Try viewing logs for a specific user.",
|
||||
"error_displaying_statistics": "Error displaying statistics.",
|
||||
"stub_page_display": "Page",
|
||||
"subscription_72h_notification": "👋 Hi, {user_name}!\n\n⏳ Your VPN subscription expires in 3 days — {end_date}.\n\nPlease renew it using the button below.",
|
||||
"subscription_48h_notification": "👋 Hi, {user_name}!\n\n⏳ Your VPN subscription expires in 2 days — {end_date}.\n\nPlease renew it using the button below.",
|
||||
"subscription_24h_notification": "👋 Hi, {user_name}!\n\n⏳ Your VPN subscription expires in 1 day — {end_date}.\n\nPlease renew it using the button below.",
|
||||
"subscription_expired_notification": "👋 Hi, {user_name}!\n\n⛔ Your VPN subscription expired on {end_date}.\n\nPlease renew it using the button below.",
|
||||
"subscription_expired_yesterday_notification": "👋 Hi, {user_name}!\n\n⏳ Your VPN subscription expired yesterday ({end_date}).\n\nPlease renew it using the button below.",
|
||||
"autorenew_48h_charge_tomorrow_notice": "🔔 Reminder\n\nTomorrow an automatic charge will occur to renew your subscription. If you don't want auto-renew, disable it using the button below.",
|
||||
"autorenew_confirm_enable": "🔄 Enable auto-renew? An automatic charge will be attempted before your subscription ends.",
|
||||
"autorenew_confirm_disable": "🛑 Disable auto-renew? No further automatic charges will occur.",
|
||||
"tribute_subscription_cancelled": "🚨 <b>Subscription Cancelled</b>\n\nYour Tribute subscription has been cancelled. You have 24 hours to restore access, after which the subscription will be blocked.\n\nTo renew your subscription, press the button below.",
|
||||
"tribute_auto_renewal": "🔄 <b>Subscription Auto-Renewed</b>\n\nYour Tribute subscription has been automatically renewed for {months} months.\nNew expiration date: {end_date}",
|
||||
"yookassa_auto_renewal": "🔄 <b>Subscription Auto-Renewed</b>\n\nYour subscription was automatically renewed for {months} month(s).\nNew expiration date: {end_date}",
|
||||
"admin_user_management_prompt": "👤 User Management\n\nEnter user ID or @username to search:",
|
||||
"admin_user_subscription_info": "Subscription Information:",
|
||||
"admin_user_reset_trial_button": "🔄 Reset Trial",
|
||||
"admin_user_add_subscription_button": "➕ Add Days",
|
||||
"admin_user_toggle_ban_button": "🚫 Block/Unblock",
|
||||
"admin_user_send_message_button": "✉️ Send Message",
|
||||
"admin_user_view_logs_button": "📜 User Actions",
|
||||
"admin_user_refresh_button": "🔄 Refresh",
|
||||
"admin_user_search_new_button": "🔍 Find Another",
|
||||
"admin_user_view_all_logs_button": "📋 All Actions",
|
||||
"admin_user_back_to_card_button": "🔙 Back to Card",
|
||||
"admin_user_recent_actions_title": "📜 Recent actions of user {user_id}:",
|
||||
"admin_user_not_found": "❌ User not found: {input}",
|
||||
"admin_user_not_found_action": "User not found",
|
||||
"admin_user_card_error": "❌ Error displaying user card",
|
||||
"admin_user_trial_reset_success": "✅ Trial reset! User can activate trial again.",
|
||||
"admin_user_trial_reset_error": "❌ Error resetting trial",
|
||||
"admin_user_add_subscription_prompt": "➕ Adding subscription days for user {user_id}\n\nEnter number of days to add:",
|
||||
"admin_user_invalid_days": "❌ Invalid number of days. Enter number from 1 to 3650.",
|
||||
"admin_user_subscription_added_success": "✅ Successfully added {days} days to user {user_id}",
|
||||
"admin_user_subscription_added_error": "❌ Error adding subscription days",
|
||||
"admin_user_ban_toggle_success": "✅ User {status}",
|
||||
"admin_user_ban_toggle_error": "❌ Error changing ban status",
|
||||
"admin_user_ban_success": "✅ User {input} has been banned",
|
||||
"admin_user_unban_success": "✅ User {input} has been unbanned",
|
||||
"admin_user_ban_error": "❌ Error banning user",
|
||||
"admin_user_unban_error": "❌ Error unbanning user",
|
||||
"admin_user_send_message_prompt": "✉️ Sending message to user {user_id}\n\nEnter message text:",
|
||||
"admin_user_message_too_long": "❌ Message too long (maximum 4000 characters)",
|
||||
"admin_user_message_sent_success": "✅ Message sent to user {user_id}",
|
||||
"admin_user_message_sent_error": "❌ Error sending message",
|
||||
"admin_user_no_logs": "📜 User has no actions",
|
||||
"admin_user_logs_error": "❌ Error loading user actions",
|
||||
"admin_direct_message_signature": "\n\n---\n💬 Message from administrator",
|
||||
"inline_user_stats_message": "👥 <b>User Statistics</b>\n\n📊 Total: <b>{total}</b>\n📈 Active today: <b>{active_today}</b>\n💳 With paid subscription: <b>{paid}</b>\n🆓 On trial period: <b>{trial}</b>\n😴 Inactive: <b>{inactive}</b>\n🚫 Banned: <b>{banned}</b>\n🎁 Via referral program: <b>{referral}</b>",
|
||||
"inline_user_stats_description": "Total: {total}, Paid: {active}",
|
||||
"inline_admin_user_stats_title": "👥 User Statistics",
|
||||
"inline_financial_stats_message": "💰 <b>Financial Statistics</b>\n\n📅 Today: <b>{today:.2f} RUB</b>\n ({today_count} payments)\n📅 Week: <b>{week:.2f} RUB</b>\n📅 Month: <b>{month:.2f} RUB</b>\n🏆 All time: <b>{all_time:.2f} RUB</b>",
|
||||
"inline_admin_financial_stats_title": "💰 Financial Statistics",
|
||||
"inline_system_stats_message": "🖥 <b>Panel Statistics</b>\n\n🟢 Online: <b>{online}</b>\n📊 Active: <b>{active}</b>\n🔴 Disabled: <b>{disabled}</b>\n⏰ Expired: <b>{expired}</b>\n⚠️ Limited: <b>{limited}</b>\n👥 Total users: <b>{total}</b>\n💾 RAM Usage: <b>{memory:.1f}%</b>\n📊 Week traffic: <b>{week_traffic}</b>\n📊 Month traffic: <b>{month_traffic}</b>\n🔗 Active nodes: <b>{active_nodes}/{total_nodes}</b>",
|
||||
"inline_admin_system_stats_title": "🖥 System Statistics",
|
||||
"log_referral_suffix": " (referral from {referrer_id})",
|
||||
"log_new_user_registration": "👤 <b>New User</b>\n\n🆔 ID: <code>{user_id}</code>\n👤 Name: {user_display}{referral_text}\n📅 Time: {timestamp}",
|
||||
"log_payment_received": "{provider_emoji} <b>Payment Received</b>\n\n👤 User: {user_display}\n💰 Amount: <b>{amount} {currency}</b>\n📅 Period: <b>{months} mo.</b>\n🏦 Provider: {payment_provider}\n🕐 Time: {timestamp}",
|
||||
"log_promo_activation": "🎁 <b>Promo Code Activated</b>\n\n👤 User: {user_display}\n🏷 Code: <code>{promo_code}</code>\n🎯 Bonus: <b>+{bonus_days}d</b>\n🕐 Time: {timestamp}",
|
||||
"log_trial_activation": "🆓 <b>Trial Activated</b>\n\n👤 User: {user_display}\n⏰ Valid until: <b>{end_date}</b>\n🕐 Time: {timestamp}",
|
||||
"log_panel_sync": "{status_emoji} <b>Panel Synchronization</b>\n\n📊 Status: <b>{status}</b>\n👥 Users processed: <b>{users_processed}</b>\n📋 Subscriptions synced: <b>{subs_synced}</b>\n🕐 Time: {timestamp}\n\n📝 Details:\n{details}",
|
||||
"log_suspicious_promo": "⚠️ <b>Suspicious Promo Code Attempt</b>\n\n👤 User: {user_display}\n🆔 ID: <code>{user_id}</code>\n📝 Input: <pre>{suspicious_input}</pre>\n🕐 Time: {timestamp}",
|
||||
"admin_logs_csv_export_started": "📄 Starting log export to CSV...",
|
||||
"admin_logs_csv_export_success": "✅ Logs exported! File attached above.",
|
||||
"admin_user_logs_title": "Logs for {user_display} (page {current_page}/{total_pages}):",
|
||||
"admin_all_logs_title": "All Logs (page {current_page}/{total_pages}):",
|
||||
"admin_csv_header_log_id": "Log ID",
|
||||
"admin_csv_header_timestamp": "Timestamp",
|
||||
"admin_csv_header_user_id": "User ID",
|
||||
"admin_csv_header_telegram_username": "Username",
|
||||
"admin_csv_header_telegram_first_name": "First Name",
|
||||
"admin_csv_header_event_type": "Event Type",
|
||||
"admin_csv_header_content": "Content",
|
||||
"admin_csv_header_is_admin_event": "Admin Event",
|
||||
"admin_csv_header_target_user_id": "Target User ID",
|
||||
"admin_csv_header_raw_update_preview": "Update Preview",
|
||||
"admin_banned_users_empty": "📋 Banned Users\n\nList is empty",
|
||||
"admin_banned_users_list": "📋 Banned Users ({count}):\n\n{users}",
|
||||
"admin_panel_stats_header": "Panel Statistics",
|
||||
"admin_bulk_promo_step1_quantity": "📦 <b>Bulk Promo Code Creation</b>\n\n<b>Step 1 of 4:</b> Quantity\n\nEnter the number of promo codes to create (1-1000):",
|
||||
"admin_bulk_promo_step2_bonus_days": "📦 <b>Bulk Promo Code Creation</b>\n\n<b>Step 2 of 4:</b> Bonus Days\n\nQuantity: <b>{quantity}</b>\n\nEnter the number of bonus days (1-365):",
|
||||
"admin_bulk_promo_step3_max_activations": "📦 <b>Bulk Promo Code Creation</b>\n\n<b>Step 3 of 4:</b> Max Activations\n\nQuantity: <b>{quantity}</b>\nBonus days: <b>{bonus_days}</b>\n\nEnter the maximum number of activations for each promo code (1-10000):",
|
||||
"admin_bulk_promo_step4_validity": "📦 <b>Bulk Promo Code Creation</b>\n\n<b>Step 4 of 4:</b> Validity Period\n\nQuantity: <b>{quantity}</b>\nBonus days: <b>{bonus_days}</b>\nMax activations: <b>{max_activations}</b>\n\nChoose the validity period for promo codes:",
|
||||
"admin_bulk_promo_invalid_quantity": "❌ Quantity must be between 1 and 1000",
|
||||
"admin_bulk_promo_enter_validity_days": "⏰ Enter the number of validity days for promo codes (1-365):",
|
||||
"admin_bulk_promo_creating": "⏳ Creating {quantity} promo codes...",
|
||||
"admin_promo_step1_code": "🎟 <b>Create Promo Code</b>\n\n<b>Step 1 of 4:</b> Promo Code\n\nEnter promo code (3-30 characters, letters and numbers only):",
|
||||
"admin_promo_step2_bonus_days": "🎟 <b>Create Promo Code</b>\n\n<b>Step 2 of 4:</b> Bonus Days\n\nCode: <b>{code}</b>\n\nEnter the number of bonus days (1-365):",
|
||||
"admin_promo_step3_max_activations": "🎟 <b>Create Promo Code</b>\n\n<b>Step 3 of 4:</b> Max Activations\n\nCode: <b>{code}</b>\nBonus days: <b>{bonus_days}</b>\n\nEnter the maximum number of activations (1-10000):",
|
||||
"admin_promo_step4_validity": "🎟 <b>Create Promo Code</b>\n\n<b>Step 4 of 4:</b> Validity Period\n\nCode: <b>{code}</b>\nBonus days: <b>{bonus_days}</b>\nMax activations: <b>{max_activations}</b>\n\nChoose the validity period for the promo code:",
|
||||
"admin_promo_code_already_exists": "❌ A promo code with this code already exists",
|
||||
"admin_promo_unlimited_validity": "♾️ Unlimited",
|
||||
"admin_promo_enter_validity_days": "⏰ Enter the number of validity days for the promo code (1-365):",
|
||||
"admin_user_id_label": "🆔 <b>ID:</b>",
|
||||
"admin_user_name_label": "👤 <b>Name:</b>",
|
||||
"admin_user_username_label": "📱 <b>Username:</b>",
|
||||
"admin_user_language_label": "🌍 <b>Language:</b>",
|
||||
"admin_user_registration_label": "📅 <b>Registration:</b>",
|
||||
"admin_user_status_label": "🛡 <b>Status:</b>",
|
||||
"admin_user_referral_label": "🎁 <b>Referred by:</b>",
|
||||
"admin_user_panel_uuid_label": "🔗 <b>Panel UUID:</b>",
|
||||
"admin_user_panel_status_label": "📊 <b>Panel Status:</b>",
|
||||
"admin_user_traffic_label": "📊 <b>Traffic:</b>",
|
||||
"admin_user_subscription_label": "💼 <b>Subscription:</b>",
|
||||
"admin_user_trial_label": "🏡 <b>Trial:</b>",
|
||||
"admin_user_status_banned": "🚫 Banned",
|
||||
"admin_user_status_active": "✅ Active",
|
||||
"admin_user_trial_used": "Used",
|
||||
"admin_user_trial_not_used": "Not used",
|
||||
"admin_user_ban_action_banned": "banned",
|
||||
"admin_user_ban_action_unbanned": "unbanned",
|
||||
"admin_user_na_value": "N/A",
|
||||
"admin_user_subscription_none": "No active subscription",
|
||||
"admin_user_actions_count_label": "📜 <b>Total actions:</b>",
|
||||
"admin_user_invited_friends_label": "👥 <b>Friends invited:</b>",
|
||||
"admin_user_ref_purchased_label": "💳 <b>Purchased subscription:</b>",
|
||||
"admin_user_subscription_active_until": "⏰ <b>Active until:</b>",
|
||||
"admin_user_subscription_error": "Loading error",
|
||||
"admin_promo_management_button": "🎟 Promo Management",
|
||||
|
||||
"subscription_ending_soon_notification": "👋 Hi, {user_name}!\n\n⏳ Your VPN subscription ends on {end_date} (in {days_left} days).\n\nTo avoid interruption, please renew it in the main menu.",
|
||||
"subscription_cancelled_notification": "Your recurring subscription was cancelled. You will keep access until the paid period ends.",
|
||||
|
||||
"admin_new_trial_notification": "\ud83c\udf21 User {user_id} activated a free trial until {end_date}.",
|
||||
"admin_new_payment_notification": "\ud83d\udcb3 Payment received from user {user_id}: {months} mo. for {amount} {currency}.",
|
||||
|
||||
"error_unknown": "An unknown error occurred."
|
||||
"admin_promo_management_title": "🎟 <b>Promo Code Management</b>\n\nSelect a promo code for detailed view:",
|
||||
"admin_promo_management_empty": "📭 No promo codes available",
|
||||
"admin_promo_card_title": "🎟 <b>Promo Code: {code}</b>",
|
||||
"admin_promo_card_bonus_days": "🎁 Bonus days: <b>{days}</b>",
|
||||
"admin_promo_card_activations": "🔢 Activations: <b>{current}/{max}</b>",
|
||||
"admin_promo_card_validity": "⏰ Valid until: <b>{validity}</b>",
|
||||
"admin_promo_card_status": "📊 Status: <b>{status}</b>",
|
||||
"admin_promo_card_created": "📅 Created: <b>{created}</b>",
|
||||
"admin_promo_card_created_by": "👤 Created by: <b>{creator}</b>",
|
||||
"admin_promo_status_active": "✅ Active",
|
||||
"admin_promo_status_inactive": "🚫 Inactive",
|
||||
"admin_promo_status_activated": "activated",
|
||||
"admin_promo_status_deactivated": "deactivated",
|
||||
"admin_promo_status_expired": "⏰ Expired",
|
||||
"admin_promo_status_used_up": "🔄 Used up",
|
||||
"admin_promo_toggle_status_button": "🔄 On/Off",
|
||||
"admin_promo_view_activations_button": "📋 Activations",
|
||||
"admin_promo_back_to_list_button": "⬅️ Back to list",
|
||||
"admin_promo_toggle_success": "✅ Promo code {code} {status}",
|
||||
"admin_promo_no_activations": "📋 <b>Activations of promo code: {code}</b>\n\n❌ No activations found",
|
||||
"admin_promo_activations_header": "📋 <b>Activations of promo code: {code}</b>\n\n",
|
||||
"admin_promo_activation_item": "👤 User ID: <b>{user_id}</b>\n📅 Date: <b>{date}</b>\n",
|
||||
"admin_promo_back_to_detail_button": "⬅️ Back to promo",
|
||||
"admin_panel_online_label": "Online",
|
||||
"admin_panel_active_label": "Active",
|
||||
"admin_panel_disabled_label": "Disabled",
|
||||
"admin_panel_expired_label": "Expired",
|
||||
"admin_panel_limited_label": "Limited",
|
||||
"admin_panel_total_users_label": "Total users",
|
||||
"admin_panel_memory_usage_label": "RAM Usage",
|
||||
"admin_panel_traffic_week_label": "Traffic this week",
|
||||
"admin_panel_traffic_month_label": "Traffic this month",
|
||||
"admin_panel_nodes_label": "Active nodes",
|
||||
"admin_panel_system_stats_error": "Error getting system statistics",
|
||||
"admin_panel_bandwidth_stats_error": "Error getting bandwidth statistics",
|
||||
"admin_panel_stats_fetch_error": "Error fetching panel data",
|
||||
"admin_panel_stats_error_details": "Details",
|
||||
"inline_referral_title": "Referral link",
|
||||
"inline_referral_description": "Share referral link to get bonuses",
|
||||
"inline_financial_description": "Today: {today} RUB",
|
||||
"inline_system_description": "🟢 Online: {online}, 📊 Active: {active}",
|
||||
"admin_user_stats_total_label": "Total",
|
||||
"admin_user_stats_paid_subs_label": "With paid subscription",
|
||||
"admin_user_stats_trial_label": "On trial period",
|
||||
"admin_user_stats_inactive_label": "Inactive",
|
||||
"admin_user_stats_banned_label": "Banned",
|
||||
"admin_user_stats_referral_label": "Attracted via referral program",
|
||||
"admin_financial_today_label": "Today",
|
||||
"admin_financial_week_label": "This week",
|
||||
"admin_financial_month_label": "This month",
|
||||
"admin_financial_all_time_label": "All time",
|
||||
"admin_financial_payments_label": "payments",
|
||||
"admin_sync_details": "📊 Synchronization Statistics:\n🔍 Panel records checked: {panel_records_checked}\n👥 Users found in DB: {users_found_in_db}\n✨ New users created: {users_created}\n🔄 Users updated: {users_updated}\n📋 Subscriptions synced: {subscriptions_synced_count}\n ├── Created new: {subscriptions_created}\n └── Updated existing: {subscriptions_updated}{additional_stats}",
|
||||
"admin_sync_no_telegram_id": "\n⚠️ Records without telegramId: {count}",
|
||||
"admin_sync_not_found_in_db": "\n❌ Not found in DB: {count}",
|
||||
"admin_payments_pagination_info": "📊 Showing {shown} of {total} payments (page {current_page}/{total_pages})",
|
||||
"my_subscription_details": "🔐 <b>My Subscription</b>\n\n⏰ Status: <b>{status}</b>\n📅 Active until: <b>{end_date}</b>\n📆 Days left: <b>{days_left}</b>\n\n🔗 Configuration link:\n<code>{config_link}</code>\n\n📊 Traffic:\nLimit: <b>{traffic_limit}</b>\nUsed: <b>{traffic_used}</b>",
|
||||
"autorenew_enable_button": "🔄 Enable auto-renew",
|
||||
"autorenew_disable_button": "🛑 Disable auto-renew",
|
||||
"subscription_autorenew_updated": "Auto-renew settings updated.",
|
||||
"payment_methods_manage_button": "💳 Payment Methods",
|
||||
"payment_methods_title": "💳 <b>Payment Methods</b>",
|
||||
"payment_method_bind_button": "➕ Add method",
|
||||
"payment_method_delete_button": "🗑 Remove",
|
||||
"payment_method_view_button": "ℹ️ Details",
|
||||
"payment_method_none": "You don't have a saved payment method yet.",
|
||||
"payment_method_bound_success": "✅ Payment method added.",
|
||||
"payment_method_deleted_success": "✅ Payment method removed.",
|
||||
"payment_method_delete_confirm": "Remove saved payment method?",
|
||||
"payment_method_card_title": "💳 {network} ••••{last4}",
|
||||
"payment_method_generic_title": "💳 {network}",
|
||||
"payment_method_wallet_title": "💼 YooMoney wallet ••••{last4}",
|
||||
"payment_network_card": "Card",
|
||||
"payment_network_generic": "Payment method",
|
||||
"payment_method_added_at": "Added: {date}",
|
||||
"payment_method_last_tx": "Last transaction: {date}",
|
||||
"payment_method_tx_history_title": "📜 Transactions history",
|
||||
"payment_method_no_history": "No transactions history.",
|
||||
"subscription_purchase_title": "Subscription purchase for {months} mo.",
|
||||
"subscription_tribute_notice": "Paid via Tribute. Renew using your Tribute link.",
|
||||
"subscription_tribute_notice_with_link": "Paid via Tribute. Renew: {link}",
|
||||
"subscription_autorenew_not_supported_for_tribute": "Auto-renew is handled by Tribute. Manage renewal in the Tribute app/link.",
|
||||
"subscription_not_active": "You don't have an active subscription.",
|
||||
"error_service_unavailable": "Service unavailable. Please try again later.",
|
||||
"error_payment_gateway": "Payment service error. Please try again later.",
|
||||
"traffic_na": "N/A",
|
||||
"payment_service_unavailable": "Payment service is unavailable.",
|
||||
"payment_service_unavailable_alert": "Payment service is unavailable. Please try again later.",
|
||||
"error_creating_payment_record": "Error creating payment record. Please try again later.",
|
||||
"error_payment_gateway_link_failed": "Error creating payment link. Please try again later.",
|
||||
"status_active": "Active",
|
||||
"status_inactive": "Inactive",
|
||||
"admin_ads_section": "📈 Ads",
|
||||
"admin_ads_header": "📈 Ad Campaigns:",
|
||||
"admin_ads_empty": "📭 No ad campaigns. Click \"Create\" to add one.",
|
||||
"admin_ads_item": "ID: {id}\nSource: <b>{source}</b>\nstart={start_param}\nCost: {cost} RUB\nActive: {active}\n— Starts: {starts}\n— Trials: {trials}\n— Payers: {payers}\n— Revenue: {revenue} RUB",
|
||||
"admin_ads_create_button": "➕ Create campaign",
|
||||
"admin_ads_create_source_prompt": "Enter source (e.g., AEZA, VK, TG-channel):",
|
||||
"admin_ads_create_start_param_prompt": "Enter start link parameter (e.g., AEZA). Will be used as start=AEZA",
|
||||
"admin_ads_create_cost_prompt": "Enter campaign cost (RUB):",
|
||||
"admin_ads_invalid_source": "❌ Invalid source. Enter up to 64 characters.",
|
||||
"admin_ads_invalid_start_param": "❌ Invalid parameter. Allowed letters/digits/underscore/dash (2-64).",
|
||||
"admin_ads_invalid_cost": "❌ Invalid amount. Enter a non-negative number.",
|
||||
"admin_ads_start_param_exists": "❌ A campaign with this start parameter already exists.",
|
||||
"admin_ads_created_success": "✅ Campaign created!\nID: {id}\nSource: {source}\nParam: {start_param}\nCost: {cost} RUB",
|
||||
"admin_ads_back_to_menu_hint": "Done. Back to Ads section:",
|
||||
"admin_ads_overview": "📈 <b>Ads</b>\n💰 Revenue: <b>{revenue} RUB</b>\n💸 Spent: <b>{cost} RUB</b>",
|
||||
"back_to_ads_list_button": "⬅️ Back to list",
|
||||
"admin_ads_card": "📈 <b>Campaign #{id}</b>\nSource: <b>{source}</b>\nstart=<code>{start_param}</code>\nCost: <b>{cost} RUB</b>\nActive: {active}\n\n👥 Starts: <b>{starts}</b>\n🆓 Trials: <b>{trials}</b>\n💳 Payers: <b>{payers}</b>\n💵 Revenue: <b>{revenue} RUB</b>",
|
||||
"admin_ads_delete_button": "🗑 Delete campaign",
|
||||
"admin_ads_delete_confirm": "Are you sure you want to delete campaign #{id}? This action is irreversible.",
|
||||
"admin_ads_deleted_success": "Campaign deleted.",
|
||||
"admin_ads_not_found": "Campaign not found."
|
||||
}
|
||||
|
||||
+325
-108
@@ -5,108 +5,112 @@
|
||||
"menu_activate_trial_button": "🆓 Пробный период",
|
||||
"menu_subscribe_inline": "🚀 Купить",
|
||||
"menu_my_subscription_inline": "🔐 Моя подписка",
|
||||
"no_subscription_options_available": "Выдача подписки не натроена администратором бота",
|
||||
"menu_referral_inline": "🎁 Рефералы",
|
||||
"referral_no_bonuses_configured": "Извините, реферальная программа в данный момент отключена",
|
||||
"menu_apply_promo_button": "🎟 Промокод",
|
||||
"menu_language_settings_inline": "🌐 Язык",
|
||||
"menu_server_status_button": "📊 Статус",
|
||||
"menu_support_button": "💬 Поддержка",
|
||||
"menu_terms_button": "📄 Условия сервиса",
|
||||
|
||||
"back_to_main_menu_button": "⬅️ Назад",
|
||||
|
||||
|
||||
"choose_language": "Выберите язык / Select language:",
|
||||
"language_set_alert": "Язык изменен!",
|
||||
|
||||
"error_occurred_try_again": "Произошла ошибка, попробуйте снова.",
|
||||
"error_try_again": "Попробуйте еще раз.",
|
||||
"error_displaying_menu": "Ошибка отображения меню.",
|
||||
"main_menu_unknown_action": "Неизвестное действие.",
|
||||
|
||||
"select_subscription_period": "Выберите срок подписки:",
|
||||
"no_subscription_options_available": "В данный момент нет доступных вариантов подписки.",
|
||||
"subscribe_for_months_button": "{months} мес. - {price} {currency_symbol}",
|
||||
|
||||
"choose_payment_method": "Выберите способ оплаты:",
|
||||
"pay_button": "💳 Оплатить",
|
||||
"pay_with_yookassa_button": "💳 ЮKassa",
|
||||
"pay_with_cryptopay_button": "💎 CryptoBot",
|
||||
"pay_with_tribute_button": "❤️ Tribute",
|
||||
"pay_with_stars_button": "🌟 Звезды Telegram",
|
||||
"connect_button": "🔗 Подключиться",
|
||||
"cancel_button": "❌ Отмена",
|
||||
"payment_description_subscription": "Оплата подписки на {months} мес.",
|
||||
"payment_service_unavailable": "Платежный сервис временно недоступен. Пожалуйста, попробуйте позже.",
|
||||
"payment_service_unavailable_alert": "Платежный сервис недоступен",
|
||||
"error_creating_payment_record": "Ошибка при создании записи о платеже. Попробуйте позже.",
|
||||
"error_payment_gateway_link_failed": "Не удалось получить ссылку на оплату. Пожалуйста, свяжитесь с поддержкой.",
|
||||
"payment_link_message": "Для оплаты подписки на {months} мес., нажмите кнопку ниже:",
|
||||
"error_payment_gateway": "Ошибка платежного шлюза. Попробуйте позже или свяжитесь с поддержкой.",
|
||||
"payment_successful": "✅ Оплата прошла успешно!\nВаша подписка на {months} мес. активна до {end_date}.",
|
||||
"payment_successful_with_promo": "✅ Оплата прошла успешно!\nВаша подписка на {months} мес. (с учетом промокода на +{bonus_days} дней) активна до {end_date}.",
|
||||
"payment_successful_with_referral_bonus": "✅ Оплата прошла успешно!\nВаша подписка на {months} мес. (базовая дата окончания: {base_end_date}) продлена на {bonus_days} бонусных дней за приглашение от {inviter_name} и теперь активна до {final_end_date}.",
|
||||
"payment_successful_error_details": "✅ Оплата прошла успешно, но возникла ошибка при отображении деталей. Ваша подписка активна. Свяжитесь с поддержкой, если что-то не так.",
|
||||
"payment_successful_full": "✅ Оплата прошла успешно!\nВаша подписка на {months} мес. активна до {end_date}.\n\nКлюч подключения:\n<code>{config_link}</code>\n\nЧтобы подключиться, перейдите по ссылке и следуйте инструкции 👇",
|
||||
"payment_successful_with_referral_bonus_full": "✅ Оплата прошла успешно!\nВаша подписка на {months} мес. (базовая дата окончания: {base_end_date}) продлена на {bonus_days} бонусных дней за приглашение от {inviter_name} и теперь активна до {final_end_date}.\n\nКлюч подключения:\n<code>{config_link}</code>\n\nЧтобы подключиться, перейдите по ссылке и следуйте инструкции 👇",
|
||||
"payment_failed": "❌ Оплата не удалась или была отменена. Пожалуйста, попробуйте еще раз или свяжитесь с поддержкой.",
|
||||
"error_processing_your_payment": "Произошла ошибка при обработке вашего платежа после его успеха. Пожалуйста, свяжитесь с поддержкой, указав детали.",
|
||||
|
||||
"my_subscription_details": "<b>🔐 Моя подписка:</b>\n\nСтатус: <b>{status}</b>\nАктивна до: <b>{end_date}</b> (осталось дней: {days_left})\n\nТрафик: <b>{traffic_used}</b> из <b>{traffic_limit}</b>\n\nСсылка на конфигурацию:\n<code>{config_link}</code>",
|
||||
"config_link_not_available": "недоступна, обратитесь в поддержку",
|
||||
"subscription_not_active": "У вас нет активной подписки. \nХотите приобрести?",
|
||||
"status_active": "Активна",
|
||||
"status_expired": "Истекла",
|
||||
"status_disabled": "Отключена",
|
||||
"traffic_unlimited": "Безлимитный",
|
||||
"traffic_na": "н/д",
|
||||
"error_service_unavailable": "Сервис временно недоступен. Пожалуйста, попробуйте позже.",
|
||||
|
||||
"promo_code_prompt": "Пожалуйста, введите ваш промокод:",
|
||||
"promo_code_not_found": "Промокод <code>{code}</code> не найден, истек или уже использован максимальное количество раз.",
|
||||
"promo_code_already_used_by_user": "Вы уже активировали промокод <code>{code}</code>.",
|
||||
"promo_code_no_active_subscription": "Для активации этого промокода у вас должна быть активная подписка.",
|
||||
"promo_code_applied_success": "✅ Промокод <code>{code}</code> успешно применен!\nВаша подписка продлена на {bonus_days} дней и теперь активна до {new_end_date}.",
|
||||
"promo_code_applied_success_full": "✅ Промокод успешно применен!\nПодписка активна до {end_date}.\n\nКлюч подключения:\n<code>{config_link}</code>\n\nЧтобы подключиться, перейдите по ссылке и следуйте инструкции 👇",
|
||||
"error_applying_promo_bonus": "Не удалось применить бонус по промокоду. Пожалуйста, попробуйте позже или свяжитесь с поддержкой.",
|
||||
"promo_input_cancelled_short": "Ввод промокода отменен.",
|
||||
|
||||
"trial_feature_disabled": "Пробный период в данный момент недоступен.",
|
||||
"trial_already_had_subscription_or_trial": "Вы уже использовали пробный период или у вас была платная подписка. Пробный период доступен только один раз для новых пользователей.",
|
||||
"trial_confirm_prompt": "Хотите активировать бесплатный пробный период на {days} дней с лимитом трафика {traffic_gb}?",
|
||||
"trial_confirm_activate_button": "✅ Активировать!",
|
||||
"trial_activated_alert": "Пробный период активирован!",
|
||||
"trial_activated_details_message": "🚀 Ваш пробный период на {days} дней активирован!\n\nДействует до: <b>{end_date}</b>\nТрафик: <b>{traffic_gb}</b>\n\nВаша ссылка на конфигурацию:\n<code>{config_link}</code>",
|
||||
"trial_activation_failed": "Не удалось активировать пробный период. Пожалуйста, попробуйте позже.",
|
||||
"trial_activation_failed_panel_link": "Не удалось связать ваш аккаунт с сервером для активации пробного периода. Попробуйте позже.",
|
||||
"trial_activation_failed_db": "Ошибка базы данных при активации пробного периода. Попробуйте позже.",
|
||||
"trial_activation_failed_panel_update": "Не удалось обновить детали на сервере для пробного периода. Попробуйте позже.",
|
||||
"user_not_found_for_trial": "Ваш аккаунт не найден в системе. Пожалуйста, сначала выполните команду /start.",
|
||||
"trial_activated_details_message": "✅ Пробный доступ активирован!\nВаш триал на {days} дн. действует до {end_date}.\n\nКлюч подключения:\n<code>{config_link}</code>\n\nЧтобы подключиться, перейдите по ссылке и следуйте инструкции 👇",
|
||||
"yes_button": "Да",
|
||||
"no_button": "Нет",
|
||||
"trial_cancelled_short": "Активация отменена.",
|
||||
|
||||
"referral_program_info_new": "Приглашайте друзей и получайте бонусы!\n\nВаша реф. ссылка:\n<code>{referral_link}</code>\n\n<b>Бонусы:</b>{bonus_details}",
|
||||
"referral_program_info_new": "🎁 <b>Реферальная программа</b>\n\n📊 <b>Твоя статистика:</b>\n👥 Приглашено друзей: <b>{invited_count}</b>\n💳 Купили подписку: <b>{purchased_count}</b>\n\n🔗 Твоя ссылка:\n<code>{referral_link}</code>\n\n💰 <b>Бонусы за приглашения:</b>\n{bonus_details}\n\n📢 Поделись ссылкой с друзьями и получай бонусы!",
|
||||
"referral_bonus_per_period": "\n\n🎁 За {months}-мес. подписку друга:\n ➢ Вы: <b>{inviter_bonus_days} дн.</b>\n ➢ Друг: <b>{referee_bonus_days} дн.</b>",
|
||||
"no_bonus_days": "0",
|
||||
"referral_no_bonuses_configured": "\nРеферальные бонусы не настроены.",
|
||||
"referral_link_for_copying_reminder": "Ссылка выше. Нажмите и удерживайте для копирования.",
|
||||
"referral_share_message_button": "📩 Сообщение для друга",
|
||||
"referral_friend_message": "🚀 Привет! Попробуй этот VPN - быстрый, надёжный и доступный!\n\n🎁 По моей ссылке тебе дадут бонусные дни к подписке!\n\n{referral_link}",
|
||||
"friend_placeholder": "друг",
|
||||
"referral_bonus_inviter_notification_extended": "🎉 Поздравляем! Ваш друг {referee_name} оплатил подписку. Вам начислено {days} бонусных дней! Ваша подписка теперь активна до {new_end_date}.",
|
||||
"referral_bonus_inviter_notification_new_sub": "🎉 Поздравляем! Ваш друг {referee_name} оплатил подписку. Вам начислена бонусная подписка на {days} дней! Она активна до {new_end_date}.",
|
||||
|
||||
"user_is_banned": "🚫 Ваш аккаунт заблокирован. Пожалуйста, свяжитесь со службой поддержки.",
|
||||
|
||||
"admin_panel_title": "Панель администратора",
|
||||
"admin_stats_button": "📊 Статистика",
|
||||
"admin_broadcast_button": "📢 Рассылка",
|
||||
"admin_create_promo_button": "🎁 Создать промо",
|
||||
"admin_manage_promos_button": "🛠 Управление промо",
|
||||
"admin_view_promos_button": "👀 Список промо",
|
||||
"admin_create_bulk_promo_button": "📦 Массовое создание",
|
||||
"admin_ban_user_button": "🚫 Забанить",
|
||||
"admin_unban_user_button": "✅ Разбанить",
|
||||
"admin_view_banned_users_button": "📜 Бан-лист",
|
||||
"admin_view_logs_menu_button": "📄 Логи",
|
||||
"admin_sync_panel_button": "🔄 Синхронизация",
|
||||
"admin_unknown_action": "Неизвестное действие администратора.",
|
||||
"admin_action_cancelled_default": "Действие отменено. Возврат в меню.",
|
||||
"admin_action_cancelled_default_alert": "Действие отменено",
|
||||
"admin_stats_and_monitoring_section": "📊 Статистика",
|
||||
"admin_user_management_section": "👥 Пользователи",
|
||||
"admin_promo_marketing_section": "🎁 Промокоды",
|
||||
"admin_system_functions_section": "⚙️ Система",
|
||||
"admin_ban_management_section": "🚫 Блокировки",
|
||||
"admin_users_management_button": "👤 Управление",
|
||||
"back_to_user_management_button": "⬅️ К пользователям",
|
||||
"back_to_admin_panel_button": "⬅️ В админку",
|
||||
|
||||
"admin_stats_header": "📊 Статистика Бота",
|
||||
"admin_stats_users": "👥 Пользователи: Всего - {total_users}, Забанено - {banned_users}, С активной подпиской - {active_subs}",
|
||||
"admin_enhanced_users_stats_header": "Пользователи",
|
||||
"admin_financial_stats_header": "Финансовая статистика",
|
||||
"admin_stats_recent_payments_header": "Последние платежи:",
|
||||
"admin_stats_payment_item": "{status_emoji} {amount} {currency} от {user_info} ({p_status}) [{p_date}]",
|
||||
"admin_stats_no_payments_found": "Платежей пока нет.",
|
||||
"admin_view_payments_button": "💰 Платежи",
|
||||
"admin_payments_header": "💰 <b>Все платежи</b>",
|
||||
"admin_no_payments_found": "Платежи не найдены.",
|
||||
"admin_export_payments_csv": "📊 Экспорт CSV",
|
||||
"admin_refresh_payments": "🔄 Обновить",
|
||||
"admin_no_payments_to_export": "Нет платежей для экспорта.",
|
||||
"admin_payments_export_success": "📊 Экспорт платежей завершен!\nВсего записей: {count}",
|
||||
"admin_export_sent": "Файл отправлен!",
|
||||
"admin_csv_payment_id": "ID",
|
||||
"admin_csv_user_id": "User ID",
|
||||
"admin_csv_username": "Логин",
|
||||
"admin_csv_first_name": "Имя",
|
||||
"admin_csv_amount": "Сумма",
|
||||
"admin_csv_currency": "Валюта",
|
||||
"admin_csv_provider": "Платежная система",
|
||||
"admin_csv_status": "Статус",
|
||||
"admin_csv_description": "Описание",
|
||||
"admin_csv_months": "Месяцев",
|
||||
"admin_csv_created_at": "Дата создания",
|
||||
"admin_csv_provider_payment_id": "ID платежа в системе",
|
||||
"admin_stats_last_sync_header": "Последняя синхронизация с панелью:",
|
||||
"admin_stats_sync_time": "Время",
|
||||
"admin_stats_sync_status": "Статус",
|
||||
@@ -114,108 +118,321 @@
|
||||
"admin_stats_sync_subs_synced": "Синхронизировано подписок",
|
||||
"admin_stats_sync_details_label": "Детали",
|
||||
"admin_sync_status_never_run": "Синхронизация с панелью еще не проводилась.",
|
||||
|
||||
"admin_broadcast_enter_message": "Введите сообщение для рассылки (HTML поддерживается):",
|
||||
"admin_broadcast_confirm_prompt": "Вы собираетесь отправить следующее сообщение (первые 200 символов):\n\n{message_preview}\n\nПодтверждаете отправку?",
|
||||
"admin_broadcast_confirm_prompt_short": "Сообщение выше будет отправлено. Подтвердить отправку?",
|
||||
"broadcast_target_all_button": "👥 Все",
|
||||
"broadcast_target_active_button": "✅ Активные",
|
||||
"broadcast_target_inactive_button": "⌛ Неактивные",
|
||||
"confirm_broadcast_send_button": "✅ Отправить",
|
||||
"cancel_broadcast_button": "❌ Отмена",
|
||||
"admin_broadcast_sending_started": "Начинаю рассылку...",
|
||||
"admin_broadcast_error_no_message": "Ошибка: сообщение для рассылки не найдено.",
|
||||
"admin_broadcast_error_no_message_alert": "Сообщение для рассылки пустое!",
|
||||
"admin_broadcast_finished_stats": "🏁 Рассылка завершена!\nОтправлено: {sent_count}\nНе удалось отправить: {failed_count}",
|
||||
"admin_broadcast_cancelled": "Рассылка отменена.",
|
||||
"admin_broadcast_cancelled_alert": "Рассылка отменена!",
|
||||
"admin_broadcast_cancelled_nav_back": "Рассылка отменена. Вы возвращены в админ-панель.",
|
||||
|
||||
"admin_promo_create_prompt": "Введите детали промокода в формате: КОД ДНИ_БОНУСА МАКС_АКТИВАЦИЙ [СРОК_ДЕЙСТВИЯ_В_ДНЯХ_ОТ_СЕЙЧАС]\nПример: <code>{example_format}</code>\n(Срок действия необязателен, по умолчанию - бессрочный)",
|
||||
"admin_promo_invalid_format": "Неверный формат ввода. Пожалуйста, используйте: КОД ДНИ_БОНУСА МАКС_АКТИВАЦИЙ [ДНИ_ДЕЙСТВИЯ]",
|
||||
"admin_promo_invalid_code_format": "Код должен быть от 3 до 30 символов и содержать только буквы и цифры.",
|
||||
"admin_promo_invalid_bonus_or_activations": "Количество бонусных дней и максимальных активаций должны быть положительными числами.",
|
||||
"admin_promo_invalid_bonus_days": "Количество бонусных дней должно быть положительным числом.",
|
||||
"admin_promo_invalid_max_activations": "Максимальное количество активаций должно быть положительным числом.",
|
||||
"admin_promo_invalid_validity_days": "Срок действия промокода (в днях) должен быть положительным числом.",
|
||||
"admin_promo_invalid_values": "Неверные значения. {error}",
|
||||
"admin_promo_invalid_format_general": "Ошибка парсинга деталей промокода. Проверьте формат.",
|
||||
"admin_promo_created_success": "✅ Промокод <code>{code}</code> успешно создан!\nБонус: {bonus_days} дней\nМакс. активаций: {max_activations}\nДействителен: {valid_until_str}",
|
||||
"admin_promo_creation_failed_duplicate": "❌ Ошибка: Промокод <code>{code}</code> уже существует.",
|
||||
"admin_promo_creation_failed": "❌ Не удалось создать промокод. Пожалуйста, попробуйте позже.",
|
||||
"subscription_72h_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на VPN истекает через 3 дня — {end_date}.\n\nПродлите её по кнопке ниже.",
|
||||
"subscription_48h_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на VPN истекает через 2 дня — {end_date}.\n\nПродлите её по кнопке ниже.",
|
||||
"subscription_24h_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на VPN истекает через 1 день — {end_date}.\n\nПродлите её по кнопке ниже.",
|
||||
"subscription_expired_notification": "👋 Привет, {user_name}!\n\n⛔ Срок вашей подписки на VPN истек ({end_date}).\n\nПродлите её по кнопке ниже.",
|
||||
"subscription_expired_yesterday_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на VPN истекла сутки назад ({end_date}).\n\nПродлите её по кнопке ниже.",
|
||||
"autorenew_48h_charge_tomorrow_notice": "🔔 Напоминание\n\nЗавтра будет автоматическое списание за продление подписки. Если вы не хотите автопродление — отключите его кнопкой ниже.",
|
||||
"autorenew_confirm_enable": "🔄 Включить автопродление? Перед окончанием подписки будет выполняться автосписание.",
|
||||
"autorenew_confirm_disable": "🛑 Отключить автопродление? Автосписаний больше не будет.",
|
||||
"tribute_subscription_cancelled": "🚨 <b>Подписка отменена</b>\n\nВаша подписка Tribute была отменена. У вас есть 24 часа для восстановления доступа, после чего подписка будет заблокирована.\n\nДля продления подписки нажмите кнопку ниже.",
|
||||
"yookassa_auto_renewal": "🔄 <b>Подписка автоматически продлена</b>\n\nВаша подписка была автоматически продлена на {months} мес.\nНовая дата окончания: {end_date}",
|
||||
"admin_promo_set_validity_days": "⏰ Установить срок (дни)",
|
||||
"admin_back_to_panel": "⬅️ В панель",
|
||||
"admin_promo_unlimited": "♾️ Неограниченно",
|
||||
"admin_bulk_promo_created_title": "📦 Массовое создание завершено",
|
||||
"admin_bulk_promo_created_stats": "📊 Создано: <b>{created}</b> из <b>{total}</b>",
|
||||
"admin_bulk_promo_settings": "🎁 Бонусные дни: <b>{bonus_days}</b>\n📊 Макс. активаций: <b>{max_activations}</b>\n⏰ Срок действия: <b>{validity}</b>",
|
||||
"admin_promo_list_page_info": "Страница {current}/{total} ({count} промокодов)",
|
||||
"admin_queue_status_button": "📊 Статус очередей",
|
||||
"admin_queue_status_title": "📊 Статус очередей сообщений",
|
||||
"admin_queue_status_info": "📤 <b>Очереди сообщений:</b>\n\n👥 <b>Пользователи (25 сообщ/сек):</b>\n 📋 В очереди: {user_queue_size}\n 🔄 Обрабатывается: {user_processing}\n 📈 Отправлено за минуту: {user_recent}\n\n📢 <b>Группы/каналы (15 сообщ/мин):</b>\n 📋 В очереди: {group_queue_size}\n 🔄 Обрабатывается: {group_processing}\n 📈 Отправлено за минуту: {group_recent}",
|
||||
"admin_active_promos_list_header": "Активные промокоды:",
|
||||
"admin_no_active_promos": "Нет активных промокодов.",
|
||||
"admin_promo_list_item": "<code>{code}</code>: +{bonus}дн, {current}/{max} акт., до {valid_until}",
|
||||
"admin_promo_valid_indefinitely": "бессрочно",
|
||||
"admin_promo_valid_until_display": "до {date}",
|
||||
"admin_manage_promos_title": "Управление промокодами",
|
||||
"admin_promo_edit_button": "✏️ Изменить",
|
||||
"admin_promo_delete_button": "🗑 Удалить",
|
||||
"admin_promo_edit_prompt": "Отправьте новые данные для <code>{code}</code> в формате: КОД ДНИ_БОНУСА МАКС_АКТИВАЦИЙ [СРОК]",
|
||||
"admin_promo_updated_success": "Промокод <code>{code}</code> обновлен.",
|
||||
"admin_promo_deleted_success": "Промокод <code>{code}</code> удален.",
|
||||
"admin_promo_deleted_success": "Промокод {code} удален.",
|
||||
"admin_promo_not_found": "Промокод не найден.",
|
||||
|
||||
"admin_promo_export_csv_button": "📄 Экспорт в CSV",
|
||||
"admin_promo_export_caption": "📄 Активации промокода {code}",
|
||||
"admin_promo_export_all_generating": "📄 Создаю CSV файл...",
|
||||
"admin_promo_export_all_caption": "📄 Экспорт всех промокодов\n📊 Всего: {count} промокодов",
|
||||
"admin_promo_csv_code": "Код",
|
||||
"admin_promo_csv_bonus_days": "Бонусные дни",
|
||||
"admin_promo_csv_max_activations": "Максимальные активации",
|
||||
"admin_promo_csv_current_activations": "Текущие активации",
|
||||
"admin_promo_csv_status": "Статус",
|
||||
"admin_promo_csv_is_active": "Активен",
|
||||
"admin_promo_csv_valid_until": "Действителен до",
|
||||
"admin_promo_csv_created_at": "Создан",
|
||||
"admin_promo_csv_created_by_admin_id": "Создал (Admin ID)",
|
||||
"csv_yes": "Да",
|
||||
"csv_no": "Нет",
|
||||
"admin_promo_edit_select_field": "Выберите поле для редактирования:",
|
||||
"admin_promo_prompt_bonus_days": "Введите новое количество бонусных дней:",
|
||||
"admin_promo_prompt_max_activations": "Введите новое максимальное количество активаций:",
|
||||
"admin_promo_prompt_validity_days": "Введите новый срок действия в днях (0 для бессрочного):",
|
||||
"admin_promo_edit_success": "Промокод успешно обновлен.",
|
||||
"admin_promo_invalid_input": "Неверный ввод, попробуйте еще раз.",
|
||||
"admin_promo_edit_bonus_days": "🎁 Бонусные дни",
|
||||
"admin_promo_edit_max_activations": "🔢 Макс. активации",
|
||||
"admin_promo_edit_validity": "⏰ Срок действия",
|
||||
"admin_ban_user_prompt": "Введите ID или @username пользователя для блокировки:",
|
||||
"admin_user_not_found_in_bot_db": "Пользователь <code>{user_id}</code> не найден в базе данных бота.",
|
||||
"admin_cannot_ban_self_or_admin": "Вы не можете заблокировать себя или другого администратора.",
|
||||
"admin_user_already_banned": "Пользователь {user_id_or_username} уже заблокирован.",
|
||||
"admin_user_banned_success_combined": "✅ Пользователь {user_id_or_username} успешно заблокирован в боте {panel_status_part}.",
|
||||
"admin_panel_ban_success_part": "и на панели",
|
||||
"admin_panel_ban_fail_part": ", но не удалось заблокировать на панели",
|
||||
"admin_user_ban_failed_local_db_error": "❌ Не удалось заблокировать пользователя в локальной БД.",
|
||||
"admin_unban_user_prompt": "Введите ID или @username пользователя для разблокировки:",
|
||||
"admin_user_not_banned": "Пользователь {user_id_or_username} не заблокирован.",
|
||||
"admin_user_unbanned_success_combined": "✅ Пользователь {user_id_or_username} успешно разблокирован в боте {panel_status_part}.",
|
||||
"admin_panel_unban_success_part": "и на панели",
|
||||
"admin_panel_unban_fail_part": ", но не удалось разблокировать на панели",
|
||||
"admin_user_unban_failed_local_db_error": "❌ Не удалось разблокировать пользователя в локальной БД.",
|
||||
"admin_no_banned_users": "В данный момент нет заблокированных пользователей.",
|
||||
"admin_banned_list_title": "Список забаненных (стр. {current_page}/{total_pages}):",
|
||||
"admin_user_not_banned": "⚠️ Пользователь не заблокирован",
|
||||
"admin_banned_user_button_text": "{user_display} (ID: {user_id})",
|
||||
"prev_page_button": "⬅️ Пред.",
|
||||
"next_page_button": "След. ➡️",
|
||||
"admin_user_card_title": "Карточка пользователя: {user_display}",
|
||||
"user_card_info": "ID: <code>{user_id}</code>\nUsername: @{username}\nИмя: {first_name} {last_name}\nЯзык: {language_code}\nPanel UUID: <code>{panel_user_uuid}</code>\nСтатус: <b>{ban_status}</b>\nРегистрация: {reg_date}\nПодписка до: <b>{sub_end_date}</b>",
|
||||
"user_card_banned": "ЗАБЛОКИРОВАН",
|
||||
"user_card_active": "Активен",
|
||||
"user_card_sub_na": "н/д",
|
||||
"admin_user_card_title": "Карточка пользователя",
|
||||
"user_card_ban_button": "🚫 Заблокировать",
|
||||
"user_card_unban_button": "✅ Разблокировать",
|
||||
"user_card_back_to_banned_list_button": "⬅️ К списку забаненных",
|
||||
"admin_confirm_action_title": "Подтверждение: {action_text}",
|
||||
"ban_verb_l": "блокировка",
|
||||
"unban_verb_l": "разблокировка",
|
||||
"admin_confirm_ban_prompt": "Вы уверены, что хотите заблокировать пользователя {user_display} (ID: {user_id})?",
|
||||
"admin_confirm_unban_prompt": "Вы уверены, что хотите разблокировать пользователя {user_display} (ID: {user_id})?",
|
||||
"admin_user_banned_from_card_alert": "Пользователь {user_display} (ID: {user_id}) заблокирован.",
|
||||
"admin_user_unbanned_from_card_alert": "Пользователь {user_display} (ID: {user_id}) разблокирован.",
|
||||
"admin_user_ban_failed_db_error": "Ошибка блокировки пользователя в БД.",
|
||||
"admin_user_unban_failed_db_error": "Ошибка разблокировки пользователя в БД.",
|
||||
"admin_panel_status_update_fail_part": ", но статус на панели не обновлен",
|
||||
|
||||
"admin_logs_menu_title": "Меню логов:",
|
||||
"admin_view_all_logs_button": "📜 Все логи сообщений",
|
||||
"admin_view_user_logs_prompt_button": "👤 Логи пользователя",
|
||||
"admin_all_logs_title": "Все логи (стр. {current_page}/{total_pages}):",
|
||||
"admin_export_logs_csv_button": "📄 Экспорт в CSV",
|
||||
"admin_no_logs_found": "Логи не найдены.",
|
||||
"admin_log_entry_format": "<code>{timestamp_str}</code> - <b>{user_display}</b> (ID: {user_id})\n <i>{event_type}</i>: {content_preview}",
|
||||
"system_or_unknown_user": "Система/Неизв.",
|
||||
"admin_prompt_for_user_id_or_username_logs": "Введите ID или @username пользователя для просмотра его логов:",
|
||||
"admin_log_user_not_found": "Пользователь по запросу \"{input}\" не найден в базе данных бота.",
|
||||
"admin_user_logs_title": "Логи пользователя {user_display} (стр. {current_page}/{total_pages}):",
|
||||
|
||||
"sync_started": "🔄 Начинаю синхронизацию данных с панелью...",
|
||||
"sync_failed": "❌ Ошибка синхронизации с панелью. Детали: {details}",
|
||||
"sync_completed": "✅ Синхронизация с панелью завершена. Статус: {status}. Детали: {details}",
|
||||
"sync_completed_details": "Проверено: {total_checked} записей.\nПользователей синхронизировано/обновлено: {users_synced}.\nПодписок синхронизировано/обновлено: {subs_synced}.",
|
||||
"sync_completed_with_errors_details": "Проверено: {total_checked} записей.\nПользователей синхронизировано/обновлено: {users_synced}.\nПодписок синхронизировано/обновлено: {subs_synced}.\nОшибок: {errors_count}.\n\nПервые ошибки:\n{error_details_preview}",
|
||||
"no_errors_placeholder": "нет",
|
||||
"sync_started_simple": "🔄 Начинаю синхронизацию...",
|
||||
"sync_success_simple": "✅ Синхронизация успешно завершена",
|
||||
"sync_failed_simple": "❌ Синхронизация завершилась с ошибкой",
|
||||
"sync_errors_simple": "⚠️ Синхронизация завершена с ошибками ({errors_count} ошибок)",
|
||||
"sync_critical_error": "❌ Критическая ошибка синхронизации",
|
||||
"admin_sync_initiated_from_panel": "Синхронизация запущена...",
|
||||
"admin_panel_user_creation_failed": "❌ Не удалось создать пользователя на панели для TG ID {user_id}. Панель недоступна?",
|
||||
"admin_broadcast_invalid_html": "❌ Некорректный HTML в сообщении. Пожалуйста, отправьте корректный HTML (поддерживаются теги Telegram) или уберите теги.",
|
||||
"error_displaying_logs_too_long": "Ошибка: логи слишком длинные для отображения одним сообщением. Попробуйте найти логи по конкретному пользователю.",
|
||||
"error_displaying_statistics": "Ошибка отображения статистики.",
|
||||
"stub_page_display": "Страница",
|
||||
"tribute_auto_renewal": "🔄 <b>Подписка автоматически продлена</b>\n\nВаша подписка Tribute была автоматически продлена на {months} мес.\nНовая дата окончания: {end_date}",
|
||||
"admin_user_management_prompt": "👤 Управление пользователями\n\nВведите ID пользователя или @username для поиска:",
|
||||
"admin_user_subscription_info": "Информация о подписке:",
|
||||
"admin_user_reset_trial_button": "🔄 Сбросить триал",
|
||||
"admin_user_add_subscription_button": "➕ Добавить дни",
|
||||
"admin_user_toggle_ban_button": "🚫 Бан/Разбан",
|
||||
"admin_user_send_message_button": "✉️ Сообщение",
|
||||
"admin_user_view_logs_button": "📜 Логи",
|
||||
"admin_user_refresh_button": "🔄 Обновить",
|
||||
"admin_user_search_new_button": "🔍 Новый поиск",
|
||||
"admin_user_view_all_logs_button": "📋 Все логи",
|
||||
"admin_user_back_to_card_button": "🔙 К карточке",
|
||||
"admin_user_recent_actions_title": "📜 Последние действия пользователя {user_id}:",
|
||||
"admin_user_not_found": "❌ Пользователь не найден: {input}",
|
||||
"admin_user_not_found_action": "Пользователь не найден",
|
||||
"admin_user_card_error": "❌ Ошибка отображения карточки пользователя",
|
||||
"admin_user_trial_reset_success": "✅ Триал сброшен! Пользователь может активировать триал заново.",
|
||||
"admin_user_trial_reset_error": "❌ Ошибка сброса триала",
|
||||
"admin_user_add_subscription_prompt": "➕ Добавление дней подписки для пользователя {user_id}\n\nВведите количество дней для добавления:",
|
||||
"admin_user_invalid_days": "❌ Неверное количество дней. Введите число от 1 до 3650.",
|
||||
"admin_user_subscription_added_success": "✅ Успешно добавлено {days} дней подписки пользователю {user_id}",
|
||||
"admin_user_subscription_added_error": "❌ Ошибка добавления дней подписки",
|
||||
"admin_user_ban_toggle_success": "✅ Пользователь {status}",
|
||||
"admin_user_ban_toggle_error": "❌ Ошибка изменения статуса блокировки",
|
||||
"admin_user_ban_success": "✅ Пользователь {input} заблокирован",
|
||||
"admin_user_unban_success": "✅ Пользователь {input} разблокирован",
|
||||
"admin_user_ban_error": "❌ Ошибка блокировки пользователя",
|
||||
"admin_user_unban_error": "❌ Ошибка разблокировки пользователя",
|
||||
"admin_user_send_message_prompt": "✉️ Отправка сообщения пользователю {user_id}\n\nВведите текст сообщения:",
|
||||
"admin_user_message_too_long": "❌ Сообщение слишком длинное (максимум 4000 символов)",
|
||||
"admin_user_message_sent_success": "✅ Сообщение отправлено пользователю {user_id}",
|
||||
"admin_user_message_sent_error": "❌ Ошибка отправки сообщения",
|
||||
"admin_user_no_logs": "📜 У пользователя нет действий",
|
||||
"admin_user_logs_error": "❌ Ошибка загрузки действий пользователя",
|
||||
"admin_direct_message_signature": "\n\n---\n💬 Сообщение от администратора",
|
||||
"inline_user_stats_message": "📊 <b>Статистика Бота</b>\n👥 Пользователи\n\n📊 Всего: <b>{total}</b>\n📈 Активных сегодня: <b>{active_today}</b>\n💳 С платной подпиской: <b>{paid}</b>\n🆓 На пробном периоде: <b>{trial}</b>\n😴 Неактивных: <b>{inactive}</b>\n🚫 Заблокированных: <b>{banned}</b>\n🎁 Привлечено по реферальной программе: <b>{referral}</b>",
|
||||
"inline_user_stats_description": "Всего: {total}, Платных: {active}",
|
||||
"inline_admin_user_stats_title": "👥 Статистика пользователей",
|
||||
"inline_financial_stats_message": "💰 <b>Финансовая статистика</b>\n\n📅 За сегодня: <b>{today:.2f} RUB</b>\n ({today_count} платежей)\n📅 За неделю: <b>{week:.2f} RUB</b>\n📅 За месяц: <b>{month:.2f} RUB</b>\n🏆 За все время: <b>{all_time:.2f} RUB</b>",
|
||||
"inline_admin_financial_stats_title": "💰 Финансовая статистика",
|
||||
"inline_system_stats_message": "🖥 <b>Статистика панели</b>\n\n🟢 Онлайн: <b>{online}</b>\n📊 Активных: <b>{active}</b>\n🔴 Отключенных: <b>{disabled}</b>\n⏰ Истекшие: <b>{expired}</b>\n⚠️ Ограниченные: <b>{limited}</b>\n👥 Всего пользователей: <b>{total}</b>\n💾 Использование RAM: <b>{memory:.1f}%</b>\n📊 Трафик за неделю: <b>{week_traffic}</b>\n📊 Трафик за месяц: <b>{month_traffic}</b>\n🔗 Активных нод: <b>{active_nodes}/{total_nodes}</b>",
|
||||
"inline_admin_system_stats_title": "🖥 Системная статистика",
|
||||
"log_referral_suffix": " (реферал от {referrer_id})",
|
||||
"log_new_user_registration": "👤 <b>Новый пользователь</b>\n\n🆔 ID: <code>{user_id}</code>\n👤 Имя: {user_display}{referral_text}\n📅 Время: {timestamp}",
|
||||
"log_payment_received": "{provider_emoji} <b>Получен платеж</b>\n\n👤 Пользователь: {user_display}\n💰 Сумма: <b>{amount} {currency}</b>\n📅 Период: <b>{months} мес.</b>\n🏦 Провайдер: {payment_provider}\n🕐 Время: {timestamp}",
|
||||
"log_promo_activation": "🎁 <b>Активирован промокод</b>\n\n👤 Пользователь: {user_display}\n🏷 Код: <code>{promo_code}</code>\n🎯 Бонус: <b>+{bonus_days} дн.</b>\n🕐 Время: {timestamp}",
|
||||
"log_trial_activation": "🆓 <b>Активирован триал</b>\n\n👤 Пользователь: {user_display}\n⏰ Действует до: <b>{end_date}</b>\n🕐 Время: {timestamp}",
|
||||
"log_panel_sync": "{status_emoji} <b>Синхронизация с панелью</b>\n\n📊 Статус: <b>{status}</b>\n👥 Обработано пользователей: <b>{users_processed}</b>\n📋 Синхронизировано подписок: <b>{subs_synced}</b>\n🕐 Время: {timestamp}\n\n📝 Детали:\n{details}",
|
||||
"log_suspicious_promo": "⚠️ <b>Подозрительная попытка ввода промокода</b>\n\n👤 Пользователь: {user_display}\n🆔 ID: <code>{user_id}</code>\n📝 Ввод: <pre>{suspicious_input}</pre>\n🕐 Время: {timestamp}",
|
||||
"admin_logs_csv_export_started": "📄 Начинаю экспорт логов в CSV...",
|
||||
"admin_logs_csv_export_success": "✅ Логи экспортированы! Файл прикреплен выше.",
|
||||
"admin_user_logs_title": "Логи пользователя {user_display} (стр. {current_page}/{total_pages}):",
|
||||
"admin_all_logs_title": "Все логи (стр. {current_page}/{total_pages}):",
|
||||
"admin_csv_header_log_id": "ID Лога",
|
||||
"admin_csv_header_timestamp": "Время",
|
||||
"admin_csv_header_user_id": "ID Пользователя",
|
||||
"admin_csv_header_telegram_username": "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": "ID Целевого юзера",
|
||||
"admin_csv_header_raw_update_preview": "Превью обновления",
|
||||
"admin_banned_users_empty": "📋 Заблокированные пользователи\n\nСписок пуст",
|
||||
"admin_banned_users_list": "📋 Заблокированные пользователи ({count}):\n\n{users}",
|
||||
"admin_panel_stats_header": "Статистика панели",
|
||||
"admin_bulk_promo_step1_quantity": "📦 <b>Массовое создание промокодов</b>\n\n<b>Шаг 1 из 4:</b> Количество промокодов\n\nВведите количество промокодов для создания (1-1000):",
|
||||
"admin_bulk_promo_step2_bonus_days": "📦 <b>Массовое создание промокодов</b>\n\n<b>Шаг 2 из 4:</b> Бонусные дни\n\nКоличество: <b>{quantity}</b>\n\nВведите количество бонусных дней (1-365):",
|
||||
"admin_bulk_promo_step3_max_activations": "📦 <b>Массовое создание промокодов</b>\n\n<b>Шаг 3 из 4:</b> Максимальные активации\n\nКоличество: <b>{quantity}</b>\nБонусные дни: <b>{bonus_days}</b>\n\nВведите максимальное количество активаций для каждого промокода (1-10000):",
|
||||
"admin_bulk_promo_step4_validity": "📦 <b>Массовое создание промокодов</b>\n\n<b>Шаг 4 из 4:</b> Срок действия\n\nКоличество: <b>{quantity}</b>\nБонусные дни: <b>{bonus_days}</b>\nМакс. активации: <b>{max_activations}</b>\n\nВыберите срок действия промокодов:",
|
||||
"admin_bulk_promo_invalid_quantity": "❌ Количество должно быть от 1 до 1000",
|
||||
"admin_bulk_promo_enter_validity_days": "⏰ Введите количество дней действия промокодов (1-365):",
|
||||
"admin_bulk_promo_creating": "⏳ Создаю {quantity} промокодов...",
|
||||
"admin_promo_step1_code": "🎟 <b>Создание промокода</b>\n\n<b>Шаг 1 из 4:</b> Код промокода\n\nВведите код промокода (3-30 символов, только буквы и цифры):",
|
||||
"admin_promo_step2_bonus_days": "🎟 <b>Создание промокода</b>\n\n<b>Шаг 2 из 4:</b> Бонусные дни\n\nКод: <b>{code}</b>\n\nВведите количество бонусных дней (1-365):",
|
||||
"admin_promo_step3_max_activations": "🎟 <b>Создание промокода</b>\n\n<b>Шаг 3 из 4:</b> Максимальные активации\n\nКод: <b>{code}</b>\nБонусные дни: <b>{bonus_days}</b>\n\nВведите максимальное количество активаций (1-10000):",
|
||||
"admin_promo_step4_validity": "🎟 <b>Создание промокода</b>\n\n<b>Шаг 4 из 4:</b> Срок действия\n\nКод: <b>{code}</b>\nБонусные дни: <b>{bonus_days}</b>\nМакс. активации: <b>{max_activations}</b>\n\nВыберите срок действия промокода:",
|
||||
"admin_promo_code_already_exists": "❌ Промокод с таким кодом уже существует",
|
||||
"admin_promo_unlimited_validity": "♾️ Неограниченно",
|
||||
"admin_promo_enter_validity_days": "⏰ Введите количество дней действия промокода (1-365):",
|
||||
"admin_user_id_label": "🆔 <b>ID:</b>",
|
||||
"admin_user_name_label": "👤 <b>Имя:</b>",
|
||||
"admin_user_username_label": "📱 <b>Username:</b>",
|
||||
"admin_user_language_label": "🌍 <b>Язык:</b>",
|
||||
"admin_user_registration_label": "📅 <b>Регистрация:</b>",
|
||||
"admin_user_status_label": "🛡 <b>Статус:</b>",
|
||||
"admin_user_referral_label": "🎁 <b>Привлечен по реферальной программе от:</b>",
|
||||
"admin_user_panel_uuid_label": "🔗 <b>Panel UUID:</b>",
|
||||
"admin_user_panel_status_label": "📊 <b>Статус на панели:</b>",
|
||||
"admin_user_traffic_label": "📊 <b>Трафик:</b>",
|
||||
"admin_user_subscription_label": "💼 <b>Подписка:</b>",
|
||||
"admin_user_trial_label": "🏡 <b>Триал:</b>",
|
||||
"admin_user_status_banned": "🚫 Заблокирован",
|
||||
"admin_user_status_active": "✅ Активен",
|
||||
"admin_user_trial_used": "Использовал",
|
||||
"admin_user_trial_not_used": "Не использовал",
|
||||
"admin_user_ban_action_banned": "заблокирован",
|
||||
"admin_user_ban_action_unbanned": "разблокирован",
|
||||
"admin_user_na_value": "N/A",
|
||||
"admin_user_subscription_none": "Нет активной подписки",
|
||||
"admin_user_actions_count_label": "📜 <b>Всего действий:</b>",
|
||||
"admin_user_invited_friends_label": "👥 <b>Приглашено друзей:</b>",
|
||||
"admin_user_ref_purchased_label": "💳 <b>Купили подписку:</b>",
|
||||
"admin_user_subscription_active_until": "⏰ <b>Действует до:</b>",
|
||||
"admin_user_subscription_error": "Ошибка загрузки",
|
||||
"admin_promo_management_button": "🎟 Управление промокодами",
|
||||
|
||||
"subscription_ending_soon_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на VPN истекает {end_date} (через {days_left} дн.).\n\nЧтобы не потерять доступ, пожалуйста, продлите ее заранее в главном меню бота.",
|
||||
"subscription_cancelled_notification": "Ваша подписка отменена. Доступ сохранится до конца оплаченного периода.",
|
||||
|
||||
"admin_new_trial_notification": "\ud83c\udf21 Пользователь {user_id} активировал пробный период до {end_date}.",
|
||||
"admin_new_payment_notification": "\ud83d\udcb3 Получен платеж от пользователя {user_id}: {months} мес. за {amount} {currency}.",
|
||||
|
||||
"error_unknown": "Произошла неизвестная ошибка."
|
||||
"admin_promo_management_title": "🎟 <b>Управление промокодами</b>\n\nВыберите промокод для детального просмотра:",
|
||||
"admin_promo_management_empty": "📭 Промокоды отсутствуют",
|
||||
"admin_promo_card_title": "🎟 <b>Промокод: {code}</b>",
|
||||
"admin_promo_card_bonus_days": "🎁 Бонусные дни: <b>{days}</b>",
|
||||
"admin_promo_card_activations": "🔢 Активации: <b>{current}/{max}</b>",
|
||||
"admin_promo_card_validity": "⏰ Действует до: <b>{validity}</b>",
|
||||
"admin_promo_card_status": "📊 Статус: <b>{status}</b>",
|
||||
"admin_promo_card_created": "📅 Создан: <b>{created}</b>",
|
||||
"admin_promo_card_created_by": "👤 Создал: <b>{creator}</b>",
|
||||
"admin_promo_status_active": "✅ Активен",
|
||||
"admin_promo_status_inactive": "🚫 Неактивен",
|
||||
"admin_promo_status_activated": "активирован",
|
||||
"admin_promo_status_deactivated": "деактивирован",
|
||||
"admin_promo_status_expired": "⏰ Истек",
|
||||
"admin_promo_status_used_up": "🔄 Исчерпан",
|
||||
"admin_promo_toggle_status_button": "🔄 Вкл/Выкл",
|
||||
"admin_promo_view_activations_button": "📋 Активации",
|
||||
"admin_promo_back_to_list_button": "⬅️ К списку",
|
||||
"admin_promo_toggle_success": "✅ Промокод {code} {status}",
|
||||
"admin_promo_no_activations": "📋 <b>Активации промокода: {code}</b>\n\n❌ Активаций не найдено",
|
||||
"admin_promo_activations_header": "📋 <b>Активации промокода: {code}</b>\n\n",
|
||||
"admin_promo_activation_item": "👤 User ID: <b>{user_id}</b>\n📅 Дата: <b>{date}</b>\n",
|
||||
"admin_promo_back_to_detail_button": "⬅️ К промокоду",
|
||||
"admin_panel_online_label": "Онлайн",
|
||||
"admin_panel_active_label": "Активных",
|
||||
"admin_panel_disabled_label": "Отключенных",
|
||||
"admin_panel_expired_label": "Истекшие",
|
||||
"admin_panel_limited_label": "Ограниченные",
|
||||
"admin_panel_total_users_label": "Всего пользователей",
|
||||
"admin_panel_memory_usage_label": "Использование RAM",
|
||||
"admin_panel_traffic_week_label": "Трафик за неделю",
|
||||
"admin_panel_traffic_month_label": "Трафик за месяц",
|
||||
"admin_panel_nodes_label": "Активных нод",
|
||||
"admin_panel_system_stats_error": "Ошибка получения системной статистики",
|
||||
"admin_panel_bandwidth_stats_error": "Ошибка получения статистики трафика",
|
||||
"admin_panel_stats_fetch_error": "Ошибка получения данных с панели",
|
||||
"admin_panel_stats_error_details": "Детали",
|
||||
"inline_referral_title": "Реферальная ссылка",
|
||||
"inline_referral_description": "Поделиться реферальной ссылкой для получения бонусов",
|
||||
"inline_financial_description": "Сегодня: {today} RUB",
|
||||
"inline_system_description": "🟢 Онлайн: {online}, 📊 Активных: {active}",
|
||||
"admin_user_stats_total_label": "Всего",
|
||||
"admin_user_stats_paid_subs_label": "С платной подпиской",
|
||||
"admin_user_stats_trial_label": "На пробном периоде",
|
||||
"admin_user_stats_inactive_label": "Неактивных",
|
||||
"admin_user_stats_banned_label": "Заблокированных",
|
||||
"admin_user_stats_referral_label": "Привлечено по реферальной программе",
|
||||
"admin_financial_today_label": "За сегодня",
|
||||
"admin_financial_week_label": "За неделю",
|
||||
"admin_financial_month_label": "За месяц",
|
||||
"admin_financial_all_time_label": "За все время",
|
||||
"admin_financial_payments_label": "платежей",
|
||||
"admin_sync_details": "📊 Статистика синхронизации:\n🔍 Проверено записей панели: {panel_records_checked}\n👥 Найдено пользователей в БД: {users_found_in_db}\n✨ Создано новых пользователей: {users_created}\n🔄 Пользователей обновлено: {users_updated}\n📋 Подписок синхронизировано: {subscriptions_synced_count}\n ├── Создано новых: {subscriptions_created}\n └── Обновлено существующих: {subscriptions_updated}{additional_stats}",
|
||||
"admin_sync_no_telegram_id": "\n⚠️ Записей без telegramId: {count}",
|
||||
"admin_sync_not_found_in_db": "\n❌ Не найдено в БД: {count}",
|
||||
"admin_payments_pagination_info": "📊 Показано {shown} из {total} платежей (стр. {current_page}/{total_pages})",
|
||||
"my_subscription_details": "🔐 <b>Моя подписка</b>\n\n⏰ Статус: <b>{status}</b>\n📅 Действует до: <b>{end_date}</b>\n📆 Осталось дней: <b>{days_left}</b>\n\n🔗 Ссылка на конфигурацию:\n<code>{config_link}</code>\n\n📊 Трафик:\nЛимит: <b>{traffic_limit}</b>\nИспользовано: <b>{traffic_used}</b>",
|
||||
"autorenew_enable_button": "🔄 Включить автопродление",
|
||||
"autorenew_disable_button": "🛑 Отключить автопродление",
|
||||
"subscription_autorenew_updated": "Настройки автопродления обновлены.",
|
||||
"payment_methods_manage_button": "💳 Способы оплаты",
|
||||
"payment_methods_title": "💳 <b>Способы оплаты</b>",
|
||||
"payment_method_bind_button": "➕ Добавить способ",
|
||||
"payment_method_delete_button": "🗑 Удалить",
|
||||
"payment_method_view_button": "ℹ️ Детали",
|
||||
"payment_method_none": "У вас пока нет сохранённого способа оплаты.",
|
||||
"payment_method_bound_success": "✅ Способ оплаты добавлен.",
|
||||
"payment_method_deleted_success": "✅ Способ оплаты удалён.",
|
||||
"payment_method_delete_confirm": "Удалить сохранённый способ оплаты?",
|
||||
"payment_method_card_title": "💳 {network} ••••{last4}",
|
||||
"payment_method_generic_title": "💳 {network}",
|
||||
"payment_method_wallet_title": "💼 Кошелёк YooMoney ••••{last4}",
|
||||
"payment_network_card": "Карта",
|
||||
"payment_network_generic": "Способ оплаты",
|
||||
"payment_method_added_at": "Добавлена: {date}",
|
||||
"payment_method_last_tx": "Последняя операция: {date}",
|
||||
"payment_method_tx_history_title": "📜 История операций",
|
||||
"payment_method_no_history": "История операций отсутствует.",
|
||||
"subscription_purchase_title": "Покупка подписки на {months} мес.",
|
||||
"subscription_tribute_notice": "Оплачено через Tribute. Продление делайте по ссылке Tribute.",
|
||||
"subscription_tribute_notice_with_link": "Оплачено через Tribute. Продлить: {link}",
|
||||
"subscription_autorenew_not_supported_for_tribute": "Автопродление управляется Tribute. Управляйте продлением в приложении/ссылке Tribute.",
|
||||
"subscription_not_active": "У вас нет активной подписки.",
|
||||
"error_service_unavailable": "Сервис недоступен. Попробуйте позже.",
|
||||
"error_payment_gateway": "Ошибка платежного сервиса. Попробуйте позже.",
|
||||
"traffic_na": "N/A",
|
||||
"payment_service_unavailable": "Платежный сервис недоступен.",
|
||||
"payment_service_unavailable_alert": "Платежный сервис недоступен. Попробуйте позже.",
|
||||
"error_creating_payment_record": "Ошибка создания записи платежа. Попробуйте позже.",
|
||||
"error_payment_gateway_link_failed": "Ошибка создания платежной ссылки. Попробуйте позже.",
|
||||
"status_active": "Активна",
|
||||
"status_inactive": "Неактивна",
|
||||
"admin_ads_section": "📈 Реклама",
|
||||
"admin_ads_header": "📈 Рекламные кампании:",
|
||||
"admin_ads_empty": "📭 Рекламные кампании отсутствуют. Нажмите \"Создать\" чтобы добавить новую.",
|
||||
"admin_ads_item": "ID: {id}\nИсточник: <b>{source}</b>\nstart={start_param}\nСтоимость: {cost} RUB\nАктивна: {active}\n— Запустили: {starts}\n— Взяли триал: {trials}\n— Оплатили: {payers}\n— Доход: {revenue} RUB",
|
||||
"admin_ads_create_button": "➕ Создать кампанию",
|
||||
"admin_ads_create_source_prompt": "Введите источник (например: AEZA, VK, TG-канал):",
|
||||
"admin_ads_create_start_param_prompt": "Введите параметр старт-ссылки (например: AEZA). Будет использован как start=AEZA",
|
||||
"admin_ads_create_cost_prompt": "Введите сумму затрат на кампанию (в RUB):",
|
||||
"admin_ads_invalid_source": "❌ Неверный источник. Введите до 64 символов.",
|
||||
"admin_ads_invalid_start_param": "❌ Неверный параметр. Допустимы буквы/цифры/подчёркивания/дефисы (2-64).",
|
||||
"admin_ads_invalid_cost": "❌ Неверная сумма. Введите неотрицательное число.",
|
||||
"admin_ads_start_param_exists": "❌ Кампания с таким start-параметром уже существует.",
|
||||
"admin_ads_created_success": "✅ Кампания создана!\nID: {id}\nИсточник: {source}\nПараметр: {start_param}\nЗатраты: {cost} RUB",
|
||||
"admin_ads_back_to_menu_hint": "Готово. Вернуться к разделу рекламы:",
|
||||
"admin_ads_overview": "📈 <b>Реклама</b>\n💰 Пришло: <b>{revenue} RUB</b>\n💸 Потрачено: <b>{cost} RUB</b>",
|
||||
"back_to_ads_list_button": "⬅️ К списку",
|
||||
"admin_ads_card": "📈 <b>Кампания #{id}</b>\nИсточник: <b>{source}</b>\nstart=<code>{start_param}</code>\nСтоимость: <b>{cost} RUB</b>\nАктивна: {active}\n\n👥 Запустили: <b>{starts}</b>\n🆓 Взяли триал: <b>{trials}</b>\n💳 Оплатили: <b>{payers}</b>\n💵 Доход: <b>{revenue} RUB</b>",
|
||||
"admin_ads_delete_button": "🗑 Удалить кампанию",
|
||||
"admin_ads_delete_confirm": "Вы уверены, что хотите удалить кампанию #{id}? Это действие необратимо.",
|
||||
"admin_ads_deleted_success": "Кампания удалена.",
|
||||
"admin_ads_not_found": "Кампания не найдена."
|
||||
}
|
||||
|
||||
+4
-4
@@ -1,11 +1,11 @@
|
||||
aiogram==3.20.0
|
||||
aiogram==3.21.0
|
||||
python-dotenv==1.0.1
|
||||
aiohttp==3.10.11
|
||||
aiohttp==3.12.14
|
||||
pydantic==2.7.1
|
||||
yookassa==3.5.0
|
||||
pycountry==23.12.11
|
||||
apscheduler==3.10.4
|
||||
pydantic_settings
|
||||
sqlalchemy[asyncio]==2.0.29
|
||||
asyncpg==0.29.0
|
||||
alembic==1.13.1
|
||||
alembic==1.13.1
|
||||
aiocryptopay==0.4.8
|
||||
|
||||
Reference in New Issue
Block a user