From aaf04faa961206e791bf6518ae0377f17669cf75 Mon Sep 17 00:00:00 2001 From: VPN Service Date: Sun, 26 Jul 2026 23:21:41 +0300 Subject: [PATCH] Initial commit: VPN Telegram bot (sanitized defaults) --- .dockerignore | 12 + .env.example | 121 ++ .gitattributes | 10 + .gitignore | 19 + Dockerfile | 20 + README.md | 390 ++++ bot.py | 355 ++++ caddy/Caddyfile | 18 + caddy/README.md | 37 + check_api.py | 88 + config.py | 348 ++++ docker-compose.yml | 89 + filters.py | 19 + handlers_admin.py | 364 ++++ handlers_shop.py | 1723 ++++++++++++++++ handlers_tickets.py | 590 ++++++ handlers_user.py | 245 +++ install.sh | 574 ++++++ keyboards.py | 717 +++++++ remnawave_client.py | 99 + requirements.txt | 9 + services/__init__.py | 0 services/admin_alerts.py | 37 + services/anypay.py | 289 +++ services/awg_client.py | 297 +++ services/awg_configs.py | 488 +++++ services/clients.py | 216 ++ services/cryptopay.py | 107 + services/db.py | 3229 ++++++++++++++++++++++++++++++ services/devices.py | 91 + services/digiseller.py | 273 +++ services/digiseller_fulfill.py | 127 ++ services/env_file.py | 194 ++ services/extras_billing.py | 355 ++++ services/faq.py | 280 +++ services/heleket.py | 159 ++ services/lava.py | 200 ++ services/legal_pages.py | 782 ++++++++ services/mailer.py | 364 ++++ services/nodes_status.py | 126 ++ services/openrouter.py | 273 +++ services/passwords.py | 49 + services/payments.py | 105 + services/payments_runtime.py | 233 +++ services/pg_backup.py | 284 +++ services/purchase.py | 674 +++++++ services/qrcode_svg.py | 26 + services/rate_limit.py | 41 + services/remnawave_users.py | 115 ++ services/smtp_runtime.py | 90 + services/stars.py | 135 ++ services/subscription_renewal.py | 254 +++ services/tariffs.py | 188 ++ services/tg_webapp.py | 193 ++ services/ticket_ai.py | 231 +++ services/tickets.py | 227 +++ services/topup_code_redeem.py | 65 + services/topup_codes.py | 31 + services/traffic.py | 134 ++ services/transactions_ui.py | 49 + services/trial.py | 99 + services/vpn_configs_expiry.py | 63 + services/web_topup.py | 264 +++ services/webapp_growth_routes.py | 833 ++++++++ services/webauthn_auth.py | 164 ++ services/xui_client.py | 643 ++++++ services/xui_wg.py | 588 ++++++ tariffs.json | 84 + webapp/admin.html | 1087 ++++++++++ webapp/index.html | 678 +++++++ webapp/static/admin.css | 1394 +++++++++++++ webapp/static/admin.js | 2422 ++++++++++++++++++++++ webapp/static/app.js | 2523 +++++++++++++++++++++++ webapp/static/styles.css | 1253 ++++++++++++ webapp/static/theme-bs.css | 86 + webapp_admin_routes.py | 1683 ++++++++++++++++ webapp_server.py | 2527 +++++++++++++++++++++++ 77 files changed, 33249 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 bot.py create mode 100644 caddy/Caddyfile create mode 100644 caddy/README.md create mode 100644 check_api.py create mode 100644 config.py create mode 100644 docker-compose.yml create mode 100644 filters.py create mode 100644 handlers_admin.py create mode 100644 handlers_shop.py create mode 100644 handlers_tickets.py create mode 100644 handlers_user.py create mode 100644 install.sh create mode 100644 keyboards.py create mode 100644 remnawave_client.py create mode 100644 requirements.txt create mode 100644 services/__init__.py create mode 100644 services/admin_alerts.py create mode 100644 services/anypay.py create mode 100644 services/awg_client.py create mode 100644 services/awg_configs.py create mode 100644 services/clients.py create mode 100644 services/cryptopay.py create mode 100644 services/db.py create mode 100644 services/devices.py create mode 100644 services/digiseller.py create mode 100644 services/digiseller_fulfill.py create mode 100644 services/env_file.py create mode 100644 services/extras_billing.py create mode 100644 services/faq.py create mode 100644 services/heleket.py create mode 100644 services/lava.py create mode 100644 services/legal_pages.py create mode 100644 services/mailer.py create mode 100644 services/nodes_status.py create mode 100644 services/openrouter.py create mode 100644 services/passwords.py create mode 100644 services/payments.py create mode 100644 services/payments_runtime.py create mode 100644 services/pg_backup.py create mode 100644 services/purchase.py create mode 100644 services/qrcode_svg.py create mode 100644 services/rate_limit.py create mode 100644 services/remnawave_users.py create mode 100644 services/smtp_runtime.py create mode 100644 services/stars.py create mode 100644 services/subscription_renewal.py create mode 100644 services/tariffs.py create mode 100644 services/tg_webapp.py create mode 100644 services/ticket_ai.py create mode 100644 services/tickets.py create mode 100644 services/topup_code_redeem.py create mode 100644 services/topup_codes.py create mode 100644 services/traffic.py create mode 100644 services/transactions_ui.py create mode 100644 services/trial.py create mode 100644 services/vpn_configs_expiry.py create mode 100644 services/web_topup.py create mode 100644 services/webapp_growth_routes.py create mode 100644 services/webauthn_auth.py create mode 100644 services/xui_client.py create mode 100644 services/xui_wg.py create mode 100644 tariffs.json create mode 100644 webapp/admin.html create mode 100644 webapp/index.html create mode 100644 webapp/static/admin.css create mode 100644 webapp/static/admin.js create mode 100644 webapp/static/app.js create mode 100644 webapp/static/styles.css create mode 100644 webapp/static/theme-bs.css create mode 100644 webapp_admin_routes.py create mode 100644 webapp_server.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..359c7ea --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +.venv +venv +__pycache__ +*.py[cod] +*.log +.env +.idea +.vscode +data/*.db +data/*.db-* +README.md +*.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d21d0e9 --- /dev/null +++ b/.env.example @@ -0,0 +1,121 @@ +# Remnawave panel +REMNAWAVE_BASE_URL=https://panel.example.com +REMNAWAVE_TOKEN= + +# Альтернатива: логин админа панели +# REMNAWAVE_USERNAME=admin +# REMNAWAVE_PASSWORD= + +# Telegram bot (от @BotFather) +BOT_TOKEN= +# Опционально: Client ID для Web Login (обычно = число до «:» в BOT_TOKEN) +# TELEGRAM_CLIENT_ID= + +# Через запятую, например: 123456789,987654321 +ADMIN_IDS= + +# Telegram Stars — пополнение баланса в боте (без доп. токена) +# STARS_ENABLED=true +# Сколько рублей зачислять за 1 ⭐ (курс) +# STARS_RUB_PER_STAR=1.5 + +# Crypto Pay (@CryptoBot → Crypto Pay → Create App) +# Можно заполнить в веб-админке /admin → Платежи (пишет в этот .env) +CRYPTO_BOT_TOKEN= +# CRYPTO_PAY_TESTNET=false + +# Heleket — резервная крипто-оплата (https://doc.heleket.com/ru) +HELEKET_MERCHANT_ID= +HELEKET_API_KEY= +# Если пусто — берётся WEBAPP_URL/api/payments/heleket +# HELEKET_CALLBACK_URL=https://bot.example.com/api/payments/heleket +# HELEKET_CURRENCY=RUB + +# Lava.ru — карта / СБП (https://dev.lava.ru/) +LAVA_SHOP_ID= +LAVA_SECRET_KEY= +# Доп. ключ для проверки webhook (обязателен для безопасного автозачисления) +LAVA_ADDITIONAL_KEY= +# LAVA_HOOK_URL=https://bot.example.com/api/payments/lava +# LAVA_SUCCESS_URL=https://bot.example.com/pay/lava/success +# LAVA_FAIL_URL=https://bot.example.com/pay/lava/fail +# LAVA_EXPIRE_MINUTES=60 + +# AnyPay — карта / СБП / крипта (https://anypay.io/doc/api/create-payment) +# В настройках проекта → URL оповещения: https://bot.example.com/api/payments/anypay +ANYPAY_API_ID= +ANYPAY_API_KEY= +ANYPAY_PROJECT_ID= +# ANYPAY_CURRENCY=RUB +# Пустой METHOD = покупатель выбирает способ на странице AnyPay (SCI) +# ANYPAY_METHOD= +# ANYPAY_SUCCESS_URL=https://bot.example.com/pay/anypay/success +# ANYPAY_FAIL_URL=https://bot.example.com/pay/anypay/fail +# ANYPAY_EMAIL=pay@example.com +# ANYPAY_CHECK_IP=false + +# Digiseller — автопополнение по уникальному коду +# (https://my.digiseller.com/inside/api_general.asp) +DIGISELLER_SELLER_ID= +DIGISELLER_API_KEY= +# ID товаров для пополнения (через запятую). Пусто = любой товар продавца. +# DIGISELLER_PRODUCT_IDS=123456 +# Ссылка на оплату (иначе соберётся из первого product id) +# DIGISELLER_PAY_URL=https://oplata.info/asp2/pay.asp?id_d=123456 +# cnt_goods = сумма в ₽ (товар без фикс. цены) | amount = сумма из ответа API +# DIGISELLER_CREDIT_FROM=cnt_goods +# URL автопроверки — укажи его в товаре Digiseller: +# «Проверка уникального кода» → Автоматическая → deliver_url +# Если пусто — берётся WEBAPP_URL/api/payments/digiseller +# DIGISELLER_CALLBACK_URL=https://bot.example.com/api/payments/digiseller + +# PostgreSQL 17 +POSTGRES_USER=vpnbot +POSTGRES_PASSWORD=change-me-strong-password +POSTGRES_DB=vpnbot +# DATABASE_URL=postgresql://vpnbot:change-me-strong-password@db:5432/vpnbot + +# Telegram Mini App (публичный HTTPS через Caddy) +WEBAPP_URL=https://bot.example.com +WEBAPP_DOMAIN=bot.example.com +WEBAPP_HOST=0.0.0.0 +WEBAPP_PORT=8080 + +# Telegram Bot API webhook (вместо long polling). +# По умолчанию ВКЛ, если WEBAPP_URL начинается с https:// +# TELEGRAM_WEBHOOK=true +# TELEGRAM_WEBHOOK_PATH=/telegram/webhook +# Полный URL (если пусто = WEBAPP_URL + PATH): +# TELEGRAM_WEBHOOK_URL=https://bot.example.com/telegram/webhook +# Секрет в заголовке X-Telegram-Bot-Api-Secret-Token (если пусто — генерируется из BOT_TOKEN) +# TELEGRAM_WEBHOOK_SECRET= + +# Коды пополнения — ссылка покупки кода (кнопка в боте и на сайте) +TOPUP_CODE_PAY_URL= + +# Веб-админка: https://ваш-домен/admin +WEB_ADMIN_LOGIN= +WEB_ADMIN_PASSWORD= + +# Для Login Widget: в @BotFather → Web Login → bot.example.com + +# Optional: Caddy API key +# CADDY_TOKEN= + +# SMTP — важные письма пользователям (также /admin → SMTP) +# SMTP_HOST=smtp.example.com +# SMTP_PORT=587 +# SMTP_USER= +# SMTP_PASSWORD= +# SMTP_FROM=noreply@example.com +# SMTP_TLS=true +# SMTP_SSL=false + +# Юр. страницы (/offer /terms /privacy /aup /refund) — для EU/US платёжек +# BRAND_NAME=VPN Service +# BRAND_SITE=example.com +# LEGAL_OPERATOR_NAME=Your Company Ltd +# LEGAL_OPERATOR_ADDRESS=Registered address / юр. адрес +# LEGAL_SUPPORT_EMAIL=support@example.com +# LEGAL_EFFECTIVE_DATE=2026-07-24 +# STATUS_PAGE_URL=https://status.example.com/ diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..848632f --- /dev/null +++ b/.gitattributes @@ -0,0 +1,10 @@ +# Shell scripts must use LF so bash on Linux doesn't break on `set -o pipefail` +*.sh text eol=lf +install.sh text eol=lf + +# Keep Docker/env examples Unix-friendly +Dockerfile text eol=lf +*.yml text eol=lf +*.yaml text eol=lf +Caddyfile text eol=lf +.env.example text eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4aa85eb --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +.env +.venv/ +venv/ +__pycache__/ +*.py[cod] +*.egg-info/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +dist/ +build/ +*.log +.idea/ +.vscode/ +data/ +*.db +backups/ +*.zip +_tmp* diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ab752df --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +FROM python:3.12-slim + +WORKDIR /app + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl postgresql-client \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --upgrade pip \ + && pip install -r requirements.txt + +COPY . . + +CMD ["python", "bot.py"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..56c80cc --- /dev/null +++ b/README.md @@ -0,0 +1,390 @@ +# VPN Telegram Bot + Remnawave Panel + +Telegram-бот для выдачи VPN-подписок через API панели **Remnawave**. + +Деплой: **Docker Compose** на Ubuntu (22.04 / 24.04 LTS). + +Сейчас контейнер запускает проверку API (`check_api.py`). Дальше — `bot.py`. + +--- + +## Содержание + +1. [Что понадобится](#1-что-понадобится) +2. [Токен API в Remnawave](#2-токен-api-в-remnawave) +3. [Установка Docker на Ubuntu](#3-установка-docker-на-ubuntu) +4. [Установка проекта](#4-установка-проекта) +5. [Настройка `.env`](#5-настройка-env) +6. [Сборка и проверка API](#6-сборка-и-проверка-api) +7. [Структура проекта](#7-структура-проекта) +8. [Постоянный запуск бота](#8-постоянный-запуск-бота) +9. [Полезные команды](#9-полезные-команды) +10. [Частые ошибки](#10-частые-ошибки) +11. [Что дальше](#11-что-дальше) + +--- + +## 1. Что понадобится + +| Компонент | Зачем | +|-----------|--------| +| VPS / сервер Ubuntu 22.04 или 24.04 | Где крутится Docker | +| Docker + Docker Compose plugin | Сборка и запуск контейнера | +| Доступ к панели Remnawave | URL, например `https://panel.example.com` | +| API Token из панели | Авторизация запросов к API | +| [@BotFather](https://t.me/BotFather) | `BOT_TOKEN` (на следующем этапе) | + +Официальные материалы Remnawave: + +- Панель: [https://remna.st](https://remna.st) +- Python SDK: [https://github.com/remnawave/python-sdk](https://github.com/remnawave/python-sdk) + +--- + +## 2. Токен API в Remnawave + +1. Открой панель (например `https://panel.example.com`). +2. Войди под админом. +3. **Settings → API Tokens** — создай токен. +4. Скопируй сразу и положи в `.env` на сервере. + +Если Caddy режет API отдельным ключом — понадобится ещё `CADDY_TOKEN`. + +--- + +## 3. Установка Docker на Ubuntu + +Подключись по SSH: + +```bash +ssh user@your-server-ip +``` + +### Официальный способ (рекомендуется) + +```bash +sudo apt update +sudo apt install -y ca-certificates curl + +sudo install -m 0755 -d /etc/apt/keyrings +sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc +sudo chmod a+r /etc/apt/keyrings/docker.asc + +echo \ + "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \ + $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \ + sudo tee /etc/apt/sources.list.d/docker.list > /dev/null + +sudo apt update +sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin +``` + +Проверка: + +```bash +docker --version +docker compose version +sudo docker run --rm hello-world +``` + +Чтобы не писать `sudo` каждый раз: + +```bash +sudo usermod -aG docker "$USER" +# выйди из SSH и зайди снова +newgrp docker +docker run --rm hello-world +``` + +### Быстрый вариант (snap, если так удобнее) + +```bash +sudo snap install docker +docker --version +``` + +На production обычно лучше пакеты с `download.docker.com`, как выше. + +--- + +## 4. Установка проекта + +### 4.0. Установщик в 1 команду (рекомендуется) + +На чистом Ubuntu 22.04 / 24.04, в каталоге с кодом: + +```bash +sudo bash install.sh +``` + +Скрипт спросит домен, Remnawave, `BOT_TOKEN`, Postgres, логин админки, SMTP (опционально), поставит Docker при необходимости, откроет 80/443 (UFW), запишет `.env`, поднимет **Postgres + бот + Caddy (Let's Encrypt SSL)**. + +Полезные флаги: + +```bash +sudo bash install.sh --help +sudo bash install.sh --reinstall # пересборка без опроса (.env уже есть) +sudo bash install.sh --env-only # только пересоздать .env +``` + +### 4.1. Положи код на сервер + +```bash +sudo mkdir -p /opt/vpn-telegram-bot +sudo chown "$USER":"$USER" /opt/vpn-telegram-bot +cd /opt/vpn-telegram-bot +``` + +Через git: + +```bash +git clone . +``` + +Или с локальной машины: + +```bash +scp -r ./vpn-telegram-bot/* user@your-server-ip:/opt/vpn-telegram-bot/ +``` + +В каталоге должны быть как минимум: + +- `Dockerfile` +- `docker-compose.yml` +- `requirements.txt` +- `check_api.py` +- `config.py` +- `remnawave_client.py` +- `.env.example` + +### 4.2. Docker Compose не ставит Python на хост + +Python и зависимости живут **внутри образа**. На хосте нужен только Docker. + +--- + +## 5. Настройка `.env` + +```bash +cd /opt/vpn-telegram-bot +cp .env.example .env +nano .env +chmod 600 .env +``` + +Пример: + +```env +REMNAWAVE_BASE_URL=https://panel.example.com +REMNAWAVE_TOKEN=вставь_сюда_токен_панели + +# Токен Telegram-бота от @BotFather +BOT_TOKEN=123456:AA... + +# ID админов через запятую +ADMIN_IDS=123456789 + +# CADDY_TOKEN= +``` + +Правила: + +- `REMNAWAVE_BASE_URL` — без `/` в конце, обычно без `/api`. +- `REMNAWAVE_TOKEN` — без слова `Bearer`. +- `BOT_TOKEN` — токен от [@BotFather](https://t.me/BotFather) (`/newbot`). +- `.env` не коммитить (есть в `.gitignore` и `.dockerignore`). + +Compose подхватывает `.env` через `env_file` и подставляет переменные в контейнер. + +--- + +## 6. Сборка и проверка API + +Из каталога проекта: + +```bash +cd /opt/vpn-telegram-bot + +# собрать образ +docker compose build + +# одноразовый прогон проверки API +docker compose run --rm bot +``` + +Или явно: + +```bash +docker compose run --rm bot python check_api.py +``` + +Успех — в логе что-то вроде: + +```text +✓ Remnawave API принимает запросы. Можно продолжать бота. +``` + +С verbose: + +```bash +docker compose run --rm bot python check_api.py --verbose +``` + +Пересобрать после смены кода: + +```bash +docker compose build --no-cache +``` + +--- + +## 7. Структура проекта + +```text +vpn-telegram-bot/ +├── Dockerfile +├── docker-compose.yml +├── .dockerignore +├── .env # секреты (не в git / не в образ) +├── .env.example +├── .gitignore +├── requirements.txt +├── config.py +├── remnawave_client.py +├── check_api.py +└── README.md +``` + +| Файл | Роль | +|------|------| +| `Dockerfile` | Образ Python 3.12 + зависимости | +| `docker-compose.yml` | Сервис `bot`, env, command | +| `check_api.py` | Проверка, что Remnawave API принимает запросы | + +--- + +## 8. Запуск бота + +`bot.py` отвечает на `/start` приветствием. + +```bash +docker compose up -d --build +docker compose ps +docker compose logs -f bot +``` + +В Telegram открой бота и нажми **Start** (или отправь `/start`). + +Остановить: + +```bash +docker compose down +``` + +Проверка API по-прежнему: + +```bash +docker compose run --rm bot python check_api.py +``` + +--- + +## 9. Полезные команды + +```bash +# статус +docker compose ps + +# логи +docker compose logs -f bot + +# перезапуск +docker compose restart bot + +# войти в контейнер +docker compose run --rm --entrypoint sh bot + +# проверить сеть до панели с хоста +curl -sS -o /dev/null -w "%{http_code}\n" https://panel.example.com/api/system/health + +# проверить из контейнера +docker compose run --rm bot curl -sS -o /dev/null -w "%{http_code}\n" https://panel.example.com/api/system/health +``` + +--- + +## 10. Частые ошибки + +### `REMNAWAVE_TOKEN не задан` + +Пустой `.env` или compose запущен не из каталога с `.env`. + +```bash +cd /opt/vpn-telegram-bot +grep REMNAWAVE_ .env +docker compose run --rm bot env | grep REMNAWAVE_ +``` + +### `401` / токен не принят + +Неверный или отозванный токен, лишние пробелы/переносы в `.env`. + +После правки `.env` перезапускать контейнер: + +```bash +docker compose run --rm bot python check_api.py +``` + +### Не собирается образ / нет Docker + +```bash +docker compose version +sudo systemctl status docker +sudo systemctl enable --now docker +``` + +### Сеть из контейнера + +Если с хоста панель открывается, а из контейнера нет — DNS/firewall Docker: + +```bash +docker compose run --rm bot curl -vI https://panel.example.com +``` + +### Permission denied на docker.sock + +Пользователь не в группе `docker` — см. раздел 3 (`usermod -aG docker`). + +### Caddy auth + +Раскомментируй `CADDY_TOKEN` в `.env`. + +--- + +## 11. Что дальше + +1. Убедиться, что `docker compose run --rm bot` проходит проверку API. +2. Добавить `BOT_TOKEN` в `.env`. +3. Написать `bot.py` (aiogram) и сменить `command` / `restart` в compose. +4. Поднять постоянно: `docker compose up -d --build`. + +--- + +## Краткая шпаргалка (Ubuntu + Docker Compose) + +```bash +# Docker (один раз) +sudo apt update +# ... установка docker-ce и docker-compose-plugin из раздела 3 ... + +# Проект +cd /opt/vpn-telegram-bot +cp .env.example .env +nano .env # REMNAWAVE_BASE_URL + REMNAWAVE_TOKEN +chmod 600 .env + +docker compose build +docker compose run --rm bot +``` + +Если проверка прошла — можно переходить к Telegram-боту в том же Compose. diff --git a/bot.py b/bot.py new file mode 100644 index 0000000..afbe48c --- /dev/null +++ b/bot.py @@ -0,0 +1,355 @@ +"""Telegram-бот + Mini App HTTP (+ Telegram webhook).""" + +from __future__ import annotations + +import asyncio +import logging +import signal +from typing import Any, Awaitable, Callable + +from aiogram import BaseMiddleware, Bot, Dispatcher +from aiogram.client.default import DefaultBotProperties +from aiogram.enums import ParseMode +from aiogram.fsm.storage.memory import MemoryStorage +from aiogram.types import MenuButtonWebApp, TelegramObject, WebAppInfo +from aiogram.webhook.aiohttp_server import SimpleRequestHandler, setup_application + +from config import Settings, get_settings +from filters import IsAdmin +from handlers_admin import router as admin_router +from handlers_admin import setup_admin_router +from handlers_shop import router as shop_router +from handlers_tickets import router as tickets_router +from handlers_user import router as user_router +from remnawave_client import create_sdk_ready, probe_users_write_access +from services.anypay import Anypay +from services.cryptopay import CryptoPay +from services.db import Database +from services.digiseller import Digiseller +from services.heleket import Heleket +from services.lava import Lava +from services.extras_billing import extras_billing_worker +from services.subscription_renewal import subscription_renewal_worker +from services.vpn_configs_expiry import vpn_configs_expiry_worker +from services.tariffs import load_tariffs +from webapp_server import create_webapp, start_webapp_site + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s: %(message)s", +) +logger = logging.getLogger(__name__) + + +class InjectMiddleware(BaseMiddleware): + """Проброс settings, SDK, DB, тарифов и платёжек в хендлеры.""" + + def __init__( + self, + settings: Settings, + sdk, + db: Database, + tariffs, + cryptopay: CryptoPay, + heleket: Heleket, + digiseller: Digiseller, + lava: Lava, + anypay: Anypay, + ) -> None: + self.settings = settings + self.sdk = sdk + self.db = db + self.tariffs = tariffs + self.cryptopay = cryptopay + self.heleket = heleket + self.digiseller = digiseller + self.lava = lava + self.anypay = anypay + + async def __call__( + self, + handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]], + event: TelegramObject, + data: dict[str, Any], + ) -> Any: + data["settings"] = self.settings + data["sdk"] = self.sdk + data["db"] = self.db + data["tariffs"] = self.tariffs + data["cryptopay"] = self.cryptopay + data["heleket"] = self.heleket + data["digiseller"] = self.digiseller + data["lava"] = self.lava + data["anypay"] = self.anypay + + # Бан: режем обычные апдейты (админам можно) + user = getattr(event, "from_user", None) or getattr( + getattr(event, "message", None), "from_user", None + ) + if user and user.id not in self.settings.admin_ids: + bot_user = self.db.get_user(user.id) + if bot_user and bot_user.banned: + from aiogram.types import CallbackQuery, Message + + try: + if isinstance(event, CallbackQuery): + await event.answer("🚫 Аккаунт заблокирован.", show_alert=True) + elif isinstance(event, Message): + await event.answer("🚫 Аккаунт заблокирован.") + except Exception: # noqa: BLE001 + pass + return None + return await handler(event, data) + + +async def _idle_until_stop() -> None: + """Держим процесс живым в режиме webhook (вместо polling).""" + stop = asyncio.Event() + loop = asyncio.get_running_loop() + + def _stop(*_: Any) -> None: + stop.set() + + for sig in (signal.SIGINT, signal.SIGTERM): + try: + loop.add_signal_handler(sig, _stop) + except (NotImplementedError, RuntimeError): + pass + await stop.wait() + + +async def main() -> None: + settings = get_settings(require_bot_token=True) + if not settings.admin_ids: + logger.warning("ADMIN_IDS пуст — админка будет недоступна никому") + if settings.web_admin_enabled: + logger.info( + "Web admin /admin OK (login=%s, pass_len=%s)", + settings.web_admin_login, + len(settings.web_admin_password), + ) + else: + logger.warning( + "Web admin /admin выключен — задайте WEB_ADMIN_LOGIN и WEB_ADMIN_PASSWORD в .env" + ) + + tariffs = load_tariffs(settings.tariffs_path) + logger.info("Loaded %s tariffs from %s", len(tariffs), settings.tariffs_path) + + logger.info( + "Telegram Stars: %s (1⭐ ≈ %s ₽)", + "ON" if settings.stars_enabled else "OFF", + settings.stars_rub_per_star, + ) + + cryptopay = CryptoPay( + settings.crypto_bot_token, + testnet=settings.crypto_pay_testnet, + ) + if cryptopay.enabled: + try: + me_cp = await cryptopay.get_me() + logger.info("Crypto Pay OK: %s", me_cp) + except Exception as exc: # noqa: BLE001 + logger.error("Crypto Pay token invalid: %s", exc) + else: + logger.warning("CRYPTO_BOT_TOKEN пуст — Crypto Bot выключен") + + heleket = Heleket( + settings.heleket_merchant_id, + settings.heleket_api_key, + callback_url=settings.heleket_callback_url, + currency=settings.heleket_currency, + ) + if heleket.enabled: + logger.info( + "Heleket OK (merchant=%s… callback=%s currency=%s)", + settings.heleket_merchant_id[:8], + settings.heleket_callback_url or "—", + settings.heleket_currency, + ) + else: + logger.warning("Heleket не настроен — резервная крипта выключена") + + digiseller = Digiseller( + settings.digiseller_seller_id, + settings.digiseller_api_key, + product_ids=settings.digiseller_product_ids, + pay_url=settings.digiseller_pay_url, + credit_from=settings.digiseller_credit_from, + ) + if digiseller.enabled: + logger.info( + "Digiseller OK (seller=%s products=%s credit=%s callback=%s)", + settings.digiseller_seller_id, + settings.digiseller_product_ids or "any", + settings.digiseller_credit_from, + settings.digiseller_callback_url or "—", + ) + else: + logger.warning("Digiseller не настроен — пополнение по уник. коду выключено") + + lava = Lava( + settings.lava_shop_id, + settings.lava_secret_key, + additional_key=settings.lava_additional_key, + hook_url=settings.lava_hook_url, + success_url=settings.lava_success_url, + fail_url=settings.lava_fail_url, + expire_minutes=settings.lava_expire_minutes, + ) + if lava.enabled: + logger.info( + "Lava OK (shop=%s… hook=%s success=%s)", + settings.lava_shop_id[:8], + settings.lava_hook_url or "—", + settings.lava_success_url or "—", + ) + else: + logger.warning("Lava не настроен — карта/СБП выключены") + + anypay = Anypay( + settings.anypay_api_id, + settings.anypay_api_key, + settings.anypay_project_id, + currency=settings.anypay_currency, + method=settings.anypay_method, + success_url=settings.anypay_success_url, + fail_url=settings.anypay_fail_url, + default_email=settings.anypay_email or "pay@anypay.local", + check_ip=settings.anypay_check_ip, + ) + if anypay.enabled: + logger.info( + "AnyPay OK (api_id=%s… project=%s notify=%s/api/payments/anypay)", + settings.anypay_api_id[:6], + settings.anypay_project_id, + settings.webapp_url or "", + ) + else: + logger.warning("AnyPay не настроен — ANYPAY_API_ID / KEY / PROJECT_ID") + + db = Database(settings.database_url) + sdk = await create_sdk_ready(settings) + ok_write, write_msg = await probe_users_write_access(sdk) + if ok_write: + logger.info("Remnawave write access: %s", write_msg) + else: + logger.error("Remnawave write access FAILED:\n%s", write_msg) + + bot = Bot( + token=settings.bot_token, + default=DefaultBotProperties(parse_mode=ParseMode.HTML), + ) + me = await bot.get_me() + bot_username = me.username or "" + + dp = Dispatcher(storage=MemoryStorage()) + inject = InjectMiddleware( + settings, sdk, db, tariffs, cryptopay, heleket, digiseller, lava, anypay + ) + dp.update.middleware(inject) + + setup_admin_router(IsAdmin(settings)) + dp.include_router(admin_router) + dp.include_router(shop_router) + dp.include_router(tickets_router) + dp.include_router(user_router) + + web_runner = None + web_app = create_webapp( + settings=settings, + db=db, + sdk=sdk, + tariffs=tariffs, + cryptopay=cryptopay, + heleket=heleket, + digiseller=digiseller, + lava=lava, + anypay=anypay, + bot=bot, + bot_username=bot_username, + ) + web_app["inject"] = inject + + use_webhook = bool(settings.telegram_webhook_enabled) + webhook_url = settings.effective_telegram_webhook_url + webhook_path = settings.effective_telegram_webhook_path + if use_webhook: + if not webhook_url.startswith("https://"): + logger.error( + "TELEGRAM_WEBHOOK включён, но нет HTTPS URL (WEBAPP_URL / TELEGRAM_WEBHOOK_URL). " + "Fallback на long polling." + ) + use_webhook = False + else: + SimpleRequestHandler( + dispatcher=dp, + bot=bot, + secret_token=settings.telegram_webhook_secret or None, + ).register(web_app, path=webhook_path) + setup_application(web_app, dp, bot=bot) + + web_runner = await start_webapp_site( + web_app, settings.webapp_host, settings.webapp_port + ) + + if settings.webapp_url: + try: + await bot.set_chat_menu_button( + menu_button=MenuButtonWebApp( + text="Кабинет", + web_app=WebAppInfo(url=settings.webapp_url), + ) + ) + logger.info("Menu button WebApp -> %s", settings.webapp_url) + except Exception as exc: # noqa: BLE001 + logger.warning("Не удалось поставить Menu Button: %s", exc) + else: + logger.warning( + "WEBAPP_URL пуст — кнопка Mini App скрыта. " + "Нужен публичный HTTPS (Caddy/nginx) на порт %s", + settings.webapp_port, + ) + + logger.info("Bot started as @%s (id=%s)", me.username, me.id) + asyncio.create_task( + extras_billing_worker(db, sdk, bot), + name="extras-billing", + ) + asyncio.create_task( + subscription_renewal_worker(db, sdk, bot, tariffs), + name="subscription-renewal", + ) + asyncio.create_task( + vpn_configs_expiry_worker(db, sdk), + name="vpn-configs-expiry", + ) + try: + if use_webhook: + await bot.set_webhook( + url=webhook_url, + secret_token=settings.telegram_webhook_secret or None, + drop_pending_updates=True, + allowed_updates=dp.resolve_used_update_types(), + ) + info = await bot.get_webhook_info() + logger.info( + "Telegram webhook ON → %s (pending=%s)", + info.url or webhook_url, + info.pending_update_count, + ) + await _idle_until_stop() + else: + await bot.delete_webhook(drop_pending_updates=False) + logger.info("Telegram long polling ON (webhook выключен)") + await dp.start_polling(bot) + finally: + db.close() + if web_runner is not None: + await web_runner.cleanup() + await bot.session.close() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/caddy/Caddyfile b/caddy/Caddyfile new file mode 100644 index 0000000..531723f --- /dev/null +++ b/caddy/Caddyfile @@ -0,0 +1,18 @@ +# Домен Mini App. DNS A-запись → IP этого сервера. +# Порты 80 и 443 должны быть свободны (для Let's Encrypt). + +{$WEBAPP_DOMAIN:bot.example.com} { + encode gzip + reverse_proxy bot:8080 + + header { + X-Content-Type-Options nosniff + Referrer-Policy no-referrer-when-downgrade + # Telegram Login popup (OIDC) + Cross-Origin-Opener-Policy "same-origin-allow-popups" + } + + log { + output stdout + } +} diff --git a/caddy/README.md b/caddy/README.md new file mode 100644 index 0000000..b1d73bc --- /dev/null +++ b/caddy/README.md @@ -0,0 +1,37 @@ +# Caddy + SSL для Mini App + +Путь на сервере: `/opt/bot` + +## Перед запуском + +1. DNS: `bot.example.com` → IP сервера +2. Свободны порты **80** и **443** (если заняты другим Caddy/nginx — останови их или используй другой сервер) +3. В `.env`: + +```env +WEBAPP_URL=https://bot.example.com +WEBAPP_DOMAIN=bot.example.com +``` + +## Запуск + +```bash +cd /opt/bot +docker compose up -d --build +docker compose ps +docker compose logs -f caddy +``` + +## Проверка + +```bash +curl -sS https://bot.example.com/api/health +# {"ok": true} +``` + +## Если 80/443 заняты + +```bash +ss -tlnp | grep -E ':80|:443' +docker ps --format 'table {{.Names}}\t{{.Ports}}' +``` diff --git a/check_api.py b/check_api.py new file mode 100644 index 0000000..63ad02b --- /dev/null +++ b/check_api.py @@ -0,0 +1,88 @@ +""" +Проверка, что Remnawave Panel API принимает запросы. + +Запуск (Docker Compose): + docker compose run --rm bot python check_api.py + docker compose run --rm bot python check_api.py --verbose +""" + +from __future__ import annotations + +import asyncio +import sys +import traceback + +import httpx + +from config import get_settings +from remnawave_client import create_sdk_ready, probe_users_write_access + + +async def probe_http(base_url: str) -> tuple[bool, str]: + """Быстрая проверка доступности панели без SDK.""" + url = f"{base_url.rstrip('/')}/api/system/health" + try: + async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client: + resp = await client.get(url) + if resp.status_code in (200, 401, 403): + return True, f"HTTP {resp.status_code} от {url}" + return False, f"Неожиданный HTTP {resp.status_code} от {url}: {resp.text[:200]}" + except httpx.RequestError as exc: + return False, f"Сеть: не удалось достучаться до {url}: {exc}" + + +async def check_with_sdk(sdk) -> None: + print("→ system.get_health() ...") + health = await sdk.system.get_health() + print(f" OK health: {health}") + + print("→ users.get_all_users(size=1) ...") + users = await sdk.users.get_all_users(size=1) + print(f" OK users read: total={getattr(users, 'total', '?')}") + + print("→ users.create_user (write probe) ...") + ok, msg = await probe_users_write_access(sdk) + if ok: + print(f" OK write: {msg}") + else: + print(f" FAIL write:\n{msg}") + raise SystemExit(3) + + +async def main() -> int: + settings = get_settings() + print(f"Panel: {settings.remnawave_base_url}") + if settings.remnawave_token: + tok = settings.remnawave_token + print(f"Token: {tok[:8]}…{tok[-4:]}") + if settings.remnawave_username: + print(f"Login: {settings.remnawave_username}") + print() + + ok, msg = await probe_http(settings.remnawave_base_url) + print(f"1) HTTP probe: {msg}") + if not ok: + print("API недоступен. Проверьте URL / DNS / SSL / firewall.") + return 1 + + print("2) SDK auth check:") + try: + sdk = await create_sdk_ready(settings) + await check_with_sdk(sdk) + except SystemExit as exc: + return int(exc.code) if isinstance(exc.code, int) else 3 + except Exception as exc: # noqa: BLE001 + print() + print("ОШИБКА: API не принял запрос с этим токеном.") + print(f" {type(exc).__name__}: {exc}") + if "--verbose" in sys.argv: + traceback.print_exc() + return 2 + + print() + print("✓ Remnawave API: чтение и запись OK. Можно продавать тарифы.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(asyncio.run(main())) diff --git a/config.py b/config.py new file mode 100644 index 0000000..a283211 --- /dev/null +++ b/config.py @@ -0,0 +1,348 @@ +"""Load settings from environment / .env file.""" + +from __future__ import annotations + +import hashlib +import os +from dataclasses import dataclass +from pathlib import Path +from urllib.parse import quote_plus + +from dotenv import load_dotenv + +_ENV_FILE = (os.getenv("ENV_FILE") or "").strip() +load_dotenv(_ENV_FILE or None) + +ROOT_DIR = Path(__file__).resolve().parent + + +@dataclass(frozen=True, slots=True) +class Settings: + remnawave_base_url: str + remnawave_token: str + bot_token: str + database_url: str + admin_ids: tuple[int, ...] = () + caddy_token: str | None = None + remnawave_username: str = "" + remnawave_password: str = "" + tariffs_path: str = str(ROOT_DIR / "tariffs.json") + crypto_bot_token: str = "" + crypto_pay_testnet: bool = False + heleket_merchant_id: str = "" + heleket_api_key: str = "" + heleket_callback_url: str = "" + heleket_currency: str = "RUB" + digiseller_seller_id: str = "" + digiseller_api_key: str = "" + digiseller_product_ids: tuple[int, ...] = () + digiseller_pay_url: str = "" + digiseller_credit_from: str = "cnt_goods" + digiseller_callback_url: str = "" + lava_shop_id: str = "" + lava_secret_key: str = "" + lava_additional_key: str = "" + lava_hook_url: str = "" + lava_success_url: str = "" + lava_fail_url: str = "" + lava_expire_minutes: int = 60 + anypay_api_id: str = "" + anypay_api_key: str = "" + anypay_project_id: str = "" + anypay_currency: str = "RUB" + anypay_method: str = "" + anypay_success_url: str = "" + anypay_fail_url: str = "" + anypay_email: str = "" + anypay_check_ip: bool = False + webapp_url: str = "" + webapp_host: str = "0.0.0.0" + webapp_port: int = 8080 + topup_code_pay_url: str = "" + web_admin_login: str = "" + web_admin_password: str = "" + stars_enabled: bool = True + stars_rub_per_star: float = 1.5 + smtp_host: str = "" + smtp_port: int = 587 + smtp_user: str = "" + smtp_password: str = "" + smtp_from: str = "" + smtp_tls: bool = True + smtp_ssl: bool = False + telegram_client_id: str = "" + telegram_webhook_enabled: bool = False + telegram_webhook_path: str = "/telegram/webhook" + telegram_webhook_secret: str = "" + telegram_webhook_url: str = "" + + @property + def web_admin_enabled(self) -> bool: + return bool(self.web_admin_login and self.web_admin_password) + + @property + def effective_telegram_client_id(self) -> str: + if (self.telegram_client_id or "").strip(): + return self.telegram_client_id.strip() + from services.tg_webapp import telegram_client_id_from_token + + return telegram_client_id_from_token(self.bot_token) + + @property + def effective_telegram_webhook_path(self) -> str: + path = (self.telegram_webhook_path or "/telegram/webhook").strip() or "/telegram/webhook" + if not path.startswith("/"): + path = f"/{path}" + return path.rstrip("/") or "/telegram/webhook" + + @property + def effective_telegram_webhook_url(self) -> str: + explicit = (self.telegram_webhook_url or "").strip().rstrip("/") + if explicit: + return explicit + base = (self.webapp_url or "").strip().rstrip("/") + if not base: + return "" + return f"{base}{self.effective_telegram_webhook_path}" + + +def _parse_env_secret(raw: str) -> str: + """Значение из .env без кавычек и пробелов по краям.""" + s = (raw or "").strip() + if len(s) >= 2 and s[0] == s[-1] and s[0] in "\"'": + s = s[1:-1].strip() + return s + + +def _parse_admin_ids(raw: str) -> tuple[int, ...]: + if not raw.strip(): + return () + result: list[int] = [] + for part in raw.split(","): + part = part.strip() + if not part: + continue + result.append(int(part)) + return tuple(result) + + +def _build_database_url() -> str: + explicit = (os.getenv("DATABASE_URL") or "").strip() + if explicit: + return explicit + + user = (os.getenv("POSTGRES_USER") or "vpnbot").strip() + password = (os.getenv("POSTGRES_PASSWORD") or "vpnbot").strip() + host = (os.getenv("POSTGRES_HOST") or "db").strip() + port = (os.getenv("POSTGRES_PORT") or "5432").strip() + name = (os.getenv("POSTGRES_DB") or "vpnbot").strip() + return f"postgresql://{quote_plus(user)}:{quote_plus(password)}@{host}:{port}/{name}" + + +def _parse_int_ids(raw: str) -> tuple[int, ...]: + if not raw.strip(): + return () + result: list[int] = [] + for part in raw.replace(";", ",").split(","): + part = part.strip() + if not part: + continue + result.append(int(part)) + return tuple(result) + + +def get_settings(*, require_bot_token: bool = False) -> Settings: + base_url = (os.getenv("REMNAWAVE_BASE_URL") or "").rstrip("/") + token = (os.getenv("REMNAWAVE_TOKEN") or "").strip() + bot_token = (os.getenv("BOT_TOKEN") or "").strip() + caddy = (os.getenv("CADDY_TOKEN") or "").strip() or None + admin_ids = _parse_admin_ids(os.getenv("ADMIN_IDS") or "") + tariffs_path = (os.getenv("TARIFFS_PATH") or str(ROOT_DIR / "tariffs.json")).strip() + crypto_bot_token = (os.getenv("CRYPTO_BOT_TOKEN") or "").strip() + crypto_pay_testnet = (os.getenv("CRYPTO_PAY_TESTNET") or "").strip().lower() in { + "1", + "true", + "yes", + "on", + } + remnawave_username = (os.getenv("REMNAWAVE_USERNAME") or "").strip() + remnawave_password = (os.getenv("REMNAWAVE_PASSWORD") or "").strip() + webapp_url = (os.getenv("WEBAPP_URL") or "").strip().rstrip("/") + webapp_host = (os.getenv("WEBAPP_HOST") or "0.0.0.0").strip() + webapp_port = int((os.getenv("WEBAPP_PORT") or "8080").strip()) + database_url = _build_database_url() + heleket_merchant_id = (os.getenv("HELEKET_MERCHANT_ID") or "").strip() + heleket_api_key = (os.getenv("HELEKET_API_KEY") or "").strip() + heleket_callback_url = (os.getenv("HELEKET_CALLBACK_URL") or "").strip().rstrip("/") + if not heleket_callback_url and webapp_url: + heleket_callback_url = f"{webapp_url}/api/payments/heleket" + heleket_currency = (os.getenv("HELEKET_CURRENCY") or "RUB").strip().upper() or "RUB" + digiseller_seller_id = (os.getenv("DIGISELLER_SELLER_ID") or "").strip() + digiseller_api_key = (os.getenv("DIGISELLER_API_KEY") or "").strip() + digiseller_product_ids = _parse_int_ids(os.getenv("DIGISELLER_PRODUCT_IDS") or "") + digiseller_pay_url = (os.getenv("DIGISELLER_PAY_URL") or "").strip() + digiseller_credit_from = ( + os.getenv("DIGISELLER_CREDIT_FROM") or "cnt_goods" + ).strip().lower() or "cnt_goods" + digiseller_callback_url = ( + os.getenv("DIGISELLER_CALLBACK_URL") or "" + ).strip().rstrip("/") + if not digiseller_callback_url and webapp_url: + digiseller_callback_url = f"{webapp_url}/api/payments/digiseller" + lava_shop_id = (os.getenv("LAVA_SHOP_ID") or "").strip() + lava_secret_key = (os.getenv("LAVA_SECRET_KEY") or "").strip() + lava_additional_key = (os.getenv("LAVA_ADDITIONAL_KEY") or "").strip() + lava_hook_url = (os.getenv("LAVA_HOOK_URL") or "").strip().rstrip("/") + if not lava_hook_url and webapp_url: + lava_hook_url = f"{webapp_url}/api/payments/lava" + lava_success_url = (os.getenv("LAVA_SUCCESS_URL") or "").strip() + if not lava_success_url and webapp_url: + lava_success_url = f"{webapp_url}/pay/lava/success" + lava_fail_url = (os.getenv("LAVA_FAIL_URL") or "").strip() + if not lava_fail_url and webapp_url: + lava_fail_url = f"{webapp_url}/pay/lava/fail" + try: + lava_expire_minutes = int((os.getenv("LAVA_EXPIRE_MINUTES") or "60").strip()) + except ValueError: + lava_expire_minutes = 60 + anypay_api_id = (os.getenv("ANYPAY_API_ID") or "").strip() + anypay_api_key = (os.getenv("ANYPAY_API_KEY") or "").strip() + anypay_project_id = (os.getenv("ANYPAY_PROJECT_ID") or "").strip() + anypay_currency = (os.getenv("ANYPAY_CURRENCY") or "RUB").strip().upper() or "RUB" + anypay_method = (os.getenv("ANYPAY_METHOD") or "").strip().lower() + anypay_success_url = (os.getenv("ANYPAY_SUCCESS_URL") or "").strip() + if not anypay_success_url and webapp_url: + anypay_success_url = f"{webapp_url}/pay/anypay/success" + anypay_fail_url = (os.getenv("ANYPAY_FAIL_URL") or "").strip() + if not anypay_fail_url and webapp_url: + anypay_fail_url = f"{webapp_url}/pay/anypay/fail" + anypay_email = (os.getenv("ANYPAY_EMAIL") or "").strip() + anypay_check_ip = (os.getenv("ANYPAY_CHECK_IP") or "").strip().lower() in { + "1", + "true", + "yes", + "on", + } + topup_code_pay_url = (os.getenv("TOPUP_CODE_PAY_URL") or "").strip() + web_admin_login = _parse_env_secret(os.getenv("WEB_ADMIN_LOGIN") or "") + web_admin_password = _parse_env_secret(os.getenv("WEB_ADMIN_PASSWORD") or "") + stars_enabled = (os.getenv("STARS_ENABLED") or "true").strip().lower() in { + "1", + "true", + "yes", + "on", + } + try: + stars_rub_per_star = float((os.getenv("STARS_RUB_PER_STAR") or "1.5").strip()) + except ValueError: + stars_rub_per_star = 1.5 + if stars_rub_per_star <= 0: + stars_rub_per_star = 1.5 + smtp_host = (os.getenv("SMTP_HOST") or "").strip() + try: + smtp_port = int((os.getenv("SMTP_PORT") or "587").strip()) + except ValueError: + smtp_port = 587 + smtp_user = (os.getenv("SMTP_USER") or "").strip() + smtp_password = os.getenv("SMTP_PASSWORD") or "" + smtp_from = (os.getenv("SMTP_FROM") or "").strip() + smtp_tls = (os.getenv("SMTP_TLS") or "true").strip().lower() in { + "1", + "true", + "yes", + "on", + } + smtp_ssl = (os.getenv("SMTP_SSL") or "").strip().lower() in { + "1", + "true", + "yes", + "on", + } + telegram_client_id = (os.getenv("TELEGRAM_CLIENT_ID") or "").strip() + webhook_env = (os.getenv("TELEGRAM_WEBHOOK") or "").strip().lower() + if webhook_env in {"1", "true", "yes", "on"}: + telegram_webhook_enabled = True + elif webhook_env in {"0", "false", "no", "off"}: + telegram_webhook_enabled = False + else: + # По умолчанию webhook, если кабинет уже на публичном HTTPS + telegram_webhook_enabled = webapp_url.startswith("https://") + telegram_webhook_path = (os.getenv("TELEGRAM_WEBHOOK_PATH") or "/telegram/webhook").strip() + telegram_webhook_secret = _parse_env_secret(os.getenv("TELEGRAM_WEBHOOK_SECRET") or "") + if not telegram_webhook_secret and bot_token and telegram_webhook_enabled: + telegram_webhook_secret = hashlib.sha256( + f"tg-webhook:{bot_token}".encode("utf-8") + ).hexdigest()[:48] + telegram_webhook_url = (os.getenv("TELEGRAM_WEBHOOK_URL") or "").strip().rstrip("/") + if not base_url: + raise SystemExit( + "REMNAWAVE_BASE_URL не задан. Скопируйте .env.example → .env и заполните." + ) + if not token and not (remnawave_username and remnawave_password): + raise SystemExit( + "Задайте REMNAWAVE_TOKEN или пару REMNAWAVE_USERNAME / REMNAWAVE_PASSWORD." + ) + if require_bot_token and not bot_token: + raise SystemExit( + "BOT_TOKEN не задан. Создайте бота у @BotFather и добавьте токен в .env." + ) + + return Settings( + remnawave_base_url=base_url, + remnawave_token=token, + bot_token=bot_token, + database_url=database_url, + admin_ids=admin_ids, + caddy_token=caddy, + remnawave_username=remnawave_username, + remnawave_password=remnawave_password, + tariffs_path=tariffs_path, + crypto_bot_token=crypto_bot_token, + crypto_pay_testnet=crypto_pay_testnet, + heleket_merchant_id=heleket_merchant_id, + heleket_api_key=heleket_api_key, + heleket_callback_url=heleket_callback_url, + heleket_currency=heleket_currency, + digiseller_seller_id=digiseller_seller_id, + digiseller_api_key=digiseller_api_key, + digiseller_product_ids=digiseller_product_ids, + digiseller_pay_url=digiseller_pay_url, + digiseller_credit_from=digiseller_credit_from, + digiseller_callback_url=digiseller_callback_url, + lava_shop_id=lava_shop_id, + lava_secret_key=lava_secret_key, + lava_additional_key=lava_additional_key, + lava_hook_url=lava_hook_url, + lava_success_url=lava_success_url, + lava_fail_url=lava_fail_url, + lava_expire_minutes=lava_expire_minutes, + anypay_api_id=anypay_api_id, + anypay_api_key=anypay_api_key, + anypay_project_id=anypay_project_id, + anypay_currency=anypay_currency, + anypay_method=anypay_method, + anypay_success_url=anypay_success_url, + anypay_fail_url=anypay_fail_url, + anypay_email=anypay_email, + anypay_check_ip=anypay_check_ip, + webapp_url=webapp_url, + webapp_host=webapp_host, + webapp_port=webapp_port, + topup_code_pay_url=topup_code_pay_url, + web_admin_login=web_admin_login, + web_admin_password=web_admin_password, + smtp_host=smtp_host, + smtp_port=smtp_port, + smtp_user=smtp_user, + smtp_password=smtp_password, + smtp_from=smtp_from, + smtp_tls=smtp_tls, + smtp_ssl=smtp_ssl, + stars_enabled=stars_enabled, + stars_rub_per_star=stars_rub_per_star, + telegram_client_id=telegram_client_id, + telegram_webhook_enabled=telegram_webhook_enabled, + telegram_webhook_path=telegram_webhook_path, + telegram_webhook_secret=telegram_webhook_secret, + telegram_webhook_url=telegram_webhook_url, + ) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c7a3ea9 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,89 @@ +services: + db: + image: postgres:17 + container_name: vpn-bot-postgres + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER:-vpnbot} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-vpnbot} + POSTGRES_DB: ${POSTGRES_DB:-vpnbot} + volumes: + - pgdata:/var/lib/postgresql/data + networks: + - bot-net + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-vpnbot} -d ${POSTGRES_DB:-vpnbot}"] + interval: 5s + timeout: 5s + retries: 10 + + bot: + build: . + container_name: vpn-telegram-bot + env_file: + - .env + environment: + REMNAWAVE_BASE_URL: ${REMNAWAVE_BASE_URL} + REMNAWAVE_TOKEN: ${REMNAWAVE_TOKEN} + REMNAWAVE_USERNAME: ${REMNAWAVE_USERNAME:-} + REMNAWAVE_PASSWORD: ${REMNAWAVE_PASSWORD:-} + CADDY_TOKEN: ${CADDY_TOKEN:-} + BOT_TOKEN: ${BOT_TOKEN} + ADMIN_IDS: ${ADMIN_IDS:-} + # Платежи / Stars / TOPUP_CODE_PAY_URL — только из env_file (.env), + # чтобы админка могла писать ключи без перетирания пустыми ${...:-} + DATABASE_URL: postgresql://${POSTGRES_USER:-vpnbot}:${POSTGRES_PASSWORD:-vpnbot}@db:5432/${POSTGRES_DB:-vpnbot} + POSTGRES_HOST: db + POSTGRES_PORT: "5432" + POSTGRES_USER: ${POSTGRES_USER:-vpnbot} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-vpnbot} + POSTGRES_DB: ${POSTGRES_DB:-vpnbot} + TARIFFS_PATH: /app/tariffs.json + ENV_FILE: /app/.env + WEBAPP_URL: ${WEBAPP_URL} + WEBAPP_HOST: 0.0.0.0 + WEBAPP_PORT: "8080" + # WEB_ADMIN_* — только из env_file (.env), без ${...:-} + expose: + - "8080" + volumes: + - ./.env:/app/.env:rw + - ./tariffs.json:/app/tariffs.json:rw + - ./webapp:/app/webapp:ro + - ./backups:/app/backups + networks: + - bot-net + depends_on: + db: + condition: service_healthy + restart: unless-stopped + command: ["python", "bot.py"] + + caddy: + image: caddy:2.9 + container_name: vpn-bot-caddy + restart: unless-stopped + ports: + - "80:80" + - "443:443" + - "443:443/udp" + environment: + WEBAPP_DOMAIN: ${WEBAPP_DOMAIN:-bot.example.com} + volumes: + - ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro + - caddy-data:/data + - caddy-config:/config + networks: + - bot-net + depends_on: + - bot + +networks: + bot-net: + name: bot-net + driver: bridge + +volumes: + pgdata: + caddy-data: + caddy-config: diff --git a/filters.py b/filters.py new file mode 100644 index 0000000..385436b --- /dev/null +++ b/filters.py @@ -0,0 +1,19 @@ +"""Фильтры доступа.""" + +from __future__ import annotations + +from aiogram.filters import BaseFilter +from aiogram.types import CallbackQuery, Message + +from config import Settings + + +class IsAdmin(BaseFilter): + def __init__(self, settings: Settings) -> None: + self._admin_ids = set(settings.admin_ids) + + async def __call__(self, event: Message | CallbackQuery) -> bool: + user = event.from_user + if user is None: + return False + return user.id in self._admin_ids diff --git a/handlers_admin.py b/handlers_admin.py new file mode 100644 index 0000000..27fdb97 --- /dev/null +++ b/handlers_admin.py @@ -0,0 +1,364 @@ +"""Админ-панель.""" + +from __future__ import annotations + +from aiogram import F, Router +from aiogram.filters import Command, CommandObject, StateFilter +from aiogram.fsm.context import FSMContext +from aiogram.fsm.state import State, StatesGroup +from aiogram.types import CallbackQuery, Message +from remnawave import RemnawaveSDK + +from filters import IsAdmin +from keyboards import ( + admin_back_inline, + admin_inline, + admin_topup_amounts, + admin_topup_cancel, +) +from services.db import Database +from services.nodes_status import fetch_nodes_status, format_nodes_status + +router = Router(name="admin") + + +class TopUpStates(StatesGroup): + waiting_user_id = State() + waiting_custom_amount = State() + + +def setup_admin_router(is_admin: IsAdmin) -> Router: + router.message.filter(is_admin) + router.callback_query.filter(is_admin) + return router + + +ADMIN_HOME = ( + "🛠 Админ-панель\n\n" + "Управление и мониторинг Remnawave.\n" + "Пополнение: кнопка ниже или\n" + "/addbal TELEGRAM_ID СУММА" +) + + +@router.message(Command("admin")) +@router.message(F.text == "🛠 Админка") +async def admin_home(message: Message, state: FSMContext) -> None: + await state.clear() + await message.answer(ADMIN_HOME, reply_markup=admin_inline(), parse_mode="HTML") + + +@router.callback_query(F.data == "admin:home") +async def cb_admin_home(callback: CallbackQuery, state: FSMContext) -> None: + await state.clear() + await callback.answer() + if callback.message: + await callback.message.edit_text(ADMIN_HOME, reply_markup=admin_inline(), parse_mode="HTML") + + +@router.callback_query(F.data == "admin:servers") +async def cb_admin_servers(callback: CallbackQuery, sdk: RemnawaveSDK) -> None: + await callback.answer("Пингую…") + try: + nodes = await fetch_nodes_status(sdk) + text = format_nodes_status(nodes, detailed=True) + if callback.message: + await callback.message.edit_text( + text, reply_markup=admin_back_inline(), parse_mode="HTML" + ) + except Exception as exc: # noqa: BLE001 + if callback.message: + await callback.message.edit_text( + f"Ошибка:\n{exc}", + reply_markup=admin_inline(), + parse_mode="HTML", + ) + + +@router.callback_query(F.data == "admin:health") +async def cb_admin_health(callback: CallbackQuery, sdk: RemnawaveSDK) -> None: + await callback.answer() + try: + health = await sdk.system.get_health() + try: + meta = await sdk.system.get_metadata() + version = getattr(meta, "version", "—") + except Exception: # noqa: BLE001 + version = "—" + + lines = [ + "💚 Remnawave Health", + "", + f"Версия: {version}", + "Статус API: OK", + ] + pm2 = getattr(health, "pm2_stats", None) or [] + if pm2: + lines.append("") + lines.append("Процессы:") + for proc in pm2: + lines.append( + f"• {proc.name} — CPU {proc.cpu}, RAM {proc.memory}" + ) + if callback.message: + await callback.message.edit_text( + "\n".join(lines), + reply_markup=admin_inline(), + parse_mode="HTML", + ) + except Exception as exc: # noqa: BLE001 + if callback.message: + await callback.message.edit_text( + f"API недоступен.\n{exc}", + reply_markup=admin_inline(), + parse_mode="HTML", + ) + + +@router.callback_query(F.data == "admin:users") +async def cb_admin_users(callback: CallbackQuery, sdk: RemnawaveSDK) -> None: + await callback.answer() + try: + users = await sdk.users.get_all_users(size=1) + total = getattr(users, "total", None) + if total is None: + items = getattr(users, "users", None) or getattr(users, "root", None) + total = len(items) if items is not None else "?" + text = f"👥 Пользователи\n\nВсего в панели: {total}" + if callback.message: + await callback.message.edit_text( + text, reply_markup=admin_inline(), parse_mode="HTML" + ) + except Exception as exc: # noqa: BLE001 + if callback.message: + await callback.message.edit_text( + f"Не удалось получить пользователей.\n{exc}", + reply_markup=admin_inline(), + parse_mode="HTML", + ) + + +@router.callback_query(F.data == "admin:stats") +async def cb_admin_stats(callback: CallbackQuery, sdk: RemnawaveSDK) -> None: + await callback.answer() + try: + stats = await sdk.system.get_stats() + mem = stats.memory + users = stats.users + online = stats.online_stats + nodes = stats.nodes + text = ( + "📊 Статистика панели\n\n" + f"CPU cores: {stats.cpu.cores:g}\n" + f"RAM: {mem.used / 1024**3:.1f} / {mem.total / 1024**3:.1f} GB\n\n" + f"Пользователи: {users.total_users:g}\n" + f"Онлайн сейчас: {online.online_now:g}\n" + f"Онлайн за сутки: {online.last_day:g}\n\n" + f"Ноды онлайн: {nodes.total_online:g}\n" + f"Трафик lifetime: {nodes.total_bytes_lifetime}" + ) + if callback.message: + await callback.message.edit_text( + text, reply_markup=admin_inline(), parse_mode="HTML" + ) + except Exception as exc: # noqa: BLE001 + if callback.message: + await callback.message.edit_text( + f"Статистика недоступна.\n{exc}", + reply_markup=admin_inline(), + parse_mode="HTML", + ) + + +@router.callback_query(F.data == "admin:topup") +async def cb_admin_topup(callback: CallbackQuery, state: FSMContext) -> None: + await state.set_state(TopUpStates.waiting_user_id) + await callback.answer() + if callback.message: + await callback.message.edit_text( + "💰 Пополнение баланса\n\n" + "Пришли Telegram ID пользователя.\n" + "Или используй: /addbal ID СУММА", + reply_markup=admin_topup_cancel(), + parse_mode="HTML", + ) + + +@router.callback_query(F.data == "admin:topup_cancel") +async def cb_admin_topup_cancel(callback: CallbackQuery, state: FSMContext) -> None: + await state.clear() + await callback.answer("Отменено") + if callback.message: + await callback.message.edit_text( + ADMIN_HOME, reply_markup=admin_inline(), parse_mode="HTML" + ) + + +@router.message(StateFilter(TopUpStates.waiting_user_id), F.text) +async def admin_topup_got_id(message: Message, state: FSMContext, db: Database) -> None: + text = (message.text or "").strip() + if not text.isdigit(): + await message.answer( + "Нужен числовой Telegram ID.\nПример: 123456789", + reply_markup=admin_topup_cancel(), + parse_mode="HTML", + ) + return + target_id = int(text) + user = db.ensure_user(target_id) + await state.clear() + await message.answer( + f"Пользователь {target_id}\n" + f"Текущий баланс: {user.balance:.0f} ₽\n\n" + "Выбери сумму пополнения:", + reply_markup=admin_topup_amounts(target_id), + parse_mode="HTML", + ) + + +@router.callback_query(F.data.startswith("admin:topup_custom:")) +async def cb_admin_topup_custom(callback: CallbackQuery, state: FSMContext) -> None: + target_id = int((callback.data or "").split(":")[-1]) + await state.set_state(TopUpStates.waiting_custom_amount) + await state.update_data(topup_target_id=target_id) + await callback.answer() + if callback.message: + await callback.message.edit_text( + f"Введи сумму пополнения для {target_id} (₽).\n" + "Можно отрицательную — для списания.", + reply_markup=admin_topup_cancel(), + parse_mode="HTML", + ) + + +@router.message(StateFilter(TopUpStates.waiting_custom_amount), F.text) +async def admin_topup_custom_amount( + message: Message, + state: FSMContext, + db: Database, +) -> None: + data = await state.get_data() + target_id = int(data.get("topup_target_id") or 0) + raw = (message.text or "").strip().replace(",", ".") + try: + amount = float(raw) + except ValueError: + await message.answer("Нужно число, например 750", parse_mode="HTML") + return + if amount == 0: + await message.answer("Сумма не должна быть 0.") + return + + await state.clear() + admin = message.from_user + balance = db.add_balance( + target_id, + amount, + kind="admin_topup", + meta=f"by:{admin.id if admin else '?'}", + ) + await message.answer( + f"✅ Начислено {amount:.0f} ₽{target_id}\n" + f"Баланс: {balance:.0f} ₽", + reply_markup=admin_inline(), + parse_mode="HTML", + ) + try: + if message.bot: + await message.bot.send_message( + target_id, + f"💰 Баланс изменён на {amount:+.0f} ₽\n" + f"Доступно: {balance:.0f} ₽", + parse_mode="HTML", + ) + except Exception: # noqa: BLE001 + pass + + +@router.callback_query(F.data.startswith("admin:topup_do:")) +async def cb_admin_topup_do(callback: CallbackQuery, db: Database) -> None: + parts = (callback.data or "").split(":") + if len(parts) != 4: + await callback.answer("Некорректные данные", show_alert=True) + return + try: + target_id = int(parts[2]) + amount = float(parts[3]) + except ValueError: + await callback.answer("Некорректные данные", show_alert=True) + return + + admin = callback.from_user + balance = db.add_balance( + target_id, + amount, + kind="admin_topup", + meta=f"by:{admin.id if admin else '?'}", + ) + await callback.answer("Готово") + if callback.message: + await callback.message.edit_text( + f"✅ Начислено {amount:.0f} ₽\n" + f"Пользователь: {target_id}\n" + f"Новый баланс: {balance:.0f} ₽", + reply_markup=admin_inline(), + parse_mode="HTML", + ) + try: + if callback.bot: + await callback.bot.send_message( + target_id, + f"💰 Баланс пополнен на {amount:.0f} ₽\n" + f"Доступно: {balance:.0f} ₽", + parse_mode="HTML", + ) + except Exception: # noqa: BLE001 + pass + + +@router.message(Command("addbal")) +async def cmd_addbal( + message: Message, + command: CommandObject, + db: Database, +) -> None: + args = (command.args or "").split() + if len(args) != 2: + await message.answer( + "Формат: /addbal TELEGRAM_ID СУММА\n" + "Пример: /addbal 123456789 500", + parse_mode="HTML", + ) + return + try: + target_id = int(args[0]) + amount = float(args[1]) + except ValueError: + await message.answer("ID и сумма должны быть числами.") + return + if amount == 0: + await message.answer("Сумма не должна быть 0.") + return + + admin = message.from_user + balance = db.add_balance( + target_id, + amount, + kind="admin_topup", + meta=f"by:{admin.id if admin else '?'}", + ) + await message.answer( + f"✅ Начислено {amount:.0f} ₽{target_id}\n" + f"Баланс: {balance:.0f} ₽", + parse_mode="HTML", + ) + try: + if message.bot: + await message.bot.send_message( + target_id, + f"💰 Баланс пополнен на {amount:.0f} ₽\n" + f"Доступно: {balance:.0f} ₽", + parse_mode="HTML", + ) + except Exception: # noqa: BLE001 + pass diff --git a/handlers_shop.py b/handlers_shop.py new file mode 100644 index 0000000..1f2362c --- /dev/null +++ b/handlers_shop.py @@ -0,0 +1,1723 @@ +"""Баланс, тарифы, оплата с баланса и криптой.""" + +import time +import uuid + +from aiogram import F, Router +from aiogram.fsm.context import FSMContext +from aiogram.fsm.state import State, StatesGroup +from aiogram.types import CallbackQuery, LabeledPrice, Message, PreCheckoutQuery +from remnawave import RemnawaveSDK + +from keyboards import ( + device_confirm_inline, + device_packs_inline, + digiseller_cancel_inline, + digiseller_inline, + invoice_inline, + renew_confirm_inline, + renew_tariffs_inline, + stars_amounts_inline, + subscription_inline, + tariffs_inline, + topup_amounts_inline, + topup_code_inline, + topup_providers_inline, + traffic_confirm_inline, + traffic_packs_inline, +) +from config import Settings +from services.anypay import Anypay, AnypayError +from services.cryptopay import CryptoPay, CryptoPayError +from services.db import Database +from services.devices import ( + BASE_DEVICES, + EXTRA_DEVICE_PRICE_RUB, + get_device_pack, +) +from services.digiseller import Digiseller, DigisellerError +from services.digiseller_fulfill import fulfill_digiseller_unique_code +from services.heleket import Heleket, HeleketError +from services.lava import Lava, LavaError +from services.payments import fulfill_paid_invoice +from services.purchase import ( + get_my_subscription_info, + purchase_device_pack, + purchase_tariff, + purchase_traffic_pack, +) +from services.stars import create_stars_topup, parse_stars_payload +from services.tariffs import Tariff, get_tariff +from services.topup_code_redeem import TopupCodeError, redeem_topup_code +from services.traffic import ( + BASE_TRAFFIC_GB, + PACK_GB, + PACK_PRICE_RUB, + format_gb, + get_traffic_pack, +) +from services.transactions_ui import transaction_label + +router = Router(name="shop") + + +class DigisellerStates(StatesGroup): + waiting_code = State() + + +class TopupCodeStates(StatesGroup): + waiting_code = State() + + +def _any_crypto(cryptopay: CryptoPay, heleket: Heleket) -> bool: + return cryptopay.enabled or heleket.enabled + + +def _pay_kb( + cryptopay: CryptoPay, + heleket: Heleket, + digiseller: Digiseller | None = None, + lava: Lava | None = None, + tariff_id: str | None = None, + *, + anypay: Anypay | None = None, + settings: Settings | None = None, + stars_enabled: bool | None = None, +): + from keyboards import balance_inline, tariff_pay_methods_inline + + digi_on = bool(digiseller and digiseller.enabled) + lava_on = bool(lava and lava.enabled) + anypay_on = bool(anypay and anypay.enabled) + if stars_enabled is None: + stars_on = bool(settings.stars_enabled) if settings else True + else: + stars_on = bool(stars_enabled) + if tariff_id: + return tariff_pay_methods_inline( + tariff_id, + crypto_enabled=cryptopay.enabled, + heleket_enabled=heleket.enabled, + lava_enabled=lava_on, + anypay_enabled=anypay_on, + ) + return balance_inline( + crypto_enabled=cryptopay.enabled, + heleket_enabled=heleket.enabled, + digiseller_enabled=digi_on, + lava_enabled=lava_on, + anypay_enabled=anypay_on, + stars_enabled=stars_on, + ) + + +def _tariff_card(t: Tariff, balance: float) -> str: + traffic = format_gb(t.traffic_gb if t.traffic_gb > 0 else BASE_TRAFFIC_GB, digits=0) + return ( + f"🛒 {t.title}\n\n" + f"{t.description}\n\n" + f"⏱ Срок: {t.days} дн.\n" + f"📦 Трафик: {traffic} (база)\n" + f"📱 Устройств: {BASE_DEVICES} (база)\n" + f"💵 Цена: {t.format_price()}\n\n" + f"Твой баланс: {balance:.0f} ₽\n\n" + f"Докупка трафика: +{format_gb(PACK_GB, digits=0)} = {int(PACK_PRICE_RUB)} ₽\n" + f"Доп. устройство: +1 = {int(EXTRA_DEVICE_PRICE_RUB)} ₽ " + f"(при превышении подписка отключается)\n" + "Оплата: с баланса или криптой (Crypto Bot / Heleket)." + ) + + +def _safe_order_id(raw: str) -> str: + cleaned = "".join(ch if ch.isalnum() or ch in "_-" else "_" for ch in raw) + return cleaned[:128] + + +@router.message(F.text == "💰 Баланс") +async def cmd_balance( + message: Message, + db: Database, + cryptopay: CryptoPay, + heleket: Heleket, + digiseller: Digiseller, + lava: Lava, + anypay: Anypay, + settings: Settings, +) -> None: + user = message.from_user + assert user + bot_user = db.ensure_user( + user.id, + username=user.username, + full_name=user.full_name, + ) + txs = db.recent_transactions(user.id, limit=5) + lines = [ + "💰 Баланс\n", + f"Доступно: {bot_user.balance:.0f} ₽\n", + "Пополнить: Stars, картой/СБП (Lava / AnyPay), криптой или кодом.", + ] + if txs: + lines.append("\nПоследние операции:") + for tx in txs: + sign = "+" if tx["amount"] > 0 else "" + label = transaction_label(str(tx["kind"]), tx.get("meta")) + lines.append(f"• {sign}{float(tx['amount']):.0f} ₽ · {label}") + await message.answer( + "\n".join(lines), + reply_markup=_pay_kb( + cryptopay, heleket, digiseller, lava, anypay=anypay, settings=settings + ), + parse_mode="HTML", + ) + + +@router.callback_query(F.data == "pay:balance") +async def cb_pay_balance( + callback: CallbackQuery, + db: Database, + cryptopay: CryptoPay, + heleket: Heleket, + digiseller: Digiseller, + lava: Lava, + anypay: Anypay, + state: FSMContext, + settings: Settings, +) -> None: + await state.clear() + user = callback.from_user + if not user: + await callback.answer() + return + balance = db.get_balance(user.id) + await callback.answer() + if callback.message: + await callback.message.edit_text( + f"💰 Баланс\n\nДоступно: {balance:.0f} ₽", + reply_markup=_pay_kb( + cryptopay, heleket, digiseller, lava, anypay=anypay, settings=settings + ), + parse_mode="HTML", + ) + + +@router.callback_query(F.data == "pay:stars") +async def cb_pay_stars(callback: CallbackQuery, settings: Settings) -> None: + if not settings.stars_enabled: + await callback.answer("Stars выключены", show_alert=True) + return + await callback.answer() + if callback.message: + rate = settings.stars_rub_per_star + await callback.message.edit_text( + "⭐ Пополнение через Telegram Stars\n\n" + f"Курс: 1 ⭐ ≈ {rate:g} ₽ на баланс.\n" + "Выбери сумму — оплата прямо в Telegram, без внешних сайтов.", + reply_markup=stars_amounts_inline(rub_per_star=rate), + parse_mode="HTML", + ) + + +@router.callback_query(F.data.startswith("pay:stars_amount:")) +async def cb_pay_stars_amount( + callback: CallbackQuery, + db: Database, + settings: Settings, + cryptopay: CryptoPay, + heleket: Heleket, + digiseller: Digiseller, + lava: Lava, +) -> None: + if not settings.stars_enabled: + await callback.answer("Stars выключены", show_alert=True) + return + user = callback.from_user + if not user or not callback.message: + await callback.answer() + return + try: + amount = float((callback.data or "").split(":")[-1]) + except ValueError: + await callback.answer("Неверная сумма", show_alert=True) + return + if amount <= 0: + await callback.answer("Неверная сумма", show_alert=True) + return + + await callback.answer("Счёт Stars…") + try: + created = await create_stars_topup( + bot=callback.bot, + db=db, + telegram_id=user.id, + amount_rub=amount, + rub_per_star=settings.stars_rub_per_star, + ) + # Чат (включая Telegram Web): инвойс + запасная ссылка + await callback.message.answer_invoice( + title="Пополнение баланса", + description=( + f"Зачисление {created['rub_label']} на баланс VPN Service " + f"({created['stars_label']})" + ), + payload=created["payload"], + currency="XTR", + prices=[ + LabeledPrice( + label=f"Баланс {created['rub_label']}", + amount=int(created["stars"]), + ) + ], + provider_token="", + ) + from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup + + await callback.message.answer( + f"Счёт на {created['stars_label']} → " + f"{created['rub_label']} на баланс.\n\n" + "Оплати инвойс выше или кнопку ниже " + "(удобно в Telegram Web / мобильном).", + parse_mode="HTML", + reply_markup=InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=f"⭐ Оплатить {created['stars_label']}", + url=created["invoice_link"], + ) + ], + [ + InlineKeyboardButton( + text="💰 Баланс", + callback_data="pay:balance", + ) + ], + ] + ), + ) + except Exception as exc: # noqa: BLE001 + await callback.message.answer( + f"Не удалось создать счёт Stars.\n{exc}", + parse_mode="HTML", + reply_markup=_pay_kb( + cryptopay, heleket, digiseller, lava, settings=settings + ), + ) + + +@router.pre_checkout_query() +async def on_pre_checkout(query: PreCheckoutQuery) -> None: + parsed = parse_stars_payload(query.invoice_payload or "") + if not parsed: + await query.answer(ok=False, error_message="Неверный платёж") + return + await query.answer(ok=True) + + +@router.message(F.successful_payment) +async def on_successful_payment( + message: Message, + db: Database, + cryptopay: CryptoPay, + heleket: Heleket, + digiseller: Digiseller, + lava: Lava, + settings: Settings, +) -> None: + payment = message.successful_payment + if not payment or payment.currency != "XTR": + return + user = message.from_user + if not user: + return + + parsed = parse_stars_payload(payment.invoice_payload or "") + if not parsed: + await message.answer("Оплата получена, но payload не распознан. Напиши админу.") + return + + invoice_id, telegram_id, amount_rub = parsed + if telegram_id != user.id: + await message.answer("Оплата не совпала с аккаунтом. Напиши админу.") + return + + local = db.get_invoice(invoice_id, provider="stars") + if not local: + # fallback: credit anyway once, record meta by charge id + charge = payment.telegram_payment_charge_id + balance = db.add_balance( + user.id, + amount_rub, + kind="stars_topup", + meta=f"stars:orphan:{charge}", + ) + await message.answer( + f"✅ Баланс пополнен\n\n" + f"+{amount_rub:.0f} ₽ через Telegram Stars\n" + f"Доступно: {balance:.0f} ₽", + parse_mode="HTML", + reply_markup=_pay_kb( + cryptopay, heleket, digiseller, lava, settings=settings + ), + ) + return + + if local.get("status") == "paid": + await message.answer( + "Этот платёж уже зачислен.", + reply_markup=_pay_kb( + cryptopay, heleket, digiseller, lava, settings=settings + ), + ) + return + + if not db.mark_invoice_paid(invoice_id, provider="stars"): + await message.answer( + "Этот платёж уже зачислен.", + reply_markup=_pay_kb( + cryptopay, heleket, digiseller, lava, settings=settings + ), + ) + return + + amount = float(local["amount_rub"]) + charge = payment.telegram_payment_charge_id + balance = db.add_balance( + user.id, + amount, + kind="stars_topup", + meta=f"stars:{invoice_id}:{charge}", + ) + await message.answer( + f"✅ Баланс пополнен\n\n" + f"+{amount:.0f} ₽ через Telegram Stars ({payment.total_amount} ⭐)\n" + f"Доступно: {balance:.0f} ₽", + parse_mode="HTML", + reply_markup=_pay_kb(cryptopay, heleket, digiseller, lava, settings=settings), + ) + + +@router.callback_query(F.data == "pay:digiseller") +async def cb_pay_digiseller( + callback: CallbackQuery, + digiseller: Digiseller, + state: FSMContext, +) -> None: + if not digiseller.enabled: + await callback.answer("Digiseller не настроен", show_alert=True) + return + user = callback.from_user + if not user: + await callback.answer() + return + await state.clear() + await callback.answer() + pay_url = digiseller.pay_url_for(user.id) + if callback.message: + await callback.message.edit_text( + "💳 Пополнение через Digiseller\n\n" + "1) Нажми «Оплатить на Digiseller»\n" + "2) Оплати товар на сайте\n" + "3) Digiseller сам перенаправит на проверку — " + "баланс зачислится автоматически\n" + "4) В бот придёт уведомление о пополнении\n\n" + "Код вводить не нужно. Ручной ввод — только если автозачисление " + "не сработало.", + reply_markup=digiseller_inline(pay_url=pay_url), + parse_mode="HTML", + ) + + +@router.callback_query(F.data == "pay:digiseller_code") +async def cb_pay_digiseller_code( + callback: CallbackQuery, + digiseller: Digiseller, + state: FSMContext, +) -> None: + if not digiseller.enabled: + await callback.answer("Digiseller не настроен", show_alert=True) + return + await state.set_state(DigisellerStates.waiting_code) + await callback.answer() + if callback.message: + await callback.message.edit_text( + "🔑 Ручной ввод кода Digiseller\n\n" + "Нужен только если автозачисление не сработало.\n" + "Отправь уникальный код сообщением.", + reply_markup=digiseller_cancel_inline(), + parse_mode="HTML", + ) + + +@router.callback_query(F.data == "pay:digiseller_cancel") +async def cb_pay_digiseller_cancel( + callback: CallbackQuery, + state: FSMContext, + db: Database, + cryptopay: CryptoPay, + heleket: Heleket, + digiseller: Digiseller, + lava: Lava, +) -> None: + await state.clear() + user = callback.from_user + balance = db.get_balance(user.id) if user else 0 + await callback.answer("Отменено") + if callback.message: + await callback.message.edit_text( + f"💰 Баланс\n\nДоступно: {balance:.0f} ₽", + reply_markup=_pay_kb(cryptopay, heleket, digiseller, lava), + parse_mode="HTML", + ) + + +@router.message(DigisellerStates.waiting_code) +async def digiseller_got_code( + message: Message, + state: FSMContext, + db: Database, + digiseller: Digiseller, + cryptopay: CryptoPay, + heleket: Heleket, + lava: Lava, +) -> None: + user = message.from_user + if not user: + return + code = (message.text or "").strip() + if not code or code.startswith("/"): + await message.answer( + "Пришли уникальный код Digiseller или нажми «Отмена».", + reply_markup=digiseller_cancel_inline(), + ) + return + + wait = await message.answer("⏳ Проверяю уникальный код…") + try: + result = await fulfill_digiseller_unique_code( + db=db, + digiseller=digiseller, + unique_code=code, + telegram_id_hint=user.id, + bot=None, + notify=False, + ) + await state.clear() + await wait.edit_text( + result.message, + reply_markup=_pay_kb(cryptopay, heleket, digiseller, lava), + parse_mode="HTML", + ) + except DigisellerError as exc: + await wait.edit_text( + f"Не удалось проверить код.\n{exc}\n\n" + "Проверь код и попробуй ещё раз, либо нажми «Отмена».", + reply_markup=digiseller_cancel_inline(), + parse_mode="HTML", + ) + except Exception as exc: # noqa: BLE001 + await wait.edit_text( + f"Ошибка проверки.\n{exc}", + reply_markup=digiseller_cancel_inline(), + parse_mode="HTML", + ) + + +@router.callback_query(F.data == "pay:topup_code") +async def cb_pay_topup_code( + callback: CallbackQuery, + state: FSMContext, + settings: Settings, +) -> None: + await state.set_state(TopupCodeStates.waiting_code) + await callback.answer() + pay_url = settings.topup_code_pay_url + lines = [ + "🎫 Пополнение по коду\n", + "Для карты и СБП — покупай код (на странице оплаты " + "более 40 способов).\n", + "Напрямую в боте без кода — только крипта.\n", + "Отправь код сообщением (XXXX-XXXX-XXXX-XXXX). Один код — одно пополнение.", + ] + if pay_url: + lines.append("\nКупить код — кнопка ниже.") + if callback.message: + await callback.message.edit_text( + "\n".join(lines), + reply_markup=topup_code_inline(pay_url=pay_url), + parse_mode="HTML", + ) + + +@router.callback_query(F.data == "pay:topup_code_cancel") +async def cb_pay_topup_code_cancel( + callback: CallbackQuery, + state: FSMContext, + db: Database, + cryptopay: CryptoPay, + heleket: Heleket, + digiseller: Digiseller, + lava: Lava, +) -> None: + await state.clear() + user = callback.from_user + balance = db.get_balance(user.id) if user else 0 + await callback.answer("Отменено") + if callback.message: + await callback.message.edit_text( + f"💰 Баланс\n\nДоступно: {balance:.0f} ₽", + reply_markup=_pay_kb(cryptopay, heleket, digiseller, lava), + parse_mode="HTML", + ) + + +@router.message(TopupCodeStates.waiting_code) +async def topup_code_got_code( + message: Message, + state: FSMContext, + db: Database, + cryptopay: CryptoPay, + heleket: Heleket, + digiseller: Digiseller, + lava: Lava, +) -> None: + user = message.from_user + if not user: + return + code = (message.text or "").strip() + if not code or code.startswith("/"): + await message.answer( + "Пришли код пополнения или нажми «Отмена».", + reply_markup=topup_code_inline(), + ) + return + + wait = await message.answer("⏳ Проверяю код…") + try: + result = redeem_topup_code(db, telegram_id=user.id, raw_code=code) + await state.clear() + await wait.edit_text( + result.message, + reply_markup=_pay_kb(cryptopay, heleket, digiseller, lava), + parse_mode="HTML", + ) + except TopupCodeError as exc: + await wait.edit_text( + f"Не удалось активировать код.\n{exc}", + reply_markup=topup_code_inline(), + parse_mode="HTML", + ) + except Exception as exc: # noqa: BLE001 + await wait.edit_text( + f"Ошибка.\n{exc}", + reply_markup=topup_code_inline(), + parse_mode="HTML", + ) + + + +@router.callback_query(F.data == "pay:lava") +async def cb_pay_lava(callback: CallbackQuery, lava: Lava) -> None: + if not lava.enabled: + await callback.answer("Lava не настроен", show_alert=True) + return + await callback.answer() + if callback.message: + await callback.message.edit_text( + "💳 Пополнение через Lava\n\n" + "Карта / СБП. Выбери сумму — после оплаты баланс зачислится автоматически.", + reply_markup=topup_amounts_inline("lava"), + parse_mode="HTML", + ) + + +@router.callback_query(F.data == "pay:anypay") +async def cb_pay_anypay(callback: CallbackQuery, anypay: Anypay) -> None: + if not anypay.enabled: + await callback.answer("AnyPay не настроен", show_alert=True) + return + await callback.answer() + if callback.message: + await callback.message.edit_text( + "💳 Пополнение через AnyPay\n\n" + "Карта, СБП, крипта и др. Сумма от 10 до 3000 ₽. " + "После оплаты баланс зачислится автоматически.", + reply_markup=topup_amounts_inline("anypay"), + parse_mode="HTML", + ) + + +@router.callback_query(F.data.startswith("shop:buy_lava:")) +async def cb_shop_buy_lava( + callback: CallbackQuery, + tariffs: list[Tariff], + db: Database, + lava: Lava, +) -> None: + if not lava.enabled: + await callback.answer("Lava не настроен", show_alert=True) + return + tariff_id = (callback.data or "").split(":", 2)[2] + tariff = get_tariff(tariffs, tariff_id) + if not tariff: + await callback.answer("Тариф не найден", show_alert=True) + return + user = callback.from_user + if not user: + await callback.answer() + return + + await callback.answer("Создаю счёт…") + order_id = _safe_order_id(f"tariff_{tariff.id}_{user.id}_{int(time.time())}") + try: + inv = await lava.create_invoice( + amount_rub=float(tariff.price), + order_id=order_id, + comment=f"Tariff {tariff.id} tg={user.id}", + custom_fields=str(user.id), + ) + except LavaError as exc: + if callback.message: + await callback.message.edit_text( + f"Не удалось создать счёт Lava.\n{exc}", + parse_mode="HTML", + ) + return + local_id = db.next_local_invoice_id() + db.create_invoice_record( + telegram_id=user.id, + invoice_id=local_id, + kind="tariff", + amount_rub=float(tariff.price), + pay_url=inv.pay_url, + payload=None, + provider="lava", + tariff_id=tariff.id, + order_id=order_id, + external_uuid=inv.invoice_id, + ) + if callback.message: + await callback.message.edit_text( + f"💳 Счёт Lava · {tariff.title}\n\n" + f"Сумма: {tariff.format_price()}\n" + "Оплати картой или СБП — тариф активируется автоматически.", + reply_markup=invoice_inline(local_id, inv.pay_url, provider="lava"), + parse_mode="HTML", + ) + + +@router.callback_query(F.data.startswith("shop:buy_anypay:")) +async def cb_shop_buy_anypay( + callback: CallbackQuery, + tariffs: list[Tariff], + db: Database, + anypay: Anypay, +) -> None: + if not anypay.enabled: + await callback.answer("AnyPay не настроен", show_alert=True) + return + tariff_id = (callback.data or "").split(":", 2)[2] + tariff = get_tariff(tariffs, tariff_id) + if not tariff: + await callback.answer("Тариф не найден", show_alert=True) + return + user = callback.from_user + if not user: + await callback.answer() + return + if float(tariff.price) < 10 or float(tariff.price) > 3000: + await callback.answer( + "Для тарифа вне 10–3000 ₽ оплатите с баланса или другим способом", + show_alert=True, + ) + return + + await callback.answer("Создаю счёт…") + local_id = db.next_local_invoice_id() + pay_id = str(local_id) + try: + inv = await anypay.create_invoice( + pay_id=pay_id, + amount_rub=float(tariff.price), + desc=f"Tariff {tariff.id} tg={user.id}", + ) + except AnypayError as exc: + if callback.message: + await callback.message.edit_text( + f"Не удалось создать счёт AnyPay.\n{exc}", + parse_mode="HTML", + ) + return + db.create_invoice_record( + telegram_id=user.id, + invoice_id=local_id, + kind="tariff", + amount_rub=float(tariff.price), + pay_url=inv.pay_url, + payload=pay_id, + provider="anypay", + tariff_id=tariff.id, + order_id=pay_id, + external_uuid=inv.transaction_id, + ) + if callback.message: + await callback.message.edit_text( + f"💳 Счёт AnyPay · {tariff.title}\n\n" + f"Сумма: {tariff.format_price()}\n" + "Оплати на странице AnyPay — тариф активируется автоматически.", + reply_markup=invoice_inline(local_id, inv.pay_url, provider="anypay"), + parse_mode="HTML", + ) + + +@router.callback_query(F.data.startswith("pay:check_lava:")) +async def cb_pay_check_lava( + callback: CallbackQuery, + db: Database, + sdk: RemnawaveSDK, + tariffs: list[Tariff], + cryptopay: CryptoPay, + heleket: Heleket, + lava: Lava, +) -> None: + if not lava.enabled: + await callback.answer("Lava не настроен", show_alert=True) + return + invoice_id = int((callback.data or "").split(":")[-1]) + local = db.get_invoice(invoice_id, provider="lava") + if not local: + await callback.answer("Счёт не найден", show_alert=True) + return + if local["telegram_id"] != (callback.from_user.id if callback.from_user else 0): + await callback.answer("Это не твой счёт", show_alert=True) + return + + await callback.answer("Проверяю…") + order_id = str(local.get("order_id") or "") + external = str(local.get("external_uuid") or "") + try: + remote = await lava.get_invoice( + order_id=order_id or None, + invoice_id=external or None, + ) + except LavaError as exc: + if callback.message: + await callback.message.answer( + f"Ошибка проверки: {exc}", parse_mode="HTML" + ) + return + + status = remote.status if remote else "unknown" + if remote is None or not lava.is_paid(status): + await callback.answer(f"Ещё не оплачено ({status})", show_alert=True) + return + + result = await fulfill_paid_invoice(db=db, sdk=sdk, tariffs=tariffs, local=local) + if callback.message: + await callback.message.edit_text( + result.message, + reply_markup=_pay_kb(cryptopay, heleket, lava=lava), + parse_mode="HTML", + ) + + +@router.callback_query(F.data == "pay:topup") +async def cb_pay_topup( + callback: CallbackQuery, + cryptopay: CryptoPay, + heleket: Heleket, +) -> None: + if not _any_crypto(cryptopay, heleket): + await callback.answer("Крипто-оплата не настроена", show_alert=True) + return + await callback.answer() + if not callback.message: + return + + if cryptopay.enabled and not heleket.enabled: + await callback.message.edit_text( + "🪙 Пополнение криптой\n\n" + "Без кода — только криптовалюта. Карта и СБП — через " + "код пополнения (40+ способов на странице покупки).\n\n" + "Выбери сумму в рублях.\n" + "Оплата через @CryptoBot (USDT / TON / BTC…).", + reply_markup=topup_amounts_inline("cryptobot"), + parse_mode="HTML", + ) + return + if heleket.enabled and not cryptopay.enabled: + await callback.message.edit_text( + "🪙 Пополнение криптой\n\n" + "Без кода — только криптовалюта. Карта и СБП — код пополнения.\n\n" + "Выбери сумму в рублях.\n" + "Оплата через Heleket.", + reply_markup=topup_amounts_inline("heleket"), + parse_mode="HTML", + ) + return + + await callback.message.edit_text( + "🪙 Пополнение криптой\n\n" + "Напрямую без кода — только крипта. Карта / СБП — «Код пополнения».\n\n" + "Выбери платёжку:\n" + "• Crypto Bot — основная\n" + "• Heleket — резервная", + reply_markup=topup_providers_inline( + crypto_enabled=cryptopay.enabled, + heleket_enabled=heleket.enabled, + ), + parse_mode="HTML", + ) + + +@router.callback_query(F.data.startswith("pay:topup_provider:")) +async def cb_pay_topup_provider(callback: CallbackQuery) -> None: + provider = (callback.data or "").split(":")[-1] + label = { + "cryptobot": "Crypto Bot", + "heleket": "Heleket", + "lava": "Lava", + "anypay": "AnyPay", + }.get(provider, provider) + await callback.answer() + if callback.message: + await callback.message.edit_text( + f"🪙 Пополнение через {label}\n\nВыбери сумму в рублях.", + reply_markup=topup_amounts_inline(provider), + parse_mode="HTML", + ) + + +@router.callback_query(F.data.startswith("pay:topup_amount:")) +async def cb_pay_topup_amount( + callback: CallbackQuery, + db: Database, + cryptopay: CryptoPay, + heleket: Heleket, + lava: Lava, + anypay: Anypay, +) -> None: + user = callback.from_user + if not user: + await callback.answer() + return + parts = (callback.data or "").split(":") + if len(parts) >= 4: + provider = parts[2] + amount = float(parts[3]) + else: + provider = "cryptobot" + amount = float(parts[-1]) + + if amount < 10 or amount > 3000: + await callback.answer("Сумма от 10 до 3000 ₽", show_alert=True) + return + + await callback.answer("Создаю счёт…") + try: + if provider == "lava": + if not lava.enabled: + await callback.answer("Lava не настроен", show_alert=True) + return + order_id = _safe_order_id(f"topup_{user.id}_{int(amount)}_{int(time.time())}") + inv = await lava.create_invoice( + amount_rub=amount, + order_id=order_id, + comment=f"Topup {int(amount)} RUB tg={user.id}", + custom_fields=str(user.id), + ) + local_id = db.next_local_invoice_id() + db.create_invoice_record( + telegram_id=user.id, + invoice_id=local_id, + kind="topup", + amount_rub=amount, + pay_url=inv.pay_url, + payload=None, + provider="lava", + order_id=order_id, + external_uuid=inv.invoice_id, + ) + if callback.message: + await callback.message.edit_text( + f"💳 Счёт Lava на {amount:.0f} ₽\n\n" + "Оплати картой или СБП. Баланс зачислится автоматически.", + reply_markup=invoice_inline(local_id, inv.pay_url, provider="lava"), + parse_mode="HTML", + ) + return + if provider == "anypay": + if not anypay.enabled: + await callback.answer("AnyPay не настроен", show_alert=True) + return + local_id = db.next_local_invoice_id() + pay_id = str(local_id) + inv = await anypay.create_invoice( + pay_id=pay_id, + amount_rub=amount, + desc=f"Topup {int(amount)} RUB tg={user.id}", + ) + db.create_invoice_record( + telegram_id=user.id, + invoice_id=local_id, + kind="topup", + amount_rub=amount, + pay_url=inv.pay_url, + payload=pay_id, + provider="anypay", + order_id=pay_id, + external_uuid=inv.transaction_id, + ) + if callback.message: + await callback.message.edit_text( + f"💳 Счёт AnyPay на {amount:.0f} ₽\n\n" + "Оплати на странице AnyPay. Баланс зачислится автоматически.", + reply_markup=invoice_inline(local_id, inv.pay_url, provider="anypay"), + parse_mode="HTML", + ) + return + if provider == "heleket": + if not heleket.enabled: + await callback.answer("Heleket не настроен", show_alert=True) + return + order_id = _safe_order_id(f"topup_{user.id}_{int(amount)}_{int(time.time())}") + inv = await heleket.create_invoice( + amount_rub=amount, + order_id=order_id, + description=f"Topup {int(amount)} RUB tg={user.id}", + ) + local_id = db.next_local_invoice_id() + db.create_invoice_record( + telegram_id=user.id, + invoice_id=local_id, + kind="topup", + amount_rub=amount, + pay_url=inv.pay_url, + payload=order_id, + provider="heleket", + order_id=order_id, + external_uuid=inv.uuid, + ) + pay_url = inv.pay_url + invoice_id = local_id + how = "Heleket" + else: + if not cryptopay.enabled: + await callback.answer("Crypto Bot не настроен", show_alert=True) + return + payload = f"topup:{user.id}:{int(amount)}" + invoice = await cryptopay.create_fiat_invoice( + amount_rub=amount, + description=f"Пополнение баланса на {int(amount)} ₽", + payload=payload, + ) + db.create_invoice_record( + telegram_id=user.id, + invoice_id=invoice.invoice_id, + kind="topup", + amount_rub=amount, + pay_url=invoice.pay_url, + payload=payload, + provider="cryptobot", + ) + pay_url = invoice.pay_url + invoice_id = invoice.invoice_id + how = "Crypto Bot" + except (CryptoPayError, HeleketError, AnypayError) as exc: + if callback.message: + await callback.message.edit_text( + f"Не удалось создать счёт.\n{exc}", + parse_mode="HTML", + ) + return + + if callback.message: + await callback.message.edit_text( + f"🪙 Счёт на {int(amount)} ₽ ({how})\n\n" + "1) Нажми «Оплатить»\n" + "2) Оплати криптой\n" + "3) Вернись и нажми «Я оплатил»", + reply_markup=invoice_inline(invoice_id, pay_url, provider=provider), + parse_mode="HTML", + ) + + +@router.message(F.text == "🛒 Тарифы") +async def cmd_tariffs(message: Message, tariffs: list[Tariff]) -> None: + await message.answer( + "🛒 Тарифы\n\nВыбери подходящий план:", + reply_markup=tariffs_inline(tariffs), + parse_mode="HTML", + ) + + +@router.callback_query(F.data == "shop:list") +async def cb_shop_list(callback: CallbackQuery, tariffs: list[Tariff]) -> None: + await callback.answer() + if callback.message: + await callback.message.edit_text( + "🛒 Тарифы\n\nВыбери подходящий план:", + reply_markup=tariffs_inline(tariffs), + parse_mode="HTML", + ) + + +@router.callback_query(F.data.startswith("shop:view:")) +async def cb_shop_view( + callback: CallbackQuery, + tariffs: list[Tariff], + db: Database, + cryptopay: CryptoPay, + heleket: Heleket, + lava: Lava, +) -> None: + tariff_id = (callback.data or "").split(":", 2)[2] + tariff = get_tariff(tariffs, tariff_id) + if not tariff: + await callback.answer("Тариф не найден", show_alert=True) + return + user = callback.from_user + balance = db.get_balance(user.id) if user else 0 + await callback.answer() + if callback.message: + await callback.message.edit_text( + _tariff_card(tariff, balance), + reply_markup=_pay_kb(cryptopay, heleket, tariff_id=tariff.id, lava=lava), + parse_mode="HTML", + ) + + +@router.callback_query(F.data.startswith("shop:buy_balance:")) +async def cb_shop_buy_balance( + callback: CallbackQuery, + tariffs: list[Tariff], + db: Database, + sdk: RemnawaveSDK, + cryptopay: CryptoPay, + heleket: Heleket, + lava: Lava, +) -> None: + tariff_id = (callback.data or "").split(":", 2)[2] + tariff = get_tariff(tariffs, tariff_id) + if not tariff: + await callback.answer("Тариф не найден", show_alert=True) + return + user = callback.from_user + if not user: + await callback.answer("Ошибка пользователя", show_alert=True) + return + + await callback.answer("Оформляю…") + db.ensure_user(user.id, username=user.username, full_name=user.full_name) + result = await purchase_tariff( + db=db, + sdk=sdk, + telegram_id=user.id, + tariff=tariff, + charge_balance=True, + ) + if callback.message: + await callback.message.edit_text( + result.message, + reply_markup=( + _pay_kb(cryptopay, heleket, lava=lava) + if result.ok + else _pay_kb(cryptopay, heleket, tariff_id=tariff.id, lava=lava) + ), + parse_mode="HTML", + ) + + +@router.callback_query(F.data.startswith("shop:buy_crypto:")) +async def cb_shop_buy_crypto( + callback: CallbackQuery, + tariffs: list[Tariff], + db: Database, + cryptopay: CryptoPay, +) -> None: + if not cryptopay.enabled: + await callback.answer("Крипто-оплата не настроена", show_alert=True) + return + tariff_id = (callback.data or "").split(":", 2)[2] + tariff = get_tariff(tariffs, tariff_id) + if not tariff: + await callback.answer("Тариф не найден", show_alert=True) + return + user = callback.from_user + if not user: + await callback.answer() + return + + await callback.answer("Создаю счёт…") + payload = f"tariff:{tariff.id}:{user.id}" + try: + invoice = await cryptopay.create_fiat_invoice( + amount_rub=tariff.price, + description=f"Тариф «{tariff.title}» — {tariff.format_price()}", + payload=payload, + ) + except CryptoPayError as exc: + if callback.message: + await callback.message.edit_text( + f"Не удалось создать счёт.\n{exc}", + parse_mode="HTML", + ) + return + + db.create_invoice_record( + telegram_id=user.id, + invoice_id=invoice.invoice_id, + kind="tariff", + amount_rub=tariff.price, + pay_url=invoice.pay_url, + payload=payload, + tariff_id=tariff.id, + provider="cryptobot", + ) + if callback.message: + await callback.message.edit_text( + f"🪙 Оплата тарифа «{tariff.title}» (Crypto Bot)\n" + f"Сумма: {tariff.format_price()}\n\n" + "1) Нажми «Оплатить»\n" + "2) Оплати в Crypto Bot\n" + "3) Вернись и нажми «Я оплатил»", + reply_markup=invoice_inline( + invoice.invoice_id, invoice.pay_url, provider="cryptobot" + ), + parse_mode="HTML", + ) + + +@router.callback_query(F.data.startswith("shop:buy_heleket:")) +async def cb_shop_buy_heleket( + callback: CallbackQuery, + tariffs: list[Tariff], + db: Database, + heleket: Heleket, +) -> None: + if not heleket.enabled: + await callback.answer("Heleket не настроен", show_alert=True) + return + tariff_id = (callback.data or "").split(":", 2)[2] + tariff = get_tariff(tariffs, tariff_id) + if not tariff: + await callback.answer("Тариф не найден", show_alert=True) + return + user = callback.from_user + if not user: + await callback.answer() + return + + await callback.answer("Создаю счёт…") + order_id = _safe_order_id( + f"tariff_{tariff.id}_{user.id}_{int(time.time())}_{uuid.uuid4().hex[:6]}" + ) + try: + inv = await heleket.create_invoice( + amount_rub=tariff.price, + order_id=order_id, + description=f"Tariff {tariff.id} tg={user.id}", + ) + except HeleketError as exc: + if callback.message: + await callback.message.edit_text( + f"Не удалось создать счёт Heleket.\n{exc}", + parse_mode="HTML", + ) + return + + local_id = db.next_local_invoice_id() + db.create_invoice_record( + telegram_id=user.id, + invoice_id=local_id, + kind="tariff", + amount_rub=tariff.price, + pay_url=inv.pay_url, + payload=order_id, + tariff_id=tariff.id, + provider="heleket", + order_id=order_id, + external_uuid=inv.uuid, + ) + if callback.message: + await callback.message.edit_text( + f"🪙 Оплата тарифа «{tariff.title}» (Heleket)\n" + f"Сумма: {tariff.format_price()}\n\n" + "1) Нажми «Оплатить»\n" + "2) Оплати на странице Heleket\n" + "3) Вернись и нажми «Я оплатил» " + "(или дождись авто-зачисления)", + reply_markup=invoice_inline(local_id, inv.pay_url, provider="heleket"), + parse_mode="HTML", + ) + + +@router.callback_query(F.data.startswith("pay:check:")) +async def cb_pay_check( + callback: CallbackQuery, + db: Database, + sdk: RemnawaveSDK, + tariffs: list[Tariff], + cryptopay: CryptoPay, + heleket: Heleket, + lava: Lava, +) -> None: + if not cryptopay.enabled: + await callback.answer("Крипто-оплата не настроена", show_alert=True) + return + invoice_id = int((callback.data or "").split(":")[-1]) + local = db.get_invoice(invoice_id, provider="cryptobot") + if not local: + await callback.answer("Счёт не найден", show_alert=True) + return + if local["telegram_id"] != (callback.from_user.id if callback.from_user else 0): + await callback.answer("Это не твой счёт", show_alert=True) + return + + await callback.answer("Проверяю…") + try: + remote = await cryptopay.get_invoice(invoice_id) + except CryptoPayError as exc: + if callback.message: + await callback.message.answer( + f"Ошибка проверки: {exc}", parse_mode="HTML" + ) + return + + if remote is None or remote.status != "paid": + status = remote.status if remote else "unknown" + await callback.answer(f"Ещё не оплачено ({status})", show_alert=True) + return + + result = await fulfill_paid_invoice(db=db, sdk=sdk, tariffs=tariffs, local=local) + if callback.message: + await callback.message.edit_text( + result.message, + reply_markup=_pay_kb(cryptopay, heleket, lava=lava), + parse_mode="HTML", + ) + + +@router.callback_query(F.data.startswith("pay:check_hk:")) +async def cb_pay_check_heleket( + callback: CallbackQuery, + db: Database, + sdk: RemnawaveSDK, + tariffs: list[Tariff], + cryptopay: CryptoPay, + heleket: Heleket, + lava: Lava, +) -> None: + if not heleket.enabled: + await callback.answer("Heleket не настроен", show_alert=True) + return + invoice_id = int((callback.data or "").split(":")[-1]) + local = db.get_invoice(invoice_id, provider="heleket") + if not local: + await callback.answer("Счёт не найден", show_alert=True) + return + if local["telegram_id"] != (callback.from_user.id if callback.from_user else 0): + await callback.answer("Это не твой счёт", show_alert=True) + return + + await callback.answer("Проверяю…") + order_id = str(local.get("order_id") or "") + try: + remote = await heleket.get_invoice( + order_id=order_id or None, + uuid=str(local.get("external_uuid") or "") or None, + ) + except HeleketError as exc: + if callback.message: + await callback.message.answer( + f"Ошибка проверки: {exc}", parse_mode="HTML" + ) + return + + if remote is None or not Heleket.is_paid(remote.status): + status = remote.status if remote else "unknown" + await callback.answer(f"Ещё не оплачено ({status})", show_alert=True) + return + + result = await fulfill_paid_invoice(db=db, sdk=sdk, tariffs=tariffs, local=local) + if callback.message: + await callback.message.edit_text( + result.message, + reply_markup=_pay_kb(cryptopay, heleket, lava=lava), + parse_mode="HTML", + ) + + +@router.callback_query(F.data == "traffic:list") +async def cb_traffic_list( + callback: CallbackQuery, + sdk: RemnawaveSDK, + db: Database, + tariffs: list[Tariff], +) -> None: + user = callback.from_user + if not user: + await callback.answer() + return + info = await get_my_subscription_info(sdk, user.id, db=db, tariffs=tariffs) + if not info.has_subscription: + await callback.answer("Сначала купи тариф", show_alert=True) + return + balance = db.get_balance(user.id) + traffic_line = info.traffic.format_line() if info.traffic else "—" + await callback.answer() + if callback.message: + await callback.message.edit_text( + "📦 Докупка трафика\n\n" + f"{traffic_line}\n" + f"Баланс: {balance:.0f} ₽\n\n" + f"База в тарифе: {format_gb(BASE_TRAFFIC_GB, digits=0)}\n" + f"Пакет: +{format_gb(PACK_GB, digits=0)} = {int(PACK_PRICE_RUB)} ₽/мес\n\n" + "⚠️ Доп. трафик оплачивается ежемесячно с баланса " + "(первый месяц — при покупке).\n\n" + "Выбери объём:", + reply_markup=traffic_packs_inline(), + parse_mode="HTML", + ) + + +@router.callback_query(F.data.startswith("traffic:buy:")) +async def cb_traffic_buy_view( + callback: CallbackQuery, + db: Database, +) -> None: + packs = int((callback.data or "").split(":")[-1]) + pack = get_traffic_pack(str(packs)) + if not pack: + await callback.answer("Пакет не найден", show_alert=True) + return + user = callback.from_user + balance = db.get_balance(user.id) if user else 0 + await callback.answer() + if callback.message: + await callback.message.edit_text( + f"📦 {pack.title}\n\n" + f"Добавится к текущему лимиту.\n" + f"Цена: {pack.format_price()} (первый месяц)\n" + f"Далее списание каждые 30 дней с баланса.\n" + f"Баланс: {balance:.0f} ₽", + reply_markup=traffic_confirm_inline(pack.packs), + parse_mode="HTML", + ) + + +@router.callback_query(F.data.startswith("traffic:confirm:")) +async def cb_traffic_confirm( + callback: CallbackQuery, + db: Database, + sdk: RemnawaveSDK, +) -> None: + packs = int((callback.data or "").split(":")[-1]) + pack = get_traffic_pack(str(packs)) + if not pack: + await callback.answer("Пакет не найден", show_alert=True) + return + user = callback.from_user + if not user: + await callback.answer() + return + await callback.answer("Покупаю…") + result = await purchase_traffic_pack( + db=db, + sdk=sdk, + telegram_id=user.id, + pack=pack, + charge_balance=True, + ) + if callback.message: + await callback.message.edit_text( + result.message, + reply_markup=( + subscription_inline(has_subscription=True) + if result.ok + else traffic_confirm_inline(pack.packs) + ), + parse_mode="HTML", + ) + + +@router.callback_query(F.data == "devices:list") +async def cb_devices_list( + callback: CallbackQuery, + sdk: RemnawaveSDK, + db: Database, + tariffs: list[Tariff], +) -> None: + user = callback.from_user + if not user: + await callback.answer() + return + info = await get_my_subscription_info(sdk, user.id, db=db, tariffs=tariffs) + if not info.has_subscription: + await callback.answer("Сначала купи тариф", show_alert=True) + return + balance = db.get_balance(user.id) + devices = info.devices + devices_line = devices.format_line() if devices else "—" + excess = devices.excess if devices else 0 + await callback.answer() + if callback.message: + await callback.message.edit_text( + "📱 Устройства\n\n" + f"{devices_line}\n" + f"Баланс: {balance:.0f} ₽\n\n" + f"База: {BASE_DEVICES} устройств в тарифе.\n" + f"Доп. слот: {int(EXTRA_DEVICE_PRICE_RUB)} ₽/мес " + f"(первый месяц при покупке).\n" + "⚠️ Списание с баланса каждые 30 дней за все доп. устройства.\n" + "При превышении HWID подписка отключается до оплаты слотов.", + reply_markup=device_packs_inline(excess=excess), + parse_mode="HTML", + ) + + +@router.callback_query(F.data.startswith("devices:buy:")) +async def cb_devices_buy_view( + callback: CallbackQuery, + db: Database, +) -> None: + slots = int((callback.data or "").split(":")[-1]) + pack = get_device_pack(str(slots)) + if not pack: + await callback.answer("Пакет не найден", show_alert=True) + return + user = callback.from_user + balance = db.get_balance(user.id) if user else 0 + await callback.answer() + if callback.message: + await callback.message.edit_text( + f"📱 {pack.title}\n\n" + f"Добавится к лимиту устройств.\n" + f"Цена: {pack.format_price()} (первый месяц)\n" + f"Далее — {int(EXTRA_DEVICE_PRICE_RUB)} ₽/мес за слот с баланса.\n" + f"Баланс: {balance:.0f} ₽", + reply_markup=device_confirm_inline(pack.slots), + parse_mode="HTML", + ) + + +@router.callback_query(F.data.startswith("devices:confirm:")) +async def cb_devices_confirm( + callback: CallbackQuery, + db: Database, + sdk: RemnawaveSDK, +) -> None: + slots = int((callback.data or "").split(":")[-1]) + pack = get_device_pack(str(slots)) + if not pack: + # оплата произвольного превышения excess N + from services.devices import DevicePack, EXTRA_DEVICE_PRICE_RUB as PRICE + + pack = DevicePack(slots=slots, price=PRICE * slots) + user = callback.from_user + if not user: + await callback.answer() + return + await callback.answer("Покупаю…") + result = await purchase_device_pack( + db=db, + sdk=sdk, + telegram_id=user.id, + pack=pack, + charge_balance=True, + ) + if callback.message: + over = bool(result.devices and result.devices.over_limit) + await callback.message.edit_text( + result.message, + reply_markup=( + subscription_inline(has_subscription=True, devices_over=over) + if result.ok + else device_confirm_inline(pack.slots) + ), + parse_mode="HTML", + ) + + +@router.message(F.text == "📱 Моя подписка") +async def cmd_my_sub( + message: Message, sdk: RemnawaveSDK, db: Database, tariffs: list[Tariff] +) -> None: + user = message.from_user + if not user: + return + db.ensure_user(user.id, username=user.username, full_name=user.full_name) + wait = await message.answer("⏳ Загружаю…") + try: + info = await get_my_subscription_info( + sdk, + user.id, + db=db, + tariffs=tariffs, + renewal_bot=message.bot, + ) + await wait.edit_text( + info.text, + reply_markup=subscription_inline( + has_subscription=info.has_subscription, + devices_over=bool(info.devices and info.devices.over_limit), + ), + parse_mode="HTML", + ) + except Exception as exc: # noqa: BLE001 + await wait.edit_text( + f"Не удалось получить подписку.\n{exc}", + parse_mode="HTML", + ) + + +@router.callback_query(F.data == "sub:mine") +async def cb_my_sub( + callback: CallbackQuery, + sdk: RemnawaveSDK, + db: Database, + tariffs: list[Tariff], +) -> None: + user = callback.from_user + if not user: + await callback.answer() + return + await callback.answer() + try: + info = await get_my_subscription_info( + sdk, + user.id, + db=db, + tariffs=tariffs, + renewal_bot=callback.bot, + ) + if callback.message: + await callback.message.edit_text( + info.text, + reply_markup=subscription_inline( + has_subscription=info.has_subscription, + devices_over=bool(info.devices and info.devices.over_limit), + ), + parse_mode="HTML", + ) + except Exception as exc: # noqa: BLE001 + if callback.message: + await callback.message.edit_text( + f"Не удалось получить подписку.\n{exc}", + parse_mode="HTML", + ) + + +@router.callback_query(F.data == "renew:list") +async def cb_renew_list( + callback: CallbackQuery, + tariffs: list[Tariff], + sdk: RemnawaveSDK, + db: Database, +) -> None: + user = callback.from_user + if not user: + await callback.answer() + return + info = await get_my_subscription_info(sdk, user.id, db=db, tariffs=tariffs) + if not info.has_subscription: + await callback.answer("Сначала купи тариф", show_alert=True) + if callback.message: + await callback.message.edit_text( + info.text, + reply_markup=subscription_inline(has_subscription=False), + parse_mode="HTML", + ) + return + + balance = db.get_balance(user.id) + await callback.answer() + expire = ( + info.expire_at.strftime("%d.%m.%Y %H:%M UTC") + if info.expire_at + else "—" + ) + if callback.message: + await callback.message.edit_text( + "🔄 Продление подписки\n\n" + f"Сейчас до: {expire}\n" + f"Баланс: {balance:.0f} ₽\n\n" + "Выбери срок — дни добавятся к текущей дате.\n" + "Оплата только с баланса.", + reply_markup=renew_tariffs_inline(tariffs), + parse_mode="HTML", + ) + + +@router.callback_query(F.data.startswith("renew:view:")) +async def cb_renew_view( + callback: CallbackQuery, + tariffs: list[Tariff], + db: Database, + sdk: RemnawaveSDK, +) -> None: + tariff_id = (callback.data or "").split(":", 2)[2] + tariff = get_tariff(tariffs, tariff_id) + if not tariff: + await callback.answer("Тариф не найден", show_alert=True) + return + user = callback.from_user + balance = db.get_balance(user.id) if user else 0 + info = await get_my_subscription_info(sdk, user.id, db=db, tariffs=tariffs) if user else None + expire = ( + info.expire_at.strftime("%d.%m.%Y") + if info and info.expire_at + else "—" + ) + await callback.answer() + if callback.message: + await callback.message.edit_text( + f"🔄 Продлить: {tariff.title}\n\n" + f"{tariff.description}\n\n" + f"➕ Срок: +{tariff.days} дн.\n" + f"💵 Цена: {tariff.format_price()}\n" + f"📅 Сейчас до: {expire}\n" + f"💰 Баланс: {balance:.0f} ₽", + reply_markup=renew_confirm_inline(tariff.id), + parse_mode="HTML", + ) + + +@router.callback_query(F.data.startswith("renew:buy:")) +async def cb_renew_buy( + callback: CallbackQuery, + tariffs: list[Tariff], + db: Database, + sdk: RemnawaveSDK, +) -> None: + tariff_id = (callback.data or "").split(":", 2)[2] + tariff = get_tariff(tariffs, tariff_id) + if not tariff: + await callback.answer("Тариф не найден", show_alert=True) + return + user = callback.from_user + if not user: + await callback.answer("Ошибка пользователя", show_alert=True) + return + + await callback.answer("Продлеваю…") + db.ensure_user(user.id, username=user.username, full_name=user.full_name) + result = await purchase_tariff( + db=db, + sdk=sdk, + telegram_id=user.id, + tariff=tariff, + charge_balance=True, + renew=True, + payment_meta=f"renew:{tariff.id}", + ) + if callback.message: + await callback.message.edit_text( + result.message, + reply_markup=( + subscription_inline(has_subscription=True) + if result.ok + else renew_confirm_inline(tariff.id) + ), + parse_mode="HTML", + ) diff --git a/handlers_tickets.py b/handlers_tickets.py new file mode 100644 index 0000000..39da07b --- /dev/null +++ b/handlers_tickets.py @@ -0,0 +1,590 @@ +"""Тикеты поддержки — пользователь и админ в Telegram.""" + +from __future__ import annotations + +import html + +from aiogram import Bot, F, Router +from aiogram.fsm.context import FSMContext +from aiogram.fsm.state import State, StatesGroup +from aiogram.types import CallbackQuery, Message + +from config import Settings +from keyboards import ( + admin_tickets_root_inline, + ticket_cancel_inline, + ticket_view_inline, + tickets_list_inline, + tickets_root_inline, +) +from services.db import Database +from services.ticket_ai import try_ai_ticket_reply +from services.tickets import ( + BODY_MAX, + MAX_OPEN_PER_USER, + SUBJECT_MAX, + clamp_body, + clamp_subject, + format_admin_ticket_card, + format_ticket_card, + notify_admins_new_ticket, + notify_admins_ticket_reply, + notify_user_ticket_reply, + status_label, +) + +router = Router(name="tickets") + + +class TicketCreateStates(StatesGroup): + waiting_subject = State() + waiting_body = State() + + +class TicketReplyStates(StatesGroup): + waiting_body = State() + + +class AdminTicketReplyStates(StatesGroup): + waiting_body = State() + + +TICKETS_INTRO = ( + "🎫 Поддержка\n\n" + "Создай тикет — ответим здесь или в Mini App.\n" + f"Одновременно можно держать до {MAX_OPEN_PER_USER} открытых обращений." +) + + +def _admin_tickets_home_text(stats: dict) -> str: + return ( + "🎫 Тикеты поддержки\n\n" + f"Открытых: {stats.get('open', 0)}\n" + f"С ответом: {stats.get('answered', 0)}\n" + f"Закрытых: {stats.get('closed', 0)}\n" + f"Всего: {stats.get('total', 0)}" + ) + + +# ─── User ─────────────────────────────────────────────────────────────────── + + +@router.message(F.text == "🎫 Тикеты") +async def cmd_tickets(message: Message, state: FSMContext, db: Database) -> None: + await state.clear() + uid = message.from_user.id if message.from_user else 0 + open_n = db.count_open_tickets(uid) if uid else 0 + await message.answer( + TICKETS_INTRO, + reply_markup=tickets_root_inline(has_open=open_n > 0), + parse_mode="HTML", + ) + + +@router.callback_query(F.data == "tix:root") +async def cb_tickets_root(callback: CallbackQuery, state: FSMContext, db: Database) -> None: + await state.clear() + await callback.answer() + uid = callback.from_user.id if callback.from_user else 0 + open_n = db.count_open_tickets(uid) if uid else 0 + if callback.message: + await callback.message.edit_text( + TICKETS_INTRO, + reply_markup=tickets_root_inline(has_open=open_n > 0), + parse_mode="HTML", + ) + + +@router.callback_query(F.data.in_({"tix:list", "tix:list:open"})) +async def cb_tickets_list(callback: CallbackQuery, db: Database) -> None: + await callback.answer() + uid = callback.from_user.id if callback.from_user else 0 + only_open = (callback.data or "").endswith(":open") + tickets = db.list_tickets_for_user( + uid, + limit=20, + include_closed=not only_open, + ) + if not tickets: + text = "Открытых тикетов нет." if only_open else "Тикетов пока нет." + if callback.message: + await callback.message.edit_text( + text, + reply_markup=tickets_root_inline(has_open=False), + parse_mode="HTML", + ) + return + title = "🟢 Открытые тикеты" if only_open else "📋 Мои тикеты" + if callback.message: + await callback.message.edit_text( + title + "\n\nВыбери обращение:", + reply_markup=tickets_list_inline(tickets), + parse_mode="HTML", + ) + + +@router.callback_query(F.data.startswith("tix:view:")) +async def cb_ticket_view(callback: CallbackQuery, state: FSMContext, db: Database) -> None: + await state.clear() + try: + tid = int((callback.data or "").rsplit(":", 1)[-1]) + except ValueError: + await callback.answer("Некорректный тикет", show_alert=True) + return + ticket = db.get_ticket(tid) + uid = callback.from_user.id if callback.from_user else 0 + if not ticket or int(ticket["telegram_id"]) != uid: + await callback.answer("Тикет не найден", show_alert=True) + return + await callback.answer() + messages = db.list_ticket_messages(tid) + text = format_ticket_card(ticket, messages) + if callback.message: + await callback.message.edit_text( + text, + reply_markup=ticket_view_inline(tid, status=str(ticket["status"])), + parse_mode="HTML", + ) + + +@router.callback_query(F.data == "tix:new") +async def cb_ticket_new(callback: CallbackQuery, state: FSMContext, db: Database) -> None: + uid = callback.from_user.id if callback.from_user else 0 + if db.count_open_tickets(uid) >= MAX_OPEN_PER_USER: + await callback.answer( + f"Лимит: не больше {MAX_OPEN_PER_USER} открытых тикетов", + show_alert=True, + ) + return + await callback.answer() + await state.set_state(TicketCreateStates.waiting_subject) + if callback.message: + await callback.message.edit_text( + f"Новый тикет\n\nНапиши тему (до {SUBJECT_MAX} символов):", + reply_markup=ticket_cancel_inline(), + parse_mode="HTML", + ) + + +@router.message(TicketCreateStates.waiting_subject) +async def ticket_subject_msg(message: Message, state: FSMContext) -> None: + subject = clamp_subject(message.text or "") + if len(subject) < 3: + await message.answer("Тема слишком короткая. Минимум 3 символа.") + return + await state.update_data(subject=subject) + await state.set_state(TicketCreateStates.waiting_body) + await message.answer( + f"Тема: {html.escape(subject)}\n\n" + f"Опиши проблему (до {BODY_MAX} символов):", + reply_markup=ticket_cancel_inline(), + parse_mode="HTML", + ) + + +@router.message(TicketCreateStates.waiting_body) +async def ticket_body_msg( + message: Message, + state: FSMContext, + db: Database, + settings: Settings, + bot: Bot, +) -> None: + body = clamp_body(message.text or "") + if len(body) < 5: + await message.answer("Сообщение слишком короткое.") + return + data = await state.get_data() + subject = clamp_subject(str(data.get("subject") or "")) + await state.clear() + uid = message.from_user.id if message.from_user else 0 + if not uid or not subject: + await message.answer("Не удалось создать тикет. Попробуй снова.") + return + if db.count_open_tickets(uid) >= MAX_OPEN_PER_USER: + await message.answer(f"Лимит открытых тикетов: {MAX_OPEN_PER_USER}.") + return + db.ensure_user( + uid, + username=message.from_user.username if message.from_user else None, + full_name=message.from_user.full_name if message.from_user else None, + ) + ticket = db.create_ticket(uid, subject=subject, body=body) + tid = int(ticket["id"]) + ai_result = await try_ai_ticket_reply( + db=db, + bot=bot, + settings=settings, + ticket_id=tid, + notify_user=False, + ) + ai_answered = ai_result.get("action") == "answer" and ai_result.get("ok") + # Если ИИ уже ответил пользователю — админам короткое уведомление. + # Если эскалация — ticket_ai уже пинганул. Если skip — обычное уведомление. + if ai_result.get("action") in ("skip", "error") or ai_answered: + if ai_result.get("action") != "escalate": + await notify_admins_new_ticket( + bot, + settings, + ticket_id=tid, + telegram_id=uid, + subject=subject, + body=body, + ai_handled=bool(ai_answered), + ) + ticket = db.get_ticket(tid) or ticket + status = str(ticket.get("status") or "open") + extra = "" + if ai_answered: + extra = "\n🤖 ИИ уже ответил — смотри переписку ниже." + await message.answer( + f"✅ Тикет #{tid} создан.{extra}\n" + "Мы ответим в этом чате или в Mini App.", + reply_markup=ticket_view_inline(tid, status=status), + parse_mode="HTML", + ) + if ai_answered: + messages = db.list_ticket_messages(tid) + await message.answer( + format_ticket_card(ticket, messages), + reply_markup=ticket_view_inline(tid, status=status), + parse_mode="HTML", + ) + + +@router.callback_query(F.data.startswith("tix:reply:")) +async def cb_ticket_reply(callback: CallbackQuery, state: FSMContext, db: Database) -> None: + try: + tid = int((callback.data or "").rsplit(":", 1)[-1]) + except ValueError: + await callback.answer("Ошибка", show_alert=True) + return + ticket = db.get_ticket(tid) + uid = callback.from_user.id if callback.from_user else 0 + if not ticket or int(ticket["telegram_id"]) != uid: + await callback.answer("Тикет не найден", show_alert=True) + return + if str(ticket["status"]) == "closed": + await callback.answer("Тикет закрыт", show_alert=True) + return + await callback.answer() + await state.set_state(TicketReplyStates.waiting_body) + await state.update_data(ticket_id=tid) + if callback.message: + await callback.message.edit_text( + f"Ответ в тикет #{tid}\n\nНапиши сообщение:", + reply_markup=ticket_cancel_inline(back=f"tix:view:{tid}"), + parse_mode="HTML", + ) + + +@router.message(TicketReplyStates.waiting_body) +async def ticket_reply_msg( + message: Message, + state: FSMContext, + db: Database, + settings: Settings, + bot: Bot, +) -> None: + body = clamp_body(message.text or "") + if len(body) < 1: + await message.answer("Пустое сообщение.") + return + data = await state.get_data() + tid = int(data.get("ticket_id") or 0) + await state.clear() + uid = message.from_user.id if message.from_user else 0 + ticket = db.get_ticket(tid) + if not ticket or int(ticket["telegram_id"]) != uid: + await message.answer("Тикет не найден.") + return + try: + ticket = db.add_ticket_message( + tid, + author_role="user", + author_id=uid, + body=body, + ) + except ValueError as exc: + await message.answer(str(exc)) + return + ai_result = await try_ai_ticket_reply( + db=db, + bot=bot, + settings=settings, + ticket_id=tid, + notify_user=False, + ) + if ai_result.get("action") in ("skip", "error"): + await notify_admins_ticket_reply( + bot, + settings, + ticket_id=tid, + telegram_id=uid, + body=body, + ) + ticket = db.get_ticket(tid) or ticket + messages = db.list_ticket_messages(tid) + await message.answer( + format_ticket_card(ticket, messages), + reply_markup=ticket_view_inline(tid, status=str(ticket["status"])), + parse_mode="HTML", + ) + + +@router.callback_query(F.data.startswith("tix:close:")) +async def cb_ticket_close(callback: CallbackQuery, db: Database) -> None: + try: + tid = int((callback.data or "").rsplit(":", 1)[-1]) + except ValueError: + await callback.answer("Ошибка", show_alert=True) + return + ticket = db.get_ticket(tid) + uid = callback.from_user.id if callback.from_user else 0 + if not ticket or int(ticket["telegram_id"]) != uid: + await callback.answer("Тикет не найден", show_alert=True) + return + if str(ticket["status"]) == "closed": + await callback.answer("Уже закрыт") + return + db.set_ticket_status(tid, "closed") + await callback.answer("Закрыто") + ticket = db.get_ticket(tid) + messages = db.list_ticket_messages(tid) + if callback.message and ticket: + await callback.message.edit_text( + format_ticket_card(ticket, messages), + reply_markup=ticket_view_inline(tid, status="closed"), + parse_mode="HTML", + ) + + +# ─── Admin (same router; callbacks prefixed admin:tix / admin:tickets) ─────── + + +@router.callback_query(F.data == "admin:tickets") +@router.callback_query(F.data.startswith("admin:tickets:")) +async def cb_admin_tickets(callback: CallbackQuery, state: FSMContext, db: Database, settings: Settings) -> None: + if not callback.from_user or callback.from_user.id not in settings.admin_ids: + await callback.answer("Нет доступа", show_alert=True) + return + await state.clear() + await callback.answer() + data = callback.data or "admin:tickets" + if data == "admin:tickets": + stats = db.ticket_stats() + if callback.message: + await callback.message.edit_text( + _admin_tickets_home_text(stats), + reply_markup=admin_tickets_root_inline(stats), + parse_mode="HTML", + ) + return + + status = data.rsplit(":", 1)[-1] + if status not in ("open", "answered", "closed", "all"): + status = "open" + tickets, _total = db.list_tickets_admin(status=status, limit=20) + if not tickets: + if callback.message: + await callback.message.edit_text( + "Тикетов в этом фильтре нет.", + reply_markup=admin_tickets_root_inline(db.ticket_stats()), + parse_mode="HTML", + ) + return + title = { + "open": "🟢 Открытые", + "answered": "💬 С ответом", + "closed": "🔒 Закрытые", + "all": "📋 Все тикеты", + }.get(status, "Тикеты") + if callback.message: + await callback.message.edit_text( + f"{title}\n\nВыбери тикет:", + reply_markup=tickets_list_inline( + tickets, + prefix="admin:tix:view", + back="admin:tickets", + ), + parse_mode="HTML", + ) + + +@router.callback_query(F.data.startswith("admin:tix:view:")) +async def cb_admin_ticket_view( + callback: CallbackQuery, + state: FSMContext, + db: Database, + settings: Settings, +) -> None: + if not callback.from_user or callback.from_user.id not in settings.admin_ids: + await callback.answer("Нет доступа", show_alert=True) + return + await state.clear() + try: + tid = int((callback.data or "").rsplit(":", 1)[-1]) + except ValueError: + await callback.answer("Ошибка", show_alert=True) + return + ticket = db.get_ticket(tid) + if not ticket: + await callback.answer("Не найден", show_alert=True) + return + await callback.answer() + messages = db.list_ticket_messages(tid) + if callback.message: + await callback.message.edit_text( + format_admin_ticket_card(ticket, messages), + reply_markup=ticket_view_inline( + tid, + status=str(ticket["status"]), + is_admin=True, + ), + parse_mode="HTML", + ) + + +@router.callback_query(F.data.startswith("admin:tix:reply:")) +async def cb_admin_ticket_reply( + callback: CallbackQuery, + state: FSMContext, + db: Database, + settings: Settings, +) -> None: + if not callback.from_user or callback.from_user.id not in settings.admin_ids: + await callback.answer("Нет доступа", show_alert=True) + return + try: + tid = int((callback.data or "").rsplit(":", 1)[-1]) + except ValueError: + await callback.answer("Ошибка", show_alert=True) + return + ticket = db.get_ticket(tid) + if not ticket: + await callback.answer("Не найден", show_alert=True) + return + if str(ticket["status"]) == "closed": + await callback.answer("Тикет закрыт", show_alert=True) + return + await callback.answer() + await state.set_state(AdminTicketReplyStates.waiting_body) + await state.update_data(ticket_id=tid) + if callback.message: + await callback.message.edit_text( + f"Ответ в тикет #{tid}\n\n" + f"Пользователь: {ticket['telegram_id']}\n" + f"Статус: {status_label(str(ticket['status']))}\n\n" + "Напиши ответ:", + reply_markup=ticket_cancel_inline(back=f"admin:tix:view:{tid}"), + parse_mode="HTML", + ) + + +@router.message(AdminTicketReplyStates.waiting_body) +async def admin_ticket_reply_msg( + message: Message, + state: FSMContext, + db: Database, + settings: Settings, + bot: Bot, +) -> None: + if not message.from_user or message.from_user.id not in settings.admin_ids: + await state.clear() + return + body = clamp_body(message.text or "") + if len(body) < 1: + await message.answer("Пустое сообщение.") + return + data = await state.get_data() + tid = int(data.get("ticket_id") or 0) + await state.clear() + ticket = db.get_ticket(tid) + if not ticket: + await message.answer("Тикет не найден.") + return + try: + ticket = db.add_ticket_message( + tid, + author_role="admin", + author_id=message.from_user.id, + body=body, + ) + except ValueError as exc: + await message.answer(str(exc)) + return + await notify_user_ticket_reply( + bot, + telegram_id=int(ticket["telegram_id"]), + ticket_id=tid, + subject=str(ticket.get("subject") or ""), + body=body, + ) + messages = db.list_ticket_messages(tid) + await message.answer( + format_admin_ticket_card(ticket, messages), + reply_markup=ticket_view_inline( + tid, + status=str(ticket["status"]), + is_admin=True, + ), + parse_mode="HTML", + ) + + +@router.callback_query(F.data.startswith("admin:tix:close:")) +async def cb_admin_ticket_close( + callback: CallbackQuery, + db: Database, + settings: Settings, +) -> None: + if not callback.from_user or callback.from_user.id not in settings.admin_ids: + await callback.answer("Нет доступа", show_alert=True) + return + try: + tid = int((callback.data or "").rsplit(":", 1)[-1]) + except ValueError: + await callback.answer("Ошибка", show_alert=True) + return + ticket = db.get_ticket(tid) + if not ticket: + await callback.answer("Не найден", show_alert=True) + return + db.set_ticket_status(tid, "closed") + await callback.answer("Закрыто") + ticket = db.get_ticket(tid) + messages = db.list_ticket_messages(tid) + if callback.message and ticket: + await callback.message.edit_text( + format_admin_ticket_card(ticket, messages), + reply_markup=ticket_view_inline(tid, status="closed", is_admin=True), + parse_mode="HTML", + ) + + +@router.callback_query(F.data.startswith("admin:tix:reopen:")) +async def cb_admin_ticket_reopen( + callback: CallbackQuery, + db: Database, + settings: Settings, +) -> None: + if not callback.from_user or callback.from_user.id not in settings.admin_ids: + await callback.answer("Нет доступа", show_alert=True) + return + try: + tid = int((callback.data or "").rsplit(":", 1)[-1]) + except ValueError: + await callback.answer("Ошибка", show_alert=True) + return + ticket = db.set_ticket_status(tid, "open") + if not ticket: + await callback.answer("Не найден", show_alert=True) + return + await callback.answer("Открыт снова") + messages = db.list_ticket_messages(tid) + if callback.message: + await callback.message.edit_text( + format_admin_ticket_card(ticket, messages), + reply_markup=ticket_view_inline(tid, status="open", is_admin=True), + parse_mode="HTML", + ) diff --git a/handlers_user.py b/handlers_user.py new file mode 100644 index 0000000..d8566cb --- /dev/null +++ b/handlers_user.py @@ -0,0 +1,245 @@ +"""Пользовательские хендлеры.""" + +from __future__ import annotations + +from aiogram import F, Router +from aiogram.filters import CommandObject, CommandStart +from aiogram.types import CallbackQuery, Message +from remnawave import RemnawaveSDK + +from config import Settings +from keyboards import ( + clients_links_inline, + clients_root_inline, + faq_back_inline, + faq_root_inline, + main_menu, + servers_inline, +) +from services.clients import CLIENTS_INTRO, client_card, get_client +from services.db import AuthError, Database +from services.faq import faq_answer_text, faq_intro, get_faq +from services.nodes_status import fetch_nodes_status, format_nodes_status + +router = Router(name="user") + +WELCOME = ( + "👋 Привет, {name}!\n\n" + "Я бот VPN на Remnawave.\n" + "Открой Mini App или пользуйся кнопками ниже.\n\n" + "Баланс: {balance:.0f} ₽" +) + +HELP = ( + "ℹ️ Помощь\n\n" + "• Mini App — кабинет в Telegram\n" + "• Тарифы — оплата с баланса или криптой\n" + "• Баланс — Crypto Bot / Heleket / Lava / Digiseller / код\n" + "• Моя подписка — ссылка, срок, трафик, продление\n" + "• Скачать клиент — топ приложений\n" + "• Статус серверов — онлайн / оффлайн\n" + "• FAQ — частые вопросы и ответы\n" + "• Тикеты — написать в поддержку\n\n" + "Не нашёл ответ — создай тикет или открой FAQ." +) + + +def _is_admin(settings: Settings, user_id: int | None) -> bool: + return bool(user_id and user_id in settings.admin_ids) + + +def _kb(settings: Settings, is_admin: bool = False): + return main_menu( + is_admin=is_admin, + webapp_url=settings.webapp_url or None, + ) + + +@router.message(CommandStart()) +async def cmd_start( + message: Message, + command: CommandObject, + settings: Settings, + db: Database, +) -> None: + user = message.from_user + name = user.full_name if user else "друг" + telegram_id = user.id if user else 0 + bot_user = db.ensure_user( + telegram_id, + username=user.username if user else None, + full_name=name if user else None, + ) + admin = _is_admin(settings, telegram_id or None) + + args = (command.args or "").strip() + if args.startswith("qrauth_") and telegram_id > 0: + token = args[len("qrauth_") :] + try: + db.approve_login_qr(token, telegram_id) + await message.answer( + "✅ Вход на сайт подтверждён\n\n" + "Можно вернуться в браузер — кабинет откроется автоматически.", + reply_markup=_kb(settings, admin), + parse_mode="HTML", + ) + return + except AuthError as exc: + await message.answer( + f"⚠️ Не удалось подтвердить QR: {exc}\n\n" + + WELCOME.format(name=name, balance=bot_user.balance), + reply_markup=_kb(settings, admin), + parse_mode="HTML", + ) + return + + if args.startswith("ref_") and telegram_id > 0: + code = args[len("ref_") :] + if db.attach_referrer(telegram_id, code): + await message.answer( + "🎁 Реферальная ссылка принята! Бонус начислим после первой покупки тарифа.", + parse_mode="HTML", + ) + db.ensure_referral_code(telegram_id) + + if bot_user.banned: + await message.answer( + "🚫 Аккаунт заблокирован. Напишите в поддержку, если это ошибка.", + parse_mode="HTML", + ) + return + + text = WELCOME.format(name=name, balance=bot_user.balance) + if not settings.webapp_url: + text += ( + "\n\nMini App пока выключен: задай WEBAPP_URL (https) в .env" + ) + await message.answer( + text, + reply_markup=_kb(settings, admin), + parse_mode="HTML", + ) + + +@router.message(F.text == "ℹ️ Помощь") +async def cmd_help(message: Message, settings: Settings) -> None: + admin = _is_admin(settings, message.from_user.id if message.from_user else None) + await message.answer(HELP, reply_markup=_kb(settings, admin), parse_mode="HTML") + + +@router.message(F.text == "❓ FAQ") +async def cmd_faq(message: Message) -> None: + await message.answer( + faq_intro(), + reply_markup=faq_root_inline(), + parse_mode="HTML", + ) + + +@router.callback_query(F.data == "faq:root") +async def cb_faq_root(callback: CallbackQuery) -> None: + await callback.answer() + if callback.message: + await callback.message.edit_text( + faq_intro(), + reply_markup=faq_root_inline(), + parse_mode="HTML", + ) + + +@router.callback_query(F.data.startswith("faq:view:")) +async def cb_faq_view(callback: CallbackQuery) -> None: + item_id = (callback.data or "").split(":", 2)[2] + item = get_faq(item_id) + if not item: + await callback.answer("Вопрос не найден", show_alert=True) + return + await callback.answer() + if callback.message: + await callback.message.edit_text( + faq_answer_text(item), + reply_markup=faq_back_inline(), + parse_mode="HTML", + ) + + +@router.message(F.text == "📥 Скачать клиент") +async def cmd_clients(message: Message) -> None: + await message.answer( + CLIENTS_INTRO, + reply_markup=clients_root_inline(), + parse_mode="HTML", + disable_web_page_preview=True, + ) + + +@router.callback_query(F.data == "clients:root") +async def cb_clients_root(callback: CallbackQuery) -> None: + await callback.answer() + if callback.message: + await callback.message.edit_text( + CLIENTS_INTRO, + reply_markup=clients_root_inline(), + parse_mode="HTML", + disable_web_page_preview=True, + ) + + +@router.callback_query(F.data.startswith("clients:view:")) +async def cb_clients_view(callback: CallbackQuery) -> None: + client_id = (callback.data or "").split(":", 2)[2] + client = get_client(client_id) + if not client: + await callback.answer("Клиент не найден", show_alert=True) + return + await callback.answer() + if callback.message: + await callback.message.edit_text( + client_card(client), + reply_markup=clients_links_inline(client.links), + parse_mode="HTML", + disable_web_page_preview=True, + ) + + +@router.message(F.text == "📡 Статус серверов") +async def cmd_servers(message: Message, sdk: RemnawaveSDK) -> None: + wait = await message.answer("⏳ Проверяю серверы…") + try: + nodes = await fetch_nodes_status(sdk) + text = format_nodes_status(nodes, detailed=False) + await wait.edit_text(text, reply_markup=servers_inline(), parse_mode="HTML") + except Exception as exc: # noqa: BLE001 + await wait.edit_text( + f"Не удалось получить статус серверов.\n{exc}", + parse_mode="HTML", + ) + + +@router.callback_query(F.data == "servers:refresh") +async def cb_servers_refresh(callback: CallbackQuery, sdk: RemnawaveSDK) -> None: + await callback.answer("Обновляю…") + try: + nodes = await fetch_nodes_status(sdk) + text = format_nodes_status(nodes, detailed=False) + if callback.message: + await callback.message.edit_text( + text, reply_markup=servers_inline(), parse_mode="HTML" + ) + except Exception as exc: # noqa: BLE001 + if callback.message: + await callback.message.edit_text( + f"Ошибка обновления.\n{exc}", + parse_mode="HTML", + ) + + +@router.callback_query(F.data == "menu:home") +async def cb_menu_home(callback: CallbackQuery, settings: Settings) -> None: + await callback.answer() + admin = _is_admin(settings, callback.from_user.id if callback.from_user else None) + if callback.message: + await callback.message.answer( + "🏠 Главное меню", + reply_markup=_kb(settings, admin), + ) diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..c3f8254 --- /dev/null +++ b/install.sh @@ -0,0 +1,574 @@ +#!/usr/bin/env bash +# VPN Service Bot — интерактивный установщик (Docker + Postgres + Caddy SSL) +# Запуск на Ubuntu 22.04 / 24.04: +# cd /path/to/project && sudo bash install.sh +# +# Если видишь ошибку вроде «set: pipefail» / «invalid option name» — +# файл сохранён с Windows-переносами (CRLF). Исправь так: +# sed -i 's/\r$//' install.sh && sudo bash install.sh +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +BOLD='\033[1m' +NC='\033[0m' + +log() { echo -e "${GREEN}✓${NC} $*"; } +warn() { echo -e "${YELLOW}!${NC} $*"; } +err() { echo -e "${RED}✗${NC} $*" >&2; } +info() { echo -e "${CYAN}→${NC} $*"; } +title() { + echo + echo -e "${BOLD}════════════════════════════════════════${NC}" + echo -e "${BOLD} $*${NC}" + echo -e "${BOLD}════════════════════════════════════════${NC}" +} + +need_root() { + if [[ "${EUID}" -ne 0 ]]; then + err "Запусти от root: sudo bash install.sh" + exit 1 + fi +} + +ask() { + # ask "Prompt" "default" → sets REPLY + local prompt="$1" + local default="${2:-}" + if [[ -n "$default" ]]; then + read -r -p "$(echo -e "${CYAN}?${NC} ${prompt} [${default}]: ")" REPLY + REPLY="${REPLY:-$default}" + else + read -r -p "$(echo -e "${CYAN}?${NC} ${prompt}: ")" REPLY + fi +} + +ask_secret() { + local prompt="$1" + local default="${2:-}" + if [[ -n "$default" ]]; then + read -r -s -p "$(echo -e "${CYAN}?${NC} ${prompt} [скрыто / Enter = сгенерировать]: ")" REPLY + echo + REPLY="${REPLY:-$default}" + else + read -r -s -p "$(echo -e "${CYAN}?${NC} ${prompt}: ")" REPLY + echo + fi +} + +ask_yn() { + local prompt="$1" + local default="${2:-y}" + local hint="y/n" + [[ "$default" == "y" ]] && hint="Y/n" + [[ "$default" == "n" ]] && hint="y/N" + while true; do + read -r -p "$(echo -e "${CYAN}?${NC} ${prompt} [${hint}]: ")" REPLY + REPLY="${REPLY:-$default}" + case "${REPLY,,}" in + y|yes|д|да) REPLY=y; return 0 ;; + n|no|н|нет) REPLY=n; return 0 ;; + esac + warn "Ответь y или n" + done +} + +rand_pass() { + if command -v openssl >/dev/null 2>&1; then + openssl rand -base64 24 | tr -d '/+=' | head -c 24 + else + head -c 48 /dev/urandom | base64 | tr -d '/+=' | head -c 24 + fi +} + +env_escape() { + # Значение для .env: кавычки если есть спецсимволы + local v="$1" + if [[ -z "$v" ]]; then + echo "" + return + fi + if [[ "$v" =~ [[:space:]\#\"\'\\\$\`] ]]; then + v="${v//\\/\\\\}" + v="${v//\"/\\\"}" + echo "\"$v\"" + else + echo "$v" + fi +} + +check_os() { + if [[ ! -f /etc/os-release ]]; then + warn "Не Linux? Скрипт рассчитан на Ubuntu 22.04/24.04" + return + fi + # shellcheck source=/dev/null + . /etc/os-release + info "ОС: ${PRETTY_NAME:-unknown}" + if [[ "${ID:-}" != "ubuntu" && "${ID:-}" != "debian" ]]; then + warn "Рекомендуется Ubuntu/Debian. Продолжаем на свой страх." + fi +} + +install_docker() { + title "Docker" + if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then + log "Docker уже установлен: $(docker --version)" + log "Compose: $(docker compose version)" + return + fi + + ask_yn "Docker не найден. Установить Docker Engine + Compose?" "y" + if [[ "$REPLY" != "y" ]]; then + err "Без Docker установка невозможна" + exit 1 + fi + + info "Установка Docker…" + apt-get update -y + apt-get install -y ca-certificates curl gnupg + install -m 0755 -d /etc/apt/keyrings + if [[ ! -f /etc/apt/keyrings/docker.asc ]]; then + curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc + chmod a+r /etc/apt/keyrings/docker.asc + fi + + local codename + # shellcheck source=/dev/null + . /etc/os-release + codename="${VERSION_CODENAME:-jammy}" + if [[ "${ID:-}" == "debian" ]]; then + echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian ${codename} stable" \ + > /etc/apt/sources.list.d/docker.list + else + echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu ${codename} stable" \ + > /etc/apt/sources.list.d/docker.list + fi + + apt-get update -y + apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin + systemctl enable --now docker + log "Docker установлен" +} + +check_ports() { + title "Порты 80 / 443 (нужны для Caddy SSL)" + local busy=0 + for port in 80 443; do + if ss -tlnp 2>/dev/null | grep -qE ":${port}\\b"; then + warn "Порт ${port} занят:" + ss -tlnp 2>/dev/null | grep -E ":${port}\\b" || true + busy=1 + else + log "Порт ${port} свободен" + fi + done + if [[ "$busy" -eq 1 ]]; then + ask_yn "Порты заняты. Остановить конфликтующие сервисы (nginx/apache2/caddy)?" "n" + if [[ "$REPLY" == "y" ]]; then + systemctl stop nginx 2>/dev/null || true + systemctl disable nginx 2>/dev/null || true + systemctl stop apache2 2>/dev/null || true + systemctl disable apache2 2>/dev/null || true + systemctl stop caddy 2>/dev/null || true + # чужой docker caddy + docker ps --format '{{.Names}}' 2>/dev/null | while read -r name; do + if docker port "$name" 2>/dev/null | grep -qE ':80|:443'; then + warn "Останавливаю контейнер $name (держит 80/443)" + docker stop "$name" || true + fi + done + else + warn "Caddy может не получить SSL, пока 80/443 заняты" + fi + fi +} + +configure_firewall() { + title "Firewall" + if ! command -v ufw >/dev/null 2>&1; then + ask_yn "UFW не установлен. Поставить и открыть 22/80/443?" "y" + if [[ "$REPLY" == "y" ]]; then + apt-get install -y ufw + else + return + fi + fi + ask_yn "Настроить UFW: разрешить 22, 80, 443?" "y" + if [[ "$REPLY" != "y" ]]; then + return + fi + ufw allow OpenSSH >/dev/null 2>&1 || ufw allow 22/tcp >/dev/null 2>&1 || true + ufw allow 80/tcp >/dev/null 2>&1 || true + ufw allow 443/tcp >/dev/null 2>&1 || true + ufw allow 443/udp >/dev/null 2>&1 || true + echo "y" | ufw enable >/dev/null 2>&1 || true + log "UFW: 22 / 80 / 443 открыты" +} + +collect_config() { + title "Настройки проекта" + + ask "Домен Mini App (A-запись → IP этого сервера)" "bot.example.com" + WEBAPP_DOMAIN="${REPLY,,}" + WEBAPP_DOMAIN="${WEBAPP_DOMAIN#https://}" + WEBAPP_DOMAIN="${WEBAPP_DOMAIN#http://}" + WEBAPP_DOMAIN="${WEBAPP_DOMAIN%%/*}" + WEBAPP_URL="https://${WEBAPP_DOMAIN}" + + ask "Email для Let's Encrypt (Caddy, необязательно)" "" + ACME_EMAIL="$REPLY" + + echo + info "Remnawave панель" + ask "REMNAWAVE_BASE_URL" "https://panel.example.com" + REMNAWAVE_BASE_URL="${REPLY%/}" + + ask_yn "Использовать API Token панели? (иначе логин/пароль)" "y" + if [[ "$REPLY" == "y" ]]; then + ask_secret "REMNAWAVE_TOKEN" + REMNAWAVE_TOKEN="$REPLY" + REMNAWAVE_USERNAME="" + REMNAWAVE_PASSWORD="" + while [[ -z "$REMNAWAVE_TOKEN" ]]; do + warn "Токен обязателен" + ask_secret "REMNAWAVE_TOKEN" + REMNAWAVE_TOKEN="$REPLY" + done + else + REMNAWAVE_TOKEN="" + ask "REMNAWAVE_USERNAME" "admin" + REMNAWAVE_USERNAME="$REPLY" + ask_secret "REMNAWAVE_PASSWORD" + REMNAWAVE_PASSWORD="$REPLY" + fi + + echo + info "Telegram бот (@BotFather)" + ask_secret "BOT_TOKEN" + BOT_TOKEN="$REPLY" + while [[ -z "$BOT_TOKEN" ]]; do + warn "BOT_TOKEN обязателен" + ask_secret "BOT_TOKEN" + BOT_TOKEN="$REPLY" + done + + ask "ADMIN_IDS (Telegram ID через запятую)" "" + ADMIN_IDS="$REPLY" + + echo + info "PostgreSQL (контейнер в Docker Compose)" + ask "POSTGRES_USER" "vpnbot" + POSTGRES_USER="$REPLY" + GEN_PG="$(rand_pass)" + ask_secret "POSTGRES_PASSWORD (Enter = сгенерировать)" "$GEN_PG" + POSTGRES_PASSWORD="$REPLY" + ask "POSTGRES_DB" "vpnbot" + POSTGRES_DB="$REPLY" + + echo + info "Веб-админка https://${WEBAPP_DOMAIN}/admin" + ask "WEB_ADMIN_LOGIN" "admin" + WEB_ADMIN_LOGIN="$REPLY" + GEN_ADM="$(rand_pass)" + ask_secret "WEB_ADMIN_PASSWORD (Enter = сгенерировать)" "$GEN_ADM" + WEB_ADMIN_PASSWORD="$REPLY" + + echo + ask_yn "Включить Telegram Stars для пополнения?" "y" + if [[ "$REPLY" == "y" ]]; then + STARS_ENABLED="true" + ask "Курс ₽ за 1⭐" "1.5" + STARS_RUB_PER_STAR="$REPLY" + else + STARS_ENABLED="false" + STARS_RUB_PER_STAR="1.5" + fi + + ask "Ссылка покупки кода пополнения (TOPUP_CODE_PAY_URL, можно пусто)" "" + TOPUP_CODE_PAY_URL="$REPLY" + + echo + ask_yn "Настроить SMTP сейчас? (можно позже в /admin → SMTP)" "n" + if [[ "$REPLY" == "y" ]]; then + ask "SMTP_HOST" "" + SMTP_HOST="$REPLY" + ask "SMTP_PORT" "587" + SMTP_PORT="$REPLY" + ask "SMTP_USER" "" + SMTP_USER="$REPLY" + ask_secret "SMTP_PASSWORD" + SMTP_PASSWORD="$REPLY" + ask "SMTP_FROM" "" + SMTP_FROM="$REPLY" + ask_yn "SMTP STARTTLS?" "y" + [[ "$REPLY" == "y" ]] && SMTP_TLS="true" || SMTP_TLS="false" + ask_yn "SMTP SSL (порт 465)?" "n" + [[ "$REPLY" == "y" ]] && SMTP_SSL="true" || SMTP_SSL="false" + else + SMTP_HOST="" + SMTP_PORT="587" + SMTP_USER="" + SMTP_PASSWORD="" + SMTP_FROM="" + SMTP_TLS="true" + SMTP_SSL="false" + fi + + echo + warn "Платёжные ключи (Crypto/Lava/Heleket/…) можно заполнить позже в веб-админке." + ask_yn "Продолжить установку?" "y" + if [[ "$REPLY" != "y" ]]; then + err "Отменено" + exit 1 + fi +} + +write_env() { + title "Запись .env" + local env_file="${SCRIPT_DIR}/.env" + if [[ -f "$env_file" ]]; then + local bak="${env_file}.bak.$(date +%Y%m%d-%H%M%S)" + cp -a "$env_file" "$bak" + warn "Старый .env сохранён: $bak" + fi + + cat > "$env_file" <> "$env_file" + fi + + chmod 600 "$env_file" + log ".env записан: $env_file" +} + +ensure_files() { + title "Проверка файлов проекта" + local need=(docker-compose.yml Dockerfile requirements.txt bot.py tariffs.json caddy/Caddyfile) + local missing=0 + for f in "${need[@]}"; do + if [[ ! -e "${SCRIPT_DIR}/${f}" ]]; then + err "Нет файла: $f" + missing=1 + fi + done + if [[ "$missing" -eq 1 ]]; then + err "Запускай install.sh из корня репозитория VPN Service Bot" + exit 1 + fi + log "Файлы проекта на месте" +} + +update_caddyfile_email() { + if [[ -z "${ACME_EMAIL:-}" ]]; then + return + fi + if grep -qE '^\s*email\s+' "${SCRIPT_DIR}/caddy/Caddyfile" 2>/dev/null; then + log "Caddyfile уже содержит email — не трогаем" + return + fi + local caddy_bak="${SCRIPT_DIR}/caddy/Caddyfile.bak.$(date +%Y%m%d-%H%M%S)" + cp -a "${SCRIPT_DIR}/caddy/Caddyfile" "$caddy_bak" + cat > "${SCRIPT_DIR}/caddy/Caddyfile" </dev/null 2>&1; then + ok=1 + break + fi + sleep 2 + done + if [[ "$ok" -eq 1 ]]; then + log "API health OK (внутри контейнера)" + else + warn "Health пока не ответил — смотри: docker compose logs -f bot" + fi +} + +print_done() { + title "Готово" + echo -e "${GREEN}Установка завершена.${NC}" + echo + echo " Кабинет: ${WEBAPP_URL}" + echo " Админка: ${WEBAPP_URL}/admin" + echo " Логин админа: ${WEB_ADMIN_LOGIN}" + echo " Пароль админа: (тот, что задал / сгенерирован)" + echo + echo "Дальше:" + echo " 1) @BotFather → /setdomain → ${WEBAPP_DOMAIN}" + echo " 2) @BotFather → Menu Button → Web App → ${WEBAPP_URL}" + echo " 3) Платежи: ${WEBAPP_URL}/admin → Платежи" + echo " 4) SMTP: ${WEBAPP_URL}/admin → SMTP" + echo " 5) Рост: ${WEBAPP_URL}/admin → Рост (промо, trial, бэкапы)" + echo + echo "Бэкапы Postgres (cron, опционально):" + echo " 0 3 * * * cd ${SCRIPT_DIR} && docker compose exec -T -e PGPASSWORD=\$POSTGRES_PASSWORD db pg_dump -U \$POSTGRES_USER -d \$POSTGRES_DB | gzip > backups/vpnbot-\$(date +\\%F).sql.gz" + echo + echo "Команды:" + echo " cd ${SCRIPT_DIR}" + echo " docker compose ps" + echo " docker compose logs -f bot" + echo " docker compose logs -f caddy" + echo " docker compose up -d --build" + echo +} + +main() { + clear 2>/dev/null || true + echo -e "${BOLD}" + echo " VPN Service Bot — установщик" + echo " Docker · PostgreSQL · Caddy (Let's Encrypt SSL)" + echo -e "${NC}" + + if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + cat <<'HELP' +Использование: + sudo bash install.sh Интерактивная установка + sudo bash install.sh --reinstall Пересобрать и перезапустить без опроса + sudo bash install.sh --env-only Только пересоздать .env (опрос), без Docker up + +Стек: db (Postgres 17) + bot + caddy (80/443, авто-SSL). +HELP + exit 0 + fi + + need_root + check_os + ensure_files + + if [[ "${1:-}" == "--reinstall" ]]; then + if [[ ! -f "${SCRIPT_DIR}/.env" ]]; then + err "Нет .env — сначала полный install.sh" + exit 1 + fi + # shellcheck disable=SC1091 + set -a + # shellcheck source=/dev/null + source "${SCRIPT_DIR}/.env" + set +a + WEBAPP_DOMAIN="${WEBAPP_DOMAIN:-}" + WEBAPP_URL="${WEBAPP_URL:-https://${WEBAPP_DOMAIN}}" + WEB_ADMIN_LOGIN="${WEB_ADMIN_LOGIN:-admin}" + install_docker + start_stack + print_done + exit 0 + fi + + install_docker + check_ports + configure_firewall + collect_config + write_env + update_caddyfile_email + + if [[ "${1:-}" == "--env-only" ]]; then + log "Только .env — контейнеры не трогали" + echo "Запуск: docker compose up -d --build" + exit 0 + fi + + dns_hint + start_stack + print_done +} + +main "$@" diff --git a/keyboards.py b/keyboards.py new file mode 100644 index 0000000..0822e8f --- /dev/null +++ b/keyboards.py @@ -0,0 +1,717 @@ +"""Клавиатуры бота.""" + +from __future__ import annotations + +from aiogram.types import ( + InlineKeyboardButton, + InlineKeyboardMarkup, + KeyboardButton, + ReplyKeyboardMarkup, + WebAppInfo, +) + +from services.tariffs import Tariff + + +def main_menu(is_admin: bool = False, *, webapp_url: str | None = None) -> ReplyKeyboardMarkup: + rows: list[list[KeyboardButton]] = [] + if webapp_url: + rows.append( + [ + KeyboardButton( + text="🚀 Mini App", + web_app=WebAppInfo(url=webapp_url), + ) + ] + ) + rows.extend( + [ + [KeyboardButton(text="🛒 Тарифы"), KeyboardButton(text="💰 Баланс")], + [KeyboardButton(text="📱 Моя подписка"), KeyboardButton(text="📡 Статус серверов")], + [KeyboardButton(text="📥 Скачать клиент"), KeyboardButton(text="❓ FAQ")], + [KeyboardButton(text="🎫 Тикеты"), KeyboardButton(text="ℹ️ Помощь")], + ] + ) + if is_admin: + rows.append([KeyboardButton(text="🛠 Админка")]) + return ReplyKeyboardMarkup( + keyboard=rows, + resize_keyboard=True, + input_field_placeholder="Выберите действие…", + ) + + +def faq_root_inline() -> InlineKeyboardMarkup: + from services.faq import FAQ_ITEMS + + rows: list[list[InlineKeyboardButton]] = [] + for item in FAQ_ITEMS: + rows.append( + [ + InlineKeyboardButton( + text=item.question, + callback_data=f"faq:view:{item.id}", + ) + ] + ) + rows.append([InlineKeyboardButton(text="🏠 Меню", callback_data="menu:home")]) + return InlineKeyboardMarkup(inline_keyboard=rows) + + +def faq_back_inline() -> InlineKeyboardMarkup: + return InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text="⬅️ Все вопросы", callback_data="faq:root")], + [InlineKeyboardButton(text="🏠 Меню", callback_data="menu:home")], + ] + ) + + +def clients_root_inline() -> InlineKeyboardMarkup: + from services.clients import TOP_CLIENTS + + rows: list[list[InlineKeyboardButton]] = [] + row: list[InlineKeyboardButton] = [] + for client in TOP_CLIENTS: + prefix = "⭐ " if client.featured else "" + row.append( + InlineKeyboardButton( + text=f"{prefix}{client.name}", + callback_data=f"clients:view:{client.id}", + ) + ) + if len(row) == 2: + rows.append(row) + row = [] + if row: + rows.append(row) + rows.append( + [InlineKeyboardButton(text="📚 Все клиенты (docs.rw)", url="https://docs.rw/clients")] + ) + rows.append([InlineKeyboardButton(text="🏠 Меню", callback_data="menu:home")]) + return InlineKeyboardMarkup(inline_keyboard=rows) + + +def clients_links_inline(links: tuple, *, back: str = "clients:root") -> InlineKeyboardMarkup: + rows: list[list[InlineKeyboardButton]] = [] + for link in links: + rows.append([InlineKeyboardButton(text=f"⬇️ {link.title}", url=link.url)]) + rows.append([InlineKeyboardButton(text="⬅️ Назад", callback_data=back)]) + return InlineKeyboardMarkup(inline_keyboard=rows) + + +def servers_inline() -> InlineKeyboardMarkup: + return InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text="🔄 Обновить", callback_data="servers:refresh")], + [InlineKeyboardButton(text="🏠 Меню", callback_data="menu:home")], + ] + ) + + +def balance_inline( + *, + crypto_enabled: bool = False, + heleket_enabled: bool = False, + digiseller_enabled: bool = False, + lava_enabled: bool = False, + anypay_enabled: bool = False, + stars_enabled: bool = False, +) -> InlineKeyboardMarkup: + rows: list[list[InlineKeyboardButton]] = [] + if stars_enabled: + rows.append( + [ + InlineKeyboardButton( + text="⭐ Telegram Stars", + callback_data="pay:stars", + ) + ] + ) + if crypto_enabled or heleket_enabled: + rows.append( + [InlineKeyboardButton(text="🪙 Пополнить криптой", callback_data="pay:topup")] + ) + if lava_enabled: + rows.append( + [ + InlineKeyboardButton( + text="💳 Lava (карта / СБП)", + callback_data="pay:lava", + ) + ] + ) + if anypay_enabled: + rows.append( + [ + InlineKeyboardButton( + text="💳 AnyPay", + callback_data="pay:anypay", + ) + ] + ) + if digiseller_enabled: + rows.append( + [ + InlineKeyboardButton( + text="💳 Digiseller", + callback_data="pay:digiseller", + ) + ] + ) + rows.append( + [ + InlineKeyboardButton( + text="🎫 Код пополнения", + callback_data="pay:topup_code", + ) + ] + ) + rows.append([InlineKeyboardButton(text="🛒 Купить тариф", callback_data="shop:list")]) + rows.append([InlineKeyboardButton(text="🏠 Меню", callback_data="menu:home")]) + return InlineKeyboardMarkup(inline_keyboard=rows) + + +def digiseller_inline(*, pay_url: str) -> InlineKeyboardMarkup: + rows: list[list[InlineKeyboardButton]] = [] + if pay_url: + rows.append([InlineKeyboardButton(text="💳 Оплатить на Digiseller", url=pay_url)]) + rows.append( + [ + InlineKeyboardButton( + text="🔄 Не зачислилось? Ввести код", + callback_data="pay:digiseller_code", + ) + ] + ) + rows.append([InlineKeyboardButton(text="⬅️ Назад", callback_data="pay:balance")]) + return InlineKeyboardMarkup(inline_keyboard=rows) + + +def digiseller_cancel_inline() -> InlineKeyboardMarkup: + return InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text="❌ Отмена", callback_data="pay:digiseller_cancel")] + ] + ) + + +def topup_code_inline(*, pay_url: str = "") -> InlineKeyboardMarkup: + rows: list[list[InlineKeyboardButton]] = [] + if pay_url: + rows.append([InlineKeyboardButton(text="🛒 Купить код", url=pay_url)]) + rows.append( + [InlineKeyboardButton(text="❌ Отмена", callback_data="pay:topup_code_cancel")] + ) + return InlineKeyboardMarkup(inline_keyboard=rows) + + +def topup_providers_inline( + *, + crypto_enabled: bool, + heleket_enabled: bool, +) -> InlineKeyboardMarkup: + rows: list[list[InlineKeyboardButton]] = [] + if crypto_enabled: + rows.append( + [ + InlineKeyboardButton( + text="Crypto Bot", + callback_data="pay:topup_provider:cryptobot", + ) + ] + ) + if heleket_enabled: + rows.append( + [ + InlineKeyboardButton( + text="Heleket (резерв)", + callback_data="pay:topup_provider:heleket", + ) + ] + ) + rows.append([InlineKeyboardButton(text="⬅️ Назад", callback_data="pay:balance")]) + return InlineKeyboardMarkup(inline_keyboard=rows) + + +def topup_amounts_inline(provider: str = "cryptobot") -> InlineKeyboardMarkup: + amounts = [50, 100, 200, 500, 1000, 2000, 3000] + rows: list[list[InlineKeyboardButton]] = [] + row: list[InlineKeyboardButton] = [] + for amount in amounts: + row.append( + InlineKeyboardButton( + text=f"{amount} ₽", + callback_data=f"pay:topup_amount:{provider}:{amount}", + ) + ) + if len(row) == 3: + rows.append(row) + row = [] + if row: + rows.append(row) + back = "pay:stars" if provider == "stars" else "pay:topup" + rows.append([InlineKeyboardButton(text="⬅️ Назад", callback_data=back)]) + return InlineKeyboardMarkup(inline_keyboard=rows) + + +def stars_amounts_inline(*, rub_per_star: float) -> InlineKeyboardMarkup: + from services.stars import STAR_TOPUP_AMOUNTS_RUB, quote_topup + + rows: list[list[InlineKeyboardButton]] = [] + for amount in STAR_TOPUP_AMOUNTS_RUB: + q = quote_topup(amount, rub_per_star=rub_per_star) + rows.append( + [ + InlineKeyboardButton( + text=f"{q.rub_label} · {q.stars_label}", + callback_data=f"pay:stars_amount:{amount}", + ) + ] + ) + rows.append([InlineKeyboardButton(text="⬅️ Назад", callback_data="pay:balance")]) + return InlineKeyboardMarkup(inline_keyboard=rows) + + +def tariffs_inline(tariffs: list[Tariff]) -> InlineKeyboardMarkup: + rows: list[list[InlineKeyboardButton]] = [] + for t in tariffs: + rows.append( + [ + InlineKeyboardButton( + text=f"{t.title} · {t.format_price()}", + callback_data=f"shop:view:{t.id}", + ) + ] + ) + rows.append([InlineKeyboardButton(text="🏠 Меню", callback_data="menu:home")]) + return InlineKeyboardMarkup(inline_keyboard=rows) + + +def renew_tariffs_inline(tariffs: list[Tariff]) -> InlineKeyboardMarkup: + rows: list[list[InlineKeyboardButton]] = [] + for t in tariffs: + rows.append( + [ + InlineKeyboardButton( + text=f"➕ {t.title} · {t.format_price()}", + callback_data=f"renew:view:{t.id}", + ) + ] + ) + rows.append([InlineKeyboardButton(text="⬅️ К подписке", callback_data="sub:mine")]) + return InlineKeyboardMarkup(inline_keyboard=rows) + + +def renew_confirm_inline(tariff_id: str) -> InlineKeyboardMarkup: + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text="✅ Продлить с баланса", + callback_data=f"renew:buy:{tariff_id}", + ) + ], + [InlineKeyboardButton(text="⬅️ Назад", callback_data="renew:list")], + ] + ) + + +def subscription_inline( + *, + has_subscription: bool, + devices_over: bool = False, +) -> InlineKeyboardMarkup: + rows: list[list[InlineKeyboardButton]] = [] + if has_subscription: + if devices_over: + rows.append( + [ + InlineKeyboardButton( + text="⚠️ Оплатить лишние устройства", + callback_data="devices:list", + ) + ] + ) + rows.append( + [ + InlineKeyboardButton( + text="🔄 Продлить с баланса", + callback_data="renew:list", + ) + ] + ) + rows.append( + [ + InlineKeyboardButton( + text="📦 Докупить трафик", + callback_data="traffic:list", + ) + ] + ) + rows.append( + [ + InlineKeyboardButton( + text="📱 Доп. устройства", + callback_data="devices:list", + ) + ] + ) + rows.append([InlineKeyboardButton(text="🛒 Тарифы", callback_data="shop:list")]) + rows.append([InlineKeyboardButton(text="🏠 Меню", callback_data="menu:home")]) + return InlineKeyboardMarkup(inline_keyboard=rows) + + +def device_packs_inline(*, excess: int = 0) -> InlineKeyboardMarkup: + from services.devices import DEVICE_PACKS, EXTRA_DEVICE_PRICE_RUB + + rows: list[list[InlineKeyboardButton]] = [] + if excess > 0: + price = int(excess * EXTRA_DEVICE_PRICE_RUB) + rows.append( + [ + InlineKeyboardButton( + text=f"⚠️ Оплатить превышение +{excess} · {price} ₽", + callback_data=f"devices:confirm:{excess}", + ) + ] + ) + for pack in DEVICE_PACKS: + rows.append( + [ + InlineKeyboardButton( + text=f"{pack.title} · {pack.format_price()}", + callback_data=f"devices:buy:{pack.slots}", + ) + ] + ) + rows.append([InlineKeyboardButton(text="⬅️ К подписке", callback_data="sub:mine")]) + return InlineKeyboardMarkup(inline_keyboard=rows) + + +def device_confirm_inline(slots: int) -> InlineKeyboardMarkup: + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text="✅ Купить с баланса", + callback_data=f"devices:confirm:{slots}", + ) + ], + [InlineKeyboardButton(text="⬅️ Назад", callback_data="devices:list")], + ] + ) + + +def traffic_packs_inline() -> InlineKeyboardMarkup: + from services.traffic import TRAFFIC_PACKS + + rows: list[list[InlineKeyboardButton]] = [] + for pack in TRAFFIC_PACKS: + rows.append( + [ + InlineKeyboardButton( + text=f"{pack.title} · {pack.format_price()}", + callback_data=f"traffic:buy:{pack.packs}", + ) + ] + ) + rows.append([InlineKeyboardButton(text="⬅️ К подписке", callback_data="sub:mine")]) + return InlineKeyboardMarkup(inline_keyboard=rows) + + +def traffic_confirm_inline(packs: int) -> InlineKeyboardMarkup: + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text="✅ Купить с баланса", + callback_data=f"traffic:confirm:{packs}", + ) + ], + [InlineKeyboardButton(text="⬅️ Назад", callback_data="traffic:list")], + ] + ) + + +def tariff_pay_methods_inline( + tariff_id: str, + *, + crypto_enabled: bool, + heleket_enabled: bool = False, + lava_enabled: bool = False, + anypay_enabled: bool = False, +) -> InlineKeyboardMarkup: + rows: list[list[InlineKeyboardButton]] = [ + [ + InlineKeyboardButton( + text="💰 С баланса", + callback_data=f"shop:buy_balance:{tariff_id}", + ) + ] + ] + if crypto_enabled: + rows.append( + [ + InlineKeyboardButton( + text="🪙 Crypto Bot", + callback_data=f"shop:buy_crypto:{tariff_id}", + ) + ] + ) + if heleket_enabled: + rows.append( + [ + InlineKeyboardButton( + text="🪙 Heleket", + callback_data=f"shop:buy_heleket:{tariff_id}", + ) + ] + ) + if lava_enabled: + rows.append( + [ + InlineKeyboardButton( + text="💳 Lava (карта / СБП)", + callback_data=f"shop:buy_lava:{tariff_id}", + ) + ] + ) + if anypay_enabled: + rows.append( + [ + InlineKeyboardButton( + text="💳 AnyPay", + callback_data=f"shop:buy_anypay:{tariff_id}", + ) + ] + ) + rows.append([InlineKeyboardButton(text="⬅️ Назад", callback_data="shop:list")]) + return InlineKeyboardMarkup(inline_keyboard=rows) + + +def invoice_inline( + invoice_id: int, + pay_url: str, + *, + provider: str = "cryptobot", +) -> InlineKeyboardMarkup: + if provider == "anypay": + return InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text="💳 Оплатить AnyPay", url=pay_url)], + [InlineKeyboardButton(text="🏠 Меню", callback_data="menu:home")], + ] + ) + if provider == "heleket": + check_cb = f"pay:check_hk:{invoice_id}" + elif provider == "lava": + check_cb = f"pay:check_lava:{invoice_id}" + else: + check_cb = f"pay:check:{invoice_id}" + return InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text="🪙 Оплатить", url=pay_url)], + [ + InlineKeyboardButton( + text="✅ Я оплатил — проверить", + callback_data=check_cb, + ) + ], + [InlineKeyboardButton(text="🏠 Меню", callback_data="menu:home")], + ] + ) + + +def admin_inline() -> InlineKeyboardMarkup: + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton(text="📡 Серверы", callback_data="admin:servers"), + InlineKeyboardButton(text="💚 Health API", callback_data="admin:health"), + ], + [ + InlineKeyboardButton(text="👥 Пользователи", callback_data="admin:users"), + InlineKeyboardButton(text="📊 Статистика", callback_data="admin:stats"), + ], + [ + InlineKeyboardButton( + text="🎫 Тикеты", + callback_data="admin:tickets", + ) + ], + [ + InlineKeyboardButton( + text="💰 Пополнить пользователю", + callback_data="admin:topup", + ) + ], + [ + InlineKeyboardButton(text="🔄 Обновить панель", callback_data="admin:home"), + InlineKeyboardButton(text="🏠 В меню", callback_data="menu:home"), + ], + ] + ) + + +def tickets_root_inline(*, has_open: bool = False) -> InlineKeyboardMarkup: + rows: list[list[InlineKeyboardButton]] = [ + [InlineKeyboardButton(text="✍️ Новый тикет", callback_data="tix:new")], + [InlineKeyboardButton(text="📋 Мои тикеты", callback_data="tix:list")], + ] + if has_open: + rows.insert( + 1, + [InlineKeyboardButton(text="🟢 Открытые", callback_data="tix:list:open")], + ) + rows.append([InlineKeyboardButton(text="🏠 Меню", callback_data="menu:home")]) + return InlineKeyboardMarkup(inline_keyboard=rows) + + +def tickets_list_inline( + tickets: list, + *, + prefix: str = "tix:view", + back: str = "tix:root", +) -> InlineKeyboardMarkup: + from services.tickets import status_label + + rows: list[list[InlineKeyboardButton]] = [] + for t in tickets[:20]: + tid = int(t["id"]) + st = status_label(str(t.get("status") or ""), short=True) + subj = str(t.get("subject") or "")[:28] + rows.append( + [ + InlineKeyboardButton( + text=f"#{tid} · {st} · {subj}", + callback_data=f"{prefix}:{tid}", + ) + ] + ) + rows.append([InlineKeyboardButton(text="⬅️ Назад", callback_data=back)]) + return InlineKeyboardMarkup(inline_keyboard=rows) + + +def ticket_view_inline(ticket_id: int, *, status: str, is_admin: bool = False) -> InlineKeyboardMarkup: + rows: list[list[InlineKeyboardButton]] = [] + if status != "closed": + reply_cb = f"admin:tix:reply:{ticket_id}" if is_admin else f"tix:reply:{ticket_id}" + close_cb = f"admin:tix:close:{ticket_id}" if is_admin else f"tix:close:{ticket_id}" + rows.append([InlineKeyboardButton(text="💬 Ответить", callback_data=reply_cb)]) + rows.append([InlineKeyboardButton(text="🔒 Закрыть", callback_data=close_cb)]) + elif is_admin: + rows.append( + [ + InlineKeyboardButton( + text="🔓 Открыть снова", + callback_data=f"admin:tix:reopen:{ticket_id}", + ) + ] + ) + back = "admin:tickets" if is_admin else "tix:list" + rows.append([InlineKeyboardButton(text="⬅️ К списку", callback_data=back)]) + return InlineKeyboardMarkup(inline_keyboard=rows) + + +def ticket_cancel_inline(*, back: str = "tix:root") -> InlineKeyboardMarkup: + return InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text="❌ Отмена", callback_data=back)], + ] + ) + + +def ticket_notify_inline(ticket_id: int) -> InlineKeyboardMarkup: + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text="Открыть тикет", + callback_data=f"tix:view:{ticket_id}", + ) + ] + ] + ) + + +def admin_ticket_notify_inline(ticket_id: int) -> InlineKeyboardMarkup: + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text="Открыть тикет", + callback_data=f"admin:tix:view:{ticket_id}", + ) + ] + ] + ) + + +def admin_tickets_root_inline(stats: dict | None = None) -> InlineKeyboardMarkup: + open_n = int((stats or {}).get("open") or 0) + ans_n = int((stats or {}).get("answered") or 0) + rows = [ + [ + InlineKeyboardButton( + text=f"🟢 Открытые ({open_n})", + callback_data="admin:tickets:open", + ) + ], + [ + InlineKeyboardButton( + text=f"💬 С ответом ({ans_n})", + callback_data="admin:tickets:answered", + ) + ], + [InlineKeyboardButton(text="📋 Все", callback_data="admin:tickets:all")], + [InlineKeyboardButton(text="⬅️ Админка", callback_data="admin:home")], + ] + return InlineKeyboardMarkup(inline_keyboard=rows) + + +def admin_back_inline() -> InlineKeyboardMarkup: + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton(text="🔄 Обновить", callback_data="admin:servers"), + InlineKeyboardButton(text="⬅️ Назад", callback_data="admin:home"), + ] + ] + ) + + +def admin_topup_cancel() -> InlineKeyboardMarkup: + return InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text="❌ Отмена", callback_data="admin:topup_cancel")] + ] + ) + + +def admin_topup_amounts(telegram_id: int) -> InlineKeyboardMarkup: + amounts = [100, 200, 500, 1000, 2000, 5000] + rows: list[list[InlineKeyboardButton]] = [] + row: list[InlineKeyboardButton] = [] + for amount in amounts: + row.append( + InlineKeyboardButton( + text=f"+{amount} ₽", + callback_data=f"admin:topup_do:{telegram_id}:{amount}", + ) + ) + if len(row) == 3: + rows.append(row) + row = [] + if row: + rows.append(row) + rows.append( + [ + InlineKeyboardButton( + text="✏️ Своя сумма", + callback_data=f"admin:topup_custom:{telegram_id}", + ) + ] + ) + rows.append([InlineKeyboardButton(text="❌ Отмена", callback_data="admin:topup_cancel")]) + return InlineKeyboardMarkup(inline_keyboard=rows) diff --git a/remnawave_client.py b/remnawave_client.py new file mode 100644 index 0000000..acaa5f5 --- /dev/null +++ b/remnawave_client.py @@ -0,0 +1,99 @@ +"""Thin wrapper around RemnawaveSDK.""" + +from __future__ import annotations + +import logging + +from remnawave import RemnawaveSDK +from remnawave.models import LoginRequestDto + +from config import Settings + +logger = logging.getLogger(__name__) + + +def create_sdk(settings: Settings, *, token: str | None = None) -> RemnawaveSDK: + kwargs: dict = { + "base_url": settings.remnawave_base_url, + "token": token if token is not None else settings.remnawave_token, + } + if settings.caddy_token: + kwargs["caddy_token"] = settings.caddy_token + return RemnawaveSDK(**kwargs) + + +async def create_sdk_ready(settings: Settings) -> RemnawaveSDK: + """ + Собирает SDK. + Если заданы REMNAWAVE_USERNAME/PASSWORD — логинится и берёт admin JWT + (нужно для create/update users, если API-токен только на чтение). + """ + if settings.remnawave_username and settings.remnawave_password: + bootstrap = create_sdk(settings, token=settings.remnawave_token or "bootstrap") + try: + login = await bootstrap.auth.login( + LoginRequestDto( + username=settings.remnawave_username, + password=settings.remnawave_password, + ) + ) + logger.info("Remnawave: вошли как %s (admin JWT)", settings.remnawave_username) + return create_sdk(settings, token=login.access_token) + except Exception as exc: # noqa: BLE001 + logger.error( + "Не удалось войти в Remnawave как %s: %s. Используем API-токен.", + settings.remnawave_username, + exc, + ) + + return create_sdk(settings) + + +async def probe_users_write_access(sdk: RemnawaveSDK) -> tuple[bool, str]: + """Проверка, что токен может создавать пользователей.""" + from datetime import datetime, timedelta, timezone + from remnawave.enums import UserStatus + from remnawave.models import CreateUserRequestDto + + username = f"rwprobe_{int(datetime.now(timezone.utc).timestamp()) % 10_000_000}" + expire = datetime.now(timezone.utc) + timedelta(hours=1) + try: + created = await sdk.users.create_user( + CreateUserRequestDto( + username=username, + expire_at=expire, + status=UserStatus.ACTIVE, + description="write-access probe (auto-delete)", + ) + ) + try: + await sdk.users.delete_user(str(created.uuid)) + except Exception: # noqa: BLE001 + logger.warning("probe user created but not deleted: %s", created.uuid) + return True, "OK: users:write доступен" + except Exception as exc: # noqa: BLE001 + msg = str(exc) + if "403" in msg or "Forbidden" in msg: + return False, ( + "API-токен не может создавать пользователей (HTTP 403).\n" + "В панели Remnawave создай новый API Token со scope " + "users:* или *,\n" + "либо укажи REMNAWAVE_USERNAME / REMNAWAVE_PASSWORD админа в .env." + ) + return False, f"Проверка записи не удалась: {exc}" + + +def friendly_remnawave_error(exc: BaseException) -> str: + text = str(exc) + if "403" in text or "Forbidden" in text: + return ( + "Remnawave запретил операцию (HTTP 403).\n\n" + "Скорее всего у API Token нет прав на запись пользователей.\n" + "Что сделать:\n" + "1) Панель → API Tokens → создай токен со scope users:* или *\n" + "2) Вставь новый токен в REMNAWAVE_TOKEN\n" + "либо задай логин/пароль админа:\n" + "REMNAWAVE_USERNAME / REMNAWAVE_PASSWORD\n\n" + f"{exc}" + ) + return f"{exc}" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..666a971 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +PyJWT[crypto]>=2.8.0 +remnawave>=2.8.0 +python-dotenv>=1.0.0 +httpx>=0.27.0 +aiogram>=3.15.0 +aiohttp>=3.10.0 +psycopg[binary,pool]>=3.2.0 +qrcode>=7.4 +webauthn>=2.2.0 diff --git a/services/__init__.py b/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/admin_alerts.py b/services/admin_alerts.py new file mode 100644 index 0000000..6e9976b --- /dev/null +++ b/services/admin_alerts.py @@ -0,0 +1,37 @@ +"""Алерты админам в Telegram (health / ошибки оплат).""" + +from __future__ import annotations + +import logging +import time +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from aiogram import Bot + from config import Settings + +logger = logging.getLogger(__name__) + +_last_sent: dict[str, float] = {} + + +async def alert_admins( + bot: Bot | None, + settings: Settings, + text: str, + *, + key: str = "generic", + cooldown_sec: float = 300.0, +) -> None: + if bot is None or not settings.admin_ids: + return + now = time.monotonic() + last = _last_sent.get(key, 0.0) + if now - last < cooldown_sec: + return + _last_sent[key] = now + for admin_id in settings.admin_ids: + try: + await bot.send_message(admin_id, text, parse_mode="HTML") + except Exception: # noqa: BLE001 + logger.warning("admin alert failed id=%s", admin_id) diff --git a/services/anypay.py b/services/anypay.py new file mode 100644 index 0000000..0782c75 --- /dev/null +++ b/services/anypay.py @@ -0,0 +1,289 @@ +"""Клиент AnyPay (https://anypay.io) — SCI + API create-payment.""" + +from __future__ import annotations + +import hashlib +import hmac +import logging +from dataclasses import dataclass +from typing import Any +from urllib.parse import urlencode + +import httpx + +logger = logging.getLogger(__name__) + +API_BASE = "https://anypay.io/api" +MERCHANT_URL = "https://anypay.io/merchant" +PAID_STATUSES = frozenset({"paid", "success"}) +ANYPAY_IPS = frozenset({"185.162.128.38", "185.162.128.39", "185.162.128.88"}) + + +class AnypayError(RuntimeError): + pass + + +@dataclass(frozen=True, slots=True) +class AnypayInvoice: + pay_id: str + pay_url: str + transaction_id: str | None = None + status: str = "waiting" + amount: float = 0.0 + + +def _fmt_amount(amount: float) -> str: + return f"{float(amount):.2f}" + + +def sign_create_payment( + *, + api_id: str, + project_id: str, + pay_id: str, + amount: str, + currency: str, + desc: str, + method: str, + api_key: str, +) -> str: + raw = ( + f"create-payment{api_id}{project_id}{pay_id}{amount}" + f"{currency}{desc}{method}{api_key}" + ) + return hashlib.sha256(raw.encode("utf-8")).hexdigest() + + +def sign_sci( + *, + project_id: str, + pay_id: str, + amount: str, + currency: str, + desc: str, + success_url: str, + fail_url: str, + api_key: str, +) -> str: + raw = ":".join( + [ + str(project_id), + str(pay_id), + amount, + currency, + desc, + success_url or "", + fail_url or "", + api_key, + ] + ) + return hashlib.sha256(raw.encode("utf-8")).hexdigest() + + +def verify_notification_sign( + data: dict[str, Any], + *, + project_id: str, + api_key: str, +) -> bool: + """Проверка подписи IPN (SHA256 или MD5).""" + received = str(data.get("sign") or "").strip() + if not received or not api_key: + return False + + currency = str(data.get("currency") or "") + amount = str(data.get("amount") or "") + pay_id = str(data.get("pay_id") or "") + merchant_id = str(data.get("merchant_id") or project_id) + status = str(data.get("status") or "paid") + + # SHA256: currency:amount:pay_id:merchant_id:status:secret + sha_raw = ":".join([currency, amount, pay_id, str(merchant_id), status, api_key]) + sha_expected = hashlib.sha256(sha_raw.encode("utf-8")).hexdigest() + if hmac.compare_digest(received.lower(), sha_expected.lower()): + return True + + if status.lower() != "paid": + sha_paid = ":".join( + [currency, amount, pay_id, str(merchant_id), "paid", api_key] + ) + sha_paid_expected = hashlib.sha256(sha_paid.encode("utf-8")).hexdigest() + if hmac.compare_digest(received.lower(), sha_paid_expected.lower()): + return True + + # MD5: merchant_id:amount:pay_id:secret + md5_raw = f"{merchant_id}:{amount}:{pay_id}:{api_key}" + md5_expected = hashlib.md5(md5_raw.encode("utf-8")).hexdigest() + return hmac.compare_digest(received.lower(), md5_expected.lower()) + + +def client_ip_allowed(remote: str | None, *, forwarded: str | None = None) -> bool: + candidates: list[str] = [] + if remote: + candidates.append(remote.strip()) + if forwarded: + first = forwarded.split(",")[0].strip() + if first: + candidates.append(first) + return any(ip in ANYPAY_IPS for ip in candidates) + + +class Anypay: + def __init__( + self, + api_id: str, + api_key: str, + project_id: str, + *, + currency: str = "RUB", + method: str = "", + success_url: str = "", + fail_url: str = "", + default_email: str = "pay@anypay.local", + check_ip: bool = False, + ) -> None: + self.api_id = str(api_id or "").strip() + self.api_key = str(api_key or "").strip() + self.project_id = str(project_id or "").strip() + self.currency = (currency or "RUB").strip().upper() or "RUB" + self.method = (method or "").strip().lower() + self.success_url = (success_url or "").strip() + self.fail_url = (fail_url or "").strip() + self.default_email = (default_email or "pay@anypay.local").strip() + self.check_ip = bool(check_ip) + + @property + def enabled(self) -> bool: + return bool(self.api_id and self.api_key and self.project_id) + + def build_sci_pay_url( + self, + *, + pay_id: str, + amount_rub: float, + desc: str, + email: str = "", + ) -> str: + if not self.enabled: + raise AnypayError("AnyPay не настроен") + amount = _fmt_amount(amount_rub) + currency = self.currency + description = (desc or "Topup")[:150] + sign = sign_sci( + project_id=self.project_id, + pay_id=str(pay_id), + amount=amount, + currency=currency, + desc=description, + success_url=self.success_url, + fail_url=self.fail_url, + api_key=self.api_key, + ) + params: dict[str, str] = { + "merchant_id": self.project_id, + "pay_id": str(pay_id), + "amount": amount, + "currency": currency, + "desc": description, + "email": (email or self.default_email)[:120], + "sign": sign, + "lang": "ru", + } + if self.success_url: + params["success_url"] = self.success_url + if self.fail_url: + params["fail_url"] = self.fail_url + if self.method: + params["method"] = self.method + return f"{MERCHANT_URL}?{urlencode(params)}" + + async def create_invoice( + self, + *, + pay_id: str, + amount_rub: float, + desc: str, + email: str = "", + ) -> AnypayInvoice: + """Создать счёт: API (если задан method) или SCI-ссылка.""" + if not self.enabled: + raise AnypayError("AnyPay не настроен (ANYPAY_API_ID / KEY / PROJECT_ID)") + + pay_id_s = "".join(ch for ch in str(pay_id) if ch.isdigit()) + if not pay_id_s or len(pay_id_s) > 15: + raise AnypayError("pay_id: только цифры, до 15 символов") + + amount = _fmt_amount(amount_rub) + description = (desc or "Topup")[:150] + mail = (email or self.default_email)[:120] + + # Без method — SCI (покупатель выбирает способ на странице AnyPay) + if not self.method: + url = self.build_sci_pay_url( + pay_id=pay_id_s, + amount_rub=amount_rub, + desc=description, + email=mail, + ) + return AnypayInvoice( + pay_id=pay_id_s, + pay_url=url, + amount=float(amount), + status="waiting", + ) + + sign = sign_create_payment( + api_id=self.api_id, + project_id=self.project_id, + pay_id=pay_id_s, + amount=amount, + currency=self.currency, + desc=description, + method=self.method, + api_key=self.api_key, + ) + params: dict[str, Any] = { + "project_id": self.project_id, + "pay_id": pay_id_s, + "amount": amount, + "currency": self.currency, + "desc": description, + "email": mail, + "method": self.method, + "sign": sign, + "lang": "ru", + } + if self.success_url: + params["success_url"] = self.success_url + if self.fail_url: + params["fail_url"] = self.fail_url + + url = f"{API_BASE}/create-payment/{self.api_id}" + async with httpx.AsyncClient(timeout=30.0) as client: + resp = await client.get( + url, params=params, headers={"Accept": "application/json"} + ) + try: + data = resp.json() + except Exception as exc: # noqa: BLE001 + raise AnypayError(f"AnyPay bad JSON: {resp.text[:200]}") from exc + + if not isinstance(data, dict): + raise AnypayError(f"AnyPay unexpected response: {data!r}") + if data.get("error"): + raise AnypayError(str(data.get("error") or data)) + result = data.get("result") + if not isinstance(result, dict): + raise AnypayError(f"AnyPay: нет result ({data})") + + pay_url = str(result.get("payment_url") or "").strip() + if not pay_url: + raise AnypayError("AnyPay: пустой payment_url") + + return AnypayInvoice( + pay_id=str(result.get("pay_id") or pay_id_s), + pay_url=pay_url, + transaction_id=str(result.get("transaction_id") or "") or None, + status=str(result.get("status") or "waiting"), + amount=float(amount), + ) diff --git a/services/awg_client.py b/services/awg_client.py new file mode 100644 index 0000000..655e25f --- /dev/null +++ b/services/awg_client.py @@ -0,0 +1,297 @@ +"""Клиент Amnezia Web Panel (AWG / AWG 2.0) — Bearer API token.""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Any +from urllib.parse import urlparse + +import httpx + +logger = logging.getLogger(__name__) + +DEFAULT_PROTOCOL = "awg2" + + +class AwgError(RuntimeError): + pass + + +@dataclass(frozen=True, slots=True) +class AwgPanelServer: + id: int + name: str + host: str + protocols: list[str] + + @property + def has_awg2(self) -> bool: + return any( + str(p).split("__", 1)[0] in {"awg2", "awg"} for p in self.protocols + ) + + +@dataclass(frozen=True, slots=True) +class AwgConnectionInfo: + ok: bool + alive: bool + ping_ms: int | None + protocols: list[str] + message: str = "" + + +def _normalize_base_url(url: str) -> str: + raw = (url or "").strip().rstrip("/") + if not raw: + raise AwgError("API URL пуст") + if not raw.startswith(("http://", "https://")): + raw = "https://" + raw + parsed = urlparse(raw) + if not parsed.netloc: + raise AwgError("Некорректный API URL") + return raw + + +class AwgClient: + """HTTP-клиент к Amnezia Web Panel (panel5 / PRVTPRO).""" + + def __init__( + self, + api_url: str, + *, + api_token: str, + verify_ssl: bool = True, + timeout: float = 60.0, + ) -> None: + self.base_url = _normalize_base_url(api_url) + self.api_token = (api_token or "").strip() + if not self.api_token: + raise AwgError("Нужен API Token (Settings → API Tokens)") + self.verify_ssl = verify_ssl + self._client = httpx.AsyncClient( + base_url=self.base_url, + timeout=timeout, + verify=verify_ssl, + headers={ + "Authorization": f"Bearer {self.api_token}", + "Accept": "application/json", + "Content-Type": "application/json", + }, + ) + + async def __aenter__(self) -> "AwgClient": + return self + + async def __aexit__(self, *args: Any) -> None: + await self.aclose() + + async def aclose(self) -> None: + await self._client.aclose() + + async def _request( + self, + method: str, + path: str, + *, + json_body: dict[str, Any] | None = None, + params: dict[str, Any] | None = None, + ) -> Any: + try: + res = await self._client.request( + method, path, json=json_body, params=params + ) + except httpx.HTTPError as exc: + raise AwgError(f"Сеть: {exc}") from exc + + if res.status_code in (401, 403): + raise AwgError("Доступ запрещён — проверьте API Token") + if res.status_code == 404: + raise AwgError("Не найдено (проверьте server_id на панели)") + + data: Any + ctype = res.headers.get("content-type", "") + if "application/json" in ctype or res.text[:1] in "{[": + try: + data = res.json() + except Exception: + data = {"raw": res.text[:500]} + else: + data = {"raw": res.text[:500]} + + if res.status_code >= 400: + err = "" + if isinstance(data, dict): + err = str(data.get("error") or data.get("detail") or "") + raise AwgError(err or f"HTTP {res.status_code}") + + return data + + async def ping(self, panel_server_id: int) -> dict[str, Any]: + return await self._request("GET", f"/api/servers/{int(panel_server_id)}/ping") + + async def list_panel_servers(self) -> list[AwgPanelServer]: + """Список нод панели через backup download (единственный полный inventory API).""" + try: + res = await self._client.get("/api/settings/backup/download") + except httpx.HTTPError as exc: + raise AwgError(f"Сеть: {exc}") from exc + if res.status_code in (401, 403): + raise AwgError("Доступ запрещён — проверьте API Token") + if res.status_code >= 400: + raise AwgError(f"Не удалось загрузить список серверов (HTTP {res.status_code})") + try: + data = res.json() + except Exception as exc: + raise AwgError("Ответ панели не JSON") from exc + servers = data.get("servers") if isinstance(data, dict) else None + if not isinstance(servers, list): + raise AwgError("В backup нет списка servers") + out: list[AwgPanelServer] = [] + for i, s in enumerate(servers): + if not isinstance(s, dict): + continue + protos = s.get("protocols") or {} + keys = list(protos.keys()) if isinstance(protos, dict) else [] + out.append( + AwgPanelServer( + id=i, + name=str(s.get("name") or s.get("host") or f"Server #{i}"), + host=str(s.get("host") or ""), + protocols=keys, + ) + ) + return out + + async def probe( + self, panel_server_id: int, *, protocol: str = DEFAULT_PROTOCOL + ) -> AwgConnectionInfo: + try: + ping = await self.ping(panel_server_id) + except AwgError as exc: + return AwgConnectionInfo( + ok=False, alive=False, ping_ms=None, protocols=[], message=str(exc) + ) + alive = bool(ping.get("alive")) + ms = ping.get("ms") + try: + ms_int = int(ms) if ms is not None else None + except (TypeError, ValueError): + ms_int = None + protocols: list[str] = [] + try: + servers = await self.list_panel_servers() + for s in servers: + if s.id == int(panel_server_id): + protocols = s.protocols + break + except AwgError: + protocols = [] + base = (protocol or DEFAULT_PROTOCOL).split("__", 1)[0] + has_proto = any(str(p).split("__", 1)[0] == base for p in protocols) if protocols else True + if not alive: + return AwgConnectionInfo( + ok=False, + alive=False, + ping_ms=ms_int, + protocols=protocols, + message=str(ping.get("error") or "SSH недоступен"), + ) + if protocols and not has_proto: + return AwgConnectionInfo( + ok=False, + alive=True, + ping_ms=ms_int, + protocols=protocols, + message=f"Протокол {base} не найден на сервере", + ) + return AwgConnectionInfo( + ok=True, + alive=True, + ping_ms=ms_int, + protocols=protocols, + message=f"OK · ping {ms_int or '—'} ms", + ) + + async def list_connections( + self, panel_server_id: int, *, protocol: str = DEFAULT_PROTOCOL + ) -> list[dict[str, Any]]: + data = await self._request( + "GET", + f"/api/servers/{int(panel_server_id)}/connections", + params={"protocol": protocol or DEFAULT_PROTOCOL}, + ) + clients = data.get("clients") if isinstance(data, dict) else None + return clients if isinstance(clients, list) else [] + + async def add_connection( + self, + panel_server_id: int, + *, + name: str, + protocol: str = DEFAULT_PROTOCOL, + ) -> dict[str, Any]: + data = await self._request( + "POST", + f"/api/servers/{int(panel_server_id)}/connections/add", + json_body={ + "protocol": protocol or DEFAULT_PROTOCOL, + "name": (name or "Client").strip()[:80] or "Client", + }, + ) + if not isinstance(data, dict): + raise AwgError("Пустой ответ при создании клиента") + if not data.get("client_id") and not data.get("config"): + raise AwgError("Панель не вернула client_id/config") + return data + + async def remove_connection( + self, + panel_server_id: int, + *, + client_id: str, + protocol: str = DEFAULT_PROTOCOL, + ) -> None: + await self._request( + "POST", + f"/api/servers/{int(panel_server_id)}/connections/remove", + json_body={ + "protocol": protocol or DEFAULT_PROTOCOL, + "client_id": client_id, + }, + ) + + async def get_connection_config( + self, + panel_server_id: int, + *, + client_id: str, + protocol: str = DEFAULT_PROTOCOL, + ) -> dict[str, Any]: + data = await self._request( + "POST", + f"/api/servers/{int(panel_server_id)}/connections/config", + json_body={ + "protocol": protocol or DEFAULT_PROTOCOL, + "client_id": client_id, + }, + ) + return data if isinstance(data, dict) else {} + + async def toggle_connection( + self, + panel_server_id: int, + *, + client_id: str, + enable: bool, + protocol: str = DEFAULT_PROTOCOL, + ) -> None: + await self._request( + "POST", + f"/api/servers/{int(panel_server_id)}/connections/toggle", + json_body={ + "protocol": protocol or DEFAULT_PROTOCOL, + "client_id": client_id, + "enable": bool(enable), + }, + ) diff --git a/services/awg_configs.py b/services/awg_configs.py new file mode 100644 index 0000000..84a1603 --- /dev/null +++ b/services/awg_configs.py @@ -0,0 +1,488 @@ +"""Покупка и управление AmneziaWG 2.0 конфигами через Amnezia Web Panel.""" + +from __future__ import annotations + +import logging +import secrets +import string +from dataclasses import dataclass +from datetime import datetime +from typing import Any + +from remnawave import RemnawaveSDK + +from services.awg_client import DEFAULT_PROTOCOL, AwgClient, AwgError +from services.db import Database +from services.xui_wg import subscription_is_active, _subscription_limits + +logger = logging.getLogger(__name__) + +MAX_AWG_CONFIGS = 10 +SETTING_ENABLED = "awg_purchase_enabled" +SETTING_PRICE = "awg_config_price" +DEFAULT_PRICE = 0.0 + + +def _truthy(value: str) -> bool: + return (value or "").strip().lower() in ("1", "true", "yes", "on", "да") + + +def _format_price(price: float) -> str: + if price <= 0: + return "бесплатно" + if price == int(price): + return f"{int(price)} ₽" + return f"{price:.2f} ₽" + + +@dataclass(slots=True) +class AwgPurchaseSettings: + enabled: bool = False + price: float = DEFAULT_PRICE + max_configs: int = MAX_AWG_CONFIGS + + def as_dict(self) -> dict[str, Any]: + return { + "enabled": self.enabled, + "price": self.price, + "price_label": _format_price(self.price), + "max_configs": self.max_configs, + } + + +@dataclass(slots=True) +class AwgActionResult: + ok: bool + message: str + balance: float = 0.0 + config: dict[str, Any] | None = None + configs: list[dict[str, Any]] | None = None + + +def load_awg_purchase_settings(db: Database) -> AwgPurchaseSettings: + raw = db.get_settings_map([SETTING_ENABLED, SETTING_PRICE]) + price_raw = (raw.get(SETTING_PRICE) or "").strip() + try: + price = float(price_raw) if price_raw else DEFAULT_PRICE + except ValueError: + price = DEFAULT_PRICE + return AwgPurchaseSettings( + enabled=_truthy(raw.get(SETTING_ENABLED, "")), + price=max(0.0, price), + max_configs=MAX_AWG_CONFIGS, + ) + + +def save_awg_purchase_settings( + db: Database, + *, + enabled: bool | None = None, + price: float | None = None, +) -> AwgPurchaseSettings: + data: dict[str, str] = {} + if enabled is not None: + data[SETTING_ENABLED] = "1" if enabled else "0" + if price is not None: + data[SETTING_PRICE] = str(max(0.0, float(price))) + if data: + db.set_settings_map(data) + return load_awg_purchase_settings(db) + + +def _client_from_server(row: dict[str, Any]) -> AwgClient: + return AwgClient( + str(row["api_url"]), + api_token=str(row.get("api_token") or ""), + verify_ssl=bool(row.get("verify_ssl", True)), + ) + + +def _new_client_name(telegram_id: int, remark: str = "") -> str: + if remark.strip(): + return remark.strip()[:80] + suffix = "".join( + secrets.choice(string.ascii_lowercase + string.digits) for _ in range(4) + ) + return f"awg_{telegram_id}_{suffix}" + + +def serialize_awg_config( + row: dict[str, Any], server: dict[str, Any] | None = None +) -> dict[str, Any]: + expire = row.get("expire_at") + return { + "id": int(row["id"]), + "server_id": int(row["server_id"]), + "server_name": (server or {}).get("name") or row.get("server_name") or "", + "client_id": row.get("client_id") or "", + "client_name": row.get("client_name") or "", + "remark": row.get("remark") or "", + "vpn_link": row.get("vpn_link") or "", + "config": row.get("config_text") or "", + "enabled": bool(row.get("enabled", True)), + "protocol": row.get("protocol") or DEFAULT_PROTOCOL, + "expire_at": expire.isoformat() if isinstance(expire, datetime) else expire, + "qr_url": f"/api/awg/configs/{int(row['id'])}/qr", + "created_at": row["created_at"].isoformat() + if isinstance(row.get("created_at"), datetime) + else row.get("created_at"), + } + + +def list_available_awg_servers(db: Database) -> list[dict[str, Any]]: + rows = db.list_awg_servers(enabled_only=True) + out: list[dict[str, Any]] = [] + for r in rows: + if not str(r.get("api_token") or "").strip(): + continue + out.append( + { + "id": int(r["id"]), + "name": r.get("name") or f"AWG #{r['id']}", + "protocol": r.get("protocol") or DEFAULT_PROTOCOL, + "panel_server_id": int(r.get("panel_server_id") or 0), + } + ) + return out + + +def list_user_awg_configs(db: Database, telegram_id: int) -> list[dict[str, Any]]: + rows = db.list_awg_configs(telegram_id) + return [serialize_awg_config(r) for r in rows] + + +async def purchase_awg_config( + *, + db: Database, + sdk: RemnawaveSDK, + telegram_id: int, + server_id: int, + remark: str = "", + login: str | None = None, +) -> AwgActionResult: + settings = load_awg_purchase_settings(db) + bot_user = db.ensure_user(telegram_id) + balance = bot_user.balance + login = login or bot_user.login + + if not settings.enabled: + return AwgActionResult( + ok=False, + message="Покупка AmneziaWG временно отключена.", + balance=balance, + ) + + active, reason = await subscription_is_active( + sdk, telegram_id, login=login, db=db + ) + if not active: + return AwgActionResult(ok=False, message=reason, balance=balance) + + count = db.count_awg_configs(telegram_id) + if count >= settings.max_configs: + return AwgActionResult( + ok=False, + message=f"Лимит: максимум {settings.max_configs} AWG-конфигов на аккаунт.", + balance=balance, + ) + + server = db.get_awg_server(server_id) + if not server or not server.get("enabled"): + return AwgActionResult(ok=False, message="Сервер недоступен.", balance=balance) + + price = float(settings.price) + if price > 0: + ok, balance = db.try_charge( + telegram_id, + price, + kind="awg_config", + meta=f"awg:{server_id}", + ) + if not ok: + return AwgActionResult( + ok=False, + message=( + f"Недостаточно средств.\n" + f"Нужно: {_format_price(price)}\n" + f"Баланс: {balance:.0f} ₽" + ), + balance=balance, + ) + + client_name = _new_client_name(telegram_id, remark) + remark = (remark or "").strip()[:80] or client_name + protocol = str(server.get("protocol") or DEFAULT_PROTOCOL) + panel_sid = int(server.get("panel_server_id") or 0) + + try: + expire_at, _traffic = await _subscription_limits( + sdk, telegram_id, login=login, db=db + ) + async with _client_from_server(server) as client: + created = await client.add_connection( + panel_sid, name=client_name, protocol=protocol + ) + client_id = str(created.get("client_id") or "") + config_text = str(created.get("config") or "") + vpn_link = str(created.get("vpn_link") or "") + if not vpn_link and config_text: + import base64 + + vpn_link = "vpn://" + base64.b64encode( + config_text.strip().encode("utf-8") + ).decode("utf-8") + row = db.create_awg_config( + telegram_id=telegram_id, + server_id=server_id, + client_id=client_id, + client_name=client_name, + remark=remark, + config_text=config_text, + vpn_link=vpn_link, + protocol=protocol, + enabled=True, + expire_at=expire_at, + ) + except Exception as exc: # noqa: BLE001 + logger.exception("AWG config create failed") + if price > 0: + balance = db.add_balance( + telegram_id, + price, + kind="refund", + meta=f"awg_refund:{server_id}", + ) + return AwgActionResult( + ok=False, + message=f"Не удалось создать конфиг: {exc}", + balance=balance, + ) + + return AwgActionResult( + ok=True, + message="AmneziaWG-конфиг создан", + balance=balance, + config=serialize_awg_config(row, server), + ) + + +async def set_awg_config_enabled( + *, + db: Database, + telegram_id: int, + config_id: int, + enabled: bool, + sdk: RemnawaveSDK | None = None, + login: str | None = None, +) -> AwgActionResult: + bot_user = db.ensure_user(telegram_id) + login = login or bot_user.login + row = db.get_awg_config(config_id, telegram_id=telegram_id) + if not row: + return AwgActionResult(ok=False, message="Конфиг не найден", balance=bot_user.balance) + + if enabled: + if sdk is None: + return AwgActionResult( + ok=False, + message="Не удалось проверить подписку.", + balance=bot_user.balance, + ) + active, reason = await subscription_is_active( + sdk, telegram_id, login=login, db=db + ) + if not active: + return AwgActionResult(ok=False, message=reason, balance=bot_user.balance) + + server = db.get_awg_server(int(row["server_id"])) + if not server: + return AwgActionResult(ok=False, message="Сервер не найден", balance=bot_user.balance) + try: + async with _client_from_server(server) as client: + await client.toggle_connection( + int(server["panel_server_id"]), + client_id=str(row["client_id"]), + enable=enabled, + protocol=str(row.get("protocol") or server.get("protocol") or DEFAULT_PROTOCOL), + ) + db.update_awg_config(config_id, enabled=enabled) + except AwgError as exc: + return AwgActionResult(ok=False, message=str(exc), balance=bot_user.balance) + except Exception as exc: # noqa: BLE001 + logger.exception("AWG toggle failed") + return AwgActionResult(ok=False, message=f"Ошибка: {exc}", balance=bot_user.balance) + refreshed = db.get_awg_config(config_id, telegram_id=telegram_id) + return AwgActionResult( + ok=True, + message="Включено" if enabled else "Выключено", + balance=bot_user.balance, + config=serialize_awg_config(refreshed, server) if refreshed else None, + ) + + +async def delete_awg_config( + *, + db: Database, + telegram_id: int, + config_id: int, +) -> AwgActionResult: + row = db.get_awg_config(config_id, telegram_id=telegram_id) + if not row: + return AwgActionResult(ok=False, message="Конфиг не найден") + server = db.get_awg_server(int(row["server_id"])) + if server: + try: + async with _client_from_server(server) as client: + await client.remove_connection( + int(server["panel_server_id"]), + client_id=str(row["client_id"]), + protocol=str( + row.get("protocol") or server.get("protocol") or DEFAULT_PROTOCOL + ), + ) + except Exception: # noqa: BLE001 + logger.exception("AWG remove on panel failed (deleting local anyway)") + db.delete_awg_config(config_id, telegram_id=telegram_id) + return AwgActionResult(ok=True, message="Удалено") + + +async def refresh_awg_config_payload( + *, + db: Database, + telegram_id: int, + config_id: int, +) -> AwgActionResult: + row = db.get_awg_config(config_id, telegram_id=telegram_id) + if not row: + return AwgActionResult(ok=False, message="Конфиг не найден") + server = db.get_awg_server(int(row["server_id"])) + if not server: + return AwgActionResult(ok=False, message="Сервер не найден") + try: + async with _client_from_server(server) as client: + data = await client.get_connection_config( + int(server["panel_server_id"]), + client_id=str(row["client_id"]), + protocol=str( + row.get("protocol") or server.get("protocol") or DEFAULT_PROTOCOL + ), + ) + config_text = str(data.get("config") or row.get("config_text") or "") + vpn_link = str(data.get("vpn_link") or "") + if not vpn_link and config_text: + import base64 + + vpn_link = "vpn://" + base64.b64encode( + config_text.strip().encode("utf-8") + ).decode("utf-8") + db.update_awg_config( + config_id, + config_text=config_text, + vpn_link=vpn_link or row.get("vpn_link") or "", + ) + except Exception as exc: # noqa: BLE001 + logger.exception("AWG refresh failed") + return AwgActionResult(ok=False, message=f"Не удалось обновить: {exc}") + refreshed = db.get_awg_config(config_id, telegram_id=telegram_id) + return AwgActionResult( + ok=True, + message="Обновлено", + config=serialize_awg_config(refreshed, server) if refreshed else None, + ) + + +async def sync_awg_configs_expiry( + *, + db: Database, + sdk: RemnawaveSDK, + telegram_id: int, + login: str | None = None, + restore_enabled: bool = False, +) -> None: + """Обновляет expire_at у локальных AWG-конфигов по подписке Remnawave.""" + rows = db.list_awg_configs(telegram_id) + if not rows: + return + bot_user = db.ensure_user(telegram_id) + login = login or bot_user.login + try: + expire_at, _ = await _subscription_limits( + sdk, telegram_id, login=login, db=db + ) + except Exception: # noqa: BLE001 + return + for row in rows: + db.update_awg_config(int(row["id"]), expire_at=expire_at) + if restore_enabled and not row.get("enabled"): + server = db.get_awg_server(int(row["server_id"])) + if not server: + continue + try: + async with _client_from_server(server) as client: + await client.toggle_connection( + int(server["panel_server_id"]), + client_id=str(row["client_id"]), + enable=True, + protocol=str( + row.get("protocol") + or server.get("protocol") + or DEFAULT_PROTOCOL + ), + ) + db.update_awg_config(int(row["id"]), enabled=True) + except Exception as exc: # noqa: BLE001 + logger.warning( + "AWG restore enable failed config=%s: %s", + row.get("id"), + exc, + ) + + +async def _disable_awg_row(db: Database, row: dict[str, Any]) -> bool: + if not row.get("enabled"): + return False + server = db.get_awg_server(int(row["server_id"])) + if server: + try: + async with _client_from_server(server) as client: + await client.toggle_connection( + int(server["panel_server_id"]), + client_id=str(row["client_id"]), + enable=False, + protocol=str( + row.get("protocol") or server.get("protocol") or DEFAULT_PROTOCOL + ), + ) + except Exception as exc: # noqa: BLE001 + logger.warning( + "AWG auto-disable panel failed config=%s: %s", + row.get("id"), + exc, + ) + db.update_awg_config(int(row["id"]), enabled=False) + return True + + +async def enforce_awg_configs_subscription( + *, + db: Database, + sdk: RemnawaveSDK, + telegram_id: int, + login: str | None = None, +) -> dict[str, Any]: + """Нет активной подписки → выключить все AWG-конфиги на панели и в БД.""" + bot_user = db.ensure_user(telegram_id) + login = login or bot_user.login + active, reason = await subscription_is_active( + sdk, telegram_id, login=login, db=db + ) + disabled = 0 + if not active: + for row in db.list_awg_configs(telegram_id): + if await _disable_awg_row(db, row): + disabled += 1 + return { + "subscription_active": active, + "reason": "" if active else reason, + "disabled": disabled, + } diff --git a/services/clients.py b/services/clients.py new file mode 100644 index 0000000..2650c81 --- /dev/null +++ b/services/clients.py @@ -0,0 +1,216 @@ +"""Топ клиентов по https://docs.rw/clients (featured + самые популярные).""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class ClientLink: + title: str + url: str + + +@dataclass(frozen=True, slots=True) +class ClientApp: + id: str + name: str + core: str + description: str + featured: bool + hwid: bool + links: tuple[ClientLink, ...] + + +# Источник: https://docs.rw/clients + src/data/clients.ts (featured первыми) +TOP_CLIENTS: tuple[ClientApp, ...] = ( + ClientApp( + id="happ", + name="Happ", + core="Xray", + description="Современный клиент для Android, iOS, macOS, Windows, Linux.", + featured=True, + hwid=True, + links=( + ClientLink("Android · Google Play", "https://play.google.com/store/apps/details?id=com.happproxy"), + ClientLink( + "Android · APK", + "https://github.com/Happ-proxy/happ-android/releases/latest/download/Happ.apk", + ), + ClientLink("iOS · App Store", "https://apps.apple.com/us/app/happ-proxy-utility/id6504287215"), + ClientLink( + "Windows", + "https://github.com/Happ-proxy/happ-desktop/releases/latest/download/setup-Happ.x64.exe", + ), + ClientLink("Linux · релизы", "https://github.com/Happ-proxy/happ-desktop/releases/latest"), + ClientLink("Сайт", "https://happ.su/main"), + ), + ), + ClientApp( + id="flclashx", + name="FlClashX", + core="Mihomo", + description="Форк FlClash с улучшениями. Android / Windows / macOS / Linux.", + featured=True, + hwid=True, + links=( + ClientLink("GitHub · релизы", "https://github.com/pluralplay/FlClashX/releases/latest"), + ClientLink("Telegram", "https://t.me/flclashx"), + ), + ), + ClientApp( + id="koala-clash", + name="Koala Clash", + core="Mihomo", + description="Форк Clash Verge Rev (community Remnawave). Windows / macOS / Linux.", + featured=True, + hwid=True, + links=( + ClientLink( + "GitHub · релизы", + "https://github.com/coolcoala/clash-verge-rev-lite/releases/latest", + ), + ClientLink("Telegram", "https://t.me/+WCL__GOFzZJkYjZi"), + ), + ), + ClientApp( + id="flowvy", + name="Flowvy", + core="Mihomo", + description="Удобный Mihomo-клиент «в два клика». Windows / macOS / Linux.", + featured=True, + hwid=True, + links=( + ClientLink( + "Windows", + "https://github.com/flowvy-proxy/desktop/releases/latest/download/Flowvy_x64.exe", + ), + ClientLink( + "Linux · deb", + "https://github.com/flowvy-proxy/desktop/releases/latest/download/Flowvy_x64.deb", + ), + ClientLink("macOS · релизы", "https://github.com/flowvy-proxy/desktop/releases/latest"), + ClientLink("Docs", "https://docs.flowvy.io"), + ), + ), + ClientApp( + id="rabbit-hole", + name="Rabbit Hole", + core="Mihomo", + description="Чистый UX для iOS и macOS.", + featured=True, + hwid=True, + links=( + ClientLink( + "iOS / macOS · App Store", + "https://apps.apple.com/app/rabbithole-vpn-client/id6683309629", + ), + ClientLink("Docs", "https://rabbit-hole-1.gitbook.io/rabbit-hole-docs/"), + ClientLink("Telegram", "https://t.me/rabbit_hole_vpn"), + ), + ), + ClientApp( + id="v2raytun", + name="V2rayTun", + core="Xray", + description="Лёгкий Xray-клиент. Android / iOS / Windows / macOS.", + featured=False, + hwid=True, + links=( + ClientLink( + "Android · Google Play", + "https://play.google.com/store/apps/details?id=com.v2raytun.android", + ), + ClientLink("iOS / macOS", "https://apps.apple.com/en/app/v2raytun/id6476628951"), + ClientLink("Windows / сайт", "https://v2raytun.com/"), + ClientLink("Telegram", "https://t.me/v2raytun"), + ), + ), + ClientApp( + id="v2rayng", + name="V2rayNG", + core="Xray", + description="Самый популярный V2Ray/Xray клиент для Android.", + featured=False, + hwid=False, + links=( + ClientLink("Android · релизы", "https://github.com/2dust/v2rayNG/releases/latest"), + ClientLink("GitHub", "https://github.com/2dust/v2rayNG"), + ), + ), + ClientApp( + id="v2rayn", + name="V2rayN", + core="Xray", + description="Популярный клиент для Windows / macOS / Linux.", + featured=False, + hwid=False, + links=( + ClientLink("Релизы", "https://github.com/2dust/v2rayN/releases/latest"), + ClientLink("Wiki", "https://github.com/2dust/v2rayN/wiki"), + ), + ), + ClientApp( + id="clash-verge", + name="Clash Verge", + core="Mihomo", + description="Clash Meta GUI на Tauri. Windows / macOS / Linux.", + featured=False, + hwid=False, + links=( + ClientLink( + "Релизы", + "https://github.com/clash-verge-rev/clash-verge-rev/releases/latest", + ), + ClientLink("Telegram", "https://t.me/clash_verge_rev"), + ), + ), + ClientApp( + id="karing", + name="Karing", + core="Sing-box", + description="Мультиплатформенный клиент на Sing-box.", + featured=False, + hwid=True, + links=( + ClientLink("Android / Desktop · релизы", "https://github.com/KaringX/karing/releases/latest"), + ClientLink("iOS · App Store", "https://apps.apple.com/us/app/karing/id6472431552"), + ClientLink("Сайт", "https://karing.app/"), + ClientLink("Telegram", "https://t.me/KaringApp"), + ), + ), +) + +DOCS_CLIENTS_URL = "https://docs.rw/clients" + +CLIENTS_INTRO = ( + "📥 Скачать клиент\n\n" + "Топ‑10 по docs.rw/clients " + "(featured Remnawave + самые популярные).\n\n" + "Установи приложение и вставь ссылку из «Моя подписка».\n" + "Выбери клиент:" +) + + +def get_client(client_id: str) -> ClientApp | None: + for client in TOP_CLIENTS: + if client.id == client_id: + return client + return None + + +def client_card(client: ClientApp) -> str: + badges: list[str] = [] + if client.featured: + badges.append("⭐ featured") + if client.hwid: + badges.append("🪪 HWID") + badge_line = " · ".join(badges) + lines = [ + f"{client.name}", + f"Core: {client.core}", + ] + if badge_line: + lines.append(badge_line) + lines.extend(["", client.description, "", "Выбери платформу / ссылку:"]) + return "\n".join(lines) diff --git a/services/cryptopay.py b/services/cryptopay.py new file mode 100644 index 0000000..45434f5 --- /dev/null +++ b/services/cryptopay.py @@ -0,0 +1,107 @@ +"""Клиент Crypto Pay API (@CryptoBot).""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +import httpx + + +class CryptoPayError(RuntimeError): + pass + + +@dataclass(frozen=True, slots=True) +class Invoice: + invoice_id: int + status: str + amount: str + pay_url: str + bot_invoice_url: str + payload: str | None = None + fiat: str | None = None + + +class CryptoPay: + def __init__(self, token: str, *, testnet: bool = False) -> None: + self.token = token.strip() + self.base_url = ( + "https://testnet-pay.crypt.bot/api" + if testnet + else "https://pay.crypt.bot/api" + ) + + @property + def enabled(self) -> bool: + return bool(self.token) + + async def _call(self, method: str, **params: Any) -> Any: + if not self.enabled: + raise CryptoPayError("CRYPTO_BOT_TOKEN не задан") + headers = {"Crypto-Pay-API-Token": self.token} + async with httpx.AsyncClient(timeout=30.0) as client: + resp = await client.post( + f"{self.base_url}/{method}", + headers=headers, + json={k: v for k, v in params.items() if v is not None}, + ) + resp.raise_for_status() + data = resp.json() + if not data.get("ok"): + raise CryptoPayError(str(data.get("error") or data)) + return data["result"] + + async def get_me(self) -> dict[str, Any]: + return await self._call("getMe") + + async def create_fiat_invoice( + self, + *, + amount_rub: float, + description: str, + payload: str, + expires_in: int = 1800, + accepted_assets: str = "USDT,TON,BTC,ETH,LTC", + ) -> Invoice: + result = await self._call( + "createInvoice", + currency_type="fiat", + fiat="RUB", + amount=f"{amount_rub:.2f}", + description=description[:1024], + payload=payload[:4096], + accepted_assets=accepted_assets, + expires_in=expires_in, + allow_comments=False, + allow_anonymous=True, + ) + return self._parse_invoice(result) + + async def get_invoice(self, invoice_id: int) -> Invoice | None: + result = await self._call( + "getInvoices", + invoice_ids=str(invoice_id), + ) + items = result if isinstance(result, list) else result.get("items") or [] + if not items: + return None + return self._parse_invoice(items[0]) + + @staticmethod + def _parse_invoice(raw: dict[str, Any]) -> Invoice: + pay_url = ( + raw.get("bot_invoice_url") + or raw.get("pay_url") + or raw.get("mini_app_invoice_url") + or "" + ) + return Invoice( + invoice_id=int(raw["invoice_id"]), + status=str(raw.get("status") or "active"), + amount=str(raw.get("amount") or ""), + pay_url=pay_url, + bot_invoice_url=str(raw.get("bot_invoice_url") or pay_url), + payload=raw.get("payload"), + fiat=raw.get("fiat"), + ) diff --git a/services/db.py b/services/db.py new file mode 100644 index 0000000..bc36dbb --- /dev/null +++ b/services/db.py @@ -0,0 +1,3229 @@ +"""PostgreSQL БД бота: пользователи, баланс, покупки, инвойсы, сессии.""" + +from __future__ import annotations + +import json +import re +import secrets +from dataclasses import dataclass +from datetime import date, datetime, timedelta, timezone +from typing import Any + +from psycopg import errors as pg_errors +from psycopg.rows import dict_row +from psycopg_pool import ConnectionPool + +from services.passwords import hash_password, new_session_token, verify_password + +LOGIN_RE = re.compile(r"^[a-zA-Z0-9_]{3,32}$") +EMAIL_RE = re.compile(r"^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$") +QR_LOGIN_TTL = timedelta(minutes=5) +WEBAUTHN_CHALLENGE_TTL = timedelta(minutes=5) +SESSION_DAYS = 30 +# Блокировка миграций схемы (bot + webapp стартуют вместе) +_SCHEMA_ADVISORY_LOCK = 872_014_401 + + +def _utc_now() -> datetime: + return datetime.now(timezone.utc) + + +@dataclass(frozen=True, slots=True) +class BotUser: + telegram_id: int + username: str | None + full_name: str | None + balance: float + created_at: datetime | str + login: str | None = None + has_password: bool = False + is_telegram: bool = True + extra_devices: int = 0 + device_blocked: bool = False + extra_traffic_packs: int = 0 + extras_next_billing_at: datetime | None = None + extras_active: bool = True + auto_renew: bool = True + email: str | None = None + banned: bool = False + referral_code: str | None = None + referred_by: int | None = None + trial_used_at: datetime | None = None + notify_telegram: bool = True + notify_email: bool = True + + @property + def display_name(self) -> str: + if self.full_name: + return self.full_name + if self.login: + return self.login + if self.username: + return f"@{self.username}" + return f"ID {self.telegram_id}" + + +class AuthError(ValueError): + pass + + +class Database: + def __init__(self, database_url: str) -> None: + if not database_url: + raise ValueError("DATABASE_URL пуст") + self.database_url = database_url + self.pool = ConnectionPool( + conninfo=database_url, + min_size=1, + max_size=10, + kwargs={"row_factory": dict_row}, + open=True, + ) + self.init_schema() + + def close(self) -> None: + self.pool.close() + + def init_schema(self) -> None: + statements = [ + """ + CREATE TABLE IF NOT EXISTS users ( + telegram_id BIGINT PRIMARY KEY, + username TEXT, + full_name TEXT, + balance NUMERIC(12, 2) NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + """, + """ + ALTER TABLE users ADD COLUMN IF NOT EXISTS login TEXT + """, + """ + ALTER TABLE users ADD COLUMN IF NOT EXISTS password_hash TEXT + """, + """ + ALTER TABLE users ADD COLUMN IF NOT EXISTS session_token TEXT + """, + """ + ALTER TABLE users ADD COLUMN IF NOT EXISTS session_expires TIMESTAMPTZ + """, + """ + ALTER TABLE users ADD COLUMN IF NOT EXISTS extra_devices INTEGER NOT NULL DEFAULT 0 + """, + """ + ALTER TABLE users ADD COLUMN IF NOT EXISTS device_blocked BOOLEAN NOT NULL DEFAULT FALSE + """, + """ + ALTER TABLE users ADD COLUMN IF NOT EXISTS extra_traffic_packs INTEGER NOT NULL DEFAULT 0 + """, + """ + ALTER TABLE users ADD COLUMN IF NOT EXISTS extras_next_billing_at TIMESTAMPTZ + """, + """ + ALTER TABLE users ADD COLUMN IF NOT EXISTS extras_active BOOLEAN NOT NULL DEFAULT TRUE + """, + """ + ALTER TABLE users ADD COLUMN IF NOT EXISTS expiry_reminder_sent_on DATE + """, + """ + ALTER TABLE users ADD COLUMN IF NOT EXISTS auto_renew BOOLEAN NOT NULL DEFAULT TRUE + """, + """ + ALTER TABLE users ADD COLUMN IF NOT EXISTS email TEXT + """, + """ + CREATE UNIQUE INDEX IF NOT EXISTS idx_users_login_unique + ON users (login) WHERE login IS NOT NULL + """, + """ + CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email_unique + ON users (lower(email)) WHERE email IS NOT NULL AND email <> '' + """, + """ + CREATE UNIQUE INDEX IF NOT EXISTS idx_users_session_token + ON users (session_token) WHERE session_token IS NOT NULL + """, + """ + CREATE TABLE IF NOT EXISTS login_qr ( + token TEXT PRIMARY KEY, + status TEXT NOT NULL DEFAULT 'pending', + telegram_id BIGINT, + session_token TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL, + approved_at TIMESTAMPTZ + ) + """, + """ + CREATE INDEX IF NOT EXISTS idx_login_qr_expires + ON login_qr (expires_at) + """, + """ + CREATE TABLE IF NOT EXISTS webauthn_credentials ( + credential_id TEXT PRIMARY KEY, + telegram_id BIGINT NOT NULL REFERENCES users(telegram_id) ON DELETE CASCADE, + public_key BYTEA NOT NULL, + sign_count BIGINT NOT NULL DEFAULT 0, + transports TEXT NOT NULL DEFAULT '[]', + device_name TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_used_at TIMESTAMPTZ + ) + """, + """ + CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user + ON webauthn_credentials (telegram_id) + """, + """ + CREATE TABLE IF NOT EXISTS webauthn_challenges ( + challenge TEXT PRIMARY KEY, + telegram_id BIGINT, + purpose TEXT NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + """, + """ + CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_expires + ON webauthn_challenges (expires_at) + """, + """ + ALTER TABLE users ADD COLUMN IF NOT EXISTS banned BOOLEAN NOT NULL DEFAULT FALSE + """, + """ + ALTER TABLE users ADD COLUMN IF NOT EXISTS referral_code TEXT + """, + """ + ALTER TABLE users ADD COLUMN IF NOT EXISTS referred_by BIGINT + """, + """ + ALTER TABLE users ADD COLUMN IF NOT EXISTS trial_used_at TIMESTAMPTZ + """, + """ + ALTER TABLE users ADD COLUMN IF NOT EXISTS notify_telegram BOOLEAN NOT NULL DEFAULT TRUE + """, + """ + ALTER TABLE users ADD COLUMN IF NOT EXISTS notify_email BOOLEAN NOT NULL DEFAULT TRUE + """, + """ + CREATE UNIQUE INDEX IF NOT EXISTS idx_users_referral_code + ON users (referral_code) WHERE referral_code IS NOT NULL + """, + """ + CREATE TABLE IF NOT EXISTS user_sessions ( + token TEXT PRIMARY KEY, + telegram_id BIGINT NOT NULL REFERENCES users(telegram_id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL, + user_agent TEXT, + ip TEXT + ) + """, + """ + CREATE INDEX IF NOT EXISTS idx_user_sessions_user + ON user_sessions (telegram_id, expires_at DESC) + """, + """ + CREATE TABLE IF NOT EXISTS promo_codes ( + id BIGSERIAL PRIMARY KEY, + code TEXT NOT NULL, + kind TEXT NOT NULL, + value NUMERIC(12, 2) NOT NULL, + max_uses INTEGER, + used_count INTEGER NOT NULL DEFAULT 0, + tariff_id TEXT, + active BOOLEAN NOT NULL DEFAULT TRUE, + expires_at TIMESTAMPTZ, + note TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + """, + """ + CREATE UNIQUE INDEX IF NOT EXISTS idx_promo_codes_code + ON promo_codes (lower(code)) + """, + """ + CREATE TABLE IF NOT EXISTS promo_redemptions ( + id BIGSERIAL PRIMARY KEY, + promo_id BIGINT NOT NULL REFERENCES promo_codes(id) ON DELETE CASCADE, + telegram_id BIGINT NOT NULL REFERENCES users(telegram_id) ON DELETE CASCADE, + amount_saved NUMERIC(12, 2) NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (promo_id, telegram_id) + ) + """, + """ + CREATE TABLE IF NOT EXISTS password_reset_tokens ( + token TEXT PRIMARY KEY, + telegram_id BIGINT NOT NULL REFERENCES users(telegram_id) ON DELETE CASCADE, + expires_at TIMESTAMPTZ NOT NULL, + used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + """, + """ + CREATE TABLE IF NOT EXISTS payment_events ( + id BIGSERIAL PRIMARY KEY, + provider TEXT NOT NULL, + event_type TEXT NOT NULL, + telegram_id BIGINT, + amount NUMERIC(12, 2), + invoice_ref TEXT, + status TEXT NOT NULL DEFAULT 'ok', + meta TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + """, + """ + CREATE INDEX IF NOT EXISTS idx_payment_events_created + ON payment_events (created_at DESC) + """, + """ + CREATE TABLE IF NOT EXISTS referral_rewards ( + id BIGSERIAL PRIMARY KEY, + referrer_id BIGINT NOT NULL REFERENCES users(telegram_id), + referee_id BIGINT NOT NULL REFERENCES users(telegram_id), + amount NUMERIC(12, 2) NOT NULL, + kind TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (referee_id, kind) + ) + """, + """ + CREATE SEQUENCE IF NOT EXISTS web_user_id_seq START WITH 1 INCREMENT BY 1 + """, + """ + CREATE TABLE IF NOT EXISTS transactions ( + id BIGSERIAL PRIMARY KEY, + telegram_id BIGINT NOT NULL REFERENCES users(telegram_id), + amount NUMERIC(12, 2) NOT NULL, + kind TEXT NOT NULL, + meta TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + """, + """ + CREATE INDEX IF NOT EXISTS idx_transactions_user_id + ON transactions (telegram_id, id DESC) + """, + """ + CREATE TABLE IF NOT EXISTS purchases ( + id BIGSERIAL PRIMARY KEY, + telegram_id BIGINT NOT NULL REFERENCES users(telegram_id), + tariff_id TEXT NOT NULL, + price NUMERIC(12, 2) NOT NULL, + remnawave_uuid TEXT, + subscription_url TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + """, + """ + CREATE TABLE IF NOT EXISTS invoices ( + id BIGSERIAL PRIMARY KEY, + telegram_id BIGINT NOT NULL REFERENCES users(telegram_id), + invoice_id BIGINT NOT NULL, + kind TEXT NOT NULL, + amount_rub NUMERIC(12, 2) NOT NULL, + tariff_id TEXT, + status TEXT NOT NULL DEFAULT 'active', + pay_url TEXT, + payload TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + paid_at TIMESTAMPTZ + ) + """, + """ + CREATE SEQUENCE IF NOT EXISTS local_invoice_id_seq START WITH 900000000001 + """, + """ + ALTER TABLE invoices ADD COLUMN IF NOT EXISTS provider TEXT NOT NULL DEFAULT 'cryptobot' + """, + """ + ALTER TABLE invoices ADD COLUMN IF NOT EXISTS order_id TEXT + """, + """ + ALTER TABLE invoices ADD COLUMN IF NOT EXISTS external_uuid TEXT + """, + """ + CREATE UNIQUE INDEX IF NOT EXISTS idx_invoices_provider_invoice + ON invoices (provider, invoice_id) + """, + """ + CREATE UNIQUE INDEX IF NOT EXISTS idx_invoices_order_id + ON invoices (order_id) WHERE order_id IS NOT NULL + """, + """ + CREATE TABLE IF NOT EXISTS digiseller_payments ( + inv BIGINT PRIMARY KEY, + unique_code TEXT NOT NULL, + telegram_id BIGINT NOT NULL REFERENCES users(telegram_id), + amount_rub NUMERIC(12, 2) NOT NULL, + product_id BIGINT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + """, + """ + CREATE UNIQUE INDEX IF NOT EXISTS idx_digiseller_unique_code + ON digiseller_payments (unique_code) + """, + """ + CREATE TABLE IF NOT EXISTS topup_codes ( + code TEXT PRIMARY KEY, + amount_rub NUMERIC(12, 2) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_by TEXT, + used_at TIMESTAMPTZ, + used_by BIGINT REFERENCES users(telegram_id) + ) + """, + """ + CREATE INDEX IF NOT EXISTS idx_topup_codes_used + ON topup_codes (used_at NULLS FIRST, created_at DESC) + """, + """ + CREATE TABLE IF NOT EXISTS tickets ( + id BIGSERIAL PRIMARY KEY, + telegram_id BIGINT NOT NULL REFERENCES users(telegram_id), + subject TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'open', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + """, + """ + CREATE INDEX IF NOT EXISTS idx_tickets_user + ON tickets (telegram_id, updated_at DESC) + """, + """ + CREATE INDEX IF NOT EXISTS idx_tickets_status + ON tickets (status, updated_at DESC) + """, + """ + CREATE TABLE IF NOT EXISTS ticket_messages ( + id BIGSERIAL PRIMARY KEY, + ticket_id BIGINT NOT NULL REFERENCES tickets(id) ON DELETE CASCADE, + author_role TEXT NOT NULL, + author_id BIGINT, + body TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + """, + """ + CREATE INDEX IF NOT EXISTS idx_ticket_messages_ticket + ON ticket_messages (ticket_id, id ASC) + """, + """ + CREATE TABLE IF NOT EXISTS app_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL DEFAULT '', + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + """, + """ + CREATE TABLE IF NOT EXISTS xui_servers ( + id BIGSERIAL PRIMARY KEY, + name TEXT NOT NULL, + api_url TEXT NOT NULL, + username TEXT NOT NULL DEFAULT '', + password TEXT NOT NULL DEFAULT '', + api_key TEXT NOT NULL DEFAULT '', + inbound_id INTEGER, + inbound_remark TEXT, + subscription_url TEXT NOT NULL DEFAULT '', + enabled BOOLEAN NOT NULL DEFAULT TRUE, + verify_ssl BOOLEAN NOT NULL DEFAULT TRUE, + last_check_at TIMESTAMPTZ, + last_check_ok BOOLEAN, + last_check_msg TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + """, + """ + ALTER TABLE xui_servers ADD COLUMN IF NOT EXISTS api_key TEXT NOT NULL DEFAULT '' + """, + """ + CREATE TABLE IF NOT EXISTS xui_wg_configs ( + id BIGSERIAL PRIMARY KEY, + telegram_id BIGINT NOT NULL REFERENCES users(telegram_id), + server_id BIGINT NOT NULL REFERENCES xui_servers(id) ON DELETE CASCADE, + email TEXT NOT NULL, + sub_id TEXT NOT NULL, + subscription_url TEXT NOT NULL, + remark TEXT, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + expire_at TIMESTAMPTZ, + traffic_bytes BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (server_id, email) + ) + """, + """ + ALTER TABLE xui_wg_configs ADD COLUMN IF NOT EXISTS expire_at TIMESTAMPTZ + """, + """ + ALTER TABLE xui_wg_configs ADD COLUMN IF NOT EXISTS traffic_bytes BIGINT NOT NULL DEFAULT 0 + """, + """ + CREATE INDEX IF NOT EXISTS idx_xui_wg_configs_user + ON xui_wg_configs (telegram_id, id DESC) + """, + """ + CREATE INDEX IF NOT EXISTS idx_xui_servers_enabled + ON xui_servers (enabled, id) + """, + """ + CREATE TABLE IF NOT EXISTS awg_servers ( + id BIGSERIAL PRIMARY KEY, + name TEXT NOT NULL, + api_url TEXT NOT NULL, + api_token TEXT NOT NULL DEFAULT '', + panel_server_id INTEGER NOT NULL DEFAULT 0, + protocol TEXT NOT NULL DEFAULT 'awg2', + enabled BOOLEAN NOT NULL DEFAULT TRUE, + verify_ssl BOOLEAN NOT NULL DEFAULT TRUE, + last_check_at TIMESTAMPTZ, + last_check_ok BOOLEAN, + last_check_msg TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + """, + """ + CREATE TABLE IF NOT EXISTS awg_configs ( + id BIGSERIAL PRIMARY KEY, + telegram_id BIGINT NOT NULL REFERENCES users(telegram_id), + server_id BIGINT NOT NULL REFERENCES awg_servers(id) ON DELETE CASCADE, + client_id TEXT NOT NULL, + client_name TEXT NOT NULL DEFAULT '', + remark TEXT, + config_text TEXT NOT NULL DEFAULT '', + vpn_link TEXT NOT NULL DEFAULT '', + protocol TEXT NOT NULL DEFAULT 'awg2', + enabled BOOLEAN NOT NULL DEFAULT TRUE, + expire_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (server_id, client_id) + ) + """, + """ + CREATE INDEX IF NOT EXISTS idx_awg_configs_user + ON awg_configs (telegram_id, id DESC) + """, + """ + CREATE INDEX IF NOT EXISTS idx_awg_servers_enabled + ON awg_servers (enabled, id) + """, + ] + with self.pool.connection() as conn: + # Один процесс за раз — иначе гонка CREATE IF NOT EXISTS → 42P07 + conn.execute("SELECT pg_advisory_lock(%s)", (_SCHEMA_ADVISORY_LOCK,)) + try: + for stmt in statements: + try: + with conn.transaction(): + conn.execute(stmt) + except (pg_errors.DuplicateTable, pg_errors.DuplicateObject): + # Параллельный старт / повторный apply — таблица уже есть + continue + # Старый UNIQUE(invoice_id) мешает разным провайдерам — убираем, если есть + with conn.transaction(): + conn.execute( + """ + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'invoices_invoice_id_key' + ) THEN + ALTER TABLE invoices DROP CONSTRAINT invoices_invoice_id_key; + END IF; + END $$; + """ + ) + with conn.transaction(): + conn.execute( + """ + UPDATE users + SET extras_next_billing_at = NOW() + INTERVAL '30 days' + WHERE extras_next_billing_at IS NULL + AND extra_devices > 0 + """ + ) + conn.commit() + finally: + conn.execute("SELECT pg_advisory_unlock(%s)", (_SCHEMA_ADVISORY_LOCK,)) + conn.commit() + + def ensure_user( + self, + telegram_id: int, + *, + username: str | None = None, + full_name: str | None = None, + ) -> BotUser: + with self.pool.connection() as conn: + row = conn.execute( + "SELECT * FROM users WHERE telegram_id = %s", + (telegram_id,), + ).fetchone() + if row: + conn.execute( + """ + UPDATE users + SET username = COALESCE(%s, username), + full_name = COALESCE(%s, full_name) + WHERE telegram_id = %s + """, + (username, full_name, telegram_id), + ) + else: + conn.execute( + """ + INSERT INTO users (telegram_id, username, full_name, balance, created_at) + VALUES (%s, %s, %s, 0, %s) + """, + (telegram_id, username, full_name, _utc_now()), + ) + conn.commit() + row = conn.execute( + "SELECT * FROM users WHERE telegram_id = %s", + (telegram_id,), + ).fetchone() + assert row is not None + return self._to_user(row) + + def get_user(self, telegram_id: int) -> BotUser | None: + with self.pool.connection() as conn: + row = conn.execute( + "SELECT * FROM users WHERE telegram_id = %s", + (telegram_id,), + ).fetchone() + return self._to_user(row) if row else None + + def get_user_by_login(self, login: str) -> BotUser | None: + with self.pool.connection() as conn: + row = conn.execute( + "SELECT * FROM users WHERE lower(login) = lower(%s)", + (login.strip(),), + ).fetchone() + return self._to_user(row) if row else None + + def get_user_by_session(self, token: str | None) -> BotUser | None: + if not token: + return None + now = _utc_now() + with self.pool.connection() as conn: + row = conn.execute( + """ + SELECT u.* + FROM user_sessions s + JOIN users u ON u.telegram_id = s.telegram_id + WHERE s.token = %s AND s.expires_at > %s + """, + (token, now), + ).fetchone() + if row: + user = self._to_user(row) + if user.banned: + return None + return user + row = conn.execute( + """ + SELECT * FROM users + WHERE session_token = %s + AND session_expires IS NOT NULL + AND session_expires > %s + """, + (token, now), + ).fetchone() + if not row: + return None + user = self._to_user(row) + if user.banned: + return None + return user + + def create_session( + self, + telegram_id: int, + *, + user_agent: str | None = None, + ip: str | None = None, + ) -> str: + token = new_session_token() + expires = _utc_now() + timedelta(days=SESSION_DAYS) + with self.pool.connection() as conn: + conn.execute( + """ + INSERT INTO user_sessions (token, telegram_id, created_at, expires_at, user_agent, ip) + VALUES (%s, %s, %s, %s, %s, %s) + """, + ( + token, + telegram_id, + _utc_now(), + expires, + (user_agent or "")[:300] or None, + (ip or "")[:80] or None, + ), + ) + # legacy single-token column (совместимость) + conn.execute( + """ + UPDATE users + SET session_token = %s, session_expires = %s + WHERE telegram_id = %s + """, + (token, expires, telegram_id), + ) + conn.commit() + return token + + def clear_session(self, token: str | None) -> None: + if not token: + return + with self.pool.connection() as conn: + conn.execute("DELETE FROM user_sessions WHERE token = %s", (token,)) + conn.execute( + """ + UPDATE users + SET session_token = NULL, session_expires = NULL + WHERE session_token = %s + """, + (token,), + ) + conn.commit() + + def clear_all_sessions(self, telegram_id: int, *, keep_token: str | None = None) -> int: + with self.pool.connection() as conn: + if keep_token: + row = conn.execute( + """ + DELETE FROM user_sessions + WHERE telegram_id = %s AND token <> %s + """, + (telegram_id, keep_token), + ) + else: + row = conn.execute( + "DELETE FROM user_sessions WHERE telegram_id = %s", + (telegram_id,), + ) + deleted = row.rowcount if row is not None else 0 + if not keep_token: + conn.execute( + """ + UPDATE users + SET session_token = NULL, session_expires = NULL + WHERE telegram_id = %s + """, + (telegram_id,), + ) + conn.commit() + return int(deleted or 0) + + def list_sessions(self, telegram_id: int) -> list[dict[str, Any]]: + with self.pool.connection() as conn: + rows = conn.execute( + """ + SELECT token, created_at, expires_at, user_agent, ip + FROM user_sessions + WHERE telegram_id = %s AND expires_at > %s + ORDER BY created_at DESC + LIMIT 20 + """, + (telegram_id, _utc_now()), + ).fetchall() + return [dict(r) for r in rows] + + def set_user_email(self, telegram_id: int, email: str | None) -> BotUser: + raw = (email or "").strip().lower() + if raw and not EMAIL_RE.fullmatch(raw): + raise AuthError("Некорректный email") + value = raw or None + with self.pool.connection() as conn: + if value: + clash = conn.execute( + """ + SELECT telegram_id FROM users + WHERE lower(email) = %s AND telegram_id <> %s + LIMIT 1 + """, + (value, telegram_id), + ).fetchone() + if clash: + raise AuthError("Этот email уже привязан к другому аккаунту") + conn.execute( + "UPDATE users SET email = %s WHERE telegram_id = %s", + (value, telegram_id), + ) + conn.commit() + user = self.get_user(telegram_id) + if not user: + raise AuthError("Пользователь не найден") + return user + + def create_login_qr(self) -> dict[str, Any]: + token = secrets.token_urlsafe(24) + now = _utc_now() + expires = now + QR_LOGIN_TTL + with self.pool.connection() as conn: + conn.execute( + """ + INSERT INTO login_qr (token, status, created_at, expires_at) + VALUES (%s, 'pending', %s, %s) + """, + (token, now, expires), + ) + conn.commit() + return {"token": token, "expires_at": expires, "expires_in": int(QR_LOGIN_TTL.total_seconds())} + + def approve_login_qr(self, token: str, telegram_id: int) -> None: + token = (token or "").strip() + if not token: + raise AuthError("Некорректный QR-токен") + now = _utc_now() + with self.pool.connection() as conn: + row = conn.execute( + "SELECT * FROM login_qr WHERE token = %s", + (token,), + ).fetchone() + if not row: + raise AuthError("QR-сессия не найдена") + if row["status"] != "pending": + raise AuthError("QR уже использован или подтверждён") + if row["expires_at"] <= now: + conn.execute( + "UPDATE login_qr SET status = 'expired' WHERE token = %s", + (token,), + ) + conn.commit() + raise AuthError("QR истёк — обновите код на сайте") + updated = conn.execute( + """ + UPDATE login_qr + SET status = 'approved', + telegram_id = %s, + approved_at = %s + WHERE token = %s AND status = 'pending' AND expires_at > %s + RETURNING token + """, + (telegram_id, now, token, now), + ).fetchone() + conn.commit() + if not updated: + raise AuthError("Не удалось подтвердить QR") + + def poll_login_qr(self, token: str) -> dict[str, Any]: + token = (token or "").strip() + if not token: + raise AuthError("Некорректный QR-токен") + now = _utc_now() + with self.pool.connection() as conn: + row = conn.execute( + "SELECT * FROM login_qr WHERE token = %s", + (token,), + ).fetchone() + if not row: + raise AuthError("QR-сессия не найдена") + if row["expires_at"] <= now and row["status"] == "pending": + conn.execute( + "UPDATE login_qr SET status = 'expired' WHERE token = %s", + (token,), + ) + conn.commit() + return {"status": "expired"} + if row["status"] == "pending": + return {"status": "pending"} + if row["status"] in {"expired", "consumed"}: + return {"status": row["status"]} + if row["status"] != "approved": + return {"status": str(row["status"])} + + telegram_id = int(row["telegram_id"]) + claimed = conn.execute( + """ + UPDATE login_qr + SET status = 'consumed' + WHERE token = %s AND status = 'approved' + RETURNING telegram_id + """, + (token,), + ).fetchone() + conn.commit() + if not claimed: + return {"status": "consumed"} + + session = self.create_session(telegram_id) + with self.pool.connection() as conn: + conn.execute( + "UPDATE login_qr SET session_token = %s WHERE token = %s", + (session, token), + ) + user_row = conn.execute( + "SELECT * FROM users WHERE telegram_id = %s", + (telegram_id,), + ).fetchone() + conn.commit() + if not user_row: + raise AuthError("Пользователь не найден") + return { + "status": "approved", + "token": session, + "user": self._to_user(user_row), + } + + def save_webauthn_challenge( + self, + *, + challenge_b64: str, + purpose: str, + telegram_id: int | None = None, + ) -> None: + expires = _utc_now() + WEBAUTHN_CHALLENGE_TTL + with self.pool.connection() as conn: + conn.execute( + "DELETE FROM webauthn_challenges WHERE expires_at < %s", + (_utc_now(),), + ) + conn.execute( + """ + INSERT INTO webauthn_challenges (challenge, telegram_id, purpose, expires_at) + VALUES (%s, %s, %s, %s) + ON CONFLICT (challenge) DO UPDATE + SET telegram_id = EXCLUDED.telegram_id, + purpose = EXCLUDED.purpose, + expires_at = EXCLUDED.expires_at + """, + (challenge_b64, telegram_id, purpose, expires), + ) + conn.commit() + + def take_webauthn_challenge( + self, challenge_b64: str, *, purpose: str + ) -> dict[str, Any] | None: + now = _utc_now() + with self.pool.connection() as conn: + row = conn.execute( + """ + DELETE FROM webauthn_challenges + WHERE challenge = %s AND purpose = %s AND expires_at > %s + RETURNING challenge, telegram_id, purpose + """, + (challenge_b64, purpose, now), + ).fetchone() + conn.commit() + return dict(row) if row else None + + def list_webauthn_credentials(self, telegram_id: int) -> list[dict[str, Any]]: + with self.pool.connection() as conn: + rows = conn.execute( + """ + SELECT credential_id, telegram_id, public_key, sign_count, + transports, device_name, created_at, last_used_at + FROM webauthn_credentials + WHERE telegram_id = %s + ORDER BY created_at DESC + """, + (telegram_id,), + ).fetchall() + return [dict(r) for r in rows] + + def get_webauthn_credential(self, credential_id: str) -> dict[str, Any] | None: + with self.pool.connection() as conn: + row = conn.execute( + """ + SELECT credential_id, telegram_id, public_key, sign_count, + transports, device_name, created_at, last_used_at + FROM webauthn_credentials + WHERE credential_id = %s + """, + (credential_id,), + ).fetchone() + return dict(row) if row else None + + def add_webauthn_credential( + self, + *, + telegram_id: int, + credential_id: str, + public_key: bytes, + sign_count: int, + transports: list[str] | None = None, + device_name: str | None = None, + ) -> None: + with self.pool.connection() as conn: + conn.execute( + """ + INSERT INTO webauthn_credentials ( + credential_id, telegram_id, public_key, sign_count, + transports, device_name, created_at + ) VALUES (%s, %s, %s, %s, %s, %s, %s) + """, + ( + credential_id, + telegram_id, + public_key, + int(sign_count), + json.dumps(transports or []), + (device_name or "").strip() or None, + _utc_now(), + ), + ) + conn.commit() + + def update_webauthn_sign_count(self, credential_id: str, sign_count: int) -> None: + with self.pool.connection() as conn: + conn.execute( + """ + UPDATE webauthn_credentials + SET sign_count = %s, last_used_at = %s + WHERE credential_id = %s + """, + (int(sign_count), _utc_now(), credential_id), + ) + conn.commit() + + def delete_webauthn_credential(self, telegram_id: int, credential_id: str) -> bool: + with self.pool.connection() as conn: + row = conn.execute( + """ + DELETE FROM webauthn_credentials + WHERE telegram_id = %s AND credential_id = %s + RETURNING credential_id + """, + (telegram_id, credential_id), + ).fetchone() + conn.commit() + return row is not None + + def register_with_password( + self, + *, + login: str, + password: str, + full_name: str | None = None, + auto_renew: bool = True, + ) -> tuple[BotUser, str]: + login_norm = login.strip() + if not LOGIN_RE.fullmatch(login_norm): + raise AuthError("Логин: 3–32 символа (латиница, цифры, _)") + if len(password) < 6: + raise AuthError("Пароль минимум 6 символов") + if self.get_user_by_login(login_norm): + raise AuthError("Такой логин уже занят") + + pwd_hash = hash_password(password) + with self.pool.connection() as conn: + web_id = conn.execute("SELECT nextval('web_user_id_seq') AS id").fetchone() + assert web_id is not None + telegram_id = -int(web_id["id"]) + try: + conn.execute( + """ + INSERT INTO users ( + telegram_id, username, full_name, balance, created_at, + login, password_hash, auto_renew + ) VALUES (%s, %s, %s, 0, %s, %s, %s, %s) + """, + ( + telegram_id, + login_norm, + full_name or login_norm, + _utc_now(), + login_norm, + pwd_hash, + bool(auto_renew), + ), + ) + except Exception as exc: # noqa: BLE001 + conn.rollback() + raise AuthError("Не удалось зарегистрировать") from exc + conn.commit() + + token = self.create_session(telegram_id) + user = self.get_user(telegram_id) + assert user is not None + return user, token + + def login_with_password(self, login: str, password: str) -> tuple[BotUser, str]: + login_norm = login.strip() + with self.pool.connection() as conn: + row = conn.execute( + "SELECT * FROM users WHERE lower(login) = lower(%s)", + (login_norm,), + ).fetchone() + if not row or not row.get("password_hash"): + raise AuthError("Неверный логин или пароль") + if not verify_password(password, row["password_hash"]): + raise AuthError("Неверный логин или пароль") + + user = self._to_user(row) + token = self.create_session(user.telegram_id) + return user, token + + def login_or_register_telegram( + self, + telegram_id: int, + *, + username: str | None = None, + full_name: str | None = None, + link_from: int | None = None, + ) -> tuple[BotUser, str]: + if telegram_id <= 0: + raise AuthError("Некорректный Telegram ID") + + if link_from is not None and link_from < 0 and link_from != telegram_id: + user = self._merge_web_into_telegram( + web_id=link_from, + telegram_id=telegram_id, + username=username, + full_name=full_name, + ) + else: + user = self.ensure_user( + telegram_id, + username=username, + full_name=full_name, + ) + token = self.create_session(user.telegram_id) + return user, token + + def _merge_web_into_telegram( + self, + *, + web_id: int, + telegram_id: int, + username: str | None, + full_name: str | None, + ) -> BotUser: + with self.pool.connection() as conn: + web = conn.execute( + "SELECT * FROM users WHERE telegram_id = %s FOR UPDATE", + (web_id,), + ).fetchone() + if not web: + raise AuthError("Веб-аккаунт не найден") + + tg = conn.execute( + "SELECT * FROM users WHERE telegram_id = %s FOR UPDATE", + (telegram_id,), + ).fetchone() + + if tg is None: + # Переносим PK: создаём tg-строку и перекидываем FK + conn.execute( + """ + INSERT INTO users ( + telegram_id, username, full_name, balance, created_at, + login, password_hash + ) VALUES (%s, %s, %s, %s, %s, %s, %s) + """, + ( + telegram_id, + username or web["username"], + full_name or web["full_name"], + web["balance"], + web["created_at"], + web["login"], + web["password_hash"], + ), + ) + for table in ("transactions", "purchases", "invoices"): + conn.execute( + f"UPDATE {table} SET telegram_id = %s WHERE telegram_id = %s", + (telegram_id, web_id), + ) + conn.execute("DELETE FROM users WHERE telegram_id = %s", (web_id,)) + else: + # Telegram уже есть — сливаем баланс и логин в него + new_balance = float(tg["balance"]) + float(web["balance"]) + login = tg["login"] or web["login"] + pwd = tg["password_hash"] or web["password_hash"] + conn.execute( + """ + UPDATE users + SET balance = %s, + login = %s, + password_hash = %s, + username = COALESCE(%s, username), + full_name = COALESCE(%s, full_name) + WHERE telegram_id = %s + """, + ( + new_balance, + login, + pwd, + username, + full_name, + telegram_id, + ), + ) + for table in ("transactions", "purchases", "invoices"): + conn.execute( + f"UPDATE {table} SET telegram_id = %s WHERE telegram_id = %s", + (telegram_id, web_id), + ) + conn.execute("DELETE FROM users WHERE telegram_id = %s", (web_id,)) + + conn.commit() + row = conn.execute( + "SELECT * FROM users WHERE telegram_id = %s", + (telegram_id,), + ).fetchone() + assert row is not None + return self._to_user(row) + + def get_balance(self, telegram_id: int) -> float: + return self.ensure_user(telegram_id).balance + + def add_balance( + self, + telegram_id: int, + amount: float, + *, + kind: str = "topup", + meta: str | None = None, + ) -> float: + if amount == 0: + return self.get_balance(telegram_id) + with self.pool.connection() as conn: + self._ensure_user_conn(conn, telegram_id) + conn.execute( + "UPDATE users SET balance = balance + %s WHERE telegram_id = %s", + (amount, telegram_id), + ) + conn.execute( + """ + INSERT INTO transactions (telegram_id, amount, kind, meta, created_at) + VALUES (%s, %s, %s, %s, %s) + """, + (telegram_id, amount, kind, meta, _utc_now()), + ) + row = conn.execute( + "SELECT balance FROM users WHERE telegram_id = %s", + (telegram_id,), + ).fetchone() + conn.commit() + assert row is not None + return float(row["balance"]) + + def try_charge( + self, + telegram_id: int, + amount: float, + *, + kind: str = "purchase", + meta: str | None = None, + ) -> tuple[bool, float]: + if amount < 0: + raise ValueError("amount must be >= 0") + with self.pool.connection() as conn: + self._ensure_user_conn(conn, telegram_id) + row = conn.execute( + "SELECT balance FROM users WHERE telegram_id = %s FOR UPDATE", + (telegram_id,), + ).fetchone() + assert row is not None + balance = float(row["balance"]) + if balance < amount: + conn.commit() + return False, balance + conn.execute( + "UPDATE users SET balance = balance - %s WHERE telegram_id = %s", + (amount, telegram_id), + ) + conn.execute( + """ + INSERT INTO transactions (telegram_id, amount, kind, meta, created_at) + VALUES (%s, %s, %s, %s, %s) + """, + (telegram_id, -amount, kind, meta, _utc_now()), + ) + conn.commit() + return True, balance - amount + + def record_purchase( + self, + telegram_id: int, + *, + tariff_id: str, + price: float, + remnawave_uuid: str | None, + subscription_url: str | None, + ) -> None: + with self.pool.connection() as conn: + conn.execute( + """ + INSERT INTO purchases ( + telegram_id, tariff_id, price, remnawave_uuid, subscription_url, created_at + ) VALUES (%s, %s, %s, %s, %s, %s) + """, + ( + telegram_id, + tariff_id, + price, + remnawave_uuid, + subscription_url, + _utc_now(), + ), + ) + conn.commit() + + def recent_transactions(self, telegram_id: int, limit: int = 5) -> list[dict[str, Any]]: + items, _total = self.list_transactions(telegram_id, limit=limit, offset=0) + return items + + def list_transactions( + self, + telegram_id: int, + *, + limit: int = 10, + offset: int = 0, + ) -> tuple[list[dict[str, Any]], int]: + limit = max(1, min(int(limit), 50)) + offset = max(0, int(offset)) + with self.pool.connection() as conn: + total_row = conn.execute( + "SELECT COUNT(*) AS c FROM transactions WHERE telegram_id = %s", + (telegram_id,), + ).fetchone() + total = int(total_row["c"] if total_row else 0) + rows = conn.execute( + """ + SELECT amount, kind, meta, created_at + FROM transactions + WHERE telegram_id = %s + ORDER BY id DESC + LIMIT %s OFFSET %s + """, + (telegram_id, limit, offset), + ).fetchall() + return [dict(r) for r in rows], total + + def list_users_admin( + self, + *, + query: str = "", + limit: int = 50, + offset: int = 0, + ) -> tuple[list[dict[str, Any]], int]: + q = (query or "").strip() + limit = max(1, min(int(limit), 200)) + offset = max(0, int(offset)) + with self.pool.connection() as conn: + if q: + pattern = f"%{q}%" + tid: int | None = None + if q.lstrip("-").isdigit(): + try: + tid = int(q) + except ValueError: + tid = None + if tid is not None: + rows = conn.execute( + """ + SELECT telegram_id, username, full_name, login, balance, created_at, banned + FROM users + WHERE telegram_id = %s + OR login ILIKE %s + OR username ILIKE %s + OR full_name ILIKE %s + ORDER BY created_at DESC + LIMIT %s OFFSET %s + """, + (tid, pattern, pattern, pattern, limit, offset), + ).fetchall() + count_row = conn.execute( + """ + SELECT COUNT(*) AS c FROM users + WHERE telegram_id = %s + OR login ILIKE %s + OR username ILIKE %s + OR full_name ILIKE %s + """, + (tid, pattern, pattern, pattern), + ).fetchone() + else: + rows = conn.execute( + """ + SELECT telegram_id, username, full_name, login, balance, created_at, banned + FROM users + WHERE login ILIKE %s + OR username ILIKE %s + OR full_name ILIKE %s + OR CAST(telegram_id AS TEXT) LIKE %s + ORDER BY created_at DESC + LIMIT %s OFFSET %s + """, + (pattern, pattern, pattern, pattern, limit, offset), + ).fetchall() + count_row = conn.execute( + """ + SELECT COUNT(*) AS c FROM users + WHERE login ILIKE %s + OR username ILIKE %s + OR full_name ILIKE %s + OR CAST(telegram_id AS TEXT) LIKE %s + """, + (pattern, pattern, pattern, pattern), + ).fetchone() + else: + rows = conn.execute( + """ + SELECT telegram_id, username, full_name, login, balance, created_at, banned + FROM users + ORDER BY created_at DESC + LIMIT %s OFFSET %s + """, + (limit, offset), + ).fetchall() + count_row = conn.execute("SELECT COUNT(*) AS c FROM users").fetchone() + total = int(count_row["c"] or 0) if count_row else 0 + return [dict(r) for r in rows], total + + def admin_credit_balance( + self, + telegram_id: int, + amount: float, + *, + comment: str, + admin_note: str | None = None, + ) -> float: + """Пополнение с баланса админом; comment виден пользователю в истории.""" + if amount <= 0: + raise ValueError("amount must be > 0") + text = (comment or "").strip()[:200] or "Зачисление администратором" + meta = text + if admin_note: + meta = f"{text} | admin:{admin_note[:80]}" + return self.add_balance( + telegram_id, + amount, + kind="admin_credit", + meta=meta, + ) + + def next_local_invoice_id(self) -> int: + with self.pool.connection() as conn: + row = conn.execute("SELECT nextval('local_invoice_id_seq') AS id").fetchone() + conn.commit() + assert row is not None + return int(row["id"]) + + def create_invoice_record( + self, + *, + telegram_id: int, + invoice_id: int, + kind: str, + amount_rub: float, + pay_url: str | None, + payload: str | None, + tariff_id: str | None = None, + provider: str = "cryptobot", + order_id: str | None = None, + external_uuid: str | None = None, + ) -> None: + self.ensure_user(telegram_id) + with self.pool.connection() as conn: + conn.execute( + """ + INSERT INTO invoices ( + telegram_id, invoice_id, kind, amount_rub, tariff_id, + status, pay_url, payload, created_at, + provider, order_id, external_uuid + ) VALUES (%s, %s, %s, %s, %s, 'active', %s, %s, %s, %s, %s, %s) + """, + ( + telegram_id, + invoice_id, + kind, + amount_rub, + tariff_id, + pay_url, + payload, + _utc_now(), + provider, + order_id, + external_uuid, + ), + ) + conn.commit() + + def get_invoice( + self, + invoice_id: int, + *, + provider: str | None = None, + ) -> dict[str, Any] | None: + with self.pool.connection() as conn: + if provider: + row = conn.execute( + """ + SELECT * FROM invoices + WHERE invoice_id = %s AND provider = %s + """, + (invoice_id, provider), + ).fetchone() + else: + row = conn.execute( + "SELECT * FROM invoices WHERE invoice_id = %s", + (invoice_id,), + ).fetchone() + return dict(row) if row else None + + def get_invoice_by_order_id(self, order_id: str) -> dict[str, Any] | None: + with self.pool.connection() as conn: + row = conn.execute( + "SELECT * FROM invoices WHERE order_id = %s", + (order_id,), + ).fetchone() + return dict(row) if row else None + + def digiseller_inv_used(self, inv: int) -> bool: + with self.pool.connection() as conn: + row = conn.execute( + "SELECT 1 FROM digiseller_payments WHERE inv = %s", + (inv,), + ).fetchone() + return row is not None + + def digiseller_code_used(self, unique_code: str) -> bool: + with self.pool.connection() as conn: + row = conn.execute( + "SELECT 1 FROM digiseller_payments WHERE unique_code = %s", + (unique_code,), + ).fetchone() + return row is not None + + def record_digiseller_payment( + self, + *, + inv: int, + unique_code: str, + telegram_id: int, + amount_rub: float, + product_id: int | None, + ) -> bool: + """True если записали впервые, False если уже был такой inv/code.""" + self.ensure_user(telegram_id) + with self.pool.connection() as conn: + try: + conn.execute( + """ + INSERT INTO digiseller_payments ( + inv, unique_code, telegram_id, amount_rub, product_id, created_at + ) VALUES (%s, %s, %s, %s, %s, %s) + """, + ( + inv, + unique_code, + telegram_id, + amount_rub, + product_id, + _utc_now(), + ), + ) + conn.commit() + return True + except Exception: # noqa: BLE001 + conn.rollback() + return False + + def create_topup_codes( + self, + *, + amount_rub: float, + count: int, + created_by: str = "", + codes: list[str], + ) -> int: + if amount_rub <= 0 or not codes: + return 0 + inserted = 0 + with self.pool.connection() as conn: + for code in codes: + try: + conn.execute( + """ + INSERT INTO topup_codes (code, amount_rub, created_by, created_at) + VALUES (%s, %s, %s, %s) + """, + (code, amount_rub, created_by or None, _utc_now()), + ) + inserted += 1 + except Exception: # noqa: BLE001 + continue + conn.commit() + return inserted + + def redeem_topup_code( + self, + *, + code: str, + telegram_id: int, + ) -> tuple[float, bool]: + """(сумма, уже_использован). NotFound -> ValueError.""" + with self.pool.connection() as conn: + row = conn.execute( + """ + SELECT code, amount_rub, used_at + FROM topup_codes + WHERE code = %s + FOR UPDATE + """, + (code,), + ).fetchone() + if not row: + raise ValueError("Код не найден") + amount = float(row["amount_rub"]) + if row["used_at"] is not None: + conn.commit() + return amount, True + conn.execute( + """ + UPDATE topup_codes + SET used_at = %s, used_by = %s + WHERE code = %s AND used_at IS NULL + """, + (_utc_now(), telegram_id, code), + ) + conn.commit() + return amount, False + + def list_topup_codes( + self, + *, + status: str = "all", + limit: int = 50, + offset: int = 0, + ) -> list[dict[str, Any]]: + limit = max(1, min(int(limit), 200)) + offset = max(0, int(offset)) + where = "" + if status == "unused": + where = "WHERE used_at IS NULL" + elif status == "used": + where = "WHERE used_at IS NOT NULL" + with self.pool.connection() as conn: + rows = conn.execute( + f""" + SELECT code, amount_rub, created_at, created_by, used_at, used_by + FROM topup_codes + {where} + ORDER BY created_at DESC + LIMIT %s OFFSET %s + """, + (limit, offset), + ).fetchall() + return [dict(r) for r in rows] + + def topup_code_stats(self) -> dict[str, int]: + with self.pool.connection() as conn: + row = conn.execute( + """ + SELECT + COUNT(*) FILTER (WHERE used_at IS NULL) AS unused, + COUNT(*) FILTER (WHERE used_at IS NOT NULL) AS used, + COUNT(*) AS total + FROM topup_codes + """ + ).fetchone() + return { + "unused": int(row["unused"] or 0), + "used": int(row["used"] or 0), + "total": int(row["total"] or 0), + } + + def mark_invoice_paid(self, invoice_id: int, *, provider: str | None = None) -> bool: + with self.pool.connection() as conn: + if provider: + row = conn.execute( + """ + UPDATE invoices + SET status = 'paid', paid_at = %s + WHERE invoice_id = %s AND provider = %s + AND status IS DISTINCT FROM 'paid' + RETURNING invoice_id + """, + (_utc_now(), invoice_id, provider), + ).fetchone() + else: + row = conn.execute( + """ + UPDATE invoices + SET status = 'paid', paid_at = %s + WHERE invoice_id = %s AND status IS DISTINCT FROM 'paid' + RETURNING invoice_id + """, + (_utc_now(), invoice_id), + ).fetchone() + conn.commit() + return row is not None + + @staticmethod + def _to_user(row: dict[str, Any]) -> BotUser: + tid = int(row["telegram_id"]) + return BotUser( + telegram_id=tid, + username=row["username"], + full_name=row["full_name"], + balance=float(row["balance"]), + created_at=row["created_at"], + login=row.get("login"), + has_password=bool(row.get("password_hash")), + is_telegram=tid > 0, + extra_devices=int(row.get("extra_devices") or 0), + device_blocked=bool(row.get("device_blocked")), + extra_traffic_packs=int(row.get("extra_traffic_packs") or 0), + extras_next_billing_at=row.get("extras_next_billing_at"), + extras_active=bool(row.get("extras_active", True)), + auto_renew=bool(row["auto_renew"]) if row.get("auto_renew") is not None else True, + email=(str(row["email"]).strip() if row.get("email") else None) or None, + banned=bool(row.get("banned")), + referral_code=(str(row["referral_code"]).strip() if row.get("referral_code") else None) + or None, + referred_by=int(row["referred_by"]) if row.get("referred_by") is not None else None, + trial_used_at=row.get("trial_used_at"), + notify_telegram=bool(row["notify_telegram"]) + if row.get("notify_telegram") is not None + else True, + notify_email=bool(row["notify_email"]) + if row.get("notify_email") is not None + else True, + ) + + def set_device_blocked(self, telegram_id: int, blocked: bool) -> None: + with self.pool.connection() as conn: + self._ensure_user_conn(conn, telegram_id) + conn.execute( + "UPDATE users SET device_blocked = %s WHERE telegram_id = %s", + (bool(blocked), telegram_id), + ) + conn.commit() + + def add_extra_devices(self, telegram_id: int, slots: int) -> int: + if slots <= 0: + return self.ensure_user(telegram_id).extra_devices + with self.pool.connection() as conn: + self._ensure_user_conn(conn, telegram_id) + row = conn.execute( + """ + UPDATE users + SET extra_devices = extra_devices + %s + WHERE telegram_id = %s + RETURNING extra_devices + """, + (int(slots), telegram_id), + ).fetchone() + conn.commit() + assert row is not None + return int(row["extra_devices"]) + + def add_extra_traffic_packs(self, telegram_id: int, packs: int) -> int: + if packs <= 0: + return self.ensure_user(telegram_id).extra_traffic_packs + with self.pool.connection() as conn: + self._ensure_user_conn(conn, telegram_id) + row = conn.execute( + """ + UPDATE users + SET extra_traffic_packs = extra_traffic_packs + %s + WHERE telegram_id = %s + RETURNING extra_traffic_packs + """, + (int(packs), telegram_id), + ).fetchone() + conn.commit() + assert row is not None + return int(row["extra_traffic_packs"]) + + def set_extras_active(self, telegram_id: int, active: bool) -> None: + with self.pool.connection() as conn: + self._ensure_user_conn(conn, telegram_id) + conn.execute( + "UPDATE users SET extras_active = %s WHERE telegram_id = %s", + (bool(active), telegram_id), + ) + conn.commit() + + def set_extras_billing( + self, + telegram_id: int, + *, + next_billing_at: datetime | None, + active: bool, + ) -> None: + with self.pool.connection() as conn: + self._ensure_user_conn(conn, telegram_id) + conn.execute( + """ + UPDATE users + SET extras_next_billing_at = %s, + extras_active = %s + WHERE telegram_id = %s + """, + (next_billing_at, bool(active), telegram_id), + ) + conn.commit() + + def list_users_for_extras_billing(self, *, limit: int = 200) -> list[int]: + with self.pool.connection() as conn: + rows = conn.execute( + """ + SELECT telegram_id FROM users + WHERE (extra_devices > 0 OR extra_traffic_packs > 0) + AND ( + extras_active = FALSE + OR extras_next_billing_at IS NULL + OR extras_next_billing_at <= NOW() + ) + ORDER BY extras_next_billing_at NULLS FIRST + LIMIT %s + """, + (int(limit),), + ).fetchall() + return [int(r["telegram_id"]) for r in rows] + + def list_telegram_ids_with_purchases(self, *, limit: int = 500) -> list[int]: + with self.pool.connection() as conn: + rows = conn.execute( + """ + SELECT DISTINCT p.telegram_id + FROM purchases p + WHERE p.telegram_id > 0 + ORDER BY p.telegram_id + LIMIT %s + """, + (int(limit),), + ).fetchall() + return [int(r["telegram_id"]) for r in rows] + + def set_auto_renew(self, telegram_id: int, enabled: bool) -> BotUser: + with self.pool.connection() as conn: + self._ensure_user_conn(conn, telegram_id) + row = conn.execute( + """ + UPDATE users + SET auto_renew = %s + WHERE telegram_id = %s + RETURNING * + """, + (bool(enabled), int(telegram_id)), + ).fetchone() + conn.commit() + assert row is not None + return self._to_user(row) + + def get_last_tariff_id(self, telegram_id: int) -> str | None: + with self.pool.connection() as conn: + row = conn.execute( + """ + SELECT tariff_id FROM purchases + WHERE telegram_id = %s + ORDER BY id DESC + LIMIT 1 + """, + (telegram_id,), + ).fetchone() + return str(row["tariff_id"]) if row else None + + def expiry_reminder_sent_on(self, telegram_id: int) -> date | None: + with self.pool.connection() as conn: + row = conn.execute( + "SELECT expiry_reminder_sent_on FROM users WHERE telegram_id = %s", + (telegram_id,), + ).fetchone() + if not row: + return None + val = row.get("expiry_reminder_sent_on") + if val is None: + return None + if isinstance(val, date): + return val + return val.date() if hasattr(val, "date") else None + + def mark_expiry_reminder_sent(self, telegram_id: int, on: date) -> None: + with self.pool.connection() as conn: + self._ensure_user_conn(conn, telegram_id) + conn.execute( + """ + UPDATE users SET expiry_reminder_sent_on = %s + WHERE telegram_id = %s + """, + (on, telegram_id), + ) + conn.commit() + + def count_open_tickets(self, telegram_id: int) -> int: + with self.pool.connection() as conn: + row = conn.execute( + """ + SELECT COUNT(*) AS c FROM tickets + WHERE telegram_id = %s AND status <> 'closed' + """, + (telegram_id,), + ).fetchone() + return int(row["c"]) if row else 0 + + def create_ticket( + self, + telegram_id: int, + *, + subject: str, + body: str, + ) -> dict[str, Any]: + subject = (subject or "").strip() + body = (body or "").strip() + if not subject or not body: + raise ValueError("Тема и сообщение обязательны") + with self.pool.connection() as conn: + self._ensure_user_conn(conn, telegram_id) + row = conn.execute( + """ + INSERT INTO tickets (telegram_id, subject, status, created_at, updated_at) + VALUES (%s, %s, 'open', NOW(), NOW()) + RETURNING * + """, + (telegram_id, subject), + ).fetchone() + assert row is not None + ticket_id = int(row["id"]) + conn.execute( + """ + INSERT INTO ticket_messages (ticket_id, author_role, author_id, body) + VALUES (%s, 'user', %s, %s) + """, + (ticket_id, telegram_id, body), + ) + conn.commit() + return dict(row) + + def list_tickets_for_user( + self, + telegram_id: int, + *, + limit: int = 30, + offset: int = 0, + include_closed: bool = True, + ) -> list[dict[str, Any]]: + limit = max(1, min(int(limit), 100)) + offset = max(0, int(offset)) + with self.pool.connection() as conn: + if include_closed: + rows = conn.execute( + """ + SELECT t.*, + ( + SELECT COUNT(*) FROM ticket_messages m + WHERE m.ticket_id = t.id + ) AS message_count + FROM tickets t + WHERE t.telegram_id = %s + ORDER BY t.updated_at DESC + LIMIT %s OFFSET %s + """, + (telegram_id, limit, offset), + ).fetchall() + else: + rows = conn.execute( + """ + SELECT t.*, + ( + SELECT COUNT(*) FROM ticket_messages m + WHERE m.ticket_id = t.id + ) AS message_count + FROM tickets t + WHERE t.telegram_id = %s AND t.status <> 'closed' + ORDER BY t.updated_at DESC + LIMIT %s OFFSET %s + """, + (telegram_id, limit, offset), + ).fetchall() + return [dict(r) for r in rows] + + def list_tickets_admin( + self, + *, + status: str = "open", + limit: int = 50, + offset: int = 0, + ) -> tuple[list[dict[str, Any]], int]: + limit = max(1, min(int(limit), 100)) + offset = max(0, int(offset)) + status = (status or "open").strip().lower() + with self.pool.connection() as conn: + if status == "all": + total_row = conn.execute("SELECT COUNT(*) AS c FROM tickets").fetchone() + rows = conn.execute( + """ + SELECT t.*, + u.username, u.full_name, u.login, + ( + SELECT COUNT(*) FROM ticket_messages m + WHERE m.ticket_id = t.id + ) AS message_count + FROM tickets t + LEFT JOIN users u ON u.telegram_id = t.telegram_id + ORDER BY + CASE t.status + WHEN 'open' THEN 0 + WHEN 'answered' THEN 1 + ELSE 2 + END, + t.updated_at DESC + LIMIT %s OFFSET %s + """, + (limit, offset), + ).fetchall() + else: + total_row = conn.execute( + "SELECT COUNT(*) AS c FROM tickets WHERE status = %s", + (status,), + ).fetchone() + rows = conn.execute( + """ + SELECT t.*, + u.username, u.full_name, u.login, + ( + SELECT COUNT(*) FROM ticket_messages m + WHERE m.ticket_id = t.id + ) AS message_count + FROM tickets t + LEFT JOIN users u ON u.telegram_id = t.telegram_id + WHERE t.status = %s + ORDER BY t.updated_at DESC + LIMIT %s OFFSET %s + """, + (status, limit, offset), + ).fetchall() + total = int(total_row["c"]) if total_row else 0 + return [dict(r) for r in rows], total + + def ticket_stats(self) -> dict[str, int]: + with self.pool.connection() as conn: + rows = conn.execute( + """ + SELECT status, COUNT(*) AS c + FROM tickets + GROUP BY status + """ + ).fetchall() + stats = {"open": 0, "answered": 0, "closed": 0, "total": 0} + for r in rows: + st = str(r["status"]) + c = int(r["c"]) + if st in stats: + stats[st] = c + stats["total"] += c + return stats + + def get_ticket(self, ticket_id: int) -> dict[str, Any] | None: + with self.pool.connection() as conn: + row = conn.execute( + """ + SELECT t.*, u.username, u.full_name, u.login + FROM tickets t + LEFT JOIN users u ON u.telegram_id = t.telegram_id + WHERE t.id = %s + """, + (int(ticket_id),), + ).fetchone() + return dict(row) if row else None + + def list_ticket_messages( + self, + ticket_id: int, + *, + limit: int = 200, + ) -> list[dict[str, Any]]: + limit = max(1, min(int(limit), 500)) + with self.pool.connection() as conn: + rows = conn.execute( + """ + SELECT * FROM ticket_messages + WHERE ticket_id = %s + ORDER BY id ASC + LIMIT %s + """, + (int(ticket_id), limit), + ).fetchall() + return [dict(r) for r in rows] + + def add_ticket_message( + self, + ticket_id: int, + *, + author_role: str, + author_id: int | None, + body: str, + new_status: str | None = None, + ) -> dict[str, Any]: + body = (body or "").strip() + if not body: + raise ValueError("Пустое сообщение") + role = (author_role or "").strip().lower() + if role not in ("user", "admin", "ai"): + raise ValueError("Некорректная роль") + with self.pool.connection() as conn: + ticket = conn.execute( + "SELECT * FROM tickets WHERE id = %s FOR UPDATE", + (int(ticket_id),), + ).fetchone() + if not ticket: + raise ValueError("Тикет не найден") + if str(ticket["status"]) == "closed": + raise ValueError("Тикет закрыт") + status = new_status or str(ticket["status"]) + if new_status is None: + status = "answered" if role in ("admin", "ai") else "open" + conn.execute( + """ + INSERT INTO ticket_messages (ticket_id, author_role, author_id, body) + VALUES (%s, %s, %s, %s) + """, + (int(ticket_id), role, author_id, body), + ) + conn.execute( + """ + UPDATE tickets + SET status = %s, updated_at = NOW() + WHERE id = %s + """, + (status, int(ticket_id)), + ) + conn.commit() + row = conn.execute( + "SELECT * FROM tickets WHERE id = %s", + (int(ticket_id),), + ).fetchone() + assert row is not None + return dict(row) + + def set_ticket_status(self, ticket_id: int, status: str) -> dict[str, Any] | None: + status = (status or "").strip().lower() + if status not in ("open", "answered", "closed"): + raise ValueError("Некорректный статус") + with self.pool.connection() as conn: + conn.execute( + """ + UPDATE tickets + SET status = %s, updated_at = NOW() + WHERE id = %s + """, + (status, int(ticket_id)), + ) + conn.commit() + row = conn.execute( + "SELECT * FROM tickets WHERE id = %s", + (int(ticket_id),), + ).fetchone() + return dict(row) if row else None + + def get_setting(self, key: str, default: str = "") -> str: + with self.pool.connection() as conn: + row = conn.execute( + "SELECT value FROM app_settings WHERE key = %s", + (key,), + ).fetchone() + if not row: + return default + return str(row["value"] if row["value"] is not None else default) + + def set_setting(self, key: str, value: str) -> None: + with self.pool.connection() as conn: + conn.execute( + """ + INSERT INTO app_settings (key, value, updated_at) + VALUES (%s, %s, NOW()) + ON CONFLICT (key) DO UPDATE + SET value = EXCLUDED.value, updated_at = NOW() + """, + (key, value if value is not None else ""), + ) + conn.commit() + + def get_settings_map(self, keys: list[str]) -> dict[str, str]: + if not keys: + return {} + with self.pool.connection() as conn: + rows = conn.execute( + "SELECT key, value FROM app_settings WHERE key = ANY(%s)", + (list(keys),), + ).fetchall() + out = {k: "" for k in keys} + for r in rows: + out[str(r["key"])] = str(r["value"] if r["value"] is not None else "") + return out + + def set_settings_map(self, data: dict[str, str]) -> None: + if not data: + return + with self.pool.connection() as conn: + for key, value in data.items(): + conn.execute( + """ + INSERT INTO app_settings (key, value, updated_at) + VALUES (%s, %s, NOW()) + ON CONFLICT (key) DO UPDATE + SET value = EXCLUDED.value, updated_at = NOW() + """, + (str(key), "" if value is None else str(value)), + ) + conn.commit() + + # ─── 3x-ui servers ───────────────────────────────────────────────────── + + def list_xui_servers(self, *, enabled_only: bool = False) -> list[dict[str, Any]]: + where = "WHERE enabled = TRUE" if enabled_only else "" + with self.pool.connection() as conn: + rows = conn.execute( + f""" + SELECT id, name, api_url, username, password, api_key, inbound_id, inbound_remark, + subscription_url, enabled, verify_ssl, + last_check_at, last_check_ok, last_check_msg, + created_at, updated_at + FROM xui_servers + {where} + ORDER BY id ASC + """ + ).fetchall() + return [dict(r) for r in rows] + + def get_xui_server(self, server_id: int) -> dict[str, Any] | None: + with self.pool.connection() as conn: + row = conn.execute( + """ + SELECT id, name, api_url, username, password, api_key, inbound_id, inbound_remark, + subscription_url, enabled, verify_ssl, + last_check_at, last_check_ok, last_check_msg, + created_at, updated_at + FROM xui_servers + WHERE id = %s + """, + (int(server_id),), + ).fetchone() + return dict(row) if row else None + + def create_xui_server( + self, + *, + name: str, + api_url: str, + username: str = "", + password: str = "", + api_key: str = "", + subscription_url: str = "", + inbound_id: int | None = None, + inbound_remark: str | None = None, + enabled: bool = True, + verify_ssl: bool = True, + ) -> dict[str, Any]: + with self.pool.connection() as conn: + row = conn.execute( + """ + INSERT INTO xui_servers ( + name, api_url, username, password, api_key, subscription_url, + inbound_id, inbound_remark, enabled, verify_ssl + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + RETURNING id, name, api_url, username, password, api_key, inbound_id, inbound_remark, + subscription_url, enabled, verify_ssl, + last_check_at, last_check_ok, last_check_msg, + created_at, updated_at + """, + ( + name.strip(), + api_url.strip(), + (username or "").strip(), + password or "", + (api_key or "").strip(), + (subscription_url or "").strip(), + inbound_id, + (inbound_remark or "").strip() or None, + bool(enabled), + bool(verify_ssl), + ), + ).fetchone() + conn.commit() + return dict(row) + + def update_xui_server(self, server_id: int, **fields: Any) -> dict[str, Any] | None: + allowed = { + "name", + "api_url", + "username", + "password", + "api_key", + "subscription_url", + "inbound_id", + "inbound_remark", + "enabled", + "verify_ssl", + "last_check_at", + "last_check_ok", + "last_check_msg", + } + updates = {k: v for k, v in fields.items() if k in allowed} + if not updates: + return self.get_xui_server(server_id) + + cols = [] + vals: list[Any] = [] + for key, value in updates.items(): + cols.append(f"{key} = %s") + if key in {"name", "api_url", "username", "subscription_url", "api_key"} and isinstance( + value, str + ): + vals.append(value.strip()) + elif key == "inbound_remark": + vals.append((value or "").strip() or None if isinstance(value, str) else value) + else: + vals.append(value) + cols.append("updated_at = NOW()") + vals.append(int(server_id)) + + with self.pool.connection() as conn: + row = conn.execute( + f""" + UPDATE xui_servers + SET {", ".join(cols)} + WHERE id = %s + RETURNING id, name, api_url, username, password, api_key, inbound_id, inbound_remark, + subscription_url, enabled, verify_ssl, + last_check_at, last_check_ok, last_check_msg, + created_at, updated_at + """, + tuple(vals), + ).fetchone() + conn.commit() + return dict(row) if row else None + + def delete_xui_server(self, server_id: int) -> bool: + with self.pool.connection() as conn: + cur = conn.execute( + "DELETE FROM xui_servers WHERE id = %s", + (int(server_id),), + ) + conn.commit() + return cur.rowcount > 0 + + def set_xui_server_check( + self, + server_id: int, + *, + ok: bool, + message: str, + ) -> None: + with self.pool.connection() as conn: + conn.execute( + """ + UPDATE xui_servers + SET last_check_at = NOW(), + last_check_ok = %s, + last_check_msg = %s, + updated_at = NOW() + WHERE id = %s + """, + (bool(ok), (message or "")[:500], int(server_id)), + ) + conn.commit() + + # ─── 3x-ui WireGuard configs ─────────────────────────────────────────── + + def count_xui_wg_configs(self, telegram_id: int) -> int: + with self.pool.connection() as conn: + row = conn.execute( + "SELECT COUNT(*) AS c FROM xui_wg_configs WHERE telegram_id = %s", + (int(telegram_id),), + ).fetchone() + return int(row["c"] if row else 0) + + def list_xui_wg_configs(self, telegram_id: int) -> list[dict[str, Any]]: + with self.pool.connection() as conn: + rows = conn.execute( + """ + SELECT c.id, c.telegram_id, c.server_id, c.email, c.sub_id, + c.subscription_url, c.remark, c.enabled, c.expire_at, + c.traffic_bytes, c.created_at, + s.name AS server_name + FROM xui_wg_configs c + LEFT JOIN xui_servers s ON s.id = c.server_id + WHERE c.telegram_id = %s + ORDER BY c.id DESC + """, + (int(telegram_id),), + ).fetchall() + return [dict(r) for r in rows] + + def get_xui_wg_config( + self, config_id: int, *, telegram_id: int | None = None + ) -> dict[str, Any] | None: + with self.pool.connection() as conn: + if telegram_id is None: + row = conn.execute( + """ + SELECT id, telegram_id, server_id, email, sub_id, + subscription_url, remark, enabled, expire_at, + traffic_bytes, created_at + FROM xui_wg_configs + WHERE id = %s + """, + (int(config_id),), + ).fetchone() + else: + row = conn.execute( + """ + SELECT id, telegram_id, server_id, email, sub_id, + subscription_url, remark, enabled, expire_at, + traffic_bytes, created_at + FROM xui_wg_configs + WHERE id = %s AND telegram_id = %s + """, + (int(config_id), int(telegram_id)), + ).fetchone() + return dict(row) if row else None + + def create_xui_wg_config( + self, + *, + telegram_id: int, + server_id: int, + email: str, + sub_id: str, + subscription_url: str, + remark: str = "", + enabled: bool = True, + expire_at: datetime | None = None, + traffic_bytes: int = 0, + ) -> dict[str, Any]: + with self.pool.connection() as conn: + row = conn.execute( + """ + INSERT INTO xui_wg_configs ( + telegram_id, server_id, email, sub_id, subscription_url, remark, + enabled, expire_at, traffic_bytes + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) + RETURNING id, telegram_id, server_id, email, sub_id, + subscription_url, remark, enabled, expire_at, + traffic_bytes, created_at + """, + ( + int(telegram_id), + int(server_id), + email, + sub_id, + subscription_url, + (remark or "").strip() or None, + bool(enabled), + expire_at, + int(traffic_bytes or 0), + ), + ).fetchone() + conn.commit() + return dict(row) + + def set_xui_wg_config_enabled( + self, config_id: int, telegram_id: int, enabled: bool + ) -> dict[str, Any] | None: + with self.pool.connection() as conn: + row = conn.execute( + """ + UPDATE xui_wg_configs + SET enabled = %s + WHERE id = %s AND telegram_id = %s + RETURNING id, telegram_id, server_id, email, sub_id, + subscription_url, remark, enabled, expire_at, + traffic_bytes, created_at + """, + (bool(enabled), int(config_id), int(telegram_id)), + ).fetchone() + conn.commit() + return dict(row) if row else None + + def update_xui_wg_config_limits( + self, + config_id: int, + *, + expire_at: datetime | None = None, + traffic_bytes: int | None = None, + ) -> None: + sets: list[str] = [] + vals: list[Any] = [] + if expire_at is not None: + sets.append("expire_at = %s") + vals.append(expire_at) + if traffic_bytes is not None: + sets.append("traffic_bytes = %s") + vals.append(int(traffic_bytes)) + if not sets: + return + vals.append(int(config_id)) + with self.pool.connection() as conn: + conn.execute( + f"UPDATE xui_wg_configs SET {', '.join(sets)} WHERE id = %s", + tuple(vals), + ) + conn.commit() + + def delete_xui_wg_config(self, config_id: int, telegram_id: int) -> bool: + with self.pool.connection() as conn: + cur = conn.execute( + "DELETE FROM xui_wg_configs WHERE id = %s AND telegram_id = %s", + (int(config_id), int(telegram_id)), + ) + conn.commit() + return cur.rowcount > 0 + + # ─── AmneziaWG (AWG 2.0) via Amnezia Web Panel ───────────────────────── + + def list_awg_servers(self, *, enabled_only: bool = False) -> list[dict[str, Any]]: + where = "WHERE enabled = TRUE" if enabled_only else "" + with self.pool.connection() as conn: + rows = conn.execute( + f""" + SELECT id, name, api_url, api_token, panel_server_id, protocol, + enabled, verify_ssl, + last_check_at, last_check_ok, last_check_msg, + created_at, updated_at + FROM awg_servers + {where} + ORDER BY id ASC + """ + ).fetchall() + return [dict(r) for r in rows] + + def get_awg_server(self, server_id: int) -> dict[str, Any] | None: + with self.pool.connection() as conn: + row = conn.execute( + """ + SELECT id, name, api_url, api_token, panel_server_id, protocol, + enabled, verify_ssl, + last_check_at, last_check_ok, last_check_msg, + created_at, updated_at + FROM awg_servers + WHERE id = %s + """, + (int(server_id),), + ).fetchone() + return dict(row) if row else None + + def create_awg_server( + self, + *, + name: str, + api_url: str, + api_token: str = "", + panel_server_id: int = 0, + protocol: str = "awg2", + enabled: bool = True, + verify_ssl: bool = True, + ) -> dict[str, Any]: + with self.pool.connection() as conn: + row = conn.execute( + """ + INSERT INTO awg_servers ( + name, api_url, api_token, panel_server_id, protocol, + enabled, verify_ssl + ) + VALUES (%s, %s, %s, %s, %s, %s, %s) + RETURNING id, name, api_url, api_token, panel_server_id, protocol, + enabled, verify_ssl, + last_check_at, last_check_ok, last_check_msg, + created_at, updated_at + """, + ( + name.strip(), + api_url.strip(), + (api_token or "").strip(), + int(panel_server_id), + (protocol or "awg2").strip() or "awg2", + bool(enabled), + bool(verify_ssl), + ), + ).fetchone() + conn.commit() + return dict(row) + + def update_awg_server(self, server_id: int, **fields: Any) -> dict[str, Any] | None: + allowed = { + "name", + "api_url", + "api_token", + "panel_server_id", + "protocol", + "enabled", + "verify_ssl", + "last_check_at", + "last_check_ok", + "last_check_msg", + } + updates = {k: v for k, v in fields.items() if k in allowed} + if not updates: + return self.get_awg_server(server_id) + cols = [] + vals: list[Any] = [] + for k, v in updates.items(): + cols.append(f"{k} = %s") + vals.append(v) + cols.append("updated_at = NOW()") + vals.append(int(server_id)) + with self.pool.connection() as conn: + row = conn.execute( + f""" + UPDATE awg_servers + SET {', '.join(cols)} + WHERE id = %s + RETURNING id, name, api_url, api_token, panel_server_id, protocol, + enabled, verify_ssl, + last_check_at, last_check_ok, last_check_msg, + created_at, updated_at + """, + tuple(vals), + ).fetchone() + conn.commit() + return dict(row) if row else None + + def delete_awg_server(self, server_id: int) -> bool: + with self.pool.connection() as conn: + cur = conn.execute( + "DELETE FROM awg_servers WHERE id = %s", + (int(server_id),), + ) + conn.commit() + return cur.rowcount > 0 + + def set_awg_server_check( + self, + server_id: int, + *, + ok: bool, + message: str, + ) -> None: + with self.pool.connection() as conn: + conn.execute( + """ + UPDATE awg_servers + SET last_check_at = NOW(), + last_check_ok = %s, + last_check_msg = %s, + updated_at = NOW() + WHERE id = %s + """, + (bool(ok), (message or "")[:500], int(server_id)), + ) + conn.commit() + + def count_awg_configs(self, telegram_id: int) -> int: + with self.pool.connection() as conn: + row = conn.execute( + "SELECT COUNT(*) AS c FROM awg_configs WHERE telegram_id = %s", + (int(telegram_id),), + ).fetchone() + return int(row["c"] if row else 0) + + def list_awg_configs(self, telegram_id: int) -> list[dict[str, Any]]: + with self.pool.connection() as conn: + rows = conn.execute( + """ + SELECT c.id, c.telegram_id, c.server_id, c.client_id, c.client_name, + c.remark, c.config_text, c.vpn_link, c.protocol, c.enabled, + c.expire_at, c.created_at, + s.name AS server_name + FROM awg_configs c + LEFT JOIN awg_servers s ON s.id = c.server_id + WHERE c.telegram_id = %s + ORDER BY c.id DESC + """, + (int(telegram_id),), + ).fetchall() + return [dict(r) for r in rows] + + def get_awg_config( + self, config_id: int, *, telegram_id: int | None = None + ) -> dict[str, Any] | None: + with self.pool.connection() as conn: + if telegram_id is None: + row = conn.execute( + """ + SELECT id, telegram_id, server_id, client_id, client_name, + remark, config_text, vpn_link, protocol, enabled, + expire_at, created_at + FROM awg_configs + WHERE id = %s + """, + (int(config_id),), + ).fetchone() + else: + row = conn.execute( + """ + SELECT id, telegram_id, server_id, client_id, client_name, + remark, config_text, vpn_link, protocol, enabled, + expire_at, created_at + FROM awg_configs + WHERE id = %s AND telegram_id = %s + """, + (int(config_id), int(telegram_id)), + ).fetchone() + return dict(row) if row else None + + def create_awg_config( + self, + *, + telegram_id: int, + server_id: int, + client_id: str, + client_name: str = "", + remark: str = "", + config_text: str = "", + vpn_link: str = "", + protocol: str = "awg2", + enabled: bool = True, + expire_at: datetime | None = None, + ) -> dict[str, Any]: + with self.pool.connection() as conn: + row = conn.execute( + """ + INSERT INTO awg_configs ( + telegram_id, server_id, client_id, client_name, remark, + config_text, vpn_link, protocol, enabled, expire_at + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + RETURNING id, telegram_id, server_id, client_id, client_name, + remark, config_text, vpn_link, protocol, enabled, + expire_at, created_at + """, + ( + int(telegram_id), + int(server_id), + client_id, + (client_name or "").strip(), + (remark or "").strip() or None, + config_text or "", + vpn_link or "", + (protocol or "awg2").strip() or "awg2", + bool(enabled), + expire_at, + ), + ).fetchone() + conn.commit() + return dict(row) + + def update_awg_config(self, config_id: int, **fields: Any) -> dict[str, Any] | None: + allowed = { + "client_name", + "remark", + "config_text", + "vpn_link", + "protocol", + "enabled", + "expire_at", + } + updates = {k: v for k, v in fields.items() if k in allowed} + if not updates: + return self.get_awg_config(config_id) + cols = [] + vals: list[Any] = [] + for k, v in updates.items(): + cols.append(f"{k} = %s") + vals.append(v) + vals.append(int(config_id)) + with self.pool.connection() as conn: + row = conn.execute( + f""" + UPDATE awg_configs + SET {', '.join(cols)} + WHERE id = %s + RETURNING id, telegram_id, server_id, client_id, client_name, + remark, config_text, vpn_link, protocol, enabled, + expire_at, created_at + """, + tuple(vals), + ).fetchone() + conn.commit() + return dict(row) if row else None + + def delete_awg_config(self, config_id: int, telegram_id: int) -> bool: + with self.pool.connection() as conn: + cur = conn.execute( + "DELETE FROM awg_configs WHERE id = %s AND telegram_id = %s", + (int(config_id), int(telegram_id)), + ) + conn.commit() + return cur.rowcount > 0 + + def list_telegram_ids_with_enabled_vpn_configs(self, *, limit: int = 500) -> list[int]: + """Пользователи с включёнными WG или AWG конфигами (для фонового enforce).""" + with self.pool.connection() as conn: + rows = conn.execute( + """ + SELECT DISTINCT telegram_id FROM ( + SELECT telegram_id FROM xui_wg_configs WHERE enabled = TRUE + UNION + SELECT telegram_id FROM awg_configs WHERE enabled = TRUE + ) t + ORDER BY telegram_id + LIMIT %s + """, + (int(limit),), + ).fetchall() + return [int(r["telegram_id"]) for r in rows] + + # ─── Growth: ban / referral / promo / trial / password reset / audit ─── + + def set_banned(self, telegram_id: int, banned: bool) -> BotUser: + with self.pool.connection() as conn: + self._ensure_user_conn(conn, telegram_id) + conn.execute( + "UPDATE users SET banned = %s WHERE telegram_id = %s", + (bool(banned), telegram_id), + ) + if banned: + conn.execute( + "DELETE FROM user_sessions WHERE telegram_id = %s", + (telegram_id,), + ) + conn.execute( + """ + UPDATE users SET session_token = NULL, session_expires = NULL + WHERE telegram_id = %s + """, + (telegram_id,), + ) + conn.commit() + user = self.get_user(telegram_id) + assert user is not None + return user + + def delete_user(self, telegram_id: int) -> bool: + """Полностью удалить пользователя и связанные записи. False если не найден.""" + tid = int(telegram_id) + with self.pool.connection() as conn: + row = conn.execute( + "SELECT telegram_id FROM users WHERE telegram_id = %s", + (tid,), + ).fetchone() + if not row: + return False + + # Снять ссылки с других пользователей + conn.execute( + "UPDATE users SET referred_by = NULL WHERE referred_by = %s", + (tid,), + ) + conn.execute( + "UPDATE topup_codes SET used_by = NULL WHERE used_by = %s", + (tid,), + ) + conn.execute( + "UPDATE login_qr SET telegram_id = NULL WHERE telegram_id = %s", + (tid,), + ) + + # Зависимые таблицы без ON DELETE CASCADE + conn.execute( + "DELETE FROM referral_rewards WHERE referrer_id = %s OR referee_id = %s", + (tid, tid), + ) + conn.execute("DELETE FROM tickets WHERE telegram_id = %s", (tid,)) + conn.execute("DELETE FROM xui_wg_configs WHERE telegram_id = %s", (tid,)) + conn.execute("DELETE FROM awg_configs WHERE telegram_id = %s", (tid,)) + conn.execute("DELETE FROM transactions WHERE telegram_id = %s", (tid,)) + conn.execute("DELETE FROM purchases WHERE telegram_id = %s", (tid,)) + conn.execute("DELETE FROM invoices WHERE telegram_id = %s", (tid,)) + conn.execute("DELETE FROM digiseller_payments WHERE telegram_id = %s", (tid,)) + conn.execute("DELETE FROM payment_events WHERE telegram_id = %s", (tid,)) + conn.execute("DELETE FROM webauthn_challenges WHERE telegram_id = %s", (tid,)) + conn.execute("DELETE FROM user_sessions WHERE telegram_id = %s", (tid,)) + conn.execute("DELETE FROM webauthn_credentials WHERE telegram_id = %s", (tid,)) + conn.execute("DELETE FROM promo_redemptions WHERE telegram_id = %s", (tid,)) + conn.execute("DELETE FROM password_reset_tokens WHERE telegram_id = %s", (tid,)) + conn.execute("DELETE FROM users WHERE telegram_id = %s", (tid,)) + conn.commit() + return True + + def set_notify_prefs( + self, + telegram_id: int, + *, + notify_telegram: bool | None = None, + notify_email: bool | None = None, + ) -> BotUser: + user = self.ensure_user(telegram_id) + tg = user.notify_telegram if notify_telegram is None else bool(notify_telegram) + em = user.notify_email if notify_email is None else bool(notify_email) + with self.pool.connection() as conn: + conn.execute( + """ + UPDATE users + SET notify_telegram = %s, notify_email = %s + WHERE telegram_id = %s + """, + (tg, em, telegram_id), + ) + conn.commit() + updated = self.get_user(telegram_id) + assert updated is not None + return updated + + def ensure_referral_code(self, telegram_id: int) -> str: + user = self.ensure_user(telegram_id) + if user.referral_code: + return user.referral_code + for _ in range(8): + code = secrets.token_urlsafe(6).replace("-", "").replace("_", "")[:10].lower() + with self.pool.connection() as conn: + try: + conn.execute( + """ + UPDATE users SET referral_code = %s + WHERE telegram_id = %s AND referral_code IS NULL + """, + (code, telegram_id), + ) + conn.commit() + except Exception: # noqa: BLE001 + conn.rollback() + continue + fresh = self.get_user(telegram_id) + if fresh and fresh.referral_code: + return fresh.referral_code + raise AuthError("Не удалось создать реферальный код") + + def get_user_by_referral_code(self, code: str) -> BotUser | None: + raw = (code or "").strip().lower() + if not raw: + return None + with self.pool.connection() as conn: + row = conn.execute( + "SELECT * FROM users WHERE lower(referral_code) = %s", + (raw,), + ).fetchone() + return self._to_user(row) if row else None + + def attach_referrer(self, telegram_id: int, referrer_code: str) -> bool: + """Привязать реферера только новым пользователям без referred_by.""" + referrer = self.get_user_by_referral_code(referrer_code) + if not referrer or referrer.telegram_id == telegram_id: + return False + with self.pool.connection() as conn: + row = conn.execute( + """ + UPDATE users + SET referred_by = %s + WHERE telegram_id = %s + AND referred_by IS NULL + AND created_at > NOW() - INTERVAL '7 days' + RETURNING telegram_id + """, + (referrer.telegram_id, telegram_id), + ).fetchone() + conn.commit() + return row is not None + + def grant_referral_rewards_on_first_purchase( + self, + referee_id: int, + *, + referrer_bonus: float, + referee_bonus: float, + ) -> None: + user = self.get_user(referee_id) + if not user or not user.referred_by: + return + referrer_id = int(user.referred_by) + with self.pool.connection() as conn: + exists = conn.execute( + """ + SELECT 1 FROM referral_rewards + WHERE referee_id = %s AND kind = 'first_purchase' + """, + (referee_id,), + ).fetchone() + if exists: + return + try: + conn.execute( + """ + INSERT INTO referral_rewards (referrer_id, referee_id, amount, kind) + VALUES (%s, %s, %s, 'first_purchase') + """, + (referrer_id, referee_id, float(referrer_bonus)), + ) + except Exception: # noqa: BLE001 + conn.rollback() + return + conn.commit() + if referrer_bonus > 0: + self.add_balance( + referrer_id, + referrer_bonus, + kind="referral_bonus", + meta=f"ref:{referee_id}", + ) + if referee_bonus > 0: + self.add_balance( + referee_id, + referee_bonus, + kind="referral_welcome", + meta=f"from:{referrer_id}", + ) + + def mark_trial_used(self, telegram_id: int) -> None: + with self.pool.connection() as conn: + conn.execute( + """ + UPDATE users SET trial_used_at = %s + WHERE telegram_id = %s AND trial_used_at IS NULL + """, + (_utc_now(), telegram_id), + ) + conn.commit() + + def create_promo_code( + self, + *, + code: str, + kind: str, + value: float, + max_uses: int | None = None, + tariff_id: str | None = None, + expires_at: datetime | None = None, + note: str | None = None, + ) -> dict[str, Any]: + code_norm = (code or "").strip().upper() + if len(code_norm) < 3: + raise AuthError("Код слишком короткий") + kind_n = (kind or "").strip().lower() + if kind_n not in {"percent", "fixed", "days"}: + raise AuthError("kind: percent | fixed | days") + with self.pool.connection() as conn: + row = conn.execute( + """ + INSERT INTO promo_codes ( + code, kind, value, max_uses, tariff_id, expires_at, note + ) VALUES (%s, %s, %s, %s, %s, %s, %s) + RETURNING * + """, + ( + code_norm, + kind_n, + float(value), + max_uses, + tariff_id or None, + expires_at, + (note or "")[:200] or None, + ), + ).fetchone() + conn.commit() + assert row is not None + return dict(row) + + def list_promo_codes(self, *, limit: int = 100) -> list[dict[str, Any]]: + with self.pool.connection() as conn: + rows = conn.execute( + """ + SELECT * FROM promo_codes + ORDER BY id DESC + LIMIT %s + """, + (max(1, min(limit, 200)),), + ).fetchall() + return [dict(r) for r in rows] + + def set_promo_active(self, promo_id: int, active: bool) -> None: + with self.pool.connection() as conn: + conn.execute( + "UPDATE promo_codes SET active = %s WHERE id = %s", + (bool(active), int(promo_id)), + ) + conn.commit() + + def apply_promo_to_price( + self, + *, + code: str, + telegram_id: int, + tariff_id: str, + price: float, + days: int, + ) -> tuple[float, int, dict[str, Any]]: + """Вернуть (new_price, bonus_days, promo_row).""" + code_norm = (code or "").strip().upper() + if not code_norm: + raise AuthError("Укажите промокод") + now = _utc_now() + with self.pool.connection() as conn: + promo = conn.execute( + "SELECT * FROM promo_codes WHERE lower(code) = lower(%s)", + (code_norm,), + ).fetchone() + if not promo or not promo.get("active"): + raise AuthError("Промокод не найден или выключен") + if promo.get("expires_at") and promo["expires_at"] <= now: + raise AuthError("Промокод истёк") + if promo.get("tariff_id") and str(promo["tariff_id"]) != str(tariff_id): + raise AuthError("Промокод не для этого тарифа") + max_uses = promo.get("max_uses") + if max_uses is not None and int(promo.get("used_count") or 0) >= int(max_uses): + raise AuthError("Лимит использований промокода исчерпан") + used = conn.execute( + """ + SELECT 1 FROM promo_redemptions + WHERE promo_id = %s AND telegram_id = %s + """, + (promo["id"], telegram_id), + ).fetchone() + if used: + raise AuthError("Вы уже использовали этот промокод") + + kind = str(promo["kind"]) + value = float(promo["value"]) + new_price = float(price) + bonus_days = 0 + if kind == "percent": + new_price = max(0.0, price * (1.0 - min(100.0, value) / 100.0)) + elif kind == "fixed": + new_price = max(0.0, price - value) + elif kind == "days": + bonus_days = max(0, int(value)) + return round(new_price, 2), bonus_days, dict(promo) + + def redeem_promo(self, *, promo_id: int, telegram_id: int, amount_saved: float) -> None: + with self.pool.connection() as conn: + conn.execute( + """ + INSERT INTO promo_redemptions (promo_id, telegram_id, amount_saved) + VALUES (%s, %s, %s) + ON CONFLICT (promo_id, telegram_id) DO NOTHING + """, + (int(promo_id), telegram_id, float(amount_saved)), + ) + conn.execute( + """ + UPDATE promo_codes SET used_count = used_count + 1 + WHERE id = %s + """, + (int(promo_id),), + ) + conn.commit() + + def create_password_reset_token(self, telegram_id: int) -> str: + token = secrets.token_urlsafe(32) + expires = _utc_now() + timedelta(hours=1) + with self.pool.connection() as conn: + conn.execute( + "DELETE FROM password_reset_tokens WHERE telegram_id = %s OR expires_at < %s", + (telegram_id, _utc_now()), + ) + conn.execute( + """ + INSERT INTO password_reset_tokens (token, telegram_id, expires_at) + VALUES (%s, %s, %s) + """, + (token, telegram_id, expires), + ) + conn.commit() + return token + + def consume_password_reset_token(self, token: str) -> int: + token = (token or "").strip() + if not token: + raise AuthError("Некорректный токен") + with self.pool.connection() as conn: + row = conn.execute( + """ + UPDATE password_reset_tokens + SET used_at = %s + WHERE token = %s AND used_at IS NULL AND expires_at > %s + RETURNING telegram_id + """, + (_utc_now(), token, _utc_now()), + ).fetchone() + conn.commit() + if not row: + raise AuthError("Ссылка недействительна или истекла") + return int(row["telegram_id"]) + + def set_password_hash(self, telegram_id: int, password: str) -> None: + if len(password) < 6: + raise AuthError("Пароль минимум 6 символов") + pwd_hash = hash_password(password) + with self.pool.connection() as conn: + conn.execute( + "UPDATE users SET password_hash = %s WHERE telegram_id = %s", + (pwd_hash, telegram_id), + ) + conn.commit() + + def get_user_by_email(self, email: str) -> BotUser | None: + raw = (email or "").strip().lower() + if not raw: + return None + with self.pool.connection() as conn: + row = conn.execute( + "SELECT * FROM users WHERE lower(email) = %s", + (raw,), + ).fetchone() + return self._to_user(row) if row else None + + def log_payment_event( + self, + *, + provider: str, + event_type: str, + telegram_id: int | None = None, + amount: float | None = None, + invoice_ref: str | None = None, + status: str = "ok", + meta: str | None = None, + ) -> None: + with self.pool.connection() as conn: + conn.execute( + """ + INSERT INTO payment_events ( + provider, event_type, telegram_id, amount, invoice_ref, status, meta + ) VALUES (%s, %s, %s, %s, %s, %s, %s) + """, + ( + (provider or "")[:40], + (event_type or "")[:60], + telegram_id, + amount, + (invoice_ref or "")[:120] or None, + (status or "ok")[:40], + (meta or "")[:500] or None, + ), + ) + conn.commit() + + def list_payment_events(self, *, limit: int = 50) -> list[dict[str, Any]]: + with self.pool.connection() as conn: + rows = conn.execute( + """ + SELECT * FROM payment_events + ORDER BY id DESC + LIMIT %s + """, + (max(1, min(limit, 200)),), + ).fetchall() + return [dict(r) for r in rows] + + @staticmethod + def _ensure_user_conn(conn, telegram_id: int) -> None: + row = conn.execute( + "SELECT 1 FROM users WHERE telegram_id = %s", + (telegram_id,), + ).fetchone() + if not row: + conn.execute( + """ + INSERT INTO users (telegram_id, username, full_name, balance, created_at) + VALUES (%s, NULL, NULL, 0, %s) + ON CONFLICT (telegram_id) DO NOTHING + """, + (telegram_id, _utc_now()), + ) diff --git a/services/devices.py b/services/devices.py new file mode 100644 index 0000000..a0f51cf --- /dev/null +++ b/services/devices.py @@ -0,0 +1,91 @@ +"""Лимит устройств (HWID): база 10, каждое сверх — 50 ₽.""" + +from __future__ import annotations + +from dataclasses import dataclass + +BASE_DEVICES = 10 +EXTRA_DEVICE_PRICE_RUB = 50.0 + +# Сколько устройств панель может зарегистрировать максимум (мягкий потолок). +# Реальная квота: BASE_DEVICES + оплаченные слоты; при превышении — DISABLE. +PANEL_SOFT_CAP = 100 + + +@dataclass(frozen=True, slots=True) +class DevicePack: + slots: int + price: float + + @property + def id(self) -> str: + return f"dev_{self.slots}" + + @property + def title(self) -> str: + return f"+{self.slots} устр." + + def format_price(self) -> str: + if self.price == int(self.price): + return f"{int(self.price)} ₽/мес" + return f"{self.price:.2f} ₽/мес" + + +DEVICE_PACKS: tuple[DevicePack, ...] = tuple( + DevicePack(slots=n, price=EXTRA_DEVICE_PRICE_RUB * n) for n in (1, 2, 3, 5) +) + + +def get_device_pack(pack_id: str) -> DevicePack | None: + for p in DEVICE_PACKS: + if p.id == pack_id or str(p.slots) == pack_id: + return p + return None + + +@dataclass(frozen=True, slots=True) +class DeviceUsage: + used: int + allowed: int + extra_paid: int + over_limit: bool + + @property + def excess(self) -> int: + return max(0, self.used - self.allowed) + + @property + def excess_price(self) -> float: + return self.excess * EXTRA_DEVICE_PRICE_RUB + + def format_line(self) -> str: + line = ( + f"📱 Устройства: {self.used} / {self.allowed} " + f"(база {BASE_DEVICES}" + + (f" + {self.extra_paid} доп." if self.extra_paid else "") + + ")" + ) + if self.over_limit: + line += ( + f"\n⚠️ Превышение: +{self.excess} · " + f"к оплате {int(self.excess_price)} ₽\n" + "Подписка отключена, пока не оплатишь лишние устройства " + f"({int(EXTRA_DEVICE_PRICE_RUB)} ₽ за каждое)." + ) + return line + + def as_dict(self) -> dict: + return { + "used": self.used, + "allowed": self.allowed, + "base": BASE_DEVICES, + "extra_paid": self.extra_paid, + "over_limit": self.over_limit, + "excess": self.excess, + "excess_price": self.excess_price, + "slot_price": EXTRA_DEVICE_PRICE_RUB, + } + + +def allowed_devices(extra_paid: int) -> int: + return BASE_DEVICES + max(0, int(extra_paid or 0)) diff --git a/services/digiseller.py b/services/digiseller.py new file mode 100644 index 0000000..fb22fbc --- /dev/null +++ b/services/digiseller.py @@ -0,0 +1,273 @@ +"""Клиент Digiseller: проверка оплаты по уникальному коду.""" + +from __future__ import annotations + +import base64 +import hashlib +import logging +import time +from dataclasses import dataclass +from typing import Any +from urllib.parse import parse_qs, urlencode, urlparse, urlunparse + +import httpx + +logger = logging.getLogger(__name__) + +API_BASE = "https://api.digiseller.com" +API_BASE_FALLBACK = "https://oplata.info" + + +class DigisellerError(RuntimeError): + pass + + +@dataclass(frozen=True, slots=True) +class DigisellerPurchase: + unique_code: str + inv: int + product_id: int + amount: float + currency: str + cnt_goods: float + email: str | None + state: int + raw: dict[str, Any] + + @property + def delivered(self) -> bool: + # 2/3/4 — уже отмечено как выдано + return self.state in {2, 3, 4} + + +class Digiseller: + def __init__( + self, + seller_id: str | int, + api_key: str, + *, + product_ids: tuple[int, ...] = (), + pay_url: str = "", + credit_from: str = "cnt_goods", + ) -> None: + self.seller_id = str(seller_id or "").strip() + self.api_key = (api_key or "").strip() + self.product_ids = product_ids + self.pay_url = (pay_url or "").strip() + self.credit_from = (credit_from or "cnt_goods").strip().lower() + self._token: str | None = None + self._token_expires: float = 0.0 + + @property + def enabled(self) -> bool: + return bool(self.seller_id and self.api_key) + + def _sign(self, timestamp: int) -> str: + raw = f"{self.api_key}{timestamp}".encode("utf-8") + return hashlib.sha256(raw).hexdigest() + + async def _login(self) -> str: + now = time.time() + if self._token and now < self._token_expires - 60: + return self._token + + timestamp = int(now) + payload = { + "seller_id": int(self.seller_id), + "timestamp": timestamp, + "sign": self._sign(timestamp), + } + data = await self._request("POST", "/api/apilogin", json_body=payload, auth=False) + if int(data.get("retval") or -1) != 0 or not data.get("token"): + raise DigisellerError(data.get("desc") or data.get("retdesc") or "login failed") + self._token = str(data["token"]) + # токен живёт ~2 часа + self._token_expires = now + 110 * 60 + return self._token + + async def _request( + self, + method: str, + path: str, + *, + json_body: dict[str, Any] | None = None, + params: dict[str, Any] | None = None, + auth: bool = True, + ) -> dict[str, Any]: + if not self.enabled: + raise DigisellerError("Digiseller не настроен") + + headers = {"Accept": "application/json", "Content-Type": "application/json"} + query = dict(params or {}) + if auth: + query["token"] = await self._login() + + last_exc: Exception | None = None + for base in (API_BASE, API_BASE_FALLBACK): + try: + async with httpx.AsyncClient(timeout=30.0) as client: + resp = await client.request( + method, + f"{base}{path}", + headers=headers, + params=query or None, + json=json_body, + ) + if resp.status_code == 401 and auth: + self._token = None + query["token"] = await self._login() + resp = await client.request( + method, + f"{base}{path}", + headers=headers, + params=query, + json=json_body, + ) + data = resp.json() + if not isinstance(data, dict): + raise DigisellerError(f"bad response: {resp.text[:200]}") + return data + except Exception as exc: # noqa: BLE001 + last_exc = exc + logger.warning("Digiseller %s %s via %s failed: %s", method, path, base, exc) + raise DigisellerError(str(last_exc or "request failed")) + + async def get_by_unique_code(self, unique_code: str) -> DigisellerPurchase: + code = "".join(ch for ch in unique_code.strip() if ch.isalnum()) + if len(code) < 10: + raise DigisellerError("Некорректный уникальный код") + + data = await self._request( + "GET", + f"/api/purchases/unique-code/{code}", + ) + retval = int(data.get("retval") if data.get("retval") is not None else -1) + if retval != 0: + raise DigisellerError( + str(data.get("retdesc") or data.get("desc") or f"код ошибки {retval}") + ) + + state_obj = data.get("unique_code_state") or {} + state = int(state_obj.get("state") or 0) + inv = int(data.get("inv") or 0) + product_id = int(data.get("id_goods") or 0) + if not inv: + raise DigisellerError("В ответе Digiseller нет номера счёта (inv)") + + return DigisellerPurchase( + unique_code=code, + inv=inv, + product_id=product_id, + amount=float(data.get("amount") or 0), + currency=str(data.get("type_curr") or ""), + cnt_goods=float(data.get("cnt_goods") or 0), + email=(str(data.get("email")).strip() if data.get("email") else None), + state=state, + raw=data, + ) + + def assert_payable(self, purchase: DigisellerPurchase) -> None: + """Проверка, что код можно принять к зачислению.""" + # 1 — ещё не верифицирован (первый запрос), 5 — верифицирован, товар не выдан + # 2/3 — уже отмечен выданным (допускаем для дозачисления после сбоя) + # 4 — оспорен + if purchase.state == 4: + raise DigisellerError("Оплата по этому коду оспорена") + if purchase.state not in {1, 2, 3, 5}: + raise DigisellerError( + f"Код ещё не готов к выдаче (state={purchase.state}). " + "Оплати товар и попробуй снова." + ) + date_pay = str((purchase.raw or {}).get("date_pay") or "").strip() + if not date_pay and purchase.state == 1: + raise DigisellerError("Код ещё не оплачен") + + async def mark_delivered(self, unique_code: str) -> None: + code = "".join(ch for ch in unique_code.strip() if ch.isalnum()) + data = await self._request( + "PUT", + f"/api/purchases/unique-code/{code}/deliver", + ) + retval = int(data.get("retval") if data.get("retval") is not None else -1) + # 4 — уже выдано ранее (идемпотентно ок) + if retval not in (0, 4): + raise DigisellerError( + str(data.get("retdesc") or data.get("desc") or f"deliver error {retval}") + ) + + def credit_amount(self, purchase: DigisellerPurchase) -> float: + """Сколько рублей зачислить на баланс бота.""" + if self.product_ids and purchase.product_id not in self.product_ids: + raise DigisellerError( + f"Товар id={purchase.product_id} не разрешён для пополнения" + ) + + if self.credit_from == "amount": + value = purchase.amount + else: + # Типичная схема: товар без фикс. цены, cnt_goods = сумма в ₽ + value = purchase.cnt_goods if purchase.cnt_goods > 0 else purchase.amount + + if value <= 0: + raise DigisellerError("Сумма пополнения равна 0") + return float(value) + + def default_pay_url(self) -> str: + if self.pay_url: + return self.pay_url + if self.product_ids: + return f"https://oplata.info/asp2/pay.asp?id_d={self.product_ids[0]}" + return "https://my.digiseller.com/" + + def pay_url_for(self, telegram_id: int) -> str: + """Ссылка на оплату с привязкой к Telegram ID (уходит в query_string).""" + base = self.default_pay_url() + parsed = urlparse(base) + q = parse_qs(parsed.query, keep_blank_values=True) + q["telegram_id"] = [str(int(telegram_id))] + # совместимость с разными интеграциями Digiseller + q["tg_id"] = [str(int(telegram_id))] + new_query = urlencode({k: v[-1] for k, v in q.items()}) + return urlunparse(parsed._replace(query=new_query)) + + @staticmethod + def extract_telegram_id(purchase: DigisellerPurchase) -> int | None: + """Достать telegram_id из query_string / options платежа.""" + raw = purchase.raw or {} + + qs_b64 = str(raw.get("query_string") or "").strip() + if qs_b64: + try: + pad = "=" * (-len(qs_b64) % 4) + decoded = base64.b64decode(qs_b64 + pad).decode("utf-8", "ignore") + params = parse_qs(decoded, keep_blank_values=True) + for key in ("telegram_id", "tg_id", "tg", "user_id", "uid"): + vals = params.get(key) or [] + if vals: + tid = int(str(vals[0]).strip()) + if tid > 0: + return tid + except Exception: # noqa: BLE001 + logger.debug("Digiseller query_string parse failed", exc_info=True) + + for opt in raw.get("options") or []: + if not isinstance(opt, dict): + continue + name = str(opt.get("name") or "").strip().lower() + value = str(opt.get("value") or "").strip() + if not value: + continue + if any( + x in name + for x in ("telegram", "телеграм", "tg id", "tg_id", "userid", "user id") + ): + digits = "".join(ch for ch in value if ch.isdigit()) + if digits: + tid = int(digits) + if tid > 0: + return tid + # иногда покупатель просто вписывает числовой ID + if value.isdigit() and len(value) >= 5: + return int(value) + + return None diff --git a/services/digiseller_fulfill.py b/services/digiseller_fulfill.py new file mode 100644 index 0000000..92578a5 --- /dev/null +++ b/services/digiseller_fulfill.py @@ -0,0 +1,127 @@ +"""Автозачисление по уникальному коду Digiseller.""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass + +from aiogram import Bot + +from config import get_settings +from services.db import Database +from services.digiseller import Digiseller, DigisellerError +from services.mailer import notify_important + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True, slots=True) +class DigisellerFulfillResult: + ok: bool + message: str + amount: float = 0.0 + balance: float = 0.0 + telegram_id: int | None = None + already: bool = False + inv: int | None = None + + +async def fulfill_digiseller_unique_code( + *, + db: Database, + digiseller: Digiseller, + unique_code: str, + telegram_id_hint: int | None = None, + bot: Bot | None = None, + notify: bool = True, +) -> DigisellerFulfillResult: + """Проверить код в Digiseller API и зачислить баланс. Идемпотентно.""" + purchase = await digiseller.get_by_unique_code(unique_code) + digiseller.assert_payable(purchase) + amount = digiseller.credit_amount(purchase) + + telegram_id = digiseller.extract_telegram_id(purchase) or telegram_id_hint + if not telegram_id: + raise DigisellerError( + "В платеже нет telegram_id. Открой оплату из бота " + "(ссылка с твоим ID) или введи код вручную в боте." + ) + + if db.digiseller_inv_used(purchase.inv) or db.digiseller_code_used( + purchase.unique_code + ): + balance = db.get_balance(telegram_id) + return DigisellerFulfillResult( + ok=True, + already=True, + message="Этот платёж уже был зачислен ранее.", + amount=amount, + balance=balance, + telegram_id=telegram_id, + inv=purchase.inv, + ) + + recorded = db.record_digiseller_payment( + inv=purchase.inv, + unique_code=purchase.unique_code, + telegram_id=telegram_id, + amount_rub=amount, + product_id=purchase.product_id, + ) + if not recorded: + balance = db.get_balance(telegram_id) + return DigisellerFulfillResult( + ok=True, + already=True, + message="Этот платёж уже был зачислен ранее.", + amount=amount, + balance=balance, + telegram_id=telegram_id, + inv=purchase.inv, + ) + + balance = db.add_balance( + telegram_id, + amount, + kind="digiseller_topup", + meta=f"digi:{purchase.inv}:{purchase.unique_code}", + ) + + try: + await digiseller.mark_delivered(purchase.unique_code) + except DigisellerError as exc: + logger.warning( + "Digiseller deliver failed inv=%s: %s", purchase.inv, exc + ) + + text = ( + f"✅ Баланс пополнен через Digiseller\n\n" + f"+{amount:.0f} ₽\n" + f"Счёт Digiseller: {purchase.inv}\n" + f"Доступно: {balance:.0f} ₽" + ) + + if notify: + try: + settings = get_settings(require_bot_token=False) + await notify_important( + db=db, + settings=settings, + telegram_id=telegram_id, + html=text, + subject="Баланс пополнен — VPN Service", + bot=bot, + ) + except Exception as exc: # noqa: BLE001 + logger.warning( + "Digiseller notify failed tg=%s: %s", telegram_id, exc + ) + + return DigisellerFulfillResult( + ok=True, + message=text, + amount=amount, + balance=balance, + telegram_id=telegram_id, + inv=purchase.inv, + ) diff --git a/services/env_file.py b/services/env_file.py new file mode 100644 index 0000000..fb5f290 --- /dev/null +++ b/services/env_file.py @@ -0,0 +1,194 @@ +"""Чтение/запись allowlist-ключей в .env (админка платежей).""" + +from __future__ import annotations + +import os +import re +from pathlib import Path + +from dotenv import dotenv_values + +from config import ROOT_DIR + +# Ключи, которыми можно управлять из админки +PAYMENT_ENV_KEYS: tuple[str, ...] = ( + "CRYPTO_BOT_TOKEN", + "CRYPTO_PAY_TESTNET", + "HELEKET_MERCHANT_ID", + "HELEKET_API_KEY", + "HELEKET_CALLBACK_URL", + "HELEKET_CURRENCY", + "LAVA_SHOP_ID", + "LAVA_SECRET_KEY", + "LAVA_ADDITIONAL_KEY", + "LAVA_HOOK_URL", + "LAVA_SUCCESS_URL", + "LAVA_FAIL_URL", + "LAVA_EXPIRE_MINUTES", + "DIGISELLER_SELLER_ID", + "DIGISELLER_API_KEY", + "DIGISELLER_PRODUCT_IDS", + "DIGISELLER_PAY_URL", + "DIGISELLER_CREDIT_FROM", + "DIGISELLER_CALLBACK_URL", + "ANYPAY_API_ID", + "ANYPAY_API_KEY", + "ANYPAY_PROJECT_ID", + "ANYPAY_CURRENCY", + "ANYPAY_METHOD", + "ANYPAY_SUCCESS_URL", + "ANYPAY_FAIL_URL", + "ANYPAY_EMAIL", + "ANYPAY_CHECK_IP", + "STARS_ENABLED", + "STARS_RUB_PER_STAR", + "TOPUP_CODE_PAY_URL", +) + +SECRET_ENV_KEYS = frozenset( + { + "CRYPTO_BOT_TOKEN", + "HELEKET_API_KEY", + "LAVA_SECRET_KEY", + "LAVA_ADDITIONAL_KEY", + "DIGISELLER_API_KEY", + "ANYPAY_API_KEY", + "SMTP_PASSWORD", + } +) + +BOOL_ENV_KEYS = frozenset( + {"CRYPTO_PAY_TESTNET", "STARS_ENABLED", "ANYPAY_CHECK_IP", "SMTP_TLS", "SMTP_SSL"} +) + +_KEY_LINE_RE = re.compile( + r"^(?P\s*(?:export\s+)?)(?P[A-Za-z_][A-Za-z0-9_]*)\s*=\s*(?P.*)$" +) + + +def env_file_path() -> Path: + raw = (os.getenv("ENV_FILE") or "").strip() + if raw: + return Path(raw) + return ROOT_DIR / ".env" + + +def env_writable(path: Path | None = None) -> bool: + p = path or env_file_path() + try: + if p.exists(): + return os.access(p, os.W_OK) + parent = p.parent + return parent.exists() and os.access(parent, os.W_OK) + except OSError: + return False + + +def mask_secret(value: str) -> str: + v = (value or "").strip() + if not v: + return "" + if len(v) <= 6: + return "••••••" + return f"{v[:3]}…{v[-4:]}" + + +def read_env_values(path: Path | None = None) -> dict[str, str]: + p = path or env_file_path() + if not p.exists(): + return {} + raw = dotenv_values(p) + out: dict[str, str] = {} + for k, v in raw.items(): + if k is None: + continue + out[str(k)] = "" if v is None else str(v) + return out + + +def _format_env_value(value: str) -> str: + """Значение для .env без лишних кавычек, если не нужны.""" + if value == "": + return "" + if any(ch in value for ch in ' \t#"\'\\$`\n\r'): + escaped = value.replace("\\", "\\\\").replace('"', '\\"') + return f'"{escaped}"' + return value + + +def _write_env_inplace(path: Path, text: str) -> None: + """Перезаписать содержимое файла без rename (Docker bind-mount .env).""" + data = text.encode("utf-8") + with open(path, "r+b") as fh: + fh.truncate(0) + fh.seek(0) + fh.write(data) + fh.flush() + os.fsync(fh.fileno()) + + +def upsert_env_keys( + updates: dict[str, str | None], + *, + path: Path | None = None, + allowlist: tuple[str, ...] = PAYMENT_ENV_KEYS, +) -> Path: + """Записать ключи в .env и в os.environ. None / пустая строка удаляет ключ. + + Пишем in-place: docker volume mount файла не позволяет atomic rename + (Errno 16 Device or resource busy). + """ + p = path or env_file_path() + allowed = frozenset(allowlist) + filtered = {k: v for k, v in updates.items() if k in allowed} + if not filtered: + return p + + if not p.exists(): + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text("", encoding="utf-8") + + raw = p.read_text(encoding="utf-8") + lines = raw.splitlines(keepends=True) + if lines and not lines[-1].endswith(("\n", "\r")): + # нормализуем конец файла при дальнейшей склейке + pass + + pending = dict(filtered) + new_lines: list[str] = [] + for line in lines: + stripped = line.lstrip() + if not stripped or stripped.startswith("#"): + new_lines.append(line) + continue + m = _KEY_LINE_RE.match(line.rstrip("\r\n")) + if not m: + new_lines.append(line) + continue + key = m.group("key") + if key not in pending: + new_lines.append(line) + continue + value = pending.pop(key) + ending = "\n" if line.endswith("\n") else ("\r\n" if line.endswith("\r\n") else "\n") + if value is None or value == "": + os.environ.pop(key, None) + continue + prefix = m.group("prefix") or "" + new_lines.append(f"{prefix}{key}={_format_env_value(value)}{ending}") + os.environ[key] = value + + for key, value in pending.items(): + if value is None or value == "": + os.environ.pop(key, None) + continue + if new_lines and not new_lines[-1].endswith(("\n", "\r")): + new_lines[-1] = new_lines[-1] + "\n" + new_lines.append(f"{key}={_format_env_value(value)}\n") + os.environ[key] = value + + text = "".join(new_lines) + if text and not text.endswith("\n"): + text += "\n" + _write_env_inplace(p, text) + return p diff --git a/services/extras_billing.py b/services/extras_billing.py new file mode 100644 index 0000000..a312998 --- /dev/null +++ b/services/extras_billing.py @@ -0,0 +1,355 @@ +"""Ежемесячная оплата доп. трафика и устройств.""" + +from __future__ import annotations + +import asyncio +import logging +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone + +from aiogram import Bot +from remnawave import RemnawaveSDK +from remnawave.models import UpdateUserRequestDto + +from services.db import BotUser, Database +from services.devices import EXTRA_DEVICE_PRICE_RUB, allowed_devices +from services.mailer import notify_important +from config import get_settings +from services.remnawave_users import find_panel_user, sync_device_policy +from services.traffic import ( + PACK_GB, + PACK_PRICE_RUB, + gb_to_bytes, + ensure_base_limit_bytes, +) + +logger = logging.getLogger(__name__) + +BILLING_PERIOD = timedelta(days=30) + + +def _utc(dt: datetime | None) -> datetime | None: + if dt is None: + return None + if dt.tzinfo is None: + return dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc) + + +def has_extras_subscription(user: BotUser) -> bool: + return user.extra_devices > 0 or user.extra_traffic_packs > 0 + + +def monthly_extras_fee_rub(*, extra_devices: int, extra_traffic_packs: int) -> float: + return max(0, int(extra_devices)) * EXTRA_DEVICE_PRICE_RUB + max( + 0, int(extra_traffic_packs) + ) * PACK_PRICE_RUB + + +def effective_extra_devices(user: BotUser) -> int: + if not has_extras_subscription(user): + return 0 + if not user.extras_active: + return 0 + return max(0, user.extra_devices) + + +def effective_extra_traffic_packs(user: BotUser) -> int: + if not has_extras_subscription(user): + return 0 + if not user.extras_active: + return 0 + return max(0, user.extra_traffic_packs) + + +def extras_monthly_fee(user: BotUser) -> float: + if not has_extras_subscription(user): + return 0.0 + return monthly_extras_fee_rub( + extra_devices=user.extra_devices, + extra_traffic_packs=user.extra_traffic_packs, + ) + + +def format_monthly_suffix() -> str: + return "/мес" + + +def extras_billing_line(user: BotUser) -> str: + if not has_extras_subscription(user): + return "" + fee = int(extras_monthly_fee(user)) + parts: list[str] = [] + if user.extra_traffic_packs: + parts.append(f"+{user.extra_traffic_packs}×500 ГБ") + if user.extra_devices: + parts.append(f"+{user.extra_devices} устр.") + sub = ", ".join(parts) + nxt = _utc(user.extras_next_billing_at) + nxt_s = nxt.strftime("%d.%m.%Y") if nxt else "—" + if not user.extras_active: + return ( + f"\n⚠️ Доп. опции приостановлены (не оплачен период).\n" + f"Подписка: {sub} · {fee} ₽/мес\n" + f"Пополни баланс минимум на {fee} ₽ — списание при открытии кабинета " + f"или автоматически раз в сутки." + ) + return ( + f"\n📅 Доп. опции: {sub} · {fee} ₽/мес\n" + f"Следующее списание: {nxt_s}" + ) + + +def extras_as_dict(user: BotUser) -> dict: + fee = extras_monthly_fee(user) + nxt = _utc(user.extras_next_billing_at) + return { + "active": bool(user.extras_active), + "has_subscription": has_extras_subscription(user), + "extra_traffic_packs": user.extra_traffic_packs, + "extra_devices": user.extra_devices, + "monthly_fee": fee, + "monthly_fee_label": f"{int(fee)} ₽/мес" if fee else "0 ₽/мес", + "next_billing_at": nxt.isoformat() if nxt else None, + "next_billing_label": nxt.strftime("%d.%m.%Y") if nxt else None, + "slot_price_monthly": EXTRA_DEVICE_PRICE_RUB, + "pack_price_monthly": PACK_PRICE_RUB, + "pack_gb": PACK_GB, + "warning": ( + "Доп. трафик и устройства оплачиваются ежемесячно с баланса. " + "При нехватке средств доп. лимиты отключаются до оплаты." + ), + } + + +@dataclass(slots=True) +class ExtrasBillingResult: + charged: bool = False + suspended: bool = False + reactivated: bool = False + amount: float = 0.0 + message: str = "" + + +async def apply_panel_extras( + sdk: RemnawaveSDK, + db: Database, + *, + telegram_id: int, + login: str | None = None, +) -> None: + """Привести лимиты панели в соответствие с оплаченными доп. опциями.""" + from services.traffic import BASE_TRAFFIC_GB + + user = db.get_user(telegram_id) + if user is None: + return + panel_user = await find_panel_user(sdk, telegram_id, login=login or user.login) + if panel_user is None: + return + + subscribed_packs = max(0, user.extra_traffic_packs) + panel_limit = float(panel_user.traffic_limit_bytes or 0) + base_floor = gb_to_bytes(BASE_TRAFFIC_GB) + + if subscribed_packs > 0: + inferred_base = panel_limit - gb_to_bytes(PACK_GB * subscribed_packs) + base_bytes = ensure_base_limit_bytes(max(inferred_base, base_floor)) + if user.extras_active: + target_limit = base_bytes + gb_to_bytes(PACK_GB * subscribed_packs) + else: + target_limit = base_bytes + else: + target_limit = ensure_base_limit_bytes(panel_limit) + + try: + current = float(panel_user.traffic_limit_bytes or 0) + if abs(current - target_limit) > 1: + panel_user = await sdk.users.update_user( + UpdateUserRequestDto( + uuid=panel_user.uuid, + traffic_limit_bytes=target_limit, + ) + ) + except Exception: # noqa: BLE001 + logger.exception("apply_panel_extras traffic telegram_id=%s", telegram_id) + + extra_dev = effective_extra_devices(user) + await sync_device_policy( + sdk, + panel_user, + db=db, + telegram_id=telegram_id, + extra_paid=extra_dev, + device_blocked=user.device_blocked, + ) + + +async def _notify_user( + bot: Bot | None, + telegram_id: int, + text: str, + *, + db: Database | None = None, + subject: str = "VPN Service", +) -> None: + if db is not None: + try: + settings = get_settings(require_bot_token=False) + except SystemExit: + settings = None + if settings is not None: + await notify_important( + db=db, + settings=settings, + telegram_id=telegram_id, + html=text, + subject=subject, + bot=bot, + ) + return + if bot is None or telegram_id <= 0: + return + try: + await bot.send_message(telegram_id, text, parse_mode="HTML") + except Exception: # noqa: BLE001 + logger.warning("extras billing notify failed tg=%s", telegram_id) + + +async def process_user_extras_billing( + db: Database, + sdk: RemnawaveSDK, + telegram_id: int, + *, + bot: Bot | None = None, + login: str | None = None, +) -> ExtrasBillingResult: + user = db.get_user(telegram_id) + if user is None or not has_extras_subscription(user): + return ExtrasBillingResult() + + now = datetime.now(timezone.utc) + fee = extras_monthly_fee(user) + next_at = _utc(user.extras_next_billing_at) + + if not user.extras_active: + if user.balance < fee: + return ExtrasBillingResult() + ok, _bal = db.try_charge( + telegram_id, + fee, + kind="extras_renewal", + meta=f"extras:reactivate:{user.extra_devices}d+{user.extra_traffic_packs}t", + ) + if not ok: + return ExtrasBillingResult() + anchor = now if not next_at or next_at <= now else next_at + new_next = anchor + BILLING_PERIOD + db.set_extras_billing(telegram_id, next_billing_at=new_next, active=True) + await apply_panel_extras(sdk, db, telegram_id=telegram_id, login=login) + await _notify_user( + bot, + telegram_id, + ( + "✅ Доп. опции снова активны\n\n" + f"Списано {int(fee)} ₽ за месяц.\n" + f"Следующее списание: {new_next.strftime('%d.%m.%Y')}" + ), + db=db, + subject="Доп. опции активны — VPN Service", + ) + return ExtrasBillingResult( + charged=True, reactivated=True, amount=fee, message="reactivated" + ) + + if not next_at or next_at > now: + return ExtrasBillingResult() + + ok, _bal = db.try_charge( + telegram_id, + fee, + kind="extras_renewal", + meta=f"extras:{user.extra_devices}d+{user.extra_traffic_packs}t", + ) + if not ok: + db.set_extras_active(telegram_id, False) + await apply_panel_extras(sdk, db, telegram_id=telegram_id, login=login) + await _notify_user( + bot, + telegram_id, + ( + "⚠️ Доп. опции приостановлены\n\n" + f"На балансе не хватает {int(fee)} ₽ для продления.\n" + "Пополни баланс для восстановления лимитов." + ), + db=db, + subject="Доп. опции приостановлены — VPN Service", + ) + return ExtrasBillingResult(suspended=True, amount=fee, message="charge_failed") + + new_next = next_at + BILLING_PERIOD + if new_next <= now: + new_next = now + BILLING_PERIOD + db.set_extras_billing(telegram_id, next_billing_at=new_next, active=True) + await _notify_user( + bot, + telegram_id, + ( + "📅 Продление доп. опций\n\n" + f"Списано {int(fee)} ₽ за месяц.\n" + f"Следующее списание: {new_next.strftime('%d.%m.%Y')}" + ), + db=db, + subject="Продление доп. опций — VPN Service", + ) + return ExtrasBillingResult(charged=True, amount=fee, message="renewed") + + +def on_extras_purchased(db: Database, telegram_id: int) -> None: + """Старт или продление учёта периода после покупки доп. опций.""" + user = db.get_user(telegram_id) + if user is None or not has_extras_subscription(user): + return + if user.extras_next_billing_at is None: + db.set_extras_billing( + telegram_id, + next_billing_at=datetime.now(timezone.utc) + BILLING_PERIOD, + active=True, + ) + else: + db.set_extras_active(telegram_id, True) + + +async def run_extras_billing_cycle( + db: Database, + sdk: RemnawaveSDK, + *, + bot: Bot | None = None, + limit: int = 200, +) -> int: + ids = db.list_users_for_extras_billing(limit=limit) + done = 0 + for tid in ids: + try: + await process_user_extras_billing(db, sdk, tid, bot=bot) + done += 1 + except Exception: # noqa: BLE001 + logger.exception("extras billing failed tg=%s", tid) + return done + + +async def extras_billing_worker( + db: Database, + sdk: RemnawaveSDK, + bot: Bot, + *, + interval_sec: int = 6 * 3600, +) -> None: + while True: + try: + n = await run_extras_billing_cycle(db, sdk, bot=bot) + if n: + logger.info("extras billing processed %s users", n) + except Exception: # noqa: BLE001 + logger.exception("extras billing cycle error") + await asyncio.sleep(interval_sec) diff --git a/services/faq.py b/services/faq.py new file mode 100644 index 0000000..f2a9fef --- /dev/null +++ b/services/faq.py @@ -0,0 +1,280 @@ +"""Часто задаваемые вопросы (FAQ).""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class FaqItem: + id: str + question: str + answer: str + + +FAQ_ITEMS: tuple[FaqItem, ...] = ( + FaqItem( + id="what_is", + question="Что это за VPN?", + answer=( + "Это сервис на базе Remnawave: после оплаты ты получаешь " + "ссылку подписки. Её вставляешь в клиент (Hiddify, Streisand, " + "v2rayNG и др.) — и подключаешься к серверам." + ), + ), + FaqItem( + id="how_start", + question="Как начать пользоваться?", + answer=( + "1) Пополни баланс: Stars / криптой в боте или кодом (карта/СБП — через покупку кода)\n" + "2) Купи тариф в «Тарифы» или Mini App\n" + "3) Открой «Моя подписка» → скопируй ссылку\n" + "4) Скачай клиент → вставь ссылку подписки\n" + "5) Выбери сервер и подключись" + ), + ), + FaqItem( + id="clients", + question="Какой клиент скачать?", + answer=( + "В боте есть раздел «Скачать клиент» — топ приложений с docs.rw.\n" + "Популярные варианты:\n" + "• iOS / macOS — Streisand, Happ, Shadowrocket\n" + "• Android — v2rayNG, Hiddify, Happ\n" + "• Windows — Hiddify, FlClash\n\n" + "Главное — клиент с поддержкой subscription URL." + ), + ), + FaqItem( + id="sub_link", + question="Где взять ссылку подписки?", + answer=( + "«Моя подписка» в боте или главная в Mini App.\n" + "Ссылка выглядит как HTTPS URL — её целиком копируешь в клиент " + "(Add subscription / Import from URL)." + ), + ), + FaqItem( + id="devices", + question="Сколько устройств можно?", + answer=( + "База — 10 устройств на подписку (в тарифе).\n" + "Доп. слот: 50 ₽/мес за каждое (первый месяц при покупке).\n" + "Списание с баланса каждые 30 дней за все доп. устройства.\n" + "Если средств нет — доп. слоты отключаются до пополнения.\n" + "Если устройств больше оплаченной квоты — " + "подписка отключается, пока не оплатишь превышение " + "или не отвяжешь лишние устройства в клиенте." + ), + ), + FaqItem( + id="extras_monthly", + question="Почему списывают за доп. трафик и устройства каждый месяц?", + answer=( + "Дополнительные 500 ГБ и устройства сверх 10 — " + "это отдельная подписка к основному тарифу.\n\n" + "• При покупке списывается первый месяц\n" + "• Далее — автоматически с баланса раз в 30 дней\n" + "• Сумма = (число пакетов × 50 ₽) + (число доп. устройств × 50 ₽)\n" + "• Не хватает баланса — доп. лимиты приостанавливаются, " + "основная подписка остаётся на базовых 1000 ГБ и 10 устройств" + ), + ), + FaqItem( + id="traffic", + question="Сколько трафика даётся?", + answer=( + "В каждом тарифе база — 1000 ГБ (включено в тариф).\n" + "Докупка: +500 ГБ = 50 ₽/мес (можно несколько пакетов).\n" + "Первый месяц списывается при покупке, далее — каждые 30 дней " + "с баланса за все подключённые пакеты.\n" + "Если на балансе не хватает — доп. ГБ отключаются до оплаты.\n" + "Расход смотри в «Моя подписка» или Mini App." + ), + ), + FaqItem( + id="traffic_over", + question="Что если трафик закончится?", + answer=( + "Подключение может ограничиться, пока не докупишь пакет " + "или не начнётся новый период после продления тарифа.\n" + "Докупка трафика не продлевает срок подписки — только лимит ГБ." + ), + ), + FaqItem( + id="pay", + question="Как оплатить?", + answer=( + "• Тарифы и услуги — с баланса\n\n" + "Без кода (напрямую в боте):\n" + "• Telegram Stars — оплата ⭐ прямо в Telegram\n" + "• Crypto Bot — крипта\n" + "• Heleket — резервная крипта\n\n" + "Карта, СБП и другие фиатные способы — через " + "код пополнения:\n" + "• На странице покупки кода — более 40 способов оплаты\n" + "• После оплаты введи код в «Баланс» → «Код пополнения» " + "или в Mini App\n\n" + "Пополнение — раздел «Баланс» в боте." + ), + ), + FaqItem( + id="stars", + question="Как пополнить через Telegram Stars?", + answer=( + "1) «Баланс» → «Telegram Stars» в боте\n" + " или в Mini App / Telegram Web — блок «Telegram Stars»\n" + "2) Выбери сумму в рублях (курс задаёт сервис)\n" + "3) Оплати инвойс Stars в Telegram\n" + "4) Баланс зачислится сразу после оплаты\n\n" + "В веб-версии Telegram удобнее кнопка оплаты по ссылке " + "или оплата прямо из Mini App." + ), + ), + FaqItem( + id="topup_code", + question="Как пополнить по коду?", + answer=( + "Нужен, если хочешь оплатить картой, СБП или другим фиатом " + "(напрямую в боте без кода — только крипта).\n\n" + "1) «Баланс» → «Код пополнения» → «Купить код» " + "или кнопка в Mini App\n" + "2) На странице оплаты — более 40 способов\n" + "3) Введи код в боте или на главной Mini App\n" + "4) Сумма зачислится на баланс сразу\n\n" + "Один код — одно использование." + ), + ), + FaqItem( + id="cards_sbp", + question="Картой или СБП — как пополнить?", + answer=( + "Купи код пополнения по ссылке из бота или кабинета.\n" + "На странице оплаты доступно более 40 способов " + "(банковские карты, СБП, электронные кошельки и др.).\n\n" + "После успешной оплаты придёт код — активируй его в боте " + "или на сайте. Дальше тарифы оплачиваешь с баланса.\n\n" + "Без кода напрямую в боте пополнить можно " + "только криптовалютой." + ), + ), + FaqItem( + id="lava", + question="Как пополнить через Lava?", + answer=( + "Если в меню «Баланс» есть Lava — можно попробовать этот способ.\n" + "Для карты и СБП основной вариант — код пополнения " + "(40+ способов на странице покупки кода).\n\n" + "Напрямую без кода в боте доступна только крипта." + ), + ), + FaqItem( + id="digiseller", + question="Как пополнить через Digiseller?", + answer=( + "1) «Баланс» → Digiseller → «Оплатить на Digiseller»\n" + "2) Оплати товар на сайте\n" + "3) Digiseller сам откроет страницу проверки — баланс " + "зачислится автоматически, в бот придёт уведомление\n\n" + "Код вводить не нужно. Если автозачисление не сработало — " + "в боте есть «Не зачислилось? Ввести код»." + ), + ), + FaqItem( + id="renew", + question="Как продлить подписку?", + answer=( + "«Моя подписка» → «Продлить с баланса» или купи тариф снова.\n" + "Дни добавляются к текущей дате окончания, " + "если подписка ещё активна.\n\n" + "За 7 дней до конца бот пришлёт напоминание раз в сутки.\n" + "Если автопродление включено и на балансе хватает на последний тариф — " + "за сутки до окончания подписка продлится автоматически " + "(вкл/выкл в кабинете)." + ), + ), + FaqItem( + id="auto_renew", + question="Автопродление подписки", + answer=( + "Если автопродление включено, система смотрит на " + "последний купленный тариф и баланс.\n\n" + "• За 7 дней до окончания — ежедневное напоминание в Telegram\n" + "• В последние 24 часа (или сразу после истечения до 3 дней) " + "при достаточном балансе — автоматическое продление с баланса\n" + "• Включить или выключить можно при регистрации и в любой момент " + "в кабинете на вкладке «Главная»\n" + "• Если средств мало или автопродление выключено — " + "пополни баланс или продли вручную в боте / Mini App" + ), + ), + FaqItem( + id="not_working", + question="Не подключается / не работает", + answer=( + "Проверь по шагам:\n" + "1) Подписка не истекла и трафик не на нуле\n" + "2) Ссылка в клиенте актуальная (обнови subscription)\n" + "3) Сервер онлайн — «Статус серверов»\n" + "4) Попробуй другой сервер / другой клиент\n" + "5) Выключи лишний VPN/антивирус на телефоне\n\n" + "Если не помогло — напиши админу и приложи скрин ошибки." + ), + ), + FaqItem( + id="slow", + question="Медленно / высокий пинг", + answer=( + "Смени сервер в клиенте (ближе к тебе или менее загруженный).\n" + "Проверь «Статус серверов» — оффлайн ноды не выбирай.\n" + "На мобильном интернете скорость зависит от оператора и Wi‑Fi." + ), + ), + FaqItem( + id="miniapp", + question="Зачем Mini App?", + answer=( + "Это кабинет в Telegram: баланс, подписка, трафик, тарифы, " + "клиенты и серверы в одном окне.\n" + "Сайт тоже открывается в браузере — там можно войти по логину " + "или через Telegram Login." + ), + ), + FaqItem( + id="refund", + question="Можно вернуть деньги?", + answer=( + "Автоматического возврата нет: доступ выдаётся сразу после оплаты.\n" + "Если была техническая ошибка (списали, а ключ не выдали) — " + "создай тикет в боте или Mini App, разберём и вернём на баланс " + "или перевыпустим." + ), + ), + FaqItem( + id="tickets", + question="Как связаться с поддержкой?", + answer=( + "В боте: кнопка «🎫 Тикеты» → «Новый тикет».\n" + "В Mini App: вкладка «Тикеты».\n" + "Ответ придёт в Telegram и будет виден в переписке тикета." + ), + ), +) + + +def get_faq(item_id: str) -> FaqItem | None: + for item in FAQ_ITEMS: + if item.id == item_id: + return item + return None + + +def faq_intro() -> str: + return ( + "❓ FAQ — частые вопросы\n\n" + "Выбери вопрос ниже. Если не нашёл ответ — создай тикет." + ) + + +def faq_answer_text(item: FaqItem) -> str: + return f"❓ {item.question}\n\n{item.answer}" diff --git a/services/heleket.py b/services/heleket.py new file mode 100644 index 0000000..b1461a3 --- /dev/null +++ b/services/heleket.py @@ -0,0 +1,159 @@ +"""Клиент Heleket Payment API (резервная крипто-оплата).""" + +from __future__ import annotations + +import base64 +import hashlib +import json +import logging +from dataclasses import dataclass +from typing import Any + +import httpx + +logger = logging.getLogger(__name__) + +BASE_URL = "https://api.heleket.com/v1" +PAID_STATUSES = frozenset({"paid", "paid_over"}) + + +class HeleketError(RuntimeError): + pass + + +@dataclass(frozen=True, slots=True) +class HeleketInvoice: + uuid: str + order_id: str + amount: str + status: str + pay_url: str + currency: str | None = None + is_final: bool = False + + +def _json_body(payload: dict[str, Any]) -> str: + # Как в PHP json_encode: без пробелов, unicode as-is, слэши экранируются + raw = json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + return raw.replace("/", "\\/") + + +def sign_request(payload: dict[str, Any], api_key: str) -> str: + body = _json_body(payload) + encoded = base64.b64encode(body.encode("utf-8")).decode("ascii") + return hashlib.md5((encoded + api_key).encode("utf-8")).hexdigest() + + +def verify_webhook_sign(data: dict[str, Any], api_key: str) -> bool: + payload = dict(data) + received = str(payload.pop("sign", "") or "") + if not received: + return False + return sign_request(payload, api_key) == received + + +class Heleket: + def __init__( + self, + merchant_id: str, + api_key: str, + *, + callback_url: str = "", + currency: str = "RUB", + ) -> None: + self.merchant_id = merchant_id.strip() + self.api_key = api_key.strip() + self.callback_url = (callback_url or "").strip().rstrip("/") + self.currency = (currency or "RUB").strip().upper() or "RUB" + + @property + def enabled(self) -> bool: + return bool(self.merchant_id and self.api_key) + + async def _post(self, path: str, payload: dict[str, Any] | None = None) -> dict[str, Any]: + if not self.enabled: + raise HeleketError("Heleket не настроен (HELEKET_MERCHANT_ID / HELEKET_API_KEY)") + body_obj = payload if payload is not None else {} + body = _json_body(body_obj) + headers = { + "merchant": self.merchant_id, + "sign": sign_request(body_obj, self.api_key), + "Content-Type": "application/json", + } + async with httpx.AsyncClient(timeout=30.0) as client: + resp = await client.post( + f"{BASE_URL}{path}", + headers=headers, + content=body.encode("utf-8"), + ) + try: + data = resp.json() + except Exception as exc: # noqa: BLE001 + raise HeleketError(f"HTTP {resp.status_code}: {resp.text[:300]}") from exc + state = data.get("state") + if resp.status_code >= 400 or (state not in (0, "0", None) and state != 0): + errors = data.get("errors") or data.get("message") or data + raise HeleketError(str(errors)) + result = data.get("result") + if not isinstance(result, dict): + raise HeleketError(f"Неожиданный ответ: {data}") + return result + + async def create_invoice( + self, + *, + amount_rub: float, + order_id: str, + description: str = "", + lifetime: int = 3600, + ) -> HeleketInvoice: + payload: dict[str, Any] = { + "amount": f"{amount_rub:.2f}", + "currency": self.currency, + "order_id": order_id, + "lifetime": max(300, min(lifetime, 43200)), + "is_payment_multiple": False, + "theme": "dark", + } + if self.callback_url: + payload["url_callback"] = self.callback_url + if description: + payload["additional_data"] = description[:255] + result = await self._post("/payment", payload) + return self._parse(result) + + async def get_invoice( + self, + *, + order_id: str | None = None, + uuid: str | None = None, + ) -> HeleketInvoice | None: + payload: dict[str, Any] = {} + if order_id: + payload["order_id"] = order_id + if uuid: + payload["uuid"] = uuid + if not payload: + raise HeleketError("Нужен order_id или uuid") + try: + result = await self._post("/payment/info", payload) + except HeleketError: + return None + return self._parse(result) + + @staticmethod + def is_paid(status: str) -> bool: + return status in PAID_STATUSES + + @staticmethod + def _parse(raw: dict[str, Any]) -> HeleketInvoice: + status = str(raw.get("payment_status") or raw.get("status") or "") + return HeleketInvoice( + uuid=str(raw.get("uuid") or ""), + order_id=str(raw.get("order_id") or ""), + amount=str(raw.get("amount") or ""), + status=status, + pay_url=str(raw.get("url") or ""), + currency=raw.get("currency"), + is_final=bool(raw.get("is_final")), + ) diff --git a/services/lava.py b/services/lava.py new file mode 100644 index 0000000..4c53cb3 --- /dev/null +++ b/services/lava.py @@ -0,0 +1,200 @@ +"""Клиент Lava.ru Business API (карта / СБП).""" + +from __future__ import annotations + +import hashlib +import hmac +import json +import logging +from dataclasses import dataclass +from typing import Any + +import httpx + +logger = logging.getLogger(__name__) + +API_BASE = "https://api.lava.ru" +PAID_STATUSES = frozenset({"success", "paid"}) + + +class LavaError(RuntimeError): + pass + + +@dataclass(frozen=True, slots=True) +class LavaInvoice: + invoice_id: str + order_id: str + amount: float + status: str + pay_url: str + expired: str | None = None + + +def _json_body(payload: dict[str, Any]) -> bytes: + # Порядок ключей и separators критичны для Signature + return json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + + +def sign_request(payload: dict[str, Any], secret_key: str) -> str: + return hmac.new( + secret_key.encode("utf-8"), + _json_body(payload), + hashlib.sha256, + ).hexdigest() + + +def verify_webhook( + *, + data: dict[str, Any], + raw_body: bytes, + authorization: str | None, + additional_key: str, +) -> bool: + """Проверка подписи webhook (два формата Lava).""" + key = (additional_key or "").strip() + if not key: + # без доп. ключа принимаем только если нет подписи вообще (нежелательно) + return not data.get("sign") and not (authorization or "").strip() + + # Новый формат SDK: HMAC SHA256(raw body) в Authorization + auth = (authorization or "").strip() + if auth: + expected = hmac.new(key.encode("utf-8"), raw_body, hashlib.sha256).hexdigest() + if hmac.compare_digest(auth.lower(), expected.lower()): + return True + + # Классический формат: md5(invoice_id:amount:pay_time:secret_key_2) + received = str(data.get("sign") or "").strip() + if received: + invoice_id = str(data.get("invoice_id") or "") + amount = data.get("amount") + pay_time = data.get("pay_time") + # amount иногда float — приводим как в доке (как пришло / без лишних нулей) + amount_s = str(amount) + raw = f"{invoice_id}:{amount_s}:{pay_time}:{key}" + expected_md5 = hashlib.md5(raw.encode("utf-8")).hexdigest() + if hmac.compare_digest(received.lower(), expected_md5.lower()): + return True + + return False + + +class Lava: + def __init__( + self, + shop_id: str, + secret_key: str, + *, + additional_key: str = "", + hook_url: str = "", + success_url: str = "", + fail_url: str = "", + expire_minutes: int = 60, + ) -> None: + self.shop_id = (shop_id or "").strip() + self.secret_key = (secret_key or "").strip() + self.additional_key = (additional_key or "").strip() + self.hook_url = (hook_url or "").strip().rstrip("/") + self.success_url = (success_url or "").strip() + self.fail_url = (fail_url or "").strip() + self.expire_minutes = max(5, min(int(expire_minutes or 60), 5 * 24 * 60)) + + @property + def enabled(self) -> bool: + return bool(self.shop_id and self.secret_key) + + async def _post(self, path: str, payload: dict[str, Any]) -> dict[str, Any]: + if not self.enabled: + raise LavaError("Lava не настроен (LAVA_SHOP_ID / LAVA_SECRET_KEY)") + body = _json_body(payload) + headers = { + "Accept": "application/json", + "Content-Type": "application/json", + "Signature": sign_request(payload, self.secret_key), + } + async with httpx.AsyncClient(timeout=30.0) as client: + resp = await client.post( + f"{API_BASE}{path}", + headers=headers, + content=body, + ) + try: + data = resp.json() + except Exception as exc: # noqa: BLE001 + raise LavaError(f"HTTP {resp.status_code}: {resp.text[:300]}") from exc + + if not isinstance(data, dict): + raise LavaError(f"Неожиданный ответ: {data}") + + if resp.status_code >= 400 or not data.get("status_check", True): + err = data.get("error") or data.get("message") or data + raise LavaError(str(err)) + + result = data.get("data") + if not isinstance(result, dict): + raise LavaError(f"Нет data в ответе: {data}") + return result + + async def create_invoice( + self, + *, + amount_rub: float, + order_id: str, + comment: str = "", + custom_fields: str = "", + ) -> LavaInvoice: + # Порядок ключей фиксируем — от него зависит Signature + payload: dict[str, Any] = { + "shopId": self.shop_id, + "sum": float(f"{amount_rub:.2f}"), + "orderId": order_id, + } + if comment: + payload["comment"] = comment[:255] + if custom_fields: + payload["customFields"] = custom_fields[:500] + if self.fail_url: + payload["failUrl"] = self.fail_url + if self.success_url: + payload["successUrl"] = self.success_url + if self.hook_url: + payload["hookUrl"] = self.hook_url + payload["expire"] = self.expire_minutes + + result = await self._post("/business/invoice/create", payload) + return self._parse(result, order_id_fallback=order_id) + + async def get_invoice( + self, + *, + order_id: str | None = None, + invoice_id: str | None = None, + ) -> LavaInvoice | None: + payload: dict[str, Any] = {"shopId": self.shop_id} + if order_id: + payload["orderId"] = order_id + if invoice_id: + payload["invoiceId"] = invoice_id + if "orderId" not in payload and "invoiceId" not in payload: + raise LavaError("Нужен order_id или invoice_id") + try: + result = await self._post("/business/invoice/status", payload) + except LavaError: + return None + return self._parse(result, order_id_fallback=order_id or "") + + @staticmethod + def is_paid(status: str) -> bool: + return str(status or "").lower() in PAID_STATUSES + + @staticmethod + def _parse(raw: dict[str, Any], *, order_id_fallback: str = "") -> LavaInvoice: + return LavaInvoice( + invoice_id=str(raw.get("id") or raw.get("invoice_id") or ""), + order_id=str(raw.get("orderId") or raw.get("order_id") or order_id_fallback), + amount=float(raw.get("amount") or raw.get("sum") or 0), + status=str(raw.get("status") or ""), + pay_url=str(raw.get("url") or raw.get("payUrl") or ""), + expired=str(raw.get("expired") or raw.get("expire") or "") or None, + ) diff --git a/services/legal_pages.py b/services/legal_pages.py new file mode 100644 index 0000000..5e7b60c --- /dev/null +++ b/services/legal_pages.py @@ -0,0 +1,782 @@ +"""Юридические и служебные страницы кабинета. + +Перед продом задайте LEGAL_* / BRAND_* и STATUS_PAGE_URL в env. +Документы не заменяют консультацию юриста. +""" + +from __future__ import annotations + +import html +import os +from datetime import date + +BRAND = (os.getenv("BRAND_NAME") or "VPN Service").strip() or "VPN Service" +BRAND_SITE = (os.getenv("BRAND_SITE") or "example.com").strip() or "example.com" +SITE = (os.getenv("WEBAPP_URL") or "https://bot.example.com").rstrip("/") +SUPPORT_EMAIL = (os.getenv("LEGAL_SUPPORT_EMAIL") or "support@example.com").strip() +OPERATOR_NAME = (os.getenv("LEGAL_OPERATOR_NAME") or BRAND_SITE).strip() +OPERATOR_ADDRESS = (os.getenv("LEGAL_OPERATOR_ADDRESS") or "").strip() +EFFECTIVE = (os.getenv("LEGAL_EFFECTIVE_DATE") or date.today().isoformat()).strip() +STATUS_URL = (os.getenv("STATUS_PAGE_URL") or "https://status.example.com/").strip() + + +def _nav() -> str: + items = [ + ("/contacts", "Контакты"), + ("/status", "Статус"), + ("/delivery", "Доставка"), + ("/refund", "Возврат"), + ("/offer", "Оферта"), + ("/privacy", "Персональные данные"), + ("/help", "Помощь"), + ("/support", "Поддержка"), + ("/cookies", "Cookie"), + ("/rules", "Правила сайта"), + ("/vpn-usage", "Правила VPN"), + ] + links = "\n ".join( + f'{html.escape(label)}' for href, label in items + ) + return f'' + + +def _page(title: str, body: str, *, lang: str = "ru", extra_head: str = "") -> str: + title_e = html.escape(title) + addr = ( + f"
Address: {html.escape(OPERATOR_ADDRESS)}" + if OPERATOR_ADDRESS + else "" + ) + return f""" + + + + + {title_e} — {html.escape(BRAND)} + + {extra_head} + + +

← Кабинет

+ {_nav()} +

{title_e}

+

Актуальная версия · {html.escape(EFFECTIVE)} · {html.escape(SITE)}

+ {body} +
+ {html.escape(BRAND_SITE)}
+ Operator: {html.escape(OPERATOR_NAME)}{addr}
+ Support: {html.escape(SUPPORT_EMAIL)} +
+""" + + +def html_offer() -> str: + e = html.escape + body = f""" +

Настоящий документ является публичной офертой и условиями использования сервиса {e(BRAND_SITE)}, +подготовлен с учётом требований ЕС (права потребителей, цифровой контент, e-commerce, GDPR — где применимо) +и США (нормы FTC и штатов, включая CCPA/CPRA). Регистрация, оплата и использование сервиса означают акцепт оферты.

+ +

1. Термины

+

Исполнитель — владелец сервиса {e(BRAND_SITE)}, предоставляющий доступ к VPN. +Заказчик (пользователь, потребитель) — дееспособное физическое или юридическое лицо, принявшее оферту +путём регистрации и/или оплаты. Услуги — предоставление доступа к VPN-конфигурациям и функциям кабинета +по выбранному тарифу. Цифровой контент — подписка, ссылка доступа и иные данные в электронной форме.

+ +

2. Контакты исполнителя

+ +

Полный список: Контакты.

+ +

3. Предмет договора

+

Исполнитель обязуется предоставить Заказчику VPN-доступ согласно оплаченному тарифу; Заказчик обязуется +оплатить услуги по ценам на сайте на момент покупки. Услуга является цифровым контентом, не поставляемым +на материальном носителе, предоставляемым удалённо (онлайн).

+

Существенные характеристики (срок, цена, лимиты трафика и устройств, список серверов) указываются +на странице тарифа до оплаты. Дополнительные условия — в +правилах использования VPN и правилах сайта.

+ +

4. Заключение договора и акцепт

+

Договор считается заключённым с момента:

+
    +
  • успешной оплаты (списание с баланса или завершение платежа через провайдера);
  • +
  • подтверждения согласия с офертой, политикой персональных данных, правилами сайта и VPN, + условиями доставки и возврата — обязательными отметками при регистрации и/или покупке.
  • +
+

Публикация тарифов — приглашение делать оферты; акцепт — оплата выбранного тарифа Заказчиком.

+ +

5. Оказание услуги и «доставка»

+

Заказчик регистрируется, при необходимости пополняет баланс и покупает подписку в кабинете. +Доступ предоставляется сразу после успешной оплаты: конфигурация и ссылка подписки отображаются в кабинете. +Отдельная плата за доставку не взимается. Подробнее: Условия доставки.

+

Срок подписки считается по тарифу (календарные дни/месяцы), если не включён режим заморозки до активации — +тогда срок начинается с момента активации, указанного на сайте.

+ +

6. Цена и оплата

+

Цены указываются на сайте до оформления заказа. Оплата — доступными в кабинете способами; +обработку платежей ведут платёжные провайдеры по своим правилам. Исполнитель не хранит полные данные банковских карт.

+

Валюта отображения — на усмотрение Исполнителя; фактическая сумма списания может зависеть от курса и комиссий. +НДС: если Исполнитель не является налогоплательщиком в соответствующей юрисдикции, цена может указываться +без отдельной налоговой строки; для потребителей ЕС налоговые обязанности определяются законом и статусом Исполнителя.

+ +

7. Право на отказ (ЕС) и немедленное предоставление цифрового контента

+

Для потребителей в ЕС действует право отказаться от дистанционного договора в течение 14 календарных дней +без указания причин (Директива 2011/83/EU), если ниже не предусмотрено иное.

+

VPN-доступ — цифровой контент не на материальном носителе. При оформлении заказа Заказчик:

+
    +
  • запрашивает немедленное начало оказания услуги после оплаты;
  • +
  • подтверждает ознакомление с условиями и понимает: после начала оказания и выдачи доступа + 14-дневное право отказа может быть утрачено в объёме фактически оказанной услуги + (ст. 16(m) Директивы о правах потребителей / национальные имплементации).
  • +
+

Возвраты после начала использования — по условиям возврата, в пределах императивных норм ЕС.

+ +

8. Возврат и отмена (США и общие положения)

+

Для потребителей в США применяются условия возврата и обязательные нормы штата проживания +(включая права жителей Калифорнии по CCPA/CPRA — см. политику персональных данных).

+

Цифровые услуги с мгновенной доставкой, как правило, не возвращаются после фактического подключения и использования, +кроме случаев, прямо указанных в условиях возврата, ошибочного двойного списания или когда это обязательно по закону.

+ +

9. Правовая рамка и обязанности Заказчика

+

Исполнитель определяет допустимое использование услуг на основе права ЕС и США +(см. правила VPN). Законодательство стран с авторитарным или цензурным регулированием интернета +не является референсной рамкой настоящих условий.

+

Заказчик обязуется:

+
    +
  • использовать VPN законно по применимому праву ЕС/США и настоящим документам;
  • +
  • не передавать доступ третьим лицам в коммерческих целях без согласия Исполнителя;
  • +
  • соблюдать лимиты устройств и условия тарифа;
  • +
  • не совершать действий, указанных в правилах сайта и VPN (взлом, мошенничество, CSAM, обход санкций OFAC/ЕС и т.п.).
  • +
+

Заказчик самостоятельно оценивает риски использования VPN в стране пребывания. +Исполнитель не гарантирует законность VPN во всех юрисдикциях мира.

+ +

10. Обработка персональных данных

+

Обработка данных — по Политике обработки персональных данных +(GDPR, ePrivacy, CCPA/CPRA — где применимо). Регистрация и оплата подтверждают ознакомление с политикой. +Права субъекта данных можно реализовать через профиль и поддержку.

+ +

11. Качество услуги и отказ от гарантий

+

Исполнитель стремится поддерживать работоспособность VPN, но услуга предоставляется «как есть» и «как доступна» +в пределах, допускаемых императивным правом ЕС и США.

+

Не гарантируются: непрерывная доступность, конкретная скорость, доступность отдельных сайтов или сервисов, +обход всех блокировок операторов и государств. Обслуживание, сбои провайдеров и форс-мажор могут влиять на доступ.

+

Для потребителей ЕС: ничто в оферте не ограничивает законные гарантии соответствия цифрового контента +по Директиве (EU) 2019/770 в объёме, применимом к характеру услуги.

+ +

12. Ограничение ответственности

+

Исполнитель не отвечает за: действия Заказчика и третьих лиц; содержание пользовательского трафика; +блокировки VPN на уровне государств и провайдеров; косвенные убытки, упущенную выгоду, потерю данных — +в максимальной мере, допускаемой применимым правом.

+

Совокупная ответственность Исполнителя ограничена суммой, фактически уплаченной Заказчиком +за спорную подписку за последние 12 месяцев, если иное не требуется императивными нормами защиты потребителей ЕС/США.

+

Ограничения не применяются к ответственности за умысел, грубую неосторожность, вред жизни и здоровью +и иным случаям, когда ответственность нельзя исключить законом.

+ +

13. Санкции и экспортный контроль

+

Услуги не предоставляются лицам и организациям, подпадающим под санкции США (OFAC), ЕС или иные обязательные режимы. +Заказчик подтверждает отсутствие таких ограничений. При нарушении Исполнитель может немедленно расторгнуть договор.

+ +

14. Приостановка и прекращение

+

Исполнитель может приостановить или прекратить доступ за нарушение оферты, правил, права ЕС/США, +злоупотребление ресурсами, неоплату или по запросу платёжного провайдера. +Постоянная блокировка за серьёзные нарушения — по правилам сайта.

+

Заказчик может прекратить использование в любое время; удаление аккаунта — через профиль. +Неиспользованный предоплаченный период не возвращается, кроме случаев по условиям возврата и императивному праву.

+ +

15. Изменение условий

+

Исполнитель может изменять оферту, цены и тарифы. Актуальная версия публикуется на этой странице. +О существенных изменениях для активных подписок может быть направлено уведомление (сайт, email). +Продолжение использования после вступления изменений в силу означает согласие, если иное не запрещено законом. +Для потребителей ЕС: изменения не ухудшают уже оплаченные права без согласия в течение оплаченного тарифа.

+ +

16. Применимое право и споры

+

Договор регулируется правом, определяемым по императивным коллизионным нормам защиты потребителей: +для потребителей в ЕС/ЕЭЗ сохраняются обязательные нормы страны обычного проживания, если они более благоприятны.

+

Для потребителей в США — обязательные нормы штата проживания в требуемом законом объёме.

+

Споры решаются переговорами и обращением в поддержку. При отсутствии согласия:

+
    +
  • ЕС: потребитель может использовать платформу ODR Еврокомиссии и компетентные органы защиты потребителей;
  • +
  • США: споры могут рассматриваться судами штата проживания или иными органами по местному праву.
  • +
+

Ничто в этом разделе не лишает потребителя права на судебную защиту по императивным нормам его страны.

+ +

17. Заключительные положения

+

Оферта действует с момента публикации на сайте. Недействительность отдельного положения не влечёт +недействительности оферты в целом. Неотъемлемые документы: +правила сайта, правила VPN, +политика персональных данных, доставка, возврат.

+""" + return _page("Договор оферты", body) + + +def html_terms() -> str: + """Алиас: на example.com Terms of Service = Offer Agreement.""" + return html_offer() + + +def html_privacy() -> str: + e = html.escape + body = f""" +

Актуальная версия. Политика подготовлена с учётом требований ЕС (GDPR, ePrivacy) +и США (штатные законы о приватности, включая CCPA/CPRA, рекомендации FTC). +По вопросам обработки данных — контакты.

+ +

1. Общие положения

+

Сервис {e(BRAND_SITE)} («мы», «сервис», «оператор») уважает вашу конфиденциальность. +Политика описывает, какие персональные данные мы собираем, на каком основании обрабатываем, +кому передаём, как долго храним и какие у вас права.

+

Политика применяется при использовании сайта, кабинета, VPN и иных услуг {e(BRAND_SITE)}. +Используя сервис, вы подтверждаете ознакомление с документом.

+ +

2. Оператор и контакты

+

Оператор (контролёр данных в смысле GDPR) — владелец сервиса {e(BRAND_SITE)}.

+ +

Отдельный DPO не назначен; запросы по персональным данным принимаются по указанным каналам.

+ +

3. Какие данные обрабатываем

+
    +
  • Идентификация и контакты: email, имя или псевдоним, идентификатор аккаунта;
  • +
  • Учётные данные: хеш пароля, данные 2FA (если включена), привязка Telegram (если используется);
  • +
  • Договор и платежи: баланс, история пополнений и списаний, тариф и подписка + (без полных данных карт — их обрабатывают платёжные провайдеры);
  • +
  • VPN-услуги: сведения о купленных конфигурациях, сроках, выбранных серверах; + мы намеренно не анализируем и не храним содержимое вашего VPN-трафика;
  • +
  • Поддержка: тема и текст тикетов, вложения, которые вы отправляете;
  • +
  • Реферальная программа (при участии): связь с пригласившим, начисления;
  • +
  • Технические данные: IP при доступе к сайту и API, тип браузера и ОС, cookies, + local storage, журналы ошибок и безопасности (в ограниченном объёме).
  • +
+

Мы не собираем особые категории данных (здоровье, биометрия, политические взгляды и т.п.); +сервис не предназначен для детей младше 16 лет (см. раздел 12).

+ +

4. Источники данных

+
    +
  • напрямую от вас (регистрация, оплата, тикеты, настройки профиля);
  • +
  • автоматически при посещении сайта (технические логи, cookies);
  • +
  • от платёжных и иных подрядчиков — в объёме, необходимом для завершения транзакции.
  • +
+ +

5. Cookies и аналогичные технологии (ePrivacy / GDPR)

+

Мы используем cookies и local storage браузера. По назначению они делятся на:

+
    +
  • Строго необходимые — сессия входа, безопасность, работа кабинета. + Основание: исполнение договора и законный интерес (ст. 6(1)(b), (f) GDPR); согласие не требуется.
  • +
  • Функциональные / предпочтения — запоминание выбора в баннере cookie (cookie_consent). + Основание: согласие (ст. 6(1)(a) GDPR) через «Принять все» или «Только необходимые».
  • +
  • Опциональные (третьи стороны) — виджет чата поддержки (если включён на сайте). + Загружается только после «Принять все»; может устанавливать cookies третьей стороны и передавать данные в США. + Отказ — «Только необходимые» или Настройки cookie.
  • +
+

Мы не используем стороннюю рекламу и профилирующие cookies для межсайтового трекинга. +Вы можете удалить или заблокировать cookies в настройках браузера; отказ от необходимых может сделать вход невозможным.

+

Срок хранения cookie согласия — до 12 месяцев, если иное не указано в браузере.

+ +

6. Цели и правовые основания (GDPR)

+
    +
  • Исполнение договора (ст. 6(1)(b)) — регистрация, выдача VPN, списания, поддержка по запросу;
  • +
  • Юридическая обязанность (ст. 6(1)(c)) — учёт и налоги, ответы на обязательные запросы органов ЕС/США;
  • +
  • Законный интерес (ст. 6(1)(f)) — безопасность, предотвращение мошенничества и злоупотреблений, + защита прав в спорах, стабильность сайта (без избыточного профилирования);
  • +
  • Согласие (ст. 6(1)(a)) — опциональные cookies, маркетинг (если введётся и только с отдельным согласием). + Согласие можно отозвать без ущерба для законной обработки до отзыва.
  • +
+ +

7. VPN и минимизация данных

+

Политика конфиденциальности VPN дополняется правилами использования VPN. +Мы соблюдаем принцип минимизации: не ведём целевой мониторинг содержимого трафика. +Технические метаданные (например, объём трафика на панелях) могут обрабатываться для биллинга, +лимитов тарифа и предотвращения злоупотреблений — без привязки к содержимому посещённых сайтов.

+ +

8. Сроки хранения

+
    +
  • данные аккаунта и подписки — пока аккаунт активен и разумный срок после удаления + (бэкапы и споры), обычно не более 90 дней после окончательного удаления, если закон не требует дольше;
  • +
  • финансовые записи — сроки налогового и бухгалтерского учёта (часто до 6–7 лет);
  • +
  • тикеты поддержки — до закрытия вопроса и до 3 лет истории обращений, если вы не запросите более раннее удаление;
  • +
  • технические логи серверов — обычно до 30–90 дней, если не требуется дольше для расследования инцидентов.
  • +
+

По истечении сроков данные удаляются или обезличиваются.

+ +

9. Передачи и обработчики

+

Мы не продаём персональные данные и не передаём их за денежное вознаграждение +(включая «sale»/«sharing» по CCPA/CPRA для поведенческой рекламы).

+

Данные могут передаваться обработчикам только для оказания услуги: платёжные системы, хостинг и ЦОД, +сервисы email/уведомлений, виджет чата (при согласии «Принять все»), подрядчики мониторинга +(без доступа к содержимому VPN-трафика). С ними заключаются DPA или стандартные договорные условия в объёме GDPR.

+

Раскрытие государственным органам — только по действительному и обязательному запросу +по праву ЕС, США или иной юрисдикции, обязательной для нас.

+ +

10. Международные передачи

+

Серверы и подрядчики могут находиться вне вашей страны, в том числе вне ЕЭЗ. +При передаче из ЕС/ЕЭЗ применяются меры главы V GDPR: SCC, решения об адекватности или иные законные механизмы.

+ +

11. Меры безопасности

+

Шифрование паролей, ограничение доступа к БД, HTTPS, разграничение прав администраторов, резервные копии. +Ни один способ передачи или хранения в интернете не гарантирует абсолютную безопасность.

+ +

12. Дети

+

Сервис не предназначен для лиц младше 16 лет (в отдельных штатах США — младше 13 без согласия родителей). +Мы сознательно не собираем данные детей. При обнаружении такого аккаунта можем удалить его и связанные данные.

+ +

13. Автоматизированные решения

+

Мы не принимаем решений о вас исключительно на основе автоматизированной обработки, +порождающих правовые последствия или существенно затрагивающих вас (ст. 22 GDPR). +Ограничения аккаунта за злоупотребления принимаются администрацией с учётом обстоятельств.

+ +

14. Ваши права

+

В зависимости от места проживания вы можете иметь права на доступ, исправление, удаление («право быть забытым»), +ограничение обработки, переносимость, возражение, отзыв согласия, жалобу в надзорный орган ЕС/ЕЭЗ +или компетентный орган штата США.

+

Как реализовать: экспорт данных и удаление аккаунта — в профиле кабинета; +иные запросы — тикет в поддержку или email {e(SUPPORT_EMAIL)}. +Срок ответа — до 30 дней (GDPR) или сроки штатного права США (например, 45 дней по CCPA с возможным продлением). +Мы можем запросить подтверждение личности.

+

Жители Калифорнии: мы не «продаём» и не «расшариваем» персональные данные для кросс-контекстной поведенческой рекламы. +Права знать категории данных, запросить удаление и исправление — по CCPA/CPRA. Дискриминации за реализацию прав нет.

+ +

15. Изменения политики

+

Актуальная версия всегда на этой странице; о существенных изменениях можем уведомить через сайт или email.

+ +

16. Контакты

+

По всем вопросам приватности: {e(SUPPORT_EMAIL)}, +раздел Поддержка, Контакты.

+""" + return _page("Политика обработки персональных данных", body) + + +def html_site_rules() -> str: + e = html.escape + body = f""" +

Использование сервиса означает принятие настоящих правил. Референсная рамка допустимого поведения — +право Европейского Союза и Соединённых Штатов Америки +(см. также правила VPN и оферту).

+ +

1. Общие положения

+

Сервис {e(BRAND_SITE)} предоставляет доступ к VPN и связанным цифровым услугам. +Регистрируясь, вы соглашаетесь с правилами, политикой персональных данных (GDPR, CCPA/CPRA) +и офертой. Подробные условия VPN — в правилах использования VPN.

+ +

2. Регистрация и аккаунт

+

Для покупки необходима регистрация. Укажите действующий email и храните пароль в безопасности. +Вы отвечаете за действия с вашего аккаунта. Сервис не предназначен для лиц младше 16 лет +(13 в отдельных штатах США без согласия родителей).

+ +

3. Оплата и использование

+

Оплата — через баланс кабинета и платёжных провайдеров. Конфигурации выдаются в кабинете. +Передача доступа третьим лицам в коммерческих целях без согласия сервиса запрещена.

+ +

4. Лимит устройств

+

Одна подписка — не более 10 одновременных устройств, если тариф не указывает иное. +Для большего числа устройств — отдельная подписка. Превышение лимита — нарушение; возможна приостановка.

+ +

5. Запрещённое использование

+

Запрещено использовать сервис для деятельности, противоречащей праву ЕС/США, включая +(но не ограничиваясь): несанкционированный доступ к системам, вредоносное ПО, DDoS, спам, мошенничество, +CSAM, обход санкций OFAC/ЕС, систематическое пиратство. Полный список — в правилах VPN.

+

Сервис не обязан ограничивать доступ к ресурсам только потому, что они заблокированы в отдельных странах +с авторитарным регулированием сети, если иное не требуется применимым правом ЕС/США.

+ +

6. Поддержка и споры

+

Вопросы — через Поддержку или контакты. +Потребители ЕС: ODR. Споры — по оферте, раздел 16.

+ +

7. Возвраты, вред и блокировки

+

Возвраты — по условиям возврата и императивному праву ЕС/США. +При существенном вреде сервису возврат может не выдаваться. +За серьёзные нарушения возможна постоянная блокировка без восстановления доступа.

+ +

8. Блокировки VPN и простой серверов

+

Государственные или провайдерские блокировки VPN — риск пользователя, а не основание для автоматического возврата +(см. возврат, раздел 7). Возврат возможен только при длительном простое по вине сервиса +(>3 подряд календарных дней без замены сервера) — см. страницу возврата.

+ +

9. Изменение правил

+

Актуальная версия — на этой странице. О существенных изменениях можем сообщить через сайт или email. +Для потребителей ЕС: изменения не ухудшают уже оплаченный период без согласия.

+""" + return _page("Правила сайта", body) + + +def html_aup() -> str: + return html_vpn_usage() + + +def html_vpn_usage() -> str: + e = html.escape + body = f""" +

Документ дополняет правила сайта и договор оферты. +Используя VPN-услуги, вы подтверждаете ознакомление со следующим. +Политика допустимого использования VPN формируется со ссылкой на право ЕС и США.

+ +

1. Назначение сервиса

+

Сервис {e(BRAND_SITE)} предоставляет доступ к зашифрованному VPN-соединению для личного законного использования: +защита приватности, безопасность данных в публичных и домашних сетях. +Сервис не предназначен для нарушения закона и не гарантирует доступ к конкретным ресурсам +при изменении политик операторов или сетевых ограничений.

+ +

2. Тарифы и трафик: условно безлимитный

+

Если в описании тарифа указано «безлимит» или сходная формулировка, это означает условно безлимитное использование +в рамках разумного личного потребления (веб, мессенджеры, стриминг в обычном качестве и т.п.).

+

Такой режим не является абсолютным неограниченным объёмом: существуют физические лимиты канала, +политики ЦОД и требования стабильной работы сети для всех клиентов.

+ +

3. Fair use и распределение ресурсов

+

VPN-инфраструктура рассчитана на совместное использование многими пользователями. +Для стабильной скорости и доступности сервис следует принципу справедливого распределения ресурсов (fair use): +избыточная, нетипичная или монополизирующая нагрузка на каналы и узлы не допускается.

+ +

4. Меры при злоупотреблении трафиком или нагрузкой

+

При обнаружении злоупотребления администрация может по своему усмотрению:

+
    +
  • установить индивидуальные лимиты скорости и/или объёма трафика на аккаунт или соединение;
  • +
  • временно ограничить или приостановить VPN-доступ до выяснения обстоятельств;
  • +
  • при серьёзных или повторных нарушениях — прекратить оказание услуги + в соответствии с правилами сайта и офертой.
  • +
+

Цель мер — справедливые условия для всех пользователей и работоспособность инфраструктуры. +Решение сообщается пользователю через поддержку (тикеты) или иные каналы на сайте.

+ +

5. Применимое право и правовая рамка сервиса

+

Сервис {e(BRAND_SITE)} при определении допустимого и недопустимого использования VPN и при принятии мер +опирается на требования и общепризнанные стандарты права ЕС и США.

+

Законодательство и практика стран с авторитарным, цензурным или иным несвободным регулированием интернета — +включая Российскую Федерацию и Украину — не являются референсной рамкой настоящих правил. +Ограничения таких юрисдикций сами по себе не создают обязанности сервиса блокировать или ограничивать VPN, +если использование не противоречит применимому праву ЕС/США и настоящему документу.

+

Это не означает безусловной безнаказанности пользователя в любой стране: +вы самостоятельно оцениваете риски в месте пребывания. Сервис не консультирует по законам конкретных стран +и не гарантирует, что использование VPN будет законным или безопасным во всех юрисдикциях.

+ +

6. Законное использование и запрещённые действия

+

VPN предназначен для законного личного использования. Сервис не мониторит содержимое трафика в реальном времени +и не отвечает за ваши действия, но может ограничить или прекратить доступ при нарушении правил.

+

Запрещено использовать VPN для деятельности, противоречащей праву ЕС и/или США, включая:

+
    +
  • несанкционированный доступ к компьютерам, сетям, аккаунтам и данным;
  • +
  • распространение вредоносного ПО, взлом, DDoS, сканирование и эксплуатация уязвимостей без согласия владельца;
  • +
  • спам, фишинг, мошенничество, кража персональных данных, финансовые и иные преступления;
  • +
  • распространение CSAM и любая эксплуатация несовершеннолетних;
  • +
  • поддержка терроризма, отмывание денег, обход санкций OFAC (США), ЕС и иных обязательных ограничений;
  • +
  • массовое нарушение авторских и смежных прав (включая систематическое пиратство в коммерческих целях);
  • +
  • перепродажа, публичное распространение или коммерческое предоставление VPN-доступа третьим лицам без согласия сервиса;
  • +
  • иные действия, указанные в правилах сайта и признаваемые незаконными по праву ЕС или США.
  • +
+ +

7. Санкции, экспортный контроль и ограниченные лица

+

Услуги не предоставляются лицам и организациям, подпадающим под обязательные санкции США (включая списки OFAC), +ЕС или иных юрисдикций, чьи ограничения сервис обязан соблюдать по закону. +Сервис может прекратить доступ при выявлении нарушений санкционных или экспортных ограничений.

+ +

8. Учётные данные и передача доступа

+

Конфигурации и ключи предназначены для личного использования владельцем аккаунта, если тариф прямо не предусматривает иное. +Передача доступа третьим лицам в коммерческих целях, массовая перепродажа или публичное распространение без согласия сервиса не допускаются.

+ +

9. Лимит устройств на подписку

+

Одна подписка допускает использование не более чем на 10 устройствах одновременно, +если на сайте для тарифа не указано иное. Для большего числа устройств нужна дополнительная подписка. +Превышение лимита нарушает правила; сервис может применить меры вплоть до приостановки аккаунта.

+ +

10. Стабильность и технические ограничения

+

VPN зависит от сетей провайдеров, ЦОД и сторонних сервисов. Возможны краткие сбои, смена IP и обслуживание. +Сервис стремится обеспечить доступность, но не гарантирует бесперебойность.

+ +

11. Запросы государственных органов

+

Сервис не ведёт целевой мониторинг содержимого пользовательского трафика. +При получении действительных и обязательных запросов от компетентных органов в рамках процедур, +предусмотренных правом ЕС, США или иной юрисдикции, обязательной для сервиса, +мы можем предоставить имеющиеся у нас данные (например, сведения об аккаунте и платежах) в объёме, требуемом законом. +Подробнее — в политике персональных данных.

+

Запросы властей РФ, Украины и иных авторитарных юрисдикций, не имеющие обязательной силы по праву ЕС/США +и не подкреплённые международно признанной процедурой, не являются основанием для произвольного раскрытия данных +или блокировки пользователя, если иное прямо не требуется применимым правом.

+ +

12. Изменение документа

+

Актуальная версия публикуется на этой странице. Продолжение использования VPN после обновления правил +означает принятие в объёме, не противоречащем заключённому договору и применимому праву.

+""" + return _page("Правила использования VPN", body) + + +def html_refund() -> str: + e = html.escape + body = f""" +

Порядок возврата средств за цифровые VPN-услуги {e(BRAND_SITE)}. +Документ учитывает обязательные нормы ЕС (Директива 2011/83/EU, Регламент (EU) 2019/770) +и США (права потребителей штатов, FTC). Референсная рамка — право ЕС/США.

+ +

1. Цифровой характер услуги

+

Услуга — цифровой контент не на материальном носителе, предоставляемый удалённо и, как правило, +мгновенно после оплаты (см. доставку).

+ +

2. Право на отказ — потребители ЕС (14 дней)

+

Если вы потребитель в ЕС, у вас может быть право отказаться от дистанционного договора +в течение 14 календарных дней без указания причин (Директива 2011/83/EU).

+

Исключение для цифрового контента: если при покупке вы явно запросили немедленное начало оказания услуги +и получили доступ (конфигурация в кабинете), право отказа может быть утрачено в объёме уже оказанной услуги +(ст. 16(m) Директивы). Подробнее — в оферте, раздел 7.

+

До начала использования (доступ не активирован/не подключён) возврат может быть рассмотрен +по обращению в поддержку в разумный срок.

+ +

3. Потребители США

+

Для жителей США возврат за цифровые услуги с мгновенной доставкой, как правило, не предоставляется +после фактического использования, если иное не требуется императивным правом штата или настоящей политикой +(ошибочное списание, двойной платёж).

+ +

4. Возврат до использования

+

Запрос на возврат неиспользованного доступа может быть рассмотрен через +поддержку или контакты. +Возврат не гарантируется автоматически и зависит от технической возможности платёжного провайдера.

+

Возврат может быть отказан при нарушении правил сайта, +правил VPN или при причинении существенного вреда сервису.

+ +

5. Ошибочный платёж и chargeback

+

При ошибочном переводе или двойном списании обратитесь в поддержку с номером транзакции, суммой и способом оплаты. +При подтверждении ошибки средства возвращаются по процедурам платёжной системы.

+

Не злоупотребляйте chargeback без оснований — это может привести к блокировке аккаунта.

+ +

6. Отказ в возврате

+

Возврат, как правило, не выдаётся, если доступ уже использован (подключение по выданным данным), +а также при серьёзных нарушениях правил или права ЕС/США.

+ +

7. Государственные и сетевые блокировки

+

Ограничения VPN, введённые государствами, провайдерами или операторами связи, +являются обстоятельствами вне контроля сервиса и сами по себе не являются основанием для возврата, +если услуга была технически оказана с нашей стороны. Вы самостоятельно оцениваете риски +в месте пребывания (см. правила VPN).

+ +

8. Недоступность по вине сервиса

+

Возврат за период подписки может быть рассмотрен, если простой серверов по вине сервиса +превысил 3 подряд календарных дня, доступ не был восстановлен заменой сервера, +и вы своевременно уведомили поддержку. Краткие сбои и плановое обслуживание не являются основанием.

+

Для потребителей ЕС сохраняются права при несоответствии цифрового контента по Регламенту (EU) 2019/770 — +в применимом объёме.

+ +

9. Сроки и способ возврата

+

Рассмотрение запроса — до 14 рабочих дней (для запросов GDPR — до 30 дней). +При одобрении — возврат тем же способом оплаты; срок зачисления — по правилам платёжной системы +(обычно 10–30 рабочих дней).

+ +

10. Споры (ЕС / США)

+

Споры — через поддержку. Потребители ЕС: платформа ODR. +Обязательные нормы страны проживания сохраняются (см. оферту, раздел 16).

+""" + return _page("Возврат и отмена платежа", body) + + +def html_delivery() -> str: + e = html.escape + body = f""" +

Сервис {e(BRAND_SITE)} предоставляет цифровой контент (VPN-доступ). +Документ подготовлен с учётом требований ЕС (Директива 2011/83/EU, Регламент (EU) 2019/770) +и США (прозрачность для потребителей, FTC).

+ +

1. Способ «доставки»

+

Доставка осуществляется исключительно в электронной форме. После успешной оплаты доступ к конфигурациям +предоставляется автоматически: ссылки подписки и данные подключения отображаются в кабинете сразу после зачисления платежа. +Физическая доставка не применяется.

+ +

2. Стоимость доставки

+

Отдельная плата за доставку не взимается. Цена на странице тарифа включает предоставление цифрового доступа +(принцип прозрачности цены — право потребителей ЕС/США).

+ +

3. Сроки

+

Доступ предоставляется сразу после подтверждения оплаты платёжной системой (обычно в течение нескольких минут). +При задержке у провайдера — в разумный срок, как правило до 24 часов; обратитесь в +поддержку.

+

Для потребителей ЕС: при оформлении заказа вы запрашиваете немедленное начало оказания услуги; +это может повлиять на 14-дневное право отказа — см. оферту и +политику возврата.

+ +

4. Регионы

+

Сервис доступен пользователям в регионах, где доступны сайт и выбранные способы оплаты, +с учётом ограничений санкций OFAC/ЕС (см. оферту).

+ +

5. Подтверждение получения

+

Предоставление доступа фиксируется автоматически в кабинете. Это считается исполнением обязанности +по поставке цифрового контента. При проблемах после оплаты — откройте тикет в поддержке.

+ +

6. Информация до покупки (ЕС / США)

+

До оплаты на сайте отображаются: наименование услуги, цена, срок подписки, основные характеристики тарифа +(лимиты, протоколы). Полные условия — в оферте, +политике персональных данных и контактах.

+""" + return _page("Доставка", body) + + +def html_contacts() -> str: + e = html.escape + body = f""" +

Актуальные контактные данные сервиса {e(BRAND_SITE)}. +По вопросам персональных данных (GDPR, CCPA/CPRA) и прав потребителей ЕС/США используйте каналы ниже.

+ +
+

Оператор сервиса (контролёр данных)
{e(OPERATOR_NAME)}

+

Email
+ Общие вопросы: {e(SUPPORT_EMAIL)}
+ Приватность и GDPR/CCPA: {e(SUPPORT_EMAIL)} + (тема: «Privacy request»)

+

Поддержка
+ {e(SUPPORT_EMAIL)}
+ Обращения через раздел Поддержка (тикеты) в кабинете. + Зарегистрированные пользователи могут создать тикет и получить ответ на email.

+

Срок ответа на запросы по персональным данным — до 30 дней (GDPR) + или в сроки CCPA/CPRA (обычно до 45 дней).

+
+ +

Права потребителей и споры (ЕС / США)

+

Жители ЕС/ЕЭЗ могут обратиться в надзорный орган по защите данных своей страны (список — EDPB) +и использовать платформу Online Dispute Resolution (ODR) Еврокомиссии.

+

Жители США (включая Калифорнию) — по вопросам персональных данных см. +политику; жалобы можно подавать в компетентный орган штата +(например, California Attorney General — Privacy).

+""" + return _page("Контакты", body) + + +def html_support() -> str: + e = html.escape + body = f""" +

Поддержка сервиса {e(BRAND_SITE)}.

+ +
+
    +
  1. Войдите в кабинет.
  2. +
  3. Откройте раздел «Поддержка» и создайте тикет.
  4. +
  5. Укажите ID конфигурации/подписки, что уже пробовали, и приложите скриншоты при необходимости.
  6. +
+

Email: {e(SUPPORT_EMAIL)}

+
+ +

Перед обращением проверьте: актуальность приложения, корректность скопированной ссылки подписки, +статус серверов на странице Статус серверов.

+

Справка по типичным вопросам: Помощь.

+""" + return _page("Поддержка", body) + + +def html_help() -> str: + e = html.escape + body = f""" +

Краткая справка по кабинету {e(BRAND)}: регистрация, тарифы, подписка Remnawave, устройства и лимиты.

+ +

С чего начать

+
    +
  1. Зарегистрируйтесь или войдите в кабинет (логин/пароль, Telegram или Passkey).
  2. +
  3. Пополните баланс доступным способом оплаты.
  4. +
  5. Выберите тариф и оплатите с баланса.
  6. +
  7. Скопируйте ссылку подписки из кабинета и импортируйте в клиент (Hiddify, v2rayNG, Streisand и др.).
  8. +
+ +

Регистрация и вход

+

При регистрации укажите логин и пароль. Забыли пароль — сброс со страницы входа (если настроен SMTP). +Реферальная ссылка при регистрации (если есть) применяется автоматически.

+ +

Баланс и оплата

+

В кабинете откройте «Баланс». Способы оплаты зависят от настроек сервиса. +Мы не списываем карту автоматически без вашего действия: услуги оплачиваются с баланса сайта. +В профиле можно включить добровольное автопродление с баланса — это не привязка карты, +а списание уже зачисленных средств при истечении тарифа.

+ +

Подписка Remnawave

+

После покупки в кабинете появляется ссылка подписки https://… для мультисерверного доступа. +У подписки есть лимит трафика и устройств (по тарифу и настройкам панели). +При необходимости докупите трафик или слоты устройств в соответствующих разделах кабинета.

+ +

Устройства и правила

+

Не превышайте лимит устройств — иначе доступ может быть ограничен до покупки слотов или удаления лишних устройств. +Одна выданная подписка/ссылка не предназначена для массовой раздачи. +Злоупотребления могут привести к блокировке аккаунта. Возвраты — см. +политику возврата и правила.

+ +

Поддержка

+

Создайте тикет в разделе Поддержка. +Укажите тип подключения и что уже пробовали. Перед обращением обновите приложение и проверьте ссылку.

+""" + return _page("Помощь", body) + + +def html_status() -> str: + e = html.escape + status = e(STATUS_URL) if STATUS_URL else "#" + body = f""" +

Актуальный статус узлов и инфраструктуры сервиса {e(BRAND_SITE)}.

+
+

Публичная страница статуса:

+

{status}

+

Краткие сбои и плановое обслуживание обычно отражаются на статус-странице. + При длительной недоступности (>3 суток подряд по вине сервиса без замены сервера) + см. условия возврата.

+
+

Если сервис недоступен — напишите в поддержку.

+""" + return _page("Статус серверов", body) + + +def html_cookies() -> str: + e = html.escape + script = """ + +""" + body = f""" +

Настройки cookie для сайта {e(BRAND_SITE)}. Подробности — в разделе 5 +Политики обработки персональных данных.

+ +
+

Строго необходимые — сессия, безопасность, работа кабинета. Согласие не требуется.

+

Функциональные — запоминание вашего выбора cookie.

+

Опциональные — сторонние виджеты (например, чат поддержки), если включены на сайте. + Загружаются только при выборе «Принять все».

+
+ + +
+ +
+

Выбор хранится в localStorage браузера (cookie_consent) до 12 месяцев +или до очистки данных сайта.

+""" + return _page("Настройки cookie", body, extra_head=script) diff --git a/services/mailer.py b/services/mailer.py new file mode 100644 index 0000000..602f52d --- /dev/null +++ b/services/mailer.py @@ -0,0 +1,364 @@ +"""SMTP-рассылка важных уведомлений пользователям.""" + +from __future__ import annotations + +import asyncio +import html as html_lib +import logging +import re +import smtplib +from email.message import EmailMessage +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from config import Settings + from services.db import Database + +logger = logging.getLogger(__name__) + +_TAG_RE = re.compile(r"<[^>]+>") +_WS_RE = re.compile(r"[ \t]+\n") + +# Фирменные цвета VPN Service (email-safe, без «фиолетового AI») +_ACCENT = "#0d9f6e" +_ACCENT_DARK = "#0a7a54" +_BG = "#eef3f0" +_CARD = "#ffffff" +_TEXT = "#12201a" +_MUTED = "#5c7167" +_LINE = "#d5e0da" +_WARN_BG = "#fff8e8" +_WARN_BORDER = "#e8c96a" +_WARN_TEXT = "#6b5420" + + +def html_to_plain(html: str) -> str: + text = (html or "").replace("
", "\n").replace("
", "\n").replace("
", "\n") + text = text.replace("

", "\n").replace("", "\n").replace("", "\n") + text = _TAG_RE.sub("", text) + text = ( + text.replace(" ", " ") + .replace("<", "<") + .replace(">", ">") + .replace("&", "&") + .replace(""", '"') + ) + text = _WS_RE.sub("\n", text) + return text.strip() + + +def _e(value: str) -> str: + return html_lib.escape(value or "", quote=True) + + +def smtp_configured(settings: Settings) -> bool: + return bool((settings.smtp_host or "").strip() and (settings.smtp_from or "").strip()) + + +def render_email_layout( + *, + title: str, + body_html: str, + preheader: str = "", + footer_note: str = "", + webapp_url: str = "", + cta_url: str = "", + cta_label: str = "", +) -> str: + """Брендированная HTML-обёртка для писем (таблицы + inline CSS).""" + title_e = _e(title) + pre_e = _e(preheader or title) + footer_e = _e( + footer_note + or "Это письмо отправлено автоматически. Отвечать на него не нужно." + ) + brand_url = (webapp_url or "").rstrip("/") + brand_link = ( + f'' + f"Открыть кабинет" + if brand_url + else "" + ) + + cta_block = "" + if cta_url and cta_label: + cta_block = f""" + + + + + + +
+ + {_e(cta_label)} + +
+ + """ + + return f""" + + + + + + + {title_e} + + + +
+ {pre_e} +
+ + + + +
+ + + + + + + + {cta_block} + + + +
+ + + + + + + +
+ 🦊 VPN Service +
+ Безопасный доступ · личный кабинет +
+
+

+ {title_e} +

+
+ {body_html} +
+
+ + + + +
+ {footer_e} + {"
" + brand_link if brand_link else ""} +
+
+

+ © VPN Service +

+
+ +""" + + +def render_info_box(html_inner: str, *, kind: str = "info") -> str: + if kind == "warn": + bg, border, color = _WARN_BG, _WARN_BORDER, _WARN_TEXT + else: + bg, border, color = "#f0faf5", _LINE, _MUTED + return f""" + + + + +
+ {html_inner} +
""" + + +def render_password_reset_email(*, reset_url: str, webapp_url: str = "") -> str: + body = f""" +

Мы получили запрос на сброс пароля для вашего аккаунта VPN Service.

+

Нажмите кнопку ниже, чтобы задать новый пароль. Ссылка действует 1 час.

+ {render_info_box( + "Безопасность. Если вы не запрашивали сброс — просто проигнорируйте это письмо. " + "Пароль не изменится, пока вы сами не подтвердите действие по ссылке.", + kind="warn", + )} +

+ Кнопка не работает? Скопируйте ссылку в браузер:
+ {_e(reset_url)} +

+ """ + return render_email_layout( + title="Сброс пароля", + preheader="Ссылка для сброса пароля действует 1 час", + body_html=body, + cta_url=reset_url, + cta_label="Сбросить пароль", + webapp_url=webapp_url, + footer_note="Письмо отправлено, потому что для аккаунта указан этот email.", + ) + + +def render_smtp_test_email(*, webapp_url: str = "") -> str: + body = f""" +

Поздравляем — SMTP настроен правильно.

+

Это тестовое письмо из админ-панели VPN Service. Теперь можно отправлять + сброс пароля и важные уведомления пользователям.

+ {render_info_box( + "Что дальше. Убедитесь, что у пользователей сохранён email в кабинете, " + "и проверьте папку «Спам», если письмо не видно во входящих.", + kind="info", + )} + """ + return render_email_layout( + title="SMTP работает", + preheader="Тестовое письмо — почта настроена верно", + body_html=body, + webapp_url=webapp_url, + cta_url=(webapp_url or "").rstrip("/") + "/admin" if webapp_url else "", + cta_label="Открыть админку" if webapp_url else "", + footer_note="Тест из раздела Админка → SMTP.", + ) + + +def render_notification_email( + *, + title: str, + body_html: str, + webapp_url: str = "", + preheader: str = "", +) -> str: + """Обернуть произвольное HTML-уведомление в фирменный шаблон.""" + return render_email_layout( + title=title, + preheader=preheader or title, + body_html=f'
{body_html}
', + webapp_url=webapp_url, + footer_note="Уведомление аккаунта VPN Service. Отключить email можно в кабинете → Аккаунт.", + ) + + +def send_email_sync( + settings: Settings, + *, + to: str, + subject: str, + html: str, + plain: str | None = None, +) -> None: + host = (settings.smtp_host or "").strip() + from_addr = (settings.smtp_from or "").strip() + to_addr = (to or "").strip() + if not host or not from_addr or not to_addr: + raise ValueError("SMTP не настроен или нет получателя") + + msg = EmailMessage() + msg["Subject"] = subject + msg["From"] = from_addr + msg["To"] = to_addr + body_plain = plain if plain is not None else html_to_plain(html) + msg.set_content(body_plain or subject) + if html.strip(): + msg.add_alternative(html, subtype="html") + + port = int(settings.smtp_port or 587) + user = (settings.smtp_user or "").strip() + password = settings.smtp_password or "" + use_ssl = bool(settings.smtp_ssl) + use_tls = bool(settings.smtp_tls) and not use_ssl + + if use_ssl: + with smtplib.SMTP_SSL(host, port, timeout=30) as smtp: + if user: + smtp.login(user, password) + smtp.send_message(msg) + return + + with smtplib.SMTP(host, port, timeout=30) as smtp: + smtp.ehlo() + if use_tls: + smtp.starttls() + smtp.ehlo() + if user: + smtp.login(user, password) + smtp.send_message(msg) + + +async def send_email( + settings: Settings, + *, + to: str, + subject: str, + html: str, + plain: str | None = None, +) -> None: + await asyncio.to_thread( + send_email_sync, + settings, + to=to, + subject=subject, + html=html, + plain=plain, + ) + + +async def notify_important( + *, + db: Database, + settings: Settings, + telegram_id: int, + html: str, + subject: str, + bot=None, +) -> None: + """Telegram (если есть) + email (если SMTP и email пользователя заданы).""" + user = db.get_user(telegram_id) + if user and user.banned: + return + + want_tg = True if not user else bool(user.notify_telegram) + want_email = True if not user else bool(user.notify_email) + + if want_tg and bot is not None and telegram_id > 0: + try: + await bot.send_message(telegram_id, html, parse_mode="HTML") + except Exception: # noqa: BLE001 + logger.warning("telegram notify failed tg=%s", telegram_id) + + if not want_email or not smtp_configured(settings): + return + email = (user.email if user else None) or "" + if not email.strip(): + return + try: + # Telegram получает компактный HTML; в письме — фирменный шаблон + title = (subject or "VPN Service").replace("— VPN Service", "").strip() or "Уведомление" + mail_html = render_notification_email( + title=title, + body_html=html, + webapp_url=settings.webapp_url or "", + preheader=html_to_plain(html)[:120], + ) + await send_email(settings, to=email.strip(), subject=subject, html=mail_html) + except Exception: # noqa: BLE001 + logger.warning("email notify failed tg=%s to=%s", telegram_id, email, exc_info=True) diff --git a/services/nodes_status.py b/services/nodes_status.py new file mode 100644 index 0000000..3533a9b --- /dev/null +++ b/services/nodes_status.py @@ -0,0 +1,126 @@ +"""Статус и TCP-пинг нод Remnawave.""" + +from __future__ import annotations + +import asyncio +import socket +from dataclasses import dataclass + +from remnawave import RemnawaveSDK + + +@dataclass(frozen=True, slots=True) +class NodeStatus: + name: str + address: str + port: int | None + country_code: str + panel_connected: bool + panel_disabled: bool + tcp_ok: bool + tcp_ms: float | None + users_online: int | None + last_status_message: str | None + + @property + def online(self) -> bool: + if self.panel_disabled: + return False + # Онлайн только если панель видит ноду и TCP отвечает + return self.panel_connected and self.tcp_ok + + @property + def label(self) -> str: + if self.panel_disabled: + return "Выключен" + if self.online: + return "Онлайн" + return "Оффлайн" + + +async def tcp_ping(host: str, port: int, timeout: float = 2.5) -> tuple[bool, float | None]: + loop = asyncio.get_running_loop() + started = loop.time() + + def _connect() -> None: + with socket.create_connection((host, port), timeout=timeout): + return None + + try: + await asyncio.wait_for(asyncio.to_thread(_connect), timeout=timeout + 0.5) + ms = (loop.time() - started) * 1000 + return True, round(ms, 1) + except Exception: # noqa: BLE001 — любой сбой = недоступен + return False, None + + +async def fetch_nodes_status(sdk: RemnawaveSDK) -> list[NodeStatus]: + nodes = await sdk.nodes.get_all_nodes() + results: list[NodeStatus] = [] + + async def _one(node) -> NodeStatus: + port = node.port or 443 + tcp_ok, tcp_ms = await tcp_ping(node.address, port) + return NodeStatus( + name=node.name, + address=node.address, + port=node.port, + country_code=node.country_code or "XX", + panel_connected=bool(node.is_connected), + panel_disabled=bool(node.is_disabled), + tcp_ok=tcp_ok, + tcp_ms=tcp_ms, + users_online=node.users_online, + last_status_message=node.last_status_message, + ) + + tasks = [_one(node) for node in nodes] + if tasks: + results = list(await asyncio.gather(*tasks)) + # сохраняем порядок панели + return results + + +def format_nodes_status(nodes: list[NodeStatus], *, detailed: bool = False) -> str: + if not nodes: + return "Серверы не найдены в панели Remnawave." + + online = sum(1 for n in nodes if n.online) + lines = [ + f"Статус серверов", + f"Онлайн: {online} / {len(nodes)}", + "", + ] + + for n in nodes: + flag = _flag(n.country_code) + if n.online: + icon = "🟢" + ping = f" · {n.tcp_ms:.0f} ms" if n.tcp_ms is not None else "" + extra = f" · 👥 {n.users_online}" if n.users_online is not None else "" + lines.append(f"{icon} {flag} {n.name} — Онлайн{ping}{extra}") + elif n.panel_disabled: + icon = "⚫" + lines.append(f"{icon} {flag} {n.name} — Выключен") + else: + icon = "🔴" + lines.append(f"{icon} {flag} {n.name} — Оффлайн") + if detailed: + reasons: list[str] = [] + if not n.panel_connected: + reasons.append("панель не видит ноду") + if not n.tcp_ok: + reasons.append(f"нет ответа {n.address}:{n.port or 443}") + if n.last_status_message: + reasons.append(n.last_status_message) + if reasons: + lines.append(" └ " + "; ".join(reasons)) + + return "\n".join(lines) + + +def _flag(code: str) -> str: + code = (code or "XX").upper() + if len(code) != 2 or not code.isalpha(): + return "🌐" + return "".join(chr(0x1F1E6 + ord(c) - ord("A")) for c in code) diff --git a/services/openrouter.py b/services/openrouter.py new file mode 100644 index 0000000..e0cd8d3 --- /dev/null +++ b/services/openrouter.py @@ -0,0 +1,273 @@ +"""OpenRouter API: модели и chat completions.""" + +from __future__ import annotations + +import json +import logging +import re +from dataclasses import dataclass, field +from typing import Any + +import httpx + +logger = logging.getLogger(__name__) + +OPENROUTER_BASE = "https://openrouter.ai/api/v1" +DEFAULT_MODELS = ( + "openai/gpt-4o-mini", + "google/gemini-2.5-flash", + "anthropic/claude-3.5-sonnet", + "deepseek/deepseek-chat", + "meta-llama/llama-3.3-70b-instruct", +) + +DEFAULT_SYSTEM_PROMPT = """Ты — ИИ-поддержка VPN-сервиса (Remnawave / VPN Service). +Отвечай кратко, по делу, на русском. Помогай с подключением, клиентами, тарифами, балансом, трафиком и устройствами. + +Правила: +- Опирайся на FAQ и переписку тикета. +- Не выдумывай факты о балансе, платежах и сроках подписки пользователя — если нужны точные данные аккаунта, эскалируй. +- Не обещай возврат денег без эскалации. +- Не раскрывай внутренние API, ключи, админку. +- Если вопрос сложный, спорный, про оплату/возврат/бан или ты не уверен — эскалируй человеку. + +Формат ответа — ТОЛЬКО JSON без markdown: +{"action":"answer","reply":"текст ответа пользователю"} +или +{"action":"escalate","reason":"кратко почему нужен человек"} +""" + + +@dataclass(slots=True) +class OpenRouterConfig: + enabled: bool = False + api_key: str = "" + model: str = DEFAULT_MODELS[0] + models: list[str] = field(default_factory=lambda: list(DEFAULT_MODELS[:3])) + system_prompt: str = DEFAULT_SYSTEM_PROMPT + auto_reply: bool = True + + @property + def ready(self) -> bool: + return bool(self.enabled and self.api_key.strip() and self.model.strip()) + + def models_for_request(self) -> list[str]: + """Primary + fallbacks (без дублей).""" + ordered: list[str] = [] + for mid in [self.model, *self.models]: + mid = (mid or "").strip() + if mid and mid not in ordered: + ordered.append(mid) + return ordered + + +@dataclass(slots=True) +class AiDecision: + action: str # answer | escalate + reply: str = "" + reason: str = "" + model: str = "" + raw: str = "" + + +def _truthy(value: str) -> bool: + return (value or "").strip().lower() in ("1", "true", "yes", "on", "да") + + +def load_openrouter_config(db) -> OpenRouterConfig: + raw = db.get_settings_map( + [ + "openrouter_enabled", + "openrouter_api_key", + "openrouter_model", + "openrouter_models", + "openrouter_system_prompt", + "openrouter_auto_reply", + ] + ) + models: list[str] = [] + models_raw = (raw.get("openrouter_models") or "").strip() + if models_raw: + try: + parsed = json.loads(models_raw) + if isinstance(parsed, list): + models = [str(x).strip() for x in parsed if str(x).strip()] + except json.JSONDecodeError: + models = [x.strip() for x in models_raw.split(",") if x.strip()] + if not models: + models = list(DEFAULT_MODELS[:3]) + + model = (raw.get("openrouter_model") or "").strip() or (models[0] if models else DEFAULT_MODELS[0]) + prompt = (raw.get("openrouter_system_prompt") or "").strip() or DEFAULT_SYSTEM_PROMPT + auto_raw = raw.get("openrouter_auto_reply") + auto_reply = True if auto_raw == "" else _truthy(auto_raw) + + return OpenRouterConfig( + enabled=_truthy(raw.get("openrouter_enabled") or ""), + api_key=(raw.get("openrouter_api_key") or "").strip(), + model=model, + models=models, + system_prompt=prompt, + auto_reply=auto_reply, + ) + + +def mask_api_key(key: str) -> str: + key = (key or "").strip() + if not key: + return "" + if len(key) <= 8: + return "••••••••" + return f"{key[:6]}…{key[-4:]}" + + +def config_public_dict(cfg: OpenRouterConfig) -> dict[str, Any]: + return { + "enabled": cfg.enabled, + "api_key_set": bool(cfg.api_key), + "api_key_masked": mask_api_key(cfg.api_key), + "model": cfg.model, + "models": cfg.models, + "system_prompt": cfg.system_prompt, + "auto_reply": cfg.auto_reply, + "ready": cfg.ready, + "defaults": list(DEFAULT_MODELS), + } + + +def _headers(api_key: str, *, referer: str = "", title: str = "VPN Bot Support") -> dict[str, str]: + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + if referer: + headers["HTTP-Referer"] = referer + headers["X-Title"] = title + return headers + + +async def list_models(api_key: str, *, referer: str = "") -> list[dict[str, Any]]: + async with httpx.AsyncClient(timeout=45.0) as client: + resp = await client.get( + f"{OPENROUTER_BASE}/models", + headers=_headers(api_key, referer=referer), + ) + resp.raise_for_status() + data = resp.json() + items = data.get("data") if isinstance(data, dict) else data + out: list[dict[str, Any]] = [] + for item in items or []: + mid = str(item.get("id") or "").strip() + if not mid: + continue + out.append( + { + "id": mid, + "name": str(item.get("name") or mid), + "context_length": item.get("context_length"), + "pricing": item.get("pricing") or {}, + } + ) + out.sort(key=lambda x: x["id"].lower()) + return out + + +async def chat_completion( + *, + api_key: str, + model: str, + messages: list[dict[str, str]], + models: list[str] | None = None, + referer: str = "", + temperature: float = 0.3, + max_tokens: int = 900, +) -> tuple[str, str]: + """Возвращает (text, used_model).""" + payload: dict[str, Any] = { + "model": model, + "messages": messages, + "temperature": temperature, + "max_tokens": max_tokens, + } + fallbacks = [m for m in (models or []) if m and m != model] + if fallbacks: + payload["models"] = [model, *fallbacks] + payload["route"] = "fallback" + + async with httpx.AsyncClient(timeout=90.0) as client: + resp = await client.post( + f"{OPENROUTER_BASE}/chat/completions", + headers=_headers(api_key, referer=referer), + json=payload, + ) + if resp.status_code >= 400: + detail = resp.text[:500] + raise RuntimeError(f"OpenRouter HTTP {resp.status_code}: {detail}") + data = resp.json() + + used = str(data.get("model") or model) + choices = data.get("choices") or [] + if not choices: + raise RuntimeError("OpenRouter: пустой ответ") + message = choices[0].get("message") or {} + content = message.get("content") + if isinstance(content, list): + parts = [] + for part in content: + if isinstance(part, dict) and part.get("type") == "text": + parts.append(str(part.get("text") or "")) + elif isinstance(part, str): + parts.append(part) + text = "\n".join(parts).strip() + else: + text = str(content or "").strip() + if not text: + raise RuntimeError("OpenRouter: пустой content") + return text, used + + +_JSON_RE = re.compile(r"\{[\s\S]*\}") + + +def parse_ai_decision(raw: str, *, used_model: str = "") -> AiDecision: + text = (raw or "").strip() + if text.startswith("```"): + text = re.sub(r"^```(?:json)?\s*", "", text) + text = re.sub(r"\s*```$", "", text).strip() + + payload: dict[str, Any] | None = None + try: + payload = json.loads(text) + except json.JSONDecodeError: + match = _JSON_RE.search(text) + if match: + try: + payload = json.loads(match.group(0)) + except json.JSONDecodeError: + payload = None + + if isinstance(payload, dict): + action = str(payload.get("action") or "").strip().lower() + reply = str(payload.get("reply") or payload.get("message") or "").strip() + reason = str(payload.get("reason") or "").strip() + if action == "escalate": + return AiDecision( + action="escalate", + reason=reason or reply or "Нужен человек", + model=used_model, + raw=raw, + ) + if action == "answer" and reply: + return AiDecision(action="answer", reply=reply, model=used_model, raw=raw) + if reply and not action: + return AiDecision(action="answer", reply=reply, model=used_model, raw=raw) + + # Fallback: если модель ответила обычным текстом — считаем ответом + if len(text) >= 8 and "{" not in text[:20]: + return AiDecision(action="answer", reply=text, model=used_model, raw=raw) + return AiDecision( + action="escalate", + reason="ИИ вернул неразборчивый ответ", + model=used_model, + raw=raw, + ) diff --git a/services/passwords.py b/services/passwords.py new file mode 100644 index 0000000..7cd51bc --- /dev/null +++ b/services/passwords.py @@ -0,0 +1,49 @@ +"""Хеширование паролей (PBKDF2-SHA256, без внешних зависимостей).""" + +from __future__ import annotations + +import hashlib +import hmac +import secrets + + +_ITERATIONS = 210_000 +_SALT_BYTES = 16 +_DKLEN = 32 + + +def hash_password(password: str) -> str: + salt = secrets.token_bytes(_SALT_BYTES) + digest = hashlib.pbkdf2_hmac( + "sha256", + password.encode("utf-8"), + salt, + _ITERATIONS, + dklen=_DKLEN, + ) + return f"pbkdf2_sha256${_ITERATIONS}${salt.hex()}${digest.hex()}" + + +def verify_password(password: str, password_hash: str) -> bool: + try: + algo, iters_s, salt_hex, digest_hex = password_hash.split("$", 3) + if algo != "pbkdf2_sha256": + return False + iterations = int(iters_s) + salt = bytes.fromhex(salt_hex) + expected = bytes.fromhex(digest_hex) + except (ValueError, TypeError): + return False + + got = hashlib.pbkdf2_hmac( + "sha256", + password.encode("utf-8"), + salt, + iterations, + dklen=len(expected), + ) + return hmac.compare_digest(got, expected) + + +def new_session_token() -> str: + return secrets.token_urlsafe(32) diff --git a/services/payments.py b/services/payments.py new file mode 100644 index 0000000..2939b43 --- /dev/null +++ b/services/payments.py @@ -0,0 +1,105 @@ +"""Зачисление оплаты по инвойсу (CryptoBot / Heleket).""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from remnawave import RemnawaveSDK + +from services.db import Database +from services.purchase import purchase_tariff +from services.tariffs import Tariff, get_tariff + + +@dataclass(frozen=True, slots=True) +class FulfillResult: + ok: bool + message: str + already: bool = False + + +async def fulfill_paid_invoice( + *, + db: Database, + sdk: RemnawaveSDK, + tariffs: list[Tariff], + local: dict[str, Any], +) -> FulfillResult: + """Идемпотентно зачислить оплаченный инвойс.""" + invoice_id = int(local["invoice_id"]) + provider = str(local.get("provider") or "cryptobot") + if local.get("status") == "paid": + return FulfillResult(ok=True, message="Уже зачислено", already=True) + + if not db.mark_invoice_paid(invoice_id, provider=provider): + return FulfillResult(ok=True, message="Уже зачислено", already=True) + + kind = local["kind"] + amount = float(local["amount_rub"]) + telegram_id = int(local["telegram_id"]) + try: + db.log_payment_event( + provider=provider, + event_type=f"fulfill:{kind}", + telegram_id=telegram_id, + amount=amount, + invoice_ref=str(invoice_id), + status="ok", + ) + except Exception: # noqa: BLE001 + pass + meta_prefix = f"{provider}:{invoice_id}" + + if kind == "topup": + if provider == "lava": + balance_kind = "lava_topup" + via = "Lava" + elif provider == "anypay": + balance_kind = "anypay_topup" + via = "AnyPay" + else: + balance_kind = "crypto_topup" + via = f"криптой ({provider})" + balance = db.add_balance( + telegram_id, + amount, + kind=balance_kind, + meta=meta_prefix, + ) + return FulfillResult( + ok=True, + message=( + f"✅ Баланс пополнен\n\n" + f"+{amount:.0f} ₽ через {via}\n" + f"Доступно: {balance:.0f} ₽" + ), + ) + + if kind == "tariff": + tariff = get_tariff(tariffs, str(local.get("tariff_id") or "")) + if not tariff: + balance = db.add_balance( + telegram_id, + amount, + kind="crypto_topup", + meta=f"{meta_prefix}:unknown_tariff", + ) + return FulfillResult( + ok=True, + message=( + "Оплата получена, тариф не найден — зачислено на баланс: " + f"{balance:.0f} ₽" + ), + ) + result = await purchase_tariff( + db=db, + sdk=sdk, + telegram_id=telegram_id, + tariff=tariff, + charge_balance=False, + payment_meta=meta_prefix, + ) + return FulfillResult(ok=result.ok, message=result.message) + + return FulfillResult(ok=False, message="Неизвестный тип счёта") diff --git a/services/payments_runtime.py b/services/payments_runtime.py new file mode 100644 index 0000000..1fd6c8e --- /dev/null +++ b/services/payments_runtime.py @@ -0,0 +1,233 @@ +"""Сборка платёжных клиентов и публичный payload для админки.""" + +from __future__ import annotations + +from typing import Any + +from config import Settings, get_settings +from services.anypay import Anypay +from services.cryptopay import CryptoPay +from services.digiseller import Digiseller +from services.env_file import ( + BOOL_ENV_KEYS, + PAYMENT_ENV_KEYS, + SECRET_ENV_KEYS, + env_file_path, + env_writable, + mask_secret, + upsert_env_keys, +) +from services.heleket import Heleket +from services.lava import Lava + + +def build_payment_clients(settings: Settings) -> dict[str, Any]: + return { + "cryptopay": CryptoPay( + settings.crypto_bot_token, + testnet=settings.crypto_pay_testnet, + ), + "heleket": Heleket( + settings.heleket_merchant_id, + settings.heleket_api_key, + callback_url=settings.heleket_callback_url, + currency=settings.heleket_currency, + ), + "digiseller": Digiseller( + settings.digiseller_seller_id, + settings.digiseller_api_key, + product_ids=settings.digiseller_product_ids, + pay_url=settings.digiseller_pay_url, + credit_from=settings.digiseller_credit_from, + ), + "lava": Lava( + settings.lava_shop_id, + settings.lava_secret_key, + additional_key=settings.lava_additional_key, + hook_url=settings.lava_hook_url, + success_url=settings.lava_success_url, + fail_url=settings.lava_fail_url, + expire_minutes=settings.lava_expire_minutes, + ), + "anypay": Anypay( + settings.anypay_api_id, + settings.anypay_api_key, + settings.anypay_project_id, + currency=settings.anypay_currency, + method=settings.anypay_method, + success_url=settings.anypay_success_url, + fail_url=settings.anypay_fail_url, + default_email=settings.anypay_email or "pay@anypay.local", + check_ip=settings.anypay_check_ip, + ), + } + + +def apply_payment_runtime(app: Any, settings: Settings) -> dict[str, Any]: + clients = build_payment_clients(settings) + app["settings"] = settings + app["cryptopay"] = clients["cryptopay"] + app["heleket"] = clients["heleket"] + app["digiseller"] = clients["digiseller"] + app["lava"] = clients["lava"] + app["anypay"] = clients["anypay"] + + inject = app.get("inject") + if inject is not None: + inject.settings = settings + inject.cryptopay = clients["cryptopay"] + inject.heleket = clients["heleket"] + inject.digiseller = clients["digiseller"] + inject.lava = clients["lava"] + inject.anypay = clients["anypay"] + return clients + + +def payments_public_dict(settings: Settings) -> dict[str, Any]: + path = env_file_path() + return { + "env_path": str(path), + "env_writable": env_writable(path), + "crypto": { + "enabled": bool(settings.crypto_bot_token), + "token_set": bool(settings.crypto_bot_token), + "token_hint": mask_secret(settings.crypto_bot_token), + "testnet": bool(settings.crypto_pay_testnet), + }, + "heleket": { + "enabled": bool(settings.heleket_merchant_id and settings.heleket_api_key), + "merchant_id": settings.heleket_merchant_id, + "api_key_set": bool(settings.heleket_api_key), + "api_key_hint": mask_secret(settings.heleket_api_key), + "callback_url": settings.heleket_callback_url, + "currency": settings.heleket_currency or "RUB", + }, + "lava": { + "enabled": bool(settings.lava_shop_id and settings.lava_secret_key), + "shop_id": settings.lava_shop_id, + "secret_key_set": bool(settings.lava_secret_key), + "secret_key_hint": mask_secret(settings.lava_secret_key), + "additional_key_set": bool(settings.lava_additional_key), + "additional_key_hint": mask_secret(settings.lava_additional_key), + "hook_url": settings.lava_hook_url, + "success_url": settings.lava_success_url, + "fail_url": settings.lava_fail_url, + "expire_minutes": settings.lava_expire_minutes, + }, + "anypay": { + "enabled": bool( + settings.anypay_api_id + and settings.anypay_api_key + and settings.anypay_project_id + ), + "api_id": settings.anypay_api_id, + "api_key_set": bool(settings.anypay_api_key), + "api_key_hint": mask_secret(settings.anypay_api_key), + "project_id": settings.anypay_project_id, + "currency": settings.anypay_currency or "RUB", + "method": settings.anypay_method, + "success_url": settings.anypay_success_url, + "fail_url": settings.anypay_fail_url, + "email": settings.anypay_email, + "check_ip": bool(settings.anypay_check_ip), + "notify_url": ( + f"{settings.webapp_url.rstrip('/')}/api/payments/anypay" + if settings.webapp_url + else "/api/payments/anypay" + ), + }, + "digiseller": { + "enabled": bool(settings.digiseller_seller_id and settings.digiseller_api_key), + "seller_id": settings.digiseller_seller_id, + "api_key_set": bool(settings.digiseller_api_key), + "api_key_hint": mask_secret(settings.digiseller_api_key), + "product_ids": ",".join(str(x) for x in settings.digiseller_product_ids), + "pay_url": settings.digiseller_pay_url, + "credit_from": settings.digiseller_credit_from or "cnt_goods", + "callback_url": settings.digiseller_callback_url, + }, + "stars": { + "enabled": bool(settings.stars_enabled), + "rub_per_star": settings.stars_rub_per_star, + }, + "topup_code_pay_url": settings.topup_code_pay_url, + } + + +def _is_masked_placeholder(value: str) -> bool: + v = (value or "").strip() + if not v: + return True + return "…" in v or "•" in v or "..." in v + + +def apply_payments_body(body: dict[str, Any]) -> Settings: + """Применить тело POST админки: .env + os.environ + новый Settings.""" + updates: dict[str, str | None] = {} + + mapping = { + "CRYPTO_BOT_TOKEN": body.get("crypto_bot_token"), + "CRYPTO_PAY_TESTNET": body.get("crypto_pay_testnet"), + "HELEKET_MERCHANT_ID": body.get("heleket_merchant_id"), + "HELEKET_API_KEY": body.get("heleket_api_key"), + "HELEKET_CALLBACK_URL": body.get("heleket_callback_url"), + "HELEKET_CURRENCY": body.get("heleket_currency"), + "LAVA_SHOP_ID": body.get("lava_shop_id"), + "LAVA_SECRET_KEY": body.get("lava_secret_key"), + "LAVA_ADDITIONAL_KEY": body.get("lava_additional_key"), + "LAVA_HOOK_URL": body.get("lava_hook_url"), + "LAVA_SUCCESS_URL": body.get("lava_success_url"), + "LAVA_FAIL_URL": body.get("lava_fail_url"), + "LAVA_EXPIRE_MINUTES": body.get("lava_expire_minutes"), + "ANYPAY_API_ID": body.get("anypay_api_id"), + "ANYPAY_API_KEY": body.get("anypay_api_key"), + "ANYPAY_PROJECT_ID": body.get("anypay_project_id"), + "ANYPAY_CURRENCY": body.get("anypay_currency"), + "ANYPAY_METHOD": body.get("anypay_method"), + "ANYPAY_SUCCESS_URL": body.get("anypay_success_url"), + "ANYPAY_FAIL_URL": body.get("anypay_fail_url"), + "ANYPAY_EMAIL": body.get("anypay_email"), + "ANYPAY_CHECK_IP": body.get("anypay_check_ip"), + "DIGISELLER_SELLER_ID": body.get("digiseller_seller_id"), + "DIGISELLER_API_KEY": body.get("digiseller_api_key"), + "DIGISELLER_PRODUCT_IDS": body.get("digiseller_product_ids"), + "DIGISELLER_PAY_URL": body.get("digiseller_pay_url"), + "DIGISELLER_CREDIT_FROM": body.get("digiseller_credit_from"), + "DIGISELLER_CALLBACK_URL": body.get("digiseller_callback_url"), + "STARS_ENABLED": body.get("stars_enabled"), + "STARS_RUB_PER_STAR": body.get("stars_rub_per_star"), + "TOPUP_CODE_PAY_URL": body.get("topup_code_pay_url"), + } + + for env_key, raw in mapping.items(): + if env_key not in PAYMENT_ENV_KEYS: + continue + if raw is None: + continue + if env_key in BOOL_ENV_KEYS: + if isinstance(raw, bool): + updates[env_key] = "true" if raw else "false" + else: + updates[env_key] = ( + "true" + if str(raw).strip().lower() in {"1", "true", "yes", "on"} + else "false" + ) + continue + + text = str(raw).strip() + if env_key in SECRET_ENV_KEYS: + if text == "__clear__": + updates[env_key] = None + elif _is_masked_placeholder(text): + continue + else: + updates[env_key] = text + continue + + updates[env_key] = text if text else None + + if updates: + upsert_env_keys(updates) + + return get_settings(require_bot_token=False) diff --git a/services/pg_backup.py b/services/pg_backup.py new file mode 100644 index 0000000..8319691 --- /dev/null +++ b/services/pg_backup.py @@ -0,0 +1,284 @@ +"""Бэкап / восстановление PostgreSQL через pg_dump и psql.""" + +from __future__ import annotations + +import asyncio +import gzip +import logging +import os +import re +import shutil +from datetime import datetime, timezone +from pathlib import Path + +logger = logging.getLogger(__name__) + +BACKUP_DIR = Path(os.getenv("BACKUP_DIR") or "/app/backups") +_SAFE_NAME = re.compile(r"^vpnbot-[\w.\-]+\.sql\.gz$", re.I) +_UPLOAD_NAME = re.compile(r"^[\w.\-]+\.sql(\.gz)?$", re.I) + + +def _pg_env() -> tuple[str, str, str, str, str]: + user = os.getenv("POSTGRES_USER") or "vpnbot" + dbname = os.getenv("POSTGRES_DB") or "vpnbot" + password = os.getenv("POSTGRES_PASSWORD") or "vpnbot" + host = os.getenv("POSTGRES_HOST") or "db" + port = os.getenv("POSTGRES_PORT") or "5432" + return user, dbname, password, host, port + + +def resolve_backup_path(name: str) -> Path: + """Безопасный путь к файлу бэкапа (без path traversal).""" + base = Path(name).name + if not _SAFE_NAME.match(base): + raise ValueError("Некорректное имя файла бэкапа") + path = (BACKUP_DIR / base).resolve() + root = BACKUP_DIR.resolve() + if path.parent != root or not path.is_file(): + raise FileNotFoundError("Бэкап не найден") + return path + + +def delete_backup(name: str) -> None: + path = resolve_backup_path(name) + path.unlink() + + +async def _run_pg_dump_to_host() -> bytes: + """pg_dump по сети Docker к сервису db.""" + user, dbname, password, host, port = _pg_env() + pg_dump = shutil.which("pg_dump") + if not pg_dump: + raise FileNotFoundError( + "pg_dump не найден. Пересоберите образ: docker compose up -d --build bot" + ) + + env = os.environ.copy() + env["PGPASSWORD"] = password + proc = await asyncio.create_subprocess_exec( + pg_dump, + "-h", + host, + "-p", + port, + "-U", + user, + "-d", + dbname, + "--no-owner", + "--no-acl", + "--clean", + "--if-exists", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + env=env, + ) + stdout, stderr = await proc.communicate() + if proc.returncode != 0: + raise RuntimeError((stderr or b"").decode("utf-8", "replace")[:500] or "pg_dump failed") + if not stdout: + raise RuntimeError("pg_dump вернул пустой дамп") + return stdout + + +async def _run_pg_dump_via_docker(compose_dir: Path | None) -> bytes: + user, dbname, password, _host, _port = _pg_env() + if not shutil.which("docker"): + raise FileNotFoundError("docker не найден") + + cmd = [ + "docker", + "compose", + "exec", + "-T", + "-e", + f"PGPASSWORD={password}", + "db", + "pg_dump", + "-U", + user, + "-d", + dbname, + "--no-owner", + "--no-acl", + "--clean", + "--if-exists", + ] + proc = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=str(compose_dir) if compose_dir else None, + ) + stdout, stderr = await proc.communicate() + if proc.returncode != 0: + raise RuntimeError((stderr or b"").decode("utf-8", "replace")[:500] or "docker pg_dump failed") + if not stdout: + raise RuntimeError("docker pg_dump вернул пустой дамп") + return stdout + + +async def run_pg_dump_backup( + *, + compose_dir: Path | None = None, + keep: int = 14, +) -> Path: + BACKUP_DIR.mkdir(parents=True, exist_ok=True) + stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") + out = BACKUP_DIR / f"vpnbot-{stamp}.sql.gz" + + errors: list[str] = [] + stdout: bytes | None = None + + try: + stdout = await _run_pg_dump_to_host() + except Exception as exc: # noqa: BLE001 + errors.append(f"network pg_dump: {exc}") + logger.warning("backup via host pg_dump failed: %s", exc) + + if stdout is None: + try: + stdout = await _run_pg_dump_via_docker(compose_dir) + except Exception as exc: # noqa: BLE001 + errors.append(f"docker pg_dump: {exc}") + logger.warning("backup via docker failed: %s", exc) + + if stdout is None: + raise RuntimeError( + "Не удалось сделать бэкап. " + + " | ".join(errors) + + ". Убедитесь, что bot пересобран с postgresql-client." + ) + + with gzip.open(out, "wb") as fh: + fh.write(stdout) + + _prune(BACKUP_DIR, keep=keep) + logger.info("postgres backup written %s (%s bytes)", out, len(stdout)) + return out + + +def _prune(directory: Path, *, keep: int) -> None: + files = sorted( + directory.glob("vpnbot-*.sql.gz"), + key=lambda p: p.stat().st_mtime, + reverse=True, + ) + for old in files[max(0, keep) :]: + try: + old.unlink() + except OSError: + pass + + +def list_backups(limit: int = 30) -> list[dict]: + BACKUP_DIR.mkdir(parents=True, exist_ok=True) + files = sorted( + BACKUP_DIR.glob("vpnbot-*.sql.gz"), + key=lambda p: p.stat().st_mtime, + reverse=True, + ) + out = [] + for p in files[:limit]: + st = p.stat() + out.append( + { + "name": p.name, + "size": st.st_size, + "mtime": datetime.fromtimestamp(st.st_mtime, tz=timezone.utc).isoformat(), + } + ) + return out + + +def _gunzip_if_needed(raw: bytes, *, filename: str = "") -> bytes: + name = (filename or "").lower() + if name.endswith(".gz") or raw[:2] == b"\x1f\x8b": + return gzip.decompress(raw) + return raw + + +async def _run_psql_sql(sql: bytes) -> None: + user, dbname, password, host, port = _pg_env() + psql = shutil.which("psql") + if not psql: + raise FileNotFoundError( + "psql не найден. Пересоберите образ: docker compose up -d --build bot" + ) + + # Чистый restore: рвём сессии и пересоздаём schema public, + # иначе старые дампы без DROP падают на «relation already exists». + safe_user = user.replace('"', "") + pre = ( + "SELECT pg_terminate_backend(pid) FROM pg_stat_activity " + f"WHERE datname = '{dbname}' AND pid <> pg_backend_pid();\n" + "DROP SCHEMA IF EXISTS public CASCADE;\n" + "CREATE SCHEMA public;\n" + f'GRANT ALL ON SCHEMA public TO "{safe_user}";\n' + "GRANT ALL ON SCHEMA public TO public;\n" + f'ALTER SCHEMA public OWNER TO "{safe_user}";\n' + ).encode("utf-8") + + env = os.environ.copy() + env["PGPASSWORD"] = password + proc = await asyncio.create_subprocess_exec( + psql, + "-h", + host, + "-p", + port, + "-U", + user, + "-d", + dbname, + "-v", + "ON_ERROR_STOP=1", + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + env=env, + ) + _stdout, stderr = await proc.communicate(pre + sql) + if proc.returncode != 0: + err = (stderr or b"").decode("utf-8", "replace")[:800] + raise RuntimeError(err or "psql restore failed") + logger.info("postgres restore ok (%s bytes sql)", len(sql)) + + +async def restore_from_bytes(raw: bytes, *, filename: str = "") -> None: + if not raw: + raise ValueError("Пустой файл") + if len(raw) > 200 * 1024 * 1024: + raise ValueError("Файл слишком большой (лимит 200 МБ)") + sql = _gunzip_if_needed(raw, filename=filename) + if not sql.strip(): + raise ValueError("Дамп пустой после распаковки") + head = sql[:4000].decode("utf-8", "replace").lower() + if ( + "create " not in head + and "drop " not in head + and "copy " not in head + and "insert " not in head + ): + raise ValueError("Файл не похож на SQL-дамп PostgreSQL") + await _run_psql_sql(sql) + + +async def restore_from_backup_name(name: str) -> None: + path = resolve_backup_path(name) + raw = path.read_bytes() + await restore_from_bytes(raw, filename=path.name) + + +async def save_uploaded_backup(raw: bytes, *, original_name: str = "") -> Path: + """Сохранить загруженный дамп как vpnbot-*.sql.gz на диск.""" + BACKUP_DIR.mkdir(parents=True, exist_ok=True) + base = Path(original_name or "upload.sql.gz").name + if not _UPLOAD_NAME.match(base): + base = "upload.sql.gz" + sql = _gunzip_if_needed(raw, filename=base) + stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") + out = BACKUP_DIR / f"vpnbot-{stamp}.sql.gz" + with gzip.open(out, "wb") as fh: + fh.write(sql) + return out diff --git a/services/purchase.py b/services/purchase.py new file mode 100644 index 0000000..44efbb5 --- /dev/null +++ b/services/purchase.py @@ -0,0 +1,674 @@ +"""Покупка тарифа: баланс + создание/продление пользователя Remnawave.""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone + +from remnawave import RemnawaveSDK +from remnawave.enums import TrafficLimitStrategy, UserStatus +from remnawave.models import CreateUserRequestDto, UpdateUserRequestDto + +from remnawave_client import friendly_remnawave_error +from services.db import Database +from services.devices import ( + BASE_DEVICES, + DEVICE_PACKS, + EXTRA_DEVICE_PRICE_RUB, + DevicePack, + DeviceUsage, +) +from services.extras_billing import ( + effective_extra_devices, + extras_billing_line, + on_extras_purchased, + process_user_extras_billing, +) +from services.tariffs import Tariff +from services.traffic import ( + BASE_TRAFFIC_GB, + TrafficPack, + TrafficUsage, + ensure_base_limit_bytes, + format_gb, + gb_to_bytes, + traffic_from_panel_user, +) + + +from services.remnawave_users import ( + device_limit_for_user as _device_limit_for_user, + find_panel_user as _find_panel_user, + sync_device_policy, + username_for, +) + +logger = logging.getLogger(__name__) + + +def _as_utc(dt: datetime) -> datetime: + if dt.tzinfo is None: + return dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc) + + +def _traffic_limit_for_update(existing, tariff: Tariff) -> float: + """При продлении не затираем докупленный трафик, но не меньше базы тарифа.""" + current = ensure_base_limit_bytes(getattr(existing, "traffic_limit_bytes", 0)) + return max(current, float(tariff.traffic_bytes)) + + +async def provision_subscription( + sdk: RemnawaveSDK, + *, + telegram_id: int, + tariff: Tariff, + login: str | None = None, + extra_devices: int = 0, +) -> tuple[str, str, datetime]: + """Создать или продлить пользователя. Возвращает (uuid, sub_url, expire_at).""" + now = datetime.now(timezone.utc) + existing = await _find_panel_user(sdk, telegram_id, login=login) + + panel_tg_id = telegram_id if telegram_id > 0 else None + device_limit = _device_limit_for_user(extra_devices, soft_cap=True) + + if existing is not None: + current_expire = _as_utc(existing.expire_at) + base = current_expire if current_expire > now else now + new_expire = base + timedelta(days=tariff.days) + body = UpdateUserRequestDto( + uuid=existing.uuid, + status=UserStatus.ACTIVE, + expire_at=new_expire, + telegram_id=panel_tg_id, + hwid_device_limit=device_limit, + traffic_limit_bytes=_traffic_limit_for_update(existing, tariff), + traffic_limit_strategy=TrafficLimitStrategy.NO_RESET, + active_internal_squads=list(tariff.squad_uuids) or None, + ) + updated = await sdk.users.update_user(body) + return str(updated.uuid), updated.subscription_url, _as_utc(updated.expire_at) + + new_expire = now + timedelta(days=tariff.days) + created = await sdk.users.create_user( + CreateUserRequestDto( + username=username_for(telegram_id, login=login), + expire_at=new_expire, + status=UserStatus.ACTIVE, + telegram_id=panel_tg_id, + hwid_device_limit=device_limit, + traffic_limit_bytes=float(tariff.traffic_bytes), + traffic_limit_strategy=TrafficLimitStrategy.NO_RESET, + active_internal_squads=list(tariff.squad_uuids) or None, + description=f"Bought via bot: {tariff.id}", + tag="BOT", + ) + ) + return str(created.uuid), created.subscription_url, _as_utc(created.expire_at) + + +async def purchase_tariff( + *, + db: Database, + sdk: RemnawaveSDK, + telegram_id: int, + tariff: Tariff, + charge_balance: bool = True, + payment_meta: str | None = None, + renew: bool = False, + promo_code: str | None = None, +) -> PurchaseResult: + bot_user = db.ensure_user(telegram_id) + if bot_user.banned: + return PurchaseResult( + ok=False, + message="Аккаунт заблокирован. Обратитесь в поддержку.", + balance=bot_user.balance, + ) + balance = bot_user.balance + login = bot_user.login + + if renew: + existing = await _find_panel_user(sdk, telegram_id, login=login) + if existing is None: + return PurchaseResult( + ok=False, + message=( + "Нет подписки для продления.\n" + "Сначала купи тариф в разделе «Тарифы»." + ), + balance=balance, + ) + + charge_price = float(tariff.price) + bonus_days = 0 + promo_row = None + if promo_code and charge_balance: + from services.db import AuthError + + try: + charge_price, bonus_days, promo_row = db.apply_promo_to_price( + code=promo_code, + telegram_id=telegram_id, + tariff_id=tariff.id, + price=float(tariff.price), + days=tariff.days, + ) + except AuthError as exc: + return PurchaseResult(ok=False, message=str(exc), balance=balance) + + effective_tariff = tariff + if bonus_days > 0: + effective_tariff = Tariff( + id=tariff.id, + title=tariff.title, + price=tariff.price, + days=tariff.days + bonus_days, + traffic_gb=tariff.traffic_gb, + device_limit=tariff.device_limit, + squad_uuids=tariff.squad_uuids, + description=tariff.description, + ) + + if charge_balance: + if charge_price > 0: + ok, balance = db.try_charge( + telegram_id, + charge_price, + kind="renewal" if renew else "purchase", + meta=payment_meta + or ( + f"promo:{promo_row['code']}:" if promo_row else "" + ) + + ("renew:" + tariff.id if renew else tariff.id), + ) + if not ok: + return PurchaseResult( + ok=False, + message=( + f"Недостаточно средств.\n" + f"Нужно: {charge_price:.0f} ₽\n" + f"Баланс: {balance:.0f} ₽\n\n" + "Пополни баланс криптой или через админа." + ), + balance=balance, + ) + # charge_price == 0 — бесплатно по промо + + try: + rw_uuid, sub_url, expire_at = await provision_subscription( + sdk, + telegram_id=telegram_id, + tariff=effective_tariff, + login=login, + extra_devices=effective_extra_devices(bot_user), + ) + except Exception as exc: # noqa: BLE001 + if charge_balance and charge_price > 0: + balance = db.add_balance( + telegram_id, + charge_price, + kind="refund", + meta=f"refund:{tariff.id}:{exc}", + ) + return PurchaseResult( + ok=False, + message=( + "Не удалось продлить/выдать подписку. Средства возвращены.\n\n" + + friendly_remnawave_error(exc) + ), + balance=balance, + ) + return PurchaseResult( + ok=False, + message=( + "Оплата прошла, но выдача подписки упала. Напиши админу.\n\n" + + friendly_remnawave_error(exc) + ), + balance=balance, + ) + + if promo_row and charge_balance: + saved = max(0.0, float(tariff.price) - charge_price) + try: + db.redeem_promo( + promo_id=int(promo_row["id"]), + telegram_id=telegram_id, + amount_saved=saved, + ) + except Exception: # noqa: BLE001 + logger.exception("promo redeem failed") + + db.record_purchase( + telegram_id, + tariff_id=tariff.id, + price=charge_price if charge_balance else float(tariff.price), + remnawave_uuid=rw_uuid, + subscription_url=sub_url, + ) + + if not renew and charge_balance: + try: + ref_bonus = float(db.get_setting("referral_bonus_referrer") or "50") + welcome = float(db.get_setting("referral_bonus_referee") or "50") + db.grant_referral_rewards_on_first_purchase( + telegram_id, + referrer_bonus=max(0.0, ref_bonus), + referee_bonus=max(0.0, welcome), + ) + except Exception: # noqa: BLE001 + logger.exception("referral reward failed") + + try: + from services.xui_wg import sync_wg_configs_limits + from services.awg_configs import sync_awg_configs_expiry + + await sync_wg_configs_limits( + db=db, + telegram_id=telegram_id, + expire_at=expire_at, + restore_enabled=True, + ) + await sync_awg_configs_expiry( + db=db, + sdk=sdk, + telegram_id=telegram_id, + login=login, + restore_enabled=True, + ) + except Exception: # noqa: BLE001 + logger.exception("WG/AWG expiry sync after tariff purchase failed") + + expire_str = expire_at.strftime("%d.%m.%Y %H:%M UTC") + if renew: + paid_how = "с баланса" if charge_balance else "криптой" + return PurchaseResult( + ok=True, + message=( + f"✅ Подписка продлена\n\n" + f"Тариф: {tariff.title} (+{tariff.days} дн.)\n" + f"Списано: {tariff.format_price()} ({paid_how})\n" + f"Баланс: {balance:.0f} ₽\n" + f"Новый срок: {expire_str}\n\n" + f"🔗 Подписка:\n{sub_url}" + ), + balance=balance, + subscription_url=sub_url, + expire_at=expire_at, + ) + + paid_how = "с баланса" if charge_balance else "криптой" + return PurchaseResult( + ok=True, + message=( + f"✅ Тариф «{tariff.title}» активирован\n\n" + f"Оплата: {tariff.format_price()} ({paid_how})\n" + f"Баланс: {balance:.0f} ₽\n" + f"Действует до: {expire_str}\n\n" + f"🔗 Подписка:\n{sub_url}" + ), + balance=balance, + subscription_url=sub_url, + expire_at=expire_at, + ) + + +@dataclass(frozen=True, slots=True) +class PurchaseResult: + ok: bool + message: str + balance: float + subscription_url: str | None = None + expire_at: datetime | None = None + traffic: TrafficUsage | None = None + devices: DeviceUsage | None = None + + +@dataclass(frozen=True, slots=True) +class SubscriptionInfo: + text: str + has_subscription: bool + expire_at: datetime | None = None + subscription_url: str | None = None + traffic: TrafficUsage | None = None + devices: DeviceUsage | None = None + + +async def get_my_subscription_info( + sdk: RemnawaveSDK, + telegram_id: int, + *, + login: str | None = None, + db: Database | None = None, + tariffs: list[Tariff] | None = None, + renewal_bot=None, +) -> SubscriptionInfo: + user = await _find_panel_user(sdk, telegram_id, login=login) + if user is None: + return SubscriptionInfo( + text=( + "Моя подписка\n\n" + "У тебя пока нет подписки.\n" + "Купи тариф в разделе «Тарифы».\n\n" + f"База: {format_gb(BASE_TRAFFIC_GB, digits=0)} · " + f"{BASE_DEVICES} устройств\n" + f"Докупка: +500 ГБ = 50 ₽/мес · +1 устройство = " + f"{int(EXTRA_DEVICE_PRICE_RUB)} ₽/мес\n" + "Доп. опции списываются с баланса каждые 30 дней." + ), + has_subscription=False, + ) + + bot_user = db.ensure_user(telegram_id) if db is not None else None + if db is not None: + await process_user_extras_billing( + db, sdk, telegram_id, login=login + ) + if tariffs: + from services.subscription_renewal import ( + process_user_subscription_renewal, + ) + + await process_user_subscription_renewal( + db, + sdk, + tariffs, + telegram_id, + bot=renewal_bot, + login=login, + ) + bot_user = db.get_user(telegram_id) + + extra_paid = effective_extra_devices(bot_user) if bot_user else 0 + device_blocked = bot_user.device_blocked if bot_user else False + + # Старые «безлимит» (0) → база 1000 ГБ + if float(getattr(user, "traffic_limit_bytes", 0) or 0) <= 0: + try: + user = await sdk.users.update_user( + UpdateUserRequestDto( + uuid=user.uuid, + traffic_limit_bytes=gb_to_bytes(BASE_TRAFFIC_GB), + traffic_limit_strategy=TrafficLimitStrategy.NO_RESET, + ) + ) + except Exception: # noqa: BLE001 + pass + + devices: DeviceUsage | None = None + if db is not None: + user, devices = await sync_device_policy( + sdk, + user, + db=db, + telegram_id=telegram_id, + extra_paid=extra_paid, + device_blocked=device_blocked, + ) + + expire = _as_utc(user.expire_at) + now = datetime.now(timezone.utc) + if devices and devices.over_limit: + status = "отключена (превышение устройств)" + elif expire > now and user.status == UserStatus.ACTIVE: + status = "активна" + else: + status = "истекла / неактивна" + traffic = traffic_from_panel_user(user) + devices_line = devices.format_line() if devices else f"📱 Устройства: база {BASE_DEVICES}" + extras_line = extras_billing_line(bot_user) if bot_user else "" + text = ( + f"Моя подписка\n\n" + f"Статус: {status}\n" + f"До: {expire.strftime('%d.%m.%Y %H:%M UTC')}\n" + f"{devices_line}\n" + f"{traffic.format_line()}\n" + f"{extras_line}\n\n" + f"🔗 {user.subscription_url}\n\n" + f"Докупка: трафик +500 ГБ / 50 ₽/мес · устройство +1 / " + f"{int(EXTRA_DEVICE_PRICE_RUB)} ₽/мес" + ) + return SubscriptionInfo( + text=text, + has_subscription=True, + expire_at=expire, + subscription_url=user.subscription_url, + traffic=traffic, + devices=devices, + ) + + +async def purchase_device_pack( + *, + db: Database, + sdk: RemnawaveSDK, + telegram_id: int, + pack: DevicePack, + charge_balance: bool = True, + login: str | None = None, + payment_meta: str | None = None, +) -> PurchaseResult: + bot_user = db.ensure_user(telegram_id) + balance = bot_user.balance + login = login or bot_user.login + + existing = await _find_panel_user(sdk, telegram_id, login=login) + if existing is None: + return PurchaseResult( + ok=False, + message="Нет подписки. Сначала купи тариф (база 10 устройств).", + balance=balance, + ) + + if charge_balance: + ok, balance = db.try_charge( + telegram_id, + pack.price, + kind="devices", + meta=payment_meta or f"devices:{pack.id}", + ) + if not ok: + return PurchaseResult( + ok=False, + message=( + f"Недостаточно средств.\n" + f"Нужно: {pack.format_price()}\n" + f"Баланс: {balance:.0f} ₽" + ), + balance=balance, + ) + + new_extra = db.add_extra_devices(telegram_id, pack.slots) + on_extras_purchased(db, telegram_id) + try: + updated, devices = await sync_device_policy( + sdk, + existing, + db=db, + telegram_id=telegram_id, + extra_paid=new_extra, + device_blocked=False, + ) + except Exception as exc: # noqa: BLE001 + if charge_balance: + # откат слотов и денег + with db.pool.connection() as conn: + conn.execute( + """ + UPDATE users + SET extra_devices = GREATEST(extra_devices - %s, 0) + WHERE telegram_id = %s + """, + (pack.slots, telegram_id), + ) + conn.commit() + balance = db.add_balance( + telegram_id, + pack.price, + kind="refund", + meta=f"refund:devices:{pack.id}:{exc}", + ) + return PurchaseResult( + ok=False, + message=( + "Не удалось обновить лимит устройств. Средства возвращены.\n\n" + + friendly_remnawave_error(exc) + ), + balance=balance, + ) + return PurchaseResult( + ok=False, + message="Оплата прошла, но лимит не обновился. Напиши админу.", + balance=balance, + ) + + return PurchaseResult( + ok=True, + message=( + f"✅ Лимит устройств увеличен\n\n" + f"Добавлено: +{pack.slots}\n" + f"Списано: {pack.format_price()} (первый месяц)\n" + f"Далее — {int(EXTRA_DEVICE_PRICE_RUB)} ₽/мес за каждое доп. устройство с баланса.\n" + f"Баланс: {balance:.0f} ₽\n" + f"{devices.format_line()}" + ), + balance=balance, + subscription_url=getattr(updated, "subscription_url", None), + devices=devices, + ) + + +async def purchase_traffic_pack( + *, + db: Database, + sdk: RemnawaveSDK, + telegram_id: int, + pack: TrafficPack, + charge_balance: bool = True, + login: str | None = None, + payment_meta: str | None = None, +) -> PurchaseResult: + bot_user = db.ensure_user(telegram_id) + balance = bot_user.balance + login = login or bot_user.login + + existing = await _find_panel_user(sdk, telegram_id, login=login) + if existing is None: + return PurchaseResult( + ok=False, + message=( + "Нет активной подписки.\n" + "Сначала купи тариф — база 1000 ГБ уже входит." + ), + balance=balance, + ) + + if charge_balance: + ok, balance = db.try_charge( + telegram_id, + pack.price, + kind="traffic", + meta=payment_meta or f"traffic:{pack.id}", + ) + if not ok: + return PurchaseResult( + ok=False, + message=( + f"Недостаточно средств.\n" + f"Нужно: {pack.format_price()} (первый месяц)\n" + f"Баланс: {balance:.0f} ₽" + ), + balance=balance, + ) + + db.add_extra_traffic_packs(telegram_id, pack.packs) + on_extras_purchased(db, telegram_id) + + current_limit = ensure_base_limit_bytes(existing.traffic_limit_bytes) + new_limit = current_limit + gb_to_bytes(pack.gb) + + try: + updated = await sdk.users.update_user( + UpdateUserRequestDto( + uuid=existing.uuid, + traffic_limit_bytes=new_limit, + traffic_limit_strategy=TrafficLimitStrategy.NO_RESET, + status=UserStatus.ACTIVE, + ) + ) + except Exception as exc: # noqa: BLE001 + with db.pool.connection() as conn: + conn.execute( + """ + UPDATE users + SET extra_traffic_packs = GREATEST(extra_traffic_packs - %s, 0) + WHERE telegram_id = %s + """, + (pack.packs, telegram_id), + ) + conn.commit() + if charge_balance: + balance = db.add_balance( + telegram_id, + pack.price, + kind="refund", + meta=f"refund:traffic:{pack.id}:{exc}", + ) + return PurchaseResult( + ok=False, + message=( + "Не удалось увеличить трафик. Средства возвращены.\n\n" + + friendly_remnawave_error(exc) + ), + balance=balance, + ) + return PurchaseResult( + ok=False, + message="Оплата прошла, но лимит не обновился. Напиши админу.\n\n" + + friendly_remnawave_error(exc), + balance=balance, + ) + + traffic = traffic_from_panel_user(updated) + try: + from services.xui_wg import sync_wg_configs_limits + from services.awg_configs import sync_awg_configs_expiry + + await sync_wg_configs_limits( + db=db, + telegram_id=telegram_id, + traffic_bytes=int(new_limit), + expire_at=_as_utc(updated.expire_at) if updated.expire_at else None, + restore_enabled=True, + ) + await sync_awg_configs_expiry( + db=db, + sdk=sdk, + telegram_id=telegram_id, + login=login, + restore_enabled=True, + ) + except Exception: # noqa: BLE001 + logger.exception("WG/AWG sync after pack purchase failed") + + return PurchaseResult( + ok=True, + message=( + f"✅ Трафик увеличен\n\n" + f"Добавлено: {format_gb(pack.gb, digits=0)}\n" + f"Списано: {pack.format_price()} (первый месяц)\n" + f"Далее — {int(pack.price / pack.packs)} ₽/мес за каждые 500 ГБ с баланса.\n" + f"Баланс: {balance:.0f} ₽\n" + f"{traffic.format_line()}" + ), + balance=balance, + subscription_url=updated.subscription_url, + expire_at=_as_utc(updated.expire_at) if updated.expire_at else None, + traffic=traffic, + ) + + +async def get_my_subscription(sdk: RemnawaveSDK, telegram_id: int) -> str: + return (await get_my_subscription_info(sdk, telegram_id)).text diff --git a/services/qrcode_svg.py b/services/qrcode_svg.py new file mode 100644 index 0000000..f4b82a2 --- /dev/null +++ b/services/qrcode_svg.py @@ -0,0 +1,26 @@ +"""Генерация QR (SVG) без Pillow — только пакет qrcode.""" + +from __future__ import annotations + +import io + +import qrcode +import qrcode.image.svg + + +def make_qr_svg(data: str, *, scale: int = 4, border: int = 2) -> bytes: + """Возвращает SVG QR как bytes (image/svg+xml).""" + text = (data or "").strip() + if not text: + raise ValueError("empty qr data") + factory = qrcode.image.svg.SvgPathImage + img = qrcode.make( + text, + error_correction=qrcode.constants.ERROR_CORRECT_M, + box_size=max(2, int(scale)), + border=max(1, int(border)), + image_factory=factory, + ) + buf = io.BytesIO() + img.save(buf) + return buf.getvalue() diff --git a/services/rate_limit.py b/services/rate_limit.py new file mode 100644 index 0000000..7ac7312 --- /dev/null +++ b/services/rate_limit.py @@ -0,0 +1,41 @@ +"""Простой in-memory rate limit (на процесс).""" + +from __future__ import annotations + +import time +from collections import defaultdict +from threading import Lock + + +class RateLimiter: + def __init__(self) -> None: + self._hits: dict[str, list[float]] = defaultdict(list) + self._lock = Lock() + + def hit(self, key: str, *, limit: int, window_sec: float) -> bool: + """True если запрос разрешён, False если превышен лимит.""" + now = time.monotonic() + with self._lock: + bucket = self._hits[key] + cutoff = now - window_sec + self._hits[key] = [t for t in bucket if t >= cutoff] + if len(self._hits[key]) >= limit: + return False + self._hits[key].append(now) + return True + + +limiter = RateLimiter() + + +def client_ip(request) -> str: + forwarded = (request.headers.get("X-Forwarded-For") or "").split(",")[0].strip() + if forwarded: + return forwarded + peer = request.remote + return str(peer or "unknown") + + +def allow(request, bucket: str, *, limit: int = 20, window_sec: float = 60.0) -> bool: + ip = client_ip(request) + return limiter.hit(f"{bucket}:{ip}", limit=limit, window_sec=window_sec) diff --git a/services/remnawave_users.py b/services/remnawave_users.py new file mode 100644 index 0000000..dd683c4 --- /dev/null +++ b/services/remnawave_users.py @@ -0,0 +1,115 @@ +"""Remnawave: поиск пользователя и синхронизация лимита устройств.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +from remnawave import RemnawaveSDK +from remnawave.enums import UserStatus +from remnawave.models import UpdateUserRequestDto + +from services.db import Database +from services.devices import ( + BASE_DEVICES, + PANEL_SOFT_CAP, + DeviceUsage, + allowed_devices, +) + + +def as_utc(dt: datetime) -> datetime: + if dt.tzinfo is None: + return dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc) + + +def username_for(telegram_id: int, *, login: str | None = None) -> str: + if telegram_id > 0: + return f"tg_{telegram_id}" + if login: + safe = "".join(ch for ch in login if ch.isalnum() or ch == "_")[:24] or "user" + return f"web_{safe}" + return f"web_{abs(telegram_id)}" + + +async def find_panel_user( + sdk: RemnawaveSDK, telegram_id: int, *, login: str | None = None +): + if telegram_id > 0: + try: + users = await sdk.users.get_users_by_telegram_id(str(telegram_id)) + if users: + return users[0] + except Exception: # noqa: BLE001 + pass + try: + return await sdk.users.get_user_by_username( + username_for(telegram_id, login=login) + ) + except Exception: # noqa: BLE001 + return None + + +def device_limit_for_user(extra_paid: int, *, soft_cap: bool = True) -> int: + allowed = allowed_devices(extra_paid) + if soft_cap: + return max(allowed, min(PANEL_SOFT_CAP, max(allowed * 2, BASE_DEVICES + 20))) + return allowed + + +async def count_hwid_devices(sdk: RemnawaveSDK, user_uuid: str) -> int: + try: + resp = await sdk.hwid.get_hwid_user(str(user_uuid)) + return int(getattr(resp, "total", None) or len(getattr(resp, "devices", []) or [])) + except Exception: # noqa: BLE001 + return 0 + + +async def sync_device_policy( + sdk: RemnawaveSDK, + panel_user, + *, + db: Database, + telegram_id: int, + extra_paid: int, + device_blocked: bool, +) -> tuple[object, DeviceUsage]: + """Синхронизация лимита HWID и отключение при превышении оплаченной квоты.""" + used = await count_hwid_devices(sdk, str(panel_user.uuid)) + allowed = allowed_devices(extra_paid) + over = used > allowed + soft_limit = device_limit_for_user(extra_paid, soft_cap=True) + + updates: dict = { + "uuid": panel_user.uuid, + "hwid_device_limit": soft_limit, + } + + if over: + updates["status"] = UserStatus.DISABLED + if not device_blocked: + db.set_device_blocked(telegram_id, True) + elif device_blocked: + expire = as_utc(panel_user.expire_at) + if expire > datetime.now(timezone.utc): + updates["status"] = UserStatus.ACTIVE + db.set_device_blocked(telegram_id, False) + + try: + current_limit = int(getattr(panel_user, "hwid_device_limit", 0) or 0) + current_status = panel_user.status + need_update = current_limit != soft_limit + if "status" in updates and updates["status"] != current_status: + need_update = True + if need_update: + panel_user = await sdk.users.update_user(UpdateUserRequestDto(**updates)) + except Exception: # noqa: BLE001 + pass + + usage = DeviceUsage( + used=used, + allowed=allowed, + extra_paid=max(0, int(extra_paid)), + over_limit=over, + ) + return panel_user, usage diff --git a/services/smtp_runtime.py b/services/smtp_runtime.py new file mode 100644 index 0000000..45c7dd0 --- /dev/null +++ b/services/smtp_runtime.py @@ -0,0 +1,90 @@ +"""SMTP runtime: публичный payload и запись в .env из админки.""" + +from __future__ import annotations + +from typing import Any + +from config import Settings, get_settings +from services.env_file import ( + BOOL_ENV_KEYS, + SECRET_ENV_KEYS, + env_file_path, + env_writable, + mask_secret, + upsert_env_keys, +) +from services.mailer import smtp_configured + +SMTP_ENV_KEYS: tuple[str, ...] = ( + "SMTP_HOST", + "SMTP_PORT", + "SMTP_USER", + "SMTP_PASSWORD", + "SMTP_FROM", + "SMTP_TLS", + "SMTP_SSL", +) + + +def smtp_public_dict(settings: Settings) -> dict[str, Any]: + path = env_file_path() + return { + "env_path": str(path), + "env_writable": env_writable(path), + "configured": smtp_configured(settings), + "host": settings.smtp_host, + "port": settings.smtp_port, + "user": settings.smtp_user, + "password_set": bool(settings.smtp_password), + "password_hint": mask_secret(settings.smtp_password), + "from_email": settings.smtp_from, + "tls": bool(settings.smtp_tls), + "ssl": bool(settings.smtp_ssl), + } + + +def _is_masked_placeholder(value: str) -> bool: + v = (value or "").strip() + if not v: + return True + return "…" in v or "•" in v or "..." in v + + +def apply_smtp_body(body: dict[str, Any]) -> Settings: + updates: dict[str, str | None] = {} + mapping = { + "SMTP_HOST": body.get("smtp_host"), + "SMTP_PORT": body.get("smtp_port"), + "SMTP_USER": body.get("smtp_user"), + "SMTP_PASSWORD": body.get("smtp_password"), + "SMTP_FROM": body.get("smtp_from"), + "SMTP_TLS": body.get("smtp_tls"), + "SMTP_SSL": body.get("smtp_ssl"), + } + for env_key, raw in mapping.items(): + if raw is None: + continue + if env_key in BOOL_ENV_KEYS: + if isinstance(raw, bool): + updates[env_key] = "true" if raw else "false" + else: + updates[env_key] = ( + "true" + if str(raw).strip().lower() in {"1", "true", "yes", "on"} + else "false" + ) + continue + text = str(raw).strip() + if env_key in SECRET_ENV_KEYS: + if text == "__clear__": + updates[env_key] = None + elif _is_masked_placeholder(text): + continue + else: + updates[env_key] = text + continue + updates[env_key] = text if text else None + + if updates: + upsert_env_keys(updates, allowlist=SMTP_ENV_KEYS) + return get_settings(require_bot_token=False) diff --git a/services/stars.py b/services/stars.py new file mode 100644 index 0000000..85e575f --- /dev/null +++ b/services/stars.py @@ -0,0 +1,135 @@ +"""Telegram Stars: курс, пакеты и создание инвойсов.""" + +from __future__ import annotations + +import math +from dataclasses import dataclass +from typing import Any + +from aiogram.types import LabeledPrice + +from services.db import Database + +# Суммы пополнения в рублях (как у крипты) +STAR_TOPUP_AMOUNTS_RUB: tuple[int, ...] = (100, 200, 500, 1000, 2000) + + +@dataclass(frozen=True, slots=True) +class StarsQuote: + amount_rub: float + stars: int + rub_per_star: float + + @property + def stars_label(self) -> str: + return f"{self.stars} ⭐" + + @property + def rub_label(self) -> str: + if self.amount_rub == int(self.amount_rub): + return f"{int(self.amount_rub)} ₽" + return f"{self.amount_rub:.2f} ₽" + + +def rub_to_stars(amount_rub: float, *, rub_per_star: float) -> int: + rate = float(rub_per_star) + if rate <= 0: + raise ValueError("rub_per_star must be > 0") + stars = int(math.ceil(float(amount_rub) / rate)) + return max(1, stars) + + +def quote_topup(amount_rub: float, *, rub_per_star: float) -> StarsQuote: + amount = float(amount_rub) + if amount <= 0: + raise ValueError("amount_rub must be > 0") + stars = rub_to_stars(amount, rub_per_star=rub_per_star) + return StarsQuote(amount_rub=amount, stars=stars, rub_per_star=float(rub_per_star)) + + +def parse_stars_payload(payload: str) -> tuple[int, int, float] | None: + """ + payload: stars:{invoice_id}:{telegram_id}:{amount_rub} + → (invoice_id, telegram_id, amount_rub) + """ + parts = (payload or "").split(":") + if len(parts) != 4 or parts[0] != "stars": + return None + try: + return int(parts[1]), int(parts[2]), float(parts[3]) + except (TypeError, ValueError): + return None + + +def build_stars_payload(*, invoice_id: int, telegram_id: int, amount_rub: float) -> str: + return f"stars:{invoice_id}:{telegram_id}:{int(amount_rub)}" + + +def packs_payload(*, rub_per_star: float) -> list[dict[str, Any]]: + out: list[dict[str, Any]] = [] + for amount in STAR_TOPUP_AMOUNTS_RUB: + q = quote_topup(amount, rub_per_star=rub_per_star) + out.append( + { + "amount_rub": q.amount_rub, + "stars": q.stars, + "rub_label": q.rub_label, + "stars_label": q.stars_label, + "title": f"{q.rub_label} · {q.stars_label}", + } + ) + return out + + +async def create_stars_topup( + *, + bot: Any, + db: Database, + telegram_id: int, + amount_rub: float, + rub_per_star: float, +) -> dict[str, Any]: + """Создать локальный инвойс + invoice link для Mini App / Telegram Web.""" + if telegram_id <= 0: + raise ValueError("Stars доступны только для Telegram-аккаунта") + quote = quote_topup(amount_rub, rub_per_star=rub_per_star) + local_id = db.next_local_invoice_id() + payload = build_stars_payload( + invoice_id=local_id, + telegram_id=telegram_id, + amount_rub=quote.amount_rub, + ) + invoice_link = await bot.create_invoice_link( + title="Пополнение баланса", + description=( + f"Зачисление {quote.rub_label} на баланс VPN Service " + f"({quote.stars_label})" + ), + payload=payload, + currency="XTR", + prices=[ + LabeledPrice( + label=f"Баланс {quote.rub_label}", + amount=quote.stars, + ) + ], + provider_token="", + ) + db.create_invoice_record( + telegram_id=telegram_id, + invoice_id=local_id, + kind="topup", + amount_rub=quote.amount_rub, + pay_url=invoice_link, + payload=payload, + provider="stars", + ) + return { + "invoice_id": local_id, + "invoice_link": invoice_link, + "amount_rub": quote.amount_rub, + "stars": quote.stars, + "rub_label": quote.rub_label, + "stars_label": quote.stars_label, + "payload": payload, + } diff --git a/services/subscription_renewal.py b/services/subscription_renewal.py new file mode 100644 index 0000000..3cddba4 --- /dev/null +++ b/services/subscription_renewal.py @@ -0,0 +1,254 @@ +"""Напоминания об окончании подписки и автопродление с баланса.""" + +from __future__ import annotations + +import asyncio +import logging +from datetime import date, datetime, timedelta, timezone + +from aiogram import Bot +from remnawave import RemnawaveSDK + +from config import get_settings +from services.db import Database +from services.mailer import notify_important +from services.remnawave_users import find_panel_user +from services.purchase import purchase_tariff +from services.tariffs import Tariff, get_tariff + +logger = logging.getLogger(__name__) + +REMIND_WITHIN_DAYS = 7 +# Автопродление: в последние сутки или уже истекло (до 3 суток простоя) +AUTO_RENEW_MAX_PAST_DAYS = 3 + + +def _utc_now() -> datetime: + return datetime.now(timezone.utc) + + +def _as_utc(dt: datetime) -> datetime: + if dt.tzinfo is None: + return dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc) + + +def _days_left(expire_at: datetime) -> float: + return (_as_utc(expire_at) - _utc_now()).total_seconds() / 86400.0 + + +def _utc_today() -> date: + return _utc_now().date() + + +async def _notify( + bot: Bot | None, + telegram_id: int, + text: str, + *, + db: Database | None = None, + subject: str = "VPN Service", +) -> None: + if db is not None: + try: + settings = get_settings(require_bot_token=False) + except SystemExit: + settings = None + if settings is not None: + await notify_important( + db=db, + settings=settings, + telegram_id=telegram_id, + html=text, + subject=subject, + bot=bot, + ) + return + if bot is None or telegram_id <= 0: + return + try: + await bot.send_message(telegram_id, text, parse_mode="HTML") + except Exception: # noqa: BLE001 + logger.warning("renewal notify failed tg=%s", telegram_id) + + +def _format_days_left(days: float) -> str: + if days < 0: + n = int(abs(days)) + if n == 0: + return "истекла сегодня" + return f"истекла {n} дн. назад" + if days < 1: + return "менее суток" + n = int(round(days)) + if n == 1: + return "1 день" + if 2 <= n <= 4: + return f"{n} дня" + return f"{n} дней" + + +async def process_user_subscription_renewal( + db: Database, + sdk: RemnawaveSDK, + tariffs: list[Tariff], + telegram_id: int, + *, + bot: Bot | None = None, + login: str | None = None, +) -> None: + if telegram_id <= 0: + return + + bot_user = db.get_user(telegram_id) + login = login or (bot_user.login if bot_user else None) + + panel_user = await find_panel_user(sdk, telegram_id, login=login) + if panel_user is None or not panel_user.expire_at: + return + + expire = _as_utc(panel_user.expire_at) + days = _days_left(expire) + + if days > REMIND_WITHIN_DAYS: + return + + tariff_id = db.get_last_tariff_id(telegram_id) + if not tariff_id: + return + tariff = get_tariff(tariffs, tariff_id) + if not tariff: + return + + balance = db.get_balance(telegram_id) + can_auto = balance >= tariff.price + + bot_user = db.get_user(telegram_id) + device_blocked = bool(bot_user and bot_user.device_blocked) + auto_renew_on = bool(bot_user.auto_renew) if bot_user else True + + # Автопродление — только если пользователь включил опцию + should_auto = days <= 1.0 or ( + days < 0 and days >= -AUTO_RENEW_MAX_PAST_DAYS + ) + if should_auto and auto_renew_on and can_auto and not device_blocked: + result = await purchase_tariff( + db=db, + sdk=sdk, + telegram_id=telegram_id, + tariff=tariff, + charge_balance=True, + renew=True, + payment_meta=f"auto_renew:{tariff.id}", + ) + if result.ok: + await _notify( + bot, + telegram_id, + ( + "✅ Подписка продлена автоматически\n\n" + f"Тариф: {tariff.title}\n" + f"Списано: {tariff.format_price()} с баланса\n" + f"Баланс: {result.balance:.0f} ₽\n\n" + "Отключить автопродление можно в кабинете в любой момент." + ), + db=db, + subject="Подписка продлена — VPN Service", + ) + db.mark_expiry_reminder_sent(telegram_id, _utc_today()) + return + logger.info( + "auto renew failed tg=%s: %s", + telegram_id, + result.message[:120], + ) + + # Напоминание раз в сутки (UTC) + if db.expiry_reminder_sent_on(telegram_id) == _utc_today(): + return + + expire_label = expire.strftime("%d.%m.%Y %H:%M UTC") + left_label = _format_days_left(days) + lines = [ + "⏳ Подписка скоро закончится\n", + f"Осталось: {left_label}\n", + f"Действует до: {expire_label}\n", + f"Тариф для продления: {tariff.title} · {tariff.format_price()}\n", + f"Баланс: {balance:.0f} ₽\n", + ] + if auto_renew_on and can_auto and days <= 1.0: + lines.append( + "\nАвтопродление включено — при достаточном балансе подписка продлится сама." + ) + elif auto_renew_on and can_auto: + lines.append( + f"\nАвтопродление включено. За сутки до окончания спишем " + f"{tariff.format_price()} с баланса." + ) + elif auto_renew_on and device_blocked: + lines.append( + "\n⚠️ Подписка отключена из‑за превышения устройств — " + "сначала оплати доп. слоты или отвяжи лишние устройства." + ) + elif auto_renew_on: + need = max(0.0, tariff.price - balance) + lines.append( + f"\nПополни баланс минимум на {need:.0f} ₽ " + f"для автопродления или продли вручную в боте / Mini App." + ) + else: + lines.append( + "\nАвтопродление выключено. Включите в кабинете или продлите тариф вручную." + ) + + await _notify( + bot, + telegram_id, + "".join(lines), + db=db, + subject="Подписка скоро закончится — VPN Service", + ) + db.mark_expiry_reminder_sent(telegram_id, _utc_today()) + + +async def run_subscription_renewal_cycle( + db: Database, + sdk: RemnawaveSDK, + tariffs: list[Tariff], + *, + bot: Bot | None = None, + limit: int = 500, +) -> int: + ids = db.list_telegram_ids_with_purchases(limit=limit) + done = 0 + for tid in ids: + try: + await process_user_subscription_renewal( + db, sdk, tariffs, tid, bot=bot + ) + done += 1 + except Exception: # noqa: BLE001 + logger.exception("subscription renewal tg=%s", tid) + return done + + +async def subscription_renewal_worker( + db: Database, + sdk: RemnawaveSDK, + bot: Bot, + tariffs: list[Tariff], + *, + interval_sec: int = 6 * 3600, +) -> None: + """Проверка несколько раз в сутки; напоминание — не чаще 1 раза в день на пользователя.""" + await asyncio.sleep(90) + while True: + try: + n = await run_subscription_renewal_cycle( + db, sdk, tariffs, bot=bot + ) + if n: + logger.info("subscription renewal cycle: %s users", n) + except Exception: # noqa: BLE001 + logger.exception("subscription renewal cycle error") + await asyncio.sleep(interval_sec) diff --git a/services/tariffs.py b/services/tariffs.py new file mode 100644 index 0000000..3703013 --- /dev/null +++ b/services/tariffs.py @@ -0,0 +1,188 @@ +"""Тарифы из tariffs.json.""" + +from __future__ import annotations + +import json +import re +from dataclasses import dataclass +from pathlib import Path +from uuid import UUID + +TARIFF_ID_RE = re.compile(r"^[A-Za-z0-9_]{2,40}$") + + +class TariffError(ValueError): + pass + + +@dataclass(frozen=True, slots=True) +class Tariff: + id: str + title: str + price: float + days: int + traffic_gb: float + device_limit: int + squad_uuids: tuple[UUID, ...] + description: str = "" + + @property + def traffic_bytes(self) -> float: + # 0 или меньше → базовая квота 1000 ГБ + from services.traffic import BASE_TRAFFIC_GB, gb_to_bytes + + gb = self.traffic_gb if self.traffic_gb > 0 else BASE_TRAFFIC_GB + return gb_to_bytes(gb) + + def format_price(self) -> str: + if self.price == int(self.price): + return f"{int(self.price)} ₽" + return f"{self.price:.2f} ₽" + + def to_dict(self) -> dict: + return { + "id": self.id, + "title": self.title, + "price": self.price if self.price != int(self.price) else int(self.price), + "days": self.days, + "traffic_gb": self.traffic_gb, + "device_limit": self.device_limit, + "squad_uuids": [str(u) for u in self.squad_uuids], + "description": self.description, + } + + def to_admin_dict(self) -> dict: + d = self.to_dict() + d["price_label"] = self.format_price() + return d + + +def load_tariffs(path: str | Path) -> list[Tariff]: + data = json.loads(Path(path).read_text(encoding="utf-8")) + tariffs: list[Tariff] = [] + for item in data: + tariffs.append(_tariff_from_item(item)) + return tariffs + + +def save_tariffs(path: str | Path, tariffs: list[Tariff]) -> None: + payload = [t.to_dict() for t in tariffs] + text = json.dumps(payload, ensure_ascii=False, indent=2) + "\n" + Path(path).write_text(text, encoding="utf-8") + + +def replace_shared_tariffs(shared: list[Tariff], new: list[Tariff]) -> None: + """Обновить общий список in-place (bot + webapp + workers).""" + shared[:] = list(new) + + +def get_tariff(tariffs: list[Tariff], tariff_id: str) -> Tariff | None: + for t in tariffs: + if t.id == tariff_id: + return t + return None + + +def parse_tariff_payload( + body: dict, + *, + existing: Tariff | None = None, + default_squads: tuple[UUID, ...] = (), +) -> Tariff: + tid = str(body.get("id") or (existing.id if existing else "")).strip() + if not TARIFF_ID_RE.fullmatch(tid): + raise TariffError("ID: 2–40 символов (латиница, цифры, _)") + + title = str(body.get("title") or (existing.title if existing else "")).strip() + if not title: + raise TariffError("Укажите название тарифа") + if len(title) > 80: + raise TariffError("Название слишком длинное") + + try: + price = float(body.get("price") if "price" in body else (existing.price if existing else 0)) + except (TypeError, ValueError) as exc: + raise TariffError("Некорректная цена") from exc + if price < 0: + raise TariffError("Цена не может быть отрицательной") + + try: + days = int(body.get("days") if "days" in body else (existing.days if existing else 0)) + except (TypeError, ValueError) as exc: + raise TariffError("Некорректное число дней") from exc + if days < 1 or days > 3650: + raise TariffError("Дни: от 1 до 3650") + + try: + traffic_gb = float( + body.get("traffic_gb") + if "traffic_gb" in body + else (existing.traffic_gb if existing else 1000) + ) + except (TypeError, ValueError) as exc: + raise TariffError("Некорректный трафик") from exc + if traffic_gb < 0: + raise TariffError("Трафик не может быть отрицательным") + + try: + device_limit = int( + body.get("device_limit") + if "device_limit" in body + else (existing.device_limit if existing else 10) + ) + except (TypeError, ValueError) as exc: + raise TariffError("Некорректный лимит устройств") from exc + if device_limit < 1 or device_limit > 100: + raise TariffError("Устройства: от 1 до 100") + + if "squad_uuids" in body: + squads_raw = body.get("squad_uuids") + if isinstance(squads_raw, str): + parts = [p.strip() for p in squads_raw.replace(";", ",").split(",") if p.strip()] + elif isinstance(squads_raw, list): + parts = [str(x).strip() for x in squads_raw if str(x).strip()] + else: + parts = [] + try: + squad_uuids = tuple(UUID(p) for p in parts) + except ValueError as exc: + raise TariffError("Некорректный UUID в squad_uuids") from exc + elif existing: + squad_uuids = existing.squad_uuids + else: + squad_uuids = default_squads + + if not squad_uuids: + raise TariffError("Нужен хотя бы один squad UUID") + + description = str( + body.get("description") + if "description" in body + else (existing.description if existing else "") + ).strip() + if len(description) > 300: + raise TariffError("Описание слишком длинное") + + return Tariff( + id=tid, + title=title, + price=price, + days=days, + traffic_gb=traffic_gb, + device_limit=device_limit, + squad_uuids=squad_uuids, + description=description, + ) + + +def _tariff_from_item(item: dict) -> Tariff: + return Tariff( + id=str(item["id"]), + title=str(item["title"]), + price=float(item["price"]), + days=int(item["days"]), + traffic_gb=float(item.get("traffic_gb") or 0), + device_limit=int(item.get("device_limit") or 1), + squad_uuids=tuple(UUID(str(u)) for u in item.get("squad_uuids") or []), + description=str(item.get("description") or ""), + ) diff --git a/services/tg_webapp.py b/services/tg_webapp.py new file mode 100644 index 0000000..d537e0a --- /dev/null +++ b/services/tg_webapp.py @@ -0,0 +1,193 @@ +"""Валидация Telegram WebApp initData, Login Widget и OIDC id_token.""" + +from __future__ import annotations + +import hashlib +import hmac +import json +import logging +import time +from dataclasses import dataclass +from typing import Any, Mapping +from urllib.parse import parse_qsl + +import jwt +from jwt import PyJWKClient + +logger = logging.getLogger(__name__) + +_JWKS_URL = "https://oauth.telegram.org/.well-known/jwks.json" +_ISSUER = "https://oauth.telegram.org" +_jwks_client: PyJWKClient | None = None + + +@dataclass(frozen=True, slots=True) +class WebAppUser: + id: int + first_name: str + last_name: str | None = None + username: str | None = None + language_code: str | None = None + is_premium: bool = False + + @property + def full_name(self) -> str: + if self.last_name: + return f"{self.first_name} {self.last_name}" + return self.first_name + + +class InitDataError(ValueError): + pass + + +def telegram_client_id_from_token(bot_token: str) -> str: + """Числовой Client ID бота = часть BOT_TOKEN до ':'.""" + raw = (bot_token or "").strip() + if ":" in raw: + return raw.split(":", 1)[0].strip() + return raw + + +def parse_and_validate_init_data( + init_data: str, + bot_token: str, + *, + max_age_sec: int = 86400, +) -> WebAppUser: + if not init_data or not init_data.strip(): + raise InitDataError("empty initData") + + pairs = dict(parse_qsl(init_data, keep_blank_values=True)) + received_hash = pairs.pop("hash", None) + if not received_hash: + raise InitDataError("missing hash") + + data_check_string = "\n".join(f"{k}={v}" for k, v in sorted(pairs.items())) + secret_key = hmac.new(b"WebAppData", bot_token.encode(), hashlib.sha256).digest() + calculated = hmac.new(secret_key, data_check_string.encode(), hashlib.sha256).hexdigest() + if not hmac.compare_digest(calculated, received_hash): + raise InitDataError("invalid hash") + + auth_date = int(pairs.get("auth_date") or 0) + if auth_date and abs(time.time() - auth_date) > max_age_sec: + raise InitDataError("initData expired") + + user_raw = pairs.get("user") + if not user_raw: + raise InitDataError("missing user") + user = json.loads(user_raw) + return WebAppUser( + id=int(user["id"]), + first_name=str(user.get("first_name") or "User"), + last_name=user.get("last_name"), + username=user.get("username"), + language_code=user.get("language_code"), + is_premium=bool(user.get("is_premium")), + ) + + +def parse_and_validate_login_widget( + data: Mapping[str, Any], + bot_token: str, + *, + max_age_sec: int = 86400, +) -> WebAppUser: + """Проверка данных legacy Telegram Login Widget (HMAC).""" + payload = {str(k): str(v) for k, v in data.items() if v is not None and str(v) != ""} + received_hash = payload.pop("hash", None) + if not received_hash: + raise InitDataError("missing hash") + + data_check_string = "\n".join(f"{k}={payload[k]}" for k in sorted(payload.keys())) + secret_key = hashlib.sha256(bot_token.encode("utf-8")).digest() + calculated = hmac.new( + secret_key, + data_check_string.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + if not hmac.compare_digest(calculated, received_hash): + raise InitDataError("invalid hash") + + auth_date = int(payload.get("auth_date") or 0) + if not auth_date or abs(time.time() - auth_date) > max_age_sec: + raise InitDataError("login data expired") + + if "id" not in payload: + raise InitDataError("missing id") + + return WebAppUser( + id=int(payload["id"]), + first_name=str(payload.get("first_name") or "User"), + last_name=payload.get("last_name"), + username=payload.get("username"), + ) + + +def _get_jwks_client() -> PyJWKClient: + global _jwks_client + if _jwks_client is None: + _jwks_client = PyJWKClient(_JWKS_URL, cache_keys=True, lifespan=3600) + return _jwks_client + + +def parse_and_validate_id_token( + id_token: str, + client_id: str, + *, + nonce: str | None = None, +) -> WebAppUser: + """Проверка OIDC id_token нового Telegram Login (JWKS).""" + token = (id_token or "").strip() + aud = str(client_id or "").strip() + if not token: + raise InitDataError("missing id_token") + if not aud: + raise InitDataError("telegram client_id not configured") + + try: + signing_key = _get_jwks_client().get_signing_key_from_jwt(token) + payload = jwt.decode( + token, + signing_key.key, + algorithms=["RS256", "RS384", "RS512", "PS256", "ES256", "ES384", "EdDSA"], + audience=aud, + issuer=_ISSUER, + options={"require": ["exp", "iat", "iss", "aud"]}, + ) + except jwt.PyJWTError as exc: + logger.info("telegram id_token invalid: %s", exc) + raise InitDataError("invalid id_token") from exc + + if nonce and payload.get("nonce") and str(payload.get("nonce")) != str(nonce): + raise InitDataError("nonce mismatch") + + tid_raw = payload.get("id") + if tid_raw is None: + tid_raw = payload.get("sub") + try: + tid = int(tid_raw) + except (TypeError, ValueError) as exc: + raise InitDataError("missing user id in id_token") from exc + + given = str(payload.get("given_name") or "").strip() + family = str(payload.get("family_name") or "").strip() or None + full = str(payload.get("name") or "").strip() + if given: + first_name = given + last_name = family + elif full: + parts = full.split(None, 1) + first_name = parts[0] + last_name = parts[1] if len(parts) > 1 else family + else: + first_name = "User" + last_name = family + + username = payload.get("preferred_username") or payload.get("username") + return WebAppUser( + id=tid, + first_name=first_name, + last_name=last_name, + username=str(username) if username else None, + ) diff --git a/services/ticket_ai.py b/services/ticket_ai.py new file mode 100644 index 0000000..c28491c --- /dev/null +++ b/services/ticket_ai.py @@ -0,0 +1,231 @@ +"""Автоответы ИИ по тикетам через OpenRouter.""" + +from __future__ import annotations + +import html +import logging +import re +from typing import Any + +from aiogram import Bot + +from config import Settings +from services.db import Database +from services.faq import FAQ_ITEMS +from services.openrouter import ( + AiDecision, + chat_completion, + load_openrouter_config, + parse_ai_decision, +) +from services.tickets import ( + BODY_MAX, + clamp_body, + notify_user_ticket_reply, +) + +logger = logging.getLogger(__name__) + +_HTML_RE = re.compile(r"<[^>]+>") + + +def _strip_html(text: str) -> str: + t = _HTML_RE.sub("", text or "") + return ( + t.replace(" ", " ") + .replace("<", "<") + .replace(">", ">") + .replace("&", "&") + .replace(""", '"') + .strip() + ) + + +def build_faq_context() -> str: + lines = ["FAQ сервиса:"] + for item in FAQ_ITEMS: + lines.append(f"Q: {_strip_html(item.question)}") + lines.append(f"A: {_strip_html(item.answer)}") + lines.append("") + return "\n".join(lines).strip() + + +def build_ticket_messages_for_ai(messages: list[dict[str, Any]]) -> list[dict[str, str]]: + out: list[dict[str, str]] = [] + for msg in messages[-20:]: + role = str(msg.get("author_role") or "") + body = str(msg.get("body") or "").strip() + if not body: + continue + if role == "user": + out.append({"role": "user", "content": body}) + else: + prefix = "ИИ" if role == "ai" else "Поддержка" + out.append({"role": "assistant", "content": f"[{prefix}] {body}"}) + return out + + +async def notify_admins_ai_escalate( + bot: Bot, + settings: Settings, + *, + ticket_id: int, + telegram_id: int, + subject: str, + reason: str, +) -> None: + from keyboards import admin_ticket_notify_inline + + text = ( + f"👤 Нужен человек · тикет #{ticket_id}\n" + f"От: {telegram_id}\n" + f"Тема: {html.escape(subject)}\n\n" + f"ИИ эскалировал: {html.escape((reason or 'без причины')[:400])}" + ) + kb = admin_ticket_notify_inline(ticket_id) + for admin_id in settings.admin_ids: + try: + await bot.send_message(admin_id, text, reply_markup=kb, parse_mode="HTML") + except Exception as exc: # noqa: BLE001 + logger.warning("notify admin escalate %s: %s", admin_id, exc) + + +async def try_ai_ticket_reply( + *, + db: Database, + bot: Bot | None, + settings: Settings, + ticket_id: int, + notify_user: bool = True, +) -> dict[str, Any]: + """ + Пытается ответить на тикет через OpenRouter. + Возвращает dict: ok, action, detail, model? + """ + cfg = load_openrouter_config(db) + if not cfg.ready or not cfg.auto_reply: + return {"ok": False, "action": "skip", "detail": "ai disabled"} + + ticket = db.get_ticket(ticket_id) + if not ticket: + return {"ok": False, "action": "skip", "detail": "ticket not found"} + if str(ticket.get("status")) == "closed": + return {"ok": False, "action": "skip", "detail": "closed"} + + messages = db.list_ticket_messages(ticket_id) + if not messages: + return {"ok": False, "action": "skip", "detail": "no messages"} + last = messages[-1] + if str(last.get("author_role")) != "user": + return {"ok": False, "action": "skip", "detail": "last not user"} + + subject = str(ticket.get("subject") or "") + system = cfg.system_prompt.strip() + faq = build_faq_context() + system_full = ( + f"{system}\n\n{faq}\n\n" + f"Текущий тикет #{ticket_id}, тема: {subject}" + ) + chat_messages = [ + {"role": "system", "content": system_full}, + *build_ticket_messages_for_ai(messages), + ] + + referer = (settings.webapp_url or "").rstrip("/") or "https://bot.example.com" + try: + raw, used_model = await chat_completion( + api_key=cfg.api_key, + model=cfg.model, + models=cfg.models_for_request(), + messages=chat_messages, + referer=referer, + ) + decision = parse_ai_decision(raw, used_model=used_model) + except Exception as exc: # noqa: BLE001 + logger.exception("OpenRouter ticket #%s failed", ticket_id) + if bot is not None: + await notify_admins_ai_escalate( + bot, + settings, + ticket_id=ticket_id, + telegram_id=int(ticket["telegram_id"]), + subject=subject, + reason=f"Ошибка ИИ: {exc}", + ) + return {"ok": False, "action": "error", "detail": str(exc)} + + return await _apply_decision( + db=db, + bot=bot, + settings=settings, + ticket=ticket, + decision=decision, + notify_user=notify_user, + ) + + +async def _apply_decision( + *, + db: Database, + bot: Bot | None, + settings: Settings, + ticket: dict[str, Any], + decision: AiDecision, + notify_user: bool, +) -> dict[str, Any]: + ticket_id = int(ticket["id"]) + subject = str(ticket.get("subject") or "") + telegram_id = int(ticket["telegram_id"]) + + if decision.action == "answer" and decision.reply.strip(): + reply = clamp_body(decision.reply) + if len(reply) > BODY_MAX: + reply = reply[: BODY_MAX - 1] + "…" + try: + db.add_ticket_message( + ticket_id, + author_role="ai", + author_id=None, + body=reply, + ) + except ValueError as exc: + return {"ok": False, "action": "error", "detail": str(exc)} + + if notify_user and bot is not None: + await notify_user_ticket_reply( + bot, + telegram_id=telegram_id, + ticket_id=ticket_id, + subject=subject, + body=reply, + is_ai=True, + ) + logger.info( + "AI answered ticket #%s model=%s", + ticket_id, + decision.model or "?", + ) + return { + "ok": True, + "action": "answer", + "detail": "ok", + "model": decision.model, + "reply": reply, + } + + reason = decision.reason or "Нужен оператор" + if bot is not None: + await notify_admins_ai_escalate( + bot, + settings, + ticket_id=ticket_id, + telegram_id=telegram_id, + subject=subject, + reason=reason, + ) + return { + "ok": True, + "action": "escalate", + "detail": reason, + "model": decision.model, + } diff --git a/services/tickets.py b/services/tickets.py new file mode 100644 index 0000000..7fdb0e0 --- /dev/null +++ b/services/tickets.py @@ -0,0 +1,227 @@ +"""Тикеты поддержки: тексты, лимиты, уведомления.""" + +from __future__ import annotations + +import html +import logging +from datetime import datetime +from typing import Any + +from aiogram import Bot + +from config import Settings + +logger = logging.getLogger(__name__) + +SUBJECT_MAX = 100 +BODY_MAX = 2000 +MAX_OPEN_PER_USER = 3 + +STATUS_LABELS: dict[str, str] = { + "open": "🟢 Открыт", + "answered": "💬 Есть ответ", + "closed": "🔒 Закрыт", +} + +STATUS_LABELS_SHORT: dict[str, str] = { + "open": "Открыт", + "answered": "Ответ", + "closed": "Закрыт", +} + + +def status_label(status: str, *, short: bool = False) -> str: + key = (status or "").strip().lower() + table = STATUS_LABELS_SHORT if short else STATUS_LABELS + return table.get(key, status or "—") + + +def clamp_subject(text: str) -> str: + return (text or "").strip()[:SUBJECT_MAX] + + +def clamp_body(text: str) -> str: + return (text or "").strip()[:BODY_MAX] + + +def _dt(value: Any) -> str: + if value is None: + return "" + if isinstance(value, datetime): + return value.isoformat() + return str(value) + + +def ticket_to_dict(row: dict[str, Any], *, include_user: bool = False) -> dict[str, Any]: + out: dict[str, Any] = { + "id": int(row["id"]), + "telegram_id": int(row["telegram_id"]), + "subject": str(row.get("subject") or ""), + "status": str(row.get("status") or "open"), + "status_label": status_label(str(row.get("status") or "open"), short=True), + "created_at": _dt(row.get("created_at")), + "updated_at": _dt(row.get("updated_at")), + "message_count": int(row.get("message_count") or 0), + } + if include_user: + name = row.get("full_name") or row.get("login") + username = row.get("username") + if name: + display = str(name) + elif username: + display = f"@{username}" + else: + display = f"ID {out['telegram_id']}" + out["user"] = { + "telegram_id": out["telegram_id"], + "display_name": display, + "username": username, + "login": row.get("login"), + } + return out + + +def message_to_dict(row: dict[str, Any]) -> dict[str, Any]: + return { + "id": int(row["id"]), + "ticket_id": int(row["ticket_id"]), + "author_role": str(row.get("author_role") or ""), + "author_id": int(row["author_id"]) if row.get("author_id") is not None else None, + "body": str(row.get("body") or ""), + "created_at": _dt(row.get("created_at")), + } + + +def format_ticket_card(ticket: dict[str, Any], messages: list[dict[str, Any]]) -> str: + tid = int(ticket["id"]) + subject = html.escape(str(ticket.get("subject") or "")) + status = status_label(str(ticket.get("status") or "open")) + lines = [ + f"🎫 Тикет #{tid}", + f"Тема: {subject}", + f"Статус: {status}", + "", + ] + for msg in messages[-12:]: + role = str(msg.get("author_role") or "") + if role == "user": + who = "Вы" + elif role == "ai": + who = "ИИ-поддержка" + else: + who = "Поддержка" + body = html.escape(str(msg.get("body") or "")) + lines.append(f"{who}:\n{body}") + lines.append("") + return "\n".join(lines).strip() + + +def format_admin_ticket_card(ticket: dict[str, Any], messages: list[dict[str, Any]]) -> str: + tid = int(ticket["id"]) + subject = html.escape(str(ticket.get("subject") or "")) + status = status_label(str(ticket.get("status") or "open")) + uid = int(ticket["telegram_id"]) + uname = ticket.get("username") + fname = ticket.get("full_name") or ticket.get("login") or "" + user_line = f"ID {uid}" + if uname: + user_line += f" · @{html.escape(str(uname))}" + if fname: + user_line += f" · {html.escape(str(fname))}" + lines = [ + f"🎫 Тикет #{tid}", + f"Тема: {subject}", + f"Статус: {status}", + f"Пользователь: {user_line}", + "", + ] + for msg in messages[-15:]: + role = str(msg.get("author_role") or "") + if role == "user": + who = "Пользователь" + elif role == "ai": + who = "ИИ" + else: + who = "Админ" + body = html.escape(str(msg.get("body") or "")) + lines.append(f"{who}:\n{body}") + lines.append("") + return "\n".join(lines).strip() + + +async def notify_admins_new_ticket( + bot: Bot, + settings: Settings, + *, + ticket_id: int, + telegram_id: int, + subject: str, + body: str, + ai_handled: bool = False, +) -> None: + prefix = "🤖 Новый тикет (ИИ ответил)" if ai_handled else "🆕 Новый тикет" + text = ( + f"{prefix} #{ticket_id}\n" + f"От: {telegram_id}\n" + f"Тема: {html.escape(subject)}\n\n" + f"{html.escape(body[:500])}" + ) + from keyboards import admin_ticket_notify_inline + + kb = admin_ticket_notify_inline(ticket_id) + for admin_id in settings.admin_ids: + try: + await bot.send_message(admin_id, text, reply_markup=kb, parse_mode="HTML") + except Exception as exc: # noqa: BLE001 + logger.warning("notify admin %s ticket: %s", admin_id, exc) + + +async def notify_admins_ticket_reply( + bot: Bot, + settings: Settings, + *, + ticket_id: int, + telegram_id: int, + body: str, +) -> None: + text = ( + f"💬 Ответ в тикете #{ticket_id}\n" + f"От: {telegram_id}\n\n" + f"{html.escape(body[:500])}" + ) + from keyboards import admin_ticket_notify_inline + + kb = admin_ticket_notify_inline(ticket_id) + for admin_id in settings.admin_ids: + try: + await bot.send_message(admin_id, text, reply_markup=kb, parse_mode="HTML") + except Exception as exc: # noqa: BLE001 + logger.warning("notify admin %s reply: %s", admin_id, exc) + + +async def notify_user_ticket_reply( + bot: Bot, + *, + telegram_id: int, + ticket_id: int, + subject: str, + body: str, + is_ai: bool = False, +) -> None: + from keyboards import ticket_notify_inline + + title = "🤖 ИИ-поддержка" if is_ai else "💬 Ответ поддержки" + text = ( + f"{title} · тикет #{ticket_id}\n" + f"Тема: {html.escape(subject)}\n\n" + f"{html.escape(body[:800])}" + ) + try: + await bot.send_message( + telegram_id, + text, + reply_markup=ticket_notify_inline(ticket_id), + parse_mode="HTML", + ) + except Exception as exc: # noqa: BLE001 + logger.warning("notify user %s ticket reply: %s", telegram_id, exc) diff --git a/services/topup_code_redeem.py b/services/topup_code_redeem.py new file mode 100644 index 0000000..a52511b --- /dev/null +++ b/services/topup_code_redeem.py @@ -0,0 +1,65 @@ +"""Активация кода пополнения.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from services.db import Database +from services.topup_codes import is_valid_code_format, normalize_code + + +class TopupCodeError(ValueError): + pass + + +@dataclass(frozen=True, slots=True) +class TopupCodeRedeemResult: + ok: bool + message: str + amount: float = 0.0 + balance: float = 0.0 + already: bool = False + + +def redeem_topup_code( + db: Database, + *, + telegram_id: int, + raw_code: str, +) -> TopupCodeRedeemResult: + code = normalize_code(raw_code) + if not is_valid_code_format(code): + raise TopupCodeError("Некорректный формат кода (ожидается XXXX-XXXX-XXXX-XXXX)") + + db.ensure_user(telegram_id) + try: + amount, already = db.redeem_topup_code(code=code, telegram_id=telegram_id) + except ValueError as exc: + raise TopupCodeError(str(exc)) from exc + if already: + balance = db.get_balance(telegram_id) + return TopupCodeRedeemResult( + ok=True, + already=True, + message="Этот код уже был использован.", + amount=amount, + balance=balance, + ) + + balance = db.add_balance( + telegram_id, + amount, + kind="code_topup", + meta=f"code:{code}", + ) + text = ( + f"✅ Баланс пополнен по коду\n\n" + f"+{amount:.0f} ₽\n" + f"Доступно: {balance:.0f} ₽" + ) + return TopupCodeRedeemResult( + ok=True, + message=text, + amount=amount, + balance=balance, + ) diff --git a/services/topup_codes.py b/services/topup_codes.py new file mode 100644 index 0000000..df6a1cc --- /dev/null +++ b/services/topup_codes.py @@ -0,0 +1,31 @@ +"""Коды пополнения баланса.""" + +from __future__ import annotations + +import re +import secrets +import string + +# Без похожих символов O/0, I/1, L +_CODE_ALPHABET = "".join( + c for c in (string.ascii_uppercase + string.digits) if c not in "O0IL1" +) +_CODE_RE = re.compile(r"^[A-Z0-9]{4}(?:-[A-Z0-9]{4}){3}$") + + +def normalize_code(raw: str) -> str: + """XXXX-XXXX-XXXX-XXXX из произвольного ввода.""" + cleaned = "".join(ch for ch in (raw or "").upper() if ch.isalnum()) + if len(cleaned) != 16: + return cleaned + return "-".join(cleaned[i : i + 4] for i in range(0, 16, 4)) + + +def is_valid_code_format(code: str) -> bool: + norm = normalize_code(code) + return bool(_CODE_RE.fullmatch(norm)) + + +def generate_code() -> str: + parts = ["".join(secrets.choice(_CODE_ALPHABET) for _ in range(4)) for _ in range(4)] + return "-".join(parts) diff --git a/services/traffic.py b/services/traffic.py new file mode 100644 index 0000000..39fad73 --- /dev/null +++ b/services/traffic.py @@ -0,0 +1,134 @@ +"""Лимиты и пакеты трафика.""" + +from __future__ import annotations + +from dataclasses import dataclass + +# База на подписку + докупка +BASE_TRAFFIC_GB = 1000.0 +PACK_GB = 500.0 +PACK_PRICE_RUB = 50.0 + +GB = 1024 * 1024 * 1024 + + +def gb_to_bytes(gb: float) -> float: + return float(gb) * GB + + +def bytes_to_gb(value: float | int | None) -> float: + if not value: + return 0.0 + return float(value) / GB + + +def format_gb(gb: float, *, digits: int = 1) -> str: + if gb >= 100 and abs(gb - round(gb)) < 0.05: + return f"{int(round(gb))} ГБ" + if digits <= 0: + return f"{int(round(gb))} ГБ" + text = f"{gb:.{digits}f}".rstrip("0").rstrip(".") + return f"{text} ГБ" + + +def effective_limit_bytes(limit_bytes: float | int | None) -> float: + """0 / None в панели = безлимит; для нас база = 1000 ГБ.""" + raw = float(limit_bytes or 0) + if raw <= 0: + return gb_to_bytes(BASE_TRAFFIC_GB) + return raw + + +def ensure_base_limit_bytes(current_limit_bytes: float | int | None) -> float: + """Не меньше базовых 1000 ГБ; безлимит (0) → база.""" + raw = float(current_limit_bytes or 0) + base = gb_to_bytes(BASE_TRAFFIC_GB) + if raw <= 0: + return base + return max(raw, base) + + +@dataclass(frozen=True, slots=True) +class TrafficPack: + packs: int + gb: float + price: float + + @property + def id(self) -> str: + return f"pack_{self.packs}" + + @property + def title(self) -> str: + return f"+{format_gb(self.gb, digits=0)}" + + def format_price(self) -> str: + if self.price == int(self.price): + return f"{int(self.price)} ₽/мес" + return f"{self.price:.2f} ₽/мес" + + +# Готовые пакеты: 1 / 2 / 4 / 10 × 500 ГБ +TRAFFIC_PACKS: tuple[TrafficPack, ...] = tuple( + TrafficPack( + packs=n, + gb=PACK_GB * n, + price=PACK_PRICE_RUB * n, + ) + for n in (1, 2, 4, 10) +) + + +def get_traffic_pack(pack_id: str) -> TrafficPack | None: + for p in TRAFFIC_PACKS: + if p.id == pack_id or str(p.packs) == pack_id: + return p + return None + + +@dataclass(frozen=True, slots=True) +class TrafficUsage: + used_bytes: float + limit_bytes: float + + @property + def used_gb(self) -> float: + return bytes_to_gb(self.used_bytes) + + @property + def limit_gb(self) -> float: + return bytes_to_gb(self.limit_bytes) + + @property + def left_gb(self) -> float: + return max(0.0, self.limit_gb - self.used_gb) + + @property + def percent(self) -> float: + if self.limit_bytes <= 0: + return 0.0 + return min(100.0, max(0.0, 100.0 * self.used_bytes / self.limit_bytes)) + + def format_line(self) -> str: + return ( + f"📦 Трафик: {format_gb(self.used_gb)} / {format_gb(self.limit_gb)} " + f"({self.percent:.0f}%)\n" + f"Осталось: {format_gb(self.left_gb)}" + ) + + def as_dict(self) -> dict: + return { + "used_gb": round(self.used_gb, 2), + "limit_gb": round(self.limit_gb, 2), + "left_gb": round(self.left_gb, 2), + "percent": round(self.percent, 1), + "used_label": format_gb(self.used_gb), + "limit_label": format_gb(self.limit_gb), + "left_label": format_gb(self.left_gb), + } + + +def traffic_from_panel_user(user) -> TrafficUsage: + used = float(getattr(user, "used_traffic_bytes", 0) or 0) + limit = effective_limit_bytes(getattr(user, "traffic_limit_bytes", 0)) + return TrafficUsage(used_bytes=used, limit_bytes=limit) diff --git a/services/transactions_ui.py b/services/transactions_ui.py new file mode 100644 index 0000000..7747223 --- /dev/null +++ b/services/transactions_ui.py @@ -0,0 +1,49 @@ +"""Человекочитаемые подписи к операциям баланса.""" + +from __future__ import annotations + +_KIND_LABELS: dict[str, str] = { + "topup": "Пополнение", + "admin_credit": "Зачисление администратором", + "topup_code": "Код пополнения", + "stars_topup": "Telegram Stars", + "crypto_topup": "Крипто-пополнение", + "lava_topup": "Lava", + "anypay_topup": "AnyPay", + "purchase": "Покупка тарифа", + "renewal": "Продление подписки", + "traffic": "Докупка трафика", + "devices": "Доп. устройства", + "extras_renewal": "Продление доп. опций", + "wg_config": "WireGuard конфиг", + "awg_config": "AmneziaWG конфиг", + "refund": "Возврат", +} + + +def transaction_label(kind: str, meta: str | None = None) -> str: + k = (kind or "").strip() + m = (meta or "").strip() + if k == "admin_credit": + if " | admin:" in m: + m = m.split(" | admin:", 1)[0].strip() + return m or _KIND_LABELS["admin_credit"] + if k == "topup_code" and m: + return f"Код пополнения · {m}" + if m and k in ("purchase", "renewal", "traffic", "devices", "wg_config", "awg_config", "refund"): + return f"{_KIND_LABELS.get(k, k)} · {m}" + return _KIND_LABELS.get(k, k or "Операция") + + +def transaction_row_dict(amount: float, kind: str, meta: str | None, created_at) -> dict: + sign = "+" if amount > 0 else "" + amt = float(amount) + label = transaction_label(kind, meta) + return { + "amount": amt, + "kind": kind, + "meta": meta, + "label": label, + "amount_label": f"{sign}{amt:.0f} ₽", + "created_at": created_at.isoformat() if hasattr(created_at, "isoformat") else str(created_at), + } diff --git a/services/trial.py b/services/trial.py new file mode 100644 index 0000000..fd119f4 --- /dev/null +++ b/services/trial.py @@ -0,0 +1,99 @@ +"""Пробный период подписки.""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone + +from remnawave import RemnawaveSDK + +from services.db import AuthError, Database +from services.purchase import provision_subscription +from services.tariffs import Tariff, get_tariff + +logger = logging.getLogger(__name__) + +DEFAULT_TRIAL_DAYS = 3 + + +@dataclass(frozen=True, slots=True) +class TrialResult: + ok: bool + message: str + expire_at: datetime | None = None + + +def trial_days_setting(db: Database) -> int: + raw = (db.get_setting("trial_days") or str(DEFAULT_TRIAL_DAYS)).strip() + try: + days = int(raw) + except ValueError: + days = DEFAULT_TRIAL_DAYS + return max(0, min(days, 30)) + + +def trial_enabled(db: Database) -> bool: + return trial_days_setting(db) > 0 + + +async def grant_trial( + *, + db: Database, + sdk: RemnawaveSDK, + telegram_id: int, + tariffs: list[Tariff], +) -> TrialResult: + days = trial_days_setting(db) + if days <= 0: + return TrialResult(ok=False, message="Пробный период выключен") + + user = db.ensure_user(telegram_id) + if user.banned: + return TrialResult(ok=False, message="Аккаунт заблокирован") + if user.trial_used_at: + return TrialResult(ok=False, message="Пробный период уже использован") + + tariff_id = (db.get_setting("trial_tariff_id") or "").strip() + tariff = get_tariff(tariffs, tariff_id) if tariff_id else None + if tariff is None and tariffs: + tariff = min(tariffs, key=lambda t: t.price) + if tariff is None: + return TrialResult(ok=False, message="Нет тарифа для пробного периода") + + trial_tariff = Tariff( + id=tariff.id, + title=f"Trial · {tariff.title}", + price=0.0, + days=days, + traffic_gb=tariff.traffic_gb, + device_limit=tariff.device_limit, + squad_uuids=tariff.squad_uuids, + description="trial", + ) + + try: + rw_uuid, sub_url, expire_at = await provision_subscription( + sdk, + telegram_id=telegram_id, + tariff=trial_tariff, + login=user.login, + extra_devices=user.extra_devices, + ) + except Exception as exc: # noqa: BLE001 + logger.exception("trial provision failed") + return TrialResult(ok=False, message=f"Не удалось выдать пробный период: {exc}") + + db.mark_trial_used(telegram_id) + db.record_purchase( + telegram_id, + tariff_id=f"trial:{tariff.id}", + price=0.0, + remnawave_uuid=rw_uuid, + subscription_url=sub_url, + ) + return TrialResult( + ok=True, + message=f"Пробный период на {days} дн. активен до {expire_at.strftime('%d.%m.%Y %H:%M UTC')}", + expire_at=expire_at, + ) diff --git a/services/vpn_configs_expiry.py b/services/vpn_configs_expiry.py new file mode 100644 index 0000000..89a451b --- /dev/null +++ b/services/vpn_configs_expiry.py @@ -0,0 +1,63 @@ +"""Фоновое отключение WG/AWG конфигов при истекшей подписке Remnawave.""" + +from __future__ import annotations + +import asyncio +import logging + +from remnawave import RemnawaveSDK + +from services.awg_configs import enforce_awg_configs_subscription +from services.db import Database +from services.xui_wg import enforce_wg_configs_subscription + +logger = logging.getLogger(__name__) + + +async def run_vpn_configs_expiry_cycle( + db: Database, + sdk: RemnawaveSDK, + *, + limit: int = 500, +) -> int: + ids = db.list_telegram_ids_with_enabled_vpn_configs(limit=limit) + touched = 0 + for tid in ids: + try: + user = db.get_user(tid) + login = user.login if user else None + wg = await enforce_wg_configs_subscription( + db=db, sdk=sdk, telegram_id=tid, login=login + ) + awg = await enforce_awg_configs_subscription( + db=db, sdk=sdk, telegram_id=tid, login=login + ) + if (wg.get("disabled") or 0) + (awg.get("disabled") or 0) > 0: + touched += 1 + logger.info( + "vpn configs auto-disabled tg=%s wg=%s awg=%s", + tid, + wg.get("disabled"), + awg.get("disabled"), + ) + except Exception: # noqa: BLE001 + logger.exception("vpn configs expiry enforce failed tg=%s", tid) + return touched + + +async def vpn_configs_expiry_worker( + db: Database, + sdk: RemnawaveSDK, + *, + interval_sec: int = 120, +) -> None: + """Каждые ~2 минуты проверяет включённые конфиги и гасит их без подписки.""" + await asyncio.sleep(45) + while True: + try: + n = await run_vpn_configs_expiry_cycle(db, sdk) + if n: + logger.info("vpn configs expiry cycle: disabled for %s users", n) + except Exception: # noqa: BLE001 + logger.exception("vpn configs expiry cycle error") + await asyncio.sleep(interval_sec) diff --git a/services/web_topup.py b/services/web_topup.py new file mode 100644 index 0000000..ff3644e --- /dev/null +++ b/services/web_topup.py @@ -0,0 +1,264 @@ +"""Пополнение баланса с сайта (Lava / AnyPay / Heleket / Crypto Pay).""" + +from __future__ import annotations + +import time +from dataclasses import dataclass +from typing import Any + +from services.anypay import Anypay, AnypayError +from services.cryptopay import CryptoPay, CryptoPayError +from services.db import Database +from services.heleket import Heleket, HeleketError +from services.lava import Lava, LavaError + +TOPUP_MIN_RUB = 10.0 +TOPUP_MAX_RUB = 3000.0 +TOPUP_PRESETS_RUB: tuple[int, ...] = (50, 100, 200, 500, 1000, 2000, 3000) + +PROVIDERS = ("lava", "anypay", "heleket", "cryptobot") + + +class TopupError(ValueError): + pass + + +@dataclass(frozen=True, slots=True) +class TopupInvoice: + provider: str + amount_rub: float + pay_url: str + invoice_id: int + order_id: str | None = None + title: str = "" + + +def parse_topup_amount(raw: Any) -> float: + try: + amount = float(raw) + except (TypeError, ValueError) as exc: + raise TopupError("Укажите сумму числом") from exc + amount = round(amount, 2) + if amount < TOPUP_MIN_RUB: + raise TopupError(f"Минимум {int(TOPUP_MIN_RUB)} ₽ за одну оплату") + if amount > TOPUP_MAX_RUB: + raise TopupError(f"Максимум {int(TOPUP_MAX_RUB)} ₽ за одну оплату") + return amount + + +def normalize_provider(raw: Any) -> str: + provider = str(raw or "").strip().lower() + if provider in {"crypto", "cryptopay", "crypto_bot"}: + provider = "cryptobot" + if provider not in PROVIDERS: + raise TopupError("Неизвестный способ оплаты") + return provider + + +def _safe_order_id(raw: str) -> str: + cleaned = "".join(ch if ch.isalnum() or ch in "_-" else "_" for ch in raw) + return cleaned[:128] + + +def available_providers( + *, + cryptopay: CryptoPay, + heleket: Heleket, + lava: Lava, + anypay: Anypay | None = None, +) -> list[dict[str, Any]]: + items: list[dict[str, Any]] = [] + if lava.enabled: + items.append( + { + "id": "lava", + "title": "Карта / СБП · Lava", + "hint": "Lava.ru · зачисление автоматически", + } + ) + if anypay and anypay.enabled: + items.append( + { + "id": "anypay", + "title": "AnyPay", + "hint": "Карта, СБП, крипта и др. · anypay.io", + } + ) + if heleket.enabled: + items.append( + { + "id": "heleket", + "title": "Крипта · Heleket", + "hint": "USDT и др. · зачисление по webhook", + } + ) + if cryptopay.enabled: + items.append( + { + "id": "cryptobot", + "title": "Крипта · Crypto Bot", + "hint": "Оплата в @CryptoBot", + } + ) + return items + + +def topup_methods_payload( + *, + cryptopay: CryptoPay, + heleket: Heleket, + lava: Lava, + anypay: Anypay | None = None, +) -> dict[str, Any]: + return { + "min_amount": int(TOPUP_MIN_RUB), + "max_amount": int(TOPUP_MAX_RUB), + "presets": list(TOPUP_PRESETS_RUB), + "providers": available_providers( + cryptopay=cryptopay, heleket=heleket, lava=lava, anypay=anypay + ), + } + + +async def create_web_topup_invoice( + *, + db: Database, + telegram_id: int, + amount_rub: float, + provider: str, + cryptopay: CryptoPay, + heleket: Heleket, + lava: Lava, + anypay: Anypay | None = None, +) -> TopupInvoice: + amount = parse_topup_amount(amount_rub) + provider = normalize_provider(provider) + stamp = int(time.time()) + order_id = _safe_order_id(f"web_topup_{telegram_id}_{int(amount)}_{stamp}") + + if provider == "lava": + if not lava.enabled: + raise TopupError("Lava не настроена") + try: + inv = await lava.create_invoice( + amount_rub=amount, + order_id=order_id, + comment=f"Web topup {int(amount)} RUB uid={telegram_id}", + custom_fields=str(telegram_id), + ) + except LavaError as exc: + raise TopupError(str(exc)) from exc + local_id = db.next_local_invoice_id() + db.create_invoice_record( + telegram_id=telegram_id, + invoice_id=local_id, + kind="topup", + amount_rub=amount, + pay_url=inv.pay_url, + payload=None, + provider="lava", + order_id=order_id, + external_uuid=inv.invoice_id, + ) + return TopupInvoice( + provider="lava", + amount_rub=amount, + pay_url=inv.pay_url, + invoice_id=local_id, + order_id=order_id, + title="Карта / СБП · Lava", + ) + + if provider == "anypay": + if not anypay or not anypay.enabled: + raise TopupError("AnyPay не настроен") + local_id = db.next_local_invoice_id() + pay_id = str(local_id) + try: + inv = await anypay.create_invoice( + pay_id=pay_id, + amount_rub=amount, + desc=f"Web topup {int(amount)} RUB uid={telegram_id}", + ) + except AnypayError as exc: + raise TopupError(str(exc)) from exc + db.create_invoice_record( + telegram_id=telegram_id, + invoice_id=local_id, + kind="topup", + amount_rub=amount, + pay_url=inv.pay_url, + payload=pay_id, + provider="anypay", + order_id=pay_id, + external_uuid=inv.transaction_id, + ) + return TopupInvoice( + provider="anypay", + amount_rub=amount, + pay_url=inv.pay_url, + invoice_id=local_id, + order_id=pay_id, + title="AnyPay", + ) + + if provider == "heleket": + if not heleket.enabled: + raise TopupError("Heleket не настроен") + try: + inv = await heleket.create_invoice( + amount_rub=amount, + order_id=order_id, + description=f"Web topup {int(amount)} RUB uid={telegram_id}", + ) + except HeleketError as exc: + raise TopupError(str(exc)) from exc + local_id = db.next_local_invoice_id() + db.create_invoice_record( + telegram_id=telegram_id, + invoice_id=local_id, + kind="topup", + amount_rub=amount, + pay_url=inv.pay_url, + payload=order_id, + provider="heleket", + order_id=order_id, + external_uuid=inv.uuid, + ) + return TopupInvoice( + provider="heleket", + amount_rub=amount, + pay_url=inv.pay_url, + invoice_id=local_id, + order_id=order_id, + title="Крипта · Heleket", + ) + + if not cryptopay.enabled: + raise TopupError("Crypto Bot не настроен") + payload = f"topup:{telegram_id}:{int(amount)}:web" + try: + invoice = await cryptopay.create_fiat_invoice( + amount_rub=amount, + description=f"Пополнение баланса на {int(amount)} ₽", + payload=payload, + ) + except CryptoPayError as exc: + raise TopupError(str(exc)) from exc + db.create_invoice_record( + telegram_id=telegram_id, + invoice_id=invoice.invoice_id, + kind="topup", + amount_rub=amount, + pay_url=invoice.pay_url, + payload=payload, + provider="cryptobot", + ) + return TopupInvoice( + provider="cryptobot", + amount_rub=amount, + pay_url=invoice.pay_url or invoice.bot_invoice_url, + invoice_id=invoice.invoice_id, + order_id=None, + title="Крипта · Crypto Bot", + ) diff --git a/services/webapp_growth_routes.py b/services/webapp_growth_routes.py new file mode 100644 index 0000000..09f61a8 --- /dev/null +++ b/services/webapp_growth_routes.py @@ -0,0 +1,833 @@ +"""Доп. API: рефералка, trial, промо, пароль, сессии, HWID, legal, бэкапы.""" + +from __future__ import annotations + +import json +import logging +from pathlib import Path +from uuid import UUID + +from aiohttp import web +from remnawave.models import HWIDDeleteRequest + +from config import Settings, ROOT_DIR +from services.db import AuthError, BotUser, Database +from services.legal_pages import ( + html_aup, + html_contacts, + html_cookies, + html_delivery, + html_help, + html_offer, + html_privacy, + html_refund, + html_site_rules, + html_status, + html_support, + html_terms, + html_vpn_usage, +) +from services.mailer import render_password_reset_email, send_email, smtp_configured +from services.pg_backup import ( + delete_backup, + list_backups, + resolve_backup_path, + restore_from_backup_name, + restore_from_bytes, + run_pg_dump_backup, + save_uploaded_backup, +) +from services.rate_limit import allow, client_ip +from services.remnawave_users import find_panel_user +from services.trial import grant_trial, trial_days_setting, trial_enabled + +logger = logging.getLogger(__name__) + + +def _require_user(request: web.Request) -> BotUser: + from webapp_server import _require_user as req + + return req(request) + + +def _user_payload(user: BotUser) -> dict: + from webapp_server import _user_payload as payload + + return payload(user) + + +def _session_token(request: web.Request) -> str | None: + from webapp_server import _session_token as tok + + return tok(request) + + +def _set_session_cookie(resp, request, token: str) -> None: + from webapp_server import _set_session_cookie as setc + + setc(resp, request, token) + + +def register_growth_routes(app: web.Application) -> None: + app.router.add_get("/api/referral", handle_referral_get) + app.router.add_post("/api/trial", handle_trial) + app.router.add_get("/api/account/notifications", handle_notify_get) + app.router.add_post("/api/account/notifications", handle_notify_set) + app.router.add_get("/api/account/sessions", handle_sessions_list) + app.router.add_post("/api/account/sessions/logout-all", handle_sessions_logout_all) + app.router.add_post("/api/auth/password/forgot", handle_password_forgot) + app.router.add_post("/api/auth/password/reset", handle_password_reset) + app.router.add_get("/api/devices/hwid", handle_hwid_list) + app.router.add_post("/api/devices/hwid/delete", handle_hwid_delete) + app.router.add_get("/terms", handle_terms_page) + app.router.add_get("/offer", handle_offer_page) + app.router.add_get("/privacy", handle_privacy_page) + app.router.add_get("/aup", handle_aup_page) + app.router.add_get("/vpn-usage", handle_vpn_usage_page) + app.router.add_get("/vpn_usage", handle_vpn_usage_page) + app.router.add_get("/rules", handle_rules_page) + app.router.add_get("/refund", handle_refund_page) + app.router.add_get("/delivery", handle_delivery_page) + app.router.add_get("/contacts", handle_contacts_page) + app.router.add_get("/help", handle_help_page) + app.router.add_get("/support", handle_support_page) + app.router.add_get("/cookies", handle_cookies_page) + app.router.add_get("/status", handle_status_page) + app.router.add_get("/api/admin/promos", handle_admin_promos_list) + app.router.add_post("/api/admin/promos", handle_admin_promos_create) + app.router.add_post("/api/admin/promos/{promo_id}/toggle", handle_admin_promos_toggle) + app.router.add_post("/api/admin/users/ban", handle_admin_user_ban) + app.router.add_post("/api/admin/users/delete", handle_admin_user_delete) + app.router.add_get("/api/admin/payments/journal", handle_admin_payment_journal) + app.router.add_get("/api/admin/backups", handle_admin_backups_list) + app.router.add_post("/api/admin/backups", handle_admin_backups_run) + app.router.add_get("/api/admin/backups/{name}/download", handle_admin_backups_download) + app.router.add_delete("/api/admin/backups/{name}", handle_admin_backups_delete) + app.router.add_post("/api/admin/backups/delete", handle_admin_backups_delete_post) + app.router.add_post("/api/admin/backups/restore", handle_admin_backups_restore) + app.router.add_post("/api/admin/backups/upload", handle_admin_backups_upload) + app.router.add_get("/api/admin/growth-settings", handle_admin_growth_get) + app.router.add_post("/api/admin/growth-settings", handle_admin_growth_set) + + +async def handle_referral_get(request: web.Request) -> web.Response: + settings: Settings = request.app["settings"] + db: Database = request.app["db"] + try: + user = _require_user(request) + except web.HTTPUnauthorized: + return web.json_response({"ok": False, "error": "unauthorized"}, status=401) + code = db.ensure_referral_code(user.telegram_id) + bot = str(request.app.get("bot_username") or "").lstrip("@") + link = f"https://t.me/{bot}?start=ref_{code}" if bot else "" + web_link = f"{settings.webapp_url.rstrip('/')}/?ref={code}" if settings.webapp_url else "" + return web.json_response( + { + "ok": True, + "code": code, + "telegram_link": link, + "web_link": web_link, + "bonus_referrer": float(db.get_setting("referral_bonus_referrer") or "50"), + "bonus_referee": float(db.get_setting("referral_bonus_referee") or "50"), + } + ) + + +async def handle_trial(request: web.Request) -> web.Response: + db: Database = request.app["db"] + sdk = request.app["sdk"] + try: + user = _require_user(request) + except web.HTTPUnauthorized: + return web.json_response({"ok": False, "error": "unauthorized"}, status=401) + if not allow(request, "trial", limit=5, window_sec=3600): + return web.json_response({"ok": False, "error": "Слишком часто"}, status=429) + result = await grant_trial( + db=db, sdk=sdk, telegram_id=user.telegram_id, tariffs=request.app["tariffs"] + ) + status = 200 if result.ok else 400 + return web.json_response( + { + "ok": result.ok, + "error": None if result.ok else result.message, + "message": result.message, + "expire_at": result.expire_at.isoformat() if result.expire_at else None, + "trial_enabled": trial_enabled(db), + "trial_days": trial_days_setting(db), + }, + status=status, + ) + + +async def handle_notify_get(request: web.Request) -> web.Response: + try: + user = _require_user(request) + except web.HTTPUnauthorized: + return web.json_response({"ok": False, "error": "unauthorized"}, status=401) + return web.json_response( + { + "ok": True, + "notify_telegram": bool(user.notify_telegram), + "notify_email": bool(user.notify_email), + } + ) + + +async def handle_notify_set(request: web.Request) -> web.Response: + db: Database = request.app["db"] + try: + user = _require_user(request) + except web.HTTPUnauthorized: + return web.json_response({"ok": False, "error": "unauthorized"}, status=401) + try: + body = await request.json() + except json.JSONDecodeError: + return web.json_response({"ok": False, "error": "invalid json"}, status=400) + updated = db.set_notify_prefs( + user.telegram_id, + notify_telegram=body.get("notify_telegram"), + notify_email=body.get("notify_email"), + ) + return web.json_response( + { + "ok": True, + "notify_telegram": updated.notify_telegram, + "notify_email": updated.notify_email, + "user": _user_payload(updated), + } + ) + + +async def handle_sessions_list(request: web.Request) -> web.Response: + db: Database = request.app["db"] + try: + user = _require_user(request) + except web.HTTPUnauthorized: + return web.json_response({"ok": False, "error": "unauthorized"}, status=401) + current = _session_token(request) + items = [] + for s in db.list_sessions(user.telegram_id): + items.append( + { + "token_hint": (s["token"][:6] + "…") if s.get("token") else "", + "current": s.get("token") == current, + "created_at": s["created_at"].isoformat() + if hasattr(s["created_at"], "isoformat") + else str(s["created_at"]), + "expires_at": s["expires_at"].isoformat() + if hasattr(s["expires_at"], "isoformat") + else str(s["expires_at"]), + "user_agent": s.get("user_agent"), + "ip": s.get("ip"), + } + ) + return web.json_response({"ok": True, "sessions": items}) + + +async def handle_sessions_logout_all(request: web.Request) -> web.Response: + db: Database = request.app["db"] + try: + user = _require_user(request) + except web.HTTPUnauthorized: + return web.json_response({"ok": False, "error": "unauthorized"}, status=401) + keep = _session_token(request) + deleted = db.clear_all_sessions(user.telegram_id, keep_token=keep) + return web.json_response({"ok": True, "deleted": deleted}) + + +async def handle_password_forgot(request: web.Request) -> web.Response: + import os + + from dotenv import load_dotenv + + from config import get_settings + + # Подтянуть .env (SMTP из админки) и обновить runtime settings + env_file = (os.getenv("ENV_FILE") or "").strip() or None + load_dotenv(env_file, override=True) + settings = get_settings(require_bot_token=False) + request.app["settings"] = settings + db: Database = request.app["db"] + if not allow(request, "pwd_forgot", limit=5, window_sec=600): + return web.json_response({"ok": False, "error": "Слишком часто"}, status=429) + try: + body = await request.json() + except json.JSONDecodeError: + return web.json_response({"ok": False, "error": "invalid json"}, status=400) + email = str(body.get("email") or "").strip() + if not email or "@" not in email: + return web.json_response({"ok": False, "error": "Укажите корректный email"}, status=400) + + if not smtp_configured(settings): + return web.json_response( + { + "ok": False, + "error": "Сброс пароля недоступен: SMTP не настроен (Админка → SMTP)", + }, + status=503, + ) + if not (settings.webapp_url or "").strip(): + return web.json_response( + { + "ok": False, + "error": "Сброс пароля недоступен: не задан WEBAPP_URL", + }, + status=503, + ) + + # Не раскрываем, есть ли email в базе + user = db.get_user_by_email(email) + if user and user.email: + token = db.create_password_reset_token(user.telegram_id) + link = f"{settings.webapp_url.rstrip('/')}/?reset={token}" + try: + await send_email( + settings, + to=user.email, + subject="Сброс пароля — VPN Service", + html=render_password_reset_email( + reset_url=link, + webapp_url=settings.webapp_url or "", + ), + ) + except Exception as exc: # noqa: BLE001 + logger.exception("password reset mail failed") + return web.json_response( + {"ok": False, "error": f"Не удалось отправить письмо: {exc}"}, + status=502, + ) + return web.json_response( + { + "ok": True, + "message": "Если email найден — отправили ссылку для сброса. Проверьте почту и «Спам».", + } + ) + + +async def handle_password_reset(request: web.Request) -> web.Response: + db: Database = request.app["db"] + if not allow(request, "pwd_reset", limit=10, window_sec=600): + return web.json_response({"ok": False, "error": "Слишком часто"}, status=429) + try: + body = await request.json() + except json.JSONDecodeError: + return web.json_response({"ok": False, "error": "invalid json"}, status=400) + token = str(body.get("token") or "") + password = str(body.get("password") or "") + try: + tid = db.consume_password_reset_token(token) + db.set_password_hash(tid, password) + db.clear_all_sessions(tid) + session = db.create_session( + tid, user_agent=request.headers.get("User-Agent"), ip=client_ip(request) + ) + except AuthError as exc: + return web.json_response({"ok": False, "error": str(exc)}, status=400) + user = db.get_user(tid) + assert user is not None + resp = web.json_response( + {"ok": True, "token": session, "user": _user_payload(user), "balance": user.balance} + ) + _set_session_cookie(resp, request, session) + return resp + + +async def handle_hwid_list(request: web.Request) -> web.Response: + db: Database = request.app["db"] + sdk = request.app["sdk"] + try: + user = _require_user(request) + except web.HTTPUnauthorized: + return web.json_response({"ok": False, "error": "unauthorized"}, status=401) + panel = await find_panel_user(sdk, user.telegram_id, login=user.login) + if panel is None: + return web.json_response({"ok": True, "devices": [], "total": 0}) + try: + resp = await sdk.hwid.get_hwid_user(str(panel.uuid)) + devices = [] + for d in getattr(resp, "devices", None) or []: + devices.append( + { + "hwid": getattr(d, "hwid", ""), + "platform": getattr(d, "platform", None), + "os_version": getattr(d, "os_version", None), + "device_model": getattr(d, "device_model", None), + "user_agent": getattr(d, "user_agent", None), + "created_at": getattr(d, "created_at", None).isoformat() + if getattr(d, "created_at", None) + else None, + } + ) + return web.json_response( + { + "ok": True, + "total": int(getattr(resp, "total", None) or len(devices)), + "devices": devices, + } + ) + except Exception as exc: # noqa: BLE001 + logger.exception("hwid list failed") + return web.json_response({"ok": False, "error": str(exc)}, status=500) + + +async def handle_hwid_delete(request: web.Request) -> web.Response: + db: Database = request.app["db"] + sdk = request.app["sdk"] + try: + user = _require_user(request) + except web.HTTPUnauthorized: + return web.json_response({"ok": False, "error": "unauthorized"}, status=401) + try: + body = await request.json() + except json.JSONDecodeError: + return web.json_response({"ok": False, "error": "invalid json"}, status=400) + hwid = str(body.get("hwid") or "").strip() + if not hwid: + return web.json_response({"ok": False, "error": "hwid required"}, status=400) + panel = await find_panel_user(sdk, user.telegram_id, login=user.login) + if panel is None: + return web.json_response({"ok": False, "error": "Нет подписки"}, status=400) + try: + await sdk.hwid.delete_hwid_to_user( + HWIDDeleteRequest(user_uuid=UUID(str(panel.uuid)), hwid=hwid) + ) + from services.remnawave_users import sync_device_policy + from services.extras_billing import effective_extra_devices + + fresh = await find_panel_user(sdk, user.telegram_id, login=user.login) + if fresh is not None: + await sync_device_policy( + sdk, + fresh, + db=db, + telegram_id=user.telegram_id, + extra_paid=effective_extra_devices(user), + device_blocked=user.device_blocked, + ) + except Exception as exc: # noqa: BLE001 + logger.exception("hwid delete failed") + return web.json_response({"ok": False, "error": str(exc)}, status=500) + return web.json_response({"ok": True}) + + +async def handle_terms_page(_: web.Request) -> web.Response: + return web.Response(text=html_terms(), content_type="text/html", charset="utf-8") + + +async def handle_offer_page(_: web.Request) -> web.Response: + return web.Response(text=html_offer(), content_type="text/html", charset="utf-8") + + +async def handle_privacy_page(_: web.Request) -> web.Response: + return web.Response(text=html_privacy(), content_type="text/html", charset="utf-8") + + +async def handle_aup_page(_: web.Request) -> web.Response: + return web.Response(text=html_aup(), content_type="text/html", charset="utf-8") + + +async def handle_vpn_usage_page(_: web.Request) -> web.Response: + return web.Response(text=html_vpn_usage(), content_type="text/html", charset="utf-8") + + +async def handle_rules_page(_: web.Request) -> web.Response: + return web.Response(text=html_site_rules(), content_type="text/html", charset="utf-8") + + +async def handle_refund_page(_: web.Request) -> web.Response: + return web.Response(text=html_refund(), content_type="text/html", charset="utf-8") + + +async def handle_delivery_page(_: web.Request) -> web.Response: + return web.Response(text=html_delivery(), content_type="text/html", charset="utf-8") + + +async def handle_contacts_page(_: web.Request) -> web.Response: + return web.Response(text=html_contacts(), content_type="text/html", charset="utf-8") + + +async def handle_help_page(_: web.Request) -> web.Response: + return web.Response(text=html_help(), content_type="text/html", charset="utf-8") + + +async def handle_support_page(_: web.Request) -> web.Response: + return web.Response(text=html_support(), content_type="text/html", charset="utf-8") + + +async def handle_cookies_page(_: web.Request) -> web.Response: + return web.Response(text=html_cookies(), content_type="text/html", charset="utf-8") + + +async def handle_status_page(_: web.Request) -> web.Response: + return web.Response(text=html_status(), content_type="text/html", charset="utf-8") + + +def _require_admin(request: web.Request): + from webapp_admin_routes import _require_admin as ra + + return ra(request) + + +async def handle_admin_promos_list(request: web.Request) -> web.Response: + denied = _require_admin(request) + if denied: + return denied + db: Database = request.app["db"] + rows = db.list_promo_codes() + items = [] + for r in rows: + items.append( + { + "id": r["id"], + "code": r["code"], + "kind": r["kind"], + "value": float(r["value"]), + "max_uses": r.get("max_uses"), + "used_count": int(r.get("used_count") or 0), + "tariff_id": r.get("tariff_id"), + "active": bool(r.get("active")), + "expires_at": r["expires_at"].isoformat() + if r.get("expires_at") and hasattr(r["expires_at"], "isoformat") + else None, + "note": r.get("note"), + } + ) + return web.json_response({"ok": True, "promos": items}) + + +async def handle_admin_promos_create(request: web.Request) -> web.Response: + denied = _require_admin(request) + if denied: + return denied + db: Database = request.app["db"] + try: + body = await request.json() + except json.JSONDecodeError: + return web.json_response({"ok": False, "error": "invalid json"}, status=400) + try: + row = db.create_promo_code( + code=str(body.get("code") or ""), + kind=str(body.get("kind") or "percent"), + value=float(body.get("value") or 0), + max_uses=int(body["max_uses"]) if body.get("max_uses") not in (None, "") else None, + tariff_id=str(body.get("tariff_id") or "").strip() or None, + note=str(body.get("note") or "").strip() or None, + ) + except (AuthError, ValueError) as exc: + return web.json_response({"ok": False, "error": str(exc)}, status=400) + return web.json_response({"ok": True, "promo": {"id": row["id"], "code": row["code"]}}) + + +async def handle_admin_promos_toggle(request: web.Request) -> web.Response: + denied = _require_admin(request) + if denied: + return denied + db: Database = request.app["db"] + promo_id = int(request.match_info["promo_id"]) + try: + body = await request.json() + except json.JSONDecodeError: + body = {} + db.set_promo_active(promo_id, bool(body.get("active", True))) + return web.json_response({"ok": True}) + + +async def handle_admin_user_ban(request: web.Request) -> web.Response: + denied = _require_admin(request) + if denied: + return denied + db: Database = request.app["db"] + try: + body = await request.json() + except json.JSONDecodeError: + return web.json_response({"ok": False, "error": "invalid json"}, status=400) + tid = int(body.get("telegram_id") or 0) + banned = bool(body.get("banned")) + user = db.set_banned(tid, banned) + return web.json_response({"ok": True, "user": _user_payload(user), "banned": user.banned}) + + +async def handle_admin_user_delete(request: web.Request) -> web.Response: + denied = _require_admin(request) + if denied: + return denied + db: Database = request.app["db"] + settings: Settings = request.app["settings"] + try: + body = await request.json() + except json.JSONDecodeError: + return web.json_response({"ok": False, "error": "invalid json"}, status=400) + try: + tid = int(body.get("telegram_id") or 0) + except (TypeError, ValueError): + return web.json_response({"ok": False, "error": "bad telegram_id"}, status=400) + if not tid: + return web.json_response({"ok": False, "error": "Укажите telegram_id"}, status=400) + if not body.get("confirm"): + return web.json_response( + {"ok": False, "error": "Нужно подтверждение (confirm=true)"}, + status=400, + ) + if tid in (settings.admin_ids or ()): + return web.json_response( + {"ok": False, "error": "Нельзя удалить пользователя из ADMIN_IDS"}, + status=400, + ) + user = db.get_user(tid) + if user is None: + return web.json_response({"ok": False, "error": "Пользователь не найден"}, status=404) + label = user.display_name + try: + ok = db.delete_user(tid) + except Exception as exc: # noqa: BLE001 + logger.exception("admin delete user failed tid=%s", tid) + return web.json_response({"ok": False, "error": str(exc)}, status=500) + if not ok: + return web.json_response({"ok": False, "error": "Пользователь не найден"}, status=404) + return web.json_response( + { + "ok": True, + "telegram_id": tid, + "message": f"Удалён: {label} (ID {tid})", + } + ) + + +async def handle_admin_payment_journal(request: web.Request) -> web.Response: + denied = _require_admin(request) + if denied: + return denied + db: Database = request.app["db"] + rows = db.list_payment_events(limit=100) + items = [] + for r in rows: + items.append( + { + "id": r["id"], + "provider": r["provider"], + "event_type": r["event_type"], + "telegram_id": r.get("telegram_id"), + "amount": float(r["amount"]) if r.get("amount") is not None else None, + "invoice_ref": r.get("invoice_ref"), + "status": r.get("status"), + "meta": r.get("meta"), + "created_at": r["created_at"].isoformat() + if hasattr(r["created_at"], "isoformat") + else str(r["created_at"]), + } + ) + return web.json_response({"ok": True, "events": items}) + + +async def handle_admin_backups_list(request: web.Request) -> web.Response: + denied = _require_admin(request) + if denied: + return denied + return web.json_response({"ok": True, "backups": list_backups()}) + + +async def handle_admin_backups_run(request: web.Request) -> web.Response: + denied = _require_admin(request) + if denied: + return denied + try: + path = await run_pg_dump_backup(compose_dir=ROOT_DIR) + except Exception as exc: # noqa: BLE001 + logger.exception("backup failed") + return web.json_response({"ok": False, "error": str(exc)}, status=500) + return web.json_response({"ok": True, "file": path.name, "backups": list_backups()}) + + +async def handle_admin_backups_download(request: web.Request) -> web.Response: + denied = _require_admin(request) + if denied: + return denied + name = request.match_info.get("name") or "" + try: + path = resolve_backup_path(name) + except (ValueError, FileNotFoundError) as exc: + return web.json_response({"ok": False, "error": str(exc)}, status=404) + return web.FileResponse( + path, + headers={ + "Content-Type": "application/gzip", + "Content-Disposition": f'attachment; filename="{path.name}"', + }, + ) + + +async def handle_admin_backups_delete(request: web.Request) -> web.Response: + denied = _require_admin(request) + if denied: + return denied + name = request.match_info.get("name") or "" + try: + delete_backup(name) + except (ValueError, FileNotFoundError) as exc: + return web.json_response({"ok": False, "error": str(exc)}, status=404) + return web.json_response({"ok": True, "backups": list_backups()}) + + +async def handle_admin_backups_delete_post(request: web.Request) -> web.Response: + """Удаление через POST — надёжнее, чем DELETE за прокси.""" + denied = _require_admin(request) + if denied: + return denied + try: + body = await request.json() + except json.JSONDecodeError: + return web.json_response({"ok": False, "error": "invalid json"}, status=400) + name = str(body.get("name") or "").strip() + if not name: + return web.json_response({"ok": False, "error": "Укажите name"}, status=400) + try: + delete_backup(name) + except (ValueError, FileNotFoundError) as exc: + return web.json_response({"ok": False, "error": str(exc)}, status=404) + return web.json_response({"ok": True, "backups": list_backups(), "message": f"Удалён {name}"}) + + +async def handle_admin_backups_restore(request: web.Request) -> web.Response: + """Восстановить БД из сохранённого бэкапа или загруженного файла (multipart).""" + denied = _require_admin(request) + if denied: + return denied + + content_type = (request.content_type or "").lower() + safety_name = None + + try: + # Сначала страховочный бэкап текущего состояния + try: + safety = await run_pg_dump_backup(compose_dir=ROOT_DIR) + safety_name = safety.name + except Exception as exc: # noqa: BLE001 + logger.warning("pre-restore safety backup failed: %s", exc) + + if "multipart" in content_type: + reader = await request.multipart() + confirm = False + file_bytes: bytes | None = None + filename = "upload.sql.gz" + while True: + part = await reader.next() + if part is None: + break + if part.name == "confirm": + confirm = (await part.text()).strip().lower() in {"1", "true", "yes", "on"} + elif part.name == "file": + filename = Path(part.filename or filename).name + file_bytes = await part.read(decode=False) + if not confirm: + return web.json_response( + {"ok": False, "error": "Нужно подтверждение restore (confirm=true)"}, + status=400, + ) + if not file_bytes: + return web.json_response({"ok": False, "error": "Файл не передан"}, status=400) + await restore_from_bytes(file_bytes, filename=filename) + try: + await save_uploaded_backup(file_bytes, original_name=filename) + except Exception: # noqa: BLE001 + pass + else: + try: + body = await request.json() + except json.JSONDecodeError: + return web.json_response({"ok": False, "error": "invalid json"}, status=400) + if not body.get("confirm"): + return web.json_response( + {"ok": False, "error": "Нужно подтверждение restore (confirm=true)"}, + status=400, + ) + name = str(body.get("name") or "").strip() + if not name: + return web.json_response({"ok": False, "error": "Укажите name бэкапа"}, status=400) + await restore_from_backup_name(name) + except Exception as exc: # noqa: BLE001 + logger.exception("restore failed") + return web.json_response( + { + "ok": False, + "error": str(exc), + "safety_backup": safety_name, + }, + status=500, + ) + + return web.json_response( + { + "ok": True, + "message": "База восстановлена. Перезапустите bot при необходимости.", + "safety_backup": safety_name, + "backups": list_backups(), + } + ) + + +async def handle_admin_backups_upload(request: web.Request) -> web.Response: + """Только сохранить файл в список бэкапов (без restore).""" + denied = _require_admin(request) + if denied: + return denied + if "multipart" not in (request.content_type or "").lower(): + return web.json_response({"ok": False, "error": "ожидается multipart"}, status=400) + reader = await request.multipart() + file_bytes: bytes | None = None + filename = "upload.sql.gz" + while True: + part = await reader.next() + if part is None: + break + if part.name == "file": + filename = Path(part.filename or filename).name + file_bytes = await part.read(decode=False) + if not file_bytes: + return web.json_response({"ok": False, "error": "Файл не передан"}, status=400) + try: + path = await save_uploaded_backup(file_bytes, original_name=filename) + except Exception as exc: # noqa: BLE001 + return web.json_response({"ok": False, "error": str(exc)}, status=400) + return web.json_response({"ok": True, "file": path.name, "backups": list_backups()}) + + +async def handle_admin_growth_get(request: web.Request) -> web.Response: + denied = _require_admin(request) + if denied: + return denied + db: Database = request.app["db"] + return web.json_response( + { + "ok": True, + "settings": { + "referral_bonus_referrer": db.get_setting("referral_bonus_referrer", "50"), + "referral_bonus_referee": db.get_setting("referral_bonus_referee", "50"), + "trial_days": db.get_setting("trial_days", "3"), + "trial_tariff_id": db.get_setting("trial_tariff_id", ""), + }, + } + ) + + +async def handle_admin_growth_set(request: web.Request) -> web.Response: + denied = _require_admin(request) + if denied: + return denied + db: Database = request.app["db"] + try: + body = await request.json() + except json.JSONDecodeError: + return web.json_response({"ok": False, "error": "invalid json"}, status=400) + mapping = { + "referral_bonus_referrer": "referral_bonus_referrer", + "referral_bonus_referee": "referral_bonus_referee", + "trial_days": "trial_days", + "trial_tariff_id": "trial_tariff_id", + } + data = {} + for k, sk in mapping.items(): + if k in body and body[k] is not None: + data[sk] = str(body[k]).strip() + if data: + db.set_settings_map(data) + return await handle_admin_growth_get(request) diff --git a/services/webauthn_auth.py b/services/webauthn_auth.py new file mode 100644 index 0000000..d30d0c3 --- /dev/null +++ b/services/webauthn_auth.py @@ -0,0 +1,164 @@ +"""WebAuthn / Passkey helpers.""" + +from __future__ import annotations + +import json +from typing import Any +from urllib.parse import urlparse + +from webauthn import ( + generate_authentication_options, + generate_registration_options, + options_to_json, + verify_authentication_response, + verify_registration_response, +) +from webauthn.helpers import ( + base64url_to_bytes, + bytes_to_base64url, + parse_authentication_credential_json, + parse_registration_credential_json, +) +from webauthn.helpers.structs import ( + AuthenticatorSelectionCriteria, + AuthenticatorTransport, + PublicKeyCredentialDescriptor, + ResidentKeyRequirement, + UserVerificationRequirement, +) + +from config import Settings + + +def rp_id_from_settings(settings: Settings) -> str: + raw = (settings.webapp_url or "").strip() + if not raw: + raise ValueError("WEBAPP_URL не задан — Passkey недоступен") + host = urlparse(raw).hostname + if not host: + raise ValueError("Некорректный WEBAPP_URL") + return host + + +def expected_origin(settings: Settings) -> str: + origin = (settings.webapp_url or "").strip().rstrip("/") + if not origin: + raise ValueError("WEBAPP_URL не задан") + return origin + + +def challenge_from_client_data(client_data_b64: str) -> str: + raw = base64url_to_bytes(client_data_b64) + data = json.loads(raw.decode("utf-8")) + challenge = data.get("challenge") + if not challenge: + raise ValueError("challenge missing") + return str(challenge) + + +def build_registration_options( + settings: Settings, + *, + user_id: int, + user_name: str, + user_display_name: str, + existing_credential_ids: list[bytes], +) -> tuple[dict[str, Any], bytes]: + options = generate_registration_options( + rp_id=rp_id_from_settings(settings), + rp_name="VPN Service", + user_id=str(user_id).encode("utf-8"), + user_name=user_name[:64] or f"user-{user_id}", + user_display_name=user_display_name[:64] or user_name, + exclude_credentials=[ + PublicKeyCredentialDescriptor(id=cid) for cid in existing_credential_ids + ], + authenticator_selection=AuthenticatorSelectionCriteria( + resident_key=ResidentKeyRequirement.PREFERRED, + user_verification=UserVerificationRequirement.PREFERRED, + ), + ) + challenge = options.challenge + return json.loads(options_to_json(options)), challenge + + +def verify_registration( + settings: Settings, + *, + credential: dict[str, Any], + expected_challenge: bytes, +) -> Any: + parsed = parse_registration_credential_json(json.dumps(credential)) + return verify_registration_response( + credential=parsed, + expected_challenge=expected_challenge, + expected_rp_id=rp_id_from_settings(settings), + expected_origin=expected_origin(settings), + ) + + +def build_authentication_options( + settings: Settings, + *, + allow_credential_ids: list[bytes] | None = None, +) -> tuple[dict[str, Any], bytes]: + allow = None + if allow_credential_ids: + allow = [PublicKeyCredentialDescriptor(id=cid) for cid in allow_credential_ids] + options = generate_authentication_options( + rp_id=rp_id_from_settings(settings), + allow_credentials=allow, + user_verification=UserVerificationRequirement.PREFERRED, + ) + return json.loads(options_to_json(options)), options.challenge + + +def verify_authentication( + settings: Settings, + *, + credential: dict[str, Any], + expected_challenge: bytes, + credential_public_key: bytes, + credential_current_sign_count: int, +) -> Any: + parsed = parse_authentication_credential_json(json.dumps(credential)) + return verify_authentication_response( + credential=parsed, + expected_challenge=expected_challenge, + expected_rp_id=rp_id_from_settings(settings), + expected_origin=expected_origin(settings), + credential_public_key=credential_public_key, + credential_current_sign_count=credential_current_sign_count, + ) + + +def credential_id_b64(raw: bytes) -> str: + return bytes_to_base64url(raw) + + +def credential_id_bytes(b64: str) -> bytes: + return base64url_to_bytes(b64) + + +def parse_transports(raw: Any) -> list[str]: + if not raw: + return [] + if isinstance(raw, list): + return [str(x) for x in raw] + try: + data = json.loads(raw) if isinstance(raw, str) else [] + except json.JSONDecodeError: + return [] + if isinstance(data, list): + return [str(x) for x in data] + return [] + + +def transports_to_enum(names: list[str]) -> list[AuthenticatorTransport]: + out: list[AuthenticatorTransport] = [] + for name in names: + try: + out.append(AuthenticatorTransport(name)) + except ValueError: + continue + return out diff --git a/services/xui_client.py b/services/xui_client.py new file mode 100644 index 0000000..e345831 --- /dev/null +++ b/services/xui_client.py @@ -0,0 +1,643 @@ +"""Клиент API панели 3x-ui (v3.5.0+): login, inbounds, WireGuard clients.""" + +from __future__ import annotations + +import json +import logging +import re +import secrets +import string +from dataclasses import dataclass +from typing import Any +from urllib.parse import quote, urljoin, urlparse + +import httpx + +logger = logging.getLogger(__name__) + +# 3x-ui 3.5.0: WireGuard — multi-client через settings.clients + addClient +SUPPORTED_PANEL = "3.5.0" + + +class XuiError(RuntimeError): + pass + + +@dataclass(frozen=True, slots=True) +class XuiInbound: + id: int + remark: str + protocol: str + port: int + enable: bool + clients_count: int = 0 + listen: str = "" + + @property + def is_wireguard(self) -> bool: + return (self.protocol or "").lower() in {"wireguard", "wg"} + + +@dataclass(frozen=True, slots=True) +class XuiConnectionInfo: + ok: bool + version: str | None + inbounds_total: int + wireguard: list[XuiInbound] + message: str = "" + + +def _normalize_base_url(url: str) -> str: + raw = (url or "").strip().rstrip("/") + if not raw: + raise XuiError("API URL пуст") + if not raw.startswith(("http://", "https://")): + raw = "https://" + raw + parsed = urlparse(raw) + if not parsed.netloc: + raise XuiError("Некорректный API URL") + return raw + + +def _new_sub_id(length: int = 16) -> str: + alphabet = string.ascii_lowercase + string.digits + return "".join(secrets.choice(alphabet) for _ in range(length)) + + +def build_subscription_link(subscription_base: str, sub_id: str) -> str: + """Склеивает ручной base URL подписки с subId клиента.""" + base = (subscription_base or "").strip().rstrip("/") + sid = (sub_id or "").strip().lstrip("/") + if not base or not sid: + return "" + return f"{base}/{sid}" + + +def _parse_settings(raw: Any) -> dict[str, Any]: + if isinstance(raw, dict): + return raw + if isinstance(raw, str) and raw.strip(): + try: + data = json.loads(raw) + return data if isinstance(data, dict) else {} + except json.JSONDecodeError: + return {} + return {} + + +def _clients_count(settings: dict[str, Any]) -> int: + clients = settings.get("clients") + if isinstance(clients, list): + return len(clients) + peers = settings.get("peers") + if isinstance(peers, list): + return len(peers) + return 0 + + +def _inbound_from_obj(obj: dict[str, Any]) -> XuiInbound: + settings = _parse_settings(obj.get("settings")) + return XuiInbound( + id=int(obj.get("id") or 0), + remark=str(obj.get("remark") or ""), + protocol=str(obj.get("protocol") or ""), + port=int(obj.get("port") or 0), + enable=bool(obj.get("enable", True)), + clients_count=_clients_count(settings), + listen=str(obj.get("listen") or ""), + ) + + +class XuiClient: + """Сессия к одной панели 3x-ui. + + Предпочтительно: API Token (Settings → Security → API Token) как Bearer. + Fallback: cookie после POST /login (username + password). + """ + + def __init__( + self, + api_url: str, + username: str = "", + password: str = "", + *, + api_key: str = "", + verify_ssl: bool = True, + timeout: float = 25.0, + ) -> None: + self.base_url = _normalize_base_url(api_url) + self.username = (username or "").strip() + self.password = password or "" + self.api_key = (api_key or "").strip() + self.verify_ssl = verify_ssl + self.timeout = timeout + self._client: httpx.AsyncClient | None = None + self._authed = False + + async def __aenter__(self) -> XuiClient: + await self.open() + return self + + async def __aexit__(self, *args: Any) -> None: + await self.close() + + async def open(self) -> None: + if self._client is not None: + return + headers: dict[str, str] = {"Accept": "application/json"} + if self.api_key: + headers["Authorization"] = f"Bearer {self.api_key}" + self._client = httpx.AsyncClient( + base_url=self.base_url, + timeout=self.timeout, + verify=self.verify_ssl, + follow_redirects=True, + headers=headers, + ) + + async def close(self) -> None: + if self._client is not None: + await self._client.aclose() + self._client = None + self._authed = False + + def _http(self) -> httpx.AsyncClient: + if self._client is None: + raise XuiError("Клиент не открыт — вызовите open() или используйте async with") + return self._client + + async def _request( + self, + method: str, + path: str, + *, + json_body: dict[str, Any] | None = None, + data: dict[str, Any] | None = None, + ) -> dict[str, Any]: + http = self._http() + url = path if path.startswith("/") else f"/{path}" + try: + resp = await http.request(method, url, json=json_body, data=data) + except httpx.RequestError as exc: + raise XuiError(f"Сеть: {exc}") from exc + + try: + payload = resp.json() + except json.JSONDecodeError: + text = (resp.text or "")[:200].replace("\n", " ") + hint = "" + if resp.status_code == 404: + hint = ( + " (часто: неверный API Token — панель отвечает 404; " + "или устаревший путь API)" + ) + raise XuiError( + f"Не JSON ответ ({resp.status_code}){hint}: {text}" + ) from None + + if not isinstance(payload, dict): + raise XuiError("Неожиданный формат ответа панели") + + if resp.status_code >= 400: + msg = payload.get("msg") or payload.get("message") or resp.reason_phrase + raise XuiError(f"HTTP {resp.status_code}: {msg}") + + # 3x-ui обычно: {success: bool, msg: str, obj: ...} + if "success" in payload and payload.get("success") is False: + raise XuiError(str(payload.get("msg") or "Ошибка панели")) + + return payload + + async def ensure_auth(self) -> None: + """Авторизация: Bearer API key или login cookie.""" + if self._authed: + return + await self.open() + if self.api_key: + # Bearer уже в headers — login не нужен (обходит CSRF на /panel/api/*) + self._authed = True + return + await self.login() + self._authed = True + + async def login(self) -> None: + if not self.username or not self.password: + raise XuiError("Нужен API Token или пара логин/пароль") + # Классический form login; часть сборок принимает JSON + try: + await self._request( + "POST", + "/login", + data={"username": self.username, "password": self.password}, + ) + except XuiError: + await self._request( + "POST", + "/login", + json_body={"username": self.username, "password": self.password}, + ) + + async def get_version(self) -> str | None: + for path in ("/panel/api/server/status", "/panel/api/server/getConfigJson"): + try: + payload = await self._request("GET", path) + except XuiError: + continue + obj = payload.get("obj") + if isinstance(obj, dict): + for key in ("version", "xrayVersion", "appVersion"): + val = obj.get(key) + if val: + return str(val) + # status иногда кладёт version на верхний уровень obj как строку в nested + if isinstance(obj, dict) and "cpu" in obj: + # нет версии в status — ок + return None + return None + + async def list_inbounds(self) -> list[XuiInbound]: + payload = await self._request("GET", "/panel/api/inbounds/list") + obj = payload.get("obj") + items: list[Any] + if isinstance(obj, list): + items = obj + elif isinstance(obj, dict): + items = obj.get("inbounds") or obj.get("list") or [] + else: + items = [] + out: list[XuiInbound] = [] + for item in items: + if isinstance(item, dict) and item.get("id") is not None: + out.append(_inbound_from_obj(item)) + return out + + async def get_inbound(self, inbound_id: int) -> dict[str, Any]: + payload = await self._request("GET", f"/panel/api/inbounds/get/{int(inbound_id)}") + obj = payload.get("obj") + if not isinstance(obj, dict): + raise XuiError(f"Инбаунд #{inbound_id} не найден") + return obj + + async def list_wireguard_inbounds(self) -> list[XuiInbound]: + return [i for i in await self.list_inbounds() if i.is_wireguard] + + async def add_wireguard_client( + self, + inbound_id: int, + *, + email: str, + sub_id: str | None = None, + tg_id: int | str = 0, + expiry_ms: int = 0, + total_gb: int = 0, + limit_ip: int = 0, + enable: bool = True, + comment: str = "", + ) -> dict[str, Any]: + """Добавляет WireGuard-клиента (3x-ui 3.5.0: POST /panel/api/clients/add).""" + await self.ensure_auth() + sid = (sub_id or "").strip() or _new_sub_id() + try: + tg_num = int(tg_id) if tg_id not in ("", None) else 0 + except (TypeError, ValueError): + tg_num = 0 + client: dict[str, Any] = { + "email": email, + "enable": bool(enable), + "expiryTime": int(expiry_ms), + "totalGB": int(total_gb), + "limitIp": int(limit_ip), + "tgId": tg_num, + "subId": sid, + "comment": comment or "", + "reset": 0, + } + modern_body = { + "client": client, + "inboundIds": [int(inbound_id)], + } + try: + payload = await self._request( + "POST", + "/panel/api/clients/add", + json_body=modern_body, + ) + except XuiError as modern_exc: + # Fallback для старых панелей с /inbounds/addClient + legacy_body = { + "id": int(inbound_id), + "settings": json.dumps({"clients": [client]}, ensure_ascii=False), + } + try: + payload = await self._request( + "POST", + "/panel/api/inbounds/addClient", + json_body=legacy_body, + ) + except XuiError: + raise modern_exc from None + + created = await self.get_client(inbound_id, email=email) + if created and created.get("subId"): + sid = str(created.get("subId") or sid) + return { + "sub_id": sid, + "email": email, + "client": created, + "response": payload, + } + + def _find_client_in_inbound( + self, inbound: dict[str, Any], *, email: str + ) -> dict[str, Any] | None: + settings = _parse_settings(inbound.get("settings")) + clients = settings.get("clients") + if not isinstance(clients, list): + return None + email_l = email.strip().lower() + for client in clients: + if isinstance(client, dict) and str(client.get("email") or "").strip().lower() == email_l: + return client + return None + + def _path_email(self, email: str) -> str: + """Email в path: простые id без кодирования, иначе PathEscape-совместимо.""" + raw = (email or "").strip() + if re.fullmatch(r"[A-Za-z0-9_.-]+", raw or ""): + return raw + return quote(raw, safe="._-~") + + @staticmethod + def _sanitize_client_payload( + client: dict[str, Any], + *, + enable: bool | None = None, + email: str | None = None, + ) -> dict[str, Any]: + """Только поля клиента — без traffic/inboundIds (иначе update ломается).""" + allowed = { + "email", + "enable", + "expiryTime", + "totalGB", + "limitIp", + "tgId", + "subId", + "comment", + "reset", + "flow", + "password", + "id", + "security", + "encryption", + "privateKey", + "publicKey", + "preSharedKey", + "allowedIPs", + "keepAlive", + "method", + "auth", + } + out: dict[str, Any] = {} + for key, value in client.items(): + if key not in allowed or value is None: + continue + if key == "allowedIPs" and not isinstance(value, list): + continue + if key != "allowedIPs" and isinstance(value, (dict, list)): + continue + out[key] = value + + # Client.id в 3x-ui — строка (UUID и т.п.), не numeric id из таблицы clients + raw_id = out.get("id", None) + if raw_id is None and client.get("uuid"): + raw_id = client.get("uuid") + if isinstance(raw_id, int): + # числовой PK таблицы clients нельзя слать как Client.id + out.pop("id", None) + if client.get("uuid"): + out["id"] = str(client["uuid"]) + elif raw_id is not None: + out["id"] = str(raw_id) + + if email: + out["email"] = email + elif "email" not in out and client.get("email"): + out["email"] = client["email"] + if enable is not None: + out["enable"] = bool(enable) + + # tgId панель иногда ждёт число + if "tgId" in out: + try: + out["tgId"] = int(out["tgId"] or 0) + except (TypeError, ValueError): + out["tgId"] = 0 + return out + + async def get_client( + self, inbound_id: int, *, email: str + ) -> dict[str, Any] | None: + await self.ensure_auth() + # Modern API + try: + payload = await self._request( + "GET", + f"/panel/api/clients/get/{self._path_email(email)}", + ) + obj = payload.get("obj") + if isinstance(obj, dict): + client = obj.get("client") if isinstance(obj.get("client"), dict) else obj + if isinstance(client, dict) and client.get("email"): + return client + except XuiError: + pass + inbound = await self.get_inbound(inbound_id) + return self._find_client_in_inbound(inbound, email=email) + + async def update_client( + self, + inbound_id: int, + client_id: str, + client: dict[str, Any], + ) -> dict[str, Any]: + """Обновление клиента: modern clients/update → legacy updateClient → inbound update.""" + await self.ensure_auth() + email = str(client.get("email") or client_id).strip() + clean = self._sanitize_client_payload(client, email=email) + + # 1) Modern API + for path in ( + f"/panel/api/clients/update/{self._path_email(email)}", + f"/panel/api/clients/update/{self._path_email(email)}?inboundIds={int(inbound_id)}", + ): + try: + return await self._request("POST", path, json_body=clean) + except XuiError: + pass + + # 2) Legacy inbounds/updateClient + legacy_body = { + "id": int(inbound_id), + "settings": json.dumps({"clients": [clean]}, ensure_ascii=False), + } + for client_key in (email, str(clean.get("id") or ""), client_id): + if not client_key: + continue + path = f"/panel/api/inbounds/updateClient/{self._path_email(str(client_key))}" + try: + return await self._request("POST", path, json_body=legacy_body) + except XuiError: + pass + try: + return await self._request( + "POST", + path, + data={ + "id": str(int(inbound_id)), + "settings": legacy_body["settings"], + }, + ) + except XuiError: + pass + + # 3) Fallback: правим поля прямо в settings inbound + return await self._update_inbound_client_fields( + inbound_id, + email=email, + patch=clean, + ) + + async def _update_inbound_client_fields( + self, + inbound_id: int, + *, + email: str, + patch: dict[str, Any], + ) -> dict[str, Any]: + inbound = await self.get_inbound(inbound_id) + settings = _parse_settings(inbound.get("settings")) + clients = settings.get("clients") + if not isinstance(clients, list): + raise XuiError(f"В inbound #{inbound_id} нет clients[]") + email_l = email.strip().lower() + found = False + new_clients: list[Any] = [] + for item in clients: + if not isinstance(item, dict): + new_clients.append(item) + continue + if str(item.get("email") or "").strip().lower() == email_l: + merged = dict(item) + merged.update(patch) + merged["email"] = item.get("email") or email + new_clients.append(merged) + found = True + else: + new_clients.append(item) + if not found: + raise XuiError(f"Клиент {email} не найден в inbound #{inbound_id}") + settings["clients"] = new_clients + + payload = dict(inbound) + payload["id"] = int(inbound_id) + payload["settings"] = json.dumps(settings, ensure_ascii=False) + for key in ("streamSettings", "sniffing", "allocate"): + val = payload.get(key) + if isinstance(val, (dict, list)): + payload[key] = json.dumps(val, ensure_ascii=False) + # убрать служебные поля ответа get + for key in ("clientStats", "clients", "obj"): + payload.pop(key, None) + return await self._request( + "POST", + f"/panel/api/inbounds/update/{int(inbound_id)}", + json_body=payload, + ) + + async def set_client_enabled( + self, + inbound_id: int, + *, + email: str, + enable: bool, + ) -> dict[str, Any]: + await self.ensure_auth() + current = await self.get_client(inbound_id, email=email) + if not current: + # всё равно пробуем через inbound settings + return await self._update_inbound_client_fields( + inbound_id, + email=email, + patch={"enable": bool(enable), "email": email}, + ) + clean = self._sanitize_client_payload(current, enable=enable, email=email) + try: + return await self.update_client(inbound_id, email, clean) + except XuiError: + return await self._update_inbound_client_fields( + inbound_id, + email=email, + patch=clean, + ) + + async def delete_client_by_email(self, inbound_id: int, email: str) -> dict[str, Any]: + await self.ensure_auth() + try: + return await self._request( + "POST", + f"/panel/api/clients/del/{self._path_email(email)}", + ) + except XuiError: + path = ( + f"/panel/api/inbounds/{int(inbound_id)}/" + f"delClientByEmail/{self._path_email(email)}" + ) + try: + return await self._request("POST", path) + except XuiError: + # отвязка / disable как мягкое удаление + return await self._update_inbound_client_fields( + inbound_id, + email=email, + patch={"enable": False, "email": email}, + ) + + async def test_connection(self) -> XuiConnectionInfo: + await self.open() + try: + await self.ensure_auth() + inbounds = await self.list_inbounds() + wg = [i for i in inbounds if i.is_wireguard] + version = None + try: + version = await self.get_version() + except XuiError: + version = None + auth_mode = "api_key" if self.api_key else "login" + msg = ( + f"OK · {auth_mode} · inbounds {len(inbounds)} · WireGuard {len(wg)}" + + (f" · panel {version}" if version else f" · target {SUPPORTED_PANEL}+") + ) + return XuiConnectionInfo( + ok=True, + version=version, + inbounds_total=len(inbounds), + wireguard=wg, + message=msg, + ) + except XuiError as exc: + return XuiConnectionInfo( + ok=False, + version=None, + inbounds_total=0, + wireguard=[], + message=str(exc), + ) + + +def subscription_preview(subscription_url: str, sample_sub_id: str = "exampleSubId") -> str: + link = build_subscription_link(subscription_url, sample_sub_id) + if link: + return link + return urljoin(_normalize_base_url(subscription_url or "https://example.invalid") + "/", sample_sub_id) diff --git a/services/xui_wg.py b/services/xui_wg.py new file mode 100644 index 0000000..23c90ca --- /dev/null +++ b/services/xui_wg.py @@ -0,0 +1,588 @@ +"""Покупка и управление WireGuard-конфигами 3x-ui (лимит 10 при активной подписке).""" + +from __future__ import annotations + +import logging +import secrets +import string +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any + +from remnawave import RemnawaveSDK + +from services.db import Database +from services.purchase import get_my_subscription_info +from services.remnawave_users import find_panel_user +from services.traffic import ( + BASE_TRAFFIC_GB, + ensure_base_limit_bytes, + format_gb, + gb_to_bytes, +) +from services.xui_client import XuiClient, XuiError, build_subscription_link + +logger = logging.getLogger(__name__) + +MAX_WG_CONFIGS = 10 +SETTING_ENABLED = "xui_wg_purchase_enabled" +SETTING_PRICE = "xui_wg_config_price" +DEFAULT_PRICE = 0.0 + + +def _truthy(value: str) -> bool: + return (value or "").strip().lower() in ("1", "true", "yes", "on", "да") + + +def _as_utc(dt: datetime) -> datetime: + if dt.tzinfo is None: + return dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc) + + +@dataclass(slots=True) +class WgPurchaseSettings: + enabled: bool = False + price: float = DEFAULT_PRICE + max_configs: int = MAX_WG_CONFIGS + + def as_dict(self) -> dict[str, Any]: + return { + "enabled": self.enabled, + "price": self.price, + "price_label": _format_price(self.price), + "max_configs": self.max_configs, + } + + +@dataclass(slots=True) +class WgActionResult: + ok: bool + message: str + balance: float = 0.0 + config: dict[str, Any] | None = None + configs: list[dict[str, Any]] | None = None + + +def _format_price(price: float) -> str: + if price <= 0: + return "бесплатно" + if price == int(price): + return f"{int(price)} ₽" + return f"{price:.2f} ₽" + + +def load_wg_purchase_settings(db: Database) -> WgPurchaseSettings: + raw = db.get_settings_map([SETTING_ENABLED, SETTING_PRICE]) + price_raw = (raw.get(SETTING_PRICE) or "").strip() + try: + price = float(price_raw) if price_raw else DEFAULT_PRICE + except ValueError: + price = DEFAULT_PRICE + return WgPurchaseSettings( + enabled=_truthy(raw.get(SETTING_ENABLED, "")), + price=max(0.0, price), + max_configs=MAX_WG_CONFIGS, + ) + + +def save_wg_purchase_settings( + db: Database, + *, + enabled: bool | None = None, + price: float | None = None, +) -> WgPurchaseSettings: + data: dict[str, str] = {} + if enabled is not None: + data[SETTING_ENABLED] = "1" if enabled else "0" + if price is not None: + data[SETTING_PRICE] = str(max(0.0, float(price))) + if data: + db.set_settings_map(data) + return load_wg_purchase_settings(db) + + +def _client_from_server(row: dict[str, Any]) -> XuiClient: + return XuiClient( + str(row["api_url"]), + username=str(row.get("username") or ""), + password=str(row.get("password") or ""), + api_key=str(row.get("api_key") or ""), + verify_ssl=bool(row.get("verify_ssl", True)), + ) + + +def _new_email(telegram_id: int) -> str: + suffix = "".join(secrets.choice(string.ascii_lowercase + string.digits) for _ in range(6)) + return f"wg_{telegram_id}_{suffix}" + + +def serialize_wg_config(row: dict[str, Any], server: dict[str, Any] | None = None) -> dict[str, Any]: + expire = row.get("expire_at") + traffic_bytes = int(row.get("traffic_bytes") or 0) + if traffic_bytes <= 0: + traffic_bytes = int(gb_to_bytes(BASE_TRAFFIC_GB)) + return { + "id": int(row["id"]), + "server_id": int(row["server_id"]), + "server_name": (server or {}).get("name") or row.get("server_name") or "", + "email": row.get("email") or "", + "sub_id": row.get("sub_id") or "", + "subscription_url": row.get("subscription_url") or "", + "remark": row.get("remark") or "", + "enabled": bool(row.get("enabled", True)), + "expire_at": expire.isoformat() if isinstance(expire, datetime) else expire, + "traffic_bytes": traffic_bytes, + "traffic_label": format_gb(traffic_bytes / (1024**3), digits=0), + "qr_url": f"/api/wg/configs/{int(row['id'])}/qr", + "created_at": row["created_at"].isoformat() + if isinstance(row.get("created_at"), datetime) + else row.get("created_at"), + } + + +async def _subscription_limits( + sdk: RemnawaveSDK, + telegram_id: int, + *, + login: str | None = None, + db: Database | None = None, +) -> tuple[datetime, int]: + """Срок и лимит трафика как у подписки в кабинете (база ≥ 1000 ГБ).""" + info = await get_my_subscription_info(sdk, telegram_id, login=login, db=db) + if not info.expire_at: + raise RuntimeError("Нет срока подписки") + expire = _as_utc(info.expire_at) + panel = await find_panel_user(sdk, telegram_id, login=login) + if panel is not None: + traffic_bytes = int(ensure_base_limit_bytes(getattr(panel, "traffic_limit_bytes", 0))) + elif info.traffic: + traffic_bytes = int(ensure_base_limit_bytes(info.traffic.limit_bytes)) + else: + traffic_bytes = int(gb_to_bytes(BASE_TRAFFIC_GB)) + return expire, traffic_bytes + + +async def subscription_is_active( + sdk: RemnawaveSDK, + telegram_id: int, + *, + login: str | None = None, + db: Database | None = None, +) -> tuple[bool, str]: + info = await get_my_subscription_info(sdk, telegram_id, login=login, db=db) + if not info.has_subscription or not info.expire_at: + return False, "Нет активной подписки. Сначала купите тариф." + now = datetime.now(timezone.utc) + if _as_utc(info.expire_at) <= now: + return ( + False, + "Подписка истекла. Продлите или купите тариф — после этого снова можно включать конфиги.", + ) + if info.devices and info.devices.over_limit: + return False, "Подписка отключена из‑за превышения устройств." + return True, "" + + +def list_available_servers(db: Database) -> list[dict[str, Any]]: + rows = db.list_xui_servers(enabled_only=True) + out: list[dict[str, Any]] = [] + for r in rows: + if r.get("inbound_id") is None: + continue + if not str(r.get("subscription_url") or "").strip(): + continue + if not (str(r.get("api_key") or "").strip() or str(r.get("password") or "").strip()): + continue + out.append( + { + "id": int(r["id"]), + "name": r.get("name") or f"Server #{r['id']}", + "inbound_id": r.get("inbound_id"), + "inbound_remark": r.get("inbound_remark") or "", + } + ) + return out + + +async def purchase_wg_config( + *, + db: Database, + sdk: RemnawaveSDK, + telegram_id: int, + server_id: int, + remark: str = "", + login: str | None = None, +) -> WgActionResult: + settings = load_wg_purchase_settings(db) + bot_user = db.ensure_user(telegram_id) + balance = bot_user.balance + login = login or bot_user.login + + if not settings.enabled: + return WgActionResult( + ok=False, + message="Покупка WireGuard-конфигов временно отключена.", + balance=balance, + ) + + active, reason = await subscription_is_active( + sdk, telegram_id, login=login, db=db + ) + if not active: + return WgActionResult(ok=False, message=reason, balance=balance) + + count = db.count_xui_wg_configs(telegram_id) + if count >= settings.max_configs: + return WgActionResult( + ok=False, + message=f"Лимит: максимум {settings.max_configs} конфигов на аккаунт.", + balance=balance, + ) + + server = db.get_xui_server(server_id) + if not server or not server.get("enabled"): + return WgActionResult(ok=False, message="Сервер недоступен.", balance=balance) + inbound_id = server.get("inbound_id") + if inbound_id is None: + return WgActionResult( + ok=False, + message="У сервера не выбран WireGuard inbound.", + balance=balance, + ) + sub_base = str(server.get("subscription_url") or "").strip() + if not sub_base: + return WgActionResult( + ok=False, + message="У сервера не задан URL подписки.", + balance=balance, + ) + + price = float(settings.price) + if price > 0: + ok, balance = db.try_charge( + telegram_id, + price, + kind="wg_config", + meta=f"wg:{server_id}", + ) + if not ok: + return WgActionResult( + ok=False, + message=( + f"Недостаточно средств.\n" + f"Нужно: {_format_price(price)}\n" + f"Баланс: {balance:.0f} ₽" + ), + balance=balance, + ) + + email = _new_email(telegram_id) + remark = (remark or "").strip()[:80] or f"WG {bot_user.display_name}" + try: + expire_at, traffic_bytes = await _subscription_limits( + sdk, telegram_id, login=login, db=db + ) + expiry_ms = int(expire_at.timestamp() * 1000) + async with _client_from_server(server) as client: + created = await client.add_wireguard_client( + int(inbound_id), + email=email, + tg_id=telegram_id if telegram_id > 0 else 0, + comment=remark, + enable=True, + expiry_ms=expiry_ms, + total_gb=traffic_bytes, # в 3x-ui clients API totalGB — байты + ) + sub_id = str(created["sub_id"]) + sub_url = build_subscription_link(sub_base, sub_id) + row = db.create_xui_wg_config( + telegram_id=telegram_id, + server_id=server_id, + email=email, + sub_id=sub_id, + subscription_url=sub_url, + remark=remark, + enabled=True, + expire_at=expire_at, + traffic_bytes=traffic_bytes, + ) + except Exception as exc: # noqa: BLE001 + logger.exception("WG config create failed") + if price > 0: + balance = db.add_balance( + telegram_id, + price, + kind="refund", + meta=f"wg_refund:{server_id}", + ) + return WgActionResult( + ok=False, + message=f"Не удалось создать конфиг: {exc}", + balance=balance, + ) + + cfg = serialize_wg_config(row, server) + expire_str = expire_at.strftime("%d.%m.%Y %H:%M UTC") + return WgActionResult( + ok=True, + message=( + f"WireGuard-конфиг создан" + + (f" (−{_format_price(price)})" if price > 0 else "") + + f".\nДо: {expire_str}" + + f"\nТрафик: {format_gb(traffic_bytes / (1024**3), digits=0)}" + + f".\n🔗 {sub_url}" + ), + balance=balance if price <= 0 else balance, + config=cfg, + ) + + +async def set_wg_config_enabled( + *, + db: Database, + telegram_id: int, + config_id: int, + enable: bool, + sdk: RemnawaveSDK | None = None, + login: str | None = None, +) -> WgActionResult: + bot_user = db.ensure_user(telegram_id) + login = login or bot_user.login + row = db.get_xui_wg_config(config_id, telegram_id=telegram_id) + if not row: + return WgActionResult(ok=False, message="Конфиг не найден.", balance=bot_user.balance) + + if enable: + if sdk is None: + return WgActionResult( + ok=False, + message="Не удалось проверить подписку.", + balance=bot_user.balance, + ) + active, reason = await subscription_is_active( + sdk, telegram_id, login=login, db=db + ) + if not active: + return WgActionResult(ok=False, message=reason, balance=bot_user.balance) + + server = db.get_xui_server(int(row["server_id"])) + if not server or server.get("inbound_id") is None: + return WgActionResult( + ok=False, + message="Сервер конфига недоступен.", + balance=bot_user.balance, + ) + + try: + async with _client_from_server(server) as client: + await client.set_client_enabled( + int(server["inbound_id"]), + email=str(row["email"]), + enable=enable, + ) + updated = db.set_xui_wg_config_enabled(config_id, telegram_id, enable) + except XuiError as exc: + return WgActionResult( + ok=False, + message=f"Панель: {exc}", + balance=bot_user.balance, + ) + except Exception as exc: # noqa: BLE001 + logger.exception("WG toggle failed") + return WgActionResult( + ok=False, + message=str(exc), + balance=bot_user.balance, + ) + + return WgActionResult( + ok=True, + message="Конфиг включён." if enable else "Конфиг выключен.", + balance=bot_user.balance, + config=serialize_wg_config(updated or row, server) if updated or row else None, + ) + + +async def _disable_wg_row(db: Database, row: dict[str, Any]) -> bool: + """Выключает один WG-конфиг на панели и в БД. True если изменили.""" + if not row.get("enabled"): + return False + server = db.get_xui_server(int(row["server_id"])) + if not server or server.get("inbound_id") is None: + db.set_xui_wg_config_enabled(int(row["id"]), int(row["telegram_id"]), False) + return True + try: + async with _client_from_server(server) as client: + await client.set_client_enabled( + int(server["inbound_id"]), + email=str(row["email"]), + enable=False, + ) + except Exception as exc: # noqa: BLE001 + logger.warning( + "WG auto-disable panel failed config=%s: %s", + row.get("id"), + exc, + ) + db.set_xui_wg_config_enabled(int(row["id"]), int(row["telegram_id"]), False) + return True + + +async def _enable_wg_row(db: Database, row: dict[str, Any]) -> bool: + if row.get("enabled"): + return False + server = db.get_xui_server(int(row["server_id"])) + if not server or server.get("inbound_id") is None: + return False + try: + async with _client_from_server(server) as client: + await client.set_client_enabled( + int(server["inbound_id"]), + email=str(row["email"]), + enable=True, + ) + db.set_xui_wg_config_enabled(int(row["id"]), int(row["telegram_id"]), True) + return True + except Exception as exc: # noqa: BLE001 + logger.warning( + "WG auto-enable panel failed config=%s: %s", + row.get("id"), + exc, + ) + return False + + +async def enforce_wg_configs_subscription( + *, + db: Database, + sdk: RemnawaveSDK, + telegram_id: int, + login: str | None = None, +) -> dict[str, Any]: + """В реальном времени: нет подписки → выключить все WG.""" + bot_user = db.ensure_user(telegram_id) + login = login or bot_user.login + active, reason = await subscription_is_active( + sdk, telegram_id, login=login, db=db + ) + disabled = 0 + if not active: + for row in db.list_xui_wg_configs(telegram_id): + if await _disable_wg_row(db, row): + disabled += 1 + return { + "subscription_active": active, + "reason": "" if active else reason, + "disabled": disabled, + } + + +async def restore_wg_configs_after_renew( + *, + db: Database, + telegram_id: int, +) -> int: + """После продления снова включает ранее отключённые WG-конфиги.""" + rows = db.list_xui_wg_configs(telegram_id) + restored = 0 + for row in rows: + if row.get("enabled"): + continue + if await _enable_wg_row(db, row): + restored += 1 + return restored + + +async def sync_wg_configs_limits( + *, + db: Database, + telegram_id: int, + expire_at: datetime | None = None, + traffic_bytes: int | None = None, + restore_enabled: bool = False, +) -> int: + """Обновляет срок/трафик всех WG-конфигов пользователя на панелях 3x-ui.""" + if expire_at is None and traffic_bytes is None and not restore_enabled: + return 0 + rows = db.list_xui_wg_configs(telegram_id) + updated = 0 + expiry_ms = int(_as_utc(expire_at).timestamp() * 1000) if expire_at else None + for row in rows: + server = db.get_xui_server(int(row["server_id"])) + if not server or server.get("inbound_id") is None: + continue + try: + async with _client_from_server(server) as client: + current = await client.get_client( + int(server["inbound_id"]), + email=str(row["email"]), + ) + if not current: + continue + patch = dict(current) + patch["email"] = str(row["email"]) + if expiry_ms is not None: + patch["expiryTime"] = expiry_ms + if traffic_bytes is not None: + patch["totalGB"] = int(traffic_bytes) + if restore_enabled: + patch["enable"] = True + await client.update_client( + int(server["inbound_id"]), + str(row["email"]), + patch, + ) + db.update_xui_wg_config_limits( + int(row["id"]), + expire_at=expire_at, + traffic_bytes=traffic_bytes, + ) + if restore_enabled and not row.get("enabled"): + db.set_xui_wg_config_enabled( + int(row["id"]), telegram_id, True + ) + updated += 1 + except Exception as exc: # noqa: BLE001 + logger.warning( + "WG sync limits failed config=%s: %s", + row.get("id"), + exc, + ) + return updated + + +def list_user_wg_configs(db: Database, telegram_id: int) -> list[dict[str, Any]]: + rows = db.list_xui_wg_configs(telegram_id) + servers = {int(s["id"]): s for s in db.list_xui_servers()} + return [serialize_wg_config(r, servers.get(int(r["server_id"]))) for r in rows] + + +async def delete_wg_config( + *, + db: Database, + telegram_id: int, + config_id: int, +) -> WgActionResult: + bot_user = db.ensure_user(telegram_id) + row = db.get_xui_wg_config(config_id, telegram_id=telegram_id) + if not row: + return WgActionResult(ok=False, message="Конфиг не найден.", balance=bot_user.balance) + + server = db.get_xui_server(int(row["server_id"])) + if server and server.get("inbound_id") is not None: + try: + async with _client_from_server(server) as client: + await client.delete_client_by_email( + int(server["inbound_id"]), + str(row["email"]), + ) + except Exception as exc: # noqa: BLE001 + logger.warning("WG delete on panel failed (will drop locally): %s", exc) + + db.delete_xui_wg_config(config_id, telegram_id) + return WgActionResult( + ok=True, + message="Конфиг удалён. Слот освобождён.", + balance=bot_user.balance, + ) diff --git a/tariffs.json b/tariffs.json new file mode 100644 index 0000000..17f8ef5 --- /dev/null +++ b/tariffs.json @@ -0,0 +1,84 @@ +[ + + { + + "id": "month_1", + + "title": "1 месяц", + + "price": 199, + + "days": 30, + + "traffic_gb": 1000, + + "device_limit": 10, + + "squad_uuids": ["03a5cc01-80b0-426e-9356-d2aa6ec20988"], + + "description": "Все серверы · 10 устройств · 1000 ГБ" + + }, + + { + + "id": "month_3", + + "title": "3 месяца", + + "price": 499, + + "days": 90, + + "traffic_gb": 1000, + + "device_limit": 10, + + "squad_uuids": ["03a5cc01-80b0-426e-9356-d2aa6ec20988"], + + "description": "Выгоднее · 10 устройств · 1000 ГБ" + + }, + + { + + "id": "month_6", + + "title": "6 месяцев", + + "price": 899, + + "days": 180, + + "traffic_gb": 1000, + + "device_limit": 10, + + "squad_uuids": ["03a5cc01-80b0-426e-9356-d2aa6ec20988"], + + "description": "Максимум экономии · 10 устройств · 1000 ГБ" + + }, + + { + + "id": "year_1", + + "title": "12 месяцев", + + "price": 1599, + + "days": 365, + + "traffic_gb": 1000, + + "device_limit": 10, + + "squad_uuids": ["03a5cc01-80b0-426e-9356-d2aa6ec20988"], + + "description": "Год доступа · 10 устройств · 1000 ГБ" + + } + +] + diff --git a/webapp/admin.html b/webapp/admin.html new file mode 100644 index 0000000..092076a --- /dev/null +++ b/webapp/admin.html @@ -0,0 +1,1087 @@ + + + + + + Админка — VPN Service + + + + + + + + + + + + + +
+
+ + + + VPN Service + Админ-панель + + +
+ + Кабинет + +
+
+ + + + + + +
+ + + + diff --git a/webapp/index.html b/webapp/index.html new file mode 100644 index 0000000..684bfde --- /dev/null +++ b/webapp/index.html @@ -0,0 +1,678 @@ + + + + + + VPN Service + + + + + + + + + + + + + +
+
+
+ +
+

VPN Service

+

Загрузка…

+
+
+
+
+ + +
+
+ + + + + + + + + + + + +
+

QR WireGuard

+

Отсканируйте в приложении с поддержкой подписки

+ QR код + +
+ + +
+
+
+
+ + + + diff --git a/webapp/static/admin.css b/webapp/static/admin.css new file mode 100644 index 0000000..6770d3b --- /dev/null +++ b/webapp/static/admin.css @@ -0,0 +1,1394 @@ +/* Admin panel — расширение дизайн-системы кабинета */ + +.admin-body { + min-height: 100vh; + position: relative; + background: var(--bg); + color: var(--text); +} + +.admin-bg { + position: fixed; + inset: 0; + pointer-events: none; + z-index: 0; + background: + radial-gradient(ellipse 80% 50% at 50% -20%, color-mix(in srgb, var(--accent) 18%, transparent), transparent 55%), + var(--bg); +} + +.admin-app { + position: relative; + z-index: 1; + max-width: 1280px; + margin: 0 auto; + padding: 10px 12px 40px; +} + +/* Top bar */ +.admin-topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 16px; + padding: 4px 0; +} + +.admin-brand { + display: flex; + align-items: center; + gap: 12px; + text-decoration: none; + color: inherit; +} + +.admin-brand-mark { + width: 44px; + height: 44px; + display: grid; + place-items: center; + font-size: 22px; + border-radius: 14px; + background: linear-gradient(145deg, rgba(61, 207, 154, 0.25), rgba(61, 207, 154, 0.06)); + border: 1px solid rgba(61, 207, 154, 0.35); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25); +} + +.admin-brand span { + display: block; +} + +.admin-brand-eyebrow { + font-size: 10px; + font-weight: 600; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--accent); +} + +.admin-brand-title { + font-size: 17px; + font-weight: 700; + line-height: 1.2; +} + +.admin-topbar-actions { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +.admin-link-cabinet { + white-space: nowrap; +} + +/* Login */ +.admin-login-wrap { + display: flex; + justify-content: center; + align-items: flex-start; + padding: 24px 0 40px; + min-height: calc(100vh - 120px); +} + +.admin-login-card { + position: relative; + width: 100%; + max-width: 420px; + padding: 32px 28px 28px; + border-radius: 24px; + background: linear-gradient(165deg, rgba(24, 33, 30, 0.95), rgba(15, 22, 20, 0.98)); + border: 1px solid rgba(61, 207, 154, 0.2); + box-shadow: + 0 24px 64px rgba(0, 0, 0, 0.45), + inset 0 1px 0 rgba(255, 255, 255, 0.06); + overflow: hidden; +} + +.admin-login-glow { + position: absolute; + top: -80px; + left: 50%; + transform: translateX(-50%); + width: 200px; + height: 200px; + background: radial-gradient(circle, rgba(61, 207, 154, 0.35), transparent 70%); + pointer-events: none; +} + +.admin-login-icon { + position: relative; + width: 56px; + height: 56px; + margin: 0 auto 12px; + display: grid; + place-items: center; + color: var(--accent); + border-radius: 16px; + background: linear-gradient(145deg, rgba(61, 207, 154, 0.22), rgba(61, 207, 154, 0.06)); + border: 1px solid rgba(61, 207, 154, 0.3); +} + +.admin-login-kicker { + position: relative; + margin: 0 0 8px; + text-align: center; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--accent); +} + +.admin-login-card h1 { + position: relative; + margin: 0 0 10px; + text-align: center; + font-size: 22px; + font-weight: 700; +} + +.admin-login-lead { + position: relative; + margin: 0 0 22px; + text-align: center; + font-size: 13px; + line-height: 1.55; + color: var(--muted); +} + +.admin-login-lead code { + font-size: 11px; + padding: 2px 6px; + border-radius: 6px; + background: rgba(0, 0, 0, 0.35); + color: var(--accent); +} + +.admin-login-form { + position: relative; +} + +.admin-login-btn { + margin-top: 4px; + padding: 14px; + font-weight: 600; +} + +.admin-alert { + margin-top: 16px; + padding: 12px 14px; + border-radius: 12px; + font-size: 13px; + line-height: 1.45; + background: rgba(255, 107, 107, 0.12); + border: 1px solid rgba(255, 107, 107, 0.35); + color: #ffb4b4; +} + +/* Dashboard */ +.admin-dashboard { + display: grid; + grid-template-columns: 220px minmax(0, 1fr); + gap: 20px; + align-items: start; + animation: admin-fade-in 0.35s ease; +} + +.admin-sidebar { + position: sticky; + top: 12px; + display: flex; + flex-direction: column; + gap: 12px; + padding: 14px 12px; + border-radius: 18px; + background: var(--card); + border: 1px solid var(--line); + backdrop-filter: blur(10px); + max-height: calc(100vh - 24px); + overflow: hidden; +} + +.admin-sidebar-brand { + padding: 4px 8px 8px; + border-bottom: 1px solid var(--line); +} + +.admin-sidebar-kicker { + display: block; + font-size: 11px; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--muted); + margin-bottom: 2px; +} + +.admin-sidebar-title { + font-size: 15px; + font-weight: 700; + letter-spacing: -0.02em; +} + +.admin-main { + min-width: 0; +} + +.admin-views { + min-width: 0; +} + +.admin-section { + display: none; +} + +.admin-section.is-active { + display: block; + animation: admin-fade-in 0.25s ease; +} + +.admin-section.admin-grid.is-active { + display: grid; +} + +@keyframes admin-fade-in { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: none; + } +} + +.admin-hello { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; + margin-bottom: 16px; +} + +.admin-hello-title { + margin: 0; + font-size: 22px; + font-weight: 700; + letter-spacing: -0.02em; +} + +.admin-hello-hint { + padding: 8px 12px; + border-radius: 12px; + background: rgba(61, 207, 154, 0.08); + border: 1px solid rgba(61, 207, 154, 0.2); + max-width: 320px; +} + +.admin-nav { + display: flex; + flex-direction: column; + gap: 4px; + overflow-y: auto; + overflow-x: hidden; + scrollbar-width: thin; + padding: 2px; + margin: 0; + border: 0; + background: transparent; + backdrop-filter: none; +} + +.admin-nav::-webkit-scrollbar { + width: 4px; +} + +.admin-nav::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.12); + border-radius: 4px; +} + +.admin-nav-btn { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + gap: 8px; + width: 100%; + border: 1px solid transparent; + background: transparent; + color: var(--muted); + padding: 10px 12px; + border-radius: 12px; + font: inherit; + font-size: 13px; + font-weight: 600; + cursor: pointer; + white-space: nowrap; + text-align: left; + transition: background 0.18s, color 0.18s, border-color 0.18s; +} + +.admin-nav-btn:hover { + color: var(--text); + background: rgba(255, 255, 255, 0.04); +} + +.admin-nav-btn.active { + color: var(--text); + background: rgba(61, 207, 154, 0.12); + border-color: rgba(61, 207, 154, 0.28); +} + +.admin-nav-ico { + display: grid; + place-items: center; + color: inherit; + flex-shrink: 0; +} + +.admin-nav-btn.active .admin-nav-ico { + color: var(--accent); +} + +@media (max-width: 900px) { + .admin-dashboard { + grid-template-columns: 1fr; + gap: 14px; + } + + .admin-sidebar { + position: static; + max-height: none; + overflow: visible; + padding: 12px; + } + + .admin-sidebar-brand { + display: none; + } + + .admin-nav { + flex-direction: row; + overflow-x: auto; + overflow-y: hidden; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; + gap: 6px; + } + + .admin-nav::-webkit-scrollbar { + display: none; + } + + .admin-nav-btn { + width: auto; + white-space: nowrap; + } +} + +.admin-stats-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 12px; + margin-bottom: 0; + scroll-margin-top: 0; +} + +@media (max-width: 900px) { + .admin-stats-grid { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 480px) { + .admin-stats-grid { + grid-template-columns: 1fr; + } +} + +.admin-stat { + padding: 16px; + border-radius: var(--radius); + background: var(--card); + border: 1px solid var(--line); + box-shadow: var(--shadow); + transition: border-color 0.2s, transform 0.2s; + display: grid; + gap: 10px; +} + +.admin-stat:hover { + border-color: rgba(61, 207, 154, 0.25); + transform: translateY(-1px); +} + +.admin-stat-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.admin-stat-ico { + width: 32px; + height: 32px; + display: grid; + place-items: center; + border-radius: 10px; + color: var(--accent); + background: rgba(61, 207, 154, 0.1); + border: 1px solid rgba(61, 207, 154, 0.22); +} + +.admin-stat-label { + display: block; + font-size: 12px; + color: var(--muted); + margin-bottom: 4px; + font-weight: 500; +} + +.admin-stat-value { + font-size: 26px; + font-weight: 700; + line-height: 1; + letter-spacing: -0.02em; +} + +.admin-stat-hint { + margin: 0; + font-size: 11px; + color: var(--muted); +} + +.admin-stat--total .admin-stat-value { + color: var(--text); +} + +.admin-stat--unused .admin-stat-value { + color: var(--accent); +} + +.admin-stat--used .admin-stat-value { + color: var(--muted); +} + +.admin-stat-skeleton { + grid-column: 1 / -1; + text-align: center; + padding: 24px; + color: var(--muted); +} + +.admin-grid { + display: grid; + gap: 16px; +} + +.admin-card { + padding: 20px; +} + +.admin-card-head { + display: flex; + align-items: flex-start; + gap: 14px; + margin-bottom: 18px; +} + +.admin-card-head.compact { + margin-bottom: 0; +} + +.admin-card-head h2 { + margin: 0 0 4px; + font-size: 18px; + font-weight: 700; +} + +.admin-card-icon { + width: 40px; + height: 40px; + flex-shrink: 0; + display: grid; + place-items: center; + font-size: 18px; + border-radius: 12px; + background: var(--bg-2); + border: 1px solid var(--line); +} + +.admin-card-icon--svg { + color: var(--accent); + background: rgba(61, 207, 154, 0.1); + border-color: rgba(61, 207, 154, 0.28); +} + +.admin-card-icon svg { + display: block; +} + +.admin-subhead { + margin: 0; + font-size: 15px; + font-weight: 700; +} + +.admin-card, +#section-xui, +#section-codes, +#section-users, +#section-tickets, +#section-ai { + scroll-margin-top: 72px; +} + +.admin-empty { + text-align: center; + padding: 28px 16px; + border-radius: 14px; + border: 1px dashed rgba(61, 207, 154, 0.25); + background: rgba(0, 0, 0, 0.16); +} + +.admin-empty .empty-title { + margin: 0 0 6px; + font-weight: 700; + font-size: 14px; +} + +.admin-form-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +.admin-form-grid .full-width { + grid-column: 1 / -1; +} + +@media (max-width: 480px) { + .admin-form-grid { + grid-template-columns: 1fr; + } +} + +.generated-wrap { + margin-top: 20px; + padding-top: 18px; + border-top: 1px solid var(--line); +} + +.generated-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 10px; +} + +.code-output { + padding: 14px 16px; + border-radius: 14px; + background: var(--bg); + border: 1px dashed rgba(61, 207, 154, 0.35); + font-family: ui-monospace, "Cascadia Code", Consolas, monospace; + font-size: 13px; + line-height: 1.65; + color: var(--accent); + white-space: pre-wrap; + word-break: break-all; + max-height: 220px; + overflow-y: auto; +} + +.admin-list-head { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + justify-content: space-between; + gap: 14px; + margin-bottom: 16px; +} + +.admin-toolbar { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.admin-select { + appearance: none; + border: 1px solid var(--line); + background: var(--bg-2) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%238fa59a'%3E%3Cpath d='M6 8L1 3h10z'/%3E%3C/svg%3E") no-repeat right 12px center; + color: var(--text); + border-radius: 12px; + padding: 10px 36px 10px 12px; + font: inherit; + font-size: 13px; + cursor: pointer; +} + +.admin-select:focus { + outline: 1px solid rgba(61, 207, 154, 0.45); +} + +.codes-table-wrap { + overflow-x: auto; + border-radius: 14px; + border: 1px solid var(--line); + background: rgba(0, 0, 0, 0.2); +} + +.codes-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} + +.codes-table thead { + background: rgba(0, 0, 0, 0.25); +} + +.codes-table th { + text-align: left; + padding: 12px 14px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--muted); + border-bottom: 1px solid var(--line); +} + +.codes-table td { + padding: 12px 14px; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); + vertical-align: middle; +} + +.codes-table tbody tr:last-child td { + border-bottom: none; +} + +.codes-table tbody tr { + transition: background 0.15s; +} + +.codes-table tbody tr:hover { + background: rgba(61, 207, 154, 0.06); +} + +.codes-table .code-cell { + font-family: ui-monospace, Consolas, monospace; + font-size: 12px; + color: var(--accent); + cursor: pointer; + user-select: all; +} + +.codes-table .code-cell:hover { + text-decoration: underline; +} + +.badge { + display: inline-block; + padding: 4px 10px; + border-radius: 999px; + font-size: 11px; + font-weight: 600; + white-space: nowrap; +} + +.badge--free { + background: rgba(61, 207, 154, 0.15); + color: var(--accent); + border: 1px solid rgba(61, 207, 154, 0.35); +} + +.badge--used { + background: rgba(143, 165, 154, 0.12); + color: var(--muted); + border: 1px solid var(--line); +} + +.admin-toast { + position: fixed; + bottom: 24px; + left: 50%; + transform: translateX(-50%); + z-index: 100; + max-width: min(90vw, 360px); + text-align: center; + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5); +} + +.admin-card-users { + margin-top: 20px; +} + +.admin-toolbar--grow { + flex: 1; + justify-content: flex-end; + max-width: 320px; +} + +.admin-search { + flex: 1; + min-width: 120px; + border: 1px solid var(--line); + background: var(--bg-2); + color: var(--text); + border-radius: 12px; + padding: 10px 12px; + font: inherit; + font-size: 13px; +} + +.admin-search:focus { + outline: none; + border-color: rgba(61, 207, 154, 0.45); +} + +.admin-users-summary { + margin: 0 0 10px; +} + +.users-table td:first-child { + max-width: 220px; +} + +.user-display { + font-weight: 600; + font-size: 13px; +} + +.user-sub { + font-size: 11px; + color: var(--muted); + margin-top: 2px; +} + +.btn-credit { + white-space: nowrap; +} + +.admin-pagination { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + margin-top: 14px; +} + +.admin-dialog { + border: 0; + padding: 0; + border-radius: 20px; + max-width: min(92vw, 420px); + background: transparent; +} + +.admin-dialog::backdrop { + background: rgba(0, 0, 0, 0.65); +} + +.admin-dialog-inner { + margin: 0; + padding: 22px 20px; + background: var(--card); + border: 1px solid var(--line); + border-radius: 20px; + box-shadow: var(--shadow); + display: grid; + gap: 12px; +} + +.admin-dialog-inner h3 { + margin: 0; + font-size: 18px; +} + +.admin-dialog-inner label { + display: grid; + gap: 6px; + font-size: 13px; + color: var(--muted); +} + +.admin-dialog-inner input, +.admin-dialog-inner textarea { + border: 1px solid var(--line); + background: var(--bg-2); + color: var(--text); + border-radius: 12px; + padding: 11px 12px; + font: inherit; + resize: vertical; +} + +.admin-dialog-actions { + display: flex; + gap: 10px; + justify-content: flex-end; + margin-top: 4px; +} + +.codes-empty { + text-align: center; + padding: 32px 16px !important; + color: var(--muted); +} + +.admin-card-tickets { + margin-top: 18px; +} + +.admin-tickets-layout { + display: grid; + grid-template-columns: minmax(220px, 1fr) minmax(280px, 1.3fr); + gap: 14px; + margin-top: 12px; +} + +.admin-ticket-empty { + border: 1px dashed rgba(61, 207, 154, 0.25); + border-radius: 14px; + padding: 36px 18px; + background: rgba(0, 0, 0, 0.12); + text-align: center; + display: grid; + place-content: center; + gap: 4px; + min-height: 280px; +} + +.admin-ticket-empty.hidden { + display: none; +} + +.admin-tickets-layout:has(#admin-ticket-detail:not(.hidden)) .admin-ticket-empty { + display: none; +} + +@media (max-width: 820px) { + .admin-tickets-layout { + grid-template-columns: 1fr; + } +} + +.admin-tickets-list { + display: grid; + gap: 8px; + max-height: 520px; + overflow: auto; +} + +.admin-ticket-row { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 10px; + width: 100%; + text-align: left; + padding: 12px 14px; + border-radius: 12px; + border: 1px solid var(--line); + background: rgba(0, 0, 0, 0.18); + color: inherit; + cursor: pointer; + font: inherit; +} + +.admin-ticket-row:hover, +.admin-ticket-row.active { + border-color: rgba(61, 207, 154, 0.4); + background: rgba(61, 207, 154, 0.08); +} + +.admin-ticket-row strong { + display: block; + font-size: 13px; + margin-bottom: 4px; +} + +.admin-ticket-detail { + border: 1px solid var(--line); + border-radius: 14px; + padding: 14px; + background: rgba(0, 0, 0, 0.16); + display: grid; + gap: 12px; + min-height: 280px; +} + +.admin-ticket-detail-head { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: flex-start; +} + +.admin-ticket-detail-head h3 { + margin: 0; + font-size: 15px; +} + +.admin-ticket-actions { + display: flex; + gap: 6px; + flex-shrink: 0; +} + +.admin-ticket-messages { + display: grid; + gap: 8px; + max-height: 320px; + overflow: auto; +} + +.admin-ticket-reply { + display: grid; + gap: 10px; +} + +.admin-ticket-reply textarea { + border: 1px solid var(--line); + background: var(--bg-2); + color: var(--text); + border-radius: 12px; + padding: 11px 12px; + font: inherit; + resize: vertical; +} + +.badge--open { + color: var(--accent); + border-color: rgba(61, 207, 154, 0.35); + background: rgba(61, 207, 154, 0.1); +} + +.badge--answered { + color: #7db7ff; + border-color: rgba(125, 183, 255, 0.35); + background: rgba(125, 183, 255, 0.1); +} + +.badge--closed { + color: var(--muted); +} + +.admin-stat--tickets .admin-stat-value { + color: var(--accent); +} + +.admin-card-ai { + margin-top: 18px; +} + +.admin-ai-form .admin-check { + display: flex; + align-items: center; + gap: 10px; + font-size: 14px; + color: var(--text); +} + +.admin-ai-form .admin-check input { + width: 18px; + height: 18px; +} + +.admin-key-row { + display: flex; + gap: 8px; +} + +.admin-key-row input { + flex: 1; +} + +.full-select { + width: 100%; +} + +.compact-head { + margin-bottom: 8px; +} + +.ai-models-pick { + display: grid; + gap: 6px; + max-height: 220px; + overflow: auto; + padding: 8px; + border: 1px solid var(--line); + border-radius: 12px; + background: rgba(0, 0, 0, 0.16); + margin-bottom: 6px; +} + +.ai-model-chip { + display: grid; + grid-template-columns: auto 1fr; + gap: 2px 10px; + align-items: center; + padding: 8px 10px; + border-radius: 10px; + border: 1px solid transparent; + cursor: pointer; + font-size: 13px; +} + +.ai-model-chip code { + grid-column: 2; + font-size: 11px; + color: var(--muted); +} + +.ai-model-chip.active, +.ai-model-chip:has(input:checked) { + border-color: rgba(61, 207, 154, 0.35); + background: rgba(61, 207, 154, 0.08); +} + +.ai-model-chip input { + width: 16px; + height: 16px; +} + +.admin-ai-actions { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.ai-test-out { + margin: 0; + padding: 12px; + border-radius: 12px; + background: var(--bg-2); + border: 1px solid var(--line); + color: var(--muted); + font-size: 12px; + white-space: pre-wrap; + max-height: 240px; + overflow: auto; +} + +.ticket-msg--ai { + border-color: rgba(125, 183, 255, 0.35); + background: rgba(125, 183, 255, 0.1); +} + +/* 3x-ui servers */ +.admin-card-xui { + margin-bottom: 20px; +} + +.xui-servers-list { + display: flex; + flex-direction: column; + gap: 10px; + margin-bottom: 16px; +} + +.xui-server-row { + display: grid; + grid-template-columns: 1fr auto; + gap: 10px 14px; + align-items: start; + padding: 12px 14px; + border-radius: 14px; + border: 1px solid var(--line); + background: rgba(12, 20, 17, 0.55); +} + +.xui-server-main { + min-width: 0; +} + +.xui-server-title { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + font-weight: 600; + font-size: 15px; +} + +.xui-badge { + display: inline-flex; + align-items: center; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + padding: 2px 7px; + border-radius: 999px; + border: 1px solid rgba(61, 207, 154, 0.35); + color: var(--accent); + background: rgba(61, 207, 154, 0.1); +} + +.xui-badge.off { + color: var(--muted); + border-color: var(--line); + background: transparent; +} + +.xui-badge.ok { + color: #3dcf9a; +} + +.xui-badge.bad { + color: #f07178; + border-color: rgba(240, 113, 120, 0.35); + background: rgba(240, 113, 120, 0.08); +} + +.xui-server-meta { + margin-top: 6px; + font-size: 12px; + color: var(--muted); + word-break: break-all; + line-height: 1.45; +} + +.xui-server-actions { + display: flex; + flex-wrap: wrap; + gap: 6px; + justify-content: flex-end; +} + +.xui-form { + margin-top: 8px; + padding-top: 14px; + border-top: 1px solid var(--line); +} + +@media (max-width: 720px) { + .xui-server-row { + grid-template-columns: 1fr; + } + .xui-server-actions { + justify-content: flex-start; + } +} + +.admin-card-awg { + margin-bottom: 20px; +} + +#section-awg { + scroll-margin-top: 72px; +} + +#section-tariffs, +#section-payments { + scroll-margin-top: 72px; +} + +.admin-subhead.full-width { + margin: 8px 0 0; + padding-top: 12px; + border-top: 1px solid var(--line); + font-size: 14px; + font-weight: 650; + color: var(--text); +} + +.admin-sale-toggle.full-width { + grid-column: 1 / -1; +} + +.admin-sale-toggle { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 14px; + margin: 0 0 16px; + padding: 14px 16px; + border-radius: 16px; + border: 1px solid rgba(61, 207, 154, 0.28); + background: + linear-gradient(160deg, rgba(61, 207, 154, 0.1), transparent 55%), + rgba(0, 0, 0, 0.18); +} + +.admin-sale-toggle.is-off { + border-color: var(--line); + background: rgba(0, 0, 0, 0.16); +} + +.admin-sale-toggle-main { + min-width: 0; + flex: 1; +} + +.admin-switch { + display: flex; + align-items: center; + gap: 12px; + cursor: pointer; + user-select: none; +} + +.admin-switch input { + position: absolute; + opacity: 0; + width: 0; + height: 0; +} + +.admin-switch-slider { + position: relative; + width: 46px; + height: 26px; + flex-shrink: 0; + border-radius: 999px; + background: rgba(255, 255, 255, 0.1); + border: 1px solid var(--line); + transition: background 0.2s, border-color 0.2s; +} + +.admin-switch-slider::after { + content: ""; + position: absolute; + top: 2px; + left: 2px; + width: 20px; + height: 20px; + border-radius: 50%; + background: #c8d6cf; + transition: transform 0.2s, background 0.2s; +} + +.admin-switch input:checked + .admin-switch-slider { + background: rgba(61, 207, 154, 0.35); + border-color: rgba(61, 207, 154, 0.55); +} + +.admin-switch input:checked + .admin-switch-slider::after { + transform: translateX(20px); + background: var(--accent); +} + +.admin-switch-text { + display: grid; + gap: 2px; + min-width: 0; +} + +.admin-switch-text strong { + font-size: 14px; +} + +.admin-switch-text small { + color: var(--muted); + font-size: 12px; + line-height: 1.35; +} + +.admin-sale-toggle-price { + display: flex; + align-items: flex-end; + gap: 8px; + flex-wrap: wrap; +} + +.admin-sale-toggle-price label { + display: grid; + gap: 4px; + font-size: 12px; + color: var(--muted); +} + +.admin-sale-toggle-price input { + width: 100px; + border: 1px solid var(--line); + background: var(--bg-2); + color: var(--text); + border-radius: 12px; + padding: 10px 12px; + font: inherit; + font-size: 13px; +} + +.admin-check { + display: flex; + align-items: center; + gap: 10px; + font-size: 14px; + color: var(--text); +} + +.admin-check input { + width: 18px; + height: 18px; +} + +.xui-wg-settings { + margin-top: 18px; + padding-top: 16px; + border-top: 1px solid var(--line); +} + +.xui-wg-settings .admin-card-head { + margin-bottom: 10px; +} + +.xui-wg-settings .admin-form-grid { + margin-top: 4px; +} + +.backup-list { + display: grid; + gap: 8px; + margin: 8px 0 16px; +} + +.backup-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 10px 12px; + border-radius: 12px; + border: 1px solid var(--line); + background: var(--bg-2); +} + +.backup-row-main { + display: grid; + gap: 2px; + min-width: 0; +} + +.backup-name { + font-size: 12px; + word-break: break-all; +} + +.backup-row-actions { + display: flex; + gap: 4px; + flex-shrink: 0; +} + +@media (max-width: 640px) { + .backup-row { + flex-direction: column; + align-items: stretch; + } + + .backup-row-actions { + justify-content: flex-end; + } +} diff --git a/webapp/static/admin.js b/webapp/static/admin.js new file mode 100644 index 0000000..264a9d3 --- /dev/null +++ b/webapp/static/admin.js @@ -0,0 +1,2422 @@ +(() => { + const THEME_KEY = "ef_theme"; + function getTheme() { + return document.documentElement.getAttribute("data-theme") === "light" ? "light" : "dark"; + } + function applyTheme(theme) { + const t = theme === "light" ? "light" : "dark"; + document.documentElement.setAttribute("data-theme", t); + document.documentElement.setAttribute("data-bs-theme", t); + try { + localStorage.setItem(THEME_KEY, t); + } catch (_) {} + } + document.getElementById("btn-theme-toggle")?.addEventListener("click", () => { + applyTheme(getTheme() === "dark" ? "light" : "dark"); + }); + + const TOKEN_KEY = "vpn_admin_token"; + const $ = (sel) => document.querySelector(sel); + + let lastGenerated = ""; + let usersOffset = 0; + const usersLimit = 30; + let usersTotal = 0; + let usersQuery = ""; + let searchDebounce = null; + + function toast(msg) { + const el = $("#admin-toast"); + el.textContent = msg; + el.classList.remove("hidden"); + clearTimeout(toast._t); + toast._t = setTimeout(() => el.classList.add("hidden"), 3200); + } + + async function copyText(text) { + if (!text) return false; + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + const ta = document.createElement("textarea"); + ta.value = text; + ta.style.position = "fixed"; + ta.style.left = "-9999px"; + document.body.appendChild(ta); + ta.select(); + const ok = document.execCommand("copy"); + document.body.removeChild(ta); + return ok; + } + } + + function getToken() { + try { + return localStorage.getItem(TOKEN_KEY) || ""; + } catch { + return ""; + } + } + + function setToken(token) { + try { + if (token) localStorage.setItem(TOKEN_KEY, token); + else localStorage.removeItem(TOKEN_KEY); + } catch { + /* ignore */ + } + } + + async function api(path, options = {}) { + const headers = { + "Content-Type": "application/json", + ...(options.headers || {}), + }; + const token = getToken(); + if (token) headers.Authorization = `Bearer ${token}`; + const res = await fetch(path, { + ...options, + headers, + credentials: "same-origin", + }); + const data = await res.json().catch(() => ({})); + if (!res.ok || data.ok === false) { + throw new Error(data.error || `HTTP ${res.status}`); + } + if (data.token) setToken(data.token); + return data; + } + + function showPanel(authed) { + $("#admin-login").classList.toggle("hidden", authed); + $("#admin-panel").classList.toggle("hidden", !authed); + $("#btn-admin-logout").classList.toggle("hidden", !authed); + if (authed) { + const hour = new Date().getHours(); + const greet = + hour < 6 ? "Доброй ночи" : hour < 12 ? "Доброе утро" : hour < 18 ? "Добрый день" : "Добрый вечер"; + const title = $("#admin-hello-title"); + const sub = $("#admin-hello-sub"); + if (title) title.textContent = `${greet} · панель управления`; + if (sub) sub.textContent = "Выберите раздел слева"; + const fromHash = (location.hash || "").replace(/^#/, ""); + let saved = ""; + try { + saved = localStorage.getItem("admin_section") || ""; + } catch (_) {} + const valid = new Set([ + "stats", + "xui", + "awg", + "tariffs", + "payments", + "smtp", + "codes", + "users", + "growth", + "db", + "tickets", + "ai", + ]); + const initial = valid.has(fromHash) ? fromHash : valid.has(saved) ? saved : "stats"; + jumpToSection(initial); + } + } + + function jumpToSection(key) { + const hints = { + stats: "Обзор статистики и быстрый статус сервиса", + xui: "Серверы 3x-ui · WireGuard", + awg: "AmneziaWG 2.0 · серверы и клиенты", + tariffs: "Тарифные планы и цены", + payments: "Платежи, YooKassa и журнал", + smtp: "Почта для сброса пароля и уведомлений", + codes: "Активационные и промокоды — клик по коду копирует", + users: "Поиск, бан, удаление, баланс и сессии", + growth: "Рефералка, trial и настройки роста", + db: "Экспорт и импорт Postgres · бэкапы", + tickets: "Поддержка и переписка с пользователями", + ai: "OpenRouter · автоответы в тикетах", + }; + const k = key || "stats"; + document.querySelectorAll(".admin-section").forEach((el) => { + el.classList.toggle("is-active", el.dataset.section === k); + }); + document.querySelectorAll(".admin-nav-btn").forEach((btn) => { + btn.classList.toggle("active", btn.dataset.jump === k); + }); + const hint = $("#admin-section-hint"); + if (hint) hint.textContent = hints[k] || ""; + try { + localStorage.setItem("admin_section", k); + if (location.hash !== `#${k}`) { + history.replaceState(null, "", `#${k}`); + } + } catch (_) {} + if (k === "db") { + loadBackups().catch(() => {}); + } + window.scrollTo({ top: 0, behavior: "smooth" }); + } + + let activeAdminTicketId = null; + let aiCatalog = []; + let aiSelectedModels = []; + let aiClearKey = false; + + function renderAiModelSelect(active) { + const sel = $("#ai-model"); + const ids = [...new Set([...(aiSelectedModels || []), active].filter(Boolean))]; + sel.innerHTML = ids + .map((id) => ``) + .join(""); + if (active) sel.value = active; + } + + function renderAiModelsPick(filter = "") { + const box = $("#ai-models-pick"); + const q = filter.trim().toLowerCase(); + const selected = new Set(aiSelectedModels); + const fromCatalog = aiCatalog.length + ? aiCatalog + : aiSelectedModels.map((id) => ({ id, name: id })); + let list = fromCatalog; + if (q) { + list = list.filter( + (m) => + String(m.id).toLowerCase().includes(q) || + String(m.name || "").toLowerCase().includes(q) + ); + } + // всегда показываем выбранные сверху + const selectedItems = aiSelectedModels.map((id) => { + const found = fromCatalog.find((m) => m.id === id); + return found || { id, name: id }; + }); + const rest = list.filter((m) => !selected.has(m.id)).slice(0, 80); + const show = [...selectedItems, ...rest]; + box.innerHTML = ""; + if (!show.length) { + box.innerHTML = '

Нажмите «Каталог» или добавьте модель вручную

'; + return; + } + for (const m of show) { + const label = document.createElement("label"); + label.className = "ai-model-chip"; + if (selected.has(m.id)) label.classList.add("active"); + label.innerHTML = ` + + ${escapeHtml(m.name || m.id)} + ${escapeHtml(m.id)} + `; + box.appendChild(label); + } + } + + async function loadAiSettings() { + const data = await api("/api/admin/ai"); + const ai = data.ai || {}; + $("#ai-enabled").checked = Boolean(ai.enabled); + $("#ai-auto-reply").checked = ai.auto_reply !== false; + $("#ai-enabled-bar")?.classList.toggle("is-off", !ai.enabled); + $("#ai-auto-bar")?.classList.toggle("is-off", !ai.auto_reply); + const enStatus = $("#ai-enabled-status"); + if (enStatus) enStatus.textContent = ai.enabled ? "Включены" : "Выключены"; + $("#ai-api-key").value = ""; + $("#ai-api-key").placeholder = ai.api_key_set + ? ai.api_key_masked || "ключ сохранён" + : "sk-or-v1-…"; + $("#ai-key-hint").textContent = ai.api_key_set + ? `Сохранён: ${ai.api_key_masked}` + : "Ключ не задан — возьмите на openrouter.ai/keys"; + aiSelectedModels = Array.isArray(ai.models) && ai.models.length + ? ai.models + : ai.defaults || []; + aiClearKey = false; + $("#ai-system-prompt").value = ai.system_prompt || ""; + renderAiModelSelect(ai.model || aiSelectedModels[0] || ""); + renderAiModelsPick($("#ai-model-search").value || ""); + } + + async function loadStats() { + const data = await api("/api/admin/stats"); + const s = data.stats || {}; + const t = data.tickets || {}; + $("#admin-stats").innerHTML = ` +
+
+ Всего кодов + +
+ ${s.total || 0} +

Сгенерировано за всё время

+
+
+
+ Свободно + +
+ ${s.unused || 0} +

Можно выдать пользователям

+
+
+
+ Использовано + +
+ ${s.used || 0} +

Уже активированы

+
+
+
+ Тикеты открыты + +
+ ${t.open || 0} +

Ждут ответа поддержки

+
+ `; + } + + async function loadTickets() { + const status = $("#filter-tickets").value; + const data = await api( + `/api/admin/tickets?status=${encodeURIComponent(status)}&limit=50` + ); + const st = data.stats || {}; + $("#tickets-stats-line").textContent = + `Открыто ${st.open || 0} · с ответом ${st.answered || 0} · закрыто ${st.closed || 0}`; + const box = $("#admin-tickets-list"); + box.innerHTML = ""; + for (const t of data.tickets || []) { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "admin-ticket-row"; + if (activeAdminTicketId === t.id) btn.classList.add("active"); + btn.dataset.id = t.id; + const user = t.user?.display_name || `ID ${t.telegram_id}`; + btn.innerHTML = ` +
+ #${t.id} · ${escapeHtml(t.subject)} +
${escapeHtml(user)} · ${fmtDate(t.updated_at)}
+
+ ${escapeHtml(t.status_label || t.status)} + `; + box.appendChild(btn); + } + if (!box.children.length) { + box.innerHTML = ` +
+

Тикетов пока нет

+

Когда пользователи напишут — обращения появятся здесь

+
`; + } + } + + function renderAdminTicket(data) { + const t = data.ticket || {}; + activeAdminTicketId = t.id; + const detail = $("#admin-ticket-detail"); + const empty = $("#admin-ticket-empty"); + detail.classList.remove("hidden"); + empty?.classList.add("hidden"); + const user = t.user?.display_name || `ID ${t.telegram_id}`; + $("#admin-ticket-title").textContent = `#${t.id} · ${t.subject || ""}`; + $("#admin-ticket-meta").textContent = + `${user} · ID ${t.telegram_id} · ${t.status_label || t.status}`; + const closed = t.status === "closed"; + $("#btn-admin-ticket-close").classList.toggle("hidden", closed); + $("#btn-admin-ticket-reopen").classList.toggle("hidden", !closed); + $("#form-admin-ticket-reply").classList.toggle("hidden", closed); + const box = $("#admin-ticket-messages"); + box.innerHTML = ""; + (data.messages || []).forEach((m) => { + const el = document.createElement("div"); + const who = m.author_role === "admin" ? "Админ" : m.author_role === "ai" ? "ИИ" : "Пользователь"; + el.className = `ticket-msg ticket-msg--${m.author_role === "admin" ? "admin" : m.author_role === "ai" ? "ai" : "user"}`; + el.innerHTML = ` +
${who} · ${fmtDate(m.created_at)}
+
${escapeHtml(m.body)}
+ `; + box.appendChild(el); + }); + box.scrollTop = box.scrollHeight; + document.querySelectorAll(".admin-ticket-row").forEach((row) => { + row.classList.toggle("active", Number(row.dataset.id) === t.id); + }); + } + + async function openAdminTicket(id) { + const data = await api(`/api/admin/tickets/${id}`); + renderAdminTicket(data); + } + + function fmtDate(iso) { + if (!iso) return "—"; + try { + return new Date(iso).toLocaleString("ru-RU", { + day: "2-digit", + month: "short", + hour: "2-digit", + minute: "2-digit", + }); + } catch { + return iso; + } + } + + async function loadCodes() { + const status = $("#filter-status").value; + const data = await api(`/api/admin/codes?status=${encodeURIComponent(status)}&limit=100`); + const tbody = $("#codes-tbody"); + tbody.innerHTML = ""; + for (const row of data.codes || []) { + const tr = document.createElement("tr"); + const used = Boolean(row.used_at); + const badge = used + ? `Использован` + : `Свободен`; + const sub = used && row.used_by ? `
ID ${row.used_by}
` : ""; + tr.innerHTML = ` + ${row.code} + ${Math.round(row.amount)} ₽ + ${fmtDate(row.created_at)} + ${badge}${sub} + `; + tbody.appendChild(tr); + } + if (!tbody.children.length) { + tbody.innerHTML = + 'Кодов пока нет — создайте первую партию слева'; + } + } + + function bindCodeCopy() { + $("#codes-tbody").addEventListener("click", async (e) => { + const cell = e.target.closest(".code-cell"); + if (!cell) return; + const code = cell.dataset.code || ""; + if (await copyText(code)) toast("Код скопирован"); + }); + } + + async function loadUsers() { + const q = usersQuery.trim(); + const data = await api( + `/api/admin/users?q=${encodeURIComponent(q)}&limit=${usersLimit}&offset=${usersOffset}` + ); + usersTotal = data.total || 0; + const tbody = $("#users-tbody"); + tbody.innerHTML = ""; + for (const u of data.users || []) { + const tr = document.createElement("tr"); + const sub = [u.login && `login: ${u.login}`, u.username && `@${u.username}`] + .filter(Boolean) + .join(" · "); + tr.innerHTML = ` + +
${escapeHtml(u.display_name || "—")}
+ ${sub ? `
${escapeHtml(sub)}
` : ""} + + ${u.telegram_id} + ${Math.round(u.balance)} ₽${u.banned ? ' BAN' : ""} + + + + + + `; + tbody.appendChild(tr); + } + if (!tbody.children.length) { + tbody.innerHTML = + 'Никого не нашли — попробуйте другой запрос'; + } + const from = usersTotal ? usersOffset + 1 : 0; + const to = Math.min(usersOffset + usersLimit, usersTotal); + $("#users-summary").textContent = usersTotal + ? `Показано ${from}–${to} из ${usersTotal}` + : "Нет пользователей"; + $("#users-page-label").textContent = usersTotal + ? `${Math.floor(usersOffset / usersLimit) + 1} / ${Math.max(1, Math.ceil(usersTotal / usersLimit))}` + : "—"; + $("#btn-users-prev").disabled = usersOffset <= 0; + $("#btn-users-next").disabled = usersOffset + usersLimit >= usersTotal; + } + + function escapeHtml(s) { + return String(s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + + function escapeAttr(s) { + return escapeHtml(s).replace(/'/g, "'"); + } + + function openCreditDialog(telegramId, name) { + $("#credit-telegram-id").value = String(telegramId); + $("#credit-user-label").textContent = `${name} · ID ${telegramId}`; + const form = $("#form-credit"); + form.reset(); + $("#credit-telegram-id").value = String(telegramId); + $("#dialog-credit").showModal(); + } + + // ─── 3x-ui servers ───────────────────────────────────────────────────── + + let xuiServers = []; + let xuiInboundOptions = []; + + function updateXuiSubPreview() { + const base = ($("#xui-subscription-url").value || "").trim().replace(/\/+$/, ""); + const el = $("#xui-sub-preview"); + el.textContent = base + ? `Итоговая ссылка: ${base}/exampleSubId` + : "Итоговая ссылка: base/{subId}"; + } + + function fillXuiInboundSelect(inbounds, selectedId) { + const sel = $("#xui-inbound-id"); + xuiInboundOptions = inbounds || []; + const opts = [ + '', + ...xuiInboundOptions.map((i) => { + const label = `#${i.id} ${i.remark || i.protocol} · :${i.port} · clients ${i.clients_count || 0}`; + return ``; + }), + ]; + sel.innerHTML = opts.join(""); + if (selectedId != null && selectedId !== "") { + sel.value = String(selectedId); + } + } + + function syncXuiInboundRemark() { + const id = $("#xui-inbound-id").value; + const found = xuiInboundOptions.find((i) => String(i.id) === String(id)); + $("#xui-inbound-remark").value = found ? found.remark || "" : ""; + } + + function hideXuiForm() { + const form = $("#form-xui"); + form.classList.add("hidden"); + form.reset(); + $("#xui-id").value = ""; + $("#xui-password").required = false; + $("#xui-password-hint").textContent = "Только если нет API Token"; + $("#xui-api-key-hint").textContent = "Рекомендуется для 3.5.0 (логин не нужен)"; + $("#xui-test-out").classList.add("hidden"); + fillXuiInboundSelect([], ""); + } + + function showXuiForm(server) { + const form = $("#form-xui"); + form.classList.remove("hidden"); + $("#xui-test-out").classList.add("hidden"); + if (server) { + $("#xui-id").value = String(server.id); + $("#xui-name").value = server.name || ""; + $("#xui-api-url").value = server.api_url || ""; + $("#xui-username").value = server.username || ""; + $("#xui-password").value = ""; + $("#xui-api-key").value = ""; + $("#xui-password").required = false; + $("#xui-password-hint").textContent = server.password_set + ? "Оставьте пустым, чтобы не менять" + : "Только если нет API Token"; + $("#xui-api-key-hint").textContent = server.api_key_set + ? "Токен сохранён — оставьте пустым, чтобы не менять" + : "Рекомендуется для 3.5.0 (логин не нужен)"; + $("#xui-subscription-url").value = server.subscription_url || ""; + $("#xui-enabled").checked = !!server.enabled; + $("#xui-verify-ssl").checked = server.verify_ssl !== false; + const opts = []; + if (server.inbound_id != null) { + opts.push({ + id: server.inbound_id, + remark: server.inbound_remark || "WireGuard", + protocol: "wireguard", + port: "?", + clients_count: "?", + }); + } + fillXuiInboundSelect(opts, server.inbound_id); + $("#xui-inbound-remark").value = server.inbound_remark || ""; + } else { + form.reset(); + $("#xui-id").value = ""; + $("#xui-enabled").checked = true; + $("#xui-verify-ssl").checked = true; + $("#xui-password").required = false; + $("#xui-password-hint").textContent = "Только если нет API Token"; + $("#xui-api-key-hint").textContent = "Рекомендуется для 3.5.0 (логин не нужен)"; + fillXuiInboundSelect([], ""); + } + updateXuiSubPreview(); + form.scrollIntoView({ behavior: "smooth", block: "nearest" }); + } + + function renderXuiServers() { + const box = $("#xui-servers-list"); + if (!xuiServers.length) { + box.innerHTML = ` +
+

Серверов пока нет

+

Добавьте панель 3x-ui 3.5.0 с WireGuard inbound — кнопка «+ Сервер»

+
`; + return; + } + box.innerHTML = xuiServers + .map((s) => { + const statusBadge = + s.last_check_ok === true + ? 'online' + : s.last_check_ok === false + ? 'fail' + : ""; + const enBadge = s.enabled + ? 'on' + : 'off'; + const authBadge = s.api_key_set + ? 'api key' + : 'login'; + const inbound = + s.inbound_id != null + ? `WG #${s.inbound_id}${s.inbound_remark ? " · " + s.inbound_remark : ""}` + : "inbound не выбран"; + const sub = s.subscription_url || "— URL подписки не задан —"; + const check = s.last_check_msg + ? `
Проверка: ${escapeHtml(s.last_check_msg)}` + : ""; + return ` +
+
+
+ ${escapeHtml(s.name)} + ${enBadge} + ${authBadge} + ${statusBadge} + 3.5.0 +
+
+ API: ${escapeHtml(s.api_url)}
+ ${escapeHtml(inbound)} · sub: ${escapeHtml(sub)}${check} +
+
+
+ + + +
+
`; + }) + .join(""); + } + + async function loadXuiServers() { + const data = await api("/api/admin/xui/servers"); + xuiServers = data.servers || []; + renderXuiServers(); + } + + async function loadWgSettings() { + const data = await api("/api/admin/xui/wg-settings"); + applyWgSaleUi(data); + } + + function applyWgSaleUi(data) { + const enabled = !!data?.enabled; + const price = data?.price ?? 0; + const priceLabel = data?.price_label || (price > 0 ? `${price} ₽` : "бесплатно"); + const toggle = $("#wg-purchase-enabled"); + const priceInput = $("#wg-config-price"); + const status = $("#wg-sale-status"); + const bar = $("#wg-sale-bar"); + if (toggle) toggle.checked = enabled; + if (priceInput) priceInput.value = price; + if (status) { + status.textContent = enabled + ? `Включена · ${priceLabel} · до ${data?.max_configs || 10} конфигов` + : "Выключена — пользователи не видят блок"; + } + bar?.classList.toggle("is-off", !enabled); + } + + async function saveWgSettings() { + const data = await api("/api/admin/xui/wg-settings", { + method: "POST", + body: JSON.stringify({ + enabled: $("#wg-purchase-enabled")?.checked || false, + price: Number($("#wg-config-price")?.value || 0), + }), + }); + applyWgSaleUi(data); + toast( + data.enabled + ? `Покупка WG включена · ${data.price_label}` + : "Покупка WG выключена" + ); + return data; + } + + // ─── AmneziaWG 2.0 ───────────────────────────────────────────────────── + + let awgServers = []; + + function hideAwgForm() { + const form = $("#form-awg"); + form.classList.add("hidden"); + form.reset(); + $("#awg-id").value = ""; + $("#awg-api-token-hint").textContent = "Создайте токен в панели Amnezia Web Panel"; + $("#awg-test-out").classList.add("hidden"); + $("#awg-discover-box").classList.add("hidden"); + $("#awg-discover-box").innerHTML = ""; + } + + function showAwgForm(server) { + const form = $("#form-awg"); + form.classList.remove("hidden"); + $("#awg-test-out").classList.add("hidden"); + $("#awg-discover-box").classList.add("hidden"); + if (server) { + $("#awg-id").value = String(server.id); + $("#awg-name").value = server.name || ""; + $("#awg-api-url").value = server.api_url || "https://panel.example.com"; + $("#awg-api-token").value = ""; + $("#awg-api-token-hint").textContent = server.api_token_set + ? "Токен сохранён — оставьте пустым, чтобы не менять" + : "Создайте токен в панели Amnezia Web Panel"; + $("#awg-panel-server-id").value = server.panel_server_id ?? 0; + $("#awg-protocol").value = server.protocol || "awg2"; + $("#awg-enabled").checked = !!server.enabled; + $("#awg-verify-ssl").checked = server.verify_ssl !== false; + } else { + form.reset(); + $("#awg-id").value = ""; + $("#awg-api-url").value = "https://panel.example.com"; + $("#awg-protocol").value = "awg2"; + $("#awg-enabled").checked = true; + $("#awg-verify-ssl").checked = true; + $("#awg-api-token-hint").textContent = "Создайте токен в панели Amnezia Web Panel"; + } + form.scrollIntoView({ behavior: "smooth", block: "nearest" }); + } + + function renderAwgServers() { + const box = $("#awg-servers-list"); + if (!box) return; + if (!awgServers.length) { + box.innerHTML = ` +
+

Серверов AWG пока нет

+

Добавьте панель panel5 · API Token · выберите ноду с awg2

+
`; + return; + } + box.innerHTML = awgServers + .map((s) => { + const statusBadge = + s.last_check_ok === true + ? 'online' + : s.last_check_ok === false + ? 'fail' + : ""; + const enBadge = s.enabled + ? 'on' + : 'off'; + const check = s.last_check_msg + ? `
Проверка: ${escapeHtml(s.last_check_msg)}` + : ""; + return ` +
+
+
+ ${escapeHtml(s.name)} + ${enBadge} + ${escapeHtml(s.protocol || "awg2")} + ${statusBadge} +
+
+ API: ${escapeHtml(s.api_url)}
+ panel server #${s.panel_server_id}${check} +
+
+
+ + + +
+
`; + }) + .join(""); + } + + async function loadAwgServers() { + const data = await api("/api/admin/awg/servers"); + awgServers = data.servers || []; + renderAwgServers(); + } + + async function loadAwgSettings() { + const data = await api("/api/admin/awg/settings"); + applyAwgSaleUi(data); + } + + function applyAwgSaleUi(data) { + const enabled = !!data?.enabled; + const price = data?.price ?? 0; + const priceLabel = data?.price_label || (price > 0 ? `${price} ₽` : "бесплатно"); + const toggle = $("#awg-purchase-enabled"); + const priceInput = $("#awg-config-price"); + const status = $("#awg-sale-status"); + const bar = $("#awg-sale-bar"); + if (toggle) toggle.checked = enabled; + if (priceInput) priceInput.value = price; + if (status) { + status.textContent = enabled + ? `Включена · ${priceLabel} · до ${data?.max_configs || 10} конфигов` + : "Выключена — пользователи не видят блок"; + } + bar?.classList.toggle("is-off", !enabled); + } + + async function saveAwgSettings() { + const data = await api("/api/admin/awg/settings", { + method: "POST", + body: JSON.stringify({ + enabled: $("#awg-purchase-enabled")?.checked || false, + price: Number($("#awg-config-price")?.value || 0), + }), + }); + applyAwgSaleUi(data); + toast( + data.enabled + ? `Покупка AWG включена · ${data.price_label}` + : "Покупка AWG выключена" + ); + return data; + } + + // ─── Тарифы ──────────────────────────────────────────────────────────── + + let adminTariffs = []; + + function hideTariffForm() { + const form = $("#form-tariff"); + if (!form) return; + form.classList.add("hidden"); + form.reset(); + $("#tariff-edit-id").value = ""; + $("#tariff-id").disabled = false; + } + + function showTariffForm(tariff) { + const form = $("#form-tariff"); + if (!form) return; + form.classList.remove("hidden"); + if (tariff) { + $("#tariff-edit-id").value = tariff.id; + $("#tariff-id").value = tariff.id; + $("#tariff-id").disabled = true; + $("#tariff-title").value = tariff.title || ""; + $("#tariff-price").value = tariff.price ?? 0; + $("#tariff-days").value = tariff.days ?? 30; + $("#tariff-traffic").value = tariff.traffic_gb ?? 1000; + $("#tariff-devices").value = tariff.device_limit ?? 10; + $("#tariff-squads").value = (tariff.squad_uuids || []).join(", "); + $("#tariff-description").value = tariff.description || ""; + } else { + form.reset(); + $("#tariff-edit-id").value = ""; + $("#tariff-id").disabled = false; + const first = adminTariffs[0]; + if (first?.squad_uuids?.length) { + $("#tariff-squads").value = first.squad_uuids.join(", "); + } + $("#tariff-traffic").value = 1000; + $("#tariff-devices").value = 10; + $("#tariff-days").value = 30; + $("#tariff-price").value = 199; + } + form.scrollIntoView({ behavior: "smooth", block: "nearest" }); + } + + function renderAdminTariffs() { + const box = $("#tariffs-admin-list"); + if (!box) return; + if (!adminTariffs.length) { + box.innerHTML = ` +
+

Тарифов пока нет

+

Добавьте первый тариф для продажи подписок

+
`; + return; + } + box.innerHTML = adminTariffs + .map( + (t) => ` +
+
+
+ ${escapeHtml(t.title)} + ${escapeHtml(t.price_label || `${t.price} ₽`)} + ${t.days} дн. +
+
+ ID: ${escapeHtml(t.id)} · + ${t.traffic_gb || 0} ГБ · ${t.device_limit || 0} устр. + ${t.description ? `
${escapeHtml(t.description)}` : ""} +
+
+
+ + +
+
` + ) + .join(""); + } + + async function loadAdminTariffs() { + const data = await api("/api/admin/tariffs"); + adminTariffs = data.tariffs || []; + renderAdminTariffs(); + } + + function collectTariffBody() { + return { + id: ($("#tariff-id").value || "").trim(), + title: ($("#tariff-title").value || "").trim(), + price: Number($("#tariff-price").value || 0), + days: Number($("#tariff-days").value || 0), + traffic_gb: Number($("#tariff-traffic").value || 0), + device_limit: Number($("#tariff-devices").value || 1), + squad_uuids: ($("#tariff-squads").value || "") + .split(/[,;]+/) + .map((x) => x.trim()) + .filter(Boolean), + description: ($("#tariff-description").value || "").trim(), + }; + } + + // ─── Платежи / .env ──────────────────────────────────────────────────── + + const paymentSecretClears = { + "pay-crypto-token": false, + "pay-heleket-key": false, + "pay-lava-secret": false, + "pay-lava-additional": false, + "pay-anypay-key": false, + "pay-digi-key": false, + "smtp-password": false, + }; + + function applyPaymentsUi(p) { + if (!p) return; + const hint = $("#payments-env-hint"); + if (hint) { + hint.textContent = p.env_writable + ? `Файл: ${p.env_path} · запись доступна` + : `Файл: ${p.env_path || "—"} · запись недоступна (проверьте mount .env:rw)`; + } + + $("#pay-crypto-token").value = ""; + $("#pay-crypto-token").placeholder = p.crypto?.token_set + ? p.crypto.token_hint || "сохранён" + : "токен приложения"; + $("#pay-crypto-hint").textContent = p.crypto?.token_set + ? `Сохранён: ${p.crypto.token_hint}` + : "не задан"; + $("#pay-crypto-testnet").checked = !!p.crypto?.testnet; + $("#pay-crypto-testnet-bar")?.classList.toggle("is-off", !p.crypto?.testnet); + + $("#pay-heleket-merchant").value = p.heleket?.merchant_id || ""; + $("#pay-heleket-currency").value = p.heleket?.currency || "RUB"; + $("#pay-heleket-callback").value = p.heleket?.callback_url || ""; + $("#pay-heleket-key").value = ""; + $("#pay-heleket-key").placeholder = p.heleket?.api_key_set + ? p.heleket.api_key_hint || "сохранён" + : "API key"; + $("#pay-heleket-hint").textContent = p.heleket?.api_key_set + ? `Сохранён: ${p.heleket.api_key_hint}` + : "не задан"; + + $("#pay-lava-shop").value = p.lava?.shop_id || ""; + $("#pay-lava-expire").value = p.lava?.expire_minutes ?? 60; + $("#pay-lava-hook").value = p.lava?.hook_url || ""; + $("#pay-lava-success").value = p.lava?.success_url || ""; + $("#pay-lava-fail").value = p.lava?.fail_url || ""; + $("#pay-lava-secret").value = ""; + $("#pay-lava-secret").placeholder = p.lava?.secret_key_set + ? p.lava.secret_key_hint || "сохранён" + : "secret key"; + $("#pay-lava-secret-hint").textContent = p.lava?.secret_key_set + ? `Сохранён: ${p.lava.secret_key_hint}` + : "не задан"; + $("#pay-lava-additional").value = ""; + $("#pay-lava-additional").placeholder = p.lava?.additional_key_set + ? p.lava.additional_key_hint || "сохранён" + : "additional key"; + $("#pay-lava-additional-hint").textContent = p.lava?.additional_key_set + ? `Сохранён: ${p.lava.additional_key_hint}` + : "не задан"; + + $("#pay-anypay-api-id").value = p.anypay?.api_id || ""; + $("#pay-anypay-project").value = p.anypay?.project_id || ""; + $("#pay-anypay-currency").value = p.anypay?.currency || "RUB"; + $("#pay-anypay-method").value = p.anypay?.method || ""; + $("#pay-anypay-success").value = p.anypay?.success_url || ""; + $("#pay-anypay-fail").value = p.anypay?.fail_url || ""; + $("#pay-anypay-email").value = p.anypay?.email || ""; + $("#pay-anypay-key").value = ""; + $("#pay-anypay-key").placeholder = p.anypay?.api_key_set + ? p.anypay.api_key_hint || "сохранён" + : "API key"; + $("#pay-anypay-hint").textContent = p.anypay?.api_key_set + ? `Сохранён: ${p.anypay.api_key_hint}` + : "не задан"; + $("#pay-anypay-check-ip").checked = !!p.anypay?.check_ip; + $("#pay-anypay-ip-bar")?.classList.toggle("is-off", !p.anypay?.check_ip); + const notify = $("#pay-anypay-notify"); + if (notify && p.anypay?.notify_url) notify.textContent = p.anypay.notify_url; + + $("#pay-digi-seller").value = p.digiseller?.seller_id || ""; + $("#pay-digi-credit").value = p.digiseller?.credit_from || "cnt_goods"; + $("#pay-digi-products").value = p.digiseller?.product_ids || ""; + $("#pay-digi-payurl").value = p.digiseller?.pay_url || ""; + $("#pay-digi-callback").value = p.digiseller?.callback_url || ""; + $("#pay-digi-key").value = ""; + $("#pay-digi-key").placeholder = p.digiseller?.api_key_set + ? p.digiseller.api_key_hint || "сохранён" + : "API key"; + $("#pay-digi-hint").textContent = p.digiseller?.api_key_set + ? `Сохранён: ${p.digiseller.api_key_hint}` + : "не задан"; + + $("#pay-stars-enabled").checked = p.stars?.enabled !== false; + $("#pay-stars-rate").value = p.stars?.rub_per_star ?? 1.5; + $("#pay-stars-bar")?.classList.toggle("is-off", p.stars?.enabled === false); + $("#pay-topup-url").value = p.topup_code_pay_url || ""; + + Object.keys(paymentSecretClears).forEach((k) => { + paymentSecretClears[k] = false; + }); + } + + async function loadPayments() { + const data = await api("/api/admin/payments"); + applyPaymentsUi(data.payments || {}); + } + + function applySmtpUi(s) { + if (!s) return; + const hint = $("#smtp-env-hint"); + if (hint) { + const status = s.configured ? "настроен" : "не настроен"; + hint.textContent = s.env_writable + ? `Файл: ${s.env_path} · запись доступна · SMTP ${status}` + : `Файл: ${s.env_path || "—"} · запись недоступна · SMTP ${status}`; + } + $("#smtp-host").value = s.host || ""; + $("#smtp-port").value = s.port ?? 587; + $("#smtp-user").value = s.user || ""; + $("#smtp-from").value = s.from_email || ""; + $("#smtp-tls").checked = s.tls !== false; + $("#smtp-ssl").checked = !!s.ssl; + $("#smtp-tls-bar")?.classList.toggle("is-off", s.tls === false); + $("#smtp-ssl-bar")?.classList.toggle("is-off", !s.ssl); + $("#smtp-password").value = ""; + $("#smtp-password").placeholder = s.password_set + ? s.password_hint || "сохранён" + : "пароль SMTP"; + $("#smtp-password-hint").textContent = s.password_set + ? `Сохранён: ${s.password_hint}` + : "не задан"; + paymentSecretClears["smtp-password"] = false; + } + + async function loadSmtp() { + const data = await api("/api/admin/smtp"); + applySmtpUi(data.smtp || {}); + } + + function secretPayload(inputId, fieldName, body) { + if (paymentSecretClears[inputId]) { + body[fieldName] = "__clear__"; + return; + } + const v = ($(`#${inputId}`)?.value || "").trim(); + if (v) body[fieldName] = v; + } + + function collectPaymentsBody() { + const body = { + crypto_pay_testnet: !!$("#pay-crypto-testnet")?.checked, + heleket_merchant_id: ($("#pay-heleket-merchant").value || "").trim(), + heleket_currency: ($("#pay-heleket-currency").value || "").trim(), + heleket_callback_url: ($("#pay-heleket-callback").value || "").trim(), + lava_shop_id: ($("#pay-lava-shop").value || "").trim(), + lava_expire_minutes: Number($("#pay-lava-expire").value || 60), + lava_hook_url: ($("#pay-lava-hook").value || "").trim(), + lava_success_url: ($("#pay-lava-success").value || "").trim(), + lava_fail_url: ($("#pay-lava-fail").value || "").trim(), + anypay_api_id: ($("#pay-anypay-api-id").value || "").trim(), + anypay_project_id: ($("#pay-anypay-project").value || "").trim(), + anypay_currency: ($("#pay-anypay-currency").value || "").trim(), + anypay_method: ($("#pay-anypay-method").value || "").trim(), + anypay_success_url: ($("#pay-anypay-success").value || "").trim(), + anypay_fail_url: ($("#pay-anypay-fail").value || "").trim(), + anypay_email: ($("#pay-anypay-email").value || "").trim(), + anypay_check_ip: !!$("#pay-anypay-check-ip")?.checked, + digiseller_seller_id: ($("#pay-digi-seller").value || "").trim(), + digiseller_credit_from: ($("#pay-digi-credit").value || "cnt_goods").trim(), + digiseller_product_ids: ($("#pay-digi-products").value || "").trim(), + digiseller_pay_url: ($("#pay-digi-payurl").value || "").trim(), + digiseller_callback_url: ($("#pay-digi-callback").value || "").trim(), + stars_enabled: !!$("#pay-stars-enabled")?.checked, + stars_rub_per_star: Number($("#pay-stars-rate").value || 1.5), + topup_code_pay_url: ($("#pay-topup-url").value || "").trim(), + }; + secretPayload("pay-crypto-token", "crypto_bot_token", body); + secretPayload("pay-heleket-key", "heleket_api_key", body); + secretPayload("pay-lava-secret", "lava_secret_key", body); + secretPayload("pay-lava-additional", "lava_additional_key", body); + secretPayload("pay-anypay-key", "anypay_api_key", body); + secretPayload("pay-digi-key", "digiseller_api_key", body); + return body; + } + + function collectAwgFormBody(requireToken) { + const id = ($("#awg-id").value || "").trim(); + const apiToken = ($("#awg-api-token").value || "").trim(); + const body = { + name: ($("#awg-name").value || "").trim(), + api_url: ($("#awg-api-url").value || "").trim(), + panel_server_id: Number($("#awg-panel-server-id").value || 0), + protocol: ($("#awg-protocol").value || "awg2").trim(), + enabled: $("#awg-enabled").checked, + verify_ssl: $("#awg-verify-ssl").checked, + }; + if (apiToken) body.api_token = apiToken; + if (!body.name || !body.api_url) { + throw new Error("Заполните название и API URL"); + } + if (requireToken && !apiToken) { + throw new Error("Укажите API Token"); + } + return { id, body }; + } + + function collectXuiFormBody(requireCreds) { + const id = ($("#xui-id").value || "").trim(); + const password = ($("#xui-password").value || "").trim(); + const apiKey = ($("#xui-api-key").value || "").trim(); + const username = ($("#xui-username").value || "").trim(); + const body = { + name: ($("#xui-name").value || "").trim(), + api_url: ($("#xui-api-url").value || "").trim(), + username, + subscription_url: ($("#xui-subscription-url").value || "").trim(), + inbound_id: $("#xui-inbound-id").value || null, + inbound_remark: ($("#xui-inbound-remark").value || "").trim(), + enabled: $("#xui-enabled").checked, + verify_ssl: $("#xui-verify-ssl").checked, + }; + if (password) body.password = password; + if (apiKey) body.api_key = apiKey; + if (!body.name || !body.api_url) { + throw new Error("Заполните название и API URL"); + } + if (requireCreds) { + const hasToken = !!apiKey; + const hasLogin = !!(username && password); + if (!hasToken && !hasLogin) { + throw new Error("Укажите API Token или логин+пароль"); + } + } + return { id, body }; + } + + async function bootstrap() { + try { + const me = await api("/api/admin/me"); + if (!me.enabled) { + const err = $("#admin-login-error"); + err.textContent = "Админка выключена — задайте WEB_ADMIN_LOGIN и WEB_ADMIN_PASSWORD в .env"; + err.classList.remove("hidden"); + return; + } + if (me.authenticated) { + showPanel(true); + await loadStats(); + await loadCodes(); + await loadUsers(); + await loadTickets(); + await loadAiSettings(); + await loadXuiServers().catch(() => { + $("#xui-servers-list").innerHTML = + '

Не удалось загрузить серверы 3x-ui

'; + }); + await loadWgSettings().catch(() => {}); + await loadAwgServers().catch(() => { + $("#awg-servers-list").innerHTML = + '

Не удалось загрузить серверы AWG

'; + }); + await loadAwgSettings().catch(() => {}); + await loadAdminTariffs().catch(() => { + $("#tariffs-admin-list").innerHTML = + '

Не удалось загрузить тарифы

'; + }); + await loadPayments().catch(() => {}); + await loadSmtp().catch(() => {}); + await loadGrowth().catch(() => {}); + await loadJournal().catch(() => {}); + await loadBackups().catch(() => {}); + } else { + showPanel(false); + } + } catch { + showPanel(false); + } + } + + $("#form-admin-login").addEventListener("submit", async (e) => { + e.preventDefault(); + const fd = new FormData(e.target); + const err = $("#admin-login-error"); + err.classList.add("hidden"); + try { + await api("/api/admin/login", { + method: "POST", + body: JSON.stringify({ + login: fd.get("login"), + password: fd.get("password"), + }), + }); + showPanel(true); + await loadStats(); + await loadCodes(); + await loadUsers(); + await loadTickets(); + await loadAiSettings(); + await loadXuiServers().catch(() => {}); + await loadWgSettings().catch(() => {}); + await loadAwgServers().catch(() => {}); + await loadAwgSettings().catch(() => {}); + await loadAdminTariffs().catch(() => {}); + await loadPayments().catch(() => {}); + await loadSmtp().catch(() => {}); + toast("Добро пожаловать"); + } catch (ex) { + err.textContent = ex.message || "Ошибка входа"; + err.classList.remove("hidden"); + } + }); + + $("#btn-admin-logout").addEventListener("click", async () => { + try { + await api("/api/admin/logout", { method: "POST", body: "{}" }); + } catch { + /* ignore */ + } + setToken(""); + showPanel(false); + $("#generated-wrap").classList.add("hidden"); + }); + + $("#form-generate").addEventListener("submit", async (e) => { + e.preventDefault(); + const fd = new FormData(e.target); + try { + const data = await api("/api/admin/codes", { + method: "POST", + body: JSON.stringify({ + amount: Number(fd.get("amount")), + count: Number(fd.get("count")), + note: fd.get("note") || "web_admin", + }), + }); + const codes = data.codes || []; + lastGenerated = codes.join("\n"); + const box = $("#generated-codes"); + box.textContent = lastGenerated; + $("#generated-wrap").classList.remove("hidden"); + toast(`Создано кодов: ${data.inserted || 0}`); + await loadStats(); + await loadCodes(); + } catch (ex) { + toast(ex.message || "Ошибка"); + } + }); + + $("#btn-copy-codes").addEventListener("click", async () => { + if (await copyText(lastGenerated)) toast("Все коды скопированы"); + else toast("Нечего копировать"); + }); + + $("#btn-refresh-codes").addEventListener("click", () => + loadCodes().catch((ex) => toast(ex.message)) + ); + $("#filter-status").addEventListener("change", () => + loadCodes().catch((ex) => toast(ex.message)) + ); + + bindCodeCopy(); + + $("#users-tbody").addEventListener("click", async (e) => { + const banBtn = e.target.closest("[data-ban-id]"); + if (banBtn) { + try { + await api("/api/admin/users/ban", { + method: "POST", + body: JSON.stringify({ + telegram_id: Number(banBtn.dataset.banId), + banned: banBtn.dataset.ban === "1", + }), + }); + toast(banBtn.dataset.ban === "1" ? "Пользователь забанен" : "Бан снят"); + await loadUsers(); + } catch (ex) { + toast(ex.message || "Ошибка"); + } + return; + } + const delBtn = e.target.closest("[data-del-user]"); + if (delBtn) { + const tid = Number(delBtn.dataset.delUser); + const name = delBtn.dataset.delName || tid; + const ok = confirm( + `Удалить пользователя «${name}» (ID ${tid}) навсегда?\n\nБудут удалены баланс, сессии, тикеты, конфиги WG/AWG и история покупок в боте. Это нельзя отменить.` + ); + if (!ok) return; + const ok2 = confirm(`Точно удалить ID ${tid}?`); + if (!ok2) return; + try { + const data = await api("/api/admin/users/delete", { + method: "POST", + body: JSON.stringify({ telegram_id: tid, confirm: true }), + }); + toast(data.message || "Удалён"); + await loadUsers(); + } catch (ex) { + toast(ex.message || "Ошибка удаления"); + } + return; + } + const btn = e.target.closest("[data-credit-id]"); + if (!btn) return; + openCreditDialog(btn.dataset.creditId, btn.dataset.creditName || btn.dataset.creditId); + }); + + async function loadGrowth() { + try { + const data = await api("/api/admin/growth-settings"); + const s = data.settings || {}; + $("#growth-ref-referrer").value = s.referral_bonus_referrer || "50"; + $("#growth-ref-referee").value = s.referral_bonus_referee || "50"; + $("#growth-trial-days").value = s.trial_days || "3"; + $("#growth-trial-tariff").value = s.trial_tariff_id || ""; + } catch { + /* ignore */ + } + try { + const data = await api("/api/admin/promos"); + const box = $("#promos-list"); + if (!box) return; + const items = data.promos || []; + box.innerHTML = items.length + ? items + .map( + (p) => + `
${p.code} · ${p.kind}=${p.value} · used ${p.used_count}${p.max_uses != null ? "/" + p.max_uses : ""} · ${p.active ? "ON" : "OFF"}
` + ) + .join("") + : "Нет промокодов"; + } catch { + /* ignore */ + } + } + + async function loadJournal() { + const box = $("#payments-journal"); + if (!box) return; + try { + const data = await api("/api/admin/payments/journal"); + const items = data.events || []; + box.innerHTML = items.length + ? items + .slice(0, 30) + .map( + (e) => + `
${e.created_at} · ${e.provider}/${e.event_type} · tg=${e.telegram_id || "—"} · ${e.amount ?? "—"} · ${e.status}
` + ) + .join("") + : "Пусто"; + } catch (ex) { + box.textContent = ex.message || "Ошибка"; + } + } + + async function loadBackups() { + const box = $("#backups-list"); + if (!box) return; + try { + const data = await api("/api/admin/backups"); + const items = data.backups || []; + if (!items.length) { + box.innerHTML = '

Нет бэкапов — создайте первый

'; + return; + } + box.innerHTML = items + .map((b) => { + const kb = (b.size / 1024).toFixed(1); + const when = (b.mtime || "").replace("T", " ").slice(0, 19); + const name = escapeAttr(b.name); + return `
+
+ ${escapeHtml(b.name)} + ${kb} KB · ${escapeHtml(when)} UTC +
+
+ + + +
+
`; + }) + .join(""); + } catch (ex) { + box.textContent = ex.message || "Ошибка"; + } + } + + async function downloadBackup(name) { + const token = getToken(); + const res = await fetch(`/api/admin/backups/${encodeURIComponent(name)}/download`, { + credentials: "same-origin", + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error || `HTTP ${res.status}`); + } + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = name; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + } + + async function restoreBackupByName(name) { + const ok = confirm( + `Восстановить базу из «${name}»?\n\nТекущие данные будут заменены. Перед этим создастся страховочный бэкап.` + ); + if (!ok) return; + const status = $("#backup-status"); + if (status) status.textContent = "Восстановление…"; + try { + const data = await api("/api/admin/backups/restore", { + method: "POST", + body: JSON.stringify({ name, confirm: true }), + }); + toast(data.message || "База восстановлена"); + if (status) { + status.textContent = data.safety_backup + ? `Готово. Страховочный бэкап: ${data.safety_backup}` + : "Готово."; + } + await loadBackups(); + } catch (ex) { + toast(ex.message || "Ошибка восстановления"); + if (status) status.textContent = ex.message || "Ошибка"; + } + } + + $("#form-growth")?.addEventListener("submit", async (e) => { + e.preventDefault(); + try { + await api("/api/admin/growth-settings", { + method: "POST", + body: JSON.stringify({ + referral_bonus_referrer: $("#growth-ref-referrer").value, + referral_bonus_referee: $("#growth-ref-referee").value, + trial_days: $("#growth-trial-days").value, + trial_tariff_id: $("#growth-trial-tariff").value, + }), + }); + toast("Настройки роста сохранены"); + } catch (ex) { + toast(ex.message || "Ошибка"); + } + }); + + $("#form-promo")?.addEventListener("submit", async (e) => { + e.preventDefault(); + const fd = new FormData(e.target); + try { + await api("/api/admin/promos", { + method: "POST", + body: JSON.stringify({ + code: fd.get("code"), + kind: fd.get("kind"), + value: Number(fd.get("value")), + max_uses: fd.get("max_uses") || null, + }), + }); + e.target.reset(); + toast("Промокод создан"); + await loadGrowth(); + } catch (ex) { + toast(ex.message || "Ошибка"); + } + }); + + $("#promos-list")?.addEventListener("click", async (e) => { + const btn = e.target.closest("[data-promo-toggle]"); + if (!btn) return; + try { + await api(`/api/admin/promos/${btn.dataset.promoToggle}/toggle`, { + method: "POST", + body: JSON.stringify({ active: btn.dataset.active === "1" }), + }); + await loadGrowth(); + } catch (ex) { + toast(ex.message || "Ошибка"); + } + }); + + $("#btn-refresh-journal")?.addEventListener("click", () => loadJournal()); + $("#btn-backup-refresh")?.addEventListener("click", () => loadBackups()); + $("#btn-backup-run")?.addEventListener("click", async () => { + const btn = $("#btn-backup-run"); + if (btn) btn.disabled = true; + try { + const data = await api("/api/admin/backups", { method: "POST", body: "{}" }); + toast(data.file ? `Бэкап: ${data.file}` : "Бэкап создан"); + await loadBackups(); + } catch (ex) { + toast(ex.message || "Ошибка бэкапа"); + } finally { + if (btn) btn.disabled = false; + } + }); + + $("#backups-list")?.addEventListener("click", async (e) => { + const btn = e.target.closest("[data-backup-act]"); + if (!btn) return; + e.preventDefault(); + e.stopPropagation(); + const row = btn.closest("[data-backup-name]"); + const name = row?.getAttribute("data-backup-name") || ""; + const act = btn.getAttribute("data-backup-act") || ""; + if (!name) { + toast("Не удалось определить файл бэкапа"); + return; + } + const status = $("#backup-status"); + + if (act === "download") { + try { + if (status) status.textContent = `Скачивание ${name}…`; + await downloadBackup(name); + toast("Скачивание начато"); + if (status) status.textContent = ""; + } catch (ex) { + toast(ex.message || "Ошибка скачивания"); + if (status) status.textContent = ex.message || "Ошибка скачивания"; + } + return; + } + + if (act === "restore") { + await restoreBackupByName(name); + return; + } + + if (act === "delete") { + const ok = window.confirm(`Удалить файл бэкапа «${name}» с сервера?\nТекущая база данных не изменится.`); + if (!ok) return; + btn.disabled = true; + if (status) status.textContent = `Удаление ${name}…`; + try { + const data = await api("/api/admin/backups/delete", { + method: "POST", + body: JSON.stringify({ name }), + }); + toast(data.message || "Бэкап удалён"); + if (status) status.textContent = data.message || "Удалено"; + await loadBackups(); + } catch (ex) { + toast(ex.message || "Ошибка удаления"); + if (status) status.textContent = ex.message || "Ошибка удаления"; + } finally { + btn.disabled = false; + } + } + }); + + $("#form-backup-restore")?.addEventListener("submit", async (e) => { + e.preventDefault(); + const fileInput = $("#backup-file"); + const confirmEl = $("#backup-confirm"); + const status = $("#backup-status"); + const file = fileInput?.files?.[0]; + if (!file) { + toast("Выберите файл"); + return; + } + if (!confirmEl?.checked) { + toast("Нужно подтверждение"); + return; + } + if (!confirm(`Восстановить базу из файла «${file.name}»?\nДанные будут перезаписаны.`)) { + return; + } + const fd = new FormData(); + fd.append("file", file); + fd.append("confirm", "true"); + const token = getToken(); + if (status) status.textContent = "Загрузка и восстановление…"; + const btn = $("#btn-backup-restore"); + if (btn) btn.disabled = true; + try { + const res = await fetch("/api/admin/backups/restore", { + method: "POST", + body: fd, + credentials: "same-origin", + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + const data = await res.json().catch(() => ({})); + if (!res.ok || data.ok === false) { + throw new Error(data.error || `HTTP ${res.status}`); + } + toast(data.message || "База восстановлена"); + if (status) { + status.textContent = data.safety_backup + ? `Готово. Страховочный бэкап: ${data.safety_backup}` + : "Готово."; + } + e.target.reset(); + await loadBackups(); + } catch (ex) { + toast(ex.message || "Ошибка импорта"); + if (status) status.textContent = ex.message || "Ошибка"; + } finally { + if (btn) btn.disabled = false; + } + }); + + $("#btn-credit-cancel").addEventListener("click", () => $("#dialog-credit").close()); + + $("#form-credit").addEventListener("submit", async (e) => { + e.preventDefault(); + const fd = new FormData(e.target); + try { + const data = await api("/api/admin/users/balance", { + method: "POST", + body: JSON.stringify({ + telegram_id: Number(fd.get("telegram_id")), + amount: Number(fd.get("amount")), + comment: fd.get("comment"), + }), + }); + $("#dialog-credit").close(); + toast(`Зачислено ${Math.round(data.amount)} ₽`); + await loadUsers(); + } catch (ex) { + toast(ex.message || "Ошибка"); + } + }); + + $("#btn-refresh-users").addEventListener("click", () => + loadUsers().catch((ex) => toast(ex.message)) + ); + + $("#users-search").addEventListener("input", (e) => { + usersQuery = e.target.value; + usersOffset = 0; + clearTimeout(searchDebounce); + searchDebounce = setTimeout(() => { + loadUsers().catch((err) => toast(err.message)); + }, 350); + }); + + $("#btn-users-prev").addEventListener("click", () => { + usersOffset = Math.max(0, usersOffset - usersLimit); + loadUsers().catch((ex) => toast(ex.message)); + }); + + $("#btn-users-next").addEventListener("click", () => { + if (usersOffset + usersLimit < usersTotal) { + usersOffset += usersLimit; + loadUsers().catch((ex) => toast(ex.message)); + } + }); + + $("#admin-tickets-list").addEventListener("click", (e) => { + const row = e.target.closest(".admin-ticket-row"); + if (!row) return; + openAdminTicket(row.dataset.id).catch((ex) => toast(ex.message)); + }); + + $("#filter-tickets").addEventListener("change", () => + loadTickets().catch((ex) => toast(ex.message)) + ); + $("#btn-refresh-tickets").addEventListener("click", () => + loadTickets().catch((ex) => toast(ex.message)) + ); + + $("#form-admin-ticket-reply").addEventListener("submit", async (e) => { + e.preventDefault(); + if (!activeAdminTicketId) return; + const fd = new FormData(e.target); + try { + const data = await api(`/api/admin/tickets/${activeAdminTicketId}/messages`, { + method: "POST", + body: JSON.stringify({ body: fd.get("body") }), + }); + e.target.reset(); + renderAdminTicket(data); + await loadTickets(); + await loadStats(); + toast("Ответ отправлен"); + } catch (ex) { + toast(ex.message || "Ошибка"); + } + }); + + $("#btn-admin-ticket-close").addEventListener("click", async () => { + if (!activeAdminTicketId) return; + try { + const data = await api(`/api/admin/tickets/${activeAdminTicketId}/close`, { + method: "POST", + body: "{}", + }); + renderAdminTicket(data); + await loadTickets(); + await loadStats(); + toast("Тикет закрыт"); + } catch (ex) { + toast(ex.message || "Ошибка"); + } + }); + + $("#btn-admin-ticket-reopen").addEventListener("click", async () => { + if (!activeAdminTicketId) return; + try { + const data = await api(`/api/admin/tickets/${activeAdminTicketId}/reopen`, { + method: "POST", + body: "{}", + }); + renderAdminTicket(data); + await loadTickets(); + await loadStats(); + toast("Тикет открыт"); + } catch (ex) { + toast(ex.message || "Ошибка"); + } + }); + + $("#ai-models-pick").addEventListener("change", (e) => { + const input = e.target.closest('input[type="checkbox"]'); + if (!input) return; + const id = input.value; + if (input.checked) { + if (!aiSelectedModels.includes(id)) aiSelectedModels.push(id); + } else { + aiSelectedModels = aiSelectedModels.filter((x) => x !== id); + } + renderAiModelSelect($("#ai-model").value || aiSelectedModels[0] || ""); + renderAiModelsPick($("#ai-model-search").value || ""); + }); + + $("#ai-model-search").addEventListener("input", (e) => { + renderAiModelsPick(e.target.value || ""); + }); + + $("#btn-ai-load-models").addEventListener("click", async () => { + try { + const data = await api("/api/admin/ai/models"); + aiCatalog = data.models || []; + renderAiModelsPick($("#ai-model-search").value || ""); + toast( + data.source === "openrouter" + ? `Загружено моделей: ${aiCatalog.length}` + : data.error || "Показаны модели по умолчанию" + ); + } catch (ex) { + toast(ex.message || "Не удалось загрузить модели"); + } + }); + + $("#btn-ai-add-model").addEventListener("click", () => { + const id = ($("#ai-model-custom").value || "").trim(); + if (!id) { + toast("Введите id модели"); + return; + } + if (!aiSelectedModels.includes(id)) aiSelectedModels.push(id); + $("#ai-model-custom").value = ""; + renderAiModelSelect($("#ai-model").value || id); + renderAiModelsPick($("#ai-model-search").value || ""); + toast(`Добавлено: ${id}`); + }); + + $("#btn-ai-clear-key").addEventListener("click", () => { + aiClearKey = true; + $("#ai-api-key").value = ""; + $("#ai-api-key").placeholder = "ключ будет удалён после сохранения"; + $("#ai-key-hint").textContent = "Ключ будет сброшен — нажмите «Сохранить»"; + toast("Ключ помечен на удаление"); + }); + + $("#form-ai").addEventListener("submit", async (e) => { + e.preventDefault(); + const payload = { + enabled: $("#ai-enabled").checked, + auto_reply: $("#ai-auto-reply").checked, + model: $("#ai-model").value, + models: aiSelectedModels, + system_prompt: $("#ai-system-prompt").value, + }; + const key = ($("#ai-api-key").value || "").trim(); + if (aiClearKey) payload.api_key = "__clear__"; + else if (key) payload.api_key = key; + try { + const data = await api("/api/admin/ai", { + method: "POST", + body: JSON.stringify(payload), + }); + aiClearKey = false; + const ai = data.ai || {}; + aiSelectedModels = ai.models || aiSelectedModels; + $("#ai-api-key").value = ""; + $("#ai-api-key").placeholder = ai.api_key_set + ? ai.api_key_masked || "ключ сохранён" + : "sk-or-v1-…"; + $("#ai-key-hint").textContent = ai.api_key_set + ? `Сохранён: ${ai.api_key_masked}` + : "Ключ не задан"; + renderAiModelSelect(ai.model); + renderAiModelsPick($("#ai-model-search").value || ""); + toast(ai.ready ? "ИИ сохранён и готов" : "Сохранено — проверьте ключ и модель"); + } catch (ex) { + toast(ex.message || "Ошибка сохранения"); + } + }); + + $("#btn-ai-test").addEventListener("click", async () => { + const out = $("#ai-test-out"); + out.classList.remove("hidden"); + out.textContent = "Запрос…"; + try { + // сначала сохраним текущие настройки без ключа, если ключ уже есть + await api("/api/admin/ai", { + method: "POST", + body: JSON.stringify({ + enabled: $("#ai-enabled").checked, + auto_reply: $("#ai-auto-reply").checked, + model: $("#ai-model").value, + models: aiSelectedModels, + system_prompt: $("#ai-system-prompt").value, + ...(aiClearKey + ? { api_key: "__clear__" } + : ($("#ai-api-key").value || "").trim() + ? { api_key: ($("#ai-api-key").value || "").trim() } + : {}), + }), + }); + aiClearKey = false; + const data = await api("/api/admin/ai/test", { + method: "POST", + body: JSON.stringify({ + prompt: "Как подключить VPN на телефоне? Коротко.", + }), + }); + out.textContent = [ + `model: ${data.model || "—"}`, + `action: ${data.action || "—"}`, + "", + data.reply || data.raw || "", + ].join("\n"); + toast("Тест OK"); + } catch (ex) { + out.textContent = ex.message || "Ошибка теста"; + toast(ex.message || "Ошибка теста"); + } + }); + + $("#btn-xui-new").addEventListener("click", () => showXuiForm(null)); + $("#btn-xui-cancel").addEventListener("click", () => hideXuiForm()); + $("#btn-refresh-xui").addEventListener("click", async () => { + try { + await loadXuiServers(); + toast("Серверы обновлены"); + } catch (ex) { + toast(ex.message || "Ошибка"); + } + }); + $("#xui-subscription-url").addEventListener("input", updateXuiSubPreview); + $("#xui-inbound-id").addEventListener("change", syncXuiInboundRemark); + + $("#xui-servers-list").addEventListener("click", async (e) => { + const btn = e.target.closest("[data-xui-act]"); + if (!btn) return; + const row = btn.closest(".xui-server-row"); + if (!row) return; + const id = Number(row.dataset.id); + const server = xuiServers.find((s) => s.id === id); + if (!server) return; + const act = btn.getAttribute("data-xui-act"); + if (act === "edit") { + showXuiForm(server); + return; + } + if (act === "delete") { + if (!confirm(`Удалить сервер «${server.name}»?`)) return; + try { + await api(`/api/admin/xui/servers/${id}`, { method: "DELETE" }); + hideXuiForm(); + await loadXuiServers(); + toast("Удалено"); + } catch (ex) { + toast(ex.message || "Ошибка удаления"); + } + return; + } + if (act === "test") { + try { + toast("Проверяю…"); + const data = await api(`/api/admin/xui/servers/${id}/test`, { + method: "POST", + body: "{}", + }); + await loadXuiServers(); + const wg = (data.wireguard || []) + .map((i) => `#${i.id} ${i.remark || ""} :${i.port}`) + .join(", "); + toast(data.message || (data.ok ? "OK" : "Fail")); + if (wg) console.info("WireGuard:", wg); + } catch (ex) { + await loadXuiServers().catch(() => {}); + toast(ex.message || "Ошибка проверки"); + } + } + }); + + $("#btn-xui-test").addEventListener("click", async () => { + const out = $("#xui-test-out"); + out.classList.remove("hidden"); + out.textContent = "Проверка…"; + try { + const id = ($("#xui-id").value || "").trim(); + const freshKey = ($("#xui-api-key").value || "").trim(); + const freshPass = ($("#xui-password").value || "").trim(); + let data; + if (id && !freshKey && !freshPass) { + data = await api(`/api/admin/xui/servers/${id}/test`, { + method: "POST", + body: "{}", + }); + } else { + const { body } = collectXuiFormBody(true); + data = await api("/api/admin/xui/test", { + method: "POST", + body: JSON.stringify(body), + }); + } + fillXuiInboundSelect(data.wireguard || [], $("#xui-inbound-id").value); + syncXuiInboundRemark(); + out.textContent = [ + data.message || (data.ok ? "OK" : "Fail"), + data.version ? `version: ${data.version}` : "target panel: 3.5.0+", + `inbounds: ${data.inbounds_total ?? "—"}`, + `wireguard: ${(data.wireguard || []).length}`, + ...(data.wireguard || []).map( + (i) => ` #${i.id} ${i.remark || "—"} :${i.port} clients=${i.clients_count}` + ), + ].join("\n"); + toast(data.ok ? "Панель доступна" : "Ошибка панели"); + if (id) await loadXuiServers().catch(() => {}); + } catch (ex) { + out.textContent = ex.message || "Ошибка"; + toast(ex.message || "Ошибка"); + } + }); + + $("#btn-xui-load-inbounds").addEventListener("click", async () => { + try { + const id = ($("#xui-id").value || "").trim(); + if (id && !($("#xui-api-key").value || "").trim() && !($("#xui-password").value || "").trim()) { + const data = await api(`/api/admin/xui/servers/${id}/inbounds`); + fillXuiInboundSelect(data.inbounds || [], $("#xui-inbound-id").value); + syncXuiInboundRemark(); + toast(`WireGuard: ${(data.inbounds || []).length}`); + return; + } + const { body } = collectXuiFormBody(true); + const data = await api("/api/admin/xui/test", { + method: "POST", + body: JSON.stringify(body), + }); + fillXuiInboundSelect(data.wireguard || [], $("#xui-inbound-id").value); + syncXuiInboundRemark(); + toast(`WireGuard: ${(data.wireguard || []).length}`); + } catch (ex) { + toast(ex.message || "Не удалось загрузить inbound"); + } + }); + + $("#form-xui").addEventListener("submit", async (e) => { + e.preventDefault(); + try { + const editingId = ($("#xui-id").value || "").trim(); + const server = editingId + ? xuiServers.find((s) => String(s.id) === editingId) + : null; + const needCreds = !editingId || !(server && (server.api_key_set || server.password_set)); + const { id, body } = collectXuiFormBody(needCreds); + syncXuiInboundRemark(); + body.inbound_remark = ($("#xui-inbound-remark").value || "").trim(); + if (id) { + await api(`/api/admin/xui/servers/${id}`, { + method: "PUT", + body: JSON.stringify(body), + }); + toast("Сервер обновлён"); + } else { + await api("/api/admin/xui/servers", { + method: "POST", + body: JSON.stringify(body), + }); + toast("Сервер добавлен"); + } + hideXuiForm(); + await loadXuiServers(); + } catch (ex) { + toast(ex.message || "Ошибка сохранения"); + } + }); + + $("#btn-wg-settings-save")?.addEventListener("click", async () => { + try { + await saveWgSettings(); + } catch (ex) { + toast(ex.message || "Ошибка"); + } + }); + + $("#wg-purchase-enabled")?.addEventListener("change", async () => { + try { + await saveWgSettings(); + } catch (ex) { + $("#wg-purchase-enabled").checked = !$("#wg-purchase-enabled").checked; + applyWgSaleUi({ + enabled: $("#wg-purchase-enabled").checked, + price: Number($("#wg-config-price")?.value || 0), + }); + toast(ex.message || "Ошибка"); + } + }); + + $("#btn-tariff-new")?.addEventListener("click", () => showTariffForm(null)); + $("#btn-tariff-cancel")?.addEventListener("click", () => hideTariffForm()); + $("#btn-refresh-tariffs")?.addEventListener("click", async () => { + try { + await loadAdminTariffs(); + toast("Тарифы обновлены"); + } catch (ex) { + toast(ex.message || "Ошибка"); + } + }); + + $("#tariffs-admin-list")?.addEventListener("click", async (e) => { + const btn = e.target.closest("[data-tariff-act]"); + if (!btn) return; + const row = btn.closest("[data-tariff-id]"); + if (!row) return; + const id = row.dataset.tariffId; + const tariff = adminTariffs.find((t) => t.id === id); + if (!tariff) return; + const act = btn.getAttribute("data-tariff-act"); + if (act === "edit") { + showTariffForm(tariff); + return; + } + if (act === "delete") { + if (!confirm(`Удалить тариф «${tariff.title}»?`)) return; + try { + await api(`/api/admin/tariffs/${encodeURIComponent(id)}`, { + method: "DELETE", + }); + hideTariffForm(); + await loadAdminTariffs(); + toast("Тариф удалён"); + } catch (ex) { + toast(ex.message || "Ошибка удаления"); + } + } + }); + + $("#form-tariff")?.addEventListener("submit", async (e) => { + e.preventDefault(); + try { + const editId = ($("#tariff-edit-id").value || "").trim(); + const body = collectTariffBody(); + if (editId) { + await api(`/api/admin/tariffs/${encodeURIComponent(editId)}`, { + method: "PUT", + body: JSON.stringify(body), + }); + toast("Тариф обновлён"); + } else { + await api("/api/admin/tariffs", { + method: "POST", + body: JSON.stringify(body), + }); + toast("Тариф создан"); + } + hideTariffForm(); + await loadAdminTariffs(); + } catch (ex) { + toast(ex.message || "Ошибка сохранения"); + } + }); + + $("#form-payments")?.addEventListener("submit", async (e) => { + e.preventDefault(); + try { + const data = await api("/api/admin/payments", { + method: "POST", + body: JSON.stringify(collectPaymentsBody()), + }); + applyPaymentsUi(data.payments || {}); + toast("Платежи сохранены в .env"); + } catch (ex) { + toast(ex.message || "Ошибка сохранения"); + } + }); + + $("#form-smtp")?.addEventListener("submit", async (e) => { + e.preventDefault(); + try { + const body = { + smtp_host: ($("#smtp-host")?.value || "").trim(), + smtp_port: Number($("#smtp-port")?.value || 587), + smtp_user: ($("#smtp-user")?.value || "").trim(), + smtp_from: ($("#smtp-from")?.value || "").trim(), + smtp_tls: !!$("#smtp-tls")?.checked, + smtp_ssl: !!$("#smtp-ssl")?.checked, + }; + secretPayload("smtp-password", "smtp_password", body); + const data = await api("/api/admin/smtp", { + method: "POST", + body: JSON.stringify(body), + }); + applySmtpUi(data.smtp || {}); + toast("SMTP сохранён в .env"); + } catch (ex) { + toast(ex.message || "Ошибка сохранения"); + } + }); + + $("#btn-smtp-test")?.addEventListener("click", async () => { + try { + await api("/api/admin/smtp/test", { + method: "POST", + body: JSON.stringify({ + to: ($("#smtp-test-to")?.value || "").trim(), + }), + }); + toast("Тестовое письмо отправлено"); + } catch (ex) { + toast(ex.message || "Ошибка отправки"); + } + }); + + $("#smtp-tls")?.addEventListener("change", () => { + $("#smtp-tls-bar")?.classList.toggle("is-off", !$("#smtp-tls").checked); + }); + $("#smtp-ssl")?.addEventListener("change", () => { + $("#smtp-ssl-bar")?.classList.toggle("is-off", !$("#smtp-ssl").checked); + }); + + function onClearSecretClick(e) { + const btn = e.target.closest("[data-clear-secret]"); + if (!btn) return; + e.preventDefault(); + const id = btn.getAttribute("data-clear-secret"); + if (!id || !(id in paymentSecretClears)) return; + paymentSecretClears[id] = true; + const input = $(`#${id}`); + if (input) { + input.value = ""; + input.placeholder = "будет очищен при сохранении"; + } + toast("Секрет будет сброшен после «Сохранить»"); + } + + $("#form-payments")?.addEventListener("click", onClearSecretClick); + $("#form-smtp")?.addEventListener("click", onClearSecretClick); + + $("#pay-anypay-check-ip")?.addEventListener("change", () => { + $("#pay-anypay-ip-bar")?.classList.toggle( + "is-off", + !$("#pay-anypay-check-ip").checked + ); + }); + $("#pay-crypto-testnet")?.addEventListener("change", () => { + $("#pay-crypto-testnet-bar")?.classList.toggle( + "is-off", + !$("#pay-crypto-testnet").checked + ); + }); + $("#pay-stars-enabled")?.addEventListener("change", () => { + $("#pay-stars-bar")?.classList.toggle("is-off", !$("#pay-stars-enabled").checked); + }); + $("#ai-enabled")?.addEventListener("change", () => { + $("#ai-enabled-bar")?.classList.toggle("is-off", !$("#ai-enabled").checked); + const st = $("#ai-enabled-status"); + if (st) st.textContent = $("#ai-enabled").checked ? "Включены" : "Выключены"; + }); + $("#ai-auto-reply")?.addEventListener("change", () => { + $("#ai-auto-bar")?.classList.toggle("is-off", !$("#ai-auto-reply").checked); + }); + + $("#btn-awg-new")?.addEventListener("click", () => showAwgForm(null)); + $("#btn-awg-cancel")?.addEventListener("click", () => hideAwgForm()); + $("#btn-refresh-awg")?.addEventListener("click", async () => { + try { + await loadAwgServers(); + toast("AWG серверы обновлены"); + } catch (ex) { + toast(ex.message || "Ошибка"); + } + }); + + $("#awg-servers-list")?.addEventListener("click", async (e) => { + const btn = e.target.closest("[data-awg-act]"); + if (!btn) return; + const row = btn.closest("[data-awg-id]"); + if (!row) return; + const id = Number(row.dataset.awgId); + const server = awgServers.find((s) => s.id === id); + if (!server) return; + const act = btn.getAttribute("data-awg-act"); + if (act === "edit") { + showAwgForm(server); + return; + } + if (act === "delete") { + if (!confirm(`Удалить сервер «${server.name}»?`)) return; + try { + await api(`/api/admin/awg/servers/${id}`, { method: "DELETE" }); + hideAwgForm(); + await loadAwgServers(); + toast("Удалено"); + } catch (ex) { + toast(ex.message || "Ошибка удаления"); + } + return; + } + if (act === "test") { + try { + toast("Проверяю…"); + const data = await api(`/api/admin/awg/servers/${id}/test`, { + method: "POST", + body: "{}", + }); + await loadAwgServers(); + toast(data.message || (data.ok ? "OK" : "Ошибка")); + } catch (ex) { + await loadAwgServers().catch(() => {}); + toast(ex.message || "Ошибка проверки"); + } + } + }); + + $("#btn-awg-discover")?.addEventListener("click", async () => { + const box = $("#awg-discover-box"); + try { + const apiUrl = ($("#awg-api-url").value || "").trim(); + const apiToken = ($("#awg-api-token").value || "").trim(); + if (!apiUrl || !apiToken) { + toast("Для загрузки нод нужны URL и Token"); + return; + } + box.classList.remove("hidden"); + box.innerHTML = '

Загрузка нод…

'; + const data = await api("/api/admin/awg/discover", { + method: "POST", + body: JSON.stringify({ + api_url: apiUrl, + api_token: apiToken, + verify_ssl: $("#awg-verify-ssl").checked, + }), + }); + const servers = data.servers || []; + if (!servers.length) { + box.innerHTML = '

На панели нет серверов

'; + return; + } + box.innerHTML = servers + .map((s) => { + const protos = (s.protocols || []).join(", ") || "—"; + const badge = s.has_awg2 + ? 'awg2' + : 'no awg2'; + return ` + `; + }) + .join(""); + } catch (ex) { + box.classList.remove("hidden"); + box.innerHTML = `

${escapeHtml(ex.message || "Ошибка")}

`; + } + }); + + $("#awg-discover-box")?.addEventListener("click", (e) => { + const btn = e.target.closest("[data-pick-panel]"); + if (!btn) return; + $("#awg-panel-server-id").value = btn.dataset.pickPanel; + if (!$("#awg-name").value) { + $("#awg-name").value = btn.dataset.pickName || `AWG #${btn.dataset.pickPanel}`; + } + toast(`Выбран server #${btn.dataset.pickPanel}`); + }); + + $("#btn-awg-test")?.addEventListener("click", async () => { + const out = $("#awg-test-out"); + out.classList.remove("hidden"); + out.textContent = "Проверка…"; + try { + const existingId = ($("#awg-id").value || "").trim(); + const { id, body } = collectAwgFormBody(!existingId); + let data; + if (id) { + if (body.api_token) { + await api(`/api/admin/awg/servers/${id}`, { + method: "PUT", + body: JSON.stringify(body), + }); + } + data = await api(`/api/admin/awg/servers/${id}/test`, { + method: "POST", + body: "{}", + }); + } else { + data = await api("/api/admin/awg/test", { + method: "POST", + body: JSON.stringify(body), + }); + } + out.textContent = [ + data.ok ? "OK" : "FAIL", + data.message || data.error || "", + data.ping_ms != null ? `ping: ${data.ping_ms} ms` : "", + (data.protocols || []).length ? `protocols: ${(data.protocols || []).join(", ")}` : "", + ] + .filter(Boolean) + .join("\n"); + toast(data.ok ? "Проверка OK" : data.message || "Ошибка"); + if (id) await loadAwgServers().catch(() => {}); + } catch (ex) { + out.textContent = ex.message || "Ошибка"; + toast(ex.message || "Ошибка"); + } + }); + + $("#form-awg")?.addEventListener("submit", async (e) => { + e.preventDefault(); + try { + const existingId = ($("#awg-id").value || "").trim(); + const { id, body } = collectAwgFormBody(!existingId); + if (id) { + await api(`/api/admin/awg/servers/${id}`, { + method: "PUT", + body: JSON.stringify(body), + }); + toast("Сервер обновлён"); + } else { + await api("/api/admin/awg/servers", { + method: "POST", + body: JSON.stringify(body), + }); + toast("Сервер добавлен"); + } + hideAwgForm(); + await loadAwgServers(); + } catch (ex) { + toast(ex.message || "Ошибка сохранения"); + } + }); + + $("#form-awg-settings")?.addEventListener("submit", async (e) => { + e.preventDefault(); + try { + await saveAwgSettings(); + } catch (ex) { + toast(ex.message || "Ошибка"); + } + }); + + $("#btn-awg-settings-save")?.addEventListener("click", async () => { + try { + await saveAwgSettings(); + } catch (ex) { + toast(ex.message || "Ошибка"); + } + }); + + $("#awg-purchase-enabled")?.addEventListener("change", async () => { + try { + await saveAwgSettings(); + } catch (ex) { + $("#awg-purchase-enabled").checked = !$("#awg-purchase-enabled").checked; + toast(ex.message || "Ошибка"); + } + }); + + $("#admin-nav")?.addEventListener("click", (e) => { + const btn = e.target.closest(".admin-nav-btn"); + if (!btn) return; + jumpToSection(btn.dataset.jump || "stats"); + }); + + bootstrap(); +})(); diff --git a/webapp/static/app.js b/webapp/static/app.js new file mode 100644 index 0000000..c7ffa32 --- /dev/null +++ b/webapp/static/app.js @@ -0,0 +1,2523 @@ +(() => { + const THEME_KEY = "ef_theme"; + + function getTheme() { + return document.documentElement.getAttribute("data-theme") === "light" ? "light" : "dark"; + } + + function applyTheme(theme) { + const t = theme === "light" ? "light" : "dark"; + document.documentElement.setAttribute("data-theme", t); + document.documentElement.setAttribute("data-bs-theme", t); + try { + localStorage.setItem(THEME_KEY, t); + } catch (_) {} + const tg = window.Telegram?.WebApp; + if (tg) { + const color = t === "light" ? "#eef3f0" : "#0a0f0d"; + try { + tg.setHeaderColor(color); + tg.setBackgroundColor(color); + } catch (_) {} + } + } + + const tg = window.Telegram?.WebApp; + if (tg) { + tg.ready(); + tg.expand(); + applyTheme(getTheme()); + document.documentElement.classList.add("is-telegram"); + document.body.classList.add("is-telegram"); + try { + if (typeof tg.disableVerticalSwipes === "function") tg.disableVerticalSwipes(); + } catch (_) {} + } + + const initData = tg?.initData || ""; + const TOKEN_KEY = "vpn_session_token"; + const $ = (sel) => document.querySelector(sel); + + document.getElementById("btn-theme-toggle")?.addEventListener("click", () => { + applyTheme(getTheme() === "dark" ? "light" : "dark"); + }); + + function setBalancePill(amount) { + const el = $("#balance-pill"); + if (!el) return; + if (amount === null || amount === undefined || amount === "" || amount === "—") { + el.innerHTML = ` —`; + return; + } + const n = typeof amount === "number" ? Math.round(amount) : amount; + el.innerHTML = ` ${n} ₽`; + } + + const state = { + me: null, + tariffs: [], + botUsername: "", + telegramClientId: "", + authed: false, + starsEnabled: false, + starsRubPerStar: 1.5, + topup: null, + }; + + function getStoredToken() { + try { + return localStorage.getItem(TOKEN_KEY) || ""; + } catch { + return ""; + } + } + + function setStoredToken(token) { + try { + if (token) localStorage.setItem(TOKEN_KEY, token); + else localStorage.removeItem(TOKEN_KEY); + } catch { + /* ignore */ + } + } + + let qrPollTimer = null; + + function b64urlToBuf(b64) { + const pad = "=".repeat((4 - ((b64 || "").length % 4)) % 4); + const str = atob(String(b64 || "").replace(/-/g, "+").replace(/_/g, "/") + pad); + const buf = new Uint8Array(str.length); + for (let i = 0; i < str.length; i++) buf[i] = str.charCodeAt(i); + return buf.buffer; + } + + function bufToB64url(buf) { + const bytes = new Uint8Array(buf); + let s = ""; + bytes.forEach((b) => { + s += String.fromCharCode(b); + }); + return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); + } + + function preparePublicKeyCreate(options) { + const o = { ...options }; + o.challenge = b64urlToBuf(options.challenge); + if (o.user?.id) o.user = { ...o.user, id: b64urlToBuf(o.user.id) }; + if (Array.isArray(o.excludeCredentials)) { + o.excludeCredentials = o.excludeCredentials.map((c) => ({ + ...c, + id: b64urlToBuf(c.id), + })); + } + return o; + } + + function preparePublicKeyGet(options) { + const o = { ...options }; + o.challenge = b64urlToBuf(options.challenge); + if (Array.isArray(o.allowCredentials)) { + o.allowCredentials = o.allowCredentials.map((c) => ({ + ...c, + id: b64urlToBuf(c.id), + })); + } + return o; + } + + function credentialToJSON(cred) { + const response = cred.response; + const out = { + id: cred.id, + rawId: bufToB64url(cred.rawId), + type: cred.type, + authenticatorAttachment: cred.authenticatorAttachment || undefined, + response: { + clientDataJSON: bufToB64url(response.clientDataJSON), + attestationObject: response.attestationObject + ? bufToB64url(response.attestationObject) + : undefined, + authenticatorData: response.authenticatorData + ? bufToB64url(response.authenticatorData) + : undefined, + signature: response.signature ? bufToB64url(response.signature) : undefined, + userHandle: response.userHandle ? bufToB64url(response.userHandle) : undefined, + }, + }; + if (typeof response.getTransports === "function") { + out.transports = response.getTransports(); + } + return out; + } + + function stopQrPoll() { + if (qrPollTimer) { + clearInterval(qrPollTimer); + qrPollTimer = null; + } + $("#qr-auth-box")?.classList.add("hidden"); + } + + function toast(msg) { + const el = $("#toast"); + el.textContent = msg.replace(/<[^>]+>/g, " "); + el.classList.remove("hidden"); + clearTimeout(toast._t); + toast._t = setTimeout(() => el.classList.add("hidden"), 3200); + } + + async function copyText(text) { + const value = String(text || ""); + if (!value) return false; + + // Clipboard API часто падает в Telegram WebView / при открытом + try { + if (navigator.clipboard?.writeText && window.isSecureContext && document.hasFocus()) { + await navigator.clipboard.writeText(value); + return true; + } + } catch { + /* fallback */ + } + + try { + const ta = document.createElement("textarea"); + ta.value = value; + ta.setAttribute("readonly", ""); + ta.style.cssText = + "position:fixed;top:0;left:0;width:1px;height:1px;padding:0;border:0;outline:none;box-shadow:none;background:transparent;opacity:0;"; + document.body.appendChild(ta); + ta.focus({ preventScroll: true }); + ta.select(); + ta.setSelectionRange(0, value.length); + const ok = document.execCommand("copy"); + document.body.removeChild(ta); + if (ok) return true; + } catch { + /* fallback */ + } + + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(value); + return true; + } + } catch { + /* ignore */ + } + return false; + } + + async function api(path, options = {}) { + const headers = { + "Content-Type": "application/json", + ...(options.headers || {}), + }; + if (initData) { + headers["X-Telegram-Init-Data"] = initData; + } + const token = getStoredToken(); + if (token) { + headers.Authorization = `Bearer ${token}`; + } + const res = await fetch(path, { + ...options, + headers, + credentials: "same-origin", + }); + const data = await res.json().catch(() => ({})); + if (!res.ok || data.ok === false) { + const err = data.message || data.error || `HTTP ${res.status}`; + throw new Error(String(err).replace(/<[^>]+>/g, " ")); + } + if (data.token) setStoredToken(data.token); + return data; + } + + function isTelegramWebApp() { + return Boolean(tg && (initData || tg.initDataUnsafe?.user)); + } + + function canOpenStarsInvoice() { + return Boolean(tg && typeof tg.openInvoice === "function" && isTelegramWebApp()); + } + + async function loadStarsPacks() { + const card = $("#stars-topup-card"); + const box = $("#stars-packs"); + const webOnly = $("#stars-web-only"); + const hint = $("#stars-topup-hint"); + if (!card || !box) return; + + if (!state.starsEnabled) { + card.classList.add("hidden"); + return; + } + card.classList.remove("hidden"); + + if (!state.authed) { + box.innerHTML = ""; + if (webOnly) { + webOnly.textContent = "Войдите через Telegram, чтобы пополнить Stars."; + webOnly.classList.remove("hidden"); + } + return; + } + + if (state.me?.user?.telegram_id != null && Number(state.me.user.telegram_id) <= 0) { + box.innerHTML = ""; + if (webOnly) { + webOnly.textContent = + "Stars только для Telegram-аккаунта. Войдите через Telegram Login или Mini App."; + webOnly.classList.remove("hidden"); + } + return; + } + + if (!canOpenStarsInvoice()) { + box.innerHTML = ""; + if (webOnly) webOnly.classList.remove("hidden"); + if (hint) { + hint.textContent = `Курс 1 ⭐ ≈ ${state.starsRubPerStar} ₽ · откройте кабинет из Telegram`; + } + return; + } + + if (webOnly) webOnly.classList.add("hidden"); + try { + const data = await api("/api/stars/packs"); + box.innerHTML = ""; + if (hint && data.note) hint.textContent = data.note; + (data.packs || []).forEach((p) => { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "btn secondary full"; + btn.textContent = `⭐ ${p.title}`; + btn.addEventListener("click", () => payStars(p.amount_rub, btn)); + box.appendChild(btn); + }); + } catch (e) { + box.innerHTML = `

${e.message || "Не удалось загрузить пакеты"}

`; + } + } + + async function payStars(amountRub, btn) { + if (!state.authed) { + toast("Сначала войдите"); + showAuth(true); + return; + } + if (!canOpenStarsInvoice()) { + toast("Откройте кабинет из Telegram"); + return; + } + const old = btn.textContent; + btn.disabled = true; + btn.textContent = "…"; + try { + const data = await api("/api/stars/invoice", { + method: "POST", + body: JSON.stringify({ amount: Number(amountRub) }), + }); + const link = data.invoice_link; + if (!link) throw new Error("Нет ссылки на оплату"); + + tg.openInvoice(link, async (status) => { + if (status === "paid") { + toast("Баланс пополнен через Stars"); + if (tg.HapticFeedback) tg.HapticFeedback.notificationOccurred("success"); + try { + await loadMe(); + renderTariffs(); + loadTransactions(1); + } catch { + /* ignore */ + } + } else if (status === "cancelled") { + toast("Оплата отменена"); + } else if (status === "failed") { + toast("Оплата не удалась"); + if (tg.HapticFeedback) tg.HapticFeedback.notificationOccurred("error"); + } + }); + } catch (e) { + toast(e.message || "Ошибка Stars"); + if (tg?.HapticFeedback) tg.HapticFeedback.notificationOccurred("error"); + } finally { + btn.disabled = false; + btn.textContent = old; + } + } + + function switchTab(name) { + const valid = new Set([ + "home", + "balance", + "configs", + "extras", + "account", + "tariffs", + "clients", + "servers", + "faq", + "tickets", + ]); + const tab = valid.has(name) ? name : "home"; + document.querySelectorAll(".tab").forEach((b) => { + const on = b.dataset.tab === tab; + b.classList.toggle("active", on); + if (on && typeof b.scrollIntoView === "function") { + try { + b.scrollIntoView({ behavior: "smooth", inline: "center", block: "nearest" }); + } catch (_) {} + } + }); + document.querySelectorAll(".panel").forEach((p) => { + p.classList.toggle("active", p.id === `tab-${tab}`); + }); + if (tab === "servers" && !$("#servers-list").children.length) { + loadServers(); + } + if (tab === "faq" && !$("#faq-list").children.length) { + loadFaq(); + } + if (tab === "tickets") { + loadTickets(); + } + if (tab === "balance") { + loadTransactions(); + } + if (tab === "configs") { + loadWgConfigs(); + loadAwgConfigs(); + } + if (tab === "extras") { + loadTrafficPacks(); + loadDevicePacks(); + } + try { + localStorage.setItem("cabinet_tab", tab); + if (location.hash !== `#${tab}`) { + history.replaceState(null, "", `#${tab}`); + } + } catch (_) {} + window.scrollTo({ top: 0, behavior: "smooth" }); + } + + document.querySelectorAll(".tab").forEach((btn) => { + btn.addEventListener("click", () => switchTab(btn.dataset.tab)); + }); + + document.querySelectorAll(".auth-tab").forEach((btn) => { + btn.addEventListener("click", () => { + setAuthMode(btn.dataset.auth === "register" ? "register" : "login"); + }); + }); + + function setAuthMode(mode) { + const m = mode || "login"; + document.querySelectorAll(".auth-tab").forEach((b) => { + b.classList.toggle("active", b.dataset.auth === (m === "register" ? "register" : "login")); + }); + $("#form-login")?.classList.toggle("hidden", m !== "login"); + $("#form-register")?.classList.toggle("hidden", m !== "register"); + $("#form-forgot")?.classList.toggle("hidden", m !== "forgot"); + $("#form-reset")?.classList.toggle("hidden", m !== "reset"); + const welcome = $(".auth-welcome"); + const sub = $(".auth-sub"); + if (welcome && sub) { + if (m === "register") { + welcome.textContent = "Добро пожаловать"; + sub.textContent = "Создайте аккаунт — дальше поможем подключить VPN"; + } else if (m === "forgot") { + welcome.textContent = "Сброс пароля"; + sub.textContent = "Письмо со ссылкой придёт на email аккаунта"; + } else if (m === "reset") { + welcome.textContent = "Новый пароль"; + sub.textContent = "Задайте пароль и войдите в кабинет"; + } else { + welcome.textContent = "С возвращением"; + sub.textContent = "Войдите, чтобы управлять VPN, балансом и конфигами"; + } + } + if (m === "forgot") { + const st = $("#forgot-status"); + if (st) st.textContent = ""; + setTimeout(() => $("#form-forgot input[name=email]")?.focus(), 50); + } + } + + function showAuth(show) { + $("#auth-panel").classList.toggle("hidden", !show); + $("#guest-banner").classList.toggle("hidden", !show); + $("#cabinet").classList.toggle("hidden", show); + $("#security-card")?.classList.toggle("hidden", show); + $("#btn-logout").classList.toggle("hidden", show || !!initData); + if (show && !state.me) { + $("#user-name").textContent = "Вход в кабинет"; + setBalancePill("—"); + $("#onboarding-card")?.classList.remove("hidden"); + $("#howto-card")?.classList.remove("hidden"); + const hello = $("#home-hello"); + const helloSub = $("#home-hello-sub"); + if (hello) hello.textContent = "Привет!"; + if (helloSub) helloSub.textContent = "Войдите или зарегистрируйтесь — поможем подключить VPN"; + setSubBadge("off", "Нет тарифа"); + const statusEl = $("#sub-status"); + if (statusEl) { + statusEl.classList.remove("status-ok", "status-warn"); + statusEl.classList.add("status-off"); + statusEl.textContent = "После входа здесь появится статус подписки"; + } + } + loadStarsPacks(); + } + + const TX_PAGE_SIZE = 10; + let txPage = 1; + + function syncTxPager(page, pages, total) { + const pager = $("#transactions-pager"); + const label = $("#tx-page-label"); + const prev = $("#tx-prev"); + const next = $("#tx-next"); + const meta = $("#balance-history-meta"); + if (meta) { + meta.textContent = total + ? `${total} операций · по ${TX_PAGE_SIZE} на странице` + : "Последние операции"; + } + if (!pager || !label || !prev || !next) return; + if (!total || pages <= 1) { + pager.classList.add("hidden"); + return; + } + pager.classList.remove("hidden"); + label.textContent = `${page} / ${pages}`; + prev.disabled = page <= 1; + next.disabled = page >= pages; + } + + async function loadTransactions(page = txPage) { + const list = $("#transactions-list"); + const card = $("#balance-history-card"); + if (!list || !card) return; + if (!state.authed) { + card.classList.add("hidden"); + $("#transactions-pager")?.classList.add("hidden"); + return; + } + txPage = Math.max(1, Number(page) || 1); + try { + const data = await api(`/api/transactions?limit=${TX_PAGE_SIZE}&page=${txPage}`); + list.innerHTML = ""; + const items = data.items || []; + const total = Number(data.total || 0); + const pages = Math.max(1, Number(data.pages || 1)); + txPage = Math.min(Math.max(1, Number(data.page || txPage)), pages); + if (!items.length) { + list.innerHTML = '
  • Операций пока нет
  • '; + syncTxPager(1, 1, 0); + card.classList.remove("hidden"); + return; + } + items.forEach((tx) => { + const li = document.createElement("li"); + const plus = tx.amount > 0; + const date = tx.created_at + ? new Date(tx.created_at).toLocaleString("ru-RU", { + day: "2-digit", + month: "short", + hour: "2-digit", + minute: "2-digit", + }) + : ""; + li.innerHTML = ` +
    +
    ${tx.label || "Операция"}
    +
    ${date}
    +
    + ${tx.amount_label || ""} + `; + list.appendChild(li); + }); + syncTxPager(txPage, pages, total); + card.classList.remove("hidden"); + } catch { + list.innerHTML = '
  • Не удалось загрузить историю
  • '; + syncTxPager(1, 1, 0); + card.classList.remove("hidden"); + } + } + + function setSubBadge(kind, label) { + const badge = $("#sub-badge"); + if (!badge) return; + badge.className = `status-badge status-badge--${kind}`; + badge.textContent = label; + } + + function syncAutoRenewUi() { + const toggle = $("#auto-renew-toggle"); + const hint = $("#auto-renew-hint"); + const box = $("#auto-renew-box"); + if (!toggle || !state.me?.user) return; + const on = !!state.me.user.auto_renew; + toggle.checked = on; + if (box) box.classList.toggle("is-off", !on); + if (hint) { + hint.textContent = on + ? "При достаточном балансе тариф продлится сам" + : "Выключено — продлевайте тариф вручную"; + } + } + + function renderHome() { + const me = state.me; + if (!me) return; + const displayName = me.user.name || me.user.login || "друг"; + $("#user-name").textContent = me.user.name || me.user.login || "Пользователь"; + setBalancePill(me.balance); + syncAutoRenewUi(); + + const hello = $("#home-hello"); + const helloSub = $("#home-hello-sub"); + if (hello) hello.textContent = `Привет, ${displayName}!`; + + const sub = me.subscription; + const statusEl = $("#sub-status"); + const wrap = $("#sub-url-wrap"); + const renewBtn = $("#btn-renew-go"); + const trafficBtn = $("#btn-traffic-go"); + const devicesBtn = $("#btn-devices-go"); + const wgBtn = $("#btn-wg-go"); + const awgBtn = $("#btn-awg-go"); + const trafficBox = $("#traffic-box"); + const devicesBox = $("#devices-box"); + const packsCard = $("#traffic-packs-card"); + const devicePacksCard = $("#device-packs-card"); + const wgCard = $("#wg-configs-card"); + const awgCard = $("#awg-configs-card"); + const extrasBox = $("#extras-billing-box"); + const onboarding = $("#onboarding-card"); + const howto = $("#howto-card"); + const subCard = $("#sub-card"); + + if (!sub.has) { + if (helloSub) { + helloSub.textContent = "Давайте подключим VPN — это займёт пару минут"; + } + onboarding?.classList.remove("hidden"); + howto?.classList.remove("hidden"); + subCard?.classList.add("card-dimmed"); + setSubBadge("off", "Нет тарифа"); + statusEl.classList.remove("status-ok", "status-warn"); + statusEl.classList.add("status-off"); + statusEl.textContent = "Тарифа пока нет. База: 1000 ГБ и 10 устройств."; + wrap.classList.add("hidden"); + renewBtn.classList.add("hidden"); + trafficBtn.classList.add("hidden"); + devicesBtn.classList.add("hidden"); + if (wgBtn) wgBtn.classList.add("hidden"); + if (awgBtn) awgBtn.classList.add("hidden"); + trafficBox.classList.add("hidden"); + devicesBox.classList.add("hidden"); + packsCard.classList.add("hidden"); + devicePacksCard.classList.add("hidden"); + if (wgCard) wgCard.classList.add("hidden"); + if (awgCard) awgCard.classList.add("hidden"); + if (extrasBox) extrasBox.classList.add("hidden"); + $("#balance-history-card")?.classList.add("hidden"); + return; + } + + if (helloSub) { + helloSub.textContent = "Всё под контролем — ссылка, трафик и устройства здесь"; + } + onboarding?.classList.add("hidden"); + howto?.classList.add("hidden"); + subCard?.classList.remove("card-dimmed"); + + statusEl.classList.remove("status-ok", "status-warn", "status-off"); + + const exp = sub.expire_at + ? new Date(sub.expire_at).toLocaleString("ru-RU") + : "—"; + const over = !!sub.devices?.over_limit; + if (over) { + setSubBadge("warn", "Лимит устройств"); + statusEl.classList.add("status-warn"); + statusEl.innerHTML = `Подписка на паузе: слишком много устройств · до ${exp}`; + } else { + setSubBadge("ok", "Активна"); + statusEl.classList.add("status-ok"); + statusEl.innerHTML = `Работает до ${exp}`; + } + if (sub.url) { + $("#sub-url").textContent = sub.url; + wrap.classList.remove("hidden"); + } + renewBtn.classList.remove("hidden"); + trafficBtn.classList.remove("hidden"); + devicesBtn.classList.remove("hidden"); + loadWgConfigs(); + loadAwgConfigs(); + + const tr = sub.traffic; + if (tr) { + trafficBox.classList.remove("hidden"); + $("#traffic-label").textContent = `${tr.used_label} / ${tr.limit_label}`; + $("#traffic-left").textContent = `Осталось ${tr.left_label} · ${tr.percent}%`; + const fill = $("#traffic-fill"); + fill.style.width = `${Math.min(100, tr.percent)}%`; + fill.classList.toggle("warn", tr.percent >= 70 && tr.percent < 90); + fill.classList.toggle("danger", tr.percent >= 90); + } else { + trafficBox.classList.add("hidden"); + } + + const dev = sub.devices; + if (dev) { + devicesBox.classList.remove("hidden"); + $("#devices-label").textContent = `${dev.used} / ${dev.allowed}`; + const warn = $("#devices-warn"); + if (dev.over_limit) { + warn.classList.remove("hidden"); + warn.textContent = `Лишних устройств: +${dev.excess}. Оплатите слот (${Math.round(dev.excess_price)} ₽/мес) или отвяжите лишние.`; + } else { + warn.classList.add("hidden"); + } + } else { + devicesBox.classList.add("hidden"); + } + + const extras = sub.extras; + if (extrasBox && extras?.has_subscription) { + extrasBox.classList.remove("hidden"); + extrasBox.classList.toggle("suspended", !extras.active); + $("#extras-fee-label").textContent = extras.monthly_fee_label || "—"; + const parts = []; + if (extras.extra_traffic_packs) { + parts.push(`+${extras.extra_traffic_packs}×500 ГБ`); + } + if (extras.extra_devices) { + parts.push(`+${extras.extra_devices} устр.`); + } + let line = parts.join(" · ") || "Доп. опции"; + if (extras.next_billing_label) { + line += extras.active + ? ` · следующее списание ${extras.next_billing_label}` + : " · на паузе — пополните баланс"; + } + $("#extras-billing-text").textContent = line; + } else if (extrasBox) { + extrasBox.classList.add("hidden"); + } + loadTransactions(); + } + + async function loadTrafficPacks() { + const box = $("#traffic-packs"); + const card = $("#traffic-packs-card"); + try { + const data = await api("/api/traffic/packs"); + box.innerHTML = ""; + (data.packs || []).forEach((p) => { + const el = document.createElement("div"); + el.className = "actions"; + el.innerHTML = ` + + `; + box.appendChild(el); + }); + box.querySelectorAll("[data-pack]").forEach((btn) => { + btn.addEventListener("click", () => buyTraffic(btn.dataset.pack, btn)); + }); + card.classList.remove("hidden"); + } catch (e) { + toast(e.message || "Не удалось загрузить пакеты"); + } + } + + async function loadDevicePacks() { + const box = $("#device-packs"); + const card = $("#device-packs-card"); + try { + const data = await api("/api/devices/packs"); + box.innerHTML = ""; + const excess = state.me?.subscription?.devices?.excess || 0; + if (excess > 0) { + const price = Math.round((state.me.subscription.devices.excess_price || 0)); + const el = document.createElement("div"); + el.className = "actions"; + el.innerHTML = ` + + `; + box.appendChild(el); + } + (data.packs || []).forEach((p) => { + const el = document.createElement("div"); + el.className = "actions"; + el.innerHTML = ` + + `; + box.appendChild(el); + }); + box.querySelectorAll("[data-slots]").forEach((btn) => { + btn.addEventListener("click", () => buyDevices(btn.dataset.slots, btn)); + }); + card.classList.remove("hidden"); + } catch (e) { + toast(e.message || "Не удалось загрузить пакеты устройств"); + } + } + + async function buyTraffic(packs, btn) { + if (!state.authed) { + toast("Сначала войдите в аккаунт"); + showAuth(true); + return; + } + const old = btn.textContent; + btn.disabled = true; + btn.textContent = "…"; + try { + await api("/api/traffic/buy", { + method: "POST", + body: JSON.stringify({ packs: Number(packs) }), + }); + toast("Трафик увеличен"); + if (tg?.HapticFeedback) tg.HapticFeedback.notificationOccurred("success"); + await loadMe(); + } catch (e) { + toast(e.message || "Ошибка покупки трафика"); + if (tg?.HapticFeedback) tg.HapticFeedback.notificationOccurred("error"); + } finally { + btn.disabled = false; + btn.textContent = old; + } + } + + async function buyDevices(slots, btn) { + if (!state.authed) { + toast("Сначала войдите в аккаунт"); + showAuth(true); + return; + } + const old = btn.textContent; + btn.disabled = true; + btn.textContent = "…"; + try { + await api("/api/devices/buy", { + method: "POST", + body: JSON.stringify({ slots: Number(slots) }), + }); + toast("Лимит устройств увеличен"); + if (tg?.HapticFeedback) tg.HapticFeedback.notificationOccurred("success"); + await loadMe(); + loadDevicePacks(); + } catch (e) { + toast(e.message || "Ошибка покупки устройств"); + if (tg?.HapticFeedback) tg.HapticFeedback.notificationOccurred("error"); + } finally { + btn.disabled = false; + btn.textContent = old; + } + } + + function renderTariffs() { + const box = $("#tariffs-list"); + box.innerHTML = ""; + const hasSub = !!state.me?.subscription?.has; + + if (!state.tariffs?.length) { + box.innerHTML = ` +
    +

    Тарифы скоро появятся

    +

    Обновите страницу или загляните чуть позже

    +
    + `; + return; + } + + const promoWrap = document.createElement("div"); + promoWrap.className = "auth-form"; + promoWrap.innerHTML = ` + `; + box.appendChild(promoWrap); + + state.tariffs.forEach((t) => { + const traffic = + t.traffic_gb > 0 ? `${t.traffic_gb} ГБ` : "1000 ГБ"; + const el = document.createElement("article"); + el.className = "tariff"; + el.innerHTML = ` +

    ${t.title}

    +

    ${t.description || ""}

    +
    + ${t.days} дн. + ${traffic} + ${t.device_limit} устр. +
    +

    ${t.price_label}

    +
    + + ${ + hasSub + ? `` + : `` + } +
    + `; + box.appendChild(el); + }); + + box.querySelectorAll("[data-buy]").forEach((btn) => { + btn.addEventListener("click", () => buy(btn.dataset.buy, false, btn)); + }); + box.querySelectorAll("[data-renew]").forEach((btn) => { + btn.addEventListener("click", () => buy(btn.dataset.renew, true, btn)); + }); + } + + async function buy(tariffId, renew, btn) { + if (!state.authed) { + toast("Сначала войдите в аккаунт"); + showAuth(true); + return; + } + const old = btn.textContent; + btn.disabled = true; + btn.textContent = "…"; + try { + const data = await api("/api/buy", { + method: "POST", + body: JSON.stringify({ + tariff_id: tariffId, + renew, + promo_code: ($("#promo-code-input")?.value || "").trim() || undefined, + }), + }); + toast(renew ? "Подписка продлена" : "Тариф активирован"); + if (tg?.HapticFeedback) tg.HapticFeedback.notificationOccurred("success"); + await loadMe(); + renderTariffs(); + if (data.subscription_url) switchTab("home"); + } catch (e) { + toast(e.message || "Ошибка оплаты"); + if (tg?.HapticFeedback) tg.HapticFeedback.notificationOccurred("error"); + } finally { + btn.disabled = false; + btn.textContent = old; + } + } + + function renderClients(clients) { + const box = $("#clients-list"); + box.innerHTML = ""; + if (!clients?.length) { + box.innerHTML = ` +
    +

    Клиенты пока не загрузились

    +

    Загляните позже или напишите в тикеты — поможем подобрать приложение

    +
    + `; + return; + } + clients.forEach((c) => { + const el = document.createElement("article"); + el.className = "client"; + const badges = [ + c.featured ? 'рекомендуем' : "", + c.hwid ? 'HWID' : "", + `${c.core}`, + ] + .filter(Boolean) + .join(""); + el.innerHTML = ` +

    ${c.name}

    +

    ${c.description}

    +
    ${badges}
    + + `; + box.appendChild(el); + }); + } + + function stripHtml(html) { + return String(html || "") + .replace(//gi, "\n") + .replace(/<\/p>/gi, "\n") + .replace(/<[^>]+>/g, "") + .replace(/ /g, " ") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/&/g, "&") + .trim(); + } + + async function loadFaq() { + const box = $("#faq-list"); + box.innerHTML = '

    Загрузка…

    '; + try { + const data = await api("/api/faq"); + box.innerHTML = ""; + (data.items || []).forEach((item) => { + const el = document.createElement("details"); + el.className = "faq-item"; + el.innerHTML = ` + ${item.question} +
    ${stripHtml(item.answer)}
    + `; + box.appendChild(el); + }); + } catch (e) { + box.innerHTML = `

    ${e.message || "Ошибка"}

    `; + } + } + + let ticketsMeta = { max_open: 3, open_count: 0 }; + let activeTicketId = null; + + function escapeHtml(s) { + return String(s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + + function fmtTicketDate(iso) { + if (!iso) return "—"; + try { + return new Date(iso).toLocaleString("ru-RU", { + day: "2-digit", + month: "short", + hour: "2-digit", + minute: "2-digit", + }); + } catch { + return iso; + } + } + + function showTicketList() { + $("#ticket-thread").classList.add("hidden"); + $("#tickets-list").classList.remove("hidden"); + $("#btn-ticket-new").classList.remove("hidden"); + activeTicketId = null; + } + + async function loadTickets() { + if (!state.me) { + $("#tickets-list").innerHTML = + '

    Войдите, чтобы создавать тикеты

    '; + $("#ticket-create-form").classList.add("hidden"); + $("#ticket-thread").classList.add("hidden"); + return; + } + showTicketList(); + const box = $("#tickets-list"); + box.innerHTML = '

    Загрузка…

    '; + try { + const data = await api("/api/tickets"); + ticketsMeta = { + max_open: data.max_open || 3, + open_count: data.open_count || 0, + }; + $("#tickets-lead").textContent = + `Открытых: ${ticketsMeta.open_count} / ${ticketsMeta.max_open}`; + box.innerHTML = ""; + const tickets = data.tickets || []; + if (!tickets.length) { + box.innerHTML = '

    Тикетов пока нет

    '; + return; + } + tickets.forEach((t) => { + const el = document.createElement("button"); + el.type = "button"; + el.className = "ticket-item"; + el.dataset.id = t.id; + el.innerHTML = ` +
    + #${t.id} · ${escapeHtml(t.subject)} + ${fmtTicketDate(t.updated_at)} +
    + ${escapeHtml(t.status_label || t.status)} + `; + box.appendChild(el); + }); + } catch (e) { + box.innerHTML = `

    ${e.message || "Ошибка"}

    `; + } + } + + function renderTicketThread(data) { + const t = data.ticket || {}; + activeTicketId = t.id; + $("#tickets-list").classList.add("hidden"); + $("#ticket-create-form").classList.add("hidden"); + $("#btn-ticket-new").classList.add("hidden"); + const thread = $("#ticket-thread"); + thread.classList.remove("hidden"); + $("#ticket-thread-title").textContent = `#${t.id} · ${t.subject || ""}`; + $("#ticket-thread-meta").textContent = + `${t.status_label || t.status} · обновлён ${fmtTicketDate(t.updated_at)}`; + const box = $("#ticket-messages"); + box.innerHTML = ""; + (data.messages || []).forEach((m) => { + const el = document.createElement("div"); + el.className = `ticket-msg ticket-msg--${m.author_role === "admin" ? "admin" : m.author_role === "ai" ? "ai" : "user"}`; + const who = + m.author_role === "admin" ? "Поддержка" : m.author_role === "ai" ? "ИИ-поддержка" : "Вы"; + el.innerHTML = ` +
    ${who} · ${fmtTicketDate(m.created_at)}
    +
    ${escapeHtml(m.body)}
    + `; + box.appendChild(el); + }); + const closed = t.status === "closed"; + $("#ticket-reply-form").classList.toggle("hidden", closed); + $("#btn-ticket-close").classList.toggle("hidden", closed); + box.scrollTop = box.scrollHeight; + } + + async function openTicket(id) { + try { + const data = await api(`/api/tickets/${id}`); + renderTicketThread(data); + } catch (e) { + toast(e.message || "Ошибка"); + } + } + + $("#btn-ticket-new")?.addEventListener("click", () => { + if (!state.me) { + toast("Сначала войдите"); + return; + } + if (ticketsMeta.open_count >= ticketsMeta.max_open) { + toast(`Лимит открытых тикетов: ${ticketsMeta.max_open}`); + return; + } + $("#ticket-create-form").classList.remove("hidden"); + $("#ticket-thread").classList.add("hidden"); + }); + + $("#btn-ticket-cancel")?.addEventListener("click", () => { + $("#ticket-create-form").classList.add("hidden"); + $("#ticket-create-form").reset(); + }); + + $("#ticket-create-form")?.addEventListener("submit", async (e) => { + e.preventDefault(); + const fd = new FormData(e.target); + try { + const data = await api("/api/tickets", { + method: "POST", + body: JSON.stringify({ + subject: fd.get("subject"), + body: fd.get("body"), + }), + }); + e.target.reset(); + $("#ticket-create-form").classList.add("hidden"); + toast(data.ai?.action === "answer" ? "Тикет создан · ИИ ответил" : "Тикет создан"); + if (data.messages && data.ticket?.id) { + renderTicketThread(data); + } else if (data.ticket?.id) { + await openTicket(data.ticket.id); + } else { + await loadTickets(); + } + } catch (ex) { + toast(ex.message || "Ошибка"); + } + }); + + $("#tickets-list")?.addEventListener("click", (e) => { + const btn = e.target.closest(".ticket-item"); + if (!btn) return; + openTicket(btn.dataset.id); + }); + + $("#btn-ticket-back")?.addEventListener("click", () => { + loadTickets(); + }); + + $("#ticket-reply-form")?.addEventListener("submit", async (e) => { + e.preventDefault(); + if (!activeTicketId) return; + const fd = new FormData(e.target); + try { + const data = await api(`/api/tickets/${activeTicketId}/messages`, { + method: "POST", + body: JSON.stringify({ body: fd.get("body") }), + }); + e.target.reset(); + renderTicketThread(data); + toast("Отправлено"); + } catch (ex) { + toast(ex.message || "Ошибка"); + } + }); + + $("#btn-ticket-close")?.addEventListener("click", async () => { + if (!activeTicketId) return; + try { + const data = await api(`/api/tickets/${activeTicketId}/close`, { + method: "POST", + body: "{}", + }); + renderTicketThread(data); + toast("Тикет закрыт"); + } catch (ex) { + toast(ex.message || "Ошибка"); + } + }); + + async function loadServers() { + const summary = $("#servers-summary"); + const box = $("#servers-list"); + summary.textContent = "Проверяю…"; + box.innerHTML = ""; + try { + const data = await api("/api/servers"); + summary.textContent = `Онлайн: ${data.online} / ${data.total}`; + data.nodes.forEach((n) => { + const el = document.createElement("article"); + el.className = "server"; + const ping = n.ping_ms != null ? `${Math.round(n.ping_ms)} ms` : "—"; + el.innerHTML = ` +
    + + ${n.name} +
    ${n.country} · ${n.label}
    +
    +
    ${ping}
    + `; + box.appendChild(el); + }); + } catch (e) { + summary.textContent = e.message || "Ошибка"; + } + } + + async function loadMe() { + state.me = await api("/api/me"); + state.authed = true; + if (state._forceResetUi && state._resetToken) { + // Ссылка сброса из письма — не прячем форму, пока не сохранят пароль + showAuth(true); + setAuthMode("reset"); + } else { + showAuth(false); + } + renderHome(); + loadStarsPacks(); + renderWebTopup(); + loadPasskeys().catch(() => {}); + syncEmailUi(); + loadReferral().catch(() => {}); + loadNotifyPrefs().catch(() => {}); + loadHwid().catch(() => {}); + const fromHash = (location.hash || "").replace(/^#/, ""); + let saved = ""; + try { + saved = localStorage.getItem("cabinet_tab") || ""; + } catch (_) {} + const initial = fromHash || saved || "home"; + if (initial !== "home") switchTab(initial); + } + + async function loadReferral() { + const el = $("#referral-link"); + if (!el) return; + try { + const data = await api("/api/referral"); + el.textContent = data.telegram_link || data.web_link || data.code || "—"; + el.dataset.copy = data.telegram_link || data.web_link || data.code || ""; + const hint = $("#referral-bonus-hint"); + if (hint) { + hint.textContent = `Бонус: вам ${data.bonus_referee} ₽ · другу ${data.bonus_referrer} ₽ после первой покупки`; + } + } catch { + el.textContent = "—"; + } + } + + async function loadNotifyPrefs() { + try { + const data = await api("/api/account/notifications"); + if ($("#notify-tg-toggle")) $("#notify-tg-toggle").checked = !!data.notify_telegram; + if ($("#notify-email-toggle")) $("#notify-email-toggle").checked = !!data.notify_email; + } catch { + /* ignore */ + } + } + + async function loadHwid() { + const list = $("#hwid-list"); + if (!list) return; + try { + const data = await api("/api/devices/hwid"); + const devices = data.devices || []; + if (!devices.length) { + list.innerHTML = "
  • Нет привязанных устройств
  • "; + return; + } + list.innerHTML = ""; + devices.forEach((d) => { + const li = document.createElement("li"); + const title = [d.device_model, d.platform, d.os_version].filter(Boolean).join(" · ") || d.hwid; + li.innerHTML = `${title}`; + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "btn ghost small-btn"; + btn.textContent = "Отвязать"; + btn.addEventListener("click", async () => { + try { + await api("/api/devices/hwid/delete", { + method: "POST", + body: JSON.stringify({ hwid: d.hwid }), + }); + toast("Устройство отвязано"); + await loadHwid(); + await loadMe(); + } catch (err) { + toast(err.message || "Ошибка"); + } + }); + li.appendChild(btn); + list.appendChild(li); + }); + } catch { + list.innerHTML = "
  • Не удалось загрузить
  • "; + } + } + + function syncEmailUi() { + const input = $("#account-email"); + if (input) input.value = state.me?.user?.email || ""; + } + + async function loadPasskeys() { + const list = $("#passkey-list"); + if (!list || !state.authed) return; + try { + const data = await api("/api/auth/passkey/list"); + const items = data.passkeys || []; + if (!items.length) { + list.innerHTML = "
  • Passkey ещё не добавлен
  • "; + return; + } + list.innerHTML = ""; + items.forEach((p) => { + const li = document.createElement("li"); + li.innerHTML = `${p.device_name || "Passkey"}`; + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "btn ghost small-btn"; + btn.textContent = "Удалить"; + btn.addEventListener("click", async () => { + try { + await api(`/api/auth/passkey/${encodeURIComponent(p.id)}`, { + method: "DELETE", + }); + toast("Passkey удалён"); + await loadPasskeys(); + } catch (err) { + toast(err.message || "Ошибка"); + } + }); + li.appendChild(btn); + list.appendChild(li); + }); + } catch { + list.innerHTML = "
  • Не удалось загрузить
  • "; + } + } + + function renderWebTopup() { + const card = $("#web-topup-card"); + const presetsBox = $("#web-topup-presets"); + const providersBox = $("#web-topup-providers"); + const hint = $("#web-topup-hint"); + const amountInput = $("#web-topup-amount"); + if (!card || !providersBox) return; + + const topup = state.topup; + if (!topup || !(topup.providers || []).length) { + card.classList.add("hidden"); + return; + } + card.classList.remove("hidden"); + + const minA = topup.min_amount || 10; + const maxA = topup.max_amount || 3000; + if (hint) { + hint.textContent = `От ${minA} до ${maxA} ₽ за одну оплату · зачисление автоматически`; + } + if (amountInput) { + amountInput.min = String(minA); + amountInput.max = String(maxA); + } + + if (presetsBox) { + presetsBox.innerHTML = ""; + (topup.presets || []).forEach((n) => { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "topup-preset-btn"; + btn.textContent = `${n} ₽`; + btn.addEventListener("click", () => { + if (amountInput) amountInput.value = String(n); + presetsBox.querySelectorAll(".topup-preset-btn").forEach((b) => { + b.classList.toggle("active", b === btn); + }); + }); + presetsBox.appendChild(btn); + }); + } + + if (!state.authed) { + providersBox.innerHTML = + '

    Войдите, чтобы пополнить баланс картой, СБП или криптой.

    '; + return; + } + + providersBox.innerHTML = ""; + (topup.providers || []).forEach((p) => { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "btn secondary full"; + btn.innerHTML = `${escapeHtml(p.title)}${escapeHtml(p.hint || "")}`; + btn.addEventListener("click", () => createWebTopup(p.id, btn)); + providersBox.appendChild(btn); + }); + } + + async function createWebTopup(provider, btn) { + if (!state.authed) { + toast("Сначала войдите"); + showAuth(true); + return; + } + const amountInput = $("#web-topup-amount"); + const resultEl = $("#web-topup-result"); + const amount = Number(amountInput?.value || 0); + const minA = state.topup?.min_amount || 10; + const maxA = state.topup?.max_amount || 3000; + if (!Number.isFinite(amount) || amount < minA || amount > maxA) { + toast(`Сумма от ${minA} до ${maxA} ₽`); + return; + } + const old = btn.innerHTML; + btn.disabled = true; + btn.textContent = "Создаю счёт…"; + if (resultEl) { + resultEl.classList.add("hidden"); + resultEl.textContent = ""; + } + try { + const data = await api("/api/topup/invoice", { + method: "POST", + body: JSON.stringify({ provider, amount }), + }); + if (resultEl) { + resultEl.classList.remove("hidden"); + resultEl.innerHTML = `${escapeHtml(data.message || "Счёт создан")}${ + data.pay_url + ? ` · открыть оплату` + : "" + }`; + } + toast(data.message || "Счёт создан"); + if (data.pay_url) { + window.open(data.pay_url, "_blank", "noopener"); + } + } catch (e) { + toast(e.message || "Ошибка"); + } finally { + btn.disabled = false; + btn.innerHTML = old; + } + } + + function renderWgConfigs(data) { + const card = $("#wg-configs-card"); + const list = $("#wg-configs-list"); + const buyBox = $("#wg-buy-box"); + const hint = $("#wg-configs-hint"); + const wgBtn = $("#btn-wg-go"); + if (!card || !list) return; + + if (!data || !data.enabled) { + card.classList.add("hidden"); + if (wgBtn) wgBtn.classList.add("hidden"); + return; + } + + card.classList.remove("hidden"); + if (wgBtn) wgBtn.classList.remove("hidden"); + + const used = data.used || (data.configs || []).length; + const max = data.max_configs || 10; + const priceLabel = data.price_label || (data.price > 0 ? `${data.price} ₽` : "бесплатно"); + const subActive = data.subscription_active !== false; + hint.textContent = subActive + ? `${used} / ${max} · ${priceLabel} · нужна активная подписка` + : `${used} / ${max} · подписка истекла — конфиги отключены`; + + const servers = data.servers || []; + const sel = $("#wg-server-select"); + if (sel) { + sel.innerHTML = servers.length + ? servers + .map( + (s) => + `` + ) + .join("") + : ''; + } + if (buyBox) { + buyBox.classList.toggle("hidden", used >= max || !servers.length || !subActive); + } + + const configs = data.configs || []; + let banner = ""; + if (!subActive) { + banner = `

    ${escapeHtml( + data.subscription_reason || + "Подписка истекла. Продлите тариф — затем снова можно включать конфиги." + )}

    `; + } + if (!configs.length) { + list.innerHTML = + banner + + '

    Конфигов пока нет — выберите сервер выше и нажмите «Создать конфиг»

    '; + return; + } + list.innerHTML = + banner + + configs + .map((c) => { + const status = c.enabled ? "вкл" : "выкл"; + const url = c.subscription_url || ""; + const exp = c.expire_at + ? new Date(c.expire_at).toLocaleString("ru-RU", { + day: "2-digit", + month: "short", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }) + : "—"; + const traffic = c.traffic_label || "1000 ГБ"; + const enableBtn = c.enabled + ? `` + : subActive + ? `` + : ``; + return ` +
    + +
    +
    ${escapeHtml(c.remark || c.server_name || "WG")} + · ${escapeHtml(c.server_name || "")} · ${status} +
    +

    До ${escapeHtml(exp)} · ${escapeHtml(traffic)}

    + ${escapeHtml(url)} +
    + + + ${enableBtn} + +
    +
    +
    `; + }) + .join(""); + loadWgQrImages(list); + } + + function escapeAttr(s) { + return String(s ?? "") + .replace(/&/g, "&") + .replace(/"/g, """) + .replace(//g, ">"); + } + + async function fetchWgQrBlobUrl(configId) { + const headers = {}; + const token = getStoredToken(); + if (token) headers.Authorization = `Bearer ${token}`; + const res = await fetch(`/api/wg/configs/${configId}/qr`, { + headers, + credentials: "same-origin", + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error || `QR HTTP ${res.status}`); + } + const blob = await res.blob(); + return URL.createObjectURL(blob); + } + + async function loadWgQrImages(root) { + const imgs = (root || document).querySelectorAll("img.wg-qr-img[data-qr-id]"); + for (const img of imgs) { + const id = img.getAttribute("data-qr-id"); + if (!id) continue; + try { + const objectUrl = await fetchWgQrBlobUrl(id); + if (img.dataset.objectUrl) URL.revokeObjectURL(img.dataset.objectUrl); + img.dataset.objectUrl = objectUrl; + img.src = objectUrl; + } catch { + img.replaceWith( + Object.assign(document.createElement("span"), { + className: "muted small", + textContent: "QR?", + }) + ); + } + } + } + + async function openWgQrDialog(configId, url, title) { + const dialog = $("#wg-qr-dialog"); + const img = $("#wg-qr-dialog-img"); + const titleEl = $("#wg-qr-dialog-title"); + const urlEl = $("#wg-qr-dialog-url"); + if (!dialog || !img || !configId) { + toast("QR недоступен"); + return; + } + titleEl.textContent = title || "QR WireGuard"; + urlEl.textContent = url || ""; + dialog.dataset.url = url || ""; + img.removeAttribute("src"); + img.alt = "Загрузка QR…"; + try { + const objectUrl = await fetchWgQrBlobUrl(configId); + if (dialog.dataset.objectUrl) URL.revokeObjectURL(dialog.dataset.objectUrl); + dialog.dataset.objectUrl = objectUrl; + img.src = objectUrl; + img.alt = "QR код"; + dialog.showModal(); + } catch (ex) { + toast(ex.message || "Не удалось построить QR"); + } + } + + async function openAwgQrDialog(configId, url, title) { + const dialog = $("#wg-qr-dialog"); + const img = $("#wg-qr-dialog-img"); + const titleEl = $("#wg-qr-dialog-title"); + const urlEl = $("#wg-qr-dialog-url"); + if (!dialog || !img || !configId) { + toast("QR недоступен"); + return; + } + titleEl.textContent = title || "QR AmneziaWG"; + urlEl.textContent = url || ""; + dialog.dataset.url = url || ""; + img.removeAttribute("src"); + img.alt = "Загрузка QR…"; + try { + const objectUrl = await fetchAwgQrBlobUrl(configId); + if (dialog.dataset.objectUrl) URL.revokeObjectURL(dialog.dataset.objectUrl); + dialog.dataset.objectUrl = objectUrl; + img.src = objectUrl; + img.alt = "QR код"; + dialog.showModal(); + } catch (ex) { + toast(ex.message || "Не удалось построить QR"); + } + } + + async function loadWgConfigs() { + const card = $("#wg-configs-card"); + if (!card) return; + try { + const data = await api("/api/wg/configs"); + renderWgConfigs(data); + } catch { + card.classList.add("hidden"); + $("#btn-wg-go")?.classList.add("hidden"); + } + } + + function renderAwgConfigs(data) { + const card = $("#awg-configs-card"); + const list = $("#awg-configs-list"); + const buyBox = $("#awg-buy-box"); + const hint = $("#awg-configs-hint"); + const awgBtn = $("#btn-awg-go"); + if (!card || !list) return; + + if (!data || !data.enabled) { + card.classList.add("hidden"); + if (awgBtn) awgBtn.classList.add("hidden"); + return; + } + + card.classList.remove("hidden"); + if (awgBtn) awgBtn.classList.remove("hidden"); + + const used = data.used || (data.configs || []).length; + const max = data.max_configs || 10; + const priceLabel = data.price_label || (data.price > 0 ? `${data.price} ₽` : "бесплатно"); + const subActive = data.subscription_active !== false; + hint.textContent = subActive + ? `${used} / ${max} · ${priceLabel} · AmneziaVPN · активная подписка` + : `${used} / ${max} · подписка истекла — конфиги отключены`; + + const servers = data.servers || []; + const sel = $("#awg-server-select"); + if (sel) { + sel.innerHTML = servers.length + ? servers + .map( + (s) => + `` + ) + .join("") + : ''; + } + if (buyBox) { + buyBox.classList.toggle("hidden", used >= max || !servers.length || !subActive); + } + + const configs = data.configs || []; + let banner = ""; + if (!subActive) { + banner = `

    ${escapeHtml( + data.subscription_reason || + "Подписка истекла. Продлите тариф — затем снова можно включать конфиги." + )}

    `; + } + if (!configs.length) { + list.innerHTML = + banner + + '

    Конфигов пока нет — выберите сервер и создайте AWG-конфиг

    '; + return; + } + list.innerHTML = + banner + + configs + .map((c) => { + const status = c.enabled ? "вкл" : "выкл"; + const link = c.vpn_link || c.config || ""; + const exp = c.expire_at + ? new Date(c.expire_at).toLocaleString("ru-RU", { + day: "2-digit", + month: "short", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }) + : "—"; + const enableBtn = c.enabled + ? `` + : subActive + ? `` + : ``; + return ` +
    + +
    +
    ${escapeHtml(c.remark || c.client_name || c.server_name || "AWG")} + · ${escapeHtml(c.server_name || "")} · ${status} +
    +

    До ${escapeHtml(exp)} · AmneziaWG 2.0

    + ${escapeHtml(link.slice(0, 64))}${link.length > 64 ? "…" : ""} +
    + + + ${enableBtn} + +
    +
    +
    `; + }) + .join(""); + loadAwgQrImages(list); + } + + async function fetchAwgQrBlobUrl(configId) { + const headers = {}; + const token = getStoredToken(); + if (token) headers.Authorization = `Bearer ${token}`; + if (initData) headers["X-Telegram-Init-Data"] = initData; + const res = await fetch(`/api/awg/configs/${configId}/qr`, { + credentials: "same-origin", + headers, + }); + if (!res.ok) throw new Error("QR недоступен"); + const blob = await res.blob(); + return URL.createObjectURL(blob); + } + + async function loadAwgQrImages(root) { + const imgs = (root || document).querySelectorAll("img.wg-qr-img[data-awg-qr-id]"); + for (const img of imgs) { + const id = img.getAttribute("data-awg-qr-id"); + if (!id) continue; + try { + const url = await fetchAwgQrBlobUrl(id); + img.src = url; + } catch { + img.alt = "QR н/д"; + } + } + } + + async function loadAwgConfigs() { + const card = $("#awg-configs-card"); + if (!card) return; + try { + const data = await api("/api/awg/configs"); + renderAwgConfigs(data); + } catch { + card.classList.add("hidden"); + $("#btn-awg-go")?.classList.add("hidden"); + } + } + + async function loadPublic() { + const t = await api("/api/tariffs"); + state.tariffs = t.tariffs || []; + const c = await api("/api/clients"); + renderClients(c.clients || []); + } + + function loadTelegramLoginSdk() { + if (window.Telegram?.Login?.auth) { + return Promise.resolve(); + } + if (loadTelegramLoginSdk._p) return loadTelegramLoginSdk._p; + loadTelegramLoginSdk._p = new Promise((resolve, reject) => { + const existing = document.querySelector('script[data-telegram-login-sdk]'); + if (existing) { + existing.addEventListener("load", () => resolve()); + existing.addEventListener("error", () => reject(new Error("Telegram Login SDK"))); + return; + } + const script = document.createElement("script"); + script.src = "https://telegram.org/js/telegram-login.js"; + script.async = true; + script.dataset.telegramLoginSdk = "1"; + script.onload = () => resolve(); + script.onerror = () => reject(new Error("Не удалось загрузить Telegram Login SDK")); + document.head.appendChild(script); + }); + return loadTelegramLoginSdk._p; + } + + function mountTelegramWidget() { + const slot = $("#telegram-login-slot"); + if (!slot) return; + slot.innerHTML = ""; + const clientId = String(state.telegramClientId || "").trim(); + if (!clientId) { + slot.innerHTML = + '

    Telegram Login недоступен (нет Client ID бота)

    '; + return; + } + + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "btn telegram-oidc-btn full"; + btn.innerHTML = ' Войти через Telegram'; + btn.addEventListener("click", async () => { + btn.disabled = true; + try { + await loadTelegramLoginSdk(); + const login = window.Telegram?.Login; + if (!login?.auth) { + throw new Error("Telegram Login SDK не готов"); + } + login.auth( + { + client_id: Number(clientId), + scope: ["profile", "write"], + lang: "ru", + }, + async (data) => { + try { + if (!data || data.error) { + throw new Error(data?.error || "Вход отменён"); + } + const idToken = data.id_token; + if (!idToken) { + throw new Error("Telegram не вернул id_token"); + } + await api("/api/auth/telegram", { + method: "POST", + body: JSON.stringify({ id_token: idToken }), + }); + toast("Вход через Telegram выполнен"); + await loadMe(); + renderTariffs(); + } catch (e) { + toast(e.message || "Ошибка Telegram-входа"); + } finally { + btn.disabled = false; + } + } + ); + } catch (e) { + toast(e.message || "Ошибка Telegram-входа"); + btn.disabled = false; + } + }); + slot.appendChild(btn); + } + + $("#form-login").addEventListener("submit", async (e) => { + e.preventDefault(); + const fd = new FormData(e.target); + try { + await api("/api/auth/login", { + method: "POST", + body: JSON.stringify({ + login: fd.get("login"), + password: fd.get("password"), + }), + }); + toast("Вы вошли"); + await loadMe(); + renderTariffs(); + } catch (err) { + toast(err.message || "Ошибка входа"); + } + }); + + $("#form-register").addEventListener("submit", async (e) => { + e.preventDefault(); + const fd = new FormData(e.target); + if (fd.get("accept_terms") !== "on") { + toast("Нужно принять оферту и правила сервиса"); + return; + } + try { + await api("/api/auth/register", { + method: "POST", + body: JSON.stringify({ + login: fd.get("login"), + password: fd.get("password"), + full_name: fd.get("full_name") || "", + auto_renew: fd.get("auto_renew") === "on", + accept_terms: true, + }), + }); + toast("Аккаунт создан"); + await loadMe(); + renderTariffs(); + } catch (err) { + toast(err.message || "Ошибка регистрации"); + } + }); + + async function startQrAuth() { + stopQrPoll(); + try { + const data = await api("/api/auth/qr/start", { + method: "POST", + body: "{}", + }); + const box = $("#qr-auth-box"); + const img = $("#qr-auth-img"); + const link = $("#qr-auth-link"); + const status = $("#qr-auth-status"); + box?.classList.remove("hidden"); + if (img) img.src = data.qr_svg || ""; + if (link) { + link.href = data.deep_link || data.web_approve_url || "#"; + link.classList.toggle("hidden", !(data.deep_link || data.web_approve_url)); + } + if (status) status.textContent = "Ожидание подтверждения в Telegram…"; + const token = data.token; + qrPollTimer = setInterval(async () => { + try { + const poll = await api( + `/api/auth/qr/poll?token=${encodeURIComponent(token)}` + ); + if (poll.status === "approved") { + stopQrPoll(); + if (poll.token) setStoredToken(poll.token); + toast("Вход по QR выполнен"); + await loadMe(); + renderTariffs(); + } else if (poll.status === "expired" || poll.status === "consumed") { + stopQrPoll(); + if (status) status.textContent = "QR истёк — нажмите кнопку снова"; + } + } catch { + /* keep polling */ + } + }, 2000); + } catch (err) { + toast(err.message || "Не удалось создать QR"); + } + } + + $("#btn-auth-qr")?.addEventListener("click", startQrAuth); + $("#btn-qr-cancel")?.addEventListener("click", stopQrPoll); + + async function loginWithPasskey() { + if (!window.PublicKeyCredential) { + toast("Passkey не поддерживается в этом браузере"); + return; + } + try { + const login = ($("#form-login input[name=login]")?.value || "").trim(); + const opt = await api("/api/auth/passkey/login/options", { + method: "POST", + body: JSON.stringify(login ? { login } : {}), + }); + const cred = await navigator.credentials.get({ + publicKey: preparePublicKeyGet(opt.options), + }); + if (!cred) { + toast("Вход отменён"); + return; + } + await api("/api/auth/passkey/login/verify", { + method: "POST", + body: JSON.stringify({ credential: credentialToJSON(cred) }), + }); + toast("Вход с Passkey выполнен"); + await loadMe(); + renderTariffs(); + } catch (err) { + toast(err.message || "Ошибка Passkey"); + } + } + + $("#btn-auth-passkey")?.addEventListener("click", loginWithPasskey); + + $("#btn-passkey-add")?.addEventListener("click", async () => { + if (!window.PublicKeyCredential) { + toast("Passkey не поддерживается"); + return; + } + try { + const opt = await api("/api/auth/passkey/register/options", { + method: "POST", + body: "{}", + }); + const cred = await navigator.credentials.create({ + publicKey: preparePublicKeyCreate(opt.options), + }); + if (!cred) { + toast("Отменено"); + return; + } + await api("/api/auth/passkey/register/verify", { + method: "POST", + body: JSON.stringify({ + credential: credentialToJSON(cred), + device_name: navigator.platform || "Устройство", + }), + }); + toast("Passkey добавлен"); + await loadPasskeys(); + } catch (err) { + toast(err.message || "Не удалось добавить Passkey"); + } + }); + + $("#form-email")?.addEventListener("submit", async (e) => { + e.preventDefault(); + try { + const data = await api("/api/account/email", { + method: "POST", + body: JSON.stringify({ + email: ($("#account-email")?.value || "").trim(), + }), + }); + if (state.me?.user && data.user) { + state.me.user.email = data.user.email; + } + syncEmailUi(); + toast("Email сохранён"); + } catch (err) { + toast(err.message || "Не удалось сохранить email"); + } + }); + + $("#btn-ref-copy")?.addEventListener("click", async () => { + const text = $("#referral-link")?.dataset?.copy || $("#referral-link")?.textContent || ""; + const ok = await copyText(text); + toast(ok ? "Ссылка скопирована" : "Не удалось скопировать"); + }); + + $("#btn-trial")?.addEventListener("click", async () => { + try { + const data = await api("/api/trial", { method: "POST", body: "{}" }); + toast(data.message || "Пробный период активирован"); + await loadMe(); + } catch (err) { + toast(err.message || "Недоступно"); + } + }); + + $("#btn-hwid-refresh")?.addEventListener("click", () => loadHwid()); + + $("#btn-logout-all")?.addEventListener("click", async () => { + try { + const data = await api("/api/account/sessions/logout-all", { + method: "POST", + body: "{}", + }); + toast(`Закрыто сессий: ${data.deleted || 0}`); + } catch (err) { + toast(err.message || "Ошибка"); + } + }); + + async function saveNotify() { + try { + await api("/api/account/notifications", { + method: "POST", + body: JSON.stringify({ + notify_telegram: !!$("#notify-tg-toggle")?.checked, + notify_email: !!$("#notify-email-toggle")?.checked, + }), + }); + toast("Настройки уведомлений сохранены"); + } catch (err) { + toast(err.message || "Ошибка"); + } + } + $("#notify-tg-toggle")?.addEventListener("change", saveNotify); + $("#notify-email-toggle")?.addEventListener("change", saveNotify); + + $("#btn-forgot-password")?.addEventListener("click", (e) => { + e.preventDefault(); + e.stopPropagation(); + showAuth(true); + setAuthMode("forgot"); + }); + $("#btn-forgot-back")?.addEventListener("click", () => { + setAuthMode("login"); + }); + $("#form-forgot")?.addEventListener("submit", async (e) => { + e.preventDefault(); + const fd = new FormData(e.target); + const status = $("#forgot-status"); + const btn = $("#btn-forgot-submit"); + if (status) status.textContent = "Отправка…"; + if (btn) btn.disabled = true; + try { + const data = await api("/api/auth/password/forgot", { + method: "POST", + body: JSON.stringify({ email: fd.get("email") }), + }); + const msg = + data.message || + "Если email найден — отправили ссылку. Проверьте почту и «Спам»."; + if (status) status.textContent = msg; + toast(msg); + } catch (err) { + const msg = err.message || "Ошибка"; + if (status) status.textContent = msg; + toast(msg); + } finally { + if (btn) btn.disabled = false; + } + }); + $("#form-reset")?.addEventListener("submit", async (e) => { + e.preventDefault(); + const fd = new FormData(e.target); + const params = new URLSearchParams(location.search); + const status = $("#reset-status"); + if (status) status.textContent = "Сохранение…"; + try { + await api("/api/auth/password/reset", { + method: "POST", + body: JSON.stringify({ + token: params.get("reset") || state._resetToken, + password: fd.get("password"), + }), + }); + toast("Пароль обновлён"); + if (status) status.textContent = "Готово — вы вошли"; + state._resetToken = ""; + state._forceResetUi = false; + history.replaceState({}, "", location.pathname); + await loadMe(); + renderTariffs(); + } catch (err) { + const msg = err.message || "Ошибка сброса"; + if (status) status.textContent = msg; + toast(msg); + } + }); + + // ref + reset from URL + (async () => { + const params = new URLSearchParams(location.search); + const ref = params.get("ref"); + if (ref) { + try { + document.cookie = `vpn_ref=${encodeURIComponent(ref)};path=/;max-age=604800;samesite=lax`; + } catch { + /* ignore */ + } + } + const reset = params.get("reset"); + if (reset) { + state._resetToken = reset; + state._forceResetUi = true; + showAuth(true); + setAuthMode("reset"); + } + })(); + + // Подтверждение QR с другого уже вошедшего устройства + (async () => { + const params = new URLSearchParams(location.search); + const approveToken = params.get("qr_approve"); + if (!approveToken) return; + try { + await api("/api/auth/qr/approve", { + method: "POST", + body: JSON.stringify({ token: approveToken }), + }); + toast("Вход на другом устройстве подтверждён"); + history.replaceState({}, "", location.pathname); + } catch (err) { + state._pendingQrApprove = approveToken; + toast(err.message || "Войдите, чтобы подтвердить QR"); + } + })(); + $("#btn-logout").addEventListener("click", async () => { + stopQrPoll(); + try { + await api("/api/auth/logout", { method: "POST", body: "{}" }); + } catch { + /* ignore */ + } + setStoredToken(""); + state.me = null; + state.authed = false; + showAuth(true); + renderTariffs(); + renderWebTopup(); + toast("Вы вышли"); + }); + + $("#refresh-servers").addEventListener("click", loadServers); + $("#copy-sub").addEventListener("click", async () => { + const url = $("#sub-url").textContent; + const ok = await copyText(url); + toast(ok ? "Ссылка скопирована" : "Не удалось скопировать"); + }); + $("#btn-renew-go").addEventListener("click", () => switchTab("tariffs")); + $("#btn-onboarding-tariffs")?.addEventListener("click", () => switchTab("tariffs")); + $("#balance-pill")?.addEventListener("click", () => { + if (state.authed) switchTab("balance"); + }); + + $("#auto-renew-toggle")?.addEventListener("change", async (e) => { + const enabled = !!e.target.checked; + const prev = !!state.me?.user?.auto_renew; + if (state.me?.user) state.me.user.auto_renew = enabled; + syncAutoRenewUi(); + try { + const data = await api("/api/auto-renew", { + method: "POST", + body: JSON.stringify({ enabled }), + }); + if (state.me?.user) { + state.me.user.auto_renew = !!data.auto_renew; + } + syncAutoRenewUi(); + toast(enabled ? "Автопродление включено" : "Автопродление выключено"); + } catch (err) { + if (state.me?.user) { + state.me.user.auto_renew = prev; + } + syncAutoRenewUi(); + toast(err.message || "Не удалось сохранить"); + } + }); + $("#btn-traffic-go").addEventListener("click", () => { + loadTrafficPacks(); + switchTab("extras"); + }); + $("#btn-devices-go").addEventListener("click", () => { + loadDevicePacks(); + switchTab("extras"); + }); + $("#btn-wg-go")?.addEventListener("click", () => { + loadWgConfigs(); + switchTab("configs"); + }); + $("#btn-awg-go")?.addEventListener("click", () => { + loadAwgConfigs(); + switchTab("configs"); + }); + $("#btn-wg-buy")?.addEventListener("click", async () => { + const serverId = Number($("#wg-server-select")?.value || 0); + if (!serverId) { + toast("Выберите сервер"); + return; + } + try { + const data = await api("/api/wg/configs", { + method: "POST", + body: JSON.stringify({ + server_id: serverId, + remark: ($("#wg-remark")?.value || "").trim(), + }), + }); + if (data.balance != null && state.me) { + state.me.balance = data.balance; + setBalancePill(data.balance); + } + await loadWgConfigs(); + toast("Конфиг создан"); + if ($("#wg-remark")) $("#wg-remark").value = ""; + } catch (ex) { + toast((ex.message || "Ошибка").replace(/<[^>]+>/g, "")); + } + }); + $("#btn-awg-buy")?.addEventListener("click", async () => { + const serverId = Number($("#awg-server-select")?.value || 0); + if (!serverId) { + toast("Выберите сервер"); + return; + } + try { + const data = await api("/api/awg/configs", { + method: "POST", + body: JSON.stringify({ + server_id: serverId, + remark: ($("#awg-remark")?.value || "").trim(), + }), + }); + if (data.balance != null && state.me) { + state.me.balance = data.balance; + setBalancePill(data.balance); + } + await loadAwgConfigs(); + toast("AWG-конфиг создан"); + if ($("#awg-remark")) $("#awg-remark").value = ""; + } catch (ex) { + toast((ex.message || "Ошибка").replace(/<[^>]+>/g, "")); + } + }); + $("#wg-configs-list")?.addEventListener("click", async (e) => { + const btn = e.target.closest("[data-wg]"); + if (!btn) return; + const row = btn.closest(".wg-config-row"); + if (!row) return; + const id = row.dataset.id; + const act = btn.getAttribute("data-wg"); + try { + if (act === "qr") { + const url = row.dataset.url || row.querySelector(".wg-sub-url")?.textContent || ""; + const title = row.querySelector("strong")?.textContent || "WireGuard"; + await openWgQrDialog(id, url, title); + return; + } + if (act === "copy") { + const url = row.dataset.url || row.querySelector(".wg-sub-url")?.textContent || ""; + const ok = await copyText(url); + toast(ok ? "Ссылка скопирована" : "Не удалось скопировать"); + return; + } + if (act === "delete") { + if (!confirm("Удалить конфиг? Слот освободится.")) return; + await api(`/api/wg/configs/${id}`, { method: "DELETE" }); + toast("Удалено"); + await loadWgConfigs(); + return; + } + if (act === "enable" || act === "disable") { + await api(`/api/wg/configs/${id}/${act}`, { method: "POST", body: "{}" }); + toast(act === "enable" ? "Включено" : "Выключено"); + await loadWgConfigs(); + } + } catch (ex) { + toast(ex.message || "Ошибка"); + } + }); + + $("#awg-configs-list")?.addEventListener("click", async (e) => { + const btn = e.target.closest("[data-awg]"); + if (!btn) return; + const row = btn.closest(".wg-config-row"); + if (!row) return; + const id = row.dataset.id; + const act = btn.getAttribute("data-awg"); + try { + if (act === "copy") { + const url = row.dataset.url || ""; + const ok = await copyText(url); + toast(ok ? "Ссылка скопирована" : "Не удалось скопировать"); + return; + } + if (act === "qr") { + const url = row.dataset.url || ""; + const title = row.querySelector("strong")?.textContent || "AWG"; + await openAwgQrDialog(id, url, title); + return; + } + if (act === "delete") { + if (!confirm("Удалить AWG-конфиг? Слот освободится.")) return; + await api(`/api/awg/configs/${id}`, { method: "DELETE" }); + toast("Удалено"); + await loadAwgConfigs(); + return; + } + if (act === "enable" || act === "disable") { + await api(`/api/awg/configs/${id}/${act}`, { method: "POST", body: "{}" }); + toast(act === "enable" ? "Включено" : "Выключено"); + await loadAwgConfigs(); + } + } catch (ex) { + toast(ex.message || "Ошибка"); + } + }); + + $("#btn-wg-qr-copy")?.addEventListener("click", async () => { + const url = $("#wg-qr-dialog")?.dataset.url || $("#wg-qr-dialog-url")?.textContent || ""; + if (!url) return; + const ok = await copyText(url); + toast(ok ? "Ссылка скопирована" : "Не удалось скопировать"); + }); + + function setupTopupCode(payUrl) { + const buy = $("#topup-code-buy"); + const hint = $("#topup-code-hint"); + if (hint) { + hint.innerHTML = payUrl + ? "Карта, СБП и др. — на странице покупки 40+ способов оплаты. После оплаты введите код ниже." + : "Для карт и СБП купите код в боте («Баланс»). Напрямую без кода — только крипта."; + } + if (!buy) return; + if (payUrl) { + buy.href = payUrl; + buy.classList.remove("hidden"); + } else { + buy.classList.add("hidden"); + } + } + + $("#tx-prev")?.addEventListener("click", () => { + if (txPage > 1) loadTransactions(txPage - 1); + }); + $("#tx-next")?.addEventListener("click", () => { + loadTransactions(txPage + 1); + }); + + const topupForm = $("#form-topup-code"); + if (topupForm) { + topupForm.addEventListener("submit", async (e) => { + e.preventDefault(); + if (!state.authed) { + toast("Сначала войдите в аккаунт"); + showAuth(true); + return; + } + const fd = new FormData(e.target); + try { + const data = await api("/api/topup/redeem", { + method: "POST", + body: JSON.stringify({ code: fd.get("code") }), + }); + toast(data.message || "Баланс пополнен"); + e.target.reset(); + await loadMe(); + txPage = 1; + await loadTransactions(1); + } catch (err) { + toast(err.message || "Не удалось активировать код"); + } + }); + } + + async function boot() { + try { + const cfg = await api("/api/config"); + state.botUsername = cfg.bot_username || ""; + state.telegramClientId = cfg.telegram_client_id || ""; + state.starsEnabled = !!cfg.stars_enabled; + state.starsRubPerStar = Number(cfg.stars_rub_per_star) || 1.5; + state.topup = cfg.topup || null; + setupTopupCode(cfg.topup_code_pay_url || ""); + renderWebTopup(); + } catch { + state.botUsername = ""; + state.telegramClientId = ""; + state.starsEnabled = false; + state.topup = null; + setupTopupCode(""); + renderWebTopup(); + } + + try { + await loadPublic(); + } catch (e) { + toast(e.message || "Не удалось загрузить каталог"); + } + + if (initData) { + try { + await loadMe(); + renderTariffs(); + } catch (e) { + $("#user-name").textContent = "Ошибка"; + toast(e.message || "Не удалось войти через Telegram"); + showAuth(true); + if (state._forceResetUi) setAuthMode("reset"); + else setAuthMode("login"); + mountTelegramWidget(); + renderTariffs(); + loadStarsPacks(); + } + return; + } + + // Браузер: пробуем сессию cookie/token + try { + await loadMe(); + renderTariffs(); + } catch { + showAuth(true); + if (state._forceResetUi) setAuthMode("reset"); + else setAuthMode("login"); + mountTelegramWidget(); + renderTariffs(); + loadStarsPacks(); + } + } + + boot(); +})(); diff --git a/webapp/static/styles.css b/webapp/static/styles.css new file mode 100644 index 0000000..f17f345 --- /dev/null +++ b/webapp/static/styles.css @@ -0,0 +1,1253 @@ +/* VPN Service UI — compact redesign with light / dark themes */ + +html[data-theme="dark"], +:root { + --bg: #0a0f0d; + --bg-2: #101714; + --card: #141c18; + --card-2: #1a2420; + --line: rgba(180, 220, 200, 0.12); + --line-strong: rgba(61, 207, 154, 0.35); + --text: #eaf3ee; + --muted: #8fa59a; + --accent: #3dcf9a; + --accent-2: #1a9d6e; + --accent-soft: rgba(61, 207, 154, 0.12); + --danger: #ff6b6b; + --warn: #e6b84d; + --ok: #3dcf9a; + --shadow: 0 10px 28px rgba(0, 0, 0, 0.35); + --shadow-btn: 0 6px 16px rgba(26, 157, 110, 0.28); + --radius: 14px; + --radius-sm: 10px; + --header-tg: #0a0f0d; +} + +html[data-theme="light"] { + --bg: #eef3f0; + --bg-2: #e4ebe7; + --card: #ffffff; + --card-2: #f7faf8; + --line: rgba(20, 40, 30, 0.1); + --line-strong: rgba(13, 159, 110, 0.35); + --text: #12201a; + --muted: #5c7167; + --accent: #0d9f6e; + --accent-2: #0a7a54; + --accent-soft: rgba(13, 159, 110, 0.1); + --danger: #d64545; + --warn: #c48a12; + --ok: #0d9f6e; + --shadow: 0 8px 22px rgba(18, 40, 30, 0.08); + --shadow-btn: 0 6px 14px rgba(13, 159, 110, 0.22); + --radius: 14px; + --radius-sm: 10px; + --header-tg: #eef3f0; +} + +*, +*::before, +*::after { box-sizing: border-box; } + +html, body { + margin: 0; + min-height: 100%; + background: var(--bg); + color: var(--text); + font-family: "Sora", system-ui, sans-serif; + font-size: 14px; + line-height: 1.4; + -webkit-font-smoothing: antialiased; +} + +body { + padding: max(10px, env(safe-area-inset-top)) 10px max(18px, env(safe-area-inset-bottom)); +} + +.app-bg { + position: fixed; + inset: 0; + z-index: -1; + pointer-events: none; + background: + radial-gradient(ellipse 90% 50% at 50% -15%, color-mix(in srgb, var(--accent) 18%, transparent), transparent 55%), + var(--bg); +} + +#app { + max-width: 1080px; + margin: 0 auto; + padding: 0 4px 28px; +} + +.hidden { display: none !important; } +.muted { color: var(--muted); } +.small { font-size: 12px; margin: 0; } +.link-accent { color: var(--accent); font-weight: 600; text-decoration: none; } +.link-accent:hover { text-decoration: underline; } + +/* ——— Header ——— */ +.top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 10px; +} + +.header-brand { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.brand-mark { + width: 34px; + height: 34px; + display: grid; + place-items: center; + font-size: 17px; + border-radius: 10px; + background: var(--accent-soft); + border: 1px solid var(--line-strong); + flex-shrink: 0; +} + +.eyebrow { + margin: 0; + font-size: 9px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--accent); + font-weight: 700; +} + +.top h1, +#user-name { + margin: 0; + font-size: 15px; + font-weight: 700; + line-height: 1.2; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 46vw; +} + +.header-actions { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} + +.balance-pill { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 6px 10px; + border-radius: 999px; + font-size: 12px; + font-weight: 700; + color: var(--accent); + background: var(--accent-soft); + border: 1px solid var(--line-strong); + cursor: pointer; + white-space: nowrap; +} + +.theme-toggle { + width: 34px; + height: 34px; + display: grid; + place-items: center; + border-radius: 10px; + border: 1px solid var(--line); + background: var(--card); + color: var(--text); + cursor: pointer; + padding: 0; + font-size: 14px; + transition: background 0.15s, border-color 0.15s, color 0.15s; +} + +.theme-toggle:hover { + border-color: var(--line-strong); + color: var(--accent); +} + +html[data-theme="dark"] .theme-toggle .icon-sun { display: none; } +html[data-theme="light"] .theme-toggle .icon-moon { display: none; } + +/* ——— Buttons ——— */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + border: 0; + border-radius: var(--radius-sm); + padding: 8px 12px; + font: inherit; + font-size: 12.5px; + font-weight: 600; + line-height: 1.2; + cursor: pointer; + text-decoration: none; + text-align: center; + max-width: 100%; + transition: transform 0.12s, background 0.15s, border-color 0.15s, opacity 0.15s; + -webkit-tap-highlight-color: transparent; +} + +.btn:active:not(:disabled) { transform: scale(0.98); } +.btn:disabled { opacity: 0.45; cursor: not-allowed; } + +.btn.primary { + background: linear-gradient(135deg, var(--accent), var(--accent-2)); + color: #04110c; + box-shadow: var(--shadow-btn); +} + +html[data-theme="light"] .btn.primary { color: #fff; } + +.btn.secondary { + background: var(--accent-soft); + color: var(--text); + border: 1px solid var(--line-strong); +} + +.btn.ghost { + background: transparent; + color: var(--text); + border: 1px solid var(--line); +} + +.btn.ghost:hover:not(:disabled) { + background: var(--accent-soft); + border-color: var(--line-strong); +} + +.btn.full { width: 100%; display: flex; } +.btn-lg { padding: 10px 14px; font-size: 13.5px; } +.small-btn { padding: 5px 9px; font-size: 11.5px; border-radius: 8px; white-space: nowrap; } +.btn-icon { width: 34px; min-width: 34px; padding: 0; } +.btn.full:not(.btn-stack .btn) { margin-top: 6px; } +.btn-stack { display: grid; gap: 6px; margin-top: 8px; } +.btn-stack .btn.full { margin-top: 0; } +.btn.secondary.full strong { display: block; font-size: 12.5px; } +.btn.secondary.full .btn-sub, +.btn.secondary.full small.btn-sub { + display: block; + margin-top: 1px; + font-size: 11px; + font-weight: 450; + color: var(--muted); +} + +/* ——— Auth ——— */ +.auth-panel { + position: relative; + overflow: hidden; + max-width: 420px; + margin: 0 auto 12px; + padding: 16px 14px; + border-radius: var(--radius); + background: var(--card); + border: 1px solid var(--line); + box-shadow: var(--shadow); +} + +.auth-panel-glow { + position: absolute; + top: -50px; + right: -30px; + width: 140px; + height: 140px; + background: radial-gradient(circle, color-mix(in srgb, var(--accent) 35%, transparent), transparent 70%); + pointer-events: none; +} + +.auth-brand-row { text-align: center; margin-bottom: 6px; } +.auth-fox { font-size: 28px; } +.auth-welcome { + margin: 0; + text-align: center; + font-size: 18px; + font-weight: 700; +} +.auth-sub { text-align: center; margin: 4px 0 12px; } + +.auth-tabs { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 4px; + padding: 3px; + margin-bottom: 12px; + border-radius: 10px; + background: var(--bg-2); + border: 1px solid var(--line); +} + +.auth-tab { + border: 0; + background: transparent; + color: var(--muted); + padding: 8px; + border-radius: 8px; + font: inherit; + font-size: 12.5px; + font-weight: 600; + cursor: pointer; +} + +.auth-tab.active { + background: var(--card); + color: var(--text); + box-shadow: var(--shadow); +} + +.auth-form { display: grid; gap: 8px; } +.auth-form label { + display: grid; + gap: 4px; + font-size: 12px; + font-weight: 500; + color: var(--muted); +} + +.auth-form input, +.auth-form select, +.auth-form textarea, +select, +textarea, +input[type="text"], +input[type="password"], +input[type="email"], +input[type="number"], +input[type="url"] { + width: 100%; + border: 1px solid var(--line); + background: var(--bg-2); + color: var(--text); + border-radius: var(--radius-sm); + padding: 9px 11px; + font: inherit; + font-size: 14px; +} + +.auth-form input:focus, +select:focus, +textarea:focus { + outline: none; + border-color: var(--line-strong); + box-shadow: 0 0 0 3px var(--accent-soft); +} + +.auth-check { + display: flex !important; + align-items: flex-start; + gap: 8px; + grid-template-columns: none !important; +} + +.auth-check input { width: auto; margin-top: 2px; } +.auth-divider { + display: flex; + align-items: center; + gap: 10px; + margin: 12px 0; + color: var(--muted); + font-size: 12px; +} +.auth-divider::before, +.auth-divider::after { + content: ""; + flex: 1; + height: 1px; + background: var(--line); +} +.auth-alt-actions { display: grid; gap: 6px; } +.auth-foot { margin: 12px 0 0; text-align: center; } +.site-legal-links { line-height: 1.7; } +.site-footer { + margin: 28px auto 12px; + max-width: 920px; + padding: 0 16px 24px; + text-align: center; + line-height: 1.75; + opacity: 0.78; +} +.site-footer a { color: var(--accent, #3dcf9a); text-decoration: none; } +.site-footer a:hover { text-decoration: underline; } +.qr-auth-box { text-align: center; margin-top: 10px; } +.qr-auth-img { max-width: 200px; width: 100%; border-radius: 12px; background: #fff; padding: 8px; } +.telegram-login-wrap { text-align: center; margin-top: 10px; } + +.telegram-oidc-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + background: #2aabee !important; + border-color: #2aabee !important; + color: #fff !important; + font-weight: 650; +} +.telegram-oidc-btn:hover { + filter: brightness(1.06); +} +.telegram-oidc-btn:disabled { + opacity: 0.65; +} + +.guest-banner { + display: flex; + gap: 8px; + align-items: flex-start; + margin: 0 0 10px; + padding: 10px 12px; + border-radius: var(--radius-sm); + border: 1px solid var(--line-strong); + background: var(--accent-soft); + color: var(--muted); + font-size: 12.5px; +} + +/* ——— Cabinet (как админка: разделы слева) ——— */ +.cabinet { + display: grid; + grid-template-columns: 200px minmax(0, 1fr); + gap: 14px; + align-items: start; +} + +.cabinet-sidebar { + position: sticky; + top: 8px; + display: flex; + flex-direction: column; + gap: 8px; + padding: 12px 10px; + border-radius: var(--radius); + background: var(--card); + border: 1px solid var(--line); + box-shadow: var(--shadow); + max-height: calc(100vh - 16px); + overflow: hidden; +} + +.cabinet-sidebar-brand { + padding: 2px 8px 8px; + border-bottom: 1px solid var(--line); + margin-bottom: 2px; +} + +.cabinet-sidebar-kicker { + display: block; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--muted); + margin-bottom: 2px; +} + +.cabinet-sidebar-title { + font-size: 14px; + font-weight: 700; +} + +.cabinet-main { + min-width: 0; + width: 100%; +} + +.main-panels { display: grid; gap: 8px; } +.section-lead { margin: 0; font-size: 12px; } + +.tabs, +.tabs--sidebar { + display: flex; + flex-direction: column; + gap: 3px; + overflow-y: auto; + overflow-x: hidden; + scrollbar-width: thin; + margin: 0; + padding: 0; + background: transparent; + border: 0; +} + +.tabs::-webkit-scrollbar { + width: 4px; +} + +.tabs::-webkit-scrollbar-thumb { + background: color-mix(in srgb, var(--muted) 35%, transparent); + border-radius: 4px; +} + +.tab { + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + width: 100%; + border: 1px solid transparent; + background: transparent; + color: var(--muted); + padding: 9px 10px; + border-radius: 10px; + font: inherit; + font-size: 13px; + font-weight: 600; + cursor: pointer; + text-align: left; + white-space: nowrap; + transition: background 0.15s, color 0.15s, border-color 0.15s; +} + +.tab:hover:not(.active) { + color: var(--text); + background: var(--accent-soft); +} + +.tab.active { + color: var(--text); + background: var(--accent-soft); + border-color: var(--line-strong); +} + +.tab.active .tab-icon { color: var(--accent); } + +.tab-icon { + width: 1.15em; + flex-shrink: 0; + display: grid; + place-items: center; + font-size: 14px; + color: inherit; +} + +.tab-label { line-height: 1.1; } + +.panel { display: none; } +.panel.active { + display: grid; + gap: 8px; + animation: rise 0.2s ease; +} + +@keyframes rise { + from { opacity: 0; transform: translateY(4px); } + to { opacity: 1; transform: none; } +} + +.home-greeting { margin: 0; } +.home-hello { margin: 0; font-size: 16px; font-weight: 700; } +.home-hello-sub { margin: 2px 0 0; } + +/* ——— Cards ——— */ +.card, +.tariff, +.client, +.server, +.faq-item { + background: var(--card); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 12px; + box-shadow: var(--shadow); +} + +.card-hero { + background: linear-gradient(160deg, color-mix(in srgb, var(--accent) 8%, var(--card)), var(--card) 50%); + border-color: var(--line-strong); +} + +.card-dimmed { opacity: 0.72; } +.card-head { + display: flex; + gap: 8px; + align-items: flex-start; + margin-bottom: 2px; +} +.card-head.compact { margin-bottom: 8px; } +.card-head-text { min-width: 0; flex: 1; } +.card-title-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + flex-wrap: wrap; +} +.card-title-row h2, +.card h2, +.tariff h3, +.client h3 { + margin: 0; + font-size: 14px; + font-weight: 700; +} + +.card-icon, +.card-icon--shield { + width: 32px; + height: 32px; + display: grid; + place-items: center; + border-radius: 9px; + font-size: 13px; + color: var(--accent); + background: var(--accent-soft); + border: 1px solid var(--line-strong); + flex-shrink: 0; +} + +.status-badge { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 2px 8px; + border-radius: 999px; + font-size: 10px; + font-weight: 700; +} +.status-badge::before { + content: ""; + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; +} +.status-badge--ok { color: var(--ok); background: var(--accent-soft); border: 1px solid var(--line-strong); } +.status-badge--warn { color: var(--warn); background: color-mix(in srgb, var(--warn) 12%, transparent); border: 1px solid color-mix(in srgb, var(--warn) 30%, transparent); } +.status-badge--off { color: var(--muted); background: var(--bg-2); border: 1px solid var(--line); } + +.sub-status { margin: 2px 0 0; font-size: 12px; } +.sub-status.status-ok { color: var(--ok); } +.sub-status.status-warn { color: var(--warn); } +.sub-status.status-off { color: var(--muted); } + +.metrics-row { display: grid; gap: 6px; margin-top: 8px; } +.metric-chip { + padding: 8px 10px; + border-radius: var(--radius-sm); + background: var(--bg-2); + border: 1px solid var(--line); +} +.metric-chip-top { + display: flex; + justify-content: space-between; + gap: 8px; + margin-bottom: 4px; +} +.metric-label { font-size: 11px; color: var(--muted); font-weight: 600; } +.metric-value { font-size: 12px; font-weight: 700; } +.metric-hint { margin: 4px 0 0; font-size: 11px; } + +.traffic-bar { + height: 6px; + border-radius: 999px; + background: var(--line); + overflow: hidden; +} +.traffic-fill { + height: 100%; + width: 0; + border-radius: inherit; + background: var(--accent); + transition: width 0.25s; +} +.traffic-fill.warn { background: var(--warn); } +.traffic-fill.danger { background: var(--danger); } + +.traffic-box, +.extras-billing-box, +.auto-renew-box { + margin-top: 8px; + padding: 8px 10px; + border-radius: var(--radius-sm); + background: var(--bg-2); + border: 1px solid var(--line); +} +.traffic-head { + display: flex; + justify-content: space-between; + gap: 8px; + font-size: 12px; + font-weight: 600; +} +.extras-billing-box.suspended { opacity: 0.7; } +.alert-inline { + margin: 6px 0 0; + font-size: 11px; + color: var(--warn); +} + +.cab-switch { + display: flex; + align-items: center; + gap: 10px; + cursor: pointer; +} +.cab-switch input { position: absolute; opacity: 0; pointer-events: none; } +.cab-switch-slider { + width: 36px; + height: 20px; + border-radius: 999px; + background: var(--line); + position: relative; + flex-shrink: 0; + transition: background 0.15s; +} +.cab-switch-slider::after { + content: ""; + position: absolute; + top: 2px; + left: 2px; + width: 16px; + height: 16px; + border-radius: 50%; + background: #fff; + transition: transform 0.15s; + box-shadow: 0 1px 4px rgba(0,0,0,0.2); +} +.cab-switch input:checked + .cab-switch-slider { background: var(--accent); } +.cab-switch input:checked + .cab-switch-slider::after { transform: translateX(16px); } +.cab-switch-text { display: grid; gap: 1px; min-width: 0; } +.cab-switch-text strong { font-size: 12.5px; } +.cab-switch-text small { color: var(--muted); font-size: 11px; } + +.sub-url-wrap { margin-top: 8px; } +.url-label { margin: 0 0 4px; } +#sub-url, .wg-sub-url, code { + display: block; + padding: 8px 10px; + border-radius: 8px; + background: var(--bg-2); + border: 1px solid var(--line); + font-size: 11px; + word-break: break-all; + color: var(--text); + opacity: 0.92; +} + +html[data-theme="dark"] #sub-url, +html[data-theme="dark"] .wg-sub-url, +html[data-theme="dark"] code { + color: #e8f5ee; + opacity: 1; + background: #0c1210; + border-color: rgba(180, 220, 200, 0.18); +} + +.quick-actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px; + margin-top: 8px; +} +.quick-action { + display: flex; + align-items: center; + gap: 7px; + min-width: 0; + overflow: hidden; + text-align: left; + padding: 7px 8px; + border-radius: 10px; + border: 1px solid var(--line); + background: var(--bg-2); + color: inherit; + cursor: pointer; + font: inherit; +} +.quick-action:hover { + border-color: var(--line-strong); + background: var(--accent-soft); +} +.qa-icon { + width: 24px; + height: 24px; + display: grid; + place-items: center; + border-radius: 7px; + font-size: 11px; + color: var(--accent); + background: var(--accent-soft); + border: 1px solid var(--line-strong); + flex-shrink: 0; +} +.qa-text { display: grid; gap: 0; min-width: 0; } +.qa-text strong { + font-size: 11.5px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.qa-text small { + font-size: 10px; + color: var(--muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Onboarding */ +.onboarding-card { + border-color: var(--line-strong); + background: linear-gradient(160deg, var(--accent-soft), var(--card) 45%); +} +.onboarding-badge { + display: inline-flex; + margin-bottom: 6px; + padding: 3px 8px; + border-radius: 999px; + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + color: var(--accent); + background: var(--accent-soft); + border: 1px solid var(--line-strong); +} +.onboarding-title { margin: 0 0 4px; font-size: 15px; } +.onboarding-lead { margin: 0 0 8px; font-size: 12px; } +.steps-list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 6px; +} +.steps-list li { + display: flex; + gap: 8px; + align-items: flex-start; + padding: 7px 9px; + border-radius: 9px; + background: var(--bg-2); + border: 1px solid var(--line); + font-size: 12px; + color: var(--muted); +} +.step-num { + width: 22px; + height: 22px; + display: grid; + place-items: center; + border-radius: 7px; + font-size: 11px; + font-weight: 700; + color: var(--accent); + background: var(--accent-soft); + flex-shrink: 0; +} + +.stack { display: grid; gap: 8px; } +.stack-tight { display: grid; gap: 6px; } +.empty-state { text-align: center; padding: 20px 12px; } +.empty-title { margin: 0 0 4px; font-weight: 700; font-size: 14px; } + +/* Tariffs / clients / servers */ +.price { + margin: 0 0 10px; + font-size: 22px; + font-weight: 700; + color: var(--accent); +} +.tariff-meta, .client-meta { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin: 6px 0 10px; +} +.chip, .badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 8px; + border-radius: 999px; + font-size: 11px; + font-weight: 600; + background: var(--bg-2); + border: 1px solid var(--line); + color: var(--muted); +} +.chip.accent, .badge.accent { color: var(--accent); border-color: var(--line-strong); background: var(--accent-soft); } +.actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px; +} +.actions .btn { min-width: 0; } +.actions .btn.full-span { grid-column: 1 / -1; } +.client-links { display: grid; gap: 6px; } +.client-links a { color: var(--accent); font-weight: 600; text-decoration: none; font-size: 13px; } +.servers-head, .card-toolbar, .tickets-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} +.dot { + width: 8px; + height: 8px; + border-radius: 50%; + display: inline-block; + background: var(--muted); +} +.dot.on { background: var(--ok); } +.dot.off { background: var(--danger); } +.faq-item details { cursor: pointer; } +.faq-answer { margin: 6px 0 0; color: var(--muted); font-size: 12.5px; } + +/* Balance */ +.balance-history-card .card-head { margin-bottom: 6px; } +.tx-list { list-style: none; margin: 0; padding: 0; } +.tx-list li { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 8px; + padding: 8px 0; + border-bottom: 1px solid var(--line); + font-size: 12.5px; +} +.tx-list--compact li { + padding: 5px 0; + font-size: 12px; + gap: 6px; +} +.tx-list li:last-child { border-bottom: 0; } +.tx-main { min-width: 0; flex: 1; } +.tx-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.tx-meta { color: var(--muted); font-size: 11px; line-height: 1.3; } +.tx-list--compact .tx-meta { font-size: 10.5px; } +.tx-amount { + flex-shrink: 0; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} +.tx-amount.plus { color: var(--ok); font-weight: 700; } +.tx-amount.minus { color: var(--danger); font-weight: 700; } +.tx-pager { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + margin-top: 8px; + padding-top: 6px; + border-top: 1px solid var(--line); +} +.tx-pager-label { + min-width: 4.5em; + text-align: center; + font-variant-numeric: tabular-nums; +} +.tx-pager .btn[disabled] { + opacity: 0.35; + pointer-events: none; +} +.topup-amount-label { display: grid; gap: 4px; font-size: 12px; color: var(--muted); margin-bottom: 6px; } +.topup-presets { display: flex; flex-wrap: wrap; gap: 6px; margin: 0 0 8px; } +.topup-preset-btn { + border: 1px solid var(--line); + background: var(--bg-2); + color: var(--text); + border-radius: 999px; + padding: 5px 10px; + font: inherit; + font-size: 12px; + font-weight: 600; + cursor: pointer; +} +.topup-preset-btn.active, +.topup-preset-btn:hover { + border-color: var(--line-strong); + background: var(--accent-soft); + color: var(--accent); +} +.pay-methods-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; } +.pay-methods-list li { + display: grid; + grid-template-columns: auto 1fr; + gap: 8px; + font-size: 12.5px; + color: var(--muted); +} +.pay-badge { + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + padding: 4px 7px; + border-radius: 7px; + white-space: nowrap; + height: fit-content; +} +.pay-badge--crypto { color: #0284c7; background: rgba(56,189,248,0.12); border: 1px solid rgba(56,189,248,0.28); } +.pay-badge--code { color: var(--accent); background: var(--accent-soft); border: 1px solid var(--line-strong); } +.input-mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: 0.04em; } + +.passkey-block { margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--line); } +.passkey-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 4px; +} +.passkey-list { margin: 0; padding-left: 16px; } + +/* Tickets */ +.ticket-item { + display: flex; + justify-content: space-between; + gap: 8px; + padding: 10px; + border-radius: var(--radius-sm); + border: 1px solid var(--line); + background: var(--card); + cursor: pointer; +} +.ticket-item:hover { border-color: var(--line-strong); } +.ticket-item-main { min-width: 0; } +.ticket-badge { + font-size: 10px; + font-weight: 700; + padding: 2px 7px; + border-radius: 999px; + height: fit-content; +} +.ticket-badge--open { color: var(--ok); background: var(--accent-soft); } +.ticket-badge--answered { color: var(--warn); background: color-mix(in srgb, var(--warn) 12%, transparent); } +.ticket-badge--closed { color: var(--muted); background: var(--bg-2); } +.ticket-thread { padding: 12px; } +.ticket-thread-head { + display: flex; + align-items: flex-start; + gap: 8px; + margin-bottom: 10px; +} +.ticket-messages { display: grid; gap: 8px; max-height: 360px; overflow: auto; margin-bottom: 10px; } +.ticket-msg { + padding: 8px 10px; + border-radius: 10px; + background: var(--bg-2); + border: 1px solid var(--line); + font-size: 12.5px; +} +.ticket-msg--user { border-color: var(--line-strong); } +.ticket-msg--admin { background: var(--accent-soft); } +.ticket-msg--ai { border-style: dashed; } +.ticket-msg-meta { color: var(--muted); font-size: 11px; margin-bottom: 2px; } +.ticket-create { padding: 12px; } +.ticket-create-actions, +.ticket-reply-actions { + display: flex; + gap: 6px; + justify-content: flex-end; + margin-top: 8px; +} + +/* WG / AWG */ +.wg-config-row { + display: grid; + grid-template-columns: auto 1fr; + gap: 10px; + align-items: start; + padding: 10px 0; + border-top: 1px solid var(--line); +} +.wg-config-row:first-child { border-top: 0; padding-top: 0; } +.wg-qr-thumb { + padding: 3px; + border: 1px solid var(--line); + border-radius: 8px; + background: #fff; + cursor: pointer; + line-height: 0; +} +.wg-qr-img { width: 64px; height: 64px; display: block; object-fit: contain; } +.wg-config-main { min-width: 0; } +.wg-meta { margin: 2px 0 0; } +.wg-config-actions { + display: flex; + flex-wrap: wrap; + gap: 5px; + margin-top: 6px; +} +.wg-config-actions .btn { + flex: 1 1 auto; + min-width: 0; + padding: 5px 8px; + font-size: 11px; +} +.wg-qr-dialog { + border: 1px solid var(--line); + border-radius: 14px; + padding: 0; + background: var(--card); + color: inherit; + max-width: min(92vw, 340px); +} +.wg-qr-dialog::backdrop { background: rgba(0,0,0,0.5); } +.wg-qr-dialog-inner { margin: 0; padding: 14px; text-align: center; } +.wg-qr-dialog-inner h3 { margin: 0 0 4px; font-size: 15px; } +.wg-qr-dialog-img { + display: block; + margin: 10px auto; + width: 240px; + max-width: 100%; + background: #fff; + border-radius: 10px; + padding: 8px; +} +#wg-buy-box label, +#awg-buy-box label { display: block; font-size: 12px; color: var(--muted); } +#wg-buy-box select, +#wg-buy-box input, +#awg-buy-box select, +#awg-buy-box input { + width: 100%; + margin-top: 4px; +} + +/* Toast */ +.toast { + position: fixed; + left: 50%; + bottom: max(16px, env(safe-area-inset-bottom)); + transform: translateX(-50%); + z-index: 100; + padding: 10px 14px; + border-radius: 10px; + background: var(--card); + border: 1px solid var(--line-strong); + box-shadow: var(--shadow); + font-size: 13px; + font-weight: 600; + max-width: min(92vw, 360px); +} + +/* Mobile: как админка — сверху только на узких экранах */ +@media (max-width: 900px) { + .cabinet { + grid-template-columns: 1fr; + gap: 10px; + } + + .cabinet-sidebar { + position: sticky; + top: 0; + z-index: 40; + max-height: none; + overflow: visible; + padding: 8px; + } + + .cabinet-sidebar-brand { + display: none; + } + + .tabs, + .tabs--sidebar { + flex-direction: row; + overflow-x: auto; + overflow-y: hidden; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; + gap: 4px; + } + + .tabs::-webkit-scrollbar { + display: none; + } + + .tab { + width: auto; + flex: 0 0 auto; + padding: 8px 10px; + font-size: 12px; + } +} + +body.is-telegram { + padding: max(6px, env(safe-area-inset-top)) 8px max(12px, env(safe-area-inset-bottom)); +} + +body.is-telegram #app { + max-width: 1100px; + padding: 0 0 18px; +} + +body.is-telegram .home-hello-sub { + display: none; +} + +body.is-telegram .auth-panel { + max-width: none; +} + +/* Telegram на телефоне: узкая колонка иконок слева */ +@media (max-width: 900px) { + body.is-telegram .cabinet { + grid-template-columns: 56px minmax(0, 1fr); + gap: 8px; + } + + body.is-telegram .cabinet-sidebar { + position: sticky; + top: 0; + z-index: 40; + max-height: calc(100vh - 12px); + overflow: hidden; + padding: 6px 4px; + } + + body.is-telegram .cabinet-sidebar-brand { + display: none; + } + + body.is-telegram .tabs, + body.is-telegram .tabs--sidebar { + flex-direction: column; + overflow-x: hidden; + overflow-y: auto; + gap: 2px; + } + + body.is-telegram .tab { + width: 100%; + flex-direction: column; + justify-content: center; + gap: 2px; + padding: 8px 4px; + font-size: 9px; + white-space: normal; + text-align: center; + } + + body.is-telegram .tab-label { + font-size: 9px; + line-height: 1.05; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + } + + body.is-telegram .tab-icon { + font-size: 14px; + } +} + +@media (max-width: 420px) { + .actions { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .wg-config-row { grid-template-columns: 1fr; } +} diff --git a/webapp/static/theme-bs.css b/webapp/static/theme-bs.css new file mode 100644 index 0000000..01af543 --- /dev/null +++ b/webapp/static/theme-bs.css @@ -0,0 +1,86 @@ +/* Bootstrap bridge for VPN Service light/dark */ + +html[data-bs-theme="dark"], +html[data-theme="dark"] { + --bs-body-bg: #0a0f0d; + --bs-body-color: #eaf3ee; + --bs-secondary-color: #8fa59a; + --bs-tertiary-bg: #101714; + --bs-border-color: rgba(180, 220, 200, 0.12); + --bs-primary: #3dcf9a; + --bs-primary-rgb: 61, 207, 154; + --bs-link-color: #3dcf9a; + --bs-link-hover-color: #6ee0b4; + --bs-focus-ring-color: rgba(61, 207, 154, 0.3); + --bs-card-bg: #141c18; + --bs-card-border-color: rgba(180, 220, 200, 0.12); + --bs-font-sans-serif: "Sora", system-ui, sans-serif; + color-scheme: dark; +} + +html[data-bs-theme="light"], +html[data-theme="light"] { + --bs-body-bg: #eef3f0; + --bs-body-color: #12201a; + --bs-secondary-color: #5c7167; + --bs-tertiary-bg: #e4ebe7; + --bs-border-color: rgba(20, 40, 30, 0.1); + --bs-primary: #0d9f6e; + --bs-primary-rgb: 13, 159, 110; + --bs-link-color: #0d9f6e; + --bs-link-hover-color: #0a7a54; + --bs-focus-ring-color: rgba(13, 159, 110, 0.28); + --bs-card-bg: #ffffff; + --bs-card-border-color: rgba(20, 40, 30, 0.1); + --bs-font-sans-serif: "Sora", system-ui, sans-serif; + color-scheme: light; +} + +body { + background-color: var(--bs-body-bg); + color: var(--bs-body-color); + font-family: var(--bs-font-sans-serif); +} + +#app .card, +#admin-app .card { + --bs-card-bg: transparent; + --bs-card-border-width: 0; + --bs-card-spacer-y: 0; + --bs-card-spacer-x: 0; +} + +#app .btn, +#admin-app .btn { + --bs-btn-font-weight: 600; + text-transform: none; + letter-spacing: 0; +} + +#app .form-control, +#app .form-select, +#admin-app .form-control, +#admin-app .form-select { + background-color: var(--bg-2, #101714); + border-color: var(--line, rgba(180, 220, 200, 0.12)); + color: var(--text, inherit); + border-radius: 10px; +} + +#app .form-control:focus, +#app .form-select:focus, +#admin-app .form-control:focus, +#admin-app .form-select:focus { + background-color: var(--bg-2, #101714); + border-color: var(--line-strong); + color: var(--text, inherit); + box-shadow: 0 0 0 0.2rem var(--accent-soft, rgba(61, 207, 154, 0.15)); +} + +.tab-icon .fa-solid, +.card-icon .fa-solid, +.admin-nav-ico .fa-solid, +.qa-icon .fa-solid, +.btn .fa-solid { + line-height: 1; +} diff --git a/webapp_admin_routes.py b/webapp_admin_routes.py new file mode 100644 index 0000000..c3cd7a2 --- /dev/null +++ b/webapp_admin_routes.py @@ -0,0 +1,1683 @@ +"""Маршруты веб-админки и кодов пополнения.""" + +from __future__ import annotations + +import json +import logging +import secrets +from datetime import datetime, timedelta, timezone +from typing import Any + +from aiohttp import web + +from config import Settings +from services.db import Database +from services.openrouter import ( + DEFAULT_MODELS, + DEFAULT_SYSTEM_PROMPT, + chat_completion, + config_public_dict, + list_models, + load_openrouter_config, + parse_ai_decision, +) +from services.payments_runtime import ( + apply_payment_runtime, + apply_payments_body, + payments_public_dict, +) +from services.smtp_runtime import apply_smtp_body, smtp_public_dict +from services.rate_limit import allow +from services.tariffs import ( + Tariff, + TariffError, + parse_tariff_payload, + replace_shared_tariffs, + save_tariffs, +) +from services.tickets import ( + BODY_MAX, + clamp_body, + message_to_dict, + notify_user_ticket_reply, + ticket_to_dict, +) +from services.topup_codes import generate_code +from services.xui_client import XuiClient, XuiError, build_subscription_link +from services.xui_wg import load_wg_purchase_settings, save_wg_purchase_settings +from services.awg_client import DEFAULT_PROTOCOL, AwgClient, AwgError +from services.awg_configs import load_awg_purchase_settings, save_awg_purchase_settings + +logger = logging.getLogger(__name__) + +ADMIN_COOKIE = "vpn_admin_session" +ADMIN_SESSION_HOURS = 12 + + +def register_topup_admin_routes(app: web.Application, *, webapp_dir) -> None: + if "admin_sessions" not in app: + app["admin_sessions"] = {} + + async def handle_admin_index(_: web.Request) -> web.Response: + return web.FileResponse(webapp_dir / "admin.html") + + app.router.add_get("/admin", handle_admin_index) + app.router.add_post("/api/admin/login", handle_admin_login) + app.router.add_post("/api/admin/logout", handle_admin_logout) + app.router.add_get("/api/admin/me", handle_admin_me) + app.router.add_get("/api/admin/stats", handle_admin_stats) + app.router.add_get("/api/admin/codes", handle_admin_list_codes) + app.router.add_post("/api/admin/codes", handle_admin_create_codes) + app.router.add_get("/api/admin/users", handle_admin_list_users) + app.router.add_post("/api/admin/users/balance", handle_admin_user_balance) + app.router.add_get("/api/admin/tickets", handle_admin_list_tickets) + app.router.add_get("/api/admin/tickets/{ticket_id}", handle_admin_get_ticket) + app.router.add_post("/api/admin/tickets/{ticket_id}/messages", handle_admin_reply_ticket) + app.router.add_post("/api/admin/tickets/{ticket_id}/close", handle_admin_close_ticket) + app.router.add_post("/api/admin/tickets/{ticket_id}/reopen", handle_admin_reopen_ticket) + app.router.add_get("/api/admin/ai", handle_admin_ai_get) + app.router.add_post("/api/admin/ai", handle_admin_ai_save) + app.router.add_get("/api/admin/ai/models", handle_admin_ai_models) + app.router.add_post("/api/admin/ai/test", handle_admin_ai_test) + app.router.add_get("/api/admin/xui/servers", handle_admin_xui_list) + app.router.add_post("/api/admin/xui/servers", handle_admin_xui_create) + app.router.add_post("/api/admin/xui/test", handle_admin_xui_test) + app.router.add_get("/api/admin/xui/servers/{server_id}", handle_admin_xui_get) + app.router.add_put("/api/admin/xui/servers/{server_id}", handle_admin_xui_update) + app.router.add_delete("/api/admin/xui/servers/{server_id}", handle_admin_xui_delete) + app.router.add_post( + "/api/admin/xui/servers/{server_id}/test", + handle_admin_xui_test_saved, + ) + app.router.add_get( + "/api/admin/xui/servers/{server_id}/inbounds", + handle_admin_xui_inbounds, + ) + app.router.add_get("/api/admin/xui/wg-settings", handle_admin_wg_settings_get) + app.router.add_post("/api/admin/xui/wg-settings", handle_admin_wg_settings_save) + + app.router.add_get("/api/admin/awg/servers", handle_admin_awg_list) + app.router.add_post("/api/admin/awg/servers", handle_admin_awg_create) + app.router.add_post("/api/admin/awg/test", handle_admin_awg_test) + app.router.add_post("/api/admin/awg/discover", handle_admin_awg_discover) + app.router.add_get("/api/admin/awg/servers/{server_id}", handle_admin_awg_get) + app.router.add_put("/api/admin/awg/servers/{server_id}", handle_admin_awg_update) + app.router.add_delete("/api/admin/awg/servers/{server_id}", handle_admin_awg_delete) + app.router.add_post( + "/api/admin/awg/servers/{server_id}/test", + handle_admin_awg_test_saved, + ) + app.router.add_get("/api/admin/awg/settings", handle_admin_awg_settings_get) + app.router.add_post("/api/admin/awg/settings", handle_admin_awg_settings_save) + + app.router.add_get("/api/admin/tariffs", handle_admin_tariffs_list) + app.router.add_post("/api/admin/tariffs", handle_admin_tariffs_create) + app.router.add_put("/api/admin/tariffs/{tariff_id}", handle_admin_tariffs_update) + app.router.add_delete("/api/admin/tariffs/{tariff_id}", handle_admin_tariffs_delete) + + app.router.add_get("/api/admin/payments", handle_admin_payments_get) + app.router.add_post("/api/admin/payments", handle_admin_payments_save) + app.router.add_get("/api/admin/smtp", handle_admin_smtp_get) + app.router.add_post("/api/admin/smtp", handle_admin_smtp_save) + app.router.add_post("/api/admin/smtp/test", handle_admin_smtp_test) + + +def _utc_now() -> datetime: + return datetime.now(timezone.utc) + + +def _admin_enabled(settings: Settings) -> bool: + return settings.web_admin_enabled + + +def _safe_secret_compare(got: str, expected: str) -> bool: + got_b = got.encode("utf-8") + exp_b = expected.encode("utf-8") + if len(got_b) != len(exp_b): + return False + return secrets.compare_digest(got_b, exp_b) + + +def _check_admin_password(settings: Settings, password: str) -> bool: + expected = settings.web_admin_password + if not expected: + return False + return _safe_secret_compare((password or "").strip(), expected) + + +def _check_admin_login(settings: Settings, login: str) -> bool: + expected = settings.web_admin_login.strip().lower() + if not expected: + return False + return _safe_secret_compare((login or "").strip().lower(), expected) + + +def _admin_token(request: web.Request) -> str | None: + auth = request.headers.get("Authorization") or "" + if auth.lower().startswith("bearer "): + return auth[7:].strip() or None + return request.cookies.get(ADMIN_COOKIE) + + +def _admin_authenticated(request: web.Request) -> bool: + settings: Settings = request.app["settings"] + if not _admin_enabled(settings): + return False + token = _admin_token(request) + if not token: + return False + sessions: dict[str, datetime] = request.app.get("admin_sessions") or {} + expires = sessions.get(token) + if not expires or expires <= _utc_now(): + sessions.pop(token, None) + return False + return True + + +def _require_admin(request: web.Request) -> web.Response | None: + if not _admin_enabled(request.app["settings"]): + return web.json_response({"ok": False, "error": "admin disabled"}, status=503) + if not _admin_authenticated(request): + return web.json_response({"ok": False, "error": "unauthorized"}, status=401) + return None + + +def _set_admin_cookie(response: web.Response, request: web.Request, token: str) -> None: + secure = request.headers.get("X-Forwarded-Proto", "").lower() == "https" or ( + request.url.scheme == "https" + ) + response.set_cookie( + ADMIN_COOKIE, + token, + max_age=ADMIN_SESSION_HOURS * 3600, + httponly=True, + samesite="Lax", + secure=secure, + path="/", + ) + + +async def handle_admin_login(request: web.Request) -> web.Response: + settings: Settings = request.app["settings"] + if not _admin_enabled(settings): + return web.json_response({"ok": False, "error": "admin disabled"}, status=503) + if not allow(request, "admin_login", limit=8, window_sec=300): + return web.json_response({"ok": False, "error": "Слишком часто"}, status=429) + try: + body = await request.json() + except json.JSONDecodeError: + return web.json_response({"ok": False, "error": "invalid json"}, status=400) + login = str(body.get("login") or "").strip() + password = str(body.get("password") or "").strip() + login_ok = _check_admin_login(settings, login) + pass_ok = _check_admin_password(settings, password) + if not login_ok or not pass_ok: + logger.warning( + "Web admin login failed (login_ok=%s pass_ok=%s, configured_login=%r)", + login_ok, + pass_ok, + settings.web_admin_login, + ) + return web.json_response({"ok": False, "error": "Неверный логин или пароль"}, status=403) + + token = secrets.token_urlsafe(32) + sessions: dict[str, datetime] = request.app["admin_sessions"] + sessions[token] = _utc_now() + timedelta(hours=ADMIN_SESSION_HOURS) + resp = web.json_response({"ok": True, "token": token}) + _set_admin_cookie(resp, request, token) + return resp + + +async def handle_admin_logout(request: web.Request) -> web.Response: + token = _admin_token(request) + if token: + sessions: dict[str, datetime] = request.app.get("admin_sessions") or {} + sessions.pop(token, None) + resp = web.json_response({"ok": True}) + resp.del_cookie(ADMIN_COOKIE, path="/") + return resp + + +async def handle_admin_me(request: web.Request) -> web.Response: + settings: Settings = request.app["settings"] + return web.json_response( + { + "ok": True, + "enabled": _admin_enabled(settings), + "authenticated": _admin_authenticated(request), + } + ) + + +async def handle_admin_stats(request: web.Request) -> web.Response: + denied = _require_admin(request) + if denied: + return denied + db: Database = request.app["db"] + return web.json_response( + { + "ok": True, + "stats": db.topup_code_stats(), + "tickets": db.ticket_stats(), + } + ) + + +async def handle_admin_list_codes(request: web.Request) -> web.Response: + denied = _require_admin(request) + if denied: + return denied + status = request.rel_url.query.get("status") or "all" + try: + limit = int(request.rel_url.query.get("limit") or "50") + offset = int(request.rel_url.query.get("offset") or "0") + except ValueError: + limit, offset = 50, 0 + db: Database = request.app["db"] + codes = db.list_topup_codes(status=status, limit=limit, offset=offset) + return web.json_response({"ok": True, "codes": _serialize_codes(codes)}) + + +async def handle_admin_create_codes(request: web.Request) -> web.Response: + denied = _require_admin(request) + if denied: + return denied + try: + body = await request.json() + except json.JSONDecodeError: + return web.json_response({"ok": False, "error": "invalid json"}, status=400) + + try: + amount = float(body.get("amount") or 0) + count = int(body.get("count") or 1) + except (TypeError, ValueError): + return web.json_response({"ok": False, "error": "bad amount/count"}, status=400) + + if amount <= 0: + return web.json_response({"ok": False, "error": "amount must be > 0"}, status=400) + count = max(1, min(count, 100)) + + note = str(body.get("note") or "web_admin")[:120] + codes: list[str] = [] + seen: set[str] = set() + while len(codes) < count: + code = generate_code() + if code in seen: + continue + seen.add(code) + codes.append(code) + + db: Database = request.app["db"] + inserted = db.create_topup_codes( + amount_rub=amount, + count=count, + created_by=note, + codes=codes, + ) + return web.json_response( + { + "ok": True, + "inserted": inserted, + "codes": codes[:inserted], + "amount": amount, + } + ) + + +async def handle_admin_list_users(request: web.Request) -> web.Response: + denied = _require_admin(request) + if denied: + return denied + q = request.rel_url.query.get("q") or "" + try: + limit = int(request.rel_url.query.get("limit") or "50") + offset = int(request.rel_url.query.get("offset") or "0") + except ValueError: + limit, offset = 50, 0 + db: Database = request.app["db"] + rows, total = db.list_users_admin(query=q, limit=limit, offset=offset) + users = [] + for row in rows: + tid = int(row["telegram_id"]) + name = row.get("full_name") or row.get("login") or row.get("username") + if row.get("username") and not str(name or "").startswith("@"): + uname = row["username"] + else: + uname = row.get("username") + users.append( + { + "telegram_id": tid, + "login": row.get("login"), + "username": uname, + "full_name": row.get("full_name"), + "display_name": name or f"ID {tid}", + "balance": float(row["balance"]), + "created_at": _iso(row.get("created_at")), + "is_telegram": tid > 0, + "banned": bool(row.get("banned")), + } + ) + return web.json_response( + {"ok": True, "users": users, "total": total, "limit": limit, "offset": offset} + ) + + +async def handle_admin_user_balance(request: web.Request) -> web.Response: + denied = _require_admin(request) + if denied: + return denied + try: + body = await request.json() + except json.JSONDecodeError: + return web.json_response({"ok": False, "error": "invalid json"}, status=400) + + try: + telegram_id = int(body.get("telegram_id")) + amount = float(body.get("amount") or 0) + except (TypeError, ValueError): + return web.json_response({"ok": False, "error": "bad telegram_id/amount"}, status=400) + + comment = str(body.get("comment") or "").strip() + if amount <= 0: + return web.json_response({"ok": False, "error": "amount must be > 0"}, status=400) + if not comment: + return web.json_response( + {"ok": False, "error": "Укажите комментарий для пользователя"}, + status=400, + ) + + db: Database = request.app["db"] + user = db.get_user(telegram_id) + if user is None: + db.ensure_user(telegram_id) + user = db.get_user(telegram_id) + if user is None: + return web.json_response({"ok": False, "error": "user not found"}, status=404) + + try: + balance = db.admin_credit_balance( + telegram_id, + amount, + comment=comment[:200], + ) + except ValueError as exc: + return web.json_response({"ok": False, "error": str(exc)}, status=400) + + bot = request.app.get("bot") + if bot is not None and telegram_id > 0: + try: + await bot.send_message( + telegram_id, + ( + "💰 Баланс пополнен\n\n" + f"Зачислено: +{amount:.0f} ₽\n" + f"Комментарий: {comment[:200]}\n" + f"Текущий баланс: {balance:.0f} ₽" + ), + parse_mode="HTML", + ) + except Exception: # noqa: BLE001 + logger.warning("admin credit notify failed tg=%s", telegram_id) + + logger.info( + "Admin credit tg=%s amount=%s comment=%r", + telegram_id, + amount, + comment[:80], + ) + return web.json_response( + { + "ok": True, + "telegram_id": telegram_id, + "balance": balance, + "amount": amount, + "comment": comment[:200], + } + ) + + +async def handle_admin_list_tickets(request: web.Request) -> web.Response: + denied = _require_admin(request) + if denied: + return denied + status = request.rel_url.query.get("status") or "open" + try: + limit = int(request.rel_url.query.get("limit") or "50") + offset = int(request.rel_url.query.get("offset") or "0") + except ValueError: + limit, offset = 50, 0 + db: Database = request.app["db"] + rows, total = db.list_tickets_admin(status=status, limit=limit, offset=offset) + return web.json_response( + { + "ok": True, + "tickets": [ticket_to_dict(r, include_user=True) for r in rows], + "total": total, + "stats": db.ticket_stats(), + "body_max": BODY_MAX, + } + ) + + +async def handle_admin_get_ticket(request: web.Request) -> web.Response: + denied = _require_admin(request) + if denied: + return denied + try: + tid = int(request.match_info["ticket_id"]) + except (KeyError, ValueError): + return web.json_response({"ok": False, "error": "bad id"}, status=400) + db: Database = request.app["db"] + ticket = db.get_ticket(tid) + if not ticket: + return web.json_response({"ok": False, "error": "not found"}, status=404) + messages = db.list_ticket_messages(tid) + return web.json_response( + { + "ok": True, + "ticket": ticket_to_dict( + {**ticket, "message_count": len(messages)}, + include_user=True, + ), + "messages": [message_to_dict(m) for m in messages], + } + ) + + +async def handle_admin_reply_ticket(request: web.Request) -> web.Response: + denied = _require_admin(request) + if denied: + return denied + try: + tid = int(request.match_info["ticket_id"]) + except (KeyError, ValueError): + return web.json_response({"ok": False, "error": "bad id"}, status=400) + try: + body = await request.json() + except json.JSONDecodeError: + return web.json_response({"ok": False, "error": "invalid json"}, status=400) + text = clamp_body(str(body.get("body") or body.get("message") or "")) + if len(text) < 1: + return web.json_response({"ok": False, "error": "Пустое сообщение"}, status=400) + db: Database = request.app["db"] + ticket = db.get_ticket(tid) + if not ticket: + return web.json_response({"ok": False, "error": "not found"}, status=404) + try: + ticket = db.add_ticket_message( + tid, + author_role="admin", + author_id=None, + body=text, + ) + except ValueError as exc: + return web.json_response({"ok": False, "error": str(exc)}, status=400) + + bot = request.app.get("bot") + if bot is not None: + await notify_user_ticket_reply( + bot, + telegram_id=int(ticket["telegram_id"]), + ticket_id=tid, + subject=str(ticket.get("subject") or ""), + body=text, + ) + messages = db.list_ticket_messages(tid) + return web.json_response( + { + "ok": True, + "ticket": ticket_to_dict( + {**ticket, "message_count": len(messages)}, + include_user=True, + ), + "messages": [message_to_dict(m) for m in messages], + } + ) + + +async def handle_admin_close_ticket(request: web.Request) -> web.Response: + denied = _require_admin(request) + if denied: + return denied + try: + tid = int(request.match_info["ticket_id"]) + except (KeyError, ValueError): + return web.json_response({"ok": False, "error": "bad id"}, status=400) + db: Database = request.app["db"] + ticket = db.set_ticket_status(tid, "closed") + if not ticket: + return web.json_response({"ok": False, "error": "not found"}, status=404) + messages = db.list_ticket_messages(tid) + return web.json_response( + { + "ok": True, + "ticket": ticket_to_dict( + {**ticket, "message_count": len(messages)}, + include_user=True, + ), + "messages": [message_to_dict(m) for m in messages], + } + ) + + +async def handle_admin_reopen_ticket(request: web.Request) -> web.Response: + denied = _require_admin(request) + if denied: + return denied + try: + tid = int(request.match_info["ticket_id"]) + except (KeyError, ValueError): + return web.json_response({"ok": False, "error": "bad id"}, status=400) + db: Database = request.app["db"] + ticket = db.set_ticket_status(tid, "open") + if not ticket: + return web.json_response({"ok": False, "error": "not found"}, status=404) + messages = db.list_ticket_messages(tid) + return web.json_response( + { + "ok": True, + "ticket": ticket_to_dict( + {**ticket, "message_count": len(messages)}, + include_user=True, + ), + "messages": [message_to_dict(m) for m in messages], + } + ) + + +async def handle_admin_ai_get(request: web.Request) -> web.Response: + denied = _require_admin(request) + if denied: + return denied + db: Database = request.app["db"] + cfg = load_openrouter_config(db) + return web.json_response({"ok": True, "ai": config_public_dict(cfg)}) + + +async def handle_admin_ai_save(request: web.Request) -> web.Response: + denied = _require_admin(request) + if denied: + return denied + try: + body = await request.json() + except json.JSONDecodeError: + return web.json_response({"ok": False, "error": "invalid json"}, status=400) + + db: Database = request.app["db"] + data: dict[str, str] = {} + + if "enabled" in body: + data["openrouter_enabled"] = "true" if bool(body.get("enabled")) else "false" + if "auto_reply" in body: + data["openrouter_auto_reply"] = ( + "true" if bool(body.get("auto_reply")) else "false" + ) + + if "api_key" in body: + key = str(body.get("api_key") or "").strip() + if key == "__clear__": + data["openrouter_api_key"] = "" + elif key and "…" not in key and "•" not in key and "..." not in key: + data["openrouter_api_key"] = key[:200] + + if "model" in body: + model = str(body.get("model") or "").strip() + if model: + data["openrouter_model"] = model[:120] + + if "models" in body: + models_raw = body.get("models") + models: list[str] = [] + if isinstance(models_raw, list): + models = [str(x).strip() for x in models_raw if str(x).strip()] + elif isinstance(models_raw, str): + models = [x.strip() for x in models_raw.split(",") if x.strip()] + # unique keep order + seen: set[str] = set() + uniq: list[str] = [] + for m in models: + if m not in seen: + seen.add(m) + uniq.append(m[:120]) + data["openrouter_models"] = json.dumps(uniq[:20], ensure_ascii=False) + + if "system_prompt" in body: + prompt = str(body.get("system_prompt") or "").strip() + data["openrouter_system_prompt"] = prompt[:8000] if prompt else DEFAULT_SYSTEM_PROMPT + + if data: + db.set_settings_map(data) + + cfg = load_openrouter_config(db) + # если активная модель не в списке — добавим + if cfg.model and cfg.model not in cfg.models: + models = [cfg.model, *cfg.models][:20] + db.set_setting("openrouter_models", json.dumps(models, ensure_ascii=False)) + cfg = load_openrouter_config(db) + + return web.json_response({"ok": True, "ai": config_public_dict(cfg)}) + + +async def handle_admin_ai_models(request: web.Request) -> web.Response: + denied = _require_admin(request) + if denied: + return denied + db: Database = request.app["db"] + cfg = load_openrouter_config(db) + settings: Settings = request.app["settings"] + if not cfg.api_key: + return web.json_response( + { + "ok": True, + "models": [{"id": m, "name": m} for m in DEFAULT_MODELS], + "source": "defaults", + "error": "Сначала сохраните API-ключ OpenRouter", + } + ) + try: + models = await list_models( + cfg.api_key, + referer=(settings.webapp_url or "").rstrip("/") or "https://bot.example.com", + ) + return web.json_response( + {"ok": True, "models": models, "source": "openrouter", "total": len(models)} + ) + except Exception as exc: # noqa: BLE001 + logger.exception("OpenRouter models list failed") + return web.json_response( + { + "ok": False, + "error": str(exc), + "models": [{"id": m, "name": m} for m in DEFAULT_MODELS], + "source": "defaults", + }, + status=400, + ) + + +async def handle_admin_ai_test(request: web.Request) -> web.Response: + denied = _require_admin(request) + if denied: + return denied + db: Database = request.app["db"] + cfg = load_openrouter_config(db) + if not cfg.api_key: + return web.json_response( + {"ok": False, "error": "Нет API-ключа"}, + status=400, + ) + try: + body = await request.json() + except json.JSONDecodeError: + body = {} + prompt = str(body.get("prompt") or "Как подключить VPN? Ответь коротко.").strip() + settings: Settings = request.app["settings"] + try: + raw, used = await chat_completion( + api_key=cfg.api_key, + model=cfg.model, + models=cfg.models_for_request(), + messages=[ + {"role": "system", "content": cfg.system_prompt}, + {"role": "user", "content": prompt}, + ], + referer=(settings.webapp_url or "").rstrip("/") or "https://bot.example.com", + max_tokens=400, + ) + decision = parse_ai_decision(raw, used_model=used) + return web.json_response( + { + "ok": True, + "model": used, + "raw": raw[:2000], + "action": decision.action, + "reply": decision.reply or decision.reason, + } + ) + except Exception as exc: # noqa: BLE001 + logger.exception("OpenRouter test failed") + return web.json_response({"ok": False, "error": str(exc)}, status=400) + + +# ─── 3x-ui servers ─────────────────────────────────────────────────────────── + + +def _serialize_xui_server(row: dict[str, Any], *, hide_password: bool = True) -> dict[str, Any]: + sub = str(row.get("subscription_url") or "").strip() + return { + "id": int(row["id"]), + "name": row.get("name") or "", + "api_url": row.get("api_url") or "", + "username": row.get("username") or "", + "password_set": bool(row.get("password")), + "password": "" if hide_password else (row.get("password") or ""), + "api_key_set": bool(str(row.get("api_key") or "").strip()), + "api_key": "" if hide_password else (row.get("api_key") or ""), + "inbound_id": row.get("inbound_id"), + "inbound_remark": row.get("inbound_remark") or "", + "subscription_url": sub, + "subscription_preview": build_subscription_link(sub, "exampleSubId") if sub else "", + "enabled": bool(row.get("enabled", True)), + "verify_ssl": bool(row.get("verify_ssl", True)), + "last_check_at": _iso(row.get("last_check_at")), + "last_check_ok": row.get("last_check_ok"), + "last_check_msg": row.get("last_check_msg") or "", + "created_at": _iso(row.get("created_at")), + "updated_at": _iso(row.get("updated_at")), + "panel": "3x-ui", + "panel_version": "3.5.0", + "protocol": "wireguard", + } + + +def _parse_inbound_id(value: Any) -> int | None: + if value is None or value == "": + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + +def _xui_client_from_fields( + *, + api_url: str, + username: str = "", + password: str = "", + api_key: str = "", + verify_ssl: bool = True, +) -> XuiClient: + return XuiClient( + api_url, + username=username, + password=password, + api_key=api_key, + verify_ssl=verify_ssl, + ) + + +async def _xui_probe( + *, + api_url: str, + username: str = "", + password: str = "", + api_key: str = "", + verify_ssl: bool = True, +) -> dict[str, Any]: + async with _xui_client_from_fields( + api_url=api_url, + username=username, + password=password, + api_key=api_key, + verify_ssl=verify_ssl, + ) as client: + info = await client.test_connection() + return { + "ok": info.ok, + "message": info.message, + "version": info.version, + "inbounds_total": info.inbounds_total, + "wireguard": [ + { + "id": i.id, + "remark": i.remark, + "protocol": i.protocol, + "port": i.port, + "enable": i.enable, + "clients_count": i.clients_count, + } + for i in info.wireguard + ], + } + + +async def handle_admin_xui_list(request: web.Request) -> web.Response: + denied = _require_admin(request) + if denied: + return denied + db: Database = request.app["db"] + rows = db.list_xui_servers() + return web.json_response( + { + "ok": True, + "servers": [_serialize_xui_server(r) for r in rows], + "panel_version": "3.5.0", + } + ) + + +async def handle_admin_xui_get(request: web.Request) -> web.Response: + denied = _require_admin(request) + if denied: + return denied + try: + server_id = int(request.match_info["server_id"]) + except (KeyError, ValueError): + return web.json_response({"ok": False, "error": "bad id"}, status=400) + db: Database = request.app["db"] + row = db.get_xui_server(server_id) + if not row: + return web.json_response({"ok": False, "error": "not found"}, status=404) + return web.json_response({"ok": True, "server": _serialize_xui_server(row)}) + + +async def handle_admin_xui_create(request: web.Request) -> web.Response: + denied = _require_admin(request) + if denied: + return denied + try: + body = await request.json() + except json.JSONDecodeError: + return web.json_response({"ok": False, "error": "invalid json"}, status=400) + + name = str(body.get("name") or "").strip() + api_url = str(body.get("api_url") or "").strip() + username = str(body.get("username") or "").strip() + password = str(body.get("password") or "") + api_key = str(body.get("api_key") or "").strip() + subscription_url = str(body.get("subscription_url") or "").strip() + if not name or not api_url: + return web.json_response( + {"ok": False, "error": "Нужны name и api_url"}, + status=400, + ) + if not api_key and (not username or not password): + return web.json_response( + {"ok": False, "error": "Нужен API Token или логин+пароль"}, + status=400, + ) + + inbound_id = _parse_inbound_id(body.get("inbound_id")) + inbound_remark = str(body.get("inbound_remark") or "").strip() or None + enabled = bool(body.get("enabled", True)) + verify_ssl = bool(body.get("verify_ssl", True)) + + db: Database = request.app["db"] + row = db.create_xui_server( + name=name, + api_url=api_url, + username=username, + password=password, + api_key=api_key, + subscription_url=subscription_url, + inbound_id=inbound_id, + inbound_remark=inbound_remark, + enabled=enabled, + verify_ssl=verify_ssl, + ) + return web.json_response({"ok": True, "server": _serialize_xui_server(row)}) + + +async def handle_admin_xui_update(request: web.Request) -> web.Response: + denied = _require_admin(request) + if denied: + return denied + try: + server_id = int(request.match_info["server_id"]) + except (KeyError, ValueError): + return web.json_response({"ok": False, "error": "bad id"}, status=400) + try: + body = await request.json() + except json.JSONDecodeError: + return web.json_response({"ok": False, "error": "invalid json"}, status=400) + + db: Database = request.app["db"] + existing = db.get_xui_server(server_id) + if not existing: + return web.json_response({"ok": False, "error": "not found"}, status=404) + + fields: dict[str, Any] = {} + if "name" in body: + name = str(body.get("name") or "").strip() + if not name: + return web.json_response({"ok": False, "error": "name пуст"}, status=400) + fields["name"] = name + if "api_url" in body: + api_url = str(body.get("api_url") or "").strip() + if not api_url: + return web.json_response({"ok": False, "error": "api_url пуст"}, status=400) + fields["api_url"] = api_url + if "username" in body: + fields["username"] = str(body.get("username") or "").strip() + if "password" in body: + password = str(body.get("password") or "") + if password: + fields["password"] = password + if "api_key" in body: + api_key = str(body.get("api_key") or "").strip() + if api_key: + fields["api_key"] = api_key + if "subscription_url" in body: + fields["subscription_url"] = str(body.get("subscription_url") or "").strip() + if "inbound_id" in body: + fields["inbound_id"] = _parse_inbound_id(body.get("inbound_id")) + if "inbound_remark" in body: + fields["inbound_remark"] = str(body.get("inbound_remark") or "").strip() or None + if "enabled" in body: + fields["enabled"] = bool(body.get("enabled")) + if "verify_ssl" in body: + fields["verify_ssl"] = bool(body.get("verify_ssl")) + + row = db.update_xui_server(server_id, **fields) + if not row: + return web.json_response({"ok": False, "error": "not found"}, status=404) + return web.json_response({"ok": True, "server": _serialize_xui_server(row)}) + + +async def handle_admin_xui_delete(request: web.Request) -> web.Response: + denied = _require_admin(request) + if denied: + return denied + try: + server_id = int(request.match_info["server_id"]) + except (KeyError, ValueError): + return web.json_response({"ok": False, "error": "bad id"}, status=400) + db: Database = request.app["db"] + deleted = db.delete_xui_server(server_id) + if not deleted: + return web.json_response({"ok": False, "error": "not found"}, status=404) + return web.json_response({"ok": True}) + + +async def handle_admin_xui_test(request: web.Request) -> web.Response: + """Проверка панели по полям формы (ещё не сохранено).""" + denied = _require_admin(request) + if denied: + return denied + try: + body = await request.json() + except json.JSONDecodeError: + return web.json_response({"ok": False, "error": "invalid json"}, status=400) + + api_url = str(body.get("api_url") or "").strip() + username = str(body.get("username") or "").strip() + password = str(body.get("password") or "") + api_key = str(body.get("api_key") or "").strip() + verify_ssl = bool(body.get("verify_ssl", True)) + if not api_url: + return web.json_response( + {"ok": False, "error": "Нужен api_url"}, + status=400, + ) + if not api_key and (not username or not password): + return web.json_response( + {"ok": False, "error": "Нужен API Token или логин+пароль"}, + status=400, + ) + try: + result = await _xui_probe( + api_url=api_url, + username=username, + password=password, + api_key=api_key, + verify_ssl=verify_ssl, + ) + return web.json_response({"ok": True, **result}) + except XuiError as exc: + return web.json_response({"ok": False, "error": str(exc)}, status=400) + except Exception as exc: # noqa: BLE001 + logger.exception("3x-ui test failed") + return web.json_response({"ok": False, "error": str(exc)}, status=400) + + +async def handle_admin_xui_test_saved(request: web.Request) -> web.Response: + denied = _require_admin(request) + if denied: + return denied + try: + server_id = int(request.match_info["server_id"]) + except (KeyError, ValueError): + return web.json_response({"ok": False, "error": "bad id"}, status=400) + + db: Database = request.app["db"] + row = db.get_xui_server(server_id) + if not row: + return web.json_response({"ok": False, "error": "not found"}, status=404) + + try: + result = await _xui_probe( + api_url=str(row["api_url"]), + username=str(row.get("username") or ""), + password=str(row.get("password") or ""), + api_key=str(row.get("api_key") or ""), + verify_ssl=bool(row.get("verify_ssl", True)), + ) + db.set_xui_server_check( + server_id, + ok=bool(result.get("ok")), + message=str(result.get("message") or ""), + ) + refreshed = db.get_xui_server(server_id) + return web.json_response( + { + "ok": True, + **result, + "server": _serialize_xui_server(refreshed) if refreshed else None, + } + ) + except Exception as exc: # noqa: BLE001 + logger.exception("3x-ui saved test failed") + db.set_xui_server_check(server_id, ok=False, message=str(exc)) + return web.json_response({"ok": False, "error": str(exc)}, status=400) + + +async def handle_admin_xui_inbounds(request: web.Request) -> web.Response: + denied = _require_admin(request) + if denied: + return denied + try: + server_id = int(request.match_info["server_id"]) + except (KeyError, ValueError): + return web.json_response({"ok": False, "error": "bad id"}, status=400) + + db: Database = request.app["db"] + row = db.get_xui_server(server_id) + if not row: + return web.json_response({"ok": False, "error": "not found"}, status=404) + + protocol = (request.rel_url.query.get("protocol") or "wireguard").strip().lower() + try: + async with _xui_client_from_fields( + api_url=str(row["api_url"]), + username=str(row.get("username") or ""), + password=str(row.get("password") or ""), + api_key=str(row.get("api_key") or ""), + verify_ssl=bool(row.get("verify_ssl", True)), + ) as client: + await client.ensure_auth() + if protocol in {"wireguard", "wg"}: + items = await client.list_wireguard_inbounds() + else: + items = await client.list_inbounds() + return web.json_response( + { + "ok": True, + "inbounds": [ + { + "id": i.id, + "remark": i.remark, + "protocol": i.protocol, + "port": i.port, + "enable": i.enable, + "clients_count": i.clients_count, + "is_wireguard": i.is_wireguard, + } + for i in items + ], + } + ) + except XuiError as exc: + return web.json_response({"ok": False, "error": str(exc)}, status=400) + except Exception as exc: # noqa: BLE001 + logger.exception("3x-ui inbounds failed") + return web.json_response({"ok": False, "error": str(exc)}, status=400) + + +async def handle_admin_wg_settings_get(request: web.Request) -> web.Response: + denied = _require_admin(request) + if denied: + return denied + db: Database = request.app["db"] + settings = load_wg_purchase_settings(db) + return web.json_response({"ok": True, **settings.as_dict()}) + + +async def handle_admin_wg_settings_save(request: web.Request) -> web.Response: + denied = _require_admin(request) + if denied: + return denied + try: + body = await request.json() + except json.JSONDecodeError: + return web.json_response({"ok": False, "error": "invalid json"}, status=400) + + enabled = body.get("enabled") + price = body.get("price") + kwargs: dict[str, Any] = {} + if enabled is not None: + kwargs["enabled"] = bool(enabled) + if price is not None: + try: + kwargs["price"] = float(price) + except (TypeError, ValueError): + return web.json_response({"ok": False, "error": "bad price"}, status=400) + + db: Database = request.app["db"] + settings = save_wg_purchase_settings(db, **kwargs) + return web.json_response({"ok": True, **settings.as_dict()}) + + +def _serialize_awg_server(row: dict[str, Any], *, hide_token: bool = True) -> dict[str, Any]: + token = str(row.get("api_token") or "") + return { + "id": int(row["id"]), + "name": row.get("name") or "", + "api_url": row.get("api_url") or "", + "api_token_set": bool(token.strip()), + "api_token_masked": (token[:8] + "…") if hide_token and len(token) > 8 else ("" if hide_token else token), + "panel_server_id": int(row.get("panel_server_id") or 0), + "protocol": row.get("protocol") or DEFAULT_PROTOCOL, + "enabled": bool(row.get("enabled", True)), + "verify_ssl": bool(row.get("verify_ssl", True)), + "last_check_at": _iso(row.get("last_check_at")), + "last_check_ok": row.get("last_check_ok"), + "last_check_msg": row.get("last_check_msg") or "", + "created_at": _iso(row.get("created_at")), + "updated_at": _iso(row.get("updated_at")), + } + + +async def handle_admin_awg_list(request: web.Request) -> web.Response: + denied = _require_admin(request) + if denied: + return denied + db: Database = request.app["db"] + rows = db.list_awg_servers() + return web.json_response( + {"ok": True, "servers": [_serialize_awg_server(r) for r in rows]} + ) + + +async def handle_admin_awg_get(request: web.Request) -> web.Response: + denied = _require_admin(request) + if denied: + return denied + try: + server_id = int(request.match_info["server_id"]) + except (KeyError, ValueError): + return web.json_response({"ok": False, "error": "bad id"}, status=400) + db: Database = request.app["db"] + row = db.get_awg_server(server_id) + if not row: + return web.json_response({"ok": False, "error": "not found"}, status=404) + return web.json_response({"ok": True, "server": _serialize_awg_server(row)}) + + +async def handle_admin_awg_create(request: web.Request) -> web.Response: + denied = _require_admin(request) + if denied: + return denied + try: + body = await request.json() + except json.JSONDecodeError: + return web.json_response({"ok": False, "error": "invalid json"}, status=400) + name = str(body.get("name") or "").strip() + api_url = str(body.get("api_url") or "").strip() + api_token = str(body.get("api_token") or "").strip() + if not name or not api_url or not api_token: + return web.json_response( + {"ok": False, "error": "Нужны название, API URL и API Token"}, + status=400, + ) + try: + panel_server_id = int(body.get("panel_server_id") or 0) + except (TypeError, ValueError): + return web.json_response({"ok": False, "error": "bad panel_server_id"}, status=400) + protocol = str(body.get("protocol") or DEFAULT_PROTOCOL).strip() or DEFAULT_PROTOCOL + db: Database = request.app["db"] + row = db.create_awg_server( + name=name, + api_url=api_url, + api_token=api_token, + panel_server_id=panel_server_id, + protocol=protocol, + enabled=bool(body.get("enabled", True)), + verify_ssl=bool(body.get("verify_ssl", True)), + ) + return web.json_response({"ok": True, "server": _serialize_awg_server(row)}) + + +async def handle_admin_awg_update(request: web.Request) -> web.Response: + denied = _require_admin(request) + if denied: + return denied + try: + server_id = int(request.match_info["server_id"]) + except (KeyError, ValueError): + return web.json_response({"ok": False, "error": "bad id"}, status=400) + try: + body = await request.json() + except json.JSONDecodeError: + return web.json_response({"ok": False, "error": "invalid json"}, status=400) + db: Database = request.app["db"] + existing = db.get_awg_server(server_id) + if not existing: + return web.json_response({"ok": False, "error": "not found"}, status=404) + fields: dict[str, Any] = {} + if "name" in body: + fields["name"] = str(body.get("name") or "").strip() + if "api_url" in body: + fields["api_url"] = str(body.get("api_url") or "").strip() + if "api_token" in body: + token = str(body.get("api_token") or "").strip() + if token: + fields["api_token"] = token + if "panel_server_id" in body: + try: + fields["panel_server_id"] = int(body.get("panel_server_id") or 0) + except (TypeError, ValueError): + return web.json_response({"ok": False, "error": "bad panel_server_id"}, status=400) + if "protocol" in body: + fields["protocol"] = str(body.get("protocol") or DEFAULT_PROTOCOL).strip() or DEFAULT_PROTOCOL + if "enabled" in body: + fields["enabled"] = bool(body.get("enabled")) + if "verify_ssl" in body: + fields["verify_ssl"] = bool(body.get("verify_ssl")) + row = db.update_awg_server(server_id, **fields) + return web.json_response({"ok": True, "server": _serialize_awg_server(row) if row else None}) + + +async def handle_admin_awg_delete(request: web.Request) -> web.Response: + denied = _require_admin(request) + if denied: + return denied + try: + server_id = int(request.match_info["server_id"]) + except (KeyError, ValueError): + return web.json_response({"ok": False, "error": "bad id"}, status=400) + db: Database = request.app["db"] + deleted = db.delete_awg_server(server_id) + if not deleted: + return web.json_response({"ok": False, "error": "not found"}, status=404) + return web.json_response({"ok": True}) + + +async def handle_admin_awg_discover(request: web.Request) -> web.Response: + """Список нод с панели (backup inventory).""" + denied = _require_admin(request) + if denied: + return denied + try: + body = await request.json() + except json.JSONDecodeError: + return web.json_response({"ok": False, "error": "invalid json"}, status=400) + api_url = str(body.get("api_url") or "").strip() + api_token = str(body.get("api_token") or "").strip() + verify_ssl = bool(body.get("verify_ssl", True)) + if not api_url or not api_token: + return web.json_response( + {"ok": False, "error": "Укажите API URL и Token"}, status=400 + ) + try: + async with AwgClient(api_url, api_token=api_token, verify_ssl=verify_ssl) as client: + servers = await client.list_panel_servers() + return web.json_response( + { + "ok": True, + "servers": [ + { + "id": s.id, + "name": s.name, + "host": s.host, + "protocols": s.protocols, + "has_awg2": s.has_awg2, + } + for s in servers + ], + } + ) + except AwgError as exc: + return web.json_response({"ok": False, "error": str(exc)}, status=400) + except Exception as exc: # noqa: BLE001 + logger.exception("AWG discover failed") + return web.json_response({"ok": False, "error": str(exc)}, status=400) + + +async def handle_admin_awg_test(request: web.Request) -> web.Response: + denied = _require_admin(request) + if denied: + return denied + try: + body = await request.json() + except json.JSONDecodeError: + return web.json_response({"ok": False, "error": "invalid json"}, status=400) + api_url = str(body.get("api_url") or "").strip() + api_token = str(body.get("api_token") or "").strip() + try: + panel_server_id = int(body.get("panel_server_id") or 0) + except (TypeError, ValueError): + return web.json_response({"ok": False, "error": "bad panel_server_id"}, status=400) + protocol = str(body.get("protocol") or DEFAULT_PROTOCOL).strip() or DEFAULT_PROTOCOL + verify_ssl = bool(body.get("verify_ssl", True)) + try: + async with AwgClient(api_url, api_token=api_token, verify_ssl=verify_ssl) as client: + result = await client.probe(panel_server_id, protocol=protocol) + return web.json_response( + { + "ok": result.ok, + "alive": result.alive, + "ping_ms": result.ping_ms, + "protocols": result.protocols, + "message": result.message, + } + ) + except AwgError as exc: + return web.json_response({"ok": False, "error": str(exc)}, status=400) + except Exception as exc: # noqa: BLE001 + logger.exception("AWG test failed") + return web.json_response({"ok": False, "error": str(exc)}, status=400) + + +async def handle_admin_awg_test_saved(request: web.Request) -> web.Response: + denied = _require_admin(request) + if denied: + return denied + try: + server_id = int(request.match_info["server_id"]) + except (KeyError, ValueError): + return web.json_response({"ok": False, "error": "bad id"}, status=400) + db: Database = request.app["db"] + row = db.get_awg_server(server_id) + if not row: + return web.json_response({"ok": False, "error": "not found"}, status=404) + try: + async with AwgClient( + str(row["api_url"]), + api_token=str(row.get("api_token") or ""), + verify_ssl=bool(row.get("verify_ssl", True)), + ) as client: + result = await client.probe( + int(row.get("panel_server_id") or 0), + protocol=str(row.get("protocol") or DEFAULT_PROTOCOL), + ) + db.set_awg_server_check(server_id, ok=result.ok, message=result.message) + refreshed = db.get_awg_server(server_id) + return web.json_response( + { + "ok": result.ok, + "alive": result.alive, + "ping_ms": result.ping_ms, + "protocols": result.protocols, + "message": result.message, + "server": _serialize_awg_server(refreshed) if refreshed else None, + } + ) + except Exception as exc: # noqa: BLE001 + db.set_awg_server_check(server_id, ok=False, message=str(exc)) + return web.json_response({"ok": False, "error": str(exc)}, status=400) + + +async def handle_admin_awg_settings_get(request: web.Request) -> web.Response: + denied = _require_admin(request) + if denied: + return denied + db: Database = request.app["db"] + settings = load_awg_purchase_settings(db) + return web.json_response({"ok": True, **settings.as_dict()}) + + +async def handle_admin_awg_settings_save(request: web.Request) -> web.Response: + denied = _require_admin(request) + if denied: + return denied + try: + body = await request.json() + except json.JSONDecodeError: + return web.json_response({"ok": False, "error": "invalid json"}, status=400) + enabled = body.get("enabled") + price = body.get("price") + kwargs: dict[str, Any] = {} + if enabled is not None: + kwargs["enabled"] = bool(enabled) + if price is not None: + try: + kwargs["price"] = float(price) + except (TypeError, ValueError): + return web.json_response({"ok": False, "error": "bad price"}, status=400) + db: Database = request.app["db"] + settings = save_awg_purchase_settings(db, **kwargs) + return web.json_response({"ok": True, **settings.as_dict()}) + + +def _serialize_codes(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + out: list[dict[str, Any]] = [] + for row in rows: + out.append( + { + "code": row["code"], + "amount": float(row["amount_rub"]), + "created_at": _iso(row.get("created_at")), + "created_by": row.get("created_by"), + "used_at": _iso(row.get("used_at")), + "used_by": row.get("used_by"), + } + ) + return out + + +def _iso(value: Any) -> str | None: + if value is None: + return None + if isinstance(value, datetime): + return value.isoformat() + return str(value) + + +def _tariffs_path(request: web.Request) -> str: + settings: Settings = request.app["settings"] + return settings.tariffs_path + + +def _persist_tariffs(request: web.Request, tariffs: list[Tariff]) -> None: + path = _tariffs_path(request) + save_tariffs(path, tariffs) + shared: list[Tariff] = request.app["tariffs"] + replace_shared_tariffs(shared, tariffs) + + +async def handle_admin_tariffs_list(request: web.Request) -> web.Response: + denied = _require_admin(request) + if denied: + return denied + tariffs: list[Tariff] = request.app["tariffs"] + return web.json_response( + {"ok": True, "tariffs": [t.to_admin_dict() for t in tariffs]} + ) + + +async def handle_admin_tariffs_create(request: web.Request) -> web.Response: + denied = _require_admin(request) + if denied: + return denied + try: + body = await request.json() + except json.JSONDecodeError: + return web.json_response({"ok": False, "error": "invalid json"}, status=400) + + shared: list[Tariff] = list(request.app["tariffs"]) + default_squads = shared[0].squad_uuids if shared else () + try: + tariff = parse_tariff_payload(body, default_squads=default_squads) + except TariffError as exc: + return web.json_response({"ok": False, "error": str(exc)}, status=400) + + if any(t.id == tariff.id for t in shared): + return web.json_response( + {"ok": False, "error": f"Тариф «{tariff.id}» уже есть"}, + status=400, + ) + + shared.append(tariff) + try: + _persist_tariffs(request, shared) + except OSError as exc: + logger.exception("tariffs save failed") + return web.json_response( + {"ok": False, "error": f"Не удалось записать tariffs.json: {exc}"}, + status=500, + ) + return web.json_response({"ok": True, "tariff": tariff.to_admin_dict()}) + + +async def handle_admin_tariffs_update(request: web.Request) -> web.Response: + denied = _require_admin(request) + if denied: + return denied + tariff_id = str(request.match_info.get("tariff_id") or "").strip() + try: + body = await request.json() + except json.JSONDecodeError: + return web.json_response({"ok": False, "error": "invalid json"}, status=400) + + shared: list[Tariff] = list(request.app["tariffs"]) + existing = next((t for t in shared if t.id == tariff_id), None) + if existing is None: + return web.json_response({"ok": False, "error": "Тариф не найден"}, status=404) + + payload = dict(body) + payload["id"] = tariff_id + try: + updated = parse_tariff_payload(payload, existing=existing) + except TariffError as exc: + return web.json_response({"ok": False, "error": str(exc)}, status=400) + + shared = [updated if t.id == tariff_id else t for t in shared] + try: + _persist_tariffs(request, shared) + except OSError as exc: + logger.exception("tariffs save failed") + return web.json_response( + {"ok": False, "error": f"Не удалось записать tariffs.json: {exc}"}, + status=500, + ) + return web.json_response({"ok": True, "tariff": updated.to_admin_dict()}) + + +async def handle_admin_tariffs_delete(request: web.Request) -> web.Response: + denied = _require_admin(request) + if denied: + return denied + tariff_id = str(request.match_info.get("tariff_id") or "").strip() + shared: list[Tariff] = list(request.app["tariffs"]) + if not any(t.id == tariff_id for t in shared): + return web.json_response({"ok": False, "error": "Тариф не найден"}, status=404) + if len(shared) <= 1: + return web.json_response( + {"ok": False, "error": "Нельзя удалить последний тариф"}, + status=400, + ) + + shared = [t for t in shared if t.id != tariff_id] + try: + _persist_tariffs(request, shared) + except OSError as exc: + logger.exception("tariffs save failed") + return web.json_response( + {"ok": False, "error": f"Не удалось записать tariffs.json: {exc}"}, + status=500, + ) + return web.json_response({"ok": True}) + + +async def handle_admin_payments_get(request: web.Request) -> web.Response: + denied = _require_admin(request) + if denied: + return denied + settings: Settings = request.app["settings"] + return web.json_response({"ok": True, "payments": payments_public_dict(settings)}) + + +async def handle_admin_payments_save(request: web.Request) -> web.Response: + denied = _require_admin(request) + if denied: + return denied + try: + body = await request.json() + except json.JSONDecodeError: + return web.json_response({"ok": False, "error": "invalid json"}, status=400) + + try: + settings = apply_payments_body(body if isinstance(body, dict) else {}) + apply_payment_runtime(request.app, settings) + except OSError as exc: + logger.exception("payments .env write failed") + return web.json_response( + {"ok": False, "error": f"Не удалось записать .env: {exc}"}, + status=500, + ) + except SystemExit as exc: + return web.json_response( + {"ok": False, "error": str(exc) or "Ошибка настроек"}, + status=400, + ) + except Exception as exc: # noqa: BLE001 + logger.exception("payments save failed") + return web.json_response({"ok": False, "error": str(exc)}, status=500) + + return web.json_response( + {"ok": True, "payments": payments_public_dict(settings)} + ) + + +async def handle_admin_smtp_get(request: web.Request) -> web.Response: + denied = _require_admin(request) + if denied: + return denied + settings: Settings = request.app["settings"] + return web.json_response({"ok": True, "smtp": smtp_public_dict(settings)}) + + +async def handle_admin_smtp_save(request: web.Request) -> web.Response: + denied = _require_admin(request) + if denied: + return denied + try: + body = await request.json() + except json.JSONDecodeError: + return web.json_response({"ok": False, "error": "invalid json"}, status=400) + + try: + settings = apply_smtp_body(body if isinstance(body, dict) else {}) + request.app["settings"] = settings + inject = request.app.get("inject") + if inject is not None: + inject.settings = settings + except OSError as exc: + logger.exception("smtp .env write failed") + return web.json_response( + {"ok": False, "error": f"Не удалось записать .env: {exc}"}, + status=500, + ) + except SystemExit as exc: + return web.json_response( + {"ok": False, "error": str(exc) or "Ошибка настроек"}, + status=400, + ) + except Exception as exc: # noqa: BLE001 + logger.exception("smtp save failed") + return web.json_response({"ok": False, "error": str(exc)}, status=500) + + return web.json_response({"ok": True, "smtp": smtp_public_dict(settings)}) + + +async def handle_admin_smtp_test(request: web.Request) -> web.Response: + denied = _require_admin(request) + if denied: + return denied + settings: Settings = request.app["settings"] + try: + body = await request.json() + except json.JSONDecodeError: + body = {} + to = str((body or {}).get("to") or settings.smtp_from or "").strip() + if not to: + return web.json_response( + {"ok": False, "error": "Укажите email получателя"}, status=400 + ) + from services.mailer import render_smtp_test_email, send_email, smtp_configured + + if not smtp_configured(settings): + return web.json_response( + {"ok": False, "error": "SMTP не настроен"}, status=400 + ) + try: + await send_email( + settings, + to=to, + subject="Тест SMTP — VPN Service", + html=render_smtp_test_email(webapp_url=settings.webapp_url or ""), + ) + except Exception as exc: # noqa: BLE001 + logger.exception("smtp test failed") + return web.json_response({"ok": False, "error": str(exc)}, status=500) + return web.json_response({"ok": True}) diff --git a/webapp_server.py b/webapp_server.py new file mode 100644 index 0000000..542b791 --- /dev/null +++ b/webapp_server.py @@ -0,0 +1,2527 @@ +"""HTTP-сервер Telegram Mini App + веб-кабинет (aiohttp).""" + +from __future__ import annotations + +import base64 +import json +import logging +from pathlib import Path +from typing import Any + +from aiohttp import web +from webauthn.helpers import bytes_to_base64url + +from config import Settings +from services.clients import TOP_CLIENTS +from services.cryptopay import CryptoPay +from services.db import AuthError, BotUser, Database, SESSION_DAYS +from services.digiseller import Digiseller, DigisellerError +from services.digiseller_fulfill import fulfill_digiseller_unique_code +from services.faq import FAQ_ITEMS +from services.heleket import Heleket, verify_webhook_sign +from services.anypay import ( + Anypay, + PAID_STATUSES as ANYPAY_PAID, + client_ip_allowed, + verify_notification_sign as verify_anypay_sign, +) +from services.lava import Lava, verify_webhook as verify_lava_webhook +from services.nodes_status import fetch_nodes_status +from services.payments import fulfill_paid_invoice +from services.purchase import ( + get_my_subscription_info, + purchase_device_pack, + purchase_tariff, + purchase_traffic_pack, +) +from services.xui_wg import ( + delete_wg_config, + enforce_wg_configs_subscription, + list_available_servers, + list_user_wg_configs, + load_wg_purchase_settings, + purchase_wg_config, + set_wg_config_enabled, +) +from services.awg_configs import ( + delete_awg_config, + enforce_awg_configs_subscription, + list_available_awg_servers, + list_user_awg_configs, + load_awg_purchase_settings, + purchase_awg_config, + set_awg_config_enabled, +) +from services.qrcode_svg import make_qr_svg +from services.tariffs import Tariff, get_tariff +from services.devices import DEVICE_PACKS, get_device_pack +from services.traffic import TRAFFIC_PACKS, get_traffic_pack +from services.extras_billing import extras_as_dict +from services.stars import create_stars_topup, packs_payload +from services.topup_code_redeem import TopupCodeError, redeem_topup_code +from services.web_topup import ( + TopupError, + create_web_topup_invoice, + topup_methods_payload, +) +from services.transactions_ui import transaction_label, transaction_row_dict +from services.webauthn_auth import ( + build_authentication_options, + build_registration_options, + challenge_from_client_data, + credential_id_b64, + credential_id_bytes, + parse_transports, + verify_authentication, + verify_registration, +) +from services.tickets import ( + BODY_MAX, + MAX_OPEN_PER_USER, + SUBJECT_MAX, + clamp_body, + clamp_subject, + message_to_dict, + notify_admins_new_ticket, + notify_admins_ticket_reply, + ticket_to_dict, +) +from services.ticket_ai import try_ai_ticket_reply +from services.tg_webapp import ( + InitDataError, + parse_and_validate_id_token, + parse_and_validate_init_data, + parse_and_validate_login_widget, +) + +from webapp_admin_routes import register_topup_admin_routes +from services.webapp_growth_routes import register_growth_routes +from services.rate_limit import allow, client_ip + +logger = logging.getLogger(__name__) + +WEBAPP_DIR = Path(__file__).resolve().parent / "webapp" +SESSION_COOKIE = "vpn_session" + + +def create_webapp( + *, + settings: Settings, + db: Database, + sdk: Any, + tariffs: list[Tariff], + cryptopay: CryptoPay, + heleket: Heleket | None = None, + digiseller: Digiseller | None = None, + lava: Lava | None = None, + anypay: Anypay | None = None, + bot: Any | None = None, + bot_username: str = "", +) -> web.Application: + app = web.Application() + app["settings"] = settings + app["db"] = db + app["sdk"] = sdk + app["tariffs"] = tariffs + app["cryptopay"] = cryptopay + app["heleket"] = heleket or Heleket("", "") + app["digiseller"] = digiseller or Digiseller("", "") + app["lava"] = lava or Lava("", "") + app["anypay"] = anypay or Anypay("", "", "") + app["bot"] = bot + app["bot_username"] = bot_username.lstrip("@") + + app.router.add_get("/api/health", handle_health) + app.router.add_get("/api/config", handle_config) + app.router.add_post("/api/auth/register", handle_register) + app.router.add_post("/api/auth/login", handle_login) + app.router.add_post("/api/auth/telegram", handle_telegram_auth) + app.router.add_post("/api/auth/logout", handle_logout) + app.router.add_post("/api/auth/qr/start", handle_qr_auth_start) + app.router.add_get("/api/auth/qr/poll", handle_qr_auth_poll) + app.router.add_post("/api/auth/qr/approve", handle_qr_auth_approve) + app.router.add_post("/api/auth/passkey/register/options", handle_passkey_register_options) + app.router.add_post("/api/auth/passkey/register/verify", handle_passkey_register_verify) + app.router.add_post("/api/auth/passkey/login/options", handle_passkey_login_options) + app.router.add_post("/api/auth/passkey/login/verify", handle_passkey_login_verify) + app.router.add_get("/api/auth/passkey/list", handle_passkey_list) + app.router.add_delete("/api/auth/passkey/{credential_id}", handle_passkey_delete) + app.router.add_post("/api/account/email", handle_account_email) + app.router.add_get("/api/me", handle_me) + app.router.add_post("/api/auto-renew", handle_auto_renew) + app.router.add_get("/api/transactions", handle_transactions) + app.router.add_get("/api/tariffs", handle_tariffs) + app.router.add_post("/api/buy", handle_buy) + app.router.add_get("/api/traffic/packs", handle_traffic_packs) + app.router.add_post("/api/traffic/buy", handle_traffic_buy) + app.router.add_get("/api/devices/packs", handle_device_packs) + app.router.add_post("/api/devices/buy", handle_device_buy) + app.router.add_get("/api/clients", handle_clients) + app.router.add_get("/api/servers", handle_servers) + app.router.add_get("/api/faq", handle_faq) + app.router.add_get("/api/tickets", handle_tickets_list) + app.router.add_post("/api/tickets", handle_tickets_create) + app.router.add_get("/api/tickets/{ticket_id}", handle_ticket_get) + app.router.add_post("/api/tickets/{ticket_id}/messages", handle_ticket_reply) + app.router.add_post("/api/tickets/{ticket_id}/close", handle_ticket_close) + app.router.add_post("/api/payments/heleket", handle_heleket_webhook) + app.router.add_get("/api/payments/digiseller", handle_digiseller_callback) + app.router.add_post("/api/payments/digiseller", handle_digiseller_callback) + app.router.add_post("/api/payments/lava", handle_lava_webhook) + app.router.add_post("/api/payments/anypay", handle_anypay_webhook) + app.router.add_get("/api/payments/anypay", handle_anypay_webhook) + app.router.add_get("/pay/lava/success", handle_lava_success_page) + app.router.add_get("/pay/lava/fail", handle_lava_fail_page) + app.router.add_get("/pay/anypay/success", handle_anypay_success_page) + app.router.add_get("/pay/anypay/fail", handle_anypay_fail_page) + app.router.add_post("/api/topup/redeem", handle_topup_redeem) + app.router.add_get("/api/topup/methods", handle_topup_methods) + app.router.add_post("/api/topup/invoice", handle_topup_invoice) + app.router.add_get("/api/stars/packs", handle_stars_packs) + app.router.add_post("/api/stars/invoice", handle_stars_invoice) + app.router.add_get("/api/wg", handle_wg_status) + app.router.add_get("/api/wg/configs", handle_wg_list) + app.router.add_post("/api/wg/configs", handle_wg_buy) + app.router.add_get("/api/wg/configs/{config_id}/qr", handle_wg_qr) + app.router.add_post("/api/wg/configs/{config_id}/enable", handle_wg_enable) + app.router.add_post("/api/wg/configs/{config_id}/disable", handle_wg_disable) + app.router.add_delete("/api/wg/configs/{config_id}", handle_wg_delete) + + app.router.add_get("/api/awg", handle_awg_status) + app.router.add_get("/api/awg/configs", handle_awg_list) + app.router.add_post("/api/awg/configs", handle_awg_buy) + app.router.add_get("/api/awg/configs/{config_id}/qr", handle_awg_qr) + app.router.add_post("/api/awg/configs/{config_id}/enable", handle_awg_enable) + app.router.add_post("/api/awg/configs/{config_id}/disable", handle_awg_disable) + app.router.add_delete("/api/awg/configs/{config_id}", handle_awg_delete) + + register_topup_admin_routes(app, webapp_dir=WEBAPP_DIR) + register_growth_routes(app) + + if WEBAPP_DIR.is_dir(): + app.router.add_get("/", handle_index) + app.router.add_static("/static/", WEBAPP_DIR / "static", name="static") + + return app + + +async def handle_index(_: web.Request) -> web.Response: + index = WEBAPP_DIR / "index.html" + resp = web.FileResponse(index) + # Нужно для popup Telegram Login (postMessage) + resp.headers["Cross-Origin-Opener-Policy"] = "same-origin-allow-popups" + return resp + + +async def handle_health(_: web.Request) -> web.Response: + return web.json_response({"ok": True}) + + +async def handle_config(request: web.Request) -> web.Response: + settings: Settings = request.app["settings"] + return web.json_response( + { + "ok": True, + "bot_username": request.app["bot_username"], + "telegram_client_id": settings.effective_telegram_client_id, + "webapp_url": settings.webapp_url, + "crypto_enabled": request.app["cryptopay"].enabled, + "heleket_enabled": request.app["heleket"].enabled, + "digiseller_enabled": request.app["digiseller"].enabled, + "lava_enabled": request.app["lava"].enabled, + "anypay_enabled": request.app["anypay"].enabled, + "stars_enabled": settings.stars_enabled, + "stars_rub_per_star": settings.stars_rub_per_star, + "topup_code_pay_url": settings.topup_code_pay_url, + "topup": topup_methods_payload( + cryptopay=request.app["cryptopay"], + heleket=request.app["heleket"], + lava=request.app["lava"], + anypay=request.app["anypay"], + ), + } + ) + + +async def handle_topup_methods(request: web.Request) -> web.Response: + return web.json_response( + { + "ok": True, + **topup_methods_payload( + cryptopay=request.app["cryptopay"], + heleket=request.app["heleket"], + lava=request.app["lava"], + anypay=request.app["anypay"], + ), + } + ) + + +async def handle_topup_invoice(request: web.Request) -> web.Response: + try: + user = _require_user(request) + except web.HTTPUnauthorized: + return web.json_response({"ok": False, "error": "unauthorized"}, status=401) + + try: + body = await request.json() + except json.JSONDecodeError: + return web.json_response({"ok": False, "error": "invalid json"}, status=400) + + db: Database = request.app["db"] + try: + inv = await create_web_topup_invoice( + db=db, + telegram_id=user.telegram_id, + amount_rub=body.get("amount"), + provider=body.get("provider"), + cryptopay=request.app["cryptopay"], + heleket=request.app["heleket"], + lava=request.app["lava"], + anypay=request.app["anypay"], + ) + except TopupError as exc: + return web.json_response({"ok": False, "error": str(exc)}, status=400) + except Exception as exc: # noqa: BLE001 + logger.exception("web topup invoice failed") + return web.json_response( + {"ok": False, "error": f"Не удалось создать счёт: {exc}"}, + status=500, + ) + + return web.json_response( + { + "ok": True, + "provider": inv.provider, + "title": inv.title, + "amount": inv.amount_rub, + "pay_url": inv.pay_url, + "invoice_id": inv.invoice_id, + "order_id": inv.order_id, + "message": ( + f"Счёт на {inv.amount_rub:.0f} ₽ создан. " + "После оплаты баланс зачислится автоматически." + ), + } + ) + + +async def handle_topup_redeem(request: web.Request) -> web.Response: + if not allow(request, "topup_redeem", limit=10, window_sec=300): + return web.json_response({"ok": False, "error": "Слишком много попыток"}, status=429) + user = _auth_user_optional(request) + if not user: + return web.json_response( + {"ok": False, "error": "Войдите в аккаунт"}, + status=401, + ) + if user.banned: + return web.json_response({"ok": False, "error": "Аккаунт заблокирован"}, status=403) + try: + body = await request.json() + except json.JSONDecodeError: + return web.json_response({"ok": False, "error": "invalid json"}, status=400) + + code = str(body.get("code") or "") + db: Database = request.app["db"] + try: + result = redeem_topup_code(db, telegram_id=user.telegram_id, raw_code=code) + db.log_payment_event( + provider="topup_code", + event_type="redeem", + telegram_id=user.telegram_id, + amount=float(getattr(result, "amount", 0) or 0), + status="ok", + meta=code[:32], + ) + except TopupCodeError as exc: + db.log_payment_event( + provider="topup_code", + event_type="redeem", + telegram_id=user.telegram_id, + status="error", + meta=str(exc)[:200], + ) + return web.json_response({"ok": False, "error": str(exc)}, status=400) + + plain = ( + result.message.replace("", "") + .replace("", "") + .replace("", "") + .replace("", "") + ) + return web.json_response( + { + "ok": result.ok, + "already": result.already, + "amount": result.amount, + "balance": result.balance, + "message": plain, + } + ) + + +async def handle_stars_packs(request: web.Request) -> web.Response: + settings: Settings = request.app["settings"] + if not settings.stars_enabled: + return web.json_response({"ok": False, "error": "Stars выключены"}, status=503) + return web.json_response( + { + "ok": True, + "rub_per_star": settings.stars_rub_per_star, + "packs": packs_payload(rub_per_star=settings.stars_rub_per_star), + "note": ( + "Оплата через Telegram Stars (Mini App / Telegram Web). " + f"Курс: 1 ⭐ ≈ {settings.stars_rub_per_star:g} ₽" + ), + } + ) + + +async def handle_stars_invoice(request: web.Request) -> web.Response: + settings: Settings = request.app["settings"] + if not settings.stars_enabled: + return web.json_response({"ok": False, "error": "Stars выключены"}, status=503) + + bot = request.app.get("bot") + if bot is None: + return web.json_response( + {"ok": False, "error": "Бот недоступен для создания счёта"}, + status=503, + ) + + # Stars только для Telegram-пользователя (initData) + init_data = request.headers.get("X-Telegram-Init-Data") or "" + db: Database = request.app["db"] + user: BotUser | None = None + if init_data.strip(): + try: + tg = parse_and_validate_init_data(init_data, settings.bot_token) + user = db.ensure_user( + tg.id, + username=tg.username, + full_name=tg.full_name, + ) + except InitDataError as exc: + return web.json_response({"ok": False, "error": str(exc)}, status=401) + else: + user = _auth_user_optional(request) + + if not user: + return web.json_response( + {"ok": False, "error": "Откройте кабинет из Telegram"}, + status=401, + ) + if user.telegram_id <= 0: + return web.json_response( + { + "ok": False, + "error": "Stars доступны только через Telegram. Войдите через Telegram Login или Mini App.", + }, + status=400, + ) + + try: + body = await request.json() + except json.JSONDecodeError: + return web.json_response({"ok": False, "error": "invalid json"}, status=400) + + try: + amount = float(body.get("amount") or body.get("amount_rub") or 0) + except (TypeError, ValueError): + return web.json_response({"ok": False, "error": "bad amount"}, status=400) + if amount <= 0: + return web.json_response({"ok": False, "error": "amount must be > 0"}, status=400) + + try: + created = await create_stars_topup( + bot=bot, + db=db, + telegram_id=user.telegram_id, + amount_rub=amount, + rub_per_star=settings.stars_rub_per_star, + ) + except Exception as exc: # noqa: BLE001 + logger.exception("stars invoice create failed") + return web.json_response({"ok": False, "error": str(exc)}, status=500) + + return web.json_response({"ok": True, **created}) + + +def _digiseller_html( + *, + title: str, + body: str, + bot_username: str, + ok: bool, +) -> str: + bot_link = f"https://t.me/{bot_username}" if bot_username else "#" + color = "#16a34a" if ok else "#dc2626" + return f""" + + + + + {title} + + + +
    +

    {title}

    +

    {body}

    + Открыть бота +
    + +""" + + +async def handle_digiseller_callback(request: web.Request) -> web.Response: + """Автопроверка уникального кода (deliver_url Digiseller).""" + digiseller: Digiseller = request.app["digiseller"] + bot_username = str(request.app.get("bot_username") or "") + if not digiseller.enabled: + return web.Response( + text=_digiseller_html( + title="Digiseller выключен", + body="Пополнение через Digiseller не настроено.", + bot_username=bot_username, + ok=False, + ), + content_type="text/html", + status=503, + ) + + code = ( + request.rel_url.query.get("uniquecode") + or request.rel_url.query.get("unique_code") + or request.rel_url.query.get("UniqueCode") + or "" + ).strip() + tg_hint_raw = ( + request.rel_url.query.get("telegram_id") + or request.rel_url.query.get("tg_id") + or "" + ).strip() + + if request.method == "POST" and not code: + ctype = request.headers.get("Content-Type", "") + try: + if "json" in ctype: + data = await request.json() + if isinstance(data, dict): + code = str( + data.get("uniquecode") + or data.get("unique_code") + or data.get("UniqueCode") + or "" + ).strip() + if not tg_hint_raw: + tg_hint_raw = str( + data.get("telegram_id") or data.get("tg_id") or "" + ).strip() + else: + form = await request.post() + code = str( + form.get("uniquecode") + or form.get("unique_code") + or form.get("UniqueCode") + or "" + ).strip() + if not tg_hint_raw: + tg_hint_raw = str( + form.get("telegram_id") or form.get("tg_id") or "" + ).strip() + except Exception: # noqa: BLE001 + logger.exception("Digiseller callback: bad POST body") + + if not code: + return web.Response( + text=_digiseller_html( + title="Нет кода", + body="В ссылке нет параметра uniquecode. Вернись в бота.", + bot_username=bot_username, + ok=False, + ), + content_type="text/html", + status=400, + ) + + tg_hint: int | None = None + if tg_hint_raw.isdigit(): + tg_hint = int(tg_hint_raw) + + try: + result = await fulfill_digiseller_unique_code( + db=request.app["db"], + digiseller=digiseller, + unique_code=code, + telegram_id_hint=tg_hint, + bot=request.app.get("bot"), + notify=True, + ) + except DigisellerError as exc: + logger.warning("Digiseller callback reject: %s", exc) + return web.Response( + text=_digiseller_html( + title="Не удалось зачислить", + body=str(exc), + bot_username=bot_username, + ok=False, + ), + content_type="text/html", + status=400, + ) + except Exception as exc: # noqa: BLE001 + logger.exception("Digiseller callback error") + return web.Response( + text=_digiseller_html( + title="Ошибка", + body=str(exc), + bot_username=bot_username, + ok=False, + ), + content_type="text/html", + status=500, + ) + + if result.already: + title = "Уже зачислено" + body = ( + f"Этот платёж уже обработан.
    " + f"Баланс: {result.balance:.0f} ₽" + ) + else: + title = "Баланс пополнен" + body = ( + f"+{result.amount:.0f} ₽ зачислено автоматически.
    " + f"Доступно: {result.balance:.0f} ₽
    " + f"Можешь закрыть страницу и вернуться в бота." + ) + + return web.Response( + text=_digiseller_html( + title=title, + body=body, + bot_username=bot_username, + ok=True, + ), + content_type="text/html", + ) + + +async def handle_heleket_webhook(request: web.Request) -> web.Response: + heleket: Heleket = request.app["heleket"] + if not heleket.enabled: + return web.json_response({"ok": False, "error": "disabled"}, status=503) + + try: + data = await request.json() + except json.JSONDecodeError: + return web.json_response({"ok": False, "error": "invalid json"}, status=400) + + if not isinstance(data, dict) or not verify_webhook_sign(data, heleket.api_key): + logger.warning("Heleket webhook: bad signature") + return web.json_response({"ok": False, "error": "bad sign"}, status=403) + + status = str(data.get("status") or "") + order_id = str(data.get("order_id") or "") + if not order_id: + return web.json_response({"ok": False, "error": "no order_id"}, status=400) + + if status not in ("paid", "paid_over"): + return web.json_response({"ok": True, "ignored": status}) + + db: Database = request.app["db"] + local = db.get_invoice_by_order_id(order_id) + if not local: + logger.warning("Heleket webhook: unknown order_id=%s", order_id) + return web.json_response({"ok": False, "error": "unknown order"}, status=404) + + try: + result = await fulfill_paid_invoice( + db=db, + sdk=request.app["sdk"], + tariffs=request.app["tariffs"], + local=local, + ) + except Exception: # noqa: BLE001 + logger.exception("Heleket fulfill failed order_id=%s", order_id) + return web.json_response({"ok": False, "error": "fulfill failed"}, status=500) + + logger.info( + "Heleket paid order_id=%s already=%s ok=%s", + order_id, + result.already, + result.ok, + ) + return web.json_response({"ok": True, "already": result.already}) + + +async def handle_lava_webhook(request: web.Request) -> web.Response: + lava: Lava = request.app["lava"] + if not lava.enabled: + return web.json_response({"ok": False, "error": "disabled"}, status=503) + + raw = await request.read() + try: + data = json.loads(raw.decode("utf-8") or "{}") + except json.JSONDecodeError: + return web.json_response({"ok": False, "error": "invalid json"}, status=400) + + if not isinstance(data, dict): + return web.json_response({"ok": False, "error": "invalid body"}, status=400) + + auth = request.headers.get("Authorization") + if not lava.additional_key: + logger.error("Lava webhook rejected: LAVA_ADDITIONAL_KEY not set") + return web.json_response( + {"ok": False, "error": "LAVA_ADDITIONAL_KEY required"}, + status=503, + ) + if not verify_lava_webhook( + data=data, + raw_body=raw, + authorization=auth, + additional_key=lava.additional_key, + ): + logger.warning("Lava webhook: bad signature") + return web.json_response({"ok": False, "error": "bad sign"}, status=403) + + status = str(data.get("status") or "").lower() + order_id = str(data.get("order_id") or data.get("orderId") or "").strip() + if not order_id: + return web.json_response({"ok": False, "error": "no order_id"}, status=400) + + if status not in ("success", "paid"): + return web.json_response({"ok": True, "ignored": status}) + + db: Database = request.app["db"] + local = db.get_invoice_by_order_id(order_id) + if not local: + logger.warning("Lava webhook: unknown order_id=%s", order_id) + return web.json_response({"ok": False, "error": "unknown order"}, status=404) + + try: + result = await fulfill_paid_invoice( + db=db, + sdk=request.app["sdk"], + tariffs=request.app["tariffs"], + local=local, + ) + except Exception: # noqa: BLE001 + logger.exception("Lava fulfill failed order_id=%s", order_id) + return web.json_response({"ok": False, "error": "fulfill failed"}, status=500) + + bot = request.app.get("bot") + if bot is not None and result.ok and not result.already: + try: + await bot.send_message( + int(local["telegram_id"]), + result.message, + parse_mode="HTML", + ) + except Exception as exc: # noqa: BLE001 + logger.warning("Lava notify failed: %s", exc) + + logger.info( + "Lava paid order_id=%s already=%s ok=%s", + order_id, + result.already, + result.ok, + ) + return web.json_response({"ok": True, "already": result.already}) + + +async def _anypay_form_data(request: web.Request) -> dict[str, Any]: + data: dict[str, Any] = {} + if request.method == "GET": + for k, v in request.rel_url.query.items(): + data[k] = v + return data + try: + if request.content_type and "json" in request.content_type: + body = await request.json() + if isinstance(body, dict): + return body + except Exception: # noqa: BLE001 + pass + try: + form = await request.post() + for k in form.keys(): + data[str(k)] = form.get(k) + except Exception: # noqa: BLE001 + logger.exception("AnyPay webhook: bad form") + return data + + +async def handle_anypay_webhook(request: web.Request) -> web.Response: + anypay: Anypay = request.app["anypay"] + if not anypay.enabled: + return web.Response(text="disabled", status=503) + + data = await _anypay_form_data(request) + if not data: + return web.Response(text="empty", status=400) + + if anypay.check_ip: + peer = request.remote + forwarded = request.headers.get("X-Forwarded-For") + if not client_ip_allowed(peer, forwarded=forwarded): + logger.warning("AnyPay webhook: bad ip peer=%s xff=%s", peer, forwarded) + return web.Response(text="bad ip", status=403) + + if not verify_anypay_sign( + data, project_id=anypay.project_id, api_key=anypay.api_key + ): + logger.warning("AnyPay webhook: bad sign pay_id=%s", data.get("pay_id")) + return web.Response(text="wrong sign", status=403) + + status = str(data.get("status") or "").lower() + pay_id = str(data.get("pay_id") or "").strip() + if not pay_id: + return web.Response(text="no pay_id", status=400) + + if status not in ANYPAY_PAID and status != "paid": + # ещё не финал — принимаем без зачисления + return web.Response(text="OK") + + # сверить сумму с инвойсом + try: + amount_notify = float(data.get("amount") or 0) + except (TypeError, ValueError): + amount_notify = 0.0 + + db: Database = request.app["db"] + local = db.get_invoice_by_order_id(pay_id) + if not local: + # иногда pay_id = local invoice_id + try: + local = db.get_invoice(int(pay_id), provider="anypay") + except Exception: # noqa: BLE001 + local = None + if not local: + logger.warning("AnyPay webhook: unknown pay_id=%s", pay_id) + return web.Response(text="unknown", status=404) + + expected = float(local.get("amount_rub") or 0) + if amount_notify > 0 and abs(amount_notify - expected) > 0.05: + logger.warning( + "AnyPay amount mismatch pay_id=%s got=%s expected=%s", + pay_id, + amount_notify, + expected, + ) + return web.Response(text="bad amount", status=400) + + try: + result = await fulfill_paid_invoice( + db=db, + sdk=request.app["sdk"], + tariffs=request.app["tariffs"], + local=local, + ) + except Exception: # noqa: BLE001 + logger.exception("AnyPay fulfill failed pay_id=%s", pay_id) + return web.Response(text="error", status=500) + + bot = request.app.get("bot") + if bot is not None and result.ok and not result.already: + try: + await bot.send_message( + int(local["telegram_id"]), + result.message, + parse_mode="HTML", + ) + except Exception as exc: # noqa: BLE001 + logger.warning("AnyPay notify failed: %s", exc) + + logger.info( + "AnyPay paid pay_id=%s already=%s ok=%s", + pay_id, + result.already, + result.ok, + ) + # Важно: ответ OK латиницей — иначе AnyPay будет ретраить + return web.Response(text="OK") + + +async def handle_anypay_success_page(request: web.Request) -> web.Response: + bot_username = str(request.app.get("bot_username") or "") + return web.Response( + text=_digiseller_html( + title="Оплата прошла", + body="Если баланс ещё не обновился — подождите минуту и обновите кабинет.", + bot_username=bot_username, + ok=True, + ), + content_type="text/html", + ) + + +async def handle_anypay_fail_page(request: web.Request) -> web.Response: + bot_username = str(request.app.get("bot_username") or "") + return web.Response( + text=_digiseller_html( + title="Оплата не завершена", + body="Платёж отменён или не прошёл. Можно попробовать снова в кабинете.", + bot_username=bot_username, + ok=False, + ), + content_type="text/html", + ) + + +async def handle_lava_success_page(request: web.Request) -> web.Response: + bot_username = str(request.app.get("bot_username") or "") + return web.Response( + text=_digiseller_html( + title="Оплата прошла", + body=( + "Платёж Lava принят. Баланс зачислится в течение минуты — " + "проверь уведомление в боте." + ), + bot_username=bot_username, + ok=True, + ), + content_type="text/html", + ) + + +async def handle_lava_fail_page(request: web.Request) -> web.Response: + bot_username = str(request.app.get("bot_username") or "") + return web.Response( + text=_digiseller_html( + title="Оплата не завершена", + body="Платёж отменён или не прошёл. Можно вернуться в бота и попробовать снова.", + bot_username=bot_username, + ok=False, + ), + content_type="text/html", + ) + + +def _secure_cookie(request: web.Request) -> bool: + # За прокси Caddy обычно HTTPS; X-Forwarded-Proto надёжнее для cookie Secure + proto = request.headers.get("X-Forwarded-Proto", "").lower() + if proto == "https": + return True + return request.url.scheme == "https" + + +def _set_session_cookie(response: web.Response, request: web.Request, token: str) -> None: + response.set_cookie( + SESSION_COOKIE, + token, + max_age=SESSION_DAYS * 24 * 3600, + httponly=True, + samesite="Lax", + secure=_secure_cookie(request), + path="/", + ) + + +def _clear_session_cookie(response: web.Response, request: web.Request) -> None: + response.del_cookie(SESSION_COOKIE, path="/") + + +def _session_token(request: web.Request) -> str | None: + auth = request.headers.get("Authorization") or "" + if auth.lower().startswith("bearer "): + return auth[7:].strip() or None + return request.cookies.get(SESSION_COOKIE) + + +def _auth_user_optional(request: web.Request) -> BotUser | None: + """Сессия cookie/Bearer или Telegram Mini App initData.""" + settings: Settings = request.app["settings"] + db: Database = request.app["db"] + + init_data = ( + request.headers.get("X-Telegram-Init-Data") + or request.query.get("initData") + or "" + ) + if init_data.strip(): + try: + tg = parse_and_validate_init_data(init_data, settings.bot_token) + return db.ensure_user( + tg.id, + username=tg.username, + full_name=tg.full_name, + ) + except InitDataError: + pass + + return db.get_user_by_session(_session_token(request)) + + +def _require_user(request: web.Request) -> BotUser: + user = _auth_user_optional(request) + if not user: + raise web.HTTPUnauthorized( + text=json.dumps({"ok": False, "error": "unauthorized"}), + content_type="application/json", + ) + if user.banned: + raise web.HTTPForbidden( + text=json.dumps({"ok": False, "error": "Аккаунт заблокирован"}), + content_type="application/json", + ) + return user + + +def _user_payload(user: BotUser) -> dict[str, Any]: + return { + "id": user.telegram_id, + "telegram_id": user.telegram_id, + "name": user.display_name, + "username": user.username, + "login": user.login, + "has_password": user.has_password, + "is_telegram": user.is_telegram, + "auto_renew": bool(user.auto_renew), + "email": user.email, + "banned": bool(user.banned), + "referral_code": user.referral_code, + "trial_used": bool(user.trial_used_at), + "notify_telegram": bool(user.notify_telegram), + "notify_email": bool(user.notify_email), + } + + +async def handle_register(request: web.Request) -> web.Response: + db: Database = request.app["db"] + if not allow(request, "auth_register", limit=8, window_sec=600): + return web.json_response({"ok": False, "error": "Слишком много попыток"}, status=429) + try: + body = await request.json() + except json.JSONDecodeError: + return web.json_response({"ok": False, "error": "invalid json"}, status=400) + + login = str(body.get("login") or "") + password = str(body.get("password") or "") + full_name = str(body.get("full_name") or "").strip() or None + auto_renew = bool(body.get("auto_renew", True)) + ref = str(body.get("ref") or request.cookies.get("vpn_ref") or "").strip() + + try: + user, token = db.register_with_password( + login=login, + password=password, + full_name=full_name, + auto_renew=auto_renew, + ) + except AuthError as exc: + return web.json_response({"ok": False, "error": str(exc)}, status=400) + + if ref: + db.attach_referrer(user.telegram_id, ref) + db.ensure_referral_code(user.telegram_id) + db.clear_session(token) + token = db.create_session( + user.telegram_id, + user_agent=request.headers.get("User-Agent"), + ip=client_ip(request), + ) + user = db.get_user(user.telegram_id) or user + + resp = web.json_response( + { + "ok": True, + "token": token, + "user": _user_payload(user), + "balance": user.balance, + } + ) + _set_session_cookie(resp, request, token) + return resp + + +async def handle_login(request: web.Request) -> web.Response: + db: Database = request.app["db"] + if not allow(request, "auth_login", limit=20, window_sec=300): + return web.json_response({"ok": False, "error": "Слишком много попыток"}, status=429) + try: + body = await request.json() + except json.JSONDecodeError: + return web.json_response({"ok": False, "error": "invalid json"}, status=400) + + try: + user, token = db.login_with_password( + str(body.get("login") or ""), + str(body.get("password") or ""), + ) + except AuthError as exc: + return web.json_response({"ok": False, "error": str(exc)}, status=401) + + if user.banned: + return web.json_response( + {"ok": False, "error": "Аккаунт заблокирован"}, status=403 + ) + + db.clear_session(token) + token = db.create_session( + user.telegram_id, + user_agent=request.headers.get("User-Agent"), + ip=client_ip(request), + ) + + resp = web.json_response( + { + "ok": True, + "token": token, + "user": _user_payload(user), + "balance": user.balance, + } + ) + _set_session_cookie(resp, request, token) + return resp + + +async def handle_telegram_auth(request: web.Request) -> web.Response: + settings: Settings = request.app["settings"] + db: Database = request.app["db"] + try: + body = await request.json() + except json.JSONDecodeError: + return web.json_response({"ok": False, "error": "invalid json"}, status=400) + + try: + id_token = str(body.get("id_token") or "").strip() + if id_token: + tg = parse_and_validate_id_token( + id_token, + settings.effective_telegram_client_id, + ) + else: + # legacy Login Widget (HMAC) — на случай старых клиентов + tg = parse_and_validate_login_widget(body, settings.bot_token) + except InitDataError as exc: + return web.json_response({"ok": False, "error": str(exc)}, status=401) + + link_from = None + current = db.get_user_by_session(_session_token(request)) + if current and not current.is_telegram: + link_from = current.telegram_id + + try: + user, token = db.login_or_register_telegram( + tg.id, + username=tg.username, + full_name=tg.full_name, + link_from=link_from, + ) + except AuthError as exc: + return web.json_response({"ok": False, "error": str(exc)}, status=400) + + resp = web.json_response( + { + "ok": True, + "token": token, + "user": _user_payload(user), + "balance": user.balance, + } + ) + _set_session_cookie(resp, request, token) + return resp + + +async def handle_logout(request: web.Request) -> web.Response: + db: Database = request.app["db"] + token = _session_token(request) + db.clear_session(token) + resp = web.json_response({"ok": True}) + _clear_session_cookie(resp, request) + return resp + + +async def handle_qr_auth_start(request: web.Request) -> web.Response: + settings: Settings = request.app["settings"] + db: Database = request.app["db"] + bot_username = str(request.app.get("bot_username") or "").lstrip("@") + created = db.create_login_qr() + token = created["token"] + deep_link = ( + f"https://t.me/{bot_username}?start=qrauth_{token}" if bot_username else "" + ) + web_approve = "" + if settings.webapp_url: + web_approve = f"{settings.webapp_url.rstrip('/')}/?qr_approve={token}" + qr_payload = deep_link or web_approve + if not qr_payload: + return web.json_response( + {"ok": False, "error": "Нет username бота и WEBAPP_URL"}, + status=400, + ) + try: + svg = make_qr_svg(qr_payload, scale=5, border=2) + qr_data_url = "data:image/svg+xml;base64," + base64.b64encode(svg).decode( + "ascii" + ) + except Exception as exc: # noqa: BLE001 + logger.exception("qr svg failed") + return web.json_response({"ok": False, "error": str(exc)}, status=500) + + return web.json_response( + { + "ok": True, + "token": token, + "deep_link": deep_link, + "web_approve_url": web_approve, + "expires_in": created["expires_in"], + "qr_svg": qr_data_url, + } + ) + + +async def handle_qr_auth_poll(request: web.Request) -> web.Response: + db: Database = request.app["db"] + token = (request.rel_url.query.get("token") or "").strip() + try: + result = db.poll_login_qr(token) + except AuthError as exc: + return web.json_response({"ok": False, "error": str(exc)}, status=400) + + status = result.get("status") + if status == "approved": + user: BotUser = result["user"] + session = str(result["token"]) + resp = web.json_response( + { + "ok": True, + "status": "approved", + "token": session, + "user": _user_payload(user), + "balance": user.balance, + } + ) + _set_session_cookie(resp, request, session) + return resp + return web.json_response({"ok": True, "status": status}) + + +async def handle_qr_auth_approve(request: web.Request) -> web.Response: + """Подтверждение QR с уже авторизованного устройства (браузер).""" + db: Database = request.app["db"] + try: + user = _require_user(request) + except web.HTTPUnauthorized: + return web.json_response({"ok": False, "error": "unauthorized"}, status=401) + try: + body = await request.json() + except json.JSONDecodeError: + return web.json_response({"ok": False, "error": "invalid json"}, status=400) + token = str(body.get("token") or "").strip() + try: + db.approve_login_qr(token, user.telegram_id) + except AuthError as exc: + return web.json_response({"ok": False, "error": str(exc)}, status=400) + return web.json_response({"ok": True}) + + +async def handle_account_email(request: web.Request) -> web.Response: + db: Database = request.app["db"] + try: + user = _require_user(request) + except web.HTTPUnauthorized: + return web.json_response({"ok": False, "error": "unauthorized"}, status=401) + try: + body = await request.json() + except json.JSONDecodeError: + return web.json_response({"ok": False, "error": "invalid json"}, status=400) + email = body.get("email") + try: + updated = db.set_user_email( + user.telegram_id, + None if email is None else str(email), + ) + except AuthError as exc: + return web.json_response({"ok": False, "error": str(exc)}, status=400) + return web.json_response({"ok": True, "user": _user_payload(updated)}) + + +async def handle_passkey_register_options(request: web.Request) -> web.Response: + settings: Settings = request.app["settings"] + db: Database = request.app["db"] + try: + user = _require_user(request) + except web.HTTPUnauthorized: + return web.json_response({"ok": False, "error": "unauthorized"}, status=401) + try: + existing = db.list_webauthn_credentials(user.telegram_id) + existing_ids = [credential_id_bytes(r["credential_id"]) for r in existing] + user_name = user.login or user.username or f"user-{user.telegram_id}" + options, challenge = build_registration_options( + settings, + user_id=user.telegram_id, + user_name=user_name, + user_display_name=user.display_name, + existing_credential_ids=existing_ids, + ) + db.save_webauthn_challenge( + challenge_b64=bytes_to_base64url(challenge), + purpose="register", + telegram_id=user.telegram_id, + ) + except Exception as exc: # noqa: BLE001 + logger.exception("passkey register options") + return web.json_response({"ok": False, "error": str(exc)}, status=400) + return web.json_response({"ok": True, "options": options}) + + +async def handle_passkey_register_verify(request: web.Request) -> web.Response: + settings: Settings = request.app["settings"] + db: Database = request.app["db"] + try: + user = _require_user(request) + except web.HTTPUnauthorized: + return web.json_response({"ok": False, "error": "unauthorized"}, status=401) + try: + body = await request.json() + except json.JSONDecodeError: + return web.json_response({"ok": False, "error": "invalid json"}, status=400) + + credential = body.get("credential") + if not isinstance(credential, dict): + return web.json_response({"ok": False, "error": "credential required"}, status=400) + + client_data = credential.get("response", {}).get("clientDataJSON") or "" + try: + challenge_b64 = challenge_from_client_data(str(client_data)) + except Exception: # noqa: BLE001 + challenge_b64 = None + if not challenge_b64: + return web.json_response({"ok": False, "error": "challenge missing"}, status=400) + + stored = db.take_webauthn_challenge(str(challenge_b64), purpose="register") + if not stored or stored.get("telegram_id") != user.telegram_id: + return web.json_response({"ok": False, "error": "challenge expired"}, status=400) + + try: + verification = verify_registration( + settings, + credential=credential, + expected_challenge=credential_id_bytes(str(challenge_b64)), + ) + cid = credential_id_b64(verification.credential_id) + transports = parse_transports(credential.get("transports")) + db.add_webauthn_credential( + telegram_id=user.telegram_id, + credential_id=cid, + public_key=verification.credential_public_key, + sign_count=int(verification.sign_count or 0), + transports=transports, + device_name=str(body.get("device_name") or "").strip() or None, + ) + except Exception as exc: # noqa: BLE001 + logger.exception("passkey register verify") + return web.json_response({"ok": False, "error": str(exc)}, status=400) + + return web.json_response({"ok": True, "credential_id": cid}) + + +async def handle_passkey_login_options(request: web.Request) -> web.Response: + settings: Settings = request.app["settings"] + db: Database = request.app["db"] + try: + body = await request.json() + except json.JSONDecodeError: + body = {} + + allow_ids: list[bytes] | None = None + login = str((body or {}).get("login") or "").strip() + telegram_id: int | None = None + if login: + user = db.get_user_by_login(login) + if not user: + return web.json_response( + {"ok": False, "error": "Пользователь не найден"}, status=404 + ) + creds = db.list_webauthn_credentials(user.telegram_id) + if not creds: + return web.json_response( + {"ok": False, "error": "Passkey не привязан"}, status=400 + ) + allow_ids = [credential_id_bytes(c["credential_id"]) for c in creds] + telegram_id = user.telegram_id + + try: + options, challenge = build_authentication_options( + settings, allow_credential_ids=allow_ids + ) + db.save_webauthn_challenge( + challenge_b64=bytes_to_base64url(challenge), + purpose="login", + telegram_id=telegram_id, + ) + except Exception as exc: # noqa: BLE001 + logger.exception("passkey login options") + return web.json_response({"ok": False, "error": str(exc)}, status=400) + return web.json_response({"ok": True, "options": options}) + + +async def handle_passkey_login_verify(request: web.Request) -> web.Response: + settings: Settings = request.app["settings"] + db: Database = request.app["db"] + try: + body = await request.json() + except json.JSONDecodeError: + return web.json_response({"ok": False, "error": "invalid json"}, status=400) + + credential = body.get("credential") + if not isinstance(credential, dict): + return web.json_response({"ok": False, "error": "credential required"}, status=400) + + raw_id = str(credential.get("id") or credential.get("rawId") or "").strip() + if not raw_id: + return web.json_response({"ok": False, "error": "credential id missing"}, status=400) + + stored_cred = db.get_webauthn_credential(raw_id) + if not stored_cred: + return web.json_response({"ok": False, "error": "Неизвестный passkey"}, status=400) + + client_data = credential.get("response", {}).get("clientDataJSON") or "" + try: + challenge_b64 = challenge_from_client_data(str(client_data)) + except Exception: # noqa: BLE001 + challenge_b64 = None + if not challenge_b64: + return web.json_response({"ok": False, "error": "challenge missing"}, status=400) + + stored = db.take_webauthn_challenge(str(challenge_b64), purpose="login") + if not stored: + return web.json_response({"ok": False, "error": "challenge expired"}, status=400) + + try: + verification = verify_authentication( + settings, + credential=credential, + expected_challenge=credential_id_bytes(str(challenge_b64)), + credential_public_key=bytes(stored_cred["public_key"]), + credential_current_sign_count=int(stored_cred["sign_count"] or 0), + ) + db.update_webauthn_sign_count( + raw_id, int(verification.new_sign_count or 0) + ) + tid = int(stored_cred["telegram_id"]) + session = db.create_session(tid) + user = db.get_user(tid) + assert user is not None + except Exception as exc: # noqa: BLE001 + logger.exception("passkey login verify") + return web.json_response({"ok": False, "error": str(exc)}, status=400) + + resp = web.json_response( + { + "ok": True, + "token": session, + "user": _user_payload(user), + "balance": user.balance, + } + ) + _set_session_cookie(resp, request, session) + return resp + + +async def handle_passkey_list(request: web.Request) -> web.Response: + db: Database = request.app["db"] + try: + user = _require_user(request) + except web.HTTPUnauthorized: + return web.json_response({"ok": False, "error": "unauthorized"}, status=401) + rows = db.list_webauthn_credentials(user.telegram_id) + items = [ + { + "id": r["credential_id"], + "device_name": r.get("device_name") or "Passkey", + "created_at": r["created_at"].isoformat() + if hasattr(r["created_at"], "isoformat") + else str(r["created_at"]), + "last_used_at": ( + r["last_used_at"].isoformat() + if r.get("last_used_at") and hasattr(r["last_used_at"], "isoformat") + else None + ), + } + for r in rows + ] + return web.json_response({"ok": True, "passkeys": items}) + + +async def handle_passkey_delete(request: web.Request) -> web.Response: + db: Database = request.app["db"] + try: + user = _require_user(request) + except web.HTTPUnauthorized: + return web.json_response({"ok": False, "error": "unauthorized"}, status=401) + cid = request.match_info.get("credential_id") or "" + ok = db.delete_webauthn_credential(user.telegram_id, cid) + if not ok: + return web.json_response({"ok": False, "error": "not found"}, status=404) + return web.json_response({"ok": True}) + + +async def handle_me(request: web.Request) -> web.Response: + settings: Settings = request.app["settings"] + db: Database = request.app["db"] + sdk = request.app["sdk"] + + # Mini App: initData → сессия, чтобы дальше работал и браузерный кабинет + init_data = request.headers.get("X-Telegram-Init-Data") or "" + session_issued: str | None = None + if init_data.strip(): + try: + tg = parse_and_validate_init_data(init_data, settings.bot_token) + user = db.ensure_user( + tg.id, + username=tg.username, + full_name=tg.full_name, + ) + session_issued = db.create_session(user.telegram_id) + except InitDataError as exc: + return web.json_response({"ok": False, "error": str(exc)}, status=401) + else: + try: + user = _require_user(request) + except web.HTTPUnauthorized: + return web.json_response({"ok": False, "error": "unauthorized"}, status=401) + + try: + sub = await get_my_subscription_info( + sdk, + user.telegram_id, + login=user.login, + db=db, + tariffs=request.app["tariffs"], + renewal_bot=request.app.get("bot"), + ) + except Exception as exc: # noqa: BLE001 + logger.exception("me subscription error") + return web.json_response({"ok": False, "error": str(exc)}, status=500) + + # Реалтайм: если подписка мертва — гасим WG/AWG на панелях + try: + await enforce_wg_configs_subscription( + db=db, sdk=sdk, telegram_id=user.telegram_id, login=user.login + ) + await enforce_awg_configs_subscription( + db=db, sdk=sdk, telegram_id=user.telegram_id, login=user.login + ) + except Exception: # noqa: BLE001 + logger.exception("vpn configs enforce on /api/me failed tg=%s", user.telegram_id) + + fresh = db.get_user(user.telegram_id) or user + extras = extras_as_dict(fresh) + + resp = web.json_response( + { + "ok": True, + "user": _user_payload(fresh), + "balance": fresh.balance, + "subscription": { + "has": sub.has_subscription, + "expire_at": sub.expire_at.isoformat() if sub.expire_at else None, + "url": sub.subscription_url, + "text": sub.text, + "traffic": sub.traffic.as_dict() if sub.traffic else None, + "devices": sub.devices.as_dict() if sub.devices else None, + "extras": extras, + }, + "crypto_enabled": request.app["cryptopay"].enabled, + "token": session_issued, + } + ) + if session_issued: + _set_session_cookie(resp, request, session_issued) + return resp + + +async def handle_auto_renew(request: web.Request) -> web.Response: + db: Database = request.app["db"] + try: + user = _require_user(request) + except web.HTTPUnauthorized: + return web.json_response({"ok": False, "error": "unauthorized"}, status=401) + + try: + body = await request.json() + except json.JSONDecodeError: + return web.json_response({"ok": False, "error": "invalid json"}, status=400) + + if "enabled" not in body: + return web.json_response({"ok": False, "error": "enabled required"}, status=400) + + updated = db.set_auto_renew(user.telegram_id, bool(body.get("enabled"))) + return web.json_response( + { + "ok": True, + "auto_renew": bool(updated.auto_renew), + "user": _user_payload(updated), + } + ) + + +async def handle_transactions(request: web.Request) -> web.Response: + db: Database = request.app["db"] + try: + user = _require_user(request) + except web.HTTPUnauthorized: + return web.json_response({"ok": False, "error": "unauthorized"}, status=401) + q = request.rel_url.query + try: + limit = int(q.get("limit") or "10") + except ValueError: + limit = 10 + try: + page = int(q.get("page") or "1") + except ValueError: + page = 1 + limit = max(1, min(limit, 50)) + page = max(1, page) + offset = (page - 1) * limit + rows, total = db.list_transactions(user.telegram_id, limit=limit, offset=offset) + pages = max(1, (total + limit - 1) // limit) if total else 1 + if page > pages and total: + page = pages + offset = (page - 1) * limit + rows, total = db.list_transactions(user.telegram_id, limit=limit, offset=offset) + items = [ + transaction_row_dict( + float(r["amount"]), + str(r["kind"]), + r.get("meta"), + r["created_at"], + ) + for r in rows + ] + return web.json_response( + { + "ok": True, + "items": items, + "total": total, + "limit": limit, + "offset": offset, + "page": page, + "pages": pages, + } + ) + + +async def handle_tariffs(request: web.Request) -> web.Response: + tariffs: list[Tariff] = request.app["tariffs"] + return web.json_response( + { + "ok": True, + "tariffs": [ + { + "id": t.id, + "title": t.title, + "price": t.price, + "price_label": t.format_price(), + "days": t.days, + "traffic_gb": t.traffic_gb, + "device_limit": t.device_limit, + "description": t.description, + } + for t in tariffs + ], + } + ) + + +async def handle_buy(request: web.Request) -> web.Response: + settings: Settings = request.app["settings"] + db: Database = request.app["db"] + user: BotUser | None = None + + init_data = request.headers.get("X-Telegram-Init-Data") or "" + if init_data.strip(): + try: + tg = parse_and_validate_init_data(init_data, settings.bot_token) + user = db.ensure_user( + tg.id, + username=tg.username, + full_name=tg.full_name, + ) + except InitDataError as exc: + return web.json_response({"ok": False, "error": str(exc)}, status=401) + else: + user = _auth_user_optional(request) + + if not user: + return web.json_response( + {"ok": False, "error": "Войдите или откройте из Telegram"}, + status=401, + ) + + try: + body = await request.json() + except json.JSONDecodeError: + return web.json_response({"ok": False, "error": "invalid json"}, status=400) + + tariff_id = str(body.get("tariff_id") or "") + renew = bool(body.get("renew")) + promo_code = str(body.get("promo_code") or "").strip() or None + tariff = get_tariff(request.app["tariffs"], tariff_id) + if not tariff: + return web.json_response({"ok": False, "error": "tariff not found"}, status=404) + + result = await purchase_tariff( + db=db, + sdk=request.app["sdk"], + telegram_id=user.telegram_id, + tariff=tariff, + charge_balance=True, + renew=renew, + promo_code=promo_code, + payment_meta=f"webapp:{'renew' if renew else 'buy'}:{tariff.id}", + ) + return web.json_response( + { + "ok": result.ok, + "message": result.message, + "balance": result.balance, + "subscription_url": result.subscription_url, + "expire_at": result.expire_at.isoformat() if result.expire_at else None, + }, + status=200 if result.ok else 400, + ) + + +async def handle_device_packs(_: web.Request) -> web.Response: + return web.json_response( + { + "ok": True, + "packs": [ + { + "id": p.id, + "slots": p.slots, + "title": p.title, + "price": p.price, + "price_label": p.format_price(), + "billing": "monthly", + } + for p in DEVICE_PACKS + ], + "monthly_note": ( + "Первое списание при покупке. Далее — каждые 30 дней с баланса " + "за все доп. устройства." + ), + } + ) + + +async def handle_device_buy(request: web.Request) -> web.Response: + settings: Settings = request.app["settings"] + db: Database = request.app["db"] + user: BotUser | None = None + + init_data = request.headers.get("X-Telegram-Init-Data") or "" + if init_data.strip(): + try: + tg = parse_and_validate_init_data(init_data, settings.bot_token) + user = db.ensure_user( + tg.id, + username=tg.username, + full_name=tg.full_name, + ) + except InitDataError as exc: + return web.json_response({"ok": False, "error": str(exc)}, status=401) + else: + user = _auth_user_optional(request) + + if not user: + return web.json_response( + {"ok": False, "error": "Войдите или откройте из Telegram"}, + status=401, + ) + + try: + body = await request.json() + except json.JSONDecodeError: + return web.json_response({"ok": False, "error": "invalid json"}, status=400) + + slots = int(body.get("slots") or 0) + pack = get_device_pack(str(body.get("pack_id") or slots or "")) + if not pack and slots > 0: + from services.devices import DevicePack, EXTRA_DEVICE_PRICE_RUB + + pack = DevicePack(slots=slots, price=EXTRA_DEVICE_PRICE_RUB * slots) + if not pack: + return web.json_response({"ok": False, "error": "pack not found"}, status=404) + + result = await purchase_device_pack( + db=db, + sdk=request.app["sdk"], + telegram_id=user.telegram_id, + pack=pack, + charge_balance=True, + login=user.login, + payment_meta=f"webapp:devices:{pack.id}", + ) + return web.json_response( + { + "ok": result.ok, + "message": result.message, + "balance": result.balance, + "devices": result.devices.as_dict() if result.devices else None, + }, + status=200 if result.ok else 400, + ) + + +async def handle_traffic_packs(_: web.Request) -> web.Response: + return web.json_response( + { + "ok": True, + "packs": [ + { + "id": p.id, + "packs": p.packs, + "gb": p.gb, + "title": p.title, + "price": p.price, + "price_label": p.format_price(), + "billing": "monthly", + } + for p in TRAFFIC_PACKS + ], + "monthly_note": ( + "Первое списание при покупке. Далее — каждые 30 дней с баланса " + "за все подключённые пакеты." + ), + } + ) + + +async def handle_traffic_buy(request: web.Request) -> web.Response: + settings: Settings = request.app["settings"] + db: Database = request.app["db"] + user: BotUser | None = None + + init_data = request.headers.get("X-Telegram-Init-Data") or "" + if init_data.strip(): + try: + tg = parse_and_validate_init_data(init_data, settings.bot_token) + user = db.ensure_user( + tg.id, + username=tg.username, + full_name=tg.full_name, + ) + except InitDataError as exc: + return web.json_response({"ok": False, "error": str(exc)}, status=401) + else: + user = _auth_user_optional(request) + + if not user: + return web.json_response( + {"ok": False, "error": "Войдите или откройте из Telegram"}, + status=401, + ) + + try: + body = await request.json() + except json.JSONDecodeError: + return web.json_response({"ok": False, "error": "invalid json"}, status=400) + + pack = get_traffic_pack(str(body.get("pack_id") or body.get("packs") or "")) + if not pack: + return web.json_response({"ok": False, "error": "pack not found"}, status=404) + + result = await purchase_traffic_pack( + db=db, + sdk=request.app["sdk"], + telegram_id=user.telegram_id, + pack=pack, + charge_balance=True, + login=user.login, + payment_meta=f"webapp:traffic:{pack.id}", + ) + return web.json_response( + { + "ok": result.ok, + "message": result.message, + "balance": result.balance, + "traffic": result.traffic.as_dict() if result.traffic else None, + }, + status=200 if result.ok else 400, + ) + + +async def handle_faq(_: web.Request) -> web.Response: + return web.json_response( + { + "ok": True, + "items": [ + { + "id": item.id, + "question": item.question, + "answer": item.answer, + } + for item in FAQ_ITEMS + ], + } + ) + + +async def handle_tickets_list(request: web.Request) -> web.Response: + try: + user = _require_user(request) + except web.HTTPUnauthorized: + return web.json_response({"ok": False, "error": "unauthorized"}, status=401) + db: Database = request.app["db"] + include_closed = (request.rel_url.query.get("closed") or "1") != "0" + tickets = db.list_tickets_for_user( + user.telegram_id, + limit=50, + include_closed=include_closed, + ) + return web.json_response( + { + "ok": True, + "tickets": [ticket_to_dict(t) for t in tickets], + "open_count": db.count_open_tickets(user.telegram_id), + "max_open": MAX_OPEN_PER_USER, + "subject_max": SUBJECT_MAX, + "body_max": BODY_MAX, + } + ) + + +async def handle_tickets_create(request: web.Request) -> web.Response: + try: + user = _require_user(request) + except web.HTTPUnauthorized: + return web.json_response({"ok": False, "error": "unauthorized"}, status=401) + db: Database = request.app["db"] + try: + body = await request.json() + except json.JSONDecodeError: + return web.json_response({"ok": False, "error": "invalid json"}, status=400) + + subject = clamp_subject(str(body.get("subject") or "")) + text = clamp_body(str(body.get("body") or body.get("message") or "")) + if len(subject) < 3: + return web.json_response( + {"ok": False, "error": "Тема слишком короткая"}, + status=400, + ) + if len(text) < 5: + return web.json_response( + {"ok": False, "error": "Сообщение слишком короткое"}, + status=400, + ) + if db.count_open_tickets(user.telegram_id) >= MAX_OPEN_PER_USER: + return web.json_response( + { + "ok": False, + "error": f"Лимит: не больше {MAX_OPEN_PER_USER} открытых тикетов", + }, + status=400, + ) + try: + ticket = db.create_ticket(user.telegram_id, subject=subject, body=text) + except ValueError as exc: + return web.json_response({"ok": False, "error": str(exc)}, status=400) + + bot = request.app.get("bot") + settings: Settings = request.app["settings"] + ai_result = {"action": "skip"} + if bot is not None: + ai_result = await try_ai_ticket_reply( + db=db, + bot=bot, + settings=settings, + ticket_id=int(ticket["id"]), + notify_user=True, + ) + if ai_result.get("action") in ("skip", "error") or ( + ai_result.get("action") == "answer" and ai_result.get("ok") + ): + if ai_result.get("action") != "escalate": + await notify_admins_new_ticket( + bot, + settings, + ticket_id=int(ticket["id"]), + telegram_id=user.telegram_id, + subject=subject, + body=text, + ai_handled=ai_result.get("action") == "answer", + ) + ticket = db.get_ticket(int(ticket["id"])) or ticket + messages = db.list_ticket_messages(int(ticket["id"])) + return web.json_response( + { + "ok": True, + "ticket": ticket_to_dict({**ticket, "message_count": len(messages)}), + "messages": [message_to_dict(m) for m in messages], + "ai": { + "action": ai_result.get("action"), + "model": ai_result.get("model"), + }, + } + ) + + +async def handle_ticket_get(request: web.Request) -> web.Response: + try: + user = _require_user(request) + except web.HTTPUnauthorized: + return web.json_response({"ok": False, "error": "unauthorized"}, status=401) + try: + tid = int(request.match_info["ticket_id"]) + except (KeyError, ValueError): + return web.json_response({"ok": False, "error": "bad id"}, status=400) + db: Database = request.app["db"] + ticket = db.get_ticket(tid) + if not ticket or int(ticket["telegram_id"]) != user.telegram_id: + return web.json_response({"ok": False, "error": "not found"}, status=404) + messages = db.list_ticket_messages(tid) + return web.json_response( + { + "ok": True, + "ticket": ticket_to_dict({**ticket, "message_count": len(messages)}), + "messages": [message_to_dict(m) for m in messages], + } + ) + + +async def handle_ticket_reply(request: web.Request) -> web.Response: + try: + user = _require_user(request) + except web.HTTPUnauthorized: + return web.json_response({"ok": False, "error": "unauthorized"}, status=401) + try: + tid = int(request.match_info["ticket_id"]) + except (KeyError, ValueError): + return web.json_response({"ok": False, "error": "bad id"}, status=400) + db: Database = request.app["db"] + ticket = db.get_ticket(tid) + if not ticket or int(ticket["telegram_id"]) != user.telegram_id: + return web.json_response({"ok": False, "error": "not found"}, status=404) + try: + body = await request.json() + except json.JSONDecodeError: + return web.json_response({"ok": False, "error": "invalid json"}, status=400) + text = clamp_body(str(body.get("body") or body.get("message") or "")) + if len(text) < 1: + return web.json_response({"ok": False, "error": "Пустое сообщение"}, status=400) + try: + ticket = db.add_ticket_message( + tid, + author_role="user", + author_id=user.telegram_id, + body=text, + ) + except ValueError as exc: + return web.json_response({"ok": False, "error": str(exc)}, status=400) + + bot = request.app.get("bot") + settings: Settings = request.app["settings"] + ai_result = {"action": "skip"} + if bot is not None: + ai_result = await try_ai_ticket_reply( + db=db, + bot=bot, + settings=settings, + ticket_id=tid, + notify_user=True, + ) + if ai_result.get("action") in ("skip", "error"): + await notify_admins_ticket_reply( + bot, + settings, + ticket_id=tid, + telegram_id=user.telegram_id, + body=text, + ) + ticket = db.get_ticket(tid) or ticket + messages = db.list_ticket_messages(tid) + return web.json_response( + { + "ok": True, + "ticket": ticket_to_dict({**ticket, "message_count": len(messages)}), + "messages": [message_to_dict(m) for m in messages], + "ai": { + "action": ai_result.get("action"), + "model": ai_result.get("model"), + }, + } + ) + + +async def handle_ticket_close(request: web.Request) -> web.Response: + try: + user = _require_user(request) + except web.HTTPUnauthorized: + return web.json_response({"ok": False, "error": "unauthorized"}, status=401) + try: + tid = int(request.match_info["ticket_id"]) + except (KeyError, ValueError): + return web.json_response({"ok": False, "error": "bad id"}, status=400) + db: Database = request.app["db"] + ticket = db.get_ticket(tid) + if not ticket or int(ticket["telegram_id"]) != user.telegram_id: + return web.json_response({"ok": False, "error": "not found"}, status=404) + ticket = db.set_ticket_status(tid, "closed") + messages = db.list_ticket_messages(tid) + return web.json_response( + { + "ok": True, + "ticket": ticket_to_dict( + {**(ticket or {}), "message_count": len(messages)} + ), + "messages": [message_to_dict(m) for m in messages], + } + ) + + +async def handle_clients(_: web.Request) -> web.Response: + return web.json_response( + { + "ok": True, + "clients": [ + { + "id": c.id, + "name": c.name, + "core": c.core, + "description": c.description, + "featured": c.featured, + "hwid": c.hwid, + "links": [{"title": l.title, "url": l.url} for l in c.links], + } + for c in TOP_CLIENTS + ], + "docs": "https://docs.rw/clients", + } + ) + + +async def handle_servers(request: web.Request) -> web.Response: + try: + nodes = await fetch_nodes_status(request.app["sdk"]) + except Exception as exc: # noqa: BLE001 + return web.json_response({"ok": False, "error": str(exc)}, status=500) + + return web.json_response( + { + "ok": True, + "online": sum(1 for n in nodes if n.online), + "total": len(nodes), + "nodes": [ + { + "name": n.name, + "country": n.country_code, + "online": n.online, + "label": n.label, + "ping_ms": n.tcp_ms, + "users_online": n.users_online, + } + for n in nodes + ], + } + ) + + +async def handle_wg_status(request: web.Request) -> web.Response: + db: Database = request.app["db"] + settings = load_wg_purchase_settings(db) + servers = list_available_servers(db) if settings.enabled else [] + used = 0 + try: + user = _require_user(request) + used = db.count_xui_wg_configs(user.telegram_id) + except web.HTTPUnauthorized: + pass + return web.json_response( + { + "ok": True, + **settings.as_dict(), + "used": used, + "remaining": max(0, settings.max_configs - used), + "servers": servers, + } + ) + + +async def handle_wg_list(request: web.Request) -> web.Response: + db: Database = request.app["db"] + sdk = request.app["sdk"] + try: + user = _require_user(request) + except web.HTTPUnauthorized: + return web.json_response({"ok": False, "error": "unauthorized"}, status=401) + gate = await enforce_wg_configs_subscription( + db=db, + sdk=sdk, + telegram_id=user.telegram_id, + login=user.login, + ) + configs = list_user_wg_configs(db, user.telegram_id) + settings = load_wg_purchase_settings(db) + return web.json_response( + { + "ok": True, + "configs": configs, + "used": len(configs), + "max_configs": settings.max_configs, + "enabled": settings.enabled, + "price": settings.price, + "price_label": settings.as_dict()["price_label"], + "servers": list_available_servers(db) if settings.enabled else [], + "subscription_active": gate["subscription_active"], + "subscription_reason": gate.get("reason") or "", + } + ) + + +async def handle_wg_buy(request: web.Request) -> web.Response: + db: Database = request.app["db"] + sdk = request.app["sdk"] + try: + user = _require_user(request) + except web.HTTPUnauthorized: + return web.json_response({"ok": False, "error": "unauthorized"}, status=401) + try: + body = await request.json() + except json.JSONDecodeError: + return web.json_response({"ok": False, "error": "invalid json"}, status=400) + try: + server_id = int(body.get("server_id") or 0) + except (TypeError, ValueError): + return web.json_response({"ok": False, "error": "bad server_id"}, status=400) + if server_id <= 0: + return web.json_response({"ok": False, "error": "Выберите сервер"}, status=400) + remark = str(body.get("remark") or "").strip()[:80] + result = await purchase_wg_config( + db=db, + sdk=sdk, + telegram_id=user.telegram_id, + server_id=server_id, + remark=remark, + login=user.login, + ) + status = 200 if result.ok else 400 + return web.json_response( + { + "ok": result.ok, + "error": None if result.ok else result.message, + "message": result.message, + "balance": result.balance, + "config": result.config, + "configs": list_user_wg_configs(db, user.telegram_id), + }, + status=status, + ) + + +async def handle_wg_enable(request: web.Request) -> web.Response: + return await _handle_wg_toggle(request, enable=True) + + +async def handle_wg_disable(request: web.Request) -> web.Response: + return await _handle_wg_toggle(request, enable=False) + + +async def _handle_wg_toggle(request: web.Request, *, enable: bool) -> web.Response: + db: Database = request.app["db"] + sdk = request.app["sdk"] + try: + user = _require_user(request) + except web.HTTPUnauthorized: + return web.json_response({"ok": False, "error": "unauthorized"}, status=401) + try: + config_id = int(request.match_info["config_id"]) + except (KeyError, ValueError): + return web.json_response({"ok": False, "error": "bad id"}, status=400) + result = await set_wg_config_enabled( + db=db, + telegram_id=user.telegram_id, + config_id=config_id, + enable=enable, + sdk=sdk, + login=user.login, + ) + status = 200 if result.ok else 400 + return web.json_response( + { + "ok": result.ok, + "error": None if result.ok else result.message, + "message": result.message, + "config": result.config, + "configs": list_user_wg_configs(db, user.telegram_id), + }, + status=status, + ) + + +async def handle_wg_delete(request: web.Request) -> web.Response: + db: Database = request.app["db"] + try: + user = _require_user(request) + except web.HTTPUnauthorized: + return web.json_response({"ok": False, "error": "unauthorized"}, status=401) + try: + config_id = int(request.match_info["config_id"]) + except (KeyError, ValueError): + return web.json_response({"ok": False, "error": "bad id"}, status=400) + result = await delete_wg_config( + db=db, + telegram_id=user.telegram_id, + config_id=config_id, + ) + status = 200 if result.ok else 400 + return web.json_response( + { + "ok": result.ok, + "error": None if result.ok else result.message, + "message": result.message, + "configs": list_user_wg_configs(db, user.telegram_id), + }, + status=status, + ) + + +async def handle_wg_qr(request: web.Request) -> web.Response: + db: Database = request.app["db"] + try: + user = _require_user(request) + except web.HTTPUnauthorized: + return web.json_response({"ok": False, "error": "unauthorized"}, status=401) + try: + config_id = int(request.match_info["config_id"]) + except (KeyError, ValueError): + return web.json_response({"ok": False, "error": "bad id"}, status=400) + row = db.get_xui_wg_config(config_id, telegram_id=user.telegram_id) + if not row: + return web.json_response({"ok": False, "error": "not found"}, status=404) + url = str(row.get("subscription_url") or "").strip() + if not url: + return web.json_response({"ok": False, "error": "no subscription url"}, status=400) + try: + svg = make_qr_svg(url, scale=5, border=2) + except Exception as exc: # noqa: BLE001 + logger.exception("QR generate failed") + return web.json_response({"ok": False, "error": str(exc)}, status=500) + return web.Response( + body=svg, + content_type="image/svg+xml", + headers={ + "Cache-Control": "private, max-age=300", + "Content-Disposition": f'inline; filename="wg-{config_id}.svg"', + }, + ) + + +async def handle_awg_status(request: web.Request) -> web.Response: + db: Database = request.app["db"] + settings = load_awg_purchase_settings(db) + servers = list_available_awg_servers(db) if settings.enabled else [] + used = 0 + try: + user = _require_user(request) + used = db.count_awg_configs(user.telegram_id) + except web.HTTPUnauthorized: + pass + return web.json_response( + { + "ok": True, + **settings.as_dict(), + "used": used, + "remaining": max(0, settings.max_configs - used), + "servers": servers, + } + ) + + +async def handle_awg_list(request: web.Request) -> web.Response: + db: Database = request.app["db"] + sdk = request.app["sdk"] + try: + user = _require_user(request) + except web.HTTPUnauthorized: + return web.json_response({"ok": False, "error": "unauthorized"}, status=401) + gate = await enforce_awg_configs_subscription( + db=db, + sdk=sdk, + telegram_id=user.telegram_id, + login=user.login, + ) + configs = list_user_awg_configs(db, user.telegram_id) + settings = load_awg_purchase_settings(db) + return web.json_response( + { + "ok": True, + "configs": configs, + "used": len(configs), + "max_configs": settings.max_configs, + "enabled": settings.enabled, + "price": settings.price, + "price_label": settings.as_dict()["price_label"], + "servers": list_available_awg_servers(db) if settings.enabled else [], + "subscription_active": gate["subscription_active"], + "subscription_reason": gate.get("reason") or "", + } + ) + + +async def handle_awg_buy(request: web.Request) -> web.Response: + db: Database = request.app["db"] + sdk = request.app["sdk"] + try: + user = _require_user(request) + except web.HTTPUnauthorized: + return web.json_response({"ok": False, "error": "unauthorized"}, status=401) + try: + body = await request.json() + except json.JSONDecodeError: + return web.json_response({"ok": False, "error": "invalid json"}, status=400) + try: + server_id = int(body.get("server_id") or 0) + except (TypeError, ValueError): + return web.json_response({"ok": False, "error": "bad server_id"}, status=400) + if server_id <= 0: + return web.json_response({"ok": False, "error": "Выберите сервер"}, status=400) + remark = str(body.get("remark") or "").strip()[:80] + result = await purchase_awg_config( + db=db, + sdk=sdk, + telegram_id=user.telegram_id, + server_id=server_id, + remark=remark, + login=user.login, + ) + status = 200 if result.ok else 400 + return web.json_response( + { + "ok": result.ok, + "error": None if result.ok else result.message, + "message": result.message, + "balance": result.balance, + "config": result.config, + "configs": list_user_awg_configs(db, user.telegram_id), + }, + status=status, + ) + + +async def handle_awg_enable(request: web.Request) -> web.Response: + return await _handle_awg_toggle(request, enable=True) + + +async def handle_awg_disable(request: web.Request) -> web.Response: + return await _handle_awg_toggle(request, enable=False) + + +async def _handle_awg_toggle(request: web.Request, *, enable: bool) -> web.Response: + db: Database = request.app["db"] + sdk = request.app["sdk"] + try: + user = _require_user(request) + except web.HTTPUnauthorized: + return web.json_response({"ok": False, "error": "unauthorized"}, status=401) + try: + config_id = int(request.match_info["config_id"]) + except (KeyError, ValueError): + return web.json_response({"ok": False, "error": "bad id"}, status=400) + result = await set_awg_config_enabled( + db=db, + telegram_id=user.telegram_id, + config_id=config_id, + enabled=enable, + sdk=sdk, + login=user.login, + ) + status = 200 if result.ok else 400 + return web.json_response( + { + "ok": result.ok, + "error": None if result.ok else result.message, + "message": result.message, + "config": result.config, + "configs": list_user_awg_configs(db, user.telegram_id), + }, + status=status, + ) + + +async def handle_awg_delete(request: web.Request) -> web.Response: + db: Database = request.app["db"] + try: + user = _require_user(request) + except web.HTTPUnauthorized: + return web.json_response({"ok": False, "error": "unauthorized"}, status=401) + try: + config_id = int(request.match_info["config_id"]) + except (KeyError, ValueError): + return web.json_response({"ok": False, "error": "bad id"}, status=400) + result = await delete_awg_config( + db=db, + telegram_id=user.telegram_id, + config_id=config_id, + ) + status = 200 if result.ok else 400 + return web.json_response( + { + "ok": result.ok, + "error": None if result.ok else result.message, + "message": result.message, + "configs": list_user_awg_configs(db, user.telegram_id), + }, + status=status, + ) + + +async def handle_awg_qr(request: web.Request) -> web.Response: + db: Database = request.app["db"] + try: + user = _require_user(request) + except web.HTTPUnauthorized: + return web.json_response({"ok": False, "error": "unauthorized"}, status=401) + try: + config_id = int(request.match_info["config_id"]) + except (KeyError, ValueError): + return web.json_response({"ok": False, "error": "bad id"}, status=400) + row = db.get_awg_config(config_id, telegram_id=user.telegram_id) + if not row: + return web.json_response({"ok": False, "error": "not found"}, status=404) + payload = str(row.get("vpn_link") or row.get("config_text") or "").strip() + if not payload: + return web.json_response({"ok": False, "error": "no config"}, status=400) + try: + svg = make_qr_svg(payload, scale=5, border=2) + except Exception as exc: # noqa: BLE001 + logger.exception("AWG QR generate failed") + return web.json_response({"ok": False, "error": str(exc)}, status=500) + return web.Response( + body=svg, + content_type="image/svg+xml", + headers={ + "Cache-Control": "private, max-age=300", + "Content-Disposition": f'inline; filename="awg-{config_id}.svg"', + }, + ) + + +async def start_webapp_site(app: web.Application, host: str, port: int) -> web.AppRunner: + runner = web.AppRunner(app) + await runner.setup() + site = web.TCPSite(runner, host=host, port=port) + await site.start() + logger.info("Mini App HTTP on http://%s:%s", host, port) + return runner