Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
183bef070d | ||
|
|
643bf5fabb | ||
|
|
7d446d5f64 | ||
|
|
73ce689d5e | ||
|
|
5791f2e58c | ||
|
|
69cc2cbe29 | ||
|
|
7df2e127a4 | ||
|
|
d503cf7f7f | ||
|
|
c0d70030c3 | ||
|
|
4e7c36dbf7 | ||
|
|
3b0b88e9a0 | ||
|
|
207e7751cd | ||
|
|
c65b608f0b | ||
|
|
cff8368b7c | ||
|
|
2f367bbd6b | ||
|
|
c3d6324ec6 | ||
|
|
4800a2da80 | ||
|
|
70a0cd44a1 | ||
|
|
545c9c7d07 | ||
|
|
cd60f496fa | ||
|
|
f883065bb9 | ||
|
|
b71bd71d8d | ||
|
|
0524d24b13 | ||
|
|
cdfd94c814 | ||
|
|
6513681125 | ||
|
|
f321fd04cf | ||
|
|
baede17adf | ||
|
|
17bf8720a3 | ||
|
|
280aced20e | ||
|
|
d672032201 | ||
|
|
e826c4309d | ||
|
|
9f525dc7da | ||
|
|
b880032b9b | ||
|
|
8f9484b6ec | ||
|
|
c3622d9c2b | ||
|
|
51ffbbfa1d | ||
|
|
394e8dcc6c | ||
|
|
5f3fc13c2b | ||
|
|
4d43c9cf0f | ||
|
|
c891122064 | ||
|
|
59d07314e2 | ||
|
|
c438672ced | ||
|
|
49c532e4db | ||
|
|
d50324d098 | ||
|
|
52893a0629 | ||
|
|
664cea447c | ||
|
|
5f50cfeaee | ||
|
|
bdade47758 | ||
|
|
a3406451cb | ||
|
|
9938671af9 | ||
|
|
1d57c9be21 | ||
|
|
fde0f1b135 | ||
|
|
67df164498 | ||
|
|
48e666f605 | ||
|
|
3cb15fa254 | ||
|
|
2100949470 |
+21
-6
@@ -20,10 +20,23 @@ TERMS_OF_SERVICE_URL=https://example.com/tos #
|
|||||||
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
|
||||||
|
MY_DEVICES_SECTION_ENABLED=False # Enable the My Devices section in the subscription menu
|
||||||
|
USER_HWID_DEVICE_LIMIT=0 # Default HWID/device limit for panel users (0 = unlimited)
|
||||||
|
|
||||||
|
# Required channel subscription
|
||||||
|
REQUIRED_CHANNEL_ID= # Telegram channel ID (e.g. -1001234567890) the user must join
|
||||||
|
REQUIRED_CHANNEL_LINK=https://t.me/your_channel # Optional: public link/invite button text opens
|
||||||
|
|
||||||
# Webhook Base URL (used for Telegram and payment providers)
|
# Webhook Base URL (used for Telegram and payment providers)
|
||||||
WEBHOOK_BASE_URL=https://webhooks.yourdomain.tld
|
WEBHOOK_BASE_URL=https://webhooks.yourdomain.tld
|
||||||
|
|
||||||
|
# Payment Method Toggles
|
||||||
|
YOOKASSA_ENABLED=True # Turn on YOOKASSA
|
||||||
|
FREEKASSA_ENABLED=True # Turn on FreeKassa
|
||||||
|
STARS_ENABLED=True # Turn on STARS
|
||||||
|
TRIBUTE_ENABLED=True # Turn on TRIBUTE
|
||||||
|
CRYPTOPAY_ENABLED=True # Turn on CRYPTOPAY
|
||||||
|
|
||||||
# YooKassa Payment Gateway Configuration
|
# YooKassa Payment Gateway Configuration
|
||||||
YOOKASSA_SHOP_ID=your_shop_id # Your store ID in YooKassa
|
YOOKASSA_SHOP_ID=your_shop_id # Your store ID in YooKassa
|
||||||
YOOKASSA_SECRET_KEY=your_secret_key # Your secret key for YooKassa
|
YOOKASSA_SECRET_KEY=your_secret_key # Your secret key for YooKassa
|
||||||
@@ -31,6 +44,14 @@ YOOKASSA_RETURN_URL=https://t.me/your_bot #
|
|||||||
YOOKASSA_DEFAULT_RECEIPT_EMAIL=your_email@example.com # Default email for sending receipts
|
YOOKASSA_DEFAULT_RECEIPT_EMAIL=your_email@example.com # Default email for sending receipts
|
||||||
YOOKASSA_VAT_CODE=1 # VAT code
|
YOOKASSA_VAT_CODE=1 # VAT code
|
||||||
YOOKASSA_AUTOPAYMENTS_ENABLED=False # Auto-renew toggle
|
YOOKASSA_AUTOPAYMENTS_ENABLED=False # Auto-renew toggle
|
||||||
|
YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING=True # Force automatic card binding when autopay is enabled (set to False to show the save-card checkbox)
|
||||||
|
|
||||||
|
# FreeKassa Payment Gateway Configuration
|
||||||
|
FREEKASSA_MERCHANT_ID=your_shop_id # Your shop ID in FreeKassa
|
||||||
|
FREEKASSA_API_KEY=your_api_key # API key for REST requests
|
||||||
|
FREEKASSA_SECOND_SECRET=your_second_secret # Secret word #2 (used to verify notifications)
|
||||||
|
FREEKASSA_PAYMENT_IP= # Public IP address reported to FreeKassa
|
||||||
|
FREEKASSA_PAYMENT_METHOD_ID=44 # Payment method ID, you can get it from https://merchant.freekassa.net/settings/currencies
|
||||||
|
|
||||||
# CryptoBot Payment Gateway Configuration
|
# CryptoBot Payment Gateway Configuration
|
||||||
CRYPTOPAY_TOKEN= # API token for CryptoPay
|
CRYPTOPAY_TOKEN= # API token for CryptoPay
|
||||||
@@ -43,12 +64,6 @@ TRIBUTE_API_KEY= #
|
|||||||
TRIBUTE_SKIP_NOTIFICATIONS=True # Skip renewal notifications for Tribute payments
|
TRIBUTE_SKIP_NOTIFICATIONS=True # Skip renewal notifications for Tribute payments
|
||||||
TRIBUTE_SKIP_CANCELLATION_NOTIFICATIONS=False # Skip cancellation notifications for Tribute payments
|
TRIBUTE_SKIP_CANCELLATION_NOTIFICATIONS=False # Skip cancellation notifications for Tribute payments
|
||||||
|
|
||||||
# Payment Method Toggles
|
|
||||||
YOOKASSA_ENABLED=True # Turn on YOOKASSA
|
|
||||||
STARS_ENABLED=True # Turn on STARS
|
|
||||||
TRIBUTE_ENABLED=True # Turn on TRIBUTE
|
|
||||||
CRYPTOPAY_ENABLED=True # Turn on CRYPTOPAY
|
|
||||||
|
|
||||||
# Subscription Options. Specify cost parameters or payment links here.
|
# Subscription Options. Specify cost parameters or payment links here.
|
||||||
1_MONTH_ENABLED=True
|
1_MONTH_ENABLED=True
|
||||||
RUB_PRICE_1_MONTH=150
|
RUB_PRICE_1_MONTH=150
|
||||||
|
|||||||
@@ -9,8 +9,9 @@ on:
|
|||||||
- dev
|
- dev
|
||||||
|
|
||||||
env:
|
env:
|
||||||
REGISTRY: ghcr.io
|
GHCR_REGISTRY: ghcr.io
|
||||||
IMAGE_NAME: ${{ github.repository }}
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
|
DOCKERHUB_IMAGE: docker.io/${{ secrets.DOCKERHUB_USERNAME }}/${{ github.event.repository.name }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
@@ -26,18 +27,26 @@ jobs:
|
|||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Log in to Container Registry
|
- name: Log in to GitHub Container Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.GHCR_REGISTRY }}
|
||||||
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: 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: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
images: |
|
||||||
|
${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
${{ env.DOCKERHUB_IMAGE }}
|
||||||
tags: |
|
tags: |
|
||||||
type=ref,event=branch
|
type=ref,event=branch
|
||||||
type=ref,event=pr
|
type=ref,event=pr
|
||||||
@@ -56,4 +65,4 @@ jobs:
|
|||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
- name: Image digest
|
- name: Image digest
|
||||||
run: echo ${{ steps.meta.outputs.digest }}
|
run: echo ${{ steps.meta.outputs.digest }}
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ on:
|
|||||||
paths-ignore:
|
paths-ignore:
|
||||||
- 'README.md'
|
- 'README.md'
|
||||||
|
|
||||||
|
env:
|
||||||
|
GHCR_IMAGE: ghcr.io/${{ github.repository }}
|
||||||
|
DOCKERHUB_IMAGE: docker.io/${{ secrets.DOCKERHUB_USERNAME }}/${{ github.event.repository.name }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-push:
|
build-and-push:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -27,6 +31,12 @@ 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
|
||||||
|
|
||||||
@@ -40,5 +50,7 @@ jobs:
|
|||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
ghcr.io/${{ github.repository }}:latest
|
${{ env.GHCR_IMAGE }}:latest
|
||||||
ghcr.io/${{ github.repository }}:${{ github.ref_name }}
|
${{ env.GHCR_IMAGE }}:${{ github.ref_name }}
|
||||||
|
${{ env.DOCKERHUB_IMAGE }}:latest
|
||||||
|
${{ env.DOCKERHUB_IMAGE }}:${{ github.ref_name }}
|
||||||
|
|||||||
@@ -7,10 +7,11 @@
|
|||||||
### Для пользователей:
|
### Для пользователей:
|
||||||
- **Регистрация и выбор языка:** Поддержка русского и английского языков.
|
- **Регистрация и выбор языка:** Поддержка русского и английского языков.
|
||||||
- **Просмотр подписки:** Пользователи могут видеть статус своей подписки, дату окончания и ссылку на конфигурацию.
|
- **Просмотр подписки:** Пользователи могут видеть статус своей подписки, дату окончания и ссылку на конфигурацию.
|
||||||
|
- **Мои устройства:** Опциональный раздел для просмотра и отключения подключенных устройств (активируется через переменную `MY_DEVICES_SECTION_ENABLED`).
|
||||||
- **Пробная подписка:** Система пробных подписок для новых пользователей (активируется вручную по кнопке).
|
- **Пробная подписка:** Система пробных подписок для новых пользователей (активируется вручную по кнопке).
|
||||||
- **Промокоды:** Возможность применять промокоды для получения скидок или бонусных дней.
|
- **Промокоды:** Возможность применять промокоды для получения скидок или бонусных дней.
|
||||||
- **Реферальная программа:** Пользователи могут приглашать друзей и получать за это бонусные дни подписки.
|
- **Реферальная программа:** Пользователи могут приглашать друзей и получать за это бонусные дни подписки.
|
||||||
- **Оплата:** Поддержка оплаты через YooKassa, CryptoPay, Telegram Stars и Tribute.
|
- **Оплата:** Поддержка оплаты через YooKassa, FreeKassa (REST API), CryptoPay, Telegram Stars и Tribute.
|
||||||
|
|
||||||
### Для администраторов:
|
### Для администраторов:
|
||||||
- **Защищенная админ-панель:** Доступ только для администраторов, указанных в `ADMIN_IDS`.
|
- **Защищенная админ-панель:** Доступ только для администраторов, указанных в `ADMIN_IDS`.
|
||||||
@@ -27,7 +28,7 @@
|
|||||||
- **Aiogram 3.x:** Асинхронный фреймворк для Telegram ботов.
|
- **Aiogram 3.x:** Асинхронный фреймворк для Telegram ботов.
|
||||||
- **aiohttp:** Для запуска веб-сервера (вебхуки).
|
- **aiohttp:** Для запуска веб-сервера (вебхуки).
|
||||||
- **SQLAlchemy 2.x & asyncpg:** Асинхронная работа с базой данных PostgreSQL.
|
- **SQLAlchemy 2.x & asyncpg:** Асинхронная работа с базой данных PostgreSQL.
|
||||||
- **YooKassa, aiocryptopay:** SDK для интеграции с платежными системами.
|
- **YooKassa, FreeKassa API, aiocryptopay:** Интеграции с платежными системами.
|
||||||
- **Pydantic:** Для управления настройками из `.env` файла.
|
- **Pydantic:** Для управления настройками из `.env` файла.
|
||||||
- **Docker & Docker Compose:** Для контейнеризации и развертывания.
|
- **Docker & Docker Compose:** Для контейнеризации и развертывания.
|
||||||
|
|
||||||
@@ -66,6 +67,9 @@
|
|||||||
| `DEFAULT_LANGUAGE` | Язык по умолчанию для новых пользователей. | `ru` |
|
| `DEFAULT_LANGUAGE` | Язык по умолчанию для новых пользователей. | `ru` |
|
||||||
| `SUPPORT_LINK` | (Опционально) Ссылка на поддержку. | `https://t.me/your_support` |
|
| `SUPPORT_LINK` | (Опционально) Ссылка на поддержку. | `https://t.me/your_support` |
|
||||||
| `SUBSCRIPTION_MINI_APP_URL` | (Опционально) URL Mini App для показа подписки. | `https://t.me/your_bot/app` |
|
| `SUBSCRIPTION_MINI_APP_URL` | (Опционально) URL Mini App для показа подписки. | `https://t.me/your_bot/app` |
|
||||||
|
| `MY_DEVICES_SECTION_ENABLED` | Включить раздел «Мои устройства» в меню подписки (`true`/`false`). | `false` |
|
||||||
|
| `REQUIRED_CHANNEL_ID` | (Опционально) ID канала, на который пользователь должен подписаться перед использованием. Оставьте пустым, если проверка не нужна. | `-1001234567890` |
|
||||||
|
| `REQUIRED_CHANNEL_LINK` | (Опционально) Публичная ссылка или invite на канал для кнопки «Проверить подписку». | `https://t.me/your_channel` |
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
@@ -79,8 +83,17 @@
|
|||||||
| `YOOKASSA_ENABLED` | Включить/выключить YooKassa (`true`/`false`). |
|
| `YOOKASSA_ENABLED` | Включить/выключить YooKassa (`true`/`false`). |
|
||||||
| `YOOKASSA_SHOP_ID` | ID вашего магазина в YooKassa. |
|
| `YOOKASSA_SHOP_ID` | ID вашего магазина в YooKassa. |
|
||||||
| `YOOKASSA_SECRET_KEY`| Секретный ключ магазина YooKassa. |
|
| `YOOKASSA_SECRET_KEY`| Секретный ключ магазина YooKassa. |
|
||||||
|
| `YOOKASSA_AUTOPAYMENTS_ENABLED` | Включить автопродление (сохранение карт, автосписания, управление способами оплаты). |
|
||||||
|
| `YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING` | Требовать обязательную привязку карты при оплате с автосписанием. Установите `false`, чтобы пользователю показывался чекбокс «Сохранить карту». |
|
||||||
| `CRYPTOPAY_ENABLED` | Включить/выключить CryptoPay (`true`/`false`). |
|
| `CRYPTOPAY_ENABLED` | Включить/выключить CryptoPay (`true`/`false`). |
|
||||||
| `CRYPTOPAY_TOKEN` | Токен из вашего CryptoPay App. |
|
| `CRYPTOPAY_TOKEN` | Токен из вашего CryptoPay App. |
|
||||||
|
| `FREEKASSA_ENABLED` | Включить/выключить FreeKassa (`true`/`false`). |
|
||||||
|
| `FREEKASSA_MERCHANT_ID` | ID вашего магазина в FreeKassa. |
|
||||||
|
| `FREEKASSA_API_KEY` | API-ключ для запросов к FreeKassa REST API. |
|
||||||
|
| `FREEKASSA_SECOND_SECRET` | Секретное слово №2 — используется для проверки уведомлений от FreeKassa. |
|
||||||
|
| `FREEKASSA_PAYMENT_URL` | (Опционально, legacy SCI) Базовый URL платёжной формы FreeKassa. По умолчанию `https://pay.freekassa.ru/`. |
|
||||||
|
| `FREEKASSA_PAYMENT_IP` | Внешний IP вашего сервера, который будет передаваться в запрос оплаты. |
|
||||||
|
| `FREEKASSA_PAYMENT_METHOD_ID` | ID метода оплаты через магазин FreeKassa. По умолчанию `44`. |
|
||||||
| `STARS_ENABLED` | Включить/выключить Telegram Stars (`true`/`false`). |
|
| `STARS_ENABLED` | Включить/выключить Telegram Stars (`true`/`false`). |
|
||||||
| `TRIBUTE_ENABLED`| Включить/выключить Tribute (`true`/`false`). |
|
| `TRIBUTE_ENABLED`| Включить/выключить Tribute (`true`/`false`). |
|
||||||
</details>
|
</details>
|
||||||
@@ -106,6 +119,9 @@
|
|||||||
| `PANEL_WEBHOOK_SECRET`| Секретный ключ для проверки вебхуков от панели. |
|
| `PANEL_WEBHOOK_SECRET`| Секретный ключ для проверки вебхуков от панели. |
|
||||||
| `USER_SQUAD_UUIDS` | ID отрядов для новых пользователей. |
|
| `USER_SQUAD_UUIDS` | ID отрядов для новых пользователей. |
|
||||||
| `USER_TRAFFIC_LIMIT_GB`| Лимит трафика в ГБ (0 - безлимит). |
|
| `USER_TRAFFIC_LIMIT_GB`| Лимит трафика в ГБ (0 - безлимит). |
|
||||||
|
| `USER_HWID_DEVICE_LIMIT`| Лимит устройств (HWID) для новых пользователей (0 - безлимит). |
|
||||||
|
|
||||||
|
> Раздел "Мои устройства" становится доступен пользователям только при включении `MY_DEVICES_SECTION_ENABLED`. Значение лимита устройств при создании записей в панели берётся из `USER_HWID_DEVICE_LIMIT`.
|
||||||
</gidetails>
|
</gidetails>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
@@ -125,12 +141,13 @@
|
|||||||
Эта команда скачает образ и запустит сервис в фоновом режиме.
|
Эта команда скачает образ и запустит сервис в фоновом режиме.
|
||||||
|
|
||||||
4. **Настройка вебхуков (Обязательно):**
|
4. **Настройка вебхуков (Обязательно):**
|
||||||
Вебхуки являются **обязательным** компонентом для работы бота, так как они используются для получения уведомлений от платежных систем (YooKassa, CryptoPay, Tribute) и панели Remnawave.
|
Вебхуки являются **обязательным** компонентом для работы бота, так как они используются для получения уведомлений от платежных систем (YooKassa, FreeKassa, CryptoPay, Tribute) и панели Remnawave.
|
||||||
|
|
||||||
Вам понадобится обратный прокси (например, Nginx) для обработки HTTPS-трафика и перенаправления запросов на контейнер с ботом.
|
Вам понадобится обратный прокси (например, Nginx) для обработки HTTPS-трафика и перенаправления запросов на контейнер с ботом.
|
||||||
|
|
||||||
**Пути для перенаправления:**
|
**Пути для перенаправления:**
|
||||||
- `https://<ваш_домен>/webhook/yookassa` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/yookassa`
|
- `https://<ваш_домен>/webhook/yookassa` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/yookassa`
|
||||||
|
- `https://<ваш_домен>/webhook/freekassa` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/freekassa`
|
||||||
- `https://<ваш_домен>/webhook/cryptopay` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/cryptopay`
|
- `https://<ваш_домен>/webhook/cryptopay` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/cryptopay`
|
||||||
- `https://<ваш_домен>/webhook/tribute` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/tribute`
|
- `https://<ваш_домен>/webhook/tribute` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/tribute`
|
||||||
- `https://<ваш_домен>/webhook/panel` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/panel`
|
- `https://<ваш_домен>/webhook/panel` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/panel`
|
||||||
@@ -143,10 +160,14 @@
|
|||||||
docker compose logs -f remnawave-tg-shop
|
docker compose logs -f remnawave-tg-shop
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> 💡 Если включена проверка подписки на канал (`REQUIRED_CHANNEL_ID`), добавьте бота администратором в этот канал. Пользователь увидит кнопку «Проверить подписку», и, после первого успешного подтверждения, дальнейшие действия блокироваться не будут.
|
||||||
|
|
||||||
## 🐳 Docker
|
## 🐳 Docker
|
||||||
|
|
||||||
Файлы `Dockerfile` и `docker-compose.yml` уже настроены для сборки и запуска проекта. `docker-compose.yml` использует готовый образ с GitHub Container Registry, но вы можете раскомментировать `build: .` для локальной сборки.
|
Файлы `Dockerfile` и `docker-compose.yml` уже настроены для сборки и запуска проекта. `docker-compose.yml` использует готовый образ с GitHub Container Registry, но вы можете раскомментировать `build: .` для локальной сборки.
|
||||||
|
|
||||||
|
Для автоматической публикации образов настроены GitHub Actions (`.github/workflows`). По умолчанию образы пушатся в GitHub Container Registry и Docker Hub. Добавьте в Secrets репозитория значения `DOCKERHUB_USERNAME` и `DOCKERHUB_TOKEN` (персональный access token или пароль для Docker Hub), чтобы загрузка в Docker Hub работала корректно.
|
||||||
|
|
||||||
## 📁 Структура проекта
|
## 📁 Структура проекта
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from bot.middlewares.i18n import I18nMiddleware, get_i18n_instance, JsonI18n
|
|||||||
from bot.middlewares.ban_check_middleware import BanCheckMiddleware
|
from bot.middlewares.ban_check_middleware import BanCheckMiddleware
|
||||||
from bot.middlewares.action_logger_middleware import ActionLoggerMiddleware
|
from bot.middlewares.action_logger_middleware import ActionLoggerMiddleware
|
||||||
from bot.middlewares.profile_sync import ProfileSyncMiddleware
|
from bot.middlewares.profile_sync import ProfileSyncMiddleware
|
||||||
|
from bot.middlewares.channel_subscription import ChannelSubscriptionMiddleware
|
||||||
|
|
||||||
|
|
||||||
def build_dispatcher(settings: Settings, async_session_factory: sessionmaker) -> tuple[Dispatcher, Bot, Dict]:
|
def build_dispatcher(settings: Settings, async_session_factory: sessionmaker) -> tuple[Dispatcher, Bot, Dict]:
|
||||||
@@ -31,8 +32,8 @@ def build_dispatcher(settings: Settings, async_session_factory: sessionmaker) ->
|
|||||||
dp.update.outer_middleware(I18nMiddleware(i18n=i18n_instance, settings=settings))
|
dp.update.outer_middleware(I18nMiddleware(i18n=i18n_instance, settings=settings))
|
||||||
dp.update.outer_middleware(ProfileSyncMiddleware())
|
dp.update.outer_middleware(ProfileSyncMiddleware())
|
||||||
dp.update.outer_middleware(BanCheckMiddleware(settings=settings, i18n_instance=i18n_instance))
|
dp.update.outer_middleware(BanCheckMiddleware(settings=settings, i18n_instance=i18n_instance))
|
||||||
|
dp.update.outer_middleware(ChannelSubscriptionMiddleware(settings=settings, i18n_instance=i18n_instance))
|
||||||
dp.update.outer_middleware(ActionLoggerMiddleware(settings=settings))
|
dp.update.outer_middleware(ActionLoggerMiddleware(settings=settings))
|
||||||
|
|
||||||
return dp, bot, {"i18n_instance": i18n_instance}
|
return dp, bot, {"i18n_instance": i18n_instance}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from bot.services.stars_service import StarsService
|
|||||||
from bot.services.tribute_service import TributeService
|
from bot.services.tribute_service import TributeService
|
||||||
from bot.services.crypto_pay_service import CryptoPayService
|
from bot.services.crypto_pay_service import CryptoPayService
|
||||||
from bot.services.panel_webhook_service import PanelWebhookService
|
from bot.services.panel_webhook_service import PanelWebhookService
|
||||||
|
from bot.services.freekassa_service import FreeKassaService
|
||||||
|
|
||||||
|
|
||||||
def build_core_services(
|
def build_core_services(
|
||||||
@@ -36,6 +37,14 @@ def build_core_services(
|
|||||||
subscription_service,
|
subscription_service,
|
||||||
referral_service,
|
referral_service,
|
||||||
)
|
)
|
||||||
|
freekassa_service = FreeKassaService(
|
||||||
|
bot=bot,
|
||||||
|
settings=settings,
|
||||||
|
i18n=i18n,
|
||||||
|
async_session_factory=async_session_factory,
|
||||||
|
subscription_service=subscription_service,
|
||||||
|
referral_service=referral_service,
|
||||||
|
)
|
||||||
tribute_service = TributeService(
|
tribute_service = TributeService(
|
||||||
bot,
|
bot,
|
||||||
settings,
|
settings,
|
||||||
@@ -70,9 +79,9 @@ def build_core_services(
|
|||||||
"promo_code_service": promo_code_service,
|
"promo_code_service": promo_code_service,
|
||||||
"stars_service": stars_service,
|
"stars_service": stars_service,
|
||||||
"cryptopay_service": cryptopay_service,
|
"cryptopay_service": cryptopay_service,
|
||||||
|
"freekassa_service": freekassa_service,
|
||||||
"tribute_service": tribute_service,
|
"tribute_service": tribute_service,
|
||||||
"panel_webhook_service": panel_webhook_service,
|
"panel_webhook_service": panel_webhook_service,
|
||||||
"yookassa_service": yookassa_service,
|
"yookassa_service": yookassa_service,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ async def build_and_start_web_app(
|
|||||||
"referral_service",
|
"referral_service",
|
||||||
"panel_service",
|
"panel_service",
|
||||||
"stars_service",
|
"stars_service",
|
||||||
|
"freekassa_service",
|
||||||
"cryptopay_service",
|
"cryptopay_service",
|
||||||
"tribute_service",
|
"tribute_service",
|
||||||
"panel_webhook_service",
|
"panel_webhook_service",
|
||||||
@@ -50,6 +51,7 @@ async def build_and_start_web_app(
|
|||||||
from bot.services.tribute_service import tribute_webhook_route
|
from bot.services.tribute_service import tribute_webhook_route
|
||||||
from bot.services.crypto_pay_service import cryptopay_webhook_route
|
from bot.services.crypto_pay_service import cryptopay_webhook_route
|
||||||
from bot.services.panel_webhook_service import panel_webhook_route
|
from bot.services.panel_webhook_service import panel_webhook_route
|
||||||
|
from bot.services.freekassa_service import freekassa_webhook_route
|
||||||
|
|
||||||
tribute_path = settings.tribute_webhook_path
|
tribute_path = settings.tribute_webhook_path
|
||||||
if tribute_path.startswith("/"):
|
if tribute_path.startswith("/"):
|
||||||
@@ -61,6 +63,11 @@ async def build_and_start_web_app(
|
|||||||
app.router.add_post(cp_path, cryptopay_webhook_route)
|
app.router.add_post(cp_path, cryptopay_webhook_route)
|
||||||
logging.info(f"CryptoPay webhook route configured at: [POST] {cp_path}")
|
logging.info(f"CryptoPay webhook route configured at: [POST] {cp_path}")
|
||||||
|
|
||||||
|
fk_path = settings.freekassa_webhook_path
|
||||||
|
if fk_path.startswith("/"):
|
||||||
|
app.router.add_post(fk_path, freekassa_webhook_route)
|
||||||
|
logging.info(f"FreeKassa webhook route configured at: [POST] {fk_path}")
|
||||||
|
|
||||||
# YooKassa webhook (register only when base URL present and path configured)
|
# YooKassa webhook (register only when base URL present and path configured)
|
||||||
yk_path = settings.yookassa_webhook_path
|
yk_path = settings.yookassa_webhook_path
|
||||||
if settings.WEBHOOK_BASE_URL and yk_path and yk_path.startswith("/"):
|
if settings.WEBHOOK_BASE_URL and yk_path and yk_path.startswith("/"):
|
||||||
@@ -88,4 +95,3 @@ async def build_and_start_web_app(
|
|||||||
# Run until cancelled
|
# Run until cancelled
|
||||||
await asyncio.Event().wait()
|
await asyncio.Event().wait()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -334,11 +334,18 @@ async def confirm_broadcast_callback_handler(
|
|||||||
await session.rollback()
|
await session.rollback()
|
||||||
logging.error(f"Error committing broadcast logs: {e_commit}")
|
logging.error(f"Error committing broadcast logs: {e_commit}")
|
||||||
|
|
||||||
# Get queue stats for detailed report
|
# Prepare queue stats presentation
|
||||||
queue_stats = queue_manager.get_queue_stats()
|
queue_stats = queue_manager.get_queue_stats()
|
||||||
|
back_keyboard = get_back_to_admin_panel_keyboard(current_lang, i18n)
|
||||||
result_message = (
|
initial_user_failed = queue_stats.get("user_failed_messages", 0)
|
||||||
_(
|
initial_group_failed = queue_stats.get("group_failed_messages", 0)
|
||||||
|
|
||||||
|
def build_queue_status(stats: dict) -> str:
|
||||||
|
dynamic_failed = max(
|
||||||
|
0, stats.get("user_failed_messages", 0) - initial_user_failed
|
||||||
|
) + max(0, stats.get("group_failed_messages", 0) - initial_group_failed)
|
||||||
|
total_failed = failed_count + dynamic_failed
|
||||||
|
return _(
|
||||||
"broadcast_queue_result",
|
"broadcast_queue_result",
|
||||||
default=(
|
default=(
|
||||||
"🚀 Рассылка поставлена в очередь!\n"
|
"🚀 Рассылка поставлена в очередь!\n"
|
||||||
@@ -350,16 +357,64 @@ async def confirm_broadcast_callback_handler(
|
|||||||
"ℹ️ Сообщения будут отправлены автоматически с соблюдением лимитов Telegram."
|
"ℹ️ Сообщения будут отправлены автоматически с соблюдением лимитов Telegram."
|
||||||
),
|
),
|
||||||
sent_count=sent_count,
|
sent_count=sent_count,
|
||||||
failed_count=failed_count,
|
failed_count=total_failed,
|
||||||
user_queue_size=queue_stats["user_queue_size"],
|
user_queue_size=stats["user_queue_size"],
|
||||||
group_queue_size=queue_stats["group_queue_size"],
|
group_queue_size=stats["group_queue_size"],
|
||||||
)
|
)
|
||||||
)
|
|
||||||
await callback.message.answer(
|
result_message = build_queue_status(queue_stats)
|
||||||
|
|
||||||
|
status_message = await callback.message.answer(
|
||||||
result_message,
|
result_message,
|
||||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
|
reply_markup=back_keyboard,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def auto_update_queue_status() -> None:
|
||||||
|
"""Refresh queue stats message twice per second via message edit."""
|
||||||
|
last_text = result_message
|
||||||
|
# Update for up to 2 minutes (240 iterations at 0.5s intervals)
|
||||||
|
max_iterations = 240
|
||||||
|
for _ in range(max_iterations):
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
|
stats = queue_manager.get_queue_stats()
|
||||||
|
new_text = build_queue_status(stats)
|
||||||
|
queues_drained = (
|
||||||
|
stats["user_queue_size"] == 0
|
||||||
|
and stats["group_queue_size"] == 0
|
||||||
|
and not stats.get("user_queue_processing")
|
||||||
|
and not stats.get("group_queue_processing")
|
||||||
|
)
|
||||||
|
|
||||||
|
if new_text != last_text:
|
||||||
|
try:
|
||||||
|
await status_message.edit_text(
|
||||||
|
new_text,
|
||||||
|
reply_markup=back_keyboard,
|
||||||
|
)
|
||||||
|
last_text = new_text
|
||||||
|
except TelegramBadRequest as e:
|
||||||
|
if "message is not modified" in str(e):
|
||||||
|
last_text = new_text
|
||||||
|
else:
|
||||||
|
logging.debug(
|
||||||
|
"Broadcast queue auto-update stopped: %s", e
|
||||||
|
)
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logging.debug(
|
||||||
|
"Broadcast queue auto-update unexpected error: %s", e
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
if queues_drained:
|
||||||
|
# Final refresh already attempted; exit loop.
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
logging.debug("Broadcast queue auto-update reached time limit.")
|
||||||
|
|
||||||
|
asyncio.create_task(auto_update_queue_status())
|
||||||
|
|
||||||
elif action == "cancel":
|
elif action == "cancel":
|
||||||
await callback.message.edit_text(
|
await callback.message.edit_text(
|
||||||
_("admin_broadcast_cancelled"),
|
_("admin_broadcast_cancelled"),
|
||||||
|
|||||||
@@ -98,8 +98,22 @@ async def admin_panel_actions_callback_handler(
|
|||||||
await admin_user_mgmnt_handlers.unban_user_prompt_handler(
|
await admin_user_mgmnt_handlers.unban_user_prompt_handler(
|
||||||
callback, state, i18n_data, settings, session)
|
callback, state, i18n_data, settings, session)
|
||||||
elif action == "users_management":
|
elif action == "users_management":
|
||||||
|
# This is deprecated, kept for compatibility
|
||||||
from . import user_management as admin_user_management_handlers
|
from . import user_management as admin_user_management_handlers
|
||||||
await admin_user_management_handlers.user_management_menu_handler(
|
await admin_user_management_handlers.user_search_prompt_handler(
|
||||||
|
callback, state, i18n_data, settings, session)
|
||||||
|
elif action == "users_list" and len(action_parts) > 2:
|
||||||
|
# Route to users list handler with page number
|
||||||
|
from . import user_management as admin_user_management_handlers
|
||||||
|
try:
|
||||||
|
page = int(action_parts[2])
|
||||||
|
await admin_user_management_handlers.users_list_handler(
|
||||||
|
callback, i18n_data, settings, session, page)
|
||||||
|
except (IndexError, ValueError):
|
||||||
|
await callback.answer("Invalid page number", show_alert=True)
|
||||||
|
elif action == "users_search_prompt":
|
||||||
|
from . import user_management as admin_user_management_handlers
|
||||||
|
await admin_user_management_handlers.user_search_prompt_handler(
|
||||||
callback, state, i18n_data, settings, session)
|
callback, state, i18n_data, settings, session)
|
||||||
elif action == "view_banned":
|
elif action == "view_banned":
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ def format_payment_text(payment: Payment, i18n: JsonI18n, lang: str) -> str:
|
|||||||
_ = lambda key, **kwargs: i18n.gettext(lang, key, **kwargs)
|
_ = lambda key, **kwargs: i18n.gettext(lang, key, **kwargs)
|
||||||
|
|
||||||
status_emoji = "✅" if payment.status == 'succeeded' else (
|
status_emoji = "✅" if payment.status == 'succeeded' else (
|
||||||
"⏳" if payment.status in ['pending', 'pending_yookassa'] else "❌"
|
"⏳" if payment.status in ['pending', 'pending_yookassa', 'pending_freekassa'] else "❌"
|
||||||
)
|
)
|
||||||
|
|
||||||
user_info = f"User {payment.user_id}"
|
user_info = f"User {payment.user_id}"
|
||||||
@@ -54,7 +54,8 @@ def format_payment_text(payment: Payment, i18n: JsonI18n, lang: str) -> str:
|
|||||||
'yookassa': 'YooKassa',
|
'yookassa': 'YooKassa',
|
||||||
'tribute': 'Tribute',
|
'tribute': 'Tribute',
|
||||||
'telegram_stars': 'Telegram Stars',
|
'telegram_stars': 'Telegram Stars',
|
||||||
'cryptopay': 'CryptoPay'
|
'cryptopay': 'CryptoPay',
|
||||||
|
'freekassa': 'FreeKassa',
|
||||||
}.get(payment.provider, payment.provider or 'Unknown')
|
}.get(payment.provider, payment.provider or 'Unknown')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -246,4 +247,4 @@ async def export_payments_csv_handler(callback: types.CallbackQuery, i18n_data:
|
|||||||
@router.callback_query(F.data == "noop")
|
@router.callback_query(F.data == "noop")
|
||||||
async def noop_handler(callback: types.CallbackQuery):
|
async def noop_handler(callback: types.CallbackQuery):
|
||||||
"""Handle no-op callback (for pagination display)."""
|
"""Handle no-op callback (for pagination display)."""
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
|
|||||||
@@ -163,8 +163,7 @@ async def show_statistics_handler(callback: types.CallbackQuery,
|
|||||||
f"\n<b>{_('admin_stats_recent_payments_header')}</b>")
|
f"\n<b>{_('admin_stats_recent_payments_header')}</b>")
|
||||||
for payment in last_payments_models:
|
for payment in last_payments_models:
|
||||||
status_emoji = "✅" if payment.status == 'succeeded' else (
|
status_emoji = "✅" if payment.status == 'succeeded' else (
|
||||||
"⏳" if payment.status == 'pending'
|
"⏳" if payment.status in ['pending', 'pending_yookassa', 'pending_freekassa'] else "❌")
|
||||||
or payment.status == 'pending_yookassa' else "❌")
|
|
||||||
|
|
||||||
user_info = f"User {payment.user_id}"
|
user_info = f"User {payment.user_id}"
|
||||||
if payment.user and payment.user.username:
|
if payment.user and payment.user.username:
|
||||||
|
|||||||
@@ -212,6 +212,7 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
|||||||
"is_active": panel_status == "ACTIVE",
|
"is_active": panel_status == "ACTIVE",
|
||||||
"status_from_panel": panel_status,
|
"status_from_panel": panel_status,
|
||||||
"traffic_limit_bytes": settings.user_traffic_limit_bytes,
|
"traffic_limit_bytes": settings.user_traffic_limit_bytes,
|
||||||
|
"auto_renew_enabled": False,
|
||||||
}
|
}
|
||||||
created_sub = await subscription_dal.upsert_subscription(
|
created_sub = await subscription_dal.upsert_subscription(
|
||||||
session, sub_payload
|
session, sub_payload
|
||||||
@@ -436,4 +437,4 @@ async def sync_status_command_handler(
|
|||||||
else:
|
else:
|
||||||
response_text = _("admin_sync_status_never_run")
|
response_text = _("admin_sync_status_never_run")
|
||||||
|
|
||||||
await message.answer(response_text, parse_mode="HTML")
|
await message.answer(response_text, parse_mode="HTML")
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from aiogram import Router, F, types, Bot
|
|||||||
from aiogram.exceptions import TelegramBadRequest
|
from aiogram.exceptions import TelegramBadRequest
|
||||||
from aiogram.fsm.context import FSMContext
|
from aiogram.fsm.context import FSMContext
|
||||||
from aiogram.utils.markdown import hcode, hbold
|
from aiogram.utils.markdown import hcode, hbold
|
||||||
from typing import Optional, Dict, Any
|
from typing import Optional, Dict, Any, Callable, Awaitable
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
@@ -15,22 +15,71 @@ from bot.states.admin_states import AdminStates
|
|||||||
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
|
||||||
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
|
||||||
|
from bot.services.referral_service import ReferralService
|
||||||
from bot.middlewares.i18n import JsonI18n
|
from bot.middlewares.i18n import JsonI18n
|
||||||
from bot.utils import get_message_content, send_direct_message
|
from bot.utils import get_message_content, send_direct_message
|
||||||
from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton
|
from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton
|
||||||
|
from bot.utils.text_sanitizer import (
|
||||||
|
sanitize_display_name,
|
||||||
|
sanitize_username,
|
||||||
|
username_for_display,
|
||||||
|
)
|
||||||
|
|
||||||
router = Router(name="admin_user_management_router")
|
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}$")
|
||||||
|
|
||||||
|
|
||||||
async def user_management_menu_handler(callback: types.CallbackQuery,
|
async def users_list_handler(callback: types.CallbackQuery,
|
||||||
state: FSMContext, i18n_data: dict,
|
i18n_data: dict, settings: Settings,
|
||||||
settings: Settings, session: AsyncSession):
|
session: AsyncSession, page: int = 0):
|
||||||
"""Display user management menu"""
|
"""Display paginated list of all users"""
|
||||||
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")
|
||||||
if not i18n or not callback.message:
|
if not i18n or not callback.message:
|
||||||
await callback.answer("Error preparing user management.", show_alert=True)
|
await callback.answer("Error preparing user list.", show_alert=True)
|
||||||
|
return
|
||||||
|
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get paginated users
|
||||||
|
from bot.keyboards.inline.admin_keyboards import get_users_list_keyboard
|
||||||
|
from db.dal import user_dal
|
||||||
|
|
||||||
|
users = await user_dal.get_all_users_paginated(session, page=page, page_size=15)
|
||||||
|
total_users = await user_dal.count_all_users(session)
|
||||||
|
total_pages = max(1, (total_users + 14) // 15)
|
||||||
|
|
||||||
|
# Format message
|
||||||
|
header_text = _(
|
||||||
|
"admin_users_list_header",
|
||||||
|
default="👥 <b>Список пользователей</b>\n\nСтраница {current}/{total} ({total_users} пользователей)",
|
||||||
|
current=page + 1,
|
||||||
|
total=total_pages,
|
||||||
|
total_users=total_users
|
||||||
|
)
|
||||||
|
|
||||||
|
keyboard = get_users_list_keyboard(users, page, total_users, i18n, current_lang, page_size=15)
|
||||||
|
|
||||||
|
await callback.message.edit_text(
|
||||||
|
header_text,
|
||||||
|
reply_markup=keyboard,
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
await callback.answer()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Error displaying user list: {e}")
|
||||||
|
await callback.answer("Ошибка отображения списка пользователей", show_alert=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def user_search_prompt_handler(callback: types.CallbackQuery,
|
||||||
|
state: FSMContext, i18n_data: dict,
|
||||||
|
settings: Settings, session: AsyncSession):
|
||||||
|
"""Display search prompt for user management"""
|
||||||
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||||
|
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||||
|
if not i18n or not callback.message:
|
||||||
|
await callback.answer("Error preparing search.", show_alert=True)
|
||||||
return
|
return
|
||||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||||
|
|
||||||
@@ -55,7 +104,8 @@ async def user_management_menu_handler(callback: types.CallbackQuery,
|
|||||||
await state.set_state(AdminStates.waiting_for_user_search)
|
await state.set_state(AdminStates.waiting_for_user_search)
|
||||||
|
|
||||||
|
|
||||||
def get_user_card_keyboard(user_id: int, i18n_instance, lang: str) -> InlineKeyboardBuilder:
|
def get_user_card_keyboard(user_id: int, i18n_instance, lang: str,
|
||||||
|
referrer_id: Optional[int] = None) -> InlineKeyboardBuilder:
|
||||||
"""Generate keyboard for user management actions"""
|
"""Generate keyboard for user management actions"""
|
||||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||||
builder = InlineKeyboardBuilder()
|
builder = InlineKeyboardBuilder()
|
||||||
@@ -89,8 +139,27 @@ def get_user_card_keyboard(user_id: int, i18n_instance, lang: str) -> InlineKeyb
|
|||||||
text=_(key="admin_user_refresh_button", default="🔄 Обновить"),
|
text=_(key="admin_user_refresh_button", default="🔄 Обновить"),
|
||||||
callback_data=f"user_action:refresh:{user_id}"
|
callback_data=f"user_action:refresh:{user_id}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Row 4: Quick links
|
||||||
|
builder.button(
|
||||||
|
text=_(key="user_card_open_profile_button",
|
||||||
|
default="👤 Открыть профиль"),
|
||||||
|
url=f"tg://user?id={user_id}"
|
||||||
|
)
|
||||||
|
if referrer_id:
|
||||||
|
builder.button(
|
||||||
|
text=_(key="user_card_open_referrer_profile_button",
|
||||||
|
default="👤 Открыть профиль пригласившего"),
|
||||||
|
url=f"tg://user?id={referrer_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Row 5: Destructive action
|
||||||
|
builder.button(
|
||||||
|
text=_(key="admin_user_delete_button", default="❌ Удалить пользователя"),
|
||||||
|
callback_data=f"user_action:delete_user:{user_id}"
|
||||||
|
)
|
||||||
|
|
||||||
# Row 4: Back button
|
# Row 6: Navigation
|
||||||
builder.button(
|
builder.button(
|
||||||
text=_(key="admin_user_search_new_button", default="🔍 Найти другого"),
|
text=_(key="admin_user_search_new_button", default="🔍 Найти другого"),
|
||||||
callback_data="admin_action:users_management"
|
callback_data="admin_action:users_management"
|
||||||
@@ -100,13 +169,65 @@ def get_user_card_keyboard(user_id: int, i18n_instance, lang: str) -> InlineKeyb
|
|||||||
callback_data="admin_action:main"
|
callback_data="admin_action:main"
|
||||||
)
|
)
|
||||||
|
|
||||||
builder.adjust(2, 2, 2, 2)
|
quick_links_width = 2 if referrer_id else 1
|
||||||
|
builder.adjust(2, 2, 2, quick_links_width, 1, 2)
|
||||||
return builder
|
return builder
|
||||||
|
|
||||||
|
|
||||||
|
def _remove_profile_link_buttons(
|
||||||
|
markup: Optional[types.InlineKeyboardMarkup]) -> Optional[types.InlineKeyboardMarkup]:
|
||||||
|
"""Drop buttons that rely on tg://user links to avoid BUTTON_USER_INVALID errors."""
|
||||||
|
if not markup or not markup.inline_keyboard:
|
||||||
|
return None
|
||||||
|
|
||||||
|
cleaned_rows = []
|
||||||
|
for row in markup.inline_keyboard:
|
||||||
|
filtered_row = [
|
||||||
|
button for button in row
|
||||||
|
if not (getattr(button, "url", None) and button.url.startswith("tg://user?id="))
|
||||||
|
]
|
||||||
|
if filtered_row:
|
||||||
|
cleaned_rows.append(filtered_row)
|
||||||
|
|
||||||
|
if not cleaned_rows:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return types.InlineKeyboardMarkup(inline_keyboard=cleaned_rows)
|
||||||
|
|
||||||
|
|
||||||
|
async def _send_with_profile_link_fallback(
|
||||||
|
sender: Callable[..., Awaitable[Any]],
|
||||||
|
*,
|
||||||
|
text: str,
|
||||||
|
markup: Optional[types.InlineKeyboardMarkup],
|
||||||
|
user_id: int,
|
||||||
|
parse_mode: Optional[str] = "HTML") -> None:
|
||||||
|
"""Send text with markup and fallback if Telegram rejects tg://user buttons."""
|
||||||
|
send_kwargs: Dict[str, Any] = {"text": text, "reply_markup": markup}
|
||||||
|
if parse_mode is not None:
|
||||||
|
send_kwargs["parse_mode"] = parse_mode
|
||||||
|
|
||||||
|
try:
|
||||||
|
await sender(**send_kwargs)
|
||||||
|
except TelegramBadRequest as exc:
|
||||||
|
message = getattr(exc, "message", "") or str(exc)
|
||||||
|
if "BUTTON_USER_INVALID" not in message:
|
||||||
|
raise
|
||||||
|
|
||||||
|
logging.warning(
|
||||||
|
"Telegram rejected profile buttons for user %s: %s. Retrying without tg:// links.",
|
||||||
|
user_id,
|
||||||
|
message,
|
||||||
|
)
|
||||||
|
fallback_markup = _remove_profile_link_buttons(markup)
|
||||||
|
send_kwargs["reply_markup"] = fallback_markup
|
||||||
|
await sender(**send_kwargs)
|
||||||
|
|
||||||
|
|
||||||
async def format_user_card(user: User, session: AsyncSession,
|
async def format_user_card(user: User, session: AsyncSession,
|
||||||
subscription_service: SubscriptionService,
|
subscription_service: SubscriptionService,
|
||||||
i18n_instance, lang: str) -> str:
|
i18n_instance, lang: str,
|
||||||
|
referral_service: Optional[ReferralService] = None) -> str:
|
||||||
"""Format user information as a detailed card"""
|
"""Format user information as a detailed card"""
|
||||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||||
|
|
||||||
@@ -116,8 +237,16 @@ async def format_user_card(user: User, session: AsyncSession,
|
|||||||
|
|
||||||
# User details
|
# User details
|
||||||
na_value = _("admin_user_na_value", default="N/A")
|
na_value = _("admin_user_na_value", default="N/A")
|
||||||
user_name = user.first_name or na_value
|
safe_first_name = sanitize_display_name(user.first_name) if user.first_name else None
|
||||||
username_display = f"@{user.username}" if user.username else na_value
|
user_name = safe_first_name or na_value
|
||||||
|
if user.username:
|
||||||
|
sanitized_username = sanitize_username(user.username)
|
||||||
|
if sanitized_username:
|
||||||
|
username_display = f"@{sanitized_username}"
|
||||||
|
else:
|
||||||
|
username_display = username_for_display(user.username, with_at=False)
|
||||||
|
else:
|
||||||
|
username_display = na_value
|
||||||
registration_date = user.registration_date.strftime('%Y-%m-%d %H:%M') if user.registration_date else na_value
|
registration_date = user.registration_date.strftime('%Y-%m-%d %H:%M') if user.registration_date else na_value
|
||||||
|
|
||||||
card_parts.append(f"{_('admin_user_id_label', default='🆔 <b>ID:</b>')} {hcode(str(user.user_id))}")
|
card_parts.append(f"{_('admin_user_id_label', default='🆔 <b>ID:</b>')} {hcode(str(user.user_id))}")
|
||||||
@@ -176,6 +305,31 @@ async def format_user_card(user: User, session: AsyncSession,
|
|||||||
had_subscriptions = await subscription_service.has_had_any_subscription(session, user.user_id)
|
had_subscriptions = await subscription_service.has_had_any_subscription(session, user.user_id)
|
||||||
trial_status = _("admin_user_trial_used", default="Использовал") if had_subscriptions else _("admin_user_trial_not_used", default="Не использовал")
|
trial_status = _("admin_user_trial_used", default="Использовал") if had_subscriptions else _("admin_user_trial_not_used", default="Не использовал")
|
||||||
card_parts.append(f"{_('admin_user_trial_label', default='🏡 <b>Триал:</b>')} {hcode(trial_status)}")
|
card_parts.append(f"{_('admin_user_trial_label', default='🏡 <b>Триал:</b>')} {hcode(trial_status)}")
|
||||||
|
|
||||||
|
# Financial analytics (admin-only)
|
||||||
|
try:
|
||||||
|
from db.dal import payment_dal
|
||||||
|
|
||||||
|
# Total amount paid by this user
|
||||||
|
total_paid = await payment_dal.get_user_total_paid(session, user.user_id)
|
||||||
|
card_parts.append(f"{_('admin_user_total_paid_label', default='💰 <b>Всего оплачено:</b>')} {hcode(f'{total_paid:.2f} RUB')}")
|
||||||
|
|
||||||
|
# Total revenue from referrals
|
||||||
|
referral_revenue = await payment_dal.get_referral_revenue(session, user.user_id)
|
||||||
|
card_parts.append(f"{_('admin_user_referral_revenue_label', default='💸 <b>Доход по рефералам:</b>')} {hcode(f'{referral_revenue:.2f} RUB')}")
|
||||||
|
except Exception as e_fin:
|
||||||
|
logging.error(f"Failed to build financial analytics for admin card {user.user_id}: {e_fin}")
|
||||||
|
|
||||||
|
# Referral stats
|
||||||
|
if referral_service is not None:
|
||||||
|
try:
|
||||||
|
stats = await referral_service.get_referral_stats(session, user.user_id)
|
||||||
|
invited_count = stats.get('invited_count', 0)
|
||||||
|
purchased_count = stats.get('purchased_count', 0)
|
||||||
|
card_parts.append(f"{_('admin_user_invited_friends_label', default='👥 <b>Приглашено друзей:</b>')} {hcode(str(invited_count))}")
|
||||||
|
card_parts.append(f"{_('admin_user_ref_purchased_label', default='💳 <b>Купили подписку:</b>')} {hcode(str(purchased_count))}")
|
||||||
|
except Exception as e_rs:
|
||||||
|
logging.error(f"Failed to build referral stats for admin card {user.user_id}: {e_rs}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Error getting user statistics for {user.user_id}: {e}")
|
logging.error(f"Error getting user statistics for {user.user_id}: {e}")
|
||||||
@@ -224,12 +378,20 @@ async def process_user_search_handler(message: types.Message, state: FSMContext,
|
|||||||
|
|
||||||
# Format and send user card
|
# Format and send user card
|
||||||
try:
|
try:
|
||||||
user_card_text = await format_user_card(user_model, session, subscription_service, i18n, current_lang)
|
referral_service = ReferralService(settings, subscription_service, message.bot, i18n)
|
||||||
keyboard = get_user_card_keyboard(user_model.user_id, i18n, current_lang)
|
user_card_text = await format_user_card(user_model, session, subscription_service, i18n, current_lang, referral_service)
|
||||||
|
keyboard = get_user_card_keyboard(
|
||||||
|
user_model.user_id,
|
||||||
|
i18n,
|
||||||
|
current_lang,
|
||||||
|
user_model.referred_by_id
|
||||||
|
)
|
||||||
|
|
||||||
await message.answer(
|
await _send_with_profile_link_fallback(
|
||||||
user_card_text,
|
message.answer,
|
||||||
reply_markup=keyboard.as_markup(),
|
text=user_card_text,
|
||||||
|
markup=keyboard.as_markup(),
|
||||||
|
user_id=user_model.user_id,
|
||||||
parse_mode="HTML"
|
parse_mode="HTML"
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -283,6 +445,10 @@ async def user_action_handler(callback: types.CallbackQuery, state: FSMContext,
|
|||||||
await handle_view_user_logs(callback, user, session, settings, i18n, current_lang)
|
await handle_view_user_logs(callback, user, session, settings, i18n, current_lang)
|
||||||
elif action == "refresh":
|
elif action == "refresh":
|
||||||
await handle_refresh_user_card(callback, user, subscription_service, session, i18n, current_lang)
|
await handle_refresh_user_card(callback, user, subscription_service, session, i18n, current_lang)
|
||||||
|
elif action == "delete_user":
|
||||||
|
await handle_delete_user_prompt(
|
||||||
|
callback, state, user, settings, i18n, current_lang, session
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
await callback.answer(_("admin_unknown_action"), show_alert=True)
|
await callback.answer(_("admin_unknown_action"), show_alert=True)
|
||||||
|
|
||||||
@@ -482,19 +648,32 @@ async def handle_refresh_user_card(callback: types.CallbackQuery, user: User,
|
|||||||
await callback.answer("User not found", show_alert=True)
|
await callback.answer("User not found", show_alert=True)
|
||||||
return
|
return
|
||||||
|
|
||||||
user_card_text = await format_user_card(fresh_user, session, subscription_service, i18n_instance, lang)
|
from config.settings import Settings as _Settings
|
||||||
keyboard = get_user_card_keyboard(fresh_user.user_id, i18n_instance, lang)
|
_settings = _Settings()
|
||||||
|
referral_service = ReferralService(_settings, subscription_service, callback.message.bot, i18n_instance)
|
||||||
|
user_card_text = await format_user_card(fresh_user, session, subscription_service, i18n_instance, lang, referral_service)
|
||||||
|
keyboard = get_user_card_keyboard(
|
||||||
|
fresh_user.user_id,
|
||||||
|
i18n_instance,
|
||||||
|
lang,
|
||||||
|
fresh_user.referred_by_id
|
||||||
|
)
|
||||||
|
markup = keyboard.as_markup()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await callback.message.edit_text(
|
await _send_with_profile_link_fallback(
|
||||||
user_card_text,
|
callback.message.edit_text,
|
||||||
reply_markup=keyboard.as_markup(),
|
text=user_card_text,
|
||||||
|
markup=markup,
|
||||||
|
user_id=fresh_user.user_id,
|
||||||
parse_mode="HTML"
|
parse_mode="HTML"
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
await callback.message.answer(
|
await _send_with_profile_link_fallback(
|
||||||
user_card_text,
|
callback.message.answer,
|
||||||
reply_markup=keyboard.as_markup(),
|
text=user_card_text,
|
||||||
|
markup=markup,
|
||||||
|
user_id=fresh_user.user_id,
|
||||||
parse_mode="HTML"
|
parse_mode="HTML"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -505,8 +684,217 @@ async def handle_refresh_user_card(callback: types.CallbackQuery, user: User,
|
|||||||
await callback.answer("Error refreshing user card", show_alert=True)
|
await callback.answer("Error refreshing user card", show_alert=True)
|
||||||
|
|
||||||
|
|
||||||
|
# Destructive deletion flow
|
||||||
|
async def handle_delete_user_prompt(callback: types.CallbackQuery, state: FSMContext,
|
||||||
|
user: User, settings: Settings, i18n_instance,
|
||||||
|
lang: str, session: AsyncSession):
|
||||||
|
"""Trigger confirmation workflow for destructive deletion."""
|
||||||
|
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||||
|
|
||||||
|
admin = callback.from_user
|
||||||
|
admin_id = admin.id if admin else None
|
||||||
|
if not admin_id or admin_id not in settings.ADMIN_IDS:
|
||||||
|
logging.warning(
|
||||||
|
f"Unauthorized delete attempt by user {admin_id} targeting {user.user_id}."
|
||||||
|
)
|
||||||
|
await callback.answer(
|
||||||
|
_(
|
||||||
|
"admin_user_delete_not_allowed",
|
||||||
|
default="❌ У вас нет прав для удаления пользователей.",
|
||||||
|
),
|
||||||
|
show_alert=True,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
await state.update_data(
|
||||||
|
target_user_id=user.user_id,
|
||||||
|
delete_initiator_id=admin_id,
|
||||||
|
)
|
||||||
|
await state.set_state(AdminStates.waiting_for_user_delete_confirmation)
|
||||||
|
|
||||||
|
prompt_text = _(
|
||||||
|
"admin_user_delete_confirmation_prompt",
|
||||||
|
default=(
|
||||||
|
"⚠️ Вы хотите полностью удалить пользователя {user_id}.\n\n"
|
||||||
|
"Отправьте точный Telegram ID этого пользователя, чтобы подтвердить удаление.\n"
|
||||||
|
"Любой другой ответ отменит операцию."
|
||||||
|
),
|
||||||
|
user_id=hcode(str(user.user_id)),
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await callback.message.answer(prompt_text, parse_mode="HTML")
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(
|
||||||
|
f"Failed to send delete confirmation prompt for user {user.user_id}: {e}"
|
||||||
|
)
|
||||||
|
await callback.message.reply(prompt_text, parse_mode="HTML")
|
||||||
|
|
||||||
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
|
async def _log_admin_user_deletion(
|
||||||
|
session: AsyncSession,
|
||||||
|
admin_id: int,
|
||||||
|
admin_user: Optional[types.User],
|
||||||
|
target_user_id: int,
|
||||||
|
) -> None:
|
||||||
|
"""Store audit log for successful deletion."""
|
||||||
|
try:
|
||||||
|
await message_log_dal.create_message_log_no_commit(
|
||||||
|
session,
|
||||||
|
{
|
||||||
|
"user_id": admin_id,
|
||||||
|
"telegram_username": admin_user.username if admin_user else None,
|
||||||
|
"telegram_first_name": admin_user.first_name if admin_user else None,
|
||||||
|
"event_type": "admin:user_deleted",
|
||||||
|
"content": f"Admin {admin_id} deleted user {target_user_id}",
|
||||||
|
"raw_update_preview": None,
|
||||||
|
"is_admin_event": True,
|
||||||
|
"target_user_id": target_user_id,
|
||||||
|
"timestamp": datetime.now(timezone.utc),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(
|
||||||
|
f"Failed to log deletion audit for admin {admin_id} -> user {target_user_id}: {e}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# Message handlers for state-based inputs
|
# Message handlers for state-based inputs
|
||||||
|
|
||||||
|
@router.message(AdminStates.waiting_for_user_delete_confirmation, F.text)
|
||||||
|
async def process_delete_user_confirmation_handler(message: types.Message,
|
||||||
|
state: FSMContext,
|
||||||
|
settings: Settings,
|
||||||
|
i18n_data: dict,
|
||||||
|
panel_service: PanelApiService,
|
||||||
|
session: AsyncSession):
|
||||||
|
"""Confirm and execute destructive user deletion."""
|
||||||
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||||
|
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||||
|
if not i18n:
|
||||||
|
await message.reply("Language service error.")
|
||||||
|
await state.clear()
|
||||||
|
return
|
||||||
|
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||||
|
|
||||||
|
admin = message.from_user
|
||||||
|
admin_id = admin.id if admin else None
|
||||||
|
if not admin_id or admin_id not in settings.ADMIN_IDS:
|
||||||
|
logging.warning(
|
||||||
|
f"Unauthorized delete confirmation attempt by user {admin_id}."
|
||||||
|
)
|
||||||
|
await message.answer(
|
||||||
|
_(
|
||||||
|
"admin_user_delete_not_allowed",
|
||||||
|
default="❌ У вас нет прав для удаления пользователей.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await state.clear()
|
||||||
|
return
|
||||||
|
|
||||||
|
data = await state.get_data()
|
||||||
|
target_user_id = data.get("target_user_id")
|
||||||
|
if not target_user_id:
|
||||||
|
await message.answer(
|
||||||
|
_(
|
||||||
|
"admin_user_delete_state_missing",
|
||||||
|
default="⚠️ Нет активной операции удаления. Начните заново.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await state.clear()
|
||||||
|
return
|
||||||
|
|
||||||
|
confirmation_input = message.text.strip() if message.text else ""
|
||||||
|
if confirmation_input.lower() in {"/cancel", "cancel", "отмена"}:
|
||||||
|
await message.answer(
|
||||||
|
_(
|
||||||
|
"admin_user_delete_cancelled",
|
||||||
|
default="Операция удаления отменена по запросу.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await state.clear()
|
||||||
|
return
|
||||||
|
|
||||||
|
if confirmation_input != str(target_user_id):
|
||||||
|
await message.answer(
|
||||||
|
_(
|
||||||
|
"admin_user_delete_mismatch",
|
||||||
|
default="⚠️ ID не совпадает. Удаление отменено.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await state.clear()
|
||||||
|
return
|
||||||
|
|
||||||
|
user_model = await user_dal.get_user_by_id(session, target_user_id)
|
||||||
|
if not user_model:
|
||||||
|
await message.answer(
|
||||||
|
_(
|
||||||
|
"admin_user_delete_already_removed",
|
||||||
|
default="ℹ️ Пользователь уже удален.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await state.clear()
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
if user_model.panel_user_uuid:
|
||||||
|
panel_deleted = await panel_service.delete_user_from_panel(
|
||||||
|
user_model.panel_user_uuid
|
||||||
|
)
|
||||||
|
if not panel_deleted:
|
||||||
|
await message.answer(
|
||||||
|
_(
|
||||||
|
"admin_user_delete_panel_error",
|
||||||
|
default=(
|
||||||
|
"❌ Не удалось удалить пользователя на панели. "
|
||||||
|
"Операция прервана."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.rollback()
|
||||||
|
await state.clear()
|
||||||
|
return
|
||||||
|
|
||||||
|
deleted = await user_dal.delete_user_and_relations(
|
||||||
|
session, target_user_id
|
||||||
|
)
|
||||||
|
if not deleted:
|
||||||
|
await message.answer(
|
||||||
|
_(
|
||||||
|
"admin_user_delete_already_removed",
|
||||||
|
default="ℹ️ Пользователь уже удален.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await state.clear()
|
||||||
|
return
|
||||||
|
|
||||||
|
await _log_admin_user_deletion(session, admin_id, admin, target_user_id)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
await message.answer(
|
||||||
|
_(
|
||||||
|
"admin_user_delete_success",
|
||||||
|
default="✅ Пользователь {user_id} удален из бота и панели.",
|
||||||
|
user_id=hcode(str(target_user_id)),
|
||||||
|
),
|
||||||
|
parse_mode="HTML",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Error deleting user {target_user_id}: {e}", exc_info=True)
|
||||||
|
await session.rollback()
|
||||||
|
await message.answer(
|
||||||
|
_(
|
||||||
|
"admin_user_delete_error",
|
||||||
|
default="❌ Не удалось завершить удаление пользователя. Попробуйте позже.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await state.clear()
|
||||||
|
|
||||||
|
|
||||||
@router.message(AdminStates.waiting_for_subscription_days_to_add, F.text)
|
@router.message(AdminStates.waiting_for_subscription_days_to_add, F.text)
|
||||||
async def process_subscription_days_handler(message: types.Message, state: FSMContext,
|
async def process_subscription_days_handler(message: types.Message, state: FSMContext,
|
||||||
settings: Settings, i18n_data: dict,
|
settings: Settings, i18n_data: dict,
|
||||||
@@ -556,12 +944,20 @@ async def process_subscription_days_handler(message: types.Message, state: FSMCo
|
|||||||
# Show updated user card
|
# Show updated user card
|
||||||
user = await user_dal.get_user_by_id(session, target_user_id)
|
user = await user_dal.get_user_by_id(session, target_user_id)
|
||||||
if user:
|
if user:
|
||||||
user_card_text = await format_user_card(user, session, subscription_service, i18n, current_lang)
|
referral_service = ReferralService(settings, subscription_service, message.bot, i18n)
|
||||||
keyboard = get_user_card_keyboard(user.user_id, i18n, current_lang)
|
user_card_text = await format_user_card(user, session, subscription_service, i18n, current_lang, referral_service)
|
||||||
|
keyboard = get_user_card_keyboard(
|
||||||
|
user.user_id,
|
||||||
|
i18n,
|
||||||
|
current_lang,
|
||||||
|
user.referred_by_id
|
||||||
|
)
|
||||||
|
|
||||||
await message.answer(
|
await _send_with_profile_link_fallback(
|
||||||
user_card_text,
|
message.answer,
|
||||||
reply_markup=keyboard.as_markup(),
|
text=user_card_text,
|
||||||
|
markup=keyboard.as_markup(),
|
||||||
|
user_id=user.user_id,
|
||||||
parse_mode="HTML"
|
parse_mode="HTML"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
@@ -664,12 +1060,20 @@ async def process_direct_message_handler(message: types.Message, state: FSMConte
|
|||||||
from bot.services.panel_api_service import PanelApiService
|
from bot.services.panel_api_service import PanelApiService
|
||||||
async with PanelApiService(settings) as panel_service:
|
async with PanelApiService(settings) as panel_service:
|
||||||
subscription_service = SubscriptionService(settings, panel_service)
|
subscription_service = SubscriptionService(settings, panel_service)
|
||||||
user_card_text = await format_user_card(target_user, session, subscription_service, i18n, current_lang)
|
referral_service = ReferralService(settings, subscription_service, bot, i18n)
|
||||||
keyboard = get_user_card_keyboard(target_user.user_id, i18n, current_lang)
|
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 message.answer(
|
await _send_with_profile_link_fallback(
|
||||||
user_card_text,
|
message.answer,
|
||||||
reply_markup=keyboard.as_markup(),
|
text=user_card_text,
|
||||||
|
markup=keyboard.as_markup(),
|
||||||
|
user_id=target_user.user_id,
|
||||||
parse_mode="HTML"
|
parse_mode="HTML"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -865,9 +1269,9 @@ async def process_ban_user_handler(message: types.Message, state: FSMContext,
|
|||||||
|
|
||||||
@router.message(AdminStates.waiting_for_user_id_to_unban, F.text)
|
@router.message(AdminStates.waiting_for_user_id_to_unban, F.text)
|
||||||
async def process_unban_user_handler(message: types.Message, state: FSMContext,
|
async def process_unban_user_handler(message: types.Message, state: FSMContext,
|
||||||
settings: Settings, i18n_data: dict,
|
settings: Settings, i18n_data: dict,
|
||||||
panel_service: PanelApiService,
|
panel_service: PanelApiService,
|
||||||
session: AsyncSession):
|
session: AsyncSession):
|
||||||
"""Process user unban input"""
|
"""Process user unban input"""
|
||||||
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")
|
||||||
@@ -931,4 +1335,68 @@ async def process_unban_user_handler(message: types.Message, state: FSMContext,
|
|||||||
default="❌ Ошибка разблокировки пользователя"
|
default="❌ Ошибка разблокировки пользователя"
|
||||||
))
|
))
|
||||||
|
|
||||||
await state.clear()
|
await state.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("admin_user_card_from_list:"))
|
||||||
|
async def user_card_from_list_handler(callback: types.CallbackQuery,
|
||||||
|
state: FSMContext, i18n_data: dict,
|
||||||
|
settings: Settings, bot: Bot,
|
||||||
|
subscription_service: SubscriptionService,
|
||||||
|
panel_service: PanelApiService,
|
||||||
|
session: AsyncSession):
|
||||||
|
"""Display user card when clicked from user list"""
|
||||||
|
try:
|
||||||
|
parts = callback.data.split(":")
|
||||||
|
user_id = int(parts[1])
|
||||||
|
page = int(parts[2])
|
||||||
|
except (IndexError, ValueError):
|
||||||
|
await callback.answer("Invalid user data", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||||
|
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||||
|
if not i18n:
|
||||||
|
await callback.answer("Language service error", show_alert=True)
|
||||||
|
return
|
||||||
|
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||||
|
|
||||||
|
# Get user from database
|
||||||
|
user = await user_dal.get_user_by_id(session, user_id)
|
||||||
|
if not user:
|
||||||
|
await callback.answer("User not found", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Create keyboard with back to list button
|
||||||
|
keyboard = get_user_card_keyboard(
|
||||||
|
user_id,
|
||||||
|
i18n,
|
||||||
|
current_lang,
|
||||||
|
user.referred_by_id
|
||||||
|
)
|
||||||
|
keyboard.button(
|
||||||
|
text=_("admin_user_back_to_list_button", default="⬅️ К списку"),
|
||||||
|
callback_data=f"admin_action:users_list:{page}"
|
||||||
|
)
|
||||||
|
quick_links_width = 2 if user.referred_by_id else 1
|
||||||
|
keyboard.adjust(2, 2, 2, quick_links_width, 1, 2, 1)
|
||||||
|
|
||||||
|
# Format user card
|
||||||
|
try:
|
||||||
|
from bot.services.referral_service import ReferralService
|
||||||
|
referral_service = ReferralService(settings, subscription_service, bot, i18n)
|
||||||
|
user_card_text = await format_user_card(user, session, subscription_service, i18n, current_lang, referral_service)
|
||||||
|
markup = keyboard.as_markup()
|
||||||
|
|
||||||
|
await _send_with_profile_link_fallback(
|
||||||
|
callback.message.edit_text,
|
||||||
|
text=user_card_text,
|
||||||
|
markup=markup,
|
||||||
|
user_id=user.user_id,
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
await callback.answer()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Error displaying user card: {e}")
|
||||||
|
await callback.answer("Error displaying user card", show_alert=True)
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ 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
|
||||||
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
|
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
|
||||||
|
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
|
||||||
|
|
||||||
payment_processing_lock = asyncio.Lock()
|
payment_processing_lock = asyncio.Lock()
|
||||||
|
|
||||||
@@ -169,6 +170,18 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
|||||||
card_last4=display_last4,
|
card_last4=display_last4,
|
||||||
card_network=display_network,
|
card_network=display_network,
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
|
await user_billing_dal.upsert_user_payment_method(
|
||||||
|
session,
|
||||||
|
user_id=user_id,
|
||||||
|
provider_payment_method_id=pm_id,
|
||||||
|
provider="yookassa",
|
||||||
|
card_last4=display_last4,
|
||||||
|
card_network=display_network,
|
||||||
|
set_default=True,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logging.exception("Failed to persist multi-card YooKassa method from webhook")
|
||||||
except Exception:
|
except Exception:
|
||||||
logging.exception("Failed to persist YooKassa payment method from webhook")
|
logging.exception("Failed to persist YooKassa payment method from webhook")
|
||||||
updated_payment_record = await payment_dal.update_payment_status_by_db_id(
|
updated_payment_record = await payment_dal.update_payment_status_by_db_id(
|
||||||
@@ -241,10 +254,12 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
|||||||
if db_user and db_user.referred_by_id:
|
if db_user and db_user.referred_by_id:
|
||||||
inviter = await user_dal.get_user_by_id(
|
inviter = await user_dal.get_user_by_id(
|
||||||
session, db_user.referred_by_id)
|
session, db_user.referred_by_id)
|
||||||
if inviter and inviter.first_name:
|
if inviter:
|
||||||
inviter_name_display = inviter.first_name
|
safe_name = sanitize_display_name(inviter.first_name) if inviter.first_name else None
|
||||||
elif inviter and inviter.username:
|
if safe_name:
|
||||||
inviter_name_display = f"@{inviter.username}"
|
inviter_name_display = safe_name
|
||||||
|
elif inviter.username:
|
||||||
|
inviter_name_display = username_for_display(inviter.username, with_at=False)
|
||||||
|
|
||||||
details_message = _(
|
details_message = _(
|
||||||
"payment_successful_with_referral_bonus_full",
|
"payment_successful_with_referral_bonus_full",
|
||||||
@@ -277,7 +292,7 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
|||||||
details_message = _("payment_successful_error_details")
|
details_message = _("payment_successful_error_details")
|
||||||
|
|
||||||
details_markup = get_connect_and_main_keyboard(
|
details_markup = get_connect_and_main_keyboard(
|
||||||
user_lang, i18n, settings, config_link
|
user_lang, i18n, settings, config_link, preserve_message=True
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
await bot.send_message(
|
await bot.send_message(
|
||||||
|
|||||||
+290
-23
@@ -7,16 +7,23 @@ from aiogram.fsm.context import FSMContext
|
|||||||
from typing import Optional, Union
|
from typing import Optional, Union
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from aiogram.exceptions import TelegramAPIError, TelegramBadRequest, TelegramForbiddenError
|
||||||
|
|
||||||
from db.dal import user_dal
|
from db.dal import user_dal
|
||||||
|
from db.models import User
|
||||||
|
|
||||||
from bot.keyboards.inline.user_keyboards import get_main_menu_inline_keyboard, get_language_selection_keyboard
|
from bot.keyboards.inline.user_keyboards import (
|
||||||
|
get_main_menu_inline_keyboard,
|
||||||
|
get_language_selection_keyboard,
|
||||||
|
get_channel_subscription_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
|
||||||
from bot.services.referral_service import ReferralService
|
from bot.services.referral_service import ReferralService
|
||||||
from bot.services.promo_code_service import PromoCodeService
|
from bot.services.promo_code_service import PromoCodeService
|
||||||
from config.settings import Settings
|
from config.settings import Settings
|
||||||
from bot.middlewares.i18n import JsonI18n
|
from bot.middlewares.i18n import JsonI18n
|
||||||
|
from bot.utils.text_sanitizer import sanitize_username, sanitize_display_name
|
||||||
|
|
||||||
router = Router(name="user_start_router")
|
router = Router(name="user_start_router")
|
||||||
|
|
||||||
@@ -50,6 +57,7 @@ async def send_main_menu(target_event: Union[types.Message,
|
|||||||
pass
|
pass
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
||||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||||
|
|
||||||
show_trial_button_in_menu = False
|
show_trial_button_in_menu = False
|
||||||
@@ -115,6 +123,184 @@ async def send_main_menu(target_event: Union[types.Message,
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_required_channel_subscription(
|
||||||
|
event: Union[types.Message, types.CallbackQuery],
|
||||||
|
settings: Settings,
|
||||||
|
i18n: Optional[JsonI18n],
|
||||||
|
current_lang: str,
|
||||||
|
session: AsyncSession,
|
||||||
|
db_user: Optional[User] = None) -> bool:
|
||||||
|
"""
|
||||||
|
Verify that the user is a member of the required channel (if configured).
|
||||||
|
Returns True when access can proceed, False when user must subscribe first.
|
||||||
|
"""
|
||||||
|
required_channel_id = settings.REQUIRED_CHANNEL_ID
|
||||||
|
if not required_channel_id:
|
||||||
|
return True
|
||||||
|
|
||||||
|
if isinstance(event, types.CallbackQuery):
|
||||||
|
user_id = event.from_user.id
|
||||||
|
bot_instance: Optional[Bot] = getattr(event, "bot", None)
|
||||||
|
if bot_instance is None and event.message:
|
||||||
|
bot_instance = event.message.bot
|
||||||
|
message_obj: Optional[types.Message] = event.message
|
||||||
|
else:
|
||||||
|
user_id = event.from_user.id
|
||||||
|
bot_instance = event.bot if hasattr(event, "bot") else None
|
||||||
|
message_obj = event
|
||||||
|
|
||||||
|
if bot_instance is None:
|
||||||
|
logging.error(
|
||||||
|
"Channel subscription check: bot instance missing for user %s.", user_id
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
if user_id in settings.ADMIN_IDS:
|
||||||
|
return True
|
||||||
|
|
||||||
|
if db_user is None:
|
||||||
|
try:
|
||||||
|
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||||
|
except Exception as fetch_error:
|
||||||
|
logging.error(
|
||||||
|
"Channel subscription check: failed to fetch user %s: %s",
|
||||||
|
user_id,
|
||||||
|
fetch_error,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not db_user:
|
||||||
|
logging.warning(
|
||||||
|
"Required channel check skipped because user %s is not persisted yet.",
|
||||||
|
user_id,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
if (db_user.channel_subscription_verified
|
||||||
|
and db_user.channel_subscription_verified_for
|
||||||
|
== required_channel_id):
|
||||||
|
return True
|
||||||
|
|
||||||
|
def translate(key: str, **kwargs) -> str:
|
||||||
|
if i18n:
|
||||||
|
return i18n.gettext(current_lang, key, **kwargs)
|
||||||
|
return key
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
is_member = False
|
||||||
|
status_value = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
member = await bot_instance.get_chat_member(required_channel_id, user_id)
|
||||||
|
status = getattr(member, "status", None)
|
||||||
|
status_value = getattr(status, "value", status)
|
||||||
|
allowed_statuses = {"creator", "administrator", "member", "restricted"}
|
||||||
|
if status_value in allowed_statuses:
|
||||||
|
is_member = True
|
||||||
|
except TelegramBadRequest as bad_request:
|
||||||
|
logging.info(
|
||||||
|
"Required channel check: user %s not subscribed (details: %s)",
|
||||||
|
user_id,
|
||||||
|
bad_request,
|
||||||
|
)
|
||||||
|
except TelegramForbiddenError as forbidden_error:
|
||||||
|
logging.error(
|
||||||
|
"Required channel check failed due to insufficient permissions: %s",
|
||||||
|
forbidden_error,
|
||||||
|
)
|
||||||
|
error_text = translate("channel_subscription_check_failed")
|
||||||
|
if isinstance(event, types.CallbackQuery):
|
||||||
|
try:
|
||||||
|
await event.answer(error_text, show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if message_obj:
|
||||||
|
try:
|
||||||
|
await message_obj.answer(error_text)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
await event.answer(error_text)
|
||||||
|
return False
|
||||||
|
except TelegramAPIError as api_error:
|
||||||
|
logging.error(
|
||||||
|
"Required channel check failed for user %s: %s",
|
||||||
|
user_id,
|
||||||
|
api_error,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
error_text = translate("channel_subscription_check_failed")
|
||||||
|
if isinstance(event, types.CallbackQuery):
|
||||||
|
try:
|
||||||
|
await event.answer(error_text, show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if message_obj:
|
||||||
|
try:
|
||||||
|
await message_obj.answer(error_text)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
await event.answer(error_text)
|
||||||
|
return False
|
||||||
|
|
||||||
|
update_payload = {
|
||||||
|
"channel_subscription_checked_at": now,
|
||||||
|
"channel_subscription_verified_for": required_channel_id,
|
||||||
|
"channel_subscription_verified": is_member,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
await user_dal.update_user(session, user_id, update_payload)
|
||||||
|
except Exception as update_error:
|
||||||
|
logging.error(
|
||||||
|
"Failed to persist channel verification result for user %s: %s",
|
||||||
|
user_id,
|
||||||
|
update_error,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if is_member:
|
||||||
|
logging.info(
|
||||||
|
"User %s confirmed as member of required channel %s (status=%s).",
|
||||||
|
user_id,
|
||||||
|
required_channel_id,
|
||||||
|
status_value,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
keyboard = (get_channel_subscription_keyboard(
|
||||||
|
current_lang, i18n, settings.REQUIRED_CHANNEL_LINK
|
||||||
|
)
|
||||||
|
if i18n else None)
|
||||||
|
|
||||||
|
prompt_text = translate("channel_subscription_required")
|
||||||
|
|
||||||
|
if isinstance(event, types.CallbackQuery):
|
||||||
|
if keyboard and event.message:
|
||||||
|
try:
|
||||||
|
await event.message.edit_text(prompt_text, reply_markup=keyboard)
|
||||||
|
except Exception as edit_error:
|
||||||
|
logging.debug(
|
||||||
|
"Failed to edit prompt message for user %s: %s",
|
||||||
|
user_id,
|
||||||
|
edit_error,
|
||||||
|
)
|
||||||
|
if keyboard is None and message_obj:
|
||||||
|
try:
|
||||||
|
await message_obj.answer(prompt_text)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
await event.answer(prompt_text, show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
await event.answer(prompt_text, reply_markup=keyboard)
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
@router.message(CommandStart())
|
@router.message(CommandStart())
|
||||||
@router.message(CommandStart(magic=F.args.regexp(r"^ref_(\d+)$").as_("ref_match")))
|
@router.message(CommandStart(magic=F.args.regexp(r"^ref_(\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")))
|
||||||
@@ -152,13 +338,17 @@ async def start_command_handler(message: types.Message,
|
|||||||
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}")
|
||||||
|
|
||||||
|
sanitized_username = sanitize_username(user.username)
|
||||||
|
sanitized_first_name = sanitize_display_name(user.first_name)
|
||||||
|
sanitized_last_name = sanitize_display_name(user.last_name)
|
||||||
|
|
||||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||||
if not db_user:
|
if not db_user:
|
||||||
user_data_to_create = {
|
user_data_to_create = {
|
||||||
"user_id": user_id,
|
"user_id": user_id,
|
||||||
"username": user.username,
|
"username": sanitized_username,
|
||||||
"first_name": user.first_name,
|
"first_name": sanitized_first_name,
|
||||||
"last_name": user.last_name,
|
"last_name": sanitized_last_name,
|
||||||
"language_code": current_lang,
|
"language_code": current_lang,
|
||||||
"referred_by_id": referred_by_user_id,
|
"referred_by_id": referred_by_user_id,
|
||||||
"registration_date": datetime.now(timezone.utc)
|
"registration_date": datetime.now(timezone.utc)
|
||||||
@@ -167,6 +357,17 @@ async def start_command_handler(message: types.Message,
|
|||||||
db_user, created = await user_dal.create_user(session, user_data_to_create)
|
db_user, created = await user_dal.create_user(session, user_data_to_create)
|
||||||
|
|
||||||
if created:
|
if created:
|
||||||
|
try:
|
||||||
|
await session.commit()
|
||||||
|
except Exception as commit_error:
|
||||||
|
await session.rollback()
|
||||||
|
logging.error(
|
||||||
|
f"Failed to commit new user {user_id}: {commit_error}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
await message.answer(_("error_occurred_processing_request"))
|
||||||
|
return
|
||||||
|
|
||||||
logging.info(
|
logging.info(
|
||||||
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'}."
|
||||||
)
|
)
|
||||||
@@ -177,8 +378,8 @@ async def start_command_handler(message: types.Message,
|
|||||||
notification_service = NotificationService(message.bot, settings, i18n)
|
notification_service = NotificationService(message.bot, settings, i18n)
|
||||||
await notification_service.notify_new_user_registration(
|
await notification_service.notify_new_user_registration(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
username=user.username,
|
username=sanitized_username,
|
||||||
first_name=user.first_name,
|
first_name=sanitized_first_name,
|
||||||
referred_by_id=referred_by_user_id
|
referred_by_id=referred_by_user_id
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -203,12 +404,12 @@ async def start_command_handler(message: types.Message,
|
|||||||
is_active_now = False
|
is_active_now = False
|
||||||
if not is_active_now:
|
if not is_active_now:
|
||||||
update_payload["referred_by_id"] = referred_by_user_id
|
update_payload["referred_by_id"] = referred_by_user_id
|
||||||
if user.username != db_user.username:
|
if sanitized_username != db_user.username:
|
||||||
update_payload["username"] = user.username
|
update_payload["username"] = sanitized_username
|
||||||
if user.first_name != db_user.first_name:
|
if sanitized_first_name != db_user.first_name:
|
||||||
update_payload["first_name"] = user.first_name
|
update_payload["first_name"] = sanitized_first_name
|
||||||
if user.last_name != db_user.last_name:
|
if sanitized_last_name != db_user.last_name:
|
||||||
update_payload["last_name"] = user.last_name
|
update_payload["last_name"] = sanitized_last_name
|
||||||
|
|
||||||
if update_payload:
|
if update_payload:
|
||||||
try:
|
try:
|
||||||
@@ -238,55 +439,60 @@ async def start_command_handler(message: types.Message,
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
if not await ensure_required_channel_subscription(message, settings, i18n,
|
||||||
|
current_lang, session,
|
||||||
|
db_user):
|
||||||
|
return
|
||||||
|
|
||||||
# Send welcome message if not disabled
|
# Send welcome message if not disabled
|
||||||
if not settings.DISABLE_WELCOME_MESSAGE:
|
if not settings.DISABLE_WELCOME_MESSAGE:
|
||||||
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
|
||||||
if promo_code_to_apply:
|
if promo_code_to_apply:
|
||||||
try:
|
try:
|
||||||
from bot.services.promo_code_service import PromoCodeService
|
from bot.services.promo_code_service import PromoCodeService
|
||||||
promo_code_service = PromoCodeService(settings, subscription_service, message.bot, i18n)
|
promo_code_service = PromoCodeService(settings, subscription_service, message.bot, i18n)
|
||||||
|
|
||||||
success, result = await promo_code_service.apply_promo_code(
|
success, result = await promo_code_service.apply_promo_code(
|
||||||
session, user_id, promo_code_to_apply, current_lang
|
session, user_id, promo_code_to_apply, current_lang
|
||||||
)
|
)
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
await session.commit()
|
await session.commit()
|
||||||
logging.info(f"Auto-applied promo code '{promo_code_to_apply}' for user {user_id}")
|
logging.info(f"Auto-applied promo code '{promo_code_to_apply}' for user {user_id}")
|
||||||
|
|
||||||
# Get updated subscription details
|
# Get updated subscription details
|
||||||
active = await subscription_service.get_active_subscription_details(session, user_id)
|
active = await subscription_service.get_active_subscription_details(session, user_id)
|
||||||
config_link = active.get("config_link") if active else None
|
config_link = active.get("config_link") if active else None
|
||||||
config_link = config_link or _("config_link_not_available")
|
config_link = config_link or _("config_link_not_available")
|
||||||
|
|
||||||
new_end_date = result if isinstance(result, datetime) else None
|
new_end_date = result if isinstance(result, datetime) else None
|
||||||
|
|
||||||
promo_success_text = _(
|
promo_success_text = _(
|
||||||
"promo_code_applied_success_full",
|
"promo_code_applied_success_full",
|
||||||
end_date=(new_end_date.strftime("%d.%m.%Y %H:%M:%S") if new_end_date else "N/A"),
|
end_date=(new_end_date.strftime("%d.%m.%Y %H:%M:%S") if new_end_date else "N/A"),
|
||||||
config_link=config_link,
|
config_link=config_link,
|
||||||
)
|
)
|
||||||
|
|
||||||
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
|
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
|
||||||
await message.answer(
|
await message.answer(
|
||||||
promo_success_text,
|
promo_success_text,
|
||||||
reply_markup=get_connect_and_main_keyboard(current_lang, i18n, settings, config_link),
|
reply_markup=get_connect_and_main_keyboard(current_lang, i18n, settings, config_link),
|
||||||
parse_mode="HTML"
|
parse_mode="HTML"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Don't show main menu if promo was successfully applied
|
# Don't show main menu if promo was successfully applied
|
||||||
return
|
return
|
||||||
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}")
|
||||||
# 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()
|
||||||
|
|
||||||
await send_main_menu(message,
|
await send_main_menu(message,
|
||||||
settings,
|
settings,
|
||||||
i18n_data,
|
i18n_data,
|
||||||
@@ -295,6 +501,57 @@ async def start_command_handler(message: types.Message,
|
|||||||
is_edit=False)
|
is_edit=False)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data == "channel_subscription:verify")
|
||||||
|
async def verify_channel_subscription_callback(
|
||||||
|
callback: types.CallbackQuery,
|
||||||
|
settings: Settings,
|
||||||
|
i18n_data: dict,
|
||||||
|
subscription_service: SubscriptionService,
|
||||||
|
session: AsyncSession):
|
||||||
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||||
|
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||||
|
|
||||||
|
db_user = await user_dal.get_user_by_id(session, callback.from_user.id)
|
||||||
|
|
||||||
|
verified = await ensure_required_channel_subscription(
|
||||||
|
callback, settings, i18n, current_lang, session, db_user)
|
||||||
|
if not verified:
|
||||||
|
return
|
||||||
|
|
||||||
|
if db_user and db_user.language_code:
|
||||||
|
current_lang = db_user.language_code
|
||||||
|
i18n_data["current_language"] = current_lang
|
||||||
|
|
||||||
|
if i18n:
|
||||||
|
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||||
|
else:
|
||||||
|
_ = lambda key, **kwargs: key
|
||||||
|
|
||||||
|
if not settings.DISABLE_WELCOME_MESSAGE:
|
||||||
|
welcome_text = _(key="welcome",
|
||||||
|
user_name=hd.quote(callback.from_user.full_name))
|
||||||
|
if callback.message:
|
||||||
|
await callback.message.answer(welcome_text)
|
||||||
|
else:
|
||||||
|
fallback_bot: Optional[Bot] = getattr(callback, "bot", None)
|
||||||
|
if fallback_bot:
|
||||||
|
await fallback_bot.send_message(callback.from_user.id,
|
||||||
|
welcome_text)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await callback.answer(_(key="channel_subscription_verified_success"),
|
||||||
|
show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
await send_main_menu(callback,
|
||||||
|
settings,
|
||||||
|
i18n_data,
|
||||||
|
subscription_service,
|
||||||
|
session,
|
||||||
|
is_edit=bool(callback.message))
|
||||||
|
|
||||||
|
|
||||||
@router.message(Command("language"))
|
@router.message(Command("language"))
|
||||||
@router.callback_query(F.data == "main_action:language")
|
@router.callback_query(F.data == "main_action:language")
|
||||||
async def language_command_handler(
|
async def language_command_handler(
|
||||||
@@ -399,10 +656,13 @@ async def main_action_callback_handler(
|
|||||||
await user_subscription_handlers.display_subscription_options(
|
await user_subscription_handlers.display_subscription_options(
|
||||||
callback, i18n_data, settings, session)
|
callback, i18n_data, settings, session)
|
||||||
elif action == "my_subscription":
|
elif action == "my_subscription":
|
||||||
|
|
||||||
await user_subscription_handlers.my_subscription_command_handler(
|
await user_subscription_handlers.my_subscription_command_handler(
|
||||||
callback, i18n_data, settings, panel_service, subscription_service,
|
callback, i18n_data, settings, panel_service, subscription_service,
|
||||||
session, bot)
|
session, bot)
|
||||||
|
elif action == "my_devices":
|
||||||
|
await user_subscription_handlers.my_devices_command_handler(
|
||||||
|
callback, i18n_data, settings, panel_service, subscription_service,
|
||||||
|
session, bot)
|
||||||
elif action == "referral":
|
elif action == "referral":
|
||||||
await user_referral_handlers.referral_command_handler(
|
await user_referral_handlers.referral_command_handler(
|
||||||
callback, settings, i18n_data, referral_service, bot, session)
|
callback, settings, i18n_data, referral_service, bot, session)
|
||||||
@@ -422,6 +682,13 @@ async def main_action_callback_handler(
|
|||||||
subscription_service,
|
subscription_service,
|
||||||
session,
|
session,
|
||||||
is_edit=True)
|
is_edit=True)
|
||||||
|
elif action == "back_to_main_keep":
|
||||||
|
await send_main_menu(callback,
|
||||||
|
settings,
|
||||||
|
i18n_data,
|
||||||
|
subscription_service,
|
||||||
|
session,
|
||||||
|
is_edit=False)
|
||||||
else:
|
else:
|
||||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||||
_ = lambda key, **kwargs: i18n.gettext(
|
_ = lambda key, **kwargs: i18n.gettext(
|
||||||
|
|||||||
@@ -12,6 +12,6 @@ router.include_router(payments.router)
|
|||||||
router.include_router(payment_methods.router)
|
router.include_router(payment_methods.router)
|
||||||
|
|
||||||
# Re-export commonly used entrypoints for backward compatibility
|
# Re-export commonly used entrypoints for backward compatibility
|
||||||
from .core import display_subscription_options, my_subscription_command_handler # noqa: E402,F401
|
from .core import display_subscription_options, my_subscription_command_handler, my_devices_command_handler # noqa: E402,F401
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ from bot.keyboards.inline.user_keyboards import (
|
|||||||
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
|
||||||
from bot.middlewares.i18n import JsonI18n
|
from bot.middlewares.i18n import JsonI18n
|
||||||
from db.dal import subscription_dal
|
from db.dal import subscription_dal, user_billing_dal
|
||||||
from db.models import Subscription
|
from db.models import Subscription
|
||||||
|
|
||||||
router = Router(name="user_subscription_core_router")
|
router = Router(name="user_subscription_core_router")
|
||||||
@@ -173,6 +173,57 @@ async def my_subscription_command_handler(
|
|||||||
)
|
)
|
||||||
])
|
])
|
||||||
|
|
||||||
|
if settings.MY_DEVICES_SECTION_ENABLED:
|
||||||
|
max_devices_value = active.get("max_devices")
|
||||||
|
max_devices_display = get_text("devices_unlimited_label")
|
||||||
|
if max_devices_value not in (None, 0):
|
||||||
|
try:
|
||||||
|
max_devices_int = int(max_devices_value)
|
||||||
|
if max_devices_int >= 0:
|
||||||
|
max_devices_display = str(max_devices_int)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
max_devices_display = str(max_devices_value)
|
||||||
|
current_devices_display = "?"
|
||||||
|
user_uuid = active.get("user_id")
|
||||||
|
devices_response = None
|
||||||
|
if user_uuid:
|
||||||
|
try:
|
||||||
|
devices_response = await panel_service.get_user_devices(user_uuid)
|
||||||
|
except Exception:
|
||||||
|
logging.exception("Failed to load devices for user %s", user_uuid)
|
||||||
|
if devices_response:
|
||||||
|
devices_count: Optional[int] = None
|
||||||
|
if isinstance(devices_response, dict):
|
||||||
|
devices_list = devices_response.get("devices")
|
||||||
|
if isinstance(devices_list, list):
|
||||||
|
devices_count = len(devices_list)
|
||||||
|
elif isinstance(devices_list, int):
|
||||||
|
devices_count = devices_list
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
devices_count = len(devices_list) # type: ignore[arg-type]
|
||||||
|
except Exception:
|
||||||
|
devices_count = None
|
||||||
|
if devices_count is None:
|
||||||
|
total_value = devices_response.get("total")
|
||||||
|
if isinstance(total_value, int):
|
||||||
|
devices_count = total_value
|
||||||
|
elif isinstance(devices_response, list):
|
||||||
|
devices_count = len(devices_response)
|
||||||
|
if devices_count is not None:
|
||||||
|
current_devices_display = str(devices_count)
|
||||||
|
devices_button_text = get_text(
|
||||||
|
"devices_button",
|
||||||
|
current_devices=current_devices_display,
|
||||||
|
max_devices=max_devices_display,
|
||||||
|
)
|
||||||
|
prepend_rows.append([
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=devices_button_text,
|
||||||
|
callback_data="main_action:my_devices",
|
||||||
|
)
|
||||||
|
])
|
||||||
|
|
||||||
# 2) Auto-renew toggle (if supported and not tribute)
|
# 2) Auto-renew toggle (if supported and not tribute)
|
||||||
if local_sub and local_sub.provider != "tribute" and getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
|
if local_sub and local_sub.provider != "tribute" and getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
|
||||||
toggle_text = (
|
toggle_text = (
|
||||||
@@ -216,6 +267,161 @@ async def my_subscription_command_handler(
|
|||||||
await target.answer(text + tribute_hint, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True)
|
await target.answer(text + tribute_hint, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data == "main_action:my_devices")
|
||||||
|
async def my_devices_command_handler(
|
||||||
|
event: Union[types.Message, types.CallbackQuery],
|
||||||
|
i18n_data: dict,
|
||||||
|
settings: Settings,
|
||||||
|
panel_service: PanelApiService,
|
||||||
|
subscription_service: SubscriptionService,
|
||||||
|
session: AsyncSession,
|
||||||
|
bot: Bot,
|
||||||
|
):
|
||||||
|
target = event.message if isinstance(event, types.CallbackQuery) else event
|
||||||
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||||
|
i18n: JsonI18n = i18n_data.get("i18n_instance")
|
||||||
|
get_text = lambda key, **kw: i18n.gettext(current_lang, key, **kw)
|
||||||
|
|
||||||
|
if not i18n or not target:
|
||||||
|
if isinstance(event, types.Message):
|
||||||
|
await event.answer(get_text("error_occurred_try_again"))
|
||||||
|
return
|
||||||
|
|
||||||
|
if not settings.MY_DEVICES_SECTION_ENABLED:
|
||||||
|
if isinstance(event, types.CallbackQuery):
|
||||||
|
try:
|
||||||
|
await event.answer(get_text("my_devices_feature_disabled"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
await target.answer(get_text("my_devices_feature_disabled"))
|
||||||
|
return
|
||||||
|
|
||||||
|
# TODO: context?
|
||||||
|
active = await subscription_service.get_active_subscription_details(session, event.from_user.id)
|
||||||
|
if not active or not active.get("user_id"):
|
||||||
|
message = get_text("subscription_not_active")
|
||||||
|
if isinstance(event, types.CallbackQuery):
|
||||||
|
try:
|
||||||
|
await event.answer(message, show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
await target.answer(message)
|
||||||
|
return
|
||||||
|
|
||||||
|
devices = await panel_service.get_user_devices(active.get("user_id")) if active else None
|
||||||
|
if not devices:
|
||||||
|
if isinstance(event, types.CallbackQuery):
|
||||||
|
try:
|
||||||
|
await event.answer(get_text("no_devices_found"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
await target.answer(get_text("no_devices_found"))
|
||||||
|
return
|
||||||
|
|
||||||
|
max_devices_value = active.get("max_devices")
|
||||||
|
max_devices_display = get_text("devices_unlimited_label")
|
||||||
|
if max_devices_value not in (None, 0):
|
||||||
|
try:
|
||||||
|
max_devices_int = int(max_devices_value)
|
||||||
|
if max_devices_int >= 0:
|
||||||
|
max_devices_display = str(max_devices_int)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
max_devices_display = str(max_devices_value)
|
||||||
|
|
||||||
|
if not devices or not devices.get('devices') or len(devices.get('devices')) == 0:
|
||||||
|
text = get_text("no_devices_details_found_message", max_devices=max_devices_display)
|
||||||
|
else:
|
||||||
|
devices_list = []
|
||||||
|
current_devices = len(devices.get('devices') or [])
|
||||||
|
for index, device in enumerate(devices.get('devices') or [], start=1):
|
||||||
|
device_model = device.get('deviceModel') or None
|
||||||
|
platform = device.get('platform') or None
|
||||||
|
user_agent = device.get('userAgent') or None
|
||||||
|
os_version = device.get('osVersion') or None
|
||||||
|
created_at = device.get('createdAt')
|
||||||
|
hwid = device.get('hwid')
|
||||||
|
created_at_str = datetime.fromisoformat(created_at).strftime("%d.%m.%Y %H:%M")
|
||||||
|
|
||||||
|
device_details = get_text("device_details", index=index, device_model=device_model, platform=platform, os_version=os_version, created_at_str=created_at_str, user_agent=user_agent, hwid=hwid)
|
||||||
|
devices_list.append(device_details)
|
||||||
|
|
||||||
|
text = get_text("my_devices_details", devices="\n\n".join(devices_list), current_devices=current_devices, max_devices=max_devices_display)
|
||||||
|
|
||||||
|
base_markup = get_back_to_main_menu_markup(current_lang, i18n, callback_data="main_action:my_subscription")
|
||||||
|
kb = base_markup.inline_keyboard
|
||||||
|
|
||||||
|
devices_kb = []
|
||||||
|
for index, device in enumerate(devices.get('devices') or [], start=1):
|
||||||
|
hwid = device.get('hwid')
|
||||||
|
device_button_text = get_text("disconnect_device_button", hwid=hwid, index=index)
|
||||||
|
|
||||||
|
devices_kb.append([InlineKeyboardButton(text=device_button_text, callback_data=f"disconnect_device:{hwid}")])
|
||||||
|
kb = devices_kb + kb
|
||||||
|
markup = InlineKeyboardMarkup(inline_keyboard=kb)
|
||||||
|
|
||||||
|
if isinstance(event, types.CallbackQuery):
|
||||||
|
try:
|
||||||
|
await event.answer()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
await event.message.edit_text(text, reply_markup=markup)
|
||||||
|
except Exception:
|
||||||
|
await event.message.answer(text, reply_markup=markup)
|
||||||
|
else:
|
||||||
|
await target.answer(text, reply_markup=markup)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("disconnect_device:"))
|
||||||
|
async def disconnect_device_handler(
|
||||||
|
callback: types.CallbackQuery,
|
||||||
|
settings: Settings,
|
||||||
|
i18n_data: dict,
|
||||||
|
session: AsyncSession,
|
||||||
|
subscription_service: SubscriptionService,
|
||||||
|
panel_service: PanelApiService,
|
||||||
|
bot: Bot,
|
||||||
|
):
|
||||||
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||||
|
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||||
|
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||||
|
|
||||||
|
if not settings.MY_DEVICES_SECTION_ENABLED:
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("my_devices_feature_disabled"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
_, hwid = callback.data.split(":", 1)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
active = await subscription_service.get_active_subscription_details(session, callback.from_user.id)
|
||||||
|
if not active:
|
||||||
|
await callback.answer(get_text("subscription_not_active"), show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
success = await panel_service.disconnect_device(active.get("user_id"), hwid)
|
||||||
|
if not success:
|
||||||
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||||
|
return
|
||||||
|
await session.commit()
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("device_disconnected"))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
await my_devices_command_handler(callback, i18n_data, settings, panel_service, subscription_service, session, bot)
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data.startswith("toggle_autorenew:"))
|
@router.callback_query(F.data.startswith("toggle_autorenew:"))
|
||||||
async def toggle_autorenew_handler(
|
async def toggle_autorenew_handler(
|
||||||
callback: types.CallbackQuery,
|
callback: types.CallbackQuery,
|
||||||
@@ -249,6 +455,14 @@ async def toggle_autorenew_handler(
|
|||||||
if sub.provider == "tribute":
|
if sub.provider == "tribute":
|
||||||
await callback.answer(get_text("subscription_autorenew_not_supported_for_tribute"), show_alert=True)
|
await callback.answer(get_text("subscription_autorenew_not_supported_for_tribute"), show_alert=True)
|
||||||
return
|
return
|
||||||
|
if enable:
|
||||||
|
has_saved_card = await user_billing_dal.user_has_saved_payment_method(session, callback.from_user.id)
|
||||||
|
if not has_saved_card:
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("autorenew_enable_requires_card"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
# Show confirmation popup and inline buttons
|
# Show confirmation popup and inline buttons
|
||||||
confirm_text = get_text("autorenew_confirm_enable") if enable else get_text("autorenew_confirm_disable")
|
confirm_text = get_text("autorenew_confirm_enable") if enable else get_text("autorenew_confirm_disable")
|
||||||
@@ -299,6 +513,18 @@ async def confirm_autorenew_handler(
|
|||||||
if sub.provider == "tribute":
|
if sub.provider == "tribute":
|
||||||
await callback.answer(get_text("subscription_autorenew_not_supported_for_tribute"), show_alert=True)
|
await callback.answer(get_text("subscription_autorenew_not_supported_for_tribute"), show_alert=True)
|
||||||
return
|
return
|
||||||
|
if enable:
|
||||||
|
has_saved_card = await user_billing_dal.user_has_saved_payment_method(session, callback.from_user.id)
|
||||||
|
if not has_saved_card:
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("autorenew_enable_requires_card"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
await my_subscription_command_handler(callback, i18n_data, settings, panel_service, subscription_service, session, bot)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
await subscription_dal.update_subscription(session, sub.subscription_id, {"auto_renew_enabled": enable})
|
await subscription_dal.update_subscription(session, sub.subscription_id, {"auto_renew_enabled": enable})
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@@ -359,5 +585,3 @@ async def connect_command_handler(
|
|||||||
):
|
):
|
||||||
logging.info(f"User {message.from_user.id} used /connect command.")
|
logging.info(f"User {message.from_user.id} used /connect command.")
|
||||||
await my_subscription_command_handler(message, i18n_data, settings, panel_service, subscription_service, session, bot)
|
await my_subscription_command_handler(message, i18n_data, settings, panel_service, subscription_service, session, bot)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -59,13 +59,15 @@ def get_user_management_keyboard(i18n_instance, lang: str) -> InlineKeyboardMark
|
|||||||
builder = InlineKeyboardBuilder()
|
builder = InlineKeyboardBuilder()
|
||||||
|
|
||||||
builder.button(text=_(key="admin_users_management_button"),
|
builder.button(text=_(key="admin_users_management_button"),
|
||||||
callback_data="admin_action:users_management")
|
callback_data="admin_action:users_list:0")
|
||||||
|
builder.button(text=_(key="admin_users_search_button"),
|
||||||
|
callback_data="admin_action:users_search_prompt")
|
||||||
builder.button(text=_(key="admin_ban_management_section"),
|
builder.button(text=_(key="admin_ban_management_section"),
|
||||||
callback_data="admin_section:ban_management")
|
callback_data="admin_section:ban_management")
|
||||||
|
|
||||||
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)
|
builder.adjust(2, 1, 1)
|
||||||
return builder.as_markup()
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
@@ -305,6 +307,68 @@ def get_banned_users_keyboard(banned_users: List[User], current_page: int,
|
|||||||
return builder.as_markup()
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
|
def get_users_list_keyboard(users: List[User], current_page: int,
|
||||||
|
total_users: int, i18n_instance, lang: str,
|
||||||
|
page_size: int = 15) -> InlineKeyboardMarkup:
|
||||||
|
"""Generate keyboard for paginated user list"""
|
||||||
|
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
|
||||||
|
# Add user buttons
|
||||||
|
for user in users:
|
||||||
|
user_display_parts = []
|
||||||
|
if user.username:
|
||||||
|
user_display_parts.append(f"@{user.username}")
|
||||||
|
user_display_parts.append(f"ID: {user.user_id}")
|
||||||
|
if user.first_name:
|
||||||
|
user_display_parts.append(f"- {user.first_name}")
|
||||||
|
|
||||||
|
button_text = " ".join(user_display_parts)
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=button_text,
|
||||||
|
callback_data=f"admin_user_card_from_list:{user.user_id}:{current_page}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Pagination buttons
|
||||||
|
if total_users > page_size:
|
||||||
|
total_pages = math.ceil(total_users / page_size)
|
||||||
|
pagination_buttons = []
|
||||||
|
if current_page > 0:
|
||||||
|
pagination_buttons.append(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=_("prev_page_button"),
|
||||||
|
callback_data=f"admin_action:users_list:{current_page - 1}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
pagination_buttons.append(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=f"{current_page + 1}/{total_pages}",
|
||||||
|
callback_data="stub_page_display"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if current_page < total_pages - 1:
|
||||||
|
pagination_buttons.append(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=_("next_page_button"),
|
||||||
|
callback_data=f"admin_action:users_list:{current_page + 1}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if pagination_buttons:
|
||||||
|
builder.row(*pagination_buttons)
|
||||||
|
|
||||||
|
# Back button
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=_("back_to_user_management_button"),
|
||||||
|
callback_data="admin_section:user_management"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
def get_user_card_keyboard(user_id: int,
|
def get_user_card_keyboard(user_id: int,
|
||||||
is_banned: bool,
|
is_banned: bool,
|
||||||
i18n_instance,
|
i18n_instance,
|
||||||
@@ -320,6 +384,13 @@ def get_user_card_keyboard(user_id: int,
|
|||||||
builder.button(
|
builder.button(
|
||||||
text=_(key="user_card_ban_button"),
|
text=_(key="user_card_ban_button"),
|
||||||
callback_data=f"admin_ban_confirm:{user_id}:{banned_list_page}")
|
callback_data=f"admin_ban_confirm:{user_id}:{banned_list_page}")
|
||||||
|
builder.button(
|
||||||
|
text=_(
|
||||||
|
key="user_card_open_profile_button",
|
||||||
|
default="👤 Open profile"
|
||||||
|
),
|
||||||
|
url=f"tg://user?id={user_id}"
|
||||||
|
)
|
||||||
builder.button(
|
builder.button(
|
||||||
text=_(key="user_card_back_to_banned_list_button"),
|
text=_(key="user_card_back_to_banned_list_button"),
|
||||||
callback_data=f"admin_action:view_banned:{banned_list_page}")
|
callback_data=f"admin_action:view_banned:{banned_list_page}")
|
||||||
|
|||||||
@@ -118,14 +118,17 @@ def get_payment_method_keyboard(months: int, price: float,
|
|||||||
i18n_instance, settings: Settings) -> InlineKeyboardMarkup:
|
i18n_instance, settings: Settings) -> InlineKeyboardMarkup:
|
||||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||||
builder = InlineKeyboardBuilder()
|
builder = InlineKeyboardBuilder()
|
||||||
if settings.STARS_ENABLED and stars_price is not None:
|
if settings.FREEKASSA_ENABLED:
|
||||||
builder.button(text=_("pay_with_stars_button"),
|
builder.button(text=_("pay_with_sbp_button"),
|
||||||
callback_data=f"pay_stars:{months}:{stars_price}")
|
callback_data=f"pay_fk:{months}:{price}")
|
||||||
if settings.TRIBUTE_ENABLED and tribute_url:
|
|
||||||
builder.button(text=_("pay_with_tribute_button"), url=tribute_url)
|
|
||||||
if settings.YOOKASSA_ENABLED:
|
if settings.YOOKASSA_ENABLED:
|
||||||
builder.button(text=_("pay_with_yookassa_button"),
|
builder.button(text=_("pay_with_yookassa_button"),
|
||||||
callback_data=f"pay_yk:{months}:{price}")
|
callback_data=f"pay_yk:{months}:{price}")
|
||||||
|
if settings.TRIBUTE_ENABLED and tribute_url:
|
||||||
|
builder.button(text=_("pay_with_tribute_button"), url=tribute_url)
|
||||||
|
if settings.STARS_ENABLED and stars_price is not None:
|
||||||
|
builder.button(text=_("pay_with_stars_button"),
|
||||||
|
callback_data=f"pay_stars:{months}:{stars_price}")
|
||||||
if settings.CRYPTOPAY_ENABLED:
|
if settings.CRYPTOPAY_ENABLED:
|
||||||
builder.button(text=_("pay_with_cryptopay_button"),
|
builder.button(text=_("pay_with_cryptopay_button"),
|
||||||
callback_data=f"pay_crypto:{months}:{price}")
|
callback_data=f"pay_crypto:{months}:{price}")
|
||||||
@@ -135,17 +138,115 @@ def get_payment_method_keyboard(months: int, price: float,
|
|||||||
return builder.as_markup()
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
def get_payment_url_keyboard(payment_url: str, lang: str,
|
def get_payment_url_keyboard(payment_url: str,
|
||||||
i18n_instance) -> InlineKeyboardMarkup:
|
lang: str,
|
||||||
|
i18n_instance,
|
||||||
|
back_callback: Optional[str] = None,
|
||||||
|
back_text_key: str = "back_to_main_menu_button"
|
||||||
|
) -> InlineKeyboardMarkup:
|
||||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||||
builder = InlineKeyboardBuilder()
|
builder = InlineKeyboardBuilder()
|
||||||
builder.button(text=_(key="pay_button"), url=payment_url)
|
builder.button(text=_(key="pay_button"), url=payment_url)
|
||||||
builder.button(text=_(key="back_to_main_menu_button"),
|
if back_callback:
|
||||||
callback_data="main_action:back_to_main")
|
builder.button(text=_(key=back_text_key), callback_data=back_callback)
|
||||||
|
else:
|
||||||
|
builder.button(text=_(key="back_to_main_menu_button"),
|
||||||
|
callback_data="main_action:back_to_main")
|
||||||
builder.adjust(1)
|
builder.adjust(1)
|
||||||
return builder.as_markup()
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
|
def get_yk_autopay_choice_keyboard(
|
||||||
|
months: int,
|
||||||
|
price: float,
|
||||||
|
lang: str,
|
||||||
|
i18n_instance,
|
||||||
|
has_saved_cards: bool = True,
|
||||||
|
) -> InlineKeyboardMarkup:
|
||||||
|
"""Keyboard for choosing between saved card charge or new card payment when auto-renew is enabled."""
|
||||||
|
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
price_str = str(price)
|
||||||
|
if has_saved_cards:
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=_(key="yookassa_autopay_pay_saved_card_button"),
|
||||||
|
callback_data=f"pay_yk_saved_list:{months}:{price_str}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=_(key="yookassa_autopay_pay_new_card_button"),
|
||||||
|
callback_data=f"pay_yk_new:{months}:{price_str}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=_(key="back_to_payment_methods_button"),
|
||||||
|
callback_data=f"subscribe_period:{months}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
|
def get_yk_saved_cards_keyboard(
|
||||||
|
cards: List[Tuple[str, str]],
|
||||||
|
months: int,
|
||||||
|
price: float,
|
||||||
|
lang: str,
|
||||||
|
i18n_instance,
|
||||||
|
page: int = 0,
|
||||||
|
) -> InlineKeyboardMarkup:
|
||||||
|
"""Paginated keyboard for selecting a saved YooKassa card."""
|
||||||
|
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
per_page = 5
|
||||||
|
total = len(cards)
|
||||||
|
start = page * per_page
|
||||||
|
end = min(total, start + per_page)
|
||||||
|
price_str = str(price)
|
||||||
|
|
||||||
|
for method_id, title in cards[start:end]:
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=title,
|
||||||
|
callback_data=f"pay_yk_use_saved:{months}:{price_str}:{method_id}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
nav_buttons: List[InlineKeyboardButton] = []
|
||||||
|
if start > 0:
|
||||||
|
nav_buttons.append(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="⬅️",
|
||||||
|
callback_data=f"pay_yk_saved_list:{months}:{price_str}:{page-1}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if end < total:
|
||||||
|
nav_buttons.append(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="➡️",
|
||||||
|
callback_data=f"pay_yk_saved_list:{months}:{price_str}:{page+1}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if nav_buttons:
|
||||||
|
builder.row(*nav_buttons)
|
||||||
|
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=_(key="yookassa_autopay_pay_new_card_button"),
|
||||||
|
callback_data=f"pay_yk_new:{months}:{price_str}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=_(key="back_to_autopay_method_choice_button"),
|
||||||
|
callback_data=f"pay_yk:{months}:{price_str}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
def get_referral_link_keyboard(lang: str,
|
def get_referral_link_keyboard(lang: str,
|
||||||
i18n_instance) -> InlineKeyboardMarkup:
|
i18n_instance) -> InlineKeyboardMarkup:
|
||||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||||
@@ -159,11 +260,16 @@ def get_referral_link_keyboard(lang: str,
|
|||||||
|
|
||||||
|
|
||||||
def get_back_to_main_menu_markup(lang: str,
|
def get_back_to_main_menu_markup(lang: str,
|
||||||
i18n_instance) -> InlineKeyboardMarkup:
|
i18n_instance,
|
||||||
|
callback_data: Optional[str] = None) -> InlineKeyboardMarkup:
|
||||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||||
builder = InlineKeyboardBuilder()
|
builder = InlineKeyboardBuilder()
|
||||||
builder.button(text=_(key="back_to_main_menu_button"),
|
if callback_data:
|
||||||
callback_data="main_action:back_to_main")
|
builder.button(text=_(key="back_to_main_menu_button"),
|
||||||
|
callback_data=callback_data)
|
||||||
|
else:
|
||||||
|
builder.button(text=_(key="back_to_main_menu_button"),
|
||||||
|
callback_data="main_action:back_to_main")
|
||||||
return builder.as_markup()
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
@@ -185,11 +291,49 @@ def get_user_banned_keyboard(support_link: Optional[str], lang: str,
|
|||||||
return builder.as_markup()
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
|
def get_channel_subscription_keyboard(
|
||||||
|
lang: str,
|
||||||
|
i18n_instance,
|
||||||
|
channel_link: Optional[str],
|
||||||
|
include_check_button: bool = True) -> Optional[InlineKeyboardMarkup]:
|
||||||
|
"""
|
||||||
|
Return keyboard with buttons to open the required channel and trigger a subscription re-check.
|
||||||
|
"""
|
||||||
|
if i18n_instance is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
|
||||||
|
has_buttons = False
|
||||||
|
|
||||||
|
if channel_link:
|
||||||
|
builder.button(
|
||||||
|
text=_(key="channel_subscription_join_button"),
|
||||||
|
url=channel_link,
|
||||||
|
)
|
||||||
|
has_buttons = True
|
||||||
|
|
||||||
|
if include_check_button:
|
||||||
|
builder.button(
|
||||||
|
text=_(key="channel_subscription_verify_button"),
|
||||||
|
callback_data="channel_subscription:verify",
|
||||||
|
)
|
||||||
|
has_buttons = True
|
||||||
|
|
||||||
|
if not has_buttons:
|
||||||
|
return None
|
||||||
|
|
||||||
|
builder.adjust(1)
|
||||||
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
def get_connect_and_main_keyboard(
|
def get_connect_and_main_keyboard(
|
||||||
lang: str,
|
lang: str,
|
||||||
i18n_instance,
|
i18n_instance,
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
config_link: Optional[str]) -> InlineKeyboardMarkup:
|
config_link: Optional[str],
|
||||||
|
preserve_message: bool = False) -> InlineKeyboardMarkup:
|
||||||
"""Keyboard with a connect button and a back to main menu button."""
|
"""Keyboard with a connect button and a back to main menu button."""
|
||||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||||
builder = InlineKeyboardBuilder()
|
builder = InlineKeyboardBuilder()
|
||||||
@@ -213,10 +357,11 @@ def get_connect_and_main_keyboard(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
back_callback = "main_action:back_to_main_keep" if preserve_message else "main_action:back_to_main"
|
||||||
builder.row(
|
builder.row(
|
||||||
InlineKeyboardButton(
|
InlineKeyboardButton(
|
||||||
text=_("back_to_main_menu_button"),
|
text=_("back_to_main_menu_button"),
|
||||||
callback_data="main_action:back_to_main",
|
callback_data=back_callback,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -199,6 +199,7 @@ async def on_shutdown_configured(dispatcher: Dispatcher):
|
|||||||
for service_key in (
|
for service_key in (
|
||||||
"panel_service",
|
"panel_service",
|
||||||
"cryptopay_service",
|
"cryptopay_service",
|
||||||
|
"freekassa_service",
|
||||||
"tribute_service",
|
"tribute_service",
|
||||||
"panel_webhook_service",
|
"panel_webhook_service",
|
||||||
"yookassa_service",
|
"yookassa_service",
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import logging
|
||||||
|
from typing import Any, Awaitable, Callable, Dict, Optional
|
||||||
|
|
||||||
|
from aiogram import BaseMiddleware
|
||||||
|
from aiogram.types import (
|
||||||
|
CallbackQuery,
|
||||||
|
Message,
|
||||||
|
Update,
|
||||||
|
)
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from config.settings import Settings
|
||||||
|
from db.dal import user_dal
|
||||||
|
from bot.middlewares.i18n import JsonI18n
|
||||||
|
from bot.keyboards.inline.user_keyboards import get_channel_subscription_keyboard
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelSubscriptionMiddleware(BaseMiddleware):
|
||||||
|
"""
|
||||||
|
Blocks access to handlers for users who have not yet passed the required channel subscription check.
|
||||||
|
The /start command is allowed through so that the handler can re-run the verification.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, settings: Settings, i18n_instance: JsonI18n):
|
||||||
|
super().__init__()
|
||||||
|
self.settings = settings
|
||||||
|
self.i18n_main_instance = i18n_instance
|
||||||
|
|
||||||
|
async def __call__(
|
||||||
|
self,
|
||||||
|
handler: Callable[[Update, Dict[str, Any]], Awaitable[Any]],
|
||||||
|
event: Update,
|
||||||
|
data: Dict[str, Any],
|
||||||
|
) -> Any:
|
||||||
|
required_channel_id = self.settings.REQUIRED_CHANNEL_ID
|
||||||
|
if not required_channel_id:
|
||||||
|
return await handler(event, data)
|
||||||
|
|
||||||
|
event_user = data.get("event_from_user")
|
||||||
|
if not event_user or event_user.id in self.settings.ADMIN_IDS:
|
||||||
|
return await handler(event, data)
|
||||||
|
|
||||||
|
callback_query = event.callback_query
|
||||||
|
if (
|
||||||
|
callback_query
|
||||||
|
and callback_query.data
|
||||||
|
and callback_query.data == "channel_subscription:verify"
|
||||||
|
):
|
||||||
|
return await handler(event, data)
|
||||||
|
|
||||||
|
# Allow /start to reach the handler so the check can be re-run.
|
||||||
|
message_object: Optional[Message] = event.message
|
||||||
|
if (
|
||||||
|
message_object
|
||||||
|
and message_object.text
|
||||||
|
and message_object.text.startswith("/start")
|
||||||
|
):
|
||||||
|
return await handler(event, data)
|
||||||
|
|
||||||
|
session: AsyncSession = data["session"]
|
||||||
|
try:
|
||||||
|
db_user = await user_dal.get_user_by_id(session, event_user.id)
|
||||||
|
except Exception as db_error:
|
||||||
|
logging.error(
|
||||||
|
"ChannelSubscriptionMiddleware: failed to fetch user %s: %s",
|
||||||
|
event_user.id,
|
||||||
|
db_error,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
return await handler(event, data)
|
||||||
|
|
||||||
|
if not db_user:
|
||||||
|
return await handler(event, data)
|
||||||
|
|
||||||
|
if (
|
||||||
|
db_user.channel_subscription_verified
|
||||||
|
and db_user.channel_subscription_verified_for == required_channel_id
|
||||||
|
):
|
||||||
|
return await handler(event, data)
|
||||||
|
|
||||||
|
i18n_payload: Dict[str, Any] = data.get("i18n_data", {})
|
||||||
|
current_lang: str = i18n_payload.get(
|
||||||
|
"current_language", self.settings.DEFAULT_LANGUAGE
|
||||||
|
)
|
||||||
|
i18n_instance: Optional[JsonI18n] = i18n_payload.get(
|
||||||
|
"i18n_instance", self.i18n_main_instance
|
||||||
|
)
|
||||||
|
|
||||||
|
def translate(key: str) -> str:
|
||||||
|
if i18n_instance:
|
||||||
|
return i18n_instance.gettext(current_lang, key)
|
||||||
|
return key
|
||||||
|
|
||||||
|
keyboard = (
|
||||||
|
get_channel_subscription_keyboard(
|
||||||
|
current_lang, i18n_instance, self.settings.REQUIRED_CHANNEL_LINK
|
||||||
|
)
|
||||||
|
if i18n_instance
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
prompt_text = translate("channel_subscription_required")
|
||||||
|
|
||||||
|
if event.callback_query:
|
||||||
|
await self._handle_callback(event.callback_query, prompt_text, keyboard, data)
|
||||||
|
return
|
||||||
|
|
||||||
|
if message_object:
|
||||||
|
await message_object.answer(prompt_text, reply_markup=keyboard)
|
||||||
|
else:
|
||||||
|
bot_instance = data["bot"]
|
||||||
|
await bot_instance.send_message(
|
||||||
|
chat_id=event_user.id,
|
||||||
|
text=prompt_text,
|
||||||
|
reply_markup=keyboard,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
async def _handle_callback(
|
||||||
|
self,
|
||||||
|
callback: CallbackQuery,
|
||||||
|
prompt_text: str,
|
||||||
|
keyboard,
|
||||||
|
data: Dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
await callback.answer(prompt_text, show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if callback.message:
|
||||||
|
try:
|
||||||
|
await callback.message.answer(prompt_text, reply_markup=keyboard)
|
||||||
|
except Exception as send_error:
|
||||||
|
logging.error(
|
||||||
|
"ChannelSubscriptionMiddleware: failed to send prompt for callback in chat %s: %s",
|
||||||
|
callback.message.chat.id,
|
||||||
|
send_error,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
bot_instance = data["bot"]
|
||||||
|
await bot_instance.send_message(
|
||||||
|
chat_id=callback.from_user.id,
|
||||||
|
text=prompt_text,
|
||||||
|
reply_markup=keyboard,
|
||||||
|
)
|
||||||
@@ -6,6 +6,7 @@ from aiogram.types import Update, User as TgUser
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from db.dal import user_dal
|
from db.dal import user_dal
|
||||||
|
from bot.utils.text_sanitizer import sanitize_username, sanitize_display_name, username_for_display
|
||||||
|
|
||||||
|
|
||||||
class ProfileSyncMiddleware(BaseMiddleware):
|
class ProfileSyncMiddleware(BaseMiddleware):
|
||||||
@@ -24,12 +25,16 @@ class ProfileSyncMiddleware(BaseMiddleware):
|
|||||||
db_user = await user_dal.get_user_by_id(session, tg_user.id)
|
db_user = await user_dal.get_user_by_id(session, tg_user.id)
|
||||||
if db_user:
|
if db_user:
|
||||||
update_payload: Dict[str, Any] = {}
|
update_payload: Dict[str, Any] = {}
|
||||||
if db_user.username != tg_user.username:
|
sanitized_username = sanitize_username(tg_user.username)
|
||||||
update_payload["username"] = tg_user.username
|
sanitized_first_name = sanitize_display_name(tg_user.first_name)
|
||||||
if db_user.first_name != tg_user.first_name:
|
sanitized_last_name = sanitize_display_name(tg_user.last_name)
|
||||||
update_payload["first_name"] = tg_user.first_name
|
|
||||||
if db_user.last_name != tg_user.last_name:
|
if db_user.username != sanitized_username:
|
||||||
update_payload["last_name"] = tg_user.last_name
|
update_payload["username"] = sanitized_username
|
||||||
|
if db_user.first_name != sanitized_first_name:
|
||||||
|
update_payload["first_name"] = sanitized_first_name
|
||||||
|
if db_user.last_name != sanitized_last_name:
|
||||||
|
update_payload["last_name"] = sanitized_last_name
|
||||||
|
|
||||||
if update_payload:
|
if update_payload:
|
||||||
await user_dal.update_user(session, tg_user.id, update_payload)
|
await user_dal.update_user(session, tg_user.id, update_payload)
|
||||||
@@ -42,10 +47,10 @@ class ProfileSyncMiddleware(BaseMiddleware):
|
|||||||
panel_service = data.get("panel_service")
|
panel_service = data.get("panel_service")
|
||||||
if panel_service and db_user.panel_user_uuid:
|
if panel_service and db_user.panel_user_uuid:
|
||||||
description_text = "\n".join([
|
description_text = "\n".join([
|
||||||
tg_user.username or "",
|
username_for_display(tg_user.username, with_at=False) if sanitized_username is not None else "",
|
||||||
tg_user.first_name or "",
|
sanitized_first_name or "",
|
||||||
tg_user.last_name or "",
|
sanitized_last_name or "",
|
||||||
])
|
]).strip()
|
||||||
await panel_service.update_user_details_on_panel(
|
await panel_service.update_user_details_on_panel(
|
||||||
db_user.panel_user_uuid,
|
db_user.panel_user_uuid,
|
||||||
{"description": description_text},
|
{"description": description_text},
|
||||||
@@ -62,4 +67,3 @@ class ProfileSyncMiddleware(BaseMiddleware):
|
|||||||
|
|
||||||
return await handler(event, data)
|
return await handler(event, data)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from bot.services.referral_service import ReferralService
|
|||||||
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
|
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
|
||||||
from bot.services.notification_service import NotificationService
|
from bot.services.notification_service import NotificationService
|
||||||
from db.dal import payment_dal, user_dal
|
from db.dal import payment_dal, user_dal
|
||||||
|
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
|
||||||
|
|
||||||
|
|
||||||
class CryptoPayService:
|
class CryptoPayService:
|
||||||
@@ -189,10 +190,12 @@ class CryptoPayService:
|
|||||||
inviter_name_display = _("friend_placeholder")
|
inviter_name_display = _("friend_placeholder")
|
||||||
if db_user and db_user.referred_by_id:
|
if db_user and db_user.referred_by_id:
|
||||||
inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id)
|
inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id)
|
||||||
if inviter and inviter.first_name:
|
if inviter:
|
||||||
inviter_name_display = inviter.first_name
|
safe_name = sanitize_display_name(inviter.first_name) if inviter.first_name else None
|
||||||
elif inviter and inviter.username:
|
if safe_name:
|
||||||
inviter_name_display = f"@{inviter.username}"
|
inviter_name_display = safe_name
|
||||||
|
elif inviter.username:
|
||||||
|
inviter_name_display = username_for_display(inviter.username, with_at=False)
|
||||||
text = _("payment_successful_with_referral_bonus_full",
|
text = _("payment_successful_with_referral_bonus_full",
|
||||||
months=months,
|
months=months,
|
||||||
base_end_date=activation["end_date"].strftime('%Y-%m-%d'),
|
base_end_date=activation["end_date"].strftime('%Y-%m-%d'),
|
||||||
@@ -206,7 +209,9 @@ class CryptoPayService:
|
|||||||
end_date=final_end.strftime('%Y-%m-%d'),
|
end_date=final_end.strftime('%Y-%m-%d'),
|
||||||
config_link=config_link)
|
config_link=config_link)
|
||||||
|
|
||||||
markup = get_connect_and_main_keyboard(lang, i18n, settings, config_link)
|
markup = get_connect_and_main_keyboard(
|
||||||
|
lang, i18n, settings, config_link, preserve_message=True
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
await bot.send_message(
|
await bot.send_message(
|
||||||
user_id,
|
user_id,
|
||||||
|
|||||||
@@ -0,0 +1,407 @@
|
|||||||
|
import asyncio
|
||||||
|
from datetime import datetime
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from decimal import Decimal, ROUND_HALF_UP
|
||||||
|
from typing import Optional, Dict, Any, Tuple
|
||||||
|
|
||||||
|
from aiohttp import ClientSession, ClientTimeout, web
|
||||||
|
from aiogram import Bot
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from config.settings import Settings
|
||||||
|
from bot.middlewares.i18n import JsonI18n
|
||||||
|
from bot.services.subscription_service import SubscriptionService
|
||||||
|
from bot.services.referral_service import ReferralService
|
||||||
|
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
|
||||||
|
from bot.services.notification_service import NotificationService
|
||||||
|
from db.dal import payment_dal, user_dal
|
||||||
|
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
|
||||||
|
|
||||||
|
|
||||||
|
class FreeKassaService:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
bot: Bot,
|
||||||
|
settings: Settings,
|
||||||
|
i18n: JsonI18n,
|
||||||
|
async_session_factory: sessionmaker,
|
||||||
|
subscription_service: SubscriptionService,
|
||||||
|
referral_service: ReferralService,
|
||||||
|
):
|
||||||
|
self.bot = bot
|
||||||
|
self.settings = settings
|
||||||
|
self.i18n = i18n
|
||||||
|
self.async_session_factory = async_session_factory
|
||||||
|
self.subscription_service = subscription_service
|
||||||
|
self.referral_service = referral_service
|
||||||
|
|
||||||
|
self.shop_id: Optional[str] = settings.FREEKASSA_MERCHANT_ID
|
||||||
|
self.api_key: Optional[str] = settings.FREEKASSA_API_KEY
|
||||||
|
self.second_secret: Optional[str] = settings.FREEKASSA_SECOND_SECRET
|
||||||
|
self.default_currency: str = (settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
|
||||||
|
self.server_ip: Optional[str] = settings.FREEKASSA_PAYMENT_IP
|
||||||
|
self.payment_method_id: Optional[int] = settings.FREEKASSA_PAYMENT_METHOD_ID
|
||||||
|
|
||||||
|
self.api_base_url: str = "https://api.fk.life/v1"
|
||||||
|
self._timeout = ClientTimeout(total=15)
|
||||||
|
self._session: Optional[ClientSession] = None
|
||||||
|
self._nonce_lock = asyncio.Lock()
|
||||||
|
self._last_nonce = int(time.time() * 1000)
|
||||||
|
|
||||||
|
self.configured: bool = bool(settings.FREEKASSA_ENABLED and self.shop_id and self.api_key)
|
||||||
|
if not self.configured:
|
||||||
|
logging.warning("FreeKassaService initialized but not fully configured. Payments disabled.")
|
||||||
|
if settings.FREEKASSA_ENABLED and not self.server_ip:
|
||||||
|
logging.warning("FreeKassaService: FREEKASSA_PAYMENT_IP is not set. Requests may be rejected by the provider.")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _format_amount(amount: float) -> str:
|
||||||
|
"""Format amount for payloads and signature with two decimal places."""
|
||||||
|
quantized = Decimal(str(amount)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||||
|
return f"{quantized:.2f}"
|
||||||
|
|
||||||
|
async def create_order(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
payment_db_id: int,
|
||||||
|
user_id: int,
|
||||||
|
months: int,
|
||||||
|
amount: float,
|
||||||
|
currency: Optional[str],
|
||||||
|
email: Optional[str] = None,
|
||||||
|
ip_address: Optional[str] = None,
|
||||||
|
payment_method_id: Optional[int] = None,
|
||||||
|
extra_params: Optional[Dict[str, Any]] = None,
|
||||||
|
) -> Tuple[bool, Dict[str, Any]]:
|
||||||
|
if not self.configured:
|
||||||
|
logging.error("FreeKassaService is not configured. Cannot create order.")
|
||||||
|
return False, {"message": "service_not_configured"}
|
||||||
|
|
||||||
|
ip_address = ip_address or self.server_ip
|
||||||
|
if not ip_address:
|
||||||
|
logging.error("FreeKassaService: payment IP is required but not configured.")
|
||||||
|
return False, {"message": "missing_ip"}
|
||||||
|
|
||||||
|
email = email or f"{user_id}@telegram.org"
|
||||||
|
amount_str = self._format_amount(amount)
|
||||||
|
currency_code = (currency or self.default_currency or "RUB").upper()
|
||||||
|
|
||||||
|
payload: Dict[str, Any] = {
|
||||||
|
"shopId": int(self.shop_id),
|
||||||
|
"nonce": await self._generate_nonce(),
|
||||||
|
"paymentId": str(payment_db_id),
|
||||||
|
"i": int(payment_method_id),
|
||||||
|
"amount": amount_str,
|
||||||
|
"currency": currency_code,
|
||||||
|
"email": email,
|
||||||
|
"ip": ip_address,
|
||||||
|
"us_user_id": str(user_id),
|
||||||
|
"us_months": str(months),
|
||||||
|
"us_payment_db_id": str(payment_db_id),
|
||||||
|
}
|
||||||
|
|
||||||
|
if extra_params:
|
||||||
|
for key, value in extra_params.items():
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
payload[key] = value
|
||||||
|
|
||||||
|
payload["signature"] = self._sign_payload(payload)
|
||||||
|
|
||||||
|
session = await self._get_session()
|
||||||
|
url = f"{self.api_base_url}/orders/create"
|
||||||
|
try:
|
||||||
|
async with session.post(url, json=payload) as response:
|
||||||
|
response_text = await response.text()
|
||||||
|
try:
|
||||||
|
response_data = json.loads(response_text) if response_text else {}
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
logging.error("FreeKassa create_order: failed to decode JSON: %s", response_text)
|
||||||
|
return False, {"status": response.status, "message": "invalid_json", "raw": response_text}
|
||||||
|
|
||||||
|
if response.status != 200 or response_data.get("type") != "success":
|
||||||
|
logging.error(
|
||||||
|
"FreeKassa create_order: API returned error (status=%s, body=%s)",
|
||||||
|
response.status,
|
||||||
|
response_data,
|
||||||
|
)
|
||||||
|
return False, {"status": response.status, "message": response_data}
|
||||||
|
|
||||||
|
return True, response_data
|
||||||
|
except Exception as exc:
|
||||||
|
logging.error("FreeKassa create_order: request failed: %s", exc, exc_info=True)
|
||||||
|
return False, {"message": str(exc)}
|
||||||
|
|
||||||
|
async def _get_session(self) -> ClientSession:
|
||||||
|
if self._session is None or self._session.closed:
|
||||||
|
self._session = ClientSession(timeout=self._timeout)
|
||||||
|
return self._session
|
||||||
|
|
||||||
|
async def _generate_nonce(self) -> int:
|
||||||
|
async with self._nonce_lock:
|
||||||
|
candidate = int(time.time() * 1000)
|
||||||
|
if candidate <= self._last_nonce:
|
||||||
|
candidate = self._last_nonce + 1
|
||||||
|
self._last_nonce = candidate
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
def _sign_payload(self, payload: Dict[str, Any]) -> str:
|
||||||
|
if not self.api_key:
|
||||||
|
raise RuntimeError("FreeKassa API key is not configured.")
|
||||||
|
items = [
|
||||||
|
(key, value)
|
||||||
|
for key, value in payload.items()
|
||||||
|
if key != "signature" and value is not None
|
||||||
|
]
|
||||||
|
items.sort(key=lambda pair: pair[0])
|
||||||
|
message = "|".join(str(value) for _, value in items)
|
||||||
|
return hmac.new(self.api_key.encode("utf-8"), message.encode("utf-8"), hashlib.sha256).hexdigest()
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
if self._session and not self._session.closed:
|
||||||
|
await self._session.close()
|
||||||
|
|
||||||
|
def _validate_signature(
|
||||||
|
self,
|
||||||
|
merchant_order_id: str,
|
||||||
|
amount: str,
|
||||||
|
provided_signature: str,
|
||||||
|
payload: Optional[Dict[str, Any]] = None,
|
||||||
|
) -> bool:
|
||||||
|
if not provided_signature:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if self.shop_id and self.second_secret:
|
||||||
|
signature_source = f"{self.shop_id}:{amount}:{self.second_secret}:{merchant_order_id}"
|
||||||
|
expected_signature = hashlib.md5(signature_source.encode("utf-8")).hexdigest()
|
||||||
|
if expected_signature.lower() == provided_signature.lower():
|
||||||
|
return True
|
||||||
|
|
||||||
|
if self.api_key and payload:
|
||||||
|
items = [
|
||||||
|
(key, value)
|
||||||
|
for key, value in payload.items()
|
||||||
|
if key not in {"signature", "SIGN"} and value is not None
|
||||||
|
]
|
||||||
|
items.sort(key=lambda pair: pair[0])
|
||||||
|
message = "|".join(str(value) for _, value in items)
|
||||||
|
alt_signature = hmac.new(self.api_key.encode("utf-8"), message.encode("utf-8"), hashlib.sha256).hexdigest()
|
||||||
|
if alt_signature.lower() == provided_signature.lower():
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def webhook_route(self, request: web.Request) -> web.Response:
|
||||||
|
if not self.configured:
|
||||||
|
return web.Response(status=503, text="freekassa_disabled")
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = await request.post()
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"FreeKassa webhook: failed to read POST data: {e}")
|
||||||
|
return web.Response(status=400, text="bad_request")
|
||||||
|
|
||||||
|
payload_dict: Dict[str, Any]
|
||||||
|
if data:
|
||||||
|
payload_dict = {str(k): v for k, v in data.items()}
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
json_payload = await request.json()
|
||||||
|
payload_dict = {str(k): v for k, v in json_payload.items()} if isinstance(json_payload, dict) else {}
|
||||||
|
data = json_payload
|
||||||
|
except Exception:
|
||||||
|
payload_dict = {}
|
||||||
|
data = {}
|
||||||
|
|
||||||
|
def _get(key: str, default: Optional[str] = None) -> Optional[str]:
|
||||||
|
if isinstance(data, dict):
|
||||||
|
return data.get(key) or data.get(key.lower()) or default
|
||||||
|
return payload_dict.get(key) or payload_dict.get(key.lower()) or default
|
||||||
|
|
||||||
|
merchant_id = _get("MERCHANT_ID")
|
||||||
|
if merchant_id != self.shop_id:
|
||||||
|
logging.error(f"FreeKassa webhook: merchant mismatch (got {merchant_id})")
|
||||||
|
return web.Response(status=403, text="merchant_mismatch")
|
||||||
|
|
||||||
|
signature = _get("SIGN") or _get("signature")
|
||||||
|
if not signature:
|
||||||
|
logging.error("FreeKassa webhook: missing signature")
|
||||||
|
return web.Response(status=400, text="missing_signature")
|
||||||
|
|
||||||
|
order_id_str = _get("MERCHANT_ORDER_ID") or _get("ORDER_ID") or _get("o")
|
||||||
|
amount_str = _get("AMOUNT") or _get("OA") or _get("amount")
|
||||||
|
provider_payment_id = _get("intid") or _get("payment_id") or _get("transaction_id")
|
||||||
|
|
||||||
|
if not order_id_str or not amount_str:
|
||||||
|
logging.error("FreeKassa webhook: missing order_id or amount")
|
||||||
|
return web.Response(status=400, text="missing_data")
|
||||||
|
|
||||||
|
if not self._validate_signature(order_id_str, amount_str, signature, payload_dict):
|
||||||
|
logging.error("FreeKassa webhook: invalid signature")
|
||||||
|
return web.Response(status=403, text="invalid_signature")
|
||||||
|
|
||||||
|
try:
|
||||||
|
payment_db_id = int(order_id_str)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
logging.error(f"FreeKassa webhook: invalid order_id value '{order_id_str}'")
|
||||||
|
return web.Response(status=400, text="invalid_order_id")
|
||||||
|
|
||||||
|
async with self.async_session_factory() as session:
|
||||||
|
payment = await payment_dal.get_payment_by_db_id(session, payment_db_id)
|
||||||
|
if not payment:
|
||||||
|
logging.error(f"FreeKassa webhook: payment {payment_db_id} not found")
|
||||||
|
return web.Response(status=404, text="payment_not_found")
|
||||||
|
|
||||||
|
if payment.status == "succeeded":
|
||||||
|
logging.info(f"FreeKassa webhook: payment {payment_db_id} already succeeded")
|
||||||
|
return web.Response(text="YES")
|
||||||
|
|
||||||
|
# Optional amount verification
|
||||||
|
try:
|
||||||
|
amount_decimal = Decimal(amount_str)
|
||||||
|
expected_amount = Decimal(str(payment.amount)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||||
|
if amount_decimal.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) != expected_amount:
|
||||||
|
logging.warning(
|
||||||
|
f"FreeKassa webhook: amount mismatch for payment {payment_db_id} "
|
||||||
|
f"(expected {expected_amount}, got {amount_decimal})"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logging.warning(f"FreeKassa webhook: failed to compare amount for payment {payment_db_id}: {e}")
|
||||||
|
|
||||||
|
activation = None
|
||||||
|
referral_bonus = None
|
||||||
|
try:
|
||||||
|
await payment_dal.update_provider_payment_and_status(
|
||||||
|
session=session,
|
||||||
|
payment_db_id=payment.payment_id,
|
||||||
|
provider_payment_id=str(provider_payment_id or f"freekassa:{order_id_str}"),
|
||||||
|
new_status="succeeded",
|
||||||
|
)
|
||||||
|
|
||||||
|
months = payment.subscription_duration_months or 1
|
||||||
|
|
||||||
|
activation = await self.subscription_service.activate_subscription(
|
||||||
|
session,
|
||||||
|
payment.user_id,
|
||||||
|
months,
|
||||||
|
float(payment.amount),
|
||||||
|
payment.payment_id,
|
||||||
|
provider="freekassa",
|
||||||
|
)
|
||||||
|
|
||||||
|
referral_bonus = await self.referral_service.apply_referral_bonuses_for_payment(
|
||||||
|
session,
|
||||||
|
payment.user_id,
|
||||||
|
months,
|
||||||
|
current_payment_db_id=payment.payment_id,
|
||||||
|
skip_if_active_before_payment=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
except Exception as e:
|
||||||
|
await session.rollback()
|
||||||
|
logging.error(f"FreeKassa webhook: failed to process payment {payment_db_id}: {e}", exc_info=True)
|
||||||
|
return web.Response(status=500, text="processing_error")
|
||||||
|
|
||||||
|
db_user = payment.user or await user_dal.get_user_by_id(session, payment.user_id)
|
||||||
|
lang = db_user.language_code if db_user and db_user.language_code else self.settings.DEFAULT_LANGUAGE
|
||||||
|
_ = lambda k, **kw: self.i18n.gettext(lang, k, **kw) if self.i18n else k
|
||||||
|
|
||||||
|
config_link = None
|
||||||
|
final_end = None
|
||||||
|
months = payment.subscription_duration_months or 1
|
||||||
|
if activation:
|
||||||
|
config_link = activation.get("subscription_url")
|
||||||
|
final_end = activation.get("end_date")
|
||||||
|
|
||||||
|
applied_days = 0
|
||||||
|
if referral_bonus and referral_bonus.get("referee_new_end_date"):
|
||||||
|
final_end = referral_bonus["referee_new_end_date"]
|
||||||
|
applied_days = referral_bonus.get("referee_bonus_applied_days", 0)
|
||||||
|
|
||||||
|
if not final_end and activation and activation.get("end_date"):
|
||||||
|
final_end = activation["end_date"]
|
||||||
|
|
||||||
|
if not config_link:
|
||||||
|
config_link = _("config_link_not_available")
|
||||||
|
if final_end:
|
||||||
|
end_date_str = final_end.strftime("%Y-%m-%d")
|
||||||
|
else:
|
||||||
|
end_date_str = _("config_link_not_available")
|
||||||
|
|
||||||
|
if applied_days:
|
||||||
|
inviter_name_display = _("friend_placeholder")
|
||||||
|
if db_user and db_user.referred_by_id:
|
||||||
|
inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id)
|
||||||
|
if inviter:
|
||||||
|
safe_name = sanitize_display_name(inviter.first_name) if inviter.first_name else None
|
||||||
|
if safe_name:
|
||||||
|
inviter_name_display = safe_name
|
||||||
|
elif inviter.username:
|
||||||
|
inviter_name_display = username_for_display(inviter.username, with_at=False)
|
||||||
|
text = _(
|
||||||
|
"payment_successful_with_referral_bonus_full",
|
||||||
|
months=months,
|
||||||
|
base_end_date=activation["end_date"].strftime("%Y-%m-%d") if activation and activation.get("end_date") else end_date_str,
|
||||||
|
bonus_days=applied_days,
|
||||||
|
final_end_date=end_date_str,
|
||||||
|
inviter_name=inviter_name_display,
|
||||||
|
config_link=config_link,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
text = _(
|
||||||
|
"payment_successful_full",
|
||||||
|
months=months,
|
||||||
|
end_date=end_date_str,
|
||||||
|
config_link=config_link,
|
||||||
|
)
|
||||||
|
if provider_payment_id:
|
||||||
|
order_info_text = _(
|
||||||
|
"free_kassa_order_full",
|
||||||
|
order_id=provider_payment_id,
|
||||||
|
date=datetime.now().strftime("%Y-%m-%d"),
|
||||||
|
)
|
||||||
|
text = f"{order_info_text}\n{text}"
|
||||||
|
|
||||||
|
markup = get_connect_and_main_keyboard(
|
||||||
|
lang,
|
||||||
|
self.i18n,
|
||||||
|
self.settings,
|
||||||
|
config_link,
|
||||||
|
preserve_message=True,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await self.bot.send_message(
|
||||||
|
payment.user_id,
|
||||||
|
text,
|
||||||
|
reply_markup=markup,
|
||||||
|
parse_mode="HTML",
|
||||||
|
disable_web_page_preview=True,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"FreeKassa notification: failed to send message to user {payment.user_id}: {e}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
notification_service = NotificationService(self.bot, self.settings, self.i18n)
|
||||||
|
await notification_service.notify_payment_received(
|
||||||
|
user_id=payment.user_id,
|
||||||
|
amount=float(payment.amount),
|
||||||
|
currency=self.default_currency,
|
||||||
|
months=months,
|
||||||
|
payment_provider="freekassa",
|
||||||
|
username=db_user.username if db_user else None,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"FreeKassa notification: failed to notify admins: {e}")
|
||||||
|
|
||||||
|
return web.Response(text="YES")
|
||||||
|
|
||||||
|
|
||||||
|
async def freekassa_webhook_route(request: web.Request) -> web.Response:
|
||||||
|
service: FreeKassaService = request.app["freekassa_service"]
|
||||||
|
return await service.webhook_route(request)
|
||||||
@@ -1,15 +1,20 @@
|
|||||||
import logging
|
import logging
|
||||||
import asyncio
|
import asyncio
|
||||||
from aiogram import Bot
|
from aiogram import Bot
|
||||||
|
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||||
from aiogram.utils.text_decorations import html_decoration as hd
|
from aiogram.utils.text_decorations import html_decoration as hd
|
||||||
from aiogram.exceptions import TelegramRetryAfter
|
from aiogram.exceptions import TelegramRetryAfter
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Optional, Union, Dict, Any
|
from typing import Optional, Union, Dict, Any, Callable
|
||||||
|
|
||||||
from config.settings import Settings
|
from config.settings import Settings
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
from bot.middlewares.i18n import JsonI18n
|
from bot.middlewares.i18n import JsonI18n
|
||||||
from bot.utils.message_queue import get_queue_manager
|
from bot.utils.message_queue import get_queue_manager
|
||||||
|
from bot.utils.text_sanitizer import (
|
||||||
|
display_name_or_fallback,
|
||||||
|
username_for_display,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class NotificationService:
|
class NotificationService:
|
||||||
@@ -19,8 +24,56 @@ class NotificationService:
|
|||||||
self.bot = bot
|
self.bot = bot
|
||||||
self.settings = settings
|
self.settings = settings
|
||||||
self.i18n = i18n
|
self.i18n = i18n
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _format_user_display(
|
||||||
|
user_id: int,
|
||||||
|
username: Optional[str] = None,
|
||||||
|
first_name: Optional[str] = None,
|
||||||
|
) -> str:
|
||||||
|
base_display = display_name_or_fallback(first_name, f"ID {user_id}")
|
||||||
|
if username:
|
||||||
|
base_display = f"{base_display} ({username_for_display(username)})"
|
||||||
|
return base_display
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_profile_keyboard(
|
||||||
|
translate: Callable[..., str],
|
||||||
|
user_id: int,
|
||||||
|
referrer_id: Optional[int] = None,
|
||||||
|
) -> InlineKeyboardMarkup:
|
||||||
|
"""Create inline keyboard with links to user (and referrer) profiles."""
|
||||||
|
buttons = [
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=translate(
|
||||||
|
"log_open_profile_link",
|
||||||
|
default="👤 Открыть профиль",
|
||||||
|
),
|
||||||
|
url=f"tg://user?id={user_id}",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
if referrer_id:
|
||||||
|
buttons.append([
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=translate(
|
||||||
|
"log_open_referrer_profile_button",
|
||||||
|
default="👤 Открыть профиль пригласившего",
|
||||||
|
),
|
||||||
|
url=f"tg://user?id={referrer_id}",
|
||||||
|
)
|
||||||
|
])
|
||||||
|
|
||||||
|
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||||
|
|
||||||
async def _send_to_log_channel(self, message: str, thread_id: Optional[int] = None):
|
async def _send_to_log_channel(
|
||||||
|
self,
|
||||||
|
message: str,
|
||||||
|
thread_id: Optional[int] = None,
|
||||||
|
reply_markup: Optional[InlineKeyboardMarkup] = None,
|
||||||
|
):
|
||||||
"""Send message to configured log channel/group using message queue"""
|
"""Send message to configured log channel/group using message queue"""
|
||||||
if not self.settings.LOG_CHAT_ID:
|
if not self.settings.LOG_CHAT_ID:
|
||||||
return
|
return
|
||||||
@@ -34,6 +87,7 @@ class NotificationService:
|
|||||||
text=message,
|
text=message,
|
||||||
parse_mode="HTML",
|
parse_mode="HTML",
|
||||||
disable_web_page_preview=True,
|
disable_web_page_preview=True,
|
||||||
|
reply_markup=reply_markup,
|
||||||
message_thread_id=thread_id or self.settings.LOG_THREAD_ID
|
message_thread_id=thread_id or self.settings.LOG_THREAD_ID
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -49,6 +103,8 @@ class NotificationService:
|
|||||||
"parse_mode": "HTML",
|
"parse_mode": "HTML",
|
||||||
"disable_web_page_preview": True
|
"disable_web_page_preview": True
|
||||||
}
|
}
|
||||||
|
if reply_markup:
|
||||||
|
kwargs["reply_markup"] = reply_markup
|
||||||
|
|
||||||
# Add thread ID for supergroups if specified
|
# Add thread ID for supergroups if specified
|
||||||
if final_thread_id:
|
if final_thread_id:
|
||||||
@@ -101,13 +157,20 @@ class NotificationService:
|
|||||||
admin_lang = self.settings.DEFAULT_LANGUAGE
|
admin_lang = self.settings.DEFAULT_LANGUAGE
|
||||||
_ = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw) if self.i18n else k
|
_ = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw) if self.i18n else k
|
||||||
|
|
||||||
user_display = first_name or f"ID {user_id}"
|
user_display = self._format_user_display(
|
||||||
if username:
|
user_id=user_id,
|
||||||
user_display += f" (@{username})"
|
username=username,
|
||||||
|
first_name=first_name,
|
||||||
|
)
|
||||||
|
|
||||||
referral_text = ""
|
referral_text = ""
|
||||||
if referred_by_id:
|
if referred_by_id:
|
||||||
referral_text = _("log_referral_suffix", default=" (реферал от {referrer_id})", referrer_id=referred_by_id)
|
referrer_link = hd.link(str(referred_by_id), f"tg://user?id={referred_by_id}")
|
||||||
|
referral_text = _(
|
||||||
|
"log_referral_suffix",
|
||||||
|
default=" (реферал от {referrer_link})",
|
||||||
|
referrer_link=referrer_link,
|
||||||
|
)
|
||||||
|
|
||||||
message = _(
|
message = _(
|
||||||
"log_new_user_registration",
|
"log_new_user_registration",
|
||||||
@@ -120,9 +183,10 @@ class NotificationService:
|
|||||||
referral_text=referral_text,
|
referral_text=referral_text,
|
||||||
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
)
|
)
|
||||||
|
|
||||||
# Send to log channel
|
# Send to log channel
|
||||||
await self._send_to_log_channel(message)
|
profile_keyboard = self._build_profile_keyboard(_, user_id, referred_by_id)
|
||||||
|
await self._send_to_log_channel(message, reply_markup=profile_keyboard)
|
||||||
|
|
||||||
async def notify_payment_received(self, user_id: int, amount: float, currency: str,
|
async def notify_payment_received(self, user_id: int, amount: float, currency: str,
|
||||||
months: int, payment_provider: str,
|
months: int, payment_provider: str,
|
||||||
@@ -134,12 +198,14 @@ class NotificationService:
|
|||||||
admin_lang = self.settings.DEFAULT_LANGUAGE
|
admin_lang = self.settings.DEFAULT_LANGUAGE
|
||||||
_ = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw) if self.i18n else k
|
_ = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw) if self.i18n else k
|
||||||
|
|
||||||
user_display = f"ID {user_id}"
|
user_display = self._format_user_display(
|
||||||
if username:
|
user_id=user_id,
|
||||||
user_display += f" (@{username})"
|
username=username,
|
||||||
|
)
|
||||||
|
|
||||||
provider_emoji = {
|
provider_emoji = {
|
||||||
"yookassa": "💳",
|
"yookassa": "💳",
|
||||||
|
"freekassa": "💳",
|
||||||
"cryptopay": "₿",
|
"cryptopay": "₿",
|
||||||
"stars": "⭐",
|
"stars": "⭐",
|
||||||
"tribute": "💎"
|
"tribute": "💎"
|
||||||
@@ -163,7 +229,8 @@ class NotificationService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Send to log channel
|
# Send to log channel
|
||||||
await self._send_to_log_channel(message)
|
profile_keyboard = self._build_profile_keyboard(_, user_id)
|
||||||
|
await self._send_to_log_channel(message, reply_markup=profile_keyboard)
|
||||||
|
|
||||||
async def notify_promo_activation(self, user_id: int, promo_code: str, bonus_days: int,
|
async def notify_promo_activation(self, user_id: int, promo_code: str, bonus_days: int,
|
||||||
username: Optional[str] = None):
|
username: Optional[str] = None):
|
||||||
@@ -174,9 +241,10 @@ class NotificationService:
|
|||||||
admin_lang = self.settings.DEFAULT_LANGUAGE
|
admin_lang = self.settings.DEFAULT_LANGUAGE
|
||||||
_ = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw) if self.i18n else k
|
_ = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw) if self.i18n else k
|
||||||
|
|
||||||
user_display = f"ID {user_id}"
|
user_display = self._format_user_display(
|
||||||
if username:
|
user_id=user_id,
|
||||||
user_display += f" (@{username})"
|
username=username,
|
||||||
|
)
|
||||||
|
|
||||||
message = _(
|
message = _(
|
||||||
"log_promo_activation",
|
"log_promo_activation",
|
||||||
@@ -192,7 +260,8 @@ class NotificationService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Send to log channel
|
# Send to log channel
|
||||||
await self._send_to_log_channel(message)
|
profile_keyboard = self._build_profile_keyboard(_, user_id)
|
||||||
|
await self._send_to_log_channel(message, reply_markup=profile_keyboard)
|
||||||
|
|
||||||
async def notify_trial_activation(self, user_id: int, end_date: datetime,
|
async def notify_trial_activation(self, user_id: int, end_date: datetime,
|
||||||
username: Optional[str] = None):
|
username: Optional[str] = None):
|
||||||
@@ -203,9 +272,10 @@ class NotificationService:
|
|||||||
admin_lang = self.settings.DEFAULT_LANGUAGE
|
admin_lang = self.settings.DEFAULT_LANGUAGE
|
||||||
_ = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw) if self.i18n else k
|
_ = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw) if self.i18n else k
|
||||||
|
|
||||||
user_display = f"ID {user_id}"
|
user_display = self._format_user_display(
|
||||||
if username:
|
user_id=user_id,
|
||||||
user_display += f" (@{username})"
|
username=username,
|
||||||
|
)
|
||||||
|
|
||||||
message = _(
|
message = _(
|
||||||
"log_trial_activation",
|
"log_trial_activation",
|
||||||
@@ -219,7 +289,8 @@ class NotificationService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Send to log channel
|
# Send to log channel
|
||||||
await self._send_to_log_channel(message)
|
profile_keyboard = self._build_profile_keyboard(_, user_id)
|
||||||
|
await self._send_to_log_channel(message, reply_markup=profile_keyboard)
|
||||||
|
|
||||||
async def notify_panel_sync(self, status: str, details: str,
|
async def notify_panel_sync(self, status: str, details: str,
|
||||||
users_processed: int, subs_synced: int,
|
users_processed: int, subs_synced: int,
|
||||||
@@ -254,7 +325,7 @@ class NotificationService:
|
|||||||
details=details
|
details=details
|
||||||
)
|
)
|
||||||
|
|
||||||
# Send to log channel
|
# Send to log channel
|
||||||
await self._send_to_log_channel(message)
|
await self._send_to_log_channel(message)
|
||||||
|
|
||||||
async def notify_suspicious_promo_attempt(
|
async def notify_suspicious_promo_attempt(
|
||||||
@@ -268,9 +339,11 @@ class NotificationService:
|
|||||||
_ = lambda k, **kw: self.i18n.gettext(
|
_ = lambda k, **kw: self.i18n.gettext(
|
||||||
admin_lang, k, **kw) if self.i18n else k
|
admin_lang, k, **kw) if self.i18n else k
|
||||||
|
|
||||||
user_display = first_name or f"ID {user_id}"
|
user_display = self._format_user_display(
|
||||||
if username:
|
user_id=user_id,
|
||||||
user_display += f" (@{username})"
|
username=username,
|
||||||
|
first_name=first_name,
|
||||||
|
)
|
||||||
|
|
||||||
message = _(
|
message = _(
|
||||||
"log_suspicious_promo",
|
"log_suspicious_promo",
|
||||||
@@ -285,7 +358,8 @@ class NotificationService:
|
|||||||
timestamp=datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S %Z"))
|
timestamp=datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S %Z"))
|
||||||
|
|
||||||
# Send to log channel
|
# Send to log channel
|
||||||
await self._send_to_log_channel(message)
|
profile_keyboard = self._build_profile_keyboard(_, user_id)
|
||||||
|
await self._send_to_log_channel(message, reply_markup=profile_keyboard)
|
||||||
|
|
||||||
async def send_custom_notification(self, message: str, to_admins: bool = False,
|
async def send_custom_notification(self, message: str, to_admins: bool = False,
|
||||||
to_log_channel: bool = True, thread_id: Optional[int] = None):
|
to_log_channel: bool = True, thread_id: Optional[int] = None):
|
||||||
@@ -295,4 +369,4 @@ class NotificationService:
|
|||||||
if to_admins:
|
if to_admins:
|
||||||
await self._send_to_admins(message)
|
await self._send_to_admins(message)
|
||||||
|
|
||||||
# Removed legacy helper functions that duplicated NotificationService API
|
# Removed legacy helper functions that duplicated NotificationService API
|
||||||
|
|||||||
@@ -21,11 +21,11 @@ class PanelApiService:
|
|||||||
self.api_key = settings.PANEL_API_KEY
|
self.api_key = settings.PANEL_API_KEY
|
||||||
self._session: Optional[aiohttp.ClientSession] = None
|
self._session: Optional[aiohttp.ClientSession] = None
|
||||||
self.default_client_ip = "127.0.0.1"
|
self.default_client_ip = "127.0.0.1"
|
||||||
|
|
||||||
async def __aenter__(self):
|
async def __aenter__(self):
|
||||||
"""Context manager entry"""
|
"""Context manager entry"""
|
||||||
return self
|
return self
|
||||||
|
|
||||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||||
"""Context manager exit - automatically close session"""
|
"""Context manager exit - automatically close session"""
|
||||||
await self.close_session()
|
await self.close_session()
|
||||||
@@ -337,6 +337,7 @@ class PanelApiService:
|
|||||||
default_expire_days: int = 1,
|
default_expire_days: int = 1,
|
||||||
default_traffic_limit_bytes: int = 0,
|
default_traffic_limit_bytes: int = 0,
|
||||||
default_traffic_limit_strategy: str = "NO_RESET",
|
default_traffic_limit_strategy: str = "NO_RESET",
|
||||||
|
hwid_device_limit: Optional[int] = None,
|
||||||
specific_squad_uuids: Optional[List[str]] = None,
|
specific_squad_uuids: Optional[List[str]] = None,
|
||||||
description: Optional[str] = None,
|
description: Optional[str] = None,
|
||||||
tag: Optional[str] = None,
|
tag: Optional[str] = None,
|
||||||
@@ -368,6 +369,18 @@ class PanelApiService:
|
|||||||
"trafficLimitStrategy": default_traffic_limit_strategy.upper(),
|
"trafficLimitStrategy": default_traffic_limit_strategy.upper(),
|
||||||
"trafficLimitBytes": default_traffic_limit_bytes,
|
"trafficLimitBytes": default_traffic_limit_bytes,
|
||||||
}
|
}
|
||||||
|
hwid_limit_value = hwid_device_limit
|
||||||
|
if hwid_limit_value is None:
|
||||||
|
hwid_limit_value = self.settings.USER_HWID_DEVICE_LIMIT
|
||||||
|
if hwid_limit_value is not None:
|
||||||
|
try:
|
||||||
|
hwid_limit_int = int(hwid_limit_value)
|
||||||
|
if hwid_limit_int >= 0:
|
||||||
|
payload["hwidDeviceLimit"] = hwid_limit_int
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
logging.warning(
|
||||||
|
f"Ignoring invalid HWID device limit '{hwid_limit_value}' while creating panel user '{username_on_panel}'."
|
||||||
|
)
|
||||||
if specific_squad_uuids:
|
if specific_squad_uuids:
|
||||||
payload["activeInternalSquads"] = specific_squad_uuids
|
payload["activeInternalSquads"] = specific_squad_uuids
|
||||||
if telegram_id is not None: payload["telegramId"] = telegram_id
|
if telegram_id is not None: payload["telegramId"] = telegram_id
|
||||||
@@ -442,6 +455,37 @@ class PanelApiService:
|
|||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
async def delete_user_from_panel(self,
|
||||||
|
user_uuid: str,
|
||||||
|
log_response: bool = True) -> bool:
|
||||||
|
"""Delete a user from the panel. Treat not-found as already deleted."""
|
||||||
|
endpoint = f"/users/{user_uuid}"
|
||||||
|
response_data = await self._request(
|
||||||
|
"DELETE", endpoint, log_full_response=log_response
|
||||||
|
)
|
||||||
|
|
||||||
|
if not response_data:
|
||||||
|
logging.error(
|
||||||
|
f"Panel API delete_user_from_panel returned no data for user {user_uuid}."
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
if response_data.get("error"):
|
||||||
|
details = response_data.get("details") or {}
|
||||||
|
error_code = details.get("errorCode") or response_data.get("errorCode")
|
||||||
|
if error_code in {"A062", "A040"}:
|
||||||
|
logging.info(
|
||||||
|
f"Panel user {user_uuid} already absent (errorCode {error_code}). Treating as deleted."
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
logging.error(
|
||||||
|
f"Failed to delete user {user_uuid} on panel. Response: {response_data}"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
logging.info(f"Panel user {user_uuid} deleted successfully.")
|
||||||
|
return True
|
||||||
|
|
||||||
async def get_subscription_link(
|
async def get_subscription_link(
|
||||||
self,
|
self,
|
||||||
short_uuid_or_sub_uuid: str,
|
short_uuid_or_sub_uuid: str,
|
||||||
@@ -455,6 +499,30 @@ class PanelApiService:
|
|||||||
return f"{base_sub_url}/{client_type.lower()}"
|
return f"{base_sub_url}/{client_type.lower()}"
|
||||||
return base_sub_url
|
return base_sub_url
|
||||||
|
|
||||||
|
async def get_user_devices(self, user_uuid: str) -> Optional[List[Dict[str, Any]]]:
|
||||||
|
endpoint = f"/hwid/devices/{user_uuid}"
|
||||||
|
response_data = await self._request("GET", endpoint, log_full_response=False)
|
||||||
|
if response_data and not response_data.get("error") and "response" in response_data:
|
||||||
|
return response_data.get("response")
|
||||||
|
logging.error(
|
||||||
|
f"Failed to get user devices for user {user_uuid}. Response: {response_data}"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def disconnect_device(self, user_uuid: str, hwid: str) -> bool:
|
||||||
|
endpoint = f"/hwid/devices/delete"
|
||||||
|
payload = {
|
||||||
|
"userUuid": user_uuid,
|
||||||
|
"hwid": hwid
|
||||||
|
}
|
||||||
|
response_data = await self._request("POST", endpoint, json=payload, log_full_response=False)
|
||||||
|
if response_data and not response_data.get("error") and "response" in response_data:
|
||||||
|
return True
|
||||||
|
logging.error(
|
||||||
|
f"Failed to disconnect device {hwid} for user {user_uuid}. Payload: {payload}, Response: {response_data}"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
async def update_bot_db_sync_status(self,
|
async def update_bot_db_sync_status(self,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
status: str,
|
status: str,
|
||||||
@@ -468,25 +536,24 @@ class PanelApiService:
|
|||||||
async def get_bot_db_last_sync_status(
|
async def get_bot_db_last_sync_status(
|
||||||
self, session: AsyncSession) -> Optional[PanelSyncStatus]:
|
self, session: AsyncSession) -> Optional[PanelSyncStatus]:
|
||||||
return await panel_sync_dal.get_panel_sync_status(session)
|
return await panel_sync_dal.get_panel_sync_status(session)
|
||||||
|
|
||||||
|
|
||||||
async def get_system_stats(self) -> Optional[Dict[str, Any]]:
|
async def get_system_stats(self) -> Optional[Dict[str, Any]]:
|
||||||
"""Get system statistics (CPU, memory, users counts)"""
|
"""Get system statistics (CPU, memory, users counts)"""
|
||||||
response_data = await self._request("GET", "/system/stats", log_full_response=False)
|
response_data = await self._request("GET", "/system/stats", log_full_response=False)
|
||||||
if response_data and not response_data.get("error") and "response" in response_data:
|
if response_data and not response_data.get("error") and "response" in response_data:
|
||||||
return response_data.get("response")
|
return response_data.get("response")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def get_bandwidth_stats(self) -> Optional[Dict[str, Any]]:
|
async def get_bandwidth_stats(self) -> Optional[Dict[str, Any]]:
|
||||||
"""Get bandwidth statistics"""
|
"""Get bandwidth statistics"""
|
||||||
response_data = await self._request("GET", "/system/stats/bandwidth", log_full_response=False)
|
response_data = await self._request("GET", "/system/stats/bandwidth", log_full_response=False)
|
||||||
if response_data and not response_data.get("error") and "response" in response_data:
|
if response_data and not response_data.get("error") and "response" in response_data:
|
||||||
return response_data.get("response")
|
return response_data.get("response")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def get_nodes_statistics(self) -> Optional[Dict[str, Any]]:
|
async def get_nodes_statistics(self) -> Optional[Dict[str, Any]]:
|
||||||
"""Get nodes statistics"""
|
"""Get nodes statistics"""
|
||||||
response_data = await self._request("GET", "/system/stats/nodes", log_full_response=False)
|
response_data = await self._request("GET", "/system/stats/nodes", log_full_response=False)
|
||||||
if response_data and not response_data.get("error") and "response" in response_data:
|
if response_data and not response_data.get("error") and "response" in response_data:
|
||||||
return response_data.get("response")
|
return response_data.get("response")
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -177,6 +177,8 @@ class ReferralService:
|
|||||||
"ACTIVE_BONUS",
|
"ACTIVE_BONUS",
|
||||||
"traffic_limit_bytes":
|
"traffic_limit_bytes":
|
||||||
self.settings.user_traffic_limit_bytes,
|
self.settings.user_traffic_limit_bytes,
|
||||||
|
"auto_renew_enabled":
|
||||||
|
False,
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
await subscription_dal.deactivate_other_active_subscriptions(
|
await subscription_dal.deactivate_other_active_subscriptions(
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from .referral_service import ReferralService
|
|||||||
from bot.middlewares.i18n import JsonI18n
|
from bot.middlewares.i18n import JsonI18n
|
||||||
from .notification_service import NotificationService
|
from .notification_service import NotificationService
|
||||||
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
|
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
|
||||||
|
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
|
||||||
|
|
||||||
|
|
||||||
class StarsService:
|
class StarsService:
|
||||||
@@ -124,10 +125,12 @@ class StarsService:
|
|||||||
db_user = await user_dal.get_user_by_id(session, message.from_user.id)
|
db_user = await user_dal.get_user_by_id(session, message.from_user.id)
|
||||||
if db_user and db_user.referred_by_id:
|
if db_user and db_user.referred_by_id:
|
||||||
inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id)
|
inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id)
|
||||||
if inviter and inviter.first_name:
|
if inviter:
|
||||||
inviter_name_display = inviter.first_name
|
safe_name = sanitize_display_name(inviter.first_name) if inviter.first_name else None
|
||||||
elif inviter and inviter.username:
|
if safe_name:
|
||||||
inviter_name_display = f"@{inviter.username}"
|
inviter_name_display = safe_name
|
||||||
|
elif inviter.username:
|
||||||
|
inviter_name_display = username_for_display(inviter.username, with_at=False)
|
||||||
success_msg = _(
|
success_msg = _(
|
||||||
"payment_successful_with_referral_bonus_full",
|
"payment_successful_with_referral_bonus_full",
|
||||||
months=months,
|
months=months,
|
||||||
@@ -145,7 +148,7 @@ class StarsService:
|
|||||||
config_link=config_link,
|
config_link=config_link,
|
||||||
)
|
)
|
||||||
markup = get_connect_and_main_keyboard(
|
markup = get_connect_and_main_keyboard(
|
||||||
current_lang, i18n, self.settings, config_link
|
current_lang, i18n, self.settings, config_link, preserve_message=True
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
await self.bot.send_message(
|
await self.bot.send_message(
|
||||||
@@ -173,4 +176,3 @@ class StarsService:
|
|||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Failed to send stars payment notification: {e}")
|
logging.error(f"Failed to send stars payment notification: {e}")
|
||||||
|
|
||||||
|
|||||||
@@ -355,6 +355,7 @@ class SubscriptionService:
|
|||||||
"is_active": True,
|
"is_active": True,
|
||||||
"status_from_panel": "TRIAL",
|
"status_from_panel": "TRIAL",
|
||||||
"traffic_limit_bytes": self.settings.trial_traffic_limit_bytes,
|
"traffic_limit_bytes": self.settings.trial_traffic_limit_bytes,
|
||||||
|
"auto_renew_enabled": False,
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
await subscription_dal.upsert_subscription(session, trial_sub_data)
|
await subscription_dal.upsert_subscription(session, trial_sub_data)
|
||||||
@@ -497,6 +498,15 @@ class SubscriptionService:
|
|||||||
session, panel_user_uuid, panel_sub_link_id
|
session, panel_user_uuid, panel_sub_link_id
|
||||||
)
|
)
|
||||||
|
|
||||||
|
auto_renew_should_enable = False
|
||||||
|
if (
|
||||||
|
provider == "yookassa"
|
||||||
|
and getattr(self.settings, "YOOKASSA_AUTOPAYMENTS_ENABLED", False)
|
||||||
|
):
|
||||||
|
auto_renew_should_enable = await user_billing_dal.user_has_saved_payment_method(
|
||||||
|
session, user_id
|
||||||
|
)
|
||||||
|
|
||||||
sub_payload = {
|
sub_payload = {
|
||||||
"user_id": user_id,
|
"user_id": user_id,
|
||||||
"panel_user_uuid": panel_user_uuid,
|
"panel_user_uuid": panel_user_uuid,
|
||||||
@@ -509,7 +519,7 @@ class SubscriptionService:
|
|||||||
"traffic_limit_bytes": self.settings.user_traffic_limit_bytes,
|
"traffic_limit_bytes": self.settings.user_traffic_limit_bytes,
|
||||||
"provider": provider,
|
"provider": provider,
|
||||||
"skip_notifications": provider == "tribute" and self.settings.TRIBUTE_SKIP_NOTIFICATIONS,
|
"skip_notifications": provider == "tribute" and self.settings.TRIBUTE_SKIP_NOTIFICATIONS,
|
||||||
"auto_renew_enabled": True,
|
"auto_renew_enabled": auto_renew_should_enable,
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
new_or_updated_sub = await subscription_dal.upsert_subscription(
|
new_or_updated_sub = await subscription_dal.upsert_subscription(
|
||||||
@@ -567,6 +577,11 @@ class SubscriptionService:
|
|||||||
bonus_days: int,
|
bonus_days: int,
|
||||||
reason: str = "bonus",
|
reason: str = "bonus",
|
||||||
) -> Optional[datetime]:
|
) -> Optional[datetime]:
|
||||||
|
reason_lower = (reason or "").lower()
|
||||||
|
apply_main_traffic_limit = any(
|
||||||
|
keyword in reason_lower for keyword in ("admin", "promo code", "referral", "bonus")
|
||||||
|
)
|
||||||
|
|
||||||
user = await user_dal.get_user_by_id(session, user_id)
|
user = await user_dal.get_user_by_id(session, user_id)
|
||||||
if not user:
|
if not user:
|
||||||
logging.warning(
|
logging.warning(
|
||||||
@@ -592,10 +607,14 @@ class SubscriptionService:
|
|||||||
)
|
)
|
||||||
start_date = datetime.now(timezone.utc)
|
start_date = datetime.now(timezone.utc)
|
||||||
new_end_date_obj = start_date + timedelta(days=bonus_days)
|
new_end_date_obj = start_date + timedelta(days=bonus_days)
|
||||||
|
|
||||||
# For promo code activations, use the configured user traffic limit
|
# Apply main traffic limit for admin/referral/promo bonuses, fallback to trial limit otherwise
|
||||||
traffic_limit = self.settings.user_traffic_limit_bytes if "promo code" in reason.lower() else self.settings.trial_traffic_limit_bytes
|
traffic_limit = (
|
||||||
|
self.settings.user_traffic_limit_bytes
|
||||||
|
if apply_main_traffic_limit
|
||||||
|
else self.settings.trial_traffic_limit_bytes
|
||||||
|
)
|
||||||
|
|
||||||
bonus_sub_payload = {
|
bonus_sub_payload = {
|
||||||
"user_id": user_id,
|
"user_id": user_id,
|
||||||
"panel_user_uuid": panel_uuid,
|
"panel_user_uuid": panel_uuid,
|
||||||
@@ -606,6 +625,7 @@ class SubscriptionService:
|
|||||||
"is_active": True,
|
"is_active": True,
|
||||||
"status_from_panel": "ACTIVE_BONUS",
|
"status_from_panel": "ACTIVE_BONUS",
|
||||||
"traffic_limit_bytes": traffic_limit,
|
"traffic_limit_bytes": traffic_limit,
|
||||||
|
"auto_renew_enabled": False,
|
||||||
}
|
}
|
||||||
await subscription_dal.deactivate_other_active_subscriptions(
|
await subscription_dal.deactivate_other_active_subscriptions(
|
||||||
session, panel_uuid, panel_sub_uuid
|
session, panel_uuid, panel_sub_uuid
|
||||||
@@ -625,16 +645,27 @@ class SubscriptionService:
|
|||||||
session, active_sub.subscription_id, new_end_date_obj
|
session, active_sub.subscription_id, new_end_date_obj
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
apply_main_traffic_limit
|
||||||
|
and updated_sub_model
|
||||||
|
and updated_sub_model.traffic_limit_bytes != self.settings.user_traffic_limit_bytes
|
||||||
|
):
|
||||||
|
updated_sub_model = await subscription_dal.update_subscription(
|
||||||
|
session,
|
||||||
|
updated_sub_model.subscription_id,
|
||||||
|
{"traffic_limit_bytes": self.settings.user_traffic_limit_bytes},
|
||||||
|
)
|
||||||
|
|
||||||
if updated_sub_model:
|
if updated_sub_model:
|
||||||
# Prepare panel update payload
|
# Prepare panel update payload
|
||||||
panel_update_payload = self._build_panel_update_payload(
|
panel_update_payload = self._build_panel_update_payload(
|
||||||
expire_at=new_end_date_obj,
|
expire_at=new_end_date_obj,
|
||||||
traffic_limit_bytes=(
|
traffic_limit_bytes=(
|
||||||
self.settings.user_traffic_limit_bytes if "promo code" in reason.lower() else None
|
self.settings.user_traffic_limit_bytes if apply_main_traffic_limit else None
|
||||||
),
|
),
|
||||||
include_uuid=False,
|
include_uuid=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
panel_update_success = (
|
panel_update_success = (
|
||||||
await self.panel_service.update_user_details_on_panel(
|
await self.panel_service.update_user_details_on_panel(
|
||||||
panel_uuid,
|
panel_uuid,
|
||||||
@@ -738,8 +769,12 @@ class SubscriptionService:
|
|||||||
if panel_user_data.get("expireAt")
|
if panel_user_data.get("expireAt")
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
|
hwid_limit = panel_user_data.get("hwidDeviceLimit")
|
||||||
|
if hwid_limit is None:
|
||||||
|
hwid_limit = self.settings.USER_HWID_DEVICE_LIMIT
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
"user_id": panel_user_data.get("uuid"),
|
||||||
"end_date": panel_end_date,
|
"end_date": panel_end_date,
|
||||||
"status_from_panel": panel_user_data.get("status", "UNKNOWN").upper(),
|
"status_from_panel": panel_user_data.get("status", "UNKNOWN").upper(),
|
||||||
"config_link": panel_user_data.get("subscriptionUrl"),
|
"config_link": panel_user_data.get("subscriptionUrl"),
|
||||||
@@ -747,6 +782,7 @@ class SubscriptionService:
|
|||||||
"traffic_used_bytes": panel_user_data.get("usedTrafficBytes"),
|
"traffic_used_bytes": panel_user_data.get("usedTrafficBytes"),
|
||||||
"user_bot_username": db_user.username,
|
"user_bot_username": db_user.username,
|
||||||
"is_panel_data": True,
|
"is_panel_data": True,
|
||||||
|
"max_devices": hwid_limit,
|
||||||
}
|
}
|
||||||
|
|
||||||
async def get_subscriptions_ending_soon(
|
async def get_subscriptions_ending_soon(
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from bot.services.referral_service import ReferralService
|
|||||||
from .notification_service import NotificationService
|
from .notification_service import NotificationService
|
||||||
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
|
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
|
||||||
from db.dal import payment_dal, user_dal, subscription_dal
|
from db.dal import payment_dal, user_dal, subscription_dal
|
||||||
|
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
|
||||||
|
|
||||||
|
|
||||||
def convert_period_to_months(period: Optional[str]) -> int:
|
def convert_period_to_months(period: Optional[str]) -> int:
|
||||||
@@ -184,10 +185,12 @@ class TributeService:
|
|||||||
inviter_name_display = _('friend_placeholder')
|
inviter_name_display = _('friend_placeholder')
|
||||||
if db_user and db_user.referred_by_id:
|
if db_user and db_user.referred_by_id:
|
||||||
inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id)
|
inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id)
|
||||||
if inviter and inviter.first_name:
|
if inviter:
|
||||||
inviter_name_display = inviter.first_name
|
safe_name = sanitize_display_name(inviter.first_name) if inviter.first_name else None
|
||||||
elif inviter and inviter.username:
|
if safe_name:
|
||||||
inviter_name_display = f"@{inviter.username}"
|
inviter_name_display = safe_name
|
||||||
|
elif inviter.username:
|
||||||
|
inviter_name_display = username_for_display(inviter.username, with_at=False)
|
||||||
success_msg = _(
|
success_msg = _(
|
||||||
"payment_successful_with_referral_bonus_full",
|
"payment_successful_with_referral_bonus_full",
|
||||||
months=months,
|
months=months,
|
||||||
@@ -205,7 +208,11 @@ class TributeService:
|
|||||||
config_link=config_link,
|
config_link=config_link,
|
||||||
)
|
)
|
||||||
markup = get_connect_and_main_keyboard(
|
markup = get_connect_and_main_keyboard(
|
||||||
lang, i18n, settings, config_link
|
lang,
|
||||||
|
i18n,
|
||||||
|
settings,
|
||||||
|
config_link,
|
||||||
|
preserve_message=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -250,8 +257,39 @@ class TributeService:
|
|||||||
from bot.keyboards.inline.user_keyboards import get_subscribe_only_markup
|
from bot.keyboards.inline.user_keyboards import get_subscribe_only_markup
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Set all user's subscriptions to expire in 1 day (grace period)
|
grace_days = 1
|
||||||
await subscription_dal.set_user_subscriptions_cancelled_with_grace(session, user_id, grace_days=1)
|
grace_end = datetime.now(timezone.utc) + timedelta(days=grace_days)
|
||||||
|
|
||||||
|
active_subscriptions = await subscription_dal.get_active_subscriptions_for_user(session, user_id)
|
||||||
|
|
||||||
|
panel_users_updated: set[str] = set()
|
||||||
|
for sub in active_subscriptions:
|
||||||
|
updated_sub = await subscription_dal.update_subscription(
|
||||||
|
session,
|
||||||
|
sub.subscription_id,
|
||||||
|
{
|
||||||
|
"end_date": grace_end,
|
||||||
|
"status_from_panel": "CANCELLED",
|
||||||
|
"skip_notifications": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
panel_uuid = updated_sub.panel_user_uuid if updated_sub else None
|
||||||
|
if panel_uuid and panel_uuid not in panel_users_updated:
|
||||||
|
panel_users_updated.add(panel_uuid)
|
||||||
|
panel_payload = {
|
||||||
|
"expireAt": grace_end.isoformat(timespec="milliseconds").replace("+00:00", "Z"),
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
await self.panel_service.update_user_details_on_panel(
|
||||||
|
panel_uuid,
|
||||||
|
panel_payload,
|
||||||
|
log_response=False,
|
||||||
|
)
|
||||||
|
except Exception as panel_err:
|
||||||
|
logging.error(
|
||||||
|
f"Failed to update panel expiry for user {user_id} (panel_uuid {panel_uuid}) during Tribute cancellation: {panel_err}")
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
# Send notification about cancellation if enabled
|
# Send notification about cancellation if enabled
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ class AdminStates(StatesGroup):
|
|||||||
waiting_for_user_search = State()
|
waiting_for_user_search = State()
|
||||||
waiting_for_subscription_days_to_add = State()
|
waiting_for_subscription_days_to_add = State()
|
||||||
waiting_for_direct_message_to_user = State()
|
waiting_for_direct_message_to_user = State()
|
||||||
|
waiting_for_user_delete_confirmation = State()
|
||||||
|
|
||||||
# Ads campaigns
|
# Ads campaigns
|
||||||
waiting_for_ad_source = State()
|
waiting_for_ad_source = State()
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ class MessageQueue:
|
|||||||
self.last_send_times: deque[datetime] = deque()
|
self.last_send_times: deque[datetime] = deque()
|
||||||
self.is_processing = False
|
self.is_processing = False
|
||||||
self.delay_between_messages = 1.0 / messages_per_second
|
self.delay_between_messages = 1.0 / messages_per_second
|
||||||
|
self.total_sent = 0
|
||||||
|
self.total_failed = 0
|
||||||
|
|
||||||
async def add_message(self, message: QueuedMessage) -> None:
|
async def add_message(self, message: QueuedMessage) -> None:
|
||||||
"""Add message to queue"""
|
"""Add message to queue"""
|
||||||
@@ -50,6 +52,7 @@ class MessageQueue:
|
|||||||
try:
|
try:
|
||||||
await self._send_message(message)
|
await self._send_message(message)
|
||||||
self.last_send_times.append(datetime.now())
|
self.last_send_times.append(datetime.now())
|
||||||
|
self.total_sent += 1
|
||||||
|
|
||||||
# Keep only recent send times (last minute)
|
# Keep only recent send times (last minute)
|
||||||
cutoff_time = datetime.now() - timedelta(seconds=60)
|
cutoff_time = datetime.now() - timedelta(seconds=60)
|
||||||
@@ -57,6 +60,7 @@ class MessageQueue:
|
|||||||
self.last_send_times.popleft()
|
self.last_send_times.popleft()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
self.total_failed += 1
|
||||||
logging.error(f"Failed to send queued message to {message.chat_id}: {e}")
|
logging.error(f"Failed to send queued message to {message.chat_id}: {e}")
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
@@ -233,7 +237,11 @@ class MessageQueueManager:
|
|||||||
"group_queue_processing": self.group_queue.is_processing,
|
"group_queue_processing": self.group_queue.is_processing,
|
||||||
"user_queue_processing": self.user_queue.is_processing,
|
"user_queue_processing": self.user_queue.is_processing,
|
||||||
"group_recent_sends": len(self.group_queue.last_send_times),
|
"group_recent_sends": len(self.group_queue.last_send_times),
|
||||||
"user_recent_sends": len(self.user_queue.last_send_times)
|
"user_recent_sends": len(self.user_queue.last_send_times),
|
||||||
|
"group_failed_messages": self.group_queue.total_failed,
|
||||||
|
"user_failed_messages": self.user_queue.total_failed,
|
||||||
|
"group_sent_messages": self.group_queue.total_sent,
|
||||||
|
"user_sent_messages": self.user_queue.total_sent,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -250,4 +258,4 @@ def init_queue_manager(bot: Bot) -> MessageQueueManager:
|
|||||||
|
|
||||||
def get_queue_manager() -> Optional[MessageQueueManager]:
|
def get_queue_manager() -> Optional[MessageQueueManager]:
|
||||||
"""Get global queue manager instance"""
|
"""Get global queue manager instance"""
|
||||||
return _queue_manager
|
return _queue_manager
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import re
|
||||||
|
import unicodedata
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
_OBFUSCATION_CHARS = " .\\-/\\\\•﹒٫_․·∙‧ꞏ‒–—﹘﹣⁻−"
|
||||||
|
|
||||||
|
_URL_PATTERNS = [
|
||||||
|
re.compile(r"(?i)https?://\S+"),
|
||||||
|
re.compile(r"(?i)www\.\S+"),
|
||||||
|
re.compile(r"(?i)tg://\S+"),
|
||||||
|
re.compile(r"(?i)telegram\.me\S*"),
|
||||||
|
re.compile(r"(?i)t\.me/\+\S*"),
|
||||||
|
re.compile(r"(?i)joinchat\S*"),
|
||||||
|
]
|
||||||
|
|
||||||
|
_OBFUSCATED_DOMAIN_PATTERNS = [
|
||||||
|
re.compile(
|
||||||
|
r"(?i)[tт][\s{}\u2022]*[\.{}\u2022]*[\s{}\u2022]*[mм][eе]".format(
|
||||||
|
re.escape(_OBFUSCATION_CHARS),
|
||||||
|
re.escape(_OBFUSCATION_CHARS),
|
||||||
|
re.escape(_OBFUSCATION_CHARS),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
re.compile(
|
||||||
|
r"(?i)[tт][{}\s]*[eе][{}\s]*[lłl1i|][{}\s]*[eе]"
|
||||||
|
r"[{}\s]*[gɢgqг][{}\s]*[rр][{}\s]*[aа]"
|
||||||
|
r"[{}\s]*(?:[mм]|rn)".format(
|
||||||
|
re.escape(_OBFUSCATION_CHARS),
|
||||||
|
re.escape(_OBFUSCATION_CHARS),
|
||||||
|
re.escape(_OBFUSCATION_CHARS),
|
||||||
|
re.escape(_OBFUSCATION_CHARS),
|
||||||
|
re.escape(_OBFUSCATION_CHARS),
|
||||||
|
re.escape(_OBFUSCATION_CHARS),
|
||||||
|
re.escape(_OBFUSCATION_CHARS),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
re.compile(r"(?i)t\.me\S*"),
|
||||||
|
]
|
||||||
|
|
||||||
|
_ENGLISH_SERVICE_PATTERNS = [
|
||||||
|
re.compile(r"(?i)telegram"),
|
||||||
|
re.compile(r"(?i)teleqram"),
|
||||||
|
re.compile(r"(?i)teiegram"),
|
||||||
|
re.compile(r"(?i)teieqram"),
|
||||||
|
re.compile(r"(?i)telegrarn"),
|
||||||
|
re.compile(r"(?i)service"),
|
||||||
|
re.compile(r"(?i)notif(?:ication)?"),
|
||||||
|
re.compile(r"(?i)system"),
|
||||||
|
re.compile(r"(?i)security"),
|
||||||
|
re.compile(r"(?i)safety"),
|
||||||
|
re.compile(r"(?i)support"),
|
||||||
|
re.compile(r"(?i)moderation"),
|
||||||
|
re.compile(r"(?i)review"),
|
||||||
|
re.compile(r"(?i)compliance"),
|
||||||
|
re.compile(r"(?i)abuse"),
|
||||||
|
re.compile(r"(?i)spam"),
|
||||||
|
re.compile(r"(?i)report"),
|
||||||
|
]
|
||||||
|
|
||||||
|
_RUSSIAN_SERVICE_PATTERNS = [
|
||||||
|
re.compile(r"(?i)телеграм\w*"),
|
||||||
|
re.compile(r"(?i)служебн\w*"),
|
||||||
|
re.compile(r"(?i)уведомлен\w*"),
|
||||||
|
re.compile(r"(?i)поддержк\w*"),
|
||||||
|
re.compile(r"(?i)безопасн\w*"),
|
||||||
|
re.compile(r"(?i)модерац\w*"),
|
||||||
|
re.compile(r"(?i)жалоб\w*"),
|
||||||
|
re.compile(r"(?i)абуз\w*"),
|
||||||
|
]
|
||||||
|
|
||||||
|
_PRE_LOWER_TRANSLATION = str.maketrans(
|
||||||
|
{
|
||||||
|
"I": "l",
|
||||||
|
"İ": "l",
|
||||||
|
"Q": "g",
|
||||||
|
"@": " ",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
_POST_LOWER_TRANSLATION = str.maketrans(
|
||||||
|
{
|
||||||
|
"а": "a",
|
||||||
|
"б": "b",
|
||||||
|
"в": "v",
|
||||||
|
"г": "g",
|
||||||
|
"д": "d",
|
||||||
|
"е": "e",
|
||||||
|
"ё": "e",
|
||||||
|
"ж": "zh",
|
||||||
|
"з": "z",
|
||||||
|
"и": "i",
|
||||||
|
"і": "i",
|
||||||
|
"й": "i",
|
||||||
|
"к": "k",
|
||||||
|
"л": "l",
|
||||||
|
"м": "m",
|
||||||
|
"н": "n",
|
||||||
|
"о": "o",
|
||||||
|
"п": "p",
|
||||||
|
"р": "r",
|
||||||
|
"с": "s",
|
||||||
|
"т": "t",
|
||||||
|
"у": "u",
|
||||||
|
"ф": "f",
|
||||||
|
"х": "h",
|
||||||
|
"ц": "c",
|
||||||
|
"ч": "ch",
|
||||||
|
"ш": "sh",
|
||||||
|
"щ": "sh",
|
||||||
|
"ъ": "",
|
||||||
|
"ы": "y",
|
||||||
|
"ь": "",
|
||||||
|
"э": "e",
|
||||||
|
"ю": "yu",
|
||||||
|
"я": "ya",
|
||||||
|
"_": "_",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
_NORMALIZED_BANNED_TOKENS = {
|
||||||
|
"tme",
|
||||||
|
"telegram",
|
||||||
|
"teleqram",
|
||||||
|
"teiegram",
|
||||||
|
"teieqram",
|
||||||
|
"telegrarn",
|
||||||
|
"joinchat",
|
||||||
|
"http",
|
||||||
|
"https",
|
||||||
|
"www",
|
||||||
|
"tg",
|
||||||
|
"service",
|
||||||
|
"notification",
|
||||||
|
"system",
|
||||||
|
"security",
|
||||||
|
"safety",
|
||||||
|
"support",
|
||||||
|
"moderation",
|
||||||
|
"review",
|
||||||
|
"compliance",
|
||||||
|
"abuse",
|
||||||
|
"spam",
|
||||||
|
"report",
|
||||||
|
}
|
||||||
|
|
||||||
|
_USERNAME_PLACEHOLDER = "клиент"
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_for_detection(value: str) -> str:
|
||||||
|
if not value:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
normalized = unicodedata.normalize("NFKD", value)
|
||||||
|
normalized = normalized.translate(_PRE_LOWER_TRANSLATION)
|
||||||
|
normalized = normalized.lower()
|
||||||
|
normalized = "".join(
|
||||||
|
ch for ch in normalized if unicodedata.category(ch) != "Mn"
|
||||||
|
)
|
||||||
|
normalized = normalized.translate(_POST_LOWER_TRANSLATION)
|
||||||
|
normalized = normalized.replace("rn", "m")
|
||||||
|
|
||||||
|
pattern = rf"[{re.escape(_OBFUSCATION_CHARS)}\s]+"
|
||||||
|
normalized = re.sub(pattern, "", normalized)
|
||||||
|
normalized = re.sub(r"[^a-z0-9]+", "", normalized)
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def _remove_patterns(value: str) -> str:
|
||||||
|
updated = value
|
||||||
|
for pattern in (
|
||||||
|
_URL_PATTERNS
|
||||||
|
+ _OBFUSCATED_DOMAIN_PATTERNS
|
||||||
|
+ _ENGLISH_SERVICE_PATTERNS
|
||||||
|
+ _RUSSIAN_SERVICE_PATTERNS
|
||||||
|
):
|
||||||
|
updated = pattern.sub(" ", updated)
|
||||||
|
return updated
|
||||||
|
|
||||||
|
|
||||||
|
def _finalize(value: str) -> Optional[str]:
|
||||||
|
compacted = re.sub(r"\s+", " ", value)
|
||||||
|
compacted = compacted.strip(" \t\r\n-_.,/\\")
|
||||||
|
compacted = compacted.strip()
|
||||||
|
if not compacted:
|
||||||
|
return None
|
||||||
|
|
||||||
|
normalized = _normalize_for_detection(compacted)
|
||||||
|
if any(token in normalized for token in _NORMALIZED_BANNED_TOKENS):
|
||||||
|
return None
|
||||||
|
return compacted
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_display_name(value: Optional[str]) -> Optional[str]:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
clean = value.replace("@", " ")
|
||||||
|
clean = _remove_patterns(clean)
|
||||||
|
return _finalize(clean)
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_username(value: Optional[str]) -> Optional[str]:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
clean = value.strip()
|
||||||
|
clean = clean.lstrip("@")
|
||||||
|
clean = _remove_patterns(clean)
|
||||||
|
return _finalize(clean)
|
||||||
|
|
||||||
|
|
||||||
|
def username_for_display(username: Optional[str], with_at: bool = False) -> str:
|
||||||
|
sanitized = sanitize_username(username)
|
||||||
|
if not sanitized:
|
||||||
|
return _USERNAME_PLACEHOLDER
|
||||||
|
return f"@{sanitized}" if with_at else sanitized
|
||||||
|
|
||||||
|
|
||||||
|
def display_name_or_fallback(
|
||||||
|
first_name: Optional[str],
|
||||||
|
fallback: Optional[str] = None,
|
||||||
|
) -> str:
|
||||||
|
sanitized = sanitize_display_name(first_name)
|
||||||
|
if sanitized:
|
||||||
|
return sanitized
|
||||||
|
if fallback is not None:
|
||||||
|
return fallback
|
||||||
|
return _USERNAME_PLACEHOLDER
|
||||||
@@ -23,6 +23,12 @@ 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)
|
||||||
|
REQUIRED_CHANNEL_ID: Optional[int] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Telegram channel ID the user must join to access the bot")
|
||||||
|
REQUIRED_CHANNEL_LINK: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Public username or invite link to the required channel for join button")
|
||||||
|
|
||||||
YOOKASSA_SHOP_ID: Optional[str] = None
|
YOOKASSA_SHOP_ID: Optional[str] = None
|
||||||
YOOKASSA_SECRET_KEY: Optional[str] = None
|
YOOKASSA_SECRET_KEY: Optional[str] = None
|
||||||
@@ -35,6 +41,10 @@ class Settings(BaseSettings):
|
|||||||
YOOKASSA_PAYMENT_SUBJECT: str = Field(default="service")
|
YOOKASSA_PAYMENT_SUBJECT: str = Field(default="service")
|
||||||
# Single toggle to enable recurring payments (saving cards, managing payment methods, auto-renew)
|
# Single toggle to enable recurring payments (saving cards, managing payment methods, auto-renew)
|
||||||
YOOKASSA_AUTOPAYMENTS_ENABLED: bool = Field(default=False)
|
YOOKASSA_AUTOPAYMENTS_ENABLED: bool = Field(default=False)
|
||||||
|
YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING: bool = Field(
|
||||||
|
default=True,
|
||||||
|
description="When true, new YooKassa payments in autopay mode force card binding without a user checkbox."
|
||||||
|
)
|
||||||
|
|
||||||
WEBHOOK_BASE_URL: Optional[str] = None
|
WEBHOOK_BASE_URL: Optional[str] = None
|
||||||
|
|
||||||
@@ -44,6 +54,15 @@ class Settings(BaseSettings):
|
|||||||
CRYPTOPAY_ASSET: str = Field(default="RUB")
|
CRYPTOPAY_ASSET: str = Field(default="RUB")
|
||||||
CRYPTOPAY_ENABLED: bool = Field(default=True)
|
CRYPTOPAY_ENABLED: bool = Field(default=True)
|
||||||
|
|
||||||
|
FREEKASSA_ENABLED: bool = Field(default=False)
|
||||||
|
FREEKASSA_MERCHANT_ID: Optional[str] = None
|
||||||
|
FREEKASSA_FIRST_SECRET: Optional[str] = None
|
||||||
|
FREEKASSA_SECOND_SECRET: Optional[str] = None
|
||||||
|
FREEKASSA_PAYMENT_URL: str = Field(default="https://pay.freekassa.ru/")
|
||||||
|
FREEKASSA_API_KEY: Optional[str] = None
|
||||||
|
FREEKASSA_PAYMENT_IP: Optional[str] = None
|
||||||
|
FREEKASSA_PAYMENT_METHOD_ID: Optional[int] = None
|
||||||
|
|
||||||
YOOKASSA_ENABLED: bool = Field(default=True)
|
YOOKASSA_ENABLED: bool = Field(default=True)
|
||||||
STARS_ENABLED: bool = Field(default=True)
|
STARS_ENABLED: bool = Field(default=True)
|
||||||
TRIBUTE_ENABLED: bool = Field(default=True)
|
TRIBUTE_ENABLED: bool = Field(default=True)
|
||||||
@@ -123,6 +142,15 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
START_COMMAND_DESCRIPTION: Optional[str] = Field(default=None)
|
START_COMMAND_DESCRIPTION: Optional[str] = Field(default=None)
|
||||||
DISABLE_WELCOME_MESSAGE: bool = Field(default=False, description="Disable welcome message on /start command")
|
DISABLE_WELCOME_MESSAGE: bool = Field(default=False, description="Disable welcome message on /start command")
|
||||||
|
|
||||||
|
MY_DEVICES_SECTION_ENABLED: bool = Field(
|
||||||
|
default=False,
|
||||||
|
description="Enable the My Devices section in the subscription menu"
|
||||||
|
)
|
||||||
|
USER_HWID_DEVICE_LIMIT: Optional[int] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Default hardware device limit for panel users (0 = unlimited)"
|
||||||
|
)
|
||||||
|
|
||||||
# Inline mode thumbnail URLs
|
# Inline mode thumbnail URLs
|
||||||
INLINE_REFERRAL_THUMBNAIL_URL: str = Field(default="https://cdn-icons-png.flaticon.com/512/1077/1077114.png")
|
INLINE_REFERRAL_THUMBNAIL_URL: str = Field(default="https://cdn-icons-png.flaticon.com/512/1077/1077114.png")
|
||||||
@@ -236,6 +264,19 @@ class Settings(BaseSettings):
|
|||||||
return f"{base.rstrip('/')}{self.cryptopay_webhook_path}"
|
return f"{base.rstrip('/')}{self.cryptopay_webhook_path}"
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@computed_field
|
||||||
|
@property
|
||||||
|
def freekassa_webhook_path(self) -> str:
|
||||||
|
return "/webhook/freekassa"
|
||||||
|
|
||||||
|
@computed_field
|
||||||
|
@property
|
||||||
|
def freekassa_full_webhook_url(self) -> Optional[str]:
|
||||||
|
base = self.WEBHOOK_BASE_URL
|
||||||
|
if base:
|
||||||
|
return f"{base.rstrip('/')}{self.freekassa_webhook_path}"
|
||||||
|
return None
|
||||||
|
|
||||||
# Computed YooKassa receipt fields based on recurring toggle
|
# Computed YooKassa receipt fields based on recurring toggle
|
||||||
@computed_field
|
@computed_field
|
||||||
@property
|
@property
|
||||||
@@ -331,6 +372,22 @@ class Settings(BaseSettings):
|
|||||||
if isinstance(v, str) and v.strip() == '':
|
if isinstance(v, str) and v.strip() == '':
|
||||||
return None
|
return None
|
||||||
return v
|
return v
|
||||||
|
|
||||||
|
@field_validator('REQUIRED_CHANNEL_LINK', mode='before')
|
||||||
|
@classmethod
|
||||||
|
def sanitize_optional_link(cls, v):
|
||||||
|
if isinstance(v, str) and not v.strip():
|
||||||
|
return None
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator('USER_HWID_DEVICE_LIMIT', mode='before')
|
||||||
|
@classmethod
|
||||||
|
def validate_optional_int(cls, v):
|
||||||
|
if isinstance(v, str):
|
||||||
|
v = v.strip()
|
||||||
|
if not v:
|
||||||
|
return None
|
||||||
|
return v
|
||||||
|
|
||||||
# Notification types
|
# Notification types
|
||||||
LOG_NEW_USERS: bool = Field(default=True, description="Send notifications for new user registrations")
|
LOG_NEW_USERS: bool = Field(default=True, description="Send notifications for new user registrations")
|
||||||
@@ -366,6 +423,22 @@ def get_settings() -> Settings:
|
|||||||
logging.warning(
|
logging.warning(
|
||||||
"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 _settings_instance.FREEKASSA_ENABLED:
|
||||||
|
if (
|
||||||
|
not _settings_instance.FREEKASSA_MERCHANT_ID
|
||||||
|
or not _settings_instance.FREEKASSA_API_KEY
|
||||||
|
):
|
||||||
|
logging.warning(
|
||||||
|
"CRITICAL: FreeKassa is enabled but SHOP_ID or API key is missing. FreeKassa payments will not work."
|
||||||
|
)
|
||||||
|
if not _settings_instance.FREEKASSA_SECOND_SECRET:
|
||||||
|
logging.warning(
|
||||||
|
"WARNING: FreeKassa second secret is not set. Incoming payment notifications cannot be verified."
|
||||||
|
)
|
||||||
|
if not _settings_instance.subscription_options:
|
||||||
|
logging.warning(
|
||||||
|
"CRITICAL: FreeKassa is enabled but no subscription prices are configured (RUB_PRICE_*). Users will not see payment buttons."
|
||||||
|
)
|
||||||
|
|
||||||
except ValidationError as e:
|
except ValidationError as e:
|
||||||
logging.critical(
|
logging.critical(
|
||||||
|
|||||||
+42
-18
@@ -98,23 +98,37 @@ async def get_campaign_stats(session: AsyncSession, campaign_id: int) -> Dict[st
|
|||||||
trials = (await session.execute(trials_stmt)).scalar() or 0
|
trials = (await session.execute(trials_stmt)).scalar() or 0
|
||||||
|
|
||||||
# Payers (unique users with succeeded payments)
|
# Payers (unique users with succeeded payments)
|
||||||
payers_stmt = select(func.count(func.distinct(Payment.user_id))).select_from(Payment).where(
|
attrib_subq = (
|
||||||
and_(
|
select(
|
||||||
Payment.status == "succeeded",
|
AdAttribution.user_id.label("user_id"),
|
||||||
Payment.user_id.in_(
|
AdAttribution.first_start_at.label("first_start_at"),
|
||||||
select(AdAttribution.user_id).where(AdAttribution.ad_campaign_id == campaign_id)
|
)
|
||||||
),
|
.where(AdAttribution.ad_campaign_id == campaign_id)
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
payers_stmt = (
|
||||||
|
select(func.count(func.distinct(Payment.user_id)))
|
||||||
|
.select_from(Payment)
|
||||||
|
.join(attrib_subq, Payment.user_id == attrib_subq.c.user_id)
|
||||||
|
.where(
|
||||||
|
and_(
|
||||||
|
Payment.status == "succeeded",
|
||||||
|
Payment.created_at >= attrib_subq.c.first_start_at,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
payers = (await session.execute(payers_stmt)).scalar() or 0
|
payers = (await session.execute(payers_stmt)).scalar() or 0
|
||||||
|
|
||||||
# Revenue sum
|
# Revenue sum
|
||||||
revenue_stmt = select(func.coalesce(func.sum(Payment.amount), 0.0)).select_from(Payment).where(
|
revenue_stmt = (
|
||||||
and_(
|
select(func.coalesce(func.sum(Payment.amount), 0.0))
|
||||||
Payment.status == "succeeded",
|
.select_from(Payment)
|
||||||
Payment.user_id.in_(
|
.join(attrib_subq, Payment.user_id == attrib_subq.c.user_id)
|
||||||
select(AdAttribution.user_id).where(AdAttribution.ad_campaign_id == campaign_id)
|
.where(
|
||||||
),
|
and_(
|
||||||
|
Payment.status == "succeeded",
|
||||||
|
Payment.created_at >= attrib_subq.c.first_start_at,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
revenue = float((await session.execute(revenue_stmt)).scalar() or 0.0)
|
revenue = float((await session.execute(revenue_stmt)).scalar() or 0.0)
|
||||||
@@ -151,10 +165,22 @@ async def get_totals(session: AsyncSession) -> Dict[str, float]:
|
|||||||
total_cost = float((await session.execute(total_cost_stmt)).scalar() or 0.0)
|
total_cost = float((await session.execute(total_cost_stmt)).scalar() or 0.0)
|
||||||
|
|
||||||
# Total revenue from all attributed users (unique users counted across all campaigns)
|
# Total revenue from all attributed users (unique users counted across all campaigns)
|
||||||
revenue_stmt = select(func.coalesce(func.sum(Payment.amount), 0.0)).select_from(Payment).where(
|
attrib_subq = (
|
||||||
and_(
|
select(
|
||||||
Payment.status == "succeeded",
|
AdAttribution.user_id.label("user_id"),
|
||||||
Payment.user_id.in_(select(AdAttribution.user_id)),
|
AdAttribution.first_start_at.label("first_start_at"),
|
||||||
|
)
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
revenue_stmt = (
|
||||||
|
select(func.coalesce(func.sum(Payment.amount), 0.0))
|
||||||
|
.select_from(Payment)
|
||||||
|
.join(attrib_subq, Payment.user_id == attrib_subq.c.user_id)
|
||||||
|
.where(
|
||||||
|
and_(
|
||||||
|
Payment.status == "succeeded",
|
||||||
|
Payment.created_at >= attrib_subq.c.first_start_at,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
total_revenue = float((await session.execute(revenue_stmt)).scalar() or 0.0)
|
total_revenue = float((await session.execute(revenue_stmt)).scalar() or 0.0)
|
||||||
@@ -178,5 +204,3 @@ async def delete_campaign(session: AsyncSession, campaign_id: int) -> bool:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Failed to delete AdCampaign id={campaign_id}: {e}", exc_info=True)
|
logging.error(f"Failed to delete AdCampaign id={campaign_id}: {e}", exc_info=True)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -262,3 +262,37 @@ async def get_last_tribute_payment(
|
|||||||
Payment.created_at.desc()).limit(1))
|
Payment.created_at.desc()).limit(1))
|
||||||
result = await session.execute(stmt)
|
result = await session.execute(stmt)
|
||||||
return result.scalar_one_or_none()
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_user_total_paid(session: AsyncSession, user_id: int) -> float:
|
||||||
|
"""Get total amount paid by a specific user (sum of all succeeded payments)."""
|
||||||
|
stmt = select(func.sum(Payment.amount)).where(
|
||||||
|
and_(
|
||||||
|
Payment.user_id == user_id,
|
||||||
|
Payment.status == 'succeeded'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
total = result.scalar()
|
||||||
|
return float(total or 0)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_referral_revenue(session: AsyncSession, referrer_id: int) -> float:
|
||||||
|
"""Get total revenue generated from referred users' payments.
|
||||||
|
|
||||||
|
This calculates the sum of all succeeded payments made by users
|
||||||
|
where referred_by_id equals the referrer_id.
|
||||||
|
"""
|
||||||
|
from db.models import User
|
||||||
|
|
||||||
|
stmt = select(func.sum(Payment.amount)).join(
|
||||||
|
User, Payment.user_id == User.user_id
|
||||||
|
).where(
|
||||||
|
and_(
|
||||||
|
User.referred_by_id == referrer_id,
|
||||||
|
Payment.status == 'succeeded'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
total = result.scalar()
|
||||||
|
return float(total or 0)
|
||||||
@@ -162,3 +162,19 @@ async def delete_user_payment_method_by_provider_id(
|
|||||||
await session.delete(method)
|
await session.delete(method)
|
||||||
await session.flush()
|
await session.flush()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def user_has_saved_payment_method(
|
||||||
|
session: AsyncSession,
|
||||||
|
user_id: int,
|
||||||
|
provider: str = "yookassa",
|
||||||
|
) -> bool:
|
||||||
|
"""Return True if the user has at least one saved payment method."""
|
||||||
|
try:
|
||||||
|
methods = await list_user_payment_methods(session, user_id, provider)
|
||||||
|
if methods:
|
||||||
|
return True
|
||||||
|
billing = await get_user_billing(session, user_id)
|
||||||
|
return bool(billing and billing.yookassa_payment_method_id)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|||||||
+72
-2
@@ -3,11 +3,20 @@ 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_
|
from sqlalchemy import update, delete, func, and_, or_
|
||||||
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
|
||||||
|
|
||||||
from ..models import User, Subscription
|
from ..models import (
|
||||||
|
User,
|
||||||
|
Subscription,
|
||||||
|
Payment,
|
||||||
|
PromoCodeActivation,
|
||||||
|
MessageLog,
|
||||||
|
UserBilling,
|
||||||
|
UserPaymentMethod,
|
||||||
|
AdAttribution,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def get_user_by_id(session: AsyncSession, user_id: int) -> Optional[User]:
|
async def get_user_by_id(session: AsyncSession, user_id: int) -> Optional[User]:
|
||||||
@@ -102,6 +111,29 @@ async def get_banned_users(session: AsyncSession) -> List[User]:
|
|||||||
return result.scalars().all()
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_all_users_paginated(
|
||||||
|
session: AsyncSession, *, page: int = 0, page_size: int = 15
|
||||||
|
) -> List[User]:
|
||||||
|
"""Return a slice of users ordered by newest registration first."""
|
||||||
|
safe_page = max(page, 0)
|
||||||
|
safe_page_size = max(page_size, 1)
|
||||||
|
|
||||||
|
stmt = (
|
||||||
|
select(User)
|
||||||
|
.order_by(User.registration_date.desc())
|
||||||
|
.offset(safe_page * safe_page_size)
|
||||||
|
.limit(safe_page_size)
|
||||||
|
)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
async def count_all_users(session: AsyncSession) -> int:
|
||||||
|
"""Count total number of users."""
|
||||||
|
result = await session.execute(select(func.count(User.user_id)))
|
||||||
|
return result.scalar_one()
|
||||||
|
|
||||||
|
|
||||||
async def get_all_active_user_ids_for_broadcast(session: AsyncSession) -> List[int]:
|
async def get_all_active_user_ids_for_broadcast(session: AsyncSession) -> List[int]:
|
||||||
stmt = select(User.user_id).where(User.is_banned == False)
|
stmt = select(User.user_id).where(User.is_banned == False)
|
||||||
result = await session.execute(stmt)
|
result = await session.execute(stmt)
|
||||||
@@ -227,3 +259,41 @@ async def get_user_ids_without_active_subscription(session: AsyncSession) -> Lis
|
|||||||
)
|
)
|
||||||
result = await session.execute(stmt)
|
result = await session.execute(stmt)
|
||||||
return result.scalars().all()
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_user_and_relations(session: AsyncSession, user_id: int) -> bool:
|
||||||
|
"""Completely remove a user and all dependent records from the database.
|
||||||
|
|
||||||
|
This helper ensures we do not leave dangling foreign keys or orphaned data.
|
||||||
|
"""
|
||||||
|
user = await get_user_by_id(session, user_id)
|
||||||
|
if not user:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Ensure referral pointers do not block deletion
|
||||||
|
await session.execute(
|
||||||
|
update(User).where(User.referred_by_id == user_id).values(referred_by_id=None)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Clean up dependent tables that do not cascade automatically
|
||||||
|
await session.execute(
|
||||||
|
delete(MessageLog).where(
|
||||||
|
or_(MessageLog.user_id == user_id, MessageLog.target_user_id == user_id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.execute(delete(Payment).where(Payment.user_id == user_id))
|
||||||
|
await session.execute(
|
||||||
|
delete(Subscription).where(Subscription.user_id == user_id)
|
||||||
|
)
|
||||||
|
await session.execute(
|
||||||
|
delete(PromoCodeActivation).where(PromoCodeActivation.user_id == user_id)
|
||||||
|
)
|
||||||
|
await session.execute(
|
||||||
|
delete(UserPaymentMethod).where(UserPaymentMethod.user_id == user_id)
|
||||||
|
)
|
||||||
|
await session.execute(delete(UserBilling).where(UserBilling.user_id == user_id))
|
||||||
|
await session.execute(delete(AdAttribution).where(AdAttribution.user_id == user_id))
|
||||||
|
|
||||||
|
await session.delete(user)
|
||||||
|
await session.flush()
|
||||||
|
return True
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from sqlalchemy.orm import sessionmaker
|
|||||||
|
|
||||||
from config.settings import Settings
|
from config.settings import Settings
|
||||||
from .models import Base
|
from .models import Base
|
||||||
from .migrator import run_simple_migrations
|
from .migrator import run_database_migrations
|
||||||
|
|
||||||
async_engine = None
|
async_engine = None
|
||||||
|
|
||||||
@@ -63,8 +63,7 @@ async def init_db(settings: Settings, session_factory: sessionmaker):
|
|||||||
|
|
||||||
async with async_engine.begin() as conn:
|
async with async_engine.begin() as conn:
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
# Run lightweight, idempotent migrations to add any missing columns
|
await conn.run_sync(run_database_migrations)
|
||||||
await conn.run_sync(run_simple_migrations)
|
|
||||||
logging.info(
|
logging.info(
|
||||||
"PostgreSQL database initialized/checked successfully using SQLAlchemy."
|
"PostgreSQL database initialized/checked successfully using SQLAlchemy."
|
||||||
)
|
)
|
||||||
|
|||||||
+85
-53
@@ -1,66 +1,98 @@
|
|||||||
import logging
|
import logging
|
||||||
from typing import Set
|
from dataclasses import dataclass
|
||||||
|
from typing import Callable, List, Set
|
||||||
|
|
||||||
from sqlalchemy import inspect, text
|
from sqlalchemy import inspect, text
|
||||||
from sqlalchemy.engine import Connection
|
from sqlalchemy.engine import Connection
|
||||||
|
|
||||||
from .models import Base
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Migration:
|
||||||
|
id: str
|
||||||
|
description: str
|
||||||
|
upgrade: Callable[[Connection], None]
|
||||||
|
|
||||||
|
|
||||||
def _add_missing_columns(connection: Connection) -> None:
|
def _ensure_migrations_table(connection: Connection) -> None:
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
|
id VARCHAR(255) PRIMARY KEY,
|
||||||
|
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _migration_0001_add_channel_subscription_fields(connection: Connection) -> None:
|
||||||
inspector = inspect(connection)
|
inspector = inspect(connection)
|
||||||
metadata = Base.metadata
|
columns: Set[str] = {col["name"] for col in inspector.get_columns("users")}
|
||||||
|
statements: List[str] = []
|
||||||
|
|
||||||
existing_tables: Set[str] = set(inspector.get_table_names())
|
if "channel_subscription_verified" not in columns:
|
||||||
|
statements.append(
|
||||||
|
"ALTER TABLE users ADD COLUMN channel_subscription_verified BOOLEAN"
|
||||||
|
)
|
||||||
|
if "channel_subscription_checked_at" not in columns:
|
||||||
|
statements.append(
|
||||||
|
"ALTER TABLE users ADD COLUMN channel_subscription_checked_at TIMESTAMPTZ"
|
||||||
|
)
|
||||||
|
if "channel_subscription_verified_for" not in columns:
|
||||||
|
statements.append(
|
||||||
|
"ALTER TABLE users ADD COLUMN channel_subscription_verified_for BIGINT"
|
||||||
|
)
|
||||||
|
|
||||||
for table in metadata.tables.values():
|
for stmt in statements:
|
||||||
table_name = table.name
|
connection.execute(text(stmt))
|
||||||
if table_name not in existing_tables:
|
|
||||||
# Tables are created elsewhere via create_all; skip here.
|
|
||||||
|
MIGRATIONS: List[Migration] = [
|
||||||
|
Migration(
|
||||||
|
id="0001_add_channel_subscription_fields",
|
||||||
|
description="Add columns to track required channel subscription verification",
|
||||||
|
upgrade=_migration_0001_add_channel_subscription_fields,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def run_database_migrations(connection: Connection) -> None:
|
||||||
|
"""
|
||||||
|
Apply pending migrations sequentially. Already applied revisions are skipped.
|
||||||
|
"""
|
||||||
|
_ensure_migrations_table(connection)
|
||||||
|
|
||||||
|
applied_revisions: Set[str] = {
|
||||||
|
row[0]
|
||||||
|
for row in connection.execute(
|
||||||
|
text("SELECT id FROM schema_migrations")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
for migration in MIGRATIONS:
|
||||||
|
if migration.id in applied_revisions:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
existing_columns = {col_info["name"] for col_info in inspector.get_columns(table_name)}
|
logging.info(
|
||||||
|
"Migrator: applying %s – %s", migration.id, migration.description
|
||||||
for desired_column in table.columns:
|
)
|
||||||
if desired_column.name in existing_columns:
|
try:
|
||||||
continue
|
with connection.begin_nested():
|
||||||
|
migration.upgrade(connection)
|
||||||
# Build ADD COLUMN DDL
|
connection.execute(
|
||||||
preparer = connection.dialect.identifier_preparer
|
text(
|
||||||
table_quoted = preparer.format_table(table)
|
"INSERT INTO schema_migrations (id) VALUES (:revision)"
|
||||||
column_name_quoted = preparer.quote(desired_column.name)
|
),
|
||||||
column_type_sql = desired_column.type.compile(dialect=connection.dialect)
|
{"revision": migration.id},
|
||||||
|
)
|
||||||
default_clause = ""
|
except Exception as exc:
|
||||||
server_default = getattr(desired_column, "server_default", None)
|
logging.error(
|
||||||
if server_default is not None and getattr(server_default, "arg", None) is not None:
|
"Migrator: failed to apply %s (%s)",
|
||||||
try:
|
migration.id,
|
||||||
compiled_default = str(
|
migration.description,
|
||||||
server_default.arg.compile(dialect=connection.dialect)
|
exc_info=True,
|
||||||
)
|
|
||||||
default_clause = f" DEFAULT {compiled_default}"
|
|
||||||
except Exception: # best-effort
|
|
||||||
pass
|
|
||||||
|
|
||||||
# For safety, add new columns as NULLable to avoid failures on existing rows
|
|
||||||
# If strict NOT NULL is needed, it can be enforced manually later.
|
|
||||||
ddl = f"ALTER TABLE {table_quoted} ADD COLUMN {column_name_quoted} {column_type_sql}{default_clause}"
|
|
||||||
|
|
||||||
logging.info(
|
|
||||||
f"Migrator: adding missing column {desired_column.name} to table {table_name}"
|
|
||||||
)
|
)
|
||||||
connection.execute(text(ddl))
|
raise exc
|
||||||
|
else:
|
||||||
|
logging.info("Migrator: migration %s applied successfully", migration.id)
|
||||||
def run_simple_migrations(connection: Connection) -> None:
|
|
||||||
"""
|
|
||||||
Run lightweight, idempotent migrations:
|
|
||||||
- Ensure missing columns are added to existing tables to match models in db/models.py
|
|
||||||
Note: Table creation is handled separately via Base.metadata.create_all.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
_add_missing_columns(connection)
|
|
||||||
logging.info("Migrator: schema synchronized (columns added as needed).")
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"Migrator: failed to run simple migrations: {e}", exc_info=True)
|
|
||||||
raise
|
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ 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)
|
||||||
|
channel_subscription_verified = Column(Boolean, nullable=True)
|
||||||
|
channel_subscription_checked_at = Column(DateTime(timezone=True),
|
||||||
|
nullable=True)
|
||||||
|
channel_subscription_verified_for = Column(BigInteger, nullable=True)
|
||||||
|
|
||||||
referrer = relationship("User", remote_side=[user_id], backref="referrals")
|
referrer = relationship("User", remote_side=[user_id], backref="referrals")
|
||||||
subscriptions = relationship("Subscription",
|
subscriptions = relationship("Subscription",
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
services:
|
||||||
|
remnawave-tg-shop:
|
||||||
|
image: ghcr.io/machka-pasla/remnawave-tg-shop:latest
|
||||||
|
container_name: remnawave-tg-shop
|
||||||
|
hostname: remnawave-tg-shop
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
ports:
|
||||||
|
- 8080:8080
|
||||||
|
networks:
|
||||||
|
- remnawave-network
|
||||||
|
environment:
|
||||||
|
- TZ=UTC
|
||||||
|
volumes:
|
||||||
|
- ./locales:/app/locales
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- remnawave-tg-shop-db
|
||||||
|
remnawave-tg-shop-db:
|
||||||
|
image: postgres:17
|
||||||
|
container_name: remnawave-tg-shop-db
|
||||||
|
hostname: remnawave-tg-shop-db
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
- TZ=UTC
|
||||||
|
volumes:
|
||||||
|
- remnawave-tg-shop-db-data:/var/lib/postgresql/data
|
||||||
|
networks:
|
||||||
|
- remnawave-network
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 20
|
||||||
|
networks:
|
||||||
|
remnawave-network: null
|
||||||
|
volumes:
|
||||||
|
remnawave-tg-shop-db-data:
|
||||||
|
name: remnawave-tg-shop-db-data
|
||||||
+56
-13
@@ -1,11 +1,15 @@
|
|||||||
{
|
{
|
||||||
"welcome": "Welcome, {user_name}!",
|
"welcome": "Welcome, {user_name}!",
|
||||||
|
"channel_subscription_required": "To use the bot, join the channel and tap \"Check subscription\".",
|
||||||
|
"channel_subscription_join_button": "Open channel",
|
||||||
|
"channel_subscription_verify_button": "Check subscription",
|
||||||
|
"channel_subscription_check_failed": "Couldn't verify the subscription. Please try again later or contact support.",
|
||||||
|
"channel_subscription_verified_success": "✅ Subscription confirmed! You're good to go.",
|
||||||
"main_menu_greeting": "Hi, {user_name}! 👋\nWhat would you like to do?",
|
"main_menu_greeting": "Hi, {user_name}! 👋\nWhat would you like to do?",
|
||||||
|
|
||||||
"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": "🎁 Referrals",
|
||||||
"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",
|
||||||
@@ -13,37 +17,53 @@
|
|||||||
"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",
|
||||||
|
|
||||||
"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!",
|
||||||
|
|
||||||
"error_occurred_try_again": "An error occurred, please try again.",
|
"error_occurred_try_again": "An error occurred, please try again.",
|
||||||
"error_try_again": "Please try again.",
|
"error_try_again": "Please try again.",
|
||||||
"error_displaying_menu": "Error displaying menu.",
|
"error_displaying_menu": "Error displaying menu.",
|
||||||
"main_menu_unknown_action": "Unknown action.",
|
"main_menu_unknown_action": "Unknown action.",
|
||||||
|
|
||||||
"select_subscription_period": "Select subscription period:",
|
"select_subscription_period": "Select subscription period:",
|
||||||
"subscribe_for_months_button": "{months} mo. - {price} {currency_symbol}",
|
"subscribe_for_months_button": "{months} mo. - {price} {currency_symbol}",
|
||||||
|
|
||||||
"choose_payment_method": "Choose payment method:",
|
"choose_payment_method": "Choose payment method:",
|
||||||
"pay_button": "💳 Pay",
|
"pay_button": "💳 Pay",
|
||||||
"pay_with_yookassa_button": "💳 YooKassa",
|
"pay_with_yookassa_button": "💳 YooKassa",
|
||||||
|
"yookassa_autopay_flow_prompt": "Auto-renew is enabled. Choose how you'd like to pay:",
|
||||||
|
"yookassa_autopay_pay_saved_card_button": "💳 Pay with saved card",
|
||||||
|
"yookassa_autopay_pay_new_card_button": "➕ Pay with new card",
|
||||||
|
"yookassa_autopay_choose_saved_card": "Choose a saved card to charge:",
|
||||||
|
"yookassa_autopay_no_saved_cards": "No saved cards found. Pay with a new card or link one in Payment Methods.",
|
||||||
|
"back_to_autopay_method_choice_button": "⬅️ Back",
|
||||||
|
"yookassa_autopay_charge_initiated": "Charge request sent to the selected card. We'll notify you once the payment completes.",
|
||||||
|
"pay_with_sbp_button": "📱 SBP",
|
||||||
|
"back_to_payment_methods_button": "⬅️ Back",
|
||||||
"pay_with_cryptopay_button": "💎 CryptoBot",
|
"pay_with_cryptopay_button": "💎 CryptoBot",
|
||||||
"pay_with_tribute_button": "❤️ Tribute",
|
"pay_with_tribute_button": "❤️ Tribute",
|
||||||
"pay_with_stars_button": "🌟 Telegram Stars",
|
"pay_with_stars_button": "🌟 Telegram Stars",
|
||||||
"connect_button": "🔗 Connect",
|
"connect_button": "🔗 Connect",
|
||||||
"cancel_button": "❌ Cancel",
|
"cancel_button": "❌ Cancel",
|
||||||
|
|
||||||
|
"devices_button": "📱 My Devices ({current_devices}/{max_devices})",
|
||||||
|
"my_devices_details": "📱 <b>My Devices ({current_devices}/{max_devices})</b>\n\n{devices}\n\nYou can disconnect a device by selecting it from the list below.\n<blockquote><i>Note: If you disconnect a device, it will be automatically connected again when you use it next. Before deleting, make sure you have deleted the subscription from the application.</i></blockquote>",
|
||||||
|
"no_devices_details_found_message": "📱 <b>My Devices</b>\n\nYou don't have any devices yet.\nYou have {max_devices} devices available. You can connect them by clicking the \"🔗 Connect\" button in the subscription menu.",
|
||||||
|
"device_details": "{index}) <b>{device_model} ({platform} {os_version})</b>\nDate connected: <b>{created_at_str}</b>\nUser Agent: <b>{user_agent}</b>\nHWID: <code>{hwid}</code>",
|
||||||
|
"disconnect_device_button": "🚫 Disconnect {hwid} ({index})",
|
||||||
|
"device_disconnected": "Device disconnected successfully.",
|
||||||
|
"no_devices_found": "Error loading device list.",
|
||||||
|
"devices_unlimited_label": "Unlimited",
|
||||||
|
"my_devices_feature_disabled": "The My Devices section is currently unavailable.",
|
||||||
|
|
||||||
"payment_description_subscription": "Subscription payment for {months} mo.",
|
"payment_description_subscription": "Subscription payment for {months} mo.",
|
||||||
"payment_link_message": "To pay for {months} mo. subscription, click the button below:",
|
"payment_link_message": "To pay for {months} mo. subscription, click the button below:",
|
||||||
|
"free_kassa_order_info": "Order #{order_id} from {date}",
|
||||||
|
"payment_invoice_sent_message": "Telegram has sent the invoice above. Complete the payment or pick another method below.",
|
||||||
"payment_successful_error_details": "✅ Payment succeeded, but an error occurred displaying details. Your subscription is active. Contact support if anything is wrong.",
|
"payment_successful_error_details": "✅ Payment succeeded, but an error occurred displaying details. Your subscription is active. Contact support if anything is wrong.",
|
||||||
"payment_successful_full": "✅ Payment successful!\nYour {months}-month subscription is active until {end_date}.\n\nConnection key:\n<code>{config_link}</code>\n\nTo connect, open the link and follow the instructions 👇",
|
"payment_successful_full": "✅ Payment successful!\nYour {months}-month subscription is active until {end_date}.\n\nConnection key:\n<code>{config_link}</code>\n\nTo connect, open the link and follow the instructions 👇",
|
||||||
"payment_successful_with_referral_bonus_full": "✅ Payment successful!\nYour {months}-month subscription (base end date: {base_end_date}) has been extended by {bonus_days} bonus days for referral from {inviter_name} and is now active until {final_end_date}.\n\nConnection key:\n<code>{config_link}</code>\n\nTo connect, open the link and follow the instructions 👇",
|
"payment_successful_with_referral_bonus_full": "✅ Payment successful!\nYour {months}-month subscription (base end date: {base_end_date}) has been extended by {bonus_days} bonus days for referral from {inviter_name} and is now active until {final_end_date}.\n\nConnection key:\n<code>{config_link}</code>\n\nTo connect, open the link and follow the instructions 👇",
|
||||||
"payment_failed": "❌ Payment failed or was cancelled. Please try again or contact support.",
|
"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",
|
||||||
|
|
||||||
"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>.",
|
||||||
@@ -65,7 +85,6 @@
|
|||||||
"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}.",
|
||||||
"user_is_banned": "🚫 Your account is banned. Please contact support.",
|
"user_is_banned": "🚫 Your account is banned. Please contact support.",
|
||||||
|
|
||||||
"admin_panel_title": "Admin Panel",
|
"admin_panel_title": "Admin Panel",
|
||||||
"admin_stats_button": "📊 Statistics",
|
"admin_stats_button": "📊 Statistics",
|
||||||
"admin_broadcast_button": "📢 Broadcast",
|
"admin_broadcast_button": "📢 Broadcast",
|
||||||
@@ -82,7 +101,10 @@
|
|||||||
"admin_promo_marketing_section": "🎁 Promos",
|
"admin_promo_marketing_section": "🎁 Promos",
|
||||||
"admin_system_functions_section": "⚙️ System",
|
"admin_system_functions_section": "⚙️ System",
|
||||||
"admin_ban_management_section": "🚫 Bans",
|
"admin_ban_management_section": "🚫 Bans",
|
||||||
"admin_users_management_button": "👤 Management",
|
"admin_users_search_button": "🔍 Search",
|
||||||
|
"admin_users_management_button": "👥 User List",
|
||||||
|
"admin_users_list_header": "👥 <b>User List</b>\n\nPage {current}/{total} ({total_users} users)",
|
||||||
|
"admin_user_back_to_list_button": "⬅️ Back to List",
|
||||||
"back_to_user_management_button": "⬅️ To Users",
|
"back_to_user_management_button": "⬅️ To Users",
|
||||||
"back_to_admin_panel_button": "⬅️ To Admin",
|
"back_to_admin_panel_button": "⬅️ To Admin",
|
||||||
"admin_stats_header": "📊 Bot Statistics",
|
"admin_stats_header": "📊 Bot Statistics",
|
||||||
@@ -188,6 +210,8 @@
|
|||||||
"admin_user_card_title": "User Card",
|
"admin_user_card_title": "User Card",
|
||||||
"user_card_ban_button": "🚫 Ban",
|
"user_card_ban_button": "🚫 Ban",
|
||||||
"user_card_unban_button": "✅ Unban",
|
"user_card_unban_button": "✅ Unban",
|
||||||
|
"user_card_open_profile_button": "👤 Open profile",
|
||||||
|
"user_card_open_referrer_profile_button": "👤 Referrer profile",
|
||||||
"user_card_back_to_banned_list_button": "⬅️ Back to Ban List",
|
"user_card_back_to_banned_list_button": "⬅️ Back to Ban List",
|
||||||
"admin_logs_menu_title": "Logs Menu:",
|
"admin_logs_menu_title": "Logs Menu:",
|
||||||
"admin_view_all_logs_button": "📜 All Message Logs",
|
"admin_view_all_logs_button": "📜 All Message Logs",
|
||||||
@@ -225,9 +249,20 @@
|
|||||||
"admin_user_send_message_button": "✉️ Send Message",
|
"admin_user_send_message_button": "✉️ Send Message",
|
||||||
"admin_user_view_logs_button": "📜 User Actions",
|
"admin_user_view_logs_button": "📜 User Actions",
|
||||||
"admin_user_refresh_button": "🔄 Refresh",
|
"admin_user_refresh_button": "🔄 Refresh",
|
||||||
|
"admin_user_delete_button": "❌ Delete User",
|
||||||
|
"admin_user_delete_not_allowed": "❌ You are not permitted to delete users.",
|
||||||
|
"admin_user_delete_confirmation_prompt": "⚠️ You are about to delete user {user_id} completely.\n\nSend the exact Telegram ID of this user to confirm.\nAny other reply will cancel the operation.",
|
||||||
|
"admin_user_delete_state_missing": "⚠️ No active delete operation. Start again.",
|
||||||
|
"admin_user_delete_cancelled": "Deletion cancelled.",
|
||||||
|
"admin_user_delete_mismatch": "⚠️ ID mismatch. Deletion aborted.",
|
||||||
|
"admin_user_delete_already_removed": "ℹ️ The user is already removed.",
|
||||||
|
"admin_user_delete_panel_error": "❌ Failed to remove the user from the panel. Operation aborted.",
|
||||||
|
"admin_user_delete_success": "✅ User {user_id} was removed from the bot and the panel.",
|
||||||
|
"admin_user_delete_error": "❌ Unable to delete the user. Please try again later.",
|
||||||
"admin_user_search_new_button": "🔍 Find Another",
|
"admin_user_search_new_button": "🔍 Find Another",
|
||||||
"admin_user_view_all_logs_button": "📋 All Actions",
|
"admin_user_view_all_logs_button": "📋 All Actions",
|
||||||
"admin_user_back_to_card_button": "🔙 Back to Card",
|
"admin_user_back_to_card_button": "🔙 Back to Card",
|
||||||
|
"admin_user_recent_actions_title": "📜 Recent actions of user {user_id}:",
|
||||||
"admin_user_not_found": "❌ User not found: {input}",
|
"admin_user_not_found": "❌ User not found: {input}",
|
||||||
"admin_user_not_found_action": "User not found",
|
"admin_user_not_found_action": "User not found",
|
||||||
"admin_user_card_error": "❌ Error displaying user card",
|
"admin_user_card_error": "❌ Error displaying user card",
|
||||||
@@ -237,6 +272,7 @@
|
|||||||
"admin_user_invalid_days": "❌ Invalid number of days. Enter number from 1 to 3650.",
|
"admin_user_invalid_days": "❌ Invalid number of days. Enter number from 1 to 3650.",
|
||||||
"admin_user_subscription_added_success": "✅ Successfully added {days} days to user {user_id}",
|
"admin_user_subscription_added_success": "✅ Successfully added {days} days to user {user_id}",
|
||||||
"admin_user_subscription_added_error": "❌ Error adding subscription days",
|
"admin_user_subscription_added_error": "❌ Error adding subscription days",
|
||||||
|
"admin_panel_user_creation_failed": "❌ Failed to create or link panel user for ID {user_id}. Manual intervention required.",
|
||||||
"admin_user_ban_toggle_success": "✅ User {status}",
|
"admin_user_ban_toggle_success": "✅ User {status}",
|
||||||
"admin_user_ban_toggle_error": "❌ Error changing ban status",
|
"admin_user_ban_toggle_error": "❌ Error changing ban status",
|
||||||
"admin_user_ban_success": "✅ User {input} has been banned",
|
"admin_user_ban_success": "✅ User {input} has been banned",
|
||||||
@@ -257,7 +293,9 @@
|
|||||||
"inline_admin_financial_stats_title": "💰 Financial Statistics",
|
"inline_admin_financial_stats_title": "💰 Financial Statistics",
|
||||||
"inline_system_stats_message": "🖥 <b>Panel Statistics</b>\n\n🟢 Online: <b>{online}</b>\n📊 Active: <b>{active}</b>\n🔴 Disabled: <b>{disabled}</b>\n⏰ Expired: <b>{expired}</b>\n⚠️ Limited: <b>{limited}</b>\n👥 Total users: <b>{total}</b>\n💾 RAM Usage: <b>{memory:.1f}%</b>\n📊 Week traffic: <b>{week_traffic}</b>\n📊 Month traffic: <b>{month_traffic}</b>\n🔗 Active nodes: <b>{active_nodes}/{total_nodes}</b>",
|
"inline_system_stats_message": "🖥 <b>Panel Statistics</b>\n\n🟢 Online: <b>{online}</b>\n📊 Active: <b>{active}</b>\n🔴 Disabled: <b>{disabled}</b>\n⏰ Expired: <b>{expired}</b>\n⚠️ Limited: <b>{limited}</b>\n👥 Total users: <b>{total}</b>\n💾 RAM Usage: <b>{memory:.1f}%</b>\n📊 Week traffic: <b>{week_traffic}</b>\n📊 Month traffic: <b>{month_traffic}</b>\n🔗 Active nodes: <b>{active_nodes}/{total_nodes}</b>",
|
||||||
"inline_admin_system_stats_title": "🖥 System Statistics",
|
"inline_admin_system_stats_title": "🖥 System Statistics",
|
||||||
"log_referral_suffix": " (referral from {referrer_id})",
|
"log_referral_suffix": " (referral from {referrer_link})",
|
||||||
|
"log_open_profile_link": "👤 Open profile",
|
||||||
|
"log_open_referrer_profile_button": "👤 Referrer profile",
|
||||||
"log_new_user_registration": "👤 <b>New User</b>\n\n🆔 ID: <code>{user_id}</code>\n👤 Name: {user_display}{referral_text}\n📅 Time: {timestamp}",
|
"log_new_user_registration": "👤 <b>New User</b>\n\n🆔 ID: <code>{user_id}</code>\n👤 Name: {user_display}{referral_text}\n📅 Time: {timestamp}",
|
||||||
"log_payment_received": "{provider_emoji} <b>Payment Received</b>\n\n👤 User: {user_display}\n💰 Amount: <b>{amount} {currency}</b>\n📅 Period: <b>{months} mo.</b>\n🏦 Provider: {payment_provider}\n🕐 Time: {timestamp}",
|
"log_payment_received": "{provider_emoji} <b>Payment Received</b>\n\n👤 User: {user_display}\n💰 Amount: <b>{amount} {currency}</b>\n📅 Period: <b>{months} mo.</b>\n🏦 Provider: {payment_provider}\n🕐 Time: {timestamp}",
|
||||||
"log_promo_activation": "🎁 <b>Promo Code Activated</b>\n\n👤 User: {user_display}\n🏷 Code: <code>{promo_code}</code>\n🎯 Bonus: <b>+{bonus_days}d</b>\n🕐 Time: {timestamp}",
|
"log_promo_activation": "🎁 <b>Promo Code Activated</b>\n\n👤 User: {user_display}\n🏷 Code: <code>{promo_code}</code>\n🎯 Bonus: <b>+{bonus_days}d</b>\n🕐 Time: {timestamp}",
|
||||||
@@ -316,10 +354,13 @@
|
|||||||
"admin_user_na_value": "N/A",
|
"admin_user_na_value": "N/A",
|
||||||
"admin_user_subscription_none": "No active subscription",
|
"admin_user_subscription_none": "No active subscription",
|
||||||
"admin_user_actions_count_label": "📜 <b>Total actions:</b>",
|
"admin_user_actions_count_label": "📜 <b>Total actions:</b>",
|
||||||
|
"admin_user_total_paid_label": "💰 <b>Total Paid:</b>",
|
||||||
|
"admin_user_referral_revenue_label": "💸 <b>Referral Revenue:</b>",
|
||||||
|
"admin_user_invited_friends_label": "👥 <b>Friends invited:</b>",
|
||||||
|
"admin_user_ref_purchased_label": "💳 <b>Purchased subscription:</b>",
|
||||||
"admin_user_subscription_active_until": "⏰ <b>Active until:</b>",
|
"admin_user_subscription_active_until": "⏰ <b>Active until:</b>",
|
||||||
"admin_user_subscription_error": "Loading error",
|
"admin_user_subscription_error": "Loading error",
|
||||||
"admin_promo_management_button": "🎟 Promo Management",
|
"admin_promo_management_button": "🎟 Promo Management",
|
||||||
|
|
||||||
"admin_promo_management_title": "🎟 <b>Promo Code Management</b>\n\nSelect a promo code for detailed view:",
|
"admin_promo_management_title": "🎟 <b>Promo Code Management</b>\n\nSelect a promo code for detailed view:",
|
||||||
"admin_promo_management_empty": "📭 No promo codes available",
|
"admin_promo_management_empty": "📭 No promo codes available",
|
||||||
"admin_promo_card_title": "🎟 <b>Promo Code: {code}</b>",
|
"admin_promo_card_title": "🎟 <b>Promo Code: {code}</b>",
|
||||||
@@ -402,6 +443,7 @@
|
|||||||
"subscription_tribute_notice": "Paid via Tribute. Renew using your Tribute link.",
|
"subscription_tribute_notice": "Paid via Tribute. Renew using your Tribute link.",
|
||||||
"subscription_tribute_notice_with_link": "Paid via Tribute. Renew: {link}",
|
"subscription_tribute_notice_with_link": "Paid via Tribute. Renew: {link}",
|
||||||
"subscription_autorenew_not_supported_for_tribute": "Auto-renew is handled by Tribute. Manage renewal in the Tribute app/link.",
|
"subscription_autorenew_not_supported_for_tribute": "Auto-renew is handled by Tribute. Manage renewal in the Tribute app/link.",
|
||||||
|
"autorenew_enable_requires_card": "Link a payment card in Payment Methods before enabling auto-renew.",
|
||||||
"subscription_not_active": "You don't have an active subscription.",
|
"subscription_not_active": "You don't have an active subscription.",
|
||||||
"error_service_unavailable": "Service unavailable. Please try again later.",
|
"error_service_unavailable": "Service unavailable. Please try again later.",
|
||||||
"error_payment_gateway": "Payment service error. Please try again later.",
|
"error_payment_gateway": "Payment service error. Please try again later.",
|
||||||
@@ -432,5 +474,6 @@
|
|||||||
"admin_ads_delete_button": "🗑 Delete campaign",
|
"admin_ads_delete_button": "🗑 Delete campaign",
|
||||||
"admin_ads_delete_confirm": "Are you sure you want to delete campaign #{id}? This action is irreversible.",
|
"admin_ads_delete_confirm": "Are you sure you want to delete campaign #{id}? This action is irreversible.",
|
||||||
"admin_ads_deleted_success": "Campaign deleted.",
|
"admin_ads_deleted_success": "Campaign deleted.",
|
||||||
"admin_ads_not_found": "Campaign not found."
|
"admin_ads_not_found": "Campaign not found.",
|
||||||
|
"free_kassa_order_full": "Order #{order_id} from {date}\n\n"
|
||||||
}
|
}
|
||||||
|
|||||||
+56
-12
@@ -1,7 +1,11 @@
|
|||||||
{
|
{
|
||||||
"welcome": "Добро пожаловать, {user_name}!",
|
"welcome": "Добро пожаловать, {user_name}!",
|
||||||
|
"channel_subscription_required": "Чтобы пользоваться ботом, подпишитесь на канал и нажмите \"Проверить подписку\".",
|
||||||
|
"channel_subscription_join_button": "Перейти в канал",
|
||||||
|
"channel_subscription_verify_button": "Проверить подписку",
|
||||||
|
"channel_subscription_check_failed": "Не удалось проверить подписку. Попробуйте позже или обратитесь в поддержку.",
|
||||||
|
"channel_subscription_verified_success": "✅ Подписка подтверждена! Можно продолжать.",
|
||||||
"main_menu_greeting": "Привет, {user_name}! 👋\nЧто бы вы хотели сделать?",
|
"main_menu_greeting": "Привет, {user_name}! 👋\nЧто бы вы хотели сделать?",
|
||||||
|
|
||||||
"menu_activate_trial_button": "🆓 Пробный период",
|
"menu_activate_trial_button": "🆓 Пробный период",
|
||||||
"menu_subscribe_inline": "🚀 Купить",
|
"menu_subscribe_inline": "🚀 Купить",
|
||||||
"menu_my_subscription_inline": "🔐 Моя подписка",
|
"menu_my_subscription_inline": "🔐 Моя подписка",
|
||||||
@@ -13,37 +17,53 @@
|
|||||||
"menu_server_status_button": "📊 Статус",
|
"menu_server_status_button": "📊 Статус",
|
||||||
"menu_support_button": "💬 Поддержка",
|
"menu_support_button": "💬 Поддержка",
|
||||||
"menu_terms_button": "📄 Условия сервиса",
|
"menu_terms_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": "Язык изменен!",
|
||||||
|
|
||||||
"error_occurred_try_again": "Произошла ошибка, попробуйте снова.",
|
"error_occurred_try_again": "Произошла ошибка, попробуйте снова.",
|
||||||
"error_try_again": "Попробуйте еще раз.",
|
"error_try_again": "Попробуйте еще раз.",
|
||||||
"error_displaying_menu": "Ошибка отображения меню.",
|
"error_displaying_menu": "Ошибка отображения меню.",
|
||||||
"main_menu_unknown_action": "Неизвестное действие.",
|
"main_menu_unknown_action": "Неизвестное действие.",
|
||||||
|
|
||||||
"select_subscription_period": "Выберите срок подписки:",
|
"select_subscription_period": "Выберите срок подписки:",
|
||||||
"subscribe_for_months_button": "{months} мес. - {price} {currency_symbol}",
|
"subscribe_for_months_button": "{months} мес. - {price} {currency_symbol}",
|
||||||
|
|
||||||
"choose_payment_method": "Выберите способ оплаты:",
|
"choose_payment_method": "Выберите способ оплаты:",
|
||||||
"pay_button": "💳 Оплатить",
|
"pay_button": "💳 Оплатить",
|
||||||
"pay_with_yookassa_button": "💳 ЮKassa",
|
"pay_with_yookassa_button": "💳 ЮKassa",
|
||||||
|
"yookassa_autopay_flow_prompt": "Автопродление включено. Выберите, как оплатить подписку:",
|
||||||
|
"yookassa_autopay_pay_saved_card_button": "💳 Оплата привязанной картой",
|
||||||
|
"yookassa_autopay_pay_new_card_button": "➕ Оплата новой картой",
|
||||||
|
"yookassa_autopay_choose_saved_card": "Выберите привязанную карту для списания:",
|
||||||
|
"yookassa_autopay_no_saved_cards": "Сохранённых карт нет. Оплатите новой картой или привяжите карту в разделе «Способы оплаты».",
|
||||||
|
"back_to_autopay_method_choice_button": "⬅️ Назад",
|
||||||
|
"yookassa_autopay_charge_initiated": "Запрос на списание с выбранной карты отправлен. Сообщим, как только платёж завершится.",
|
||||||
|
"pay_with_sbp_button": "📱 СБП",
|
||||||
|
"back_to_payment_methods_button": "⬅️ Назад",
|
||||||
"pay_with_cryptopay_button": "💎 CryptoBot",
|
"pay_with_cryptopay_button": "💎 CryptoBot",
|
||||||
"pay_with_tribute_button": "❤️ Tribute",
|
"pay_with_tribute_button": "❤️ Tribute",
|
||||||
"pay_with_stars_button": "🌟 Звезды Telegram",
|
"pay_with_stars_button": "🌟 Звезды Telegram",
|
||||||
"connect_button": "🔗 Подключиться",
|
"connect_button": "🔗 Подключиться",
|
||||||
|
|
||||||
|
"devices_button": "📱 Мои устройства ({current_devices}/{max_devices})",
|
||||||
|
"my_devices_details": "📱 <b>Список ваших устройств ({current_devices}/{max_devices})</b>\n\n{devices}\n\nВы можете отключить устройство, выбрав его в списке ниже.\n<blockquote><i>Примечание: Если вы отключили устройство, оно будет автоматически подключено заново при следующем использовании. Перед удалением убедитесь, что вы удалили подписку из приложения.</i></blockquote>",
|
||||||
|
"no_devices_details_found_message": "📱 <b>Список ваших устройств</b>\n\nУ вас пока нет устройств.\nВам доступно {max_devices} устройств. Подключить их можно через кнопку \"🔗 Подключиться\" в меню подписки.",
|
||||||
|
"device_details": "{index}) <b>{device_model} ({platform} {os_version})</b>\nДата подключения: <b>{created_at_str}</b>\nUser Agent: <b>{user_agent}</b>\nHWID: <code>{hwid}</code>",
|
||||||
|
"disconnect_device_button": "🚫 Отключить {hwid} ({index})",
|
||||||
|
"device_disconnected": "Устройство успешно отключено.",
|
||||||
|
"no_devices_found": "Ошибка загрузки списка устройств.",
|
||||||
|
"devices_unlimited_label": "Без ограничений",
|
||||||
|
"my_devices_feature_disabled": "Раздел \"Мои устройства\" сейчас недоступен.",
|
||||||
|
|
||||||
"cancel_button": "❌ Отмена",
|
"cancel_button": "❌ Отмена",
|
||||||
"payment_description_subscription": "Оплата подписки на {months} мес.",
|
"payment_description_subscription": "Оплата подписки на {months} мес.",
|
||||||
"payment_link_message": "Для оплаты подписки на {months} мес., нажмите кнопку ниже:",
|
"payment_link_message": "Для оплаты подписки на {months} мес., нажмите кнопку ниже:",
|
||||||
|
"free_kassa_order_info": "Заказ №{order_id} от {date}",
|
||||||
|
"payment_invoice_sent_message": "Счёт Telegram Stars отправлен выше. Нажмите «Оплатить» или вернитесь к выбору способа ниже.",
|
||||||
"payment_successful_error_details": "✅ Оплата прошла успешно, но возникла ошибка при отображении деталей. Ваша подписка активна. Свяжитесь с поддержкой, если что-то не так.",
|
"payment_successful_error_details": "✅ Оплата прошла успешно, но возникла ошибка при отображении деталей. Ваша подписка активна. Свяжитесь с поддержкой, если что-то не так.",
|
||||||
"payment_successful_full": "✅ Оплата прошла успешно!\nВаша подписка на {months} мес. активна до {end_date}.\n\nКлюч подключения:\n<code>{config_link}</code>\n\nЧтобы подключиться, перейдите по ссылке и следуйте инструкции 👇",
|
"payment_successful_full": "✅ Оплата прошла успешно!\nВаша подписка на {months} мес. активна до {end_date}.\n\nКлюч подключения:\n<code>{config_link}</code>\n\nЧтобы подключиться, перейдите по ссылке и следуйте инструкции 👇",
|
||||||
"payment_successful_with_referral_bonus_full": "✅ Оплата прошла успешно!\nВаша подписка на {months} мес. (базовая дата окончания: {base_end_date}) продлена на {bonus_days} бонусных дней за приглашение от {inviter_name} и теперь активна до {final_end_date}.\n\nКлюч подключения:\n<code>{config_link}</code>\n\nЧтобы подключиться, перейдите по ссылке и следуйте инструкции 👇",
|
"payment_successful_with_referral_bonus_full": "✅ Оплата прошла успешно!\nВаша подписка на {months} мес. (базовая дата окончания: {base_end_date}) продлена на {bonus_days} бонусных дней за приглашение от {inviter_name} и теперь активна до {final_end_date}.\n\nКлюч подключения:\n<code>{config_link}</code>\n\nЧтобы подключиться, перейдите по ссылке и следуйте инструкции 👇",
|
||||||
"payment_failed": "❌ Оплата не удалась или была отменена. Пожалуйста, попробуйте еще раз или свяжитесь с поддержкой.",
|
"payment_failed": "❌ Оплата не удалась или была отменена. Пожалуйста, попробуйте еще раз или свяжитесь с поддержкой.",
|
||||||
"config_link_not_available": "недоступна, обратитесь в поддержку",
|
"config_link_not_available": "недоступна, обратитесь в поддержку",
|
||||||
"traffic_unlimited": "Безлимитный",
|
"traffic_unlimited": "Безлимитный",
|
||||||
|
|
||||||
"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>.",
|
||||||
@@ -65,7 +85,6 @@
|
|||||||
"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}.",
|
||||||
"user_is_banned": "🚫 Ваш аккаунт заблокирован. Пожалуйста, свяжитесь со службой поддержки.",
|
"user_is_banned": "🚫 Ваш аккаунт заблокирован. Пожалуйста, свяжитесь со службой поддержки.",
|
||||||
|
|
||||||
"admin_panel_title": "Панель администратора",
|
"admin_panel_title": "Панель администратора",
|
||||||
"admin_stats_button": "📊 Статистика",
|
"admin_stats_button": "📊 Статистика",
|
||||||
"admin_broadcast_button": "📢 Рассылка",
|
"admin_broadcast_button": "📢 Рассылка",
|
||||||
@@ -82,7 +101,10 @@
|
|||||||
"admin_promo_marketing_section": "🎁 Промокоды",
|
"admin_promo_marketing_section": "🎁 Промокоды",
|
||||||
"admin_system_functions_section": "⚙️ Система",
|
"admin_system_functions_section": "⚙️ Система",
|
||||||
"admin_ban_management_section": "🚫 Блокировки",
|
"admin_ban_management_section": "🚫 Блокировки",
|
||||||
"admin_users_management_button": "👤 Управление",
|
"admin_users_search_button": "🔍 Поиск",
|
||||||
|
"admin_users_management_button": "👥 Список пользователей",
|
||||||
|
"admin_users_list_header": "👥 <b>Список пользователей</b>\n\nСтраница {current}/{total} ({total_users} пользователей)",
|
||||||
|
"admin_user_back_to_list_button": "⬅️ К списку",
|
||||||
"back_to_user_management_button": "⬅️ К пользователям",
|
"back_to_user_management_button": "⬅️ К пользователям",
|
||||||
"back_to_admin_panel_button": "⬅️ В админку",
|
"back_to_admin_panel_button": "⬅️ В админку",
|
||||||
"admin_stats_header": "📊 Статистика Бота",
|
"admin_stats_header": "📊 Статистика Бота",
|
||||||
@@ -131,6 +153,7 @@
|
|||||||
"admin_broadcast_cancelled": "Рассылка отменена.",
|
"admin_broadcast_cancelled": "Рассылка отменена.",
|
||||||
"admin_broadcast_cancelled_alert": "Рассылка отменена!",
|
"admin_broadcast_cancelled_alert": "Рассылка отменена!",
|
||||||
"admin_broadcast_cancelled_nav_back": "Рассылка отменена. Вы возвращены в админ-панель.",
|
"admin_broadcast_cancelled_nav_back": "Рассылка отменена. Вы возвращены в админ-панель.",
|
||||||
|
"broadcast_queue_result": "🚀 Рассылка поставлена в очередь!\n📤 В очередь добавлено: {sent_count}\n❌ Ошибок: {failed_count}\n\n📊 Статус очередей:\n👥 Очередь пользователей: {user_queue_size} сообщений\n📢 Очередь групп: {group_queue_size} сообщений\n\nℹ️ Сообщения будут отправлены автоматически с соблюдением лимитов Telegram.",
|
||||||
"admin_promo_invalid_code_format": "Код должен быть от 3 до 30 символов и содержать только буквы и цифры.",
|
"admin_promo_invalid_code_format": "Код должен быть от 3 до 30 символов и содержать только буквы и цифры.",
|
||||||
"admin_promo_invalid_bonus_days": "Количество бонусных дней должно быть положительным числом.",
|
"admin_promo_invalid_bonus_days": "Количество бонусных дней должно быть положительным числом.",
|
||||||
"admin_promo_invalid_max_activations": "Максимальное количество активаций должно быть положительным числом.",
|
"admin_promo_invalid_max_activations": "Максимальное количество активаций должно быть положительным числом.",
|
||||||
@@ -197,6 +220,8 @@
|
|||||||
"admin_user_card_title": "Карточка пользователя",
|
"admin_user_card_title": "Карточка пользователя",
|
||||||
"user_card_ban_button": "🚫 Заблокировать",
|
"user_card_ban_button": "🚫 Заблокировать",
|
||||||
"user_card_unban_button": "✅ Разблокировать",
|
"user_card_unban_button": "✅ Разблокировать",
|
||||||
|
"user_card_open_profile_button": "👤 Открыть профиль",
|
||||||
|
"user_card_open_referrer_profile_button": "👤 Профиль пригласившего",
|
||||||
"user_card_back_to_banned_list_button": "⬅️ К списку забаненных",
|
"user_card_back_to_banned_list_button": "⬅️ К списку забаненных",
|
||||||
"admin_logs_menu_title": "Меню логов:",
|
"admin_logs_menu_title": "Меню логов:",
|
||||||
"admin_view_all_logs_button": "📜 Все логи сообщений",
|
"admin_view_all_logs_button": "📜 Все логи сообщений",
|
||||||
@@ -224,9 +249,20 @@
|
|||||||
"admin_user_send_message_button": "✉️ Сообщение",
|
"admin_user_send_message_button": "✉️ Сообщение",
|
||||||
"admin_user_view_logs_button": "📜 Логи",
|
"admin_user_view_logs_button": "📜 Логи",
|
||||||
"admin_user_refresh_button": "🔄 Обновить",
|
"admin_user_refresh_button": "🔄 Обновить",
|
||||||
|
"admin_user_delete_button": "❌ Удалить пользователя",
|
||||||
|
"admin_user_delete_not_allowed": "❌ У вас нет прав для удаления пользователей.",
|
||||||
|
"admin_user_delete_confirmation_prompt": "⚠️ Вы хотите полностью удалить пользователя {user_id}.\n\nОтправьте точный Telegram ID этого пользователя, чтобы подтвердить удаление.\nЛюбой другой ответ отменит операцию.",
|
||||||
|
"admin_user_delete_state_missing": "⚠️ Нет активной операции удаления. Начните заново.",
|
||||||
|
"admin_user_delete_cancelled": "Удаление отменено.",
|
||||||
|
"admin_user_delete_mismatch": "⚠️ ID не совпадает. Удаление отменено.",
|
||||||
|
"admin_user_delete_already_removed": "ℹ️ Пользователь уже удалён.",
|
||||||
|
"admin_user_delete_panel_error": "❌ Не удалось удалить пользователя на панели. Операция прервана.",
|
||||||
|
"admin_user_delete_success": "✅ Пользователь {user_id} удалён из бота и панели.",
|
||||||
|
"admin_user_delete_error": "❌ Не удалось удалить пользователя. Попробуйте позже.",
|
||||||
"admin_user_search_new_button": "🔍 Новый поиск",
|
"admin_user_search_new_button": "🔍 Новый поиск",
|
||||||
"admin_user_view_all_logs_button": "📋 Все логи",
|
"admin_user_view_all_logs_button": "📋 Все логи",
|
||||||
"admin_user_back_to_card_button": "🔙 К карточке",
|
"admin_user_back_to_card_button": "🔙 К карточке",
|
||||||
|
"admin_user_recent_actions_title": "📜 Последние действия пользователя {user_id}:",
|
||||||
"admin_user_not_found": "❌ Пользователь не найден: {input}",
|
"admin_user_not_found": "❌ Пользователь не найден: {input}",
|
||||||
"admin_user_not_found_action": "Пользователь не найден",
|
"admin_user_not_found_action": "Пользователь не найден",
|
||||||
"admin_user_card_error": "❌ Ошибка отображения карточки пользователя",
|
"admin_user_card_error": "❌ Ошибка отображения карточки пользователя",
|
||||||
@@ -236,6 +272,7 @@
|
|||||||
"admin_user_invalid_days": "❌ Неверное количество дней. Введите число от 1 до 3650.",
|
"admin_user_invalid_days": "❌ Неверное количество дней. Введите число от 1 до 3650.",
|
||||||
"admin_user_subscription_added_success": "✅ Успешно добавлено {days} дней подписки пользователю {user_id}",
|
"admin_user_subscription_added_success": "✅ Успешно добавлено {days} дней подписки пользователю {user_id}",
|
||||||
"admin_user_subscription_added_error": "❌ Ошибка добавления дней подписки",
|
"admin_user_subscription_added_error": "❌ Ошибка добавления дней подписки",
|
||||||
|
"admin_panel_user_creation_failed": "❌ Не удалось создать или привязать пользователя в панели для ID {user_id}. Требуется ручная проверка.",
|
||||||
"admin_user_ban_toggle_success": "✅ Пользователь {status}",
|
"admin_user_ban_toggle_success": "✅ Пользователь {status}",
|
||||||
"admin_user_ban_toggle_error": "❌ Ошибка изменения статуса блокировки",
|
"admin_user_ban_toggle_error": "❌ Ошибка изменения статуса блокировки",
|
||||||
"admin_user_ban_success": "✅ Пользователь {input} заблокирован",
|
"admin_user_ban_success": "✅ Пользователь {input} заблокирован",
|
||||||
@@ -256,7 +293,9 @@
|
|||||||
"inline_admin_financial_stats_title": "💰 Финансовая статистика",
|
"inline_admin_financial_stats_title": "💰 Финансовая статистика",
|
||||||
"inline_system_stats_message": "🖥 <b>Статистика панели</b>\n\n🟢 Онлайн: <b>{online}</b>\n📊 Активных: <b>{active}</b>\n🔴 Отключенных: <b>{disabled}</b>\n⏰ Истекшие: <b>{expired}</b>\n⚠️ Ограниченные: <b>{limited}</b>\n👥 Всего пользователей: <b>{total}</b>\n💾 Использование RAM: <b>{memory:.1f}%</b>\n📊 Трафик за неделю: <b>{week_traffic}</b>\n📊 Трафик за месяц: <b>{month_traffic}</b>\n🔗 Активных нод: <b>{active_nodes}/{total_nodes}</b>",
|
"inline_system_stats_message": "🖥 <b>Статистика панели</b>\n\n🟢 Онлайн: <b>{online}</b>\n📊 Активных: <b>{active}</b>\n🔴 Отключенных: <b>{disabled}</b>\n⏰ Истекшие: <b>{expired}</b>\n⚠️ Ограниченные: <b>{limited}</b>\n👥 Всего пользователей: <b>{total}</b>\n💾 Использование RAM: <b>{memory:.1f}%</b>\n📊 Трафик за неделю: <b>{week_traffic}</b>\n📊 Трафик за месяц: <b>{month_traffic}</b>\n🔗 Активных нод: <b>{active_nodes}/{total_nodes}</b>",
|
||||||
"inline_admin_system_stats_title": "🖥 Системная статистика",
|
"inline_admin_system_stats_title": "🖥 Системная статистика",
|
||||||
"log_referral_suffix": " (реферал от {referrer_id})",
|
"log_referral_suffix": " (реферал от {referrer_link})",
|
||||||
|
"log_open_profile_link": "👤 Открыть профиль",
|
||||||
|
"log_open_referrer_profile_button": "👤 Профиль пригласившего",
|
||||||
"log_new_user_registration": "👤 <b>Новый пользователь</b>\n\n🆔 ID: <code>{user_id}</code>\n👤 Имя: {user_display}{referral_text}\n📅 Время: {timestamp}",
|
"log_new_user_registration": "👤 <b>Новый пользователь</b>\n\n🆔 ID: <code>{user_id}</code>\n👤 Имя: {user_display}{referral_text}\n📅 Время: {timestamp}",
|
||||||
"log_payment_received": "{provider_emoji} <b>Получен платеж</b>\n\n👤 Пользователь: {user_display}\n💰 Сумма: <b>{amount} {currency}</b>\n📅 Период: <b>{months} мес.</b>\n🏦 Провайдер: {payment_provider}\n🕐 Время: {timestamp}",
|
"log_payment_received": "{provider_emoji} <b>Получен платеж</b>\n\n👤 Пользователь: {user_display}\n💰 Сумма: <b>{amount} {currency}</b>\n📅 Период: <b>{months} мес.</b>\n🏦 Провайдер: {payment_provider}\n🕐 Время: {timestamp}",
|
||||||
"log_promo_activation": "🎁 <b>Активирован промокод</b>\n\n👤 Пользователь: {user_display}\n🏷 Код: <code>{promo_code}</code>\n🎯 Бонус: <b>+{bonus_days} дн.</b>\n🕐 Время: {timestamp}",
|
"log_promo_activation": "🎁 <b>Активирован промокод</b>\n\n👤 Пользователь: {user_display}\n🏷 Код: <code>{promo_code}</code>\n🎯 Бонус: <b>+{bonus_days} дн.</b>\n🕐 Время: {timestamp}",
|
||||||
@@ -315,10 +354,13 @@
|
|||||||
"admin_user_na_value": "N/A",
|
"admin_user_na_value": "N/A",
|
||||||
"admin_user_subscription_none": "Нет активной подписки",
|
"admin_user_subscription_none": "Нет активной подписки",
|
||||||
"admin_user_actions_count_label": "📜 <b>Всего действий:</b>",
|
"admin_user_actions_count_label": "📜 <b>Всего действий:</b>",
|
||||||
|
"admin_user_total_paid_label": "💰 <b>Всего оплачено:</b>",
|
||||||
|
"admin_user_referral_revenue_label": "💸 <b>Доход по рефералам:</b>",
|
||||||
|
"admin_user_invited_friends_label": "👥 <b>Приглашено друзей:</b>",
|
||||||
|
"admin_user_ref_purchased_label": "💳 <b>Купили подписку:</b>",
|
||||||
"admin_user_subscription_active_until": "⏰ <b>Действует до:</b>",
|
"admin_user_subscription_active_until": "⏰ <b>Действует до:</b>",
|
||||||
"admin_user_subscription_error": "Ошибка загрузки",
|
"admin_user_subscription_error": "Ошибка загрузки",
|
||||||
"admin_promo_management_button": "🎟 Управление промокодами",
|
"admin_promo_management_button": "🎟 Управление промокодами",
|
||||||
|
|
||||||
"admin_promo_management_title": "🎟 <b>Управление промокодами</b>\n\nВыберите промокод для детального просмотра:",
|
"admin_promo_management_title": "🎟 <b>Управление промокодами</b>\n\nВыберите промокод для детального просмотра:",
|
||||||
"admin_promo_management_empty": "📭 Промокоды отсутствуют",
|
"admin_promo_management_empty": "📭 Промокоды отсутствуют",
|
||||||
"admin_promo_card_title": "🎟 <b>Промокод: {code}</b>",
|
"admin_promo_card_title": "🎟 <b>Промокод: {code}</b>",
|
||||||
@@ -401,6 +443,7 @@
|
|||||||
"subscription_tribute_notice": "Оплачено через Tribute. Продление делайте по ссылке Tribute.",
|
"subscription_tribute_notice": "Оплачено через Tribute. Продление делайте по ссылке Tribute.",
|
||||||
"subscription_tribute_notice_with_link": "Оплачено через Tribute. Продлить: {link}",
|
"subscription_tribute_notice_with_link": "Оплачено через Tribute. Продлить: {link}",
|
||||||
"subscription_autorenew_not_supported_for_tribute": "Автопродление управляется Tribute. Управляйте продлением в приложении/ссылке Tribute.",
|
"subscription_autorenew_not_supported_for_tribute": "Автопродление управляется Tribute. Управляйте продлением в приложении/ссылке Tribute.",
|
||||||
|
"autorenew_enable_requires_card": "Прежде чем включать автоплатёж, привяжите карту в разделе «Способы оплаты».",
|
||||||
"subscription_not_active": "У вас нет активной подписки.",
|
"subscription_not_active": "У вас нет активной подписки.",
|
||||||
"error_service_unavailable": "Сервис недоступен. Попробуйте позже.",
|
"error_service_unavailable": "Сервис недоступен. Попробуйте позже.",
|
||||||
"error_payment_gateway": "Ошибка платежного сервиса. Попробуйте позже.",
|
"error_payment_gateway": "Ошибка платежного сервиса. Попробуйте позже.",
|
||||||
@@ -431,5 +474,6 @@
|
|||||||
"admin_ads_delete_button": "🗑 Удалить кампанию",
|
"admin_ads_delete_button": "🗑 Удалить кампанию",
|
||||||
"admin_ads_delete_confirm": "Вы уверены, что хотите удалить кампанию #{id}? Это действие необратимо.",
|
"admin_ads_delete_confirm": "Вы уверены, что хотите удалить кампанию #{id}? Это действие необратимо.",
|
||||||
"admin_ads_deleted_success": "Кампания удалена.",
|
"admin_ads_deleted_success": "Кампания удалена.",
|
||||||
"admin_ads_not_found": "Кампания не найдена."
|
"admin_ads_not_found": "Кампания не найдена.",
|
||||||
|
"free_kassa_order_full": "Заказ №{order_id} от {date}\n\n"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user