Compare commits

..
24 Commits
Author SHA1 Message Date
kavoreandGitHub 8dde585d49 Merge pull request #159 from kavore/dev
bugfix
2026-02-03 12:18:37 +03:00
kavore 3c84007f17 fix(promo): count activations on payment
Allow promo usage increments to overflow the max limit when a payment completes so counters reflect paid activations only.
2026-02-03 12:05:16 +03:00
kavore d11b0cabaa fix(users): handle active discounts on delete
Ensure active_discounts cascades on user/promo removal, clean orphan rows during migration, and rely on DB cascade for user deletion.
2026-02-03 11:25:53 +03:00
kavore 4b5908d6cc fix(promo): Improve error handling for promo code application
- Enhanced the logic for determining the response to users when a promo code application fails, ensuring clearer messaging based on the type of error encountered.
- Introduced specific error messages for cases where the promo code is not found or not applicable as a discount, improving user experience and clarity.
2026-02-02 23:55:04 +03:00
kavore 5bb8f2add0 feat(promo): Integrate promo code service into subscription options
- Enhanced the display_subscription_options and reshow_subscription_options_callback functions to accept a promo_code_service parameter for improved discount handling.
- Updated the logic to calculate and display discounted prices based on active promo codes, including support for both regular and star-based pricing.
- Modified relevant locales to include currency symbols in discount notices for better user clarity.
2026-02-02 00:35:31 +03:00
kavore 484327a032 feat(promo): Enhance promo code functionality and discount consumption logic
- Integrated promo code service into subscription service to streamline discount consumption during payment processing.
- Improved error handling and logging for promo code activation and usage increment.
- Updated payment services to handle discount calculations more robustly, including fallback mechanisms for invalid discount scenarios.
- Added new methods for managing promo code activations and usage in the database layer.
2026-02-01 19:29:31 +03:00
kavoreandGitHub 9ea9f571bb Merge pull request #155 from kavore/dev 2026-01-31 07:13:59 +03:00
kavoreandGitHub 9b4a58f82f Merge pull request #154 from VAQYBIN/dev
Исправление бага при удалении промокода на скидку
2026-01-28 16:18:23 +03:00
VAQYBIN e6adbbece4 Merge branch 'dev' of https://github.com/VAQYBIN/remnawave-tg-shop into dev 2026-01-25 20:43:20 +05:00
VAQYBIN 1e79b15351 fix(promo): Исправлена ошибка с удалением промокода на скидку
Исправлен баг, когда промокод на скидку не мог быть удалён, если пользователь применил скидку, но не воспользовался ей.
Добавлено истечение времени действия промокода, даже если промокод на скидку уже был введён
2026-01-25 20:43:01 +05:00
kavoreandGitHub 6f3c123b3a Merge pull request #153 from VAQYBIN/dev
Промокоды на скидку
2026-01-24 22:15:06 +03:00
VAQYBINandGitHub 4a0f763307 Delete CLAUDE.md 2026-01-23 12:42:40 +05:00
VAQYBIN a121d38fbb fix(payments): Исправлена ошибка с дублированием скидки для всех платёжных систем 2026-01-23 00:00:16 +05:00
VAQYBIN 1e8b97888a fix(promo): Исправлен баг с применением двойной скидки при оплате через CryptoBot 2026-01-21 01:26:10 +05:00
VAQYBIN 9ca2fe487c fix(promo): Исправлена ошибка с зацыкленностью импортов для оплат звёздами и криптой 2026-01-21 01:13:26 +05:00
VAQYBIN 229ce7e1e0 feat(promo): Добавлена поддержка промокодов на скидку для всех платёжных систем 2026-01-21 01:09:49 +05:00
VAQYBIN 39d5fd1856 chore(promo): Добавлена локализация кнопки изменения процента скидки 2026-01-20 01:44:22 +05:00
VAQYBIN fed1c1c960 feat(promo): Добавлена локализация при изменении процента скидки и логи при активации промокода на скидку 2026-01-20 01:26:14 +05:00
VAQYBIN e7df5e539c chore(promo): Добавлены локализации при создании промокодов на скидку 2026-01-20 01:06:18 +05:00
VAQYBIN dcc7f9eb72 fix(promo): Исправлена ошибка с дублированием скидки при использование промокода 2026-01-20 00:51:05 +05:00
VAQYBIN baaf5c457f fix(promo): Исправлена ошибка при применении промокода на скидку 2026-01-20 00:34:17 +05:00
VAQYBIN 62bf5c35a8 fix(db): Добавлены новые таблицы для хранения информации о новых и старых ценах при использовании промокодов на скидку 2026-01-20 00:16:24 +05:00
VAQYBIN d619afff29 test: fix ci/cd 2026-01-19 04:00:10 +05:00
VAQYBIN 3bf9acf4d4 feat(promo): Добавлены промокоды на скидку в процентах 2026-01-19 03:50:33 +05:00
418 changed files with 13188 additions and 74117 deletions
+3 -19
View File
@@ -1,21 +1,10 @@
# Git
.git
.gitignore
.github
.gitattributes
LICENSE
README.md
.env
.env.*
!.env.example
scratch_*.py
scratch/
.claude/
*.local.*
node_modules/
frontend/node_modules/
deploy/compose/docker-compose-dev.yml
data/*
!data/tariffs.example.json
# CI
@@ -25,14 +14,9 @@ data/*
# Docker
docker-compose.yml
deploy/compose/*.yml
Dockerfile
.docker
.dockerignore
tmp/
# WebApp build artifacts (regenerated inside Docker)
backend/bot/app/web/templates/subscription_webapp.css
backend/bot/app/web/templates/subscription_webapp.js
backend/bot/app/web/templates/subscription_webapp.min.*.js
# Byte-compiled / optimized / DLL files
**/__pycache__/
+17 -68
View File
@@ -3,26 +3,11 @@ BOT_TOKEN=your_bot_token_here #
ADMIN_IDS=comma_separated_admin_ids # Your telegram ID
# PostgreSQL Database Connection Settings
POSTGRES_USER= # Required: database user name
POSTGRES_PASSWORD= # Required: database password
POSTGRES_HOST=remnawave-minishop-db # Database container name
POSTGRES_USER=postgres # Database user name
POSTGRES_PASSWORD=postgres # Database password
POSTGRES_HOST=remnawave-tg-shop-db # Database container name
POSTGRES_PORT=5432 # Port
POSTGRES_DB=postgres # Database name
DB_POOL_SIZE=20 # SQLAlchemy async pool size per backend/worker process
DB_MAX_OVERFLOW=10 # Extra transient DB connections above pool size
DB_POOL_TIMEOUT_SECONDS=30 # Seconds to wait for a DB pool connection
DB_POOL_RECYCLE_SECONDS=1800 # Recycle DB connections to avoid stale sockets
REDIS_URL=redis://redis:6379/0 # Shared Redis for FSM, rate limits, cache, locks and queues
REDIS_KEY_PREFIX=remnawave-tg-shop # Prefix for Redis keys
WEBAPP_ME_CACHE_TTL_SECONDS=15 # Short TTL for /api/me payload cache
WEBAPP_RATE_LIMIT_TTL_SECONDS=60 # Redis rate-limit window
WEBAPP_RATE_LIMIT_MAX_REQUESTS=30 # Requests per window/action/user/IP
WEBHOOK_QUEUE_NAME=webhook-events # Redis queue for heavy webhook processing
WEBHOOK_QUEUE_CONCURRENCY=4 # Worker webhook consumers
WORKER_PANEL_SYNC_INTERVAL_SECONDS=900 # Worker panel sync interval
TARIFF_WORKER_LOCK_TTL_SECONDS=240 # Redis lock TTL for tariff tick
TARIFF_WORKER_TICK_SECONDS=300 # Tariff worker tick interval
# Localization and Display
DEFAULT_LANGUAGE="ru" # or "en"
@@ -32,9 +17,7 @@ DEFAULT_CURRENCY_SYMBOL="RUB" #
SUPPORT_LINK=https://t.me/your_support_link # Link to the support chat
SERVER_STATUS_URL=https://status.yourdomain.tld/status/your_service # Link to the server status page
TERMS_OF_SERVICE_URL=https://example.com/tos # Link to the terms of service
PRIVACY_POLICY_URL=https://example.com/privacy # Link to the privacy policy
USER_AGREEMENT_URL=https://example.com/user-agreement # Link to the user agreement
SUBSCRIPTION_MINI_APP_URL= # Public URL of the subscription Mini App, e.g. https://app.yourdomain.tld/
SUBSCRIPTION_MINI_APP_URL= # URL of the subscription mini-app
START_COMMAND_DESCRIPTION= # Description of the /start command
DISABLE_WELCOME_MESSAGE= # Disable the welcome message
MY_DEVICES_SECTION_ENABLED=False # Enable the My Devices section in the subscription menu
@@ -46,51 +29,17 @@ REQUIRED_CHANNEL_LINK=https://t.me/your_channel #
# Webhook Base URL (used for Telegram and payment providers)
WEBHOOK_BASE_URL=https://webhooks.yourdomain.tld
TRUSTED_PROXIES=127.0.0.1,::1 # Reverse proxies trusted for X-Forwarded-For
# Subscription Mini App (same container, separate port)
WEBAPP_ENABLED=True # Run Mini App HTTP server
WEBAPP_SERVER_HOST=0.0.0.0 # Internal listen host
WEBAPP_SERVER_PORT=8081 # Internal/published Mini App port
WEBAPP_TITLE="/minishop" # Mini App title
WEBAPP_THEMES_DIR=data/themes # Folder with theme subfolders: <key>/theme.json and optional CSS/assets
WEBAPP_DEFAULT_THEME= # Optional: override descriptor default theme key (e.g. light)
WEBAPP_SESSION_SECRET= # Optional: HMAC secret for webapp sessions; generated if empty
WEBHOOK_SECRET_TOKEN= # Optional: Telegram webhook secret token; generated if empty
WEBAPP_SESSION_TTL_SECONDS=86400 # Web App session lifetime (24h)
WEBAPP_AUTH_MAX_AGE_SECONDS=86400 # Max Telegram initData age
WEBAPP_LOGIN_TOKEN_TTL_SECONDS=600 # External browser login link lifetime
TELEGRAM_OAUTH_CLIENT_ID= # Telegram Web Login Client ID from BotFather; defaults to bot ID from BOT_TOKEN
TELEGRAM_OAUTH_CLIENT_SECRET= # Optional Telegram Web Login Client Secret; reserved for full OIDC code flow
TELEGRAM_OAUTH_REQUEST_ACCESS=write # Optional comma-separated permissions: write,phone; empty = OpenID profile only
# Email login and account linking via SMTP (Brevo SMTP relay defaults)
SMTP_HOST=smtp-relay.brevo.com # SMTP server
SMTP_PORT=587 # Brevo recommends 587 with STARTTLS
SMTP_FALLBACK_PORTS=2525,465 # Tried after SMTP_PORT; 465 uses SSL automatically
SMTP_TIMEOUT_SECONDS=30 # Per SMTP connection/send attempt timeout
SMTP_USERNAME= # Brevo SMTP login
SMTP_PASSWORD= # Brevo SMTP key/password
SMTP_FROM_EMAIL= # Verified sender email
SMTP_FROM_NAME= # Optional sender name
SMTP_STARTTLS=True # Use STARTTLS on SMTP_PORT
SMTP_USE_SSL=False # Use SSL wrapper, usually only for port 465
EMAIL_CODE_TTL_SECONDS=600 # Email verification code lifetime
EMAIL_CODE_RESEND_SECONDS=60 # Minimum delay between code sends
EMAIL_CODE_MAX_ATTEMPTS=5 # Max attempts per code
BRUTE_FORCE_MAX_FAILURES=5 # Max failed code attempts in the throttle window
BRUTE_FORCE_WINDOW_SECONDS=900 # Rolling window used to count failures
BRUTE_FORCE_LOCK_SECONDS=1800 # Temporary lockout duration after too many failures
# 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
PLATEGA_ENABLED=False # Turn on PLATEGA
SEVERPAY_ENABLED=False # Turn on SeverPay
# Order of payment methods (top to bottom). Supported: severpay, freekassa, platega, yookassa, stars, cryptopay
PAYMENT_METHODS_ORDER=severpay,yookassa,cryptopay,freekassa,platega,stars
# Order of payment methods (top to bottom). Supported: severpay, freekassa, platega, yookassa, tribute, stars, cryptopay
PAYMENT_METHODS_ORDER=severpay,yookassa,cryptopay,freekassa,platega,tribute,stars
# YooKassa Payment Gateway Configuration
YOOKASSA_SHOP_ID=your_shop_id # Your store ID in YooKassa
@@ -113,7 +62,6 @@ FREEKASSA_API_KEY=your_api_key #
FREEKASSA_SECOND_SECRET=your_second_secret # Secret word #2 (used to verify notifications)
FREEKASSA_PAYMENT_IP= # Public IP address reported to FreeKassa
FREEKASSA_PAYMENT_METHOD_ID=44 # Payment method ID, you can get it from https://merchant.freekassa.net/settings/currencies
FREEKASSA_TRUSTED_IPS=168.119.157.136,168.119.60.227,178.154.197.79,51.250.54.238 # FreeKassa webhook source IP allowlist
# CryptoBot Payment Gateway Configuration
CRYPTOPAY_TOKEN= # API token for CryptoPay
@@ -125,11 +73,7 @@ CRYPTOPAY_ASSET=RUB #
PLATEGA_BASE_URL=https://app.platega.io # Base API URL
PLATEGA_MERCHANT_ID= # Your MerchantId from Platega
PLATEGA_SECRET= # API secret from Platega
PLATEGA_PAYMENT_METHOD=2 # Legacy method ID; fallback for the SBP button when PLATEGA_SBP_METHOD stays default
PLATEGA_SBP_ENABLED=False # Show a separate "Pay via SBP" Platega button
PLATEGA_CRYPTO_ENABLED=False # Show a separate "Pay with crypto" Platega button
PLATEGA_SBP_METHOD=2 # Platega method ID for SBP QR (default 2)
PLATEGA_CRYPTO_METHOD=13 # Platega method ID for crypto (default 13)
PLATEGA_PAYMENT_METHOD=2 # Payment method ID (2=SBP QR, 10=RU cards, 12=International, 13=Crypto)
PLATEGA_RETURN_URL= # Optional: redirect after successful payment (defaults to bot link)
PLATEGA_FAILED_URL= # Optional: redirect after failed/cancelled payment (defaults to return URL)
@@ -140,28 +84,35 @@ SEVERPAY_TOKEN= #
SEVERPAY_RETURN_URL= # Optional: redirect URL after payment (defaults to bot link)
SEVERPAY_LIFETIME_MINUTES= # Optional: payment link lifetime in minutes (30-4320, leave empty for default)
# Tribute Payment Gateway Configuration
TRIBUTE_API_KEY= # API key for verifying Tribute webhook signatures
TRIBUTE_SKIP_NOTIFICATIONS=True # Skip renewal notifications for Tribute payments
TRIBUTE_SKIP_CANCELLATION_NOTIFICATIONS=False # Skip cancellation notifications for Tribute payments
# Subscription Options. Specify cost parameters or payment links here.
1_MONTH_ENABLED=True
RUB_PRICE_1_MONTH=150
STARS_PRICE_1_MONTH=0
TRIBUTE_LINK_1_MONTH=
3_MONTHS_ENABLED=True
RUB_PRICE_3_MONTHS=300
STARS_PRICE_3_MONTHS=0
TRIBUTE_LINK_3_MONTHS=
6_MONTHS_ENABLED=True
RUB_PRICE_6_MONTHS=500
STARS_PRICE_6_MONTHS=0
TRIBUTE_LINK_6_MONTHS=
12_MONTHS_ENABLED=True
RUB_PRICE_12_MONTHS=900
STARS_PRICE_12_MONTHS=0
TRIBUTE_LINK_12_MONTHS=
# Traffic Packages (enables traffic sale mode when set)
TRAFFIC_PACKAGES=10:199,50:799 # Format: "<GB>:<price>", comma-separated
STARS_TRAFFIC_PACKAGES=10:2500 # Optional: traffic packages priced in Stars
TARIFFS_CONFIG_PATH=data/tariffs.json # Optional Tariffs 2.0 JSON config. If missing, legacy .env pricing is used.
TARIFF_TRAFFIC_WARNING_LEVELS=85,90,95 # Tariffs 2.0 traffic warning levels, percent used
# Subscription Notifications
SUBSCRIPTION_NOTIFICATIONS_ENABLED=True # Enable subscription
@@ -171,7 +122,6 @@ SUBSCRIPTION_NOTIFY_DAYS_BEFORE=3 #
REFERRAL_ONE_BONUS_PER_REFEREE=False # Give a bonus only once per referee
REFERRAL_WELCOME_BONUS_DAYS=3 # Welcome bonus for newly registered user from referral link
LEGACY_REFS=true # Allow ref_<tg_id> links. Leave unset/true unless you want to disable old links
# Referral Bonus Days
# Bonus for the inviting user
@@ -226,7 +176,6 @@ LOG_PAYMENTS=True #
LOG_PROMO_ACTIVATIONS=True # Log promo code activations
LOG_TRIAL_ACTIVATIONS=True # Log trial activations
LOG_SUSPICIOUS_ACTIVITY=True # Log suspicious activity
LOG_ADMIN_ACTIONS=True # Log actions from users listed in ADMIN_IDS
# Embedded mode thumbnails. Please don't touch this if you don't know what it is.
INLINE_REFERRAL_THUMBNAIL_URL=https://cdn-icons-png.flaticon.com/512/1077/1077114.png
-2
View File
@@ -1,2 +0,0 @@
*.sh text eol=lf
deploy/docker/frontend/*.sh text eol=lf
+68
View File
@@ -0,0 +1,68 @@
name: Build and Push Dev Docker Image
on:
push:
branches:
- dev
pull_request:
branches:
- dev
env:
GHCR_REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
DOCKERHUB_IMAGE: docker.io/${{ secrets.DOCKERHUB_USERNAME }}/${{ github.event.repository.name }}
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.GHCR_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}
${{ env.DOCKERHUB_IMAGE }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=sha,prefix={{branch}}-
flavor: |
latest=false
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Image digest
run: echo ${{ steps.meta.outputs.digest }}
+56
View File
@@ -0,0 +1,56 @@
name: Build and Publish multi-arch Docker Image
on:
push:
branches:
- main
tags:
- 'v*.*.*'
paths-ignore:
- 'README.md'
env:
GHCR_IMAGE: ghcr.io/${{ github.repository }}
DOCKERHUB_IMAGE: docker.io/${{ secrets.DOCKERHUB_USERNAME }}/${{ github.event.repository.name }}
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up QEMU (для эмуляции arm64 на x86)
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push multi-arch Docker image
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: |
${{ env.GHCR_IMAGE }}:latest
${{ env.GHCR_IMAGE }}:${{ github.ref_name }}
${{ env.DOCKERHUB_IMAGE }}:latest
${{ env.DOCKERHUB_IMAGE }}:${{ github.ref_name }}
+1 -20
View File
@@ -3,23 +3,6 @@ bot_database.sqlite3
# Игнорировать файлы окружения
.env
.env.*
!.env.example
docker-compose-dev.yml
scratch_*.py
*.local.*
node_modules/
.git/
# WebApp build artifacts (regenerated by `npm run build:webapp` / Docker build)
bot/app/web/templates/subscription_webapp.css
bot/app/web/templates/subscription_webapp.js
bot/app/web/templates/subscription_webapp.min.*.js
backend/bot/app/web/templates/subscription_webapp.css
backend/bot/app/web/templates/subscription_webapp.js
backend/bot/app/web/templates/subscription_webapp.min.*.js
tmp
.claude
# Игнорировать кэш Python
__pycache__/
@@ -33,6 +16,4 @@ __pycache__/
*.pid
locales/ru_backup.json
locales/en_backup.json
db/models_old.py
data/*
!data/tariffs.example.json
db/models_old.py
+20
View File
@@ -0,0 +1,20 @@
FROM python:3.12-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
pip install --no-cache-dir -r requirements.txt
FROM python:3.12-slim
WORKDIR /app
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
COPY . .
RUN rm -rf /root/.cache
CMD ["python", "main.py"]
+330 -84
View File
@@ -1,118 +1,364 @@
# Remnawave Minishop
# Telegram-бот для продажи подписок Remnawave
![Remnawave Minishop](docs/remnawave-minishop.webp)
Этот Telegram-бот предназначен для автоматизации продажи и управления подписками для панели **Remnawave**. Он интегрируется с API Remnawave для управления пользователями и подписками, а также использует различные платежные системы для приема платежей.
Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи и управления подписками Remnawave. Бот обрабатывает регистрацию, оплату, продление, пробный период, промокоды, рефералов и поддержку в чате. Web App показывает ссылку подключения, срок действия, трафик, оплату, устройства и вход по Telegram Mini Apps `initData`, Telegram OAuth / OpenID Connect и одноразовому email-коду.
## ✨ Ключевые возможности
Проект является переработанным форком [kavore/remnawave-tg-shop](https://github.com/kavore/remnawave-tg-shop). Для переноса данных из прежнего стека используйте [инструкцию по миграции](docs/migration-to-minishop.md).
### Для пользователей:
- **Регистрация и выбор языка:** Поддержка русского и английского языков.
- **Просмотр подписки:** Пользователи могут видеть статус своей подписки, дату окончания и ссылку на конфигурацию.
- **Мои устройства:** Опциональный раздел для просмотра и отключения подключенных устройств (активируется через переменную `MY_DEVICES_SECTION_ENABLED`).
- **Пробная подписка:** Система пробных подписок для новых пользователей (активируется вручную по кнопке).
- **Промокоды:** Возможность применять промокоды для получения скидок или бонусных дней.
- **Реферальная программа:** Пользователи могут приглашать друзей и получать за это бонусные дни подписки.
- **Оплата:** Поддержка оплаты через YooKassa, FreeKassa (REST API), Platega, SeverPay, CryptoPay и Telegram Stars.
## Возможности
### Для администраторов:
- **Защищенная админ-панель:** Доступ только для администраторов, указанных в `ADMIN_IDS`.
- **Статистика:** Просмотр статистики использования бота (общее количество пользователей, забаненные, активные подписки), недавние платежи и статус синхронизации с панелью.
- **Управление пользователями:** Блокировка/разблокировка пользователей, просмотр списка забаненных и детальной информации о пользователе.
- **Рассылка:** Отправка сообщений всем пользователям, пользователям с активной или истекшей подпиской.
- **Управление промокодами:** Создание и просмотр промокодов.
- **Синхронизация с панелью:** Ручной запуск синхронизации пользователей и подписок с панелью Remnawave.
- **Логи действий:** Просмотр логов всех действий пользователей.
Для пользователей:
## 🚀 Технологии
- регистрация с выбором русского или английского языка;
- просмотр статуса подписки, даты окончания, ссылки подключения и трафика;
- покупка подписок, пакетов трафика, обычная и premium-докупка трафика, докупка устройств по настроенному каталогу тарифов;
- Web App / Mini App с входом через Telegram или email;
- пробный период, промокоды и реферальная программа;
- оплата через YooKassa, FreeKassa, Platega, SeverPay, CryptoPay и Telegram Stars;
- раздел "Мои устройства" при включенном `MY_DEVICES_SECTION_ENABLED`.
- **Python 3.12**
- **Aiogram 3.x:** Асинхронный фреймворк для Telegram ботов.
- **aiohttp:** Для запуска веб-сервера (вебхуки).
- **SQLAlchemy 2.x & asyncpg:** Асинхронная работа с базой данных PostgreSQL.
- **YooKassa, FreeKassa API, Platega, SeverPay, aiocryptopay:** Интеграции с платежными системами.
- **Pydantic:** Для управления настройками из `.env` файла.
- **Docker & Docker Compose:** Для контейнеризации и развертывания.
Для администраторов:
## ⚙️ Установка и запуск
- админ-панель для пользователей из `ADMIN_IDS` (только при входе через Telegram, не для аккаунтов только с email);
- статистика пользователей, подписок, платежей и синхронизации с Remnawave;
- список пользователей с поиском, фильтрами и колонкой premium-трафика;
- блокировка пользователей, рассылки, промокоды, логи действий и настройка разрешенных параметров приложения поверх `.env`;
- редактор JSON-каталога тарифов с period/traffic-моделями, Internal Squads, premium-сквадами и HWID-пакетами;
- ручная синхронизация пользователей и подписок с панелью.
### Предварительные требования
## Документация
- Установленные Docker и Docker Compose.
- Рабочая панель Remnawave.
- Токен Telegram-бота.
- Данные для подключения к платежным системам (YooKassa, CryptoPay и т.д.).
- [Настройка окружения](docs/configuration.md) - основные переменные `.env`, платежи, Remnawave, пробный период, SMTP для email-входа и секреты.
- [Тарифы](docs/tariffs.md) - каталог тарифов, period- и traffic-модели, обычные и premium-докупки, premium-сквады, смена тарифа, HWID-лимиты и обработка трафика.
- [Админ-панель](docs/admin.md) - права доступа, настройки, редактор тарифов, premium-сквады и сохранение JSON-каталога.
- [Web App / Mini App](docs/webapp.md) - отдельный порт, домен, Telegram OAuth, email-вход и реферальные ссылки.
- [Темы Web App](docs/webapp-themes.md) - кастомные темы, настройка внешнего вида, логотипы, CSS/ассеты и пайплайн создания новой темы.
- [Развертывание](docs/deployment.md) - Docker Compose, reverse proxy, Nginx, Caddy, вебхуки, запуск из образа и обновление версии (`IMAGE_TAG`).
- [Миграция с remnawave-tg-shop](docs/migration-to-minishop.md) - перенос данных из прежнего стека.
### Шаги установки
## Совместимость
1. **Клонируйте репозиторий:**
```bash
git clone https://github.com/kavore/remnawave-tg-shop
cd remnawave-tg-shop
```
Интеграция с API панели Remnawave (вебхуки, пользователи, подписки, статистика в админке и т.д.) **протестирована** на панели Remnawave версии **`> 2.7.0`**. Более старые версии могут работать частично или не работать из‑за изменений в API.
2. **Создайте и настройте файл `.env`:**
Скопируйте `env.example` в `.env` и заполните своими данными.
```bash
cp .env.example .env
nano .env
```
Ниже перечислены ключевые переменные.
## Стек
<details>
<summary><b>Основные настройки</b></summary>
Сборка и runtime задаются **deploy/docker/Dockerfile** и **docker-compose.yml**; точные версии пакетов — в **backend/requirements.txt** и **frontend/package.json**.
| Переменная | Описание | Пример |
| --- | --- | --- |
| `BOT_TOKEN` | **Обязательно.** Токен вашего Telegram-бота. | `1234567890:ABC-DEF1234ghIkl-zyx57W2v1u123ew11` |
| `ADMIN_IDS` | **Обязательно.** ID администраторов в Telegram через запятую. | `12345678,98765432` |
| `DEFAULT_LANGUAGE` | Язык по умолчанию для новых пользователей. | `ru` |
| `SUPPORT_LINK` | (Опционально) Ссылка на поддержку. | `https://t.me/your_support` |
| `SUBSCRIPTION_MINI_APP_URL` | (Опционально) URL Mini App для показа подписки. | `https://t.me/your_bot/app` |
| `MY_DEVICES_SECTION_ENABLED` | Включить раздел «Мои устройства» в меню подписки (`true`/`false`). | `false` |
| `REQUIRED_CHANNEL_ID` | (Опционально) ID канала, на который пользователь должен подписаться перед использованием. Оставьте пустым, если проверка не нужна. | `-1001234567890` |
| `REQUIRED_CHANNEL_LINK` | (Опционально) Публичная ссылка или invite на канал для кнопки «Проверить подписку». | `https://t.me/your_channel` |
</details>
| Слой | Технологии |
| --- | --- |
| Backend | Python **3.12**, [aiogram](https://docs.aiogram.dev/) 3.x (Telegram), **aiohttp** (HTTP и Web App), **SQLAlchemy** 2 async, **asyncpg**, **Pydantic** / pydantic-settings, **httpx**, платёжные SDK (в т.ч. YooKassa, aiocryptopay), **PyJWT** |
| Данные | **PostgreSQL** **17** (сервис `postgres` в Compose) и **Redis** **7** (сервис `redis`) |
| Сборка Web App | **Node.js** **22**, **Svelte** **5**, **Vite**, **Tailwind CSS** 4; артефакты попадают в шаблоны `backend/bot/app/web/templates/` |
<details>
<summary><b>Настройки платежей и вебхуков</b></summary>
Локальная разработка без Docker возможна при установленных Python 3.12, PostgreSQL и (для пересборки фронта) Node 22; типичный сценарий — всё через Compose.
| Переменная | Описание |
| --- | --- |
| `WEBHOOK_BASE_URL`| **Обязательно.** Базовый URL для вебхуков, например `https://your.domain.com`. |
| `WEB_SERVER_HOST` | Хост для веб-сервера. | `0.0.0.0` |
| `WEB_SERVER_PORT` | Порт для веб-сервера. | `8080` |
| `PAYMENT_METHODS_ORDER` | (Опционально) Порядок отображения кнопок оплаты через запятую. Поддерживаемые ключи: `severpay`, `freekassa`, `platega`, `yookassa`, `stars`, `cryptopay`. Первый будет сверху. |
| `YOOKASSA_ENABLED` | Включить/выключить YooKassa (`true`/`false`). |
| `YOOKASSA_SHOP_ID` | ID вашего магазина в YooKassa. |
| `YOOKASSA_SECRET_KEY`| Секретный ключ магазина YooKassa. |
| `YOOKASSA_AUTOPAYMENTS_ENABLED` | Включить автопродление (сохранение карт, автосписания, управление способами оплаты). |
| `YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING` | Требовать обязательную привязку карты при оплате с автосписанием. Установите `false`, чтобы пользователю показывался чекбокс «Сохранить карту». |
| `NALOGO_INN` | ИНН для авторизации в nalog.ru (самозанятый). |
| `NALOGO_PASSWORD` | Пароль для авторизации в nalog.ru (самозанятый). |
| `CRYPTOPAY_ENABLED` | Включить/выключить CryptoPay (`true`/`false`). |
| `CRYPTOPAY_TOKEN` | Токен из вашего CryptoPay App. |
| `FREEKASSA_ENABLED` | Включить/выключить FreeKassa (`true`/`false`). |
| `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`). |
| `PLATEGA_ENABLED`| Включить/выключить Platega (`true`/`false`). |
| `PLATEGA_MERCHANT_ID`| MerchantId из личного кабинета Platega. |
| `PLATEGA_SECRET`| API секрет для запросов Platega. |
| `PLATEGA_PAYMENT_METHOD`| ID способа оплаты (2 — SBP QR, 10 — РФ карты, 12 — международные карты, 13 — crypto). |
| `PLATEGA_RETURN_URL`| (Опционально) URL редиректа после успешной оплаты. По умолчанию ссылка на бота. |
| `PLATEGA_FAILED_URL`| (Опционально) URL редиректа при ошибке/отмене. По умолчанию как `PLATEGA_RETURN_URL`. |
| `SEVERPAY_ENABLED` | Включить/выключить SeverPay (`true`/`false`). |
| `SEVERPAY_MID` | MID магазина в SeverPay. |
| `SEVERPAY_TOKEN` | Секрет/токен для подписи запросов SeverPay. |
| `SEVERPAY_BASE_URL` | (Опционально) Базовый URL API SeverPay. По умолчанию `https://severpay.io/api/merchant`. |
| `SEVERPAY_RETURN_URL` | (Опционально) URL редиректа после оплаты (по умолчанию ссылка на бота). |
| `SEVERPAY_LIFETIME_MINUTES` | (Опционально) Время жизни платежной ссылки в минутах (30–4320). |
</details>
## Быстрый старт
<details>
<summary><b>Настройки подписок</b></summary>
Требования:
Для каждого периода (1, 3, 6, 12 месяцев) можно настроить доступность и цены:
- `1_MONTH_ENABLED`: `true` или `false`
- `RUB_PRICE_1_MONTH`: Цена в рублях
- `STARS_PRICE_1_MONTH`: Цена в Telegram Stars
Аналогичные переменные есть для `3_MONTHS`, `6_MONTHS`, `12_MONTHS`.
</details>
- Docker и Docker Compose;
- рабочая панель Remnawave версии **`> 2.7.0`** (см. раздел «Совместимость»);
- токен Telegram-бота;
- параметры хотя бы одного платежного провайдера.
<details>
<summary><b>Настройки панели Remnawave</b></summary>
| Переменная | Описание |
| --- | --- |
| `PANEL_API_URL` | URL API вашей панели Remnawave. |
| `PANEL_API_KEY` | API ключ для доступа к панели. |
| `PANEL_WEBHOOK_SECRET`| Секретный ключ для проверки вебхуков от панели. |
| `USER_SQUAD_UUIDS` | ID отрядов для новых пользователей. |
| `USER_EXTERNAL_SQUAD_UUID` | Опционально. UUID внешнего отряда (External Squad) из [документации Remnawave](https://docs.rw/api), куда автоматически добавляются новые пользователи. |
| `USER_TRAFFIC_LIMIT_GB`| Лимит трафика в ГБ (0 - безлимит). |
| `USER_HWID_DEVICE_LIMIT`| Лимит устройств (HWID) для новых пользователей (0 - безлимит). |
> Раздел "Мои устройства" становится доступен пользователям только при включении `MY_DEVICES_SECTION_ENABLED`. Значение лимита устройств при создании записей в панели берётся из `USER_HWID_DEVICE_LIMIT`.
</details>
<details>
<summary><b>Настройки пробного периода</b></summary>
| Переменная | Описание |
| --- | --- |
| `TRIAL_ENABLED` | Включить/выключить пробный период (`true`/`false`). |
| `TRIAL_DURATION_DAYS`| Длительность пробного периода в днях. |
| `TRIAL_TRAFFIC_LIMIT_GB`| Лимит трафика для пробного периода в ГБ. |
</details>
3. **Запустите контейнеры:**
```bash
docker compose up -d
```
Эта команда скачает образ и запустит сервис в фоновом режиме.
4. **Настройка вебхуков (Обязательно):**
Вебхуки являются **обязательным** компонентом для работы бота, так как они используются для получения уведомлений от платежных систем (YooKassa, FreeKassa, CryptoPay, Platega, SeverPay) и панели Remnawave.
Вам понадобится обратный прокси (например, Nginx) для обработки HTTPS-трафика и перенаправления запросов на контейнер с ботом.
**Пути для перенаправления:**
- `https://<ваш_домен>/webhook/yookassa` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/yookassa`
- `https://<ваш_домен>/webhook/freekassa` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/freekassa`
- `https://<ваш_домен>/webhook/platega` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/platega`
- `https://<ваш_домен>/webhook/severpay` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/severpay`
- `https://<ваш_домен>/webhook/cryptopay` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/cryptopay`
- `https://<ваш_домен>/webhook/panel` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/panel`
- **Для Telegram:** Бот автоматически установит вебхук, если в `.env` указан `WEBHOOK_BASE_URL`. Путь будет `https://<ваш_домен>/<BOT_TOKEN>`.
Где `remnawave-tg-shop` — это имя сервиса из `docker-compose.yml`, а `<WEB_SERVER_PORT>` — порт, указанный в `.env`.
5. **Просмотр логов:**
```bash
docker compose logs -f remnawave-tg-shop
```
> 💡 Если включена проверка подписки на канал (`REQUIRED_CHANNEL_ID`), добавьте бота администратором в этот канал. Пользователь увидит кнопку «Проверить подписку», и, после первого успешного подтверждения, дальнейшие действия блокироваться не будут.
## Подробная инструкция для развертывания на сервере с панелью Remnawave
### 1. Клонирование репозитория
```bash
git clone https://github.com/3252a8/remnawave-minishop
cd remnawave-minishop
cp .env.example .env
nano .env
docker compose up -d --build
docker compose logs -f backend worker frontend
git clone https://github.com/kavore/remnawave-tg-shop && cd remnawave-tg-shop
```
Минимально заполните в `.env`:
- `BOT_TOKEN` - токен Telegram-бота;
- `ADMIN_IDS` - Telegram ID администраторов через запятую;
- `WEBHOOK_BASE_URL` - публичный URL вебхуков;
- `PANEL_API_URL`, `PANEL_API_KEY`, `PANEL_WEBHOOK_SECRET` - доступ к Remnawave;
- `USER_SQUAD_UUIDS` - Internal Squads для пользователей;
- настройки платежного провайдера;
- `SUBSCRIPTION_MINI_APP_URL`, если используется Web App.
Для каталога тарифов используется `TARIFFS_CONFIG_PATH` со значением по умолчанию `data/tariffs.json`. Пример формата лежит в [data/tariffs.example.json](data/tariffs.example.json), подробности - в [docs/tariffs.md](docs/tariffs.md).
Если в Docker Compose включаете bind mount `./data:/app/data`, заранее создайте каталог и отдайте его пользователю контейнера. Это нужно для сохранения `data/tariffs.json`, каталога тем `data/themes`, кеша логотипа Web App и animated emoji:
### 2. Настройка переменных окружения
```bash
mkdir -p data/themes data/webapp-logo data/webapp-emoji
chown -R 10001:10001 data
chmod -R u+rwX data
cp .env.example .env && nano .env
```
## Полезные команды
**Обязательные поля для заполнения:**
- `BOT_TOKEN` - токен телеграмм бота, например, `234567890:ABC-DEF1234ghIkl-zyx57W2v1u123ew11`
- `ADMIN_IDS` - TG ID администраторов, например, `12345678,98765432` и т.д. (через запятую без пробелов)
- `WEBHOOK_BASE_URL` - Обязательно. Базовый URL для вебхуков, например `https://webhook.domain.com`
- `PANEL_API_URL` - URL API вашей панели Remnawave (например, `http://remnawave:3000/api` или `https://panel.domain.com/api`)
- `PANEL_API_KEY` - API ключ для доступа к панели (генерируется из UI-интерфейса панели)
- `PANEL_WEBHOOK_SECRET` - Секретный ключ для проверки вебхуков от панели (берётся из `.env` самой панели)
- `USER_SQUAD_UUIDS` - ID отрядов для новых пользователей
### 3. Настройка Reverse Proxy (Nginx)
Перейдите в директорию конфигурации Nginx панели Remnawave:
```bash
# Локальная сборка и запуск
docker compose up -d --build
# Логи приложения
docker compose logs -f backend worker frontend
# Запуск с Caddy
docker compose -f deploy/compose/docker-compose-caddy.yml up -d
# Запуск из готового образа
IMAGE_TAG=3.1.0 docker compose -f deploy/compose/docker-compose-remote-server.yml up -d
cd /opt/remnawave/nginx && nano nginx.conf
```
GHCR image names for releases:
Добавьте в `nginx.conf` следующую конфигурацию:
- `ghcr.io/3252a8/remnawave-minishop-backend`
- `ghcr.io/3252a8/remnawave-minishop-worker`
- `ghcr.io/3252a8/remnawave-minishop-frontend`
```nginx
upstream remnawave-tg-shop {
server remnawave-tg-shop:8080;
}
## Поддержка
map $http_upgrade $connection_upgrade {
default upgrade;
"" close;
}
- Crypto: `USDT/Other ERC-20 0xeD506D44aae634fEc0E01C8835744fBedb7B2a44 (Ethereum/Polygon/Gnosis)`
server {
server_name webhook.domain.com; # Домен для отправки Webhook'ов
listen 443 ssl;
http2 on;
ssl_certificate "/etc/nginx/ssl/webhook_fullchain.pem";
ssl_certificate_key "/etc/nginx/ssl/webhook_privkey.key";
ssl_trusted_certificate "/etc/nginx/ssl/webhook_fullchain.pem";
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
proxy_intercept_errors on;
error_page 400 404 500 502 @redirect;
location / {
proxy_pass http://remnawave-tg-shop$request_uri;
}
location @redirect {
return 404;
}
}
```
### 4. Выпуск SSL-сертификата для домена webhook
Убедитесь, что установлены необходимые компоненты, а также откройте 80 порт:
```bash
sudo apt-get install cron socat
curl https://get.acme.sh | sh -s email=EMAIL && source ~/.bashrc
ufw allow 80/tcp && ufw reload
```
Выпустите сертификат:
```bash
acme.sh --set-default-ca --server letsencrypt
acme.sh --issue --standalone -d 'webhook.domain.com' \
--key-file /opt/remnawave/nginx/webhook_privkey.key \
--fullchain-file /opt/remnawave/nginx/webhook_fullchain.pem
```
### 5. Добавление сертификатов в Docker Compose Nginx
Отредактируйте `docker-compose.yml` панели Nginx:
```bash
cd /opt/remnawave/nginx && nano docker-compose.yml
```
Добавьте две строки в секцию `volumes`:
```yaml
services:
remnawave-nginx:
image: nginx:1.26
container_name: remnawave-nginx
hostname: remnawave-nginx
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
- ./fullchain.pem:/etc/nginx/ssl/fullchain.pem:ro
- ./privkey.key:/etc/nginx/ssl/privkey.key:ro
- ./subdomain_fullchain.pem:/etc/nginx/ssl/subdomain_fullchain.pem:ro
- ./subdomain_privkey.key:/etc/nginx/ssl/subdomain_privkey.key:ro
- ./webhook_fullchain.pem:/etc/nginx/ssl/webhook_fullchain.pem:ro # Добавьте эту строку
- ./webhook_privkey.key:/etc/nginx/ssl/webhook_privkey.key:ro # Добавьте эту строку
restart: always
ports:
- '0.0.0.0:443:443'
networks:
- remnawave-network
networks:
remnawave-network:
name: remnawave-network
driver: bridge
external: true
```
### 6. Запуск бота и перезапуск Nginx
Запустите бота:
```bash
cd /root/remnawave-tg-shop && docker compose up -d && docker compose logs -f -t
```
Перезапустите Nginx:
```bash
cd /opt/remnawave/nginx && docker compose down && docker compose up -d && docker compose logs -f -t
```
## 🐳 Docker
Файлы `Dockerfile` и `docker-compose.yml` уже настроены для сборки и запуска проекта. `docker-compose.yml` использует готовый образ с GitHub Container Registry, но вы можете раскомментировать `build: .` для локальной сборки.
Для автоматической публикации образов настроены GitHub Actions (`.github/workflows`). По умолчанию образы пушатся в GitHub Container Registry и Docker Hub. Добавьте в Secrets репозитория значения `DOCKERHUB_USERNAME` и `DOCKERHUB_TOKEN` (персональный access token или пароль для Docker Hub), чтобы загрузка в Docker Hub работала корректно.
## 📁 Структура проекта
```
.
├── bot/
│ ├── filters/ # Пользовательские фильтры Aiogram
│ ├── handlers/ # Обработчики сообщений и колбэков
│ ├── keyboards/ # Клавиатуры
│ ├── middlewares/ # Промежуточные слои (i18n, проверка бана)
│ ├── services/ # Бизнес-логика (платежи, API панели)
│ ├── states/ # Состояния FSM
│ └── main_bot.py # Основная логика бота
├── config/
│ └── settings.py # Настройки Pydantic
├── db/
│ ├── dal/ # Слой доступа к данным (DAL)
│ ├── database_setup.py # Настройка БД
│ └── models.py # Модели SQLAlchemy
├── locales/ # Файлы локализации (ru, en)
├── .env.example # Пример файла с переменными окружения
├── Dockerfile # Инструкции для сборки Docker-образа
├── docker-compose.yml # Файл для оркестрации контейнеров
├── requirements.txt # Зависимости Python
└── main.py # Точка входа в приложение
```
## 🔮 Планы на будущее
- Расширенные типы промокодов (например, скидки в процентах).
## ❤️ Поддержка
- Карты РФ и зарубежные: [Tribute](https://t.me/tribute/app?startapp=dqdg)
- Crypto: `USDT TRC-20 TT3SqBbfU4vYm6SUwUVNZsy278m2xbM4GE`
-12
View File
@@ -1,12 +0,0 @@
import logging
import os
import sys
def configure_logging() -> None:
level = getattr(logging, os.getenv("LOG_LEVEL", "INFO").upper(), logging.INFO)
logging.basicConfig(
level=level,
stream=sys.stdout,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
)
-58
View File
@@ -1,58 +0,0 @@
"""Compatibility facade for the admin Mini App API."""
# ruff: noqa: I001
from bot.app.web.admin_api_impl import (
_runtime as _runtime,
ads as _ads,
auth as _auth,
broadcast as _broadcast,
common as _common,
logs as _logs,
panel as _panel,
payments as _payments,
promos as _promos,
routes as _routes,
settings as _settings,
stats as _stats,
sync as _sync,
tariffs as _tariffs,
themes as _themes,
users as _users,
)
_MODULES = (
_runtime,
_auth,
_common,
_stats,
_users,
_payments,
_promos,
_logs,
_broadcast,
_sync,
_ads,
_settings,
_tariffs,
_themes,
_panel,
_routes,
)
_NAMESPACE = {}
for _module in _MODULES:
_NAMESPACE.update(
{
_name: _value
for _name, _value in vars(_module).items()
if not _name.startswith("__") and _name != "annotations"
}
)
for _module in _MODULES:
vars(_module).update(_NAMESPACE)
globals().update(_NAMESPACE)
__all__ = sorted(_name for _name in _NAMESPACE if not _name.startswith("__"))
@@ -1 +0,0 @@
"""Domain modules for the admin Mini App API."""
@@ -1,66 +0,0 @@
# ruff: noqa: F401,F403,F405,I001
"""HTTP API powering the admin section of the subscription Mini App.
All routes require an authenticated webapp session (cookie or Bearer
token) AND the resolved Telegram user id must appear in
``settings.ADMIN_IDS``. Authorization is enforced via the
``_require_admin_user_id`` helper, never trusted from the client.
"""
from __future__ import annotations
import csv
import io
import json
import logging
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from urllib.parse import parse_qsl, urlsplit, urlunsplit
from aiohttp import web
from pydantic import ValidationError
from sqlalchemy import Float, and_, case, cast, or_, select
from sqlalchemy import func as sa_func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import sessionmaker
from bot.app.web.admin_settings_manifest import (
manifest_payload,
)
from bot.infra.webhook_queue import enqueue_webhook_event
from bot.services.referral_service import ReferralService
from bot.services.settings_override_service import (
current_value,
update_overrides,
)
from bot.utils import MessageContent, send_message_via_queue
from bot.utils.message_queue import get_queue_manager
from config.settings import Settings
from config.tariffs_config import TariffsConfig
from db.dal import (
ad_dal,
app_settings_dal,
message_log_dal,
panel_sync_dal,
payment_dal,
promo_code_dal,
subscription_dal,
user_dal,
)
from db.models import (
AdCampaign,
MessageLog,
Payment,
PromoCode,
Subscription,
User,
UserTelegramAvatar,
)
logger = logging.getLogger(__name__)
# ─── Auth ──────────────────────────────────────────────────────────
__all__ = [name for name in globals() if not name.startswith("__")]
-69
View File
@@ -1,69 +0,0 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
async def admin_ads_list_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
campaigns = await ad_dal.list_campaigns(session)
totals = await ad_dal.get_totals(session)
results = []
for campaign in campaigns:
try:
stats = await ad_dal.get_campaign_stats(session, campaign.ad_campaign_id)
except Exception:
stats = {}
results.append(_serialize_ad(campaign, stats))
return _ok({"campaigns": results, "totals": totals})
async def admin_ad_create_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
payload = await _read_json(request)
source = str(payload.get("source") or "").strip()
start_param = str(payload.get("start_param") or "").strip()
cost = float(payload.get("cost") or 0.0)
if not source or not start_param:
return _error(400, "invalid_payload")
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
existing = await ad_dal.get_campaign_by_start_param(session, start_param)
if existing:
return _error(409, "duplicate_start_param")
campaign = await ad_dal.create_campaign(
session,
source=source,
start_param=start_param,
cost=cost,
)
await session.commit()
await session.refresh(campaign)
return _ok({"campaign": _serialize_ad(campaign)})
async def admin_ad_toggle_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
campaign_id = int(request.match_info["campaign_id"])
payload = await _read_json(request)
is_active = bool(payload.get("is_active", True))
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
ok = await ad_dal.toggle_campaign_active(session, campaign_id, is_active)
if not ok:
return _error(404, "not_found")
await session.commit()
return _ok({})
async def admin_ad_delete_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
campaign_id = int(request.match_info["campaign_id"])
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
ok = await ad_dal.delete_campaign(session, campaign_id)
if not ok:
return _error(404, "not_found")
await session.commit()
return _ok({})
@@ -1,58 +0,0 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
def _require_admin_user_id(request: web.Request) -> int:
"""Return the authenticated user id, or raise 401/403 for non-admins."""
from bot.app.web.session import extract_authenticated_user_id
settings: Settings = request.app["settings"]
user_id = extract_authenticated_user_id(request)
if not user_id:
raise web.HTTPUnauthorized(
text=json.dumps({"ok": False, "error": "unauthorized"}),
content_type="application/json",
)
admin_ids = settings.ADMIN_IDS or []
db_user_telegram_id = request.get("admin_telegram_id")
if db_user_telegram_id is None:
raise web.HTTPForbidden(
text=json.dumps({"ok": False, "error": "forbidden"}),
content_type="application/json",
)
if int(db_user_telegram_id) not in {int(x) for x in admin_ids}:
raise web.HTTPForbidden(
text=json.dumps({"ok": False, "error": "forbidden"}),
content_type="application/json",
)
return int(user_id)
@web.middleware
async def admin_auth_middleware(request: web.Request, handler):
"""Resolve the Telegram id of the current user and stash it on the request.
Doing this once per request lets every admin route call
``_require_admin_user_id`` without re-querying the DB.
"""
if not request.path.startswith("/api/admin"):
return await handler(request)
from bot.app.web.session import extract_authenticated_user_id
user_id = extract_authenticated_user_id(request)
if user_id:
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
db_user = await user_dal.get_user_by_id(session, user_id)
if db_user and db_user.telegram_id:
request["admin_telegram_id"] = int(db_user.telegram_id)
elif db_user:
# No telegram_id yet (email-only user) — can't be an admin
request["admin_telegram_id"] = None
return await handler(request)
@@ -1,54 +0,0 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
async def admin_broadcast_route(request: web.Request) -> web.Response:
actor_id = _require_admin_user_id(request)
payload = await _read_json(request)
text = str(payload.get("text") or "").strip()
target = str(payload.get("target") or "all").strip().lower()
if not text:
return _error(400, "empty_text")
if target not in {"all", "active", "inactive"}:
target = "all"
queue_manager = get_queue_manager()
if not queue_manager:
return _error(503, "queue_unavailable")
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
if target == "active":
user_ids = await user_dal.get_user_ids_with_active_subscription(session)
elif target == "inactive":
user_ids = await user_dal.get_user_ids_without_active_subscription(session)
else:
user_ids = await user_dal.get_all_active_user_ids_for_broadcast(session)
sent = 0
failed = 0
for uid in user_ids:
try:
await send_message_via_queue(
queue_manager,
int(uid),
MessageContent(content_type="text", text=text),
parse_mode="HTML",
disable_web_page_preview=True,
)
sent += 1
except Exception as exc:
failed += 1
logger.debug("Broadcast queue failed for %s: %s", uid, exc)
await message_log_dal.create_message_log(
session,
{
"user_id": actor_id,
"event_type": "admin_broadcast_webapp",
"content": f"target={target} sent={sent} failed={failed} text={text[:120]}",
"is_admin_event": True,
},
)
return _ok({"queued": sent, "failed": failed, "target": target})
@@ -1,371 +0,0 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
def _ok(payload: Dict[str, Any], **extra) -> web.Response:
body = {"ok": True, **payload, **extra}
return web.json_response(body)
def _error(status: int, code: str, message: str = "") -> web.Response:
return web.json_response(
{"ok": False, "error": code, "message": message or code},
status=status,
)
async def _read_json(request: web.Request) -> Dict[str, Any]:
try:
data = await request.json()
return data if isinstance(data, dict) else {}
except Exception:
return {}
def _serialize_user(user: User) -> Dict[str, Any]:
return {
"user_id": int(user.user_id),
"telegram_id": int(user.telegram_id) if user.telegram_id else None,
"telegram_photo_url": user.telegram_photo_url,
"username": user.username,
"first_name": user.first_name,
"last_name": user.last_name,
"email": user.email,
"language_code": user.language_code,
"is_banned": bool(user.is_banned),
"registration_date": user.registration_date.isoformat() if user.registration_date else None,
"panel_user_uuid": user.panel_user_uuid,
"referral_code": user.referral_code,
"referred_by_id": int(user.referred_by_id) if user.referred_by_id else None,
}
def _premium_limit_bytes_from_subscription(sub: Subscription) -> int:
premium_bonus_bytes = int(getattr(sub, "premium_bonus_bytes", 0) or 0)
return (
int(sub.premium_baseline_bytes or 0)
+ int(sub.premium_topup_balance_bytes or 0)
+ int(getattr(sub, "premium_topup_used_bytes", 0) or 0)
+ premium_bonus_bytes
)
def _premium_traffic_list_payload(sub: Optional[Subscription]) -> Dict[str, Any]:
"""Premium traffic column when subscription has a finite premium quota (bytes > 0).
Note: ``Subscription.premium_is_limited`` in the DB means *quota exhausted* for panel
routing, not 'tariff includes premium traffic' — do not use it here.
"""
if sub is None:
return {"state": "none"}
if bool(getattr(sub, "premium_unlimited_override", False)):
return {
"state": "unlimited",
"unlimited": True,
"used_bytes": int(sub.premium_used_bytes or 0),
"limit_bytes": None,
"percent": None,
}
limit_bytes = _premium_limit_bytes_from_subscription(sub)
if limit_bytes <= 0:
return {"state": "none"}
used_bytes = int(sub.premium_used_bytes or 0)
ratio = float(used_bytes) / float(limit_bytes) if limit_bytes else 0.0
pct = int(max(0, min(100, round(ratio * 100))))
if ratio >= 1.0:
state = "critical"
elif ratio >= 0.85:
state = "warn"
else:
state = "good"
return {
"state": state,
"unlimited": False,
"used_bytes": used_bytes,
"limit_bytes": limit_bytes,
"percent": pct,
}
def _serialize_subscription(sub: Subscription) -> Dict[str, Any]:
premium_bonus_bytes = int(getattr(sub, "premium_bonus_bytes", 0) or 0)
regular_bonus_bytes = int(getattr(sub, "regular_bonus_bytes", 0) or 0)
regular_unlimited_override = bool(getattr(sub, "regular_unlimited_override", False))
premium_unlimited_override = bool(getattr(sub, "premium_unlimited_override", False))
premium_limit_bytes = _premium_limit_bytes_from_subscription(sub)
return {
"subscription_id": int(sub.subscription_id),
"panel_user_uuid": sub.panel_user_uuid,
"panel_subscription_uuid": sub.panel_subscription_uuid,
"start_date": sub.start_date.isoformat() if sub.start_date else None,
"end_date": sub.end_date.isoformat() if sub.end_date else None,
"duration_months": sub.duration_months,
"is_active": bool(sub.is_active),
"status_from_panel": sub.status_from_panel,
"traffic_limit_bytes": sub.traffic_limit_bytes,
"traffic_used_bytes": sub.traffic_used_bytes,
"tier_baseline_bytes": sub.tier_baseline_bytes,
"topup_balance_bytes": sub.topup_balance_bytes,
"premium_used_bytes": sub.premium_used_bytes,
"premium_limit_bytes": premium_limit_bytes,
"premium_baseline_bytes": sub.premium_baseline_bytes,
"premium_topup_balance_bytes": sub.premium_topup_balance_bytes,
"premium_topup_used_bytes": getattr(sub, "premium_topup_used_bytes", 0),
"premium_bonus_bytes": premium_bonus_bytes,
"regular_bonus_bytes": regular_bonus_bytes,
"regular_unlimited_override": regular_unlimited_override,
"premium_unlimited_override": premium_unlimited_override,
"premium_is_limited": bool(sub.premium_is_limited),
"tariff_key": sub.tariff_key,
"auto_renew_enabled": bool(sub.auto_renew_enabled),
"provider": sub.provider,
"is_throttled": bool(sub.is_throttled),
}
def _payment_traffic_gb_split(payment: Payment) -> Tuple[Optional[float], Optional[float]]:
"""For traffic purchases: ``(regular_gb, premium_gb)``. Other payments → (None, None)."""
if payment.purchased_gb is None:
return None, None
try:
gb = float(payment.purchased_gb)
except (TypeError, ValueError):
return None, None
sm = (payment.sale_mode or "").strip()
if not sm:
return None, None
base = sm.split("@", 1)[0].split("|", 1)[0].lower()
if base == "premium_topup":
return None, gb
if base in {"traffic", "traffic_package", "topup"}:
return gb, None
return None, None
def _payment_user_display_label(loaded_user: Any, payment_user_id: int) -> str:
"""Human-facing name for payments tables: TG profile name, else email, else user id."""
if loaded_user is None:
return str(payment_user_id)
tid = getattr(loaded_user, "telegram_id", None)
if tid is not None:
fn = (getattr(loaded_user, "first_name", None) or "").strip()
ln = (getattr(loaded_user, "last_name", None) or "").strip()
full = f"{fn} {ln}".strip()
if full:
return full
un = (getattr(loaded_user, "username", None) or "").strip()
if un:
return un if un.startswith("@") else f"@{un}"
return str(payment_user_id)
email = (getattr(loaded_user, "email", None) or "").strip()
if email:
return email
return str(payment_user_id)
def _serialize_payment(payment: Payment) -> Dict[str, Any]:
# Avoid lazy-loading `payment.user` outside an active SQLAlchemy session.
# Some admin routes serialize payments after the session scope is closed.
telegram_id = None
loaded_user = payment.__dict__.get("user")
user_label = _payment_user_display_label(loaded_user, int(payment.user_id))
if loaded_user is not None:
tid = getattr(loaded_user, "telegram_id", None)
if tid is not None:
try:
telegram_id = int(tid)
except (TypeError, ValueError):
telegram_id = None
reg_gb, prem_gb = _payment_traffic_gb_split(payment)
return {
"payment_id": int(payment.payment_id),
"user_id": int(payment.user_id),
"user_label": user_label,
"telegram_id": telegram_id,
"traffic_regular_gb": reg_gb,
"traffic_premium_gb": prem_gb,
"provider": payment.provider,
"provider_payment_id": payment.provider_payment_id,
"amount": float(payment.amount),
"currency": payment.currency,
"status": payment.status,
"description": payment.description,
"subscription_duration_months": payment.subscription_duration_months,
"sale_mode": payment.sale_mode,
"tariff_key": payment.tariff_key,
"purchased_gb": payment.purchased_gb,
"purchased_hwid_devices": payment.purchased_hwid_devices,
"created_at": payment.created_at.isoformat() if payment.created_at else None,
}
def _serialize_promo(promo: PromoCode) -> Dict[str, Any]:
return {
"id": int(promo.promo_code_id),
"code": promo.code,
"bonus_days": int(promo.bonus_days),
"max_activations": int(promo.max_activations),
"current_activations": int(promo.current_activations or 0),
"is_active": bool(promo.is_active),
"valid_until": promo.valid_until.isoformat() if promo.valid_until else None,
"created_at": promo.created_at.isoformat() if promo.created_at else None,
"created_by_admin_id": int(promo.created_by_admin_id)
if promo.created_by_admin_id
else None,
}
def _serialize_ad(campaign: AdCampaign, totals: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
return {
"id": int(campaign.ad_campaign_id),
"source": campaign.source,
"start_param": campaign.start_param,
"cost": float(campaign.cost or 0),
"is_active": bool(campaign.is_active),
"created_at": campaign.created_at.isoformat() if campaign.created_at else None,
"stats": totals or {},
}
def _serialize_log(entry: MessageLog) -> Dict[str, Any]:
return {
"log_id": int(entry.log_id),
"user_id": int(entry.user_id) if entry.user_id else None,
"telegram_username": entry.telegram_username,
"telegram_first_name": entry.telegram_first_name,
"event_type": entry.event_type,
"content": entry.content,
"is_admin_event": bool(entry.is_admin_event),
"target_user_id": int(entry.target_user_id) if entry.target_user_id else None,
"timestamp": entry.timestamp.isoformat() if entry.timestamp else None,
}
def _tariffs_config_path(settings: Settings) -> Path:
return Path(settings.TARIFFS_CONFIG_PATH).expanduser()
def _tariffs_config_payload(config: TariffsConfig) -> Dict[str, Any]:
return config.model_dump(mode="json", exclude_none=True)
def _write_tariffs_config_file(path: Path, config: TariffsConfig) -> None:
data = _tariffs_config_payload(config)
path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = path.with_suffix(f"{path.suffix}.tmp")
payload = json.dumps(data, ensure_ascii=False, indent=2) + "\n"
try:
tmp_path.write_text(payload, encoding="utf-8")
tmp_path.replace(path)
except PermissionError:
# A docker-compose single-file bind mount can make /app/config
# unwritable while the mounted tariffs.json itself is writable.
# Fall back to updating the existing file in-place.
if tmp_path.exists():
try:
tmp_path.unlink()
except OSError:
pass
path.write_text(payload, encoding="utf-8")
def _webapp_themes_catalog_payload(config: Any) -> Dict[str, Any]:
return config.model_dump(mode="json", exclude_none=True)
def _panel_node_uuid_key(node: Dict[str, Any]) -> str:
uid = node.get("nodeUuid") or node.get("node_uuid") or node.get("uuid") or node.get("id")
return str(uid).strip().lower() if uid else ""
def _panel_node_users_online(node: Dict[str, Any]) -> Optional[int]:
uo = node.get("usersOnline")
if uo is None:
uo = node.get("users_online")
if uo is None:
uo = node.get("onlineUsers") or node.get("online_users")
if uo is None:
mg = node.get("metricGroups")
if isinstance(mg, dict):
uo = mg.get("onlineUsers") or mg.get("online_users")
if uo is None:
return None
try:
return int(uo)
except (TypeError, ValueError):
return None
def _panel_nodes_online_by_uuid(nodes_payload: Any) -> Dict[str, int]:
"""Build node_uuid(lower) -> usersOnline from GET /system/stats/nodes payload."""
out: Dict[str, int] = {}
raw_list: Optional[List[Any]] = None
if isinstance(nodes_payload, list):
raw_list = nodes_payload
elif isinstance(nodes_payload, dict):
raw_list = nodes_payload.get("nodes")
if raw_list is None:
raw_list = nodes_payload.get("items") or nodes_payload.get("data")
if not isinstance(raw_list, list):
return out
for n in raw_list:
if not isinstance(n, dict):
continue
key = _panel_node_uuid_key(n)
if not key:
continue
online = _panel_node_users_online(n)
if online is not None:
out[key] = online
return out
def _enrich_bandwidth_nodes_with_online(
bw: Any,
online_by_uuid: Dict[str, int],
online_by_name: Optional[Dict[str, int]] = None,
) -> None:
"""Attach usersOnline to topNodes/series (UUID and optional node name)."""
if not isinstance(bw, dict):
return
if not online_by_uuid and not online_by_name:
return
for key in ("topNodes", "series"):
arr = bw.get(key)
if not isinstance(arr, list):
continue
for item in arr:
if not isinstance(item, dict):
continue
if item.get("usersOnline") is not None:
continue
uid = item.get("uuid") or item.get("nodeUuid") or item.get("node_uuid")
if uid and online_by_uuid:
hit = online_by_uuid.get(str(uid).strip().lower())
if hit is not None:
item["usersOnline"] = hit
continue
if online_by_name:
nm = item.get("name")
if nm and isinstance(nm, str):
hitn = online_by_name.get(nm.strip().lower())
if hitn is not None:
item["usersOnline"] = hitn
def _build_admin_webapp_referral_link(
base_url: Optional[str], referral_code: Optional[str]
) -> Optional[str]:
"""Mirror of ``subscription_webapp._build_webapp_referral_link``.
Kept local to avoid a cross-module import cycle (subscription_webapp
imports admin_api).
"""
if not base_url or not referral_code:
return None
parts = urlsplit(base_url)
query = dict(parse_qsl(parts.query, keep_blank_values=True))
query["ref"] = f"u{referral_code}"
new_query = "&".join(f"{k}={v}" for k, v in query.items())
return urlunsplit((parts.scheme, parts.netloc, parts.path, new_query, parts.fragment))
@@ -1,36 +0,0 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
async def admin_logs_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
async_session_factory: sessionmaker = request.app["async_session_factory"]
page = max(0, int(request.query.get("page", 0) or 0))
page_size = min(200, max(1, int(request.query.get("page_size", 50) or 50)))
user_filter = request.query.get("user_id")
async with async_session_factory() as session:
if user_filter:
try:
user_id = int(user_filter)
except (TypeError, ValueError):
return _error(400, "invalid_user_id")
entries = await message_log_dal.get_user_message_logs(
session, user_id, page_size, page * page_size
)
total = await message_log_dal.count_user_message_logs(session, user_id)
else:
entries = await message_log_dal.get_all_message_logs(
session, page_size, page * page_size
)
total = await message_log_dal.count_all_message_logs(session)
return _ok(
{
"logs": [_serialize_log(entry) for entry in entries],
"page": page,
"page_size": page_size,
"total": int(total or 0),
}
)
@@ -1,35 +0,0 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
async def admin_panel_internal_squads_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
panel_service = request.app.get("panel_service")
if panel_service is None:
return _error(503, "panel_unavailable", "Panel service unavailable")
try:
squads = await panel_service.get_internal_squads()
except Exception as exc:
logger.exception("Failed to load internal squads from panel")
return _error(502, "panel_request_failed", str(exc))
if squads is None:
return _error(502, "panel_request_failed", "Unable to load internal squads")
items = []
for squad in squads:
if not isinstance(squad, dict):
continue
uuid = squad.get("uuid") or squad.get("id")
if not uuid:
continue
items.append(
{
"uuid": str(uuid),
"name": squad.get("name") or squad.get("title") or str(uuid),
"members_count": squad.get("membersCount")
or squad.get("usersCount")
or squad.get("members_count"),
"active_inbounds_count": squad.get("activeInboundsCount")
or squad.get("active_inbounds_count"),
}
)
return _ok({"squads": items})
@@ -1,95 +0,0 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
async def admin_payments_list_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
async_session_factory: sessionmaker = request.app["async_session_factory"]
page = max(0, int(request.query.get("page", 0) or 0))
page_size = min(100, max(1, int(request.query.get("page_size", 25) or 25)))
async with async_session_factory() as session:
from sqlalchemy.orm import selectinload
stmt = (
select(Payment)
.options(selectinload(Payment.user))
.order_by(Payment.created_at.desc())
.offset(page * page_size)
.limit(page_size)
)
rows = (await session.execute(stmt)).scalars().all()
total = await payment_dal.get_payments_count(session)
return _ok(
{
"payments": [_serialize_payment(p) for p in rows],
"page": page,
"page_size": page_size,
"total": int(total or 0),
}
)
async def admin_payments_export_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
from sqlalchemy.orm import selectinload
stmt = (
select(Payment)
.options(selectinload(Payment.user))
.order_by(Payment.created_at.desc())
.limit(10000)
)
rows = (await session.execute(stmt)).scalars().all()
buffer = io.StringIO()
writer = csv.writer(buffer)
writer.writerow(
[
"payment_id",
"user_id",
"user_label",
"provider",
"provider_payment_id",
"amount",
"currency",
"status",
"description",
"duration_months",
"sale_mode",
"tariff_key",
"created_at",
]
)
for p in rows:
label = _payment_user_display_label(p.user, int(p.user_id)) if p.user else str(p.user_id)
writer.writerow(
[
p.payment_id,
p.user_id,
label,
p.provider,
p.provider_payment_id or "",
p.amount,
p.currency,
p.status,
p.description or "",
p.subscription_duration_months or "",
p.sale_mode or "",
p.tariff_key or "",
p.created_at.isoformat() if p.created_at else "",
]
)
response = web.Response(
body=buffer.getvalue().encode("utf-8-sig"),
content_type="text/csv",
charset="utf-8",
)
response.headers["Content-Disposition"] = 'attachment; filename="payments.csv"'
return response
@@ -1,96 +0,0 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
async def admin_promos_list_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
async_session_factory: sessionmaker = request.app["async_session_factory"]
page = max(0, int(request.query.get("page", 0) or 0))
page_size = min(100, max(1, int(request.query.get("page_size", 25) or 25)))
async with async_session_factory() as session:
promos = await promo_code_dal.get_all_promo_codes_with_details(
session, limit=page_size, offset=page * page_size
)
total = await promo_code_dal.get_promo_codes_count(session)
return _ok(
{
"promos": [_serialize_promo(p) for p in promos],
"page": page,
"page_size": page_size,
"total": int(total or 0),
}
)
async def admin_promo_create_route(request: web.Request) -> web.Response:
actor_id = _require_admin_user_id(request)
payload = await _read_json(request)
code = str(payload.get("code") or "").strip().upper()
bonus_days = int(payload.get("bonus_days") or 0)
max_activations = int(payload.get("max_activations") or 0)
valid_days = payload.get("valid_days")
if not code or bonus_days <= 0 or max_activations <= 0:
return _error(400, "invalid_payload")
valid_until = None
if valid_days:
try:
valid_until = datetime.now(timezone.utc) + timedelta(days=int(valid_days))
except (TypeError, ValueError):
return _error(400, "invalid_valid_days")
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
existing = await promo_code_dal.get_promo_code_by_code(session, code)
if existing:
return _error(409, "duplicate_code")
promo = await promo_code_dal.create_promo_code(
session,
{
"code": code,
"bonus_days": bonus_days,
"max_activations": max_activations,
"valid_until": valid_until,
"created_by_admin_id": actor_id,
"is_active": True,
},
)
await session.commit()
return _ok({"promo": _serialize_promo(promo)})
async def admin_promo_update_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
promo_id = int(request.match_info["promo_id"])
payload = await _read_json(request)
update_data: Dict[str, Any] = {}
if "is_active" in payload:
update_data["is_active"] = bool(payload["is_active"])
if "bonus_days" in payload and payload["bonus_days"] is not None:
update_data["bonus_days"] = int(payload["bonus_days"])
if "max_activations" in payload and payload["max_activations"] is not None:
update_data["max_activations"] = int(payload["max_activations"])
if not update_data:
return _error(400, "no_changes")
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
promo = await promo_code_dal.update_promo_code(session, promo_id, update_data)
if not promo:
return _error(404, "not_found")
await session.commit()
await session.refresh(promo)
return _ok({"promo": _serialize_promo(promo)})
async def admin_promo_delete_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
promo_id = int(request.match_info["promo_id"])
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
promo = await promo_code_dal.delete_promo_code(session, promo_id)
if not promo:
return _error(404, "not_found")
await session.commit()
return _ok({})
@@ -1,61 +0,0 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
def setup_admin_routes(app: web.Application) -> None:
router = app.router
router.add_get("/api/admin/me", admin_me_route)
router.add_get("/api/admin/stats", admin_stats_route)
router.add_get("/api/admin/users", admin_users_list_route)
router.add_get("/api/admin/users/{user_id:-?\\d+}", admin_user_detail_route)
router.add_get("/api/admin/users/{user_id:-?\\d+}/avatar", admin_user_avatar_route)
router.add_post("/api/admin/users/{user_id:-?\\d+}/ban", admin_user_ban_route)
router.add_post("/api/admin/users/{user_id:-?\\d+}/message", admin_user_message_route)
router.add_post(
"/api/admin/users/{user_id:-?\\d+}/message/preview", admin_user_message_preview_route
)
router.add_post("/api/admin/users/{user_id:-?\\d+}/reset-trial", admin_user_reset_trial_route)
router.add_post("/api/admin/users/{user_id:-?\\d+}/extend", admin_user_extend_route)
router.add_post(
"/api/admin/users/{user_id:-?\\d+}/premium-override",
admin_user_premium_override_route,
)
router.add_post(
"/api/admin/users/{user_id:-?\\d+}/regular-traffic-override",
admin_user_regular_traffic_override_route,
)
router.add_post(
"/api/admin/users/{user_id:-?\\d+}/traffic-grant",
admin_user_traffic_grant_route,
)
router.add_delete("/api/admin/users/{user_id:-?\\d+}", admin_user_delete_route)
router.add_get("/api/admin/payments", admin_payments_list_route)
router.add_get("/api/admin/payments/export.csv", admin_payments_export_route)
router.add_get("/api/admin/promos", admin_promos_list_route)
router.add_post("/api/admin/promos", admin_promo_create_route)
router.add_patch("/api/admin/promos/{promo_id:\\d+}", admin_promo_update_route)
router.add_delete("/api/admin/promos/{promo_id:\\d+}", admin_promo_delete_route)
router.add_get("/api/admin/logs", admin_logs_route)
router.add_post("/api/admin/broadcast", admin_broadcast_route)
router.add_post("/api/admin/sync", admin_sync_route)
router.add_get("/api/admin/ads", admin_ads_list_route)
router.add_post("/api/admin/ads", admin_ad_create_route)
router.add_post("/api/admin/ads/{campaign_id:\\d+}/toggle", admin_ad_toggle_route)
router.add_delete("/api/admin/ads/{campaign_id:\\d+}", admin_ad_delete_route)
router.add_get("/api/admin/settings", admin_settings_get_route)
router.add_patch("/api/admin/settings", admin_settings_patch_route)
router.add_get("/api/admin/tariffs", admin_tariffs_get_route)
router.add_put("/api/admin/tariffs", admin_tariffs_save_route)
router.add_get("/api/admin/themes", admin_themes_get_route)
router.add_put("/api/admin/themes", admin_themes_save_route)
router.add_post("/api/admin/appearance/logo", admin_appearance_logo_upload_route)
router.add_post("/api/admin/appearance/favicon", admin_appearance_favicon_upload_route)
router.add_get("/api/admin/panel/internal-squads", admin_panel_internal_squads_route)
@@ -1,90 +0,0 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
async def admin_settings_get_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
settings: Settings = request.app["settings"]
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
overrides = await app_settings_dal.get_overrides_with_meta(session)
overrides_by_key = {entry["key"]: entry for entry in overrides}
fields = manifest_payload()
sections: Dict[str, Dict[str, Any]] = {}
for field in fields:
key = field["key"]
section_id = field["section"]
if section_id not in sections:
sections[section_id] = {
"id": section_id,
"order": field["section_order"],
"fields": [],
}
override = overrides_by_key.get(key)
value = current_value(settings, key)
is_secret = bool(field.get("secret"))
response_field = {
**field,
"value": "" if is_secret else value,
"overridden": bool(override),
"updated_at": override.get("updated_at") if override else None,
}
if is_secret:
response_field["has_value"] = bool(value)
sections[section_id]["fields"].append(response_field)
ordered_sections = sorted(sections.values(), key=lambda s: s["order"])
return _ok({"sections": ordered_sections})
async def admin_settings_patch_route(request: web.Request) -> web.Response:
actor_id = _require_admin_user_id(request)
settings: Settings = request.app["settings"]
async_session_factory: sessionmaker = request.app["async_session_factory"]
payload = await _read_json(request)
updates = payload.get("updates") or {}
deletes = payload.get("deletes") or []
if not isinstance(updates, dict):
return _error(400, "invalid_updates")
if not isinstance(deletes, list):
return _error(400, "invalid_deletes")
result = await update_overrides(
settings,
async_session_factory,
updates=updates,
deletes=deletes,
actor_id=actor_id,
)
if not result.get("ok"):
return web.json_response(
{"ok": False, "error": "validation_failed", "errors": result.get("errors", {})},
status=400,
)
# Bust the public webapp settings cache so users see new values immediately.
cache = request.app.get("webapp_settings_cache")
if isinstance(cache, dict):
cache["ts"] = 0.0
cache["data"] = {}
if (
"WEBAPP_LOGO_URL" in updates
or "WEBAPP_LOGO_URL" in deletes
or "WEBAPP_LOGO_USE_EMOJI" in updates
or "WEBAPP_LOGO_USE_EMOJI" in deletes
or "WEBAPP_FAVICON_URL" in updates
or "WEBAPP_FAVICON_URL" in deletes
or "WEBAPP_FAVICON_USE_CUSTOM" in updates
or "WEBAPP_FAVICON_USE_CUSTOM" in deletes
or "WEBAPP_LOGO_FAVICON_URL" in updates
or "WEBAPP_LOGO_FAVICON_URL" in deletes
):
request.app["webapp_logo_cache"] = None
from bot.app.web.admin_api_impl.themes import prune_unused_appearance_assets
prune_unused_appearance_assets(settings)
return _ok({"applied": result.get("applied", 0), "reverted": result.get("reverted", 0)})
@@ -1,89 +0,0 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
async def admin_me_route(request: web.Request) -> web.Response:
user_id = _require_admin_user_id(request)
settings: Settings = request.app["settings"]
return _ok({}, user_id=user_id, admin_ids=list(settings.ADMIN_IDS or []))
async def admin_stats_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
settings: Settings = request.app["settings"]
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
user_stats = await user_dal.get_enhanced_user_statistics(session)
financial_stats = await payment_dal.get_financial_statistics(session)
sync_status = await panel_sync_dal.get_panel_sync_status(session)
recent_payments = await payment_dal.get_recent_payment_logs_with_user(session, limit=10)
payload = {
"users": user_stats,
"financial": financial_stats,
"panel_sync": {
"status": sync_status.status if sync_status else "never_run",
"last_sync_time": sync_status.last_sync_time.isoformat()
if sync_status and sync_status.last_sync_time
else None,
"details": sync_status.details if sync_status else None,
"users_processed": sync_status.users_processed_from_panel if sync_status else 0,
"subscriptions_synced": sync_status.subscriptions_synced if sync_status else 0,
},
"recent_payments": [_serialize_payment(p) for p in recent_payments],
}
panel_service = request.app.get("panel_service")
if panel_service is not None:
try:
system = await panel_service.get_system_stats()
bandwidth = await panel_service.get_bandwidth_stats()
panel_body: Dict[str, Any] = {
"system": system or {},
"bandwidth": bandwidth or {},
}
try:
nodes = await panel_service.get_nodes_statistics()
panel_body["nodes"] = nodes or {}
except Exception as exc_nodes: # pragma: no cover - optional endpoint
logger.debug("Panel nodes stats unavailable: %s", exc_nodes)
panel_body["nodes"] = {}
try:
today = datetime.now(timezone.utc).date()
start_d = today - timedelta(days=7)
nodes_bw = await panel_service.get_nodes_bandwidth_usage(
start=start_d.isoformat(),
end=today.isoformat(),
top_nodes_limit=64,
)
panel_body["nodes_bandwidth"] = nodes_bw or {}
except Exception as exc_nb: # pragma: no cover - optional endpoint
logger.debug("Panel nodes bandwidth range unavailable: %s", exc_nb)
panel_body["nodes_bandwidth"] = {}
try:
online_map = _panel_nodes_online_by_uuid(panel_body.get("nodes"))
lookups = await panel_service.get_nodes_online_lookups()
for k, v in lookups.get("byUuid", {}).items():
online_map[k] = v
_enrich_bandwidth_nodes_with_online(
panel_body.get("nodes_bandwidth"),
online_map,
lookups.get("byName") or {},
)
except Exception as exc_merge: # pragma: no cover
logger.debug("Panel nodes online merge skipped: %s", exc_merge)
payload["panel"] = panel_body
except Exception as exc:
logger.debug("Panel stats unavailable: %s", exc)
payload["panel"] = {"error": "unavailable"}
queue_manager = get_queue_manager()
if queue_manager:
try:
payload["queue"] = queue_manager.get_queue_stats()
except Exception: # pragma: no cover - defensive
payload["queue"] = None
payload["currency_symbol"] = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
return _ok(payload)
@@ -1,16 +0,0 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
async def admin_sync_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
settings: Settings = request.app["settings"]
queued = await enqueue_webhook_event(
settings,
"panel_sync",
{"requested_by": _require_admin_user_id(request)},
event_id=None,
)
if queued:
return _ok({"result": {"status": "queued"}})
return _error(503, "queue_unavailable")
@@ -1,63 +0,0 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
async def admin_tariffs_get_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
settings: Settings = request.app["settings"]
path = _tariffs_config_path(settings)
try:
config = settings.tariffs_config
except Exception as exc:
logger.warning("Invalid tariffs config requested from admin UI: %s", exc)
return _error(400, "invalid_tariffs_config", str(exc))
if config is None:
return _ok(
{
"exists": path.exists(),
"path": str(path),
"catalog": {
"default_tariff": "",
"topup_packages_default": {"rub": [], "stars": []},
"tariffs": [],
},
}
)
return _ok(
{
"exists": True,
"path": str(path),
"catalog": _tariffs_config_payload(config),
}
)
async def admin_tariffs_save_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
settings: Settings = request.app["settings"]
payload = await _read_json(request)
catalog = payload.get("catalog") if "catalog" in payload else payload
if not isinstance(catalog, dict):
return _error(400, "invalid_payload", "catalog must be an object")
try:
config = TariffsConfig.model_validate(catalog)
except (ValidationError, ValueError) as exc:
return _error(400, "invalid_tariffs_config", str(exc))
path = _tariffs_config_path(settings)
try:
_write_tariffs_config_file(path, config)
except OSError as exc:
logger.exception("Failed to write tariffs config to %s", path)
return _error(500, "write_failed", str(exc))
cache = request.app.get("webapp_settings_cache")
if isinstance(cache, dict):
cache["ts"] = 0.0
cache["data"] = {}
return _ok({"exists": True, "path": str(path), "catalog": _tariffs_config_payload(config)})
@@ -1,452 +0,0 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
import asyncio
import hashlib
import ipaddress
import shutil
import re
import socket
from aiohttp import ClientSession, ClientTimeout
from PIL import Image, ImageOps, UnidentifiedImageError
from config.webapp_themes_config import (
WebappThemesConfig,
ensure_webapp_core_themes,
resolved_webapp_themes_catalog,
write_webapp_theme_dir,
)
WEBAPP_LOGO_MAX_BYTES = 2 * 1024 * 1024
WEBAPP_UPLOADED_LOGO_DIR = Path(__file__).resolve().parents[5] / "data" / "webapp-logo" / "uploads"
WEBAPP_UPLOADED_LOGO_PATH = "/webapp-uploaded-logo"
WEBAPP_FAVICON_DIR = Path(__file__).resolve().parents[5] / "data" / "webapp-logo" / "favicons"
WEBAPP_FAVICON_PATH = "/webapp-favicon"
WEBAPP_EMOJI_CACHE_DIR = Path(__file__).resolve().parents[5] / "data" / "webapp-emoji"
WEBAPP_FAVICON_SIZES = (16, 32, 48, 180, 192, 512)
WEBAPP_LOGO_UPLOAD_CONTENT_TYPES = {
".gif": "image/gif",
".ico": "image/x-icon",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".svg": "image/svg+xml",
".webp": "image/webp",
}
def _detect_logo_extension(
body: bytes, content_type: str = "", filename: str = ""
) -> Optional[str]:
content_type = (content_type or "").split(";", 1)[0].strip().lower()
suffix = Path(filename or "").suffix.lower()
if content_type == "image/png" or body.startswith(b"\x89PNG\r\n\x1a\n"):
return ".png"
if content_type == "image/jpeg" or body.startswith(b"\xff\xd8\xff"):
return ".jpg"
if content_type == "image/gif" or body.startswith((b"GIF87a", b"GIF89a")):
return ".gif"
if content_type == "image/webp" or (
len(body) > 12 and body[:4] == b"RIFF" and body[8:12] == b"WEBP"
):
return ".webp"
if content_type in {"image/svg+xml", "image/svg"} or suffix == ".svg":
head = body[:512].lstrip().lower()
if head.startswith(b"<svg") or b"<svg" in head:
return ".svg"
if content_type == "image/x-icon" or suffix == ".ico":
if body.startswith(b"\x00\x00\x01\x00"):
return ".ico"
return suffix if suffix in WEBAPP_LOGO_UPLOAD_CONTENT_TYPES else None
def _write_uploaded_logo(body: bytes, content_type: str = "", filename: str = "") -> str:
if not body or len(body) > WEBAPP_LOGO_MAX_BYTES:
raise ValueError("logo must be a non-empty image up to 2 MiB")
ext = _detect_logo_extension(body, content_type, filename)
if ext not in WEBAPP_LOGO_UPLOAD_CONTENT_TYPES:
raise ValueError("unsupported image type")
digest = hashlib.sha256(body).hexdigest()[:16]
safe_name = f"logo-{digest}{ext}"
WEBAPP_UPLOADED_LOGO_DIR.mkdir(parents=True, exist_ok=True)
(WEBAPP_UPLOADED_LOGO_DIR / safe_name).write_bytes(body)
return f"{WEBAPP_UPLOADED_LOGO_PATH}/{safe_name}"
def _uploaded_logo_filename(url: str) -> Optional[str]:
parsed = urlsplit(str(url or ""))
path = parsed.path if parsed.scheme or parsed.netloc else str(url or "")
prefix = f"{WEBAPP_UPLOADED_LOGO_PATH}/"
if not path.startswith(prefix):
return None
filename = path.removeprefix(prefix)
if re.fullmatch(r"logo-[0-9a-f]{16}\.(?:gif|ico|jpe?g|png|svg|webp)", filename):
return filename
return None
def _favicon_digest(url: str) -> Optional[str]:
parsed = urlsplit(str(url or ""))
path = parsed.path if parsed.scheme or parsed.netloc else str(url or "")
match = re.fullmatch(
rf"{re.escape(WEBAPP_FAVICON_PATH)}/([0-9a-f]{{16}})/(?:[A-Za-z0-9_.-]+)",
path,
)
return match.group(1) if match else None
def _emoji_to_codepoints(value: str) -> str:
return "_".join(f"{ord(char):x}" for char in str(value or "").strip())
def prune_unused_appearance_assets(settings: Settings) -> None:
keep_logos = {
filename
for filename in [
_uploaded_logo_filename(getattr(settings, "WEBAPP_LOGO_URL", "")),
]
if filename
}
keep_favicons = {
digest
for digest in [
_favicon_digest(getattr(settings, "WEBAPP_FAVICON_URL", "")),
_favicon_digest(getattr(settings, "WEBAPP_LOGO_FAVICON_URL", "")),
]
if digest
}
keep_emoji_prefixes = set()
if (
getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False)
and str(getattr(settings, "WEBAPP_LOGO_EMOJI_FONT", "") or "").strip()
== "noto-color-animated"
):
codepoints = _emoji_to_codepoints(getattr(settings, "WEBAPP_LOGO_EMOJI", ""))
if codepoints:
keep_emoji_prefixes.add(f"{codepoints}.512.")
for path in WEBAPP_UPLOADED_LOGO_DIR.glob("logo-*"):
if path.is_file() and path.name not in keep_logos:
try:
path.unlink()
except OSError:
logger.warning("Failed to remove unused webapp logo %s", path, exc_info=True)
for path in WEBAPP_FAVICON_DIR.glob("*"):
if (
path.is_dir()
and re.fullmatch(r"[0-9a-f]{16}", path.name)
and path.name not in keep_favicons
):
try:
shutil.rmtree(path)
except OSError:
logger.warning("Failed to remove unused webapp favicon set %s", path, exc_info=True)
for path in WEBAPP_EMOJI_CACHE_DIR.glob("*.512.*"):
if path.is_file() and not any(
path.name.startswith(prefix) for prefix in keep_emoji_prefixes
):
try:
path.unlink()
except OSError:
logger.warning("Failed to remove unused webapp emoji asset %s", path, exc_info=True)
async def _persist_appearance_upload(
request: web.Request,
updates: Dict[str, Any],
actor_id: int,
) -> bool:
settings: Settings = request.app["settings"]
async_session_factory: sessionmaker = request.app["async_session_factory"]
result = await update_overrides(
settings,
async_session_factory,
updates=updates,
deletes=[],
actor_id=actor_id,
)
if not result.get("ok"):
logger.warning("Failed to persist uploaded appearance asset settings: %s", result)
return False
cache = request.app.get("webapp_settings_cache")
if isinstance(cache, dict):
cache["ts"] = 0.0
cache["data"] = {}
request.app["webapp_logo_cache"] = None
prune_unused_appearance_assets(settings)
return True
def _image_to_square_icon(source: Image.Image, size: int) -> Image.Image:
fitted = source.copy()
fitted.thumbnail((size, size), Image.Resampling.LANCZOS)
canvas = Image.new("RGBA", (size, size), (0, 0, 0, 0))
left = (size - fitted.width) // 2
top = (size - fitted.height) // 2
canvas.alpha_composite(fitted, (left, top))
return canvas
def _write_favicon_set(body: bytes, content_type: str = "", filename: str = "") -> Dict[str, Any]:
if not body or len(body) > WEBAPP_LOGO_MAX_BYTES:
raise ValueError("favicon source must be a non-empty image up to 2 MiB")
ext = _detect_logo_extension(body, content_type, filename)
digest = hashlib.sha256(body).hexdigest()[:16]
target_dir = WEBAPP_FAVICON_DIR / digest
target_dir.mkdir(parents=True, exist_ok=True)
if ext == ".svg":
safe_name = "favicon.svg"
(target_dir / safe_name).write_bytes(body)
return {
"favicon_url": f"{WEBAPP_FAVICON_PATH}/{digest}/{safe_name}",
"variants": {"svg": f"{WEBAPP_FAVICON_PATH}/{digest}/{safe_name}"},
}
try:
with Image.open(io.BytesIO(body)) as image:
image.seek(0)
source = ImageOps.exif_transpose(image).convert("RGBA")
except (OSError, UnidentifiedImageError, ValueError) as exc:
raise ValueError("favicon source must be a raster image") from exc
if source.width < 1 or source.height < 1 or source.width > 8192 or source.height > 8192:
raise ValueError("favicon source dimensions are not supported")
variants: Dict[str, str] = {}
png_icons: Dict[int, Image.Image] = {}
for size in WEBAPP_FAVICON_SIZES:
icon = _image_to_square_icon(source, size)
png_icons[size] = icon
filename = f"icon-{size}.png"
icon.save(target_dir / filename, format="PNG", optimize=True)
variants[f"{size}"] = f"{WEBAPP_FAVICON_PATH}/{digest}/{filename}"
png_icons[180].save(target_dir / "apple-touch-icon.png", format="PNG", optimize=True)
variants["apple_touch"] = f"{WEBAPP_FAVICON_PATH}/{digest}/apple-touch-icon.png"
png_icons[32].save(
target_dir / "favicon.ico",
format="ICO",
sizes=[(16, 16), (32, 32), (48, 48)],
)
variants["ico"] = f"{WEBAPP_FAVICON_PATH}/{digest}/favicon.ico"
return {
"favicon_url": variants["180"],
"variants": variants,
}
async def _read_uploaded_logo_file(request: web.Request) -> tuple[bytes, str, str]:
reader = await request.multipart()
async for part in reader:
if part.name != "file":
continue
body = bytearray()
while True:
chunk = await part.read_chunk(size=64 * 1024)
if not chunk:
break
body.extend(chunk)
if len(body) > WEBAPP_LOGO_MAX_BYTES:
raise ValueError("logo must be up to 2 MiB")
return bytes(body), part.headers.get("Content-Type", ""), part.filename or ""
raise ValueError("file field is required")
async def _hostname_resolves_to_public_address(hostname: str) -> bool:
if not hostname:
return False
try:
ip_obj = ipaddress.ip_address(hostname)
return not (
ip_obj.is_private
or ip_obj.is_loopback
or ip_obj.is_link_local
or ip_obj.is_unspecified
or ip_obj.is_reserved
)
except ValueError:
pass
loop = asyncio.get_running_loop()
try:
resolved = await loop.getaddrinfo(hostname, None, type=socket.SOCK_STREAM)
except Exception:
return False
found_public_ip = False
for entry in resolved:
sockaddr = entry[4]
candidate = sockaddr[0] if sockaddr else ""
try:
ip_obj = ipaddress.ip_address(candidate)
except ValueError:
continue
if (
ip_obj.is_private
or ip_obj.is_loopback
or ip_obj.is_link_local
or ip_obj.is_unspecified
or ip_obj.is_reserved
):
return False
found_public_ip = True
return found_public_ip
async def _fetch_logo_from_url(url: str) -> tuple[bytes, str, str]:
parsed = urlsplit(url)
if parsed.scheme != "https" or not parsed.hostname:
raise ValueError("only https image URLs are supported")
if not await _hostname_resolves_to_public_address(parsed.hostname):
raise ValueError("logo URL must resolve to a public address")
timeout = ClientTimeout(total=5)
async with ClientSession(timeout=timeout, headers={"User-Agent": "Mozilla/5.0"}) as session:
async with session.get(
url,
allow_redirects=False,
headers={"Accept": "image/avif,image/webp,image/svg+xml,image/png,image/*,*/*;q=0.8"},
) as response:
if response.status != 200:
raise ValueError(f"logo URL returned HTTP {response.status}")
content_type = (
(response.headers.get("Content-Type") or "").split(";", 1)[0].strip().lower()
)
if content_type and not content_type.startswith("image/"):
raise ValueError("logo URL returned non-image content")
body = bytearray()
async for chunk in response.content.iter_chunked(64 * 1024):
body.extend(chunk)
if len(body) > WEBAPP_LOGO_MAX_BYTES:
raise ValueError("logo must be up to 2 MiB")
return bytes(body), content_type, Path(parsed.path).name
async def admin_appearance_logo_upload_route(request: web.Request) -> web.Response:
actor_id = _require_admin_user_id(request)
content_type = (request.headers.get("Content-Type") or "").lower()
try:
if content_type.startswith("multipart/form-data"):
body, detected_content_type, filename = await _read_uploaded_logo_file(request)
else:
payload = await _read_json(request)
source_url = str(payload.get("url") or "").strip()
if not source_url:
return _error(400, "invalid_payload", "url or file is required")
body, detected_content_type, filename = await _fetch_logo_from_url(source_url)
logo_url = _write_uploaded_logo(body, detected_content_type, filename)
try:
favicon_payload = _write_favicon_set(body, detected_content_type, filename)
except ValueError:
favicon_payload = {}
except ValueError as exc:
return _error(400, "invalid_logo", str(exc))
except OSError as exc:
logger.exception("Failed to save uploaded webapp logo")
return _error(500, "write_failed", str(exc))
persisted = await _persist_appearance_upload(
request,
{
"WEBAPP_LOGO_URL": logo_url,
"WEBAPP_LOGO_USE_EMOJI": False,
**(
{"WEBAPP_LOGO_FAVICON_URL": favicon_payload["favicon_url"]}
if favicon_payload.get("favicon_url")
else {}
),
},
actor_id,
)
return _ok({"logo_url": logo_url, "persisted": persisted, **favicon_payload})
async def admin_appearance_favicon_upload_route(request: web.Request) -> web.Response:
actor_id = _require_admin_user_id(request)
content_type = (request.headers.get("Content-Type") or "").lower()
try:
if content_type.startswith("multipart/form-data"):
body, detected_content_type, filename = await _read_uploaded_logo_file(request)
else:
payload = await _read_json(request)
source_url = str(payload.get("url") or "").strip()
if not source_url:
return _error(400, "invalid_payload", "url or file is required")
body, detected_content_type, filename = await _fetch_logo_from_url(source_url)
favicon_payload = _write_favicon_set(body, detected_content_type, filename)
except ValueError as exc:
return _error(400, "invalid_favicon", str(exc))
except OSError as exc:
logger.exception("Failed to save uploaded webapp favicon")
return _error(500, "write_failed", str(exc))
persisted = await _persist_appearance_upload(
request,
{
"WEBAPP_FAVICON_URL": favicon_payload["favicon_url"],
"WEBAPP_FAVICON_USE_CUSTOM": True,
},
actor_id,
)
return _ok({"persisted": persisted, **favicon_payload})
async def admin_themes_get_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
settings: Settings = request.app["settings"]
primary = settings.WEBAPP_PRIMARY_COLOR or "#00fe7a"
catalog = resolved_webapp_themes_catalog(
primary_accent=primary,
env_default_theme=settings.WEBAPP_DEFAULT_THEME,
theme_dir=settings.WEBAPP_THEMES_DIR,
)
return _ok(
{
"exists": Path(settings.WEBAPP_THEMES_DIR).expanduser().exists(),
"themes_dir": str(Path(settings.WEBAPP_THEMES_DIR).expanduser()),
"catalog": _webapp_themes_catalog_payload(catalog),
}
)
async def admin_themes_save_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
settings: Settings = request.app["settings"]
payload = await _read_json(request)
catalog = payload.get("catalog") if "catalog" in payload else payload
if not isinstance(catalog, dict):
return _error(400, "invalid_payload", "catalog must be an object")
try:
config = WebappThemesConfig.model_validate(catalog)
except (ValidationError, ValueError) as exc:
return _error(400, "invalid_webapp_themes_config", str(exc))
config, _changed = ensure_webapp_core_themes(config, settings.WEBAPP_PRIMARY_COLOR or "#00fe7a")
try:
write_webapp_theme_dir(settings.WEBAPP_THEMES_DIR, config, delete_missing=True)
except OSError as exc:
logger.exception("Failed to write webapp themes to %s", settings.WEBAPP_THEMES_DIR)
return _error(500, "write_failed", str(exc))
cache = request.app.get("webapp_settings_cache")
if isinstance(cache, dict):
cache["ts"] = 0.0
cache["data"] = {}
return _ok(
{
"exists": True,
"themes_dir": str(Path(settings.WEBAPP_THEMES_DIR).expanduser()),
"catalog": _webapp_themes_catalog_payload(config),
}
)
-925
View File
@@ -1,925 +0,0 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
async def admin_users_list_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
async_session_factory: sessionmaker = request.app["async_session_factory"]
page = max(0, int(request.query.get("page", 0) or 0))
page_size = min(100, max(1, int(request.query.get("page_size", 25) or 25)))
query = (request.query.get("q") or "").strip()
filter_value = (request.query.get("filter") or "all").lower()
panel_status = (request.query.get("panel_status") or "all").lower()
premium_traffic = (request.query.get("premium_traffic") or "all").lower()
sort_value = (request.query.get("sort") or "registered_desc").lower()
async with async_session_factory() as session:
users, total = await _filter_and_sort_users(
session,
query=query,
filter_value=filter_value,
panel_status=panel_status,
premium_traffic=premium_traffic,
sort_value=sort_value,
page=page,
page_size=page_size,
)
statuses = await _bulk_user_statuses(session, [u.user_id for u in users])
cached_avatar_ids = await _bulk_user_avatar_keys(session, [u.user_id for u in users])
active_subs = await _bulk_active_subscriptions_for_users(
session, [u.user_id for u in users]
)
serialized = []
for user in users:
payload = _serialize_user(user)
status_payload = statuses.get(user.user_id) or {"status": "bot_only", "end_date": None}
payload["panel_status"] = status_payload.get("status")
if status_payload.get("status") == "expired" and status_payload.get("end_date"):
payload["panel_status_expired_at"] = status_payload["end_date"]
payload["avatar_url"] = (
f"/api/admin/users/{user.user_id}/avatar?v={cached_avatar_ids[user.user_id]}"
if user.user_id in cached_avatar_ids
else None
)
payload["premium_traffic"] = _premium_traffic_list_payload(active_subs.get(user.user_id))
serialized.append(payload)
return _ok(
{
"users": serialized,
"page": page,
"page_size": page_size,
"total": total,
}
)
async def _bulk_user_statuses(
session: AsyncSession, user_ids: List[int]
) -> Dict[int, Dict[str, Optional[str]]]:
"""Return active subscription status for a batch of users.
Returns the panel status (active/expired/limited/disabled) when an active
subscription exists, otherwise ``"bot_only"`` when the user is in the bot
but has no panel subscription.
"""
if not user_ids:
return {}
stmt = (
select(
Subscription.user_id,
Subscription.status_from_panel,
Subscription.is_active,
Subscription.end_date,
)
.where(Subscription.user_id.in_(user_ids))
.order_by(Subscription.is_active.desc(), Subscription.end_date.desc().nullslast())
)
rows = (await session.execute(stmt)).all()
out: Dict[int, Dict[str, Optional[str]]] = {}
for uid, panel_status, is_active, end_date in rows:
if uid in out:
continue
if is_active:
status = (panel_status or "active").lower()
else:
status = (panel_status or "expired").lower()
out[uid] = {
"status": status,
"end_date": end_date.isoformat() if end_date else None,
}
for uid in user_ids:
if uid not in out:
out[uid] = {"status": "bot_only", "end_date": None}
return out
async def _bulk_user_avatar_keys(session: AsyncSession, user_ids: List[int]) -> Dict[int, str]:
"""Return ``{user_id: cache_key}`` for users with a cached Telegram avatar.
The cache key is the row's ``updated_at`` timestamp — used as a
cache-buster query param so the browser refetches when the avatar
changes.
"""
if not user_ids:
return {}
stmt = select(UserTelegramAvatar.user_id, UserTelegramAvatar.updated_at).where(
UserTelegramAvatar.user_id.in_(user_ids)
)
rows = (await session.execute(stmt)).all()
return {int(uid): (updated_at.isoformat() if updated_at else "") for uid, updated_at in rows}
async def admin_user_avatar_route(request: web.Request) -> web.Response:
"""Serve the cached Telegram avatar for any user (admin-only).
Mirrors ``/api/account/avatar`` but takes a ``user_id`` from the URL
and uses admin auth. Only the cached blob from
``user_telegram_avatars`` is served — refreshing from Telegram is the
job of the user-facing endpoint, so the admin list never blocks on a
Telegram round-trip.
"""
_require_admin_user_id(request)
target_id = int(request.match_info["user_id"])
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
avatar = await session.get(UserTelegramAvatar, target_id)
if not avatar:
raise web.HTTPNotFound(text="avatar_not_cached")
etag = (
f'W/"avatar-{target_id}-{int(avatar.updated_at.timestamp())}"'
if avatar.updated_at
else None
)
if etag and request.headers.get("If-None-Match") == etag:
return web.Response(status=304, headers={"ETag": etag})
response = web.Response(
body=bytes(avatar.image_bytes),
content_type=avatar.content_type or "image/jpeg",
)
response.headers["Cache-Control"] = "private, max-age=3600"
if etag:
response.headers["ETag"] = etag
return response
def _ranked_active_subscriptions_sq(now: datetime):
"""Latest active subscription per user (same ordering as subscription_dal)."""
rn = sa_func.row_number().over(
partition_by=Subscription.user_id,
order_by=(
Subscription.end_date.desc(),
Subscription.subscription_id.desc(),
),
)
inner = (
select(
Subscription.user_id,
Subscription.subscription_id,
Subscription.premium_used_bytes,
Subscription.premium_baseline_bytes,
Subscription.premium_topup_balance_bytes,
Subscription.premium_topup_used_bytes,
Subscription.premium_bonus_bytes,
Subscription.premium_unlimited_override,
rn.label("rn"),
)
.where(
Subscription.is_active.is_(True),
Subscription.end_date > now,
)
.subquery()
)
return select(inner).where(inner.c.rn == 1).subquery(name="ranked_active_sub")
async def _bulk_active_subscriptions_for_users(
session: AsyncSession, user_ids: List[int]
) -> Dict[int, Subscription]:
"""Active subscription row per user (for admin list premium traffic column)."""
if not user_ids:
return {}
now = datetime.now(timezone.utc)
stmt = (
select(Subscription)
.where(
Subscription.user_id.in_(user_ids),
Subscription.is_active.is_(True),
Subscription.end_date > now,
)
.order_by(
Subscription.user_id.asc(),
Subscription.end_date.desc(),
Subscription.subscription_id.desc(),
)
)
rows = (await session.execute(stmt)).scalars().all()
out: Dict[int, Subscription] = {}
for sub in rows:
uid = int(sub.user_id)
if uid not in out:
out[uid] = sub
return out
async def _filter_and_sort_users(
session: AsyncSession,
*,
query: str = "",
filter_value: str,
panel_status: str = "all",
premium_traffic: str = "all",
sort_value: str,
page: int,
page_size: int,
) -> tuple[List[User], int]:
"""Return paginated users with optional search, filter and sort applied."""
now = datetime.now(timezone.utc)
sort_key = (sort_value or "registered_desc").lower()
pt_filter = (premium_traffic or "all").lower()
needs_premium_sq = pt_filter != "all" or sort_key in {
"premium_ratio_asc",
"premium_ratio_desc",
}
stmt = select(User)
count_stmt = select(sa_func.count(User.user_id))
sq = None
ratio_expr = None
plim_expr = None
pu_expr = None
if needs_premium_sq:
sq = _ranked_active_subscriptions_sq(now)
stmt = stmt.outerjoin(sq, User.user_id == sq.c.user_id)
count_stmt = count_stmt.outerjoin(sq, User.user_id == sq.c.user_id)
pb = sa_func.coalesce(sq.c.premium_bonus_bytes, 0)
plim_expr = (
sa_func.coalesce(sq.c.premium_baseline_bytes, 0)
+ sa_func.coalesce(sq.c.premium_topup_balance_bytes, 0)
+ sa_func.coalesce(sq.c.premium_topup_used_bytes, 0)
+ pb
)
pu_expr = sa_func.coalesce(sq.c.premium_used_bytes, 0)
ratio_expr = case(
(sq.c.user_id.is_(None), None),
(sq.c.premium_unlimited_override.is_(True), None),
(plim_expr <= 0, None),
else_=cast(pu_expr, Float) / cast(plim_expr, Float),
)
search_cond = _user_search_condition(query)
if search_cond is not None:
stmt = stmt.where(search_cond)
count_stmt = count_stmt.where(search_cond)
f = (filter_value or "all").lower()
if f == "banned":
cond = User.is_banned.is_(True)
elif f == "active":
cond = User.is_banned.is_(False)
elif f == "tg_linked":
cond = User.telegram_id.is_not(None)
elif f == "no_tg":
cond = User.telegram_id.is_(None)
elif f == "email_linked":
cond = User.email.is_not(None)
elif f == "no_email":
cond = User.email.is_(None)
elif f == "panel_linked":
cond = User.panel_user_uuid.is_not(None)
else:
cond = None
if cond is not None:
stmt = stmt.where(cond)
count_stmt = count_stmt.where(cond)
panel_cond = _user_panel_status_condition(panel_status)
if panel_cond is not None:
stmt = stmt.where(panel_cond)
count_stmt = count_stmt.where(panel_cond)
if needs_premium_sq and sq is not None and plim_expr is not None and pu_expr is not None:
if pt_filter == "none":
premium_cond = or_(
sq.c.user_id.is_(None),
and_(
sq.c.premium_unlimited_override.is_(False),
plim_expr <= 0,
),
)
stmt = stmt.where(premium_cond)
count_stmt = count_stmt.where(premium_cond)
elif pt_filter == "unlimited":
premium_cond = and_(
sq.c.user_id.isnot(None),
sq.c.premium_unlimited_override.is_(True),
)
stmt = stmt.where(premium_cond)
count_stmt = count_stmt.where(premium_cond)
elif pt_filter == "good":
premium_cond = and_(
sq.c.user_id.isnot(None),
sq.c.premium_unlimited_override.is_(False),
plim_expr > 0,
(100 * pu_expr) < (85 * plim_expr),
)
stmt = stmt.where(premium_cond)
count_stmt = count_stmt.where(premium_cond)
elif pt_filter == "warn":
premium_cond = and_(
sq.c.user_id.isnot(None),
sq.c.premium_unlimited_override.is_(False),
plim_expr > 0,
(100 * pu_expr) >= (85 * plim_expr),
pu_expr < plim_expr,
)
stmt = stmt.where(premium_cond)
count_stmt = count_stmt.where(premium_cond)
elif pt_filter == "critical":
premium_cond = and_(
sq.c.user_id.isnot(None),
sq.c.premium_unlimited_override.is_(False),
plim_expr > 0,
pu_expr >= plim_expr,
)
stmt = stmt.where(premium_cond)
count_stmt = count_stmt.where(premium_cond)
sort_map = {
"registered_desc": User.registration_date.desc().nullslast(),
"registered_asc": User.registration_date.asc().nullslast(),
"name_asc": (
sa_func.coalesce(User.first_name, User.username, User.email).asc(),
User.user_id.asc(),
),
"name_desc": (
sa_func.coalesce(User.first_name, User.username, User.email).desc(),
User.user_id.desc(),
),
"id_asc": User.user_id.asc(),
"id_desc": User.user_id.desc(),
}
if needs_premium_sq and ratio_expr is not None and sort_key == "premium_ratio_asc":
stmt = stmt.order_by(ratio_expr.asc().nullslast(), User.user_id.asc())
elif needs_premium_sq and ratio_expr is not None and sort_key == "premium_ratio_desc":
stmt = stmt.order_by(ratio_expr.desc().nullslast(), User.user_id.desc())
else:
order = sort_map.get(sort_key, sort_map["registered_desc"])
if isinstance(order, tuple):
stmt = stmt.order_by(*order)
else:
stmt = stmt.order_by(order)
stmt = stmt.offset(max(page, 0) * max(page_size, 1)).limit(max(page_size, 1))
users = (await session.execute(stmt)).scalars().all()
total = (await session.execute(count_stmt)).scalar_one()
return users, int(total)
def _user_panel_status_condition(panel_status: str):
status = (panel_status or "all").lower()
if status not in {"active", "expired", "limited"}:
return None
normalized_status = sa_func.lower(sa_func.coalesce(Subscription.status_from_panel, ""))
blank_status = or_(
Subscription.status_from_panel.is_(None), Subscription.status_from_panel == ""
)
if status == "active":
status_cond = or_(
normalized_status == "active", blank_status & Subscription.is_active.is_(True)
)
elif status == "expired":
status_cond = or_(
normalized_status == "expired", blank_status & Subscription.is_active.is_(False)
)
else:
status_cond = normalized_status == "limited"
return (
select(Subscription.subscription_id)
.where(Subscription.user_id == User.user_id, status_cond)
.exists()
)
def _user_search_condition(query: str):
raw = (query or "").strip().lstrip("@")
if not raw:
return None
like = f"%{raw}%"
conditions = [
User.username.ilike(like),
User.first_name.ilike(like),
User.last_name.ilike(like),
User.email.ilike(like),
]
if raw.isdigit():
numeric = int(raw)
conditions.extend([User.user_id == numeric, User.telegram_id == numeric])
return or_(*conditions)
async def admin_user_detail_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
target_id = int(request.match_info["user_id"])
async_session_factory: sessionmaker = request.app["async_session_factory"]
settings: Settings = request.app["settings"]
async with async_session_factory() as session:
user = await user_dal.get_user_by_id(session, target_id)
if not user:
return _error(404, "not_found", "User not found")
active_sub = await subscription_dal.get_active_subscription_by_user_id(session, target_id)
latest_subs_stmt = (
select(Subscription)
.where(Subscription.user_id == target_id)
.order_by(Subscription.start_date.desc().nullslast())
.limit(20)
)
latest_subs = (await session.execute(latest_subs_stmt)).scalars().all()
total_paid = await payment_dal.get_user_total_paid(session, target_id)
recent_payments_stmt = (
select(Payment)
.where(Payment.user_id == target_id)
.order_by(Payment.created_at.desc())
.limit(20)
)
recent_payments = (await session.execute(recent_payments_stmt)).scalars().all()
log_count = await message_log_dal.count_user_message_logs(session, target_id)
avatar_keys = await _bulk_user_avatar_keys(session, [target_id])
# Referral links — both the bot deep-link and the webapp deep-link.
referral_code: Optional[str] = None
try:
referral_code = await user_dal.ensure_referral_code(session, user)
await session.commit()
except Exception as exc_ref: # pragma: no cover — defensive
logger.warning("Failed to ensure referral code for user %s: %s", target_id, exc_ref)
await session.rollback()
referral_service: Optional[ReferralService] = request.app.get("referral_service")
bot_username = request.app.get("bot_username") or ""
referral_bot_link: Optional[str] = None
if referral_service and bot_username and referral_code:
try:
async with async_session_factory() as session:
referral_bot_link = await referral_service.generate_referral_link(
session, bot_username, target_id
)
except Exception as exc_link: # pragma: no cover
logger.warning("Failed to build bot referral link for %s: %s", target_id, exc_link)
referral_webapp_link = _build_admin_webapp_referral_link(
getattr(settings, "SUBSCRIPTION_MINI_APP_URL", None),
referral_code,
)
# Subscription page URL — the raw panel `subscriptionUrl` that the user
# imports into their VPN client. May be missing if the user has never
# been provisioned on the panel.
subscription_url: Optional[str] = None
panel_uuid = getattr(user, "panel_user_uuid", None)
if panel_uuid:
subscription_service = request.app.get("subscription_service")
panel_service = getattr(subscription_service, "panel_service", None)
if panel_service is not None:
try:
panel_data = await panel_service.get_user_by_uuid(panel_uuid)
if panel_data:
subscription_url = panel_data.get("subscriptionUrl") or None
except Exception as exc_panel: # pragma: no cover
logger.warning(
"Failed to fetch subscriptionUrl for user %s (uuid=%s): %s",
target_id,
panel_uuid,
exc_panel,
)
serialized_user = _serialize_user(user)
serialized_user["avatar_url"] = (
f"/api/admin/users/{target_id}/avatar?v={avatar_keys[target_id]}"
if target_id in avatar_keys
else None
)
return _ok(
{
"user": serialized_user,
"active_subscription": _serialize_subscription(active_sub) if active_sub else None,
"subscriptions": [_serialize_subscription(s) for s in (latest_subs or [])],
"total_paid": float(total_paid),
"recent_payments": [_serialize_payment(p) for p in recent_payments],
"log_count": int(log_count or 0),
"subscription_url": subscription_url,
"referral": {
"code": referral_code,
"bot_link": referral_bot_link,
"webapp_link": referral_webapp_link,
},
}
)
async def admin_user_ban_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
target_id = int(request.match_info["user_id"])
payload = await _read_json(request)
desired = bool(payload.get("banned"))
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
user = await user_dal.get_user_by_id(session, target_id)
if not user:
return _error(404, "not_found")
user.is_banned = bool(desired)
await session.commit()
await session.refresh(user)
return _ok({"user": _serialize_user(user)})
async def admin_user_message_route(request: web.Request) -> web.Response:
actor_id = _require_admin_user_id(request)
target_id = int(request.match_info["user_id"])
payload = await _read_json(request)
text = str(payload.get("text") or "").strip()
if not text:
return _error(400, "empty_text")
queue_manager = get_queue_manager()
if not queue_manager:
return _error(503, "queue_unavailable")
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
target_user = await user_dal.get_user_by_id(session, target_id)
if not target_user or not target_user.telegram_id:
return _error(404, "no_telegram_account")
try:
await send_message_via_queue(
queue_manager,
int(target_user.telegram_id),
MessageContent(content_type="text", text=text),
parse_mode="HTML",
disable_web_page_preview=True,
)
except Exception as exc:
logger.warning("Admin direct message failed: %s", exc)
return _error(502, "send_failed", str(exc))
await message_log_dal.create_message_log(
session,
{
"user_id": actor_id,
"event_type": "admin_direct_message_webapp",
"content": text[:4000],
"is_admin_event": True,
"target_user_id": target_id,
},
)
return _ok({})
async def admin_user_message_preview_route(request: web.Request) -> web.Response:
actor_id = _require_admin_user_id(request)
admin_telegram_id = request.get("admin_telegram_id")
target_id = int(request.match_info["user_id"])
payload = await _read_json(request)
text = str(payload.get("text") or "").strip()
if not text:
return _error(400, "empty_text")
if not admin_telegram_id:
return _error(403, "admin_telegram_unavailable")
queue_manager = get_queue_manager()
if not queue_manager:
return _error(503, "queue_unavailable")
try:
await send_message_via_queue(
queue_manager,
int(admin_telegram_id),
MessageContent(content_type="text", text=text),
parse_mode="HTML",
disable_web_page_preview=True,
)
except Exception as exc:
logger.warning("Admin direct message preview failed: %s", exc)
return _error(502, "preview_failed", str(exc))
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
await message_log_dal.create_message_log(
session,
{
"user_id": actor_id,
"event_type": "admin_direct_message_preview_webapp",
"content": text[:4000],
"is_admin_event": True,
"target_user_id": target_id,
},
)
return _ok({})
async def admin_user_delete_route(request: web.Request) -> web.Response:
actor_id = _require_admin_user_id(request)
target_id = int(request.match_info["user_id"])
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
ok = await user_dal.delete_user_and_relations(session, target_id)
if not ok:
await session.rollback()
return _error(404, "not_found")
await message_log_dal.create_message_log(
session,
{
"user_id": actor_id,
"event_type": "admin_delete_user_webapp",
"content": f"Deleted user_id={target_id}",
"is_admin_event": True,
},
)
await session.commit()
return _ok({})
async def admin_user_reset_trial_route(request: web.Request) -> web.Response:
actor_id = _require_admin_user_id(request)
target_id = int(request.match_info["user_id"])
panel_service = request.app.get("panel_service")
subscription_service = request.app.get("subscription_service")
if panel_service is None or subscription_service is None:
return _error(503, "service_unavailable")
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
user = await user_dal.get_user_by_id(session, target_id)
if not user:
return _error(404, "not_found")
active = await subscription_dal.get_active_subscription_by_user_id(session, target_id)
if active:
await session.delete(active)
await message_log_dal.create_message_log(
session,
{
"user_id": actor_id,
"event_type": "admin_reset_trial_webapp",
"content": f"Reset trial for user_id={target_id}",
"is_admin_event": True,
"target_user_id": target_id,
},
)
await session.commit()
return _ok({})
async def admin_user_premium_override_route(request: web.Request) -> web.Response:
"""Premium-squad traffic overrides only (unlimited toggle + bonus GB)."""
actor_id = _require_admin_user_id(request)
target_id = int(request.match_info["user_id"])
payload = await _read_json(request)
subscription_service = request.app.get("subscription_service")
unlimited = bool(payload.get("unlimited"))
bonus_bytes_raw = payload.get("bonus_bytes")
bonus_gb_raw = payload.get("bonus_gb")
if bonus_bytes_raw is None and bonus_gb_raw is None:
bonus_bytes = 0
elif bonus_bytes_raw is not None:
try:
bonus_bytes = int(bonus_bytes_raw)
except (TypeError, ValueError):
return _error(400, "invalid_bonus", "bonus_bytes must be an integer")
else:
try:
bonus_bytes = int(round(float(bonus_gb_raw) * (1024**3)))
except (TypeError, ValueError):
return _error(400, "invalid_bonus", "bonus_gb must be a number")
if bonus_bytes < 0:
return _error(400, "invalid_bonus", "bonus must be non-negative")
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
active = await subscription_dal.get_active_subscription_by_user_id(session, target_id)
if not active:
return _error(404, "no_active_subscription")
active.premium_unlimited_override = bool(unlimited)
active.premium_bonus_bytes = int(bonus_bytes)
if active.premium_unlimited_override:
active.premium_is_limited = False
await message_log_dal.create_message_log(
session,
{
"user_id": actor_id,
"event_type": "admin_premium_override_webapp",
"content": (f"unlimited={bool(unlimited)} bonus_bytes={int(bonus_bytes)}"),
"is_admin_event": True,
"target_user_id": target_id,
},
)
await session.commit()
await session.refresh(active)
if subscription_service is not None:
await subscription_service.sync_premium_squad_access_to_panel(session, target_id)
await session.commit()
await session.refresh(active)
return _ok({"subscription": _serialize_subscription(active)})
async def admin_user_regular_traffic_override_route(request: web.Request) -> web.Response:
"""Main (regular) traffic: unlimited-style ceiling + admin bonus GB."""
actor_id = _require_admin_user_id(request)
target_id = int(request.match_info["user_id"])
payload = await _read_json(request)
unlimited = bool(payload.get("unlimited"))
regular_bonus_bytes_raw = payload.get("regular_bonus_bytes")
regular_bonus_gb_raw = payload.get("regular_bonus_gb")
if regular_bonus_bytes_raw is None and regular_bonus_gb_raw is None:
regular_bonus_bytes = 0
elif regular_bonus_bytes_raw is not None:
try:
regular_bonus_bytes = int(regular_bonus_bytes_raw)
except (TypeError, ValueError):
return _error(400, "invalid_regular_bonus", "regular_bonus_bytes must be an integer")
else:
try:
regular_bonus_bytes = int(round(float(regular_bonus_gb_raw) * (1024**3)))
except (TypeError, ValueError):
return _error(400, "invalid_regular_bonus", "regular_bonus_gb must be a number")
if regular_bonus_bytes < 0:
return _error(400, "invalid_regular_bonus", "regular bonus must be non-negative")
subscription_service = request.app.get("subscription_service")
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
active = await subscription_dal.get_active_subscription_by_user_id(session, target_id)
if not active:
return _error(404, "no_active_subscription")
active.regular_unlimited_override = bool(unlimited)
active.regular_bonus_bytes = int(regular_bonus_bytes)
if subscription_service is not None:
await subscription_service.sync_main_traffic_limit_to_panel(session, target_id)
await message_log_dal.create_message_log(
session,
{
"user_id": actor_id,
"event_type": "admin_regular_traffic_override_webapp",
"content": (
f"unlimited={bool(unlimited)} regular_bonus_bytes={int(regular_bonus_bytes)}"
),
"is_admin_event": True,
"target_user_id": target_id,
},
)
await session.commit()
await session.refresh(active)
return _ok({"subscription": _serialize_subscription(active)})
async def admin_user_traffic_grant_route(request: web.Request) -> web.Response:
"""Credit regular or premium traffic to a user without a payment.
Body: ``{"kind": "regular" | "premium", "gb": float}`` (alternatively
``"bytes": int``). Mirrors the same effect as a user-purchased top-up:
the chosen balance grows, panel limit/squads are refreshed, and an entry
is added to ``traffic_topups`` with ``kind="admin_topup"`` or
``kind="admin_premium_topup"`` and ``payment_id=NULL``.
"""
actor_id = _require_admin_user_id(request)
target_id = int(request.match_info["user_id"])
payload = await _read_json(request)
kind = str(payload.get("kind") or "regular").strip().lower()
if kind not in {"regular", "premium"}:
return _error(400, "invalid_kind", "kind must be 'regular' or 'premium'")
bytes_raw = payload.get("bytes")
gb_raw = payload.get("gb")
if bytes_raw is None and gb_raw is None:
return _error(400, "missing_amount", "either 'gb' or 'bytes' is required")
try:
if bytes_raw is not None:
grant_bytes = int(bytes_raw)
gb_value = grant_bytes / (1024**3)
else:
gb_value = float(gb_raw)
grant_bytes = int(round(gb_value * (1024**3)))
except (TypeError, ValueError):
return _error(400, "invalid_amount", "amount must be a positive number")
if gb_value <= 0 or grant_bytes <= 0:
return _error(400, "invalid_amount", "amount must be positive")
subscription_service = request.app.get("subscription_service")
if subscription_service is None:
return _error(503, "subscription_service_unavailable")
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
active = await subscription_dal.get_active_subscription_by_user_id(session, target_id)
if not active:
return _error(404, "no_active_subscription")
if kind == "regular":
result = await subscription_service.admin_grant_topup(session, target_id, gb_value)
else:
result = await subscription_service.admin_grant_premium_topup(
session, target_id, gb_value
)
if not result:
await session.rollback()
return _error(
422,
"grant_failed",
"Unable to credit traffic (missing tariff/squads or panel error)",
)
await message_log_dal.create_message_log(
session,
{
"user_id": actor_id,
"event_type": "admin_traffic_grant_webapp",
"content": f"kind={kind} bytes={grant_bytes}",
"is_admin_event": True,
"target_user_id": target_id,
},
)
await session.commit()
refreshed = await subscription_dal.get_active_subscription_by_user_id(session, target_id)
return _ok(
{
"subscription": _serialize_subscription(refreshed) if refreshed else None,
"grant": {
"kind": kind,
"granted_bytes": grant_bytes,
"granted_gb": gb_value,
},
}
)
async def admin_user_extend_route(request: web.Request) -> web.Response:
actor_id = _require_admin_user_id(request)
target_id = int(request.match_info["user_id"])
payload = await _read_json(request)
try:
days = int(payload.get("days") or 0)
except (TypeError, ValueError):
return _error(400, "invalid_days")
if days <= 0:
return _error(400, "invalid_days")
subscription_service = request.app.get("subscription_service")
if subscription_service is None:
return _error(503, "subscription_service_unavailable")
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
new_end = await subscription_service.extend_active_subscription_days(
session,
target_id,
days,
"admin_extend_subscription_webapp",
)
if not new_end:
await session.rollback()
return _error(500, "extend_failed")
await message_log_dal.create_message_log(
session,
{
"user_id": actor_id,
"event_type": "admin_extend_subscription_webapp",
"content": f"+{days}d -> {new_end.isoformat()}",
"is_admin_event": True,
"target_user_id": target_id,
},
)
await session.commit()
refreshed = await subscription_dal.get_active_subscription_by_user_id(session, target_id)
return _ok(
{
"subscription": _serialize_subscription(refreshed) if refreshed else None,
}
)
@@ -1,536 +0,0 @@
"""Manifest of settings editable from the admin web app.
Each entry describes a single overridable attribute on the global
``Settings`` instance. The manifest is the only contract between the
admin UI and the backend: keys not listed here cannot be changed via
the API, even by an admin.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, List, Optional, Tuple
@dataclass(frozen=True)
class SettingField:
key: str
type: str # "string" | "int" | "float" | "bool" | "text" | "url" | "color" | "secret"
section: str
label: str
description: str = ""
placeholder: str = ""
optional: bool = True
secret: bool = False
min: Optional[float] = None
max: Optional[float] = None
choices: Optional[Tuple[Tuple[str, str], ...]] = None
subsection: Optional[str] = None # group label inside a section
i18n_label_key: Optional[str] = None
i18n_description_key: Optional[str] = None
SETTINGS_MANIFEST: List[SettingField] = [
# ─── General ────────────────────────────────────────────────────
SettingField(
"DEFAULT_LANGUAGE",
"string",
"general",
"Язык по умолчанию",
"Используется для приветственных сообщений и публичных страниц.",
),
SettingField(
"DEFAULT_CURRENCY_SYMBOL",
"string",
"general",
"Валюта",
"Например, RUB, USD, EUR.",
placeholder="RUB",
),
SettingField(
"SUPPORT_LINK", "url", "general", "Ссылка поддержки", "Куда вести пользователей за помощью."
),
SettingField("SERVER_STATUS_URL", "url", "general", "Ссылка на статус серверов"),
SettingField("TERMS_OF_SERVICE_URL", "url", "general", "Условия использования"),
SettingField("PRIVACY_POLICY_URL", "url", "general", "Политика конфиденциальности"),
SettingField("USER_AGREEMENT_URL", "url", "general", "Пользовательское соглашение"),
SettingField("DISABLE_WELCOME_MESSAGE", "bool", "general", "Скрыть приветствие /start"),
SettingField(
"START_COMMAND_DESCRIPTION", "string", "general", "Описание /start", placeholder=""
),
SettingField(
"REQUIRED_CHANNEL_ID",
"int",
"general",
"ID обязательного канала",
"Telegram ID канала, в котором нужно состоять.",
),
SettingField(
"REQUIRED_CHANNEL_LINK",
"string",
"general",
"Ссылка на канал",
"Имя пользователя или invite-link.",
),
# ─── Web app appearance ────────────────────────────────────────
SettingField(
"WEBAPP_TITLE", "string", "appearance", "Название Web App", placeholder="Моя подписка"
),
SettingField(
"WEBAPP_PRIMARY_COLOR", "color", "appearance", "Основной цвет", placeholder="#00fe7a"
),
SettingField("WEBAPP_LOGO_USE_EMOJI", "bool", "appearance", "Использовать эмоджи-логотип"),
SettingField("WEBAPP_LOGO_URL", "url", "appearance", "URL логотипа"),
SettingField("WEBAPP_LOGO_EMOJI", "string", "appearance", "Эмоджи-логотип", placeholder="🫥"),
SettingField(
"WEBAPP_LOGO_EMOJI_FONT",
"string",
"appearance",
"Шрифт эмоджи-логотипа",
"Выберите шрифт для отображения эмодзи-логотипа",
choices=(
("system", "Системный (по умолчанию)"),
("noto-color", "Noto Color Emoji"),
("noto-color-animated", "Noto Color Emoji Animated"),
("noto-emoji", "Noto Emoji"),
("twemoji", "Twitter Emoji"),
("openmoji", "OpenMoji"),
("apple", "Apple Color Emoji (local)"),
("segoe", "Segoe UI Emoji (local)"),
("noto-local", "Noto Emoji (local)"),
),
),
SettingField(
"WEBAPP_FAVICON_USE_CUSTOM",
"bool",
"appearance",
"Использовать отдельную favicon",
),
SettingField("WEBAPP_FAVICON_URL", "url", "appearance", "URL отдельной favicon"),
SettingField("WEBAPP_LOGO_FAVICON_URL", "url", "appearance", "Favicon из логотипа"),
SettingField("WEBAPP_ENABLED", "bool", "appearance", "Web App включён"),
# ─── Subscription periods & pricing ────────────────────────────
SettingField("MONTH_1_ENABLED", "bool", "pricing", "Тариф 1 месяц"),
SettingField("MONTH_3_ENABLED", "bool", "pricing", "Тариф 3 месяца"),
SettingField("MONTH_6_ENABLED", "bool", "pricing", "Тариф 6 месяцев"),
SettingField("MONTH_12_ENABLED", "bool", "pricing", "Тариф 12 месяцев"),
SettingField("RUB_PRICE_1_MONTH", "int", "pricing", "Цена 1 мес. (RUB)"),
SettingField("RUB_PRICE_3_MONTHS", "int", "pricing", "Цена 3 мес. (RUB)"),
SettingField("RUB_PRICE_6_MONTHS", "int", "pricing", "Цена 6 мес. (RUB)"),
SettingField("RUB_PRICE_12_MONTHS", "int", "pricing", "Цена 12 мес. (RUB)"),
SettingField("STARS_PRICE_1_MONTH", "int", "pricing", "Цена 1 мес. (Stars)"),
SettingField("STARS_PRICE_3_MONTHS", "int", "pricing", "Цена 3 мес. (Stars)"),
SettingField("STARS_PRICE_6_MONTHS", "int", "pricing", "Цена 6 мес. (Stars)"),
SettingField("STARS_PRICE_12_MONTHS", "int", "pricing", "Цена 12 мес. (Stars)"),
SettingField(
"TRAFFIC_PACKAGES", "string", "pricing", "Пакеты трафика", "Формат: 10:199,50:799 (ГБ:цена)"
),
SettingField("STARS_TRAFFIC_PACKAGES", "string", "pricing", "Пакеты трафика (Stars)"),
SettingField(
"PAYMENT_METHODS_ORDER",
"string",
"pricing",
"Порядок методов оплаты",
"Через запятую, например: severpay,freekassa,yookassa",
),
# ─── Payment providers (toggles) ───────────────────────────────
# Common
SettingField("STARS_ENABLED", "bool", "payments", "Telegram Stars", subsection="Общие"),
SettingField(
"PAYMENT_METHODS_ORDER",
"string",
"payments",
"Порядок методов оплаты",
"Через запятую: severpay,freekassa,yookassa,platega,stars,cryptopay",
subsection="Общие",
),
# YooKassa
SettingField("YOOKASSA_ENABLED", "bool", "payments", "Включена", subsection="YooKassa"),
SettingField("YOOKASSA_SHOP_ID", "string", "payments", "Shop ID", subsection="YooKassa"),
SettingField(
"YOOKASSA_SECRET_KEY",
"string",
"payments",
"Secret key",
subsection="YooKassa",
secret=True,
),
SettingField("YOOKASSA_RETURN_URL", "url", "payments", "Return URL", subsection="YooKassa"),
SettingField(
"YOOKASSA_DEFAULT_RECEIPT_EMAIL",
"string",
"payments",
"Email для чека по умолчанию",
subsection="YooKassa",
),
SettingField(
"YOOKASSA_VAT_CODE",
"int",
"payments",
"VAT code",
"1..6 в зависимости от системы налогообложения",
subsection="YooKassa",
min=1,
max=6,
),
SettingField(
"YOOKASSA_AUTOPAYMENTS_ENABLED",
"bool",
"payments",
"Автоплатежи (recurring)",
subsection="YooKassa",
),
SettingField(
"YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING",
"bool",
"payments",
"Принудительная привязка карты",
subsection="YooKassa",
),
# FreeKassa
SettingField("FREEKASSA_ENABLED", "bool", "payments", "Включена", subsection="FreeKassa"),
SettingField(
"FREEKASSA_MERCHANT_ID", "string", "payments", "Merchant ID", subsection="FreeKassa"
),
SettingField(
"FREEKASSA_FIRST_SECRET",
"string",
"payments",
"First secret",
subsection="FreeKassa",
secret=True,
),
SettingField(
"FREEKASSA_SECOND_SECRET",
"string",
"payments",
"Second secret",
"Используется для проверки подписи входящих уведомлений",
subsection="FreeKassa",
secret=True,
),
SettingField(
"FREEKASSA_API_KEY", "string", "payments", "API key", subsection="FreeKassa", secret=True
),
SettingField(
"FREEKASSA_PAYMENT_URL",
"url",
"payments",
"Payment URL",
placeholder="https://pay.freekassa.ru/",
subsection="FreeKassa",
),
SettingField(
"FREEKASSA_PAYMENT_METHOD_ID",
"int",
"payments",
"Метод оплаты по умолчанию",
subsection="FreeKassa",
),
SettingField(
"FREEKASSA_PAYMENT_IP",
"string",
"payments",
"IP сервера",
"Передаётся в подпись запроса при создании платежа",
subsection="FreeKassa",
),
SettingField(
"FREEKASSA_TRUSTED_IPS",
"string",
"payments",
"Доверенные IP",
"Через запятую — IP-адреса, с которых принимаются нотификации",
subsection="FreeKassa",
),
# Platega
SettingField("PLATEGA_ENABLED", "bool", "payments", "Включена", subsection="Platega"),
SettingField(
"PLATEGA_BASE_URL",
"url",
"payments",
"Base URL",
placeholder="https://app.platega.io",
subsection="Platega",
),
SettingField("PLATEGA_MERCHANT_ID", "string", "payments", "Merchant ID", subsection="Platega"),
SettingField(
"PLATEGA_SECRET", "string", "payments", "Secret", subsection="Platega", secret=True
),
SettingField(
"PLATEGA_PAYMENT_METHOD", "int", "payments", "Метод оплаты (legacy)", subsection="Platega"
),
SettingField("PLATEGA_SBP_ENABLED", "bool", "payments", "SBP-кнопка", subsection="Platega"),
SettingField("PLATEGA_SBP_METHOD", "int", "payments", "SBP method ID", subsection="Platega"),
SettingField(
"PLATEGA_CRYPTO_ENABLED", "bool", "payments", "Crypto-кнопка", subsection="Platega"
),
SettingField(
"PLATEGA_CRYPTO_METHOD", "int", "payments", "Crypto method ID", subsection="Platega"
),
SettingField("PLATEGA_RETURN_URL", "url", "payments", "Return URL", subsection="Platega"),
SettingField("PLATEGA_FAILED_URL", "url", "payments", "Failed URL", subsection="Platega"),
# SeverPay
SettingField("SEVERPAY_ENABLED", "bool", "payments", "Включена", subsection="SeverPay"),
SettingField("SEVERPAY_MID", "int", "payments", "MID", subsection="SeverPay"),
SettingField(
"SEVERPAY_TOKEN", "string", "payments", "Token", subsection="SeverPay", secret=True
),
SettingField(
"SEVERPAY_BASE_URL",
"url",
"payments",
"Base URL",
placeholder="https://severpay.io/api/merchant",
subsection="SeverPay",
),
SettingField("SEVERPAY_RETURN_URL", "url", "payments", "Return URL", subsection="SeverPay"),
SettingField(
"SEVERPAY_LIFETIME_MINUTES",
"int",
"payments",
"Срок жизни ссылки (мин)",
"30..4320; пусто — значение провайдера",
subsection="SeverPay",
min=30,
max=4320,
),
# CryptoPay
SettingField("CRYPTOPAY_ENABLED", "bool", "payments", "Включена", subsection="CryptoPay"),
SettingField(
"CRYPTOPAY_TOKEN", "string", "payments", "Token", subsection="CryptoPay", secret=True
),
SettingField(
"CRYPTOPAY_NETWORK",
"string",
"payments",
"Network",
"mainnet или testnet",
subsection="CryptoPay",
),
SettingField(
"CRYPTOPAY_CURRENCY_TYPE",
"string",
"payments",
"Currency type",
"fiat или crypto",
subsection="CryptoPay",
),
SettingField(
"CRYPTOPAY_ASSET", "string", "payments", "Asset", placeholder="RUB", subsection="CryptoPay"
),
# ─── Trial ─────────────────────────────────────────────────────
SettingField("TRIAL_ENABLED", "bool", "trial", "Триал включён"),
SettingField("TRIAL_DURATION_DAYS", "int", "trial", "Длительность триала (дней)", min=0),
SettingField("TRIAL_TRAFFIC_LIMIT_GB", "float", "trial", "Лимит трафика триала (ГБ)", min=0),
SettingField("TRIAL_TRAFFIC_STRATEGY", "string", "trial", "Стратегия сброса трафика триала"),
# ─── Referral program ──────────────────────────────────────────
SettingField(
"REFERRAL_ONE_BONUS_PER_REFEREE", "bool", "referral", "Один бонус на приглашённого"
),
SettingField(
"REFERRAL_WELCOME_BONUS_DAYS", "int", "referral", "Приветственный бонус (дней)", min=0
),
SettingField("LEGACY_REFS", "bool", "referral", "Поддержка старых ref-ссылок"),
SettingField(
"REFERRAL_BONUS_DAYS_INVITER_1_MONTH",
"int",
"referral",
"Бонус приглашающему: 1 мес.",
min=0,
),
SettingField(
"REFERRAL_BONUS_DAYS_INVITER_3_MONTHS",
"int",
"referral",
"Бонус приглашающему: 3 мес.",
min=0,
),
SettingField(
"REFERRAL_BONUS_DAYS_INVITER_6_MONTHS",
"int",
"referral",
"Бонус приглашающему: 6 мес.",
min=0,
),
SettingField(
"REFERRAL_BONUS_DAYS_INVITER_12_MONTHS",
"int",
"referral",
"Бонус приглашающему: 12 мес.",
min=0,
),
SettingField(
"REFERRAL_BONUS_DAYS_REFEREE_1_MONTH",
"int",
"referral",
"Бонус приглашённому: 1 мес.",
min=0,
),
SettingField(
"REFERRAL_BONUS_DAYS_REFEREE_3_MONTHS",
"int",
"referral",
"Бонус приглашённому: 3 мес.",
min=0,
),
SettingField(
"REFERRAL_BONUS_DAYS_REFEREE_6_MONTHS",
"int",
"referral",
"Бонус приглашённому: 6 мес.",
min=0,
),
SettingField(
"REFERRAL_BONUS_DAYS_REFEREE_12_MONTHS",
"int",
"referral",
"Бонус приглашённому: 12 мес.",
min=0,
),
# ─── Notifications ─────────────────────────────────────────────
SettingField(
"SUBSCRIPTION_NOTIFICATIONS_ENABLED",
"bool",
"notifications",
"Включены уведомления о подписке",
),
SettingField(
"SUBSCRIPTION_NOTIFY_ON_EXPIRE", "bool", "notifications", "Уведомлять об истечении"
),
SettingField(
"SUBSCRIPTION_NOTIFY_AFTER_EXPIRE", "bool", "notifications", "Уведомлять после истечения"
),
SettingField(
"SUBSCRIPTION_NOTIFY_DAYS_BEFORE",
"int",
"notifications",
"За сколько дней предупреждать",
min=0,
),
SettingField("LOG_NEW_USERS", "bool", "notifications", "Логировать новых пользователей"),
SettingField("LOG_PAYMENTS", "bool", "notifications", "Логировать платежи"),
SettingField(
"LOG_PROMO_ACTIVATIONS", "bool", "notifications", "Логировать активации промокодов"
),
SettingField("LOG_TRIAL_ACTIVATIONS", "bool", "notifications", "Логировать активации триала"),
SettingField(
"LOG_SUSPICIOUS_ACTIVITY", "bool", "notifications", "Логировать подозрительные действия"
),
SettingField(
"LOG_ADMIN_ACTIONS",
"bool",
"notifications",
"Логировать действия администраторов",
"Если выключено, события от пользователей из ADMIN_IDS не записываются в message logs.",
i18n_label_key="settings_field_log_admin_actions_label",
i18n_description_key="settings_field_log_admin_actions_description",
),
SettingField(
"LOG_LEVEL",
"string",
"notifications",
"Глобальный уровень логов",
"DEBUG / INFO / WARNING / ERROR",
),
SettingField("LOG_CHAT_ID", "int", "notifications", "ID чата для логов"),
SettingField("LOG_THREAD_ID", "int", "notifications", "ID треда (для супергрупп)"),
# ─── Devices ───────────────────────────────────────────────────
SettingField("MY_DEVICES_SECTION_ENABLED", "bool", "devices", "Раздел «Мои устройства»"),
SettingField(
"USER_HWID_DEVICE_LIMIT", "int", "devices", "Лимит устройств по умолчанию (0 = ∞)", min=0
),
SettingField("USER_TRAFFIC_LIMIT_GB", "float", "devices", "Лимит трафика пользователя (ГБ)"),
SettingField("USER_TRAFFIC_STRATEGY", "string", "devices", "Стратегия сброса трафика"),
]
def get_field_by_key(key: str) -> Optional[SettingField]:
for field in SETTINGS_MANIFEST:
if field.key == key:
return field
return None
def manifest_keys() -> List[str]:
return [f.key for f in SETTINGS_MANIFEST]
def coerce_value(field: SettingField, raw: Any) -> Any:
"""Coerce a value coming from JSON to the type declared by the field."""
if raw is None or (isinstance(raw, str) and raw.strip() == ""):
return None
if field.type == "bool":
if isinstance(raw, bool):
return raw
if isinstance(raw, (int, float)):
return bool(raw)
if isinstance(raw, str):
return raw.strip().lower() in {"1", "true", "yes", "on"}
return bool(raw)
if field.type == "int":
try:
value = int(str(raw).strip())
except (TypeError, ValueError) as exc:
raise ValueError(f"{field.key}: integer expected") from exc
if field.min is not None and value < field.min:
raise ValueError(f"{field.key}: must be >= {field.min:g}")
if field.max is not None and value > field.max:
raise ValueError(f"{field.key}: must be <= {field.max:g}")
return value
if field.type == "float":
try:
value = float(str(raw).strip())
except (TypeError, ValueError) as exc:
raise ValueError(f"{field.key}: number expected") from exc
if field.min is not None and value < field.min:
raise ValueError(f"{field.key}: must be >= {field.min:g}")
if field.max is not None and value > field.max:
raise ValueError(f"{field.key}: must be <= {field.max:g}")
return value
if isinstance(raw, str):
return raw.strip()
return str(raw)
def manifest_payload() -> List[dict]:
"""Serialize the manifest for the admin UI."""
sections_order = {
"general": 1,
"appearance": 2,
"pricing": 3,
"payments": 4,
"trial": 5,
"referral": 6,
"notifications": 7,
"devices": 8,
}
items: List[dict] = []
for field in SETTINGS_MANIFEST:
auto_label_i18n_key = f"settings_field_{field.key.lower()}_label"
auto_description_i18n_key = f"settings_field_{field.key.lower()}_description"
item = {
"key": field.key,
"type": field.type,
"section": field.section,
"section_order": sections_order.get(field.section, 99),
"subsection": field.subsection,
"label": field.label,
"description": field.description,
"i18n_label_key": field.i18n_label_key or auto_label_i18n_key,
"i18n_description_key": field.i18n_description_key
or (auto_description_i18n_key if field.description else None),
"placeholder": field.placeholder,
"optional": field.optional,
"secret": field.secret,
}
if field.choices:
item["choices"] = [{"value": v, "label": lbl} for v, lbl in field.choices]
items.append(item)
return items
-29
View File
@@ -1,29 +0,0 @@
from __future__ import annotations
from typing import Optional
from aiohttp import web
from bot.app.web.webapp_auth import verify_webapp_session_token
from config.settings import Settings
WEBAPP_SESSION_COOKIE_NAME = "rw_webapp_session"
def extract_authenticated_user_id(request: web.Request) -> Optional[int]:
settings: Settings = request.app["settings"]
auth_header = request.headers.get("Authorization", "")
if auth_header.startswith("Bearer "):
user_id = verify_webapp_session_token(
settings,
auth_header.removeprefix("Bearer ").strip(),
)
if user_id:
return user_id
session_cookie = request.cookies.get(WEBAPP_SESSION_COOKIE_NAME)
if session_cookie:
return verify_webapp_session_token(settings, session_cookie)
return None
@@ -1,48 +0,0 @@
"""Compatibility facade for the subscription Mini App backend."""
# ruff: noqa: I001
from bot.app.web.webapp import (
_runtime as _runtime,
account as _account,
application as _application,
assets as _assets,
auth as _auth,
billing as _billing,
common as _common,
devices as _devices,
payloads as _payloads,
routes as _routes,
serializers as _serializers,
)
_MODULES = (
_runtime,
_payloads,
_common,
_assets,
_auth,
_account,
_serializers,
_billing,
_devices,
_routes,
_application,
)
_NAMESPACE = {}
for _module in _MODULES:
_NAMESPACE.update(
{
_name: _value
for _name, _value in vars(_module).items()
if not _name.startswith("__") and _name != "annotations"
}
)
for _module in _MODULES:
vars(_module).update(_NAMESPACE)
globals().update(_NAMESPACE)
__all__ = sorted(_name for _name in _NAMESPACE if not _name.startswith("__"))
@@ -1,25 +0,0 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
<meta name="robots" content="noindex, nofollow">
<meta name="theme-color" content="#03070b">
<link id="app-favicon" rel="icon" href="data:," sizes="any">
<title>/minishop</title>
<link rel="stylesheet" href="/subscription_webapp.css">
</head>
<body>
<main id="app"></main>
<!-- WEBAPP_I18N_SCRIPT -->
<!-- WEBAPP_CONFIG_SCRIPT -->
<!-- WEBAPP_JS_SCRIPT -->
<!-- WEBAPP_DEV_MOCK_START -->
<script src="/subscription_webapp.js" defer></script>
<!-- WEBAPP_DEV_MOCK_END -->
</body>
</html>
-953
View File
@@ -1,953 +0,0 @@
/*
* ASCII / console theme.
* Pure black background, white foreground, monospace everything,
* 1px white borders, animated ASCII spinners and block-progress bars.
*/
.theme-key-ascii {
color-scheme: dark;
--accent: #ffffff;
--accent-contrast: #000000;
--bg: #000000;
--panel: #000000;
--panel-2: #050505;
--panel-3: #0c0c0c;
--border: #ffffff;
--border-strong: #ffffff;
--text: #ffffff;
--muted: #b0b0b0;
--dim: #6a6a6a;
--danger: #ff5555;
--blue: #ffffff;
--radius: 0px;
--font-sans: "JetBrains Mono", "Cascadia Code", "Fira Code", "Consolas",
"Source Code Pro", "Courier New", ui-monospace, monospace;
--font-logo: "JetBrains Mono", "Cascadia Code", "Consolas", "Courier New",
ui-monospace, monospace;
--font-mono: "JetBrains Mono", "Cascadia Code", "Consolas", "Courier New",
ui-monospace, monospace;
--surface-sheen: transparent;
--surface-sheen-soft: transparent;
--surface-hover: rgba(255, 255, 255, 0.08);
--surface-muted: #0a0a0a;
--surface-subtle-border: #ffffff;
--overlay-scrim: rgba(0, 0, 0, 0.85);
--nav-bg: #000000;
--rail-bg: #000000;
--shadow-soft: none;
--shadow-strong: none;
--shadow-popover: 0 0 0 1px #ffffff;
--inset-highlight: transparent;
--admin-bg: #000000;
--admin-surface: #000000;
--admin-surface-2: #050505;
--admin-elev: #0c0c0c;
--admin-border: #ffffff;
--admin-border-strong: #ffffff;
--admin-text: #ffffff;
--admin-muted: #b0b0b0;
--admin-dim: #6a6a6a;
}
/* ---------- Base typography ---------- */
.theme-key-ascii,
.theme-key-ascii body,
.theme-key-ascii button,
.theme-key-ascii input,
.theme-key-ascii textarea,
.theme-key-ascii select {
font-family: var(--font-sans);
letter-spacing: 0;
font-synthesis: none;
-webkit-font-smoothing: none;
font-smooth: never;
font-variant-ligatures: none;
}
.theme-key-ascii.app-shell {
background: var(--bg) !important;
background-image:
repeating-linear-gradient(
0deg,
rgba(255, 255, 255, 0.025) 0,
rgba(255, 255, 255, 0.025) 1px,
transparent 1px,
transparent 3px
) !important;
}
/* Slight CRT-like flicker on the shell. */
@keyframes ascii-flicker {
0%, 96%, 100% { opacity: 1; }
97% { opacity: 0.96; }
98% { opacity: 1; }
99% { opacity: 0.94; }
}
.theme-key-ascii.app-shell::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
z-index: 9998;
background: repeating-linear-gradient(
180deg,
rgba(255, 255, 255, 0.02) 0,
rgba(255, 255, 255, 0.02) 1px,
transparent 1px,
transparent 2px
);
animation: ascii-flicker 5s infinite;
}
/* ---------- Panels / cards ---------- */
.theme-key-ascii .card,
.theme-key-ascii .period-card,
.theme-key-ascii .method-card,
.theme-key-ascii .settings-row,
.theme-key-ascii .option-row,
.theme-key-ascii .tariff-selected-card,
.theme-key-ascii .tariff-action-card,
.theme-key-ascii .tariff-warning-card,
.theme-key-ascii .topup-carryover-note,
.theme-key-ascii .input,
.theme-key-ascii .dialog-card,
.theme-key-ascii .language-select-content,
.theme-key-ascii .bottom-nav,
.theme-key-ascii .toast,
.theme-key-ascii .admin-sidebar,
.theme-key-ascii .admin-header,
.theme-key-ascii .admin-card,
.theme-key-ascii .admin-stat-card,
.theme-key-ascii .admin-revenue-panel,
.theme-key-ascii .admin-empty,
.theme-key-ascii .admin-tariff-card,
.theme-key-ascii .admin-toolbar-card,
.theme-key-ascii .admin-table-card,
.theme-key-ascii .admin-panel-dash-card,
.theme-key-ascii .admin-select-trigger,
.theme-key-ascii .admin-select-content,
.theme-key-ascii .admin-cn-card[data-slot="card"],
.theme-key-ascii .admin-dialog .dialog-card,
.theme-key-ascii .admin-theme-editor-section {
border: 1px solid #ffffff;
border-radius: 0;
background: var(--panel);
box-shadow: none;
}
/* No ribbon/corner overlays: those caused dialog overflow scrollbars.
* The console feel comes from the crisp 1px borders, monospace text,
* and the animated marquees / glitches applied to interactive elements. */
/* ---------- Buttons ---------- */
.theme-key-ascii .btn,
.theme-key-ascii .language-select-trigger,
.theme-key-ascii .bottom-nav button,
.theme-key-ascii .admin-btn,
.theme-key-ascii .admin-chip,
.theme-key-ascii .admin-tabs-trigger,
.theme-key-ascii .admin-revenue-period-btn,
.theme-key-ascii .admin-mobile-toggle,
.theme-key-ascii .admin-nav-item {
border: 1px solid #ffffff;
border-radius: 0;
background: #000000;
color: #ffffff;
box-shadow: none;
text-transform: none;
font-family: var(--font-sans);
transform: none;
position: relative;
}
.theme-key-ascii .btn:hover:not(:disabled),
.theme-key-ascii .admin-btn:hover:not(:disabled),
.theme-key-ascii .admin-nav-item:hover,
.theme-key-ascii .admin-tabs-trigger:hover,
.theme-key-ascii .admin-revenue-period-btn:hover,
.theme-key-ascii .bottom-nav button:hover {
background: #ffffff;
color: #000000;
}
.theme-key-ascii .btn:active:not(:disabled),
.theme-key-ascii .bottom-nav button:active,
.theme-key-ascii .admin-btn:active:not(:disabled) {
background: #ffffff;
color: #000000;
transform: translate(1px, 1px);
}
.theme-key-ascii .btn-primary,
.theme-key-ascii .admin-btn-primary,
.theme-key-ascii .bottom-nav button.active,
.theme-key-ascii .period-card.active,
.theme-key-ascii .method-card.active,
.theme-key-ascii .option-row.active,
.theme-key-ascii .admin-nav-item.active,
.theme-key-ascii .admin-tabs-trigger[data-state="active"],
.theme-key-ascii .admin-revenue-period-btn.is-active {
background: #ffffff;
color: #000000;
border-color: #ffffff;
}
.theme-key-ascii .btn-primary:hover:not(:disabled),
.theme-key-ascii .admin-btn-primary:hover:not(:disabled) {
background: #000000;
color: #ffffff;
outline: 1px solid #ffffff;
outline-offset: -2px;
}
/* Blinking caret-style focus ring. */
@keyframes ascii-caret {
0%, 49% { outline-color: #ffffff; }
50%, 100% { outline-color: transparent; }
}
.theme-key-ascii .btn:focus-visible,
.theme-key-ascii .admin-btn:focus-visible,
.theme-key-ascii .admin-nav-item:focus-visible,
.theme-key-ascii .admin-tabs-trigger:focus-visible,
.theme-key-ascii .admin-revenue-period-btn:focus-visible,
.theme-key-ascii .admin-mobile-toggle:focus-visible,
.theme-key-ascii .language-select-trigger:focus-visible,
.theme-key-ascii .bottom-nav button:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 1px;
animation: ascii-caret 1s steps(1) infinite;
}
/* ---------- Inputs ---------- */
.theme-key-ascii .input,
.theme-key-ascii .admin-input,
.theme-key-ascii .admin-textarea,
.theme-key-ascii .admin-screen-wrap textarea,
.theme-key-ascii .admin-dialog textarea {
border: 1px solid #ffffff;
border-radius: 0;
background: #000000;
color: #ffffff;
box-shadow: none;
font-family: var(--font-mono);
caret-color: #ffffff;
}
.theme-key-ascii .input::placeholder,
.theme-key-ascii .admin-input::placeholder,
.theme-key-ascii .admin-textarea::placeholder,
.theme-key-ascii .admin-screen-wrap textarea::placeholder,
.theme-key-ascii .admin-dialog textarea::placeholder {
color: var(--dim);
font-style: normal;
}
.theme-key-ascii .input:focus,
.theme-key-ascii .admin-input:focus,
.theme-key-ascii .admin-textarea:focus,
.theme-key-ascii .admin-screen-wrap textarea:focus,
.theme-key-ascii .admin-dialog textarea:focus {
outline: none;
border-color: #ffffff;
box-shadow: inset 0 0 0 1px #ffffff;
}
/* ---------- Bottom nav (desktop rail) ---------- */
@media (min-width: 1024px) {
.theme-key-ascii .bottom-nav {
border-right: 1px solid #ffffff !important;
background: var(--rail-bg) !important;
backdrop-filter: none !important;
box-shadow: none !important;
}
.theme-key-ascii .bottom-nav button {
border: 1px solid #ffffff !important;
border-radius: 0 !important;
background: #000000 !important;
color: #ffffff !important;
box-shadow: none !important;
}
.theme-key-ascii .bottom-nav button.active {
background: #ffffff !important;
color: #000000 !important;
}
}
/* ---------- ASCII progress bar ---------- *
* Empty track: ░░░░░░░░░░░ (low-contrast dotted fill).
* Filled span: ██████ (solid white blocks).
*/
.theme-key-ascii .progress {
height: 14px;
border: 1px solid #ffffff;
border-radius: 0 !important;
background-color: #000000;
background-image: repeating-linear-gradient(
90deg,
rgba(255, 255, 255, 0.22) 0,
rgba(255, 255, 255, 0.22) 1px,
transparent 1px,
transparent 4px
);
position: relative;
overflow: hidden;
font-family: var(--font-mono);
}
.theme-key-ascii .progress span {
border-radius: 0 !important;
background: #ffffff !important;
background-image: repeating-linear-gradient(
90deg,
rgba(0, 0, 0, 0.0) 0,
rgba(0, 0, 0, 0.0) 5px,
rgba(0, 0, 0, 0.35) 5px,
rgba(0, 0, 0, 0.35) 6px
) !important;
box-shadow: none;
}
/* Indeterminate scanning effect for any progress lacking a width-set span. */
@keyframes ascii-scan {
0% { background-position: 0 0; }
100% { background-position: 12px 0; }
}
/* ---------- ASCII spinner replacement ---------- */
.theme-key-ascii .ui-spinner,
.theme-key-ascii .telegram-button-spinner,
.theme-key-ascii .brand-mark-spinner {
border: none !important;
border-radius: 0 !important;
width: 1ch !important;
height: 1em !important;
background: transparent !important;
position: relative;
animation: none !important;
color: currentColor;
font-family: var(--font-mono);
font-weight: 700;
text-align: center;
vertical-align: middle;
overflow: visible;
}
.theme-key-ascii .ui-spinner::before,
.theme-key-ascii .telegram-button-spinner::before,
.theme-key-ascii .brand-mark-spinner::before {
content: "|";
display: inline-block;
animation: ascii-spin 0.8s steps(1) infinite;
font-family: var(--font-mono);
line-height: 1;
}
@keyframes ascii-spin {
0% { content: "|"; }
25% { content: "/"; }
50% { content: "-"; }
75% { content: "\\"; }
100% { content: "|"; }
}
/* Some browsers don't animate content; fallback rotation of a glyph. */
@supports not (animation-name: ascii-spin) {
.theme-key-ascii .ui-spinner::before,
.theme-key-ascii .telegram-button-spinner::before,
.theme-key-ascii .brand-mark-spinner::before {
content: "+";
animation: ascii-spin-rotate 0.8s steps(4) infinite;
}
@keyframes ascii-spin-rotate {
to { transform: rotate(360deg); }
}
}
/* Blinking cursor appended to brand text. */
.theme-key-ascii .login-brand h1::after,
.theme-key-ascii .admin-sidebar-brand strong::after,
.theme-key-ascii .brand-row strong::after {
content: "_";
display: inline-block;
margin-left: 0.2ch;
color: #ffffff;
animation: ascii-blink 1s steps(1) infinite;
}
@keyframes ascii-blink {
0%, 49% { opacity: 1; }
50%, 100% { opacity: 0; }
}
/* Section heading prompt prefix. */
.theme-key-ascii .admin-card-head h2::before,
.theme-key-ascii .admin-card-head h3::before,
.theme-key-ascii .card > h2:first-child::before,
.theme-key-ascii .card > h3:first-child::before {
content: "> ";
color: #ffffff;
opacity: 0.85;
font-family: var(--font-mono);
}
/* ---------- Tables ---------- */
.theme-key-ascii .admin-table thead th {
background: #000000;
color: #ffffff;
border-bottom: 1px solid #ffffff;
text-transform: uppercase;
letter-spacing: 0.04em;
font-weight: 700;
}
.theme-key-ascii .admin-table tbody tr {
border-bottom: 1px dashed #ffffff;
}
.theme-key-ascii .admin-table tbody tr:hover {
background: rgba(255, 255, 255, 0.08);
}
/* ---------- Badges / chips ---------- */
.theme-key-ascii .admin-badge,
.theme-key-ascii .admin-cn-badge {
border: 1px solid #ffffff;
border-radius: 0;
background: #000000;
color: #ffffff;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.theme-key-ascii .admin-badge::before,
.theme-key-ascii .admin-cn-badge::before {
content: "[";
}
.theme-key-ascii .admin-badge::after,
.theme-key-ascii .admin-cn-badge::after {
content: "]";
}
/* ---------- Links ---------- */
.theme-key-ascii a:not(.btn):not(.bottom-nav button):not([class*="-trigger"]),
.theme-key-ascii .admin-screen-wrap a:not(.admin-btn):not(.admin-nav-item) {
color: #ffffff;
text-decoration: underline;
text-underline-offset: 2px;
}
.theme-key-ascii a:not(.btn):not(.bottom-nav button):not([class*="-trigger"]):hover,
.theme-key-ascii .admin-screen-wrap a:not(.admin-btn):not(.admin-nav-item):hover {
background: #ffffff;
color: #000000;
text-decoration: none;
}
/* ---------- Selection ---------- */
.theme-key-ascii ::selection {
background: #ffffff;
color: #000000;
}
/* ---------- Scrollbars ---------- */
.theme-key-ascii ::-webkit-scrollbar {
width: 12px;
height: 12px;
}
.theme-key-ascii ::-webkit-scrollbar-track {
background-color: #000000;
background-image: repeating-linear-gradient(
0deg,
#ffffff 0,
#ffffff 1px,
transparent 1px,
transparent 4px
);
}
.theme-key-ascii ::-webkit-scrollbar-thumb {
background: #ffffff;
border: 1px solid #000000;
}
.theme-key-ascii ::-webkit-scrollbar-thumb:active {
background: #b0b0b0;
}
.theme-key-ascii ::-webkit-scrollbar-corner {
background: #000000;
}
/* ---------- Lucide icons: render as crisp white outlines ---------- */
.theme-key-ascii svg.lucide,
.theme-key-ascii svg[class*="lucide-"] {
color: #ffffff !important;
stroke: #ffffff !important;
fill: none !important;
stroke-width: 1.75;
filter: none;
}
.theme-key-ascii .btn-primary svg.lucide,
.theme-key-ascii .bottom-nav button.active svg.lucide,
.theme-key-ascii .period-card.active svg.lucide,
.theme-key-ascii .method-card.active svg.lucide,
.theme-key-ascii .option-row.active svg.lucide,
.theme-key-ascii .admin-btn-primary svg.lucide,
.theme-key-ascii .admin-nav-item.active svg.lucide,
.theme-key-ascii .admin-tabs-trigger[data-state="active"] svg.lucide,
.theme-key-ascii .admin-revenue-period-btn.is-active svg.lucide {
color: #000000 !important;
stroke: #000000 !important;
}
/* ---------- Toast / language select polish ---------- */
.theme-key-ascii .toast {
background: #000000;
border: 1px solid #ffffff;
color: #ffffff;
}
.theme-key-ascii .language-select-item {
border-radius: 0;
}
.theme-key-ascii .language-select-item[data-highlighted],
.theme-key-ascii .language-select-item[data-selected] {
background: #ffffff;
color: #000000 !important;
}
/* ---------- Headings: stronger console feel ---------- */
.theme-key-ascii h1,
.theme-key-ascii h2,
.theme-key-ascii h3,
.theme-key-ascii h4 {
font-family: var(--font-mono);
letter-spacing: 0;
text-transform: none;
}
.theme-key-ascii .admin-header {
background: #000000;
border-bottom: 1px solid #ffffff;
color: #ffffff;
}
.theme-key-ascii .admin-header-title h2,
.theme-key-ascii .admin-header-title small {
color: #ffffff;
}
/* Make any element with role progressbar but no inner span show animated stripes. */
.theme-key-ascii [role="progressbar"]:not(.progress) {
background:
repeating-linear-gradient(
90deg,
#ffffff 0,
#ffffff 6px,
#000000 6px,
#000000 8px
);
animation: ascii-scan 0.6s linear infinite;
border: 1px solid #ffffff;
border-radius: 0;
color: #000000;
}
/* ============================================================
* Console-themed extras
* ============================================================ */
/* ---------- ASCII skeletons ---------- *
* Subtle dark shimmer with a single bright scan line moving across.
*/
@keyframes ascii-skeleton-scan {
0% { background-position: -120% 0; }
100% { background-position: 220% 0; }
}
.theme-key-ascii .ui-skeleton,
.theme-key-ascii .admin-skeleton,
.theme-key-ascii .skeleton-line,
.theme-key-ascii .skeleton-dot,
.theme-key-ascii .skeleton-pay-button,
.theme-key-ascii .ui-skeleton-line,
.theme-key-ascii .admin-skeleton-line,
.theme-key-ascii .admin-skeleton-line-strong,
.theme-key-ascii .admin-skeleton-line-soft,
.theme-key-ascii .admin-skeleton-line-short,
.theme-key-ascii .admin-skeleton-line-tiny,
.theme-key-ascii .ui-skeleton-title,
.theme-key-ascii .ui-skeleton-short,
.theme-key-ascii .ui-skeleton-tiny,
.theme-key-ascii .ui-skeleton-badge,
.theme-key-ascii .admin-skeleton-badge,
.theme-key-ascii .admin-skeleton-avatar,
.theme-key-ascii .admin-stat-skeleton-card,
.theme-key-ascii .admin-stat-skeleton-wide,
.theme-key-ascii .admin-cn-card-skeleton--tall {
border-radius: 0 !important;
border: 1px solid #ffffff !important;
background-color: #050505 !important;
background-image: linear-gradient(
90deg,
transparent 0%,
transparent 40%,
rgba(255, 255, 255, 0.18) 50%,
transparent 60%,
transparent 100%
) !important;
background-size: 200% 100% !important;
background-repeat: no-repeat !important;
color: #ffffff !important;
animation: ascii-skeleton-scan 1.6s linear infinite !important;
}
.theme-key-ascii .admin-skeleton-avatar {
width: 32px !important;
height: 32px !important;
}
/* ---------- Empty / loading state console message ---------- */
.theme-key-ascii .admin-empty {
position: relative;
}
.theme-key-ascii .admin-empty::before {
content: "$ tail -f /var/log/empty.log";
display: block;
font-family: var(--font-mono);
color: var(--muted);
margin-bottom: 8px;
letter-spacing: 0;
}
/* ---------- Buttons: glitch on hover ---------- */
@keyframes ascii-glitch {
0%, 100% { transform: translate(0, 0); clip-path: inset(0 0 0 0); }
20% { transform: translate(-1px, 0); clip-path: inset(20% 0 50% 0); }
40% { transform: translate(1px, 0); clip-path: inset(40% 0 30% 0); }
60% { transform: translate(-1px, 0); clip-path: inset(10% 0 70% 0); }
80% { transform: translate(1px, 0); clip-path: inset(60% 0 10% 0); }
}
.theme-key-ascii .btn:hover:not(:disabled)::after,
.theme-key-ascii .admin-btn:hover:not(:disabled)::after {
content: attr(data-label, "");
pointer-events: none;
}
/* Disable glitch text duplication if the button has no data-label.
* Apply a subtle scanline overlay instead, which is content-agnostic. */
.theme-key-ascii .btn,
.theme-key-ascii .admin-btn,
.theme-key-ascii .admin-nav-item,
.theme-key-ascii .bottom-nav button {
overflow: hidden;
}
.theme-key-ascii .btn:hover:not(:disabled)::before,
.theme-key-ascii .admin-btn:hover:not(:disabled)::before,
.theme-key-ascii .admin-nav-item:hover::before,
.theme-key-ascii .bottom-nav button:hover::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background: repeating-linear-gradient(
0deg,
rgba(0, 0, 0, 0.4) 0,
rgba(0, 0, 0, 0.4) 1px,
transparent 1px,
transparent 3px
);
animation: ascii-glitch 0.6s steps(1) infinite;
z-index: 1;
}
/* ---------- Bottom nav active markers "> item <" ---------- */
.theme-key-ascii .bottom-nav button.active::before,
.theme-key-ascii .admin-nav-item.active::before {
content: ">";
position: absolute;
left: 6px;
top: 50%;
transform: translateY(-50%);
font-family: var(--font-mono);
color: #000000;
animation: ascii-blink 1s steps(1) infinite;
z-index: 2;
}
.theme-key-ascii .bottom-nav button.active::after,
.theme-key-ascii .admin-nav-item.active::after {
content: "<";
position: absolute;
right: 6px;
top: 50%;
transform: translateY(-50%);
font-family: var(--font-mono);
color: #000000;
animation: ascii-blink 1s steps(1) infinite;
animation-delay: 0.5s;
z-index: 2;
}
.theme-key-ascii .bottom-nav button.active,
.theme-key-ascii .admin-nav-item.active {
position: relative;
}
/* On the mobile bottom-bar (compact) hide the markers to avoid overlap. */
@media (max-width: 1023px) {
.theme-key-ascii .bottom-nav button.active::before,
.theme-key-ascii .bottom-nav button.active::after {
content: none;
}
}
/* ---------- ASCII block progress fill ---------- *
* The actual fill renders as alternating █ blocks via the existing span
* gradient. We also overlay a slow scanning highlight to make it feel
* "live", and add a soft typed counter to the right edge.
*/
/* (duplicate progress fill rules removed — see definition above) */
/* ---------- Headings: subtle CRT glitch on hover ---------- */
@keyframes ascii-heading-jitter {
0%, 92%, 100% { transform: translate(0, 0); }
93% { transform: translate(-1px, 0); }
94% { transform: translate(1px, 0); }
95% { transform: translate(0, -1px); }
96% { transform: translate(0, 1px); }
}
.theme-key-ascii h1,
.theme-key-ascii h2,
.theme-key-ascii h3,
.theme-key-ascii .login-brand h1,
.theme-key-ascii .admin-sidebar-brand strong,
.theme-key-ascii .admin-card-head h2,
.theme-key-ascii .admin-card-head h3 {
display: inline-block;
animation: ascii-heading-jitter 7s steps(1) infinite;
}
/* ---------- App-shell boot banner ---------- *
* A non-blocking strip at the very top of the viewport that displays a
* typed "booting…" line, then settles. Pure CSS so it cannot interfere
* with any DOM. The animation runs once on mount.
*/
@keyframes ascii-boot-type {
0% { width: 0; }
85% { width: 28ch; }
100% { width: 28ch; }
}
@keyframes ascii-boot-fade {
0%, 70% { opacity: 1; }
100% { opacity: 0; visibility: hidden; }
}
.theme-key-ascii.app-shell::after {
content: "$ remnawave --start --tty=0";
position: fixed;
top: 0;
left: 0;
z-index: 9999;
display: block;
padding: 2px 8px;
max-width: 28ch;
overflow: hidden;
white-space: nowrap;
background: #000000;
color: #ffffff;
font-family: var(--font-mono);
font-size: 11px;
line-height: 1.5;
border-right: 1px solid #ffffff;
border-bottom: 1px solid #ffffff;
pointer-events: none;
animation:
ascii-boot-type 1.6s steps(28) 1 both,
ascii-boot-fade 3s linear 1.6s 1 forwards;
}
/* ---------- Toggle / checkbox squareification (best-effort) ---------- */
.theme-key-ascii input[type="checkbox"],
.theme-key-ascii input[type="radio"] {
appearance: none;
-webkit-appearance: none;
width: 1em;
height: 1em;
border: 1px solid #ffffff;
background: #000000;
border-radius: 0 !important;
position: relative;
vertical-align: middle;
cursor: pointer;
}
.theme-key-ascii input[type="checkbox"]:checked::after,
.theme-key-ascii input[type="radio"]:checked::after {
content: "x";
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-mono);
font-weight: 700;
color: #ffffff;
line-height: 1;
}
/* ---------- Code-like "$ " prefix on toast messages ---------- */
.theme-key-ascii .toast::before {
content: "$ ";
color: #ffffff;
font-family: var(--font-mono);
font-weight: 700;
}
/* ---------- Disabled state — strikethrough hatching ---------- */
.theme-key-ascii .btn:disabled,
.theme-key-ascii .admin-btn:disabled,
.theme-key-ascii button:disabled {
background-image: repeating-linear-gradient(
-45deg,
transparent 0,
transparent 4px,
rgba(255, 255, 255, 0.18) 4px,
rgba(255, 255, 255, 0.18) 5px
);
color: var(--dim) !important;
border-color: var(--dim) !important;
cursor: not-allowed;
}
/* ============================================================
* Square everything: drop all rounded corners on touched surfaces.
* ============================================================ */
.theme-key-ascii :is(
.card, .dialog-card, .toast,
.btn, .input,
.period-card, .method-card, .settings-row, .option-row,
.tariff-selected-card, .tariff-action-card, .tariff-warning-card,
.topup-carryover-note, .language-select-content, .language-select-item,
.language-select-trigger, .bottom-nav, .bottom-nav button,
.field-error-tooltip,
.admin-card, .admin-card-head, .admin-card-body,
.admin-stat-card, .admin-stat-skeleton-card, .admin-stat-skeleton-wide,
.admin-revenue-panel, .admin-empty,
.admin-tariff-card, .admin-toolbar-card, .admin-table-card,
.admin-panel-dash-card,
.admin-select-trigger, .admin-select-content, .admin-select-item,
.admin-cn-card, .admin-cn-badge, .admin-badge,
.admin-cn-card-skeleton--tall,
.admin-input, .admin-textarea, .admin-btn, .admin-chip,
.admin-tabs-trigger, .admin-tabs-list,
.admin-nav-item, .admin-revenue-period-btn, .admin-mobile-toggle,
.admin-header, .admin-sidebar, .admin-sidebar-brand,
.admin-dialog,
.admin-theme-editor-section,
[data-slot="card"], [data-slot="card-header"],
[data-slot="card-content"], [data-slot="card-footer"]
),
.theme-key-ascii :is(
.card, .dialog-card, .toast,
.btn, .input,
.admin-card, .admin-card-head, .admin-card-body,
.admin-stat-card, .admin-revenue-panel, .admin-empty,
.admin-tariff-card, .admin-toolbar-card, .admin-table-card,
.admin-panel-dash-card,
.admin-select-trigger, .admin-select-content,
.admin-cn-card,
.admin-input, .admin-textarea, .admin-btn,
.admin-nav-item, .admin-tabs-trigger
) * {
border-radius: 0 !important;
}
.theme-key-ascii img,
.theme-key-ascii .admin-avatar,
.theme-key-ascii .admin-skeleton-avatar {
border-radius: 0 !important;
}
/* ============================================================
* Console-style tables: cell borders, header underline,
* row separator using dashed line.
* ============================================================ */
.theme-key-ascii .admin-table,
.theme-key-ascii table {
border-collapse: collapse;
border: 1px solid #ffffff;
font-family: var(--font-mono);
}
.theme-key-ascii .admin-table th,
.theme-key-ascii .admin-table td,
.theme-key-ascii table th,
.theme-key-ascii table td {
border: 1px solid #ffffff;
border-radius: 0 !important;
padding: 6px 10px;
}
.theme-key-ascii .admin-table thead th,
.theme-key-ascii table thead th {
background: #000000;
border-bottom: 2px solid #ffffff;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.theme-key-ascii .admin-table tbody tr,
.theme-key-ascii table tbody tr {
border-bottom: 1px solid #ffffff;
}
.theme-key-ascii .admin-table tbody tr:hover,
.theme-key-ascii table tbody tr:hover {
background: rgba(255, 255, 255, 0.07);
}
.theme-key-ascii .admin-table tbody tr:hover td,
.theme-key-ascii table tbody tr:hover td {
color: #ffffff;
}
@@ -1,17 +0,0 @@
{
"key": "ascii",
"names": {
"ru": "ASCII",
"en": "ASCII"
},
"enabled": true,
"default": false,
"use_primary_accent": false,
"use_in_admin": true,
"css_file": "style.css",
"assets_version": 1,
"tokens": {
"color_scheme": "dark",
"style_preset": "ascii"
}
}
@@ -1,27 +0,0 @@
{
"key": "dark",
"names": {
"ru": "Темная",
"en": "Dark"
},
"enabled": true,
"default": true,
"use_primary_accent": true,
"use_in_admin": true,
"assets_version": 1,
"tokens": {
"color_scheme": "dark",
"bg": "#03070b",
"panel": "#111820",
"panel_2": "#0b1118",
"panel_3": "#17212b",
"border": "rgba(255, 255, 255, 0.12)",
"border_strong": "rgba(255, 255, 255, 0.2)",
"text": "#f2f7f4",
"muted": "#a9b4b0",
"dim": "#68736f",
"danger": "#ff6b6b",
"blue": "#2d9cff",
"radius": "8px"
}
}
-131
View File
@@ -1,131 +0,0 @@
.theme-key-light {
color-scheme: light;
--accent: #047857;
--bg: #f7f8fb;
--panel: #ffffff;
--panel-2: #f1f5f9;
--panel-3: #e8edf3;
--border: rgba(15, 23, 42, 0.11);
--border-strong: rgba(15, 23, 42, 0.2);
--text: #0f172a;
--muted: #475569;
--dim: #64748b;
--danger: #dc2626;
--danger-text: #b91c1c;
--danger-soft: color-mix(in srgb, var(--danger) 9%, var(--panel));
--danger-border: color-mix(in srgb, var(--danger) 34%, var(--border));
--success: #16a34a;
--success-text: #166534;
--success-soft: color-mix(in srgb, var(--success) 10%, var(--panel));
--success-border: color-mix(in srgb, var(--success) 34%, var(--border));
--warning: #d97706;
--warning-text: #92400e;
--warning-soft: color-mix(in srgb, var(--warning) 11%, var(--panel));
--warning-border: color-mix(in srgb, var(--warning) 34%, var(--border));
--info: #2563eb;
--info-text: #1d4ed8;
--info-soft: color-mix(in srgb, var(--info) 9%, var(--panel));
--info-border: color-mix(in srgb, var(--info) 30%, var(--border));
--blue: #2563eb;
--radius: 8px;
--accent-contrast: #ffffff;
--surface-sheen: rgba(15, 23, 42, 0.035);
--surface-sheen-soft: rgba(15, 23, 42, 0.012);
--surface-hover: rgba(15, 23, 42, 0.045);
--surface-muted: rgba(15, 23, 42, 0.035);
--surface-subtle-border: rgba(15, 23, 42, 0.1);
--overlay-scrim: rgba(15, 23, 42, 0.34);
--nav-bg: rgba(255, 255, 255, 0.88);
--rail-bg: rgba(255, 255, 255, 0.72);
--shadow-soft: 0 6px 18px rgba(15, 23, 42, 0.06);
--shadow-strong: 0 18px 44px rgba(15, 23, 42, 0.12);
--shadow-popover: 0 14px 28px rgba(15, 23, 42, 0.12);
--inset-highlight: rgba(255, 255, 255, 0.75);
--admin-bg: #f7f8fb;
--admin-surface: #ffffff;
--admin-surface-2: #f1f5f9;
--admin-elev: #e8edf3;
--admin-border: rgba(15, 23, 42, 0.1);
--admin-border-strong: rgba(15, 23, 42, 0.18);
--admin-text: #0f172a;
--admin-muted: #64748b;
--admin-dim: #64748b;
--admin-chart-stroke: #065f46;
--admin-chart-fill: rgba(6, 95, 70, 0.22);
}
.theme-key-light .ui-spinner,
.theme-key-light .brand-mark-spinner {
color: inherit;
}
.theme-key-light .telegram-button-spinner {
border-color: rgba(255, 255, 255, 0.35);
border-top-color: #ffffff;
}
.theme-key-light .btn-primary,
.theme-key-light .admin-btn.admin-btn-primary,
.theme-key-light .admin-extend-control .admin-btn.admin-btn-primary {
background: color-mix(in srgb, var(--accent) 50%, #000000);
border-color: color-mix(in srgb, var(--accent) 42%, #000000);
color: #ffffff;
}
.theme-key-light .btn-primary:hover:not(:disabled),
.theme-key-light .admin-btn.admin-btn-primary:hover:not(:disabled),
.theme-key-light .admin-extend-control .admin-btn.admin-btn-primary:hover:not(:disabled) {
background: color-mix(in srgb, var(--accent) 52%, #000000);
}
.theme-key-light.app-shell {
background: var(--bg) !important;
}
.theme-key-light .phone-screen {
background: var(--bg);
}
/* Flatten Settings rows: no gradient sheen, no inset highlight that reads as a 3D bevel */
.theme-key-light .settings-row {
background: var(--panel);
box-shadow: none;
}
.theme-key-light .settings-row-linked {
background: var(--success-soft);
}
/* Avatar/profile card: bigger lift, but rows below have an opaque background and
stack above, so the shadow stays visually under them instead of bleeding through. */
.theme-key-light .settings-profile {
box-shadow:
0 10px 24px rgba(15, 23, 42, 0.10),
inset 0 1px 0 var(--inset-highlight);
}
.theme-key-light .settings-links-block {
position: relative;
z-index: 1;
}
/* Slightly stronger axis/grid contrast for the revenue chart on a light surface */
.theme-key-light .admin-revenue-svg-frame {
background: #ffffff;
}
/* Bonus section: drop accent color from body strongs; only the bonus-system heading
and explicitly-accent card headings stay tinted — and they use the same darkened
accent technique as .btn-primary on light, so they remain readable on white. */
.theme-key-light .bonus-card strong {
color: var(--text);
}
.theme-key-light .bonus-card-head strong,
.theme-key-light .card-heading-accent {
color: color-mix(in srgb, var(--accent) 50%, #000000);
}
.theme-key-light .bonus-card-head > svg {
color: color-mix(in srgb, var(--accent) 50%, #000000);
}
@@ -1,16 +0,0 @@
{
"key": "light",
"names": {
"ru": "Светлая",
"en": "Light"
},
"enabled": true,
"default": false,
"use_primary_accent": true,
"use_in_admin": true,
"css_file": "style.css",
"assets_version": 2,
"tokens": {
"color_scheme": "light"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 340 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 375 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 424 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 356 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 419 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 372 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 388 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 378 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 424 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 636 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 364 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 390 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 385 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 415 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 356 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 393 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 474 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 395 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 461 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 327 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 411 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 395 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 415 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 393 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 422 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 478 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 500 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 589 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 392 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 392 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 384 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 419 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 403 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 371 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 344 B

File diff suppressed because it is too large Load Diff
@@ -1,17 +0,0 @@
{
"key": "windows95",
"names": {
"ru": "Windows 95",
"en": "Windows 95"
},
"enabled": true,
"default": false,
"use_primary_accent": false,
"use_in_admin": true,
"css_file": "style.css",
"assets_version": 6,
"tokens": {
"color_scheme": "light",
"style_preset": "win95"
}
}
-1
View File
@@ -1 +0,0 @@
"""Domain modules for the subscription Mini App backend."""
-118
View File
@@ -1,118 +0,0 @@
# ruff: noqa: F401,F403,F405,I001
import asyncio
import base64
import hashlib
import html
import hmac
import io
import ipaddress
import json
import logging
import os
import re
import secrets
import socket
import subprocess
import time
from collections import deque
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from urllib.parse import parse_qsl, quote, urlencode, urlsplit, urlunsplit
from aiogram import Bot, Dispatcher
from aiogram.types import LabeledPrice
from aiohttp import ClientSession, ClientTimeout, web
from pydantic import BaseModel, ConfigDict, EmailStr, ValidationError, constr, field_validator
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import sessionmaker
from bot.app.web.admin_api import (
admin_auth_middleware,
setup_admin_routes,
)
from bot.app.web.webapp_auth import (
create_signed_telegram_oauth_state,
create_telegram_oauth_nonce,
create_webapp_session_token,
validate_telegram_login_widget_data,
validate_telegram_oauth_id_token,
validate_telegram_webapp_init_data,
verify_signed_telegram_oauth_state,
verify_telegram_oauth_nonce,
verify_webapp_session_token,
)
from bot.infra.redis import cache_get_json, cache_set_json, get_redis, redis_key
from bot.services.crypto_pay_service import CryptoPayService
from bot.services.email_auth_service import EmailAuthService, normalize_email
from bot.services.email_templates import render_account_merged
from bot.services.freekassa_service import FreeKassaService
from bot.services.platega_service import PlategaService
from bot.services.promo_code_service import PromoCodeService
from bot.services.referral_service import ReferralService
from bot.services.severpay_service import SeverPayService
from bot.services.subscription_service import SubscriptionService
from bot.services.yookassa_service import YooKassaService
from bot.utils.config_link import prepare_config_links
from bot.utils.request_security import parse_ip_entries, request_client_ip
from bot.utils.text_sanitizer import sanitize_display_name, sanitize_username
from config.settings import Settings
from db.dal import payment_dal, subscription_dal, user_dal
from db.dal.user_dal import UserMergeConflictError
from db.models import Payment, User, UserTelegramAvatar
logger = logging.getLogger(__name__)
TEMPLATE_PATH = Path(__file__).resolve().parents[1] / "templates" / "subscription_webapp.html"
ASSET_DIR = TEMPLATE_PATH.parent
APP_ROOT = Path(__file__).resolve().parents[5]
WEBAPP_LOGO_PROXY_PATH = "/webapp-logo"
WEBAPP_LOGO_CACHE_DIR = APP_ROOT / "data" / "webapp-logo"
WEBAPP_UPLOADED_LOGO_DIR = WEBAPP_LOGO_CACHE_DIR / "uploads"
WEBAPP_UPLOADED_LOGO_PATH = "/webapp-uploaded-logo"
WEBAPP_FAVICON_DIR = WEBAPP_LOGO_CACHE_DIR / "favicons"
WEBAPP_FAVICON_PATH = "/webapp-favicon"
WEBAPP_EMOJI_CACHE_DIR = APP_ROOT / "data" / "webapp-emoji"
WEBAPP_CONFIG_PLACEHOLDER = "<!-- WEBAPP_CONFIG_SCRIPT -->"
WEBAPP_I18N_PLACEHOLDER = "<!-- WEBAPP_I18N_SCRIPT -->"
WEBAPP_JS_PLACEHOLDER = "<!-- WEBAPP_JS_SCRIPT -->"
APP_REPOSITORY_URL = "https://github.com/3252a8/remnawave-minishop"
DEV_MOCK_START_MARKER = "<!-- WEBAPP_DEV_MOCK_START -->"
DEV_MOCK_END_MARKER = "<!-- WEBAPP_DEV_MOCK_END -->"
WEBAPP_RATE_LIMIT_WINDOW_SECONDS = 60
WEBAPP_RATE_LIMIT_MAX_REQUESTS = 30
WEBAPP_LOGO_MAX_BYTES = 2 * 1024 * 1024
WEBAPP_EMOJI_MAX_BYTES = 4 * 1024 * 1024
WEBAPP_THEME_CSS_MAX_BYTES = 512 * 1024
WEBAPP_THEME_ASSET_MAX_BYTES = 1024 * 1024
WEBAPP_THEME_ASSET_CONTENT_TYPES = {
".gif": "image/gif",
".ico": "image/x-icon",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".svg": "image/svg+xml",
".webp": "image/webp",
}
WEBAPP_TELEGRAM_AVATAR_MAX_BYTES = 128 * 1024
WEBAPP_TELEGRAM_AVATAR_REFRESH_SECONDS = 24 * 60 * 60
WEBAPP_TELEGRAM_AVATAR_FETCH_TIMEOUT_SECONDS = 4
WEBAPP_SESSION_COOKIE_NAME = "rw_webapp_session"
WEBAPP_CSRF_COOKIE_NAME = "rw_webapp_csrf"
WEBAPP_TELEGRAM_OAUTH_STATE_COOKIE_NAME = "rw_tg_oauth_state"
WEBAPP_CSRF_HEADER_NAME = "X-CSRF-Token"
WEBAPP_STATE_CHANGING_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
_APP_VERSION_CACHE: Optional[str] = None
WEBAPP_CSRF_EXEMPT_PATHS = {
"/api/auth/telegram/nonce",
"/api/auth/token",
"/api/auth/email/request",
"/api/auth/email/verify",
"/api/auth/email/magic",
"/api/auth/logout",
}
_SHARED_HTTP_SESSION: Optional[ClientSession] = None
_SHARED_HTTP_SESSION_LOCK = asyncio.Lock()
__all__ = [name for name in globals() if not name.startswith("__")]
-540
View File
@@ -1,540 +0,0 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
async def account_email_request_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
settings: Settings = request.app["settings"]
payload = await _read_json(request)
email_payload, validation_error = _validate_model_payload(WebAppEmailPayload, payload)
if validation_error:
return validation_error
email = email_payload.email
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user or db_user.is_banned:
return _json_error(403, "access_denied", "Access denied")
if db_user.email == email and db_user.email_verified_at:
return web.json_response({"ok": True, "already_linked": True})
lang = _normalize_language(db_user.language_code or settings.DEFAULT_LANGUAGE)
return await _request_email_code(
request,
email=email,
purpose="link_email",
language_code=lang,
target_user_id=user_id,
)
async def account_email_verify_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
rate_limit_response = await _enforce_webapp_rate_limit(
request,
user_id=user_id,
action="account_email_verify",
)
if rate_limit_response:
return rate_limit_response
payload = await _read_json(request)
email_payload, validation_error = _validate_model_payload(WebAppEmailCodePayload, payload)
if validation_error:
return validation_error
email = email_payload.email
code = str(email_payload.code or "")
email_service: EmailAuthService = request.app["email_auth_service"]
settings: Settings = request.app["settings"]
async_session_factory: sessionmaker = request.app["async_session_factory"]
merge_notice: Optional[Dict[str, Any]] = None
source_panel_uuid: Optional[str] = None
final_user_id = user_id
final_email = email
final_telegram_id: Optional[int] = None
final_username: Optional[str] = None
final_first_name: Optional[str] = None
final_panel_uuid: Optional[str] = None
should_notify_email_linked = False
async with async_session_factory() as session:
try:
verify_result = await email_service.verify_code(
session,
email=email,
purpose="link_email",
code=code,
target_user_id=user_id,
)
if not verify_result.ok:
await session.commit()
status = 429 if verify_result.error == "rate_limited" else 400
return web.json_response(
{
"ok": False,
"error": verify_result.error or "invalid_code",
"retry_after": verify_result.retry_after,
"message": "Invalid code",
},
status=status,
)
current_user = await user_dal.get_user_by_id(session, user_id)
if not current_user or current_user.is_banned:
await session.rollback()
return _json_error(403, "access_denied", "Access denied")
should_notify_email_linked = (
bool(_telegram_id_for_user(current_user)) and not current_user.email
)
existing_email_user = await user_dal.get_user_by_email(session, email)
if existing_email_user and existing_email_user.user_id != current_user.user_id:
source_panel_uuid = existing_email_user.panel_user_uuid
current_user = await user_dal.merge_users(
session,
source_user_id=existing_email_user.user_id,
target_user_id=current_user.user_id,
)
merge_notice = await _build_account_merge_notice(
session,
merged_user=current_user,
source_user_id=existing_email_user.user_id,
source_panel_uuid=source_panel_uuid,
settings=settings,
)
current_user.email = email
current_user.email_verified_at = datetime.now(timezone.utc)
await _sync_panel_identity_for_user(request, current_user)
await session.commit()
final_user_id = int(current_user.user_id)
final_telegram_id = _telegram_id_for_user(current_user)
final_username = current_user.username
final_first_name = current_user.first_name
final_panel_uuid = current_user.panel_user_uuid
if merge_notice:
merge_end_date_raw = merge_notice.get("final_end_date")
merge_end_date = (
datetime.fromisoformat(merge_end_date_raw) if merge_end_date_raw else None
)
await _sync_panel_identity_for_user(
request,
current_user,
expire_at=merge_end_date,
)
# Best-effort cleanup of the removed panel account after the DB merge.
if source_panel_uuid and final_panel_uuid and source_panel_uuid != final_panel_uuid:
subscription_service: SubscriptionService = request.app.get(
"subscription_service"
)
if subscription_service and subscription_service.panel_service:
try:
await subscription_service.panel_service.delete_user_from_panel(
source_panel_uuid,
log_response=False,
)
except Exception as exc:
logger.warning(
"Failed to delete merged source panel user %s: %s",
source_panel_uuid,
exc,
)
email_service: EmailAuthService = request.app.get("email_auth_service")
if email_service and final_email:
email_content = render_account_merged(
settings,
language_code=merge_notice.get("language") or settings.DEFAULT_LANGUAGE,
primary_user_id=merge_notice.get("primary_user_id"),
removed_user_id=merge_notice.get("removed_user_id"),
final_end_date_text=str(
merge_notice.get("final_end_date_text")
or merge_notice.get("final_end_date")
or ""
),
)
try:
await email_service.send_rendered_email(
email=final_email,
content=email_content,
)
except Exception as exc:
logger.warning(
"Failed to send account merge email to %s: %s",
final_email,
exc,
)
except UserMergeConflictError as exc:
await session.rollback()
return _json_error(409, "account_merge_conflict", str(exc))
except Exception:
await session.rollback()
logger.exception("Email account link failed")
return _json_error(500, "link_failed", "Link failed")
if should_notify_email_linked:
try:
from bot.services.notification_service import NotificationService
bot: Bot = request.app["bot"]
notification_service = NotificationService(
bot,
settings,
request.app.get("i18n"),
)
await notification_service.notify_account_email_linked(
user_id=int(final_user_id),
email=final_email,
telegram_id=final_telegram_id,
username=final_username,
first_name=final_first_name,
)
except Exception:
logger.exception("Failed to send account email linked notification")
token = create_webapp_session_token(settings, int(final_user_id))
response_payload: Dict[str, Any] = {"ok": True}
if merge_notice:
response_payload["account_merge"] = merge_notice
response_payload["user_id"] = final_user_id
return _build_webapp_auth_response(settings, response_payload, token=token)
async def account_telegram_link_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
settings: Settings = request.app["settings"]
payload = await _read_json(request)
telegram_user = await _validate_telegram_auth_payload(request, payload)
if not telegram_user:
return _json_error(401, "invalid_auth", "Invalid Telegram auth data")
async_session_factory: sessionmaker = request.app["async_session_factory"]
merge_notice: Optional[Dict[str, Any]] = None
source_panel_uuid: Optional[str] = None
final_user_id = user_id
final_telegram_id: Optional[int] = None
final_email: Optional[str] = None
final_username: Optional[str] = None
final_first_name: Optional[str] = None
final_panel_uuid: Optional[str] = None
should_notify_telegram_linked = False
async with async_session_factory() as session:
try:
current_user_before_link = await user_dal.get_user_by_id(session, user_id)
if not current_user_before_link or current_user_before_link.is_banned:
await session.rollback()
return _json_error(403, "access_denied", "Access denied")
should_notify_telegram_linked = bool(
current_user_before_link.email
) and not _telegram_id_for_user(current_user_before_link)
source_panel_uuid = current_user_before_link.panel_user_uuid
db_user = await _link_telegram_to_user(
request,
session,
current_user_id=user_id,
telegram_user=telegram_user,
settings=settings,
)
if db_user.is_banned:
await session.rollback()
return _json_error(403, "banned", "Access denied")
final_user_id = int(db_user.user_id)
final_telegram_id = _telegram_id_for_user(db_user)
final_email = db_user.email
final_username = db_user.username
final_first_name = db_user.first_name
final_panel_uuid = db_user.panel_user_uuid
if final_user_id != user_id:
merge_notice = await _build_account_merge_notice(
session,
merged_user=db_user,
source_user_id=user_id,
source_panel_uuid=source_panel_uuid,
settings=settings,
)
await session.commit()
if merge_notice:
merge_end_date_raw = merge_notice.get("final_end_date")
merge_end_date = (
datetime.fromisoformat(merge_end_date_raw) if merge_end_date_raw else None
)
await _sync_panel_identity_for_user(
request,
db_user,
expire_at=merge_end_date,
)
# Best-effort cleanup of the removed panel account after the DB merge.
if source_panel_uuid and final_panel_uuid and source_panel_uuid != final_panel_uuid:
subscription_service: SubscriptionService = request.app.get(
"subscription_service"
)
if subscription_service and subscription_service.panel_service:
try:
await subscription_service.panel_service.delete_user_from_panel(
source_panel_uuid,
log_response=False,
)
except Exception as exc:
logger.warning(
"Failed to delete merged source panel user %s: %s",
source_panel_uuid,
exc,
)
email_service: EmailAuthService = request.app.get("email_auth_service")
if email_service and final_email:
email_content = render_account_merged(
settings,
language_code=merge_notice.get("language") or settings.DEFAULT_LANGUAGE,
primary_user_id=merge_notice.get("primary_user_id"),
removed_user_id=merge_notice.get("removed_user_id"),
final_end_date_text=str(
merge_notice.get("final_end_date_text")
or merge_notice.get("final_end_date")
or ""
),
)
try:
await email_service.send_rendered_email(
email=final_email,
content=email_content,
)
except Exception as exc:
logger.warning(
"Failed to send account merge email to %s: %s",
final_email,
exc,
)
except UserMergeConflictError as exc:
await session.rollback()
return _json_error(409, "account_merge_conflict", str(exc))
except Exception:
await session.rollback()
logger.exception("Telegram account link failed")
return _json_error(500, "link_failed", "Link failed")
if should_notify_telegram_linked and final_telegram_id:
try:
from bot.services.notification_service import NotificationService
bot: Bot = request.app["bot"]
notification_service = NotificationService(
bot,
settings,
request.app.get("i18n"),
)
await notification_service.notify_account_telegram_linked(
user_id=int(final_user_id),
email=final_email,
telegram_id=int(final_telegram_id),
username=final_username,
first_name=final_first_name,
)
except Exception:
logger.exception("Failed to send account Telegram linked notification")
token = create_webapp_session_token(settings, int(final_user_id))
response_payload: Dict[str, Any] = {
"ok": True,
"user_id": int(final_user_id),
"telegram_id": final_telegram_id,
}
if merge_notice:
response_payload["account_merge"] = merge_notice
return _build_webapp_auth_response(settings, response_payload, token=token)
async def me_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
settings: Settings = request.app["settings"]
cache_key = redis_key(settings, "cache", "webapp", "me", user_id)
cached = await cache_get_json(settings, cache_key)
if cached:
return web.json_response({"ok": True, **cached})
data = await _build_user_payload(request, user_id)
await cache_set_json(settings, cache_key, data, settings.WEBAPP_ME_CACHE_TTL_SECONDS)
return web.json_response({"ok": True, **data})
async def account_avatar_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user or db_user.is_banned:
await session.rollback()
return _json_error(403, "access_denied", "Access denied")
avatar = await _ensure_cached_telegram_avatar(request, session, db_user)
await session.commit()
if not avatar:
raise web.HTTPNotFound(text="avatar_not_cached")
etag = _telegram_avatar_etag(avatar)
if etag and request.headers.get("If-None-Match") == etag:
return web.Response(status=304, headers={"ETag": etag})
response = web.Response(
body=bytes(avatar.image_bytes),
content_type=avatar.content_type or "image/jpeg",
)
response.headers["Cache-Control"] = "private, max-age=3600"
if etag:
response.headers["ETag"] = etag
return response
async def account_language_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
payload = await _read_json(request)
language_payload, validation_error = _validate_model_payload(WebAppLanguagePayload, payload)
if validation_error:
return validation_error
language = _normalize_language(str(language_payload.language or ""))
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user or db_user.is_banned:
await session.rollback()
return _json_error(403, "access_denied", "Access denied")
if _normalize_language(db_user.language_code or "") != language:
db_user.language_code = language
await session.flush()
await session.commit()
return web.json_response({"ok": True, "language": language})
def _format_webapp_datetime(value: Optional[datetime]) -> Optional[str]:
if not value:
return None
normalized = value if value.tzinfo else value.replace(tzinfo=timezone.utc)
return normalized.strftime("%d.%m.%Y %H:%M")
def _telegram_photo_url_value(telegram_user: Dict[str, Any]) -> Optional[str]:
raw_value = telegram_user.get("photo_url")
if not raw_value:
return None
value = str(raw_value).strip()
return value or None
def _telegram_avatar_is_stale(avatar: Optional[UserTelegramAvatar]) -> bool:
if not avatar or not avatar.updated_at:
return True
updated_at = avatar.updated_at
if updated_at.tzinfo is None:
updated_at = updated_at.replace(tzinfo=timezone.utc)
return (
datetime.now(timezone.utc) - updated_at
).total_seconds() >= WEBAPP_TELEGRAM_AVATAR_REFRESH_SECONDS
def _telegram_avatar_etag(avatar: UserTelegramAvatar) -> str:
digest = hashlib.sha256(bytes(avatar.image_bytes)).hexdigest()[:16]
return f'"tg-avatar-{int(avatar.user_id)}-{digest}"'
def _telegram_avatar_url(avatar: Optional[UserTelegramAvatar]) -> str:
if not avatar:
return ""
updated_at = avatar.updated_at
if updated_at and updated_at.tzinfo is None:
updated_at = updated_at.replace(tzinfo=timezone.utc)
version = (
int(updated_at.timestamp())
if updated_at
else hashlib.sha256(bytes(avatar.image_bytes)).hexdigest()[:8]
)
return f"/api/account/avatar?v={version}"
def _select_compact_telegram_photo_size(sizes: List[Any]) -> Optional[Any]:
if not sizes:
return None
suitable = [size for size in sizes if int(getattr(size, "width", 0) or 0) >= 160]
candidates = suitable or sizes
return min(
candidates,
key=lambda size: (
int(getattr(size, "file_size", 0) or 0)
or int(getattr(size, "width", 0) or 0) * int(getattr(size, "height", 0) or 0),
int(getattr(size, "width", 0) or 0),
),
)
def _telegram_file_content_type(file_path: Optional[str]) -> str:
path = str(file_path or "").lower()
if path.endswith(".png"):
return "image/png"
if path.endswith(".webp"):
return "image/webp"
return "image/jpeg"
async def _fetch_compact_telegram_avatar(
bot: Bot, telegram_id: int
) -> Optional[Tuple[bytes, str, Optional[str]]]:
photos = await bot.get_user_profile_photos(user_id=telegram_id, limit=1)
if not photos or not photos.photos:
return None
photo_size = _select_compact_telegram_photo_size(list(photos.photos[0] or []))
if not photo_size:
return None
file_info = await bot.get_file(photo_size.file_id)
destination = io.BytesIO()
await bot.download_file(file_info.file_path, destination=destination)
body = destination.getvalue()
if not body or len(body) > WEBAPP_TELEGRAM_AVATAR_MAX_BYTES:
return None
return (
body,
_telegram_file_content_type(file_info.file_path),
getattr(photo_size, "file_unique_id", None),
)
async def _ensure_cached_telegram_avatar(
request: web.Request,
session: AsyncSession,
user: User,
) -> Optional[UserTelegramAvatar]:
avatar = await user_dal.get_user_telegram_avatar(session, int(user.user_id))
telegram_id = _telegram_id_for_user(user)
if not telegram_id:
return avatar
if avatar and not _telegram_avatar_is_stale(avatar):
return avatar
bot: Bot = request.app["bot"]
try:
fetched = await asyncio.wait_for(
_fetch_compact_telegram_avatar(bot, int(telegram_id)),
timeout=WEBAPP_TELEGRAM_AVATAR_FETCH_TIMEOUT_SECONDS,
)
except Exception as exc:
logger.info("Failed to refresh Telegram avatar for user %s: %s", user.user_id, exc)
return avatar
if not fetched:
return avatar
body, content_type, file_unique_id = fetched
return await user_dal.upsert_user_telegram_avatar(
session,
user_id=int(user.user_id),
file_unique_id=file_unique_id,
content_type=content_type,
image_bytes=body,
)
-59
View File
@@ -1,59 +0,0 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
def create_subscription_webapp_application(
dp: Dispatcher,
bot: Bot,
settings: Settings,
async_session_factory: sessionmaker,
) -> web.Application:
app = web.Application(
middlewares=[
_security_headers_middleware,
_csrf_protection_middleware,
admin_auth_middleware,
]
)
app["bot"] = bot
app["dp"] = dp
app["settings"] = settings
app["async_session_factory"] = async_session_factory
app["i18n"] = dp.get("i18n_instance")
app["email_auth_service"] = EmailAuthService(settings)
app["webapp_logo_cache"] = None
app["webapp_logo_cache_lock"] = asyncio.Lock()
app["webapp_settings_cache"] = {"ts": 0.0, "data": {}}
app["webapp_rate_limit_buckets"] = {}
app["webapp_rate_limit_lock"] = asyncio.Lock()
async def _startup(app_obj: web.Application) -> None:
await _ensure_shared_http_session()
await _warm_webapp_logo_cache(app_obj)
await _warm_webapp_animated_emoji_cache(app_obj)
async def _shutdown(app_obj: web.Application) -> None:
await _close_shared_http_session()
app.on_startup.append(_startup)
app.on_shutdown.append(_shutdown)
for key in (
"subscription_service",
"yookassa_service",
"freekassa_service",
"cryptopay_service",
"platega_service",
"severpay_service",
"promo_code_service",
"referral_service",
"panel_service",
):
if hasattr(dp, "workflow_data") and key in dp.workflow_data: # type: ignore[attr-defined]
app[key] = dp.workflow_data[key] # type: ignore[index]
if hasattr(dp, "workflow_data") and "bot_username" in dp.workflow_data: # type: ignore[attr-defined]
app["bot_username"] = dp.workflow_data["bot_username"] # type: ignore[index]
setup_subscription_webapp_routes(app)
return app
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-156
View File
@@ -1,156 +0,0 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
async def _read_json(request: web.Request) -> Dict[str, Any]:
try:
data = await request.json()
return data if isinstance(data, dict) else {}
except Exception:
return {}
def _json_error(status: int, code: str, message: str) -> web.Response:
return web.json_response(
{"ok": False, "error": code, "message": message},
status=status,
)
def _validation_error_response(exc: ValidationError) -> web.Response:
for error in exc.errors():
loc = error.get("loc") or ()
field = str(loc[0]) if loc else ""
error_type = str(error.get("type") or "")
message = str(error.get("msg") or "")
message_lower = message.lower()
if field == "email":
if (
"too_long" in message_lower
or "too long" in message_lower
or error_type == "string_too_long"
):
return _json_error(400, "email_too_long", "Email is too long")
return _json_error(400, "invalid_email", "Invalid email")
if field in {"description", "comment", "note"} and error_type == "string_too_long":
return _json_error(400, f"{field}_too_long", f"{field.capitalize()} is too long")
if error_type == "string_too_long":
return _json_error(400, "text_too_long", "Text is too long")
return _json_error(400, "invalid_request", "Invalid request")
def _validate_model_payload(
model_cls: type[BaseModel],
payload: Dict[str, Any],
) -> tuple[Optional[BaseModel], Optional[web.Response]]:
try:
return model_cls.model_validate(payload), None
except ValidationError as exc:
return None, _validation_error_response(exc)
def _normalize_language(lang: Optional[str]) -> str:
value = (lang or "ru").split("-")[0].lower()
return value if value in {"ru", "en"} else "ru"
def _format_remaining(seconds: int, lang: str) -> str:
if seconds <= 0:
if lang == "en":
return "Subscription inactive"
return "Подписка не активна"
days, rem = divmod(seconds, 86400)
hours, rem = divmod(rem, 3600)
minutes = rem // 60
if lang == "en":
if days > 0:
return f"{days} d. {hours} h."
if hours > 0:
return f"{hours} h. {minutes} min."
return f"{max(1, minutes)} min."
if days > 0:
return f"{days} д. {hours} ч."
if hours > 0:
return f"{hours} ч. {minutes} мин."
return f"{max(1, minutes)} мин."
def _coerce_int_or_none(value: Optional[Any]) -> Optional[int]:
if value is None:
return None
try:
return int(value)
except (TypeError, ValueError):
return None
def _format_bytes(value: Optional[Any], *, zero_as_unlimited: bool = False) -> str:
if value is None:
return "N/A"
try:
size = float(value)
except (TypeError, ValueError):
return str(value)
if size <= 0 and zero_as_unlimited:
return ""
if size <= 0:
size = 0
units = ["B", "KB", "MB", "GB", "TB"]
index = 0
while size >= 1024 and index < len(units) - 1:
size /= 1024
index += 1
return f"{size:.2f} {units[index]}"
def _format_months_title(months: int, lang: str) -> str:
if lang == "en":
if months == 1:
return "1 month"
return f"{months} months"
if months == 1:
return "1 месяц"
if 2 <= months <= 4:
return f"{months} месяца"
return f"{months} месяцев"
def _format_number_for_payload(value: Any) -> str:
numeric = float(value or 0)
return str(int(numeric)) if numeric.is_integer() else f"{numeric:g}"
def _format_traffic_title(traffic_gb: float, lang: str) -> str:
return f"{_format_number_for_payload(traffic_gb)} GB"
def _traffic_payment_description(traffic_gb: float, lang: str) -> str:
if lang == "en":
return f"Traffic package {_format_traffic_title(traffic_gb, lang)}"
return f"Пакет трафика {_format_traffic_title(traffic_gb, lang)}"
def _hwid_devices_payment_description(device_count: int, lang: str) -> str:
if lang == "en":
return f"HWID device package +{device_count}"
return f"Докупка устройств HWID +{device_count}"
def _resolve_numeric_option_key(options: Dict[Any, Any], target: float) -> Optional[Any]:
for key in options:
try:
if abs(float(key) - float(target)) < 0.000001:
return key
except (TypeError, ValueError):
continue
return None
def _payment_description(months: int, lang: str) -> str:
if lang == "en":
return f"Subscription for {_format_months_title(months, lang)}"
return f"Подписка на {_format_months_title(months, lang)}"
-169
View File
@@ -1,169 +0,0 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
async def devices_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
settings: Settings = request.app["settings"]
if not settings.MY_DEVICES_SECTION_ENABLED:
return _json_error(404, "devices_disabled", "Devices section is disabled")
async_session_factory: sessionmaker = request.app["async_session_factory"]
subscription_service: SubscriptionService = request.app["subscription_service"]
async with async_session_factory() as session:
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user or db_user.is_banned:
return _json_error(403, "access_denied", "Access denied")
active = await subscription_service.get_active_subscription_details(session, user_id)
panel_user_uuid = active.get("user_id") if active else None
if not panel_user_uuid:
return _json_error(400, "subscription_not_active", "Subscription is not active")
panel_service = getattr(subscription_service, "panel_service", None)
if not panel_service:
return _json_error(503, "panel_unavailable", "Panel service unavailable")
try:
devices_response = await panel_service.get_user_devices(panel_user_uuid)
except Exception:
logger.exception("Failed to load WebApp devices for user %s", user_id)
return _json_error(502, "devices_load_failed", "Failed to load devices")
devices = _normalize_devices_response(devices_response)
max_devices = _coerce_int_or_none(active.get("max_devices")) if active else None
return web.json_response(
{
"ok": True,
"enabled": True,
"current_devices": len(devices),
"max_devices": max_devices,
"max_devices_label": _format_devices_limit(max_devices),
"devices": [
_serialize_device(device, index) for index, device in enumerate(devices, start=1)
],
}
)
async def disconnect_device_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
rate_limit_response = await _enforce_webapp_rate_limit(
request,
user_id=user_id,
action="devices_disconnect",
)
if rate_limit_response:
return rate_limit_response
settings: Settings = request.app["settings"]
if not settings.MY_DEVICES_SECTION_ENABLED:
return _json_error(404, "devices_disabled", "Devices section is disabled")
payload = await _read_json(request)
disconnect_payload, validation_error = _validate_model_payload(
WebAppDeviceDisconnectPayload, payload
)
if validation_error:
return validation_error
token = str(disconnect_payload.token or "").strip()
async_session_factory: sessionmaker = request.app["async_session_factory"]
subscription_service: SubscriptionService = request.app["subscription_service"]
async with async_session_factory() as session:
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user or db_user.is_banned:
return _json_error(403, "access_denied", "Access denied")
active = await subscription_service.get_active_subscription_details(session, user_id)
panel_user_uuid = active.get("user_id") if active else None
if not panel_user_uuid:
return _json_error(400, "subscription_not_active", "Subscription is not active")
panel_service = getattr(subscription_service, "panel_service", None)
if not panel_service:
return _json_error(503, "panel_unavailable", "Panel service unavailable")
try:
devices_response = await panel_service.get_user_devices(panel_user_uuid)
except Exception:
logger.exception("Failed to load WebApp devices before disconnect for user %s", user_id)
return _json_error(502, "devices_load_failed", "Failed to load devices")
target_hwid = None
for device in _normalize_devices_response(devices_response):
hwid = str(device.get("hwid") or "").strip()
if hwid and hmac.compare_digest(_device_hwid_token(hwid), token):
target_hwid = hwid
break
if not target_hwid:
return _json_error(404, "device_not_found", "Device not found")
success = await panel_service.disconnect_device(panel_user_uuid, target_hwid)
if not success:
return _json_error(502, "device_disconnect_failed", "Failed to disconnect device")
await session.commit()
return web.json_response({"ok": True})
def _device_hwid_token(hwid: str) -> str:
return hashlib.sha256(str(hwid or "").encode()).hexdigest()[:32]
def _shorten_hwid_for_display(hwid: Optional[str], max_length: int = 24) -> str:
value = str(hwid or "").strip()
if len(value) <= max_length:
return value
return f"{value[:8]}...{value[-6:]}"
def _normalize_devices_response(devices_response: Any) -> List[Dict[str, Any]]:
if isinstance(devices_response, dict):
devices = devices_response.get("devices") or []
else:
devices = devices_response or []
if not isinstance(devices, list):
return []
return [device for device in devices if isinstance(device, dict)]
def _format_devices_limit(max_devices: Optional[int]) -> str:
if max_devices in (None, 0):
return "Unlimited"
return str(max_devices)
def _format_device_datetime(value: Any) -> str:
if not value:
return ""
text = str(value)
try:
normalized = datetime.fromisoformat(text.replace("Z", "+00:00"))
return normalized.strftime("%d.%m.%Y %H:%M")
except Exception:
return text
def _serialize_device(device: Dict[str, Any], index: int) -> Dict[str, Any]:
hwid = str(device.get("hwid") or "").strip()
model = str(device.get("deviceModel") or "").strip()
platform = str(device.get("platform") or "").strip()
os_version = str(device.get("osVersion") or "").strip()
user_agent = str(device.get("userAgent") or "").strip()
display_name = model or platform or f"Device {index}"
platform_label = " ".join(part for part in (platform, os_version) if part).strip()
return {
"index": index,
"display_name": display_name,
"platform": platform,
"os_version": os_version,
"platform_label": platform_label,
"user_agent": user_agent,
"created_at": device.get("createdAt"),
"created_at_text": _format_device_datetime(device.get("createdAt")),
"hwid_short": _shorten_hwid_for_display(hwid),
"token": _device_hwid_token(hwid) if hwid else "",
"can_disconnect": bool(hwid),
}
-59
View File
@@ -1,59 +0,0 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
class WebAppEmailPayload(BaseModel):
model_config = ConfigDict(extra="ignore")
email: EmailStr
@field_validator("email")
@classmethod
def _normalize_and_limit_email(cls, value: EmailStr) -> str:
normalized = normalize_email(str(value))
if len(normalized) > 254:
raise ValueError("email_too_long")
return normalized
class WebAppEmailCodePayload(WebAppEmailPayload):
code: str = ""
class WebAppEmailMagicPayload(BaseModel):
model_config = ConfigDict(extra="ignore")
token: constr(min_length=8, max_length=512)
class WebAppPaymentCreatePayload(BaseModel):
model_config = ConfigDict(extra="ignore")
method: str = ""
months: Any = None
traffic_gb: Any = None
device_count: Any = None
tariff_key: Optional[constr(max_length=128)] = None
sale_mode: Optional[constr(max_length=64)] = None
description: Optional[constr(max_length=4096)] = None
comment: Optional[constr(max_length=4096)] = None
note: Optional[constr(max_length=4096)] = None
class WebAppTariffChangePayload(BaseModel):
model_config = ConfigDict(extra="ignore")
tariff_key: constr(min_length=1, max_length=128)
mode: constr(min_length=1, max_length=64)
class WebAppLanguagePayload(BaseModel):
model_config = ConfigDict(extra="ignore")
language: constr(min_length=2, max_length=16)
class WebAppDeviceDisconnectPayload(BaseModel):
model_config = ConfigDict(extra="ignore")
token: constr(min_length=8, max_length=128)
-65
View File
@@ -1,65 +0,0 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
def setup_subscription_webapp_routes(app: web.Application) -> None:
app.router.add_get("/", index_route)
app.router.add_get("/home", index_route)
app.router.add_get("/invite", index_route)
app.router.add_get("/devices", index_route)
app.router.add_get("/settings", index_route)
app.router.add_get("/admin", index_route)
app.router.add_get(
(
"/admin/{section:stats|users|payments|promos|ads|broadcast|logs|tariffs|"
"appearance|settings}"
),
index_route,
)
app.router.add_get("/admin/users/{user_id:-?[0-9]+}", index_route)
app.router.add_get("/auth/telegram/start", telegram_oauth_start_route)
app.router.add_get("/auth/telegram/callback", telegram_oauth_callback_route)
app.router.add_get("/health", health_route)
app.router.add_get(WEBAPP_LOGO_PROXY_PATH, webapp_logo_route)
app.router.add_get(
rf"{WEBAPP_UPLOADED_LOGO_PATH}/{{filename:[A-Za-z0-9_.-]+}}",
webapp_uploaded_logo_route,
)
app.router.add_get(
rf"{WEBAPP_FAVICON_PATH}/{{digest:[0-9a-f]{{16}}}}/{{filename:[A-Za-z0-9_.-]+}}",
webapp_favicon_route,
)
app.router.add_get(
r"/webapp-emoji/{codepoints:[0-9a-f_]+}/512.{ext:gif|webp}",
webapp_animated_emoji_route,
)
app.router.add_get("/subscription_webapp.css", css_asset_route)
app.router.add_get(r"/webapp-theme-css/{path:.+}", theme_css_asset_route)
app.router.add_get(r"/webapp-theme-assets/{path:.+}", theme_asset_route)
app.router.add_get("/subscription_webapp.min.{asset_hash}.js", js_asset_route)
app.router.add_get("/subscription_webapp.js", js_asset_route)
app.router.add_post("/api/auth/telegram/nonce", telegram_oauth_nonce_route)
app.router.add_post("/api/auth/token", auth_token_route)
app.router.add_post("/api/auth/email/request", email_auth_request_route)
app.router.add_post("/api/auth/email/verify", email_auth_verify_route)
app.router.add_post("/api/auth/email/magic", email_auth_magic_route)
app.router.add_post("/api/auth/logout", logout_route)
app.router.add_get("/api/bootstrap", bootstrap_route)
app.router.add_get("/api/me", me_route)
app.router.add_get("/api/account/avatar", account_avatar_route)
app.router.add_post("/api/account/language", account_language_route)
app.router.add_post("/api/account/email/request", account_email_request_route)
app.router.add_post("/api/account/email/verify", account_email_verify_route)
app.router.add_post("/api/account/telegram/link", account_telegram_link_route)
app.router.add_post("/api/promo/apply", apply_promo_route)
app.router.add_post("/api/trial/activate", activate_trial_route)
app.router.add_get("/api/devices", devices_route)
app.router.add_post("/api/devices/disconnect", disconnect_device_route)
app.router.add_get("/api/devices/topup-options", device_topup_options_route)
app.router.add_get("/api/tariffs/topup-options", tariff_topup_options_route)
app.router.add_get("/api/tariffs/change-options", tariff_change_options_route)
app.router.add_post("/api/tariffs/change", tariff_change_route)
app.router.add_post("/api/tariffs/change-payment", tariff_change_payment_route)
app.router.add_post("/api/payments", create_payment_route)
app.router.add_get("/api/payments/{payment_id}", payment_status_route)
setup_admin_routes(app)
-627
View File
@@ -1,627 +0,0 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
from config.webapp_themes_config import public_themes_catalog_payload
async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, Any]:
settings: Settings = request.app["settings"]
async_session_factory: sessionmaker = request.app["async_session_factory"]
subscription_service: SubscriptionService = request.app["subscription_service"]
cached = _get_cached_webapp_settings(request)
async with async_session_factory() as session:
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user or db_user.is_banned:
raise web.HTTPForbidden(
text=json.dumps({"ok": False, "error": "access_denied"}),
content_type="application/json",
)
active = await subscription_service.get_active_subscription_details(session, user_id)
referral_code = await user_dal.ensure_referral_code(session, db_user)
referral_service: Optional[ReferralService] = request.app.get("referral_service")
bot_username = request.app.get("bot_username") or ""
referral_link = None
if referral_service and bot_username:
referral_link = await referral_service.generate_referral_link(
session,
bot_username,
user_id,
)
webapp_referral_link = _build_webapp_referral_link(
request.app["settings"].SUBSCRIPTION_MINI_APP_URL,
referral_code,
)
referral_stats = (
await referral_service.get_referral_stats(session, user_id)
if referral_service
else {"invited_count": 0, "purchased_count": 0}
)
local_sub = (
await subscription_dal.get_active_subscription_by_user_id(
session,
user_id,
db_user.panel_user_uuid,
)
if db_user.panel_user_uuid
else None
)
trial_available = bool(
settings.TRIAL_ENABLED
and settings.TRIAL_DURATION_DAYS > 0
and not await subscription_service.has_had_any_subscription(session, user_id)
)
avatar = await _ensure_cached_telegram_avatar(request, session, db_user)
try:
await session.commit()
except Exception:
await session.rollback()
lang = _normalize_language(db_user.language_code or settings.DEFAULT_LANGUAGE)
admin_ids = {int(x) for x in (settings.ADMIN_IDS or [])}
is_admin = bool(db_user.telegram_id and int(db_user.telegram_id) in admin_ids)
return {
"user": {
"id": user_id,
"username": db_user.username,
"email": db_user.email,
"email_verified": bool(db_user.email_verified_at),
"telegram_id": db_user.telegram_id,
"telegram_linked": bool(_telegram_id_for_user(db_user)),
"telegram_photo_url": _telegram_avatar_url(avatar),
"first_name": db_user.first_name,
"language_code": lang,
"is_admin": is_admin,
},
"subscription": _serialize_subscription(settings, active, local_sub, lang),
"referral": {
"code": referral_code,
"bot_link": referral_link,
"webapp_link": webapp_referral_link,
"invited_count": referral_stats.get("invited_count", 0),
"purchased_count": referral_stats.get("purchased_count", 0),
"welcome_bonus_days": max(
0, int(getattr(settings, "REFERRAL_WELCOME_BONUS_DAYS", 0) or 0)
),
"one_bonus_per_referee": bool(
getattr(settings, "REFERRAL_ONE_BONUS_PER_REFEREE", False)
),
"bonus_details": _serialize_referral_bonus_details(settings, lang),
},
"plans": _serialize_plans(
settings,
lang,
subscription_options=cached["subscription_options"],
stars_subscription_options=cached["stars_subscription_options"],
traffic_packages=cached["traffic_packages"],
stars_traffic_packages=cached["stars_traffic_packages"],
),
"payment_methods": _serialize_payment_methods(settings, request.app),
"themes_catalog": public_themes_catalog_payload(
settings.webapp_themes_catalog,
settings.WEBAPP_PRIMARY_COLOR or "#00fe7a",
enabled_only=True,
),
"settings": {
"support_url": settings.SUPPORT_LINK,
"traffic_mode": bool(settings.traffic_sale_mode),
"my_devices_enabled": bool(settings.MY_DEVICES_SECTION_ENABLED),
"user_hwid_device_limit": (
int(settings.USER_HWID_DEVICE_LIMIT)
if settings.USER_HWID_DEVICE_LIMIT is not None
else None
),
"trial_enabled": bool(settings.TRIAL_ENABLED),
"trial_available": trial_available,
"trial_duration_days": int(settings.TRIAL_DURATION_DAYS or 0),
"trial_traffic_limit_gb": float(settings.TRIAL_TRAFFIC_LIMIT_GB or 0),
"trial_traffic_strategy": getattr(settings, "TRIAL_TRAFFIC_STRATEGY", "NO_RESET"),
"email_auth_enabled": settings.email_auth_configured,
},
}
def _serialize_referral_bonus_details(settings: Settings, lang: str) -> List[Dict[str, Any]]:
if getattr(settings, "traffic_sale_mode", False):
return []
details: List[Dict[str, Any]] = []
for months, _price in sorted(settings.subscription_options.items()):
inviter_days = settings.referral_bonus_inviter.get(months)
friend_days = settings.referral_bonus_referee.get(months)
if inviter_days is None and friend_days is None:
continue
details.append(
{
"months": int(months),
"title": _format_months_title(int(months), lang),
"inviter_days": int(inviter_days or 0),
"friend_days": int(friend_days or 0),
}
)
return details
def _build_webapp_referral_link(
base_url: Optional[str],
referral_code: Optional[str],
) -> Optional[str]:
if not base_url or not referral_code:
return None
parts = urlsplit(base_url)
query = dict(parse_qsl(parts.query, keep_blank_values=True))
query["ref"] = f"u{referral_code}"
return urlunsplit(
(
parts.scheme,
parts.netloc,
parts.path or "/",
urlencode(query),
parts.fragment,
)
)
def _serialize_subscription(
settings: Settings,
active: Optional[Dict[str, Any]],
local_sub: Optional[Any],
lang: str,
) -> Dict[str, Any]:
if not active:
return {
"active": False,
"status": "INACTIVE",
"remaining_text": _format_remaining(0, lang),
"days_left": 0,
"config_link": None,
"connect_url": None,
}
end_date = active.get("end_date")
if end_date and end_date.tzinfo is None:
end_date = end_date.replace(tzinfo=timezone.utc)
seconds_left = 0
if end_date:
seconds_left = max(
0,
int((end_date - datetime.now(timezone.utc)).total_seconds()),
)
can_topup_regular_traffic = False
can_topup_premium_traffic = False
can_topup_traffic = False
can_topup_devices = False
if settings.tariffs_config and active.get("tariff_key"):
try:
tariff = settings.tariffs_config.require(str(active.get("tariff_key")))
packages = settings.tariffs_config.topup_packages_for(tariff)
can_topup_regular_traffic = bool(packages and packages.has_any())
can_topup_premium_traffic = bool(
tariff.premium_squad_uuids
and tariff.premium_topup_packages
and tariff.premium_topup_packages.has_any()
)
can_topup_traffic = bool(can_topup_regular_traffic or can_topup_premium_traffic)
# max_devices == 0 means unlimited — top-up is pointless in that case.
can_topup_devices = bool(
tariff.has_hwid_device_packages()
and _coerce_int_or_none(active.get("max_devices")) != 0
)
except Exception:
can_topup_regular_traffic = False
can_topup_premium_traffic = False
can_topup_traffic = False
can_topup_devices = False
return {
"active": seconds_left > 0,
"status": active.get("status_from_panel") or "UNKNOWN",
"end_date": end_date.isoformat() if end_date else None,
"end_date_text": end_date.strftime("%d.%m.%Y %H:%M") if end_date else "N/A",
"days_left": seconds_left // 86400,
"remaining_text": _format_remaining(seconds_left, lang),
"config_link": active.get("config_link"),
"connect_url": active.get("connect_button_url") or active.get("config_link"),
"traffic_limit": _format_bytes(active.get("traffic_limit_bytes"), zero_as_unlimited=True),
"traffic_used": _format_bytes(active.get("traffic_used_bytes")),
"traffic_limit_bytes": _coerce_int_or_none(active.get("traffic_limit_bytes")),
"traffic_used_bytes": _coerce_int_or_none(active.get("traffic_used_bytes")),
"tariff_key": active.get("tariff_key"),
"tariff_name": active.get("tariff_name"),
"tariff_description": active.get("tariff_description"),
"premium_title": active.get("premium_title"),
"billing_model": active.get("billing_model"),
"traffic_limit_strategy": str(active.get("traffic_limit_strategy") or ""),
"tier_baseline_bytes": _coerce_int_or_none(active.get("tier_baseline_bytes")),
"topup_balance_bytes": _coerce_int_or_none(active.get("topup_balance_bytes")),
"premium_limit": _format_bytes(active.get("premium_limit_bytes"), zero_as_unlimited=True),
"premium_used": _format_bytes(active.get("premium_used_bytes")),
"premium_limit_bytes": _coerce_int_or_none(active.get("premium_limit_bytes")),
"premium_used_bytes": _coerce_int_or_none(active.get("premium_used_bytes")),
"premium_baseline_bytes": _coerce_int_or_none(active.get("premium_baseline_bytes")),
"premium_topup_balance_bytes": _coerce_int_or_none(
active.get("premium_topup_balance_bytes")
),
"premium_topup_used_bytes": _coerce_int_or_none(active.get("premium_topup_used_bytes")),
"premium_bonus_bytes": _coerce_int_or_none(active.get("premium_bonus_bytes")) or 0,
"regular_bonus_bytes": _coerce_int_or_none(active.get("regular_bonus_bytes")) or 0,
"regular_unlimited_override": bool(active.get("regular_unlimited_override")),
"premium_unlimited_override": bool(active.get("premium_unlimited_override")),
"premium_is_limited": bool(active.get("premium_is_limited")),
"premium_squad_labels": list(active.get("premium_squad_labels") or []),
"premium_node_labels": list(active.get("premium_node_labels") or []),
"can_topup_traffic": can_topup_traffic,
"can_topup_regular_traffic": can_topup_regular_traffic,
"can_topup_premium_traffic": can_topup_premium_traffic,
"can_topup_devices": can_topup_devices,
"period_start_at": active.get("period_start_at").isoformat()
if active.get("period_start_at")
else None,
"is_throttled": bool(active.get("is_throttled")),
"max_devices": _coerce_int_or_none(active.get("max_devices")),
"base_hwid_device_limit": _coerce_int_or_none(active.get("base_hwid_device_limit")),
"extra_hwid_devices": _coerce_int_or_none(active.get("extra_hwid_devices")) or 0,
"auto_renew_enabled": bool(getattr(local_sub, "auto_renew_enabled", False)),
"provider": getattr(local_sub, "provider", None),
}
def _serialize_plans(
settings: Settings,
lang: str,
*,
subscription_options: Optional[Dict[int, float]] = None,
stars_subscription_options: Optional[Dict[int, int]] = None,
traffic_packages: Optional[Dict[float, float]] = None,
stars_traffic_packages: Optional[Dict[float, int]] = None,
) -> List[Dict[str, Any]]:
tariffs_config = settings.tariffs_config
if tariffs_config:
plans: List[Dict[str, Any]] = []
for tariff in tariffs_config.enabled_tariffs:
common = {
"tariff_key": tariff.key,
"tariff_name": tariff.name(lang),
"billing_model": tariff.billing_model,
"description": tariff.description(lang),
"squad_uuids": tariff.squad_uuids,
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
"hwid_device_limit": tariff.hwid_device_limit,
"hwid_device_packages": _serialize_hwid_device_packages(
settings,
tariff,
tariff.hwid_device_packages,
lang,
),
}
if tariff.billing_model == "period":
for months in sorted(tariff.enabled_periods):
price = tariff.period_price(int(months), "rub")
stars_price = tariff.period_price(int(months), "stars")
if price is None and (stars_price is None or int(stars_price) <= 0):
continue
plan = {
**common,
"id": f"{tariff.key}:period:{int(months)}",
"sale_mode": "subscription",
"months": int(months),
"price": float(price or 0),
"title": tariff.name(lang),
"subtitle": _format_months_title(int(months), lang),
"monthly_gb": tariff.monthly_gb,
}
if stars_price is not None and int(stars_price) > 0:
plan["stars_price"] = int(stars_price)
plans.append(plan)
else:
rub_packages = {
float(package.gb): float(package.price)
for package in (tariff.traffic_packages.rub if tariff.traffic_packages else [])
}
stars_packages = {
float(package.gb): int(float(package.price))
for package in (
tariff.traffic_packages.stars if tariff.traffic_packages else []
)
}
for traffic_gb in sorted(set(rub_packages) | set(stars_packages)):
price = rub_packages.get(traffic_gb)
stars_price = stars_packages.get(traffic_gb)
if price is None and (stars_price is None or int(stars_price) <= 0):
continue
traffic_value = float(traffic_gb)
plan = {
**common,
"id": f"{tariff.key}:traffic:{_format_number_for_payload(traffic_value)}",
"sale_mode": "traffic_package",
"months": int(traffic_value)
if traffic_value.is_integer()
else traffic_value,
"traffic_gb": traffic_value,
"price": float(price or 0),
"title": tariff.name(lang),
"subtitle": _format_traffic_title(traffic_value, lang),
}
if stars_price is not None and int(stars_price) > 0:
plan["stars_price"] = int(stars_price)
plans.append(plan)
return plans
if getattr(settings, "traffic_sale_mode", False):
active_traffic_packages = traffic_packages or settings.traffic_packages
active_stars_traffic_packages = stars_traffic_packages or settings.stars_traffic_packages
traffic_units = sorted(set(active_traffic_packages) | set(active_stars_traffic_packages))
plans: List[Dict[str, Any]] = []
for traffic_gb in traffic_units:
price = active_traffic_packages.get(traffic_gb)
stars_price = active_stars_traffic_packages.get(traffic_gb)
if price is None and (stars_price is None or int(stars_price) <= 0):
continue
traffic_value = float(traffic_gb)
plan = {
"months": int(traffic_value) if traffic_value.is_integer() else traffic_value,
"traffic_gb": traffic_value,
"price": float(price or 0),
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
"title": _format_traffic_title(traffic_value, lang),
"sale_mode": "traffic",
}
if stars_price is not None and int(stars_price) > 0:
plan["stars_price"] = int(stars_price)
plans.append(plan)
return plans
active_subscription_options = subscription_options or settings.subscription_options
active_stars_subscription_options = (
stars_subscription_options or settings.stars_subscription_options
)
plans: List[Dict[str, Any]] = []
for months in sorted(set(active_subscription_options) | set(active_stars_subscription_options)):
price = active_subscription_options.get(months)
stars_price = active_stars_subscription_options.get(months)
if price is None and (stars_price is None or int(stars_price) <= 0):
continue
plan = {
"months": int(months),
"price": float(price or 0),
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
"title": _format_months_title(int(months), lang),
"sale_mode": "subscription",
}
if stars_price is not None and int(stars_price) > 0:
plan["stars_price"] = int(stars_price)
plans.append(plan)
return plans
def _traffic_percent(used: Optional[int], limit: Optional[int]) -> int:
used_val = int(used or 0)
limit_val = int(limit or 0)
if limit_val <= 0:
return 0
return max(0, min(100, round((used_val / limit_val) * 100)))
def _serialize_topup_packages(
settings: Settings,
tariff: Any,
packages: Optional[Any],
lang: str,
*,
sale_mode: str = "topup",
title_prefix: str = "",
) -> List[Dict[str, Any]]:
rub_packages = {
float(package.gb): float(package.price) for package in (packages.rub if packages else [])
}
stars_packages = {
float(package.gb): int(float(package.price))
for package in (packages.stars if packages else [])
}
plans: List[Dict[str, Any]] = []
for traffic_gb in sorted(set(rub_packages) | set(stars_packages)):
price = rub_packages.get(traffic_gb)
stars_price = stars_packages.get(traffic_gb)
if price is None and (stars_price is None or int(stars_price) <= 0):
continue
traffic_value = float(traffic_gb)
plan: Dict[str, Any] = {
"id": f"{tariff.key}:{sale_mode}:{_format_number_for_payload(traffic_value)}",
"tariff_key": tariff.key,
"tariff_name": tariff.name(lang),
"billing_model": tariff.billing_model,
"sale_mode": sale_mode,
"months": int(traffic_value) if traffic_value.is_integer() else traffic_value,
"traffic_gb": traffic_value,
"price": float(price or 0),
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
"title": f"{title_prefix}{_format_traffic_title(traffic_value, lang)}",
"subtitle": tariff.premium_name(lang)
if sale_mode == "premium_topup"
else tariff.name(lang),
}
if stars_price is not None and int(stars_price) > 0:
plan["stars_price"] = int(stars_price)
plans.append(plan)
return plans
def _serialize_hwid_device_packages(
settings: Settings,
tariff: Any,
packages: Optional[Any],
lang: str,
) -> List[Dict[str, Any]]:
rub_packages = {
int(package.count): float(package.price) for package in (packages.rub if packages else [])
}
stars_packages = {
int(package.count): int(float(package.price))
for package in (packages.stars if packages else [])
}
plans: List[Dict[str, Any]] = []
for count in sorted(set(rub_packages) | set(stars_packages)):
price = rub_packages.get(count)
stars_price = stars_packages.get(count)
if price is None and (stars_price is None or int(stars_price) <= 0):
continue
plan: Dict[str, Any] = {
"id": f"{tariff.key}:hwid:{count}",
"tariff_key": tariff.key,
"tariff_name": tariff.name(lang),
"billing_model": tariff.billing_model,
"sale_mode": "hwid_devices",
"months": int(count),
"device_count": int(count),
"price": float(price or 0),
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
"title": f"+{count}",
"subtitle": tariff.name(lang),
}
if stars_price is not None and int(stars_price) > 0:
plan["stars_price"] = int(stars_price)
plans.append(plan)
return plans
def _serialize_tariff_change_target(
settings: Settings,
config: Any,
tariff: Any,
options: Dict[str, Any],
lang: str,
) -> Dict[str, Any]:
actions: List[Dict[str, Any]] = []
mode = str(options.get("mode") or "")
if mode == "period_to_period":
actions.append(
{
"mode": "recalc_days",
"kind": "free",
"title": "recalc_days",
"days_after": int(options.get("recalc_days") or 0),
"remaining_days": int(options.get("remaining_days") or 0),
}
)
paid_diff = float(options.get("paid_diff_rub") or 0)
if paid_diff > 0:
actions.append(
{
"mode": "paid_diff",
"kind": "payment",
"title": "paid_diff",
"price": paid_diff,
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
}
)
elif mode == "period_to_traffic":
actions.append(
{
"mode": "convert_days_to_gb",
"kind": "free",
"title": "convert_days_to_gb",
"converted_gb": float(options.get("converted_gb") or 0),
"remaining_days": int(options.get("remaining_days") or 0),
}
)
actions.extend(
{
"mode": "buy_package",
"kind": "payment",
"title": f"+{package.gb:g} GB",
"traffic_gb": float(package.gb),
"price": float(package.price),
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
}
for package in (tariff.traffic_packages.rub if tariff.traffic_packages else [])
)
else:
for months in tariff.enabled_periods:
price = tariff.period_price(int(months), "rub")
if price:
actions.append(
{
"mode": "buy_period",
"kind": "payment",
"months": int(months),
"title": _format_months_title(int(months), lang),
"price": float(price),
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
}
)
return {
"tariff_key": tariff.key,
"title": tariff.name(lang),
"description": tariff.description(lang),
"billing_model": tariff.billing_model,
"monthly_gb": tariff.monthly_gb,
"options": options,
"actions": actions,
}
def _serialize_payment_methods(
settings: Settings,
app: web.Application,
) -> List[Dict[str, Any]]:
labels = {
"severpay": "SeverPay",
"freekassa": "FreeKassa / СБП",
"platega_sbp": "Platega · СБП",
"platega_crypto": "Platega · Crypto",
"yookassa": "Банковская карта",
"stars": "Telegram Stars",
"cryptopay": "CryptoPay",
}
methods: List[Dict[str, Any]] = []
for method in settings.payment_methods_order:
method = method.lower()
if (
method == "severpay"
and settings.SEVERPAY_ENABLED
and _service_configured(app, "severpay_service")
):
methods.append({"id": method, "name": labels[method]})
elif (
method == "freekassa"
and settings.FREEKASSA_ENABLED
and _service_configured(app, "freekassa_service")
):
methods.append({"id": method, "name": labels[method]})
elif (
method == "platega_sbp"
and settings.PLATEGA_ENABLED
and settings.PLATEGA_SBP_ENABLED
and _service_configured(app, "platega_service")
):
methods.append({"id": method, "name": labels[method]})
elif (
method == "platega_crypto"
and settings.PLATEGA_ENABLED
and settings.PLATEGA_CRYPTO_ENABLED
and _service_configured(app, "platega_service")
):
methods.append({"id": method, "name": labels[method]})
elif (
method == "yookassa"
and settings.YOOKASSA_ENABLED
and _service_configured(app, "yookassa_service")
):
methods.append({"id": method, "name": labels[method]})
elif method == "stars" and settings.STARS_ENABLED:
methods.append({"id": method, "name": labels[method]})
elif (
method == "cryptopay"
and settings.CRYPTOPAY_ENABLED
and _service_configured(app, "cryptopay_service")
):
methods.append({"id": method, "name": labels[method]})
return methods
def _service_configured(app: web.Application, key: str) -> bool:
service = app.get(key)
return bool(service and getattr(service, "configured", False))
-362
View File
@@ -1,362 +0,0 @@
import asyncio
import base64
import hashlib
import hmac
import json
import logging
import secrets
import time
from typing import Any, Dict, Optional
from urllib.parse import parse_qsl
from config.settings import Settings
logger = logging.getLogger(__name__)
# 5 minutes clock skew tolerance for Telegram clients
TELEGRAM_CLOCK_SKEW_SECONDS = 300
TELEGRAM_OAUTH_ISSUER = "https://oauth.telegram.org"
TELEGRAM_OAUTH_JWKS_URL = "https://oauth.telegram.org/.well-known/jwks.json"
TELEGRAM_OAUTH_ALGORITHMS = ["RS256", "ES256", "EdDSA"]
def _urlsafe_b64encode(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
def _urlsafe_b64decode(raw: str) -> bytes:
padded = raw + ("=" * (-len(raw) % 4))
return base64.urlsafe_b64decode(padded.encode("ascii"))
def _session_secret(settings: Settings) -> bytes:
return hmac.new(
settings.WEBAPP_SESSION_SECRET.encode("utf-8"),
b"remnawave-tg-shop-webapp-session",
hashlib.sha256,
).digest()
def create_webapp_session_token(settings: Settings, user_id: int) -> str:
now = int(time.time())
payload = {
"sub": int(user_id),
"iat": now,
"exp": now + max(60, int(settings.WEBAPP_SESSION_TTL_SECONDS)),
}
payload_part = _urlsafe_b64encode(json.dumps(payload, separators=(",", ":")).encode("utf-8"))
signature = hmac.new(
_session_secret(settings),
payload_part.encode("ascii"),
hashlib.sha256,
).digest()
return f"{payload_part}.{_urlsafe_b64encode(signature)}"
def verify_webapp_session_token(settings: Settings, token: str) -> Optional[int]:
if not token or "." not in token:
return None
try:
payload_part, signature_part = token.split(".", 1)
expected_signature = hmac.new(
_session_secret(settings),
payload_part.encode("ascii"),
hashlib.sha256,
).digest()
received_signature = _urlsafe_b64decode(signature_part)
if not hmac.compare_digest(expected_signature, received_signature):
return None
payload = json.loads(_urlsafe_b64decode(payload_part).decode("utf-8"))
if int(payload.get("exp", 0)) < int(time.time()):
return None
return int(payload["sub"])
except Exception as exc:
logger.debug("Failed to verify webapp session token: %s", exc)
return None
def create_telegram_oauth_nonce(settings: Settings, *, ttl_seconds: int = 600) -> str:
now = int(time.time())
payload = {
"n": secrets.token_urlsafe(24),
"iat": now,
"exp": now + max(60, int(ttl_seconds)),
}
payload_part = _urlsafe_b64encode(json.dumps(payload, separators=(",", ":")).encode("utf-8"))
signature = hmac.new(
_session_secret(settings),
f"telegram-oauth-nonce.{payload_part}".encode("ascii"),
hashlib.sha256,
).digest()
return f"{payload_part}.{_urlsafe_b64encode(signature)}"
def verify_telegram_oauth_nonce(settings: Settings, nonce: str) -> bool:
if not nonce or "." not in nonce:
return False
try:
payload_part, signature_part = nonce.split(".", 1)
expected_signature = hmac.new(
_session_secret(settings),
f"telegram-oauth-nonce.{payload_part}".encode("ascii"),
hashlib.sha256,
).digest()
received_signature = _urlsafe_b64decode(signature_part)
if not hmac.compare_digest(expected_signature, received_signature):
return False
payload = json.loads(_urlsafe_b64decode(payload_part).decode("utf-8"))
now = int(time.time())
if int(payload.get("exp", 0)) < now:
return False
if int(payload.get("iat", 0)) > now + TELEGRAM_CLOCK_SKEW_SECONDS:
return False
return bool(payload.get("n"))
except Exception as exc:
logger.debug("Failed to verify Telegram OAuth nonce: %s", exc)
return False
def create_signed_telegram_oauth_state(
settings: Settings,
payload: Dict[str, Any],
*,
ttl_seconds: int = 600,
) -> str:
now = int(time.time())
state_payload = {
**payload,
"iat": now,
"exp": now + max(60, int(ttl_seconds)),
}
payload_part = _urlsafe_b64encode(
json.dumps(state_payload, separators=(",", ":")).encode("utf-8")
)
signature = hmac.new(
_session_secret(settings),
f"telegram-oauth-state.{payload_part}".encode("ascii"),
hashlib.sha256,
).digest()
return f"{payload_part}.{_urlsafe_b64encode(signature)}"
def verify_signed_telegram_oauth_state(
settings: Settings,
state: str,
) -> Optional[Dict[str, Any]]:
if not state or "." not in state:
return None
try:
payload_part, signature_part = state.split(".", 1)
expected_signature = hmac.new(
_session_secret(settings),
f"telegram-oauth-state.{payload_part}".encode("ascii"),
hashlib.sha256,
).digest()
received_signature = _urlsafe_b64decode(signature_part)
if not hmac.compare_digest(expected_signature, received_signature):
return None
payload = json.loads(_urlsafe_b64decode(payload_part).decode("utf-8"))
now = int(time.time())
if int(payload.get("exp", 0)) < now:
return None
if int(payload.get("iat", 0)) > now + TELEGRAM_CLOCK_SKEW_SECONDS:
return None
return payload
except Exception as exc:
logger.debug("Failed to verify Telegram OAuth state: %s", exc)
return None
async def validate_telegram_oauth_id_token(
id_token: str,
*,
client_id: int,
expected_nonce: str,
max_age_seconds: int,
) -> Optional[Dict[str, Any]]:
"""Validate Telegram OIDC ID token and return a Telegram-like user payload."""
if not id_token or not client_id or not expected_nonce:
return None
try:
import jwt
from jwt import PyJWKClient
except Exception as exc:
logger.error(
"PyJWT is not installed; Telegram OAuth ID token validation is unavailable: %s",
exc,
)
return None
try:
jwks_client = PyJWKClient(TELEGRAM_OAUTH_JWKS_URL)
signing_key = await asyncio.to_thread(
jwks_client.get_signing_key_from_jwt,
id_token,
)
claims = await asyncio.to_thread(
jwt.decode,
id_token,
signing_key.key,
algorithms=TELEGRAM_OAUTH_ALGORITHMS,
audience=str(client_id),
issuer=TELEGRAM_OAUTH_ISSUER,
leeway=TELEGRAM_CLOCK_SKEW_SECONDS,
options={"require": ["exp", "iat", "iss", "aud"]},
)
if not hmac.compare_digest(str(claims.get("nonce") or ""), expected_nonce):
logger.warning("Telegram OAuth nonce mismatch.")
return None
now = int(time.time())
issued_at = int(claims.get("iat") or 0)
max_age = max(60, int(max_age_seconds))
if issued_at > now + TELEGRAM_CLOCK_SKEW_SECONDS or now - issued_at > max_age:
logger.warning("Telegram OAuth ID token is stale.")
return None
telegram_id_raw = claims.get("id")
if not telegram_id_raw:
return None
telegram_id = int(telegram_id_raw)
full_name = str(claims.get("name") or "").strip()
first_name = str(claims.get("given_name") or "").strip()
last_name = str(claims.get("family_name") or "").strip()
if full_name and not first_name:
name_parts = full_name.split(None, 1)
first_name = name_parts[0]
if len(name_parts) > 1 and not last_name:
last_name = name_parts[1]
return {
"id": telegram_id,
"username": claims.get("preferred_username") or claims.get("username"),
"first_name": first_name or full_name or "Telegram",
"last_name": last_name,
"photo_url": claims.get("picture"),
"language_code": claims.get("locale"),
}
except Exception as exc:
logger.warning("Failed to validate Telegram OAuth ID token: %s", exc)
return None
def validate_telegram_webapp_init_data(
init_data: str,
bot_token: str,
*,
max_age_seconds: int,
) -> Optional[Dict[str, Any]]:
"""Validate Telegram Mini App initData and return the trusted user payload."""
try:
parsed_data = dict(parse_qsl(init_data or "", keep_blank_values=True))
received_hash = parsed_data.pop("hash", None)
if not received_hash:
return None
data_check_string = "\n".join(
f"{key}={value}" for key, value in sorted(parsed_data.items())
)
secret_key = hmac.new(
b"WebAppData",
bot_token.encode("utf-8"),
hashlib.sha256,
).digest()
calculated_hash = hmac.new(
secret_key,
data_check_string.encode("utf-8"),
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(calculated_hash, received_hash):
logger.warning("Telegram WebApp initData hash mismatch.")
return None
auth_date_raw = parsed_data.get("auth_date")
if auth_date_raw:
auth_date = int(auth_date_raw)
now = int(time.time())
max_age = max(60, int(max_age_seconds))
if auth_date > now + TELEGRAM_CLOCK_SKEW_SECONDS or now - auth_date > max_age:
logger.warning("Telegram WebApp initData auth_date is stale.")
return None
user_json = parsed_data.get("user")
if not user_json:
return None
user_data = json.loads(user_json)
if not user_data.get("id"):
return None
if parsed_data.get("start_param"):
user_data["start_param"] = parsed_data.get("start_param")
return user_data
except Exception as exc:
logger.warning("Failed to validate Telegram WebApp initData: %s", exc)
return None
def validate_telegram_login_widget_data(
auth_data: Any,
bot_token: str,
*,
max_age_seconds: int,
) -> Optional[Dict[str, Any]]:
"""Validate Telegram Login Widget data and return the trusted user payload."""
try:
if isinstance(auth_data, str):
parsed_data = dict(parse_qsl(auth_data or "", keep_blank_values=True))
elif isinstance(auth_data, dict):
parsed_data = {
str(key): str(value) for key, value in auth_data.items() if value is not None
}
else:
return None
received_hash = str(parsed_data.pop("hash", "") or "")
if not received_hash:
return None
data_check_string = "\n".join(
f"{key}={value}" for key, value in sorted(parsed_data.items())
)
secret_key = hashlib.sha256(bot_token.encode("utf-8")).digest()
calculated_hash = hmac.new(
secret_key,
data_check_string.encode("utf-8"),
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(calculated_hash, received_hash):
logger.warning("Telegram Login Widget hash mismatch.")
return None
auth_date_raw = parsed_data.get("auth_date")
if auth_date_raw:
auth_date = int(auth_date_raw)
now = int(time.time())
max_age = max(60, int(max_age_seconds))
if auth_date > now + TELEGRAM_CLOCK_SKEW_SECONDS or now - auth_date > max_age:
logger.warning("Telegram Login Widget auth_date is stale.")
return None
user_id_raw = parsed_data.get("id")
if not user_id_raw:
return None
int(user_id_raw)
if not parsed_data.get("first_name"):
return None
return parsed_data
except Exception as exc:
logger.warning("Failed to validate Telegram Login Widget data: %s", exc)
return None
-302
View File
@@ -1,302 +0,0 @@
import csv
import io
import logging
from datetime import datetime
from typing import List, Optional
from aiogram import F, Router, types
from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton
from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.inline.admin_keyboards import get_back_to_admin_panel_keyboard
from bot.middlewares.i18n import JsonI18n
from config.settings import Settings
from db.dal import payment_dal
from db.models import Payment
router = Router(name="admin_payments_router")
async def get_payments_with_pagination(
session: AsyncSession, page: int = 0, page_size: int = 10
) -> tuple[List[Payment], int]:
"""Get payments with pagination and total count."""
offset = page * page_size
# Get total count
total_count = await payment_dal.get_payments_count(session)
# Get payments for current page
payments = await payment_dal.get_recent_payment_logs_with_user(
session, limit=page_size, offset=offset
)
return payments, total_count
def format_payment_text(payment: Payment, i18n: JsonI18n, lang: str, settings: Settings) -> str:
"""Format single payment info as text."""
_ = lambda key, **kwargs: i18n.gettext(lang, key, **kwargs)
pending_statuses = [
"pending",
"pending_yookassa",
"pending_freekassa",
"pending_platega",
"pending_severpay",
"pending_cryptopay",
]
status_emoji = (
""
if payment.status == "succeeded"
else ("" if payment.status in pending_statuses else "")
)
user_info = f"User {payment.user_id}"
if payment.user and payment.user.username:
user_info += f" (@{payment.user.username})"
elif payment.user and payment.user.first_name:
user_info += f" ({payment.user.first_name})"
payment_date = payment.created_at.strftime("%Y-%m-%d %H:%M") if payment.created_at else "N/A"
provider_text = {
"yookassa": "YooKassa",
"telegram_stars": "Telegram Stars",
"cryptopay": "CryptoPay",
"freekassa": "FreeKassa",
"severpay": "SeverPay",
"platega": "Platega",
}.get(payment.provider, payment.provider or "Unknown")
sale_base = (payment.sale_mode or "").split("@", 1)[0].split("|", 1)[0]
traffic_like = sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
if traffic_like:
traffic_val = payment.purchased_gb or payment.subscription_duration_months or 0
traffic_display = (
str(int(traffic_val)) if float(traffic_val).is_integer() else f"{traffic_val:g}"
)
period_line = _("admin_payment_traffic_label", traffic_gb=traffic_display)
else:
period_line = _(
"admin_payment_months_label", months=payment.subscription_duration_months or 0
)
tariff_line = f"\nTariff: {payment.tariff_key}" if payment.tariff_key else ""
sale_line = f"\nSale mode: {payment.sale_mode}" if payment.sale_mode else ""
return (
f"{status_emoji} <b>{payment.amount} {payment.currency}</b>\n"
f"👤 {user_info}\n"
f"💳 {provider_text}\n"
f"📅 {payment_date}\n"
f"{period_line}{tariff_line}{sale_line}\n"
f"📋 {payment.status}\n"
f"📝 {payment.description or 'N/A'}"
)
async def view_payments_handler(
callback: types.CallbackQuery,
i18n_data: dict,
settings: Settings,
session: AsyncSession,
page: int = 0,
):
"""Display paginated list of all payments."""
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n or not callback.message:
await callback.answer("Error processing request.", show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
page_size = 5 # Show 5 payments per page
payments, total_count = await get_payments_with_pagination(session, page, page_size)
total_pages = (total_count + page_size - 1) // page_size if total_count > 0 else 1
if not payments and page == 0:
await callback.message.edit_text(
_("admin_no_payments_found"),
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML",
)
await callback.answer()
return
# Format payments text
text_parts = [_("admin_payments_header")]
text_parts.append(
_(
"admin_payments_pagination_info",
shown=len(payments),
total=total_count,
current_page=page + 1,
total_pages=total_pages,
)
+ "\n"
)
for i, payment in enumerate(payments, 1):
text_parts.append(
f"<b>{page * page_size + i}.</b> {format_payment_text(payment, i18n, current_lang, settings)}" # noqa: E501
)
text_parts.append("") # Empty line between payments
# Build keyboard with pagination and export
builder = InlineKeyboardBuilder()
# Pagination buttons
nav_buttons = []
if page > 0:
nav_buttons.append(
InlineKeyboardButton(text="⬅️", callback_data=f"payments_page:{page - 1}")
)
nav_buttons.append(InlineKeyboardButton(text=f"{page + 1}/{total_pages}", callback_data="noop"))
if page < total_pages - 1:
nav_buttons.append(
InlineKeyboardButton(text="➡️", callback_data=f"payments_page:{page + 1}")
)
if nav_buttons:
builder.row(*nav_buttons)
# Export and refresh buttons
builder.row(
InlineKeyboardButton(
text=_("admin_export_payments_csv"), callback_data="payments_export_csv"
),
InlineKeyboardButton(
text=_("admin_refresh_payments"), callback_data=f"payments_page:{page}"
),
)
# Back button
builder.row(
InlineKeyboardButton(
text=_("back_to_admin_panel_button"), callback_data="admin_section:stats_monitoring"
)
)
await callback.message.edit_text(
"\n".join(text_parts), reply_markup=builder.as_markup(), parse_mode="HTML"
)
await callback.answer()
@router.callback_query(F.data.startswith("payments_page:"))
async def payments_pagination_handler(
callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession
):
"""Handle pagination for payments list."""
try:
page = int(callback.data.split(":")[1])
await view_payments_handler(callback, i18n_data, settings, session, page)
except (ValueError, IndexError):
await callback.answer("Error processing pagination.", show_alert=True)
@router.callback_query(F.data == "payments_export_csv")
async def export_payments_csv_handler(
callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession
):
"""Export all successful payments to CSV file."""
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n:
await callback.answer("Language service error.", show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
try:
# Get all successful payments
all_payments = await payment_dal.get_all_succeeded_payments_with_user(session)
if not all_payments:
await callback.answer(_("admin_no_payments_to_export"), show_alert=True)
return
# Create CSV in memory
output = io.StringIO()
writer = csv.writer(output)
# Write header
writer.writerow(
[
_("admin_csv_payment_id"),
_("admin_csv_user_id"),
_("admin_csv_username"),
_("admin_csv_first_name"),
_("admin_csv_amount"),
_("admin_csv_currency"),
_("admin_csv_provider"),
_("admin_csv_status"),
_("admin_csv_description"),
_("admin_csv_units"),
"sale_mode",
"tariff_key",
"purchased_gb",
_("admin_csv_created_at"),
_("admin_csv_provider_payment_id"),
]
)
# Write payment data
for payment in all_payments:
units_val = payment.purchased_gb or payment.subscription_duration_months or ""
if (payment.purchased_gb is not None) and units_val not in ("", None):
try:
units_val = (
str(int(units_val)) if float(units_val).is_integer() else f"{units_val:g}"
)
except Exception:
units_val = payment.purchased_gb or payment.subscription_duration_months or ""
writer.writerow(
[
payment.payment_id,
payment.user_id,
payment.user.username if payment.user and payment.user.username else "",
payment.user.first_name if payment.user and payment.user.first_name else "",
payment.amount,
payment.currency,
payment.provider or "",
payment.status,
payment.description or "",
units_val,
payment.sale_mode or "",
payment.tariff_key or "",
payment.purchased_gb or "",
payment.created_at.strftime("%Y-%m-%d %H:%M:%S") if payment.created_at else "",
payment.provider_payment_id or "",
]
)
# Prepare file
csv_content = output.getvalue().encode("utf-8-sig") # UTF-8 with BOM for Excel
output.close()
# Generate filename with current date
current_time = datetime.now().strftime("%Y-%m-%d_%H-%M")
filename = f"payments_export_{current_time}.csv"
# Send file
from aiogram.types import BufferedInputFile
file = BufferedInputFile(csv_content, filename=filename)
await callback.message.reply_document(
document=file, caption=_("admin_payments_export_success", count=len(all_payments))
)
await callback.answer(_("admin_export_sent"), show_alert=False)
except Exception as e:
logging.error(f"Failed to export payments CSV: {e}", exc_info=True)
await callback.answer(f"❌ Ошибка экспорта: {str(e)}", show_alert=True)
@router.callback_query(F.data == "noop")
async def noop_handler(callback: types.CallbackQuery):
"""Handle no-op callback (for pagination display)."""
await callback.answer()
-412
View File
@@ -1,412 +0,0 @@
import logging
from datetime import datetime, timedelta, timezone
from typing import Optional
from aiogram import F, Router, types
from aiogram.filters import StateFilter
from aiogram.fsm.context import FSMContext
from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton
from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.inline.admin_keyboards import (
get_admin_panel_keyboard,
get_back_to_admin_panel_keyboard,
)
from bot.middlewares.i18n import JsonI18n
from bot.states.admin_states import AdminStates
from config.settings import Settings
from db.dal import promo_code_dal
router = Router(name="promo_create_router")
async def create_promo_prompt_handler(
callback: types.CallbackQuery,
state: FSMContext,
i18n_data: dict,
settings: Settings,
session: AsyncSession,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n or not callback.message:
await callback.answer("Error preparing promo creation.", show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
# Step 1: Ask for promo code
prompt_text = _("admin_promo_step1_code")
try:
await callback.message.edit_text(
prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML",
)
except Exception as e:
logging.warning(f"Could not edit message for promo prompt: {e}. Sending new.")
await callback.message.answer(
prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML",
)
await callback.answer()
await state.set_state(AdminStates.waiting_for_promo_code)
# Step 1: Process promo code
@router.message(AdminStates.waiting_for_promo_code, F.text)
async def process_promo_code_handler(
message: types.Message,
state: FSMContext,
i18n_data: dict,
settings: Settings,
session: AsyncSession,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n:
await message.reply("Language service error.")
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
try:
code_str = message.text.strip().upper()
if not (3 <= len(code_str) <= 30 and code_str.isalnum()):
await message.answer(_("admin_promo_invalid_code_format"))
return
# Check if code already exists
existing_promo = await promo_code_dal.get_promo_code_by_code(session, code_str)
if existing_promo:
await message.answer(_("admin_promo_code_already_exists"))
return
await state.update_data(promo_code=code_str)
# Step 2: Ask for bonus days
prompt_text = _("admin_promo_step2_bonus_days", code=code_str)
await message.answer(
prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML",
)
await state.set_state(AdminStates.waiting_for_promo_bonus_days)
except Exception as e:
logging.error(f"Error processing promo code: {e}")
await message.answer(_("error_occurred_try_again"))
# Step 2: Process bonus days
@router.message(AdminStates.waiting_for_promo_bonus_days, F.text)
async def process_promo_bonus_days_handler(
message: types.Message, state: FSMContext, i18n_data: dict, settings: Settings
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n:
await message.reply("Language service error.")
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
try:
bonus_days = int(message.text.strip())
if not (1 <= bonus_days <= 365):
await message.answer(_("admin_promo_invalid_bonus_days"))
return
await state.update_data(bonus_days=bonus_days)
# Step 3: Ask for max activations
data = await state.get_data()
prompt_text = _(
"admin_promo_step3_max_activations", code=data.get("promo_code"), bonus_days=bonus_days
)
await message.answer(
prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML",
)
await state.set_state(AdminStates.waiting_for_promo_max_activations)
except ValueError:
await message.answer(_("admin_promo_invalid_number"))
except Exception as e:
logging.error(f"Error processing promo bonus days: {e}")
await message.answer(_("error_occurred_try_again"))
# Step 3: Process max activations
@router.message(AdminStates.waiting_for_promo_max_activations, F.text)
async def process_promo_max_activations_handler(
message: types.Message, state: FSMContext, i18n_data: dict, settings: Settings
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n:
await message.reply("Language service error.")
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
try:
max_activations = int(message.text.strip())
if not (1 <= max_activations <= 10000):
await message.answer(_("admin_promo_invalid_max_activations"))
return
await state.update_data(max_activations=max_activations)
# Step 4: Ask for validity
data = await state.get_data()
prompt_text = _(
"admin_promo_step4_validity",
code=data.get("promo_code"),
bonus_days=data.get("bonus_days"),
max_activations=max_activations,
)
# Create keyboard for validity options
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text=_("admin_promo_unlimited_validity"), callback_data="promo_unlimited_validity"
)
)
builder.row(
InlineKeyboardButton(
text=_("admin_promo_set_validity_days"), callback_data="promo_set_validity"
)
)
builder.row(
InlineKeyboardButton(text=_("admin_back_to_panel"), callback_data="admin_action:main")
)
await message.answer(prompt_text, reply_markup=builder.as_markup(), parse_mode="HTML")
await state.set_state(AdminStates.waiting_for_promo_validity_days)
except ValueError:
await message.answer(_("admin_promo_invalid_number"))
except Exception as e:
logging.error(f"Error processing promo max activations: {e}")
await message.answer(_("error_occurred_try_again"))
# Step 4: Handle unlimited validity
@router.callback_query(
F.data == "promo_unlimited_validity", StateFilter(AdminStates.waiting_for_promo_validity_days)
)
async def process_promo_unlimited_validity(
callback: types.CallbackQuery,
state: FSMContext,
i18n_data: dict,
settings: Settings,
session: AsyncSession,
):
await state.update_data(validity_days=None)
await create_promo_code_final(callback, state, i18n_data, settings, session)
# Step 4: Handle set validity
@router.callback_query(
F.data == "promo_set_validity", StateFilter(AdminStates.waiting_for_promo_validity_days)
)
async def process_promo_set_validity(
callback: types.CallbackQuery, state: FSMContext, i18n_data: dict, settings: Settings
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n or not callback.message:
await callback.answer("Error processing validity.", show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
data = await state.get_data()
prompt_text = _(
"admin_promo_enter_validity_days",
code=data.get("promo_code"),
bonus_days=data.get("bonus_days"),
max_activations=data.get("max_activations"),
)
try:
await callback.message.edit_text(
prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML",
)
except Exception:
await callback.message.answer(
prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML",
)
await callback.answer()
# Step 4: Process validity days
@router.message(AdminStates.waiting_for_promo_validity_days, F.text)
async def process_promo_validity_days_handler(
message: types.Message,
state: FSMContext,
i18n_data: dict,
settings: Settings,
session: AsyncSession,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n:
await message.reply("Language service error.")
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
try:
validity_days = int(message.text.strip())
if not (1 <= validity_days <= 365):
await message.answer(_("admin_promo_invalid_validity_days"))
return
await state.update_data(validity_days=validity_days)
await create_promo_code_final(message, state, i18n_data, settings, session)
except ValueError:
await message.answer(_("admin_promo_invalid_number"))
except Exception as e:
logging.error(f"Error processing promo validity days: {e}")
await message.answer(_("error_occurred_try_again"))
async def create_promo_code_final(
callback_or_message,
state: FSMContext,
i18n_data: dict,
settings: Settings,
session: AsyncSession,
):
"""Final step - create the promo code in database"""
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n:
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
try:
data = await state.get_data()
# Prepare promo code data
promo_data = {
"code": data["promo_code"],
"bonus_days": data["bonus_days"],
"max_activations": data["max_activations"],
"current_activations": 0,
"is_active": True,
"created_by_admin_id": callback_or_message.from_user.id,
"created_at": datetime.now(timezone.utc),
}
# Set validity
if data.get("validity_days"):
promo_data["valid_until"] = datetime.now(timezone.utc) + timedelta(
days=data["validity_days"]
)
else:
promo_data["valid_until"] = None
# Create promo code
created_promo = await promo_code_dal.create_promo_code(session, promo_data)
await session.commit()
# Log successful creation
logging.info(
f"Promo code '{data['promo_code']}' created with ID {created_promo.promo_code_id}"
)
# Success message
valid_until_str = (
_("admin_promo_unlimited")
if not data.get("validity_days")
else f"{data['validity_days']} дней"
)
success_text = _(
"admin_promo_created_success",
code=data["promo_code"],
bonus_days=data["bonus_days"],
max_activations=data["max_activations"],
valid_until_str=valid_until_str,
)
if hasattr(callback_or_message, "message"): # CallbackQuery
try:
await callback_or_message.message.edit_text(
success_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML",
)
except Exception:
await callback_or_message.message.answer(
success_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML",
)
await callback_or_message.answer()
else: # Message
await callback_or_message.answer(
success_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML",
)
await state.clear()
except Exception as e:
logging.error(f"Error creating promo code: {e}")
error_text = _("error_occurred_try_again")
if hasattr(callback_or_message, "message"): # CallbackQuery
await callback_or_message.message.answer(error_text)
await callback_or_message.answer()
else: # Message
await callback_or_message.answer(error_text)
await state.clear()
# Cancel promo creation
@router.callback_query(
F.data == "admin_action:main",
StateFilter(
AdminStates.waiting_for_promo_code,
AdminStates.waiting_for_promo_bonus_days,
AdminStates.waiting_for_promo_max_activations,
AdminStates.waiting_for_promo_validity_days,
),
)
async def cancel_promo_creation_state_to_menu(
callback: types.CallbackQuery,
state: FSMContext,
settings: Settings,
i18n_data: dict,
session: AsyncSession,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n or not callback.message:
await callback.answer("Error cancelling.", show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
try:
await callback.message.edit_text(
_(key="admin_panel_title"),
reply_markup=get_admin_panel_keyboard(i18n, current_lang, settings),
)
except Exception:
await callback.message.answer(
_(key="admin_panel_title"),
reply_markup=get_admin_panel_keyboard(i18n, current_lang, settings),
)
await callback.answer(_("admin_promo_creation_cancelled"))
await state.clear()
-406
View File
@@ -1,406 +0,0 @@
import html
import logging
from typing import Dict, List, Optional
from aiogram import Router, types
from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.inline.admin_keyboards import (
get_back_to_admin_panel_keyboard,
get_back_to_user_management_keyboard,
)
from bot.middlewares.i18n import JsonI18n
from bot.services.panel_api_service import PanelApiService
from config.settings import Settings
from db.dal import panel_sync_dal, payment_dal, user_dal
from db.models import PanelSyncStatus, Payment
router = Router(name="admin_statistics_router")
def _format_rating_user_label(
user_row: Dict[str, object], bot_username: Optional[str] = None
) -> str:
user_id = int(user_row.get("user_id", 0) or 0)
username = user_row.get("username")
first_name = user_row.get("first_name")
user_id_text = str(user_id)
user_id_html = html.escape(user_id_text)
if bot_username:
safe_bot_username = html.escape(bot_username)
user_id_html = (
f'<a href="https://t.me/{safe_bot_username}?start=admin_user_{user_id_text}">'
f"{user_id_html}</a>"
)
parts: List[str] = []
if username:
parts.append(f"@{html.escape(str(username))}")
elif first_name:
parts.append(html.escape(str(first_name)))
if not parts:
parts.append(f"ID {user_id_html}")
else:
parts.append(f"(ID {user_id_html})")
return " ".join(parts)
async def show_statistics_handler(
callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n or not callback.message:
await callback.answer("Error displaying statistics.", show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
await callback.answer()
stats_text_parts = [f"<b>{_('admin_stats_header')}</b>"]
# Enhanced user statistics
user_stats = await user_dal.get_enhanced_user_statistics(session)
stats_text_parts.append(f"\n<b>👥 {_('admin_enhanced_users_stats_header')}</b>")
stats_text_parts.append(
f"📊 {_('admin_user_stats_total_label')}: <b>{user_stats['total_users']}</b>"
)
# Removed: Active today moved to panel stats
stats_text_parts.append(
f"💳 {_('admin_user_stats_paid_subs_label')}: <b>{user_stats['paid_subscriptions']}</b>"
)
stats_text_parts.append(
f"🆓 {_('admin_user_stats_trial_label')}: <b>{user_stats['trial_users']}</b>"
)
stats_text_parts.append(
f"😴 {_('admin_user_stats_inactive_label')}: <b>{user_stats['inactive_users']}</b>"
)
stats_text_parts.append(
f"🚫 {_('admin_user_stats_banned_label')}: <b>{user_stats['banned_users']}</b>"
)
stats_text_parts.append(
f"🎁 {_('admin_user_stats_referral_label')}: <b>{user_stats['referral_users']}</b>"
)
# Panel Statistics - moved above financial
stats_text_parts.append(f"\n<b>🖥 {_('admin_panel_stats_header')}</b>")
try:
async with PanelApiService(settings) as panel_service:
# Get system stats
system_stats = await panel_service.get_system_stats()
bandwidth_stats = await panel_service.get_bandwidth_stats()
nodes_stats = await panel_service.get_nodes_statistics()
logging.info(
f"Panel stats response: system={system_stats}, bandwidth={bandwidth_stats}, nodes={nodes_stats}" # noqa: E501
)
if system_stats:
users = system_stats.get("users", {})
status_counts = users.get("statusCounts", {})
online_stats = system_stats.get("onlineStats", {})
active_users = status_counts.get("ACTIVE", 0)
disabled_users = status_counts.get("DISABLED", 0)
expired_users = status_counts.get("EXPIRED", 0)
limited_users = status_counts.get("LIMITED", 0)
total_users = users.get("totalUsers", 0)
online_now = online_stats.get("onlineNow", 0)
stats_text_parts.append(f"🟢 {_('admin_panel_online_label')}: <b>{online_now}</b>")
stats_text_parts.append(
f"📊 {_('admin_panel_active_label')}: <b>{active_users}</b>"
)
stats_text_parts.append(
f"🔴 {_('admin_panel_disabled_label')}: <b>{disabled_users}</b>"
)
stats_text_parts.append(
f"{_('admin_panel_expired_label')}: <b>{expired_users}</b>"
)
stats_text_parts.append(
f"⚠️ {_('admin_panel_limited_label')}: <b>{limited_users}</b>"
)
stats_text_parts.append(
f"👥 {_('admin_panel_total_users_label')}: <b>{total_users}</b>"
)
# System resources
memory = system_stats.get("memory", {})
if memory:
memory_total = memory.get("total", 1)
memory_used = memory.get("used", 0)
memory_usage = (memory_used / memory_total) * 100 if memory_total > 0 else 0
stats_text_parts.append(
f"💾 {_('admin_panel_memory_usage_label')}: <b>{memory_usage:.1f}%</b>"
)
else:
stats_text_parts.append(f"⚠️ {_('admin_panel_system_stats_error')}")
# Bandwidth stats
if bandwidth_stats:
week_traffic = bandwidth_stats.get("bandwidthLastSevenDays", {})
month_traffic = bandwidth_stats.get("bandwidthLast30Days", {})
# Fallback to the actual key name from API if the above doesn't exist
if not month_traffic:
month_traffic = bandwidth_stats.get("bandwidthLastThirtyDays", {})
if week_traffic:
week_total = week_traffic.get("current", "0 B")
stats_text_parts.append(
f"📊 {_('admin_panel_traffic_week_label')}: <b>{week_total}</b>"
)
if month_traffic:
month_total = month_traffic.get("current", "0 B")
stats_text_parts.append(
f"📊 {_('admin_panel_traffic_month_label')}: <b>{month_total}</b>"
)
else:
stats_text_parts.append(f"⚠️ {_('admin_panel_bandwidth_stats_error')}")
# Nodes stats
if nodes_stats and "lastSevenDays" in nodes_stats:
last_seven_days = nodes_stats.get("lastSevenDays", [])
# Get unique node names from the data
unique_nodes = set()
for node_data in last_seven_days:
unique_nodes.add(node_data.get("nodeName", ""))
total_nodes_count = len(unique_nodes)
# Assume all nodes are active since we don't have status info
stats_text_parts.append(
f"🔗 {_('admin_panel_nodes_label')}: <b>{total_nodes_count}/{total_nodes_count}</b>" # noqa: E501
)
else:
# Use nodes total from system stats as fallback
nodes_info = system_stats.get("nodes", {}) if system_stats else {}
total_online = nodes_info.get("totalOnline", 0)
stats_text_parts.append(f"🔗 {_('admin_panel_nodes_label')}: <b>{total_online}</b>")
except Exception as e:
logging.error(f"Failed to fetch panel statistics: {e}", exc_info=True)
stats_text_parts.append(f"{_('admin_panel_stats_fetch_error')}")
stats_text_parts.append(f"⚠️ {_('admin_panel_stats_error_details')}: {str(e)}")
# Financial statistics
financial_stats = await payment_dal.get_financial_statistics(session)
stats_text_parts.append(f"\n<b>💰 {_('admin_financial_stats_header')}</b>")
stats_text_parts.append(
f"📅 {_('admin_financial_today_label')}: <b>{financial_stats['today_revenue']:.2f} RUB</b> ({financial_stats['today_payments_count']} {_('admin_financial_payments_label')})" # noqa: E501
)
stats_text_parts.append(
f"📅 {_('admin_financial_week_label')}: <b>{financial_stats['week_revenue']:.2f} RUB</b>"
)
stats_text_parts.append(
f"📅 {_('admin_financial_month_label')}: <b>{financial_stats['month_revenue']:.2f} RUB</b>"
)
stats_text_parts.append(
f"🏆 {_('admin_financial_all_time_label')}: <b>{financial_stats['all_time_revenue']:.2f} RUB</b>" # noqa: E501
)
last_payments_models: List[Payment] = await payment_dal.get_recent_payment_logs_with_user(
session, limit=5
)
if last_payments_models:
stats_text_parts.append(f"\n<b>{_('admin_stats_recent_payments_header')}</b>")
for payment in last_payments_models:
pending_statuses = [
"pending",
"pending_yookassa",
"pending_freekassa",
"pending_platega",
"pending_severpay",
"pending_cryptopay",
]
status_emoji = (
""
if payment.status == "succeeded"
else ""
if payment.status in pending_statuses
else ""
)
user_info = f"User {payment.user_id}"
if payment.user and payment.user.username:
user_info += f" (@{payment.user.username})"
elif payment.user and payment.user.first_name:
user_info += f" ({payment.user.first_name})"
payment_date_str = (
payment.created_at.strftime("%Y-%m-%d") if payment.created_at else "N/A"
)
stats_text_parts.append(
_(
"admin_stats_payment_item",
status_emoji=status_emoji,
amount=payment.amount,
currency=payment.currency,
user_info=user_info,
p_status=payment.status,
p_date=payment_date_str,
)
)
else:
stats_text_parts.append(f"\n{_('admin_stats_no_payments_found')}")
sync_status_model: Optional[PanelSyncStatus] = await panel_sync_dal.get_panel_sync_status(
session
)
if sync_status_model and sync_status_model.status != "never_run":
stats_text_parts.append(f"\n<b>{_('admin_stats_last_sync_header')}</b>")
sync_time_val = sync_status_model.last_sync_time
sync_time_str = sync_time_val.strftime("%Y-%m-%d %H:%M:%S UTC") if sync_time_val else "N/A"
details_val = sync_status_model.details
details_str = details_val or "N/A"
stats_text_parts.append(f" {_('admin_stats_sync_time')}: {sync_time_str}")
stats_text_parts.append(f" {_('admin_stats_sync_status')}: {sync_status_model.status}")
stats_text_parts.append(
f" {_('admin_stats_sync_users_processed')}: {sync_status_model.users_processed_from_panel}" # noqa: E501
)
stats_text_parts.append(
f" {_('admin_stats_sync_subs_synced')}: {sync_status_model.subscriptions_synced}"
)
stats_text_parts.append(f" {_('admin_stats_sync_details_label')}: {details_str}")
else:
stats_text_parts.append(f"\n{_('admin_sync_status_never_run')}")
final_text = "\n".join(stats_text_parts)
try:
await callback.message.edit_text(
final_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML",
)
except Exception as e_edit:
logging.error(f"Error editing message for statistics: {e_edit}", exc_info=True)
max_chunk_size = 4000
for i in range(0, len(final_text), max_chunk_size):
chunk = final_text[i : i + max_chunk_size]
is_last_chunk = (i + max_chunk_size) >= len(final_text)
try:
await callback.message.answer(
chunk,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n)
if is_last_chunk
else None,
parse_mode="HTML",
)
except Exception as e_chunk:
logging.error(f"Failed to send statistics chunk: {e_chunk}")
if i == 0:
await callback.message.answer(
_("error_displaying_statistics"),
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
)
break
async def show_user_ratings_handler(
callback: types.CallbackQuery,
i18n_data: dict,
settings: Settings,
session: AsyncSession,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n or not callback.message:
await callback.answer("Error displaying ratings.", show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
await callback.answer()
top_limit = 10
bot_username: Optional[str] = None
try:
me = await callback.bot.get_me()
bot_username = me.username
except Exception as e_get_me:
logging.warning("Failed to resolve bot username for ratings links: %s", e_get_me)
traffic_top = await user_dal.get_top_users_by_traffic_used(session, limit=top_limit)
lifetime_traffic_top = await user_dal.get_top_users_by_lifetime_traffic_used(
session, limit=top_limit
)
invited_top = await user_dal.get_top_users_by_referrals_count(session, limit=top_limit)
revenue_top = await user_dal.get_top_users_by_referral_revenue(session, limit=top_limit)
text_parts: List[str] = [
_("admin_user_ratings_header", top_limit=top_limit),
"",
f"<b>{_('admin_user_ratings_traffic_month_title')}</b>",
]
if traffic_top:
for idx, row in enumerate(traffic_top, start=1):
traffic_gb = float(row.get("traffic_used_bytes") or 0) / (1024**3)
text_parts.append(
_(
"admin_user_ratings_traffic_item",
rank=idx,
user=_format_rating_user_label(row, bot_username),
traffic_gb=f"{traffic_gb:.2f}",
)
)
else:
text_parts.append(_("admin_user_ratings_empty"))
text_parts.extend(["", f"<b>{_('admin_user_ratings_traffic_lifetime_title')}</b>"])
if lifetime_traffic_top:
for idx, row in enumerate(lifetime_traffic_top, start=1):
traffic_gb = float(row.get("lifetime_used_traffic_bytes") or 0) / (1024**3)
text_parts.append(
_(
"admin_user_ratings_traffic_item",
rank=idx,
user=_format_rating_user_label(row, bot_username),
traffic_gb=f"{traffic_gb:.2f}",
)
)
else:
text_parts.append(_("admin_user_ratings_empty"))
text_parts.extend(["", f"<b>{_('admin_user_ratings_invited_title')}</b>"])
if invited_top:
for idx, row in enumerate(invited_top, start=1):
text_parts.append(
_(
"admin_user_ratings_invited_item",
rank=idx,
user=_format_rating_user_label(row, bot_username),
invited_count=int(row.get("invited_count") or 0),
)
)
else:
text_parts.append(_("admin_user_ratings_empty"))
text_parts.extend(["", f"<b>{_('admin_user_ratings_revenue_title')}</b>"])
if revenue_top:
for idx, row in enumerate(revenue_top, start=1):
text_parts.append(
_(
"admin_user_ratings_revenue_item",
rank=idx,
user=_format_rating_user_label(row, bot_username),
revenue=f"{float(row.get('referral_revenue') or 0):.2f}",
)
)
else:
text_parts.append(_("admin_user_ratings_empty"))
await callback.message.edit_text(
"\n".join(text_parts),
reply_markup=get_back_to_user_management_keyboard(current_lang, i18n),
parse_mode="HTML",
)
File diff suppressed because it is too large Load Diff
-220
View File
@@ -1,220 +0,0 @@
import logging
import re
from datetime import datetime
from typing import Optional
from aiogram import Bot, F, Router, types
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.inline.user_keyboards import (
get_back_to_main_menu_markup,
get_connect_and_main_keyboard,
)
from bot.middlewares.i18n import JsonI18n
from bot.services.promo_code_service import PromoCodeService
from bot.services.subscription_service import SubscriptionService
from bot.states.user_states import UserPromoStates
from bot.utils.callback_answer import safe_answer_callback
from config.settings import Settings
from .start import send_main_menu
router = Router(name="user_promo_router")
SUSPICIOUS_SQL_KEYWORDS_REGEX = re.compile(
r"\b(DROP\s*TABLE|DELETE\s*FROM|ALTER\s*TABLE|TRUNCATE\s*TABLE|UNION\s*SELECT|"
r";\s*SELECT|;\s*INSERT|;\s*UPDATE|;\s*DELETE|xp_cmdshell|sysdatabases|sysobjects|INFORMATION_SCHEMA)\b",
re.IGNORECASE,
)
SUSPICIOUS_CHARS_REGEX = re.compile(r"(--|#\s|;|\*\/|\/\*)")
MAX_PROMO_CODE_INPUT_LENGTH = 100
async def prompt_promo_code_input(
callback: types.CallbackQuery,
state: FSMContext,
i18n_data: dict,
settings: Settings,
session: AsyncSession,
back_callback: str = "main_action:back_to_main",
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n:
await safe_answer_callback(callback, "Language service error.", show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
if not callback.message:
logging.error("CallbackQuery has no message in prompt_promo_code_input")
await safe_answer_callback(
callback,
_("error_occurred_processing_request"),
show_alert=True,
)
return
try:
await callback.message.edit_text(
text=_(key="promo_code_prompt"),
reply_markup=get_back_to_main_menu_markup(
current_lang,
i18n,
callback_data=back_callback,
),
)
except Exception as e_edit:
logging.warning(f"Failed to edit message for promo prompt: {e_edit}. Sending new one.")
await callback.message.answer(
text=_(key="promo_code_prompt"),
reply_markup=get_back_to_main_menu_markup(
current_lang,
i18n,
callback_data=back_callback,
),
)
await safe_answer_callback(callback)
await state.set_state(UserPromoStates.waiting_for_promo_code)
logging.info(
f"User {callback.from_user.id} entered state UserPromoStates.waiting_for_promo_code. "
f"FSM state: {await state.get_state()}"
)
@router.message(UserPromoStates.waiting_for_promo_code, F.text)
async def process_promo_code_input(
message: types.Message,
state: FSMContext,
settings: Settings,
i18n_data: dict,
promo_code_service: PromoCodeService,
subscription_service: SubscriptionService,
bot: Bot,
session: AsyncSession,
):
logging.info(
f"Processing promo code input from user {message.from_user.id} in state {await state.get_state()}: '{message.text}'" # noqa: E501
)
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n or not promo_code_service:
logging.error("Dependencies (i18n or PromoCodeService) missing in process_promo_code_input")
await message.reply("Service error. Please try again later.")
await state.clear()
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
code_input = message.text.strip() if message.text else ""
user = message.from_user
is_suspicious = False
if not code_input:
is_suspicious = True
logging.warning(f"Empty promo code input by user {user.id}.")
elif (
len(code_input) > MAX_PROMO_CODE_INPUT_LENGTH
or SUSPICIOUS_SQL_KEYWORDS_REGEX.search(code_input)
or SUSPICIOUS_CHARS_REGEX.search(code_input)
):
is_suspicious = True
logging.warning(
f"Suspicious input for promo code by user {user.id} (len: {len(code_input)}): '{code_input}'" # noqa: E501
)
response_to_user_text = ""
if is_suspicious:
# Send notification through NotificationService if enabled
if settings.LOG_SUSPICIOUS_ACTIVITY:
try:
from bot.services.notification_service import NotificationService
notification_service = NotificationService(bot, settings, i18n)
await notification_service.notify_suspicious_promo_attempt(
user_id=user.id,
username=user.username,
first_name=user.first_name,
suspicious_input=code_input,
)
except Exception as e:
logging.error(f"Failed to send suspicious promo notification: {e}")
success, result = await promo_code_service.apply_promo_code(
session, user.id, code_input, current_lang
)
if success:
await session.commit()
logging.info(f"Promo code '{code_input}' successfully applied for user {user.id}.")
new_end_date = result if isinstance(result, datetime) else None
active = await subscription_service.get_active_subscription_details(session, user.id)
config_link_display = active.get("config_link") if active else None
connect_button_url = active.get("connect_button_url") if active else None
config_link_text = config_link_display or _("config_link_not_available")
response_to_user_text = _(
"promo_code_applied_success_full",
end_date=(new_end_date.strftime("%d.%m.%Y %H:%M:%S") if new_end_date else "N/A"),
config_link=config_link_text,
)
reply_markup = get_connect_and_main_keyboard(
current_lang,
i18n,
settings,
config_link_display,
connect_button_url=connect_button_url,
)
else:
await session.commit()
logging.info(
f"Promo code '{code_input}' application failed for user {user.id}. Reason: {result}"
)
response_to_user_text = result
reply_markup = get_back_to_main_menu_markup(current_lang, i18n)
await message.answer(
response_to_user_text,
reply_markup=reply_markup,
parse_mode="HTML",
)
await state.clear()
logging.info(
f"Promo code input '{code_input}' processing finished for user {message.from_user.id}. State cleared." # noqa: E501
)
@router.callback_query(F.data == "main_action:back_to_main", UserPromoStates.waiting_for_promo_code)
async def cancel_promo_input_via_button(
callback: types.CallbackQuery,
state: FSMContext,
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")
if not i18n:
logging.error("i18n missing in cancel_promo_input_via_button")
await safe_answer_callback(callback, "Language error", show_alert=True)
return
logging.info(
f"User {callback.from_user.id} cancelled promo code input via button from state {await state.get_state()}. Clearing state." # noqa: E501
)
await state.clear()
if callback.message:
await send_main_menu(
callback, settings, i18n_data, subscription_service, session, is_edit=True
)
else:
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
await safe_answer_callback(
callback,
_("promo_input_cancelled_short"),
show_alert=False,
)
-254
View File
@@ -1,254 +0,0 @@
import logging
from typing import Optional, Union
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
from aiogram import Bot, F, Router, types
from aiogram.filters import Command
from sqlalchemy.ext.asyncio import AsyncSession
from bot.middlewares.i18n import JsonI18n
from bot.services.referral_service import ReferralService
from config.settings import Settings
from db.dal import user_dal
router = Router(name="user_referral_router")
async def referral_command_handler(
event: Union[types.Message, types.CallbackQuery],
settings: Settings,
i18n_data: dict,
referral_service: ReferralService,
bot: Bot,
session: AsyncSession,
back_callback: str = "main_action:back_to_main",
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
target_message_obj = event.message if isinstance(event, types.CallbackQuery) else event
if not target_message_obj:
logging.error(
"Target message is None in referral_command_handler (possibly from callback without message)." # noqa: E501
)
if isinstance(event, types.CallbackQuery):
await event.answer("Error displaying referral info.", show_alert=True)
return
if not i18n or not referral_service:
logging.error("Dependencies (i18n or ReferralService) missing in referral_command_handler")
await target_message_obj.answer("Service error. Please try again later.")
if isinstance(event, types.CallbackQuery):
await event.answer()
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
try:
bot_info = await bot.get_me()
bot_username = bot_info.username
except Exception as e_bot_info:
logging.error(f"Failed to get bot info for referral link: {e_bot_info}")
await target_message_obj.answer(_("error_generating_referral_link"))
if isinstance(event, types.CallbackQuery):
await event.answer()
return
if not bot_username:
logging.error("Bot username is None, cannot generate referral link.")
await target_message_obj.answer(_("error_generating_referral_link"))
if isinstance(event, types.CallbackQuery):
await event.answer()
return
inviter_user_id = event.from_user.id
referral_link = await referral_service.generate_referral_link(
session, bot_username, inviter_user_id
)
if not referral_link:
logging.error(
"Failed to generate referral link for user %s (probably missing DB record).",
inviter_user_id,
)
await target_message_obj.answer(_("error_generating_referral_link"))
if isinstance(event, types.CallbackQuery):
await event.answer()
return
bonus_info_parts = []
if getattr(settings, "traffic_sale_mode", False):
bonus_details_str = _("referral_not_available_for_traffic")
else:
if settings.subscription_options:
for months_period_key, _price in sorted(settings.subscription_options.items()):
inv_bonus = settings.referral_bonus_inviter.get(months_period_key)
ref_bonus = settings.referral_bonus_referee.get(months_period_key)
if inv_bonus is not None or ref_bonus is not None:
bonus_info_parts.append(
_(
"referral_bonus_per_period",
months=months_period_key,
inviter_bonus_days=inv_bonus
if inv_bonus is not None
else _("no_bonus_placeholder"),
referee_bonus_days=ref_bonus
if ref_bonus is not None
else _("no_bonus_placeholder"),
)
)
bonus_details_str = (
"\n".join(bonus_info_parts) if bonus_info_parts else _("referral_no_bonuses_configured")
)
referral_stats = await referral_service.get_referral_stats(session, inviter_user_id)
webapp_referral_link = await _generate_webapp_referral_link(
session,
settings,
inviter_user_id,
)
webapp_link_section = (
_(
"referral_webapp_link_line",
webapp_referral_link=webapp_referral_link,
)
if webapp_referral_link
else ""
)
text = _(
"referral_program_info_new",
referral_link=referral_link,
webapp_link_section=webapp_link_section,
bonus_details=bonus_details_str,
invited_count=referral_stats["invited_count"],
purchased_count=referral_stats["purchased_count"],
)
from bot.keyboards.inline.user_keyboards import get_referral_link_keyboard
reply_markup_val = get_referral_link_keyboard(
current_lang,
i18n,
back_callback=back_callback,
)
if isinstance(event, types.Message):
await event.answer(text, reply_markup=reply_markup_val, disable_web_page_preview=True)
elif isinstance(event, types.CallbackQuery) and event.message:
try:
await event.message.edit_text(
text, reply_markup=reply_markup_val, disable_web_page_preview=True
)
except Exception as e_edit:
logging.warning(f"Failed to edit message for referral info: {e_edit}. Sending new one.")
await event.message.answer(
text, reply_markup=reply_markup_val, disable_web_page_preview=True
)
await event.answer()
@router.callback_query(F.data.startswith("referral_action:"))
async def referral_action_handler(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
referral_service: ReferralService,
bot: Bot,
session: AsyncSession,
):
action = callback.data.split(":")[1]
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n = i18n_data.get("i18n_instance")
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
if action == "share_message":
try:
bot_info = await bot.get_me()
bot_username = bot_info.username
if not bot_username:
await callback.answer(_("error_generating_referral_link"), show_alert=True)
return
inviter_user_id = callback.from_user.id
referral_link = await referral_service.generate_referral_link(
session, bot_username, inviter_user_id
)
if not referral_link:
logging.error(
"Failed to generate referral link for user %s via inline button.",
inviter_user_id,
)
await callback.answer(_("error_generating_referral_link"), show_alert=True)
return
webapp_referral_link = await _generate_webapp_referral_link(
session,
settings,
inviter_user_id,
)
if webapp_referral_link:
friend_message = _(
"referral_friend_message_with_webapp",
referral_link=referral_link,
webapp_referral_link=webapp_referral_link,
)
else:
friend_message = _("referral_friend_message", referral_link=referral_link)
await callback.message.answer(friend_message, disable_web_page_preview=True)
except Exception as e:
logging.error(f"Error in referral share message: {e}")
await callback.answer(_("error_occurred_try_again"), show_alert=True)
await callback.answer()
def _build_webapp_referral_link(
base_url: Optional[str], referral_code: Optional[str]
) -> Optional[str]:
if not base_url or not referral_code:
return None
parts = urlsplit(base_url)
query = dict(parse_qsl(parts.query, keep_blank_values=True))
query["ref"] = f"u{referral_code}"
return urlunsplit(
(
parts.scheme,
parts.netloc,
parts.path or "/",
urlencode(query),
parts.fragment,
)
)
async def _generate_webapp_referral_link(
session: AsyncSession,
settings: Settings,
inviter_user_id: int,
) -> Optional[str]:
if not settings.SUBSCRIPTION_MINI_APP_URL:
return None
db_user = await user_dal.get_user_by_id(session, inviter_user_id)
referral_code = await user_dal.ensure_referral_code(session, db_user) if db_user else None
return _build_webapp_referral_link(
settings.SUBSCRIPTION_MINI_APP_URL,
referral_code,
)
@router.message(Command("referral"))
async def referral_command_message_handler(
message: types.Message,
settings: Settings,
i18n_data: dict,
referral_service: ReferralService,
bot: Bot,
session: AsyncSession,
):
await referral_command_handler(message, settings, i18n_data, referral_service, bot, session)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-113
View File
@@ -1,113 +0,0 @@
import asyncio
import json
import logging
import secrets
from contextlib import asynccontextmanager
from typing import Any, AsyncIterator, Optional
try:
from redis.asyncio import Redis
except ModuleNotFoundError: # pragma: no cover - local dev environments may not be installed yet
Redis = None # type: ignore[assignment]
from config.settings import Settings
logger = logging.getLogger(__name__)
_redis: Optional["Redis"] = None
def redis_key(settings: Settings, *parts: object) -> str:
prefix = (settings.REDIS_KEY_PREFIX or "remnawave-tg-shop").strip(":")
clean = [str(part).strip(":") for part in parts if str(part).strip(":")]
return ":".join([prefix, *clean])
async def get_redis(settings: Settings) -> Optional["Redis"]:
global _redis
if not settings.REDIS_URL:
return None
if Redis is None:
logger.warning("REDIS_URL is set but redis package is not installed")
return None
if _redis is None:
_redis = Redis.from_url(
settings.REDIS_URL,
encoding="utf-8",
decode_responses=True,
health_check_interval=30,
)
return _redis
async def close_redis() -> None:
global _redis
if _redis is not None:
await _redis.aclose()
_redis = None
async def cache_get_json(settings: Settings, key: str) -> Any:
redis = await get_redis(settings)
if redis is None:
return None
raw = await redis.get(key)
if raw is None:
return None
try:
return json.loads(raw)
except json.JSONDecodeError:
logger.warning("Invalid JSON in Redis cache key %s", key)
return None
async def cache_set_json(settings: Settings, key: str, value: Any, ttl_seconds: int) -> None:
redis = await get_redis(settings)
if redis is None:
return
await redis.set(key, json.dumps(value, ensure_ascii=False, default=str), ex=ttl_seconds)
async def cache_delete(settings: Settings, *keys: str) -> None:
redis = await get_redis(settings)
if redis is None or not keys:
return
await redis.delete(*keys)
@asynccontextmanager
async def redis_lock(
settings: Settings,
name: str,
*,
ttl_seconds: int,
) -> AsyncIterator[bool]:
redis = await get_redis(settings)
if redis is None:
yield True
return
key = redis_key(settings, "lock", name)
token = secrets.token_urlsafe(16)
acquired = bool(await redis.set(key, token, nx=True, ex=ttl_seconds))
try:
yield acquired
finally:
if acquired:
script = """
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
end
return 0
"""
try:
await redis.eval(script, 1, key, token)
except Exception:
logger.exception("Failed to release Redis lock %s", key)
async def sleep_or_stop(stop_event: asyncio.Event, seconds: float) -> None:
try:
await asyncio.wait_for(stop_event.wait(), timeout=seconds)
except asyncio.TimeoutError:
return
-63
View File
@@ -1,63 +0,0 @@
import json
import logging
import time
from typing import Any, Dict, Optional
from bot.infra.redis import get_redis, redis_key
from config.settings import Settings
logger = logging.getLogger(__name__)
def webhook_queue_key(settings: Settings) -> str:
return redis_key(settings, "queue", settings.WEBHOOK_QUEUE_NAME)
async def enqueue_webhook_event(
settings: Settings,
provider: str,
payload: Dict[str, Any],
*,
event_id: Optional[str] = None,
) -> bool:
redis = await get_redis(settings)
if redis is None:
return False
dedupe_id = event_id or payload.get("id") or payload.get("event_id")
if dedupe_id:
dedupe_key = redis_key(settings, "webhook", "seen", provider, dedupe_id)
if not await redis.set(dedupe_key, "1", nx=True, ex=24 * 60 * 60):
logger.info("Skipping duplicate %s webhook event %s", provider, dedupe_id)
return True
message = {
"provider": provider,
"event_id": dedupe_id,
"payload": payload,
"enqueued_at": time.time(),
}
await redis.lpush(webhook_queue_key(settings), json.dumps(message, ensure_ascii=False))
return True
async def pop_webhook_event(settings: Settings, timeout_seconds: int = 5) -> Optional[dict]:
redis = await get_redis(settings)
if redis is None:
return None
item = await redis.brpop(webhook_queue_key(settings), timeout=timeout_seconds)
if not item:
return None
_, raw = item
try:
return json.loads(raw)
except json.JSONDecodeError:
logger.warning("Invalid webhook queue payload discarded")
return None
async def webhook_queue_depth(settings: Settings) -> int:
redis = await get_redis(settings)
if redis is None:
return 0
return int(await redis.llen(webhook_queue_key(settings)))

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