Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85c276bcf5 | ||
|
|
55f8db11ce | ||
|
|
79af61a48e | ||
|
|
69d2e2a899 | ||
|
|
069ad967b5 | ||
|
|
18a6b3e18d | ||
|
|
3b9043332c | ||
|
|
d94b57bd0b | ||
|
|
5247092ca2 | ||
|
|
049789c9c5 | ||
|
|
6b74ae4a8e | ||
|
|
c13c01ea43 | ||
|
|
48e7d3569f | ||
|
|
4f3b45cbd8 | ||
|
|
a7ec55d741 | ||
|
|
0701af0f35 | ||
|
|
c0851c5339 | ||
|
|
785b6c2d41 | ||
|
|
cce3fd4f58 | ||
|
|
0dd6beebb2 | ||
|
|
8fb2a76698 | ||
|
|
3d6713caa9 | ||
|
|
8d8fdce519 | ||
|
|
c385a1466c | ||
|
|
8e1fdb33d2 | ||
|
|
29222be1fa | ||
|
|
08aca3b28d | ||
|
|
658139d607 | ||
|
|
1d4abf7977 | ||
|
|
90ab186a7c | ||
|
|
62e8bc08d5 | ||
|
|
dc725ccd68 | ||
|
|
d88aee03bb | ||
|
|
475a30caf4 | ||
|
|
c1593d7868 | ||
|
|
0dbcfe3770 | ||
|
|
e3ffb87cfc | ||
|
|
b4218ec6a7 | ||
|
|
48f8656ef8 | ||
|
|
cee8491b3d | ||
|
|
4bc3f3b4b3 | ||
|
|
adb1105621 | ||
|
|
c8fd79e4ba | ||
|
|
52bc9f71cc | ||
|
|
a1d3db0e16 | ||
|
|
d0e34126e1 | ||
|
|
c96ebea077 | ||
|
|
f00a72ad15 | ||
|
|
22171eb66f | ||
|
|
061fdeb72b | ||
|
|
8eba23574b | ||
|
|
7b650a74a6 | ||
|
|
805bd4cf60 | ||
|
|
56a32d493f | ||
|
|
b24a685066 | ||
|
|
fb06fbd0e1 | ||
|
|
719369057f | ||
|
|
cea9d98155 | ||
|
|
6cf15e0698 | ||
|
|
2971b9ad2e | ||
|
|
3e86fc76b6 | ||
|
|
829a18715a | ||
|
|
844c8e12a7 | ||
|
|
ef75f905f4 | ||
|
|
cd816cbe5c | ||
|
|
2b0fa3a314 | ||
|
|
ead990e61c | ||
|
|
eb6e343e4c | ||
|
|
caf5b88eef | ||
|
|
183bef070d | ||
|
|
643bf5fabb | ||
|
|
7d446d5f64 | ||
|
|
73ce689d5e | ||
|
|
5791f2e58c | ||
|
|
69cc2cbe29 | ||
|
|
7df2e127a4 | ||
|
|
d503cf7f7f | ||
|
|
c0d70030c3 | ||
|
|
4e7c36dbf7 | ||
|
|
3b0b88e9a0 | ||
|
|
207e7751cd | ||
|
|
c65b608f0b | ||
|
|
cff8368b7c | ||
|
|
2f367bbd6b | ||
|
|
c3d6324ec6 | ||
|
|
4800a2da80 | ||
|
|
70a0cd44a1 | ||
|
|
545c9c7d07 | ||
|
|
cd60f496fa | ||
|
|
f883065bb9 | ||
|
|
b71bd71d8d | ||
|
|
0524d24b13 |
+64
-22
@@ -17,6 +17,8 @@ DEFAULT_CURRENCY_SYMBOL="RUB" #
|
||||
SUPPORT_LINK=https://t.me/your_support_link # Link to the support chat
|
||||
SERVER_STATUS_URL=https://status.yourdomain.tld/status/your_service # Link to the server status page
|
||||
TERMS_OF_SERVICE_URL=https://example.com/tos # Link to the terms of service
|
||||
PRIVACY_POLICY_URL=https://example.com/privacy # Link to the privacy policy
|
||||
USER_AGREEMENT_URL=https://example.com/user-agreement # Link to the user agreement
|
||||
SUBSCRIPTION_MINI_APP_URL= # URL of the subscription mini-app
|
||||
START_COMMAND_DESCRIPTION= # Description of the /start command
|
||||
DISABLE_WELCOME_MESSAGE= # Disable the welcome message
|
||||
@@ -36,6 +38,10 @@ FREEKASSA_ENABLED=True #
|
||||
STARS_ENABLED=True # Turn on STARS
|
||||
TRIBUTE_ENABLED=True # Turn on TRIBUTE
|
||||
CRYPTOPAY_ENABLED=True # Turn on CRYPTOPAY
|
||||
PLATEGA_ENABLED=False # Turn on PLATEGA
|
||||
SEVERPAY_ENABLED=False # Turn on SeverPay
|
||||
# Order of payment methods (top to bottom). Supported: severpay, freekassa, platega, yookassa, tribute, stars, cryptopay
|
||||
PAYMENT_METHODS_ORDER=severpay,yookassa,cryptopay,freekassa,platega,tribute,stars
|
||||
|
||||
# YooKassa Payment Gateway Configuration
|
||||
YOOKASSA_SHOP_ID=your_shop_id # Your store ID in YooKassa
|
||||
@@ -44,12 +50,20 @@ YOOKASSA_RETURN_URL=https://t.me/your_bot #
|
||||
YOOKASSA_DEFAULT_RECEIPT_EMAIL=your_email@example.com # Default email for sending receipts
|
||||
YOOKASSA_VAT_CODE=1 # VAT code
|
||||
YOOKASSA_AUTOPAYMENTS_ENABLED=False # Auto-renew toggle
|
||||
YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING=True # Force automatic card binding when autopay is enabled (set to False to show the save-card checkbox)
|
||||
|
||||
# Nalogo (self-employed receipts)
|
||||
NALOGO_INN=your_inn # INN for nalog.ru
|
||||
NALOGO_PASSWORD=your_nalogo_password # Password for nalog.ru
|
||||
NALOGO_RECEIPT_NAME_SUBSCRIPTION=subscription {months} months # Receipt name for time-based subscriptions ({months} = duration)
|
||||
NALOGO_RECEIPT_NAME_TRAFFIC=traffic package {gb} GB # Receipt name for traffic packages ({gb} = traffic amount)
|
||||
|
||||
# FreeKassa Payment Gateway Configuration
|
||||
FREEKASSA_MERCHANT_ID=your_shop_id # Your shop ID in FreeKassa
|
||||
FREEKASSA_API_KEY=your_api_key # API key for REST requests
|
||||
FREEKASSA_SECOND_SECRET=your_second_secret # Secret word #2 (used to verify notifications)
|
||||
FREEKASSA_PAYMENT_IP= # Public IP address reported to FreeKassa
|
||||
FREEKASSA_PAYMENT_METHOD_ID=44 # Payment method ID, you can get it from https://merchant.freekassa.net/settings/currencies
|
||||
|
||||
# CryptoBot Payment Gateway Configuration
|
||||
CRYPTOPAY_TOKEN= # API token for CryptoPay
|
||||
@@ -57,6 +71,21 @@ CRYPTOPAY_NETWORK=mainnet #
|
||||
CRYPTOPAY_CURRENCY_TYPE=fiat # Currency type (fiat or crypto)
|
||||
CRYPTOPAY_ASSET=RUB # Asset, e.g., RUB, BTC, USDT
|
||||
|
||||
# Platega Payment Gateway Configuration
|
||||
PLATEGA_BASE_URL=https://app.platega.io # Base API URL
|
||||
PLATEGA_MERCHANT_ID= # Your MerchantId from Platega
|
||||
PLATEGA_SECRET= # API secret from Platega
|
||||
PLATEGA_PAYMENT_METHOD=2 # Payment method ID (2=SBP QR, 10=RU cards, 12=International, 13=Crypto)
|
||||
PLATEGA_RETURN_URL= # Optional: redirect after successful payment (defaults to bot link)
|
||||
PLATEGA_FAILED_URL= # Optional: redirect after failed/cancelled payment (defaults to return URL)
|
||||
|
||||
# SeverPay Payment Gateway Configuration
|
||||
SEVERPAY_BASE_URL=https://severpay.io/api/merchant # Base API URL
|
||||
SEVERPAY_MID= # Your MID from SeverPay
|
||||
SEVERPAY_TOKEN= # API token/secret for signing requests
|
||||
SEVERPAY_RETURN_URL= # Optional: redirect URL after payment (defaults to bot link)
|
||||
SEVERPAY_LIFETIME_MINUTES= # Optional: payment link lifetime in minutes (30-4320, leave empty for default)
|
||||
|
||||
# Tribute Payment Gateway Configuration
|
||||
TRIBUTE_API_KEY= # API key for verifying Tribute webhook signatures
|
||||
TRIBUTE_SKIP_NOTIFICATIONS=True # Skip renewal notifications for Tribute payments
|
||||
@@ -83,14 +112,20 @@ RUB_PRICE_12_MONTHS=900
|
||||
STARS_PRICE_12_MONTHS=0
|
||||
TRIBUTE_LINK_12_MONTHS=
|
||||
|
||||
# Traffic Packages (enables traffic sale mode when set)
|
||||
TRAFFIC_PACKAGES=10:199,50:799 # Format: "<GB>:<price>", comma-separated
|
||||
STARS_TRAFFIC_PACKAGES=10:2500 # Optional: traffic packages priced in Stars
|
||||
|
||||
# Subscription Notifications
|
||||
SUBSCRIPTION_NOTIFICATIONS_ENABLED=True # Enable subscription
|
||||
SUBSCRIPTION_NOTIFY_ON_EXPIRE=True # Notify on subscription
|
||||
SUBSCRIPTION_NOTIFY_AFTER_EXPIRE=True # Notify after
|
||||
SUBSCRIPTION_NOTIFY_DAYS_BEFORE=3 # Days before expiration to notify
|
||||
SUBSCRIPTION_NOTIFICATIONS_ENABLED=True # Enable subscription
|
||||
SUBSCRIPTION_NOTIFY_ON_EXPIRE=True # Notify on subscription
|
||||
SUBSCRIPTION_NOTIFY_AFTER_EXPIRE=True # Notify after
|
||||
SUBSCRIPTION_NOTIFY_DAYS_BEFORE=3 # Days before expiration to notify
|
||||
|
||||
|
||||
REFERRAL_ONE_BONUS_PER_REFEREE=False # Give a bonus only once per referee
|
||||
REFERRAL_ONE_BONUS_PER_REFEREE=False # Give a bonus only once per referee
|
||||
REFERRAL_WELCOME_BONUS_DAYS=3 # Welcome bonus for newly registered user from referral link
|
||||
LEGACY_REFS=true # Allow ref_<tg_id> links. Leave unset/true unless you want to disable old links
|
||||
# Referral Bonus Days
|
||||
# Bonus for the inviting user
|
||||
REFERRAL_BONUS_DAYS_1_MONTH=3
|
||||
@@ -104,39 +139,46 @@ REFEREE_BONUS_DAYS_6_MONTHS=7
|
||||
REFEREE_BONUS_DAYS_12_MONTHS=15
|
||||
|
||||
# Panel API Configuration
|
||||
PANEL_API_URL=http://your_panel_api_url/api # URL of the panel API
|
||||
PANEL_API_KEY=your_panel_api_key # Panel API key
|
||||
PANEL_WEBHOOK_SECRET= # secret used to verify panel webhook signatures
|
||||
PANEL_API_URL=http://your_panel_api_url/api # URL of the panel API
|
||||
PANEL_API_KEY=your_panel_api_key # Panel API key
|
||||
PANEL_WEBHOOK_SECRET= # secret used to verify panel webhook signatures
|
||||
|
||||
# User traffic limits (applied for all users)
|
||||
# 0 means unlimited
|
||||
USER_TRAFFIC_LIMIT_GB=0 # Traffic limit for users (0 unlimited)
|
||||
USER_TRAFFIC_STRATEGY="NO_RESET" # Traffic reset strategy (NO_RESET, WEEK, MONTH)
|
||||
USER_TRAFFIC_LIMIT_GB=0 # Traffic limit for users (0 unlimited)
|
||||
USER_TRAFFIC_STRATEGY="NO_RESET" # Traffic reset strategy (NO_RESET, WEEK, MONTH)
|
||||
|
||||
# Default Internal Squads for Users (Optional, comma-separated UUIDs)
|
||||
USER_SQUAD_UUIDS=uuid1,uuid2,uuid3
|
||||
# Default External Squad for Users (Optional, single UUID)
|
||||
USER_EXTERNAL_SQUAD_UUID= # Optional: UUID from Remnawave External Squads to auto-link new panel users
|
||||
|
||||
# Trial Settings
|
||||
TRIAL_ENABLED=True # Enable the trial period
|
||||
TRIAL_DURATION_DAYS=5 # Duration of the trial period in days
|
||||
TRIAL_TRAFFIC_LIMIT_GB=0 # Traffic limit for the trial period (0 = unlimited)
|
||||
TRIAL_TRAFFIC_STRATEGY="NO_RESET" # Traffic reset strategy for the trial period (NO_RESET, WEEK, MONTH)
|
||||
TRIAL_ENABLED=True # Enable the trial period
|
||||
TRIAL_DURATION_DAYS=5 # Duration of the trial period in days
|
||||
TRIAL_TRAFFIC_LIMIT_GB=0 # Traffic limit for the trial period (0 = unlimited)
|
||||
TRIAL_TRAFFIC_STRATEGY="NO_RESET" # Traffic reset strategy for the trial period (NO_RESET, WEEK, MONTH)
|
||||
|
||||
# Connection link handling (happ crypt4)
|
||||
CRYPT4_ENABLED=False # Enable happ crypt4 encryption for subscription URLs
|
||||
CRYPT4_REDIRECT_URL= # Base redirect to wrap the connect button, e.g. https://redir.example.com?url=
|
||||
|
||||
# Web Server Settings (for handling webhooks)
|
||||
WEB_SERVER_HOST="0.0.0.0"
|
||||
WEB_SERVER_PORT=8080
|
||||
|
||||
# Admin Panel Log Pagination
|
||||
LOGS_PAGE_SIZE=10 # Number of events in the log
|
||||
LOGS_PAGE_SIZE=10 # Number of events in the log
|
||||
LOG_LEVEL=INFO # Global log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
||||
|
||||
# Admin Logging Configuration
|
||||
LOG_CHAT_ID=-1001234567890 # Telegram chat/group ID for admin notifications
|
||||
LOG_THREAD_ID= # Optional: Thread ID for supergroup messages
|
||||
LOG_NEW_USERS=True # Log new user registrations
|
||||
LOG_PAYMENTS=True # Log payments
|
||||
LOG_PROMO_ACTIVATIONS=True # Log promo code activations
|
||||
LOG_TRIAL_ACTIVATIONS=True # Log trial activations
|
||||
LOG_SUSPICIOUS_ACTIVITY=True # Log suspicious activity
|
||||
LOG_CHAT_ID=-1001234567890 # Telegram chat/group ID for admin notifications
|
||||
LOG_THREAD_ID= # Optional: Thread ID for supergroup messages
|
||||
LOG_NEW_USERS=True # Log new user registrations
|
||||
LOG_PAYMENTS=True # Log payments
|
||||
LOG_PROMO_ACTIVATIONS=True # Log promo code activations
|
||||
LOG_TRIAL_ACTIVATIONS=True # Log trial activations
|
||||
LOG_SUSPICIOUS_ACTIVITY=True # Log suspicious activity
|
||||
|
||||
# Embedded mode thumbnails. Please don't touch this if you don't know what it is.
|
||||
INLINE_REFERRAL_THUMBNAIL_URL=https://cdn-icons-png.flaticon.com/512/1077/1077114.png
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Build and Push Dev Docker Image
|
||||
name: Build and Push Dev GHCR Image
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -9,8 +9,7 @@ on:
|
||||
- dev
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
GHCR_IMAGE: ghcr.io/3252a8/remnawave-tg-shop
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -23,21 +22,22 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Container Registry
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
images: |
|
||||
${{ env.GHCR_IMAGE }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
@@ -56,4 +56,4 @@ jobs:
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Image digest
|
||||
run: echo ${{ steps.meta.outputs.digest }}
|
||||
run: echo ${{ steps.meta.outputs.digest }}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Build and Publish multi-arch Docker Image
|
||||
name: Build and Publish GHCR Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -9,6 +9,9 @@ on:
|
||||
paths-ignore:
|
||||
- 'README.md'
|
||||
|
||||
env:
|
||||
GHCR_IMAGE: ghcr.io/3252a8/remnawave-tg-shop
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -33,12 +36,21 @@ jobs:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ env.GHCR_IMAGE }}
|
||||
tags: |
|
||||
type=raw,value=latest
|
||||
type=semver,pattern={{version}}
|
||||
|
||||
- name: Build and push multi-arch Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: |
|
||||
ghcr.io/${{ github.repository }}:latest
|
||||
ghcr.io/${{ github.repository }}:${{ github.ref_name }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
+3
-3
@@ -1,4 +1,4 @@
|
||||
FROM python:3.11-slim AS builder
|
||||
FROM python:3.12-slim AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -7,11 +7,11 @@ COPY requirements.txt .
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
FROM python:3.11-slim
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
|
||||
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
|
||||
|
||||
COPY . .
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
- **Пробная подписка:** Система пробных подписок для новых пользователей (активируется вручную по кнопке).
|
||||
- **Промокоды:** Возможность применять промокоды для получения скидок или бонусных дней.
|
||||
- **Реферальная программа:** Пользователи могут приглашать друзей и получать за это бонусные дни подписки.
|
||||
- **Оплата:** Поддержка оплаты через YooKassa, FreeKassa (REST API), CryptoPay, Telegram Stars и Tribute.
|
||||
- **Оплата:** Поддержка оплаты через YooKassa, FreeKassa (REST API), Platega, SeverPay, CryptoPay и Telegram Stars.
|
||||
|
||||
### Для администраторов:
|
||||
- **Защищенная админ-панель:** Доступ только для администраторов, указанных в `ADMIN_IDS`.
|
||||
@@ -24,11 +24,11 @@
|
||||
|
||||
## 🚀 Технологии
|
||||
|
||||
- **Python 3.11**
|
||||
- **Python 3.12**
|
||||
- **Aiogram 3.x:** Асинхронный фреймворк для Telegram ботов.
|
||||
- **aiohttp:** Для запуска веб-сервера (вебхуки).
|
||||
- **SQLAlchemy 2.x & asyncpg:** Асинхронная работа с базой данных PostgreSQL.
|
||||
- **YooKassa, FreeKassa API, aiocryptopay:** Интеграции с платежными системами.
|
||||
- **YooKassa, FreeKassa API, Platega, SeverPay, aiocryptopay:** Интеграции с платежными системами.
|
||||
- **Pydantic:** Для управления настройками из `.env` файла.
|
||||
- **Docker & Docker Compose:** Для контейнеризации и развертывания.
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
|
||||
1. **Клонируйте репозиторий:**
|
||||
```bash
|
||||
git clone https://github.com/machka-pasla/remnawave-tg-shop
|
||||
git clone https://github.com/3252a8/remnawave-tg-shop
|
||||
cd remnawave-tg-shop
|
||||
```
|
||||
|
||||
@@ -80,9 +80,14 @@
|
||||
| `WEBHOOK_BASE_URL`| **Обязательно.** Базовый URL для вебхуков, например `https://your.domain.com`. |
|
||||
| `WEB_SERVER_HOST` | Хост для веб-сервера. | `0.0.0.0` |
|
||||
| `WEB_SERVER_PORT` | Порт для веб-сервера. | `8080` |
|
||||
| `PAYMENT_METHODS_ORDER` | (Опционально) Порядок отображения кнопок оплаты через запятую. Поддерживаемые ключи: `severpay`, `freekassa`, `platega`, `yookassa`, `stars`, `cryptopay`. Первый будет сверху. |
|
||||
| `YOOKASSA_ENABLED` | Включить/выключить YooKassa (`true`/`false`). |
|
||||
| `YOOKASSA_SHOP_ID` | ID вашего магазина в YooKassa. |
|
||||
| `YOOKASSA_SECRET_KEY`| Секретный ключ магазина YooKassa. |
|
||||
| `YOOKASSA_AUTOPAYMENTS_ENABLED` | Включить автопродление (сохранение карт, автосписания, управление способами оплаты). |
|
||||
| `YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING` | Требовать обязательную привязку карты при оплате с автосписанием. Установите `false`, чтобы пользователю показывался чекбокс «Сохранить карту». |
|
||||
| `NALOGO_INN` | ИНН для авторизации в nalog.ru (самозанятый). |
|
||||
| `NALOGO_PASSWORD` | Пароль для авторизации в nalog.ru (самозанятый). |
|
||||
| `CRYPTOPAY_ENABLED` | Включить/выключить CryptoPay (`true`/`false`). |
|
||||
| `CRYPTOPAY_TOKEN` | Токен из вашего CryptoPay App. |
|
||||
| `FREEKASSA_ENABLED` | Включить/выключить FreeKassa (`true`/`false`). |
|
||||
@@ -91,8 +96,20 @@
|
||||
| `FREEKASSA_SECOND_SECRET` | Секретное слово №2 — используется для проверки уведомлений от FreeKassa. |
|
||||
| `FREEKASSA_PAYMENT_URL` | (Опционально, legacy SCI) Базовый URL платёжной формы FreeKassa. По умолчанию `https://pay.freekassa.ru/`. |
|
||||
| `FREEKASSA_PAYMENT_IP` | Внешний IP вашего сервера, который будет передаваться в запрос оплаты. |
|
||||
| `FREEKASSA_PAYMENT_METHOD_ID` | ID метода оплаты через магазин FreeKassa. По умолчанию `44`. |
|
||||
| `STARS_ENABLED` | Включить/выключить Telegram Stars (`true`/`false`). |
|
||||
| `TRIBUTE_ENABLED`| Включить/выключить Tribute (`true`/`false`). |
|
||||
| `PLATEGA_ENABLED`| Включить/выключить Platega (`true`/`false`). |
|
||||
| `PLATEGA_MERCHANT_ID`| MerchantId из личного кабинета Platega. |
|
||||
| `PLATEGA_SECRET`| API секрет для запросов Platega. |
|
||||
| `PLATEGA_PAYMENT_METHOD`| ID способа оплаты (2 — SBP QR, 10 — РФ карты, 12 — международные карты, 13 — crypto). |
|
||||
| `PLATEGA_RETURN_URL`| (Опционально) URL редиректа после успешной оплаты. По умолчанию ссылка на бота. |
|
||||
| `PLATEGA_FAILED_URL`| (Опционально) URL редиректа при ошибке/отмене. По умолчанию как `PLATEGA_RETURN_URL`. |
|
||||
| `SEVERPAY_ENABLED` | Включить/выключить SeverPay (`true`/`false`). |
|
||||
| `SEVERPAY_MID` | MID магазина в SeverPay. |
|
||||
| `SEVERPAY_TOKEN` | Секрет/токен для подписи запросов SeverPay. |
|
||||
| `SEVERPAY_BASE_URL` | (Опционально) Базовый URL API SeverPay. По умолчанию `https://severpay.io/api/merchant`. |
|
||||
| `SEVERPAY_RETURN_URL` | (Опционально) URL редиректа после оплаты (по умолчанию ссылка на бота). |
|
||||
| `SEVERPAY_LIFETIME_MINUTES` | (Опционально) Время жизни платежной ссылки в минутах (30–4320). |
|
||||
</details>
|
||||
|
||||
<details>
|
||||
@@ -102,7 +119,6 @@
|
||||
- `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>
|
||||
|
||||
@@ -115,11 +131,12 @@
|
||||
| `PANEL_API_KEY` | API ключ для доступа к панели. |
|
||||
| `PANEL_WEBHOOK_SECRET`| Секретный ключ для проверки вебхуков от панели. |
|
||||
| `USER_SQUAD_UUIDS` | ID отрядов для новых пользователей. |
|
||||
| `USER_EXTERNAL_SQUAD_UUID` | Опционально. UUID внешнего отряда (External Squad) из [документации Remnawave](https://docs.rw/api), куда автоматически добавляются новые пользователи. |
|
||||
| `USER_TRAFFIC_LIMIT_GB`| Лимит трафика в ГБ (0 - безлимит). |
|
||||
| `USER_HWID_DEVICE_LIMIT`| Лимит устройств (HWID) для новых пользователей (0 - безлимит). |
|
||||
|
||||
> Раздел "Мои устройства" становится доступен пользователям только при включении `MY_DEVICES_SECTION_ENABLED`. Значение лимита устройств при создании записей в панели берётся из `USER_HWID_DEVICE_LIMIT`.
|
||||
</gidetails>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Настройки пробного периода</b></summary>
|
||||
@@ -138,15 +155,16 @@
|
||||
Эта команда скачает образ и запустит сервис в фоновом режиме.
|
||||
|
||||
4. **Настройка вебхуков (Обязательно):**
|
||||
Вебхуки являются **обязательным** компонентом для работы бота, так как они используются для получения уведомлений от платежных систем (YooKassa, FreeKassa, CryptoPay, Tribute) и панели Remnawave.
|
||||
Вебхуки являются **обязательным** компонентом для работы бота, так как они используются для получения уведомлений от платежных систем (YooKassa, FreeKassa, CryptoPay, Platega, SeverPay) и панели Remnawave.
|
||||
|
||||
Вам понадобится обратный прокси (например, Nginx) для обработки HTTPS-трафика и перенаправления запросов на контейнер с ботом.
|
||||
|
||||
**Пути для перенаправления:**
|
||||
- `https://<ваш_домен>/webhook/yookassa` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/yookassa`
|
||||
- `https://<ваш_домен>/webhook/freekassa` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/freekassa`
|
||||
- `https://<ваш_домен>/webhook/platega` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/platega`
|
||||
- `https://<ваш_домен>/webhook/severpay` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/severpay`
|
||||
- `https://<ваш_домен>/webhook/cryptopay` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/cryptopay`
|
||||
- `https://<ваш_домен>/webhook/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>`.
|
||||
|
||||
@@ -159,9 +177,162 @@
|
||||
|
||||
> 💡 Если включена проверка подписки на канал (`REQUIRED_CHANNEL_ID`), добавьте бота администратором в этот канал. Пользователь увидит кнопку «Проверить подписку», и, после первого успешного подтверждения, дальнейшие действия блокироваться не будут.
|
||||
|
||||
## Подробная инструкция для развертывания на сервере с панелью Remnawave
|
||||
|
||||
### 1. Клонирование репозитория
|
||||
|
||||
```bash
|
||||
git clone https://github.com/3252a8/remnawave-tg-shop && cd remnawave-tg-shop
|
||||
```
|
||||
|
||||
### 2. Настройка переменных окружения
|
||||
|
||||
```bash
|
||||
cp .env.example .env && nano .env
|
||||
```
|
||||
|
||||
**Обязательные поля для заполнения:**
|
||||
- `BOT_TOKEN` - токен телеграмм бота, например, `234567890:ABC-DEF1234ghIkl-zyx57W2v1u123ew11`
|
||||
- `ADMIN_IDS` - TG ID администраторов, например, `12345678,98765432` и т.д. (через запятую без пробелов)
|
||||
- `WEBHOOK_BASE_URL` - Обязательно. Базовый URL для вебхуков, например `https://webhook.domain.com`
|
||||
- `PANEL_API_URL` - URL API вашей панели Remnawave (например, `http://remnawave:3000/api` или `https://panel.domain.com/api`)
|
||||
- `PANEL_API_KEY` - API ключ для доступа к панели (генерируется из UI-интерфейса панели)
|
||||
- `PANEL_WEBHOOK_SECRET` - Секретный ключ для проверки вебхуков от панели (берётся из `.env` самой панели)
|
||||
- `USER_SQUAD_UUIDS` - ID отрядов для новых пользователей
|
||||
|
||||
### 3. Настройка Reverse Proxy (Nginx)
|
||||
|
||||
Перейдите в директорию конфигурации Nginx панели Remnawave:
|
||||
|
||||
```bash
|
||||
cd /opt/remnawave/nginx && nano nginx.conf
|
||||
```
|
||||
|
||||
Добавьте в `nginx.conf` следующую конфигурацию:
|
||||
|
||||
```nginx
|
||||
upstream remnawave-tg-shop {
|
||||
server remnawave-tg-shop:8080;
|
||||
}
|
||||
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
"" close;
|
||||
}
|
||||
|
||||
server {
|
||||
server_name webhook.domain.com; # Домен для отправки Webhook'ов
|
||||
listen 443 ssl;
|
||||
http2 on;
|
||||
|
||||
ssl_certificate "/etc/nginx/ssl/webhook_fullchain.pem";
|
||||
ssl_certificate_key "/etc/nginx/ssl/webhook_privkey.key";
|
||||
ssl_trusted_certificate "/etc/nginx/ssl/webhook_fullchain.pem";
|
||||
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
proxy_intercept_errors on;
|
||||
error_page 400 404 500 502 @redirect;
|
||||
|
||||
location / {
|
||||
proxy_pass http://remnawave-tg-shop$request_uri;
|
||||
}
|
||||
|
||||
location @redirect {
|
||||
return 404;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Выпуск SSL-сертификата для домена webhook
|
||||
|
||||
Убедитесь, что установлены необходимые компоненты, а также откройте 80 порт:
|
||||
|
||||
```bash
|
||||
sudo apt-get install cron socat
|
||||
curl https://get.acme.sh | sh -s email=EMAIL && source ~/.bashrc
|
||||
ufw allow 80/tcp && ufw reload
|
||||
```
|
||||
|
||||
Выпустите сертификат:
|
||||
|
||||
```bash
|
||||
acme.sh --set-default-ca --server letsencrypt
|
||||
acme.sh --issue --standalone -d 'webhook.domain.com' \
|
||||
--key-file /opt/remnawave/nginx/webhook_privkey.key \
|
||||
--fullchain-file /opt/remnawave/nginx/webhook_fullchain.pem
|
||||
```
|
||||
|
||||
### 5. Добавление сертификатов в Docker Compose Nginx
|
||||
|
||||
Отредактируйте `docker-compose.yml` панели Nginx:
|
||||
|
||||
```bash
|
||||
cd /opt/remnawave/nginx && nano docker-compose.yml
|
||||
```
|
||||
|
||||
Добавьте две строки в секцию `volumes`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
remnawave-nginx:
|
||||
image: nginx:1.26
|
||||
container_name: remnawave-nginx
|
||||
hostname: remnawave-nginx
|
||||
volumes:
|
||||
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
- ./fullchain.pem:/etc/nginx/ssl/fullchain.pem:ro
|
||||
- ./privkey.key:/etc/nginx/ssl/privkey.key:ro
|
||||
- ./subdomain_fullchain.pem:/etc/nginx/ssl/subdomain_fullchain.pem:ro
|
||||
- ./subdomain_privkey.key:/etc/nginx/ssl/subdomain_privkey.key:ro
|
||||
- ./webhook_fullchain.pem:/etc/nginx/ssl/webhook_fullchain.pem:ro # Добавьте эту строку
|
||||
- ./webhook_privkey.key:/etc/nginx/ssl/webhook_privkey.key:ro # Добавьте эту строку
|
||||
restart: always
|
||||
ports:
|
||||
- '0.0.0.0:443:443'
|
||||
networks:
|
||||
- remnawave-network
|
||||
|
||||
networks:
|
||||
remnawave-network:
|
||||
name: remnawave-network
|
||||
driver: bridge
|
||||
external: true
|
||||
```
|
||||
|
||||
### 6. Запуск бота и перезапуск Nginx
|
||||
|
||||
Запустите бота:
|
||||
|
||||
```bash
|
||||
cd /root/remnawave-tg-shop && docker compose up -d && docker compose logs -f -t
|
||||
```
|
||||
|
||||
Перезапустите Nginx:
|
||||
|
||||
```bash
|
||||
cd /opt/remnawave/nginx && docker compose down && docker compose up -d && docker compose logs -f -t
|
||||
```
|
||||
|
||||
## 🐳 Docker
|
||||
|
||||
Файлы `Dockerfile` и `docker-compose.yml` уже настроены для сборки и запуска проекта. `docker-compose.yml` использует готовый образ с GitHub Container Registry, но вы можете раскомментировать `build: .` для локальной сборки.
|
||||
Файлы `Dockerfile` и `docker-compose.yml` уже настроены для локальной сборки и запуска проекта. Если нужен готовый образ из GHCR, используйте `docker-compose-remote-server.yml` или переключите `docker-compose.yml` на строку `image:` вместо `build: .`.
|
||||
|
||||
Образ публикуется в GitHub Container Registry по пути `ghcr.io/3252a8/remnawave-tg-shop`. GitHub Actions выкладывают теги `latest` и `0.1.0`: `latest` обновляется из `main`, а `0.1.0` появляется при сборке тега `v0.1.0`.
|
||||
|
||||
Чтобы закрепить версию на сервере, можно запустить:
|
||||
```bash
|
||||
IMAGE_TAG=0.1.0 docker compose -f docker-compose-remote-server.yml up -d
|
||||
```
|
||||
|
||||
## 📁 Структура проекта
|
||||
|
||||
|
||||
@@ -9,10 +9,12 @@ from bot.services.subscription_service import SubscriptionService
|
||||
from bot.services.referral_service import ReferralService
|
||||
from bot.services.promo_code_service import PromoCodeService
|
||||
from bot.services.stars_service import StarsService
|
||||
from bot.services.tribute_service import TributeService
|
||||
from bot.services.crypto_pay_service import CryptoPayService
|
||||
from bot.services.panel_webhook_service import PanelWebhookService
|
||||
from bot.services.freekassa_service import FreeKassaService
|
||||
from bot.services.platega_service import PlategaService
|
||||
from bot.services.severpay_service import SeverPayService
|
||||
from bot.services.lknpd_service import LknpdService
|
||||
|
||||
|
||||
def build_core_services(
|
||||
@@ -45,14 +47,23 @@ def build_core_services(
|
||||
subscription_service=subscription_service,
|
||||
referral_service=referral_service,
|
||||
)
|
||||
tribute_service = TributeService(
|
||||
bot,
|
||||
settings,
|
||||
i18n,
|
||||
async_session_factory,
|
||||
panel_service,
|
||||
subscription_service,
|
||||
referral_service,
|
||||
platega_service = PlategaService(
|
||||
bot=bot,
|
||||
settings=settings,
|
||||
i18n=i18n,
|
||||
async_session_factory=async_session_factory,
|
||||
subscription_service=subscription_service,
|
||||
referral_service=referral_service,
|
||||
default_return_url=bot_username_for_default_return,
|
||||
)
|
||||
severpay_service = SeverPayService(
|
||||
bot=bot,
|
||||
settings=settings,
|
||||
i18n=i18n,
|
||||
async_session_factory=async_session_factory,
|
||||
subscription_service=subscription_service,
|
||||
referral_service=referral_service,
|
||||
default_return_url=bot_username_for_default_return,
|
||||
)
|
||||
panel_webhook_service = PanelWebhookService(bot, settings, i18n, async_session_factory, panel_service)
|
||||
yookassa_service = YooKassaService(
|
||||
@@ -62,6 +73,11 @@ def build_core_services(
|
||||
bot_username_for_default_return=bot_username_for_default_return,
|
||||
settings_obj=settings,
|
||||
)
|
||||
lknpd_service = LknpdService(
|
||||
settings.LKNPD_INN,
|
||||
settings.LKNPD_PASSWORD,
|
||||
api_url=settings.LKNPD_API_URL,
|
||||
)
|
||||
|
||||
# Wire services that depend on each other
|
||||
try:
|
||||
@@ -80,8 +96,9 @@ def build_core_services(
|
||||
"stars_service": stars_service,
|
||||
"cryptopay_service": cryptopay_service,
|
||||
"freekassa_service": freekassa_service,
|
||||
"tribute_service": tribute_service,
|
||||
"panel_webhook_service": panel_webhook_service,
|
||||
"yookassa_service": yookassa_service,
|
||||
"lknpd_service": lknpd_service,
|
||||
"platega_service": platega_service,
|
||||
"severpay_service": severpay_service,
|
||||
}
|
||||
|
||||
|
||||
@@ -23,14 +23,16 @@ async def build_and_start_web_app(
|
||||
app["i18n"] = dp.get("i18n_instance")
|
||||
for key in (
|
||||
"yookassa_service",
|
||||
"lknpd_service",
|
||||
"subscription_service",
|
||||
"referral_service",
|
||||
"panel_service",
|
||||
"stars_service",
|
||||
"freekassa_service",
|
||||
"cryptopay_service",
|
||||
"tribute_service",
|
||||
"panel_webhook_service",
|
||||
"platega_service",
|
||||
"severpay_service",
|
||||
):
|
||||
# Access dispatcher workflow_data directly to avoid sequence protocol issues
|
||||
if hasattr(dp, "workflow_data") and key in dp.workflow_data: # type: ignore
|
||||
@@ -48,15 +50,11 @@ async def build_and_start_web_app(
|
||||
)
|
||||
|
||||
from bot.handlers.user.payment import yookassa_webhook_route
|
||||
from bot.services.tribute_service import tribute_webhook_route
|
||||
from bot.services.crypto_pay_service import cryptopay_webhook_route
|
||||
from bot.services.panel_webhook_service import panel_webhook_route
|
||||
from bot.services.freekassa_service import freekassa_webhook_route
|
||||
|
||||
tribute_path = settings.tribute_webhook_path
|
||||
if tribute_path.startswith("/"):
|
||||
app.router.add_post(tribute_path, tribute_webhook_route)
|
||||
logging.info(f"Tribute webhook route configured at: [POST] {tribute_path}")
|
||||
from bot.services.platega_service import platega_webhook_route
|
||||
from bot.services.severpay_service import severpay_webhook_route
|
||||
|
||||
cp_path = settings.cryptopay_webhook_path
|
||||
if cp_path.startswith("/"):
|
||||
@@ -68,6 +66,16 @@ async def build_and_start_web_app(
|
||||
app.router.add_post(fk_path, freekassa_webhook_route)
|
||||
logging.info(f"FreeKassa webhook route configured at: [POST] {fk_path}")
|
||||
|
||||
pg_path = settings.platega_webhook_path
|
||||
if pg_path.startswith("/"):
|
||||
app.router.add_post(pg_path, platega_webhook_route)
|
||||
logging.info(f"Platega webhook route configured at: [POST] {pg_path}")
|
||||
|
||||
sp_path = settings.severpay_webhook_path
|
||||
if sp_path.startswith("/"):
|
||||
app.router.add_post(sp_path, severpay_webhook_route)
|
||||
logging.info(f"SeverPay webhook route configured at: [POST] {sp_path}")
|
||||
|
||||
# YooKassa webhook (register only when base URL present and path configured)
|
||||
yk_path = settings.yookassa_webhook_path
|
||||
if settings.WEBHOOK_BASE_URL and yk_path and yk_path.startswith("/"):
|
||||
@@ -94,4 +102,3 @@ async def build_and_start_web_app(
|
||||
|
||||
# Run until cancelled
|
||||
await asyncio.Event().wait()
|
||||
|
||||
|
||||
@@ -178,7 +178,7 @@ async def ads_delete_cancel(callback: types.CallbackQuery, settings: Settings, i
|
||||
|
||||
camp = await ad_dal.get_campaign_by_id(session, camp_id)
|
||||
if not camp:
|
||||
await callback.answer(_("admin_ads_not_found", default="Кампания не найдена."), show_alert=True)
|
||||
await callback.answer(_("admin_ads_not_found"), show_alert=True)
|
||||
return
|
||||
try:
|
||||
stats = await ad_dal.get_campaign_stats(session, camp_id)
|
||||
@@ -224,7 +224,7 @@ async def ads_delete_confirm(callback: types.CallbackQuery, settings: Settings,
|
||||
|
||||
existed = await ad_dal.delete_campaign(session, camp_id)
|
||||
if not existed:
|
||||
await callback.answer(_("admin_ads_not_found", default="Кампания не найдена."), show_alert=True)
|
||||
await callback.answer(_("admin_ads_not_found"), show_alert=True)
|
||||
return
|
||||
await session.commit()
|
||||
|
||||
|
||||
@@ -122,7 +122,6 @@ async def process_broadcast_message_handler(
|
||||
await message.answer(
|
||||
_(
|
||||
"admin_broadcast_invalid_html",
|
||||
default="❌ Некорректный HTML в сообщении. Пожалуйста, отправьте корректный HTML (поддерживаются теги Telegram) или уберите теги.\nОшибка: {error}",
|
||||
error=str(e),
|
||||
)
|
||||
)
|
||||
@@ -347,15 +346,6 @@ async def confirm_broadcast_callback_handler(
|
||||
total_failed = failed_count + dynamic_failed
|
||||
return _(
|
||||
"broadcast_queue_result",
|
||||
default=(
|
||||
"🚀 Рассылка поставлена в очередь!\n"
|
||||
"📤 В очередь добавлено: {sent_count}\n"
|
||||
"❌ Ошибок: {failed_count}\n\n"
|
||||
"📊 Статус очередей:\n"
|
||||
"👥 Очередь пользователей: {user_queue_size} сообщений\n"
|
||||
"📢 Очередь групп: {group_queue_size} сообщений\n\n"
|
||||
"ℹ️ Сообщения будут отправлены автоматически с соблюдением лимитов Telegram."
|
||||
),
|
||||
sent_count=sent_count,
|
||||
failed_count=total_failed,
|
||||
user_queue_size=stats["user_queue_size"],
|
||||
|
||||
@@ -98,8 +98,22 @@ async def admin_panel_actions_callback_handler(
|
||||
await admin_user_mgmnt_handlers.unban_user_prompt_handler(
|
||||
callback, state, i18n_data, settings, session)
|
||||
elif action == "users_management":
|
||||
# This is deprecated, kept for compatibility
|
||||
from . import user_management as admin_user_management_handlers
|
||||
await admin_user_management_handlers.user_management_menu_handler(
|
||||
await admin_user_management_handlers.user_search_prompt_handler(
|
||||
callback, state, i18n_data, settings, session)
|
||||
elif action == "users_list" and len(action_parts) > 2:
|
||||
# Route to users list handler with page number
|
||||
from . import user_management as admin_user_management_handlers
|
||||
try:
|
||||
page = int(action_parts[2])
|
||||
await admin_user_management_handlers.users_list_handler(
|
||||
callback, i18n_data, settings, session, page)
|
||||
except (IndexError, ValueError):
|
||||
await callback.answer("Invalid page number", show_alert=True)
|
||||
elif action == "users_search_prompt":
|
||||
from . import user_management as admin_user_management_handlers
|
||||
await admin_user_management_handlers.user_search_prompt_handler(
|
||||
callback, state, i18n_data, settings, session)
|
||||
elif action == "view_banned":
|
||||
|
||||
@@ -127,6 +141,9 @@ async def admin_panel_actions_callback_handler(
|
||||
from . import payments as admin_payments_handlers
|
||||
await admin_payments_handlers.view_payments_handler(
|
||||
callback, i18n_data, settings, session)
|
||||
elif action == "user_ratings":
|
||||
await admin_stats_handlers.show_user_ratings_handler(
|
||||
callback, i18n_data, settings, session)
|
||||
elif action == "ads":
|
||||
from . import ads as admin_ads_handlers
|
||||
await admin_ads_handlers.show_ads_menu(callback, settings, i18n_data, session)
|
||||
|
||||
@@ -338,8 +338,7 @@ async def export_logs_csv_handler(callback: types.CallbackQuery,
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
await callback.answer(_(
|
||||
"admin_logs_csv_export_started",
|
||||
default="🔄 Начинаю экспорт логов в CSV..."
|
||||
"admin_logs_csv_export_started"
|
||||
))
|
||||
|
||||
try:
|
||||
@@ -349,8 +348,7 @@ async def export_logs_csv_handler(callback: types.CallbackQuery,
|
||||
|
||||
if not logs_models:
|
||||
await callback.message.answer(_(
|
||||
"admin_logs_csv_no_data",
|
||||
default="❌ Нет данных для экспорта"
|
||||
"admin_logs_csv_no_data"
|
||||
))
|
||||
return
|
||||
|
||||
@@ -360,16 +358,16 @@ async def export_logs_csv_handler(callback: types.CallbackQuery,
|
||||
|
||||
# Write header
|
||||
headers = [
|
||||
_("admin_csv_header_log_id", default="Log ID"),
|
||||
_("admin_csv_header_timestamp", default="Timestamp"),
|
||||
_("admin_csv_header_user_id", default="User ID"),
|
||||
_("admin_csv_header_telegram_username", default="Telegram Username"),
|
||||
_("admin_csv_header_telegram_first_name", default="Telegram First Name"),
|
||||
_("admin_csv_header_event_type", default="Event Type"),
|
||||
_("admin_csv_header_content", default="Content"),
|
||||
_("admin_csv_header_is_admin_event", default="Is Admin Event"),
|
||||
_("admin_csv_header_target_user_id", default="Target User ID"),
|
||||
_("admin_csv_header_raw_update_preview", default="Raw Update Preview")
|
||||
_("admin_csv_header_log_id"),
|
||||
_("admin_csv_header_timestamp"),
|
||||
_("admin_csv_header_user_id"),
|
||||
_("admin_csv_header_telegram_username"),
|
||||
_("admin_csv_header_telegram_first_name"),
|
||||
_("admin_csv_header_event_type"),
|
||||
_("admin_csv_header_content"),
|
||||
_("admin_csv_header_is_admin_event"),
|
||||
_("admin_csv_header_target_user_id"),
|
||||
_("admin_csv_header_raw_update_preview")
|
||||
]
|
||||
csv_writer.writerow(headers)
|
||||
|
||||
@@ -414,7 +412,6 @@ async def export_logs_csv_handler(callback: types.CallbackQuery,
|
||||
csv_file,
|
||||
caption=_(
|
||||
"admin_logs_csv_export_success",
|
||||
default="✅ Экспорт логов завершен!\n\n📊 Записей: {count}\n📅 Дата экспорта: {date}",
|
||||
count=len(logs_models),
|
||||
date=now.strftime('%Y-%m-%d %H:%M:%S')
|
||||
)
|
||||
@@ -424,6 +421,5 @@ async def export_logs_csv_handler(callback: types.CallbackQuery,
|
||||
logging.error(f"Error exporting logs to CSV: {e}", exc_info=True)
|
||||
await callback.message.answer(_(
|
||||
"admin_logs_csv_export_failed",
|
||||
default="❌ Ошибка при экспорте логов: {error}",
|
||||
error=str(e)
|
||||
))
|
||||
|
||||
@@ -34,12 +34,20 @@ async def get_payments_with_pagination(session: AsyncSession, page: int = 0,
|
||||
return payments, total_count
|
||||
|
||||
|
||||
def format_payment_text(payment: Payment, i18n: JsonI18n, lang: str) -> str:
|
||||
def format_payment_text(payment: Payment, i18n: JsonI18n, lang: str, settings: Settings) -> str:
|
||||
"""Format single payment info as text."""
|
||||
_ = lambda key, **kwargs: i18n.gettext(lang, key, **kwargs)
|
||||
|
||||
pending_statuses = [
|
||||
'pending',
|
||||
'pending_yookassa',
|
||||
'pending_freekassa',
|
||||
'pending_platega',
|
||||
'pending_severpay',
|
||||
'pending_cryptopay',
|
||||
]
|
||||
status_emoji = "✅" if payment.status == 'succeeded' else (
|
||||
"⏳" if payment.status in ['pending', 'pending_yookassa', 'pending_freekassa'] else "❌"
|
||||
"⏳" if payment.status in pending_statuses else "❌"
|
||||
)
|
||||
|
||||
user_info = f"User {payment.user_id}"
|
||||
@@ -52,17 +60,27 @@ def format_payment_text(payment: Payment, i18n: JsonI18n, lang: str) -> str:
|
||||
|
||||
provider_text = {
|
||||
'yookassa': 'YooKassa',
|
||||
'tribute': 'Tribute',
|
||||
'telegram_stars': 'Telegram Stars',
|
||||
'cryptopay': 'CryptoPay',
|
||||
'freekassa': 'FreeKassa',
|
||||
'severpay': 'SeverPay',
|
||||
'platega': 'Platega',
|
||||
}.get(payment.provider, payment.provider or 'Unknown')
|
||||
|
||||
traffic_mode = getattr(settings, "traffic_sale_mode", False)
|
||||
if traffic_mode:
|
||||
traffic_val = payment.subscription_duration_months or 0
|
||||
traffic_display = str(int(traffic_val)) if float(traffic_val).is_integer() else f"{traffic_val:g}"
|
||||
period_line = _("admin_payment_traffic_label", traffic_gb=traffic_display)
|
||||
else:
|
||||
period_line = _("admin_payment_months_label", months=payment.subscription_duration_months or 0)
|
||||
|
||||
return (
|
||||
f"{status_emoji} <b>{payment.amount} {payment.currency}</b>\n"
|
||||
f"👤 {user_info}\n"
|
||||
f"💳 {provider_text}\n"
|
||||
f"📅 {payment_date}\n"
|
||||
f"{period_line}\n"
|
||||
f"📋 {payment.status}\n"
|
||||
f"📝 {payment.description or 'N/A'}"
|
||||
)
|
||||
@@ -84,7 +102,7 @@ async def view_payments_handler(callback: types.CallbackQuery, i18n_data: dict,
|
||||
|
||||
if not payments and page == 0:
|
||||
await callback.message.edit_text(
|
||||
_("admin_no_payments_found", default="Платежи не найдены."),
|
||||
_("admin_no_payments_found"),
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
@@ -92,7 +110,7 @@ async def view_payments_handler(callback: types.CallbackQuery, i18n_data: dict,
|
||||
return
|
||||
|
||||
# Format payments text
|
||||
text_parts = [_("admin_payments_header", default="💰 <b>Все платежи</b>")]
|
||||
text_parts = [_("admin_payments_header")]
|
||||
text_parts.append(_("admin_payments_pagination_info",
|
||||
shown=len(payments),
|
||||
total=total_count,
|
||||
@@ -100,7 +118,7 @@ async def view_payments_handler(callback: types.CallbackQuery, i18n_data: dict,
|
||||
total_pages=total_pages) + "\n")
|
||||
|
||||
for i, payment in enumerate(payments, 1):
|
||||
text_parts.append(f"<b>{page * page_size + i}.</b> {format_payment_text(payment, i18n, current_lang)}")
|
||||
text_parts.append(f"<b>{page * page_size + i}.</b> {format_payment_text(payment, i18n, current_lang, settings)}")
|
||||
text_parts.append("") # Empty line between payments
|
||||
|
||||
# Build keyboard with pagination and export
|
||||
@@ -122,11 +140,11 @@ async def view_payments_handler(callback: types.CallbackQuery, i18n_data: dict,
|
||||
# Export and refresh buttons
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_("admin_export_payments_csv", default="📊 Экспорт CSV"),
|
||||
text=_("admin_export_payments_csv"),
|
||||
callback_data="payments_export_csv"
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text=_("admin_refresh_payments", default="🔄 Обновить"),
|
||||
text=_("admin_refresh_payments"),
|
||||
callback_data=f"payments_page:{page}"
|
||||
)
|
||||
)
|
||||
@@ -173,7 +191,7 @@ async def export_payments_csv_handler(callback: types.CallbackQuery, i18n_data:
|
||||
|
||||
if not all_payments:
|
||||
await callback.answer(
|
||||
_("admin_no_payments_to_export", default="Нет платежей для экспорта."),
|
||||
_("admin_no_payments_to_export"),
|
||||
show_alert=True
|
||||
)
|
||||
return
|
||||
@@ -184,22 +202,30 @@ async def export_payments_csv_handler(callback: types.CallbackQuery, i18n_data:
|
||||
|
||||
# Write header
|
||||
writer.writerow([
|
||||
_("admin_csv_payment_id", default="ID"),
|
||||
_("admin_csv_user_id", default="User ID"),
|
||||
_("admin_csv_username", default="Username"),
|
||||
_("admin_csv_first_name", default="First Name"),
|
||||
_("admin_csv_amount", default="Amount"),
|
||||
_("admin_csv_currency", default="Currency"),
|
||||
_("admin_csv_provider", default="Provider"),
|
||||
_("admin_csv_status", default="Status"),
|
||||
_("admin_csv_description", default="Description"),
|
||||
_("admin_csv_months", default="Months"),
|
||||
_("admin_csv_created_at", default="Created At"),
|
||||
_("admin_csv_provider_payment_id", default="Provider Payment ID")
|
||||
_("admin_csv_payment_id"),
|
||||
_("admin_csv_user_id"),
|
||||
_("admin_csv_username"),
|
||||
_("admin_csv_first_name"),
|
||||
_("admin_csv_amount"),
|
||||
_("admin_csv_currency"),
|
||||
_("admin_csv_provider"),
|
||||
_("admin_csv_status"),
|
||||
_("admin_csv_description"),
|
||||
_("admin_csv_units"),
|
||||
_("admin_csv_created_at"),
|
||||
_("admin_csv_provider_payment_id")
|
||||
])
|
||||
|
||||
traffic_mode = getattr(settings, "traffic_sale_mode", False)
|
||||
|
||||
# Write payment data
|
||||
for payment in all_payments:
|
||||
units_val = payment.subscription_duration_months or ""
|
||||
if traffic_mode and units_val not in ("", None):
|
||||
try:
|
||||
units_val = str(int(units_val)) if float(units_val).is_integer() else f"{units_val:g}"
|
||||
except Exception:
|
||||
units_val = payment.subscription_duration_months or ""
|
||||
writer.writerow([
|
||||
payment.payment_id,
|
||||
payment.user_id,
|
||||
@@ -210,7 +236,7 @@ async def export_payments_csv_handler(callback: types.CallbackQuery, i18n_data:
|
||||
payment.provider or "",
|
||||
payment.status,
|
||||
payment.description or "",
|
||||
payment.subscription_duration_months or "",
|
||||
units_val,
|
||||
payment.created_at.strftime('%Y-%m-%d %H:%M:%S') if payment.created_at else "",
|
||||
payment.provider_payment_id or ""
|
||||
])
|
||||
@@ -229,13 +255,12 @@ async def export_payments_csv_handler(callback: types.CallbackQuery, i18n_data:
|
||||
|
||||
await callback.message.reply_document(
|
||||
document=file,
|
||||
caption=_("admin_payments_export_success",
|
||||
default="📊 Payments export completed!\nTotal records: {count}",
|
||||
caption=_("admin_payments_export_success",
|
||||
count=len(all_payments))
|
||||
)
|
||||
|
||||
await callback.answer(
|
||||
_("admin_export_sent", default="File sent!"),
|
||||
_("admin_export_sent"),
|
||||
show_alert=False
|
||||
)
|
||||
|
||||
|
||||
@@ -34,8 +34,7 @@ async def create_bulk_promo_prompt_handler(callback: types.CallbackQuery,
|
||||
|
||||
# Step 1: Ask for quantity
|
||||
prompt_text = _(
|
||||
"admin_bulk_promo_step1_quantity",
|
||||
default="🎟 <b>Массовое создание промокодов</b>\n\n<b>Шаг 1 из 4:</b> Количество\n\nВведите количество промокодов для создания (1-100):"
|
||||
"admin_bulk_promo_step1_quantity"
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -77,8 +76,7 @@ async def process_bulk_promo_quantity_handler(message: types.Message,
|
||||
quantity = int(message.text.strip())
|
||||
if not (1 <= quantity <= 100):
|
||||
await message.answer(_(
|
||||
"admin_bulk_promo_invalid_quantity",
|
||||
default="❌ Количество промокодов должно быть от 1 до 100"
|
||||
"admin_bulk_promo_invalid_quantity"
|
||||
))
|
||||
return
|
||||
|
||||
@@ -87,7 +85,6 @@ async def process_bulk_promo_quantity_handler(message: types.Message,
|
||||
# Step 2: Ask for bonus days
|
||||
prompt_text = _(
|
||||
"admin_bulk_promo_step2_bonus_days",
|
||||
default="🎟 <b>Массовое создание промокодов</b>\n\n<b>Шаг 2 из 4:</b> Бонусные дни\n\nКоличество: <b>{quantity}</b>\n\nВведите количество бонусных дней для каждого промокода (1-365):",
|
||||
quantity=quantity
|
||||
)
|
||||
|
||||
@@ -100,8 +97,7 @@ async def process_bulk_promo_quantity_handler(message: types.Message,
|
||||
|
||||
except ValueError:
|
||||
await message.answer(_(
|
||||
"admin_promo_invalid_number",
|
||||
default="❌ Введите корректное число"
|
||||
"admin_promo_invalid_number"
|
||||
))
|
||||
except Exception as e:
|
||||
logging.error(f"Error processing bulk promo quantity: {e}")
|
||||
@@ -125,8 +121,7 @@ async def process_bulk_promo_bonus_days_handler(message: types.Message,
|
||||
bonus_days = int(message.text.strip())
|
||||
if not (1 <= bonus_days <= 365):
|
||||
await message.answer(_(
|
||||
"admin_promo_invalid_bonus_days",
|
||||
default="❌ Количество бонусных дней должно быть от 1 до 365"
|
||||
"admin_promo_invalid_bonus_days"
|
||||
))
|
||||
return
|
||||
|
||||
@@ -136,7 +131,6 @@ async def process_bulk_promo_bonus_days_handler(message: types.Message,
|
||||
data = await state.get_data()
|
||||
prompt_text = _(
|
||||
"admin_bulk_promo_step3_max_activations",
|
||||
default="🎟 <b>Массовое создание промокодов</b>\n\n<b>Шаг 3 из 4:</b> Лимит активаций\n\nКоличество: <b>{quantity}</b>\nБонусные дни: <b>{bonus_days}</b>\n\nВведите максимальное количество активаций для каждого промокода (1-10000):",
|
||||
quantity=data.get("quantity"),
|
||||
bonus_days=bonus_days
|
||||
)
|
||||
@@ -150,8 +144,7 @@ async def process_bulk_promo_bonus_days_handler(message: types.Message,
|
||||
|
||||
except ValueError:
|
||||
await message.answer(_(
|
||||
"admin_promo_invalid_number",
|
||||
default="❌ Введите корректное число"
|
||||
"admin_promo_invalid_number"
|
||||
))
|
||||
except Exception as e:
|
||||
logging.error(f"Error processing bulk promo bonus days: {e}")
|
||||
@@ -175,8 +168,7 @@ async def process_bulk_promo_max_activations_handler(message: types.Message,
|
||||
max_activations = int(message.text.strip())
|
||||
if not (1 <= max_activations <= 10000):
|
||||
await message.answer(_(
|
||||
"admin_promo_invalid_max_activations",
|
||||
default="❌ Максимальное количество активаций должно быть от 1 до 10000"
|
||||
"admin_promo_invalid_max_activations"
|
||||
))
|
||||
return
|
||||
|
||||
@@ -186,7 +178,6 @@ async def process_bulk_promo_max_activations_handler(message: types.Message,
|
||||
data = await state.get_data()
|
||||
prompt_text = _(
|
||||
"admin_bulk_promo_step4_validity",
|
||||
default="🎟 <b>Массовое создание промокодов</b>\n\n<b>Шаг 4 из 4:</b> Срок действия\n\nКоличество: <b>{quantity}</b>\nБонусные дни: <b>{bonus_days}</b>\nМакс. активаций: <b>{max_activations}</b>\n\nВыберите срок действия промокодов:",
|
||||
quantity=data.get("quantity"),
|
||||
bonus_days=data.get("bonus_days"),
|
||||
max_activations=max_activations
|
||||
@@ -196,19 +187,19 @@ async def process_bulk_promo_max_activations_handler(message: types.Message,
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_("admin_promo_unlimited_validity", default="🔄 Без ограничений"),
|
||||
text=_("admin_promo_unlimited_validity"),
|
||||
callback_data="bulk_promo_unlimited_validity"
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_("admin_promo_set_validity_days", default="📅 Указать дни"),
|
||||
text=_("admin_promo_set_validity_days"),
|
||||
callback_data="bulk_promo_set_validity"
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_("admin_back_to_panel", default="🔙 В админ панель"),
|
||||
text=_("admin_back_to_panel"),
|
||||
callback_data="admin_action:main"
|
||||
)
|
||||
)
|
||||
@@ -222,8 +213,7 @@ async def process_bulk_promo_max_activations_handler(message: types.Message,
|
||||
|
||||
except ValueError:
|
||||
await message.answer(_(
|
||||
"admin_promo_invalid_number",
|
||||
default="❌ Введите корректное число"
|
||||
"admin_promo_invalid_number"
|
||||
))
|
||||
except Exception as e:
|
||||
logging.error(f"Error processing bulk promo max activations: {e}")
|
||||
@@ -257,7 +247,6 @@ async def process_bulk_promo_set_validity(callback: types.CallbackQuery,
|
||||
data = await state.get_data()
|
||||
prompt_text = _(
|
||||
"admin_bulk_promo_enter_validity_days",
|
||||
default="🎟 <b>Массовое создание промокодов</b>\n\n<b>Шаг 4 из 4:</b> Срок действия\n\nКоличество: <b>{quantity}</b>\nБонусные дни: <b>{bonus_days}</b>\nМакс. активаций: <b>{max_activations}</b>\n\nВведите количество дней действия промокодов (1-365):",
|
||||
quantity=data.get("quantity"),
|
||||
bonus_days=data.get("bonus_days"),
|
||||
max_activations=data.get("max_activations")
|
||||
@@ -296,8 +285,7 @@ async def process_bulk_promo_validity_days_handler(message: types.Message,
|
||||
validity_days = int(message.text.strip())
|
||||
if not (1 <= validity_days <= 365):
|
||||
await message.answer(_(
|
||||
"admin_promo_invalid_validity_days",
|
||||
default="❌ Срок действия должен быть от 1 до 365 дней"
|
||||
"admin_promo_invalid_validity_days"
|
||||
))
|
||||
return
|
||||
|
||||
@@ -306,8 +294,7 @@ async def process_bulk_promo_validity_days_handler(message: types.Message,
|
||||
|
||||
except ValueError:
|
||||
await message.answer(_(
|
||||
"admin_promo_invalid_number",
|
||||
default="❌ Введите корректное число"
|
||||
"admin_promo_invalid_number"
|
||||
))
|
||||
except Exception as e:
|
||||
logging.error(f"Error processing bulk promo validity days: {e}")
|
||||
@@ -333,7 +320,6 @@ async def create_bulk_promo_codes_final(callback_or_message,
|
||||
# Show progress message
|
||||
progress_text = _(
|
||||
"admin_bulk_promo_creating",
|
||||
default="🔄 Создание {quantity} промокодов...",
|
||||
quantity=quantity
|
||||
)
|
||||
|
||||
@@ -395,12 +381,10 @@ async def create_bulk_promo_codes_final(callback_or_message,
|
||||
# Success message
|
||||
success_lines = [
|
||||
_(
|
||||
"admin_bulk_promo_created_title",
|
||||
default="✅ <b>Массовое создание завершено!</b>\n"
|
||||
"admin_bulk_promo_created_title"
|
||||
),
|
||||
_(
|
||||
"admin_bulk_promo_created_stats",
|
||||
default="📊 Создано: <b>{created}</b> из <b>{total}</b>",
|
||||
created=len(created_codes),
|
||||
total=quantity
|
||||
)
|
||||
@@ -409,14 +393,11 @@ async def create_bulk_promo_codes_final(callback_or_message,
|
||||
if data.get("validity_days"):
|
||||
validity_text = f"{data['validity_days']} дней"
|
||||
else:
|
||||
validity_text = _("admin_promo_unlimited", default="Без ограничений")
|
||||
validity_text = _("admin_promo_unlimited")
|
||||
|
||||
success_lines.append(
|
||||
_(
|
||||
"admin_bulk_promo_settings",
|
||||
default="🎁 Бонусные дни: <b>{bonus_days}</b>\n"
|
||||
"📊 Макс. активаций: <b>{max_activations}</b>\n"
|
||||
"⏰ Срок действия: <b>{validity}</b>",
|
||||
bonus_days=data["bonus_days"],
|
||||
max_activations=data["max_activations"],
|
||||
validity=validity_text
|
||||
@@ -521,7 +502,7 @@ async def create_bulk_promo_codes_final(callback_or_message,
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error creating bulk promo codes: {e}")
|
||||
error_text = _("error_occurred_try_again", default="❌ Произошла ошибка. Попробуйте снова.")
|
||||
error_text = _("error_occurred_try_again")
|
||||
|
||||
if hasattr(callback_or_message, 'message'): # CallbackQuery
|
||||
await callback_or_message.message.answer(error_text)
|
||||
@@ -564,5 +545,5 @@ async def cancel_bulk_promo_creation_state_to_menu(callback: types.CallbackQuery
|
||||
reply_markup=get_admin_panel_keyboard(i18n, current_lang, settings)
|
||||
)
|
||||
|
||||
await callback.answer(_("admin_bulk_promo_creation_cancelled", default="Массовое создание промокодов отменено"))
|
||||
await state.clear()
|
||||
await callback.answer(_("admin_bulk_promo_creation_cancelled"))
|
||||
await state.clear()
|
||||
|
||||
@@ -30,8 +30,7 @@ async def create_promo_prompt_handler(callback: types.CallbackQuery,
|
||||
|
||||
# Step 1: Ask for promo code
|
||||
prompt_text = _(
|
||||
"admin_promo_step1_code",
|
||||
default="🎟 <b>Создание промокода</b>\n\n<b>Шаг 1 из 4:</b> Код промокода\n\nВведите код промокода (3-30 символов, только буквы и цифры):"
|
||||
"admin_promo_step1_code"
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -68,8 +67,7 @@ async def process_promo_code_handler(message: types.Message,
|
||||
code_str = message.text.strip().upper()
|
||||
if not (3 <= len(code_str) <= 30 and code_str.isalnum()):
|
||||
await message.answer(_(
|
||||
"admin_promo_invalid_code_format",
|
||||
default="❌ Код промокода должен содержать 3-30 символов (только буквы и цифры)"
|
||||
"admin_promo_invalid_code_format"
|
||||
))
|
||||
return
|
||||
|
||||
@@ -77,8 +75,7 @@ async def process_promo_code_handler(message: types.Message,
|
||||
existing_promo = await promo_code_dal.get_promo_code_by_code(session, code_str)
|
||||
if existing_promo:
|
||||
await message.answer(_(
|
||||
"admin_promo_code_already_exists",
|
||||
default="❌ Промокод с таким кодом уже существует"
|
||||
"admin_promo_code_already_exists"
|
||||
))
|
||||
return
|
||||
|
||||
@@ -87,7 +84,6 @@ async def process_promo_code_handler(message: types.Message,
|
||||
# Step 2: Ask for bonus days
|
||||
prompt_text = _(
|
||||
"admin_promo_step2_bonus_days",
|
||||
default="🎟 <b>Создание промокода</b>\n\n<b>Шаг 2 из 4:</b> Бонусные дни\n\nКод: <b>{code}</b>\n\nВведите количество бонусных дней (1-365):",
|
||||
code=code_str
|
||||
)
|
||||
|
||||
@@ -120,8 +116,7 @@ async def process_promo_bonus_days_handler(message: types.Message,
|
||||
bonus_days = int(message.text.strip())
|
||||
if not (1 <= bonus_days <= 365):
|
||||
await message.answer(_(
|
||||
"admin_promo_invalid_bonus_days",
|
||||
default="❌ Количество бонусных дней должно быть от 1 до 365"
|
||||
"admin_promo_invalid_bonus_days"
|
||||
))
|
||||
return
|
||||
|
||||
@@ -131,7 +126,6 @@ async def process_promo_bonus_days_handler(message: types.Message,
|
||||
data = await state.get_data()
|
||||
prompt_text = _(
|
||||
"admin_promo_step3_max_activations",
|
||||
default="🎟 <b>Создание промокода</b>\n\n<b>Шаг 3 из 4:</b> Лимит активаций\n\nКод: <b>{code}</b>\nБонусные дни: <b>{bonus_days}</b>\n\nВведите максимальное количество активаций (1-10000):",
|
||||
code=data.get("promo_code"),
|
||||
bonus_days=bonus_days
|
||||
)
|
||||
@@ -145,8 +139,7 @@ async def process_promo_bonus_days_handler(message: types.Message,
|
||||
|
||||
except ValueError:
|
||||
await message.answer(_(
|
||||
"admin_promo_invalid_number",
|
||||
default="❌ Введите корректное число"
|
||||
"admin_promo_invalid_number"
|
||||
))
|
||||
except Exception as e:
|
||||
logging.error(f"Error processing promo bonus days: {e}")
|
||||
@@ -170,8 +163,7 @@ async def process_promo_max_activations_handler(message: types.Message,
|
||||
max_activations = int(message.text.strip())
|
||||
if not (1 <= max_activations <= 10000):
|
||||
await message.answer(_(
|
||||
"admin_promo_invalid_max_activations",
|
||||
default="❌ Максимальное количество активаций должно быть от 1 до 10000"
|
||||
"admin_promo_invalid_max_activations"
|
||||
))
|
||||
return
|
||||
|
||||
@@ -181,7 +173,6 @@ async def process_promo_max_activations_handler(message: types.Message,
|
||||
data = await state.get_data()
|
||||
prompt_text = _(
|
||||
"admin_promo_step4_validity",
|
||||
default="🎟 <b>Создание промокода</b>\n\n<b>Шаг 4 из 4:</b> Срок действия\n\nКод: <b>{code}</b>\nБонусные дни: <b>{bonus_days}</b>\nМакс. активаций: <b>{max_activations}</b>\n\nВыберите срок действия промокода:",
|
||||
code=data.get("promo_code"),
|
||||
bonus_days=data.get("bonus_days"),
|
||||
max_activations=max_activations
|
||||
@@ -191,19 +182,19 @@ async def process_promo_max_activations_handler(message: types.Message,
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_("admin_promo_unlimited_validity", default="🔄 Без ограничений"),
|
||||
text=_("admin_promo_unlimited_validity"),
|
||||
callback_data="promo_unlimited_validity"
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_("admin_promo_set_validity_days", default="📅 Указать дни"),
|
||||
text=_("admin_promo_set_validity_days"),
|
||||
callback_data="promo_set_validity"
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_("admin_back_to_panel", default="🔙 В админ панель"),
|
||||
text=_("admin_back_to_panel"),
|
||||
callback_data="admin_action:main"
|
||||
)
|
||||
)
|
||||
@@ -217,8 +208,7 @@ async def process_promo_max_activations_handler(message: types.Message,
|
||||
|
||||
except ValueError:
|
||||
await message.answer(_(
|
||||
"admin_promo_invalid_number",
|
||||
default="❌ Введите корректное число"
|
||||
"admin_promo_invalid_number"
|
||||
))
|
||||
except Exception as e:
|
||||
logging.error(f"Error processing promo max activations: {e}")
|
||||
@@ -252,7 +242,6 @@ async def process_promo_set_validity(callback: types.CallbackQuery,
|
||||
data = await state.get_data()
|
||||
prompt_text = _(
|
||||
"admin_promo_enter_validity_days",
|
||||
default="🎟 <b>Создание промокода</b>\n\n<b>Шаг 4 из 4:</b> Срок действия\n\nКод: <b>{code}</b>\nБонусные дни: <b>{bonus_days}</b>\nМакс. активаций: <b>{max_activations}</b>\n\nВведите количество дней действия промокода (1-365):",
|
||||
code=data.get("promo_code"),
|
||||
bonus_days=data.get("bonus_days"),
|
||||
max_activations=data.get("max_activations")
|
||||
@@ -291,8 +280,7 @@ async def process_promo_validity_days_handler(message: types.Message,
|
||||
validity_days = int(message.text.strip())
|
||||
if not (1 <= validity_days <= 365):
|
||||
await message.answer(_(
|
||||
"admin_promo_invalid_validity_days",
|
||||
default="❌ Срок действия должен быть от 1 до 365 дней"
|
||||
"admin_promo_invalid_validity_days"
|
||||
))
|
||||
return
|
||||
|
||||
@@ -301,8 +289,7 @@ async def process_promo_validity_days_handler(message: types.Message,
|
||||
|
||||
except ValueError:
|
||||
await message.answer(_(
|
||||
"admin_promo_invalid_number",
|
||||
default="❌ Введите корректное число"
|
||||
"admin_promo_invalid_number"
|
||||
))
|
||||
except Exception as e:
|
||||
logging.error(f"Error processing promo validity days: {e}")
|
||||
@@ -349,14 +336,9 @@ async def create_promo_code_final(callback_or_message,
|
||||
logging.info(f"Promo code '{data['promo_code']}' created with ID {created_promo.promo_code_id}")
|
||||
|
||||
# Success message
|
||||
valid_until_str = _("admin_promo_unlimited", default="Без ограничений") if not data.get("validity_days") else f"{data['validity_days']} дней"
|
||||
valid_until_str = _("admin_promo_unlimited") if not data.get("validity_days") else f"{data['validity_days']} дней"
|
||||
success_text = _(
|
||||
"admin_promo_created_success",
|
||||
default="✅ <b>Промокод успешно создан!</b>\n\n"
|
||||
"🎟 Код: <code>{code}</code>\n"
|
||||
"🎁 Бонусные дни: <b>{bonus_days}</b>\n"
|
||||
"📊 Макс. активаций: <b>{max_activations}</b>\n"
|
||||
"⏰ Срок действия: <b>{valid_until_str}</b>",
|
||||
code=data["promo_code"],
|
||||
bonus_days=data["bonus_days"],
|
||||
max_activations=data["max_activations"],
|
||||
@@ -388,7 +370,7 @@ async def create_promo_code_final(callback_or_message,
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error creating promo code: {e}")
|
||||
error_text = _("error_occurred_try_again", default="❌ Произошла ошибка. Попробуйте снова.")
|
||||
error_text = _("error_occurred_try_again")
|
||||
|
||||
if hasattr(callback_or_message, 'message'): # CallbackQuery
|
||||
await callback_or_message.message.answer(error_text)
|
||||
@@ -432,5 +414,5 @@ async def cancel_promo_creation_state_to_menu(callback: types.CallbackQuery,
|
||||
reply_markup=get_admin_panel_keyboard(i18n, current_lang, settings)
|
||||
)
|
||||
|
||||
await callback.answer(_("admin_promo_creation_cancelled", default="Создание промокода отменено"))
|
||||
await state.clear()
|
||||
await callback.answer(_("admin_promo_creation_cancelled"))
|
||||
await state.clear()
|
||||
|
||||
@@ -2,6 +2,7 @@ import logging
|
||||
from aiogram import Router, F, types
|
||||
from typing import Optional, Dict, List
|
||||
from datetime import datetime
|
||||
import html
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config.settings import Settings
|
||||
@@ -10,12 +11,44 @@ from db.dal import user_dal, payment_dal, panel_sync_dal
|
||||
from db.models import Payment, PanelSyncStatus
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
|
||||
from bot.keyboards.inline.admin_keyboards import get_back_to_admin_panel_keyboard
|
||||
from bot.keyboards.inline.admin_keyboards import (
|
||||
get_back_to_admin_panel_keyboard,
|
||||
get_back_to_user_management_keyboard,
|
||||
)
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
|
||||
router = Router(name="admin_statistics_router")
|
||||
|
||||
|
||||
def _format_rating_user_label(user_row: Dict[str, object],
|
||||
bot_username: Optional[str] = None) -> str:
|
||||
user_id = int(user_row.get("user_id", 0) or 0)
|
||||
username = user_row.get("username")
|
||||
first_name = user_row.get("first_name")
|
||||
user_id_text = str(user_id)
|
||||
user_id_html = html.escape(user_id_text)
|
||||
|
||||
if bot_username:
|
||||
safe_bot_username = html.escape(bot_username)
|
||||
user_id_html = (
|
||||
f'<a href="https://t.me/{safe_bot_username}?start=admin_user_{user_id_text}">'
|
||||
f"{user_id_html}</a>"
|
||||
)
|
||||
|
||||
parts: List[str] = []
|
||||
if username:
|
||||
parts.append(f"@{html.escape(str(username))}")
|
||||
elif first_name:
|
||||
parts.append(html.escape(str(first_name)))
|
||||
|
||||
if not parts:
|
||||
parts.append(f"ID {user_id_html}")
|
||||
else:
|
||||
parts.append(f"(ID {user_id_html})")
|
||||
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
async def show_statistics_handler(callback: types.CallbackQuery,
|
||||
i18n_data: dict, settings: Settings,
|
||||
session: AsyncSession):
|
||||
@@ -34,30 +67,30 @@ async def show_statistics_handler(callback: types.CallbackQuery,
|
||||
user_stats = await user_dal.get_enhanced_user_statistics(session)
|
||||
|
||||
stats_text_parts.append(
|
||||
f"\n<b>👥 {_('admin_enhanced_users_stats_header', default='Пользователи')}</b>"
|
||||
f"\n<b>👥 {_('admin_enhanced_users_stats_header')}</b>"
|
||||
)
|
||||
stats_text_parts.append(
|
||||
f"📊 {_('admin_user_stats_total_label', default='Всего')}: <b>{user_stats['total_users']}</b>"
|
||||
f"📊 {_('admin_user_stats_total_label')}: <b>{user_stats['total_users']}</b>"
|
||||
)
|
||||
# Removed: Active today moved to panel stats
|
||||
stats_text_parts.append(
|
||||
f"💳 {_('admin_user_stats_paid_subs_label', default='С платной подпиской')}: <b>{user_stats['paid_subscriptions']}</b>"
|
||||
f"💳 {_('admin_user_stats_paid_subs_label')}: <b>{user_stats['paid_subscriptions']}</b>"
|
||||
)
|
||||
stats_text_parts.append(
|
||||
f"🆓 {_('admin_user_stats_trial_label', default='На пробном периоде')}: <b>{user_stats['trial_users']}</b>"
|
||||
f"🆓 {_('admin_user_stats_trial_label')}: <b>{user_stats['trial_users']}</b>"
|
||||
)
|
||||
stats_text_parts.append(
|
||||
f"😴 {_('admin_user_stats_inactive_label', default='Неактивных')}: <b>{user_stats['inactive_users']}</b>"
|
||||
f"😴 {_('admin_user_stats_inactive_label')}: <b>{user_stats['inactive_users']}</b>"
|
||||
)
|
||||
stats_text_parts.append(
|
||||
f"🚫 {_('admin_user_stats_banned_label', default='Заблокированных')}: <b>{user_stats['banned_users']}</b>"
|
||||
f"🚫 {_('admin_user_stats_banned_label')}: <b>{user_stats['banned_users']}</b>"
|
||||
)
|
||||
stats_text_parts.append(
|
||||
f"🎁 {_('admin_user_stats_referral_label', default='Привлечено по реферальной программе')}: <b>{user_stats['referral_users']}</b>"
|
||||
f"🎁 {_('admin_user_stats_referral_label')}: <b>{user_stats['referral_users']}</b>"
|
||||
)
|
||||
|
||||
# Panel Statistics - moved above financial
|
||||
stats_text_parts.append(f"\n<b>🖥 {_('admin_panel_stats_header', default='Статистика панели')}</b>")
|
||||
stats_text_parts.append(f"\n<b>🖥 {_('admin_panel_stats_header')}</b>")
|
||||
|
||||
try:
|
||||
async with PanelApiService(settings) as panel_service:
|
||||
@@ -80,12 +113,12 @@ async def show_statistics_handler(callback: types.CallbackQuery,
|
||||
total_users = users.get('totalUsers', 0)
|
||||
online_now = online_stats.get('onlineNow', 0)
|
||||
|
||||
stats_text_parts.append(f"🟢 {_('admin_panel_online_label', default='Онлайн')}: <b>{online_now}</b>")
|
||||
stats_text_parts.append(f"📊 {_('admin_panel_active_label', default='Активных')}: <b>{active_users}</b>")
|
||||
stats_text_parts.append(f"🔴 {_('admin_panel_disabled_label', default='Отключенных')}: <b>{disabled_users}</b>")
|
||||
stats_text_parts.append(f"⏰ {_('admin_panel_expired_label', default='Истекшие')}: <b>{expired_users}</b>")
|
||||
stats_text_parts.append(f"⚠️ {_('admin_panel_limited_label', default='Ограниченные')}: <b>{limited_users}</b>")
|
||||
stats_text_parts.append(f"👥 {_('admin_panel_total_users_label', default='Всего пользователей')}: <b>{total_users}</b>")
|
||||
stats_text_parts.append(f"🟢 {_('admin_panel_online_label')}: <b>{online_now}</b>")
|
||||
stats_text_parts.append(f"📊 {_('admin_panel_active_label')}: <b>{active_users}</b>")
|
||||
stats_text_parts.append(f"🔴 {_('admin_panel_disabled_label')}: <b>{disabled_users}</b>")
|
||||
stats_text_parts.append(f"⏰ {_('admin_panel_expired_label')}: <b>{expired_users}</b>")
|
||||
stats_text_parts.append(f"⚠️ {_('admin_panel_limited_label')}: <b>{limited_users}</b>")
|
||||
stats_text_parts.append(f"👥 {_('admin_panel_total_users_label')}: <b>{total_users}</b>")
|
||||
|
||||
# System resources
|
||||
memory = system_stats.get('memory', {})
|
||||
@@ -93,9 +126,9 @@ async def show_statistics_handler(callback: types.CallbackQuery,
|
||||
memory_total = memory.get('total', 1)
|
||||
memory_used = memory.get('used', 0)
|
||||
memory_usage = (memory_used / memory_total) * 100 if memory_total > 0 else 0
|
||||
stats_text_parts.append(f"💾 {_('admin_panel_memory_usage_label', default='Использование RAM')}: <b>{memory_usage:.1f}%</b>")
|
||||
stats_text_parts.append(f"💾 {_('admin_panel_memory_usage_label')}: <b>{memory_usage:.1f}%</b>")
|
||||
else:
|
||||
stats_text_parts.append(f"⚠️ {_('admin_panel_system_stats_error', default='Ошибка получения системной статистики')}")
|
||||
stats_text_parts.append(f"⚠️ {_('admin_panel_system_stats_error')}")
|
||||
|
||||
# Bandwidth stats
|
||||
if bandwidth_stats:
|
||||
@@ -107,13 +140,13 @@ async def show_statistics_handler(callback: types.CallbackQuery,
|
||||
|
||||
if week_traffic:
|
||||
week_total = week_traffic.get('current', '0 B')
|
||||
stats_text_parts.append(f"📊 {_('admin_panel_traffic_week_label', default='Трафик за неделю')}: <b>{week_total}</b>")
|
||||
stats_text_parts.append(f"📊 {_('admin_panel_traffic_week_label')}: <b>{week_total}</b>")
|
||||
|
||||
if month_traffic:
|
||||
month_total = month_traffic.get('current', '0 B')
|
||||
stats_text_parts.append(f"📊 {_('admin_panel_traffic_month_label', default='Трафик за месяц')}: <b>{month_total}</b>")
|
||||
stats_text_parts.append(f"📊 {_('admin_panel_traffic_month_label')}: <b>{month_total}</b>")
|
||||
else:
|
||||
stats_text_parts.append(f"⚠️ {_('admin_panel_bandwidth_stats_error', default='Ошибка получения статистики трафика')}")
|
||||
stats_text_parts.append(f"⚠️ {_('admin_panel_bandwidth_stats_error')}")
|
||||
|
||||
# Nodes stats
|
||||
if nodes_stats and 'lastSevenDays' in nodes_stats:
|
||||
@@ -124,35 +157,35 @@ async def show_statistics_handler(callback: types.CallbackQuery,
|
||||
unique_nodes.add(node_data.get('nodeName', ''))
|
||||
total_nodes_count = len(unique_nodes)
|
||||
# Assume all nodes are active since we don't have status info
|
||||
stats_text_parts.append(f"🔗 {_('admin_panel_nodes_label', default='Активных нод')}: <b>{total_nodes_count}/{total_nodes_count}</b>")
|
||||
stats_text_parts.append(f"🔗 {_('admin_panel_nodes_label')}: <b>{total_nodes_count}/{total_nodes_count}</b>")
|
||||
else:
|
||||
# Use nodes total from system stats as fallback
|
||||
nodes_info = system_stats.get('nodes', {}) if system_stats else {}
|
||||
total_online = nodes_info.get('totalOnline', 0)
|
||||
stats_text_parts.append(f"🔗 {_('admin_panel_nodes_label', default='Активных нод')}: <b>{total_online}</b>")
|
||||
stats_text_parts.append(f"🔗 {_('admin_panel_nodes_label')}: <b>{total_online}</b>")
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to fetch panel statistics: {e}", exc_info=True)
|
||||
stats_text_parts.append(f"❌ {_('admin_panel_stats_fetch_error', default='Ошибка получения данных с панели')}")
|
||||
stats_text_parts.append(f"⚠️ {_('admin_panel_stats_error_details', default='Детали')}: {str(e)}")
|
||||
stats_text_parts.append(f"❌ {_('admin_panel_stats_fetch_error')}")
|
||||
stats_text_parts.append(f"⚠️ {_('admin_panel_stats_error_details')}: {str(e)}")
|
||||
|
||||
# Financial statistics
|
||||
financial_stats = await payment_dal.get_financial_statistics(session)
|
||||
|
||||
stats_text_parts.append(
|
||||
f"\n<b>💰 {_('admin_financial_stats_header', default='Финансовая статистика')}</b>"
|
||||
f"\n<b>💰 {_('admin_financial_stats_header')}</b>"
|
||||
)
|
||||
stats_text_parts.append(
|
||||
f"📅 {_('admin_financial_today_label', default='За сегодня')}: <b>{financial_stats['today_revenue']:.2f} RUB</b> ({financial_stats['today_payments_count']} {_('admin_financial_payments_label', default='платежей')})"
|
||||
f"📅 {_('admin_financial_today_label')}: <b>{financial_stats['today_revenue']:.2f} RUB</b> ({financial_stats['today_payments_count']} {_('admin_financial_payments_label')})"
|
||||
)
|
||||
stats_text_parts.append(
|
||||
f"📅 {_('admin_financial_week_label', default='За неделю')}: <b>{financial_stats['week_revenue']:.2f} RUB</b>"
|
||||
f"📅 {_('admin_financial_week_label')}: <b>{financial_stats['week_revenue']:.2f} RUB</b>"
|
||||
)
|
||||
stats_text_parts.append(
|
||||
f"📅 {_('admin_financial_month_label', default='За месяц')}: <b>{financial_stats['month_revenue']:.2f} RUB</b>"
|
||||
f"📅 {_('admin_financial_month_label')}: <b>{financial_stats['month_revenue']:.2f} RUB</b>"
|
||||
)
|
||||
stats_text_parts.append(
|
||||
f"🏆 {_('admin_financial_all_time_label', default='За все время')}: <b>{financial_stats['all_time_revenue']:.2f} RUB</b>"
|
||||
f"🏆 {_('admin_financial_all_time_label')}: <b>{financial_stats['all_time_revenue']:.2f} RUB</b>"
|
||||
)
|
||||
|
||||
last_payments_models: List[
|
||||
@@ -162,8 +195,21 @@ async def show_statistics_handler(callback: types.CallbackQuery,
|
||||
stats_text_parts.append(
|
||||
f"\n<b>{_('admin_stats_recent_payments_header')}</b>")
|
||||
for payment in last_payments_models:
|
||||
status_emoji = "✅" if payment.status == 'succeeded' else (
|
||||
"⏳" if payment.status in ['pending', 'pending_yookassa', 'pending_freekassa'] else "❌")
|
||||
pending_statuses = [
|
||||
"pending",
|
||||
"pending_yookassa",
|
||||
"pending_freekassa",
|
||||
"pending_platega",
|
||||
"pending_severpay",
|
||||
"pending_cryptopay",
|
||||
]
|
||||
status_emoji = (
|
||||
"✅"
|
||||
if payment.status == "succeeded"
|
||||
else "⏳"
|
||||
if payment.status in pending_statuses
|
||||
else "❌"
|
||||
)
|
||||
|
||||
user_info = f"User {payment.user_id}"
|
||||
if payment.user and payment.user.username:
|
||||
@@ -242,3 +288,104 @@ async def show_statistics_handler(callback: types.CallbackQuery,
|
||||
reply_markup=get_back_to_admin_panel_keyboard(
|
||||
current_lang, i18n))
|
||||
break
|
||||
|
||||
|
||||
async def show_user_ratings_handler(
|
||||
callback: types.CallbackQuery,
|
||||
i18n_data: dict,
|
||||
settings: Settings,
|
||||
session: AsyncSession,
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n or not callback.message:
|
||||
await callback.answer("Error displaying ratings.", show_alert=True)
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
top_limit = 10
|
||||
bot_username: Optional[str] = None
|
||||
try:
|
||||
me = await callback.bot.get_me()
|
||||
bot_username = me.username
|
||||
except Exception as e_get_me:
|
||||
logging.warning("Failed to resolve bot username for ratings links: %s",
|
||||
e_get_me)
|
||||
|
||||
traffic_top = await user_dal.get_top_users_by_traffic_used(session, limit=top_limit)
|
||||
lifetime_traffic_top = await user_dal.get_top_users_by_lifetime_traffic_used(
|
||||
session, limit=top_limit
|
||||
)
|
||||
invited_top = await user_dal.get_top_users_by_referrals_count(session, limit=top_limit)
|
||||
revenue_top = await user_dal.get_top_users_by_referral_revenue(session, limit=top_limit)
|
||||
|
||||
text_parts: List[str] = [
|
||||
_("admin_user_ratings_header", top_limit=top_limit),
|
||||
"",
|
||||
f"<b>{_('admin_user_ratings_traffic_month_title')}</b>",
|
||||
]
|
||||
|
||||
if traffic_top:
|
||||
for idx, row in enumerate(traffic_top, start=1):
|
||||
traffic_gb = float(row.get("traffic_used_bytes") or 0) / (1024**3)
|
||||
text_parts.append(
|
||||
_(
|
||||
"admin_user_ratings_traffic_item",
|
||||
rank=idx,
|
||||
user=_format_rating_user_label(row, bot_username),
|
||||
traffic_gb=f"{traffic_gb:.2f}",
|
||||
)
|
||||
)
|
||||
else:
|
||||
text_parts.append(_("admin_user_ratings_empty"))
|
||||
|
||||
text_parts.extend(["", f"<b>{_('admin_user_ratings_traffic_lifetime_title')}</b>"])
|
||||
if lifetime_traffic_top:
|
||||
for idx, row in enumerate(lifetime_traffic_top, start=1):
|
||||
traffic_gb = float(row.get("lifetime_used_traffic_bytes") or 0) / (1024**3)
|
||||
text_parts.append(
|
||||
_(
|
||||
"admin_user_ratings_traffic_item",
|
||||
rank=idx,
|
||||
user=_format_rating_user_label(row, bot_username),
|
||||
traffic_gb=f"{traffic_gb:.2f}",
|
||||
)
|
||||
)
|
||||
else:
|
||||
text_parts.append(_("admin_user_ratings_empty"))
|
||||
|
||||
text_parts.extend(["", f"<b>{_('admin_user_ratings_invited_title')}</b>"])
|
||||
if invited_top:
|
||||
for idx, row in enumerate(invited_top, start=1):
|
||||
text_parts.append(
|
||||
_(
|
||||
"admin_user_ratings_invited_item",
|
||||
rank=idx,
|
||||
user=_format_rating_user_label(row, bot_username),
|
||||
invited_count=int(row.get("invited_count") or 0),
|
||||
)
|
||||
)
|
||||
else:
|
||||
text_parts.append(_("admin_user_ratings_empty"))
|
||||
|
||||
text_parts.extend(["", f"<b>{_('admin_user_ratings_revenue_title')}</b>"])
|
||||
if revenue_top:
|
||||
for idx, row in enumerate(revenue_top, start=1):
|
||||
text_parts.append(
|
||||
_(
|
||||
"admin_user_ratings_revenue_item",
|
||||
rank=idx,
|
||||
user=_format_rating_user_label(row, bot_username),
|
||||
revenue=f"{float(row.get('referral_revenue') or 0):.2f}",
|
||||
)
|
||||
)
|
||||
else:
|
||||
text_parts.append(_("admin_user_ratings_empty"))
|
||||
|
||||
await callback.message.edit_text(
|
||||
"\n".join(text_parts),
|
||||
reply_markup=get_back_to_user_management_keyboard(current_lang, i18n),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@ from aiogram import Router, types, Bot
|
||||
from aiogram.filters import Command
|
||||
from typing import Optional, Union
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import update, or_
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from config.settings import Settings
|
||||
@@ -10,14 +11,37 @@ from bot.services.panel_api_service import PanelApiService
|
||||
from bot.services.notification_service import NotificationService
|
||||
|
||||
from db.dal import user_dal, subscription_dal, panel_sync_dal
|
||||
from db.models import Subscription
|
||||
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
|
||||
router = Router(name="admin_sync_router")
|
||||
|
||||
|
||||
async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
||||
settings: Settings, i18n_instance: JsonI18n) -> dict:
|
||||
def _extract_lifetime_used_traffic_bytes(panel_user_data: dict) -> Optional[int]:
|
||||
user_traffic = panel_user_data.get("userTraffic") or {}
|
||||
raw_value = (
|
||||
user_traffic.get("lifetimeUsedTrafficBytes")
|
||||
if isinstance(user_traffic, dict)
|
||||
else None
|
||||
)
|
||||
if raw_value is None:
|
||||
raw_value = panel_user_data.get("lifetimeUsedTrafficBytes")
|
||||
|
||||
try:
|
||||
if raw_value is None:
|
||||
return None
|
||||
return int(raw_value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
async def perform_sync(
|
||||
panel_service: PanelApiService,
|
||||
session: AsyncSession,
|
||||
settings: Settings,
|
||||
i18n_instance: JsonI18n,
|
||||
) -> dict:
|
||||
"""
|
||||
Perform panel synchronization and return results
|
||||
Returns dict with status, details, and sync statistics
|
||||
@@ -27,7 +51,7 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
||||
users_updated = 0
|
||||
subscriptions_synced_count = 0
|
||||
sync_errors = []
|
||||
|
||||
|
||||
# Additional counters for detailed logging
|
||||
users_without_telegram_id = 0
|
||||
users_not_found_in_db = 0
|
||||
@@ -52,7 +76,12 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
||||
session, "success", status_msg, 0, 0
|
||||
)
|
||||
await session.commit()
|
||||
return {"status": "success", "details": status_msg, "users_synced": 0, "subs_synced": 0}
|
||||
return {
|
||||
"status": "success",
|
||||
"details": status_msg,
|
||||
"users_synced": 0,
|
||||
"subs_synced": 0,
|
||||
}
|
||||
|
||||
total_panel_users = len(panel_users_data)
|
||||
logging.info(f"Starting sync for {total_panel_users} panel users.")
|
||||
@@ -61,12 +90,16 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
||||
try:
|
||||
panel_records_checked += 1
|
||||
panel_uuid = panel_user_dict.get("uuid")
|
||||
panel_subscription_uuid = panel_user_dict.get("subscriptionUuid") or panel_user_dict.get("shortUuid")
|
||||
panel_subscription_uuid = panel_user_dict.get("subscriptionUuid") or panel_user_dict.get(
|
||||
"shortUuid"
|
||||
)
|
||||
telegram_id_from_panel = panel_user_dict.get("telegramId")
|
||||
|
||||
if not panel_uuid:
|
||||
sync_errors.append(f"Panel user missing UUID: {panel_user_dict}")
|
||||
logging.warning(f"Skipping panel user without UUID: {panel_user_dict}")
|
||||
logging.warning(
|
||||
f"Skipping panel user without UUID: {panel_user_dict}"
|
||||
)
|
||||
continue
|
||||
|
||||
# Track users without telegram ID
|
||||
@@ -75,22 +108,35 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
||||
|
||||
# Try to find existing user in local DB
|
||||
existing_user = None
|
||||
|
||||
|
||||
# First, try to find by telegram ID if available
|
||||
if telegram_id_from_panel:
|
||||
existing_user = await user_dal.get_user_by_id(session, telegram_id_from_panel)
|
||||
existing_user = await user_dal.get_user_by_id(
|
||||
session, telegram_id_from_panel
|
||||
)
|
||||
if existing_user:
|
||||
logging.debug(f"Found user by telegramId {telegram_id_from_panel}")
|
||||
|
||||
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)
|
||||
existing_user = await user_dal.get_user_by_panel_uuid(
|
||||
session, panel_uuid
|
||||
)
|
||||
if existing_user:
|
||||
logging.info(f"Found user by panel UUID {panel_uuid}, telegramId: {existing_user.user_id}")
|
||||
logging.info(
|
||||
f"Found user by panel UUID {panel_uuid}, telegramId: {existing_user.user_id}"
|
||||
)
|
||||
# Update telegram ID if it was missing in panel data but we have local user
|
||||
if telegram_id_from_panel and existing_user.user_id != telegram_id_from_panel:
|
||||
logging.warning(f"TelegramId mismatch: panel={telegram_id_from_panel}, local={existing_user.user_id}")
|
||||
|
||||
if (
|
||||
telegram_id_from_panel
|
||||
and existing_user.user_id != telegram_id_from_panel
|
||||
):
|
||||
logging.warning(
|
||||
f"TelegramId mismatch: panel={telegram_id_from_panel}, local={existing_user.user_id}"
|
||||
)
|
||||
|
||||
if not existing_user:
|
||||
users_not_found_in_db += 1
|
||||
if telegram_id_from_panel:
|
||||
@@ -100,26 +146,36 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
||||
"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
|
||||
"last_name": None, # Panel doesn't provide this info
|
||||
"language_code": "ru", # Default language
|
||||
"panel_user_uuid": panel_uuid,
|
||||
"is_banned": False,
|
||||
"referred_by_id": None
|
||||
"referred_by_id": None,
|
||||
}
|
||||
|
||||
new_user, was_created = await user_dal.create_user(session, user_data)
|
||||
|
||||
new_user, was_created = await user_dal.create_user(
|
||||
session, user_data
|
||||
)
|
||||
if was_created:
|
||||
users_created += 1
|
||||
logging.info(f"Created new user {telegram_id_from_panel} from panel sync with UUID {panel_uuid}")
|
||||
|
||||
logging.info(
|
||||
f"Created new user {telegram_id_from_panel} from panel sync with UUID {panel_uuid}"
|
||||
)
|
||||
|
||||
existing_user = new_user
|
||||
|
||||
|
||||
except Exception as e_create:
|
||||
sync_errors.append(f"Error creating user {telegram_id_from_panel}: {str(e_create)}")
|
||||
logging.error(f"Error creating user {telegram_id_from_panel}: {e_create}")
|
||||
sync_errors.append(
|
||||
f"Error creating user {telegram_id_from_panel}: {str(e_create)}"
|
||||
)
|
||||
logging.error(
|
||||
f"Error creating user {telegram_id_from_panel}: {e_create}"
|
||||
)
|
||||
continue
|
||||
else:
|
||||
logging.debug(f"Panel user with UUID {panel_uuid} (no telegramId) not found in local DB - skipping")
|
||||
logging.debug(
|
||||
f"Panel user with UUID {panel_uuid} (no telegramId) not found in local DB - skipping"
|
||||
)
|
||||
continue
|
||||
|
||||
# User found in local DB
|
||||
@@ -134,20 +190,37 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
||||
existing_user.panel_user_uuid = panel_uuid
|
||||
user_was_updated = True
|
||||
users_uuid_updated += 1
|
||||
logging.info(f"Updated panel UUID for user {actual_user_id}: {panel_uuid}")
|
||||
logging.info(
|
||||
f"Updated panel UUID for user {actual_user_id}: {panel_uuid}"
|
||||
)
|
||||
|
||||
lifetime_used = _extract_lifetime_used_traffic_bytes(panel_user_dict)
|
||||
if (
|
||||
lifetime_used is not None
|
||||
and existing_user.lifetime_used_traffic_bytes != lifetime_used
|
||||
):
|
||||
existing_user.lifetime_used_traffic_bytes = lifetime_used
|
||||
user_was_updated = True
|
||||
|
||||
# Ensure panel description contains Telegram fields
|
||||
try:
|
||||
if panel_uuid and existing_user:
|
||||
description_text = "\n".join([
|
||||
existing_user.username or "",
|
||||
existing_user.first_name or "",
|
||||
existing_user.last_name or "",
|
||||
])
|
||||
description_text = "\n".join(
|
||||
[
|
||||
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()
|
||||
current_panel_description = (
|
||||
panel_user_dict.get("description") or ""
|
||||
).strip()
|
||||
desired_description = description_text.strip()
|
||||
if desired_description and desired_description != current_panel_description:
|
||||
if (
|
||||
desired_description
|
||||
and desired_description != current_panel_description
|
||||
):
|
||||
await panel_service.update_user_details_on_panel(
|
||||
panel_uuid, {"description": description_text}
|
||||
)
|
||||
@@ -159,13 +232,13 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
||||
# Sync subscription data
|
||||
panel_expire_at_iso = panel_user_dict.get("expireAt")
|
||||
panel_status = panel_user_dict.get("status", "UNKNOWN")
|
||||
|
||||
|
||||
if panel_expire_at_iso:
|
||||
try:
|
||||
panel_expire_at = datetime.fromisoformat(
|
||||
panel_expire_at_iso.replace("Z", "+00:00")
|
||||
)
|
||||
|
||||
|
||||
# Prefer syncing by concrete subscription UUID (shortUuid/subscriptionUuid)
|
||||
subscription_uuid_from_panel = (
|
||||
panel_user_dict.get("subscriptionUuid")
|
||||
@@ -173,6 +246,27 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
||||
)
|
||||
|
||||
if subscription_uuid_from_panel:
|
||||
# Если панель говорит, что подписка ACTIVE — сначала деактивируем все другие активные
|
||||
if panel_status == "ACTIVE":
|
||||
await session.execute(
|
||||
update(Subscription)
|
||||
.where(
|
||||
Subscription.panel_user_uuid == panel_uuid,
|
||||
Subscription.is_active.is_(True),
|
||||
or_(
|
||||
Subscription.panel_subscription_uuid
|
||||
!= subscription_uuid_from_panel,
|
||||
Subscription.panel_subscription_uuid.is_(
|
||||
None
|
||||
),
|
||||
),
|
||||
)
|
||||
.values(
|
||||
is_active=False,
|
||||
status_from_panel="INACTIVE",
|
||||
)
|
||||
)
|
||||
|
||||
# Try to find subscription by its panel_subscription_uuid first (idempotent)
|
||||
existing_sub_by_uuid = (
|
||||
await subscription_dal.get_subscription_by_panel_subscription_uuid(
|
||||
@@ -197,7 +291,8 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
||||
subscriptions_updated += 1
|
||||
user_was_updated = True
|
||||
logging.info(
|
||||
f"Synced existing subscription {existing_sub_by_uuid.subscription_id} for user {actual_user_id}: expires {panel_expire_at}, status {panel_status}"
|
||||
f"Synced existing subscription {existing_sub_by_uuid.subscription_id} "
|
||||
f"for user {actual_user_id}: expires {panel_expire_at}, status {panel_status}"
|
||||
)
|
||||
else:
|
||||
# Create a new subscription only when we have a concrete subscription UUID
|
||||
@@ -212,6 +307,7 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
||||
"is_active": panel_status == "ACTIVE",
|
||||
"status_from_panel": panel_status,
|
||||
"traffic_limit_bytes": settings.user_traffic_limit_bytes,
|
||||
"auto_renew_enabled": False,
|
||||
}
|
||||
created_sub = await subscription_dal.upsert_subscription(
|
||||
session, sub_payload
|
||||
@@ -220,12 +316,15 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
||||
subscriptions_created += 1
|
||||
user_was_updated = True
|
||||
logging.info(
|
||||
f"Created subscription {created_sub.subscription_id} for user {actual_user_id} by panel_sub_uuid {subscription_uuid_from_panel}"
|
||||
f"Created subscription {created_sub.subscription_id} "
|
||||
f"for user {actual_user_id} by panel_sub_uuid {subscription_uuid_from_panel}"
|
||||
)
|
||||
else:
|
||||
# No subscription UUID from panel: only update an already active subscription for this user/panel UUID
|
||||
active_sub = await subscription_dal.get_active_subscription_by_user_id(
|
||||
session, actual_user_id, panel_uuid
|
||||
active_sub = (
|
||||
await subscription_dal.get_active_subscription_by_user_id(
|
||||
session, actual_user_id, panel_uuid
|
||||
)
|
||||
)
|
||||
if active_sub:
|
||||
await subscription_dal.update_subscription(
|
||||
@@ -241,23 +340,30 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
||||
subscriptions_updated += 1
|
||||
user_was_updated = True
|
||||
logging.info(
|
||||
f"Updated active subscription {active_sub.subscription_id} for user {actual_user_id}: expires {panel_expire_at}, status {panel_status}"
|
||||
f"Updated active subscription {active_sub.subscription_id} "
|
||||
f"for user {actual_user_id}: expires {panel_expire_at}, status {panel_status}"
|
||||
)
|
||||
else:
|
||||
# Without a concrete subscription UUID we avoid creating new records to keep sync idempotent
|
||||
logging.debug(
|
||||
f"No subscriptionUuid for panel user {panel_uuid}; skipped creation for user {actual_user_id}"
|
||||
)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
sync_errors.append(f"Error syncing subscription for user {actual_user_id}: {str(e)}")
|
||||
logging.error(f"Error syncing subscription for user {actual_user_id}: {e}")
|
||||
sync_errors.append(
|
||||
f"Error syncing subscription for user {actual_user_id}: {str(e)}"
|
||||
)
|
||||
logging.error(
|
||||
f"Error syncing subscription for user {actual_user_id}: {e}"
|
||||
)
|
||||
|
||||
if user_was_updated:
|
||||
users_updated += 1
|
||||
|
||||
|
||||
except Exception as e_user:
|
||||
sync_errors.append(f"Error processing panel user {panel_user_dict.get('uuid', 'unknown')}: {str(e_user)}")
|
||||
sync_errors.append(
|
||||
f"Error processing panel user {panel_user_dict.get('uuid', 'unknown')}: {str(e_user)}"
|
||||
)
|
||||
logging.error(f"Error syncing user: {e_user}")
|
||||
|
||||
# Update sync status
|
||||
@@ -266,14 +372,26 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
||||
default_lang = settings.DEFAULT_LANGUAGE
|
||||
additional_stats = ""
|
||||
if users_without_telegram_id > 0:
|
||||
additional_stats += i18n_instance.gettext(default_lang, "admin_sync_no_telegram_id", count=users_without_telegram_id)
|
||||
additional_stats += i18n_instance.gettext(
|
||||
default_lang,
|
||||
"admin_sync_no_telegram_id",
|
||||
count=users_without_telegram_id,
|
||||
)
|
||||
if users_not_found_in_db > 0:
|
||||
additional_stats += i18n_instance.gettext(default_lang, "admin_sync_not_found_in_db", count=users_not_found_in_db)
|
||||
additional_stats += i18n_instance.gettext(
|
||||
default_lang,
|
||||
"admin_sync_not_found_in_db",
|
||||
count=users_not_found_in_db,
|
||||
)
|
||||
if sync_errors:
|
||||
additional_stats += i18n_instance.gettext(default_lang, "admin_sync_errors", count=len(sync_errors))
|
||||
additional_stats += i18n_instance.gettext(
|
||||
default_lang, "admin_sync_errors", count=len(sync_errors)
|
||||
)
|
||||
|
||||
# Build full details using localization
|
||||
details = i18n_instance.gettext(default_lang, "admin_sync_details",
|
||||
details = i18n_instance.gettext(
|
||||
default_lang,
|
||||
"admin_sync_details",
|
||||
panel_records_checked=panel_records_checked,
|
||||
users_found_in_db=users_found_in_db,
|
||||
users_created=users_created,
|
||||
@@ -281,11 +399,15 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
||||
subscriptions_synced_count=subscriptions_synced_count,
|
||||
subscriptions_created=subscriptions_created,
|
||||
subscriptions_updated=subscriptions_updated,
|
||||
additional_stats=additional_stats
|
||||
additional_stats=additional_stats,
|
||||
)
|
||||
|
||||
await panel_sync_dal.update_panel_sync_status(
|
||||
session, status, details, panel_records_checked, subscriptions_synced_count
|
||||
session,
|
||||
status,
|
||||
details,
|
||||
panel_records_checked,
|
||||
subscriptions_synced_count,
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
@@ -310,19 +432,27 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
||||
"users_synced": users_found_in_db,
|
||||
"users_created": users_created,
|
||||
"subs_synced": subscriptions_synced_count,
|
||||
"errors": sync_errors
|
||||
"errors": sync_errors,
|
||||
}
|
||||
|
||||
except Exception as e_sync_global:
|
||||
await session.rollback()
|
||||
logging.error(f"Global error during sync: {e_sync_global}", exc_info=True)
|
||||
error_detail = f"Unexpected error during sync: {str(e_sync_global)}"
|
||||
|
||||
|
||||
await panel_sync_dal.update_panel_sync_status(
|
||||
session, "failed", error_detail, panel_records_checked, subscriptions_synced_count
|
||||
session,
|
||||
"failed",
|
||||
error_detail,
|
||||
panel_records_checked,
|
||||
subscriptions_synced_count,
|
||||
)
|
||||
|
||||
return {"status": "failed", "details": error_detail, "errors": [str(e_sync_global)]}
|
||||
|
||||
return {
|
||||
"status": "failed",
|
||||
"details": error_detail,
|
||||
"errors": [str(e_sync_global)],
|
||||
}
|
||||
|
||||
|
||||
@router.message(Command("sync"))
|
||||
@@ -365,34 +495,40 @@ async def sync_command_handler(
|
||||
# Use the extracted perform_sync function
|
||||
try:
|
||||
sync_result = await perform_sync(panel_service, session, settings, i18n)
|
||||
|
||||
|
||||
status = sync_result.get("status")
|
||||
details = sync_result.get("details", "No details available")
|
||||
errors = sync_result.get("errors", [])
|
||||
|
||||
|
||||
# Simple confirmation message to admin
|
||||
if status == "failed":
|
||||
await bot.send_message(target_chat_id, _("sync_failed_simple"))
|
||||
elif status == "completed_with_errors":
|
||||
await bot.send_message(target_chat_id, _("sync_errors_simple", errors_count=len(errors)))
|
||||
await bot.send_message(
|
||||
target_chat_id,
|
||||
_("sync_errors_simple", errors_count=len(errors)),
|
||||
)
|
||||
else:
|
||||
await bot.send_message(target_chat_id, _("sync_success_simple"))
|
||||
|
||||
|
||||
# Send notification to log channel with proper thread handling
|
||||
try:
|
||||
notification_service = NotificationService(bot, settings, i18n)
|
||||
await notification_service.notify_panel_sync(
|
||||
status, details,
|
||||
status,
|
||||
details,
|
||||
sync_result.get("users_processed", 0),
|
||||
sync_result.get("subs_synced", 0)
|
||||
sync_result.get("subs_synced", 0),
|
||||
)
|
||||
except Exception as e_notification:
|
||||
logging.error(f"Failed to send sync notification: {e_notification}")
|
||||
|
||||
|
||||
except Exception as e_sync_global:
|
||||
logging.error(f"Global error during /sync command: {e_sync_global}", exc_info=True)
|
||||
logging.error(
|
||||
f"Global error during /sync command: {e_sync_global}", exc_info=True
|
||||
)
|
||||
await bot.send_message(target_chat_id, _("sync_critical_error"))
|
||||
|
||||
|
||||
# Send notification to log channel about failure
|
||||
try:
|
||||
notification_service = NotificationService(bot, settings, i18n)
|
||||
@@ -400,7 +536,9 @@ async def sync_command_handler(
|
||||
"failed", str(e_sync_global), 0, 0
|
||||
)
|
||||
except Exception as e_notification:
|
||||
logging.error(f"Failed to send sync failure notification: {e_notification}")
|
||||
logging.error(
|
||||
f"Failed to send sync failure notification: {e_notification}"
|
||||
)
|
||||
|
||||
|
||||
@router.message(Command("syncstatus"))
|
||||
@@ -419,7 +557,9 @@ async def sync_status_command_handler(
|
||||
if status_record_model:
|
||||
last_time_val = status_record_model.last_sync_time
|
||||
last_time_str = (
|
||||
last_time_val.strftime("%Y-%m-%d %H:%M:%S UTC") if last_time_val else "N/A"
|
||||
last_time_val.strftime("%Y-%m-%d %H:%M:%S UTC")
|
||||
if last_time_val
|
||||
else "N/A"
|
||||
)
|
||||
|
||||
details_val = status_record_model.details
|
||||
@@ -436,4 +576,4 @@ async def sync_status_command_handler(
|
||||
else:
|
||||
response_text = _("admin_sync_status_never_run")
|
||||
|
||||
await message.answer(response_text, parse_mode="HTML")
|
||||
await message.answer(response_text, parse_mode="HTML")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+32
-51
@@ -38,7 +38,13 @@ async def inline_query_handler(inline_query: InlineQuery,
|
||||
# For all users: referral functionality
|
||||
if not query or "реф" in query or "ref" in query or "друг" in query or "friend" in query:
|
||||
referral_result = await create_referral_result(
|
||||
inline_query, bot, referral_service, i18n, current_lang, settings
|
||||
inline_query,
|
||||
bot,
|
||||
referral_service,
|
||||
i18n,
|
||||
current_lang,
|
||||
settings,
|
||||
session,
|
||||
)
|
||||
if referral_result:
|
||||
results.append(referral_result)
|
||||
@@ -67,9 +73,15 @@ async def inline_query_handler(inline_query: InlineQuery,
|
||||
await inline_query.answer(results=[], cache_time=10)
|
||||
|
||||
|
||||
async def create_referral_result(inline_query: InlineQuery, bot: Bot,
|
||||
referral_service: ReferralService,
|
||||
i18n_instance, lang: str, settings: Settings) -> Optional[InlineQueryResultArticle]:
|
||||
async def create_referral_result(
|
||||
inline_query: InlineQuery,
|
||||
bot: Bot,
|
||||
referral_service: ReferralService,
|
||||
i18n_instance,
|
||||
lang: str,
|
||||
settings: Settings,
|
||||
session: AsyncSession,
|
||||
) -> Optional[InlineQueryResultArticle]:
|
||||
"""Create referral link result for inline query"""
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
|
||||
@@ -80,25 +92,27 @@ async def create_referral_result(inline_query: InlineQuery, bot: Bot,
|
||||
return None
|
||||
|
||||
user_id = inline_query.from_user.id
|
||||
referral_link = referral_service.generate_referral_link(bot_username, user_id)
|
||||
referral_link = await referral_service.generate_referral_link(
|
||||
session, bot_username, user_id
|
||||
)
|
||||
|
||||
if not referral_link:
|
||||
logging.warning("Could not produce referral link for inline user %s", user_id)
|
||||
return None
|
||||
|
||||
# Create message content (use same text as friend message)
|
||||
message_text = _(
|
||||
"referral_friend_message",
|
||||
default="🚀 Привет! Попробуй этот VPN - быстрый, надёжный и доступный!\n\n"
|
||||
"🎁 По моей ссылке тебе дадут бонусные дни к подписке!\n\n{referral_link}",
|
||||
referral_link=referral_link
|
||||
)
|
||||
|
||||
return InlineQueryResultArticle(
|
||||
id="referral_link",
|
||||
title=_(
|
||||
"inline_referral_title",
|
||||
default="🎁 Пригласить друга"
|
||||
"inline_referral_title"
|
||||
),
|
||||
description=_(
|
||||
"inline_referral_description",
|
||||
default="Поделиться реферальной ссылкой для получения бонусов"
|
||||
"inline_referral_description"
|
||||
),
|
||||
input_message_content=InputTextMessageContent(
|
||||
message_text=message_text,
|
||||
@@ -149,13 +163,6 @@ async def create_user_stats_result(session: AsyncSession, i18n_instance, lang: s
|
||||
|
||||
stats_text = _(
|
||||
"inline_user_stats_message",
|
||||
default="📊 <b>Статистика Бота</b>\n👥 Пользователи\n\n"
|
||||
"📊 Всего: <b>{total}</b>\n"
|
||||
"💳 С платной подпиской: <b>{paid}</b>\n"
|
||||
"🆓 На пробном периоде: <b>{trial}</b>\n"
|
||||
"😴 Неактивных: <b>{inactive}</b>\n"
|
||||
"🚫 Заблокированных: <b>{banned}</b>\n"
|
||||
"🎁 Привлечено по реферальной программе: <b>{referral}</b>",
|
||||
total=user_stats['total_users'],
|
||||
active_today=user_stats['active_today'],
|
||||
paid=user_stats['paid_subscriptions'],
|
||||
@@ -168,12 +175,10 @@ async def create_user_stats_result(session: AsyncSession, i18n_instance, lang: s
|
||||
return InlineQueryResultArticle(
|
||||
id="admin_user_stats",
|
||||
title=_(
|
||||
"inline_admin_user_stats_title",
|
||||
default="📊 Статистика пользователей"
|
||||
"inline_admin_user_stats_title"
|
||||
),
|
||||
description=_(
|
||||
"inline_user_stats_description",
|
||||
default="Всего: {total}, Платных: {active}",
|
||||
total=user_stats['total_users'],
|
||||
active=user_stats['paid_subscriptions']
|
||||
),
|
||||
@@ -199,12 +204,6 @@ async def create_financial_stats_result(session: AsyncSession, i18n_instance, la
|
||||
|
||||
stats_text = _(
|
||||
"inline_financial_stats_message",
|
||||
default="💰 <b>Финансовая статистика</b>\n\n"
|
||||
"📅 За сегодня: <b>{today:.2f} RUB</b>\n"
|
||||
" ({today_count} платежей)\n"
|
||||
"📅 За неделю: <b>{week:.2f} RUB</b>\n"
|
||||
"📅 За месяц: <b>{month:.2f} RUB</b>\n"
|
||||
"🏆 За все время: <b>{all_time:.2f} RUB</b>",
|
||||
today=financial_stats['today_revenue'],
|
||||
today_count=financial_stats['today_payments_count'],
|
||||
week=financial_stats['week_revenue'],
|
||||
@@ -215,12 +214,10 @@ async def create_financial_stats_result(session: AsyncSession, i18n_instance, la
|
||||
return InlineQueryResultArticle(
|
||||
id="admin_financial_stats",
|
||||
title=_(
|
||||
"inline_admin_financial_stats_title",
|
||||
default="💰 Финансовая статистика"
|
||||
"inline_admin_financial_stats_title"
|
||||
),
|
||||
description=_(
|
||||
"inline_financial_description",
|
||||
default="Сегодня: {today} RUB",
|
||||
today=f"{financial_stats['today_revenue']:.2f}"
|
||||
),
|
||||
input_message_content=InputTextMessageContent(
|
||||
@@ -293,17 +290,6 @@ async def create_system_stats_result(session: AsyncSession, i18n_instance, lang:
|
||||
|
||||
stats_text = _(
|
||||
"inline_system_stats_message",
|
||||
default="🖥 <b>Статистика панели</b>\n\n"
|
||||
"🟢 Онлайн: <b>{online}</b>\n"
|
||||
"📊 Активных: <b>{active}</b>\n"
|
||||
"🔴 Отключенных: <b>{disabled}</b>\n"
|
||||
"⏰ Истекшие: <b>{expired}</b>\n"
|
||||
"⚠️ Ограниченные: <b>{limited}</b>\n"
|
||||
"👥 Всего пользователей: <b>{total}</b>\n"
|
||||
"💾 Использование RAM: <b>{memory:.1f}%</b>\n"
|
||||
"📊 Трафик за неделю: <b>{week_traffic}</b>\n"
|
||||
"📊 Трафик за месяц: <b>{month_traffic}</b>\n"
|
||||
"🔗 Активных нод: <b>{active_nodes}/{total_nodes}</b>",
|
||||
online=online_now,
|
||||
active=active_users,
|
||||
disabled=disabled_users,
|
||||
@@ -317,17 +303,15 @@ async def create_system_stats_result(session: AsyncSession, i18n_instance, lang:
|
||||
total_nodes=total_nodes
|
||||
)
|
||||
else:
|
||||
stats_text = _("inline_panel_stats_error", default="❌ Ошибка получения данных с панели")
|
||||
stats_text = _("inline_panel_stats_error")
|
||||
|
||||
return InlineQueryResultArticle(
|
||||
id="admin_system_stats",
|
||||
title=_(
|
||||
"inline_admin_system_stats_title",
|
||||
default="🖥 Системная статистика"
|
||||
"inline_admin_system_stats_title"
|
||||
),
|
||||
description=_(
|
||||
"inline_system_description",
|
||||
default="🟢 Онлайн: {online}, 📊 Активных: {active}",
|
||||
online=online_now,
|
||||
active=active_users
|
||||
),
|
||||
@@ -341,15 +325,14 @@ async def create_system_stats_result(session: AsyncSession, i18n_instance, lang:
|
||||
except Exception as e:
|
||||
logging.error(f"Error creating system stats result: {e}")
|
||||
# Fallback error message
|
||||
error_text = _("inline_panel_stats_error", default="❌ Ошибка получения данных с панели")
|
||||
error_text = _("inline_panel_stats_error")
|
||||
|
||||
return InlineQueryResultArticle(
|
||||
id="admin_system_stats",
|
||||
title=_(
|
||||
"inline_admin_system_stats_title",
|
||||
default="🖥 Системная статистика"
|
||||
"inline_admin_system_stats_title"
|
||||
),
|
||||
description=_("inline_system_error", default="Ошибка получения данных"),
|
||||
description=_("inline_system_error"),
|
||||
input_message_content=InputTextMessageContent(
|
||||
message_text=error_text,
|
||||
parse_mode="HTML"
|
||||
@@ -357,5 +340,3 @@ async def create_system_stats_result(session: AsyncSession, i18n_instance, lang:
|
||||
thumbnail_url=settings.INLINE_SYSTEM_STATS_THUMBNAIL_URL
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
|
||||
+166
-70
@@ -18,11 +18,13 @@ from bot.services.subscription_service import SubscriptionService
|
||||
from bot.services.referral_service import ReferralService
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
from bot.services.yookassa_service import YooKassaService
|
||||
from bot.services.lknpd_service import LknpdService
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from config.settings import Settings
|
||||
from bot.services.notification_service import NotificationService
|
||||
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
|
||||
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
|
||||
from bot.utils.config_link import prepare_config_links
|
||||
|
||||
payment_processing_lock = asyncio.Lock()
|
||||
|
||||
@@ -36,10 +38,13 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
||||
i18n: JsonI18n, settings: Settings,
|
||||
panel_service: PanelApiService,
|
||||
subscription_service: SubscriptionService,
|
||||
referral_service: ReferralService):
|
||||
referral_service: ReferralService,
|
||||
lknpd_service: Optional[LknpdService] = None):
|
||||
metadata = payment_info_from_webhook.get("metadata", {})
|
||||
user_id_str = metadata.get("user_id")
|
||||
subscription_months_str = metadata.get("subscription_months")
|
||||
traffic_gb_str = metadata.get("traffic_gb")
|
||||
sale_mode = metadata.get("sale_mode") or ("traffic" if settings.traffic_sale_mode else "subscription")
|
||||
promo_code_id_str = metadata.get("promo_code_id")
|
||||
payment_db_id_str = metadata.get("payment_db_id")
|
||||
auto_renew_subscription_id_str = metadata.get(
|
||||
@@ -47,8 +52,11 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
||||
|
||||
# For auto-renew payments, payment_db_id may be absent. In that case,
|
||||
# we will create/ensure a payment record idempotently using provider payment id.
|
||||
if (not user_id_str or not subscription_months_str
|
||||
or (not payment_db_id_str and not auto_renew_subscription_id_str)):
|
||||
if (
|
||||
not user_id_str
|
||||
or (not subscription_months_str and not traffic_gb_str)
|
||||
or (not payment_db_id_str and not auto_renew_subscription_id_str)
|
||||
):
|
||||
logging.error(
|
||||
f"Missing crucial metadata for payment: {payment_info_from_webhook.get('id')}, metadata: {metadata}"
|
||||
)
|
||||
@@ -57,54 +65,65 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
||||
db_user = None
|
||||
try:
|
||||
user_id = int(user_id_str)
|
||||
subscription_months = int(subscription_months_str)
|
||||
subscription_months = float(subscription_months_str or 0)
|
||||
traffic_amount_gb = float(traffic_gb_str) if traffic_gb_str else subscription_months
|
||||
payment_db_id = int(
|
||||
payment_db_id_str) if payment_db_id_str and payment_db_id_str.isdigit() else None
|
||||
is_auto_renew = bool(auto_renew_subscription_id_str and not payment_db_id)
|
||||
is_auto_renew = bool(auto_renew_subscription_id_str and not payment_db_id and sale_mode != "traffic")
|
||||
promo_code_id = int(
|
||||
promo_code_id_str
|
||||
) if promo_code_id_str and promo_code_id_str.isdigit() else None
|
||||
|
||||
amount_data = payment_info_from_webhook.get("amount", {})
|
||||
months_for_record = int(subscription_months) if sale_mode != "traffic" else 0
|
||||
payment_value = float(amount_data.get("value", 0.0))
|
||||
yk_payment_id_from_hook = payment_info_from_webhook.get("id")
|
||||
|
||||
payment_record = None
|
||||
# If this is an auto-renewal (no payment_db_id in metadata), ensure a payment record exists
|
||||
if payment_db_id is None and auto_renew_subscription_id_str:
|
||||
try:
|
||||
# Create/ensure provider payment by YooKassa payment id for idempotency
|
||||
yk_payment_id_from_hook = payment_info_from_webhook.get("id")
|
||||
if not yk_payment_id_from_hook:
|
||||
logging.error(
|
||||
"Auto-renew webhook missing YooKassa payment id; cannot ensure payment record."
|
||||
)
|
||||
return
|
||||
from db.dal import payment_dal as _payment_dal
|
||||
ensured_payment = await _payment_dal.ensure_payment_with_provider_id(
|
||||
session,
|
||||
user_id=user_id,
|
||||
amount=payment_value,
|
||||
currency=amount_data.get("currency", settings.DEFAULT_CURRENCY_SYMBOL),
|
||||
months=subscription_months,
|
||||
description=payment_info_from_webhook.get(
|
||||
"description") or f"Auto-renewal for {subscription_months} months",
|
||||
provider="yookassa",
|
||||
provider_payment_id=yk_payment_id_from_hook,
|
||||
payment_record = await _payment_dal.get_payment_by_provider_payment_id(
|
||||
session, yk_payment_id_from_hook
|
||||
)
|
||||
payment_db_id = ensured_payment.payment_id
|
||||
# Also persist yookassa_payment_id field if not set yet
|
||||
try:
|
||||
await _payment_dal.update_payment_status_by_db_id(
|
||||
if not payment_record:
|
||||
payment_record = await _payment_dal.ensure_payment_with_provider_id(
|
||||
session,
|
||||
payment_db_id,
|
||||
payment_info_from_webhook.get("status", "succeeded"),
|
||||
yk_payment_id_from_hook,
|
||||
)
|
||||
except Exception:
|
||||
# Non-fatal; continue processing
|
||||
logging.exception(
|
||||
"Failed to backfill yookassa_payment_id for ensured auto-renew payment"
|
||||
user_id=user_id,
|
||||
amount=payment_value,
|
||||
currency=amount_data.get("currency", settings.DEFAULT_CURRENCY_SYMBOL),
|
||||
months=months_for_record or 1,
|
||||
description=payment_info_from_webhook.get(
|
||||
"description") or f"Auto-renewal for {months_for_record or subscription_months} months",
|
||||
provider="yookassa",
|
||||
provider_payment_id=yk_payment_id_from_hook,
|
||||
)
|
||||
payment_db_id = payment_record.payment_id
|
||||
except Exception as e_ensure:
|
||||
logging.error(
|
||||
f"Failed to ensure payment record for auto-renew webhook (YK {payment_info_from_webhook.get('id')}): {e_ensure}",
|
||||
exc_info=True,
|
||||
)
|
||||
return
|
||||
elif payment_db_id is not None:
|
||||
payment_record = await payment_dal.get_payment_by_db_id(session, payment_db_id)
|
||||
if not payment_record:
|
||||
logging.error(
|
||||
f"Payment record {payment_db_id} not found for YK ID {yk_payment_id_from_hook}."
|
||||
)
|
||||
return
|
||||
|
||||
if payment_record and payment_record.status == "succeeded":
|
||||
logging.info(
|
||||
f"Skipping duplicate YooKassa webhook for payment {payment_db_id} (YK: {yk_payment_id_from_hook})."
|
||||
)
|
||||
return
|
||||
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
if not db_user:
|
||||
@@ -136,10 +155,24 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
||||
|
||||
try:
|
||||
yk_payment_id_from_hook = payment_info_from_webhook.get("id")
|
||||
payment_before_update = None
|
||||
if payment_db_id is not None:
|
||||
payment_before_update = await payment_dal.get_payment_by_db_id(
|
||||
session,
|
||||
payment_db_id,
|
||||
)
|
||||
should_send_lknpd_receipt = bool(
|
||||
lknpd_service
|
||||
and lknpd_service.configured
|
||||
and payment_info_from_webhook.get("paid") is True
|
||||
and payment_info_from_webhook.get("status") == "succeeded"
|
||||
and payment_before_update
|
||||
and payment_before_update.status != "succeeded"
|
||||
)
|
||||
# Try to capture and save payment method for future charges if available
|
||||
try:
|
||||
payment_method = payment_info_from_webhook.get("payment_method")
|
||||
if getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False) and isinstance(payment_method, dict) and payment_method.get("saved", False):
|
||||
if settings.yookassa_autopayments_active and isinstance(payment_method, dict) and payment_method.get("saved", False):
|
||||
pm_id = payment_method.get("id")
|
||||
pm_type = payment_method.get("type")
|
||||
title = payment_method.get("title")
|
||||
@@ -170,8 +203,40 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
||||
card_last4=display_last4,
|
||||
card_network=display_network,
|
||||
)
|
||||
try:
|
||||
await user_billing_dal.upsert_user_payment_method(
|
||||
session,
|
||||
user_id=user_id,
|
||||
provider_payment_method_id=pm_id,
|
||||
provider="yookassa",
|
||||
card_last4=display_last4,
|
||||
card_network=display_network,
|
||||
set_default=True,
|
||||
)
|
||||
except Exception:
|
||||
logging.exception("Failed to persist multi-card YooKassa method from webhook")
|
||||
except Exception:
|
||||
logging.exception("Failed to persist YooKassa payment method from webhook")
|
||||
months_for_activation = int(subscription_months) if sale_mode != "traffic" else 0
|
||||
activation_details = await subscription_service.activate_subscription(
|
||||
session,
|
||||
user_id,
|
||||
months_for_activation,
|
||||
payment_value,
|
||||
payment_db_id,
|
||||
promo_code_id_from_payment=promo_code_id,
|
||||
provider="yookassa",
|
||||
sale_mode=sale_mode,
|
||||
traffic_gb=traffic_amount_gb if sale_mode == "traffic" else None,
|
||||
)
|
||||
|
||||
if not activation_details or not activation_details.get('end_date'):
|
||||
logging.error(
|
||||
f"Failed to activate subscription for user {user_id} after payment {yk_payment_id_from_hook}"
|
||||
)
|
||||
raise Exception(
|
||||
f"Subscription Error: Failed to activate for user {user_id}")
|
||||
|
||||
updated_payment_record = await payment_dal.update_payment_status_by_db_id(
|
||||
session,
|
||||
payment_db_id=payment_db_id,
|
||||
@@ -184,34 +249,20 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
||||
raise Exception(
|
||||
f"DB Error: Could not update payment record {payment_db_id}")
|
||||
|
||||
activation_details = await subscription_service.activate_subscription(
|
||||
session,
|
||||
user_id,
|
||||
subscription_months,
|
||||
payment_value,
|
||||
payment_db_id,
|
||||
promo_code_id_from_payment=promo_code_id,
|
||||
provider="yookassa")
|
||||
|
||||
if not activation_details or not activation_details.get('end_date'):
|
||||
logging.error(
|
||||
f"Failed to activate subscription for user {user_id} after payment {yk_payment_id_from_hook}"
|
||||
)
|
||||
raise Exception(
|
||||
f"Subscription Error: Failed to activate for user {user_id}")
|
||||
|
||||
base_subscription_end_date = activation_details['end_date']
|
||||
final_end_date_for_user = base_subscription_end_date
|
||||
applied_promo_bonus_days = activation_details.get(
|
||||
"applied_promo_bonus_days", 0)
|
||||
|
||||
referral_bonus_info = await referral_service.apply_referral_bonuses_for_payment(
|
||||
session,
|
||||
user_id,
|
||||
subscription_months,
|
||||
current_payment_db_id=payment_db_id,
|
||||
skip_if_active_before_payment=False,
|
||||
)
|
||||
referral_bonus_info = None
|
||||
if sale_mode != "traffic":
|
||||
referral_bonus_info = await referral_service.apply_referral_bonuses_for_payment(
|
||||
session,
|
||||
user_id,
|
||||
months_for_activation or int(subscription_months) or 1,
|
||||
current_payment_db_id=payment_db_id,
|
||||
skip_if_active_before_payment=False,
|
||||
)
|
||||
applied_referee_bonus_days_from_referral: Optional[int] = None
|
||||
if referral_bonus_info and referral_bonus_info.get(
|
||||
"referee_new_end_date"):
|
||||
@@ -224,19 +275,56 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
||||
user_lang = db_user.language_code if db_user and db_user.language_code else settings.DEFAULT_LANGUAGE
|
||||
_ = lambda key, **kwargs: i18n.gettext(user_lang, key, **kwargs)
|
||||
|
||||
traffic_label = (
|
||||
str(int(traffic_amount_gb)) if float(traffic_amount_gb).is_integer() else f"{traffic_amount_gb:g}"
|
||||
)
|
||||
if should_send_lknpd_receipt:
|
||||
receipt_item_name = payment_info_from_webhook.get("description")
|
||||
if not receipt_item_name:
|
||||
if sale_mode == "traffic":
|
||||
receipt_item_name = settings.LKNPD_RECEIPT_NAME_TRAFFIC.format(gb=traffic_label)
|
||||
else:
|
||||
receipt_item_name = settings.LKNPD_RECEIPT_NAME_SUBSCRIPTION.format(months=int(subscription_months))
|
||||
try:
|
||||
await lknpd_service.create_income_receipt(
|
||||
item_name=receipt_item_name,
|
||||
amount=payment_value,
|
||||
quantity=1.0,
|
||||
operation_time=datetime.now(timezone.utc),
|
||||
)
|
||||
except Exception:
|
||||
logging.exception(
|
||||
"Failed to send LKNPD receipt for payment %s",
|
||||
yk_payment_id_from_hook,
|
||||
)
|
||||
config_link_display, connect_button_url = await prepare_config_links(
|
||||
settings, activation_details.get("subscription_url") if activation_details else None
|
||||
)
|
||||
config_link_text = config_link_display or _("config_link_not_available")
|
||||
# For auto-renew charges, avoid re-sending config link; send concise message
|
||||
if is_auto_renew and final_end_date_for_user:
|
||||
if sale_mode != "traffic" and is_auto_renew and final_end_date_for_user:
|
||||
details_message = _(
|
||||
"yookassa_auto_renewal",
|
||||
months=subscription_months,
|
||||
months=int(subscription_months),
|
||||
end_date=final_end_date_for_user.strftime('%Y-%m-%d'),
|
||||
)
|
||||
details_markup = None
|
||||
else:
|
||||
config_link = activation_details.get("subscription_url") or _(
|
||||
"config_link_not_available"
|
||||
elif sale_mode == "traffic":
|
||||
details_message = _(
|
||||
"payment_successful_traffic_full",
|
||||
traffic_gb=traffic_label,
|
||||
end_date=final_end_date_for_user.strftime('%Y-%m-%d') if final_end_date_for_user else "—",
|
||||
config_link=config_link_text,
|
||||
)
|
||||
|
||||
details_markup = get_connect_and_main_keyboard(
|
||||
user_lang,
|
||||
i18n,
|
||||
settings,
|
||||
config_link_display,
|
||||
connect_button_url=connect_button_url,
|
||||
preserve_message=True,
|
||||
)
|
||||
else:
|
||||
if applied_referee_bonus_days_from_referral and final_end_date_for_user:
|
||||
inviter_name_display = _("friend_placeholder")
|
||||
if db_user and db_user.referred_by_id:
|
||||
@@ -251,27 +339,27 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
||||
|
||||
details_message = _(
|
||||
"payment_successful_with_referral_bonus_full",
|
||||
months=subscription_months,
|
||||
months=int(subscription_months),
|
||||
base_end_date=base_subscription_end_date.strftime('%Y-%m-%d'),
|
||||
bonus_days=applied_referee_bonus_days_from_referral,
|
||||
final_end_date=final_end_date_for_user.strftime('%Y-%m-%d'),
|
||||
inviter_name=inviter_name_display,
|
||||
config_link=config_link,
|
||||
config_link=config_link_text,
|
||||
)
|
||||
elif applied_promo_bonus_days > 0 and final_end_date_for_user:
|
||||
details_message = _(
|
||||
"payment_successful_with_promo_full",
|
||||
months=subscription_months,
|
||||
months=int(subscription_months),
|
||||
bonus_days=applied_promo_bonus_days,
|
||||
end_date=final_end_date_for_user.strftime('%Y-%m-%d'),
|
||||
config_link=config_link,
|
||||
config_link=config_link_text,
|
||||
)
|
||||
elif final_end_date_for_user:
|
||||
details_message = _(
|
||||
"payment_successful_full",
|
||||
months=subscription_months,
|
||||
months=int(subscription_months),
|
||||
end_date=final_end_date_for_user.strftime('%Y-%m-%d'),
|
||||
config_link=config_link,
|
||||
config_link=config_link_text,
|
||||
)
|
||||
else:
|
||||
logging.error(
|
||||
@@ -280,7 +368,12 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
||||
details_message = _("payment_successful_error_details")
|
||||
|
||||
details_markup = get_connect_and_main_keyboard(
|
||||
user_lang, i18n, settings, config_link, preserve_message=True
|
||||
user_lang,
|
||||
i18n,
|
||||
settings,
|
||||
config_link_display,
|
||||
connect_button_url=connect_button_url,
|
||||
preserve_message=True,
|
||||
)
|
||||
try:
|
||||
await bot.send_message(
|
||||
@@ -303,9 +396,10 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
||||
user_id=user_id,
|
||||
amount=payment_value,
|
||||
currency=settings.DEFAULT_CURRENCY_SYMBOL,
|
||||
months=subscription_months,
|
||||
months=int(subscription_months) if sale_mode != "traffic" else 0,
|
||||
payment_provider="yookassa", # This is specifically for YooKassa webhook
|
||||
username=user.username if user else None
|
||||
username=user.username if user else None,
|
||||
traffic_gb=traffic_amount_gb if sale_mode == "traffic" else None,
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to send payment notification: {e}")
|
||||
@@ -379,6 +473,7 @@ async def yookassa_webhook_route(request: web.Request):
|
||||
subscription_service: SubscriptionService = request.app[
|
||||
'subscription_service']
|
||||
referral_service: ReferralService = request.app['referral_service']
|
||||
lknpd_service: Optional[LknpdService] = request.app.get('lknpd_service')
|
||||
async_session_factory: sessionmaker = request.app[
|
||||
'async_session_factory']
|
||||
except KeyError as e_app_ctx:
|
||||
@@ -471,7 +566,8 @@ async def yookassa_webhook_route(request: web.Request):
|
||||
await process_successful_payment(
|
||||
session, bot, payment_dict_for_processing,
|
||||
i18n_instance, settings, panel_service,
|
||||
subscription_service, referral_service)
|
||||
subscription_service, referral_service,
|
||||
lknpd_service)
|
||||
await session.commit()
|
||||
else:
|
||||
logging.warning(
|
||||
@@ -487,7 +583,7 @@ async def yookassa_webhook_route(request: web.Request):
|
||||
elif notification_object.event == YOOKASSA_EVENT_PAYMENT_WAITING_FOR_CAPTURE:
|
||||
# Bind-only flow: save method and cancel auth if metadata has bind_only
|
||||
metadata = payment_dict_for_processing.get("metadata", {}) or {}
|
||||
if getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False) and metadata.get("bind_only") == "1":
|
||||
if settings.yookassa_autopayments_active and metadata.get("bind_only") == "1":
|
||||
try:
|
||||
user_id_str = metadata.get("user_id")
|
||||
if user_id_str and user_id_str.isdigit():
|
||||
|
||||
@@ -134,16 +134,21 @@ async def process_promo_code_input(message: types.Message, state: FSMContext,
|
||||
|
||||
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")
|
||||
config_link_display = active.get("config_link") if active else None
|
||||
connect_button_url = active.get("connect_button_url") if active else None
|
||||
config_link_text = config_link_display or _("config_link_not_available")
|
||||
|
||||
response_to_user_text = _(
|
||||
"promo_code_applied_success_full",
|
||||
end_date=(new_end_date.strftime("%d.%m.%Y %H:%M:%S") if new_end_date else "N/A"),
|
||||
config_link=config_link,
|
||||
config_link=config_link_text,
|
||||
)
|
||||
reply_markup = get_connect_and_main_keyboard(
|
||||
current_lang, i18n, settings, config_link
|
||||
current_lang,
|
||||
i18n,
|
||||
settings,
|
||||
config_link_display,
|
||||
connect_button_url=connect_button_url,
|
||||
)
|
||||
else:
|
||||
await session.rollback()
|
||||
|
||||
@@ -60,28 +60,40 @@ async def referral_command_handler(event: Union[types.Message,
|
||||
return
|
||||
|
||||
inviter_user_id = event.from_user.id
|
||||
referral_link = referral_service.generate_referral_link(
|
||||
bot_username, inviter_user_id)
|
||||
referral_link = await referral_service.generate_referral_link(
|
||||
session, bot_username, inviter_user_id)
|
||||
|
||||
if not referral_link:
|
||||
logging.error(
|
||||
"Failed to generate referral link for user %s (probably missing DB record).",
|
||||
inviter_user_id,
|
||||
)
|
||||
await target_message_obj.answer(_("error_generating_referral_link"))
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
await event.answer()
|
||||
return
|
||||
|
||||
bonus_info_parts = []
|
||||
if settings.subscription_options:
|
||||
if getattr(settings, "traffic_sale_mode", False):
|
||||
bonus_details_str = _("referral_not_available_for_traffic")
|
||||
else:
|
||||
if settings.subscription_options:
|
||||
for months_period_key, _price in sorted(
|
||||
settings.subscription_options.items()):
|
||||
|
||||
for months_period_key, _price in sorted(
|
||||
settings.subscription_options.items()):
|
||||
inv_bonus = settings.referral_bonus_inviter.get(months_period_key)
|
||||
ref_bonus = settings.referral_bonus_referee.get(months_period_key)
|
||||
if inv_bonus is not None or ref_bonus is not None:
|
||||
bonus_info_parts.append(
|
||||
_("referral_bonus_per_period",
|
||||
months=months_period_key,
|
||||
inviter_bonus_days=inv_bonus
|
||||
if inv_bonus is not None else _("no_bonus_placeholder"),
|
||||
referee_bonus_days=ref_bonus
|
||||
if ref_bonus is not None else _("no_bonus_placeholder")))
|
||||
|
||||
inv_bonus = settings.referral_bonus_inviter.get(months_period_key)
|
||||
ref_bonus = settings.referral_bonus_referee.get(months_period_key)
|
||||
if inv_bonus is not None or ref_bonus is not None:
|
||||
bonus_info_parts.append(
|
||||
_("referral_bonus_per_period",
|
||||
months=months_period_key,
|
||||
inviter_bonus_days=inv_bonus
|
||||
if inv_bonus is not None else _("no_bonus_placeholder"),
|
||||
referee_bonus_days=ref_bonus
|
||||
if ref_bonus is not None else _("no_bonus_placeholder")))
|
||||
|
||||
bonus_details_str = "\n".join(bonus_info_parts) if bonus_info_parts else _(
|
||||
"referral_no_bonuses_configured")
|
||||
bonus_details_str = "\n".join(bonus_info_parts) if bonus_info_parts else _(
|
||||
"referral_no_bonuses_configured")
|
||||
|
||||
# Get referral statistics
|
||||
referral_stats = await referral_service.get_referral_stats(session, inviter_user_id)
|
||||
@@ -132,7 +144,16 @@ async def referral_action_handler(callback: types.CallbackQuery, settings: Setti
|
||||
return
|
||||
|
||||
inviter_user_id = callback.from_user.id
|
||||
referral_link = referral_service.generate_referral_link(bot_username, inviter_user_id)
|
||||
referral_link = await referral_service.generate_referral_link(
|
||||
session, bot_username, inviter_user_id)
|
||||
|
||||
if not referral_link:
|
||||
logging.error(
|
||||
"Failed to generate referral link for user %s via inline button.",
|
||||
inviter_user_id,
|
||||
)
|
||||
await callback.answer(_("error_generating_referral_link"), show_alert=True)
|
||||
return
|
||||
|
||||
friend_message = _("referral_friend_message", referral_link=referral_link)
|
||||
|
||||
|
||||
+194
-11
@@ -16,6 +16,7 @@ from bot.keyboards.inline.user_keyboards import (
|
||||
get_main_menu_inline_keyboard,
|
||||
get_language_selection_keyboard,
|
||||
get_channel_subscription_keyboard,
|
||||
get_information_links_keyboard,
|
||||
)
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
@@ -302,18 +303,23 @@ async def ensure_required_channel_subscription(
|
||||
|
||||
|
||||
@router.message(CommandStart())
|
||||
@router.message(CommandStart(magic=F.args.regexp(r"^ref_(\d+)$").as_("ref_match")))
|
||||
@router.message(CommandStart(magic=F.args.regexp(r"^ref_((?:[uU][A-Za-z0-9]{9})|(?:[A-Za-z0-9]{9})|\d+)$").as_("ref_match")))
|
||||
@router.message(CommandStart(magic=F.args.regexp(r"^promo_(\w+)$").as_("promo_match")))
|
||||
@router.message(CommandStart(magic=F.args.regexp(r"^(?!ref_|promo_)([A-Za-z0-9_\-]{2,64})$").as_("ad_param_match")))
|
||||
@router.message(CommandStart(magic=F.args.regexp(r"^admin_user_(\d+)$").as_("admin_user_match")))
|
||||
@router.message(CommandStart(magic=F.args.regexp(r"^page_ref$").as_("page_ref_match")))
|
||||
@router.message(CommandStart(magic=F.args.regexp(r"^(?!ref_|promo_|admin_user_|page_ref$)([A-Za-z0-9_\-]{2,64})$").as_("ad_param_match")))
|
||||
async def start_command_handler(message: types.Message,
|
||||
state: FSMContext,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
subscription_service: SubscriptionService,
|
||||
referral_service: ReferralService,
|
||||
session: AsyncSession,
|
||||
ref_match: Optional[re.Match] = None,
|
||||
promo_match: Optional[re.Match] = None,
|
||||
ad_param_match: Optional[re.Match] = None):
|
||||
page_ref_match: Optional[re.Match] = None,
|
||||
ad_param_match: Optional[re.Match] = None,
|
||||
admin_user_match: Optional[re.Match] = None):
|
||||
await state.clear()
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
@@ -323,17 +329,87 @@ async def start_command_handler(message: types.Message,
|
||||
user = message.from_user
|
||||
user_id = user.id
|
||||
|
||||
if admin_user_match and user_id in settings.ADMIN_IDS:
|
||||
target_user_id = int(admin_user_match.group(1))
|
||||
target_user = await user_dal.get_user_by_id(session, target_user_id)
|
||||
if not target_user:
|
||||
await message.answer(
|
||||
_("admin_user_not_found", input=hd.quote(str(target_user_id)))
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
from bot.handlers.admin.user_management import (
|
||||
format_user_card,
|
||||
get_user_card_keyboard,
|
||||
_send_with_profile_link_fallback,
|
||||
)
|
||||
|
||||
referral_service = ReferralService(
|
||||
settings, subscription_service, message.bot, i18n
|
||||
)
|
||||
user_card_text = await format_user_card(
|
||||
target_user,
|
||||
session,
|
||||
subscription_service,
|
||||
i18n,
|
||||
current_lang,
|
||||
referral_service,
|
||||
)
|
||||
keyboard = get_user_card_keyboard(
|
||||
target_user.user_id,
|
||||
i18n,
|
||||
current_lang,
|
||||
target_user.referred_by_id,
|
||||
)
|
||||
|
||||
await _send_with_profile_link_fallback(
|
||||
message.answer,
|
||||
text=user_card_text,
|
||||
markup=keyboard.as_markup(),
|
||||
user_id=target_user.user_id,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
return
|
||||
except Exception as e_admin_card:
|
||||
logging.error(
|
||||
"Failed to open admin user card via deep-link for %s: %s",
|
||||
target_user_id,
|
||||
e_admin_card,
|
||||
exc_info=True,
|
||||
)
|
||||
await message.answer(_("admin_user_card_error"))
|
||||
return
|
||||
|
||||
referred_by_user_id: Optional[int] = None
|
||||
promo_code_to_apply: Optional[str] = None
|
||||
should_open_referral_from_start = False
|
||||
ad_start_param: Optional[str] = None
|
||||
|
||||
if ref_match:
|
||||
potential_referrer_id = int(ref_match.group(1))
|
||||
if await user_dal.get_user_by_id(session, potential_referrer_id):
|
||||
referred_by_user_id = potential_referrer_id
|
||||
raw_ref_value = ref_match.group(1)
|
||||
if raw_ref_value.isdigit():
|
||||
if settings.LEGACY_REFS:
|
||||
potential_referrer_id = int(raw_ref_value)
|
||||
if potential_referrer_id != user_id and await user_dal.get_user_by_id(
|
||||
session, potential_referrer_id):
|
||||
referred_by_user_id = potential_referrer_id
|
||||
else:
|
||||
normalized_code = raw_ref_value.strip()
|
||||
if normalized_code and normalized_code[0].lower() == "u":
|
||||
normalized_code = normalized_code[1:]
|
||||
ref_user = None
|
||||
if normalized_code:
|
||||
ref_user = await user_dal.get_user_by_referral_code(
|
||||
session, normalized_code)
|
||||
if ref_user and ref_user.user_id != user_id:
|
||||
referred_by_user_id = ref_user.user_id
|
||||
elif promo_match:
|
||||
promo_code_to_apply = promo_match.group(1)
|
||||
logging.info(f"User {user_id} started with promo code: {promo_code_to_apply}")
|
||||
elif page_ref_match:
|
||||
should_open_referral_from_start = True
|
||||
logging.info(f"User {user_id} started with page_ref deep-link.")
|
||||
elif ad_param_match:
|
||||
ad_start_param = ad_param_match.group(1)
|
||||
logging.info(f"User {user_id} started with ad start param: {ad_start_param}")
|
||||
@@ -343,6 +419,7 @@ async def start_command_handler(message: types.Message,
|
||||
sanitized_last_name = sanitize_display_name(user.last_name)
|
||||
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
is_existing_user = db_user is not None
|
||||
if not db_user:
|
||||
user_data_to_create = {
|
||||
"user_id": user_id,
|
||||
@@ -357,10 +434,65 @@ async def start_command_handler(message: types.Message,
|
||||
db_user, created = await user_dal.create_user(session, user_data_to_create)
|
||||
|
||||
if created:
|
||||
try:
|
||||
await session.commit()
|
||||
except Exception as commit_error:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"Failed to commit new user {user_id}: {commit_error}",
|
||||
exc_info=True,
|
||||
)
|
||||
await message.answer(_("error_occurred_processing_request"))
|
||||
return
|
||||
|
||||
logging.info(
|
||||
f"New user {user_id} added to session. Referred by: {referred_by_user_id or 'N/A'}."
|
||||
)
|
||||
|
||||
# Auto-grant referral welcome bonus to newly registered referred users.
|
||||
referral_welcome_days = max(
|
||||
0, int(getattr(settings, "REFERRAL_WELCOME_BONUS_DAYS", 0) or 0)
|
||||
)
|
||||
if referred_by_user_id and referral_welcome_days > 0:
|
||||
try:
|
||||
referral_bonus_end_date = await subscription_service.extend_active_subscription_days(
|
||||
session,
|
||||
user_id,
|
||||
referral_welcome_days,
|
||||
reason="referral_welcome_bonus",
|
||||
)
|
||||
if referral_bonus_end_date:
|
||||
await session.commit()
|
||||
logging.info(
|
||||
"Referral welcome bonus applied: user %s got %s days, new end date %s.",
|
||||
user_id,
|
||||
referral_welcome_days,
|
||||
referral_bonus_end_date.isoformat(),
|
||||
)
|
||||
await message.answer(
|
||||
_(
|
||||
"referral_welcome_bonus_applied",
|
||||
days=referral_welcome_days,
|
||||
end_date=referral_bonus_end_date.strftime("%d.%m.%Y %H:%M:%S"),
|
||||
),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
else:
|
||||
await session.rollback()
|
||||
logging.warning(
|
||||
"Referral welcome bonus was not applied for user %s (referred by %s).",
|
||||
user_id,
|
||||
referred_by_user_id,
|
||||
)
|
||||
except Exception as referral_bonus_error:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
"Failed to apply referral welcome bonus for user %s: %s",
|
||||
user_id,
|
||||
referral_bonus_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# Send notification about new user registration
|
||||
try:
|
||||
from bot.services.notification_service import NotificationService
|
||||
@@ -433,8 +565,12 @@ async def start_command_handler(message: types.Message,
|
||||
db_user):
|
||||
return
|
||||
|
||||
open_referral_page_for_existing_user = (
|
||||
should_open_referral_from_start and is_existing_user
|
||||
)
|
||||
|
||||
# Send welcome message if not disabled
|
||||
if not settings.DISABLE_WELCOME_MESSAGE:
|
||||
if not settings.DISABLE_WELCOME_MESSAGE and not open_referral_page_for_existing_user:
|
||||
await message.answer(_(key="welcome", user_name=hd.quote(user.full_name)))
|
||||
|
||||
# Auto-apply promo code if provided via start parameter
|
||||
@@ -453,21 +589,28 @@ async def start_command_handler(message: types.Message,
|
||||
|
||||
# Get updated subscription details
|
||||
active = await subscription_service.get_active_subscription_details(session, user_id)
|
||||
config_link = active.get("config_link") if active else None
|
||||
config_link = config_link or _("config_link_not_available")
|
||||
config_link_display = active.get("config_link") if active else None
|
||||
connect_button_url = active.get("connect_button_url") if active else None
|
||||
config_link_text = config_link_display or _("config_link_not_available")
|
||||
|
||||
new_end_date = result if isinstance(result, datetime) else None
|
||||
|
||||
promo_success_text = _(
|
||||
"promo_code_applied_success_full",
|
||||
end_date=(new_end_date.strftime("%d.%m.%Y %H:%M:%S") if new_end_date else "N/A"),
|
||||
config_link=config_link,
|
||||
config_link=config_link_text,
|
||||
)
|
||||
|
||||
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
|
||||
await message.answer(
|
||||
promo_success_text,
|
||||
reply_markup=get_connect_and_main_keyboard(current_lang, i18n, settings, config_link),
|
||||
reply_markup=get_connect_and_main_keyboard(
|
||||
current_lang,
|
||||
i18n,
|
||||
settings,
|
||||
config_link_display,
|
||||
connect_button_url=connect_button_url,
|
||||
),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
@@ -476,12 +619,20 @@ async def start_command_handler(message: types.Message,
|
||||
else:
|
||||
await session.rollback()
|
||||
logging.warning(f"Failed to auto-apply promo code '{promo_code_to_apply}' for user {user_id}: {result}")
|
||||
await message.answer(str(result), parse_mode="HTML")
|
||||
# Continue to show main menu if promo failed
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error auto-applying promo code '{promo_code_to_apply}' for user {user_id}: {e}")
|
||||
await session.rollback()
|
||||
|
||||
if open_referral_page_for_existing_user:
|
||||
from . import referral as user_referral_handlers
|
||||
await user_referral_handlers.referral_command_handler(
|
||||
message, settings, i18n_data, referral_service, message.bot, session
|
||||
)
|
||||
return
|
||||
|
||||
await send_main_menu(message,
|
||||
settings,
|
||||
i18n_data,
|
||||
@@ -664,6 +815,38 @@ async def main_action_callback_handler(
|
||||
elif action == "language":
|
||||
|
||||
await language_command_handler(callback, i18n_data, settings)
|
||||
elif action == "info":
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
current_lang = i18n_data.get("current_language",
|
||||
settings.DEFAULT_LANGUAGE)
|
||||
if not i18n:
|
||||
await callback.answer("Language service error.",
|
||||
show_alert=True)
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(
|
||||
current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
privacy_url = settings.PRIVACY_POLICY_URL
|
||||
user_agreement_url = settings.USER_AGREEMENT_URL or settings.TERMS_OF_SERVICE_URL
|
||||
|
||||
if not privacy_url and not user_agreement_url:
|
||||
await callback.answer(_("error_occurred_try_again"),
|
||||
show_alert=True)
|
||||
return
|
||||
|
||||
reply_markup = get_information_links_keyboard(
|
||||
current_lang,
|
||||
i18n,
|
||||
privacy_url,
|
||||
user_agreement_url,
|
||||
)
|
||||
try:
|
||||
await callback.message.edit_text(_(key="info_links_message"),
|
||||
reply_markup=reply_markup)
|
||||
except Exception:
|
||||
await callback.message.answer(_(key="info_links_message"),
|
||||
reply_markup=reply_markup)
|
||||
await callback.answer()
|
||||
elif action == "back_to_main":
|
||||
await send_main_menu(callback,
|
||||
settings,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import hashlib
|
||||
import logging
|
||||
from aiogram import Router, F, types, Bot
|
||||
from aiogram.filters import Command
|
||||
@@ -16,12 +17,28 @@ from bot.keyboards.inline.user_keyboards import (
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from db.dal import subscription_dal
|
||||
from db.dal import subscription_dal, user_billing_dal
|
||||
from db.models import Subscription
|
||||
|
||||
router = Router(name="user_subscription_core_router")
|
||||
|
||||
|
||||
def _shorten_hwid_for_display(hwid: Optional[str], max_length: int = 24) -> str:
|
||||
"""Trim HWID for button text to keep within Telegram limits."""
|
||||
if not hwid:
|
||||
return "-"
|
||||
hwid_str = str(hwid)
|
||||
if len(hwid_str) <= max_length:
|
||||
return hwid_str
|
||||
return f"{hwid_str[:8]}...{hwid_str[-6:]}"
|
||||
|
||||
|
||||
def _hwid_callback_token(hwid: Optional[str]) -> str:
|
||||
"""Stable short token for callback_data; avoids 64b limit with raw HWID."""
|
||||
hwid_str = str(hwid or "")
|
||||
return hashlib.sha256(hwid_str.encode()).hexdigest()[:32]
|
||||
|
||||
|
||||
async def display_subscription_options(event: Union[types.Message, types.CallbackQuery], i18n_data: dict, settings: Settings, session: AsyncSession):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
@@ -40,13 +57,29 @@ async def display_subscription_options(event: Union[types.Message, types.Callbac
|
||||
return
|
||||
|
||||
currency_symbol_val = settings.DEFAULT_CURRENCY_SYMBOL
|
||||
text_content = get_text("select_subscription_period") if settings.subscription_options else get_text("no_subscription_options_available")
|
||||
traffic_packages = getattr(settings, "traffic_packages", {}) or {}
|
||||
stars_traffic_packages = getattr(settings, "stars_traffic_packages", {}) or {}
|
||||
traffic_mode = bool(getattr(settings, "traffic_sale_mode", False) or stars_traffic_packages)
|
||||
|
||||
reply_markup = (
|
||||
get_subscription_options_keyboard(settings.subscription_options, currency_symbol_val, current_lang, i18n)
|
||||
if settings.subscription_options
|
||||
else get_back_to_main_menu_markup(current_lang, i18n)
|
||||
)
|
||||
if traffic_mode:
|
||||
if traffic_packages:
|
||||
options = traffic_packages
|
||||
elif stars_traffic_packages:
|
||||
options = stars_traffic_packages
|
||||
currency_symbol_val = "⭐"
|
||||
else:
|
||||
options = {}
|
||||
else:
|
||||
options = settings.subscription_options
|
||||
|
||||
if options:
|
||||
text_content = get_text("select_traffic_package") if traffic_mode else get_text("select_subscription_period")
|
||||
reply_markup = get_subscription_options_keyboard(
|
||||
options, currency_symbol_val, current_lang, i18n, traffic_mode=traffic_mode
|
||||
)
|
||||
else:
|
||||
text_content = get_text("no_subscription_options_available")
|
||||
reply_markup = get_back_to_main_menu_markup(current_lang, i18n)
|
||||
|
||||
target_message_obj = event.message if isinstance(event, types.CallbackQuery) else event
|
||||
if not target_message_obj:
|
||||
@@ -104,7 +137,7 @@ async def my_subscription_command_handler(
|
||||
text = get_text("subscription_not_active")
|
||||
|
||||
buy_button = InlineKeyboardButton(
|
||||
text=get_text("menu_subscribe_inline", default="Купить"), callback_data="main_action:subscribe"
|
||||
text=get_text("menu_subscribe_inline"), callback_data="main_action:subscribe"
|
||||
)
|
||||
back_markup = get_back_to_main_menu_markup(current_lang, i18n)
|
||||
|
||||
@@ -125,28 +158,78 @@ async def my_subscription_command_handler(
|
||||
|
||||
end_date = active.get("end_date")
|
||||
days_left = (end_date.date() - datetime.now().date()).days if end_date else 0
|
||||
tribute_hint = ""
|
||||
if active.get("status_from_panel", "").lower() == "active":
|
||||
local_sub = await subscription_dal.get_active_subscription_by_user_id(session, event.from_user.id)
|
||||
if local_sub:
|
||||
if local_sub.provider == "tribute":
|
||||
link = None
|
||||
link = settings.tribute_payment_links.get(local_sub.duration_months or 1) if hasattr(settings, "tribute_payment_links") else None
|
||||
tribute_hint = "\n\n" + (
|
||||
get_text("subscription_tribute_notice_with_link", link=link) if link else get_text("subscription_tribute_notice")
|
||||
)
|
||||
traffic_mode = bool(getattr(settings, "traffic_sale_mode", False))
|
||||
config_link_display = active.get("config_link")
|
||||
connect_button_url = active.get("connect_button_url")
|
||||
config_link_value = config_link_display or get_text("config_link_not_available")
|
||||
def _fmt_gb(val: Optional[float]) -> str:
|
||||
if val is None:
|
||||
return get_text("traffic_na")
|
||||
try:
|
||||
if isinstance(val, (int, float)):
|
||||
val_gb = float(val) / (2**30)
|
||||
return f"{val_gb:.2f} GB"
|
||||
except Exception:
|
||||
pass
|
||||
return str(val)
|
||||
def _format_traffic_period(strategy: Optional[str]) -> Optional[str]:
|
||||
if not strategy:
|
||||
return None
|
||||
strategy_upper = str(strategy).upper()
|
||||
key_map = {
|
||||
"MONTH": "traffic_period_month",
|
||||
"WEEK": "traffic_period_week",
|
||||
"DAY": "traffic_period_day",
|
||||
"NO_RESET": "traffic_period_no_reset",
|
||||
}
|
||||
label_key = key_map.get(strategy_upper)
|
||||
return get_text(label_key) if label_key else strategy_upper
|
||||
|
||||
text = get_text(
|
||||
"my_subscription_details",
|
||||
end_date=end_date.strftime("%Y-%m-%d") if end_date else "N/A",
|
||||
days_left=max(0, days_left),
|
||||
status=active.get("status_from_panel", get_text("status_active")).capitalize(),
|
||||
config_link=active.get("config_link") or get_text("config_link_not_available"),
|
||||
traffic_limit=(f"{active['traffic_limit_bytes'] / 2**30:.2f} GB" if active.get("traffic_limit_bytes") else get_text("traffic_unlimited")),
|
||||
traffic_used=(
|
||||
f"{active['traffic_used_bytes'] / 2**30:.2f} GB" if active.get("traffic_used_bytes") is not None else get_text("traffic_na")
|
||||
),
|
||||
)
|
||||
def _format_used_with_period(used_display: str, period_label: Optional[str]) -> str:
|
||||
if not period_label:
|
||||
return used_display
|
||||
return get_text("traffic_used_with_period", traffic_used=used_display, traffic_period=period_label)
|
||||
|
||||
period_label = _format_traffic_period(active.get("traffic_limit_strategy"))
|
||||
period_label = period_label or get_text("traffic_period_unknown")
|
||||
|
||||
if traffic_mode:
|
||||
limit_display = _fmt_gb(active.get("traffic_limit_bytes"))
|
||||
used_display = _format_used_with_period(_fmt_gb(active.get("traffic_used_bytes")), period_label)
|
||||
remaining_display = get_text("traffic_na")
|
||||
try:
|
||||
limit_val = active.get("traffic_limit_bytes") or 0
|
||||
used_val = active.get("traffic_used_bytes") or 0
|
||||
remaining_val = max(0, float(limit_val) - float(used_val))
|
||||
remaining_display = _fmt_gb(remaining_val)
|
||||
except Exception:
|
||||
pass
|
||||
text = get_text(
|
||||
"my_traffic_details",
|
||||
status=active.get("status_from_panel", get_text("status_active")).capitalize(),
|
||||
end_date=end_date.strftime("%Y-%m-%d") if end_date else get_text("traffic_no_expiry"),
|
||||
traffic_limit=limit_display,
|
||||
traffic_used=used_display,
|
||||
traffic_left=remaining_display,
|
||||
traffic_period=period_label,
|
||||
config_link=config_link_value,
|
||||
)
|
||||
else:
|
||||
text = get_text(
|
||||
"my_subscription_details",
|
||||
end_date=end_date.strftime("%Y-%m-%d") if end_date else "N/A",
|
||||
days_left=max(0, days_left),
|
||||
status=active.get("status_from_panel", get_text("status_active")).capitalize(),
|
||||
config_link=config_link_value,
|
||||
traffic_limit=(f"{active['traffic_limit_bytes'] / 2**30:.2f} GB" if active.get("traffic_limit_bytes") else get_text("traffic_unlimited")),
|
||||
traffic_used=(
|
||||
_format_used_with_period(
|
||||
f"{active['traffic_used_bytes'] / 2**30:.2f} GB" if active.get("traffic_used_bytes") is not None else get_text("traffic_na"),
|
||||
period_label,
|
||||
)
|
||||
),
|
||||
traffic_period=period_label,
|
||||
)
|
||||
|
||||
base_markup = get_back_to_main_menu_markup(current_lang, i18n)
|
||||
kb = base_markup.inline_keyboard
|
||||
@@ -164,7 +247,7 @@ async def my_subscription_command_handler(
|
||||
)
|
||||
])
|
||||
else:
|
||||
cfg_link_val = (active or {}).get("config_link")
|
||||
cfg_link_val = connect_button_url or config_link_display
|
||||
if cfg_link_val:
|
||||
prepend_rows.append([
|
||||
InlineKeyboardButton(
|
||||
@@ -224,8 +307,8 @@ async def my_subscription_command_handler(
|
||||
)
|
||||
])
|
||||
|
||||
# 2) Auto-renew toggle (if supported and not tribute)
|
||||
if local_sub and local_sub.provider != "tribute" and getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
|
||||
# 2) Auto-renew toggle (YooKassa only)
|
||||
if not traffic_mode and local_sub and local_sub.provider == "yookassa" and settings.yookassa_autopayments_active:
|
||||
toggle_text = (
|
||||
get_text("autorenew_disable_button") if local_sub.auto_renew_enabled else get_text("autorenew_enable_button")
|
||||
)
|
||||
@@ -237,7 +320,7 @@ async def my_subscription_command_handler(
|
||||
])
|
||||
|
||||
# 3) Payment methods management (when autopayments enabled)
|
||||
if getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
|
||||
if not traffic_mode and settings.yookassa_autopayments_active:
|
||||
prepend_rows.append([
|
||||
InlineKeyboardButton(text=get_text("payment_methods_manage_button"), callback_data="pm:manage")
|
||||
])
|
||||
@@ -254,17 +337,17 @@ async def my_subscription_command_handler(
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await event.message.edit_text(text + tribute_hint, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True)
|
||||
await event.message.edit_text(text, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True)
|
||||
except Exception:
|
||||
await bot.send_message(
|
||||
chat_id=target.chat.id,
|
||||
text=text + tribute_hint,
|
||||
text=text,
|
||||
reply_markup=markup,
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
else:
|
||||
await target.answer(text + tribute_hint, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True)
|
||||
await target.answer(text, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "main_action:my_devices")
|
||||
@@ -321,6 +404,12 @@ async def my_devices_command_handler(
|
||||
await target.answer(get_text("no_devices_found"))
|
||||
return
|
||||
|
||||
devices_list_raw = []
|
||||
if isinstance(devices, dict):
|
||||
devices_list_raw = devices.get("devices") or []
|
||||
elif isinstance(devices, list):
|
||||
devices_list_raw = devices
|
||||
|
||||
max_devices_value = active.get("max_devices")
|
||||
max_devices_display = get_text("devices_unlimited_label")
|
||||
if max_devices_value not in (None, 0):
|
||||
@@ -331,19 +420,22 @@ async def my_devices_command_handler(
|
||||
except (TypeError, ValueError):
|
||||
max_devices_display = str(max_devices_value)
|
||||
|
||||
if not devices or not devices.get('devices') or len(devices.get('devices')) == 0:
|
||||
if not devices_list_raw:
|
||||
text = get_text("no_devices_details_found_message", max_devices=max_devices_display)
|
||||
else:
|
||||
devices_list = []
|
||||
current_devices = len(devices.get('devices') or [])
|
||||
for index, device in enumerate(devices.get('devices') or [], start=1):
|
||||
current_devices = len(devices_list_raw)
|
||||
for index, device in enumerate(devices_list_raw, start=1):
|
||||
device_model = device.get('deviceModel') or None
|
||||
platform = device.get('platform') or None
|
||||
user_agent = device.get('userAgent') or None
|
||||
os_version = device.get('osVersion') or None
|
||||
created_at = device.get('createdAt')
|
||||
hwid = device.get('hwid')
|
||||
created_at_str = datetime.fromisoformat(created_at).strftime("%d.%m.%Y %H:%M")
|
||||
try:
|
||||
created_at_str = datetime.fromisoformat(created_at).strftime("%d.%m.%Y %H:%M") if created_at else "-"
|
||||
except Exception:
|
||||
created_at_str = str(created_at)
|
||||
|
||||
device_details = get_text("device_details", index=index, device_model=device_model, platform=platform, os_version=os_version, created_at_str=created_at_str, user_agent=user_agent, hwid=hwid)
|
||||
devices_list.append(device_details)
|
||||
@@ -354,11 +446,14 @@ async def my_devices_command_handler(
|
||||
kb = base_markup.inline_keyboard
|
||||
|
||||
devices_kb = []
|
||||
for index, device in enumerate(devices.get('devices') or [], start=1):
|
||||
for index, device in enumerate(devices_list_raw, start=1):
|
||||
hwid = device.get('hwid')
|
||||
device_button_text = get_text("disconnect_device_button", hwid=hwid, index=index)
|
||||
if not hwid:
|
||||
continue
|
||||
device_button_text = get_text("disconnect_device_button", hwid=_shorten_hwid_for_display(hwid), index=index)
|
||||
hwid_token = _hwid_callback_token(hwid)
|
||||
|
||||
devices_kb.append([InlineKeyboardButton(text=device_button_text, callback_data=f"disconnect_device:{hwid}")])
|
||||
devices_kb.append([InlineKeyboardButton(text=device_button_text, callback_data=f"disconnect_device:{hwid_token}")])
|
||||
kb = devices_kb + kb
|
||||
markup = InlineKeyboardMarkup(inline_keyboard=kb)
|
||||
|
||||
@@ -397,7 +492,7 @@ async def disconnect_device_handler(
|
||||
return
|
||||
|
||||
try:
|
||||
_, hwid = callback.data.split(":", 1)
|
||||
_, hwid_token = callback.data.split(":", 1)
|
||||
except Exception:
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
@@ -406,10 +501,32 @@ async def disconnect_device_handler(
|
||||
return
|
||||
|
||||
active = await subscription_service.get_active_subscription_details(session, callback.from_user.id)
|
||||
if not active:
|
||||
if not active or not active.get("user_id"):
|
||||
await callback.answer(get_text("subscription_not_active"), show_alert=True)
|
||||
return
|
||||
|
||||
devices = await panel_service.get_user_devices(active.get("user_id"))
|
||||
if not devices:
|
||||
await callback.answer(get_text("no_devices_found"), show_alert=True)
|
||||
return
|
||||
|
||||
devices_list_raw = []
|
||||
if isinstance(devices, dict):
|
||||
devices_list_raw = devices.get("devices") or []
|
||||
elif isinstance(devices, list):
|
||||
devices_list_raw = devices
|
||||
|
||||
hwid = None
|
||||
for device in devices_list_raw:
|
||||
hwid_candidate = device.get("hwid")
|
||||
if hwid_candidate and _hwid_callback_token(hwid_candidate) == hwid_token:
|
||||
hwid = hwid_candidate
|
||||
break
|
||||
|
||||
if not hwid:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
return
|
||||
|
||||
success = await panel_service.disconnect_device(active.get("user_id"), hwid)
|
||||
if not success:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
@@ -452,9 +569,17 @@ async def toggle_autorenew_handler(
|
||||
if not sub or sub.user_id != callback.from_user.id:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
return
|
||||
if sub.provider == "tribute":
|
||||
await callback.answer(get_text("subscription_autorenew_not_supported_for_tribute"), show_alert=True)
|
||||
if sub.provider != "yookassa":
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
return
|
||||
if enable:
|
||||
has_saved_card = await user_billing_dal.user_has_saved_payment_method(session, callback.from_user.id)
|
||||
if not has_saved_card:
|
||||
try:
|
||||
await callback.answer(get_text("autorenew_enable_requires_card"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
# Show confirmation popup and inline buttons
|
||||
confirm_text = get_text("autorenew_confirm_enable") if enable else get_text("autorenew_confirm_disable")
|
||||
@@ -502,9 +627,21 @@ async def confirm_autorenew_handler(
|
||||
if not sub or sub.user_id != callback.from_user.id:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
return
|
||||
if sub.provider == "tribute":
|
||||
await callback.answer(get_text("subscription_autorenew_not_supported_for_tribute"), show_alert=True)
|
||||
if sub.provider != "yookassa":
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
return
|
||||
if enable:
|
||||
has_saved_card = await user_billing_dal.user_has_saved_payment_method(session, callback.from_user.id)
|
||||
if not has_saved_card:
|
||||
try:
|
||||
await callback.answer(get_text("autorenew_enable_requires_card"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await my_subscription_command_handler(callback, i18n_data, settings, panel_service, subscription_service, session, bot)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
await subscription_dal.update_subscription(session, sub.subscription_id, {"auto_renew_enabled": enable})
|
||||
await session.commit()
|
||||
@@ -529,7 +666,7 @@ async def autorenew_cancel_from_webhook_button(
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
# Disable auto-renew on the active subscription (non-tribute)
|
||||
# Disable auto-renew on the active subscription
|
||||
from db.dal import subscription_dal
|
||||
sub = await subscription_dal.get_active_subscription_by_user_id(session, callback.from_user.id)
|
||||
if not sub:
|
||||
@@ -538,9 +675,9 @@ async def autorenew_cancel_from_webhook_button(
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
if sub.provider == "tribute":
|
||||
if sub.provider != "yookassa":
|
||||
try:
|
||||
await callback.answer(get_text("subscription_autorenew_not_supported_for_tribute"), show_alert=True)
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
@@ -22,7 +22,7 @@ router = Router(name="user_subscription_payment_methods_router")
|
||||
async def payment_methods_manage(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
|
||||
if not settings.yookassa_autopayments_active:
|
||||
try:
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
await callback.answer(_("error_service_unavailable"), show_alert=True)
|
||||
@@ -51,9 +51,9 @@ async def payment_methods_manage(callback: types.CallbackQuery, settings: Settin
|
||||
return get_text("payment_method_wallet_title", last4=l4)
|
||||
return get_text("payment_method_wallet_title", last4="****")
|
||||
if last4:
|
||||
network_name = network or get_text("payment_network_card", default="Card")
|
||||
network_name = network or get_text("payment_network_card")
|
||||
return get_text("payment_method_card_title", network=network_name, last4=last4)
|
||||
network_name = network or get_text("payment_network_generic", default="Payment method")
|
||||
network_name = network or get_text("payment_network_generic")
|
||||
return get_text("payment_method_generic_title", network=network_name)
|
||||
|
||||
for m in methods:
|
||||
@@ -75,7 +75,7 @@ async def payment_methods_manage(callback: types.CallbackQuery, settings: Settin
|
||||
async def payment_method_bind(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession, yookassa_service: YooKassaService):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
|
||||
if not settings.yookassa_autopayments_active:
|
||||
try:
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
await callback.answer(_("error_service_unavailable"), show_alert=True)
|
||||
@@ -109,7 +109,7 @@ async def payment_method_bind(callback: types.CallbackQuery, settings: Settings,
|
||||
async def payment_method_delete_confirm(callback: types.CallbackQuery, settings: Settings, i18n_data: dict):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
|
||||
if not settings.yookassa_autopayments_active:
|
||||
try:
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
await callback.answer(_("error_service_unavailable"), show_alert=True)
|
||||
@@ -130,7 +130,7 @@ async def payment_method_delete_confirm(callback: types.CallbackQuery, settings:
|
||||
async def payment_method_delete(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
|
||||
if not settings.yookassa_autopayments_active:
|
||||
try:
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
await callback.answer(_("error_service_unavailable"), show_alert=True)
|
||||
@@ -177,9 +177,9 @@ async def payment_method_delete(callback: types.CallbackQuery, settings: Setting
|
||||
return _("payment_method_wallet_title", last4=l4)
|
||||
return _("payment_method_wallet_title", last4="****")
|
||||
if last4:
|
||||
network_name = network or _("payment_network_card", default="Card")
|
||||
network_name = network or _("payment_network_card")
|
||||
return _("payment_method_card_title", network=network_name, last4=last4)
|
||||
network_name = network or _("payment_network_generic", default="Payment method")
|
||||
network_name = network or _("payment_network_generic")
|
||||
return _("payment_method_generic_title", network=network_name)
|
||||
title = _format_pm_title(m.card_network, m.card_last4)
|
||||
cards.append((str(m.method_id), title if not m.is_default else f"⭐ {title}"))
|
||||
@@ -204,7 +204,7 @@ async def payment_method_delete(callback: types.CallbackQuery, settings: Setting
|
||||
async def payment_method_view(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
|
||||
if not settings.yookassa_autopayments_active:
|
||||
try:
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
await callback.answer(_("error_service_unavailable"), show_alert=True)
|
||||
@@ -239,9 +239,9 @@ async def payment_method_view(callback: types.CallbackQuery, settings: Settings,
|
||||
return _("payment_method_wallet_title", last4=l4)
|
||||
return _("payment_method_wallet_title", last4="****")
|
||||
if last4:
|
||||
network_name = network or _("payment_network_card", default="Card")
|
||||
network_name = network or _("payment_network_card")
|
||||
return _("payment_method_card_title", network=network_name, last4=last4)
|
||||
network_name = network or _("payment_network_generic", default="Payment method")
|
||||
network_name = network or _("payment_network_generic")
|
||||
return _("payment_method_generic_title", network=network_name)
|
||||
|
||||
title = _format_pm_title(sel.card_network, sel.card_last4)
|
||||
@@ -307,9 +307,9 @@ async def payment_method_view(callback: types.CallbackQuery, settings: Settings,
|
||||
return _("payment_method_wallet_title", last4=l4)
|
||||
return _("payment_method_wallet_title", last4="****")
|
||||
if last4:
|
||||
network_name = network or _("payment_network_card", default="Card")
|
||||
network_name = network or _("payment_network_card")
|
||||
return _("payment_method_card_title", network=network_name, last4=last4)
|
||||
network_name = network or _("payment_network_generic", default="Payment method")
|
||||
network_name = network or _("payment_network_generic")
|
||||
return _("payment_method_generic_title", network=network_name)
|
||||
|
||||
title = _format_pm_title(billing.card_network, billing.card_last4)
|
||||
@@ -325,7 +325,7 @@ async def payment_method_view(callback: types.CallbackQuery, settings: Settings,
|
||||
async def payment_method_history(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession, yookassa_service: YooKassaService):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
|
||||
if not settings.yookassa_autopayments_active:
|
||||
try:
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
await callback.answer(_("error_service_unavailable"), show_alert=True)
|
||||
@@ -390,8 +390,15 @@ async def payment_method_history(callback: types.CallbackQuery, settings: Settin
|
||||
await callback.message.edit_text(_("payment_method_no_history"), reply_markup=back_markup)
|
||||
return
|
||||
|
||||
traffic_mode = getattr(settings, "traffic_sale_mode", False)
|
||||
|
||||
def _format_item(p: Payment) -> str:
|
||||
title = p.description or _("subscription_purchase_title", months=p.subscription_duration_months or 1)
|
||||
if traffic_mode:
|
||||
units_val = p.subscription_duration_months or 0
|
||||
units_display = str(int(units_val)) if float(units_val).is_integer() else f"{units_val:g}"
|
||||
title = p.description or _("traffic_purchase_title", traffic_gb=units_display)
|
||||
else:
|
||||
title = p.description or _("subscription_purchase_title", months=p.subscription_duration_months or 1)
|
||||
date_str = p.created_at.strftime('%Y-%m-%d') if p.created_at else "N/A"
|
||||
return f"{date_str} — {title} — {p.amount:.2f} {p.currency}"
|
||||
|
||||
@@ -433,9 +440,9 @@ async def payment_methods_list(callback: types.CallbackQuery, settings: Settings
|
||||
return get_text("payment_method_wallet_title", last4=l4)
|
||||
return get_text("payment_method_wallet_title", last4="****")
|
||||
if last4:
|
||||
network_name = network or get_text("payment_network_card", default="Card")
|
||||
network_name = network or get_text("payment_network_card")
|
||||
return get_text("payment_method_card_title", network=network_name, last4=last4)
|
||||
network_name = network or get_text("payment_network_generic", default="Payment method")
|
||||
network_name = network or get_text("payment_network_generic")
|
||||
return get_text("payment_method_generic_title", network=network_name)
|
||||
title = _format_pm_title(m.card_network, m.card_last4)
|
||||
cards.append((str(m.method_id), title if not m.is_default else f"⭐ {title}"))
|
||||
@@ -455,4 +462,3 @@ async def payment_methods_list(callback: types.CallbackQuery, settings: Settings
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,115 @@
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import F, Router, types
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot.keyboards.inline.user_keyboards import get_payment_url_keyboard
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.crypto_pay_service import CryptoPayService
|
||||
from config.settings import Settings
|
||||
|
||||
router = Router(name="user_subscription_payments_crypto_router")
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("pay_crypto:"))
|
||||
async def pay_crypto_callback_handler(
|
||||
callback: types.CallbackQuery,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
session: AsyncSession,
|
||||
cryptopay_service: CryptoPayService,
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
get_text = (lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key)
|
||||
|
||||
if not i18n or not callback.message:
|
||||
try:
|
||||
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
if not cryptopay_service or not getattr(cryptopay_service, "configured", False):
|
||||
try:
|
||||
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
_, data_payload = callback.data.split(":", 1)
|
||||
parts = data_payload.split(":")
|
||||
months = float(parts[0])
|
||||
price_amount = float(parts[1])
|
||||
sale_mode = parts[2] if len(parts) > 2 else "subscription"
|
||||
except (ValueError, IndexError):
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
user_id = callback.from_user.id
|
||||
human_value = str(int(months)) if float(months).is_integer() else f"{months:g}"
|
||||
payment_description = (
|
||||
get_text("payment_description_traffic", traffic_gb=human_value)
|
||||
if sale_mode == "traffic"
|
||||
else get_text("payment_description_subscription", months=int(months))
|
||||
)
|
||||
|
||||
invoice_url = await cryptopay_service.create_invoice(
|
||||
session=session,
|
||||
user_id=user_id,
|
||||
months=months,
|
||||
amount=price_amount,
|
||||
description=payment_description,
|
||||
sale_mode=sale_mode,
|
||||
)
|
||||
|
||||
if invoice_url:
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
get_text(
|
||||
key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message",
|
||||
months=int(months),
|
||||
traffic_gb=human_value,
|
||||
),
|
||||
reply_markup=get_payment_url_keyboard(
|
||||
invoice_url,
|
||||
current_lang,
|
||||
i18n,
|
||||
back_callback=f"subscribe_period:{human_value}",
|
||||
back_text_key="back_to_payment_methods_button",
|
||||
),
|
||||
disable_web_page_preview=False,
|
||||
)
|
||||
except Exception:
|
||||
try:
|
||||
await callback.message.answer(
|
||||
get_text(
|
||||
key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message",
|
||||
months=int(months),
|
||||
traffic_gb=human_value,
|
||||
),
|
||||
reply_markup=get_payment_url_keyboard(
|
||||
invoice_url,
|
||||
current_lang,
|
||||
i18n,
|
||||
back_callback=f"subscribe_period:{human_value}",
|
||||
back_text_key="back_to_payment_methods_button",
|
||||
),
|
||||
disable_web_page_preview=False,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
await callback.answer(get_text("error_payment_gateway"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,214 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import F, Router, types
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot.keyboards.inline.user_keyboards import get_payment_url_keyboard
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.freekassa_service import FreeKassaService
|
||||
from config.settings import Settings
|
||||
from db.dal import payment_dal
|
||||
|
||||
router = Router(name="user_subscription_payments_freekassa_router")
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("pay_fk:"))
|
||||
async def pay_fk_callback_handler(
|
||||
callback: types.CallbackQuery,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
freekassa_service: FreeKassaService,
|
||||
session: AsyncSession,
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
if not i18n or not callback.message:
|
||||
try:
|
||||
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
if not freekassa_service or not freekassa_service.configured:
|
||||
logging.error("FreeKassa service is not configured or unavailable.")
|
||||
try:
|
||||
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await callback.message.edit_text(get_text("payment_service_unavailable"))
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
_, data_payload = callback.data.split(":", 1)
|
||||
parts = data_payload.split(":")
|
||||
months = float(parts[0])
|
||||
price_rub = float(parts[1])
|
||||
sale_mode = parts[2] if len(parts) > 2 else "subscription"
|
||||
except (ValueError, IndexError):
|
||||
logging.error(f"Invalid pay_fk data in callback: {callback.data}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
user_id = callback.from_user.id
|
||||
human_value = str(int(months)) if float(months).is_integer() else f"{months:g}"
|
||||
payment_description = (
|
||||
get_text("payment_description_traffic", traffic_gb=human_value)
|
||||
if sale_mode == "traffic"
|
||||
else get_text("payment_description_subscription", months=int(months))
|
||||
)
|
||||
currency_code = getattr(freekassa_service, "default_currency", None) or settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
|
||||
|
||||
payment_record_payload = {
|
||||
"user_id": user_id,
|
||||
"amount": price_rub,
|
||||
"currency": currency_code,
|
||||
"status": "pending_freekassa",
|
||||
"description": payment_description,
|
||||
"subscription_duration_months": int(months),
|
||||
"provider": "freekassa",
|
||||
}
|
||||
|
||||
try:
|
||||
payment_record = await payment_dal.create_payment_record(session, payment_record_payload)
|
||||
await session.commit()
|
||||
except Exception as e_db_create:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"FreeKassa: failed to create payment record for user {user_id}: {e_db_create}",
|
||||
exc_info=True,
|
||||
)
|
||||
try:
|
||||
await callback.message.edit_text(get_text("error_creating_payment_record"))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
success, response_data = await freekassa_service.create_order(
|
||||
payment_db_id=payment_record.payment_id,
|
||||
user_id=payment_record.user_id,
|
||||
months=months,
|
||||
amount=price_rub,
|
||||
currency=freekassa_service.default_currency,
|
||||
payment_method_id=freekassa_service.payment_method_id,
|
||||
ip_address=freekassa_service.server_ip,
|
||||
extra_params={
|
||||
"us_method": freekassa_service.payment_method_id,
|
||||
},
|
||||
)
|
||||
|
||||
if success:
|
||||
location = response_data.get("location")
|
||||
order_hash = response_data.get("orderHash")
|
||||
order_id_api = response_data.get("orderId")
|
||||
provider_identifier = order_hash or order_id_api
|
||||
|
||||
if provider_identifier:
|
||||
try:
|
||||
await payment_dal.update_provider_payment_and_status(
|
||||
session,
|
||||
payment_record.payment_id,
|
||||
str(provider_identifier),
|
||||
payment_record.status,
|
||||
)
|
||||
await session.commit()
|
||||
except Exception as e_status:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"FreeKassa: failed to store provider order id for payment {payment_record.payment_id}: {e_status}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if location:
|
||||
order_identifier_display = str(order_id_api or provider_identifier or payment_record.payment_id)
|
||||
order_info_text = get_text(
|
||||
"free_kassa_order_info",
|
||||
order_id=order_identifier_display,
|
||||
date=datetime.now().strftime("%Y-%m-%d"),
|
||||
)
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
f"{order_info_text}\n\n" + get_text(
|
||||
key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message",
|
||||
months=int(months),
|
||||
traffic_gb=human_value,
|
||||
),
|
||||
reply_markup=get_payment_url_keyboard(
|
||||
location,
|
||||
current_lang,
|
||||
i18n,
|
||||
back_callback=f"subscribe_period:{human_value}",
|
||||
back_text_key="back_to_payment_methods_button",
|
||||
),
|
||||
disable_web_page_preview=False,
|
||||
)
|
||||
except Exception as e_edit:
|
||||
logging.warning(f"FreeKassa: failed to display payment link ({e_edit}), sending new message.")
|
||||
try:
|
||||
await callback.message.answer(
|
||||
f"{order_info_text}\n\n" + get_text(
|
||||
key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message",
|
||||
months=int(months),
|
||||
traffic_gb=human_value,
|
||||
),
|
||||
reply_markup=get_payment_url_keyboard(
|
||||
location,
|
||||
current_lang,
|
||||
i18n,
|
||||
back_callback=f"subscribe_period:{human_value}",
|
||||
back_text_key="back_to_payment_methods_button",
|
||||
),
|
||||
disable_web_page_preview=False,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
logging.error(
|
||||
"FreeKassa: create_order succeeded but no payment link returned for payment %s. Response: %s",
|
||||
payment_record.payment_id,
|
||||
response_data,
|
||||
)
|
||||
else:
|
||||
logging.error(
|
||||
"FreeKassa: create_order failed for payment %s with response %s",
|
||||
payment_record.payment_id,
|
||||
response_data,
|
||||
)
|
||||
|
||||
try:
|
||||
await payment_dal.update_payment_status_by_db_id(
|
||||
session,
|
||||
payment_record.payment_id,
|
||||
"failed_creation",
|
||||
)
|
||||
await session.commit()
|
||||
except Exception as e_status:
|
||||
await session.rollback()
|
||||
logging.error(f"FreeKassa: failed to mark payment {payment_record.payment_id} as failed_creation: {e_status}", exc_info=True)
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(get_text("error_payment_gateway"))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await callback.answer(get_text("error_payment_gateway"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,210 @@
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import F, Router, types
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot.keyboards.inline.user_keyboards import get_payment_url_keyboard
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.platega_service import PlategaService
|
||||
from config.settings import Settings
|
||||
from db.dal import payment_dal
|
||||
|
||||
router = Router(name="user_subscription_payments_platega_router")
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("pay_platega:"))
|
||||
async def pay_platega_callback_handler(
|
||||
callback: types.CallbackQuery,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
platega_service: PlategaService,
|
||||
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 platega_service or not platega_service.configured:
|
||||
logging.error("Platega service is not configured or unavailable.")
|
||||
try:
|
||||
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await callback.message.edit_text(get_text("payment_service_unavailable"))
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
_, data_payload = callback.data.split(":", 1)
|
||||
parts = data_payload.split(":")
|
||||
months = float(parts[0])
|
||||
price_rub = float(parts[1])
|
||||
sale_mode = parts[2] if len(parts) > 2 else "subscription"
|
||||
except (ValueError, IndexError):
|
||||
logging.error(f"Invalid pay_platega data in callback: {callback.data}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
user_id = callback.from_user.id
|
||||
human_value = str(int(months)) if float(months).is_integer() else f"{months:g}"
|
||||
payment_description = (
|
||||
get_text("payment_description_traffic", traffic_gb=human_value)
|
||||
if sale_mode == "traffic"
|
||||
else get_text("payment_description_subscription", months=int(months))
|
||||
)
|
||||
currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
|
||||
|
||||
payment_record_payload = {
|
||||
"user_id": user_id,
|
||||
"amount": price_rub,
|
||||
"currency": currency_code,
|
||||
"status": "pending_platega",
|
||||
"description": payment_description,
|
||||
"subscription_duration_months": int(months),
|
||||
"provider": "platega",
|
||||
}
|
||||
|
||||
try:
|
||||
payment_record = await payment_dal.create_payment_record(session, payment_record_payload)
|
||||
await session.commit()
|
||||
except Exception as e_db_create:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"Platega: failed to create payment record for user {user_id}: {e_db_create}",
|
||||
exc_info=True,
|
||||
)
|
||||
try:
|
||||
await callback.message.edit_text(get_text("error_creating_payment_record"))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
payload_meta = json.dumps(
|
||||
{
|
||||
"payment_db_id": payment_record.payment_id,
|
||||
"user_id": user_id,
|
||||
"months": months,
|
||||
"sale_mode": sale_mode,
|
||||
}
|
||||
)
|
||||
|
||||
success, response_data = await platega_service.create_transaction(
|
||||
payment_db_id=payment_record.payment_id,
|
||||
user_id=user_id,
|
||||
months=months,
|
||||
amount=price_rub,
|
||||
currency=currency_code,
|
||||
description=payment_description,
|
||||
payload=payload_meta,
|
||||
)
|
||||
|
||||
if success:
|
||||
transaction_id = response_data.get("transactionId") or response_data.get("id")
|
||||
redirect_url = (
|
||||
response_data.get("redirect")
|
||||
or response_data.get("url")
|
||||
or response_data.get("paymentUrl")
|
||||
)
|
||||
provider_status = response_data.get("status", payment_record.status)
|
||||
|
||||
if transaction_id and redirect_url:
|
||||
try:
|
||||
await payment_dal.update_provider_payment_and_status(
|
||||
session,
|
||||
payment_record.payment_id,
|
||||
str(transaction_id),
|
||||
str(provider_status),
|
||||
)
|
||||
await session.commit()
|
||||
except Exception as e_status:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"Platega: failed to store transaction id for payment {payment_record.payment_id}: {e_status}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
get_text(
|
||||
key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message",
|
||||
months=int(months),
|
||||
traffic_gb=human_value,
|
||||
),
|
||||
reply_markup=get_payment_url_keyboard(
|
||||
redirect_url,
|
||||
current_lang,
|
||||
i18n,
|
||||
back_callback=f"subscribe_period:{human_value}",
|
||||
back_text_key="back_to_payment_methods_button",
|
||||
),
|
||||
disable_web_page_preview=False,
|
||||
)
|
||||
except Exception as e_edit:
|
||||
logging.warning(f"Platega: failed to display payment link ({e_edit}), sending new message.")
|
||||
try:
|
||||
await callback.message.answer(
|
||||
get_text(
|
||||
key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message",
|
||||
months=int(months),
|
||||
traffic_gb=human_value,
|
||||
),
|
||||
reply_markup=get_payment_url_keyboard(
|
||||
redirect_url,
|
||||
current_lang,
|
||||
i18n,
|
||||
back_callback=f"subscribe_period:{human_value}",
|
||||
back_text_key="back_to_payment_methods_button",
|
||||
),
|
||||
disable_web_page_preview=False,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
logging.error(
|
||||
"Platega: transaction created but missing transaction id or payment link for payment %s. Response: %s",
|
||||
payment_record.payment_id,
|
||||
response_data,
|
||||
)
|
||||
|
||||
try:
|
||||
await payment_dal.update_payment_status_by_db_id(
|
||||
session,
|
||||
payment_record.payment_id,
|
||||
"failed_creation",
|
||||
)
|
||||
await session.commit()
|
||||
except Exception as e_status:
|
||||
await session.rollback()
|
||||
logging.error(f"Platega: failed to mark payment {payment_record.payment_id} as failed_creation: {e_status}", exc_info=True)
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(get_text("error_payment_gateway"))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await callback.answer(get_text("error_payment_gateway"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,199 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import F, Router, types
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot.keyboards.inline.user_keyboards import get_payment_url_keyboard
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.severpay_service import SeverPayService
|
||||
from config.settings import Settings
|
||||
from db.dal import payment_dal
|
||||
|
||||
router = Router(name="user_subscription_payments_severpay_router")
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("pay_severpay:"))
|
||||
async def pay_severpay_callback_handler(
|
||||
callback: types.CallbackQuery,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
severpay_service: SeverPayService,
|
||||
session: AsyncSession,
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
if not i18n or not callback.message:
|
||||
try:
|
||||
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
if not severpay_service or not severpay_service.configured:
|
||||
logging.error("SeverPay service is not configured or unavailable.")
|
||||
try:
|
||||
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await callback.message.edit_text(get_text("payment_service_unavailable"))
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
_, data_payload = callback.data.split(":", 1)
|
||||
parts = data_payload.split(":")
|
||||
months = float(parts[0])
|
||||
price_rub = float(parts[1])
|
||||
sale_mode = parts[2] if len(parts) > 2 else "subscription"
|
||||
except (ValueError, IndexError):
|
||||
logging.error(f"Invalid pay_severpay data in callback: {callback.data}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
user_id = callback.from_user.id
|
||||
human_value = str(int(months)) if float(months).is_integer() else f"{months:g}"
|
||||
payment_description = (
|
||||
get_text("payment_description_traffic", traffic_gb=human_value)
|
||||
if sale_mode == "traffic"
|
||||
else get_text("payment_description_subscription", months=int(months))
|
||||
)
|
||||
currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
|
||||
|
||||
payment_record_payload = {
|
||||
"user_id": user_id,
|
||||
"amount": price_rub,
|
||||
"currency": currency_code,
|
||||
"status": "pending_severpay",
|
||||
"description": payment_description,
|
||||
"subscription_duration_months": int(months),
|
||||
"provider": "severpay",
|
||||
}
|
||||
|
||||
try:
|
||||
payment_record = await payment_dal.create_payment_record(session, payment_record_payload)
|
||||
await session.commit()
|
||||
except Exception as e_db_create:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"SeverPay: failed to create payment record for user {user_id}: {e_db_create}",
|
||||
exc_info=True,
|
||||
)
|
||||
try:
|
||||
await callback.message.edit_text(get_text("error_creating_payment_record"))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
success, response_data = await severpay_service.create_payment(
|
||||
payment_db_id=payment_record.payment_id,
|
||||
user_id=user_id,
|
||||
months=months,
|
||||
amount=price_rub,
|
||||
currency=currency_code,
|
||||
description=payment_description,
|
||||
)
|
||||
|
||||
if success:
|
||||
payment_link = (
|
||||
response_data.get("url")
|
||||
or response_data.get("payment_url")
|
||||
or response_data.get("paymentUrl")
|
||||
)
|
||||
provider_identifier = response_data.get("id") or response_data.get("uid")
|
||||
|
||||
if provider_identifier:
|
||||
try:
|
||||
await payment_dal.update_provider_payment_and_status(
|
||||
session,
|
||||
payment_record.payment_id,
|
||||
str(provider_identifier),
|
||||
payment_record.status,
|
||||
)
|
||||
await session.commit()
|
||||
except Exception as e_status:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"SeverPay: failed to store provider payment id for payment {payment_record.payment_id}: {e_status}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if payment_link:
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
get_text(
|
||||
key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message",
|
||||
months=int(months),
|
||||
traffic_gb=human_value,
|
||||
),
|
||||
reply_markup=get_payment_url_keyboard(
|
||||
payment_link,
|
||||
current_lang,
|
||||
i18n,
|
||||
back_callback=f"subscribe_period:{human_value}",
|
||||
back_text_key="back_to_payment_methods_button",
|
||||
),
|
||||
disable_web_page_preview=False,
|
||||
)
|
||||
except Exception as e_edit:
|
||||
logging.warning(f"SeverPay: failed to display payment link ({e_edit}), sending new message.")
|
||||
try:
|
||||
await callback.message.answer(
|
||||
get_text(
|
||||
key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message",
|
||||
months=int(months),
|
||||
traffic_gb=human_value,
|
||||
),
|
||||
reply_markup=get_payment_url_keyboard(
|
||||
payment_link,
|
||||
current_lang,
|
||||
i18n,
|
||||
back_callback=f"subscribe_period:{human_value}",
|
||||
back_text_key="back_to_payment_methods_button",
|
||||
),
|
||||
disable_web_page_preview=False,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
logging.error(
|
||||
"SeverPay: payment created but missing payment link for payment %s. Response: %s",
|
||||
payment_record.payment_id,
|
||||
response_data,
|
||||
)
|
||||
|
||||
try:
|
||||
await payment_dal.update_payment_status_by_db_id(
|
||||
session,
|
||||
payment_record.payment_id,
|
||||
"failed_creation",
|
||||
)
|
||||
await session.commit()
|
||||
except Exception as e_status:
|
||||
await session.rollback()
|
||||
logging.error(f"SeverPay: failed to mark payment {payment_record.payment_id} as failed_creation: {e_status}", exc_info=True)
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(get_text("error_payment_gateway"))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await callback.answer(get_text("error_payment_gateway"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,136 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import F, Router, types
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.stars_service import StarsService
|
||||
from config.settings import Settings
|
||||
|
||||
router = Router(name="user_subscription_payments_stars_router")
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("pay_stars:"))
|
||||
async def pay_stars_callback_handler(
|
||||
callback: types.CallbackQuery,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
session: AsyncSession,
|
||||
stars_service: StarsService,
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
get_text = (lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key)
|
||||
|
||||
if not i18n or not callback.message:
|
||||
try:
|
||||
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
if not settings.STARS_ENABLED:
|
||||
try:
|
||||
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
_, data_payload = callback.data.split(":", 1)
|
||||
parts = data_payload.split(":")
|
||||
months = float(parts[0])
|
||||
stars_price = int(float(parts[1]))
|
||||
sale_mode = parts[2] if len(parts) > 2 else "subscription"
|
||||
except (ValueError, IndexError):
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
user_id = callback.from_user.id
|
||||
human_value = str(int(months)) if float(months).is_integer() else f"{months:g}"
|
||||
payment_description = (
|
||||
get_text("payment_description_traffic", traffic_gb=human_value)
|
||||
if sale_mode == "traffic"
|
||||
else get_text("payment_description_subscription", months=int(months))
|
||||
)
|
||||
|
||||
payment_db_id = await stars_service.create_invoice(
|
||||
session=session,
|
||||
user_id=user_id,
|
||||
months=months,
|
||||
stars_price=stars_price,
|
||||
description=payment_description,
|
||||
sale_mode=sale_mode,
|
||||
)
|
||||
|
||||
if payment_db_id:
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
get_text(
|
||||
"payment_invoice_sent_message_traffic" if sale_mode == "traffic" else "payment_invoice_sent_message",
|
||||
months=int(months),
|
||||
traffic_gb=human_value,
|
||||
),
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(
|
||||
text=get_text("back_to_payment_methods_button"),
|
||||
callback_data=f"subscribe_period:{human_value}",
|
||||
)]
|
||||
]),
|
||||
)
|
||||
except Exception as e_edit:
|
||||
logging.warning(f"Stars payment: failed to show invoice info message ({e_edit})")
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
await callback.answer(get_text("error_payment_gateway"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@router.pre_checkout_query()
|
||||
async def handle_pre_checkout_query(query: types.PreCheckoutQuery):
|
||||
try:
|
||||
await query.answer(ok=True)
|
||||
except Exception:
|
||||
# Nothing else to do here; Telegram will show an error if not answered
|
||||
pass
|
||||
|
||||
|
||||
@router.message(F.successful_payment)
|
||||
async def handle_successful_stars_payment(
|
||||
message: types.Message,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
session: AsyncSession,
|
||||
stars_service: StarsService,
|
||||
):
|
||||
payload = (message.successful_payment.invoice_payload
|
||||
if message and message.successful_payment else "")
|
||||
try:
|
||||
parts = (payload or "").split(":")
|
||||
payment_db_id = int(parts[0])
|
||||
months = float(parts[1]) if len(parts) > 1 else 0
|
||||
sale_mode = parts[2] if len(parts) > 2 else "subscription"
|
||||
except Exception:
|
||||
return
|
||||
|
||||
stars_amount = int(message.successful_payment.total_amount) if message.successful_payment else 0
|
||||
await stars_service.process_successful_payment(
|
||||
session=session,
|
||||
message=message,
|
||||
payment_db_id=payment_db_id,
|
||||
months=months,
|
||||
stars_amount=stars_amount,
|
||||
i18n_data=i18n_data,
|
||||
sale_mode=sale_mode,
|
||||
)
|
||||
@@ -0,0 +1,107 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import F, Router, types
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot.keyboards.inline.user_keyboards import get_payment_method_keyboard
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from config.settings import Settings
|
||||
|
||||
router = Router(name="user_subscription_payments_selection_router")
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("subscribe_period:"))
|
||||
async def select_subscription_period_callback_handler(
|
||||
callback: types.CallbackQuery,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
session: AsyncSession,
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
if not i18n or not callback.message:
|
||||
try:
|
||||
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
traffic_packages = getattr(settings, "traffic_packages", {}) or {}
|
||||
stars_traffic_packages = getattr(settings, "stars_traffic_packages", {}) or {}
|
||||
traffic_mode = bool(getattr(settings, "traffic_sale_mode", False) or stars_traffic_packages)
|
||||
try:
|
||||
months = float(callback.data.split(":")[-1])
|
||||
except (ValueError, IndexError):
|
||||
logging.error(f"Invalid subscription period in callback_data: {callback.data}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
price_source = traffic_packages if traffic_mode else settings.subscription_options
|
||||
stars_price_source = stars_traffic_packages if traffic_mode else settings.stars_subscription_options
|
||||
|
||||
price_rub = price_source.get(months)
|
||||
stars_price = stars_price_source.get(months)
|
||||
currency_symbol_val = settings.DEFAULT_CURRENCY_SYMBOL
|
||||
|
||||
if price_rub is None:
|
||||
if traffic_mode and not price_source and stars_price is not None:
|
||||
currency_methods_enabled = any(
|
||||
[
|
||||
settings.FREEKASSA_ENABLED,
|
||||
settings.PLATEGA_ENABLED,
|
||||
settings.SEVERPAY_ENABLED,
|
||||
settings.YOOKASSA_ENABLED,
|
||||
settings.CRYPTOPAY_ENABLED,
|
||||
]
|
||||
)
|
||||
if currency_methods_enabled:
|
||||
logging.error(
|
||||
"Currency price missing for traffic option %s while fiat providers are enabled.",
|
||||
months,
|
||||
)
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
price_rub = 0.0
|
||||
currency_symbol_val = "⭐"
|
||||
else:
|
||||
logging.error(
|
||||
f"Price not found for option {months} using {'traffic_packages' if traffic_mode else 'subscription_options'}."
|
||||
)
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
text_content = get_text("choose_payment_method_traffic") if traffic_mode else get_text("choose_payment_method")
|
||||
reply_markup = get_payment_method_keyboard(
|
||||
months,
|
||||
price_rub,
|
||||
stars_price,
|
||||
currency_symbol_val,
|
||||
current_lang,
|
||||
i18n,
|
||||
settings,
|
||||
sale_mode="traffic" if traffic_mode else "subscription",
|
||||
)
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(text_content, reply_markup=reply_markup)
|
||||
except Exception as e_edit:
|
||||
logging.warning(
|
||||
f"Edit message for payment method selection failed: {e_edit}. Sending new one."
|
||||
)
|
||||
await callback.message.answer(text_content, reply_markup=reply_markup)
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,771 @@
|
||||
import logging
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from aiogram import F, Router, types
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot.keyboards.inline.user_keyboards import (
|
||||
get_back_to_main_menu_markup,
|
||||
get_payment_url_keyboard,
|
||||
get_yk_autopay_choice_keyboard,
|
||||
get_yk_saved_cards_keyboard,
|
||||
)
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.yookassa_service import YooKassaService
|
||||
from config.settings import Settings
|
||||
from db.dal import payment_dal, user_billing_dal
|
||||
|
||||
router = Router(name="user_subscription_payments_yookassa_router")
|
||||
|
||||
|
||||
def _format_value(val: float) -> str:
|
||||
return str(int(val)) if float(val).is_integer() else f"{val:g}"
|
||||
|
||||
|
||||
def _parse_offer_payload(payload: str) -> Optional[Tuple[float, float, str]]:
|
||||
try:
|
||||
parts = payload.split(":")
|
||||
value = float(parts[0])
|
||||
price = float(parts[1])
|
||||
sale_mode = parts[2] if len(parts) > 2 else "subscription"
|
||||
return value, price, sale_mode
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
|
||||
|
||||
def _format_saved_payment_method_title(get_text, network: Optional[str], last4: Optional[str], is_default: bool) -> str:
|
||||
def _is_yoomoney_network(name: Optional[str]) -> bool:
|
||||
s = (name or "").lower()
|
||||
return "yoomoney" in s or "yoo money" in s or "yoo-money" in s
|
||||
|
||||
def _extract_last4(text: str) -> Optional[str]:
|
||||
digits = "".join(ch for ch in text if ch.isdigit())
|
||||
return digits[-4:] if len(digits) >= 4 else None
|
||||
|
||||
if _is_yoomoney_network(network):
|
||||
inferred_last4 = last4 or (_extract_last4(network or "") or "****")
|
||||
title = get_text("payment_method_wallet_title", last4=inferred_last4)
|
||||
elif last4:
|
||||
network_name = network or get_text("payment_network_card")
|
||||
title = get_text("payment_method_card_title", network=network_name, last4=last4)
|
||||
else:
|
||||
network_name = network or get_text("payment_network_generic")
|
||||
title = get_text("payment_method_generic_title", network=network_name)
|
||||
return f"⭐ {title}" if is_default else title
|
||||
|
||||
|
||||
async def _initiate_yk_payment(
|
||||
callback: types.CallbackQuery,
|
||||
*,
|
||||
settings: Settings,
|
||||
session: AsyncSession,
|
||||
yookassa_service: YooKassaService,
|
||||
i18n: Optional[JsonI18n],
|
||||
current_lang: str,
|
||||
get_text,
|
||||
user_id: int,
|
||||
months: int,
|
||||
price_rub: float,
|
||||
currency_code_for_yk: str,
|
||||
save_payment_method: bool,
|
||||
back_callback: str,
|
||||
payment_method_id: Optional[str] = None,
|
||||
selected_method_internal_id: Optional[int] = None,
|
||||
sale_mode: str = "subscription",
|
||||
) -> bool:
|
||||
"""Create payment record and initiate YooKassa payment (new card or saved card)."""
|
||||
if not callback.message:
|
||||
return False
|
||||
|
||||
payment_description = (
|
||||
get_text("payment_description_traffic", traffic_gb=_format_value(months))
|
||||
if sale_mode == "traffic"
|
||||
else get_text("payment_description_subscription", months=int(months))
|
||||
)
|
||||
payment_record_data = {
|
||||
"user_id": user_id,
|
||||
"amount": price_rub,
|
||||
"currency": currency_code_for_yk,
|
||||
"status": "pending_yookassa",
|
||||
"description": payment_description,
|
||||
"subscription_duration_months": int(months),
|
||||
}
|
||||
|
||||
db_payment_record = None
|
||||
try:
|
||||
db_payment_record = await payment_dal.create_payment_record(session, payment_record_data)
|
||||
await session.commit()
|
||||
logging.info(
|
||||
f"Payment record {db_payment_record.payment_id} created for user {user_id} with status 'pending_yookassa'."
|
||||
)
|
||||
except Exception as e_db_payment:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"Failed to create payment record in DB for user {user_id}: {e_db_payment}",
|
||||
exc_info=True,
|
||||
)
|
||||
try:
|
||||
await callback.message.edit_text(get_text("error_creating_payment_record"))
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
if not db_payment_record:
|
||||
try:
|
||||
await callback.message.edit_text(get_text("error_creating_payment_record"))
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
yookassa_metadata = {
|
||||
"user_id": str(user_id),
|
||||
"subscription_months": str(months),
|
||||
"payment_db_id": str(db_payment_record.payment_id),
|
||||
"sale_mode": sale_mode,
|
||||
}
|
||||
if sale_mode == "traffic":
|
||||
yookassa_metadata["traffic_gb"] = str(months)
|
||||
if payment_method_id:
|
||||
yookassa_metadata["used_saved_payment_method_id"] = payment_method_id
|
||||
|
||||
receipt_email_for_yk = settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL
|
||||
|
||||
payment_response_yk = await yookassa_service.create_payment(
|
||||
amount=price_rub,
|
||||
currency=currency_code_for_yk,
|
||||
description=payment_description,
|
||||
metadata=yookassa_metadata,
|
||||
receipt_email=receipt_email_for_yk,
|
||||
save_payment_method=save_payment_method,
|
||||
payment_method_id=payment_method_id,
|
||||
)
|
||||
|
||||
if payment_response_yk and payment_response_yk.get("confirmation_url"):
|
||||
pm = payment_response_yk.get("payment_method")
|
||||
try:
|
||||
if pm and pm.get("id"):
|
||||
pm_type = pm.get("type")
|
||||
title = pm.get("title")
|
||||
card = pm.get("card") or {}
|
||||
account_number = pm.get("account_number") or pm.get("account")
|
||||
if isinstance(card, dict) and (pm_type or "").lower() in {"bank_card", "bank-card", "card"}:
|
||||
display_network = card.get("card_type") or title or "Card"
|
||||
display_last4 = card.get("last4")
|
||||
elif (pm_type or "").lower() in {"yoo_money", "yoomoney", "yoo-money", "wallet"}:
|
||||
display_network = "YooMoney"
|
||||
display_last4 = (
|
||||
account_number[-4:]
|
||||
if isinstance(account_number, str) and len(account_number) >= 4
|
||||
else None
|
||||
)
|
||||
else:
|
||||
display_network = title or (pm_type.upper() if pm_type else "Payment method")
|
||||
display_last4 = None
|
||||
await user_billing_dal.upsert_yk_payment_method(
|
||||
session,
|
||||
user_id=user_id,
|
||||
payment_method_id=pm["id"],
|
||||
card_last4=display_last4,
|
||||
card_network=display_network,
|
||||
)
|
||||
try:
|
||||
await user_billing_dal.upsert_user_payment_method(
|
||||
session,
|
||||
user_id=user_id,
|
||||
provider_payment_method_id=pm["id"],
|
||||
provider="yookassa",
|
||||
card_last4=display_last4,
|
||||
card_network=display_network,
|
||||
set_default=save_payment_method,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logging.exception("Failed to save YooKassa payment method preliminarily")
|
||||
try:
|
||||
await payment_dal.update_payment_status_by_db_id(
|
||||
session,
|
||||
payment_db_id=db_payment_record.payment_id,
|
||||
new_status=payment_response_yk.get("status", "pending"),
|
||||
yk_payment_id=payment_response_yk.get("id"),
|
||||
)
|
||||
if selected_method_internal_id is not None:
|
||||
try:
|
||||
await user_billing_dal.set_user_default_payment_method(
|
||||
session, user_id, selected_method_internal_id
|
||||
)
|
||||
except Exception:
|
||||
logging.exception("Failed to set default payment method after initiating payment")
|
||||
await session.commit()
|
||||
except Exception as e_db_update_ykid:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"Failed to update payment record {db_payment_record.payment_id} with YK ID: {e_db_update_ykid}",
|
||||
exc_info=True,
|
||||
)
|
||||
try:
|
||||
await callback.message.edit_text(get_text("error_payment_gateway_link_failed"))
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
get_text(
|
||||
key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message",
|
||||
months=int(months),
|
||||
traffic_gb=_format_value(months),
|
||||
),
|
||||
reply_markup=get_payment_url_keyboard(
|
||||
payment_response_yk["confirmation_url"],
|
||||
current_lang,
|
||||
i18n,
|
||||
back_callback=back_callback,
|
||||
back_text_key="back_to_payment_methods_button",
|
||||
),
|
||||
disable_web_page_preview=False,
|
||||
)
|
||||
except Exception as e_edit:
|
||||
logging.warning(
|
||||
f"Edit message for payment link failed: {e_edit}. Sending new one."
|
||||
)
|
||||
try:
|
||||
await callback.message.answer(
|
||||
get_text(
|
||||
key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message",
|
||||
months=int(months),
|
||||
traffic_gb=_format_value(months),
|
||||
),
|
||||
reply_markup=get_payment_url_keyboard(
|
||||
payment_response_yk["confirmation_url"],
|
||||
current_lang,
|
||||
i18n,
|
||||
back_callback=back_callback,
|
||||
back_text_key="back_to_payment_methods_button",
|
||||
),
|
||||
disable_web_page_preview=False,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
|
||||
if payment_response_yk and payment_method_id:
|
||||
status_to_store = payment_response_yk.get("status", "pending")
|
||||
try:
|
||||
await payment_dal.update_payment_status_by_db_id(
|
||||
session,
|
||||
payment_db_id=db_payment_record.payment_id,
|
||||
new_status=status_to_store,
|
||||
yk_payment_id=payment_response_yk.get("id"),
|
||||
)
|
||||
if selected_method_internal_id is not None:
|
||||
try:
|
||||
await user_billing_dal.set_user_default_payment_method(
|
||||
session, user_id, selected_method_internal_id
|
||||
)
|
||||
except Exception:
|
||||
logging.exception("Failed to set default payment method after saved-card payment start")
|
||||
await session.commit()
|
||||
except Exception as e_db_update_saved:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"Failed to update saved-card payment record {db_payment_record.payment_id}: {e_db_update_saved}",
|
||||
exc_info=True,
|
||||
)
|
||||
try:
|
||||
await callback.message.edit_text(get_text("error_payment_gateway"))
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
message_text = get_text("yookassa_autopay_charge_initiated")
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=get_back_to_main_menu_markup(current_lang, i18n),
|
||||
)
|
||||
except Exception as e_edit:
|
||||
logging.warning(f"Failed to notify about saved-card charge start: {e_edit}")
|
||||
try:
|
||||
await callback.message.answer(
|
||||
message_text,
|
||||
reply_markup=get_back_to_main_menu_markup(current_lang, i18n),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
|
||||
try:
|
||||
await payment_dal.update_payment_status_by_db_id(
|
||||
session, db_payment_record.payment_id, "failed_creation"
|
||||
)
|
||||
await session.commit()
|
||||
except Exception as e_db_fail_create:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"Additionally failed to update payment record to 'failed_creation': {e_db_fail_create}",
|
||||
exc_info=True,
|
||||
)
|
||||
logging.error(
|
||||
f"Failed to create payment in YooKassa for user {user_id}, payment_db_id {db_payment_record.payment_id}. Response: {payment_response_yk}"
|
||||
)
|
||||
try:
|
||||
await callback.message.edit_text(get_text("error_payment_gateway"))
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("pay_yk:"))
|
||||
async def pay_yk_callback_handler(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, yookassa_service: YooKassaService, session: AsyncSession):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
if not i18n or not callback.message:
|
||||
try:
|
||||
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
if not yookassa_service or not yookassa_service.configured:
|
||||
logging.error("YooKassa service is not configured or unavailable.")
|
||||
target_msg_edit = callback.message
|
||||
await target_msg_edit.edit_text(get_text("payment_service_unavailable"))
|
||||
try:
|
||||
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
_, data_payload = callback.data.split(":", 1)
|
||||
except ValueError:
|
||||
logging.error(f"Invalid pay_yk data in callback: {callback.data}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
parsed = _parse_offer_payload(data_payload)
|
||||
if not parsed:
|
||||
logging.error(f"Invalid pay_yk payload structure: {callback.data}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
months, price_rub, sale_mode = parsed
|
||||
user_id = callback.from_user.id
|
||||
currency_code_for_yk = "RUB"
|
||||
autopay_enabled = bool(settings.yookassa_autopayments_active and sale_mode != "traffic" and not settings.traffic_sale_mode)
|
||||
autopay_require_binding = bool(
|
||||
getattr(settings, 'YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING', True)
|
||||
)
|
||||
saved_methods: List = []
|
||||
if autopay_enabled:
|
||||
try:
|
||||
saved_methods = await user_billing_dal.list_user_payment_methods(
|
||||
session, user_id, provider="yookassa"
|
||||
)
|
||||
except Exception as e_list:
|
||||
logging.exception(f"Failed to load saved payment methods for user {user_id}: {e_list}")
|
||||
saved_methods = []
|
||||
|
||||
if autopay_enabled and saved_methods:
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
get_text("yookassa_autopay_flow_prompt"),
|
||||
reply_markup=get_yk_autopay_choice_keyboard(
|
||||
months,
|
||||
price_rub,
|
||||
current_lang,
|
||||
i18n,
|
||||
has_saved_cards=True,
|
||||
sale_mode=sale_mode,
|
||||
),
|
||||
)
|
||||
except Exception as e_edit:
|
||||
logging.warning(f"Failed to show autopay choice: {e_edit}. Sending new message.")
|
||||
try:
|
||||
await callback.message.answer(
|
||||
get_text("yookassa_autopay_flow_prompt"),
|
||||
reply_markup=get_yk_autopay_choice_keyboard(
|
||||
months,
|
||||
price_rub,
|
||||
current_lang,
|
||||
i18n,
|
||||
has_saved_cards=True,
|
||||
sale_mode=sale_mode,
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
await _initiate_yk_payment(
|
||||
callback,
|
||||
settings=settings,
|
||||
session=session,
|
||||
yookassa_service=yookassa_service,
|
||||
i18n=i18n,
|
||||
current_lang=current_lang,
|
||||
get_text=get_text,
|
||||
user_id=user_id,
|
||||
months=months,
|
||||
price_rub=price_rub,
|
||||
currency_code_for_yk=currency_code_for_yk,
|
||||
save_payment_method=autopay_enabled and autopay_require_binding,
|
||||
back_callback=f"subscribe_period:{_format_value(months)}",
|
||||
sale_mode=sale_mode,
|
||||
)
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("pay_yk_new:"))
|
||||
async def pay_yk_new_card_handler(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, yookassa_service: YooKassaService, session: AsyncSession):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
if not i18n or not callback.message:
|
||||
try:
|
||||
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
if not yookassa_service or not yookassa_service.configured:
|
||||
logging.error("YooKassa service unavailable for pay_yk_new.")
|
||||
try:
|
||||
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await callback.message.edit_text(get_text("payment_service_unavailable"))
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
_, data_payload = callback.data.split(":", 1)
|
||||
except ValueError:
|
||||
logging.error(f"Invalid pay_yk_new data in callback: {callback.data}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
parsed = _parse_offer_payload(data_payload)
|
||||
if not parsed:
|
||||
logging.error(f"Invalid pay_yk_new payload structure: {callback.data}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
months, price_rub, sale_mode = parsed
|
||||
user_id = callback.from_user.id
|
||||
currency_code_for_yk = "RUB"
|
||||
autopay_enabled = bool(settings.yookassa_autopayments_active and sale_mode != "traffic" and not settings.traffic_sale_mode)
|
||||
autopay_require_binding = bool(
|
||||
getattr(settings, 'YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING', True)
|
||||
)
|
||||
|
||||
await _initiate_yk_payment(
|
||||
callback,
|
||||
settings=settings,
|
||||
session=session,
|
||||
yookassa_service=yookassa_service,
|
||||
i18n=i18n,
|
||||
current_lang=current_lang,
|
||||
get_text=get_text,
|
||||
user_id=user_id,
|
||||
months=months,
|
||||
price_rub=price_rub,
|
||||
currency_code_for_yk=currency_code_for_yk,
|
||||
save_payment_method=autopay_enabled and autopay_require_binding,
|
||||
back_callback=f"subscribe_period:{_format_value(months)}",
|
||||
sale_mode=sale_mode,
|
||||
)
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("pay_yk_saved_list:"))
|
||||
async def pay_yk_saved_list_handler(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, yookassa_service: YooKassaService, session: AsyncSession):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
if not i18n or not callback.message:
|
||||
try:
|
||||
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
_, data_payload = callback.data.split(":", 1)
|
||||
except ValueError:
|
||||
logging.error(f"Invalid pay_yk_saved_list data: {callback.data}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
parts = data_payload.split(":")
|
||||
if len(parts) < 2:
|
||||
logging.error(f"pay_yk_saved_list payload missing components: {callback.data}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
months = float(parts[0])
|
||||
price_rub = float(parts[1])
|
||||
page = int(parts[2]) if len(parts) > 2 else 0
|
||||
sale_mode = parts[3] if len(parts) > 3 else "subscription"
|
||||
except (ValueError, IndexError):
|
||||
logging.error(f"pay_yk_saved_list payload parsing error: {callback.data}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
autopay_enabled = bool(settings.yookassa_autopayments_active and sale_mode != "traffic" and not settings.traffic_sale_mode)
|
||||
if not autopay_enabled:
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
user_id = callback.from_user.id
|
||||
try:
|
||||
saved_methods = await user_billing_dal.list_user_payment_methods(
|
||||
session, user_id, provider="yookassa"
|
||||
)
|
||||
except Exception as e_list:
|
||||
logging.exception(f"Failed to list saved payment methods for user {user_id}: {e_list}")
|
||||
saved_methods = []
|
||||
|
||||
if not saved_methods:
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
get_text("yookassa_autopay_no_saved_cards"),
|
||||
reply_markup=get_yk_autopay_choice_keyboard(
|
||||
months,
|
||||
price_rub,
|
||||
current_lang,
|
||||
i18n,
|
||||
has_saved_cards=False,
|
||||
sale_mode=sale_mode,
|
||||
),
|
||||
)
|
||||
except Exception as e_edit:
|
||||
logging.warning(f"Failed to display no-saved-card notice: {e_edit}")
|
||||
try:
|
||||
await callback.message.answer(
|
||||
get_text("yookassa_autopay_no_saved_cards"),
|
||||
reply_markup=get_yk_autopay_choice_keyboard(
|
||||
months,
|
||||
price_rub,
|
||||
current_lang,
|
||||
i18n,
|
||||
has_saved_cards=False,
|
||||
sale_mode=sale_mode,
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
cards: List[Tuple[str, str]] = []
|
||||
for method in saved_methods:
|
||||
title = _format_saved_payment_method_title(
|
||||
get_text, method.card_network, method.card_last4, method.is_default
|
||||
)
|
||||
cards.append((str(method.method_id), title))
|
||||
|
||||
per_page = 5
|
||||
max_page = max(0, (len(cards) - 1) // per_page)
|
||||
page = max(0, min(page, max_page))
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
get_text("yookassa_autopay_choose_saved_card"),
|
||||
reply_markup=get_yk_saved_cards_keyboard(
|
||||
cards,
|
||||
months,
|
||||
price_rub,
|
||||
current_lang,
|
||||
i18n,
|
||||
page=page,
|
||||
sale_mode=sale_mode,
|
||||
),
|
||||
)
|
||||
except Exception as e_edit:
|
||||
logging.warning(f"Failed to display saved card list: {e_edit}")
|
||||
try:
|
||||
await callback.message.answer(
|
||||
get_text("yookassa_autopay_choose_saved_card"),
|
||||
reply_markup=get_yk_saved_cards_keyboard(
|
||||
cards,
|
||||
months,
|
||||
price_rub,
|
||||
current_lang,
|
||||
i18n,
|
||||
page=page,
|
||||
sale_mode=sale_mode,
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("pay_yk_use_saved:"))
|
||||
async def pay_yk_use_saved_handler(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, yookassa_service: YooKassaService, session: AsyncSession):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
if not i18n or not callback.message:
|
||||
try:
|
||||
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
if not yookassa_service or not yookassa_service.configured:
|
||||
logging.error("YooKassa service unavailable for pay_yk_use_saved.")
|
||||
try:
|
||||
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await callback.message.edit_text(get_text("payment_service_unavailable"))
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
_, data_payload = callback.data.split(":", 1)
|
||||
except ValueError:
|
||||
logging.error(f"Invalid pay_yk_use_saved data: {callback.data}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
parts = data_payload.split(":")
|
||||
if len(parts) < 3:
|
||||
logging.error(f"pay_yk_use_saved payload missing components: {callback.data}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
months = float(parts[0])
|
||||
price_rub = float(parts[1])
|
||||
sale_mode = parts[3] if len(parts) > 3 else "subscription"
|
||||
except (ValueError, IndexError):
|
||||
logging.error(f"pay_yk_use_saved months/price parsing error: {callback.data}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
autopay_enabled = bool(settings.yookassa_autopayments_active and sale_mode != "traffic" and not settings.traffic_sale_mode)
|
||||
if not autopay_enabled:
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
method_identifier = parts[2]
|
||||
user_id = callback.from_user.id
|
||||
|
||||
try:
|
||||
saved_methods = await user_billing_dal.list_user_payment_methods(
|
||||
session, user_id, provider="yookassa"
|
||||
)
|
||||
except Exception as e_list:
|
||||
logging.exception(f"Failed to list saved payment methods for user {user_id}: {e_list}")
|
||||
saved_methods = []
|
||||
|
||||
selected_method = None
|
||||
for method in saved_methods:
|
||||
if method_identifier.isdigit():
|
||||
if method.method_id == int(method_identifier):
|
||||
selected_method = method
|
||||
break
|
||||
if method.provider_payment_method_id == method_identifier:
|
||||
selected_method = method
|
||||
break
|
||||
|
||||
if not selected_method:
|
||||
logging.warning(f"Selected payment method not found for user {user_id}: {method_identifier}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
currency_code_for_yk = "RUB"
|
||||
|
||||
await _initiate_yk_payment(
|
||||
callback,
|
||||
settings=settings,
|
||||
session=session,
|
||||
yookassa_service=yookassa_service,
|
||||
i18n=i18n,
|
||||
current_lang=current_lang,
|
||||
get_text=get_text,
|
||||
user_id=user_id,
|
||||
months=months,
|
||||
price_rub=price_rub,
|
||||
currency_code_for_yk=currency_code_for_yk,
|
||||
save_payment_method=False,
|
||||
back_callback=f"pay_yk_saved_list:{_format_value(months)}:{price_rub}:{sale_mode}",
|
||||
payment_method_id=selected_method.provider_payment_method_id,
|
||||
selected_method_internal_id=selected_method.method_id,
|
||||
sale_mode=sale_mode,
|
||||
)
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
@@ -13,6 +13,7 @@ from bot.keyboards.inline.user_keyboards import (
|
||||
get_main_menu_inline_keyboard,
|
||||
get_connect_and_main_keyboard,
|
||||
)
|
||||
from bot.utils.config_link import prepare_config_links
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from .start import send_main_menu
|
||||
|
||||
@@ -76,7 +77,9 @@ async def request_trial_confirmation_handler(
|
||||
|
||||
final_message_text_in_chat = ""
|
||||
show_trial_button_after_action = False
|
||||
config_link_display_for_trial = None
|
||||
config_link_for_trial = None
|
||||
connect_button_url_for_trial = None
|
||||
|
||||
if activation_result and activation_result.get("activated"):
|
||||
try:
|
||||
@@ -85,9 +88,10 @@ async def request_trial_confirmation_handler(
|
||||
pass
|
||||
|
||||
end_date_obj = activation_result.get("end_date")
|
||||
config_link_for_trial = activation_result.get("subscription_url") or _(
|
||||
"config_link_not_available"
|
||||
config_link_display_for_trial, connect_button_url_for_trial = await prepare_config_links(
|
||||
settings, activation_result.get("subscription_url")
|
||||
)
|
||||
config_link_for_trial = config_link_display_for_trial or _("config_link_not_available")
|
||||
|
||||
traffic_gb_val = activation_result.get(
|
||||
"traffic_gb", settings.TRIAL_TRAFFIC_LIMIT_GB
|
||||
@@ -142,7 +146,11 @@ async def request_trial_confirmation_handler(
|
||||
|
||||
reply_markup = (
|
||||
get_connect_and_main_keyboard(
|
||||
current_lang, i18n, settings, config_link_for_trial
|
||||
current_lang,
|
||||
i18n,
|
||||
settings,
|
||||
config_link_display_for_trial,
|
||||
connect_button_url=connect_button_url_for_trial,
|
||||
)
|
||||
if activation_result and activation_result.get("activated")
|
||||
else get_main_menu_inline_keyboard(
|
||||
@@ -221,7 +229,9 @@ async def confirm_activate_trial_handler(
|
||||
|
||||
final_message_text_in_chat = ""
|
||||
show_trial_button_after_action = False
|
||||
config_link_display_for_trial = None
|
||||
config_link_for_trial = None
|
||||
connect_button_url_for_trial = None
|
||||
|
||||
if activation_result and activation_result.get("activated"):
|
||||
try:
|
||||
@@ -230,9 +240,10 @@ async def confirm_activate_trial_handler(
|
||||
pass
|
||||
|
||||
end_date_obj = activation_result.get("end_date")
|
||||
config_link_for_trial = activation_result.get("subscription_url") or _(
|
||||
"config_link_not_available"
|
||||
config_link_display_for_trial, connect_button_url_for_trial = await prepare_config_links(
|
||||
settings, activation_result.get("subscription_url")
|
||||
)
|
||||
config_link_for_trial = config_link_display_for_trial or _("config_link_not_available")
|
||||
|
||||
traffic_gb_val = activation_result.get(
|
||||
"traffic_gb", settings.TRIAL_TRAFFIC_LIMIT_GB
|
||||
@@ -275,7 +286,11 @@ async def confirm_activate_trial_handler(
|
||||
|
||||
reply_markup = (
|
||||
get_connect_and_main_keyboard(
|
||||
current_lang, i18n, settings, config_link_for_trial
|
||||
current_lang,
|
||||
i18n,
|
||||
settings,
|
||||
config_link_display_for_trial,
|
||||
connect_button_url=connect_button_url_for_trial,
|
||||
)
|
||||
if activation_result and activation_result.get("activated")
|
||||
else get_main_menu_inline_keyboard(
|
||||
|
||||
@@ -26,7 +26,7 @@ def get_admin_panel_keyboard(i18n_instance, lang: str,
|
||||
callback_data="admin_section:promo_marketing")
|
||||
|
||||
# Реклама
|
||||
builder.button(text=_(key="admin_ads_section", default="📈 Реклама"),
|
||||
builder.button(text=_(key="admin_ads_section"),
|
||||
callback_data="admin_action:ads")
|
||||
|
||||
# Системные функции
|
||||
@@ -43,7 +43,7 @@ def get_stats_monitoring_keyboard(i18n_instance, lang: str) -> InlineKeyboardMar
|
||||
|
||||
builder.button(text=_(key="admin_stats_button"),
|
||||
callback_data="admin_action:stats")
|
||||
builder.button(text=_(key="admin_view_payments_button", default="💰 Платежи"),
|
||||
builder.button(text=_(key="admin_view_payments_button"),
|
||||
callback_data="admin_action:view_payments")
|
||||
builder.button(text=_(key="admin_view_logs_menu_button"),
|
||||
callback_data="admin_action:view_logs_menu")
|
||||
@@ -59,13 +59,17 @@ def get_user_management_keyboard(i18n_instance, lang: str) -> InlineKeyboardMark
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
builder.button(text=_(key="admin_users_management_button"),
|
||||
callback_data="admin_action:users_management")
|
||||
callback_data="admin_action:users_list:0")
|
||||
builder.button(text=_(key="admin_users_search_button"),
|
||||
callback_data="admin_action:users_search_prompt")
|
||||
builder.button(text=_(key="admin_ban_management_section"),
|
||||
callback_data="admin_section:ban_management")
|
||||
builder.button(text=_(key="admin_user_ratings_button"),
|
||||
callback_data="admin_action:user_ratings")
|
||||
|
||||
builder.button(text=_(key="back_to_admin_panel_button"),
|
||||
callback_data="admin_action:main")
|
||||
builder.adjust(2, 1)
|
||||
builder.adjust(2, 2, 1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
@@ -123,7 +127,7 @@ def get_system_functions_keyboard(i18n_instance, lang: str) -> InlineKeyboardMar
|
||||
def get_ads_menu_keyboard(i18n_instance, lang: str) -> InlineKeyboardMarkup:
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text=_(key="admin_ads_create_button", default="➕ Создать кампанию"),
|
||||
builder.button(text=_(key="admin_ads_create_button"),
|
||||
callback_data="admin_action:ads_create")
|
||||
builder.button(text=_(key="back_to_admin_panel_button"),
|
||||
callback_data="admin_action:main")
|
||||
@@ -154,7 +158,7 @@ def get_ads_list_keyboard(
|
||||
if current_page > 0:
|
||||
row.append(
|
||||
InlineKeyboardButton(
|
||||
text="⬅️ " + _("prev_page_button", default="Prev"),
|
||||
text="⬅️ " + _("prev_page_button"),
|
||||
callback_data=f"admin_ads:page:{current_page - 1}",
|
||||
)
|
||||
)
|
||||
@@ -167,14 +171,14 @@ def get_ads_list_keyboard(
|
||||
if current_page < total_pages - 1:
|
||||
row.append(
|
||||
InlineKeyboardButton(
|
||||
text=_("next_page_button", default="Next") + " ➡️",
|
||||
text=_("next_page_button") + " ➡️",
|
||||
callback_data=f"admin_ads:page:{current_page + 1}",
|
||||
)
|
||||
)
|
||||
if row:
|
||||
builder.row(*row)
|
||||
|
||||
builder.button(text=_(key="admin_ads_create_button", default="➕ Создать кампанию"),
|
||||
builder.button(text=_(key="admin_ads_create_button"),
|
||||
callback_data="admin_action:ads_create")
|
||||
builder.button(text=_(key="back_to_admin_panel_button"),
|
||||
callback_data="admin_action:main")
|
||||
@@ -186,9 +190,9 @@ def get_ad_card_keyboard(i18n_instance, lang: str, campaign_id: int, back_page:
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
# Dangerous action: Delete campaign
|
||||
builder.button(text=_(key="admin_ads_delete_button", default="🗑 Удалить кампанию"),
|
||||
builder.button(text=_(key="admin_ads_delete_button"),
|
||||
callback_data=f"admin_ads:delete:{campaign_id}:{back_page}")
|
||||
builder.button(text=_(key="back_to_ads_list_button", default="⬅️ К списку"),
|
||||
builder.button(text=_(key="back_to_ads_list_button"),
|
||||
callback_data=f"admin_ads:page:{back_page}")
|
||||
builder.button(text=_(key="back_to_admin_panel_button"),
|
||||
callback_data="admin_action:main")
|
||||
@@ -225,12 +229,12 @@ def get_logs_pagination_keyboard(
|
||||
if current_page > 0:
|
||||
row_buttons.append(
|
||||
InlineKeyboardButton(
|
||||
text="⬅️ " + _("prev_page_button", default="Prev"),
|
||||
text="⬅️ " + _("prev_page_button"),
|
||||
callback_data=f"{base_callback_data}:{current_page - 1}"))
|
||||
if current_page < total_pages - 1:
|
||||
row_buttons.append(
|
||||
InlineKeyboardButton(
|
||||
text=_("next_page_button", default="Next") + " ➡️",
|
||||
text=_("next_page_button") + " ➡️",
|
||||
callback_data=f"{base_callback_data}:{current_page + 1}"))
|
||||
|
||||
if row_buttons: builder.row(*row_buttons)
|
||||
@@ -305,6 +309,68 @@ def get_banned_users_keyboard(banned_users: List[User], current_page: int,
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def get_users_list_keyboard(users: List[User], current_page: int,
|
||||
total_users: int, i18n_instance, lang: str,
|
||||
page_size: int = 15) -> InlineKeyboardMarkup:
|
||||
"""Generate keyboard for paginated user list"""
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
# Add user buttons
|
||||
for user in users:
|
||||
user_display_parts = []
|
||||
if user.username:
|
||||
user_display_parts.append(f"@{user.username}")
|
||||
user_display_parts.append(f"ID: {user.user_id}")
|
||||
if user.first_name:
|
||||
user_display_parts.append(f"- {user.first_name}")
|
||||
|
||||
button_text = " ".join(user_display_parts)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=button_text,
|
||||
callback_data=f"admin_user_card_from_list:{user.user_id}:{current_page}"
|
||||
)
|
||||
)
|
||||
|
||||
# Pagination buttons
|
||||
if total_users > page_size:
|
||||
total_pages = math.ceil(total_users / page_size)
|
||||
pagination_buttons = []
|
||||
if current_page > 0:
|
||||
pagination_buttons.append(
|
||||
InlineKeyboardButton(
|
||||
text=_("prev_page_button"),
|
||||
callback_data=f"admin_action:users_list:{current_page - 1}"
|
||||
)
|
||||
)
|
||||
pagination_buttons.append(
|
||||
InlineKeyboardButton(
|
||||
text=f"{current_page + 1}/{total_pages}",
|
||||
callback_data="stub_page_display"
|
||||
)
|
||||
)
|
||||
if current_page < total_pages - 1:
|
||||
pagination_buttons.append(
|
||||
InlineKeyboardButton(
|
||||
text=_("next_page_button"),
|
||||
callback_data=f"admin_action:users_list:{current_page + 1}"
|
||||
)
|
||||
)
|
||||
if pagination_buttons:
|
||||
builder.row(*pagination_buttons)
|
||||
|
||||
# Back button
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_("back_to_user_management_button"),
|
||||
callback_data="admin_section:user_management"
|
||||
)
|
||||
)
|
||||
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def get_user_card_keyboard(user_id: int,
|
||||
is_banned: bool,
|
||||
i18n_instance,
|
||||
@@ -320,6 +386,12 @@ def get_user_card_keyboard(user_id: int,
|
||||
builder.button(
|
||||
text=_(key="user_card_ban_button"),
|
||||
callback_data=f"admin_ban_confirm:{user_id}:{banned_list_page}")
|
||||
builder.button(
|
||||
text=_(
|
||||
key="user_card_open_profile_button"
|
||||
),
|
||||
url=f"tg://user?id={user_id}"
|
||||
)
|
||||
builder.button(
|
||||
text=_(key="user_card_back_to_banned_list_button"),
|
||||
callback_data=f"admin_action:view_banned:{banned_list_page}")
|
||||
@@ -347,16 +419,13 @@ def get_broadcast_confirmation_keyboard(lang: str,
|
||||
|
||||
# Row: target selection (all / active / inactive)
|
||||
target_all_label = _(
|
||||
key="broadcast_target_all_button",
|
||||
default="👥 Все"
|
||||
key="broadcast_target_all_button"
|
||||
)
|
||||
target_active_label = _(
|
||||
key="broadcast_target_active_button",
|
||||
default="✅ Активные"
|
||||
key="broadcast_target_active_button"
|
||||
)
|
||||
target_inactive_label = _(
|
||||
key="broadcast_target_inactive_button",
|
||||
default="⌛ Неактивные"
|
||||
key="broadcast_target_inactive_button"
|
||||
)
|
||||
|
||||
# Highlight current selection with a prefix
|
||||
@@ -378,9 +447,9 @@ def get_broadcast_confirmation_keyboard(lang: str,
|
||||
builder.adjust(3)
|
||||
|
||||
# Row: confirmation
|
||||
builder.button(text=_(key="confirm_broadcast_send_button", default="🚀 Отправить"),
|
||||
builder.button(text=_(key="confirm_broadcast_send_button"),
|
||||
callback_data="broadcast_final_action:send")
|
||||
builder.button(text=_(key="cancel_broadcast_button", default="❌ Отмена"),
|
||||
builder.button(text=_(key="cancel_broadcast_button"),
|
||||
callback_data="broadcast_final_action:cancel")
|
||||
builder.adjust(2)
|
||||
return builder.as_markup()
|
||||
@@ -393,3 +462,21 @@ def get_back_to_admin_panel_keyboard(lang: str,
|
||||
builder.button(text=_(key="back_to_admin_panel_button"),
|
||||
callback_data="admin_action:main")
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def get_back_to_stats_monitoring_keyboard(lang: str,
|
||||
i18n_instance) -> InlineKeyboardMarkup:
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text=_(key="back_to_stats_monitoring_button"),
|
||||
callback_data="admin_section:stats_monitoring")
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def get_back_to_user_management_keyboard(lang: str,
|
||||
i18n_instance) -> InlineKeyboardMarkup:
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text=_(key="back_to_user_management_button"),
|
||||
callback_data="admin_section:user_management")
|
||||
return builder.as_markup()
|
||||
|
||||
@@ -34,7 +34,8 @@ def get_main_menu_inline_keyboard(
|
||||
promo_button = InlineKeyboardButton(
|
||||
text=_(key="menu_apply_promo_button"),
|
||||
callback_data="main_action:apply_promo")
|
||||
builder.row(referral_button, promo_button)
|
||||
builder.row(referral_button)
|
||||
builder.row(promo_button)
|
||||
|
||||
language_button = InlineKeyboardButton(
|
||||
text=_(key="menu_language_settings_inline"),
|
||||
@@ -55,14 +56,36 @@ def get_main_menu_inline_keyboard(
|
||||
InlineKeyboardButton(text=_(key="menu_support_button"),
|
||||
url=settings.SUPPORT_LINK))
|
||||
|
||||
if settings.TERMS_OF_SERVICE_URL:
|
||||
user_agreement_url = settings.USER_AGREEMENT_URL or settings.TERMS_OF_SERVICE_URL
|
||||
if settings.PRIVACY_POLICY_URL or user_agreement_url:
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=_(key="menu_terms_button"),
|
||||
url=settings.TERMS_OF_SERVICE_URL))
|
||||
InlineKeyboardButton(text=_(key="menu_info_button"),
|
||||
callback_data="main_action:info"))
|
||||
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def get_information_links_keyboard(
|
||||
lang: str,
|
||||
i18n_instance,
|
||||
privacy_policy_url: Optional[str],
|
||||
user_agreement_url: Optional[str]) -> InlineKeyboardMarkup:
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
if privacy_policy_url:
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=_(key="privacy_policy_button"),
|
||||
url=privacy_policy_url))
|
||||
if user_agreement_url:
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=_(key="user_agreement_button"),
|
||||
url=user_agreement_url))
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=_(key="back_to_main_menu_button"),
|
||||
callback_data="main_action:back_to_main"))
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def get_language_selection_keyboard(i18n_instance,
|
||||
current_lang: str) -> InlineKeyboardMarkup:
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(current_lang, key, **kwargs
|
||||
@@ -91,19 +114,31 @@ def get_trial_confirmation_keyboard(lang: str,
|
||||
|
||||
|
||||
def get_subscription_options_keyboard(subscription_options: Dict[
|
||||
int, Optional[int]], currency_symbol_val: str, lang: str,
|
||||
i18n_instance) -> InlineKeyboardMarkup:
|
||||
float, Optional[float]], currency_symbol_val: str, lang: str,
|
||||
i18n_instance, traffic_mode: bool = False) -> InlineKeyboardMarkup:
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
def _format_gb(val: float) -> str:
|
||||
return str(int(val)) if float(val).is_integer() else f"{val:g}"
|
||||
if subscription_options:
|
||||
for months, price in subscription_options.items():
|
||||
if price is not None:
|
||||
button_text = _("subscribe_for_months_button",
|
||||
months=months,
|
||||
price=price,
|
||||
currency_symbol=currency_symbol_val)
|
||||
if traffic_mode:
|
||||
button_text = _(
|
||||
"buy_traffic_package_button",
|
||||
traffic_gb=_format_gb(months),
|
||||
price=price,
|
||||
currency_symbol=currency_symbol_val,
|
||||
)
|
||||
callback_data = f"subscribe_period:{_format_gb(months)}"
|
||||
else:
|
||||
button_text = _("subscribe_for_months_button",
|
||||
months=months,
|
||||
price=price,
|
||||
currency_symbol=currency_symbol_val)
|
||||
callback_data = f"subscribe_period:{months}"
|
||||
builder.button(text=button_text,
|
||||
callback_data=f"subscribe_period:{months}")
|
||||
callback_data=callback_data)
|
||||
builder.adjust(1)
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=_(key="back_to_main_menu_button"),
|
||||
@@ -112,26 +147,46 @@ def get_subscription_options_keyboard(subscription_options: Dict[
|
||||
|
||||
|
||||
def get_payment_method_keyboard(months: int, price: float,
|
||||
tribute_url: Optional[str],
|
||||
stars_price: Optional[int],
|
||||
currency_symbol_val: str, lang: str,
|
||||
i18n_instance, settings: Settings) -> InlineKeyboardMarkup:
|
||||
i18n_instance, settings: Settings, sale_mode: str = "subscription") -> InlineKeyboardMarkup:
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
if settings.FREEKASSA_ENABLED:
|
||||
builder.button(text=_("pay_with_sbp_button"),
|
||||
callback_data=f"pay_fk:{months}:{price}")
|
||||
if settings.YOOKASSA_ENABLED:
|
||||
builder.button(text=_("pay_with_yookassa_button"),
|
||||
callback_data=f"pay_yk:{months}:{price}")
|
||||
if settings.TRIBUTE_ENABLED and tribute_url:
|
||||
builder.button(text=_("pay_with_tribute_button"), url=tribute_url)
|
||||
if settings.STARS_ENABLED and stars_price is not None:
|
||||
builder.button(text=_("pay_with_stars_button"),
|
||||
callback_data=f"pay_stars:{months}:{stars_price}")
|
||||
if settings.CRYPTOPAY_ENABLED:
|
||||
builder.button(text=_("pay_with_cryptopay_button"),
|
||||
callback_data=f"pay_crypto:{months}:{price}")
|
||||
def _format_value(val: float) -> str:
|
||||
return str(int(val)) if float(val).is_integer() else f"{val:g}"
|
||||
value_str = _format_value(months)
|
||||
mode_suffix = f":{sale_mode}"
|
||||
for method in settings.payment_methods_order:
|
||||
if method == "severpay" and getattr(settings, "SEVERPAY_ENABLED", False):
|
||||
builder.button(
|
||||
text=_("pay_with_severpay_button"),
|
||||
callback_data=f"pay_severpay:{value_str}:{price}{mode_suffix}",
|
||||
)
|
||||
elif method == "freekassa" and settings.FREEKASSA_ENABLED:
|
||||
builder.button(
|
||||
text=_("pay_with_sbp_button"),
|
||||
callback_data=f"pay_fk:{value_str}:{price}{mode_suffix}",
|
||||
)
|
||||
elif method == "platega" and settings.PLATEGA_ENABLED:
|
||||
builder.button(
|
||||
text=_("pay_with_platega_button"),
|
||||
callback_data=f"pay_platega:{value_str}:{price}{mode_suffix}",
|
||||
)
|
||||
elif method == "yookassa" and settings.YOOKASSA_ENABLED:
|
||||
builder.button(
|
||||
text=_("pay_with_yookassa_button"),
|
||||
callback_data=f"pay_yk:{value_str}:{price}{mode_suffix}",
|
||||
)
|
||||
elif method == "stars" and settings.STARS_ENABLED and stars_price is not None:
|
||||
builder.button(
|
||||
text=_("pay_with_stars_button"),
|
||||
callback_data=f"pay_stars:{value_str}:{stars_price}{mode_suffix}",
|
||||
)
|
||||
elif method == "cryptopay" and settings.CRYPTOPAY_ENABLED:
|
||||
builder.button(
|
||||
text=_("pay_with_cryptopay_button"),
|
||||
callback_data=f"pay_crypto:{value_str}:{price}{mode_suffix}",
|
||||
)
|
||||
builder.button(text=_(key="cancel_button"),
|
||||
callback_data="main_action:subscribe")
|
||||
builder.adjust(1)
|
||||
@@ -162,28 +217,33 @@ def get_yk_autopay_choice_keyboard(
|
||||
lang: str,
|
||||
i18n_instance,
|
||||
has_saved_cards: bool = True,
|
||||
sale_mode: str = "subscription",
|
||||
) -> InlineKeyboardMarkup:
|
||||
"""Keyboard for choosing between saved card charge or new card payment when auto-renew is enabled."""
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
price_str = str(price)
|
||||
def _format_value(val: float) -> str:
|
||||
return str(int(val)) if float(val).is_integer() else f"{val:g}"
|
||||
value_str = _format_value(months)
|
||||
suffix = f":{sale_mode}"
|
||||
if has_saved_cards:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_(key="yookassa_autopay_pay_saved_card_button"),
|
||||
callback_data=f"pay_yk_saved_list:{months}:{price_str}",
|
||||
callback_data=f"pay_yk_saved_list:{value_str}:{price_str}{suffix}",
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_(key="yookassa_autopay_pay_new_card_button"),
|
||||
callback_data=f"pay_yk_new:{months}:{price_str}",
|
||||
callback_data=f"pay_yk_new:{value_str}:{price_str}{suffix}",
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_(key="back_to_payment_methods_button"),
|
||||
callback_data=f"subscribe_period:{months}",
|
||||
callback_data=f"subscribe_period:{value_str}",
|
||||
)
|
||||
)
|
||||
return builder.as_markup()
|
||||
@@ -196,6 +256,7 @@ def get_yk_saved_cards_keyboard(
|
||||
lang: str,
|
||||
i18n_instance,
|
||||
page: int = 0,
|
||||
sale_mode: str = "subscription",
|
||||
) -> InlineKeyboardMarkup:
|
||||
"""Paginated keyboard for selecting a saved YooKassa card."""
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
@@ -205,12 +266,16 @@ def get_yk_saved_cards_keyboard(
|
||||
start = page * per_page
|
||||
end = min(total, start + per_page)
|
||||
price_str = str(price)
|
||||
def _format_value(val: float) -> str:
|
||||
return str(int(val)) if float(val).is_integer() else f"{val:g}"
|
||||
value_str = _format_value(months)
|
||||
suffix = f":{sale_mode}"
|
||||
|
||||
for method_id, title in cards[start:end]:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=title,
|
||||
callback_data=f"pay_yk_use_saved:{months}:{price_str}:{method_id}",
|
||||
callback_data=f"pay_yk_use_saved:{value_str}:{price_str}:{method_id}{suffix}",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -219,14 +284,14 @@ def get_yk_saved_cards_keyboard(
|
||||
nav_buttons.append(
|
||||
InlineKeyboardButton(
|
||||
text="⬅️",
|
||||
callback_data=f"pay_yk_saved_list:{months}:{price_str}:{page-1}",
|
||||
callback_data=f"pay_yk_saved_list:{value_str}:{price_str}:{page-1}{suffix}",
|
||||
)
|
||||
)
|
||||
if end < total:
|
||||
nav_buttons.append(
|
||||
InlineKeyboardButton(
|
||||
text="➡️",
|
||||
callback_data=f"pay_yk_saved_list:{months}:{price_str}:{page+1}",
|
||||
callback_data=f"pay_yk_saved_list:{value_str}:{price_str}:{page+1}{suffix}",
|
||||
)
|
||||
)
|
||||
if nav_buttons:
|
||||
@@ -235,13 +300,13 @@ def get_yk_saved_cards_keyboard(
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_(key="yookassa_autopay_pay_new_card_button"),
|
||||
callback_data=f"pay_yk_new:{months}:{price_str}",
|
||||
callback_data=f"pay_yk_new:{value_str}:{price_str}{suffix}",
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_(key="back_to_autopay_method_choice_button"),
|
||||
callback_data=f"pay_yk:{months}:{price_str}",
|
||||
callback_data=f"pay_yk:{value_str}:{price_str}{suffix}",
|
||||
)
|
||||
)
|
||||
return builder.as_markup()
|
||||
@@ -333,10 +398,12 @@ def get_connect_and_main_keyboard(
|
||||
i18n_instance,
|
||||
settings: Settings,
|
||||
config_link: Optional[str],
|
||||
connect_button_url: Optional[str] = None,
|
||||
preserve_message: bool = False) -> InlineKeyboardMarkup:
|
||||
"""Keyboard with a connect button and a back to main menu button."""
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
button_target = connect_button_url or config_link
|
||||
|
||||
if settings.SUBSCRIPTION_MINI_APP_URL:
|
||||
builder.row(
|
||||
@@ -345,9 +412,9 @@ def get_connect_and_main_keyboard(
|
||||
web_app=WebAppInfo(url=settings.SUBSCRIPTION_MINI_APP_URL),
|
||||
)
|
||||
)
|
||||
elif config_link:
|
||||
elif button_target:
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=_("connect_button"), url=config_link)
|
||||
InlineKeyboardButton(text=_("connect_button"), url=button_target)
|
||||
)
|
||||
else:
|
||||
builder.row(
|
||||
|
||||
+3
-2
@@ -33,7 +33,6 @@ from bot.services.subscription_service import SubscriptionService
|
||||
from bot.services.referral_service import ReferralService
|
||||
from bot.services.promo_code_service import PromoCodeService
|
||||
from bot.services.stars_service import StarsService
|
||||
from bot.services.tribute_service import TributeService, tribute_webhook_route
|
||||
from bot.services.crypto_pay_service import CryptoPayService, cryptopay_webhook_route
|
||||
|
||||
from bot.handlers.user import payment as user_payment_webhook_module
|
||||
@@ -200,13 +199,15 @@ async def on_shutdown_configured(dispatcher: Dispatcher):
|
||||
"panel_service",
|
||||
"cryptopay_service",
|
||||
"freekassa_service",
|
||||
"tribute_service",
|
||||
"panel_webhook_service",
|
||||
"yookassa_service",
|
||||
"lknpd_service",
|
||||
"promo_code_service",
|
||||
"stars_service",
|
||||
"subscription_service",
|
||||
"referral_service",
|
||||
"platega_service",
|
||||
"severpay_service",
|
||||
):
|
||||
await close_service(service_key)
|
||||
|
||||
|
||||
@@ -40,6 +40,14 @@ class ChannelSubscriptionMiddleware(BaseMiddleware):
|
||||
if not event_user or event_user.id in self.settings.ADMIN_IDS:
|
||||
return await handler(event, data)
|
||||
|
||||
callback_query = event.callback_query
|
||||
if (
|
||||
callback_query
|
||||
and callback_query.data
|
||||
and callback_query.data == "channel_subscription:verify"
|
||||
):
|
||||
return await handler(event, data)
|
||||
|
||||
# Allow /start to reach the handler so the check can be re-run.
|
||||
message_object: Optional[Message] = event.message
|
||||
if (
|
||||
|
||||
@@ -17,6 +17,7 @@ from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
|
||||
from bot.services.notification_service import NotificationService
|
||||
from db.dal import payment_dal, user_dal
|
||||
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
|
||||
from bot.utils.config_link import prepare_config_links
|
||||
|
||||
|
||||
class CryptoPayService:
|
||||
@@ -63,6 +64,7 @@ class CryptoPayService:
|
||||
months: int,
|
||||
amount: float,
|
||||
description: str,
|
||||
sale_mode: str = "subscription",
|
||||
) -> Optional[str]:
|
||||
if not self.configured or not self.client:
|
||||
logging.error("CryptoPayService not configured")
|
||||
@@ -78,7 +80,7 @@ class CryptoPayService:
|
||||
"currency": self.settings.CRYPTOPAY_ASSET,
|
||||
"status": "pending_cryptopay",
|
||||
"description": description,
|
||||
"subscription_duration_months": months,
|
||||
"subscription_duration_months": int(months),
|
||||
"provider": "cryptopay",
|
||||
},
|
||||
)
|
||||
@@ -94,6 +96,8 @@ class CryptoPayService:
|
||||
"user_id": str(user_id),
|
||||
"subscription_months": str(months),
|
||||
"payment_db_id": str(payment_record.payment_id),
|
||||
"sale_mode": sale_mode,
|
||||
"traffic_gb": str(months) if sale_mode == "traffic" else None,
|
||||
})
|
||||
try:
|
||||
invoice = await self.client.create_invoice(
|
||||
@@ -132,8 +136,10 @@ class CryptoPayService:
|
||||
try:
|
||||
meta = json.loads(invoice.payload)
|
||||
user_id = int(meta["user_id"])
|
||||
months = int(meta["subscription_months"])
|
||||
months = float(meta.get("subscription_months") or 0)
|
||||
payment_db_id = int(meta["payment_db_id"])
|
||||
sale_mode = meta.get("sale_mode") or ("traffic" if self.settings.traffic_sale_mode else "subscription")
|
||||
traffic_gb = float(meta.get("traffic_gb")) if meta.get("traffic_gb") else months
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to parse CryptoPay payload: {e}")
|
||||
return
|
||||
@@ -156,18 +162,22 @@ class CryptoPayService:
|
||||
activation = await subscription_service.activate_subscription(
|
||||
session,
|
||||
user_id,
|
||||
months,
|
||||
int(months) if sale_mode != "traffic" else 0,
|
||||
float(invoice.amount),
|
||||
payment_db_id,
|
||||
provider="cryptopay",
|
||||
sale_mode=sale_mode,
|
||||
traffic_gb=traffic_gb if sale_mode == "traffic" else None,
|
||||
)
|
||||
referral_bonus = await referral_service.apply_referral_bonuses_for_payment(
|
||||
session,
|
||||
user_id,
|
||||
months,
|
||||
current_payment_db_id=payment_db_id,
|
||||
skip_if_active_before_payment=False,
|
||||
)
|
||||
referral_bonus = None
|
||||
if sale_mode != "traffic":
|
||||
referral_bonus = await referral_service.apply_referral_bonuses_for_payment(
|
||||
session,
|
||||
user_id,
|
||||
int(months) or 1,
|
||||
current_payment_db_id=payment_db_id,
|
||||
skip_if_active_before_payment=False,
|
||||
)
|
||||
await session.commit()
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
@@ -179,14 +189,21 @@ class CryptoPayService:
|
||||
lang = db_user.language_code if db_user and db_user.language_code else settings.DEFAULT_LANGUAGE
|
||||
_ = lambda k, **kw: i18n.gettext(lang, k, **kw)
|
||||
|
||||
config_link = activation.get("subscription_url") or _("config_link_not_available")
|
||||
raw_config_link = activation.get("subscription_url") if activation else None
|
||||
display_link, button_link = await prepare_config_links(settings, raw_config_link)
|
||||
config_link_text = display_link or _("config_link_not_available")
|
||||
final_end = activation.get("end_date")
|
||||
applied_days = 0
|
||||
if referral_bonus and referral_bonus.get("referee_new_end_date"):
|
||||
final_end = referral_bonus["referee_new_end_date"]
|
||||
applied_days = referral_bonus.get("referee_bonus_applied_days", 0)
|
||||
|
||||
if applied_days:
|
||||
if sale_mode == "traffic":
|
||||
text = _("payment_successful_traffic_full",
|
||||
traffic_gb=str(int(traffic_gb)) if float(traffic_gb).is_integer() else f"{traffic_gb:g}",
|
||||
end_date=final_end.strftime('%Y-%m-%d') if final_end else "—",
|
||||
config_link=config_link_text)
|
||||
elif applied_days:
|
||||
inviter_name_display = _("friend_placeholder")
|
||||
if db_user and db_user.referred_by_id:
|
||||
inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id)
|
||||
@@ -197,20 +214,25 @@ class CryptoPayService:
|
||||
elif inviter.username:
|
||||
inviter_name_display = username_for_display(inviter.username, with_at=False)
|
||||
text = _("payment_successful_with_referral_bonus_full",
|
||||
months=months,
|
||||
months=int(months),
|
||||
base_end_date=activation["end_date"].strftime('%Y-%m-%d'),
|
||||
bonus_days=applied_days,
|
||||
final_end_date=final_end.strftime('%Y-%m-%d'),
|
||||
inviter_name=inviter_name_display,
|
||||
config_link=config_link)
|
||||
config_link=config_link_text)
|
||||
else:
|
||||
text = _("payment_successful_full",
|
||||
months=months,
|
||||
end_date=final_end.strftime('%Y-%m-%d'),
|
||||
config_link=config_link)
|
||||
months=int(months),
|
||||
end_date=final_end.strftime('%Y-%m-%d') if final_end else "—",
|
||||
config_link=config_link_text)
|
||||
|
||||
markup = get_connect_and_main_keyboard(
|
||||
lang, i18n, settings, config_link, preserve_message=True
|
||||
lang,
|
||||
i18n,
|
||||
settings,
|
||||
display_link,
|
||||
connect_button_url=button_link,
|
||||
preserve_message=True,
|
||||
)
|
||||
try:
|
||||
await bot.send_message(
|
||||
@@ -231,7 +253,8 @@ class CryptoPayService:
|
||||
user_id=user_id,
|
||||
amount=float(invoice.amount),
|
||||
currency=invoice.asset or settings.DEFAULT_CURRENCY_SYMBOL,
|
||||
months=months,
|
||||
months=int(months) if sale_mode != "traffic" else 0,
|
||||
traffic_gb=traffic_gb if sale_mode == "traffic" else None,
|
||||
payment_provider="crypto_pay",
|
||||
username=user.username if user else None
|
||||
)
|
||||
|
||||
@@ -20,6 +20,7 @@ from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
|
||||
from bot.services.notification_service import NotificationService
|
||||
from db.dal import payment_dal, user_dal
|
||||
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
|
||||
from bot.utils.config_link import prepare_config_links
|
||||
|
||||
|
||||
class FreeKassaService:
|
||||
@@ -45,6 +46,7 @@ class FreeKassaService:
|
||||
self.second_secret: Optional[str] = settings.FREEKASSA_SECOND_SECRET
|
||||
self.default_currency: str = (settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
|
||||
self.server_ip: Optional[str] = settings.FREEKASSA_PAYMENT_IP
|
||||
self.payment_method_id: Optional[int] = settings.FREEKASSA_PAYMENT_METHOD_ID
|
||||
|
||||
self.api_base_url: str = "https://api.fk.life/v1"
|
||||
self._timeout = ClientTimeout(total=15)
|
||||
@@ -72,9 +74,9 @@ class FreeKassaService:
|
||||
months: int,
|
||||
amount: float,
|
||||
currency: Optional[str],
|
||||
method_code: int,
|
||||
email: Optional[str] = None,
|
||||
ip_address: Optional[str] = None,
|
||||
payment_method_id: Optional[int] = None,
|
||||
extra_params: Optional[Dict[str, Any]] = None,
|
||||
) -> Tuple[bool, Dict[str, Any]]:
|
||||
if not self.configured:
|
||||
@@ -94,7 +96,7 @@ class FreeKassaService:
|
||||
"shopId": int(self.shop_id),
|
||||
"nonce": await self._generate_nonce(),
|
||||
"paymentId": str(payment_db_id),
|
||||
"i": int(method_code),
|
||||
"i": int(payment_method_id),
|
||||
"amount": amount_str,
|
||||
"currency": currency_code,
|
||||
"email": email,
|
||||
@@ -283,23 +285,28 @@ class FreeKassaService:
|
||||
)
|
||||
|
||||
months = payment.subscription_duration_months or 1
|
||||
sale_mode = "traffic" if self.settings.traffic_sale_mode else "subscription"
|
||||
|
||||
activation = await self.subscription_service.activate_subscription(
|
||||
session,
|
||||
payment.user_id,
|
||||
months,
|
||||
int(months) if sale_mode != "traffic" else 0,
|
||||
float(payment.amount),
|
||||
payment.payment_id,
|
||||
provider="freekassa",
|
||||
sale_mode=sale_mode,
|
||||
traffic_gb=months if sale_mode == "traffic" else None,
|
||||
)
|
||||
|
||||
referral_bonus = await self.referral_service.apply_referral_bonuses_for_payment(
|
||||
session,
|
||||
payment.user_id,
|
||||
months,
|
||||
current_payment_db_id=payment.payment_id,
|
||||
skip_if_active_before_payment=False,
|
||||
)
|
||||
referral_bonus = None
|
||||
if sale_mode != "traffic":
|
||||
referral_bonus = await self.referral_service.apply_referral_bonuses_for_payment(
|
||||
session,
|
||||
payment.user_id,
|
||||
int(months),
|
||||
current_payment_db_id=payment.payment_id,
|
||||
skip_if_active_before_payment=False,
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
except Exception as e:
|
||||
@@ -311,12 +318,12 @@ class FreeKassaService:
|
||||
lang = db_user.language_code if db_user and db_user.language_code else self.settings.DEFAULT_LANGUAGE
|
||||
_ = lambda k, **kw: self.i18n.gettext(lang, k, **kw) if self.i18n else k
|
||||
|
||||
config_link = None
|
||||
final_end = None
|
||||
raw_config_link = activation.get("subscription_url") if activation else None
|
||||
config_link_display, connect_button_url = await prepare_config_links(self.settings, raw_config_link)
|
||||
config_link_text = config_link_display or _("config_link_not_available")
|
||||
final_end = activation.get("end_date") if activation else None
|
||||
months = payment.subscription_duration_months or 1
|
||||
if activation:
|
||||
config_link = activation.get("subscription_url")
|
||||
final_end = activation.get("end_date")
|
||||
sale_mode = "traffic" if self.settings.traffic_sale_mode else "subscription"
|
||||
|
||||
applied_days = 0
|
||||
if referral_bonus and referral_bonus.get("referee_new_end_date"):
|
||||
@@ -326,14 +333,19 @@ class FreeKassaService:
|
||||
if not final_end and activation and activation.get("end_date"):
|
||||
final_end = activation["end_date"]
|
||||
|
||||
if not config_link:
|
||||
config_link = _("config_link_not_available")
|
||||
if final_end:
|
||||
end_date_str = final_end.strftime("%Y-%m-%d")
|
||||
else:
|
||||
end_date_str = _("config_link_not_available")
|
||||
|
||||
if applied_days:
|
||||
traffic_label = str(int(months)) if float(months).is_integer() else f"{months:g}"
|
||||
|
||||
if sale_mode == "traffic":
|
||||
text = _("payment_successful_traffic_full",
|
||||
traffic_gb=traffic_label,
|
||||
end_date=end_date_str if final_end else "",
|
||||
config_link=config_link_text)
|
||||
elif applied_days:
|
||||
inviter_name_display = _("friend_placeholder")
|
||||
if db_user and db_user.referred_by_id:
|
||||
inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id)
|
||||
@@ -350,14 +362,14 @@ class FreeKassaService:
|
||||
bonus_days=applied_days,
|
||||
final_end_date=end_date_str,
|
||||
inviter_name=inviter_name_display,
|
||||
config_link=config_link,
|
||||
config_link=config_link_text,
|
||||
)
|
||||
else:
|
||||
text = _(
|
||||
"payment_successful_full",
|
||||
months=months,
|
||||
end_date=end_date_str,
|
||||
config_link=config_link,
|
||||
config_link=config_link_text,
|
||||
)
|
||||
if provider_payment_id:
|
||||
order_info_text = _(
|
||||
@@ -371,7 +383,8 @@ class FreeKassaService:
|
||||
lang,
|
||||
self.i18n,
|
||||
self.settings,
|
||||
config_link,
|
||||
config_link_display,
|
||||
connect_button_url=connect_button_url,
|
||||
preserve_message=True,
|
||||
)
|
||||
try:
|
||||
@@ -391,7 +404,8 @@ class FreeKassaService:
|
||||
user_id=payment.user_id,
|
||||
amount=float(payment.amount),
|
||||
currency=self.default_currency,
|
||||
months=months,
|
||||
months=int(months) if sale_mode != "traffic" else 0,
|
||||
traffic_gb=months if sale_mode == "traffic" else None,
|
||||
payment_provider="freekassa",
|
||||
username=db_user.username if db_user else None,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
"""
|
||||
LKNPD API client for self-employed (NPD) tax receipts.
|
||||
Custom implementation for lknpd.nalog.ru API.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from decimal import Decimal
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PaymentType(str, Enum):
|
||||
"""Payment type for income registration."""
|
||||
CASH = "CASH"
|
||||
WIRE = "WIRE"
|
||||
|
||||
|
||||
class IncomeType(str, Enum):
|
||||
"""Income source type."""
|
||||
FROM_INDIVIDUAL = "FROM_INDIVIDUAL"
|
||||
FROM_LEGAL_ENTITY = "FROM_LEGAL_ENTITY"
|
||||
FROM_FOREIGN_AGENCY = "FROM_FOREIGN_AGENCY"
|
||||
|
||||
|
||||
class LknpdApiError(Exception):
|
||||
"""Base exception for LKNPD API errors."""
|
||||
def __init__(self, message: str, status_code: int | None = None):
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
class LknpdAuthError(LknpdApiError):
|
||||
"""Authentication error (401)."""
|
||||
pass
|
||||
|
||||
|
||||
class LknpdValidationError(LknpdApiError):
|
||||
"""Validation error (400)."""
|
||||
pass
|
||||
|
||||
|
||||
def _generate_device_id() -> str:
|
||||
"""Generate device ID for API requests."""
|
||||
return str(uuid.uuid4()).replace("-", "")[:21].lower()
|
||||
|
||||
|
||||
def _format_datetime(dt: datetime) -> str:
|
||||
"""Format datetime to ISO/ATOM format with Z suffix."""
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=UTC)
|
||||
elif dt.tzinfo != UTC:
|
||||
dt = dt.astimezone(UTC)
|
||||
return dt.isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
class LknpdClient:
|
||||
"""
|
||||
Async client for LKNPD (lknpd.nalog.ru) self-employed API.
|
||||
|
||||
Supports:
|
||||
- INN + password authentication
|
||||
- Token refresh
|
||||
- Income registration with proper payment types (CASH/WIRE)
|
||||
"""
|
||||
|
||||
DEFAULT_HEADERS = {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
"Accept-Language": "ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7",
|
||||
"Referrer": "https://lknpd.nalog.ru/auth/login",
|
||||
}
|
||||
|
||||
DEVICE_INFO_TEMPLATE = {
|
||||
"sourceType": "WEB",
|
||||
"appVersion": "1.0.0",
|
||||
"metaDetails": {
|
||||
"userAgent": (
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 11_2_2) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/88.0.4324.192 Safari/537.36"
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str = "https://lknpd.nalog.ru/api",
|
||||
timeout: float = 10.0,
|
||||
):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.timeout = timeout
|
||||
self.device_id = _generate_device_id()
|
||||
self._token_data: dict[str, Any] | None = None
|
||||
self._refresh_lock = asyncio.Lock()
|
||||
|
||||
def _get_device_info(self) -> dict[str, Any]:
|
||||
"""Get device info with current device ID."""
|
||||
info = self.DEVICE_INFO_TEMPLATE.copy()
|
||||
info["sourceDeviceId"] = self.device_id
|
||||
return info
|
||||
|
||||
async def authenticate(self, inn: str, password: str) -> bool:
|
||||
"""
|
||||
Authenticate with INN and password.
|
||||
|
||||
Returns True if authentication was successful.
|
||||
"""
|
||||
request_data = {
|
||||
"username": inn,
|
||||
"password": password,
|
||||
"deviceInfo": self._get_device_info(),
|
||||
}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url}/v1/auth/lkfl",
|
||||
json=request_data,
|
||||
headers=self.DEFAULT_HEADERS,
|
||||
)
|
||||
|
||||
if response.status_code == 401:
|
||||
raise LknpdAuthError("Invalid credentials", 401)
|
||||
|
||||
if response.status_code >= 400:
|
||||
raise LknpdApiError(
|
||||
f"Authentication failed: {response.text}",
|
||||
response.status_code,
|
||||
)
|
||||
|
||||
self._token_data = response.json()
|
||||
logger.info("LKNPD authentication successful")
|
||||
return True
|
||||
|
||||
except httpx.RequestError as e:
|
||||
logger.exception("Network error during authentication")
|
||||
raise LknpdApiError(f"Network error: {e}")
|
||||
|
||||
async def _refresh_token(self) -> bool:
|
||||
"""Refresh access token using refresh token."""
|
||||
async with self._refresh_lock:
|
||||
if not self._token_data or "refreshToken" not in self._token_data:
|
||||
return False
|
||||
|
||||
request_data = {
|
||||
"deviceInfo": self._get_device_info(),
|
||||
"refreshToken": self._token_data["refreshToken"],
|
||||
}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url}/v1/auth/token",
|
||||
json=request_data,
|
||||
headers=self.DEFAULT_HEADERS,
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
return False
|
||||
|
||||
self._token_data = response.json()
|
||||
logger.info("LKNPD token refreshed")
|
||||
return True
|
||||
|
||||
except Exception:
|
||||
logger.exception("Token refresh failed")
|
||||
return False
|
||||
|
||||
def _get_auth_headers(self) -> dict[str, str]:
|
||||
"""Get authorization headers from current token."""
|
||||
if not self._token_data or "token" not in self._token_data:
|
||||
return {}
|
||||
return {"Authorization": f"Bearer {self._token_data['token']}"}
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
json_data: dict[str, Any] | None = None,
|
||||
retry_on_401: bool = True,
|
||||
) -> httpx.Response:
|
||||
"""Make authenticated API request with auto-retry on 401."""
|
||||
headers = {**self.DEFAULT_HEADERS, **self._get_auth_headers()}
|
||||
url = f"{self.base_url}/v1{path}"
|
||||
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.request(
|
||||
method,
|
||||
url,
|
||||
json=json_data,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
# Handle 401 with token refresh
|
||||
if response.status_code == 401 and retry_on_401:
|
||||
if await self._refresh_token():
|
||||
headers = {**self.DEFAULT_HEADERS, **self._get_auth_headers()}
|
||||
response = await client.request(
|
||||
method,
|
||||
url,
|
||||
json=json_data,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
@property
|
||||
def is_authenticated(self) -> bool:
|
||||
"""Check if client has valid token data."""
|
||||
return self._token_data is not None and "token" in self._token_data
|
||||
|
||||
async def create_income(
|
||||
self,
|
||||
*,
|
||||
name: str,
|
||||
amount: Decimal | float,
|
||||
quantity: Decimal | float | int = 1,
|
||||
payment_type: PaymentType = PaymentType.WIRE,
|
||||
income_type: IncomeType = IncomeType.FROM_INDIVIDUAL,
|
||||
client_inn: str | None = None,
|
||||
client_name: str | None = None,
|
||||
client_phone: str | None = None,
|
||||
operation_time: datetime | None = None,
|
||||
) -> str | None:
|
||||
"""
|
||||
Register income and create receipt.
|
||||
|
||||
Args:
|
||||
name: Service/item description
|
||||
amount: Price per unit
|
||||
quantity: Number of units
|
||||
payment_type: CASH or WIRE (for card/bank payments)
|
||||
income_type: Source type (individual, legal entity, foreign)
|
||||
client_inn: Client's INN (required for legal entities)
|
||||
client_name: Client's display name
|
||||
client_phone: Client's phone number
|
||||
operation_time: Time of operation (defaults to now)
|
||||
|
||||
Returns:
|
||||
Receipt UUID if successful, None otherwise
|
||||
"""
|
||||
if not self.is_authenticated:
|
||||
raise LknpdAuthError("Not authenticated")
|
||||
|
||||
# Prepare times
|
||||
now = datetime.now(UTC)
|
||||
op_time = operation_time or now
|
||||
|
||||
# Calculate total
|
||||
amount_decimal = Decimal(str(amount))
|
||||
qty_decimal = Decimal(str(quantity))
|
||||
total = amount_decimal * qty_decimal
|
||||
|
||||
# API expects quantity as integer when it's a whole number
|
||||
qty_value: int | str
|
||||
if qty_decimal == qty_decimal.to_integral_value():
|
||||
qty_value = int(qty_decimal)
|
||||
else:
|
||||
qty_value = str(qty_decimal)
|
||||
|
||||
# Build request
|
||||
request_data = {
|
||||
"operationTime": _format_datetime(op_time),
|
||||
"requestTime": _format_datetime(now),
|
||||
"services": [
|
||||
{
|
||||
"name": name,
|
||||
"amount": str(amount_decimal),
|
||||
"quantity": qty_value,
|
||||
}
|
||||
],
|
||||
"totalAmount": str(total),
|
||||
"client": {
|
||||
"contactPhone": client_phone,
|
||||
"displayName": client_name,
|
||||
"incomeType": income_type.value,
|
||||
"inn": client_inn,
|
||||
},
|
||||
"paymentType": payment_type.value,
|
||||
"ignoreMaxTotalIncomeRestriction": False,
|
||||
}
|
||||
|
||||
try:
|
||||
response = await self._request("POST", "/income", json_data=request_data)
|
||||
|
||||
if response.status_code == 400:
|
||||
logger.error("LKNPD validation error: %s", response.text)
|
||||
raise LknpdValidationError(response.text, 400)
|
||||
|
||||
if response.status_code == 401:
|
||||
raise LknpdAuthError("Authentication expired", 401)
|
||||
|
||||
if response.status_code >= 400:
|
||||
logger.error(
|
||||
"LKNPD API error: status=%d body=%s",
|
||||
response.status_code,
|
||||
response.text,
|
||||
)
|
||||
raise LknpdApiError(response.text, response.status_code)
|
||||
|
||||
payload = response.json()
|
||||
receipt_uuid = (
|
||||
payload.get("approvedReceiptUuid")
|
||||
or payload.get("receiptUuid")
|
||||
or payload.get("receipt_uuid")
|
||||
)
|
||||
|
||||
if receipt_uuid:
|
||||
logger.info("LKNPD receipt created: %s", receipt_uuid)
|
||||
|
||||
return receipt_uuid
|
||||
|
||||
except httpx.RequestError as e:
|
||||
logger.exception("Network error creating income")
|
||||
raise LknpdApiError(f"Network error: {e}")
|
||||
@@ -0,0 +1,69 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from .lknpd_client import LknpdClient, PaymentType, LknpdApiError
|
||||
|
||||
|
||||
class LknpdService:
|
||||
def __init__(
|
||||
self,
|
||||
inn: Optional[str],
|
||||
password: Optional[str],
|
||||
api_url: str = "https://lknpd.nalog.ru/api",
|
||||
) -> None:
|
||||
self.inn = inn.strip() if inn else None
|
||||
self.password = password
|
||||
self.configured = bool(self.inn and self.password)
|
||||
self._client = LknpdClient(base_url=api_url) if self.configured else None
|
||||
self._auth_lock = asyncio.Lock()
|
||||
|
||||
if not self.configured:
|
||||
logging.warning("LKNPD credentials are missing. Receipt sending disabled.")
|
||||
|
||||
async def _ensure_authenticated(self) -> bool:
|
||||
if not self._client:
|
||||
return False
|
||||
|
||||
async with self._auth_lock:
|
||||
if self._client.is_authenticated:
|
||||
return True
|
||||
|
||||
try:
|
||||
await self._client.authenticate(self.inn, self.password)
|
||||
return True
|
||||
except LknpdApiError:
|
||||
logging.exception("LKNPD authentication failed.")
|
||||
return False
|
||||
|
||||
async def create_income_receipt(
|
||||
self,
|
||||
*,
|
||||
item_name: str,
|
||||
amount: float,
|
||||
quantity: float = 1.0,
|
||||
operation_time: Optional[datetime] = None,
|
||||
) -> Optional[str]:
|
||||
if not self.configured:
|
||||
return None
|
||||
if not await self._ensure_authenticated():
|
||||
return None
|
||||
|
||||
try:
|
||||
receipt_uuid = await self._client.create_income(
|
||||
name=item_name,
|
||||
amount=amount,
|
||||
quantity=quantity,
|
||||
payment_type=PaymentType.WIRE,
|
||||
operation_time=operation_time,
|
||||
)
|
||||
if not receipt_uuid:
|
||||
logging.info("LKNPD receipt created without a UUID in response.")
|
||||
return receipt_uuid
|
||||
except LknpdApiError:
|
||||
logging.exception("Failed to create LKNPD receipt.")
|
||||
return None
|
||||
|
||||
async def close(self) -> None:
|
||||
return None
|
||||
@@ -1,10 +1,11 @@
|
||||
import logging
|
||||
import asyncio
|
||||
from aiogram import Bot
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from aiogram.utils.text_decorations import html_decoration as hd
|
||||
from aiogram.exceptions import TelegramRetryAfter
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, Union, Dict, Any
|
||||
from typing import Optional, Union, Dict, Any, Callable
|
||||
|
||||
from config.settings import Settings
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
@@ -14,6 +15,10 @@ from bot.utils.text_sanitizer import (
|
||||
display_name_or_fallback,
|
||||
username_for_display,
|
||||
)
|
||||
from bot.utils.telegram_markup import (
|
||||
is_profile_link_error,
|
||||
remove_profile_link_buttons,
|
||||
)
|
||||
|
||||
|
||||
class NotificationService:
|
||||
@@ -34,8 +39,43 @@ class NotificationService:
|
||||
if username:
|
||||
base_display = f"{base_display} ({username_for_display(username)})"
|
||||
return base_display
|
||||
|
||||
@staticmethod
|
||||
def _build_profile_keyboard(
|
||||
translate: Callable[..., str],
|
||||
user_id: int,
|
||||
referrer_id: Optional[int] = None,
|
||||
) -> InlineKeyboardMarkup:
|
||||
"""Create inline keyboard with links to user (and referrer) profiles."""
|
||||
buttons = [
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=translate(
|
||||
"log_open_profile_link",
|
||||
),
|
||||
url=f"tg://user?id={user_id}",
|
||||
)
|
||||
]
|
||||
]
|
||||
|
||||
if referrer_id:
|
||||
buttons.append([
|
||||
InlineKeyboardButton(
|
||||
text=translate(
|
||||
"log_open_referrer_profile_button",
|
||||
),
|
||||
url=f"tg://user?id={referrer_id}",
|
||||
)
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
async def _send_to_log_channel(self, message: str, thread_id: Optional[int] = None):
|
||||
async def _send_to_log_channel(
|
||||
self,
|
||||
message: str,
|
||||
thread_id: Optional[int] = None,
|
||||
reply_markup: Optional[InlineKeyboardMarkup] = None,
|
||||
):
|
||||
"""Send message to configured log channel/group using message queue"""
|
||||
if not self.settings.LOG_CHAT_ID:
|
||||
return
|
||||
@@ -43,13 +83,42 @@ class NotificationService:
|
||||
queue_manager = get_queue_manager()
|
||||
if not queue_manager:
|
||||
logging.warning("Message queue manager not available, falling back to direct send")
|
||||
final_thread_id = thread_id or self.settings.LOG_THREAD_ID
|
||||
|
||||
def _build_kwargs(markup: Optional[InlineKeyboardMarkup]) -> Dict[str, Any]:
|
||||
kwargs: Dict[str, Any] = {
|
||||
"chat_id": self.settings.LOG_CHAT_ID,
|
||||
"text": message,
|
||||
"parse_mode": "HTML",
|
||||
"disable_web_page_preview": True,
|
||||
}
|
||||
if markup:
|
||||
kwargs["reply_markup"] = markup
|
||||
if final_thread_id:
|
||||
kwargs["message_thread_id"] = final_thread_id
|
||||
return kwargs
|
||||
|
||||
try:
|
||||
await self.bot.send_message(
|
||||
chat_id=self.settings.LOG_CHAT_ID,
|
||||
text=message,
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True,
|
||||
message_thread_id=thread_id or self.settings.LOG_THREAD_ID
|
||||
await self.bot.send_message(**_build_kwargs(reply_markup))
|
||||
except TelegramBadRequest as exc:
|
||||
if is_profile_link_error(exc):
|
||||
fallback_markup = remove_profile_link_buttons(reply_markup)
|
||||
logging.warning(
|
||||
"Telegram rejected profile buttons for log chat %s: %s. "
|
||||
"Retrying without tg:// links.",
|
||||
self.settings.LOG_CHAT_ID,
|
||||
getattr(exc, "message", "") or str(exc),
|
||||
)
|
||||
try:
|
||||
await self.bot.send_message(**_build_kwargs(fallback_markup))
|
||||
except Exception as retry_exc:
|
||||
logging.error(
|
||||
"Failed to send notification without profile buttons to log "
|
||||
f"channel {self.settings.LOG_CHAT_ID}: {retry_exc}"
|
||||
)
|
||||
return
|
||||
logging.error(
|
||||
f"Failed to send notification to log channel {self.settings.LOG_CHAT_ID}: {exc}"
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to send notification to log channel {self.settings.LOG_CHAT_ID}: {e}")
|
||||
@@ -64,6 +133,8 @@ class NotificationService:
|
||||
"parse_mode": "HTML",
|
||||
"disable_web_page_preview": True
|
||||
}
|
||||
if reply_markup:
|
||||
kwargs["reply_markup"] = reply_markup
|
||||
|
||||
# Add thread ID for supergroups if specified
|
||||
if final_thread_id:
|
||||
@@ -124,26 +195,28 @@ class NotificationService:
|
||||
|
||||
referral_text = ""
|
||||
if referred_by_id:
|
||||
referral_text = _("log_referral_suffix", default=" (реферал от {referrer_id})", referrer_id=referred_by_id)
|
||||
referrer_link = hd.link(str(referred_by_id), f"tg://user?id={referred_by_id}")
|
||||
referral_text = _(
|
||||
"log_referral_suffix",
|
||||
referrer_link=referrer_link,
|
||||
)
|
||||
|
||||
message = _(
|
||||
"log_new_user_registration",
|
||||
default="👤 <b>Новый пользователь</b>\n\n"
|
||||
"🆔 ID: <code>{user_id}</code>\n"
|
||||
"👤 Имя: {user_display}{referral_text}\n"
|
||||
"📅 Время: {timestamp}",
|
||||
user_id=user_id,
|
||||
user_display=user_display,
|
||||
referral_text=referral_text,
|
||||
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
)
|
||||
|
||||
|
||||
# Send to log channel
|
||||
await self._send_to_log_channel(message)
|
||||
profile_keyboard = self._build_profile_keyboard(_, user_id, referred_by_id)
|
||||
await self._send_to_log_channel(message, reply_markup=profile_keyboard)
|
||||
|
||||
async def notify_payment_received(self, user_id: int, amount: float, currency: str,
|
||||
months: int, payment_provider: str,
|
||||
username: Optional[str] = None):
|
||||
username: Optional[str] = None,
|
||||
traffic_gb: Optional[float] = None):
|
||||
"""Send notification about successful payment"""
|
||||
if not self.settings.LOG_PAYMENTS:
|
||||
return
|
||||
@@ -161,28 +234,37 @@ class NotificationService:
|
||||
"freekassa": "💳",
|
||||
"cryptopay": "₿",
|
||||
"stars": "⭐",
|
||||
"tribute": "💎"
|
||||
"platega": "💳",
|
||||
"severpay": "💳",
|
||||
}.get(payment_provider.lower(), "💰")
|
||||
|
||||
message = _(
|
||||
"log_payment_received",
|
||||
default="{provider_emoji} <b>Получен платеж</b>\n\n"
|
||||
"👤 Пользователь: {user_display}\n"
|
||||
"💰 Сумма: <b>{amount} {currency}</b>\n"
|
||||
"📅 Период: <b>{months} мес.</b>\n"
|
||||
"🏦 Провайдер: {payment_provider}\n"
|
||||
"🕐 Время: {timestamp}",
|
||||
provider_emoji=provider_emoji,
|
||||
user_display=user_display,
|
||||
amount=amount,
|
||||
currency=currency,
|
||||
months=months,
|
||||
payment_provider=payment_provider,
|
||||
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
)
|
||||
|
||||
if traffic_gb is not None:
|
||||
traffic_label = str(int(traffic_gb)) if float(traffic_gb).is_integer() else f"{traffic_gb:g}"
|
||||
message = _(
|
||||
"log_payment_received_traffic",
|
||||
provider_emoji=provider_emoji,
|
||||
user_display=user_display,
|
||||
amount=amount,
|
||||
currency=currency,
|
||||
traffic_gb=traffic_label,
|
||||
payment_provider=payment_provider,
|
||||
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
)
|
||||
else:
|
||||
message = _(
|
||||
"log_payment_received",
|
||||
provider_emoji=provider_emoji,
|
||||
user_display=user_display,
|
||||
amount=amount,
|
||||
currency=currency,
|
||||
months=months,
|
||||
payment_provider=payment_provider,
|
||||
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
)
|
||||
|
||||
# Send to log channel
|
||||
await self._send_to_log_channel(message)
|
||||
profile_keyboard = self._build_profile_keyboard(_, user_id)
|
||||
await self._send_to_log_channel(message, reply_markup=profile_keyboard)
|
||||
|
||||
async def notify_promo_activation(self, user_id: int, promo_code: str, bonus_days: int,
|
||||
username: Optional[str] = None):
|
||||
@@ -200,11 +282,6 @@ class NotificationService:
|
||||
|
||||
message = _(
|
||||
"log_promo_activation",
|
||||
default="🎁 <b>Активирован промокод</b>\n\n"
|
||||
"👤 Пользователь: {user_display}\n"
|
||||
"🏷 Код: <code>{promo_code}</code>\n"
|
||||
"🎯 Бонус: <b>+{bonus_days} дн.</b>\n"
|
||||
"🕐 Время: {timestamp}",
|
||||
user_display=user_display,
|
||||
promo_code=promo_code,
|
||||
bonus_days=bonus_days,
|
||||
@@ -212,7 +289,8 @@ class NotificationService:
|
||||
)
|
||||
|
||||
# Send to log channel
|
||||
await self._send_to_log_channel(message)
|
||||
profile_keyboard = self._build_profile_keyboard(_, user_id)
|
||||
await self._send_to_log_channel(message, reply_markup=profile_keyboard)
|
||||
|
||||
async def notify_trial_activation(self, user_id: int, end_date: datetime,
|
||||
username: Optional[str] = None):
|
||||
@@ -230,17 +308,14 @@ class NotificationService:
|
||||
|
||||
message = _(
|
||||
"log_trial_activation",
|
||||
default="🆓 <b>Активирован триал</b>\n\n"
|
||||
"👤 Пользователь: {user_display}\n"
|
||||
"⏰ Действует до: <b>{end_date}</b>\n"
|
||||
"🕐 Время: {timestamp}",
|
||||
user_display=user_display,
|
||||
end_date=end_date.strftime("%Y-%m-%d %H:%M"),
|
||||
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
)
|
||||
|
||||
# Send to log channel
|
||||
await self._send_to_log_channel(message)
|
||||
profile_keyboard = self._build_profile_keyboard(_, user_id)
|
||||
await self._send_to_log_channel(message, reply_markup=profile_keyboard)
|
||||
|
||||
async def notify_panel_sync(self, status: str, details: str,
|
||||
users_processed: int, subs_synced: int,
|
||||
@@ -261,12 +336,6 @@ class NotificationService:
|
||||
|
||||
message = _(
|
||||
"log_panel_sync",
|
||||
default="{status_emoji} <b>Синхронизация с панелью</b>\n\n"
|
||||
"📊 Статус: <b>{status}</b>\n"
|
||||
"👥 Обработано пользователей: <b>{users_processed}</b>\n"
|
||||
"📋 Синхронизировано подписок: <b>{subs_synced}</b>\n"
|
||||
"🕐 Время: {timestamp}\n\n"
|
||||
"📝 Детали:\n{details}",
|
||||
status_emoji=status_emoji,
|
||||
status=status,
|
||||
users_processed=users_processed,
|
||||
@@ -275,7 +344,7 @@ class NotificationService:
|
||||
details=details
|
||||
)
|
||||
|
||||
# Send to log channel
|
||||
# Send to log channel
|
||||
await self._send_to_log_channel(message)
|
||||
|
||||
async def notify_suspicious_promo_attempt(
|
||||
@@ -297,18 +366,14 @@ class NotificationService:
|
||||
|
||||
message = _(
|
||||
"log_suspicious_promo",
|
||||
default="⚠️ <b>Подозрительная попытка ввода промокода</b>\n\n"
|
||||
"👤 Пользователь: {user_display}\n"
|
||||
"🆔 ID: <code>{user_id}</code>\n"
|
||||
"📝 Ввод: <pre>{suspicious_input}</pre>\n"
|
||||
"🕐 Время: {timestamp}",
|
||||
user_display=hd.quote(user_display),
|
||||
user_id=user_id,
|
||||
suspicious_input=hd.quote(suspicious_input),
|
||||
timestamp=datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S %Z"))
|
||||
|
||||
# Send to log channel
|
||||
await self._send_to_log_channel(message)
|
||||
profile_keyboard = self._build_profile_keyboard(_, user_id)
|
||||
await self._send_to_log_channel(message, reply_markup=profile_keyboard)
|
||||
|
||||
async def send_custom_notification(self, message: str, to_admins: bool = False,
|
||||
to_log_channel: bool = True, thread_id: Optional[int] = None):
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import aiohttp
|
||||
import logging
|
||||
import json
|
||||
import re
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import asyncio
|
||||
@@ -339,23 +340,25 @@ class PanelApiService:
|
||||
default_traffic_limit_strategy: str = "NO_RESET",
|
||||
hwid_device_limit: Optional[int] = None,
|
||||
specific_squad_uuids: Optional[List[str]] = None,
|
||||
external_squad_uuid: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
tag: Optional[str] = None,
|
||||
status: str = "ACTIVE",
|
||||
log_response: bool = True) -> Optional[Dict[str, Any]]:
|
||||
|
||||
if not (6 <= len(username_on_panel) <= 34 and
|
||||
username_on_panel.replace('_', '').replace('-', '').isalnum()):
|
||||
if not (username_on_panel.startswith("tg_")
|
||||
and username_on_panel.split("tg_")[-1].isdigit()):
|
||||
msg = f"Panel username '{username_on_panel}' does not meet panel requirements."
|
||||
logging.error(msg)
|
||||
return {
|
||||
"error": True,
|
||||
"status_code": 400,
|
||||
"message": msg,
|
||||
"errorCode": "VALIDATION_ERROR_USERNAME"
|
||||
}
|
||||
username_is_valid = (
|
||||
3 <= len(username_on_panel) <= 36
|
||||
and re.match(r"^[A-Za-z0-9_-]+$", username_on_panel) is not None
|
||||
)
|
||||
if not username_is_valid:
|
||||
msg = f"Panel username '{username_on_panel}' does not meet panel requirements."
|
||||
logging.error(msg)
|
||||
return {
|
||||
"error": True,
|
||||
"status_code": 400,
|
||||
"message": msg,
|
||||
"errorCode": "VALIDATION_ERROR_USERNAME"
|
||||
}
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
expire_at_dt = now + timedelta(days=default_expire_days)
|
||||
@@ -383,6 +386,8 @@ class PanelApiService:
|
||||
)
|
||||
if specific_squad_uuids:
|
||||
payload["activeInternalSquads"] = specific_squad_uuids
|
||||
if external_squad_uuid:
|
||||
payload["externalSquadUuid"] = external_squad_uuid
|
||||
if telegram_id is not None: payload["telegramId"] = telegram_id
|
||||
if email: payload["email"] = email
|
||||
if description: payload["description"] = description
|
||||
@@ -455,6 +460,37 @@ class PanelApiService:
|
||||
)
|
||||
return False
|
||||
|
||||
async def delete_user_from_panel(self,
|
||||
user_uuid: str,
|
||||
log_response: bool = True) -> bool:
|
||||
"""Delete a user from the panel. Treat not-found as already deleted."""
|
||||
endpoint = f"/users/{user_uuid}"
|
||||
response_data = await self._request(
|
||||
"DELETE", endpoint, log_full_response=log_response
|
||||
)
|
||||
|
||||
if not response_data:
|
||||
logging.error(
|
||||
f"Panel API delete_user_from_panel returned no data for user {user_uuid}."
|
||||
)
|
||||
return False
|
||||
|
||||
if response_data.get("error"):
|
||||
details = response_data.get("details") or {}
|
||||
error_code = details.get("errorCode") or response_data.get("errorCode")
|
||||
if error_code in {"A062", "A040"}:
|
||||
logging.info(
|
||||
f"Panel user {user_uuid} already absent (errorCode {error_code}). Treating as deleted."
|
||||
)
|
||||
return True
|
||||
logging.error(
|
||||
f"Failed to delete user {user_uuid} on panel. Response: {response_data}"
|
||||
)
|
||||
return False
|
||||
|
||||
logging.info(f"Panel user {user_uuid} deleted successfully.")
|
||||
return True
|
||||
|
||||
async def get_subscription_link(
|
||||
self,
|
||||
short_uuid_or_sub_uuid: str,
|
||||
@@ -526,3 +562,20 @@ class PanelApiService:
|
||||
if response_data and not response_data.get("error") and "response" in response_data:
|
||||
return response_data.get("response")
|
||||
return None
|
||||
|
||||
async def encrypt_happ_link(self, link_to_encrypt: str) -> Optional[str]:
|
||||
"""Encrypt a subscription link using the panel's happ crypt4 API.
|
||||
|
||||
Returns the encrypted link string or None if encryption failed.
|
||||
"""
|
||||
payload = {"linkToEncrypt": link_to_encrypt}
|
||||
response_data = await self._request(
|
||||
"POST",
|
||||
"/system/tools/happ/encrypt",
|
||||
json=payload,
|
||||
log_full_response=False
|
||||
)
|
||||
if response_data and not response_data.get("error") and "response" in response_data:
|
||||
return response_data.get("response", {}).get("encryptedLink")
|
||||
logging.error(f"Failed to encrypt happ link. Response: {response_data}")
|
||||
return None
|
||||
|
||||
@@ -12,7 +12,6 @@ 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"),
|
||||
@@ -44,128 +43,6 @@ class PanelWebhookService:
|
||||
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:
|
||||
@@ -193,7 +70,7 @@ class PanelWebhookService:
|
||||
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':
|
||||
if sub and sub.auto_renew_enabled and sub.provider == 'yookassa':
|
||||
try:
|
||||
ok = await subscription_service.charge_subscription_renewal(session, sub)
|
||||
# If initiation succeeded, suppress the 24h reminder by returning early
|
||||
@@ -208,7 +85,7 @@ class PanelWebhookService:
|
||||
except Exception:
|
||||
logging.exception("Auto-renew trigger (24h) failed pre-check")
|
||||
if days_left <= self.settings.SUBSCRIPTION_NOTIFY_DAYS_BEFORE:
|
||||
# For 48h event, if auto-renew is enabled and not tribute, show special notice with cancel button
|
||||
# For 48h event, if auto-renew is enabled, show special notice with cancel button
|
||||
if days_left == 2:
|
||||
async with self.async_session_factory() as session:
|
||||
from db.dal import subscription_dal
|
||||
@@ -220,7 +97,7 @@ class PanelWebhookService:
|
||||
getattr(sub, 'auto_renew_enabled', None) if sub else None,
|
||||
getattr(sub, 'provider', None) if sub else None,
|
||||
)
|
||||
if sub and sub.auto_renew_enabled and sub.provider != 'tribute':
|
||||
if sub and sub.auto_renew_enabled and sub.provider == 'yookassa':
|
||||
cancel_kb = get_autorenew_cancel_keyboard(lang, self.i18n)
|
||||
await self._send_message(
|
||||
user_id,
|
||||
@@ -239,11 +116,7 @@ class PanelWebhookService:
|
||||
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:
|
||||
if self.settings.SUBSCRIPTION_NOTIFY_ON_EXPIRE:
|
||||
await self._send_message(
|
||||
user_id,
|
||||
lang,
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
import json
|
||||
import logging
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
from typing import Optional, Dict, Any, Tuple
|
||||
|
||||
from aiohttp import ClientSession, ClientTimeout, web
|
||||
from aiogram import Bot
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from config.settings import Settings
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.services.referral_service import ReferralService
|
||||
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
|
||||
from bot.services.notification_service import NotificationService
|
||||
from db.dal import payment_dal, user_dal
|
||||
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
|
||||
from bot.utils.config_link import prepare_config_links
|
||||
|
||||
|
||||
class PlategaService:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
bot: Bot,
|
||||
settings: Settings,
|
||||
i18n: JsonI18n,
|
||||
async_session_factory: sessionmaker,
|
||||
subscription_service: SubscriptionService,
|
||||
referral_service: ReferralService,
|
||||
default_return_url: str,
|
||||
):
|
||||
self.bot = bot
|
||||
self.settings = settings
|
||||
self.i18n = i18n
|
||||
self.async_session_factory = async_session_factory
|
||||
self.subscription_service = subscription_service
|
||||
self.referral_service = referral_service
|
||||
|
||||
self.base_url = (settings.PLATEGA_BASE_URL or "https://app.platega.io").rstrip("/")
|
||||
self.merchant_id = settings.PLATEGA_MERCHANT_ID
|
||||
self.secret = settings.PLATEGA_SECRET
|
||||
self.payment_method = settings.PLATEGA_PAYMENT_METHOD
|
||||
self.return_url = settings.PLATEGA_RETURN_URL or f"https://t.me/{default_return_url}"
|
||||
self.failed_url = settings.PLATEGA_FAILED_URL or self.return_url
|
||||
|
||||
self._timeout = ClientTimeout(total=20)
|
||||
self._session: Optional[ClientSession] = None
|
||||
self._auth_headers = {
|
||||
"X-MerchantId": self.merchant_id or "",
|
||||
"X-Secret": self.secret or "",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
self.configured: bool = bool(
|
||||
settings.PLATEGA_ENABLED and self.merchant_id and self.secret
|
||||
)
|
||||
if not self.configured:
|
||||
logging.warning("PlategaService initialized but not fully configured. Payments disabled.")
|
||||
|
||||
async def _get_session(self) -> ClientSession:
|
||||
if self._session is None or self._session.closed:
|
||||
self._session = ClientSession(timeout=self._timeout)
|
||||
return self._session
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._session and not self._session.closed:
|
||||
await self._session.close()
|
||||
|
||||
async def create_transaction(
|
||||
self,
|
||||
*,
|
||||
payment_db_id: int,
|
||||
user_id: int,
|
||||
months: int,
|
||||
amount: float,
|
||||
currency: Optional[str],
|
||||
description: str,
|
||||
payload: Optional[str] = None,
|
||||
) -> Tuple[bool, Dict[str, Any]]:
|
||||
if not self.configured:
|
||||
logging.error("PlategaService is not configured. Cannot create transaction.")
|
||||
return False, {"message": "service_not_configured"}
|
||||
|
||||
session = await self._get_session()
|
||||
url = f"{self.base_url}/transaction/process"
|
||||
currency_code = (currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
|
||||
|
||||
body: Dict[str, Any] = {
|
||||
"paymentMethod": int(self.payment_method),
|
||||
"paymentDetails": {"amount": float(amount), "currency": currency_code},
|
||||
"description": description,
|
||||
"return": self.return_url,
|
||||
"failedUrl": self.failed_url,
|
||||
"payload": payload,
|
||||
}
|
||||
|
||||
# Remove optional keys with falsy values to avoid validation errors
|
||||
clean_body = {k: v for k, v in body.items() if v not in (None, "")}
|
||||
safe_headers = {
|
||||
"X-MerchantId": self._auth_headers.get("X-MerchantId"),
|
||||
"X-Secret": "***" if self._auth_headers.get("X-Secret") else "",
|
||||
"Content-Type": self._auth_headers.get("Content-Type"),
|
||||
}
|
||||
logging.info("Platega create_transaction request: url=%s headers=%s body=%s", url, safe_headers, clean_body)
|
||||
|
||||
try:
|
||||
async with session.post(url, json=clean_body, headers=self._auth_headers) as response:
|
||||
response_text = await response.text()
|
||||
try:
|
||||
response_data = json.loads(response_text) if response_text else {}
|
||||
except json.JSONDecodeError:
|
||||
logging.error("Platega create_transaction: invalid JSON response: %s", response_text)
|
||||
return False, {
|
||||
"status": response.status,
|
||||
"message": "invalid_json",
|
||||
"raw": response_text,
|
||||
}
|
||||
|
||||
if response.status != 200:
|
||||
logging.error(
|
||||
"Platega create_transaction: API returned error (status=%s, body=%s)",
|
||||
response.status,
|
||||
response_data,
|
||||
)
|
||||
return False, {"status": response.status, "message": response_data}
|
||||
|
||||
return True, response_data
|
||||
except Exception as exc:
|
||||
logging.error("Platega create_transaction: request failed: %s", exc, exc_info=True)
|
||||
return False, {"message": str(exc)}
|
||||
|
||||
async def webhook_route(self, request: web.Request) -> web.Response:
|
||||
if not self.configured:
|
||||
return web.Response(status=503, text="platega_disabled")
|
||||
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception as exc:
|
||||
logging.error("Platega webhook: failed to parse JSON: %s", exc)
|
||||
return web.Response(status=400, text="bad_request")
|
||||
|
||||
header_merchant = request.headers.get("X-MerchantId")
|
||||
header_secret = request.headers.get("X-Secret")
|
||||
if header_merchant != self.merchant_id or header_secret != self.secret:
|
||||
logging.error("Platega webhook: invalid auth headers")
|
||||
return web.Response(status=403, text="forbidden")
|
||||
|
||||
transaction_id = str(data.get("id") or data.get("transactionId") or "").strip()
|
||||
status = str(data.get("status") or "").upper()
|
||||
amount_raw = data.get("amount")
|
||||
currency = data.get("currency") or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
|
||||
|
||||
if not transaction_id or not status:
|
||||
logging.error("Platega webhook: missing transaction id or status in payload: %s", data)
|
||||
return web.Response(status=400, text="missing_fields")
|
||||
|
||||
async with self.async_session_factory() as session:
|
||||
payment = await payment_dal.get_payment_by_provider_payment_id(session, transaction_id)
|
||||
if not payment:
|
||||
logging.error("Platega webhook: payment not found for transaction %s", transaction_id)
|
||||
return web.Response(status=404, text="payment_not_found")
|
||||
|
||||
if payment.status == "succeeded" and status == "CONFIRMED":
|
||||
return web.Response(text="ok")
|
||||
|
||||
payment_months = payment.subscription_duration_months or 1
|
||||
sale_mode = "traffic" if self.settings.traffic_sale_mode else "subscription"
|
||||
|
||||
if status == "CONFIRMED":
|
||||
if amount_raw is not None:
|
||||
try:
|
||||
incoming_amount = Decimal(str(amount_raw)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
expected_amount = Decimal(str(payment.amount)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
if incoming_amount != expected_amount:
|
||||
logging.warning(
|
||||
"Platega webhook: amount mismatch for payment %s (expected %s, got %s)",
|
||||
payment.payment_id,
|
||||
expected_amount,
|
||||
incoming_amount,
|
||||
)
|
||||
except Exception as exc:
|
||||
logging.warning("Platega webhook: failed to compare amounts for %s: %s", payment.payment_id, exc)
|
||||
|
||||
try:
|
||||
await payment_dal.update_provider_payment_and_status(
|
||||
session,
|
||||
payment.payment_id,
|
||||
transaction_id,
|
||||
"succeeded",
|
||||
)
|
||||
|
||||
activation = await self.subscription_service.activate_subscription(
|
||||
session,
|
||||
payment.user_id,
|
||||
int(payment_months) if sale_mode != "traffic" else 0,
|
||||
float(payment.amount),
|
||||
payment.payment_id,
|
||||
provider="platega",
|
||||
sale_mode=sale_mode,
|
||||
traffic_gb=payment_months if sale_mode == "traffic" else None,
|
||||
)
|
||||
|
||||
referral_bonus = None
|
||||
if sale_mode != "traffic":
|
||||
referral_bonus = await self.referral_service.apply_referral_bonuses_for_payment(
|
||||
session,
|
||||
payment.user_id,
|
||||
int(payment_months),
|
||||
current_payment_db_id=payment.payment_id,
|
||||
skip_if_active_before_payment=False,
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
except Exception as exc:
|
||||
await session.rollback()
|
||||
logging.error("Platega webhook: failed to process payment %s: %s", transaction_id, exc, exc_info=True)
|
||||
return web.Response(status=500, text="processing_error")
|
||||
|
||||
db_user = await user_dal.get_user_by_id(session, payment.user_id)
|
||||
lang = db_user.language_code if db_user and db_user.language_code else self.settings.DEFAULT_LANGUAGE
|
||||
_ = lambda k, **kw: self.i18n.gettext(lang, k, **kw) if self.i18n else k
|
||||
|
||||
raw_config_link = activation.get("subscription_url") if activation else None
|
||||
config_link_display, connect_button_url = await prepare_config_links(self.settings, raw_config_link)
|
||||
config_link_text = config_link_display or _("config_link_not_available")
|
||||
final_end = activation.get("end_date") if activation else None
|
||||
applied_days = 0
|
||||
applied_promo_days = activation.get("applied_promo_bonus_days", 0) if activation else 0
|
||||
|
||||
if referral_bonus and referral_bonus.get("referee_new_end_date"):
|
||||
final_end = referral_bonus["referee_new_end_date"]
|
||||
applied_days = referral_bonus.get("referee_bonus_applied_days", 0)
|
||||
|
||||
traffic_label = str(int(payment_months)) if float(payment_months).is_integer() else f"{payment_months:g}"
|
||||
|
||||
if sale_mode == "traffic":
|
||||
text = _(
|
||||
"payment_successful_traffic_full",
|
||||
traffic_gb=traffic_label,
|
||||
end_date=final_end.strftime("%Y-%m-%d") if final_end else "",
|
||||
config_link=config_link_text,
|
||||
)
|
||||
elif applied_days:
|
||||
inviter_name_display = _("friend_placeholder")
|
||||
if db_user and db_user.referred_by_id:
|
||||
inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id)
|
||||
if inviter:
|
||||
safe_name = sanitize_display_name(inviter.first_name) if inviter.first_name else None
|
||||
if safe_name:
|
||||
inviter_name_display = safe_name
|
||||
elif inviter.username:
|
||||
inviter_name_display = username_for_display(inviter.username, with_at=False)
|
||||
|
||||
text = _(
|
||||
"payment_successful_with_referral_bonus_full",
|
||||
months=payment_months,
|
||||
base_end_date=activation["end_date"].strftime("%Y-%m-%d") if activation and activation.get("end_date") else final_end.strftime("%Y-%m-%d") if final_end else "",
|
||||
bonus_days=applied_days,
|
||||
final_end_date=final_end.strftime("%Y-%m-%d") if final_end else "",
|
||||
inviter_name=inviter_name_display,
|
||||
config_link=config_link_text,
|
||||
)
|
||||
elif applied_promo_days and final_end:
|
||||
text = _(
|
||||
"payment_successful_with_promo_full",
|
||||
months=payment_months,
|
||||
bonus_days=applied_promo_days,
|
||||
end_date=final_end.strftime("%Y-%m-%d"),
|
||||
config_link=config_link_text,
|
||||
)
|
||||
else:
|
||||
text = _(
|
||||
"payment_successful_full",
|
||||
months=payment_months,
|
||||
end_date=final_end.strftime("%Y-%m-%d") if final_end else "",
|
||||
config_link=config_link_text,
|
||||
)
|
||||
|
||||
markup = get_connect_and_main_keyboard(
|
||||
lang,
|
||||
self.i18n,
|
||||
self.settings,
|
||||
config_link_display,
|
||||
connect_button_url=connect_button_url,
|
||||
preserve_message=True,
|
||||
)
|
||||
try:
|
||||
await self.bot.send_message(
|
||||
payment.user_id,
|
||||
text,
|
||||
reply_markup=markup,
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
except Exception as exc:
|
||||
logging.error("Platega webhook: failed to notify user %s: %s", payment.user_id, exc)
|
||||
|
||||
try:
|
||||
notification_service = NotificationService(self.bot, self.settings, self.i18n)
|
||||
await notification_service.notify_payment_received(
|
||||
user_id=payment.user_id,
|
||||
amount=float(payment.amount),
|
||||
currency=currency,
|
||||
months=int(payment_months) if sale_mode != "traffic" else 0,
|
||||
traffic_gb=payment_months if sale_mode == "traffic" else None,
|
||||
payment_provider="platega",
|
||||
username=db_user.username if db_user else None,
|
||||
)
|
||||
except Exception as exc:
|
||||
logging.error("Platega webhook: failed to notify admins: %s", exc)
|
||||
|
||||
return web.Response(text="ok")
|
||||
|
||||
if status in {"CANCELED", "CANCELLED", "CHARGEBACKED"}:
|
||||
try:
|
||||
await payment_dal.update_provider_payment_and_status(
|
||||
session,
|
||||
payment.payment_id,
|
||||
transaction_id,
|
||||
"canceled",
|
||||
)
|
||||
await session.commit()
|
||||
except Exception as exc:
|
||||
await session.rollback()
|
||||
logging.error("Platega webhook: failed to cancel payment %s: %s", transaction_id, exc)
|
||||
return web.Response(status=500, text="processing_error")
|
||||
|
||||
db_user = await user_dal.get_user_by_id(session, payment.user_id)
|
||||
lang = db_user.language_code if db_user and db_user.language_code else self.settings.DEFAULT_LANGUAGE
|
||||
_ = lambda k, **kw: self.i18n.gettext(lang, k, **kw) if self.i18n else k
|
||||
try:
|
||||
await self.bot.send_message(payment.user_id, _("payment_failed"))
|
||||
except Exception:
|
||||
pass
|
||||
return web.Response(text="ok_canceled")
|
||||
|
||||
logging.warning("Platega webhook: unhandled status '%s' for transaction %s", status, transaction_id)
|
||||
return web.Response(status=202, text="status_ignored")
|
||||
|
||||
|
||||
async def platega_webhook_route(request: web.Request) -> web.Response:
|
||||
service: PlategaService = request.app["platega_service"]
|
||||
return await service.webhook_route(request)
|
||||
@@ -177,6 +177,8 @@ class ReferralService:
|
||||
"ACTIVE_BONUS",
|
||||
"traffic_limit_bytes":
|
||||
self.settings.user_traffic_limit_bytes,
|
||||
"auto_renew_enabled":
|
||||
False,
|
||||
}
|
||||
try:
|
||||
await subscription_dal.deactivate_other_active_subscriptions(
|
||||
@@ -255,9 +257,35 @@ class ReferralService:
|
||||
|
||||
raise
|
||||
|
||||
def generate_referral_link(self, bot_username: str,
|
||||
inviter_user_id: int) -> str:
|
||||
return f"https://t.me/{bot_username}?start=ref_{inviter_user_id}"
|
||||
async def generate_referral_link(self, session: AsyncSession,
|
||||
bot_username: str,
|
||||
inviter_user_id: int) -> Optional[str]:
|
||||
try:
|
||||
user = await user_dal.get_user_by_id(session, inviter_user_id)
|
||||
if not user:
|
||||
logging.warning(
|
||||
"Unable to generate referral link: user %s not found.",
|
||||
inviter_user_id,
|
||||
)
|
||||
return None
|
||||
|
||||
referral_code = await user_dal.ensure_referral_code(session, user)
|
||||
if not referral_code:
|
||||
logging.warning(
|
||||
"User %s has no referral code even after regeneration attempt.",
|
||||
inviter_user_id,
|
||||
)
|
||||
return None
|
||||
|
||||
return f"https://t.me/{bot_username}?start=ref_u{referral_code}"
|
||||
except Exception as exc:
|
||||
logging.error(
|
||||
"Failed to generate referral link for user %s: %s",
|
||||
inviter_user_id,
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
return None
|
||||
|
||||
async def get_referral_stats(self, session: AsyncSession, user_id: int) -> dict:
|
||||
"""Get referral statistics for a user"""
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
import hmac
|
||||
import hashlib
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
from typing import Optional, Dict, Any, Tuple
|
||||
|
||||
from aiohttp import ClientSession, ClientTimeout, web
|
||||
from aiogram import Bot
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from config.settings import Settings
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.services.referral_service import ReferralService
|
||||
from bot.services.notification_service import NotificationService
|
||||
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
|
||||
from db.dal import payment_dal, user_dal
|
||||
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
|
||||
from bot.utils.config_link import prepare_config_links
|
||||
|
||||
|
||||
class SeverPayService:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
bot: Bot,
|
||||
settings: Settings,
|
||||
i18n: JsonI18n,
|
||||
async_session_factory: sessionmaker,
|
||||
subscription_service: SubscriptionService,
|
||||
referral_service: ReferralService,
|
||||
default_return_url: str,
|
||||
):
|
||||
self.bot = bot
|
||||
self.settings = settings
|
||||
self.i18n = i18n
|
||||
self.async_session_factory = async_session_factory
|
||||
self.subscription_service = subscription_service
|
||||
self.referral_service = referral_service
|
||||
|
||||
self.base_url = (settings.SEVERPAY_BASE_URL or "https://severpay.io/api/merchant").rstrip("/")
|
||||
self.mid = settings.SEVERPAY_MID
|
||||
self.token = settings.SEVERPAY_TOKEN or ""
|
||||
self.return_url = settings.SEVERPAY_RETURN_URL or f"https://t.me/{default_return_url}"
|
||||
self.lifetime_minutes = settings.SEVERPAY_LIFETIME_MINUTES
|
||||
|
||||
self._timeout = ClientTimeout(total=15)
|
||||
self._session: Optional[ClientSession] = None
|
||||
|
||||
self.configured: bool = bool(settings.SEVERPAY_ENABLED and self.mid and self.token)
|
||||
if not self.configured:
|
||||
logging.warning("SeverPayService initialized but not fully configured. Payments disabled.")
|
||||
|
||||
async def _get_session(self) -> ClientSession:
|
||||
if self._session is None or self._session.closed:
|
||||
self._session = ClientSession(timeout=self._timeout)
|
||||
return self._session
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._session and not self._session.closed:
|
||||
await self._session.close()
|
||||
|
||||
@staticmethod
|
||||
def _format_amount(amount: float) -> str:
|
||||
quantized = Decimal(str(amount)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
return f"{quantized:.2f}"
|
||||
|
||||
def _sign_payload(self, payload: Dict[str, Any]) -> str:
|
||||
message = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
||||
return hmac.new(self.token.encode("utf-8"), message.encode("utf-8"), hashlib.sha256).hexdigest()
|
||||
|
||||
def _build_signed_body(self, extra: Dict[str, Any]) -> Dict[str, Any]:
|
||||
body: Dict[str, Any] = {
|
||||
"mid": self.mid,
|
||||
"salt": secrets.token_hex(8),
|
||||
}
|
||||
body.update(extra)
|
||||
sorted_body = dict(sorted(body.items()))
|
||||
sorted_body["sign"] = self._sign_payload(sorted_body)
|
||||
return sorted_body
|
||||
|
||||
def _validate_signature(self, payload: Dict[str, Any]) -> bool:
|
||||
provided_sign = str(payload.get("sign") or "")
|
||||
if not provided_sign or not self.token:
|
||||
return False
|
||||
# Webhook signatures are calculated on the original payload order (without sorting).
|
||||
data = {k: v for k, v in payload.items() if k != "sign"}
|
||||
expected_sign = self._sign_payload(data)
|
||||
return hmac.compare_digest(provided_sign, expected_sign)
|
||||
|
||||
async def create_payment(
|
||||
self,
|
||||
*,
|
||||
payment_db_id: int,
|
||||
user_id: int,
|
||||
months: int,
|
||||
amount: float,
|
||||
currency: Optional[str],
|
||||
description: str,
|
||||
) -> Tuple[bool, Dict[str, Any]]:
|
||||
if not self.configured:
|
||||
logging.error("SeverPayService is not configured. Cannot create payment.")
|
||||
return False, {"message": "service_not_configured"}
|
||||
|
||||
session = await self._get_session()
|
||||
url = f"{self.base_url}/payin/create"
|
||||
currency_code = (currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
|
||||
amount_str = self._format_amount(amount)
|
||||
|
||||
body = {
|
||||
"order_id": str(payment_db_id),
|
||||
"amount": amount_str,
|
||||
"currency": currency_code,
|
||||
"client_email": f"{user_id}@telegram.org",
|
||||
"client_id": str(user_id),
|
||||
"url_return": self.return_url,
|
||||
}
|
||||
|
||||
if self.lifetime_minutes:
|
||||
body["lifetime"] = int(self.lifetime_minutes)
|
||||
|
||||
signed_body = self._build_signed_body(body)
|
||||
|
||||
try:
|
||||
async with session.post(url, json=signed_body) as response:
|
||||
response_text = await response.text()
|
||||
try:
|
||||
response_data = json.loads(response_text) if response_text else {}
|
||||
except json.JSONDecodeError:
|
||||
logging.error("SeverPay create_payment: invalid JSON response: %s", response_text)
|
||||
return False, {"status": response.status, "message": "invalid_json", "raw": response_text}
|
||||
|
||||
if response.status != 200 or not response_data.get("status"):
|
||||
logging.error(
|
||||
"SeverPay create_payment: API returned error (status=%s, body=%s)",
|
||||
response.status,
|
||||
response_data,
|
||||
)
|
||||
return False, {"status": response.status, "message": response_data}
|
||||
|
||||
return True, response_data.get("data") or response_data
|
||||
except Exception as exc:
|
||||
logging.error("SeverPay create_payment: request failed: %s", exc, exc_info=True)
|
||||
return False, {"message": str(exc)}
|
||||
|
||||
async def webhook_route(self, request: web.Request) -> web.Response:
|
||||
if not self.configured:
|
||||
return web.json_response({"status": False, "msg": "severpay_disabled"}, status=503)
|
||||
|
||||
try:
|
||||
payload = await request.json()
|
||||
except Exception as exc:
|
||||
logging.error("SeverPay webhook: failed to parse JSON: %s", exc)
|
||||
return web.json_response({"status": False, "msg": "bad_request"}, status=400)
|
||||
|
||||
if not isinstance(payload, dict) or not self._validate_signature(payload):
|
||||
logging.error("SeverPay webhook: invalid signature or payload.")
|
||||
return web.json_response({"status": False, "msg": "invalid_signature"}, status=403)
|
||||
|
||||
event_type = str(payload.get("type") or "").lower()
|
||||
data = payload.get("data") or {}
|
||||
|
||||
if event_type != "payin" or not isinstance(data, dict):
|
||||
logging.warning("SeverPay webhook: unsupported event type '%s'", event_type)
|
||||
return web.json_response({"status": True})
|
||||
|
||||
provider_payment_id = str(data.get("id") or data.get("uid") or "")
|
||||
order_id_raw = data.get("order_id")
|
||||
status = str(data.get("status") or "").lower()
|
||||
|
||||
payment_db_id: Optional[int] = None
|
||||
try:
|
||||
if isinstance(order_id_raw, int):
|
||||
payment_db_id = order_id_raw
|
||||
elif isinstance(order_id_raw, str) and order_id_raw.isdigit():
|
||||
payment_db_id = int(order_id_raw)
|
||||
except Exception:
|
||||
payment_db_id = None
|
||||
|
||||
async with self.async_session_factory() as session:
|
||||
payment = None
|
||||
if payment_db_id is not None:
|
||||
payment = await payment_dal.get_payment_by_db_id(session, payment_db_id)
|
||||
if not payment and provider_payment_id:
|
||||
payment = await payment_dal.get_payment_by_provider_payment_id(session, provider_payment_id)
|
||||
|
||||
if not payment:
|
||||
logging.error("SeverPay webhook: payment not found (order_id=%s, provider_id=%s)", order_id_raw, provider_payment_id)
|
||||
return web.json_response({"status": False, "msg": "payment_not_found"}, status=404)
|
||||
|
||||
payment_months = payment.subscription_duration_months or 1
|
||||
sale_mode = "traffic" if self.settings.traffic_sale_mode else "subscription"
|
||||
if status == "success":
|
||||
try:
|
||||
await payment_dal.update_provider_payment_and_status(
|
||||
session,
|
||||
payment.payment_id,
|
||||
provider_payment_id or str(payment.payment_id),
|
||||
"succeeded",
|
||||
)
|
||||
|
||||
activation = await self.subscription_service.activate_subscription(
|
||||
session,
|
||||
payment.user_id,
|
||||
int(payment_months) if sale_mode != "traffic" else 0,
|
||||
float(payment.amount),
|
||||
payment.payment_id,
|
||||
provider="severpay",
|
||||
sale_mode=sale_mode,
|
||||
traffic_gb=payment_months if sale_mode == "traffic" else None,
|
||||
)
|
||||
|
||||
referral_bonus = None
|
||||
if sale_mode != "traffic":
|
||||
referral_bonus = await self.referral_service.apply_referral_bonuses_for_payment(
|
||||
session,
|
||||
payment.user_id,
|
||||
int(payment_months),
|
||||
current_payment_db_id=payment.payment_id,
|
||||
skip_if_active_before_payment=False,
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
except Exception as exc:
|
||||
await session.rollback()
|
||||
logging.error("SeverPay webhook: failed to process payment %s: %s", provider_payment_id, exc, exc_info=True)
|
||||
return web.json_response({"status": False, "msg": "processing_error"}, status=500)
|
||||
|
||||
db_user = payment.user or await user_dal.get_user_by_id(session, payment.user_id)
|
||||
lang = db_user.language_code if db_user and db_user.language_code else self.settings.DEFAULT_LANGUAGE
|
||||
_ = lambda k, **kw: self.i18n.gettext(lang, k, **kw) if self.i18n else k
|
||||
|
||||
raw_config_link = activation.get("subscription_url") if activation else None
|
||||
config_link_display, connect_button_url = await prepare_config_links(self.settings, raw_config_link)
|
||||
config_link_text = config_link_display or _("config_link_not_available")
|
||||
final_end = activation.get("end_date") if activation else None
|
||||
applied_days = 0
|
||||
applied_promo_days = activation.get("applied_promo_bonus_days", 0) if activation else 0
|
||||
|
||||
if referral_bonus and referral_bonus.get("referee_new_end_date"):
|
||||
final_end = referral_bonus["referee_new_end_date"]
|
||||
applied_days = referral_bonus.get("referee_bonus_applied_days", 0)
|
||||
|
||||
traffic_label = str(int(payment_months)) if float(payment_months).is_integer() else f"{payment_months:g}"
|
||||
|
||||
if sale_mode == "traffic":
|
||||
text = _(
|
||||
"payment_successful_traffic_full",
|
||||
traffic_gb=traffic_label,
|
||||
end_date=final_end.strftime("%Y-%m-%d") if final_end else "",
|
||||
config_link=config_link_text,
|
||||
)
|
||||
elif applied_days:
|
||||
inviter_name_display = _("friend_placeholder")
|
||||
if db_user and db_user.referred_by_id:
|
||||
inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id)
|
||||
if inviter:
|
||||
safe_name = sanitize_display_name(inviter.first_name) if inviter.first_name else None
|
||||
if safe_name:
|
||||
inviter_name_display = safe_name
|
||||
elif inviter.username:
|
||||
inviter_name_display = username_for_display(inviter.username, with_at=False)
|
||||
|
||||
text = _(
|
||||
"payment_successful_with_referral_bonus_full",
|
||||
months=payment_months,
|
||||
base_end_date=activation["end_date"].strftime("%Y-%m-%d") if activation and activation.get("end_date") else final_end.strftime("%Y-%m-%d") if final_end else "",
|
||||
bonus_days=applied_days,
|
||||
final_end_date=final_end.strftime("%Y-%m-%d") if final_end else "",
|
||||
inviter_name=inviter_name_display,
|
||||
config_link=config_link_text,
|
||||
)
|
||||
elif applied_promo_days and final_end:
|
||||
text = _(
|
||||
"payment_successful_with_promo_full",
|
||||
months=payment_months,
|
||||
bonus_days=applied_promo_days,
|
||||
end_date=final_end.strftime("%Y-%m-%d"),
|
||||
config_link=config_link_text,
|
||||
)
|
||||
else:
|
||||
text = _(
|
||||
"payment_successful_full",
|
||||
months=payment_months,
|
||||
end_date=final_end.strftime("%Y-%m-%d") if final_end else "",
|
||||
config_link=config_link_text,
|
||||
)
|
||||
|
||||
markup = get_connect_and_main_keyboard(
|
||||
lang,
|
||||
self.i18n,
|
||||
self.settings,
|
||||
config_link_display,
|
||||
connect_button_url=connect_button_url,
|
||||
preserve_message=True,
|
||||
)
|
||||
try:
|
||||
await self.bot.send_message(
|
||||
payment.user_id,
|
||||
text,
|
||||
reply_markup=markup,
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
except Exception as exc:
|
||||
logging.error("SeverPay webhook: failed to notify user %s: %s", payment.user_id, exc)
|
||||
|
||||
try:
|
||||
notification_service = NotificationService(self.bot, self.settings, self.i18n)
|
||||
await notification_service.notify_payment_received(
|
||||
user_id=payment.user_id,
|
||||
amount=float(payment.amount),
|
||||
currency=payment.currency,
|
||||
months=int(payment_months) if sale_mode != "traffic" else 0,
|
||||
traffic_gb=payment_months if sale_mode == "traffic" else None,
|
||||
payment_provider="severpay",
|
||||
username=db_user.username if db_user else None,
|
||||
)
|
||||
except Exception as exc:
|
||||
logging.error("SeverPay webhook: failed to notify admins: %s", exc)
|
||||
|
||||
return web.json_response({"status": True})
|
||||
|
||||
if status in {"fail", "decline"}:
|
||||
try:
|
||||
await payment_dal.update_provider_payment_and_status(
|
||||
session,
|
||||
payment.payment_id,
|
||||
provider_payment_id or str(payment.payment_id),
|
||||
"failed",
|
||||
)
|
||||
await session.commit()
|
||||
except Exception as exc:
|
||||
await session.rollback()
|
||||
logging.error("SeverPay webhook: failed to mark payment %s as failed: %s", provider_payment_id, exc)
|
||||
return web.json_response({"status": False, "msg": "processing_error"}, status=500)
|
||||
|
||||
db_user = payment.user or await user_dal.get_user_by_id(session, payment.user_id)
|
||||
lang = db_user.language_code if db_user and db_user.language_code else self.settings.DEFAULT_LANGUAGE
|
||||
_ = lambda k, **kw: self.i18n.gettext(lang, k, **kw) if self.i18n else k
|
||||
try:
|
||||
await self.bot.send_message(payment.user_id, _("payment_failed"))
|
||||
except Exception:
|
||||
pass
|
||||
return web.json_response({"status": True})
|
||||
|
||||
if status in {"process", "new"}:
|
||||
try:
|
||||
await payment_dal.update_provider_payment_and_status(
|
||||
session,
|
||||
payment.payment_id,
|
||||
provider_payment_id or str(payment.payment_id),
|
||||
"pending_severpay",
|
||||
)
|
||||
await session.commit()
|
||||
except Exception as exc:
|
||||
await session.rollback()
|
||||
logging.error("SeverPay webhook: failed to update pending status for %s: %s", provider_payment_id, exc)
|
||||
return web.json_response({"status": True})
|
||||
|
||||
logging.warning("SeverPay webhook: unhandled status '%s' for payment %s", status, provider_payment_id)
|
||||
return web.json_response({"status": True})
|
||||
|
||||
|
||||
async def severpay_webhook_route(request: web.Request) -> web.Response:
|
||||
service: SeverPayService = request.app["severpay_service"]
|
||||
return await service.webhook_route(request)
|
||||
@@ -13,6 +13,7 @@ from bot.middlewares.i18n import JsonI18n
|
||||
from .notification_service import NotificationService
|
||||
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
|
||||
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
|
||||
from bot.utils.config_link import prepare_config_links
|
||||
|
||||
|
||||
class StarsService:
|
||||
@@ -26,14 +27,14 @@ class StarsService:
|
||||
self.referral_service = referral_service
|
||||
|
||||
async def create_invoice(self, session: AsyncSession, user_id: int, months: int,
|
||||
stars_price: int, description: str) -> Optional[int]:
|
||||
stars_price: int, description: str, sale_mode: str = "subscription") -> Optional[int]:
|
||||
payment_record_data = {
|
||||
"user_id": user_id,
|
||||
"amount": float(stars_price),
|
||||
"currency": "XTR",
|
||||
"status": "pending_stars",
|
||||
"description": description,
|
||||
"subscription_duration_months": months,
|
||||
"subscription_duration_months": int(months),
|
||||
"provider": "telegram_stars",
|
||||
}
|
||||
try:
|
||||
@@ -46,7 +47,7 @@ class StarsService:
|
||||
exc_info=True)
|
||||
return None
|
||||
|
||||
payload = f"{db_payment_record.payment_id}:{months}"
|
||||
payload = f"{db_payment_record.payment_id}:{months}:{sale_mode}"
|
||||
prices = [LabeledPrice(label=description, amount=stars_price)]
|
||||
try:
|
||||
await self.bot.send_invoice(
|
||||
@@ -69,7 +70,8 @@ class StarsService:
|
||||
payment_db_id: int,
|
||||
months: int,
|
||||
stars_amount: int,
|
||||
i18n_data: dict) -> None:
|
||||
i18n_data: dict,
|
||||
sale_mode: str = "subscription") -> None:
|
||||
try:
|
||||
await payment_dal.update_provider_payment_and_status(
|
||||
session, payment_db_id,
|
||||
@@ -86,23 +88,27 @@ class StarsService:
|
||||
activation_details = await self.subscription_service.activate_subscription(
|
||||
session,
|
||||
message.from_user.id,
|
||||
months,
|
||||
int(months) if sale_mode != "traffic" else 0,
|
||||
float(stars_amount),
|
||||
payment_db_id,
|
||||
provider="telegram_stars",
|
||||
sale_mode=sale_mode,
|
||||
traffic_gb=months if sale_mode == "traffic" else None,
|
||||
)
|
||||
if not activation_details or not activation_details.get("end_date"):
|
||||
logging.error(
|
||||
f"Failed to activate subscription after stars payment for user {message.from_user.id}")
|
||||
return
|
||||
|
||||
referral_bonus = await self.referral_service.apply_referral_bonuses_for_payment(
|
||||
session,
|
||||
message.from_user.id,
|
||||
months,
|
||||
current_payment_db_id=payment_db_id,
|
||||
skip_if_active_before_payment=False,
|
||||
)
|
||||
referral_bonus = None
|
||||
if sale_mode != "traffic":
|
||||
referral_bonus = await self.referral_service.apply_referral_bonuses_for_payment(
|
||||
session,
|
||||
message.from_user.id,
|
||||
int(months) or 1,
|
||||
current_payment_db_id=payment_db_id,
|
||||
skip_if_active_before_payment=False,
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
applied_days = referral_bonus.get("referee_bonus_applied_days") if referral_bonus else None
|
||||
@@ -116,11 +122,18 @@ class StarsService:
|
||||
i18n: JsonI18n = i18n_data.get("i18n_instance")
|
||||
_ = lambda k, **kw: i18n.gettext(current_lang, k, **kw) if i18n else k
|
||||
|
||||
config_link = activation_details.get("subscription_url") or _(
|
||||
"config_link_not_available"
|
||||
)
|
||||
raw_config_link = activation_details.get("subscription_url") if activation_details else None
|
||||
config_link_display, connect_button_url = await prepare_config_links(self.settings, raw_config_link)
|
||||
config_link_text = config_link_display or _("config_link_not_available")
|
||||
|
||||
if applied_days:
|
||||
if sale_mode == "traffic":
|
||||
success_msg = _(
|
||||
"payment_successful_traffic_full",
|
||||
traffic_gb=str(int(months)) if float(months).is_integer() else f"{months:g}",
|
||||
end_date=final_end.strftime('%Y-%m-%d'),
|
||||
config_link=config_link_text,
|
||||
)
|
||||
elif applied_days:
|
||||
inviter_name_display = _("friend_placeholder")
|
||||
db_user = await user_dal.get_user_by_id(session, message.from_user.id)
|
||||
if db_user and db_user.referred_by_id:
|
||||
@@ -138,17 +151,22 @@ class StarsService:
|
||||
bonus_days=applied_days,
|
||||
final_end_date=final_end.strftime('%Y-%m-%d'),
|
||||
inviter_name=inviter_name_display,
|
||||
config_link=config_link,
|
||||
config_link=config_link_text,
|
||||
)
|
||||
else:
|
||||
success_msg = _(
|
||||
"payment_successful_full",
|
||||
months=months,
|
||||
end_date=final_end.strftime('%Y-%m-%d'),
|
||||
config_link=config_link,
|
||||
config_link=config_link_text,
|
||||
)
|
||||
markup = get_connect_and_main_keyboard(
|
||||
current_lang, i18n, self.settings, config_link, preserve_message=True
|
||||
current_lang,
|
||||
i18n,
|
||||
self.settings,
|
||||
config_link_display,
|
||||
connect_button_url=connect_button_url,
|
||||
preserve_message=True,
|
||||
)
|
||||
try:
|
||||
await self.bot.send_message(
|
||||
@@ -170,9 +188,10 @@ class StarsService:
|
||||
user_id=message.from_user.id,
|
||||
amount=float(stars_amount),
|
||||
currency="XTR",
|
||||
months=months,
|
||||
months=int(months) if sale_mode != "traffic" else 0,
|
||||
payment_provider="stars",
|
||||
username=user.username if user else None
|
||||
username=user.username if user else None,
|
||||
traffic_gb=months if sale_mode == "traffic" else None,
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to send stars payment notification: {e}")
|
||||
|
||||
@@ -7,6 +7,7 @@ from bot.middlewares.i18n import JsonI18n
|
||||
|
||||
from db.dal import user_dal, subscription_dal, promo_code_dal, payment_dal, user_billing_dal
|
||||
from bot.utils.date_utils import add_months
|
||||
from bot.utils.config_link import prepare_config_links
|
||||
from db.models import User, Subscription
|
||||
|
||||
from config.settings import Settings
|
||||
@@ -54,6 +55,33 @@ class SubscriptionService:
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _extract_panel_traffic_details(
|
||||
self, panel_user_data: Dict[str, Any]
|
||||
) -> Tuple[Optional[int], Optional[int], Optional[str]]:
|
||||
traffic_stats = panel_user_data.get("userTraffic") or {}
|
||||
used = traffic_stats.get("usedTrafficBytes")
|
||||
if used is None:
|
||||
used = panel_user_data.get("usedTrafficBytes")
|
||||
limit = panel_user_data.get("trafficLimitBytes")
|
||||
strategy = panel_user_data.get("trafficLimitStrategy")
|
||||
if strategy is None:
|
||||
strategy = traffic_stats.get("trafficLimitStrategy")
|
||||
return used, limit, strategy
|
||||
|
||||
def _extract_lifetime_used_traffic(
|
||||
self, panel_user_data: Dict[str, Any]
|
||||
) -> Optional[int]:
|
||||
traffic_stats = panel_user_data.get("userTraffic") or {}
|
||||
lifetime = traffic_stats.get("lifetimeUsedTrafficBytes")
|
||||
if lifetime is None:
|
||||
lifetime = panel_user_data.get("lifetimeUsedTrafficBytes")
|
||||
try:
|
||||
if lifetime is None:
|
||||
return None
|
||||
return int(lifetime)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
async def _notify_admin_panel_user_creation_failed(self, user_id: int):
|
||||
if not self.bot or not self.i18n or not self.settings.ADMIN_IDS:
|
||||
return
|
||||
@@ -125,6 +153,7 @@ class SubscriptionService:
|
||||
(db_user.last_name or "") if db_user else "",
|
||||
]),
|
||||
specific_squad_uuids=self.settings.parsed_user_squad_uuids,
|
||||
external_squad_uuid=self.settings.parsed_user_external_squad_uuid,
|
||||
default_traffic_limit_bytes=self.settings.user_traffic_limit_bytes,
|
||||
default_traffic_limit_strategy=self.settings.USER_TRAFFIC_STRATEGY,
|
||||
)
|
||||
@@ -153,6 +182,7 @@ class SubscriptionService:
|
||||
(db_user.last_name or "") if db_user else "",
|
||||
]),
|
||||
specific_squad_uuids=self.settings.parsed_user_squad_uuids,
|
||||
external_squad_uuid=self.settings.parsed_user_external_squad_uuid,
|
||||
default_traffic_limit_bytes=self.settings.user_traffic_limit_bytes,
|
||||
default_traffic_limit_strategy=self.settings.USER_TRAFFIC_STRATEGY,
|
||||
)
|
||||
@@ -417,6 +447,117 @@ class SubscriptionService:
|
||||
"subscription_url": final_subscription_url,
|
||||
}
|
||||
|
||||
async def _activate_traffic_package(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
traffic_gb: float,
|
||||
payment_amount: float,
|
||||
payment_db_id: int,
|
||||
provider: str = "yookassa",
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Activate or extend a traffic-based package instead of a time-based subscription."""
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
if not db_user:
|
||||
logging.error("User %s not found for traffic package activation", user_id)
|
||||
return None
|
||||
|
||||
panel_user_uuid, panel_sub_link_id, panel_short_uuid, _ = (
|
||||
await self._get_or_create_panel_user_link_details(session, user_id, db_user)
|
||||
)
|
||||
|
||||
if not panel_user_uuid or not panel_sub_link_id:
|
||||
logging.error("Failed to ensure panel linkage for user %s during traffic activation", user_id)
|
||||
return None
|
||||
|
||||
panel_user_data = await self.panel_service.get_user_by_uuid(panel_user_uuid) or {}
|
||||
current_used, current_limit, _ = self._extract_panel_traffic_details(panel_user_data)
|
||||
|
||||
active_sub = await subscription_dal.get_active_subscription_by_user_id(
|
||||
session, user_id, panel_user_uuid
|
||||
)
|
||||
if current_limit is None and active_sub:
|
||||
current_limit = active_sub.traffic_limit_bytes
|
||||
if current_used is None and active_sub:
|
||||
current_used = active_sub.traffic_used_bytes
|
||||
|
||||
purchase_bytes = int(float(traffic_gb) * (1024**3))
|
||||
new_limit = (current_limit or 0) + purchase_bytes
|
||||
|
||||
start_date = datetime.now(timezone.utc)
|
||||
# Set a far-future expiry to satisfy panel requirements; keep the latest known expiry if it's further.
|
||||
far_future = datetime(2099, 1, 1, tzinfo=timezone.utc)
|
||||
final_end_date = far_future
|
||||
if active_sub and active_sub.end_date and active_sub.end_date > final_end_date:
|
||||
final_end_date = active_sub.end_date
|
||||
|
||||
await subscription_dal.deactivate_other_active_subscriptions(
|
||||
session, panel_user_uuid, panel_sub_link_id
|
||||
)
|
||||
|
||||
sub_payload = {
|
||||
"user_id": user_id,
|
||||
"panel_user_uuid": panel_user_uuid,
|
||||
"panel_subscription_uuid": panel_sub_link_id,
|
||||
"start_date": start_date,
|
||||
"end_date": final_end_date,
|
||||
"duration_months": 0,
|
||||
"is_active": True,
|
||||
"status_from_panel": "ACTIVE",
|
||||
"traffic_limit_bytes": new_limit,
|
||||
"traffic_used_bytes": current_used,
|
||||
"provider": provider,
|
||||
"skip_notifications": True,
|
||||
"auto_renew_enabled": False,
|
||||
}
|
||||
|
||||
try:
|
||||
new_or_updated_sub = await subscription_dal.upsert_subscription(session, sub_payload)
|
||||
except Exception as exc:
|
||||
logging.error("Failed to upsert traffic subscription for user %s: %s", user_id, exc, exc_info=True)
|
||||
return None
|
||||
|
||||
panel_update_payload = self._build_panel_update_payload(
|
||||
panel_user_uuid=panel_user_uuid,
|
||||
expire_at=final_end_date,
|
||||
status="ACTIVE",
|
||||
traffic_limit_bytes=new_limit,
|
||||
traffic_limit_strategy="NO_RESET",
|
||||
)
|
||||
|
||||
panel_update_payload["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
|
||||
)
|
||||
if not updated_panel_user or updated_panel_user.get("error"):
|
||||
logging.warning(
|
||||
"Panel user details update FAILED for traffic package user %s. Response: %s",
|
||||
panel_user_uuid,
|
||||
updated_panel_user,
|
||||
)
|
||||
return None
|
||||
|
||||
final_subscription_url = updated_panel_user.get("subscriptionUrl")
|
||||
final_panel_short_uuid = updated_panel_user.get("shortUuid", panel_short_uuid)
|
||||
|
||||
return {
|
||||
"subscription_id": new_or_updated_sub.subscription_id,
|
||||
"end_date": final_end_date,
|
||||
"is_active": True,
|
||||
"panel_user_uuid": panel_user_uuid,
|
||||
"panel_short_uuid": final_panel_short_uuid,
|
||||
"subscription_url": final_subscription_url,
|
||||
"applied_promo_bonus_days": 0,
|
||||
"traffic_limit_bytes": new_limit,
|
||||
}
|
||||
|
||||
async def activate_subscription(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
@@ -426,8 +567,21 @@ class SubscriptionService:
|
||||
payment_db_id: int,
|
||||
promo_code_id_from_payment: Optional[int] = None,
|
||||
provider: str = "yookassa",
|
||||
sale_mode: str = "subscription",
|
||||
traffic_gb: Optional[float] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
|
||||
if sale_mode == "traffic" or getattr(self.settings, "traffic_sale_mode", False):
|
||||
target_gb = traffic_gb if traffic_gb is not None else float(months)
|
||||
return await self._activate_traffic_package(
|
||||
session=session,
|
||||
user_id=user_id,
|
||||
traffic_gb=target_gb,
|
||||
payment_amount=payment_amount,
|
||||
payment_db_id=payment_db_id,
|
||||
provider=provider,
|
||||
)
|
||||
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
if not db_user:
|
||||
logging.error(
|
||||
@@ -445,6 +599,11 @@ class SubscriptionService:
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
months_int = int(months)
|
||||
except Exception:
|
||||
months_int = 1
|
||||
|
||||
current_active_sub = await subscription_dal.get_active_subscription_by_user_id(
|
||||
session, user_id, panel_user_uuid
|
||||
)
|
||||
@@ -457,7 +616,7 @@ class SubscriptionService:
|
||||
start_date = current_active_sub.end_date
|
||||
|
||||
# base duration by months
|
||||
end_after_months = add_months(start_date, months)
|
||||
end_after_months = add_months(start_date, months_int)
|
||||
duration_days_total = (end_after_months - start_date).days
|
||||
applied_promo_bonus_days = 0
|
||||
|
||||
@@ -498,19 +657,25 @@ class SubscriptionService:
|
||||
session, panel_user_uuid, panel_sub_link_id
|
||||
)
|
||||
|
||||
auto_renew_should_enable = False
|
||||
if provider == "yookassa" and self.settings.yookassa_autopayments_active:
|
||||
auto_renew_should_enable = await user_billing_dal.user_has_saved_payment_method(
|
||||
session, user_id
|
||||
)
|
||||
|
||||
sub_payload = {
|
||||
"user_id": user_id,
|
||||
"panel_user_uuid": panel_user_uuid,
|
||||
"panel_subscription_uuid": panel_sub_link_id,
|
||||
"start_date": start_date,
|
||||
"end_date": final_end_date,
|
||||
"duration_months": months,
|
||||
"duration_months": months_int,
|
||||
"is_active": True,
|
||||
"status_from_panel": "ACTIVE",
|
||||
"traffic_limit_bytes": self.settings.user_traffic_limit_bytes,
|
||||
"provider": provider,
|
||||
"skip_notifications": provider == "tribute" and self.settings.TRIBUTE_SKIP_NOTIFICATIONS,
|
||||
"auto_renew_enabled": True,
|
||||
"skip_notifications": False,
|
||||
"auto_renew_enabled": auto_renew_should_enable,
|
||||
}
|
||||
try:
|
||||
new_or_updated_sub = await subscription_dal.upsert_subscription(
|
||||
@@ -616,6 +781,7 @@ class SubscriptionService:
|
||||
"is_active": True,
|
||||
"status_from_panel": "ACTIVE_BONUS",
|
||||
"traffic_limit_bytes": traffic_limit,
|
||||
"auto_renew_enabled": False,
|
||||
}
|
||||
await subscription_dal.deactivate_other_active_subscriptions(
|
||||
session, panel_uuid, panel_sub_uuid
|
||||
@@ -701,12 +867,22 @@ class SubscriptionService:
|
||||
await user_dal.update_user(session, user_id, {"panel_user_uuid": None})
|
||||
return None
|
||||
|
||||
panel_lifetime_used = self._extract_lifetime_used_traffic(panel_user_data)
|
||||
if (
|
||||
panel_lifetime_used is not None
|
||||
and db_user.lifetime_used_traffic_bytes != panel_lifetime_used
|
||||
):
|
||||
await user_dal.update_user(
|
||||
session,
|
||||
user_id,
|
||||
{"lifetime_used_traffic_bytes": panel_lifetime_used},
|
||||
)
|
||||
|
||||
if local_active_sub:
|
||||
update_payload_local = {}
|
||||
panel_status = panel_user_data.get("status", "UNKNOWN").upper()
|
||||
panel_expire_at_str = panel_user_data.get("expireAt")
|
||||
panel_traffic_used = panel_user_data.get("usedTrafficBytes")
|
||||
panel_traffic_limit = panel_user_data.get("trafficLimitBytes")
|
||||
panel_traffic_used, panel_traffic_limit, _ = self._extract_panel_traffic_details(panel_user_data)
|
||||
panel_sub_uuid_from_panel = panel_user_data.get(
|
||||
"subscriptionUuid"
|
||||
) or panel_user_data.get("shortUuid")
|
||||
@@ -759,6 +935,9 @@ class SubscriptionService:
|
||||
if panel_user_data.get("expireAt")
|
||||
else None
|
||||
)
|
||||
panel_traffic_used, panel_traffic_limit, panel_traffic_strategy = self._extract_panel_traffic_details(panel_user_data)
|
||||
config_link_raw = panel_user_data.get("subscriptionUrl")
|
||||
display_link, connect_button_url = await prepare_config_links(self.settings, config_link_raw)
|
||||
hwid_limit = panel_user_data.get("hwidDeviceLimit")
|
||||
if hwid_limit is None:
|
||||
hwid_limit = self.settings.USER_HWID_DEVICE_LIMIT
|
||||
@@ -767,9 +946,11 @@ class SubscriptionService:
|
||||
"user_id": panel_user_data.get("uuid"),
|
||||
"end_date": panel_end_date,
|
||||
"status_from_panel": panel_user_data.get("status", "UNKNOWN").upper(),
|
||||
"config_link": panel_user_data.get("subscriptionUrl"),
|
||||
"traffic_limit_bytes": panel_user_data.get("trafficLimitBytes"),
|
||||
"traffic_used_bytes": panel_user_data.get("usedTrafficBytes"),
|
||||
"config_link": display_link,
|
||||
"connect_button_url": connect_button_url,
|
||||
"traffic_limit_bytes": panel_traffic_limit,
|
||||
"traffic_used_bytes": panel_traffic_used,
|
||||
"traffic_limit_strategy": panel_traffic_strategy,
|
||||
"user_bot_username": db_user.username,
|
||||
"is_panel_data": True,
|
||||
"max_devices": hwid_limit,
|
||||
@@ -813,13 +994,16 @@ class SubscriptionService:
|
||||
sub: Subscription,
|
||||
) -> bool:
|
||||
"""Attempt to charge user using saved payment method. Return True on initiated/handled, False on failure."""
|
||||
if getattr(self.settings, "traffic_sale_mode", False):
|
||||
logging.info("Auto-renew skipped: traffic sale mode enabled")
|
||||
return True
|
||||
if not sub.auto_renew_enabled:
|
||||
return True
|
||||
# If autopayments are disabled globally, skip charging attempts
|
||||
if not getattr(self.settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
|
||||
if not self.settings.yookassa_autopayments_active:
|
||||
return True
|
||||
if sub.provider == "tribute":
|
||||
# Tribute is paid externally; we do not auto-charge here
|
||||
if sub.provider != "yookassa":
|
||||
logging.info("Auto-renew skipped: provider %s does not support auto-renew", sub.provider)
|
||||
return True
|
||||
|
||||
from db.dal.user_billing_dal import get_user_default_payment_method
|
||||
@@ -892,6 +1076,7 @@ class SubscriptionService:
|
||||
status: Optional[str] = None,
|
||||
traffic_limit_bytes: Optional[int] = None,
|
||||
include_uuid: bool = True,
|
||||
traffic_limit_strategy: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
payload: Dict[str, Any] = {}
|
||||
if include_uuid and panel_user_uuid:
|
||||
@@ -902,7 +1087,9 @@ class SubscriptionService:
|
||||
payload["status"] = status
|
||||
if traffic_limit_bytes is not None:
|
||||
payload["trafficLimitBytes"] = traffic_limit_bytes
|
||||
payload["trafficLimitStrategy"] = self.settings.USER_TRAFFIC_STRATEGY
|
||||
payload["trafficLimitStrategy"] = traffic_limit_strategy or self.settings.USER_TRAFFIC_STRATEGY
|
||||
if self.settings.parsed_user_squad_uuids:
|
||||
payload["activeInternalSquads"] = self.settings.parsed_user_squad_uuids
|
||||
if self.settings.parsed_user_external_squad_uuid:
|
||||
payload["externalSquadUuid"] = self.settings.parsed_user_external_squad_uuid
|
||||
return payload
|
||||
|
||||
@@ -1,335 +0,0 @@
|
||||
import logging
|
||||
import hmac
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Optional
|
||||
|
||||
from aiohttp import web
|
||||
from aiogram import Bot
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from config.settings import Settings
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
from bot.services.referral_service import ReferralService
|
||||
from .notification_service import NotificationService
|
||||
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
|
||||
from db.dal import payment_dal, user_dal, subscription_dal
|
||||
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
|
||||
|
||||
|
||||
def convert_period_to_months(period: Optional[str]) -> int:
|
||||
"""Map Tribute subscription period strings to months."""
|
||||
if not period:
|
||||
return 1
|
||||
|
||||
mapping = {
|
||||
"monthly": 1,
|
||||
"quarterly": 3,
|
||||
"3-month": 3,
|
||||
"3months": 3,
|
||||
"3-months": 3,
|
||||
"q": 3,
|
||||
"halfyearly": 6,
|
||||
"yearly": 12,
|
||||
"annual": 12,
|
||||
"y": 12,
|
||||
}
|
||||
return mapping.get(period.lower(), 1)
|
||||
|
||||
|
||||
class TributeService:
|
||||
def __init__(
|
||||
self,
|
||||
bot: Bot,
|
||||
settings: Settings,
|
||||
i18n: JsonI18n,
|
||||
async_session_factory: sessionmaker,
|
||||
panel_service: PanelApiService,
|
||||
subscription_service: SubscriptionService,
|
||||
referral_service: ReferralService,
|
||||
):
|
||||
self.bot = bot
|
||||
self.settings = settings
|
||||
self.i18n = i18n
|
||||
self.async_session_factory = async_session_factory
|
||||
self.panel_service = panel_service
|
||||
self.subscription_service = subscription_service
|
||||
self.referral_service = referral_service
|
||||
|
||||
async def handle_webhook(self, raw_body: bytes, signature_header: Optional[str]) -> web.Response:
|
||||
settings = self.settings
|
||||
bot = self.bot
|
||||
i18n = self.i18n
|
||||
async_session_factory = self.async_session_factory
|
||||
subscription_service = self.subscription_service
|
||||
referral_service = self.referral_service
|
||||
|
||||
def ok(data: Optional[dict] = None) -> web.Response:
|
||||
payload = {"status": "ok"}
|
||||
if data:
|
||||
payload.update(data)
|
||||
return web.json_response(payload, status=200)
|
||||
|
||||
def ignored(reason: str) -> web.Response:
|
||||
return web.json_response({"status": "ignored", "reason": reason}, status=200)
|
||||
|
||||
def bad_request(reason: str) -> web.Response:
|
||||
return web.json_response({"status": "error", "reason": reason}, status=400)
|
||||
|
||||
if settings.TRIBUTE_API_KEY:
|
||||
if not signature_header:
|
||||
return web.json_response({"status": "error", "reason": "no_signature"}, status=403)
|
||||
expected_sig = hmac.new(settings.TRIBUTE_API_KEY.encode(), raw_body,
|
||||
hashlib.sha256).hexdigest()
|
||||
if not hmac.compare_digest(expected_sig, signature_header):
|
||||
return web.json_response({"status": "error", "reason": "invalid_signature"}, status=403)
|
||||
|
||||
try:
|
||||
payload = json.loads(raw_body.decode())
|
||||
except Exception:
|
||||
return bad_request("invalid_json")
|
||||
|
||||
logging.info(
|
||||
"Tribute webhook data: %s",
|
||||
json.dumps(payload, ensure_ascii=False),
|
||||
)
|
||||
|
||||
# Tribute webhook spec: only two events are sent
|
||||
# name: new_subscription | cancelled_subscription
|
||||
event_name = payload.get("name")
|
||||
data = payload.get("payload", {})
|
||||
|
||||
# Mandatory routing fields
|
||||
user_id = data.get("telegram_user_id")
|
||||
if not user_id:
|
||||
# Permanent format issue — acknowledge to avoid retries
|
||||
return ignored("missing_telegram_user_id")
|
||||
|
||||
period_val = data.get("period")
|
||||
months = convert_period_to_months(period_val)
|
||||
|
||||
# Tribute sends amount in minor units (kopecks/cents). Convert to major units before persisting.
|
||||
amount_value = data.get("amount") or data.get("price")
|
||||
currency = (data.get("currency") or settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
|
||||
if amount_value is not None:
|
||||
try:
|
||||
amount_minor_units = float(amount_value)
|
||||
except (TypeError, ValueError):
|
||||
amount_minor_units = 0.0
|
||||
amount_float = round(amount_minor_units / 100.0, 2)
|
||||
else:
|
||||
amount_float = 0.0
|
||||
|
||||
async with async_session_factory() as session:
|
||||
if event_name == "new_subscription":
|
||||
# Use a unique, idempotent provider payment id per webhook event
|
||||
# Prefer explicit event/payment identifiers if present; otherwise fall back to payload hash suffix
|
||||
candidate_event_id = (
|
||||
str(data.get("event_id") or data.get("payment_id") or data.get("purchase_id") or data.get("invoice_id") or "")
|
||||
)
|
||||
if candidate_event_id:
|
||||
provider_payment_id = candidate_event_id
|
||||
else:
|
||||
# Combine subscription_id (if any) with a stable hash of the raw payload to ensure uniqueness per event
|
||||
sub_id_part = str(data.get("subscription_id") or "sub")
|
||||
payload_hash = hashlib.sha256(raw_body).hexdigest()[:16]
|
||||
provider_payment_id = f"{sub_id_part}:{payload_hash}"
|
||||
|
||||
# Idempotent ensure payment
|
||||
payment_record = await payment_dal.ensure_payment_with_provider_id(
|
||||
session,
|
||||
user_id=int(user_id),
|
||||
amount=amount_float,
|
||||
currency=currency,
|
||||
months=months,
|
||||
description="Tribute subscription",
|
||||
provider="tribute",
|
||||
provider_payment_id=provider_payment_id,
|
||||
)
|
||||
|
||||
activation_details = await subscription_service.activate_subscription(
|
||||
session,
|
||||
int(user_id),
|
||||
months,
|
||||
float(amount_float),
|
||||
payment_record.payment_id,
|
||||
provider="tribute",
|
||||
)
|
||||
referral_bonus = await referral_service.apply_referral_bonuses_for_payment(
|
||||
session,
|
||||
int(user_id),
|
||||
months,
|
||||
current_payment_db_id=payment_record.payment_id,
|
||||
skip_if_active_before_payment=False,
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
db_user = await user_dal.get_user_by_id(session, int(user_id))
|
||||
lang = db_user.language_code if db_user and db_user.language_code else settings.DEFAULT_LANGUAGE
|
||||
_ = lambda k, **kw: i18n.gettext(lang, k, **kw)
|
||||
|
||||
applied_ref_days = referral_bonus.get('referee_bonus_applied_days') if referral_bonus else None
|
||||
final_end = (referral_bonus.get('referee_new_end_date')
|
||||
if referral_bonus else None)
|
||||
if not final_end:
|
||||
final_end = activation_details.get('end_date')
|
||||
|
||||
if final_end:
|
||||
config_link = activation_details.get("subscription_url") or _(
|
||||
"config_link_not_available"
|
||||
)
|
||||
|
||||
if applied_ref_days:
|
||||
inviter_name_display = _('friend_placeholder')
|
||||
if db_user and db_user.referred_by_id:
|
||||
inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id)
|
||||
if inviter:
|
||||
safe_name = sanitize_display_name(inviter.first_name) if inviter.first_name else None
|
||||
if safe_name:
|
||||
inviter_name_display = safe_name
|
||||
elif inviter.username:
|
||||
inviter_name_display = username_for_display(inviter.username, with_at=False)
|
||||
success_msg = _(
|
||||
"payment_successful_with_referral_bonus_full",
|
||||
months=months,
|
||||
base_end_date=activation_details["end_date"].strftime('%Y-%m-%d'),
|
||||
bonus_days=applied_ref_days,
|
||||
final_end_date=final_end.strftime('%Y-%m-%d'),
|
||||
inviter_name=inviter_name_display,
|
||||
config_link=config_link,
|
||||
)
|
||||
else:
|
||||
success_msg = _(
|
||||
"payment_successful_full",
|
||||
months=months,
|
||||
end_date=final_end.strftime('%Y-%m-%d'),
|
||||
config_link=config_link,
|
||||
)
|
||||
markup = get_connect_and_main_keyboard(
|
||||
lang,
|
||||
i18n,
|
||||
settings,
|
||||
config_link,
|
||||
preserve_message=True,
|
||||
)
|
||||
|
||||
try:
|
||||
# Use user's DB language in success messages prepared above
|
||||
await bot.send_message(
|
||||
int(user_id),
|
||||
success_msg,
|
||||
reply_markup=markup,
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(
|
||||
f"Failed to send Tribute payment success message to user {user_id}: {e}")
|
||||
|
||||
# Send notification about payment
|
||||
try:
|
||||
notification_service = NotificationService(bot, settings, i18n)
|
||||
user = await user_dal.get_user_by_id(session, int(user_id))
|
||||
await notification_service.notify_payment_received(
|
||||
user_id=int(user_id),
|
||||
amount=float(amount_float),
|
||||
currency=currency,
|
||||
months=months,
|
||||
payment_provider="tribute",
|
||||
username=user.username if user else None
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to send tribute payment notification: {e}")
|
||||
elif event_name == "cancelled_subscription":
|
||||
await self._handle_tribute_cancellation(session, int(user_id), bot, i18n)
|
||||
|
||||
else:
|
||||
await session.commit()
|
||||
# Acknowledge to Tribute that webhook was received and processed/accepted
|
||||
return ok({"event": event_name or "unknown"})
|
||||
|
||||
async def _handle_tribute_cancellation(self, session, user_id: int, bot: Bot, i18n: JsonI18n):
|
||||
"""Handle tribute subscription cancellation - set subscription to 1 day grace period"""
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from db.dal import subscription_dal, user_dal
|
||||
from bot.keyboards.inline.user_keyboards import get_subscribe_only_markup
|
||||
|
||||
try:
|
||||
grace_days = 1
|
||||
grace_end = datetime.now(timezone.utc) + timedelta(days=grace_days)
|
||||
|
||||
active_subscriptions = await subscription_dal.get_active_subscriptions_for_user(session, user_id)
|
||||
|
||||
panel_users_updated: set[str] = set()
|
||||
for sub in active_subscriptions:
|
||||
updated_sub = await subscription_dal.update_subscription(
|
||||
session,
|
||||
sub.subscription_id,
|
||||
{
|
||||
"end_date": grace_end,
|
||||
"status_from_panel": "CANCELLED",
|
||||
"skip_notifications": True,
|
||||
},
|
||||
)
|
||||
|
||||
panel_uuid = updated_sub.panel_user_uuid if updated_sub else None
|
||||
if panel_uuid and panel_uuid not in panel_users_updated:
|
||||
panel_users_updated.add(panel_uuid)
|
||||
panel_payload = {
|
||||
"expireAt": grace_end.isoformat(timespec="milliseconds").replace("+00:00", "Z"),
|
||||
}
|
||||
try:
|
||||
await self.panel_service.update_user_details_on_panel(
|
||||
panel_uuid,
|
||||
panel_payload,
|
||||
log_response=False,
|
||||
)
|
||||
except Exception as panel_err:
|
||||
logging.error(
|
||||
f"Failed to update panel expiry for user {user_id} (panel_uuid {panel_uuid}) during Tribute cancellation: {panel_err}")
|
||||
|
||||
await session.commit()
|
||||
|
||||
# Send notification about cancellation if enabled
|
||||
if not self.settings.TRIBUTE_SKIP_CANCELLATION_NOTIFICATIONS:
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
lang = db_user.language_code if db_user and db_user.language_code else self.settings.DEFAULT_LANGUAGE
|
||||
first_name = db_user.first_name or f"User {user_id}" if db_user else f"User {user_id}"
|
||||
|
||||
_ = lambda k, **kw: i18n.gettext(lang, k, **kw) if i18n else k
|
||||
markup = get_subscribe_only_markup(lang, i18n)
|
||||
|
||||
cancellation_msg = _(
|
||||
"tribute_subscription_cancelled",
|
||||
default="🚨 <b>Подписка отменена</b>\n\n"
|
||||
"Ваша подписка Tribute была отменена. У вас есть 24 часа для восстановления доступа, "
|
||||
"после чего подписка будет заблокирована.\n\n"
|
||||
"Для продления подписки нажмите кнопку ниже.",
|
||||
user_name=first_name
|
||||
)
|
||||
|
||||
try:
|
||||
await bot.send_message(
|
||||
int(user_id),
|
||||
cancellation_msg,
|
||||
reply_markup=markup,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to send tribute cancellation notification to user {user_id}: {e}")
|
||||
|
||||
logging.info(f"Tribute subscription cancelled for user {user_id}, grace period set to 1 day")
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error handling tribute cancellation for user {user_id}: {e}")
|
||||
await session.rollback()
|
||||
|
||||
|
||||
async def tribute_webhook_route(request: web.Request):
|
||||
"""AIOHTTP route handler for Tribute webhook calls."""
|
||||
tribute_service: TributeService = request.app['tribute_service']
|
||||
raw_body = await request.read()
|
||||
signature_header = request.headers.get('trbt-signature')
|
||||
return await tribute_service.handle_webhook(raw_body, signature_header)
|
||||
@@ -21,7 +21,10 @@ class YooKassaService:
|
||||
|
||||
self.settings = settings_obj
|
||||
|
||||
if not shop_id or not secret_key:
|
||||
if self.settings and not self.settings.YOOKASSA_ENABLED:
|
||||
logging.warning("YooKassa is disabled via YOOKASSA_ENABLED flag. Payment functionality will be DISABLED.")
|
||||
self.configured = False
|
||||
elif not shop_id or not secret_key:
|
||||
logging.warning(
|
||||
"YooKassa SHOP_ID or SECRET_KEY not configured in settings. "
|
||||
"Payment functionality will be DISABLED.")
|
||||
@@ -111,10 +114,12 @@ class YooKassaService:
|
||||
capture = False
|
||||
amount = max(amount, 1.00)
|
||||
builder.set_capture(capture)
|
||||
builder.set_confirmation({
|
||||
"type": ConfirmationType.REDIRECT,
|
||||
"return_url": self.return_url
|
||||
})
|
||||
if not payment_method_id:
|
||||
# Saved payment_method_id charges must omit confirmation per YooKassa API
|
||||
builder.set_confirmation({
|
||||
"type": ConfirmationType.REDIRECT,
|
||||
"return_url": self.return_url
|
||||
})
|
||||
builder.set_description(description)
|
||||
builder.set_metadata(metadata)
|
||||
if save_payment_method:
|
||||
|
||||
@@ -28,6 +28,7 @@ class AdminStates(StatesGroup):
|
||||
waiting_for_user_search = State()
|
||||
waiting_for_subscription_days_to_add = State()
|
||||
waiting_for_direct_message_to_user = State()
|
||||
waiting_for_user_delete_confirmation = State()
|
||||
|
||||
# Ads campaigns
|
||||
waiting_for_ad_source = State()
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import logging
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from config.settings import Settings
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
|
||||
|
||||
async def _encrypt_raw_link(settings: Settings, raw_link: str) -> Optional[str]:
|
||||
"""Encrypt the raw subscription URL using the panel's happ crypt4 API."""
|
||||
async with PanelApiService(settings) as panel_service:
|
||||
encrypted_link = await panel_service.encrypt_happ_link(raw_link)
|
||||
if encrypted_link:
|
||||
return encrypted_link
|
||||
return None
|
||||
|
||||
|
||||
async def prepare_config_links(settings: Settings, raw_link: Optional[str]) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""
|
||||
Build the user-facing connection key and the URL for the connect button.
|
||||
|
||||
Returns (display_link, button_link). When CRYPT4 is enabled the display link
|
||||
is encrypted and prefixed with happ://crypt4/ by panel API, and the button link is wrapped
|
||||
with CRYPT4_REDIRECT_URL if provided.
|
||||
"""
|
||||
if not raw_link:
|
||||
return None, None
|
||||
|
||||
cleaned = raw_link.strip()
|
||||
if not cleaned:
|
||||
return None, None
|
||||
|
||||
display_link = cleaned
|
||||
button_link = cleaned
|
||||
|
||||
if settings.CRYPT4_ENABLED:
|
||||
encrypted_payload = await _encrypt_raw_link(settings, cleaned)
|
||||
if encrypted_payload:
|
||||
display_link = encrypted_payload
|
||||
button_link = display_link
|
||||
else:
|
||||
logging.error("CRYPT4_ENABLED is set but encryption failed; using raw link as fallback.")
|
||||
|
||||
redirect_base = (settings.CRYPT4_REDIRECT_URL or "").strip()
|
||||
if redirect_base and settings.CRYPT4_ENABLED and display_link:
|
||||
button_link = f"{redirect_base}{display_link}"
|
||||
|
||||
return display_link, button_link
|
||||
@@ -5,6 +5,12 @@ from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from collections import deque
|
||||
from aiogram import Bot
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
|
||||
from bot.utils.telegram_markup import (
|
||||
is_profile_link_error,
|
||||
remove_profile_link_buttons,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -51,14 +57,31 @@ class MessageQueue:
|
||||
message = self.queue.popleft()
|
||||
try:
|
||||
await self._send_message(message)
|
||||
self.last_send_times.append(datetime.now())
|
||||
self.total_sent += 1
|
||||
self._record_send_time()
|
||||
|
||||
except TelegramBadRequest as exc:
|
||||
fallback_message = self._build_profile_link_fallback(message, exc)
|
||||
if fallback_message:
|
||||
logging.warning(
|
||||
"Telegram rejected profile buttons for chat %s: %s. "
|
||||
"Retrying without tg:// links.",
|
||||
message.chat_id,
|
||||
getattr(exc, "message", "") or str(exc),
|
||||
)
|
||||
try:
|
||||
await self._send_message(fallback_message)
|
||||
self._record_send_time()
|
||||
continue
|
||||
except Exception as retry_exc:
|
||||
self.total_failed += 1
|
||||
logging.error(
|
||||
f"Failed to send fallback message to {message.chat_id}: {retry_exc}"
|
||||
)
|
||||
continue
|
||||
|
||||
self.total_failed += 1
|
||||
logging.error(f"Failed to send queued message to {message.chat_id}: {exc}")
|
||||
|
||||
# Keep only recent send times (last minute)
|
||||
cutoff_time = datetime.now() - timedelta(seconds=60)
|
||||
while self.last_send_times and self.last_send_times[0] < cutoff_time:
|
||||
self.last_send_times.popleft()
|
||||
|
||||
except Exception as e:
|
||||
self.total_failed += 1
|
||||
logging.error(f"Failed to send queued message to {message.chat_id}: {e}")
|
||||
@@ -77,6 +100,38 @@ class MessageQueue:
|
||||
if time_since_last < self.delay_between_messages:
|
||||
wait_time = self.delay_between_messages - time_since_last
|
||||
await asyncio.sleep(wait_time)
|
||||
|
||||
def _record_send_time(self) -> None:
|
||||
"""Track sent message timestamps and purge old entries for rate limiting."""
|
||||
now = datetime.now()
|
||||
self.last_send_times.append(now)
|
||||
self.total_sent += 1
|
||||
|
||||
cutoff_time = now - timedelta(seconds=60)
|
||||
while self.last_send_times and self.last_send_times[0] < cutoff_time:
|
||||
self.last_send_times.popleft()
|
||||
|
||||
def _build_profile_link_fallback(
|
||||
self, message: QueuedMessage, exc: Exception
|
||||
) -> Optional[QueuedMessage]:
|
||||
"""Create a fallback message without tg://user buttons when Telegram rejects them."""
|
||||
if not is_profile_link_error(exc):
|
||||
return None
|
||||
|
||||
markup = message.kwargs.get("reply_markup")
|
||||
if markup is None:
|
||||
return None
|
||||
|
||||
safe_markup = remove_profile_link_buttons(markup)
|
||||
fallback_kwargs = dict(message.kwargs)
|
||||
fallback_kwargs["reply_markup"] = safe_markup
|
||||
|
||||
return QueuedMessage(
|
||||
chat_id=message.chat_id,
|
||||
method_name=message.method_name,
|
||||
kwargs=fallback_kwargs,
|
||||
callback=message.callback,
|
||||
)
|
||||
|
||||
async def _send_message(self, message: QueuedMessage) -> Any:
|
||||
"""Send a single message - to be implemented by subclass"""
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import types
|
||||
|
||||
PROFILE_BUTTON_ERROR_CODES = ("BUTTON_USER_INVALID", "BUTTON_USER_PRIVACY_RESTRICTED")
|
||||
TG_USER_LINK_PREFIX = "tg://user?id="
|
||||
|
||||
|
||||
def remove_profile_link_buttons(
|
||||
markup: Optional[types.InlineKeyboardMarkup],
|
||||
) -> Optional[types.InlineKeyboardMarkup]:
|
||||
"""Remove buttons that point to tg://user links to avoid privacy-related errors."""
|
||||
inline_keyboard = getattr(markup, "inline_keyboard", None)
|
||||
if not markup or not inline_keyboard:
|
||||
return None
|
||||
|
||||
cleaned_rows = []
|
||||
for row in inline_keyboard:
|
||||
filtered_row = [
|
||||
button
|
||||
for button in row
|
||||
if not (
|
||||
getattr(button, "url", None)
|
||||
and str(button.url).startswith(TG_USER_LINK_PREFIX)
|
||||
)
|
||||
]
|
||||
if filtered_row:
|
||||
cleaned_rows.append(filtered_row)
|
||||
|
||||
if not cleaned_rows:
|
||||
return None
|
||||
|
||||
return types.InlineKeyboardMarkup(inline_keyboard=cleaned_rows)
|
||||
|
||||
|
||||
def is_profile_link_error(exc: BaseException) -> bool:
|
||||
"""Return True if Telegram rejected markup because of profile link buttons."""
|
||||
message = getattr(exc, "message", "") or str(exc)
|
||||
return any(code in message for code in PROFILE_BUTTON_ERROR_CODES)
|
||||
+246
-37
@@ -23,6 +23,8 @@ class Settings(BaseSettings):
|
||||
SUPPORT_LINK: Optional[str] = Field(default=None)
|
||||
SERVER_STATUS_URL: Optional[str] = Field(default=None)
|
||||
TERMS_OF_SERVICE_URL: Optional[str] = Field(default=None)
|
||||
PRIVACY_POLICY_URL: Optional[str] = Field(default=None)
|
||||
USER_AGREEMENT_URL: Optional[str] = Field(default=None)
|
||||
REQUIRED_CHANNEL_ID: Optional[int] = Field(
|
||||
default=None,
|
||||
description="Telegram channel ID the user must join to access the bot")
|
||||
@@ -41,6 +43,36 @@ class Settings(BaseSettings):
|
||||
YOOKASSA_PAYMENT_SUBJECT: str = Field(default="service")
|
||||
# Single toggle to enable recurring payments (saving cards, managing payment methods, auto-renew)
|
||||
YOOKASSA_AUTOPAYMENTS_ENABLED: bool = Field(default=False)
|
||||
YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING: bool = Field(
|
||||
default=True,
|
||||
description="When true, new YooKassa payments in autopay mode force card binding without a user checkbox."
|
||||
)
|
||||
|
||||
LKNPD_INN: Optional[str] = Field(
|
||||
default=None,
|
||||
alias="NALOGO_INN",
|
||||
description="INN for lknpd.nalog.ru (self-employed) authentication"
|
||||
)
|
||||
LKNPD_PASSWORD: Optional[str] = Field(
|
||||
default=None,
|
||||
alias="NALOGO_PASSWORD",
|
||||
description="Password for lknpd.nalog.ru (self-employed) authentication"
|
||||
)
|
||||
LKNPD_API_URL: str = Field(
|
||||
default="https://lknpd.nalog.ru/api",
|
||||
alias="NALOGO_API_URL",
|
||||
description="Base URL for LKNPD API (can be overridden for proxies)"
|
||||
)
|
||||
LKNPD_RECEIPT_NAME_SUBSCRIPTION: str = Field(
|
||||
default="subscription {months} months",
|
||||
alias="NALOGO_RECEIPT_NAME_SUBSCRIPTION",
|
||||
description="Receipt item name for time-based subscriptions. Use {months} placeholder for duration."
|
||||
)
|
||||
LKNPD_RECEIPT_NAME_TRAFFIC: str = Field(
|
||||
default="traffic package {gb} GB",
|
||||
alias="NALOGO_RECEIPT_NAME_TRAFFIC",
|
||||
description="Receipt item name for traffic packages. Use {gb} placeholder for traffic amount."
|
||||
)
|
||||
|
||||
WEBHOOK_BASE_URL: Optional[str] = None
|
||||
|
||||
@@ -49,6 +81,15 @@ class Settings(BaseSettings):
|
||||
CRYPTOPAY_CURRENCY_TYPE: str = Field(default="fiat")
|
||||
CRYPTOPAY_ASSET: str = Field(default="RUB")
|
||||
CRYPTOPAY_ENABLED: bool = Field(default=True)
|
||||
PLATEGA_ENABLED: bool = Field(default=False)
|
||||
PLATEGA_BASE_URL: str = Field(default="https://app.platega.io")
|
||||
PLATEGA_MERCHANT_ID: Optional[str] = None
|
||||
PLATEGA_SECRET: Optional[str] = None
|
||||
PLATEGA_PAYMENT_METHOD: int = Field(
|
||||
default=2, description="Platega payment method ID (e.g., 2 for SBP QR)"
|
||||
)
|
||||
PLATEGA_RETURN_URL: Optional[str] = Field(default=None)
|
||||
PLATEGA_FAILED_URL: Optional[str] = Field(default=None)
|
||||
|
||||
FREEKASSA_ENABLED: bool = Field(default=False)
|
||||
FREEKASSA_MERCHANT_ID: Optional[str] = None
|
||||
@@ -57,10 +98,24 @@ class Settings(BaseSettings):
|
||||
FREEKASSA_PAYMENT_URL: str = Field(default="https://pay.freekassa.ru/")
|
||||
FREEKASSA_API_KEY: Optional[str] = None
|
||||
FREEKASSA_PAYMENT_IP: Optional[str] = None
|
||||
FREEKASSA_PAYMENT_METHOD_ID: Optional[int] = None
|
||||
|
||||
SEVERPAY_ENABLED: bool = Field(default=False)
|
||||
SEVERPAY_MID: Optional[int] = None
|
||||
SEVERPAY_TOKEN: Optional[str] = None
|
||||
SEVERPAY_RETURN_URL: Optional[str] = None
|
||||
SEVERPAY_BASE_URL: str = Field(default="https://severpay.io/api/merchant")
|
||||
SEVERPAY_LIFETIME_MINUTES: Optional[int] = Field(
|
||||
default=None,
|
||||
description="Lifetime of the payment link in minutes (30-4320, defaults to provider value)",
|
||||
)
|
||||
|
||||
YOOKASSA_ENABLED: bool = Field(default=True)
|
||||
STARS_ENABLED: bool = Field(default=True)
|
||||
TRIBUTE_ENABLED: bool = Field(default=True)
|
||||
PAYMENT_METHODS_ORDER: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Comma-separated list of payment methods to show (e.g., severpay,freekassa,yookassa,platega,stars,cryptopay)",
|
||||
)
|
||||
|
||||
MONTH_1_ENABLED: bool = Field(default=True, alias="1_MONTH_ENABLED")
|
||||
MONTH_3_ENABLED: bool = Field(default=True, alias="3_MONTHS_ENABLED")
|
||||
@@ -76,17 +131,17 @@ class Settings(BaseSettings):
|
||||
STARS_PRICE_3_MONTHS: Optional[int] = Field(default=None)
|
||||
STARS_PRICE_6_MONTHS: Optional[int] = Field(default=None)
|
||||
STARS_PRICE_12_MONTHS: Optional[int] = Field(default=None)
|
||||
|
||||
|
||||
TRIBUTE_LINK_1_MONTH: Optional[str] = Field(default=None)
|
||||
TRIBUTE_LINK_3_MONTHS: Optional[str] = Field(default=None)
|
||||
TRIBUTE_LINK_6_MONTHS: Optional[str] = Field(default=None)
|
||||
TRIBUTE_LINK_12_MONTHS: Optional[str] = Field(default=None)
|
||||
TRIBUTE_API_KEY: Optional[str] = Field(default=None)
|
||||
TRIBUTE_SKIP_NOTIFICATIONS: bool = Field(default=True, description="Skip renewal notifications for Tribute payments")
|
||||
TRIBUTE_SKIP_CANCELLATION_NOTIFICATIONS: bool = Field(default=False, description="Skip cancellation notifications for Tribute payments")
|
||||
PANEL_WEBHOOK_SECRET: Optional[str] = Field(default=None)
|
||||
|
||||
TRAFFIC_PACKAGES: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Comma-separated list of traffic packages in the format '<GB>:<price>', e.g. '10:199,50:799'",
|
||||
)
|
||||
STARS_TRAFFIC_PACKAGES: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Comma-separated list of traffic packages priced in Stars, e.g. '5:500,20:1500'",
|
||||
)
|
||||
|
||||
SUBSCRIPTION_NOTIFICATIONS_ENABLED: bool = Field(default=True)
|
||||
SUBSCRIPTION_NOTIFY_ON_EXPIRE: bool = Field(default=True)
|
||||
SUBSCRIPTION_NOTIFY_AFTER_EXPIRE: bool = Field(default=True)
|
||||
@@ -113,7 +168,15 @@ class Settings(BaseSettings):
|
||||
# Referral program configuration
|
||||
REFERRAL_ONE_BONUS_PER_REFEREE: bool = Field(
|
||||
default=True,
|
||||
description="When true, referral bonuses (for inviter and referee) are applied only once per invited user – on their first successful payment."
|
||||
description="When true, referral bonuses (for inviter and referee) are applied only once per invited user - on their first successful payment."
|
||||
)
|
||||
REFERRAL_WELCOME_BONUS_DAYS: int = Field(
|
||||
default=3,
|
||||
description="Welcome bonus days granted to a newly registered user who joined via referral link.",
|
||||
)
|
||||
LEGACY_REFS: bool = Field(
|
||||
default=True,
|
||||
description="Allow legacy referral links like ref_<telegram_id> to continue working. Defaults to True when unset."
|
||||
)
|
||||
|
||||
PANEL_API_URL: Optional[str] = None
|
||||
@@ -124,11 +187,18 @@ class Settings(BaseSettings):
|
||||
default=None,
|
||||
description=
|
||||
"Comma-separated UUIDs of internal squads to assign to new panel users")
|
||||
USER_EXTERNAL_SQUAD_UUID: Optional[str] = Field(
|
||||
default=None,
|
||||
description=
|
||||
"UUID of the external squad to assign to new panel users (optional)")
|
||||
|
||||
TRIAL_ENABLED: bool = Field(default=True)
|
||||
TRIAL_DURATION_DAYS: int = Field(default=3)
|
||||
TRIAL_TRAFFIC_LIMIT_GB: Optional[float] = Field(default=5.0)
|
||||
|
||||
CRYPT4_ENABLED: bool = Field(default=False, description="Enable happ crypt4 encryption for subscription URLs")
|
||||
CRYPT4_REDIRECT_URL: Optional[str] = Field(default=None, description="Base redirect URL used for the connect button when crypt4 is enabled")
|
||||
|
||||
WEB_SERVER_HOST: str = Field(default="0.0.0.0")
|
||||
WEB_SERVER_PORT: int = Field(default=8080)
|
||||
LOGS_PAGE_SIZE: int = Field(default=10)
|
||||
@@ -206,6 +276,15 @@ class Settings(BaseSettings):
|
||||
]
|
||||
return None
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def parsed_user_external_squad_uuid(self) -> Optional[str]:
|
||||
if self.USER_EXTERNAL_SQUAD_UUID:
|
||||
cleaned = self.USER_EXTERNAL_SQUAD_UUID.strip()
|
||||
if cleaned:
|
||||
return cleaned
|
||||
return None
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def yookassa_webhook_path(self) -> str:
|
||||
@@ -220,19 +299,6 @@ class Settings(BaseSettings):
|
||||
return f"{base.rstrip('/')}{self.yookassa_webhook_path}"
|
||||
return None
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def tribute_webhook_path(self) -> str:
|
||||
return "/webhook/tribute"
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def tribute_full_webhook_url(self) -> Optional[str]:
|
||||
base = self.WEBHOOK_BASE_URL
|
||||
if base:
|
||||
return f"{base.rstrip('/')}{self.tribute_webhook_path}"
|
||||
return None
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def panel_webhook_path(self) -> str:
|
||||
@@ -272,6 +338,32 @@ class Settings(BaseSettings):
|
||||
return f"{base.rstrip('/')}{self.freekassa_webhook_path}"
|
||||
return None
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def severpay_webhook_path(self) -> str:
|
||||
return "/webhook/severpay"
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def severpay_full_webhook_url(self) -> Optional[str]:
|
||||
base = self.WEBHOOK_BASE_URL
|
||||
if base:
|
||||
return f"{base.rstrip('/')}{self.severpay_webhook_path}"
|
||||
return None
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def platega_webhook_path(self) -> str:
|
||||
return "/webhook/platega"
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def platega_full_webhook_url(self) -> Optional[str]:
|
||||
base = self.WEBHOOK_BASE_URL
|
||||
if base:
|
||||
return f"{base.rstrip('/')}{self.platega_webhook_path}"
|
||||
return None
|
||||
|
||||
# Computed YooKassa receipt fields based on recurring toggle
|
||||
@computed_field
|
||||
@property
|
||||
@@ -316,17 +408,59 @@ class Settings(BaseSettings):
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def tribute_payment_links(self) -> Dict[int, str]:
|
||||
links: Dict[int, str] = {}
|
||||
if self.TRIBUTE_ENABLED and self.MONTH_1_ENABLED and self.TRIBUTE_LINK_1_MONTH:
|
||||
links[1] = self.TRIBUTE_LINK_1_MONTH
|
||||
if self.TRIBUTE_ENABLED and self.MONTH_3_ENABLED and self.TRIBUTE_LINK_3_MONTHS:
|
||||
links[3] = self.TRIBUTE_LINK_3_MONTHS
|
||||
if self.TRIBUTE_ENABLED and self.MONTH_6_ENABLED and self.TRIBUTE_LINK_6_MONTHS:
|
||||
links[6] = self.TRIBUTE_LINK_6_MONTHS
|
||||
if self.TRIBUTE_ENABLED and self.MONTH_12_ENABLED and self.TRIBUTE_LINK_12_MONTHS:
|
||||
links[12] = self.TRIBUTE_LINK_12_MONTHS
|
||||
return links
|
||||
def traffic_packages(self) -> Dict[float, float]:
|
||||
"""
|
||||
Mapping of traffic size in GB to price in the default currency.
|
||||
"""
|
||||
packages: Dict[float, float] = {}
|
||||
raw = (self.TRAFFIC_PACKAGES or "").strip()
|
||||
if not raw:
|
||||
return packages
|
||||
for part in raw.split(","):
|
||||
chunk = part.strip()
|
||||
if not chunk or ":" not in chunk:
|
||||
continue
|
||||
size_str, price_str = chunk.split(":", 1)
|
||||
try:
|
||||
size_gb = float(size_str.strip())
|
||||
price_val = float(price_str.strip())
|
||||
if size_gb > 0 and price_val >= 0:
|
||||
packages[size_gb] = price_val
|
||||
except ValueError:
|
||||
logging.warning("Invalid TRAFFIC_PACKAGES entry skipped: %s", chunk)
|
||||
continue
|
||||
return packages
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def stars_traffic_packages(self) -> Dict[float, int]:
|
||||
"""
|
||||
Mapping of traffic size in GB to price in Telegram Stars.
|
||||
"""
|
||||
packages: Dict[float, int] = {}
|
||||
raw = (self.STARS_TRAFFIC_PACKAGES or "").strip()
|
||||
if not raw:
|
||||
return packages
|
||||
for part in raw.split(","):
|
||||
chunk = part.strip()
|
||||
if not chunk or ":" not in chunk:
|
||||
continue
|
||||
size_str, price_str = chunk.split(":", 1)
|
||||
try:
|
||||
size_gb = float(size_str.strip())
|
||||
price_val = int(float(price_str.strip()))
|
||||
if size_gb > 0 and price_val >= 0:
|
||||
packages[size_gb] = price_val
|
||||
except ValueError:
|
||||
logging.warning("Invalid STARS_TRAFFIC_PACKAGES entry skipped: %s", chunk)
|
||||
continue
|
||||
return packages
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def traffic_sale_mode(self) -> bool:
|
||||
"""When true, the bot sells traffic packages instead of time-based subscriptions."""
|
||||
return bool(self.traffic_packages or self.stars_traffic_packages)
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
@@ -355,11 +489,53 @@ class Settings(BaseSettings):
|
||||
if self.REFERRAL_BONUS_DAYS_REFEREE_12_MONTHS is not None:
|
||||
bonuses[12] = self.REFERRAL_BONUS_DAYS_REFEREE_12_MONTHS
|
||||
return bonuses
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def yookassa_autopayments_active(self) -> bool:
|
||||
"""Autopay features are available only when YooKassa itself is enabled."""
|
||||
return bool(self.YOOKASSA_ENABLED and self.YOOKASSA_AUTOPAYMENTS_ENABLED)
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def payment_methods_order(self) -> List[str]:
|
||||
"""
|
||||
Ordered list of payment providers to show in the subscription payment keyboard.
|
||||
"""
|
||||
default_order = [
|
||||
"freekassa",
|
||||
"platega",
|
||||
"severpay",
|
||||
"yookassa",
|
||||
"stars",
|
||||
"cryptopay",
|
||||
]
|
||||
if not self.PAYMENT_METHODS_ORDER:
|
||||
return default_order
|
||||
methods = []
|
||||
for item in self.PAYMENT_METHODS_ORDER.split(","):
|
||||
slug = item.strip().lower()
|
||||
if slug:
|
||||
methods.append(slug)
|
||||
return methods or default_order
|
||||
|
||||
# Logging Configuration
|
||||
LOG_LEVEL: str = Field(
|
||||
default="INFO",
|
||||
description="Global log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)",
|
||||
)
|
||||
LOG_CHAT_ID: Optional[int] = Field(default=None, description="Telegram chat/group ID for sending notifications")
|
||||
LOG_THREAD_ID: Optional[int] = Field(default=None, description="Thread ID for supergroup messages (optional)")
|
||||
|
||||
@field_validator('LOG_LEVEL', mode='before')
|
||||
@classmethod
|
||||
def normalize_log_level(cls, v):
|
||||
if isinstance(v, str):
|
||||
v = v.strip().upper()
|
||||
if not v:
|
||||
return "INFO"
|
||||
return v
|
||||
|
||||
@field_validator('LOG_CHAT_ID', 'LOG_THREAD_ID', mode='before')
|
||||
@classmethod
|
||||
def validate_optional_int_fields(cls, v):
|
||||
@@ -368,14 +544,23 @@ class Settings(BaseSettings):
|
||||
return None
|
||||
return v
|
||||
|
||||
@field_validator('REQUIRED_CHANNEL_LINK', mode='before')
|
||||
@field_validator(
|
||||
'REQUIRED_CHANNEL_LINK',
|
||||
'PLATEGA_RETURN_URL',
|
||||
'PLATEGA_FAILED_URL',
|
||||
'SEVERPAY_RETURN_URL',
|
||||
'CRYPT4_REDIRECT_URL',
|
||||
'PRIVACY_POLICY_URL',
|
||||
'USER_AGREEMENT_URL',
|
||||
mode='before',
|
||||
)
|
||||
@classmethod
|
||||
def sanitize_optional_link(cls, v):
|
||||
if isinstance(v, str) and not v.strip():
|
||||
return None
|
||||
return v
|
||||
|
||||
@field_validator('USER_HWID_DEVICE_LIMIT', mode='before')
|
||||
@field_validator('USER_HWID_DEVICE_LIMIT', 'SEVERPAY_MID', 'SEVERPAY_LIFETIME_MINUTES', mode='before')
|
||||
@classmethod
|
||||
def validate_optional_int(cls, v):
|
||||
if isinstance(v, str):
|
||||
@@ -418,6 +603,16 @@ def get_settings() -> Settings:
|
||||
logging.warning(
|
||||
"CRITICAL: YooKassa credentials (SHOP_ID or SECRET_KEY) are not set. Payments will not work."
|
||||
)
|
||||
if (
|
||||
_settings_instance.LKNPD_INN
|
||||
or _settings_instance.LKNPD_PASSWORD
|
||||
) and not (
|
||||
_settings_instance.LKNPD_INN
|
||||
and _settings_instance.LKNPD_PASSWORD
|
||||
):
|
||||
logging.warning(
|
||||
"WARNING: LKNPD credentials are incomplete. Receipt sending will be disabled."
|
||||
)
|
||||
if _settings_instance.FREEKASSA_ENABLED:
|
||||
if (
|
||||
not _settings_instance.FREEKASSA_MERCHANT_ID
|
||||
@@ -435,6 +630,20 @@ def get_settings() -> Settings:
|
||||
"CRITICAL: FreeKassa is enabled but no subscription prices are configured (RUB_PRICE_*). Users will not see payment buttons."
|
||||
)
|
||||
|
||||
if _settings_instance.PLATEGA_ENABLED:
|
||||
if (
|
||||
not _settings_instance.PLATEGA_MERCHANT_ID
|
||||
or not _settings_instance.PLATEGA_SECRET
|
||||
):
|
||||
logging.warning(
|
||||
"CRITICAL: Platega is enabled but merchant credentials (PLATEGA_MERCHANT_ID/PLATEGA_SECRET) are missing. Platega payments will not work."
|
||||
)
|
||||
if _settings_instance.SEVERPAY_ENABLED:
|
||||
if not _settings_instance.SEVERPAY_MID or not _settings_instance.SEVERPAY_TOKEN:
|
||||
logging.warning(
|
||||
"CRITICAL: SeverPay is enabled but MID or TOKEN is missing. SeverPay payments will not work."
|
||||
)
|
||||
|
||||
except ValidationError as e:
|
||||
logging.critical(
|
||||
f"Pydantic validation error while loading settings: {e}")
|
||||
|
||||
+29
-19
@@ -60,17 +60,18 @@ async def ensure_payment_with_provider_id(
|
||||
"""Idempotently create a payment record for a provider event.
|
||||
|
||||
If a payment with the same provider_payment_id already exists, returns it.
|
||||
Otherwise creates a new succeeded payment with provided data.
|
||||
Otherwise creates a new pending payment with provided data.
|
||||
"""
|
||||
existing = await get_payment_by_provider_payment_id(session, provider_payment_id)
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
pending_status = f"pending_{provider}" if provider else "pending"
|
||||
payment_payload: Dict[str, Any] = {
|
||||
"user_id": user_id,
|
||||
"amount": float(amount),
|
||||
"currency": currency,
|
||||
"status": "succeeded",
|
||||
"status": pending_status,
|
||||
"description": description,
|
||||
"subscription_duration_months": months,
|
||||
"provider_payment_id": provider_payment_id,
|
||||
@@ -239,26 +240,35 @@ async def get_financial_statistics(session: AsyncSession) -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
async def get_last_tribute_payment_duration(session: AsyncSession, user_id: int) -> Optional[int]:
|
||||
"""Get duration in months from the last successful tribute payment for a user."""
|
||||
stmt = select(Payment.subscription_duration_months).where(
|
||||
async def get_user_total_paid(session: AsyncSession, user_id: int) -> float:
|
||||
"""Get total amount paid by a specific user (sum of all succeeded payments)."""
|
||||
stmt = select(func.sum(Payment.amount)).where(
|
||||
and_(
|
||||
Payment.user_id == user_id,
|
||||
Payment.provider == 'tribute',
|
||||
Payment.status == 'succeeded'
|
||||
)
|
||||
).order_by(Payment.created_at.desc()).limit(1)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
total = result.scalar()
|
||||
return float(total or 0)
|
||||
|
||||
|
||||
async def get_referral_revenue(session: AsyncSession, referrer_id: int) -> float:
|
||||
"""Get total revenue generated from referred users' payments.
|
||||
|
||||
This calculates the sum of all succeeded payments made by users
|
||||
where referred_by_id equals the referrer_id.
|
||||
"""
|
||||
from db.models import User
|
||||
|
||||
stmt = select(func.sum(Payment.amount)).join(
|
||||
User, Payment.user_id == User.user_id
|
||||
).where(
|
||||
and_(
|
||||
User.referred_by_id == referrer_id,
|
||||
Payment.status == 'succeeded'
|
||||
)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_last_tribute_payment(
|
||||
session: AsyncSession, user_id: int) -> Optional[Payment]:
|
||||
"""Return the most recent succeeded Tribute payment for the user."""
|
||||
stmt = (select(Payment).where(
|
||||
and_(Payment.user_id == user_id, Payment.provider == 'tribute',
|
||||
Payment.status == 'succeeded')).order_by(
|
||||
Payment.created_at.desc()).limit(1))
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
total = result.scalar()
|
||||
return float(total or 0)
|
||||
|
||||
@@ -162,3 +162,19 @@ async def delete_user_payment_method_by_provider_id(
|
||||
await session.delete(method)
|
||||
await session.flush()
|
||||
return True
|
||||
|
||||
|
||||
async def user_has_saved_payment_method(
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
provider: str = "yookassa",
|
||||
) -> bool:
|
||||
"""Return True if the user has at least one saved payment method."""
|
||||
try:
|
||||
methods = await list_user_payment_methods(session, user_id, provider)
|
||||
if methods:
|
||||
return True
|
||||
billing = await get_user_billing(session, user_id)
|
||||
return bool(billing and billing.yookassa_payment_method_id)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
+255
-2
@@ -1,13 +1,72 @@
|
||||
import logging
|
||||
import secrets
|
||||
import string
|
||||
from typing import Optional, List, Dict, Any, Tuple
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.future import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlalchemy import update, delete, func, and_
|
||||
from sqlalchemy import update, delete, func, and_, or_, desc
|
||||
from sqlalchemy.orm import aliased
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
|
||||
from ..models import User, Subscription
|
||||
from ..models import (
|
||||
User,
|
||||
Subscription,
|
||||
Payment,
|
||||
PromoCodeActivation,
|
||||
MessageLog,
|
||||
UserBilling,
|
||||
UserPaymentMethod,
|
||||
AdAttribution,
|
||||
)
|
||||
|
||||
REFERRAL_CODE_ALPHABET = string.ascii_uppercase + string.digits
|
||||
REFERRAL_CODE_LENGTH = 9
|
||||
MAX_REFERRAL_CODE_ATTEMPTS = 25
|
||||
|
||||
|
||||
def _generate_referral_code_candidate() -> str:
|
||||
return "".join(
|
||||
secrets.choice(REFERRAL_CODE_ALPHABET) for _ in range(REFERRAL_CODE_LENGTH)
|
||||
)
|
||||
|
||||
|
||||
async def _referral_code_exists(session: AsyncSession, code: str) -> bool:
|
||||
stmt = select(User.user_id).where(User.referral_code == code)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
|
||||
async def generate_unique_referral_code(session: AsyncSession) -> str:
|
||||
"""
|
||||
Generate a unique referral code consisting of uppercase alphanumeric characters.
|
||||
Retries until a free code is found or raises RuntimeError after exceeding attempts.
|
||||
"""
|
||||
for _ in range(MAX_REFERRAL_CODE_ATTEMPTS):
|
||||
candidate = _generate_referral_code_candidate()
|
||||
if not await _referral_code_exists(session, candidate):
|
||||
return candidate
|
||||
raise RuntimeError("Failed to generate a unique referral code after several attempts.")
|
||||
|
||||
|
||||
async def ensure_referral_code(session: AsyncSession, user: User) -> str:
|
||||
"""
|
||||
Ensure the provided user has a referral code, generating and persisting it if missing.
|
||||
Returns the existing or newly generated code.
|
||||
"""
|
||||
if user.referral_code:
|
||||
normalized = user.referral_code.strip().upper()
|
||||
if normalized != user.referral_code:
|
||||
user.referral_code = normalized
|
||||
await session.flush()
|
||||
await session.refresh(user)
|
||||
return user.referral_code
|
||||
|
||||
user.referral_code = await generate_unique_referral_code(session)
|
||||
await session.flush()
|
||||
await session.refresh(user)
|
||||
return user.referral_code
|
||||
|
||||
|
||||
async def get_user_by_id(session: AsyncSession, user_id: int) -> Optional[User]:
|
||||
@@ -43,6 +102,11 @@ async def create_user(session: AsyncSession, user_data: Dict[str, Any]) -> Tuple
|
||||
if "registration_date" not in user_data:
|
||||
user_data["registration_date"] = datetime.now(timezone.utc)
|
||||
|
||||
if not user_data.get("referral_code"):
|
||||
user_data["referral_code"] = await generate_unique_referral_code(session)
|
||||
else:
|
||||
user_data["referral_code"] = user_data["referral_code"].strip().upper()
|
||||
|
||||
# Use PostgreSQL upsert to avoid IntegrityError on concurrent inserts
|
||||
stmt = (
|
||||
pg_insert(User)
|
||||
@@ -71,6 +135,15 @@ async def create_user(session: AsyncSession, user_data: Dict[str, Any]) -> Tuple
|
||||
return user, created
|
||||
|
||||
|
||||
async def get_user_by_referral_code(session: AsyncSession, referral_code: str) -> Optional[User]:
|
||||
normalized = referral_code.strip().upper()
|
||||
if not normalized:
|
||||
return None
|
||||
stmt = select(User).where(User.referral_code == normalized)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_user(
|
||||
session: AsyncSession, user_id: int, update_data: Dict[str, Any]
|
||||
) -> Optional[User]:
|
||||
@@ -102,6 +175,29 @@ async def get_banned_users(session: AsyncSession) -> List[User]:
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_all_users_paginated(
|
||||
session: AsyncSession, *, page: int = 0, page_size: int = 15
|
||||
) -> List[User]:
|
||||
"""Return a slice of users ordered by newest registration first."""
|
||||
safe_page = max(page, 0)
|
||||
safe_page_size = max(page_size, 1)
|
||||
|
||||
stmt = (
|
||||
select(User)
|
||||
.order_by(User.registration_date.desc())
|
||||
.offset(safe_page * safe_page_size)
|
||||
.limit(safe_page_size)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def count_all_users(session: AsyncSession) -> int:
|
||||
"""Count total number of users."""
|
||||
result = await session.execute(select(func.count(User.user_id)))
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
async def get_all_active_user_ids_for_broadcast(session: AsyncSession) -> List[int]:
|
||||
stmt = select(User.user_id).where(User.is_banned == False)
|
||||
result = await session.execute(stmt)
|
||||
@@ -227,3 +323,160 @@ async def get_user_ids_without_active_subscription(session: AsyncSession) -> Lis
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def delete_user_and_relations(session: AsyncSession, user_id: int) -> bool:
|
||||
"""Completely remove a user and all dependent records from the database.
|
||||
|
||||
This helper ensures we do not leave dangling foreign keys or orphaned data.
|
||||
"""
|
||||
user = await get_user_by_id(session, user_id)
|
||||
if not user:
|
||||
return False
|
||||
|
||||
# Ensure referral pointers do not block deletion
|
||||
await session.execute(
|
||||
update(User).where(User.referred_by_id == user_id).values(referred_by_id=None)
|
||||
)
|
||||
|
||||
# Clean up dependent tables that do not cascade automatically
|
||||
await session.execute(
|
||||
delete(MessageLog).where(
|
||||
or_(MessageLog.user_id == user_id, MessageLog.target_user_id == user_id)
|
||||
)
|
||||
)
|
||||
await session.execute(delete(Payment).where(Payment.user_id == user_id))
|
||||
await session.execute(
|
||||
delete(Subscription).where(Subscription.user_id == user_id)
|
||||
)
|
||||
await session.execute(
|
||||
delete(PromoCodeActivation).where(PromoCodeActivation.user_id == user_id)
|
||||
)
|
||||
await session.execute(
|
||||
delete(UserPaymentMethod).where(UserPaymentMethod.user_id == user_id)
|
||||
)
|
||||
await session.execute(delete(UserBilling).where(UserBilling.user_id == user_id))
|
||||
await session.execute(delete(AdAttribution).where(AdAttribution.user_id == user_id))
|
||||
|
||||
await session.delete(user)
|
||||
await session.flush()
|
||||
return True
|
||||
|
||||
|
||||
async def get_top_users_by_traffic_used(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
limit: int = 10,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Return top users by total used traffic across all subscriptions."""
|
||||
safe_limit = max(1, limit)
|
||||
|
||||
total_traffic_used = func.coalesce(func.sum(Subscription.traffic_used_bytes), 0)
|
||||
|
||||
stmt = (
|
||||
select(
|
||||
User.user_id,
|
||||
User.username,
|
||||
User.first_name,
|
||||
total_traffic_used.label("traffic_used_bytes"),
|
||||
)
|
||||
.join(Subscription, Subscription.user_id == User.user_id, isouter=True)
|
||||
.group_by(User.user_id, User.username, User.first_name)
|
||||
.having(total_traffic_used > 0)
|
||||
.order_by(desc("traffic_used_bytes"), User.user_id.asc())
|
||||
.limit(safe_limit)
|
||||
)
|
||||
|
||||
result = await session.execute(stmt)
|
||||
return [dict(row._mapping) for row in result]
|
||||
|
||||
|
||||
async def get_top_users_by_lifetime_traffic_used(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
limit: int = 10,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Return top users by lifetime used traffic from panel data."""
|
||||
safe_limit = max(1, limit)
|
||||
lifetime_used = func.coalesce(User.lifetime_used_traffic_bytes, 0)
|
||||
|
||||
stmt = (
|
||||
select(
|
||||
User.user_id,
|
||||
User.username,
|
||||
User.first_name,
|
||||
lifetime_used.label("lifetime_used_traffic_bytes"),
|
||||
)
|
||||
.where(lifetime_used > 0)
|
||||
.order_by(desc("lifetime_used_traffic_bytes"), User.user_id.asc())
|
||||
.limit(safe_limit)
|
||||
)
|
||||
|
||||
result = await session.execute(stmt)
|
||||
return [dict(row._mapping) for row in result]
|
||||
|
||||
|
||||
async def get_top_users_by_referrals_count(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
limit: int = 10,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Return top users by number of invited users."""
|
||||
safe_limit = max(1, limit)
|
||||
referred_user = aliased(User)
|
||||
|
||||
invited_count = func.count(referred_user.user_id)
|
||||
|
||||
stmt = (
|
||||
select(
|
||||
User.user_id,
|
||||
User.username,
|
||||
User.first_name,
|
||||
invited_count.label("invited_count"),
|
||||
)
|
||||
.join(referred_user, referred_user.referred_by_id == User.user_id, isouter=True)
|
||||
.group_by(User.user_id, User.username, User.first_name)
|
||||
.having(invited_count > 0)
|
||||
.order_by(desc("invited_count"), User.user_id.asc())
|
||||
.limit(safe_limit)
|
||||
)
|
||||
|
||||
result = await session.execute(stmt)
|
||||
return [dict(row._mapping) for row in result]
|
||||
|
||||
|
||||
async def get_top_users_by_referral_revenue(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
limit: int = 10,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Return top users by total revenue brought by all invited users."""
|
||||
safe_limit = max(1, limit)
|
||||
referred_user = aliased(User)
|
||||
|
||||
referral_revenue = func.coalesce(func.sum(Payment.amount), 0.0)
|
||||
|
||||
stmt = (
|
||||
select(
|
||||
User.user_id,
|
||||
User.username,
|
||||
User.first_name,
|
||||
referral_revenue.label("referral_revenue"),
|
||||
)
|
||||
.join(referred_user, referred_user.referred_by_id == User.user_id, isouter=True)
|
||||
.join(
|
||||
Payment,
|
||||
and_(
|
||||
Payment.user_id == referred_user.user_id,
|
||||
Payment.status == "succeeded",
|
||||
),
|
||||
isouter=True,
|
||||
)
|
||||
.group_by(User.user_id, User.username, User.first_name)
|
||||
.having(referral_revenue > 0)
|
||||
.order_by(desc("referral_revenue"), User.user_id.asc())
|
||||
.limit(safe_limit)
|
||||
)
|
||||
|
||||
result = await session.execute(stmt)
|
||||
return [dict(row._mapping) for row in result]
|
||||
|
||||
@@ -48,12 +48,104 @@ def _migration_0001_add_channel_subscription_fields(connection: Connection) -> N
|
||||
connection.execute(text(stmt))
|
||||
|
||||
|
||||
def _migration_0002_add_referral_code(connection: Connection) -> None:
|
||||
inspector = inspect(connection)
|
||||
columns: Set[str] = {col["name"] for col in inspector.get_columns("users")}
|
||||
|
||||
if "referral_code" not in columns:
|
||||
connection.execute(
|
||||
text("ALTER TABLE users ADD COLUMN referral_code VARCHAR(16)")
|
||||
)
|
||||
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
WITH generated_codes AS (
|
||||
SELECT
|
||||
user_id,
|
||||
UPPER(
|
||||
SUBSTRING(
|
||||
md5(
|
||||
user_id::text
|
||||
|| clock_timestamp()::text
|
||||
|| random()::text
|
||||
)
|
||||
FROM 1 FOR 9
|
||||
)
|
||||
) AS referral_code
|
||||
FROM users
|
||||
WHERE referral_code IS NULL OR referral_code = ''
|
||||
)
|
||||
UPDATE users AS u
|
||||
SET referral_code = g.referral_code
|
||||
FROM generated_codes AS g
|
||||
WHERE u.user_id = g.user_id
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_users_referral_code
|
||||
ON users (referral_code)
|
||||
WHERE referral_code IS NOT NULL
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _migration_0003_normalize_referral_codes(connection: Connection) -> None:
|
||||
inspector = inspect(connection)
|
||||
columns: Set[str] = {col["name"] for col in inspector.get_columns("users")}
|
||||
if "referral_code" not in columns:
|
||||
return
|
||||
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE users
|
||||
SET referral_code = UPPER(referral_code)
|
||||
WHERE referral_code IS NOT NULL
|
||||
AND referral_code <> UPPER(referral_code)
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _migration_0004_add_lifetime_used_traffic(connection: Connection) -> None:
|
||||
inspector = inspect(connection)
|
||||
columns: Set[str] = {col["name"] for col in inspector.get_columns("users")}
|
||||
if "lifetime_used_traffic_bytes" in columns:
|
||||
return
|
||||
|
||||
connection.execute(
|
||||
text(
|
||||
"ALTER TABLE users ADD COLUMN lifetime_used_traffic_bytes BIGINT"
|
||||
)
|
||||
)
|
||||
|
||||
MIGRATIONS: List[Migration] = [
|
||||
Migration(
|
||||
id="0001_add_channel_subscription_fields",
|
||||
description="Add columns to track required channel subscription verification",
|
||||
upgrade=_migration_0001_add_channel_subscription_fields,
|
||||
),
|
||||
Migration(
|
||||
id="0002_add_referral_code",
|
||||
description="Store short referral codes for users and backfill existing rows",
|
||||
upgrade=_migration_0002_add_referral_code,
|
||||
),
|
||||
Migration(
|
||||
id="0003_normalize_referral_codes",
|
||||
description="Normalize referral codes to uppercase for consistent lookups",
|
||||
upgrade=_migration_0003_normalize_referral_codes,
|
||||
),
|
||||
Migration(
|
||||
id="0004_add_lifetime_used_traffic",
|
||||
description="Store lifetime traffic usage for users",
|
||||
upgrade=_migration_0004_add_lifetime_used_traffic,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -21,9 +21,11 @@ class User(Base):
|
||||
server_default=func.now())
|
||||
is_banned = Column(Boolean, default=False)
|
||||
panel_user_uuid = Column(String, nullable=True, unique=True, index=True)
|
||||
referral_code = Column(String(16), nullable=True, unique=True, index=True)
|
||||
referred_by_id = Column(BigInteger,
|
||||
ForeignKey("users.user_id"),
|
||||
nullable=True)
|
||||
lifetime_used_traffic_bytes = Column(BigInteger, nullable=True)
|
||||
channel_subscription_verified = Column(Boolean, nullable=True)
|
||||
channel_subscription_checked_at = Column(DateTime(timezone=True),
|
||||
nullable=True)
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
services:
|
||||
remnawave-tg-shop:
|
||||
image: ghcr.io/3252a8/remnawave-tg-shop:${IMAGE_TAG:-latest}
|
||||
container_name: remnawave-tg-shop
|
||||
hostname: remnawave-tg-shop
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- '127.0.0.1:8080:8080'
|
||||
networks:
|
||||
- remnawave-network
|
||||
environment:
|
||||
- TZ=UTC
|
||||
volumes:
|
||||
- ./locales:/app/locales
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- remnawave-tg-shop-db
|
||||
remnawave-tg-shop-db:
|
||||
image: postgres:17
|
||||
container_name: remnawave-tg-shop-db
|
||||
hostname: remnawave-tg-shop-db
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- TZ=UTC
|
||||
volumes:
|
||||
- remnawave-tg-shop-db-data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- remnawave-network
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
networks:
|
||||
remnawave-network: null
|
||||
volumes:
|
||||
remnawave-tg-shop-db-data:
|
||||
name: remnawave-tg-shop-db-data
|
||||
+4
-2
@@ -1,7 +1,7 @@
|
||||
services:
|
||||
remnawave-tg-shop:
|
||||
image: ghcr.io/machka-pasla/remnawave-tg-shop:latest
|
||||
# build: .
|
||||
# image: ghcr.io/3252a8/remnawave-tg-shop:${IMAGE_TAG:-latest}
|
||||
build: .
|
||||
container_name: remnawave-tg-shop
|
||||
hostname: remnawave-tg-shop
|
||||
env_file:
|
||||
@@ -14,6 +14,8 @@ services:
|
||||
depends_on:
|
||||
remnawave-tg-shop-db:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- 127.0.0.1:8080:8080
|
||||
|
||||
remnawave-tg-shop-db:
|
||||
image: postgres:17
|
||||
|
||||
+71
-17
@@ -5,18 +5,22 @@
|
||||
"channel_subscription_verify_button": "Check subscription",
|
||||
"channel_subscription_check_failed": "Couldn't verify the subscription. Please try again later or contact support.",
|
||||
"channel_subscription_verified_success": "✅ Subscription confirmed! You're good to go.",
|
||||
"main_menu_greeting": "Hi, {user_name}! 👋\nWhat would you like to do?",
|
||||
"main_menu_greeting": "Hi, {user_name}! 👋\nWhat would you like to do?\n\nTo set up the connection on your device, go to \"My subscription\" and click \"Connect\". If needed, add access days in the \"Buy\" section.",
|
||||
"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",
|
||||
"menu_referral_inline": "🎁 Invite friend",
|
||||
"referral_no_bonuses_configured": "Sorry, the referral program is currently disabled",
|
||||
"menu_apply_promo_button": "🎟 Promo Code",
|
||||
"menu_language_settings_inline": "🌐 Language",
|
||||
"menu_server_status_button": "📊 Status",
|
||||
"menu_support_button": "💬 Support",
|
||||
"menu_terms_button": "📄 Terms of Service",
|
||||
"menu_info_button": "ℹ️ Information",
|
||||
"info_links_message": "Choose a document:",
|
||||
"privacy_policy_button": "🔒 Privacy Policy",
|
||||
"user_agreement_button": "📄 User Agreement",
|
||||
"back_to_main_menu_button": "⬅️ Back",
|
||||
"choose_language": "Choose language:",
|
||||
"language_set_alert": "Language changed!",
|
||||
@@ -25,8 +29,11 @@
|
||||
"error_displaying_menu": "Error displaying menu.",
|
||||
"main_menu_unknown_action": "Unknown action.",
|
||||
"select_subscription_period": "Select subscription period:",
|
||||
"select_traffic_package": "Select a traffic package:",
|
||||
"subscribe_for_months_button": "{months} mo. - {price} {currency_symbol}",
|
||||
"buy_traffic_package_button": "{traffic_gb} GB - {price} {currency_symbol}",
|
||||
"choose_payment_method": "Choose payment method:",
|
||||
"choose_payment_method_traffic": "Choose how to pay for the traffic package:",
|
||||
"pay_button": "💳 Pay",
|
||||
"pay_with_yookassa_button": "💳 YooKassa",
|
||||
"yookassa_autopay_flow_prompt": "Auto-renew is enabled. Choose how you'd like to pay:",
|
||||
@@ -37,9 +44,10 @@
|
||||
"back_to_autopay_method_choice_button": "⬅️ Back",
|
||||
"yookassa_autopay_charge_initiated": "Charge request sent to the selected card. We'll notify you once the payment completes.",
|
||||
"pay_with_sbp_button": "📱 SBP",
|
||||
"pay_with_platega_button": "💳 Platega (SBP/Cards)",
|
||||
"pay_with_severpay_button": "💳 SeverPay",
|
||||
"back_to_payment_methods_button": "⬅️ Back",
|
||||
"pay_with_cryptopay_button": "💎 CryptoBot",
|
||||
"pay_with_tribute_button": "❤️ Tribute",
|
||||
"pay_with_stars_button": "🌟 Telegram Stars",
|
||||
"connect_button": "🔗 Connect",
|
||||
"cancel_button": "❌ Cancel",
|
||||
@@ -55,15 +63,24 @@
|
||||
"my_devices_feature_disabled": "The My Devices section is currently unavailable.",
|
||||
|
||||
"payment_description_subscription": "Subscription payment for {months} mo.",
|
||||
"payment_description_traffic": "Traffic package {traffic_gb} GB",
|
||||
"payment_link_message": "To pay for {months} mo. subscription, click the button below:",
|
||||
"payment_link_message_traffic": "To pay for a {traffic_gb} GB package, tap the button below:",
|
||||
"free_kassa_order_info": "Order #{order_id} from {date}",
|
||||
"payment_invoice_sent_message": "Telegram has sent the invoice above. Complete the payment or pick another method below.",
|
||||
"payment_invoice_sent_message_traffic": "Invoice for {traffic_gb} GB sent above. Complete the payment or pick another method below.",
|
||||
"payment_successful_error_details": "✅ Payment succeeded, but an error occurred displaying details. Your subscription is active. Contact support if anything is wrong.",
|
||||
"payment_successful_full": "✅ Payment successful!\nYour {months}-month subscription is active until {end_date}.\n\nConnection key:\n<code>{config_link}</code>\n\nTo connect, open the link and follow the instructions 👇",
|
||||
"payment_successful_traffic_full": "✅ Payment successful!\nYour {traffic_gb} GB package is active.\nValidity: {end_date}\n\nConnection key:\n<code>{config_link}</code>\n\nTo connect, open the link and follow the instructions 👇",
|
||||
"payment_successful_with_referral_bonus_full": "✅ Payment successful!\nYour {months}-month subscription (base end date: {base_end_date}) has been extended by {bonus_days} bonus days for referral from {inviter_name} and is now active until {final_end_date}.\n\nConnection key:\n<code>{config_link}</code>\n\nTo connect, open the link and follow the instructions 👇",
|
||||
"payment_failed": "❌ Payment failed or was cancelled. Please try again or contact support.",
|
||||
"config_link_not_available": "not available, contact support",
|
||||
"traffic_unlimited": "Unlimited",
|
||||
"traffic_period_day": "per day",
|
||||
"traffic_period_week": "per week",
|
||||
"traffic_period_month": "per month",
|
||||
"traffic_period_no_reset": "no reset",
|
||||
"traffic_used_with_period": "{traffic_used} ({traffic_period})",
|
||||
"promo_code_prompt": "Please enter your promo code:",
|
||||
"promo_code_not_found": "Promo code <code>{code}</code> not found, expired, or already used the maximum number of times.",
|
||||
"promo_code_already_used_by_user": "You have already used promo code <code>{code}</code>.",
|
||||
@@ -75,12 +92,14 @@
|
||||
"trial_confirm_activate_button": "✅ Activate!",
|
||||
"trial_activated_alert": "Trial activated!",
|
||||
"trial_activated_details_message": "✅ Trial activated!\nYour {days}-day trial is active until {end_date}.\n\nConnection key:\n<code>{config_link}</code>\n\nTo connect, open the link and follow the instructions 👇",
|
||||
"referral_welcome_bonus_applied": "🎁 You have received {days} bonus day(s) for registering via a referral link!\nYour subscription is active until {end_date}.",
|
||||
"yes_button": "Yes",
|
||||
"no_button": "No",
|
||||
"referral_program_info_new": "🎁 <b>Referral Program</b>\n\n📊 <b>Your stats:</b>\n👥 Friends invited: <b>{invited_count}</b>\n💳 Purchased subscription: <b>{purchased_count}</b>\n\n🔗 Your link:\n<code>{referral_link}</code>\n\n💰 <b>Invitation bonuses:</b>\n{bonus_details}\n\n📢 Share the link with friends and get bonuses!",
|
||||
"referral_bonus_per_period": "\n\n🎁 For a friend's {months}-month subscription:\n ➢ You: <b>{inviter_bonus_days} days</b>\n ➢ Friend: <b>{referee_bonus_days} days</b>",
|
||||
"referral_not_available_for_traffic": "Referral bonuses are not available for traffic packages.",
|
||||
"referral_share_message_button": "📩 Message for friend",
|
||||
"referral_friend_message": "🚀 Hey! Try this VPN - it's fast, reliable and affordable!\n\n🎁 Use my link to get bonus days with your subscription!\n\n{referral_link}",
|
||||
"referral_friend_message": "🚀 Hey! Try this service - it's fast, reliable and affordable!\n\n🎁 Use my link to get bonus days with your subscription!\n\n{referral_link}",
|
||||
"friend_placeholder": "friend",
|
||||
"referral_bonus_inviter_notification_extended": "🎉 Congrats! Your friend {referee_name} paid for a subscription. You received {days} bonus days! Your subscription is now active until {new_end_date}.",
|
||||
"referral_bonus_inviter_notification_new_sub": "🎉 Congrats! Your friend {referee_name} paid for a subscription. You received a {days}-day bonus subscription! It is active until {new_end_date}.",
|
||||
@@ -101,7 +120,10 @@
|
||||
"admin_promo_marketing_section": "🎁 Promos",
|
||||
"admin_system_functions_section": "⚙️ System",
|
||||
"admin_ban_management_section": "🚫 Bans",
|
||||
"admin_users_management_button": "👤 Management",
|
||||
"admin_users_search_button": "🔍 Search",
|
||||
"admin_users_management_button": "👥 User List",
|
||||
"admin_users_list_header": "👥 <b>User List</b>\n\nPage {current}/{total} ({total_users} users)",
|
||||
"admin_user_back_to_list_button": "⬅️ Back to List",
|
||||
"back_to_user_management_button": "⬅️ To Users",
|
||||
"back_to_admin_panel_button": "⬅️ To Admin",
|
||||
"admin_stats_header": "📊 Bot Statistics",
|
||||
@@ -111,6 +133,18 @@
|
||||
"admin_stats_payment_item": "{status_emoji} {amount} {currency} from {user_info} ({p_status}) [{p_date}]",
|
||||
"admin_stats_no_payments_found": "No payments found yet.",
|
||||
"admin_view_payments_button": "💰 Payments",
|
||||
"admin_user_ratings_button": "🏆 User Ratings",
|
||||
"back_to_stats_monitoring_button": "⬅️ To Statistics",
|
||||
"admin_user_ratings_header": "🏆 <b>User Ratings (TOP {top_limit})</b>",
|
||||
"admin_user_ratings_traffic_title": "📊 By used traffic",
|
||||
"admin_user_ratings_traffic_month_title": "📊 By used traffic (month)",
|
||||
"admin_user_ratings_traffic_lifetime_title": "📊 By used traffic (lifetime)",
|
||||
"admin_user_ratings_invited_title": "👥 By invited users",
|
||||
"admin_user_ratings_revenue_title": "💸 By referral revenue",
|
||||
"admin_user_ratings_traffic_item": "{rank}. {user} — <b>{traffic_gb} GB</b>",
|
||||
"admin_user_ratings_invited_item": "{rank}. {user} — <b>{invited_count}</b>",
|
||||
"admin_user_ratings_revenue_item": "{rank}. {user} — <b>{revenue} RUB</b>",
|
||||
"admin_user_ratings_empty": "No data yet.",
|
||||
"admin_payments_header": "💰 <b>All Payments</b>",
|
||||
"admin_no_payments_found": "No payments found.",
|
||||
"admin_export_payments_csv": "📊 Export CSV",
|
||||
@@ -127,6 +161,7 @@
|
||||
"admin_csv_provider": "Provider",
|
||||
"admin_csv_status": "Status",
|
||||
"admin_csv_description": "Description",
|
||||
"admin_csv_units": "Months/GB",
|
||||
"admin_csv_months": "Months",
|
||||
"admin_csv_created_at": "Created At",
|
||||
"admin_csv_provider_payment_id": "Provider Payment ID",
|
||||
@@ -207,6 +242,8 @@
|
||||
"admin_user_card_title": "User Card",
|
||||
"user_card_ban_button": "🚫 Ban",
|
||||
"user_card_unban_button": "✅ Unban",
|
||||
"user_card_open_profile_button": "👤 Open profile",
|
||||
"user_card_open_referrer_profile_button": "👤 Referrer profile",
|
||||
"user_card_back_to_banned_list_button": "⬅️ Back to Ban List",
|
||||
"admin_logs_menu_title": "Logs Menu:",
|
||||
"admin_view_all_logs_button": "📜 All Message Logs",
|
||||
@@ -225,16 +262,14 @@
|
||||
"admin_broadcast_invalid_html": "❌ Invalid HTML in message. Please send valid HTML (Telegram-supported tags) or remove tags.",
|
||||
"error_displaying_logs_too_long": "Error: logs too long to display in one message. Try viewing logs for a specific user.",
|
||||
"error_displaying_statistics": "Error displaying statistics.",
|
||||
"subscription_72h_notification": "👋 Hi, {user_name}!\n\n⏳ Your VPN subscription expires in 3 days — {end_date}.\n\nPlease renew it using the button below.",
|
||||
"subscription_48h_notification": "👋 Hi, {user_name}!\n\n⏳ Your VPN subscription expires in 2 days — {end_date}.\n\nPlease renew it using the button below.",
|
||||
"subscription_24h_notification": "👋 Hi, {user_name}!\n\n⏳ Your VPN subscription expires in 1 day — {end_date}.\n\nPlease renew it using the button below.",
|
||||
"subscription_expired_notification": "👋 Hi, {user_name}!\n\n⛔ Your VPN subscription expired on {end_date}.\n\nPlease renew it using the button below.",
|
||||
"subscription_expired_yesterday_notification": "👋 Hi, {user_name}!\n\n⏳ Your VPN subscription expired yesterday ({end_date}).\n\nPlease renew it using the button below.",
|
||||
"subscription_72h_notification": "👋 Hi, {user_name}!\n\n⏳ Your service subscription expires in 3 days — {end_date}.\n\nPlease renew it using the button below.",
|
||||
"subscription_48h_notification": "👋 Hi, {user_name}!\n\n⏳ Your service subscription expires in 2 days — {end_date}.\n\nPlease renew it using the button below.",
|
||||
"subscription_24h_notification": "👋 Hi, {user_name}!\n\n⏳ Your service subscription expires in 1 day — {end_date}.\n\nPlease renew it using the button below.",
|
||||
"subscription_expired_notification": "👋 Hi, {user_name}!\n\n⛔ Your service subscription expired on {end_date}.\n\nPlease renew it using the button below.",
|
||||
"subscription_expired_yesterday_notification": "👋 Hi, {user_name}!\n\n⏳ Your service subscription expired yesterday ({end_date}).\n\nPlease renew it using the button below.",
|
||||
"autorenew_48h_charge_tomorrow_notice": "🔔 Reminder\n\nTomorrow an automatic charge will occur to renew your subscription. If you don't want auto-renew, disable it using the button below.",
|
||||
"autorenew_confirm_enable": "🔄 Enable auto-renew? An automatic charge will be attempted before your subscription ends.",
|
||||
"autorenew_confirm_disable": "🛑 Disable auto-renew? No further automatic charges will occur.",
|
||||
"tribute_subscription_cancelled": "🚨 <b>Subscription Cancelled</b>\n\nYour Tribute subscription has been cancelled. You have 24 hours to restore access, after which the subscription will be blocked.\n\nTo renew your subscription, press the button below.",
|
||||
"tribute_auto_renewal": "🔄 <b>Subscription Auto-Renewed</b>\n\nYour Tribute subscription has been automatically renewed for {months} months.\nNew expiration date: {end_date}",
|
||||
"yookassa_auto_renewal": "🔄 <b>Subscription Auto-Renewed</b>\n\nYour subscription was automatically renewed for {months} month(s).\nNew expiration date: {end_date}",
|
||||
"admin_user_management_prompt": "👤 User Management\n\nEnter user ID or @username to search:",
|
||||
"admin_user_subscription_info": "Subscription Information:",
|
||||
@@ -244,6 +279,16 @@
|
||||
"admin_user_send_message_button": "✉️ Send Message",
|
||||
"admin_user_view_logs_button": "📜 User Actions",
|
||||
"admin_user_refresh_button": "🔄 Refresh",
|
||||
"admin_user_delete_button": "❌ Delete User",
|
||||
"admin_user_delete_not_allowed": "❌ You are not permitted to delete users.",
|
||||
"admin_user_delete_confirmation_prompt": "⚠️ You are about to delete user {user_id} completely.\n\nSend the exact Telegram ID of this user to confirm.\nAny other reply will cancel the operation.",
|
||||
"admin_user_delete_state_missing": "⚠️ No active delete operation. Start again.",
|
||||
"admin_user_delete_cancelled": "Deletion cancelled.",
|
||||
"admin_user_delete_mismatch": "⚠️ ID mismatch. Deletion aborted.",
|
||||
"admin_user_delete_already_removed": "ℹ️ The user is already removed.",
|
||||
"admin_user_delete_panel_error": "❌ Failed to remove the user from the panel. Operation aborted.",
|
||||
"admin_user_delete_success": "✅ User {user_id} was removed from the bot and the panel.",
|
||||
"admin_user_delete_error": "❌ Unable to delete the user. Please try again later.",
|
||||
"admin_user_search_new_button": "🔍 Find Another",
|
||||
"admin_user_view_all_logs_button": "📋 All Actions",
|
||||
"admin_user_back_to_card_button": "🔙 Back to Card",
|
||||
@@ -278,9 +323,12 @@
|
||||
"inline_admin_financial_stats_title": "💰 Financial Statistics",
|
||||
"inline_system_stats_message": "🖥 <b>Panel Statistics</b>\n\n🟢 Online: <b>{online}</b>\n📊 Active: <b>{active}</b>\n🔴 Disabled: <b>{disabled}</b>\n⏰ Expired: <b>{expired}</b>\n⚠️ Limited: <b>{limited}</b>\n👥 Total users: <b>{total}</b>\n💾 RAM Usage: <b>{memory:.1f}%</b>\n📊 Week traffic: <b>{week_traffic}</b>\n📊 Month traffic: <b>{month_traffic}</b>\n🔗 Active nodes: <b>{active_nodes}/{total_nodes}</b>",
|
||||
"inline_admin_system_stats_title": "🖥 System Statistics",
|
||||
"log_referral_suffix": " (referral from {referrer_id})",
|
||||
"log_referral_suffix": " (referral from {referrer_link})",
|
||||
"log_open_profile_link": "👤 Open profile",
|
||||
"log_open_referrer_profile_button": "👤 Referrer profile",
|
||||
"log_new_user_registration": "👤 <b>New User</b>\n\n🆔 ID: <code>{user_id}</code>\n👤 Name: {user_display}{referral_text}\n📅 Time: {timestamp}",
|
||||
"log_payment_received": "{provider_emoji} <b>Payment Received</b>\n\n👤 User: {user_display}\n💰 Amount: <b>{amount} {currency}</b>\n📅 Period: <b>{months} mo.</b>\n🏦 Provider: {payment_provider}\n🕐 Time: {timestamp}",
|
||||
"log_payment_received_traffic": "{provider_emoji} <b>Payment Received</b>\n\n👤 User: {user_display}\n💰 Amount: <b>{amount} {currency}</b>\n🗂 Traffic: <b>{traffic_gb} GB</b>\n🏦 Provider: {payment_provider}\n🕐 Time: {timestamp}",
|
||||
"log_promo_activation": "🎁 <b>Promo Code Activated</b>\n\n👤 User: {user_display}\n🏷 Code: <code>{promo_code}</code>\n🎯 Bonus: <b>+{bonus_days}d</b>\n🕐 Time: {timestamp}",
|
||||
"log_trial_activation": "🆓 <b>Trial Activated</b>\n\n👤 User: {user_display}\n⏰ Valid until: <b>{end_date}</b>\n🕐 Time: {timestamp}",
|
||||
"log_panel_sync": "{status_emoji} <b>Panel Synchronization</b>\n\n📊 Status: <b>{status}</b>\n👥 Users processed: <b>{users_processed}</b>\n📋 Subscriptions synced: <b>{subs_synced}</b>\n🕐 Time: {timestamp}\n\n📝 Details:\n{details}",
|
||||
@@ -337,6 +385,8 @@
|
||||
"admin_user_na_value": "N/A",
|
||||
"admin_user_subscription_none": "No active subscription",
|
||||
"admin_user_actions_count_label": "📜 <b>Total actions:</b>",
|
||||
"admin_user_total_paid_label": "💰 <b>Total Paid:</b>",
|
||||
"admin_user_referral_revenue_label": "💸 <b>Referral Revenue:</b>",
|
||||
"admin_user_invited_friends_label": "👥 <b>Friends invited:</b>",
|
||||
"admin_user_ref_purchased_label": "💳 <b>Purchased subscription:</b>",
|
||||
"admin_user_subscription_active_until": "⏰ <b>Active until:</b>",
|
||||
@@ -398,7 +448,12 @@
|
||||
"admin_sync_no_telegram_id": "\n⚠️ Records without telegramId: {count}",
|
||||
"admin_sync_not_found_in_db": "\n❌ Not found in DB: {count}",
|
||||
"admin_payments_pagination_info": "📊 Showing {shown} of {total} payments (page {current_page}/{total_pages})",
|
||||
"my_subscription_details": "🔐 <b>My Subscription</b>\n\n⏰ Status: <b>{status}</b>\n📅 Active until: <b>{end_date}</b>\n📆 Days left: <b>{days_left}</b>\n\n🔗 Configuration link:\n<code>{config_link}</code>\n\n📊 Traffic:\nLimit: <b>{traffic_limit}</b>\nUsed: <b>{traffic_used}</b>",
|
||||
"admin_payment_traffic_label": "🗂 Traffic: <b>{traffic_gb} GB</b>",
|
||||
"admin_payment_months_label": "📅 Period: <b>{months} mo.</b>",
|
||||
"my_subscription_details": "🔐 <b>My Subscription</b>\n\n⏰ Status: <b>{status}</b>\n📅 Active until: <b>{end_date}</b>\n📆 Days left: <b>{days_left}</b>\n\n🔗 Configuration link:\n<code>{config_link}</code>\n\n📊 Traffic ({traffic_period}):\nLimit: <b>{traffic_limit}</b>\nUsed: <b>{traffic_used}</b>",
|
||||
"my_traffic_details": "🔐 <b>My Traffic</b>\n\n⏰ Status: <b>{status}</b>\n📅 Valid until: <b>{end_date}</b>\n\n🔗 Configuration link:\n<code>{config_link}</code>\n\n📊 Traffic ({traffic_period}):\nLimit: <b>{traffic_limit}</b>\nUsed: <b>{traffic_used}</b>\nLeft: <b>{traffic_left}</b>",
|
||||
"traffic_no_expiry": "no limit",
|
||||
"traffic_period_unknown": "unknown",
|
||||
"autorenew_enable_button": "🔄 Enable auto-renew",
|
||||
"autorenew_disable_button": "🛑 Disable auto-renew",
|
||||
"subscription_autorenew_updated": "Auto-renew settings updated.",
|
||||
@@ -421,9 +476,8 @@
|
||||
"payment_method_tx_history_title": "📜 Transactions history",
|
||||
"payment_method_no_history": "No transactions history.",
|
||||
"subscription_purchase_title": "Subscription purchase for {months} mo.",
|
||||
"subscription_tribute_notice": "Paid via Tribute. Renew using your Tribute link.",
|
||||
"subscription_tribute_notice_with_link": "Paid via Tribute. Renew: {link}",
|
||||
"subscription_autorenew_not_supported_for_tribute": "Auto-renew is handled by Tribute. Manage renewal in the Tribute app/link.",
|
||||
"traffic_purchase_title": "Traffic purchase {traffic_gb} GB",
|
||||
"autorenew_enable_requires_card": "Link a payment card in Payment Methods before enabling auto-renew.",
|
||||
"subscription_not_active": "You don't have an active subscription.",
|
||||
"error_service_unavailable": "Service unavailable. Please try again later.",
|
||||
"error_payment_gateway": "Payment service error. Please try again later.",
|
||||
|
||||
+72
-18
@@ -5,18 +5,22 @@
|
||||
"channel_subscription_verify_button": "Проверить подписку",
|
||||
"channel_subscription_check_failed": "Не удалось проверить подписку. Попробуйте позже или обратитесь в поддержку.",
|
||||
"channel_subscription_verified_success": "✅ Подписка подтверждена! Можно продолжать.",
|
||||
"main_menu_greeting": "Привет, {user_name}! 👋\nЧто бы вы хотели сделать?",
|
||||
"main_menu_greeting": "Привет, {user_name}! 👋\nЧто бы вы хотели сделать?\n\nДля настройки подключения на устройстве перейдите в раздел \"Моя подписка\" и нажмите кнопку \"Подключиться\". При необходимости добавьте дни доступа в разделе \"Купить\".",
|
||||
"menu_activate_trial_button": "🆓 Пробный период",
|
||||
"menu_subscribe_inline": "🚀 Купить",
|
||||
"menu_my_subscription_inline": "🔐 Моя подписка",
|
||||
"no_subscription_options_available": "Выдача подписки не натроена администратором бота",
|
||||
"menu_referral_inline": "🎁 Рефералы",
|
||||
"no_subscription_options_available": "Выдача подписки не настроена администратором бота",
|
||||
"menu_referral_inline": "🎁 Пригласить друга",
|
||||
"referral_no_bonuses_configured": "Извините, реферальная программа в данный момент отключена",
|
||||
"menu_apply_promo_button": "🎟 Промокод",
|
||||
"menu_language_settings_inline": "🌐 Язык",
|
||||
"menu_server_status_button": "📊 Статус",
|
||||
"menu_support_button": "💬 Поддержка",
|
||||
"menu_terms_button": "📄 Условия сервиса",
|
||||
"menu_info_button": "ℹ️ Информация",
|
||||
"info_links_message": "Выберите документ:",
|
||||
"privacy_policy_button": "🔒 Политика конфиденциальности",
|
||||
"user_agreement_button": "📄 Пользовательское соглашение",
|
||||
"back_to_main_menu_button": "⬅️ Назад",
|
||||
"choose_language": "Выберите язык / Select language:",
|
||||
"language_set_alert": "Язык изменен!",
|
||||
@@ -25,8 +29,11 @@
|
||||
"error_displaying_menu": "Ошибка отображения меню.",
|
||||
"main_menu_unknown_action": "Неизвестное действие.",
|
||||
"select_subscription_period": "Выберите срок подписки:",
|
||||
"select_traffic_package": "Выберите пакет трафика:",
|
||||
"subscribe_for_months_button": "{months} мес. - {price} {currency_symbol}",
|
||||
"buy_traffic_package_button": "{traffic_gb} ГБ - {price} {currency_symbol}",
|
||||
"choose_payment_method": "Выберите способ оплаты:",
|
||||
"choose_payment_method_traffic": "Выберите способ оплаты пакета трафика:",
|
||||
"pay_button": "💳 Оплатить",
|
||||
"pay_with_yookassa_button": "💳 ЮKassa",
|
||||
"yookassa_autopay_flow_prompt": "Автопродление включено. Выберите, как оплатить подписку:",
|
||||
@@ -37,9 +44,10 @@
|
||||
"back_to_autopay_method_choice_button": "⬅️ Назад",
|
||||
"yookassa_autopay_charge_initiated": "Запрос на списание с выбранной карты отправлен. Сообщим, как только платёж завершится.",
|
||||
"pay_with_sbp_button": "📱 СБП",
|
||||
"pay_with_platega_button": "💳 Platega (СБП/карты)",
|
||||
"pay_with_severpay_button": "💳 SeverPay",
|
||||
"back_to_payment_methods_button": "⬅️ Назад",
|
||||
"pay_with_cryptopay_button": "💎 CryptoBot",
|
||||
"pay_with_tribute_button": "❤️ Tribute",
|
||||
"pay_with_stars_button": "🌟 Звезды Telegram",
|
||||
"connect_button": "🔗 Подключиться",
|
||||
|
||||
@@ -55,15 +63,24 @@
|
||||
|
||||
"cancel_button": "❌ Отмена",
|
||||
"payment_description_subscription": "Оплата подписки на {months} мес.",
|
||||
"payment_description_traffic": "Пакет трафика {traffic_gb} ГБ",
|
||||
"payment_link_message": "Для оплаты подписки на {months} мес., нажмите кнопку ниже:",
|
||||
"payment_link_message_traffic": "Для оплаты пакета {traffic_gb} ГБ нажмите кнопку ниже:",
|
||||
"free_kassa_order_info": "Заказ №{order_id} от {date}",
|
||||
"payment_invoice_sent_message": "Счёт Telegram Stars отправлен выше. Нажмите «Оплатить» или вернитесь к выбору способа ниже.",
|
||||
"payment_invoice_sent_message_traffic": "Счет на пакет {traffic_gb} ГБ отправлен выше. Нажмите «Оплатить» или вернитесь к выбору способа ниже.",
|
||||
"payment_successful_error_details": "✅ Оплата прошла успешно, но возникла ошибка при отображении деталей. Ваша подписка активна. Свяжитесь с поддержкой, если что-то не так.",
|
||||
"payment_successful_full": "✅ Оплата прошла успешно!\nВаша подписка на {months} мес. активна до {end_date}.\n\nКлюч подключения:\n<code>{config_link}</code>\n\nЧтобы подключиться, перейдите по ссылке и следуйте инструкции 👇",
|
||||
"payment_successful_traffic_full": "✅ Оплата прошла успешно!\nВаш пакет {traffic_gb} ГБ активирован.\nДата действия: {end_date}\n\nКлюч подключения:\n<code>{config_link}</code>\n\nЧтобы подключиться, перейдите по ссылке и следуйте инструкции 👇",
|
||||
"payment_successful_with_referral_bonus_full": "✅ Оплата прошла успешно!\nВаша подписка на {months} мес. (базовая дата окончания: {base_end_date}) продлена на {bonus_days} бонусных дней за приглашение от {inviter_name} и теперь активна до {final_end_date}.\n\nКлюч подключения:\n<code>{config_link}</code>\n\nЧтобы подключиться, перейдите по ссылке и следуйте инструкции 👇",
|
||||
"payment_failed": "❌ Оплата не удалась или была отменена. Пожалуйста, попробуйте еще раз или свяжитесь с поддержкой.",
|
||||
"config_link_not_available": "недоступна, обратитесь в поддержку",
|
||||
"traffic_unlimited": "Безлимитный",
|
||||
"traffic_period_day": "за день",
|
||||
"traffic_period_week": "за неделю",
|
||||
"traffic_period_month": "за месяц",
|
||||
"traffic_period_no_reset": "без сброса",
|
||||
"traffic_used_with_period": "{traffic_used} ({traffic_period})",
|
||||
"promo_code_prompt": "Пожалуйста, введите ваш промокод:",
|
||||
"promo_code_not_found": "Промокод <code>{code}</code> не найден, истек или уже использован максимальное количество раз.",
|
||||
"promo_code_already_used_by_user": "Вы уже активировали промокод <code>{code}</code>.",
|
||||
@@ -75,12 +92,14 @@
|
||||
"trial_confirm_activate_button": "✅ Активировать!",
|
||||
"trial_activated_alert": "Пробный период активирован!",
|
||||
"trial_activated_details_message": "✅ Пробный доступ активирован!\nВаш триал на {days} дн. действует до {end_date}.\n\nКлюч подключения:\n<code>{config_link}</code>\n\nЧтобы подключиться, перейдите по ссылке и следуйте инструкции 👇",
|
||||
"referral_welcome_bonus_applied": "🎁 Вам начислено {days} бонусных дн. за регистрацию по реферальной ссылке!\nПодписка активна до {end_date}.",
|
||||
"yes_button": "Да",
|
||||
"no_button": "Нет",
|
||||
"referral_program_info_new": "🎁 <b>Реферальная программа</b>\n\n📊 <b>Твоя статистика:</b>\n👥 Приглашено друзей: <b>{invited_count}</b>\n💳 Купили подписку: <b>{purchased_count}</b>\n\n🔗 Твоя ссылка:\n<code>{referral_link}</code>\n\n💰 <b>Бонусы за приглашения:</b>\n{bonus_details}\n\n📢 Поделись ссылкой с друзьями и получай бонусы!",
|
||||
"referral_bonus_per_period": "\n\n🎁 За {months}-мес. подписку друга:\n ➢ Вы: <b>{inviter_bonus_days} дн.</b>\n ➢ Друг: <b>{referee_bonus_days} дн.</b>",
|
||||
"referral_not_available_for_traffic": "Для пакетов трафика реферальные бонусы не начисляются.",
|
||||
"referral_share_message_button": "📩 Сообщение для друга",
|
||||
"referral_friend_message": "🚀 Привет! Попробуй этот VPN - быстрый, надёжный и доступный!\n\n🎁 По моей ссылке тебе дадут бонусные дни к подписке!\n\n{referral_link}",
|
||||
"referral_friend_message": "🚀 Привет! Попробуй этот сервис - быстрый, надёжный и доступный!\n\n🎁 По моей ссылке тебе дадут бонусные дни к подписке!\n\n{referral_link}",
|
||||
"friend_placeholder": "друг",
|
||||
"referral_bonus_inviter_notification_extended": "🎉 Поздравляем! Ваш друг {referee_name} оплатил подписку. Вам начислено {days} бонусных дней! Ваша подписка теперь активна до {new_end_date}.",
|
||||
"referral_bonus_inviter_notification_new_sub": "🎉 Поздравляем! Ваш друг {referee_name} оплатил подписку. Вам начислена бонусная подписка на {days} дней! Она активна до {new_end_date}.",
|
||||
@@ -101,7 +120,10 @@
|
||||
"admin_promo_marketing_section": "🎁 Промокоды",
|
||||
"admin_system_functions_section": "⚙️ Система",
|
||||
"admin_ban_management_section": "🚫 Блокировки",
|
||||
"admin_users_management_button": "👤 Управление",
|
||||
"admin_users_search_button": "🔍 Поиск",
|
||||
"admin_users_management_button": "👥 Список пользователей",
|
||||
"admin_users_list_header": "👥 <b>Список пользователей</b>\n\nСтраница {current}/{total} ({total_users} пользователей)",
|
||||
"admin_user_back_to_list_button": "⬅️ К списку",
|
||||
"back_to_user_management_button": "⬅️ К пользователям",
|
||||
"back_to_admin_panel_button": "⬅️ В админку",
|
||||
"admin_stats_header": "📊 Статистика Бота",
|
||||
@@ -111,6 +133,18 @@
|
||||
"admin_stats_payment_item": "{status_emoji} {amount} {currency} от {user_info} ({p_status}) [{p_date}]",
|
||||
"admin_stats_no_payments_found": "Платежей пока нет.",
|
||||
"admin_view_payments_button": "💰 Платежи",
|
||||
"admin_user_ratings_button": "🏆 Рейтинг пользователей",
|
||||
"back_to_stats_monitoring_button": "⬅️ К статистике",
|
||||
"admin_user_ratings_header": "🏆 <b>Рейтинг пользователей (ТОП {top_limit})</b>",
|
||||
"admin_user_ratings_traffic_title": "📊 По использованному трафику",
|
||||
"admin_user_ratings_traffic_month_title": "📊 По использованному трафику за месяц",
|
||||
"admin_user_ratings_traffic_lifetime_title": "📊 По использованному трафику за всё время",
|
||||
"admin_user_ratings_invited_title": "👥 По количеству приглашенных",
|
||||
"admin_user_ratings_revenue_title": "💸 По доходу с приглашенных",
|
||||
"admin_user_ratings_traffic_item": "{rank}. {user} — <b>{traffic_gb} ГБ</b>",
|
||||
"admin_user_ratings_invited_item": "{rank}. {user} — <b>{invited_count}</b>",
|
||||
"admin_user_ratings_revenue_item": "{rank}. {user} — <b>{revenue} RUB</b>",
|
||||
"admin_user_ratings_empty": "Пока нет данных.",
|
||||
"admin_payments_header": "💰 <b>Все платежи</b>",
|
||||
"admin_no_payments_found": "Платежи не найдены.",
|
||||
"admin_export_payments_csv": "📊 Экспорт CSV",
|
||||
@@ -127,6 +161,7 @@
|
||||
"admin_csv_provider": "Платежная система",
|
||||
"admin_csv_status": "Статус",
|
||||
"admin_csv_description": "Описание",
|
||||
"admin_csv_units": "Месяцы/ГБ",
|
||||
"admin_csv_months": "Месяцев",
|
||||
"admin_csv_created_at": "Дата создания",
|
||||
"admin_csv_provider_payment_id": "ID платежа в системе",
|
||||
@@ -156,15 +191,14 @@
|
||||
"admin_promo_invalid_max_activations": "Максимальное количество активаций должно быть положительным числом.",
|
||||
"admin_promo_invalid_validity_days": "Срок действия промокода (в днях) должен быть положительным числом.",
|
||||
"admin_promo_created_success": "✅ Промокод <code>{code}</code> успешно создан!\nБонус: {bonus_days} дней\nМакс. активаций: {max_activations}\nДействителен: {valid_until_str}",
|
||||
"subscription_72h_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на VPN истекает через 3 дня — {end_date}.\n\nПродлите её по кнопке ниже.",
|
||||
"subscription_48h_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на VPN истекает через 2 дня — {end_date}.\n\nПродлите её по кнопке ниже.",
|
||||
"subscription_24h_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на VPN истекает через 1 день — {end_date}.\n\nПродлите её по кнопке ниже.",
|
||||
"subscription_expired_notification": "👋 Привет, {user_name}!\n\n⛔ Срок вашей подписки на VPN истек ({end_date}).\n\nПродлите её по кнопке ниже.",
|
||||
"subscription_expired_yesterday_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на VPN истекла сутки назад ({end_date}).\n\nПродлите её по кнопке ниже.",
|
||||
"subscription_72h_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на сервис истекает через 3 дня — {end_date}.\n\nПродлите её по кнопке ниже.",
|
||||
"subscription_48h_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на сервис истекает через 2 дня — {end_date}.\n\nПродлите её по кнопке ниже.",
|
||||
"subscription_24h_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на сервис истекает через 1 день — {end_date}.\n\nПродлите её по кнопке ниже.",
|
||||
"subscription_expired_notification": "👋 Привет, {user_name}!\n\n⛔ Срок вашей подписки на сервис истек ({end_date}).\n\nПродлите её по кнопке ниже.",
|
||||
"subscription_expired_yesterday_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на сервис истекла сутки назад ({end_date}).\n\nПродлите её по кнопке ниже.",
|
||||
"autorenew_48h_charge_tomorrow_notice": "🔔 Напоминание\n\nЗавтра будет автоматическое списание за продление подписки. Если вы не хотите автопродление — отключите его кнопкой ниже.",
|
||||
"autorenew_confirm_enable": "🔄 Включить автопродление? Перед окончанием подписки будет выполняться автосписание.",
|
||||
"autorenew_confirm_disable": "🛑 Отключить автопродление? Автосписаний больше не будет.",
|
||||
"tribute_subscription_cancelled": "🚨 <b>Подписка отменена</b>\n\nВаша подписка Tribute была отменена. У вас есть 24 часа для восстановления доступа, после чего подписка будет заблокирована.\n\nДля продления подписки нажмите кнопку ниже.",
|
||||
"yookassa_auto_renewal": "🔄 <b>Подписка автоматически продлена</b>\n\nВаша подписка была автоматически продлена на {months} мес.\nНовая дата окончания: {end_date}",
|
||||
"admin_promo_set_validity_days": "⏰ Установить срок (дни)",
|
||||
"admin_back_to_panel": "⬅️ В панель",
|
||||
@@ -217,6 +251,8 @@
|
||||
"admin_user_card_title": "Карточка пользователя",
|
||||
"user_card_ban_button": "🚫 Заблокировать",
|
||||
"user_card_unban_button": "✅ Разблокировать",
|
||||
"user_card_open_profile_button": "👤 Открыть профиль",
|
||||
"user_card_open_referrer_profile_button": "👤 Профиль пригласившего",
|
||||
"user_card_back_to_banned_list_button": "⬅️ К списку забаненных",
|
||||
"admin_logs_menu_title": "Меню логов:",
|
||||
"admin_view_all_logs_button": "📜 Все логи сообщений",
|
||||
@@ -235,7 +271,6 @@
|
||||
"admin_broadcast_invalid_html": "❌ Некорректный HTML в сообщении. Пожалуйста, отправьте корректный HTML (поддерживаются теги Telegram) или уберите теги.",
|
||||
"error_displaying_logs_too_long": "Ошибка: логи слишком длинные для отображения одним сообщением. Попробуйте найти логи по конкретному пользователю.",
|
||||
"error_displaying_statistics": "Ошибка отображения статистики.",
|
||||
"tribute_auto_renewal": "🔄 <b>Подписка автоматически продлена</b>\n\nВаша подписка Tribute была автоматически продлена на {months} мес.\nНовая дата окончания: {end_date}",
|
||||
"admin_user_management_prompt": "👤 Управление пользователями\n\nВведите ID пользователя или @username для поиска:",
|
||||
"admin_user_subscription_info": "Информация о подписке:",
|
||||
"admin_user_reset_trial_button": "🔄 Сбросить триал",
|
||||
@@ -244,6 +279,16 @@
|
||||
"admin_user_send_message_button": "✉️ Сообщение",
|
||||
"admin_user_view_logs_button": "📜 Логи",
|
||||
"admin_user_refresh_button": "🔄 Обновить",
|
||||
"admin_user_delete_button": "❌ Удалить пользователя",
|
||||
"admin_user_delete_not_allowed": "❌ У вас нет прав для удаления пользователей.",
|
||||
"admin_user_delete_confirmation_prompt": "⚠️ Вы хотите полностью удалить пользователя {user_id}.\n\nОтправьте точный Telegram ID этого пользователя, чтобы подтвердить удаление.\nЛюбой другой ответ отменит операцию.",
|
||||
"admin_user_delete_state_missing": "⚠️ Нет активной операции удаления. Начните заново.",
|
||||
"admin_user_delete_cancelled": "Удаление отменено.",
|
||||
"admin_user_delete_mismatch": "⚠️ ID не совпадает. Удаление отменено.",
|
||||
"admin_user_delete_already_removed": "ℹ️ Пользователь уже удалён.",
|
||||
"admin_user_delete_panel_error": "❌ Не удалось удалить пользователя на панели. Операция прервана.",
|
||||
"admin_user_delete_success": "✅ Пользователь {user_id} удалён из бота и панели.",
|
||||
"admin_user_delete_error": "❌ Не удалось удалить пользователя. Попробуйте позже.",
|
||||
"admin_user_search_new_button": "🔍 Новый поиск",
|
||||
"admin_user_view_all_logs_button": "📋 Все логи",
|
||||
"admin_user_back_to_card_button": "🔙 К карточке",
|
||||
@@ -278,9 +323,12 @@
|
||||
"inline_admin_financial_stats_title": "💰 Финансовая статистика",
|
||||
"inline_system_stats_message": "🖥 <b>Статистика панели</b>\n\n🟢 Онлайн: <b>{online}</b>\n📊 Активных: <b>{active}</b>\n🔴 Отключенных: <b>{disabled}</b>\n⏰ Истекшие: <b>{expired}</b>\n⚠️ Ограниченные: <b>{limited}</b>\n👥 Всего пользователей: <b>{total}</b>\n💾 Использование RAM: <b>{memory:.1f}%</b>\n📊 Трафик за неделю: <b>{week_traffic}</b>\n📊 Трафик за месяц: <b>{month_traffic}</b>\n🔗 Активных нод: <b>{active_nodes}/{total_nodes}</b>",
|
||||
"inline_admin_system_stats_title": "🖥 Системная статистика",
|
||||
"log_referral_suffix": " (реферал от {referrer_id})",
|
||||
"log_referral_suffix": " (реферал от {referrer_link})",
|
||||
"log_open_profile_link": "👤 Открыть профиль",
|
||||
"log_open_referrer_profile_button": "👤 Профиль пригласившего",
|
||||
"log_new_user_registration": "👤 <b>Новый пользователь</b>\n\n🆔 ID: <code>{user_id}</code>\n👤 Имя: {user_display}{referral_text}\n📅 Время: {timestamp}",
|
||||
"log_payment_received": "{provider_emoji} <b>Получен платеж</b>\n\n👤 Пользователь: {user_display}\n💰 Сумма: <b>{amount} {currency}</b>\n📅 Период: <b>{months} мес.</b>\n🏦 Провайдер: {payment_provider}\n🕐 Время: {timestamp}",
|
||||
"log_payment_received_traffic": "{provider_emoji} <b>Получен платеж</b>\n\n👤 Пользователь: {user_display}\n💰 Сумма: <b>{amount} {currency}</b>\n🗂 Трафик: <b>{traffic_gb} ГБ</b>\n🏦 Провайдер: {payment_provider}\n🕐 Время: {timestamp}",
|
||||
"log_promo_activation": "🎁 <b>Активирован промокод</b>\n\n👤 Пользователь: {user_display}\n🏷 Код: <code>{promo_code}</code>\n🎯 Бонус: <b>+{bonus_days} дн.</b>\n🕐 Время: {timestamp}",
|
||||
"log_trial_activation": "🆓 <b>Активирован триал</b>\n\n👤 Пользователь: {user_display}\n⏰ Действует до: <b>{end_date}</b>\n🕐 Время: {timestamp}",
|
||||
"log_panel_sync": "{status_emoji} <b>Синхронизация с панелью</b>\n\n📊 Статус: <b>{status}</b>\n👥 Обработано пользователей: <b>{users_processed}</b>\n📋 Синхронизировано подписок: <b>{subs_synced}</b>\n🕐 Время: {timestamp}\n\n📝 Детали:\n{details}",
|
||||
@@ -337,6 +385,8 @@
|
||||
"admin_user_na_value": "N/A",
|
||||
"admin_user_subscription_none": "Нет активной подписки",
|
||||
"admin_user_actions_count_label": "📜 <b>Всего действий:</b>",
|
||||
"admin_user_total_paid_label": "💰 <b>Всего оплачено:</b>",
|
||||
"admin_user_referral_revenue_label": "💸 <b>Доход по рефералам:</b>",
|
||||
"admin_user_invited_friends_label": "👥 <b>Приглашено друзей:</b>",
|
||||
"admin_user_ref_purchased_label": "💳 <b>Купили подписку:</b>",
|
||||
"admin_user_subscription_active_until": "⏰ <b>Действует до:</b>",
|
||||
@@ -398,7 +448,12 @@
|
||||
"admin_sync_no_telegram_id": "\n⚠️ Записей без telegramId: {count}",
|
||||
"admin_sync_not_found_in_db": "\n❌ Не найдено в БД: {count}",
|
||||
"admin_payments_pagination_info": "📊 Показано {shown} из {total} платежей (стр. {current_page}/{total_pages})",
|
||||
"my_subscription_details": "🔐 <b>Моя подписка</b>\n\n⏰ Статус: <b>{status}</b>\n📅 Действует до: <b>{end_date}</b>\n📆 Осталось дней: <b>{days_left}</b>\n\n🔗 Ссылка на конфигурацию:\n<code>{config_link}</code>\n\n📊 Трафик:\nЛимит: <b>{traffic_limit}</b>\nИспользовано: <b>{traffic_used}</b>",
|
||||
"admin_payment_traffic_label": "🗂 Трафик: <b>{traffic_gb} ГБ</b>",
|
||||
"admin_payment_months_label": "📅 Период: <b>{months} мес.</b>",
|
||||
"my_subscription_details": "🔐 <b>Моя подписка</b>\n\n⏰ Статус: <b>{status}</b>\n📅 Действует до: <b>{end_date}</b>\n📆 Осталось дней: <b>{days_left}</b>\n\n🔗 Ссылка на конфигурацию:\n<code>{config_link}</code>\n\n📊 Трафик ({traffic_period}):\nЛимит: <b>{traffic_limit}</b>\nИспользовано: <b>{traffic_used}</b>",
|
||||
"my_traffic_details": "🔐 <b>Мой трафик</b>\n\n⏰ Статус: <b>{status}</b>\n📅 Действует до: <b>{end_date}</b>\n\n🔗 Ссылка на конфигурацию:\n<code>{config_link}</code>\n\n📊 Трафик ({traffic_period}):\nЛимит: <b>{traffic_limit}</b>\nИспользовано: <b>{traffic_used}</b>\nОсталось: <b>{traffic_left}</b>",
|
||||
"traffic_no_expiry": "без ограничения",
|
||||
"traffic_period_unknown": "неизвестно",
|
||||
"autorenew_enable_button": "🔄 Включить автопродление",
|
||||
"autorenew_disable_button": "🛑 Отключить автопродление",
|
||||
"subscription_autorenew_updated": "Настройки автопродления обновлены.",
|
||||
@@ -421,9 +476,8 @@
|
||||
"payment_method_tx_history_title": "📜 История операций",
|
||||
"payment_method_no_history": "История операций отсутствует.",
|
||||
"subscription_purchase_title": "Покупка подписки на {months} мес.",
|
||||
"subscription_tribute_notice": "Оплачено через Tribute. Продление делайте по ссылке Tribute.",
|
||||
"subscription_tribute_notice_with_link": "Оплачено через Tribute. Продлить: {link}",
|
||||
"subscription_autorenew_not_supported_for_tribute": "Автопродление управляется Tribute. Управляйте продлением в приложении/ссылке Tribute.",
|
||||
"traffic_purchase_title": "Покупка {traffic_gb} ГБ",
|
||||
"autorenew_enable_requires_card": "Прежде чем включать автоплатёж, привяжите карту в разделе «Способы оплаты».",
|
||||
"subscription_not_active": "У вас нет активной подписки.",
|
||||
"error_service_unavailable": "Сервис недоступен. Попробуйте позже.",
|
||||
"error_payment_gateway": "Ошибка платежного сервиса. Попробуйте позже.",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
from dotenv import load_dotenv
|
||||
@@ -9,6 +10,21 @@ from config.settings import get_settings, Settings
|
||||
from db.database_setup import init_db, init_db_connection
|
||||
|
||||
|
||||
def _resolve_log_level(value: str) -> int:
|
||||
if not value:
|
||||
return logging.INFO
|
||||
if isinstance(value, str):
|
||||
normalized = value.strip()
|
||||
if not normalized:
|
||||
return logging.INFO
|
||||
if normalized.isdigit():
|
||||
return int(normalized)
|
||||
level = getattr(logging, normalized.upper(), None)
|
||||
if isinstance(level, int):
|
||||
return level
|
||||
return logging.INFO
|
||||
|
||||
|
||||
async def main():
|
||||
load_dotenv()
|
||||
settings = get_settings()
|
||||
@@ -25,8 +41,9 @@ async def main():
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
load_dotenv()
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
level=_resolve_log_level(os.getenv("LOG_LEVEL", "INFO")),
|
||||
stream=sys.stdout,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||
try:
|
||||
|
||||
+9
-10
@@ -1,11 +1,10 @@
|
||||
aiogram==3.21.0
|
||||
python-dotenv==1.0.1
|
||||
aiohttp==3.12.14
|
||||
pydantic==2.7.1
|
||||
yookassa==3.5.0
|
||||
pycountry==23.12.11
|
||||
pydantic_settings
|
||||
sqlalchemy[asyncio]==2.0.29
|
||||
asyncpg==0.29.0
|
||||
alembic==1.13.1
|
||||
aiogram==3.24.0
|
||||
python-dotenv==1.2.1
|
||||
aiohttp==3.13.3
|
||||
pydantic==2.12.5
|
||||
yookassa==3.9.0
|
||||
httpx>=0.27.0
|
||||
pydantic_settings==2.12.0
|
||||
sqlalchemy[asyncio]==2.0.45
|
||||
asyncpg==0.31.0
|
||||
aiocryptopay==0.4.8
|
||||
|
||||
Reference in New Issue
Block a user