commit aaf04faa961206e791bf6518ae0377f17669cf75 Author: VPN Service Date: Sun Jul 26 23:21:41 2026 +0300 Initial commit: VPN Telegram bot (sanitized defaults) 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