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 | ||
|
|
90ab186a7c | ||
|
|
dc725ccd68 | ||
|
|
d88aee03bb |
@@ -17,6 +17,8 @@ DEFAULT_CURRENCY_SYMBOL="RUB" #
|
|||||||
SUPPORT_LINK=https://t.me/your_support_link # Link to the support chat
|
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
|
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
|
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
|
SUBSCRIPTION_MINI_APP_URL= # URL of the subscription mini-app
|
||||||
START_COMMAND_DESCRIPTION= # Description of the /start command
|
START_COMMAND_DESCRIPTION= # Description of the /start command
|
||||||
DISABLE_WELCOME_MESSAGE= # Disable the welcome message
|
DISABLE_WELCOME_MESSAGE= # Disable the welcome message
|
||||||
@@ -53,6 +55,8 @@ YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING=True #
|
|||||||
# Nalogo (self-employed receipts)
|
# Nalogo (self-employed receipts)
|
||||||
NALOGO_INN=your_inn # INN for nalog.ru
|
NALOGO_INN=your_inn # INN for nalog.ru
|
||||||
NALOGO_PASSWORD=your_nalogo_password # Password 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 Payment Gateway Configuration
|
||||||
FREEKASSA_MERCHANT_ID=your_shop_id # Your shop ID in FreeKassa
|
FREEKASSA_MERCHANT_ID=your_shop_id # Your shop ID in FreeKassa
|
||||||
@@ -120,6 +124,7 @@ SUBSCRIPTION_NOTIFY_DAYS_BEFORE=3 #
|
|||||||
|
|
||||||
|
|
||||||
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
|
LEGACY_REFS=true # Allow ref_<tg_id> links. Leave unset/true unless you want to disable old links
|
||||||
# Referral Bonus Days
|
# Referral Bonus Days
|
||||||
# Bonus for the inviting user
|
# Bonus for the inviting user
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
name: Build and Push Dev Docker Image
|
name: Build and Push Dev GHCR Image
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -9,9 +9,7 @@ on:
|
|||||||
- dev
|
- dev
|
||||||
|
|
||||||
env:
|
env:
|
||||||
GHCR_REGISTRY: ghcr.io
|
GHCR_IMAGE: ghcr.io/3252a8/remnawave-tg-shop
|
||||||
IMAGE_NAME: ${{ github.repository }}
|
|
||||||
DOCKERHUB_IMAGE: docker.io/${{ secrets.DOCKERHUB_USERNAME }}/${{ github.event.repository.name }}
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
@@ -24,29 +22,22 @@ jobs:
|
|||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Log in to GitHub Container Registry
|
- name: Log in to GitHub Container Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.GHCR_REGISTRY }}
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
- name: Set up Docker Buildx
|
||||||
uses: docker/login-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
with:
|
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Extract metadata (tags, labels) for Docker
|
- name: Extract metadata (tags, labels) for Docker
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: |
|
images: |
|
||||||
${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}
|
${{ env.GHCR_IMAGE }}
|
||||||
${{ env.DOCKERHUB_IMAGE }}
|
|
||||||
tags: |
|
tags: |
|
||||||
type=ref,event=branch
|
type=ref,event=branch
|
||||||
type=ref,event=pr
|
type=ref,event=pr
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
name: Build and Publish multi-arch Docker Image
|
name: Build and Publish GHCR Docker Image
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -10,8 +10,7 @@ on:
|
|||||||
- 'README.md'
|
- 'README.md'
|
||||||
|
|
||||||
env:
|
env:
|
||||||
GHCR_IMAGE: ghcr.io/${{ github.repository }}
|
GHCR_IMAGE: ghcr.io/3252a8/remnawave-tg-shop
|
||||||
DOCKERHUB_IMAGE: docker.io/${{ secrets.DOCKERHUB_USERNAME }}/${{ github.event.repository.name }}
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-push:
|
build-and-push:
|
||||||
@@ -31,26 +30,27 @@ jobs:
|
|||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Set up QEMU (для эмуляции arm64 на x86)
|
- name: Set up QEMU (для эмуляции arm64 на x86)
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
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
|
- name: Build and push multi-arch Docker image
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
${{ env.GHCR_IMAGE }}:latest
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
${{ env.GHCR_IMAGE }}:${{ github.ref_name }}
|
|
||||||
${{ env.DOCKERHUB_IMAGE }}:latest
|
|
||||||
${{ env.DOCKERHUB_IMAGE }}:${{ github.ref_name }}
|
|
||||||
|
|||||||
@@ -45,7 +45,7 @@
|
|||||||
|
|
||||||
1. **Клонируйте репозиторий:**
|
1. **Клонируйте репозиторий:**
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/machka-pasla/remnawave-tg-shop
|
git clone https://github.com/3252a8/remnawave-tg-shop
|
||||||
cd remnawave-tg-shop
|
cd remnawave-tg-shop
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -177,11 +177,162 @@
|
|||||||
|
|
||||||
> 💡 Если включена проверка подписки на канал (`REQUIRED_CHANNEL_ID`), добавьте бота администратором в этот канал. Пользователь увидит кнопку «Проверить подписку», и, после первого успешного подтверждения, дальнейшие действия блокироваться не будут.
|
> 💡 Если включена проверка подписки на канал (`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
|
## 🐳 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 Actions (`.github/workflows`). По умолчанию образы пушатся в GitHub Container Registry и Docker Hub. Добавьте в Secrets репозитория значения `DOCKERHUB_USERNAME` и `DOCKERHUB_TOKEN` (персональный access token или пароль для Docker Hub), чтобы загрузка в Docker Hub работала корректно.
|
Образ публикуется в 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
|
||||||
|
```
|
||||||
|
|
||||||
## 📁 Структура проекта
|
## 📁 Структура проекта
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from bot.services.panel_webhook_service import PanelWebhookService
|
|||||||
from bot.services.freekassa_service import FreeKassaService
|
from bot.services.freekassa_service import FreeKassaService
|
||||||
from bot.services.platega_service import PlategaService
|
from bot.services.platega_service import PlategaService
|
||||||
from bot.services.severpay_service import SeverPayService
|
from bot.services.severpay_service import SeverPayService
|
||||||
from bot.services.nalogo_service import NalogoService
|
from bot.services.lknpd_service import LknpdService
|
||||||
|
|
||||||
|
|
||||||
def build_core_services(
|
def build_core_services(
|
||||||
@@ -73,9 +73,10 @@ def build_core_services(
|
|||||||
bot_username_for_default_return=bot_username_for_default_return,
|
bot_username_for_default_return=bot_username_for_default_return,
|
||||||
settings_obj=settings,
|
settings_obj=settings,
|
||||||
)
|
)
|
||||||
nalogo_service = NalogoService(
|
lknpd_service = LknpdService(
|
||||||
settings.NALOGO_INN,
|
settings.LKNPD_INN,
|
||||||
settings.NALOGO_PASSWORD,
|
settings.LKNPD_PASSWORD,
|
||||||
|
api_url=settings.LKNPD_API_URL,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Wire services that depend on each other
|
# Wire services that depend on each other
|
||||||
@@ -97,7 +98,7 @@ def build_core_services(
|
|||||||
"freekassa_service": freekassa_service,
|
"freekassa_service": freekassa_service,
|
||||||
"panel_webhook_service": panel_webhook_service,
|
"panel_webhook_service": panel_webhook_service,
|
||||||
"yookassa_service": yookassa_service,
|
"yookassa_service": yookassa_service,
|
||||||
"nalogo_service": nalogo_service,
|
"lknpd_service": lknpd_service,
|
||||||
"platega_service": platega_service,
|
"platega_service": platega_service,
|
||||||
"severpay_service": severpay_service,
|
"severpay_service": severpay_service,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ async def build_and_start_web_app(
|
|||||||
app["i18n"] = dp.get("i18n_instance")
|
app["i18n"] = dp.get("i18n_instance")
|
||||||
for key in (
|
for key in (
|
||||||
"yookassa_service",
|
"yookassa_service",
|
||||||
"nalogo_service",
|
"lknpd_service",
|
||||||
"subscription_service",
|
"subscription_service",
|
||||||
"referral_service",
|
"referral_service",
|
||||||
"panel_service",
|
"panel_service",
|
||||||
|
|||||||
@@ -141,6 +141,9 @@ async def admin_panel_actions_callback_handler(
|
|||||||
from . import payments as admin_payments_handlers
|
from . import payments as admin_payments_handlers
|
||||||
await admin_payments_handlers.view_payments_handler(
|
await admin_payments_handlers.view_payments_handler(
|
||||||
callback, i18n_data, settings, session)
|
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":
|
elif action == "ads":
|
||||||
from . import ads as admin_ads_handlers
|
from . import ads as admin_ads_handlers
|
||||||
await admin_ads_handlers.show_ads_menu(callback, settings, i18n_data, session)
|
await admin_ads_handlers.show_ads_menu(callback, settings, i18n_data, session)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import logging
|
|||||||
from aiogram import Router, F, types
|
from aiogram import Router, F, types
|
||||||
from typing import Optional, Dict, List
|
from typing import Optional, Dict, List
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
import html
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from config.settings import Settings
|
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 db.models import Payment, PanelSyncStatus
|
||||||
from bot.services.panel_api_service import PanelApiService
|
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
|
from bot.middlewares.i18n import JsonI18n
|
||||||
|
|
||||||
router = Router(name="admin_statistics_router")
|
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,
|
async def show_statistics_handler(callback: types.CallbackQuery,
|
||||||
i18n_data: dict, settings: Settings,
|
i18n_data: dict, settings: Settings,
|
||||||
session: AsyncSession):
|
session: AsyncSession):
|
||||||
@@ -255,3 +288,104 @@ async def show_statistics_handler(callback: types.CallbackQuery,
|
|||||||
reply_markup=get_back_to_admin_panel_keyboard(
|
reply_markup=get_back_to_admin_panel_keyboard(
|
||||||
current_lang, i18n))
|
current_lang, i18n))
|
||||||
break
|
break
|
||||||
|
|
||||||
|
|
||||||
|
async def show_user_ratings_handler(
|
||||||
|
callback: types.CallbackQuery,
|
||||||
|
i18n_data: dict,
|
||||||
|
settings: Settings,
|
||||||
|
session: AsyncSession,
|
||||||
|
):
|
||||||
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||||
|
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||||
|
if not i18n or not callback.message:
|
||||||
|
await callback.answer("Error displaying ratings.", show_alert=True)
|
||||||
|
return
|
||||||
|
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||||
|
|
||||||
|
await callback.answer()
|
||||||
|
|
||||||
|
top_limit = 10
|
||||||
|
bot_username: Optional[str] = None
|
||||||
|
try:
|
||||||
|
me = await callback.bot.get_me()
|
||||||
|
bot_username = me.username
|
||||||
|
except Exception as e_get_me:
|
||||||
|
logging.warning("Failed to resolve bot username for ratings links: %s",
|
||||||
|
e_get_me)
|
||||||
|
|
||||||
|
traffic_top = await user_dal.get_top_users_by_traffic_used(session, limit=top_limit)
|
||||||
|
lifetime_traffic_top = await user_dal.get_top_users_by_lifetime_traffic_used(
|
||||||
|
session, limit=top_limit
|
||||||
|
)
|
||||||
|
invited_top = await user_dal.get_top_users_by_referrals_count(session, limit=top_limit)
|
||||||
|
revenue_top = await user_dal.get_top_users_by_referral_revenue(session, limit=top_limit)
|
||||||
|
|
||||||
|
text_parts: List[str] = [
|
||||||
|
_("admin_user_ratings_header", top_limit=top_limit),
|
||||||
|
"",
|
||||||
|
f"<b>{_('admin_user_ratings_traffic_month_title')}</b>",
|
||||||
|
]
|
||||||
|
|
||||||
|
if traffic_top:
|
||||||
|
for idx, row in enumerate(traffic_top, start=1):
|
||||||
|
traffic_gb = float(row.get("traffic_used_bytes") or 0) / (1024**3)
|
||||||
|
text_parts.append(
|
||||||
|
_(
|
||||||
|
"admin_user_ratings_traffic_item",
|
||||||
|
rank=idx,
|
||||||
|
user=_format_rating_user_label(row, bot_username),
|
||||||
|
traffic_gb=f"{traffic_gb:.2f}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
text_parts.append(_("admin_user_ratings_empty"))
|
||||||
|
|
||||||
|
text_parts.extend(["", f"<b>{_('admin_user_ratings_traffic_lifetime_title')}</b>"])
|
||||||
|
if lifetime_traffic_top:
|
||||||
|
for idx, row in enumerate(lifetime_traffic_top, start=1):
|
||||||
|
traffic_gb = float(row.get("lifetime_used_traffic_bytes") or 0) / (1024**3)
|
||||||
|
text_parts.append(
|
||||||
|
_(
|
||||||
|
"admin_user_ratings_traffic_item",
|
||||||
|
rank=idx,
|
||||||
|
user=_format_rating_user_label(row, bot_username),
|
||||||
|
traffic_gb=f"{traffic_gb:.2f}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
text_parts.append(_("admin_user_ratings_empty"))
|
||||||
|
|
||||||
|
text_parts.extend(["", f"<b>{_('admin_user_ratings_invited_title')}</b>"])
|
||||||
|
if invited_top:
|
||||||
|
for idx, row in enumerate(invited_top, start=1):
|
||||||
|
text_parts.append(
|
||||||
|
_(
|
||||||
|
"admin_user_ratings_invited_item",
|
||||||
|
rank=idx,
|
||||||
|
user=_format_rating_user_label(row, bot_username),
|
||||||
|
invited_count=int(row.get("invited_count") or 0),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
text_parts.append(_("admin_user_ratings_empty"))
|
||||||
|
|
||||||
|
text_parts.extend(["", f"<b>{_('admin_user_ratings_revenue_title')}</b>"])
|
||||||
|
if revenue_top:
|
||||||
|
for idx, row in enumerate(revenue_top, start=1):
|
||||||
|
text_parts.append(
|
||||||
|
_(
|
||||||
|
"admin_user_ratings_revenue_item",
|
||||||
|
rank=idx,
|
||||||
|
user=_format_rating_user_label(row, bot_username),
|
||||||
|
revenue=f"{float(row.get('referral_revenue') or 0):.2f}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
text_parts.append(_("admin_user_ratings_empty"))
|
||||||
|
|
||||||
|
await callback.message.edit_text(
|
||||||
|
"\n".join(text_parts),
|
||||||
|
reply_markup=get_back_to_user_management_keyboard(current_lang, i18n),
|
||||||
|
parse_mode="HTML",
|
||||||
|
)
|
||||||
|
|||||||
@@ -18,6 +18,24 @@ from bot.middlewares.i18n import JsonI18n
|
|||||||
router = Router(name="admin_sync_router")
|
router = Router(name="admin_sync_router")
|
||||||
|
|
||||||
|
|
||||||
|
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(
|
async def perform_sync(
|
||||||
panel_service: PanelApiService,
|
panel_service: PanelApiService,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
@@ -176,6 +194,14 @@ async def perform_sync(
|
|||||||
f"Updated panel UUID for user {actual_user_id}: {panel_uuid}"
|
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
|
# Ensure panel description contains Telegram fields
|
||||||
try:
|
try:
|
||||||
if panel_uuid and existing_user:
|
if panel_uuid and existing_user:
|
||||||
|
|||||||
@@ -33,6 +33,26 @@ router = Router(name="admin_user_management_router")
|
|||||||
USERNAME_REGEX = re.compile(r"^[a-zA-Z0-9_]{5,32}$")
|
USERNAME_REGEX = re.compile(r"^[a-zA-Z0-9_]{5,32}$")
|
||||||
|
|
||||||
|
|
||||||
|
def _format_traffic_period(strategy: Optional[str], get_text: Callable[..., str]) -> Optional[str]:
|
||||||
|
if not strategy:
|
||||||
|
return None
|
||||||
|
strategy_upper = str(strategy).upper()
|
||||||
|
key_map = {
|
||||||
|
"MONTH": "traffic_period_month",
|
||||||
|
"WEEK": "traffic_period_week",
|
||||||
|
"DAY": "traffic_period_day",
|
||||||
|
"NO_RESET": "traffic_period_no_reset",
|
||||||
|
}
|
||||||
|
label_key = key_map.get(strategy_upper)
|
||||||
|
return get_text(label_key) if label_key else strategy_upper
|
||||||
|
|
||||||
|
|
||||||
|
def _format_used_with_period(get_text: Callable[..., str], used_display: str, period_label: Optional[str]) -> str:
|
||||||
|
if not period_label:
|
||||||
|
return used_display
|
||||||
|
return get_text("traffic_used_with_period", traffic_used=used_display, traffic_period=period_label)
|
||||||
|
|
||||||
|
|
||||||
async def users_list_handler(callback: types.CallbackQuery,
|
async def users_list_handler(callback: types.CallbackQuery,
|
||||||
i18n_data: dict, settings: Settings,
|
i18n_data: dict, settings: Settings,
|
||||||
session: AsyncSession, page: int = 0):
|
session: AsyncSession, page: int = 0):
|
||||||
@@ -263,10 +283,22 @@ async def format_user_card(user: User, session: AsyncSession,
|
|||||||
|
|
||||||
traffic_limit = subscription_details.get('traffic_limit_bytes')
|
traffic_limit = subscription_details.get('traffic_limit_bytes')
|
||||||
traffic_used = subscription_details.get('traffic_used_bytes')
|
traffic_used = subscription_details.get('traffic_used_bytes')
|
||||||
if traffic_limit and traffic_used is not None:
|
traffic_strategy = subscription_details.get('traffic_limit_strategy')
|
||||||
traffic_limit_gb = traffic_limit / (1024**3)
|
period_label = _format_traffic_period(traffic_strategy, _)
|
||||||
|
if traffic_used is not None or traffic_limit is not None:
|
||||||
|
used_display = _("traffic_na")
|
||||||
|
if traffic_used is not None:
|
||||||
traffic_used_gb = traffic_used / (1024**3)
|
traffic_used_gb = traffic_used / (1024**3)
|
||||||
card_parts.append(f"{_('admin_user_traffic_label')} {hcode(f'{traffic_used_gb:.2f}GB / {traffic_limit_gb:.2f}GB')}")
|
used_display = f"{traffic_used_gb:.2f}GB"
|
||||||
|
used_display = _format_used_with_period(_, used_display, period_label)
|
||||||
|
|
||||||
|
if traffic_limit:
|
||||||
|
traffic_limit_gb = traffic_limit / (1024**3)
|
||||||
|
limit_display = f"{traffic_limit_gb:.2f}GB"
|
||||||
|
else:
|
||||||
|
limit_display = _("traffic_unlimited")
|
||||||
|
|
||||||
|
card_parts.append(f"{_('admin_user_traffic_label')} {hcode(f'{used_display} / {limit_display}')}")
|
||||||
else:
|
else:
|
||||||
card_parts.append(f"{_('admin_user_subscription_label')} {hcode(_('admin_user_subscription_none'))}")
|
card_parts.append(f"{_('admin_user_subscription_label')} {hcode(_('admin_user_subscription_none'))}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ from bot.services.subscription_service import SubscriptionService
|
|||||||
from bot.services.referral_service import ReferralService
|
from bot.services.referral_service import ReferralService
|
||||||
from bot.services.panel_api_service import PanelApiService
|
from bot.services.panel_api_service import PanelApiService
|
||||||
from bot.services.yookassa_service import YooKassaService
|
from bot.services.yookassa_service import YooKassaService
|
||||||
from bot.services.nalogo_service import NalogoService
|
from bot.services.lknpd_service import LknpdService
|
||||||
from bot.middlewares.i18n import JsonI18n
|
from bot.middlewares.i18n import JsonI18n
|
||||||
from config.settings import Settings
|
from config.settings import Settings
|
||||||
from bot.services.notification_service import NotificationService
|
from bot.services.notification_service import NotificationService
|
||||||
@@ -39,7 +39,7 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
|||||||
panel_service: PanelApiService,
|
panel_service: PanelApiService,
|
||||||
subscription_service: SubscriptionService,
|
subscription_service: SubscriptionService,
|
||||||
referral_service: ReferralService,
|
referral_service: ReferralService,
|
||||||
nalogo_service: Optional[NalogoService] = None):
|
lknpd_service: Optional[LknpdService] = None):
|
||||||
metadata = payment_info_from_webhook.get("metadata", {})
|
metadata = payment_info_from_webhook.get("metadata", {})
|
||||||
user_id_str = metadata.get("user_id")
|
user_id_str = metadata.get("user_id")
|
||||||
subscription_months_str = metadata.get("subscription_months")
|
subscription_months_str = metadata.get("subscription_months")
|
||||||
@@ -161,9 +161,9 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
|||||||
session,
|
session,
|
||||||
payment_db_id,
|
payment_db_id,
|
||||||
)
|
)
|
||||||
should_send_nalogo_receipt = bool(
|
should_send_lknpd_receipt = bool(
|
||||||
nalogo_service
|
lknpd_service
|
||||||
and nalogo_service.configured
|
and lknpd_service.configured
|
||||||
and payment_info_from_webhook.get("paid") is True
|
and payment_info_from_webhook.get("paid") is True
|
||||||
and payment_info_from_webhook.get("status") == "succeeded"
|
and payment_info_from_webhook.get("status") == "succeeded"
|
||||||
and payment_before_update
|
and payment_before_update
|
||||||
@@ -278,15 +278,15 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
|||||||
traffic_label = (
|
traffic_label = (
|
||||||
str(int(traffic_amount_gb)) if float(traffic_amount_gb).is_integer() else f"{traffic_amount_gb:g}"
|
str(int(traffic_amount_gb)) if float(traffic_amount_gb).is_integer() else f"{traffic_amount_gb:g}"
|
||||||
)
|
)
|
||||||
if should_send_nalogo_receipt:
|
if should_send_lknpd_receipt:
|
||||||
receipt_item_name = payment_info_from_webhook.get("description")
|
receipt_item_name = payment_info_from_webhook.get("description")
|
||||||
if not receipt_item_name:
|
if not receipt_item_name:
|
||||||
if sale_mode == "traffic":
|
if sale_mode == "traffic":
|
||||||
receipt_item_name = f"Remnawave traffic package {traffic_label} GB"
|
receipt_item_name = settings.LKNPD_RECEIPT_NAME_TRAFFIC.format(gb=traffic_label)
|
||||||
else:
|
else:
|
||||||
receipt_item_name = f"Remnawave subscription {int(subscription_months)} months"
|
receipt_item_name = settings.LKNPD_RECEIPT_NAME_SUBSCRIPTION.format(months=int(subscription_months))
|
||||||
try:
|
try:
|
||||||
await nalogo_service.create_income_receipt(
|
await lknpd_service.create_income_receipt(
|
||||||
item_name=receipt_item_name,
|
item_name=receipt_item_name,
|
||||||
amount=payment_value,
|
amount=payment_value,
|
||||||
quantity=1.0,
|
quantity=1.0,
|
||||||
@@ -294,7 +294,7 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
|||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logging.exception(
|
logging.exception(
|
||||||
"Failed to send Nalogo receipt for payment %s",
|
"Failed to send LKNPD receipt for payment %s",
|
||||||
yk_payment_id_from_hook,
|
yk_payment_id_from_hook,
|
||||||
)
|
)
|
||||||
config_link_display, connect_button_url = await prepare_config_links(
|
config_link_display, connect_button_url = await prepare_config_links(
|
||||||
@@ -473,7 +473,7 @@ async def yookassa_webhook_route(request: web.Request):
|
|||||||
subscription_service: SubscriptionService = request.app[
|
subscription_service: SubscriptionService = request.app[
|
||||||
'subscription_service']
|
'subscription_service']
|
||||||
referral_service: ReferralService = request.app['referral_service']
|
referral_service: ReferralService = request.app['referral_service']
|
||||||
nalogo_service: Optional[NalogoService] = request.app.get('nalogo_service')
|
lknpd_service: Optional[LknpdService] = request.app.get('lknpd_service')
|
||||||
async_session_factory: sessionmaker = request.app[
|
async_session_factory: sessionmaker = request.app[
|
||||||
'async_session_factory']
|
'async_session_factory']
|
||||||
except KeyError as e_app_ctx:
|
except KeyError as e_app_ctx:
|
||||||
@@ -567,7 +567,7 @@ async def yookassa_webhook_route(request: web.Request):
|
|||||||
session, bot, payment_dict_for_processing,
|
session, bot, payment_dict_for_processing,
|
||||||
i18n_instance, settings, panel_service,
|
i18n_instance, settings, panel_service,
|
||||||
subscription_service, referral_service,
|
subscription_service, referral_service,
|
||||||
nalogo_service)
|
lknpd_service)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
else:
|
else:
|
||||||
logging.warning(
|
logging.warning(
|
||||||
|
|||||||
+154
-3
@@ -16,6 +16,7 @@ from bot.keyboards.inline.user_keyboards import (
|
|||||||
get_main_menu_inline_keyboard,
|
get_main_menu_inline_keyboard,
|
||||||
get_language_selection_keyboard,
|
get_language_selection_keyboard,
|
||||||
get_channel_subscription_keyboard,
|
get_channel_subscription_keyboard,
|
||||||
|
get_information_links_keyboard,
|
||||||
)
|
)
|
||||||
from bot.services.subscription_service import SubscriptionService
|
from bot.services.subscription_service import SubscriptionService
|
||||||
from bot.services.panel_api_service import PanelApiService
|
from bot.services.panel_api_service import PanelApiService
|
||||||
@@ -304,16 +305,21 @@ async def ensure_required_channel_subscription(
|
|||||||
@router.message(CommandStart())
|
@router.message(CommandStart())
|
||||||
@router.message(CommandStart(magic=F.args.regexp(r"^ref_((?:[uU][A-Za-z0-9]{9})|(?:[A-Za-z0-9]{9})|\d+)$").as_("ref_match")))
|
@router.message(CommandStart(magic=F.args.regexp(r"^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"^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,
|
async def start_command_handler(message: types.Message,
|
||||||
state: FSMContext,
|
state: FSMContext,
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
i18n_data: dict,
|
i18n_data: dict,
|
||||||
subscription_service: SubscriptionService,
|
subscription_service: SubscriptionService,
|
||||||
|
referral_service: ReferralService,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
ref_match: Optional[re.Match] = None,
|
ref_match: Optional[re.Match] = None,
|
||||||
promo_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()
|
await state.clear()
|
||||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||||
@@ -323,8 +329,61 @@ async def start_command_handler(message: types.Message,
|
|||||||
user = message.from_user
|
user = message.from_user
|
||||||
user_id = user.id
|
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
|
referred_by_user_id: Optional[int] = None
|
||||||
promo_code_to_apply: Optional[str] = None
|
promo_code_to_apply: Optional[str] = None
|
||||||
|
should_open_referral_from_start = False
|
||||||
ad_start_param: Optional[str] = None
|
ad_start_param: Optional[str] = None
|
||||||
|
|
||||||
if ref_match:
|
if ref_match:
|
||||||
@@ -348,6 +407,9 @@ async def start_command_handler(message: types.Message,
|
|||||||
elif promo_match:
|
elif promo_match:
|
||||||
promo_code_to_apply = promo_match.group(1)
|
promo_code_to_apply = promo_match.group(1)
|
||||||
logging.info(f"User {user_id} started with promo code: {promo_code_to_apply}")
|
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:
|
elif ad_param_match:
|
||||||
ad_start_param = ad_param_match.group(1)
|
ad_start_param = ad_param_match.group(1)
|
||||||
logging.info(f"User {user_id} started with ad start param: {ad_start_param}")
|
logging.info(f"User {user_id} started with ad start param: {ad_start_param}")
|
||||||
@@ -357,6 +419,7 @@ async def start_command_handler(message: types.Message,
|
|||||||
sanitized_last_name = sanitize_display_name(user.last_name)
|
sanitized_last_name = sanitize_display_name(user.last_name)
|
||||||
|
|
||||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||||
|
is_existing_user = db_user is not None
|
||||||
if not db_user:
|
if not db_user:
|
||||||
user_data_to_create = {
|
user_data_to_create = {
|
||||||
"user_id": user_id,
|
"user_id": user_id,
|
||||||
@@ -386,6 +449,50 @@ async def start_command_handler(message: types.Message,
|
|||||||
f"New user {user_id} added to session. Referred by: {referred_by_user_id or 'N/A'}."
|
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
|
# Send notification about new user registration
|
||||||
try:
|
try:
|
||||||
from bot.services.notification_service import NotificationService
|
from bot.services.notification_service import NotificationService
|
||||||
@@ -458,8 +565,12 @@ async def start_command_handler(message: types.Message,
|
|||||||
db_user):
|
db_user):
|
||||||
return
|
return
|
||||||
|
|
||||||
|
open_referral_page_for_existing_user = (
|
||||||
|
should_open_referral_from_start and is_existing_user
|
||||||
|
)
|
||||||
|
|
||||||
# Send welcome message if not disabled
|
# 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)))
|
await message.answer(_(key="welcome", user_name=hd.quote(user.full_name)))
|
||||||
|
|
||||||
# Auto-apply promo code if provided via start parameter
|
# Auto-apply promo code if provided via start parameter
|
||||||
@@ -508,12 +619,20 @@ async def start_command_handler(message: types.Message,
|
|||||||
else:
|
else:
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
logging.warning(f"Failed to auto-apply promo code '{promo_code_to_apply}' for user {user_id}: {result}")
|
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
|
# Continue to show main menu if promo failed
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Error auto-applying promo code '{promo_code_to_apply}' for user {user_id}: {e}")
|
logging.error(f"Error auto-applying promo code '{promo_code_to_apply}' for user {user_id}: {e}")
|
||||||
await session.rollback()
|
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,
|
await send_main_menu(message,
|
||||||
settings,
|
settings,
|
||||||
i18n_data,
|
i18n_data,
|
||||||
@@ -696,6 +815,38 @@ async def main_action_callback_handler(
|
|||||||
elif action == "language":
|
elif action == "language":
|
||||||
|
|
||||||
await language_command_handler(callback, i18n_data, settings)
|
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":
|
elif action == "back_to_main":
|
||||||
await send_main_menu(callback,
|
await send_main_menu(callback,
|
||||||
settings,
|
settings,
|
||||||
|
|||||||
@@ -172,10 +172,30 @@ async def my_subscription_command_handler(
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return str(val)
|
return str(val)
|
||||||
|
def _format_traffic_period(strategy: Optional[str]) -> Optional[str]:
|
||||||
|
if not strategy:
|
||||||
|
return None
|
||||||
|
strategy_upper = str(strategy).upper()
|
||||||
|
key_map = {
|
||||||
|
"MONTH": "traffic_period_month",
|
||||||
|
"WEEK": "traffic_period_week",
|
||||||
|
"DAY": "traffic_period_day",
|
||||||
|
"NO_RESET": "traffic_period_no_reset",
|
||||||
|
}
|
||||||
|
label_key = key_map.get(strategy_upper)
|
||||||
|
return get_text(label_key) if label_key else strategy_upper
|
||||||
|
|
||||||
|
def _format_used_with_period(used_display: str, period_label: Optional[str]) -> str:
|
||||||
|
if not period_label:
|
||||||
|
return used_display
|
||||||
|
return get_text("traffic_used_with_period", traffic_used=used_display, traffic_period=period_label)
|
||||||
|
|
||||||
|
period_label = _format_traffic_period(active.get("traffic_limit_strategy"))
|
||||||
|
period_label = period_label or get_text("traffic_period_unknown")
|
||||||
|
|
||||||
if traffic_mode:
|
if traffic_mode:
|
||||||
limit_display = _fmt_gb(active.get("traffic_limit_bytes"))
|
limit_display = _fmt_gb(active.get("traffic_limit_bytes"))
|
||||||
used_display = _fmt_gb(active.get("traffic_used_bytes"))
|
used_display = _format_used_with_period(_fmt_gb(active.get("traffic_used_bytes")), period_label)
|
||||||
remaining_display = get_text("traffic_na")
|
remaining_display = get_text("traffic_na")
|
||||||
try:
|
try:
|
||||||
limit_val = active.get("traffic_limit_bytes") or 0
|
limit_val = active.get("traffic_limit_bytes") or 0
|
||||||
@@ -191,6 +211,7 @@ async def my_subscription_command_handler(
|
|||||||
traffic_limit=limit_display,
|
traffic_limit=limit_display,
|
||||||
traffic_used=used_display,
|
traffic_used=used_display,
|
||||||
traffic_left=remaining_display,
|
traffic_left=remaining_display,
|
||||||
|
traffic_period=period_label,
|
||||||
config_link=config_link_value,
|
config_link=config_link_value,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
@@ -202,8 +223,12 @@ async def my_subscription_command_handler(
|
|||||||
config_link=config_link_value,
|
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_limit=(f"{active['traffic_limit_bytes'] / 2**30:.2f} GB" if active.get("traffic_limit_bytes") else get_text("traffic_unlimited")),
|
||||||
traffic_used=(
|
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")
|
_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)
|
base_markup = get_back_to_main_menu_markup(current_lang, i18n)
|
||||||
|
|||||||
@@ -64,10 +64,12 @@ def get_user_management_keyboard(i18n_instance, lang: str) -> InlineKeyboardMark
|
|||||||
callback_data="admin_action:users_search_prompt")
|
callback_data="admin_action:users_search_prompt")
|
||||||
builder.button(text=_(key="admin_ban_management_section"),
|
builder.button(text=_(key="admin_ban_management_section"),
|
||||||
callback_data="admin_section:ban_management")
|
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"),
|
builder.button(text=_(key="back_to_admin_panel_button"),
|
||||||
callback_data="admin_action:main")
|
callback_data="admin_action:main")
|
||||||
builder.adjust(2, 1, 1)
|
builder.adjust(2, 2, 1)
|
||||||
return builder.as_markup()
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
@@ -460,3 +462,21 @@ def get_back_to_admin_panel_keyboard(lang: str,
|
|||||||
builder.button(text=_(key="back_to_admin_panel_button"),
|
builder.button(text=_(key="back_to_admin_panel_button"),
|
||||||
callback_data="admin_action:main")
|
callback_data="admin_action:main")
|
||||||
return builder.as_markup()
|
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(
|
promo_button = InlineKeyboardButton(
|
||||||
text=_(key="menu_apply_promo_button"),
|
text=_(key="menu_apply_promo_button"),
|
||||||
callback_data="main_action:apply_promo")
|
callback_data="main_action:apply_promo")
|
||||||
builder.row(referral_button, promo_button)
|
builder.row(referral_button)
|
||||||
|
builder.row(promo_button)
|
||||||
|
|
||||||
language_button = InlineKeyboardButton(
|
language_button = InlineKeyboardButton(
|
||||||
text=_(key="menu_language_settings_inline"),
|
text=_(key="menu_language_settings_inline"),
|
||||||
@@ -55,14 +56,36 @@ def get_main_menu_inline_keyboard(
|
|||||||
InlineKeyboardButton(text=_(key="menu_support_button"),
|
InlineKeyboardButton(text=_(key="menu_support_button"),
|
||||||
url=settings.SUPPORT_LINK))
|
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(
|
builder.row(
|
||||||
InlineKeyboardButton(text=_(key="menu_terms_button"),
|
InlineKeyboardButton(text=_(key="menu_info_button"),
|
||||||
url=settings.TERMS_OF_SERVICE_URL))
|
callback_data="main_action:info"))
|
||||||
|
|
||||||
return builder.as_markup()
|
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,
|
def get_language_selection_keyboard(i18n_instance,
|
||||||
current_lang: str) -> InlineKeyboardMarkup:
|
current_lang: str) -> InlineKeyboardMarkup:
|
||||||
_ = lambda key, **kwargs: i18n_instance.gettext(current_lang, key, **kwargs
|
_ = lambda key, **kwargs: i18n_instance.gettext(current_lang, key, **kwargs
|
||||||
|
|||||||
+1
-1
@@ -201,7 +201,7 @@ async def on_shutdown_configured(dispatcher: Dispatcher):
|
|||||||
"freekassa_service",
|
"freekassa_service",
|
||||||
"panel_webhook_service",
|
"panel_webhook_service",
|
||||||
"yookassa_service",
|
"yookassa_service",
|
||||||
"nalogo_service",
|
"lknpd_service",
|
||||||
"promo_code_service",
|
"promo_code_service",
|
||||||
"stars_service",
|
"stars_service",
|
||||||
"subscription_service",
|
"subscription_service",
|
||||||
|
|||||||
@@ -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,103 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
from datetime import datetime
|
|
||||||
from decimal import Decimal
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from nalogo import Client
|
|
||||||
from nalogo.dto.income import (
|
|
||||||
AtomDateTime,
|
|
||||||
IncomeClient,
|
|
||||||
IncomeRequest,
|
|
||||||
IncomeServiceItem,
|
|
||||||
PaymentType,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class NalogoService:
|
|
||||||
def __init__(self, inn: Optional[str], password: Optional[str]) -> None:
|
|
||||||
self.inn = inn.strip() if inn else None
|
|
||||||
self.password = password
|
|
||||||
self.configured = bool(self.inn and self.password)
|
|
||||||
self._client = Client() if self.configured else None
|
|
||||||
self._auth_lock = asyncio.Lock()
|
|
||||||
|
|
||||||
if not self.configured:
|
|
||||||
logging.warning("Nalogo credentials are missing. Receipt sending disabled.")
|
|
||||||
|
|
||||||
async def _ensure_authenticated(self) -> bool:
|
|
||||||
if not self._client:
|
|
||||||
return False
|
|
||||||
|
|
||||||
async with self._auth_lock:
|
|
||||||
token_data = await self._client.auth_provider.get_token()
|
|
||||||
if token_data:
|
|
||||||
return True
|
|
||||||
|
|
||||||
try:
|
|
||||||
token_json = await self._client.create_new_access_token(
|
|
||||||
self.inn,
|
|
||||||
self.password,
|
|
||||||
)
|
|
||||||
await self._client.authenticate(token_json)
|
|
||||||
logging.info("Nalogo authentication succeeded.")
|
|
||||||
return True
|
|
||||||
except Exception:
|
|
||||||
logging.exception("Nalogo authentication failed.")
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def create_income_receipt(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
item_name: str,
|
|
||||||
amount: float,
|
|
||||||
quantity: float = 1.0,
|
|
||||||
client: Optional[IncomeClient] = None,
|
|
||||||
operation_time: Optional[datetime] = None,
|
|
||||||
) -> Optional[str]:
|
|
||||||
if not self.configured:
|
|
||||||
return None
|
|
||||||
if not await self._ensure_authenticated():
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
service_item = IncomeServiceItem(
|
|
||||||
name=item_name,
|
|
||||||
amount=Decimal(str(amount)),
|
|
||||||
quantity=Decimal(str(quantity)),
|
|
||||||
)
|
|
||||||
total_amount = service_item.get_total_amount()
|
|
||||||
request = IncomeRequest(
|
|
||||||
operation_time=(
|
|
||||||
AtomDateTime.from_datetime(operation_time)
|
|
||||||
if operation_time
|
|
||||||
else AtomDateTime.now()
|
|
||||||
),
|
|
||||||
request_time=AtomDateTime.now(),
|
|
||||||
services=[service_item],
|
|
||||||
total_amount=str(total_amount),
|
|
||||||
client=client or IncomeClient(),
|
|
||||||
payment_type=PaymentType.ACCOUNT,
|
|
||||||
ignore_max_total_income_restriction=False,
|
|
||||||
)
|
|
||||||
response = await self._client.http_client.post(
|
|
||||||
"/income",
|
|
||||||
json_data=request.model_dump(),
|
|
||||||
)
|
|
||||||
payload = response.json()
|
|
||||||
receipt_uuid = (
|
|
||||||
payload.get("approvedReceiptUuid")
|
|
||||||
or payload.get("receiptUuid")
|
|
||||||
or payload.get("receipt_uuid")
|
|
||||||
)
|
|
||||||
if receipt_uuid:
|
|
||||||
logging.info("Nalogo receipt created: %s", receipt_uuid)
|
|
||||||
else:
|
|
||||||
logging.info("Nalogo receipt created without a UUID in response.")
|
|
||||||
return receipt_uuid
|
|
||||||
except Exception:
|
|
||||||
logging.exception("Failed to create Nalogo receipt.")
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def close(self) -> None:
|
|
||||||
return None
|
|
||||||
@@ -96,6 +96,12 @@ class PlategaService:
|
|||||||
|
|
||||||
# Remove optional keys with falsy values to avoid validation errors
|
# 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, "")}
|
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:
|
try:
|
||||||
async with session.post(url, json=clean_body, headers=self._auth_headers) as response:
|
async with session.post(url, json=clean_body, headers=self._auth_headers) as response:
|
||||||
|
|||||||
@@ -55,6 +55,33 @@ class SubscriptionService:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return False
|
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):
|
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:
|
if not self.bot or not self.i18n or not self.settings.ADMIN_IDS:
|
||||||
return
|
return
|
||||||
@@ -444,9 +471,7 @@ class SubscriptionService:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
panel_user_data = await self.panel_service.get_user_by_uuid(panel_user_uuid) or {}
|
panel_user_data = await self.panel_service.get_user_by_uuid(panel_user_uuid) or {}
|
||||||
traffic_info = panel_user_data.get("userTraffic") or {}
|
current_used, current_limit, _ = self._extract_panel_traffic_details(panel_user_data)
|
||||||
current_limit = panel_user_data.get("trafficLimitBytes")
|
|
||||||
current_used = traffic_info.get("usedTrafficBytes")
|
|
||||||
|
|
||||||
active_sub = await subscription_dal.get_active_subscription_by_user_id(
|
active_sub = await subscription_dal.get_active_subscription_by_user_id(
|
||||||
session, user_id, panel_user_uuid
|
session, user_id, panel_user_uuid
|
||||||
@@ -842,13 +867,22 @@ class SubscriptionService:
|
|||||||
await user_dal.update_user(session, user_id, {"panel_user_uuid": None})
|
await user_dal.update_user(session, user_id, {"panel_user_uuid": None})
|
||||||
return 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:
|
if local_active_sub:
|
||||||
update_payload_local = {}
|
update_payload_local = {}
|
||||||
panel_status = panel_user_data.get("status", "UNKNOWN").upper()
|
panel_status = panel_user_data.get("status", "UNKNOWN").upper()
|
||||||
panel_expire_at_str = panel_user_data.get("expireAt")
|
panel_expire_at_str = panel_user_data.get("expireAt")
|
||||||
traffic_stats = panel_user_data.get("userTraffic") or {}
|
panel_traffic_used, panel_traffic_limit, _ = self._extract_panel_traffic_details(panel_user_data)
|
||||||
panel_traffic_used = traffic_stats.get("usedTrafficBytes")
|
|
||||||
panel_traffic_limit = panel_user_data.get("trafficLimitBytes")
|
|
||||||
panel_sub_uuid_from_panel = panel_user_data.get(
|
panel_sub_uuid_from_panel = panel_user_data.get(
|
||||||
"subscriptionUuid"
|
"subscriptionUuid"
|
||||||
) or panel_user_data.get("shortUuid")
|
) or panel_user_data.get("shortUuid")
|
||||||
@@ -901,6 +935,7 @@ class SubscriptionService:
|
|||||||
if panel_user_data.get("expireAt")
|
if panel_user_data.get("expireAt")
|
||||||
else None
|
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")
|
config_link_raw = panel_user_data.get("subscriptionUrl")
|
||||||
display_link, connect_button_url = await prepare_config_links(self.settings, config_link_raw)
|
display_link, connect_button_url = await prepare_config_links(self.settings, config_link_raw)
|
||||||
hwid_limit = panel_user_data.get("hwidDeviceLimit")
|
hwid_limit = panel_user_data.get("hwidDeviceLimit")
|
||||||
@@ -913,8 +948,9 @@ class SubscriptionService:
|
|||||||
"status_from_panel": panel_user_data.get("status", "UNKNOWN").upper(),
|
"status_from_panel": panel_user_data.get("status", "UNKNOWN").upper(),
|
||||||
"config_link": display_link,
|
"config_link": display_link,
|
||||||
"connect_button_url": connect_button_url,
|
"connect_button_url": connect_button_url,
|
||||||
"traffic_limit_bytes": panel_user_data.get("trafficLimitBytes"),
|
"traffic_limit_bytes": panel_traffic_limit,
|
||||||
"traffic_used_bytes": (panel_user_data.get("userTraffic") or {}).get("usedTrafficBytes"),
|
"traffic_used_bytes": panel_traffic_used,
|
||||||
|
"traffic_limit_strategy": panel_traffic_strategy,
|
||||||
"user_bot_username": db_user.username,
|
"user_bot_username": db_user.username,
|
||||||
"is_panel_data": True,
|
"is_panel_data": True,
|
||||||
"max_devices": hwid_limit,
|
"max_devices": hwid_limit,
|
||||||
|
|||||||
+34
-9
@@ -23,6 +23,8 @@ class Settings(BaseSettings):
|
|||||||
SUPPORT_LINK: Optional[str] = Field(default=None)
|
SUPPORT_LINK: Optional[str] = Field(default=None)
|
||||||
SERVER_STATUS_URL: Optional[str] = Field(default=None)
|
SERVER_STATUS_URL: Optional[str] = Field(default=None)
|
||||||
TERMS_OF_SERVICE_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(
|
REQUIRED_CHANNEL_ID: Optional[int] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description="Telegram channel ID the user must join to access the bot")
|
description="Telegram channel ID the user must join to access the bot")
|
||||||
@@ -46,13 +48,30 @@ class Settings(BaseSettings):
|
|||||||
description="When true, new YooKassa payments in autopay mode force card binding without a user checkbox."
|
description="When true, new YooKassa payments in autopay mode force card binding without a user checkbox."
|
||||||
)
|
)
|
||||||
|
|
||||||
NALOGO_INN: Optional[str] = Field(
|
LKNPD_INN: Optional[str] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description="INN for nalog.ru (self-employed) authentication"
|
alias="NALOGO_INN",
|
||||||
|
description="INN for lknpd.nalog.ru (self-employed) authentication"
|
||||||
)
|
)
|
||||||
NALOGO_PASSWORD: Optional[str] = Field(
|
LKNPD_PASSWORD: Optional[str] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description="Password for nalog.ru (self-employed) authentication"
|
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
|
WEBHOOK_BASE_URL: Optional[str] = None
|
||||||
@@ -151,6 +170,10 @@ class Settings(BaseSettings):
|
|||||||
default=True,
|
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(
|
LEGACY_REFS: bool = Field(
|
||||||
default=True,
|
default=True,
|
||||||
description="Allow legacy referral links like ref_<telegram_id> to continue working. Defaults to True when unset."
|
description="Allow legacy referral links like ref_<telegram_id> to continue working. Defaults to True when unset."
|
||||||
@@ -527,6 +550,8 @@ class Settings(BaseSettings):
|
|||||||
'PLATEGA_FAILED_URL',
|
'PLATEGA_FAILED_URL',
|
||||||
'SEVERPAY_RETURN_URL',
|
'SEVERPAY_RETURN_URL',
|
||||||
'CRYPT4_REDIRECT_URL',
|
'CRYPT4_REDIRECT_URL',
|
||||||
|
'PRIVACY_POLICY_URL',
|
||||||
|
'USER_AGREEMENT_URL',
|
||||||
mode='before',
|
mode='before',
|
||||||
)
|
)
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -579,14 +604,14 @@ def get_settings() -> Settings:
|
|||||||
"CRITICAL: YooKassa credentials (SHOP_ID or SECRET_KEY) are not set. Payments will not work."
|
"CRITICAL: YooKassa credentials (SHOP_ID or SECRET_KEY) are not set. Payments will not work."
|
||||||
)
|
)
|
||||||
if (
|
if (
|
||||||
_settings_instance.NALOGO_INN
|
_settings_instance.LKNPD_INN
|
||||||
or _settings_instance.NALOGO_PASSWORD
|
or _settings_instance.LKNPD_PASSWORD
|
||||||
) and not (
|
) and not (
|
||||||
_settings_instance.NALOGO_INN
|
_settings_instance.LKNPD_INN
|
||||||
and _settings_instance.NALOGO_PASSWORD
|
and _settings_instance.LKNPD_PASSWORD
|
||||||
):
|
):
|
||||||
logging.warning(
|
logging.warning(
|
||||||
"WARNING: Nalogo credentials are incomplete. Receipt sending will be disabled."
|
"WARNING: LKNPD credentials are incomplete. Receipt sending will be disabled."
|
||||||
)
|
)
|
||||||
if _settings_instance.FREEKASSA_ENABLED:
|
if _settings_instance.FREEKASSA_ENABLED:
|
||||||
if (
|
if (
|
||||||
|
|||||||
+121
-1
@@ -5,7 +5,8 @@ from typing import Optional, List, Dict, Any, Tuple
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.future import select
|
from sqlalchemy.future import select
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
from sqlalchemy import update, delete, func, and_, or_
|
from sqlalchemy import update, delete, func, and_, or_, desc
|
||||||
|
from sqlalchemy.orm import aliased
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
|
|
||||||
@@ -360,3 +361,122 @@ async def delete_user_and_relations(session: AsyncSession, user_id: int) -> bool
|
|||||||
await session.delete(user)
|
await session.delete(user)
|
||||||
await session.flush()
|
await session.flush()
|
||||||
return True
|
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]
|
||||||
|
|||||||
@@ -112,6 +112,19 @@ def _migration_0003_normalize_referral_codes(connection: Connection) -> None:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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] = [
|
MIGRATIONS: List[Migration] = [
|
||||||
Migration(
|
Migration(
|
||||||
id="0001_add_channel_subscription_fields",
|
id="0001_add_channel_subscription_fields",
|
||||||
@@ -128,6 +141,11 @@ MIGRATIONS: List[Migration] = [
|
|||||||
description="Normalize referral codes to uppercase for consistent lookups",
|
description="Normalize referral codes to uppercase for consistent lookups",
|
||||||
upgrade=_migration_0003_normalize_referral_codes,
|
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,
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ class User(Base):
|
|||||||
referred_by_id = Column(BigInteger,
|
referred_by_id = Column(BigInteger,
|
||||||
ForeignKey("users.user_id"),
|
ForeignKey("users.user_id"),
|
||||||
nullable=True)
|
nullable=True)
|
||||||
|
lifetime_used_traffic_bytes = Column(BigInteger, nullable=True)
|
||||||
channel_subscription_verified = Column(Boolean, nullable=True)
|
channel_subscription_verified = Column(Boolean, nullable=True)
|
||||||
channel_subscription_checked_at = Column(DateTime(timezone=True),
|
channel_subscription_checked_at = Column(DateTime(timezone=True),
|
||||||
nullable=True)
|
nullable=True)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
remnawave-tg-shop:
|
remnawave-tg-shop:
|
||||||
image: kavore/remnawave-tg-shop:latest
|
image: ghcr.io/3252a8/remnawave-tg-shop:${IMAGE_TAG:-latest}
|
||||||
container_name: remnawave-tg-shop
|
container_name: remnawave-tg-shop
|
||||||
hostname: remnawave-tg-shop
|
hostname: remnawave-tg-shop
|
||||||
env_file:
|
env_file:
|
||||||
|
|||||||
+4
-2
@@ -1,7 +1,7 @@
|
|||||||
services:
|
services:
|
||||||
remnawave-tg-shop:
|
remnawave-tg-shop:
|
||||||
image: kavore/remnawave-tg-shop:latest
|
# image: ghcr.io/3252a8/remnawave-tg-shop:${IMAGE_TAG:-latest}
|
||||||
# build: .
|
build: .
|
||||||
container_name: remnawave-tg-shop
|
container_name: remnawave-tg-shop
|
||||||
hostname: remnawave-tg-shop
|
hostname: remnawave-tg-shop
|
||||||
env_file:
|
env_file:
|
||||||
@@ -14,6 +14,8 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
remnawave-tg-shop-db:
|
remnawave-tg-shop-db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
ports:
|
||||||
|
- 127.0.0.1:8080:8080
|
||||||
|
|
||||||
remnawave-tg-shop-db:
|
remnawave-tg-shop-db:
|
||||||
image: postgres:17
|
image: postgres:17
|
||||||
|
|||||||
+33
-10
@@ -5,18 +5,22 @@
|
|||||||
"channel_subscription_verify_button": "Check subscription",
|
"channel_subscription_verify_button": "Check subscription",
|
||||||
"channel_subscription_check_failed": "Couldn't verify the subscription. Please try again later or contact support.",
|
"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.",
|
"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_activate_trial_button": "🆓 Free Trial",
|
||||||
"menu_subscribe_inline": "🚀 Purchase",
|
"menu_subscribe_inline": "🚀 Purchase",
|
||||||
"menu_my_subscription_inline": "🔐 My Subscription",
|
"menu_my_subscription_inline": "🔐 My Subscription",
|
||||||
"no_subscription_options_available": "Subscription issuance is not configured by the bot administrator",
|
"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",
|
"referral_no_bonuses_configured": "Sorry, the referral program is currently disabled",
|
||||||
"menu_apply_promo_button": "🎟 Promo Code",
|
"menu_apply_promo_button": "🎟 Promo Code",
|
||||||
"menu_language_settings_inline": "🌐 Language",
|
"menu_language_settings_inline": "🌐 Language",
|
||||||
"menu_server_status_button": "📊 Status",
|
"menu_server_status_button": "📊 Status",
|
||||||
"menu_support_button": "💬 Support",
|
"menu_support_button": "💬 Support",
|
||||||
"menu_terms_button": "📄 Terms of Service",
|
"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",
|
"back_to_main_menu_button": "⬅️ Back",
|
||||||
"choose_language": "Choose language:",
|
"choose_language": "Choose language:",
|
||||||
"language_set_alert": "Language changed!",
|
"language_set_alert": "Language changed!",
|
||||||
@@ -72,6 +76,11 @@
|
|||||||
"payment_failed": "❌ Payment failed or was cancelled. Please try again or contact support.",
|
"payment_failed": "❌ Payment failed or was cancelled. Please try again or contact support.",
|
||||||
"config_link_not_available": "not available, contact support",
|
"config_link_not_available": "not available, contact support",
|
||||||
"traffic_unlimited": "Unlimited",
|
"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_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_not_found": "Promo code <code>{code}</code> not found, expired, or already used the maximum number of times.",
|
||||||
"promo_code_already_used_by_user": "You have already used promo code <code>{code}</code>.",
|
"promo_code_already_used_by_user": "You have already used promo code <code>{code}</code>.",
|
||||||
@@ -83,13 +92,14 @@
|
|||||||
"trial_confirm_activate_button": "✅ Activate!",
|
"trial_confirm_activate_button": "✅ Activate!",
|
||||||
"trial_activated_alert": "Trial activated!",
|
"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 👇",
|
"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",
|
"yes_button": "Yes",
|
||||||
"no_button": "No",
|
"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_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_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_not_available_for_traffic": "Referral bonuses are not available for traffic packages.",
|
||||||
"referral_share_message_button": "📩 Message for friend",
|
"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",
|
"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_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}.",
|
"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}.",
|
||||||
@@ -123,6 +133,18 @@
|
|||||||
"admin_stats_payment_item": "{status_emoji} {amount} {currency} from {user_info} ({p_status}) [{p_date}]",
|
"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_stats_no_payments_found": "No payments found yet.",
|
||||||
"admin_view_payments_button": "💰 Payments",
|
"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_payments_header": "💰 <b>All Payments</b>",
|
||||||
"admin_no_payments_found": "No payments found.",
|
"admin_no_payments_found": "No payments found.",
|
||||||
"admin_export_payments_csv": "📊 Export CSV",
|
"admin_export_payments_csv": "📊 Export CSV",
|
||||||
@@ -240,11 +262,11 @@
|
|||||||
"admin_broadcast_invalid_html": "❌ Invalid HTML in message. Please send valid HTML (Telegram-supported tags) or remove tags.",
|
"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_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.",
|
"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_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 VPN subscription expires in 2 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 VPN subscription expires in 1 day — {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 VPN subscription expired on {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 VPN subscription expired yesterday ({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_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_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.",
|
"autorenew_confirm_disable": "🛑 Disable auto-renew? No further automatic charges will occur.",
|
||||||
@@ -428,9 +450,10 @@
|
|||||||
"admin_payments_pagination_info": "📊 Showing {shown} of {total} payments (page {current_page}/{total_pages})",
|
"admin_payments_pagination_info": "📊 Showing {shown} of {total} payments (page {current_page}/{total_pages})",
|
||||||
"admin_payment_traffic_label": "🗂 Traffic: <b>{traffic_gb} GB</b>",
|
"admin_payment_traffic_label": "🗂 Traffic: <b>{traffic_gb} GB</b>",
|
||||||
"admin_payment_months_label": "📅 Period: <b>{months} mo.</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:\nLimit: <b>{traffic_limit}</b>\nUsed: <b>{traffic_used}</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:\nLimit: <b>{traffic_limit}</b>\nUsed: <b>{traffic_used}</b>\nLeft: <b>{traffic_left}</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_no_expiry": "no limit",
|
||||||
|
"traffic_period_unknown": "unknown",
|
||||||
"autorenew_enable_button": "🔄 Enable auto-renew",
|
"autorenew_enable_button": "🔄 Enable auto-renew",
|
||||||
"autorenew_disable_button": "🛑 Disable auto-renew",
|
"autorenew_disable_button": "🛑 Disable auto-renew",
|
||||||
"subscription_autorenew_updated": "Auto-renew settings updated.",
|
"subscription_autorenew_updated": "Auto-renew settings updated.",
|
||||||
|
|||||||
+33
-10
@@ -5,18 +5,22 @@
|
|||||||
"channel_subscription_verify_button": "Проверить подписку",
|
"channel_subscription_verify_button": "Проверить подписку",
|
||||||
"channel_subscription_check_failed": "Не удалось проверить подписку. Попробуйте позже или обратитесь в поддержку.",
|
"channel_subscription_check_failed": "Не удалось проверить подписку. Попробуйте позже или обратитесь в поддержку.",
|
||||||
"channel_subscription_verified_success": "✅ Подписка подтверждена! Можно продолжать.",
|
"channel_subscription_verified_success": "✅ Подписка подтверждена! Можно продолжать.",
|
||||||
"main_menu_greeting": "Привет, {user_name}! 👋\nЧто бы вы хотели сделать?",
|
"main_menu_greeting": "Привет, {user_name}! 👋\nЧто бы вы хотели сделать?\n\nДля настройки подключения на устройстве перейдите в раздел \"Моя подписка\" и нажмите кнопку \"Подключиться\". При необходимости добавьте дни доступа в разделе \"Купить\".",
|
||||||
"menu_activate_trial_button": "🆓 Пробный период",
|
"menu_activate_trial_button": "🆓 Пробный период",
|
||||||
"menu_subscribe_inline": "🚀 Купить",
|
"menu_subscribe_inline": "🚀 Купить",
|
||||||
"menu_my_subscription_inline": "🔐 Моя подписка",
|
"menu_my_subscription_inline": "🔐 Моя подписка",
|
||||||
"no_subscription_options_available": "Выдача подписки не настроена администратором бота",
|
"no_subscription_options_available": "Выдача подписки не настроена администратором бота",
|
||||||
"menu_referral_inline": "🎁 Рефералы",
|
"menu_referral_inline": "🎁 Пригласить друга",
|
||||||
"referral_no_bonuses_configured": "Извините, реферальная программа в данный момент отключена",
|
"referral_no_bonuses_configured": "Извините, реферальная программа в данный момент отключена",
|
||||||
"menu_apply_promo_button": "🎟 Промокод",
|
"menu_apply_promo_button": "🎟 Промокод",
|
||||||
"menu_language_settings_inline": "🌐 Язык",
|
"menu_language_settings_inline": "🌐 Язык",
|
||||||
"menu_server_status_button": "📊 Статус",
|
"menu_server_status_button": "📊 Статус",
|
||||||
"menu_support_button": "💬 Поддержка",
|
"menu_support_button": "💬 Поддержка",
|
||||||
"menu_terms_button": "📄 Условия сервиса",
|
"menu_terms_button": "📄 Условия сервиса",
|
||||||
|
"menu_info_button": "ℹ️ Информация",
|
||||||
|
"info_links_message": "Выберите документ:",
|
||||||
|
"privacy_policy_button": "🔒 Политика конфиденциальности",
|
||||||
|
"user_agreement_button": "📄 Пользовательское соглашение",
|
||||||
"back_to_main_menu_button": "⬅️ Назад",
|
"back_to_main_menu_button": "⬅️ Назад",
|
||||||
"choose_language": "Выберите язык / Select language:",
|
"choose_language": "Выберите язык / Select language:",
|
||||||
"language_set_alert": "Язык изменен!",
|
"language_set_alert": "Язык изменен!",
|
||||||
@@ -72,6 +76,11 @@
|
|||||||
"payment_failed": "❌ Оплата не удалась или была отменена. Пожалуйста, попробуйте еще раз или свяжитесь с поддержкой.",
|
"payment_failed": "❌ Оплата не удалась или была отменена. Пожалуйста, попробуйте еще раз или свяжитесь с поддержкой.",
|
||||||
"config_link_not_available": "недоступна, обратитесь в поддержку",
|
"config_link_not_available": "недоступна, обратитесь в поддержку",
|
||||||
"traffic_unlimited": "Безлимитный",
|
"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_prompt": "Пожалуйста, введите ваш промокод:",
|
||||||
"promo_code_not_found": "Промокод <code>{code}</code> не найден, истек или уже использован максимальное количество раз.",
|
"promo_code_not_found": "Промокод <code>{code}</code> не найден, истек или уже использован максимальное количество раз.",
|
||||||
"promo_code_already_used_by_user": "Вы уже активировали промокод <code>{code}</code>.",
|
"promo_code_already_used_by_user": "Вы уже активировали промокод <code>{code}</code>.",
|
||||||
@@ -83,13 +92,14 @@
|
|||||||
"trial_confirm_activate_button": "✅ Активировать!",
|
"trial_confirm_activate_button": "✅ Активировать!",
|
||||||
"trial_activated_alert": "Пробный период активирован!",
|
"trial_activated_alert": "Пробный период активирован!",
|
||||||
"trial_activated_details_message": "✅ Пробный доступ активирован!\nВаш триал на {days} дн. действует до {end_date}.\n\nКлюч подключения:\n<code>{config_link}</code>\n\nЧтобы подключиться, перейдите по ссылке и следуйте инструкции 👇",
|
"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": "Да",
|
"yes_button": "Да",
|
||||||
"no_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_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_bonus_per_period": "\n\n🎁 За {months}-мес. подписку друга:\n ➢ Вы: <b>{inviter_bonus_days} дн.</b>\n ➢ Друг: <b>{referee_bonus_days} дн.</b>",
|
||||||
"referral_not_available_for_traffic": "Для пакетов трафика реферальные бонусы не начисляются.",
|
"referral_not_available_for_traffic": "Для пакетов трафика реферальные бонусы не начисляются.",
|
||||||
"referral_share_message_button": "📩 Сообщение для друга",
|
"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": "друг",
|
"friend_placeholder": "друг",
|
||||||
"referral_bonus_inviter_notification_extended": "🎉 Поздравляем! Ваш друг {referee_name} оплатил подписку. Вам начислено {days} бонусных дней! Ваша подписка теперь активна до {new_end_date}.",
|
"referral_bonus_inviter_notification_extended": "🎉 Поздравляем! Ваш друг {referee_name} оплатил подписку. Вам начислено {days} бонусных дней! Ваша подписка теперь активна до {new_end_date}.",
|
||||||
"referral_bonus_inviter_notification_new_sub": "🎉 Поздравляем! Ваш друг {referee_name} оплатил подписку. Вам начислена бонусная подписка на {days} дней! Она активна до {new_end_date}.",
|
"referral_bonus_inviter_notification_new_sub": "🎉 Поздравляем! Ваш друг {referee_name} оплатил подписку. Вам начислена бонусная подписка на {days} дней! Она активна до {new_end_date}.",
|
||||||
@@ -123,6 +133,18 @@
|
|||||||
"admin_stats_payment_item": "{status_emoji} {amount} {currency} от {user_info} ({p_status}) [{p_date}]",
|
"admin_stats_payment_item": "{status_emoji} {amount} {currency} от {user_info} ({p_status}) [{p_date}]",
|
||||||
"admin_stats_no_payments_found": "Платежей пока нет.",
|
"admin_stats_no_payments_found": "Платежей пока нет.",
|
||||||
"admin_view_payments_button": "💰 Платежи",
|
"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_payments_header": "💰 <b>Все платежи</b>",
|
||||||
"admin_no_payments_found": "Платежи не найдены.",
|
"admin_no_payments_found": "Платежи не найдены.",
|
||||||
"admin_export_payments_csv": "📊 Экспорт CSV",
|
"admin_export_payments_csv": "📊 Экспорт CSV",
|
||||||
@@ -169,11 +191,11 @@
|
|||||||
"admin_promo_invalid_max_activations": "Максимальное количество активаций должно быть положительным числом.",
|
"admin_promo_invalid_max_activations": "Максимальное количество активаций должно быть положительным числом.",
|
||||||
"admin_promo_invalid_validity_days": "Срок действия промокода (в днях) должен быть положительным числом.",
|
"admin_promo_invalid_validity_days": "Срок действия промокода (в днях) должен быть положительным числом.",
|
||||||
"admin_promo_created_success": "✅ Промокод <code>{code}</code> успешно создан!\nБонус: {bonus_days} дней\nМакс. активаций: {max_activations}\nДействителен: {valid_until_str}",
|
"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_72h_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на сервис истекает через 3 дня — {end_date}.\n\nПродлите её по кнопке ниже.",
|
||||||
"subscription_48h_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на VPN истекает через 2 дня — {end_date}.\n\nПродлите её по кнопке ниже.",
|
"subscription_48h_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на сервис истекает через 2 дня — {end_date}.\n\nПродлите её по кнопке ниже.",
|
||||||
"subscription_24h_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на VPN истекает через 1 день — {end_date}.\n\nПродлите её по кнопке ниже.",
|
"subscription_24h_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на сервис истекает через 1 день — {end_date}.\n\nПродлите её по кнопке ниже.",
|
||||||
"subscription_expired_notification": "👋 Привет, {user_name}!\n\n⛔ Срок вашей подписки на VPN истек ({end_date}).\n\nПродлите её по кнопке ниже.",
|
"subscription_expired_notification": "👋 Привет, {user_name}!\n\n⛔ Срок вашей подписки на сервис истек ({end_date}).\n\nПродлите её по кнопке ниже.",
|
||||||
"subscription_expired_yesterday_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на VPN истекла сутки назад ({end_date}).\n\nПродлите её по кнопке ниже.",
|
"subscription_expired_yesterday_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на сервис истекла сутки назад ({end_date}).\n\nПродлите её по кнопке ниже.",
|
||||||
"autorenew_48h_charge_tomorrow_notice": "🔔 Напоминание\n\nЗавтра будет автоматическое списание за продление подписки. Если вы не хотите автопродление — отключите его кнопкой ниже.",
|
"autorenew_48h_charge_tomorrow_notice": "🔔 Напоминание\n\nЗавтра будет автоматическое списание за продление подписки. Если вы не хотите автопродление — отключите его кнопкой ниже.",
|
||||||
"autorenew_confirm_enable": "🔄 Включить автопродление? Перед окончанием подписки будет выполняться автосписание.",
|
"autorenew_confirm_enable": "🔄 Включить автопродление? Перед окончанием подписки будет выполняться автосписание.",
|
||||||
"autorenew_confirm_disable": "🛑 Отключить автопродление? Автосписаний больше не будет.",
|
"autorenew_confirm_disable": "🛑 Отключить автопродление? Автосписаний больше не будет.",
|
||||||
@@ -428,9 +450,10 @@
|
|||||||
"admin_payments_pagination_info": "📊 Показано {shown} из {total} платежей (стр. {current_page}/{total_pages})",
|
"admin_payments_pagination_info": "📊 Показано {shown} из {total} платежей (стр. {current_page}/{total_pages})",
|
||||||
"admin_payment_traffic_label": "🗂 Трафик: <b>{traffic_gb} ГБ</b>",
|
"admin_payment_traffic_label": "🗂 Трафик: <b>{traffic_gb} ГБ</b>",
|
||||||
"admin_payment_months_label": "📅 Период: <b>{months} мес.</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📊 Трафик:\nЛимит: <b>{traffic_limit}</b>\nИспользовано: <b>{traffic_used}</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📊 Трафик:\nЛимит: <b>{traffic_limit}</b>\nИспользовано: <b>{traffic_used}</b>\nОсталось: <b>{traffic_left}</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_no_expiry": "без ограничения",
|
||||||
|
"traffic_period_unknown": "неизвестно",
|
||||||
"autorenew_enable_button": "🔄 Включить автопродление",
|
"autorenew_enable_button": "🔄 Включить автопродление",
|
||||||
"autorenew_disable_button": "🛑 Отключить автопродление",
|
"autorenew_disable_button": "🛑 Отключить автопродление",
|
||||||
"subscription_autorenew_updated": "Настройки автопродления обновлены.",
|
"subscription_autorenew_updated": "Настройки автопродления обновлены.",
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@ python-dotenv==1.2.1
|
|||||||
aiohttp==3.13.3
|
aiohttp==3.13.3
|
||||||
pydantic==2.12.5
|
pydantic==2.12.5
|
||||||
yookassa==3.9.0
|
yookassa==3.9.0
|
||||||
nalogo==1.0.0
|
httpx>=0.27.0
|
||||||
pydantic_settings==2.12.0
|
pydantic_settings==2.12.0
|
||||||
sqlalchemy[asyncio]==2.0.45
|
sqlalchemy[asyncio]==2.0.45
|
||||||
asyncpg==0.31.0
|
asyncpg==0.31.0
|
||||||
|
|||||||
Reference in New Issue
Block a user